mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
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:
co-authored by
Codex Local
parent
39fba5292c
commit
f9ef3a4489
@@ -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');
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }));
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
@@ -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
@@ -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())
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user