diff --git a/apps/desktop/e2e/workhub-layout.spec.ts b/apps/desktop/e2e/workhub-layout.spec.ts index 6e426cc9ff3..0e97178f003 100644 --- a/apps/desktop/e2e/workhub-layout.spec.ts +++ b/apps/desktop/e2e/workhub-layout.spec.ts @@ -417,7 +417,7 @@ test('WorkHub keeps the submitted prompt visible while its agent is still runnin await expect(prompt).toHaveCount(1); await expect(workhub.locator('.maka-bubble-streaming')).toContainText('Fake backend waiting'); await expect(stop).toBeVisible(); - const followups = workhub.locator('[data-queue-placement="next_turn"] .maka-composer-queue-text'); + const followups = workhub.locator('.maka-composer-queue .maka-composer-queue-text'); const queuedTexts = ['下一轮整理测试结果', '再下一轮补充使用说明'] as const; await workhub.locator(COMPOSER_INPUT).fill(queuedTexts[0]); await workhub.getByRole('button', { name: /^(发送|Send)$/ }).click(); @@ -428,17 +428,6 @@ test('WorkHub keeps the submitted prompt visible while its agent is still runnin await awaitSendReady(workhub); await workhub.locator(COMPOSER_INPUT).press('Enter'); await expect(followups).toHaveText(queuedTexts); - const shortcuts = workhub.getByRole('button', { name: '发送快捷键', exact: true }); - await expect(shortcuts).toHaveCount(1); - await shortcuts.hover(); - const shortcutHint = workhub.getByRole('tooltip'); - const steerModifier = process.platform === 'darwin' ? 'Cmd' : 'Ctrl'; - await expect(shortcutHint).toHaveText(`${steerModifier}+Enter:转向(Steering)\nEnter:下一轮(Follow-up)\nShift+Enter:换行`); - await expect.poll(() => shortcutHint.evaluate((element) => { - const bounds = element.getBoundingClientRect(); - return bounds.left >= 0 && bounds.right <= innerWidth && bounds.top >= 0 && bounds.bottom <= innerHeight; - })).toBe(true); - await workhub.screenshot({ path: testInfo.outputPath('workhub-queue-shortcuts.png') }); for (const text of queuedTexts) { await expect(workhub.locator('.maka-user-message').filter({ hasText: text })).toHaveCount(0); } diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index b2972dd25fb..ffd72e00daa 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -32,7 +32,6 @@ "src/renderer/chat-message-surface.tsx", "src/renderer/chat-recovery-notice.tsx", "src/renderer/command-palette-commands.ts", - "src/renderer/composer-attachments.ts", "src/renderer/composer-defaults.ts", "src/renderer/composer-mentions.tsx", "src/renderer/computer-use-overlay/engine/cursor-candidate-grid.ts", @@ -219,7 +218,6 @@ "src/renderer/features/workbar/tools/side-chat/quote-companion-core.ts -> src/renderer/model-connection-errors", "src/renderer/features/workbar/tools/side-chat/quote-companion-core.ts -> src/renderer/session-copy-attempt", "src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx -> src/renderer/attachment-preflight", - "src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx -> src/renderer/composer-attachments", "src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx -> src/renderer/composer-mentions", "src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx -> src/renderer/scroll-motion-policy", "src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts -> src/renderer/settled-message-merge", @@ -322,7 +320,7 @@ "@maka/ui": 1 }, "importSpecifiers": 10, - "nonTriviaTokens": 3565 + "nonTriviaTokens": 3525 }, "src/renderer/app-shell-chrome-actions.tsx": { "importDeclarations": 1, @@ -595,7 +593,7 @@ "@maka/ui": 1 }, "importSpecifiers": 7, - "nonTriviaTokens": 2603 + "nonTriviaTokens": 2590 }, "src/renderer/app-shell-session-ui-state.ts": { "importDeclarations": 0, @@ -663,11 +661,7 @@ "window.maka.sessions.compact": 1, "window.maka.sessions.listActiveInteractions": 1, "window.maka.sessions.listTurnLandmarks": 1, - "window.maka.sessions.promoteQueueEntry": 1, - "window.maka.sessions.reorderQueueEntries": 1, - "window.maka.sessions.retractQueueEntry": 1, "window.maka.sessions.subscribeActiveInteractions": 1, - "window.maka.sessions.updateQueueEntry": 1, "window.maka.settings.getClient": 1, "window.maka.settings.subscribeClientChanged": 1 }, @@ -693,7 +687,7 @@ "useNewTaskChoice": 1, "useOnboardingSnapshot": 1, "usePlanModeState": 1, - "useRef": 13, + "useRef": 12, "useSessionEventHealthPolling": 1, "useSessionNavigationReads": 1, "useSessionSettingIntent": 1, @@ -792,7 +786,7 @@ "react": 1 }, "importSpecifiers": 92, - "nonTriviaTokens": 12373 + "nonTriviaTokens": 12152 }, "src/renderer/use-app-shell-session-list.ts": { "importDeclarations": 4, @@ -1061,17 +1055,6 @@ "@maka/ui/icons": 1 } }, - "src/renderer/composer-attachments.ts": { - "bridgePaths": {}, - "environmentCapabilities": {}, - "hookCalls": {}, - "lifecycleMethods": {}, - "unresolvedDependencies": 0, - "actionFactories": [], - "dependencyPaths": { - "./features/conversation/index.js": 1 - } - }, "src/renderer/composer-defaults.ts": { "bridgePaths": {}, "environmentCapabilities": {}, diff --git a/apps/desktop/src/main/__tests__/app-shell-attachment-preflight-send.test.ts b/apps/desktop/src/main/__tests__/app-shell-attachment-preflight-send.test.ts index f1cf9a8b04e..448cca4efc9 100644 --- a/apps/desktop/src/main/__tests__/app-shell-attachment-preflight-send.test.ts +++ b/apps/desktop/src/main/__tests__/app-shell-attachment-preflight-send.test.ts @@ -20,7 +20,7 @@ import { MAX_ATTACHMENT_COUNT } from '@maka/core/attachments'; import { strict as assert } from 'node:assert'; import { test } from 'node:test'; -import type { PendingAttachment } from '../../renderer/composer-attachments.js'; +import type { PendingAttachment } from '@maka/ui/composer-attachments'; import { createAppShellChatActions } from '../../renderer/app-shell-chat-actions.js'; import { getShellCopy } from '../../renderer/locales/shell-copy.js'; import { diff --git a/apps/desktop/src/main/__tests__/app-shell-busy-race-settlement.test.ts b/apps/desktop/src/main/__tests__/app-shell-busy-race-settlement.test.ts index 1c7ee3d654b..7aad3b2aab4 100644 --- a/apps/desktop/src/main/__tests__/app-shell-busy-race-settlement.test.ts +++ b/apps/desktop/src/main/__tests__/app-shell-busy-race-settlement.test.ts @@ -80,11 +80,11 @@ describe('busy-raced send settlement', () => { } finally { restoreWindow(); } }); - it('keeps steering pending while the Host admits it', async () => { + it('shows steering in the transcript while the Host admits it', async () => { const transient = new Map(); const restoreWindow = installWindow({ sessions: { submitMessage: async (_sessionId: string, _placement: string, _command: unknown) => { - assert.equal([...transient.values()][0]?.transientPlacement, 'steering'); + assert.equal([...transient.values()][0]?.transientPlacement, 'transcript'); return { ok: true, disposition: 'steering', attachments: [], inlineReferences: [], skillInvocation: EMPTY_SKILL_INVOCATION }; }, } }); @@ -96,7 +96,7 @@ describe('busy-raced send settlement', () => { updateTransientMessage: (_sessionId, message) => transient.set(message.id, message), }); assert.equal(await actions.enqueueMessage('session-a', 'steer', 'current_turn'), true); - assert.equal([...transient.values()][0]?.transientPlacement, 'steering'); + assert.equal([...transient.values()][0]?.transientPlacement, 'transcript'); } finally { restoreWindow(); } }); diff --git a/apps/desktop/src/main/__tests__/app-shell-first-send-cleanup.test.ts b/apps/desktop/src/main/__tests__/app-shell-first-send-cleanup.test.ts index 5f12b8c8973..0b287bcc6ff 100644 --- a/apps/desktop/src/main/__tests__/app-shell-first-send-cleanup.test.ts +++ b/apps/desktop/src/main/__tests__/app-shell-first-send-cleanup.test.ts @@ -38,13 +38,16 @@ import { describe, it } from 'node:test'; import { act, createElement } from 'react'; import type { StoredMessage } from '@maka/core/session'; import { cleanupFakeDom, installReactRenderer } from './fake-dom.js'; -import { useAppShellSessionUiState } from '../../renderer/features/conversation/index.js'; +import { + ConversationServicesProvider, + useAppShellSessionUiState, +} from '../../renderer/features/conversation/index.js'; import { createSessionCatalogController } from '../../renderer/application/contracts/session-catalog/session-catalog-state.js'; -import type { LiveTurnProjection } from '@maka/ui'; +import { LocaleProvider, ToastProvider, type LiveTurnProjection } from '@maka/ui'; import type { DesktopTranscriptRangeController } from '../../renderer/platform/desktop/desktop-transcript-range-store.js'; import { createAppShellChatActions } from '../../renderer/app-shell-chat-actions.js'; -import { prepareTranscriptForSend } from '../../renderer/features/conversation/testing.js'; +import { prepareTranscriptForSend, stubConversationServices } from '../../renderer/features/conversation/testing.js'; import { createActionsDeps, @@ -715,6 +718,7 @@ describe('composer first-send cleanup', () => { const { root } = installReactRenderer(); let publication!: ReturnType['publication']; const catalog = createSessionCatalogController(); + const services = stubConversationServices(); function Probe(): null { publication = useAppShellSessionUiState( catalog, undefined, deps.activeIdRef, @@ -723,7 +727,16 @@ describe('composer first-send cleanup', () => { return null; } try { - act(() => root.render(createElement(Probe))); + act(() => root.render( + createElement(LocaleProvider, { + locale: 'en', + children: createElement(ToastProvider, { + children: createElement(ConversationServicesProvider, { + services, children: createElement(Probe), + }), + }), + }), + )); const actions = createAppShellChatActions({ ...deps, transcriptRangeRef: { current: controller }, isMessagePublished: publication.isMessagePublished, diff --git a/apps/desktop/src/main/__tests__/chat-view-optimistic-render.test.ts b/apps/desktop/src/main/__tests__/chat-view-optimistic-render.test.ts index 9c0ead10660..f843d4aafd8 100644 --- a/apps/desktop/src/main/__tests__/chat-view-optimistic-render.test.ts +++ b/apps/desktop/src/main/__tests__/chat-view-optimistic-render.test.ts @@ -134,9 +134,7 @@ test('ordinary sends stay in ChatView while queued prompts stay in the composer' 'the ordinary prompt remains in the transcript'); } - for (const transientPlacement of ['steering', 'follow_up'] as const) { - const document = render({ ...OPTIMISTIC_BUBBLE, transientPlacement }); - assert.ok(document.querySelector('.maka-composer-queue')?.textContent?.includes(OPTIMISTIC_BUBBLE.text)); - assert.equal(document.querySelector('.maka-user-message'), null, `${transientPlacement} stays out of the transcript`); - } + const followUp = render({ ...OPTIMISTIC_BUBBLE, transientPlacement: 'follow_up' }); + assert.ok(followUp.querySelector('.maka-composer-queue')?.textContent?.includes(OPTIMISTIC_BUBBLE.text)); + assert.equal(followUp.querySelector('.maka-user-message'), null, 'a follow-up stays out of the transcript'); }); diff --git a/apps/desktop/src/main/__tests__/composer-directories.test.ts b/apps/desktop/src/main/__tests__/composer-directories.test.ts index f3b093de2c5..f7272a52109 100644 --- a/apps/desktop/src/main/__tests__/composer-directories.test.ts +++ b/apps/desktop/src/main/__tests__/composer-directories.test.ts @@ -143,3 +143,13 @@ test('IPC validates directory references without turning them into attachments o }), /Invalid directory references/); } }); + +test('restoreDirectories stages references under another draft for a later visit', async () => { + const probe = await mount(); + await act(() => probe.state().restoreDirectories('draft-b', [reference])); + assert.deepEqual(probe.state().pendingDirectories, [], 'draft-a stays untouched'); + await probe.render({ draftKey: 'draft-b' }); + assert.deepEqual(probe.state().pendingDirectories, [reference]); + await act(() => probe.state().restoreDirectories('draft-b', [reference, reference])); + assert.deepEqual(probe.state().pendingDirectories, [reference], 'duplicates stay out'); +}); diff --git a/apps/desktop/src/main/__tests__/composer-mentions.test.ts b/apps/desktop/src/main/__tests__/composer-mentions.test.ts index dc652b4159b..9904821b557 100644 --- a/apps/desktop/src/main/__tests__/composer-mentions.test.ts +++ b/apps/desktop/src/main/__tests__/composer-mentions.test.ts @@ -29,10 +29,8 @@ import { useComposerMentionsContext, type ComposerMentions, } from '../../renderer/composer-mentions.js'; -import { - ConversationServicesProvider, - type ConversationServices, -} from '../../renderer/features/conversation/index.js'; +import { ConversationServicesProvider } from '../../renderer/features/conversation/index.js'; +import { stubConversationServices } from '../../renderer/features/conversation/testing.js'; import { createSessionCatalogController, SessionCatalogContext, @@ -80,33 +78,13 @@ function installCatalogRenderer(t: TestContext) { sessionId: string; resolve(skills: InvocableSkillEntry[]): void; }> = []; - const services: ConversationServices = { - listMessages: async () => [], - cancelMessage: async () => undefined, - reconcileMessage: async () => undefined, - subscribeChanges: () => () => undefined, + const services = stubConversationServices({ skills: { listInvocable: (sessionId: string) => new Promise((resolve) => { pending.push({ sessionId, resolve }); }), }, - sessions: { - readSnapshot: async () => { - throw new Error('Session snapshot is not used in catalog tests'); - }, - readExecutionBoundary: async () => { - throw new Error('Execution boundary is not used in catalog tests'); - }, - }, - runtimeHosts: { subscribeChanges: () => () => undefined }, - workspace: { searchFiles: async () => ({ ok: false, reason: 'no_project' }) }, - newTasks: { - subscribeChanges: () => () => undefined, - listInvocableSkills: async () => [], - searchFiles: async () => ({ ok: false, reason: 'no_project' }), - }, - mcp: { subscribeChanges: () => () => undefined }, - }; + }); const sessionCatalog = createSessionCatalogController(); const observations: CatalogObservation[] = []; diff --git a/apps/desktop/src/main/__tests__/goals-boundary.test.ts b/apps/desktop/src/main/__tests__/goals-boundary.test.ts index e52b4a280c8..7b656c7e18a 100644 --- a/apps/desktop/src/main/__tests__/goals-boundary.test.ts +++ b/apps/desktop/src/main/__tests__/goals-boundary.test.ts @@ -160,8 +160,8 @@ describe('Goals feature boundary', () => { for (const [source, required] of [ [composer, 'ComposerGoalProjectionConsumer,'], [composer, ''], - [composer, 'goalActive={goalProjection.goalActive}'], - [composer, 'onSetGoal={goalProjection.onSetGoal}'], + [composer, 'goalActive: goalProjection.goalActive,'], + [composer, 'onSetGoal: goalProjection.onSetGoal,'], [messageSurface, 'ChatViewGoalProjectionConsumer,'], [messageSurface, ''], [messageSurface, 'goalIndicator={goalProjection.goalIndicator}'], diff --git a/apps/desktop/src/main/__tests__/guest-turn-requests.test.ts b/apps/desktop/src/main/__tests__/guest-turn-requests.test.ts new file mode 100644 index 00000000000..eb3b60805b9 --- /dev/null +++ b/apps/desktop/src/main/__tests__/guest-turn-requests.test.ts @@ -0,0 +1,333 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { afterEach, test } from 'node:test'; +import { act, createElement, createRef } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { parseHTML } from 'linkedom'; +import { AstryxLocaleProvider, type ComposerHandle, LocaleProvider, ToastProvider } from '@maka/ui'; +import type { SessionTurnAccessRequest } from '@maka/runtime-host/protocol'; +import { ChatComposerRegion } from '../../renderer/chat-composer-region.js'; +import { + GuestTurnRequests, + SessionCollaborationServicesProvider, + createFakeSessionCollaborationServices, + type SessionCollaborationServices, +} from '../../renderer/features/session-collaboration/testing.js'; + +const originalGlobals = { + document: globalThis.document, + window: globalThis.window, + HTMLElement: globalThis.HTMLElement, + HTMLIFrameElement: globalThis.HTMLIFrameElement, + HTMLBRElement: globalThis.HTMLBRElement, + Element: globalThis.Element, + Event: globalThis.Event, + Node: globalThis.Node, + CSS: globalThis.CSS, + getComputedStyle: globalThis.getComputedStyle, + sessionStorage: globalThis.sessionStorage, + matchMedia: globalThis.matchMedia, + requestAnimationFrame: globalThis.requestAnimationFrame, + cancelAnimationFrame: globalThis.cancelAnimationFrame, + IS_REACT_ACT_ENVIRONMENT: (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }) + .IS_REACT_ACT_ENVIRONMENT, +}; + +let mountedRoot: Root | undefined; + +afterEach(async () => { + if (mountedRoot) await act(() => mountedRoot?.unmount()); + mountedRoot = undefined; + Object.assign(globalThis, originalGlobals); +}); + +const OWNED = 'owned-session'; +const SHARED = 'shared-session'; + +function request(turnId: string, text: string): SessionTurnAccessRequest { + return { + requestId: `request:${turnId}`, + principalId: 'guest-1', + grantId: 'grant-1', + intent: { sessionId: SHARED, turnId, content: { text } }, + createdAt: '2026-09-03T00:00:00.000Z', + state: { kind: 'pending' }, + }; +} + +function guestServices(overrides: Partial): SessionCollaborationServices { + return createFakeSessionCollaborationServices({ + getTurnRequests: async () => ({ canRequestTurns: true, requests: [] }), + createOperationId: () => 'turn-1', + ...overrides, + }); +} + +async function mountShell(services: SessionCollaborationServices) { + const { document, window } = parseHTML('
'); + const storage = new Map(); + const getSelection = () => + ({ + rangeCount: 0, + isCollapsed: true, + anchorNode: null, + focusNode: null, + removeAllRanges() {}, + addRange() {}, + getRangeAt: () => { + throw new Error('no range'); + }, + }) as unknown as Selection; + const getComputedStyle = () => + ({ direction: 'ltr', writingMode: 'horizontal-tb', getPropertyValue: () => '' }) as unknown as CSSStyleDeclaration; + const matchMedia = () => + ({ matches: false, addEventListener() {}, removeEventListener() {} }) as unknown as MediaQueryList; + const refreshes: Array<() => void> = []; + const nativeSetTimeout = globalThis.setTimeout; + const nativeClearTimeout = globalThis.clearTimeout; + Object.assign(document, { getSelection }); + Object.assign(window, { + getSelection, + getComputedStyle, + matchMedia, + scrollTo() {}, + // The Guest projection refreshes on a 2s loop; tests advance it by hand. + setTimeout(handler: TimerHandler, timeout?: number) { + if (timeout === 2_000 && typeof handler === 'function') { + refreshes.push(handler as () => void); + return 0; + } + return nativeSetTimeout(handler as () => void, timeout) as unknown as number; + }, + clearTimeout(handle: number) { + if (handle !== 0) nativeClearTimeout(handle); + }, + }); + document.createRange = () => + ({ selectNodeContents() {}, collapse() {}, cloneRange() { return this; } }) as unknown as Range; + Object.assign(globalThis, { + document, + window, + HTMLElement: window.HTMLElement, + HTMLIFrameElement: window.HTMLIFrameElement ?? class HTMLIFrameElement {}, + HTMLBRElement: window.HTMLBRElement, + Element: window.Element, + Event: window.Event, + Node: window.Node, + CSS: { escape: (value: string) => value }, + getComputedStyle, + sessionStorage: { + getItem: (key: string) => storage.get(key) ?? null, + setItem: (key: string, value: string) => storage.set(key, value), + removeItem: (key: string) => storage.delete(key), + }, + matchMedia, + requestAnimationFrame: (callback: FrameRequestCallback) => nativeSetTimeout(callback, 0), + cancelAnimationFrame: (handle: number) => nativeClearTimeout(handle), + IS_REACT_ACT_ENVIRONMENT: true, + }); + const root = createRoot(document.querySelector('#root')!); + mountedRoot = root; + const composer = createRef(); + const flush = () => new Promise((resolve) => nativeSetTimeout(resolve, 0)); + + // Mirrors AppShell: the Guest projection wraps the one ChatComposerRegion, + // and only its `sessionId` says whether the active Session is shared. + async function open(sessionId: string, shared: boolean) { + await act(async () => { + root.render(createElement(LocaleProvider, { + locale: 'en', + children: createElement(AstryxLocaleProvider, { + children: createElement(ToastProvider, { + children: createElement(SessionCollaborationServicesProvider, { + services, + children: createElement(GuestTurnRequests, { + sessionId: shared ? sessionId : undefined, + composerRef: composer, + children: (guest) => createElement(ChatComposerRegion, { + composerRef: composer, + guest, + onOpenContextUsage: () => undefined, + directoryComposerProps: {}, + directoryPickerEnabled: false, + active: true, + onboardingComposerHidden: false, + activeInteraction: undefined, + activeId: sessionId, + contextUsageSessionId: sessionId, + newTaskDraftKey: 'new-task:local', + newTaskSendPending: false, + stopPendingBySession: {}, + respondToSandboxBoundary: () => {}, + respondToClientCapability: () => {}, + respondToUserQuestion: () => {}, + respondToUserForm: () => {}, + stop: () => {}, + onSend: () => { + throw new Error('an owner send must not run for a Guest Session'); + }, + onStop: () => {}, + onPickAttachments: () => {}, + }), + }), + }), + }), + }), + })); + await flush(); + }); + } + + return { + composer, + document, + open, + buttons: () => [...document.querySelectorAll('button[aria-label]')].map((button) => button.getAttribute('aria-label')), + async send(text: string) { + await act(async () => composer.current!.setText(text)); + await act(async () => { + document.querySelector('form')!.dispatchEvent(new window.Event('submit', { bubbles: true, cancelable: true })); + await flush(); + await flush(); + }); + }, + async refresh() { + await act(async () => { + refreshes.shift()?.(); + await flush(); + }); + }, + }; +} + +// The harness below mirrors AppShell's wiring; this pins AppShell to it, since +// app-shell.tsx is not in the node test build. +test('AppShell mounts the one ChatComposerRegion inside GuestTurnRequests with no owner/Guest remount', () => { + const source = readFileSync(new URL('../../../src/renderer/app-shell.tsx', import.meta.url), 'utf8'); + const regions = [...source.matchAll(/'); + assert.ok(open !== -1 && open < regions[0]!.index && regions[0]!.index < close); + const slot = source.slice(open, close); + assert.doesNotMatch(slot, /\bkey=/); + assert.deepEqual(slot.match(/sharedSessionActive[^}]*/g), ['sharedSessionActive ? activeId : undefined'], + 'only the Guest projection\'s sessionId distinguishes a shared Session'); +}); + +test('switching to a shared Session and back keeps the one Composer and its owned draft', async () => { + const shell = await mountShell(guestServices({})); + await shell.open(OWNED, false); + const handle = shell.composer.current; + assert.ok(shell.buttons().includes('Add context')); + await act(async () => shell.composer.current!.setText('owned draft')); + + await shell.open(SHARED, true); + assert.equal(shell.composer.current, handle, 'a shared Session must not replace the Composer'); + assert.equal(shell.composer.current!.getText(), ''); + assert.deepEqual(shell.buttons(), ['Send'], 'owner controls and pickers do not reach a Guest'); + + await shell.open(OWNED, false); + assert.equal(shell.composer.current, handle); + assert.equal(shell.composer.current!.getText(), 'owned draft'); +}); + +test('a Guest send creates one Turn request and lists it in the staging drawer', async () => { + const sent: Array<{ turnId: string; text: string }> = []; + let requests: SessionTurnAccessRequest[] = []; + const shell = await mountShell(guestServices({ + getTurnRequests: async () => ({ canRequestTurns: true, requests }), + requestTurn: async (_sessionId, intent) => { + if (intent.kind !== 'start') throw new Error('unexpected intent'); + sent.push({ turnId: intent.turnId, text: intent.text }); + requests = [request(intent.turnId, intent.text)]; + return requests[0]!; + }, + })); + await shell.open(SHARED, true); + await shell.send('please review the retry plan'); + + assert.deepEqual(sent, [{ turnId: 'turn-1', text: 'please review the retry plan' }]); + assert.equal(shell.composer.current!.getText(), ''); + const row = shell.document.querySelector('.maka-composer-queue-text'); + assert.equal(row?.textContent, 'please review the retry plan'); + assert.ok(shell.document.querySelector('.maka-composer-queue [aria-label="Withdraw"]')); +}); + +test('a lost response the Host already accepted counts as sent instead of being resent', async () => { + let dispatched = 0; + let requests: SessionTurnAccessRequest[] = []; + const shell = await mountShell(guestServices({ + getTurnRequests: async () => ({ canRequestTurns: true, requests }), + requestTurn: async (_sessionId, intent) => { + dispatched += 1; + if (intent.kind === 'start') requests = [request(intent.turnId, intent.text)]; + throw new Error('connection lost after dispatch'); + }, + })); + await shell.open(SHARED, true); + await shell.send('one request only'); + + assert.equal(dispatched, 1); + assert.equal(shell.composer.current!.getText(), ''); + assert.equal(shell.document.querySelector('.maka-composer-queue-text')?.textContent, 'one request only'); +}); + +test('an unreachable Host keeps the draft and a retry reuses the same Turn id', async () => { + const turnIds: string[] = []; + let hostReachable = true; + let nextId = 0; + const shell = await mountShell(guestServices({ + createOperationId: () => `turn-${++nextId}`, + getTurnRequests: async () => { + if (!hostReachable) throw new Error('Runtime Host is reconnecting'); + return { canRequestTurns: true, requests: [] }; + }, + requestTurn: async (_sessionId, intent) => { + turnIds.push(intent.turnId); + if (!hostReachable) throw new Error('Runtime Host is reconnecting'); + return request(intent.turnId, intent.kind === 'start' ? intent.text : ''); + }, + })); + await shell.open(SHARED, true); + hostReachable = false; + await shell.send('keep this draft'); + assert.equal(shell.composer.current!.getText(), 'keep this draft'); + + hostReachable = true; + await shell.refresh(); + await shell.send('keep this draft'); + assert.deepEqual(turnIds, ['turn-1', 'turn-1']); + assert.equal(shell.composer.current!.getText(), ''); +}); + +test('a Guest without Turn access sees the notice instead of the composer', async () => { + const shell = await mountShell(guestServices({ + getTurnRequests: async () => ({ canRequestTurns: false, requests: [] }), + })); + await shell.open(SHARED, true); + assert.equal( + shell.document.querySelector('.sessionCollaborationReadOnly')?.textContent, + 'Read the full history and live updates', + ); + assert.equal(shell.document.querySelector('form.maka-composer')?.hasAttribute('hidden'), true); +}); diff --git a/apps/desktop/src/main/__tests__/message-queue-ui-state.test.ts b/apps/desktop/src/main/__tests__/message-queue-ui-state.test.ts index 05f768b8ef5..077b8d0fa6a 100644 --- a/apps/desktop/src/main/__tests__/message-queue-ui-state.test.ts +++ b/apps/desktop/src/main/__tests__/message-queue-ui-state.test.ts @@ -20,8 +20,15 @@ import assert from 'node:assert/strict'; import { afterEach, test } from 'node:test'; import { act, createElement } from 'react'; -import { LocaleProvider, type TransientUserMessageProjection } from '@maka/ui'; +import { + LocaleProvider, + ToastProvider, + type ComposerHandle, + type TransientUserMessageProjection, +} from '@maka/ui'; import { ConversationServicesProvider, SessionLocalMessages } from '../../renderer/features/conversation/index.js'; +import { stubConversationServices, useSessionMessageQueue } from '../../renderer/features/conversation/testing.js'; +import type { RestoredDraftContent } from '../../renderer/application/contracts/transient-message-projection.js'; import type { DesktopLocalMessage } from '../../shared/session-local-contract.js'; import { mergeTransientMessageProjection } from '../../renderer/application/contracts/transient-message-projection.js'; import { cleanupFakeDom, installReactRenderer } from './fake-dom.js'; @@ -34,6 +41,9 @@ test('local delivery recovery cannot republish accepted Host queue rows', async const { root } = installReactRenderer(); const transient = new Map(); let changed!: (sessionId: string) => void; + const cancelled: string[][] = []; + const reconciled: string[][] = []; + const restored: string[][] = []; let messages: DesktopLocalMessage[] = ['steering', 'followup', 'root'].map((messageId) => ({ sessionId: 'session-1', messageId, createdAt: 1, state: 'unknown', canCancel: false, text: messageId, attachments: [], inlineReferences: [], @@ -41,20 +51,12 @@ test('local delivery recovery cannot republish accepted Host queue rows', async ...(messageId === 'root' ? { localDisplayPlacement: 'current_turn' as const } : {}), })); await act(async () => root.render(createElement(LocaleProvider, { locale: 'en', children: - createElement(ConversationServicesProvider, { services: { + createElement(ConversationServicesProvider, { services: stubConversationServices({ listMessages: async () => messages, subscribeChanges: (handler) => { changed = handler; return () => {}; }, - cancelMessage: async () => {}, reconcileMessage: async () => {}, - sessions: { - readSnapshot: async () => { throw new Error('unexpected snapshot read'); }, - readExecutionBoundary: async () => { throw new Error('unexpected boundary read'); }, - }, - runtimeHosts: { subscribeChanges: () => () => {} }, - skills: { listInvocable: async () => [] }, - workspace: { searchFiles: async () => ({ ok: false as const, reason: 'no_project' as const }) }, - newTasks: { subscribeChanges: () => () => {}, listInvocableSkills: async () => [], searchFiles: async () => ({ ok: false as const, reason: 'no_project' as const }) }, - mcp: { subscribeChanges: () => () => {} }, - }, children: createElement(SessionLocalMessages, { + cancelMessage: async (sessionId, messageId) => { cancelled.push([sessionId, messageId]); }, + reconcileMessage: async (sessionId, messageId) => { reconciled.push([sessionId, messageId]); }, + }), children: createElement(SessionLocalMessages, { sessionId: 'session-1', publish: (_id, message) => { const current = transient.get(message.id); @@ -62,26 +64,40 @@ test('local delivery recovery cannot republish accepted Host queue rows', async }, retire: (_id, messageId) => { transient.delete(messageId); }, reportError: (message) => { throw new Error(message); }, + restoreDraft: (sessionId, draft) => { restored.push([sessionId, draft.text]); }, }) }), }))); - assert.equal(transient.get('steering')?.deliveryActions?.length, 1, 'unconfirmed sends retain their receipt check'); - assert.equal(transient.get('root')?.deliveryStatus, 'Host outcome unknown'); + const steering = transient.get('steering'); + assert.equal(steering?.deliveryStatus, 'Delivery unconfirmed. Do not send again.'); + assert.deepEqual(steering?.deliveryActions?.map((action) => action.label), ['Check delivery'], + 'an unconfirmed send offers only its receipt check, never cancellation'); const placements = () => Object.fromEntries([...transient].map(([id, message]) => [id, message.transientPlacement])); - assert.deepEqual(placements(), { steering: 'steering', followup: 'follow_up', root: 'transcript' }); + assert.deepEqual(placements(), { steering: 'transcript', followup: 'follow_up', root: 'transcript' }); + await act(async () => { await steering?.deliveryActions?.[0]?.onClick(); }); + assert.deepEqual(reconciled, [['session-1', 'steering']]); + assert.equal(transient.has('steering'), true, 'checking delivery does not retire the row'); messages = messages.map((message) => ({ ...message, state: 'saved', canCancel: true })); await act(async () => changed('session-1')); - assert.equal(transient.get('root')?.deliveryStatus, 'Saved locally · waiting to send', 'Main cannot reach the Host'); - messages = messages.map((message) => ({ ...message, delivering: true })); + const followup = transient.get('followup'); + assert.equal(followup?.deliveryStatus, 'Waiting to send'); + assert.deepEqual(followup?.deliveryActions?.map((action) => action.label), ['Edit', 'Delete unsent message']); + await act(async () => { await followup?.deliveryActions?.[0]?.onClick(); }); + assert.deepEqual(cancelled, [['session-1', 'followup']]); + assert.deepEqual(restored, [['session-1', 'followup']], + 'editing a never-dispatched message returns its text to the composer'); + assert.equal(transient.has('followup'), false, 'edit retires the local row'); + messages = messages.filter((message) => message.messageId !== 'followup') + .map((message) => ({ ...message, delivering: true })); await act(async () => changed('session-1')); assert.equal(transient.get('root')?.deliveryStatus, undefined, 'a message Main will deliver shows nothing'); assert.deepEqual(transient.get('root')?.deliveryActions, []); messages = messages.map((message) => ({ ...message, error: 'Saved locally. Waiting for the Host to become available.' })); await act(async () => changed('session-1')); - assert.equal(transient.get('root')?.deliveryStatus, 'Saved locally · waiting to send'); - assert.equal(transient.get('root')?.deliveryActions?.length, 1, 'a Host outage keeps the copy removable'); + assert.equal(transient.get('root')?.deliveryStatus, 'Waiting to send'); + assert.equal(transient.get('root')?.deliveryActions?.length, 2, 'a Host outage keeps the copy editable and removable'); messages = messages.map((message) => ({ ...message, state: 'failed' })); await act(async () => changed('session-1')); - assert.deepEqual(placements(), { steering: 'steering', followup: 'follow_up', root: 'transcript' }, 'failed delivery moves nothing'); + assert.deepEqual(placements(), { steering: 'transcript', root: 'transcript' }, 'failed delivery moves nothing'); messages = messages.map((message) => ({ ...message, state: 'accepted', ...(message.messageId === 'root' ? { turnId: 'started-turn' } : {}) })); await act(async () => changed('session-1')); assert.deepEqual([...transient.keys()], ['root']); @@ -91,9 +107,9 @@ test('local delivery recovery cannot republish accepted Host queue rows', async assert.deepEqual([...transient.keys()], ['root'], 'a retained local copy cannot resurrect a withdrawn queue entry'); }); -test('queue_update events drive the independent desktop queue projection', () => { +test('queue_update stores the snapshot and retires every listed local placeholder', async () => { const controller = createAppShellSessionUiStateController(); - const transientMessages = new Set(['message-steer', 'message-next']); + const transientMessages = new Map(); const handlers = createAppShellSessionEventHandlers({ uiLocale: 'zh-CN', activeIdRef: { current: 'session-1' }, @@ -103,8 +119,9 @@ test('queue_update events drive the independent desktop queue projection', () => setLiveTurnBySession: controller.setLiveTurnBySession, setInteractionBySession: controller.setInteractionBySession, setMessageQueueBySession: controller.setMessageQueueBySession, - removeTransientMessage: (_sessionId, messageId) => - transientMessages.delete(messageId), + removeTransientMessage: (_sessionId, messageId) => { + transientMessages.delete(messageId); + }, showModelSetupToast() {}, toastApi: { error() {} }, }); @@ -119,39 +136,39 @@ test('queue_update events drive the independent desktop queue projection', () => ...steeringEntry, state: 'in_flight' as const, }; - - handlers.handleEvent('session-1', { - type: 'queue_update', + const followupEntry = { + entryId: 'entry-next', + messageId: 'message-next', + content: { text: 'do this next' }, + placement: 'next_turn' as const, + state: 'queued' as const, + }; + const queueUpdate = (steering: import('@maka/core/events').MessageQueueEntryProjection[]) => ({ + type: 'queue_update' as const, id: 'queue-1', turnId: 'turn-1', ts: 1, queueRevision: 3, steering: ['adjust this run'], followup: ['do this next'], - steeringEntries: [steeringEntry], - followupEntries: [{ - entryId: 'entry-next', - messageId: 'message-next', - content: { text: 'do this next' }, - placement: 'next_turn', - state: 'queued', - }], + steeringEntries: steering, + followupEntries: [followupEntry], + }); + transientMessages.set('message-next', { + id: 'message-next', text: 'do this next', ts: 1, transientPlacement: 'follow_up', }); + transientMessages.set('message-steer', { + id: 'message-steer', text: 'adjust this run', ts: 1, transientPlacement: 'transcript', + }); + + handlers.handleEvent('session-1', queueUpdate([steeringEntry])); assert.deepEqual(controller.getState().messageQueueBySession['session-1'], { - queueRevision: 3, - entries: [ - steeringEntry, - { - entryId: 'entry-next', - messageId: 'message-next', - content: { text: 'do this next' }, - placement: 'next_turn', - state: 'queued', - }, - ], + ts: 1, + entries: [steeringEntry, followupEntry], }); - assert.equal(transientMessages.size, 0, 'Host evidence retires local placeholders'); + assert.equal(transientMessages.size, 0, + 'the store keeps no copy of entries the Host snapshot now owns — queued steering derives from it at render'); const nextEntry = { entryId: 'entry-next', @@ -188,7 +205,7 @@ test('queue_update events drive the independent desktop queue projection', () => }); assert.equal(transientMessages.size, 0); assert.deepEqual(controller.getState().messageQueueBySession['session-1'], { - queueRevision: 4, + ts: 3, entries: [nextEntry], }); @@ -266,6 +283,55 @@ test('a rootless resubscription seed retires a stale queued card', () => { ); }); +test('an empty queue_update clears the last-seen snapshot after an unobserved drain', async () => { + const controller = createAppShellSessionUiStateController(); + const handlers = createAppShellSessionEventHandlers({ + uiLocale: 'en', + activeIdRef: { current: 'session-1' }, + liveTurnBySessionRef: controller.liveTurnBySessionRef, + refreshMessages: async () => true, + refreshSessions: async () => [], + setLiveTurnBySession: controller.setLiveTurnBySession, + setInteractionBySession: controller.setInteractionBySession, + setMessageQueueBySession: controller.setMessageQueueBySession, + removeTransientMessage: () => {}, + showModelSetupToast() {}, + toastApi: { error() {} }, + }); + + handlers.handleEvent('session-1', { + type: 'queue_update', + id: 'queue-drained-before', + turnId: 'turn-1', + ts: 1, + queueRevision: 2, + steering: [], + followup: ['do this next'], + steeringEntries: [], + followupEntries: [{ + entryId: 'entry-next', + messageId: 'message-next', + content: { text: 'do this next' }, + placement: 'next_turn', + state: 'queued', + }], + }); + assert.equal(controller.getState().messageQueueBySession['session-1']?.entries.length, 1); + + handlers.handleEvent('session-1', { + type: 'queue_update', + id: 'queue-drained-after', + turnId: 'turn-1', + ts: 2, + queueRevision: 3, + steering: [], + followup: [], + steeringEntries: [], + followupEntries: [], + }); + assert.equal(controller.getState().messageQueueBySession['session-1'], undefined); +}); + test('steering delivery clears a promoted follow-up from the desktop queue', () => { const controller = createAppShellSessionUiStateController(); const handlers = createAppShellSessionEventHandlers({ @@ -347,3 +413,77 @@ test('complete events deliver the durable context compaction outcome to Desktop' }, ]); }); + +test('editing a queued message retracts it and restores its content under the owning Session', async () => { + const { root } = installReactRenderer(); + const entry = { + entryId: 'entry-1', messageId: 'message-1', + placement: 'current_turn' as const, state: 'queued' as const, + content: { + text: 'steer it', + attachments: [{ + kind: 'other' as const, name: 'a.png', mimeType: 'image/png', bytes: 1, + ref: { kind: 'external_file' as const, absolutePath: '/tmp/a.png' }, + }], + quotes: [{ text: 'quoted' }], + }, + }; + const followUp = { + entryId: 'entry-2', messageId: 'message-2', + placement: 'next_turn' as const, state: 'queued' as const, + content: { text: 'model text', displayText: 'follow up', directoryReferences: [{ hostId: 'h', path: '/repo' }] }, + }; + const retracted: string[][] = []; + const restoredDrafts: [string, string][] = []; + const restoredContext: [string, RestoredDraftContent][] = []; + // The user navigated to another Session before the retract resolves. + const activeSessionId = { current: 'session-b' as string | undefined }; + let surface!: ReturnType; + function Probe() { + surface = useSessionMessageQueue({ + sessionId: 'session-a', + queue: { entries: [entry, followUp], ts: 1 }, + transientMessages: [], + activeSessionId, + }); + return null; + } + await act(async () => root.render(createElement(LocaleProvider, { locale: 'en', children: + createElement(ToastProvider, { children: + createElement(ConversationServicesProvider, { services: stubConversationServices({ + sessions: { + retractQueueEntry: async (sessionId: string, entryId: string) => { + retracted.push([sessionId, entryId]); + }, + }, + }), children: createElement(Probe) }) })}))); + surface.composer.current = { + setText() {}, appendText() {}, getText: () => '', clearDraft() {}, + setDraft: (key, text) => { restoredDrafts.push([key, text]); }, + getDraft: () => '', + appendDraft: (key, text) => { restoredDrafts.push([key, text]); }, + focus() {}, openModelPicker() {}, + } as ComposerHandle; + surface.draftContextRestorer.current = (sessionId, draft) => { restoredContext.push([sessionId, draft]); }; + const bubble = surface.transientMessages.find((message) => message.id === entry.messageId); + assert.ok(bubble, 'a queued steering entry derives a transcript bubble'); + const edit = bubble.deliveryActions?.find((action) => action.label === 'Edit'); + assert.ok(edit, 'the bubble offers edit'); + await act(async () => { await edit.onClick(); }); + assert.deepEqual(retracted, [['session-a', 'entry-1']]); + assert.equal(restoredContext.length, 1); + assert.equal(restoredContext[0]![0], 'session-a'); + assert.equal(restoredContext[0]![1].attachments, entry.content.attachments, + 'attachments ride back into the draft'); + assert.equal(restoredContext[0]![1].quotes, entry.content.quotes, + 'quotes ride back into the draft'); + assert.deepEqual(restoredDrafts, [['session-a', 'steer it']], + 'the draft lands under the owning Session even while another is active'); + + // A queued follow-up row edits the same way: out of the queue, back into the draft. + activeSessionId.current = 'session-a'; + await act(async () => { await surface.editQueuedEntry(followUp); }); + assert.deepEqual(retracted.at(-1), ['session-a', 'entry-2']); + assert.deepEqual(restoredDrafts.at(-1), ['session-a', 'follow up']); + assert.equal(restoredContext.at(-1)![1].directoryReferences, followUp.content.directoryReferences); +}); 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 b42cf3f0f71..be14c0b6ef1 100644 --- a/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts +++ b/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts @@ -27,7 +27,8 @@ import { parseHTML } from 'linkedom'; import { act, createElement } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { ChatSurfaceLayout, ChatView, LocaleProvider } from '@maka/ui'; -import type { SessionEvent } from '@maka/core/events'; +import type { AttachmentRef, SessionEvent } from '@maka/core/events'; +import type { PendingAttachment } from '@maka/ui/composer-attachments'; import type { ChatModelChoice } from '@maka/core/chat-model-choice'; import type { PermissionMode } from '@maka/core/permission'; import type { @@ -45,7 +46,6 @@ import { WorkbarServicesProvider, type CompanionQuoteSnapshot, type StagedCompanionQuote, - type WorkbarIngestInput, type WorkbarServices, } from '../../renderer/features/workbar/testing.js'; import { renderTranscriptMarkup } from './transcript-test-dom.js'; @@ -64,11 +64,27 @@ const originalGlobals = { let mountedRoot: Root | undefined; const SOURCE_SESSION = session('source-session'); type SideChatStopTarget = Parameters[1]; +type SendFn = (text: string, attachments?: readonly PendingAttachment[]) => Promise; type SteerFn = ( text: string, - attachmentItems?: WorkbarIngestInput[], + attachments?: readonly PendingAttachment[], onAdmitted?: () => void, ) => Promise; + +function approvalAttachment(approvalId: string, name: string): PendingAttachment { + return { stagingKey: approvalId, displayName: name, kind: 'other', size: 1, source: { type: 'approval', approvalId, name } }; +} + +function retainedAttachment(attachment: AttachmentRef): PendingAttachment { + return { + stagingKey: `retained:${attachment.name}`, + displayName: attachment.name, + mimeType: attachment.mimeType, + kind: attachment.kind, + size: attachment.bytes, + source: { type: 'retained', attachment }, + }; +} type QueueUpdate = Extract; type QueueEntry = NonNullable[number]; @@ -133,7 +149,7 @@ async function renderProbe( sourceSession?: SessionSummary; modelChoices?: readonly ChatModelChoice[]; ready?: (container: Element) => boolean; - onSend?: (send: (text: string) => Promise) => void; + onSend?: (send: SendFn) => void; onProjection?: (companion: ReturnType) => void; onQueue?: (queue: (text: string) => Promise) => void; onSteer?: (steer: SteerFn) => void; @@ -220,7 +236,7 @@ async function renderOwnershipProbe( onContextCompactionError?: (sessionId: string, error: unknown) => void; } = {}, ) { - let send!: (text: string) => Promise; + let send!: SendFn; let projection!: ReturnType; let queue!: (text: string) => Promise; let steer!: SteerFn; @@ -262,10 +278,10 @@ async function renderOwnershipProbe( return { ...rendered, setActive: rendered.setActive, - send: (text: string) => send(text), + send: (text: string, attachments?: readonly PendingAttachment[]) => send(text, attachments), queue: (text: string) => queue(text), - steer: (text: string, attachmentItems?: WorkbarIngestInput[], onAdmitted?: () => void) => - steer(text, attachmentItems, onAdmitted), + steer: (text: string, attachments?: readonly PendingAttachment[], onAdmitted?: () => void) => + steer(text, attachments, onAdmitted), stop: () => stop(), deleteQueuedEntry: (entryId: string) => deleteQueuedEntry(entryId), setPermissionMode: (mode: PermissionMode) => setPermissionMode(mode), @@ -350,7 +366,7 @@ async function rerenderProbeSource( function ownershipProbeTree( services: WorkbarServices, sourceSession: SessionSummary, - onSend: (send: (text: string) => Promise) => void, + onSend: (send: SendFn) => void, ) { return createElement(WorkbarServicesProvider, { services, @@ -365,7 +381,7 @@ function ownershipProbeTree( async function rerenderOwnershipSource( rendered: { root: Root; services: WorkbarServices }, sourceSession: SessionSummary, - onSend: (send: (text: string) => Promise) => void, + onSend: (send: SendFn) => void, ) { await act(async () => { rendered.root.render(ownershipProbeTree(rendered.services, sourceSession, onSend)); @@ -2076,8 +2092,10 @@ test('consumes a steered attachment when the started turn binds the admission', const pendingSteer = deferred<{ kind: 'started'; turnId: string }>(); let admissionId: string | undefined; let admitted = 0; - let steerPayload: { attachmentItems?: readonly WorkbarIngestInput[] } | undefined; - const attachmentItem: WorkbarIngestInput = { approvalId: 'approval-1', name: 'kept.png' }; + let steerPayload: Parameters[4]; + // A retracted edit stages its Host attachment as `retained`; it rides the + // same steer as a newly picked file. + const restored: AttachmentRef = { kind: 'doc', name: 'brief.txt', mimeType: 'text/plain', bytes: 4, ref: { kind: 'workspace_file', relativePath: 'brief.txt' } }; const { container, emit, send, steer, hostTurn } = await renderOwnershipProbe({ send: async () => ({ ok: true as const, turnId: 'old-turn' }), submitFollowUp: async (_sessionId, placement, _text, requestedAdmissionId, payload) => { @@ -2095,7 +2113,7 @@ test('consumes a steered attachment when the started turn binds the admission', }); let steerResult!: Promise; await act(async () => { - steerResult = steer('steer with the kept image', [attachmentItem], () => { + steerResult = steer('steer with the kept image', [approvalAttachment('approval-1', 'kept.png'), retainedAttachment(restored)], () => { admitted += 1; }); await Promise.resolve(); @@ -2109,7 +2127,10 @@ test('consumes a steered attachment when the started turn binds the admission', }); // The attachments travel with the steering Message... - assert.deepEqual(steerPayload, { attachmentItems: [attachmentItem] }); + assert.deepEqual(steerPayload, { + attachmentItems: [{ approvalId: 'approval-1', name: 'kept.png' }], + retainedAttachments: [restored], + }); assert.equal( container.firstElementChild?.getAttribute('data-live-turn-id'), 'steer-started-turn', @@ -2132,6 +2153,23 @@ test('consumes a steered attachment when the started turn binds the admission', assert.equal(admitted, 1, 'the admission echo must not consume a second time'); }); +test('a send carries a restored attachment as a retained Host reference', async () => { + const commands: Parameters[1][] = []; + const restored: AttachmentRef = { kind: 'doc', name: 'brief.txt', mimeType: 'text/plain', bytes: 4, ref: { kind: 'workspace_file', relativePath: 'brief.txt' } }; + const { send } = await renderOwnershipProbe({ + send: async (_sessionId, command) => { + commands.push(command); + return { ok: true as const, turnId: 'turn-1' }; + }, + }); + await act(async () => { + assert.equal(await send('', [retainedAttachment(restored)]), true); + }); + assert.equal(commands.length, 1); + assert.deepEqual(commands[0]!.retainedAttachments, [restored]); + assert.equal(commands[0]!.attachmentItems, undefined); +}); + test('retracts a queued Side Conversation message without stopping the active turn', async () => { let messageId: string | undefined; const retracted: string[] = []; @@ -2725,6 +2763,93 @@ test('retires a cancelled queued Side Conversation message after observation res assert.ok(queriedMessageIds.some((messageIds) => messageIds.includes(queuedMessageId as string))); }); +test('binds a drained queued follow-up to its completed Turn when reseeding', async () => { + let queuedMessageId: string | undefined; + let markSeeded: (() => void) | undefined; + let seedCount = 0; + let reconnected = false; + const { container, emit, send, queue, hostTurn } = await renderOwnershipProbe({ + subscribeEvents: (_sessionId, _handler, onSeeded) => { + markSeeded = onSeeded; + if (seedCount === 0) { + seedCount += 1; + onSeeded?.(); + } + return () => undefined; + }, + send: async () => ({ ok: true as const, turnId: 'old-turn' }), + submitFollowUp: async (_sessionId, placement, _text, messageId) => { + assert.equal(placement, 'next_turn'); + queuedMessageId = messageId; + return { kind: 'queued' as const }; + }, + readSettledMessages: async () => ({ + messages: reconnected + ? [ + { + type: 'user' as const, + id: queuedMessageId as string, + turnId: 'successor-turn', + ts: 3, + text: 'drained while disconnected', + }, + { + type: 'assistant' as const, + id: 'successor-answer', + turnId: 'successor-turn', + ts: 4, + text: 'successor answer', + modelId: 'test-model', + }, + { + type: 'turn_state' as const, + id: 'successor-done', + turnId: 'successor-turn', + ts: 5, + status: 'completed' as const, + }, + ] + : [], + settled: true, + }), + queryMessageExecutions: async (_sessionId, messageIds) => ({ + resolutions: messageIds.map((messageId) => ({ messageId, state: 'pending' as const })), + }), + }); + + await act(async () => { + assert.equal(await send('initial prompt'), true); + hostTurn('old-turn'); + }); + await act(async () => { + assert.equal(await queue('drained while disconnected'), true); + emit( + queueUpdateEvent('queued-follow-up', 'old-turn', 2, [], [ + { + entryId: 'follow-up-entry', + messageId: queuedMessageId as string, + content: { text: 'drained while disconnected' }, + placement: 'next_turn', + state: 'queued', + }, + ]), + ); + await Promise.resolve(); + }); + // The Host drained the queue while the panel was disconnected, so the + // post-reconnect snapshot lists nothing and the admission events are gone — + // only the reseed can bind the completed Turn. + await act(async () => { + emit(queueUpdateEvent('drained-queue', 'old-turn', 3)); + reconnected = true; + markSeeded?.(); + await Promise.resolve(); + }); + await waitUntil(() => + (container.firstElementChild?.getAttribute('data-message-texts') ?? '') + .includes('drained while disconnected|successor answer')); +}); + for (const resolutionState of ['cancelled', 'owned'] as const) { test(`releases an outcome-unknown Side Conversation steer when reseeding proves it ${resolutionState}`, async () => { let admissionId: string | undefined; @@ -3435,6 +3560,7 @@ function QuoteCompanionProbe(props: { locale: 'en', onQuotesConsumed: () => undefined, confirmBypass: props.confirmBypass ?? (async () => true), + restoreDraft: () => undefined, }); props.onSetPermissionMode?.(companion.setPermissionMode); return createElement('div', { @@ -3469,6 +3595,7 @@ function QuoteCompanionOwnershipProbe(props: { onQuotesConsumed: props.onQuotesConsumed ?? (() => undefined), confirmBypass: async () => true, onContextCompactionError: props.onContextCompactionError, + restoreDraft: () => undefined, }); props.onSend(companion.send); props.onProjection?.(companion); @@ -3668,7 +3795,7 @@ test('a steer with staged attachments consumes them only on confirmed admission' const consumed: string[] = []; await act(async () => { assert.equal( - await rendered.steer('', [{ approvalId: 'a-1', name: 'notes.txt' }], () => { + await rendered.steer('', [approvalAttachment('a-1', 'notes.txt')], () => { consumed.push('admitted'); }), true, @@ -3714,7 +3841,7 @@ test('an unknown steer outcome that later retracts keeps the staged attachments' const consumed: string[] = []; await act(async () => { assert.equal( - await rendered.steer('', [{ approvalId: 'a-1', name: 'notes.txt' }], () => { + await rendered.steer('', [approvalAttachment('a-1', 'notes.txt')], () => { consumed.push('admitted'); }), true, diff --git a/apps/desktop/src/main/__tests__/runtime-host-client-operations.test.ts b/apps/desktop/src/main/__tests__/runtime-host-client-operations.test.ts index 4c8c716a18e..821d8b080e3 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-client-operations.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-client-operations.test.ts @@ -449,9 +449,8 @@ test('binds message controls to the current Host Epoch', async () => { const { client, requests } = clientWithResponses([ { disposition: 'steering', queueRevision: 2 }, { queueRevision: 3 }, - { queueRevision: 4 }, { - queueRevision: 5, + queueRevision: 4, retracted: [], turn: { sessionId: 'session-1', @@ -475,13 +474,6 @@ test('binds message controls to the current Host Epoch', async () => { entryId: 'entry-1', retractId: 'retract-1', }); - await client.updateQueueEntry({ - sessionId: 'session-1', - entryId: 'entry-1', - updateId: 'update-1', - expectedQueueRevision: 3, - text: 'Updated steer', - }); await client.interruptTurn({ sessionId: 'session-1', interruptId: 'interrupt-1', @@ -509,17 +501,6 @@ test('binds message controls to the current Host Epoch', async () => { originHostEpoch: 'host-current', }, }, - { - operation: 'queue.entry.update', - input: { - sessionId: 'session-1', - entryId: 'entry-1', - updateId: 'update-1', - expectedQueueRevision: 3, - text: 'Updated steer', - originHostEpoch: 'host-current', - }, - }, { operation: 'turn.interrupt', input: { 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 055ead8ca96..bd5ad8eeae3 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 @@ -1894,13 +1894,9 @@ test("routes per-entry queue mutations to the Runtime Host", async () => { calls.push({ operation: "promote", ...input }); return { queueRevision: 4 }; }, - updateQueueEntry: async (input) => { - calls.push({ operation: "update", ...input }); - return { queueRevision: 5 }; - }, reorderQueueEntries: async (input) => { calls.push({ operation: "reorder", ...input }); - return { queueRevision: 6 }; + return { queueRevision: 5 }; }, }), observer: unusedObserver(), @@ -1916,13 +1912,6 @@ test("routes per-entry queue mutations to the Runtime Host", async () => { assert.equal(await ipc.invoke("sessions:retractQueueEntry", "session-1", "entry-1"), undefined); await ipc.invoke("sessions:promoteQueueEntry", "session-1", "entry-2"); - await ipc.invoke( - "sessions:updateQueueEntry", - "session-1", - "entry-2", - 4, - " revised ", - ); await ipc.invoke("sessions:reorderQueueEntries", "session-1", ["entry-3", "entry-2"]); assert.deepEqual(calls, [ @@ -1938,26 +1927,14 @@ test("routes per-entry queue mutations to the Runtime Host", async () => { entryId: "entry-2", promoteId: "id-2", }, - { - operation: "update", - sessionId: "session-1", - entryId: "entry-2", - updateId: "id-3", - expectedQueueRevision: 4, - text: "revised", - }, { operation: "reorder", sessionId: "session-1", - reorderId: "id-4", + reorderId: "id-3", entryIds: ["entry-3", "entry-2"], }, ]); - await assert.rejects( - () => ipc.invoke("sessions:updateQueueEntry", "session-1", "entry-1", 4, " "), - /Invalid Queued message text/, - ); await assert.rejects( () => ipc.invoke("sessions:promoteQueueEntry", "session-1", 42), /Invalid queue entry identity/, @@ -2318,7 +2295,6 @@ function executionClient(overrides: Partial): ExecutionClient { openSession: unavailable, retractQueueEntry: unavailable, promoteQueueEntry: unavailable, - updateQueueEntry: unavailable, reorderQueueEntries: unavailable, setSessionReadMarker: unavailable, startTurnResume: unavailable, diff --git a/apps/desktop/src/main/__tests__/runtime-host-session-observer.test.ts b/apps/desktop/src/main/__tests__/runtime-host-session-observer.test.ts index 73f9582c4e5..e82485498f3 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-session-observer.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-session-observer.test.ts @@ -140,7 +140,7 @@ test("joins an active Turn without losing or replaying assistant text", async () }, ]); await Promise.all([watching, observing]); - await waitFor(() => target.events.length === 2); + await waitFor(() => target.events.length === 3); assert.deepEqual( target.events.map((event) => [ @@ -150,6 +150,7 @@ test("joins an active Turn without losing or replaying assistant text", async () ]), [ ["text_delta", "Hello", 0], + ["queue_update", undefined, undefined], ["text_delta", " world", 5], ], ); @@ -240,7 +241,7 @@ test("restores renderer observation after the Host connection is replaced", asyn observations.detach(firstObserver); await firstObserver.close(); assert.deepEqual(await observations.attach(secondObserver), ["session-1"]); - await waitFor(() => target.events.length === 1); + await waitFor(() => target.events.length === 3); secondEvents.push(deltaFrame(1, 5, " again")); secondEvents.push({ @@ -268,7 +269,9 @@ test("restores renderer observation after the Host connection is replaced", asyn "text" in event ? event.text : undefined, ]), [ + ["queue_update", undefined], ["text_delta", "Hello"], + ["queue_update", undefined], ["text_delta", " again"], ["text_complete", "Hello again"], ["complete", undefined], @@ -2234,13 +2237,13 @@ test("reopens an evicted active subscription without a renderer resubscribe", as await waitFor(() => openCount === 2); assert.equal(target.observations.at(-1)?.type, 'host_observation_pending', 'observation is invalidated while reopen is still waiting'); - assert.equal(target.events.length, 1, 'no replacement content is accepted before reopen completes'); + assert.equal(target.events.length, 2, 'no replacement content is accepted before reopen completes'); reopen.resolve(); - await waitFor(() => target.events.length === 2); + await waitFor(() => target.events.length === 4); assert.equal(target.observations.at(-1)?.type, 'host_observation_seed'); secondEvents.push(deltaFrame(1, 5, " world")); - await waitFor(() => target.events.length === 3); + await waitFor(() => target.events.length === 5); assert.deepEqual( target.events.map((event) => [ event.type, @@ -2248,8 +2251,10 @@ test("reopens an evicted active subscription without a renderer resubscribe", as "startOffset" in event ? event.startOffset : undefined, ]), [ + ["queue_update", undefined, undefined], ["text_delta", "Hel", 0], ["text_delta", "Hello", 0], + ["queue_update", undefined, undefined], ["text_delta", " world", 5], ], ); @@ -2538,7 +2543,7 @@ test("seeds a joining observer from the attempt that survives repeated catch-up joiningTarget.events.map((event) => event.type === "text_delta" ? event.text : event.type, ), - ["Hello"], + ["queue_update", "Hello", "queue_update"], ); await observer.close(); }); @@ -2879,10 +2884,10 @@ test("shares one Host subscription and one delivery per renderer target", async observer.observe("session-1", "observer-2", target), ]); events.push(deltaFrame(1, 0, "one")); - await waitFor(() => target.events.length === 1); + await waitFor(() => target.events.length === 2); assert.equal(openCount, 1); - assert.equal(target.events.length, 1); + assert.equal(target.events.length, 2); await observer.unobserve("observer-1"); assert.equal(closeCount, 0); await observer.unobserve("observer-2"); @@ -2981,7 +2986,7 @@ test("rehydrates pending interactions and publishes answer acknowledgements", as assert.deepEqual( await observer.readActiveInteractions("session-1"), - target.events, + target.events.filter((event) => event.type !== "queue_update"), ); observer.publishInteractionAnswer( { @@ -3086,7 +3091,7 @@ test("projects Host queue revisions and places steering from the runtime event", }, }), }); - await waitFor(() => target.events.length === 1); + await waitFor(() => target.events.length === 2); events.push({ kind: "subscription.session_projection", hostEpoch: "host-1", @@ -3102,7 +3107,7 @@ test("projects Host queue revisions and places steering from the runtime event", }, }), }); - await waitFor(() => target.events.length === 2); + await waitFor(() => target.events.length === 3); events.push({ kind: "subscription.session_event", hostEpoch: "host-1", @@ -3119,15 +3124,15 @@ test("projects Host queue revisions and places steering from the runtime event", content: { text: "Change direction" }, }, }); - await waitFor(() => target.events.length === 4); + await waitFor(() => target.events.length === 5); assert.deepEqual( target.events.map((event) => event.type === "queue_update" ? event.steeringEntries?.map((entry) => entry.state) : event.type, ), - [["queued"], ["in_flight"], [], "steering_message"], + [[], ["queued"], ["in_flight"], [], "steering_message"], ); - assert.deepEqual(target.events[0], { + assert.deepEqual(target.events[1], { type: "queue_update", id: "host-queue:host-1:1", turnId: "turn-1", @@ -3138,7 +3143,7 @@ test("projects Host queue revisions and places steering from the runtime event", steeringEntries: [queued], followupEntries: [], }); - assert.deepEqual(target.events[3], { + assert.deepEqual(target.events[4], { type: "steering_message", id: "steering-event-1", turnId: "turn-1", diff --git a/apps/desktop/src/main/__tests__/session-query-gate.test.ts b/apps/desktop/src/main/__tests__/session-query-gate.test.ts index 69e43183e8b..3add499fc8d 100644 --- a/apps/desktop/src/main/__tests__/session-query-gate.test.ts +++ b/apps/desktop/src/main/__tests__/session-query-gate.test.ts @@ -29,10 +29,8 @@ import { ComposerMentionsProvider, useComposerMentionsContext, } from '../../renderer/composer-mentions.js'; -import { - ConversationServicesProvider, - type ConversationServices, -} from '../../renderer/features/conversation/index.js'; +import { ConversationServicesProvider } from '../../renderer/features/conversation/index.js'; +import { stubConversationServices } from '../../renderer/features/conversation/testing.js'; import { usePlanModeState, type PlanModeState, @@ -67,34 +65,14 @@ test('query blocking pauses, resumes, and fences automatic Skills and Plan reads let planMode: PlanModeState | undefined; let skillsUnavailable = false; - const services: ConversationServices = { - listMessages: async () => [], - cancelMessage: async () => undefined, - reconcileMessage: async () => undefined, - subscribeChanges: () => () => undefined, + const services = stubConversationServices({ skills: { listInvocable: async () => { skillQueries += 1; return skillQueries === 1 ? firstSkillQuery.promise : []; }, }, - sessions: { - readSnapshot: async () => { - throw new Error('Session snapshot is not used in query gate tests'); - }, - readExecutionBoundary: async () => { - throw new Error('Execution boundary is not used in query gate tests'); - }, - }, - runtimeHosts: { subscribeChanges: () => () => undefined }, - workspace: { searchFiles: async () => ({ ok: false, reason: 'no_project' }) }, - newTasks: { - subscribeChanges: () => () => undefined, - listInvocableSkills: async () => [], - searchFiles: async () => ({ ok: false, reason: 'no_project' }), - }, - mcp: { subscribeChanges: () => () => undefined }, - }; + }); (globalThis.window as unknown as { maka: unknown }).maka = { sessions: { diff --git a/apps/desktop/src/main/__tests__/session-reference-composer.test.ts b/apps/desktop/src/main/__tests__/session-reference-composer.test.ts index b72e62942c3..2738fc0e0ae 100644 --- a/apps/desktop/src/main/__tests__/session-reference-composer.test.ts +++ b/apps/desktop/src/main/__tests__/session-reference-composer.test.ts @@ -57,6 +57,12 @@ const readExecutionBoundary = async () => { throw new Error('Execution boundary is not used in session reference tests'); }; +const queueStubs = { + promoteQueueEntry: async () => undefined, + retractQueueEntry: async () => undefined, + reorderQueueEntries: async () => undefined, +}; + afterEach(async () => { if (root) await act(() => root?.unmount()); root = undefined; @@ -108,6 +114,7 @@ test('Session reference picker keeps same-Host sessions and send waits for the s sessions: { readSnapshot: async () => snapshot, readExecutionBoundary, + ...queueStubs, }, skills: { listInvocable: async () => [] }, workspace: { searchFiles: async () => ({ ok: false, reason: 'no_project' }) }, @@ -247,6 +254,7 @@ test('send resolves the selected Session snapshot at the send boundary', async ( const services: ConversationServices = { ...sessionLocalServices, sessions: { + ...queueStubs, readSnapshot: async () => new Promise((resolve) => { reads += 1; queueMicrotask(() => resolve({ @@ -352,6 +360,7 @@ test('ignores a snapshot that resolves after the Composer owner changes', async const services: ConversationServices = { ...sessionLocalServices, sessions: { + ...queueStubs, readSnapshot: async () => new Promise((resolve) => { release = resolve; }), diff --git a/apps/desktop/src/main/__tests__/session-turn-request-composer.test.ts b/apps/desktop/src/main/__tests__/session-turn-request-composer.test.ts deleted file mode 100644 index 71c5d232803..00000000000 --- a/apps/desktop/src/main/__tests__/session-turn-request-composer.test.ts +++ /dev/null @@ -1,328 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import assert from 'node:assert/strict'; -import { afterEach, test } from 'node:test'; -import { act, createElement } from 'react'; -import { createRoot, type Root } from 'react-dom/client'; -import { parseHTML } from 'linkedom'; -import { AstryxLocaleProvider, LocaleProvider, ToastProvider } from '@maka/ui'; -import type { SessionTurnAccessRequest } from '@maka/runtime-host/protocol'; -import { - SessionCollaborationServicesProvider, - createFakeSessionCollaborationServices, - SessionTurnRequestComposer, - type SessionCollaborationServices, -} from '../../renderer/features/session-collaboration/testing.js'; - -const originalGlobals = { - document: globalThis.document, - window: globalThis.window, - HTMLElement: globalThis.HTMLElement, - HTMLIFrameElement: globalThis.HTMLIFrameElement, - Event: globalThis.Event, - Node: globalThis.Node, - CSS: globalThis.CSS, - getComputedStyle: globalThis.getComputedStyle, - matchMedia: globalThis.matchMedia, - requestAnimationFrame: globalThis.requestAnimationFrame, - cancelAnimationFrame: globalThis.cancelAnimationFrame, - IS_REACT_ACT_ENVIRONMENT: (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }) - .IS_REACT_ACT_ENVIRONMENT, -}; - -let mountedRoot: Root | undefined; - -afterEach(async () => { - if (mountedRoot) await act(() => mountedRoot?.unmount()); - mountedRoot = undefined; - Object.assign(globalThis, originalGlobals); -}); - -test('keeps a newer Guest draft across remount when an old request settles later', async () => { - let sessionOneQueryCount = 0; - let resolveOldReconciliation: ((requests: { - readonly canRequestTurns: boolean; - readonly requests: readonly SessionTurnAccessRequest[]; - }) => void) | undefined; - const oldReconciliation = new Promise<{ - readonly canRequestTurns: boolean; - readonly requests: readonly SessionTurnAccessRequest[]; - }>((resolve) => { - resolveOldReconciliation = resolve; - }); - const accepted: SessionTurnAccessRequest = { - requestId: 'request-1', - principalId: 'guest-1', - grantId: 'grant-1', - intent: { - sessionId: 'session-1', - turnId: 'turn-1', - content: { text: 'submitted text' }, - }, - createdAt: '2026-09-03T00:00:00.000Z', - state: { kind: 'pending' }, - }; - const services: SessionCollaborationServices = { - ...createFakeSessionCollaborationServices(), - importInvitation: async () => ({ kind: 'connected', mountId: 'unused' }), - cancelImport: async () => 'cancelled', - readInvitationClipboard: async () => '', - listMounts: async () => [], - subscribeMountChanges: () => () => undefined, - removeMount: async () => undefined, - retryMount: async () => undefined, - renameMount: async () => undefined, - renamePrincipal: async () => ({ renamed: true }), - requestTurn: async () => { - throw new Error('connection lost after dispatch'); - }, - getTurnRequests: async (sessionId) => { - if (sessionId !== 'session-1') { - return { canRequestTurns: true, requests: [] }; - } - sessionOneQueryCount += 1; - if (sessionOneQueryCount === 2) return oldReconciliation; - return { canRequestTurns: true, requests: [] }; - }, - acknowledgeTurnRequest: async () => ({ acknowledged: false }), - withdrawTurnRequest: async () => ({ withdrawn: false }), - getPendingTurnRequests: async () => [], - decideTurnRequest: async () => { - throw new Error('unused'); - }, - createOperationId: () => 'turn-1', - }; - const { document } = installDom(() => undefined); - const container = document.querySelector('#root'); - assert.ok(container); - mountedRoot = createRoot(container); - - await act(async () => { - mountedRoot?.render(renderComposer(services, 'session-1')); - await Promise.resolve(); - }); - - const textbox = document.querySelector('[role="textbox"]'); - assert.ok(textbox); - await edit(textbox, 'submitted text'); - await pressEnter(textbox); - assert.equal(sessionOneQueryCount, 2); - - await act(async () => { - mountedRoot?.render(renderComposer(services, 'session-2')); - await Promise.resolve(); - }); - await act(async () => { - mountedRoot?.render(renderComposer(services, 'session-1')); - await Promise.resolve(); - }); - const remountedTextbox = document.querySelector('[role="textbox"]'); - assert.ok(remountedTextbox); - await edit(remountedTextbox, 'next draft'); - - await act(async () => { - resolveOldReconciliation?.({ canRequestTurns: true, requests: [accepted] }); - await Promise.resolve(); - }); - await act(async () => { - mountedRoot?.render(renderComposer(services, 'session-2')); - await Promise.resolve(); - }); - await act(async () => { - mountedRoot?.render(renderComposer(services, 'session-1')); - await Promise.resolve(); - }); - - assert.equal( - document.querySelector('[role="textbox"]')?.textContent, - 'next draft', - ); -}); - -test('resumes an in-flight Guest request across remount without submitting it twice', async () => { - let sessionQueryCount = 0; - let requestCount = 0; - let resolveRequest: ((request: SessionTurnAccessRequest) => void) | undefined; - const requestResult = new Promise((resolve) => { - resolveRequest = resolve; - }); - const accepted: SessionTurnAccessRequest = { - requestId: 'request-resumed', - principalId: 'guest-1', - grantId: 'grant-1', - intent: { - sessionId: 'session-resumed', - turnId: 'turn-resumed', - content: { text: 'one request only' }, - }, - createdAt: '2026-09-03T00:00:00.000Z', - state: { kind: 'pending' }, - }; - const services: SessionCollaborationServices = { - ...createFakeSessionCollaborationServices(), - importInvitation: async () => ({ kind: 'connected', mountId: 'unused' }), - cancelImport: async () => 'cancelled', - readInvitationClipboard: async () => '', - listMounts: async () => [], - subscribeMountChanges: () => () => undefined, - removeMount: async () => undefined, - retryMount: async () => undefined, - renameMount: async () => undefined, - renamePrincipal: async () => ({ renamed: true }), - requestTurn: async () => { - requestCount += 1; - return requestResult; - }, - getTurnRequests: async (sessionId) => { - if (sessionId !== 'session-resumed') { - return { canRequestTurns: true, requests: [] }; - } - sessionQueryCount += 1; - return { - canRequestTurns: true, - requests: sessionQueryCount === 1 ? [] : [accepted], - }; - }, - acknowledgeTurnRequest: async () => ({ acknowledged: false }), - withdrawTurnRequest: async () => ({ withdrawn: false }), - getPendingTurnRequests: async () => [], - decideTurnRequest: async () => { - throw new Error('unused'); - }, - createOperationId: () => 'turn-resumed', - }; - const { document } = installDom(() => undefined); - const container = document.querySelector('#root'); - assert.ok(container); - mountedRoot = createRoot(container); - - await act(async () => { - mountedRoot?.render(renderComposer(services, 'session-resumed')); - await Promise.resolve(); - }); - const textbox = document.querySelector('[role="textbox"]'); - assert.ok(textbox); - await edit(textbox, 'one request only'); - await pressEnter(textbox); - assert.equal(requestCount, 1); - - await act(async () => { - mountedRoot?.render(renderComposer(services, 'session-away')); - await Promise.resolve(); - }); - await act(async () => { - mountedRoot?.render(renderComposer(services, 'session-resumed')); - await Promise.resolve(); - }); - - assert.equal( - document.querySelector('[role="textbox"]')?.textContent, - '', - ); - assert.equal(requestCount, 1); - - await act(async () => { - resolveRequest?.(accepted); - await Promise.resolve(); - }); -}); - -function renderComposer(services: SessionCollaborationServices, sessionId: string) { - return createElement(LocaleProvider, { - locale: 'en', - children: createElement(AstryxLocaleProvider, { - children: createElement(ToastProvider, { - children: createElement(SessionCollaborationServicesProvider, { - services, - children: createElement(SessionTurnRequestComposer, { sessionId }), - }), - }), - }), - }); -} - -function installDom(captureRefresh: (refresh: () => void) => void): { document: Document } { - const parsed = parseHTML('
'); - const { document, window } = parsed; - const nativeSetTimeout = globalThis.setTimeout; - const nativeClearTimeout = globalThis.clearTimeout; - const refreshTimer = 2_000_000_001; - const matchMedia = (media: string) => ({ - matches: false, - media, - onchange: null, - addListener() {}, - removeListener() {}, - addEventListener() {}, - removeEventListener() {}, - dispatchEvent: () => false, - }); - const getComputedStyle = () => ({ - getPropertyValue: () => '', - }) as unknown as CSSStyleDeclaration; - Object.assign(window, { - matchMedia, - getComputedStyle, - scrollTo() {}, - setTimeout(handler: TimerHandler, timeout?: number) { - if (timeout === 2_000 && typeof handler === 'function') { - captureRefresh(handler as () => void); - return refreshTimer; - } - return nativeSetTimeout(handler as (...args: unknown[]) => void, timeout) as unknown as number; - }, - clearTimeout(handle: number) { - if (handle !== refreshTimer) nativeClearTimeout(handle); - }, - }); - Object.assign(globalThis, { - document, - window, - matchMedia, - HTMLElement: window.HTMLElement, - HTMLIFrameElement: window.HTMLIFrameElement ?? class HTMLIFrameElement {}, - Event: window.Event, - Node: window.Node, - CSS: { escape: (value: string) => value }, - getComputedStyle, - requestAnimationFrame: (callback: FrameRequestCallback) => nativeSetTimeout(callback, 0), - cancelAnimationFrame: (handle: number) => clearTimeout(handle), - IS_REACT_ACT_ENVIRONMENT: true, - }); - return { document }; -} - -async function edit(textbox: HTMLElement, value: string): Promise { - await act(async () => { - textbox.textContent = value; - textbox.dispatchEvent(new Event('input', { bubbles: true })); - await Promise.resolve(); - }); -} - -async function pressEnter(textbox: HTMLElement): Promise { - await act(async () => { - const event = new Event('keydown', { bubbles: true }); - Object.defineProperty(event, 'key', { value: 'Enter' }); - textbox.dispatchEvent(event); - await Promise.resolve(); - await Promise.resolve(); - }); -} diff --git a/apps/desktop/src/main/__tests__/session-workspace-action-identity.test.ts b/apps/desktop/src/main/__tests__/session-workspace-action-identity.test.ts index f4d3977038d..02b725dfa6d 100644 --- a/apps/desktop/src/main/__tests__/session-workspace-action-identity.test.ts +++ b/apps/desktop/src/main/__tests__/session-workspace-action-identity.test.ts @@ -20,7 +20,7 @@ import { strict as assert } from 'node:assert'; import { afterEach, describe, it } from 'node:test'; import { act, createElement } from 'react'; -import { LocaleProvider } from '@maka/ui'; +import { LocaleProvider, ToastProvider } from '@maka/ui'; import type { StoredMessage } from '@maka/core/session'; import { cleanupFakeDom, installReactRenderer } from './fake-dom.js'; import { @@ -28,6 +28,8 @@ import { SessionCatalogContext, } from '../../renderer/application/contracts/session-catalog/session-catalog-state.js'; import { useAppShellSessionWorkspace } from '../../renderer/use-app-shell-session-workspace.js'; +import { ConversationServicesProvider } from '../../renderer/features/conversation/index.js'; +import { stubConversationServices } from '../../renderer/features/conversation/testing.js'; import { createDesktopTranscriptRangeController, DesktopTranscriptRangeStore } from '../../renderer/platform/desktop/desktop-transcript-range-store.js'; import { encodeDesktopTranscriptSnapshot } from '../desktop-transcript-ipc.js'; @@ -65,6 +67,7 @@ describe('session workspace action identity', () => { const catalog = createSessionCatalogController(); let workspace!: Workspace; const displays: Array<{ id: string | undefined; messages: StoredMessage[] }> = []; + const services = stubConversationServices(); function Probe(): null { workspace = useAppShellSessionWorkspace({ error: () => {} }); displays.push({ id: workspace.activeId, messages: workspace.messages }); @@ -72,8 +75,13 @@ describe('session workspace action identity', () => { } act(() => root.render(createElement(LocaleProvider, { locale: 'en', - children: createElement(SessionCatalogContext.Provider, { - value: catalog, children: createElement(Probe), + children: createElement(ToastProvider, { + children: createElement(ConversationServicesProvider, { + services, + children: createElement(SessionCatalogContext.Provider, { + value: catalog, children: createElement(Probe), + }), + }), }), }))); act(() => workspace.seedSessions([sessionA, sessionB, sessionC].map((id) => ({ @@ -186,8 +194,13 @@ describe('session workspace action identity', () => { root.render( createElement(LocaleProvider, { locale: 'en', - children: createElement(SessionCatalogContext.Provider, { - value: catalog, children: createElement(Probe), + children: createElement(ToastProvider, { + children: createElement(ConversationServicesProvider, { + services: stubConversationServices(), + children: createElement(SessionCatalogContext.Provider, { + value: catalog, children: createElement(Probe), + }), + }), }), }), ); diff --git a/apps/desktop/src/main/__tests__/transient-message-projection.test.ts b/apps/desktop/src/main/__tests__/transient-message-projection.test.ts index c310f6641cd..9379e59b938 100644 --- a/apps/desktop/src/main/__tests__/transient-message-projection.test.ts +++ b/apps/desktop/src/main/__tests__/transient-message-projection.test.ts @@ -21,11 +21,10 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import type { StoredMessage } from '@maka/core/session'; import type { TransientUserMessageProjection } from '@maka/ui'; -import { deriveMessageQueueProjection } from '../../renderer/application/contracts/message-queue-projection.js'; import { mergeTransientMessageProjection, - projectQueuedTransientMessages, reconcileTransientMessages, + withQueuedSteeringTransients, } from '../../renderer/application/contracts/transient-message-projection.js'; /** The durable Message that replaces the transient row above. */ @@ -103,88 +102,61 @@ test('keeps transient messages ordered independently from a sparse durable tail' assert.deepEqual(projected.map((message) => message.id), ['message-1']); }); -test('uses the Host queue snapshot order for already-present transient messages', () => { - const localSecond = { - ...transient, - id: 'message-2', - turnId: 'message-2', - text: 'second', +test('queued steering derives a transcript bubble that lives and dies with the snapshot', async () => { + const queueEntry = { + entryId: 'steer', + messageId: 'message-steer', + content: { text: 'raw', displayText: 'steer', quotes: [{ text: 'context' }] }, + placement: 'current_turn' as const, + state: 'queued' as const, }; - const remoteFirst = { - ...transient, - id: 'message-1', - turnId: 'message-1', - text: 'first', + const followupEntry = { + entryId: 'next', + messageId: 'message-next', + content: { text: 'follow up' }, + placement: 'next_turn' as const, + state: 'queued' as const, }; - const pending = new Map([[localSecond.id, localSecond]]); - - projectQueuedTransientMessages(pending, [remoteFirst, localSecond]); - - assert.deepEqual( - reconcileTransientMessages(pending, []).map((message) => message.id), - ['message-1', 'message-2'], - ); -}); - -test('derives one queue projection for main and Side Conversation consumers', () => { - const projection = deriveMessageQueueProjection({ - type: 'queue_update', - id: 'queue-1', - turnId: 'turn-1', - ts: 7, - steering: ['in flight', 'steer'], - followup: ['next'], - steeringEntries: [ - { - entryId: 'in-flight', - messageId: 'message-in-flight', - content: { text: 'in flight' }, - placement: 'current_turn', - state: 'in_flight', - }, - { - entryId: 'steer', - messageId: 'message-steer', - content: { text: 'raw', displayText: 'steer', quotes: [{ text: 'context' }] }, - placement: 'current_turn', - state: 'queued', - }, - ], - followupEntries: [ - { - entryId: 'next', - messageId: 'message-next', - content: { text: 'next' }, - placement: 'next_turn', - state: 'queued', - }, - ], - }); - - assert.deepEqual(projection.entries.map((entry) => entry.entryId), ['in-flight', 'steer', 'next']); - assert.deepEqual(projection.transientMessages, [ - { - id: 'message-in-flight', - transientPlacement: 'steering', - hostTurnId: 'turn-1', - ts: 7, - text: 'in flight', - }, - { - id: 'message-steer', - transientPlacement: 'steering', - hostTurnId: 'turn-1', - ts: 7, - text: 'steer', - quotes: [{ text: 'context' }], - }, - { - id: 'message-next', - transientPlacement: 'follow_up', - ts: 7, - text: 'next', + const queue = { ts: 7, entries: [queueEntry, followupEntry] }; + const retracted: string[] = []; + const restored: string[] = []; + let failRetract = false; + const actions = { + locale: 'en' as const, + retract: async (entryId: string) => { + if (failRetract) throw new Error('retract failed'); + retracted.push(entryId); }, - ]); + restoreDraft: (draft: { text: string }) => { restored.push(draft.text); }, + }; + const localCopy = { ...transient, id: 'message-steer', text: 'local copy' }; + + const derived = withQueuedSteeringTransients([transient, localCopy], queue, actions); + + assert.deepEqual(derived.map((message) => message.id), ['message-1', 'message-steer'], + 'the queue-owned bubble replaces its stored local copy, and a queued follow-up stays out of the transcript'); + const bubble = derived.at(-1); + assert.equal(bubble?.text, 'steer'); + assert.deepEqual(bubble?.quotes, [{ text: 'context' }]); + assert.deepEqual(bubble?.deliveryActions?.map((action) => action.label), ['Edit', 'Delete']); + failRetract = true; + await bubble?.deliveryActions?.[0]?.onClick(); + assert.deepEqual(restored, [], 'a failed retract restores nothing'); + failRetract = false; + await bubble?.deliveryActions?.[0]?.onClick(); + assert.deepEqual([retracted, restored], [['steer'], ['steer']], 'edit retracts, then hands the text back'); + await bubble?.deliveryActions?.[1]?.onClick(); + assert.deepEqual([retracted, restored], [['steer', 'steer'], ['steer']], 'delete retracts without a draft'); + + assert.equal( + withQueuedSteeringTransients([transient], { ...queue, entries: [] }, actions).length, + 1, + 'an entry gone from the snapshot leaves no bubble behind', + ); + assert.equal( + withQueuedSteeringTransients([transient], undefined, actions).length, + 1, + ); }); test('keeps a Host-bound current Turn when a later IPC result has no Turn identity', () => { diff --git a/apps/desktop/src/main/__tests__/workbar-services-adapter.test.ts b/apps/desktop/src/main/__tests__/workbar-services-adapter.test.ts index fd54fa2ec2f..0a670e29d2b 100644 --- a/apps/desktop/src/main/__tests__/workbar-services-adapter.test.ts +++ b/apps/desktop/src/main/__tests__/workbar-services-adapter.test.ts @@ -275,7 +275,6 @@ describe('createDesktopWorkbarServices', () => { await services.sideChat.queryMessageExecutions('fork', ['message-next']); await services.sideChat.retractQueueEntry('fork', 'entry-1'); await services.sideChat.promoteQueueEntry('fork', 'entry-2'); - await services.sideChat.updateQueueEntry('fork', 'entry-3', 4, 'updated'); await services.sideChat.reorderQueueEntries('fork', ['entry-3', 'entry-2']); await services.sideChat.setPermissionMode('fork', 'ask'); await services.sideChat.respondToSandboxBoundary('fork', {} as never); @@ -332,7 +331,6 @@ describe('createDesktopWorkbarServices', () => { 'sessions.queryMessageExecutions', 'sessions.retractQueueEntry', 'sessions.promoteQueueEntry', - 'sessions.updateQueueEntry', 'sessions.reorderQueueEntries', 'sessions.setPermissionMode', 'sessions.respondToSandboxBoundary', diff --git a/apps/desktop/src/main/__tests__/workhub-composer-restore.test.ts b/apps/desktop/src/main/__tests__/workhub-composer-restore.test.ts new file mode 100644 index 00000000000..14757acd62d --- /dev/null +++ b/apps/desktop/src/main/__tests__/workhub-composer-restore.test.ts @@ -0,0 +1,139 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { afterEach, test } from 'node:test'; +import { act, createElement, type MutableRefObject } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { parseHTML } from 'linkedom'; +import type { AttachmentRef } from '@maka/core/events'; +import { AstryxLocaleProvider, LocaleProvider, ToastProvider } from '@maka/ui'; +import { WorkHubServicesProvider, type WorkHubServices } from '../../renderer/features/workhub/index.js'; +import { WorkHubComposer } from '../../renderer/features/workhub/testing.js'; +import type { RestoredDraftContent } from '../../renderer/application/contracts/transient-message-projection.js'; + +const originalGlobals = { + document: globalThis.document, + window: globalThis.window, + HTMLElement: globalThis.HTMLElement, + Element: globalThis.Element, + Event: globalThis.Event, + Node: globalThis.Node, + matchMedia: globalThis.matchMedia, + requestAnimationFrame: globalThis.requestAnimationFrame, + cancelAnimationFrame: globalThis.cancelAnimationFrame, + IS_REACT_ACT_ENVIRONMENT: (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }) + .IS_REACT_ACT_ENVIRONMENT, +}; + +let mountedRoot: Root | undefined; + +afterEach(async () => { + if (mountedRoot) await act(() => mountedRoot?.unmount()); + mountedRoot = undefined; + Object.assign(globalThis, originalGlobals); +}); + +async function mountComposer() { + const { document, window } = parseHTML('
'); + const getSelection = () => null; + Object.assign(document, { getSelection }); + Object.assign(window, { + getSelection, + getComputedStyle: () => + ({ direction: 'ltr', writingMode: 'horizontal-tb', getPropertyValue: () => '' }) as unknown as CSSStyleDeclaration, + matchMedia: () => + ({ matches: false, addEventListener() {}, removeEventListener() {} }) as unknown as MediaQueryList, + }); + Object.assign(globalThis, { + document, + window, + HTMLElement: window.HTMLElement, + Element: window.Element, + Event: window.Event, + Node: window.Node, + matchMedia: window.matchMedia, + requestAnimationFrame: (callback: FrameRequestCallback) => setTimeout(callback, 0), + cancelAnimationFrame: (handle: number) => clearTimeout(handle), + IS_REACT_ACT_ENVIRONMENT: true, + }); + const root = createRoot(document.querySelector('#root')!); + mountedRoot = root; + const uploads: unknown[] = []; + const sends: Array<{ text: string; attachments: AttachmentRef[] }> = []; + const draftRestore: MutableRefObject< + ((sessionId: string, draft: RestoredDraftContent) => void) | undefined + > = { current: undefined }; + const services = { + attachments: {}, + prepareAttachments: async (_sessionId: string, items: unknown[]) => { + uploads.push(...items); + return []; + }, + } as unknown as WorkHubServices; + await act(async () => { + root.render(createElement(LocaleProvider, { + locale: 'en', + children: createElement(AstryxLocaleProvider, { + children: createElement(ToastProvider, { + children: createElement(WorkHubServicesProvider, { services }, + createElement(WorkHubComposer, { + sessionId: 'workhub-session', + draftRestore, + onStop: () => undefined, + onSend: async (text, attachments) => { + sends.push({ text, attachments }); + return true; + }, + })), + }), + }), + })); + }); + return { + uploads, + sends, + async restore(draft: RestoredDraftContent) { + await act(async () => draftRestore.current!('workhub-session', draft)); + }, + async submit() { + await act(async () => { + document.querySelector('form')!.dispatchEvent( + new window.Event('submit', { bubbles: true, cancelable: true }), + ); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + }, + }; +} + +test('a retracted edit resends its restored attachment as the same Host reference', async () => { + const h = await mountComposer(); + const brief: AttachmentRef = { + kind: 'doc', + name: 'brief.txt', + mimeType: 'text/plain', + bytes: 4, + ref: { kind: 'workspace_file', relativePath: 'brief.txt' }, + }; + await h.restore({ text: 'redo this with the brief', attachments: [brief] }); + await h.submit(); + assert.deepEqual(h.sends, [{ text: 'redo this with the brief', attachments: [brief] }]); + assert.deepEqual(h.uploads, [], 'a Host attachment is reused, not uploaded again'); +}); diff --git a/apps/desktop/src/main/__tests__/workhub-send-visibility.test.ts b/apps/desktop/src/main/__tests__/workhub-send-visibility.test.ts index 8225b7bab90..ac5d80b03f9 100644 --- a/apps/desktop/src/main/__tests__/workhub-send-visibility.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-send-visibility.test.ts @@ -50,8 +50,11 @@ async function mountController(failFirstRead = false, overrides: Partial[1]> = []; let rootTurn: { turnId: string; runId: string; status: 'running' | 'cancelled' | 'completed' } | undefined; const queueMutations: unknown[][] = []; + const restoredDrafts: [string, string][] = []; const steers: Array> = []; let steerResult: Awaited> = 'admitted'; + const executionQueries: string[][] = []; + let executionResolutions: Awaited>['resolutions'] = []; let newWorkDefaults: Awaited> = {}; let onSteer: ((input: Parameters) => void) | undefined; const interrupts: Array<{ sessionId: string; turnId: string; runId: string }> = []; @@ -115,9 +118,12 @@ async function mountController(failFirstRead = false, overrides: Partial) => { queueMutations.push(['retract', ...input]); }, promoteQueueEntry: async (...input: Parameters) => { queueMutations.push(['promote', ...input]); }, - updateQueueEntry: async (...input: Parameters) => { queueMutations.push(['update', ...input]); }, reorderQueueEntries: async (...input: Parameters) => { queueMutations.push(['reorder', ...input]); }, enqueueMessage: async (...input: Parameters) => { steers.push(input); onSteer?.(input); return steerResult; }, + queryMessageExecutions: async (_id: string, messageIds: readonly string[]) => { + executionQueries.push([...messageIds]); + return { resolutions: executionResolutions }; + }, listActiveInteractions: async () => [], subscribeActiveInteractions: () => () => {}, respondToUserForm: async () => {}, @@ -130,7 +136,7 @@ async function mountController(failFirstRead = false, overrides: Partial { submissions++; }); return null; } + function Probe() { controller = useWorkHubController(() => { submissions++; }, (sessionId, draft) => { restoredDrafts.push([sessionId, draft.text]); }); return null; } await act(async () => { root.render(createElement(LocaleProvider, { locale: 'en', children: createElement(WorkHubServicesProvider, { services }, createElement(Probe)), @@ -143,7 +149,8 @@ async function mountController(failFirstRead = false, overrides: Partial(); }, @@ -593,6 +600,44 @@ test('Host retraction resolves an uncertain WorkHub attempt before the next draf assert.notEqual(h.steers[1]![1], messageId); }); +test('an unobserved retraction resolves an uncertain WorkHub send on reseed', async () => { + const h = await mountController(); + await act(() => { h.admit('active-turn'); h.emit({ type: 'text_delta', id: 'live', turnId: 'active-turn', messageId: 'answer', ts: 1, text: 'Working' }); }); + h.setSteerResult('unknown'); + await act(async () => { assert.equal(await h.controller.send('old direction', [], 'steer'), false); }); + const messageId = h.steers[0]![1]; + assert.deepEqual(h.controller.transientMessages.map((message) => message.id), [messageId]); + // The Host retracted the entry while Desktop was away: no admission event + // replays and the reseeded queue arrives already empty — only the + // resolution record can still say what happened to the send. + h.setExecutionResolutions([{ messageId, state: 'cancelled' }]); + await act(async () => h.reconnect()); + assert.deepEqual(h.executionQueries, [[messageId]]); + assert.deepEqual(h.controller.transientMessages, []); + h.setSteerResult('admitted'); + await act(async () => { assert.equal(await h.controller.send('new direction', [], 'steer'), true); }); + assert.notEqual(h.steers[1]![1], messageId); +}); + +test('a queued send consumed while away keeps its row until the durable merge but unblocks retry', async () => { + const h = await mountController(); + await act(() => { h.admit('active-turn'); h.emit({ type: 'text_delta', id: 'live', turnId: 'active-turn', messageId: 'answer', ts: 1, text: 'Working' }); }); + h.setSteerResult('unknown'); + await act(async () => { assert.equal(await h.controller.send('do this next', []), false); }); + const messageId = h.steers[0]![1]; + assert.deepEqual(h.controller.transientMessages.map((message) => message.id), [messageId]); + // The Host drained the entry into a successor Turn during the gap. + h.setExecutionResolutions([{ messageId, state: 'owned', turnId: 'followup-turn', runId: 'run-2' }]); + await act(async () => h.reconnect()); + assert.deepEqual(h.executionQueries, [[messageId]]); + assert.deepEqual(h.controller.transientMessages.map((message) => message.id), [messageId], + 'the placeholder stays until its durable row lands — never dropped, never stuck in the plate'); + await act(() => h.publish([{ type: 'user', id: messageId, turnId: 'followup-turn', ts: 3, text: 'do this next' } as StoredMessage])); + assert.deepEqual(h.controller.transientMessages, []); + h.setSteerResult('admitted'); + await act(async () => { assert.equal(await h.controller.send('another message', []), true); }); +}); + test('steering observed before its admission response renders once and outranks an uncertain receipt', async () => { const h = await mountController(); await act(() => { h.admit('active-turn'); h.emit({ type: 'text_delta', id: 'live', turnId: 'active-turn', messageId: 'answer', ts: 1, text: 'Working' }); }); @@ -605,7 +650,7 @@ test('steering observed before its admission response renders once and outranks }); -test('WorkHub Host queue owns restored, consumed and retracted rows without transient mirrors', async () => { +test('WorkHub queued steering keeps a transcript bubble until consumed or retracted', async () => { const h = await mountController(); await act(() => { h.admit('active-turn'); h.emit({ type: 'text_delta', id: 'live', turnId: 'active-turn', messageId: 'answer', ts: 1, text: 'Working' }); }); const entry = { entryId: 'queued', messageId: 'queued', placement: 'current_turn' as const, state: 'queued' as const, content: { text: 'change direction' } }; @@ -615,13 +660,16 @@ test('WorkHub Host queue owns restored, consumed and retracted rows without tran }); await act(() => project('queued')); assert.deepEqual(h.controller.messageQueue.entries, [entry]); - assert.deepEqual(h.controller.transientMessages, []); + assert.deepEqual(h.controller.transientMessages.map((message) => message.id), ['queued'], + 'queued steering waits in the transcript, not the follow-up plate'); + await act(() => h.emit({ type: 'steering_message', id: 'consumed', turnId: 'active-turn', ts: 3, messageId: entry.messageId, content: entry.content })); await act(() => project('in_flight')); assert.deepEqual(h.controller.messageQueue.entries, [{ ...entry, state: 'in_flight' }], 'a pulled message stays pending until the runtime places it'); await act(() => h.emit({ type: 'steering_message', id: 'consumed', turnId: 'active-turn', ts: 3, messageId: entry.messageId, content: entry.content })); assert.deepEqual(h.controller.messageQueue.entries, []); assert.deepEqual(h.controller.transientMessages, []); await act(() => project('queued')); + assert.equal(h.controller.transientMessages.length, 1); await act(async () => { await h.controller.deleteQueuedEntry(entry.entryId); }); await act(() => h.emit({ type: 'queue_update', id: 'removed', turnId: 'active-turn', ts: 4, steering: [], followup: [], steeringEntries: [] })); assert.deepEqual(h.controller.messageQueue.entries, []); @@ -635,20 +683,22 @@ test('WorkHub sends queue edits, withdrawal and both queue orders to the Host an await act(() => h.emit({ type: 'queue_update', id: 'queued', turnId: 'active-turn', ts: 2, queueRevision: 7, steering: ['first', 'second'], followup: [], steeringEntries: entries })); await act(async () => { - await h.controller.updateQueuedEntry('second', 7, 'edited second'); + await h.controller.editQueuedEntry?.(entries[1]!); await h.controller.reorderQueuedEntries(['second', 'first']); await h.controller.deleteQueuedEntry('first'); }); assert.deepEqual(h.queueMutations, [ - ['update', h.controller.sessionId, 'second', 7, 'edited second'], + ['retract', h.controller.sessionId, 'second'], ['reorder', h.controller.sessionId, ['second', 'first']], ['retract', h.controller.sessionId, 'first'], ]); + assert.deepEqual(h.restoredDrafts, [[h.controller.sessionId, 'second']], 'edit hands the retracted text back to the draft'); assert.deepEqual(h.controller.messageQueue.entries.map((entry) => entry.entryId), ['first', 'second']); await act(() => h.emit({ type: 'queue_update', id: 'updated', turnId: 'active-turn', ts: 3, queueRevision: 10, steering: ['edited second'], followup: [], steeringEntries: [{ ...entries[1]!, content: { text: 'edited second' } }] })); assert.deepEqual(h.controller.messageQueue.entries.map((entry) => entry.content.text), ['edited second']); - assert.deepEqual(h.controller.transientMessages, []); + assert.deepEqual(h.controller.transientMessages.map((message) => message.text), ['edited second'], + 'the queue snapshot retires the edited-out steering bubble and republishes the edited one'); }); diff --git a/apps/desktop/src/main/runtime-host-client.ts b/apps/desktop/src/main/runtime-host-client.ts index 1ece571ba87..f2c6954a287 100644 --- a/apps/desktop/src/main/runtime-host-client.ts +++ b/apps/desktop/src/main/runtime-host-client.ts @@ -104,7 +104,6 @@ import { type QueueEntriesReorderInput, type QueueEntryPromoteInput, type QueueEntryRetractInput, - type QueueEntryUpdateInput, type QueueMutationResult, SESSION_TRANSCRIPT_BOOTSTRAP_MAX_BYTES, type SessionCatalogChangedFrame, @@ -1342,15 +1341,6 @@ export class DesktopRuntimeHostClient { }); } - updateQueueEntry( - input: Omit, - ): Promise { - return this.request("queue.entry.update", { - ...input, - originHostEpoch: this.connection.hostEpoch, - }); - } - reorderQueueEntries( input: Omit, ): Promise { 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 821ecd2e5ee..62fe6dbd302 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 @@ -124,7 +124,6 @@ type RuntimeHostSessionExecutionClient = Pick< | "readExecutionBoundary" | "retractQueueEntry" | "promoteQueueEntry" - | "updateQueueEntry" | "reorderQueueEntries" | "setSessionReadMarker" | "startTurnResume" @@ -675,25 +674,6 @@ export function registerRuntimeHostSessionExecutionIpc( }); }, ); - ipcMain.handle( - "sessions:updateQueueEntry", - async ( - _event, - sessionId: unknown, - entryId: unknown, - expectedQueueRevision: unknown, - text: unknown, - ) => { - const normalizedText = requiredText(text, "Queued message").trim(); - await deps.client.updateQueueEntry({ - sessionId: requiredId(sessionId, "Session"), - entryId: requiredId(entryId, "Queue entry"), - updateId: newId(), - expectedQueueRevision: requiredSequence(expectedQueueRevision, "Queue"), - text: normalizedText, - }); - }, - ); ipcMain.handle( "sessions:reorderQueueEntries", async (_event, sessionId: string, entryIds: unknown) => { @@ -1083,24 +1063,6 @@ function requiredMessageId(value: unknown): string { return value; } -function requiredText(value: unknown, label: string): string { - if ( - typeof value !== "string" || - value.trim().length === 0 || - Buffer.byteLength(value, "utf8") > 48 * 1024 - ) { - throw new Error(`Invalid ${label} text`); - } - return value; -} - -function requiredSequence(value: unknown, label: string): number { - if (!Number.isSafeInteger(value) || (value as number) < 0) { - throw new Error(`Invalid ${label} sequence`); - } - return value as number; -} - function normalizeSnapshotMaxChars(options: unknown): number { if (options === undefined) return SESSION_SNAPSHOT_DEFAULT_MAX_CHARS; if (!options || typeof options !== 'object' || Array.isArray(options)) { diff --git a/apps/desktop/src/main/runtime-host-session-observer.ts b/apps/desktop/src/main/runtime-host-session-observer.ts index d9ee8d6d3ae..cf669c11620 100644 --- a/apps/desktop/src/main/runtime-host-session-observer.ts +++ b/apps/desktop/src/main/runtime-host-session-observer.ts @@ -764,7 +764,7 @@ export class RuntimeHostSessionObserver { state: ObservedSessionState, group: ObserverTargetGroup, observerId?: string, - events = state.projector?.seedActive(true) ?? [], + events = state.projector?.seedActive(true, { includeEmptyQueue: true }) ?? [], ): void { if (!state.snapshot || !state.replica) return; const observerIds = observerId ? [observerId] : [...group.observerIds]; @@ -1754,7 +1754,7 @@ function replacementProjection( const previousRoot = previous.rootTurn; const root = next.rootTurn; const terminalEvents: SessionEvent[] = []; - const seedEvents = projector.seedActive(true); + const seedEvents = projector.seedActive(true, { includeEmptyQueue: true }); if (previousRoot && !isTerminalTurn(previousRoot)) { if (!root || root.runId !== previousRoot.runId) { const stored = projector.seedStoredTerminal( diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts index 5aed37c36ab..bb376a5fc6d 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -1293,12 +1293,6 @@ export interface MakaBridge { ): Promise; retractQueueEntry(sessionId: string, entryId: string): Promise; promoteQueueEntry(sessionId: string, entryId: string): Promise; - updateQueueEntry( - sessionId: string, - entryId: string, - expectedQueueRevision: number, - text: string, - ): Promise; reorderQueueEntries(sessionId: string, entryIds: readonly string[]): Promise; readExecutionBoundary(sessionId: string): Promise; listActiveInteractions(sessionId: string): Promise; diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 6476d066c49..a338e69bfa3 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -2347,20 +2347,6 @@ const makaBridge = { promoteQueueEntry(sessionId: string, entryId: string): Promise { return invokeSessionRuntimeHost('sessions:promoteQueueEntry', sessionId, entryId); }, - updateQueueEntry( - sessionId: string, - entryId: string, - expectedQueueRevision: number, - text: string, - ): Promise { - return invokeSessionRuntimeHost( - 'sessions:updateQueueEntry', - sessionId, - entryId, - expectedQueueRevision, - text, - ); - }, reorderQueueEntries(sessionId: string, entryIds: readonly string[]): Promise { return invokeSessionRuntimeHost('sessions:reorderQueueEntries', sessionId, [...entryIds]); }, diff --git a/apps/desktop/src/renderer/app-shell-chat-actions.ts b/apps/desktop/src/renderer/app-shell-chat-actions.ts index 985e6cc18cb..fff2ad5cb55 100644 --- a/apps/desktop/src/renderer/app-shell-chat-actions.ts +++ b/apps/desktop/src/renderer/app-shell-chat-actions.ts @@ -47,7 +47,6 @@ import * as skillFeedback from './skill-invocation-feedback.js'; import type { DesktopTranscriptRangeController } from './platform/desktop/desktop-transcript-range-store.js'; import type { SessionPendingClaim } from './app-shell-session-ui-state.js'; import * as Conversation from './features/conversation/index.js'; -import type { PendingAttachment } from './composer-attachments.js'; export interface WorkspaceFileReferencePosition { value: string; @@ -115,7 +114,7 @@ function copiedArray( export interface AppShellChatActions { send( text: string, - pending?: readonly PendingAttachment[], + pending?: readonly Conversation.PendingAttachment[], options?: SendOptions, ): Promise; /** @@ -127,7 +126,7 @@ export interface AppShellChatActions { sessionId: string, text: string, placement: 'current_turn' | 'next_turn', - pending?: readonly PendingAttachment[], + pending?: readonly Conversation.PendingAttachment[], options?: MessageContextOptions, ): Promise; respondToSandboxBoundary(response: SandboxBoundaryResponse): Promise; @@ -249,7 +248,6 @@ export function createAppShellChatActions(deps: { >; displayText?: string; quotes?: readonly QuoteRef[]; - steering?: boolean; waitForHostAdmission?: boolean; /** Whether this Session's surface is on screen to receive Skill feedback. */ isSurfaceVisible?: () => boolean; @@ -282,8 +280,7 @@ export function createAppShellChatActions(deps: { id: messageId, text: input.displayText ?? skillFeedback.skillInvocationDisplayText(input.command.text, result.skillInvocation), attachments: [...result.attachments], - transientPlacement: result.disposition === 'turn_started' ? 'transcript' - : placement === 'next_turn' ? 'follow_up' : input.steering ? 'steering' : 'transcript', + transientPlacement: result.disposition !== 'turn_started' && placement === 'next_turn' ? 'follow_up' : 'transcript', ...(result.turnId ? { hostTurnId: result.turnId } : {}), ...copiedArray('directoryReferences', directoryReferences), ...copiedArray('quotes', input.quotes ?? []), @@ -298,7 +295,7 @@ export function createAppShellChatActions(deps: { async function send( text: string, - pending?: readonly PendingAttachment[], + pending?: readonly Conversation.PendingAttachment[], options: SendOptions = {}, ): Promise { const { directoryReferences, quotes } = options; @@ -339,18 +336,13 @@ export function createAppShellChatActions(deps: { }; try { async function submitIntoSession(sessionId: string, messageId: string) { + const attachments = Conversation.toSubmittedAttachments(pending ?? []); const sendCommand = { text, localDisplayPlacement: 'current_turn' as const, ...(options.displayText ? { displayText: options.displayText } : {}), - ...copiedArray( - 'attachmentItems', - pending && Conversation.toComposerIngestItems(pending), - ), - ...copiedArray( - 'retainedAttachments', - pending && Conversation.retainedAttachmentRefs(pending), - ), + ...copiedArray('attachmentItems', attachments.attachmentItems), + ...copiedArray('retainedAttachments', attachments.retainedAttachments), ...copiedArray('directoryReferences', directoryReferences), ...copiedArray('quotes', quotes), ...copiedArray('workspaceFileReferences', options.workspaceFileReferences), @@ -487,29 +479,27 @@ export function createAppShellChatActions(deps: { sessionId: string, text: string, placement: 'current_turn' | 'next_turn', - pending?: readonly PendingAttachment[], + pending?: readonly Conversation.PendingAttachment[], options: MessageContextOptions = {}, ): Promise { const messageId = crypto.randomUUID(); const steeringTurnId = placement === 'current_turn' ? deps.getRunningTurnId?.(sessionId) : undefined; const directoryReferences = options.directoryReferences; const quotes = options.quotes ?? []; + const { attachmentItems, retainedAttachments = [] } = Conversation.toSubmittedAttachments(pending ?? []); publishTransientUserMessage(sessionId, { - id: messageId, text, attachments: Conversation.retainedAttachmentRefs(pending ?? []), + id: messageId, text, attachments: retainedAttachments, ...(steeringTurnId ? { hostTurnId: steeringTurnId } : {}), - transientPlacement: placement === 'current_turn' ? 'steering' : 'follow_up', + transientPlacement: placement === 'next_turn' ? 'follow_up' : 'transcript', ...copiedArray('directoryReferences', directoryReferences), ...copiedArray('quotes', quotes), inlineReferences: [], }); try { - const attachmentItems = pending?.length ? Conversation.toComposerIngestItems(pending) : []; - const retainedAttachments = pending?.length ? Conversation.retainedAttachmentRefs(pending) : []; const submitted = await submitAndProject({ sessionId, messageId, placement, - steering: placement === 'current_turn', command: { text, ...copiedArray('attachmentItems', attachmentItems), diff --git a/apps/desktop/src/renderer/app-shell-session-events.ts b/apps/desktop/src/renderer/app-shell-session-events.ts index f5af41d62e7..d5bf2663c25 100644 --- a/apps/desktop/src/renderer/app-shell-session-events.ts +++ b/apps/desktop/src/renderer/app-shell-session-events.ts @@ -314,10 +314,7 @@ export function createAppShellSessionEventHandlers(options: { } return { ...current, - [sessionId]: { - queueRevision: event.queueRevision, - entries: queue.entries, - }, + [sessionId]: queue, }; }); break; diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index 6f4778f0b3f..72d3b5f384b 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -341,6 +341,7 @@ function AppShellContent({ sharedSessionActive, ownerActiveId, switchingSession, + queueSurface, } = useAppShellSessionWorkspace(toastApi); // The shell's own reading of the catalog rides the membership set the list // hook already publishes — background row churn belongs to the rail, which @@ -394,6 +395,7 @@ function AppShellContent({ pickAttachments, attachFilePaths, restoreAttachments, + restoreDirectories, removeAttachment, clearSubmittedContext, imageNoticeLifecycle, @@ -402,6 +404,7 @@ function AppShellContent({ quotesForSend, addQuote, clearQuotes, + restoreQuotes, composerQuoteProps, chatViewQuoteProps, } = useComposerAttachments({ @@ -609,7 +612,6 @@ function AppShellContent({ // recent workspace history so the home view is populated before the async // `app:info` round-trip completes on mount. const persistedComposerDefaults = loadComposerDefaults(); - const composerRef = useRef(null); const openComposerModelPicker = useCallback(() => { composerRef.current?.openModelPicker(); }, []); @@ -664,6 +666,24 @@ function AppShellContent({ } : undefined; const activeMessageQueue = activeId ? messageQueueBySession[activeId] : undefined; + const { + composer: composerRef, + transientMessages: transcriptTransientMessages, + restoreDraft: restoreLocalMessageDraft, + draftContextRestorer, + promoteQueuedEntry, + editQueuedEntry, + deleteQueuedEntry, + reorderQueuedEntries, + } = queueSurface; + // A retracted send hands its staged context back through the same keyed + // stores a picked file or quote would land in — keyed by Session, so the + // restore survives the owning Session navigating away mid-request. + draftContextRestorer.current = (targetSessionId, draft) => { + if (draft.attachments?.length) restoreAttachments(targetSessionId, draft.attachments); + if (draft.directoryReferences?.length) restoreDirectories(targetSessionId, draft.directoryReferences); + if (draft.quotes?.length) restoreQuotes(targetSessionId, draft.quotes); + }; // The shell's reading of the active live turn: streaming/settled flags, the // in-flight tool signal, and the #646 turn-wait cues, all derived from the // semantic snapshot rather than the projection (#1985). @@ -1617,58 +1637,7 @@ function AppShellContent({ return ok; } - async function updateQueuedEntry( - entryId: string, - expectedQueueRevision: number, - text: string, - ): Promise { - await runQueueEntryAction((sessionId) => - window.maka.sessions.updateQueueEntry(sessionId, entryId, expectedQueueRevision, text) - ); - } - - async function deleteQueuedEntry(entryId: string): Promise { - const messageId = activeMessageQueue?.entries.find((entry) => entry.entryId === entryId)?.messageId; - const sessionId = await runQueueEntryAction((sessionId) => - window.maka.sessions.retractQueueEntry(sessionId, entryId).then(() => undefined) - ); - if (sessionId && messageId) removeTransientMessage(sessionId, messageId); - } - - // Surfaces the failure, then rethrows so the pending plate can settle its - // in-flight action state without guessing with a timer. - async function runQueueEntryAction( - action: (sessionId: string) => Promise, - ): Promise { - const sessionId = activeIdRef.current; - if (!sessionId) return; - try { - await action(sessionId); - return sessionId; - } catch (error) { - if (activeIdRef.current === sessionId) { - const copy = getDesktopConversationCopy(uiLocale).actions; - showSessionError( - sessionId, - copy.operationFailedTitle, - localizedShellErrorMessage(error, copy.operationFailedFallback, uiLocale), - ); - } - throw error; - } - } - - async function promoteQueuedEntry(entryId: string): Promise { - await runQueueEntryAction((sessionId) => - window.maka.sessions.promoteQueueEntry(sessionId, entryId).then(() => undefined) - ); - } - async function reorderQueuedEntries(entryIds: readonly string[]): Promise { - await runQueueEntryAction((sessionId) => - window.maka.sessions.reorderQueueEntries(sessionId, entryIds).then(() => undefined) - ); - } const stop = createAppShellStopAction({ uiLocale, @@ -2071,7 +2040,13 @@ function AppShellContent({ canOpenDialog={activeBoundarySurface.localInteractionAvailable} reportError={showSessionError} > - + ) : null} {!sharedSessionActive && sessionsSelected ? : null} - {sharedSessionActive && activeId ? ( - - ) : ( - - {(workspacePicker) => ( + + {(workspacePicker) => ( + + {(guest) => ( - )} - - )} + )} + + )} + } > @@ -2460,7 +2436,7 @@ function AppShellContent({ onLoadTranscriptTurn={(turn) => transcriptReadingCommands.current?.loadEarlier(turn.sequence)} liveContentSeedRevision={liveContent.liveContentSeedRevision(activeEventSeed, activeId)} messages={messages} - transientMessages={transientMessages} + transientMessages={transcriptTransientMessages} messageLoading={activeMessageLoading} onStreamingSettled={ activeId ? (messageId) => settleAssistantStreaming(activeId, messageId) : undefined diff --git a/apps/desktop/src/renderer/application/contracts/message-queue-projection.ts b/apps/desktop/src/renderer/application/contracts/message-queue-projection.ts index 7325868a381..1b9712d79cd 100644 --- a/apps/desktop/src/renderer/application/contracts/message-queue-projection.ts +++ b/apps/desktop/src/renderer/application/contracts/message-queue-projection.ts @@ -21,11 +21,11 @@ import type { MessageQueueEntryProjection, QueueUpdateEvent, } from '@maka/core/events'; -import type { TransientUserMessageProjection } from '@maka/ui'; +/** The last Host queue snapshot a surface renders from. */ export interface MessageQueueProjection { + readonly ts: number; readonly entries: readonly MessageQueueEntryProjection[]; - readonly transientMessages: readonly TransientUserMessageProjection[]; } /** One presentation contract for Host queue snapshots in every chat surface. */ @@ -37,21 +37,7 @@ export function deriveMessageQueueProjection( ...(event.followupEntries ?? []), ].map((entry) => structuredClone(entry)); return { + ts: event.ts, entries, - transientMessages: entries.map((entry) => ({ - id: entry.messageId, - transientPlacement: entry.placement === 'current_turn' ? 'steering' : 'follow_up', - ...(entry.placement === 'current_turn' && { hostTurnId: event.turnId }), - ts: event.ts, - text: entry.content.displayText ?? entry.content.text, - ...(entry.content.attachments && { attachments: [...entry.content.attachments] }), - ...(entry.content.directoryReferences && { - directoryReferences: entry.content.directoryReferences, - }), - ...(entry.content.quotes && { quotes: [...entry.content.quotes] }), - ...(entry.content.inlineReferences && { - inlineReferences: [...entry.content.inlineReferences], - }), - })), }; } diff --git a/apps/desktop/src/renderer/application/contracts/transient-message-projection.ts b/apps/desktop/src/renderer/application/contracts/transient-message-projection.ts index 29a9c8411cb..8f6a4a1f1cc 100644 --- a/apps/desktop/src/renderer/application/contracts/transient-message-projection.ts +++ b/apps/desktop/src/renderer/application/contracts/transient-message-projection.ts @@ -17,26 +17,96 @@ * under the License. */ +import { createElement } from 'react'; +import type { + AttachmentRef, + DirectoryReference, + MessageQueueEntryProjection, + QuoteRef, +} from '@maka/core/events'; import type { StoredMessage } from '@maka/core/session'; -import type { TransientUserMessageProjection } from '@maka/ui'; +import type { UiLocale } from '@maka/core/ui-locale'; +import { getConversationCopy, type TransientUserMessageProjection } from '@maka/ui'; +import { ICON_SIZE, Pencil, Trash2 } from '@maka/ui/icons'; type TransientUserMessage = TransientUserMessageProjection; /** - * Replace the queue-backed subset in the exact order supplied by the Host. - * Other local intents keep their relative position because queue absence is - * not cancellation or delivery proof. + * What a retracted send hands back to the composer: the editable text plus + * the staged context (attachments, directory references, quotes) that rode + * with it. `text` is the editable serialization — `displayText` when the + * content carries a separate model-facing `text`. */ -export function projectQueuedTransientMessages( - transient: Map, - queued: readonly TransientUserMessage[], -): void { - if (queued.length === 0) return; - const queuedIds = new Set(queued.map((message) => message.id)); - const retained = [...transient.entries()].filter(([id]) => !queuedIds.has(id)); - transient.clear(); - for (const [id, message] of retained) transient.set(id, message); - for (const message of queued) transient.set(message.id, message); +export interface RestoredDraftContent { + text: string; + attachments?: readonly AttachmentRef[]; + directoryReferences?: readonly DirectoryReference[]; + quotes?: readonly QuoteRef[]; +} + +export interface QueuedEntryDraftActions { + retract(entryId: string): Promise; + restoreDraft(draft: RestoredDraftContent): void; +} + +/** + * Editing any queued message takes it out of the Host queue first, so it cannot + * run half-edited, then hands its full content back to the composer. `retract` + * reports its own failure and rejects, so a failed edit restores nothing. + */ +export async function retractQueuedEntryToDraft( + { entryId, content }: Pick, + actions: QueuedEntryDraftActions, +): Promise { + await actions.retract(entryId); + actions.restoreDraft({ + text: content.displayText ?? content.text, + attachments: content.attachments, + directoryReferences: content.directoryReferences, + quotes: content.quotes, + }); +} + +/** + * Queued steering is a thin projection of the Host queue snapshot, not a stored + * transient: one tail bubble per current_turn entry, appearing and disappearing + * with `queue` alone. + */ +export function withQueuedSteeringTransients( + transientMessages: readonly TransientUserMessage[], + queue: { readonly entries: readonly MessageQueueEntryProjection[]; readonly ts?: number } | undefined, + actions: QueuedEntryDraftActions & { locale: UiLocale }, +): TransientUserMessage[] { + const steering = (queue?.entries ?? []).filter( + (entry) => entry.placement === 'current_turn', + ); + if (steering.length === 0) return [...transientMessages]; + const copy = getConversationCopy(actions.locale).composer; + const icon = (glyph: typeof Pencil) => createElement(glyph, { size: ICON_SIZE.control, 'aria-hidden': true }); + const ignore = () => {}; + const bubbles = steering.map((entry): TransientUserMessage => ({ + id: entry.messageId, + transientPlacement: 'transcript', + ts: queue?.ts ?? 0, + text: entry.content.displayText ?? entry.content.text, + ...(entry.content.attachments && { attachments: [...entry.content.attachments] }), + ...(entry.content.directoryReferences && { directoryReferences: entry.content.directoryReferences }), + ...(entry.content.quotes && { quotes: [...entry.content.quotes] }), + ...(entry.content.inlineReferences && { inlineReferences: [...entry.content.inlineReferences] }), + deliveryActions: [ + { + label: copy.editQueuedEntry, + icon: icon(Pencil), + onClick: () => retractQueuedEntryToDraft(entry, actions).catch(ignore), + }, + { label: copy.deleteQueuedEntry, icon: icon(Trash2), onClick: () => actions.retract(entry.entryId).catch(ignore) }, + ], + })); + const ids = new Set(bubbles.map((message) => message.id)); + return [ + ...transientMessages.filter((message) => !ids.has(message.id)), + ...bubbles, + ]; } /** diff --git a/apps/desktop/src/renderer/chat-composer-region.tsx b/apps/desktop/src/renderer/chat-composer-region.tsx index 4d3f9db8fd6..d2d58cb6b33 100644 --- a/apps/desktop/src/renderer/chat-composer-region.tsx +++ b/apps/desktop/src/renderer/chat-composer-region.tsx @@ -32,6 +32,7 @@ import { import type { ComposerHandle } from '@maka/ui'; export { selectLatestRequestUsage } from './application/contracts/session-inspector/latest-request-usage.js'; import { useComposerMentionsContext } from './composer-mentions.js'; +import type { GuestComposerProjection } from './features/session-collaboration/index.js'; import { readNewTaskReloadDraft, readNewTaskReloadIntent, @@ -104,6 +105,8 @@ interface ChatComposerRegionProps | 'onPickDirectory' > { composerRef: RefObject; + /** Set while the active Session is a Guest's: the same Composer sends Turn requests. */ + guest?: GuestComposerProjection; active: boolean; onboardingComposerHidden: boolean; activeInteraction: ComposerInteraction | undefined; @@ -161,9 +164,10 @@ interface ChatComposerRegionProps export function ChatComposerRegion({ composerRef, + guest, active, onboardingComposerHidden, - activeInteraction, + activeInteraction: ownerInteraction, activeId, contextUsageSessionId, newTaskDraftKey, @@ -183,6 +187,8 @@ export function ChatComposerRegion({ ...composerRest }: ChatComposerRegionProps) { const mentions = useComposerMentionsContext(); + // Interactions answer the owner's Turn; a Guest never sees their prompts. + const activeInteraction = guest ? undefined : ownerInteraction; const activeSandboxBoundary = activeInteraction?.type === 'sandbox_boundary_request' ? activeInteraction : undefined; const activeClientCapability = @@ -271,37 +277,37 @@ export function ChatComposerRegion({ {(goalProjection) => (
@@ -314,7 +320,10 @@ export function ChatComposerRegion({ the composer would have been — and never over a turn-scoped interaction, which already owns the slot and is the more urgent thing to answer. */} - {boundaryUnreadableNotice && active && !activeInteraction && ( + {guest?.notice && active && ( +
{guest.notice}
+ )} + {boundaryUnreadableNotice && active && !activeInteraction && !guest && (
} />
)} - {mentions?.sessionReferenceError && active && !onboardingComposerHidden && !activeInteraction && ( + {mentions?.sessionReferenceError && active && !onboardingComposerHidden && !activeInteraction && !guest && ( ); } + +type OwnerComposerProps = Pick< + ComponentProps, + | 'activeSession' + | 'activeModel' + | 'activeModelLabel' + | 'activeModelConnectionId' + | 'activeModelConnectionSlug' + | 'activeProviderType' + | 'modelChoices' + | 'modelLabel' + | 'renderProviderMark' +>; + +/** + * A Guest's Composer only sends Turn requests: the Session's model shows + * read-only, and no owner control or context picker reaches it. + */ +function guestComposerProps( + owner: OwnerComposerProps, + guest: GuestComposerProjection, +): ComponentProps { + return { + activeSession: owner.activeSession, + activeModel: owner.activeModel, + activeModelLabel: owner.activeModelLabel, + activeModelConnectionId: owner.activeModelConnectionId, + activeModelConnectionSlug: owner.activeModelConnectionSlug, + activeProviderType: owner.activeProviderType, + modelChoices: owner.modelChoices, + modelLabel: owner.modelLabel, + renderProviderMark: owner.renderProviderMark, + onStop: () => undefined, + onSend: async () => false, + ...guest.composer, + }; +} diff --git a/apps/desktop/src/renderer/features/conversation/controller/session-local-messages.tsx b/apps/desktop/src/renderer/features/conversation/controller/session-local-messages.tsx index fb43e41780f..b4549959dfe 100644 --- a/apps/desktop/src/renderer/features/conversation/controller/session-local-messages.tsx +++ b/apps/desktop/src/renderer/features/conversation/controller/session-local-messages.tsx @@ -19,7 +19,9 @@ import { useEffect } from 'react'; import { useUiLocale, type TransientUserMessageProjection } from '@maka/ui'; +import { ICON_SIZE, Pencil, Search, Trash2 } from '@maka/ui/icons'; import { getSessionLocalCopy } from '../../../locales/session-local-copy.js'; +import type { RestoredDraftContent } from '../../../application/contracts/transient-message-projection.js'; import { useConversationServices } from '../services.js'; export function SessionLocalMessages(props: { @@ -27,10 +29,12 @@ export function SessionLocalMessages(props: { readonly publish: (sessionId: string, message: TransientUserMessageProjection) => void; readonly retire: (sessionId: string, messageId: string) => void; readonly reportError: (message: string) => void; + /** Puts a never-dispatched message's content back into the composer for editing. */ + readonly restoreDraft: (sessionId: string, draft: RestoredDraftContent) => void; }): null { const services = useConversationServices(); const locale = useUiLocale(); - const { sessionId, publish, retire, reportError } = props; + const { sessionId, publish, retire, reportError, restoreDraft } = props; useEffect(() => { if (!sessionId) return; let disposed = false; @@ -49,19 +53,23 @@ export function SessionLocalMessages(props: { retire(sessionId, message.messageId); continue; } - const action = (operation: () => Promise) => () => { - void operation().catch(() => reportError(copy.updateError)); - }; const status = message.state === 'accepted' || message.state === 'sending' || (message.state === 'saved' && message.delivering && !message.error) ? undefined : copy[message.state]; + const action = (operation: () => Promise) => () => { + void operation().catch(() => reportError(copy.updateError)); + }; + const remove = async () => { + await services.cancelMessage(sessionId, message.messageId); + retire(sessionId, message.messageId); + }; publish(sessionId, { id: message.messageId, text: message.text, ts: message.createdAt, // Only an ordinary send records `localDisplayPlacement`. - transientPlacement: message.turnId || message.localDisplayPlacement === 'current_turn' ? 'transcript' - : message.placement === 'current_turn' ? 'steering' : 'follow_up', + transientPlacement: message.turnId || message.localDisplayPlacement === 'current_turn' + || message.placement === 'current_turn' ? 'transcript' : 'follow_up', attachments: message.attachments, directoryReferences: message.directoryReferences, quotes: message.quotes, @@ -71,23 +79,35 @@ export function SessionLocalMessages(props: { deliveryDetail: message.error, deliveryActions: status === undefined ? [] - : message.canCancel + : message.state === 'unknown' ? [ { - label: copy.remove, - onClick: action(async () => { - await services.cancelMessage(sessionId, message.messageId); - retire(sessionId, message.messageId); - }), + label: copy.check, + icon: