diff --git a/docs/design.md b/docs/design.md index d36707b0..a74d02e4 100644 --- a/docs/design.md +++ b/docs/design.md @@ -58,6 +58,8 @@ Multiple panels are separate Obsidian leaves. Treat each panel as its own Codex 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. +Mode-derived restrictions for the active panel thread belong to one pure operation policy. Actions and UI projections must use that policy instead of independently interpreting panel phase, side-chat lifetime, or subagent provenance. Keep connection state, turn busy state, and operations targeting another listed thread in their owning workflows. + Thread history 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, not replacements for Codex history. Routine thread lists should load a bounded recent set and paginate older threads on demand instead of eagerly fetching complete inventories. diff --git a/src/features/chat/application/composer/slash-commands.ts b/src/features/chat/application/composer/slash-commands.ts index 7f7b3396..92c2df21 100644 --- a/src/features/chat/application/composer/slash-commands.ts +++ b/src/features/chat/application/composer/slash-commands.ts @@ -1,3 +1,5 @@ +import { type ActivePanelOperation, activePanelOperationDecisionForFacts } from "../panel-operation-policy"; + type SlashCommandArgsKind = | "none" | "optionalThread" @@ -33,7 +35,6 @@ export interface SlashCommandDefinitionShape { argsKind: SlashCommandArgsKind; surface: SlashCommandSurface; detail: string; - sideChatAvailable?: false; subcommands?: readonly SlashCommandSubcommandDefinition[]; } @@ -79,7 +80,6 @@ export const SLASH_COMMANDS = [ argsKind: "none", surface: "panelAction", detail: "Fork the active Codex thread.", - sideChatAvailable: false, }, { command: "/btw", @@ -87,7 +87,6 @@ export const SLASH_COMMANDS = [ argsKind: "none", surface: "panelAction", detail: "Open a temporary side chat.", - sideChatAvailable: false, }, { command: "/rollback", @@ -95,7 +94,6 @@ export const SLASH_COMMANDS = [ argsKind: "none", surface: "panelAction", detail: "Roll back the latest turn and restore its prompt.", - sideChatAvailable: false, }, { command: "/compact", usage: "/compact", argsKind: "none", surface: "panelAction", detail: "Compact the current thread context." }, { @@ -220,8 +218,61 @@ export function slashCommandRequiresConnection(command: SlashCommandName): boole } export function slashCommandAvailableInSideChat(command: SlashCommandName): boolean { - const definition = slashCommandDefinition(command); - return !("sideChatAvailable" in definition); + const operation = activePanelOperationForSlashCommandSuggestion(command); + return ( + !operation || + activePanelOperationDecisionForFacts({ phase: "active", lifetime: "ephemeral", provenance: "interactive" }, operation).kind === + "allowed" + ); +} + +/** Maps a parsed command to the active-panel operation it performs, if any. */ +export function activePanelOperationForSlashCommand(command: SlashCommandName, args: string): ActivePanelOperation | null { + switch (command) { + case "goal": + return args.trim() ? "goal-mutation" : "goal-read"; + case "compact": + return "compact"; + case "fork": + return "fork"; + case "rollback": + return "rollback"; + case "btw": + return "start-side-chat"; + case "fast": + case "auto-review": + case "plan": + return "thread-settings"; + case "permissions": + return args ? "permission-settings" : null; + case "model": + case "reasoning": + return args ? "thread-settings" : null; + case "status": + case "doctor": + case "tools": + case "help": + return null; + default: + return "submit"; + } +} + +/** Maps a slash-completion to its mode-derived operation without assuming arguments. */ +export function activePanelOperationForSlashCommandSuggestion(command: SlashCommandName): ActivePanelOperation | null { + switch (command) { + case "goal": + case "compact": + case "fork": + case "rollback": + case "btw": + case "fast": + case "auto-review": + case "plan": + return activePanelOperationForSlashCommand(command, ""); + default: + return null; + } } export function slashCommandDefinition(command: SlashCommandName): SlashCommandDefinition { diff --git a/src/features/chat/application/composer/suggestions.ts b/src/features/chat/application/composer/suggestions.ts index d758fd87..d790d104 100644 --- a/src/features/chat/application/composer/suggestions.ts +++ b/src/features/chat/application/composer/suggestions.ts @@ -19,13 +19,7 @@ import { selectionContextReferenceMarker, } from "./context-references"; import type { DailyNoteReferenceCandidate } from "./daily-note-references"; -import { - isSlashCommandName, - SLASH_COMMANDS, - type SlashCommandName, - slashCommandAvailableInSideChat, - slashCommandSubcommands, -} from "./slash-commands"; +import { isSlashCommandName, SLASH_COMMANDS, type SlashCommandName, slashCommandSubcommands } from "./slash-commands"; export interface ComposerSuggestion { display: string; @@ -41,8 +35,7 @@ export interface ComposerSuggestion { export interface ComposerSuggestionOptions { activeThreadId?: string | null; - activeThreadEphemeral?: boolean; - activeThreadSubagent?: boolean; + slashCommandAvailable?: (command: SlashCommandName) => boolean; contextReferences?: ComposerContextReferences; dailyNoteReferences?: readonly DailyNoteReferenceCandidate[] | (() => readonly DailyNoteReferenceCandidate[]); permissionProfiles?: readonly RuntimePermissionProfileSummary[]; @@ -121,14 +114,16 @@ 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)); + const slashCommandAvailable = options.slashCommandAvailable ?? (() => true); + const slashSuggestions = slashSuggestionsForActivePanel( + beforeCursor, + threads, + models, + currentModel, + options.activeThreadId ?? null, + options.permissionProfiles ?? [], + slashCommandAvailable, + ); return ( activeWikiLinkSuggestions(beforeCursor, notes) ?? activeContextReferenceSuggestions(beforeCursor, options.contextReferences, options.dailyNoteReferences) ?? @@ -139,6 +134,27 @@ export function activeComposerSuggestions( ); } +function slashSuggestionsForActivePanel( + beforeCursor: string, + threads: readonly Thread[], + models: readonly ModelMetadata[], + currentModel: string | null, + activeThreadId: string | null, + permissionProfiles: readonly RuntimePermissionProfileSummary[], + slashCommandAvailable: (command: SlashCommandName) => boolean, +): ComposerSuggestion[] | null { + const command = /^\/([A-Za-z-]+)/.exec(beforeCursor)?.[1]; + if (command && isSlashCommandName(command) && !slashCommandAvailable(command)) return []; + return ( + activeSlashSubcommandSuggestions(beforeCursor, slashCommandAvailable) ?? + activeThreadCommandSuggestions(beforeCursor, threads, activeThreadId) ?? + modelOverrideSuggestions(beforeCursor, models) ?? + reasoningEffortOverrideSuggestions(beforeCursor, models, currentModel) ?? + permissionProfileOverrideSuggestions(beforeCursor, permissionProfiles) ?? + activeSlashCommandSuggestions(beforeCursor, slashCommandAvailable) + ); +} + function activeContextReferenceSuggestions( beforeCursor: string, references: ComposerContextReferences | undefined, @@ -405,8 +421,7 @@ function compareWikiLinkSuggestionTiebreakers(a: NoteCandidateMatch, b: NoteCand function activeSlashCommandSuggestions( beforeCursor: string, - activeThreadEphemeral: boolean, - activeThreadSubagent: boolean, + slashCommandAvailable: (command: SlashCommandName) => boolean, ): ComposerSuggestion[] | null { const match = /^(\/[A-Za-z-]*)$/.exec(beforeCursor); if (match?.index === undefined) return null; @@ -414,13 +429,10 @@ function activeSlashCommandSuggestions( 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( - (item) => - item.command.toLowerCase().startsWith(query) && - (!activeThreadEphemeral || slashCommandAvailableInSideChat(item.command.slice(1) as SlashCommandName)), + (item) => item.command.toLowerCase().startsWith(query) && slashCommandAvailable(item.command.slice(1) as SlashCommandName), ) .slice(0, 8) .map((item) => ({ @@ -432,13 +444,17 @@ function activeSlashCommandSuggestions( })); } -function activeSlashSubcommandSuggestions(beforeCursor: string): ComposerSuggestion[] | null { +function activeSlashSubcommandSuggestions( + beforeCursor: string, + slashCommandAvailable: (command: SlashCommandName) => boolean, +): ComposerSuggestion[] | null { const match = /^\/([A-Za-z-]+)\s+([A-Za-z-]{0,120})$/.exec(beforeCursor); if (!match) return null; const command = match[1]; const rawQuery = match[2]; if (!command || !isSlashCommandName(command) || rawQuery === undefined) return null; + if (!slashCommandAvailable(command)) return []; const query = rawQuery.toLowerCase(); const subcommands = slashCommandSubcommands(command); diff --git a/src/features/chat/application/panel-operation-policy.ts b/src/features/chat/application/panel-operation-policy.ts new file mode 100644 index 00000000..93cd0849 --- /dev/null +++ b/src/features/chat/application/panel-operation-policy.ts @@ -0,0 +1,122 @@ +import type { ChatState } from "./state/root-reducer"; + +/** + * Operations whose availability derives from the panel's current thread mode. + * + * This intentionally excludes connection, turn-busy, and operations aimed at a + * different listed thread. Those are workflow concerns rather than properties + * of the active panel thread. + */ +export type ActivePanelOperation = + | "submit" + | "start-side-chat" + | "compact" + | "fork" + | "rollback" + | "thread-settings" + | "permission-settings" + | "goal-read" + | "goal-mutation" + | "implement-plan"; + +export type ActivePanelOperationDecision = + | { readonly kind: "allowed" } + | { readonly kind: "blocked"; readonly message: string } + | { readonly kind: "resume-required" }; + +export type ActivePanelThreadFacts = + | { readonly phase: "empty" } + | { readonly phase: "awaiting-resume"; readonly provenance: "interactive" | "subagent" | null } + | { + readonly phase: "active"; + readonly lifetime: "persistent" | "ephemeral"; + readonly provenance: "interactive" | "subagent" | null; + }; + +const ALLOWED: ActivePanelOperationDecision = { kind: "allowed" }; + +export function activePanelOperationDecision(state: ChatState, operation: ActivePanelOperation): ActivePanelOperationDecision { + return activePanelOperationDecisionForFacts(activePanelThreadFacts(state), operation); +} + +export function activePanelOperationDecisionForFacts( + facts: ActivePanelThreadFacts, + operation: ActivePanelOperation, +): ActivePanelOperationDecision { + if (facts.phase === "awaiting-resume") { + if (facts.provenance === "subagent") return agentThreadBlocked(operation); + return operation === "goal-mutation" ? { kind: "resume-required" } : ALLOWED; + } + if (facts.phase === "empty") return ALLOWED; + + // Keep the stricter interpretation if a malformed or future state contains + // both mode facts. This makes mode restrictions monotonically safe. + if (facts.provenance === "subagent" && facts.lifetime === "ephemeral") { + return operation === "goal-read" ? sideChatDecision(operation) : agentThreadBlocked(operation); + } + if (facts.provenance === "subagent") return agentThreadBlocked(operation); + if (facts.lifetime === "ephemeral") return sideChatDecision(operation); + return ALLOWED; +} + +function activePanelThreadFacts(state: ChatState): ActivePanelThreadFacts { + const panelThread = state.panelThread; + if (panelThread.kind === "empty") return { phase: "empty" }; + if (panelThread.kind === "awaiting-resume") { + return { phase: "awaiting-resume", provenance: panelThread.provenance?.kind ?? null }; + } + return { + phase: "active", + lifetime: panelThread.thread.lifetime?.kind ?? "persistent", + provenance: panelThread.thread.provenance?.kind ?? null, + }; +} + +function agentThreadBlocked(operation: ActivePanelOperation): ActivePanelOperationDecision { + switch (operation) { + case "submit": + return { kind: "blocked", message: "Messages are unavailable in agent threads. Start a new chat to continue." }; + case "goal-mutation": + return { kind: "blocked", message: "Goals are read-only in agent threads." }; + case "goal-read": + return ALLOWED; + case "implement-plan": + return { kind: "blocked", message: "Plans cannot be implemented from agent threads." }; + case "compact": + return { kind: "blocked", message: "Agent threads cannot be compacted." }; + case "fork": + return { kind: "blocked", message: "Agent threads cannot be forked." }; + case "rollback": + return { kind: "blocked", message: "Agent threads cannot be rolled back." }; + case "start-side-chat": + return { kind: "blocked", message: "Side chats cannot be started from agent threads." }; + case "thread-settings": + case "permission-settings": + return { kind: "blocked", message: "Thread settings are unavailable in agent threads." }; + } +} + +function sideChatDecision(operation: ActivePanelOperation): ActivePanelOperationDecision { + switch (operation) { + case "goal-mutation": + return { kind: "blocked", message: "Goals are unavailable in side chats." }; + case "goal-read": + return { kind: "blocked", message: "Goals are unavailable in side chats." }; + case "implement-plan": + return { kind: "blocked", message: "Plans cannot be implemented from side chats." }; + case "compact": + return ALLOWED; + case "fork": + return { kind: "blocked", message: "Side chats cannot be forked." }; + case "rollback": + return { kind: "blocked", message: "Side chats cannot be rolled back." }; + case "start-side-chat": + return { kind: "blocked", message: "Side chats cannot be started from another side chat." }; + case "permission-settings": + return { kind: "blocked", message: "Permission changes are unavailable in side chats." }; + case "submit": + return ALLOWED; + case "thread-settings": + return { kind: "blocked", message: "Thread settings are unavailable in side chats." }; + } +} diff --git a/src/features/chat/application/runtime/settings-actions.ts b/src/features/chat/application/runtime/settings-actions.ts index b67bfc95..79f13805 100644 --- a/src/features/chat/application/runtime/settings-actions.ts +++ b/src/features/chat/application/runtime/settings-actions.ts @@ -9,7 +9,8 @@ import { pendingRuntimeSettingsPatch as buildPendingRuntimeSettingsPatch, type PendingRuntimeSettingsPatch, } from "../../domain/runtime/thread-settings-patch"; -import { activeThreadId, activeThreadState, type ChatAction, type ChatState } from "../state/root-reducer"; +import { type ActivePanelOperation, activePanelOperationDecision } from "../panel-operation-policy"; +import { activeThreadId, type ChatAction, type ChatState } from "../state/root-reducer"; import type { ChatStateStore } from "../state/store"; import type { RuntimeSettingsTransport } from "./settings-transport"; @@ -91,6 +92,7 @@ async function commitPendingThreadSettings(host: RuntimeSettingsActionsHost): Pr if (Object.keys(update).length === 0) return { ok: true, collaborationModeApplied }; try { + if (activePanelOperationBlocked(host, "thread-settings")) return { ok: false, collaborationModeApplied: false }; if (!(await host.runtimeTransport.updateThreadSettings(threadId, update))) { return { ok: false, collaborationModeApplied: false }; } @@ -108,13 +110,13 @@ async function commitPendingThreadSettings(host: RuntimeSettingsActionsHost): Pr } async function requestModel(host: RuntimeSettingsActionsHost, model: string): Promise { - if (agentThreadSettingsBlocked(host)) return false; + if (activePanelOperationBlocked(host, "thread-settings")) return false; dispatch(host, { type: "runtime/model-requested", model }); return applyPendingThreadSettings(host); } async function resetModelToConfig(host: RuntimeSettingsActionsHost): Promise { - if (agentThreadSettingsBlocked(host)) return false; + if (activePanelOperationBlocked(host, "thread-settings")) return false; dispatch(host, { type: "runtime/model-reset-to-config" }); return applyPendingThreadSettings(host); } @@ -124,13 +126,13 @@ async function requestModelFromUi(host: RuntimeSettingsActionsHost, model: strin } async function requestReasoningEffort(host: RuntimeSettingsActionsHost, effort: ReasoningEffort): Promise { - if (agentThreadSettingsBlocked(host)) return false; + if (activePanelOperationBlocked(host, "thread-settings")) return false; dispatch(host, { type: "runtime/reasoning-effort-requested", effort }); return applyPendingThreadSettings(host); } async function resetReasoningEffortToConfig(host: RuntimeSettingsActionsHost): Promise { - if (agentThreadSettingsBlocked(host)) return false; + if (activePanelOperationBlocked(host, "thread-settings")) return false; dispatch(host, { type: "runtime/reasoning-effort-reset-to-config" }); return applyPendingThreadSettings(host); } @@ -144,28 +146,21 @@ async function resetReasoningEffortToConfigFromUi(host: RuntimeSettingsActionsHo } async function requestPermissionProfile(host: RuntimeSettingsActionsHost, permissionProfile: string): Promise { - if (agentThreadSettingsBlocked(host)) return false; - if (permissionChangesBlocked(host)) return false; + if (activePanelOperationBlocked(host, "permission-settings")) 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; + if (activePanelOperationBlocked(host, "permission-settings")) return false; dispatch(host, { type: "runtime/permission-profile-reset-to-config" }); return applyPendingThreadSettings(host); } -function permissionChangesBlocked(host: RuntimeSettingsActionsHost): boolean { - if (activeThreadState(state(host))?.lifetime?.kind !== "ephemeral") return false; - host.addSystemMessage("Permission changes are unavailable in side chats."); - return true; -} - -function agentThreadSettingsBlocked(host: RuntimeSettingsActionsHost): boolean { - if (activeThreadState(state(host))?.provenance?.kind !== "subagent") return false; - host.addSystemMessage("Thread settings are unavailable in agent threads."); +function activePanelOperationBlocked(host: RuntimeSettingsActionsHost, operation: ActivePanelOperation): boolean { + const decision = activePanelOperationDecision(state(host), operation); + if (decision.kind !== "blocked") return false; + host.addSystemMessage(decision.message); return true; } @@ -175,7 +170,7 @@ async function toggleFastMode(host: RuntimeSettingsActionsHost): Promise { } async function setFastMode(host: RuntimeSettingsActionsHost, mode: FastModeState): Promise { - if (agentThreadSettingsBlocked(host)) return; + if (activePanelOperationBlocked(host, "thread-settings")) return; const fastMode: RequestedFastMode = mode; await runRuntimeUiCommand( host, @@ -194,7 +189,7 @@ async function toggleCollaborationMode(host: RuntimeSettingsActionsHost): Promis } async function setCollaborationMode(host: RuntimeSettingsActionsHost, collaborationMode: CollaborationModeSelection): Promise { - if (agentThreadSettingsBlocked(host)) return false; + if (activePanelOperationBlocked(host, "thread-settings")) return false; dispatch(host, { type: "runtime/requested-collaboration-mode-set", collaborationMode }); const result = await commitPendingThreadSettings(host); if (result.ok) closeRuntimePanel(host); @@ -205,7 +200,7 @@ async function setCollaborationMode(host: RuntimeSettingsActionsHost, collaborat } function requestDefaultCollaborationModeForNextTurn(host: RuntimeSettingsActionsHost): void { - if (agentThreadSettingsBlocked(host)) return; + if (activePanelOperationBlocked(host, "thread-settings")) return; dispatch(host, { type: "runtime/requested-collaboration-mode-set", collaborationMode: "default" }); } @@ -216,7 +211,7 @@ async function toggleAutoReview(host: RuntimeSettingsActionsHost): Promise } async function setAutoReview(host: RuntimeSettingsActionsHost, mode: AutoReviewState): Promise { - if (agentThreadSettingsBlocked(host)) return; + if (activePanelOperationBlocked(host, "thread-settings")) return; await runRuntimeUiCommand( host, async () => { diff --git a/src/features/chat/application/threads/goal-actions.ts b/src/features/chat/application/threads/goal-actions.ts index afa1a7de..b181c3cc 100644 --- a/src/features/chat/application/threads/goal-actions.ts +++ b/src/features/chat/application/threads/goal-actions.ts @@ -2,6 +2,7 @@ import type { ThreadGoal, ThreadGoalStatus, ThreadGoalUpdate } from "../../../.. import { goalChangeItem } from "../../domain/thread-stream/factories/goal-items"; import type { GoalThreadStreamItem } from "../../domain/thread-stream/items"; import type { LocalIdSource } from "../local-id-source"; +import { activePanelOperationDecision } from "../panel-operation-policy"; import { activeThreadId, activeThreadState } from "../state/root-reducer"; import type { ChatStateStore } from "../state/store"; import type { ThreadGoalReadTransport, ThreadGoalTransport } from "./goal-transport"; @@ -18,6 +19,7 @@ export interface ThreadGoalSyncHost { export interface GoalActionsHost extends ThreadGoalSyncHost { goalTransport: ThreadGoalTransport; startThread: (preview?: string, options?: { syncGoal?: boolean }) => Promise<{ threadId: string } | null>; + ensureRestoredThreadLoaded?: () => Promise; } export interface ThreadGoalSyncActions { @@ -61,7 +63,7 @@ export function createGoalActions(host: GoalActionsHost): GoalActions { setStatus: (threadId, status) => setGoalStatus(host, threadId, status), clear: (threadId) => clearGoal(host, threadId), startEditingCurrent: () => { - startEditingCurrent(host); + void startEditingCurrent(host); }, startEditing: (threadId, objective, tokenBudget) => { startEditing(host, threadId, objective, tokenBudget); @@ -117,6 +119,7 @@ async function setNormalizedObjective( } async function saveObjective(host: GoalActionsHost, objective: string, tokenBudget: number | null): Promise { + if (!(await prepareGoalMutation(host))) return false; const plan = planGoalObjectiveSave(activeThreadId(host.stateStore.getState()), objective, tokenBudget); switch (plan.kind) { case "reject": @@ -134,6 +137,7 @@ function setGoalStatus(host: GoalActionsHost, threadId: string, status: ThreadGo } async function clearGoal(host: GoalActionsHost, threadId: string): Promise { + if (!(await prepareGoalMutation(host)) || !goalMutationTargetsActiveThread(host, threadId)) return false; try { if (!(await host.goalTransport.clearThreadGoal(threadId))) return false; applyGoalIfActive(host, threadId, null, { reportChange: true }); @@ -145,6 +149,7 @@ async function clearGoal(host: GoalActionsHost, threadId: string): Promise { + if (!(await prepareGoalMutation(host)) || !goalMutationTargetsActiveThread(host, threadId)) return false; try { const goal = await host.goalTransport.setThreadGoal(threadId, params); if (goal === undefined) return false; @@ -171,12 +176,38 @@ function applyGoalIfActive( return true; } -function startEditingCurrent(host: GoalActionsHost): void { +async function startEditingCurrent(host: GoalActionsHost): Promise { + if (!(await prepareGoalMutation(host)) || !goalMutationAllowedNow(host)) return; host.stateStore.dispatch({ type: "ui/panel-set", panel: null }); const goal = activeThreadState(host.stateStore.getState())?.goal ?? null; startEditing(host, goal?.threadId ?? null, goal?.objective ?? "", goal?.tokenBudget ?? null); } +async function prepareGoalMutation(host: GoalActionsHost): Promise { + const decision = activePanelOperationDecision(host.stateStore.getState(), "goal-mutation"); + if (decision.kind === "allowed") return true; + if (decision.kind === "blocked") { + host.addSystemMessage(decision.message); + return false; + } + if (!host.ensureRestoredThreadLoaded || !(await host.ensureRestoredThreadLoaded())) return false; + const resumedDecision = activePanelOperationDecision(host.stateStore.getState(), "goal-mutation"); + if (resumedDecision.kind === "allowed") return true; + if (resumedDecision.kind === "blocked") host.addSystemMessage(resumedDecision.message); + return false; +} + +function goalMutationTargetsActiveThread(host: GoalActionsHost, threadId: string): boolean { + return activeThreadId(host.stateStore.getState()) === threadId && goalMutationAllowedNow(host); +} + +function goalMutationAllowedNow(host: GoalActionsHost): boolean { + const decision = activePanelOperationDecision(host.stateStore.getState(), "goal-mutation"); + if (decision.kind === "allowed") return true; + if (decision.kind === "blocked") host.addSystemMessage(decision.message); + return false; +} + function startEditing(host: GoalActionsHost, threadId: string | null, objective: string, tokenBudget: number | null): void { host.stateStore.dispatch({ type: "ui/goal-editor-started", threadId, objective, tokenBudget }); } @@ -200,6 +231,7 @@ async function startThreadAndSaveObjective( ): Promise { try { if (!(await host.goalTransport.ensureConnected())) return false; + if (!emptyPanelCanStartGoalThread(host)) return false; const response = await host.startThread(plan.objective, { syncGoal: false }); const threadId = response?.threadId ?? null; return threadId ? await setNormalizedObjective(host, threadId, plan.objective, plan.tokenBudget) : false; @@ -209,6 +241,11 @@ async function startThreadAndSaveObjective( } } +function emptyPanelCanStartGoalThread(host: GoalActionsHost): boolean { + const state = host.stateStore.getState(); + return state.panelThread.kind === "empty" && activePanelOperationDecision(state, "goal-mutation").kind === "allowed"; +} + async function recordGoalUserMessage(host: GoalActionsHost, threadId: string, objective: string): Promise { try { await host.goalTransport.recordThreadGoalUserMessage(threadId, objective); diff --git a/src/features/chat/application/threads/thread-management-actions.ts b/src/features/chat/application/threads/thread-management-actions.ts index 89cab876..0991a169 100644 --- a/src/features/chat/application/threads/thread-management-actions.ts +++ b/src/features/chat/application/threads/thread-management-actions.ts @@ -1,7 +1,8 @@ import { inheritedForkThreadName, type Thread } from "../../../../domain/threads/model"; import { activeThreadRuntimeState } from "../../domain/runtime/state"; +import { type ActivePanelOperation, activePanelOperationDecision } from "../panel-operation-policy"; import { resumedThreadActionFromActiveRuntime } from "../state/actions"; -import { activeThreadId, activeThreadState, type ChatAction, type ChatState } from "../state/root-reducer"; +import { activeThreadId, type ChatAction, type ChatState } from "../state/root-reducer"; import type { ChatStateStore } from "../state/store"; import { threadStreamRollbackCandidate, threadStreamTurnsAfterTurnId } from "../state/thread-stream"; import { chatTurnBusy } from "../turns/turn-state"; @@ -69,6 +70,7 @@ async function compactActiveThread(host: ThreadManagementActionsHost): Promise { + if (activePanelOperationBlocked(host, threadId, "compact")) return; const scope = captureThreadManagementPanelScope(host, threadId); try { if (!(await host.threadTransport.compactThread(threadId))) return; @@ -108,15 +110,7 @@ async function forkThreadFromTurn( turnId: string | null, archiveSource: boolean, ): Promise { - const activeThread = activeThreadState(threadManagementState(host)); - if (activeThread?.id === threadId && activeThread.lifetime?.kind === "ephemeral") { - 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 (activePanelOperationBlocked(host, threadId, "fork")) return; if (chatTurnBusy(threadManagementState(host))) { host.addSystemMessage("Finish or interrupt the current turn before forking threads."); return; @@ -177,15 +171,7 @@ async function renameThread(host: ThreadManagementActionsHost, threadId: string, } async function rollbackThread(host: ThreadManagementActionsHost, threadId: string): Promise { - const activeThread = activeThreadState(threadManagementState(host)); - if (activeThread?.id === threadId && activeThread.lifetime?.kind === "ephemeral") { - 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 (activePanelOperationBlocked(host, threadId, "rollback")) return; if (chatTurnBusy(threadManagementState(host))) { host.addSystemMessage("Interrupt the current turn before rolling back."); return; @@ -229,6 +215,15 @@ async function rollbackThread(host: ThreadManagementActionsHost, threadId: strin } } +function activePanelOperationBlocked(host: ThreadManagementActionsHost, threadId: string, operation: ActivePanelOperation): boolean { + const state = threadManagementState(host); + if (activeThreadId(state) !== threadId) return false; + const decision = activePanelOperationDecision(state, operation); + if (decision.kind !== "blocked") return false; + host.addSystemMessage(decision.message); + return true; +} + function threadManagementState(host: ThreadManagementActionsHost): ChatState { return host.stateStore.getState(); } diff --git a/src/features/chat/application/turns/composer-submit-actions.ts b/src/features/chat/application/turns/composer-submit-actions.ts index 0b9163f0..54c59665 100644 --- a/src/features/chat/application/turns/composer-submit-actions.ts +++ b/src/features/chat/application/turns/composer-submit-actions.ts @@ -2,6 +2,7 @@ import type { ComposerInputSnapshot } from "../composer/input-snapshot"; import { type SlashCommandName, slashCommandRequiresConnection } from "../composer/slash-commands"; import { parseSlashCommand } from "../composer/suggestions"; import type { LocalIdSource } from "../local-id-source"; +import { activePanelOperationDecision } from "../panel-operation-policy"; import { cancellablePendingSubmissionMatches } from "../state/pending-submission"; import type { ChatStateStore } from "../state/store"; import { parseWebCommandArgs, type SlashCommandExecutionResult } from "./slash-command-execution"; @@ -61,10 +62,12 @@ export async function submitComposer(host: ComposerSubmitActionsHost): Promise | null; + activeThread: Pick | null; + modeAllowed: boolean; turn: ChatTurnState; runtime: { pending: Pick }; threadStream: Pick; @@ -24,6 +26,7 @@ interface PlanImplementationState { export function implementPlanTargetFromState(state: ChatState): PlanImplementationTarget | null { return implementPlanTarget({ activeThread: activeThreadState(state), + modeAllowed: activePanelOperationDecision(state, "implement-plan").kind === "allowed", turn: state.turn, runtime: state.runtime, threadStream: state.threadStream, @@ -34,7 +37,7 @@ export function implementPlanTarget(state: PlanImplementationState): PlanImpleme const { activeThread } = state; if ( !activeThread || - activeThread.provenance?.kind === "subagent" || + !state.modeAllowed || chatTurnBusy(state) || state.runtime.pending.collaborationMode.kind !== "set" || state.runtime.pending.collaborationMode.value !== "plan" @@ -46,7 +49,8 @@ export function implementPlanTarget(state: PlanImplementationState): PlanImpleme export async function implementPlan(host: PlanImplementationHost, itemId: string): Promise { if (itemId !== implementPlanTargetFromState(host.stateStore.getState())?.itemId) return; - if (!(await host.ensureConnected()) || !activeThreadId(host.stateStore.getState())) return; + if (!(await host.ensureConnected())) return; + if (itemId !== implementPlanTargetFromState(host.stateStore.getState())?.itemId || !activeThreadId(host.stateStore.getState())) return; host.requestDefaultCollaborationModeForNextTurn(); host.stateStore.dispatch({ type: "ui/panel-set", panel: null }); diff --git a/src/features/chat/application/turns/slash-command-execution.ts b/src/features/chat/application/turns/slash-command-execution.ts index 13b5bc05..540b68df 100644 --- a/src/features/chat/application/turns/slash-command-execution.ts +++ b/src/features/chat/application/turns/slash-command-execution.ts @@ -66,7 +66,6 @@ export interface SlashCommandExecutionPorts { export interface SlashCommandExecutionContext extends SlashCommandExecutionPorts { activeThreadId: string | null; - activeThreadEphemeral: boolean; listedThreads: readonly Thread[]; referThread: (thread: Thread, message: string, inputSnapshot: ComposerInputSnapshot) => Promise; readWebUrl: (url: string, message: string, inputSnapshot: ComposerInputSnapshot, isCurrent?: () => boolean) => Promise; @@ -166,10 +165,6 @@ export async function executeSlashCommand( context.addSystemMessage("No active thread to fork."); return; } - if (context.activeThreadEphemeral) { - context.addSystemMessage("Side chats cannot be forked."); - return; - } await context.threadActions.forkThread(context.activeThreadId); return; case "btw": @@ -177,10 +172,6 @@ export async function executeSlashCommand( context.addSystemMessage("No active thread for a side chat."); return; } - if (context.activeThreadEphemeral) { - context.addSystemMessage("Side chats cannot be started from another side chat."); - return; - } if (!context.openSideChat) { context.addSystemMessage("Side chat is not available."); return; @@ -192,10 +183,6 @@ export async function executeSlashCommand( context.addSystemMessage("No active thread to roll back."); return; } - if (context.activeThreadEphemeral) { - context.addSystemMessage("Side chats cannot be rolled back."); - return; - } await context.threadActions.rollbackThread(context.activeThreadId); return; case "compact": @@ -246,10 +233,6 @@ export async function executeSlashCommand( case "permissions": { const requested = parsePermissionProfileOverride(args); if (requested !== undefined) { - if (context.activeThreadEphemeral) { - context.addSystemMessage("Permission changes are unavailable in side chats."); - return; - } const applied = await applyPermissionProfileOverride(context, requested); if (applied === false) return; context.addSystemMessage(permissionProfileOverrideMessage(requested)); diff --git a/src/features/chat/application/turns/slash-command-executor.ts b/src/features/chat/application/turns/slash-command-executor.ts index b70aedcb..05aef5b1 100644 --- a/src/features/chat/application/turns/slash-command-executor.ts +++ b/src/features/chat/application/turns/slash-command-executor.ts @@ -4,7 +4,8 @@ import { runtimeConfigOrDefault } from "../../../../domain/runtime/config"; import type { Thread } from "../../../../domain/threads/model"; import { resolveRuntimeControls } from "../../domain/runtime/resolution"; import type { ComposerInputSnapshot } from "../composer/input-snapshot"; -import { type SlashCommandName, slashCommandRequiresConnection } from "../composer/slash-commands"; +import { activePanelOperationForSlashCommand, type SlashCommandName, slashCommandRequiresConnection } from "../composer/slash-commands"; +import { activePanelOperationDecision } from "../panel-operation-policy"; import { runtimeSnapshotForChatState } from "../runtime/snapshot"; import type { ChatStateStore } from "../state/store"; import { @@ -31,15 +32,17 @@ export async function executeSlashCommandWithState( inputSnapshot?: ComposerInputSnapshot, isWebImportCurrent?: () => boolean, ): 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."); + const chatState = host.stateStore.getState(); + const operation = activePanelOperationForSlashCommand(command, args); + if (operation) { + const decision = activePanelOperationDecision(chatState, operation); + if (decision.kind === "blocked") throw new Error(decision.message); } + const state = submissionStateSnapshot(chatState); if (!host.connectionAvailable() && slashCommandRequiresConnection(command)) return; return runSlashCommand(command, args, { ...host, activeThreadId: state.activeThreadId, - activeThreadEphemeral: state.activeThreadEphemeral, listedThreads: state.listedThreads, referThread: host.referThread, readWebUrl: host.readWebUrl, diff --git a/src/features/chat/application/turns/submission-state.ts b/src/features/chat/application/turns/submission-state.ts index 5a1f22e9..1edba6d1 100644 --- a/src/features/chat/application/turns/submission-state.ts +++ b/src/features/chat/application/turns/submission-state.ts @@ -1,13 +1,11 @@ import type { Thread } from "../../../../domain/threads/model"; import type { ThreadStreamItem } from "../../domain/thread-stream/items"; -import { activeThreadState, type ChatState, panelThreadProvenance } from "../state/root-reducer"; +import { activeThreadState, type ChatState } from "../state/root-reducer"; import { threadStreamItems } from "../state/thread-stream"; import { activeTurnId, chatTurnBusy, type PendingTurnStart, pendingTurnStart } from "./turn-state"; export interface SubmissionStateSnapshot { activeThreadId: string | null; - activeThreadEphemeral: boolean; - activeThreadSubagent: boolean; activeTurnId: string | null; busy: boolean; listedThreads: readonly Thread[]; @@ -19,8 +17,6 @@ export function submissionStateSnapshot(state: ChatState): SubmissionStateSnapsh const activeThread = activeThreadState(state); return { activeThreadId: activeThread?.id ?? null, - activeThreadEphemeral: activeThread?.lifetime?.kind === "ephemeral", - activeThreadSubagent: panelThreadProvenance(state)?.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 21eea1da..5bd8eccf 100644 --- a/src/features/chat/application/turns/turn-submission-actions.ts +++ b/src/features/chat/application/turns/turn-submission-actions.ts @@ -2,6 +2,7 @@ import { type CodexInput, codexTextInput } from "../../../../domain/chat/input"; import type { ReferencedThreadMetadata } from "../../../../domain/threads/reference"; import type { ComposerInputSnapshot } from "../composer/input-snapshot"; import type { LocalIdSource } from "../local-id-source"; +import { activePanelOperationDecision } from "../panel-operation-policy"; import { pendingSubmissionMatches } from "../state/pending-submission"; import type { ChatStateStore } from "../state/store"; import { @@ -86,6 +87,12 @@ async function sendTurnText( if (!(await host.ensureRestoredThreadLoaded())) return false; if (!pendingRequestIsCurrent(host, request)) return false; + const operationDecision = activePanelOperationDecision(host.stateStore.getState(), "submit"); + if (operationDecision.kind === "blocked") { + host.addSystemMessage(operationDecision.message); + return false; + } + const initialState = submissionStateSnapshot(host.stateStore.getState()); const plan = planTurnSubmission(initialState); @@ -204,9 +211,6 @@ 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 a8451357..6ce64f06 100644 --- a/src/features/chat/host/bundles/shell-bundle.ts +++ b/src/features/chat/host/bundles/shell-bundle.ts @@ -1,6 +1,7 @@ import type { ConnectionManager } from "../../../../app-server/connection/connection-manager"; +import { activePanelOperationDecision } from "../../application/panel-operation-policy"; import type { PendingRequestActions } from "../../application/pending-requests/pending-request-actions"; -import { activeThreadId, panelThreadProvenance } from "../../application/state/root-reducer"; +import { activeThreadId } from "../../application/state/root-reducer"; import type { ChatStateStore } from "../../application/state/store"; import type { HistoryController } from "../../application/threads/history-controller"; import type { ThreadRenameEditorActions } from "../../application/threads/rename-editor-actions"; @@ -69,12 +70,16 @@ export function createShellBundle(host: ChatPanelShellBundleHost, input: ChatPan rename, navigation, openSideChat: () => { - const threadId = activeThreadId(stateStore.getState()); + const state = stateStore.getState(); + if (activePanelOperationDecision(state, "start-side-chat").kind !== "allowed") return; + const threadId = activeThreadId(state); if (!threadId) return; - const thread = stateStore.getState().threadList.listedThreads.find((item) => item.id === threadId); + const thread = state.threadList.listedThreads.find((item) => item.id === threadId); void environment.plugin.workspace.openSideChat(threadId, thread?.name ?? thread?.preview ?? null); }, - activeThreadChatActionsDisabled: () => panelThreadProvenance(stateStore.getState())?.kind === "subagent", + canStartSideChat: () => activePanelOperationDecision(stateStore.getState(), "start-side-chat").kind === "allowed", + canCompact: () => activePanelOperationDecision(stateStore.getState(), "compact").kind === "allowed", + canMutateGoal: () => activePanelOperationDecision(stateStore.getState(), "goal-mutation").kind !== "blocked", }); const toolbarSurface: ChatPanelToolbarSurface = { connection: { diff --git a/src/features/chat/host/bundles/thread-bundle.ts b/src/features/chat/host/bundles/thread-bundle.ts index 230f1284..5987cba9 100644 --- a/src/features/chat/host/bundles/thread-bundle.ts +++ b/src/features/chat/host/bundles/thread-bundle.ts @@ -211,11 +211,16 @@ export function createThreadLifecycleBundle( ): ChatPanelThreadLifecycleBundle { const { appServer, localItemIds, ensureConnected, status, threadStart, foundation, refreshLiveState, notifyActiveThreadIdentityChanged } = input; + let sessionLifecycle: ChatPanelThreadLifecycle | null = null; const goals = createGoalActions({ stateStore: host.stateStore, goalTransport: appServer.threadGoal, localItemIds, startThread: (preview, options) => threadStart.startThread(preview, options), + ensureRestoredThreadLoaded: async () => { + if (!sessionLifecycle) return false; + return sessionLifecycle.restoration.ensureLoaded((threadId) => sessionLifecycle?.resume.resumeThread(threadId) ?? Promise.resolve()); + }, addSystemMessage: (text) => { status.addSystemMessage(text); }, @@ -243,6 +248,7 @@ export function createThreadLifecycleBundle( refreshLiveState, notifyActiveThreadIdentityChanged, }); + sessionLifecycle = lifecycle; const { identity, restoration, resume } = lifecycle; return { diff --git a/src/features/chat/panel/composer-controller.ts b/src/features/chat/panel/composer-controller.ts index 6d75a4ec..fab7cdf5 100644 --- a/src/features/chat/panel/composer-controller.ts +++ b/src/features/chat/panel/composer-controller.ts @@ -15,6 +15,7 @@ import { } from "../application/composer/context-references"; import type { ComposerInputSnapshot } from "../application/composer/input-snapshot"; import type { NoteCandidateProvider } from "../application/composer/note-context"; +import { activePanelOperationForSlashCommandSuggestion } from "../application/composer/slash-commands"; import { activeComposerSuggestions, applyComposerSuggestionInsertion, @@ -27,7 +28,8 @@ import { type PreparedComposerInput, preparedUserInputWithWikiLinkMentionsSkillsAndContext, } from "../application/composer/wikilink-context"; -import { activeThreadState, type ChatAction, type ChatState, panelThreadProvenance } from "../application/state/root-reducer"; +import { activePanelOperationDecision } from "../application/panel-operation-policy"; +import { activeThreadState, type ChatAction, type ChatState } from "../application/state/root-reducer"; import type { ChatStateStore } from "../application/state/store"; import type { ComposerCallbacks, ComposerPendingSelection, ComposerShellProps } from "../ui/composer"; import { syncComposerHeight } from "../ui/composer.dom"; @@ -107,7 +109,7 @@ export class ChatComposerController { draft: model.draft, busy: model.turnBusy, canInterrupt: this.options.canInterrupt(model), - submissionDisabled: model.activeThreadSubagent || model.webSubmissionPending, + submissionDisabled: model.submissionBlockedByPanelPolicy || model.webSubmissionPending, webSubmissionCancellable: model.webSubmissionCancellable, normalPlaceholder: projection.placeholder, suggestions: model.suggestions, @@ -287,7 +289,6 @@ export class ChatComposerController { } private activeSuggestions(beforeCursor: string, state: ChatState): readonly ComposerSuggestion[] { - const activeThread = activeThreadState(state); return activeComposerSuggestions( beforeCursor, this.noteCandidates(), @@ -296,9 +297,12 @@ export class ChatComposerController { state.connection.availableModels, this.options.currentModelForSuggestions(), { - activeThreadId: activeThread?.id ?? null, - activeThreadEphemeral: activeThread?.lifetime?.kind === "ephemeral", - activeThreadSubagent: panelThreadProvenance(state)?.kind === "subagent", + activeThreadId: activeThreadState(state)?.id ?? null, + slashCommandAvailable: (command) => { + if (activePanelOperationDecision(state, "submit").kind === "blocked") return false; + const operation = activePanelOperationForSlashCommandSuggestion(command); + return !operation || activePanelOperationDecision(state, operation).kind === "allowed"; + }, contextReferences: this.contextReferences(), dailyNoteReferences: () => this.options.noteCandidateProvider.dailyNoteReferences(this.options.sourcePath()), permissionProfiles: state.connection.availablePermissionProfiles, diff --git a/src/features/chat/panel/shell-selectors.ts b/src/features/chat/panel/shell-selectors.ts index 78fe0fc6..b2e5bf98 100644 --- a/src/features/chat/panel/shell-selectors.ts +++ b/src/features/chat/panel/shell-selectors.ts @@ -1,4 +1,5 @@ import { explicitThreadName } from "../../../domain/threads/model"; +import { activePanelOperationDecision } from "../application/panel-operation-policy"; import { threadStreamItemsHaveThreadTurns } from "../application/runtime/snapshot"; import { activeThreadState, type ChatActiveThreadState, type ChatState, panelThreadProvenance } from "../application/state/root-reducer"; import { threadStreamItems } from "../application/state/thread-stream"; @@ -10,6 +11,9 @@ export interface ChatPanelToolbarModel { readonly threads: ChatState["threadList"]["listedThreads"]; readonly activeThreadId: string | null; readonly activeThreadSubagent: boolean; + readonly sideChatStartDisabled: boolean; + readonly compactDisabled: boolean; + readonly goalMutationDisabled: boolean; readonly activeThreadTokenUsage: ChatActiveThreadState["tokenUsage"]; readonly turnBusy: boolean; readonly connection: ChatState["connection"]; @@ -21,6 +25,7 @@ export interface ChatPanelToolbarModel { export interface ChatPanelGoalModel { readonly goal: ChatActiveThreadState["goal"]; + readonly goalMutationsAllowed: boolean; readonly goalEditor: ChatState["ui"]["goalEditor"]; readonly goalObjectiveExpanded: ChatState["ui"]["disclosures"]["goalObjectiveExpanded"]; } @@ -28,8 +33,9 @@ export interface ChatPanelGoalModel { export interface ChatPanelThreadStreamModel { readonly activeThreadId: string | null; readonly activeThreadCwd: ChatActiveThreadState["cwd"]; - readonly activeThreadLifetime: ChatActiveThreadState["lifetime"]; - readonly activeThreadProvenance: ChatActiveThreadState["provenance"]; + readonly forkAllowed: boolean; + readonly rollbackAllowed: boolean; + readonly planImplementationAllowed: boolean; readonly turn: ChatState["turn"]; readonly runtimeCollaborationMode: ChatState["runtime"]["pending"]["collaborationMode"]; readonly threadStream: ChatState["threadStream"]; @@ -57,6 +63,7 @@ export interface ChatPanelComposerModel { readonly activeThreadId: string | null; readonly activeThreadTokenUsage: ChatActiveThreadState["tokenUsage"]; readonly activeThreadSubagent: boolean; + readonly submissionBlockedByPanelPolicy: boolean; readonly webSubmissionPending: boolean; readonly webSubmissionCancellable: boolean; readonly turnBusy: boolean; @@ -71,6 +78,9 @@ export function selectChatPanelToolbar(state: ChatState): ChatPanelToolbarModel threads: state.threadList.listedThreads, activeThreadId: activeThread?.id ?? null, activeThreadSubagent: panelThreadProvenance(state)?.kind === "subagent", + sideChatStartDisabled: activePanelOperationDecision(state, "start-side-chat").kind !== "allowed", + compactDisabled: activePanelOperationDecision(state, "compact").kind !== "allowed", + goalMutationDisabled: activePanelOperationDecision(state, "goal-mutation").kind === "blocked", activeThreadTokenUsage: activeThread?.tokenUsage ?? null, turnBusy: chatTurnBusy(state), connection: state.connection, @@ -84,6 +94,7 @@ export function selectChatPanelToolbar(state: ChatState): ChatPanelToolbarModel export function selectChatPanelGoal(state: ChatState): ChatPanelGoalModel { return { goal: activeThreadState(state)?.goal ?? null, + goalMutationsAllowed: activePanelOperationDecision(state, "goal-mutation").kind === "allowed", goalEditor: state.ui.goalEditor, goalObjectiveExpanded: state.ui.disclosures.goalObjectiveExpanded, }; @@ -94,8 +105,9 @@ export function selectChatPanelThreadStream(state: ChatState): ChatPanelThreadSt return { activeThreadId: activeThread?.id ?? null, activeThreadCwd: activeThread?.cwd ?? null, - activeThreadLifetime: activeThread?.lifetime ?? null, - activeThreadProvenance: panelThreadProvenance(state), + forkAllowed: activePanelOperationDecision(state, "fork").kind === "allowed", + rollbackAllowed: activePanelOperationDecision(state, "rollback").kind === "allowed", + planImplementationAllowed: activePanelOperationDecision(state, "implement-plan").kind === "allowed", turn: state.turn, runtimeCollaborationMode: state.runtime.pending.collaborationMode, threadStream: state.threadStream, @@ -128,6 +140,7 @@ export function selectChatPanelComposer(state: ChatState): ChatPanelComposerMode activeThreadId, activeThreadTokenUsage: activeThread?.tokenUsage ?? null, activeThreadSubagent: panelThreadProvenance(state)?.kind === "subagent", + submissionBlockedByPanelPolicy: activePanelOperationDecision(state, "submit").kind === "blocked", webSubmissionPending: state.pendingSubmission !== null, webSubmissionCancellable: state.pendingSubmission?.phase === "cancellable", turnBusy: chatTurnBusy(state), diff --git a/src/features/chat/panel/surface/goal-projection.tsx b/src/features/chat/panel/surface/goal-projection.tsx index 87499b19..1de64729 100644 --- a/src/features/chat/panel/surface/goal-projection.tsx +++ b/src/features/chat/panel/surface/goal-projection.tsx @@ -101,6 +101,7 @@ function chatPanelGoalViewModel( }, options: { sendShortcut: surface.sendShortcut(), + readOnly: !model.goalMutationsAllowed, }, editor: projection.editor, display: projection.display, diff --git a/src/features/chat/panel/surface/thread-stream-projection.ts b/src/features/chat/panel/surface/thread-stream-projection.ts index 2c1e7b67..a2ce3374 100644 --- a/src/features/chat/panel/surface/thread-stream-projection.ts +++ b/src/features/chat/panel/surface/thread-stream-projection.ts @@ -148,12 +148,11 @@ function threadStreamStateProjection( }; const workspaceRoot = model.activeThreadCwd ?? context.vaultPath; const turnBusy = chatTurnBusy(model.turn); - const actionCandidatesAllowed = - !turnBusy && model.activeThreadLifetime?.kind !== "ephemeral" && model.activeThreadProvenance?.kind !== "subagent"; - const rollbackCandidate = actionCandidatesAllowed ? threadStreamRollbackCandidateFromItems(canonicalItems) : null; - const forkCandidates = actionCandidatesAllowed ? forkCandidatesFromItems(canonicalItems) : []; + const rollbackCandidate = !turnBusy && model.rollbackAllowed ? threadStreamRollbackCandidateFromItems(canonicalItems) : null; + const forkCandidates = !turnBusy && model.forkAllowed ? forkCandidatesFromItems(canonicalItems) : []; const planTarget = implementPlanTarget({ - activeThread: model.activeThreadId ? { id: model.activeThreadId, provenance: model.activeThreadProvenance } : null, + activeThread: model.activeThreadId ? { id: model.activeThreadId } : null, + modeAllowed: model.planImplementationAllowed, turn: model.turn, runtime: { pending: { collaborationMode: model.runtimeCollaborationMode } }, threadStream: model.threadStream, diff --git a/src/features/chat/panel/surface/toolbar-projection.tsx b/src/features/chat/panel/surface/toolbar-projection.tsx index 271578a5..f1bc0540 100644 --- a/src/features/chat/panel/surface/toolbar-projection.tsx +++ b/src/features/chat/panel/surface/toolbar-projection.tsx @@ -43,7 +43,9 @@ interface ToolbarViewModelInput { interface ToolbarStateProjection { newChatDisabled: boolean; - activeThreadChatActionsDisabled: boolean; + sideChatStartDisabled: boolean; + compactDisabled: boolean; + goalMutationDisabled: boolean; chatActionsOpen: boolean; historyOpen: boolean; statusPanelOpen: boolean; @@ -97,7 +99,9 @@ function chatPanelToolbarProjection(input: ToolbarViewModelInput): ToolbarViewMo }); return { newChatDisabled: projection.newChatDisabled, - activeThreadChatActionsDisabled: projection.activeThreadChatActionsDisabled, + sideChatStartDisabled: projection.sideChatStartDisabled, + compactDisabled: projection.compactDisabled, + goalMutationDisabled: projection.goalMutationDisabled, chatActionsOpen: projection.chatActionsOpen, historyOpen: projection.historyOpen, statusPanelOpen: projection.statusPanelOpen, @@ -158,7 +162,9 @@ function toolbarStateProjection(input: { const statusPanelOpen = toolbarPanel === "status-panel"; return { newChatDisabled: input.turnBusy && !input.model.activeThreadSubagent, - activeThreadChatActionsDisabled: input.model.activeThreadSubagent, + sideChatStartDisabled: input.model.sideChatStartDisabled, + compactDisabled: input.model.compactDisabled, + goalMutationDisabled: input.model.goalMutationDisabled, chatActionsOpen, historyOpen, statusPanelOpen, diff --git a/src/features/chat/panel/toolbar-actions.ts b/src/features/chat/panel/toolbar-actions.ts index 44781c57..0e5c82ce 100644 --- a/src/features/chat/panel/toolbar-actions.ts +++ b/src/features/chat/panel/toolbar-actions.ts @@ -34,7 +34,9 @@ export interface ToolbarUiActionDependencies { rename: ThreadRenameEditorActions; navigation: ThreadNavigationActions; openSideChat?: () => void; - activeThreadChatActionsDisabled: () => boolean; + canStartSideChat: () => boolean; + canCompact: () => boolean; + canMutateGoal: () => boolean; } export interface ToolbarOutsidePointerHit { @@ -139,17 +141,17 @@ export function createToolbarUiActions(deps: ToolbarUiActionDependencies): Toolb ...(deps.openSideChat ? { startSideChat: () => { - if (deps.activeThreadChatActionsDisabled()) return; + if (!deps.canStartSideChat()) return; deps.openSideChat?.(); }, } : {}), compactContext: () => { - if (deps.activeThreadChatActionsDisabled()) return; + if (!deps.canCompact()) return; void deps.threadActions.compactActiveThread(); }, setGoal: () => { - if (deps.activeThreadChatActionsDisabled()) return; + if (!deps.canMutateGoal()) return; deps.goals.startEditingCurrent(); }, }, diff --git a/src/features/chat/ui/goal.tsx b/src/features/chat/ui/goal.tsx index 365bd14f..edad76e4 100644 --- a/src/features/chat/ui/goal.tsx +++ b/src/features/chat/ui/goal.tsx @@ -24,6 +24,7 @@ export interface GoalPanelActions { export interface GoalPanelOptions { sendShortcut: SendShortcut; + readOnly?: boolean; } export interface GoalPanelEditorState { @@ -58,7 +59,7 @@ export function GoalPanel({ const resetObjective = goal?.objective ?? ""; const resetStatus = goal?.status ?? null; const resetTokenBudget = goal?.tokenBudget ?? null; - const editing = editor.editing; + const editing = editor.editing && !options.readOnly; const objective = editor.objectiveDraft; const tokenBudget = editor.tokenBudgetDraft; const objectiveExpanded = display.objectiveExpanded; @@ -114,7 +115,7 @@ export function GoalPanel({
Goal
- {goal && !editing ? ( + {goal && !editing && !options.readOnly ? ( ) : null} - {goal && !terminal && !editing && goal.status === "active" ? ( + {goal && !terminal && !editing && !options.readOnly && goal.status === "active" ? ( ) : null} - {goal && !terminal && !editing && goal.status === "paused" ? ( + {goal && !terminal && !editing && !options.readOnly && goal.status === "paused" ? ( ) : null} - {goal && !editing ? ( + {goal && !editing && !options.readOnly ? ( !thread.selected)} + disabled={model.sideChatStartDisabled || model.threads.every((thread) => !thread.selected)} />
); diff --git a/tests/features/chat/application/composer/slash-commands.test.ts b/tests/features/chat/application/composer/slash-commands.test.ts index 61bd9bab..0c960469 100644 --- a/tests/features/chat/application/composer/slash-commands.test.ts +++ b/tests/features/chat/application/composer/slash-commands.test.ts @@ -38,6 +38,6 @@ describe("slash command catalog", () => { SLASH_COMMANDS.filter((definition) => !slashCommandAvailableInSideChat(definition.command.slice(1) as SlashCommandName)).map( (item) => item.command, ), - ).toEqual(["/fork", "/btw", "/rollback"]); + ).toEqual(["/fork", "/btw", "/rollback", "/auto-review", "/fast", "/plan", "/goal"]); }); }); diff --git a/tests/features/chat/application/composer/suggestions.test.ts b/tests/features/chat/application/composer/suggestions.test.ts index 0727af3d..747620da 100644 --- a/tests/features/chat/application/composer/suggestions.test.ts +++ b/tests/features/chat/application/composer/suggestions.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from "vitest"; import type { ModelMetadata, ReasoningEffort, SkillMetadata } from "../../../../../src/domain/catalog/metadata"; import type { Thread } from "../../../../../src/domain/threads/model"; import { emptyComposerContextReferences } from "../../../../../src/features/chat/application/composer/context-references"; +import { slashCommandAvailableInSideChat } from "../../../../../src/features/chat/application/composer/slash-commands"; import { activeComposerSuggestions, applyComposerSuggestionInsertion, @@ -282,17 +283,17 @@ describe("composer suggestions", () => { }); it("omits unavailable thread mutations from side-chat slash suggestions", () => { - const options = { activeThreadEphemeral: true }; + const options = { slashCommandAvailable: slashCommandAvailableInSideChat }; expect(suggestionReplacements(activeComposerSuggestions("/f", notes, [], [], [], null, options))).not.toContain("/fork"); expect(suggestionReplacements(activeComposerSuggestions("/r", notes, [], [], [], null, options))).not.toContain("/rollback"); expect(suggestionReplacements(activeComposerSuggestions("/b", notes, [], [], [], null, options))).not.toContain("/btw"); expect(suggestionReplacements(activeComposerSuggestions("/c", notes, [], [], [], null, options))).toContain("/compact"); - expect(suggestionReplacements(activeComposerSuggestions("/g", notes, [], [], [], null, options))).toContain("/goal"); + expect(suggestionReplacements(activeComposerSuggestions("/g", notes, [], [], [], null, options))).not.toContain("/goal"); }); it("omits slash suggestions from subagent threads", () => { - const options = { activeThreadSubagent: true }; + const options = { slashCommandAvailable: () => false }; expect(activeComposerSuggestions("/", notes, [], [], [], null, options)).toEqual([]); expect(activeComposerSuggestions("/permissions ", notes, [], [], [], null, options)).toEqual([]); expect(activeComposerSuggestions("/resume ", notes, [], [], [], null, options)).toEqual([]); diff --git a/tests/features/chat/application/panel-operation-policy.test.ts b/tests/features/chat/application/panel-operation-policy.test.ts new file mode 100644 index 00000000..db125739 --- /dev/null +++ b/tests/features/chat/application/panel-operation-policy.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from "vitest"; +import { + type ActivePanelOperation, + type ActivePanelThreadFacts, + activePanelOperationDecisionForFacts, +} from "../../../../src/features/chat/application/panel-operation-policy"; + +const operations: readonly ActivePanelOperation[] = [ + "submit", + "start-side-chat", + "compact", + "fork", + "rollback", + "thread-settings", + "permission-settings", + "goal-read", + "goal-mutation", + "implement-plan", +]; + +describe("activePanelOperationDecisionForFacts", () => { + it("allows every mode-derived operation in an interactive persistent panel", () => { + expectDecisionKinds({ phase: "active", lifetime: "persistent", provenance: "interactive" }, allAllowed()); + }); + + it("keeps side-chat submission and compaction available while blocking persistent-thread mutations", () => { + expectDecisionKinds( + { phase: "active", lifetime: "ephemeral", provenance: "interactive" }, + decisions({ submit: "allowed", compact: "allowed" }), + ); + }); + + it("keeps subagent panels read-only", () => { + expectDecisionKinds({ phase: "active", lifetime: "persistent", provenance: "subagent" }, decisions({ "goal-read": "allowed" })); + }); + + it("keeps the stricter subagent restrictions when mode facts conflict", () => { + expectDecisionKinds({ phase: "active", lifetime: "ephemeral", provenance: "subagent" }, decisions({})); + }); + + it("requires restoration before a persistent goal mutation", () => { + expect(activePanelOperationDecisionForFacts({ phase: "awaiting-resume", provenance: "interactive" }, "goal-mutation")).toEqual({ + kind: "resume-required", + }); + }); + + it("keeps restored subagent goals read-only before loading", () => { + expect(activePanelOperationDecisionForFacts({ phase: "awaiting-resume", provenance: "subagent" }, "goal-mutation")).toEqual({ + kind: "blocked", + message: "Goals are read-only in agent threads.", + }); + }); + + it("allows empty panels to start a goal-backed thread", () => { + expectDecisionKinds({ phase: "empty" }, allAllowed()); + }); +}); + +function allAllowed(): Record { + return Object.fromEntries(operations.map((operation) => [operation, "allowed"])) as Record; +} + +function decisions(allowed: Partial>): Record { + return Object.fromEntries(operations.map((operation) => [operation, allowed[operation] ?? "blocked"])) as Record< + ActivePanelOperation, + "allowed" | "blocked" + >; +} + +function expectDecisionKinds(facts: ActivePanelThreadFacts, expected: Record): void { + for (const operation of operations) { + expect(activePanelOperationDecisionForFacts(facts, operation).kind).toBe(expected[operation]); + } +} diff --git a/tests/features/chat/application/runtime/settings-actions.test.ts b/tests/features/chat/application/runtime/settings-actions.test.ts index 2d251147..2628d48f 100644 --- a/tests/features/chat/application/runtime/settings-actions.test.ts +++ b/tests/features/chat/application/runtime/settings-actions.test.ts @@ -114,6 +114,26 @@ describe("createChatRuntimeSettingsActions", () => { expect(messages).toEqual(["Permission changes are unavailable in side chats.", "Permission changes are unavailable in side chats."]); }); + it("keeps an empty settings commit harmless in a side chat while blocking a real settings mutation", async () => { + let state = chatStateFixture(); + state = chatStateWith(state, { + activeThread: { + id: "side", + lifetime: { kind: "ephemeral", sourceThreadId: "source", sourceThreadTitle: "Source" }, + }, + }); + const store = createChatStateStore(state); + const transport = settingsTransportFixture(); + const messages: string[] = []; + const actions = runtimeActionsFixture(store, transport, messages); + + await expect(actions.applyPendingThreadSettings()).resolves.toBe(true); + await expect(actions.requestModel("gpt-5.5")).resolves.toBe(false); + + expect(transport.updateThreadSettings).not.toHaveBeenCalled(); + expect(messages).toEqual(["Thread settings are unavailable in side chats."]); + }); + it("blocks all thread setting changes for subagent threads", async () => { let state = chatStateFixture(); state = chatStateWith(state, { diff --git a/tests/features/chat/application/threads/goal-actions.test.ts b/tests/features/chat/application/threads/goal-actions.test.ts index 713afa81..bb03bd86 100644 --- a/tests/features/chat/application/threads/goal-actions.test.ts +++ b/tests/features/chat/application/threads/goal-actions.test.ts @@ -93,6 +93,35 @@ describe("createGoalActions", () => { expect(activeThreadState(stateStore.getState())?.goal).toBeNull(); }); + it("blocks goal mutations in side chats before calling app-server", async () => { + let state = chatStateFixture(); + state = chatStateWith(state, { + activeThread: { + id: "side", + lifetime: { kind: "ephemeral", sourceThreadId: "source", sourceThreadTitle: "Source" }, + }, + }); + const stateStore = createChatStateStore(state); + const goalTransport = goalTransportFixture(); + const addSystemMessage = vi.fn(); + const actions = createGoalActions({ + stateStore, + goalTransport, + localItemIds: createLocalIdSource({ nowMs: () => 1, seed: "goal" }), + startThread: vi.fn().mockResolvedValue({ threadId: "side" }), + addSystemMessage, + addGoalEvent: vi.fn(), + refreshLiveState: vi.fn(), + }); + + await expect(actions.setObjective("side", "Ship", null)).resolves.toBe(false); + await expect(actions.clear("side")).resolves.toBe(false); + + expect(goalTransport.setThreadGoal).not.toHaveBeenCalled(); + expect(goalTransport.clearThreadGoal).not.toHaveBeenCalled(); + expect(addSystemMessage).toHaveBeenCalledWith("Goals are unavailable in side chats."); + }); + it("does not report stale goal action failures after the active thread changes", async () => { let state = chatStateFixture(); state = chatStateWith(state, { activeThread: { id: "thread" } }); @@ -147,6 +176,29 @@ describe("createGoalActions", () => { expect(addSystemMessage).not.toHaveBeenCalled(); }); + it("does not clear a stale goal when the active thread changes during the policy guard", async () => { + let state = chatStateFixture(); + state = chatStateWith(state, { activeThread: { id: "thread" } }); + state = chatStateWith(state, { activeThread: { goal: goal() } }); + const stateStore = createChatStateStore(state); + const goalTransport = goalTransportFixture(); + const actions = createGoalActions({ + stateStore, + goalTransport, + localItemIds: createLocalIdSource({ nowMs: () => 1, seed: "goal" }), + startThread: vi.fn().mockResolvedValue({ threadId: "thread" }), + addSystemMessage: vi.fn(), + addGoalEvent: vi.fn(), + refreshLiveState: vi.fn(), + }); + + const pending = actions.clear("thread"); + stateStore.dispatch({ type: "active-thread/cleared" }); + + await expect(pending).resolves.toBe(false); + expect(goalTransport.clearThreadGoal).not.toHaveBeenCalled(); + }); + it("reports goal creation as a structured goal event", async () => { let state = chatStateFixture(); state = chatStateWith(state, { activeThread: { id: "thread" } }); @@ -227,6 +279,86 @@ describe("createGoalActions", () => { expect(goalTransport.recordThreadGoalUserMessage).toHaveBeenCalledWith("thread-new", "Plan release"); }); + it("does not start a goal thread when the empty panel changes during connection", async () => { + const stateStore = createChatStateStore(chatStateFixture()); + const connection = deferred(); + const goalTransport = goalTransportFixture({ ensureConnected: vi.fn(() => connection.promise) }); + const startThread = vi.fn().mockResolvedValue({ threadId: "thread-new" }); + const actions = createGoalActions({ + stateStore, + goalTransport, + localItemIds: createLocalIdSource({ nowMs: () => 1, seed: "goal" }), + startThread, + addSystemMessage: vi.fn(), + addGoalEvent: vi.fn(), + refreshLiveState: vi.fn(), + }); + + const pending = actions.saveObjective("Plan release", null); + await vi.waitFor(() => expect(goalTransport.ensureConnected).toHaveBeenCalledOnce()); + stateStore.dispatch({ type: "panel/restored-thread-applied", threadId: "restored", fallbackTitle: "Restored" }); + connection.resolve(true); + + await expect(pending).resolves.toBe(false); + expect(startThread).not.toHaveBeenCalled(); + expect(goalTransport.setThreadGoal).not.toHaveBeenCalled(); + }); + + it("loads an awaiting restored thread before saving its goal", async () => { + const stateStore = createChatStateStore(chatStateFixture()); + stateStore.dispatch({ type: "panel/restored-thread-applied", threadId: "restored", fallbackTitle: "Restored" }); + const goalTransport = goalTransportFixture({ + setThreadGoal: vi.fn().mockResolvedValue(goal({ threadId: "restored", objective: "Resume work" })), + }); + const startThread = vi.fn().mockResolvedValue({ threadId: "new-thread" }); + const ensureRestoredThreadLoaded = vi.fn(async () => { + stateStore.dispatch({ + type: "active-thread/resumed", + approvalPolicyKnown: true, + sandboxPolicyKnown: true, + permissionProfileKnown: true, + approvalPolicy: null, + sandboxPolicy: null, + activePermissionProfile: null, + thread: { + id: "restored", + preview: "Restored", + createdAt: 1, + updatedAt: 1, + name: null, + archived: false, + provenance: { kind: "interactive" }, + }, + cwd: "/vault", + model: null, + reasoningEffort: null, + serviceTier: null, + approvalsReviewer: null, + }); + return true; + }); + const actions = createGoalActions({ + stateStore, + goalTransport, + localItemIds: createLocalIdSource({ nowMs: () => 1, seed: "goal" }), + startThread, + ensureRestoredThreadLoaded, + addSystemMessage: vi.fn(), + addGoalEvent: vi.fn(), + refreshLiveState: vi.fn(), + }); + + await expect(actions.saveObjective("Resume work", null)).resolves.toBe(true); + + expect(ensureRestoredThreadLoaded).toHaveBeenCalledOnce(); + expect(startThread).not.toHaveBeenCalled(); + expect(goalTransport.setThreadGoal).toHaveBeenCalledWith("restored", { + objective: "Resume work", + status: "active", + tokenBudget: null, + }); + }); + it("rejects empty goal objective saves before connecting or starting a thread", async () => { const stateStore = createChatStateStore(chatStateFixture()); const goalTransport = goalTransportFixture(); diff --git a/tests/features/chat/application/threads/thread-management-actions.test.ts b/tests/features/chat/application/threads/thread-management-actions.test.ts index c0b13838..d4468342 100644 --- a/tests/features/chat/application/threads/thread-management-actions.test.ts +++ b/tests/features/chat/application/threads/thread-management-actions.test.ts @@ -54,6 +54,20 @@ type ThreadManagementActionsHostMock = Omit< }; describe("thread management actions", () => { + it("allows direct compaction of an active side chat", async () => { + const host = hostMock({ + items: [], + activeThread: { + id: "side-thread", + lifetime: { kind: "ephemeral", sourceThreadId: "source", sourceThreadTitle: "Source" }, + }, + }); + + await threadManagementActions(host).compactThread("side-thread"); + + expect(host.threadTransport.compactThread).toHaveBeenCalledWith("side-thread"); + }); + it("does not fork an ephemeral side chat", async () => { const host = hostMock({ items: [], diff --git a/tests/features/chat/application/turns/composer-submit-actions.test.ts b/tests/features/chat/application/turns/composer-submit-actions.test.ts index 0ea82e6a..4c0f7a37 100644 --- a/tests/features/chat/application/turns/composer-submit-actions.test.ts +++ b/tests/features/chat/application/turns/composer-submit-actions.test.ts @@ -120,9 +120,7 @@ describe("submitComposer", () => { expect(execute).not.toHaveBeenCalled(); expect(sendTurnText).not.toHaveBeenCalled(); - expect(host.status.addSystemMessage).toHaveBeenCalledWith( - "Messages and slash commands are unavailable in agent threads. Start a new chat to continue.", - ); + expect(host.status.addSystemMessage).toHaveBeenCalledWith("Messages are unavailable in agent threads. Start a new chat to continue."); }); it("sends plain drafts as turn text", async () => { diff --git a/tests/features/chat/application/turns/plan-implementation.test.ts b/tests/features/chat/application/turns/plan-implementation.test.ts index e53a086b..0af07da2 100644 --- a/tests/features/chat/application/turns/plan-implementation.test.ts +++ b/tests/features/chat/application/turns/plan-implementation.test.ts @@ -27,7 +27,13 @@ const streamingPlanItem = (id: string): ThreadStreamItem => ({ dialogueState: "streaming", }); -function resumeThread(stateStore: ChatStateStore, items: readonly ThreadStreamItem[]): void { +function resumeThread( + stateStore: ChatStateStore, + items: readonly ThreadStreamItem[], + lifetime: { kind: "persistent" } | { kind: "ephemeral"; sourceThreadId: string; sourceThreadTitle: string | null } = { + kind: "persistent", + }, +): void { stateStore.dispatch({ type: "active-thread/resumed", approvalPolicyKnown: true, @@ -43,6 +49,7 @@ function resumeThread(stateStore: ChatStateStore, items: readonly ThreadStreamIt serviceTier: null, approvalsReviewer: null, items, + lifetime, }); stateStore.dispatch({ type: "runtime/requested-collaboration-mode-set", collaborationMode: "plan" }); } @@ -99,6 +106,19 @@ describe("implementPlan", () => { expect(implementPlanTargetFromState(stateStore.getState())).toEqual({ itemId: completed.id }); }); + it("does not offer or send plan implementation from a side chat", async () => { + const { host, ensureConnected, sendTurnText, stateStore } = createPlanImplementationHost(); + const plan = planItem("plan"); + resumeThread(stateStore, [plan], { kind: "ephemeral", sourceThreadId: "source", sourceThreadTitle: "Source" }); + + expect(implementPlanTargetFromState(stateStore.getState())).toBeNull(); + + await implementPlan(host, plan.id); + + expect(ensureConnected).not.toHaveBeenCalled(); + expect(sendTurnText).not.toHaveBeenCalled(); + }); + it("switches out of plan mode and submits the implementation prompt", async () => { const { host, ensureConnected, requestDefaultCollaborationModeForNextTurn, sendTurnText, stateStore } = createPlanImplementationHost(); const plan = planItem("plan"); diff --git a/tests/features/chat/application/turns/slash-command-execution.test.ts b/tests/features/chat/application/turns/slash-command-execution.test.ts index ff26971d..9c1377ff 100644 --- a/tests/features/chat/application/turns/slash-command-execution.test.ts +++ b/tests/features/chat/application/turns/slash-command-execution.test.ts @@ -15,7 +15,6 @@ import { function context(overrides: Partial = {}): SlashCommandExecutionContext { return { activeThreadId: "thread-1", - activeThreadEphemeral: false, listedThreads: [thread({ id: "thread-1", name: "Current" })], startNewThread: vi.fn().mockResolvedValue(undefined), startThreadForGoal: vi.fn().mockResolvedValue("thread-new"), @@ -299,15 +298,6 @@ describe("slash commands", () => { expect(ctx.threadActions.forkThread).toHaveBeenCalledWith("active-thread"); }); - it("does not fork a side chat", async () => { - const ctx = context({ activeThreadId: "side-thread", activeThreadEphemeral: true }); - - await executeSlashCommand("fork", "", ctx); - - expect(ctx.threadActions.forkThread).not.toHaveBeenCalled(); - expect(ctx.addSystemMessage).toHaveBeenCalledWith("Side chats cannot be forked."); - }); - it("opens a side chat from the active thread", async () => { const openSideChat = vi.fn().mockResolvedValue(undefined); const ctx = context({ activeThreadId: "active-thread", openSideChat }); @@ -317,16 +307,6 @@ describe("slash commands", () => { expect(openSideChat).toHaveBeenCalledWith("active-thread"); }); - it("does not open a nested side chat", async () => { - const openSideChat = vi.fn().mockResolvedValue(undefined); - const ctx = context({ activeThreadId: "side-thread", activeThreadEphemeral: true, openSideChat }); - - await executeSlashCommand("btw", "", ctx); - - expect(openSideChat).not.toHaveBeenCalled(); - expect(ctx.addSystemMessage).toHaveBeenCalledWith("Side chats cannot be started from another side chat."); - }); - it("rolls back the active thread for /rollback", async () => { const ctx = context({ activeThreadId: "active-thread" }); @@ -335,15 +315,6 @@ describe("slash commands", () => { expect(ctx.threadActions.rollbackThread).toHaveBeenCalledWith("active-thread"); }); - it("does not roll back a side chat", async () => { - const ctx = context({ activeThreadId: "side-thread", activeThreadEphemeral: true }); - - await executeSlashCommand("rollback", "", ctx); - - expect(ctx.threadActions.rollbackThread).not.toHaveBeenCalled(); - expect(ctx.addSystemMessage).toHaveBeenCalledWith("Side chats cannot be rolled back."); - }); - it("rejects /rollback without an active thread", async () => { const ctx = context({ activeThreadId: null }); @@ -601,18 +572,6 @@ describe("slash commands", () => { expect(ctx.addSystemMessage).toHaveBeenCalledWith("Permission profile reset to default for subsequent turns."); }); - it("blocks permission profile changes in side chats while preserving permission status", async () => { - const ctx = context({ activeThreadEphemeral: true }); - - await executeSlashCommand("permissions", ":workspace", ctx); - await executeSlashCommand("permissions", "", ctx); - - expect(ctx.runtimeSettings.requestPermissionProfile).not.toHaveBeenCalled(); - expect(ctx.runtimeSettings.resetPermissionProfileToConfig).not.toHaveBeenCalled(); - expect(ctx.addSystemMessage).toHaveBeenCalledWith("Permission changes are unavailable in side chats."); - expect(ctx.addStructuredSystemMessage).toHaveBeenCalledWith("Permissions & Approvals", ctx.permissionDetails()); - }); - it.each(["reset", "clear", "off"])("treats permission profile alias-like value %s as a profile id", async (profile) => { const ctx = context(); diff --git a/tests/features/chat/application/turns/slash-command-executor.test.ts b/tests/features/chat/application/turns/slash-command-executor.test.ts index 06653a1c..c6b9e3b1 100644 --- a/tests/features/chat/application/turns/slash-command-executor.test.ts +++ b/tests/features/chat/application/turns/slash-command-executor.test.ts @@ -119,6 +119,103 @@ describe("executeSlashCommandWithState", () => { expect(host.goals.setObjective).toHaveBeenCalledWith("thread-new", "Ship this", null); }); + it("rejects a directly typed goal command in a side chat before it reaches the goal transport", async () => { + const { host, stateStore } = createHost(); + stateStore.dispatch({ + type: "active-thread/resumed", + approvalPolicyKnown: true, + sandboxPolicyKnown: true, + permissionProfileKnown: true, + approvalPolicy: null, + sandboxPolicy: null, + activePermissionProfile: null, + thread: thread("side", "Side chat"), + cwd: "/vault", + model: null, + reasoningEffort: null, + serviceTier: null, + approvalsReviewer: null, + lifetime: { kind: "ephemeral", sourceThreadId: "source", sourceThreadTitle: "Source" }, + }); + + await expect(executeSlashCommandWithState(host, "goal", "")).rejects.toThrow("Goals are unavailable in side chats."); + await expect(executeSlashCommandWithState(host, "goal", "set Ship this")).rejects.toThrow("Goals are unavailable in side chats."); + + expect(host.goals.setObjective).not.toHaveBeenCalled(); + }); + + it("allows directly typed goal display in a persistent subagent panel", async () => { + const { host, stateStore } = createHost(); + stateStore.dispatch({ + type: "active-thread/resumed", + approvalPolicyKnown: true, + sandboxPolicyKnown: true, + permissionProfileKnown: true, + approvalPolicy: null, + sandboxPolicy: null, + activePermissionProfile: null, + thread: { + ...thread("child", "Child"), + provenance: { + kind: "subagent", + subagentKind: "thread-spawn", + parentThreadId: "parent", + sessionId: "session", + depth: 1, + agentNickname: "Scout", + agentRole: "explorer", + }, + }, + cwd: "/vault", + model: null, + reasoningEffort: null, + serviceTier: null, + approvalsReviewer: null, + }); + stateStore.dispatch({ + type: "active-thread/goal-set", + goal: { + threadId: "child", + objective: "Inspect", + status: "active", + tokenBudget: null, + tokensUsed: 0, + timeUsedSeconds: 0, + createdAt: 1, + updatedAt: 1, + }, + }); + + await expect(executeSlashCommandWithState(host, "goal", "")).resolves.toBeUndefined(); + + expect(host.addStructuredSystemMessage).toHaveBeenCalledWith("Thread goal", expect.any(Array)); + expect(host.goals.setObjective).not.toHaveBeenCalled(); + }); + + it("keeps directly typed compact available in a side chat", async () => { + const { compactThread, host, stateStore } = createHost(); + stateStore.dispatch({ + type: "active-thread/resumed", + approvalPolicyKnown: true, + sandboxPolicyKnown: true, + permissionProfileKnown: true, + approvalPolicy: null, + sandboxPolicy: null, + activePermissionProfile: null, + thread: thread("side", "Side chat"), + cwd: "/vault", + model: null, + reasoningEffort: null, + serviceTier: null, + approvalsReviewer: null, + lifetime: { kind: "ephemeral", sourceThreadId: "source", sourceThreadTitle: "Source" }, + }); + + await executeSlashCommandWithState(host, "compact", ""); + + expect(compactThread).toHaveBeenCalledWith("side"); + }); + it("runs reconnect even when there is no current app-server client", async () => { const { host } = createHost({ connectionAvailable: () => false }); diff --git a/tests/features/chat/application/turns/turn-submission-actions.test.ts b/tests/features/chat/application/turns/turn-submission-actions.test.ts index 78678266..7578d300 100644 --- a/tests/features/chat/application/turns/turn-submission-actions.test.ts +++ b/tests/features/chat/application/turns/turn-submission-actions.test.ts @@ -110,6 +110,25 @@ function resumeSubagentThread(stateStore: ReturnType) { + stateStore.dispatch({ + type: "active-thread/resumed", + approvalPolicyKnown: true, + sandboxPolicyKnown: true, + permissionProfileKnown: true, + approvalPolicy: null, + sandboxPolicy: null, + activePermissionProfile: null, + thread: thread("side"), + cwd: "/vault", + model: null, + reasoningEffort: null, + serviceTier: null, + approvalsReviewer: null, + lifetime: { kind: "ephemeral", sourceThreadId: "source", sourceThreadTitle: "Source" }, + }); +} + describe("TurnSubmissionActions", () => { it("aborts when restoration changes target while hydration is pending", async () => { const { host, startTurn, stateStore } = createHost(); @@ -147,6 +166,18 @@ describe("TurnSubmissionActions", () => { expect(host.addSystemMessage).toHaveBeenCalledWith("Messages are unavailable in agent threads. Start a new chat to continue."); }); + it("starts a side-chat turn when no pending runtime setting needs transport", async () => { + const { host, startTurn, stateStore } = createHost(); + resumeSideChat(stateStore); + const actions = createTurnSubmissionActions(host); + + await expect(actions.sendTurnText({ text: "hello" })).resolves.toBe(true); + + expect(host.applyPendingThreadSettings).toHaveBeenCalledOnce(); + expect(startTurn).toHaveBeenCalledWith(expect.objectContaining({ threadId: "side", input: textInput("hello") })); + expect(host.addSystemMessage).not.toHaveBeenCalled(); + }); + it("starts a thread when needed and acknowledges the optimistic turn", async () => { const { host, startTurn, stateStore } = createHost(); const actions = createTurnSubmissionActions(host); diff --git a/tests/features/chat/panel/composer-controller.test.ts b/tests/features/chat/panel/composer-controller.test.ts index 5dbb8088..119e75de 100644 --- a/tests/features/chat/panel/composer-controller.test.ts +++ b/tests/features/chat/panel/composer-controller.test.ts @@ -230,7 +230,7 @@ describe("ChatComposerController", () => { composer(parent).dispatchEvent(new Event("input", { bubbles: true })); expect(stateStore.getState().composer.suggestions.map((suggestion) => suggestion.replacement)).not.toContain("/fork"); - expect(stateStore.getState().composer.suggestions.map((suggestion) => suggestion.replacement)).toContain("/fast"); + expect(stateStore.getState().composer.suggestions.map((suggestion) => suggestion.replacement)).not.toContain("/fast"); }); it("keeps a disconnected subagent composer read-only without slash suggestions", () => { diff --git a/tests/features/chat/panel/surface/projections.test.ts b/tests/features/chat/panel/surface/projections.test.ts index 739baded..7f0a3607 100644 --- a/tests/features/chat/panel/surface/projections.test.ts +++ b/tests/features/chat/panel/surface/projections.test.ts @@ -93,6 +93,56 @@ describe("chat panel surface projections", () => { expect(JSON.stringify(projection.blocks)).not.toContain('"rollback":true'); }); + it("keeps compact available but hides goal mutation behind the side-chat policy", () => { + let state = chatStateFixture(); + state = chatStateWith(state, { + activeThread: { + id: "side", + lifetime: { kind: "ephemeral", sourceThreadId: "source", sourceThreadTitle: "Source" }, + }, + ui: { toolbarPanel: "chat-actions" }, + }); + const parent = renderWithShellModels(state, (models) => + h(ChatPanelToolbar, { + model: models.toolbar, + stateStore: createChatStateStore(state), + surface: toolbarSurfaceFixture(), + actions: toolbarActionsFixture(), + }), + ); + + const items = [...parent.querySelectorAll(".codex-panel__chat-actions-panel-item")]; + expect(items.map((item) => [item.textContent, item.classList.contains("is-disabled")])).toEqual([ + ["Start new chat", false], + ["Start side chat", true], + ["Compact context", false], + ["Set goal...", true], + ]); + unmountUiRoot(parent); + }); + + it("keeps Set goal enabled while a restored thread still needs hydration", () => { + const store = createChatStateStore(chatStateFixture()); + store.dispatch({ type: "panel/restored-thread-applied", threadId: "restored", fallbackTitle: "Restored" }); + store.dispatch({ type: "ui/panel-set", panel: "chat-actions" }); + const restored = store.getState(); + const parent = renderWithShellModels(restored, (models) => + h(ChatPanelToolbar, { + model: models.toolbar, + stateStore: store, + surface: toolbarSurfaceFixture(), + actions: toolbarActionsFixture(), + }), + ); + + const goalAction = [...parent.querySelectorAll(".codex-panel__chat-actions-panel-item")].find( + (item) => item.textContent === "Set goal...", + ); + expect(goalAction?.classList.contains("is-disabled")).toBe(false); + expect(selectChatPanelGoal(restored).goalMutationsAllowed).toBe(false); + unmountUiRoot(parent); + }); + it("builds toolbar rows from immutable chat state snapshots", () => { let state = chatStateFixture(); state = chatStateWith(state, { activeThread: { id: "thread-1" } }); @@ -448,6 +498,32 @@ describe("chat panel surface projections", () => { unmountUiRoot(resumeParent); }); + it("renders persistent subagent goals without mutation controls", () => { + let state = chatStateFixture(); + state = chatStateWith(state, { + activeThread: { + id: "child", + goal: goalFixture("child"), + provenance: { + kind: "subagent", + subagentKind: "thread-spawn", + parentThreadId: "parent", + sessionId: "session", + depth: 1, + agentNickname: "Scout", + agentRole: "explorer", + }, + }, + }); + + const parent = renderWithShellModels(state, (models) => h(ChatPanelGoal, { model: models.goal, surface: goalSurfaceFixture() })); + + expect(parent.querySelector('[aria-label="Edit goal"]')).toBeNull(); + expect(parent.querySelector('[aria-label="Pause goal"]')).toBeNull(); + expect(parent.querySelector('[aria-label="Clear goal"]')).toBeNull(); + unmountUiRoot(parent); + }); + it("builds composer meta from one captured chat state", () => { let state = chatStateFixture(); state = chatStateWith(state, { diff --git a/tests/features/chat/panel/toolbar-actions.test.ts b/tests/features/chat/panel/toolbar-actions.test.ts index 170b9f54..0bf88ec8 100644 --- a/tests/features/chat/panel/toolbar-actions.test.ts +++ b/tests/features/chat/panel/toolbar-actions.test.ts @@ -5,7 +5,7 @@ import { describe, expect, it, vi } from "vitest"; import { createChatState } from "../../../../src/features/chat/application/state/root-reducer"; import { createChatStateStore } from "../../../../src/features/chat/application/state/store"; import type { ThreadManagementActions } from "../../../../src/features/chat/application/threads/thread-management-actions"; -import { createToolbarPanelActions } from "../../../../src/features/chat/panel/toolbar-actions"; +import { createToolbarPanelActions, createToolbarUiActions } from "../../../../src/features/chat/panel/toolbar-actions"; describe("createToolbarPanelActions", () => { it("tracks archive confirmation and delegates archive actions", async () => { @@ -41,4 +41,26 @@ describe("createToolbarPanelActions", () => { expect(stateStore.getState().ui.toolbarPanel).toBeNull(); }); + + it("keeps the Set goal action reachable while a restored thread needs hydration", () => { + const stateStore = createChatStateStore(createChatState()); + stateStore.dispatch({ type: "panel/restored-thread-applied", threadId: "restored", fallbackTitle: "Restored" }); + const startEditingCurrent = vi.fn(); + const actions = createToolbarUiActions({ + connectionActions: {} as never, + reconnectPanel: vi.fn(), + threadActions: {} as never, + goals: { startEditingCurrent } as never, + toolbarPanel: {} as never, + rename: {} as never, + navigation: {} as never, + canStartSideChat: () => false, + canCompact: () => false, + canMutateGoal: () => true, + }); + + actions.chat.setGoal(); + + expect(startEditingCurrent).toHaveBeenCalledOnce(); + }); }); diff --git a/tests/features/chat/ui/toolbar.test.ts b/tests/features/chat/ui/toolbar.test.ts index 24b6c786..063427dc 100644 --- a/tests/features/chat/ui/toolbar.test.ts +++ b/tests/features/chat/ui/toolbar.test.ts @@ -404,7 +404,9 @@ describe("Toolbar decisions", () => { function toolbarModel(overrides: Partial = {}): ToolbarViewModel { return { newChatDisabled: false, - activeThreadChatActionsDisabled: false, + sideChatStartDisabled: false, + compactDisabled: false, + goalMutationDisabled: false, chatActionsOpen: false, historyOpen: false, statusPanelOpen: false,