From ccf8e8ec910407d3e60c5ab2ee1289011624bf36 Mon Sep 17 00:00:00 2001 From: murashit Date: Sat, 6 Jun 2026 16:44:27 +0900 Subject: [PATCH] Add Codex goal management UI --- README.md | 3 +- eslint.config.mjs | 1 + src/app-server/client.ts | 22 ++ .../chat/chat-app-server-controller.ts | 2 + src/features/chat/chat-notification-plan.ts | 13 +- src/features/chat/chat-state.ts | 10 + .../chat/chat-view-controller-assembly.ts | 24 ++ src/features/chat/composer/slash-commands.ts | 44 ++- src/features/chat/composer/suggestions.ts | 29 +- src/features/chat/controllers/state-ports.ts | 4 +- .../composer-submission-controller.ts | 3 + .../submission/slash-command-controller.ts | 14 + .../thread/thread-resume-controller.ts | 3 + .../view/view-render-controller.ts | 6 + src/features/chat/goal-controller.ts | 109 ++++++ src/features/chat/slash-commands.ts | 133 +++++++- src/features/chat/ui/goal-banner.tsx | 281 ++++++++++++++++ src/features/chat/ui/shell.tsx | 10 + src/features/chat/view-snapshot.ts | 4 + src/features/chat/view.ts | 38 +++ src/styles/00-tokens.css | 1 + src/styles/23-chat-goal.css | 158 +++++++++ src/styles/30-chat-layout.css | 15 +- src/styles/order.json | 1 + tests/app-server/app-server-client.test.ts | 31 ++ .../chat/chat-app-server-controller.test.ts | 8 + tests/features/chat/chat-controller.test.ts | 95 ++++-- .../features/chat/chat-state-reducer.test.ts | 20 ++ .../composer/composer-suggestions.test.ts | 23 ++ .../chat/composer/slash-commands.test.ts | 117 +++++++ .../composer-submission-controller.test.ts | 11 + .../slash-command-controller.test.ts | 20 +- .../thread/thread-resume-controller.test.ts | 2 + .../view/view-render-controller.test.ts | 3 + tests/features/chat/goal-controller.test.ts | 126 +++++++ .../chat/ui/renderers/goal-banner.test.tsx | 317 ++++++++++++++++++ tests/features/chat/ui/shell.test.tsx | 22 ++ 37 files changed, 1670 insertions(+), 53 deletions(-) create mode 100644 src/features/chat/goal-controller.ts create mode 100644 src/features/chat/ui/goal-banner.tsx create mode 100644 src/styles/23-chat-goal.css create mode 100644 tests/features/chat/goal-controller.test.ts create mode 100644 tests/features/chat/ui/renderers/goal-banner.test.tsx diff --git a/README.md b/README.md index 97525bd2..42abe718 100644 --- a/README.md +++ b/README.md @@ -44,10 +44,9 @@ Codex Panel supports Codex App Server workflows that fit a persistent panel in O - Control subsequent turns from slash commands, with the same state visible and lightly adjustable in the composer status row: Plan mode (`/plan`), fast mode (`/fast`), approval auto-review (`/auto-review`), model (`/model`), and reasoning effort (`/reasoning`). - Follow a running turn as Codex streams assistant messages, reasoning, commands, tool calls, hooks, file changes, plan updates, and agent activity. - Respond to Codex questions, command approvals, file approvals, MCP requests, and permission requests; send steering messages; or interrupt when the composer is empty. +- Show the active Codex goal above the message stream, create one with `/goal set `, and edit, pause, resume, or clear it from the panel. - Inspect context usage from the composer status row, and inspect usage limits, connection diagnostics, MCP servers, enabled skills, effective Codex config, and discovered hooks from the toolbar, settings, or `/status`, `/doctor`, and `/mcp`. -Codex goal management is not supported. Incoming goal notifications are shown only as brief system notices. - ## Obsidian integration Codex Panel adds vault-aware behavior where Obsidian benefits from a different surface than the terminal UI: diff --git a/eslint.config.mjs b/eslint.config.mjs index 40f92025..0e53cd93 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -87,6 +87,7 @@ const chatImperativeDomBridgeFiles = [ "src/features/chat/chat-message-renderer.ts", "src/features/chat/markdown-message-renderer.ts", "src/features/chat/ui/composer.tsx", + "src/features/chat/ui/goal-banner.tsx", "src/features/chat/ui/message-stream.tsx", "src/features/chat/ui/scroll.ts", "src/features/chat/ui/shell.tsx", diff --git a/src/app-server/client.ts b/src/app-server/client.ts index f6a523c6..0804874b 100644 --- a/src/app-server/client.ts +++ b/src/app-server/client.ts @@ -17,6 +17,10 @@ import type { ModelProviderCapabilitiesReadResponse } from "../generated/app-ser import type { SkillsListResponse } from "../generated/app-server/v2/SkillsListResponse"; import type { ThreadArchiveResponse } from "../generated/app-server/v2/ThreadArchiveResponse"; import type { ThreadForkResponse } from "../generated/app-server/v2/ThreadForkResponse"; +import type { ThreadGoalClearResponse } from "../generated/app-server/v2/ThreadGoalClearResponse"; +import type { ThreadGoalGetResponse } from "../generated/app-server/v2/ThreadGoalGetResponse"; +import type { ThreadGoalSetResponse } from "../generated/app-server/v2/ThreadGoalSetResponse"; +import type { ThreadGoalStatus } from "../generated/app-server/v2/ThreadGoalStatus"; import type { ThreadListResponse } from "../generated/app-server/v2/ThreadListResponse"; import type { ThreadReadResponse } from "../generated/app-server/v2/ThreadReadResponse"; import type { ThreadResumeResponse } from "../generated/app-server/v2/ThreadResumeResponse"; @@ -80,6 +84,9 @@ interface ClientResponseByMethod { "thread/start": ThreadStartResponse; "thread/resume": ThreadResumeResponse; "thread/fork": ThreadForkResponse; + "thread/goal/get": ThreadGoalGetResponse; + "thread/goal/set": ThreadGoalSetResponse; + "thread/goal/clear": ThreadGoalClearResponse; "thread/list": ThreadListResponse; "thread/read": ThreadReadResponse; "thread/archive": ThreadArchiveResponse; @@ -274,6 +281,21 @@ export class AppServerClient { return this.request("thread/name/set", { threadId, name }); } + getThreadGoal(threadId: string): Promise { + return this.request("thread/goal/get", { threadId }); + } + + setThreadGoal( + threadId: string, + params: { objective?: string | null; status?: ThreadGoalStatus | null; tokenBudget?: number | null }, + ): Promise { + return this.request("thread/goal/set", { threadId, ...params }); + } + + clearThreadGoal(threadId: string): Promise { + return this.request("thread/goal/clear", { threadId }); + } + updateThreadSettings(threadId: string, settings: Omit): Promise { return this.request("thread/settings/update", { threadId, ...settings }); } diff --git a/src/features/chat/chat-app-server-controller.ts b/src/features/chat/chat-app-server-controller.ts index 1a691036..927c67e1 100644 --- a/src/features/chat/chat-app-server-controller.ts +++ b/src/features/chat/chat-app-server-controller.ts @@ -26,6 +26,7 @@ export interface ChatAppServerControllerHost { forceMessagesToBottom: () => void; publishThreadList: (threads: readonly Thread[]) => void; publishAppServerMetadata: (metadata: SharedAppServerMetadata) => void; + syncThreadGoal: (threadId: string) => void; } export interface RefreshCapabilityDiagnosticsOptions { @@ -118,6 +119,7 @@ export class ChatAppServerController { this.dispatch(resumedThreadAction({ response, listedThreads, forceMessagesToBottom: true })); this.host.publishThreadList(listedThreads); this.host.forceMessagesToBottom(); + this.host.syncThreadGoal(response.thread.id); return response; } diff --git a/src/features/chat/chat-notification-plan.ts b/src/features/chat/chat-notification-plan.ts index 5caf396e..00d6b3f9 100644 --- a/src/features/chat/chat-notification-plan.ts +++ b/src/features/chat/chat-notification-plan.ts @@ -69,7 +69,7 @@ export function planChatNotification( case "turnLifecycle": return planTurnLifecycle(state, route.notification); case "threadLifecycle": - return planThreadLifecycle(state, route.notification, localItemId); + return planThreadLifecycle(state, route.notification); case "requestResolved": return { actions: [{ type: "request/resolved", requestId: route.notification.params.requestId }], @@ -191,7 +191,7 @@ function planTurnLifecycle(state: ChatState, notification: ServerNotification): return EMPTY_PLAN; } -function planThreadLifecycle(state: ChatState, notification: ServerNotification, localItemId: LocalItemIdFactory): ChatNotificationPlan { +function planThreadLifecycle(state: ChatState, notification: ServerNotification): ChatNotificationPlan { const { method, params } = notification; if (method === "thread/started") { if (!state.activeThreadId || state.activeThreadId === params.thread.id) { @@ -238,13 +238,12 @@ function planThreadLifecycle(state: ChatState, notification: ServerNotification, }); } if (method === "thread/goal/updated") { - return systemMessagePlan({ - id: localItemId("system"), - text: `Thread goal updated: status ${params.goal.status}. Codex Panel does not support goals.`, - }); + if (state.activeThreadId !== params.threadId) return EMPTY_PLAN; + return actionPlan({ type: "thread/goal-set", goal: params.goal }); } if (method === "thread/goal/cleared") { - return systemMessagePlan({ id: localItemId("system"), text: "Thread goal cleared. Codex Panel does not support goals." }); + if (state.activeThreadId !== params.threadId) return EMPTY_PLAN; + return actionPlan({ type: "thread/goal-set", goal: null }); } return EMPTY_PLAN; } diff --git a/src/features/chat/chat-state.ts b/src/features/chat/chat-state.ts index 15a77a11..f8ae6ad0 100644 --- a/src/features/chat/chat-state.ts +++ b/src/features/chat/chat-state.ts @@ -9,6 +9,7 @@ import type { Model } from "../../generated/app-server/v2/Model"; import type { RateLimitSnapshot } from "../../generated/app-server/v2/RateLimitSnapshot"; import type { SkillMetadata } from "../../generated/app-server/v2/SkillMetadata"; import type { Thread } from "../../generated/app-server/v2/Thread"; +import type { ThreadGoal } from "../../generated/app-server/v2/ThreadGoal"; import type { ThreadSettingsUpdateParams } from "../../generated/app-server/v2/ThreadSettingsUpdateParams"; import type { ThreadTokenUsage } from "../../generated/app-server/v2/ThreadTokenUsage"; import type { AppServerDiagnostics } from "../../app-server/compatibility"; @@ -60,6 +61,7 @@ export interface ChatState { activeApprovalsReviewer: ApprovalsReviewer | null; activePermissionProfile: ActivePermissionProfile | null; activeThreadCreationCliVersion: string | null; + activeGoal: ThreadGoal | null; appServerDiagnostics: AppServerDiagnostics; requestedModel: PendingRuntimeSetting; requestedReasoningEffort: PendingRuntimeSetting; @@ -172,6 +174,7 @@ export type ChatAction = | { type: "runtime/pending-thread-settings-committed"; update: Omit } | { type: "connection/initialized"; initializeResponse: InitializeResponse } | { type: "thread/cwd-set"; cwd: string | null } + | { type: "thread/goal-set"; goal: ThreadGoal | null } | { type: "thread/token-usage-set"; tokenUsage: ThreadTokenUsage | null } | { type: "thread/settings-applied"; @@ -211,6 +214,7 @@ export function createChatState(): ChatState { turnLifecycle: { kind: "idle" }, ...initialActiveRuntimeState(), activeThreadCreationCliVersion: null, + activeGoal: null, appServerDiagnostics: createAppServerDiagnostics(), ...initialRequestedRuntimeState(), tokenUsage: null, @@ -319,6 +323,7 @@ function reduceThreadState(state: ChatState, action: ThreadAction): ChatState { activeApprovalsReviewer: action.approvalsReviewer, activePermissionProfile: action.activePermissionProfile, activeThreadCreationCliVersion: action.thread.cliVersion, + activeGoal: null, tokenUsage: null, ...initialHistoryState(), ...initialPendingRequestState(), @@ -341,6 +346,8 @@ function reduceThreadState(state: ChatState, action: ThreadAction): ChatState { }); case "thread/cwd-set": return patchChatState(state, { activeThreadCwd: action.cwd }); + case "thread/goal-set": + return patchChatState(state, { activeGoal: action.goal }); case "thread/token-usage-set": return patchChatState(state, { tokenUsage: action.tokenUsage }); case "thread/settings-applied": @@ -362,6 +369,7 @@ function reduceThreadState(state: ChatState, action: ThreadAction): ChatState { activeThreadCwd: null, ...initialActiveRuntimeState(), activeThreadCreationCliVersion: null, + activeGoal: null, tokenUsage: null, ...initialHistoryState(), displayItems: [action.item], @@ -543,6 +551,7 @@ export function clearActiveThreadState(state: ChatState): ChatState { activeThreadCwd: null, ...initialActiveRuntimeState(), activeThreadCreationCliVersion: null, + activeGoal: null, tokenUsage: null, ...initialHistoryState(), ...initialDisplayState(), @@ -555,6 +564,7 @@ export function clearConnectionScopedState(state: ChatState): ChatState { return patchChatState(clearActiveTurnState(state), { ...initialActiveRuntimeState(), activeThreadCreationCliVersion: null, + activeGoal: null, rateLimit: null, listedThreads: [], threadsLoaded: false, diff --git a/src/features/chat/chat-view-controller-assembly.ts b/src/features/chat/chat-view-controller-assembly.ts index d81b6da6..83bf8a70 100644 --- a/src/features/chat/chat-view-controller-assembly.ts +++ b/src/features/chat/chat-view-controller-assembly.ts @@ -20,6 +20,7 @@ import { ChatThreadActionController } from "./thread-actions"; import { ThreadHistoryLoader } from "./thread-history"; import { ThreadRenameController } from "./thread-rename"; import { ChatRuntimeSettingsController } from "./runtime-settings-controller"; +import { ChatGoalController } from "./goal-controller"; import { ToolbarPanelController } from "./toolbar-panel-controller"; import { AppServerWarmupController } from "./controllers/connection/app-server-warmup-controller"; import { ChatConnectionController } from "./controllers/connection/connection-controller"; @@ -56,6 +57,7 @@ export interface ChatViewControllerAssembly { threadResume: ThreadResumeController; threadActions: ChatThreadActionController; runtimeSettings: ChatRuntimeSettingsController; + goals: ChatGoalController; restoredThread: RestoredThreadController; threadIdentity: ThreadIdentityController; threadRename: ThreadRenameController; @@ -92,6 +94,7 @@ export interface ChatViewControllerAssemblyHost { setClosing: (closing: boolean) => void; panelRoot: () => HTMLElement | null; renderToolbar: (toolbar: HTMLElement) => void; + renderGoal: (goal: HTMLElement) => void; renderMessages: (parent: HTMLElement) => void; renderComposer: (parent: HTMLElement) => void; pendingRequestsSignature: () => string; @@ -129,6 +132,7 @@ export function createChatViewControllerAssembly(host: ChatViewControllerAssembl let threadResume!: ThreadResumeController; let threadActions!: ChatThreadActionController; let runtimeSettings!: ChatRuntimeSettingsController; + let goals!: ChatGoalController; let restoredThread!: RestoredThreadController; let threadIdentity!: ThreadIdentityController; let threadRename!: ThreadRenameController; @@ -161,6 +165,7 @@ export function createChatViewControllerAssembly(host: ChatViewControllerAssembl }), panelRoot: host.panelRoot, renderToolbar: host.renderToolbar, + renderGoal: host.renderGoal, renderMessages: host.renderMessages, renderComposer: host.renderComposer, clearScheduledRender: () => { @@ -220,6 +225,12 @@ export function createChatViewControllerAssembly(host: ChatViewControllerAssembl setRequestedModel: (model) => runtimeSettings.setRequestedModel(model), setRequestedReasoningEffort: (effort) => runtimeSettings.setRequestedReasoningEffort(effort), }, + goals: { + activeGoal: () => goals.activeGoal(), + setObjective: (threadId, objective, tokenBudget) => goals.setObjective(threadId, objective, tokenBudget), + setStatus: (threadId, status) => goals.setStatus(threadId, status), + clear: (threadId) => goals.clear(threadId), + }, status: { addSystemMessage: host.effects.status.addSystemMessage, addStructuredSystemMessage: host.effects.status.addStructuredSystemMessage, @@ -399,6 +410,9 @@ export function createChatViewControllerAssembly(host: ChatViewControllerAssembl publishAppServerMetadata: (metadata) => { host.plugin.publishAppServerMetadata(metadata); }, + syncThreadGoal: (threadId) => { + void goals.syncThreadGoal(threadId); + }, }); connectionController = new ChatConnectionController({ state: connectionState, @@ -493,6 +507,14 @@ export function createChatViewControllerAssembly(host: ChatViewControllerAssembl collaborationModeLabel: host.collaborationModeLabel, addSystemMessage: host.effects.status.addSystemMessage, }); + goals = new ChatGoalController({ + stateStore: host.stateStore, + currentClient: host.getClient, + ensureConnected: host.effects.client.ensureConnected, + addSystemMessage: host.effects.status.addSystemMessage, + render: host.effects.render.now, + refreshLiveState: host.effects.liveState.refresh, + }); restoredThread = new RestoredThreadController({ deferredTasks: host.deferredTasks, opened: host.getOpened, @@ -527,6 +549,7 @@ export function createChatViewControllerAssembly(host: ChatViewControllerAssembl forceMessagesToBottom: host.effects.scroll.forceBottom, render: host.effects.render.now, refreshLiveState: host.effects.liveState.refresh, + syncThreadGoal: (threadId) => goals.syncThreadGoal(threadId), recoverTokenUsageFromRollout: (path) => recoverRolloutTokenUsage(path, async (filePath, options) => { const response = await host.getClient()?.readFile(filePath, options); @@ -565,6 +588,7 @@ export function createChatViewControllerAssembly(host: ChatViewControllerAssembl threadResume, threadActions, runtimeSettings, + goals, restoredThread, threadIdentity, threadRename, diff --git a/src/features/chat/composer/slash-commands.ts b/src/features/chat/composer/slash-commands.ts index 7bbc7c61..3edd1ddc 100644 --- a/src/features/chat/composer/slash-commands.ts +++ b/src/features/chat/composer/slash-commands.ts @@ -5,8 +5,11 @@ export type SlashCommandArgsKind = | "optionalMessage" | "threadAndMessage" | "threadAndName" + | "goal" | "showOrSet"; +export type SlashCommandSubcommandArgsKind = "none" | "requiredMessage"; + export type SlashCommandSurface = "panelAction" | "threadSetting" | "diagnostic" | "composition"; export const SLASH_COMMAND_SURFACE_LABELS: Record = { @@ -16,6 +19,22 @@ export const SLASH_COMMAND_SURFACE_LABELS: Record = composition: "Composition", }; +export interface SlashCommandSubcommandDefinition { + subcommand: string; + usage: string; + argsKind: SlashCommandSubcommandArgsKind; + detail: string; +} + +export interface SlashCommandDefinitionShape { + command: string; + usage: string; + argsKind: SlashCommandArgsKind; + surface: SlashCommandSurface; + detail: string; + subcommands?: readonly SlashCommandSubcommandDefinition[]; +} + export const SLASH_COMMANDS = [ { command: "/clear", @@ -89,6 +108,20 @@ export const SLASH_COMMANDS = [ surface: "threadSetting", detail: "Toggle Plan mode, optionally with a message.", }, + { + command: "/goal", + usage: "/goal [set |edit|pause|resume|clear]", + argsKind: "goal", + surface: "threadSetting", + detail: "Show or manage the current thread goal.", + subcommands: [ + { subcommand: "set", usage: "/goal set ", argsKind: "requiredMessage", detail: "Create or update the thread goal." }, + { subcommand: "edit", usage: "/goal edit", argsKind: "none", detail: "Load the current thread goal into the composer." }, + { subcommand: "pause", usage: "/goal pause", argsKind: "none", detail: "Pause the current thread goal." }, + { subcommand: "resume", usage: "/goal resume", argsKind: "none", detail: "Resume the current thread goal." }, + { subcommand: "clear", usage: "/goal clear", argsKind: "none", detail: "Clear the current thread goal." }, + ], + }, { command: "/status", usage: "/status", @@ -131,7 +164,7 @@ export const SLASH_COMMANDS = [ surface: "diagnostic", detail: "Show available Codex slash commands.", }, -] as const; +] as const satisfies readonly SlashCommandDefinitionShape[]; type SlashCommand = (typeof SLASH_COMMANDS)[number]["command"]; @@ -145,6 +178,15 @@ export function slashCommandDefinition(command: SlashCommandName): SlashCommandD return definition; } +export function slashCommandSubcommands(command: SlashCommandName): readonly SlashCommandSubcommandDefinition[] { + const definition = slashCommandDefinition(command); + return "subcommands" in definition ? definition.subcommands : []; +} + +export function slashCommandSubcommandDefinition(command: SlashCommandName, subcommand: string): SlashCommandSubcommandDefinition | null { + return slashCommandSubcommands(command).find((item) => item.subcommand === subcommand) ?? null; +} + export function slashCommandHelpLines(): string[] { return SLASH_COMMANDS.map((item) => `${item.usage} - ${item.detail}`); } diff --git a/src/features/chat/composer/suggestions.ts b/src/features/chat/composer/suggestions.ts index 018cd264..ddd294f9 100644 --- a/src/features/chat/composer/suggestions.ts +++ b/src/features/chat/composer/suggestions.ts @@ -9,7 +9,7 @@ import { sortedAvailableModels, supportedEffortsForModel, } from "../../../runtime/model"; -import { SLASH_COMMANDS, type SlashCommandName } from "./slash-commands"; +import { SLASH_COMMANDS, slashCommandSubcommands, type SlashCommandName } from "./slash-commands"; import { getThreadTitle } from "../../../domain/threads/model"; import { shortThreadId } from "../../../utils"; @@ -65,6 +65,7 @@ export function activeComposerSuggestions( ): ComposerSuggestion[] { return ( activeWikiLinkSuggestions(beforeCursor, notes) ?? + activeSlashSubcommandSuggestions(beforeCursor) ?? activeThreadCommandSuggestions(beforeCursor, threads) ?? activeModelOverrideSuggestions(beforeCursor, models) ?? activeReasoningEffortSuggestions(beforeCursor, models, currentModel) ?? @@ -248,6 +249,32 @@ export function activeSlashCommandSuggestions(beforeCursor: string): ComposerSug })); } +export function activeSlashSubcommandSuggestions(beforeCursor: string): ComposerSuggestion[] | null { + const match = /^\/([A-Za-z-]+)\s+([A-Za-z-]{0,120})$/.exec(beforeCursor); + if (!match) return null; + + const command = match[1] as SlashCommandName | undefined; + const rawQuery = match[2]; + if (!command || rawQuery === undefined) return null; + if (!SLASH_COMMANDS.some((item) => item.command === `/${command}`)) return null; + + const query = rawQuery.toLowerCase(); + const subcommands = slashCommandSubcommands(command); + if (subcommands.length === 0) return null; + if (subcommands.some((item) => item.subcommand.toLowerCase() === query)) return null; + const start = beforeCursor.length - rawQuery.length; + return subcommands + .filter((item) => item.subcommand.toLowerCase().startsWith(query)) + .slice(0, 8) + .map((item) => ({ + display: item.subcommand, + detail: `${item.usage} - ${item.detail}`, + replacement: item.subcommand, + start, + appendSpaceOnInsert: true, + })); +} + export function activeThreadCommandSuggestions(beforeCursor: string, threads: readonly Thread[]): ComposerSuggestion[] | null { const completion = activeCommandArgumentCompletionQuery(beforeCursor, /^\/(?:resume|refer|archive|rename)\s+([^\s\n]{0,120})$/); if (!completion) return null; diff --git a/src/features/chat/controllers/state-ports.ts b/src/features/chat/controllers/state-ports.ts index 2c5d323e..374b96de 100644 --- a/src/features/chat/controllers/state-ports.ts +++ b/src/features/chat/controllers/state-ports.ts @@ -7,7 +7,7 @@ import type { PendingUserInput } from "../user-input/model"; import type { DisplayItem } from "../display/types"; import { implementPlanCandidateFromState } from "../plan-implementation"; import { resumedThreadAction, type ThreadActivationResponse } from "../thread-resume"; -import { composerSlotSnapshot, messagesSlotSnapshot, toolbarSlotSnapshot } from "../view-snapshot"; +import { composerSlotSnapshot, goalSlotSnapshot, messagesSlotSnapshot, toolbarSlotSnapshot } from "../view-snapshot"; import { renderChatPanelShell } from "../ui/shell"; export interface ConnectionStatePort { @@ -72,6 +72,7 @@ export interface ChatShellRenderPort { renderVersion: number, slots: { renderToolbar: (toolbar: HTMLElement) => void; + renderGoal: (goal: HTMLElement) => void; renderMessages: (parent: HTMLElement) => void; renderComposer: (parent: HTMLElement) => void; }, @@ -215,6 +216,7 @@ export function createChatShellRenderPort( renderVersion, showToolbar: options.showToolbar(), toolbar: { render: slots.renderToolbar, snapshot: (state) => toolbarSlotSnapshot(state, options.connected()) }, + goal: { render: slots.renderGoal, snapshot: goalSlotSnapshot }, messages: { render: slots.renderMessages, snapshot: (state) => messagesSlotSnapshot(state, options.pendingRequestsSignature()), diff --git a/src/features/chat/controllers/submission/composer-submission-controller.ts b/src/features/chat/controllers/submission/composer-submission-controller.ts index a7c4061c..c251bb45 100644 --- a/src/features/chat/controllers/submission/composer-submission-controller.ts +++ b/src/features/chat/controllers/submission/composer-submission-controller.ts @@ -62,6 +62,9 @@ export class ComposerSubmissionController { if (slashCommand) { this.host.composer.setDraft("", { clearSuggestions: true }); const result = await this.host.slashCommands.execute(slashCommand.command, slashCommand.args); + if (result?.composerDraft !== undefined) { + this.host.composer.setDraft(result.composerDraft, { focus: true, clearSuggestions: true }); + } if (result?.sendText) { await this.host.turnSubmission.sendTurnText(result.sendText, result.sendInput, result.referencedThread); } diff --git a/src/features/chat/controllers/submission/slash-command-controller.ts b/src/features/chat/controllers/submission/slash-command-controller.ts index 701dae00..37002170 100644 --- a/src/features/chat/controllers/submission/slash-command-controller.ts +++ b/src/features/chat/controllers/submission/slash-command-controller.ts @@ -9,6 +9,8 @@ import { executeSlashCommand as runSlashCommand, type SlashCommandExecutionResul import type { SlashCommandName } from "../../composer/slash-commands"; import type { DisplayDetailSection } from "../../display/types"; import type { ReasoningEffort } from "../../../../generated/app-server/ReasoningEffort"; +import type { ThreadGoal } from "../../../../generated/app-server/v2/ThreadGoal"; +import type { ThreadGoalStatus } from "../../../../generated/app-server/v2/ThreadGoalStatus"; import type { UserInput } from "../../../../generated/app-server/v2/UserInput"; import type { SubmissionStatePort } from "../state-ports"; @@ -41,12 +43,20 @@ export interface SlashCommandStatusPort { effortStatusLines: () => string[]; } +export interface SlashCommandGoalPort { + activeGoal: () => ThreadGoal | null; + setObjective: (threadId: string, objective: string, tokenBudget: number | null) => Promise; + setStatus: (threadId: string, status: ThreadGoalStatus) => Promise; + clear: (threadId: string) => Promise; +} + export interface SlashCommandControllerHost { state: SubmissionStatePort; currentClient: () => AppServerClient | null; codexInput: (text: string) => UserInput[]; threads: SlashCommandThreadPort; runtime: SlashCommandRuntimePort; + goals: SlashCommandGoalPort; status: SlashCommandStatusPort; } @@ -90,6 +100,10 @@ export class SlashCommandController { }, setRequestedModel: (model) => this.host.runtime.setRequestedModel(model), setRequestedReasoningEffort: (effort) => this.host.runtime.setRequestedReasoningEffort(effort), + activeGoal: () => this.host.goals.activeGoal(), + setGoalObjective: (threadId, objective, tokenBudget) => this.host.goals.setObjective(threadId, objective, tokenBudget), + setGoalStatus: (threadId, status) => this.host.goals.setStatus(threadId, status), + clearGoal: (threadId) => this.host.goals.clear(threadId), statusSummaryLines: () => this.host.status.statusSummaryLines(), connectionDiagnosticDetails: () => this.host.status.connectionDiagnosticDetails(), mcpStatusLines: () => this.host.status.mcpStatusLines(), diff --git a/src/features/chat/controllers/thread/thread-resume-controller.ts b/src/features/chat/controllers/thread/thread-resume-controller.ts index e504bfcb..e4d07f82 100644 --- a/src/features/chat/controllers/thread/thread-resume-controller.ts +++ b/src/features/chat/controllers/thread/thread-resume-controller.ts @@ -24,6 +24,7 @@ export interface ThreadResumeControllerHost { forceMessagesToBottom: () => void; render: () => void; refreshLiveState: () => void; + syncThreadGoal: (threadId: string) => Promise; recoverTokenUsageFromRollout?: (path: string) => Promise; } @@ -51,6 +52,8 @@ export class ThreadResumeController { await this.host.history.loadLatest(response.thread.id); } if (this.isStale(resume)) return; + await this.host.syncThreadGoal(response.thread.id); + if (this.isStale(resume)) return; if (this.host.state.displayItemsEmpty()) { this.host.addSystemMessage(`Resumed thread ${response.thread.id}`); this.host.forceMessagesToBottom(); diff --git a/src/features/chat/controllers/view/view-render-controller.ts b/src/features/chat/controllers/view/view-render-controller.ts index e6016c51..0229573d 100644 --- a/src/features/chat/controllers/view/view-render-controller.ts +++ b/src/features/chat/controllers/view/view-render-controller.ts @@ -5,6 +5,7 @@ export interface ChatViewRenderControllerHost { shell: ChatShellRenderPort; panelRoot: () => HTMLElement | null; renderToolbar: (toolbar: HTMLElement) => void; + renderGoal: (goal: HTMLElement) => void; renderMessages: (parent: HTMLElement) => void; renderComposer: (parent: HTMLElement) => void; clearScheduledRender: () => void; @@ -22,6 +23,7 @@ export class ChatViewRenderController { if (options.forceSlots) this.shellRenderVersion += 1; this.host.shell.render(root, this.shellRenderVersion, { renderToolbar: this.renderToolbarSlot, + renderGoal: this.renderGoalSlot, renderMessages: this.renderMessagesSlot, renderComposer: this.renderComposerSlot, }); @@ -35,6 +37,10 @@ export class ChatViewRenderController { this.host.renderToolbar(toolbar); }; + private readonly renderGoalSlot = (goal: HTMLElement): void => { + this.host.renderGoal(goal); + }; + private readonly renderMessagesSlot = (parent: HTMLElement): void => { this.host.renderMessages(parent); }; diff --git a/src/features/chat/goal-controller.ts b/src/features/chat/goal-controller.ts new file mode 100644 index 00000000..fe4a18a6 --- /dev/null +++ b/src/features/chat/goal-controller.ts @@ -0,0 +1,109 @@ +import type { AppServerClient } from "../../app-server/client"; +import type { ThreadGoal } from "../../generated/app-server/v2/ThreadGoal"; +import type { ThreadGoalStatus } from "../../generated/app-server/v2/ThreadGoalStatus"; +import type { ChatStateStore } from "./chat-state"; + +export interface ChatGoalControllerHost { + stateStore: ChatStateStore; + currentClient: () => AppServerClient | null; + ensureConnected: () => Promise; + addSystemMessage: (text: string) => void; + render: () => void; + refreshLiveState: () => void; +} + +export class ChatGoalController { + constructor(private readonly host: ChatGoalControllerHost) {} + + activeGoal(): ThreadGoal | null { + return this.host.stateStore.getState().activeGoal; + } + + async syncThreadGoal(threadId: string): Promise { + const client = this.host.currentClient(); + if (!client) return; + try { + const response = await client.getThreadGoal(threadId); + this.applyGoalIfActive(threadId, response.goal); + } catch (error) { + if (this.host.stateStore.getState().activeThreadId !== threadId) return; + this.host.addSystemMessage(`Could not load thread goal: ${errorMessage(error)}`); + } + } + + async setObjective(threadId: string, objective: string, tokenBudget: number | null): Promise { + const trimmed = objective.trim(); + if (!trimmed) { + this.host.addSystemMessage("Goal objective cannot be empty."); + return false; + } + const current = this.host.stateStore.getState().activeGoal; + const applied = await this.setGoal(threadId, { + objective: trimmed, + status: current?.status ?? "active", + tokenBudget, + }); + if (applied) this.addSystemMessageIfActive(threadId, current ? "Goal updated." : "Goal set."); + return applied; + } + + async setStatus(threadId: string, status: ThreadGoalStatus): Promise { + const applied = await this.setGoal(threadId, { status }); + if (applied) this.addSystemMessageIfActive(threadId, goalStatusMessage(status)); + return applied; + } + + async clear(threadId: string): Promise { + await this.host.ensureConnected(); + const client = this.host.currentClient(); + if (!client) return false; + try { + await client.clearThreadGoal(threadId); + this.applyGoalIfActive(threadId, null); + this.addSystemMessageIfActive(threadId, "Goal cleared."); + return true; + } catch (error) { + this.host.addSystemMessage(errorMessage(error)); + return false; + } + } + + private async setGoal( + threadId: string, + params: { objective?: string | null; status?: ThreadGoalStatus | null; tokenBudget?: number | null }, + ): Promise { + await this.host.ensureConnected(); + const client = this.host.currentClient(); + if (!client) return false; + try { + const response = await client.setThreadGoal(threadId, params); + this.applyGoalIfActive(threadId, response.goal); + return true; + } catch (error) { + this.host.addSystemMessage(errorMessage(error)); + return false; + } + } + + private applyGoalIfActive(threadId: string, goal: ThreadGoal | null): void { + if (this.host.stateStore.getState().activeThreadId !== threadId) return; + this.host.stateStore.dispatch({ type: "thread/goal-set", goal }); + this.host.refreshLiveState(); + this.host.render(); + } + + private addSystemMessageIfActive(threadId: string, text: string): void { + if (this.host.stateStore.getState().activeThreadId !== threadId) return; + this.host.addSystemMessage(text); + } +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function goalStatusMessage(status: ThreadGoalStatus): string { + if (status === "paused") return "Goal paused."; + if (status === "active") return "Goal resumed."; + return `Goal status set to ${status}.`; +} diff --git a/src/features/chat/slash-commands.ts b/src/features/chat/slash-commands.ts index fcc8d315..112a6b79 100644 --- a/src/features/chat/slash-commands.ts +++ b/src/features/chat/slash-commands.ts @@ -1,9 +1,18 @@ import type { ReasoningEffort } from "../../generated/app-server/ReasoningEffort"; import type { Thread } from "../../generated/app-server/v2/Thread"; +import type { ThreadGoal } from "../../generated/app-server/v2/ThreadGoal"; +import type { ThreadGoalStatus } from "../../generated/app-server/v2/ThreadGoalStatus"; import type { UserInput } from "../../generated/app-server/v2/UserInput"; import { getThreadTitle } from "../../domain/threads/model"; import type { ReferencedThreadDisplay } from "../../domain/threads/reference"; -import { slashCommandDefinition, slashCommandHelpSections, type SlashCommandName } from "./composer/slash-commands"; +import { + slashCommandDefinition, + slashCommandHelpSections, + slashCommandSubcommandDefinition, + slashCommandSubcommands, + type SlashCommandName, + type SlashCommandSubcommandDefinition, +} from "./composer/slash-commands"; import type { DisplayDetailSection, DisplayDetailMetaRow } from "./display/types"; import { modelOverrideMessage, @@ -33,6 +42,10 @@ export interface SlashCommandExecutionContext { setStatus: (status: string) => void; setRequestedModel: (model: string | null) => boolean | undefined | Promise; setRequestedReasoningEffort: (effort: ReasoningEffort | null) => boolean | undefined | Promise; + activeGoal: () => ThreadGoal | null; + setGoalObjective: (threadId: string, objective: string, tokenBudget: number | null) => Promise; + setGoalStatus: (threadId: string, status: ThreadGoalStatus) => Promise; + clearGoal: (threadId: string) => Promise; statusSummaryLines: () => string[]; connectionDiagnosticDetails: () => DisplayDetailSection[]; mcpStatusLines: () => Promise; @@ -44,6 +57,7 @@ export interface SlashCommandExecutionResult { sendText?: string; sendInput?: UserInput[]; referencedThread?: ReferencedThreadDisplay; + composerDraft?: string; } export interface ThreadReferenceInput { @@ -185,6 +199,10 @@ export async function executeSlashCommand( return; } + if (command === "goal") { + return await executeGoalCommand(args, context); + } + if (command === "status") { context.addStructuredSystemMessage("Thread status", detailsFromLines(context.statusSummaryLines())); return; @@ -240,6 +258,119 @@ function validateSlashCommandArguments(command: SlashCommandName, args: string): return null; } +async function executeGoalCommand(args: string, context: SlashCommandExecutionContext): Promise { + const parsed = parseGoalArgs(args); + if (parsed.kind === "invalid") { + context.addSystemMessage(parsed.message); + return; + } + if (parsed.kind === "show") { + const goal = context.activeGoal(); + if (!goal) { + context.addSystemMessage("No goal set."); + return; + } + context.addStructuredSystemMessage("Thread goal", goalDetails(goal)); + return; + } + const threadId = context.activeThreadId; + if (!threadId) { + context.addSystemMessage("No active thread for goal management."); + return; + } + const goal = context.activeGoal(); + if (parsed.kind === "set") { + await context.setGoalObjective(threadId, parsed.objective, goal?.tokenBudget ?? null); + return; + } + if (!goal) { + context.addSystemMessage("No goal set."); + return; + } + if (parsed.kind === "edit") { + return { composerDraft: `/goal set ${goal.objective}` }; + } + if (parsed.kind === "pause") { + await context.setGoalStatus(threadId, "paused"); + return; + } + if (parsed.kind === "resume") { + await context.setGoalStatus(threadId, "active"); + return; + } + await context.clearGoal(threadId); +} + +type GoalArgs = + | { kind: "show" } + | { kind: "set"; objective: string } + | { kind: "edit" } + | { kind: "pause" } + | { kind: "resume" } + | { kind: "clear" } + | { kind: "invalid"; message: string }; + +function parseGoalArgs(args: string): GoalArgs { + const trimmed = args.trim(); + if (!trimmed) return { kind: "show" }; + const subcommandMatch = /^([^\s]+)(?:\s+([\s\S]*))?$/.exec(trimmed); + const subcommandName = subcommandMatch?.[1] ?? ""; + const subcommand = slashCommandSubcommandDefinition("goal", subcommandName); + if (!subcommand) return { kind: "invalid", message: goalUsageError("requires set , edit, pause, resume, or clear") }; + + const rawArgs = subcommandMatch?.[2] ?? ""; + const subcommandArgs = rawArgs.trim(); + const argumentError = validateSubcommandArguments(subcommand, subcommandArgs); + if (argumentError) return { kind: "invalid", message: argumentError }; + if (subcommand.subcommand === "set") return { kind: "set", objective: subcommandArgs }; + if (subcommand.subcommand === "edit") return { kind: "edit" }; + if (subcommand.subcommand === "pause") return { kind: "pause" }; + if (subcommand.subcommand === "resume") return { kind: "resume" }; + return { kind: "clear" }; +} + +function validateSubcommandArguments(subcommand: SlashCommandSubcommandDefinition, args: string): string | null { + if (subcommand.argsKind === "none" && args) { + return `${subcommand.usage} does not take arguments. Usage: ${subcommand.usage}`; + } + if (subcommand.argsKind === "requiredMessage" && !args) { + return `${subcommand.usage} requires an objective. Usage: ${subcommand.usage}`; + } + return null; +} + +function goalUsageError(message: string): string { + return usageError( + "goal", + `${message}. Subcommands: ${slashCommandSubcommands("goal") + .map((item) => item.usage) + .join(", ")}`, + ); +} + +function goalDetails(goal: ThreadGoal): DisplayDetailSection[] { + const rows: DisplayDetailMetaRow[] = [ + { key: "status", value: goal.status }, + { key: "objective", value: goal.objective }, + { + key: "tokens", + value: goal.tokenBudget === null ? String(goal.tokensUsed) : `${String(goal.tokensUsed)} / ${String(goal.tokenBudget)}`, + }, + { key: "elapsed", value: formatGoalElapsed(goal.timeUsedSeconds) }, + ]; + return [{ rows }]; +} + +function formatGoalElapsed(seconds: number): string { + if (seconds < 60) return `${String(seconds)}s`; + const minutes = Math.floor(seconds / 60); + const remainingSeconds = seconds % 60; + if (minutes < 60) return remainingSeconds === 0 ? `${String(minutes)}m` : `${String(minutes)}m ${String(remainingSeconds)}s`; + const hours = Math.floor(minutes / 60); + const remainingMinutes = minutes % 60; + return remainingMinutes === 0 ? `${String(hours)}h` : `${String(hours)}h ${String(remainingMinutes)}m`; +} + function usageError(command: SlashCommandName, message: string): string { const definition = slashCommandDefinition(command); return `${definition.command} ${message}. Usage: ${definition.usage}`; diff --git a/src/features/chat/ui/goal-banner.tsx b/src/features/chat/ui/goal-banner.tsx new file mode 100644 index 00000000..9a1ab145 --- /dev/null +++ b/src/features/chat/ui/goal-banner.tsx @@ -0,0 +1,281 @@ +import type { ComponentChild as UiNode } from "preact"; +import { useEffect, useLayoutEffect, useRef, useState } from "preact/hooks"; + +import type { ThreadGoal } from "../../../generated/app-server/v2/ThreadGoal"; +import type { ThreadGoalStatus } from "../../../generated/app-server/v2/ThreadGoalStatus"; +import { isComposerSendKey, type SendShortcut } from "../../../shared/ui/keyboard"; +import { IconButton } from "../../../shared/ui/components"; +import { syncTextareaHeight } from "../../../shared/ui/textarea-autogrow"; +import { renderUiRoot } from "../../../shared/ui/ui-root"; + +export interface GoalBannerActions { + onSave: (objective: string, tokenBudget: number | null) => void; + onPause: () => void; + onResume: () => void; + onClear: () => void; +} + +export interface GoalBannerOptions { + sendShortcut: SendShortcut; +} + +export function renderGoalBanner( + parent: HTMLElement, + goal: ThreadGoal | null, + actions: GoalBannerActions, + options: GoalBannerOptions, +): void { + renderUiRoot(parent, ); +} + +function GoalBanner({ + goal, + actions, + options, +}: { + goal: ThreadGoal | null; + actions: GoalBannerActions; + options: GoalBannerOptions; +}): UiNode { + const [editing, setEditing] = useState(false); + const [objective, setObjective] = useState(goal?.objective ?? ""); + const [objectiveOverflows, setObjectiveOverflows] = useState(false); + const [objectiveExpanded, setObjectiveExpanded] = useState(false); + const goalRef = useRef(null); + const objectiveContentRef = useRef(null); + const objectiveRef = useRef(null); + + const resetThreadId = goal?.threadId ?? null; + const resetObjective = goal?.objective ?? ""; + const resetStatus = goal?.status ?? null; + const resetTokenBudget = goal?.tokenBudget ?? null; + + useLayoutEffect(() => { + setEditing(false); + setObjective(resetObjective); + setObjectiveOverflows(false); + setObjectiveExpanded(false); + }, [resetThreadId, resetObjective, resetStatus, resetTokenBudget]); + + useLayoutEffect(() => { + if (editing) syncGoalObjectiveHeight(objectiveRef.current); + }, [editing, objective]); + + useLayoutEffect(() => { + if (!editing) return; + objectiveRef.current?.focus(); + }, [editing]); + + useLayoutEffect(() => { + if (editing) return; + const content = objectiveContentRef.current; + if (!content) return; + const update = () => { + setObjectiveOverflows(content.scrollHeight > goalObjectiveCollapseHeight(content) + 1); + }; + update(); + content.win.requestAnimationFrame(update); + }, [editing, resetObjective]); + + useEffect(() => { + if (!editing) return; + const doc = goalRef.current?.ownerDocument; + if (!doc) return; + const cancelEditing = () => { + setEditing(false); + setObjective(resetObjective); + }; + const closeOnOutsidePointer = (event: PointerEvent) => { + if (event.target instanceof Node && goalRef.current?.contains(event.target)) return; + cancelEditing(); + }; + const closeOnEscape = (event: KeyboardEvent) => { + if (event.key !== "Escape") return; + event.preventDefault(); + cancelEditing(); + }; + doc.addEventListener("pointerdown", closeOnOutsidePointer, true); + doc.addEventListener("keydown", closeOnEscape); + return () => { + doc.removeEventListener("pointerdown", closeOnOutsidePointer, true); + doc.removeEventListener("keydown", closeOnEscape); + }; + }, [editing, resetObjective]); + + useEffect(() => { + if (!objectiveExpanded) return; + const doc = goalRef.current?.ownerDocument; + if (!doc) return; + const collapseOnOutsidePointer = (event: PointerEvent) => { + if (event.target instanceof Node && goalRef.current?.contains(event.target)) return; + setObjectiveExpanded(false); + }; + doc.addEventListener("pointerdown", collapseOnOutsidePointer, true); + return () => { + doc.removeEventListener("pointerdown", collapseOnOutsidePointer, true); + }; + }, [objectiveExpanded]); + + if (!goal) return null; + + const terminal = terminalGoalStatus(goal.status); + const saveDisabled = objective.trim().length === 0; + const saveObjective = () => { + if (saveDisabled) return; + actions.onSave(objective, goal.tokenBudget); + setEditing(false); + }; + + return ( +
+
+
+ Goal +
+ {!editing ? ( + { + setEditing(true); + }} + /> + ) : null} + {!terminal && !editing && goal.status === "active" ? ( + + ) : null} + {!terminal && !editing && goal.status === "paused" ? ( + + ) : null} + {!editing ? ( + + ) : null} +
+
+ {editing ? ( +
+
+