From 0ed495a122e66738e12b82be200a9ddc4e1acc7e Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 24 Jul 2026 07:43:45 -0700 Subject: [PATCH 1/3] feat(sidebar-v2): add task tabs and context forks --- .plans/task-tabs-context-fork.html | 2390 +++++++++++++++++ .../src/environment/ServerEnvironment.test.ts | 1 + .../src/environment/ServerEnvironment.ts | 1 + .../Layers/ProjectionPipeline.ts | 14 + .../Layers/ProjectionSnapshotQuery.test.ts | 28 + .../Layers/ProjectionSnapshotQuery.ts | 68 +- .../Layers/ProviderCommandReactor.test.ts | 75 + .../Layers/ProviderCommandReactor.ts | 16 +- .../orchestration/decider.taskTabs.test.ts | 137 + apps/server/src/orchestration/decider.ts | 48 + .../src/orchestration/portableFork.test.ts | 100 + apps/server/src/orchestration/portableFork.ts | 55 + apps/server/src/orchestration/projector.ts | 8 + .../Layers/ProjectionRepositories.test.ts | 12 +- .../persistence/Layers/ProjectionThreads.ts | 28 +- apps/server/src/persistence/Migrations.ts | 2 + .../035_ProjectionThreadTasks.test.ts | 97 + .../Migrations/035_ProjectionThreadTasks.ts | 53 + .../persistence/Services/ProjectionThreads.ts | 7 + apps/server/src/ws.ts | 15 + apps/web/src/components/ChatView.tsx | 14 + apps/web/src/components/SidebarV2.tsx | 273 +- apps/web/src/components/WorkspaceTaskTabs.tsx | 139 + .../components/settings/BetaSettingsPanel.tsx | 2 +- apps/web/src/composerDraftStore.test.ts | 41 + apps/web/src/composerDraftStore.ts | 85 +- apps/web/src/hooks/useHandleNewThread.ts | 3 +- apps/web/src/hooks/useWorkspaceTaskTabs.ts | 332 +++ apps/web/src/lib/workspaceTasks.test.ts | 76 + apps/web/src/lib/workspaceTasks.ts | 129 + apps/web/src/routes/_chat.tsx | 11 + packages/contracts/src/baseSchemas.ts | 2 + packages/contracts/src/environment.ts | 3 + packages/contracts/src/keybindings.ts | 1 + packages/contracts/src/orchestration.ts | 30 + packages/shared/src/keybindings.ts | 1 + 36 files changed, 4207 insertions(+), 90 deletions(-) create mode 100644 .plans/task-tabs-context-fork.html create mode 100644 apps/server/src/orchestration/decider.taskTabs.test.ts create mode 100644 apps/server/src/orchestration/portableFork.test.ts create mode 100644 apps/server/src/orchestration/portableFork.ts create mode 100644 apps/server/src/persistence/Migrations/035_ProjectionThreadTasks.test.ts create mode 100644 apps/server/src/persistence/Migrations/035_ProjectionThreadTasks.ts create mode 100644 apps/web/src/components/WorkspaceTaskTabs.tsx create mode 100644 apps/web/src/hooks/useWorkspaceTaskTabs.ts create mode 100644 apps/web/src/lib/workspaceTasks.test.ts create mode 100644 apps/web/src/lib/workspaceTasks.ts 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. +
+
+
+
+ +
+ Prepared from the current T3 Code contracts, Sidebar v2, draft-promotion flow, + orchestration/event-store layers, provider adapters, Codex app-server generated protocol, + and checkpoint/worktree cleanup behavior. Scope: web Sidebar v2 experiment only. +
+
+ + diff --git a/apps/server/src/environment/ServerEnvironment.test.ts b/apps/server/src/environment/ServerEnvironment.test.ts index 61892d53d63..33e53eea447 100644 --- a/apps/server/src/environment/ServerEnvironment.test.ts +++ b/apps/server/src/environment/ServerEnvironment.test.ts @@ -68,6 +68,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/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..adca4cba47a 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,23 @@ 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 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..db8e9d865cf 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, @@ -324,6 +330,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 +367,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,6 +394,7 @@ 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 `, }); @@ -390,6 +407,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 +770,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 +806,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 +1220,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 +1427,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 +1565,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 +1708,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, @@ -1876,7 +1924,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 +1957,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 +1995,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 +2060,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, 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/decider.taskTabs.test.ts b/apps/server/src/orchestration/decider.taskTabs.test.ts new file mode 100644 index 00000000000..31fd7071366 --- /dev/null +++ b/apps/server/src/orchestration/decider.taskTabs.test.ts @@ -0,0 +1,137 @@ +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, + }, + }); +}); + +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"); + }), + ); +}); diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 100369ae6e3..60fcab35032 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"; @@ -355,6 +356,45 @@ 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 && + (thread.workspaceTaskId ?? WorkspaceTaskId.make(String(thread.id))) === 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.workspaceTaskId ?? WorkspaceTaskId.make(String(source.id))) !== workspaceTaskId + ) { + 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 +406,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, @@ -659,6 +704,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/ws.ts b/apps/server/src/ws.ts index f6f46d1e76e..e6eda98df71 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -984,6 +984,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, 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 fd59cb63adc..72bffaab1e7 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -18,7 +18,6 @@ import { AlarmClockOffIcon, CheckIcon, ChevronDownIcon, - ChevronRightIcon, CircleAlertIcon, CircleCheckIcon, CircleDashedIcon, @@ -101,6 +100,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, @@ -365,6 +369,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; @@ -766,6 +771,14 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { /> {title} + {props.tabCount > 1 ? ( + + {props.tabCount} + + ) : 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. */} @@ -941,7 +954,17 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { ) : null} -
{title}
+
+ {title} + {props.tabCount > 1 ? ( + + {props.tabCount} + + ) : null} +
{thread.branch ? ( {thread.branch} @@ -1056,6 +1079,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 @@ -1368,13 +1419,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 @@ -1383,17 +1436,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); @@ -1418,6 +1476,7 @@ export default function SidebarV2() { serverConfigs, snoozeWakeTick, threads, + routeThreadKey, ]); // Arm a timeout for the earliest upcoming wake so the shelf empties the @@ -1455,17 +1514,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), @@ -1475,13 +1531,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 @@ -1494,13 +1549,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], @@ -1812,6 +1864,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( @@ -1827,13 +1925,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 && @@ -1870,7 +1975,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, @@ -1885,18 +1990,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(); @@ -1907,8 +2010,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"), ), ); @@ -1919,9 +2022,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, }); @@ -1951,6 +2052,7 @@ export default function SidebarV2() { markThreadUnread, removeFromSelection, serverConfigs, + taskTabsByThreadKey, ], ); @@ -1967,6 +2069,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 @@ -1994,8 +2097,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 @@ -2013,9 +2116,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, ), @@ -2025,7 +2128,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) { @@ -2053,43 +2156,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; } @@ -2099,16 +2212,17 @@ export default function SidebarV2() { })(); }, [ - attemptSettle, - attemptSnooze, - attemptUnsettle, - attemptUnsnooze, + attemptSettleTask, + attemptSnoozeTask, + attemptUnsettleTask, + attemptUnsnoozeTask, confirmThreadDelete, deleteThread, handleMultiSelectContextMenu, markThreadUnread, serverConfigs, startThreadRename, + taskTabsForRef, ], ); @@ -2221,7 +2335,7 @@ export default function SidebarV2() { @@ -2246,7 +2360,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" /> } > @@ -2257,7 +2371,7 @@ export default function SidebarV2() { /> - {newThreadShortcutLabel ? `New thread (${newThreadShortcutLabel})` : "New thread"} + {newThreadShortcutLabel ? `New task (${newThreadShortcutLabel})` : "New task"}
@@ -2268,7 +2382,7 @@ export default function SidebarV2() {
{scopedProjectGroup ? ( @@ -2417,7 +2531,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} @@ -2439,10 +2554,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} /> ); @@ -2544,9 +2659,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..267d4162a42 --- /dev/null +++ b/apps/web/src/components/WorkspaceTaskTabs.tsx @@ -0,0 +1,139 @@ +import { GitForkIcon, PlusIcon, XIcon } from "lucide-react"; +import { useState } from "react"; + +import { shortcutLabelForCommand } from "../keybindings"; +import { useClientSettings } from "../hooks/useSettings"; +import { type WorkspaceTaskTab, useWorkspaceTaskTabs } from "../hooks/useWorkspaceTaskTabs"; +import { cn } from "../lib/utils"; +import { Tooltip, TooltipPopup, TooltipProvider, TooltipTrigger } from "./ui/tooltip"; + +function tabStatusClass(tab: WorkspaceTaskTab): string { + if (tab.blocked) return "bg-amber-500"; + if (tab.working) return "animate-pulse bg-sky-500 motion-reduce:animate-none"; + return "bg-muted-foreground/35"; +} + +export function WorkspaceTaskTabs() { + const sidebarV2Enabled = useClientSettings((settings) => settings.sidebarV2Enabled); + const { tabs, keybindings, hasTask, canForkContext, createTab, closeTab, navigateToTab } = + useWorkspaceTaskTabs(); + const [creatingMode, setCreatingMode] = useState<"fresh" | "portable" | null>(null); + + if (!sidebarV2Enabled || !hasTask) return null; + + const newTabShortcut = shortcutLabelForCommand(keybindings, "chat.newTab"); + const workingCount = tabs.filter((tab) => tab.working).length; + const create = (mode: "fresh" | "portable") => { + if (creatingMode !== null) return; + setCreatingMode(mode); + void createTab(mode).finally(() => setCreatingMode(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, + }); + + 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, + }, + }); + }); + 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..f11238b3917 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,10 @@ 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)), environmentId: Schema.String, projectId: ProjectId, logicalProjectKey: Schema.optionalKey(Schema.String), @@ -285,6 +292,10 @@ export interface ComposerThreadDraftState { */ export interface DraftSessionState { threadId: ThreadId; + workspaceTaskId?: WorkspaceTaskId | undefined; + tabLabel?: string | null | undefined; + tabPosition?: number | undefined; + forkProvenance?: ThreadForkProvenance | null | undefined; environmentId: EnvironmentId; projectId: ProjectId; logicalProjectKey: string; @@ -359,6 +370,11 @@ interface ComposerDraftStoreState { startFromOrigin?: boolean; runtimeMode?: RuntimeMode; interactionMode?: ProviderInteractionMode; + workspaceTaskId?: WorkspaceTaskId; + tabLabel?: string | null; + tabPosition?: number; + forkProvenance?: ThreadForkProvenance | null; + retainPreviousDraft?: boolean; }, ) => void; /** Creates or updates the draft session tracked for a concrete project ref. */ @@ -374,6 +390,11 @@ interface ComposerDraftStoreState { startFromOrigin?: boolean; runtimeMode?: RuntimeMode; interactionMode?: ProviderInteractionMode; + workspaceTaskId?: WorkspaceTaskId; + tabLabel?: string | null; + tabPosition?: number; + forkProvenance?: ThreadForkProvenance | null; + retainPreviousDraft?: boolean; }, ) => void; /** Updates mutable draft-session metadata without touching composer content. */ @@ -1331,6 +1352,11 @@ function createDraftThreadState( startFromOrigin?: boolean; runtimeMode?: RuntimeMode; interactionMode?: ProviderInteractionMode; + workspaceTaskId?: WorkspaceTaskId; + tabLabel?: string | null; + tabPosition?: number; + forkProvenance?: ThreadForkProvenance | null; + retainPreviousDraft?: boolean; }, ): DraftThreadState { const projectChanged = @@ -1357,6 +1383,10 @@ 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, environmentId: projectRef.environmentId, projectId: projectRef.projectId, logicalProjectKey, @@ -1396,6 +1426,10 @@ 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.environmentId === right.environmentId && left.projectId === right.projectId && left.logicalProjectKey === right.logicalProjectKey && @@ -1524,6 +1558,23 @@ 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 } + : {}), environmentId: normalizedEnvironmentId, projectId: projectId as ProjectId, logicalProjectKey: @@ -1907,9 +1958,32 @@ 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 } : {}), + 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 +2223,10 @@ function toHydratedDraftThreadState( ): DraftThreadState { return { threadId: persistedDraftThread.threadId, + workspaceTaskId: persistedDraftThread.workspaceTaskId, + tabLabel: persistedDraftThread.tabLabel ?? null, + tabPosition: persistedDraftThread.tabPosition ?? 0, + forkProvenance: persistedDraftThread.forkProvenance ?? null, environmentId: persistedDraftThread.environmentId as EnvironmentId, projectId: persistedDraftThread.projectId, logicalProjectKey: @@ -2288,6 +2366,7 @@ const composerDraftStore = create()( if ( previousThreadKeyForLogicalProject && previousThreadKeyForLogicalProject !== draftId && + options?.retainPreviousDraft !== true && !isComposerThreadKeyInUse( nextLogicalProjectDraftThreadKeyByLogicalProjectKey, previousThreadKeyForLogicalProject, @@ -2359,6 +2438,10 @@ const composerDraftStore = create()( : options.startFromOrigin; const nextDraftThread: DraftThreadState = { threadId: existing.threadId, + workspaceTaskId: existing.workspaceTaskId, + tabLabel: existing.tabLabel, + tabPosition: existing.tabPosition, + forkProvenance: existing.forkProvenance, environmentId: nextProjectRef.environmentId, projectId: nextProjectRef.projectId, logicalProjectKey: existing.logicalProjectKey, 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..de5eb2756a8 --- /dev/null +++ b/apps/web/src/hooks/useWorkspaceTaskTabs.ts @@ -0,0 +1,332 @@ +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 } from "react"; + +import { DraftId, type DraftSessionState, useComposerDraftStore } from "../composerDraftStore"; +import { + deriveLogicalProjectKeyFromSettings, + selectProjectGroupingSettings, +} from "../logicalProject"; +import { newDraftId, newThreadId } from "../lib/utils"; +import { resolveWorkspaceTaskId } from "../lib/workspaceTasks"; +import { primaryServerKeybindingsAtom } from "../state/server"; +import { threadEnvironment } from "../state/threads"; +import { 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; + }; + +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)); +} + +export function useWorkspaceTaskTabs() { + const router = useRouter(); + 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 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.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 navigateToTab = useCallback( + (tab: WorkspaceTaskTab) => { + 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; + + const draftId = newDraftId(); + const threadId = newThreadId(); + const createdAt = new Date().toISOString(); + const position = tabs.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 }, + }); + }, + [currentContext, routeTarget, router, supportsTaskTabs, tabs], + ); + + const closeTab = useCallback( + async (tab: WorkspaceTaskTab) => { + if (tabs.length <= 1 || tab.position === 0 || 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().clearDraftThread(tab.draftId); + } else { + const result = await updateThreadMetadata({ + environmentId: tab.threadRef.environmentId, + input: { + threadId: tab.threadRef.threadId, + tabClosedAt: new Date().toISOString(), + }, + }); + if (result._tag === "Failure") return; + } + if (tab.active && fallback) { + await navigateToTab(fallback); + } + }, + [navigateToTab, tabs, updateThreadMetadata], + ); + + return { + tabs, + keybindings, + hasTask: currentContext !== null && supportsTaskTabs, + canForkContext: currentContext?.sourceIsServer === true && supportsTaskTabs, + createTab, + closeTab, + navigateToTab, + } as const; +} 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..a22078abe6e 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,14 @@ function ChatRouteGlobalShortcuts() { return; } + if (command === "chat.newTab") { + if (!sidebarV2Enabled || !workspaceTaskTabs.hasTask) return; + event.preventDefault(); + event.stopPropagation(); + void workspaceTaskTabs.createTab("fresh"); + return; + } + if (command === "preview.toggle") { event.preventDefault(); event.stopPropagation(); @@ -169,6 +179,7 @@ function ChatRouteGlobalShortcuts() { selectedThreadKeysSize, sidebarV2Enabled, terminalOpen, + workspaceTaskTabs, ]); return null; 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/keybindings.ts b/packages/contracts/src/keybindings.ts index c7cff9943cd..6c0e7417381 100644 --- a/packages/contracts/src/keybindings.ts +++ b/packages/contracts/src/keybindings.ts @@ -65,6 +65,7 @@ const STATIC_KEYBINDING_COMMANDS = [ "commandPalette.toggle", "chat.new", "chat.newLocal", + "chat.newTab", "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..9538703a984 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"; @@ -342,9 +343,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 +421,7 @@ export type OrchestrationProjectShell = typeof OrchestrationProjectShell.Type; export const OrchestrationThreadShell = Schema.Struct({ id: ThreadId, projectId: ProjectId, + ...ThreadTaskFields, title: TrimmedNonEmptyString, modelSelection: ModelSelection, runtimeMode: RuntimeMode, @@ -546,6 +567,7 @@ const ThreadCreateCommand = Schema.Struct({ commandId: CommandId, threadId: ThreadId, projectId: ProjectId, + ...ThreadTaskFields, title: TrimmedNonEmptyString, modelSelection: ModelSelection, runtimeMode: RuntimeMode, @@ -621,6 +643,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 +666,7 @@ const ThreadInteractionModeSetCommand = Schema.Struct({ const ThreadTurnStartBootstrapCreateThread = Schema.Struct({ projectId: ProjectId, + ...ThreadTaskFields, title: TrimmedNonEmptyString, modelSelection: ModelSelection, runtimeMode: RuntimeMode, @@ -940,6 +966,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 +1030,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, }); diff --git a/packages/shared/src/keybindings.ts b/packages/shared/src/keybindings.ts index b6bdd7b4783..0cfba3149ad 100644 --- a/packages/shared/src/keybindings.ts +++ b/packages/shared/src/keybindings.ts @@ -36,6 +36,7 @@ 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+o", command: "chat.new", when: "!terminalFocus" }, { key: "mod+shift+n", command: "chat.newLocal", when: "!terminalFocus" }, { key: "mod+shift+m", command: "modelPicker.toggle", when: "!terminalFocus" }, From 9b5063bcd4fafd53485ceced08a987118ab4a9ee Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 24 Jul 2026 07:54:30 -0700 Subject: [PATCH 2/3] fix: preserve task tab invariants --- .../orchestration/decider.taskTabs.test.ts | 79 +++++++++++++++++++ apps/server/src/orchestration/decider.ts | 30 ++++++- apps/web/src/composerDraftStore.test.ts | 7 ++ apps/web/src/composerDraftStore.ts | 5 ++ 4 files changed, 118 insertions(+), 3 deletions(-) diff --git a/apps/server/src/orchestration/decider.taskTabs.test.ts b/apps/server/src/orchestration/decider.taskTabs.test.ts index 31fd7071366..d2d2dfd0ee8 100644 --- a/apps/server/src/orchestration/decider.taskTabs.test.ts +++ b/apps/server/src/orchestration/decider.taskTabs.test.ts @@ -75,6 +75,30 @@ const seededReadModel = Effect.gen(function* () { }); }); +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* () { @@ -134,4 +158,59 @@ it.layer(NodeServices.layer)("task tab invariants", (it) => { 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 60fcab35032..463d7758c33 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -25,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. @@ -360,8 +364,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" command.workspaceTaskId ?? WorkspaceTaskId.make(String(command.threadId)); const sibling = readModel.threads.find( (thread) => - thread.deletedAt === null && - (thread.workspaceTaskId ?? WorkspaceTaskId.make(String(thread.id))) === workspaceTaskId, + thread.deletedAt === null && workspaceTaskIdForThread(thread) === workspaceTaskId, ); if ( sibling && @@ -387,7 +390,10 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" ); if ( !source || - (source.workspaceTaskId ?? WorkspaceTaskId.make(String(source.id))) !== workspaceTaskId + source.deletedAt !== null || + workspaceTaskIdForThread(source) !== workspaceTaskId || + source.projectId !== command.projectId || + source.worktreePath !== command.worktreePath ) { return yield* new OrchestrationCommandInvariantError({ commandType: command.type, @@ -687,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({ diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts index 8dd556fa91b..921da33b743 100644 --- a/apps/web/src/composerDraftStore.test.ts +++ b/apps/web/src/composerDraftStore.test.ts @@ -873,6 +873,12 @@ describe("composerDraftStore project draft thread mapping", () => { }, 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, @@ -889,6 +895,7 @@ describe("composerDraftStore project draft thread mapping", () => { sourceThreadId: threadId, }, }); + expect(draftByKey(otherDraftId)?.prompt).toBe("fork tab draft"); }); it("keeps composer drafts when the thread is still mapped by another project", () => { diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index f11238b3917..e6185dcba88 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -2363,10 +2363,15 @@ const composerDraftStore = create()( previousThreadKeyForLogicalProject === undefined ? undefined : nextDraftThreadsByThreadKey[previousThreadKeyForLogicalProject]; + const sharesWorkspaceTask = + previousDraftThread?.workspaceTaskId !== undefined && + nextDraftThread.workspaceTaskId !== undefined && + previousDraftThread.workspaceTaskId === nextDraftThread.workspaceTaskId; if ( previousThreadKeyForLogicalProject && previousThreadKeyForLogicalProject !== draftId && options?.retainPreviousDraft !== true && + !sharesWorkspaceTask && !isComposerThreadKeyInUse( nextLogicalProjectDraftThreadKeyByLogicalProjectKey, previousThreadKeyForLogicalProject, From c8cf5357802cd1cde3a1a6ed75f680b815269e25 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 24 Jul 2026 16:54:54 -0700 Subject: [PATCH 3/3] polish task tab context UI --- apps/web/src/components/SidebarV2.tsx | 48 ++-- apps/web/src/components/WorkspaceTaskTabs.tsx | 240 +++++++++++++----- apps/web/src/lib/taskTabContext.test.ts | 123 +++++++++ apps/web/src/lib/taskTabContext.ts | 120 +++++++++ 4 files changed, 447 insertions(+), 84 deletions(-) create mode 100644 apps/web/src/lib/taskTabContext.test.ts create mode 100644 apps/web/src/lib/taskTabContext.ts diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index 72bffaab1e7..493234ba6b3 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"; @@ -217,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, @@ -226,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; @@ -289,6 +309,15 @@ function SidebarV2ThreadTooltip({
{modelLabel}
) : null} + {tabCount > 1 ? ( +
+ +
+ {tabCount} tabs sharing this worktree and branch. Each tab keeps its own + conversation. +
+
+ ) : null} {thread.session?.lastError ? (
@@ -538,6 +567,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { modelInstanceId={modelInstanceId} modelLabel={modelLabel} branchMismatch={branchMismatch} + tabCount={props.tabCount} /> ); @@ -771,14 +801,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { /> {title} - {props.tabCount > 1 ? ( - - {props.tabCount} - - ) : null} + {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. */} @@ -956,14 +979,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
{title} - {props.tabCount > 1 ? ( - - {props.tabCount} - - ) : null} + {props.tabCount > 1 ? : null}
{thread.branch ? ( diff --git a/apps/web/src/components/WorkspaceTaskTabs.tsx b/apps/web/src/components/WorkspaceTaskTabs.tsx index 267d4162a42..dc6aceaa616 100644 --- a/apps/web/src/components/WorkspaceTaskTabs.tsx +++ b/apps/web/src/components/WorkspaceTaskTabs.tsx @@ -1,16 +1,58 @@ -import { GitForkIcon, PlusIcon, XIcon } from "lucide-react"; -import { useState } from "react"; +import { CircleDashedIcon, GitForkIcon, InfoIcon, PlusIcon, XIcon } from "lucide-react"; +import { useMemo, useState } from "react"; import { shortcutLabelForCommand } from "../keybindings"; import { useClientSettings } from "../hooks/useSettings"; import { type WorkspaceTaskTab, useWorkspaceTaskTabs } from "../hooks/useWorkspaceTaskTabs"; +import { + TASK_TABS_SHARED_SCOPE_EXPLANATION, + describeTaskTabContexts, + type TaskTabContextDescriptor, +} from "../lib/taskTabContext"; import { cn } from "../lib/utils"; import { Tooltip, TooltipPopup, TooltipProvider, TooltipTrigger } from "./ui/tooltip"; -function tabStatusClass(tab: WorkspaceTaskTab): string { - if (tab.blocked) return "bg-amber-500"; - if (tab.working) return "animate-pulse bg-sky-500 motion-reduce:animate-none"; - return "bg-muted-foreground/35"; +function tabThreadId(tab: WorkspaceTaskTab): string { + return String(tab.kind === "server" ? tab.threadRef.threadId : tab.threadId); +} + +/** + * 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. Colour + * is the accent, never the only signal. + */ +function TabStateGlyph(props: { tab: WorkspaceTaskTab }) { + const { tab } = props; + if (tab.blocked) { + return ( + + + + ); + } + if (tab.working) { + // Duty-cycled steps() pulse from index.css, not a continuous spin: this + // strip can be on screen for hours on a 120Hz display. + return ( + + ); + } + return null; +} + +function contextChipClass(kind: TaskTabContextDescriptor["kind"], active: boolean): string { + if (!active) return "border-border/30 bg-background/30 text-muted-foreground/60"; + return kind === "snapshot" + ? "border-border/60 bg-muted/80 text-muted-foreground/90" + : "border-border/45 bg-muted/60 text-muted-foreground/75"; } export function WorkspaceTaskTabs() { @@ -18,11 +60,31 @@ export function WorkspaceTaskTabs() { const { tabs, keybindings, hasTask, canForkContext, createTab, closeTab, navigateToTab } = useWorkspaceTaskTabs(); const [creatingMode, setCreatingMode] = useState<"fresh" | "portable" | null>(null); + const contexts = useMemo( + () => + describeTaskTabContexts( + tabs.map((tab) => ({ + kind: tab.kind, + threadId: tabThreadId(tab), + title: tab.title, + position: tab.position, + forkProvenance: tab.forkProvenance + ? { + mode: tab.forkProvenance.mode, + sourceThreadId: + tab.forkProvenance.sourceThreadId === null + ? null + : String(tab.forkProvenance.sourceThreadId), + } + : null, + })), + ), + [tabs], + ); if (!sidebarV2Enabled || !hasTask) return null; const newTabShortcut = shortcutLabelForCommand(keybindings, "chat.newTab"); - const workingCount = tabs.filter((tab) => tab.working).length; const create = (mode: "fresh" | "portable") => { if (creatingMode !== null) return; setCreatingMode(mode); @@ -34,66 +96,86 @@ export function WorkspaceTaskTabs() {
-
- {workingCount > 0 ? ( - - ) : null} +
- New tab{newTabShortcut ? ` (${newTabShortcut})` : ""} + New empty tab{newTabShortcut ? ` (${newTabShortcut})` : ""} @@ -126,10 +208,32 @@ export function WorkspaceTaskTabs() { > - - {canForkContext - ? "Fork this tab’s context into a new tab" - : "Send the first message before forking context"} + + + {canForkContext + ? "New tab seeded with a one-time snapshot of this tab's conversation" + : "Send the first message before forking context"} + + + + {/* Stated once for the whole task, so individual tabs don't need to + repeat the shared-worktree / separate-history caveat. */} + + + } + > + + + + + {TASK_TABS_SHARED_SCOPE_EXPLANATION} +
diff --git a/apps/web/src/lib/taskTabContext.test.ts b/apps/web/src/lib/taskTabContext.test.ts new file mode 100644 index 00000000000..cd001d5ff5c --- /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("Own context"); + expect(main?.detail).toContain("one-time context fork"); + }); + + 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("Starts empty"); + expect(draft?.detail).toContain("no conversation yet"); + }); + + 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("Own context"); + expect(started?.detail).toContain("does not follow the other tabs"); + }); + + 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("Snapshot from Main"); + expect(fork?.detail).toContain("bounded snapshot"); + }); + + 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("Snapshot from Main"); + expect(fork?.detail).toContain("continued independently"); + }); + + 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("Snapshot from another 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..18ad08b871d --- /dev/null +++ b/apps/web/src/lib/taskTabContext.ts @@ -0,0 +1,120 @@ +/** + * 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 chip label, e.g. "Snapshot 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; +} + +/** Shared across every tab of a task, so it is stated once and not per tab. */ +export const TASK_TABS_SHARED_SCOPE_EXPLANATION = + "Tabs in this task share the same project, worktree, and branch. Each keeps its own ongoing conversation. A context fork copies one bounded snapshot; it does not keep histories in sync."; + +const FALLBACK_SOURCE_TITLE = "another 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: `Snapshot from ${from}`, + detail: + tab.kind === "draft" + ? `On its first message this tab receives a bounded snapshot of ${from}'s conversation, then continues independently.` + : `Started from a bounded snapshot of ${from}'s conversation and has continued independently since.`, + }; + } + + if (tab.kind === "draft") { + return { + ...base, + kind: "empty", + label: "Starts empty", + detail: + "This tab has no conversation yet. Once you send a message its context is its own — no history is carried over.", + }; + } + + if (tab.position === 0) { + return { + ...base, + kind: "main", + label: "Own context", + detail: + "The task's first tab keeps its own conversation. Other tabs only receive it through a one-time context fork.", + }; + } + + return { + ...base, + kind: "own", + label: "Own context", + detail: "This tab keeps an independent conversation and does not follow the other tabs.", + }; +} + +/** + * 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); + }); +}