| … | … | @@ -12491,31 +12586,68 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio | |
| 12634 | + | Effect.mapError((cause) => new OrchestratorProjectionError({ threadId, cause })), | |
| 12635 | + | ); | |
| 12636 | + | if (sideChat?.lineage.sideChat !== true || sideChat.deletedAt !== null) continue; | |
| 12637 | + | const sideChatDeletion = yield* planDeletion({ | |
🟠 High orchestration-v2/Orchestrator.ts:12637
Deleting a parent can leave its side chat undeleted: a concurrent side-chat command can commit a full-row payload with deletedAt: null after this deletion, resurrecting the child after cleanup effects are queued. This cascade plans and commits the child deletion while holding only the parent dispatch lock, so child work can also escape the deletion snapshot; serialize child planning and commit with side-chat dispatches.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
| 1 | In file @apps/server/src/orchestration-v2/Orchestrator.ts around line 12637: |
| 2 | |
| 3 | Deleting a parent can leave its side chat undeleted: a concurrent side-chat command can commit a full-row payload with `deletedAt: null` after this deletion, resurrecting the child after cleanup effects are queued. This cascade plans and commits the child deletion while holding only the parent dispatch lock, so child work can also escape the deletion snapshot; serialize child planning and commit with side-chat dispatches. |
Note
🤖 Claude Opus 5.5 responding on behalf of Theo
Fixed in 5a99500. thread.delete takes the parent lock, then each live side chat's lock, and a side chat fork takes the parent lock first. A fork of a deleted parent is rejected. A deterministic race test in runtimeLayer.test.ts fails without the lock change.
| … | … | @@ -4222,6 +4234,27 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio | |
| 4247 | + | transfer.type === "merge_back" && | |
| 4248 | + | transfer.status === "pending" && | |
| 4249 | + | transfer.sourceThreadId !== command.sourceThreadId, | |
| 4250 | + | ); | |
🟠 High orchestration-v2/Orchestrator.ts:4247
Bringing fork C back to idle B is incorrectly rejected when B has a pending merge-back transfer to A; the error claims B already has incoming context, although B→A cannot be consumed by B. This guard checks the source thread but not the target, so restrict it to transfers whose targetThreadId is command.targetThreadId.
| 1 | - (transfer) => |
| 2 | - transfer.type === "merge_back" && |
| 3 | - transfer.status === "pending" && |
| 4 | - transfer.sourceThreadId !== command.sourceThreadId, |
| 5 | + (transfer) => |
| 6 | + transfer.type === "merge_back" && |
| 7 | + transfer.status === "pending" && |
| 8 | + transfer.targetThreadId === command.targetThreadId && |
| 9 | + transfer.sourceThreadId !== command.sourceThreadId, |
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
| 1 | In file @apps/server/src/orchestration-v2/Orchestrator.ts around lines 4247-4250: |
| 2 | |
| 3 | Bringing fork C back to idle B is incorrectly rejected when B has a pending merge-back transfer to A; the error claims B already has incoming context, although B→A cannot be consumed by B. This guard checks the source thread but not the target, so restrict it to transfers whose `targetThreadId` is `command.targetThreadId`. |
Note
🤖 Claude Opus 5.5 responding on behalf of Theo
Fixed in 5a99500. The guard now only counts pending merge-backs whose target is this command's target. Covered by a nested-fork row in the merge-back test in ThreadFork.execution.test.ts.
| … | … | @@ -100,6 +105,7 @@ export function useChatShortcuts(input: ChatShortcutsInput) { | |
| 100 | 105 | }); | |
| 101 | 106 | // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- attaches window keydown listeners for chat shortcuts and find dismissal for the component's lifetime | |
| 102 | 107 | useEffect(() => { | |
| 108 | + | if (embedded) return; | |
🟡 Medium chat/useChatShortcuts.ts:108
Composer shortcuts such as mod+shift+m and the effort/mode chords do nothing when the side chat is focused. embedded prevents this hook from attaching listeners, while the parent listener rejects events from [data-side-chat-pane], so composerShortcutCommands never dispatches them; route composer shortcuts to the focused side chat without forwarding parent-thread actions.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
| 1 | In file @apps/web/src/components/chat/useChatShortcuts.ts around line 108: |
| 2 | |
| 3 | Composer shortcuts such as `mod+shift+m` and the effort/mode chords do nothing when the side chat is focused. `embedded` prevents this hook from attaching listeners, while the parent listener rejects events from `[data-side-chat-pane]`, so `composerShortcutCommands` never dispatches them; route composer shortcuts to the focused side chat without forwarding parent-thread actions. |
Note
🤖 Claude Opus 5.5 responding on behalf of Theo
Fixed in 5a99500. A side chat's composer shortcuts (model, effort, mode) run from one keydown listener on that composer's element. Thread-level commands stay off in the side chat. Browser test added in useChatShortcuts.browser.test.tsx.
| … | … | @@ -348,6 +349,12 @@ export const layer: Layer.Layer< | |
| 352 | + | const sideChatContext = | |
| 353 | + | projection.contextHandoffs.find( | |
| 354 | + | (handoff) => | |
| 355 | + | handoff.targetRunId === run.id && | |
🟠 High orchestration-v2/ProviderTurnStartService.ts:355
The side-chat digest is permanently lost when its first run fails before provider acceptance or is a Codex/Claude /goal turn: this lookup only selects handoffs targeting the current run, and the normal carry-forward excludes side-chat handoffs, while the goal path sends only userText. The next eligible run therefore receives no parent context. Preserve the digest until a provider turn accepts it, deferring it past verbatim goal commands.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
| 1 | In file @apps/server/src/orchestration-v2/ProviderTurnStartService.ts around line 355: |
| 2 | |
| 3 | The side-chat digest is permanently lost when its first run fails before provider acceptance or is a Codex/Claude `/goal` turn: this lookup only selects handoffs targeting the current run, and the normal carry-forward excludes side-chat handoffs, while the goal path sends only `userText`. The next eligible run therefore receives no parent context. Preserve the digest until a provider turn accepts it, deferring it past verbatim goal commands. |
Note
🤖 Claude Opus 5.5 responding on behalf of Theo
Fixed in 5a99500. The side chat context now uses the same carry-forward rule as other handoffs. It waits past /goal and /compact turns and runs that never reached the provider, and it is marked delivered once a provider accepts it. Covered by a failed-first-run row in ProviderTurnStartService.test.ts.
| … | … | @@ -730,17 +733,34 @@ export function useComposerSend(input: ComposerSendInput) { | |
| 743 | + | composerEnvironmentContexts.length === 0; | |
| 744 | + | const standaloneSlashCommand = | |
| 745 | + | sendInteractionModeEnabled && textOnly ? parseStandaloneComposerSlashCommand(trimmed) : null; | |
| 746 | + | const sideChatCommand = onSideChat ? parseSideChatCommand(trimmed) : null; | |
🟠 High chat/useComposerSend.ts:746
On a server thread with a completed actionable plan, sending /side question or /btw question submits the literal command as plan feedback and starts a turn on the parent instead of opening a side chat. The plan-follow-up branch returns before sideChatCommand is parsed; handle side-chat commands before that branch so they are dispatched locally.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
| 1 | In file @apps/web/src/components/chat/useComposerSend.ts around line 746: |
| 2 | |
| 3 | On a server thread with a completed actionable plan, sending `/side question` or `/btw question` submits the literal command as plan feedback and starts a turn on the parent instead of opening a side chat. The plan-follow-up branch returns before `sideChatCommand` is parsed; handle side-chat commands before that branch so they are dispatched locally. |
Note
🤖 Claude Opus 5.5 responding on behalf of Theo
Fixed in 5a99500. The side chat command is parsed at the top of the send path, before the pending-question and plan follow-up branches.
| … | … | @@ -3695,121 +3762,129 @@ export default function ChatView(props: ChatViewProps) { | |
| 3810 | 3885 | </div> | |
| 3811 | 3886 | ||
| 3812 | - | {rightPanelPresent && activeThreadRef ? ( | |
| 3887 | + | {rightPanelPresent && activeThreadRef && !embedded ? ( | |
🟡 Medium components/ChatView.tsx:3887
In embedded side chats, clicking a tool diff or file attachment opens a right-panel surface that is never rendered, so the preview is invisible. The timeline and composer still receive onOpenToolDiffStable, onOpenTurnDiffStable, and openFileAttachmentStable, but this !embedded guard removes their only ChatRightPanel renderer; route these actions to a visible host or provide an embedded preview.
No longer relevant as of 5a9950052cd112eab49c6a95ecad37ba497e2ae7
Note
🤖 Claude Opus 5.5 responding on behalf of Theo
Partly fixed in 5a99500. Attachments and tool diffs from a side chat now open in the parent's right panel. Turn diffs are hidden, because side chat runs have no checkpoints. Markdown file links and browser links inside a side chat still target the hidden panel. That fix is next.
Note
🤖 Claude Opus 5.5 responding on behalf of Theo
Fixed in 7e88d5b949b. While a side chat pane is mounted, the right panel store forwards that thread's user actions to the parent's panel, so markdown file links, browser links, and attachments all open where you can see them. Automatic updates still apply to the side chat itself. Covered in rightPanelStore.test.ts.
| … | … | @@ -236,6 +238,24 @@ export function useComposerMenuItems({ | |
| 236 | 238 | }, | |
| 237 | 239 | ] as const) | |
| 238 | 240 | : []), | |
| 241 | + | ...(sideChatAvailable | |
🟠 High chat/useComposerMenuItems.ts:241
Selecting /side or /btw after existing prompt text sends the text as a normal main-thread message instead of opening a side chat. These items are offered at nonzero composerTrigger.rangeStart, but parseSideChatCommand only recognizes a leading command; only offer them when the trigger starts at position 0.
| 1 | ...(sideChatAvailable && composerTrigger.rangeStart === 0 |
Also found in 1 other location(s)
apps/web/src/components/chat/ChatComposer.tsx:1878
Selecting
/sideor/btwafter existing prompt text silently leaves it as an ordinary main-thread message. For example, typeExplain this /si, select/side, and send: this branch producesExplain this /siderather than starting a side chat.useComposerMenuItemsoffers these built-ins at nonzero offsets (slashCommandItemsForPromptPositiononly filters provider commands), butparseSideChatCommandrequires the command at the beginning, souseComposerSendfalls through to the normal send snapshot. Reject non-leading selections or move the command to the beginning so selecting the advertised side-chat action cannot accidentally dispatch into the main conversation.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
| 1 | In file @apps/web/src/components/chat/useComposerMenuItems.ts around line 241: |
| 2 | |
| 3 | Selecting `/side` or `/btw` after existing prompt text sends the text as a normal main-thread message instead of opening a side chat. These items are offered at nonzero `composerTrigger.rangeStart`, but `parseSideChatCommand` only recognizes a leading command; only offer them when the trigger starts at position `0`. |
| 4 | |
| 5 | Also found in 1 other location(s): |
| 6 | - apps/web/src/components/chat/ChatComposer.tsx:1878 -- Selecting `/side` or `/btw` after existing prompt text silently leaves it as an ordinary main-thread message. For example, type `Explain this /si`, select `/side`, and send: this branch produces `Explain this /side ` rather than starting a side chat. `useComposerMenuItems` offers these built-ins at nonzero offsets (`slashCommandItemsForPromptPosition` only filters provider commands), but `parseSideChatCommand` requires the command at the beginning, so `useComposerSend` falls through to the normal send snapshot. Reject non-leading selections or move the command to the beginning so selecting the advertised side-chat action cannot accidentally dispatch into the main conversation. |
Note
🤖 Claude Opus 5.5 responding on behalf of Theo
Fixed in 5a99500. /side and /btw are only offered when the trigger starts at position 0. Asserted in composer-logic.test.ts.
| … | … | @@ -60,6 +69,26 @@ export function useRightPanelSurfaceClose(input: { | |
| 74 | + | readEnvironmentScope(ref.environmentId, AuthOrchestrationOperateScope) && | |
| 75 | + | readThreadShell(scopeThreadRef(ref.environmentId, surface.threadId))?.latestRun === null | |
| 76 | + | ) { | |
| 77 | + | void deleteThread({ | |
🟠 High hooks/useRightPanelSurfaceClose.ts:77
Closing the tab can delete a conversation after its first message has started, discarding the conversation and cancelling its active run. This check uses the client’s potentially stale latestRun, while deleteThread sends an unconditional thread.delete; make deletion conditional on the thread still having no runs when the server executes it.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
| 1 | In file @apps/web/src/hooks/useRightPanelSurfaceClose.ts around line 77: |
| 2 | |
| 3 | Closing the tab can delete a conversation after its first message has started, discarding the conversation and cancelling its active run. This check uses the client’s potentially stale `latestRun`, while `deleteThread` sends an unconditional `thread.delete`; make deletion conditional on the thread still having no runs when the server executes it. |
Note
🤖 Claude Opus 5.5 responding on behalf of Theo
Fixed in 5a99500. Tab close sends thread.delete with the new onlyIfUnused: true, which the server checks under the thread lock. A thread with runs or messages is left alone.
| … | … | @@ -730,17 +733,34 @@ export function useComposerSend(input: ComposerSendInput) { | |
| 755 | + | ); | |
| 756 | + | return; | |
| 757 | + | } | |
| 758 | + | onSideChat(sideChatCommand.prompt); | |
🟠 High chat/useComposerSend.ts:758
When opening a /side chat is rejected or its first-turn send fails, this branch still clears the composer, permanently losing the user's prompt. onSideChat discards the sideChat.open() result, so this code cannot tell whether the prompt was accepted; clear it only after confirmed success, or restore it on failure.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
| 1 | In file @apps/web/src/components/chat/useComposerSend.ts around line 758: |
| 2 | |
| 3 | When opening a `/side` chat is rejected or its first-turn send fails, this branch still clears the composer, permanently losing the user's prompt. `onSideChat` discards the `sideChat.open()` result, so this code cannot tell whether the prompt was accepted; clear it only after confirmed success, or restore it on failure. |
Note
🤖 Claude Opus 5.5 responding on behalf of Theo
Fixed in 5a99500. open returns an outcome. The composer clears only when the fork and the first send both succeed and the draft is unchanged. On failure the draft stays and the empty side chat is removed.