From 7b0bae209d0c4201ae4bf48fcdfcecbc953e741b Mon Sep 17 00:00:00 2001 From: Nul-led Date: Mon, 20 Jul 2026 13:55:44 +0200 Subject: [PATCH 1/5] Add app-server thread goal mode --- src/App.vue | 39 +++ src/api/codexGateway.test.ts | 50 ++- src/api/codexGateway.ts | 53 +++ src/components/content/ThreadGoalBar.vue | 303 ++++++++++++++++++ src/composables/useDesktopState.test.ts | 63 ++++ src/composables/useDesktopState.ts | 191 ++++++++++- src/types/codex.ts | 13 + src/utils/goalCommand.test.ts | 31 ++ src/utils/goalCommand.ts | 26 ++ tests.md | 2 +- tests/chat-composer-rendering/index.md | 1 + .../thread-goal-mode.md | 31 ++ 12 files changed, 799 insertions(+), 4 deletions(-) create mode 100644 src/components/content/ThreadGoalBar.vue create mode 100644 src/utils/goalCommand.test.ts create mode 100644 src/utils/goalCommand.ts create mode 100644 tests/chat-composer-rendering/thread-goal-mode.md diff --git a/src/App.vue b/src/App.vue index c39b2cd5b..b00dd89b2 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1012,6 +1012,14 @@ @hide="onHideSelectedThreadTerminal" @terminal-focus-change="onTerminalFocusChange" /> + (null) const threadComposerRef = ref(null) +const isUpdatingThreadGoal = ref(false) const threadConversationRef = ref<{ jumpToLatest: () => void } | null>(null) const homeTerminalPanelRef = ref(null) const threadTerminalPanelRef = ref(null) @@ -3430,6 +3444,31 @@ function onSubmitThreadMessage(payload: { text: string; imageUrls: string[]; fil void sendMessageToSelectedThread(text, payload.imageUrls, payload.skills, payload.mode, payload.fileAttachments, queueInsertIndex) } +async function runThreadGoalUpdate(update: () => Promise): Promise { + if (isUpdatingThreadGoal.value) return + isUpdatingThreadGoal.value = true + try { + await update() + } catch { + // The shared desktop error state already contains the app-server failure. + } finally { + isUpdatingThreadGoal.value = false + } +} + +function onEditThreadGoal(objective: string): void { + void runThreadGoalUpdate(() => updateSelectedThreadGoalObjective(objective)) +} + +function onToggleThreadGoalPaused(): void { + void runThreadGoalUpdate(toggleSelectedThreadGoalPaused) +} + +function onClearThreadGoal(): void { + if (!window.confirm('Clear this thread goal?')) return + void runThreadGoalUpdate(clearSelectedThreadGoal) +} + function onEditQueuedMessage(messageId: string): void { const queueIndex = selectedThreadQueuedMessages.value.findIndex((item) => item.id === messageId) const message = queueIndex >= 0 ? selectedThreadQueuedMessages.value[queueIndex] : undefined diff --git a/src/api/codexGateway.test.ts b/src/api/codexGateway.test.ts index 7bb4980f9..4bc5bb74c 100644 --- a/src/api/codexGateway.test.ts +++ b/src/api/codexGateway.test.ts @@ -1,5 +1,14 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import { getAvailableModelIds, getThreadDetail, listDirectoryComposioConnectors, resumeThread, startThreadTurn } from './codexGateway' +import { + clearThreadGoal, + getAvailableModelIds, + getThreadDetail, + getThreadGoal, + listDirectoryComposioConnectors, + resumeThread, + setThreadGoal, + startThreadTurn, +} from './codexGateway' function mockRpcFetch(): { requests: Array<{ method: string, params: Record }> } { const requests: Array<{ method: string, params: Record }> = [] @@ -61,6 +70,45 @@ describe('startThreadTurn collaboration mode payloads', () => { }) }) +describe('thread goal RPCs', () => { + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('normalizes goal reads and sends lifecycle updates through app-server', async () => { + const requests: Array<{ method: string; params: Record }> = [] + const goal = { + threadId: 'thread-1', + objective: 'Ship Goal mode', + status: 'active', + tokenBudget: 5000, + tokensUsed: 120, + timeUsedSeconds: 9, + createdAt: 10, + updatedAt: 11, + } + vi.stubGlobal('fetch', vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => { + const request = JSON.parse(String(init?.body)) as { method: string; params: Record } + requests.push(request) + const result = request.method === 'thread/goal/clear' ? { cleared: true } : { goal } + return new Response(JSON.stringify({ result }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + })) + + await expect(getThreadGoal('thread-1')).resolves.toEqual(goal) + await expect(setThreadGoal('thread-1', { status: 'paused' })).resolves.toEqual(goal) + await expect(clearThreadGoal('thread-1')).resolves.toBe(true) + + expect(requests).toEqual([ + { method: 'thread/goal/get', params: { threadId: 'thread-1' } }, + { method: 'thread/goal/set', params: { threadId: 'thread-1', status: 'paused' } }, + { method: 'thread/goal/clear', params: { threadId: 'thread-1' } }, + ]) + }) +}) + describe('listDirectoryComposioConnectors', () => { afterEach(() => { vi.unstubAllGlobals() diff --git a/src/api/codexGateway.ts b/src/api/codexGateway.ts index e66e46366..159de1a8d 100644 --- a/src/api/codexGateway.ts +++ b/src/api/codexGateway.ts @@ -40,6 +40,8 @@ import type { UiMessage, UiProjectGroup, UiThread, + UiThreadGoal, + ThreadGoalStatus, UiReviewAction, UiReviewActionLevel, UiReviewFile, @@ -555,6 +557,57 @@ async function callRpc(method: string, params?: unknown): Promise { } } +const THREAD_GOAL_STATUSES = new Set([ + 'active', + 'paused', + 'blocked', + 'usageLimited', + 'budgetLimited', + 'complete', +]) + +export function normalizeThreadGoal(value: unknown): UiThreadGoal | null { + const record = asRecord(value) + if (!record) return null + const threadId = readString(record.threadId) + const objective = readString(record.objective) + const rawStatus = readString(record.status) + if (!threadId || objective === null || !rawStatus || !THREAD_GOAL_STATUSES.has(rawStatus as ThreadGoalStatus)) { + return null + } + + return { + threadId, + objective, + status: rawStatus as ThreadGoalStatus, + tokenBudget: readNumber(record.tokenBudget), + tokensUsed: readNumber(record.tokensUsed) ?? 0, + timeUsedSeconds: readNumber(record.timeUsedSeconds) ?? 0, + createdAt: readNumber(record.createdAt) ?? 0, + updatedAt: readNumber(record.updatedAt) ?? 0, + } +} + +export async function getThreadGoal(threadId: string): Promise { + const payload = await callRpc<{ goal?: unknown }>('thread/goal/get', { threadId }) + return normalizeThreadGoal(payload.goal) +} + +export async function setThreadGoal( + threadId: string, + update: { objective?: string | null; status?: ThreadGoalStatus | null; tokenBudget?: number | null }, +): Promise { + const payload = await callRpc<{ goal?: unknown }>('thread/goal/set', { threadId, ...update }) + const goal = normalizeThreadGoal(payload.goal) + if (!goal) throw new Error('RPC thread/goal/set returned an invalid goal') + return goal +} + +export async function clearThreadGoal(threadId: string): Promise { + const payload = await callRpc<{ cleared?: boolean }>('thread/goal/clear', { threadId }) + return payload.cleared === true +} + function normalizeFallbackFileChange(value: unknown): UiFileChange | null { const record = asRecord(value) if (!record) return null diff --git a/src/components/content/ThreadGoalBar.vue b/src/components/content/ThreadGoalBar.vue new file mode 100644 index 000000000..68da618d4 --- /dev/null +++ b/src/components/content/ThreadGoalBar.vue @@ -0,0 +1,303 @@ + + + + + diff --git a/src/composables/useDesktopState.test.ts b/src/composables/useDesktopState.test.ts index b17d1f2b2..5ab15220a 100644 --- a/src/composables/useDesktopState.test.ts +++ b/src/composables/useDesktopState.test.ts @@ -21,6 +21,7 @@ const gatewayMocks = vi.hoisted(() => ({ getPendingServerRequests: vi.fn(), getSkillsList: vi.fn(), getThreadDetail: vi.fn(), + getThreadGoal: vi.fn(), getThreadGroupsPage: vi.fn(), getThreadQueueState: vi.fn(), getThreadTitleCache: vi.fn(), @@ -35,6 +36,8 @@ const gatewayMocks = vi.hoisted(() => ({ rollbackThread: vi.fn(), setCodexSpeedMode: vi.fn(), setThreadQueueState: vi.fn(), + setThreadGoal: vi.fn(), + clearThreadGoal: vi.fn(), setWorkspaceRootsState: vi.fn(), startThread: vi.fn(), startThreadTurn: vi.fn(), @@ -45,6 +48,7 @@ vi.mock('../api/codexGateway', () => ({ ...gatewayMocks, getBackgroundThreadListLimit: vi.fn(() => 100), pickCodexRateLimitSnapshot: vi.fn(() => null), + normalizeThreadGoal: vi.fn((value) => value), })) function thread(id: string, cwd: string, options: { hasWorktree?: boolean } = {}) { @@ -82,6 +86,7 @@ function installTestWindow(initialStorage: Record = {}) { beforeEach(() => { vi.clearAllMocks() gatewayMocks.getThreadQueueState.mockResolvedValue({}) + gatewayMocks.getThreadGoal.mockResolvedValue(null) gatewayMocks.getThreadTitleCache.mockResolvedValue({ titles: {} }) gatewayMocks.getWorkspaceRootsState.mockRejectedValue(new Error('no workspace roots state')) }) @@ -618,6 +623,64 @@ describe('startup request deduplication', () => { }) }) +describe('thread goals', () => { + const goal = { + threadId: 'thread-goal', + objective: 'Ship Goal mode', + status: 'active' as const, + tokenBudget: null, + tokensUsed: 0, + timeUsedSeconds: 0, + createdAt: 1, + updatedAt: 1, + } + + it('loads once per selected thread and applies notifications without a broad refresh', async () => { + installTestWindow() + let notificationHandler: (notification: { method: string; params?: unknown }) => void = () => {} + gatewayMocks.subscribeCodexNotifications.mockImplementation((handler) => { + notificationHandler = handler + return vi.fn() + }) + gatewayMocks.getPendingServerRequests.mockResolvedValue([]) + gatewayMocks.getThreadGoal.mockResolvedValue(goal) + + const state = useDesktopState() + state.primeSelectedThread('thread-goal') + state.startPolling() + await Promise.resolve() + await Promise.resolve() + + expect(gatewayMocks.getThreadGoal).toHaveBeenCalledTimes(1) + expect(state.selectedThreadGoal.value).toEqual(goal) + const threadListCalls = gatewayMocks.getThreadGroupsPage.mock.calls.length + + notificationHandler({ + method: 'thread/goal/updated', + params: { threadId: 'thread-goal', goal: { ...goal, status: 'paused', updatedAt: 2 } }, + }) + + expect(state.selectedThreadGoal.value?.status).toBe('paused') + expect(gatewayMocks.getThreadGroupsPage).toHaveBeenCalledTimes(threadListCalls) + }) + + it('lets app-server start a slash-command goal without duplicating turn/start', async () => { + installTestWindow() + gatewayMocks.getThreadGoal.mockResolvedValue(null) + gatewayMocks.setThreadGoal.mockResolvedValue(goal) + + const state = useDesktopState() + state.primeSelectedThread('thread-goal') + await state.sendMessageToSelectedThread('/goal Ship Goal mode') + + expect(gatewayMocks.setThreadGoal).toHaveBeenCalledWith('thread-goal', { + objective: 'Ship Goal mode', + status: 'active', + }) + expect(gatewayMocks.startThreadTurn).not.toHaveBeenCalled() + }) +}) + describe('live error overlay', () => { it('shows the default thinking overlay while a selected thread is in progress without activity events', async () => { installTestWindow() diff --git a/src/composables/useDesktopState.ts b/src/composables/useDesktopState.ts index 50244a778..acd1e394c 100644 --- a/src/composables/useDesktopState.ts +++ b/src/composables/useDesktopState.ts @@ -11,6 +11,7 @@ import { getPendingServerRequests, getSkillsList, getThreadDetail, + getThreadGoal, getOlderThreadMessages, getBackgroundThreadListLimit, interruptThreadTurn, @@ -23,6 +24,9 @@ import { getWorkspaceRootsState, setCodexSpeedMode, setThreadQueueState, + setThreadGoal, + clearThreadGoal, + normalizeThreadGoal, setWorkspaceRootsState, getThreadTitleCache, persistThreadTitle, @@ -56,10 +60,12 @@ import type { UiServerRequest, UiServerRequestReply, UiThreadTokenUsage, + UiThreadGoal, UiTokenUsageBreakdown, UiThread, } from '../types/codex' import { getPathParent, isProjectlessChatPath, normalizePathForUi, toProjectName } from '../pathUtils.js' +import { parseGoalCommand, type GoalCommand } from '../utils/goalCommand' function flattenThreads(groups: UiProjectGroup[]): UiThread[] { return groups.flatMap((group) => group.threads) @@ -1460,6 +1466,7 @@ export function useDesktopState() { const pendingTurnRequestByThreadId = ref>({}) const codexRateLimit = ref(null) const threadTokenUsageByThreadId = ref>(loadThreadTokenUsageMap()) + const threadGoalByThreadId = ref>({}) const terminalOpenByThreadId = ref>(loadThreadTerminalOpenMap()) const threadModelProviderByThreadId = ref>({}) @@ -1537,6 +1544,8 @@ export function useDesktopState() { let shouldAutoScrollOnNextAgentEvent = false const pendingTurnStartsById = new Map() const fallbackRetryInFlightThreadIds = new Set() + const loadedThreadGoalIds = new Set() + const threadGoalLoadByThreadId = new Map>() const allThreads = computed(() => flattenThreads(projectGroups.value)) @@ -1602,6 +1611,11 @@ export function useDesktopState() { if (!threadId) return null return threadTokenUsageByThreadId.value[threadId] ?? null }) + const selectedThreadGoal = computed(() => { + const threadId = selectedThreadId.value + if (!threadId) return null + return threadGoalByThreadId.value[threadId] ?? null + }) const messages = computed(() => { const threadId = selectedThreadId.value if (!threadId) return [] @@ -1687,6 +1701,117 @@ export function useDesktopState() { ) activeReasoningItemId = '' shouldAutoScrollOnNextAgentEvent = false + if (nextThreadId) void loadThreadGoal(nextThreadId).catch(() => {}) + } + + function storeThreadGoal(threadId: string, goal: UiThreadGoal | null): void { + threadGoalByThreadId.value = { + ...threadGoalByThreadId.value, + [threadId]: goal, + } + loadedThreadGoalIds.add(threadId) + } + + async function loadThreadGoal(threadId: string, options: { force?: boolean } = {}): Promise { + const normalizedThreadId = threadId.trim() + if (!normalizedThreadId) return null + if (!options.force && loadedThreadGoalIds.has(normalizedThreadId)) { + return threadGoalByThreadId.value[normalizedThreadId] ?? null + } + + const existingLoad = threadGoalLoadByThreadId.get(normalizedThreadId) + if (existingLoad) return existingLoad + + const load = getThreadGoal(normalizedThreadId) + .then((goal) => { + storeThreadGoal(normalizedThreadId, goal) + return goal + }) + .finally(() => { + threadGoalLoadByThreadId.delete(normalizedThreadId) + }) + threadGoalLoadByThreadId.set(normalizedThreadId, load) + return load + } + + async function updateThreadGoal( + threadId: string, + update: Parameters[1], + ): Promise { + try { + const goal = await setThreadGoal(threadId, update) + storeThreadGoal(threadId, goal) + error.value = '' + return goal + } catch (unknownError) { + error.value = unknownError instanceof Error ? unknownError.message : 'Failed to update goal' + throw unknownError + } + } + + async function updateSelectedThreadGoalObjective(objective: string): Promise { + const threadId = selectedThreadId.value + const normalizedObjective = objective.trim() + if (!threadId || !normalizedObjective) return + await updateThreadGoal(threadId, { objective: normalizedObjective }) + } + + async function toggleSelectedThreadGoalPaused(): Promise { + const threadId = selectedThreadId.value + const goal = selectedThreadGoal.value + if (!threadId || !goal) return + await updateThreadGoal(threadId, { status: goal.status === 'paused' ? 'active' : 'paused' }) + } + + async function clearSelectedThreadGoal(): Promise { + const threadId = selectedThreadId.value + if (!threadId) return + try { + await clearThreadGoal(threadId) + storeThreadGoal(threadId, null) + error.value = '' + } catch (unknownError) { + error.value = unknownError instanceof Error ? unknownError.message : 'Failed to clear goal' + throw unknownError + } + } + + async function executeGoalCommand(threadId: string, command: GoalCommand): Promise { + if (command.action === 'view') { + const goal = await loadThreadGoal(threadId, { force: true }) + if (!goal) error.value = 'No goal is set for this thread. Use /goal to start one.' + return + } + if (command.action === 'clear') { + await clearThreadGoal(threadId) + storeThreadGoal(threadId, null) + error.value = '' + return + } + if (command.action === 'pause' || command.action === 'resume') { + const goal = await loadThreadGoal(threadId) + if (!goal) { + error.value = 'No goal is set for this thread.' + return + } + await updateThreadGoal(threadId, { status: command.action === 'pause' ? 'paused' : 'active' }) + return + } + if (command.action === 'edit') { + if (!command.objective) { + error.value = 'Use /goal edit , or choose Edit in the goal bar.' + return + } + const goal = await loadThreadGoal(threadId) + if (!goal) { + error.value = 'No goal is set for this thread.' + return + } + await updateThreadGoal(threadId, { objective: command.objective }) + return + } + + await updateThreadGoal(threadId, { objective: command.objective, status: 'active' }) } function setSelectedModelIdForThread(threadId: string, modelId: string): void { @@ -2274,6 +2399,10 @@ export function useDesktopState() { persistQueueState() } threadTokenUsageByThreadId.value = pruneThreadStateMap(threadTokenUsageByThreadId.value, activeThreadIds) + threadGoalByThreadId.value = pruneThreadStateMap(threadGoalByThreadId.value, activeThreadIds) + for (const threadId of loadedThreadGoalIds) { + if (!activeThreadIds.has(threadId)) loadedThreadGoalIds.delete(threadId) + } eventUnreadByThreadId.value = pruneThreadStateMap(eventUnreadByThreadId.value, activeThreadIds) inProgressById.value = pruneThreadStateMap(inProgressById.value, activeThreadIds) const nextPending: Record = {} @@ -3752,6 +3881,20 @@ export function useDesktopState() { } } + if (notification.method === 'thread/goal/updated') { + const params = asRecord(notification.params) + const threadId = readString(params?.threadId) + const goal = normalizeThreadGoal(params?.goal) + if (threadId && goal) storeThreadGoal(threadId, goal) + return + } + + if (notification.method === 'thread/goal/cleared') { + const threadId = extractThreadIdFromNotification(notification) + if (threadId) storeThreadGoal(threadId, null) + return + } + if (notification.method === 'account/rateLimits/updated') { setCodexRateLimit(pickCodexRateLimitSnapshot(notification.params)) return @@ -3995,7 +4138,11 @@ export function useDesktopState() { } function queueEventDrivenSync(notification: RpcNotification): void { - if (notification.method === 'thread/tokenUsage/updated') return + if ( + notification.method === 'thread/tokenUsage/updated' + || notification.method === 'thread/goal/updated' + || notification.method === 'thread/goal/cleared' + ) return const method = notification.method const shouldRefreshMessages = @@ -4852,6 +4999,14 @@ export function useDesktopState() { const nextText = text.trim() if (!threadId || (!nextText && imageUrls.length === 0 && fileAttachments.length === 0)) return + const goalCommand = imageUrls.length === 0 && skills.length === 0 && fileAttachments.length === 0 + ? parseGoalCommand(nextText) + : null + if (goalCommand) { + await executeGoalCommand(threadId, goalCommand) + return + } + if (await maybeReplyToPendingUserInputRequest(threadId, nextText, imageUrls, skills, fileAttachments)) { return } @@ -4952,12 +5107,21 @@ export function useDesktopState() { ): Promise { if (isUpdatingSpeedMode.value) return '' - const nextText = text.trim() + let nextText = text.trim() const targetCwd = cwd.trim() const selectedModel = readModelIdForThread(NEW_THREAD_COLLABORATION_MODE_CONTEXT).trim() const selectedMode = selectedCollaborationMode.value if (!nextText && imageUrls.length === 0 && fileAttachments.length === 0) return '' + const goalCommand = imageUrls.length === 0 && skills.length === 0 && fileAttachments.length === 0 + ? parseGoalCommand(nextText) + : null + if (goalCommand && goalCommand.action !== 'set') { + error.value = 'Start a goal from Home with /goal .' + return '' + } + if (goalCommand?.action === 'set') nextText = goalCommand.objective + isSendingMessage.value = true error.value = '' let threadId = '' @@ -4983,6 +5147,21 @@ export function useDesktopState() { } if (!threadId) return '' + if (goalCommand?.action === 'set') { + await updateThreadGoal(threadId, { objective: goalCommand.objective, status: 'active' }) + insertOptimisticThread(threadId, targetCwd, goalCommand.objective) + resumedThreadById.value = { + ...resumedThreadById.value, + [threadId]: true, + } + setSelectedThreadId(threadId) + pendingThreadsRefresh = true + pendingThreadsRefreshForce = true + await syncFromNotifications() + isSendingMessage.value = false + return threadId + } + insertOptimisticThread(threadId, targetCwd, nextText || '[Image]') appendOptimisticUserMessage(threadId, nextText, imageUrls, skills, fileAttachments) blockInterruptUntilThreadIsPersisted(threadId) @@ -5520,6 +5699,7 @@ export function useDesktopState() { if (stopNotificationStream) return void loadPendingServerRequestsFromBridge() + if (selectedThreadId.value) void loadThreadGoal(selectedThreadId.value).catch(() => {}) stopNotificationStream = subscribeCodexNotifications((notification) => { if (notification.method === 'ready') { clearAllTransientTurnErrors() @@ -5605,6 +5785,9 @@ export function useDesktopState() { persistQueueState() codexRateLimit.value = null threadTokenUsageByThreadId.value = {} + threadGoalByThreadId.value = {} + loadedThreadGoalIds.clear() + threadGoalLoadByThreadId.clear() } const selectedThreadQueuedMessages = computed(() => { @@ -5666,6 +5849,7 @@ export function useDesktopState() { projectDisplayNameById, selectedThread, selectedThreadTokenUsage, + selectedThreadGoal, selectedThreadTerminalOpen, isSelectedThreadInterruptPending, selectedThreadServerRequests, @@ -5706,6 +5890,9 @@ export function useDesktopState() { forkThreadById, forkThreadFromTurn, rollbackSelectedThread, + updateSelectedThreadGoalObjective, + toggleSelectedThreadGoalPaused, + clearSelectedThreadGoal, sendMessageToSelectedThread, sendMessageToNewThread, diff --git a/src/types/codex.ts b/src/types/codex.ts index a80fe5d5a..109ac5caa 100644 --- a/src/types/codex.ts +++ b/src/types/codex.ts @@ -6,6 +6,19 @@ export type ReasoningEffort = 'none' | 'minimal' | 'low' | 'medium' | 'high' | ' export type SpeedMode = 'standard' | 'fast' export type CollaborationModeKind = 'default' | 'plan' +export type ThreadGoalStatus = 'active' | 'paused' | 'blocked' | 'usageLimited' | 'budgetLimited' | 'complete' + +export type UiThreadGoal = { + threadId: string + objective: string + status: ThreadGoalStatus + tokenBudget: number | null + tokensUsed: number + timeUsedSeconds: number + createdAt: number + updatedAt: number +} + export type RpcMethodCatalog = { data: string[] } diff --git a/src/utils/goalCommand.test.ts b/src/utils/goalCommand.test.ts new file mode 100644 index 000000000..f16d4b78d --- /dev/null +++ b/src/utils/goalCommand.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest' +import { parseGoalCommand } from './goalCommand' + +describe('parseGoalCommand', () => { + it('ignores ordinary messages and similarly named commands', () => { + expect(parseGoalCommand('please use /goal later')).toBeNull() + expect(parseGoalCommand('/goals ship it')).toBeNull() + }) + + it('parses goal lifecycle commands case-insensitively', () => { + expect(parseGoalCommand('/goal')).toEqual({ action: 'view' }) + expect(parseGoalCommand('/GOAL pause')).toEqual({ action: 'pause' }) + expect(parseGoalCommand('/goal resume')).toEqual({ action: 'resume' }) + expect(parseGoalCommand('/goal clear')).toEqual({ action: 'clear' }) + }) + + it('preserves multiline objectives and separates edits from new goals', () => { + expect(parseGoalCommand('/goal Ship the feature\nwith tests')).toEqual({ + action: 'set', + objective: 'Ship the feature\nwith tests', + }) + expect(parseGoalCommand('/goal edit Narrow the scope')).toEqual({ + action: 'edit', + objective: 'Narrow the scope', + }) + expect(parseGoalCommand('/goal pause after this turn')).toEqual({ + action: 'set', + objective: 'pause after this turn', + }) + }) +}) diff --git a/src/utils/goalCommand.ts b/src/utils/goalCommand.ts new file mode 100644 index 000000000..ef32dcbed --- /dev/null +++ b/src/utils/goalCommand.ts @@ -0,0 +1,26 @@ +export type GoalCommand = + | { action: 'view' } + | { action: 'set'; objective: string } + | { action: 'edit'; objective: string } + | { action: 'pause' } + | { action: 'resume' } + | { action: 'clear' } + +export function parseGoalCommand(value: string): GoalCommand | null { + const match = value.trim().match(/^\/goal(?:\s+([\s\S]*))?$/iu) + if (!match) return null + + const argument = (match[1] ?? '').trim() + if (!argument) return { action: 'view' } + + const subcommand = argument.match(/^(edit|pause|resume|clear)(?:\s+([\s\S]*))?$/iu) + if (!subcommand) return { action: 'set', objective: argument } + + const action = subcommand[1].toLowerCase() + const remainder = (subcommand[2] ?? '').trim() + if (action === 'edit') return { action: 'edit', objective: remainder } + if (remainder) return { action: 'set', objective: argument } + if (action === 'pause') return { action: 'pause' } + if (action === 'resume') return { action: 'resume' } + return { action: 'clear' } +} diff --git a/tests.md b/tests.md index 54bdfb58c..22da90344 100644 --- a/tests.md +++ b/tests.md @@ -18,7 +18,7 @@ This file is the manual test index. Detailed regression and feature verification | [Projects, Sidebar, and New Chat](tests/projects-sidebar-new-chat/index.md) | 16 | Home route, project picker, sidebar organization, new-chat setup, projectless folders, and project/worktree shell behavior. | | [Automations](tests/automations/index.md) | 4 | Thread heartbeat automations, project cron automations, dialogs, action rows, and automation panel behavior. | | [Skills, Plugins, and Integrations](tests/skills-plugins-integrations/index.md) | 27 | Skills Hub, skill sync, plugin/app directory surfaces, prompts, Composio, Telegram, and installed skill behavior. | -| [Chat Composer and Message Rendering](tests/chat-composer-rendering/index.md) | 33 | Composer controls, queued messages, plan mode, markdown parsing, file links, attachments, generated images, and visible message rows. | +| [Chat Composer and Message Rendering](tests/chat-composer-rendering/index.md) | 34 | Composer controls, queued messages, plan mode, goals, markdown parsing, file links, attachments, generated images, and visible message rows. | | [Thread Loading, Streaming, and State](tests/thread-loading-state/index.md) | 26 | Thread list/detail loading, pagination, selected-thread stability, streaming scroll behavior, live-state reads, and missing-thread handling. | | [Providers and Models](tests/providers-models/index.md) | 24 | Provider selectors, model menus, OpenRouter, OpenCode Zen, custom endpoints, Responses/Completions format, and model refresh behavior. | | [Auth and Docker Runtime](tests/auth-docker-runtime/index.md) | 12 | Codex auth, Docker-packaged runtime cases, copied auth behavior, invalid auth errors, and auth-aware provider fallback. | diff --git a/tests/chat-composer-rendering/index.md b/tests/chat-composer-rendering/index.md index b41a2dd99..c35d916ec 100644 --- a/tests/chat-composer-rendering/index.md +++ b/tests/chat-composer-rendering/index.md @@ -8,6 +8,7 @@ Return to the [manual test index](../../tests.md). | Section | | --- | +| [Feature: Thread goal mode](thread-goal-mode.md) | | [Codex thread deep links render as local web thread URLs](codex-thread-deep-links-render-as-local-web-thread-urls.md) | | [Bold-wrapped Markdown links render without literal markers](bold-wrapped-markdown-links-render-without-literal-markers.md) | | [Composer expands long drafts to full screen](composer-expands-long-drafts-to-full-screen.md) | diff --git a/tests/chat-composer-rendering/thread-goal-mode.md b/tests/chat-composer-rendering/thread-goal-mode.md new file mode 100644 index 000000000..c661ddd05 --- /dev/null +++ b/tests/chat-composer-rendering/thread-goal-mode.md @@ -0,0 +1,31 @@ +# Feature: Thread goal mode + +## Prerequisites + +- Run CodexApp against a Codex app-server version that exposes `thread/goal/get`, `thread/goal/set`, and `thread/goal/clear`. +- Open an existing idle chat. + +## Steps + +1. Send `/goal Ship the goal-mode feature with tests`. +2. Confirm the objective appears in a goal bar above the composer and app-server starts the autonomous goal loop. +3. Send `/goal` and confirm the existing goal remains visible without adding a chat message. +4. Choose **Edit**, change the objective, save it, and reload the page. +5. Choose **Pause**, then **Resume**, and verify the status label changes each time. +6. Send `/goal edit Refined objective`, `/goal pause`, and `/goal resume`; verify each command updates the same bar without appearing as a user message. +7. Open the chat at 375x812 and 768x1024 in both light and dark themes; verify the objective, status, and actions remain legible without horizontal page overflow. +8. Choose **Clear**, accept the confirmation, and reload the page. +9. From Home, send `/goal New-thread objective` and confirm a new chat is created with an active goal and its first turn starts. + +## Expected Results + +- Goal state is persisted by app-server, restored when selecting or reloading the thread, and updated immediately by goal notifications. +- Goal commands are handled as controls rather than ordinary chat messages. +- Goal loading is cached per thread; notifications update local state without triggering a thread-list or message reload. +- CodexApp does not issue a duplicate `turn/start`; app-server owns the autonomous goal loop. +- Editing, pausing, resuming, and clearing never start an extra agent turn. +- The goal bar is usable in light and dark themes at desktop, phone, and tablet widths. + +## Rollback/Cleanup + +- Clear any test goal with `/goal clear` or the **Clear** action. From 8bdef193ff00b48cd1ca3bada69a1572535cd0a3 Mon Sep 17 00:00:00 2001 From: Nul-led Date: Mon, 20 Jul 2026 14:53:28 +0200 Subject: [PATCH 2/5] Add Goal slash command discovery --- src/components/content/ThreadComposer.vue | 161 ++++++++++++++++++ src/style.css | 47 +++++ src/utils/goalCommand.test.ts | 22 ++- src/utils/goalCommand.ts | 82 +++++++++ .../thread-goal-mode.md | 23 ++- 5 files changed, 325 insertions(+), 10 deletions(-) diff --git a/src/components/content/ThreadComposer.vue b/src/components/content/ThreadComposer.vue index 9be5a3561..72513d378 100644 --- a/src/components/content/ThreadComposer.vue +++ b/src/components/content/ThreadComposer.vue @@ -96,6 +96,45 @@ +
+
+ Slash commands + Goal mode +
+ +
↑↓ Navigate · Tab select · Esc close
+
+
+ + + {{ goalCommandPreview.label }} + {{ goalCommandPreview.description }} + + Enter +