diff --git a/.plans/task-tabs-context-fork.html b/.plans/task-tabs-context-fork.html new file mode 100644 index 00000000000..4d04e808279 --- /dev/null +++ b/.plans/task-tabs-context-fork.html @@ -0,0 +1,2390 @@ + + + + + + + Workspace Tasks + Agent Tabs — Implementation Plan + + + +
+
+
T3 Code · Sidebar v2 experiment · implementation plan
+

Workspace tasks with agent tabs

+

+ Turn one sidebar “thread” into a durable task that owns a worktree, then let multiple + provider-backed conversation tabs branch from the task’s context while operating on the + same files. +

+
+ Recommended: additive WorkspaceTask aggregate + Keep Thread as the provider conversation primitive + Web · Sidebar v2 only + Experimental shared-worktree concurrency +
+
+ +
+ + +
+
+

The recommendation

+

+ Do not rename the existing backend concept of a thread. It is already the identity + used by provider sessions, turns, messages, approvals, checkpoints, terminals, + routes, caches, and runtime events. Treat that existing object as an + agent tab, then add one product-level container above it. +

+ +
+
+ New product primitive +

WorkspaceTask

+

+ Owns the goal/title, project, environment, branch, worktree, sidebar lifecycle, + aggregate status, and the set of tabs. +

+
+
+ Existing primitive, new presentation +

Thread → agent tab

+

+ Continues to own provider instance, model, harness resume state, transcript, + turns, approvals, interaction mode, and permissions. +

+
+
+ +
+
+ ⌘N / Ctrl+N +

+ Creates a new task draft with its first agent tab. This feels exactly like “new + thread” today until the user adds another tab. +

+
+
+ New tab +

+ Desktop/Electron uses ⌘T. Browsers reserve that accelerator, so web + uses Mod+Alt+T plus a visible + button and “New tab in + task” command-palette action. +

+
+
+ Context choice +

+ The new-tab sheet defaults to “Fork through the latest completed turn,” with + “Start fresh in the same worktree” as the explicit alternative. +

+
+
+ Provider choice +

+ Every tab picks its own provider instance, model, model options, permission mode, + and interaction mode. The source tab’s selections are convenient defaults, not + task-wide constraints. +

+
+
+ Route strategy +

+ Keep /$environmentId/$threadId as the canonical active-tab route in + the first experiment. Resolve the task from the thread shell. This preserves + every existing deep link and avoids a route migration. +

+
+
+ Rollout boundary +

+ Render task rows and tab chrome only when + sidebarV2Enabled && taskTabsEnabled. Advertise server support + with a new workspaceTasks environment capability. +

+
+
+ +
+ Why this seam is unusually valuable. + Provider adapters and orchestration can keep speaking in threads. The UI gains a + richer task abstraction without forcing a high-risk rename through hundreds of + thread-scoped call sites. A kill switch can always fall back to rendering each tab as + an ordinary thread, so the experiment is reversible without discarding data. +
+ +

What the repository already tells us

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Current responsibilityEvidence in the current codeImplication
Sidebar v2 is thread-first + apps/web/src/components/SidebarV2.tsx directly sorts, renders, + settles, snoozes, renames, and navigates thread shells. + Replace its input view-model with task summaries; do not rewrite rows blind.
Draft promotion is first-turn driven + useHandleNewThread.ts, ChatView.tsx, and + apps/server/src/ws.ts create the thread/worktree during the first + turn bootstrap. + A task draft and a tab draft can reuse the same promotion pattern.
Provider state is thread-scoped + ProviderService, adapters, resume cursors, and runtime bindings + all key sessions by ThreadId. + The existing Thread is exactly the correct agent-tab primitive.
Codex supports exact forks + The generated app-server contract already includes thread/fork + with threadId and optional lastTurnId. + Same-continuation Codex tabs can get a real provider-native fork.
Worktree deletion detects sharing + worktreeCleanup.ts avoids deleting a path while another thread + references it. + Existing safety helps, but task-level deletion should become canonical.
Checkpoints assume one writer + CheckpointReactor.ts captures diffs per thread against a shared + filesystem state. + Parallel tabs require explicit overlap detection before revert can be safe.
+
+
+ +
+

What it looks and feels like

+

+ The sidebar remains the task inbox. Tabs live in the task workspace header because + they are alternate agents working on the same thing—not peer items that should crowd + the global task list. +

+ +
+
+ + +
+
+
+ C + Codex plan + +
+
+ A + Claude impl + +
+
+ C + Review +
+
+
+
+
+ Task tabs + context forks + · + t3code/task-tabs-context-fork + · + shared worktree + 2 agents active +
+
+
+
+ Forked context from “Codex plan” through turn 7 · Portable + handoff · View inherited context +
+
+
Theo
+

Take the approved approach and implement the contracts + migration slice.

+
+
+
Claude · working
+

+ I’m adding the task projection and keeping ThreadId as the provider session + key. I’ll leave the Sidebar v1 path untouched. +

+
+
+
+ Message Claude in this tab… + Claude Sonnet · Full access ↑ +
+
+
+
+
+ One sidebar card per task; provider tabs, fork provenance, and shared-worktree + awareness sit inside the active task. +
+
+ +

New-tab sheet

+
+
+ +
+
Start fresh
+

+ Share the task’s worktree and branch but bring no conversation history into the + new agent. +

+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + +
Agent / harnessProvider-instance picker (Codex, Claude, Cursor, OpenCode, custom instances)
ModelTarget instance’s default, with source selection prefilled when compatible
Fork pointLatest completed turn; while a turn runs, the sheet states that the live turn is excluded
PermissionsCopy the source tab’s runtime mode by default; editable before creation
Tab labelOptional; defaults to provider display name with a numeric suffix when needed
+
+
+ +

Interaction contract

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ActionSidebar v2 behaviorKeyboard
New taskSame draft flow as “new thread” today; copy active working mode, use configured workspace defaultsMod+N (retain chat.new as a compatibility alias)
New tab in taskOpen the fork/fresh sheet in the active task; disabled when no task is activeCmd+T desktop; Mod+Alt+T browser
Switch tabNavigate to the selected thread-backed route; composer and transcript are tab-localClick first; configurable taskTab.previous/next commands
Jump taskExisting sidebar jump slots become task slots and open that task’s last-visited visible tabKeep Mod+1…9
Close tabHide one conversation from the strip; block while it is running or awaiting input; never remove the worktreeCommand palette initially; no browser-conflicting default required
Settle / snooze / archive / deleteOperate on the whole task. Individual tabs are closed/restored, not settled or snoozed.Existing task-row actions
+
+ +
+ Browser accelerator reality. A web page cannot reliably own + Cmd+T or Ctrl+T; the browser opens a browser tab before the + React key handler can act. Electron can register the native accelerator. The visible + plus button and command palette are therefore first-class, not fallbacks. +
+
+ +
+

Domain model and ownership

+

+ Internally call the new entity WorkspaceTask, not just + Task. The contracts already have RuntimeTaskId for provider + tool/sub-agent progress events, so the longer name prevents a subtle and permanent + terminology collision. +

+ +
+ + + + + + + + + PROJECT + Project + workspaceRoot + repositoryIdentity + + + NEW · PRODUCT CONTAINER + WorkspaceTask + + title / goal + projectId + environment + branch + worktreePath + settle / snooze / archive lifecycle + aggregate activity + status + createdAt / updatedAt + Internal id: WorkspaceTaskId + Product label: Task + + + EXISTING · AGENT TAB + Thread + + provider instance + model/options + transcript / turns / approvals + runtime + interaction modes + fork provenance + tab label + + + PROVIDER RUNTIME + Provider session + resume cursor / provider thread id + process + live turn state + + + 1:N + + 1:N + + 1:1 active + +
+ Provider events can continue to use ThreadId. The WorkspaceTask owns everything + that must be shared across tabs. +
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
StateOwnerWhy
Project, environment, branch, worktree pathWorkspaceTaskEvery tab must resolve exactly the same effective cwd.
Task title, sidebar position, settle/snooze/archive stateWorkspaceTaskThe sidebar row represents the whole unit of work.
Provider instance, model, options, permission modeThread/tabChoosing a different harness is a core reason tabs exist.
Messages, turns, pending approval/input, provider sessionThread/tabProvider conversations remain isolated and independently interruptible.
Fork source, fork point, strategy, context bundleThread/tabProvenance describes how this particular conversation began.
Composer draftThread/tab (client local until promotion)Each agent can hold a distinct unsent prompt.
Terminals, preview, file panel presentationThread/tab for the experimentThey already key by ThreadId and still launch in the task’s effective cwd; task-scoping can be a later UX choice.
Git/source-control statusWorkspaceTask-derivedIt reflects one shared worktree even when surfaced inside multiple tabs.
+
+ +
+ Compatibility denormalization. During the experiment, keep + branch, worktreePath, and a compatibility + title on each thread shell. The server derives them from the task and + enforces equality. New code reads the task as canonical; older clients continue to + function from thread fields. Remove the duplicate fields only in a later protocol + major version. +
+ +

Proposed contract shapes

+
WorkspaceTask {
+  id: WorkspaceTaskId
+  projectId: ProjectId
+  title: string
+  branch: string | null
+  worktreePath: string | null
+  createdAt: IsoDateTime
+  updatedAt: IsoDateTime
+  archivedAt: IsoDateTime | null
+  settledOverride: "settled" | "active" | null
+  settledAt: IsoDateTime | null
+  snoozedUntil: IsoDateTime | null
+  snoozedAt: IsoDateTime | null
+}
+
+Thread {
+  // existing fields remain
+  workspaceTaskId: WorkspaceTaskId
+  tabLabel: string | null
+  tabClosedAt: IsoDateTime | null
+  fork: {
+    sourceThreadId: ThreadId
+    sourceTurnId: TurnId | null
+    strategy: "native" | "portable"
+    contextBundleId: ContextBundleId | null
+  } | null
+}
+
+ +
+

Context forking: exact when possible, honest when not

+

+ “Fork” cannot mean the same implementation for every harness. The product should + expose one consistent action while recording which fidelity it delivered. +

+ +
+
+ Strategy A · highest fidelity +

Native provider fork

+

+ Use when source and target share a provider driver and continuation identity, and + that adapter advertises native fork support. +

+
    +
  • Codex calls app-server thread/fork.
  • +
  • Pass the source provider thread id and provider-native last completed turn id.
  • +
  • Bind the returned provider thread id as the target tab’s resume cursor.
  • +
  • Never reuse the source resume cursor for two live T3 threads.
  • +
+
+
+ Strategy B · cross-provider +

Portable context handoff

+

+ Use when moving between harnesses, accounts, provider homes, or adapters without a + native fork primitive. +

+
    +
  • Freeze a bounded, provider-neutral ContextBundle at the chosen turn.
  • +
  • Inject it once when the target tab starts its first provider session.
  • +
  • Show a “Portable handoff” provenance card in the timeline.
  • +
  • Do not silently label this as an exact clone.
  • +
+
+
+ +
+ + + + + + + + + NEW TAB DRAFT + Capture source + fork point + + + Native fork eligible? + same driver + continuationKey + adapter supports fork + provider turn id is known + + + NATIVE + Adapter.forkSession(...) + Codex → thread/fork + persist target resume cursor + strategy = "native" + + + PORTABLE + ContextForkService.buildBundle(...) + transcript + plan + task/worktree facts + bounded + stored as immutable blob + inject once at first session start + strategy = "portable" + + + + yes + + no + + A running source turn is never forked mid-flight. + The captured fork point is the latest completed turn at the moment the draft tab is created. + +
+ One UX action, two persisted strategies. Failures remain visible and retryable; they + never silently change fidelity. +
+
+ +

Portable ContextBundle contents

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
IncludeExclude or summarizeConstraint
Task title, project, branch, effective cwd, fork source and pointAbsolute machine metadata that the target does not needStructured envelope, versioned schema
User + assistant conversation through the fork pointRaw provider-native event payloadsDeterministic truncation from oldest low-value content first
Latest proposed plan and unresolved user decisionsResolved approvals and transient permission promptsMark source message ids for provenance
Changed-file list, HEAD/branch, dirty-worktree summaryFull diffs by defaultThe agent can inspect the shared worktree directly
Attachment names/types and durable referencesInline duplicate binaries unless the target explicitly supports themNever smuggle local secrets or raw tool logs
+
+ +
+ Do not summarize with an LLM in the first slice. Begin with a + deterministic, bounded transcript/context envelope so the fork is predictable, + testable, and does not make an invisible model call. A separately labeled + “condensed handoff” optimization can come later if real usage proves the raw bundle + too expensive. +
+ +

Provider contract additions

+
ProviderAdapterCapabilities {
+  sessionModelSwitch: "in-session" | "unsupported"
+  nativeConversationFork: "supported" | "unsupported"
+}
+
+ProviderAdapterShape {
+  forkSession?(input: {
+    sourceThreadId: ThreadId
+    targetThreadId: ThreadId
+    sourceResumeCursor: unknown
+    sourceProviderTurnId?: string
+    cwd: string
+    modelSelection: ModelSelection
+    runtimeMode: RuntimeMode
+  }): Effect<ProviderSession>
+}
+
+// Exact fork only when source/target continuation identities match.
+sourceInstance.continuationIdentity.continuationKey
+  === targetInstance.continuationIdentity.continuationKey
+
+ +
+

Shared worktree concurrency is the sharp edge

+

+ Multiple agents can safely read the same worktree. Multiple agents can also write it, + but T3 cannot truthfully attribute overlapping filesystem changes to one tab using + ordinary git diffs. The plan must make that limitation visible and prevent unsafe + reverts. +

+ +
+
+ Failure mode +

Cross-contaminated checkpoints

+

+ Tab A’s completion diff may include Tab B’s edits if their turns overlap in the + same checkout. +

+
+
+ Failure mode +

Destructive revert

+

+ Reverting A’s checkpoint could erase B’s legitimate work because the existing + checkpoint model assumes one writer. +

+
+
+ UX risk +

Invisible collisions

+

+ Two agents can edit the same file while both timelines look independently + healthy. +

+
+
+ +
+ Recommended experiment policy +

Allow parallel turns, but degrade attribution explicitly

+
    +
  1. + Add a task-scoped workspace activity coordinator that records + active tab turns and a git/worktree fingerprint at every turn boundary. +
  2. +
  3. + When more than one writing-capable turn overlaps, mark all involved checkpoints + isolation: "overlapping". +
  4. +
  5. + Continue showing task-wide changed files and diff, but label them + shared-worktree changes, not “changes from this tab.” +
  6. +
  7. + Disable checkpoint revert for overlapping turns and explain why. Never expose an + action that may erase a sibling tab’s work. +
  8. +
  9. + Show a persistent header badge such as “2 agents active in shared worktree” and + surface which tabs are running. +
  10. +
  11. + Keep an optional task setting for single-writer mode if dogfood + usage shows parallel writes are too chaotic; it can queue or block a second turn. +
  12. +
+
+ +
+ Hard invariant for launch: no overlapping checkpoint may be reverted. + Perfect per-agent diff attribution would require isolated worktrees/overlays and a + merge layer, which contradicts the experiment’s defining idea that tabs operate on + the same live worktree. +
+ +

Workspace fingerprint

+

+ Capture enough state to detect interference without trying to assign authorship: + HEAD, branch, staged/unstaged status entries, untracked-path set, and a + stable hash of the status snapshot. Store the fingerprint and sibling active-turn ids + with the checkpoint projection. +

+
+ +
+

Task-level status and lifecycle

+

+ A task row needs one understandable status even when several tabs are doing different + things. Preserve Sidebar v2’s “inbox” philosophy and aggregate by urgency. +

+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PriorityTask statusAggregation ruleRow treatment
1ApprovalAny visible tab has a pending approval“Approval · Codex” plus count when more than one
2InputAny visible tab awaits structured user input“Input · Claude”
3FailedAny tab failed and no tab has a more urgent user request“Failed · Review”
4WorkingOne or more tabs are starting/running“2 working · 3 tabs” and provider avatar stack
5DoneAt least one tab completed after its last local visitUnread completion treatment
6ReadyNo higher-priority stateQuiet row using current v2 semantics
+
+ +
+
+ Task actions +

Settle, snooze, archive, delete

+
    +
  • Block settle while any tab is running or needs user input/approval.
  • +
  • Snoozing suppresses the whole task; activity in any tab wakes it.
  • +
  • Archiving stops all live provider sessions and task-linked terminals.
  • +
  • Deleting offers worktree cleanup once, after all tabs are handled.
  • +
+
+
+ Tab actions +

Rename, close, restore

+
    +
  • Rename changes only the tab label.
  • +
  • Close is blocked for running/pending tabs and preserves history.
  • +
  • The last visible tab cannot be closed; archive/delete the task instead.
  • +
  • Closed tabs remain available from a task menu for restoration.
  • +
+
+
+ +
+ Last-opened tab is client preference. Store + lastVisitedThreadIdByTask in the UI state store. Clicking a task opens + that visible tab when it still exists; otherwise choose the tab with the most recent + actionable activity, then the oldest root tab. Avoid persisting “active tab” on the + server, which would create noisy cross-device races. +
+
+ +
+

Architecture changes

+

+ This is an additive vertical slice through contracts, event sourcing, provider + adapters, shared client state, and Sidebar v2. The provider runtime remains + thread-scoped end to end. +

+ +

Contracts and shell protocol

+
+
    +
  • + Add WorkspaceTaskId and ContextBundleId in + packages/contracts/src/baseSchemas.ts. +
  • +
  • + Extend OrchestrationReadModel and shell snapshots with a + tasks array. Keep tasks and threads separate and join by + workspaceTaskId; avoid nesting copies of thread shells inside every + task. +
  • +
  • + Add shell events task-upserted and task-removed. Existing + thread shell events continue unchanged except for additive fields. +
  • +
  • + Add task commands/events for create, metadata, settle/unsettle, snooze/unsnooze, + archive/unarchive, and delete. +
  • +
  • + Add thread tab commands/events for label, close/restore, and fork metadata. +
  • +
  • + Advertise environment.capabilities.workspaceTasks and add provider + native-fork capability at the provider adapter/instance layer. +
  • +
+
+ +

Persistence and event sourcing

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
StorageChange
orchestration_eventsAdd aggregate kind workspace-task and task event types.
projection_workspace_tasksNew canonical task projection with project/workspace/lifecycle fields.
projection_threadsAdd task id, tab label, closed timestamp, fork source/point/strategy, and context bundle reference.
projection_context_bundlesImmutable bounded JSON/blob payload for portable handoffs; events store the reference, not the potentially large body.
projection_turnsPersist provider-native turn id plus overlap/isolation metadata required by exact fork and safe revert.
checkpoint_diff_blobsAdd task id / workspace fingerprint linkage or companion metadata table.
+
+ +

First-turn bootstrap

+

+ Keep drafts local until the first message, including a newly added unsent tab. + Extend the existing thread.turn.start bootstrap rather than inventing a + second transport path. +

+
bootstrap: {
+  createWorkspaceTask?: {
+    taskId, projectId, title, branch, worktreePath, createdAt
+  }
+  createThread?: {
+    // existing fields
+    workspaceTaskId, tabLabel, fork
+  }
+  prepareWorktree?: { ...existing }
+  runSetupScript?: boolean
+}
+ +
+ Bootstrap cleanup must become task-aware. If worktree preparation, + tab fork setup, or the first provider turn fails, tombstone the newly created thread + and task and remove any worktree created by that bootstrap. Existing cleanup currently + centers on the thread; this slice must not leave an orphan task or checkout. +
+ +

Client state and UI

+
+
+ Shared client runtime +
    +
  • Add environment-scoped task models and refs.
  • +
  • Teach shell reducer/cache to accept task events.
  • +
  • Build pure selectors that join task + tab shells and aggregate status.
  • +
  • Add task commands and capability reads.
  • +
  • Decode old cached snapshots with missing tasks.
  • +
+
+
+ Web · Sidebar v2 +
    +
  • Feed SidebarV2 task summaries rather than raw threads.
  • +
  • Add TaskTabBar and NewTaskTabDialog above ChatView.
  • +
  • Extend the composer draft store with task id + captured fork spec.
  • +
  • Keep existing active thread routes and detail subscriptions.
  • +
  • Leave Sidebar v1 and mobile task UI out of scope.
  • +
+
+
+ +

Primary file touchpoints

+
+
+ packages/contracts/src/baseSchemas.ts + packages/contracts/src/orchestration.ts + packages/contracts/src/environment.ts + packages/contracts/src/keybindings.ts + apps/server/src/orchestration/decider.ts + apps/server/src/orchestration/projector.ts + apps/server/src/orchestration/Layers/ProjectionPipeline.ts + apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts + apps/server/src/orchestration/Layers/ProviderCommandReactor.ts + apps/server/src/orchestration/Layers/CheckpointReactor.ts + apps/server/src/provider/Services/ProviderAdapter.ts + apps/server/src/provider/Layers/CodexAdapter.ts + apps/server/src/provider/Layers/CodexSessionRuntime.ts + apps/server/src/ws.ts + packages/client-runtime/src/state/models.ts + packages/client-runtime/src/state/shellReducer.ts + packages/client-runtime/src/operations/commands.ts + apps/web/src/components/SidebarV2.tsx + apps/web/src/components/ChatView.tsx + apps/web/src/hooks/useHandleNewThread.ts + apps/web/src/composerDraftStore.ts + apps/web/src/routes/_chat.tsx + apps/web/src/keybindings.ts + apps/web/src/components/CommandPalette.tsx +
+
+ +
+ Suggested new modules. + packages/client-runtime/src/state/tasks.ts, + apps/web/src/components/tasks/TaskTabBar.tsx, + apps/web/src/components/tasks/NewTaskTabDialog.tsx, + apps/web/src/tasks/taskPresentation.ts, + apps/server/src/orchestration/Layers/TaskLifecycleReactor.ts, and + apps/server/src/provider/ContextForkService.ts. Keep pure aggregation + logic out of the already-large Sidebar and ChatView components. +
+
+ +
+

Migration and version skew

+

+ Every existing thread becomes a one-tab task. Use + workspaceTaskId = threadId for the backfill so identifiers are stable, + deterministic, and easy to reason about. +

+ +
+ Migration 035 · proposed +
    +
  1. Create projection_workspace_tasks and context-bundle storage.
  2. +
  3. Add task/tab/fork columns and indexes to thread and turn projections.
  4. +
  5. + For every existing thread, set workspace_task_id = thread_id and copy + title/project/branch/worktree/lifecycle fields into a one-tab task. +
  6. +
  7. + Append deterministic, idempotent workspace-task.created migration + events plus lifecycle events needed to reproduce archive/settle/snooze/delete + state during a complete replay. +
  8. +
  9. + Use deterministic event ids and stream versions; migration tests must prove a + second run inserts nothing. +
  10. +
  11. + Backfill provider_turn_id from orchestration event metadata where it + exists. Legacy turns without it simply cannot be a precise historical native-fork + point and fall back to a portable handoff. +
  12. +
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
Client/server pairingBehavior
New client + old server + Synthesize one task per thread client-side for Sidebar v2 presentation. + Hide/disable “New tab” and explain that the environment must be updated. +
Old client + new server + Extra fields are ignored. Tabs render as separate legacy threads with + compatibility titles; thread commands remain valid and cannot delete a + worktree still referenced by sibling tabs. +
New client + new server, flag off + Preserve current thread-per-row UI. This is the operational kill switch and + must reveal all created tabs rather than hiding data. +
New client + new server, flag onFull task rows, tab strip, fresh tabs, context forks, and task lifecycle.
+
+ +
+ Reversibility target. Disabling the experiment changes only + presentation and available creation actions. It does not need a down-migration: + every tab remains a valid thread, provider resume state remains keyed by ThreadId, and + the worktree remains referenced by all sibling thread shells. +
+
+ +
+

Delivery plan

+

+ Ship behind a beta flag in reviewable vertical slices. Each phase has a useful exit + condition and keeps current thread behavior working. +

+ +
+
+
+
+

Pure model and UX specification

+

+ Add pure task aggregation selectors, naming, status priority, last-tab fallback, + and fork-strategy decision logic with tests. Add the beta setting and keybinding + command ids, but keep the feature unavailable. +

+
Exit: core semantics are testable without server or React component churn.
+
+
+ +
+
+
+

Contracts, projections, migration, and capability

+

+ Add WorkspaceTask schemas/events/commands, shell protocol fields, migration 035, + task repositories, projection pipeline support, client shell state, and + workspaceTasks capability. Backfill every thread as a one-tab task. +

+
+ Exit: a new client can read real task shells and an old environment still decodes + as synthetic one-tab tasks. +
+
+
+ +
+
+
+

Sidebar v2 grouping and task lifecycle

+

+ Switch Sidebar v2 to task summaries. Move rename, settle, snooze, archive, + delete, selection, jump hints, status, and worktree cleanup to task actions. + Keep the flag off by default. +

+
+ Exit: existing users see one task row per migrated thread with no visual or + lifecycle regression when each task has one tab. +
+
+
+ +
+
+
+

Tab chrome and fresh tabs

+

+ Add TaskTabBar, NewTaskTabDialog, draft-tab state, task-aware promotion, route + fallback, tab labels, close/restore, command palette entries, and desktop/web + shortcut split. First ship “Start fresh” in the same worktree. +

+
+ Exit: a task can run two independent providers against one worktree without + context inheritance. +
+
+
+ +
+
+
+

Portable cross-provider context handoff

+

+ Add immutable ContextBundle storage/building, captured fork points, one-time + target-session injection, provenance cards, size limits, and deterministic + truncation. This unlocks different harnesses first. +

+
+ Exit: Codex → Claude (and any other provider pair) starts with a visible, + reproducible portable context bundle. +
+
+
+ +
+
+
+

Exact Codex native fork

+

+ Extend ProviderAdapter capabilities and fork method, persist provider turn ids, + wire CodexSessionRuntime to thread/fork, enforce continuation-key + compatibility, bind the new resume cursor, and add exact-fork retry/fallback UI. +

+
+ Exit: Codex → compatible Codex creates a genuine provider-native branch through + the selected completed turn. +
+
+
+ +
+
+
+

Shared-worktree safety and dogfood rollout

+

+ Add workspace activity coordination, overlap metadata, unsafe-revert blocking, + shared-worktree status UI, metrics, and the hidden Beta toggle. Dogfood with + focused scenarios before enabling the experiment for broader v2 users. +

+
+ Exit: parallel tabs are observable, overlapping changes cannot be destructively + reverted, and the feature can be disabled without losing access to any thread. +
+
+
+
+ +
+ Why fresh tabs precede forks. They prove the task/worktree/tab model, + provider isolation, lifecycle, draft promotion, and route behavior without mixing in + context-fidelity bugs. Portable handoff then unlocks the headline cross-harness use + case; native Codex fork becomes an optimization with stronger fidelity. +
+
+ +
+

Verification strategy

+

+ Backend behavior requires focused tests in every changed layer. The final web slices + require one integrated Sidebar v2 pass in an isolated T3 environment, as required by + the repository workflow. +

+ +
+
+ Contracts + migration +
    +
  • Decode old snapshots/events.
  • +
  • Backfill one task per thread.
  • +
  • Preserve lifecycle state.
  • +
  • Prove migration idempotence.
  • +
  • Replay reconstructs the same projections.
  • +
+
+
+ Orchestration +
    +
  • Task/thread membership invariants.
  • +
  • All tabs resolve the task cwd.
  • +
  • Task settle/snooze guards.
  • +
  • Activity wakes the whole task.
  • +
  • Cascade stop/archive/delete behavior.
  • +
+
+
+ Provider layer +
    +
  • Native eligibility matrix.
  • +
  • Codex request parameters.
  • +
  • New cursor bound to target only.
  • +
  • Portable injection happens once.
  • +
  • Failure is retryable and visible.
  • +
+
+
+ Client + React +
    +
  • Status priority and counts across mixed tab states.
  • +
  • Task sorting stays stable under tab activity.
  • +
  • Task click chooses last visited/fallback tab.
  • +
  • Draft tab captures a fixed fork point and promotes once.
  • +
  • Keyboard commands respect terminal/preview focus and browser differences.
  • +
  • Flag-off mode exposes every underlying thread.
  • +
+
+
+ Checkpoint safety +
    +
  • One active tab keeps current checkpoint/revert behavior.
  • +
  • Two overlapping turns mark both checkpoints overlapping.
  • +
  • Overlapping revert is rejected server-side, not only hidden in UI.
  • +
  • Task-wide diff remains viewable and clearly labeled.
  • +
  • Deleting one tab never offers shared worktree deletion.
  • +
+
+
+ +

Integrated dogfood script

+
+
    +
  1. Enable Sidebar v2 + Task Tabs in an isolated environment.
  2. +
  3. Create a new task with Mod+N in a dedicated worktree.
  4. +
  5. Send a planning turn in the root Codex tab and wait for completion.
  6. +
  7. Create a fresh Claude tab; confirm same branch/cwd and independent transcript.
  8. +
  9. Create a portable fork from Codex to Claude; inspect the provenance card and handoff contents.
  10. +
  11. Create an exact Codex fork; confirm the native branch begins at the captured completed turn.
  12. +
  13. Run two tabs concurrently; confirm the task row shows both and overlapping revert is disabled/rejected.
  14. +
  15. Complete one background tab; confirm task “Done” and last-visited routing.
  16. +
  17. Settle, un-settle, snooze, wake, archive, and restore the whole task.
  18. +
  19. Disable the beta flag; confirm all tab threads remain reachable in legacy thread-per-row presentation.
  20. +
  21. Delete the task; confirm one worktree-removal prompt and no provider/terminal residue.
  22. +
+
+ +

+ Run focused vp test run <affected-test-files>, targeted package + typechecks/formatting, and the test-t3-app integrated web pass. Do not + substitute the repo-wide suite for focused local verification. +

+
+ +
+

Risks and decisions to lock before implementation

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
QuestionRecommendationReason
Are task tabs allowed to write concurrently?Yes for the experiment, with overlap labeling and server-enforced revert blocking.This tests the bold idea without pretending attribution is safe.
Should “fork” always imply exact history?No. Persist and display native vs portable.Cross-provider exact cloning is not generally available.
Should a new tab immediately persist?No. Keep it as a local draft until first send, matching current thread drafts.Closing an unused tab leaves no server/provider debris.
Should routes be task-shaped now?No. Preserve active thread routes for the first version.Deep-link stability and smaller blast radius outweigh URL purity.
Should terminals/previews be shared across tabs?Not initially. Keep presentation tab-local while resolving all launches to task cwd.Avoids forcing a ChatView/right-panel/terminal state migration into the core experiment.
Should mobile or Sidebar v1 group tasks?No for this experiment. They retain thread-per-row compatibility presentation.The user-visible scope is explicitly Sidebar v2.
Can the task title and tab title be the same field?No. Add a tab label; denormalize task title into legacy thread title only for compatibility.Multi-tab tasks need a stable goal and distinguishable agent labels.
+
+ +
+
+ Do not do +
    +
  • Do not mass-rename ThreadId or provider runtime events to TabId.
  • +
  • Do not share one provider resume cursor across concurrent T3 threads.
  • +
  • Do not call portable handoff an exact fork.
  • +
  • Do not allow task tabs to diverge onto different worktrees.
  • +
  • Do not expose checkpoint revert after overlapping writes.
  • +
  • Do not hide task data when the beta flag is disabled.
  • +
+
+
+ Preserve +
    +
  • Current Mod+N new-work behavior and draft-first feel.
  • +
  • Per-provider sessions, approvals, and interrupt controls.
  • +
  • Existing thread deep links and subscriptions.
  • +
  • Sidebar v2 stable creation-order sorting and inbox lifecycle.
  • +
  • Worktree sharing checks for legacy clients.
  • +
  • Capability-based behavior under client/server version skew.
  • +
+
+
+ +

Telemetry for the experiment

+

+ Record counts and timings only: task creation, tab count distribution, fork strategy, + source/target driver kinds, fork failures, time to first target turn, overlapping-turn + frequency, and flag disablement. Never include prompt text, context bundles, paths, or + transcript content in analytics. +

+
+ +
+

Definition of done

+
+
Sidebar v2 shows one row per WorkspaceTask.
+
Existing threads migrate to one-tab tasks without title or lifecycle loss.
+
Mod+N creates a task draft and first tab with current defaults.
+
Desktop Cmd+T and web-safe action open the new-tab sheet.
+
Fresh tabs use the exact same project, branch, and effective cwd.
+
Each tab independently selects provider, model, and runtime mode.
+
Portable forks capture a fixed, bounded, visible ContextBundle.
+
Compatible Codex forks use app-server thread/fork.
+
Running source turns fork only through the latest completed turn.
+
Task status aggregates all visible tab states deterministically.
+
Task lifecycle actions guard and affect every tab.
+
Parallel tab activity is visible at task and tab level.
+
Overlapping checkpoints cannot be reverted in client or server.
+
Disabling the flag exposes all tabs as ordinary threads.
+
Old-server capability fallback is clear and non-failing.
+
Focused backend, contract, client, and integrated web verification passes.
+
+ +
+ The experiment succeeds if one task can hold multiple independently + useful agent conversations—especially across harnesses—while the user continues to + reason about exactly one checkout, one goal, and one sidebar item. The plan + deliberately makes provider-fork fidelity and shared-filesystem hazards visible + instead of smoothing over them. +
+
+
+
+ + +
+ + diff --git a/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts b/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts index 8e0e5fb74d5..0d5b77d1d79 100644 --- a/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts +++ b/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts @@ -83,6 +83,8 @@ describe("CheckpointDiffQuery.layer", () => { Effect.die("CheckpointDiffQuery should not request the orchestration shell snapshot"), getArchivedShellSnapshot: () => Effect.die("CheckpointDiffQuery should not request archived shell snapshots"), + getClosedTaskTabs: () => + Effect.die("CheckpointDiffQuery should not request closed task tabs"), getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 0 }), getCounts: () => Effect.succeed({ projectCount: 0, threadCount: 0 }), getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), @@ -191,6 +193,8 @@ describe("CheckpointDiffQuery.layer", () => { Effect.die("CheckpointDiffQuery should not request the orchestration shell snapshot"), getArchivedShellSnapshot: () => Effect.die("CheckpointDiffQuery should not request archived shell snapshots"), + getClosedTaskTabs: () => + Effect.die("CheckpointDiffQuery should not request closed task tabs"), getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 0 }), getCounts: () => Effect.succeed({ projectCount: 0, threadCount: 0 }), getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), @@ -274,6 +278,8 @@ describe("CheckpointDiffQuery.layer", () => { Effect.die("CheckpointDiffQuery should not request the orchestration shell snapshot"), getArchivedShellSnapshot: () => Effect.die("CheckpointDiffQuery should not request archived shell snapshots"), + getClosedTaskTabs: () => + Effect.die("CheckpointDiffQuery should not request closed task tabs"), getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 0 }), getCounts: () => Effect.succeed({ projectCount: 0, threadCount: 0 }), getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), @@ -342,6 +348,8 @@ describe("CheckpointDiffQuery.layer", () => { Effect.die("CheckpointDiffQuery should not request the orchestration shell snapshot"), getArchivedShellSnapshot: () => Effect.die("CheckpointDiffQuery should not request archived shell snapshots"), + getClosedTaskTabs: () => + Effect.die("CheckpointDiffQuery should not request closed task tabs"), getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 0 }), getCounts: () => Effect.succeed({ projectCount: 0, threadCount: 0 }), getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), @@ -395,6 +403,8 @@ describe("CheckpointDiffQuery.layer", () => { Effect.die("CheckpointDiffQuery should not request the orchestration shell snapshot"), getArchivedShellSnapshot: () => Effect.die("CheckpointDiffQuery should not request archived shell snapshots"), + getClosedTaskTabs: () => + Effect.die("CheckpointDiffQuery should not request closed task tabs"), getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 0 }), getCounts: () => Effect.succeed({ projectCount: 0, threadCount: 0 }), getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), diff --git a/apps/server/src/environment/ServerEnvironment.test.ts b/apps/server/src/environment/ServerEnvironment.test.ts index e9dbc4a5956..61bd5b0202c 100644 --- a/apps/server/src/environment/ServerEnvironment.test.ts +++ b/apps/server/src/environment/ServerEnvironment.test.ts @@ -69,6 +69,7 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { expect(first.environmentId).toBe(second.environmentId); expect(second.capabilities.repositoryIdentity).toBe(true); expect(second.capabilities.connectionProbe).toBe(true); + expect(second.capabilities.workspaceTaskTabs).toBe(true); }), ); diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index 0eaf5a7c16a..f0497ab651b 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -142,6 +142,7 @@ export const make = Effect.gen(function* () { connectionProbe: true, threadSettlement: true, threadSnooze: true, + workspaceTaskTabs: true, ...(serverSelfUpdate === null ? {} : { serverSelfUpdate }), }, }; diff --git a/apps/server/src/keybindings.test.ts b/apps/server/src/keybindings.test.ts index 2eef6ac8416..67a6867720a 100644 --- a/apps/server/src/keybindings.test.ts +++ b/apps/server/src/keybindings.test.ts @@ -198,6 +198,8 @@ it.layer(NodeServices.layer)("keybindings", (it) => { assert.equal(defaultsByCommand.get("thread.jump.1"), "mod+1"); assert.equal(defaultsByCommand.get("thread.jump.9"), "mod+9"); assert.equal(defaultsByCommand.get("modelPicker.toggle"), "mod+shift+m"); + assert.equal(defaultsByCommand.get("chat.newTab"), "mod+t"); + assert.equal(defaultsByCommand.get("chat.reopenClosedTab"), "mod+shift+t"); assert.equal(defaultsByCommand.get("sidebar.toggle"), "mod+b"); assert.equal(defaultsByCommand.get("rightPanel.toggle"), "mod+alt+b"); assert.equal(defaultsByCommand.get("terminal.splitVertical"), "mod+shift+d"); diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts index 731002ea783..eb80f62db33 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts @@ -192,6 +192,7 @@ describe("OrchestrationEngine", () => { threads: [], updatedAt: projectionSnapshot.updatedAt, }), + getClosedTaskTabs: () => Effect.succeed([]), getSnapshotSequence: () => Effect.succeed({ snapshotSequence: projectionSnapshot.snapshotSequence }), getCounts: () => Effect.succeed({ projectCount: 1, threadCount: 1 }), diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index 1f24a4a0200..f3ffe657f21 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -4,6 +4,7 @@ import { type OrchestrationEvent, type OrchestrationSessionStatus, ThreadId, + WorkspaceTaskId, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -597,6 +598,12 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti yield* projectionThreadRepository.upsert({ threadId: event.payload.threadId, projectId: event.payload.projectId, + workspaceTaskId: + event.payload.workspaceTaskId ?? WorkspaceTaskId.make(String(event.payload.threadId)), + tabLabel: event.payload.tabLabel ?? null, + tabPosition: event.payload.tabPosition ?? 0, + tabClosedAt: event.payload.tabClosedAt ?? null, + forkProvenance: event.payload.forkProvenance ?? null, title: event.payload.title, modelSelection: event.payload.modelSelection, runtimeMode: event.payload.runtimeMode, @@ -730,6 +737,13 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti ...(event.payload.worktreePath !== undefined ? { worktreePath: event.payload.worktreePath } : {}), + ...(event.payload.tabLabel !== undefined ? { tabLabel: event.payload.tabLabel } : {}), + ...(event.payload.tabPosition !== undefined + ? { tabPosition: event.payload.tabPosition } + : {}), + ...(event.payload.tabClosedAt !== undefined + ? { tabClosedAt: event.payload.tabClosedAt } + : {}), updatedAt: event.payload.updatedAt, }); return; diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index d4a24a209ad..2db68c0ad67 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -6,6 +6,7 @@ import { ThreadId, TurnId, ProviderInstanceId, + WorkspaceTaskId, } from "@t3tools/contracts"; import { assert, it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; @@ -284,6 +285,11 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { { id: ThreadId.make("thread-1"), projectId: asProjectId("project-1"), + workspaceTaskId: WorkspaceTaskId.make("thread-1"), + tabLabel: null, + tabPosition: 0, + tabClosedAt: null, + forkProvenance: null, title: "Thread 1", modelSelection: { instanceId: ProviderInstanceId.make("codex"), @@ -398,6 +404,11 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { { id: ThreadId.make("thread-1"), projectId: asProjectId("project-1"), + workspaceTaskId: WorkspaceTaskId.make("thread-1"), + tabLabel: null, + tabPosition: 0, + tabClosedAt: null, + forkProvenance: null, title: "Thread 1", modelSelection: { instanceId: ProviderInstanceId.make("codex"), @@ -447,6 +458,29 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { if (threadDetail._tag === "Some") { assert.deepEqual(threadDetail.value, snapshot.threads[0]); } + + yield* sql` + UPDATE projection_threads + SET tab_closed_at = '2026-02-24T00:00:09.000Z' + WHERE thread_id = 'thread-1' + `; + const closedShellSnapshot = yield* snapshotQuery.getShellSnapshot(); + assert.deepEqual(closedShellSnapshot.threads, []); + const closedThreadShell = yield* snapshotQuery.getThreadShellById(ThreadId.make("thread-1")); + assert.equal(closedThreadShell._tag, "None"); + const closedTaskTabs = yield* snapshotQuery.getClosedTaskTabs(); + assert.equal(closedTaskTabs.length, 1); + assert.equal(closedTaskTabs[0]?.id, ThreadId.make("thread-1")); + assert.equal(closedTaskTabs[0]?.tabClosedAt, "2026-02-24T00:00:09.000Z"); + assert.isNull(closedTaskTabs[0]?.session); + assert.isNull(closedTaskTabs[0]?.latestTurn); + const closedThreadDetail = yield* snapshotQuery.getThreadDetailById( + ThreadId.make("thread-1"), + ); + assert.equal(closedThreadDetail._tag, "Some"); + if (closedThreadDetail._tag === "Some") { + assert.equal(closedThreadDetail.value.tabClosedAt, "2026-02-24T00:00:09.000Z"); + } }), ); diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index 3d05bef4bdf..00c9491eceb 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -24,6 +24,7 @@ import { ModelSelection, ProjectId, ThreadId, + ThreadForkProvenance, } from "@t3tools/contracts"; import * as Arr from "effect/Array"; import * as Effect from "effect/Effect"; @@ -78,6 +79,7 @@ const ProjectionThreadProposedPlanDbRowSchema = ProjectionThreadProposedPlan; const ProjectionThreadDbRowSchema = ProjectionThread.mapFields( Struct.assign({ modelSelection: Schema.fromJsonString(ModelSelection), + forkProvenance: Schema.NullOr(Schema.fromJsonString(ThreadForkProvenance)), }), ); const ProjectionThreadActivityDbRowSchema = ProjectionThreadActivity.mapFields( @@ -117,6 +119,10 @@ const ProjectIdLookupInput = Schema.Struct({ const ThreadIdLookupInput = Schema.Struct({ threadId: ThreadId, }); +const ThreadRowByIdLookupInput = Schema.Struct({ + threadId: ThreadId, + includeClosed: Schema.Boolean, +}); const ProjectionProjectLookupRowSchema = ProjectionProjectDbRowSchema; const ProjectionThreadIdLookupRowSchema = Schema.Struct({ threadId: ThreadId, @@ -240,6 +246,39 @@ function mapProjectShellRow( }; } +function mapThreadShellRow( + row: Schema.Schema.Type, +): OrchestrationThreadShell { + return { + id: row.threadId, + projectId: row.projectId, + workspaceTaskId: row.workspaceTaskId, + tabLabel: row.tabLabel, + tabPosition: row.tabPosition, + tabClosedAt: row.tabClosedAt, + forkProvenance: row.forkProvenance, + title: row.title, + modelSelection: row.modelSelection, + runtimeMode: row.runtimeMode, + interactionMode: row.interactionMode, + branch: row.branch, + worktreePath: row.worktreePath, + latestTurn: null, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + archivedAt: row.archivedAt, + settledOverride: row.settledOverride, + settledAt: row.settledAt, + snoozedUntil: row.snoozedUntil, + snoozedAt: row.snoozedAt, + session: null, + latestUserMessageAt: row.latestUserMessageAt, + hasPendingApprovals: row.pendingApprovalCount > 0, + hasPendingUserInput: row.pendingUserInputCount > 0, + hasActionableProposedPlan: row.hasActionableProposedPlan > 0, + }; +} + function mapProposedPlanRow( row: Schema.Schema.Type, ): OrchestrationProposedPlan { @@ -324,6 +363,11 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { SELECT thread_id AS "threadId", project_id AS "projectId", + COALESCE(workspace_task_id, thread_id) AS "workspaceTaskId", + tab_label AS "tabLabel", + tab_position AS "tabPosition", + tab_closed_at AS "tabClosedAt", + fork_provenance_json AS "forkProvenance", title, model_selection_json AS "modelSelection", runtime_mode AS "runtimeMode", @@ -356,6 +400,11 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { SELECT thread_id AS "threadId", project_id AS "projectId", + COALESCE(workspace_task_id, thread_id) AS "workspaceTaskId", + tab_label AS "tabLabel", + tab_position AS "tabPosition", + tab_closed_at AS "tabClosedAt", + fork_provenance_json AS "forkProvenance", title, model_selection_json AS "modelSelection", runtime_mode AS "runtimeMode", @@ -378,10 +427,51 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { FROM projection_threads WHERE deleted_at IS NULL AND archived_at IS NULL + AND tab_closed_at IS NULL ORDER BY project_id ASC, created_at ASC, thread_id ASC `, }); + const listClosedTaskTabRows = SqlSchema.findAll({ + Request: Schema.Void, + Result: ProjectionThreadDbRowSchema, + execute: () => + sql` + SELECT + thread_id AS "threadId", + project_id AS "projectId", + COALESCE(workspace_task_id, thread_id) AS "workspaceTaskId", + tab_label AS "tabLabel", + tab_position AS "tabPosition", + tab_closed_at AS "tabClosedAt", + fork_provenance_json AS "forkProvenance", + title, + model_selection_json AS "modelSelection", + runtime_mode AS "runtimeMode", + interaction_mode AS "interactionMode", + branch, + worktree_path AS "worktreePath", + latest_turn_id AS "latestTurnId", + created_at AS "createdAt", + updated_at AS "updatedAt", + archived_at AS "archivedAt", + settled_override AS "settledOverride", + settled_at AS "settledAt", + snoozed_until AS "snoozedUntil", + snoozed_at AS "snoozedAt", + latest_user_message_at AS "latestUserMessageAt", + pending_approval_count AS "pendingApprovalCount", + pending_user_input_count AS "pendingUserInputCount", + has_actionable_proposed_plan AS "hasActionableProposedPlan", + deleted_at AS "deletedAt" + FROM projection_threads + WHERE deleted_at IS NULL + AND archived_at IS NULL + AND tab_closed_at IS NOT NULL + ORDER BY tab_closed_at DESC, thread_id ASC + `, + }); + const listArchivedThreadRows = SqlSchema.findAll({ Request: Schema.Void, Result: ProjectionThreadDbRowSchema, @@ -390,6 +480,11 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { SELECT thread_id AS "threadId", project_id AS "projectId", + COALESCE(workspace_task_id, thread_id) AS "workspaceTaskId", + tab_label AS "tabLabel", + tab_position AS "tabPosition", + tab_closed_at AS "tabClosedAt", + fork_provenance_json AS "forkProvenance", title, model_selection_json AS "modelSelection", runtime_mode AS "runtimeMode", @@ -748,14 +843,19 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { `, }); - const getActiveThreadRowById = SqlSchema.findOneOption({ - Request: ThreadIdLookupInput, + const getThreadRowById = SqlSchema.findOneOption({ + Request: ThreadRowByIdLookupInput, Result: ProjectionThreadDbRowSchema, - execute: ({ threadId }) => + execute: ({ threadId, includeClosed }) => sql` SELECT thread_id AS "threadId", project_id AS "projectId", + COALESCE(workspace_task_id, thread_id) AS "workspaceTaskId", + tab_label AS "tabLabel", + tab_position AS "tabPosition", + tab_closed_at AS "tabClosedAt", + fork_provenance_json AS "forkProvenance", title, model_selection_json AS "modelSelection", runtime_mode AS "runtimeMode", @@ -779,6 +879,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { WHERE thread_id = ${threadId} AND deleted_at IS NULL AND archived_at IS NULL + AND (${includeClosed ? 1 : 0} = 1 OR tab_closed_at IS NULL) LIMIT 1 `, }); @@ -1192,6 +1293,11 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { const threads: ReadonlyArray = threadRows.map((row) => ({ id: row.threadId, projectId: row.projectId, + workspaceTaskId: row.workspaceTaskId, + tabLabel: row.tabLabel, + tabPosition: row.tabPosition, + tabClosedAt: row.tabClosedAt, + forkProvenance: row.forkProvenance, title: row.title, modelSelection: row.modelSelection, runtimeMode: row.runtimeMode, @@ -1394,6 +1500,11 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { threads.push({ id: row.threadId, projectId: row.projectId, + workspaceTaskId: row.workspaceTaskId, + tabLabel: row.tabLabel, + tabPosition: row.tabPosition, + tabClosedAt: row.tabClosedAt, + forkProvenance: row.forkProvenance, title: row.title, modelSelection: row.modelSelection, runtimeMode: row.runtimeMode, @@ -1527,6 +1638,11 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { ? Result.succeed({ id: row.threadId, projectId: row.projectId, + workspaceTaskId: row.workspaceTaskId, + tabLabel: row.tabLabel, + tabPosition: row.tabPosition, + tabClosedAt: row.tabClosedAt, + forkProvenance: row.forkProvenance, title: row.title, modelSelection: row.modelSelection, runtimeMode: row.runtimeMode, @@ -1665,6 +1781,11 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { (row): OrchestrationThreadShell => ({ id: row.threadId, projectId: row.projectId, + workspaceTaskId: row.workspaceTaskId, + tabLabel: row.tabLabel, + tabPosition: row.tabPosition, + tabClosedAt: row.tabClosedAt, + forkProvenance: row.forkProvenance, title: row.title, modelSelection: row.modelSelection, runtimeMode: row.runtimeMode, @@ -1708,6 +1829,17 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { }), ); + const getClosedTaskTabs: ProjectionSnapshotQueryShape["getClosedTaskTabs"] = () => + listClosedTaskTabRows(undefined).pipe( + Effect.map((rows) => rows.map(mapThreadShellRow)), + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getClosedTaskTabs:query", + "ProjectionSnapshotQuery.getClosedTaskTabs:decodeRows", + ), + ), + ); + const getSnapshotSequence: ProjectionSnapshotQueryShape["getSnapshotSequence"] = () => listProjectionStateRows(undefined).pipe( Effect.mapError( @@ -1876,7 +2008,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { const getThreadShellById: ProjectionSnapshotQueryShape["getThreadShellById"] = (threadId) => Effect.gen(function* () { const [threadRow, latestTurnRow, sessionRow] = yield* Effect.all([ - getActiveThreadRowById({ threadId }).pipe( + getThreadRowById({ threadId, includeClosed: false }).pipe( Effect.mapError( toPersistenceSqlOrDecodeError( "ProjectionSnapshotQuery.getThreadShellById:getThread:query", @@ -1909,6 +2041,11 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { return Option.some({ id: threadRow.value.threadId, projectId: threadRow.value.projectId, + workspaceTaskId: threadRow.value.workspaceTaskId, + tabLabel: threadRow.value.tabLabel, + tabPosition: threadRow.value.tabPosition, + tabClosedAt: threadRow.value.tabClosedAt, + forkProvenance: threadRow.value.forkProvenance, title: threadRow.value.title, modelSelection: threadRow.value.modelSelection, runtimeMode: threadRow.value.runtimeMode, @@ -1942,7 +2079,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { latestTurnRow, sessionRow, ] = yield* Effect.all([ - getActiveThreadRowById({ threadId }).pipe( + getThreadRowById({ threadId, includeClosed: true }).pipe( Effect.mapError( toPersistenceSqlOrDecodeError( "ProjectionSnapshotQuery.getThreadDetailById:getThread:query", @@ -2007,6 +2144,11 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { const thread = { id: threadRow.value.threadId, projectId: threadRow.value.projectId, + workspaceTaskId: threadRow.value.workspaceTaskId, + tabLabel: threadRow.value.tabLabel, + tabPosition: threadRow.value.tabPosition, + tabClosedAt: threadRow.value.tabClosedAt, + forkProvenance: threadRow.value.forkProvenance, title: threadRow.value.title, modelSelection: threadRow.value.modelSelection, runtimeMode: threadRow.value.runtimeMode, @@ -2108,6 +2250,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { getSnapshot, getShellSnapshot, getArchivedShellSnapshot, + getClosedTaskTabs, getSnapshotSequence, getCounts, getActiveProjectByWorkspaceRoot, diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index c49646b7a4b..9be9f357d0f 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -20,6 +20,7 @@ import { ProjectId, ThreadId, TurnId, + WorkspaceTaskId, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Deferred from "effect/Deferred"; @@ -477,6 +478,80 @@ describe("ProviderCommandReactor", () => { expect(thread?.session?.runtimeMode).toBe("approval-required"); }); + effectIt.effect( + "injects a source transcript only into the first provider turn of a portable task fork", + () => + Effect.gen(function* () { + const harness = yield* Effect.promise(() => createHarness()); + const now = "2026-01-01T00:00:00.000Z"; + + yield* harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-source-turn"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("source-user-message"), + role: "user", + text: "Source tab context", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }); + yield* Effect.promise(() => waitFor(() => harness.sendTurn.mock.calls.length === 1)); + + yield* harness.engine.dispatch({ + type: "thread.create", + commandId: CommandId.make("cmd-fork-thread-create"), + threadId: ThreadId.make("thread-fork"), + projectId: asProjectId("project-1"), + workspaceTaskId: WorkspaceTaskId.make("thread-1"), + tabPosition: 1, + forkProvenance: { + mode: "portable", + sourceThreadId: ThreadId.make("thread-1"), + createdAt: now, + }, + title: "Fork", + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + branch: null, + worktreePath: null, + createdAt: now, + }); + yield* harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-fork-turn"), + threadId: ThreadId.make("thread-fork"), + message: { + messageId: asMessageId("fork-user-message"), + role: "user", + text: "Try another approach", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }); + + yield* Effect.promise(() => waitFor(() => harness.sendTurn.mock.calls.length === 2)); + expect(harness.sendTurn.mock.calls[1]?.[0]).toMatchObject({ + threadId: ThreadId.make("thread-fork"), + }); + const forkInput = harness.sendTurn.mock.calls[1]?.[0] as { input?: string }; + expect(forkInput.input).toContain("Source tab context"); + expect(forkInput.input?.endsWith("Try another approach")).toBe(true); + const readModel = yield* Effect.promise(() => harness.readModel()); + const fork = readModel.threads.find((thread) => thread.id === ThreadId.make("thread-fork")); + expect(fork?.messages.map((message) => message.text)).toEqual(["Try another approach"]); + }), + ); + effectIt.effect("projects starting before a slow provider session finishes", () => Effect.gen(function* () { const releaseStart = yield* Deferred.make(); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index b6bff8c766a..0c8feed5be3 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -41,6 +41,7 @@ import { import { ServerSettingsService } from "../../serverSettings.ts"; import { VcsStatusBroadcaster } from "../../vcs/VcsStatusBroadcaster.ts"; import { GitWorkflowService } from "../../git/GitWorkflowService.ts"; +import { buildPortableForkProviderInput } from "../portableFork.ts"; const isProviderAdapterRequestError = Schema.is(ProviderAdapterRequestError); const isProviderDriverKind = Schema.is(ProviderDriverKind); @@ -629,7 +630,20 @@ const make = Effect.gen(function* () { if (input.modelSelection !== undefined) { threadModelSelections.set(input.threadId, input.modelSelection); } - const normalizedInput = toNonEmptyProviderInput(input.messageText); + const sourceThread = + thread.forkProvenance?.mode === "portable" && + thread.forkProvenance.sourceThreadId !== null && + thread.messages.filter((entry) => entry.role === "user").length === 1 + ? yield* resolveThread(thread.forkProvenance.sourceThreadId) + : undefined; + const providerInput = + sourceThread !== undefined + ? buildPortableForkProviderInput({ + source: sourceThread, + userInput: input.messageText, + }) + : input.messageText; + const normalizedInput = toNonEmptyProviderInput(providerInput); const normalizedAttachments = input.attachments ?? []; const activeSession = yield* providerService .listSessions() diff --git a/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts index 23b291d8778..3edadd02ebe 100644 --- a/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts @@ -94,6 +94,15 @@ export interface ProjectionSnapshotQueryShape { ProjectionRepositoryError >; + /** + * Read closed, non-archived task-tab shells so clients can offer restore + * without bootstrapping them into normal navigation state. + */ + readonly getClosedTaskTabs: () => Effect.Effect< + ReadonlyArray, + ProjectionRepositoryError + >; + /** * Read the latest projection snapshot sequence without hydrating read-model * entities. diff --git a/apps/server/src/orchestration/decider.taskTabs.test.ts b/apps/server/src/orchestration/decider.taskTabs.test.ts new file mode 100644 index 00000000000..d2d2dfd0ee8 --- /dev/null +++ b/apps/server/src/orchestration/decider.taskTabs.test.ts @@ -0,0 +1,216 @@ +import { + CommandId, + EventId, + ProjectId, + ProviderInstanceId, + ThreadId, + WorkspaceTaskId, +} from "@t3tools/contracts"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; + +import { decideOrchestrationCommand } from "./decider.ts"; +import { createEmptyReadModel, projectEvent } from "./projector.ts"; + +const now = "2026-07-24T00:00:00.000Z"; +const projectId = ProjectId.make("project-1"); +const taskId = WorkspaceTaskId.make("task-1"); +const rootThreadId = ThreadId.make("thread-root"); +const modelSelection = { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5", +} as const; + +const seededReadModel = Effect.gen(function* () { + const withProject = yield* projectEvent(createEmptyReadModel(now), { + sequence: 1, + eventId: EventId.make("event-project"), + aggregateKind: "project", + aggregateId: projectId, + type: "project.created", + occurredAt: now, + commandId: CommandId.make("command-project"), + causationEventId: null, + correlationId: CommandId.make("command-project"), + metadata: {}, + payload: { + projectId, + title: "Project", + workspaceRoot: "/tmp/project", + defaultModelSelection: modelSelection, + scripts: [], + createdAt: now, + updatedAt: now, + }, + }); + return yield* projectEvent(withProject, { + sequence: 2, + eventId: EventId.make("event-root"), + aggregateKind: "thread", + aggregateId: rootThreadId, + type: "thread.created", + occurredAt: now, + commandId: CommandId.make("command-root"), + causationEventId: null, + correlationId: CommandId.make("command-root"), + metadata: {}, + payload: { + threadId: rootThreadId, + projectId, + workspaceTaskId: taskId, + tabLabel: null, + tabPosition: 0, + tabClosedAt: null, + forkProvenance: null, + title: "Task", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: "feature/task", + worktreePath: "/tmp/project-task", + createdAt: now, + updatedAt: now, + }, + }); +}); + +const seededReadModelWithSibling = Effect.gen(function* () { + const readModel = yield* seededReadModel; + const decided = yield* decideOrchestrationCommand({ + readModel, + command: { + type: "thread.create", + commandId: CommandId.make("command-seed-child"), + threadId: ThreadId.make("thread-child"), + projectId, + workspaceTaskId: taskId, + tabPosition: 1, + title: "Alternative", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: "feature/task", + worktreePath: "/tmp/project-task", + createdAt: now, + }, + }); + const event = Array.isArray(decided) ? decided[0]! : decided; + return yield* projectEvent(readModel, event); +}); + +it.layer(NodeServices.layer)("task tab invariants", (it) => { + it.effect("accepts a sibling tab on the same project and worktree", () => + Effect.gen(function* () { + const event = yield* decideOrchestrationCommand({ + readModel: yield* seededReadModel, + command: { + type: "thread.create", + commandId: CommandId.make("command-child"), + threadId: ThreadId.make("thread-child"), + projectId, + workspaceTaskId: taskId, + tabPosition: 1, + forkProvenance: { + mode: "portable", + sourceThreadId: rootThreadId, + createdAt: now, + }, + title: "Alternative", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: "feature/task", + worktreePath: "/tmp/project-task", + createdAt: now, + }, + }); + const firstEvent = Array.isArray(event) ? event[0] : event; + expect(firstEvent.type).toBe("thread.created"); + expect(firstEvent.payload).toMatchObject({ + workspaceTaskId: taskId, + tabPosition: 1, + }); + }), + ); + + it.effect("rejects a sibling tab that points at another worktree", () => + Effect.gen(function* () { + const failure = yield* Effect.flip( + decideOrchestrationCommand({ + readModel: yield* seededReadModel, + command: { + type: "thread.create", + commandId: CommandId.make("command-wrong-tree"), + threadId: ThreadId.make("thread-wrong-tree"), + projectId, + workspaceTaskId: taskId, + title: "Wrong tree", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: "other", + worktreePath: "/tmp/other-tree", + createdAt: now, + }, + }), + ); + expect(failure.message).toContain("must keep project and worktree identity"); + }), + ); + + it.effect("rejects moving one tab to another worktree after task creation", () => + Effect.gen(function* () { + const failure = yield* Effect.flip( + decideOrchestrationCommand({ + readModel: yield* seededReadModelWithSibling, + command: { + type: "thread.meta.update", + commandId: CommandId.make("command-move-root"), + threadId: rootThreadId, + worktreePath: "/tmp/other-tree", + }, + }), + ); + expect(failure.message).toContain("must keep project and worktree identity"); + }), + ); + + it.effect("rejects a deleted source for a portable fork", () => + Effect.gen(function* () { + const readModel = yield* seededReadModel; + const withDeletedSource = { + ...readModel, + threads: readModel.threads.map((thread) => + thread.id === rootThreadId ? { ...thread, deletedAt: now } : thread, + ), + }; + const failure = yield* Effect.flip( + decideOrchestrationCommand({ + readModel: withDeletedSource, + command: { + type: "thread.create", + commandId: CommandId.make("command-deleted-source"), + threadId: ThreadId.make("thread-from-deleted-source"), + projectId, + workspaceTaskId: taskId, + tabPosition: 1, + forkProvenance: { + mode: "portable", + sourceThreadId: rootThreadId, + createdAt: now, + }, + title: "Deleted source", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: "feature/task", + worktreePath: "/tmp/project-task", + createdAt: now, + }, + }), + ); + expect(failure.message).toContain("portable fork source must be an existing tab"); + }), + ); +}); diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 100369ae6e3..463d7758c33 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -3,6 +3,7 @@ import { type OrchestrationCommand, type OrchestrationEvent, type OrchestrationReadModel, + WorkspaceTaskId, } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; import * as Crypto from "effect/Crypto"; @@ -24,6 +25,10 @@ import { projectEvent } from "./projector.ts"; const nowIso = Effect.map(DateTime.now, DateTime.formatIso); +function workspaceTaskIdForThread(thread: OrchestrationReadModel["threads"][number]) { + return thread.workspaceTaskId ?? WorkspaceTaskId.make(String(thread.id)); +} + // Session adoption takes seconds; a user message still unadopted after this // window is a failed/stale start, not pending work. Mirrors the client's // QUEUED_TURN_START_GRACE_MS in client-runtime threadSettled.ts. @@ -355,6 +360,47 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" command, threadId: command.threadId, }); + const workspaceTaskId = + command.workspaceTaskId ?? WorkspaceTaskId.make(String(command.threadId)); + const sibling = readModel.threads.find( + (thread) => + thread.deletedAt === null && workspaceTaskIdForThread(thread) === workspaceTaskId, + ); + if ( + sibling && + (sibling.projectId !== command.projectId || sibling.worktreePath !== command.worktreePath) + ) { + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `workspace task ${workspaceTaskId} must keep project and worktree identity from sibling thread ${sibling.id}`, + }); + } + if ( + command.forkProvenance?.mode === "fresh" && + command.forkProvenance.sourceThreadId !== null + ) { + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: "a fresh task tab cannot declare a source thread", + }); + } + if (command.forkProvenance?.mode === "portable") { + const source = readModel.threads.find( + (thread) => thread.id === command.forkProvenance?.sourceThreadId, + ); + if ( + !source || + source.deletedAt !== null || + workspaceTaskIdForThread(source) !== workspaceTaskId || + source.projectId !== command.projectId || + source.worktreePath !== command.worktreePath + ) { + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `portable fork source must be an existing tab in workspace task ${workspaceTaskId}`, + }); + } + } return { ...(yield* withEventBase({ aggregateKind: "thread", @@ -366,6 +412,11 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" payload: { threadId: command.threadId, projectId: command.projectId, + workspaceTaskId, + tabLabel: command.tabLabel ?? null, + tabPosition: command.tabPosition ?? 0, + tabClosedAt: command.tabClosedAt ?? null, + forkProvenance: command.forkProvenance ?? null, title: command.title, modelSelection: command.modelSelection, runtimeMode: command.runtimeMode, @@ -642,6 +693,24 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" thread.branch !== command.expectedBranch ? thread.branch : command.branch; + if (command.worktreePath !== undefined) { + const workspaceTaskId = workspaceTaskIdForThread(thread); + const sibling = readModel.threads.find( + (candidate) => + candidate.id !== thread.id && + candidate.deletedAt === null && + workspaceTaskIdForThread(candidate) === workspaceTaskId, + ); + if ( + sibling && + (sibling.projectId !== thread.projectId || sibling.worktreePath !== command.worktreePath) + ) { + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `workspace task ${workspaceTaskId} must keep project and worktree identity from sibling thread ${sibling.id}`, + }); + } + } const occurredAt = yield* nowIso; return { ...(yield* withEventBase({ @@ -659,6 +728,9 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" : {}), ...(branch !== undefined ? { branch } : {}), ...(command.worktreePath !== undefined ? { worktreePath: command.worktreePath } : {}), + ...(command.tabLabel !== undefined ? { tabLabel: command.tabLabel } : {}), + ...(command.tabPosition !== undefined ? { tabPosition: command.tabPosition } : {}), + ...(command.tabClosedAt !== undefined ? { tabClosedAt: command.tabClosedAt } : {}), updatedAt: occurredAt, }, }; diff --git a/apps/server/src/orchestration/portableFork.test.ts b/apps/server/src/orchestration/portableFork.test.ts new file mode 100644 index 00000000000..1d8af860299 --- /dev/null +++ b/apps/server/src/orchestration/portableFork.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from "@effect/vitest"; +import { + MessageId, + ProviderInstanceId, + ProjectId, + ThreadId, + WorkspaceTaskId, + type OrchestrationThread, +} from "@t3tools/contracts"; + +import { buildPortableForkProviderInput } from "./portableFork.ts"; + +function makeSource(messages: OrchestrationThread["messages"]): OrchestrationThread { + return { + id: ThreadId.make("source-thread"), + projectId: ProjectId.make("project-1"), + workspaceTaskId: WorkspaceTaskId.make("task-1"), + tabLabel: "Main", + tabPosition: 0, + tabClosedAt: null, + forkProvenance: null, + title: "Investigate task tabs", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: "feature/task-tabs", + worktreePath: "/tmp/task-tabs", + latestTurn: null, + createdAt: "2026-07-24T00:00:00.000Z", + updatedAt: "2026-07-24T00:00:00.000Z", + archivedAt: null, + settledOverride: null, + settledAt: null, + snoozedUntil: null, + snoozedAt: null, + deletedAt: null, + messages, + proposedPlans: [], + activities: [], + checkpoints: [], + session: null, + }; +} + +describe("buildPortableForkProviderInput", () => { + it("places the source transcript before the child tab request", () => { + const result = buildPortableForkProviderInput({ + source: makeSource([ + { + id: MessageId.make("message-1"), + role: "user", + text: "Inspect the architecture.", + attachments: [], + turnId: null, + streaming: false, + createdAt: "2026-07-24T00:00:00.000Z", + updatedAt: "2026-07-24T00:00:00.000Z", + }, + ]), + userInput: "Try a different implementation.", + }); + + expect(result).toContain("same T3 Code task"); + expect(result).toContain("Inspect the architecture."); + expect(result).toContain("Shared worktree: /tmp/task-tabs"); + expect(result.endsWith("Try a different implementation.")).toBe(true); + }); + + it("bounds large transcripts while retaining recent messages", () => { + const result = buildPortableForkProviderInput({ + source: makeSource([ + { + id: MessageId.make("message-old"), + role: "user", + text: `old-${"x".repeat(48_000)}`, + attachments: [], + turnId: null, + streaming: false, + createdAt: "2026-07-24T00:00:00.000Z", + updatedAt: "2026-07-24T00:00:00.000Z", + }, + { + id: MessageId.make("message-recent"), + role: "assistant", + text: "recent-context", + attachments: [], + turnId: null, + streaming: false, + createdAt: "2026-07-24T00:01:00.000Z", + updatedAt: "2026-07-24T00:01:00.000Z", + }, + ]), + userInput: "Continue.", + }); + + expect(result).not.toContain("old-"); + expect(result).toContain("recent-context"); + expect(result.length).toBeLessThan(50_000); + }); +}); diff --git a/apps/server/src/orchestration/portableFork.ts b/apps/server/src/orchestration/portableFork.ts new file mode 100644 index 00000000000..eb7e33cfab4 --- /dev/null +++ b/apps/server/src/orchestration/portableFork.ts @@ -0,0 +1,55 @@ +import type { OrchestrationMessage, OrchestrationThread } from "@t3tools/contracts"; + +const MAX_HANDOFF_CHARACTERS = 48_000; + +function renderMessage(message: OrchestrationMessage): string { + const attachmentNote = + message.attachments && message.attachments.length > 0 + ? `\n[${message.attachments.length} attachment${message.attachments.length === 1 ? "" : "s"} omitted from the handoff]` + : ""; + return `\n${message.text}${attachmentNote}\n`; +} + +/** + * Builds a provider-neutral first-turn handoff for a context-forked task tab. + * The source transcript remains owned by its original thread; the child only + * receives this bounded snapshot as provider input. + */ +export function buildPortableForkProviderInput(input: { + readonly source: OrchestrationThread; + readonly userInput: string; +}): string { + const header = [ + "", + "You are continuing in a sibling tab of the same T3 Code task.", + "The sibling tabs share one worktree. Treat the source transcript below as prior context,", + "but follow the new user request after the closing tag as the current instruction.", + `Source tab: ${input.source.title}`, + `Source thread id: ${input.source.id}`, + `Source branch: ${input.source.branch ?? "(current checkout)"}`, + `Shared worktree: ${input.source.worktreePath ?? "(project workspace)"}`, + "", + ].join("\n"); + const footer = "\n\n"; + const fixedSize = header.length + footer.length; + const budget = Math.max(0, MAX_HANDOFF_CHARACTERS - fixedSize); + const selected: string[] = []; + let used = 0; + + // Preserve the most recent context when the source conversation is larger + // than the portable handoff budget. + for (let index = input.source.messages.length - 1; index >= 0; index -= 1) { + const message = input.source.messages[index]; + if (!message) continue; + const rendered = renderMessage(message); + if (used + rendered.length > budget) { + continue; + } + selected.unshift(rendered); + used += rendered.length; + } + + const transcript = + selected.length > 0 ? selected.join("\n") : "[No source transcript was available.]"; + return `${header}\n${transcript}${footer}\n\n${input.userInput}`; +} diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index 0504cb36f9a..db52be64695 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -280,6 +280,11 @@ export function projectEvent( { id: payload.threadId, projectId: payload.projectId, + workspaceTaskId: payload.workspaceTaskId, + tabLabel: payload.tabLabel, + tabPosition: payload.tabPosition, + tabClosedAt: payload.tabClosedAt, + forkProvenance: payload.forkProvenance, title: payload.title, modelSelection: payload.modelSelection, runtimeMode: payload.runtimeMode, @@ -404,6 +409,9 @@ export function projectEvent( : {}), ...(payload.branch !== undefined ? { branch: payload.branch } : {}), ...(payload.worktreePath !== undefined ? { worktreePath: payload.worktreePath } : {}), + ...(payload.tabLabel !== undefined ? { tabLabel: payload.tabLabel } : {}), + ...(payload.tabPosition !== undefined ? { tabPosition: payload.tabPosition } : {}), + ...(payload.tabClosedAt !== undefined ? { tabClosedAt: payload.tabClosedAt } : {}), updatedAt: payload.updatedAt, }), })), diff --git a/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts b/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts index 4763f565653..8e3c21973f0 100644 --- a/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts +++ b/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts @@ -1,4 +1,4 @@ -import { ProjectId, ThreadId, ProviderInstanceId } from "@t3tools/contracts"; +import { ProjectId, ProviderInstanceId, ThreadId, WorkspaceTaskId } from "@t3tools/contracts"; import { assert, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -78,6 +78,11 @@ projectionRepositoriesLayer("Projection repositories", (it) => { yield* threads.upsert({ threadId: ThreadId.make("thread-null-options"), projectId: ProjectId.make("project-null-options"), + workspaceTaskId: WorkspaceTaskId.make("thread-null-options"), + tabLabel: null, + tabPosition: 0, + tabClosedAt: null, + forkProvenance: null, title: "Null options thread", modelSelection: { instanceId: ProviderInstanceId.make("claudeAgent"), @@ -140,6 +145,11 @@ projectionRepositoriesLayer("Projection repositories", (it) => { yield* threads.upsert({ threadId: ThreadId.make("thread-settled"), projectId: ProjectId.make("project-1"), + workspaceTaskId: WorkspaceTaskId.make("thread-settled"), + tabLabel: null, + tabPosition: 0, + tabClosedAt: null, + forkProvenance: null, title: "Settled thread", modelSelection: { instanceId: ProviderInstanceId.make("codex"), diff --git a/apps/server/src/persistence/Layers/ProjectionThreads.ts b/apps/server/src/persistence/Layers/ProjectionThreads.ts index 7e86d49eac3..44e057394de 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreads.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreads.ts @@ -14,11 +14,12 @@ import { ProjectionThreadRepository, type ProjectionThreadRepositoryShape, } from "../Services/ProjectionThreads.ts"; -import { ModelSelection } from "@t3tools/contracts"; +import { ModelSelection, ThreadForkProvenance } from "@t3tools/contracts"; const ProjectionThreadDbRow = ProjectionThread.mapFields( Struct.assign({ modelSelection: Schema.fromJsonString(ModelSelection), + forkProvenance: Schema.NullOr(Schema.fromJsonString(ThreadForkProvenance)), }), ); type ProjectionThreadDbRow = typeof ProjectionThreadDbRow.Type; @@ -33,6 +34,11 @@ const makeProjectionThreadRepository = Effect.gen(function* () { INSERT INTO projection_threads ( thread_id, project_id, + workspace_task_id, + tab_label, + tab_position, + tab_closed_at, + fork_provenance_json, title, model_selection_json, runtime_mode, @@ -56,6 +62,11 @@ const makeProjectionThreadRepository = Effect.gen(function* () { VALUES ( ${row.threadId}, ${row.projectId}, + ${row.workspaceTaskId}, + ${row.tabLabel}, + ${row.tabPosition}, + ${row.tabClosedAt}, + ${row.forkProvenance === null ? null : JSON.stringify(row.forkProvenance)}, ${row.title}, ${JSON.stringify(row.modelSelection)}, ${row.runtimeMode}, @@ -79,6 +90,11 @@ const makeProjectionThreadRepository = Effect.gen(function* () { ON CONFLICT (thread_id) DO UPDATE SET project_id = excluded.project_id, + workspace_task_id = excluded.workspace_task_id, + tab_label = excluded.tab_label, + tab_position = excluded.tab_position, + tab_closed_at = excluded.tab_closed_at, + fork_provenance_json = excluded.fork_provenance_json, title = excluded.title, model_selection_json = excluded.model_selection_json, runtime_mode = excluded.runtime_mode, @@ -109,6 +125,11 @@ const makeProjectionThreadRepository = Effect.gen(function* () { SELECT thread_id AS "threadId", project_id AS "projectId", + COALESCE(workspace_task_id, thread_id) AS "workspaceTaskId", + tab_label AS "tabLabel", + tab_position AS "tabPosition", + tab_closed_at AS "tabClosedAt", + fork_provenance_json AS "forkProvenance", title, model_selection_json AS "modelSelection", runtime_mode AS "runtimeMode", @@ -141,6 +162,11 @@ const makeProjectionThreadRepository = Effect.gen(function* () { SELECT thread_id AS "threadId", project_id AS "projectId", + COALESCE(workspace_task_id, thread_id) AS "workspaceTaskId", + tab_label AS "tabLabel", + tab_position AS "tabPosition", + tab_closed_at AS "tabClosedAt", + fork_provenance_json AS "forkProvenance", title, model_selection_json AS "modelSelection", runtime_mode AS "runtimeMode", diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index d25895671a9..5e6caf2cd96 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -47,6 +47,7 @@ import Migration0031 from "./Migrations/031_AuthAuthorizationScopes.ts"; import Migration0032 from "./Migrations/032_AuthPairingProofKeyThumbprint.ts"; import Migration0033 from "./Migrations/033_ProjectionThreadsSettled.ts"; import Migration0034 from "./Migrations/034_ProjectionThreadsSnoozed.ts"; +import Migration0035 from "./Migrations/035_ProjectionThreadTasks.ts"; /** * Migration loader with all migrations defined inline. @@ -93,6 +94,7 @@ export const migrationEntries = [ [32, "AuthPairingProofKeyThumbprint", Migration0032], [33, "ProjectionThreadsSettled", Migration0033], [34, "ProjectionThreadsSnoozed", Migration0034], + [35, "ProjectionThreadTasks", Migration0035], ] as const; export const makeMigrationLoader = (throughId?: number) => diff --git a/apps/server/src/persistence/Migrations/035_ProjectionThreadTasks.test.ts b/apps/server/src/persistence/Migrations/035_ProjectionThreadTasks.test.ts new file mode 100644 index 00000000000..e974bf810ff --- /dev/null +++ b/apps/server/src/persistence/Migrations/035_ProjectionThreadTasks.test.ts @@ -0,0 +1,97 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { runMigrations } from "../Migrations.ts"; +import * as NodeSqliteClient from "../NodeSqliteClient.ts"; + +const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory())); + +layer("035_ProjectionThreadTasks", (it) => { + it.effect("backfills legacy threads as one-tab tasks and creates the lookup index", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* runMigrations({ toMigrationInclusive: 34 }); + yield* sql` + INSERT INTO projection_threads ( + thread_id, + project_id, + title, + model_selection_json, + runtime_mode, + interaction_mode, + branch, + worktree_path, + latest_turn_id, + created_at, + updated_at, + archived_at, + settled_override, + settled_at, + snoozed_until, + snoozed_at, + latest_user_message_at, + pending_approval_count, + pending_user_input_count, + has_actionable_proposed_plan, + deleted_at + ) + VALUES ( + 'thread-legacy', + 'project-1', + 'Legacy thread', + '{"instanceId":"codex","model":"gpt-5"}', + 'full-access', + 'default', + 'main', + '/tmp/worktree', + NULL, + '2026-07-24T00:00:00.000Z', + '2026-07-24T00:00:00.000Z', + NULL, + NULL, + NULL, + NULL, + NULL, + NULL, + 0, + 0, + 0, + NULL + ) + `; + + yield* runMigrations({ toMigrationInclusive: 35 }); + + const rows = yield* sql<{ + readonly workspaceTaskId: string; + readonly tabPosition: number; + readonly tabClosedAt: string | null; + readonly forkProvenance: string | null; + }>` + SELECT + workspace_task_id AS "workspaceTaskId", + tab_position AS "tabPosition", + tab_closed_at AS "tabClosedAt", + fork_provenance_json AS "forkProvenance" + FROM projection_threads + WHERE thread_id = 'thread-legacy' + `; + assert.deepStrictEqual(rows, [ + { + workspaceTaskId: "thread-legacy", + tabPosition: 0, + tabClosedAt: null, + forkProvenance: null, + }, + ]); + + const indexes = yield* sql<{ readonly name: string }>` + PRAGMA index_list(projection_threads) + `; + assert.ok(indexes.some((index) => index.name === "idx_projection_threads_workspace_task")); + }), + ); +}); diff --git a/apps/server/src/persistence/Migrations/035_ProjectionThreadTasks.ts b/apps/server/src/persistence/Migrations/035_ProjectionThreadTasks.ts new file mode 100644 index 00000000000..d4b2f81892b --- /dev/null +++ b/apps/server/src/persistence/Migrations/035_ProjectionThreadTasks.ts @@ -0,0 +1,53 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const columns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(projection_threads) + `; + + if (!columns.some((column) => column.name === "workspace_task_id")) { + yield* sql` + ALTER TABLE projection_threads + ADD COLUMN workspace_task_id TEXT + `; + } + if (!columns.some((column) => column.name === "tab_label")) { + yield* sql` + ALTER TABLE projection_threads + ADD COLUMN tab_label TEXT + `; + } + if (!columns.some((column) => column.name === "tab_position")) { + yield* sql` + ALTER TABLE projection_threads + ADD COLUMN tab_position INTEGER NOT NULL DEFAULT 0 + `; + } + if (!columns.some((column) => column.name === "tab_closed_at")) { + yield* sql` + ALTER TABLE projection_threads + ADD COLUMN tab_closed_at TEXT + `; + } + if (!columns.some((column) => column.name === "fork_provenance_json")) { + yield* sql` + ALTER TABLE projection_threads + ADD COLUMN fork_provenance_json TEXT + `; + } + + // Every pre-task thread becomes a one-tab task. This makes the migration + // lossless and lets mixed-version clients continue using thread routes. + yield* sql` + UPDATE projection_threads + SET workspace_task_id = thread_id + WHERE workspace_task_id IS NULL + `; + + yield* sql` + CREATE INDEX IF NOT EXISTS idx_projection_threads_workspace_task + ON projection_threads (workspace_task_id, tab_closed_at, tab_position, created_at, thread_id) + `; +}); diff --git a/apps/server/src/persistence/Services/ProjectionThreads.ts b/apps/server/src/persistence/Services/ProjectionThreads.ts index 056425ae886..bd72813a16d 100644 --- a/apps/server/src/persistence/Services/ProjectionThreads.ts +++ b/apps/server/src/persistence/Services/ProjectionThreads.ts @@ -14,7 +14,9 @@ import { ProviderInteractionMode, RuntimeMode, ThreadId, + ThreadForkProvenance, TurnId, + WorkspaceTaskId, } from "@t3tools/contracts"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; @@ -26,6 +28,11 @@ import type { ProjectionRepositoryError } from "../Errors.ts"; export const ProjectionThread = Schema.Struct({ threadId: ThreadId, projectId: ProjectId, + workspaceTaskId: WorkspaceTaskId, + tabLabel: Schema.NullOr(Schema.String), + tabPosition: NonNegativeInt, + tabClosedAt: Schema.NullOr(IsoDateTime), + forkProvenance: Schema.NullOr(ThreadForkProvenance), title: Schema.String, modelSelection: ModelSelection, runtimeMode: RuntimeMode, diff --git a/apps/server/src/project/ProjectSetupScriptRunner.test.ts b/apps/server/src/project/ProjectSetupScriptRunner.test.ts index 15612908079..db6292b269f 100644 --- a/apps/server/src/project/ProjectSetupScriptRunner.test.ts +++ b/apps/server/src/project/ProjectSetupScriptRunner.test.ts @@ -30,6 +30,7 @@ const makeProjectionSnapshotQueryLayer = (project: OrchestrationProject) => getSnapshot: () => Effect.die("unused"), getShellSnapshot: () => Effect.die("unused"), getArchivedShellSnapshot: () => Effect.die("unused"), + getClosedTaskTabs: () => Effect.die("unused"), getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 1 }), getCounts: () => Effect.die("unused"), getActiveProjectByWorkspaceRoot: (workspaceRoot) => diff --git a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts index 3843c8acbcd..c56ff4457b1 100644 --- a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts +++ b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts @@ -196,6 +196,7 @@ describe("ProviderSessionReaper", () => { getSnapshot: () => Effect.die("unused"), getShellSnapshot: () => Effect.die("unused"), getArchivedShellSnapshot: () => Effect.die("unused"), + getClosedTaskTabs: () => Effect.die("unused"), getSnapshotSequence: () => Effect.succeed({ snapshotSequence: input.readModel.snapshotSequence }), getCounts: () => Effect.die("unused"), diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index f06c27a066e..df7f98e92ef 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -5553,11 +5553,16 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }, ], }; + const closedTaskTab = makeDefaultOrchestrationThreadShell({ + id: ThreadId.make("thread-closed"), + tabClosedAt: now, + }); yield* buildAppUnderTest({ layers: { projectionSnapshotQuery: { getSnapshot: () => Effect.succeed(snapshot), + getClosedTaskTabs: () => Effect.succeed([closedTaskTab]), }, orchestrationEngine: { dispatch: () => Effect.succeed({ sequence: 7 }), @@ -5624,6 +5629,11 @@ it.layer(NodeServices.layer)("server router seam", (it) => { ), ); assert.deepEqual(replayResult, []); + + const closedTaskTabs = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => client[ORCHESTRATION_WS_METHODS.getClosedTaskTabs]({})), + ); + assert.deepEqual(closedTaskTabs, [closedTaskTab]); }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); diff --git a/apps/server/src/serverRuntimeStartup.test.ts b/apps/server/src/serverRuntimeStartup.test.ts index b8102bda9ad..df5d17e1d1c 100644 --- a/apps/server/src/serverRuntimeStartup.test.ts +++ b/apps/server/src/serverRuntimeStartup.test.ts @@ -81,6 +81,7 @@ it.effect("launchStartupHeartbeat does not block the caller while counts are loa getSnapshot: () => Effect.die("unused"), getShellSnapshot: () => Effect.die("unused"), getArchivedShellSnapshot: () => Effect.die("unused"), + getClosedTaskTabs: () => Effect.die("unused"), getSnapshotSequence: () => Effect.die("unused"), getCounts: () => Deferred.await(releaseCounts).pipe( @@ -138,6 +139,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets returns existing project and threa getSnapshot: () => Effect.die("unused"), getShellSnapshot: () => Effect.die("unused"), getArchivedShellSnapshot: () => Effect.die("unused"), + getClosedTaskTabs: () => Effect.die("unused"), getSnapshotSequence: () => Effect.die("unused"), getCounts: () => Effect.die("unused"), getActiveProjectByWorkspaceRoot: () => @@ -194,6 +196,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets creates a project and thread when getSnapshot: () => Effect.die("unused"), getShellSnapshot: () => Effect.die("unused"), getArchivedShellSnapshot: () => Effect.die("unused"), + getClosedTaskTabs: () => Effect.die("unused"), getSnapshotSequence: () => Effect.die("unused"), getCounts: () => Effect.die("unused"), getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), @@ -244,6 +247,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets preserves typed UUID generation fa getSnapshot: () => Effect.die("unused"), getShellSnapshot: () => Effect.die("unused"), getArchivedShellSnapshot: () => Effect.die("unused"), + getClosedTaskTabs: () => Effect.die("unused"), getSnapshotSequence: () => Effect.die("unused"), getCounts: () => Effect.die("unused"), getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index b8f4b07124d..d828dac4db9 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -296,6 +296,7 @@ const RPC_REQUIRED_SCOPE = new Map([ [ORCHESTRATION_WS_METHODS.replayEvents, AuthOrchestrationReadScope], [ORCHESTRATION_WS_METHODS.subscribeShell, AuthOrchestrationReadScope], [ORCHESTRATION_WS_METHODS.getArchivedShellSnapshot, AuthOrchestrationReadScope], + [ORCHESTRATION_WS_METHODS.getClosedTaskTabs, AuthOrchestrationReadScope], [ORCHESTRATION_WS_METHODS.subscribeThread, AuthOrchestrationReadScope], [WS_METHODS.serverProbe, AuthOrchestrationReadScope], [WS_METHODS.serverGetConfig, AuthOrchestrationReadScope], @@ -988,6 +989,21 @@ const makeWsRpcLayer = ( commandId: yield* serverCommandId("bootstrap-thread-create"), threadId: command.threadId, projectId: bootstrap.createThread.projectId, + ...(bootstrap.createThread.workspaceTaskId !== undefined + ? { workspaceTaskId: bootstrap.createThread.workspaceTaskId } + : {}), + ...(bootstrap.createThread.tabLabel !== undefined + ? { tabLabel: bootstrap.createThread.tabLabel } + : {}), + ...(bootstrap.createThread.tabPosition !== undefined + ? { tabPosition: bootstrap.createThread.tabPosition } + : {}), + ...(bootstrap.createThread.tabClosedAt !== undefined + ? { tabClosedAt: bootstrap.createThread.tabClosedAt } + : {}), + ...(bootstrap.createThread.forkProvenance !== undefined + ? { forkProvenance: bootstrap.createThread.forkProvenance } + : {}), title: bootstrap.createThread.title, modelSelection: bootstrap.createThread.modelSelection, runtimeMode: bootstrap.createThread.runtimeMode, @@ -1352,6 +1368,23 @@ const makeWsRpcLayer = ( ), { "rpc.aggregate": "orchestration" }, ), + [ORCHESTRATION_WS_METHODS.getClosedTaskTabs]: (_input) => + observeRpcEffect( + ORCHESTRATION_WS_METHODS.getClosedTaskTabs, + projectionSnapshotQuery.getClosedTaskTabs().pipe( + Effect.tapError((cause) => + Effect.logError("orchestration closed task tabs load failed", { cause }), + ), + Effect.mapError( + (cause) => + new OrchestrationGetSnapshotError({ + message: "Failed to load closed task tabs", + cause, + }), + ), + ), + { "rpc.aggregate": "orchestration" }, + ), [ORCHESTRATION_WS_METHODS.subscribeThread]: (input) => observeRpcStreamEffect( ORCHESTRATION_WS_METHODS.subscribeThread, diff --git a/apps/server/test/ActivityPayloadProjection.test.ts b/apps/server/test/ActivityPayloadProjection.test.ts index f7645a26a6a..782586a073e 100644 --- a/apps/server/test/ActivityPayloadProjection.test.ts +++ b/apps/server/test/ActivityPayloadProjection.test.ts @@ -69,6 +69,22 @@ function makeThread(activities: ReadonlyArray): Orc }; } +function buildMaterializedThreadFeed(activities: ReadonlyArray) { + return buildThreadFeed(makeThread(activities)).map((entry) => { + if (entry.type !== "activity-group") { + return entry; + } + return { + ...entry, + activities: entry.activities.map(({ getFullDetail, getCopyText, ...activity }) => ({ + ...activity, + fullDetail: getFullDetail(), + copyText: getCopyText(), + })), + }; + }); +} + const fixtures = [ makeActivity("command", "command_execution", { item: { @@ -170,8 +186,8 @@ describe("projectActivityPayload", () => { for (const activity of fixtures) { const projected = projectActivityPayload(activity); expect(deriveWorkLogEntries([projected])).toEqual(deriveWorkLogEntries([activity])); - expect(buildThreadFeed(makeThread([projected]))).toEqual( - buildThreadFeed(makeThread([activity])), + expect(buildMaterializedThreadFeed([projected])).toEqual( + buildMaterializedThreadFeed([activity]), ); } }); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index ab1256cddb3..dd96f3c5fca 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -115,6 +115,7 @@ import { useTheme } from "../hooks/useTheme"; import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; import { isCommandPaletteOpen } from "../commandPaletteBus"; import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git"; +import { WorkspaceTaskTabs } from "./WorkspaceTaskTabs"; import { useMediaQuery } from "../hooks/useMediaQuery"; import { RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY } from "../rightPanelLayout"; import { @@ -4725,6 +4726,18 @@ function ChatViewContent(props: ChatViewProps) { ? { createThread: { projectId: activeProject.id, + ...(draftThread?.workspaceTaskId + ? { workspaceTaskId: draftThread.workspaceTaskId } + : {}), + ...(draftThread?.tabLabel !== undefined + ? { tabLabel: draftThread.tabLabel } + : {}), + ...(draftThread?.tabPosition !== undefined + ? { tabPosition: draftThread.tabPosition } + : {}), + ...(draftThread?.forkProvenance !== undefined + ? { forkProvenance: draftThread.forkProvenance } + : {}), title, modelSelection: threadCreateModelSelection, runtimeMode, @@ -5655,6 +5668,7 @@ function ChatViewContent(props: ChatViewProps) { /> + setThreadError(activeThread.id, null)} diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index 8c5891ebe7e..3023cc9c512 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -32,6 +32,7 @@ import { SearchIcon, ServerIcon, SquarePenIcon, + SquareStackIcon, Trash2Icon, Undo2Icon, } from "lucide-react"; @@ -100,6 +101,11 @@ import { buildThreadRouteParams, resolveThreadRouteTarget } from "../threadRoute import { formatRelativeTimeLabel, parseTimestampDate } from "../timestampFormat"; import type { SidebarThreadSummary } from "../types"; import { cn } from "~/lib/utils"; +import { + groupThreadsByWorkspaceTask, + taskPresentationThread, + workspaceTaskKey, +} from "../lib/workspaceTasks"; import { formatWorkingDurationLabel, firstValidTimestampMs, @@ -212,6 +218,23 @@ function WorkingDuration(props: { startedAt: string | null }) { return {formatWorkingDurationLabel(Date.now() - startedMs)}; } +/** + * A task row stands for every tab of that task, so the count is a quiet glyph + * badge rather than a bare number — the icon carries the meaning without color, + * and the row tooltip explains what the tabs do and don't share. + */ +function TaskTabCountBadge(props: { count: number }) { + return ( + + + {props.count} + + ); +} + function SidebarV2ThreadTooltip({ thread, projectTitle, @@ -221,11 +244,13 @@ function SidebarV2ThreadTooltip({ modelInstanceId, modelLabel, branchMismatch, + tabCount, }: { thread: SidebarThreadSummary; projectTitle: string | null; projectCwd: string | null; environmentLabel: string | null; + tabCount: number; driverKind: ProviderInstanceEntry["driverKind"] | null; modelInstanceId: string; modelLabel: string; @@ -288,6 +313,14 @@ function SidebarV2ThreadTooltip({
{modelLabel}
) : null} + {tabCount > 1 ? ( +
+ +
+ {tabCount} tabs · Shared files, separate chats. +
+
+ ) : null} {thread.session?.lastError ? (
@@ -368,6 +401,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { // When a snooze ended (timer or early wake); drives the Woke pill until // the user visits the thread. wokeAt: string | null; + tabCount: number; isActive: boolean; jumpLabel: string | null; currentEnvironmentId: string | null; @@ -536,6 +570,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { modelInstanceId={modelInstanceId} modelLabel={modelLabel} branchMismatch={branchMismatch} + tabCount={props.tabCount} /> ); @@ -768,6 +803,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { /> {title} + {props.tabCount > 1 ? : null} {/* The PR badge stays outside the hover-fading slot: it must remain visible AND clickable while the row is hovered. Only the time/jump label yields to the settle affordance. */} @@ -943,7 +979,10 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { ) : null}
-
{title}
+
+ {title} + {props.tabCount > 1 ? : null} +
{thread.branch ? ( {thread.branch} @@ -1058,6 +1097,34 @@ export default function SidebarV2() { }); const routeThreadRef = routeTarget?.kind === "server" ? routeTarget.threadRef : null; const routeThreadKey = routeThreadRef ? scopedThreadKey(routeThreadRef) : null; + const routeTaskKey = useMemo(() => { + if (routeThreadKey === null) return null; + const routeThread = threads.find( + (thread) => + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) === routeThreadKey, + ); + return routeThread ? workspaceTaskKey(routeThread) : null; + }, [routeThreadKey, threads]); + const taskTabCountByKey = useMemo( + () => + new Map( + groupThreadsByWorkspaceTask(threads.filter((thread) => thread.tabClosedAt == null)).map( + (task) => [task.key, task.tabs.length] as const, + ), + ), + [threads], + ); + const taskTabsByThreadKey = useMemo(() => { + const mapping = new Map>(); + for (const task of groupThreadsByWorkspaceTask( + threads.filter((thread) => thread.tabClosedAt == null), + )) { + for (const tab of task.tabs) { + mapping.set(scopedThreadKey(scopeThreadRef(tab.environmentId, tab.id)), task.tabs); + } + } + return mapping; + }, [threads]); const routeTargetRef = useRef(routeTarget); routeTargetRef.current = routeTarget; // Post-settle navigation validates against the CURRENT route, not the one @@ -1370,13 +1437,15 @@ export default function SidebarV2() { const visible = threads.filter( (thread) => thread.archivedAt === null && + thread.tabClosedAt == null && (scopedProjectKeys === null || scopedProjectKeys.has(`${thread.environmentId}:${thread.projectId}`)), ); const active: EnvironmentThreadShell[] = []; const snoozed: EnvironmentThreadShell[] = []; const settled: EnvironmentThreadShell[] = []; - for (const thread of visible) { + for (const task of groupThreadsByWorkspaceTask(visible)) { + const thread = taskPresentationThread(task, routeThreadKey); // Threads on servers without the settlement capability (old server, // or descriptor not loaded yet) never classify as settled: the user // could neither un-settle nor pin them, so auto-settling them would @@ -1385,17 +1454,22 @@ export default function SidebarV2() { serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSettlement === true; const supportsSnooze = serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSnooze === true; - const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); - const changeRequestState = changeRequestStateByKey.get(threadKey) ?? null; + const tabStates = task.tabs.map((tab) => { + const threadKey = scopedThreadKey(scopeThreadRef(tab.environmentId, tab.id)); + const changeRequestState = changeRequestStateByKey.get(threadKey) ?? null; + return { + snoozed: supportsSnooze && effectiveSnoozed(tab, { now: preciseNow }), + settled: + supportsSettlement && + effectiveSettled(tab, { now, autoSettleAfterDays, changeRequestState }), + }; + }); // Snooze outranks settled classification: an explicitly snoozed thread // belongs to the shelf even if it would also auto-settle (the shelf's // wake time is a stronger statement about when it matters again). - if (supportsSnooze && effectiveSnoozed(thread, { now: preciseNow })) { + if (tabStates.every((state) => state.snoozed)) { snoozed.push(thread); - } else if ( - supportsSettlement && - effectiveSettled(thread, { now, autoSettleAfterDays, changeRequestState }) - ) { + } else if (tabStates.every((state) => state.settled)) { settled.push(thread); } else { active.push(thread); @@ -1420,6 +1494,7 @@ export default function SidebarV2() { serverConfigs, snoozeWakeTick, threads, + routeThreadKey, ]); // Arm a timeout for the earliest upcoming wake so the shelf empties the @@ -1457,17 +1532,14 @@ export default function SidebarV2() { // The open thread must never hide under "Show more": navigating into a // deep settled thread (search, deep link) pulls its row into the visible // tail so the highlight and the un-settle affordance stay reachable. - if (routeThreadKey !== null) { + if (routeTaskKey !== null) { const routeThread = settledThreads .slice(settledVisibleCount) - .find( - (thread) => - scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) === routeThreadKey, - ); + .find((thread) => workspaceTaskKey(thread) === routeTaskKey); if (routeThread !== undefined) visible.push(routeThread); } return visible; - }, [routeThreadKey, settledThreads, settledVisibleCount]); + }, [routeTaskKey, settledThreads, settledVisibleCount]); const hiddenSettledCount = settledThreads.length - visibleSettledThreads.length; const showMoreSettled = useCallback( () => setSettledVisibleCount((count) => count + SETTLED_TAIL_PAGE_COUNT), @@ -1477,13 +1549,12 @@ export default function SidebarV2() { const toggleSettledShelf = useCallback(() => setSettledShelfExpanded((value) => !value), []); const renderedSettledThreads = useMemo(() => { if (settledShelfExpanded) return visibleSettledThreads; - if (routeThreadKey === null) return []; + if (routeTaskKey === null) return []; const routeThread = visibleSettledThreads.find( - (thread) => - scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) === routeThreadKey, + (thread) => workspaceTaskKey(thread) === routeTaskKey, ); return routeThread === undefined ? [] : [routeThread]; - }, [routeThreadKey, settledShelfExpanded, visibleSettledThreads]); + }, [routeTaskKey, settledShelfExpanded, visibleSettledThreads]); // The snoozed shelf is collapsed by default: out of the way, never gone. // Collapsed threads don't render (and so don't participate in jump @@ -1496,13 +1567,10 @@ export default function SidebarV2() { // snoozed thread reached by route (deep link, open before snoozing // elsewhere) keeps its row — with highlight and wake affordance — same // exception the settled tail's "Show more" makes. - if (routeThreadKey === null) return []; - const routeThread = snoozedThreads.find( - (thread) => - scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) === routeThreadKey, - ); + if (routeTaskKey === null) return []; + const routeThread = snoozedThreads.find((thread) => workspaceTaskKey(thread) === routeTaskKey); return routeThread === undefined ? [] : [routeThread]; - }, [routeThreadKey, snoozedShelfExpanded, snoozedThreads]); + }, [routeTaskKey, snoozedShelfExpanded, snoozedThreads]); const orderedThreads = useMemo( () => [...activeThreads, ...visibleSnoozedThreads, ...renderedSettledThreads], @@ -1814,6 +1882,52 @@ export default function SidebarV2() { }, [attemptUnsnooze, planForwardNavigation, snoozeThread], ); + const taskTabsForRef = useCallback( + (threadRef: ScopedThreadRef) => taskTabsByThreadKey.get(scopedThreadKey(threadRef)) ?? [], + [taskTabsByThreadKey], + ); + const attemptSettleTask = useCallback( + (threadRef: ScopedThreadRef) => { + const tabs = taskTabsForRef(threadRef); + const coSettlingKeys = new Set( + tabs.map((tab) => scopedThreadKey(scopeThreadRef(tab.environmentId, tab.id))), + ); + for (const tab of tabs) { + attemptSettle(scopeThreadRef(tab.environmentId, tab.id), { coSettlingKeys }); + } + }, + [attemptSettle, taskTabsForRef], + ); + const attemptUnsettleTask = useCallback( + (threadRef: ScopedThreadRef) => { + for (const tab of taskTabsForRef(threadRef)) { + attemptUnsettle(scopeThreadRef(tab.environmentId, tab.id)); + } + }, + [attemptUnsettle, taskTabsForRef], + ); + const attemptSnoozeTask = useCallback( + (threadRef: ScopedThreadRef, preset: SnoozePreset) => { + const tabs = taskTabsForRef(threadRef); + const coSnoozingKeys = new Set( + tabs.map((tab) => scopedThreadKey(scopeThreadRef(tab.environmentId, tab.id))), + ); + for (const tab of tabs) { + attemptSnooze(scopeThreadRef(tab.environmentId, tab.id), preset, { + coSnoozingKeys, + }); + } + }, + [attemptSnooze, taskTabsForRef], + ); + const attemptUnsnoozeTask = useCallback( + (threadRef: ScopedThreadRef) => { + for (const tab of taskTabsForRef(threadRef)) { + attemptUnsnooze(scopeThreadRef(tab.environmentId, tab.id)); + } + }, + [attemptUnsnooze, taskTabsForRef], + ); const removeFromSelection = useThreadSelectionStore((s) => s.removeFromSelection); const handleMultiSelectContextMenu = useCallback( @@ -1829,13 +1943,20 @@ export default function SidebarV2() { ); if (threadKeys.length === 0) return; const count = threadKeys.length; + const selectedTabsByKey = new Map(); + for (const threadKey of threadKeys) { + const thread = threadByKeyRef.current.get(threadKey); + if (!thread) continue; + const tabs = taskTabsByThreadKey.get(threadKey) ?? [thread]; + for (const tab of tabs) { + selectedTabsByKey.set(scopedThreadKey(scopeThreadRef(tab.environmentId, tab.id)), tab); + } + } + const selectedTabEntries = [...selectedTabsByKey.entries()]; // Snooze (N) is offered when every selected thread can actually take // it — a mixed selection with blocked-on-you work would half-apply. const selectionNow = new Date().toISOString(); - const snoozableThreads = threadKeys.flatMap((threadKey) => { - const thread = threadByKeyRef.current.get(threadKey); - return thread ? [thread] : []; - }); + const snoozableThreads = selectedTabEntries.map(([, thread]) => thread); const canSnoozeSelection = snoozableThreads.every( (thread) => serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSnooze === true && @@ -1872,7 +1993,7 @@ export default function SidebarV2() { if (preset) { // Post-snooze navigation must skip threads snoozing in this same // batch — they are all leaving the card block together. - const coSnoozingKeys = new Set(threadKeys); + const coSnoozingKeys = new Set(selectedTabsByKey.keys()); for (const thread of snoozableThreads) { attemptSnooze(scopeThreadRef(thread.environmentId, thread.id), preset, { coSnoozingKeys, @@ -1887,18 +2008,16 @@ export default function SidebarV2() { // batch — they are all leaving the card block together. Rows that // are already explicitly settled are skipped: nothing to do on a // valid mixed selection. - const coSettlingKeys = new Set(threadKeys); - for (const threadKey of threadKeys) { - const thread = threadByKeyRef.current.get(threadKey); - if (!thread || thread.settledOverride === "settled") continue; + const coSettlingKeys = new Set(selectedTabsByKey.keys()); + for (const [, thread] of selectedTabEntries) { + if (thread.settledOverride === "settled") continue; attemptSettle(scopeThreadRef(thread.environmentId, thread.id), { coSettlingKeys }); } clearSelection(); return; } if (clicked.value === "mark-unread") { - for (const threadKey of threadKeys) { - const thread = threadByKeyRef.current.get(threadKey); + for (const [threadKey, thread] of selectedTabEntries) { markThreadUnread(threadKey, thread?.latestTurn?.completedAt); } clearSelection(); @@ -1909,8 +2028,8 @@ export default function SidebarV2() { const confirmed = await settlePromise(() => api.dialogs.confirm( [ - `Delete ${count} thread${count === 1 ? "" : "s"}?`, - "This permanently clears conversation history for these threads.", + `Delete ${count} task${count === 1 ? "" : "s"}?`, + "This permanently clears conversation history for every tab in these tasks.", ].join("\n"), ), ); @@ -1921,9 +2040,7 @@ export default function SidebarV2() { // really gone, or the first delete would treat still-alive batch mates // as deleted and remove a worktree they still point at. const deletedThreadKeys = new Set(); - for (const threadKey of threadKeys) { - const thread = threadByKeyRef.current.get(threadKey); - if (!thread) continue; + for (const [threadKey, thread] of selectedTabEntries) { const result = await deleteThread(scopeThreadRef(thread.environmentId, thread.id), { deletedThreadKeys, }); @@ -1953,6 +2070,7 @@ export default function SidebarV2() { markThreadUnread, removeFromSelection, serverConfigs, + taskTabsByThreadKey, ], ); @@ -1969,6 +2087,7 @@ export default function SidebarV2() { } const thread = threadByKeyRef.current.get(threadKey); if (!thread) return; + const taskTabs = taskTabsForRef(threadRef); // Un-settle works on every settled row: for explicit settles it // clears the override, for auto-settled rows it pins the thread // active until real activity clears the pin. Environments without @@ -1996,8 +2115,8 @@ export default function SidebarV2() { ...(supportsSettlement ? [ isSettled - ? { id: "unsettle", label: "Un-settle thread" } - : { id: "settle", label: "Settle thread" }, + ? { id: "unsettle", label: "Un-settle task" } + : { id: "settle", label: "Settle task" }, ] : []), ...(supportsSnooze @@ -2015,9 +2134,9 @@ export default function SidebarV2() { }, ] : []), - { id: "rename", label: "Rename thread" }, + { id: "rename", label: "Rename task" }, { id: "mark-unread", label: "Mark unread" }, - { id: "delete", label: "Delete", destructive: true, icon: "trash" }, + { id: "delete", label: "Delete task", destructive: true, icon: "trash" }, ], position, ), @@ -2027,7 +2146,7 @@ export default function SidebarV2() { const preset = snoozePresets.find( (candidate) => `snooze:${candidate.id}` === clicked.value, ); - if (preset) attemptSnooze(threadRef, preset); + if (preset) attemptSnoozeTask(threadRef, preset); return; } switch (clicked.value) { @@ -2055,43 +2174,53 @@ export default function SidebarV2() { return; } case "settle": - attemptSettle(threadRef); + attemptSettleTask(threadRef); return; case "unsettle": - attemptUnsettle(threadRef); + attemptUnsettleTask(threadRef); return; case "unsnooze": - attemptUnsnooze(threadRef); + attemptUnsnoozeTask(threadRef); return; case "rename": startThreadRename(threadRef, thread.title); return; case "mark-unread": - markThreadUnread(threadKey, thread.latestTurn?.completedAt); + for (const tab of taskTabs) { + markThreadUnread( + scopedThreadKey(scopeThreadRef(tab.environmentId, tab.id)), + tab.latestTurn?.completedAt, + ); + } return; case "delete": { if (confirmThreadDelete) { const confirmed = await settlePromise(() => api.dialogs.confirm( [ - `Delete thread "${thread.title}"?`, - "This permanently clears conversation history for this thread.", + `Delete task "${thread.title}" and its ${taskTabs.length} tab${taskTabs.length === 1 ? "" : "s"}?`, + "This permanently clears conversation history for every tab in the task.", ].join("\n"), ), ); if (confirmed._tag === "Failure" || !confirmed.value) return; } - const result = await deleteThread(threadRef); - if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Failed to delete thread", - description: error instanceof Error ? error.message : "An error occurred.", - }), - ); - return; + const deletedThreadKeys = new Set(); + for (const tab of taskTabs) { + const tabRef = scopeThreadRef(tab.environmentId, tab.id); + const result = await deleteThread(tabRef, { deletedThreadKeys }); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to delete task", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + return; + } + deletedThreadKeys.add(scopedThreadKey(tabRef)); } return; } @@ -2101,16 +2230,17 @@ export default function SidebarV2() { })(); }, [ - attemptSettle, - attemptSnooze, - attemptUnsettle, - attemptUnsnooze, + attemptSettleTask, + attemptSnoozeTask, + attemptUnsettleTask, + attemptUnsnoozeTask, confirmThreadDelete, deleteThread, handleMultiSelectContextMenu, markThreadUnread, serverConfigs, startThreadRename, + taskTabsForRef, ], ); @@ -2223,7 +2353,7 @@ export default function SidebarV2() { @@ -2248,7 +2378,7 @@ export default function SidebarV2() { className="relative size-8 justify-center rounded-md border-0 bg-transparent p-0 text-sidebar-muted-foreground hover:bg-sidebar-row-hover hover:text-sidebar-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-sidebar" onClick={handleNewThreadClick} disabled={projects.length === 0} - aria-label="New thread" + aria-label="New task" /> } > @@ -2259,7 +2389,7 @@ export default function SidebarV2() { /> - {newThreadShortcutLabel ? `New thread (${newThreadShortcutLabel})` : "New thread"} + {newThreadShortcutLabel ? `New task (${newThreadShortcutLabel})` : "New task"}
@@ -2270,7 +2400,7 @@ export default function SidebarV2() {
{scopedProjectGroup ? ( @@ -2419,7 +2549,8 @@ export default function SidebarV2() { // the wake signal must survive the trip. Still-snoozed // rows resolve to null on their own. wokeAt={threadWokeAt(thread, { now: snoozeNow })} - isActive={routeThreadKey === threadKey} + tabCount={taskTabCountByKey.get(workspaceTaskKey(thread)) ?? 1} + isActive={routeTaskKey === workspaceTaskKey(thread)} jumpLabel={showJumpHints ? (jumpLabelByKey.get(threadKey) ?? null) : null} currentEnvironmentId={primaryEnvironmentId} environmentLabel={environmentLabelById.get(thread.environmentId) ?? null} @@ -2441,10 +2572,10 @@ export default function SidebarV2() { isRenaming={renamingThreadKey === threadKey} renamingTitle={renamingThreadKey === threadKey ? renamingTitle : ""} onContextMenu={handleThreadContextMenu} - onSettle={attemptSettle} - onUnsettle={attemptUnsettle} - onSnooze={attemptSnooze} - onUnsnooze={attemptUnsnooze} + onSettle={attemptSettleTask} + onUnsettle={attemptUnsettleTask} + onSnooze={attemptSnoozeTask} + onUnsnooze={attemptUnsnoozeTask} onChangeRequestState={handleChangeRequestState} /> ); @@ -2546,9 +2677,9 @@ export default function SidebarV2() { ) : scopedProjectGroup ? ( - `No threads in ${scopedProjectGroup.displayName} yet` + `No tasks in ${scopedProjectGroup.displayName} yet` ) : ( - "No threads yet" + "No tasks yet" )}
) : null} diff --git a/apps/web/src/components/WorkspaceTaskTabs.tsx b/apps/web/src/components/WorkspaceTaskTabs.tsx new file mode 100644 index 00000000000..d639162e343 --- /dev/null +++ b/apps/web/src/components/WorkspaceTaskTabs.tsx @@ -0,0 +1,289 @@ +import { + CircleDashedIcon, + CopyPlusIcon, + HistoryIcon, + PlusIcon, + RotateCcwIcon, + XIcon, +} from "lucide-react"; +import { useMemo, useState } from "react"; + +import { shortcutLabelForCommand } from "../keybindings"; +import { useClientSettings } from "../hooks/useSettings"; +import { + type ClosedWorkspaceTaskTab, + type WorkspaceTaskTab, + useWorkspaceTaskTabs, +} from "../hooks/useWorkspaceTaskTabs"; +import { + describeTaskTabContexts, + type TaskTabContextDescriptor, + type TaskTabContextSource, +} from "../lib/taskTabContext"; +import { cn } from "../lib/utils"; +import { Menu, MenuItem, MenuPopup, MenuShortcut, MenuTrigger } from "./ui/menu"; +import { Tooltip, TooltipPopup, TooltipProvider, TooltipTrigger } from "./ui/tooltip"; + +type AnyWorkspaceTaskTab = WorkspaceTaskTab | ClosedWorkspaceTaskTab; + +function tabContextSource(tab: AnyWorkspaceTaskTab): TaskTabContextSource { + return { + kind: tab.kind, + threadId: String(tab.kind === "server" ? tab.threadRef.threadId : tab.threadId), + title: tab.title, + position: tab.position, + forkProvenance: tab.forkProvenance + ? { + mode: tab.forkProvenance.mode, + sourceThreadId: + tab.forkProvenance.sourceThreadId === null + ? null + : String(tab.forkProvenance.sourceThreadId), + } + : null, + }; +} + +/** + * State is carried by shape and text as well as hue: blocked tabs get a filled + * ring plus "Waiting on you", working tabs get a dashed spinner glyph. + */ +function TabStateGlyph(props: { tab: WorkspaceTaskTab }) { + const { tab } = props; + if (tab.blocked) { + return ( + + + + ); + } + if (tab.working) { + return ( + + ); + } + return null; +} + +function ContextSummary(props: { + context: TaskTabContextDescriptor; + active: boolean; + className?: string; +}) { + return ( + + · {props.context.label} + + ); +} + +export function WorkspaceTaskTabs() { + const sidebarV2Enabled = useClientSettings((settings) => settings.sidebarV2Enabled); + const { + tabs, + closedTabs, + keybindings, + hasTask, + canForkContext, + createTab, + closeTab, + reopenTab, + navigateToTab, + } = useWorkspaceTaskTabs(); + const [creatingMode, setCreatingMode] = useState<"fresh" | "portable" | null>(null); + const [reopeningKey, setReopeningKey] = useState(null); + const contextByTabKey = useMemo(() => { + const allTabs = [...tabs, ...closedTabs]; + const contexts = describeTaskTabContexts(allTabs.map(tabContextSource)); + return new Map(allTabs.map((tab, index) => [tab.key, contexts[index]!])); + }, [closedTabs, tabs]); + + if (!sidebarV2Enabled || !hasTask) return null; + + const newTabShortcut = shortcutLabelForCommand(keybindings, "chat.newTab"); + const reopenShortcut = shortcutLabelForCommand(keybindings, "chat.reopenClosedTab"); + const create = (mode: "fresh" | "portable") => { + if (creatingMode !== null) return; + setCreatingMode(mode); + void createTab(mode).finally(() => setCreatingMode(null)); + }; + const reopen = (tab: ClosedWorkspaceTaskTab) => { + if (reopeningKey !== null) return; + setReopeningKey(tab.key); + void reopenTab(tab).finally(() => setReopeningKey(null)); + }; + + return ( + + + + ); +} diff --git a/apps/web/src/components/settings/BetaSettingsPanel.tsx b/apps/web/src/components/settings/BetaSettingsPanel.tsx index 942bd374d91..5a5af3d7804 100644 --- a/apps/web/src/components/settings/BetaSettingsPanel.tsx +++ b/apps/web/src/components/settings/BetaSettingsPanel.tsx @@ -62,7 +62,7 @@ export function BetaSettingsPanel() { { expect(draftByKey(draftId)).toBeUndefined(); }); + it("retains sibling task-tab drafts and their fork metadata when remapping the project", () => { + const store = useComposerDraftStore.getState(); + const workspaceTaskId = WorkspaceTaskId.make("task-a"); + store.setProjectDraftThreadId(projectRef, draftId, { + threadId, + workspaceTaskId, + tabPosition: 0, + retainPreviousDraft: true, + }); + store.setPrompt(draftId, "main tab draft"); + + store.setProjectDraftThreadId(projectRef, otherDraftId, { + threadId: otherThreadId, + workspaceTaskId, + tabPosition: 1, + forkProvenance: { + mode: "portable", + sourceThreadId: threadId, + createdAt: "2026-07-24T00:00:00.000Z", + }, + retainPreviousDraft: true, + }); + store.setPrompt(otherDraftId, "fork tab draft"); + + // Other call sites remap an already-existing draft without passing the + // creation-only retention option. Task identity itself must keep the + // sibling registered and preserve its unsent composer. + store.setProjectDraftThreadId(projectRef, draftId, { threadId }); + + expect(useComposerDraftStore.getState().getDraftThread(draftId)).toMatchObject({ + threadId, + workspaceTaskId, + tabPosition: 0, + }); + expect(draftByKey(draftId)?.prompt).toBe("main tab draft"); + expect(useComposerDraftStore.getState().getDraftThread(otherDraftId)).toMatchObject({ + threadId: otherThreadId, + workspaceTaskId, + tabPosition: 1, + forkProvenance: { + mode: "portable", + sourceThreadId: threadId, + }, + }); + expect(draftByKey(otherDraftId)?.prompt).toBe("fork tab draft"); + }); + + it("soft-closes and reopens draft tabs without losing composer state", () => { + const store = useComposerDraftStore.getState(); + const closedAt = "2026-07-27T12:00:00.000Z"; + store.setProjectDraftThreadId(projectRef, draftId, { + threadId, + workspaceTaskId: WorkspaceTaskId.make("task-a"), + tabPosition: 1, + }); + store.setPrompt(draftId, "keep this unsent"); + + store.setDraftThreadContext(draftId, { tabClosedAt: closedAt }); + + expect(useComposerDraftStore.getState().getDraftThread(draftId)?.tabClosedAt).toBe(closedAt); + expect(draftByKey(draftId)?.prompt).toBe("keep this unsent"); + + const persistApi = useComposerDraftStore.persist as unknown as { + getOptions: () => { + partialize: (state: ReturnType) => unknown; + }; + }; + const persistedState = persistApi.getOptions().partialize(useComposerDraftStore.getState()) as { + draftThreadsByThreadKey?: Record; + }; + expect(persistedState.draftThreadsByThreadKey?.[draftId]?.tabClosedAt).toBe(closedAt); + + store.setDraftThreadContext(draftId, { tabClosedAt: null }); + expect(useComposerDraftStore.getState().getDraftThread(draftId)?.tabClosedAt).toBeNull(); + expect(draftByKey(draftId)?.prompt).toBe("keep this unsent"); + }); + + it("retains a closed draft when its project mapping moves to another task", () => { + const store = useComposerDraftStore.getState(); + store.setProjectDraftThreadId(projectRef, draftId, { + threadId, + workspaceTaskId: WorkspaceTaskId.make("task-a"), + tabPosition: 1, + }); + store.setPrompt(draftId, "restore me later"); + store.setDraftThreadContext(draftId, { tabClosedAt: "2026-07-27T12:00:00.000Z" }); + + store.setProjectDraftThreadId(projectRef, otherDraftId, { + threadId: otherThreadId, + workspaceTaskId: WorkspaceTaskId.make("task-b"), + }); + + expect(useComposerDraftStore.getState().getDraftThread(draftId)?.tabClosedAt).not.toBeNull(); + expect(draftByKey(draftId)?.prompt).toBe("restore me later"); + }); + it("keeps composer drafts when the thread is still mapped by another project", () => { const store = useComposerDraftStore.getState(); store.setProjectDraftThreadId(projectRef, draftId, { threadId }); diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index d4f38adcacd..24c38e1550e 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -15,7 +15,9 @@ import { type ServerProvider, type ScopedProjectRef, type ScopedThreadRef, + ThreadForkProvenance, ThreadId, + WorkspaceTaskId, } from "@t3tools/contracts"; import { parseScopedProjectKey, @@ -55,6 +57,7 @@ import { ReviewCommentContextSchema, type ReviewCommentContext } from "./reviewC const isRuntimeMode = Schema.is(RuntimeMode); const isProviderDriverKind = Schema.is(ProviderDriverKind); const isReviewCommentContext = Schema.is(ReviewCommentContextSchema); +const isThreadForkProvenance = Schema.is(ThreadForkProvenance); export const COMPOSER_DRAFT_STORAGE_KEY = "t3code:composer-drafts:v1"; const COMPOSER_DRAFT_STORAGE_VERSION = 8; @@ -206,6 +209,11 @@ type LegacyPersistedComposerDraftStoreState = PersistedComposerDraftStoreState & const PersistedDraftThreadState = Schema.Struct({ threadId: ThreadId, + workspaceTaskId: Schema.optionalKey(WorkspaceTaskId), + tabLabel: Schema.optionalKey(Schema.NullOr(Schema.String)), + tabPosition: Schema.optionalKey(Schema.Number), + forkProvenance: Schema.optionalKey(Schema.NullOr(ThreadForkProvenance)), + tabClosedAt: Schema.optionalKey(Schema.NullOr(Schema.String)), environmentId: Schema.String, projectId: ProjectId, logicalProjectKey: Schema.optionalKey(Schema.String), @@ -285,6 +293,11 @@ export interface ComposerThreadDraftState { */ export interface DraftSessionState { threadId: ThreadId; + workspaceTaskId?: WorkspaceTaskId | undefined; + tabLabel?: string | null | undefined; + tabPosition?: number | undefined; + forkProvenance?: ThreadForkProvenance | null | undefined; + tabClosedAt: string | null; environmentId: EnvironmentId; projectId: ProjectId; logicalProjectKey: string; @@ -359,6 +372,12 @@ interface ComposerDraftStoreState { startFromOrigin?: boolean; runtimeMode?: RuntimeMode; interactionMode?: ProviderInteractionMode; + workspaceTaskId?: WorkspaceTaskId; + tabLabel?: string | null; + tabPosition?: number; + forkProvenance?: ThreadForkProvenance | null; + tabClosedAt?: string | null; + retainPreviousDraft?: boolean; }, ) => void; /** Creates or updates the draft session tracked for a concrete project ref. */ @@ -374,6 +393,12 @@ interface ComposerDraftStoreState { startFromOrigin?: boolean; runtimeMode?: RuntimeMode; interactionMode?: ProviderInteractionMode; + workspaceTaskId?: WorkspaceTaskId; + tabLabel?: string | null; + tabPosition?: number; + forkProvenance?: ThreadForkProvenance | null; + tabClosedAt?: string | null; + retainPreviousDraft?: boolean; }, ) => void; /** Updates mutable draft-session metadata without touching composer content. */ @@ -388,6 +413,7 @@ interface ComposerDraftStoreState { startFromOrigin?: boolean; runtimeMode?: RuntimeMode; interactionMode?: ProviderInteractionMode; + tabClosedAt?: string | null; }, ) => void; clearProjectDraftThreadId: (projectRef: ScopedProjectRef) => void; @@ -1331,6 +1357,12 @@ function createDraftThreadState( startFromOrigin?: boolean; runtimeMode?: RuntimeMode; interactionMode?: ProviderInteractionMode; + workspaceTaskId?: WorkspaceTaskId; + tabLabel?: string | null; + tabPosition?: number; + forkProvenance?: ThreadForkProvenance | null; + tabClosedAt?: string | null; + retainPreviousDraft?: boolean; }, ): DraftThreadState { const projectChanged = @@ -1357,6 +1389,14 @@ function createDraftThreadState( : options.startFromOrigin; return { threadId, + workspaceTaskId: options?.workspaceTaskId ?? existingThread?.workspaceTaskId, + tabLabel: options?.tabLabel ?? existingThread?.tabLabel ?? null, + tabPosition: options?.tabPosition ?? existingThread?.tabPosition ?? 0, + forkProvenance: options?.forkProvenance ?? existingThread?.forkProvenance ?? null, + tabClosedAt: + options?.tabClosedAt === undefined + ? (existingThread?.tabClosedAt ?? null) + : options.tabClosedAt, environmentId: projectRef.environmentId, projectId: projectRef.projectId, logicalProjectKey, @@ -1396,6 +1436,11 @@ function draftThreadsEqual(left: DraftThreadState | undefined, right: DraftThrea return ( !!left && left.threadId === right.threadId && + left.workspaceTaskId === right.workspaceTaskId && + left.tabLabel === right.tabLabel && + left.tabPosition === right.tabPosition && + Equal.equals(left.forkProvenance, right.forkProvenance) && + left.tabClosedAt === right.tabClosedAt && left.environmentId === right.environmentId && left.projectId === right.projectId && left.logicalProjectKey === right.logicalProjectKey && @@ -1524,6 +1569,26 @@ function normalizePersistedDraftThreads( const normalizedEnvironmentId = environmentId as EnvironmentId; draftThreadsByThreadKey[threadKey] = { threadId, + ...(typeof candidateDraftThread.workspaceTaskId === "string" && + candidateDraftThread.workspaceTaskId.length > 0 + ? { + workspaceTaskId: WorkspaceTaskId.make(candidateDraftThread.workspaceTaskId), + } + : {}), + ...(typeof candidateDraftThread.tabLabel === "string" + ? { tabLabel: candidateDraftThread.tabLabel } + : {}), + ...(typeof candidateDraftThread.tabPosition === "number" && + Number.isInteger(candidateDraftThread.tabPosition) && + candidateDraftThread.tabPosition >= 0 + ? { tabPosition: candidateDraftThread.tabPosition } + : {}), + ...(isThreadForkProvenance(candidateDraftThread.forkProvenance) + ? { forkProvenance: candidateDraftThread.forkProvenance } + : {}), + ...(typeof candidateDraftThread.tabClosedAt === "string" + ? { tabClosedAt: candidateDraftThread.tabClosedAt } + : {}), environmentId: normalizedEnvironmentId, projectId: projectId as ProjectId, logicalProjectKey: @@ -1595,6 +1660,7 @@ function normalizePersistedDraftThreads( worktreePath: null, envMode: "local", startFromOrigin: false, + tabClosedAt: null, promotedTo: null, }; } else if ( @@ -1907,9 +1973,33 @@ function partializeComposerDraftStoreState( }; persistedDraftsByThreadKey[threadKey] = persistedDraft; } + const persistedDraftThreadsByThreadKey = Object.fromEntries( + Object.entries(state.draftThreadsByThreadKey).map(([draftId, draft]) => [ + draftId, + { + threadId: draft.threadId, + ...(draft.workspaceTaskId !== undefined ? { workspaceTaskId: draft.workspaceTaskId } : {}), + ...(draft.tabLabel !== undefined ? { tabLabel: draft.tabLabel } : {}), + ...(draft.tabPosition !== undefined ? { tabPosition: draft.tabPosition } : {}), + ...(draft.forkProvenance !== undefined ? { forkProvenance: draft.forkProvenance } : {}), + ...(draft.tabClosedAt !== null ? { tabClosedAt: draft.tabClosedAt } : {}), + environmentId: draft.environmentId, + projectId: draft.projectId, + logicalProjectKey: draft.logicalProjectKey, + createdAt: draft.createdAt, + runtimeMode: draft.runtimeMode, + interactionMode: draft.interactionMode, + branch: draft.branch, + worktreePath: draft.worktreePath, + envMode: draft.envMode, + startFromOrigin: draft.startFromOrigin, + ...(draft.promotedTo !== undefined ? { promotedTo: draft.promotedTo } : {}), + }, + ]), + ) as PersistedComposerDraftStoreState["draftThreadsByThreadKey"]; return { draftsByThreadKey: persistedDraftsByThreadKey, - draftThreadsByThreadKey: state.draftThreadsByThreadKey, + draftThreadsByThreadKey: persistedDraftThreadsByThreadKey, logicalProjectDraftThreadKeyByLogicalProjectKey: state.logicalProjectDraftThreadKeyByLogicalProjectKey, stickyModelSelectionByProvider: compactModelSelectionByProvider( @@ -2149,6 +2239,11 @@ function toHydratedDraftThreadState( ): DraftThreadState { return { threadId: persistedDraftThread.threadId, + workspaceTaskId: persistedDraftThread.workspaceTaskId, + tabLabel: persistedDraftThread.tabLabel ?? null, + tabPosition: persistedDraftThread.tabPosition ?? 0, + forkProvenance: persistedDraftThread.forkProvenance ?? null, + tabClosedAt: persistedDraftThread.tabClosedAt ?? null, environmentId: persistedDraftThread.environmentId as EnvironmentId, projectId: persistedDraftThread.projectId, logicalProjectKey: @@ -2285,9 +2380,17 @@ const composerDraftStore = create()( previousThreadKeyForLogicalProject === undefined ? undefined : nextDraftThreadsByThreadKey[previousThreadKeyForLogicalProject]; + const sharesWorkspaceTask = + previousDraftThread?.workspaceTaskId !== undefined && + nextDraftThread.workspaceTaskId !== undefined && + previousDraftThread.workspaceTaskId === nextDraftThread.workspaceTaskId; + const previousDraftIsClosed = previousDraftThread?.tabClosedAt != null; if ( previousThreadKeyForLogicalProject && previousThreadKeyForLogicalProject !== draftId && + options?.retainPreviousDraft !== true && + !sharesWorkspaceTask && + !previousDraftIsClosed && !isComposerThreadKeyInUse( nextLogicalProjectDraftThreadKeyByLogicalProjectKey, previousThreadKeyForLogicalProject, @@ -2359,6 +2462,12 @@ const composerDraftStore = create()( : options.startFromOrigin; const nextDraftThread: DraftThreadState = { threadId: existing.threadId, + workspaceTaskId: existing.workspaceTaskId, + tabLabel: existing.tabLabel, + tabPosition: existing.tabPosition, + forkProvenance: existing.forkProvenance, + tabClosedAt: + options.tabClosedAt === undefined ? existing.tabClosedAt : options.tabClosedAt, environmentId: nextProjectRef.environmentId, projectId: nextProjectRef.projectId, logicalProjectKey: existing.logicalProjectKey, @@ -2387,6 +2496,7 @@ const composerDraftStore = create()( nextDraftThread.createdAt === existing.createdAt && nextDraftThread.runtimeMode === existing.runtimeMode && nextDraftThread.interactionMode === existing.interactionMode && + nextDraftThread.tabClosedAt === existing.tabClosedAt && nextDraftThread.branch === existing.branch && nextDraftThread.worktreePath === existing.worktreePath && nextDraftThread.envMode === existing.envMode && diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts index 2479d6ba02f..011b7abce49 100644 --- a/apps/web/src/hooks/useHandleNewThread.ts +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -4,7 +4,7 @@ import { scopeProjectRef, scopeThreadRef, } from "@t3tools/client-runtime/environment"; -import { DEFAULT_RUNTIME_MODE, type ScopedProjectRef } from "@t3tools/contracts"; +import { DEFAULT_RUNTIME_MODE, type ScopedProjectRef, WorkspaceTaskId } from "@t3tools/contracts"; import { useParams, useRouter } from "@tanstack/react-router"; import { useCallback, useMemo } from "react"; import { @@ -249,6 +249,7 @@ export function useNewThreadHandler() { return (async () => { setLogicalProjectDraftThreadId(logicalProjectKey, projectRef, draftId, { threadId, + workspaceTaskId: WorkspaceTaskId.make(String(threadId)), createdAt, branch: options?.branch ?? null, worktreePath: options?.worktreePath ?? null, diff --git a/apps/web/src/hooks/useWorkspaceTaskTabs.ts b/apps/web/src/hooks/useWorkspaceTaskTabs.ts new file mode 100644 index 00000000000..a835b03262b --- /dev/null +++ b/apps/web/src/hooks/useWorkspaceTaskTabs.ts @@ -0,0 +1,465 @@ +import { useAtomValue } from "@effect/atom-react"; +import { + scopedProjectKey, + scopedThreadKey, + scopeProjectRef, + scopeThreadRef, +} from "@t3tools/client-runtime/environment"; +import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; +import { + type ModelSelection, + type ScopedThreadRef, + type ThreadForkProvenance, + ThreadId, + ProviderInstanceId, + WorkspaceTaskId, +} from "@t3tools/contracts"; +import { useParams, useRouter } from "@tanstack/react-router"; +import { useCallback, useMemo, useRef } from "react"; + +import { DraftId, type DraftSessionState, useComposerDraftStore } from "../composerDraftStore"; +import { + deriveLogicalProjectKeyFromSettings, + selectProjectGroupingSettings, +} from "../logicalProject"; +import { refreshClosedTaskTabsForEnvironment, useClosedTaskTabs } from "../lib/closedTaskTabsState"; +import { newDraftId, newThreadId } from "../lib/utils"; +import { resolveWorkspaceTaskId } from "../lib/workspaceTasks"; +import { primaryServerKeybindingsAtom } from "../state/server"; +import { threadEnvironment } from "../state/threads"; +import { readThreadShell, useProjects, useServerConfigs, useThreadShells } from "../state/entities"; +import { useAtomCommand } from "../state/use-atom-command"; +import { buildThreadRouteParams, resolveThreadRouteTarget } from "../threadRoutes"; +import { useClientSettings } from "./useSettings"; + +export type WorkspaceTaskTab = + | { + readonly kind: "server"; + readonly key: string; + readonly threadRef: ScopedThreadRef; + readonly title: string; + readonly modelSelection: ModelSelection; + readonly position: number; + readonly active: boolean; + readonly working: boolean; + readonly blocked: boolean; + readonly forkProvenance: ThreadForkProvenance | null; + } + | { + readonly kind: "draft"; + readonly key: string; + readonly draftId: DraftId; + readonly threadId: ThreadId; + readonly title: string; + readonly modelSelection: ModelSelection | null; + readonly position: number; + readonly active: boolean; + readonly working: false; + readonly blocked: false; + readonly forkProvenance: ThreadForkProvenance | null; + }; + +export type ClosedWorkspaceTaskTab = + | { + readonly kind: "server"; + readonly key: string; + readonly threadRef: ScopedThreadRef; + readonly title: string; + readonly position: number; + readonly forkProvenance: ThreadForkProvenance | null; + readonly closedAt: string; + } + | { + readonly kind: "draft"; + readonly key: string; + readonly draftId: DraftId; + readonly threadId: ThreadId; + readonly title: string; + readonly position: number; + readonly forkProvenance: ThreadForkProvenance | null; + readonly closedAt: string; + }; + +interface CurrentTaskContext { + readonly environmentId: EnvironmentThreadShell["environmentId"]; + readonly projectId: EnvironmentThreadShell["projectId"]; + readonly threadId: ThreadId; + readonly workspaceTaskId: WorkspaceTaskId; + readonly branch: string | null; + readonly worktreePath: string | null; + readonly runtimeMode: EnvironmentThreadShell["runtimeMode"]; + readonly interactionMode: EnvironmentThreadShell["interactionMode"]; + readonly modelSelection: ModelSelection; + readonly logicalProjectKey: string; + readonly sourceIsServer: boolean; +} + +function draftTaskId(draft: DraftSessionState): WorkspaceTaskId { + return draft.workspaceTaskId ?? WorkspaceTaskId.make(String(draft.threadId)); +} + +async function waitForThreadShellState( + threadRef: ScopedThreadRef, + expectedToExist: boolean, +): Promise { + const deadline = Date.now() + 3_000; + while ((readThreadShell(threadRef) !== null) !== expectedToExist && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 50)); + } +} + +export function useWorkspaceTaskTabs() { + const router = useRouter(); + const creatingTabRef = useRef(false); + const routeTarget = useParams({ + strict: false, + select: (params) => resolveThreadRouteTarget(params), + }); + const threads = useThreadShells(); + const projects = useProjects(); + const serverConfigs = useServerConfigs(); + const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); + const draftSessions = useComposerDraftStore((state) => state.draftThreadsByThreadKey); + const keybindings = useAtomValue(primaryServerKeybindingsAtom); + const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, { + reportFailure: false, + }); + + const currentServerThread = + routeTarget?.kind === "server" + ? (threads.find( + (thread) => + thread.environmentId === routeTarget.threadRef.environmentId && + thread.id === routeTarget.threadRef.threadId, + ) ?? null) + : null; + const currentDraft = + routeTarget?.kind === "draft" ? (draftSessions[routeTarget.draftId] ?? null) : null; + const currentProject = currentServerThread + ? (projects.find( + (project) => + project.environmentId === currentServerThread.environmentId && + project.id === currentServerThread.projectId, + ) ?? null) + : currentDraft + ? (projects.find( + (project) => + project.environmentId === currentDraft.environmentId && + project.id === currentDraft.projectId, + ) ?? null) + : null; + + const currentContext: CurrentTaskContext | null = currentServerThread + ? { + environmentId: currentServerThread.environmentId, + projectId: currentServerThread.projectId, + threadId: currentServerThread.id, + workspaceTaskId: resolveWorkspaceTaskId(currentServerThread), + branch: currentServerThread.branch, + worktreePath: currentServerThread.worktreePath, + runtimeMode: currentServerThread.runtimeMode, + interactionMode: currentServerThread.interactionMode, + modelSelection: currentServerThread.modelSelection, + logicalProjectKey: currentProject + ? deriveLogicalProjectKeyFromSettings(currentProject, projectGroupingSettings) + : scopedProjectKey( + scopeProjectRef(currentServerThread.environmentId, currentServerThread.projectId), + ), + sourceIsServer: true, + } + : currentDraft && currentProject + ? { + environmentId: currentDraft.environmentId, + projectId: currentDraft.projectId, + threadId: currentDraft.threadId, + workspaceTaskId: draftTaskId(currentDraft), + branch: currentDraft.branch, + worktreePath: currentDraft.worktreePath, + runtimeMode: currentDraft.runtimeMode, + interactionMode: currentDraft.interactionMode, + modelSelection: currentProject.defaultModelSelection ?? { + instanceId: ProviderInstanceId.make("codex"), + model: "default", + }, + logicalProjectKey: currentDraft.logicalProjectKey, + sourceIsServer: false, + } + : null; + const supportsTaskTabs = + currentContext !== null && + serverConfigs.get(currentContext.environmentId)?.environment.capabilities.workspaceTaskTabs === + true; + const closedServerTaskTabs = useClosedTaskTabs( + supportsTaskTabs ? currentContext.environmentId : null, + ); + + const tabs = useMemo>(() => { + if (currentContext === null) return []; + const serverTabs: WorkspaceTaskTab[] = threads.flatMap((thread) => { + if ( + thread.environmentId !== currentContext.environmentId || + resolveWorkspaceTaskId(thread) !== currentContext.workspaceTaskId || + thread.tabClosedAt != null + ) { + return []; + } + const threadRef = scopeThreadRef(thread.environmentId, thread.id); + return [ + { + kind: "server", + key: scopedThreadKey(threadRef), + threadRef, + title: thread.tabLabel ?? (thread.tabPosition === 0 ? "Main" : thread.title), + modelSelection: thread.modelSelection, + position: thread.tabPosition ?? 0, + active: + routeTarget?.kind === "server" && + routeTarget.threadRef.environmentId === thread.environmentId && + routeTarget.threadRef.threadId === thread.id, + working: thread.session?.status === "running" || thread.session?.status === "starting", + blocked: thread.hasPendingApprovals || thread.hasPendingUserInput, + forkProvenance: thread.forkProvenance ?? null, + }, + ]; + }); + const draftTabs: WorkspaceTaskTab[] = Object.entries(draftSessions).flatMap( + ([rawDraftId, draft]) => { + if ( + draft.environmentId !== currentContext.environmentId || + draftTaskId(draft) !== currentContext.workspaceTaskId || + draft.tabClosedAt != null || + draft.promotedTo != null + ) { + return []; + } + const draftId = DraftId.make(rawDraftId); + const composer = useComposerDraftStore.getState().getComposerDraft(draftId); + const activeProvider = composer?.activeProvider ?? null; + const modelSelection = activeProvider + ? (composer?.modelSelectionByProvider[activeProvider] ?? null) + : null; + return [ + { + kind: "draft", + key: `draft:${draftId}`, + draftId, + threadId: draft.threadId, + title: draft.tabLabel ?? `Tab ${(draft.tabPosition ?? 0) + 1}`, + modelSelection, + position: draft.tabPosition ?? 0, + active: routeTarget?.kind === "draft" && routeTarget.draftId === draftId, + working: false, + blocked: false, + forkProvenance: draft.forkProvenance ?? null, + }, + ]; + }, + ); + return [...serverTabs, ...draftTabs].toSorted( + (left, right) => left.position - right.position || left.key.localeCompare(right.key), + ); + }, [currentContext, draftSessions, routeTarget, threads]); + + const closedTabs = useMemo>(() => { + if (currentContext === null) return []; + const serverTabs: ClosedWorkspaceTaskTab[] = closedServerTaskTabs.flatMap((thread) => { + if ( + resolveWorkspaceTaskId(thread) !== currentContext.workspaceTaskId || + thread.tabClosedAt == null + ) { + return []; + } + const threadRef = scopeThreadRef(currentContext.environmentId, thread.id); + return [ + { + kind: "server", + key: scopedThreadKey(threadRef), + threadRef, + title: thread.tabLabel ?? (thread.tabPosition === 0 ? "Main" : thread.title), + position: thread.tabPosition ?? 0, + forkProvenance: thread.forkProvenance ?? null, + closedAt: thread.tabClosedAt, + }, + ]; + }); + const draftTabs: ClosedWorkspaceTaskTab[] = Object.entries(draftSessions).flatMap( + ([rawDraftId, draft]) => { + if ( + draft.environmentId !== currentContext.environmentId || + draftTaskId(draft) !== currentContext.workspaceTaskId || + draft.tabClosedAt == null || + draft.promotedTo != null + ) { + return []; + } + const draftId = DraftId.make(rawDraftId); + return [ + { + kind: "draft", + key: `draft:${draftId}`, + draftId, + threadId: draft.threadId, + title: draft.tabLabel ?? `Tab ${(draft.tabPosition ?? 0) + 1}`, + position: draft.tabPosition ?? 0, + forkProvenance: draft.forkProvenance ?? null, + closedAt: draft.tabClosedAt, + }, + ]; + }, + ); + return [...serverTabs, ...draftTabs].toSorted( + (left, right) => + right.closedAt.localeCompare(left.closedAt) || + left.position - right.position || + left.key.localeCompare(right.key), + ); + }, [closedServerTaskTabs, currentContext, draftSessions]); + + const navigateToTab = useCallback( + (tab: WorkspaceTaskTab | ClosedWorkspaceTaskTab) => { + if (tab.kind === "server") { + return router.navigate({ + to: "/$environmentId/$threadId", + params: buildThreadRouteParams(tab.threadRef), + }); + } + return router.navigate({ + to: "/draft/$draftId", + params: { draftId: tab.draftId }, + }); + }, + [router], + ); + + const createTab = useCallback( + async (mode: "fresh" | "portable" = "fresh") => { + if (currentContext === null || !supportsTaskTabs) return; + if (mode === "portable" && !currentContext.sourceIsServer) return; + if (creatingTabRef.current) return; + creatingTabRef.current = true; + + try { + const draftId = newDraftId(); + const threadId = newThreadId(); + const createdAt = new Date().toISOString(); + const position = + [...tabs, ...closedTabs].reduce((max, tab) => Math.max(max, tab.position), -1) + 1; + const forkProvenance: ThreadForkProvenance = { + mode, + sourceThreadId: mode === "portable" ? currentContext.threadId : null, + createdAt, + }; + const store = useComposerDraftStore.getState(); + const currentComposer = + routeTarget?.kind === "server" + ? store.getComposerDraft(routeTarget.threadRef) + : routeTarget?.kind === "draft" + ? store.getComposerDraft(routeTarget.draftId) + : null; + const activeProvider = currentComposer?.activeProvider ?? null; + const carriedModelSelection = + (activeProvider + ? currentComposer?.modelSelectionByProvider[activeProvider] + : undefined) ?? currentContext.modelSelection; + + store.setLogicalProjectDraftThreadId( + currentContext.logicalProjectKey, + scopeProjectRef(currentContext.environmentId, currentContext.projectId), + draftId, + { + threadId, + workspaceTaskId: currentContext.workspaceTaskId, + tabPosition: position, + tabLabel: null, + forkProvenance, + createdAt, + branch: currentContext.branch, + worktreePath: currentContext.worktreePath, + envMode: currentContext.worktreePath ? "worktree" : "local", + startFromOrigin: false, + runtimeMode: currentContext.runtimeMode, + interactionMode: currentContext.interactionMode, + retainPreviousDraft: true, + }, + ); + store.applyStickyState(draftId); + store.setModelSelection(draftId, carriedModelSelection, { replaceOptions: true }); + + await router.navigate({ + to: "/draft/$draftId", + params: { draftId }, + }); + } finally { + creatingTabRef.current = false; + } + }, + [closedTabs, currentContext, routeTarget, router, supportsTaskTabs, tabs], + ); + + const closeTab = useCallback( + async (tab: WorkspaceTaskTab) => { + if (tabs.length <= 1 || tab.working || tab.blocked) return; + const index = tabs.findIndex((candidate) => candidate.key === tab.key); + const fallback = tabs[index + 1] ?? tabs[index - 1] ?? null; + if (tab.kind === "draft") { + useComposerDraftStore + .getState() + .setDraftThreadContext(tab.draftId, { tabClosedAt: new Date().toISOString() }); + } else { + const result = await updateThreadMetadata({ + environmentId: tab.threadRef.environmentId, + input: { + threadId: tab.threadRef.threadId, + tabClosedAt: new Date().toISOString(), + }, + }); + if (result._tag === "Failure") return; + await waitForThreadShellState(tab.threadRef, false); + refreshClosedTaskTabsForEnvironment(tab.threadRef.environmentId); + } + if (tab.active && fallback) { + await navigateToTab(fallback); + } + }, + [navigateToTab, tabs, updateThreadMetadata], + ); + + const reopenTab = useCallback( + async (tab: ClosedWorkspaceTaskTab) => { + if (tab.kind === "draft") { + useComposerDraftStore.getState().setDraftThreadContext(tab.draftId, { tabClosedAt: null }); + } else { + const result = await updateThreadMetadata({ + environmentId: tab.threadRef.environmentId, + input: { + threadId: tab.threadRef.threadId, + tabClosedAt: null, + }, + }); + if (result._tag === "Failure") return; + await waitForThreadShellState(tab.threadRef, true); + refreshClosedTaskTabsForEnvironment(tab.threadRef.environmentId); + } + await navigateToTab(tab); + }, + [navigateToTab, updateThreadMetadata], + ); + + const reopenLastClosedTab = useCallback(async () => { + const lastClosed = closedTabs[0]; + if (lastClosed) await reopenTab(lastClosed); + }, [closedTabs, reopenTab]); + + return { + tabs, + closedTabs, + keybindings, + hasTask: currentContext !== null && supportsTaskTabs, + canForkContext: currentContext?.sourceIsServer === true && supportsTaskTabs, + createTab, + closeTab, + reopenTab, + reopenLastClosedTab, + navigateToTab, + } as const; +} diff --git a/apps/web/src/keybindings.test.ts b/apps/web/src/keybindings.test.ts index c0d326edd55..3f5729216c4 100644 --- a/apps/web/src/keybindings.test.ts +++ b/apps/web/src/keybindings.test.ts @@ -124,6 +124,7 @@ const DEFAULT_BINDINGS = compile([ whenAst: whenNot(whenIdentifier("terminalFocus")), }, { shortcut: modShortcut("o", { shiftKey: true }), command: "chat.new" }, + { shortcut: modShortcut("t", { shiftKey: true }), command: "chat.reopenClosedTab" }, { shortcut: modShortcut("n", { shiftKey: true }), command: "chat.newLocal" }, { shortcut: modShortcut("o"), command: "editor.openFavorite" }, { shortcut: modShortcut("[", { shiftKey: true }), command: "thread.previous" }, @@ -318,6 +319,10 @@ describe("shortcutLabelForCommand", () => { "⌘B", ); assert.strictEqual(shortcutLabelForCommand(DEFAULT_BINDINGS, "chat.new", "MacIntel"), "⇧⌘O"); + assert.strictEqual( + shortcutLabelForCommand(DEFAULT_BINDINGS, "chat.reopenClosedTab", "MacIntel"), + "⇧⌘T", + ); assert.strictEqual(shortcutLabelForCommand(DEFAULT_BINDINGS, "diff.toggle", "Linux"), "Ctrl+D"); assert.strictEqual( shortcutLabelForCommand(DEFAULT_BINDINGS, "rightPanel.toggle", "MacIntel"), diff --git a/apps/web/src/lib/closedTaskTabsState.ts b/apps/web/src/lib/closedTaskTabsState.ts new file mode 100644 index 00000000000..fc2fff26fd4 --- /dev/null +++ b/apps/web/src/lib/closedTaskTabsState.ts @@ -0,0 +1,32 @@ +import { useAtomValue } from "@effect/atom-react"; +import type { EnvironmentId, OrchestrationThreadShell } from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import { AsyncResult, Atom } from "effect/unstable/reactivity"; + +import { appAtomRegistry } from "../rpc/atomRegistry"; +import { orchestrationEnvironment } from "../state/orchestration"; + +const EMPTY_CLOSED_TASK_TABS: ReadonlyArray = Object.freeze([]); +const EMPTY_CLOSED_TASK_TABS_ATOM = Atom.make(AsyncResult.success(EMPTY_CLOSED_TASK_TABS)).pipe( + Atom.withLabel("web:closed-task-tabs:empty"), +); + +function closedTaskTabsAtom(environmentId: EnvironmentId) { + return orchestrationEnvironment.closedTaskTabs({ + environmentId, + input: {}, + }); +} + +export function refreshClosedTaskTabsForEnvironment(environmentId: EnvironmentId): void { + appAtomRegistry.refresh(closedTaskTabsAtom(environmentId)); +} + +export function useClosedTaskTabs( + environmentId: EnvironmentId | null, +): ReadonlyArray { + const result = useAtomValue( + environmentId === null ? EMPTY_CLOSED_TASK_TABS_ATOM : closedTaskTabsAtom(environmentId), + ); + return Option.getOrElse(AsyncResult.value(result), () => EMPTY_CLOSED_TASK_TABS); +} diff --git a/apps/web/src/lib/taskTabContext.test.ts b/apps/web/src/lib/taskTabContext.test.ts new file mode 100644 index 00000000000..d9dc851a289 --- /dev/null +++ b/apps/web/src/lib/taskTabContext.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { + describeTaskTabContexts, + taskTabDisplayTitle, + type TaskTabContextSource, +} from "./taskTabContext"; + +function tab( + overrides: Partial & { threadId: string }, +): TaskTabContextSource { + return { + kind: "server", + title: overrides.threadId, + position: 0, + forkProvenance: null, + ...overrides, + }; +} + +describe("task tab context descriptors", () => { + it("labels the first tab Main and gives it its own context", () => { + const [main] = describeTaskTabContexts([tab({ threadId: "root", title: "Tab 1" })]); + expect(main?.title).toBe("Main"); + expect(main?.kind).toBe("main"); + expect(main?.label).toBe("Independent"); + expect(main?.detail).toBe("Separate chat history."); + }); + + it("keeps an explicit tab label on the first tab", () => { + expect(taskTabDisplayTitle(tab({ threadId: "root", title: "Reviewer" }))).toBe("Reviewer"); + }); + + it("describes an unsent draft tab as starting empty", () => { + const [, draft] = describeTaskTabContexts([ + tab({ threadId: "root", title: "Main" }), + tab({ + threadId: "draft-thread", + kind: "draft", + title: "Tab 2", + position: 1, + forkProvenance: { mode: "fresh", sourceThreadId: null }, + }), + ]); + expect(draft?.kind).toBe("empty"); + expect(draft?.label).toBe("Empty"); + expect(draft?.detail).toBe("No chat history."); + }); + + it("describes a started sibling tab as independent, not shared", () => { + const [, started] = describeTaskTabContexts([ + tab({ threadId: "root", title: "Main" }), + tab({ + threadId: "second", + title: "Tab 2", + position: 1, + forkProvenance: { mode: "fresh", sourceThreadId: null }, + }), + ]); + expect(started?.kind).toBe("own"); + expect(started?.label).toBe("Independent"); + expect(started?.detail).toBe("Separate chat history."); + }); + + it("resolves a portable fork's source to the sibling tab label", () => { + const [, fork] = describeTaskTabContexts([ + tab({ threadId: "root", title: "Tab 1" }), + tab({ + threadId: "fork", + kind: "draft", + title: "Tab 2", + position: 1, + forkProvenance: { mode: "portable", sourceThreadId: "root" }, + }), + ]); + expect(fork?.kind).toBe("snapshot"); + expect(fork?.sourceTitle).toBe("Main"); + expect(fork?.label).toBe("From Main"); + expect(fork?.detail).toBe("Copies Main once on send."); + }); + + it("describes a started fork in the past tense once it owns a thread", () => { + const [, fork] = describeTaskTabContexts([ + tab({ threadId: "root", title: "Main" }), + tab({ + threadId: "fork", + title: "Retry", + position: 1, + forkProvenance: { mode: "portable", sourceThreadId: "root" }, + }), + ]); + expect(fork?.label).toBe("From Main"); + expect(fork?.detail).toBe("Started from Main; now independent."); + }); + + it("falls back to a neutral phrase when the source tab is gone", () => { + const [fork] = describeTaskTabContexts([ + tab({ + threadId: "fork", + title: "Retry", + position: 1, + forkProvenance: { mode: "portable", sourceThreadId: "closed-thread" }, + }), + ]); + expect(fork?.sourceTitle).toBeNull(); + expect(fork?.label).toBe("From other tab"); + }); + + it("never invents message counts", () => { + const descriptors = describeTaskTabContexts([ + tab({ threadId: "root", title: "Tab 1" }), + tab({ + threadId: "fork", + title: "Tab 2", + position: 1, + forkProvenance: { mode: "portable", sourceThreadId: "root" }, + }), + ]); + for (const descriptor of descriptors) { + expect(`${descriptor.label} ${descriptor.detail}`).not.toMatch(/\d/); + } + }); +}); diff --git a/apps/web/src/lib/taskTabContext.ts b/apps/web/src/lib/taskTabContext.ts new file mode 100644 index 00000000000..b53ecb83674 --- /dev/null +++ b/apps/web/src/lib/taskTabContext.ts @@ -0,0 +1,114 @@ +/** + * Honest, metadata-only descriptions of what conversation context a task tab + * has. Tabs in one workspace task share a project/worktree/branch but never a + * live conversation, so the copy here must never imply synced history: + * - Main and any started tab own their transcript outright. + * - A fresh tab is literally empty until its first message. + * - A portable fork receives one bounded snapshot of its source tab on the + * first provider call, then continues on its own. + * + * Nothing here counts messages or tokens — only shape is known client-side. + */ + +export type TaskTabContextKind = "main" | "empty" | "own" | "snapshot"; + +export interface TaskTabContextSource { + /** Server tabs have a real thread; drafts have not sent a message yet. */ + readonly kind: "server" | "draft"; + readonly threadId: string; + readonly title: string; + readonly position: number; + readonly forkProvenance: { + readonly mode: "fresh" | "portable"; + readonly sourceThreadId: string | null; + } | null; +} + +export interface TaskTabContextDescriptor { + readonly kind: TaskTabContextKind; + /** Tab name as rendered in the strip. */ + readonly title: string; + /** Short inline label, e.g. "From Main". */ + readonly label: string; + /** One sentence expanding the label, for tooltips. */ + readonly detail: string; + /** Resolved sibling tab title for portable forks, when still present. */ + readonly sourceTitle: string | null; +} + +const FALLBACK_SOURCE_TITLE = "other tab"; + +/** + * Mirrors the legacy strip fallback: the first tab reads as "Main" even when a + * draft was persisted before tab labels existed. + */ +export function taskTabDisplayTitle(tab: TaskTabContextSource): string { + if (tab.position === 0 && (tab.title === "Tab 1" || tab.title.trim() === "")) return "Main"; + return tab.title; +} + +function describeOne( + tab: TaskTabContextSource, + sourceTitle: string | null, +): TaskTabContextDescriptor { + const title = taskTabDisplayTitle(tab); + const base = { title, sourceTitle } as const; + + if (tab.forkProvenance?.mode === "portable") { + const from = sourceTitle ?? FALLBACK_SOURCE_TITLE; + return { + ...base, + kind: "snapshot", + label: `From ${from}`, + detail: + tab.kind === "draft" + ? `Copies ${from} once on send.` + : `Started from ${from}; now independent.`, + }; + } + + if (tab.kind === "draft") { + return { + ...base, + kind: "empty", + label: "Empty", + detail: "No chat history.", + }; + } + + if (tab.position === 0) { + return { + ...base, + kind: "main", + label: "Independent", + detail: "Separate chat history.", + }; + } + + return { + ...base, + kind: "own", + label: "Independent", + detail: "Separate chat history.", + }; +} + +/** + * Describes every tab in one pass so a portable fork can name its source by + * the label that source currently shows in the strip. A source that was closed + * (or lives outside this task) degrades to "another tab" rather than leaking a + * thread id. + */ +export function describeTaskTabContexts( + tabs: ReadonlyArray, +): ReadonlyArray { + const titleByThreadId = new Map(tabs.map((tab) => [tab.threadId, taskTabDisplayTitle(tab)])); + return tabs.map((tab) => { + const sourceThreadId = tab.forkProvenance?.sourceThreadId ?? null; + const sourceTitle = + sourceThreadId !== null && sourceThreadId !== tab.threadId + ? (titleByThreadId.get(sourceThreadId) ?? null) + : null; + return describeOne(tab, sourceTitle); + }); +} diff --git a/apps/web/src/lib/workspaceTasks.test.ts b/apps/web/src/lib/workspaceTasks.test.ts new file mode 100644 index 00000000000..da723112c0d --- /dev/null +++ b/apps/web/src/lib/workspaceTasks.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from "@effect/vitest"; +import { + EnvironmentId, + ProjectId, + ProviderInstanceId, + ThreadId, + WorkspaceTaskId, + type OrchestrationThreadShell, +} from "@t3tools/contracts"; +import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; + +import { groupThreadsByWorkspaceTask, taskPresentationThread } from "./workspaceTasks"; + +function makeThread( + id: string, + overrides: Partial = {}, +): EnvironmentThreadShell { + return { + id: ThreadId.make(id), + environmentId: EnvironmentId.make("local"), + projectId: ProjectId.make("project-1"), + title: id, + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: "main", + worktreePath: "/tmp/task", + latestTurn: null, + createdAt: "2026-07-24T00:00:00.000Z", + updatedAt: "2026-07-24T00:00:00.000Z", + archivedAt: null, + settledOverride: null, + settledAt: null, + snoozedUntil: null, + snoozedAt: null, + session: null, + latestUserMessageAt: null, + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, + ...overrides, + }; +} + +describe("workspace task grouping", () => { + it("backfills legacy shells to one-task/one-tab groups", () => { + const groups = groupThreadsByWorkspaceTask([makeThread("legacy")]); + expect(groups).toHaveLength(1); + expect(groups[0]?.id).toBe("legacy"); + }); + + it("orders sibling tabs and aggregates task status under the root title", () => { + const taskId = WorkspaceTaskId.make("task-1"); + const groups = groupThreadsByWorkspaceTask([ + makeThread("child", { + workspaceTaskId: taskId, + title: "Alternative", + tabPosition: 1, + hasPendingApprovals: true, + updatedAt: "2026-07-24T01:00:00.000Z", + }), + makeThread("root", { + workspaceTaskId: taskId, + title: "Task title", + tabPosition: 0, + }), + ]); + const group = groups[0]!; + const presentation = taskPresentationThread(group, null); + + expect(group.tabs.map((thread) => thread.id)).toEqual(["root", "child"]); + expect(presentation.title).toBe("Task title"); + expect(presentation.hasPendingApprovals).toBe(true); + expect(presentation.updatedAt).toBe("2026-07-24T01:00:00.000Z"); + }); +}); diff --git a/apps/web/src/lib/workspaceTasks.ts b/apps/web/src/lib/workspaceTasks.ts new file mode 100644 index 00000000000..2da567babf7 --- /dev/null +++ b/apps/web/src/lib/workspaceTasks.ts @@ -0,0 +1,129 @@ +import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; +import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { WorkspaceTaskId } from "@t3tools/contracts"; + +export interface WorkspaceTaskGroup { + readonly id: WorkspaceTaskId; + readonly key: string; + readonly tabs: ReadonlyArray; + readonly root: EnvironmentThreadShell; +} + +export function resolveWorkspaceTaskId( + thread: Pick, +): WorkspaceTaskId { + return thread.workspaceTaskId ?? WorkspaceTaskId.make(String(thread.id)); +} + +export function workspaceTaskKey( + thread: Pick, +): string { + return `${thread.environmentId}:${resolveWorkspaceTaskId(thread)}`; +} + +export function groupThreadsByWorkspaceTask( + threads: ReadonlyArray, +): ReadonlyArray { + const grouped = new Map(); + for (const thread of threads) { + const key = workspaceTaskKey(thread); + const existing = grouped.get(key); + if (existing) { + existing.push(thread); + } else { + grouped.set(key, [thread]); + } + } + + return [...grouped.entries()].map(([key, unsortedTabs]) => { + const tabs = unsortedTabs.toSorted( + (left, right) => + (left.tabPosition ?? 0) - (right.tabPosition ?? 0) || + left.createdAt.localeCompare(right.createdAt) || + left.id.localeCompare(right.id), + ); + return { + id: resolveWorkspaceTaskId(tabs[0]!), + key, + tabs, + root: tabs[0]!, + }; + }); +} + +function representativePriority(thread: EnvironmentThreadShell): number { + if (thread.hasPendingApprovals) return 5; + if (thread.hasPendingUserInput) return 4; + if (thread.session?.status === "error") return 3; + if (thread.session?.status === "running" || thread.session?.status === "starting") return 2; + return 1; +} + +export function taskPresentationThread( + task: WorkspaceTaskGroup, + activeThreadKey: string | null, +): EnvironmentThreadShell { + const activeTab = + activeThreadKey === null + ? null + : (task.tabs.find( + (thread) => + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) === activeThreadKey, + ) ?? null); + const representative = [...task.tabs].toSorted( + (left, right) => + representativePriority(right) - representativePriority(left) || + right.updatedAt.localeCompare(left.updatedAt), + )[0]!; + const source = + activeTab && representativePriority(activeTab) >= representativePriority(representative) + ? activeTab + : representative; + const updatedAt = task.tabs.reduce( + (latest, thread) => (thread.updatedAt > latest ? thread.updatedAt : latest), + task.root.updatedAt, + ); + const latestUserMessageAt = task.tabs.reduce( + (latest, thread) => + thread.latestUserMessageAt !== null && + (latest === null || thread.latestUserMessageAt > latest) + ? thread.latestUserMessageAt + : latest, + null, + ); + + return { + ...source, + // Task title and workspace identity are owned by the root tab. Status and + // recency are aggregated from every sibling tab. + id: task.root.id, + environmentId: task.root.environmentId, + title: task.root.title, + projectId: task.root.projectId, + branch: task.root.branch, + worktreePath: task.root.worktreePath, + workspaceTaskId: task.id, + tabLabel: null, + tabPosition: 0, + updatedAt, + latestUserMessageAt, + hasPendingApprovals: task.tabs.some((thread) => thread.hasPendingApprovals), + hasPendingUserInput: task.tabs.some((thread) => thread.hasPendingUserInput), + hasActionableProposedPlan: task.tabs.some((thread) => thread.hasActionableProposedPlan), + }; +} + +export function workspaceTaskTabCountByThread( + tasks: ReadonlyArray, +): ReadonlyMap { + const counts = new Map(); + for (const task of tasks) { + for (const thread of task.tabs) { + counts.set( + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + task.tabs.length, + ); + } + } + return counts; +} diff --git a/apps/web/src/routes/_chat.tsx b/apps/web/src/routes/_chat.tsx index d3cf003d99c..18b36e10e8e 100644 --- a/apps/web/src/routes/_chat.tsx +++ b/apps/web/src/routes/_chat.tsx @@ -21,6 +21,7 @@ import { selectActiveRightPanel, useRightPanelStore } from "../rightPanelStore"; import { useThreadSelectionStore } from "../threadSelectionStore"; import { stackedThreadToast, toastManager } from "~/components/ui/toast"; import { primaryServerKeybindingsAtom } from "~/state/server"; +import { useWorkspaceTaskTabs } from "../hooks/useWorkspaceTaskTabs"; function ChatRouteGlobalShortcuts() { const clearSelection = useThreadSelectionStore((state) => state.clearSelection); @@ -29,6 +30,7 @@ function ChatRouteGlobalShortcuts() { useHandleNewThread(); const keybindings = useAtomValue(primaryServerKeybindingsAtom); const sidebarV2Enabled = useClientSettings((settings) => settings.sidebarV2Enabled); + const workspaceTaskTabs = useWorkspaceTaskTabs(); const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); const projects = useProjects(); const primaryEnvironmentId = usePrimaryEnvironmentId(); @@ -108,6 +110,22 @@ function ChatRouteGlobalShortcuts() { return; } + if (command === "chat.newTab") { + if (!sidebarV2Enabled || !workspaceTaskTabs.hasTask) return; + event.preventDefault(); + event.stopPropagation(); + void workspaceTaskTabs.createTab("fresh"); + return; + } + + if (command === "chat.reopenClosedTab") { + if (!sidebarV2Enabled || workspaceTaskTabs.closedTabs.length === 0) return; + event.preventDefault(); + event.stopPropagation(); + void workspaceTaskTabs.reopenLastClosedTab(); + return; + } + if (command === "preview.toggle") { event.preventDefault(); event.stopPropagation(); @@ -169,6 +187,7 @@ function ChatRouteGlobalShortcuts() { selectedThreadKeysSize, sidebarV2Enabled, terminalOpen, + workspaceTaskTabs, ]); return null; diff --git a/packages/client-runtime/src/state/orchestration.ts b/packages/client-runtime/src/state/orchestration.ts index f8faa49ea38..702f01fa500 100644 --- a/packages/client-runtime/src/state/orchestration.ts +++ b/packages/client-runtime/src/state/orchestration.ts @@ -20,5 +20,9 @@ export function createOrchestrationEnvironmentAtoms( label: "environment-data:orchestration:archived-shell-snapshot", tag: ORCHESTRATION_WS_METHODS.getArchivedShellSnapshot, }), + closedTaskTabs: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:orchestration:closed-task-tabs", + tag: ORCHESTRATION_WS_METHODS.getClosedTaskTabs, + }), }; } diff --git a/packages/contracts/src/baseSchemas.ts b/packages/contracts/src/baseSchemas.ts index 614ea5131fb..ef77c44eaa3 100644 --- a/packages/contracts/src/baseSchemas.ts +++ b/packages/contracts/src/baseSchemas.ts @@ -29,6 +29,8 @@ const makeEntityId = (brand: Brand) => { export const ThreadId = makeEntityId("ThreadId"); export type ThreadId = typeof ThreadId.Type; +export const WorkspaceTaskId = makeEntityId("WorkspaceTaskId"); +export type WorkspaceTaskId = typeof WorkspaceTaskId.Type; export const ProjectId = makeEntityId("ProjectId"); export type ProjectId = typeof ProjectId.Type; export const EnvironmentId = makeEntityId("EnvironmentId"); diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index 7f4b6c16541..bff2fb3a400 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -47,6 +47,9 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ /** Server understands thread.snooze / thread.unsnooze commands. Same version-skew contract as threadSettlement. */ threadSnooze: Schema.optionalKey(Schema.Boolean), + /** Server persists workspace-task tab metadata and portable context-fork + provenance. Missing means clients must keep the legacy one-thread UI. */ + workspaceTaskTabs: Schema.optionalKey(Schema.Boolean), /** The update path clients should offer for this server. Absent on servers that must be relaunched manually (dev checkouts, Windows foreground runs, pre-update servers). */ diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 4b1676d7926..5449aca32a3 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -93,6 +93,7 @@ import type { OrchestrationGetTurnDiffInput, OrchestrationGetTurnDiffResult, OrchestrationShellSnapshot, + OrchestrationThreadShell, OrchestrationShellStreamItem, OrchestrationSubscribeThreadInput, OrchestrationThreadStreamItem, @@ -1245,6 +1246,7 @@ export interface EnvironmentApi { input: OrchestrationGetFullThreadDiffInput, ) => Promise; getArchivedShellSnapshot: () => Promise; + getClosedTaskTabs: () => Promise>; subscribeShell: ( callback: (event: OrchestrationShellStreamItem) => void, options?: { diff --git a/packages/contracts/src/keybindings.ts b/packages/contracts/src/keybindings.ts index c7cff9943cd..f0c8f9415f1 100644 --- a/packages/contracts/src/keybindings.ts +++ b/packages/contracts/src/keybindings.ts @@ -65,6 +65,8 @@ const STATIC_KEYBINDING_COMMANDS = [ "commandPalette.toggle", "chat.new", "chat.newLocal", + "chat.newTab", + "chat.reopenClosedTab", "editor.openFavorite", ...MODEL_PICKER_KEYBINDING_COMMANDS, ...THREAD_KEYBINDING_COMMANDS, diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 84b7a8fa07f..783e82e9763 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -19,6 +19,7 @@ import { ThreadId, TrimmedNonEmptyString, TurnId, + WorkspaceTaskId, } from "./baseSchemas.ts"; import { ProviderInstanceId } from "./providerInstance.ts"; @@ -28,6 +29,7 @@ export const ORCHESTRATION_WS_METHODS = { getFullThreadDiff: "orchestration.getFullThreadDiff", replayEvents: "orchestration.replayEvents", getArchivedShellSnapshot: "orchestration.getArchivedShellSnapshot", + getClosedTaskTabs: "orchestration.getClosedTaskTabs", subscribeShell: "orchestration.subscribeShell", subscribeThread: "orchestration.subscribeThread", } as const; @@ -342,9 +344,28 @@ export const OrchestrationLatestTurn = Schema.Struct({ }); export type OrchestrationLatestTurn = typeof OrchestrationLatestTurn.Type; +export const ThreadForkProvenance = Schema.Struct({ + mode: Schema.Literals(["fresh", "portable"]), + sourceThreadId: Schema.NullOr(ThreadId), + createdAt: IsoDateTime, +}); +export type ThreadForkProvenance = typeof ThreadForkProvenance.Type; + +const ThreadTaskFields = { + // Optional at the wire boundary so a task-aware client remains compatible + // with pre-task servers. New servers always materialize these fields and + // legacy rows are backfilled to one task with one tab. + workspaceTaskId: Schema.optional(WorkspaceTaskId), + tabLabel: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), + tabPosition: Schema.optional(NonNegativeInt), + tabClosedAt: Schema.optional(Schema.NullOr(IsoDateTime)), + forkProvenance: Schema.optional(Schema.NullOr(ThreadForkProvenance)), +} as const; + export const OrchestrationThread = Schema.Struct({ id: ThreadId, projectId: ProjectId, + ...ThreadTaskFields, title: TrimmedNonEmptyString, modelSelection: ModelSelection, runtimeMode: RuntimeMode, @@ -401,6 +422,7 @@ export type OrchestrationProjectShell = typeof OrchestrationProjectShell.Type; export const OrchestrationThreadShell = Schema.Struct({ id: ThreadId, projectId: ProjectId, + ...ThreadTaskFields, title: TrimmedNonEmptyString, modelSelection: ModelSelection, runtimeMode: RuntimeMode, @@ -427,6 +449,9 @@ export const OrchestrationThreadShell = Schema.Struct({ }); export type OrchestrationThreadShell = typeof OrchestrationThreadShell.Type; +export const OrchestrationClosedTaskTabs = Schema.Array(OrchestrationThreadShell); +export type OrchestrationClosedTaskTabs = typeof OrchestrationClosedTaskTabs.Type; + export const OrchestrationShellSnapshot = Schema.Struct({ snapshotSequence: NonNegativeInt, projects: Schema.Array(OrchestrationProjectShell), @@ -546,6 +571,7 @@ const ThreadCreateCommand = Schema.Struct({ commandId: CommandId, threadId: ThreadId, projectId: ProjectId, + ...ThreadTaskFields, title: TrimmedNonEmptyString, modelSelection: ModelSelection, runtimeMode: RuntimeMode, @@ -621,6 +647,9 @@ const ThreadMetaUpdateCommand = Schema.Struct({ branch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), expectedBranch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), worktreePath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), + tabLabel: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), + tabPosition: Schema.optional(NonNegativeInt), + tabClosedAt: Schema.optional(Schema.NullOr(IsoDateTime)), }); const ThreadRuntimeModeSetCommand = Schema.Struct({ @@ -641,6 +670,7 @@ const ThreadInteractionModeSetCommand = Schema.Struct({ const ThreadTurnStartBootstrapCreateThread = Schema.Struct({ projectId: ProjectId, + ...ThreadTaskFields, title: TrimmedNonEmptyString, modelSelection: ModelSelection, runtimeMode: RuntimeMode, @@ -940,6 +970,7 @@ export const ProjectDeletedPayload = Schema.Struct({ export const ThreadCreatedPayload = Schema.Struct({ threadId: ThreadId, projectId: ProjectId, + ...ThreadTaskFields, title: TrimmedNonEmptyString, modelSelection: ModelSelection, runtimeMode: RuntimeMode.pipe(Schema.withDecodingDefault(Effect.succeed(DEFAULT_RUNTIME_MODE))), @@ -1003,6 +1034,9 @@ export const ThreadMetaUpdatedPayload = Schema.Struct({ modelSelection: Schema.optional(ModelSelection), branch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), worktreePath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), + tabLabel: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), + tabPosition: Schema.optional(NonNegativeInt), + tabClosedAt: Schema.optional(Schema.NullOr(IsoDateTime)), updatedAt: IsoDateTime, }); @@ -1392,6 +1426,10 @@ export const OrchestrationRpcSchemas = { input: Schema.Struct({}), output: OrchestrationShellSnapshot, }, + getClosedTaskTabs: { + input: Schema.Struct({}), + output: OrchestrationClosedTaskTabs, + }, subscribeThread: { input: OrchestrationSubscribeThreadInput, output: OrchestrationThreadStreamItem, diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index fa2d23b8ef2..997bfbfc77d 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -646,6 +646,15 @@ export const WsOrchestrationGetArchivedShellSnapshotRpc = Rpc.make( }, ); +export const WsOrchestrationGetClosedTaskTabsRpc = Rpc.make( + ORCHESTRATION_WS_METHODS.getClosedTaskTabs, + { + payload: OrchestrationRpcSchemas.getClosedTaskTabs.input, + success: OrchestrationRpcSchemas.getClosedTaskTabs.output, + error: Schema.Union([OrchestrationGetSnapshotError, EnvironmentAuthorizationError]), + }, +); + export const WsOrchestrationSubscribeShellRpc = Rpc.make(ORCHESTRATION_WS_METHODS.subscribeShell, { payload: OrchestrationRpcSchemas.subscribeShell.input, success: OrchestrationRpcSchemas.subscribeShell.output, @@ -767,6 +776,7 @@ export const WsRpcGroup = RpcGroup.make( WsOrchestrationGetFullThreadDiffRpc, WsOrchestrationReplayEventsRpc, WsOrchestrationGetArchivedShellSnapshotRpc, + WsOrchestrationGetClosedTaskTabsRpc, WsOrchestrationSubscribeShellRpc, WsOrchestrationSubscribeThreadRpc, ); diff --git a/packages/shared/src/keybindings.ts b/packages/shared/src/keybindings.ts index b6bdd7b4783..f8be6bbe92d 100644 --- a/packages/shared/src/keybindings.ts +++ b/packages/shared/src/keybindings.ts @@ -36,6 +36,8 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray = [ { key: "mod+0", command: "preview.resetZoom", when: "previewFocus" }, { key: "mod+k", command: "commandPalette.toggle", when: "!terminalFocus" }, { key: "mod+n", command: "chat.new", when: "!terminalFocus" }, + { key: "mod+t", command: "chat.newTab", when: "!terminalFocus" }, + { key: "mod+shift+t", command: "chat.reopenClosedTab", when: "!terminalFocus" }, { key: "mod+shift+o", command: "chat.new", when: "!terminalFocus" }, { key: "mod+shift+n", command: "chat.newLocal", when: "!terminalFocus" }, { key: "mod+shift+m", command: "modelPicker.toggle", when: "!terminalFocus" },