feat(web): side chats for V2 threads#14220

closed
t3code/codex-turn-mapping
GitHub fork: agent/v2-side-chats
openedSep 29, 2026bySsaphid
View on GitHubTwo-way sync enabledPR last imported 10d ago

Problem

Asking a quick question about a running thread today means interrupting it or forking to a new thread that lands in the sidebar. Earlier attempts (#8296, #9396/#9436/#9437, #12794) were closed for targeting V1 orchestration or adding a second ephemeral protocol. Discussion #7311 is the canonical thread. This adds no such protocol: a side chat is an ordinary V2 thread.

What this does

Two independent facts, kept separate:

  • Lineage (existing): fork, subagent, or a plain link. A thread that inherited nothing is never labelled a fork.
  • Presentation (new): one optional sideChat boolean on the thread. It means: open in the right panel, stay out of the sidebar and palette. It is not a relationship kind.

Two kinds of side chat:

  • With history: thread.fork at latest_stable (or a chosen run) plus the flag. Same provider uses the native fork path.
  • Without history: a new thread.create with parentThreadId (a link, no context transfer) plus the flag.

Read-only by default: side chats are created with the approval-required runtime mode, and a header switch moves them to auto-accept-edits. Model defaults to the parent's. Ways back: Promote to thread (clears the flag), Bring back to main (existing merge-back, forks only), Discard (existing delete). Closing a tab keeps the thread listed under Side chats; closing one with no messages discards it.

Before and after

Same script on both builds: a thread starts sleep 25, then the user asks "In one sentence, what have we discussed so far?" while it runs.

Before (base, V2 head cb64162dee7): the only way to ask is Fork, which navigates away from the running thread and adds a new thread to the sidebar.

Before: fork leaves the running thread and adds a sidebar thread

After (this PR): Ask about this response opens a side chat in the right panel. The parent keeps running beside it, the answer uses the inherited history ("Sees up to run 1"), and nothing new appears in the sidebar.

After: side chat opens beside the running thread

Full-length recordings: before.mp4, after.mp4. Final frames: before, after.

Capture notes: headless Chrome against isolated dev servers with fresh state, web only, a real Codex GPT-6 Astra provider. GIFs are 6 fps with the first 5.5 s (page load) trimmed. Thread titles differ between the runs because they are generated per run. The base is the unmodified V2 workflow, not a staged defect.

Verified unknowns (answers from the code)

a. Can V2 create a linked thread with no context transfer today? No. thread.create always wrote null lineage (Orchestrator.ts dispatchThreadCreate), thread.fork always creates a fork with a pending ContextTransfer, and delegated_task.request needs an active parent run. So "without history" needed a small server change: an optional parentThreadId on thread.create. Lineage records the parent, relationshipToParent stays null, and no ContextTransfer is created. The client relationship graph now calls such an edge parent (it previously fell through to fork).

b. Can a user create a subagent-style child, and does it auto-return to the parent? No to both as far as side chats are concerned. The only creator is delegated_task.request, an agent-side command that requires an active parent run and parent node (Parent run ... is not active). The child's result is delivered back to the parent through completionWake and completion delivery, which is exactly what a side chat must not do. So side chats are not built on subagents.

thread.fork while the source run is active: allowed for latest_stable, which resolves to the newest run with status completed and a checkpoint (latestStableRun), so a run still streaming is never included. Pinning the streaming run explicitly ({ type: "run" }) is rejected by isForkableSourceRunStatus. Covered by a new test. If the thread has no finished run yet, latest_stable fails, and the UI disables "with history" with a reason.

What shipped

  • Contracts: sideChat on thread and shell; parentThreadId + sideChat on thread.create; sideChat + runtimeMode on thread.fork; sideChat on thread.metadata.update; threadSideChats environment capability; thread.sideChat keybinding command.
  • Server: the above, with focused tests. ThreadForkService sets the flag explicitly because it spreads the source thread and would otherwise copy a side chat's flag into an ordinary fork.
  • Client runtime: command inputs, resolveSideChatHistoryAvailability (latest finished run plus the existing fork capability gate, with a reason), describeSideChatVisibility ("Sees up to run N"), parent relationship kind.
  • Web:
    • Right-panel surface aside:<threadId> (peer tabs, several at once) rendering the normal ChatView in a new embedded mode: no header, thread details, terminals, right panel, or global shortcuts of its own, no nested side chats.
    • Header chip ("Sees up to run N"), Allow edits switch, and menu (Bring back to main, Promote, Discard).
    • Side chats section with a split + in the thread details panel, beside Lineage, always visible when the server supports it. Rows show status and elapsed time; finished ones group under Previous.
    • Fork button on a response is now a menu: Fork to new thread / Ask about this response.
    • Ask next to Cite on a selection (quote lands in the side chat's composer).
    • /side and /btw (/btw <question> sends immediately; bare /side reopens the latest), keybinding Side Chat: Open or Start (Mod+Shift+B), command palette entry.
    • Hidden from the sidebar and palette recents, and listed only in the Side chats section (not duplicated in Lineage). If the parent is deleted, a surviving side chat is listed as an ordinary thread so it stays reachable.
    • The embedded chat has its own composer handle and keyboard scope: the host's sends, citations and Mod+S never touch the side chat's composer (or the reverse), and Mod+W and the panel toggles still work with focus inside it.
  • Docs: docs/user/side-chats.md, glossary entry, and a paragraph in the lineage design doc.

Hit every surface

  • Entry points: thread details +, response menu, selection Ask, /side and /btw, keybinding, palette. All go through one hook (useSideChatActions).
  • Clients: web and desktop (desktop wraps web) done. Mobile is deferred: side chats show as ordinary threads there; no mobile panel or entry points.
  • Providers: with-history reuses the existing fork gate (native fork, or full-thread handoff), so a provider whose session reports neither is disabled with "This provider cannot share history with a side chat yet." Without-history works on every provider since nothing is transferred. Codex, Claude and OpenCode fork natively; other providers depend on their advertised handoff capability, not a per-provider list.
  • Contracts: all optional fields; threadSideChats capability hides every entry point on older servers, which would otherwise silently drop the fields and create an ordinary thread.
  • Reverse states: promote, discard, bring back, reopen from Side chats. A tab whose thread is deleted or promoted elsewhere closes itself. Deleting a parent leaves its side chats reachable as ordinary threads.
  • Connection modes: commands and the flag ride the existing thread stream, so local, remote and tunnel behave like any other thread. Not exercised against a remote here.
  • Docs: updated as listed above.

Verification

Merged the latest t3code/codex-turn-mapping (f48d2571668) in; the only conflict was the namespaced Effect service imports in two server tests. After the merge: the server, web, contracts, client-runtime and mobile typecheck, and every touched test file below passes again (ServerEnvironment.test.ts needs T3_SERVICE_LAUNCHER_CONTEXT unset when run from inside a T3-managed shell, unrelated to this change).

Run: vp test run on the touched files only, plus targeted tsc --noEmit for server, web, contracts, client-runtime, mobile, desktop and shared, and vp lint on the changed files (no errors; existing warnings in ChatView.tsx are unchanged).

  • Server: ThreadForkService.test.ts (flag set, not inherited, runtime mode), Orchestrator.control-reads.test.ts (linked create, promote, cross-project rejection, fork from the last finished run while the source is mid-run, explicit mid-run pin rejected), ServerEnvironment.test.ts, SubagentProjection.test.ts (a subagent never inherits the flag), plus the existing fork, launch and projection suites.
  • Client runtime: full package (116 files), including new command, relationship and availability tests.
  • Web: sideChat.logic.test.ts, rightPanelStore.test.ts, Sidebar.logic.test.ts, CommandPalette.logic.test.ts, keybindings.test.ts, MessagesTimeline.test.tsx, ThreadDetailsPanel.test.tsx, ThreadRelationshipsControl.test.tsx, KeybindingsSettings.logic.test.ts.

Verified in a running client

Driven in the web client (headless Chrome against an isolated dev server in this worktree, fresh .t3 state, a real Codex GPT-6 Astra provider, no fixtures):

  • Fork menu on a finished response shows Fork to new thread and Ask about this response; Ask opens the panel tab with the inherited history, a "Forked from conversation" divider, "Sees up to run 1", and the Allow edits switch. The side chat is absent from the sidebar.
  • A question in the side chat is answered from the inherited context; the parent thread is unchanged; the tab survives a page reload; the title is regenerated after the first message.
  • Without history starts empty ("Sees no history"), linked to its parent with no fork label.
  • Mid-run: with the parent running sleep 60, /btw <question> opened a side chat and answered while the parent kept working and finished normally.
  • Selection toolbar shows Cite and Ask; Ask opens a side chat with the quote (> done) prefilled.
  • Side chats section (with + and the with/without-history menu), several tabs at once, Mod+Shift+B, /side and /btw in the slash menu.
  • Bring back to main ("It goes in with your next message"), Promote to thread (appears in the sidebar as a normal thread), Discard (tab and row removed).

Fixed after that pass (commits 9c67ff673ac, 34c14c6f861, bb9cd2440a4): /side, /btw, the keybinding, the palette entry and selection Ask reused the last + choice, so one "without history" pick made /btw answer with no context (now they share history whenever available; only + remembers); an open but idle side chat was hidden under a collapsed "Previous"; new side chats were titled after the parent; the row's discard icon was clipped and the details panel scrolled sideways; side chats were listed in both Side chats and Lineage (now only Side chats); the hover highlight on the +, caret and discard icons was off-center. Each fix was rechecked in the running client.

Review pass

A cross-vendor review (GPT-6 Astra) of the full diff found seven defects, all confirmed against the source and fixed in 9ea85060679 and caf6e7d1345:

  1. The side chat's ChatView shared the host's composer handle through context, so the host could send the side chat's draft settings or insert citations into it. The panel now provides its own handle.
  2. SideChatsSection called a store hook after its capability early return (a hook-order crash when capability changes while mounted). Hooks now run first; the lint warning is gone.
  3. Mod+S stashed both drafts because both composers listen on window. It now stashes only the composer whose chat contains the event target.
  4. With focus in a side chat, Mod+W fell through to closing the window. The host now handles rightPanel.close, rightPanel.toggle and threadPanel.toggle from inside a side chat, and nothing else.
  5. makeSubagentChildThread spread the parent, so a subagent started inside a side chat became a side chat. It now sets sideChat: false (new test).
  6. Deleting a parent left its side chats hidden everywhere. They are now listed as ordinary threads once the parent is gone (new test).
  7. "Ask about this response" on an inherited response (from before a fork) silently did nothing. Only the thread's own responses offer it now; inherited ones keep the plain fork button.

Rechecked in the running client after the fixes: typed drafts in both composers and pressed Mod+S in the side chat (only its draft was stashed); with a side chat open, Cite on a parent response put the quote in the parent's composer only; Mod+W in the side chat closed its tab and nothing else; a fresh fork shows the plain fork button on inherited responses; deleting a fork that had a side chat left that side chat in the sidebar as an ordinary thread. Findings 2 and 5 are covered by lint and a unit test rather than the client.

Not proven

Web only, one provider (Codex). Not exercised: the desktop shell, mobile, a remote or tunnel connection, Claude and OpenCode native forks, very narrow panel widths, whether Discard deletes the thread server-side beyond the row disappearing, and whether "Bring back to main" content is actually used by the parent on its next run. A promoted side chat keeps its supervised runtime mode.

Known gaps and follow-ups

  • Read-only is not a sandbox. V2 has no read-only runtime mode, so the default is approval-required (edits and commands ask first) and Allow edits is auto-accept-edits. A user can approve an edit in a side chat while the parent is running.
  • Diff, file and terminal links inside an embedded side chat currently do nothing rather than being hidden.
  • No model-picker note that changing the model gives up the prompt-cache hit; no hover card on side chat rows.
  • Mobile entry points and panel.

Model and harness: Claude Sonnet 5.5 (implementation and fixes) and Claude Opus 5.5 (base merge), Claude Code in T3 Code; cross-vendor review by GPT-6 Astra.

🤖 Generated with Claude Code

Checks

19 successful · 7 skipped · 2 neutral
All checks passed
4c1e85f
……@@ -8077,6 +8146,19 @@ export default function ChatView(props: ChatViewProps) {
8151+ if (sideChatsAvailable && !directAnnotation && !composerHasNonPromptContent) {
8152+ const sideCommand = parseSideCommand(promptRef.current);
8153+ if (sideCommand !== null) {
8154+ promptRef.current = "";
MmacroscopeappBoton line 8154Oct 1, 2026

🟡 Medium components/ChatView.tsx:8154

A failed /side <question> or /btw <question> permanently discards the user's question, so it cannot be retried from the composer. This branch clears promptRef and the composer draft before runSideChatCommand completes, while useSideChatActions.start only reports the failure; preserve the draft until the side chat is created successfully and clear it only then.

🤖 Copy this AI Prompt to have your agent fix this:
text
1In file @apps/web/src/components/ChatView.tsx around line 8154:
2
3A failed `/side <question>` or `/btw <question>` permanently discards the user's question, so it cannot be retried from the composer. This branch clears `promptRef` and the composer draft before `runSideChatCommand` completes, while `useSideChatActions.start` only reports the failure; preserve the draft until the side chat is created successfully and clear it only then.
……@@ -1174,6 +1187,7 @@ function OpenCommandPaletteDialog(props: {
11741187 threads.filter(
11751188 (thread) =>
11761189 thread.archivedAt === null &&
1190+ !thread.sideChat &&
MmacroscopeappBoton line 1190Oct 1, 2026

🟡 Medium components/CommandPalette.tsx:1190

Selecting a grouped project with only a surviving orphaned side chat creates or navigates to a new thread instead of reopening that chat. The unconditional !thread.sideChat filter removes side chats even when their parent is deleted, although makeSideChatListingFilter treats those chats as ordinary reachable threads; use that same visibility predicate here.

🤖 Copy this AI Prompt to have your agent fix this:
text
1In file @apps/web/src/components/CommandPalette.tsx around line 1190:
2
3Selecting a grouped project with only a surviving orphaned side chat creates or navigates to a new thread instead of reopening that chat. The unconditional `!thread.sideChat` filter removes side chats even when their parent is deleted, although `makeSideChatListingFilter` treats those chats as ordinary reachable threads; use that same visibility predicate here.
……@@ -3877,6 +3902,25 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
3917+ );
3918+ if (applied) {
3919+ setComposerHighlightedItemId(null);
3920+ if (opensNow) void onSideChatCommand("");
MmacroscopeappBoton line 3920Oct 1, 2026

🟡 Medium chat/ChatComposer.tsx:3920

Selecting /side from the menu opens an empty side chat even when the composer contains attachments or context chips, leaving that non-prompt content stranded in the parent draft. This branch calls onSideChatCommand("") without applying the typed-command path's composerHasNonPromptContent guard; only take the immediate side-chat path when the composer has no non-prompt content, otherwise preserve /side as prompt text.

🤖 Copy this AI Prompt to have your agent fix this:
text
1In file @apps/web/src/components/chat/ChatComposer.tsx around line 3920:
2
3Selecting `/side` from the menu opens an empty side chat even when the composer contains attachments or context chips, leaving that non-prompt content stranded in the parent draft. This branch calls `onSideChatCommand("")` without applying the typed-command path's `composerHasNonPromptContent` guard; only take the immediate side-chat path when the composer has no non-prompt content, otherwise preserve `/side` as prompt text.
……@@ -270,8 +273,9 @@ export function buildThreadActionItems<TThread extends BuildThreadActionItemsThr
270273 runThread: (thread: Pick<SidebarThreadSummary, "environmentId" | "id">) => Promise<void>;
271274 limit?: number;
272275}): CommandPaletteActionItem[] {
276+ const listable = makeSideChatListingFilter(input.threads);
MmacroscopeappBoton lines 276–276Oct 1, 2026

🟡 Medium components/CommandPalette.logic.ts:276

Archiving a parent hides its still-unarchived side chat from the command palette, leaving that chat unreachable unless its panel is already open. listable is built from all input.threads, so makeSideChatListingFilter still sees the archived parent as present even though the subsequent filter removes it; build the listing filter from unarchived threads as well.

suggestion
1 const listable = makeSideChatListingFilter(input.threads.filter((thread) => thread.archivedAt === null));
Also found in 1 other location(s)

apps/web/src/components/Sidebar.logic.ts:524

listable(thread) treats an archived parent as still present, even though this same filter excludes that parent via thread.archivedAt === null. Consequently, archiving a parent with an unarchived side chat hides both the parent and child from the sidebar (and the identical palette filter), leaving the surviving side chat unreachable unless its panel was already open. The presence set must exclude archived parents as well as deleted ones.

🤖 Copy this AI Prompt to have your agent fix this:
text
1In file @apps/web/src/components/CommandPalette.logic.ts around line 276:
2
3Archiving a parent hides its still-unarchived side chat from the command palette, leaving that chat unreachable unless its panel is already open. `listable` is built from all `input.threads`, so `makeSideChatListingFilter` still sees the archived parent as present even though the subsequent filter removes it; build the listing filter from unarchived threads as well.
4
5Also found in 1 other location(s):
6- apps/web/src/components/Sidebar.logic.ts:524 -- `listable(thread)` treats an archived parent as still present, even though this same filter excludes that parent via `thread.archivedAt === null`. Consequently, archiving a parent with an unarchived side chat hides both the parent and child from the sidebar (and the identical palette filter), leaving the surviving side chat unreachable unless its panel was already open. The presence set must exclude archived parents as well as deleted ones.
apps/web/src/components/chat/ChatComposer.tsxlines 2598–2598Open thread
……@@ -2589,6 +2595,24 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
25892595 label: "/model",
25902596 description: "Switch response model for this thread",
25912597 },
2598+ ...(props.onSideChatCommand
MmacroscopeappBoton lines 2598–2598Oct 1, 2026

🟡 Medium chat/ChatComposer.tsx:2598

Selecting /btw after existing prompt text inserts the literal command instead of opening or sending to side chat, because parseSideCommand only recognizes prompts that begin with /btw. Restrict this menu entry to prompt-start triggers (and likewise /side), or handle selection as a side-chat action.

diff
1- ...(props.onSideChatCommand
2+ ...(props.onSideChatCommand && composerTrigger.rangeStart === 0
🤖 Copy this AI Prompt to have your agent fix this:
text
1In file @apps/web/src/components/chat/ChatComposer.tsx around line 2598:
2
3Selecting `/btw` after existing prompt text inserts the literal command instead of opening or sending to side chat, because `parseSideCommand` only recognizes prompts that begin with `/btw`. Restrict this menu entry to prompt-start triggers (and likewise `/side`), or handle selection as a side-chat action.
……@@ -11280,3 +11389,37 @@ export default function ChatView(props: ChatViewProps) {
11415+ return (
11416+ <ComposerHandleContext value={composerRef}>
11417+ <ChatView
11418+ embedded
MmacroscopeappBoton line 11418Oct 1, 2026

🟡 Medium components/ChatView.tsx:11418

A side-chat composer popup is treated as part of the host chat, so pressing Mod+S while focus is in the side chat's portaled ProviderModelPicker popup stashes the host draft (and can trigger host thread shortcuts) instead of suppressing the shortcut for the embedded chat. Because PopoverPrimitive.Portal moves the popup outside [data-chat-embedded], isInSameChatView sees no embedded ancestor and classifies the target as belonging to the host. Preserve the side-chat ownership on portaled composer popups—for example, by portal-mounting them in the embedded container or checking an explicit chat-owner marker—before applying host shortcuts.

Also found in 1 other location(s)

apps/web/src/components/chat/ChatComposer.tsx:5602

The isInSameChatView check treats a target outside [data-chat-embedded] as belonging to the host composer. The side chat's ProviderModelPicker renders its focused popup through PopoverPrimitive.Portal, so pressing Mod+S while that popup is focused makes the host handler pass this line and stash the host draft; the side handler rejects it because its form is embedded. This can stash the wrong conversation whenever a side-chat composer popover has focus.

🤖 Copy this AI Prompt to have your agent fix this:
text
1In file @apps/web/src/components/ChatView.tsx around line 11418:
2
3A side-chat composer popup is treated as part of the host chat, so pressing `Mod+S` while focus is in the side chat's portaled `ProviderModelPicker` popup stashes the host draft (and can trigger host thread shortcuts) instead of suppressing the shortcut for the embedded chat. Because `PopoverPrimitive.Portal` moves the popup outside `[data-chat-embedded]`, `isInSameChatView` sees no embedded ancestor and classifies the target as belonging to the host. Preserve the side-chat ownership on portaled composer popups—for example, by portal-mounting them in the embedded container or checking an explicit chat-owner marker—before applying host shortcuts.
4
5Also found in 1 other location(s):
6- apps/web/src/components/chat/ChatComposer.tsx:5602 -- The `isInSameChatView` check treats a target outside `[data-chat-embedded]` as belonging to the host composer. The side chat's `ProviderModelPicker` renders its focused popup through `PopoverPrimitive.Portal`, so pressing `Mod+S` while that popup is focused makes the host handler pass this line and stash the host draft; the side handler rejects it because its form is embedded. This can stash the wrong conversation whenever a side-chat composer popover has focus.
……@@ -7612,8 +7679,10 @@ export default function ChatView(props: ChatViewProps) {
76127679 pasteAsTextShortcutUntilRef.current = Date.now() + 1_000;
76137680 }
76147681 };
7682+ if (embedded) return;
MmacroscopeappBoton line 7682Oct 1, 2026

🟡 Medium components/ChatView.tsx:7682

Side chats no longer support paste-to-focus or type-to-focus after the composer blurs: text pasted or typed over the timeline is dropped instead of being inserted into and expanding the side-chat composer. The if (embedded) return disables the side chat's listener, while the host listener also rejects [data-chat-embedded] targets; allow the embedded view's own listener to process these inputs and keep the host from duplicating them.

🤖 Copy this AI Prompt to have your agent fix this:
text
1In file @apps/web/src/components/ChatView.tsx around line 7682:
2
3Side chats no longer support paste-to-focus or type-to-focus after the composer blurs: text pasted or typed over the timeline is dropped instead of being inserted into and expanding the side-chat composer. The `if (embedded) return` disables the side chat's listener, while the host listener also rejects `[data-chat-embedded]` targets; allow the embedded view's own listener to process these inputs and keep the host from duplicating them.
apps/web/src/sideChat.logic.tslines 67–67Open thread
……@@ -0,0 +1,123 @@
64+ };
65+}
66+
67+const SETTLED_RUN_STATUSES = new Set(["completed", "failed", "interrupted", "cancelled"]);
MmacroscopeappBoton lines 67–67Oct 1, 2026

🟡 Medium src/sideChat.logic.ts:67

A side chat whose latest run is rolled_back is treated as still running, so it remains in the active section, increments the running count, and sideChatActivity returns running instead of a finished status. Add rolled_back to SETTLED_RUN_STATUSES so terminal rolled-back runs follow the finished-run path.

suggestion
1const SETTLED_RUN_STATUSES = new Set(["completed", "failed", "interrupted", "cancelled", "rolled_back"]);
🤖 Copy this AI Prompt to have your agent fix this:
text
1In file @apps/web/src/sideChat.logic.ts around line 67:
2
3A side chat whose latest run is `rolled_back` is treated as still running, so it remains in the active section, increments the running count, and `sideChatActivity` returns `running` instead of a finished status. Add `rolled_back` to `SETTLED_RUN_STATUSES` so terminal rolled-back runs follow the finished-run path.
Synced from GitHub

Approvability

Verdict: Not approved

Macroscope's review found this PR not approvable — This PR introduces a large, cross-layer side-chat workflow with new persistent thread semantics, provider-history forks, embedded composers, panel state, and a new default keyboard shortcut. Several unresolved Medium findings also identify concrete draft, navigation, lifecycle, and keyboard-scoping risks that merit human review.

Not approved because:

  • 8 blocking correctness issues found at or above your repo's Minimum Blocking Severity

Adjust the Minimum Blocking Severity for this repo — including turning it Off — in Settings. You can add or adjust custom eligibility rules. Learn more.

Note

This comment is posted by Julius' dot

This uses ordinary V2 threads, avoiding the separate protocol rejected in #8296 and the V1 code deferred in #9436. Can a maintainer confirm that the invitation to revisit this on V2 covers this persistent side-chat flag and the new linked-thread path without inherited history?

Direction and scope approval is still unclear in the linked discussions. Leaving this open for that decision; the supplied recordings demonstrate the proposed interaction.