diff --git a/apps/desktop/e2e/session-workbar.spec.ts b/apps/desktop/e2e/session-workbar.spec.ts index 777767e19a..f6bee3f5ca 100644 --- a/apps/desktop/e2e/session-workbar.spec.ts +++ b/apps/desktop/e2e/session-workbar.spec.ts @@ -401,6 +401,14 @@ test('Side Chat survives collapse, confirms close, and cleans up on source switc const companion = page.locator('.maka-quote-companion'); await expect(companion).toBeVisible(); + + // The companion forks lazily on the first send, not when the panel opens. + const sideComposer = companion.locator(COMPOSER_INPUT); + await sideComposer.fill('inspect this source without changing it'); + await sideComposer.press('Enter'); + await expect(companion).toContainText( + 'Fake backend received: inspect this source without changing it', + ); const firstForkId = await waitForCompanionForkId(page, sessionId); await expect(sidebar.locator(`[data-session-id=${JSON.stringify(firstForkId)}]`)).toHaveCount(0); @@ -416,13 +424,6 @@ test('Side Chat survives collapse, confirms close, and cleans up on source switc await page.getByRole('button', { name: '展开任务工作栏' }).click(); await expect(companion).toBeVisible(); - const sideComposer = companion.locator(COMPOSER_INPUT); - await sideComposer.fill('inspect this source without changing it'); - await sideComposer.press('Enter'); - await expect(companion).toContainText( - 'Fake backend received: inspect this source without changing it', - ); - const workbarToolbar = page.getByRole('toolbar', { name: '任务工作栏标签' }).first(); const closeActiveSideChat = () => workbarToolbar @@ -449,6 +450,13 @@ test('Side Chat survives collapse, confirms close, and cleans up on source switc await expect(page.getByRole('list', { name: '打开工具' })).toBeVisible(); await openSideChat.click(); await expect(companion).toBeVisible(); + // Fork again on the reopened panel's first send. + const reopenedComposer = companion.locator(COMPOSER_INPUT); + await reopenedComposer.fill('inspect once more before switching away'); + await reopenedComposer.press('Enter'); + await expect(companion).toContainText( + 'Fake backend received: inspect once more before switching away', + ); const secondForkId = await waitForCompanionForkId(page, sessionId); await sidebar.getByRole('button', { name: '新任务', exact: true }).click(); diff --git a/apps/desktop/src/main/__tests__/permission-response-ipc-boundary.test.ts b/apps/desktop/src/main/__tests__/permission-response-ipc-boundary.test.ts index 86b0bac861..5b186c081d 100644 --- a/apps/desktop/src/main/__tests__/permission-response-ipc-boundary.test.ts +++ b/apps/desktop/src/main/__tests__/permission-response-ipc-boundary.test.ts @@ -92,6 +92,11 @@ describe('permission response IPC boundary', () => { sourceTurnId: 'turn-2', turnId: 'turn-3', }); + // A through-turn branch keeps its sourceTurnId; a spurious copyId is dropped. + assert.deepEqual( + normalizeBranchFromTurnInput({ sourceTurnId: 'turn-legacy', copyId: 'ignored-here' }), + { sourceTurnId: 'turn-legacy' }, + ); assert.deepEqual( normalizeBranchFromTurnInput({ sourceTurnId: 'turn-3', @@ -99,7 +104,16 @@ describe('permission response IPC boundary', () => { sideConversation: true, ignored: 1, }), - { sourceTurnId: 'turn-3', name: 'Branch name', sideConversation: true }, + { + sourceTurnId: 'turn-3', + name: 'Branch name', + sideConversation: true, + }, + ); + // An empty side-conversation branch omits sourceTurnId entirely. + assert.deepEqual( + normalizeBranchFromTurnInput({ sideConversation: true }), + { sideConversation: true }, ); assert.deepEqual( normalizeRuntimeHostBranchFromTurnInput({ @@ -130,11 +144,23 @@ describe('permission response IPC boundary', () => { const invalidActions: Array<() => unknown> = [ () => normalizeRegenerateTurnInput({ sourceTurnId: 'turn-1', turnId: 1 }), - () => normalizeBranchFromTurnInput({ sourceTurnId: 'turn-1', name: 1 }), - () => normalizeBranchFromTurnInput({ sourceTurnId: 'turn-1', sideConversation: 'yes' }), - () => normalizeBranchFromTurnInput({ sourceTurnId: 'x'.repeat(129) }), + () => + normalizeBranchFromTurnInput({ + sourceTurnId: 'turn-1', + name: 1, + }), + () => + normalizeBranchFromTurnInput({ + sourceTurnId: 'turn-1', + sideConversation: 'yes', + }), + () => + normalizeBranchFromTurnInput({ sourceTurnId: 'x'.repeat(129) }), () => normalizeReviseBeforeTurnInput({ sourceTurnId: 1 }), - () => normalizeRuntimeHostBranchFromTurnInput({ sourceTurnId: 'turn-1' }), + () => + normalizeRuntimeHostBranchFromTurnInput({ + sourceTurnId: 'turn-1', + }), () => normalizeRuntimeHostReviseBeforeTurnInput({ sourceTurnId: 'turn-1', copyId: '' }), ]; for (const action of invalidActions) assert.throws(action, /Invalid/); diff --git a/apps/desktop/src/main/__tests__/quote-companion-disposal.test.ts b/apps/desktop/src/main/__tests__/quote-companion-disposal.test.ts index fe15499fd0..625380d509 100644 --- a/apps/desktop/src/main/__tests__/quote-companion-disposal.test.ts +++ b/apps/desktop/src/main/__tests__/quote-companion-disposal.test.ts @@ -20,7 +20,10 @@ import { deferred } from '@maka/core/test-only/async-primitives'; import { strict as assert } from 'node:assert'; import { afterEach, describe, it } from 'node:test'; -import type { SessionSummary, TurnRecord } from '@maka/core/session'; +import type { + SessionSummary, + TurnRecord, +} from '@maka/core/session'; import { abandonPendingCompanionCopy, createFakeWorkbarServices, diff --git a/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts b/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts index ca798394e8..d2e4f29224 100644 --- a/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts +++ b/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts @@ -26,7 +26,11 @@ import { createRoot, type Root } from 'react-dom/client'; import type { SessionEvent } from '@maka/core/events'; import type { ChatModelChoice } from '@maka/core/chat-model-choice'; import type { PermissionMode } from '@maka/core/permission'; -import type { SessionChangedEvent, SessionSummary, TurnRecord } from '@maka/core/session'; +import type { + SessionChangedEvent, + SessionSummary, + TurnRecord, +} from '@maka/core/session'; import { createFakeWorkbarServices, useQuoteCompanion, @@ -54,37 +58,6 @@ type SideChatStopTarget = Parameters[1]; type QueueUpdate = Extract; type QueueEntry = NonNullable[number]; -test('declining Full access through the side-chat hook does not persist the permission mode', async () => { - let confirmations = 0; - let writes = 0; - let setPermissionMode!: (mode: PermissionMode) => Promise; - - const { container } = await renderProbe( - { - setPermissionMode: async (sessionId, mode) => { - writes += 1; - return session(sessionId, { permissionMode: mode }); - }, - }, - { - confirmBypass: async () => { - confirmations += 1; - return false; - }, - onSetPermissionMode: (setter) => { - setPermissionMode = setter; - }, - }, - ); - - assert.ok(container.firstElementChild); - const result = await act(async () => setPermissionMode('bypass')); - - assert.equal(result, false); - assert.equal(confirmations, 1); - assert.equal(writes, 0); -}); - function completeEvent(id: string, turnId: string, ts: number): SessionEvent { return { type: 'complete', id, turnId, ts, stopReason: 'end_turn' }; } @@ -161,7 +134,7 @@ async function renderProbe( onSend?: (send: (text: string) => Promise) => void; onSteer?: (steer: (text: string) => Promise) => void; onStop?: (stop: () => Promise) => void; - onSetPermissionMode?: (setPermissionMode: (mode: PermissionMode) => Promise) => void; + onSetPermissionMode?: (set: (mode: PermissionMode) => Promise) => void; confirmBypass?: () => Promise; pendingQuotes?: readonly StagedCompanionQuote[]; onQuotesConsumed?: (snapshot: CompanionQuoteSnapshot) => void; @@ -185,6 +158,7 @@ async function renderProbe( onSend: options.onSend ?? (() => undefined), onSteer: options.onSteer, onStop: options.onStop, + onSetPermissionMode: options.onSetPermissionMode, pendingQuotes: options.pendingQuotes, onQuotesConsumed: options.onQuotesConsumed, sourceSession: options.sourceSession, @@ -203,8 +177,9 @@ async function renderProbe( }); await waitUntil( () => - options.ready?.(container) ?? - container.firstElementChild?.getAttribute('data-companion-id') === 'side-conversation', + // The fork is created lazily on the first send, so mounting no longer + // produces a companion. Default readiness is just "the probe mounted". + options.ready?.(container) ?? container.firstElementChild != null, ); return { container, root, services }; } @@ -221,6 +196,7 @@ async function renderOwnershipProbe( let send!: (text: string) => Promise; let steer!: (text: string) => Promise; let stop!: () => Promise; + let setPermissionMode!: (mode: PermissionMode) => Promise; let eventHandler: ((event: SessionEvent) => void) | undefined; const subscribeEvents = sideChat.subscribeEvents; const rendered = await renderProbe( @@ -240,6 +216,7 @@ async function renderOwnershipProbe( onSend: (value) => (send = value), onSteer: (value) => (steer = value), onStop: (value) => (stop = value), + onSetPermissionMode: (value) => (setPermissionMode = value), ...options, }, ); @@ -248,6 +225,7 @@ async function renderOwnershipProbe( send: (text: string) => send(text), steer: (text: string) => steer(text), stop: () => stop(), + setPermissionMode: (mode: PermissionMode) => setPermissionMode(mode), emit(event: SessionEvent) { assert.ok(eventHandler); eventHandler(event); @@ -330,93 +308,76 @@ afterEach(async () => { Object.assign(globalThis, originalGlobals); }); -test('retries a busy Side Conversation at the newest settled boundary and clears its banner', async () => { - let listCount = 0; - let sessionChange: ((event: SessionChangedEvent) => void) | undefined; - let releaseRetry: (() => void) | undefined; - const branchInputs: Array<{ sourceTurnId: string; copyId: string }> = []; - const { container } = await renderProbe( - { - listTurns: async () => { - listCount += 1; - return listCount === 1 - ? [settledTurn('turn-before-busy')] - : [settledTurn('turn-before-busy'), settledTurn('turn-after-busy')]; - }, - branchFromTurn: async (_sessionId, input) => { - branchInputs.push({ sourceTurnId: input.sourceTurnId, copyId: input.copyId }); - if (branchInputs.length === 1) { - return { ok: false as const, reason: 'session_busy' as const }; - } - await new Promise((resolve) => { - releaseRetry = resolve; - }); - return { ok: true as const, session: session('side-conversation') }; - }, - subscribeSessionChanges: (handler) => { - sessionChange = handler; - return () => { - if (sessionChange === handler) sessionChange = undefined; - }; - }, +test('first send while the source is still on its first turn forks with an empty context', async () => { + const branchInputs: (string | undefined)[] = []; + const rendered = await renderOwnershipProbe({ + // The panel opens while the main session is still running its first turn: + // no completed turn exists to branch from yet. + listTurns: async () => [runningTurn('first-turn')], + branchFromTurn: async (_sessionId, input) => { + branchInputs.push(input.sourceTurnId); + return { ok: true as const, session: session('side-conversation') }; }, - { ready: () => branchInputs.length === 1 && sessionChange !== undefined }, - ); - assert.match(container.textContent, /main conversation or a linked task is still running/i); - const probe = container.firstElementChild; + send: async () => ({ ok: true as const, turnId: 'empty-first-turn' }), + }); + const probe = rendered.container.firstElementChild; assert.ok(probe); + // No eager fork at mount — the composer is immediately usable and nothing is + // branched until the user sends. + assert.equal(branchInputs.length, 0); + assert.equal(probe.getAttribute('data-companion-id'), ''); await act(async () => { - sessionChange?.({ - reason: 'turn-status-change', - sessionId: 'source-session', - turnId: 'turn-after-busy', - ts: Date.now(), - }); + assert.equal(await rendered.send('explain the running turn'), true); await Promise.resolve(); }); - await waitUntil(() => branchInputs.length === 2 && releaseRetry !== undefined); - assert.equal(probe.getAttribute('data-preparing'), 'false'); - assert.match(container.textContent, /main conversation or a linked task is still running/i); + await awaitCompanion(rendered.container); + // Forking mid-first-turn copies no source transcript: an empty context. + assert.deepEqual(branchInputs, [undefined]); + assert.equal(probe.getAttribute('data-error'), ''); +}); + +test('first send after a completed turn forks through the settled turn', async () => { + const branchInputs: (string | undefined)[] = []; + const rendered = await renderOwnershipProbe({ + listTurns: async () => [settledTurn('done-turn')], + branchFromTurn: async (_sessionId, input) => { + branchInputs.push(input.sourceTurnId); + return { ok: true as const, session: session('side-conversation') }; + }, + send: async () => ({ ok: true as const, turnId: 'through-turn' }), + }); + const probe = rendered.container.firstElementChild; + assert.ok(probe); + assert.equal(branchInputs.length, 0); await act(async () => { - releaseRetry?.(); + assert.equal(await rendered.send('explain the finished turn'), true); await Promise.resolve(); }); - await waitUntil( - () => probe.getAttribute('data-companion-id') === 'side-conversation', - () => - `branch inputs: ${JSON.stringify(branchInputs)}; companion: ${probe.getAttribute('data-companion-id')}; error: ${probe.getAttribute('data-error')}`, - ); - - assert.deepEqual( - branchInputs.map(({ sourceTurnId }) => sourceTurnId), - ['turn-before-busy', 'turn-after-busy'], - ); - assert.notEqual(branchInputs[0]?.copyId, branchInputs[1]?.copyId); + await awaitCompanion(rendered.container); + // A settled turn exists, so the fork carries the full context through it. + assert.deepEqual(branchInputs, ['done-turn']); assert.equal(probe.getAttribute('data-error'), ''); }); -test('does not restart foreground setup when the source Session object refreshes', async () => { +test('does not fork on mount or when the source Session object refreshes', async () => { let branchCount = 0; const { container, root, services } = await renderProbe( { listTurns: async () => [settledTurn('settled-turn')], branchFromTurn: async () => { branchCount += 1; - if (branchCount === 1) { - return { ok: false as const, reason: 'session_busy' as const }; - } - return await new Promise(() => undefined); + return { ok: true as const, session: session('side-conversation') }; }, }, - { sourceSession: session('source-session'), ready: () => branchCount === 1 }, + { sourceSession: session('source-session') }, ); const probe = container.firstElementChild; assert.ok(probe); - await waitUntil( - () => branchCount === 1 && probe.getAttribute('data-preparing') === 'false', - ); + // Lazy fork: mounting never branches, and the composer is immediately usable. + assert.equal(branchCount, 0); + assert.equal(probe.getAttribute('data-companion-id'), ''); await act(async () => { root.render( @@ -429,14 +390,13 @@ test('does not restart foreground setup when the source Session object refreshes ); await Promise.resolve(); }); - - assert.equal(branchCount, 1); - assert.equal(probe.getAttribute('data-preparing'), 'false'); + // A refreshed source identity must not spuriously trigger a fork. + assert.equal(branchCount, 0); }); -test('waits for the source model to become available before forking', async () => { +test('does not fork or send when the source model is unavailable', async () => { let branchCount = 0; - const { container, root, services } = await renderProbe( + const rendered = await renderOwnershipProbe( { listTurns: async () => [settledTurn('settled-turn')], branchFromTurn: async () => { @@ -444,96 +404,56 @@ test('waits for the source model to become available before forking', async () = return { ok: true as const, session: session('side-conversation') }; }, }, - { - sourceSession: session('source-session'), - modelChoices: [], - ready: (current) => - current.firstElementChild?.getAttribute('data-preparing') === 'false', - }, + { sourceSession: session('source-session'), modelChoices: [] }, ); - const probe = container.firstElementChild; + const probe = rendered.container.firstElementChild; assert.ok(probe); assert.equal(branchCount, 0); - assert.equal(probe.getAttribute('data-companion-id'), ''); - - await rerenderProbeSource({ root, services }, session('source-session')); - await waitUntil(() => probe.getAttribute('data-companion-id') === 'side-conversation'); - - assert.equal(branchCount, 1); -}); - -test('replaces an empty companion whose exact model is no longer available', async () => { - const { sourceA, sourceB, forkB } = exactModelRebindScenario(); - let branchCount = 0; - const cleaned: string[] = []; - const { container, root, services } = await renderProbe( - { - branchFromTurn: async () => { - branchCount += 1; - return { - ok: true as const, - session: branchCount === 1 ? session('side-conversation-a') : forkB, - }; - }, - cleanupSessionCopy: async (sessionId) => { - cleaned.push(sessionId); - }, - }, - { - sourceSession: sourceA, - modelChoices: [choiceFor(sourceA)], - ready: (current) => - current.firstElementChild?.getAttribute('data-companion-id') === - 'side-conversation-a', - }, - ); - const probe = container.firstElementChild; - assert.ok(probe); - - await rerenderProbeSource({ root, services }, sourceB); - await waitUntil(() => probe.getAttribute('data-companion-id') === forkB.id); - assert.equal(branchCount, 2); - assert.deepEqual(cleaned, ['side-conversation-a']); + await act(async () => { + assert.equal(await rendered.send('cannot send without a ready model'), false); + await Promise.resolve(); + }); + // The send is refused before any branch is attempted. + assert.equal(branchCount, 0); + assert.equal(probe.getAttribute('data-companion-id'), ''); }); -test('does not commit a fork whose model becomes unavailable during setup', async () => { +test('cleans up a first-send fork whose model no longer matches on commit', async () => { const source = session('source-session'); - const pendingFork = deferred(); + const mismatchedFork = session('side-conversation', REBOUND_MODEL); const cleaned: string[] = []; let branchCount = 0; - const { container, root, services } = await renderProbe( + const rendered = await renderOwnershipProbe( { + listTurns: async () => [settledTurn('settled-turn')], branchFromTurn: async () => { branchCount += 1; - return { ok: true as const, session: await pendingFork.promise }; + return { ok: true as const, session: mismatchedFork }; }, cleanupSessionCopy: async (sessionId) => { cleaned.push(sessionId); }, }, - { - sourceSession: source, - modelChoices: [choiceFor(source)], - ready: () => branchCount === 1, - }, + { sourceSession: source, modelChoices: [choiceFor(source)] }, ); - const probe = container.firstElementChild; + const probe = rendered.container.firstElementChild; assert.ok(probe); await act(async () => { - root.render(probeTree(services, source, [])); - pendingFork.resolve(session('side-conversation-a')); - await pendingFork.promise; + assert.equal(await rendered.send('the fork model drifted'), false); + await Promise.resolve(); }); - await waitUntil(() => probe.getAttribute('data-preparing') === 'false'); - + await waitUntil(() => cleaned.length === 1); + // The fork committed but its model is no longer authorized, so it is torn + // down instead of being adopted. + assert.equal(branchCount, 1); + assert.deepEqual(cleaned, ['side-conversation']); assert.equal(probe.getAttribute('data-companion-id'), ''); - assert.deepEqual(cleaned, ['side-conversation-a']); }); -test('does not replace a fork while its send waits for observation readiness', async () => { - const { sourceA, sourceB, forkB } = exactModelRebindScenario(); +test('retains a fork whose in-flight send is waiting for observation when the model rebinds', async () => { + const { sourceA, sourceB } = exactModelRebindScenario(); let branchCount = 0; let seedA: (() => void) | undefined; const cleaned: string[] = []; @@ -543,10 +463,7 @@ test('does not replace a fork while its send waits for observation readiness', a { branchFromTurn: async () => { branchCount += 1; - return { - ok: true as const, - session: branchCount === 1 ? session('side-conversation') : forkB, - }; + return { ok: true as const, session: session('side-conversation') }; }, subscribeEvents: (sessionId, _handler, onSeeded) => { if (sessionId === 'side-conversation') seedA = onSeeded; @@ -572,101 +489,31 @@ test('does not replace a fork while its send waits for observation readiness', a await act(async () => { firstSend = currentSend('waiting send'); await Promise.resolve(); + }); + // Let the lazy fork commit and establish its subscription (which then blocks + // the send on observation readiness) before the source model rebinds. + await waitUntil(() => seedA !== undefined); + await act(async () => { rendered.root.render(ownershipProbeTree(rendered.services, sourceB, (send) => { currentSend = send; })); await Promise.resolve(); }); + // An in-flight send holds the submit lock, so the model rebind must not + // implicitly discard or replace the fork it is still waiting on. assert.deepEqual(cleaned, [], 'the send lock must retain its fork'); await act(async () => { seedA?.(); assert.equal(await firstSend, false); }); - assert.deepEqual(sendTargets, ['side-conversation']); const probe = rendered.container.firstElementChild; assert.ok(probe); - await waitUntil(() => probe.getAttribute('data-companion-id') === forkB.id); - assert.deepEqual(cleaned, ['side-conversation']); - assert.equal(branchCount, 2); - - await act(async () => { - assert.equal(await currentSend('retry after rebind'), false); - }); - assert.deepEqual(sendTargets, ['side-conversation', 'side-conversation-b']); -}); - -test('replaces an empty stale fork after its pending admission is retracted', async () => { - const { sourceA, sourceB, forkB } = exactModelRebindScenario(); - const pendingSend = deferred<{ - ok: true; - steered: true; - turnId: string; - messageId: string; - }>(); - let admissionId: string | undefined; - let branchCount = 0; - const cleaned: string[] = []; - let currentSend!: (text: string) => Promise; - const rendered = await renderOwnershipProbe( - { - branchFromTurn: async () => { - branchCount += 1; - return { - ok: true as const, - session: branchCount === 1 ? session('side-conversation') : forkB, - }; - }, - cleanupSessionCopy: async (sessionId) => { - cleaned.push(sessionId); - }, - send: async (_sessionId, command) => { - admissionId = command.turnId; - return pendingSend.promise; - }, - }, - { - sourceSession: sourceA, - modelChoices: [choiceFor(sourceA)], - }, - ); - currentSend = rendered.send; - - let sendResult!: Promise; - await act(async () => { - sendResult = currentSend('pending send'); - await Promise.resolve(); - }); - await waitUntil(() => admissionId !== undefined); - await rerenderOwnershipSource(rendered, sourceB, (send) => { currentSend = send; }); - assert.deepEqual(cleaned, [], 'pending admission must retain its fork'); - - await act(async () => { - rendered.emit({ - type: 'message_admission', - id: 'retracted-after-rebind', - turnId: 'old-turn', - ts: 1, - messageId: admissionId as string, - outcome: 'retracted', - }); - await Promise.resolve(); - }); - const probe = rendered.container.firstElementChild; - assert.ok(probe); - await waitUntil(() => probe.getAttribute('data-companion-id') === forkB.id); - assert.deepEqual(cleaned, ['side-conversation']); - assert.equal(branchCount, 2); - - await act(async () => { - pendingSend.resolve({ - ok: true, - steered: true, - turnId: 'old-turn', - messageId: admissionId as string, - }); - assert.equal(await sendResult, false); - }); + // The one fork was reused for the send and never cleaned up behind it. + assert.deepEqual(sendTargets, ['side-conversation']); + assert.deepEqual(cleaned, []); + assert.equal(branchCount, 1); + assert.equal(probe.getAttribute('data-companion-id'), 'side-conversation'); }); test('retains an admitted fork interrupted before send settles when its model changes', async () => { @@ -768,6 +615,7 @@ test('keeps Side Conversation events owned by the Host-admitted turn across an a sendResult = send('new prompt'); await Promise.resolve(); }); + await awaitProcessing(container); await act(async () => { emit(completeEvent('late-old-terminal', 'old-turn', 1)); @@ -824,6 +672,7 @@ test('binds a busy-raced Side Conversation send through its Host-admitted messag sendResult = send('steer the active turn'); await Promise.resolve(); }); + await waitUntil(() => admissionId !== undefined); await act(async () => { emit(completeEvent('late-old-terminal', 'old-turn', 1)); emit( @@ -953,6 +802,7 @@ test('replays queued Side Conversation text after Host assigns the ticket to a s sendResult = send('continue in the successor turn'); await Promise.resolve(); }); + await waitUntil(() => admissionId !== undefined); await act(async () => { emit( messageAdmittedEvent( @@ -1069,6 +919,7 @@ test('clears a stopped Side Conversation admission when its live retraction is l sendResult = send('stop this queued send'); await Promise.resolve(); }); + await waitUntil(() => admissionId !== undefined); let stopResult!: Promise; await act(async () => { stopResult = stop(); @@ -1198,6 +1049,7 @@ test('releases a queued Side Conversation admission from the Host queue retract' sendResult = send('retract this queued send'); await Promise.resolve(); }); + await awaitProcessing(container); assert.equal(container.firstElementChild?.getAttribute('data-processing'), 'true'); await act(async () => { @@ -1237,7 +1089,6 @@ test('keeps the same Side Conversation admission across a recoverable subscripti }, send: async () => pendingSend.promise, }); - assert.equal(subscriptionCount, 1); let sendResult!: Promise; await act(async () => { @@ -1245,6 +1096,8 @@ test('keeps the same Side Conversation admission across a recoverable subscripti await Promise.resolve(); }); await waitUntil(() => container.firstElementChild?.getAttribute('data-processing') === 'true'); + // The lazy fork subscribes exactly once, when the first send commits it. + assert.equal(subscriptionCount, 1); await act(async () => { emit(recoverableErrorEvent('recoverable-subscription-error', 'old-turn', 1)); await Promise.resolve(); @@ -1481,11 +1334,15 @@ test('fails a send when observation seed rejects and resubscribes for retry', as return { ok: true as const, turnId: 'retry-turn' }; }, }); - assert.ok(rejectSeed); let failedResult!: Promise; await act(async () => { failedResult = send('observer failure'); + await Promise.resolve(); + }); + // The fork subscribes during the first send; fail that observation seed. + await waitUntil(() => rejectSeed !== undefined); + await act(async () => { rejectSeed?.(new Error('observer failed')); assert.equal(await failedResult, false); }); @@ -1534,6 +1391,197 @@ test('releases a send waiting for observation when the Side Conversation is disp mountedRoot = undefined; }); +test('applies a permission mode picked before the first send once the fork is created', async () => { + const permissionCalls: Array<{ sessionId: string; mode: PermissionMode }> = []; + const probe = await renderOwnershipProbe({ + send: async () => ({ ok: true as const, turnId: 'turn-1' }), + setPermissionMode: async (sessionId, mode) => { + permissionCalls.push({ sessionId, mode }); + return { ...session('side-conversation'), permissionMode: mode }; + }, + }); + + // No fork exists yet: the choice is staged and drives the read-only chip. + await act(async () => { + assert.equal(await probe.setPermissionMode('bypass'), true); + await Promise.resolve(); + }); + const el = probe.container.firstElementChild; + assert.equal(el?.getAttribute('data-permission-mode'), 'bypass'); + assert.equal(el?.getAttribute('data-companion-id'), ''); + assert.deepEqual(permissionCalls, []); + + // The first send creates the fork and applies the staged mode to it. + await act(async () => { + assert.equal(await probe.send('first message'), true); + await Promise.resolve(); + }); + await waitUntil(() => permissionCalls.length === 1); + assert.deepEqual(permissionCalls, [{ sessionId: 'side-conversation', mode: 'bypass' }]); +}); + +test('replaces a stale empty fork on the next send after the source model rebinds', async () => { + const { sourceA, sourceB, forkB } = exactModelRebindScenario(); + let rejectSeed: ((error: unknown) => void) | undefined; + let branchCount = 0; + const cleaned: string[] = []; + let currentSend!: (text: string) => Promise; + const rendered = await renderOwnershipProbe( + { + subscribeEvents: (sessionId, _handler, onSeeded, onSeedError) => { + // The first fork's observation seed fails; the replacement seeds fine. + if (sessionId === 'side-conversation') rejectSeed ??= onSeedError; + else onSeeded?.(); + return () => undefined; + }, + branchFromTurn: async () => { + branchCount += 1; + return { + ok: true as const, + session: branchCount === 1 ? session('side-conversation') : forkB, + }; + }, + cleanupSessionCopy: async (sessionId) => { + cleaned.push(sessionId); + }, + send: async () => ({ ok: true as const, turnId: 'turn-1' }), + }, + { sourceSession: sourceA, modelChoices: [choiceFor(sourceA)] }, + ); + currentSend = rendered.send; + + // The first send commits an (empty) fork, then its observation seed fails, so + // the fork is retained with no content. + let failed!: Promise; + await act(async () => { + failed = currentSend('first message'); + await Promise.resolve(); + }); + await waitUntil(() => rejectSeed !== undefined); + await act(async () => { + rejectSeed?.(new Error('seed failed')); + assert.equal(await failed, false); + }); + const el = rendered.container.firstElementChild; + assert.equal(el?.getAttribute('data-companion-id'), 'side-conversation'); + assert.equal(el?.getAttribute('data-model-ready'), 'true'); + + // The source model rebinds; the empty fork's inherited model is now stale but + // the composer stays usable, and the next send must replace the fork rather + // than being wedged by the retained stale one. + await rerenderOwnershipSource(rendered, sourceB, (send) => { + currentSend = send; + }); + assert.equal(el?.getAttribute('data-model-ready'), 'true'); + + await act(async () => { + assert.equal(await currentSend('retry after rebind'), true); + await Promise.resolve(); + }); + await waitUntil( + () => rendered.container.firstElementChild?.getAttribute('data-companion-id') === forkB.id, + ); + assert.equal(branchCount, 2); + assert.deepEqual(cleaned, ['side-conversation']); +}); + +test('fails closed when the staged permission write fails on the first send', async () => { + let sendCalls = 0; + const probe = await renderOwnershipProbe({ + send: async () => { + sendCalls += 1; + return { ok: true as const, turnId: 'turn-1' }; + }, + setPermissionMode: async () => { + throw new Error('permission write failed'); + }, + }); + + // Stage a stricter mode before the fork exists (source default is 'ask'). + await act(async () => { + assert.equal(await probe.setPermissionMode('explore'), true); + await Promise.resolve(); + }); + + // The first send creates the fork; applying the staged mode fails, so the + // send aborts WITHOUT dispatching the turn and keeps the staged choice. + await act(async () => { + assert.equal(await probe.send('do not run under the inherited mode'), false); + await Promise.resolve(); + }); + assert.equal(sendCalls, 0); + assert.equal( + probe.container.firstElementChild?.getAttribute('data-permission-mode'), + 'explore', + ); +}); + +test('replays the empty copy point across an ambiguous retry even after the source settles', async () => { + const sourceTurnIds: (string | undefined)[] = []; + let listCount = 0; + let branchCount = 0; + const probe = await renderOwnershipProbe({ + listTurns: async () => { + listCount += 1; + return listCount === 1 ? [runningTurn('t1')] : [settledTurn('t1')]; + }, + branchFromTurn: async (_sessionId, input) => { + sourceTurnIds.push(input.sourceTurnId); + branchCount += 1; + if (branchCount === 1) throw new Error('ambiguous outcome lost'); + return { ok: true as const, session: session('side-conversation') }; + }, + send: async () => ({ ok: true as const, turnId: 'turn-1' }), + }); + + // First send: only a running turn exists, so the fork is empty — but the + // branch's outcome is lost (throws), leaving the retry lease open. + await act(async () => { + assert.equal(await probe.send('first attempt'), false); + await Promise.resolve(); + }); + // Second send: the source has since settled a turn, but the retry must REPLAY + // the empty copy point (same copyId) instead of switching to through_turn, + // or the Host fingerprint would reject the reused identity. + await act(async () => { + await probe.send('retry'); + await Promise.resolve(); + }); + await waitUntil(() => branchCount === 2); + assert.deepEqual(sourceTurnIds, [undefined, undefined]); +}); + +test('declining Full access through the side-chat hook does not persist the permission mode', async () => { + let confirmations = 0; + let writes = 0; + let setPermissionMode!: (mode: PermissionMode) => Promise; + + const { container } = await renderProbe( + { + setPermissionMode: async (sessionId, mode) => { + writes += 1; + return session(sessionId, { permissionMode: mode }); + }, + }, + { + confirmBypass: async () => { + confirmations += 1; + return false; + }, + onSetPermissionMode: (setter) => { + setPermissionMode = setter; + }, + }, + ); + + assert.ok(container.firstElementChild); + const result = await act(async () => setPermissionMode('bypass')); + + assert.equal(result, false); + assert.equal(confirmations, 1); + assert.equal(writes, 0); +}); + function QuoteCompanionProbe(props: { sourceSession?: SessionSummary; modelChoices?: readonly ChatModelChoice[]; @@ -1554,7 +1602,6 @@ function QuoteCompanionProbe(props: { return createElement('div', { 'data-error': companion.error ?? '', 'data-companion-id': companion.companionSession?.id ?? '', - 'data-preparing': String(companion.preparing), }, companion.error); } @@ -1562,6 +1609,7 @@ function QuoteCompanionOwnershipProbe(props: { onSend: (send: (text: string) => Promise) => void; onSteer?: (steer: (text: string) => Promise) => void; onStop?: (stop: () => Promise) => void; + onSetPermissionMode?: (set: (mode: PermissionMode) => Promise) => void; pendingQuotes?: readonly StagedCompanionQuote[]; onQuotesConsumed?: (snapshot: CompanionQuoteSnapshot) => void; sourceSession?: SessionSummary; @@ -1580,6 +1628,7 @@ function QuoteCompanionOwnershipProbe(props: { props.onSend(companion.send); props.onSteer?.(companion.steer); props.onStop?.(companion.stop); + props.onSetPermissionMode?.(companion.setPermissionMode); return createElement('div', { 'data-companion-id': companion.companionSession?.id ?? '', 'data-error': companion.error ?? '', @@ -1587,6 +1636,8 @@ function QuoteCompanionOwnershipProbe(props: { 'data-live-text': companion.liveTurn?.steps.find((step) => step.text)?.text?.text ?? '', 'data-streaming': String(companion.streaming), 'data-processing': String(companion.processing), + 'data-model-ready': String(companion.modelReady), + 'data-permission-mode': companion.permissionMode ?? '', }); } @@ -1631,6 +1682,10 @@ function settledTurn(turnId: string): TurnRecord { return { turnId, status: 'completed', partialOutputRetained: false }; } +function runningTurn(turnId: string): TurnRecord { + return { turnId, status: 'running', partialOutputRetained: false }; +} + async function waitUntil(predicate: () => boolean, diagnostics?: () => string): Promise { for (let attempt = 0; attempt < 50; attempt += 1) { if (predicate()) return; @@ -1642,3 +1697,16 @@ async function waitUntil(predicate: () => boolean, diagnostics?: () => string): `Timed out waiting for the Side Conversation state${diagnostics ? ` (${diagnostics()})` : ''}`, ); } + +// The fork is created lazily during the first send. Emitting fork events (or +// stopping) before that fork commits would race a not-yet-established +// subscription, so tests await the committed companion first. +async function awaitCompanion(container: Element, id = 'side-conversation'): Promise { + await waitUntil(() => container.firstElementChild?.getAttribute('data-companion-id') === id); +} + +// A live-turn admission is armed only once the send reaches its optimistic +// dispatch, which is a stricter barrier than the fork merely committing. +async function awaitProcessing(container: Element): Promise { + await waitUntil(() => container.firstElementChild?.getAttribute('data-processing') === 'true'); +} diff --git a/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts b/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts index c5b865ed70..1c71e36785 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts @@ -194,7 +194,7 @@ test("retries committed Branch and Revision copies with the renderer-owned ident const calls: Array<{ kind: "branch" | "revision"; targetSessionId: string; - sourceTurnId: string; + sourceTurnId: string | undefined; }> = []; let fallbackIds = 0; const ipc = ipcHarness(); @@ -237,25 +237,23 @@ test("retries committed Branch and Revision copies with the renderer-owned ident { channel: "sessions:branchFromTurn", copyId: "branch-copy-1", - sourceTurnId: "branch-source-turn", + payload: { sourceTurnId: "branch-source-turn", copyId: "branch-copy-1" }, }, { channel: "sessions:reviseBeforeTurn", copyId: "revision-copy-1", - sourceTurnId: "revision-source-turn", + payload: { sourceTurnId: "revision-source-turn", copyId: "revision-copy-1" }, }, ] as const) { await assert.rejects( - ipc.invoke(input.channel, "source-session", { - sourceTurnId: input.sourceTurnId, - copyId: input.copyId, - }), + ipc.invoke(input.channel, "source-session", input.payload), /response was lost/, ); - const retried = (await ipc.invoke(input.channel, "source-session", { - sourceTurnId: input.sourceTurnId, - copyId: input.copyId, - })) as { id: string }; + const retried = (await ipc.invoke( + input.channel, + "source-session", + input.payload, + )) as { id: string }; assert.equal(retried.id, input.copyId); } diff --git a/apps/desktop/src/main/__tests__/workbar-controller.test.ts b/apps/desktop/src/main/__tests__/workbar-controller.test.ts index e3b51fc19d..bde0503b47 100644 --- a/apps/desktop/src/main/__tests__/workbar-controller.test.ts +++ b/apps/desktop/src/main/__tests__/workbar-controller.test.ts @@ -469,9 +469,6 @@ describe('useWorkbarController', () => { await act(async () => controller().commands.toggleRight()); assert.equal(controller().host.quotes?.some((panel) => panel.id === panelId), true); - await act(async () => - controller().host.onPreparingStateChange?.(panelId, false), - ); await act(async () => controller().host.onContentStateChange?.(panelId, true)); const tab = controller().host.panelsState.right.tabs.find( (candidate) => candidate.id === `side-chat:${panelId}`, diff --git a/apps/desktop/src/main/permission-response-guard.ts b/apps/desktop/src/main/permission-response-guard.ts index e29fe05aab..9fc25de20e 100644 --- a/apps/desktop/src/main/permission-response-guard.ts +++ b/apps/desktop/src/main/permission-response-guard.ts @@ -154,8 +154,18 @@ export function normalizeBranchFromTurnInput(input: unknown): BranchFromTurnInpu if (value.sideConversation !== undefined && typeof value.sideConversation !== 'boolean') { throw new Error('Invalid branch sideConversation'); } + // Absent sourceTurnId forks with an empty context (a side conversation opened + // before the source has any settled turn). + const sourceTurnId = + value.sourceTurnId === undefined + ? undefined + : normalizeRequiredString( + value.sourceTurnId, + 'Invalid branch sourceTurnId', + MAX_TURN_ID_LENGTH, + ); return { - sourceTurnId: normalizeRequiredString(value.sourceTurnId, 'Invalid branch sourceTurnId', MAX_TURN_ID_LENGTH), + ...(sourceTurnId === undefined ? {} : { sourceTurnId }), ...(name ? { name } : {}), ...(value.sideConversation === true ? { sideConversation: true } : {}), }; diff --git a/apps/desktop/src/main/runtime-host-desktop-candidate.ts b/apps/desktop/src/main/runtime-host-desktop-candidate.ts index a2f0685983..582e6e9fae 100644 --- a/apps/desktop/src/main/runtime-host-desktop-candidate.ts +++ b/apps/desktop/src/main/runtime-host-desktop-candidate.ts @@ -23,7 +23,10 @@ import type { ActiveInteractionRequestEvent } from '@maka/core/events'; import { redactSecrets } from '@maka/core/redaction'; import type { CreateSessionRequestInput } from '@maka/core/runtime-inputs'; import { isSideConversationSession } from '@maka/core/side-conversation'; -import type { SessionChangedEvent, SessionChangedReason } from '@maka/core/session'; +import type { + SessionChangedEvent, + SessionChangedReason, +} from '@maka/core/session'; import type { BotRegistry } from '@maka/runtime/bots'; import { type RuntimeHostSshOperatorActivationInput, @@ -163,7 +166,7 @@ export interface DesktopRuntimeHostCandidateDeps { sessionId: string; kind: 'branch' | 'revision'; sourceSessionId: string; - sourceTurnId: string; + sourceTurnId?: string; intent?: 'side_conversation'; }) => Promise; }) => SessionCopyCleanupAuthority; @@ -780,7 +783,7 @@ export async function createDesktopRuntimeHostCandidate( await client.copySession(kind, { sourceSessionId, targetSessionId: sessionId, - sourceTurnId, + ...(sourceTurnId === undefined ? {} : { sourceTurnId }), ...(intent ? { intent } : {}), }); }, diff --git a/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts b/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts index 6e21583524..acd6dab4aa 100644 --- a/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts @@ -722,7 +722,9 @@ export function registerRuntimeHostSessionExecutionIpc( deps.client.copySession("branch", { sourceSessionId: sessionId, targetSessionId: normalized.copyId, - sourceTurnId: normalized.sourceTurnId, + ...(normalized.sourceTurnId === undefined + ? {} + : { sourceTurnId: normalized.sourceTurnId }), ...(normalized.sideConversation ? { intent: 'side_conversation' as const } : {}), }); let branch; @@ -733,7 +735,9 @@ export function registerRuntimeHostSessionExecutionIpc( sessionId: normalized.copyId, kind: 'branch', sourceSessionId: sessionId, - sourceTurnId: normalized.sourceTurnId, + ...(normalized.sourceTurnId === undefined + ? {} + : { sourceTurnId: normalized.sourceTurnId }), intent: 'side_conversation', ownerId: bindCopyOwner(event), }, diff --git a/apps/desktop/src/renderer/features/workbar/controller/use-workbar-controller.ts b/apps/desktop/src/renderer/features/workbar/controller/use-workbar-controller.ts index 688248f0a2..77a7fbad4f 100644 --- a/apps/desktop/src/renderer/features/workbar/controller/use-workbar-controller.ts +++ b/apps/desktop/src/renderer/features/workbar/controller/use-workbar-controller.ts @@ -295,7 +295,7 @@ export function useWorkbarController( initialPrompt, newId: () => crypto.randomUUID(), }); - sideConversations.upsertPanel(panel, true); + sideConversations.upsertPanel(panel); layout.openDynamicWorkbarTab( { id: `side-chat:${panel.id}`, @@ -413,7 +413,7 @@ export function useWorkbarController( quote, newId: () => crypto.randomUUID(), }); - sideConversations.upsertPanel(panel, !activePanel); + sideConversations.upsertPanel(panel); const placement = activeSideChat?.placement ?? 'right'; layout.openDynamicWorkbarTab( { @@ -463,17 +463,10 @@ export function useWorkbarController( placement: SessionWorkbarPlacement, tabs: readonly SessionWorkbarTab[], ) => { - const closableTabs = tabs.filter( - (tab) => - tab.kind !== 'side-chat' || - !sideConversations.preparingPanelIds.has( - tab.id.slice('side-chat:'.length), - ), - ); - if (closableTabs.length === 0) return; + if (tabs.length === 0) return; const needsConfirmation = !skipSideChatCloseConfirmation && - closableTabs.some( + tabs.some( (tab) => tab.kind === 'side-chat' && sideConversations.contentPanelIds.has( @@ -482,16 +475,15 @@ export function useWorkbarController( ); if (needsConfirmation) { setPendingSideChatClose( - closableTabs.map((tab) => ({ placement, tab })), + tabs.map((tab) => ({ placement, tab })), ); return; } - closeTabsImmediately(placement, closableTabs); + closeTabsImmediately(placement, tabs); }, [ closeTabsImmediately, sideConversations.contentPanelIds, - sideConversations.preparingPanelIds, skipSideChatCloseConfirmation, ], ); @@ -733,9 +725,7 @@ export function useWorkbarController( ), onForkVisibilityChange, onContentStateChange: sideConversations.setContent, - preparingSideChatPanelIds: sideConversations.preparingPanelIds, activeSideChatPanelIds: sideConversations.activePanelIds, - onPreparingStateChange: sideConversations.setPreparing, onInitialPromptStarted: (panelId) => sideConversations.updatePanel(panelId, (panel) => consumeCompanionInitialPrompt(panel, panelId) ?? panel, diff --git a/apps/desktop/src/renderer/features/workbar/ports.ts b/apps/desktop/src/renderer/features/workbar/ports.ts index 37c4c87fc7..8963776e0e 100644 --- a/apps/desktop/src/renderer/features/workbar/ports.ts +++ b/apps/desktop/src/renderer/features/workbar/ports.ts @@ -222,7 +222,7 @@ export interface SideChatSessionPort { branchFromTurn( sessionId: string, input: { - sourceTurnId: string; + sourceTurnId?: string; name?: string; copyId: string; sideConversation: true; diff --git a/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-core.ts b/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-core.ts index a12512b0fc..734279c28a 100644 --- a/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-core.ts +++ b/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-core.ts @@ -28,7 +28,10 @@ import { import type { PermissionMode } from '@maka/core/permission'; import type { ChatModelChoice } from '@maka/core/chat-model-choice'; import type { QuoteRef, SessionEvent } from '@maka/core/events'; -import type { SessionSummary, TurnRecord } from '@maka/core/session'; +import type { + SessionSummary, + TurnRecord, +} from '@maka/core/session'; import type { UiLocale } from '@maka/core/ui-locale'; import type { SideChatSessionPort, @@ -108,11 +111,26 @@ export interface EnsureCompanionForkDeps { /** The latest successfully completed turn of the source session. * Failed and aborted turns are not reference context: branching through one * makes a fresh side prompt look like a continuation of unfinished parent - * work. If no completed turn exists, the side conversation starts empty. */ + * work. Callers decide whether a missing completed turn is temporary (the + * first turn is still running) or a hard setup failure. */ export function latestSettledTurnId(turns: readonly TurnRecord[]): string | undefined { return [...turns].reverse().find((turn) => turn.status === 'completed')?.turnId; } +/** + * Sentinel boundary stored in the retry lease for an empty copy. Turn ids match + * /^[A-Za-z0-9_-]{1,128}$/, so the NUL-prefixed token can never be one. It lets + * the shared `SessionCopyAttempt.sourceTurnId` stay a required string (revision + * copies still need it) while still representing the absent (empty) source turn + * for a side conversation. + */ +const EMPTY_SOURCE_TURN_SENTINEL = '\0empty'; + +/** Decode a persisted retry-lease boundary into an optional source turn id. */ +function sourceTurnIdFromBoundary(boundary: string): string | undefined { + return boundary === EMPTY_SOURCE_TURN_SENTINEL ? undefined : boundary; +} + function companionCopyAttemptKey( sourceSessionId: string, panelId: string, @@ -234,8 +252,11 @@ export async function ensureCompanionFork( ): Promise { const { api, sourceSession, name, isDisposed } = deps; - // Branch at the latest SETTLED turn (durable), not the last message that - // happens to carry a turnId — so a fork never starts from a mid-flight turn. + // Prefer the latest SETTLED turn (durable) as the branch boundary — a fork + // never starts from a mid-flight turn. When the source has no completed turn + // yet (most visibly the main session's very first turn is still running), the + // side conversation forks with an EMPTY context instead of failing: it + // inherits the source model / cwd / permission but copies no messages. let turns: TurnRecord[]; try { turns = await api.listTurns(sourceSession.id); @@ -243,14 +264,19 @@ export async function ensureCompanionFork( return { status: 'error', code: 'fork_setup_failed' }; } if (isDisposed()) return { status: 'disposed' }; + // The boundary is derived once, persisted in the retry lease, and REPLAYED on + // every retry of the same copyId — never recomputed from the live turns. + // Otherwise an ambiguous first send of an empty copy could retry through a + // settled turn once the source's first turn settled, and the Host fingerprint + // (which includes the source turn) would reject the same copyId. const boundaryTurnId = latestSettledTurnId(turns); - if (!boundaryTurnId) return { status: 'error', code: 'fork_setup_failed' }; + const attemptBoundary = boundaryTurnId ?? EMPTY_SOURCE_TURN_SENTINEL; let created: SessionSummary; try { let copyAttempt = acquireSessionCopyAttempt( companionCopyAttemptKey(sourceSession.id, deps.panelId), - boundaryTurnId, + attemptBoundary, ); if (copyAttempt.phase === 'abandoning') { if (!(await abandonPendingCompanionCopy(api, sourceSession.id, deps.panelId))) { @@ -258,7 +284,7 @@ export async function ensureCompanionFork( } copyAttempt = acquireSessionCopyAttempt( companionCopyAttemptKey(sourceSession.id, deps.panelId), - boundaryTurnId, + attemptBoundary, ); } if ( @@ -270,7 +296,7 @@ export async function ensureCompanionFork( return { status: 'error', code: 'fork_setup_failed' }; } const result = await api.branchFromTurn(sourceSession.id, { - sourceTurnId: copyAttempt.sourceTurnId, + sourceTurnId: sourceTurnIdFromBoundary(copyAttempt.sourceTurnId), name, copyId: copyAttempt.copyId, sideConversation: true, diff --git a/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx b/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx index 616fb2675c..5f50ee231b 100644 --- a/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx +++ b/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx @@ -19,7 +19,6 @@ import { useCallback, useEffect, useRef, type ComponentProps } from 'react'; import { Banner } from '@astryxdesign/core/Banner'; -import { Spinner } from '@astryxdesign/core/Spinner'; import { ChatView, ChatSurfaceLayout, @@ -73,7 +72,6 @@ export function QuoteCompanionPanel(props: { onRemoveQuote?: (target: CompanionQuoteTarget) => void; onForkVisibilityChange?: (event: CompanionForkVisibilityEvent) => void; onContentStateChange?: (panelId: string, hasContent: boolean) => void; - onPreparingStateChange?: (panelId: string, preparing: boolean) => void; onInitialPromptStarted?: (panelId: string) => void; onPromptAccepted?: (panelId: string, prompt: string) => void; onActivityStateChange?: (panelId: string, active: boolean) => void; @@ -110,9 +108,6 @@ export function QuoteCompanionPanel(props: { useEffect(() => { props.onContentStateChange?.(props.panelId, companion.hasContent); }, [companion.hasContent, props.onContentStateChange, props.panelId]); - useEffect(() => { - props.onPreparingStateChange?.(props.panelId, companion.preparing); - }, [companion.preparing, props.onPreparingStateChange, props.panelId]); useEffect(() => { props.onActivityStateChange?.( props.panelId, @@ -125,15 +120,14 @@ export function QuoteCompanionPanel(props: { props.panelId, ]); useEffect(() => { - if (!props.active || companion.preparing) return; + if (!props.active) return; const frame = window.requestAnimationFrame(() => composerRef.current?.focus()); return () => window.cancelAnimationFrame(frame); - }, [companion.preparing, props.active]); + }, [props.active]); useEffect(() => { const prompt = props.initialPrompt?.trim(); if ( !props.active || - companion.preparing || !companion.modelReady || !prompt || initialPromptStartedRef.current @@ -157,7 +151,6 @@ export function QuoteCompanionPanel(props: { composerRef.current?.focus(); }); }, [ - companion.preparing, companion.send, props.active, props.initialPrompt, @@ -209,10 +202,7 @@ export function QuoteCompanionPanel(props: { ); return ( -
+
- -
- ) : ( -