diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 4408bee38ad01..817acfbd9b4a8 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -131,7 +131,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, @@ -2806,7 +2806,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, @@ -3418,6 +3422,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 cea2daa111207..d93b9aa575149 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, @@ -1302,6 +1304,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 1587526528987..2f6f0b49a6598 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 { readThreadShell, 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, @@ -67,7 +75,10 @@ export function useSideChat(input: { discard(); return false; } - const text = prompt?.trim(); + const text = seed.prompt?.trim(); + if (seed.draft) { + useComposerDraftStore.getState().setPrompt(targetRef, seed.draft); + } // Send before showing the tab: closing a tab whose side chat has no run deletes it. if (text) { const turn = await startThreadTurn({ @@ -88,13 +99,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..4a09369786b0d 100644 --- a/apps/web/src/hooks/useRightPanelSurfaceClose.browser.test.tsx +++ b/apps/web/src/hooks/useRightPanelSurfaceClose.browser.test.tsx @@ -11,6 +11,13 @@ const fixture = vi.hoisted(() => ({ allowed: new Set(), sessions: new Map(), failClose: false, + sideChatShell: null as { latestRun: { runId: string } | null } | null, + threadRemoved: false, + presence: "missing", + presenceReads: 0, + failDelete: false, + shellStatus: "live", + clearedDrafts: [] as ScopedThreadRef[], })); vi.mock("../lib/terminalCloseConfirm", () => ({ confirmTerminalClose: (labels: readonly string[]) => fixture.confirm(labels), @@ -31,25 +38,36 @@ vi.mock("../state/session", () => ({ vi.mock("../state/entities", () => ({ useQuietThreadShell: () => null, useProject: () => null, - readThreadShell: () => null, + readThreadShell: () => fixture.sideChatShell, + waitForThreadRemoval: async () => fixture.threadRemoved, })); 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")), }; }); 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" }; }, @@ -57,8 +75,14 @@ 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 }) }, +})); vi.mock("@t3tools/client-runtime/state/runtime", () => ({ isAtomCommandInterrupted: () => false, })); @@ -67,6 +91,13 @@ function setup() { fixture.allowed = new Set(["environment-a", "environment-b"]); fixture.sessions = new Map(); fixture.failClose = false; + fixture.sideChatShell = null; + fixture.threadRemoved = false; + fixture.presence = "missing"; + fixture.presenceReads = 0; + fixture.failDelete = false; + fixture.shellStatus = "live"; + fixture.clearedDrafts = []; useRightPanelStore.setState({ byThreadKey: {} }); useTerminalUiStateStore.setState({ terminalUiStateByThreadKey: {} }); const original: ScopedThreadRef = { @@ -135,3 +166,73 @@ it.each(["cancel", "revoked"])( ).toHaveLength(1); }, ); + +// The local shell lags the server, so a run-less shell never proves the side chat was deleted. +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", + sideChatShell: null, + 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 ({ + sideChatShell = { latestRun: null }, + threadRemoved, + presence = "missing", + failDelete = false, + shellStatus = "live", + cleared, + }) => { + 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"); + 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(); + 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([]); + }, +); diff --git a/apps/web/src/hooks/useRightPanelSurfaceClose.ts b/apps/web/src/hooks/useRightPanelSurfaceClose.ts index 305c2a73dd3b4..f6c245af11b48 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"; @@ -20,11 +21,11 @@ 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"; -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"; @@ -40,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, @@ -70,21 +75,32 @@ export function useRightPanelSurfaceClose(input: { terminalActions.closeTerminalSession(terminalId, ref); } } - 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 - ) - ) { - // The server keeps a side chat with a run or message. Reopen skips one it deleted. - void deleteThread({ + if (surface.kind !== "side-chat") return; + 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() && 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({ + environmentId: ref.environmentId, + input: { threadId: surface.threadId, onlyIfUnused: true }, + }).then(async (result) => { + // 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: { threadId: surface.threadId, onlyIfUnused: true }, + input: surface.threadId, }); - } + if (presence._tag !== "Success" || presence.value !== "missing") return; + if (shellIsLive()) useComposerDraftStore.getState().clearDraftThread(sideChatRef); + }); }; const closeAfterAgentBrowserConfirmation = ( toClose: readonly RightPanelSurface[], diff --git a/apps/web/src/state/entities.ts b/apps/web/src/state/entities.ts index 19c0736779897..78954ff2cb203 100644 --- a/apps/web/src/state/entities.ts +++ b/apps/web/src/state/entities.ts @@ -338,6 +338,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 ( 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/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index 4829fa3c6b7b5..c54925ab8e9f9 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -296,9 +296,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 and inherits its permissions when created, including for its first reply. Change permissions for an individual diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index 15d9bc6696281..dd65f2f5fbfed 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -315,6 +315,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"