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..d1f3068d9 100644 --- a/src/api/codexGateway.test.ts +++ b/src/api/codexGateway.test.ts @@ -1,5 +1,15 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import { getAvailableModelIds, getThreadDetail, listDirectoryComposioConnectors, resumeThread, startThreadTurn } from './codexGateway' +import { + clearThreadGoal, + compactThread, + 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 +71,61 @@ 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('thread compaction RPC', () => { + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('starts app-server compaction for the selected thread', async () => { + const { requests } = mockRpcFetch() + + await compactThread('thread-compact') + + expect(requests).toEqual([ + { method: 'thread/compact/start', params: { threadId: 'thread-compact' } }, + ]) + }) +}) + describe('listDirectoryComposioConnectors', () => { afterEach(() => { vi.unstubAllGlobals() diff --git a/src/api/codexGateway.ts b/src/api/codexGateway.ts index e66e46366..b0918e7a9 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 @@ -1550,6 +1603,10 @@ export async function archiveThread(threadId: string): Promise { await callRpc('thread/archive', { threadId }) } +export async function compactThread(threadId: string): Promise { + await callRpc('thread/compact/start', { threadId }) +} + export async function renameThread(threadId: string, threadName: string): Promise { await callRpc('thread/name/set', { threadId, name: threadName }) } diff --git a/src/components/content/ThreadComposer.vue b/src/components/content/ThreadComposer.vue index 9be5a3561..c68e308ce 100644 --- a/src/components/content/ThreadComposer.vue +++ b/src/components/content/ThreadComposer.vue @@ -96,6 +96,45 @@ +
+
+ Slash commands + Codex +
+ +
↑↓ Navigate · Tab select · Esc close
+
+
+ + + {{ slashCommandPreview.label }} + {{ slashCommandPreview.description }} + + Enter +