From 160c344008b5efd029b808cce0346b7cf37bd686 Mon Sep 17 00:00:00 2001 From: murashit Date: Sat, 11 Jul 2026 23:51:16 +0900 Subject: [PATCH] Model subagent threads as read-only panels --- docs/design.md | 2 + src/app-server/protocol/thread.ts | 45 ++++++++- src/domain/threads/model.ts | 17 ++++ .../app-server/inbound/notification-plan.ts | 18 ++-- .../chat/application/composer/suggestions.ts | 23 +++-- .../application/runtime/settings-actions.ts | 16 ++++ .../chat/application/state/actions.ts | 6 +- .../chat/application/state/root-reducer.ts | 5 + .../threads/thread-management-actions.ts | 8 ++ .../threads/thread-navigation-actions.ts | 3 +- .../turns/composer-submit-actions.ts | 11 ++- .../chat/application/turns/composition.ts | 1 + .../application/turns/plan-implementation.ts | 3 +- .../turns/slash-command-executor.ts | 3 + .../application/turns/submission-state.ts | 2 + .../turns/turn-submission-actions.ts | 3 + .../chat/host/bundles/shell-bundle.ts | 1 + src/features/chat/host/session.ts | 2 +- .../chat/panel/composer-controller.ts | 2 + src/features/chat/panel/shell-read-model.ts | 12 ++- .../panel/surface/composer-projection.tsx | 8 +- .../chat/panel/surface/toolbar-projection.tsx | 5 +- src/features/chat/panel/toolbar-actions.ts | 12 ++- src/features/chat/ui/composer.tsx | 53 +++++++++-- src/features/chat/ui/toolbar.tsx | 17 +++- tests/app-server/query-cache.test.ts | 1 + tests/app-server/shared-queries.test.ts | 1 + tests/app-server/threads.test.ts | 95 +++++++++++++++++-- tests/domain/threads/archive-markdown.test.ts | 1 + tests/domain/threads/reference.test.ts | 1 + tests/domain/threads/search.test.ts | 1 + tests/domain/threads/threads.test.ts | 1 + .../chat/app-server/inbound/handler.test.ts | 32 +++++++ .../app-server/mappers/thread-stream.test.ts | 10 +- .../chat/app-server/transports.test.ts | 10 +- .../application/composer/suggestions.test.ts | 9 ++ .../runtime/settings-actions.test.ts | 33 +++++++ .../chat/application/state/actions.test.ts | 22 +++++ .../application/state/root-reducer.test.ts | 1 + .../active-thread-identity-sync.test.ts | 1 + .../threads/auto-title-coordinator.test.ts | 1 + .../ephemeral-thread-lifecycle.test.ts | 22 ++++- .../application/threads/goal-actions.test.ts | 10 +- .../threads/rename-editor-actions.test.ts | 1 + .../threads/resume-actions.test.ts | 1 + .../threads/thread-management-actions.test.ts | 1 + .../threads/thread-navigation-actions.test.ts | 29 +++++- .../threads/thread-start-actions.test.ts | 1 + .../turns/composer-submit-actions.test.ts | 37 +++++++- .../application/turns/composition.test.ts | 1 + .../turns/slash-command-execution.test.ts | 1 + .../turns/slash-command-executor.test.ts | 1 + .../turns/turn-submission-actions.test.ts | 46 +++++++++ .../features/chat/host/session-graph.test.ts | 1 + .../chat/host/view-connection.test.ts | 2 + tests/features/chat/panel/shell.test.tsx | 1 + .../chat/panel/surface/projections.test.ts | 34 +++++++ .../surface/thread-stream-presenter.test.ts | 10 +- .../chat/panel/toolbar-archive-state.test.tsx | 2 + tests/features/chat/ui/composer.test.ts | 3 + tests/features/chat/ui/toolbar.test.ts | 1 + tests/features/thread-picker/modal.test.ts | 1 + tests/features/threads-view/shell.test.tsx | 1 + tests/features/threads-view/state.test.ts | 1 + tests/features/threads-view/view.test.ts | 1 + .../threads/catalog/thread-catalog.test.ts | 1 + .../threads/list/row-projection.test.ts | 1 + .../threads/workflows/archive-export.test.ts | 1 + tests/main.test.ts | 1 + tests/settings/settings-tab.test.ts | 1 + 70 files changed, 650 insertions(+), 62 deletions(-) diff --git a/docs/design.md b/docs/design.md index fe145fba..80dc6ac7 100644 --- a/docs/design.md +++ b/docs/design.md @@ -52,6 +52,8 @@ Imperative DOM bridges are allowed when an external API, host lifecycle, hit-tes Multiple panels are separate Obsidian leaves. Treat each panel as its own Codex working surface with independent connection, thread, turn state, composer, and pending requests. +Subagent threads opened from agent activity remain persistent and restorable but stay outside ordinary thread history. Treat their panels as read-only conversation surfaces while preserving their parent and agent provenance for future specialized behavior. + Thread history, archived state, forks, catalog snapshots, and other app-server resources should follow app-server semantics. Panel-side views are read models over app-server snapshots and lifecycle events; stale or partial refreshes must not overwrite newer state. Obsidian integrations such as archive note export are convenience views of Codex state, not replacements for Codex history. Routine panel and Threads view refreshes should load only the first recency-ordered thread page. Older active threads are appended through an explicit load-more action; workflows that explicitly need a complete inventory, such as opening the thread picker, may finish pagination on demand. diff --git a/src/app-server/protocol/thread.ts b/src/app-server/protocol/thread.ts index 4018f37e..c991930c 100644 --- a/src/app-server/protocol/thread.ts +++ b/src/app-server/protocol/thread.ts @@ -1,4 +1,4 @@ -import type { Thread } from "../../domain/threads/model"; +import type { Thread, ThreadProvenance } from "../../domain/threads/model"; export interface ThreadRecord { id: string; @@ -20,10 +20,53 @@ export function threadFromThreadRecord(thread: ThreadRecord, options: { archived archived: options.archived ?? false, createdAt: finiteTimestamp(thread.createdAt), updatedAt: finiteTimestamp(thread.updatedAt), + provenance: threadProvenance(thread), ...(hasRecencyAt ? { recencyAt: typeof recencyAt === "number" && Number.isFinite(recencyAt) ? recencyAt : null } : {}), }; } +function threadProvenance(thread: ThreadRecord): ThreadProvenance { + const source = recordOrNull(thread["source"]); + const subagent = source?.["subAgent"]; + const threadSource = stringOrNull(thread["threadSource"]); + if (subagent === undefined && stringOrNull(thread["parentThreadId"]) === null && !threadSource?.startsWith("subAgent")) { + return { kind: "interactive" }; + } + + const spawn = recordOrNull(recordOrNull(subagent)?.["thread_spawn"]); + return { + kind: "subagent", + subagentKind: subagentKind(subagent, threadSource), + parentThreadId: stringOrNull(thread["parentThreadId"]) ?? stringOrNull(spawn?.["parent_thread_id"]), + sessionId: stringOrNull(thread["sessionId"]), + depth: finiteNumberOrNull(spawn?.["depth"]), + agentNickname: stringOrNull(thread["agentNickname"]) ?? stringOrNull(spawn?.["agent_nickname"]), + agentRole: stringOrNull(thread["agentRole"]) ?? stringOrNull(spawn?.["agent_role"]), + }; +} + +function subagentKind(value: unknown, threadSource: string | null): Extract["subagentKind"] { + if (value === "review" || value === "compact" || value === "memory_consolidation") { + return value === "memory_consolidation" ? "memory-consolidation" : value; + } + if (recordOrNull(value)?.["thread_spawn"] || threadSource === "subAgentThreadSpawn") return "thread-spawn"; + if (threadSource === "subAgentReview") return "review"; + if (threadSource === "subAgentCompact") return "compact"; + return "other"; +} + +function recordOrNull(value: unknown): Record | null { + return typeof value === "object" && value !== null && !Array.isArray(value) ? (value as Record) : null; +} + +function stringOrNull(value: unknown): string | null { + return typeof value === "string" && value.length > 0 ? value : null; +} + +function finiteNumberOrNull(value: unknown): number | null { + return typeof value === "number" && Number.isFinite(value) ? value : null; +} + export function threadsFromThreadRecords(threads: readonly ThreadRecord[], options: { archived?: boolean } = {}): Thread[] { return threads.map((thread) => threadFromThreadRecord(thread, options)); } diff --git a/src/domain/threads/model.ts b/src/domain/threads/model.ts index eecc7c1f..c07acd63 100644 --- a/src/domain/threads/model.ts +++ b/src/domain/threads/model.ts @@ -6,6 +6,23 @@ export interface Thread { readonly createdAt: number; readonly updatedAt: number; readonly recencyAt?: number | null; + readonly provenance: ThreadProvenance; +} + +export type ThreadProvenance = + | { readonly kind: "interactive" } + | { + readonly kind: "subagent"; + readonly subagentKind: "thread-spawn" | "review" | "compact" | "memory-consolidation" | "other"; + readonly parentThreadId: string | null; + readonly sessionId: string | null; + readonly depth: number | null; + readonly agentNickname: string | null; + readonly agentRole: string | null; + }; + +export function isSubagentThread(thread: Thread): boolean { + return thread.provenance.kind === "subagent"; } export function explicitThreadName(thread: Thread): string | null { diff --git a/src/features/chat/app-server/inbound/notification-plan.ts b/src/features/chat/app-server/inbound/notification-plan.ts index f10cf833..da32d031 100644 --- a/src/features/chat/app-server/inbound/notification-plan.ts +++ b/src/features/chat/app-server/inbound/notification-plan.ts @@ -166,14 +166,16 @@ function threadStartedPlan( state: ChatState, notification: Extract, ): ChatNotificationPlan { - const effects: ChatNotificationEffect[] = notification.params.thread.ephemeral - ? [] - : [ - { - type: "apply-thread-catalog-event", - event: { type: "thread-started", thread: threadFromAppServerRecord(notification.params.thread) }, - }, - ]; + const thread = threadFromAppServerRecord(notification.params.thread); + const effects: ChatNotificationEffect[] = + notification.params.thread.ephemeral || thread.provenance.kind === "subagent" + ? [] + : [ + { + type: "apply-thread-catalog-event", + event: { type: "thread-started", thread }, + }, + ]; if (!state.activeThread.id || state.activeThread.id === notification.params.thread.id) { return { actions: [{ type: "active-thread/cwd-set", cwd: notification.params.thread.cwd }], effects }; } diff --git a/src/features/chat/application/composer/suggestions.ts b/src/features/chat/application/composer/suggestions.ts index c71a094d..e68f613a 100644 --- a/src/features/chat/application/composer/suggestions.ts +++ b/src/features/chat/application/composer/suggestions.ts @@ -36,6 +36,7 @@ export interface ComposerSuggestion { export interface ComposerSuggestionOptions { activeThreadId?: string | null; activeThreadEphemeral?: boolean; + activeThreadSubagent?: boolean; contextReferences?: ComposerContextReferences; dailyNoteReferences?: readonly DailyNoteReferenceCandidate[] | (() => readonly DailyNoteReferenceCandidate[]); permissionProfiles?: readonly RuntimePermissionProfileSummary[]; @@ -114,16 +115,19 @@ export function activeComposerSuggestions( currentModel: string | null = null, options: ComposerSuggestionOptions = {}, ): ComposerSuggestion[] { + const slashSuggestions = options.activeThreadSubagent + ? null + : (activeSlashSubcommandSuggestions(beforeCursor) ?? + activeThreadCommandSuggestions(beforeCursor, threads, options.activeThreadId ?? null) ?? + modelOverrideSuggestions(beforeCursor, models) ?? + reasoningEffortOverrideSuggestions(beforeCursor, models, currentModel) ?? + permissionProfileOverrideSuggestions(beforeCursor, options.permissionProfiles ?? []) ?? + activeSlashCommandSuggestions(beforeCursor, options.activeThreadEphemeral ?? false, false)); return ( activeWikiLinkSuggestions(beforeCursor, notes) ?? activeContextReferenceSuggestions(beforeCursor, options.contextReferences, options.dailyNoteReferences) ?? activeTagSuggestions(beforeCursor, options.tagCandidates ?? []) ?? - activeSlashSubcommandSuggestions(beforeCursor) ?? - activeThreadCommandSuggestions(beforeCursor, threads, options.activeThreadId ?? null) ?? - modelOverrideSuggestions(beforeCursor, models) ?? - reasoningEffortOverrideSuggestions(beforeCursor, models, currentModel) ?? - permissionProfileOverrideSuggestions(beforeCursor, options.permissionProfiles ?? []) ?? - activeSlashCommandSuggestions(beforeCursor, options.activeThreadEphemeral ?? false) ?? + slashSuggestions ?? activeSkillSuggestions(beforeCursor, skills) ?? [] ); @@ -395,13 +399,18 @@ function compareWikiLinkSuggestionTiebreakers(a: NoteCandidateMatch, b: NoteCand const SIDE_CHAT_UNAVAILABLE_SLASH_COMMANDS = new Set(["/btw", "/fork", "/rollback"]); -function activeSlashCommandSuggestions(beforeCursor: string, activeThreadEphemeral: boolean): ComposerSuggestion[] | null { +function activeSlashCommandSuggestions( + beforeCursor: string, + activeThreadEphemeral: boolean, + activeThreadSubagent: boolean, +): ComposerSuggestion[] | null { const match = /^(\/[A-Za-z-]*)$/.exec(beforeCursor); if (match?.index === undefined) return null; const rawQuery = match[1]; if (rawQuery === undefined) return null; const query = rawQuery.toLowerCase(); + if (activeThreadSubagent) return []; if (SLASH_COMMANDS.some((item) => item.command.toLowerCase() === query)) return null; const start = match.index + match[0].lastIndexOf("/"); return SLASH_COMMANDS.filter( diff --git a/src/features/chat/application/runtime/settings-actions.ts b/src/features/chat/application/runtime/settings-actions.ts index 9d825296..cdb6db78 100644 --- a/src/features/chat/application/runtime/settings-actions.ts +++ b/src/features/chat/application/runtime/settings-actions.ts @@ -108,11 +108,13 @@ async function commitPendingThreadSettings(host: RuntimeSettingsActionsHost): Pr } async function requestModel(host: RuntimeSettingsActionsHost, model: string): Promise { + if (agentThreadSettingsBlocked(host)) return false; dispatch(host, { type: "runtime/model-requested", model }); return applyPendingThreadSettings(host); } async function resetModelToConfig(host: RuntimeSettingsActionsHost): Promise { + if (agentThreadSettingsBlocked(host)) return false; dispatch(host, { type: "runtime/model-reset-to-config" }); return applyPendingThreadSettings(host); } @@ -122,11 +124,13 @@ async function requestModelFromUi(host: RuntimeSettingsActionsHost, model: strin } async function requestReasoningEffort(host: RuntimeSettingsActionsHost, effort: ReasoningEffort): Promise { + if (agentThreadSettingsBlocked(host)) return false; dispatch(host, { type: "runtime/reasoning-effort-requested", effort }); return applyPendingThreadSettings(host); } async function resetReasoningEffortToConfig(host: RuntimeSettingsActionsHost): Promise { + if (agentThreadSettingsBlocked(host)) return false; dispatch(host, { type: "runtime/reasoning-effort-reset-to-config" }); return applyPendingThreadSettings(host); } @@ -140,12 +144,14 @@ async function resetReasoningEffortToConfigFromUi(host: RuntimeSettingsActionsHo } async function requestPermissionProfile(host: RuntimeSettingsActionsHost, permissionProfile: string): Promise { + if (agentThreadSettingsBlocked(host)) return false; if (permissionChangesBlocked(host)) return false; dispatch(host, { type: "runtime/permission-profile-requested", permissionProfile }); return applyPendingThreadSettings(host); } async function resetPermissionProfileToConfig(host: RuntimeSettingsActionsHost): Promise { + if (agentThreadSettingsBlocked(host)) return false; if (permissionChangesBlocked(host)) return false; dispatch(host, { type: "runtime/permission-profile-reset-to-config" }); return applyPendingThreadSettings(host); @@ -157,12 +163,19 @@ function permissionChangesBlocked(host: RuntimeSettingsActionsHost): boolean { return true; } +function agentThreadSettingsBlocked(host: RuntimeSettingsActionsHost): boolean { + if (state(host).activeThread.provenance?.kind !== "subagent") return false; + host.addSystemMessage("Thread settings are unavailable in agent threads."); + return true; +} + async function toggleFastMode(host: RuntimeSettingsActionsHost): Promise { const { snapshot, config } = runtimeProjection(host); await setFastMode(host, resolveRuntimeControls(snapshot, config).fastMode.active ? "disabled" : "enabled"); } async function setFastMode(host: RuntimeSettingsActionsHost, mode: FastModeState): Promise { + if (agentThreadSettingsBlocked(host)) return; const fastMode: RequestedFastMode = mode; await runRuntimeUiCommand( host, @@ -181,6 +194,7 @@ async function toggleCollaborationMode(host: RuntimeSettingsActionsHost): Promis } async function setCollaborationMode(host: RuntimeSettingsActionsHost, collaborationMode: CollaborationModeSelection): Promise { + if (agentThreadSettingsBlocked(host)) return false; dispatch(host, { type: "runtime/requested-collaboration-mode-set", collaborationMode }); const result = await commitPendingThreadSettings(host); if (result.ok) closeRuntimePanel(host); @@ -191,6 +205,7 @@ async function setCollaborationMode(host: RuntimeSettingsActionsHost, collaborat } function requestDefaultCollaborationModeForNextTurn(host: RuntimeSettingsActionsHost): void { + if (agentThreadSettingsBlocked(host)) return; dispatch(host, { type: "runtime/requested-collaboration-mode-set", collaborationMode: "default" }); } @@ -201,6 +216,7 @@ async function toggleAutoReview(host: RuntimeSettingsActionsHost): Promise } async function setAutoReview(host: RuntimeSettingsActionsHost, mode: AutoReviewState): Promise { + if (agentThreadSettingsBlocked(host)) return; await runRuntimeUiCommand( host, async () => { diff --git a/src/features/chat/application/state/actions.ts b/src/features/chat/application/state/actions.ts index e7a76309..39203ced 100644 --- a/src/features/chat/application/state/actions.ts +++ b/src/features/chat/application/state/actions.ts @@ -7,7 +7,7 @@ import { import { parseServiceTier, type ServiceTier } from "../../../../domain/runtime/policy"; import type { ServerInitialization } from "../../../../domain/server/initialization"; import type { ThreadActivationSnapshot } from "../../../../domain/threads/activation"; -import { type Thread, upsertThread } from "../../../../domain/threads/model"; +import { isSubagentThread, type Thread, upsertThread } from "../../../../domain/threads/model"; import type { CollaborationModeSelection } from "../../domain/runtime/intent"; import type { ActiveThreadRuntimeState } from "../../domain/runtime/state"; import type { ThreadStreamItem } from "../../domain/thread-stream/items"; @@ -166,7 +166,9 @@ export function resumedThreadAction(params: ResumedThreadActionParams): ActiveTh ...permissions, lifetime: { kind: "persistent" }, ...(params.items ? { items: params.items } : {}), - ...(params.listedThreads ? { listedThreads: upsertThread(params.listedThreads, response.thread) } : {}), + ...(params.listedThreads + ? { listedThreads: isSubagentThread(response.thread) ? params.listedThreads : upsertThread(params.listedThreads, response.thread) } + : {}), ...(params.preserveRequestedRuntimeSettings ? { preserveRequestedRuntimeSettings: true } : {}), }; } diff --git a/src/features/chat/application/state/root-reducer.ts b/src/features/chat/application/state/root-reducer.ts index 376b025e..5931ae20 100644 --- a/src/features/chat/application/state/root-reducer.ts +++ b/src/features/chat/application/state/root-reducer.ts @@ -106,10 +106,12 @@ interface ChatThreadListState { export interface ChatActiveThreadState { readonly id: string | null; + readonly title?: string | null; readonly cwd: string | null; readonly goal: ThreadGoal | null; readonly tokenUsage: ThreadTokenUsage | null; readonly lifetime: ActiveThreadLifetime | null; + readonly provenance: Thread["provenance"] | null; } type ActiveThreadLifetime = @@ -327,10 +329,12 @@ function reduceActiveThreadResumedTransition(state: ChatState, action: ActiveThr }, activeThread: { id: action.thread.id, + title: (action.thread.name ?? action.thread.preview) || null, cwd: action.cwd, goal: null, tokenUsage: null, lifetime: action.lifetime ?? { kind: "persistent" }, + provenance: action.thread.provenance, }, runtime: { ...runtimeBase, @@ -656,6 +660,7 @@ function initialActiveThreadState(): ChatActiveThreadState { goal: null, tokenUsage: null, lifetime: null, + provenance: null, }; } diff --git a/src/features/chat/application/threads/thread-management-actions.ts b/src/features/chat/application/threads/thread-management-actions.ts index aa4a0557..59c6694d 100644 --- a/src/features/chat/application/threads/thread-management-actions.ts +++ b/src/features/chat/application/threads/thread-management-actions.ts @@ -112,6 +112,10 @@ async function forkThreadFromTurn( host.addSystemMessage("Side chats cannot be forked."); return; } + if (activeThread.id === threadId && activeThread.provenance?.kind === "subagent") { + host.addSystemMessage("Agent threads cannot be forked."); + return; + } if (chatTurnBusy(threadManagementState(host))) { host.addSystemMessage("Finish or interrupt the current turn before forking threads."); return; @@ -177,6 +181,10 @@ async function rollbackThread(host: ThreadManagementActionsHost, threadId: strin host.addSystemMessage("Side chats cannot be rolled back."); return; } + if (activeThread.id === threadId && activeThread.provenance?.kind === "subagent") { + host.addSystemMessage("Agent threads cannot be rolled back."); + return; + } if (chatTurnBusy(threadManagementState(host))) { host.addSystemMessage("Interrupt the current turn before rolling back."); return; diff --git a/src/features/chat/application/threads/thread-navigation-actions.ts b/src/features/chat/application/threads/thread-navigation-actions.ts index 43b090e4..7a07a0e3 100644 --- a/src/features/chat/application/threads/thread-navigation-actions.ts +++ b/src/features/chat/application/threads/thread-navigation-actions.ts @@ -35,7 +35,8 @@ export function createThreadNavigationActions(host: ThreadNavigationActionsHost) return { async startNewThread(): Promise { - if (chatTurnBusy(host.stateStore.getState())) return; + const state = host.stateStore.getState(); + if (chatTurnBusy(state) && state.activeThread.provenance?.kind !== "subagent") return; if (host.prepareForPersistentNavigation && !(await host.prepareForPersistentNavigation())) return; host.identity.clearActiveThreadIdentity(); diff --git a/src/features/chat/application/turns/composer-submit-actions.ts b/src/features/chat/application/turns/composer-submit-actions.ts index 498dda1e..08a3e6be 100644 --- a/src/features/chat/application/turns/composer-submit-actions.ts +++ b/src/features/chat/application/turns/composer-submit-actions.ts @@ -11,6 +11,7 @@ const STATUS_INTERRUPT_REQUESTED = "Interrupt requested."; export interface ComposerSubmitActionsHost { stateStore: ChatStateStore; + ensureRestoredThreadLoaded?: () => Promise; composer: { readonly trimmedDraft: string; setDraft(text: string, options?: { clearSuggestions?: boolean; focus?: boolean; preserveContext?: boolean }): void; @@ -45,16 +46,20 @@ export interface ComposerSubmitActions { export async function submitComposer(host: ComposerSubmitActionsHost): Promise { const draft = host.composer.trimmedDraft; + if (host.ensureRestoredThreadLoaded && !(await host.ensureRestoredThreadLoaded())) return; const state = submissionStateSnapshot(host.stateStore.getState()); + if (state.activeThreadSubagent) { + host.status.addSystemMessage("Messages and slash commands are unavailable in agent threads. Start a new chat to continue."); + return; + } if (state.busy && state.activeThreadId && state.activeTurnId && draft.length === 0) { await interruptTurn(host); return; } - await sendMessage(host); + await sendMessage(host, draft); } -async function sendMessage(host: ComposerSubmitActionsHost): Promise { - const text = host.composer.trimmedDraft; +async function sendMessage(host: ComposerSubmitActionsHost, text: string): Promise { if (!text) return; const inputSnapshot = host.composer.captureInputSnapshot(); diff --git a/src/features/chat/application/turns/composition.ts b/src/features/chat/application/turns/composition.ts index d53c43d5..a9518273 100644 --- a/src/features/chat/application/turns/composition.ts +++ b/src/features/chat/application/turns/composition.ts @@ -126,6 +126,7 @@ export function createTurnWorkflowActions(context: TurnWorkflowContext, refs: Tu }; const composerSubmitHost: ComposerSubmitActionsHost = { stateStore, + ensureRestoredThreadLoaded: thread.ensureRestoredThreadLoaded, composer: { get trimmedDraft() { return composer.trimmedDraft(); diff --git a/src/features/chat/application/turns/plan-implementation.ts b/src/features/chat/application/turns/plan-implementation.ts index 3897bcd5..2b86fbeb 100644 --- a/src/features/chat/application/turns/plan-implementation.ts +++ b/src/features/chat/application/turns/plan-implementation.ts @@ -15,13 +15,14 @@ export interface PlanImplementationHost { } export function implementPlanTargetFromState(state: { - activeThread: Pick; + activeThread: Pick & { provenance?: ChatActiveThreadState["provenance"] }; turn: ChatTurnState; runtime: { pending: Pick }; threadStream: Pick; }): PlanImplementationTarget | null { if ( !state.activeThread.id || + state.activeThread.provenance?.kind === "subagent" || chatTurnBusy(state) || state.runtime.pending.collaborationMode.kind !== "set" || state.runtime.pending.collaborationMode.value !== "plan" diff --git a/src/features/chat/application/turns/slash-command-executor.ts b/src/features/chat/application/turns/slash-command-executor.ts index 2e1895fd..c052df97 100644 --- a/src/features/chat/application/turns/slash-command-executor.ts +++ b/src/features/chat/application/turns/slash-command-executor.ts @@ -31,6 +31,9 @@ export async function executeSlashCommandWithState( inputSnapshot?: ComposerInputSnapshot, ): Promise { const state = submissionStateSnapshot(host.stateStore.getState()); + if (state.activeThreadSubagent) { + throw new Error("Slash commands are unavailable in agent threads. Start a new chat to continue."); + } if (!host.connectionAvailable() && command !== "reconnect" && command !== "compact") return; return runSlashCommand(command, args, { ...host, diff --git a/src/features/chat/application/turns/submission-state.ts b/src/features/chat/application/turns/submission-state.ts index 1b249522..3e983163 100644 --- a/src/features/chat/application/turns/submission-state.ts +++ b/src/features/chat/application/turns/submission-state.ts @@ -7,6 +7,7 @@ import { activeTurnId, chatTurnBusy, type PendingTurnStart, pendingTurnStart } f export interface SubmissionStateSnapshot { activeThreadId: string | null; activeThreadEphemeral: boolean; + activeThreadSubagent: boolean; activeTurnId: string | null; busy: boolean; listedThreads: readonly Thread[]; @@ -18,6 +19,7 @@ export function submissionStateSnapshot(state: ChatState): SubmissionStateSnapsh return { activeThreadId: state.activeThread.id, activeThreadEphemeral: state.activeThread.lifetime?.kind === "ephemeral", + activeThreadSubagent: state.activeThread.provenance?.kind === "subagent", activeTurnId: activeTurnId(state), busy: chatTurnBusy(state), listedThreads: state.threadList.listedThreads, diff --git a/src/features/chat/application/turns/turn-submission-actions.ts b/src/features/chat/application/turns/turn-submission-actions.ts index 6c28b746..32311589 100644 --- a/src/features/chat/application/turns/turn-submission-actions.ts +++ b/src/features/chat/application/turns/turn-submission-actions.ts @@ -162,6 +162,9 @@ async function sendTurnText( } function planTurnSubmission(state: TurnSubmissionSnapshot): TurnSubmissionPlan { + if (state.activeThreadSubagent) { + return { kind: "blocked", message: "Messages are unavailable in agent threads. Start a new chat to continue." }; + } if (state.busy) { return state.activeThreadId && state.activeTurnId ? { kind: "steer", threadId: state.activeThreadId, turnId: state.activeTurnId } diff --git a/src/features/chat/host/bundles/shell-bundle.ts b/src/features/chat/host/bundles/shell-bundle.ts index 21404a75..73c6bd03 100644 --- a/src/features/chat/host/bundles/shell-bundle.ts +++ b/src/features/chat/host/bundles/shell-bundle.ts @@ -73,6 +73,7 @@ export function createShellBundle(host: ChatPanelShellBundleHost, input: ChatPan const thread = stateStore.getState().threadList.listedThreads.find((item) => item.id === activeThreadId); void environment.plugin.workspace.openSideChat(activeThreadId, thread?.name ?? thread?.preview ?? null); }, + activeThreadChatActionsDisabled: () => stateStore.getState().activeThread.provenance?.kind === "subagent", }); const toolbarSurface: ChatPanelToolbarSurface = { connection: { diff --git a/src/features/chat/host/session.ts b/src/features/chat/host/session.ts index e6d38d79..9130515a 100644 --- a/src/features/chat/host/session.ts +++ b/src/features/chat/host/session.ts @@ -264,7 +264,7 @@ export class ChatPanelSession implements ChatPanelHandle { const threadId = this.state.activeThread.id; if (!threadId) return null; const thread = this.state.threadList.listedThreads.find((item) => item.id === threadId); - return thread ? threadMeaningfulTitle(thread) : null; + return thread ? threadMeaningfulTitle(thread) : (this.state.activeThread.title ?? null); } private restoredThreadTitle(): string | null { diff --git a/src/features/chat/panel/composer-controller.ts b/src/features/chat/panel/composer-controller.ts index b4ecc4ed..3a638d8c 100644 --- a/src/features/chat/panel/composer-controller.ts +++ b/src/features/chat/panel/composer-controller.ts @@ -103,6 +103,7 @@ export class ChatComposerController { draft: model.draft.value, busy: model.turnBusy.value, canInterrupt: this.options.canInterrupt(model), + submissionDisabled: model.activeThreadSubagent.value, normalPlaceholder: projection.placeholder, suggestions: model.suggestions.value, selectedSuggestionIndex: model.selectedSuggestionIndex.value, @@ -249,6 +250,7 @@ export class ChatComposerController { { activeThreadId: state.activeThread.id, activeThreadEphemeral: state.activeThread.lifetime?.kind === "ephemeral", + activeThreadSubagent: state.activeThread.provenance?.kind === "subagent", contextReferences: this.contextReferences(), dailyNoteReferences: () => this.options.noteCandidateProvider.dailyNoteReferences(this.options.sourcePath()), permissionProfiles: state.connection.availablePermissionProfiles, diff --git a/src/features/chat/panel/shell-read-model.ts b/src/features/chat/panel/shell-read-model.ts index a0e195b2..981ab2bc 100644 --- a/src/features/chat/panel/shell-read-model.ts +++ b/src/features/chat/panel/shell-read-model.ts @@ -80,6 +80,7 @@ interface ChatPanelToolbarDebugState { export interface ChatPanelToolbarReadModel { readonly threads: ReadonlySignal; readonly activeThreadId: ReadonlySignal; + readonly activeThreadSubagent: ReadonlySignal; readonly turnBusy: ReadonlySignal; readonly runtimeSnapshot: ReadonlySignal; readonly toolbarPanel: ReadonlySignal; @@ -138,6 +139,7 @@ export interface ChatPanelComposerReadModel { readonly suggestions: ReadonlySignal; readonly selectedSuggestionIndex: ReadonlySignal; readonly activeThreadId: ReadonlySignal; + readonly activeThreadSubagent: ReadonlySignal; readonly turnBusy: ReadonlySignal; readonly activeTurnId: ReadonlySignal; readonly runtimeSnapshot: ReadonlySignal; @@ -179,16 +181,18 @@ export function createChatPanelShellReadModelBinding(initialState: ChatState): C threadStreamStableItems: computed(() => threadStreamStableItems(threadStream.value)), threadStreamActiveItems: computed(() => threadStreamActiveItems(threadStream.value)), threadStreamRollbackCandidate: computed(() => - turnBusy.value || activeThread.value.lifetime?.kind === "ephemeral" + turnBusy.value || activeThread.value.lifetime?.kind === "ephemeral" || activeThread.value.provenance?.kind === "subagent" ? null : threadStreamRollbackCandidateFromItems(streamItems.value), ), threadStreamForkCandidates: computed(() => - turnBusy.value || activeThread.value.lifetime?.kind === "ephemeral" ? [] : forkCandidatesFromItems(streamItems.value), + turnBusy.value || activeThread.value.lifetime?.kind === "ephemeral" || activeThread.value.provenance?.kind === "subagent" + ? [] + : forkCandidatesFromItems(streamItems.value), ), threadStreamImplementPlanTarget: computed(() => implementPlanTargetFromState({ - activeThread: { id: activeThreadIdSignal.value }, + activeThread: { id: activeThreadIdSignal.value, provenance: activeThread.value.provenance }, turn: turn.value, runtime: { pending: { collaborationMode: runtime.value.pending.collaborationMode } }, threadStream: threadStream.value, @@ -256,6 +260,7 @@ function toolbarReadModelFromSignals(signals: ChatPanelShellSignals): ChatPanelT return { threads: computed(() => signals.threadList.value.listedThreads), activeThreadId: signals.activeThreadId, + activeThreadSubagent: computed(() => signals.activeThread.value.provenance?.kind === "subagent"), turnBusy: signals.turnBusy, runtimeSnapshot: signals.toolbarRuntimeSnapshot, toolbarPanel: computed(() => signals.ui.value.toolbarPanel), @@ -334,6 +339,7 @@ function composerReadModelFromSignals(signals: ChatPanelShellSignals): ChatPanel suggestions: computed(() => signals.composer.value.suggestions), selectedSuggestionIndex: computed(() => signals.composer.value.suggestSelected), activeThreadId: signals.activeThreadId, + activeThreadSubagent: computed(() => signals.activeThread.value.provenance?.kind === "subagent"), turnBusy: signals.turnBusy, activeTurnId: signals.activeTurnId, runtimeSnapshot: signals.composerRuntimeSnapshot, diff --git a/src/features/chat/panel/surface/composer-projection.tsx b/src/features/chat/panel/surface/composer-projection.tsx index a2053bfd..7a6aa7cb 100644 --- a/src/features/chat/panel/surface/composer-projection.tsx +++ b/src/features/chat/panel/surface/composer-projection.tsx @@ -63,11 +63,9 @@ export function chatPanelComposerProjection( ): ChatPanelComposerProjection { const snapshot = readModel.runtimeSnapshot.value; return { - placeholder: composerPlaceholder( - activeComposerThreadName(readModel), - readModel.sideChatActive.value, - readModel.sideChatSourceTitle.value, - ), + placeholder: readModel.activeThreadSubagent.value + ? "Agent thread is read-only." + : composerPlaceholder(activeComposerThreadName(readModel), readModel.sideChatActive.value, readModel.sideChatSourceTitle.value), meta: { ...composerMetaViewModel(readModel, snapshot), ...runtimeComposerChoices({ diff --git a/src/features/chat/panel/surface/toolbar-projection.tsx b/src/features/chat/panel/surface/toolbar-projection.tsx index 383f874c..c81e3f59 100644 --- a/src/features/chat/panel/surface/toolbar-projection.tsx +++ b/src/features/chat/panel/surface/toolbar-projection.tsx @@ -39,6 +39,7 @@ interface ToolbarViewModelInput { interface ToolbarStateProjection { newChatDisabled: boolean; + activeThreadChatActionsDisabled: boolean; chatActionsOpen: boolean; historyOpen: boolean; statusPanelOpen: boolean; @@ -82,6 +83,7 @@ function chatPanelToolbarProjection(input: ToolbarViewModelInput): ToolbarViewMo }); return { newChatDisabled: projection.newChatDisabled, + activeThreadChatActionsDisabled: projection.activeThreadChatActionsDisabled, chatActionsOpen: projection.chatActionsOpen, historyOpen: projection.historyOpen, statusPanelOpen: projection.statusPanelOpen, @@ -140,7 +142,8 @@ function toolbarStateProjection(input: { const chatActionsOpen = toolbarPanel === "chat-actions"; const statusPanelOpen = toolbarPanel === "status-panel"; return { - newChatDisabled: input.turnBusy, + newChatDisabled: input.turnBusy && !input.model.activeThreadSubagent.value, + activeThreadChatActionsDisabled: input.model.activeThreadSubagent.value, chatActionsOpen, historyOpen, statusPanelOpen, diff --git a/src/features/chat/panel/toolbar-actions.ts b/src/features/chat/panel/toolbar-actions.ts index 63aa9c6c..44781c57 100644 --- a/src/features/chat/panel/toolbar-actions.ts +++ b/src/features/chat/panel/toolbar-actions.ts @@ -34,6 +34,7 @@ export interface ToolbarUiActionDependencies { rename: ThreadRenameEditorActions; navigation: ThreadNavigationActions; openSideChat?: () => void; + activeThreadChatActionsDisabled: () => boolean; } export interface ToolbarOutsidePointerHit { @@ -135,11 +136,20 @@ export function createToolbarUiActions(deps: ToolbarUiActionDependencies): Toolb startNewThread: () => { void deps.navigation.startNewThread(); }, - ...(deps.openSideChat ? { startSideChat: deps.openSideChat } : {}), + ...(deps.openSideChat + ? { + startSideChat: () => { + if (deps.activeThreadChatActionsDisabled()) return; + deps.openSideChat?.(); + }, + } + : {}), compactContext: () => { + if (deps.activeThreadChatActionsDisabled()) return; void deps.threadActions.compactActiveThread(); }, setGoal: () => { + if (deps.activeThreadChatActionsDisabled()) return; deps.goals.startEditingCurrent(); }, }, diff --git a/src/features/chat/ui/composer.tsx b/src/features/chat/ui/composer.tsx index 7c956a6c..2b5a188b 100644 --- a/src/features/chat/ui/composer.tsx +++ b/src/features/chat/ui/composer.tsx @@ -88,6 +88,7 @@ export interface ComposerShellProps { draft: string; busy: boolean; canInterrupt: boolean; + submissionDisabled: boolean; normalPlaceholder: string; meta: ComposerMetaViewModel; suggestions: readonly ComposerSuggestion[]; @@ -103,6 +104,7 @@ export function ComposerShell({ draft, busy, canInterrupt, + submissionDisabled, normalPlaceholder, meta, suggestions, @@ -147,7 +149,7 @@ export function ComposerShell({ if (pendingSelection.value === draft) restoreComposerCursor(composerRef.current, pendingSelection.cursor); onPendingSelectionApplied?.(); }, [draft, pendingSelection, onPendingSelectionApplied]); - const sendMode = composerSendMode(busy, canInterrupt, draft); + const sendMode = composerSendMode(busy, canInterrupt, draft, submissionDisabled); const normalizedSelectedSuggestionIndex = suggestions.length === 0 ? 0 : Math.min(selectedSuggestionIndex, suggestions.length - 1); const selectedSuggestionId = suggestions.length > 0 ? composerSuggestionOptionId(viewId, normalizedSelectedSuggestionIndex) : undefined; @@ -164,6 +166,7 @@ export function ComposerShell({ aria-controls={composerSuggestionsListId(viewId)} aria-activedescendant={selectedSuggestionId} value={draft} + readOnly={submissionDisabled} onInput={(event) => { if (syncComposerHeight(event.currentTarget)) callbacks.onHeightChange(); callbacks.onInput(event.currentTarget.value); @@ -184,7 +187,7 @@ export function ComposerShell({ callbacks.onDragOver?.(event); }} /> - + (null); const statusRef = useRef(null); @@ -226,6 +231,7 @@ function ComposerMeta({ }); }, [picker]); const openPicker = (kind: ComposerMetaPickerKind) => { + if (disabled) return; const nextPicker = composerMetaPickerState( kind, kind === "model" ? modelTriggerRef.current : effortTriggerRef.current, @@ -253,6 +259,7 @@ function ComposerMeta({ { callbacks.onTogglePlan?.(); }} @@ -260,6 +267,7 @@ function ComposerMeta({ { callbacks.onToggleAutoReview?.(); }} @@ -267,6 +275,7 @@ function ComposerMeta({ { callbacks.onToggleFast?.(); }} @@ -280,6 +289,7 @@ function ComposerMeta({ triggerRef={modelTriggerRef} kind="model" value={meta.model} + disabled={disabled} onMouseDown={() => { openPicker("model"); }} @@ -292,6 +302,7 @@ function ComposerMeta({ triggerRef={effortTriggerRef} kind="effort" value={meta.effort} + disabled={disabled} onMouseDown={() => { openPicker("effort"); }} @@ -337,7 +348,17 @@ function ComposerContextMeter({ context }: { context: ComposerMetaViewModel["con ); } -function ComposerMetaModeButton({ icon, active, onMouseDown }: { icon: string; active: boolean; onMouseDown: () => void }): UiNode { +function ComposerMetaModeButton({ + icon, + active, + disabled, + onMouseDown, +}: { + icon: string; + active: boolean; + disabled: boolean; + onMouseDown: () => void; +}): UiNode { const iconRef = useRef(null); useLayoutEffect(() => { const element = iconRef.current; @@ -348,12 +369,17 @@ function ComposerMetaModeButton({ icon, active, onMouseDown }: { icon: string; a