From 8bd2d46b65566c4895284c23f13d948bfc7241ed Mon Sep 17 00:00:00 2001 From: Yuruzuu Date: Sun, 1 Nov 2026 15:59:59 +0800 Subject: [PATCH 1/6] feat(web): ask about selected text in a side chat Co-Authored-By: Claude Opus 5.5 --- apps/web/src/components/ChatView.tsx | 13 ++++- .../chat/AssistantSelectionToolbar.tsx | 53 ++++++++++++++----- .../src/components/chat/MessagesTimeline.tsx | 3 ++ apps/web/src/components/chat/SideChatPane.tsx | 4 +- apps/web/src/components/chat/useSideChat.ts | 29 ++++++++-- ...useRightPanelSurfaceClose.browser.test.tsx | 42 ++++++++++++++- .../src/hooks/useRightPanelSurfaceClose.ts | 5 ++ docs/user/thread-sidebar.md | 8 +-- 8 files changed, 130 insertions(+), 27 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index ea477b9144791..990ab3909d4c4 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -126,7 +126,7 @@ import { AsyncResult } from "effect/reactivity"; import { isElectron } from "../env"; import { useDiffPanelStore } from "../diffPanelStore"; import { useActiveThreadRef } from "../hooks/useActiveThreadRef"; -import { composerRequiresModifier } from "../composer-logic"; +import { composerRequiresModifier, formatAssistantCitationForComposer } from "../composer-logic"; import { derivePhase, deriveActiveWorkStartedAt, @@ -2791,7 +2791,11 @@ export default function ChatView(props: ChatViewProps) { ? "Available once the thread's worktree is set up." : null; const openSideChatStable = useStableCallback(() => void sideChat.open()); - const sendSideChatStable = useStableCallback((prompt: string) => sideChat.open(prompt)); + const sendSideChatStable = useStableCallback((prompt: string) => sideChat.open({ prompt })); + const openCitationInSideChatStable = useStableCallback( + (citation: AssistantCitation) => + void sideChat.open({ draft: formatAssistantCitationForComposer(citation) }), + ); const { onConfirmRevertStable, onForkFromRunStable, onRevertTimelineTurnStable } = useTimelineRewind({ environmentId, @@ -3400,6 +3404,11 @@ export default function ChatView(props: ChatViewProps) { citationRequest={citationRequest} citationHistory={citationHistory} onCiteAssistantText={citeAssistantTextStable} + onOpenCitationInSideChat={ + sideChat.available && sideChatDisabledReason === null + ? openCitationInSideChatStable + : undefined + } {...(activeProject ? { onRunShellCommand: parentPanel?.runShellCommand ?? runShellCommandStable } : {})} diff --git a/apps/web/src/components/chat/AssistantSelectionToolbar.tsx b/apps/web/src/components/chat/AssistantSelectionToolbar.tsx index 6eed862ed7a03..5022e6bbc9bf4 100644 --- a/apps/web/src/components/chat/AssistantSelectionToolbar.tsx +++ b/apps/web/src/components/chat/AssistantSelectionToolbar.tsx @@ -4,7 +4,7 @@ import { type AssistantCitation, type ScopedThreadRef, } from "@t3tools/contracts"; -import { QuoteIcon } from "lucide-react"; +import { MessagesSquareIcon, QuoteIcon } from "lucide-react"; import { use, useEffect, useLayoutEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { @@ -18,23 +18,26 @@ import { type SelectionActionPoint, } from "~/lib/selectionActions"; import { Button } from "../ui/button"; +import { ButtonGroup } from "../ui/group"; import { BackgroundThreadContext } from "./backgroundThread"; export function AssistantSelectionToolbar({ viewport, threadRef, onCite, + onOpenInSideChat, }: { viewport: HTMLElement | null; threadRef: ScopedThreadRef; onCite: (citation: AssistantCitation, sourceAnchor: AssistantCitationSourceAnchor) => boolean; + onOpenInSideChat: ((citation: AssistantCitation) => void) | undefined; }) { const [selection, setSelection] = useState<{ citation: AssistantCitation; position: SelectionActionPoint; sourceAnchor: AssistantCitationSourceAnchor; } | null>(null); - const toolbarRef = useRef(null); + const toolbarRef = useRef(null); const actionsRef = useRef | null>(null); const backgroundThread = use(BackgroundThreadContext); @@ -160,10 +163,11 @@ export function AssistantSelectionToolbar({ ) { return; } - if (toolbar.disabled) return; + const firstAction = toolbar.querySelector("button:not(:disabled)"); + if (!firstAction) return; event.preventDefault(); event.stopPropagation(); - toolbar.focus({ preventScroll: true }); + firstAction.focus({ preventScroll: true }); }; // Tab belongs to the foreground chat; a background timeline only follows its selection. if (!backgroundThread) document.addEventListener("keydown", focusActions, true); @@ -188,18 +192,18 @@ export function AssistantSelectionToolbar({ dismiss(); return true; }; + const openInSideChat = () => { + onOpenInSideChat?.(selection.citation); + window.getSelection()?.removeAllRanges(); + dismiss(); + }; return createPortal( - , + + {onOpenInSideChat && !tooLong ? ( + + ) : null} + , document.body, ); } diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 4c28163766058..e1dfa1b1d839f 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -223,6 +223,7 @@ interface MessagesTimelineProps { citation: AssistantCitation, sourceAnchor: AssistantCitationSourceAnchor, ) => boolean; + onOpenCitationInSideChat?: ((citation: AssistantCitation) => void) | undefined; isWorking: boolean; runlessWorkActive?: boolean; activeTurnStartedAt?: string | null; @@ -336,6 +337,7 @@ const ConversationTimeline = memo(function ConversationTimeline({ citationRequest = null, citationHistory = CITED_HISTORY_LOADING, onCiteAssistantText, + onOpenCitationInSideChat, isWorking, runlessWorkActive = false, activeTurnStartedAt = null, @@ -1315,6 +1317,7 @@ const ConversationTimeline = memo(function ConversationTimeline({ viewport={timelineViewportElement} threadRef={citationThreadRef} onCite={onCiteAssistantText} + onOpenInSideChat={onOpenCitationInSideChat} /> ) : null} diff --git a/apps/web/src/components/chat/SideChatPane.tsx b/apps/web/src/components/chat/SideChatPane.tsx index 1a5bd1141b31f..8e28614d06d56 100644 --- a/apps/web/src/components/chat/SideChatPane.tsx +++ b/apps/web/src/components/chat/SideChatPane.tsx @@ -14,6 +14,7 @@ import { import { Trash2Icon, XIcon } from "lucide-react"; import { useEffect, useState } from "react"; +import { useComposerDraftStore } from "../../composerDraftStore"; import { ComposerHandleContext } from "../../composerHandleContext"; import { useClientSettings } from "../../hooks/useSettings"; import type { SideChatParentPanel } from "../../lib/sideChatPane"; @@ -111,6 +112,7 @@ export default function SideChatPane(props: { }); setDeleting(false); if (result._tag === "Success") { + useComposerDraftStore.getState().clearDraftThread(threadRef); useRightPanelStore.getState().closeSurface(parentRef, `side-chat:${threadId}`); } else if (!isAtomCommandInterrupted(result)) { const error = squashAtomCommandFailure(result); @@ -123,7 +125,7 @@ export default function SideChatPane(props: { }; return ( -
+
{shell?.title ?? "Side chat"} diff --git a/apps/web/src/components/chat/useSideChat.ts b/apps/web/src/components/chat/useSideChat.ts index 7ac27f758265f..932e25c4b7ff2 100644 --- a/apps/web/src/components/chat/useSideChat.ts +++ b/apps/web/src/components/chat/useSideChat.ts @@ -9,6 +9,8 @@ import { import { useRef } from "react"; import { newMessageId, newThreadId } from "~/lib/utils"; +import { requestWorkspaceFocus } from "~/lib/workspaceFocus"; +import { useComposerDraftStore } from "../../composerDraftStore"; import { useStableCallback } from "../../hooks/useStableCallback"; import { useRightPanelStore } from "../../rightPanelStore"; import { waitForThreadShell } from "../../state/entities"; @@ -16,9 +18,15 @@ import { threadEnvironment } from "../../state/threads"; import { useAtomCommand } from "../../state/use-atom-command"; import { useOrchestrationCommand } from "../../state/use-orchestration-command"; +/** `prompt` is sent as the first message; `draft` waits in the side chat's composer. */ +interface SideChatSeed { + readonly prompt?: string; + readonly draft?: string; +} + /** * Starts side chats from `threadRef`, or offers none when it is null. `open` forks the - * thread's latest stable point, sends `prompt` there, and shows the side chat's tab. It + * thread's latest stable point, applies the seed there, and shows the side chat's tab. It * resolves false when the side chat did not start, or another one is still starting. */ export function useSideChat(input: { @@ -39,7 +47,7 @@ export function useSideChat(input: { const error = squashAtomCommandFailure(failure); input.onError(error instanceof Error ? error.message : fallback); }; - const start = async (threadRef: ScopedThreadRef, prompt: string | undefined) => { + const start = async (threadRef: ScopedThreadRef, seed: SideChatSeed) => { const targetThreadId = newThreadId(); const fork = await forkSideChat({ environmentId: threadRef.environmentId, @@ -60,7 +68,12 @@ export function useSideChat(input: { discard(); return false; } - const text = prompt?.trim(); + const text = seed.prompt?.trim(); + if (seed.draft) { + useComposerDraftStore + .getState() + .setPrompt(scopeThreadRef(threadRef.environmentId, targetThreadId), seed.draft); + } // Send before showing the tab: closing a tab whose side chat has no run deletes it. if (text) { // The server asks before edits on a side chat's first turn whatever mode is sent. @@ -82,13 +95,19 @@ export function useSideChat(input: { } } useRightPanelStore.getState().openSideChat(threadRef, targetThreadId); + if (seed.draft) { + requestWorkspaceFocus({ + area: "panel", + selector: `[data-side-chat-pane="${targetThreadId}"] [data-testid="composer-editor"]`, + }); + } return true; }; - const open = async (prompt?: string) => { + const open = async (seed: SideChatSeed = {}) => { const { threadRef } = input; if (!threadRef || pendingRef.current) return false; pendingRef.current = true; - return start(threadRef, prompt).finally(() => { + return start(threadRef, seed).finally(() => { pendingRef.current = false; }); }; diff --git a/apps/web/src/hooks/useRightPanelSurfaceClose.browser.test.tsx b/apps/web/src/hooks/useRightPanelSurfaceClose.browser.test.tsx index 9b1299aee1dea..baa677030a0fd 100644 --- a/apps/web/src/hooks/useRightPanelSurfaceClose.browser.test.tsx +++ b/apps/web/src/hooks/useRightPanelSurfaceClose.browser.test.tsx @@ -11,6 +11,8 @@ const fixture = vi.hoisted(() => ({ allowed: new Set(), sessions: new Map(), failClose: false, + sideChatShell: null as { latestRun: { runId: string } | null } | null, + clearedDrafts: [] as ScopedThreadRef[], })); vi.mock("../lib/terminalCloseConfirm", () => ({ confirmTerminalClose: (labels: readonly string[]) => fixture.confirm(labels), @@ -31,13 +33,17 @@ vi.mock("../state/session", () => ({ vi.mock("../state/entities", () => ({ useQuietThreadShell: () => null, useProject: () => null, - readThreadShell: () => null, + readThreadShell: () => fixture.sideChatShell, })); vi.mock("../state/terminalSessions", () => ({ useKnownTerminalSessions: () => null })); vi.mock("../composerDraftStore", async () => { const Schema = await import("effect/Schema"); return { - useComposerDraftStore: () => null, + useComposerDraftStore: Object.assign(() => null, { + getState: () => ({ + clearDraftThread: (ref: ScopedThreadRef) => fixture.clearedDrafts.push(ref), + }), + }), DraftId: Schema.String.pipe(Schema.brand("DraftId")), }; }); @@ -59,6 +65,7 @@ vi.mock("../state/terminal", () => ({ terminalEnvironment: { open: {}, close: {} vi.mock("../state/preview", () => ({ previewEnvironment: { close: {} } })); vi.mock("../state/threads", () => ({ threadEnvironment: { delete: {} } })); vi.mock("../state/shell", () => ({ environmentShell: { stateValueAtom: () => null } })); +vi.mock("../rpc/atomRegistry", () => ({ appAtomRegistry: { get: () => ({ status: "live" }) } })); vi.mock("@t3tools/client-runtime/state/runtime", () => ({ isAtomCommandInterrupted: () => false, })); @@ -67,6 +74,8 @@ function setup() { fixture.allowed = new Set(["environment-a", "environment-b"]); fixture.sessions = new Map(); fixture.failClose = false; + fixture.sideChatShell = null; + fixture.clearedDrafts = []; useRightPanelStore.setState({ byThreadKey: {} }); useTerminalUiStateStore.setState({ terminalUiStateByThreadKey: {} }); const original: ScopedThreadRef = { @@ -135,3 +144,32 @@ it.each(["cancel", "revoked"])( ).toHaveLength(1); }, ); + +it.each([ + { latestRun: null, cleared: true }, + { latestRun: { runId: "run-1" }, cleared: false }, +])( + "clears a closed side chat's draft only when it never ran, cleared=$cleared", + async ({ latestRun, cleared }) => { + const { original } = setup(); + fixture.sideChatShell = { latestRun }; + const sideChatId = ThreadId.make("side-chat-a"); + useRightPanelStore.getState().openSideChat(original, sideChatId); + const surface = selectThreadRightPanelState( + useRightPanelStore.getState().byThreadKey, + original, + ).surfaces.find((entry) => entry.kind === "side-chat")!; + function SideChatHarness() { + const { closeSurface } = useRightPanelSurfaceClose({ + threadRef: original, + terminalLabelsById: new Map(), + }); + return ; + } + await using screen = await render(); + await screen.getByRole("button").click(); + expect(fixture.clearedDrafts).toEqual( + cleared ? [{ environmentId: original.environmentId, threadId: sideChatId }] : [], + ); + }, +); diff --git a/apps/web/src/hooks/useRightPanelSurfaceClose.ts b/apps/web/src/hooks/useRightPanelSurfaceClose.ts index 305c2a73dd3b4..995a591cca730 100644 --- a/apps/web/src/hooks/useRightPanelSurfaceClose.ts +++ b/apps/web/src/hooks/useRightPanelSurfaceClose.ts @@ -9,6 +9,7 @@ import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; import { agentControlledBrowserCloseConfirmation } from "../components/ChatView.logic"; import { closePreviewSession } from "../components/preview/closePreviewSession"; import { useThreadTerminalActions } from "../components/terminal/useThreadTerminalActions"; +import { useComposerDraftStore } from "../composerDraftStore"; import { confirmTerminalClose } from "../lib/terminalCloseConfirm"; import { focusPanelTerminalAfterClose } from "../lib/terminalFocus"; import { readLocalApi } from "../localApi"; @@ -79,6 +80,10 @@ export function useRightPanelSurfaceClose(input: { readThreadShell(scopeThreadRef(ref.environmentId, surface.threadId)) === null ) ) { + const sideChatRef = scopeThreadRef(ref.environmentId, surface.threadId); + if (readThreadShell(sideChatRef)?.latestRun === null) { + useComposerDraftStore.getState().clearDraftThread(sideChatRef); + } // The server keeps a side chat with a run or message. Reopen skips one it deleted. void deleteThread({ environmentId: ref.environmentId, diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index bed4ed73a40c5..9213e536dcc56 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -291,9 +291,11 @@ A side chat lets you ask a question about a thread without adding to it. It star with what the thread knows, and the thread keeps working while you ask. On web and desktop, start a message with `/side` or `/btw`, then your question. -You can also choose **Side chat** from the **+** menu in the right panel. The side -chat opens as a tab there, with its own composer. Side chats do not show in the -sidebar. Find one again under **Side chats** in thread details. +You can also choose **Side chat** from the **+** menu in the right panel, or select +text in an assistant response and choose **Ask** to start one with that text cited +in its composer. The side chat opens as a tab there, with its own composer. Side +chats do not show in the sidebar. Find one again under **Side chats** in thread +details. A side chat works in the same folder as the thread. Its first reply asks before it edits files or runs commands. After that, it uses the permission mode you choose From d7a9fc9313c8b46cdbdfec24b1132ce133645290 Mon Sep 17 00:00:00 2001 From: Yuruzuu Date: Sun, 1 Nov 2026 16:15:53 +0800 Subject: [PATCH 2/6] fix(web): keep a side chat's draft until its deletion is confirmed Co-Authored-By: Claude Opus 5.5 --- ...useRightPanelSurfaceClose.browser.test.tsx | 29 +++++++++++++++---- .../src/hooks/useRightPanelSurfaceClose.ts | 20 ++++++------- apps/web/src/state/entities.ts | 10 +++++++ 3 files changed, 43 insertions(+), 16 deletions(-) diff --git a/apps/web/src/hooks/useRightPanelSurfaceClose.browser.test.tsx b/apps/web/src/hooks/useRightPanelSurfaceClose.browser.test.tsx index baa677030a0fd..b7ddddef6c2aa 100644 --- a/apps/web/src/hooks/useRightPanelSurfaceClose.browser.test.tsx +++ b/apps/web/src/hooks/useRightPanelSurfaceClose.browser.test.tsx @@ -12,6 +12,9 @@ const fixture = vi.hoisted(() => ({ sessions: new Map(), failClose: false, sideChatShell: null as { latestRun: { runId: string } | null } | null, + threadRemoved: false, + failDelete: false, + shellStatus: "live", clearedDrafts: [] as ScopedThreadRef[], })); vi.mock("../lib/terminalCloseConfirm", () => ({ @@ -34,6 +37,7 @@ vi.mock("../state/entities", () => ({ useQuietThreadShell: () => null, useProject: () => null, readThreadShell: () => fixture.sideChatShell, + waitForThreadRemoval: async () => fixture.threadRemoved, })); vi.mock("../state/terminalSessions", () => ({ useKnownTerminalSessions: () => null })); vi.mock("../composerDraftStore", async () => { @@ -56,6 +60,7 @@ vi.mock("../state/use-atom-command", () => ({ }) => { const key = `${request.environmentId}/${request.input.threadId}/${request.input.terminalId}`; if (label === "terminal close" && fixture.failClose) return { _tag: "Failure" }; + if (label === "side chat delete" && fixture.failDelete) return { _tag: "Failure" }; if (label === "terminal close" || label === "terminal write") fixture.sessions.delete(key); return { _tag: "Success" }; }, @@ -65,7 +70,9 @@ vi.mock("../state/terminal", () => ({ terminalEnvironment: { open: {}, close: {} vi.mock("../state/preview", () => ({ previewEnvironment: { close: {} } })); vi.mock("../state/threads", () => ({ threadEnvironment: { delete: {} } })); vi.mock("../state/shell", () => ({ environmentShell: { stateValueAtom: () => null } })); -vi.mock("../rpc/atomRegistry", () => ({ appAtomRegistry: { get: () => ({ status: "live" }) } })); +vi.mock("../rpc/atomRegistry", () => ({ + appAtomRegistry: { get: () => ({ status: fixture.shellStatus }) }, +})); vi.mock("@t3tools/client-runtime/state/runtime", () => ({ isAtomCommandInterrupted: () => false, })); @@ -75,6 +82,9 @@ function setup() { fixture.sessions = new Map(); fixture.failClose = false; fixture.sideChatShell = null; + fixture.threadRemoved = false; + fixture.failDelete = false; + fixture.shellStatus = "live"; fixture.clearedDrafts = []; useRightPanelStore.setState({ byThreadKey: {} }); useTerminalUiStateStore.setState({ terminalUiStateByThreadKey: {} }); @@ -145,14 +155,20 @@ it.each(["cancel", "revoked"])( }, ); +// The local shell lags the server, so a run-less shell never proves the side chat was deleted. it.each([ - { latestRun: null, cleared: true }, - { latestRun: { runId: "run-1" }, cleared: false }, + { name: "the thread leaves the live shell", threadRemoved: true, cleared: true }, + { name: "the server keeps the thread", threadRemoved: false, cleared: false }, + { name: "the delete fails", threadRemoved: true, failDelete: true, cleared: false }, + { name: "the shell is not live", threadRemoved: true, shellStatus: "loading", cleared: false }, ])( - "clears a closed side chat's draft only when it never ran, cleared=$cleared", - async ({ latestRun, cleared }) => { + "clears a closed side chat's draft only after deletion: $name", + async ({ threadRemoved, failDelete = false, shellStatus = "live", cleared }) => { const { original } = setup(); - fixture.sideChatShell = { latestRun }; + fixture.sideChatShell = { latestRun: null }; + fixture.threadRemoved = threadRemoved; + fixture.failDelete = failDelete; + fixture.shellStatus = shellStatus; const sideChatId = ThreadId.make("side-chat-a"); useRightPanelStore.getState().openSideChat(original, sideChatId); const surface = selectThreadRightPanelState( @@ -168,6 +184,7 @@ it.each([ } await using screen = await render(); await screen.getByRole("button").click(); + await act(async () => {}); expect(fixture.clearedDrafts).toEqual( cleared ? [{ environmentId: original.environmentId, threadId: sideChatId }] : [], ); diff --git a/apps/web/src/hooks/useRightPanelSurfaceClose.ts b/apps/web/src/hooks/useRightPanelSurfaceClose.ts index 995a591cca730..d4eb55347c099 100644 --- a/apps/web/src/hooks/useRightPanelSurfaceClose.ts +++ b/apps/web/src/hooks/useRightPanelSurfaceClose.ts @@ -21,7 +21,7 @@ import { type RightPanelSurface, } from "../rightPanelStore"; import { appAtomRegistry } from "../rpc/atomRegistry"; -import { readThreadShell } from "../state/entities"; +import { readThreadShell, waitForThreadRemoval } from "../state/entities"; import { previewEnvironment } from "../state/preview"; import { readEnvironmentScope, useEnvironmentScope } from "../state/session"; import { environmentShell } from "../state/shell"; @@ -71,23 +71,23 @@ export function useRightPanelSurfaceClose(input: { terminalActions.closeTerminalSession(terminalId, ref); } } + if (surface.kind !== "side-chat") return; + const sideChatRef = scopeThreadRef(ref.environmentId, surface.threadId); + const shellIsLive = () => + appAtomRegistry.get(environmentShell.stateValueAtom(ref.environmentId)).status === "live"; if ( - surface.kind === "side-chat" && readEnvironmentScope(ref.environmentId, AuthOrchestrationOperateScope) && // A live shell without the side chat means it is already deleted. - !( - appAtomRegistry.get(environmentShell.stateValueAtom(ref.environmentId)).status === "live" && - readThreadShell(scopeThreadRef(ref.environmentId, surface.threadId)) === null - ) + !(shellIsLive() && readThreadShell(sideChatRef) === null) ) { - const sideChatRef = scopeThreadRef(ref.environmentId, surface.threadId); - if (readThreadShell(sideChatRef)?.latestRun === null) { - useComposerDraftStore.getState().clearDraftThread(sideChatRef); - } // The server keeps a side chat with a run or message. Reopen skips one it deleted. void deleteThread({ environmentId: ref.environmentId, input: { threadId: surface.threadId, onlyIfUnused: true }, + }).then(async (result) => { + // Success also covers a kept side chat, whose draft must survive. + if (result._tag !== "Success" || !(await waitForThreadRemoval(sideChatRef))) return; + if (shellIsLive()) useComposerDraftStore.getState().clearDraftThread(sideChatRef); }); } }; diff --git a/apps/web/src/state/entities.ts b/apps/web/src/state/entities.ts index 0f94280768d4b..702d8320c9a44 100644 --- a/apps/web/src/state/entities.ts +++ b/apps/web/src/state/entities.ts @@ -321,6 +321,16 @@ export function waitForThreadShell(ref: ScopedThreadRef, timeoutMs = 5_000): Pro }); } +/** Waits for removal from the live store; delete replies arrive before the shell stream. */ +export function waitForThreadRemoval(ref: ScopedThreadRef, timeoutMs = 5_000): Promise { + return waitForAtomValue({ + registry: appAtomRegistry, + atom: environmentThreadShells.threadShellAtom(ref), + predicate: (thread) => thread === null, + timeoutMs, + }); +} + export function useEnvironmentSupportsServerBrowser(environmentId: EnvironmentId | null): boolean { const configs = useServerConfigs(); return ( From 3abfe7542673005f152fe37950e6d05b58d40b59 Mon Sep 17 00:00:00 2001 From: Yuruzuu Date: Tue, 3 Nov 2026 04:14:56 +0800 Subject: [PATCH 3/6] fix(web): closing an already deleted side chat clears its draft Co-Authored-By: Claude Opus 5.5 --- ...useRightPanelSurfaceClose.browser.test.tsx | 16 +++++++++-- .../src/hooks/useRightPanelSurfaceClose.ts | 28 +++++++++---------- 2 files changed, 28 insertions(+), 16 deletions(-) diff --git a/apps/web/src/hooks/useRightPanelSurfaceClose.browser.test.tsx b/apps/web/src/hooks/useRightPanelSurfaceClose.browser.test.tsx index b7ddddef6c2aa..ae7eca6375b81 100644 --- a/apps/web/src/hooks/useRightPanelSurfaceClose.browser.test.tsx +++ b/apps/web/src/hooks/useRightPanelSurfaceClose.browser.test.tsx @@ -161,11 +161,23 @@ it.each([ { name: "the server keeps the thread", threadRemoved: false, cleared: false }, { name: "the delete fails", threadRemoved: true, failDelete: true, cleared: false }, { name: "the shell is not live", threadRemoved: true, shellStatus: "loading", cleared: false }, + { + name: "it was deleted before closing", + sideChatShell: null, + threadRemoved: false, + cleared: true, + }, ])( "clears a closed side chat's draft only after deletion: $name", - async ({ threadRemoved, failDelete = false, shellStatus = "live", cleared }) => { + async ({ + sideChatShell = { latestRun: null }, + threadRemoved, + failDelete = false, + shellStatus = "live", + cleared, + }) => { const { original } = setup(); - fixture.sideChatShell = { latestRun: null }; + fixture.sideChatShell = sideChatShell; fixture.threadRemoved = threadRemoved; fixture.failDelete = failDelete; fixture.shellStatus = shellStatus; diff --git a/apps/web/src/hooks/useRightPanelSurfaceClose.ts b/apps/web/src/hooks/useRightPanelSurfaceClose.ts index d4eb55347c099..22cd05f9cb9ca 100644 --- a/apps/web/src/hooks/useRightPanelSurfaceClose.ts +++ b/apps/web/src/hooks/useRightPanelSurfaceClose.ts @@ -75,21 +75,21 @@ export function useRightPanelSurfaceClose(input: { const sideChatRef = scopeThreadRef(ref.environmentId, surface.threadId); const shellIsLive = () => appAtomRegistry.get(environmentShell.stateValueAtom(ref.environmentId)).status === "live"; - if ( - readEnvironmentScope(ref.environmentId, AuthOrchestrationOperateScope) && - // A live shell without the side chat means it is already deleted. - !(shellIsLive() && readThreadShell(sideChatRef) === null) - ) { - // The server keeps a side chat with a run or message. Reopen skips one it deleted. - void deleteThread({ - environmentId: ref.environmentId, - input: { threadId: surface.threadId, onlyIfUnused: true }, - }).then(async (result) => { - // Success also covers a kept side chat, whose draft must survive. - if (result._tag !== "Success" || !(await waitForThreadRemoval(sideChatRef))) return; - if (shellIsLive()) useComposerDraftStore.getState().clearDraftThread(sideChatRef); - }); + // A live shell without the side chat means it is already deleted. + if (shellIsLive() && readThreadShell(sideChatRef) === null) { + useComposerDraftStore.getState().clearDraftThread(sideChatRef); + return; } + if (!readEnvironmentScope(ref.environmentId, AuthOrchestrationOperateScope)) return; + // The server keeps a side chat with a run or message. Reopen skips one it deleted. + void deleteThread({ + environmentId: ref.environmentId, + input: { threadId: surface.threadId, onlyIfUnused: true }, + }).then(async (result) => { + // Success also covers a kept side chat, whose draft must survive. + if (result._tag !== "Success" || !(await waitForThreadRemoval(sideChatRef))) return; + if (shellIsLive()) useComposerDraftStore.getState().clearDraftThread(sideChatRef); + }); }; const closeAfterAgentBrowserConfirmation = ( toClose: readonly RightPanelSurface[], From db9cc569151542534d35498eedf75bc8de4aea97 Mon Sep 17 00:00:00 2001 From: Yuruzuu Date: Tue, 3 Nov 2026 12:13:06 +0800 Subject: [PATCH 4/6] fix(web): closing an archived side chat keeps its draft Co-Authored-By: Claude Opus 5.5 --- .../src/hooks/useRightPanelSurfaceClose.browser.test.tsx | 4 ++-- apps/web/src/hooks/useRightPanelSurfaceClose.ts | 7 ++----- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/apps/web/src/hooks/useRightPanelSurfaceClose.browser.test.tsx b/apps/web/src/hooks/useRightPanelSurfaceClose.browser.test.tsx index ae7eca6375b81..8b21efb49ee43 100644 --- a/apps/web/src/hooks/useRightPanelSurfaceClose.browser.test.tsx +++ b/apps/web/src/hooks/useRightPanelSurfaceClose.browser.test.tsx @@ -162,10 +162,10 @@ it.each([ { name: "the delete fails", threadRemoved: true, failDelete: true, cleared: false }, { name: "the shell is not live", threadRemoved: true, shellStatus: "loading", cleared: false }, { - name: "it was deleted before closing", + name: "it already left the live shell, as an archived one does", sideChatShell: null, threadRemoved: false, - cleared: true, + cleared: false, }, ])( "clears a closed side chat's draft only after deletion: $name", diff --git a/apps/web/src/hooks/useRightPanelSurfaceClose.ts b/apps/web/src/hooks/useRightPanelSurfaceClose.ts index 22cd05f9cb9ca..a99d125ea537e 100644 --- a/apps/web/src/hooks/useRightPanelSurfaceClose.ts +++ b/apps/web/src/hooks/useRightPanelSurfaceClose.ts @@ -75,11 +75,8 @@ export function useRightPanelSurfaceClose(input: { const sideChatRef = scopeThreadRef(ref.environmentId, surface.threadId); const shellIsLive = () => appAtomRegistry.get(environmentShell.stateValueAtom(ref.environmentId)).status === "live"; - // A live shell without the side chat means it is already deleted. - if (shellIsLive() && readThreadShell(sideChatRef) === null) { - useComposerDraftStore.getState().clearDraftThread(sideChatRef); - return; - } + // Deleting and archiving both drop it from the live shell; an archived draft must survive. + if (shellIsLive() && readThreadShell(sideChatRef) === null) return; if (!readEnvironmentScope(ref.environmentId, AuthOrchestrationOperateScope)) return; // The server keeps a side chat with a run or message. Reopen skips one it deleted. void deleteThread({ From 8942ffe0d161668c602d5d283aa440515608ed9c Mon Sep 17 00:00:00 2001 From: Yuruzuu Date: Tue, 3 Nov 2026 12:30:13 +0800 Subject: [PATCH 5/6] fix(web): a side chat with a run keeps its draft when closed Co-Authored-By: Claude Opus 5.5 --- .../src/hooks/useRightPanelSurfaceClose.browser.test.tsx | 6 ++++++ apps/web/src/hooks/useRightPanelSurfaceClose.ts | 8 ++++++-- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/apps/web/src/hooks/useRightPanelSurfaceClose.browser.test.tsx b/apps/web/src/hooks/useRightPanelSurfaceClose.browser.test.tsx index 8b21efb49ee43..b3c706b57190a 100644 --- a/apps/web/src/hooks/useRightPanelSurfaceClose.browser.test.tsx +++ b/apps/web/src/hooks/useRightPanelSurfaceClose.browser.test.tsx @@ -167,6 +167,12 @@ it.each([ threadRemoved: false, cleared: false, }, + { + name: "it has a run, so its removal can only be an archive", + sideChatShell: { latestRun: { runId: "run-1" } }, + threadRemoved: true, + cleared: false, + }, ])( "clears a closed side chat's draft only after deletion: $name", async ({ diff --git a/apps/web/src/hooks/useRightPanelSurfaceClose.ts b/apps/web/src/hooks/useRightPanelSurfaceClose.ts index a99d125ea537e..342c6a1d247c2 100644 --- a/apps/web/src/hooks/useRightPanelSurfaceClose.ts +++ b/apps/web/src/hooks/useRightPanelSurfaceClose.ts @@ -75,8 +75,11 @@ export function useRightPanelSurfaceClose(input: { const sideChatRef = scopeThreadRef(ref.environmentId, surface.threadId); const shellIsLive = () => appAtomRegistry.get(environmentShell.stateValueAtom(ref.environmentId)).status === "live"; + const sideChatShell = readThreadShell(sideChatRef); // Deleting and archiving both drop it from the live shell; an archived draft must survive. - if (shellIsLive() && readThreadShell(sideChatRef) === null) return; + if (shellIsLive() && sideChatShell === null) return; + // The server keeps a side chat with a run, so a later removal is an archive, not this delete. + const keptByServer = sideChatShell?.latestRun != null; if (!readEnvironmentScope(ref.environmentId, AuthOrchestrationOperateScope)) return; // The server keeps a side chat with a run or message. Reopen skips one it deleted. void deleteThread({ @@ -84,7 +87,8 @@ export function useRightPanelSurfaceClose(input: { input: { threadId: surface.threadId, onlyIfUnused: true }, }).then(async (result) => { // Success also covers a kept side chat, whose draft must survive. - if (result._tag !== "Success" || !(await waitForThreadRemoval(sideChatRef))) return; + if (keptByServer || result._tag !== "Success") return; + if (!(await waitForThreadRemoval(sideChatRef))) return; if (shellIsLive()) useComposerDraftStore.getState().clearDraftThread(sideChatRef); }); }; From c50a8b7b4a7eedf6cc20fb1d77198123b6a1c6f4 Mon Sep 17 00:00:00 2001 From: Yuruzuu Date: Tue, 3 Nov 2026 19:27:40 +0800 Subject: [PATCH 6/6] fix(web): closing a side chat keeps its draft when another client archived it Co-Authored-By: Claude Opus 5.5 --- ...useRightPanelSurfaceClose.browser.test.tsx | 38 ++++++++++++++++--- .../src/hooks/useRightPanelSurfaceClose.ts | 12 +++++- apps/web/src/state/threads.ts | 16 ++++++++ packages/client-runtime/package.json | 4 ++ 4 files changed, 64 insertions(+), 6 deletions(-) diff --git a/apps/web/src/hooks/useRightPanelSurfaceClose.browser.test.tsx b/apps/web/src/hooks/useRightPanelSurfaceClose.browser.test.tsx index b3c706b57190a..4a09369786b0d 100644 --- a/apps/web/src/hooks/useRightPanelSurfaceClose.browser.test.tsx +++ b/apps/web/src/hooks/useRightPanelSurfaceClose.browser.test.tsx @@ -13,6 +13,8 @@ const fixture = vi.hoisted(() => ({ failClose: false, sideChatShell: null as { latestRun: { runId: string } | null } | null, threadRemoved: false, + presence: "missing", + presenceReads: 0, failDelete: false, shellStatus: "live", clearedDrafts: [] as ScopedThreadRef[], @@ -53,14 +55,19 @@ vi.mock("../composerDraftStore", async () => { }); vi.mock("../state/use-atom-command", () => ({ useAtomCommand: - (_command: unknown, label: string) => + (_command: unknown, options: string | { label: string }) => async (request: { environmentId: string; input: { threadId: string; terminalId: string; data?: string }; }) => { + const label = typeof options === "string" ? options : options.label; const key = `${request.environmentId}/${request.input.threadId}/${request.input.terminalId}`; if (label === "terminal close" && fixture.failClose) return { _tag: "Failure" }; if (label === "side chat delete" && fixture.failDelete) return { _tag: "Failure" }; + if (label === "side chat presence") { + fixture.presenceReads += 1; + return { _tag: "Success", value: fixture.presence }; + } if (label === "terminal close" || label === "terminal write") fixture.sessions.delete(key); return { _tag: "Success" }; }, @@ -68,7 +75,10 @@ vi.mock("../state/use-atom-command", () => ({ // The commands are replaced above; importing their runtime would connect a client. vi.mock("../state/terminal", () => ({ terminalEnvironment: { open: {}, close: {}, write: {} } })); vi.mock("../state/preview", () => ({ previewEnvironment: { close: {} } })); -vi.mock("../state/threads", () => ({ threadEnvironment: { delete: {} } })); +vi.mock("../state/threads", () => ({ + threadEnvironment: { delete: {} }, + readThreadPresence: {}, +})); vi.mock("../state/shell", () => ({ environmentShell: { stateValueAtom: () => null } })); vi.mock("../rpc/atomRegistry", () => ({ appAtomRegistry: { get: () => ({ status: fixture.shellStatus }) }, @@ -83,6 +93,8 @@ function setup() { fixture.failClose = false; fixture.sideChatShell = null; fixture.threadRemoved = false; + fixture.presence = "missing"; + fixture.presenceReads = 0; fixture.failDelete = false; fixture.shellStatus = "live"; fixture.clearedDrafts = []; @@ -160,6 +172,13 @@ it.each([ { name: "the thread leaves the live shell", threadRemoved: true, cleared: true }, { name: "the server keeps the thread", threadRemoved: false, cleared: false }, { name: "the delete fails", threadRemoved: true, failDelete: true, cleared: false }, + { + name: "another client archived it after the shell check", + threadRemoved: true, + presence: "present", + cleared: false, + }, + { name: "the fresh read fails", threadRemoved: true, presence: "unavailable", cleared: false }, { name: "the shell is not live", threadRemoved: true, shellStatus: "loading", cleared: false }, { name: "it already left the live shell, as an archived one does", @@ -178,6 +197,7 @@ it.each([ async ({ sideChatShell = { latestRun: null }, threadRemoved, + presence = "missing", failDelete = false, shellStatus = "live", cleared, @@ -185,6 +205,7 @@ it.each([ const { original } = setup(); fixture.sideChatShell = sideChatShell; fixture.threadRemoved = threadRemoved; + fixture.presence = presence; fixture.failDelete = failDelete; fixture.shellStatus = shellStatus; const sideChatId = ThreadId.make("side-chat-a"); @@ -202,9 +223,16 @@ it.each([ } await using screen = await render(); await screen.getByRole("button").click(); + if (cleared) { + await vi.waitFor(() => + expect(fixture.clearedDrafts).toEqual([ + { environmentId: original.environmentId, threadId: sideChatId }, + ]), + ); + return; + } + if (presence !== "missing") await vi.waitFor(() => expect(fixture.presenceReads).toBe(1)); await act(async () => {}); - expect(fixture.clearedDrafts).toEqual( - cleared ? [{ environmentId: original.environmentId, threadId: sideChatId }] : [], - ); + expect(fixture.clearedDrafts).toEqual([]); }, ); diff --git a/apps/web/src/hooks/useRightPanelSurfaceClose.ts b/apps/web/src/hooks/useRightPanelSurfaceClose.ts index 342c6a1d247c2..f6c245af11b48 100644 --- a/apps/web/src/hooks/useRightPanelSurfaceClose.ts +++ b/apps/web/src/hooks/useRightPanelSurfaceClose.ts @@ -25,7 +25,7 @@ import { readThreadShell, waitForThreadRemoval } from "../state/entities"; import { previewEnvironment } from "../state/preview"; import { readEnvironmentScope, useEnvironmentScope } from "../state/session"; import { environmentShell } from "../state/shell"; -import { threadEnvironment } from "../state/threads"; +import { readThreadPresence, threadEnvironment } from "../state/threads"; import { useAtomCommand } from "../state/use-atom-command"; import { useTerminalUiStateStore } from "../terminalUiStateStore"; import { useStableCallback } from "./useStableCallback"; @@ -41,6 +41,10 @@ export function useRightPanelSurfaceClose(input: { const { threadRef, terminalLabelsById } = input; const closePreview = useAtomCommand(previewEnvironment.close, "preview close"); const deleteThread = useAtomCommand(threadEnvironment.delete, "side chat delete"); + const readPresence = useAtomCommand(readThreadPresence, { + label: "side chat presence", + reportFailure: false, + }); const canOperatePreview = useEnvironmentScope( threadRef?.environmentId ?? null, AuthPreviewOperateScope, @@ -89,6 +93,12 @@ export function useRightPanelSurfaceClose(input: { // Success also covers a kept side chat, whose draft must survive. if (keptByServer || result._tag !== "Success") return; if (!(await waitForThreadRemoval(sideChatRef))) return; + // Another client's archive also drops it from the shell; only a missing thread was deleted. + const presence = await readPresence({ + environmentId: ref.environmentId, + input: surface.threadId, + }); + if (presence._tag !== "Success" || presence.value !== "missing") return; if (shellIsLive()) useComposerDraftStore.getState().clearDraftThread(sideChatRef); }); }; diff --git a/apps/web/src/state/threads.ts b/apps/web/src/state/threads.ts index 6d2ce2a07c90b..64303f3b1c92a 100644 --- a/apps/web/src/state/threads.ts +++ b/apps/web/src/state/threads.ts @@ -12,8 +12,13 @@ import { createThreadEnvironmentAtoms, createThreadFamilyAtoms, } from "@t3tools/client-runtime/state/threads"; +import { ThreadSnapshotLoader } from "@t3tools/client-runtime/state/thread-snapshot"; +import { EnvironmentSupervisor } from "@t3tools/client-runtime/connection"; +import { createEnvironmentCommand } from "@t3tools/client-runtime/state/runtime"; import type { EnvironmentId, OrchestrationV2ThreadShell, ThreadId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; +import * as SubscriptionRef from "effect/SubscriptionRef"; import { AsyncResult, Atom } from "effect/reactivity"; import { environmentCatalog } from "../connection/catalog"; @@ -33,6 +38,17 @@ export const threadEnvironment = createThreadEnvironmentAtoms( connectionAtomRuntime, threadFamilies.snapshotAtom, ); +/** One fresh server read: "present" includes archived threads, "missing" means deleted. */ +export const readThreadPresence = createEnvironmentCommand(connectionAtomRuntime, { + label: "web-environment-thread:read-presence", + execute: Effect.fn("WebThreads.readThreadPresence")(function* (threadId: ThreadId) { + const supervisor = yield* EnvironmentSupervisor.EnvironmentSupervisor; + const loader = yield* ThreadSnapshotLoader; + const prepared = yield* SubscriptionRef.get(supervisor.prepared); + if (Option.isNone(prepared)) return "unavailable" as const; + return (yield* loader.load(prepared.value, threadId))._tag; + }), +}); const environmentThreads = createEnvironmentThreadStateAtoms(connectionAtomRuntime); export const environmentThreadDetails = createEnvironmentThreadDetailAtoms( environmentThreads.stateAtom, diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index 6fd4550d93f31..d879460c3f98e 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -311,6 +311,10 @@ "types": "./src/state/threads.ts", "default": "./src/state/threads.ts" }, + "./state/thread-snapshot": { + "types": "./src/state/threadSnapshotHttp.ts", + "default": "./src/state/threadSnapshotHttp.ts" + }, "./state/subagent-display": { "types": "./src/state/subagentDisplay.ts", "default": "./src/state/subagentDisplay.ts"