From 4804fe5ce389512ef9d3ef8291996fca9c43ae24 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 20:01:20 -0700 Subject: [PATCH 1/9] fix(native-chat): text typed during /clear follows the chat to its new conversation A structured chat's unsent draft is kept per conversation, and /clear moves the chat's tab to a new conversation. Whatever the box held while the clear ran stayed with the old conversation, which nothing shows any more, so it disappeared. When a chat's tab moves to another conversation, its draft now moves along (after anything the new conversation's draft already holds). The text of a send still waiting for its reply, such as the "/clear" itself, stays behind for that send to clear, since the tab can move before the reply arrives. --- .../native-chat-draft-startup.test.ts | 113 +++++++++++++++ .../app-shell/native-chat-draft-startup.ts | 23 ++- ...tructured-agent-session-draft-move.test.ts | 137 ++++++++++++++++++ .../structured-agent-session-draft-move.ts | 98 +++++++++++++ ...ructured-composer-send.clear-move.test.tsx | 94 ++++++++++++ ...se-native-chat-structured-composer-send.ts | 4 + 6 files changed, 468 insertions(+), 1 deletion(-) create mode 100644 src/renderer/src/app-shell/native-chat-draft-startup.test.ts create mode 100644 src/renderer/src/components/native-chat/structured-agent-session-draft-move.test.ts create mode 100644 src/renderer/src/components/native-chat/structured-agent-session-draft-move.ts create mode 100644 src/renderer/src/components/native-chat/use-native-chat-structured-composer-send.clear-move.test.tsx diff --git a/src/renderer/src/app-shell/native-chat-draft-startup.test.ts b/src/renderer/src/app-shell/native-chat-draft-startup.test.ts new file mode 100644 index 000000000000..afcdad31379c --- /dev/null +++ b/src/renderer/src/app-shell/native-chat-draft-startup.test.ts @@ -0,0 +1,113 @@ +// @vitest-environment happy-dom +import { afterEach, beforeEach, expect, it } from 'vitest' +import { useAppStore } from '@/store' +import { startNativeChatDraftLoad } from './native-chat-draft-startup' +import { applyWebSessionTabsSnapshot } from '@/runtime/web-session-tabs-sync' +import { + ENV, + NOW, + WT, + makeSnapshot, + resetWebSessionTabsSyncTestState +} from '@/runtime/web-session-tabs-sync-test-harness' +import { + clearNativeChatComposerDraftsForTests, + readNativeChatComposerDraft, + structuredAgentSessionDraftScopeKey, + updateNativeChatComposerDraft, + waitForNativeChatComposerDrafts +} from '@/components/native-chat/native-chat-composer-draft-store' +import { + createMemoryNativeChatComposerDraftStorage, + setNativeChatComposerDraftStorageForTests +} from '@/components/native-chat/native-chat-composer-draft-storage' + +const initialState = useAppStore.getState() +let stop: () => void = () => {} + +beforeEach(async () => { + resetWebSessionTabsSyncTestState() + setNativeChatComposerDraftStorageForTests(createMemoryNativeChatComposerDraftStorage()) + stop = startNativeChatDraftLoad() + await waitForNativeChatComposerDrafts(1_000) +}) + +afterEach(() => { + stop() + useAppStore.setState(initialState, true) + clearNativeChatComposerDraftsForTests() +}) + +function chatTab(entityId: string) { + return { + id: 'local-pane', + entityId, + contentType: 'agent-session' as const, + agentSessionAgent: 'codex' as const, + worktreeId: WT, + groupId: 'local-group', + label: 'Codex Chat', + customLabel: null, + color: null, + createdAt: 1, + sortOrder: 0 + } +} + +// The host moves a cleared chat's tab to its new conversation, whichever client ran the /clear. +it("moves what the chat's box held into the conversation its tab moved to", () => { + useAppStore.setState({ + unifiedTabsByWorktree: { [WT]: [chatTab('old-session')] }, + groupsByWorktree: { + [WT]: [ + { id: 'local-group', worktreeId: WT, tabOrder: ['local-pane'], activeTabId: 'local-pane' } + ] + }, + activeGroupIdByWorktree: { [WT]: 'local-group' } + }) + updateNativeChatComposerDraft( + structuredAgentSessionDraftScopeKey('old-session'), + { + text: 'typed during the clear', + images: [{ id: 'i1', path: '/remote/shot.png', connectionId: 'ssh-1' }] + }, + 'immediate' + ) + + useAppStore.setState((state) => + applyWebSessionTabsSnapshot( + state, + makeSnapshot( + [ + { + type: 'agent-session', + id: 'agent-session:new-session', + sessionId: 'new-session', + replacesSessionId: 'old-session', + agent: 'codex', + title: 'Codex Chat', + isActive: true + } + ], + { activeTabId: 'agent-session:new-session', activeTabType: 'agent-session' } + ), + ENV, + NOW, + { contentScope: 'agent-session', preserveLocalLayout: true, terminalPtyMode: 'local' } + ) + ) + + expect(useAppStore.getState().unifiedTabsByWorktree[WT]?.[0]).toMatchObject({ + id: 'local-pane', + entityId: 'new-session' + }) + expect( + readNativeChatComposerDraft(structuredAgentSessionDraftScopeKey('new-session')) + ).toMatchObject({ + text: 'typed during the clear', + images: [{ id: 'i1', path: '/remote/shot.png', connectionId: 'ssh-1' }] + }) + expect(readNativeChatComposerDraft(structuredAgentSessionDraftScopeKey('old-session')).text).toBe( + '' + ) +}) diff --git a/src/renderer/src/app-shell/native-chat-draft-startup.ts b/src/renderer/src/app-shell/native-chat-draft-startup.ts index 4d64bdd708f2..e59b2ada1d12 100644 --- a/src/renderer/src/app-shell/native-chat-draft-startup.ts +++ b/src/renderer/src/app-shell/native-chat-draft-startup.ts @@ -5,17 +5,38 @@ import { setNativeChatComposerDraftOwnerResolver, waitForNativeChatComposerDrafts } from '@/components/native-chat/native-chat-composer-draft-store' +import { + moveStructuredAgentSessionDraft, + structuredAgentSessionConversationMoves +} from '@/components/native-chat/structured-agent-session-draft-move' // Why bounded: loading drafts is bookkeeping and must never hold startup; a slower load still // fills in every draft not edited meanwhile when it lands. const DRAFT_LOAD_WAIT_MS = 1_500 /** Before any startup step, so a step that fails can't leave the drafts unloaded. */ -export function startNativeChatDraftLoad(): void { +export function startNativeChatDraftLoad(): () => void { setNativeChatComposerDraftOwnerResolver((scopeKey) => resolveNativeChatDraftOwner(useAppStore.getState(), scopeKey) ) void hydrateNativeChatComposerDrafts() + // In the same store update as the tab's move, so the chat's new composer mounts with the draft. + return useAppStore.subscribe((state, previous) => { + if (state.unifiedTabsByWorktree === previous.unifiedTabsByWorktree) { + return + } + for (const { from, to } of structuredAgentSessionConversationMoves( + previous.unifiedTabsByWorktree, + state.unifiedTabsByWorktree + )) { + // Why caught: a draft that fails to move must not fail the tab update that triggered it. + try { + moveStructuredAgentSessionDraft(from, to) + } catch (error) { + console.warn('[native-chat-drafts] a cleared chat draft could not move', error) + } + } + }) } /** Startup waits for the drafts alongside the session read, so a composer shows its draft from diff --git a/src/renderer/src/components/native-chat/structured-agent-session-draft-move.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-draft-move.test.ts new file mode 100644 index 000000000000..87d1912df2c8 --- /dev/null +++ b/src/renderer/src/components/native-chat/structured-agent-session-draft-move.test.ts @@ -0,0 +1,137 @@ +// @vitest-environment happy-dom +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import type { Tab } from '../../../../shared/tab-types' +import { + moveStructuredAgentSessionDraft, + noteNativeChatDraftSendOut, + resetStructuredAgentSessionDraftMoveForTests, + structuredAgentSessionConversationMoves +} from './structured-agent-session-draft-move' +import { + clearNativeChatComposerDraftsForTests, + hydrateNativeChatComposerDrafts, + readNativeChatComposerDraft, + structuredAgentSessionDraftScopeKey, + updateNativeChatComposerDraft +} from './native-chat-composer-draft-store' +import { + createMemoryNativeChatComposerDraftStorage, + setNativeChatComposerDraftStorageForTests +} from './native-chat-composer-draft-storage' + +const scope = structuredAgentSessionDraftScopeKey +const SSH_IMAGE = { id: 'i1', path: '/remote/shot.png', connectionId: 'ssh-1' } + +beforeEach(async () => { + setNativeChatComposerDraftStorageForTests(createMemoryNativeChatComposerDraftStorage()) + await hydrateNativeChatComposerDrafts() +}) + +afterEach(() => { + resetStructuredAgentSessionDraftMoveForTests() + clearNativeChatComposerDraftsForTests() +}) + +describe('moving a cleared conversation draft', () => { + it('moves text, SSH images and skill chips whole into the new conversation', () => { + const document = { type: 'doc', content: [{ type: 'paragraph' }] } + updateNativeChatComposerDraft( + scope('a'), + { text: 'typed during clear', document, images: [SSH_IMAGE] }, + 'immediate' + ) + + moveStructuredAgentSessionDraft('a', 'b') + + expect(readNativeChatComposerDraft(scope('b'))).toMatchObject({ + text: 'typed during clear', + document, + images: [SSH_IMAGE] + }) + expect(readNativeChatComposerDraft(scope('a'))).toMatchObject({ text: '', images: [] }) + }) + + it('goes after what the new conversation already holds', () => { + updateNativeChatComposerDraft(scope('a'), { text: 'moved' }, 'immediate') + updateNativeChatComposerDraft(scope('b'), { text: 'already here' }, 'immediate') + + moveStructuredAgentSessionDraft('a', 'b') + + expect(readNativeChatComposerDraft(scope('b')).text).toBe('already here\n\nmoved') + }) + + it("leaves a send's own text behind for it to clear, and moves what was typed after it", () => { + updateNativeChatComposerDraft(scope('a'), { text: '/clear' }, 'immediate') + const settled = noteNativeChatDraftSendOut(scope('a'), readNativeChatComposerDraft(scope('a'))) + updateNativeChatComposerDraft( + scope('a'), + { text: '/clear\nnext question', images: [SSH_IMAGE] }, + 'immediate' + ) + + moveStructuredAgentSessionDraft('a', 'b') + + expect(readNativeChatComposerDraft(scope('b'))).toMatchObject({ + text: '\nnext question', + images: [SSH_IMAGE] + }) + expect(readNativeChatComposerDraft(scope('a'))).toMatchObject({ text: '/clear', images: [] }) + settled() + updateNativeChatComposerDraft(scope('a'), { text: '/clear more' }, 'immediate') + moveStructuredAgentSessionDraft('a', 'b') + expect(readNativeChatComposerDraft(scope('b')).text).toBe('\nnext question\n\n/clear more') + }) + + it('moves nothing from an empty draft', () => { + updateNativeChatComposerDraft(scope('b'), { text: 'mine' }, 'immediate') + moveStructuredAgentSessionDraft('a', 'b') + expect(readNativeChatComposerDraft(scope('b')).text).toBe('mine') + }) + + it('waits for the saved drafts to load before moving one only storage holds', async () => { + clearNativeChatComposerDraftsForTests() + const storage = createMemoryNativeChatComposerDraftStorage() + storage.drafts.set(scope('a'), { text: 'saved before quit', images: [], savedAt: 1 }) + setNativeChatComposerDraftStorageForTests(storage) + const loading = hydrateNativeChatComposerDrafts() + + moveStructuredAgentSessionDraft('a', 'b') + await loading + await Promise.resolve() + + expect(readNativeChatComposerDraft(scope('b')).text).toBe('saved before quit') + expect(readNativeChatComposerDraft(scope('a')).text).toBe('') + }) +}) + +function chat(id: string, entityId: string, contentType: Tab['contentType'] = 'agent-session') { + return { + id, + entityId, + contentType, + groupId: 'g', + worktreeId: 'wt', + label: 'Chat', + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 0 + } satisfies Tab +} + +describe('which chats moved conversation', () => { + it('finds a tab that now shows another conversation, and nothing else', () => { + const previous = { + wt: [chat('t1', 'a'), chat('t2', 'c'), chat('term', 'pty-1', 'terminal')], + other: [chat('t9', 'z')] + } + const next = { + wt: [chat('t1', 'b'), chat('t2', 'c'), chat('t3', 'a'), chat('term', 'pty-2', 'terminal')], + other: previous.other, + added: [chat('t4', 'y')] + } + expect(structuredAgentSessionConversationMoves(previous, next)).toEqual([ + { from: 'a', to: 'b' } + ]) + }) +}) diff --git a/src/renderer/src/components/native-chat/structured-agent-session-draft-move.ts b/src/renderer/src/components/native-chat/structured-agent-session-draft-move.ts new file mode 100644 index 000000000000..cf7595105a63 --- /dev/null +++ b/src/renderer/src/components/native-chat/structured-agent-session-draft-move.ts @@ -0,0 +1,98 @@ +// A structured chat's draft belongs to its conversation. A /clear moves the chat to a new +// conversation, so the unsent draft moves with it rather than staying in one nothing shows. + +import { appendNativeChatDraftCache } from './native-chat-draft-cache' +import { + appendToNativeChatComposerDraft, + hydrateNativeChatComposerDrafts, + isNativeChatComposerDraftLoadPending, + readNativeChatComposerDraft, + structuredAgentSessionDraftScopeKey, + updateNativeChatComposerDraft +} from './native-chat-composer-draft-store' +import { nativeChatComposerDraftLeftAfterSend } from './native-chat-composer-draft-comparison' +import type { NativeChatComposerDraft } from './native-chat-composer-draft-storage' +import type { Tab } from '../../../../shared/tab-types' + +// In memory: a send's text stays in the box until its host accepts it, and the chat can move first. +const sendsOut = new Map() + +/** Marks what a send in flight takes out of the box once accepted; a move leaves that behind for + * the send to clear. Returns the release, for when the send settles. */ +export function noteNativeChatDraftSendOut( + scopeKey: string, + submitted: NativeChatComposerDraft +): () => void { + sendsOut.set(scopeKey, submitted) + return () => { + if (sendsOut.get(scopeKey) === submitted) { + sendsOut.delete(scopeKey) + } + } +} + +/** Moves a conversation's draft into the one its chat moved to, after whatever that one holds. */ +export function moveStructuredAgentSessionDraft(fromSessionId: string, toSessionId: string): void { + if (isNativeChatComposerDraftLoadPending()) { + // Why: until the saved drafts load, the old conversation's draft may not be in memory yet. + void hydrateNativeChatComposerDrafts().then(() => + moveStructuredAgentSessionDraft(fromSessionId, toSessionId) + ) + return + } + const from = structuredAgentSessionDraftScopeKey(fromSessionId) + const to = structuredAgentSessionDraftScopeKey(toSessionId) + const current = readNativeChatComposerDraft(from) + const out = sendsOut.get(from) + // Null once edited inside the sent text: none of it is the send's alone any more, so all moves. + const left = out ? nativeChatComposerDraftLeftAfterSend(current, out) : null + const moving = left ?? current + if (moving.text.trim() === '' && moving.images.length === 0) { + return + } + let durable = appendNativeChatDraftCache(to, moving.text) + if (moving.images.length > 0) { + durable = appendToNativeChatComposerDraft(to, { images: moving.images }) && durable + } + // Skill chips live in the document, which only holds for exactly this text. + if (!left && current.document && readNativeChatComposerDraft(to).text === current.text) { + updateNativeChatComposerDraft(to, { document: current.document }, 'deferred') + } + if (!durable) { + // Why: a copy in both beats losing it, should the app quit before the new one is saved. + return + } + const kept = + out && left + ? { text: out.text, images: current.images.filter((image) => !left.images.includes(image)) } + : { text: '', images: [] } + updateNativeChatComposerDraft(from, { ...kept, document: undefined }, 'immediate') +} + +/** Each chat whose tab now shows another conversation: a /clear's, whichever client ran it. */ +export function structuredAgentSessionConversationMoves( + previous: Readonly>, + next: Readonly> +): { from: string; to: string }[] { + const moves: { from: string; to: string }[] = [] + for (const [worktreeId, tabs] of Object.entries(next)) { + const before = previous[worktreeId] + if (!before || before === tabs) { + continue + } + const shown = new Map( + before.flatMap((tab) => (tab.contentType === 'agent-session' ? [[tab.id, tab.entityId]] : [])) + ) + for (const tab of tabs) { + const was = tab.contentType === 'agent-session' ? shown.get(tab.id) : undefined + if (was !== undefined && was !== tab.entityId) { + moves.push({ from: was, to: tab.entityId }) + } + } + } + return moves +} + +export function resetStructuredAgentSessionDraftMoveForTests(): void { + sendsOut.clear() +} diff --git a/src/renderer/src/components/native-chat/use-native-chat-structured-composer-send.clear-move.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-structured-composer-send.clear-move.test.tsx new file mode 100644 index 000000000000..84330a6fdaf9 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-structured-composer-send.clear-move.test.tsx @@ -0,0 +1,94 @@ +// @vitest-environment happy-dom +import { renderHook } from '@testing-library/react' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { dispatchStructuredAgentSessionComposerCommand } from '../../../../shared/structured-agent-session-composer' +import type { NativeChatStructuredComposerTransport } from './native-chat-composer-types' +import { useNativeChatStructuredComposerSend } from './use-native-chat-structured-composer-send' +import { + clearNativeChatComposerDraftsForTests, + hydrateNativeChatComposerDrafts, + readNativeChatComposerDraft, + structuredAgentSessionDraftScopeKey, + updateNativeChatComposerDraft +} from './native-chat-composer-draft-store' +import { + createMemoryNativeChatComposerDraftStorage, + setNativeChatComposerDraftStorageForTests +} from './native-chat-composer-draft-storage' +import { writeNativeChatDraftCache } from './native-chat-draft-cache' +import { + moveStructuredAgentSessionDraft, + resetStructuredAgentSessionDraftMoveForTests +} from './structured-agent-session-draft-move' + +vi.mock('@/lib/native-chat-telemetry', () => ({ emitNativeChatMessageSent: vi.fn() })) +vi.mock('@/lib/worker-terminal-takeover-report', () => ({ + reportStructuredSessionUserInput: vi.fn() +})) + +const from = structuredAgentSessionDraftScopeKey('session-a') +const to = structuredAgentSessionDraftScopeKey('session-b') + +beforeEach(async () => { + setNativeChatComposerDraftStorageForTests(createMemoryNativeChatComposerDraftStorage()) + await hydrateNativeChatComposerDrafts() +}) + +afterEach(() => { + resetStructuredAgentSessionDraftMoveForTests() + clearNativeChatComposerDraftsForTests() +}) + +// The host moves the chat's tab before the /clear's reply lands, and the box holds "/clear" until then. +it('moves only what was typed after /clear when the chat moves before the reply', async () => { + updateNativeChatComposerDraft(from, { text: '/clear' }, 'immediate') + let reply: () => void = () => {} + const transport: NativeChatStructuredComposerTransport = { + send: vi.fn(() => true), + dispatchCommand: (text: string) => + dispatchStructuredAgentSessionComposerCommand(text, { + agent: 'codex', + snapshot: [], + invokeAction: async () => true, + setOption: async () => true, + conversationCommands: ['clear'], + runConversationCommand: () => + new Promise((resolve) => { + reply = () => resolve({ accepted: true, error: null }) + }) + }), + optionsSurface: { + getSnapshot: () => [], + setOption: vi.fn(), + invokeAction: vi.fn(), + subscribe: () => () => {} + }, + optionSnapshot: [], + onError: vi.fn(), + runtime: 'local', + sessionId: 'session-a', + runtimeEnvironmentId: null + } + const { result } = renderHook(() => + useNativeChatStructuredComposerSend({ + agent: 'codex', + draftScopeKey: from, + imageAttachments: [], + structuredTransport: transport, + isComposing: () => false, + clearSkillOrigin: vi.fn(), + setHistory: vi.fn(), + setDraft: (value) => writeNativeChatDraftCache(from, value), + setCaret: vi.fn() + }) + ) + + const sent = result.current('/clear') + updateNativeChatComposerDraft(from, { text: '/clear\nnext question' }, 'immediate') + moveStructuredAgentSessionDraft('session-a', 'session-b') + reply() + await sent + + expect(readNativeChatComposerDraft(to).text).toBe('\nnext question') + expect(readNativeChatComposerDraft(from).text).toBe('') +}) diff --git a/src/renderer/src/components/native-chat/use-native-chat-structured-composer-send.ts b/src/renderer/src/components/native-chat/use-native-chat-structured-composer-send.ts index 5ba38731c22d..b596ce69bc45 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-structured-composer-send.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-structured-composer-send.ts @@ -18,6 +18,7 @@ import { } from './native-chat-composer-draft-store' import { nativeChatComposerDraftLeftAfterSend } from './native-chat-composer-draft-comparison' import type { NativeChatComposerDraft } from './native-chat-composer-draft-storage' +import { noteNativeChatDraftSendOut } from './structured-agent-session-draft-move' export type UseNativeChatStructuredComposerSendArgs = { agent: AgentType @@ -85,6 +86,8 @@ export function useNativeChatStructuredComposerSend({ structuredTransport.onSubmitted?.() } const submitted = sentFrom ?? readNativeChatComposerDraft(draftScopeKey) + // A /clear can move the chat before its reply lands; its own text must not move along. + const settled = noteNativeChatDraftSendOut(draftScopeKey, submitted) await dispatchNativeChatStructuredComposerText(structuredTransport, text, attachments) .then(({ accepted, error, revealsTranscript }) => { structuredTransport.onError(error) @@ -122,6 +125,7 @@ export function useNativeChatStructuredComposerSend({ .catch((error) => structuredTransport.onError(error instanceof Error ? error.message : String(error)) ) + .finally(settled) }, [ agent, From 6922812bd711d27ddbf10268db9c9a847c7f51d4 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 7 Oct 2026 17:41:10 -0700 Subject: [PATCH 2/9] fix(native-chat): preserve drafts through clear and restart --- .../native-chat-draft-startup.test.ts | 69 +++++++++- .../app-shell/native-chat-draft-startup.ts | 25 +++- .../native-chat-composer-draft-load.ts | 9 ++ .../native-chat-composer-draft-store.ts | 9 ++ ...tructured-agent-session-draft-move.test.ts | 108 ++++++++++++---- .../structured-agent-session-draft-move.ts | 122 ++++++++++-------- ...ructured-composer-send.clear-move.test.tsx | 63 ++++++--- ...se-native-chat-structured-composer-send.ts | 28 +++- .../terminal-surfaces.ts | 1 + src/shared/tab-types.ts | 2 + 10 files changed, 329 insertions(+), 107 deletions(-) diff --git a/src/renderer/src/app-shell/native-chat-draft-startup.test.ts b/src/renderer/src/app-shell/native-chat-draft-startup.test.ts index afcdad31379c..ec6b4a6e4100 100644 --- a/src/renderer/src/app-shell/native-chat-draft-startup.test.ts +++ b/src/renderer/src/app-shell/native-chat-draft-startup.test.ts @@ -1,5 +1,5 @@ // @vitest-environment happy-dom -import { afterEach, beforeEach, expect, it } from 'vitest' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' import { useAppStore } from '@/store' import { startNativeChatDraftLoad } from './native-chat-draft-startup' import { applyWebSessionTabsSnapshot } from '@/runtime/web-session-tabs-sync' @@ -15,6 +15,7 @@ import { readNativeChatComposerDraft, structuredAgentSessionDraftScopeKey, updateNativeChatComposerDraft, + nativeChatComposerDraftWritesSettled, waitForNativeChatComposerDrafts } from '@/components/native-chat/native-chat-composer-draft-store' import { @@ -36,6 +37,40 @@ afterEach(() => { stop() useAppStore.setState(initialState, true) clearNativeChatComposerDraftsForTests() + vi.useRealTimers() +}) + +it('re-derives a saved draft move after the first storage load fails and a retry succeeds', async () => { + stop() + clearNativeChatComposerDraftsForTests() + vi.useFakeTimers() + const storage = createMemoryNativeChatComposerDraftStorage() + storage.drafts.set(structuredAgentSessionDraftScopeKey('old-session'), { + text: 'saved despite the failed load', + images: [], + savedAt: 1 + }) + storage.loadAll = vi + .fn() + .mockRejectedValueOnce(new Error('temporarily unavailable')) + .mockImplementationOnce(async () => new Map(storage.drafts)) + setNativeChatComposerDraftStorageForTests(storage) + useAppStore.setState({ + unifiedTabsByWorktree: { + [WT]: [{ ...chatTab('new-session'), agentSessionReplacesSessionId: 'old-session' }] + } + }) + stop = startNativeChatDraftLoad() + await waitForNativeChatComposerDrafts(1_000) + expect(readNativeChatComposerDraft(structuredAgentSessionDraftScopeKey('new-session')).text).toBe( + '' + ) + await vi.advanceTimersByTimeAsync(1_000) + await nativeChatComposerDraftWritesSettled() + await Promise.resolve() + expect(readNativeChatComposerDraft(structuredAgentSessionDraftScopeKey('new-session')).text).toBe( + 'saved despite the failed load' + ) }) function chatTab(entityId: string) { @@ -55,7 +90,7 @@ function chatTab(entityId: string) { } // The host moves a cleared chat's tab to its new conversation, whichever client ran the /clear. -it("moves what the chat's box held into the conversation its tab moved to", () => { +it("moves what the chat's box held into the conversation its tab moved to", async () => { useAppStore.setState({ unifiedTabsByWorktree: { [WT]: [chatTab('old-session')] }, groupsByWorktree: { @@ -97,6 +132,9 @@ it("moves what the chat's box held into the conversation its tab moved to", () = ) ) + await nativeChatComposerDraftWritesSettled() + await Promise.resolve() + expect(useAppStore.getState().unifiedTabsByWorktree[WT]?.[0]).toMatchObject({ id: 'local-pane', entityId: 'new-session' @@ -111,3 +149,30 @@ it("moves what the chat's box held into the conversation its tab moved to", () = '' ) }) + +it('restores a saved source into an already switched tab when startup reads its replacement', async () => { + stop() + clearNativeChatComposerDraftsForTests() + const storage = createMemoryNativeChatComposerDraftStorage() + storage.drafts.set(structuredAgentSessionDraftScopeKey('old-session'), { + text: 'saved before the switch', + images: [], + savedAt: 1 + }) + setNativeChatComposerDraftStorageForTests(storage) + useAppStore.setState({ + unifiedTabsByWorktree: { + [WT]: [{ ...chatTab('new-session'), agentSessionReplacesSessionId: 'old-session' }] + } + }) + stop = startNativeChatDraftLoad() + await waitForNativeChatComposerDrafts(1_000) + await nativeChatComposerDraftWritesSettled() + await Promise.resolve() + expect(readNativeChatComposerDraft(structuredAgentSessionDraftScopeKey('new-session')).text).toBe( + 'saved before the switch' + ) + expect(readNativeChatComposerDraft(structuredAgentSessionDraftScopeKey('old-session')).text).toBe( + '' + ) +}) diff --git a/src/renderer/src/app-shell/native-chat-draft-startup.ts b/src/renderer/src/app-shell/native-chat-draft-startup.ts index e59b2ada1d12..811b381475f3 100644 --- a/src/renderer/src/app-shell/native-chat-draft-startup.ts +++ b/src/renderer/src/app-shell/native-chat-draft-startup.ts @@ -3,6 +3,7 @@ import { resolveNativeChatDraftOwner } from '../lib/native-chat-draft-owner' import { hydrateNativeChatComposerDrafts, setNativeChatComposerDraftOwnerResolver, + subscribeToNativeChatComposerDraftLoad, waitForNativeChatComposerDrafts } from '@/components/native-chat/native-chat-composer-draft-store' import { @@ -19,9 +20,21 @@ export function startNativeChatDraftLoad(): () => void { setNativeChatComposerDraftOwnerResolver((scopeKey) => resolveNativeChatDraftOwner(useAppStore.getState(), scopeKey) ) + const restoreMoves = (): void => { + for (const { from, to } of structuredAgentSessionConversationMoves( + {}, + useAppStore.getState().unifiedTabsByWorktree + )) { + void moveStructuredAgentSessionDraft(from, to).catch((error) => { + console.warn('[native-chat-drafts] a restored chat draft could not move', error) + }) + } + } + const stopLoad = subscribeToNativeChatComposerDraftLoad(restoreMoves) void hydrateNativeChatComposerDrafts() + restoreMoves() // In the same store update as the tab's move, so the chat's new composer mounts with the draft. - return useAppStore.subscribe((state, previous) => { + const stopTabs = useAppStore.subscribe((state, previous) => { if (state.unifiedTabsByWorktree === previous.unifiedTabsByWorktree) { return } @@ -30,13 +43,15 @@ export function startNativeChatDraftLoad(): () => void { state.unifiedTabsByWorktree )) { // Why caught: a draft that fails to move must not fail the tab update that triggered it. - try { - moveStructuredAgentSessionDraft(from, to) - } catch (error) { + void moveStructuredAgentSessionDraft(from, to).catch((error) => { console.warn('[native-chat-drafts] a cleared chat draft could not move', error) - } + }) } }) + return () => { + stopLoad() + stopTabs() + } } /** Startup waits for the drafts alongside the session read, so a composer shows its draft from diff --git a/src/renderer/src/components/native-chat/native-chat-composer-draft-load.ts b/src/renderer/src/components/native-chat/native-chat-composer-draft-load.ts index 02de2aa92bf1..1d7993f3d743 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-draft-load.ts +++ b/src/renderer/src/components/native-chat/native-chat-composer-draft-load.ts @@ -34,6 +34,13 @@ let hydration: Promise | null = null let started = false let failedLoads = 0 let retryTimer: ReturnType | null = null +const loadedListeners = new Set<() => void>() + +/** Re-derive draft ownership once saved records are available, including a successful retry. */ +export function subscribeToNativeChatComposerDraftLoad(listener: () => void): () => void { + loadedListeners.add(listener) + return () => loadedListeners.delete(listener) +} /** The appends this load could not have read: made after it began reading, or never written. */ function withAppends( @@ -93,6 +100,7 @@ function applyLoaded( load.appendsBeforeLoad.clear() load.deletionsBeforeLoad.length = 0 flushNativeChatComposerDrafts() + loadedListeners.forEach((listener) => listener()) } function retryLater(error: unknown): void { @@ -149,6 +157,7 @@ export function isNativeChatComposerDraftLoadPending(): boolean { } export function resetNativeChatComposerDraftLoadForTests(): void { + loadedListeners.clear() started = false if (retryTimer !== null) { clearTimeout(retryTimer) diff --git a/src/renderer/src/components/native-chat/native-chat-composer-draft-store.ts b/src/renderer/src/components/native-chat/native-chat-composer-draft-store.ts index 60dddf06d2f7..97562d7bcba4 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-draft-store.ts +++ b/src/renderer/src/components/native-chat/native-chat-composer-draft-store.ts @@ -54,6 +54,7 @@ export { export { hydrateNativeChatComposerDrafts, isNativeChatComposerDraftLoadPending, + subscribeToNativeChatComposerDraftLoad, waitForNativeChatComposerDrafts } from './native-chat-composer-draft-load' @@ -112,6 +113,14 @@ export function isNativeChatComposerDraftUnverified(scopeKey: string): boolean { return unverifiedScopes.has(scopeKey) } +/** A moved draft read from storage still needs its image files checked in the new scope. */ +export function markNativeChatComposerDraftUnverified(scopeKey: string): void { + if (!unverifiedScopes.has(scopeKey)) { + unverifiedScopes.add(scopeKey) + notifyScope(scopeKey) + } +} + export function markNativeChatComposerDraftVerified(scopeKey: string): void { if (unverifiedScopes.delete(scopeKey)) { notifyScope(scopeKey) diff --git a/src/renderer/src/components/native-chat/structured-agent-session-draft-move.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-draft-move.test.ts index 87d1912df2c8..5ebdb801e4f6 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-draft-move.test.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-draft-move.test.ts @@ -3,13 +3,12 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest' import type { Tab } from '../../../../shared/tab-types' import { moveStructuredAgentSessionDraft, - noteNativeChatDraftSendOut, - resetStructuredAgentSessionDraftMoveForTests, structuredAgentSessionConversationMoves } from './structured-agent-session-draft-move' import { clearNativeChatComposerDraftsForTests, hydrateNativeChatComposerDrafts, + isNativeChatComposerDraftUnverified, readNativeChatComposerDraft, structuredAgentSessionDraftScopeKey, updateNativeChatComposerDraft @@ -28,12 +27,11 @@ beforeEach(async () => { }) afterEach(() => { - resetStructuredAgentSessionDraftMoveForTests() clearNativeChatComposerDraftsForTests() }) describe('moving a cleared conversation draft', () => { - it('moves text, SSH images and skill chips whole into the new conversation', () => { + it('moves text, SSH images and skill chips whole into the new conversation', async () => { const document = { type: 'doc', content: [{ type: 'paragraph' }] } updateNativeChatComposerDraft( scope('a'), @@ -41,7 +39,7 @@ describe('moving a cleared conversation draft', () => { 'immediate' ) - moveStructuredAgentSessionDraft('a', 'b') + await moveStructuredAgentSessionDraft('a', 'b') expect(readNativeChatComposerDraft(scope('b'))).toMatchObject({ text: 'typed during clear', @@ -51,56 +49,99 @@ describe('moving a cleared conversation draft', () => { expect(readNativeChatComposerDraft(scope('a'))).toMatchObject({ text: '', images: [] }) }) - it('goes after what the new conversation already holds', () => { + it('goes after what the new conversation already holds', async () => { updateNativeChatComposerDraft(scope('a'), { text: 'moved' }, 'immediate') updateNativeChatComposerDraft(scope('b'), { text: 'already here' }, 'immediate') - moveStructuredAgentSessionDraft('a', 'b') + await moveStructuredAgentSessionDraft('a', 'b') expect(readNativeChatComposerDraft(scope('b')).text).toBe('already here\n\nmoved') }) - it("leaves a send's own text behind for it to clear, and moves what was typed after it", () => { - updateNativeChatComposerDraft(scope('a'), { text: '/clear' }, 'immediate') - const settled = noteNativeChatDraftSendOut(scope('a'), readNativeChatComposerDraft(scope('a'))) + it('moves nothing from an empty draft', async () => { + updateNativeChatComposerDraft(scope('b'), { text: 'mine' }, 'immediate') + await moveStructuredAgentSessionDraft('a', 'b') + expect(readNativeChatComposerDraft(scope('b')).text).toBe('mine') + }) + + it('keeps the source when saving the destination fails', async () => { + const storage = createMemoryNativeChatComposerDraftStorage() + setNativeChatComposerDraftStorageForTests(storage) + updateNativeChatComposerDraft(scope('a'), { text: 'still owed' }, 'immediate') + storage.refuseWrites = true + + await moveStructuredAgentSessionDraft('a', 'b') + + expect(readNativeChatComposerDraft(scope('a')).text).toBe('still owed') + expect(readNativeChatComposerDraft(scope('b')).text).toBe('still owed') + storage.refuseWrites = false + await moveStructuredAgentSessionDraft('a', 'b') + expect(readNativeChatComposerDraft(scope('b')).text).toBe('still owed') + expect(readNativeChatComposerDraft(scope('a')).text).toBe('') + }) + + it('saves the skill document before removing its source, and restores it after restart', async () => { + const storage = createMemoryNativeChatComposerDraftStorage() + setNativeChatComposerDraftStorageForTests(storage) + const document = { + type: 'doc', + content: [ + { type: 'paragraph', content: [{ type: 'nativeChatSkill', attrs: { token: '/review' } }] } + ] + } updateNativeChatComposerDraft( scope('a'), - { text: '/clear\nnext question', images: [SSH_IMAGE] }, + { text: '/review', document, images: [SSH_IMAGE] }, 'immediate' ) - moveStructuredAgentSessionDraft('a', 'b') - + await moveStructuredAgentSessionDraft('a', 'b') + expect(storage.drafts.get(scope('b'))).toMatchObject({ document, images: [SSH_IMAGE] }) + clearNativeChatComposerDraftsForTests() + setNativeChatComposerDraftStorageForTests(storage) + await hydrateNativeChatComposerDrafts() expect(readNativeChatComposerDraft(scope('b'))).toMatchObject({ - text: '\nnext question', + text: '/review', + document, images: [SSH_IMAGE] }) - expect(readNativeChatComposerDraft(scope('a'))).toMatchObject({ text: '/clear', images: [] }) - settled() - updateNativeChatComposerDraft(scope('a'), { text: '/clear more' }, 'immediate') - moveStructuredAgentSessionDraft('a', 'b') - expect(readNativeChatComposerDraft(scope('b')).text).toBe('\nnext question\n\n/clear more') + expect(readNativeChatComposerDraft(scope('a')).text).toBe('') }) - it('moves nothing from an empty draft', () => { - updateNativeChatComposerDraft(scope('b'), { text: 'mine' }, 'immediate') - moveStructuredAgentSessionDraft('a', 'b') - expect(readNativeChatComposerDraft(scope('b')).text).toBe('mine') + it('keeps unavailable images intact and deduplicates an already copied image', async () => { + const image = { id: 'missing', path: '', unavailableName: 'notes.png' } + updateNativeChatComposerDraft(scope('a'), { text: 'moved', images: [image] }, 'immediate') + updateNativeChatComposerDraft(scope('b'), { text: 'moved', images: [image] }, 'immediate') + await moveStructuredAgentSessionDraft('a', 'b') + expect(readNativeChatComposerDraft(scope('b'))).toMatchObject({ + text: 'moved', + images: [image] + }) + }) + + it('does not clear a source edited while the destination save is outstanding', async () => { + updateNativeChatComposerDraft(scope('a'), { text: 'moved' }, 'immediate') + const moving = moveStructuredAgentSessionDraft('a', 'b') + updateNativeChatComposerDraft(scope('a'), { text: 'new old-chat draft' }, 'immediate') + await moving + expect(readNativeChatComposerDraft(scope('a')).text).toBe('new old-chat draft') + expect(readNativeChatComposerDraft(scope('b')).text).toBe('moved') }) it('waits for the saved drafts to load before moving one only storage holds', async () => { clearNativeChatComposerDraftsForTests() const storage = createMemoryNativeChatComposerDraftStorage() - storage.drafts.set(scope('a'), { text: 'saved before quit', images: [], savedAt: 1 }) + storage.drafts.set(scope('a'), { text: 'saved before quit', images: [SSH_IMAGE], savedAt: 1 }) setNativeChatComposerDraftStorageForTests(storage) const loading = hydrateNativeChatComposerDrafts() - moveStructuredAgentSessionDraft('a', 'b') + await moveStructuredAgentSessionDraft('a', 'b') await loading await Promise.resolve() expect(readNativeChatComposerDraft(scope('b')).text).toBe('saved before quit') expect(readNativeChatComposerDraft(scope('a')).text).toBe('') + expect(isNativeChatComposerDraftUnverified(scope('b'))).toBe(true) }) }) @@ -135,3 +176,20 @@ describe('which chats moved conversation', () => { ]) }) }) + +it('derives a move from a first host replacement and ignores repeated publication', () => { + const target = { ...chat('new-tab', 'b'), agentSessionReplacesSessionId: 'a' } + expect(structuredAgentSessionConversationMoves({}, { wt: [target] })).toEqual([ + { from: 'a', to: 'b' } + ]) + expect( + structuredAgentSessionConversationMoves({ wt: [target] }, { wt: [{ ...target }] }) + ).toEqual([]) +}) + +it('keeps a draft in a source explicitly reopened from history after restart', () => { + const target = { ...chat('new-tab', 'b'), agentSessionReplacesSessionId: 'a' } + expect( + structuredAgentSessionConversationMoves({}, { wt: [target, chat('history', 'a')] }) + ).toEqual([]) +}) diff --git a/src/renderer/src/components/native-chat/structured-agent-session-draft-move.ts b/src/renderer/src/components/native-chat/structured-agent-session-draft-move.ts index cf7595105a63..e34af5016205 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-draft-move.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-draft-move.ts @@ -1,72 +1,71 @@ // A structured chat's draft belongs to its conversation. A /clear moves the chat to a new // conversation, so the unsent draft moves with it rather than staying in one nothing shows. -import { appendNativeChatDraftCache } from './native-chat-draft-cache' +import { withNativeChatComposerDraftAddition } from './native-chat-composer-draft-addition' import { - appendToNativeChatComposerDraft, + clearNativeChatComposerDraftIfUnchanged, + nativeChatComposerDraftWriteSettled, hydrateNativeChatComposerDrafts, isNativeChatComposerDraftLoadPending, + isNativeChatComposerDraftUnverified, + markNativeChatComposerDraftUnverified, readNativeChatComposerDraft, structuredAgentSessionDraftScopeKey, updateNativeChatComposerDraft } from './native-chat-composer-draft-store' -import { nativeChatComposerDraftLeftAfterSend } from './native-chat-composer-draft-comparison' -import type { NativeChatComposerDraft } from './native-chat-composer-draft-storage' +import { sameNativeChatComposerDraftDocument } from './native-chat-composer-draft-comparison' import type { Tab } from '../../../../shared/tab-types' -// In memory: a send's text stays in the box until its host accepts it, and the chat can move first. -const sendsOut = new Map() - -/** Marks what a send in flight takes out of the box once accepted; a move leaves that behind for - * the send to clear. Returns the release, for when the send settles. */ -export function noteNativeChatDraftSendOut( - scopeKey: string, - submitted: NativeChatComposerDraft -): () => void { - sendsOut.set(scopeKey, submitted) - return () => { - if (sendsOut.get(scopeKey) === submitted) { - sendsOut.delete(scopeKey) - } +/** Moves the whole draft, removing its source only after the destination is saved. */ +export async function moveStructuredAgentSessionDraft( + fromSessionId: string, + toSessionId: string +): Promise { + if (fromSessionId === toSessionId) { + return } -} - -/** Moves a conversation's draft into the one its chat moved to, after whatever that one holds. */ -export function moveStructuredAgentSessionDraft(fromSessionId: string, toSessionId: string): void { if (isNativeChatComposerDraftLoadPending()) { - // Why: until the saved drafts load, the old conversation's draft may not be in memory yet. - void hydrateNativeChatComposerDrafts().then(() => - moveStructuredAgentSessionDraft(fromSessionId, toSessionId) - ) - return + await hydrateNativeChatComposerDrafts() + if (isNativeChatComposerDraftLoadPending()) { + return + } } const from = structuredAgentSessionDraftScopeKey(fromSessionId) const to = structuredAgentSessionDraftScopeKey(toSessionId) - const current = readNativeChatComposerDraft(from) - const out = sendsOut.get(from) - // Null once edited inside the sent text: none of it is the send's alone any more, so all moves. - const left = out ? nativeChatComposerDraftLeftAfterSend(current, out) : null - const moving = left ?? current - if (moving.text.trim() === '' && moving.images.length === 0) { + const source = readNativeChatComposerDraft(from) + if (source.text === '' && source.images.length === 0) { return } - let durable = appendNativeChatDraftCache(to, moving.text) - if (moving.images.length > 0) { - durable = appendToNativeChatComposerDraft(to, { images: moving.images }) && durable - } - // Skill chips live in the document, which only holds for exactly this text. - if (!left && current.document && readNativeChatComposerDraft(to).text === current.text) { - updateNativeChatComposerDraft(to, { document: current.document }, 'deferred') + const target = readNativeChatComposerDraft(to) + const merged = withNativeChatComposerDraftAddition(target, { text: source.text }) + const emptyTarget = target.text === '' && target.images.length === 0 + updateNativeChatComposerDraft( + to, + { + ...(emptyTarget ? source : merged), + images: emptyTarget + ? source.images + : [ + ...target.images, + ...source.images.filter((image) => !target.images.some((held) => held.id === image.id)) + ], + document: emptyTarget + ? source.document + : merged.text === target.text + ? target.document + : undefined + }, + 'immediate' + ) + if (source.images.length > 0 && isNativeChatComposerDraftUnverified(from)) { + markNativeChatComposerDraftUnverified(to) } - if (!durable) { - // Why: a copy in both beats losing it, should the app quit before the new one is saved. - return + if ( + (await nativeChatComposerDraftWriteSettled(to)) && + sameNativeChatComposerDraftDocument(readNativeChatComposerDraft(from).document, source.document) + ) { + clearNativeChatComposerDraftIfUnchanged(from, source) } - const kept = - out && left - ? { text: out.text, images: current.images.filter((image) => !left.images.includes(image)) } - : { text: '', images: [] } - updateNativeChatComposerDraft(from, { ...kept, document: undefined }, 'immediate') } /** Each chat whose tab now shows another conversation: a /clear's, whichever client ran it. */ @@ -76,23 +75,34 @@ export function structuredAgentSessionConversationMoves( ): { from: string; to: string }[] { const moves: { from: string; to: string }[] = [] for (const [worktreeId, tabs] of Object.entries(next)) { - const before = previous[worktreeId] - if (!before || before === tabs) { + const before = previous[worktreeId] ?? [] + if (before === tabs) { continue } const shown = new Map( - before.flatMap((tab) => (tab.contentType === 'agent-session' ? [[tab.id, tab.entityId]] : [])) + before.flatMap((tab) => (tab.contentType === 'agent-session' ? [[tab.id, tab]] : [])) + ) + const shownSessions = new Set( + tabs.filter((tab) => tab.contentType === 'agent-session').map((tab) => tab.entityId) ) for (const tab of tabs) { - const was = tab.contentType === 'agent-session' ? shown.get(tab.id) : undefined + const prior = shown.get(tab.id) + const was = tab.contentType === 'agent-session' ? prior?.entityId : undefined if (was !== undefined && was !== tab.entityId) { moves.push({ from: was, to: tab.entityId }) } + const source = tab.agentSessionReplacesSessionId + if ( + tab.contentType === 'agent-session' && + source && + source !== was && + source !== tab.entityId && + !shownSessions.has(source) && + (prior?.entityId !== tab.entityId || prior?.agentSessionReplacesSessionId !== source) + ) { + moves.push({ from: source, to: tab.entityId }) + } } } return moves } - -export function resetStructuredAgentSessionDraftMoveForTests(): void { - sendsOut.clear() -} diff --git a/src/renderer/src/components/native-chat/use-native-chat-structured-composer-send.clear-move.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-structured-composer-send.clear-move.test.tsx index 84330a6fdaf9..dbfe47954a22 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-structured-composer-send.clear-move.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-structured-composer-send.clear-move.test.tsx @@ -16,10 +16,7 @@ import { setNativeChatComposerDraftStorageForTests } from './native-chat-composer-draft-storage' import { writeNativeChatDraftCache } from './native-chat-draft-cache' -import { - moveStructuredAgentSessionDraft, - resetStructuredAgentSessionDraftMoveForTests -} from './structured-agent-session-draft-move' +import { moveStructuredAgentSessionDraft } from './structured-agent-session-draft-move' vi.mock('@/lib/native-chat-telemetry', () => ({ emitNativeChatMessageSent: vi.fn() })) vi.mock('@/lib/worker-terminal-takeover-report', () => ({ @@ -35,14 +32,13 @@ beforeEach(async () => { }) afterEach(() => { - resetStructuredAgentSessionDraftMoveForTests() clearNativeChatComposerDraftsForTests() }) -// The host moves the chat's tab before the /clear's reply lands, and the box holds "/clear" until then. -it('moves only what was typed after /clear when the chat moves before the reply', async () => { +// The host moves the chat before the command reply lands. +async function clearHarness() { updateNativeChatComposerDraft(from, { text: '/clear' }, 'immediate') - let reply: () => void = () => {} + let reply: (accepted: boolean) => void = () => {} const transport: NativeChatStructuredComposerTransport = { send: vi.fn(() => true), dispatchCommand: (text: string) => @@ -54,7 +50,7 @@ it('moves only what was typed after /clear when the chat moves before the reply' conversationCommands: ['clear'], runConversationCommand: () => new Promise((resolve) => { - reply = () => resolve({ accepted: true, error: null }) + reply = (accepted) => resolve({ accepted, error: accepted ? null : 'not cleared' }) }) }), optionsSurface: { @@ -77,18 +73,55 @@ it('moves only what was typed after /clear when the chat moves before the reply' structuredTransport: transport, isComposing: () => false, clearSkillOrigin: vi.fn(), - setHistory: vi.fn(), setDraft: (value) => writeNativeChatDraftCache(from, value), setCaret: vi.fn() }) ) - const sent = result.current('/clear') - updateNativeChatComposerDraft(from, { text: '/clear\nnext question' }, 'immediate') - moveStructuredAgentSessionDraft('session-a', 'session-b') - reply() + return { send: result.current, reply: (accepted: boolean) => reply(accepted) } +} + +it('moves only what was typed after /clear when the chat moves before the reply', async () => { + const { send, reply } = await clearHarness() + const sent = send('/clear') + expect(readNativeChatComposerDraft(from).text).toBe('') + updateNativeChatComposerDraft(from, { text: 'next question' }, 'immediate') + await moveStructuredAgentSessionDraft('session-a', 'session-b') + reply(true) await sent - expect(readNativeChatComposerDraft(to).text).toBe('\nnext question') + expect(readNativeChatComposerDraft(to).text).toBe('next question') + expect(readNativeChatComposerDraft(from).text).toBe('') +}) + +it('restores a refused clear command if no new draft replaced it', async () => { + const { send, reply } = await clearHarness() + const sent = send('/clear') expect(readNativeChatComposerDraft(from).text).toBe('') + reply(false) + await sent + expect(readNativeChatComposerDraft(from).text).toBe('/clear') +}) + +it('leaves text and images typed after a refused clear untouched', async () => { + const { send, reply } = await clearHarness() + const sent = send('/clear') + const image = { id: 'image', path: '/remote/image.png', connectionId: 'ssh-1' } + updateNativeChatComposerDraft(from, { text: 'next question', images: [image] }, 'immediate') + reply(false) + await sent + expect(readNativeChatComposerDraft(from)).toMatchObject({ + text: 'next question', + images: [image] + }) +}) + +it('leaves the new composer empty when clear succeeds with no following input', async () => { + const { send, reply } = await clearHarness() + const sent = send('/clear') + await moveStructuredAgentSessionDraft('session-a', 'session-b') + reply(true) + await sent + expect(readNativeChatComposerDraft(to)).toMatchObject({ text: '', images: [] }) + expect(readNativeChatComposerDraft(from)).toMatchObject({ text: '', images: [] }) }) diff --git a/src/renderer/src/components/native-chat/use-native-chat-structured-composer-send.ts b/src/renderer/src/components/native-chat/use-native-chat-structured-composer-send.ts index 7fb6c45baa80..2eeaa2c156c4 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-structured-composer-send.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-structured-composer-send.ts @@ -15,12 +15,12 @@ import { nativeChatNoticeFromError } from './native-chat-composer-notice' import { agentSessionWriteNoticeText } from './agent-session-write-notice-text' import { translate } from '@/i18n/i18n' import { + clearNativeChatComposerDraftIfUnchanged, readNativeChatComposerDraft, updateNativeChatComposerDraft } from './native-chat-composer-draft-store' import { nativeChatComposerDraftLeftAfterSend } from './native-chat-composer-draft-comparison' import type { NativeChatComposerDraft } from './native-chat-composer-draft-storage' -import { noteNativeChatDraftSendOut } from './structured-agent-session-draft-move' export type UseNativeChatStructuredComposerSendArgs = { agent: AgentType @@ -92,12 +92,29 @@ export function useNativeChatStructuredComposerSend({ structuredTransport.onSubmitted?.() } const submitted = sentFrom ?? readNativeChatComposerDraft(draftScopeKey) - // A /clear can move the chat before its reply lands; its own text must not move along. - const settled = noteNativeChatDraftSendOut(draftScopeKey, submitted) + // Consume /clear before the host replaces the conversation and its composer. + const clearedAtSubmit = + hostCommand && + /^\/clear(?:\s|$)/i.test(text) && + clearNativeChatComposerDraftIfUnchanged(draftScopeKey, submitted) + if (clearedAtSubmit) { + setDraft('') + setCaret(0) + clearSkillOrigin() + } + const restoreCommand = (): void => { + const current = readNativeChatComposerDraft(draftScopeKey) + if (clearedAtSubmit && current.text === '' && current.images.length === 0) { + updateNativeChatComposerDraft(draftScopeKey, submitted, 'immediate') + setDraft(submitted.text) + setCaret(submitted.text.length) + } + } await dispatchNativeChatStructuredComposerText(structuredTransport, text, attachments) .then(({ accepted, error, revealsTranscript }) => { structuredTransport.onError(error) if (!accepted) { + restoreCommand() return } emitNativeChatMessageSent({ agent, runtime: structuredTransport.runtime }) @@ -111,6 +128,9 @@ export function useNativeChatStructuredComposerSend({ structuredTransport.sessionId, structuredTransport.runtimeEnvironmentId ) + if (clearedAtSubmit) { + return + } // Why: the send settles after a round trip, while this or another composer of the same // conversation may have changed the draft; only what was sent leaves it. const left = nativeChatComposerDraftLeftAfterSend( @@ -128,13 +148,13 @@ export function useNativeChatStructuredComposerSend({ clearSkillOrigin() }) .catch((error) => { + restoreCommand() const notice = nativeChatNoticeFromError( error, agentSessionWriteNoticeText([hostCommand ? 'notDoneCommand' : 'notDoneSend']) ) structuredTransport.onError(notice.text, notice.errorText) }) - .finally(settled) }, [ agent, diff --git a/src/renderer/src/runtime/web-session-tabs-sync/terminal-surfaces.ts b/src/renderer/src/runtime/web-session-tabs-sync/terminal-surfaces.ts index fe94791d8174..5cf7024ddb15 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/terminal-surfaces.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/terminal-surfaces.ts @@ -126,6 +126,7 @@ export function buildMirroredAgentTabs( executionHostId, contentType: 'agent-session', agentSessionAgent: tab.agent, + agentSessionReplacesSessionId: tab.replacesSessionId, // Why: `title` is wire data typed `string`; a host that violates that must // degrade to the placeholder, not throw inside the snapshot patch. label: tab.title?.trim() || defaultAgentChatLabel(tab.agent), diff --git a/src/shared/tab-types.ts b/src/shared/tab-types.ts index a788f246bab1..c611d09ed6dd 100644 --- a/src/shared/tab-types.ts +++ b/src/shared/tab-types.ts @@ -70,6 +70,8 @@ export type Tab = { isPinned?: boolean // pinned tabs survive "close others" /** Provider backing a structured agent-session tab. */ agentSessionAgent?: AgentType + /** Host-published source of a cleared conversation, for restoring its unsent draft. */ + agentSessionReplacesSessionId?: string /** Why: per-tab rendering mode for coding-agent terminals. `'chat'` shows the * native chat view as an overlay while the live terminal stays mounted * underneath; `'terminal'` (the default for legacy/missing) shows the raw From e1542c2ebfd9cc68a0b177a8af6f3bd531d5bb6e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 7 Oct 2026 17:48:54 -0700 Subject: [PATCH 3/9] fix(native-chat): derive restart recovery from host publications --- .../native-chat-draft-startup.test.ts | 98 ++++++++++++++++++- .../app-shell/native-chat-draft-startup.ts | 48 +++++++-- .../native-chat-composer-draft-load.ts | 1 + ...tructured-agent-session-draft-move.test.ts | 17 ---- .../structured-agent-session-draft-move.ts | 25 +---- .../initial-host-session-tabs-events.ts | 25 +++++ .../snapshot-apply.ts | 4 + .../terminal-surfaces.ts | 1 - .../tracking-decisions.ts | 7 ++ src/shared/tab-types.ts | 2 - 10 files changed, 175 insertions(+), 53 deletions(-) create mode 100644 src/renderer/src/runtime/initial-host-session-tabs-events.ts diff --git a/src/renderer/src/app-shell/native-chat-draft-startup.test.ts b/src/renderer/src/app-shell/native-chat-draft-startup.test.ts index ec6b4a6e4100..98947fd4ed7a 100644 --- a/src/renderer/src/app-shell/native-chat-draft-startup.test.ts +++ b/src/renderer/src/app-shell/native-chat-draft-startup.test.ts @@ -2,7 +2,10 @@ import { afterEach, beforeEach, expect, it, vi } from 'vitest' import { useAppStore } from '@/store' import { startNativeChatDraftLoad } from './native-chat-draft-startup' -import { applyWebSessionTabsSnapshot } from '@/runtime/web-session-tabs-sync' +import { + applyWebSessionTabsSnapshot, + shouldApplyWebSessionTabsSnapshot +} from '@/runtime/web-session-tabs-sync' import { ENV, NOW, @@ -57,10 +60,11 @@ it('re-derives a saved draft move after the first storage load fails and a retry setNativeChatComposerDraftStorageForTests(storage) useAppStore.setState({ unifiedTabsByWorktree: { - [WT]: [{ ...chatTab('new-session'), agentSessionReplacesSessionId: 'old-session' }] + [WT]: [chatTab('new-session')] } }) stop = startNativeChatDraftLoad() + publishReplacement() await waitForNativeChatComposerDrafts(1_000) expect(readNativeChatComposerDraft(structuredAgentSessionDraftScopeKey('new-session')).text).toBe( '' @@ -162,10 +166,11 @@ it('restores a saved source into an already switched tab when startup reads its setNativeChatComposerDraftStorageForTests(storage) useAppStore.setState({ unifiedTabsByWorktree: { - [WT]: [{ ...chatTab('new-session'), agentSessionReplacesSessionId: 'old-session' }] + [WT]: [chatTab('new-session')] } }) stop = startNativeChatDraftLoad() + publishReplacement() await waitForNativeChatComposerDrafts(1_000) await nativeChatComposerDraftWritesSettled() await Promise.resolve() @@ -176,3 +181,90 @@ it('restores a saved source into an already switched tab when startup reads its '' ) }) + +function publishReplacement(version = 1, sourceVisible = false): void { + const snapshot = makeSnapshot( + [ + { + type: 'agent-session', + id: 'agent-session:new-session', + sessionId: 'new-session', + replacesSessionId: 'old-session', + agent: 'codex', + title: 'Codex Chat', + isActive: true + }, + ...(sourceVisible + ? [ + { + type: 'agent-session' as const, + id: 'history', + sessionId: 'old-session', + agent: 'codex' as const, + title: 'History', + isActive: false + } + ] + : []) + ], + { snapshotVersion: version } + ) + if (shouldApplyWebSessionTabsSnapshot(snapshot, ENV)) { + useAppStore.setState((state) => + applyWebSessionTabsSnapshot(state, snapshot, ENV, NOW, { + contentScope: 'agent-session', + preserveLocalLayout: true, + terminalPtyMode: 'local' + }) + ) + } +} + +it('does not move a later source-history draft when the host repeats the replacement', async () => { + useAppStore.setState({ unifiedTabsByWorktree: { [WT]: [chatTab('new-session')] } }) + updateNativeChatComposerDraft( + structuredAgentSessionDraftScopeKey('old-session'), + { text: 'initial draft' }, + 'immediate' + ) + publishReplacement() + await Promise.resolve() + await nativeChatComposerDraftWritesSettled() + await Promise.resolve() + expect(readNativeChatComposerDraft(structuredAgentSessionDraftScopeKey('new-session')).text).toBe( + 'initial draft' + ) + updateNativeChatComposerDraft( + structuredAgentSessionDraftScopeKey('old-session'), + { text: 'later history draft' }, + 'immediate' + ) + publishReplacement(2) + await Promise.resolve() + await nativeChatComposerDraftWritesSettled() + expect(readNativeChatComposerDraft(structuredAgentSessionDraftScopeKey('old-session')).text).toBe( + 'later history draft' + ) +}) + +it('leaves an explicitly reopened source history draft in that conversation on first publication', async () => { + useAppStore.setState({ + unifiedTabsByWorktree: { + [WT]: [chatTab('new-session'), { ...chatTab('old-session'), id: 'history' }] + } + }) + updateNativeChatComposerDraft( + structuredAgentSessionDraftScopeKey('old-session'), + { text: 'history draft' }, + 'immediate' + ) + publishReplacement(1, true) + await Promise.resolve() + await nativeChatComposerDraftWritesSettled() + expect(readNativeChatComposerDraft(structuredAgentSessionDraftScopeKey('old-session')).text).toBe( + 'history draft' + ) + expect(readNativeChatComposerDraft(structuredAgentSessionDraftScopeKey('new-session')).text).toBe( + '' + ) +}) diff --git a/src/renderer/src/app-shell/native-chat-draft-startup.ts b/src/renderer/src/app-shell/native-chat-draft-startup.ts index 811b381475f3..f9d90c8563ae 100644 --- a/src/renderer/src/app-shell/native-chat-draft-startup.ts +++ b/src/renderer/src/app-shell/native-chat-draft-startup.ts @@ -1,7 +1,10 @@ import { useAppStore } from '../store' +import { subscribeInitialHostSessionTabs } from '../runtime/initial-host-session-tabs-events' +import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-types' import { resolveNativeChatDraftOwner } from '../lib/native-chat-draft-owner' import { hydrateNativeChatComposerDrafts, + isNativeChatComposerDraftLoadPending, setNativeChatComposerDraftOwnerResolver, subscribeToNativeChatComposerDraftLoad, waitForNativeChatComposerDrafts @@ -20,19 +23,42 @@ export function startNativeChatDraftLoad(): () => void { setNativeChatComposerDraftOwnerResolver((scopeKey) => resolveNativeChatDraftOwner(useAppStore.getState(), scopeKey) ) - const restoreMoves = (): void => { - for (const { from, to } of structuredAgentSessionConversationMoves( - {}, - useAppStore.getState().unifiedTabsByWorktree - )) { - void moveStructuredAgentSessionDraft(from, to).catch((error) => { - console.warn('[native-chat-drafts] a restored chat draft could not move', error) - }) + const pending = new Map() + const restorePending = (): void => { + if (isNativeChatComposerDraftLoadPending()) { + return + } + for (const snapshot of pending.values()) { + const tabs = useAppStore.getState().unifiedTabsByWorktree[snapshot.worktree] ?? [] + const published = new Set( + snapshot.tabs.filter((tab) => tab.type === 'agent-session').map((tab) => tab.sessionId) + ) + for (const tab of snapshot.tabs) { + if ( + tab.type === 'agent-session' && + tab.replacesSessionId && + !published.has(tab.replacesSessionId) && + tabs.some( + (shown) => shown.contentType === 'agent-session' && shown.entityId === tab.sessionId + ) + ) { + void moveStructuredAgentSessionDraft(tab.replacesSessionId, tab.sessionId).catch( + (error) => { + console.warn('[native-chat-drafts] a restored chat draft could not move', error) + } + ) + } + } } + pending.clear() } - const stopLoad = subscribeToNativeChatComposerDraftLoad(restoreMoves) + const stopLoad = subscribeToNativeChatComposerDraftLoad(restorePending) + const stopHost = subscribeInitialHostSessionTabs((snapshot, environmentId) => { + // Retained only until this bounded load finishes; a later run re-derives from its host. + pending.set(`${environmentId}:${snapshot.worktree}`, snapshot) + restorePending() + }) void hydrateNativeChatComposerDrafts() - restoreMoves() // In the same store update as the tab's move, so the chat's new composer mounts with the draft. const stopTabs = useAppStore.subscribe((state, previous) => { if (state.unifiedTabsByWorktree === previous.unifiedTabsByWorktree) { @@ -50,6 +76,8 @@ export function startNativeChatDraftLoad(): () => void { }) return () => { stopLoad() + stopHost() + pending.clear() stopTabs() } } diff --git a/src/renderer/src/components/native-chat/native-chat-composer-draft-load.ts b/src/renderer/src/components/native-chat/native-chat-composer-draft-load.ts index 1d7993f3d743..5cd1cbd40f78 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-draft-load.ts +++ b/src/renderer/src/components/native-chat/native-chat-composer-draft-load.ts @@ -115,6 +115,7 @@ function retryLater(error: unknown): void { load.editedBeforeLoad.clear() load.appendsBeforeLoad.clear() load.deletionsBeforeLoad.length = 0 + loadedListeners.forEach((listener) => listener()) return } retryTimer = setTimeout(() => { diff --git a/src/renderer/src/components/native-chat/structured-agent-session-draft-move.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-draft-move.test.ts index 5ebdb801e4f6..fc8f4867c2c9 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-draft-move.test.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-draft-move.test.ts @@ -176,20 +176,3 @@ describe('which chats moved conversation', () => { ]) }) }) - -it('derives a move from a first host replacement and ignores repeated publication', () => { - const target = { ...chat('new-tab', 'b'), agentSessionReplacesSessionId: 'a' } - expect(structuredAgentSessionConversationMoves({}, { wt: [target] })).toEqual([ - { from: 'a', to: 'b' } - ]) - expect( - structuredAgentSessionConversationMoves({ wt: [target] }, { wt: [{ ...target }] }) - ).toEqual([]) -}) - -it('keeps a draft in a source explicitly reopened from history after restart', () => { - const target = { ...chat('new-tab', 'b'), agentSessionReplacesSessionId: 'a' } - expect( - structuredAgentSessionConversationMoves({}, { wt: [target, chat('history', 'a')] }) - ).toEqual([]) -}) diff --git a/src/renderer/src/components/native-chat/structured-agent-session-draft-move.ts b/src/renderer/src/components/native-chat/structured-agent-session-draft-move.ts index e34af5016205..b4ed7a7aa3f0 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-draft-move.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-draft-move.ts @@ -68,40 +68,25 @@ export async function moveStructuredAgentSessionDraft( } } -/** Each chat whose tab now shows another conversation: a /clear's, whichever client ran it. */ +/** Each chat whose tab now shows another conversation, whichever client ran the clear. */ export function structuredAgentSessionConversationMoves( previous: Readonly>, next: Readonly> ): { from: string; to: string }[] { const moves: { from: string; to: string }[] = [] for (const [worktreeId, tabs] of Object.entries(next)) { - const before = previous[worktreeId] ?? [] - if (before === tabs) { + const before = previous[worktreeId] + if (!before || before === tabs) { continue } const shown = new Map( - before.flatMap((tab) => (tab.contentType === 'agent-session' ? [[tab.id, tab]] : [])) - ) - const shownSessions = new Set( - tabs.filter((tab) => tab.contentType === 'agent-session').map((tab) => tab.entityId) + before.flatMap((tab) => (tab.contentType === 'agent-session' ? [[tab.id, tab.entityId]] : [])) ) for (const tab of tabs) { - const prior = shown.get(tab.id) - const was = tab.contentType === 'agent-session' ? prior?.entityId : undefined + const was = tab.contentType === 'agent-session' ? shown.get(tab.id) : undefined if (was !== undefined && was !== tab.entityId) { moves.push({ from: was, to: tab.entityId }) } - const source = tab.agentSessionReplacesSessionId - if ( - tab.contentType === 'agent-session' && - source && - source !== was && - source !== tab.entityId && - !shownSessions.has(source) && - (prior?.entityId !== tab.entityId || prior?.agentSessionReplacesSessionId !== source) - ) { - moves.push({ from: source, to: tab.entityId }) - } } } return moves diff --git a/src/renderer/src/runtime/initial-host-session-tabs-events.ts b/src/renderer/src/runtime/initial-host-session-tabs-events.ts new file mode 100644 index 000000000000..78f3d55856ed --- /dev/null +++ b/src/renderer/src/runtime/initial-host-session-tabs-events.ts @@ -0,0 +1,25 @@ +import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-types' + +type Listener = (snapshot: RuntimeMobileSessionTabsResult, environmentId: string) => void +const listeners = new Set() + +export function subscribeInitialHostSessionTabs(listener: Listener): () => void { + listeners.add(listener) + return () => listeners.delete(listener) +} + +/** The existing publication cursor decides first receipt; no replacement identity is stored. */ +export function queueInitialHostSessionTabs( + snapshot: RuntimeMobileSessionTabsResult, + environmentId: string +): void { + const eligible = [...listeners] + // Publication decisions can run inside a store updater; the binding must land first. + queueMicrotask(() => { + for (const listener of eligible) { + if (listeners.has(listener)) { + listener(snapshot, environmentId) + } + } + }) +} diff --git a/src/renderer/src/runtime/local-structured-session-tabs-sync/snapshot-apply.ts b/src/renderer/src/runtime/local-structured-session-tabs-sync/snapshot-apply.ts index ba882865dfb2..eac795359701 100644 --- a/src/renderer/src/runtime/local-structured-session-tabs-sync/snapshot-apply.ts +++ b/src/renderer/src/runtime/local-structured-session-tabs-sync/snapshot-apply.ts @@ -36,6 +36,7 @@ import { import { suppressCancelledStructuredSessionTabs } from '../structured-agent-session-tab-retirement' import { LOCAL_STRUCTURED_SESSION_OWNER } from '../local-structured-session-owner' import { closeStructuredAgentSession } from '../structured-agent-session-close' +import { queueInitialHostSessionTabs } from '../initial-host-session-tabs-events' /** The host saying it no longer publishes this worktree at all, rather than publishing an empty one. */ function isWorktreeRetraction( @@ -158,6 +159,9 @@ export function applyLocalStructuredSessionTabSnapshots< } ) next = patch === next ? next : ({ ...next, ...patch } as State) + if (!prior && !isWorktreeRetraction(effectiveSnapshot)) { + queueInitialHostSessionTabs(effectiveSnapshot, owner) + } if (isWorktreeRetraction(effectiveSnapshot)) { // A retraction was applied above — the mirrored rows must go — but it is not a publication // to fence later frames against. Recording it would retire the renderer's own epoch, which diff --git a/src/renderer/src/runtime/web-session-tabs-sync/terminal-surfaces.ts b/src/renderer/src/runtime/web-session-tabs-sync/terminal-surfaces.ts index 5cf7024ddb15..fe94791d8174 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/terminal-surfaces.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/terminal-surfaces.ts @@ -126,7 +126,6 @@ export function buildMirroredAgentTabs( executionHostId, contentType: 'agent-session', agentSessionAgent: tab.agent, - agentSessionReplacesSessionId: tab.replacesSessionId, // Why: `title` is wire data typed `string`; a host that violates that must // degrade to the placeholder, not throw inside the snapshot patch. label: tab.title?.trim() || defaultAgentChatLabel(tab.agent), diff --git a/src/renderer/src/runtime/web-session-tabs-sync/tracking-decisions.ts b/src/renderer/src/runtime/web-session-tabs-sync/tracking-decisions.ts index d794fc83bcc2..e672f7c22e16 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/tracking-decisions.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/tracking-decisions.ts @@ -22,6 +22,7 @@ import { clearWebSessionTabsTrackingForWorktree } from './tracking-lifecycle' import { queueAcceptedWebSessionTerminalSnapshot } from '../web-session-terminal-handle-events' import { shouldAutoCreateInitialTerminal } from '@/components/terminal/initial-terminal' import { hostSnapshotAffirmsWorktreeContents } from '../host-session-snapshot-authority' +import { queueInitialHostSessionTabs } from '../initial-host-session-tabs-events' /** A frame's fate, paired with whether that fate is host evidence for the worktree. */ export type WebSessionTabsSnapshotDecision = { @@ -129,6 +130,12 @@ export function decideWebSessionTabsSnapshot( recordAcceptedWebSessionTabsEnvironment(environmentId, snapshot) // Why: a mounted mirror that exhausted bounded polling needs fresh host evidence without subscribing to every store write. queueAcceptedWebSessionTerminalSnapshot(snapshot, environmentId) + if ( + (!current || !hostSnapshotAffirmsWorktreeContents(current)) && + hostSnapshotAffirmsWorktreeContents(snapshot) + ) { + queueInitialHostSessionTabs(snapshot, environmentId) + } return WEB_SESSION_TABS_FRAME_APPLIED } diff --git a/src/shared/tab-types.ts b/src/shared/tab-types.ts index c611d09ed6dd..a788f246bab1 100644 --- a/src/shared/tab-types.ts +++ b/src/shared/tab-types.ts @@ -70,8 +70,6 @@ export type Tab = { isPinned?: boolean // pinned tabs survive "close others" /** Provider backing a structured agent-session tab. */ agentSessionAgent?: AgentType - /** Host-published source of a cleared conversation, for restoring its unsent draft. */ - agentSessionReplacesSessionId?: string /** Why: per-tab rendering mode for coding-agent terminals. `'chat'` shows the * native chat view as an overlay while the live terminal stays mounted * underneath; `'terminal'` (the default for legacy/missing) shows the raw From 5cb156bdb0f5424f39c6d1ac67a1db73e04c4988 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 7 Oct 2026 18:23:18 -0700 Subject: [PATCH 4/9] fix(native-chat): carry pending uploads and preserve merged skill chips --- .../native-chat-draft-startup.test.ts | 371 ++++++++---------- .../app-shell/native-chat-draft-startup.ts | 70 ++-- .../native-chat/NativeChatComposer.tsx | 9 +- .../native-chat-draft-document-merge.ts | 72 ++++ .../native-chat-pending-attachment-cache.ts | 54 ++- ...ent-session-draft-attachment-move.test.tsx | 108 +++++ ...tructured-agent-session-draft-move.test.ts | 62 +++ .../structured-agent-session-draft-move.ts | 13 +- .../use-native-chat-composer-attachments.ts | 12 +- .../use-native-chat-composer-paste.ts | 11 +- .../use-native-chat-paste-lifetime.ts | 44 ++- .../initial-host-session-tabs-events.ts | 25 -- .../snapshot-apply.ts | 4 - .../tracking-decisions.ts | 7 - 14 files changed, 545 insertions(+), 317 deletions(-) create mode 100644 src/renderer/src/components/native-chat/native-chat-draft-document-merge.ts create mode 100644 src/renderer/src/components/native-chat/structured-agent-session-draft-attachment-move.test.tsx delete mode 100644 src/renderer/src/runtime/initial-host-session-tabs-events.ts diff --git a/src/renderer/src/app-shell/native-chat-draft-startup.test.ts b/src/renderer/src/app-shell/native-chat-draft-startup.test.ts index 98947fd4ed7a..04d9a816d093 100644 --- a/src/renderer/src/app-shell/native-chat-draft-startup.test.ts +++ b/src/renderer/src/app-shell/native-chat-draft-startup.test.ts @@ -6,6 +6,10 @@ import { applyWebSessionTabsSnapshot, shouldApplyWebSessionTabsSnapshot } from '@/runtime/web-session-tabs-sync' +import { + applyLocalStructuredSessionTabSnapshots, + resetLocalStructuredSessionVersionForTests +} from '@/runtime/local-structured-session-tabs-sync' import { ENV, NOW, @@ -16,7 +20,7 @@ import { import { clearNativeChatComposerDraftsForTests, readNativeChatComposerDraft, - structuredAgentSessionDraftScopeKey, + structuredAgentSessionDraftScopeKey as scope, updateNativeChatComposerDraft, nativeChatComposerDraftWritesSettled, waitForNativeChatComposerDrafts @@ -26,192 +30,43 @@ import { setNativeChatComposerDraftStorageForTests } from '@/components/native-chat/native-chat-composer-draft-storage' -const initialState = useAppStore.getState() -let stop: () => void = () => {} - -beforeEach(async () => { - resetWebSessionTabsSyncTestState() - setNativeChatComposerDraftStorageForTests(createMemoryNativeChatComposerDraftStorage()) - stop = startNativeChatDraftLoad() - await waitForNativeChatComposerDrafts(1_000) -}) - -afterEach(() => { - stop() - useAppStore.setState(initialState, true) - clearNativeChatComposerDraftsForTests() - vi.useRealTimers() -}) - -it('re-derives a saved draft move after the first storage load fails and a retry succeeds', async () => { - stop() - clearNativeChatComposerDraftsForTests() - vi.useFakeTimers() - const storage = createMemoryNativeChatComposerDraftStorage() - storage.drafts.set(structuredAgentSessionDraftScopeKey('old-session'), { - text: 'saved despite the failed load', - images: [], - savedAt: 1 - }) - storage.loadAll = vi - .fn() - .mockRejectedValueOnce(new Error('temporarily unavailable')) - .mockImplementationOnce(async () => new Map(storage.drafts)) - setNativeChatComposerDraftStorageForTests(storage) - useAppStore.setState({ - unifiedTabsByWorktree: { - [WT]: [chatTab('new-session')] - } - }) - stop = startNativeChatDraftLoad() - publishReplacement() - await waitForNativeChatComposerDrafts(1_000) - expect(readNativeChatComposerDraft(structuredAgentSessionDraftScopeKey('new-session')).text).toBe( - '' - ) - await vi.advanceTimersByTimeAsync(1_000) - await nativeChatComposerDraftWritesSettled() - await Promise.resolve() - expect(readNativeChatComposerDraft(structuredAgentSessionDraftScopeKey('new-session')).text).toBe( - 'saved despite the failed load' - ) -}) - -function chatTab(entityId: string) { +const initial = useAppStore.getState() +let stop = (): void => {} +function chat(entityId: string, id = 'pane') { return { - id: 'local-pane', + id, entityId, contentType: 'agent-session' as const, agentSessionAgent: 'codex' as const, worktreeId: WT, - groupId: 'local-group', - label: 'Codex Chat', + groupId: 'group', + label: 'Chat', customLabel: null, color: null, createdAt: 1, sortOrder: 0 } } - -// The host moves a cleared chat's tab to its new conversation, whichever client ran the /clear. -it("moves what the chat's box held into the conversation its tab moved to", async () => { - useAppStore.setState({ - unifiedTabsByWorktree: { [WT]: [chatTab('old-session')] }, - groupsByWorktree: { - [WT]: [ - { id: 'local-group', worktreeId: WT, tabOrder: ['local-pane'], activeTabId: 'local-pane' } - ] - }, - activeGroupIdByWorktree: { [WT]: 'local-group' } - }) - updateNativeChatComposerDraft( - structuredAgentSessionDraftScopeKey('old-session'), - { - text: 'typed during the clear', - images: [{ id: 'i1', path: '/remote/shot.png', connectionId: 'ssh-1' }] - }, - 'immediate' - ) - - useAppStore.setState((state) => - applyWebSessionTabsSnapshot( - state, - makeSnapshot( - [ - { - type: 'agent-session', - id: 'agent-session:new-session', - sessionId: 'new-session', - replacesSessionId: 'old-session', - agent: 'codex', - title: 'Codex Chat', - isActive: true - } - ], - { activeTabId: 'agent-session:new-session', activeTabType: 'agent-session' } - ), - ENV, - NOW, - { contentScope: 'agent-session', preserveLocalLayout: true, terminalPtyMode: 'local' } - ) - ) - - await nativeChatComposerDraftWritesSettled() - await Promise.resolve() - - expect(useAppStore.getState().unifiedTabsByWorktree[WT]?.[0]).toMatchObject({ - id: 'local-pane', - entityId: 'new-session' - }) - expect( - readNativeChatComposerDraft(structuredAgentSessionDraftScopeKey('new-session')) - ).toMatchObject({ - text: 'typed during the clear', - images: [{ id: 'i1', path: '/remote/shot.png', connectionId: 'ssh-1' }] - }) - expect(readNativeChatComposerDraft(structuredAgentSessionDraftScopeKey('old-session')).text).toBe( - '' - ) -}) - -it('restores a saved source into an already switched tab when startup reads its replacement', async () => { - stop() - clearNativeChatComposerDraftsForTests() - const storage = createMemoryNativeChatComposerDraftStorage() - storage.drafts.set(structuredAgentSessionDraftScopeKey('old-session'), { - text: 'saved before the switch', - images: [], - savedAt: 1 - }) - setNativeChatComposerDraftStorageForTests(storage) - useAppStore.setState({ - unifiedTabsByWorktree: { - [WT]: [chatTab('new-session')] - } - }) - stop = startNativeChatDraftLoad() - publishReplacement() - await waitForNativeChatComposerDrafts(1_000) - await nativeChatComposerDraftWritesSettled() - await Promise.resolve() - expect(readNativeChatComposerDraft(structuredAgentSessionDraftScopeKey('new-session')).text).toBe( - 'saved before the switch' - ) - expect(readNativeChatComposerDraft(structuredAgentSessionDraftScopeKey('old-session')).text).toBe( - '' - ) -}) - -function publishReplacement(version = 1, sourceVisible = false): void { - const snapshot = makeSnapshot( +function snapshot(sessionId: string, source: string, version = 1) { + return makeSnapshot( [ { type: 'agent-session', - id: 'agent-session:new-session', - sessionId: 'new-session', - replacesSessionId: 'old-session', + id: `agent-session:${sessionId}`, + sessionId, + replacesSessionId: source, agent: 'codex', - title: 'Codex Chat', + title: 'Chat', isActive: true - }, - ...(sourceVisible - ? [ - { - type: 'agent-session' as const, - id: 'history', - sessionId: 'old-session', - agent: 'codex' as const, - title: 'History', - isActive: false - } - ] - : []) + } ], { snapshotVersion: version } ) - if (shouldApplyWebSessionTabsSnapshot(snapshot, ENV)) { +} +function publish(frame: ReturnType): void { + if (shouldApplyWebSessionTabsSnapshot(frame, ENV)) { useAppStore.setState((state) => - applyWebSessionTabsSnapshot(state, snapshot, ENV, NOW, { + applyWebSessionTabsSnapshot(state, frame, ENV, NOW, { contentScope: 'agent-session', preserveLocalLayout: true, terminalPtyMode: 'local' @@ -219,52 +74,158 @@ function publishReplacement(version = 1, sourceVisible = false): void { ) } } - -it('does not move a later source-history draft when the host repeats the replacement', async () => { - useAppStore.setState({ unifiedTabsByWorktree: { [WT]: [chatTab('new-session')] } }) - updateNativeChatComposerDraft( - structuredAgentSessionDraftScopeKey('old-session'), - { text: 'initial draft' }, - 'immediate' - ) - publishReplacement() +async function settled(): Promise { await Promise.resolve() await nativeChatComposerDraftWritesSettled() await Promise.resolve() - expect(readNativeChatComposerDraft(structuredAgentSessionDraftScopeKey('new-session')).text).toBe( - 'initial draft' - ) +} +beforeEach(async () => { + resetWebSessionTabsSyncTestState() + resetLocalStructuredSessionVersionForTests() + setNativeChatComposerDraftStorageForTests(createMemoryNativeChatComposerDraftStorage()) + stop = startNativeChatDraftLoad() + await waitForNativeChatComposerDrafts(1000) +}) +afterEach(() => { + stop() + useAppStore.setState(initial, true) + clearNativeChatComposerDraftsForTests() + vi.useRealTimers() +}) + +it('moves text and images through the real host snapshot binding change', async () => { + useAppStore.setState({ unifiedTabsByWorktree: { [WT]: [chat('a')] } }) updateNativeChatComposerDraft( - structuredAgentSessionDraftScopeKey('old-session'), - { text: 'later history draft' }, + scope('a'), + { + text: 'typed during clear', + images: [{ id: 'i', path: '/remote/shot.png', connectionId: 'ssh-1' }] + }, 'immediate' ) - publishReplacement(2) - await Promise.resolve() - await nativeChatComposerDraftWritesSettled() - expect(readNativeChatComposerDraft(structuredAgentSessionDraftScopeKey('old-session')).text).toBe( - 'later history draft' - ) + publish(snapshot('b', 'a')) + await settled() + expect(useAppStore.getState().unifiedTabsByWorktree[WT]?.[0]).toMatchObject({ + id: 'pane', + entityId: 'b' + }) + expect(readNativeChatComposerDraft(scope('b'))).toMatchObject({ + text: 'typed during clear', + images: [{ id: 'i', path: '/remote/shot.png', connectionId: 'ssh-1' }] + }) + expect(readNativeChatComposerDraft(scope('a')).text).toBe('') }) -it('leaves an explicitly reopened source history draft in that conversation on first publication', async () => { - useAppStore.setState({ - unifiedTabsByWorktree: { - [WT]: [chatTab('new-session'), { ...chatTab('old-session'), id: 'history' }] +it('leaves a saved draft on its earlier conversation when this renderer did not observe the clear', async () => { + stop() + clearNativeChatComposerDraftsForTests() + const storage = createMemoryNativeChatComposerDraftStorage() + storage.drafts.set(scope('a'), { text: 'reachable from history', images: [], savedAt: 1 }) + setNativeChatComposerDraftStorageForTests(storage) + useAppStore.setState({ unifiedTabsByWorktree: { [WT]: [chat('b')] } }) + stop = startNativeChatDraftLoad() + publish(snapshot('b', 'a')) + await waitForNativeChatComposerDrafts(1000) + await settled() + expect(readNativeChatComposerDraft(scope('a')).text).toBe('reachable from history') + expect(readNativeChatComposerDraft(scope('b')).text).toBe('') +}) + +it('keeps a later closed-history draft through restart and repeated replacement publications', async () => { + const storage = createMemoryNativeChatComposerDraftStorage() + setNativeChatComposerDraftStorageForTests(storage) + useAppStore.setState({ unifiedTabsByWorktree: { [WT]: [chat('a')] } }) + updateNativeChatComposerDraft(scope('a'), { text: 'first draft' }, 'immediate') + publish(snapshot('b', 'a')) + await settled() + useAppStore.setState({ unifiedTabsByWorktree: { [WT]: [chat('b'), chat('a', 'history')] } }) + updateNativeChatComposerDraft(scope('a'), { text: 'later history draft' }, 'immediate') + useAppStore.setState({ unifiedTabsByWorktree: { [WT]: [chat('b')] } }) + await settled() + stop() + clearNativeChatComposerDraftsForTests() + setNativeChatComposerDraftStorageForTests(storage) + resetWebSessionTabsSyncTestState() + stop = startNativeChatDraftLoad() + publish(snapshot('b', 'a')) + await waitForNativeChatComposerDrafts(1000) + publish(snapshot('b', 'a', 2)) + await settled() + expect(readNativeChatComposerDraft(scope('a')).text).toBe('later history draft') + expect(readNativeChatComposerDraft(scope('b')).text).toBe('first draft') +}) + +it.each(['local', 'paired'] as const)( + 'does not infer a move from %s unverifiable then verified inventories', + async (host) => { + useAppStore.setState({ unifiedTabsByWorktree: { [WT]: [chat('b')] } }) + updateNativeChatComposerDraft(scope('a'), { text: 'earlier conversation' }, 'immediate') + const first = makeSnapshot([], { agentSessionsUnverifiable: true }) + const next = snapshot('b', 'a', 2) + for (const frame of [first, next]) { + if (host === 'paired') { + publish(frame) + } else { + useAppStore.setState((state) => + applyLocalStructuredSessionTabSnapshots(state, [frame], undefined, NOW, { + authoritative: true + }) + ) + } + await settled() } - }) - updateNativeChatComposerDraft( - structuredAgentSessionDraftScopeKey('old-session'), - { text: 'history draft' }, - 'immediate' - ) - publishReplacement(1, true) - await Promise.resolve() - await nativeChatComposerDraftWritesSettled() - expect(readNativeChatComposerDraft(structuredAgentSessionDraftScopeKey('old-session')).text).toBe( - 'history draft' - ) - expect(readNativeChatComposerDraft(structuredAgentSessionDraftScopeKey('new-session')).text).toBe( - '' + expect(readNativeChatComposerDraft(scope('a')).text).toBe('earlier conversation') + expect(readNativeChatComposerDraft(scope('b')).text).toBe('') + publish(snapshot('c', 'b', 3)) + await settled() + expect(readNativeChatComposerDraft(scope('a')).text).toBe('earlier conversation') + } +) + +it('replays an observed clear after a failed storage load retries successfully', async () => { + stop() + clearNativeChatComposerDraftsForTests() + vi.useFakeTimers() + const storage = createMemoryNativeChatComposerDraftStorage() + storage.drafts.set(scope('b'), { text: 'saved in b', images: [], savedAt: 1 }) + storage.loadAll = vi + .fn() + .mockRejectedValueOnce(new Error('temporary failure')) + .mockImplementationOnce(async () => new Map(storage.drafts)) + setNativeChatComposerDraftStorageForTests(storage) + useAppStore.setState({ unifiedTabsByWorktree: { [WT]: [chat('b')] } }) + stop = startNativeChatDraftLoad() + publish(snapshot('b', 'a')) + await waitForNativeChatComposerDrafts(1000) + publish(snapshot('c', 'b', 2)) + await settled() + await vi.advanceTimersByTimeAsync(1000) + await settled() + expect(readNativeChatComposerDraft(scope('c')).text).toBe('saved in b') + expect(readNativeChatComposerDraft(scope('b')).text).toBe('') +}) + +it('collapses repeated observed clears while storage is still loading', async () => { + stop() + clearNativeChatComposerDraftsForTests() + const storage = createMemoryNativeChatComposerDraftStorage() + storage.drafts.set(scope('a'), { text: 'a to c', images: [], savedAt: 1 }) + let finish = (): void => {} + storage.loadAll = vi.fn().mockImplementation( + () => + new Promise((resolve) => { + finish = () => resolve(new Map(storage.drafts)) + }) ) + setNativeChatComposerDraftStorageForTests(storage) + useAppStore.setState({ unifiedTabsByWorktree: { [WT]: [chat('a')] } }) + stop = startNativeChatDraftLoad() + publish(snapshot('b', 'a')) + publish(snapshot('c', 'b', 2)) + finish() + await waitForNativeChatComposerDrafts(1000) + await settled() + expect(readNativeChatComposerDraft(scope('c')).text).toBe('a to c') + expect(readNativeChatComposerDraft(scope('a')).text).toBe('') + expect(readNativeChatComposerDraft(scope('b')).text).toBe('') }) diff --git a/src/renderer/src/app-shell/native-chat-draft-startup.ts b/src/renderer/src/app-shell/native-chat-draft-startup.ts index f9d90c8563ae..620598f3ad4c 100644 --- a/src/renderer/src/app-shell/native-chat-draft-startup.ts +++ b/src/renderer/src/app-shell/native-chat-draft-startup.ts @@ -1,21 +1,20 @@ import { useAppStore } from '../store' -import { subscribeInitialHostSessionTabs } from '../runtime/initial-host-session-tabs-events' -import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-types' import { resolveNativeChatDraftOwner } from '../lib/native-chat-draft-owner' import { hydrateNativeChatComposerDrafts, isNativeChatComposerDraftLoadPending, setNativeChatComposerDraftOwnerResolver, subscribeToNativeChatComposerDraftLoad, + structuredAgentSessionDraftScopeKey, waitForNativeChatComposerDrafts } from '@/components/native-chat/native-chat-composer-draft-store' import { moveStructuredAgentSessionDraft, structuredAgentSessionConversationMoves } from '@/components/native-chat/structured-agent-session-draft-move' +import { moveNativeChatPendingAttachments } from '@/components/native-chat/native-chat-pending-attachment-cache' -// Why bounded: loading drafts is bookkeeping and must never hold startup; a slower load still -// fills in every draft not edited meanwhile when it lands. +// A slower load fills drafts not edited meanwhile without holding startup. const DRAFT_LOAD_WAIT_MS = 1_500 /** Before any startup step, so a step that fails can't leave the drafts unloaded. */ @@ -23,43 +22,23 @@ export function startNativeChatDraftLoad(): () => void { setNativeChatComposerDraftOwnerResolver((scopeKey) => resolveNativeChatDraftOwner(useAppStore.getState(), scopeKey) ) - const pending = new Map() - const restorePending = (): void => { + const pending = new Map() + const move = (from: string, to: string): void => { + void moveStructuredAgentSessionDraft(from, to).catch((error) => { + console.warn('[native-chat-drafts] a cleared chat draft could not move', error) + }) + } + const drain = (): void => { if (isNativeChatComposerDraftLoadPending()) { return } - for (const snapshot of pending.values()) { - const tabs = useAppStore.getState().unifiedTabsByWorktree[snapshot.worktree] ?? [] - const published = new Set( - snapshot.tabs.filter((tab) => tab.type === 'agent-session').map((tab) => tab.sessionId) - ) - for (const tab of snapshot.tabs) { - if ( - tab.type === 'agent-session' && - tab.replacesSessionId && - !published.has(tab.replacesSessionId) && - tabs.some( - (shown) => shown.contentType === 'agent-session' && shown.entityId === tab.sessionId - ) - ) { - void moveStructuredAgentSessionDraft(tab.replacesSessionId, tab.sessionId).catch( - (error) => { - console.warn('[native-chat-drafts] a restored chat draft could not move', error) - } - ) - } - } + for (const [from, to] of pending) { + move(from, to) } pending.clear() } - const stopLoad = subscribeToNativeChatComposerDraftLoad(restorePending) - const stopHost = subscribeInitialHostSessionTabs((snapshot, environmentId) => { - // Retained only until this bounded load finishes; a later run re-derives from its host. - pending.set(`${environmentId}:${snapshot.worktree}`, snapshot) - restorePending() - }) + const stopLoad = subscribeToNativeChatComposerDraftLoad(drain) void hydrateNativeChatComposerDrafts() - // In the same store update as the tab's move, so the chat's new composer mounts with the draft. const stopTabs = useAppStore.subscribe((state, previous) => { if (state.unifiedTabsByWorktree === previous.unifiedTabsByWorktree) { return @@ -68,22 +47,31 @@ export function startNativeChatDraftLoad(): () => void { previous.unifiedTabsByWorktree, state.unifiedTabsByWorktree )) { - // Why caught: a draft that fails to move must not fail the tab update that triggered it. - void moveStructuredAgentSessionDraft(from, to).catch((error) => { - console.warn('[native-chat-drafts] a cleared chat draft could not move', error) - }) + moveNativeChatPendingAttachments( + structuredAgentSessionDraftScopeKey(from), + structuredAgentSessionDraftScopeKey(to) + ) + if (!isNativeChatComposerDraftLoadPending()) { + move(from, to) + continue + } + // Only observed moves wait for the bounded load; never infer ownership from old clear markers. + for (const [source, destination] of pending) { + if (destination === from) { + pending.set(source, to) + } + } + pending.set(from, to) } }) return () => { stopLoad() - stopHost() pending.clear() stopTabs() } } -/** Startup waits for the drafts alongside the session read, so a composer shows its draft from - * its first frame. */ +/** Startup waits alongside the session read, so a composer shows its draft from its first frame. */ export function waitForNativeChatDraftsAtStartup(): Promise { return waitForNativeChatComposerDrafts(DRAFT_LOAD_WAIT_MS) } diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.tsx index 2dd6a57b55a0..62315c377ece 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposer.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposer.tsx @@ -174,12 +174,8 @@ const NativeChatComposerPane = forwardRef block.content?.map(inlineText).join('') ?? '') + .join('\n') + return text === draft.text && draft.document ? draft.document : promptTextContent(draft.text) +} + +/** Trim only the suffix removed by the text merge, preserving each remaining skill node. */ +function trimmedContent(document: JSONContent, length: number): JSONContent[] { + const blocks: JSONContent[] = [] + let remaining = length + for (const block of document.content ?? []) { + if (blocks.length > 0) { + if (remaining === 0) { + break + } + remaining -= 1 + } + const content: JSONContent[] = [] + for (const node of block.content ?? []) { + const text = inlineText(node) + if (remaining === 0) { + break + } + content.push( + node.type === 'text' && text.length > remaining + ? { ...node, text: text.slice(0, remaining) } + : node + ) + remaining = Math.max(0, remaining - text.length) + } + blocks.push({ ...block, content }) + } + return blocks +} + +/** The same text merge, with picker-created chips retained in both documents. */ +export function mergeNativeChatDraftDocument( + target: NativeChatComposerDraft, + source: NativeChatComposerDraft, + mergedText: string +): JSONContent | undefined { + if (mergedText === target.text) { + return target.document + } + if (mergedText === source.text) { + return source.document + } + if (!target.document && !source.document) { + return undefined + } + return { + type: 'doc', + content: [ + ...trimmedContent(documentFor(target), target.text.trimEnd().length), + { type: 'paragraph', content: [] }, + ...(documentFor(source).content ?? []) + ] + } +} diff --git a/src/renderer/src/components/native-chat/native-chat-pending-attachment-cache.ts b/src/renderer/src/components/native-chat/native-chat-pending-attachment-cache.ts index 3d4e477d0cee..08e3065b5ad3 100644 --- a/src/renderer/src/components/native-chat/native-chat-pending-attachment-cache.ts +++ b/src/renderer/src/components/native-chat/native-chat-pending-attachment-cache.ts @@ -19,6 +19,8 @@ import { const EMPTY: readonly NativeChatComposerImageAttachment[] = Object.freeze([]) const pendingCache = new Map() +// An upload's original scope survives only until that upload settles or is removed. +const origins = new Map() // Why: a chip can outlive its tab, which is what names the draft's owner, so it is named at once. const pendingOwners = new Map() const listeners = new Map void>>() @@ -31,6 +33,11 @@ export function nativeChatPendingAttachmentSnapshot( } function writePending(scopeKey: string, next: readonly NativeChatComposerImageAttachment[]): void { + for (const chip of nativeChatPendingAttachmentSnapshot(scopeKey)) { + if (!next.includes(chip)) { + origins.delete(chip) + } + } if (next.length === 0) { pendingCache.delete(scopeKey) pendingOwners.delete(scopeKey) @@ -76,14 +83,52 @@ export function addNativeChatPendingAttachment( if (owner) { pendingOwners.set(scopeKey, owner) } + origins.set(pending, scopeKey) writePending(scopeKey, [...nativeChatPendingAttachmentSnapshot(scopeKey), pending]) } +/** Current ownership of an operation, including callbacks held by its earlier composer. */ +function pendingScope(scopeKey: string, id: string): string { + if (nativeChatPendingAttachmentSnapshot(scopeKey).some((chip) => chip.id === id)) { + return scopeKey + } + for (const [scope, chips] of pendingCache) { + if (chips.some((chip) => chip.id === id && origins.get(chip) === scopeKey)) { + return scope + } + } + return scopeKey +} + +export function nativeChatPendingAttachmentMoved(scopeKey: string, id: string): boolean { + return pendingScope(scopeKey, id) !== scopeKey +} + +/** Pending work follows the draft; its original callbacks still address the same operation. */ +export function moveNativeChatPendingAttachments(from: string, to: string): void { + if (from === to) { + return + } + const source = nativeChatPendingAttachmentSnapshot(from) + if (source.length === 0) { + return + } + const owner = pendingOwners.get(from) + const sourceOrigins = source.map((chip) => origins.get(chip) ?? from) + writePending(from, []) + if (owner && !pendingOwners.has(to)) { + pendingOwners.set(to, owner) + } + source.forEach((chip, index) => origins.set(chip, sourceOrigins[index])) + writePending(to, [...nativeChatPendingAttachmentSnapshot(to), ...source]) +} + /** Removes a pending chip and returns it; undefined when the user already removed it. */ export function takeNativeChatPendingAttachment( scopeKey: string, id: string ): NativeChatComposerImageAttachment | undefined { + scopeKey = pendingScope(scopeKey, id) const current = nativeChatPendingAttachmentSnapshot(scopeKey) const taken = current.find((attachment) => attachment.id === id) if (taken) { @@ -103,6 +148,7 @@ export function settleNativeChatPendingAttachment( path: string, connectionId?: string | null ): boolean { + scopeKey = pendingScope(scopeKey, id) const owner = pendingOwners.get(scopeKey) if (!takeNativeChatPendingAttachment(scopeKey, id)) { return false @@ -117,13 +163,18 @@ export function settleNativeChatPendingAttachment( /** Shows a pending chip that was held out of sight, such as while a server was asked first. */ export function revealNativeChatPendingAttachment(scopeKey: string, id: string): void { + scopeKey = pendingScope(scopeKey, id) const current = nativeChatPendingAttachmentSnapshot(scopeKey) if (current.some((attachment) => attachment.id === id && attachment.hidden)) { writePending( scopeKey, current.map((attachment) => { const { hidden: _hidden, ...shown } = attachment - return attachment.id === id ? shown : attachment + if (attachment.id === id) { + origins.set(shown, origins.get(attachment) ?? scopeKey) + return shown + } + return attachment }) ) } @@ -166,4 +217,5 @@ export function dropNativeChatPendingAttachmentsOwnedBy(owner: NativeChatCompose export function clearNativeChatPendingAttachmentsForTests(): void { pendingCache.clear() pendingOwners.clear() + origins.clear() } diff --git a/src/renderer/src/components/native-chat/structured-agent-session-draft-attachment-move.test.tsx b/src/renderer/src/components/native-chat/structured-agent-session-draft-attachment-move.test.tsx new file mode 100644 index 000000000000..9ef6acc38710 --- /dev/null +++ b/src/renderer/src/components/native-chat/structured-agent-session-draft-attachment-move.test.tsx @@ -0,0 +1,108 @@ +// @vitest-environment happy-dom +import { afterEach, beforeEach, expect, it } from 'vitest' +import { cleanup, renderHook } from '@testing-library/react' +import { useNativeChatPasteLifetime } from './use-native-chat-paste-lifetime' +import { moveStructuredAgentSessionDraft } from './structured-agent-session-draft-move' +import { + clearNativeChatComposerDraftsForTests, + hydrateNativeChatComposerDrafts, + readNativeChatComposerDraft, + structuredAgentSessionDraftScopeKey as scope +} from './native-chat-composer-draft-store' +import { + createMemoryNativeChatComposerDraftStorage, + setNativeChatComposerDraftStorageForTests +} from './native-chat-composer-draft-storage' +import { + addNativeChatPendingAttachment, + clearNativeChatPendingAttachmentsForTests, + nativeChatPendingAttachmentSnapshot, + revealNativeChatPendingAttachment, + settleNativeChatPendingAttachment, + takeNativeChatPendingAttachment +} from './native-chat-pending-attachment-cache' + +beforeEach(async () => { + setNativeChatComposerDraftStorageForTests(createMemoryNativeChatComposerDraftStorage()) + await hydrateNativeChatComposerDrafts() +}) +afterEach(() => { + cleanup() + clearNativeChatPendingAttachmentsForTests() + clearNativeChatComposerDraftsForTests() +}) + +it.each(['local', 'paired'] as const)( + 'keeps a pending-only %s paste whose upload settles after replacement unmounts the composer', + async (host) => { + const view = renderHook(() => + useNativeChatPasteLifetime({ + targetKey: 'a', + attachmentScopeKey: scope('a'), + beginPendingImageAttachment: () => 'upload', + resolvePendingImageAttachment: (id, path, connection) => { + settleNativeChatPendingAttachment(scope('a'), id, path, connection) + }, + dropPendingImageAttachment: (id) => { + takeNativeChatPendingAttachment(scope('a'), id) + } + }) + ) + const lifetime = view.result.current + addNativeChatPendingAttachment(scope('a'), { id: 'upload', path: '', pending: true }) + lifetime.track( + 'upload', + '', + host === 'paired' + ? { kind: 'runtime-session', environmentId: 'paired', sessionId: 'a', pairingRevision: 1 } + : { kind: 'local' } + ) + await moveStructuredAgentSessionDraft('a', 'b') + expect(nativeChatPendingAttachmentSnapshot(scope('a'))).toEqual([]) + expect(nativeChatPendingAttachmentSnapshot(scope('b'))).toMatchObject([ + { id: 'upload', pending: true } + ]) + view.unmount() + expect( + lifetime.keepStoreUploadAfterUnmount('upload', { + status: 'saved', + tempPath: '/stored/image.png' + }) + ).toBe(true) + expect(readNativeChatComposerDraft(scope('b')).images).toEqual([ + { id: 'upload', path: '/stored/image.png' } + ]) + expect(readNativeChatComposerDraft(scope('a')).images).toEqual([]) + expect(nativeChatPendingAttachmentSnapshot(scope('b'))).toEqual([]) + } +) + +it('keeps original upload callbacks through repeated clears and reveal', async () => { + addNativeChatPendingAttachment(scope('a'), { + id: 'upload', + path: '', + pending: true, + hidden: true + }) + await moveStructuredAgentSessionDraft('a', 'b') + await moveStructuredAgentSessionDraft('b', 'c') + revealNativeChatPendingAttachment(scope('a'), 'upload') + expect(nativeChatPendingAttachmentSnapshot(scope('c'))).toEqual([ + { id: 'upload', path: '', pending: true } + ]) + expect(settleNativeChatPendingAttachment(scope('a'), 'upload', '/ssh/image.png', 'ssh-1')).toBe( + true + ) + expect(readNativeChatComposerDraft(scope('c')).images).toEqual([ + { id: 'upload', path: '/ssh/image.png', connectionId: 'ssh-1' } + ]) +}) + +it('does not attach a moved upload the user already removed', async () => { + addNativeChatPendingAttachment(scope('a'), { id: 'upload', path: '', pending: true }) + await moveStructuredAgentSessionDraft('a', 'b') + takeNativeChatPendingAttachment(scope('b'), 'upload') + expect(settleNativeChatPendingAttachment(scope('a'), 'upload', '/stored/image.png')).toBe(false) + expect(readNativeChatComposerDraft(scope('a')).images).toEqual([]) + expect(readNativeChatComposerDraft(scope('b')).images).toEqual([]) +}) diff --git a/src/renderer/src/components/native-chat/structured-agent-session-draft-move.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-draft-move.test.ts index fc8f4867c2c9..f1a6ceaa67cb 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-draft-move.test.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-draft-move.test.ts @@ -31,6 +31,68 @@ afterEach(() => { }) describe('moving a cleared conversation draft', () => { + it('preserves the skill document when the destination holds only an image', async () => { + const document = { + type: 'doc', + content: [ + { type: 'paragraph', content: [{ type: 'nativeChatSkill', attrs: { token: '$review' } }] } + ] + } + updateNativeChatComposerDraft(scope('a'), { text: '$review', document }, 'immediate') + updateNativeChatComposerDraft(scope('b'), { images: [SSH_IMAGE] }, 'immediate') + await moveStructuredAgentSessionDraft('a', 'b') + expect(readNativeChatComposerDraft(scope('b'))).toMatchObject({ + text: '$review', + document, + images: [SSH_IMAGE] + }) + }) + + it('saves chips in both merged documents without flattening either', async () => { + const storage = createMemoryNativeChatComposerDraftStorage() + setNativeChatComposerDraftStorageForTests(storage) + const first = { + type: 'paragraph', + content: [ + { type: 'nativeChatSkill', attrs: { token: '$first' } }, + { type: 'text', text: ' \n' } + ] + } + const second = { + type: 'paragraph', + content: [{ type: 'nativeChatSkill', attrs: { token: '$second' } }] + } + updateNativeChatComposerDraft( + scope('b'), + { text: '$first \n', document: { type: 'doc', content: [first] } }, + 'immediate' + ) + updateNativeChatComposerDraft( + scope('a'), + { text: '$second', document: { type: 'doc', content: [second] } }, + 'immediate' + ) + await moveStructuredAgentSessionDraft('a', 'b') + const expected = { + type: 'doc', + content: [ + { ...first, content: [first.content[0]] }, + { type: 'paragraph', content: [] }, + second + ] + } + expect(readNativeChatComposerDraft(scope('b'))).toMatchObject({ + text: '$first\n\n$second', + document: expected + }) + clearNativeChatComposerDraftsForTests() + setNativeChatComposerDraftStorageForTests(storage) + await hydrateNativeChatComposerDrafts() + expect(readNativeChatComposerDraft(scope('b'))).toMatchObject({ + text: '$first\n\n$second', + document: expected + }) + }) it('moves text, SSH images and skill chips whole into the new conversation', async () => { const document = { type: 'doc', content: [{ type: 'paragraph' }] } updateNativeChatComposerDraft( diff --git a/src/renderer/src/components/native-chat/structured-agent-session-draft-move.ts b/src/renderer/src/components/native-chat/structured-agent-session-draft-move.ts index b4ed7a7aa3f0..026d904d00b5 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-draft-move.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-draft-move.ts @@ -14,6 +14,8 @@ import { updateNativeChatComposerDraft } from './native-chat-composer-draft-store' import { sameNativeChatComposerDraftDocument } from './native-chat-composer-draft-comparison' +import { moveNativeChatPendingAttachments } from './native-chat-pending-attachment-cache' +import { mergeNativeChatDraftDocument } from './native-chat-draft-document-merge' import type { Tab } from '../../../../shared/tab-types' /** Moves the whole draft, removing its source only after the destination is saved. */ @@ -24,14 +26,15 @@ export async function moveStructuredAgentSessionDraft( if (fromSessionId === toSessionId) { return } + const from = structuredAgentSessionDraftScopeKey(fromSessionId) + const to = structuredAgentSessionDraftScopeKey(toSessionId) + moveNativeChatPendingAttachments(from, to) if (isNativeChatComposerDraftLoadPending()) { await hydrateNativeChatComposerDrafts() if (isNativeChatComposerDraftLoadPending()) { return } } - const from = structuredAgentSessionDraftScopeKey(fromSessionId) - const to = structuredAgentSessionDraftScopeKey(toSessionId) const source = readNativeChatComposerDraft(from) if (source.text === '' && source.images.length === 0) { return @@ -49,11 +52,7 @@ export async function moveStructuredAgentSessionDraft( ...target.images, ...source.images.filter((image) => !target.images.some((held) => held.id === image.id)) ], - document: emptyTarget - ? source.document - : merged.text === target.text - ? target.document - : undefined + document: mergeNativeChatDraftDocument(target, source, merged.text) }, 'immediate' ) diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.ts b/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.ts index b3fe6e13bf24..05b6e89d75fd 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.ts @@ -31,9 +31,13 @@ import { takeNativeChatPendingAttachment, useNativeChatPendingAttachments } from './native-chat-pending-attachment-cache' -import { appendNativeChatAttachmentCache, appendNativeChatDraftCache } from './native-chat-draft-cache' +import { + appendNativeChatAttachmentCache, + appendNativeChatDraftCache +} from './native-chat-draft-cache' import { useNativeChatComposerAttachmentPreviews } from './use-native-chat-composer-attachment-previews' import { formatNativeChatFileReference } from '../../../../shared/agent-image-paste' +import { createUuidV4 } from '../../../../shared/uuid-v4' export type UseNativeChatComposerAttachmentsArgs = { attachmentScopeKey: string @@ -114,7 +118,6 @@ export function useNativeChatComposerAttachments({ ], [pending, previews, restoring, settled] ) - const imageAttachmentCounter = useRef(0) const mountedRef = useRef(true) useEffect(() => { mountedRef.current = true @@ -123,10 +126,7 @@ export function useNativeChatComposerAttachments({ } }, []) - const nextAttachmentId = useCallback((): string => { - imageAttachmentCounter.current += 1 - return `${Date.now()}-${imageAttachmentCounter.current}` - }, []) + const nextAttachmentId = useCallback(() => createUuidV4(), []) // Client-local paths cannot cross into a runtime target; workspace-owned // paths may only bypass this after the internal drop ownership gate. diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-paste.ts b/src/renderer/src/components/native-chat/use-native-chat-composer-paste.ts index 1701c069b342..448a7044548b 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-composer-paste.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-paste.ts @@ -24,9 +24,9 @@ import { export type UseNativeChatComposerPasteArgs = { targetKey?: string + attachmentScopeKey?: string agent: AgentType - /** Live composer-disabled state (no pty / presence-lock); read at await-resume - * via a ref so a flip mid-paste doesn't write into a guarded composer. */ + /** Re-read after awaits so a mid-paste disable guards the composer. */ disabled: boolean caret: number /** Resolved at paste time: SSH panes must save the clipboard image on the @@ -49,6 +49,7 @@ export type UseNativeChatComposerPasteArgs = { export function useNativeChatComposerPaste({ targetKey, + attachmentScopeKey, disabled, caret, resolveAttachmentOwner, @@ -71,6 +72,7 @@ export function useNativeChatComposerPaste({ const { lifetime, track, startImageChip, keepStoreUploadAfterUnmount } = useNativeChatPasteLifetime({ targetKey, + attachmentScopeKey, beginPendingImageAttachment, resolvePendingImageAttachment, revealPendingImageAttachment, @@ -188,16 +190,15 @@ export function useNativeChatComposerPaste({ // Beside pasted text, a server too old to store the image drops it quietly, so its chip stays // out of sight until the server answers; Send waits for it from the start all the same. const awaitServer = Boolean(text) && owner.kind === 'runtime-session' - const chip = startImageChip(owner, imageFile, { + const { id: pendingId, reveal } = startImageChip(owner, imageFile, { hidden: awaitServer, canShow: () => ownerAcceptsClipboardImage(owner) && canPaste() }) - const pendingId = chip.id void (async () => { const saved = await saveClipboardImageForOwner( owner, async () => Boolean(text), - awaitServer ? chip.reveal : undefined + awaitServer ? reveal : undefined ) if (keepStoreUploadAfterUnmount(pendingId, saved)) { return diff --git a/src/renderer/src/components/native-chat/use-native-chat-paste-lifetime.ts b/src/renderer/src/components/native-chat/use-native-chat-paste-lifetime.ts index f801303dab14..5a10749977d5 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-paste-lifetime.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-paste-lifetime.ts @@ -1,5 +1,6 @@ import { useCallback, useLayoutEffect, useMemo, useRef } from 'react' import type { NativeChatAttachmentOwner } from './native-chat-attachment-upload' +import { nativeChatPendingAttachmentMoved } from './native-chat-pending-attachment-cache' /** * The pending chips of pastes this composer started, released when it unmounts or changes target. @@ -9,6 +10,7 @@ import type { NativeChatAttachmentOwner } from './native-chat-attachment-upload' */ export function useNativeChatPasteLifetime(args: { targetKey?: string + attachmentScopeKey?: string beginPendingImageAttachment: ( previewUrl?: string, pendingName?: string, @@ -33,7 +35,12 @@ export function useNativeChatPasteLifetime(args: { saved: { status: string; tempPath?: string } ) => boolean } { - const { targetKey, beginPendingImageAttachment, revealPendingImageAttachment } = args + const { + targetKey, + attachmentScopeKey, + beginPendingImageAttachment, + revealPendingImageAttachment + } = args const { resolvePendingImageAttachment, dropPendingImageAttachment } = args const dropPendingRef = useRef(dropPendingImageAttachment) useLayoutEffect(() => { @@ -44,7 +51,7 @@ export function useNativeChatPasteLifetime(args: { targetKey, active: false, pending: new Map(), - storeUploads: new Set() + uploads: new Map() }), [targetKey] ) @@ -56,19 +63,20 @@ export function useNativeChatPasteLifetime(args: { if (preview.startsWith('blob:')) { URL.revokeObjectURL(preview) } - if (!lifetime.storeUploads.has(id)) { + if ( + lifetime.uploads.get(id)?.kind !== 'runtime-session' && + !(attachmentScopeKey && nativeChatPendingAttachmentMoved(attachmentScopeKey, id)) + ) { dropPendingRef.current(id) } } lifetime.pending.clear() } - }, [lifetime]) + }, [attachmentScopeKey, lifetime]) const track = useCallback( (pendingId: string, preview: string, owner: NativeChatAttachmentOwner) => { lifetime.pending.set(pendingId, preview) - if (owner.kind === 'runtime-session') { - lifetime.storeUploads.add(pendingId) - } + lifetime.uploads.set(pendingId, owner) }, [lifetime] ) @@ -112,17 +120,33 @@ export function useNativeChatPasteLifetime(args: { ) const keepStoreUploadAfterUnmount = useCallback( (pendingId: string | null, saved: { status: string; tempPath?: string }): boolean => { - if (!pendingId || lifetime.active || !lifetime.storeUploads.has(pendingId)) { + if (!pendingId || lifetime.active) { + if (pendingId) { + lifetime.uploads.delete(pendingId) + } + return false + } + const owner = lifetime.uploads.get(pendingId) + if ( + owner?.kind !== 'runtime-session' && + !(attachmentScopeKey && nativeChatPendingAttachmentMoved(attachmentScopeKey, pendingId)) + ) { + lifetime.uploads.delete(pendingId) return false } + lifetime.uploads.delete(pendingId) if (saved.status === 'saved' && saved.tempPath) { - resolvePendingImageAttachment(pendingId, saved.tempPath, null) + resolvePendingImageAttachment( + pendingId, + saved.tempPath, + owner?.kind === 'ssh' ? owner.connectionId : null + ) } else { dropPendingImageAttachment(pendingId) } return true }, - [dropPendingImageAttachment, lifetime, resolvePendingImageAttachment] + [attachmentScopeKey, dropPendingImageAttachment, lifetime, resolvePendingImageAttachment] ) return { lifetime, track, startImageChip, keepStoreUploadAfterUnmount } } diff --git a/src/renderer/src/runtime/initial-host-session-tabs-events.ts b/src/renderer/src/runtime/initial-host-session-tabs-events.ts deleted file mode 100644 index 78f3d55856ed..000000000000 --- a/src/renderer/src/runtime/initial-host-session-tabs-events.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-types' - -type Listener = (snapshot: RuntimeMobileSessionTabsResult, environmentId: string) => void -const listeners = new Set() - -export function subscribeInitialHostSessionTabs(listener: Listener): () => void { - listeners.add(listener) - return () => listeners.delete(listener) -} - -/** The existing publication cursor decides first receipt; no replacement identity is stored. */ -export function queueInitialHostSessionTabs( - snapshot: RuntimeMobileSessionTabsResult, - environmentId: string -): void { - const eligible = [...listeners] - // Publication decisions can run inside a store updater; the binding must land first. - queueMicrotask(() => { - for (const listener of eligible) { - if (listeners.has(listener)) { - listener(snapshot, environmentId) - } - } - }) -} diff --git a/src/renderer/src/runtime/local-structured-session-tabs-sync/snapshot-apply.ts b/src/renderer/src/runtime/local-structured-session-tabs-sync/snapshot-apply.ts index eac795359701..ba882865dfb2 100644 --- a/src/renderer/src/runtime/local-structured-session-tabs-sync/snapshot-apply.ts +++ b/src/renderer/src/runtime/local-structured-session-tabs-sync/snapshot-apply.ts @@ -36,7 +36,6 @@ import { import { suppressCancelledStructuredSessionTabs } from '../structured-agent-session-tab-retirement' import { LOCAL_STRUCTURED_SESSION_OWNER } from '../local-structured-session-owner' import { closeStructuredAgentSession } from '../structured-agent-session-close' -import { queueInitialHostSessionTabs } from '../initial-host-session-tabs-events' /** The host saying it no longer publishes this worktree at all, rather than publishing an empty one. */ function isWorktreeRetraction( @@ -159,9 +158,6 @@ export function applyLocalStructuredSessionTabSnapshots< } ) next = patch === next ? next : ({ ...next, ...patch } as State) - if (!prior && !isWorktreeRetraction(effectiveSnapshot)) { - queueInitialHostSessionTabs(effectiveSnapshot, owner) - } if (isWorktreeRetraction(effectiveSnapshot)) { // A retraction was applied above — the mirrored rows must go — but it is not a publication // to fence later frames against. Recording it would retire the renderer's own epoch, which diff --git a/src/renderer/src/runtime/web-session-tabs-sync/tracking-decisions.ts b/src/renderer/src/runtime/web-session-tabs-sync/tracking-decisions.ts index e672f7c22e16..d794fc83bcc2 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/tracking-decisions.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/tracking-decisions.ts @@ -22,7 +22,6 @@ import { clearWebSessionTabsTrackingForWorktree } from './tracking-lifecycle' import { queueAcceptedWebSessionTerminalSnapshot } from '../web-session-terminal-handle-events' import { shouldAutoCreateInitialTerminal } from '@/components/terminal/initial-terminal' import { hostSnapshotAffirmsWorktreeContents } from '../host-session-snapshot-authority' -import { queueInitialHostSessionTabs } from '../initial-host-session-tabs-events' /** A frame's fate, paired with whether that fate is host evidence for the worktree. */ export type WebSessionTabsSnapshotDecision = { @@ -130,12 +129,6 @@ export function decideWebSessionTabsSnapshot( recordAcceptedWebSessionTabsEnvironment(environmentId, snapshot) // Why: a mounted mirror that exhausted bounded polling needs fresh host evidence without subscribing to every store write. queueAcceptedWebSessionTerminalSnapshot(snapshot, environmentId) - if ( - (!current || !hostSnapshotAffirmsWorktreeContents(current)) && - hostSnapshotAffirmsWorktreeContents(snapshot) - ) { - queueInitialHostSessionTabs(snapshot, environmentId) - } return WEB_SESSION_TABS_FRAME_APPLIED } From 2746b896eaa228d97fbf059a61b672263aa9684a Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 7 Oct 2026 19:03:12 -0700 Subject: [PATCH 5/9] fix(native-chat): freeze transferred drafts at tab changes --- .../native-chat-draft-startup.test.ts | 60 +++++++++++++ .../app-shell/native-chat-draft-startup.ts | 50 ++++++++--- .../native-chat-draft-document-merge.ts | 81 ++++++++++++++++- ...ative-chat-draft-transfer-snapshot.test.ts | 49 +++++++++++ .../native-chat-draft-transfer-snapshot.ts | 41 +++++++++ ...tructured-agent-session-draft-move.test.ts | 86 +++++++++++++++++++ .../structured-agent-session-draft-move.ts | 29 +++++-- 7 files changed, 378 insertions(+), 18 deletions(-) create mode 100644 src/renderer/src/components/native-chat/native-chat-draft-transfer-snapshot.test.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-draft-transfer-snapshot.ts diff --git a/src/renderer/src/app-shell/native-chat-draft-startup.test.ts b/src/renderer/src/app-shell/native-chat-draft-startup.test.ts index 04d9a816d093..ed557a141da4 100644 --- a/src/renderer/src/app-shell/native-chat-draft-startup.test.ts +++ b/src/renderer/src/app-shell/native-chat-draft-startup.test.ts @@ -29,6 +29,11 @@ import { createMemoryNativeChatComposerDraftStorage, setNativeChatComposerDraftStorageForTests } from '@/components/native-chat/native-chat-composer-draft-storage' +import { + addNativeChatPendingAttachment, + clearNativeChatPendingAttachmentsForTests, + settleNativeChatPendingAttachment +} from '@/components/native-chat/native-chat-pending-attachment-cache' const initial = useAppStore.getState() let stop = (): void => {} @@ -90,6 +95,7 @@ afterEach(() => { stop() useAppStore.setState(initial, true) clearNativeChatComposerDraftsForTests() + clearNativeChatPendingAttachmentsForTests() vi.useRealTimers() }) @@ -229,3 +235,57 @@ it('collapses repeated observed clears while storage is still loading', async () expect(readNativeChatComposerDraft(scope('a')).text).toBe('') expect(readNativeChatComposerDraft(scope('b')).text).toBe('') }) + +async function deferredReplacement(): Promise<() => Promise> { + stop() + clearNativeChatComposerDraftsForTests() + const storage = createMemoryNativeChatComposerDraftStorage() + storage.drafts.set(scope('a'), { text: 'before clear', images: [], savedAt: 1 }) + let finish = (): void => {} + storage.loadAll = () => + new Promise((resolve) => { + finish = () => resolve(new Map(storage.drafts)) + }) + setNativeChatComposerDraftStorageForTests(storage) + useAppStore.setState({ unifiedTabsByWorktree: { [WT]: [chat('a')] } }) + stop = startNativeChatDraftLoad() + publish(snapshot('b', 'a')) + useAppStore.setState({ unifiedTabsByWorktree: { [WT]: [chat('b'), chat('a', 'history')] } }) + return async () => { + finish() + await waitForNativeChatComposerDrafts(1000) + await settled() + } +} + +it('carries the captured draft while later history text stays in its earlier conversation', async () => { + const finish = await deferredReplacement() + updateNativeChatComposerDraft(scope('a'), { text: 'new history input' }, 'immediate') + useAppStore.setState({ unifiedTabsByWorktree: { [WT]: [chat('b')] } }) + await finish() + expect(readNativeChatComposerDraft(scope('a')).text).toBe('new history input') + expect(readNativeChatComposerDraft(scope('b')).text).toBe('before clear') +}) + +it('leaves a new history upload where it began when the captured move drains', async () => { + const finish = await deferredReplacement() + addNativeChatPendingAttachment(scope('a'), { id: 'history-image', path: '', pending: true }) + await finish() + settleNativeChatPendingAttachment(scope('a'), 'history-image', '/ssh/history.png', 'ssh-1') + expect(readNativeChatComposerDraft(scope('a')).images).toEqual([ + { id: 'history-image', path: '/ssh/history.png', connectionId: 'ssh-1' } + ]) + expect(readNativeChatComposerDraft(scope('b')).images).toEqual([]) +}) + +it('keeps history uploads that settle before the saved-draft load completes', async () => { + const finish = await deferredReplacement() + addNativeChatPendingAttachment(scope('a'), { id: 'history-image', path: '', pending: true }) + settleNativeChatPendingAttachment(scope('a'), 'history-image', '/ssh/history.png', 'ssh-1') + await finish() + expect(readNativeChatComposerDraft(scope('a')).images).toEqual([ + { id: 'history-image', path: '/ssh/history.png', connectionId: 'ssh-1' } + ]) + expect(readNativeChatComposerDraft(scope('b')).images).toEqual([]) + expect(readNativeChatComposerDraft(scope('b')).text).toBe('before clear') +}) diff --git a/src/renderer/src/app-shell/native-chat-draft-startup.ts b/src/renderer/src/app-shell/native-chat-draft-startup.ts index 620598f3ad4c..839f254d7f58 100644 --- a/src/renderer/src/app-shell/native-chat-draft-startup.ts +++ b/src/renderer/src/app-shell/native-chat-draft-startup.ts @@ -9,10 +9,14 @@ import { waitForNativeChatComposerDrafts } from '@/components/native-chat/native-chat-composer-draft-store' import { - moveStructuredAgentSessionDraft, + moveCapturedStructuredAgentSessionDraft, structuredAgentSessionConversationMoves } from '@/components/native-chat/structured-agent-session-draft-move' import { moveNativeChatPendingAttachments } from '@/components/native-chat/native-chat-pending-attachment-cache' +import { + captureNativeChatDraftTransfer, + type NativeChatDraftTransferSnapshot +} from '@/components/native-chat/native-chat-draft-transfer-snapshot' // A slower load fills drafts not edited meanwhile without holding startup. const DRAFT_LOAD_WAIT_MS = 1_500 @@ -22,18 +26,33 @@ export function startNativeChatDraftLoad(): () => void { setNativeChatComposerDraftOwnerResolver((scopeKey) => resolveNativeChatDraftOwner(useAppStore.getState(), scopeKey) ) - const pending = new Map() - const move = (from: string, to: string): void => { - void moveStructuredAgentSessionDraft(from, to).catch((error) => { - console.warn('[native-chat-drafts] a cleared chat draft could not move', error) - }) + let active = true + const pending = new Map< + string, + { to: string; snapshot: Promise } + >() + const move = ( + from: string, + to: string, + snapshot: Promise + ): void => { + void snapshot + .then((captured) => { + if (active && captured) { + return moveCapturedStructuredAgentSessionDraft(from, to, captured) + } + return undefined + }) + .catch((error) => { + console.warn('[native-chat-drafts] a cleared chat draft could not move', error) + }) } const drain = (): void => { if (isNativeChatComposerDraftLoadPending()) { return } - for (const [from, to] of pending) { - move(from, to) + for (const [from, { to, snapshot }] of pending) { + move(from, to, snapshot) } pending.clear() } @@ -47,24 +66,31 @@ export function startNativeChatDraftLoad(): () => void { previous.unifiedTabsByWorktree, state.unifiedTabsByWorktree )) { + const snapshot = captureNativeChatDraftTransfer( + structuredAgentSessionDraftScopeKey(from) + ).catch((error) => { + console.warn('[native-chat-drafts] a cleared chat draft could not be captured', error) + return null + }) moveNativeChatPendingAttachments( structuredAgentSessionDraftScopeKey(from), structuredAgentSessionDraftScopeKey(to) ) if (!isNativeChatComposerDraftLoadPending()) { - move(from, to) + move(from, to, snapshot) continue } // Only observed moves wait for the bounded load; never infer ownership from old clear markers. for (const [source, destination] of pending) { - if (destination === from) { - pending.set(source, to) + if (destination.to === from) { + pending.set(source, { ...destination, to }) } } - pending.set(from, to) + pending.set(from, { to, snapshot }) } }) return () => { + active = false stopLoad() pending.clear() stopTabs() diff --git a/src/renderer/src/components/native-chat/native-chat-draft-document-merge.ts b/src/renderer/src/components/native-chat/native-chat-draft-document-merge.ts index a7a9486a9bbc..4f1b7855958f 100644 --- a/src/renderer/src/components/native-chat/native-chat-draft-document-merge.ts +++ b/src/renderer/src/components/native-chat/native-chat-draft-document-merge.ts @@ -17,6 +17,85 @@ function documentFor(draft: NativeChatComposerDraft): JSONContent { return text === draft.text && draft.document ? draft.document : promptTextContent(draft.text) } +type SkillRange = { start: number; end: number; node: JSONContent } + +function skillRanges(document: JSONContent, start: number): SkillRange[] { + const ranges: SkillRange[] = [] + let offset = start + for (const [index, block] of (document.content ?? []).entries()) { + if (index > 0) { + offset += 1 + } + for (const node of block.content ?? []) { + const end = offset + inlineText(node).length + if (node.type === 'nativeChatSkill') { + ranges.push({ start: offset, end, node }) + } + offset = end + } + } + return ranges +} + +function carrySkills( + node: JSONContent, + start: number, + ranges: readonly SkillRange[] +): JSONContent[] { + if (node.type !== 'text') { + return [node] + } + const text = node.text ?? '' + const end = start + text.length + const content: JSONContent[] = [] + let keptFrom = start + for (const skill of ranges) { + if (skill.end <= start || skill.start >= end) { + continue + } + if (skill.start > keptFrom) { + content.push({ ...node, text: text.slice(keptFrom - start, skill.start - start) }) + } + if (skill.start >= start) { + content.push(skill.node) + } + keptFrom = Math.min(end, Math.max(keptFrom, skill.end)) + } + if (keptFrom < end) { + content.push({ ...node, text: text.slice(keptFrom - start) }) + } + return content +} + +/** Matching literal text does not already hold a picker node; copy the explicit node. */ +function deduplicatedDocument( + target: NativeChatComposerDraft, + source: NativeChatComposerDraft +): JSONContent | undefined { + const start = target.text.trimEnd().length - source.text.trimEnd().length + const ranges = skillRanges(documentFor(source), start) + if (ranges.length === 0) { + return target.document ?? (target.text === source.text ? source.document : undefined) + } + let offset = 0 + return { + type: 'doc', + content: (documentFor(target).content ?? []).map((block, index) => { + if (index > 0) { + offset += 1 + } + return { + ...block, + content: (block.content ?? []).flatMap((node) => { + const content = carrySkills(node, offset, ranges) + offset += inlineText(node).length + return content + }) + } + }) + } +} + /** Trim only the suffix removed by the text merge, preserving each remaining skill node. */ function trimmedContent(document: JSONContent, length: number): JSONContent[] { const blocks: JSONContent[] = [] @@ -53,7 +132,7 @@ export function mergeNativeChatDraftDocument( mergedText: string ): JSONContent | undefined { if (mergedText === target.text) { - return target.document + return deduplicatedDocument(target, source) } if (mergedText === source.text) { return source.document diff --git a/src/renderer/src/components/native-chat/native-chat-draft-transfer-snapshot.test.ts b/src/renderer/src/components/native-chat/native-chat-draft-transfer-snapshot.test.ts new file mode 100644 index 000000000000..f6eda93f19e3 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-draft-transfer-snapshot.test.ts @@ -0,0 +1,49 @@ +// @vitest-environment happy-dom +import { afterEach, expect, it } from 'vitest' +import { captureNativeChatDraftTransfer } from './native-chat-draft-transfer-snapshot' +import { + clearNativeChatComposerDraftsForTests, + hydrateNativeChatComposerDrafts, + updateNativeChatComposerDraft +} from './native-chat-composer-draft-store' +import { + createMemoryNativeChatComposerDraftStorage, + setNativeChatComposerDraftStorageForTests +} from './native-chat-composer-draft-storage' + +afterEach(clearNativeChatComposerDraftsForTests) + +it('captures a saved record before a later history write even while startup has not loaded', async () => { + const storage = createMemoryNativeChatComposerDraftStorage() + storage.drafts.set('source', { text: 'before switch', images: [], savedAt: 1 }) + setNativeChatComposerDraftStorageForTests(storage) + const captured = captureNativeChatDraftTransfer('source') + await storage.write('source', { text: 'later history', images: [], savedAt: 2 }) + expect(await captured).toEqual({ + draft: { text: 'before switch', images: [], savedAt: 1 }, + unverified: true + }) +}) + +it('freezes memory input without depending on a disk read', async () => { + const storage = createMemoryNativeChatComposerDraftStorage() + setNativeChatComposerDraftStorageForTests(storage) + await hydrateNativeChatComposerDrafts() + storage.read = async () => { + throw new Error('unavailable') + } + updateNativeChatComposerDraft('source', { text: 'before switch' }, 'immediate') + const captured = captureNativeChatDraftTransfer('source') + updateNativeChatComposerDraft('source', { text: 'later history' }, 'immediate') + expect(await captured).toMatchObject({ draft: { text: 'before switch' }, unverified: false }) +}) + +it('reports a failed saved-record capture rather than reading a later scope value', async () => { + const storage = createMemoryNativeChatComposerDraftStorage() + storage.read = async () => { + throw new Error('unavailable') + } + setNativeChatComposerDraftStorageForTests(storage) + await expect(captureNativeChatDraftTransfer('source')).rejects.toThrow('unavailable') + expect(storage.drafts.size).toBe(0) +}) diff --git a/src/renderer/src/components/native-chat/native-chat-draft-transfer-snapshot.ts b/src/renderer/src/components/native-chat/native-chat-draft-transfer-snapshot.ts new file mode 100644 index 000000000000..396742bf6934 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-draft-transfer-snapshot.ts @@ -0,0 +1,41 @@ +import { load, records, unverifiedScopes } from './native-chat-composer-draft-memory' +import { + nativeChatComposerDraftStorage, + parseStoredNativeChatComposerDraft, + type StoredNativeChatComposerDraft +} from './native-chat-composer-draft-storage' + +export type NativeChatDraftTransferSnapshot = { + readonly draft: StoredNativeChatComposerDraft + readonly unverified: boolean +} + +/** Capture before later history writes, rather than rereading its scope when loading finishes. */ +export async function captureNativeChatDraftTransfer( + scopeKey: string +): Promise { + const current = records.get(scopeKey) ?? { text: '', images: [], savedAt: 0 } + if (load.hydrated || load.editedBeforeLoad.has(scopeKey)) { + return { draft: current, unverified: unverifiedScopes.has(scopeKey) } + } + const appends = [...(load.appendsBeforeLoad.get(scopeKey) ?? [])] + const stored = parseStoredNativeChatComposerDraft( + await nativeChatComposerDraftStorage().read(scopeKey) + ) + if (!stored) { + return { draft: current, unverified: unverifiedScopes.has(scopeKey) } + } + const draft = appends.reduce( + (held, entry) => (entry.committed ? held : entry.append(held)), + stored + ) + return { draft, unverified: true } +} + +/** A later history draft can have identical content and still be a different write. */ +export function nativeChatDraftTransferSourceUnchanged( + scopeKey: string, + snapshot: NativeChatDraftTransferSnapshot +): boolean { + return records.get(scopeKey)?.savedAt === snapshot.draft.savedAt +} diff --git a/src/renderer/src/components/native-chat/structured-agent-session-draft-move.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-draft-move.test.ts index f1a6ceaa67cb..26dee7c377b9 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-draft-move.test.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-draft-move.test.ts @@ -31,6 +31,60 @@ afterEach(() => { }) describe('moving a cleared conversation draft', () => { + it.each(['$review', 'question\n\n$review'])( + 'keeps a picked skill when the destination already holds its literal text: %s', + async (text) => { + const document = { + type: 'doc', + content: [ + { type: 'paragraph', content: [{ type: 'nativeChatSkill', attrs: { token: '$review' } }] } + ] + } + updateNativeChatComposerDraft(scope('a'), { text: '$review', document }, 'immediate') + updateNativeChatComposerDraft(scope('b'), { text }, 'immediate') + await moveStructuredAgentSessionDraft('a', 'b') + expect(readNativeChatComposerDraft(scope('b')).text).toBe(text) + expect(JSON.stringify(readNativeChatComposerDraft(scope('b')).document) ?? '').toContain( + 'nativeChatSkill' + ) + if (text === '$review') { + expect(readNativeChatComposerDraft(scope('b')).document).toEqual(document) + } + expect(readNativeChatComposerDraft(scope('a')).text).toBe('') + } + ) + + it('preserves an existing chip and copies the source chip into a matching literal suffix', async () => { + const first = { type: 'nativeChatSkill', attrs: { token: '$first' } } + const review = { type: 'nativeChatSkill', attrs: { token: '$review' } } + updateNativeChatComposerDraft( + scope('a'), + { + text: '$review', + document: { type: 'doc', content: [{ type: 'paragraph', content: [review] }] } + }, + 'immediate' + ) + const document = { + type: 'doc', + content: [ + { type: 'paragraph', content: [first] }, + { type: 'paragraph', content: [] }, + { type: 'paragraph', content: [{ type: 'text', text: '$review' }] } + ] + } + updateNativeChatComposerDraft(scope('b'), { text: '$first\n\n$review', document }, 'immediate') + await moveStructuredAgentSessionDraft('a', 'b') + expect(readNativeChatComposerDraft(scope('b')).document).toEqual({ + type: 'doc', + content: [ + { type: 'paragraph', content: [first] }, + { type: 'paragraph', content: [] }, + { type: 'paragraph', content: [review] } + ] + }) + }) + it('preserves the skill document when the destination holds only an image', async () => { const document = { type: 'doc', @@ -190,6 +244,16 @@ describe('moving a cleared conversation draft', () => { expect(readNativeChatComposerDraft(scope('b')).text).toBe('moved') }) + it('keeps a later history write even when it recreates the captured text', async () => { + updateNativeChatComposerDraft(scope('a'), { text: 'same question' }, 'immediate') + const moving = moveStructuredAgentSessionDraft('a', 'b') + updateNativeChatComposerDraft(scope('a'), { text: '' }, 'immediate') + updateNativeChatComposerDraft(scope('a'), { text: 'same question' }, 'immediate') + await moving + expect(readNativeChatComposerDraft(scope('a')).text).toBe('same question') + expect(readNativeChatComposerDraft(scope('b')).text).toBe('same question') + }) + it('waits for the saved drafts to load before moving one only storage holds', async () => { clearNativeChatComposerDraftsForTests() const storage = createMemoryNativeChatComposerDraftStorage() @@ -205,6 +269,28 @@ describe('moving a cleared conversation draft', () => { expect(readNativeChatComposerDraft(scope('a')).text).toBe('') expect(isNativeChatComposerDraftUnverified(scope('b'))).toBe(true) }) + + it('waits for a slow destination load so its saved input is merged', async () => { + clearNativeChatComposerDraftsForTests() + const storage = createMemoryNativeChatComposerDraftStorage() + storage.drafts.set(scope('a'), { text: 'source', images: [], savedAt: 1 }) + storage.drafts.set(scope('b'), { text: 'destination', images: [], savedAt: 2 }) + let finish = (): void => {} + storage.loadAll = () => + new Promise((resolve) => { + finish = () => resolve(new Map(storage.drafts)) + }) + setNativeChatComposerDraftStorageForTests(storage) + const loading = hydrateNativeChatComposerDrafts() + const moving = moveStructuredAgentSessionDraft('a', 'b') + await Promise.resolve() + await Promise.resolve() + expect(readNativeChatComposerDraft(scope('b')).text).toBe('') + finish() + await loading + await moving + expect(readNativeChatComposerDraft(scope('b')).text).toBe('destination\n\nsource') + }) }) function chat(id: string, entityId: string, contentType: Tab['contentType'] = 'agent-session') { diff --git a/src/renderer/src/components/native-chat/structured-agent-session-draft-move.ts b/src/renderer/src/components/native-chat/structured-agent-session-draft-move.ts index 026d904d00b5..0f063cb0f372 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-draft-move.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-draft-move.ts @@ -1,5 +1,4 @@ -// A structured chat's draft belongs to its conversation. A /clear moves the chat to a new -// conversation, so the unsent draft moves with it rather than staying in one nothing shows. +// Unsent input follows its chat tab when /clear changes that tab's conversation. import { withNativeChatComposerDraftAddition } from './native-chat-composer-draft-addition' import { @@ -7,7 +6,6 @@ import { nativeChatComposerDraftWriteSettled, hydrateNativeChatComposerDrafts, isNativeChatComposerDraftLoadPending, - isNativeChatComposerDraftUnverified, markNativeChatComposerDraftUnverified, readNativeChatComposerDraft, structuredAgentSessionDraftScopeKey, @@ -16,6 +14,11 @@ import { import { sameNativeChatComposerDraftDocument } from './native-chat-composer-draft-comparison' import { moveNativeChatPendingAttachments } from './native-chat-pending-attachment-cache' import { mergeNativeChatDraftDocument } from './native-chat-draft-document-merge' +import { + captureNativeChatDraftTransfer, + nativeChatDraftTransferSourceUnchanged, + type NativeChatDraftTransferSnapshot +} from './native-chat-draft-transfer-snapshot' import type { Tab } from '../../../../shared/tab-types' /** Moves the whole draft, removing its source only after the destination is saved. */ @@ -29,13 +32,28 @@ export async function moveStructuredAgentSessionDraft( const from = structuredAgentSessionDraftScopeKey(fromSessionId) const to = structuredAgentSessionDraftScopeKey(toSessionId) moveNativeChatPendingAttachments(from, to) + const captured = await captureNativeChatDraftTransfer(from) if (isNativeChatComposerDraftLoadPending()) { await hydrateNativeChatComposerDrafts() if (isNativeChatComposerDraftLoadPending()) { return } } - const source = readNativeChatComposerDraft(from) + await moveCapturedStructuredAgentSessionDraft(fromSessionId, toSessionId, captured) +} + +/** Pending operations already moved at the switch; later history operations stay where begun. */ +export async function moveCapturedStructuredAgentSessionDraft( + fromSessionId: string, + toSessionId: string, + snapshot: NativeChatDraftTransferSnapshot +): Promise { + if (fromSessionId === toSessionId) { + return + } + const from = structuredAgentSessionDraftScopeKey(fromSessionId) + const to = structuredAgentSessionDraftScopeKey(toSessionId) + const source = snapshot.draft if (source.text === '' && source.images.length === 0) { return } @@ -56,11 +74,12 @@ export async function moveStructuredAgentSessionDraft( }, 'immediate' ) - if (source.images.length > 0 && isNativeChatComposerDraftUnverified(from)) { + if (source.images.length > 0 && snapshot.unverified) { markNativeChatComposerDraftUnverified(to) } if ( (await nativeChatComposerDraftWriteSettled(to)) && + nativeChatDraftTransferSourceUnchanged(from, snapshot) && sameNativeChatComposerDraftDocument(readNativeChatComposerDraft(from).document, source.document) ) { clearNativeChatComposerDraftIfUnchanged(from, source) From b1237f0adde4f83b7015412983e187be3c77ee93 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 7 Oct 2026 19:27:28 -0700 Subject: [PATCH 6/9] fix(native-chat): move loaded drafts synchronously --- .../native-chat-draft-startup.test.ts | 167 ++++++++++-------- .../app-shell/native-chat-draft-startup.ts | 71 +------- .../native-chat-composer-draft-load.ts | 11 -- .../native-chat-composer-draft-store.ts | 87 ++++++--- ...ative-chat-draft-transfer-snapshot.test.ts | 49 ----- .../native-chat-draft-transfer-snapshot.ts | 41 ----- ...tructured-agent-session-draft-move.test.ts | 86 ++++----- .../structured-agent-session-draft-move.ts | 76 ++------ 8 files changed, 208 insertions(+), 380 deletions(-) delete mode 100644 src/renderer/src/components/native-chat/native-chat-draft-transfer-snapshot.test.ts delete mode 100644 src/renderer/src/components/native-chat/native-chat-draft-transfer-snapshot.ts diff --git a/src/renderer/src/app-shell/native-chat-draft-startup.test.ts b/src/renderer/src/app-shell/native-chat-draft-startup.test.ts index ed557a141da4..c62f7909c141 100644 --- a/src/renderer/src/app-shell/native-chat-draft-startup.test.ts +++ b/src/renderer/src/app-shell/native-chat-draft-startup.test.ts @@ -96,6 +96,7 @@ afterEach(() => { useAppStore.setState(initial, true) clearNativeChatComposerDraftsForTests() clearNativeChatPendingAttachmentsForTests() + localStorage.removeItem('orca:nativeChatComposerDraftJournal:v1') vi.useRealTimers() }) @@ -188,104 +189,114 @@ it.each(['local', 'paired'] as const)( } ) -it('replays an observed clear after a failed storage load retries successfully', async () => { +it('carries back-to-back hydrated replacements without leaving an intermediate draft', () => { + useAppStore.setState({ unifiedTabsByWorktree: { [WT]: [chat('a')] } }) + updateNativeChatComposerDraft(scope('a'), { text: 'question' }, 'immediate') + addNativeChatPendingAttachment(scope('a'), { id: 'upload', path: '', pending: true }) + publish(snapshot('b', 'a')) + publish(snapshot('c', 'b', 2)) + expect(readNativeChatComposerDraft(scope('a')).text).toBe('') + expect(readNativeChatComposerDraft(scope('b')).text).toBe('') + expect(readNativeChatComposerDraft(scope('c')).text).toBe('question') + settleNativeChatPendingAttachment(scope('a'), 'upload', '/ssh/image.png', 'ssh-1') + expect(readNativeChatComposerDraft(scope('c')).images).toEqual([ + { id: 'upload', path: '/ssh/image.png', connectionId: 'ssh-1' } + ]) +}) + +it.each(['before', 'after'] as const)( + 'leaves input and an upload settled %s a pre-load replacement in history', + async (settleAt) => { + stop() + clearNativeChatComposerDraftsForTests() + const storage = createMemoryNativeChatComposerDraftStorage() + storage.drafts.set(scope('a'), { text: 'saved history input', images: [], savedAt: 1 }) + let finish = (): void => {} + storage.loadAll = () => + new Promise((resolve) => { + const drafts = new Map(storage.drafts) + finish = () => resolve(drafts) + }) + setNativeChatComposerDraftStorageForTests(storage) + useAppStore.setState({ unifiedTabsByWorktree: { [WT]: [chat('a')] } }) + stop = startNativeChatDraftLoad() + addNativeChatPendingAttachment(scope('a'), { id: 'upload', path: '', pending: true }) + if (settleAt === 'before') { + settleNativeChatPendingAttachment(scope('a'), 'upload', '/ssh/image.png', 'ssh-1') + await settled() + } + publish(snapshot('b', 'a')) + if (settleAt === 'after') { + settleNativeChatPendingAttachment(scope('a'), 'upload', '/ssh/image.png', 'ssh-1') + } + finish() + await waitForNativeChatComposerDrafts(1000) + await settled() + expect(readNativeChatComposerDraft(scope('a'))).toMatchObject({ + text: 'saved history input', + images: [{ id: 'upload', path: '/ssh/image.png', connectionId: 'ssh-1' }] + }) + expect(readNativeChatComposerDraft(scope('b'))).toMatchObject({ text: '', images: [] }) + } +) + +it('does not replay a skipped clear after the existing load retry succeeds', async () => { stop() clearNativeChatComposerDraftsForTests() vi.useFakeTimers() const storage = createMemoryNativeChatComposerDraftStorage() - storage.drafts.set(scope('b'), { text: 'saved in b', images: [], savedAt: 1 }) + storage.drafts.set(scope('a'), { text: 'history draft', images: [], savedAt: 1 }) storage.loadAll = vi .fn() .mockRejectedValueOnce(new Error('temporary failure')) .mockImplementationOnce(async () => new Map(storage.drafts)) setNativeChatComposerDraftStorageForTests(storage) - useAppStore.setState({ unifiedTabsByWorktree: { [WT]: [chat('b')] } }) + useAppStore.setState({ unifiedTabsByWorktree: { [WT]: [chat('a')] } }) stop = startNativeChatDraftLoad() publish(snapshot('b', 'a')) await waitForNativeChatComposerDrafts(1000) - publish(snapshot('c', 'b', 2)) - await settled() await vi.advanceTimersByTimeAsync(1000) await settled() - expect(readNativeChatComposerDraft(scope('c')).text).toBe('saved in b') + expect(readNativeChatComposerDraft(scope('a')).text).toBe('history draft') expect(readNativeChatComposerDraft(scope('b')).text).toBe('') }) -it('collapses repeated observed clears while storage is still loading', async () => { - stop() - clearNativeChatComposerDraftsForTests() - const storage = createMemoryNativeChatComposerDraftStorage() - storage.drafts.set(scope('a'), { text: 'a to c', images: [], savedAt: 1 }) - let finish = (): void => {} - storage.loadAll = vi.fn().mockImplementation( - () => +it.each(['draft', 'removal'] as const)( + 'keeps journal recovery authoritative after a skipped pre-load clear: %s', + async (kind) => { + stop() + clearNativeChatComposerDraftsForTests() + const storage = createMemoryNativeChatComposerDraftStorage() + storage.drafts.set(scope('a'), { text: 'older disk text', images: [], savedAt: 1 }) + localStorage.setItem( + 'orca:nativeChatComposerDraftJournal:v1', + JSON.stringify([ + { + scopeKey: scope('a'), + at: 2, + run: 'earlier-run', + draft: kind === 'draft' ? { text: 'newer recovered text', images: [], savedAt: 2 } : null + } + ]) + ) + let finish = (): void => {} + storage.loadAll = () => new Promise((resolve) => { - finish = () => resolve(new Map(storage.drafts)) + const loaded = new Map(storage.drafts) + finish = () => resolve(loaded) }) - ) - setNativeChatComposerDraftStorageForTests(storage) - useAppStore.setState({ unifiedTabsByWorktree: { [WT]: [chat('a')] } }) - stop = startNativeChatDraftLoad() - publish(snapshot('b', 'a')) - publish(snapshot('c', 'b', 2)) - finish() - await waitForNativeChatComposerDrafts(1000) - await settled() - expect(readNativeChatComposerDraft(scope('c')).text).toBe('a to c') - expect(readNativeChatComposerDraft(scope('a')).text).toBe('') - expect(readNativeChatComposerDraft(scope('b')).text).toBe('') -}) - -async function deferredReplacement(): Promise<() => Promise> { - stop() - clearNativeChatComposerDraftsForTests() - const storage = createMemoryNativeChatComposerDraftStorage() - storage.drafts.set(scope('a'), { text: 'before clear', images: [], savedAt: 1 }) - let finish = (): void => {} - storage.loadAll = () => - new Promise((resolve) => { - finish = () => resolve(new Map(storage.drafts)) - }) - setNativeChatComposerDraftStorageForTests(storage) - useAppStore.setState({ unifiedTabsByWorktree: { [WT]: [chat('a')] } }) - stop = startNativeChatDraftLoad() - publish(snapshot('b', 'a')) - useAppStore.setState({ unifiedTabsByWorktree: { [WT]: [chat('b'), chat('a', 'history')] } }) - return async () => { + storage.read = vi.fn(storage.read) + setNativeChatComposerDraftStorageForTests(storage) + useAppStore.setState({ unifiedTabsByWorktree: { [WT]: [chat('a')] } }) + stop = startNativeChatDraftLoad() + publish(snapshot('b', 'a')) + expect(storage.read).not.toHaveBeenCalled() finish() await waitForNativeChatComposerDrafts(1000) await settled() + expect(readNativeChatComposerDraft(scope('a')).text).toBe( + kind === 'draft' ? 'newer recovered text' : '' + ) + expect(readNativeChatComposerDraft(scope('b')).text).toBe('') } -} - -it('carries the captured draft while later history text stays in its earlier conversation', async () => { - const finish = await deferredReplacement() - updateNativeChatComposerDraft(scope('a'), { text: 'new history input' }, 'immediate') - useAppStore.setState({ unifiedTabsByWorktree: { [WT]: [chat('b')] } }) - await finish() - expect(readNativeChatComposerDraft(scope('a')).text).toBe('new history input') - expect(readNativeChatComposerDraft(scope('b')).text).toBe('before clear') -}) - -it('leaves a new history upload where it began when the captured move drains', async () => { - const finish = await deferredReplacement() - addNativeChatPendingAttachment(scope('a'), { id: 'history-image', path: '', pending: true }) - await finish() - settleNativeChatPendingAttachment(scope('a'), 'history-image', '/ssh/history.png', 'ssh-1') - expect(readNativeChatComposerDraft(scope('a')).images).toEqual([ - { id: 'history-image', path: '/ssh/history.png', connectionId: 'ssh-1' } - ]) - expect(readNativeChatComposerDraft(scope('b')).images).toEqual([]) -}) - -it('keeps history uploads that settle before the saved-draft load completes', async () => { - const finish = await deferredReplacement() - addNativeChatPendingAttachment(scope('a'), { id: 'history-image', path: '', pending: true }) - settleNativeChatPendingAttachment(scope('a'), 'history-image', '/ssh/history.png', 'ssh-1') - await finish() - expect(readNativeChatComposerDraft(scope('a')).images).toEqual([ - { id: 'history-image', path: '/ssh/history.png', connectionId: 'ssh-1' } - ]) - expect(readNativeChatComposerDraft(scope('b')).images).toEqual([]) - expect(readNativeChatComposerDraft(scope('b')).text).toBe('before clear') -}) +) diff --git a/src/renderer/src/app-shell/native-chat-draft-startup.ts b/src/renderer/src/app-shell/native-chat-draft-startup.ts index 839f254d7f58..18d6c29deba0 100644 --- a/src/renderer/src/app-shell/native-chat-draft-startup.ts +++ b/src/renderer/src/app-shell/native-chat-draft-startup.ts @@ -2,21 +2,13 @@ import { useAppStore } from '../store' import { resolveNativeChatDraftOwner } from '../lib/native-chat-draft-owner' import { hydrateNativeChatComposerDrafts, - isNativeChatComposerDraftLoadPending, setNativeChatComposerDraftOwnerResolver, - subscribeToNativeChatComposerDraftLoad, - structuredAgentSessionDraftScopeKey, waitForNativeChatComposerDrafts } from '@/components/native-chat/native-chat-composer-draft-store' import { - moveCapturedStructuredAgentSessionDraft, + moveStructuredAgentSessionDraft, structuredAgentSessionConversationMoves } from '@/components/native-chat/structured-agent-session-draft-move' -import { moveNativeChatPendingAttachments } from '@/components/native-chat/native-chat-pending-attachment-cache' -import { - captureNativeChatDraftTransfer, - type NativeChatDraftTransferSnapshot -} from '@/components/native-chat/native-chat-draft-transfer-snapshot' // A slower load fills drafts not edited meanwhile without holding startup. const DRAFT_LOAD_WAIT_MS = 1_500 @@ -26,39 +18,8 @@ export function startNativeChatDraftLoad(): () => void { setNativeChatComposerDraftOwnerResolver((scopeKey) => resolveNativeChatDraftOwner(useAppStore.getState(), scopeKey) ) - let active = true - const pending = new Map< - string, - { to: string; snapshot: Promise } - >() - const move = ( - from: string, - to: string, - snapshot: Promise - ): void => { - void snapshot - .then((captured) => { - if (active && captured) { - return moveCapturedStructuredAgentSessionDraft(from, to, captured) - } - return undefined - }) - .catch((error) => { - console.warn('[native-chat-drafts] a cleared chat draft could not move', error) - }) - } - const drain = (): void => { - if (isNativeChatComposerDraftLoadPending()) { - return - } - for (const [from, { to, snapshot }] of pending) { - move(from, to, snapshot) - } - pending.clear() - } - const stopLoad = subscribeToNativeChatComposerDraftLoad(drain) void hydrateNativeChatComposerDrafts() - const stopTabs = useAppStore.subscribe((state, previous) => { + return useAppStore.subscribe((state, previous) => { if (state.unifiedTabsByWorktree === previous.unifiedTabsByWorktree) { return } @@ -66,35 +27,9 @@ export function startNativeChatDraftLoad(): () => void { previous.unifiedTabsByWorktree, state.unifiedTabsByWorktree )) { - const snapshot = captureNativeChatDraftTransfer( - structuredAgentSessionDraftScopeKey(from) - ).catch((error) => { - console.warn('[native-chat-drafts] a cleared chat draft could not be captured', error) - return null - }) - moveNativeChatPendingAttachments( - structuredAgentSessionDraftScopeKey(from), - structuredAgentSessionDraftScopeKey(to) - ) - if (!isNativeChatComposerDraftLoadPending()) { - move(from, to, snapshot) - continue - } - // Only observed moves wait for the bounded load; never infer ownership from old clear markers. - for (const [source, destination] of pending) { - if (destination.to === from) { - pending.set(source, { ...destination, to }) - } - } - pending.set(from, { to, snapshot }) + moveStructuredAgentSessionDraft(from, to) } }) - return () => { - active = false - stopLoad() - pending.clear() - stopTabs() - } } /** Startup waits alongside the session read, so a composer shows its draft from its first frame. */ diff --git a/src/renderer/src/components/native-chat/native-chat-composer-draft-load.ts b/src/renderer/src/components/native-chat/native-chat-composer-draft-load.ts index 5cd1cbd40f78..53da6ccfc2e0 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-draft-load.ts +++ b/src/renderer/src/components/native-chat/native-chat-composer-draft-load.ts @@ -34,14 +34,6 @@ let hydration: Promise | null = null let started = false let failedLoads = 0 let retryTimer: ReturnType | null = null -const loadedListeners = new Set<() => void>() - -/** Re-derive draft ownership once saved records are available, including a successful retry. */ -export function subscribeToNativeChatComposerDraftLoad(listener: () => void): () => void { - loadedListeners.add(listener) - return () => loadedListeners.delete(listener) -} - /** The appends this load could not have read: made after it began reading, or never written. */ function withAppends( scopeKey: string, @@ -100,7 +92,6 @@ function applyLoaded( load.appendsBeforeLoad.clear() load.deletionsBeforeLoad.length = 0 flushNativeChatComposerDrafts() - loadedListeners.forEach((listener) => listener()) } function retryLater(error: unknown): void { @@ -115,7 +106,6 @@ function retryLater(error: unknown): void { load.editedBeforeLoad.clear() load.appendsBeforeLoad.clear() load.deletionsBeforeLoad.length = 0 - loadedListeners.forEach((listener) => listener()) return } retryTimer = setTimeout(() => { @@ -158,7 +148,6 @@ export function isNativeChatComposerDraftLoadPending(): boolean { } export function resetNativeChatComposerDraftLoadForTests(): void { - loadedListeners.clear() started = false if (retryTimer !== null) { clearTimeout(retryTimer) diff --git a/src/renderer/src/components/native-chat/native-chat-composer-draft-store.ts b/src/renderer/src/components/native-chat/native-chat-composer-draft-store.ts index 97562d7bcba4..730974424e49 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-draft-store.ts +++ b/src/renderer/src/components/native-chat/native-chat-composer-draft-store.ts @@ -54,7 +54,6 @@ export { export { hydrateNativeChatComposerDrafts, isNativeChatComposerDraftLoadPending, - subscribeToNativeChatComposerDraftLoad, waitForNativeChatComposerDrafts } from './native-chat-composer-draft-load' @@ -113,14 +112,6 @@ export function isNativeChatComposerDraftUnverified(scopeKey: string): boolean { return unverifiedScopes.has(scopeKey) } -/** A moved draft read from storage still needs its image files checked in the new scope. */ -export function markNativeChatComposerDraftUnverified(scopeKey: string): void { - if (!unverifiedScopes.has(scopeKey)) { - unverifiedScopes.add(scopeKey) - notifyScope(scopeKey) - } -} - export function markNativeChatComposerDraftVerified(scopeKey: string): void { if (unverifiedScopes.delete(scopeKey)) { notifyScope(scopeKey) @@ -140,17 +131,10 @@ export function readNativeChatComposerDraft(scopeKey: string): NativeChatCompose return records.get(scopeKey) ?? EMPTY_DRAFT } -/** - * Changes fields of the scope's draft. `deferred` is for typing, coalesced into one write; - * `immediate` saves now: text or images given back from a copy about to be deleted, and clears. - * An emptied draft is removed at once, so a sent message never comes back. - */ -export function updateNativeChatComposerDraft( +function changeNativeChatComposerDraft( scopeKey: string, - change: NativeChatComposerDraftChange, - persist: 'immediate' | 'deferred', - append?: DraftAppend -): void { + change: NativeChatComposerDraftChange +): boolean { const current = records.get(scopeKey) ?? EMPTY_DRAFT const text = change.text ?? current.text const document = 'document' in change ? change.document : current.document @@ -162,16 +146,11 @@ export function updateNativeChatComposerDraft( unsavedText === current.unsavedText && sameNativeChatComposerDraftImages(images, current.images) ) { - if (persist === 'immediate' && dirtyScopes.has(scopeKey)) { - persistNativeChatComposerDraft(scopeKey, 'immediate') - } - return + return false } if (isEmptyDraft({ text, images })) { records.delete(scopeKey) - notifyScope(scopeKey) - persistNativeChatComposerDraft(scopeKey, 'immediate', append) - return + return true } // Why stamped once: a conversation never moves to another workspace, so its owner stays true. const owner = current.owner ?? change.owner ?? resolveOwner?.(scopeKey) @@ -184,14 +163,64 @@ export function updateNativeChatComposerDraft( ...(unsavedText === undefined ? {} : { unsavedText }) } records.set(scopeKey, record) + return true +} + +/** + * Changes fields of the scope's draft. `deferred` is for typing, coalesced into one write; + * `immediate` saves now: text or images given back from a copy about to be deleted, and clears. + * An emptied draft is removed at once, so a sent message never comes back. + */ +export function updateNativeChatComposerDraft( + scopeKey: string, + change: NativeChatComposerDraftChange, + persist: 'immediate' | 'deferred', + append?: DraftAppend +): void { + if (!changeNativeChatComposerDraft(scopeKey, change)) { + if (persist === 'immediate' && dirtyScopes.has(scopeKey)) { + persistNativeChatComposerDraft(scopeKey, 'immediate') + } + return + } notifyScope(scopeKey) - persistNativeChatComposerDraft(scopeKey, persist, append) - if (!load.hydrated) { - // Why: a window whose startup never started the load still gets its saved drafts. + persistNativeChatComposerDraft(scopeKey, records.has(scopeKey) ? persist : 'immediate', append) + if (records.has(scopeKey) && !load.hydrated) { void hydrateNativeChatComposerDrafts() } } +export function isNativeChatComposerDraftHydrated(): boolean { + return load.hydrated +} + +/** Both scope changes are visible together; storage follows the same save path as an edit. */ +export function moveNativeChatComposerDraft( + from: string, + to: string, + merge: ( + target: NativeChatComposerDraft, + source: NativeChatComposerDraft + ) => NativeChatComposerDraftChange +): void { + if (from === to || !load.hydrated) { + return + } + const source = records.get(from) + if (!source) { + return + } + changeNativeChatComposerDraft(to, merge(readNativeChatComposerDraft(to), source)) + records.delete(from) + if (unverifiedScopes.delete(from)) { + unverifiedScopes.add(to) + } + persistNativeChatComposerDraft(to, 'immediate') + persistNativeChatComposerDraft(from, 'immediate') + notifyScope(to) + notifyScope(from) +} + function withAddition( draft: T, addition: NativeChatComposerDraftAddition, diff --git a/src/renderer/src/components/native-chat/native-chat-draft-transfer-snapshot.test.ts b/src/renderer/src/components/native-chat/native-chat-draft-transfer-snapshot.test.ts deleted file mode 100644 index f6eda93f19e3..000000000000 --- a/src/renderer/src/components/native-chat/native-chat-draft-transfer-snapshot.test.ts +++ /dev/null @@ -1,49 +0,0 @@ -// @vitest-environment happy-dom -import { afterEach, expect, it } from 'vitest' -import { captureNativeChatDraftTransfer } from './native-chat-draft-transfer-snapshot' -import { - clearNativeChatComposerDraftsForTests, - hydrateNativeChatComposerDrafts, - updateNativeChatComposerDraft -} from './native-chat-composer-draft-store' -import { - createMemoryNativeChatComposerDraftStorage, - setNativeChatComposerDraftStorageForTests -} from './native-chat-composer-draft-storage' - -afterEach(clearNativeChatComposerDraftsForTests) - -it('captures a saved record before a later history write even while startup has not loaded', async () => { - const storage = createMemoryNativeChatComposerDraftStorage() - storage.drafts.set('source', { text: 'before switch', images: [], savedAt: 1 }) - setNativeChatComposerDraftStorageForTests(storage) - const captured = captureNativeChatDraftTransfer('source') - await storage.write('source', { text: 'later history', images: [], savedAt: 2 }) - expect(await captured).toEqual({ - draft: { text: 'before switch', images: [], savedAt: 1 }, - unverified: true - }) -}) - -it('freezes memory input without depending on a disk read', async () => { - const storage = createMemoryNativeChatComposerDraftStorage() - setNativeChatComposerDraftStorageForTests(storage) - await hydrateNativeChatComposerDrafts() - storage.read = async () => { - throw new Error('unavailable') - } - updateNativeChatComposerDraft('source', { text: 'before switch' }, 'immediate') - const captured = captureNativeChatDraftTransfer('source') - updateNativeChatComposerDraft('source', { text: 'later history' }, 'immediate') - expect(await captured).toMatchObject({ draft: { text: 'before switch' }, unverified: false }) -}) - -it('reports a failed saved-record capture rather than reading a later scope value', async () => { - const storage = createMemoryNativeChatComposerDraftStorage() - storage.read = async () => { - throw new Error('unavailable') - } - setNativeChatComposerDraftStorageForTests(storage) - await expect(captureNativeChatDraftTransfer('source')).rejects.toThrow('unavailable') - expect(storage.drafts.size).toBe(0) -}) diff --git a/src/renderer/src/components/native-chat/native-chat-draft-transfer-snapshot.ts b/src/renderer/src/components/native-chat/native-chat-draft-transfer-snapshot.ts deleted file mode 100644 index 396742bf6934..000000000000 --- a/src/renderer/src/components/native-chat/native-chat-draft-transfer-snapshot.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { load, records, unverifiedScopes } from './native-chat-composer-draft-memory' -import { - nativeChatComposerDraftStorage, - parseStoredNativeChatComposerDraft, - type StoredNativeChatComposerDraft -} from './native-chat-composer-draft-storage' - -export type NativeChatDraftTransferSnapshot = { - readonly draft: StoredNativeChatComposerDraft - readonly unverified: boolean -} - -/** Capture before later history writes, rather than rereading its scope when loading finishes. */ -export async function captureNativeChatDraftTransfer( - scopeKey: string -): Promise { - const current = records.get(scopeKey) ?? { text: '', images: [], savedAt: 0 } - if (load.hydrated || load.editedBeforeLoad.has(scopeKey)) { - return { draft: current, unverified: unverifiedScopes.has(scopeKey) } - } - const appends = [...(load.appendsBeforeLoad.get(scopeKey) ?? [])] - const stored = parseStoredNativeChatComposerDraft( - await nativeChatComposerDraftStorage().read(scopeKey) - ) - if (!stored) { - return { draft: current, unverified: unverifiedScopes.has(scopeKey) } - } - const draft = appends.reduce( - (held, entry) => (entry.committed ? held : entry.append(held)), - stored - ) - return { draft, unverified: true } -} - -/** A later history draft can have identical content and still be a different write. */ -export function nativeChatDraftTransferSourceUnchanged( - scopeKey: string, - snapshot: NativeChatDraftTransferSnapshot -): boolean { - return records.get(scopeKey)?.savedAt === snapshot.draft.savedAt -} diff --git a/src/renderer/src/components/native-chat/structured-agent-session-draft-move.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-draft-move.test.ts index 26dee7c377b9..c75ffd16898c 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-draft-move.test.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-draft-move.test.ts @@ -10,6 +10,10 @@ import { hydrateNativeChatComposerDrafts, isNativeChatComposerDraftUnverified, readNativeChatComposerDraft, + subscribeToNativeChatComposerDraft, + nativeChatComposerDraftWritesSettled, + flushNativeChatComposerDrafts, + isNativeChatComposerDraftUnsaved, structuredAgentSessionDraftScopeKey, updateNativeChatComposerDraft } from './native-chat-composer-draft-store' @@ -180,23 +184,51 @@ describe('moving a cleared conversation draft', () => { expect(readNativeChatComposerDraft(scope('b')).text).toBe('mine') }) - it('keeps the source when saving the destination fails', async () => { + it('keeps the moved draft in memory through a refused save and repairs it on a normal flush', async () => { const storage = createMemoryNativeChatComposerDraftStorage() setNativeChatComposerDraftStorageForTests(storage) updateNativeChatComposerDraft(scope('a'), { text: 'still owed' }, 'immediate') + await nativeChatComposerDraftWritesSettled() storage.refuseWrites = true - - await moveStructuredAgentSessionDraft('a', 'b') - - expect(readNativeChatComposerDraft(scope('a')).text).toBe('still owed') + moveStructuredAgentSessionDraft('a', 'b') + await nativeChatComposerDraftWritesSettled() + expect(readNativeChatComposerDraft(scope('a')).text).toBe('') expect(readNativeChatComposerDraft(scope('b')).text).toBe('still owed') + expect(isNativeChatComposerDraftUnsaved(scope('b'))).toBe(true) storage.refuseWrites = false - await moveStructuredAgentSessionDraft('a', 'b') - expect(readNativeChatComposerDraft(scope('b')).text).toBe('still owed') - expect(readNativeChatComposerDraft(scope('a')).text).toBe('') + flushNativeChatComposerDrafts() + await nativeChatComposerDraftWritesSettled() + expect(storage.drafts.get(scope('b'))?.text).toBe('still owed') + expect(storage.drafts.has(scope('a'))).toBe(false) + expect(isNativeChatComposerDraftUnsaved(scope('b'))).toBe(false) + }) + + it('publishes both scope changes before any subscriber observes the move', () => { + updateNativeChatComposerDraft( + scope('a'), + { text: 'question', images: [SSH_IMAGE] }, + 'immediate' + ) + const seen: { source: string; target: string }[] = [] + const observe = (): void => { + seen.push({ + source: readNativeChatComposerDraft(scope('a')).text, + target: readNativeChatComposerDraft(scope('b')).text + }) + } + const unsubscribeSource = subscribeToNativeChatComposerDraft(scope('a'), observe) + const unsubscribeTarget = subscribeToNativeChatComposerDraft(scope('b'), observe) + moveStructuredAgentSessionDraft('a', 'b') + unsubscribeSource() + unsubscribeTarget() + expect(seen).toEqual([ + { source: '', target: 'question' }, + { source: '', target: 'question' } + ]) + expect(readNativeChatComposerDraft(scope('b')).images).toEqual([SSH_IMAGE]) }) - it('saves the skill document before removing its source, and restores it after restart', async () => { + it('saves the whole moved draft through the store and restores it after restart', async () => { const storage = createMemoryNativeChatComposerDraftStorage() setNativeChatComposerDraftStorageForTests(storage) const document = { @@ -235,7 +267,7 @@ describe('moving a cleared conversation draft', () => { }) }) - it('does not clear a source edited while the destination save is outstanding', async () => { + it('keeps new history input entered after the synchronous move', async () => { updateNativeChatComposerDraft(scope('a'), { text: 'moved' }, 'immediate') const moving = moveStructuredAgentSessionDraft('a', 'b') updateNativeChatComposerDraft(scope('a'), { text: 'new old-chat draft' }, 'immediate') @@ -244,7 +276,7 @@ describe('moving a cleared conversation draft', () => { expect(readNativeChatComposerDraft(scope('b')).text).toBe('moved') }) - it('keeps a later history write even when it recreates the captured text', async () => { + it('keeps a later history write even when it recreates the moved text', async () => { updateNativeChatComposerDraft(scope('a'), { text: 'same question' }, 'immediate') const moving = moveStructuredAgentSessionDraft('a', 'b') updateNativeChatComposerDraft(scope('a'), { text: '' }, 'immediate') @@ -254,43 +286,17 @@ describe('moving a cleared conversation draft', () => { expect(readNativeChatComposerDraft(scope('b')).text).toBe('same question') }) - it('waits for the saved drafts to load before moving one only storage holds', async () => { + it('moves a restored source only after the load has finished', async () => { clearNativeChatComposerDraftsForTests() const storage = createMemoryNativeChatComposerDraftStorage() storage.drafts.set(scope('a'), { text: 'saved before quit', images: [SSH_IMAGE], savedAt: 1 }) setNativeChatComposerDraftStorageForTests(storage) - const loading = hydrateNativeChatComposerDrafts() - - await moveStructuredAgentSessionDraft('a', 'b') - await loading - await Promise.resolve() - + await hydrateNativeChatComposerDrafts() + moveStructuredAgentSessionDraft('a', 'b') expect(readNativeChatComposerDraft(scope('b')).text).toBe('saved before quit') expect(readNativeChatComposerDraft(scope('a')).text).toBe('') expect(isNativeChatComposerDraftUnverified(scope('b'))).toBe(true) }) - - it('waits for a slow destination load so its saved input is merged', async () => { - clearNativeChatComposerDraftsForTests() - const storage = createMemoryNativeChatComposerDraftStorage() - storage.drafts.set(scope('a'), { text: 'source', images: [], savedAt: 1 }) - storage.drafts.set(scope('b'), { text: 'destination', images: [], savedAt: 2 }) - let finish = (): void => {} - storage.loadAll = () => - new Promise((resolve) => { - finish = () => resolve(new Map(storage.drafts)) - }) - setNativeChatComposerDraftStorageForTests(storage) - const loading = hydrateNativeChatComposerDrafts() - const moving = moveStructuredAgentSessionDraft('a', 'b') - await Promise.resolve() - await Promise.resolve() - expect(readNativeChatComposerDraft(scope('b')).text).toBe('') - finish() - await loading - await moving - expect(readNativeChatComposerDraft(scope('b')).text).toBe('destination\n\nsource') - }) }) function chat(id: string, entityId: string, contentType: Tab['contentType'] = 'agent-session') { diff --git a/src/renderer/src/components/native-chat/structured-agent-session-draft-move.ts b/src/renderer/src/components/native-chat/structured-agent-session-draft-move.ts index 0f063cb0f372..e284aaea83da 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-draft-move.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-draft-move.ts @@ -2,67 +2,26 @@ import { withNativeChatComposerDraftAddition } from './native-chat-composer-draft-addition' import { - clearNativeChatComposerDraftIfUnchanged, - nativeChatComposerDraftWriteSettled, - hydrateNativeChatComposerDrafts, - isNativeChatComposerDraftLoadPending, - markNativeChatComposerDraftUnverified, - readNativeChatComposerDraft, - structuredAgentSessionDraftScopeKey, - updateNativeChatComposerDraft + isNativeChatComposerDraftHydrated, + moveNativeChatComposerDraft, + structuredAgentSessionDraftScopeKey } from './native-chat-composer-draft-store' -import { sameNativeChatComposerDraftDocument } from './native-chat-composer-draft-comparison' import { moveNativeChatPendingAttachments } from './native-chat-pending-attachment-cache' import { mergeNativeChatDraftDocument } from './native-chat-draft-document-merge' -import { - captureNativeChatDraftTransfer, - nativeChatDraftTransferSourceUnchanged, - type NativeChatDraftTransferSnapshot -} from './native-chat-draft-transfer-snapshot' import type { Tab } from '../../../../shared/tab-types' -/** Moves the whole draft, removing its source only after the destination is saved. */ -export async function moveStructuredAgentSessionDraft( - fromSessionId: string, - toSessionId: string -): Promise { - if (fromSessionId === toSessionId) { +/** Moves the loaded draft synchronously with its tab; unobserved input stays in history. */ +export function moveStructuredAgentSessionDraft(fromSessionId: string, toSessionId: string): void { + if (fromSessionId === toSessionId || !isNativeChatComposerDraftHydrated()) { return } const from = structuredAgentSessionDraftScopeKey(fromSessionId) const to = structuredAgentSessionDraftScopeKey(toSessionId) moveNativeChatPendingAttachments(from, to) - const captured = await captureNativeChatDraftTransfer(from) - if (isNativeChatComposerDraftLoadPending()) { - await hydrateNativeChatComposerDrafts() - if (isNativeChatComposerDraftLoadPending()) { - return - } - } - await moveCapturedStructuredAgentSessionDraft(fromSessionId, toSessionId, captured) -} - -/** Pending operations already moved at the switch; later history operations stay where begun. */ -export async function moveCapturedStructuredAgentSessionDraft( - fromSessionId: string, - toSessionId: string, - snapshot: NativeChatDraftTransferSnapshot -): Promise { - if (fromSessionId === toSessionId) { - return - } - const from = structuredAgentSessionDraftScopeKey(fromSessionId) - const to = structuredAgentSessionDraftScopeKey(toSessionId) - const source = snapshot.draft - if (source.text === '' && source.images.length === 0) { - return - } - const target = readNativeChatComposerDraft(to) - const merged = withNativeChatComposerDraftAddition(target, { text: source.text }) - const emptyTarget = target.text === '' && target.images.length === 0 - updateNativeChatComposerDraft( - to, - { + moveNativeChatComposerDraft(from, to, (target, source) => { + const merged = withNativeChatComposerDraftAddition(target, { text: source.text }) + const emptyTarget = target.text === '' && target.images.length === 0 + return { ...(emptyTarget ? source : merged), images: emptyTarget ? source.images @@ -71,19 +30,8 @@ export async function moveCapturedStructuredAgentSessionDraft( ...source.images.filter((image) => !target.images.some((held) => held.id === image.id)) ], document: mergeNativeChatDraftDocument(target, source, merged.text) - }, - 'immediate' - ) - if (source.images.length > 0 && snapshot.unverified) { - markNativeChatComposerDraftUnverified(to) - } - if ( - (await nativeChatComposerDraftWriteSettled(to)) && - nativeChatDraftTransferSourceUnchanged(from, snapshot) && - sameNativeChatComposerDraftDocument(readNativeChatComposerDraft(from).document, source.document) - ) { - clearNativeChatComposerDraftIfUnchanged(from, source) - } + } + }) } /** Each chat whose tab now shows another conversation, whichever client ran the clear. */ From 2c3077ed032a9e583b48790b7ece5098b8b0bd6d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 7 Oct 2026 19:45:16 -0700 Subject: [PATCH 7/9] fix(native-chat): preserve whole drafts without merging --- .../native-chat-composer-draft-store.ts | 60 +++--- .../native-chat-draft-document-merge.ts | 151 -------------- ...ent-session-draft-attachment-move.test.tsx | 16 ++ ...tructured-agent-session-draft-move.test.ts | 186 ++++++------------ .../structured-agent-session-draft-move.ts | 33 ++-- ...ructured-composer-send.clear-move.test.tsx | 25 ++- ...se-native-chat-structured-composer-send.ts | 8 +- 7 files changed, 145 insertions(+), 334 deletions(-) delete mode 100644 src/renderer/src/components/native-chat/native-chat-draft-document-merge.ts diff --git a/src/renderer/src/components/native-chat/native-chat-composer-draft-store.ts b/src/renderer/src/components/native-chat/native-chat-composer-draft-store.ts index 730974424e49..fd386d0dbb88 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-draft-store.ts +++ b/src/renderer/src/components/native-chat/native-chat-composer-draft-store.ts @@ -131,10 +131,17 @@ export function readNativeChatComposerDraft(scopeKey: string): NativeChatCompose return records.get(scopeKey) ?? EMPTY_DRAFT } -function changeNativeChatComposerDraft( +/** + * Changes fields of the scope's draft. `deferred` is for typing, coalesced into one write; + * `immediate` saves now: text or images given back from a copy about to be deleted, and clears. + * An emptied draft is removed at once, so a sent message never comes back. + */ +export function updateNativeChatComposerDraft( scopeKey: string, - change: NativeChatComposerDraftChange -): boolean { + change: NativeChatComposerDraftChange, + persist: 'immediate' | 'deferred', + append?: DraftAppend +): void { const current = records.get(scopeKey) ?? EMPTY_DRAFT const text = change.text ?? current.text const document = 'document' in change ? change.document : current.document @@ -146,11 +153,16 @@ function changeNativeChatComposerDraft( unsavedText === current.unsavedText && sameNativeChatComposerDraftImages(images, current.images) ) { - return false + if (persist === 'immediate' && dirtyScopes.has(scopeKey)) { + persistNativeChatComposerDraft(scopeKey, 'immediate') + } + return } if (isEmptyDraft({ text, images })) { records.delete(scopeKey) - return true + notifyScope(scopeKey) + persistNativeChatComposerDraft(scopeKey, 'immediate', append) + return } // Why stamped once: a conversation never moves to another workspace, so its owner stays true. const owner = current.owner ?? change.owner ?? resolveOwner?.(scopeKey) @@ -163,29 +175,10 @@ function changeNativeChatComposerDraft( ...(unsavedText === undefined ? {} : { unsavedText }) } records.set(scopeKey, record) - return true -} - -/** - * Changes fields of the scope's draft. `deferred` is for typing, coalesced into one write; - * `immediate` saves now: text or images given back from a copy about to be deleted, and clears. - * An emptied draft is removed at once, so a sent message never comes back. - */ -export function updateNativeChatComposerDraft( - scopeKey: string, - change: NativeChatComposerDraftChange, - persist: 'immediate' | 'deferred', - append?: DraftAppend -): void { - if (!changeNativeChatComposerDraft(scopeKey, change)) { - if (persist === 'immediate' && dirtyScopes.has(scopeKey)) { - persistNativeChatComposerDraft(scopeKey, 'immediate') - } - return - } notifyScope(scopeKey) - persistNativeChatComposerDraft(scopeKey, records.has(scopeKey) ? persist : 'immediate', append) - if (records.has(scopeKey) && !load.hydrated) { + persistNativeChatComposerDraft(scopeKey, persist, append) + if (!load.hydrated) { + // Why: a window whose startup never started the load still gets its saved drafts. void hydrateNativeChatComposerDrafts() } } @@ -195,22 +188,15 @@ export function isNativeChatComposerDraftHydrated(): boolean { } /** Both scope changes are visible together; storage follows the same save path as an edit. */ -export function moveNativeChatComposerDraft( - from: string, - to: string, - merge: ( - target: NativeChatComposerDraft, - source: NativeChatComposerDraft - ) => NativeChatComposerDraftChange -): void { - if (from === to || !load.hydrated) { +export function moveNativeChatComposerDraft(from: string, to: string): void { + if (from === to || !load.hydrated || !isEmptyDraft(readNativeChatComposerDraft(to))) { return } const source = records.get(from) if (!source) { return } - changeNativeChatComposerDraft(to, merge(readNativeChatComposerDraft(to), source)) + records.set(to, { ...source, savedAt: nextSavedAt() }) records.delete(from) if (unverifiedScopes.delete(from)) { unverifiedScopes.add(to) diff --git a/src/renderer/src/components/native-chat/native-chat-draft-document-merge.ts b/src/renderer/src/components/native-chat/native-chat-draft-document-merge.ts deleted file mode 100644 index 4f1b7855958f..000000000000 --- a/src/renderer/src/components/native-chat/native-chat-draft-document-merge.ts +++ /dev/null @@ -1,151 +0,0 @@ -import type { JSONContent } from '@tiptap/react' -import type { NativeChatComposerDraft } from './native-chat-composer-draft-storage' -import { promptTextContent } from './native-chat-prompt-document' - -function inlineText(node: JSONContent): string { - return node.type === 'text' - ? (node.text ?? '') - : node.type === 'hardBreak' - ? '\n' - : String(node.attrs?.token ?? '') -} - -function documentFor(draft: NativeChatComposerDraft): JSONContent { - const text = draft.document?.content - ?.map((block) => block.content?.map(inlineText).join('') ?? '') - .join('\n') - return text === draft.text && draft.document ? draft.document : promptTextContent(draft.text) -} - -type SkillRange = { start: number; end: number; node: JSONContent } - -function skillRanges(document: JSONContent, start: number): SkillRange[] { - const ranges: SkillRange[] = [] - let offset = start - for (const [index, block] of (document.content ?? []).entries()) { - if (index > 0) { - offset += 1 - } - for (const node of block.content ?? []) { - const end = offset + inlineText(node).length - if (node.type === 'nativeChatSkill') { - ranges.push({ start: offset, end, node }) - } - offset = end - } - } - return ranges -} - -function carrySkills( - node: JSONContent, - start: number, - ranges: readonly SkillRange[] -): JSONContent[] { - if (node.type !== 'text') { - return [node] - } - const text = node.text ?? '' - const end = start + text.length - const content: JSONContent[] = [] - let keptFrom = start - for (const skill of ranges) { - if (skill.end <= start || skill.start >= end) { - continue - } - if (skill.start > keptFrom) { - content.push({ ...node, text: text.slice(keptFrom - start, skill.start - start) }) - } - if (skill.start >= start) { - content.push(skill.node) - } - keptFrom = Math.min(end, Math.max(keptFrom, skill.end)) - } - if (keptFrom < end) { - content.push({ ...node, text: text.slice(keptFrom - start) }) - } - return content -} - -/** Matching literal text does not already hold a picker node; copy the explicit node. */ -function deduplicatedDocument( - target: NativeChatComposerDraft, - source: NativeChatComposerDraft -): JSONContent | undefined { - const start = target.text.trimEnd().length - source.text.trimEnd().length - const ranges = skillRanges(documentFor(source), start) - if (ranges.length === 0) { - return target.document ?? (target.text === source.text ? source.document : undefined) - } - let offset = 0 - return { - type: 'doc', - content: (documentFor(target).content ?? []).map((block, index) => { - if (index > 0) { - offset += 1 - } - return { - ...block, - content: (block.content ?? []).flatMap((node) => { - const content = carrySkills(node, offset, ranges) - offset += inlineText(node).length - return content - }) - } - }) - } -} - -/** Trim only the suffix removed by the text merge, preserving each remaining skill node. */ -function trimmedContent(document: JSONContent, length: number): JSONContent[] { - const blocks: JSONContent[] = [] - let remaining = length - for (const block of document.content ?? []) { - if (blocks.length > 0) { - if (remaining === 0) { - break - } - remaining -= 1 - } - const content: JSONContent[] = [] - for (const node of block.content ?? []) { - const text = inlineText(node) - if (remaining === 0) { - break - } - content.push( - node.type === 'text' && text.length > remaining - ? { ...node, text: text.slice(0, remaining) } - : node - ) - remaining = Math.max(0, remaining - text.length) - } - blocks.push({ ...block, content }) - } - return blocks -} - -/** The same text merge, with picker-created chips retained in both documents. */ -export function mergeNativeChatDraftDocument( - target: NativeChatComposerDraft, - source: NativeChatComposerDraft, - mergedText: string -): JSONContent | undefined { - if (mergedText === target.text) { - return deduplicatedDocument(target, source) - } - if (mergedText === source.text) { - return source.document - } - if (!target.document && !source.document) { - return undefined - } - return { - type: 'doc', - content: [ - ...trimmedContent(documentFor(target), target.text.trimEnd().length), - { type: 'paragraph', content: [] }, - ...(documentFor(source).content ?? []) - ] - } -} diff --git a/src/renderer/src/components/native-chat/structured-agent-session-draft-attachment-move.test.tsx b/src/renderer/src/components/native-chat/structured-agent-session-draft-attachment-move.test.tsx index 9ef6acc38710..5181b8a3546d 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-draft-attachment-move.test.tsx +++ b/src/renderer/src/components/native-chat/structured-agent-session-draft-attachment-move.test.tsx @@ -7,6 +7,7 @@ import { clearNativeChatComposerDraftsForTests, hydrateNativeChatComposerDrafts, readNativeChatComposerDraft, + updateNativeChatComposerDraft, structuredAgentSessionDraftScopeKey as scope } from './native-chat-composer-draft-store' import { @@ -106,3 +107,18 @@ it('does not attach a moved upload the user already removed', async () => { expect(readNativeChatComposerDraft(scope('a')).images).toEqual([]) expect(readNativeChatComposerDraft(scope('b')).images).toEqual([]) }) + +it('keeps source ownership when the replacement already holds a pending upload', () => { + updateNativeChatComposerDraft(scope('a'), { text: 'source history draft' }, 'immediate') + addNativeChatPendingAttachment(scope('a'), { id: 'source-upload', path: '', pending: true }) + addNativeChatPendingAttachment(scope('b'), { id: 'target-upload', path: '', pending: true }) + moveStructuredAgentSessionDraft('a', 'b') + expect(readNativeChatComposerDraft(scope('a')).text).toBe('source history draft') + expect(readNativeChatComposerDraft(scope('b')).text).toBe('') + expect(nativeChatPendingAttachmentSnapshot(scope('a'))).toMatchObject([{ id: 'source-upload' }]) + expect(nativeChatPendingAttachmentSnapshot(scope('b'))).toMatchObject([{ id: 'target-upload' }]) + settleNativeChatPendingAttachment(scope('a'), 'source-upload', '/ssh/source.png', 'ssh-1') + settleNativeChatPendingAttachment(scope('b'), 'target-upload', '/ssh/target.png', 'ssh-1') + expect(readNativeChatComposerDraft(scope('a')).images[0]?.path).toBe('/ssh/source.png') + expect(readNativeChatComposerDraft(scope('b')).images[0]?.path).toBe('/ssh/target.png') +}) diff --git a/src/renderer/src/components/native-chat/structured-agent-session-draft-move.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-draft-move.test.ts index c75ffd16898c..3bee83b2ff9d 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-draft-move.test.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-draft-move.test.ts @@ -35,148 +35,82 @@ afterEach(() => { }) describe('moving a cleared conversation draft', () => { - it.each(['$review', 'question\n\n$review'])( - 'keeps a picked skill when the destination already holds its literal text: %s', - async (text) => { - const document = { - type: 'doc', - content: [ - { type: 'paragraph', content: [{ type: 'nativeChatSkill', attrs: { token: '$review' } }] } - ] - } - updateNativeChatComposerDraft(scope('a'), { text: '$review', document }, 'immediate') - updateNativeChatComposerDraft(scope('b'), { text }, 'immediate') - await moveStructuredAgentSessionDraft('a', 'b') - expect(readNativeChatComposerDraft(scope('b')).text).toBe(text) - expect(JSON.stringify(readNativeChatComposerDraft(scope('b')).document) ?? '').toContain( - 'nativeChatSkill' - ) - if (text === '$review') { - expect(readNativeChatComposerDraft(scope('b')).document).toEqual(document) - } - expect(readNativeChatComposerDraft(scope('a')).text).toBe('') - } - ) - - it('preserves an existing chip and copies the source chip into a matching literal suffix', async () => { - const first = { type: 'nativeChatSkill', attrs: { token: '$first' } } - const review = { type: 'nativeChatSkill', attrs: { token: '$review' } } - updateNativeChatComposerDraft( - scope('a'), - { - text: '$review', - document: { type: 'doc', content: [{ type: 'paragraph', content: [review] }] } - }, - 'immediate' - ) - const document = { - type: 'doc', - content: [ - { type: 'paragraph', content: [first] }, - { type: 'paragraph', content: [] }, - { type: 'paragraph', content: [{ type: 'text', text: '$review' }] } - ] - } - updateNativeChatComposerDraft(scope('b'), { text: '$first\n\n$review', document }, 'immediate') - await moveStructuredAgentSessionDraft('a', 'b') - expect(readNativeChatComposerDraft(scope('b')).document).toEqual({ - type: 'doc', - content: [ - { type: 'paragraph', content: [first] }, - { type: 'paragraph', content: [] }, - { type: 'paragraph', content: [review] } - ] - }) - }) - - it('preserves the skill document when the destination holds only an image', async () => { + it('moves text, SSH images and skill chips whole into the new conversation', async () => { const document = { type: 'doc', content: [ - { type: 'paragraph', content: [{ type: 'nativeChatSkill', attrs: { token: '$review' } }] } - ] - } - updateNativeChatComposerDraft(scope('a'), { text: '$review', document }, 'immediate') - updateNativeChatComposerDraft(scope('b'), { images: [SSH_IMAGE] }, 'immediate') - await moveStructuredAgentSessionDraft('a', 'b') - expect(readNativeChatComposerDraft(scope('b'))).toMatchObject({ - text: '$review', - document, - images: [SSH_IMAGE] - }) - }) - - it('saves chips in both merged documents without flattening either', async () => { - const storage = createMemoryNativeChatComposerDraftStorage() - setNativeChatComposerDraftStorageForTests(storage) - const first = { - type: 'paragraph', - content: [ - { type: 'nativeChatSkill', attrs: { token: '$first' } }, - { type: 'text', text: ' \n' } - ] - } - const second = { - type: 'paragraph', - content: [{ type: 'nativeChatSkill', attrs: { token: '$second' } }] - } - updateNativeChatComposerDraft( - scope('b'), - { text: '$first \n', document: { type: 'doc', content: [first] } }, - 'immediate' - ) - updateNativeChatComposerDraft( - scope('a'), - { text: '$second', document: { type: 'doc', content: [second] } }, - 'immediate' - ) - await moveStructuredAgentSessionDraft('a', 'b') - const expected = { - type: 'doc', - content: [ - { ...first, content: [first.content[0]] }, - { type: 'paragraph', content: [] }, - second + { + type: 'paragraph', + content: [ + { type: 'text', text: 'before ' }, + { type: 'nativeChatSkill', attrs: { token: '$review' } }, + { type: 'text', text: ' after' } + ] + } ] } - expect(readNativeChatComposerDraft(scope('b'))).toMatchObject({ - text: '$first\n\n$second', - document: expected - }) - clearNativeChatComposerDraftsForTests() - setNativeChatComposerDraftStorageForTests(storage) - await hydrateNativeChatComposerDrafts() - expect(readNativeChatComposerDraft(scope('b'))).toMatchObject({ - text: '$first\n\n$second', - document: expected - }) - }) - it('moves text, SSH images and skill chips whole into the new conversation', async () => { - const document = { type: 'doc', content: [{ type: 'paragraph' }] } updateNativeChatComposerDraft( scope('a'), - { text: 'typed during clear', document, images: [SSH_IMAGE] }, + { text: 'before $review after', document, images: [SSH_IMAGE] }, 'immediate' ) await moveStructuredAgentSessionDraft('a', 'b') expect(readNativeChatComposerDraft(scope('b'))).toMatchObject({ - text: 'typed during clear', + text: 'before $review after', document, images: [SSH_IMAGE] }) expect(readNativeChatComposerDraft(scope('a'))).toMatchObject({ text: '', images: [] }) }) - it('goes after what the new conversation already holds', async () => { - updateNativeChatComposerDraft(scope('a'), { text: 'moved' }, 'immediate') - updateNativeChatComposerDraft(scope('b'), { text: 'already here' }, 'immediate') - - await moveStructuredAgentSessionDraft('a', 'b') - - expect(readNativeChatComposerDraft(scope('b')).text).toBe('already here\n\nmoved') - }) + it.each(['text', 'image', 'skill'] as const)( + 'keeps both drafts intact when the destination has %s input', + (kind) => { + const source = { + text: '$review-long', + document: { + type: 'doc', + content: [ + { + type: 'paragraph', + content: [{ type: 'nativeChatSkill', attrs: { token: '$review-long' } }] + } + ] + }, + images: [SSH_IMAGE] + } + const destination = + kind === 'image' + ? { text: '', images: [SSH_IMAGE] } + : kind === 'text' + ? { text: '$review-long', images: [] } + : { + text: '$review-long', + images: [], + document: { + type: 'doc', + content: [ + { + type: 'paragraph', + content: [ + { type: 'nativeChatSkill', attrs: { token: '$review' } }, + { type: 'text', text: '-long' } + ] + } + ] + } + } + updateNativeChatComposerDraft(scope('a'), source, 'immediate') + updateNativeChatComposerDraft(scope('b'), destination, 'immediate') + const originalSource = readNativeChatComposerDraft(scope('a')) + const originalTarget = readNativeChatComposerDraft(scope('b')) + moveStructuredAgentSessionDraft('a', 'b') + expect(readNativeChatComposerDraft(scope('a'))).toBe(originalSource) + expect(readNativeChatComposerDraft(scope('b'))).toBe(originalTarget) + } + ) it('moves nothing from an empty draft', async () => { updateNativeChatComposerDraft(scope('b'), { text: 'mine' }, 'immediate') @@ -256,15 +190,15 @@ describe('moving a cleared conversation draft', () => { expect(readNativeChatComposerDraft(scope('a')).text).toBe('') }) - it('keeps unavailable images intact and deduplicates an already copied image', async () => { + it('keeps unavailable images intact in the whole transferred record', () => { const image = { id: 'missing', path: '', unavailableName: 'notes.png' } updateNativeChatComposerDraft(scope('a'), { text: 'moved', images: [image] }, 'immediate') - updateNativeChatComposerDraft(scope('b'), { text: 'moved', images: [image] }, 'immediate') - await moveStructuredAgentSessionDraft('a', 'b') + moveStructuredAgentSessionDraft('a', 'b') expect(readNativeChatComposerDraft(scope('b'))).toMatchObject({ text: 'moved', images: [image] }) + expect(readNativeChatComposerDraft(scope('a')).images).toEqual([]) }) it('keeps new history input entered after the synchronous move', async () => { diff --git a/src/renderer/src/components/native-chat/structured-agent-session-draft-move.ts b/src/renderer/src/components/native-chat/structured-agent-session-draft-move.ts index e284aaea83da..b6daa7911c77 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-draft-move.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-draft-move.ts @@ -1,37 +1,34 @@ // Unsent input follows its chat tab when /clear changes that tab's conversation. -import { withNativeChatComposerDraftAddition } from './native-chat-composer-draft-addition' import { isNativeChatComposerDraftHydrated, moveNativeChatComposerDraft, + readNativeChatComposerDraft, structuredAgentSessionDraftScopeKey } from './native-chat-composer-draft-store' -import { moveNativeChatPendingAttachments } from './native-chat-pending-attachment-cache' -import { mergeNativeChatDraftDocument } from './native-chat-draft-document-merge' +import { + moveNativeChatPendingAttachments, + nativeChatPendingAttachmentSnapshot +} from './native-chat-pending-attachment-cache' import type { Tab } from '../../../../shared/tab-types' -/** Moves the loaded draft synchronously with its tab; unobserved input stays in history. */ +/** Moves loaded input into an empty replacement; conflicting input stays in history. */ export function moveStructuredAgentSessionDraft(fromSessionId: string, toSessionId: string): void { if (fromSessionId === toSessionId || !isNativeChatComposerDraftHydrated()) { return } const from = structuredAgentSessionDraftScopeKey(fromSessionId) const to = structuredAgentSessionDraftScopeKey(toSessionId) + const target = readNativeChatComposerDraft(to) + if ( + target.text !== '' || + target.images.length > 0 || + nativeChatPendingAttachmentSnapshot(to).length > 0 + ) { + return + } moveNativeChatPendingAttachments(from, to) - moveNativeChatComposerDraft(from, to, (target, source) => { - const merged = withNativeChatComposerDraftAddition(target, { text: source.text }) - const emptyTarget = target.text === '' && target.images.length === 0 - return { - ...(emptyTarget ? source : merged), - images: emptyTarget - ? source.images - : [ - ...target.images, - ...source.images.filter((image) => !target.images.some((held) => held.id === image.id)) - ], - document: mergeNativeChatDraftDocument(target, source, merged.text) - } - }) + moveNativeChatComposerDraft(from, to) } /** Each chat whose tab now shows another conversation, whichever client ran the clear. */ diff --git a/src/renderer/src/components/native-chat/use-native-chat-structured-composer-send.clear-move.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-structured-composer-send.clear-move.test.tsx index dbfe47954a22..404cfc679586 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-structured-composer-send.clear-move.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-structured-composer-send.clear-move.test.tsx @@ -16,6 +16,12 @@ import { setNativeChatComposerDraftStorageForTests } from './native-chat-composer-draft-storage' import { writeNativeChatDraftCache } from './native-chat-draft-cache' +import { + addNativeChatPendingAttachment, + clearNativeChatPendingAttachmentsForTests, + nativeChatPendingAttachmentSnapshot, + settleNativeChatPendingAttachment +} from './native-chat-pending-attachment-cache' import { moveStructuredAgentSessionDraft } from './structured-agent-session-draft-move' vi.mock('@/lib/native-chat-telemetry', () => ({ emitNativeChatMessageSent: vi.fn() })) @@ -33,6 +39,7 @@ beforeEach(async () => { afterEach(() => { clearNativeChatComposerDraftsForTests() + clearNativeChatPendingAttachmentsForTests() }) // The host moves the chat before the command reply lands. @@ -78,7 +85,7 @@ async function clearHarness() { }) ) - return { send: result.current, reply: (accepted: boolean) => reply(accepted) } + return { transport, send: result.current, reply: (accepted: boolean) => reply(accepted) } } it('moves only what was typed after /clear when the chat moves before the reply', async () => { @@ -125,3 +132,19 @@ it('leaves the new composer empty when clear succeeds with no following input', expect(readNativeChatComposerDraft(to)).toMatchObject({ text: '', images: [] }) expect(readNativeChatComposerDraft(from)).toMatchObject({ text: '', images: [] }) }) + +it('does not restore a refused clear beside a pending-only image and sends that image next', async () => { + const { send, reply, transport } = await clearHarness() + const sent = send('/clear') + addNativeChatPendingAttachment(from, { id: 'new-image', path: '', pending: true }) + reply(false) + await sent + expect(readNativeChatComposerDraft(from).text).toBe('') + expect(nativeChatPendingAttachmentSnapshot(from)).toHaveLength(1) + settleNativeChatPendingAttachment(from, 'new-image', '/stored/new-image.png') + const next = readNativeChatComposerDraft(from) + await send(next.text, next.images) + expect(transport.send).toHaveBeenCalledWith('', [ + { id: 'new-image', path: '/stored/new-image.png' } + ]) +}) diff --git a/src/renderer/src/components/native-chat/use-native-chat-structured-composer-send.ts b/src/renderer/src/components/native-chat/use-native-chat-structured-composer-send.ts index 2eeaa2c156c4..43e02156207b 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-structured-composer-send.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-structured-composer-send.ts @@ -19,6 +19,7 @@ import { readNativeChatComposerDraft, updateNativeChatComposerDraft } from './native-chat-composer-draft-store' +import { nativeChatPendingAttachmentSnapshot } from './native-chat-pending-attachment-cache' import { nativeChatComposerDraftLeftAfterSend } from './native-chat-composer-draft-comparison' import type { NativeChatComposerDraft } from './native-chat-composer-draft-storage' @@ -104,7 +105,12 @@ export function useNativeChatStructuredComposerSend({ } const restoreCommand = (): void => { const current = readNativeChatComposerDraft(draftScopeKey) - if (clearedAtSubmit && current.text === '' && current.images.length === 0) { + if ( + clearedAtSubmit && + current.text === '' && + current.images.length === 0 && + nativeChatPendingAttachmentSnapshot(draftScopeKey).length === 0 + ) { updateNativeChatComposerDraft(draftScopeKey, submitted, 'immediate') setDraft(submitted.text) setCaret(submitted.text.length) From 840a915dfa3621b4fbb93e658a1adf66d252f9fd Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 7 Oct 2026 20:16:02 -0700 Subject: [PATCH 8/9] fix(native-chat): settle pending uploads into their owning draft --- .../native-chat/native-chat-draft-cache.ts | 9 +- ...tive-chat-pending-attachment-cache.test.ts | 20 +++ .../native-chat-pending-attachment-cache.ts | 58 ++++++- .../native-chat-session-attachment-drop.ts | 8 +- ...d-agent-session-draft-file-upload.test.tsx | 138 +++++++++++++++ ...ent-session-draft-upload-lifetime.test.tsx | 162 ++++++++++++++++++ ...-native-chat-composer-attachments.test.tsx | 23 ++- .../use-native-chat-composer-attachments.ts | 20 +-- ...-native-chat-external-attachments.test.tsx | 13 +- .../use-native-chat-paste-lifetime.ts | 11 +- 10 files changed, 412 insertions(+), 50 deletions(-) create mode 100644 src/renderer/src/components/native-chat/structured-agent-session-draft-file-upload.test.tsx create mode 100644 src/renderer/src/components/native-chat/structured-agent-session-draft-upload-lifetime.test.tsx diff --git a/src/renderer/src/components/native-chat/native-chat-draft-cache.ts b/src/renderer/src/components/native-chat/native-chat-draft-cache.ts index 8d086d5ac35f..f4f7eee53bd8 100644 --- a/src/renderer/src/components/native-chat/native-chat-draft-cache.ts +++ b/src/renderer/src/components/native-chat/native-chat-draft-cache.ts @@ -1,5 +1,6 @@ import type { NativeChatComposerImageAttachment } from './NativeChatComposerField' import type { JSONContent } from '@tiptap/react' +import type { NativeChatComposerDraftOwner } from './native-chat-composer-draft-storage' // The composer's in-progress draft text and its editor document, keyed by the same stable pane // scope as image attachments. The composer unmounts when the pane toggles back to the hosted // terminal, so without this the typed-but-unsent draft would be lost on every TUI/GUI round-trip. @@ -40,13 +41,17 @@ const appendListeners = new Map /** Puts text back after whatever is typed, and tells a mounted composer to show it. True once it * is durable, so the copy it came from may go. */ -export function appendNativeChatDraftCache(scopeKey: string, text: string): boolean { +export function appendNativeChatDraftCache( + scopeKey: string, + text: string, + owner?: NativeChatComposerDraftOwner +): boolean { if (text === '') { return true } const previous = readNativeChatDraftCache(scopeKey) // Durable now: the copy it came from (a send handed back, a queued card) goes right after this. - const durable = appendToNativeChatComposerDraft(scopeKey, { text }) + const durable = appendToNativeChatComposerDraft(scopeKey, { text }, owner) appendListeners.get(scopeKey)?.forEach((listener) => listener(text, previous)) return durable } diff --git a/src/renderer/src/components/native-chat/native-chat-pending-attachment-cache.test.ts b/src/renderer/src/components/native-chat/native-chat-pending-attachment-cache.test.ts index 3dccd946f89b..d689b9881f67 100644 --- a/src/renderer/src/components/native-chat/native-chat-pending-attachment-cache.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-pending-attachment-cache.test.ts @@ -7,6 +7,7 @@ import { nativeChatPendingAttachmentSnapshot, revealNativeChatPendingAttachment, settleNativeChatPendingAttachment, + settleNativeChatPendingAttachmentReferences, subscribeToNativeChatPendingAttachments, takeNativeChatPendingAttachment } from './native-chat-pending-attachment-cache' @@ -89,6 +90,25 @@ describe('the pane pending attachment cache', () => { expect(readNativeChatComposerDraft(conversation).images).toEqual([]) }) + it('keeps reference settlement owned by the workspace after its chat closes', () => { + const conversation = structuredAgentSessionDraftScopeKey('session-1') + let open = true + setNativeChatComposerDraftOwnerResolver((scopeKey) => + open && scopeKey === conversation ? OWNER : undefined + ) + addPending(conversation, 'file') + open = false + + settleNativeChatPendingAttachmentReferences(conversation, [ + { id: 'file', path: '/store/a.pdf' } + ]) + + expect(readNativeChatComposerDraft(conversation).text).toBe('@/store/a.pdf') + expect(nativeChatPendingAttachmentSnapshot(conversation)).toEqual([]) + deleteNativeChatComposerDraftsOwnedBy(OWNER) + expect(readNativeChatComposerDraft(conversation).text).toBe('') + }) + it('drops the chips begun in a removed workspace on that host only', () => { const resolved = new Map([ ['tab-1:leaf', OWNER], diff --git a/src/renderer/src/components/native-chat/native-chat-pending-attachment-cache.ts b/src/renderer/src/components/native-chat/native-chat-pending-attachment-cache.ts index 08e3065b5ad3..1dd2a4dd96a1 100644 --- a/src/renderer/src/components/native-chat/native-chat-pending-attachment-cache.ts +++ b/src/renderer/src/components/native-chat/native-chat-pending-attachment-cache.ts @@ -10,6 +10,8 @@ import { useCallback, useSyncExternalStore } from 'react' import type { NativeChatComposerImageAttachment } from './NativeChatComposerField' import type { NativeChatComposerDraftOwner } from './native-chat-composer-draft-storage' +import { appendNativeChatDraftCache } from './native-chat-draft-cache' +import { formatNativeChatFileReference } from '../../../../shared/agent-image-paste' import { appendToNativeChatComposerDraft, nativeChatDraftScopeTabId, @@ -88,7 +90,7 @@ export function addNativeChatPendingAttachment( } /** Current ownership of an operation, including callbacks held by its earlier composer. */ -function pendingScope(scopeKey: string, id: string): string { +export function nativeChatPendingAttachmentScope(scopeKey: string, id: string): string | undefined { if (nativeChatPendingAttachmentSnapshot(scopeKey).some((chip) => chip.id === id)) { return scopeKey } @@ -97,11 +99,7 @@ function pendingScope(scopeKey: string, id: string): string { return scope } } - return scopeKey -} - -export function nativeChatPendingAttachmentMoved(scopeKey: string, id: string): boolean { - return pendingScope(scopeKey, id) !== scopeKey + return undefined } /** Pending work follows the draft; its original callbacks still address the same operation. */ @@ -128,7 +126,11 @@ export function takeNativeChatPendingAttachment( scopeKey: string, id: string ): NativeChatComposerImageAttachment | undefined { - scopeKey = pendingScope(scopeKey, id) + const scope = nativeChatPendingAttachmentScope(scopeKey, id) + if (scope === undefined) { + return undefined + } + scopeKey = scope const current = nativeChatPendingAttachmentSnapshot(scopeKey) const taken = current.find((attachment) => attachment.id === id) if (taken) { @@ -148,7 +150,11 @@ export function settleNativeChatPendingAttachment( path: string, connectionId?: string | null ): boolean { - scopeKey = pendingScope(scopeKey, id) + const scope = nativeChatPendingAttachmentScope(scopeKey, id) + if (scope === undefined) { + return false + } + scopeKey = scope const owner = pendingOwners.get(scopeKey) if (!takeNativeChatPendingAttachment(scopeKey, id)) { return false @@ -161,9 +167,43 @@ export function settleNativeChatPendingAttachment( return true } +/** Settles references against their operation owners before releasing their pending chips. */ +export function settleNativeChatPendingAttachmentReferences( + scopeKey: string, + references: { id: string; path: string }[], + insertAtCaret?: (paths: string[]) => void +): void { + const batches = new Map< + string, + { paths: string[]; owner: NativeChatComposerDraftOwner | undefined } + >() + for (const { id, path } of references) { + const scope = nativeChatPendingAttachmentScope(scopeKey, id) + if (scope === undefined) { + continue + } + const batch = batches.get(scope) ?? { paths: [], owner: pendingOwners.get(scope) } + if (takeNativeChatPendingAttachment(scope, id)) { + batch.paths.push(path) + batches.set(scope, batch) + } + } + for (const [scope, { paths, owner }] of batches) { + if (scope === scopeKey && insertAtCaret) { + insertAtCaret(paths) + } else { + appendNativeChatDraftCache(scope, paths.map(formatNativeChatFileReference).join(' '), owner) + } + } +} + /** Shows a pending chip that was held out of sight, such as while a server was asked first. */ export function revealNativeChatPendingAttachment(scopeKey: string, id: string): void { - scopeKey = pendingScope(scopeKey, id) + const scope = nativeChatPendingAttachmentScope(scopeKey, id) + if (scope === undefined) { + return + } + scopeKey = scope const current = nativeChatPendingAttachmentSnapshot(scopeKey) if (current.some((attachment) => attachment.id === id && attachment.hidden)) { writePending( diff --git a/src/renderer/src/components/native-chat/native-chat-session-attachment-drop.ts b/src/renderer/src/components/native-chat/native-chat-session-attachment-drop.ts index 0d655d8de8a3..e51be382d945 100644 --- a/src/renderer/src/components/native-chat/native-chat-session-attachment-drop.ts +++ b/src/renderer/src/components/native-chat/native-chat-session-attachment-drop.ts @@ -18,7 +18,7 @@ export type NativeChatPendingAttachmentChips = { drop: (id: string) => boolean /** Adds stored files as `@path` references to the scope's draft, which keeps them through an * input-method composition or a remount. */ - attachReferences: (paths: string[]) => void + attachReferences: (references: { id: string; path: string }[]) => void } /** @@ -86,7 +86,7 @@ export async function attachNativeChatSessionAttachmentPaths(args: { return } const notAttached: { name: string; reason: string }[] = [] - const references: string[] = [] + const references: { id: string; path: string }[] = [] for (const { path, chipId } of pending) { const storedPath = stored.get(path) if (!storedPath) { @@ -100,9 +100,7 @@ export async function attachNativeChatSessionAttachmentPaths(args: { continue } // Other files become `@path` references, as every attach does, unless their chip was removed. - if (args.chips.drop(chipId)) { - references.push(storedPath) - } + references.push({ id: chipId, path: storedPath }) } // Inserting at the caret clears the notice, so what failed is said after. if (references.length > 0) { diff --git a/src/renderer/src/components/native-chat/structured-agent-session-draft-file-upload.test.tsx b/src/renderer/src/components/native-chat/structured-agent-session-draft-file-upload.test.tsx new file mode 100644 index 000000000000..5c84c4597ac3 --- /dev/null +++ b/src/renderer/src/components/native-chat/structured-agent-session-draft-file-upload.test.tsx @@ -0,0 +1,138 @@ +// @vitest-environment happy-dom +import { useRef } from 'react' +import { act, cleanup, renderHook } from '@testing-library/react' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { useNativeChatComposerAttachments } from './use-native-chat-composer-attachments' +import { attachNativeChatSessionAttachmentPaths } from './native-chat-session-attachment-drop' +import { moveStructuredAgentSessionDraft } from './structured-agent-session-draft-move' +import { + clearNativeChatComposerDraftsForTests, + hydrateNativeChatComposerDrafts, + nativeChatComposerDraftWritesSettled, + readNativeChatComposerDraft, + structuredAgentSessionDraftScopeKey as scope, + updateNativeChatComposerDraft +} from './native-chat-composer-draft-store' +import { + createMemoryNativeChatComposerDraftStorage, + setNativeChatComposerDraftStorageForTests +} from './native-chat-composer-draft-storage' +import { + clearNativeChatPendingAttachmentsForTests, + nativeChatPendingAttachmentSnapshot +} from './native-chat-pending-attachment-cache' + +// Only host replies are controlled; pending-chip ownership and upload completion are production code. +vi.mock('@/runtime/runtime-rpc-client', () => ({ + callRuntimeRpc: vi.fn(async () => ({ + runtimeId: 'paired-host', + capabilities: ['agent-session.attachments.v1'] + })) +})) +beforeEach(async () => { + setNativeChatComposerDraftStorageForTests(createMemoryNativeChatComposerDraftStorage()) + await hydrateNativeChatComposerDrafts() +}) +afterEach(async () => { + cleanup() + await nativeChatComposerDraftWritesSettled() + clearNativeChatPendingAttachmentsForTests() + clearNativeChatComposerDraftsForTests() + vi.unstubAllGlobals() +}) +async function uploadFixture(extension: string, removed = false) { + const path = `/srv/agent-session-attachments/u1/report.${extension}` + const sourcePath = `/local/report.${extension}` + let finish = () => {} + vi.stubGlobal('window', { + api: { + fs: { + uploadPathsToAgentSessionAttachments: () => + new Promise((resolve) => { + finish = () => resolve({ uploaded: [{ sourcePath, path }], skipped: [], failed: [] }) + }) + } + } + }) + updateNativeChatComposerDraft(scope('a'), { text: 'next question' }, 'immediate') + const view = renderHook(() => { + const textareaRef = useRef(null) + return useNativeChatComposerAttachments({ + attachmentScopeKey: scope('a'), + allowWithoutTarget: true, + caret: 0, + disabled: false, + isComposing: () => false, + resolveTarget: () => null, + textareaRef, + setCaret: () => {}, + setDraft: (updater) => + updateNativeChatComposerDraft( + scope('a'), + { text: updater(readNativeChatComposerDraft(scope('a')).text) }, + 'immediate' + ), + setNotice: () => {} + }) + }) + let uploaded = Promise.resolve() + act(() => { + uploaded = attachNativeChatSessionAttachmentPaths({ + paths: [sourcePath], + owner: { + kind: 'runtime-session', + environmentId: 'paired', + sessionId: 'a', + pairingRevision: 1 + }, + chips: view.result.current.pendingChips, + isAbandoned: () => false, + ownerStillCurrent: () => true, + setNotice: () => {} + }) + }) + await Promise.resolve() + await Promise.resolve() + const chip = nativeChatPendingAttachmentSnapshot(scope('a'))[0] + if (!chip) { + throw new Error('Expected pending upload chip') + } + expect(chip.pendingName).toBe(`report.${extension}`) + act(() => moveStructuredAgentSessionDraft('a', 'b')) + expect(nativeChatPendingAttachmentSnapshot(scope('b'))).toMatchObject([ + { id: chip.id, pendingName: `report.${extension}` } + ]) + if (removed) { + act(() => view.result.current.removeImageAttachment(chip.id)) + } + view.unmount() + finish() + await uploaded + return { + path, + source: readNativeChatComposerDraft(scope('a')), + target: readNativeChatComposerDraft(scope('b')), + pending: nativeChatPendingAttachmentSnapshot(scope('b')) + } +} +it('a moved paired PDF upload settles its reference into the replacement draft', async () => { + const result = await uploadFixture('pdf') + expect(result.pending).toEqual([]) + expect(result.target.text).toContain(`@${result.path}`) + expect(result.source.text).toBe('') +}) +it('control: a moved paired image upload settles into the replacement draft', async () => { + const result = await uploadFixture('png') + expect(result.pending).toEqual([]) + expect(result.target.text).toBe('next question') + expect(result.target.images).toHaveLength(1) + expect(result.target.images[0].path).toBe(result.path) + expect(result.source.text).toBe('') +}) + +it('does not attach a moved file reference the user removed while uploading', async () => { + const result = await uploadFixture('pdf', true) + expect(result.pending).toEqual([]) + expect(result.target.text).toBe('next question') + expect(result.source.text).toBe('') +}) diff --git a/src/renderer/src/components/native-chat/structured-agent-session-draft-upload-lifetime.test.tsx b/src/renderer/src/components/native-chat/structured-agent-session-draft-upload-lifetime.test.tsx new file mode 100644 index 000000000000..abf962089f7a --- /dev/null +++ b/src/renderer/src/components/native-chat/structured-agent-session-draft-upload-lifetime.test.tsx @@ -0,0 +1,162 @@ +// @vitest-environment happy-dom +import { cleanup, renderHook } from '@testing-library/react' +import { afterEach, beforeEach, expect, it } from 'vitest' +import { useNativeChatPasteLifetime } from './use-native-chat-paste-lifetime' +import { moveStructuredAgentSessionDraft } from './structured-agent-session-draft-move' +import { + clearNativeChatComposerDraftsForTests, + hydrateNativeChatComposerDrafts, + nativeChatComposerDraftWritesSettled, + readNativeChatComposerDraft, + structuredAgentSessionDraftScopeKey as scope, + updateNativeChatComposerDraft +} from './native-chat-composer-draft-store' +import { + createMemoryNativeChatComposerDraftStorage, + setNativeChatComposerDraftStorageForTests +} from './native-chat-composer-draft-storage' +import { + addNativeChatPendingAttachment, + clearNativeChatPendingAttachmentsForTests, + nativeChatPendingAttachmentSnapshot, + settleNativeChatPendingAttachment, + takeNativeChatPendingAttachment +} from './native-chat-pending-attachment-cache' +import type { NativeChatAttachmentOwner } from './native-chat-attachment-upload' + +beforeEach(async () => { + setNativeChatComposerDraftStorageForTests(createMemoryNativeChatComposerDraftStorage()) + await hydrateNativeChatComposerDrafts() +}) +afterEach(async () => { + cleanup() + await nativeChatComposerDraftWritesSettled() + clearNativeChatPendingAttachmentsForTests() + clearNativeChatComposerDraftsForTests() +}) +const owners: Record = { + local: { kind: 'local' }, + ssh: { + kind: 'ssh', + connectionId: 'ssh-1', + worktreePath: '/remote/worktree', + expectedExecutionHostId: 'ssh:ssh-1', + expectedSshTargetId: 'ssh-1', + expectedSshConnectionGeneration: 1 + }, + paired: { kind: 'runtime-session', environmentId: 'paired', sessionId: 'a', pairingRevision: 1 } +} +async function fixture(host: string, reason: string, removed = false, status = 'saved') { + let releaseLoad = () => {} + let loading = Promise.resolve() + if (reason === 'preload') { + clearNativeChatComposerDraftsForTests() + const storage = createMemoryNativeChatComposerDraftStorage() + const held = new Promise((resolve) => { + releaseLoad = resolve + }) + setNativeChatComposerDraftStorageForTests({ + ...storage, + loadAll: async () => { + await held + return storage.loadAll() + } + }) + loading = hydrateNativeChatComposerDrafts() + } + updateNativeChatComposerDraft(scope('a'), { text: 'source history text' }, 'immediate') + if (reason === 'target-text') { + updateNativeChatComposerDraft(scope('b'), { text: 'target input' }, 'immediate') + } + if (reason === 'target-pending') { + addNativeChatPendingAttachment(scope('b'), { id: 'target', path: '', pending: true }) + } + const view = renderHook(() => + useNativeChatPasteLifetime({ + targetKey: 'a', + attachmentScopeKey: scope('a'), + beginPendingImageAttachment: () => 'source', + resolvePendingImageAttachment: (id, path, connectionId) => { + settleNativeChatPendingAttachment(scope('a'), id, path, connectionId) + }, + dropPendingImageAttachment: (id) => { + takeNativeChatPendingAttachment(scope('a'), id) + } + }) + ) + const lifetime = view.result.current + addNativeChatPendingAttachment(scope('a'), { id: 'source', path: '', pending: true }) + const owner = owners[host] + if (!owner) { + throw new Error('Expected upload owner') + } + lifetime.track('source', '', owner) + moveStructuredAgentSessionDraft('a', 'b') + if (removed) { + takeNativeChatPendingAttachment(scope('a'), 'source') + } + const before = nativeChatPendingAttachmentSnapshot(scope('a')).map((chip) => chip.id) + view.unmount() + const after = nativeChatPendingAttachmentSnapshot(scope('a')).map((chip) => chip.id) + const kept = lifetime.keepStoreUploadAfterUnmount('source', { + status, + tempPath: '/stored/image.png' + }) + releaseLoad() + await loading + return { + before, + after, + kept, + source: readNativeChatComposerDraft(scope('a')), + target: readNativeChatComposerDraft(scope('b')) + } +} +it.each( + ['preload', 'target-text', 'target-pending'].flatMap((reason) => + ['local', 'ssh', 'paired'].map((host) => ({ reason, host })) + ) +)('skipped clear preserves $host pending upload in source ($reason)', async ({ host, reason }) => { + const result = await fixture(host, reason) + expect(result.before).toEqual(['source']) + expect(result.after).toEqual(['source']) + expect(result.kept).toBe(true) + expect(result.source.text).toBe('source history text') + expect(result.source.images).toEqual([ + { + id: 'source', + path: '/stored/image.png', + ...(host === 'ssh' ? { connectionId: 'ssh-1' } : {}) + } + ]) + expect(result.target.images).toEqual([]) +}) +it.each(['local', 'ssh'])( + 'control: moved $s upload survives teardown and settles in destination', + async (host) => { + const result = await fixture(host, 'empty') + expect(result.source.text).toBe('') + expect(result.source.images).toEqual([]) + expect(result.target.images).toEqual([ + { + id: 'source', + path: '/stored/image.png', + ...(host === 'ssh' ? { connectionId: 'ssh-1' } : {}) + } + ]) + } +) + +it.each(['local', 'ssh'])('does not settle a removed %s upload after teardown', async (host) => { + const result = await fixture(host, 'target-text', true) + expect(result.kept).toBe(false) + expect(result.source.images).toEqual([]) + expect(result.target.images).toEqual([]) +}) +it.each(['local', 'ssh'])('drops a failed %s upload after teardown', async (host) => { + const result = await fixture(host, 'target-text', false, 'error') + expect(result.kept).toBe(true) + expect(nativeChatPendingAttachmentSnapshot(scope('a'))).toEqual([]) + expect(result.source.images).toEqual([]) + expect(result.target.images).toEqual([]) +}) diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.test.tsx index 5208dfe6e8ac..511008f478f5 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.test.tsx @@ -477,13 +477,14 @@ describe('useNativeChatComposerAttachments', () => { it('keeps an upload that finishes while the composer is unmounted, for when it returns', async () => { const probe = await renderProbe('pty-gone') const chips = probe.latest().pendingChips - const begun: { image?: string | null; removed?: string | null } = {} + const begun: { image?: string | null; removed?: string | null; reference?: string | null } = {} act(() => { begun.image = chips.begin(undefined, 'shot.png') begun.removed = chips.begin(undefined, 'old.png') + begun.reference = chips.begin(undefined, 'notes.pdf') }) - const { image, removed } = begun - if (!image || !removed) { + const { image, removed, reference } = begun + if (!image || !removed || !reference) { throw new Error('expected pending chips') } act(() => probe.latest().removeImageAttachment(removed)) @@ -492,7 +493,7 @@ describe('useNativeChatComposerAttachments', () => { chips.resolve(image, '/srv/agent-session-attachments/u1/shot.png') chips.resolve(removed, '/srv/agent-session-attachments/u2/old.png') - chips.attachReferences(['/srv/agent-session-attachments/u3/notes.pdf']) + chips.attachReferences([{ id: reference, path: '/srv/agent-session-attachments/u3/notes.pdf' }]) expect(readNativeChatAttachmentCache('pty-gone')).toEqual([ { id: image, path: '/srv/agent-session-attachments/u1/shot.png' } @@ -533,9 +534,14 @@ describe('useNativeChatComposerAttachments', () => { it('inserts a stored file at the caret while the composer is showing and not composing', async () => { const probe = await renderProbe('pty-caret', true) - act(() => - probe.latest().pendingChips.attachReferences(['/srv/agent-session-attachments/u6/a.pdf']) - ) + act(() => { + const chips = probe.latest().pendingChips + const id = chips.begin(undefined, 'a.pdf') + if (!id) { + throw new Error('expected pending chip') + } + chips.attachReferences([{ id, path: '/srv/agent-session-attachments/u6/a.pdf' }]) + }) expect(probe.draft()).toBe('@/srv/agent-session-attachments/u6/a.pdf ') expect(readNativeChatDraftCache('pty-caret')).toBe('') act(() => probe.root.unmount()) @@ -555,8 +561,7 @@ describe('useNativeChatComposerAttachments', () => { const id: string = chipId // The upload finishes mid-composition: the reference waits for the composition to settle. act(() => { - expect(chips.drop(id)).toBe(true) - chips.attachReferences(['/srv/agent-session-attachments/u4/notes.pdf']) + chips.attachReferences([{ id, path: '/srv/agent-session-attachments/u4/notes.pdf' }]) }) // A prompt card takes the composer's place before the composition settles. act(() => probe.root.unmount()) diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.ts b/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.ts index 05b6e89d75fd..c8b9e84a872a 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.ts @@ -28,15 +28,12 @@ import { clearNativeChatPendingAttachments, revealNativeChatPendingAttachment, settleNativeChatPendingAttachment, + settleNativeChatPendingAttachmentReferences, takeNativeChatPendingAttachment, useNativeChatPendingAttachments } from './native-chat-pending-attachment-cache' -import { - appendNativeChatAttachmentCache, - appendNativeChatDraftCache -} from './native-chat-draft-cache' +import { appendNativeChatAttachmentCache } from './native-chat-draft-cache' import { useNativeChatComposerAttachmentPreviews } from './use-native-chat-composer-attachment-previews' -import { formatNativeChatFileReference } from '../../../../shared/agent-image-paste' import { createUuidV4 } from '../../../../shared/uuid-v4' export type UseNativeChatComposerAttachmentsArgs = { @@ -242,14 +239,13 @@ export function useNativeChatComposerAttachments({ drop: dropPendingImageAttachment, // At the caret, as every attach does; mid-composition or once the composer is gone, into the // scope's draft, which keeps it until the composition settles or the composer comes back. - attachReferences: (paths) => { - if (mountedRef.current && !isComposing()) { - attachResolvedPaths(paths, null) - return - } - appendNativeChatDraftCache( + attachReferences: (references) => { + settleNativeChatPendingAttachmentReferences( attachmentScopeKey, - paths.map(formatNativeChatFileReference).join(' ') + references, + mountedRef.current && !isComposing() + ? (paths) => attachResolvedPaths(paths, null) + : undefined ) } }), diff --git a/src/renderer/src/components/native-chat/use-native-chat-external-attachments.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-external-attachments.test.tsx index 74d257e5af1a..f92095aaad12 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-external-attachments.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-external-attachments.test.tsx @@ -587,9 +587,9 @@ describe('useNativeChatExternalAttachments', () => { '/srv/agent-session-attachments/u1/shot.png', null ) - expect(chips.drop).toHaveBeenCalledExactlyOnceWith('chip-2') + expect(chips.drop).not.toHaveBeenCalled() expect(chips.attachReferences).toHaveBeenCalledExactlyOnceWith([ - '/srv/agent-session-attachments/u2/notes.md' + { id: 'chip-2', path: '/srv/agent-session-attachments/u2/notes.md' } ]) // The client never reads its own disk for these paths: the server stores the bytes. expect(mocks.stat).not.toHaveBeenCalled() @@ -653,11 +653,11 @@ describe('useNativeChatExternalAttachments', () => { ) ) expect(chips.attachReferences).toHaveBeenCalledExactlyOnceWith([ - '/srv/agent-session-attachments/u1/notes.md' + { id: 'chip-1', path: '/srv/agent-session-attachments/u1/notes.md' } ]) }) - it('does not insert a file whose chip the user removed while it uploaded', async () => { + it('keeps each file operation ID through reference settlement, including removed chips', async () => { const upload = deferred() mocks.uploadNativeChatSessionAttachmentPaths.mockReturnValueOnce(upload.promise) const chips = trackingChips() @@ -681,7 +681,8 @@ describe('useNativeChatExternalAttachments', () => { ) expect(chips.attachReferences).toHaveBeenCalledExactlyOnceWith([ - '/srv/agent-session-attachments/u2/notes.md' + { id: 'chip-1', path: '/srv/agent-session-attachments/u1/report.pdf' }, + { id: 'chip-2', path: '/srv/agent-session-attachments/u2/notes.md' } ]) }) @@ -709,7 +710,7 @@ describe('useNativeChatExternalAttachments', () => { expect(chips.drop).toHaveBeenCalledWith('chip-1') expect(chips.attachReferences).toHaveBeenCalledExactlyOnceWith([ - '/srv/agent-session-attachments/u2/notes.md' + { id: 'chip-2', path: '/srv/agent-session-attachments/u2/notes.md' } ]) // The cause, the size limit here, rides in the same notice. expect(notices).toEqual([ diff --git a/src/renderer/src/components/native-chat/use-native-chat-paste-lifetime.ts b/src/renderer/src/components/native-chat/use-native-chat-paste-lifetime.ts index 5a10749977d5..52275278a7bb 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-paste-lifetime.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-paste-lifetime.ts @@ -1,12 +1,9 @@ import { useCallback, useLayoutEffect, useMemo, useRef } from 'react' import type { NativeChatAttachmentOwner } from './native-chat-attachment-upload' -import { nativeChatPendingAttachmentMoved } from './native-chat-pending-attachment-cache' +import { nativeChatPendingAttachmentScope } from './native-chat-pending-attachment-cache' /** - * The pending chips of pastes this composer started, released when it unmounts or changes target. - * A paste uploading into a paired server's store outlives the composer, which a prompt card - * unmounts: its chip stays pending in the scope's attachment cache, where its result settles for - * the composer's return (`native-chat-pending-attachment-cache.ts`). + * Preview URLs belong to this composer; pending uploads belong to the draft's attachment cache. */ export function useNativeChatPasteLifetime(args: { targetKey?: string @@ -65,7 +62,7 @@ export function useNativeChatPasteLifetime(args: { } if ( lifetime.uploads.get(id)?.kind !== 'runtime-session' && - !(attachmentScopeKey && nativeChatPendingAttachmentMoved(attachmentScopeKey, id)) + !(attachmentScopeKey && nativeChatPendingAttachmentScope(attachmentScopeKey, id)) ) { dropPendingRef.current(id) } @@ -129,7 +126,7 @@ export function useNativeChatPasteLifetime(args: { const owner = lifetime.uploads.get(pendingId) if ( owner?.kind !== 'runtime-session' && - !(attachmentScopeKey && nativeChatPendingAttachmentMoved(attachmentScopeKey, pendingId)) + !(attachmentScopeKey && nativeChatPendingAttachmentScope(attachmentScopeKey, pendingId)) ) { lifetime.uploads.delete(pendingId) return false From 83fece3bdc2df887a38325c2a23854055f2f5447 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 7 Oct 2026 20:46:34 -0700 Subject: [PATCH 9/9] fix(native-chat): preserve picked skills when appending draft text --- ...ative-chat-composer-draft-addition.test.ts | 68 +++++++++++++ .../native-chat-composer-draft-addition.ts | 5 +- .../native-chat-composer-draft-journal.ts | 1 - .../native-chat-composer-draft-store.ts | 6 +- .../native-chat-prompt-document-text.test.ts | 66 +++++++++++++ .../native-chat-prompt-document-text.ts | 59 +++++++++++ .../native-chat-prompt-document.ts | 13 +-- ...d-agent-session-draft-file-upload.test.tsx | 97 ++++++++++++++++--- 8 files changed, 286 insertions(+), 29 deletions(-) create mode 100644 src/renderer/src/components/native-chat/native-chat-prompt-document-text.test.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-prompt-document-text.ts diff --git a/src/renderer/src/components/native-chat/native-chat-composer-draft-addition.test.ts b/src/renderer/src/components/native-chat/native-chat-composer-draft-addition.test.ts index 25782727d406..9a95205d4d07 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-draft-addition.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-composer-draft-addition.test.ts @@ -5,6 +5,7 @@ import type * as DraftCache from './native-chat-draft-cache' import { withNativeChatComposerDraftAddition } from './native-chat-composer-draft-addition' import { createMemoryNativeChatComposerDraftStorage, + type NativeChatComposerDraft, type NativeChatComposerDraftStorage } from './native-chat-composer-draft-storage' @@ -47,6 +48,22 @@ function slowLoading(): { using: NativeChatComposerDraftStorage; land: () => voi } const SCOPE = 'agent-session:s1' +const pickedDraft: NativeChatComposerDraft = { + text: '$review-long', + images: [], + document: { + type: 'doc', + content: [ + { + type: 'paragraph', + content: [ + { type: 'nativeChatSkill', attrs: { token: '$review' } }, + { type: 'text', text: '-long' } + ] + } + ] + } +} beforeEach(() => { localStorage.clear() @@ -115,4 +132,55 @@ describe('native-chat composer draft addition', () => { expect(next.drafts.readNativeChatDraftCache('agent-session:s2')).toBe('please go\n\ngo') expect(storage.drafts.get('agent-session:s2')?.text).toBe('please go\n\ngo') }) + + it('preserves picked nodes while appending literal text and file references', () => { + const result = withNativeChatComposerDraftAddition(pickedDraft, { + text: '$review\n@/report.pdf' + }) + expect(result.text).toBe('$review-long\n\n$review\n@/report.pdf') + expect(result.document?.content).toEqual([ + pickedDraft.document?.content?.[0], + { type: 'paragraph', content: [] }, + { type: 'paragraph', content: [{ type: 'text', text: '$review' }] }, + { type: 'paragraph', content: [{ type: 'text', text: '@/report.pdf' }] } + ]) + }) + + it('keeps the document unchanged for image-only additions and duplicate text replay', () => { + const imageOnly = withNativeChatComposerDraftAddition(pickedDraft, { + images: [{ id: 'image', path: '/image.png' }] + }) + expect(imageOnly.document).toBe(pickedDraft.document) + const first = withNativeChatComposerDraftAddition(pickedDraft, { text: 'go' }) + expect(withNativeChatComposerDraftAddition(first, { text: 'go' }).document).toBe(first.document) + }) + + it('keeps picked nodes when a hand-back waits for the stored draft to load', async () => { + storage.drafts.set(SCOPE, { ...pickedDraft, savedAt: 1 }) + const { using, land } = slowLoading() + const next = await reload({ using, hydrate: false }) + await next.store.waitForNativeChatComposerDrafts(1) + next.drafts.appendNativeChatDraftCache(SCOPE, 'go') + land() + await next.store.waitForNativeChatComposerDrafts(1_000) + await next.store.nativeChatComposerDraftWritesSettled() + const result = next.store.readNativeChatComposerDraft(SCOPE) + expect(result.text).toBe('$review-long\n\ngo') + expect(result.document?.content?.[0]).toEqual(pickedDraft.document?.content?.[0]) + expect(storage.drafts.get(SCOPE)?.document).toEqual(result.document) + }) + + it('replays an unconfirmed append after a crash without flattening picked nodes', async () => { + storage.drafts.set(SCOPE, { ...pickedDraft, savedAt: 1 }) + const crashing = { ...storage, write: () => new Promise(() => {}) } + const crashed = await reload({ using: crashing }) + expect(crashed.drafts.appendNativeChatDraftCache(SCOPE, 'go')).toBe(true) + crashed.store.clearNativeChatComposerDraftsForTests() + const next = await reload() + const result = next.store.readNativeChatComposerDraft(SCOPE) + expect(result.text).toBe('$review-long\n\ngo') + expect(result.document?.content?.[0]).toEqual(pickedDraft.document?.content?.[0]) + await next.store.nativeChatComposerDraftWritesSettled() + expect(storage.drafts.get(SCOPE)?.document).toEqual(result.document) + }) }) diff --git a/src/renderer/src/components/native-chat/native-chat-composer-draft-addition.ts b/src/renderer/src/components/native-chat/native-chat-composer-draft-addition.ts index 76b82af53935..441ce2264cc9 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-draft-addition.ts +++ b/src/renderer/src/components/native-chat/native-chat-composer-draft-addition.ts @@ -3,6 +3,7 @@ // loaded later, or replayed from the journal after a crash. import { appendReturnedDraftText } from '../../../../shared/returned-draft-text' +import { appendPromptDocumentText } from './native-chat-prompt-document-text' import { basename } from '@/lib/path' import type { NativeChatComposerDraft, @@ -25,7 +26,7 @@ export function withNativeChatComposerDraftAddition( draft: NativeChatComposerDraft, addition: NativeChatComposerDraftAddition, options: { once?: boolean } = {} -): Pick { +): Pick { const text = appendReturnedDraftText(draft.text, addition.text ?? '') const images = [...draft.images] for (const { id, path, connectionId } of addition.images ?? []) { @@ -42,5 +43,5 @@ export function withNativeChatComposerDraftAddition( images[placeholder] = image } } - return { text, images } + return { text, images, document: appendPromptDocumentText(draft.document, draft.text, text) } } diff --git a/src/renderer/src/components/native-chat/native-chat-composer-draft-journal.ts b/src/renderer/src/components/native-chat/native-chat-composer-draft-journal.ts index c1b979e36456..dc4a210d6cc6 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-draft-journal.ts +++ b/src/renderer/src/components/native-chat/native-chat-composer-draft-journal.ts @@ -145,7 +145,6 @@ export function replayNativeChatComposerDraftJournal( drafts.set(scopeKey, { ...from, ...next, - ...(next.text === from.text ? {} : { document: undefined }), savedAt: at }) changed.add(scopeKey) diff --git a/src/renderer/src/components/native-chat/native-chat-composer-draft-store.ts b/src/renderer/src/components/native-chat/native-chat-composer-draft-store.ts index fd386d0dbb88..6a4713f70d4d 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-draft-store.ts +++ b/src/renderer/src/components/native-chat/native-chat-composer-draft-store.ts @@ -213,7 +213,7 @@ function withAddition( options: { once?: boolean } = {} ): T { const next = withNativeChatComposerDraftAddition(draft, addition, options) - return { ...draft, ...next, ...(next.text === draft.text ? {} : { document: undefined }) } + return { ...draft, ...next } } /** @@ -230,13 +230,13 @@ export function appendToNativeChatComposerDraft( ): boolean { const before = records.get(scopeKey) const current = readNativeChatComposerDraft(scopeKey) - const { text, images } = withAddition(current, addition) + const { text, images, document } = withAddition(current, addition) updateNativeChatComposerDraft( scopeKey, { text, images, - ...(text === current.text ? {} : { document: undefined }), + document, ...(owner ? { owner } : {}) }, 'immediate', diff --git a/src/renderer/src/components/native-chat/native-chat-prompt-document-text.test.ts b/src/renderer/src/components/native-chat/native-chat-prompt-document-text.test.ts new file mode 100644 index 000000000000..24f3056c1ee4 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-prompt-document-text.test.ts @@ -0,0 +1,66 @@ +import { expect, it } from 'vitest' +import { withNativeChatComposerDraftAddition } from './native-chat-composer-draft-addition' +import type { NativeChatComposerDraft } from './native-chat-composer-draft-storage' + +it.each(['spaces', 'hard breaks', 'empty paragraphs'])( + 'trims trailing %s while keeping picked skills and earlier paragraphs', + (trailing) => { + const first = { type: 'paragraph', content: [{ type: 'text', text: 'first' }] } + const picked = { type: 'nativeChatSkill', attrs: { token: '$review' } } + const draft: NativeChatComposerDraft = { + text: `first\n$review${trailing === 'spaces' ? ' ' : '\n\n'}`, + images: [], + document: { + type: 'doc', + content: [ + first, + { + type: 'paragraph', + content: [ + picked, + ...(trailing === 'spaces' + ? [{ type: 'text', text: ' ' }] + : trailing === 'hard breaks' + ? [{ type: 'hardBreak' }, { type: 'hardBreak' }] + : []) + ] + }, + ...(trailing === 'empty paragraphs' + ? [ + { type: 'paragraph', content: [] }, + { type: 'paragraph', content: [] } + ] + : []) + ] + } + } + const result = withNativeChatComposerDraftAddition(draft, { text: 'go' }) + expect(result.text).toBe('first\n$review\n\ngo') + expect(result.document?.content).toEqual([ + first, + { type: 'paragraph', content: [picked] }, + { type: 'paragraph', content: [] }, + { type: 'paragraph', content: [{ type: 'text', text: 'go' }] } + ]) + } +) + +it('replaces an all-whitespace document with literal returned text', () => { + const result = withNativeChatComposerDraftAddition( + { + text: ' \n', + images: [], + document: { + type: 'doc', + content: [ + { type: 'paragraph', content: [{ type: 'text', text: ' ' }] }, + { type: 'paragraph', content: [] } + ] + } + }, + { text: '$review' } + ) + expect(result.document?.content).toEqual([ + { type: 'paragraph', content: [{ type: 'text', text: '$review' }] } + ]) +}) diff --git a/src/renderer/src/components/native-chat/native-chat-prompt-document-text.ts b/src/renderer/src/components/native-chat/native-chat-prompt-document-text.ts new file mode 100644 index 000000000000..6151a994c95d --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-prompt-document-text.ts @@ -0,0 +1,59 @@ +import type { JSONContent } from '@tiptap/react' + +export function promptTextContent(text: string): JSONContent { + return { + type: 'doc', + content: text.split('\n').map((line) => ({ + type: 'paragraph', + content: line ? [{ type: 'text', text: line }] : [] + })) + } +} + +/** Applies an appended text change without replacing existing picked nodes. */ +export function appendPromptDocumentText( + document: JSONContent | undefined, + before: string, + after: string +): JSONContent | undefined { + if (!document || before === after) { + return document + } + const kept = before.trimEnd() + if (kept === '') { + return promptTextContent(after) + } + const blocks = [...(document.content ?? [])] + while (blocks.length > 0) { + const block = blocks.pop() + const content = [...(block?.content ?? [])] + while (content.length > 0) { + const node = content.at(-1) + if (node?.type === 'hardBreak') { + content.pop() + } else if (node?.type === 'text') { + const text = node.text?.trimEnd() ?? '' + if (text === '') { + content.pop() + } else { + content[content.length - 1] = { ...node, text } + break + } + } else { + break + } + } + if (content.length > 0 && block) { + const [first, ...rest] = promptTextContent(after.slice(kept.length)).content ?? [] + return { + ...document, + content: [ + ...blocks, + { ...block, content: [...content, ...(first?.content ?? [])] }, + ...rest + ] + } + } + } + return promptTextContent(after) +} diff --git a/src/renderer/src/components/native-chat/native-chat-prompt-document.ts b/src/renderer/src/components/native-chat/native-chat-prompt-document.ts index 7ed5fd8c1306..96bba1a75fda 100644 --- a/src/renderer/src/components/native-chat/native-chat-prompt-document.ts +++ b/src/renderer/src/components/native-chat/native-chat-prompt-document.ts @@ -1,6 +1,7 @@ import { NativeChatSkillPill } from './NativeChatSkillPill' -import { ReactNodeViewRenderer, Node, type JSONContent } from '@tiptap/react' +import { ReactNodeViewRenderer, Node } from '@tiptap/react' import type { Node as ProseMirrorNode } from '@tiptap/pm/model' +export { promptTextContent } from './native-chat-prompt-document-text' export const NativeChatSkill = Node.create({ name: 'nativeChatSkill', @@ -24,16 +25,6 @@ export const NativeChatSkill = Node.create({ ] }) -export function promptTextContent(text: string): JSONContent { - return { - type: 'doc', - content: text.split('\n').map((line) => ({ - type: 'paragraph', - content: line ? [{ type: 'text', text: line }] : [] - })) - } -} - /** Each boundary maps a plain-text caret to a document position, including atomic skills. */ export function promptTextMap(doc: ProseMirrorNode): { text: string; positions: number[] } { let text = '' diff --git a/src/renderer/src/components/native-chat/structured-agent-session-draft-file-upload.test.tsx b/src/renderer/src/components/native-chat/structured-agent-session-draft-file-upload.test.tsx index 5c84c4597ac3..8e29e5c0182b 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-draft-file-upload.test.tsx +++ b/src/renderer/src/components/native-chat/structured-agent-session-draft-file-upload.test.tsx @@ -1,10 +1,12 @@ // @vitest-environment happy-dom -import { useRef } from 'react' -import { act, cleanup, renderHook } from '@testing-library/react' +import { createRef, useRef } from 'react' +import { act, cleanup, render, renderHook } from '@testing-library/react' import { afterEach, beforeEach, expect, it, vi } from 'vitest' import { useNativeChatComposerAttachments } from './use-native-chat-composer-attachments' import { attachNativeChatSessionAttachmentPaths } from './native-chat-session-attachment-drop' import { moveStructuredAgentSessionDraft } from './structured-agent-session-draft-move' +import { NativeChatPromptEditor } from './NativeChatPromptEditor' +import type { NativeChatComposerInput } from './native-chat-composer-input' import { clearNativeChatComposerDraftsForTests, hydrateNativeChatComposerDrafts, @@ -29,7 +31,9 @@ vi.mock('@/runtime/runtime-rpc-client', () => ({ capabilities: ['agent-session.attachments.v1'] })) })) +let previousApi: typeof window.api beforeEach(async () => { + previousApi = window.api setNativeChatComposerDraftStorageForTests(createMemoryNativeChatComposerDraftStorage()) await hydrateNativeChatComposerDrafts() }) @@ -38,14 +42,19 @@ afterEach(async () => { await nativeChatComposerDraftWritesSettled() clearNativeChatPendingAttachmentsForTests() clearNativeChatComposerDraftsForTests() - vi.unstubAllGlobals() + window.api = previousApi }) -async function uploadFixture(extension: string, removed = false) { +async function uploadFixture( + extension: string, + { removed = false, withSkill = false, move = true } = {} +) { const path = `/srv/agent-session-attachments/u1/report.${extension}` const sourcePath = `/local/report.${extension}` let finish = () => {} - vi.stubGlobal('window', { - api: { + Object.defineProperty(window, 'api', { + configurable: true, + writable: true, + value: { fs: { uploadPathsToAgentSessionAttachments: () => new Promise((resolve) => { @@ -54,7 +63,29 @@ async function uploadFixture(extension: string, removed = false) { } } }) - updateNativeChatComposerDraft(scope('a'), { text: 'next question' }, 'immediate') + updateNativeChatComposerDraft( + scope('a'), + { + text: withSkill ? '$review-long' : 'next question', + ...(withSkill + ? { + document: { + type: 'doc', + content: [ + { + type: 'paragraph', + content: [ + { type: 'nativeChatSkill', attrs: { token: '$review' } }, + { type: 'text', text: '-long' } + ] + } + ] + } + } + : {}) + }, + 'immediate' + ) const view = renderHook(() => { const textareaRef = useRef(null) return useNativeChatComposerAttachments({ @@ -98,8 +129,11 @@ async function uploadFixture(extension: string, removed = false) { throw new Error('Expected pending upload chip') } expect(chip.pendingName).toBe(`report.${extension}`) - act(() => moveStructuredAgentSessionDraft('a', 'b')) - expect(nativeChatPendingAttachmentSnapshot(scope('b'))).toMatchObject([ + const targetScope = scope(move ? 'b' : 'a') + if (move) { + act(() => moveStructuredAgentSessionDraft('a', 'b')) + } + expect(nativeChatPendingAttachmentSnapshot(targetScope)).toMatchObject([ { id: chip.id, pendingName: `report.${extension}` } ]) if (removed) { @@ -110,9 +144,10 @@ async function uploadFixture(extension: string, removed = false) { await uploaded return { path, + targetScope, source: readNativeChatComposerDraft(scope('a')), - target: readNativeChatComposerDraft(scope('b')), - pending: nativeChatPendingAttachmentSnapshot(scope('b')) + target: readNativeChatComposerDraft(targetScope), + pending: nativeChatPendingAttachmentSnapshot(targetScope) } } it('a moved paired PDF upload settles its reference into the replacement draft', async () => { @@ -131,8 +166,46 @@ it('control: a moved paired image upload settles into the replacement draft', as }) it('does not attach a moved file reference the user removed while uploading', async () => { - const result = await uploadFixture('pdf', true) + const result = await uploadFixture('pdf', { removed: true }) expect(result.pending).toEqual([]) expect(result.target.text).toBe('next question') expect(result.source.text).toBe('') }) + +it.each([ + { extension: 'pdf', move: true }, + { extension: 'png', move: true }, + { extension: 'pdf', move: false }, + { extension: 'png', move: false } +])( + 'preserves picked skills after $extension upload with move=$move', + async ({ extension, move }) => { + const result = await uploadFixture(extension, { withSkill: true, move }) + expect(result.pending).toEqual([]) + if (move) { + expect(result.source.text).toBe('') + } + const inputRef = createRef() + const view = render( + {}} + onSelect={() => {}} + /> + ) + expect(inputRef.current?.value).toContain('$review-long') + if (extension === 'pdf') { + expect(inputRef.current?.value).toContain(`@${result.path}`) + } else { + expect(result.target.images[0]?.path).toBe(result.path) + } + await vi.waitFor(() => { + expect(view.container.querySelectorAll('[data-native-chat-skill]')).toHaveLength(1) + expect(view.container.querySelector('[data-native-chat-skill]')?.textContent).toBe('Review') + }) + } +)