diff --git a/src/app-server/services/thread-rename.ts b/src/app-server/services/thread-rename.ts deleted file mode 100644 index 931c4e2a..00000000 --- a/src/app-server/services/thread-rename.ts +++ /dev/null @@ -1,24 +0,0 @@ -import type { AppServerClient } from "../connection/client"; -import { normalizeExplicitThreadName } from "../../domain/threads/model"; - -export interface RenameThreadResult { - name: string; -} - -export interface ThreadRename { - name: string; -} - -export function threadRenameFromValue(value: string): ThreadRename | null { - const name = normalizeExplicitThreadName(value); - return name ? { name } : null; -} - -export async function renameThreadOnAppServer( - client: AppServerClient, - threadId: string, - rename: ThreadRename, -): Promise { - await client.setThreadName(threadId, rename.name); - return { name: rename.name }; -} diff --git a/src/features/chat/app-server/actions/diagnostics.ts b/src/features/chat/app-server/actions/diagnostics.ts index 2302f52a..70a0e450 100644 --- a/src/features/chat/app-server/actions/diagnostics.ts +++ b/src/features/chat/app-server/actions/diagnostics.ts @@ -34,7 +34,6 @@ export interface ChatServerDiagnosticsActionsHost extends ChatServerActionHost { export interface ChatServerDiagnosticsActions { refreshDiagnosticProbes: (options?: RefreshDiagnosticProbesOptions) => Promise; - refreshPublishedDiagnosticProbes: (options?: RefreshDiagnosticProbesOptions) => Promise; mcpStatusLines: () => Promise; recordMcpStartupStatus: (name: string, startupStatus: McpServerStartupStatus, message: string | null) => void; } @@ -44,7 +43,6 @@ export function createChatServerDiagnosticsActions(host: ChatServerDiagnosticsAc refreshDiagnosticProbes: async (options) => { await refreshDiagnosticProbes(host, options); }, - refreshPublishedDiagnosticProbes: (options) => refreshPublishedDiagnosticProbes(host, options), mcpStatusLines: () => mcpStatusLines(host), recordMcpStartupStatus: (name, startupStatus, message) => { recordMcpStartupStatus(host, name, startupStatus, message); @@ -137,13 +135,6 @@ async function readRateLimitDiagnosticProbe(client: AppServerClient): Promise { - await refreshDiagnosticProbes(host, options); -} - async function mcpStatusLines(host: ChatServerDiagnosticsActionsHost): Promise { const client = host.currentClient(); if (!client) return ["MCP servers", "Codex app-server is not connected."]; diff --git a/src/features/chat/app-server/actions/metadata.ts b/src/features/chat/app-server/actions/metadata.ts index 826832c1..1585c8c8 100644 --- a/src/features/chat/app-server/actions/metadata.ts +++ b/src/features/chat/app-server/actions/metadata.ts @@ -20,13 +20,10 @@ export interface ChatServerMetadataActions { applyAppServerMetadata: (metadata: SharedServerMetadata) => void; loadAppServerMetadata: () => Promise; refreshAppServerMetadata: () => Promise; - refreshPublishedAppServerMetadata: () => Promise; applyAppServerMetadataSnapshot: () => void; refreshSkills: (forceReload?: boolean) => Promise; - refreshPublishedSkills: (forceReload?: boolean) => Promise; loadSkills: (forceReload?: boolean) => Promise; - refreshRateLimits: () => Promise; - refreshPublishedRateLimits: () => Promise; + refreshRateLimits: (options?: { preserveExistingOnFailure?: boolean }) => Promise; loadRateLimit: () => Promise; } @@ -37,17 +34,14 @@ export function createChatServerMetadataActions(host: ChatServerMetadataActionsH }, loadAppServerMetadata: () => loadAppServerMetadata(host), refreshAppServerMetadata: () => refreshAppServerMetadata(host), - refreshPublishedAppServerMetadata: () => refreshPublishedAppServerMetadata(host), applyAppServerMetadataSnapshot: () => { applyAppServerMetadataSnapshot(host); }, refreshSkills: async (forceReload) => { await refreshSkills(host, forceReload); }, - refreshPublishedSkills: (forceReload) => refreshPublishedSkills(host, forceReload), loadSkills: (forceReload) => loadSkills(host, forceReload), - refreshRateLimits: () => refreshRateLimits(host), - refreshPublishedRateLimits: () => refreshPublishedRateLimits(host), + refreshRateLimits: (options) => refreshRateLimits(host, options), loadRateLimit: () => loadRateLimit(host), }; } @@ -80,10 +74,6 @@ async function refreshAppServerMetadata(host: ChatServerMetadataActionsHost): Pr return metadata; } -async function refreshPublishedAppServerMetadata(host: ChatServerMetadataActionsHost): Promise { - return refreshAppServerMetadata(host); -} - function applyAppServerMetadataSnapshot(host: ChatServerMetadataActionsHost): void { const metadata = host.appServerMetadataSnapshot(); if (metadata) applyAppServerMetadata(host, metadata); @@ -114,50 +104,33 @@ async function refreshSkills(host: ChatServerMetadataActionsHost, forceReload = return null; } -async function refreshPublishedSkills(host: ChatServerMetadataActionsHost, forceReload = false): Promise { - await refreshSkills(host, forceReload); -} - async function loadSkills(host: ChatServerMetadataActionsHost, forceReload = false): Promise { return readSkillMetadataProbe(host.currentClient(), host.vaultPath, forceReload); } -async function refreshRateLimits(host: ChatServerMetadataActionsHost): Promise { +async function refreshRateLimits( + host: ChatServerMetadataActionsHost, + options: { preserveExistingOnFailure?: boolean } = {}, +): Promise { const client = host.currentClient(); const rateLimit = await readRateLimitMetadataProbe(client); if (client && host.currentClient() !== client) return; - const next = updateRateLimitMetadata(host, rateLimit, { preserveRateLimitOnFailure: false }); + const preserveExistingOnFailure = options.preserveExistingOnFailure === true; + const next = updateRateLimitMetadata(host, rateLimit, { preserveRateLimitOnFailure: preserveExistingOnFailure }); if (next) { applyAppServerMetadata(host, next); return; } const diagnostics = diagnosticsWithProbe(currentMetadataDiagnostics(host), rateLimit.probe); - host.stateStore.dispatch({ - type: "connection/metadata-applied", - rateLimit: rateLimit.data, - serverDiagnostics: diagnostics, - }); -} - -async function refreshPublishedRateLimits(host: ChatServerMetadataActionsHost): Promise { - const client = host.currentClient(); - const rateLimit = await readRateLimitMetadataProbe(client); - if (client && host.currentClient() !== client) return; - const next = updateRateLimitMetadata(host, rateLimit, { preserveRateLimitOnFailure: true }); - if (next) { - applyAppServerMetadata(host, next); - return; - } - const diagnostics = diagnosticsWithProbe(currentMetadataDiagnostics(host), rateLimit.probe); - if (rateLimit.probe.status === "ok") { - host.stateStore.dispatch({ - type: "connection/metadata-applied", - rateLimit: rateLimit.data, - serverDiagnostics: diagnostics, - }); - return; - } - host.stateStore.dispatch({ type: "connection/metadata-applied", serverDiagnostics: diagnostics }); + host.stateStore.dispatch( + preserveExistingOnFailure && rateLimit.probe.status !== "ok" + ? { type: "connection/metadata-applied", serverDiagnostics: diagnostics } + : { + type: "connection/metadata-applied", + rateLimit: rateLimit.data, + serverDiagnostics: diagnostics, + }, + ); } async function loadRateLimit(host: ChatServerMetadataActionsHost): Promise { diff --git a/src/features/chat/application/connection/connection-controller.ts b/src/features/chat/application/connection/connection-controller.ts index 86287b4a..c865d716 100644 --- a/src/features/chat/application/connection/connection-controller.ts +++ b/src/features/chat/application/connection/connection-controller.ts @@ -20,12 +20,12 @@ export interface ChatConnectionAdapter { } export interface ChatConnectionMetadataActions { - refreshPublishedAppServerMetadata: () => Promise; - refreshPublishedSkills: (forceReload?: boolean) => Promise; + refreshAppServerMetadata: () => Promise; + refreshSkills: (forceReload?: boolean) => Promise; } export interface ChatConnectionDiagnosticsActions { - refreshPublishedDiagnosticProbes: (options?: { appServerMetadataSnapshot?: boolean; forceResourceProbes?: boolean }) => Promise; + refreshDiagnosticProbes: (options?: { appServerMetadataSnapshot?: boolean; forceResourceProbes?: boolean }) => Promise; } export interface ChatConnectionControllerHost { @@ -125,8 +125,8 @@ async function refreshDiagnostics( await controller.ensureConnected(); if (!host.connection.currentClient()) return; host.clearDeferredDiagnostics(); - await host.metadata.refreshPublishedAppServerMetadata(); - await host.diagnostics.refreshPublishedDiagnosticProbes({ appServerMetadataSnapshot: true }); + await host.metadata.refreshAppServerMetadata(); + await host.diagnostics.refreshDiagnosticProbes({ appServerMetadataSnapshot: true }); } async function refreshStatusPanel( @@ -143,7 +143,7 @@ async function refreshStatusPanel( async function refreshSkills(host: ChatConnectionControllerHost, forceReload = false): Promise { if (!host.connection.currentClient()) return; - await host.metadata.refreshPublishedSkills(forceReload); + await host.metadata.refreshSkills(forceReload); } async function initializeConnection(host: ChatConnectionControllerHost, connection: ActiveConnectionWork): Promise { @@ -154,7 +154,7 @@ async function initializeConnection(host: ChatConnectionControllerHost, connecti host.stateStore.dispatch({ type: "connection/initialized", initializeResponse: initialization }); const client = host.connection.currentClient(); if (!client) throw new Error("Codex app-server connection did not initialize."); - await host.metadata.refreshPublishedAppServerMetadata(); + await host.metadata.refreshAppServerMetadata(); if (host.connectionWork.isStale(connection)) return; await host.loadSharedThreadList(); if (host.connectionWork.isStale(connection)) return; diff --git a/src/features/chat/application/conversation/composition.ts b/src/features/chat/application/conversation/composition.ts index 6add774d..6fd5d838 100644 --- a/src/features/chat/application/conversation/composition.ts +++ b/src/features/chat/application/conversation/composition.ts @@ -90,23 +90,10 @@ export function createConversationTurnActions( startNewThread: thread.startNewThread, startThreadForGoal: (objective) => startThreadForGoal(refs.threadStarter, objective), resumeThread: thread.selectThread, - forkThread: (threadId) => refs.threadActions.forkThread(threadId), - rollbackThread: (threadId) => refs.threadActions.rollbackThread(threadId), - compactThread: (threadId) => refs.threadActions.compactThread(threadId), - archiveThread: (threadId, saveMarkdown) => refs.threadActions.archiveThread(threadId, saveMarkdown), - renameThread: (threadId, name) => refs.threadActions.renameThread(threadId, name).then(() => undefined), + threadActions: refs.threadActions, reconnect: refs.reconnectPanel, - toggleFastMode: () => refs.runtimeSettings.toggleFastMode(), - toggleCollaborationMode: () => refs.runtimeSettings.toggleCollaborationMode(), - toggleAutoReview: () => void refs.runtimeSettings.toggleAutoReview(), - requestModel: (model) => refs.runtimeSettings.requestModel(model), - resetModelToConfig: () => refs.runtimeSettings.resetModelToConfig(), - requestReasoningEffort: (effort) => refs.runtimeSettings.requestReasoningEffort(effort), - resetReasoningEffortToConfig: () => refs.runtimeSettings.resetReasoningEffortToConfig(), - activeGoal: () => refs.goals.activeGoal(), - setGoalObjective: (threadId, objective, tokenBudget) => refs.goals.setObjective(threadId, objective, tokenBudget), - setGoalStatus: (threadId, goalStatus) => refs.goals.setStatus(threadId, goalStatus), - clearGoal: (threadId) => refs.goals.clear(threadId), + runtimeSettings: refs.runtimeSettings, + goals: refs.goals, addSystemMessage: status.addSystemMessage, addStructuredSystemMessage: status.addStructuredSystemMessage, setStatus: status.set, diff --git a/src/features/chat/application/conversation/slash-command-execution.ts b/src/features/chat/application/conversation/slash-command-execution.ts index 50281f3c..e51ef483 100644 --- a/src/features/chat/application/conversation/slash-command-execution.ts +++ b/src/features/chat/application/conversation/slash-command-execution.ts @@ -1,10 +1,13 @@ import type { CodexInput } from "../../../../domain/chat/input"; -import type { ThreadGoal, ThreadGoalStatus } from "../../../../domain/threads/goal"; +import type { ThreadGoal } from "../../../../domain/threads/goal"; import type { ReasoningEffort } from "../../../../domain/catalog/metadata"; import { normalizeReasoningEffort } from "../../../../domain/catalog/metadata"; import type { Thread } from "../../../../domain/threads/model"; import { getThreadTitle } from "../../../../domain/threads/model"; import type { ReferencedThreadMetadata } from "../../../../domain/threads/reference"; +import type { ChatRuntimeSettingsActions } from "../runtime/settings-actions"; +import type { GoalActions } from "../threads/goal-actions"; +import type { ThreadManagementActions } from "../threads/thread-management-actions"; import { slashCommandDefinition, slashCommandHelpSections, @@ -34,26 +37,22 @@ export interface SlashCommandExecutionContext { startThreadForGoal: (objective: string) => Promise; resumeThread: (threadId: string) => Promise; referThread: (thread: Thread, message: string) => Promise; - forkThread: (threadId: string) => Promise; - rollbackThread: (threadId: string) => Promise; - compactThread: (threadId: string) => Promise; - archiveThread: (threadId: string, saveMarkdown?: boolean) => Promise; - renameThread: (threadId: string, name: string) => Promise; + threadActions: Pick; reconnect: () => Promise; - toggleFastMode: () => void | Promise; - toggleCollaborationMode: () => void | Promise; - toggleAutoReview: () => void | Promise; addSystemMessage: (text: string) => void; addStructuredSystemMessage: (text: string, details: MessageStreamNoticeSection[]) => void; - requestModel: (model: string) => boolean | undefined | Promise; - resetModelToConfig: () => boolean | undefined | Promise; - requestReasoningEffort: (effort: ReasoningEffort) => boolean | undefined | Promise; - resetReasoningEffortToConfig: () => boolean | undefined | Promise; + runtimeSettings: Pick< + ChatRuntimeSettingsActions, + | "toggleFastMode" + | "toggleCollaborationMode" + | "toggleAutoReview" + | "requestModel" + | "resetModelToConfig" + | "requestReasoningEffort" + | "resetReasoningEffortToConfig" + >; supportedReasoningEfforts: () => readonly ReasoningEffort[]; - activeGoal: () => ThreadGoal | null; - setGoalObjective: (threadId: string, objective: string, tokenBudget: number | null) => Promise; - setGoalStatus: (threadId: string, status: ThreadGoalStatus) => Promise; - clearGoal: (threadId: string) => Promise; + goals: Pick; statusSummaryLines: () => string[]; connectionDiagnosticDetails: () => MessageStreamNoticeSection[]; mcpStatusLines: () => Promise; @@ -84,189 +83,167 @@ export async function executeSlashCommand( return; } - if (command === "clear") { - await context.startNewThread(); - return; - } - - if (command === "resume") { - const thread = resolveThreadArgument(args, context.listedThreads); - if (!thread.ok) { - context.addSystemMessage(thread.message); + switch (command) { + case "clear": + await context.startNewThread(); + return; + case "resume": { + const thread = resolveThreadArgument(args, context.listedThreads); + if (!thread.ok) { + context.addSystemMessage(thread.message); + return; + } + await context.resumeThread(thread.thread.id); return; } - await context.resumeThread(thread.thread.id); - return; - } + case "reconnect": + await context.reconnect(); + return; + case "refer": { + const parsed = parseReferArgs(args); + if (!parsed) { + context.addSystemMessage(usageError(command, "requires a thread and a message")); + return; + } + const thread = resolveThreadArgument(parsed.threadQuery, context.listedThreads); + if (!thread.ok) { + context.addSystemMessage(thread.message); + return; + } + if (thread.thread.id === context.activeThreadId) { + context.addSystemMessage(currentThreadReferenceMessage()); + return; + } + const reference = await context.referThread(thread.thread, parsed.message); + if (!reference) return; + return { sendText: parsed.message, sendInput: reference.input, referencedThread: reference.referencedThread }; + } + case "fork": + if (!context.activeThreadId) { + context.addSystemMessage(noActiveThreadToForkMessage()); + return; + } + await context.threadActions.forkThread(context.activeThreadId); + return; + case "rollback": + if (!context.activeThreadId) { + context.addSystemMessage(noActiveThreadToRollbackMessage()); + return; + } + if (context.busy) { + context.addSystemMessage(interruptBeforeRollbackMessage()); + return; + } + await context.threadActions.rollbackThread(context.activeThreadId); + return; + case "compact": + if (!context.activeThreadId) { + context.addSystemMessage(noActiveThreadToCompactMessage()); + return; + } + await context.threadActions.compactThread(context.activeThreadId); + return; + case "archive": { + if (context.busy) { + context.addSystemMessage(finishBeforeArchivingThreadsMessage()); + return; + } - if (command === "reconnect") { - await context.reconnect(); - return; - } - - if (command === "refer") { - const parsed = parseReferArgs(args); - if (!parsed) { - context.addSystemMessage(usageError(command, "requires a thread and a message")); + const thread = resolveThreadArgument(args, context.listedThreads); + if (!thread.ok) { + context.addSystemMessage(thread.message); + return; + } + await context.threadActions.archiveThread(thread.thread.id); return; } - const thread = resolveThreadArgument(parsed.threadQuery, context.listedThreads); - if (!thread.ok) { - context.addSystemMessage(thread.message); + case "rename": { + const parsed = parseThreadAndNameArgs(args); + if (!parsed) { + context.addSystemMessage(usageError(command, "requires a thread and a name")); + return; + } + const thread = resolveThreadArgument(parsed.threadQuery, context.listedThreads); + if (!thread.ok) { + context.addSystemMessage(thread.message); + return; + } + await context.threadActions.renameThread(thread.thread.id, parsed.text); return; } - if (thread.thread.id === context.activeThreadId) { - context.addSystemMessage(currentThreadReferenceMessage()); + case "fast": + await context.runtimeSettings.toggleFastMode(); + return; + case "auto-review": + await context.runtimeSettings.toggleAutoReview(); + return; + case "plan": + await context.runtimeSettings.toggleCollaborationMode(); + if (args) return { sendText: args }; + return; + case "goal": + return await executeGoalCommand(args, context); + case "status": + context.addStructuredSystemMessage("Thread status", detailsFromLines(context.statusSummaryLines())); + return; + case "doctor": + context.addStructuredSystemMessage("Connection diagnostics", context.connectionDiagnosticDetails()); + return; + case "mcp": + context.addStructuredSystemMessage("MCP servers", detailsFromLines(await context.mcpStatusLines())); + return; + case "model": { + const requested = parseModelOverride(args); + if (requested !== undefined) { + const applied = await applyModelOverride(context, requested); + if (applied === false) return; + context.addSystemMessage(modelOverrideMessage(requested)); + return; + } + context.addStructuredSystemMessage("Model settings", detailsFromLines(context.modelStatusLines())); return; } - const reference = await context.referThread(thread.thread, parsed.message); - if (!reference) return; - return { sendText: parsed.message, sendInput: reference.input, referencedThread: reference.referencedThread }; - } - - if (command === "fork") { - if (!context.activeThreadId) { - context.addSystemMessage(noActiveThreadToForkMessage()); - return; - } - await context.forkThread(context.activeThreadId); - return; - } - - if (command === "rollback") { - if (!context.activeThreadId) { - context.addSystemMessage(noActiveThreadToRollbackMessage()); - return; - } - if (context.busy) { - context.addSystemMessage(interruptBeforeRollbackMessage()); - return; - } - await context.rollbackThread(context.activeThreadId); - return; - } - - if (command === "compact") { - if (!context.activeThreadId) { - context.addSystemMessage(noActiveThreadToCompactMessage()); - return; - } - await context.compactThread(context.activeThreadId); - return; - } - - if (command === "archive") { - if (context.busy) { - context.addSystemMessage(finishBeforeArchivingThreadsMessage()); - return; - } - - const thread = resolveThreadArgument(args, context.listedThreads); - if (!thread.ok) { - context.addSystemMessage(thread.message); - return; - } - await context.archiveThread(thread.thread.id); - return; - } - - if (command === "rename") { - const parsed = parseThreadAndNameArgs(args); - if (!parsed) { - context.addSystemMessage(usageError(command, "requires a thread and a name")); - return; - } - const thread = resolveThreadArgument(parsed.threadQuery, context.listedThreads); - if (!thread.ok) { - context.addSystemMessage(thread.message); - return; - } - await context.renameThread(thread.thread.id, parsed.text); - return; - } - - if (command === "fast") { - await context.toggleFastMode(); - return; - } - - if (command === "auto-review") { - await context.toggleAutoReview(); - return; - } - - if (command === "plan") { - await context.toggleCollaborationMode(); - if (args) return { sendText: args }; - return; - } - - if (command === "goal") { - return await executeGoalCommand(args, context); - } - - if (command === "status") { - context.addStructuredSystemMessage("Thread status", detailsFromLines(context.statusSummaryLines())); - return; - } - - if (command === "doctor") { - context.addStructuredSystemMessage("Connection diagnostics", context.connectionDiagnosticDetails()); - return; - } - - if (command === "mcp") { - context.addStructuredSystemMessage("MCP servers", detailsFromLines(await context.mcpStatusLines())); - return; - } - - if (command === "model") { - const requested = parseModelOverride(args); - if (requested !== undefined) { - const applied = await applyModelOverride(context, requested); - if (applied === false) return; - context.addSystemMessage(modelOverrideMessage(requested)); - return; - } - context.addStructuredSystemMessage("Model settings", detailsFromLines(context.modelStatusLines())); - return; - } - - if (command === "reasoning") { - const requested = parseReasoningEffortOverride(args); - if (requested !== undefined) { - if (requested !== null && !context.supportedReasoningEfforts().includes(requested)) { + case "reasoning": { + const requested = parseReasoningEffortOverride(args); + if (requested !== undefined) { + if (requested !== null && !context.supportedReasoningEfforts().includes(requested)) { + context.addSystemMessage(`Unsupported reasoning level: ${args}. Usage: ${slashCommandDefinition(command).usage}`); + return; + } + const applied = await applyReasoningEffortOverride(context, requested); + if (applied === false) return; + context.addSystemMessage(reasoningEffortOverrideMessage(requested)); + return; + } + if (args) { context.addSystemMessage(`Unsupported reasoning level: ${args}. Usage: ${slashCommandDefinition(command).usage}`); return; } - const applied = await applyReasoningEffortOverride(context, requested); - if (applied === false) return; - context.addSystemMessage(reasoningEffortOverrideMessage(requested)); + context.addStructuredSystemMessage("Reasoning effort", detailsFromLines(context.effortStatusLines())); return; } - if (args) { - context.addSystemMessage(`Unsupported reasoning level: ${args}. Usage: ${slashCommandDefinition(command).usage}`); + case "help": + context.addStructuredSystemMessage("Available slash commands", slashCommandHelpSections()); return; - } - context.addStructuredSystemMessage("Reasoning effort", detailsFromLines(context.effortStatusLines())); - return; } - - context.addStructuredSystemMessage("Available slash commands", slashCommandHelpSections()); + const _exhaustive: never = command; + return _exhaustive; } function applyModelOverride( context: SlashCommandExecutionContext, requested: string | null, ): boolean | undefined | Promise { - return requested === null ? context.resetModelToConfig() : context.requestModel(requested); + return requested === null ? context.runtimeSettings.resetModelToConfig() : context.runtimeSettings.requestModel(requested); } function applyReasoningEffortOverride( context: SlashCommandExecutionContext, requested: ReasoningEffort | null, ): boolean | undefined | Promise { - return requested === null ? context.resetReasoningEffortToConfig() : context.requestReasoningEffort(requested); + return requested === null + ? context.runtimeSettings.resetReasoningEffortToConfig() + : context.runtimeSettings.requestReasoningEffort(requested); } function parseModelOverride(args: string): string | null | undefined { @@ -299,7 +276,7 @@ async function executeGoalCommand(args: string, context: SlashCommandExecutionCo return; } if (parsed.kind === "show") { - const goal = context.activeGoal(); + const goal = context.goals.activeGoal(); if (!goal) { context.addSystemMessage("No goal set."); return; @@ -307,14 +284,14 @@ async function executeGoalCommand(args: string, context: SlashCommandExecutionCo context.addStructuredSystemMessage("Thread goal", goalDetails(goal)); return; } - const goal = context.activeGoal(); + const goal = context.goals.activeGoal(); if (parsed.kind === "set") { const threadId = context.activeThreadId ?? (await context.startThreadForGoal(parsed.objective)); if (!threadId) { context.addSystemMessage("No active thread for goal management."); return; } - await context.setGoalObjective(threadId, parsed.objective, goal?.tokenBudget ?? null); + await context.goals.setObjective(threadId, parsed.objective, goal?.tokenBudget ?? null); return; } const threadId = context.activeThreadId; @@ -330,14 +307,14 @@ async function executeGoalCommand(args: string, context: SlashCommandExecutionCo return { composerDraft: `/goal set ${goal.objective}` }; } if (parsed.kind === "pause") { - await context.setGoalStatus(threadId, "paused"); + await context.goals.setStatus(threadId, "paused"); return; } if (parsed.kind === "resume") { - await context.setGoalStatus(threadId, "active"); + await context.goals.setStatus(threadId, "active"); return; } - await context.clearGoal(threadId); + await context.goals.clear(threadId); } type GoalArgs = diff --git a/src/features/chat/application/conversation/slash-command-handler.ts b/src/features/chat/application/conversation/slash-command-handler.ts index d2cb6055..21612b10 100644 --- a/src/features/chat/application/conversation/slash-command-handler.ts +++ b/src/features/chat/application/conversation/slash-command-handler.ts @@ -3,10 +3,11 @@ import { codexTextInputWithAttachments, type CodexInput } from "../../../../doma import { readReferencedThreadConversationSummaries } from "../../../../app-server/threads/data"; import { referencedThreadPromptBundle, REFERENCED_THREAD_TURN_LIMIT } from "../../../../domain/threads/reference"; import type { Thread } from "../../../../domain/threads/model"; +import type { MessageStreamNoticeSection } from "../../domain/message-stream/items"; +import type { ChatRuntimeSettingsActions } from "../runtime/settings-actions"; import { referencedThreadStatus, referencedThreadUnreadableMessage } from "./messages"; import { executeSlashCommand as runSlashCommand, - type SlashCommandExecutionContext, type SlashCommandExecutionResult, type ThreadReferenceInput, } from "./slash-command-execution"; @@ -17,14 +18,37 @@ import { submissionStateSnapshot } from "../state/selectors"; import type { ChatStateStore } from "../state/store"; import { currentModel, runtimeConfigOrDefault } from "../../domain/runtime/effective"; import { runtimeSnapshotForChatState } from "../runtime/snapshot"; +import type { GoalActions } from "../threads/goal-actions"; +import type { ThreadManagementActions } from "../threads/thread-management-actions"; -type DynamicSlashCommandExecutionContext = "activeThreadId" | "busy" | "listedThreads" | "referThread" | "supportedReasoningEfforts"; - -export interface SlashCommandHandlerHost extends Omit { +export interface SlashCommandHandlerHost { stateStore: ChatStateStore; currentClient: () => AppServerClient | null; codexInput: (text: string) => CodexInput; setStatus: (status: string) => void; + startNewThread: () => Promise; + startThreadForGoal: (objective: string) => Promise; + resumeThread: (threadId: string) => Promise; + reconnect: () => Promise; + threadActions: Pick; + runtimeSettings: Pick< + ChatRuntimeSettingsActions, + | "toggleFastMode" + | "toggleCollaborationMode" + | "toggleAutoReview" + | "requestModel" + | "resetModelToConfig" + | "requestReasoningEffort" + | "resetReasoningEffortToConfig" + >; + goals: Pick; + addSystemMessage: (text: string) => void; + addStructuredSystemMessage: (text: string, details: MessageStreamNoticeSection[]) => void; + statusSummaryLines: () => string[]; + connectionDiagnosticDetails: () => MessageStreamNoticeSection[]; + mcpStatusLines: () => Promise; + modelStatusLines: () => string[]; + effortStatusLines: () => string[]; } export async function executeSlashCommandWithState( diff --git a/src/features/chat/host/session.ts b/src/features/chat/host/session.ts index c1dfd623..c19ed05f 100644 --- a/src/features/chat/host/session.ts +++ b/src/features/chat/host/session.ts @@ -5,11 +5,10 @@ import { ConnectionManager } from "../../../app-server/connection/connection-man import type { AppServerObservedQueryResult } from "../../../app-server/query/cache"; import { isStaleAppServerSharedQueryContextError } from "../../../app-server/query/shared-queries"; import { appServerQueryContextRawEquals, type AppServerQueryContext } from "../../../app-server/query/keys"; -import { renameThreadOnAppServer, threadRenameFromValue } from "../../../app-server/services/thread-rename"; import type { ModelMetadata } from "../../../domain/catalog/metadata"; import type { Thread } from "../../../domain/threads/model"; -import { getThreadTitle } from "../../../domain/threads/model"; +import { getThreadTitle, normalizeExplicitThreadName } from "../../../domain/threads/model"; import type { SharedServerMetadata } from "../../../domain/server/metadata"; import { ConnectionWorkTracker } from "../../../shared/lifecycle/connection-work"; import { shortThreadId } from "../../../utils"; @@ -640,15 +639,15 @@ export class ChatPanelSession implements ChatSurfaceHandle { stateStore: this.stateStore, titleService, renameGeneratedTitle: async (threadId, title, options) => { - const rename = threadRenameFromValue(title); - if (!rename) return false; + const name = normalizeExplicitThreadName(title); + if (!name) return false; const client = currentClient(); if (!client) return false; - const result = await renameThreadOnAppServer(client, threadId, rename); + await client.setThreadName(threadId, name); if (currentClient() !== client) return false; if (options.shouldPublish()) { - this.environment.plugin.threadCatalog.renameThreadInCatalog(threadId, result.name); + this.environment.plugin.threadCatalog.renameThreadInCatalog(threadId, name); } return true; }, @@ -1168,9 +1167,9 @@ export class ChatPanelSession implements ChatSurfaceHandle { void loadSharedThreadList(); }, refreshRateLimits: () => { - void serverMetadata.refreshPublishedRateLimits(); + void serverMetadata.refreshRateLimits({ preserveExistingOnFailure: true }); }, - refreshSkills: (forceReload) => void serverMetadata.refreshPublishedSkills(forceReload), + refreshSkills: (forceReload) => void serverMetadata.refreshSkills(forceReload), applyAppServerMetadataSnapshot: () => { serverMetadata.applyAppServerMetadataSnapshot(); }, @@ -1227,17 +1226,17 @@ export class ChatPanelSession implements ChatSurfaceHandle { isConnected: () => connection.isConnected(), }, metadata: { - refreshPublishedAppServerMetadata: () => serverMetadata.refreshPublishedAppServerMetadata(), - refreshPublishedSkills: (forceReload) => serverMetadata.refreshPublishedSkills(forceReload), + refreshAppServerMetadata: () => serverMetadata.refreshAppServerMetadata(), + refreshSkills: (forceReload) => serverMetadata.refreshSkills(forceReload), }, diagnostics: { - refreshPublishedDiagnosticProbes: (options) => serverDiagnostics.refreshPublishedDiagnosticProbes(options), + refreshDiagnosticProbes: (options) => serverDiagnostics.refreshDiagnosticProbes(options), }, loadSharedThreadList, scheduleDeferredDiagnostics: () => { this.deferredTasks.scheduleDiagnostics(() => { if (connection.isConnected()) { - void serverDiagnostics.refreshPublishedDiagnosticProbes({ appServerMetadataSnapshot: true }); + void serverDiagnostics.refreshDiagnosticProbes({ appServerMetadataSnapshot: true }); } }); }, diff --git a/src/features/threads/thread-operations.ts b/src/features/threads/thread-operations.ts index 20650d35..90276b9f 100644 --- a/src/features/threads/thread-operations.ts +++ b/src/features/threads/thread-operations.ts @@ -1,7 +1,7 @@ import type { AppServerClient } from "../../app-server/connection/client"; import { archiveThreadOnAppServer, type ArchiveThreadResult } from "../../app-server/services/thread-archive"; import type { ArchiveExportAdapter } from "../../app-server/services/thread-archive-markdown"; -import { renameThreadOnAppServer, threadRenameFromValue, type ThreadRename } from "../../app-server/services/thread-rename"; +import { normalizeExplicitThreadName } from "../../domain/threads/model"; import type { CodexPanelSettings } from "../../settings/model"; export interface ThreadOperationsHost { @@ -48,25 +48,16 @@ async function renameThread( value: string, options: RenameThreadOptions = {}, ): Promise { - const rename = threadRenameFromValue(value); - if (!rename) return false; + const name = normalizeExplicitThreadName(value); + if (!name) return false; await host.connection.ensureConnected(); - return renameConnectedThread(host, threadId, rename, options); -} - -async function renameConnectedThread( - host: ThreadOperationsHost, - threadId: string, - rename: ThreadRename, - options: RenameThreadOptions = {}, -): Promise { const client = host.connection.currentClient(); if (!client) return false; - const result = await renameThreadOnAppServer(client, threadId, rename); + await client.setThreadName(threadId, name); if (options.shouldPublish?.() ?? true) { - host.catalog.renameThreadInCatalog(threadId, result.name); + host.catalog.renameThreadInCatalog(threadId, name); } return true; } diff --git a/src/plugin-runtime.ts b/src/plugin-runtime.ts index ff548da6..38bf79e5 100644 --- a/src/plugin-runtime.ts +++ b/src/plugin-runtime.ts @@ -51,9 +51,6 @@ export class CodexPanelRuntime { this.threadCatalog = createSharedThreadCatalog({ queries: this.appServerSharedQueries, surfaces: { - invalidateThreadsFromOpenSurface: () => { - this.invalidateThreadsFromOpenSurface(); - }, applyThreadArchived: (threadId, archiveOptions) => { this.applyThreadArchived(threadId, archiveOptions); }, @@ -178,17 +175,6 @@ export class CodexPanelRuntime { } } - private invalidateThreadsFromOpenSurface(): void { - const chatSurface = this.connectedPanelSurface(); - if (chatSurface) { - void chatSurface.refreshSharedThreadList(); - return; - } - - const threadsView = this.threadsViews().at(0); - if (threadsView) void threadsView.refresh(); - } - private applyThreadArchived(threadId: string, archiveOptions: { closeOpenPanels?: boolean } = {}): void { const leavesToClose = archiveOptions.closeOpenPanels ? this.panels.panelLeavesForThread(threadId) : []; for (const view of this.panels.panelViews()) { @@ -207,14 +193,6 @@ export class CodexPanelRuntime { } } - private connectedPanelSurface(): (ChatWorkspacePanelSurface & ChatSharedThreadSurface) | null { - for (const view of this.panels.panelViews()) { - const surface: ChatWorkspacePanelSurface & ChatSharedThreadSurface = view.surface; - if (surface.openPanelSnapshot().connected) return surface; - } - return null; - } - private refreshThreadsViewLiveState(): void { for (const view of this.threadsViews()) { view.refreshLiveState(); diff --git a/src/settings/dynamic-data-controller.ts b/src/settings/dynamic-data-controller.ts index 7d0ff4ea..7f2d4048 100644 --- a/src/settings/dynamic-data-controller.ts +++ b/src/settings/dynamic-data-controller.ts @@ -36,7 +36,7 @@ type SettingsAppServerData = Pick< "modelsSnapshot" | "observeModelsResult" | "fetchModels" | "refreshModels" | "notifyContextChanged" >; -type SettingsThreadCatalog = Pick; +type SettingsThreadCatalog = Pick; function archivedThreadTitleForStatus(thread: Thread | undefined, threadId: string): string { return thread ? archivedThreadDisplayTitle(thread) : threadId; @@ -60,7 +60,10 @@ export interface SettingsDynamicDataSnapshot { export class SettingsDynamicDataController { private settingsDataAutoLoadStarted = false; - private settingsDynamicOperationId = 0; + private settingsRefreshOperationId = 0; + private modelsOperationId = 0; + private hooksOperationId = 0; + private archivedThreadsOperationId = 0; private settingsDataRefreshLifecycle: SettingsDataRefreshLifecycleState = { kind: "idle" }; private archivedThreads: Thread[] = []; @@ -99,7 +102,10 @@ export class SettingsDynamicDataController { resetSettingsDataContext(): void { this.settingsDataAutoLoadStarted = false; - this.settingsDynamicOperationId += 1; + this.settingsRefreshOperationId += 1; + this.modelsOperationId += 1; + this.hooksOperationId += 1; + this.archivedThreadsOperationId += 1; this.settingsDataRefreshLifecycle = { kind: "idle" }; this.models = [...(this.host.appServerData.modelsSnapshot() ?? [])]; this.modelsLifecycle = createSettingsDynamicSectionLifecycle(); @@ -124,7 +130,10 @@ export class SettingsDynamicDataController { async refreshSettingsData(options: { forceModels?: boolean } = {}): Promise { this.settingsDataAutoLoadStarted = true; - const operationId = this.nextSettingsDynamicOperationId(); + const operationId = this.nextSettingsRefreshOperationId(); + const modelsOperationId = this.nextModelsOperationId(); + const hooksOperationId = this.nextHooksOperationId(); + const archivedThreadsOperationId = this.nextArchivedThreadsOperationId(); this.settingsDataRefreshLifecycle = transitionSettingsDataRefreshLifecycle(this.settingsDataRefreshLifecycle, { type: "started", operationId, @@ -132,17 +141,17 @@ export class SettingsDynamicDataController { this.modelsLifecycle = transitionSettingsDynamicSectionLifecycle(this.modelsLifecycle, { type: "started", status: "Loading models...", - operationId, + operationId: modelsOperationId, }); this.archivedThreadsLifecycle = transitionSettingsDynamicSectionLifecycle(this.archivedThreadsLifecycle, { type: "started", status: "Loading archived threads...", - operationId, + operationId: archivedThreadsOperationId, }); this.hooksLifecycle = transitionSettingsDynamicSectionLifecycle(this.hooksLifecycle, { type: "started", status: "Loading hooks...", - operationId, + operationId: hooksOperationId, }); this.callbacks.display(); @@ -152,14 +161,16 @@ export class SettingsDynamicDataController { options.forceModels === false ? this.host.appServerData.fetchModels() : this.host.appServerData.refreshModels(), this.withSettingsConnection((client) => loadSettingsCompanionData(client, this.host.vaultPath)), ] as const); - if (this.isStaleSettingsDynamicOperation(operationId)) return; + if (this.isStaleSettingsRefreshOperation(operationId)) return; - if (modelsResult.status === "fulfilled") { + if (this.isStaleModelsOperation(modelsOperationId)) { + // A newer models operation owns this section. + } else if (modelsResult.status === "fulfilled") { this.models = [...modelsResult.value]; this.modelsLifecycle = transitionSettingsDynamicSectionLifecycle(this.modelsLifecycle, { type: "loaded", status: `Loaded ${String(modelsResult.value.length)} model${modelsResult.value.length === 1 ? "" : "s"}.`, - operationId, + operationId: modelsOperationId, }); } else if (isStaleAppServerSharedQueryContextError(modelsResult.reason)) { return; @@ -168,7 +179,7 @@ export class SettingsDynamicDataController { this.modelsLifecycle = transitionSettingsDynamicSectionLifecycle(this.modelsLifecycle, { type: "failed", status: `Could not load models: ${errorMessage(modelsResult.reason)}`, - operationId, + operationId: modelsOperationId, }); } @@ -180,60 +191,70 @@ export class SettingsDynamicDataController { archivedThreads: { ok: false as const, status: `Could not load archived threads: ${errorMessage(companionResult.reason)}` }, }; - if (companion.hooks.ok) { + if (this.isStaleHooksOperation(hooksOperationId)) { + // A newer hooks operation owns this section. + } else if (companion.hooks.ok) { this.hooks = companion.hooks.data.hooks; this.hookWarnings = companion.hooks.data.warnings; this.hookErrors = companion.hooks.data.errors; this.hooksLifecycle = transitionSettingsDynamicSectionLifecycle(this.hooksLifecycle, { type: "loaded", status: companion.hooks.status, - operationId, + operationId: hooksOperationId, }); } else { failedCount += 1; this.hooksLifecycle = transitionSettingsDynamicSectionLifecycle(this.hooksLifecycle, { type: "failed", status: companion.hooks.status, - operationId, + operationId: hooksOperationId, }); } - if (companion.archivedThreads.ok) { + if (this.isStaleArchivedThreadsOperation(archivedThreadsOperationId)) { + // A newer archived threads operation owns this section. + } else if (companion.archivedThreads.ok) { this.archivedThreads = companion.archivedThreads.data; this.archivedThreadsLifecycle = transitionSettingsDynamicSectionLifecycle(this.archivedThreadsLifecycle, { type: "loaded", status: companion.archivedThreads.status, - operationId, + operationId: archivedThreadsOperationId, }); } else { failedCount += 1; this.archivedThreadsLifecycle = transitionSettingsDynamicSectionLifecycle(this.archivedThreadsLifecycle, { type: "failed", status: companion.archivedThreads.status, - operationId, + operationId: archivedThreadsOperationId, }); } } catch (error) { - if (this.isStaleSettingsDynamicOperation(operationId)) return; + if (this.isStaleSettingsRefreshOperation(operationId)) return; failedCount = 3; const message = errorMessage(error); - this.modelsLifecycle = transitionSettingsDynamicSectionLifecycle(this.modelsLifecycle, { - type: "failed", - status: `Could not load models: ${message}`, - operationId, - }); - this.hooksLifecycle = transitionSettingsDynamicSectionLifecycle(this.hooksLifecycle, { - type: "failed", - status: `Could not load hooks: ${message}`, - operationId, - }); - this.archivedThreadsLifecycle = transitionSettingsDynamicSectionLifecycle(this.archivedThreadsLifecycle, { - type: "failed", - status: `Could not load archived threads: ${message}`, - operationId, - }); + if (!this.isStaleModelsOperation(modelsOperationId)) { + this.modelsLifecycle = transitionSettingsDynamicSectionLifecycle(this.modelsLifecycle, { + type: "failed", + status: `Could not load models: ${message}`, + operationId: modelsOperationId, + }); + } + if (!this.isStaleHooksOperation(hooksOperationId)) { + this.hooksLifecycle = transitionSettingsDynamicSectionLifecycle(this.hooksLifecycle, { + type: "failed", + status: `Could not load hooks: ${message}`, + operationId: hooksOperationId, + }); + } + if (!this.isStaleArchivedThreadsOperation(archivedThreadsOperationId)) { + this.archivedThreadsLifecycle = transitionSettingsDynamicSectionLifecycle(this.archivedThreadsLifecycle, { + type: "failed", + status: `Could not load archived threads: ${message}`, + operationId: archivedThreadsOperationId, + }); + } } finally { - const staleOperation = this.isStaleSettingsDynamicOperation(operationId); + const staleOperation = this.isStaleSettingsRefreshOperation(operationId); this.settingsDataRefreshLifecycle = transitionSettingsDataRefreshLifecycle(this.settingsDataRefreshLifecycle, { type: "completed", failedCount, @@ -266,7 +287,7 @@ export class SettingsDynamicDataController { } async trustHook(hook: HookItem): Promise { - const operationId = this.nextSettingsDynamicOperationId(); + const operationId = this.nextHooksOperationId(); this.hooksLifecycle = transitionSettingsDynamicSectionLifecycle(this.hooksLifecycle, { type: "started", status: "Loading hooks...", @@ -275,7 +296,7 @@ export class SettingsDynamicDataController { this.callbacks.display(); try { await this.withSettingsConnection((client) => trustHookItem(client, hook)); - if (this.isStaleSettingsDynamicOperation(operationId)) return; + if (this.isStaleHooksOperation(operationId)) return; this.hooksLifecycle = transitionSettingsDynamicSectionLifecycle(this.hooksLifecycle, { type: "loaded", status: "Trusted hook definition.", @@ -283,7 +304,7 @@ export class SettingsDynamicDataController { }); await this.loadHooks(); } catch (error) { - if (this.isStaleSettingsDynamicOperation(operationId)) return; + if (this.isStaleHooksOperation(operationId)) return; this.hooksLifecycle = transitionSettingsDynamicSectionLifecycle(this.hooksLifecycle, { type: "failed", status: `Could not trust hook: ${errorMessage(error)}`, @@ -295,7 +316,7 @@ export class SettingsDynamicDataController { } async setHookEnabled(hook: HookItem, enabled: boolean): Promise { - const operationId = this.nextSettingsDynamicOperationId(); + const operationId = this.nextHooksOperationId(); this.hooksLifecycle = transitionSettingsDynamicSectionLifecycle(this.hooksLifecycle, { type: "started", status: "Loading hooks...", @@ -304,7 +325,7 @@ export class SettingsDynamicDataController { this.callbacks.display(); try { await this.withSettingsConnection((client) => setHookItemEnabled(client, hook, enabled)); - if (this.isStaleSettingsDynamicOperation(operationId)) return; + if (this.isStaleHooksOperation(operationId)) return; this.hooksLifecycle = transitionSettingsDynamicSectionLifecycle(this.hooksLifecycle, { type: "loaded", status: enabled ? "Enabled hook." : "Disabled hook.", @@ -312,7 +333,7 @@ export class SettingsDynamicDataController { }); await this.loadHooks(); } catch (error) { - if (this.isStaleSettingsDynamicOperation(operationId)) return; + if (this.isStaleHooksOperation(operationId)) return; this.hooksLifecycle = transitionSettingsDynamicSectionLifecycle(this.hooksLifecycle, { type: "failed", status: `Could not update hook: ${errorMessage(error)}`, @@ -324,7 +345,7 @@ export class SettingsDynamicDataController { } async restoreArchivedThread(threadId: string): Promise { - const operationId = this.nextSettingsDynamicOperationId(); + const operationId = this.nextArchivedThreadsOperationId(); this.archivedThreadsLifecycle = transitionSettingsDynamicSectionLifecycle(this.archivedThreadsLifecycle, { type: "started", status: "Loading archived threads...", @@ -333,16 +354,23 @@ export class SettingsDynamicDataController { this.callbacks.display(); try { const restoredThread = await this.withSettingsConnection((client) => restoreArchivedThreadOnAppServer(client, threadId)); - if (this.isStaleSettingsDynamicOperation(operationId)) return; + if (this.isStaleArchivedThreadsOperation(operationId)) return; this.archivedThreads = this.archivedThreads.filter((thread) => thread.id !== threadId); this.archivedThreadsLifecycle = transitionSettingsDynamicSectionLifecycle(this.archivedThreadsLifecycle, { type: "loaded", status: `Restored "${archivedThreadDisplayTitle(restoredThread)}".`, operationId, }); - this.host.threadCatalog.invalidateThreadsFromOpenSurface(); + this.callbacks.display(); + try { + await this.host.threadCatalog.refreshActiveThreads(); + } catch (error) { + if (!this.isStaleArchivedThreadsOperation(operationId) && !isStaleAppServerSharedQueryContextError(error)) { + this.callbacks.notify("Could not refresh active Codex threads."); + } + } } catch (error) { - if (this.isStaleSettingsDynamicOperation(operationId)) return; + if (this.isStaleArchivedThreadsOperation(operationId)) return; this.archivedThreadsLifecycle = transitionSettingsDynamicSectionLifecycle(this.archivedThreadsLifecycle, { type: "failed", status: `Could not restore archived thread: ${errorMessage(error)}`, @@ -350,12 +378,12 @@ export class SettingsDynamicDataController { }); this.callbacks.notify("Could not restore archived Codex thread."); } finally { - if (!this.isStaleSettingsDynamicOperation(operationId)) this.callbacks.display(); + if (!this.isStaleArchivedThreadsOperation(operationId)) this.callbacks.display(); } } async deleteArchivedThread(threadId: string): Promise { - const operationId = this.nextSettingsDynamicOperationId(); + const operationId = this.nextArchivedThreadsOperationId(); const title = archivedThreadTitleForStatus( this.archivedThreads.find((thread) => thread.id === threadId), threadId, @@ -368,16 +396,15 @@ export class SettingsDynamicDataController { this.callbacks.display(); try { await this.withSettingsConnection((client) => deleteArchivedThreadOnAppServer(client, threadId)); - if (this.isStaleSettingsDynamicOperation(operationId)) return; + if (this.isStaleArchivedThreadsOperation(operationId)) return; this.archivedThreads = this.archivedThreads.filter((thread) => thread.id !== threadId); this.archivedThreadsLifecycle = transitionSettingsDynamicSectionLifecycle(this.archivedThreadsLifecycle, { type: "loaded", status: `Deleted "${title}".`, operationId, }); - this.host.threadCatalog.invalidateThreadsFromOpenSurface(); } catch (error) { - if (this.isStaleSettingsDynamicOperation(operationId)) return; + if (this.isStaleArchivedThreadsOperation(operationId)) return; this.archivedThreadsLifecycle = transitionSettingsDynamicSectionLifecycle(this.archivedThreadsLifecycle, { type: "failed", status: `Could not delete archived thread: ${errorMessage(error)}`, @@ -385,7 +412,7 @@ export class SettingsDynamicDataController { }); this.callbacks.notify("Could not delete archived Codex thread."); } finally { - if (!this.isStaleSettingsDynamicOperation(operationId)) this.callbacks.display(); + if (!this.isStaleArchivedThreadsOperation(operationId)) this.callbacks.display(); } } @@ -407,7 +434,7 @@ export class SettingsDynamicDataController { } private async loadHooks(): Promise { - const operationId = this.nextSettingsDynamicOperationId(); + const operationId = this.nextHooksOperationId(); this.hooksLifecycle = transitionSettingsDynamicSectionLifecycle(this.hooksLifecycle, { type: "started", status: "Loading hooks...", @@ -416,7 +443,7 @@ export class SettingsDynamicDataController { this.callbacks.display(); try { const hooks = await this.withSettingsConnection((client) => loadHookData(client, this.host.vaultPath)); - if (this.isStaleSettingsDynamicOperation(operationId)) return; + if (this.isStaleHooksOperation(operationId)) return; this.hooks = hooks.hooks; this.hookWarnings = hooks.warnings; this.hookErrors = hooks.errors; @@ -426,7 +453,7 @@ export class SettingsDynamicDataController { operationId, }); } catch (error) { - if (this.isStaleSettingsDynamicOperation(operationId)) return; + if (this.isStaleHooksOperation(operationId)) return; this.hooksLifecycle = transitionSettingsDynamicSectionLifecycle(this.hooksLifecycle, { type: "failed", status: `Could not load hooks: ${errorMessage(error)}`, @@ -434,7 +461,7 @@ export class SettingsDynamicDataController { }); this.callbacks.notify("Could not load Codex hooks."); } finally { - if (!this.isStaleSettingsDynamicOperation(operationId)) this.callbacks.display(); + if (!this.isStaleHooksOperation(operationId)) this.callbacks.display(); } } @@ -444,13 +471,40 @@ export class SettingsDynamicDataController { }); } - private nextSettingsDynamicOperationId(): number { - this.settingsDynamicOperationId += 1; - return this.settingsDynamicOperationId; + private nextSettingsRefreshOperationId(): number { + this.settingsRefreshOperationId += 1; + return this.settingsRefreshOperationId; } - private isStaleSettingsDynamicOperation(operationId: number): boolean { - return operationId !== this.settingsDynamicOperationId; + private nextModelsOperationId(): number { + this.modelsOperationId += 1; + return this.modelsOperationId; + } + + private nextHooksOperationId(): number { + this.hooksOperationId += 1; + return this.hooksOperationId; + } + + private nextArchivedThreadsOperationId(): number { + this.archivedThreadsOperationId += 1; + return this.archivedThreadsOperationId; + } + + private isStaleSettingsRefreshOperation(operationId: number): boolean { + return operationId !== this.settingsRefreshOperationId; + } + + private isStaleModelsOperation(operationId: number): boolean { + return operationId !== this.modelsOperationId; + } + + private isStaleHooksOperation(operationId: number): boolean { + return operationId !== this.hooksOperationId; + } + + private isStaleArchivedThreadsOperation(operationId: number): boolean { + return operationId !== this.archivedThreadsOperationId; } private selectedModel(modelIdOrName: string | null): ModelMetadata | null { diff --git a/src/workspace/shared-thread-catalog.ts b/src/workspace/shared-thread-catalog.ts index 84cb9640..cf6ec8be 100644 --- a/src/workspace/shared-thread-catalog.ts +++ b/src/workspace/shared-thread-catalog.ts @@ -3,7 +3,6 @@ import type { AppServerObservedQueryResult } from "../app-server/query/cache"; import type { AppServerSharedQueries } from "../app-server/query/shared-queries"; interface ThreadSurfaceActions { - invalidateThreadsFromOpenSurface(): void; applyThreadArchived(threadId: string, options?: { closeOpenPanels?: boolean }): void; applyThreadRenamed(threadId: string, name: string | null): void; refreshThreadsViewLiveState(): void; @@ -23,7 +22,6 @@ export interface SharedThreadCatalog { listener: (result: AppServerObservedQueryResult) => void, options?: { emitCurrent?: boolean }, ): () => void; - invalidateThreadsFromOpenSurface(): void; renameThreadInCatalog(threadId: string, name: string | null): void; archiveThreadInCatalog(threadId: string, options?: { closeOpenPanels?: boolean }): void; refreshThreadsViewLiveState(): void; @@ -38,9 +36,6 @@ export function createSharedThreadCatalog(options: SharedThreadCatalogOptions): options.queries.setActiveThreads(threads); }, observeActiveThreadsResult: (listener, observeOptions) => options.queries.observeActiveThreadsResult(listener, observeOptions), - invalidateThreadsFromOpenSurface: () => { - options.surfaces.invalidateThreadsFromOpenSurface(); - }, renameThreadInCatalog: (threadId, name) => { options.queries.updateActiveThreads((current) => { return current ? current.map((thread) => (thread.id === threadId ? { ...thread, name } : thread)) : null; diff --git a/tests/app-server/thread-rename.test.ts b/tests/app-server/thread-rename.test.ts deleted file mode 100644 index b05bbf30..00000000 --- a/tests/app-server/thread-rename.test.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; - -import type { AppServerClient } from "../../src/app-server/connection/client"; -import { renameThreadOnAppServer, threadRenameFromValue } from "../../src/app-server/services/thread-rename"; - -describe("thread rename operation", () => { - it("normalizes thread names before saving them", async () => { - const setThreadName = vi.fn().mockResolvedValue({}); - const client = fakeClient({ setThreadName }); - - const rename = threadRenameFromValue(" Renamed thread "); - - expect(rename).toEqual({ name: "Renamed thread" }); - if (!rename) throw new Error("Expected normalized rename"); - await expect(renameThreadOnAppServer(client, "thread", rename)).resolves.toEqual({ name: "Renamed thread" }); - - expect(setThreadName).toHaveBeenCalledWith("thread", "Renamed thread"); - }); - - it("exposes normalized rename construction for surface guards", () => { - expect(threadRenameFromValue(" Saved title ")).toEqual({ name: "Saved title" }); - expect(threadRenameFromValue(" ")).toBeNull(); - }); -}); - -function fakeClient(options: { setThreadName: ReturnType }): AppServerClient { - return { - setThreadName: options.setThreadName, - } as unknown as AppServerClient; -} diff --git a/tests/features/chat/connection/connection-controller.test.ts b/tests/features/chat/connection/connection-controller.test.ts index 9200c887..a3f8506f 100644 --- a/tests/features/chat/connection/connection-controller.test.ts +++ b/tests/features/chat/connection/connection-controller.test.ts @@ -25,15 +25,15 @@ function createController({ connected = false, client = {} as AppServerClient } currentClient: () => currentClient, isConnected: () => Boolean(currentClient), }; - const refreshPublishedAppServerMetadata = vi.fn().mockResolvedValue(null); - const refreshPublishedDiagnosticProbes = vi.fn().mockResolvedValue(undefined); - const refreshPublishedSkills = vi.fn().mockResolvedValue(undefined); + const refreshAppServerMetadata = vi.fn().mockResolvedValue(null); + const refreshDiagnosticProbes = vi.fn().mockResolvedValue(undefined); + const refreshSkills = vi.fn().mockResolvedValue(undefined); const metadata = { - refreshPublishedAppServerMetadata, - refreshPublishedSkills, + refreshAppServerMetadata, + refreshSkills, } satisfies ChatConnectionMetadataActions; const diagnostics = { - refreshPublishedDiagnosticProbes, + refreshDiagnosticProbes, } satisfies ChatConnectionDiagnosticsActions; const host: ChatConnectionControllerHost = { stateStore, @@ -57,15 +57,15 @@ function createController({ connected = false, client = {} as AppServerClient } connect, controller: createChatConnectionController(host), host, - refreshPublishedAppServerMetadata, - refreshPublishedDiagnosticProbes, + refreshAppServerMetadata, + refreshDiagnosticProbes, stateStore, }; } describe("ChatConnectionController", () => { it("connects once and publishes startup metadata", async () => { - const { connect, controller, host, refreshPublishedAppServerMetadata, stateStore } = createController(); + const { connect, controller, host, refreshAppServerMetadata, stateStore } = createController(); await controller.ensureConnected(); @@ -76,29 +76,29 @@ describe("ChatConnectionController", () => { platformOs: "macos", userAgent: "test", }); - expect(refreshPublishedAppServerMetadata).toHaveBeenCalledOnce(); + expect(refreshAppServerMetadata).toHaveBeenCalledOnce(); expect(host.loadSharedThreadList).toHaveBeenCalledOnce(); expect(host.scheduleDeferredDiagnostics).toHaveBeenCalledOnce(); expect(host.setStatus).toHaveBeenCalledWith("Connected.", { kind: "connected" }); }); it("refreshes metadata before metadata-backed diagnostics", async () => { - const { controller, host, refreshPublishedAppServerMetadata, refreshPublishedDiagnosticProbes } = createController({ connected: true }); + const { controller, host, refreshAppServerMetadata, refreshDiagnosticProbes } = createController({ connected: true }); await controller.refreshDiagnostics(); expect(host.clearDeferredDiagnostics).toHaveBeenCalledTimes(2); - expect(refreshPublishedAppServerMetadata).toHaveBeenCalledOnce(); - expect(refreshPublishedDiagnosticProbes).toHaveBeenCalledWith({ appServerMetadataSnapshot: true }); + expect(refreshAppServerMetadata).toHaveBeenCalledOnce(); + expect(refreshDiagnosticProbes).toHaveBeenCalledWith({ appServerMetadataSnapshot: true }); }); it("refreshes active threads without refreshing metadata", async () => { - const { controller, host, refreshPublishedAppServerMetadata } = createController({ connected: true }); + const { controller, host, refreshAppServerMetadata } = createController({ connected: true }); await controller.fetchActiveThreads(); expect(host.loadSharedThreadList).toHaveBeenCalledOnce(); - expect(refreshPublishedAppServerMetadata).not.toHaveBeenCalled(); + expect(refreshAppServerMetadata).not.toHaveBeenCalled(); }); it("clears disconnected connection state on server exit while keeping last startup metadata", () => { diff --git a/tests/features/chat/connection/server-actions/server-actions.test.ts b/tests/features/chat/connection/server-actions/server-actions.test.ts index fdfc93fd..ea9b006d 100644 --- a/tests/features/chat/connection/server-actions/server-actions.test.ts +++ b/tests/features/chat/connection/server-actions/server-actions.test.ts @@ -423,7 +423,7 @@ describe("chat server actions", () => { updateAppServerMetadata, }); - const refreshing = diagnostics.refreshPublishedDiagnosticProbes({ appServerMetadataSnapshot: true }); + const refreshing = diagnostics.refreshDiagnosticProbes({ appServerMetadataSnapshot: true }); currentClient = secondClient; hooksRefresh.resolve({ data: [{ cwd: "/vault", hooks: [{}] }] }); @@ -473,7 +473,7 @@ describe("chat server actions", () => { refreshAppServerMetadata, }); - const refreshing = controller.refreshPublishedAppServerMetadata(); + const refreshing = controller.refreshAppServerMetadata(); await expect(refreshing).resolves.toBeNull(); expect(stateStore.getState().connection.availableModels).toEqual([]); @@ -543,7 +543,7 @@ describe("chat server actions", () => { refreshAppServerMetadata: async () => null, }); - const refreshing = controller.refreshPublishedSkills(true); + const refreshing = controller.refreshSkills(true); currentClient = secondClient; skillRefresh.resolve({ data: [{ skills: [skillFixture("stale-skill")] }] }); @@ -571,7 +571,7 @@ describe("chat server actions", () => { refreshAppServerMetadata: async () => null, }); - await controller.refreshPublishedRateLimits(); + await controller.refreshRateLimits({ preserveExistingOnFailure: true }); expect(stateStore.getState().connection.rateLimit).toMatchObject({ primary: { usedPercent: 64 } }); expect(cachedMetadata.current?.rateLimit).toStrictEqual(rateLimit); @@ -597,7 +597,7 @@ describe("chat server actions", () => { refreshAppServerMetadata: async () => null, }); - await controller.refreshPublishedRateLimits(); + await controller.refreshRateLimits({ preserveExistingOnFailure: true }); expect(stateStore.getState().connection.rateLimit).toBe(previousRateLimit); expect(stateStore.getState().connection.serverDiagnostics.probes["account/rateLimits/read"]).toMatchObject({ status: "failed" }); @@ -622,7 +622,7 @@ describe("chat server actions", () => { refreshAppServerMetadata: async () => null, }); - const refreshing = controller.refreshPublishedRateLimits(); + const refreshing = controller.refreshRateLimits({ preserveExistingOnFailure: true }); currentClient = secondClient; rateLimitRefresh.resolve({ rateLimits: rateLimitFixture({ primary: { usedPercent: 88, windowDurationMins: 300, resetsAt: null } }), diff --git a/tests/features/chat/conversation/turns/slash-command-execution.test.ts b/tests/features/chat/conversation/turns/slash-command-execution.test.ts index 5a978226..ef8a8422 100644 --- a/tests/features/chat/conversation/turns/slash-command-execution.test.ts +++ b/tests/features/chat/conversation/turns/slash-command-execution.test.ts @@ -20,26 +20,32 @@ function context(overrides: Partial = {}): SlashCo input: [{ type: "text", text: "referenced" }], referencedThread: { threadId: "thread-2", title: "Referenced", includedTurns: 1, turnLimit: 20 }, }), - forkThread: vi.fn().mockResolvedValue(undefined), - rollbackThread: vi.fn().mockResolvedValue(undefined), - compactThread: vi.fn().mockResolvedValue(undefined), - archiveThread: vi.fn().mockResolvedValue(undefined), - renameThread: vi.fn().mockResolvedValue(undefined), + threadActions: { + forkThread: vi.fn().mockResolvedValue(undefined), + rollbackThread: vi.fn().mockResolvedValue(undefined), + compactThread: vi.fn().mockResolvedValue(undefined), + archiveThread: vi.fn().mockResolvedValue(undefined), + renameThread: vi.fn().mockResolvedValue(true), + }, reconnect: vi.fn().mockResolvedValue(undefined), - toggleFastMode: vi.fn(), - toggleCollaborationMode: vi.fn(), - toggleAutoReview: vi.fn(), addSystemMessage: vi.fn(), addStructuredSystemMessage: vi.fn(), - requestModel: vi.fn(), - resetModelToConfig: vi.fn(), - requestReasoningEffort: vi.fn(), - resetReasoningEffortToConfig: vi.fn(), + runtimeSettings: { + toggleFastMode: vi.fn(), + toggleCollaborationMode: vi.fn(), + toggleAutoReview: vi.fn(), + requestModel: vi.fn(), + resetModelToConfig: vi.fn(), + requestReasoningEffort: vi.fn(), + resetReasoningEffortToConfig: vi.fn(), + }, supportedReasoningEfforts: () => ["low", "medium", "high"], - activeGoal: vi.fn(() => null), - setGoalObjective: vi.fn().mockResolvedValue(true), - setGoalStatus: vi.fn().mockResolvedValue(true), - clearGoal: vi.fn().mockResolvedValue(true), + goals: { + activeGoal: vi.fn(() => null), + setObjective: vi.fn().mockResolvedValue(true), + setStatus: vi.fn().mockResolvedValue(true), + clear: vi.fn().mockResolvedValue(true), + }, statusSummaryLines: () => ["status"], connectionDiagnosticDetails: () => [{ title: "Process", rows: [{ key: "connection", value: "connected" }] }], mcpStatusLines: vi.fn().mockResolvedValue(["mcp"]), @@ -173,7 +179,7 @@ describe("slash commands", () => { await executeSlashCommand("fork", "", ctx); - expect(ctx.forkThread).toHaveBeenCalledWith("active-thread"); + expect(ctx.threadActions.forkThread).toHaveBeenCalledWith("active-thread"); }); it("rejects /fork arguments", async () => { @@ -181,7 +187,7 @@ describe("slash commands", () => { await executeSlashCommand("fork", "anything", ctx); - expect(ctx.forkThread).not.toHaveBeenCalled(); + expect(ctx.threadActions.forkThread).not.toHaveBeenCalled(); expect(ctx.addSystemMessage).toHaveBeenCalledWith("/fork does not take arguments. Usage: /fork"); }); @@ -190,7 +196,7 @@ describe("slash commands", () => { await executeSlashCommand("rollback", "", ctx); - expect(ctx.rollbackThread).toHaveBeenCalledWith("active-thread"); + expect(ctx.threadActions.rollbackThread).toHaveBeenCalledWith("active-thread"); }); it("rejects /rollback without an active thread", async () => { @@ -198,7 +204,7 @@ describe("slash commands", () => { await executeSlashCommand("rollback", "", ctx); - expect(ctx.rollbackThread).not.toHaveBeenCalled(); + expect(ctx.threadActions.rollbackThread).not.toHaveBeenCalled(); expect(ctx.addSystemMessage).toHaveBeenCalledWith("No active thread to roll back."); }); @@ -207,7 +213,7 @@ describe("slash commands", () => { await executeSlashCommand("rollback", "", ctx); - expect(ctx.rollbackThread).not.toHaveBeenCalled(); + expect(ctx.threadActions.rollbackThread).not.toHaveBeenCalled(); expect(ctx.addSystemMessage).toHaveBeenCalledWith("Interrupt the current turn before rolling back."); }); @@ -216,7 +222,7 @@ describe("slash commands", () => { await executeSlashCommand("rollback", "2", ctx); - expect(ctx.rollbackThread).not.toHaveBeenCalled(); + expect(ctx.threadActions.rollbackThread).not.toHaveBeenCalled(); expect(ctx.addSystemMessage).toHaveBeenCalledWith("/rollback does not take arguments. Usage: /rollback"); }); @@ -225,13 +231,14 @@ describe("slash commands", () => { const result = await executeSlashCommand("plan", "", ctx); - expect(ctx.toggleCollaborationMode).toHaveBeenCalledOnce(); + expect(ctx.runtimeSettings.toggleCollaborationMode).toHaveBeenCalledOnce(); expect(result).toBeUndefined(); }); it("shows the current goal for /goal", async () => { const currentGoal = goal(); - const ctx = context({ activeGoal: vi.fn(() => currentGoal) }); + const ctx = context(); + ctx.goals.activeGoal = vi.fn(() => currentGoal); await executeSlashCommand("goal", "", ctx); @@ -253,21 +260,23 @@ describe("slash commands", () => { it("sets, pauses, resumes, and clears goals", async () => { const currentGoal = goal(); - const ctx = context({ activeGoal: vi.fn(() => currentGoal) }); + const ctx = context(); + ctx.goals.activeGoal = vi.fn(() => currentGoal); await executeSlashCommand("goal", "set Ship this", ctx); await executeSlashCommand("goal", "pause", ctx); await executeSlashCommand("goal", "resume", ctx); await executeSlashCommand("goal", "clear", ctx); - expect(ctx.setGoalObjective).toHaveBeenCalledWith("thread-1", "Ship this", null); - expect(ctx.setGoalStatus).toHaveBeenCalledWith("thread-1", "paused"); - expect(ctx.setGoalStatus).toHaveBeenCalledWith("thread-1", "active"); - expect(ctx.clearGoal).toHaveBeenCalledWith("thread-1"); + expect(ctx.goals.setObjective).toHaveBeenCalledWith("thread-1", "Ship this", null); + expect(ctx.goals.setStatus).toHaveBeenCalledWith("thread-1", "paused"); + expect(ctx.goals.setStatus).toHaveBeenCalledWith("thread-1", "active"); + expect(ctx.goals.clear).toHaveBeenCalledWith("thread-1"); }); it("loads the current goal into the composer for /goal edit", async () => { - const ctx = context({ activeGoal: vi.fn(() => goal({ objective: "Ship goal support" })) }); + const ctx = context(); + ctx.goals.activeGoal = vi.fn(() => goal({ objective: "Ship goal support" })); const result = await executeSlashCommand("goal", "edit", ctx); @@ -287,21 +296,22 @@ describe("slash commands", () => { await executeSlashCommand("goal", "set", ctx); - expect(ctx.setGoalObjective).not.toHaveBeenCalled(); + expect(ctx.goals.setObjective).not.toHaveBeenCalled(); expect(ctx.addSystemMessage).toHaveBeenCalledWith("/goal set requires an objective. Usage: /goal set "); }); it("rejects extra arguments for goal subcommands without free text", async () => { const currentGoal = goal(); - const ctx = context({ activeGoal: vi.fn(() => currentGoal) }); + const ctx = context(); + ctx.goals.activeGoal = vi.fn(() => currentGoal); await executeSlashCommand("goal", "edit later", ctx); await executeSlashCommand("goal", "pause later", ctx); await executeSlashCommand("goal", "resume now", ctx); await executeSlashCommand("goal", "clear please", ctx); - expect(ctx.setGoalStatus).not.toHaveBeenCalled(); - expect(ctx.clearGoal).not.toHaveBeenCalled(); + expect(ctx.goals.setStatus).not.toHaveBeenCalled(); + expect(ctx.goals.clear).not.toHaveBeenCalled(); expect(ctx.addSystemMessage).toHaveBeenCalledWith("/goal edit does not take arguments. Usage: /goal edit"); expect(ctx.addSystemMessage).toHaveBeenCalledWith("/goal pause does not take arguments. Usage: /goal pause"); expect(ctx.addSystemMessage).toHaveBeenCalledWith("/goal resume does not take arguments. Usage: /goal resume"); @@ -324,7 +334,7 @@ describe("slash commands", () => { await executeSlashCommand("goal", "set Ship this", ctx); expect(ctx.startThreadForGoal).toHaveBeenCalledWith("Ship this"); - expect(ctx.setGoalObjective).toHaveBeenCalledWith("thread-new", "Ship this", null); + expect(ctx.goals.setObjective).toHaveBeenCalledWith("thread-new", "Ship this", null); expect(ctx.addSystemMessage).not.toHaveBeenCalledWith("No active thread for goal management."); }); @@ -334,7 +344,7 @@ describe("slash commands", () => { await executeSlashCommand("goal", "pause", ctx); expect(ctx.startThreadForGoal).not.toHaveBeenCalled(); - expect(ctx.setGoalStatus).not.toHaveBeenCalled(); + expect(ctx.goals.setStatus).not.toHaveBeenCalled(); expect(ctx.addSystemMessage).toHaveBeenCalledWith("No active thread for goal management."); }); @@ -343,7 +353,7 @@ describe("slash commands", () => { const result = await executeSlashCommand("plan", "OK、実装してください", ctx); - expect(ctx.toggleCollaborationMode).toHaveBeenCalledOnce(); + expect(ctx.runtimeSettings.toggleCollaborationMode).toHaveBeenCalledOnce(); expect(result).toEqual({ sendText: "OK、実装してください" }); }); @@ -352,7 +362,7 @@ describe("slash commands", () => { const result = await executeSlashCommand("auto-review", "", ctx); - expect(ctx.toggleAutoReview).toHaveBeenCalledOnce(); + expect(ctx.runtimeSettings.toggleAutoReview).toHaveBeenCalledOnce(); expect(result).toBeUndefined(); }); @@ -361,7 +371,7 @@ describe("slash commands", () => { await executeSlashCommand("auto-review", "この依頼からお願いします", ctx); - expect(ctx.toggleAutoReview).not.toHaveBeenCalled(); + expect(ctx.runtimeSettings.toggleAutoReview).not.toHaveBeenCalled(); expect(ctx.addSystemMessage).toHaveBeenCalledWith("/auto-review does not take arguments. Usage: /auto-review"); }); @@ -370,7 +380,7 @@ describe("slash commands", () => { await executeSlashCommand("compact", "ignored for now", ctx); - expect(ctx.compactThread).not.toHaveBeenCalled(); + expect(ctx.threadActions.compactThread).not.toHaveBeenCalled(); expect(ctx.addSystemMessage).toHaveBeenCalledWith("/compact does not take arguments. Usage: /compact"); }); @@ -379,7 +389,7 @@ describe("slash commands", () => { await executeSlashCommand("archive", "", ctx); - expect(ctx.archiveThread).not.toHaveBeenCalled(); + expect(ctx.threadActions.archiveThread).not.toHaveBeenCalled(); expect(ctx.addSystemMessage).toHaveBeenCalledWith("/archive requires a thread. Usage: /archive "); }); @@ -390,7 +400,7 @@ describe("slash commands", () => { await executeSlashCommand("archive", "thread-beta", ctx); - expect(ctx.archiveThread).toHaveBeenCalledWith("thread-beta"); + expect(ctx.threadActions.archiveThread).toHaveBeenCalledWith("thread-beta"); }); it("rejects /archive without a thread before active-thread checks", async () => { @@ -398,7 +408,7 @@ describe("slash commands", () => { await executeSlashCommand("archive", "", ctx); - expect(ctx.archiveThread).not.toHaveBeenCalled(); + expect(ctx.threadActions.archiveThread).not.toHaveBeenCalled(); expect(ctx.addSystemMessage).toHaveBeenCalledWith("/archive requires a thread. Usage: /archive "); }); @@ -407,7 +417,7 @@ describe("slash commands", () => { await executeSlashCommand("archive", "thread-1", ctx); - expect(ctx.archiveThread).not.toHaveBeenCalled(); + expect(ctx.threadActions.archiveThread).not.toHaveBeenCalled(); expect(ctx.addSystemMessage).toHaveBeenCalledWith("Finish or interrupt the current turn before archiving threads."); }); @@ -418,7 +428,7 @@ describe("slash commands", () => { await executeSlashCommand("archive", "Draft", ctx); - expect(ctx.archiveThread).not.toHaveBeenCalled(); + expect(ctx.threadActions.archiveThread).not.toHaveBeenCalled(); expect(ctx.addSystemMessage).toHaveBeenCalledWith("Multiple matching threads: Draft, Draft notes"); }); @@ -429,7 +439,7 @@ describe("slash commands", () => { await executeSlashCommand("rename", "thread-beta New Beta Name", ctx); - expect(ctx.renameThread).toHaveBeenCalledWith("thread-beta", "New Beta Name"); + expect(ctx.threadActions.renameThread).toHaveBeenCalledWith("thread-beta", "New Beta Name"); }); it("trims /rename names before saving", async () => { @@ -439,7 +449,7 @@ describe("slash commands", () => { await executeSlashCommand("rename", "thread-beta New Beta Name ", ctx); - expect(ctx.renameThread).toHaveBeenCalledWith("thread-beta", "New Beta Name"); + expect(ctx.threadActions.renameThread).toHaveBeenCalledWith("thread-beta", "New Beta Name"); }); it("rejects /rename without a thread and name", async () => { @@ -447,7 +457,7 @@ describe("slash commands", () => { await executeSlashCommand("rename", "thread-1", ctx); - expect(ctx.renameThread).not.toHaveBeenCalled(); + expect(ctx.threadActions.renameThread).not.toHaveBeenCalled(); expect(ctx.addSystemMessage).toHaveBeenCalledWith("/rename requires a thread and a name. Usage: /rename "); }); @@ -456,7 +466,7 @@ describe("slash commands", () => { await executeSlashCommand("rename", "thread-1 ", ctx); - expect(ctx.renameThread).not.toHaveBeenCalled(); + expect(ctx.threadActions.renameThread).not.toHaveBeenCalled(); expect(ctx.addSystemMessage).toHaveBeenCalledWith("/rename requires a thread and a name. Usage: /rename "); }); @@ -467,7 +477,7 @@ describe("slash commands", () => { await executeSlashCommand("rename", "Draft New name", ctx); - expect(ctx.renameThread).not.toHaveBeenCalled(); + expect(ctx.threadActions.renameThread).not.toHaveBeenCalled(); expect(ctx.addSystemMessage).toHaveBeenCalledWith("Multiple matching threads: Draft, Draft notes"); }); @@ -601,22 +611,21 @@ describe("slash commands", () => { await executeSlashCommand("reasoning", "extreme", ctx); - expect(ctx.requestReasoningEffort).not.toHaveBeenCalled(); - expect(ctx.resetReasoningEffortToConfig).not.toHaveBeenCalled(); + expect(ctx.runtimeSettings.requestReasoningEffort).not.toHaveBeenCalled(); + expect(ctx.runtimeSettings.resetReasoningEffortToConfig).not.toHaveBeenCalled(); expect(ctx.addSystemMessage).toHaveBeenCalledWith("Unsupported reasoning level: extreme. Usage: /reasoning [level|default]"); }); it("does not announce model or effort changes when applying them fails", async () => { - const ctx = context({ - requestModel: vi.fn().mockResolvedValue(false), - requestReasoningEffort: vi.fn().mockResolvedValue(false), - }); + const ctx = context(); + ctx.runtimeSettings.requestModel = vi.fn().mockResolvedValue(false); + ctx.runtimeSettings.requestReasoningEffort = vi.fn().mockResolvedValue(false); await executeSlashCommand("model", "gpt-5.5", ctx); await executeSlashCommand("reasoning", "high", ctx); - expect(ctx.requestModel).toHaveBeenCalledWith("gpt-5.5"); - expect(ctx.requestReasoningEffort).toHaveBeenCalledWith("high"); + expect(ctx.runtimeSettings.requestModel).toHaveBeenCalledWith("gpt-5.5"); + expect(ctx.runtimeSettings.requestReasoningEffort).toHaveBeenCalledWith("high"); expect(ctx.addSystemMessage).not.toHaveBeenCalled(); }); @@ -626,10 +635,10 @@ describe("slash commands", () => { await executeSlashCommand("model", "default", ctx); await executeSlashCommand("reasoning", "default", ctx); - expect(ctx.resetModelToConfig).toHaveBeenCalledOnce(); - expect(ctx.resetReasoningEffortToConfig).toHaveBeenCalledOnce(); - expect(ctx.requestModel).not.toHaveBeenCalled(); - expect(ctx.requestReasoningEffort).not.toHaveBeenCalled(); + expect(ctx.runtimeSettings.resetModelToConfig).toHaveBeenCalledOnce(); + expect(ctx.runtimeSettings.resetReasoningEffortToConfig).toHaveBeenCalledOnce(); + expect(ctx.runtimeSettings.requestModel).not.toHaveBeenCalled(); + expect(ctx.runtimeSettings.requestReasoningEffort).not.toHaveBeenCalled(); expect(ctx.addSystemMessage).toHaveBeenCalledWith("Model reset to default for subsequent turns."); expect(ctx.addSystemMessage).toHaveBeenCalledWith("Reasoning effort reset to default for subsequent turns."); }); @@ -642,10 +651,10 @@ describe("slash commands", () => { await executeSlashCommand("reasoning", alias, ctx); } - expect(ctx.resetModelToConfig).toHaveBeenCalledTimes(3); - expect(ctx.resetReasoningEffortToConfig).toHaveBeenCalledTimes(3); - expect(ctx.requestModel).not.toHaveBeenCalled(); - expect(ctx.requestReasoningEffort).not.toHaveBeenCalled(); + expect(ctx.runtimeSettings.resetModelToConfig).toHaveBeenCalledTimes(3); + expect(ctx.runtimeSettings.resetReasoningEffortToConfig).toHaveBeenCalledTimes(3); + expect(ctx.runtimeSettings.requestModel).not.toHaveBeenCalled(); + expect(ctx.runtimeSettings.requestReasoningEffort).not.toHaveBeenCalled(); }); it("shows model and reasoning status for empty runtime commands", async () => { @@ -659,8 +668,8 @@ describe("slash commands", () => { expect(ctx.addStructuredSystemMessage).toHaveBeenCalledWith("Model settings", [{ auditFacts: [{ key: "model", value: "gpt-5.5" }] }]); expect(ctx.addStructuredSystemMessage).toHaveBeenCalledWith("Reasoning effort", [{ auditFacts: [{ key: "effort", value: "high" }] }]); - expect(ctx.requestModel).not.toHaveBeenCalled(); - expect(ctx.requestReasoningEffort).not.toHaveBeenCalled(); + expect(ctx.runtimeSettings.requestModel).not.toHaveBeenCalled(); + expect(ctx.runtimeSettings.requestReasoningEffort).not.toHaveBeenCalled(); }); it("preserves supported reasoning effort casing", async () => { @@ -670,7 +679,7 @@ describe("slash commands", () => { await executeSlashCommand("reasoning", "CaseSensitive", ctx); - expect(ctx.requestReasoningEffort).toHaveBeenCalledWith("CaseSensitive"); + expect(ctx.runtimeSettings.requestReasoningEffort).toHaveBeenCalledWith("CaseSensitive"); expect(ctx.addSystemMessage).toHaveBeenCalledWith("Reasoning effort set to CaseSensitive for subsequent turns."); }); @@ -697,7 +706,7 @@ describe("slash commands", () => { await executeSlashCommand("fast", "now", ctx); - expect(ctx.toggleFastMode).not.toHaveBeenCalled(); + expect(ctx.runtimeSettings.toggleFastMode).not.toHaveBeenCalled(); expect(ctx.addSystemMessage).toHaveBeenCalledWith("/fast does not take arguments. Usage: /fast"); }); diff --git a/tests/features/chat/conversation/turns/slash-command-handler.test.ts b/tests/features/chat/conversation/turns/slash-command-handler.test.ts index c71860fc..d7ca6f53 100644 --- a/tests/features/chat/conversation/turns/slash-command-handler.test.ts +++ b/tests/features/chat/conversation/turns/slash-command-handler.test.ts @@ -38,23 +38,29 @@ function createHost(overrides: SlashCommandHostOverrides = {}) { startNewThread: vi.fn().mockResolvedValue(undefined), startThreadForGoal: vi.fn().mockResolvedValue("thread-new"), resumeThread: vi.fn().mockResolvedValue(undefined), - forkThread: vi.fn().mockResolvedValue(undefined), - rollbackThread: vi.fn().mockResolvedValue(undefined), - compactThread, - archiveThread: vi.fn().mockResolvedValue(undefined), - renameThread: vi.fn().mockResolvedValue(undefined), + threadActions: { + forkThread: vi.fn().mockResolvedValue(undefined), + rollbackThread: vi.fn().mockResolvedValue(undefined), + compactThread, + archiveThread: vi.fn().mockResolvedValue(undefined), + renameThread: vi.fn().mockResolvedValue(true), + }, reconnect: vi.fn().mockResolvedValue(undefined), - toggleFastMode: vi.fn(), - toggleCollaborationMode: vi.fn(), - toggleAutoReview: vi.fn(), - requestModel: vi.fn(), - resetModelToConfig: vi.fn(), - requestReasoningEffort: vi.fn(), - resetReasoningEffortToConfig: vi.fn(), - activeGoal: vi.fn(() => stateStore.getState().activeThread.goal), - setGoalObjective: vi.fn().mockResolvedValue(true), - setGoalStatus: vi.fn().mockResolvedValue(true), - clearGoal: vi.fn().mockResolvedValue(true), + runtimeSettings: { + toggleFastMode: vi.fn(), + toggleCollaborationMode: vi.fn(), + toggleAutoReview: vi.fn(), + requestModel: vi.fn(), + resetModelToConfig: vi.fn(), + requestReasoningEffort: vi.fn(), + resetReasoningEffortToConfig: vi.fn(), + }, + goals: { + activeGoal: vi.fn(() => stateStore.getState().activeThread.goal), + setObjective: vi.fn().mockResolvedValue(true), + setStatus: vi.fn().mockResolvedValue(true), + clear: vi.fn().mockResolvedValue(true), + }, addSystemMessage: vi.fn(), addStructuredSystemMessage: vi.fn(), setStatus: vi.fn(), @@ -126,7 +132,7 @@ describe("executeSlashCommandWithState", () => { await executeSlashCommandWithState(host, "goal", "set Ship this"); expect(host.startThreadForGoal).toHaveBeenCalledWith("Ship this"); - expect(host.setGoalObjective).toHaveBeenCalledWith("thread-new", "Ship this", null); + expect(host.goals.setObjective).toHaveBeenCalledWith("thread-new", "Ship this", null); }); it("runs reconnect even when there is no current app-server client", async () => { diff --git a/tests/features/chat/threads/rename-editor-controller.test.ts b/tests/features/chat/threads/rename-editor-controller.test.ts index 12f65e09..3b36658a 100644 --- a/tests/features/chat/threads/rename-editor-controller.test.ts +++ b/tests/features/chat/threads/rename-editor-controller.test.ts @@ -3,9 +3,8 @@ import { describe, expect, it, vi } from "vitest"; import { createChatStateStore } from "../../../../src/features/chat/application/state/store"; import { ThreadRenameEditorController } from "../../../../src/features/chat/application/threads/rename-editor-controller"; import type { AppServerClient } from "../../../../src/app-server/connection/client"; -import type { Thread } from "../../../../src/domain/threads/model"; +import { normalizeExplicitThreadName, type Thread } from "../../../../src/domain/threads/model"; import type { TurnItem, TurnRecord } from "../../../../src/app-server/protocol/turn"; -import { threadRenameFromValue } from "../../../../src/app-server/services/thread-rename"; import { deferred } from "../../../support/async"; describe("ThreadRenameEditorController", () => { @@ -163,16 +162,14 @@ function controllerFixture( addSystemMessage: overrides.addSystemMessage ?? vi.fn(), operations: { renameThread: async (threadId: string, value: string) => { - const rename = threadRenameFromValue(value); - if (!rename) return false; - await currentClient().setThreadName(threadId, rename.name); + const name = normalizeExplicitThreadName(value); + if (!name) return false; + await currentClient().setThreadName(threadId, name); stateStore.dispatch({ type: "thread-list/applied", - threads: stateStore - .getState() - .threadList.listedThreads.map((thread) => (thread.id === threadId ? { ...thread, name: rename.name } : thread)), + threads: stateStore.getState().threadList.listedThreads.map((thread) => (thread.id === threadId ? { ...thread, name } : thread)), }); - notifyThreadRenamed(threadId, rename.name); + notifyThreadRenamed(threadId, name); return true; }, }, diff --git a/tests/features/chat/threads/thread-management-actions.test.ts b/tests/features/chat/threads/thread-management-actions.test.ts index 9fda1c20..8ce85e66 100644 --- a/tests/features/chat/threads/thread-management-actions.test.ts +++ b/tests/features/chat/threads/thread-management-actions.test.ts @@ -4,7 +4,7 @@ import type { AppServerClient } from "../../../../src/app-server/connection/clie import type { ThreadRecord } from "../../../../src/app-server/protocol/thread"; import { archiveThreadOnAppServer } from "../../../../src/app-server/services/thread-archive"; import type { ArchiveExportAdapter } from "../../../../src/app-server/services/thread-archive-markdown"; -import { threadRenameFromValue } from "../../../../src/app-server/services/thread-rename"; +import { normalizeExplicitThreadName } from "../../../../src/domain/threads/model"; import { createChatStateStore } from "../../../../src/features/chat/application/state/store"; import { createThreadManagementActions, @@ -451,11 +451,11 @@ function hostMock({ return result; }), renameThread: vi.fn(async (threadId: string, value: string) => { - const rename = threadRenameFromValue(value); - if (!rename) return false; + const name = normalizeExplicitThreadName(value); + if (!name) return false; await ensureConnected(); - await client.setThreadName(threadId, rename.name); - notifyThreadRenamed(threadId, rename.name); + await client.setThreadName(threadId, name); + notifyThreadRenamed(threadId, name); return true; }), }, diff --git a/tests/main.test.ts b/tests/main.test.ts index b00b9d59..d110a782 100644 --- a/tests/main.test.ts +++ b/tests/main.test.ts @@ -505,28 +505,6 @@ describe("CodexPanelPlugin boot restored panel loading", () => { expect(threadCatalog(plugin).activeThreadsSnapshot()).toEqual([thread("cached")]); }); - it("refreshes shared thread lists from a connected chat panel", async () => { - const { CodexChatView } = await import("../src/features/chat/host/view"); - const disconnectedLeaf = leaf(); - disconnectedLeaf.view = chatView(CodexChatView, disconnectedLeaf); - const disconnectedView = disconnectedLeaf.view as CodexChatView; - vi.spyOn(disconnectedView.surface, "openPanelSnapshot").mockReturnValue(panelSnapshot({ viewId: "disconnected", connected: false })); - const disconnectedRefresh = vi.spyOn(disconnectedView.surface, "refreshSharedThreadList").mockResolvedValue(undefined); - - const connectedLeaf = leaf(); - connectedLeaf.view = chatView(CodexChatView, connectedLeaf); - const connectedView = connectedLeaf.view as CodexChatView; - vi.spyOn(connectedView.surface, "openPanelSnapshot").mockReturnValue(panelSnapshot({ viewId: "connected", connected: true })); - const connectedRefresh = vi.spyOn(connectedView.surface, "refreshSharedThreadList").mockResolvedValue(undefined); - - const plugin = await pluginWithLeaves([disconnectedLeaf, connectedLeaf]); - - plugin.runtime.settingTabHost().threadCatalog.invalidateThreadsFromOpenSurface(); - - expect(disconnectedRefresh).not.toHaveBeenCalled(); - expect(connectedRefresh).toHaveBeenCalledOnce(); - }); - it("refreshes shared thread lists from a remaining connected panel after the archived panel is detached", async () => { const { CodexChatView } = await import("../src/features/chat/host/view"); const sourceLeaf = leaf(); diff --git a/tests/settings/settings-tab.test.ts b/tests/settings/settings-tab.test.ts index a346db83..08a660f5 100644 --- a/tests/settings/settings-tab.test.ts +++ b/tests/settings/settings-tab.test.ts @@ -6,7 +6,7 @@ import type { CatalogHookMetadata, CatalogModel } from "../../src/app-server/pro import type { ThreadRecord } from "../../src/app-server/protocol/thread"; import type { ModelMetadata, ReasoningEffort } from "../../src/domain/catalog/metadata"; import { modelMetadataFromCatalogModels } from "../../src/app-server/protocol/catalog"; -import { SettingsDynamicDataController } from "../../src/settings/dynamic-data-controller"; +import { SettingsDynamicDataController, type SettingsDynamicDataSnapshot } from "../../src/settings/dynamic-data-controller"; import { CodexPanelSettingTab } from "../../src/settings/tab"; import type { CodexPanelSettingTabHost } from "../../src/settings/tab"; import type { Thread } from "../../src/domain/threads/model"; @@ -357,9 +357,49 @@ describe("settings tab", () => { expect(controller.snapshot().hooks.map((item) => item.currentHash)).toEqual(["newhash"]); }); + it("keeps full refresh models and archived threads current when hook operations overlap", async () => { + const models = deferred(); + const fullRefreshClient = settingsClient({ + hooks: [hook({ key: "hook-full", command: "full refresh hook", currentHash: "fullhash" })], + threads: [appServerThread({ id: "thread-full", preview: "Full archived" })], + }); + const trustClient = { + trustHook: vi.fn().mockResolvedValue({}), + }; + const hookReloadClient = settingsClient({ + hooks: [hook({ key: "hook-new", command: "new hook", currentHash: "newhash" })], + }); + withShortLivedAppServerClientMock + .mockImplementationOnce((_codexPath: string, _cwd: string, operation: (client: unknown) => Promise) => + operation(fullRefreshClient), + ) + .mockImplementationOnce((_codexPath: string, _cwd: string, operation: (client: unknown) => Promise) => + operation(trustClient), + ) + .mockImplementationOnce((_codexPath: string, _cwd: string, operation: (client: unknown) => Promise) => + operation(hookReloadClient), + ); + const refreshModels = vi.fn().mockReturnValue(models.promise); + const controller = new SettingsDynamicDataController(settingsTabHost({ refreshModels }), { display: vi.fn(), notify: vi.fn() }); + + const fullRefresh = controller.refreshSettingsData(); + await flushPromises(); + await controller.trustHook(hook({ key: "hook-initial", command: "initial hook", currentHash: "initialhash" })); + + models.resolve(modelMetadataFromCatalogModels([model("gpt-refreshed")])); + await fullRefresh; + + const snapshot = controller.snapshot(); + expect(snapshot.models.map((item) => item.model)).toEqual(["gpt-refreshed"]); + expect(snapshot.modelsLifecycle.kind).toBe("loaded"); + expect(snapshot.archivedThreads.map((thread) => thread.preview)).toEqual(["Full archived"]); + expect(snapshot.archivedThreadsLifecycle.kind).toBe("loaded"); + expect(snapshot.hooks.map((item) => item.currentHash)).toEqual(["newhash"]); + }); + it("ignores stale archived restore results after a newer dynamic operation completes", async () => { const staleRestore = deferred<{ thread: ThreadRecord }>(); - const invalidateThreadsFromOpenSurface = vi.fn(); + const refreshActiveThreads = vi.fn().mockResolvedValue([]); const initialClient = settingsClient({ threads: [appServerThread({ id: "thread-old", preview: "Old archived" })], }); @@ -379,7 +419,7 @@ describe("settings tab", () => { .mockImplementationOnce((_codexPath: string, _cwd: string, operation: (client: unknown) => Promise) => operation(newerClient), ); - const controller = new SettingsDynamicDataController(settingsTabHost({ invalidateThreadsFromOpenSurface }), { + const controller = new SettingsDynamicDataController(settingsTabHost({ refreshActiveThreads }), { display: vi.fn(), notify: vi.fn(), }); @@ -394,10 +434,77 @@ describe("settings tab", () => { staleRestore.resolve({ thread: appServerThread({ id: "thread-old", preview: "Restored old" }) }); await restore; - expect(invalidateThreadsFromOpenSurface).not.toHaveBeenCalled(); + expect(refreshActiveThreads).not.toHaveBeenCalled(); expect(controller.snapshot().archivedThreads.map((thread) => thread.preview)).toEqual(["New archived"]); }); + it("keeps restored archived threads removed when active thread refresh fails", async () => { + const notify = vi.fn(); + const refreshActiveThreads = vi.fn().mockRejectedValue(new Error("offline")); + const initialClient = settingsClient({ + threads: [appServerThread({ id: "thread-old", preview: "Old archived" })], + }); + const restoreClient = { + unarchiveThread: vi.fn().mockResolvedValue({ thread: appServerThread({ id: "thread-old", preview: "Restored old" }) }), + }; + withShortLivedAppServerClientMock + .mockImplementationOnce((_codexPath: string, _cwd: string, operation: (client: unknown) => Promise) => + operation(initialClient), + ) + .mockImplementationOnce((_codexPath: string, _cwd: string, operation: (client: unknown) => Promise) => + operation(restoreClient), + ); + const controller = new SettingsDynamicDataController(settingsTabHost({ refreshActiveThreads }), { display: vi.fn(), notify }); + + await controller.refreshSettingsData(); + await controller.restoreArchivedThread("thread-old"); + + const snapshot = controller.snapshot(); + expect(snapshot.archivedThreads).toEqual([]); + expect(snapshot.archivedThreadsLifecycle.kind).toBe("loaded"); + expect(refreshActiveThreads).toHaveBeenCalledOnce(); + expect(notify).toHaveBeenCalledWith("Could not refresh active Codex threads."); + expect(notify).not.toHaveBeenCalledWith("Could not restore archived Codex thread."); + }); + + it("displays restored archived thread state before active thread refresh completes", async () => { + const activeRefresh = deferred(); + const snapshots: SettingsDynamicDataSnapshot[] = []; + const initialClient = settingsClient({ + threads: [appServerThread({ id: "thread-old", preview: "Old archived" })], + }); + const restoreClient = { + unarchiveThread: vi.fn().mockResolvedValue({ thread: appServerThread({ id: "thread-old", preview: "Restored old" }) }), + }; + withShortLivedAppServerClientMock + .mockImplementationOnce((_codexPath: string, _cwd: string, operation: (client: unknown) => Promise) => + operation(initialClient), + ) + .mockImplementationOnce((_codexPath: string, _cwd: string, operation: (client: unknown) => Promise) => + operation(restoreClient), + ); + const controllerRef: { current: SettingsDynamicDataController | null } = { current: null }; + const controller = new SettingsDynamicDataController(settingsTabHost({ refreshActiveThreads: () => activeRefresh.promise }), { + display: () => { + const snapshot = controllerRef.current?.snapshot(); + if (snapshot) snapshots.push(snapshot); + }, + notify: vi.fn(), + }); + controllerRef.current = controller; + + await controller.refreshSettingsData(); + snapshots.length = 0; + const restore = controller.restoreArchivedThread("thread-old"); + await flushPromises(); + + expect(snapshots.at(-1)?.archivedThreads).toEqual([]); + expect(snapshots.at(-1)?.archivedThreadsLifecycle.kind).toBe("loaded"); + + activeRefresh.resolve([]); + await restore; + }); + it("uses cached models initially and publishes refreshed models", async () => { const fetchModels = vi.fn().mockResolvedValue(modelMetadataFromCatalogModels([model("gpt-5.5")])); const client = settingsClient({ models: [model("gpt-5.5")] }); @@ -572,14 +679,13 @@ describe("settings tab", () => { }); it("permanently deletes an archived thread from the confirmed settings row", async () => { - const invalidateThreadsFromOpenSurface = vi.fn(); const client = settingsClient({ threads: [appServerThread({ id: "thread-archived", preview: "Archived thread" })], }); withShortLivedAppServerClientMock.mockImplementation( (_codexPath: string, _cwd: string, operation: (client: unknown) => Promise) => operation(client), ); - const tab = newSettingsTab({ invalidateThreadsFromOpenSurface }); + const tab = newSettingsTab(); tab.display(); await flushPromises(); @@ -590,7 +696,6 @@ describe("settings tab", () => { await flushPromises(); expect(client.deleteThread).toHaveBeenCalledWith("thread-archived"); - expect(invalidateThreadsFromOpenSurface).toHaveBeenCalledOnce(); expect(tab.containerEl.textContent).toContain("No archived threads."); expect(tab.containerEl.querySelectorAll(".codex-panel-settings__archived-list .setting-item")).toHaveLength(0); }); @@ -709,7 +814,7 @@ function newSettingsTab( observeModels?: CodexPanelSettingTabHost["appServerData"]["observeModelsResult"]; notifyContextChanged?: () => void; refreshOpenViews?: () => void; - invalidateThreadsFromOpenSurface?: () => void; + refreshActiveThreads?: () => Promise; settings?: Partial<{ threadNamingModel: string | null; threadNamingEffort: string | null; @@ -731,7 +836,7 @@ function settingsTabHost( observeModels?: CodexPanelSettingTabHost["appServerData"]["observeModelsResult"]; notifyContextChanged?: () => void; refreshOpenViews?: () => void; - invalidateThreadsFromOpenSurface?: () => void; + refreshActiveThreads?: () => Promise; settings?: Partial<{ threadNamingModel: string | null; threadNamingEffort: string | null; @@ -766,7 +871,7 @@ function settingsTabHost( notifyContextChanged: options.notifyContextChanged ?? vi.fn(), }, threadCatalog: { - invalidateThreadsFromOpenSurface: options.invalidateThreadsFromOpenSurface ?? vi.fn(), + refreshActiveThreads: options.refreshActiveThreads ?? vi.fn().mockResolvedValue([]), }, }; } diff --git a/tests/workspace/shared-thread-catalog.test.ts b/tests/workspace/shared-thread-catalog.test.ts index de6146d7..77da999a 100644 --- a/tests/workspace/shared-thread-catalog.test.ts +++ b/tests/workspace/shared-thread-catalog.test.ts @@ -7,7 +7,6 @@ import { createSharedThreadCatalog } from "../../src/workspace/shared-thread-cat interface MockSurfaceActions { refreshOpenViews: Mock<() => void>; - invalidateThreadsFromOpenSurface: Mock<() => void>; applyThreadArchived: Mock<(threadId: string, options?: { closeOpenPanels?: boolean }) => void>; applyThreadRenamed: Mock<(threadId: string, name: string | null) => void>; refreshThreadsViewLiveState: Mock<() => void>; @@ -107,7 +106,6 @@ function cacheWithThreads( function surfaceActions(): MockSurfaceActions { return { refreshOpenViews: vi.fn(), - invalidateThreadsFromOpenSurface: vi.fn(), applyThreadArchived: vi.fn(), applyThreadRenamed: vi.fn(), refreshThreadsViewLiveState: vi.fn(),