diff --git a/cli/src/modules/common/codexSessions.test.ts b/cli/src/modules/common/codexSessions.test.ts new file mode 100644 index 000000000..44e6e2ec6 --- /dev/null +++ b/cli/src/modules/common/codexSessions.test.ts @@ -0,0 +1,59 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test' +import { mkdir, writeFile, utimes } from 'node:fs/promises' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { listCodexSessions } from './codexSessions' + +describe('listCodexSessions', () => { + const originalCodexHome = process.env.CODEX_HOME + let codexHome: string + + beforeEach(async () => { + codexHome = join(tmpdir(), `hapi-codex-sessions-${Date.now()}-${Math.random().toString(16).slice(2)}`) + process.env.CODEX_HOME = codexHome + await mkdir(join(codexHome, 'sessions'), { recursive: true }) + }) + + afterEach(() => { + if (originalCodexHome === undefined) { + delete process.env.CODEX_HOME + return + } + process.env.CODEX_HOME = originalCodexHome + }) + + it('lists codex sessions and hides old entries by default', async () => { + const sessionsDir = join(codexHome, 'sessions') + const recentPath = join(sessionsDir, 'recent.jsonl') + const oldPath = join(sessionsDir, 'old.jsonl') + + await writeFile(recentPath, `${JSON.stringify({ type: 'session_meta', payload: { id: 'thread-recent', cwd: '/repo/recent', model: 'gpt-5' } })}\n`) + await writeFile(oldPath, `${JSON.stringify({ type: 'session_meta', payload: { id: 'thread-old', cwd: '/repo/old', model: 'gpt-5' } })}\n`) + + const oldDate = new Date(Date.now() - (190 * 24 * 60 * 60 * 1000)) + await utimes(oldPath, oldDate, oldDate) + + const recentOnly = await listCodexSessions() + expect(recentOnly.sessions.map((entry) => entry.id)).toEqual(['thread-recent']) + + const withOld = await listCodexSessions({ includeOld: true }) + expect(withOld.sessions.map((entry) => entry.id)).toEqual(['thread-recent', 'thread-old']) + expect(withOld.sessions[1]?.isOld).toBe(true) + }) + + it('supports cursor pagination', async () => { + const sessionsDir = join(codexHome, 'sessions') + for (let i = 0; i < 3; i++) { + const sessionPath = join(sessionsDir, `s-${i}.jsonl`) + await writeFile(sessionPath, `${JSON.stringify({ type: 'session_meta', payload: { id: `thread-${i}` } })}\n`) + } + + const page1 = await listCodexSessions({ includeOld: true, limit: 2 }) + expect(page1.sessions.length).toBe(2) + expect(page1.nextCursor).toBe('2') + + const page2 = await listCodexSessions({ includeOld: true, limit: 2, cursor: page1.nextCursor ?? undefined }) + expect(page2.sessions.length).toBe(1) + expect(page2.nextCursor).toBeNull() + }) +}) diff --git a/cli/src/modules/common/codexSessions.ts b/cli/src/modules/common/codexSessions.ts new file mode 100644 index 000000000..639ba08ec --- /dev/null +++ b/cli/src/modules/common/codexSessions.ts @@ -0,0 +1,227 @@ +import { homedir } from 'node:os'; +import { basename, extname, join } from 'node:path'; +import { promises as fs } from 'node:fs'; + +export interface CodexSessionSummary { + id: string; + title: string; + updatedAt: number; + path: string | null; + model: string | null; + isOld: boolean; +} + +export interface ListCodexSessionsRequest { + includeOld?: boolean; + olderThanDays?: number; + limit?: number; + cursor?: string; +} + +export interface ListCodexSessionsResponse { + success: boolean; + sessions?: CodexSessionSummary[]; + nextCursor?: string | null; + error?: string; +} + +type RawSession = { + id: string; + title: string; + updatedAt: number; + path: string | null; + model: string | null; +}; + +function asRecord(value: unknown): Record | null { + if (!value || typeof value !== 'object') { + return null; + } + return value as Record; +} + +function asNonEmptyString(value: unknown): string | null { + return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null; +} + +function readCodexHome(): string { + return process.env.CODEX_HOME ?? join(homedir(), '.codex'); +} + +async function walkJsonlFiles(rootDir: string, maxFiles: number): Promise { + const queue: string[] = [rootDir]; + const files: string[] = []; + + while (queue.length > 0 && files.length < maxFiles) { + const current = queue.shift(); + if (!current) { + break; + } + + let entries: fs.Dirent[]; + try { + entries = await fs.readdir(current, { withFileTypes: true }); + } catch { + continue; + } + + for (const entry of entries) { + if (entry.name.startsWith('.')) { + continue; + } + const fullPath = join(current, entry.name); + if (entry.isDirectory()) { + queue.push(fullPath); + continue; + } + if (entry.isFile() && extname(entry.name) === '.jsonl') { + files.push(fullPath); + if (files.length >= maxFiles) { + break; + } + } + } + } + + return files; +} + +function parseSessionLine(line: string): { id?: string; title?: string; path?: string; model?: string } | null { + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch { + return null; + } + + const record = asRecord(parsed); + if (!record) { + return null; + } + + const type = asNonEmptyString(record.type); + const payload = asRecord(record.payload); + + if (type === 'session_meta') { + const id = asNonEmptyString(payload?.id) ?? asNonEmptyString(record.id); + const path = asNonEmptyString(payload?.cwd) ?? asNonEmptyString(payload?.path); + const model = asNonEmptyString(payload?.model); + return { id: id ?? undefined, path: path ?? undefined, model: model ?? undefined }; + } + + if (type === 'event_msg') { + const messageType = asNonEmptyString(payload?.type); + if (messageType === 'agent_message') { + const text = asNonEmptyString(payload?.message) ?? asNonEmptyString(payload?.text); + if (text) { + return { title: text.slice(0, 80) }; + } + } + + if (messageType === 'thread_started') { + const id = asNonEmptyString(payload?.thread_id) ?? asNonEmptyString(payload?.threadId) ?? asNonEmptyString(payload?.id); + return id ? { id } : null; + } + } + + return null; +} + +async function parseSessionFile(filePath: string): Promise { + let stat: fs.Stats; + let content: string; + try { + [stat, content] = await Promise.all([ + fs.stat(filePath), + fs.readFile(filePath, 'utf8') + ]); + } catch { + return null; + } + + const lines = content.split('\n').filter((line) => line.trim().length > 0).slice(0, 400); + let id: string | null = null; + let title: string | null = null; + let path: string | null = null; + let model: string | null = null; + + for (const line of lines) { + const parsed = parseSessionLine(line); + if (!parsed) { + continue; + } + if (!id && parsed.id) { + id = parsed.id; + } + if (!title && parsed.title) { + title = parsed.title; + } + if (!path && parsed.path) { + path = parsed.path; + } + if (!model && parsed.model) { + model = parsed.model; + } + } + + const fallbackId = basename(filePath, extname(filePath)); + const resolvedId = id ?? fallbackId; + if (!resolvedId) { + return null; + } + + return { + id: resolvedId, + title: title ?? resolvedId, + updatedAt: Math.max(0, Math.floor(stat.mtimeMs)), + path, + model + }; +} + +export async function listCodexSessions(request: ListCodexSessionsRequest = {}): Promise<{ sessions: CodexSessionSummary[]; nextCursor: string | null }> { + const includeOld = request.includeOld === true; + const olderThanDays = Number.isFinite(request.olderThanDays) && (request.olderThanDays ?? 0) > 0 + ? Number(request.olderThanDays) + : 180; + const limit = Number.isFinite(request.limit) && (request.limit ?? 0) > 0 + ? Math.min(100, Math.floor(Number(request.limit))) + : 50; + const offset = Number.isFinite(Number(request.cursor)) && Number(request.cursor) >= 0 + ? Math.floor(Number(request.cursor)) + : 0; + + const sessionsDir = join(readCodexHome(), 'sessions'); + const files = await walkJsonlFiles(sessionsDir, 5000); + const raw = (await Promise.all(files.map((filePath) => parseSessionFile(filePath)))) + .filter((entry): entry is RawSession => entry !== null); + + const deduped = new Map(); + for (const entry of raw) { + const existing = deduped.get(entry.id); + if (!existing || existing.updatedAt < entry.updatedAt) { + deduped.set(entry.id, entry); + } + } + + const cutoff = Date.now() - olderThanDays * 24 * 60 * 60 * 1000; + const sorted = Array.from(deduped.values()) + .sort((a, b) => b.updatedAt - a.updatedAt) + .map((entry) => ({ + id: entry.id, + title: entry.title, + updatedAt: entry.updatedAt, + path: entry.path, + model: entry.model, + isOld: entry.updatedAt < cutoff + })); + + const filtered = includeOld ? sorted : sorted.filter((entry) => !entry.isOld); + const sliced = filtered.slice(offset, offset + limit); + const nextOffset = offset + sliced.length; + + return { + sessions: sliced, + nextCursor: nextOffset < filtered.length ? String(nextOffset) : null + }; +} diff --git a/cli/src/modules/common/handlers/codexSessions.ts b/cli/src/modules/common/handlers/codexSessions.ts new file mode 100644 index 000000000..e3e256fcc --- /dev/null +++ b/cli/src/modules/common/handlers/codexSessions.ts @@ -0,0 +1,26 @@ +import { logger } from '@/ui/logger'; +import type { RpcHandlerManager } from '@/api/rpc/RpcHandlerManager'; +import { + listCodexSessions, + type ListCodexSessionsRequest, + type ListCodexSessionsResponse +} from '../codexSessions'; +import { getErrorMessage, rpcError } from '../rpcResponses'; + +export function registerCodexSessionHandlers(rpcHandlerManager: RpcHandlerManager): void { + rpcHandlerManager.registerHandler('listCodexSessions', async (data) => { + logger.debug('List Codex sessions request'); + + try { + const result = await listCodexSessions(data ?? {}); + return { + success: true, + sessions: result.sessions, + nextCursor: result.nextCursor + }; + } catch (error) { + logger.debug('Failed to list Codex sessions:', error); + return rpcError(getErrorMessage(error, 'Failed to list Codex sessions')); + } + }); +} diff --git a/cli/src/modules/common/registerCommonHandlers.ts b/cli/src/modules/common/registerCommonHandlers.ts index fa8ba0b6d..68d981195 100644 --- a/cli/src/modules/common/registerCommonHandlers.ts +++ b/cli/src/modules/common/registerCommonHandlers.ts @@ -1,6 +1,7 @@ import type { RpcHandlerManager } from '@/api/rpc/RpcHandlerManager' import { registerBashHandlers } from './handlers/bash' import { registerCodexModelHandlers } from './handlers/codexModels' +import { registerCodexSessionHandlers } from './handlers/codexSessions' import { registerDirectoryHandlers } from './handlers/directories' import { registerDifftasticHandlers } from './handlers/difftastic' import { registerFileHandlers } from './handlers/files' @@ -13,6 +14,7 @@ import { registerUploadHandlers } from './handlers/uploads' export function registerCommonHandlers(rpcHandlerManager: RpcHandlerManager, workingDirectory: string): void { registerBashHandlers(rpcHandlerManager, workingDirectory) registerCodexModelHandlers(rpcHandlerManager) + registerCodexSessionHandlers(rpcHandlerManager) registerFileHandlers(rpcHandlerManager, workingDirectory) registerDirectoryHandlers(rpcHandlerManager, workingDirectory) registerRipgrepHandlers(rpcHandlerManager, workingDirectory) diff --git a/hub/src/sync/rpcGateway.ts b/hub/src/sync/rpcGateway.ts index 7899261d9..f996b42da 100644 --- a/hub/src/sync/rpcGateway.ts +++ b/hub/src/sync/rpcGateway.ts @@ -58,6 +58,23 @@ export type RpcListCodexModelsResponse = { error?: string } + +export type RpcCodexSession = { + id: string + title: string + updatedAt: number + path: string | null + model: string | null + isOld: boolean +} + +export type RpcListCodexSessionsResponse = { + success: boolean + sessions?: RpcCodexSession[] + nextCursor?: string | null + error?: string +} + export class RpcGateway { constructor( private readonly io: Server, @@ -262,6 +279,13 @@ export class RpcGateway { return await this.machineRpc(machineId, 'listCodexModels', {}) as RpcListCodexModelsResponse } + async listCodexSessionsForMachine( + machineId: string, + options?: { includeOld?: boolean; olderThanDays?: number; limit?: number; cursor?: string } + ): Promise { + return await this.machineRpc(machineId, 'listCodexSessions', options ?? {}) as RpcListCodexSessionsResponse + } + private async sessionRpc(sessionId: string, method: string, params: unknown): Promise { return await this.rpcCall(`${sessionId}:${method}`, params) } diff --git a/hub/src/sync/syncEngine.ts b/hub/src/sync/syncEngine.ts index d92037961..466c4a889 100644 --- a/hub/src/sync/syncEngine.ts +++ b/hub/src/sync/syncEngine.ts @@ -22,6 +22,7 @@ import { type RpcDeleteUploadResponse, type RpcListDirectoryResponse, type RpcListCodexModelsResponse, + type RpcListCodexSessionsResponse, type RpcPathExistsResponse, type RpcReadFileResponse, type RpcUploadFileResponse @@ -586,4 +587,13 @@ export class SyncEngine { async listCodexModelsForMachine(machineId: string): Promise { return await this.rpcGateway.listCodexModelsForMachine(machineId) } + + + async listCodexSessionsForMachine( + machineId: string, + options?: { includeOld?: boolean; olderThanDays?: number; limit?: number; cursor?: string } + ): Promise { + return await this.rpcGateway.listCodexSessionsForMachine(machineId, options) + } + } diff --git a/hub/src/web/routes/machines.test.ts b/hub/src/web/routes/machines.test.ts index f616adaa7..b88f968bb 100644 --- a/hub/src/web/routes/machines.test.ts +++ b/hub/src/web/routes/machines.test.ts @@ -56,4 +56,51 @@ describe('machines routes', () => { ] }) }) + + it('returns Codex sessions for an online machine', async () => { + const machine = createMachine() + const engine = { + getMachine: () => machine, + getMachineByNamespace: () => machine, + listCodexSessionsForMachine: async () => ({ + success: true, + sessions: [ + { + id: 'thread-1', + title: 'Fix auth bug', + updatedAt: 100, + path: '/repo', + model: 'gpt-5', + isOld: false + } + ], + nextCursor: null + }) + } as Partial + + const app = new Hono() + app.use('*', async (c, next) => { + c.set('namespace', 'default') + await next() + }) + app.route('/api', createMachinesRoutes(() => engine as SyncEngine)) + + const response = await app.request('/api/machines/machine-1/codex-sessions?includeOld=1&limit=20') + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + success: true, + sessions: [ + { + id: 'thread-1', + title: 'Fix auth bug', + updatedAt: 100, + path: '/repo', + model: 'gpt-5', + isOld: false + } + ], + nextCursor: null + }) + }) }) diff --git a/hub/src/web/routes/machines.ts b/hub/src/web/routes/machines.ts index cf4a46055..d4d7051e5 100644 --- a/hub/src/web/routes/machines.ts +++ b/hub/src/web/routes/machines.ts @@ -12,7 +12,8 @@ const spawnBodySchema = z.object({ modelReasoningEffort: z.string().optional(), yolo: z.boolean().optional(), sessionType: z.enum(['simple', 'worktree']).optional(), - worktreeName: z.string().optional() + worktreeName: z.string().optional(), + resumeSessionId: z.string().optional() }) const pathsExistsSchema = z.object({ @@ -60,7 +61,7 @@ export function createMachinesRoutes(getSyncEngine: () => SyncEngine | null): Ho parsed.data.yolo, parsed.data.sessionType, parsed.data.worktreeName, - undefined, + parsed.data.resumeSessionId, parsed.data.effort ) return c.json(result) @@ -123,6 +124,47 @@ export function createMachinesRoutes(getSyncEngine: () => SyncEngine | null): Ho } }) + + app.get('/machines/:id/codex-sessions', async (c) => { + const engine = getSyncEngine() + if (!engine) { + return c.json({ success: false, error: 'Not connected' }, 503) + } + + const machineId = c.req.param('id') + const machine = requireMachine(c, engine, machineId) + if (machine instanceof Response) { + return machine + } + + const query = c.req.query() + const includeOld = query.includeOld === '1' || query.includeOld === 'true' + const olderThanDays = Number.isFinite(Number(query.olderThanDays)) + ? Number(query.olderThanDays) + : undefined + const limit = Number.isFinite(Number(query.limit)) + ? Number(query.limit) + : undefined + const cursor = typeof query.cursor === 'string' && query.cursor.length > 0 + ? query.cursor + : undefined + + try { + const result = await engine.listCodexSessionsForMachine(machineId, { + includeOld, + olderThanDays, + limit, + cursor + }) + return c.json(result) + } catch (error) { + return c.json({ + success: false, + error: error instanceof Error ? error.message : 'Failed to list Codex sessions' + }, 500) + } + }) + app.get('/machines/:id/codex-models', async (c) => { const engine = getSyncEngine() if (!engine) { diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 19c577b54..ef0bf4afb 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -12,6 +12,7 @@ import type { MachinesResponse, MessagesResponse, CodexModelsResponse, + CodexSessionsResponse, PermissionMode, PushSubscriptionPayload, PushUnsubscribePayload, @@ -414,14 +415,40 @@ export class ApiClient { yolo?: boolean, sessionType?: 'simple' | 'worktree', worktreeName?: string, - effort?: string + effort?: string, + resumeSessionId?: string ): Promise { return await this.request(`/api/machines/${encodeURIComponent(machineId)}/spawn`, { method: 'POST', - body: JSON.stringify({ directory, agent, model, modelReasoningEffort, yolo, sessionType, worktreeName, effort }) + body: JSON.stringify({ directory, agent, model, modelReasoningEffort, yolo, sessionType, worktreeName, effort, resumeSessionId }) }) } + + async getMachineCodexSessions( + machineId: string, + options?: { includeOld?: boolean; olderThanDays?: number; limit?: number; cursor?: string } + ): Promise { + const params = new URLSearchParams() + if (options?.includeOld !== undefined) { + params.set('includeOld', options.includeOld ? '1' : '0') + } + if (options?.olderThanDays !== undefined) { + params.set('olderThanDays', String(options.olderThanDays)) + } + if (options?.limit !== undefined) { + params.set('limit', String(options.limit)) + } + if (options?.cursor) { + params.set('cursor', options.cursor) + } + + const query = params.toString() + return await this.request( + `/api/machines/${encodeURIComponent(machineId)}/codex-sessions${query ? `?${query}` : ''}` + ) + } + async getMachineCodexModels(machineId: string): Promise { return await this.request( `/api/machines/${encodeURIComponent(machineId)}/codex-models` diff --git a/web/src/components/NewSession/CodexSessionSelector.tsx b/web/src/components/NewSession/CodexSessionSelector.tsx new file mode 100644 index 000000000..3b1f428ac --- /dev/null +++ b/web/src/components/NewSession/CodexSessionSelector.tsx @@ -0,0 +1,69 @@ +import type { CodexSessionSummary } from '@/types/api' +import { useTranslation } from '@/lib/use-translation' + +function formatDate(timestamp: number): string { + if (!Number.isFinite(timestamp) || timestamp <= 0) { + return '-' + } + return new Date(timestamp).toLocaleDateString() +} + +export function CodexSessionSelector(props: { + enabled: boolean + includeOld: boolean + sessions: CodexSessionSummary[] + selectedSessionId: string + isLoading: boolean + isDisabled?: boolean + error?: string | null + onToggleIncludeOld: (value: boolean) => void + onSelectSession: (sessionId: string) => void +}) { + const { t } = useTranslation() + + if (!props.enabled) { + return null + } + + const options = [{ id: '', label: t('newSession.codexSession.newSession') }] + for (const session of props.sessions) { + const date = formatDate(session.updatedAt) + const location = session.path ? ` · ${session.path}` : '' + options.push({ + id: session.id, + label: `${session.title} (${date})${location}` + }) + } + + return ( +
+
+ + +
+ + {props.isLoading ?
{t('newSession.codexSession.loading')}
: null} + {props.error ?
{props.error}
: null} +
+ ) +} diff --git a/web/src/components/NewSession/index.tsx b/web/src/components/NewSession/index.tsx index 721596b8d..df3d57f2b 100644 --- a/web/src/components/NewSession/index.tsx +++ b/web/src/components/NewSession/index.tsx @@ -5,6 +5,7 @@ import { usePlatform } from '@/hooks/usePlatform' import { useMachinePathsExists } from '@/hooks/useMachinePathsExists' import { useSpawnSession } from '@/hooks/mutations/useSpawnSession' import { useCodexModels } from '@/hooks/queries/useCodexModels' +import { useCodexSessions } from '@/hooks/queries/useCodexSessions' import { useSessions } from '@/hooks/queries/useSessions' import { useActiveSuggestions, type Suggestion } from '@/hooks/useActiveSuggestions' import { useDirectorySuggestions } from '@/hooks/useDirectorySuggestions' @@ -17,6 +18,7 @@ import { DirectorySection } from './DirectorySection' import { MachineSelector } from './MachineSelector' import { ModelSelector } from './ModelSelector' import { ClaudeEffortSelector } from './ClaudeEffortSelector' +import { CodexSessionSelector } from './CodexSessionSelector' import { ReasoningEffortSelector } from './ReasoningEffortSelector' import { loadPreferredAgent, @@ -53,6 +55,8 @@ export function NewSession(props: { const [model, setModel] = useState('auto') const [effort, setEffort] = useState('auto') const [modelReasoningEffort, setModelReasoningEffort] = useState('default') + const [showOldCodexSessions, setShowOldCodexSessions] = useState(false) + const [selectedCodexSessionId, setSelectedCodexSessionId] = useState('') const [yoloMode, setYoloMode] = useState(loadPreferredYoloMode) const [sessionType, setSessionType] = useState('simple') const [worktreeName, setWorktreeName] = useState('') @@ -69,6 +73,9 @@ export function NewSession(props: { useEffect(() => { setModel('auto') setEffort('auto') + if (agent !== 'codex') { + setSelectedCodexSessionId('') + } }, [agent]) useEffect(() => { @@ -106,6 +113,13 @@ export function NewSession(props: { machineId, enabled: agent === 'codex' && Boolean(machineId) }) + + const codexSessionsState = useCodexSessions({ + api: props.api, + machineId, + includeOld: showOldCodexSessions, + enabled: agent === 'codex' && Boolean(machineId) + }) const runnerSpawnError = useMemo( () => formatRunnerSpawnError(selectedMachine), [selectedMachine] @@ -191,6 +205,7 @@ export function NewSession(props: { const handleMachineChange = useCallback((newMachineId: string) => { setMachineId(newMachineId) + setSelectedCodexSessionId('') const paths = getRecentPaths(newMachineId) if (paths[0]) { setDirectory(paths[0]) @@ -291,7 +306,8 @@ export function NewSession(props: { modelReasoningEffort: resolvedModelReasoningEffort, yolo: yoloMode, sessionType, - worktreeName: sessionType === 'worktree' ? (worktreeName.trim() || undefined) : undefined + worktreeName: sessionType === 'worktree' ? (worktreeName.trim() || undefined) : undefined, + resumeSessionId: agent === 'codex' && selectedCodexSessionId ? selectedCodexSessionId : undefined }) if (result.type === 'success') { @@ -372,6 +388,18 @@ export function NewSession(props: { isDisabled={isFormDisabled} onEffortChange={setEffort} /> + + { diff --git a/web/src/hooks/queries/useCodexSessions.ts b/web/src/hooks/queries/useCodexSessions.ts new file mode 100644 index 000000000..8be3f5a6f --- /dev/null +++ b/web/src/hooks/queries/useCodexSessions.ts @@ -0,0 +1,48 @@ +import { useQuery } from '@tanstack/react-query' +import type { ApiClient } from '@/api/client' +import type { CodexSessionSummary } from '@/types/api' +import { queryKeys } from '@/lib/query-keys' + +export function useCodexSessions(args: { + api: ApiClient | null + machineId?: string | null + includeOld?: boolean + enabled?: boolean +}): { + sessions: CodexSessionSummary[] + isLoading: boolean + error: string | null +} { + const { api, machineId } = args + const includeOld = args.includeOld === true + const enabled = Boolean(args.enabled && api && machineId) + + const query = useQuery({ + queryKey: queryKeys.machineCodexSessions(machineId ?? 'unknown', includeOld), + queryFn: async () => { + if (!api || !machineId) { + throw new Error('Codex sessions target unavailable') + } + return await api.getMachineCodexSessions(machineId, { + includeOld, + olderThanDays: 180, + limit: 200 + }) + }, + enabled, + staleTime: 30_000, + retry: false, + }) + + return { + sessions: query.data?.sessions ?? [], + isLoading: query.isLoading, + error: query.data?.success === false + ? (query.data.error ?? 'Failed to load Codex sessions') + : query.error instanceof Error + ? query.error.message + : query.error + ? 'Failed to load Codex sessions' + : null, + } +} diff --git a/web/src/lib/locales/en.ts b/web/src/lib/locales/en.ts index 1596adbd3..1d234f585 100644 --- a/web/src/lib/locales/en.ts +++ b/web/src/lib/locales/en.ts @@ -111,6 +111,10 @@ export default { 'newSession.effort': 'Effort', 'newSession.model.optional': 'optional', 'newSession.model.loadFailed': 'Failed to load Codex models', + 'newSession.codexSession.label': 'Resume Codex session', + 'newSession.codexSession.newSession': 'Start new Codex session', + 'newSession.codexSession.showOld': 'Show older than 6 months', + 'newSession.codexSession.loading': 'Loading Codex sessions…', 'newSession.reasoningEffort': 'Reasoning effort', 'newSession.yolo': 'YOLO mode', 'newSession.yolo.title': 'Bypass approvals and sandbox', diff --git a/web/src/lib/locales/zh-CN.ts b/web/src/lib/locales/zh-CN.ts index 8539a00d2..54d46a34b 100644 --- a/web/src/lib/locales/zh-CN.ts +++ b/web/src/lib/locales/zh-CN.ts @@ -113,6 +113,10 @@ export default { 'newSession.effort': '思考强度', 'newSession.model.optional': '可选', 'newSession.model.loadFailed': '加载 Codex 模型失败', + 'newSession.codexSession.label': '恢复 Codex 会话', + 'newSession.codexSession.newSession': '新建 Codex 会话', + 'newSession.codexSession.showOld': '显示 6 个月前会话', + 'newSession.codexSession.loading': '正在加载 Codex 会话…', 'newSession.reasoningEffort': '推理强度', 'newSession.yolo': 'YOLO 模式', 'newSession.yolo.title': '跳过审批和沙箱', diff --git a/web/src/lib/query-keys.ts b/web/src/lib/query-keys.ts index 2c723ff46..b62fbdadf 100644 --- a/web/src/lib/query-keys.ts +++ b/web/src/lib/query-keys.ts @@ -4,6 +4,7 @@ export const queryKeys = { messages: (sessionId: string) => ['messages', sessionId] as const, machines: ['machines'] as const, machineCodexModels: (machineId: string) => ['machine-codex-models', machineId] as const, + machineCodexSessions: (machineId: string, includeOld: boolean) => ['machine-codex-sessions', machineId, includeOld ? 'all' : 'recent'] as const, gitStatus: (sessionId: string) => ['git-status', sessionId] as const, sessionFiles: (sessionId: string, query: string) => ['session-files', sessionId, query] as const, sessionDirectory: (sessionId: string, path: string) => ['session-directory', sessionId, path] as const, diff --git a/web/src/types/api.ts b/web/src/types/api.ts index 0dd55158d..df2a1c751 100644 --- a/web/src/types/api.ts +++ b/web/src/types/api.ts @@ -223,6 +223,23 @@ export type CodexModelsResponse = { error?: string } + +export type CodexSessionSummary = { + id: string + title: string + updatedAt: number + path: string | null + model: string | null + isOld: boolean +} + +export type CodexSessionsResponse = { + success: boolean + sessions?: CodexSessionSummary[] + nextCursor?: string | null + error?: string +} + export type PushSubscriptionKeys = { p256dh: string auth: string