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 @@ Drop images or files + + + Slash commands + Codex + + + ◎ + + + {{ option.label }} + {{ option.command }} + + {{ option.description }} + + + ↑↓ Navigate · Tab select · Esc close + + + ◎ + + {{ slashCommandPreview.label }} + {{ slashCommandPreview.description }} + + Enter + ([]) const isFileMentionOpen = ref(false) const fileMentionHighlightedIndex = ref(0) +const slashCommandHighlightedIndex = ref(0) +const dismissedSlashCommandDraft = ref('') const isComposerExpanded = ref(false) const isDraftOverflowing = ref(false) let composerOverflowMeasurementQueued = false @@ -585,6 +631,21 @@ const isAndroid = typeof navigator !== 'undefined' && /Android/i.test(navigator. const DRAFT_STORAGE_PREFIX = 'codex-web-local.thread-draft.v1.' let lastActiveThreadId = '' +const slashCommandSuggestions = computed(() => getSlashCommandSuggestions(draft.value)) +const slashCommandPreview = computed(() => describeSlashCommand(draft.value)) +const isSlashCommandDismissed = computed(() => dismissedSlashCommandDraft.value === draft.value) +const isSlashCommandMenuOpen = computed(() => + !isFileMentionOpen.value + && !isSlashCommandDismissed.value + && slashCommandSuggestions.value.length > 0, +) +const isSlashCommandPreviewOpen = computed(() => + !isFileMentionOpen.value + && !isSlashCommandDismissed.value + && slashCommandSuggestions.value.length === 0 + && slashCommandPreview.value !== null, +) + const reasoningOptions: Array<{ value: ReasoningEffort; label: string }> = [ { value: 'none', label: 'None' }, { value: 'minimal', label: 'Minimal' }, @@ -1552,6 +1613,41 @@ function onInputChange(): void { } function onInputKeydown(event: KeyboardEvent): void { + if (isSlashCommandMenuOpen.value) { + if (event.key === 'Escape') { + event.preventDefault() + dismissedSlashCommandDraft.value = draft.value + return + } + if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { + event.preventDefault() + const size = slashCommandSuggestions.value.length + if (size > 0) { + const offset = event.key === 'ArrowDown' ? 1 : size - 1 + slashCommandHighlightedIndex.value = (slashCommandHighlightedIndex.value + offset) % size + } + return + } + if (event.key === 'Tab') { + event.preventDefault() + const selected = slashCommandSuggestions.value[slashCommandHighlightedIndex.value] + if (selected) applySlashCommandOption(selected) + return + } + if (event.key === 'Enter') { + const selected = slashCommandSuggestions.value[slashCommandHighlightedIndex.value] + const normalizedDraft = draft.value.trim() + const exactOption = describeSlashCommand(normalizedDraft) + const isRunnableExact = exactOption?.requiresArgument === false + && exactOption.insertText.trim() === normalizedDraft + if (!isRunnableExact) { + event.preventDefault() + if (selected) applySlashCommandOption(selected) + return + } + } + } + if (isFileMentionOpen.value) { if (event.key === 'Escape') { event.preventDefault() @@ -1596,6 +1692,18 @@ function onInputKeydown(event: KeyboardEvent): void { } } +function applySlashCommandOption(option: SlashCommandOption): void { + draft.value = option.insertText + dismissedSlashCommandDraft.value = option.insertText + slashCommandHighlightedIndex.value = 0 + void nextTick(() => { + const input = inputRef.value + input?.focus() + const cursor = option.insertText.length + input?.setSelectionRange(cursor, cursor) + }) +} + function closeFileMention(): void { isFileMentionOpen.value = false mentionStartIndex.value = null @@ -1837,6 +1945,7 @@ watch( () => props.activeThreadId, (nextThreadId) => { cancelDictation() + dismissedSlashCommandDraft.value = '' if (lastActiveThreadId) { persistDraftForThread(lastActiveThreadId, getCurrentDraftPayload()) } @@ -1858,6 +1967,7 @@ watch([draft, selectedImages, fileAttachments, selectedSkills], () => { watch(draft, () => { queueComposerOverflowMeasurement() + slashCommandHighlightedIndex.value = 0 }) watch( @@ -2045,6 +2155,58 @@ watch( @apply absolute left-0 right-0 bottom-[calc(100%+8px)] z-40 max-h-52 overflow-y-auto rounded-xl border border-zinc-200 bg-white p-1 shadow-lg; } +.thread-composer-slash-menu { + @apply absolute left-0 right-0 bottom-[calc(100%+8px)] z-40 max-h-80 overflow-y-auto rounded-xl border border-zinc-200 bg-white p-1 shadow-xl sm:max-h-[26rem]; +} + +.thread-composer-slash-heading { + @apply sticky top-0 z-10 flex items-center justify-between bg-white px-2 py-1.5 text-[10px] font-semibold uppercase tracking-[0.12em] text-zinc-400; +} + +.thread-composer-slash-row { + @apply flex w-full items-start gap-2 rounded-lg border-0 bg-transparent px-2 py-2 text-left text-zinc-700 transition hover:bg-amber-50; +} + +.thread-composer-slash-row.is-active { + @apply bg-amber-50; +} + +.thread-composer-slash-icon { + @apply mt-0.5 inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-amber-100 text-sm leading-none text-amber-700; +} + +.thread-composer-slash-copy { + @apply min-w-0 flex-1; +} + +.thread-composer-slash-title { + @apply flex min-w-0 items-baseline gap-2 text-xs text-zinc-900; +} + +.thread-composer-slash-title strong { + @apply shrink-0 font-medium; +} + +.thread-composer-slash-title code { + @apply truncate rounded bg-zinc-100 px-1 py-0.5 font-mono text-[10px] text-zinc-500; +} + +.thread-composer-slash-description { + @apply mt-0.5 block text-[11px] leading-4 text-zinc-500; +} + +.thread-composer-slash-help { + @apply sticky bottom-0 z-10 border-t border-zinc-100 bg-white px-2 pb-1 pt-1.5 text-[10px] text-zinc-400; +} + +.thread-composer-command-preview { + @apply absolute left-0 right-0 bottom-[calc(100%+8px)] z-40 flex items-start gap-2 rounded-xl border border-amber-200 bg-amber-50 px-3 py-2 shadow-lg; +} + +.thread-composer-command-preview kbd { + @apply ml-auto mt-1 shrink-0 rounded border border-amber-200 bg-white px-1.5 py-0.5 font-sans text-[10px] text-amber-700 shadow-sm; +} + .thread-composer-file-mention-row { @apply flex w-full items-center gap-2 rounded-md border-0 bg-transparent px-2 py-1.5 text-left text-xs text-zinc-700 transition hover:bg-zinc-100; } 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 @@ + + + ◎ + + + Goal + {{ statusLabel }} + {{ usageLabel }} + + + + Save + Cancel + + {{ goal.objective }} + + + Edit + + {{ goal.status === 'paused' ? 'Resume' : 'Pause' }} + + Clear + + + + + + + diff --git a/src/composables/useDesktopState.test.ts b/src/composables/useDesktopState.test.ts index b17d1f2b2..75b1446bb 100644 --- a/src/composables/useDesktopState.test.ts +++ b/src/composables/useDesktopState.test.ts @@ -13,6 +13,7 @@ import type { WorkspaceRootsState } from '../api/codexGateway' const gatewayMocks = vi.hoisted(() => ({ archiveThread: vi.fn(), + compactThread: vi.fn(), forkThread: vi.fn(), getAccountRateLimits: vi.fn(), getAvailableCollaborationModes: vi.fn(), @@ -21,6 +22,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,8 +37,11 @@ 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(), + startThreadReview: vi.fn(), startThreadTurn: vi.fn(), subscribeCodexNotifications: vi.fn(), })) @@ -45,6 +50,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 +88,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 +625,108 @@ 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() + }) + + it('executes Codex slash commands through native app-server methods', async () => { + installTestWindow() + gatewayMocks.compactThread.mockResolvedValue(undefined) + gatewayMocks.startThreadReview.mockResolvedValue(undefined) + + const state = useDesktopState() + state.primeSelectedThread('thread-goal') + + await state.sendMessageToSelectedThread('/compact') + await state.sendMessageToSelectedThread('/review') + await state.sendMessageToSelectedThread('/rename Release prep') + + expect(gatewayMocks.compactThread).toHaveBeenCalledWith('thread-goal') + expect(gatewayMocks.startThreadReview).toHaveBeenCalledWith( + 'thread-goal', + 'workspace', + 'unstaged', + ) + expect(gatewayMocks.renameThread).toHaveBeenCalledWith('thread-goal', 'Release prep') + expect(gatewayMocks.startThreadTurn).not.toHaveBeenCalled() + }) + + it('strips /plan and starts the turn in Plan mode', async () => { + installTestWindow() + gatewayMocks.resumeThread.mockResolvedValue({ + model: 'gpt-5.4', + modelProvider: 'openai', + messages: [], + inProgress: false, + activeTurnId: '', + hasMoreOlder: false, + turnIndexByTurnId: {}, + }) + gatewayMocks.startThreadTurn.mockResolvedValue({ id: 'turn-plan' }) + + const state = useDesktopState() + state.primeSelectedThread('thread-goal') + await state.sendMessageToSelectedThread('/plan Design the migration') + + expect(gatewayMocks.startThreadTurn).toHaveBeenCalled() + expect(gatewayMocks.startThreadTurn.mock.calls[0]?.[1]).toBe('Design the migration') + expect(gatewayMocks.startThreadTurn.mock.calls[0]?.[7]).toBe('plan') + }) +}) + 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..6953c0b41 100644 --- a/src/composables/useDesktopState.ts +++ b/src/composables/useDesktopState.ts @@ -2,6 +2,7 @@ import { computed, ref } from 'vue' import { archiveThread, + compactThread, forkThread, getAvailableCollaborationModes, getAccountRateLimits, @@ -11,6 +12,7 @@ import { getPendingServerRequests, getSkillsList, getThreadDetail, + getThreadGoal, getOlderThreadMessages, getBackgroundThreadListLimit, interruptThreadTurn, @@ -23,6 +25,9 @@ import { getWorkspaceRootsState, setCodexSpeedMode, setThreadQueueState, + setThreadGoal, + clearThreadGoal, + normalizeThreadGoal, setWorkspaceRootsState, getThreadTitleCache, persistThreadTitle, @@ -30,6 +35,7 @@ import { resumeThread, startThread, + startThreadReview, subscribeCodexNotifications, startThreadTurn, type RpcNotification, @@ -56,10 +62,13 @@ import type { UiServerRequest, UiServerRequestReply, UiThreadTokenUsage, + UiThreadGoal, UiTokenUsageBreakdown, UiThread, } from '../types/codex' import { getPathParent, isProjectlessChatPath, normalizePathForUi, toProjectName } from '../pathUtils.js' +import type { GoalCommand } from '../utils/goalCommand' +import { parseSlashCommand, type CodexSlashCommand } from '../utils/slashCommand' function flattenThreads(groups: UiProjectGroup[]): UiThread[] { return groups.flatMap((group) => group.threads) @@ -1460,6 +1469,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 +1547,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 +1614,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 +1704,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 +2402,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 +3884,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 +4141,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 = @@ -4807,6 +4957,65 @@ export function useDesktopState() { } } + function resolveAvailableModelId(model: string): string { + const normalizedModel = model.trim().toLowerCase() + if (!normalizedModel) return '' + return availableModelIds.value.find((modelId) => modelId.toLowerCase() === normalizedModel) ?? '' + } + + async function executeCodexControlCommand(threadId: string, command: CodexSlashCommand): Promise { + if (command.action === 'plan') return false + + error.value = '' + if (command.action === 'model') { + if (!command.model) { + error.value = 'Use /model , for example /model gpt-5.4.' + return true + } + const modelId = resolveAvailableModelId(command.model) + if (!modelId) { + error.value = `Unknown model "${command.model}". Choose one from the model picker.` + return true + } + setSelectedModelIdForThread(threadId, modelId) + return true + } + + if (command.action === 'rename') { + if (!command.name) { + error.value = 'Use /rename .' + return true + } + await renameThreadById(threadId, command.name) + return true + } + + if (inProgressById.value[threadId] === true) { + error.value = `Finish the current turn before running /${command.action}.` + return true + } + + if (command.action === 'fork') { + await forkThreadById(threadId) + return true + } + if (command.action === 'archive') { + await archiveThreadById(threadId) + return true + } + + try { + if (command.action === 'compact') { + await compactThread(threadId) + } else { + await startThreadReview(threadId, 'workspace', 'unstaged') + } + } catch (unknownError) { + error.value = unknownError instanceof Error ? unknownError.message : `Failed to run /${command.action}` + } + return true + } + async function maybeReplyToPendingUserInputRequest( threadId: string, text: string, @@ -4849,9 +5058,28 @@ export function useDesktopState() { if (isUpdatingSpeedMode.value) return const threadId = selectedThreadId.value - const nextText = text.trim() + let nextText = text.trim() + let effectiveCollaborationModeOverride = collaborationModeOverride if (!threadId || (!nextText && imageUrls.length === 0 && fileAttachments.length === 0)) return + const slashCommand = imageUrls.length === 0 && skills.length === 0 && fileAttachments.length === 0 + ? parseSlashCommand(nextText) + : null + if (slashCommand?.kind === 'goal') { + await executeGoalCommand(threadId, slashCommand.command) + return + } + if (slashCommand?.kind === 'codex') { + if (slashCommand.command.action === 'plan') { + setSelectedCollaborationModeForThread(threadId, 'plan') + if (!slashCommand.command.prompt) return + nextText = slashCommand.command.prompt + effectiveCollaborationModeOverride = 'plan' + } else if (await executeCodexControlCommand(threadId, slashCommand.command)) { + return + } + } + if (await maybeReplyToPendingUserInputRequest(threadId, nextText, imageUrls, skills, fileAttachments)) { return } @@ -4871,9 +5099,9 @@ export function useDesktopState() { imageUrls, skills, fileAttachments, - collaborationMode: collaborationModeOverride === 'plan' + collaborationMode: effectiveCollaborationModeOverride === 'plan' ? 'plan' - : collaborationModeOverride === 'default' + : effectiveCollaborationModeOverride === 'default' ? 'default' : selectedCollaborationMode.value, }) @@ -4893,7 +5121,7 @@ export function useDesktopState() { imageUrls, skills, fileAttachments, - collaborationModeOverride, + effectiveCollaborationModeOverride, ).catch((unknownError) => { const errorMessage = unknownError instanceof Error ? unknownError.message : 'Unknown application error' setTurnErrorForThread(threadId, errorMessage) @@ -4912,9 +5140,9 @@ export function useDesktopState() { details: buildPendingTurnDetails( readModelIdForThread(threadId), selectedReasoningEffort.value, - collaborationModeOverride === 'plan' + effectiveCollaborationModeOverride === 'plan' ? 'plan' - : collaborationModeOverride === 'default' + : effectiveCollaborationModeOverride === 'default' ? 'default' : selectedCollaborationMode.value, ), @@ -4930,7 +5158,7 @@ export function useDesktopState() { imageUrls, skills, fileAttachments, - collaborationModeOverride, + effectiveCollaborationModeOverride, ) } catch (unknownError) { shouldAutoScrollOnNextAgentEvent = false @@ -4952,11 +5180,46 @@ export function useDesktopState() { ): Promise { if (isUpdatingSpeedMode.value) return '' - const nextText = text.trim() + let nextText = text.trim() const targetCwd = cwd.trim() + if (!nextText && imageUrls.length === 0 && fileAttachments.length === 0) return '' + + const slashCommand = imageUrls.length === 0 && skills.length === 0 && fileAttachments.length === 0 + ? parseSlashCommand(nextText) + : null + const goalCommand = slashCommand?.kind === 'goal' ? slashCommand.command : null + if (goalCommand) { + if (goalCommand.action !== 'set') { + error.value = 'Start a goal from Home with /goal .' + return '' + } + nextText = goalCommand.objective + } else if (slashCommand?.kind === 'codex') { + if (slashCommand.command.action === 'plan') { + setSelectedCollaborationMode('plan') + if (!slashCommand.command.prompt) return '' + nextText = slashCommand.command.prompt + } else if (slashCommand.command.action === 'model') { + if (!slashCommand.command.model) { + error.value = 'Use /model , for example /model gpt-5.4.' + return '' + } + const modelId = resolveAvailableModelId(slashCommand.command.model) + if (!modelId) { + error.value = `Unknown model "${slashCommand.command.model}". Choose one from the model picker.` + return '' + } + setSelectedModelIdForThread('', modelId) + error.value = '' + return '' + } else { + error.value = `Open a chat before running /${slashCommand.command.action}.` + return '' + } + } + const selectedModel = readModelIdForThread(NEW_THREAD_COLLABORATION_MODE_CONTEXT).trim() const selectedMode = selectedCollaborationMode.value - if (!nextText && imageUrls.length === 0 && fileAttachments.length === 0) return '' isSendingMessage.value = true error.value = '' @@ -4983,6 +5246,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 +5798,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 +5884,9 @@ export function useDesktopState() { persistQueueState() codexRateLimit.value = null threadTokenUsageByThreadId.value = {} + threadGoalByThreadId.value = {} + loadedThreadGoalIds.clear() + threadGoalLoadByThreadId.clear() } const selectedThreadQueuedMessages = computed(() => { @@ -5666,6 +5948,7 @@ export function useDesktopState() { projectDisplayNameById, selectedThread, selectedThreadTokenUsage, + selectedThreadGoal, selectedThreadTerminalOpen, isSelectedThreadInterruptPending, selectedThreadServerRequests, @@ -5706,6 +5989,9 @@ export function useDesktopState() { forkThreadById, forkThreadFromTurn, rollbackSelectedThread, + updateSelectedThreadGoalObjective, + toggleSelectedThreadGoalPaused, + clearSelectedThreadGoal, sendMessageToSelectedThread, sendMessageToNewThread, diff --git a/src/server/localBrowseUi.test.ts b/src/server/localBrowseUi.test.ts new file mode 100644 index 000000000..7a200dd8a --- /dev/null +++ b/src/server/localBrowseUi.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest' +import { decodeBrowsePath, toBrowseHref, toEditHref } from './localBrowseUi' + +describe('decodeBrowsePath', () => { + it('removes the browse-route slash before Windows drive paths', () => { + expect(decodeBrowsePath('/C:/Users/Nulled/video.mp4', 'win32')).toBe('C:/Users/Nulled/video.mp4') + expect(decodeBrowsePath('/%43%3A/Users/Nulled/file.ps1', 'win32')).toBe('C:/Users/Nulled/file.ps1') + }) + + it('preserves Unix, UNC, and already normalized paths', () => { + expect(decodeBrowsePath('/home/codex/file.txt', 'linux')).toBe('/home/codex/file.txt') + expect(decodeBrowsePath('//server/share/file.txt', 'win32')).toBe('//server/share/file.txt') + expect(decodeBrowsePath('C:/Users/Nulled/file.txt', 'win32')).toBe('C:/Users/Nulled/file.txt') + }) + + it('leaves malformed URL encoding usable for the normal validation path', () => { + expect(decodeBrowsePath('/tmp/100%/file.txt', 'linux')).toBe('/tmp/100%/file.txt') + }) +}) + +describe('local browse route hrefs', () => { + it('adds the route separator and converts Windows path separators', () => { + const path = String.raw`C:\Users\Nulled\Documents\invasion\artifacts\autonomous-weaponry\mammoth-v1` + expect(toBrowseHref(path)).toBe( + '/codex-local-browse/C:/Users/Nulled/Documents/invasion/artifacts/autonomous-weaponry/mammoth-v1', + ) + expect(toEditHref(String.raw`C:\Users\Nulled\file.ps1`)).toBe( + '/codex-local-edit/C:/Users/Nulled/file.ps1', + ) + }) + + it('preserves Unix and UNC absolute paths', () => { + expect(toBrowseHref('/home/codex/folder')).toBe('/codex-local-browse/home/codex/folder') + expect(toBrowseHref(String.raw`\\server\share\folder`)).toBe('/codex-local-browse//server/share/folder') + }) + + it('keeps project picker query parameters encoded', () => { + expect(toBrowseHref(String.raw`C:\Users\Nulled\Parent Folder`, 'My Project')).toBe( + '/codex-local-browse/C:/Users/Nulled/Parent%20Folder?newProjectName=My%20Project', + ) + }) +}) diff --git a/src/server/localBrowseUi.ts b/src/server/localBrowseUi.ts index d5841107b..96a996bb3 100644 --- a/src/server/localBrowseUi.ts +++ b/src/server/localBrowseUi.ts @@ -69,13 +69,23 @@ export function normalizeLocalPath(rawPath: string): string { return trimmed } -export function decodeBrowsePath(rawPath: string): string { +export function decodeBrowsePath(rawPath: string, platform: NodeJS.Platform = process.platform): string { if (!rawPath) return '' + let decoded: string try { - return decodeURIComponent(rawPath) + decoded = decodeURIComponent(rawPath) } catch { - return rawPath + decoded = rawPath } + + // Browse URLs keep an absolute-path slash after the route prefix. On Windows, + // that turns `C:/path` into `/C:/path`, which Node resolves as `C:\C:\path`. + // Remove only that synthetic slash; Unix and UNC absolute paths stay intact. + if (platform === 'win32' && /^\/[A-Za-z]:[\\/]/u.test(decoded)) { + return decoded.slice(1) + } + + return decoded } export function isTextEditablePath(pathValue: string): boolean { @@ -131,16 +141,21 @@ function normalizeNewProjectName(value: string): string { return value.trim().replace(/[\\/]+/gu, '').trim() } -function toBrowseHref(pathValue: string, newProjectName = ''): string { +function normalizeLocalRoutePath(pathValue: string): string { + const normalized = pathValue.replace(/\\/gu, '/') + return normalized.startsWith('/') ? normalized : `/${normalized}` +} + +export function toBrowseHref(pathValue: string, newProjectName = ''): string { const normalizedName = normalizeNewProjectName(newProjectName) const query = normalizedName ? `?newProjectName=${encodeURIComponent(normalizedName)}` : '' - return `/codex-local-browse${encodeURI(pathValue)}${query}` + return `/codex-local-browse${encodeURI(normalizeLocalRoutePath(pathValue))}${query}` } -function toEditHref(pathValue: string, newProjectName = ''): string { +export function toEditHref(pathValue: string, newProjectName = ''): string { const normalizedName = normalizeNewProjectName(newProjectName) const query = normalizedName ? `?newProjectName=${encodeURIComponent(normalizedName)}` : '' - return `/codex-local-edit${encodeURI(pathValue)}${query}` + return `/codex-local-edit${encodeURI(normalizeLocalRoutePath(pathValue))}${query}` } function escapeForInlineScriptString(value: string): string { diff --git a/src/style.css b/src/style.css index 998480615..a8679d182 100644 --- a/src/style.css +++ b/src/style.css @@ -437,6 +437,53 @@ @apply border-zinc-600 bg-zinc-800; } +:root.dark .thread-composer-slash-menu { + @apply border-zinc-600 bg-zinc-800; +} + +:root.dark .thread-composer-slash-heading, +:root.dark .thread-composer-slash-description, +:root.dark .thread-composer-slash-help { + @apply text-zinc-400; +} + +:root.dark .thread-composer-slash-heading, +:root.dark .thread-composer-slash-help { + @apply bg-zinc-800; +} + +:root.dark .thread-composer-slash-row { + @apply text-zinc-300 hover:bg-amber-950/40; +} + +:root.dark .thread-composer-slash-row.is-active { + @apply bg-amber-950/40; +} + +:root.dark .thread-composer-slash-title { + @apply text-zinc-100; +} + +:root.dark .thread-composer-slash-title code { + @apply bg-zinc-700 text-zinc-300; +} + +:root.dark .thread-composer-slash-help { + @apply border-zinc-700; +} + +:root.dark .thread-composer-slash-icon { + @apply bg-amber-950/70 text-amber-300; +} + +:root.dark .thread-composer-command-preview { + @apply border-amber-800/70 bg-amber-950/60; +} + +:root.dark .thread-composer-command-preview kbd { + @apply border-amber-800 bg-zinc-800 text-amber-300; +} + :root.dark .thread-composer-file-mention-row { @apply text-zinc-300 hover:bg-zinc-700; } 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..cf3314d01 --- /dev/null +++ b/src/utils/goalCommand.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest' +import { describeGoalCommand, getGoalCommandSuggestions, 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', + }) + }) +}) + +describe('goal command discovery', () => { + it('shows Goal actions while the user types a command prefix', () => { + expect(getGoalCommandSuggestions('/').map((option) => option.id)).toEqual([ + 'start', 'view', 'edit', 'pause', 'resume', 'clear', + ]) + expect(getGoalCommandSuggestions('/go').map((option) => option.id)).toContain('start') + expect(getGoalCommandSuggestions('/goal e').map((option) => option.id)).toEqual(['edit']) + expect(getGoalCommandSuggestions('/goal pa').map((option) => option.id)).toEqual(['pause']) + }) + + it('switches from suggestions to an execution preview for an objective', () => { + expect(getGoalCommandSuggestions('/goal Ship it')).toEqual([]) + expect(describeGoalCommand('/goal Ship it')).toMatchObject({ + id: 'start', + label: 'Start a goal', + }) + expect(describeGoalCommand('ordinary message')).toBeNull() + }) +}) diff --git a/src/utils/goalCommand.ts b/src/utils/goalCommand.ts new file mode 100644 index 000000000..6696176b4 --- /dev/null +++ b/src/utils/goalCommand.ts @@ -0,0 +1,108 @@ +export type GoalCommand = + | { action: 'view' } + | { action: 'set'; objective: string } + | { action: 'edit'; objective: string } + | { action: 'pause' } + | { action: 'resume' } + | { action: 'clear' } + +export type GoalCommandOption = { + id: 'start' | 'view' | 'edit' | 'pause' | 'resume' | 'clear' + insertText: string + command: string + label: string + description: string + requiresArgument: boolean +} + +export const GOAL_COMMAND_OPTIONS: GoalCommandOption[] = [ + { + id: 'start', + insertText: '/goal ', + command: '/goal ', + label: 'Start a goal', + description: 'Give Codex a persistent objective and start autonomous work.', + requiresArgument: true, + }, + { + id: 'view', + insertText: '/goal', + command: '/goal', + label: 'View goal', + description: 'Show the goal attached to this chat.', + requiresArgument: false, + }, + { + id: 'edit', + insertText: '/goal edit ', + command: '/goal edit ', + label: 'Edit goal', + description: 'Replace the current goal objective without starting another turn.', + requiresArgument: true, + }, + { + id: 'pause', + insertText: '/goal pause', + command: '/goal pause', + label: 'Pause goal', + description: 'Stop autonomous continuation until you resume it.', + requiresArgument: false, + }, + { + id: 'resume', + insertText: '/goal resume', + command: '/goal resume', + label: 'Resume goal', + description: 'Continue working toward a paused goal.', + requiresArgument: false, + }, + { + id: 'clear', + insertText: '/goal clear', + command: '/goal clear', + label: 'Clear goal', + description: 'Remove the persistent goal from this chat.', + requiresArgument: false, + }, +] + +export function getGoalCommandSuggestions(value: string): GoalCommandOption[] { + const input = value.trimStart() + if (!input.startsWith('/') || input.includes('\n') || input.includes('\r')) return [] + + const normalized = input.toLowerCase() + if (normalized === '/') return GOAL_COMMAND_OPTIONS + if (!'/goal'.startsWith(normalized) && !normalized.startsWith('/goal ')) return [] + + if (!normalized.includes(' ')) { + return GOAL_COMMAND_OPTIONS + } + + return GOAL_COMMAND_OPTIONS.filter((option) => option.insertText.toLowerCase().startsWith(normalized)) +} + +export function describeGoalCommand(value: string): GoalCommandOption | null { + const command = parseGoalCommand(value) + if (!command) return null + if (command.action === 'set') return GOAL_COMMAND_OPTIONS[0] + return GOAL_COMMAND_OPTIONS.find((option) => option.id === command.action) ?? null +} + +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/src/utils/slashCommand.test.ts b/src/utils/slashCommand.test.ts new file mode 100644 index 000000000..c21b69a27 --- /dev/null +++ b/src/utils/slashCommand.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest' +import { + describeSlashCommand, + getSlashCommandSuggestions, + parseSlashCommand, +} from './slashCommand' + +describe('Codex slash command discovery', () => { + it('shows Goal mode and app-server-supported Codex commands', () => { + expect(getSlashCommandSuggestions('/').map((option) => option.id)).toEqual([ + 'start', + 'view', + 'edit', + 'pause', + 'resume', + 'clear', + 'plan', + 'review', + 'compact', + 'model', + 'rename', + 'fork', + 'archive', + ]) + }) + + it('filters command prefixes and keeps argument text out of the menu', () => { + expect(getSlashCommandSuggestions('/comp').map((option) => option.id)).toEqual(['compact']) + expect(getSlashCommandSuggestions('/model').map((option) => option.id)).toEqual(['model']) + expect(getSlashCommandSuggestions('/model gpt-5.4')).toEqual([]) + expect(getSlashCommandSuggestions('/goal e').map((option) => option.id)).toEqual(['edit']) + }) +}) + +describe('parseSlashCommand', () => { + it('parses native controls without treating ordinary prompts as commands', () => { + expect(parseSlashCommand('/plan Design the migration')).toEqual({ + kind: 'codex', + command: { action: 'plan', prompt: 'Design the migration' }, + }) + expect(parseSlashCommand('/compact')).toEqual({ + kind: 'codex', + command: { action: 'compact' }, + }) + expect(parseSlashCommand('/rename Release prep')).toEqual({ + kind: 'codex', + command: { action: 'rename', name: 'Release prep' }, + }) + expect(parseSlashCommand('please /review this')).toBeNull() + }) + + it('provides execution previews for argument commands', () => { + expect(describeSlashCommand('/plan Make a plan')).toMatchObject({ id: 'plan', label: 'Plan mode' }) + expect(describeSlashCommand('/model gpt-5.4')).toMatchObject({ id: 'model', label: 'Choose model' }) + expect(describeSlashCommand('/goal Ship it')).toMatchObject({ id: 'start', label: 'Start a goal' }) + }) +}) diff --git a/src/utils/slashCommand.ts b/src/utils/slashCommand.ts new file mode 100644 index 000000000..a325cb5d4 --- /dev/null +++ b/src/utils/slashCommand.ts @@ -0,0 +1,132 @@ +import { + GOAL_COMMAND_OPTIONS, + parseGoalCommand, + type GoalCommand, + type GoalCommandOption, +} from './goalCommand' + +export type CodexSlashCommand = + | { action: 'plan'; prompt: string } + | { action: 'compact' } + | { action: 'review' } + | { action: 'model'; model: string } + | { action: 'rename'; name: string } + | { action: 'fork' } + | { action: 'archive' } + +export type ParsedSlashCommand = + | { kind: 'goal'; command: GoalCommand } + | { kind: 'codex'; command: CodexSlashCommand } + +export type SlashCommandOption = GoalCommandOption | { + id: 'plan' | 'compact' | 'review' | 'model' | 'rename' | 'fork' | 'archive' + insertText: string + command: string + label: string + description: string + requiresArgument: boolean +} + +export const CODEX_COMMAND_OPTIONS: SlashCommandOption[] = [ + { + id: 'plan', + insertText: '/plan ', + command: '/plan [prompt]', + label: 'Plan mode', + description: 'Switch this chat to Plan mode, optionally starting with a prompt.', + requiresArgument: false, + }, + { + id: 'review', + insertText: '/review', + command: '/review', + label: 'Review changes', + description: 'Ask the Codex reviewer to inspect uncommitted changes.', + requiresArgument: false, + }, + { + id: 'compact', + insertText: '/compact', + command: '/compact', + label: 'Compact chat', + description: 'Summarize this chat to free space in the context window.', + requiresArgument: false, + }, + { + id: 'model', + insertText: '/model ', + command: '/model ', + label: 'Choose model', + description: 'Set the model used by later turns in this chat.', + requiresArgument: true, + }, + { + id: 'rename', + insertText: '/rename ', + command: '/rename ', + label: 'Rename chat', + description: 'Give the current chat a recognizable name.', + requiresArgument: true, + }, + { + id: 'fork', + insertText: '/fork', + command: '/fork', + label: 'Fork chat', + description: 'Branch this chat into a new conversation.', + requiresArgument: false, + }, + { + id: 'archive', + insertText: '/archive', + command: '/archive', + label: 'Archive chat', + description: 'Move this chat out of the active chat list.', + requiresArgument: false, + }, +] + +export const SLASH_COMMAND_OPTIONS: SlashCommandOption[] = [ + ...GOAL_COMMAND_OPTIONS, + ...CODEX_COMMAND_OPTIONS, +] + +export function getSlashCommandSuggestions(value: string): SlashCommandOption[] { + const input = value.trimStart() + if (!input.startsWith('/') || input.includes('\n') || input.includes('\r')) return [] + + const normalized = input.toLowerCase() + if (normalized === '/') return SLASH_COMMAND_OPTIONS + + return SLASH_COMMAND_OPTIONS.filter((option) => option.insertText.toLowerCase().startsWith(normalized)) +} + +export function describeSlashCommand(value: string): SlashCommandOption | null { + const parsed = parseSlashCommand(value) + if (!parsed) return null + + if (parsed.kind === 'goal') { + if (parsed.command.action === 'set') return GOAL_COMMAND_OPTIONS[0] + return GOAL_COMMAND_OPTIONS.find((option) => option.id === parsed.command.action) ?? null + } + + return CODEX_COMMAND_OPTIONS.find((option) => option.id === parsed.command.action) ?? null +} + +export function parseSlashCommand(value: string): ParsedSlashCommand | null { + const goalCommand = parseGoalCommand(value) + if (goalCommand) return { kind: 'goal', command: goalCommand } + + const match = value.trim().match(/^\/(plan|compact|review|model|rename|fork|archive)(?:\s+([\s\S]*))?$/iu) + if (!match) return null + + const action = match[1].toLowerCase() + const argument = (match[2] ?? '').trim() + if (action === 'plan') return { kind: 'codex', command: { action: 'plan', prompt: argument } } + if (action === 'model') return { kind: 'codex', command: { action: 'model', model: argument } } + if (action === 'rename') return { kind: 'codex', command: { action: 'rename', name: argument } } + if (action === 'compact') return { kind: 'codex', command: { action: 'compact' } } + if (action === 'review') return { kind: 'codex', command: { action: 'review' } } + if (action === 'fork') return { kind: 'codex', command: { action: 'fork' } } + return { kind: 'codex', command: { action: 'archive' } } +} 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..bdfb8f8fa 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) | @@ -27,6 +28,7 @@ Return to the [manual test index](../../tests.md). | [Feature: Inline thread image payloads are rewritten to renderable local file URLs](inline-thread-image-payloads-are-rewritten-to-renderable-local-file-urls.md) | | [Feature: Markdown file links with spaces and parentheses in path](markdown-file-links-with-spaces-and-parentheses-in-path.md) | | [Feature: Markdown link with backticked label renders as file link](markdown-link-with-backticked-label-renders-as-file-link.md) | +| [Feature: Windows absolute file links open through local browse](windows-absolute-file-links-open-local-browse.md) | | [Feature: Backticked bare filenames render as file links](backticked-bare-filenames-render-as-file-links.md) | | [Feature: Lazy message rendering (windowed conversation)](lazy-message-rendering-windowed-conversation.md) | | [Assistant generated image rendering](assistant-generated-image-rendering.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..7ec09ac1c --- /dev/null +++ b/tests/chat-composer-rendering/thread-goal-mode.md @@ -0,0 +1,37 @@ +# 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. Type `/` and confirm the composer shows all six Goal commands plus the supported Codex commands: Plan, Review, Compact, Model, Rename, Fork, and Archive. +2. Use Arrow Up/Down to change the highlighted command, Tab to complete it, and Escape to dismiss the menu. +3. Type `/goal pa` and confirm the list filters to **Pause goal**; press Tab and confirm the composer contains `/goal pause` without submitting it. +4. Type `/goal Ship the goal-mode feature with tests` and confirm the picker becomes a **Start a goal** execution preview. +5. Send the command and confirm the objective appears in a goal bar above the composer and app-server starts the autonomous goal loop. +6. Send `/goal` and confirm the existing goal remains visible without adding a chat message. +7. Choose **Edit**, change the objective, save it, and reload the page. +8. Choose **Pause**, then **Resume**, and verify the status label changes each time. +9. Send `/goal edit Refined objective`, `/goal pause`, and `/goal resume`; verify each command updates the same bar without appearing as a user message. +10. Open the chat at 375x812 and 768x1024 in both light and dark themes; verify the picker, preview, objective, status, and actions remain legible without horizontal page overflow. +11. Choose **Clear**, accept the confirmation, and reload the page. +12. From Home, select **Start a goal** from the slash picker, enter an 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. +- The slash picker makes Goal mode discoverable before submission and filters as a command prefix is typed. +- Codex commands execute through their native app-server actions; command text is not sent to the model as an ordinary prompt. +- Commands that require an objective complete their syntax instead of submitting an incomplete command. +- 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. diff --git a/tests/chat-composer-rendering/windows-absolute-file-links-open-local-browse.md b/tests/chat-composer-rendering/windows-absolute-file-links-open-local-browse.md new file mode 100644 index 000000000..3aac548df --- /dev/null +++ b/tests/chat-composer-rendering/windows-absolute-file-links-open-local-browse.md @@ -0,0 +1,27 @@ +### Feature: Windows absolute file links open through local browse + +## Prerequisites + +- Run CodexApp on Windows. +- Open TestChat with a project that contains an existing text file and MP4 file on a drive-letter path. + +## Steps + +1. Send a message containing Markdown links to `C:/path/to/file.ps1` and `C:/path/to/video.mp4`. +2. Inspect both rendered links and confirm their `href`, title, and visible text preserve the complete drive-letter paths. +3. Open the text-file link and confirm the request returns the existing file instead of a 404 response. +4. Open the MP4 link and seek within the video. +5. Inspect the MP4 request and confirm byte-range requests return `206 Partial Content` with `Accept-Ranges: bytes`. +6. Open a directory through local browse and click a nested folder plus the parent (`..`) link. + +## Expected Results + +- Windows drive paths are decoded as `C:/...`, not `/C:/...` or `C:\\C:\\...`. +- Existing files return successfully through `/codex-local-browse/C:/...`. +- MP4 files use the correct media content type and support browser range requests. +- Directory, parent, and edit links use `/codex-local-browse/C:/...` or `/codex-local-edit/C:/...` with forward slashes and a separator after the route prefix. +- Unix absolute paths and UNC paths retain their existing behavior. + +## Rollback / Cleanup + +- Close the opened file and video tabs. No files are modified by this test.
{{ option.command }}
{{ goal.objective }}