Add Codex goal management UI

This commit is contained in:
murashit 2026-06-06 16:44:27 +09:00
parent 6a4c36d50d
commit ccf8e8ec91
37 changed files with 1670 additions and 53 deletions

View file

@ -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 <objective>`, 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:

View file

@ -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",

View file

@ -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<ThreadGoalGetResponse> {
return this.request("thread/goal/get", { threadId });
}
setThreadGoal(
threadId: string,
params: { objective?: string | null; status?: ThreadGoalStatus | null; tokenBudget?: number | null },
): Promise<ThreadGoalSetResponse> {
return this.request("thread/goal/set", { threadId, ...params });
}
clearThreadGoal(threadId: string): Promise<ThreadGoalClearResponse> {
return this.request("thread/goal/clear", { threadId });
}
updateThreadSettings(threadId: string, settings: Omit<ThreadSettingsUpdateParams, "threadId">): Promise<ThreadSettingsUpdateResponse> {
return this.request("thread/settings/update", { threadId, ...settings });
}

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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<string>;
requestedReasoningEffort: PendingRuntimeSetting<ReasoningEffort>;
@ -172,6 +174,7 @@ export type ChatAction =
| { type: "runtime/pending-thread-settings-committed"; update: Omit<ThreadSettingsUpdateParams, "threadId"> }
| { 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,

View file

@ -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,

View file

@ -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<SlashCommandSurface, string> = {
@ -16,6 +19,22 @@ export const SLASH_COMMAND_SURFACE_LABELS: Record<SlashCommandSurface, string> =
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 <objective>|edit|pause|resume|clear]",
argsKind: "goal",
surface: "threadSetting",
detail: "Show or manage the current thread goal.",
subcommands: [
{ subcommand: "set", usage: "/goal set <objective>", 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}`);
}

View file

@ -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;

View file

@ -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()),

View file

@ -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);
}

View file

@ -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<boolean>;
setStatus: (threadId: string, status: ThreadGoalStatus) => Promise<boolean>;
clear: (threadId: string) => Promise<boolean>;
}
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(),

View file

@ -24,6 +24,7 @@ export interface ThreadResumeControllerHost {
forceMessagesToBottom: () => void;
render: () => void;
refreshLiveState: () => void;
syncThreadGoal: (threadId: string) => Promise<void>;
recoverTokenUsageFromRollout?: (path: string) => Promise<ThreadTokenUsage | null>;
}
@ -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();

View file

@ -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);
};

View file

@ -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<void>;
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<void> {
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<boolean> {
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<boolean> {
const applied = await this.setGoal(threadId, { status });
if (applied) this.addSystemMessageIfActive(threadId, goalStatusMessage(status));
return applied;
}
async clear(threadId: string): Promise<boolean> {
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<boolean> {
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}.`;
}

View file

@ -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<boolean | undefined>;
setRequestedReasoningEffort: (effort: ReasoningEffort | null) => boolean | undefined | Promise<boolean | undefined>;
activeGoal: () => ThreadGoal | null;
setGoalObjective: (threadId: string, objective: string, tokenBudget: number | null) => Promise<boolean>;
setGoalStatus: (threadId: string, status: ThreadGoalStatus) => Promise<boolean>;
clearGoal: (threadId: string) => Promise<boolean>;
statusSummaryLines: () => string[];
connectionDiagnosticDetails: () => DisplayDetailSection[];
mcpStatusLines: () => Promise<string[]>;
@ -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<SlashCommandExecutionResult | undefined> {
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 <objective>, 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}`;

View file

@ -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, <GoalBanner goal={goal} actions={actions} options={options} />);
}
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<HTMLDivElement | null>(null);
const objectiveContentRef = useRef<HTMLDivElement | null>(null);
const objectiveRef = useRef<HTMLTextAreaElement | null>(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 (
<div ref={goalRef} className={`codex-panel__goal codex-panel__goal--${goal.status}${terminal ? " is-terminal" : ""}`}>
<div className="codex-panel__goal-main">
<div className="codex-panel__goal-role">
<span>Goal</span>
<div className="codex-panel__goal-actions">
{!editing ? (
<IconButton
icon="pencil"
label="Edit goal"
className="clickable-icon codex-panel__message-action codex-panel__goal-action"
onClick={() => {
setEditing(true);
}}
/>
) : null}
{!terminal && !editing && goal.status === "active" ? (
<IconButton
icon="pause"
label="Pause goal"
className="clickable-icon codex-panel__message-action codex-panel__goal-action"
onClick={actions.onPause}
/>
) : null}
{!terminal && !editing && goal.status === "paused" ? (
<IconButton
icon="play"
label="Resume goal"
className="clickable-icon codex-panel__message-action codex-panel__goal-action"
onClick={actions.onResume}
/>
) : null}
{!editing ? (
<IconButton
icon="x"
label="Clear goal"
className="clickable-icon codex-panel__message-action codex-panel__goal-action"
onClick={actions.onClear}
/>
) : null}
</div>
</div>
{editing ? (
<div className="codex-panel__goal-editor">
<div className="codex-panel__goal-editor-frame">
<textarea
ref={objectiveRef}
className="codex-panel-ui__text-input codex-panel__goal-objective-input"
value={objective}
onInput={(event) => {
setObjective(event.currentTarget.value);
syncGoalObjectiveHeight(event.currentTarget);
}}
onKeyDown={(event) => {
if (!isComposerSendKey(event, options.sendShortcut)) return;
event.preventDefault();
event.stopPropagation();
saveObjective();
}}
/>
<IconButton
icon="check"
label="Save goal"
className="clickable-icon codex-panel-ui__icon-button codex-panel__goal-save"
disabled={saveDisabled}
onMouseDown={(event) => {
event.preventDefault();
}}
onClick={saveObjective}
/>
</div>
</div>
) : (
<>
<div
className={[
"codex-panel__goal-objective-collapse",
objectiveOverflows ? "codex-panel__goal-objective-collapse--overflow" : "",
objectiveOverflows && objectiveExpanded ? "codex-panel__goal-objective-collapse--expanded" : "",
]
.filter(Boolean)
.join(" ")}
>
<div
ref={objectiveContentRef}
className={[
"codex-panel__goal-objective",
objectiveOverflows && !objectiveExpanded ? "codex-panel__goal-objective--collapsed" : "",
]
.filter(Boolean)
.join(" ")}
>
{goal.objective}
</div>
<details
className="codex-panel__goal-objective-collapse-details"
hidden={!objectiveOverflows || objectiveExpanded}
onToggle={(event) => {
if (!event.currentTarget.open) return;
event.currentTarget.open = false;
setObjectiveExpanded(true);
}}
>
<summary tabIndex={-1}>Show more</summary>
</details>
</div>
<div className="codex-panel__goal-usage">{goalUsage(goal)}</div>
</>
)}
</div>
</div>
);
}
function terminalGoalStatus(status: ThreadGoalStatus): boolean {
return status === "complete" || status === "blocked" || status === "usageLimited" || status === "budgetLimited";
}
function syncGoalObjectiveHeight(textarea: HTMLTextAreaElement | null): void {
syncTextareaHeight(textarea, {
minHeightFallback: 56,
maxHeightFallback: textarea ? Math.min(180, textarea.win.innerHeight * 0.3) : 180,
});
}
function goalObjectiveCollapseHeight(element: HTMLElement): number {
const lineHeight = computedLineHeight(element);
return lineHeight * 3;
}
function computedLineHeight(element: HTMLElement): number {
const style = element.win.getComputedStyle(element);
const lineHeight = Number.parseFloat(style.lineHeight);
if (Number.isFinite(lineHeight) && lineHeight > 0) return lineHeight;
const fontSize = Number.parseFloat(style.fontSize);
return Number.isFinite(fontSize) && fontSize > 0 ? fontSize * 1.5 : 24;
}
function goalUsage(goal: ThreadGoal): string {
const tokens =
goal.tokenBudget === null ? `${String(goal.tokensUsed)} tokens` : `${String(goal.tokensUsed)} / ${String(goal.tokenBudget)} tokens`;
return `${tokens}, ${formatElapsed(goal.timeUsedSeconds)}`;
}
function formatElapsed(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`;
}

View file

@ -7,6 +7,7 @@ export interface ChatPanelShellProps {
renderVersion: number;
showToolbar: boolean;
toolbar: ChatPanelSlotProps;
goal: ChatPanelSlotProps;
messages: ChatPanelSlotProps;
composer: ChatPanelSlotProps;
}
@ -33,6 +34,15 @@ const shellSlots = {
return props.toolbar;
},
},
goal: {
selector: ":scope > .codex-panel__body > .codex-panel__slot--goal",
create(container: HTMLElement): HTMLElement {
return ensureBody(container).createDiv({ cls: "codex-panel__slot codex-panel__slot--goal" });
},
props(props: ChatPanelShellProps): ChatPanelSlotProps {
return props.goal;
},
},
messages: {
selector: ":scope > .codex-panel__body > .codex-panel__slot--messages > .codex-panel__messages",
create(container: HTMLElement): HTMLElement {

View file

@ -68,6 +68,10 @@ export function messagesSlotSnapshot(state: ChatState, pendingRequestsSignature:
);
}
export function goalSlotSnapshot(state: ChatState): ChatPanelSlotSnapshot {
return signatureParts(state.activeThreadId, state.activeGoal);
}
export function composerSlotSnapshot(state: ChatState, activeComposerThreadName: string | null): ChatPanelSlotSnapshot {
return signatureParts(
state.composerDraft,

View file

@ -12,6 +12,7 @@ import type { RuntimeSnapshot } from "../../runtime/state";
import { pendingRequestsSignature as requestStateSignature } from "./request-state";
import { activeTurnId, chatTurnBusy, createChatStateStore, type ChatState, type ChatAction } from "./chat-state";
import { renderToolbar } from "./ui/toolbar";
import { renderGoalBanner } from "./ui/goal-banner";
import type { ToolbarViewModel } from "./toolbar-model";
import type { OpenCodexPanelSnapshot } from "../../runtime/open-panel-snapshot";
import type { SharedAppServerMetadata } from "../../runtime/shared-app-server-state";
@ -55,6 +56,7 @@ export class CodexChatView extends ItemView {
private readonly threadResume: ChatViewControllerAssembly["threadResume"];
private readonly threadActions: ChatViewControllerAssembly["threadActions"];
private readonly runtimeSettings: ChatViewControllerAssembly["runtimeSettings"];
private readonly goals: ChatViewControllerAssembly["goals"];
private readonly restoredThread: ChatViewControllerAssembly["restoredThread"];
private readonly threadIdentity: ChatViewControllerAssembly["threadIdentity"];
private readonly threadRename: ChatViewControllerAssembly["threadRename"];
@ -123,6 +125,9 @@ export class CodexChatView extends ItemView {
renderToolbar: (toolbar) => {
this.renderToolbar(toolbar);
},
renderGoal: (goal) => {
this.renderGoal(goal);
},
renderMessages: (parent) => {
this.renderMessages(parent);
},
@ -173,6 +178,7 @@ export class CodexChatView extends ItemView {
this.threadResume = controllers.threadResume;
this.threadActions = controllers.threadActions;
this.runtimeSettings = controllers.runtimeSettings;
this.goals = controllers.goals;
this.restoredThread = controllers.restoredThread;
this.threadIdentity = controllers.threadIdentity;
this.threadRename = controllers.threadRename;
@ -611,6 +617,38 @@ export class CodexChatView extends ItemView {
});
}
private renderGoal(goal: HTMLElement): void {
renderGoalBanner(
goal,
this.state.activeGoal,
{
onSave: (objective, tokenBudget) => {
const threadId = this.state.activeThreadId;
if (!threadId) return;
void this.goals.setObjective(threadId, objective, tokenBudget);
},
onPause: () => {
const threadId = this.state.activeThreadId;
if (!threadId) return;
void this.goals.setStatus(threadId, "paused");
},
onResume: () => {
const threadId = this.state.activeThreadId;
if (!threadId) return;
void this.goals.setStatus(threadId, "active");
},
onClear: () => {
const threadId = this.state.activeThreadId;
if (!threadId) return;
void this.goals.clear(threadId);
},
},
{
sendShortcut: this.plugin.settings.sendShortcut,
},
);
}
private toolbarViewModel(): ToolbarViewModel {
return buildToolbarViewModel({
state: this.state,

View file

@ -18,6 +18,7 @@
--codex-panel-size-toolbar-status-panel-max-height: 384px;
--codex-panel-size-message-collapse-max-height: 360px;
--codex-panel-size-message-collapse-fade-height: 72px;
--codex-panel-size-goal-objective-collapse-max-height: 4.5lh;
--codex-panel-size-output-max-height: 420px;
--codex-panel-size-selection-rewrite-width: 520px;
--codex-panel-size-selection-rewrite-max-height: 560px;

158
src/styles/23-chat-goal.css Normal file
View file

@ -0,0 +1,158 @@
.codex-panel__slot--goal:not(:empty) {
padding-bottom: var(--codex-panel-edge-padding-x);
}
.codex-panel__goal {
margin: var(--codex-panel-edge-padding-x) var(--codex-panel-edge-padding-x) 0;
padding-left: var(--codex-panel-message-indent);
border-left: var(--codex-panel-rail);
color: var(--codex-panel-text-normal);
white-space: pre-wrap;
overflow-wrap: anywhere;
user-select: text;
}
.codex-panel__goal--active {
border-left-color: var(--codex-panel-color-accent);
}
.codex-panel__goal--paused {
border-left-color: var(--codex-panel-color-warning);
}
.codex-panel__goal--complete {
border-left-color: var(--codex-panel-color-success);
}
.codex-panel__goal--blocked,
.codex-panel__goal--usageLimited,
.codex-panel__goal--budgetLimited {
border-left-color: var(--codex-panel-color-danger);
}
.codex-panel__goal-main {
min-width: 0;
}
.codex-panel__goal-role {
display: flex;
align-items: center;
gap: var(--codex-panel-item-gap);
margin-bottom: var(--codex-panel-control-gap);
color: var(--codex-panel-role-label-color);
font-size: var(--codex-panel-role-label-size);
font-weight: var(--codex-panel-role-label-weight);
}
.codex-panel__goal-actions {
display: inline-flex;
align-items: center;
gap: var(--codex-panel-control-gap);
}
.codex-panel__goal:hover .codex-panel__goal-action,
.codex-panel__goal-role--confirm-open .codex-panel__goal-action,
.codex-panel__goal-action:focus-visible {
opacity: 1;
}
.codex-panel__goal-objective-collapse {
position: relative;
}
.codex-panel__goal-objective {
position: relative;
overflow: hidden;
font-family: var(--codex-panel-content-font);
font-size: var(--codex-panel-content-font-size);
overflow-wrap: anywhere;
cursor: text;
user-select: text;
white-space: pre-wrap;
}
.codex-panel__goal-objective--collapsed {
max-height: var(--codex-panel-size-goal-objective-collapse-max-height);
}
.codex-panel__goal-objective--collapsed::after {
content: "";
position: absolute;
right: 0;
bottom: 0;
left: 0;
height: var(--codex-panel-size-message-collapse-fade-height);
background: linear-gradient(to bottom, transparent, var(--codex-panel-surface));
pointer-events: none;
}
.codex-panel__goal-objective-collapse-details {
position: relative;
z-index: 1;
margin-top: var(--codex-panel-item-gap);
}
.codex-panel__goal-objective-collapse-details > summary {
cursor: default;
color: var(--text-muted);
font-size: var(--font-ui-smaller);
}
.codex-panel__goal-objective-collapse-details > summary:hover {
color: var(--nav-item-color-hover, var(--text-normal));
}
.codex-panel__goal-usage {
margin-top: var(--codex-panel-control-gap);
color: var(--codex-panel-text-faint);
font-size: var(--font-ui-smaller);
line-height: var(--line-height-tight);
}
.codex-panel__goal-editor {
display: grid;
gap: var(--codex-panel-section-gap);
}
.codex-panel__goal-editor-frame {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: var(--codex-panel-control-gap);
min-width: 0;
align-items: end;
border: var(--input-border-width, var(--border-width, 1px)) solid var(--background-modifier-border);
border-radius: var(--input-radius, var(--radius-s));
background: var(--background-modifier-form-field);
}
.codex-panel__goal-editor-frame:focus-within {
border-color: var(--background-modifier-border-focus);
box-shadow: 0 0 0 var(--input-border-width, var(--border-width, 1px)) var(--background-modifier-border-focus);
}
.codex-panel__goal-objective-input {
min-height: var(--codex-panel-composer-min-height);
max-height: min(var(--codex-panel-size-selection-rewrite-preview-max-height), 30vh);
border: 0;
border-radius: 0;
background: transparent;
box-shadow: none;
overflow-y: hidden;
resize: none;
}
.codex-panel__goal-objective-input.codex-panel__goal-objective-input:hover,
.codex-panel__goal-objective-input.codex-panel__goal-objective-input:focus,
.codex-panel__goal-objective-input.codex-panel__goal-objective-input:focus-visible,
.codex-panel__goal-objective-input.codex-panel__goal-objective-input:active {
border: 0;
background: transparent;
box-shadow: none;
outline: none;
}
.codex-panel__goal-save {
min-width: calc(var(--codex-panel-control-icon-size) + var(--codex-panel-panel-gap) * 2);
margin: var(--codex-panel-panel-gap);
padding: var(--codex-panel-panel-gap);
}

View file

@ -1,7 +1,7 @@
.codex-panel__body {
flex: 1 1 auto;
display: grid;
grid-template-rows: 1fr auto;
grid-template-rows: auto minmax(0, 1fr) auto;
min-width: 0;
min-height: 0;
}
@ -10,11 +10,24 @@
min-width: 0;
}
.codex-panel__slot--goal {
grid-row: 1;
}
.codex-panel__slot--messages {
grid-row: 2;
min-height: 0;
overflow: hidden;
}
.codex-panel__slot--composer {
grid-row: 3;
}
.codex-panel__slot--goal:empty {
display: none;
}
.codex-panel__toolbar-panel .codex-panel__config {
margin-top: var(--codex-panel-panel-gap);
padding: var(--codex-panel-item-gap) 0 0;

View file

@ -6,6 +6,7 @@
"20-chat-toolbar.css",
"21-chat-status-diagnostics.css",
"22-chat-thread-list.css",
"23-chat-goal.css",
"30-chat-layout.css",
"31-chat-messages.css",
"32-chat-user-input.css",

View file

@ -233,6 +233,37 @@ describe("AppServerClient", () => {
await expect(reading).resolves.toEqual({ dataBase64: btoa("hello") });
});
it("sends thread goal management requests", async () => {
const { client, transport } = await connectedClient();
const reading = client.getThreadGoal("thread");
await expectRequest(transport, reading, { method: "thread/goal/get", params: { threadId: "thread" } }, { goal: null });
await expect(reading).resolves.toEqual({ goal: null });
const setting = client.setThreadGoal("thread", { objective: "Finish", status: "active", tokenBudget: 1000 });
const goal = {
threadId: "thread",
objective: "Finish",
status: "active",
tokenBudget: 1000,
tokensUsed: 0,
timeUsedSeconds: 0,
createdAt: 1,
updatedAt: 1,
};
await expectRequest(
transport,
setting,
{ method: "thread/goal/set", params: { threadId: "thread", objective: "Finish", status: "active", tokenBudget: 1000 } },
{ goal },
);
await expect(setting).resolves.toEqual({ goal });
const clearing = client.clearThreadGoal("thread");
await expectRequest(transport, clearing, { method: "thread/goal/clear", params: { threadId: "thread" } }, { cleared: true });
await expect(clearing).resolves.toEqual({ cleared: true });
});
it("suppresses late responses after per-request timeouts", async () => {
vi.useFakeTimers();
vi.stubGlobal("window", {

View file

@ -18,6 +18,7 @@ describe("ChatAppServerController", () => {
const started = threadFixture("started");
const optimistic = { ...started, preview: "first prompt" };
const publishThreadList = vi.fn();
const syncThreadGoal = vi.fn();
const client = {
startThread: vi.fn().mockResolvedValue({
thread: started,
@ -39,12 +40,14 @@ describe("ChatAppServerController", () => {
forceMessagesToBottom: () => undefined,
publishThreadList,
publishAppServerMetadata: () => undefined,
syncThreadGoal,
});
await controller.startThread("first prompt");
expect(stateStore.getState().listedThreads.map((thread) => thread.id)).toEqual(["started", "existing"]);
expect(publishThreadList).toHaveBeenCalledWith([optimistic, existing]);
expect(syncThreadGoal).toHaveBeenCalledWith("started");
});
it("keeps app-server preview when newly started threads already have one", async () => {
@ -72,6 +75,7 @@ describe("ChatAppServerController", () => {
forceMessagesToBottom: () => undefined,
publishThreadList,
publishAppServerMetadata: () => undefined,
syncThreadGoal: () => undefined,
});
await controller.startThread("local preview");
@ -106,6 +110,7 @@ describe("ChatAppServerController", () => {
forceMessagesToBottom: () => undefined,
publishThreadList: () => undefined,
publishAppServerMetadata: () => undefined,
syncThreadGoal: () => undefined,
});
await controller.refreshAppServerMetadata();
@ -149,6 +154,7 @@ describe("ChatAppServerController", () => {
forceMessagesToBottom: () => undefined,
publishThreadList: () => undefined,
publishAppServerMetadata,
syncThreadGoal: () => undefined,
});
await controller.refreshPublishedRateLimits();
@ -177,6 +183,7 @@ describe("ChatAppServerController", () => {
forceMessagesToBottom: () => undefined,
publishThreadList: () => undefined,
publishAppServerMetadata,
syncThreadGoal: () => undefined,
});
await controller.refreshPublishedRateLimits();
@ -202,6 +209,7 @@ describe("ChatAppServerController", () => {
forceMessagesToBottom: () => undefined,
publishThreadList: () => undefined,
publishAppServerMetadata: () => undefined,
syncThreadGoal: () => undefined,
});
controller.recordMcpStartupStatus("github", "ready", null);

View file

@ -1375,46 +1375,67 @@ describe("ChatController", () => {
expect(state.activeServiceTier).toBeNull();
});
it("shows unsupported goal notifications as brief system messages", () => {
const cases: { notification: ServerNotification; text: string }[] = [
{
notification: {
method: "thread/goal/updated",
params: {
threadId: "thread-active",
turnId: null,
goal: {
threadId: "thread-active",
objective: "Finish",
status: "active",
tokenBudget: null,
tokensUsed: 0,
timeUsedSeconds: 0,
createdAt: 1,
updatedAt: 1,
},
},
} satisfies Extract<ServerNotification, { method: "thread/goal/updated" }>,
text: "Thread goal updated: status active. Codex Panel does not support goals.",
},
{
notification: {
method: "thread/goal/cleared",
params: { threadId: "thread-active" },
} satisfies Extract<ServerNotification, { method: "thread/goal/cleared" }>,
text: "Thread goal cleared. Codex Panel does not support goals.",
},
];
it("applies goal notifications to thread state without adding system messages", () => {
const state = createChatState();
state.activeThreadId = "thread-active";
const controller = controllerForState(state);
const goal = {
threadId: "thread-active",
objective: "Finish",
status: "active",
tokenBudget: null,
tokensUsed: 0,
timeUsedSeconds: 0,
createdAt: 1,
updatedAt: 1,
} satisfies Extract<ServerNotification, { method: "thread/goal/updated" }>["params"]["goal"];
for (const { notification, text } of cases) {
const state = createChatState();
state.activeThreadId = "thread-active";
const controller = controllerForState(state);
controller.handleNotification({
method: "thread/goal/updated",
params: { threadId: "thread-active", turnId: null, goal },
} satisfies Extract<ServerNotification, { method: "thread/goal/updated" }>);
controller.handleNotification(notification);
expect(state.activeGoal).toEqual(goal);
expect(state.displayItems).toEqual([]);
expect(state.displayItems).toMatchObject([{ kind: "system", text }]);
}
controller.handleNotification({
method: "thread/goal/cleared",
params: { threadId: "thread-active" },
} satisfies Extract<ServerNotification, { method: "thread/goal/cleared" }>);
expect(state.activeGoal).toBeNull();
expect(state.displayItems).toEqual([]);
});
it("ignores goal notifications that do not match the active thread", () => {
const goal = {
threadId: "previous-thread",
objective: "Stale",
status: "active",
tokenBudget: null,
tokensUsed: 0,
timeUsedSeconds: 0,
createdAt: 1,
updatedAt: 1,
} satisfies Extract<ServerNotification, { method: "thread/goal/updated" }>["params"]["goal"];
const noActiveState = createChatState();
const noActiveController = controllerForState(noActiveState);
noActiveController.handleNotification({
method: "thread/goal/updated",
params: { threadId: "previous-thread", turnId: null, goal },
} satisfies Extract<ServerNotification, { method: "thread/goal/updated" }>);
expect(noActiveState.activeGoal).toBeNull();
const otherThreadState = createChatState();
otherThreadState.activeThreadId = "thread-active";
otherThreadState.activeGoal = { ...goal, threadId: "thread-active", objective: "Current" };
const otherThreadController = controllerForState(otherThreadState);
otherThreadController.handleNotification({
method: "thread/goal/cleared",
params: { threadId: "previous-thread" },
} satisfies Extract<ServerNotification, { method: "thread/goal/cleared" }>);
expect(otherThreadState.activeGoal.objective).toBe("Current");
});
});

View file

@ -12,6 +12,7 @@ import {
} from "../../../src/features/chat/chat-state";
import type { DisplayItem } from "../../../src/features/chat/display/types";
import type { Thread } from "../../../src/generated/app-server/v2/Thread";
import type { ThreadGoal } from "../../../src/generated/app-server/v2/ThreadGoal";
describe("chatReducer", () => {
it("clears active turn and thread-scoped state", () => {
@ -19,6 +20,7 @@ describe("chatReducer", () => {
state.activeThreadId = "thread";
state.turnLifecycle = { kind: "running", turnId: "turn" };
state.activeModel = "gpt-5.1";
state.activeGoal = goal("thread");
state.historyCursor = "cursor";
state.loadingHistory = true;
state.composerDraft = "keep me";
@ -35,6 +37,7 @@ describe("chatReducer", () => {
expect(next).not.toBe(state);
expect(next.activeThreadId).toBeNull();
expect(next.activeGoal).toBeNull();
expect(activeTurnId(next)).toBeNull();
expect(next.displayItems).toEqual([]);
expect(next.turnDiffs.size).toBe(0);
@ -54,6 +57,7 @@ describe("chatReducer", () => {
state.activeThreadId = "previous-thread";
state.turnLifecycle = { kind: "running", turnId: "previous-turn" };
state.historyCursor = "cursor";
state.activeGoal = goal("previous-thread");
state.loadingHistory = true;
state.composerDraft = "previous draft";
state.displayItems = [message("previous-message")];
@ -81,6 +85,7 @@ describe("chatReducer", () => {
});
expect(next.activeThreadId).toBe("resumed-thread");
expect(next.activeGoal).toBeNull();
expect(activeTurnId(next)).toBeNull();
expect(next.historyCursor).toBeNull();
expect(next.loadingHistory).toBe(false);
@ -120,6 +125,7 @@ describe("chatReducer", () => {
state.activeThreadId = "previous-thread";
state.turnLifecycle = { kind: "running", turnId: "previous-turn" };
state.activeModel = "gpt-5.1";
state.activeGoal = goal("previous-thread");
state.historyCursor = "cursor";
state.loadingHistory = true;
state.composerDraft = "draft in this panel";
@ -143,6 +149,7 @@ describe("chatReducer", () => {
expect(next.activeThreadId).toBe("restored-thread");
expect(activeTurnId(next)).toBeNull();
expect(next.activeModel).toBeNull();
expect(next.activeGoal).toBeNull();
expect(next.historyCursor).toBeNull();
expect(next.loadingHistory).toBe(false);
expect(next.displayItems).toEqual([placeholder]);
@ -464,6 +471,19 @@ function userInput(requestId: number): ChatState["pendingUserInputs"][number] {
};
}
function goal(threadId: string): ThreadGoal {
return {
threadId,
objective: "Finish",
status: "active",
tokenBudget: null,
tokensUsed: 0,
timeUsedSeconds: 0,
createdAt: 1,
updatedAt: 1,
};
}
export function thread(id: string): Thread {
return {
id,

View file

@ -307,6 +307,29 @@ describe("composer suggestions", () => {
expect(suggestions).toEqual([]);
});
it("suggests slash subcommands from command definitions", () => {
expect(activeComposerSuggestions("/goal ", notes, []).map((suggestion) => suggestion.replacement)).toEqual([
"set",
"edit",
"pause",
"resume",
"clear",
]);
expect(activeComposerSuggestions("/goal p", notes, [])[0]).toMatchObject({
display: "pause",
detail: "/goal pause - Pause the current thread goal.",
replacement: "pause",
appendSpaceOnInsert: true,
});
expect(applyComposerSuggestionInsertion("/goal p", 7, expectPresent(activeComposerSuggestions("/goal p", notes, [])[0]))).toEqual({
value: "/goal pause ",
cursor: 12,
});
expect(activeComposerSuggestions("/goal pause", notes, [])).toEqual([]);
expect(activeComposerSuggestions("/goal set Ship it", notes, [])).toEqual([]);
expect(activeComposerSuggestions("/plan p", notes, [])).toEqual([]);
});
it("suggests model override arguments for /model", () => {
const models = [
model("gpt-5.5", ["low", "medium", "high"]),

View file

@ -2,6 +2,7 @@ import { describe, expect, it, vi } from "vitest";
import { slashCommandHelpLines, slashCommandHelpSections } from "../../../../src/features/chat/composer/slash-commands";
import type { Thread } from "../../../../src/generated/app-server/v2/Thread";
import type { ThreadGoal } from "../../../../src/generated/app-server/v2/ThreadGoal";
import { executeSlashCommand, type SlashCommandExecutionContext } from "../../../../src/features/chat/slash-commands";
function context(overrides: Partial<SlashCommandExecutionContext> = {}): SlashCommandExecutionContext {
@ -29,6 +30,10 @@ function context(overrides: Partial<SlashCommandExecutionContext> = {}): SlashCo
setStatus: vi.fn(),
setRequestedModel: vi.fn(),
setRequestedReasoningEffort: vi.fn(),
activeGoal: vi.fn(() => null),
setGoalObjective: vi.fn().mockResolvedValue(true),
setGoalStatus: vi.fn().mockResolvedValue(true),
clearGoal: vi.fn().mockResolvedValue(true),
statusSummaryLines: () => ["status"],
connectionDiagnosticDetails: () => [{ title: "Process", rows: [{ key: "connection", value: "connected" }] }],
mcpStatusLines: vi.fn().mockResolvedValue(["mcp"]),
@ -64,6 +69,20 @@ function thread(overrides: Partial<Thread> = {}): Thread {
} as Thread;
}
function goal(overrides: Partial<ThreadGoal> = {}): ThreadGoal {
return {
threadId: "thread-1",
objective: "Finish",
status: "active",
tokenBudget: null,
tokensUsed: 0,
timeUsedSeconds: 0,
createdAt: 1,
updatedAt: 1,
...overrides,
};
}
describe("slash commands", () => {
it("clears the current panel for /clear", async () => {
const ctx = context();
@ -218,6 +237,104 @@ describe("slash commands", () => {
expect(result).toBeUndefined();
});
it("shows the current goal for /goal", async () => {
const currentGoal = goal();
const ctx = context({ activeGoal: vi.fn(() => currentGoal) });
await executeSlashCommand("goal", "", ctx);
expect(ctx.addStructuredSystemMessage).toHaveBeenCalledWith(
"Thread goal",
expect.arrayContaining([
expect.objectContaining({ rows: expect.arrayContaining([expect.objectContaining({ key: "objective", value: "Finish" })]) }),
]),
);
});
it("reports no goal for bare /goal when unset", async () => {
const ctx = context();
await executeSlashCommand("goal", "", ctx);
expect(ctx.addSystemMessage).toHaveBeenCalledWith("No goal set.");
});
it("sets, pauses, resumes, and clears goals", async () => {
const currentGoal = goal();
const ctx = context({ 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");
});
it("loads the current goal into the composer for /goal edit", async () => {
const ctx = context({ activeGoal: vi.fn(() => goal({ objective: "Ship goal support" })) });
const result = await executeSlashCommand("goal", "edit", ctx);
expect(result).toEqual({ composerDraft: "/goal set Ship goal support" });
});
it("reports no goal for /goal edit when unset", async () => {
const ctx = context();
await executeSlashCommand("goal", "edit", ctx);
expect(ctx.addSystemMessage).toHaveBeenCalledWith("No goal set.");
});
it("requires objective text for /goal set", async () => {
const ctx = context();
await executeSlashCommand("goal", "set", ctx);
expect(ctx.setGoalObjective).not.toHaveBeenCalled();
expect(ctx.addSystemMessage).toHaveBeenCalledWith("/goal set <objective> requires an objective. Usage: /goal set <objective>");
});
it("rejects extra arguments for goal subcommands without free text", async () => {
const currentGoal = goal();
const ctx = context({ 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.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");
expect(ctx.addSystemMessage).toHaveBeenCalledWith("/goal clear does not take arguments. Usage: /goal clear");
});
it("rejects unknown goal subcommands", async () => {
const ctx = context();
await executeSlashCommand("goal", "stop", ctx);
expect(ctx.addSystemMessage).toHaveBeenCalledWith(
"/goal requires set <objective>, edit, pause, resume, or clear. Subcommands: /goal set <objective>, /goal edit, /goal pause, /goal resume, /goal clear. Usage: /goal [set <objective>|edit|pause|resume|clear]",
);
});
it("rejects goal mutation without an active thread", async () => {
const ctx = context({ activeThreadId: null });
await executeSlashCommand("goal", "set Ship this", ctx);
expect(ctx.setGoalObjective).not.toHaveBeenCalled();
expect(ctx.addSystemMessage).toHaveBeenCalledWith("No active thread for goal management.");
});
it("returns message text after toggling Plan mode for /plan arguments", async () => {
const ctx = context();

View file

@ -80,6 +80,17 @@ describe("ComposerSubmissionController", () => {
expect(sendTurnText).toHaveBeenCalledWith("hello", undefined, undefined);
});
it("restores slash command composer drafts from command results", async () => {
const { controller, execute, sendTurnText, setDraft } = createController("/goal edit");
execute.mockResolvedValue({ composerDraft: "/goal set Current objective" });
await controller.submit();
expect(setDraft).toHaveBeenCalledWith("", { clearSuggestions: true });
expect(setDraft).toHaveBeenCalledWith("/goal set Current objective", { focus: true, clearSuggestions: true });
expect(sendTurnText).not.toHaveBeenCalled();
});
it("interrupts a running turn when submitting an empty draft", async () => {
const { controller, interruptTurn, stateStore } = createController("");
stateStore.dispatch({

View file

@ -5,6 +5,7 @@ import { createChatState, createChatStateStore } from "../../../../../src/featur
import {
SlashCommandController,
type SlashCommandControllerHost,
type SlashCommandGoalPort,
type SlashCommandRuntimePort,
type SlashCommandStatusPort,
type SlashCommandThreadPort,
@ -40,14 +41,21 @@ function thread(id: string, name: string | null = null): Thread {
};
}
interface SlashCommandHostOverrides extends Partial<Omit<SlashCommandControllerHost, "threads" | "runtime" | "status">> {
interface SlashCommandHostOverrides extends Partial<Omit<SlashCommandControllerHost, "threads" | "runtime" | "goals" | "status">> {
threads?: Partial<SlashCommandThreadPort>;
runtime?: Partial<SlashCommandRuntimePort>;
goals?: Partial<SlashCommandGoalPort>;
status?: Partial<SlashCommandStatusPort>;
}
function createHost(overrides: SlashCommandHostOverrides = {}) {
const { threads: threadOverrides, runtime: runtimeOverrides, status: statusOverrides, ...hostOverrides } = overrides;
const {
threads: threadOverrides,
runtime: runtimeOverrides,
goals: goalOverrides,
status: statusOverrides,
...hostOverrides
} = overrides;
const stateStore = createChatStateStore(createChatState());
const compactThread = vi.fn().mockResolvedValue({});
const threadTurnsList = vi.fn().mockResolvedValue({ data: [] });
@ -81,12 +89,20 @@ function createHost(overrides: SlashCommandHostOverrides = {}) {
effortStatusLines: () => [],
...statusOverrides,
};
const goals: SlashCommandGoalPort = {
activeGoal: vi.fn(() => stateStore.getState().activeGoal),
setObjective: vi.fn().mockResolvedValue(true),
setStatus: vi.fn().mockResolvedValue(true),
clear: vi.fn().mockResolvedValue(true),
...goalOverrides,
};
const host: SlashCommandControllerHost = {
state: createSubmissionStatePort(stateStore),
currentClient: () => client,
codexInput: vi.fn((text: string) => textInput(text)),
threads,
runtime,
goals,
status,
...hostOverrides,
};

View file

@ -78,6 +78,7 @@ function createController(
forceMessagesToBottom: vi.fn(),
render: vi.fn(),
refreshLiveState: vi.fn(),
syncThreadGoal: vi.fn().mockResolvedValue(undefined),
...overrides,
};
return { controller: new ThreadResumeController(host), host, applyLatestPage, loadLatest, restoredClear, resumeThread, stateStore };
@ -90,6 +91,7 @@ describe("ThreadResumeController", () => {
await controller.resumeThread("thread");
expect(resumeThread).toHaveBeenCalledWith("thread", "/vault");
expect(host.syncThreadGoal).toHaveBeenCalledWith("thread");
expect(stateStore.getState().activeThreadId).toBe("thread");
expect(loadLatest).toHaveBeenCalledWith("thread");
expect(restoredClear).toHaveBeenCalledOnce();

View file

@ -11,6 +11,7 @@ describe("ChatViewRenderController", () => {
shell: { render },
panelRoot: () => root,
renderToolbar: vi.fn(),
renderGoal: vi.fn(),
renderMessages: vi.fn(),
renderComposer: vi.fn(),
clearScheduledRender: vi.fn(),
@ -23,6 +24,7 @@ describe("ChatViewRenderController", () => {
0,
expect.objectContaining({
renderToolbar: expect.any(Function),
renderGoal: expect.any(Function),
renderMessages: expect.any(Function),
renderComposer: expect.any(Function),
}),
@ -39,6 +41,7 @@ describe("ChatViewRenderController", () => {
shell: { render },
panelRoot: () => root,
renderToolbar: vi.fn(),
renderGoal: vi.fn(),
renderMessages: vi.fn(),
renderComposer: vi.fn(),
clearScheduledRender: vi.fn(),

View file

@ -0,0 +1,126 @@
import { describe, expect, it, vi } from "vitest";
import type { AppServerClient } from "../../../src/app-server/client";
import { ChatGoalController } from "../../../src/features/chat/goal-controller";
import { createChatState, createChatStateStore } from "../../../src/features/chat/chat-state";
import type { ThreadGoal } from "../../../src/generated/app-server/v2/ThreadGoal";
describe("ChatGoalController", () => {
it("syncs the active thread goal into chat state", async () => {
const state = createChatState();
state.activeThreadId = "thread";
const stateStore = createChatStateStore(state);
const currentGoal = goal();
const client = { getThreadGoal: vi.fn().mockResolvedValue({ goal: currentGoal }) } as unknown as AppServerClient;
const render = vi.fn();
const refreshLiveState = vi.fn();
const controller = new ChatGoalController({
stateStore,
currentClient: () => client,
ensureConnected: vi.fn().mockResolvedValue(undefined),
addSystemMessage: vi.fn(),
render,
refreshLiveState,
});
await controller.syncThreadGoal("thread");
expect(stateStore.getState().activeGoal).toEqual(currentGoal);
expect(refreshLiveState).toHaveBeenCalledOnce();
expect(render).toHaveBeenCalledOnce();
});
it("reports goal sync failures without clearing the active thread", async () => {
const state = createChatState();
state.activeThreadId = "thread";
const stateStore = createChatStateStore(state);
const addSystemMessage = vi.fn();
const client = { getThreadGoal: vi.fn().mockRejectedValue(new Error("offline")) } as unknown as AppServerClient;
const controller = new ChatGoalController({
stateStore,
currentClient: () => client,
ensureConnected: vi.fn().mockResolvedValue(undefined),
addSystemMessage,
render: vi.fn(),
refreshLiveState: vi.fn(),
});
await controller.syncThreadGoal("thread");
expect(stateStore.getState().activeThreadId).toBe("thread");
expect(stateStore.getState().activeGoal).toBeNull();
expect(addSystemMessage).toHaveBeenCalledWith("Could not load thread goal: offline");
});
it("sets objective, status, and clears goals through app-server", async () => {
const state = createChatState();
state.activeThreadId = "thread";
state.activeGoal = goal({ tokenBudget: 500 });
const stateStore = createChatStateStore(state);
const updated = goal({ objective: "Updated", tokenBudget: 250 });
const setThreadGoal = vi.fn().mockResolvedValue({ goal: updated });
const clearThreadGoal = vi.fn().mockResolvedValue({ cleared: true });
const client = {
setThreadGoal,
clearThreadGoal,
} as unknown as AppServerClient;
const addSystemMessage = vi.fn();
const controller = new ChatGoalController({
stateStore,
currentClient: () => client,
ensureConnected: vi.fn().mockResolvedValue(undefined),
addSystemMessage,
render: vi.fn(),
refreshLiveState: vi.fn(),
});
await controller.setObjective("thread", " Updated ", 250);
await controller.setStatus("thread", "paused");
await controller.clear("thread");
expect(setThreadGoal).toHaveBeenCalledWith("thread", { objective: "Updated", status: "active", tokenBudget: 250 });
expect(setThreadGoal).toHaveBeenCalledWith("thread", { status: "paused" });
expect(clearThreadGoal).toHaveBeenCalledWith("thread");
expect(addSystemMessage).toHaveBeenCalledWith("Goal updated.");
expect(addSystemMessage).toHaveBeenCalledWith("Goal paused.");
expect(addSystemMessage).toHaveBeenCalledWith("Goal cleared.");
expect(stateStore.getState().activeGoal).toBeNull();
});
it("reports goal creation and resume as user-visible system messages", async () => {
const state = createChatState();
state.activeThreadId = "thread";
const stateStore = createChatStateStore(state);
const setThreadGoal = vi.fn().mockResolvedValue({ goal: goal() });
const client = { setThreadGoal } as unknown as AppServerClient;
const addSystemMessage = vi.fn();
const controller = new ChatGoalController({
stateStore,
currentClient: () => client,
ensureConnected: vi.fn().mockResolvedValue(undefined),
addSystemMessage,
render: vi.fn(),
refreshLiveState: vi.fn(),
});
await controller.setObjective("thread", "Finish", null);
await controller.setStatus("thread", "active");
expect(addSystemMessage).toHaveBeenCalledWith("Goal set.");
expect(addSystemMessage).toHaveBeenCalledWith("Goal resumed.");
});
});
function goal(overrides: Partial<ThreadGoal> = {}): ThreadGoal {
return {
threadId: "thread",
objective: "Finish",
status: "active",
tokenBudget: null,
tokensUsed: 0,
timeUsedSeconds: 0,
createdAt: 1,
updatedAt: 1,
...overrides,
};
}

View file

@ -0,0 +1,317 @@
// @vitest-environment jsdom
import { describe, expect, it, vi } from "vitest";
import { act } from "preact/test-utils";
import { renderGoalBanner } from "../../../../../src/features/chat/ui/goal-banner";
import type { ThreadGoal } from "../../../../../src/generated/app-server/v2/ThreadGoal";
import type { SendShortcut } from "../../../../../src/shared/ui/keyboard";
import { installObsidianDomShims } from "../../../../support/dom";
installObsidianDomShims();
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
describe("renderGoalBanner", () => {
it("renders nothing when there is no goal", async () => {
const parent = document.createElement("div");
await act(async () => {
renderGoal(parent, null, actions());
});
expect(parent.textContent).toBe("");
});
it("renders active goal details and actions", async () => {
const parent = document.createElement("div");
await act(async () => {
renderGoal(parent, goal({ objective: "Ship goal support", tokenBudget: 100, tokensUsed: 12 }), actions());
});
expect(parent.textContent).toContain("Goal");
expect(parent.textContent).toContain("Ship goal support");
expect(parent.textContent).toContain("12 / 100 tokens");
expect(parent.querySelector(".codex-panel__goal--active")).not.toBeNull();
expect(parent.querySelector('[aria-label="Goal active"]')).toBeNull();
expect(parent.querySelector('[aria-label="Pause goal"]')).not.toBeNull();
});
it("collapses long objectives, expands from the message-style control, and recollapses on outside pointer", async () => {
const parent = document.createElement("div");
document.body.appendChild(parent);
await withGoalObjectiveScrollHeight(100, async () => {
await act(async () => {
renderGoal(parent, goal({ objective: "line 1\nline 2\nline 3\nline 4" }), actions());
});
const content = expectPresent(parent.querySelector<HTMLElement>(".codex-panel__goal-objective"));
const details = expectPresent(parent.querySelector<HTMLDetailsElement>(".codex-panel__goal-objective-collapse-details"));
expect(content.classList.contains("codex-panel__goal-objective--collapsed")).toBe(true);
expect(details.hidden).toBe(false);
expect(details.querySelector("summary")?.textContent).toBe("Show more");
await act(async () => {
details.open = true;
details.dispatchEvent(new Event("toggle"));
});
expect(content.classList.contains("codex-panel__goal-objective--collapsed")).toBe(false);
expect(details.hidden).toBe(true);
await outsidePointerDown();
expect(content.classList.contains("codex-panel__goal-objective--collapsed")).toBe(true);
expect(details.hidden).toBe(false);
});
parent.remove();
});
it("does not show the collapse control for short objectives", async () => {
const parent = document.createElement("div");
await withGoalObjectiveScrollHeight(40, async () => {
await act(async () => {
renderGoal(parent, goal({ objective: "short" }), actions());
});
});
expect(parent.querySelector(".codex-panel__goal-objective")?.classList.contains("codex-panel__goal-objective--collapsed")).toBe(false);
expect(parent.querySelector<HTMLDetailsElement>(".codex-panel__goal-objective-collapse-details")?.hidden).toBe(true);
});
it("saves inline edits from the editor frame icon while preserving the existing token budget", async () => {
const parent = document.createElement("div");
document.body.appendChild(parent);
const callbacks = actions();
await act(async () => {
renderGoal(parent, goal({ tokenBudget: 250 }), callbacks);
});
await click(parent, '[aria-label="Edit goal"]');
expect(document.activeElement).toBe(parent.querySelector("textarea"));
await input(parent, "textarea", "Updated objective");
await click(parent, '[aria-label="Save goal"]');
expect(parent.querySelector("textarea")).toBeNull();
expect(parent.querySelector('[aria-label="Save goal"]')).toBeNull();
expect(callbacks.onSave).toHaveBeenCalledWith("Updated objective", 250);
parent.remove();
});
it("keeps an in-progress edit open across usage-only goal updates", async () => {
const parent = document.createElement("div");
const callbacks = actions();
const currentGoal = goal({ objective: "Original", tokensUsed: 1, timeUsedSeconds: 2 });
await act(async () => {
renderGoal(parent, currentGoal, callbacks);
});
await click(parent, '[aria-label="Edit goal"]');
await input(parent, "textarea", "Draft objective");
await act(async () => {
renderGoal(parent, { ...currentGoal, tokensUsed: 99, timeUsedSeconds: 120 }, callbacks);
});
expect(parent.querySelector<HTMLTextAreaElement>("textarea")?.value).toBe("Draft objective");
});
it("cancels inline edits from Escape or an outside pointer", async () => {
const parent = document.createElement("div");
document.body.appendChild(parent);
const callbacks = actions();
await act(async () => {
renderGoal(parent, goal({ objective: "Original" }), callbacks);
});
await click(parent, '[aria-label="Edit goal"]');
await input(parent, "textarea", "Changed");
await documentKeydown(parent, "Escape");
expect(callbacks.onSave).not.toHaveBeenCalled();
expect(parent.querySelector("textarea")).toBeNull();
expect(parent.textContent).toContain("Original");
await click(parent, '[aria-label="Edit goal"]');
await input(parent, "textarea", "Changed again");
await outsidePointerDown();
expect(callbacks.onSave).not.toHaveBeenCalled();
expect(parent.querySelector("textarea")).toBeNull();
expect(parent.textContent).toContain("Original");
parent.remove();
});
it("autogrows the objective editor and saves with the configured composer send shortcut", async () => {
const parent = document.createElement("div");
const callbacks = actions();
await act(async () => {
renderGoal(parent, goal(), callbacks, "enter");
});
await click(parent, '[aria-label="Edit goal"]');
const textarea = expectPresent(parent.querySelector<HTMLTextAreaElement>("textarea"));
await input(parent, "textarea", "line 1\nline 2\nline 3");
expect(textarea.style.height).not.toBe("");
expect(textarea.style.overflowY).toBe("hidden");
await textareaKeydown(parent, { key: "Enter" });
expect(callbacks.onSave).toHaveBeenCalledWith("line 1\nline 2\nline 3", null);
expect(parent.querySelector("textarea")).toBeNull();
const modCallbacks = actions();
await act(async () => {
renderGoal(parent, goal({ objective: "Saved objective" }), modCallbacks, "mod-enter");
});
await click(parent, '[aria-label="Edit goal"]');
await input(parent, "textarea", "mod save");
await textareaKeydown(parent, { key: "Enter" });
expect(modCallbacks.onSave).not.toHaveBeenCalled();
await textareaKeydown(parent, { key: "Enter", metaKey: true });
expect(modCallbacks.onSave).toHaveBeenCalledWith("mod save", null);
});
it("routes pause, resume, and clear actions", async () => {
const parent = document.createElement("div");
const callbacks = actions();
await act(async () => {
renderGoal(parent, goal({ status: "active" }), callbacks);
});
await click(parent, '[aria-label="Pause goal"]');
expect(callbacks.onPause).toHaveBeenCalledOnce();
await act(async () => {
renderGoal(parent, goal({ status: "paused" }), callbacks);
});
await click(parent, '[aria-label="Resume goal"]');
expect(callbacks.onResume).toHaveBeenCalledOnce();
await click(parent, '[aria-label="Clear goal"]');
expect(callbacks.onClear).toHaveBeenCalledOnce();
});
it("does not show pause or resume for terminal statuses", async () => {
const parent = document.createElement("div");
await act(async () => {
renderGoal(parent, goal({ status: "complete" }), actions());
});
expect(parent.textContent).not.toContain("complete");
expect(parent.querySelector(".codex-panel__goal--complete")).not.toBeNull();
expect(parent.querySelector('[aria-label="Pause goal"]')).toBeNull();
expect(parent.querySelector('[aria-label="Resume goal"]')).toBeNull();
expect(parent.querySelector('[aria-label="Clear goal"]')).not.toBeNull();
});
});
function renderGoal(
parent: HTMLElement,
currentGoal: ThreadGoal | null,
callbacks = actions(),
sendShortcut: SendShortcut = "enter",
): void {
renderGoalBanner(parent, currentGoal, callbacks, { sendShortcut });
}
function actions() {
return {
onSave: vi.fn(),
onPause: vi.fn(),
onResume: vi.fn(),
onClear: vi.fn(),
};
}
function goal(overrides: Partial<ThreadGoal> = {}): ThreadGoal {
return {
threadId: "thread",
objective: "Finish",
status: "active",
tokenBudget: null,
tokensUsed: 0,
timeUsedSeconds: 0,
createdAt: 1,
updatedAt: 1,
...overrides,
};
}
async function click(parent: HTMLElement, selector: string): Promise<void> {
const element = parent.querySelector<HTMLElement>(selector);
if (!element) throw new Error(`Missing ${selector}`);
await act(async () => {
element.click();
});
}
async function input(parent: HTMLElement, selector: string, value: string): Promise<void> {
const element = parent.querySelector<HTMLInputElement | HTMLTextAreaElement>(selector);
if (!element) throw new Error(`Missing ${selector}`);
await act(async () => {
element.value = value;
element.dispatchEvent(new InputEvent("input", { bubbles: true }));
});
}
async function documentKeydown(parent: HTMLElement, key: string): Promise<void> {
if (!parent.isConnected) throw new Error("Parent must be connected");
await act(async () => {
parent.ownerDocument.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key }));
});
}
async function outsidePointerDown(): Promise<void> {
await act(async () => {
document.body.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true }));
});
}
async function withGoalObjectiveScrollHeight<T>(scrollHeight: number, fn: () => Promise<T>): Promise<T> {
const descriptor = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "scrollHeight");
Object.defineProperty(HTMLElement.prototype, "scrollHeight", {
configurable: true,
get() {
return this.classList.contains("codex-panel__goal-objective") ? scrollHeight : 0;
},
});
try {
return await fn();
} finally {
if (descriptor) {
Object.defineProperty(HTMLElement.prototype, "scrollHeight", descriptor);
} else {
Reflect.deleteProperty(HTMLElement.prototype, "scrollHeight");
}
}
}
async function textareaKeydown(
parent: HTMLElement,
options: { key: string; metaKey?: boolean; ctrlKey?: boolean; shiftKey?: boolean; altKey?: boolean; isComposing?: boolean },
): Promise<void> {
const element = parent.querySelector<HTMLTextAreaElement>("textarea");
if (!element) throw new Error("Missing textarea");
await act(async () => {
element.dispatchEvent(
new KeyboardEvent("keydown", {
bubbles: true,
key: options.key,
metaKey: options.metaKey ?? false,
ctrlKey: options.ctrlKey ?? false,
shiftKey: options.shiftKey ?? false,
altKey: options.altKey ?? false,
isComposing: options.isComposing ?? false,
}),
);
});
}
function expectPresent<T>(value: T | null | undefined): T {
if (value === null || value === undefined) throw new Error("Expected value to be present");
return value;
}

View file

@ -25,6 +25,7 @@ describe("ChatPanelShell", () => {
expect(container.classList.contains("codex-panel")).toBe(true);
expect(container.textContent).toContain("Idle");
expect(container.textContent).toContain("no goal");
expect(container.textContent).toContain("0");
expect(container.textContent).toContain("ready");
expect(container.querySelector(".codex-panel__slot--config")).toBeNull();
@ -76,6 +77,7 @@ describe("ChatPanelShell", () => {
});
expect(container.querySelector(".codex-panel__toolbar .test-toolbar")?.textContent).toBe("Working");
expect(container.querySelector(".codex-panel__slot--goal .test-goal")?.textContent).toBe("no goal");
expect(container.querySelector(".codex-panel__slot--messages .test-messages")?.textContent).toBe("1");
expect(container.querySelector<HTMLTextAreaElement>(".codex-panel__slot--composer .test-composer textarea")?.value).toBe("ready");
expect(container.querySelector(".codex-panel__slot--composer .test-toolbar")).toBeNull();
@ -175,6 +177,7 @@ describe("ChatPanelShell", () => {
expect(cleanup).toHaveBeenCalledWith("toolbar");
expect(container.textContent).toContain("toolbar");
expect(container.textContent).toContain("goal");
expect(container.textContent).toContain("messages");
expect(container.textContent).toContain("composer");
@ -201,6 +204,7 @@ describe("ChatPanelShell", () => {
});
expect(cleanup).toHaveBeenCalledWith("toolbar");
expect(cleanup).toHaveBeenCalledWith("goal");
expect(cleanup).toHaveBeenCalledWith("messages");
expect(cleanup).toHaveBeenCalledWith("composer");
});
@ -238,6 +242,12 @@ function shellRenderers(store: ReturnType<typeof createChatStateStore>) {
}),
snapshot: () => store.getState().status,
},
goal: {
render: vi.fn((goal: HTMLElement) => {
goal.textContent = store.getState().activeGoal?.objective ?? "no goal";
}),
snapshot: () => store.getState().activeGoal?.objective ?? "",
},
messages: {
render: vi.fn((messages: HTMLElement) => {
messages.textContent = String(store.getState().displayItems.length);
@ -270,6 +280,12 @@ function nestedRootShellRenderers(store: ReturnType<typeof createChatStateStore>
}),
snapshot: () => store.getState().status,
},
goal: {
render: vi.fn((goal: HTMLElement) => {
renderUiRoot(goal, <div className="test-goal">{store.getState().activeGoal?.objective ?? "no goal"}</div>);
}),
snapshot: () => store.getState().activeGoal?.objective ?? "",
},
messages: {
render: vi.fn((messages: HTMLElement) => {
renderUiRoot(messages, <div className="test-messages">{String(store.getState().displayItems.length)}</div>);
@ -302,6 +318,12 @@ function trackedRootShellRenderers(store: ReturnType<typeof createChatStateStore
}),
snapshot: () => store.getState().status,
},
goal: {
render: vi.fn((goal: HTMLElement) => {
renderUiRoot(goal, <TrackedSlot slot="goal" cleanup={cleanup} />);
}),
snapshot: () => store.getState().activeGoal?.objective ?? "",
},
messages: {
render: vi.fn((messages: HTMLElement) => {
renderUiRoot(messages, <TrackedSlot slot="messages" cleanup={cleanup} />);