From f0d73e9bf4a89b652a0f7b2daf0dd1c95cfbd4b4 Mon Sep 17 00:00:00 2001 From: phantom5099 <1011668688@qq.com> Date: Tue, 16 Jun 2026 19:08:36 +0800 Subject: [PATCH] refactor: Refactor the conversation branching feature, using turnId instead of uuid as the basis for branching --- packages/codingcode/src/client/direct.ts | 4 +- .../codingcode/src/client/direct/sessions.ts | 6 +- packages/codingcode/src/client/http.ts | 2 +- .../codingcode/src/client/http/sessions.ts | 6 +- packages/codingcode/src/client/types.ts | 2 +- .../codingcode/src/server/routes/sessions.ts | 5 +- packages/codingcode/src/session/store.ts | 10 ++- packages/codingcode/test/session/fork.test.ts | 12 +-- .../test/session/prompt-estimate.test.ts | 4 +- packages/desktop/src/agent/MessageStream.tsx | 67 +++++++++-------- packages/desktop/src/hooks/useAgent.ts | 9 +-- packages/desktop/src/lib/core-api.ts | 4 +- packages/desktop/src/stores/global.store.ts | 74 ++++++++----------- .../test/global-store-rollback-state.test.ts | 37 ++-------- packages/desktop/test/global-store.test.ts | 72 ++++++++++++++++++ .../test/message-stream-scroll.test.tsx | 2 - .../test/performance-optimization.test.ts | 54 -------------- packages/desktop/test/scroll-layout.test.ts | 2 +- 18 files changed, 176 insertions(+), 196 deletions(-) diff --git a/packages/codingcode/src/client/direct.ts b/packages/codingcode/src/client/direct.ts index c76bd2d..26caa15 100644 --- a/packages/codingcode/src/client/direct.ts +++ b/packages/codingcode/src/client/direct.ts @@ -336,12 +336,12 @@ export async function createDirectClient(llm: LLMClient, rt: AppRuntime): Promis return clients.sessions.getRollbackState({ sessionId: currentSessionId, cwd: cwd() }); }, - async forkSession(atUuid?: string) { + async forkSession(atTurnId?: number) { if (!currentSessionId) return ''; const result = await clients.sessions.forkSession({ sessionId: currentSessionId, cwd: cwd(), - atUuid, + atTurnId, }); return result.sessionId; }, diff --git a/packages/codingcode/src/client/direct/sessions.ts b/packages/codingcode/src/client/direct/sessions.ts index 400eafd..fae8de5 100644 --- a/packages/codingcode/src/client/direct/sessions.ts +++ b/packages/codingcode/src/client/direct/sessions.ts @@ -65,7 +65,7 @@ export interface SessionClient { forkSession(input: { sessionId: string; cwd: string; - atUuid?: string; + atTurnId?: number; }): Promise<{ sessionId: string; turns: SessionEvent[] }>; } @@ -221,13 +221,13 @@ export function createDirectSessionClient(rt: AppRuntime): SessionClient { code: { canUndoLast: false, lastEntry: null, revertedFiles: [], lastEntryId: null }, }; }, - async forkSession({ sessionId, atUuid }) { + async forkSession({ sessionId, atTurnId }) { const cwd = await getWorkspaceCwd(rt); const newSessionId = await rt.runPromise( Effect.gen(function* () { const session = yield* SessionService; const state = yield* session.create(cwd, 'unknown', sessionId); - return yield* session.forkSession(state, atUuid ?? ''); + return yield* session.forkSession(state, atTurnId ?? 0); }) ); return { sessionId: newSessionId, turns: [] as SessionEvent[] }; diff --git a/packages/codingcode/src/client/http.ts b/packages/codingcode/src/client/http.ts index a2288a3..7579d60 100644 --- a/packages/codingcode/src/client/http.ts +++ b/packages/codingcode/src/client/http.ts @@ -210,7 +210,7 @@ export async function createHttpClient(serverUrl: string): Promise code: { canUndoLast: false, lastEntry: null, revertedFiles: [], lastEntryId: null }, }; }, - async forkSession() { + async forkSession(_atTurnId?: number) { return ''; }, diff --git a/packages/codingcode/src/client/http/sessions.ts b/packages/codingcode/src/client/http/sessions.ts index 031a269..9f182c5 100644 --- a/packages/codingcode/src/client/http/sessions.ts +++ b/packages/codingcode/src/client/http/sessions.ts @@ -61,7 +61,7 @@ export interface SessionClient { forkSession(input: { sessionId: string; cwd: string; - atUuid?: string; + atTurnId?: number; }): Promise<{ sessionId: string; turns: SessionEvent[] }>; } @@ -140,8 +140,8 @@ export function createHttpSessionClient( return apiGet(`/api/sessions/${sessionId}/rollback-state?cwd=${encodeURIComponent(cwd)}`); }, - async forkSession({ sessionId, cwd, atUuid }) { - return apiPost(`/api/sessions/${sessionId}/fork`, { cwd, atUuid }); + async forkSession({ sessionId, cwd, atTurnId }) { + return apiPost(`/api/sessions/${sessionId}/fork`, { cwd, atTurnId }); }, }; } diff --git a/packages/codingcode/src/client/types.ts b/packages/codingcode/src/client/types.ts index a1c3662..6eda8e1 100644 --- a/packages/codingcode/src/client/types.ts +++ b/packages/codingcode/src/client/types.ts @@ -50,7 +50,7 @@ export interface AgentClient { }>; undoLastCodeRollback(force?: boolean, files?: string[]): Promise; getRollbackState(): Promise; - forkSession(atUuid?: string): Promise; + forkSession(atTurnId?: number): Promise; compact(): Promise; getMemoryEnabled(): Promise; setMemoryEnabled(enabled: boolean): Promise; diff --git a/packages/codingcode/src/server/routes/sessions.ts b/packages/codingcode/src/server/routes/sessions.ts index e522e0f..7996f0d 100644 --- a/packages/codingcode/src/server/routes/sessions.ts +++ b/packages/codingcode/src/server/routes/sessions.ts @@ -454,18 +454,19 @@ export function createSessionsRouter(rt: ManagedRt): Hono { router.post('/:id/fork', async (c) => { const sessionId = c.req.param('id'); - const body = (await c.req.json()) as { cwd: string; atUuid?: string }; + const body = (await c.req.json()) as { cwd: string; atTurnId?: number }; const cwd = await rt.runPromise( Effect.gen(function* () { const ws = yield* WorkspaceService; return ws.resolveWorkspaceCwd(body.cwd); }) ); + const atTurnId = body.atTurnId ?? 0; const result = await runWithLayer( Effect.gen(function* () { const session = yield* SessionService; const state = yield* session.create(cwd, 'unknown', sessionId); - const newSessionId = yield* session.forkSession(state, body.atUuid ?? ''); + const newSessionId = yield* session.forkSession(state, atTurnId); const turns = readUIHistory(newSessionId); return { sessionId: newSessionId, turns }; }) as any diff --git a/packages/codingcode/src/session/store.ts b/packages/codingcode/src/session/store.ts index 2141dfb..34a47d1 100644 --- a/packages/codingcode/src/session/store.ts +++ b/packages/codingcode/src/session/store.ts @@ -350,10 +350,10 @@ export class SessionService extends Effect.Service()('Session', const forkSession = ( state: SessionStoreState, - atUuid: string + atTurnId: number ): Effect.Effect => Effect.sync(() => { - return forkSessionImpl(state.sessionId, state.transcriptPath, atUuid); + return forkSessionImpl(state.sessionId, state.transcriptPath, atTurnId); }); const renameSession = ( @@ -485,9 +485,11 @@ function initState(cwd: string, sessionId?: string, parentSessionId?: string): S }; } -function forkSessionImpl(sourceSessionId: string, sourceJsonlPath: string, atUuid: string): string { +function forkSessionImpl(sourceSessionId: string, sourceJsonlPath: string, atTurnId: number): string { const events = readHistory(sourceJsonlPath); - const atIdx = atUuid ? events.findIndex((e) => 'uuid' in e && (e as any).uuid === atUuid) : -1; + const atIdx = events.findIndex( + (e) => e.type === 'user' && (e as any).turnId === atTurnId + ); const chain = atIdx >= 0 ? events.slice(0, atIdx + 1) : events; const newSessionId = randomUUID(); diff --git a/packages/codingcode/test/session/fork.test.ts b/packages/codingcode/test/session/fork.test.ts index 5d35f86..60feb6f 100644 --- a/packages/codingcode/test/session/fork.test.ts +++ b/packages/codingcode/test/session/fork.test.ts @@ -102,7 +102,7 @@ function run(eff: Effect.Effect): Promise { } describe('forkSession', () => { - it('fork copies events from root to atUuid', async () => { + it('fork copies events from root to atTurnId', async () => { const sessionId = randomUUID(); const slug = randomUUID(); const fx = makeFixture(sessionId, slug); @@ -122,11 +122,11 @@ describe('forkSession', () => { memorySnapshot: '', }; - // Fork at u2 (turn 2 start) + // Fork at turn 2 (user message "second") const newSessionId = await run( Effect.gen(function* () { const svc = yield* SessionService; - return yield* svc.forkSession(state, 'u2'); + return yield* svc.forkSession(state, 2); }) ); @@ -169,7 +169,7 @@ describe('forkSession', () => { const newSessionId = await run( Effect.gen(function* () { const svc = yield* SessionService; - return yield* svc.forkSession(state, 'u2'); + return yield* svc.forkSession(state, 2); }) ); @@ -214,7 +214,7 @@ describe('forkSession', () => { const newSessionId = await run( Effect.gen(function* () { const svc = yield* SessionService; - return yield* svc.forkSession(state, 'u2'); + return yield* svc.forkSession(state, 2); }) ); @@ -277,7 +277,7 @@ describe('forkSession', () => { const newSessionId = await run( Effect.gen(function* () { const svc = yield* SessionService; - return yield* svc.forkSession(state, 'a1'); + return yield* svc.forkSession(state, 1); }) ); diff --git a/packages/codingcode/test/session/prompt-estimate.test.ts b/packages/codingcode/test/session/prompt-estimate.test.ts index d6547c0..bb39916 100644 --- a/packages/codingcode/test/session/prompt-estimate.test.ts +++ b/packages/codingcode/test/session/prompt-estimate.test.ts @@ -219,7 +219,7 @@ describe('promptEstimate', () => { const newSessionId = await run( Effect.gen(function* () { const svc = yield* SessionService; - return yield* svc.forkSession(state, 'a1'); + return yield* svc.forkSession(state, 2); }) ); const newIndexPath = join(fx.dir, `${newSessionId}.index.json`); @@ -253,7 +253,7 @@ describe('promptEstimate', () => { const newSessionId = await run( Effect.gen(function* () { const svc = yield* SessionService; - return yield* svc.forkSession(state, 'u2'); + return yield* svc.forkSession(state, 2); }) ); const newIndexPath = join(fx.dir, `${newSessionId}.index.json`); diff --git a/packages/desktop/src/agent/MessageStream.tsx b/packages/desktop/src/agent/MessageStream.tsx index adfab30..fc78b2b 100644 --- a/packages/desktop/src/agent/MessageStream.tsx +++ b/packages/desktop/src/agent/MessageStream.tsx @@ -230,6 +230,7 @@ export default function MessageStream({ threadId }: MessageStreamProps) { const { copiedId, copy } = useCopyToClipboard(); const parentRef = useRef(null); const didScrollToEndRef = useRef(false); + const loadedCheckpointRef = useRef(null); const markFileRestored = useGlobalStore((s) => s.markFileRestored); const setPendingInput = useGlobalStore((s) => s.setPendingInput); @@ -341,7 +342,7 @@ export default function MessageStream({ threadId }: MessageStreamProps) { (i) => i.type === 'message' && (i as any).role === 'user' ); const userContent = userMsg && 'content' in userMsg ? (userMsg as any).content : ''; - const newSessionId = await forkThread(threadId, lastItem.id); + const newSessionId = await forkThread(threadId, Number(turn.id)); if (newSessionId) { setCurrentThread(newSessionId); if (userContent) setPendingInput(userContent); @@ -363,16 +364,23 @@ export default function MessageStream({ threadId }: MessageStreamProps) { setPendingInput, ]); + const getItemKey = useCallback( + (index: number) => renderEntries[index]?.key ?? `empty-${index}`, + [renderEntries] + ); + + const getScrollElement = useCallback(() => parentRef.current, []); + const virtualizer = useVirtualizer({ count: renderEntries.length, - getScrollElement: () => parentRef.current, - estimateSize: () => 60, - getItemKey: (index: number) => renderEntries[index]?.key ?? `empty-${index}`, + getScrollElement, + estimateSize: useCallback(() => 60, []), + getItemKey, overscan: 5, anchorTo: 'end', followOnAppend: 'smooth', scrollEndThreshold: 80, - initialOffset: () => Number.MAX_SAFE_INTEGER, + initialOffset: useCallback(() => Number.MAX_SAFE_INTEGER, []), }); useLayoutEffect(() => { @@ -384,34 +392,31 @@ export default function MessageStream({ threadId }: MessageStreamProps) { const turnStatusKey = useMemo(() => turns.map((t) => `${t.id}:${t.status}`).join(','), [turns]); - const handleLoadDiff = useCallback( - async (uiTurnId: string) => { - const diff = await loadCheckpointDiff(threadId); - if (diff.turnId > 0) { - const state = useGlobalStore.getState(); - const mapping = state.rollback.turnCheckpointMapping[threadId]; - if (mapping?.[diff.turnId] !== uiTurnId) { - state.setTurnCheckpointMapping(threadId, diff.turnId, uiTurnId); - } - } - }, - [threadId, loadCheckpointDiff] - ); + useEffect(() => { + loadedCheckpointRef.current = null; + }, [threadId]); useEffect(() => { - for (const turn of turns) { - if (turn.status !== 'completed' && turn.status !== 'error') continue; - const ckKey = getCheckpointKey( - threadId, - turn.id, - useGlobalStore.getState().rollback.checkpointDiffByTurnId, - useGlobalStore.getState().rollback.turnCheckpointMapping[threadId] ?? EMPTY_MAPPING - ); - if (!ckKey) { - handleLoadDiff(turn.id); - } - } - }, [turnStatusKey, threadId, handleLoadDiff]); + const completedTurnIds = turns + .filter((t) => t.status === 'completed' || t.status === 'error') + .map((t) => t.id); + if (completedTurnIds.length === 0) return; + + const loadKey = `${threadId}:${completedTurnIds.join(',')}`; + if (loadedCheckpointRef.current === loadKey) return; + loadedCheckpointRef.current = loadKey; + + const state = useGlobalStore.getState(); + const existingMapping = state.rollback.turnCheckpointMapping[threadId] ?? EMPTY_MAPPING; + const existingDiffs = state.rollback.checkpointDiffByTurnId; + + const alreadyLoaded = completedTurnIds.some((id) => + getCheckpointKey(threadId, id, existingDiffs, existingMapping) !== null + ); + if (alreadyLoaded) return; + + loadCheckpointDiff(threadId); + }, [turnStatusKey, threadId, loadCheckpointDiff]); const handleRevertFile = useCallback( async (uiTurnId: string, file: string, isReverted: boolean) => { diff --git a/packages/desktop/src/hooks/useAgent.ts b/packages/desktop/src/hooks/useAgent.ts index 5857ac5..a8a7a5f 100644 --- a/packages/desktop/src/hooks/useAgent.ts +++ b/packages/desktop/src/hooks/useAgent.ts @@ -24,7 +24,6 @@ import type { CheckpointDiff, CodeRollbackResult, CodeRollbackUndoResult, - RollbackPreviewDiff, SessionRollbackState, } from '../lib/core-api'; import type { Item, Turn, Project } from '@shared/types'; @@ -356,7 +355,6 @@ export function useAgentRollback() { const revertedFilesByTurnId = useGlobalStore((s) => s.rollback.revertedFilesByTurnId); const setRollbackState = useGlobalStore((s) => s.setRollbackState); const setCheckpointDiff = useGlobalStore((s) => s.setCheckpointDiff); - const setRollbackPreview = useGlobalStore((s) => s.setRollbackPreview); const markFileReverted = useGlobalStore((s) => s.markFileReverted); const markFileRestored = useGlobalStore((s) => s.markFileRestored); const setTurnCheckpointMapping = useGlobalStore((s) => s.setTurnCheckpointMapping); @@ -422,10 +420,9 @@ export function useAgentRollback() { async (threadId: string, throughTurnId: number) => { const cwd = useGlobalStore.getState().agent.threads[threadId]?.cwd ?? workspace.rootPath; const preview = await previewRollbackDiff(threadId, cwd, throughTurnId); - setRollbackPreview(threadId, preview); return preview; }, - [workspace.rootPath, setRollbackPreview] + [workspace.rootPath] ); const rollbackCode = useCallback( @@ -495,9 +492,9 @@ export function useAgentRollback() { ); const forkThread = useCallback( - async (threadId: string, atUuid?: string) => { + async (threadId: string, atTurnId?: number) => { const cwd = useGlobalStore.getState().agent.threads[threadId]?.cwd ?? workspace.rootPath; - const res = await forkSession(threadId, cwd, atUuid); + const res = await forkSession(threadId, cwd, atTurnId); return res.sessionId; }, [workspace.rootPath] diff --git a/packages/desktop/src/lib/core-api.ts b/packages/desktop/src/lib/core-api.ts index d3acf1f..6959166 100644 --- a/packages/desktop/src/lib/core-api.ts +++ b/packages/desktop/src/lib/core-api.ts @@ -386,9 +386,9 @@ export function getRollbackState(sessionId: string, cwd: string): Promise { - return clients.sessions.forkSession({ sessionId, cwd, atUuid }); + return clients.sessions.forkSession({ sessionId, cwd, atTurnId }); } // ---- Automations ---- diff --git a/packages/desktop/src/stores/global.store.ts b/packages/desktop/src/stores/global.store.ts index 9af398d..190372b 100644 --- a/packages/desktop/src/stores/global.store.ts +++ b/packages/desktop/src/stores/global.store.ts @@ -12,7 +12,7 @@ import type { Turn, TodoItem, } from '@shared/types'; -import type { SessionRollbackState, CheckpointDiff, RollbackPreviewDiff } from '../lib/core-api'; +import type { SessionRollbackState, CheckpointDiff } from '../lib/core-api'; import { buildToolDiff } from '../lib/diff-compute'; function normalizeCwd(p: string): string { @@ -83,19 +83,12 @@ interface AgentState { pendingInput: string | null; usageByThreadId: Record; isCompressing: boolean; - hasRunningTurn: boolean; automations: Automation[]; } -interface EditorState { - cursorLine: number; - cursorCol: number; -} - interface RollbackState { rollbackStateByThreadId: Record; checkpointDiffByTurnId: Record; - rollbackPreviewByThreadId: Record; revertedFilesByTurnId: Record; turnCheckpointMapping: Record>; } @@ -107,7 +100,6 @@ interface GlobalState { git: GitStatus; terminals: TerminalSession[]; agent: AgentState; - editor: EditorState; rollback: RollbackState; } @@ -146,7 +138,6 @@ interface GlobalActions { threadId: string, usage: { prompt: number; completion: number; total: number } ) => void; - setCursor: (line: number, col: number) => void; loadThreads: (threads: Thread[]) => void; updateToolCallStatus: ( threadId: string, @@ -165,8 +156,6 @@ interface GlobalActions { // Rollback state setRollbackState: (threadId: string, state: SessionRollbackState) => void; setCheckpointDiff: (threadId: string, turnId: string, diff: CheckpointDiff) => void; - setRollbackPreview: (threadId: string, preview: RollbackPreviewDiff) => void; - clearRollbackPreview: (threadId: string) => void; markFileReverted: (threadId: string, turnId: string, file: string) => void; markFileRestored: (threadId: string, turnId: string, file: string) => void; initRevertedFilesFromState: (threadId: string) => void; @@ -239,17 +228,11 @@ export const useGlobalStore = create()( pendingInput: null, usageByThreadId: {}, isCompressing: false, - hasRunningTurn: false, automations: [], }, - editor: { - cursorLine: 1, - cursorCol: 1, - }, rollback: { rollbackStateByThreadId: {}, checkpointDiffByTurnId: {}, - rollbackPreviewByThreadId: {}, revertedFilesByTurnId: {}, turnCheckpointMapping: {}, }, @@ -409,12 +392,6 @@ export const useGlobalStore = create()( set((s) => { s.agent.usageByThreadId[threadId] = usage; }), - setCursor: (line, col) => - set((s) => { - s.editor.cursorLine = line; - s.editor.cursorCol = col; - }), - loadThreads: (threads) => set((s) => { const incomingIds = new Set(threads.map((t) => t.id)); @@ -435,6 +412,35 @@ export const useGlobalStore = create()( delete s.agent.usageByThreadId[id]; } } + // Clean up todoByThreadId for deleted threads + for (const id of Object.keys(s.agent.todoByThreadId)) { + if (!incomingIds.has(id)) { + delete s.agent.todoByThreadId[id]; + } + } + // Clean up rollback data for deleted threads + for (const id of Object.keys(s.rollback.rollbackStateByThreadId)) { + if (!incomingIds.has(id)) { + delete s.rollback.rollbackStateByThreadId[id]; + } + } + for (const key of Object.keys(s.rollback.checkpointDiffByTurnId)) { + const threadId = key.split(':')[0]; + if (threadId && !incomingIds.has(threadId)) { + delete s.rollback.checkpointDiffByTurnId[key]; + } + } + for (const id of Object.keys(s.rollback.revertedFilesByTurnId)) { + const threadId = id.split(':')[0]; + if (threadId && !incomingIds.has(threadId)) { + delete s.rollback.revertedFilesByTurnId[id]; + } + } + for (const id of Object.keys(s.rollback.turnCheckpointMapping)) { + if (!incomingIds.has(id)) { + delete s.rollback.turnCheckpointMapping[id]; + } + } }), updateToolCallStatus: (threadId, callId, status) => @@ -468,7 +474,6 @@ export const useGlobalStore = create()( thread.turns.push(turn); thread.updatedAt = Date.now(); } - s.agent.hasRunningTurn = true; }), applyChunk: (threadId, turnId, chunk) => @@ -585,10 +590,6 @@ export const useGlobalStore = create()( item.partial = false; } } - // Recalculate hasRunningTurn across all threads - s.agent.hasRunningTurn = Object.values(s.agent.threads).some((t) => - t.turns.some((tu) => tu.status === 'running') - ); }), setPendingInput: (input) => @@ -601,9 +602,6 @@ export const useGlobalStore = create()( const thread = s.agent.threads[threadId]; if (!thread) return; thread.turns = thread.turns.filter((t) => t.status !== 'running'); - s.agent.hasRunningTurn = Object.values(s.agent.threads).some((t) => - t.turns.some((tu) => tu.status === 'running') - ); }), applyTodoUpdate: (threadId, items) => @@ -653,14 +651,6 @@ export const useGlobalStore = create()( set((s) => { s.rollback.checkpointDiffByTurnId[`${threadId}:${turnId}`] = diff as any; }), - setRollbackPreview: (threadId, preview) => - set((s) => { - s.rollback.rollbackPreviewByThreadId[threadId] = preview as any; - }), - clearRollbackPreview: (threadId) => - set((s) => { - delete s.rollback.rollbackPreviewByThreadId[threadId]; - }), markFileReverted: (threadId, turnId, file) => set((s) => { const key = `${threadId}:${turnId}`; @@ -734,10 +724,6 @@ export const useGlobalStore = create()( approvalPolicy: state.agent.approvalPolicy, model: state.agent.model, }, - editor: { - cursorLine: state.editor.cursorLine, - cursorCol: state.editor.cursorCol, - }, }), merge: (persisted, current) => { const persistedAny = persisted as any; diff --git a/packages/desktop/test/global-store-rollback-state.test.ts b/packages/desktop/test/global-store-rollback-state.test.ts index d6022c6..edba340 100644 --- a/packages/desktop/test/global-store-rollback-state.test.ts +++ b/packages/desktop/test/global-store-rollback-state.test.ts @@ -6,12 +6,11 @@ describe('Rollback state in global store', () => { // Reset the store state useGlobalStore.setState({ rollback: { - rollbackStateByThreadId: {}, - checkpointDiffByTurnId: {}, - rollbackPreviewByThreadId: {}, - revertedFilesByTurnId: {}, - turnCheckpointMapping: {}, - }, + rollbackStateByThreadId: {}, + checkpointDiffByTurnId: {}, + revertedFilesByTurnId: {}, + turnCheckpointMapping: {}, + }, }); }); @@ -56,32 +55,6 @@ describe('Rollback state in global store', () => { expect(cached!.files[0]!.deletions).toBe(1); }); - it('setRollbackPreview stores preview', () => { - const preview = { - throughTurnId: 2, - affectedTurns: [3, 4], - diff: 'diff content', - }; - useGlobalStore.getState().setRollbackPreview('thread1', preview); - - const cached = useGlobalStore.getState().rollback.rollbackPreviewByThreadId['thread1']; - expect(cached).toBeDefined(); - expect(cached!.diff).toBe('diff content'); - }); - - it('clearRollbackPreview removes preview', () => { - const preview = { - throughTurnId: 2, - affectedTurns: [3, 4], - diff: 'diff content', - }; - useGlobalStore.getState().setRollbackPreview('thread1', preview); - expect(useGlobalStore.getState().rollback.rollbackPreviewByThreadId['thread1']).toBeDefined(); - - useGlobalStore.getState().clearRollbackPreview('thread1'); - expect(useGlobalStore.getState().rollback.rollbackPreviewByThreadId['thread1']).toBeUndefined(); - }); - it('markFileReverted adds file to reverted list', () => { useGlobalStore.getState().markFileReverted('thread1', '3', '/test/a.ts'); const reverted = useGlobalStore.getState().rollback.revertedFilesByTurnId['thread1:3']; diff --git a/packages/desktop/test/global-store.test.ts b/packages/desktop/test/global-store.test.ts index 61f1c2a..f8d5c19 100644 --- a/packages/desktop/test/global-store.test.ts +++ b/packages/desktop/test/global-store.test.ts @@ -611,3 +611,75 @@ describe('global store - compressing state', () => { expect(useGlobalStore.getState().agent.isCompressing).toBe(false); }); }); + +describe('global store - loadThreads orphan data cleanup', () => { + it('cleans up todoByThreadId for deleted threads', () => { + useGlobalStore.getState().applyTodoUpdate('deleted-thread', [ + { id: '1', text: 'todo', status: 'in_progress' }, + ]); + expect(useGlobalStore.getState().agent.todoByThreadId['deleted-thread']).toBeDefined(); + + useGlobalStore.getState().loadThreads([]); + expect(useGlobalStore.getState().agent.todoByThreadId['deleted-thread']).toBeUndefined(); + }); + + it('preserves todoByThreadId for threads still in the list', () => { + useGlobalStore.getState().applyTodoUpdate('kept-thread', [ + { id: '1', text: 'todo', status: 'in_progress' }, + ]); + useGlobalStore.getState().loadThreads([ + { id: 'kept-thread', projectId: '', title: 'test', cwd: '/x', turns: [], createdAt: 1, updatedAt: 2 }, + ]); + expect(useGlobalStore.getState().agent.todoByThreadId['kept-thread']).toBeDefined(); + }); + + it('cleans up rollbackStateByThreadId for deleted threads', () => { + useGlobalStore.getState().setRollbackState('deleted-thread', { + context: { active: false, currentThroughTurnId: null }, + code: { canUndoLast: false, lastEntry: null, revertedFiles: [], lastEntryId: '' }, + } as any); + useGlobalStore.getState().loadThreads([]); + expect(useGlobalStore.getState().rollback.rollbackStateByThreadId['deleted-thread']).toBeUndefined(); + }); + + it('cleans up checkpointDiffByTurnId for deleted threads', () => { + useGlobalStore.getState().setCheckpointDiff('deleted-thread', '1', { + turnId: 1, files: [], + } as any); + useGlobalStore.getState().loadThreads([]); + expect(useGlobalStore.getState().rollback.checkpointDiffByTurnId['deleted-thread:1']).toBeUndefined(); + }); + + it('cleans up revertedFilesByTurnId for deleted threads', () => { + useGlobalStore.getState().markFileReverted('deleted-thread', '1', '/a.ts'); + useGlobalStore.getState().loadThreads([]); + expect(useGlobalStore.getState().rollback.revertedFilesByTurnId['deleted-thread:1']).toBeUndefined(); + }); + + it('cleans up turnCheckpointMapping for deleted threads', () => { + useGlobalStore.getState().setTurnCheckpointMapping('deleted-thread', 1, 'ui-1'); + useGlobalStore.getState().loadThreads([]); + expect(useGlobalStore.getState().rollback.turnCheckpointMapping['deleted-thread']).toBeUndefined(); + }); + + it('preserves rollback data for threads still in the list', () => { + useGlobalStore.getState().setRollbackState('kept-thread', { + context: { active: false, currentThroughTurnId: null }, + code: { canUndoLast: false, lastEntry: null, revertedFiles: [], lastEntryId: '' }, + } as any); + useGlobalStore.getState().setCheckpointDiff('kept-thread', '1', { + turnId: 1, files: [], + } as any); + useGlobalStore.getState().markFileReverted('kept-thread', '1', '/a.ts'); + useGlobalStore.getState().setTurnCheckpointMapping('kept-thread', 1, 'ui-1'); + + useGlobalStore.getState().loadThreads([ + { id: 'kept-thread', projectId: '', title: 'test', cwd: '/x', turns: [], createdAt: 1, updatedAt: 2 }, + ]); + + expect(useGlobalStore.getState().rollback.rollbackStateByThreadId['kept-thread']).toBeDefined(); + expect(useGlobalStore.getState().rollback.checkpointDiffByTurnId['kept-thread:1']).toBeDefined(); + expect(useGlobalStore.getState().rollback.revertedFilesByTurnId['kept-thread:1']).toBeDefined(); + expect(useGlobalStore.getState().rollback.turnCheckpointMapping['kept-thread']).toBeDefined(); + }); +}); diff --git a/packages/desktop/test/message-stream-scroll.test.tsx b/packages/desktop/test/message-stream-scroll.test.tsx index 09e269c..11c4672 100644 --- a/packages/desktop/test/message-stream-scroll.test.tsx +++ b/packages/desktop/test/message-stream-scroll.test.tsx @@ -82,13 +82,11 @@ beforeEach(() => { pendingInput: null, usageByThreadId: {}, isCompressing: false, - hasRunningTurn: false, automations: [], }, rollback: { rollbackStateByThreadId: {}, checkpointDiffByTurnId: {}, - rollbackPreviewByThreadId: {}, revertedFilesByTurnId: {}, turnCheckpointMapping: {}, }, diff --git a/packages/desktop/test/performance-optimization.test.ts b/packages/desktop/test/performance-optimization.test.ts index 6b00c64..8375203 100644 --- a/packages/desktop/test/performance-optimization.test.ts +++ b/packages/desktop/test/performance-optimization.test.ts @@ -50,58 +50,6 @@ describe('computeDiff - large file protection', () => { }); }); -// ─── global.store: hasRunningTurn ──────────────────────────────────────── - -describe('global store - hasRunningTurn', () => { - beforeEach(() => { - useGlobalStore.setState({ - agent: { - currentThreadId: null, - threads: {}, - approvalPolicy: 'ask-all', - model: '', - models: [], - contextUsage: null, - todoByThreadId: {}, - pendingInput: null, - usageByThreadId: {}, - isCompressing: false, - hasRunningTurn: false, - }, - }); - }); - - it('startTurn sets hasRunningTurn to true', () => { - useGlobalStore.getState().startTurn('t1', { id: 'turn-1', items: [], status: 'running' }); - expect(useGlobalStore.getState().agent.hasRunningTurn).toBe(true); - }); - - it('completeTurn sets hasRunningTurn to false when no other running turns', () => { - useGlobalStore.getState().startTurn('t1', { id: 'turn-1', items: [], status: 'running' }); - useGlobalStore.getState().completeTurn('t1', 'turn-1', 'completed'); - expect(useGlobalStore.getState().agent.hasRunningTurn).toBe(false); - }); - - it('hasRunningTurn stays true when one of two turns is still running', () => { - useGlobalStore.getState().startTurn('t1', { id: 'turn-1', items: [], status: 'running' }); - useGlobalStore.getState().startTurn('t2', { id: 'turn-2', items: [], status: 'running' }); - - useGlobalStore.getState().completeTurn('t1', 'turn-1', 'completed'); - expect(useGlobalStore.getState().agent.hasRunningTurn).toBe(true); - - useGlobalStore.getState().completeTurn('t2', 'turn-2', 'completed'); - expect(useGlobalStore.getState().agent.hasRunningTurn).toBe(false); - }); - - it('clearRunningTurns recalculates hasRunningTurn', () => { - useGlobalStore.getState().startTurn('t1', { id: 'turn-1', items: [], status: 'running' }); - expect(useGlobalStore.getState().agent.hasRunningTurn).toBe(true); - - useGlobalStore.getState().clearRunningTurns('t1'); - expect(useGlobalStore.getState().agent.hasRunningTurn).toBe(false); - }); -}); - // ─── global.store: applyChunk tool_result priority ────────────────────── describe('global store - applyChunk tool_result searches current turn first', () => { @@ -118,7 +66,6 @@ describe('global store - applyChunk tool_result searches current turn first', () pendingInput: null, usageByThreadId: {}, isCompressing: false, - hasRunningTurn: false, }, }); }); @@ -260,7 +207,6 @@ describe('global store - applyChunk tool_result uses push', () => { pendingInput: null, usageByThreadId: {}, isCompressing: false, - hasRunningTurn: false, }, }); }); diff --git a/packages/desktop/test/scroll-layout.test.ts b/packages/desktop/test/scroll-layout.test.ts index fc7b67e..c03943d 100644 --- a/packages/desktop/test/scroll-layout.test.ts +++ b/packages/desktop/test/scroll-layout.test.ts @@ -34,7 +34,7 @@ describe('MessageStream scroll layout', () => { it('virtualizer starts at the bottom via initialOffset', () => { const src = readSource('agent/MessageStream.tsx'); - expect(src).toContain('initialOffset: () => Number.MAX_SAFE_INTEGER'); + expect(src).toContain('Number.MAX_SAFE_INTEGER'); }); it('scrollToEnd uses instant behavior to avoid top-to-bottom animation', () => {