feat(codex): import local Codex sessions into Hapi (#796)

* local: add Codex Desktop session sync controls

* feat(codex): import local Codex sessions into Hapi

---------

Co-authored-by: Codex Local <codex-local@example.invalid>
This commit is contained in:
DolphinZZZZZ
2026-06-04 17:53:12 +08:00
committed by GitHub
co-authored by Codex Local
parent 39fba5292c
commit f9ef3a4489
25 changed files with 3156 additions and 15 deletions
+96 -2
View File
@@ -69,9 +69,11 @@ function createSessionStub(
permissionMode: 'default' | 'read-only' | 'safe-yolo' | 'yolo',
codexArgs?: string[],
path = '/tmp/worktree',
initialTranscriptPath: string | null = null
initialTranscriptPath: string | null = null,
replayTranscriptHistoryOnStart = false
) {
const sessionEvents: Array<{ type: string; message?: string }> = [];
const userMessages: string[] = [];
const agentMessages: unknown[] = [];
let localLaunchFailure: { message: string; exitReason: 'switch' | 'exit' } | null = null;
let sessionId: string | null = null;
@@ -90,6 +92,7 @@ function createSessionStub(
startedBy: 'terminal' as const,
startingMode: 'local' as const,
codexArgs,
replayTranscriptHistoryOnStart,
client: {
rpcHandlerManager: {
registerHandler: () => {}
@@ -124,13 +127,16 @@ function createSessionStub(
recordLocalLaunchFailure: (message: string, exitReason: 'switch' | 'exit') => {
localLaunchFailure = { message, exitReason };
},
sendUserMessage: () => {},
sendUserMessage: (message: string) => {
userMessages.push(message);
},
sendAgentMessage: (message: unknown) => {
agentMessages.push(message);
},
queue: createQueueStub()
},
sessionEvents,
userMessages,
agentMessages,
getLocalLaunchFailure: () => localLaunchFailure
};
@@ -348,6 +354,94 @@ describe('codexLocalLauncher', () => {
});
});
it('replays existing transcript messages when importing a Codex thread into a new Hapi session', async () => {
const transcriptPath = join(tempDir, 'codex-import-transcript.jsonl');
const { session, agentMessages } = createSessionStub('default', undefined, '/tmp/worktree', null, true);
let releaseRunBarrier: (() => void) | undefined;
harness.runBarrier = new Promise((resolve) => {
releaseRunBarrier = resolve;
});
await writeFile(
transcriptPath,
[
JSON.stringify({ type: 'session_meta', payload: { id: 'codex-thread-import' } }),
JSON.stringify({ type: 'event_msg', payload: { type: 'agent_message', message: 'old imported message' } })
].join('\n') + '\n'
);
const launcherPromise = codexLocalLauncher(session as never);
await wait(50);
harness.sessionHookHandlers[0]?.('codex-thread-import', {
transcript_path: transcriptPath
});
await wait(300);
if (releaseRunBarrier) {
releaseRunBarrier();
}
await launcherPromise;
expect(agentMessages).toContainEqual({
type: 'message',
message: 'old imported message',
id: expect.any(String)
});
});
it('replays existing response_item chat messages when importing a Codex thread into a new Hapi session', async () => {
const transcriptPath = join(tempDir, 'codex-import-response-item-transcript.jsonl');
const { session, userMessages, agentMessages } = createSessionStub('default', undefined, '/tmp/worktree', null, true);
let releaseRunBarrier: (() => void) | undefined;
harness.runBarrier = new Promise((resolve) => {
releaseRunBarrier = resolve;
});
await writeFile(
transcriptPath,
[
JSON.stringify({ type: 'session_meta', payload: { id: 'codex-thread-import-response-item' } }),
JSON.stringify({
type: 'response_item',
payload: {
type: 'message',
role: 'user',
content: [{ type: 'input_text', text: 'old response_item user message' }]
}
}),
JSON.stringify({
type: 'response_item',
payload: {
type: 'message',
role: 'assistant',
content: [{ type: 'output_text', text: 'old response_item assistant message' }]
}
})
].join('\n') + '\n'
);
const launcherPromise = codexLocalLauncher(session as never);
await wait(50);
harness.sessionHookHandlers[0]?.('codex-thread-import-response-item', {
transcript_path: transcriptPath
});
await wait(300);
if (releaseRunBarrier) {
releaseRunBarrier();
}
await launcherPromise;
expect(userMessages).toContain('old response_item user message');
expect(agentMessages).toContainEqual({
type: 'message',
message: 'old response_item assistant message',
id: expect.any(String)
});
});
it('does not let a later non-clear hook replace the primary session', async () => {
const primaryTranscriptPath = await writeTranscriptMeta('primary-later-hook.jsonl', 'primary-thread');
const otherTranscriptPath = await writeTranscriptMeta('later-other-transcript.jsonl', 'other-thread');
+2
View File
@@ -83,6 +83,8 @@ export async function codexLocalLauncher(session: CodexSession): Promise<'switch
}
const createdScanner = await createCodexSessionScanner({
transcriptPath,
// 中文注释:导入模式下允许 scanner 首次回放 transcript 全量内容,补齐 Codex 客户端里已有但 Hapi 还未看到的消息。
replayExistingHistory: session.replayTranscriptHistoryOnStart,
onSessionId: (sessionId) => {
if (!isPrimarySessionId(sessionId)) {
logger.debug(`[codex-local]: Ignoring transcript session id ${sessionId}; primary is ${primarySessionId}`);
+3 -1
View File
@@ -33,6 +33,7 @@ interface LoopOptions {
modelReasoningEffort?: ReasoningEffort;
collaborationMode?: CodexCollaborationMode;
resumeSessionId?: string;
replayTranscriptHistoryOnStart?: boolean;
onSessionReady?: (session: CodexSession) => void;
}
@@ -56,7 +57,8 @@ export async function loop(opts: LoopOptions): Promise<void> {
permissionMode: opts.permissionMode ?? 'default',
model: opts.model,
modelReasoningEffort: opts.modelReasoningEffort,
collaborationMode: opts.collaborationMode ?? 'default'
collaborationMode: opts.collaborationMode ?? 'default',
replayTranscriptHistoryOnStart: opts.replayTranscriptHistoryOnStart ?? false
});
await runLocalRemoteSession({
+14 -1
View File
@@ -134,8 +134,21 @@ describe('runCodex', () => {
}))
expect(harness.loopArgs[0]).toEqual(expect.objectContaining({
resumeSessionId: 'codex-thread-1',
collaborationMode: 'plan'
collaborationMode: 'plan',
replayTranscriptHistoryOnStart: false
}))
expect(mockCodexSession.setCollaborationMode).toHaveBeenLastCalledWith('plan')
})
it('replays transcript history when attaching a new Hapi session to an existing Codex thread', async () => {
await runCodexImpl({
workingDirectory: '/tmp/project',
resumeSessionId: 'codex-thread-2'
})
expect(harness.loopArgs[0]).toEqual(expect.objectContaining({
resumeSessionId: 'codex-thread-2',
replayTranscriptHistoryOnStart: true
}))
})
})
+4
View File
@@ -73,6 +73,9 @@ export async function runCodex(opts: {
const codexCliOverrides = parseCodexCliOverrides(opts.codexArgs);
const sessionWrapperRef: { current: CodexSession | null } = { current: null };
// 中文注释:当用户直接把现成的 Codex thread 导入到一个全新的 Hapi 会话时,
// 需要在首次附着 transcript 时回放已有历史;恢复已有 Hapi 会话时则保持原来的增量模式,避免重复灌入旧消息。
const replayTranscriptHistoryOnStart = Boolean(opts.resumeSessionId && !opts.existingSessionId);
let currentPermissionMode: PermissionMode = opts.permissionMode ?? 'default';
let currentModel = opts.model;
@@ -353,6 +356,7 @@ export async function runCodex(opts: {
modelReasoningEffort: currentModelReasoningEffort,
collaborationMode: currentCollaborationMode,
resumeSessionId: opts.resumeSessionId,
replayTranscriptHistoryOnStart,
onModeChange: createModeChangeHandler(session),
onSessionReady: (instance) => {
sessionWrapperRef.current = instance;
+3
View File
@@ -17,6 +17,7 @@ export class CodexSession extends AgentSessionBase<EnhancedMode> {
readonly codexCliOverrides?: CodexCliOverrides;
readonly startedBy: 'runner' | 'terminal';
readonly startingMode: 'local' | 'remote';
readonly replayTranscriptHistoryOnStart: boolean;
localLaunchFailure: LocalLaunchFailure | null = null;
private transcriptPathCallbacks: Array<(path: string) => void> = [];
@@ -38,6 +39,7 @@ export class CodexSession extends AgentSessionBase<EnhancedMode> {
model?: SessionModel;
modelReasoningEffort?: SessionModelReasoningEffort;
collaborationMode?: EnhancedMode['collaborationMode'];
replayTranscriptHistoryOnStart?: boolean;
}) {
super({
api: opts.api,
@@ -64,6 +66,7 @@ export class CodexSession extends AgentSessionBase<EnhancedMode> {
this.codexCliOverrides = opts.codexCliOverrides;
this.startedBy = opts.startedBy;
this.startingMode = opts.startingMode;
this.replayTranscriptHistoryOnStart = opts.replayTranscriptHistoryOnStart ?? false;
this.permissionMode = opts.permissionMode;
this.model = opts.model;
this.modelReasoningEffort = opts.modelReasoningEffort;
@@ -32,6 +32,37 @@ describe('convertCodexEvent', () => {
expect(result?.userMessage).toBe('hello user');
});
it('converts response_item user messages', () => {
const result = convertCodexEvent({
type: 'response_item',
payload: {
type: 'message',
role: 'user',
content: [{ type: 'input_text', text: 'hello from response_item user' }]
}
});
expect(result).toEqual({
userMessage: 'hello from response_item user'
});
});
it('converts response_item assistant messages', () => {
const result = convertCodexEvent({
type: 'response_item',
payload: {
type: 'message',
role: 'assistant',
content: [{ type: 'output_text', text: 'hello from response_item assistant' }]
}
});
expect(result?.message).toMatchObject({
type: 'message',
message: 'hello from response_item assistant'
});
});
it('converts reasoning events', () => {
const result = convertCodexEvent({
type: 'event_msg',
@@ -55,6 +55,30 @@ function asString(value: unknown): string | null {
return typeof value === 'string' && value.length > 0 ? value : null;
}
function extractCodexText(value: unknown): string {
if (typeof value === 'string') {
return value.trim();
}
if (Array.isArray(value)) {
return value
.map((item) => {
const record = asRecord(item);
if (record?.type === 'input_text' && typeof record.text === 'string') return record.text;
if (record?.type === 'output_text' && typeof record.text === 'string') return record.text;
if (record?.type === 'text' && typeof record.text === 'string') return record.text;
return null;
})
.filter((part): part is string => Boolean(part))
.join(' ')
.trim();
}
const record = asRecord(value);
if (record?.type === 'input_text' && typeof record.text === 'string') return record.text.trim();
if (record?.type === 'output_text' && typeof record.text === 'string') return record.text.trim();
if (record?.type === 'text' && typeof record.text === 'string') return record.text.trim();
return '';
}
function parseArguments(value: unknown): unknown {
if (typeof value !== 'string') {
return value;
@@ -194,6 +218,27 @@ export function convertCodexEvent(rawEvent: unknown): CodexConversionResult | nu
return null;
}
if (itemType === 'message') {
const role = asString(payloadRecord.role);
const text = extractCodexText(payloadRecord.content);
if (!text) {
return null;
}
if (role === 'user') {
return { userMessage: text };
}
if (role === 'assistant') {
return {
message: {
type: 'message',
message: text,
id: randomUUID()
}
};
}
return null;
}
if (itemType === 'function_call') {
const name = asString(payloadRecord.name);
const callId = extractCallId(payloadRecord);
@@ -59,6 +59,27 @@ describe('codexSessionScanner', () => {
expect(events[0]?.type).toBe('event_msg');
});
it('can replay existing transcript history on first attach', async () => {
await writeFile(
transcriptPath,
[
JSON.stringify({ type: 'session_meta', payload: { id: 'session-replay' } }),
JSON.stringify({ type: 'event_msg', payload: { type: 'agent_message', message: 'old' } })
].join('\n') + '\n'
);
scanner = await createCodexSessionScanner({
transcriptPath,
replayExistingHistory: true,
onEvent: (event) => events.push(event)
});
await wait(300);
expect(events).toHaveLength(2);
expect(events[0]?.type).toBe('session_meta');
expect(events[1]?.payload).toEqual({ type: 'agent_message', message: 'old' });
});
it('reports session id from the transcript metadata', async () => {
await writeFile(
transcriptPath,
+16 -2
View File
@@ -7,6 +7,7 @@ interface CodexSessionScannerOptions {
transcriptPath: string | null;
onEvent: (event: CodexSessionEvent) => void;
onSessionId?: (sessionId: string) => void;
replayExistingHistory?: boolean;
}
export interface CodexSessionScanner {
@@ -34,6 +35,7 @@ class CodexSessionScannerImpl extends BaseSessionScanner<CodexSessionEvent> {
private readonly onSessionId?: (sessionId: string) => void;
private readonly fileEpochByPath = new Map<string, number>();
private readonly fileSizeByPath = new Map<string, number>();
private replayExistingHistoryOnNextAttach: boolean;
private observedSessionId: string | null = null;
constructor(opts: CodexSessionScannerOptions) {
@@ -41,6 +43,7 @@ class CodexSessionScannerImpl extends BaseSessionScanner<CodexSessionEvent> {
this.transcriptPath = opts.transcriptPath;
this.onEvent = opts.onEvent;
this.onSessionId = opts.onSessionId;
this.replayExistingHistoryOnNextAttach = opts.replayExistingHistory ?? false;
}
async setTranscriptPath(transcriptPath: string): Promise<void> {
@@ -48,14 +51,14 @@ class CodexSessionScannerImpl extends BaseSessionScanner<CodexSessionEvent> {
return;
}
this.transcriptPath = transcriptPath;
await this.primeTranscript(transcriptPath);
await this.prepareTranscript(transcriptPath);
this.pruneWatchers(this.transcriptPath ? [this.transcriptPath] : []);
this.invalidate();
}
protected async initialize(): Promise<void> {
if (this.transcriptPath) {
await this.primeTranscript(this.transcriptPath);
await this.prepareTranscript(this.transcriptPath);
}
}
@@ -89,6 +92,17 @@ class CodexSessionScannerImpl extends BaseSessionScanner<CodexSessionEvent> {
this.pruneWatchers(this.transcriptPath ? [this.transcriptPath] : []);
}
private async prepareTranscript(filePath: string): Promise<void> {
if (this.replayExistingHistoryOnNextAttach) {
// 中文注释:导入既有 Codex thread 时,首次挂接 transcript 不能先 prime 到 EOF
// 否则 Hapi 只会看到后续增量,客户端里已经存在的最新消息会被跳过。
this.replayExistingHistoryOnNextAttach = false;
return;
}
await this.primeTranscript(filePath);
}
private async primeTranscript(filePath: string): Promise<void> {
const { events, nextCursor } = await this.readSessionFile(filePath, 0);
const keys = events.map((entry) => this.generateEventKey(entry.event, { filePath, lineIndex: entry.lineIndex }));
+33 -1
View File
@@ -1,7 +1,27 @@
import type { Database } from 'bun:sqlite'
import type { StoredMessage } from './types'
import { addMessage, cancelQueuedMessage, deleteQueuedMessageById, lookupQueuedMessage, getMessages, getFirstMessages, getDeliverableMessagesAfter, getMessagesByPosition, getUninvokedLocalMessages, getMatureScheduledMessages, getImmediateQueuedLocalMessages, countFutureScheduledBySessionIds, countFutureScheduledLocalMessages, markMessagesInvoked, mergeSessionMessages, type CancelQueuedMessageResult, type LookupQueuedMessageResult } from './messages'
import {
addMessage,
cancelQueuedMessage,
deleteQueuedMessageById,
lookupQueuedMessage,
getMessages,
getFirstMessages,
getDeliverableMessagesAfter,
getMessagesByPosition,
getUninvokedLocalMessages,
getMatureScheduledMessages,
getImmediateQueuedLocalMessages,
countFutureScheduledBySessionIds,
countFutureScheduledLocalMessages,
markMessagesInvoked,
mergeSessionMessages,
copyMessageToSession as copyStoredMessageToSession,
getAllMessages,
type CancelQueuedMessageResult,
type LookupQueuedMessageResult,
} from './messages'
export class MessageStore {
private readonly db: Database
@@ -14,6 +34,18 @@ export class MessageStore {
return addMessage(this.db, sessionId, content, localId, scheduledAt)
}
copyMessageToSession(
sessionId: string,
message: Pick<StoredMessage, 'content' | 'createdAt' | 'localId' | 'invokedAt' | 'scheduledAt'>
): StoredMessage {
// 中文注释:重复会话合并时需要保留源消息的时间戳和排队信息,因此走专门的复制入口而不是普通 addMessage。
return copyStoredMessageToSession(this.db, sessionId, message)
}
getAllMessages(sessionId: string): StoredMessage[] {
return getAllMessages(this.db, sessionId)
}
getMessages(sessionId: string, limit: number = 200): StoredMessage[] {
return getMessages(this.db, sessionId, limit)
}
+66
View File
@@ -28,6 +28,11 @@ function toStoredMessage(row: DbMessageRow): StoredMessage {
}
}
export type CopyStoredMessageInput = Pick<
StoredMessage,
'content' | 'createdAt' | 'localId' | 'invokedAt' | 'scheduledAt'
>
export function addMessage(
db: Database,
sessionId: string,
@@ -92,6 +97,56 @@ export function addMessage(
return toStoredMessage(row)
}
export function copyMessageToSession(
db: Database,
sessionId: string,
message: CopyStoredMessageInput
): StoredMessage {
const createdAt = Number.isFinite(message.createdAt) ? message.createdAt : Date.now()
const nextSeq = getMaxSeq(db, sessionId) + 1
let localId = message.localId
if (localId) {
const collision = db.prepare(
'SELECT 1 FROM messages WHERE session_id = ? AND local_id = ? LIMIT 1'
).get(sessionId, localId) as { 1: number } | undefined
if (collision) {
// 中文注释:重复会话合并时如果 localId 撞车,给复制进目标会话的消息生成一个新 localId,避免误判成同一条已存在消息。
localId = `${localId}:merged:${randomUUID().slice(0, 8)}`
}
}
if (message.scheduledAt != null && !localId && message.invokedAt === null) {
// 中文注释:未来计划消息仍需要 ack 路径;异常情况下若源数据缺少 localId,这里补一个稳定可写的新值以保留调度语义。
localId = `merged-scheduled:${randomUUID()}`
}
const invokedAt = localId ? message.invokedAt : (message.invokedAt ?? createdAt)
const id = randomUUID()
db.prepare(`
INSERT INTO messages (
id, session_id, content, created_at, seq, local_id, invoked_at, scheduled_at
) VALUES (
@id, @session_id, @content, @created_at, @seq, @local_id, @invoked_at, @scheduled_at
)
`).run({
id,
session_id: sessionId,
content: JSON.stringify(message.content),
created_at: createdAt,
seq: nextSeq,
local_id: localId ?? null,
invoked_at: invokedAt ?? null,
scheduled_at: message.scheduledAt ?? null
})
const row = db.prepare('SELECT * FROM messages WHERE id = ?').get(id) as DbMessageRow | undefined
if (!row) {
throw new Error('Failed to copy message into target session')
}
return toStoredMessage(row)
}
export function getMessages(
db: Database,
sessionId: string,
@@ -106,6 +161,17 @@ export function getMessages(
return rows.reverse().map(toStoredMessage)
}
export function getAllMessages(
db: Database,
sessionId: string
): StoredMessage[] {
const rows = db.prepare(
'SELECT * FROM messages WHERE session_id = ? ORDER BY seq ASC'
).all(sessionId) as DbMessageRow[]
return rows.map(toStoredMessage)
}
export function getFirstMessages(
db: Database,
sessionId: string,
@@ -157,4 +157,5 @@ describe('permission mode persistence', () => {
expect(capturedSpawnPermissionMode).toBe('yolo')
expect(configRpcCalls).toBe(0)
})
})
+149
View File
@@ -0,0 +1,149 @@
import { afterEach, describe, expect, it } from 'bun:test'
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { Hono } from 'hono'
import { AGENT_MESSAGE_PAYLOAD_TYPE } from '@hapi/protocol'
import { Store } from '../../store'
import type { SyncEngine } from '../../sync/syncEngine'
import type { WebAppEnv } from '../middleware/auth'
import { createCodexDesktopRoutes, importSelectedCodexSessions } from './codexDesktop'
const originalCodexHome = process.env.CODEX_HOME
function createTranscript(codexHome: string, sessionId: string): void {
const sessionDir = join(codexHome, 'sessions', '2026', '06', '04')
mkdirSync(sessionDir, { recursive: true })
const transcriptPath = join(sessionDir, `rollout-${sessionId}.jsonl`)
const lines = [
{
type: 'session_meta',
payload: {
id: sessionId,
cwd: 'C:\\work\\project',
originator: 'codex_cli_rs',
cli_version: '0.0.0-test'
}
},
{
type: 'response_item',
payload: {
type: 'message',
role: 'user',
content: [{ type: 'input_text', text: 'normal user message' }]
}
},
{
type: 'response_item',
payload: {
type: 'message',
role: 'assistant',
content: [{ type: 'output_text', text: 'normal assistant message' }]
}
}
]
writeFileSync(transcriptPath, `${lines.map((line) => JSON.stringify(line)).join('\n')}\n`, 'utf-8')
}
function createRoutesApp(namespace: string): Hono<WebAppEnv> {
const app = new Hono<WebAppEnv>()
app.use('*', async (c, next) => {
c.set('namespace', namespace)
await next()
})
app.route('/api', createCodexDesktopRoutes({
store: new Store(':memory:'),
getSyncEngine: () => null
}))
return app
}
describe('Codex Desktop import routes', () => {
afterEach(() => {
if (originalCodexHome === undefined) {
delete process.env.CODEX_HOME
} else {
process.env.CODEX_HOME = originalCodexHome
}
})
it('imports normal response_item chat messages', async () => {
const codexHome = mkdtempSync(join(tmpdir(), 'hapi-codex-home-test-'))
const store = new Store(':memory:')
const codexSessionId = '11111111-1111-4111-8111-111111111111'
process.env.CODEX_HOME = codexHome
try {
createTranscript(codexHome, codexSessionId)
const result = await importSelectedCodexSessions({
codexSessionIds: [codexSessionId],
store,
namespace: 'default',
getSyncEngine: () => null
})
expect(result.success).toBe(true)
const session = store.sessions.getSessionsByNamespace('default')[0]
expect(session).toBeDefined()
const messages = store.messages.getAllMessages(session.id)
expect(messages).toHaveLength(2)
expect(messages[0].content).toEqual({
role: 'user',
content: {
type: 'text',
text: 'normal user message'
},
meta: {
sentFrom: 'cli'
}
})
expect(messages[1].content).toEqual({
role: 'agent',
content: {
type: AGENT_MESSAGE_PAYLOAD_TYPE,
data: {
type: 'message',
message: 'normal assistant message',
id: expect.any(String)
}
},
meta: {
sentFrom: 'cli'
}
})
} finally {
store.close()
rmSync(codexHome, { recursive: true, force: true })
}
})
it('rejects Codex transcript endpoints outside the default namespace', async () => {
const app = createRoutesApp('team-a')
const response = await app.request('/api/codex/sessions')
expect(response.status).toBe(403)
expect(await response.json()).toEqual({
success: false,
error: 'Codex transcript import is not available outside the default namespace'
})
})
it('allows Codex transcript endpoints in the default namespace', async () => {
const codexHome = mkdtempSync(join(tmpdir(), 'hapi-codex-home-route-test-'))
process.env.CODEX_HOME = codexHome
try {
const app = createRoutesApp('default')
const response = await app.request('/api/codex/sessions')
expect(response.status).toBe(200)
expect(await response.json()).toEqual({
success: true,
sessions: []
})
} finally {
rmSync(codexHome, { recursive: true, force: true })
}
})
})
File diff suppressed because it is too large Load Diff
+6
View File
@@ -17,6 +17,7 @@ import { createPermissionsRoutes } from './routes/permissions'
import { createMachinesRoutes } from './routes/machines'
import { createGitRoutes } from './routes/git'
import { createCliRoutes } from './routes/cli'
import { createCodexDesktopRoutes } from './routes/codexDesktop'
import { createPushRoutes } from './routes/push'
import { createVoiceRoutes } from './routes/voice'
import type { SSEManager } from '../sse/sseManager'
@@ -96,6 +97,11 @@ function createWebApp(options: {
app.route('/api', createPermissionsRoutes(options.getSyncEngine))
app.route('/api', createMachinesRoutes(options.getSyncEngine))
app.route('/api', createGitRoutes(options.getSyncEngine))
// 中文注释:这里提供两类 Codex 辅助能力:扫描本地 transcript 以导入到 Hapi,以及按需重启 Codex Desktop 客户端。
app.route('/api', createCodexDesktopRoutes({
store: options.store,
getSyncEngine: options.getSyncEngine
}))
app.route('/api', createPushRoutes(options.store, options.vapidPublicKey))
app.route('/api', createVoiceRoutes())
+44
View File
@@ -1,6 +1,12 @@
import type {
AttachmentMetadata,
AuthResponse,
CodexLocalSessionsResponse,
CodexDuplicateSessionsResponse,
CodexMergeDuplicateSessionsResponse,
CodexDesktopScriptResponse,
CodexDesktopSyncRequest,
CodexDesktopStatusResponse,
CodexCollaborationMode,
FileSearchResponse,
MachinesResponse,
@@ -179,6 +185,44 @@ export class ApiClient {
})
}
async syncCodexSession(payload?: CodexDesktopSyncRequest): Promise<CodexDesktopScriptResponse> {
// 中文注释:当前按钮语义已改为“从 Codex 导入到 Hapi”;这里提交的是本地 transcript 对应的 Codex thread ID 列表。
return await this.request<CodexDesktopScriptResponse>('/api/codex/sync-session', {
method: 'POST',
...(payload ? { body: JSON.stringify(payload) } : {})
})
}
async getCodexSessions(): Promise<CodexLocalSessionsResponse> {
return await this.request<CodexLocalSessionsResponse>('/api/codex/sessions')
}
async getCodexDesktopStatus(): Promise<CodexDesktopStatusResponse> {
return await this.request<CodexDesktopStatusResponse>('/api/codex/status')
}
async getCodexDuplicateSessions(payload: CodexDesktopSyncRequest): Promise<CodexDuplicateSessionsResponse> {
// 中文注释:重复会话检测只传本次用户勾选导入的 codexSessionId,避免把未选中的历史会话也纳入提示。
return await this.request<CodexDuplicateSessionsResponse>('/api/codex/duplicate-sessions', {
method: 'POST',
body: JSON.stringify(payload)
})
}
async mergeCodexDuplicateSessions(payload: CodexDesktopSyncRequest): Promise<CodexMergeDuplicateSessionsResponse> {
// 中文注释:真正执行合并时沿用同一批选中 codexSessionId,保证检测范围与执行范围一致。
return await this.request<CodexMergeDuplicateSessionsResponse>('/api/codex/merge-duplicate-sessions', {
method: 'POST',
body: JSON.stringify(payload)
})
}
async restartCodexDesktop(): Promise<CodexDesktopScriptResponse> {
return await this.request<CodexDesktopScriptResponse>('/api/codex/restart-desktop', {
method: 'POST'
})
}
async unsubscribePushNotifications(payload: PushUnsubscribePayload): Promise<void> {
await this.request('/api/push/subscribe', {
method: 'DELETE',
@@ -0,0 +1,242 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import type { CodexLocalSessionSummary } from '@/types/api'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { useTranslation } from '@/lib/use-translation'
function formatCodexSessionTime(value: number): string | null {
if (!Number.isFinite(value)) return null
return new Date(value).toLocaleString()
}
function getCodexSessionPreview(session: CodexLocalSessionSummary): string {
if (session.lastUserMessage?.trim()) {
return session.lastUserMessage.trim()
}
const parts = [session.originator, session.cliVersion].filter(Boolean)
return parts.join(' · ')
}
export function CodexSessionSyncDialog(props: {
isOpen: boolean
onClose: () => void
sessions: CodexLocalSessionSummary[]
currentCodexSessionId: string | null
onConfirm: (sessionIds: string[]) => Promise<void>
onRestartCodexDesktop: () => Promise<void>
isPending: boolean
isRestartingCodexDesktop: boolean
isLoading: boolean
}) {
const { t } = useTranslation()
const {
isOpen,
sessions,
currentCodexSessionId,
onConfirm,
onRestartCodexDesktop,
isPending,
isRestartingCodexDesktop,
isLoading,
onClose
} = props
const [selectedSessionIds, setSelectedSessionIds] = useState<string[]>([])
const [hasInitializedSelection, setHasInitializedSelection] = useState(false)
const wasOpenRef = useRef(false)
const sessionIdSet = useMemo(
() => new Set(sessions.map((session) => session.id)),
[sessions]
)
const selectedSessionIdSet = useMemo(
() => new Set(selectedSessionIds),
[selectedSessionIds]
)
useEffect(() => {
if (isOpen && !wasOpenRef.current) {
wasOpenRef.current = true
setSelectedSessionIds([])
setHasInitializedSelection(false)
return
}
if (!isOpen && wasOpenRef.current) {
wasOpenRef.current = false
setSelectedSessionIds([])
setHasInitializedSelection(false)
}
}, [isOpen])
useEffect(() => {
if (!isOpen || isLoading || hasInitializedSelection) return
// 中文注释:弹窗打开后等本地 Codex 会话列表加载完成,再尝试默认勾选当前 Hapi 会话关联的 Codex thread,避免异步加载时默认值丢失。
const defaultSelected = currentCodexSessionId && sessionIdSet.has(currentCodexSessionId)
? [currentCodexSessionId]
: []
setSelectedSessionIds(defaultSelected)
setHasInitializedSelection(true)
}, [currentCodexSessionId, hasInitializedSelection, isLoading, isOpen, sessionIdSet])
const toggleSession = (sessionId: string) => {
if (isPending || isLoading) return
// 中文注释:列表项支持多选导入;再次点击同一行则取消勾选,便于快速调整导入批次。
setSelectedSessionIds((current) => current.includes(sessionId)
? current.filter((id) => id !== sessionId)
: [...current, sessionId])
}
const selectAll = () => {
setSelectedSessionIds(sessions.map((session) => session.id))
}
const clearAll = () => {
// 中文注释:全取消放在左侧,和底部“取消 / 导入”的左右语义保持一致。
setSelectedSessionIds([])
}
const handleConfirm = async () => {
if (selectedSessionIds.length === 0 || isPending || isLoading) return
// 中文注释:确认按钮只提交用户在弹窗中勾选的 Codex thread,实际导入逻辑由父组件统一处理并给出 toast 提示。
await onConfirm(selectedSessionIds)
}
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-w-xl">
<div className="flex items-start justify-between gap-3">
<DialogHeader className="flex-1 text-left">
<DialogTitle>{t('codexSync.confirm.title')}</DialogTitle>
<DialogDescription className="mt-2">
{t('codexSync.confirm.description')}
</DialogDescription>
</DialogHeader>
<Button
type="button"
variant="secondary"
size="sm"
onClick={() => void onRestartCodexDesktop()}
disabled={isRestartingCodexDesktop}
aria-label={t('codexSync.restart.tooltip')}
title={t('codexSync.restart.tooltip')}
>
{/* 中文注释:把容易被误解为“刷新页面”的 icon 改成明确文字按钮,直接说明这是重启 Codex 客户端。 */}
{isRestartingCodexDesktop ? t('codexSync.restart.confirming') : t('codexSync.restart.tooltip')}
</Button>
</div>
<div className="mt-4 space-y-3">
<div className="flex items-center justify-between gap-2">
<div className="text-xs text-[var(--app-hint)]">
{t('codexSync.confirm.selectedCount', { n: selectedSessionIds.length })}
</div>
<div className="flex items-center gap-2">
<Button
type="button"
variant="secondary"
size="sm"
onClick={clearAll}
disabled={isPending || isLoading || selectedSessionIds.length === 0}
>
{t('codexSync.confirm.clearAll')}
</Button>
<Button
type="button"
variant="secondary"
size="sm"
onClick={selectAll}
disabled={isPending || isLoading || sessions.length === 0}
>
{t('codexSync.confirm.selectAll')}
</Button>
</div>
</div>
<div className="max-h-[50vh] overflow-y-auto rounded-lg border border-[var(--app-border)] bg-[var(--app-bg)]">
{isLoading ? (
<div className="px-4 py-8 text-center text-sm text-[var(--app-hint)]">
{t('codexSync.confirm.loading')}
</div>
) : sessions.length === 0 ? (
<div className="px-4 py-8 text-center text-sm text-[var(--app-hint)]">
{t('codexSync.confirm.empty')}
</div>
) : (
<div className="divide-y divide-[var(--app-border)]">
{sessions.map((session) => {
const checked = selectedSessionIdSet.has(session.id)
const time = formatCodexSessionTime(session.modifiedAt)
return (
<label
key={session.id}
className="flex cursor-pointer items-start gap-3 px-3 py-2 transition-colors hover:bg-[var(--app-subtle-bg)]"
>
<input
type="checkbox"
className="mt-1 h-4 w-4 accent-[var(--app-link)]"
checked={checked}
disabled={isPending || isLoading}
onChange={() => toggleSession(session.id)}
/>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<div className="truncate text-sm font-medium text-[var(--app-fg)]">
{session.title}
</div>
{session.id === currentCodexSessionId ? (
<span className="shrink-0 rounded-full bg-[var(--app-secondary-bg)] px-2 py-0.5 text-[10px] text-[var(--app-hint)]">
{t('codexSync.confirm.current')}
</span>
) : null}
</div>
{getCodexSessionPreview(session) ? (
<div className="mt-0.5 truncate text-xs text-[var(--app-hint)]">
{getCodexSessionPreview(session)}
</div>
) : null}
{time ? (
<div className="mt-0.5 text-[11px] text-[var(--app-hint)]">
{time}
</div>
) : null}
</div>
</label>
)
})}
</div>
)}
</div>
</div>
<div className="mt-4 flex justify-end gap-2">
<Button
type="button"
variant="secondary"
onClick={onClose}
disabled={isPending || isRestartingCodexDesktop}
>
{t('button.cancel')}
</Button>
<Button
type="button"
variant="secondary"
onClick={() => void handleConfirm()}
disabled={isPending || isLoading || selectedSessionIds.length === 0}
>
{isPending ? t('codexSync.confirm.confirming') : t('codexSync.confirm.confirm')}
</Button>
</div>
</DialogContent>
</Dialog>
)
}
+38 -1
View File
@@ -16,6 +16,7 @@ import { useSessionListStatusMode } from '@/hooks/useSessionListStatusMode'
import { classifySessionAttention } from '@/lib/sessionAttention'
import { getSessionLastSeenAt } from '@/lib/sessionLastSeen'
import { getAttentionLabel, SessionAttentionIndicator } from '@/components/SessionAttentionIndicator'
import { getCodexImportedAt, subscribeCodexImportedSessions } from '@/lib/codexImportedSessions'
type SessionGroup = {
key: string
@@ -521,6 +522,34 @@ function formatRelativeTime(value: number, t: (key: string, params?: Record<stri
return new Date(ms).toLocaleDateString()
}
function formatCodexImportedRelativeTime(value: number, t: (key: string, params?: Record<string, string | number>) => string): string | null {
const ms = value < 1_000_000_000_000 ? value * 1000 : value
if (!Number.isFinite(ms)) return null
const delta = Date.now() - ms
if (delta < 60_000) return t('session.time.importedFromCodex.justNow')
const minutes = Math.floor(delta / 60_000)
if (minutes < 60) return t('session.time.importedFromCodex.minutesAgo', { n: minutes })
const hours = Math.floor(minutes / 60)
if (hours < 24) return t('session.time.importedFromCodex.hoursAgo', { n: hours })
const days = Math.floor(hours / 24)
if (days < 7) return t('session.time.importedFromCodex.daysAgo', { n: days })
return new Date(ms).toLocaleDateString()
}
function getSessionTimeLabel(session: SessionSummary, t: (key: string, params?: Record<string, string | number>) => string): string | null {
const codexSessionId = session.metadata?.agentSessionId
const importedAt = session.metadata?.flavor === 'codex'
? getCodexImportedAt(codexSessionId)
: null
// 中文注释:导入标记存在时优先显示“xx 前从 Codex 客户端导入”;等用户在 Hapi 里继续发消息后,再由发送逻辑清除该标记。
if (importedAt !== null) {
return formatCodexImportedRelativeTime(importedAt, t)
}
return formatRelativeTime(session.updatedAt, t)
}
function SessionItem(props: {
session: SessionSummary
onSelect: (sessionId: string) => void
@@ -615,7 +644,7 @@ function SessionItem(props: {
</span>
) : null}
<span className="text-[var(--app-hint)]">
{formatRelativeTime(s.updatedAt, t)}
{getSessionTimeLabel(s, t)}
</span>
</div>
</div>
@@ -690,9 +719,17 @@ export function SessionList(props: {
const { sessionListStatusMode } = useSessionListStatusMode()
const showDetailedStatus = sessionListStatusMode === 'detailed'
const [searchQuery, setSearchQuery] = useState('')
const [, setCodexImportedSessionsVersion] = useState(0)
const normalizedQuery = normalizeSearch(searchQuery)
const isSearching = normalizedQuery.length > 0
useEffect(() => {
// 中文注释:监听导入标记变化,让列表在“导入完成”或“用户已在 Hapi 中继续会话”后立即刷新时间文案。
return subscribeCodexImportedSessions(() => {
setCodexImportedSessionsVersion((value) => value + 1)
})
}, [])
const resolveMachineLabel = (machineId: string | null): string => {
if (machineId && machineLabelsById[machineId]) {
return machineLabelsById[machineId]
+1 -1
View File
@@ -63,7 +63,7 @@ export function ConfirmDialog(props: ConfirmDialogProps) {
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription className="mt-2">
<DialogDescription className="mt-2 whitespace-pre-line">
{description}
</DialogDescription>
</DialogHeader>
+94
View File
@@ -0,0 +1,94 @@
const CODEX_IMPORTED_SESSIONS_STORAGE_KEY = 'hapi.codexImportedSessions'
const CODEX_IMPORTED_SESSIONS_EVENT = 'hapi:codex-imported-sessions-updated'
type CodexImportedSessionsMap = Record<string, number>
function isBrowser(): boolean {
return typeof window !== 'undefined' && typeof localStorage !== 'undefined'
}
function dispatchCodexImportedSessionsChanged(): void {
if (!isBrowser()) return
window.dispatchEvent(new CustomEvent(CODEX_IMPORTED_SESSIONS_EVENT))
}
export function readCodexImportedSessions(): CodexImportedSessionsMap {
if (!isBrowser()) return {}
try {
const raw = localStorage.getItem(CODEX_IMPORTED_SESSIONS_STORAGE_KEY)
if (!raw) return {}
const parsed = JSON.parse(raw)
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
return {}
}
const result: CodexImportedSessionsMap = {}
for (const [key, value] of Object.entries(parsed)) {
if (typeof key === 'string' && typeof value === 'number' && Number.isFinite(value)) {
result[key] = value
}
}
return result
} catch {
return {}
}
}
function writeCodexImportedSessions(map: CodexImportedSessionsMap): void {
if (!isBrowser()) return
localStorage.setItem(CODEX_IMPORTED_SESSIONS_STORAGE_KEY, JSON.stringify(map))
dispatchCodexImportedSessionsChanged()
}
export function markCodexSessionsImported(codexSessionIds: string[], importedAt = Date.now()): void {
if (!isBrowser() || codexSessionIds.length === 0) return
// 中文注释:以 Codex thread ID 为 key 记录导入时间,便于会话列表把时间文案切换成“从 Codex 客户端导入”。
const next = readCodexImportedSessions()
for (const codexSessionId of codexSessionIds) {
const trimmed = codexSessionId.trim()
if (trimmed) {
next[trimmed] = importedAt
}
}
writeCodexImportedSessions(next)
}
export function clearCodexImportedSession(codexSessionId: string | null | undefined): void {
if (!isBrowser() || !codexSessionId) return
const next = readCodexImportedSessions()
if (!(codexSessionId in next)) return
// 中文注释:当用户已经在 Hapi 内继续这个会话后,移除导入标记,列表时间恢复为普通“xx 分钟前”。
delete next[codexSessionId]
writeCodexImportedSessions(next)
}
export function getCodexImportedAt(codexSessionId: string | null | undefined): number | null {
if (!codexSessionId) return null
const importedAt = readCodexImportedSessions()[codexSessionId]
return typeof importedAt === 'number' && Number.isFinite(importedAt) ? importedAt : null
}
export function subscribeCodexImportedSessions(onChange: () => void): () => void {
if (!isBrowser()) {
return () => {}
}
const handleStorage = (event: StorageEvent) => {
if (event.key === CODEX_IMPORTED_SESSIONS_STORAGE_KEY) {
onChange()
}
}
const handleCustomEvent = () => {
onChange()
}
window.addEventListener('storage', handleStorage)
window.addEventListener(CODEX_IMPORTED_SESSIONS_EVENT, handleCustomEvent)
return () => {
window.removeEventListener('storage', handleStorage)
window.removeEventListener(CODEX_IMPORTED_SESSIONS_EVENT, handleCustomEvent)
}
}
+43
View File
@@ -53,6 +53,45 @@ export default {
'sessions.group.showMore': 'Show {n} more',
'sessions.group.showLess': 'Show less',
'sessions.group.new': 'New session in this directory',
'codexSync.tooltip': 'Import sessions from Codex into Hapi',
'codexSync.confirm.title': 'Import Codex sessions',
'codexSync.confirm.description': 'Choose Codex sessions to import into Hapi',
// 中文注释:以下文案支撑导入弹窗的多选列表、加载态,以及右上角重启 Codex 客户端按钮提示。
'codexSync.confirm.selectAll': 'Select all',
'codexSync.confirm.clearAll': 'Clear all',
'codexSync.confirm.selectedCount': '{n} sessions selected',
'codexSync.confirm.empty': 'No local Codex sessions found',
'codexSync.confirm.current': 'Linked',
'codexSync.confirm.confirm': 'Import',
'codexSync.confirm.confirming': 'Importing…',
'codexSync.confirm.loading': 'Loading local Codex sessions…',
'codexSync.success.title': 'Import complete',
'codexSync.success.body': 'Imported {n} Codex session(s) into Hapi.',
'codexSync.error.timeout': 'Operation timed out',
'codexSync.error.active': 'This session is still active. Please wait until it ends and try again.',
'codexSync.failed.title': 'Failed to import Codex sessions',
'codexSync.failed.body': 'Failed to import Codex sessions.',
'codexSync.failed.bodyWithReason': 'Import failed: {reason}',
'codexSync.restart.tooltip': 'Restart Codex client',
'codexSync.restart.title': 'Restart Codex client',
'codexSync.restart.description': 'Restart Codex client now to refresh sessions?',
'codexSync.restart.confirm': 'Restart',
'codexSync.restart.confirming': 'Restarting…',
'codexSync.restart.failed.title': 'Failed to restart Codex client',
'codexSync.restart.failed.notFound': 'Failed to restart Codex client: not installed or not found',
'codexSync.restart.failed.body': 'Failed to run the restart script.',
'codexSync.restart.started.title': 'Restart started',
'codexSync.restart.started.body': 'Codex client is restarting to refresh sessions.',
'codexSync.duplicates.confirm.title': 'Duplicate sessions detected',
'codexSync.duplicates.confirm.description': 'Duplicate sessions were detected. Merge them?',
'codexSync.duplicates.confirm.confirm': 'Merge',
'codexSync.duplicates.confirm.confirming': 'Merging…',
'codexSync.duplicates.detect.failed.title': 'Failed to detect duplicate sessions',
'codexSync.duplicates.detect.failed.body': 'Failed to detect duplicate sessions.',
'codexSync.duplicates.merge.success.title': 'Duplicate sessions merged',
'codexSync.duplicates.merge.success.body': 'Merged duplicates for the selected Codex sessions.',
'codexSync.duplicates.merge.failed.title': 'Failed to merge duplicate sessions',
'codexSync.duplicates.merge.failed.body': 'Failed to merge duplicate sessions.',
// Session list
'session.item.path': 'path',
@@ -71,6 +110,10 @@ export default {
'session.time.minutesAgo': '{n}m ago',
'session.time.hoursAgo': '{n}h ago',
'session.time.daysAgo': '{n}d ago',
'session.time.importedFromCodex.justNow': 'just imported from Codex',
'session.time.importedFromCodex.minutesAgo': 'imported from Codex {n}m ago',
'session.time.importedFromCodex.hoursAgo': 'imported from Codex {n}h ago',
'session.time.importedFromCodex.daysAgo': 'imported from Codex {n}d ago',
// Session header
'session.title': 'Files',
+43
View File
@@ -53,6 +53,45 @@ export default {
'sessions.group.showMore': '再显示 {n} 个',
'sessions.group.showLess': '收起',
'sessions.group.new': '在此目录新建会话',
'codexSync.tooltip': '从 Codex 导入会话到 Hapi',
'codexSync.confirm.title': '导入 Codex 会话',
'codexSync.confirm.description': '选择需要导入到 Hapi 的 Codex 会话',
// 中文注释:以下文案支撑导入弹窗的多选列表、加载态,以及右上角重启 Codex 客户端按钮提示。
'codexSync.confirm.selectAll': '全选',
'codexSync.confirm.clearAll': '全取消',
'codexSync.confirm.selectedCount': '已选择 {n} 个会话',
'codexSync.confirm.empty': '未找到本地 Codex 会话',
'codexSync.confirm.current': '当前关联',
'codexSync.confirm.confirm': '导入',
'codexSync.confirm.confirming': '导入中…',
'codexSync.confirm.loading': '正在读取本地 Codex 会话…',
'codexSync.success.title': '导入完成',
'codexSync.success.body': '已导入 {n} 个 Codex 会话到 Hapi。',
'codexSync.error.timeout': '执行超时',
'codexSync.error.active': '当前会话仍处于活跃状态,请等待会话结束后重试',
'codexSync.failed.title': '导入 Codex 会话失败',
'codexSync.failed.body': '导入 Codex 会话失败。',
'codexSync.failed.bodyWithReason': '导入失败:{reason}',
'codexSync.restart.tooltip': '重启codex客户端',
'codexSync.restart.title': '重启 Codex 客户端',
'codexSync.restart.description': '是否立即重启 Codex 客户端以刷新会话?',
'codexSync.restart.confirm': '重启',
'codexSync.restart.confirming': '重启中…',
'codexSync.restart.failed.title': '重启 Codex 客户端失败',
'codexSync.restart.failed.notFound': '尝试重启codex客户端失败,未安装/找不到codex客户端',
'codexSync.restart.failed.body': '重启脚本执行失败。',
'codexSync.restart.started.title': '已发起重启',
'codexSync.restart.started.body': 'Codex 客户端正在重启以刷新会话。',
'codexSync.duplicates.confirm.title': '检测到重复会话',
'codexSync.duplicates.confirm.description': '检测到重复会话,是否进行合并?',
'codexSync.duplicates.confirm.confirm': '合并',
'codexSync.duplicates.confirm.confirming': '合并中…',
'codexSync.duplicates.detect.failed.title': '重复会话检测失败',
'codexSync.duplicates.detect.failed.body': '重复会话检测失败。',
'codexSync.duplicates.merge.success.title': '重复会话已合并',
'codexSync.duplicates.merge.success.body': '已按本次选中的 Codex 会话完成去重合并。',
'codexSync.duplicates.merge.failed.title': '重复会话合并失败',
'codexSync.duplicates.merge.failed.body': '重复会话合并失败。',
// Session list
'session.item.path': '路径',
@@ -71,6 +110,10 @@ export default {
'session.time.minutesAgo': '{n} 分钟前',
'session.time.hoursAgo': '{n} 小时前',
'session.time.daysAgo': '{n} 天前',
'session.time.importedFromCodex.justNow': '刚刚从codex客户端导入',
'session.time.importedFromCodex.minutesAgo': '{n} 分钟前从codex客户端导入',
'session.time.importedFromCodex.hoursAgo': '{n} 小时前从codex客户端导入',
'session.time.importedFromCodex.daysAgo': '{n} 天前从codex客户端导入',
// Session header
'session.title': '文件',
+321 -4
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo } from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useQueryClient } from '@tanstack/react-query'
import {
Navigate,
@@ -15,6 +15,8 @@ import { getScrollRestorationKey } from '@/lib/scrollRestorationKey'
import { App } from '@/App'
import { SessionChat } from '@/components/SessionChat'
import { SessionList } from '@/components/SessionList'
import { CodexSessionSyncDialog } from '@/components/CodexSessionSyncDialog'
import { ConfirmDialog } from '@/components/ui/ConfirmDialog'
import { NewSession } from '@/components/NewSession'
import { WorkspaceBrowser } from '@/components/WorkspaceBrowser'
import { LoadingState } from '@/components/LoadingState'
@@ -36,7 +38,8 @@ import { fetchLatestMessages, seedMessageWindowFromSession } from '@/lib/message
import { clearDraftsAfterSend } from '@/lib/clearDraftsAfterSend'
import { inactiveSessionCanResume } from '@/lib/sessionResume'
import { markSessionSeen } from '@/lib/sessionLastSeen'
import type { Machine } from '@/types/api'
import { clearCodexImportedSession, markCodexSessionsImported } from '@/lib/codexImportedSessions'
import type { Machine, CodexDuplicateSessionGroup, CodexLocalSessionSummary } from '@/types/api'
import FilesPage from '@/routes/sessions/files'
import FilePage from '@/routes/sessions/file'
import TerminalPage from '@/routes/sessions/terminal'
@@ -81,6 +84,27 @@ function PlusIcon(props: { className?: string }) {
)
}
function CodexImportIcon(props: { className?: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={props.className}
>
{/* 中文注释:入口图标改成纯更新箭头,弱化“聊天”含义,避免用户误解成会话本身而不是导入动作。 */}
<path d="M21 12a9 9 0 1 1-2.64-6.36" />
<path d="M21 3v6h-6" />
</svg>
)
}
function FolderOpenIcon(props: { className?: string }) {
return (
<svg
@@ -129,11 +153,22 @@ function getMachineTitle(machine: Machine): string {
function SessionsPage() {
const { api } = useAppContext()
const navigate = useNavigate()
const queryClient = useQueryClient()
const pathname = useLocation({ select: location => location.pathname })
const matchRoute = useMatchRoute()
const { t } = useTranslation()
const { addToast } = useToast()
const { sessions, isLoading, error, refetch } = useSessions(api)
const { machines } = useMachines(api, true)
const [isSyncingCodexSession, setIsSyncingCodexSession] = useState(false)
const [codexSessions, setCodexSessions] = useState<CodexLocalSessionSummary[]>([])
const [isLoadingCodexSessions, setIsLoadingCodexSessions] = useState(false)
const [isSyncConfirmOpen, setIsSyncConfirmOpen] = useState(false)
const [isRestartingCodexDesktop, setIsRestartingCodexDesktop] = useState(false)
const [pendingDuplicateSessionIds, setPendingDuplicateSessionIds] = useState<string[]>([])
const [duplicateSessionGroups, setDuplicateSessionGroups] = useState<CodexDuplicateSessionGroup[]>([])
const [isDuplicateMergeConfirmOpen, setIsDuplicateMergeConfirmOpen] = useState(false)
const [isMergingDuplicateSessions, setIsMergingDuplicateSessions] = useState(false)
const handleRefresh = useCallback(() => {
void refetch()
@@ -152,8 +187,8 @@ function SessionsPage() {
const sessionMatch = matchRoute({ to: '/sessions/$sessionId', fuzzy: true })
const selectedSessionId = sessionMatch && sessionMatch.sessionId !== 'new' ? sessionMatch.sessionId : null
const selectedSession = useMemo(
() => sessions.find((session) => session.id === selectedSessionId) ?? null,
[sessions, selectedSessionId]
() => selectedSessionId ? sessions.find((session) => session.id === selectedSessionId) ?? null : null,
[selectedSessionId, sessions]
)
useEffect(() => {
if (!selectedSessionId || !selectedSession) {
@@ -161,6 +196,9 @@ function SessionsPage() {
}
markSessionSeen(selectedSessionId, selectedSession.updatedAt)
}, [selectedSessionId, selectedSession?.updatedAt])
const currentCodexSessionId = selectedSession?.metadata?.flavor === 'codex'
? (selectedSession.metadata.agentSessionId ?? null)
: null
const isSessionsIndex = pathname === '/sessions' || pathname === '/sessions/'
const sidebar = useSidebarResize()
const handleNewSessionInDirectory = useCallback((args: { machineId: string | null; directory: string }) => {
@@ -172,7 +210,250 @@ function SessionsPage() {
})
}, [navigate])
const isCodexScriptTimeout = useCallback((message: string | null | undefined): boolean => {
const raw = (message ?? '').trim()
return /执行超时|timed\s*out|timeout/i.test(raw)
}, [])
const normalizeCodexScriptError = useCallback((message: string | null | undefined, fallback: string): string => {
const raw = (message ?? '').trim()
if (!raw) return fallback
if (isCodexScriptTimeout(raw)) {
return t('codexSync.error.timeout')
}
if (/当前会话仍处于活跃状态,请等待会话结束后重试|Active Hapi process already has this Codex thread/i.test(raw)) {
return t('codexSync.error.active')
}
if (/未安装\/找不到codex客户端|unable to find codex launcher|找不到.*codex/i.test(raw)) {
return t('codexSync.restart.failed.notFound')
}
return raw
}, [isCodexScriptTimeout, t])
const formatCodexSyncFailureBody = useCallback((reason: string): string => {
if (
reason === t('codexSync.error.timeout') ||
reason === t('codexSync.error.active') ||
reason === t('codexSync.restart.failed.notFound')
) {
return reason
}
return t('codexSync.failed.bodyWithReason', { reason })
}, [t])
const closeDuplicateMergeDialog = useCallback(() => {
// 中文注释:重复会话确认框关闭时一并清空“本次选中导入”的上下文,确保后续检测不会误用上一轮的 codexSessionId。
setIsDuplicateMergeConfirmOpen(false)
setPendingDuplicateSessionIds([])
setDuplicateSessionGroups([])
}, [])
const handleRestartCodexDesktop = useCallback(async () => {
setIsRestartingCodexDesktop(true)
try {
const status = await api.getCodexDesktopStatus()
if (!status.codexClientAvailable) {
throw new Error(t('codexSync.restart.failed.notFound'))
}
const result = await api.restartCodexDesktop()
if (!result.success) {
throw new Error(normalizeCodexScriptError(result.error, t('codexSync.restart.failed.body')))
}
addToast({
title: t('codexSync.restart.started.title'),
body: t('codexSync.restart.started.body'),
sessionId: '',
url: ''
})
} catch (error) {
addToast({
title: t('codexSync.restart.failed.title'),
body: normalizeCodexScriptError(
error instanceof Error ? error.message : null,
t('codexSync.restart.failed.body')
),
sessionId: '',
url: ''
})
} finally {
setIsRestartingCodexDesktop(false)
}
}, [addToast, api, normalizeCodexScriptError, t])
const handleMergeDuplicateSessions = useCallback(async () => {
if (isMergingDuplicateSessions || pendingDuplicateSessionIds.length === 0) return
setIsMergingDuplicateSessions(true)
try {
const result = await api.mergeCodexDuplicateSessions({ sessionIds: pendingDuplicateSessionIds })
if (!result.success) {
throw new Error(normalizeCodexScriptError(result.error, t('codexSync.duplicates.merge.failed.body')))
}
addToast({
title: t('codexSync.duplicates.merge.success.title'),
body: t('codexSync.duplicates.merge.success.body'),
sessionId: '',
url: ''
})
const redirectTarget = selectedSessionId
? result.merged.find((group) => group.removedSessionIds?.includes(selectedSessionId))
: undefined
closeDuplicateMergeDialog()
await Promise.all([
queryClient.invalidateQueries({ queryKey: queryKeys.sessions }),
selectedSessionId
? queryClient.invalidateQueries({ queryKey: queryKeys.session(selectedSessionId) })
: Promise.resolve(),
selectedSessionId
? queryClient.invalidateQueries({ queryKey: queryKeys.messages(selectedSessionId) })
: Promise.resolve()
])
await refetch()
if (redirectTarget?.canonicalSessionId) {
navigate({
to: '/sessions/$sessionId',
params: { sessionId: redirectTarget.canonicalSessionId }
})
}
} catch (error) {
addToast({
title: t('codexSync.duplicates.merge.failed.title'),
body: normalizeCodexScriptError(
error instanceof Error ? error.message : null,
t('codexSync.duplicates.merge.failed.body')
),
sessionId: '',
url: ''
})
throw error
} finally {
setIsMergingDuplicateSessions(false)
}
}, [
addToast,
api,
closeDuplicateMergeDialog,
isMergingDuplicateSessions,
navigate,
normalizeCodexScriptError,
pendingDuplicateSessionIds,
queryClient,
refetch,
selectedSessionId,
t
])
const openCodexImportDialog = useCallback(async () => {
if (isLoadingCodexSessions) return
setIsSyncConfirmOpen(true)
setIsLoadingCodexSessions(true)
try {
const result = await api.getCodexSessions()
setCodexSessions(result.sessions)
} catch (error) {
setCodexSessions([])
const reason = normalizeCodexScriptError(
error instanceof Error ? error.message : null,
t('dialog.error.default')
)
addToast({
title: t('codexSync.failed.title'),
body: formatCodexSyncFailureBody(reason),
sessionId: '',
url: ''
})
} finally {
setIsLoadingCodexSessions(false)
}
}, [addToast, api, formatCodexSyncFailureBody, isLoadingCodexSessions, normalizeCodexScriptError, t])
const handleImportCodexSessions = useCallback(async (sessionIds: string[]) => {
if (isSyncingCodexSession || isLoadingCodexSessions) return
setIsSyncingCodexSession(true)
try {
// 中文注释:弹窗提交的是本地 Codex thread ID;后端会直接读取这些 transcript 并导入到 Hapi。
const result = await api.syncCodexSession({ sessionIds })
if (!result.success) {
throw new Error(normalizeCodexScriptError(result.error, t('codexSync.failed.body')))
}
addToast({
title: t('codexSync.success.title'),
body: t('codexSync.success.body', { n: result.syncedCount ?? sessionIds.length }),
sessionId: '',
url: ''
})
// 中文注释:导入成功后先在浏览器侧记住这些 Codex thread 的导入时间,供左侧会话列表显示特殊时间文案。
markCodexSessionsImported(sessionIds)
setIsSyncConfirmOpen(false)
await refetch()
setPendingDuplicateSessionIds([])
setDuplicateSessionGroups([])
setIsDuplicateMergeConfirmOpen(false)
try {
// 中文注释:重复会话检测严格限定在这次用户勾选导入的 codexSessionId 范围内;未勾选的其它会话不参与检测,也不弹合并提示。
const duplicateResult = await api.getCodexDuplicateSessions({ sessionIds })
if (!duplicateResult.success) {
throw new Error(normalizeCodexScriptError(
duplicateResult.error,
t('codexSync.duplicates.detect.failed.body')
))
}
if (duplicateResult.duplicates.length > 0) {
setPendingDuplicateSessionIds(sessionIds)
setDuplicateSessionGroups(duplicateResult.duplicates)
setIsDuplicateMergeConfirmOpen(true)
}
} catch (duplicateError) {
addToast({
title: t('codexSync.duplicates.detect.failed.title'),
body: normalizeCodexScriptError(
duplicateError instanceof Error ? duplicateError.message : null,
t('codexSync.duplicates.detect.failed.body')
),
sessionId: '',
url: ''
})
}
} catch (syncError) {
const reason = normalizeCodexScriptError(
syncError instanceof Error ? syncError.message : null,
t('dialog.error.default')
)
addToast({
title: t('codexSync.failed.title'),
body: formatCodexSyncFailureBody(reason),
sessionId: '',
url: ''
})
} finally {
setIsSyncingCodexSession(false)
}
}, [
addToast,
api,
formatCodexSyncFailureBody,
isLoadingCodexSessions,
isSyncingCodexSession,
normalizeCodexScriptError,
refetch,
setDuplicateSessionGroups,
setIsDuplicateMergeConfirmOpen,
setPendingDuplicateSessionIds,
t
])
return (
<>
<div className="flex h-full min-h-0">
<div
className={`${isSessionsIndex ? 'flex' : 'hidden lg:flex'} w-full shrink-0 flex-col bg-[var(--app-bg)]`}
@@ -184,6 +465,17 @@ function SessionsPage() {
{t('sessions.count', { n: sessions.length, m: projectCount })}
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => void openCodexImportDialog()}
disabled={isSyncingCodexSession || isLoadingCodexSessions}
aria-label={t('codexSync.tooltip')}
aria-busy={isSyncingCodexSession || isLoadingCodexSessions}
className="p-1.5 rounded-full text-[var(--app-hint)] hover:text-[var(--app-fg)] hover:bg-[var(--app-subtle-bg)] transition-colors disabled:opacity-60 disabled:cursor-wait"
title={t('codexSync.tooltip')}
>
<CodexImportIcon className={`h-5 w-5 ${isLoadingCodexSessions ? 'animate-spin' : ''}`} />
</button>
<button
type="button"
onClick={() => navigate({ to: '/browse' })}
@@ -250,6 +542,29 @@ function SessionsPage() {
</div>
</div>
</div>
{/* 中文注释:这里展示的是本地 Codex transcript 列表;默认尝试勾选当前 Hapi 会话关联的 Codex thread。 */}
<CodexSessionSyncDialog
isOpen={isSyncConfirmOpen}
onClose={() => setIsSyncConfirmOpen(false)}
sessions={codexSessions}
currentCodexSessionId={currentCodexSessionId}
onConfirm={handleImportCodexSessions}
onRestartCodexDesktop={handleRestartCodexDesktop}
isPending={isSyncingCodexSession}
isRestartingCodexDesktop={isRestartingCodexDesktop}
isLoading={isLoadingCodexSessions}
/>
<ConfirmDialog
isOpen={isDuplicateMergeConfirmOpen && duplicateSessionGroups.length > 0}
onClose={closeDuplicateMergeDialog}
title={t('codexSync.duplicates.confirm.title')}
description={t('codexSync.duplicates.confirm.description')}
confirmLabel={t('codexSync.duplicates.confirm.confirm')}
confirmingLabel={t('codexSync.duplicates.confirm.confirming')}
onConfirm={handleMergeDuplicateSessions}
isPending={isMergingDuplicateSessions}
/>
</>
)
}
@@ -292,6 +607,8 @@ function SessionPage() {
isSessionThinking: session?.thinking ?? false,
onSuccess: (sentSessionId) => {
clearDraftsAfterSend(sentSessionId, sessionId)
// 中文注释:一旦用户已经在 Hapi 内继续这个 Codex 会话,就清除“刚从 Codex 导入”的标记。
clearCodexImportedSession(session?.metadata?.codexSessionId)
},
resolveSessionId: async (currentSessionId) => {
if (!api || !session || session.active) {
+69
View File
@@ -142,6 +142,75 @@ export type PushVapidPublicKeyResponse = {
publicKey: string
}
export type CodexDesktopScriptResponse = {
success: boolean
message?: string
pid?: number
command?: string
script?: string
cwd?: string
output?: string
error?: string
codexDesktopRunning?: boolean
codexClientAvailable?: boolean
// 中文注释:多选导入时返回实际处理完成的 Codex 会话数量,用于前端提示本次导入条数。
syncedCount?: number
// 中文注释:这里存放本次导入对应的 Codex thread ID 列表,方便日志和排查 direct import 结果。
sessionIds?: string[]
}
export type CodexLocalSessionSummary = {
id: string
title: string
lastUserMessage?: string | null
cwd?: string | null
file: string
modifiedAt: number
originator?: string | null
cliVersion?: string | null
}
export type CodexLocalSessionsResponse = {
success: true
sessions: CodexLocalSessionSummary[]
}
export type CodexDesktopSyncRequest = {
// 中文注释:前端弹窗直接提交 Codex thread ID,后端会按这些 transcript 直接导入到 Hapi。
sessionIds: string[]
}
export type CodexDesktopStatusResponse = {
success: true
codexDesktopRunning: boolean
codexClientAvailable: boolean
}
export type CodexDuplicateSessionGroup = {
codexSessionId: string
hapiSessionIds: string[]
canonicalSessionId?: string
removedSessionIds?: string[]
}
export type CodexDuplicateSessionsResponse = {
success: true
// 中文注释:这里只返回本次选中导入的 codexSessionId 中检测出来的重复会话,不包含未勾选的其它会话。
duplicates: CodexDuplicateSessionGroup[]
} | {
success: false
error: string
}
export type CodexMergeDuplicateSessionsResponse = {
success: true
merged: CodexDuplicateSessionGroup[]
mergedCount: number
} | {
success: false
error: string
}
export type VisibilityPayload = {
subscriptionId: string
visibility: 'visible' | 'hidden'