diff --git a/apps/desktop/e2e/composer-session-reference.spec.ts b/apps/desktop/e2e/composer-session-reference.spec.ts new file mode 100644 index 0000000000..ed94bb198d --- /dev/null +++ b/apps/desktop/e2e/composer-session-reference.spec.ts @@ -0,0 +1,80 @@ +/* + * 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 { ensureSidebarExpanded, expect, test, COMPOSER_INPUT } from './fixtures'; + +test('references another Session from @, including a trailing-space browse', async ({ + window: page, +}, testInfo) => { + const composer = page.locator(COMPOSER_INPUT); + const sourceName = 'Reference source'; + const sourcePrompt = 'source transcript marker'; + + await composer.fill(sourcePrompt); + await composer.press('Enter'); + await expect(page.getByText(`Fake backend received: ${sourcePrompt}`)).toBeVisible(); + await expect(page.getByRole('button', { name: '重新生成' })).toHaveCount(1, { + timeout: 20_000, + }); + + await page.evaluate(async (name) => { + const source = (await window.maka.sessions.list())[0]; + if (!source) throw new Error('the source Session was not created'); + await window.maka.sessions.rename(source.id, name); + }, sourceName); + + await ensureSidebarExpanded(page); + const sidebar = page.getByRole('navigation', { name: '任务列表' }); + await sidebar.getByRole('button', { name: '新任务', exact: true }).click(); + await expect(composer).toHaveText(''); + + await composer.click(); + await composer.pressSequentially('@'); + const menu = page.getByRole('listbox', { name: '工作区文件和会话' }); + await expect(menu).toBeVisible(); + + const sourceOption = menu.getByRole('option', { name: sourceName, exact: true }); + await expect(sourceOption).toBeVisible(); + await expect(sourceOption.locator('svg.lucide-messages-square')).toHaveCount(1); + + await composer.press('Space'); + await expect(menu).toBeVisible(); + await expect(sourceOption).toBeVisible(); + + await composer.fill('@reference'); + await expect(sourceOption).toBeVisible(); + await sourceOption.click(); + await expect(menu).not.toBeVisible(); + + const chip = page.locator('.maka-composer-session-token'); + await expect(chip).toContainText(sourceName); + await expect(chip.locator('svg.lucide-messages-square')).toHaveCount(1); + await page.screenshot({ path: testInfo.outputPath('session-reference-staged.png') }); + + const followUp = 'continue from the referenced session'; + await composer.fill(followUp); + await composer.press('Enter'); + const sent = page.getByLabel('你发送的消息').last(); + await expect(sent).toContainText(followUp); + await expect(sent).toContainText(sourceName); + await expect(chip).toHaveCount(0); + await expect(page.getByRole('button', { name: '重新生成' })).toHaveCount(1, { + timeout: 20_000, + }); +}); diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index e54ddacfd2..80ad199fb7 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -980,25 +980,21 @@ "react": 1 }, "importSpecifiers": 184, - "nonTriviaTokens": 15692 + "nonTriviaTokens": 15689 }, "src/renderer/use-app-shell-composer-quotes.ts": { - "importDeclarations": 3, + "importDeclarations": 1, "bridgePaths": {}, "environmentCapabilities": {}, - "hookCalls": { - "useState": 1 - }, + "hookCalls": {}, "lifecycleMethods": {}, "unresolvedDependencies": 0, "actionFactories": [], "dependencyPaths": { - "./pending-items.js": 1, - "@maka/core/events": 1, - "react": 1 + "./features/conversation/index.js": 1 }, - "importSpecifiers": 7, - "nonTriviaTokens": 360 + "importSpecifiers": 1, + "nonTriviaTokens": 24 }, "src/renderer/use-app-shell-session-list.ts": { "importDeclarations": 11, @@ -1384,28 +1380,14 @@ } }, "src/renderer/composer-mentions.tsx": { - "bridgePaths": { - "window.maka.mcp.subscribeChanges": 1, - "window.maka.newTasks.listInvocableSkills": 1, - "window.maka.newTasks.searchFiles": 1, - "window.maka.newTasks.subscribeChanges": 1, - "window.maka.sessions.subscribeChanges": 1, - "window.maka.skills.listInvocable": 1, - "window.maka.workspace.searchFiles": 1 - }, + "bridgePaths": {}, "environmentCapabilities": {}, - "hookCalls": { - "useEffect": 1, - "useState": 1 - }, + "hookCalls": {}, "lifecycleMethods": {}, "unresolvedDependencies": 0, "actionFactories": [], "dependencyPaths": { - "../preload/bridge-contract.js": 1, - "@maka/core/settings": 1, - "@maka/runtime/skill-invocation": 1, - "react": 1 + "./features/conversation/index.js": 1 } }, "src/renderer/conversation-markdown.ts": { diff --git a/apps/desktop/src/main/__tests__/conversation-services-adapter.test.ts b/apps/desktop/src/main/__tests__/conversation-services-adapter.test.ts new file mode 100644 index 0000000000..f9793ceea8 --- /dev/null +++ b/apps/desktop/src/main/__tests__/conversation-services-adapter.test.ts @@ -0,0 +1,50 @@ +/* + * 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 test from 'node:test'; +import type { MakaBridge } from '../../preload/bridge-contract.js'; +import { createDesktopConversationServices } from '../../renderer/platform/desktop/create-conversation-services.js'; + +test('Desktop conversation adapter keeps snapshot reads and catalog access on the bridge', async () => { + const calls: string[] = []; + const bridge = { + sessions: { + list: async () => [], + subscribeChanges: () => () => undefined, + readSnapshot: async (sessionId: string) => { + calls.push(`snapshot:${sessionId}`); + return {}; + }, + }, + skills: { listInvocable: async () => [] }, + workspace: { searchFiles: async () => ({ ok: false as const, reason: 'no_project' as const }) }, + newTasks: { + subscribeChanges: () => () => undefined, + listInvocableSkills: async () => [], + searchFiles: async () => ({ ok: false as const, reason: 'no_project' as const }), + }, + mcp: { subscribeChanges: () => () => undefined }, + } as unknown as MakaBridge; + const services = createDesktopConversationServices(bridge); + + await services.sessions.readSnapshot('source'); + assert.deepEqual(await services.sessions.list(), []); + assert.deepEqual(calls, ['snapshot:source']); +}); diff --git a/apps/desktop/src/main/__tests__/new-task-staged-content.test.ts b/apps/desktop/src/main/__tests__/new-task-staged-content.test.ts index ee1ca35b77..4307a88875 100644 --- a/apps/desktop/src/main/__tests__/new-task-staged-content.test.ts +++ b/apps/desktop/src/main/__tests__/new-task-staged-content.test.ts @@ -206,6 +206,23 @@ test('a Session keeps its own staged quotes, and the new-task bucket keeps its o ['quoted for the Session'], ); + await act(() => probe.latest().addQuote({ + text: 'bounded session context', + label: 'Session: Research', + sourceSessionId: 'source-session', + sourceSessionName: 'Research', + sourceCapturedAt: 123, + sourceTruncated: true, + })); + assert.deepEqual(probe.latest().pendingQuotes.at(-1), { + text: 'bounded session context', + label: 'Session: Research', + sourceSessionId: 'source-session', + sourceSessionName: 'Research', + sourceCapturedAt: 123, + sourceTruncated: true, + }); + await probe.render(NEW_TASK_PENDING_KEY); assert.deepEqual( probe.latest().pendingQuotes.map((quote) => quote.text), diff --git a/apps/desktop/src/main/__tests__/permission-response-ipc-boundary.test.ts b/apps/desktop/src/main/__tests__/permission-response-ipc-boundary.test.ts index 5b186c081d..3eca0d0263 100644 --- a/apps/desktop/src/main/__tests__/permission-response-ipc-boundary.test.ts +++ b/apps/desktop/src/main/__tests__/permission-response-ipc-boundary.test.ts @@ -190,7 +190,16 @@ describe('permission response IPC boundary', () => { ], turnOrchestration: { mode: 'swarm', source: 'slash_command', ignored: true }, quotes: [ - { text: 'the excerpt', label: ' Assistant ', sourceTurnId: 'turn-9', extra: true }, + { + text: 'the excerpt', + label: ' Assistant ', + sourceTurnId: 'turn-9', + sourceSessionId: 'source-session', + sourceSessionName: 'Research', + sourceCapturedAt: 123, + sourceTruncated: false, + extra: true, + }, ], workspaceFileReferences: [ { @@ -223,7 +232,15 @@ describe('permission response IPC boundary', () => { }, ], turnOrchestration: { mode: 'swarm', source: 'slash_command' }, - quotes: [{ text: 'the excerpt', label: 'Assistant', sourceTurnId: 'turn-9' }], + quotes: [{ + text: 'the excerpt', + label: 'Assistant', + sourceTurnId: 'turn-9', + sourceSessionId: 'source-session', + sourceSessionName: 'Research', + sourceCapturedAt: 123, + sourceTruncated: false, + }], workspaceFileReferences: [ { value: '@packages/ui/src/chat turn.tsx', @@ -253,6 +270,7 @@ describe('permission response IPC boundary', () => { { type: 'send', text: 'hello', quotes: Array(17).fill({ text: 'x' }) }, { type: 'send', text: 'hello', quotes: [{ text: '' }] }, { type: 'send', text: 'hello', quotes: [{ text: 'x', sourceTurnId: 1 }] }, + { type: 'send', text: 'hello', quotes: [{ text: 'x', sourceSessionId: 'source-session' }] }, { type: 'send', text: 'hello', workspaceFileReferences: {} }, { type: 'send', 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 1c71e36785..828801b7fc 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 @@ -58,6 +58,236 @@ test('registers Session observation as one reconnectable operation', () => { assert.equal(ipc.reconnectableChannels.has('sessions:observe'), true); }); +test('reads a bounded Session snapshot without loading or waking the target runtime', async () => { + const ipc = ipcHarness(); + let closed = false; + let decodedPages = 0; + const sourceSession = session('workspace', 'source-session'); + const sourceMessages = [ + { + type: 'user' as const, + id: 'user-1', + turnId: 'turn-1', + ts: 1, + text: 'Keep this user context', + }, + { + type: 'assistant' as const, + id: 'assistant-1', + turnId: 'turn-1', + ts: 2, + text: 'Keep this assistant result', + modelId: 'model', + }, + { + type: 'tool_call' as const, + id: 'tool-1', + turnId: 'turn-1', + ts: 3, + toolName: 'Bash', + args: { command: 'cat secret.txt' }, + }, + ]; + registerExecutionIpc( + { + client: executionClient({ + getSession: async () => sourceSession, + openSession: async () => runtimeHostSessionFixture({ + snapshot: { + schemaVersion: SESSION_CONTINUITY_SCHEMA_VERSION, + session: { + sessionId: 'source-session', + metadataRevision: 1, + status: 'active', + createdAt: 1, + isArchived: false, + }, + projectionRevision: 1, + rootTurn: null, + goal: null, + queue: { hostEpoch: 'host-1', queueRevision: 0, steering: [], followup: [] }, + interactions: { pending: [] }, + }, + transcript: Promise.resolve(sourceMessages), + transcriptBootstrap: { + throughSequence: null, + durableCoverage: 'projected', + overlayMessageCount: 0, + durable: { + kind: 'page', + sessionId: 'source-session', + source: 'durable', + direction: 'older', + throughSequence: null, + rawBytes: 1, + fragments: [], + rangeBoundarySequence: null, + protectedTurnSequence: null, + nextCursor: null, + }, + overlay: { + kind: 'page', + sessionId: 'source-session', + source: 'overlay', + direction: 'older', + throughSequence: null, + rawBytes: 0, + fragments: [], + rangeBoundarySequence: null, + protectedTurnSequence: null, + nextCursor: null, + }, + }, + decodeTranscriptPage: async (page) => { + decodedPages += 1; + return { + messages: page.source === 'durable' + ? sourceMessages.map((message, identity) => ({ identity, message })) + : [], + nextCursor: null, + }; + }, + close: async () => { + closed = true; + }, + events: (async function* () {})(), + }), + }), + }, + ipc, + ); + assert.equal(ipc.reconnectableChannels.has('sessions:readSnapshot'), true); + + const snapshot = await ipc.invoke('sessions:readSnapshot', 'source-session', { maxChars: 10_000 }) as { + text: string; + reference: { sessionId: string; sessionName: string; capturedAt: number }; + truncated: boolean; + }; + assert.match(snapshot.text, /Keep this user context/); + assert.match(snapshot.text, /Keep this assistant result/); + assert.doesNotMatch(snapshot.text, /secret/); + assert.deepEqual( + { + sessionId: snapshot.reference.sessionId, + sessionName: snapshot.reference.sessionName, + }, + { + sessionId: 'source-session', + sessionName: 'Session', + }, + ); + assert.equal(typeof snapshot.reference.capturedAt, 'number'); + assert.equal(snapshot.truncated, false); + assert.equal(decodedPages, 2); + assert.equal(closed, true); +}); + +test('marks transcript-tail omissions as truncated and rejects archived snapshots', async () => { + const ipc = ipcHarness(); + let openCalls = 0; + registerExecutionIpc( + { + client: executionClient({ + getSession: async (_sessionId) => session(), + openSession: async () => { + openCalls += 1; + return runtimeHostSessionFixture({ + snapshot: { + schemaVersion: SESSION_CONTINUITY_SCHEMA_VERSION, + session: { + sessionId: 'session-1', + metadataRevision: 1, + status: 'active', + createdAt: 1, + isArchived: false, + }, + projectionRevision: 1, + rootTurn: null, + goal: null, + queue: { hostEpoch: 'host-1', queueRevision: 0, steering: [], followup: [] }, + interactions: { pending: [] }, + }, + transcript: Promise.resolve([]), + events: (async function* () {})(), + transcriptBootstrap: { + throughSequence: 3, + durableCoverage: 'projected', + overlayMessageCount: 0, + durable: { + kind: 'page', + sessionId: 'session-1', + source: 'durable', + direction: 'older', + throughSequence: 3, + rawBytes: 1, + fragments: [], + rangeBoundarySequence: null, + protectedTurnSequence: null, + nextCursor: 'older-page', + }, + overlay: { + kind: 'page', + sessionId: 'session-1', + source: 'overlay', + direction: 'older', + throughSequence: 3, + rawBytes: 0, + fragments: [], + rangeBoundarySequence: null, + protectedTurnSequence: null, + nextCursor: null, + }, + }, + decodeTranscriptPage: async (page) => ({ + messages: page.source === 'durable' + ? [{ + identity: 1, + message: { + type: 'assistant' as const, + id: 'assistant-1', + turnId: 'turn-1', + ts: 1, + text: 'Recent answer', + modelId: 'model', + }, + }] + : [], + nextCursor: page.nextCursor, + }), + close: async () => undefined, + }); + }, + }), + }, + ipc, + ); + + const snapshot = await ipc.invoke('sessions:readSnapshot', 'session-1') as { + text: string; + truncated: boolean; + }; + assert.match(snapshot.text, /Recent answer/); + assert.equal(snapshot.truncated, true); + assert.equal(openCalls, 1); + + const archivedIpc = ipcHarness(); + registerExecutionIpc( + { + client: executionClient({ + getSession: async () => ({ ...session(), isArchived: true }), + openSession: async () => { + throw new Error('archived session must not be opened'); + }, + }), + }, + archivedIpc, + ); + await assert.rejects( + archivedIpc.invoke('sessions:readSnapshot', 'session-1'), + /archived Runtime Host Session/, + ); +}); + test("keeps synthetic E2E interactions visible through Host hydration and retires their answer", async () => { const observer = observerWithSnapshot(); const ipc = ipcHarness(); @@ -1645,6 +1875,7 @@ function executionClient(overrides: Partial): ExecutionClient { getSession: unavailable, ingestAttachment: unavailable, interruptTurn: unavailable, + openSession: unavailable, listSessionTurnLandmarks: unavailable, listSessionTurns: unavailable, queryMessageExecutions: unavailable, diff --git a/apps/desktop/src/main/__tests__/session-reference-composer.test.ts b/apps/desktop/src/main/__tests__/session-reference-composer.test.ts new file mode 100644 index 0000000000..ee744ebc4c --- /dev/null +++ b/apps/desktop/src/main/__tests__/session-reference-composer.test.ts @@ -0,0 +1,365 @@ +/* + * 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 type { SessionChangedEvent } from '@maka/core/session'; +import type { SessionSnapshot } from '@maka/core/session-reference'; +import { + ConversationServicesProvider, + type ConversationServices, + useComposerQuotes, + useSessionReferenceComposer, +} from '../../renderer/features/conversation/index.js'; + +const originalGlobals = { + document: globalThis.document, + window: globalThis.window, + HTMLElement: globalThis.HTMLElement, + Event: globalThis.Event, + Node: globalThis.Node, + IS_REACT_ACT_ENVIRONMENT: (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }) + .IS_REACT_ACT_ENVIRONMENT, +}; + +let root: Root | undefined; + +afterEach(async () => { + if (root) await act(() => root?.unmount()); + root = undefined; + Object.assign(globalThis, originalGlobals); +}); + +test('Session reference picker keeps same-Host sessions and send waits for the snapshot', async () => { + const { document, window } = parseHTML('
'); + Object.assign(globalThis, { + document, + window, + HTMLElement: window.HTMLElement, + Event: window.Event, + Node: window.Node, + IS_REACT_ACT_ENVIRONMENT: true, + }); + const container = document.querySelector('#root'); + assert.ok(container); + root = createRoot(container); + + const session = (id: string, runtimeHostId: string, extra = {}) => ({ + id, + runtimeHostId, + name: id, + isFlagged: false, + isArchived: false, + labels: [], + hasUnread: false, + status: 'active' as const, + backend: 'ai-sdk' as const, + llmConnectionSlug: 'connection', + connectionLocked: false, + model: 'model', + permissionMode: 'ask' as const, + ...extra, + }); + const sessions = [ + session('current', 'host-a'), + session('source', 'host-a'), + session('other-host', 'host-b'), + session('archived', 'host-a', { isArchived: true }), + ]; + let releaseSnapshot: (snapshot: SessionSnapshot) => void = () => undefined; + const snapshot = new Promise((resolve) => { + releaseSnapshot = resolve; + }); + const services: ConversationServices = { + sessions: { + list: async () => sessions, + subscribeChanges: (_handler: (event: SessionChangedEvent) => void) => () => undefined, + readSnapshot: async () => snapshot, + }, + skills: { listInvocable: async () => [] }, + workspace: { searchFiles: async () => ({ ok: false, reason: 'no_project' }) }, + newTasks: { + subscribeChanges: () => () => undefined, + listInvocableSkills: async () => [], + searchFiles: async () => ({ ok: false, reason: 'no_project' }), + }, + mcp: { subscribeChanges: () => () => undefined }, + }; + let latestQuotes: ReturnType | undefined; + let latest: ReturnType | undefined; + function Probe() { + latestQuotes = useComposerQuotes({ draftKey: 'current' }); + latest = useSessionReferenceComposer({ + sessions, + activeId: 'current', + hostId: 'host-a', + addQuote: latestQuotes.addQuote, + errorCopy: { + unavailableTitle: 'Session unavailable', + unavailableDetail: 'Refresh and try again.', + emptyTitle: 'No referenceable content', + emptyDetail: 'Only user and assistant text can be referenced.', + readFailedTitle: 'Read failed', + readFailedDetail: 'Try again later.', + }, + }); + return null; + } + await act(async () => { + root?.render(createElement(ConversationServicesProvider, { + services, + children: createElement(Probe), + })); + }); + assert.deepEqual(latest?.references.map((item) => item.id), ['source']); + + let pick!: Promise; + await act(async () => { + pick = latest!.pick({ id: 'source' }); + await Promise.resolve(); + }); + assert.equal(latest?.pending, true); + const waiting = latest!.waitForPending(); + const pendingQuotes = latestQuotes!.pendingQuotes; + await act(async () => { + releaseSnapshot({ + reference: { sessionId: 'source', sessionName: 'source', capturedAt: 1 }, + items: [], + text: 'Assistant: bounded context', + estimatedTokens: 4, + maxChars: 12_000, + truncated: false, + }); + await pick; + }); + assert.equal(await waiting, true); + assert.deepEqual(pendingQuotes, [{ + text: 'Assistant: bounded context', + label: 'Session: source', + sourceSessionId: 'source', + sourceSessionName: 'source', + sourceCapturedAt: 1, + sourceTruncated: false, + }]); +}); + +test('an immediate send observes the selected Session snapshot in its QuoteRef payload', async () => { + const { document, window } = parseHTML('
'); + Object.assign(globalThis, { + document, + window, + HTMLElement: window.HTMLElement, + Event: window.Event, + Node: window.Node, + IS_REACT_ACT_ENVIRONMENT: true, + }); + const container = document.querySelector('#root'); + assert.ok(container); + root = createRoot(container); + + const source = { + id: 'source', + runtimeHostId: 'host-a', + name: 'Research', + isFlagged: false, + isArchived: false, + labels: [], + hasUnread: false, + status: 'active' as const, + backend: 'ai-sdk' as const, + llmConnectionSlug: 'connection', + connectionLocked: false, + model: 'model', + permissionMode: 'ask' as const, + }; + const services: ConversationServices = { + sessions: { + list: async () => [source], + subscribeChanges: () => () => undefined, + readSnapshot: async () => new Promise((resolve) => { + queueMicrotask(() => resolve({ + reference: { sessionId: 'source', sessionName: 'Research', capturedAt: 2 }, + items: [], + text: 'Assistant: prior research', + estimatedTokens: 4, + maxChars: 12_000, + truncated: false, + })); + }), + }, + skills: { listInvocable: async () => [] }, + workspace: { searchFiles: async () => ({ ok: false, reason: 'no_project' }) }, + newTasks: { + subscribeChanges: () => () => undefined, + listInvocableSkills: async () => [], + searchFiles: async () => ({ ok: false, reason: 'no_project' }), + }, + mcp: { subscribeChanges: () => () => undefined }, + }; + let latestQuotes: ReturnType | undefined; + let latest: ReturnType | undefined; + let sendCapturedQuotes: () => readonly unknown[] = () => []; + function Probe() { + latestQuotes = useComposerQuotes({ draftKey: 'current' }); + const capturedQuotes = latestQuotes.pendingQuotes; + sendCapturedQuotes = () => capturedQuotes; + latest = useSessionReferenceComposer({ + sessions: [ + { ...source, id: 'current', runtimeHostId: 'host-a', name: 'Current' }, + source, + ], + activeId: 'current', + hostId: 'host-a', + addQuote: latestQuotes.addQuote, + errorCopy: { + unavailableTitle: 'Session unavailable', + unavailableDetail: 'Refresh and try again.', + emptyTitle: 'No referenceable content', + emptyDetail: 'Only user and assistant text can be referenced.', + readFailedTitle: 'Read failed', + readFailedDetail: 'Try again later.', + }, + }); + return null; + } + await act(async () => { + root?.render(createElement(ConversationServicesProvider, { + services, + children: createElement(Probe), + })); + }); + + await act(async () => { + const pick = latest!.pick({ id: 'source' }); + await latest!.waitForPending(); + await pick; + }); + + assert.deepEqual(sendCapturedQuotes(), [{ + text: 'Assistant: prior research', + label: 'Session: Research', + sourceSessionId: 'source', + sourceSessionName: 'Research', + sourceCapturedAt: 2, + sourceTruncated: false, + }]); +}); + +test('ignores a snapshot that resolves after the Composer owner changes', async () => { + const { document, window } = parseHTML('
'); + Object.assign(globalThis, { + document, + window, + HTMLElement: window.HTMLElement, + Event: window.Event, + Node: window.Node, + IS_REACT_ACT_ENVIRONMENT: true, + }); + const container = document.querySelector('#root'); + assert.ok(container); + root = createRoot(container); + + const session = (id: string) => ({ + id, + runtimeHostId: 'host-a', + name: id, + isFlagged: false, + isArchived: false, + labels: [], + hasUnread: false, + status: 'active' as const, + backend: 'ai-sdk' as const, + llmConnectionSlug: 'connection', + connectionLocked: false, + model: 'model', + permissionMode: 'ask' as const, + }); + let release!: (snapshot: SessionSnapshot) => void; + const services: ConversationServices = { + sessions: { + list: async () => [session('current'), session('next'), session('source')], + subscribeChanges: () => () => undefined, + readSnapshot: async () => new Promise((resolve) => { + release = resolve; + }), + }, + skills: { listInvocable: async () => [] }, + workspace: { searchFiles: async () => ({ ok: false, reason: 'no_project' }) }, + newTasks: { + subscribeChanges: () => () => undefined, + listInvocableSkills: async () => [], + searchFiles: async () => ({ ok: false, reason: 'no_project' }), + }, + mcp: { subscribeChanges: () => () => undefined }, + }; + let activeId = 'current'; + let latestQuotes: ReturnType | undefined; + let latest: ReturnType | undefined; + function Probe() { + latestQuotes = useComposerQuotes({ draftKey: 'current' }); + latest = useSessionReferenceComposer({ + sessions: [session('current'), session('next'), session('source')], + activeId, + hostId: 'host-a', + addQuote: latestQuotes.addQuote, + errorCopy: { + unavailableTitle: 'Session unavailable', + unavailableDetail: 'Refresh and try again.', + emptyTitle: 'No referenceable content', + emptyDetail: 'Only user and assistant text can be referenced.', + readFailedTitle: 'Read failed', + readFailedDetail: 'Try again later.', + }, + }); + return null; + } + await act(async () => { + root?.render(createElement(ConversationServicesProvider, { + services, + children: createElement(Probe), + })); + }); + let pick!: Promise; + await act(async () => { + pick = latest!.pick({ id: 'source' }); + await Promise.resolve(); + }); + await act(async () => { + activeId = 'next'; + root?.render(createElement(ConversationServicesProvider, { + services, + children: createElement(Probe), + })); + }); + await act(async () => { + release({ + reference: { sessionId: 'source', sessionName: 'source', capturedAt: 1 }, + items: [], + text: 'stale context', + estimatedTokens: 3, + maxChars: 12_000, + truncated: false, + }); + await pick; + }); + assert.deepEqual(latestQuotes?.pendingQuotes, []); +}); diff --git a/apps/desktop/src/main/__tests__/session-settings-controller.test.ts b/apps/desktop/src/main/__tests__/session-settings-controller.test.ts index aa8a21768f..8e34acd5f5 100644 --- a/apps/desktop/src/main/__tests__/session-settings-controller.test.ts +++ b/apps/desktop/src/main/__tests__/session-settings-controller.test.ts @@ -253,7 +253,7 @@ function Harness(props: { catalogRevision: 0, isActiveSession: () => true, sessions: props.sessions, - newTaskPermissionMode: 'ask', + newSessionPermissionMode: 'ask', refreshCatalog: async () => {}, saveComposerDefaults: props.saveComposerDefaults, writeFailureCopy: () => ({ title: 'failed', description: 'failed' }), diff --git a/apps/desktop/src/main/permission-response-guard.ts b/apps/desktop/src/main/permission-response-guard.ts index 9fc25de20e..cab0d1785d 100644 --- a/apps/desktop/src/main/permission-response-guard.ts +++ b/apps/desktop/src/main/permission-response-guard.ts @@ -45,6 +45,8 @@ const MAX_SESSION_SEND_TEXT_LENGTH = 128_000; const MAX_QUOTE_COUNT = 16; const MAX_QUOTE_TEXT_LENGTH = 32_000; const MAX_QUOTE_LABEL_LENGTH = 200; +const MAX_QUOTE_SOURCE_SESSION_ID_LENGTH = 512; +const MAX_QUOTE_SOURCE_SESSION_NAME_LENGTH = 200; const MAX_INLINE_REFERENCE_COUNT = 32; const MAX_INLINE_REFERENCE_VALUE_LENGTH = 4_096; @@ -321,10 +323,48 @@ function normalizeOptionalQuotes(input: unknown): { quotes?: QuoteRef[] } { 'Invalid send quote sourceTurnId', MAX_TURN_ID_LENGTH, ); + const sourceSessionId = + value.sourceSessionId === undefined + ? undefined + : normalizeRequiredString( + value.sourceSessionId, + 'Invalid send quote sourceSessionId', + MAX_QUOTE_SOURCE_SESSION_ID_LENGTH, + ); + const sourceSessionName = + value.sourceSessionName === undefined + ? undefined + : normalizeRequiredString( + value.sourceSessionName, + 'Invalid send quote sourceSessionName', + MAX_QUOTE_SOURCE_SESSION_NAME_LENGTH, + ); + const sourceCapturedAt = value.sourceCapturedAt; + const sourceTruncated = value.sourceTruncated; + const hasSourceMetadata = + sourceSessionId !== undefined || + sourceSessionName !== undefined || + sourceCapturedAt !== undefined || + sourceTruncated !== undefined; + if ( + hasSourceMetadata && + (sourceSessionId === undefined || + sourceSessionName === undefined || + typeof sourceCapturedAt !== 'number' || + !Number.isFinite(sourceCapturedAt) || + sourceCapturedAt < 0 || + typeof sourceTruncated !== 'boolean') + ) { + throw new Error('Invalid send quote Session provenance'); + } return { text: normalizeRequiredString(value.text, 'Invalid send quote text', MAX_QUOTE_TEXT_LENGTH), ...(label ? { label } : {}), ...(sourceTurnId ? { sourceTurnId } : {}), + ...(sourceSessionId ? { sourceSessionId } : {}), + ...(sourceSessionName ? { sourceSessionName } : {}), + ...(hasSourceMetadata ? { sourceCapturedAt: sourceCapturedAt as number } : {}), + ...(hasSourceMetadata ? { sourceTruncated: sourceTruncated as boolean } : {}), }; }); return quotes.length > 0 ? { quotes } : {}; 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 acd6dab4aa..a3363193ed 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 @@ -30,6 +30,12 @@ import { type SessionChangedReason, } from '@maka/core/session'; import { type ActiveInteractionRequestEvent, type AttachmentRef } from '@maka/core/events'; +import { + createSessionSnapshot, + SESSION_SNAPSHOT_DEFAULT_MAX_CHARS, + SESSION_SNAPSHOT_MAX_CHARS, +} from '@maka/core/session-reference'; +import type { StoredMessage } from '@maka/core/session'; import { type PermissionMode } from '@maka/core/permission'; import { type SandboxBoundaryResponse } from '@maka/core/sandbox-boundary'; import type { AttachmentApprovalRegistry } from "./attachment-approval.js"; @@ -94,6 +100,7 @@ type RuntimeHostSessionExecutionClient = Pick< | "getSession" | "ingestAttachment" | "interruptTurn" + | "openSession" | 'listSessionTurns' | 'listSessionTurnLandmarks' | 'queryMessageExecutions' @@ -276,6 +283,52 @@ export function registerRuntimeHostSessionExecutionIpc( handleReconnectableRead(ipcMain, 'sessions:listTurns', async (_event, sessionId: unknown) => deps.client.listSessionTurns(requiredId(sessionId, 'Session')), ); + handleReconnectableRead( + ipcMain, + 'sessions:readSnapshot', + async (_event, sessionId: unknown, options?: unknown) => { + const normalizedSessionId = requiredId(sessionId, 'Session'); + const maxChars = normalizeSnapshotMaxChars(options); + const session = await deps.client.getSession(normalizedSessionId); + if (!session) throw new Error(`Runtime Host Session not found: ${normalizedSessionId}`); + if (session.isArchived) { + throw new Error(`Cannot read an archived Runtime Host Session: ${normalizedSessionId}`); + } + const opened = await deps.client.openSession(normalizedSessionId); + try { + if (opened.snapshot.session.isArchived) { + throw new Error(`Cannot read an archived Runtime Host Session: ${normalizedSessionId}`); + } + const { durable, overlay } = opened.transcriptBootstrap; + const [durablePage, overlayPage] = await Promise.all([ + opened.decodeTranscriptPage(durable), + opened.decodeTranscriptPage(overlay), + ]); + const messagesById = new Map(); + for (const entry of [...durablePage.messages, ...overlayPage.messages]) { + messagesById.set(entry.message.id, entry.message); + } + const snapshot = createSessionSnapshot( + [...messagesById.values()].sort((left, right) => left.ts - right.ts), + { + sessionId: normalizedSessionId, + sessionName: session.name, + maxChars, + }, + ); + // `openSession` intentionally receives a bounded tail. A non-null + // cursor means older transcript records were omitted before Core's + // character/item budget ran, so preserve that provenance on the quote. + return { + ...snapshot, + truncated: + snapshot.truncated || durablePage.nextCursor !== null || overlayPage.nextCursor !== null, + }; + } finally { + await opened.close(); + } + }, + ); handleReconnectableRead( ipcMain, 'sessions:listTurnLandmarks', @@ -947,6 +1000,24 @@ function requiredSequence(value: unknown, label: string): number { 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)) { + throw new Error('Invalid Session snapshot options'); + } + const value = (options as { maxChars?: unknown }).maxChars; + if ( + value !== undefined && + (typeof value !== 'number' || + !Number.isSafeInteger(value) || + value < 1 || + value > SESSION_SNAPSHOT_MAX_CHARS) + ) { + throw new Error('Invalid Session snapshot maxChars'); + } + return value === undefined ? SESSION_SNAPSHOT_DEFAULT_MAX_CHARS : value; +} + function isTerminalStatus(status: string): boolean { return ( diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts index d68e5c75c2..1cb4bc5449 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -67,6 +67,7 @@ import type { import type { PlanSessionState } from '@maka/core/plan'; import type { SearchErrorReason, SearchRequest, SearchResult } from '@maka/core/search'; import type { SessionChangedEvent, SessionSummary, TurnRecord } from '@maka/core/session'; +import type { SessionSnapshot } from '@maka/core/session-reference'; import type { ThinkingLevel } from '@maka/core/model-thinking'; import type { E2eFixtureState } from '@maka/core/e2e-fixture'; import type { @@ -1196,6 +1197,8 @@ export interface MakaBridge { }) => void, ): () => void; listTurns(sessionId: string): Promise; + /** Read a bounded, redacted tail from another same-Host Session without waking it. */ + readSnapshot(sessionId: string, options?: { maxChars?: number }): Promise; listTurnLandmarks(sessionId: string): Promise>; compact(sessionId: string): Promise>; resumeLatest(sessionId: string): Promise< diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index b4013452f4..a597dd47e9 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -2107,6 +2107,28 @@ const makaBridge = { ) as TurnRecord[]; return turns.map((turn) => projectDesktopTurnRecord(session.scope, turn)); }, + async readSnapshot( + sessionId: string, + options?: { maxChars?: number }, + ): Promise { + const session = await runtimeHostSessionRef(sessionId); + const snapshot = await ipcRenderer.invoke( + 'sessions:readSnapshot', + session.scope, + session.sessionId, + options, + ) as import('@maka/core/session-reference').SessionSnapshot; + return { + ...snapshot, + reference: { + ...snapshot.reference, + sessionId: recordRuntimeHostSessionScope( + session.scope, + snapshot.reference.sessionId, + ), + }, + }; + }, listTurnLandmarks(sessionId) { return invokeProjectedSessionRuntimeHost('sessions:listTurnLandmarks', sessionId); }, diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index a847a21794..6314d3da9d 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -440,18 +440,19 @@ function AppShellContent({ }); const { pendingQuotes, - addQuote, + addQuote: onAddQuote, removeQuote, clearQuotes, restoreQuotes, } = useAppShellComposerQuotes({ draftKey: attachmentDraftKey }); + // Held for the whole of sendOwningItsTarget; see ChatComposerRegion. const [newTaskSendPending, setNewTaskSendPending] = useState(false); // What a new chat will start with, held the way the Session holds it: a // Plan toggle and one orchestration value, not one fused choice. const [newChatPlanModeActive, setNewChatPlanModeActive] = useState(false); const [newChatOrchestrationMode, setNewChatOrchestrationMode] = useState('default'); - const [newTaskPermissionChoice, setNewTaskPermissionChoice, clearNewTaskPermissionChoice] = + const [newTaskPermissionChoice, setNewTaskPermissionMode, clearNewTaskPermissionChoice] = useNewTaskChoice(currentNewTaskDraftKey); const [historyLoadPendingSessionId, setHistoryLoadPendingSessionId] = useState(); // The state above is what the transcript renders; this is what the guard @@ -657,11 +658,10 @@ function AppShellContent({ * not a statement about every later task, so it is sent once on create and * never written back to `chatDefaults` — the Settings surface owns that. */ - const newTaskPermissionMode = + const newSessionPermissionMode = newTaskPermissionChoice ?? taskEntry.selectors.selectedHost?.chatDefaults.permissionMode ?? 'ask'; - const setNewTaskPermissionMode = setNewTaskPermissionChoice; useEffect(() => { if (!appearanceHydrated) return; let cancelled = false; @@ -831,7 +831,7 @@ function AppShellContent({ catalogRevision, isActiveSession: (sessionId) => activeIdRef.current === sessionId, sessions, - newTaskPermissionMode, + newSessionPermissionMode, refreshCatalog: refreshSessions, saveComposerDefaults: (model) => saveComposerDefaults({ model }), writeFailureCopy: (setting, error) => sessionSettingFailureCopy(uiLocale, setting, error), @@ -1166,7 +1166,7 @@ function AppShellContent({ ? pendingSessionView({ sessionId: activeId, name: shellCopy.newConversation, - permissionMode: newTaskPermissionMode, + permissionMode: newSessionPermissionMode, }) : undefined); // Each control reads its own field. There is nothing to project and nothing @@ -1239,7 +1239,7 @@ function AppShellContent({ const activeBoundarySurface = deriveDesktopExecutionBoundarySurface( activeId, activeExecutionBoundary, - activeId ? (activeSessionForView?.permissionMode ?? 'ask') : newTaskPermissionMode, + activeId ? (activeSessionForView?.permissionMode ?? 'ask') : newSessionPermissionMode, ); const activePermissionMode = activeId ? sessionSettingIntent.overlays.permissionMode[activeId] @@ -1622,7 +1622,8 @@ function AppShellContent({ newSessionCollaborationMode: newChatPlanModeActive ? 'plan' : 'agent', // Refresh only; Desktop Main re-reads the authoritative default before // constructing the Runtime Host preview target. - newSessionPermissionMode: newTaskPermissionMode, + newSessionPermissionMode, + onAddQuote, }; const hasModalOpen = helpOpen || paletteOpen || searchModalOpen || sharedSessionDialog.isOpen; @@ -2976,7 +2977,7 @@ function AppShellContent({ onRemoveAttachment={removeAttachment} pendingQuotes={pendingQuotes} onRemoveQuote={removeQuote} - onPasteAsQuote={canStageComposerContext ? addQuote : undefined} + onPasteAsQuote={canStageComposerContext ? onAddQuote : undefined} onPickAttachments={ !canStageComposerContext || (revisionDraft && activeId === revisionDraft.draftSessionId) @@ -3169,7 +3170,7 @@ function AppShellContent({ sharedSessionActive ? undefined : (selection) => { - addQuote(selection); + onAddQuote(selection); composerRef.current?.focus(); } } diff --git a/apps/desktop/src/renderer/chat-composer-region.tsx b/apps/desktop/src/renderer/chat-composer-region.tsx index 2c25797fd7..7444b28f75 100644 --- a/apps/desktop/src/renderer/chat-composer-region.tsx +++ b/apps/desktop/src/renderer/chat-composer-region.tsx @@ -228,6 +228,14 @@ export function ChatComposerRegion({ />} /> )} + {mentions?.sessionReferenceError && ( + + )} {activeSandboxBoundary && ( { + if (mentions && !(await mentions.waitForSessionReference())) return false; + return composerRest.onSend(text, metadata); + }} // AppShell carries staged attachments into both queued and steering // follow-ups. Other Composer hosts remain gated by default because a // text-only running-turn submission would leave attachments behind. @@ -262,6 +274,8 @@ export function ChatComposerRegion({ mentionSkillsUnavailable={mentions?.mentionSkillsUnavailable} mentionSkillsLoading={mentions?.mentionSkillsLoading} onSearchMentionFiles={mentions?.searchMentionFiles} + sessionReferences={mentions?.sessionReferences} + onPickSessionReference={mentions?.onPickSessionReference} {...directoryComposerProps} onPickDirectory={ directoryPickerEnabled ? directoryComposerProps.onPickDirectory : undefined diff --git a/apps/desktop/src/renderer/composer-mentions.tsx b/apps/desktop/src/renderer/composer-mentions.tsx index 4a4eeb62fc..85119b4eb8 100644 --- a/apps/desktop/src/renderer/composer-mentions.tsx +++ b/apps/desktop/src/renderer/composer-mentions.tsx @@ -17,285 +17,9 @@ * under the License. */ -import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from 'react'; -import type { ChatDefaultPermissionMode } from '@maka/core/settings'; -import type { InvocableSkillEntry } from '@maka/runtime/skill-invocation'; -import type { DesktopNewTaskTarget } from '../preload/bridge-contract.js'; - -/** One frozen identity, so a context-mismatch render does not churn props. */ -const EMPTY_SKILLS: InvocableSkillEntry[] = []; - -/** - * Whether a reloaded projection describes the same Skills as the one on - * screen, so an unchanged refresh can keep the array it already published. - */ -function invocableSkillListsEqual( - current: readonly InvocableSkillEntry[], - next: readonly InvocableSkillEntry[], -): boolean { - if (current.length !== next.length) return false; - return current.every((skill, index) => { - const other = next[index]; - return ( - other !== undefined && - skill.ref === other.ref && - skill.id === other.id && - skill.name === other.name && - skill.description === other.description - ); - }); -} - -/** What the composer needs to render its `/` and `@` popups. */ -export interface ComposerMentions { - mentionSkills: ReadonlyArray<{ ref?: string; id: string; name: string; description?: string }>; - mentionSkillsUnavailable: boolean; - mentionSkillsLoading: boolean; - searchMentionFiles(query: string): Promise>; -} - -/** Which backend surface the popups should describe. */ -export interface ComposerMentionsSurface { - /** Invalidates Runtime's invocable projection after installed Skills settle. */ - skillCatalogRevision: number; - sessionId?: string; - projectPath?: string; - newSessionModel?: { llmConnectionSlug: string; model: string }; - newSessionCollaborationMode?: 'agent' | 'plan'; - newSessionPermissionMode?: ChatDefaultPermissionMode; - newTaskTarget?: DesktopNewTaskTarget; -} - -/** - * Owns the composer mention popup wiring so app-shell.tsx keeps no inline - * `window.maka` state (app-shell-composer-attachment-owner-contract). Derives - * the `/` popup's skill list from Runtime's authoritative invocable projection, and - * exposes a fail-soft file-search callback backed by the `workspace:searchFiles` - * IPC. Both return values are memoized so the Composer props keep stable - * identities across renders. - */ -function useComposerMentions(options: ComposerMentionsSurface): ComposerMentions { - const { - projectPath, - sessionId, - skillCatalogRevision, - newSessionModel, - newSessionCollaborationMode, - newSessionPermissionMode, - newTaskTarget, - } = options; - // One explicit representation of the Skill catalog — in flight, settled - // empty, or settled populated — held as a single value so a refresh can - // never tear its facets apart. - // - // `skills` is the live, fail-closed list the `/` popup reads: it is cleared - // the moment a refresh starts, because a visible popup must never advertise - // a Skill the new backend surface may not carry. That clear is exactly why - // `length === 0` cannot tell "re-fetching" from "nothing to offer", so the - // + menu's Skills row renders from `settled` — the last RESOLVED verdict, - // held across refreshes of the SAME context — and repaints only when the - // catalog's emptiness actually changed. `loading` gates interaction: while - // a request is in flight (including the very first, before anything has - // settled), a click on the row must have no side effect — the held - // presentation is the old catalog's look, not a promise the current one - // can honor. - // - // `contextKey` names which backend surface the value describes. The clear - // above happens in a passive effect, one commit AFTER a session/project/ - // model switch has rendered — a window where the old context's Skills are - // still on screen for the new one. Deriving through the key below makes the - // render itself fail closed the moment the context changes, without waiting - // for the effect. - const contextKey = [ - sessionId ?? '', - projectPath ?? '', - newSessionModel?.llmConnectionSlug ?? '', - newSessionModel?.model ?? '', - newSessionCollaborationMode ?? 'agent', - newSessionPermissionMode ?? '', - newTaskTarget?.profileId ?? '', - newTaskTarget?.hostId ?? '', - newTaskTarget?.projectId ?? '', - ].join('\u0000'); - const [catalog, setCatalog] = useState<{ - contextKey: string; - loading: boolean; - settled?: 'empty' | 'populated'; - skills: InvocableSkillEntry[]; - }>({ contextKey, loading: true, skills: EMPTY_SKILLS }); - const liveCatalog = catalog.contextKey === contextKey - ? catalog - : { contextKey, loading: true, settled: undefined, skills: EMPTY_SKILLS }; - - useEffect(() => { - let cancelled = false; - let requestVersion = 0; - const refresh = () => { - const version = ++requestVersion; - setCatalog((previous) => - previous.contextKey === contextKey - ? // A same-context refresh keeps both its settled verdict and the - // Skills already on screen. Clearing here is what made an open `/` - // menu alternate between its commands-only and commands-plus-skills - // geometries on every session or MCP event (#2667). The backend - // surface has not changed, so there is nothing to fail closed - // against; and a Skill withdrawn inside the one-IPC-round-trip - // stale window still fails safely, because selection resolves - // through the Runtime resolver that no longer knows it. - { ...previous, loading: true } - : // A context switch has nothing settled to hold, and its Skills - // belong to the surface being left behind. - { contextKey, loading: true, settled: undefined, skills: EMPTY_SKILLS }, - ); - const context = { - ...(newSessionModel ?? {}), - collaborationMode: newSessionCollaborationMode ?? 'agent', - ...(newSessionPermissionMode - ? { permissionMode: newSessionPermissionMode } - : {}), - } as const; - const request = sessionId - ? window.maka.skills.listInvocable(sessionId) - : newTaskTarget - ? window.maka.newTasks.listInvocableSkills(newTaskTarget, context) - : Promise.resolve([]); - void request.then( - (next) => { - if (cancelled || version !== requestVersion) return; - setCatalog((previous) => ({ - contextKey, - loading: false, - settled: next.length === 0 ? 'empty' : 'populated', - // A refresh that changed nothing keeps the previous array - // identity, so the composer's trigger memo and the menu-replay - // effect stay quiet instead of remounting the popup. - skills: - previous.contextKey === contextKey && - invocableSkillListsEqual(previous.skills, next) - ? previous.skills - : [...next], - })); - }, - () => { - // Fail soft: an unavailable projection leaves `/` with no suggestions. - // Direct `/skill:` input still reaches the same Runtime resolver. - if (cancelled || version !== requestVersion) return; - setCatalog({ contextKey, loading: false, settled: 'empty', skills: EMPTY_SKILLS }); - }, - ); - }; - refresh(); - const unsubscribeSessions = window.maka.sessions.subscribeChanges((event) => { - if ( - sessionId && - event.sessionId === sessionId && - (event.reason === 'updated' || - event.reason === 'mode-change' || - event.reason === 'turn-status-change' || - event.reason === 'rebound') - ) { - refresh(); - } - }); - const unsubscribeContext = sessionId - ? window.maka.mcp.subscribeChanges(() => refresh()) - : window.maka.newTasks.subscribeChanges(() => refresh()); - return () => { - cancelled = true; - requestVersion += 1; - unsubscribeSessions(); - unsubscribeContext(); - }; - }, [ - projectPath, - sessionId, - skillCatalogRevision, - newSessionModel?.llmConnectionSlug, - newSessionModel?.model, - newSessionCollaborationMode, - newSessionPermissionMode, - newTaskTarget?.profileId, - newTaskTarget?.hostId, - newTaskTarget?.projectId, - ]); - - const searchMentionFiles = useCallback( - async (query: string): Promise> => { - try { - const result = sessionId - ? await window.maka.workspace.searchFiles(query, { sessionId }) - : newTaskTarget - ? await window.maka.newTasks.searchFiles(newTaskTarget, query) - : { ok: false as const, reason: 'no_project' as const }; - return result.ok ? result.files : []; - } catch { - // Fail soft: a failed search just yields an empty list, so the popup - // shows 未找到文件 rather than surfacing an error into the composer. - return []; - } - }, - [ - sessionId, - newTaskTarget?.profileId, - newTaskTarget?.hostId, - newTaskTarget?.projectId, - ], - ); - - return { - mentionSkills: liveCatalog.skills, - mentionSkillsUnavailable: liveCatalog.settled === 'empty', - mentionSkillsLoading: liveCatalog.loading, - searchMentionFiles, - }; -} - -/** - * Undefined, not an empty projection. A composer rendered outside the shell — - * the draft-handoff suite mounts `ChatComposerRegion` on its own — must see - * exactly what it saw when these arrived as optional props: nothing. Standing - * in an empty catalog instead flips `onSearchMentionFiles` from absent to - * present, and the Composer mounts the mention popup's layer for a surface - * that has no catalog behind it. - */ -const ComposerMentionsContext = createContext(undefined); - -/** - * Publishes the mention projection to whichever composers are on screen. - * - * The catalog reloads on every session switch and on every MCP or session - * change event, several times per switch. Holding it in AppShell put those - * reloads above the whole tree, so each one re-rendered ~1600 components to - * repaint two popups. Owning it here keeps the reload inside this provider: - * `children` is the element AppShell already built, so React bails out of the - * subtree and only the composers that read the context re-render. - */ -export function ComposerMentionsProvider({ - children, - ...surface -}: ComposerMentionsSurface & { children: ReactNode }) { - const { - mentionSkills, - mentionSkillsUnavailable, - mentionSkillsLoading, - searchMentionFiles, - } = useComposerMentions(surface); - // Destructured so the dependencies ARE the materials. Memoizing the returned - // object against a hand-listed mirror of its fields reads the same until a - // fifth field is added and not mirrored — then consumers keep a wholly stale - // value, and no lint rule can see it. - const value = useMemo( - () => ({ - mentionSkills, - mentionSkillsUnavailable, - mentionSkillsLoading, - searchMentionFiles, - }), - [mentionSkills, mentionSkillsUnavailable, mentionSkillsLoading, searchMentionFiles], - ); - return {children}; -} - -export function useComposerMentionsContext(): ComposerMentions | undefined { - return useContext(ComposerMentionsContext); -} +/** Compatibility entry point for older renderer consumers. */ +export { + ComposerMentionsProvider, + useComposerMentionsContext, + type ComposerMentionsSurface, +} from './features/conversation/index.js'; diff --git a/apps/desktop/src/renderer/composition/desktop-feature-services.tsx b/apps/desktop/src/renderer/composition/desktop-feature-services.tsx index 91755a2677..05c9b183a8 100644 --- a/apps/desktop/src/renderer/composition/desktop-feature-services.tsx +++ b/apps/desktop/src/renderer/composition/desktop-feature-services.tsx @@ -19,6 +19,7 @@ import type { ReactNode } from 'react'; import { ConnectionSettingsServicesProvider } from '../features/connection-settings'; +import { ConversationServicesProvider } from '../features/conversation'; import { GoalServicesProvider } from '../features/goals'; import { ModuleHubServicesProvider } from '../features/module-hub'; import { RuntimeHostManagementServicesProvider } from '../features/runtime-host-management'; @@ -29,6 +30,7 @@ import { TaskEntryServicesProvider } from '../features/task-entry'; import { WorkbarServicesProvider } from '../features/workbar'; import { createDesktopGoalServices } from '../platform/desktop/create-goal-services'; import { createDesktopConnectionSettingsServices } from '../platform/desktop/create-connection-settings-services'; +import { createDesktopConversationServices } from '../platform/desktop/create-conversation-services'; import { createDesktopModuleHubServices } from '../platform/desktop/create-module-hub-services'; import { createDesktopRuntimeHostManagementServices } from '../platform/desktop/create-runtime-host-management-services'; import { createDesktopSessionCollaborationServices } from '../platform/desktop/create-session-collaboration-services'; @@ -40,6 +42,7 @@ import { createDesktopWorkbarServices } from '../platform/desktop/create-workbar export function createDesktopFeatureServices() { return { connectionSettings: createDesktopConnectionSettingsServices(), + conversation: createDesktopConversationServices(), goal: createDesktopGoalServices(), moduleHub: createDesktopModuleHubServices(), runtimeHostManagement: createDesktopRuntimeHostManagementServices(), @@ -58,21 +61,23 @@ export function DesktopFeatureServicesProvider(props: { return ( - - - - - - - - {props.children} - - - - - - - + + + + + + + + + {props.children} + + + + + + + + ); diff --git a/apps/desktop/src/renderer/features/conversation/controller/use-composer-quotes.ts b/apps/desktop/src/renderer/features/conversation/controller/use-composer-quotes.ts new file mode 100644 index 0000000000..c6bc2c93a5 --- /dev/null +++ b/apps/desktop/src/renderer/features/conversation/controller/use-composer-quotes.ts @@ -0,0 +1,101 @@ +/* + * 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 { useCallback, useRef, useState } from 'react'; +import type { QuoteRef } from '@maka/core/events'; + +const MAX_QUOTE_CHARS = 32_000; + +type PendingQuotes = Record; + +export function useComposerQuotes(options: { readonly draftKey: string }) { + const [pendingByKey, setPendingByKey] = useState({}); + // React state triggers rendering, while each bucket is kept mutable so a + // send callback from the current render observes a quote selected in the + // same tick as the snapshot read. This avoids making AppShell reach into a + // second quote getter solely to bridge React's commit timing. + const pendingByKeyRef = useRef({}); + const bucket = pendingByKeyRef.current[options.draftKey] ?? + (pendingByKeyRef.current[options.draftKey] = []); + // This is intentionally a live bucket so a same-tick send can observe a + // snapshot selected before React commits the state update. Consumers must + // read its contents, not use the array identity as a useMemo/useEffect + // dependency; the identity is stable while the bucket is mutated in place. + const pendingQuotes = pendingByKey[options.draftKey] ?? bucket; + + const publish = useCallback((): void => { + setPendingByKey({ ...pendingByKeyRef.current }); + }, []); + + const addQuote = useCallback((input: { + text: string; + turnId?: string; + label?: string; + sourceSessionId?: string; + sourceSessionName?: string; + sourceCapturedAt?: number; + sourceTruncated?: boolean; + }): void => { + const text = input.text.slice(0, MAX_QUOTE_CHARS).trim(); + if (!text) return; + const quote: QuoteRef = { + text, + ...(input.label ? { label: input.label } : {}), + ...(input.turnId ? { sourceTurnId: input.turnId } : {}), + ...(input.sourceSessionId ? { sourceSessionId: input.sourceSessionId } : {}), + ...(input.sourceSessionName ? { sourceSessionName: input.sourceSessionName } : {}), + ...(input.sourceCapturedAt !== undefined ? { sourceCapturedAt: input.sourceCapturedAt } : {}), + ...(input.sourceTruncated !== undefined ? { sourceTruncated: input.sourceTruncated } : {}), + }; + bucket.push(quote); + publish(); + }, [bucket, options.draftKey, publish]); + + const removeQuote = useCallback((index: number): void => { + bucket.splice(index, 1); + publish(); + }, [bucket, publish]); + + const clearQuotes = useCallback((): void => { + bucket.splice(0, bucket.length); + publish(); + }, [bucket, publish]); + + const clearAllQuotes = useCallback((): void => { + for (const quotes of Object.values(pendingByKeyRef.current)) quotes.splice(0, quotes.length); + publish(); + }, [publish]); + + const restoreQuotes = useCallback((ownerKey: string, quotes: readonly QuoteRef[]): void => { + if (quotes.length === 0) return; + const ownerBucket = pendingByKeyRef.current[ownerKey] ?? + (pendingByKeyRef.current[ownerKey] = []); + ownerBucket.push(...quotes.map((quote) => ({ ...quote }))); + publish(); + }, [publish]); + + return { + pendingQuotes, + addQuote, + removeQuote, + clearQuotes, + clearAllQuotes, + restoreQuotes, + }; +} diff --git a/apps/desktop/src/renderer/features/conversation/controller/use-session-reference-composer.ts b/apps/desktop/src/renderer/features/conversation/controller/use-session-reference-composer.ts new file mode 100644 index 0000000000..fff3d471ae --- /dev/null +++ b/apps/desktop/src/renderer/features/conversation/controller/use-session-reference-composer.ts @@ -0,0 +1,152 @@ +/* + * 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 { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import type { QuoteRef } from '@maka/core/events'; +import type { ConversationSession } from '../ports.js'; +import { sessionSnapshotToQuote } from '@maka/core/session-reference'; +import { useConversationServices } from '../services-context.js'; + +export interface SessionReferenceSession { + readonly id: string; + readonly name: string; + readonly status?: string; + readonly lastMessageAt?: number; + readonly lastMessagePreview?: string; +} + +export interface SessionReferenceErrorCopy { + readonly unavailableTitle: string; + readonly unavailableDetail: string; + readonly emptyTitle: string; + readonly emptyDetail: string; + readonly readFailedTitle: string; + readonly readFailedDetail: string; +} + +export function useSessionReferenceComposer(options: { + readonly sessions: readonly ConversationSession[]; + readonly activeId?: string; + readonly hostId?: string; + readonly addQuote?: (quote: QuoteRef) => void; + readonly errorCopy: SessionReferenceErrorCopy; +}) { + const services = useConversationServices(); + const [pending, setPending] = useState(false); + const [error, setError] = useState<{ + contextKey: string; + title: string; + detail: string; + }>(); + const generation = useRef(0); + const contextKey = `${options.activeId ?? ''}\u0000${options.hostId ?? ''}`; + const contextKeyRef = useRef(contextKey); + contextKeyRef.current = contextKey; + const pendingPromise = useRef | null>(null); + const pendingContextKey = useRef(undefined); + const references = useMemo( + () => options.sessions + .filter((session) => + session.runtimeHostId === options.hostId && + session.id !== options.activeId && + !session.isArchived && + session.shared !== true, + ) + .map((session) => ({ + id: session.id, + name: session.name, + status: session.status, + lastMessageAt: session.lastMessageAt, + lastMessagePreview: session.lastMessagePreview, + })), + [options.activeId, options.hostId, options.sessions], + ); + useEffect(() => { + generation.current += 1; + pendingPromise.current = null; + pendingContextKey.current = undefined; + setPending(false); + setError(undefined); + }, [contextKey]); + + const reportError = useCallback((title: string, detail: string) => { + setError({ contextKey, title, detail }); + }, [contextKey]); + const pick = useCallback(async (session: { id: string }): Promise => { + const request = ++generation.current; + const requestContextKey = contextKey; + const source = options.sessions.find((candidate) => candidate.id === session.id); + if ( + !source || + source.isArchived || + source.shared === true || + source.id === options.activeId || + source.runtimeHostId !== options.hostId + ) { + pendingPromise.current = null; + pendingContextKey.current = undefined; + setPending(false); + reportError(options.errorCopy.unavailableTitle, options.errorCopy.unavailableDetail); + return; + } + setError(undefined); + setPending(true); + pendingContextKey.current = requestContextKey; + const operation = (async (): Promise => { + try { + const snapshot = await services.sessions.readSnapshot(source.id); + if (request !== generation.current || requestContextKey !== contextKeyRef.current) return false; + if (!snapshot.text.trim()) { + reportError(options.errorCopy.emptyTitle, options.errorCopy.emptyDetail); + return false; + } + options.addQuote?.(sessionSnapshotToQuote(snapshot)); + return options.addQuote !== undefined; + } catch { + if (request === generation.current && requestContextKey === contextKeyRef.current) { + reportError(options.errorCopy.readFailedTitle, options.errorCopy.readFailedDetail); + } + return false; + } finally { + if (request === generation.current) { + pendingPromise.current = null; + pendingContextKey.current = undefined; + setPending(false); + } + } + })(); + pendingPromise.current = operation; + await operation; + }, [contextKey, options.activeId, options.addQuote, options.errorCopy, options.hostId, options.sessions, reportError, services]); + + const waitForPending = useCallback(async (): Promise => { + const operation = pendingPromise.current; + return operation && pendingContextKey.current === contextKey ? operation : true; + }, [contextKey]); + + return { + references, + pick, + pending, + error: error?.contextKey === contextKey + ? { title: error.title, detail: error.detail } + : undefined, + waitForPending, + }; +} diff --git a/apps/desktop/src/renderer/features/conversation/index.ts b/apps/desktop/src/renderer/features/conversation/index.ts index e8f6b42289..bdb5998705 100644 --- a/apps/desktop/src/renderer/features/conversation/index.ts +++ b/apps/desktop/src/renderer/features/conversation/index.ts @@ -40,3 +40,15 @@ export { type TaskReadinessNotice, } from './model/task-readiness-notice.js'; export * from './model/session-ui-state.js'; + +export { ConversationServicesProvider } from './services-context.js'; +export type { ConversationServices } from './ports.js'; +export { + useSessionReferenceComposer, +} from './controller/use-session-reference-composer.js'; +export { useComposerQuotes } from './controller/use-composer-quotes.js'; +export { + ComposerMentionsProvider, + useComposerMentionsContext, + type ComposerMentionsSurface, +} from './ui/composer-mentions-provider.js'; diff --git a/apps/desktop/src/renderer/features/conversation/ports.ts b/apps/desktop/src/renderer/features/conversation/ports.ts new file mode 100644 index 0000000000..2c94768eb8 --- /dev/null +++ b/apps/desktop/src/renderer/features/conversation/ports.ts @@ -0,0 +1,84 @@ +/* + * 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 type { SessionSnapshot } from '@maka/core/session-reference'; +import type { SessionChangedEvent, SessionSummary } from '@maka/core/session'; +import type { ChatDefaultPermissionMode } from '@maka/core/settings'; +import type { InvocableSkillEntry } from '@maka/runtime/skill-invocation'; + +export interface ConversationSession extends SessionSummary { + readonly runtimeHostId: string; + readonly shared?: true; +} + +export interface ConversationTaskTarget { + readonly profileId: string; + readonly hostId: string; + readonly projectId: string | null; +} + +export interface ConversationServices { + readonly sessions: { + list(): Promise; + subscribeChanges(handler: (event: SessionChangedEvent) => void): () => void; + readSnapshot(sessionId: string): Promise; + }; + readonly skills: { + listInvocable( + sessionId?: string, + context?: { + llmConnectionSlug?: string; + model?: string; + collaborationMode?: 'agent' | 'plan'; + permissionMode?: ChatDefaultPermissionMode; + }, + ): Promise; + }; + readonly workspace: { + searchFiles( + query: string, + options?: { sessionId?: string }, + ): Promise< + | { ok: true; files: Array<{ relativePath: string }> } + | { ok: false; reason: 'no_project' | 'search_failed' } + >; + }; + readonly newTasks: { + subscribeChanges(handler: () => void): () => void; + listInvocableSkills( + target: ConversationTaskTarget, + context?: { + llmConnectionSlug?: string; + model?: string; + collaborationMode?: 'agent' | 'plan'; + permissionMode?: ChatDefaultPermissionMode; + }, + ): Promise; + searchFiles( + target: ConversationTaskTarget, + query: string, + ): Promise< + | { ok: true; files: Array<{ relativePath: string }> } + | { ok: false; reason: 'no_project' | 'search_failed' } + >; + }; + readonly mcp: { + subscribeChanges(handler: () => void): () => void; + }; +} diff --git a/apps/desktop/src/renderer/features/conversation/services-context.tsx b/apps/desktop/src/renderer/features/conversation/services-context.tsx new file mode 100644 index 0000000000..6102150c38 --- /dev/null +++ b/apps/desktop/src/renderer/features/conversation/services-context.tsx @@ -0,0 +1,40 @@ +/* + * 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 { createContext, useContext, type ReactNode } from 'react'; +import type { ConversationServices } from './ports.js'; + +const ConversationServicesContext = createContext(null); + +export function ConversationServicesProvider(props: { + readonly services: ConversationServices; + readonly children?: ReactNode; +}) { + return ( + + {props.children} + + ); +} + +export function useConversationServices(): ConversationServices { + const services = useContext(ConversationServicesContext); + if (!services) throw new Error('ConversationServicesProvider is missing'); + return services; +} diff --git a/apps/desktop/src/renderer/features/conversation/ui/composer-mentions-provider.tsx b/apps/desktop/src/renderer/features/conversation/ui/composer-mentions-provider.tsx new file mode 100644 index 0000000000..a10536f8dd --- /dev/null +++ b/apps/desktop/src/renderer/features/conversation/ui/composer-mentions-provider.tsx @@ -0,0 +1,308 @@ +/* + * 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 { createContext, useContext, useEffect, useMemo, useState, type ReactNode } from 'react'; +import { getConversationCopy, useUiLocale } from '@maka/ui'; +import type { ChatDefaultPermissionMode } from '@maka/core/settings'; +import type { QuoteRef } from '@maka/core/events'; +import type { InvocableSkillEntry } from '@maka/runtime/skill-invocation'; +import type { ConversationSession } from '../ports.js'; +import { useConversationServices } from '../services-context.js'; +import { + useSessionReferenceComposer, + type SessionReferenceSession, +} from '../controller/use-session-reference-composer.js'; + +export interface ComposerMentionsSurface { + readonly skillCatalogRevision: number; + readonly sessionId?: string; + readonly projectPath?: string; + readonly newSessionModel?: { llmConnectionSlug: string; model: string }; + readonly newSessionCollaborationMode?: 'agent' | 'plan'; + readonly newSessionPermissionMode?: ChatDefaultPermissionMode; + readonly newTaskTarget?: { + readonly profileId: string; + readonly hostId: string; + readonly projectId: string | null; + }; + readonly onAddQuote?: (quote: QuoteRef) => void; +} + +export interface ComposerMentions { + readonly mentionSkills: ReadonlyArray<{ + ref?: string; + id: string; + name: string; + description?: string; + }>; + readonly mentionSkillsUnavailable: boolean; + readonly mentionSkillsLoading: boolean; + searchMentionFiles(query: string): Promise>; + readonly sessionReferences: ReadonlyArray; + readonly onPickSessionReference?: (session: SessionReferenceSession) => Promise; + readonly sessionReferenceError?: { title: string; detail: string }; + waitForSessionReference(): Promise; +} + +const EMPTY_SKILLS: InvocableSkillEntry[] = []; +const ComposerMentionsContext = createContext(undefined); + +function skillListsEqual( + current: readonly InvocableSkillEntry[], + next: readonly InvocableSkillEntry[], +): boolean { + return current.length === next.length && current.every((skill, index) => { + const other = next[index]; + return ( + other?.ref === skill.ref && + other?.id === skill.id && + other?.name === skill.name && + other?.description === skill.description + ); + }); +} + +function conversationSessionListsEqual( + current: readonly ConversationSession[], + next: readonly ConversationSession[], +): boolean { + if (current.length !== next.length) return false; + return current.every((session, index) => { + const other = next[index]; + return ( + other !== undefined && + session.id === other.id && + session.runtimeHostId === other.runtimeHostId && + session.name === other.name && + session.status === other.status && + session.lastMessageAt === other.lastMessageAt && + session.lastMessagePreview === other.lastMessagePreview && + session.isArchived === other.isArchived && + session.shared === other.shared + ); + }); +} + +function useConversationMentions(surface: ComposerMentionsSurface): ComposerMentions { + const services = useConversationServices(); + const locale = useUiLocale(); + const mentionCopy = getConversationCopy(locale).mentions; + const [catalog, setCatalog] = useState<{ + key: string; + loading: boolean; + settled?: 'empty' | 'populated'; + skills: InvocableSkillEntry[]; + }>({ + key: '', + loading: true, + skills: EMPTY_SKILLS, + }); + const [sessions, setSessions] = useState([]); + const contextKey = [ + surface.sessionId ?? '', + surface.projectPath ?? '', + surface.newSessionModel?.llmConnectionSlug ?? '', + surface.newSessionModel?.model ?? '', + surface.newSessionCollaborationMode ?? 'agent', + surface.newSessionPermissionMode ?? '', + surface.newTaskTarget?.profileId ?? '', + surface.newTaskTarget?.hostId ?? '', + surface.newTaskTarget?.projectId ?? '', + surface.skillCatalogRevision, + ].join('\u0000'); + const activeHostId = surface.sessionId + ? sessions.find((session) => session.id === surface.sessionId)?.runtimeHostId + : surface.newTaskTarget?.hostId; + + useEffect(() => { + let cancelled = false; + const refreshSessions = () => { + void services.sessions.list().then((next) => { + if (!cancelled) { + setSessions((previous) => + conversationSessionListsEqual(previous, next) ? previous : next, + ); + } + }).catch(() => { + if (!cancelled) { + setSessions((previous) => (previous.length === 0 ? previous : [])); + } + }); + }; + refreshSessions(); + const unsubscribe = services.sessions.subscribeChanges(refreshSessions); + return () => { + cancelled = true; + unsubscribe(); + }; + }, [services]); + + useEffect(() => { + let cancelled = false; + let requestVersion = 0; + const context = { + ...(surface.newSessionModel ?? {}), + collaborationMode: surface.newSessionCollaborationMode ?? 'agent', + ...(surface.newSessionPermissionMode + ? { permissionMode: surface.newSessionPermissionMode } + : {}), + }; + const refresh = () => { + const version = ++requestVersion; + const request = surface.sessionId + ? services.skills.listInvocable(surface.sessionId) + : surface.newTaskTarget + ? services.newTasks.listInvocableSkills(surface.newTaskTarget, context) + : Promise.resolve([]); + setCatalog((previous) => ({ + key: contextKey, + loading: true, + settled: previous.key === contextKey ? previous.settled : undefined, + skills: previous.key === contextKey ? previous.skills : EMPTY_SKILLS, + })); + void request.then((next) => { + if (cancelled || version !== requestVersion) return; + setCatalog((previous) => ({ + key: contextKey, + loading: false, + settled: next.length === 0 ? 'empty' : 'populated', + skills: skillListsEqual(previous.skills, next) ? previous.skills : [...next], + })); + }).catch(() => { + if (!cancelled && version === requestVersion) { + setCatalog({ key: contextKey, loading: false, settled: 'empty', skills: EMPTY_SKILLS }); + } + }); + }; + refresh(); + const unsubscribeContext = surface.sessionId + ? services.mcp.subscribeChanges(refresh) + : services.newTasks.subscribeChanges(refresh); + const unsubscribeSession = surface.sessionId + ? services.sessions.subscribeChanges((event) => { + if ( + event.sessionId === surface.sessionId && + (event.reason === 'updated' || + event.reason === 'mode-change' || + event.reason === 'turn-status-change' || + event.reason === 'rebound') + ) { + refresh(); + } + }) + : () => undefined; + return () => { + cancelled = true; + requestVersion += 1; + unsubscribeContext(); + unsubscribeSession(); + }; + }, [ + contextKey, + services, + surface.newSessionModel?.llmConnectionSlug, + surface.newSessionModel?.model, + surface.newSessionCollaborationMode, + surface.newSessionPermissionMode, + surface.sessionId, + surface.newTaskTarget?.profileId, + surface.newTaskTarget?.hostId, + surface.newTaskTarget?.projectId, + ]); + + const searchMentionFiles = useMemo( + () => async (query: string): Promise> => { + try { + const result = surface.sessionId + ? await services.workspace.searchFiles(query, { sessionId: surface.sessionId }) + : surface.newTaskTarget + ? await services.newTasks.searchFiles(surface.newTaskTarget, query) + : { ok: false as const, reason: 'no_project' as const }; + return result.ok ? result.files : []; + } catch { + return []; + } + }, + [ + services, + surface.newTaskTarget?.profileId, + surface.newTaskTarget?.hostId, + surface.newTaskTarget?.projectId, + surface.sessionId, + ], + ); + + const reference = useSessionReferenceComposer({ + sessions, + activeId: surface.sessionId, + hostId: activeHostId, + addQuote: surface.onAddQuote, + errorCopy: useMemo( + () => ({ + unavailableTitle: mentionCopy.sessionReferenceUnavailableTitle, + unavailableDetail: mentionCopy.sessionReferenceUnavailableDetail, + emptyTitle: mentionCopy.sessionReferenceEmptyTitle, + emptyDetail: mentionCopy.sessionReferenceEmptyDetail, + readFailedTitle: mentionCopy.sessionReferenceReadFailedTitle, + readFailedDetail: mentionCopy.sessionReferenceReadFailedDetail, + }), + [ + mentionCopy.sessionReferenceEmptyDetail, + mentionCopy.sessionReferenceEmptyTitle, + mentionCopy.sessionReferenceReadFailedDetail, + mentionCopy.sessionReferenceReadFailedTitle, + mentionCopy.sessionReferenceUnavailableDetail, + mentionCopy.sessionReferenceUnavailableTitle, + ], + ), + }); + const referenceEnabled = surface.sessionId !== undefined || surface.newTaskTarget !== undefined; + return useMemo(() => ({ + mentionSkills: catalog.key === contextKey ? catalog.skills : EMPTY_SKILLS, + mentionSkillsUnavailable: catalog.key === contextKey && catalog.settled === 'empty', + mentionSkillsLoading: catalog.loading, + searchMentionFiles, + sessionReferences: surface.onAddQuote && referenceEnabled ? reference.references : [], + onPickSessionReference: + surface.onAddQuote && referenceEnabled ? reference.pick : undefined, + sessionReferenceError: reference.error, + waitForSessionReference: reference.waitForPending, + }), [ + catalog.key, + catalog.loading, + catalog.settled, + catalog.skills, + contextKey, + reference.error, + reference.pick, + reference.references, + reference.waitForPending, + searchMentionFiles, + surface.onAddQuote, + ]); +} + +export function ComposerMentionsProvider(props: ComposerMentionsSurface & { readonly children: ReactNode }) { + const mentions = useConversationMentions(props); + return {props.children}; +} + +export function useComposerMentionsContext(): ComposerMentions | undefined { + return useContext(ComposerMentionsContext); +} diff --git a/apps/desktop/src/renderer/features/session-settings/use-session-setting-intent.ts b/apps/desktop/src/renderer/features/session-settings/use-session-setting-intent.ts index 438ca98097..e848c0c605 100644 --- a/apps/desktop/src/renderer/features/session-settings/use-session-setting-intent.ts +++ b/apps/desktop/src/renderer/features/session-settings/use-session-setting-intent.ts @@ -48,7 +48,7 @@ export function useSessionSettingIntent(in catalogRevision: number; isActiveSession(sessionId: string): boolean; sessions: readonly SessionSummary[]; - newTaskPermissionMode: ChatDefaultPermissionMode; + newSessionPermissionMode: ChatDefaultPermissionMode; refreshCatalog(): Promise; saveComposerDefaults(model: SessionModelTarget): void; writeFailureCopy( @@ -145,7 +145,7 @@ export function useSessionSettingIntent(in const overlay = sessionId ? intent.overlayByChannel.permissionMode[sessionId] : undefined; const currentMode = sessionId ? overlay ?? input.sessions.find((session) => session.id === sessionId)?.permissionMode - : input.newTaskPermissionMode; + : input.newSessionPermissionMode; if (currentMode === mode) { return sessionId && overlay !== undefined ? intent.request('permissionMode', sessionId, mode) diff --git a/apps/desktop/src/renderer/platform/desktop/create-conversation-services.ts b/apps/desktop/src/renderer/platform/desktop/create-conversation-services.ts new file mode 100644 index 0000000000..26d1bbfdc2 --- /dev/null +++ b/apps/desktop/src/renderer/platform/desktop/create-conversation-services.ts @@ -0,0 +1,50 @@ +/* + * 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 type { MakaBridge } from '../../../preload/bridge-contract.js'; +import type { ConversationServices } from '../../features/conversation'; + +export function createDesktopConversationServices( + bridge: Pick = window.maka, +): ConversationServices { + return { + sessions: { + list: () => bridge.sessions.list(), + subscribeChanges: (handler) => bridge.sessions.subscribeChanges(handler), + readSnapshot: (sessionId) => bridge.sessions.readSnapshot(sessionId), + }, + skills: { + listInvocable: (sessionId, context) => { + const { permissionMode: _permissionMode, ...skillsContext } = context ?? {}; + return bridge.skills.listInvocable(sessionId, skillsContext); + }, + }, + workspace: { + searchFiles: (query, options) => bridge.workspace.searchFiles(query, options), + }, + newTasks: { + subscribeChanges: (handler) => bridge.newTasks.subscribeChanges(handler), + listInvocableSkills: (target, context) => bridge.newTasks.listInvocableSkills(target, context), + searchFiles: (target, query) => bridge.newTasks.searchFiles(target, query), + }, + mcp: { + subscribeChanges: (handler) => bridge.mcp.subscribeChanges(() => handler()), + }, + }; +} diff --git a/apps/desktop/src/renderer/styles/composer.css b/apps/desktop/src/renderer/styles/composer.css index 1e7af4adcf..4bf306c719 100644 --- a/apps/desktop/src/renderer/styles/composer.css +++ b/apps/desktop/src/renderer/styles/composer.css @@ -134,6 +134,37 @@ padding: var(--space-1) var(--space-3) var(--space-2); } +/* A Session reference follows the compact Codex-style context rail: one + content-sized token directly above the input, with no extra disclosure band + or empty panel around it. Attachment and directory drawers keep the + standard collapsible layout below. */ +.maka-composer-astryx + .maka-composer-drawer[data-maka-session-reference-only='true'] { + margin: 0 0 var(--space-1); + padding: 0; + background: transparent; + border-radius: 0; +} + +.maka-composer-astryx + .maka-composer-drawer[data-maka-session-reference-only='true'] + .maka-composer-context-drawer { + gap: var(--space-1); + padding: 0; +} + +.maka-composer-astryx + .maka-composer-drawer[data-maka-session-reference-only='true'] + .maka-composer-session-token { + /* Keep a single reference compact like Codex's context chip. The token's + own Astryx surface supplies its height, padding, radius, and remove + affordance; this rule only constrains unusually long session names. */ + flex: 0 1 auto; + min-width: 0; + max-width: min(420px, 100%); + overflow: hidden; +} + /* Astryx ChatComposerDrawer wraps its content in a display:grid whose single implicit column sizes to the content's max-content contribution — with a long chip row that resolves WIDER than the grid (measured: 896px column in diff --git a/apps/desktop/src/renderer/use-app-shell-composer-quotes.ts b/apps/desktop/src/renderer/use-app-shell-composer-quotes.ts index 83eccbbe4d..5567e56a8b 100644 --- a/apps/desktop/src/renderer/use-app-shell-composer-quotes.ts +++ b/apps/desktop/src/renderer/use-app-shell-composer-quotes.ts @@ -17,74 +17,8 @@ * under the License. */ -import { useState } from 'react'; -import type { QuoteRef } from '@maka/core/events'; -import { - appendPending, - clearPending, - removePending, - selectPending, - type PendingByKey, -} from './pending-items.js'; +/** Compatibility entry point; quote state now belongs to Conversation. */ +import { useComposerQuotes } from './features/conversation/index.js'; -/** - * Excerpts longer than this are truncated before staging. Kept equal to the - * `sessions:send` normalizer's per-quote cap so the renderer can never stage - * something the IPC boundary would reject on send. - */ -const MAX_QUOTE_CHARS = 32_000; - -/** - * Quoted excerpts staged for the next send, keyed by draft key so each session - * keeps its own (mirrors pending attachments). Cleared once the turn is sent. - */ -export function useAppShellComposerQuotes(options: { draftKey: string }) { - const [pendingByKey, setPendingByKey] = useState>({}); - const pendingQuotes = selectPending(pendingByKey, options.draftKey); - - function addQuote(input: { text: string; turnId?: string; label?: string }): void { - const text = input.text.slice(0, MAX_QUOTE_CHARS).trim(); - if (!text) return; - const ownerKey = options.draftKey; - const quote: QuoteRef = { - text, - ...(input.label ? { label: input.label } : {}), - ...(input.turnId ? { sourceTurnId: input.turnId } : {}), - }; - setPendingByKey((map) => appendPending(map, ownerKey, [quote])); - } - - function removeQuote(index: number): void { - const ownerKey = options.draftKey; - setPendingByKey((map) => removePending(map, ownerKey, index)); - } - - function clearQuotes(): void { - const ownerKey = options.draftKey; - setPendingByKey((map) => clearPending(map, ownerKey)); - } - - function clearAllQuotes(): void { - setPendingByKey({}); - } - - function restoreQuotes(ownerKey: string, quotes: readonly QuoteRef[]): void { - if (quotes.length === 0) return; - setPendingByKey((map) => - appendPending( - map, - ownerKey, - quotes.map((quote) => ({ ...quote })), - ), - ); - } - - return { - pendingQuotes, - addQuote, - removeQuote, - clearQuotes, - clearAllQuotes, - restoreQuotes, - }; -} +export { useComposerQuotes }; +export const useAppShellComposerQuotes: typeof useComposerQuotes = useComposerQuotes; diff --git a/docs/astryx-surface-file-inventory.md b/docs/astryx-surface-file-inventory.md index 14992542f9..6e73a5fb24 100644 --- a/docs/astryx-surface-file-inventory.md +++ b/docs/astryx-surface-file-inventory.md @@ -6,7 +6,7 @@ Generated against `@astryxdesign/core@0.5.2` (194 component exports). Wiki bar: Design Conventions · API Use-the-System · Theming · Container Padding. -**Totals:** 244 files — blocker 0, reimplementation 0, polish 1, aligned 243. +**Totals:** 246 files — blocker 0, reimplementation 0, polish 1, aligned 245. ## Exclusions (explicit) @@ -43,6 +43,8 @@ Wiki bar: Design Conventions · API Use-the-System · Theming · Container Paddi | `apps/desktop/src/renderer/custom-pet-companion.tsx` | other | none | aligned — no raw controls; no Astryx JSX usage | aligned | | `apps/desktop/src/renderer/error-boundary.tsx` | other | Button, Card | aligned — uses Astryx (Button, Card) | aligned | | `apps/desktop/src/renderer/features/connection-settings/services-context.tsx` | other | none | aligned — no raw controls; no Astryx JSX usage | aligned | +| `apps/desktop/src/renderer/features/conversation/services-context.tsx` | other | none | aligned — no raw controls; no Astryx JSX usage | aligned | +| `apps/desktop/src/renderer/features/conversation/ui/composer-mentions-provider.tsx` | shell-chrome-or-panel | none | aligned — no raw controls; no Astryx JSX usage | aligned | | `apps/desktop/src/renderer/features/goals/services-context.tsx` | other | none | aligned — no raw controls; no Astryx JSX usage | aligned | | `apps/desktop/src/renderer/features/goals/ui/goal-dialog.tsx` | dialog-overlay | Button, Dialog, DialogHeader, HStack, Layout, LayoutContent, LayoutFooter, Text, TextArea, TextInput, VStack | aligned — uses Astryx (Button, Dialog, DialogHeader, HStack, Layout, LayoutContent, LayoutFooter, Text) | aligned | | `apps/desktop/src/renderer/features/goals/ui/goal-host.tsx` | other | none | aligned — no raw controls; no Astryx JSX usage | aligned | diff --git a/docs/astryx-surface-file-inventory.paths b/docs/astryx-surface-file-inventory.paths index e256ca9acb..cccce12492 100644 --- a/docs/astryx-surface-file-inventory.paths +++ b/docs/astryx-surface-file-inventory.paths @@ -14,6 +14,8 @@ apps/desktop/src/renderer/composition/desktop-feature-services.tsx apps/desktop/src/renderer/custom-pet-companion.tsx apps/desktop/src/renderer/error-boundary.tsx apps/desktop/src/renderer/features/connection-settings/services-context.tsx +apps/desktop/src/renderer/features/conversation/services-context.tsx +apps/desktop/src/renderer/features/conversation/ui/composer-mentions-provider.tsx apps/desktop/src/renderer/features/goals/services-context.tsx apps/desktop/src/renderer/features/goals/ui/goal-dialog.tsx apps/desktop/src/renderer/features/goals/ui/goal-host.tsx diff --git a/packages/core/package.json b/packages/core/package.json index 8ef4b2638c..38c32ae423 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -26,6 +26,7 @@ "./provider-retry-countdown": "./dist/provider-retry-countdown.js", "./interaction": "./dist/interaction.js", "./session": "./dist/session.js", + "./session-reference": "./dist/session-reference.js", "./session-revisions": "./dist/session-revisions.js", "./collaboration": "./dist/collaboration.js", "./orchestration": "./dist/orchestration.js", diff --git a/packages/core/src/__tests__/events.test.ts b/packages/core/src/__tests__/events.test.ts index b57bdbfea7..553ecf223d 100644 --- a/packages/core/src/__tests__/events.test.ts +++ b/packages/core/src/__tests__/events.test.ts @@ -23,6 +23,8 @@ import { aggregateMessageContents, decodeToolStepProgress, encodeToolStepProgress, + decodeMessageContent, + isQuoteRef, } from '../events.js'; test('aggregates inline references against the combined display text', () => { @@ -57,6 +59,21 @@ test('preserves an explicit empty inline-reference marker while aggregating', () }); }); +test('round-trips Session snapshot provenance and rejects partial provenance', () => { + const quote = { + text: 'bounded excerpt', + label: 'Session: Research', + sourceSessionId: 'session-source', + sourceSessionName: 'Research', + sourceCapturedAt: 1_735_000_000_000, + sourceTruncated: true, + } as const; + assert.equal(isQuoteRef(quote), true); + assert.deepEqual(decodeMessageContent({ text: 'continue', quotes: [quote] }).quotes, [quote]); + assert.equal(isQuoteRef({ ...quote, sourceTruncated: undefined }), false); + assert.equal(isQuoteRef({ ...quote, sourceCapturedAt: Number.NaN }), false); +}); + test('round-trips bounded tool step progress through the shared wire codec', () => { const encoded = encodeToolStepProgress({ current: 1, total: 2 }); diff --git a/packages/core/src/__tests__/session-reference.test.ts b/packages/core/src/__tests__/session-reference.test.ts new file mode 100644 index 0000000000..bdae908f33 --- /dev/null +++ b/packages/core/src/__tests__/session-reference.test.ts @@ -0,0 +1,166 @@ +/* + * 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 test from 'node:test'; +import type { StoredMessage } from '../session.js'; +import { createSessionSnapshot, sessionSnapshotToQuote } from '../session-reference.js'; + +function user(id: string, text: string, ts = 1): StoredMessage { + return { type: 'user', id, turnId: `turn-${id}`, ts, text }; +} + +function assistant(id: string, text: string, ts = 2): StoredMessage { + return { type: 'assistant', id, turnId: `turn-${id}`, ts, text, modelId: 'model' }; +} + +test('creates a recent, redacted snapshot and excludes non-conversation messages', () => { + const snapshot = createSessionSnapshot( + [ + user('old-user', 'old context'), + assistant('old-assistant', 'old answer'), + { + type: 'tool_call', + id: 'tool-call', + turnId: 'turn-tool', + ts: 3, + toolName: 'Bash', + args: { command: 'cat secret.txt' }, + }, + { + type: 'system_note', + id: 'system-note', + ts: 4, + kind: 'error', + data: { secret: 'do-not-share' }, + }, + user('new-user', 'new question', 5), + assistant('new-assistant', 'new answer', 6), + ], + { + sessionId: 'session-source', + sessionName: 'Runtime research', + capturedAt: 123, + maxChars: 10_000, + }, + ); + + assert.deepEqual( + snapshot.items.map((item) => item.role), + ['user', 'assistant', 'user', 'assistant'], + ); + assert.match(snapshot.text, /new question/); + assert.match(snapshot.text, /new answer/); + assert.doesNotMatch(snapshot.text, /secret/); + assert.equal(snapshot.truncated, false); + assert.equal(snapshot.reference.sessionId, 'session-source'); + assert.equal(snapshot.reference.sessionName, 'Runtime research'); + assert.equal(snapshot.reference.capturedAt, 123); +}); + +test('redacts secrets from retained user and assistant messages before quoting them', () => { + const snapshot = createSessionSnapshot( + [ + user('user-secret', 'Use Authorization: Bearer sk-live-secret-token-value'), + assistant('assistant-secret', 'The key is sk-ant-api03-live-secret-token-value'), + ], + { + sessionId: 'session-source', + sessionName: 'Sensitive session', + }, + ); + + assert.doesNotMatch(snapshot.text, /sk-live-secret-token-value/); + assert.doesNotMatch(snapshot.text, /sk-ant-api03-live-secret-token-value/); + assert.match(snapshot.text, /\[redacted\]/); + assert.doesNotMatch(sessionSnapshotToQuote(snapshot).text, /sk-live-secret-token-value/); +}); + +test('bounds a snapshot from the newest content and preserves truncation provenance', () => { + const snapshot = createSessionSnapshot( + [ + user('first', 'first message'), + assistant('second', 'second message'), + user('last', 'latest message'), + ], + { + sessionId: 'session-source', + sessionName: 'Long session', + capturedAt: 456, + maxChars: 24, + }, + ); + + assert.equal(snapshot.truncated, true); + assert.ok(snapshot.text.length <= 24); + assert.match(snapshot.text, /latest/); + assert.equal(sessionSnapshotToQuote(snapshot).sourceSessionId, 'session-source'); + assert.equal(sessionSnapshotToQuote(snapshot).sourceSessionName, 'Long session'); + assert.equal(sessionSnapshotToQuote(snapshot).sourceCapturedAt, 456); + assert.equal(sessionSnapshotToQuote(snapshot).sourceTruncated, true); +}); + +test('accounts for the role prefix when truncating a single item', () => { + const snapshot = createSessionSnapshot([user('last', 'latest message')], { + sessionId: 'session-source', + sessionName: 'Short budget', + maxChars: 10, + }); + + assert.equal(snapshot.items[0]?.text, 'late'); + assert.equal(snapshot.text, 'User: late'); + assert.ok(snapshot.text.length <= 10); + assert.doesNotMatch(snapshot.text, /User: User/); +}); + +test('does not emit a partial role prefix when no content fits', () => { + const snapshot = createSessionSnapshot([user('last', 'latest message')], { + sessionId: 'session-source', + sessionName: 'Tiny budget', + maxChars: 5, + }); + + assert.deepEqual(snapshot.items, []); + assert.equal(snapshot.text, ''); + assert.equal(snapshot.truncated, true); +}); + +test('does not split an emoji when truncating the first item', () => { + const snapshot = createSessionSnapshot([user('last', 'a😀b')], { + sessionId: 'session-source', + sessionName: 'Unicode boundary', + maxChars: 8, + }); + + assert.equal(snapshot.text, 'User: a'); + assert.equal(snapshot.items[0]?.text, 'a'); + assert.equal([...snapshot.text].join(''), snapshot.text); +}); + +test('does not emit a partial role prefix when an emoji cannot fit', () => { + const snapshot = createSessionSnapshot([user('last', '😀')], { + sessionId: 'session-source', + sessionName: 'Joined boundary', + maxChars: 7, + }); + + assert.equal(snapshot.text, ''); + assert.deepEqual(snapshot.items, []); + assert.equal([...snapshot.text].join(''), snapshot.text); +}); diff --git a/packages/core/src/events.ts b/packages/core/src/events.ts index 52af0d0727..bbb46ea5b7 100644 --- a/packages/core/src/events.ts +++ b/packages/core/src/events.ts @@ -125,6 +125,14 @@ export interface QuoteRef { label?: string; /** Provenance: the transcript turn the excerpt was selected from. */ sourceTurnId?: string; + /** Source Session identity for a read-only cross-session snapshot. */ + sourceSessionId?: string; + /** Frozen source Session display name for provenance chips and replay. */ + sourceSessionName?: string; + /** Unix timestamp at which the source snapshot was captured. */ + sourceCapturedAt?: number; + /** Whether the source snapshot was bounded before it was attached. */ + sourceTruncated?: boolean; } /** @@ -169,7 +177,19 @@ const ATTACHMENT_REF_SHAPE = defineObjectShape()( ['kind', 'name', 'mimeType', 'bytes', 'ref'], [], ); -const QUOTE_REF_SHAPE = defineObjectShape()(['text'], ['label', 'sourceTurnId']); +const QUOTE_REF_SHAPE = defineObjectShape()( + ['text'], + [ + 'label', + 'sourceTurnId', + 'sourceSessionId', + 'sourceSessionName', + 'sourceCapturedAt', + 'sourceTruncated', + ], +); +const QUOTE_REF_SESSION_ID_MAX_LENGTH = 512; +const QUOTE_REF_SESSION_NAME_MAX_LENGTH = 200; const INLINE_REFERENCE_SHAPE = defineObjectShape()( ['kind', 'value', 'label', 'start'], [], @@ -217,6 +237,22 @@ export function normalizeMessageContent(content: MessageContent): MessageContent text: quote.text, ...(quote.label !== undefined ? { label: quote.label } : {}), ...(quote.sourceTurnId !== undefined ? { sourceTurnId: quote.sourceTurnId } : {}), + ...(quote.sourceSessionId !== undefined + ? { sourceSessionId: quote.sourceSessionId } + : {}), + ...(quote.sourceSessionName !== undefined + ? { sourceSessionName: quote.sourceSessionName } + : {}), + ...(quote.sourceCapturedAt !== undefined + ? { + sourceCapturedAt: Object.is(quote.sourceCapturedAt, -0) + ? 0 + : quote.sourceCapturedAt, + } + : {}), + ...(quote.sourceTruncated !== undefined + ? { sourceTruncated: quote.sourceTruncated } + : {}), })), } : {}), @@ -321,12 +357,33 @@ export function isInlineReference(value: unknown): value is InlineReference { } export function isQuoteRef(value: unknown): value is QuoteRef { + const record = isRecord(value) ? value : undefined; + const sourceFields = record + ? [ + record.sourceSessionId, + record.sourceSessionName, + record.sourceCapturedAt, + record.sourceTruncated, + ] + : []; + const hasSourceMetadata = sourceFields.some((field) => field !== undefined); return ( - isRecord(value) && - hasExactShape(value, QUOTE_REF_SHAPE) && - typeof value.text === 'string' && - (value.label === undefined || typeof value.label === 'string') && - (value.sourceTurnId === undefined || typeof value.sourceTurnId === 'string') + record !== undefined && + hasExactShape(record, QUOTE_REF_SHAPE) && + typeof record.text === 'string' && + (record.label === undefined || typeof record.label === 'string') && + (record.sourceTurnId === undefined || typeof record.sourceTurnId === 'string') && + (!hasSourceMetadata || + (typeof record.sourceSessionId === 'string' && + record.sourceSessionId.length > 0 && + record.sourceSessionId.length <= QUOTE_REF_SESSION_ID_MAX_LENGTH && + typeof record.sourceSessionName === 'string' && + record.sourceSessionName.length > 0 && + record.sourceSessionName.length <= QUOTE_REF_SESSION_NAME_MAX_LENGTH && + typeof record.sourceCapturedAt === 'number' && + Number.isFinite(record.sourceCapturedAt) && + record.sourceCapturedAt >= 0 && + typeof record.sourceTruncated === 'boolean')) ); } @@ -488,7 +545,11 @@ function quoteRefsEqual(left: QuoteRef, right: QuoteRef): boolean { return ( left.text === right.text && left.label === right.label && - left.sourceTurnId === right.sourceTurnId + left.sourceTurnId === right.sourceTurnId && + left.sourceSessionId === right.sourceSessionId && + left.sourceSessionName === right.sourceSessionName && + left.sourceCapturedAt === right.sourceCapturedAt && + left.sourceTruncated === right.sourceTruncated ); } diff --git a/packages/core/src/session-reference.ts b/packages/core/src/session-reference.ts new file mode 100644 index 0000000000..0e67eaed2d --- /dev/null +++ b/packages/core/src/session-reference.ts @@ -0,0 +1,177 @@ +/* + * 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 type { QuoteRef } from './events.js'; +import { redactSecrets } from './redaction.js'; +import type { StoredMessage } from './session.js'; +import { userFacingText } from './session.js'; + +/** Default per-reference budget. It is deliberately small enough to leave room for the active task. */ +export const SESSION_SNAPSHOT_DEFAULT_MAX_CHARS = 12_000; +export const SESSION_SNAPSHOT_MAX_CHARS = 32_000; +export const SESSION_SNAPSHOT_MAX_ITEMS = 24; + +export interface SessionSnapshotReference { + sessionId: string; + sessionName: string; + capturedAt: number; +} + +export interface SessionSnapshotItem { + role: 'user' | 'assistant'; + text: string; + turnId: string; + ts: number; +} + +export interface SessionSnapshot { + reference: SessionSnapshotReference; + items: readonly SessionSnapshotItem[]; + text: string; + estimatedTokens: number; + maxChars: number; + truncated: boolean; +} + +export interface SessionSnapshotOptions { + sessionId: string; + sessionName: string; + capturedAt?: number; + maxChars?: number; + maxItems?: number; +} + +/** + * Build the model-safe portion of a Session transcript. + * + * Only user and assistant text is shareable. Tool calls/results, permission + * events, system notes, and other Runtime records remain outside this + * projection. Items are selected from the tail, then restored to transcript + * order so a large history cannot crowd the current request unexpectedly. + */ +export function createSessionSnapshot( + messages: readonly StoredMessage[], + options: SessionSnapshotOptions, +): SessionSnapshot { + const maxChars = clampPositiveInteger( + options.maxChars ?? SESSION_SNAPSHOT_DEFAULT_MAX_CHARS, + 1, + SESSION_SNAPSHOT_MAX_CHARS, + ); + const maxItems = clampPositiveInteger( + options.maxItems ?? SESSION_SNAPSHOT_MAX_ITEMS, + 1, + SESSION_SNAPSHOT_MAX_ITEMS, + ); + const candidates: SessionSnapshotItem[] = messages.flatMap((message) => { + if (message.type !== 'user' && message.type !== 'assistant') return []; + const text = message.type === 'user' ? userFacingText(message) : message.text; + const normalized = redactSecrets(text).trim(); + if (!normalized) return []; + return [ + { + role: message.type, + text: normalized, + turnId: message.turnId, + ts: message.ts, + }, + ]; + }); + + const selected: SessionSnapshotItem[] = []; + let usedChars = 0; + let truncated = false; + for (let index = candidates.length - 1; index >= 0 && selected.length < maxItems; index -= 1) { + const candidate = candidates[index]!; + const line = formatSnapshotItem(candidate); + const separator = selected.length > 0 ? 2 : 0; + const available = maxChars - usedChars - separator; + if (available <= 0) { + truncated = true; + break; + } + if (line.length <= available) { + selected.push(candidate); + usedChars += separator + line.length; + continue; + } + if (selected.length === 0) { + const prefixLength = formatSnapshotItem({ ...candidate, text: '' }).length; + const contentBudget = Math.max(0, available - prefixLength); + if (contentBudget > 0) { + const text = sliceAtCodePointBoundary(candidate.text, contentBudget).trimEnd(); + if (!text) { + truncated = true; + break; + } + selected.push({ + ...candidate, + text, + }); + usedChars = maxChars; + } + } + truncated = true; + break; + } + if (selected.length < candidates.length) truncated = true; + selected.reverse(); + + const text = sliceAtCodePointBoundary(selected.map(formatSnapshotItem).join('\n\n'), maxChars); + return { + reference: { + sessionId: options.sessionId, + sessionName: options.sessionName, + capturedAt: options.capturedAt ?? Date.now(), + }, + items: selected, + text, + estimatedTokens: Math.ceil(text.length / 4), + maxChars, + truncated, + }; +} + +/** Convert a snapshot into the existing inline quote transport. */ +export function sessionSnapshotToQuote(snapshot: SessionSnapshot): QuoteRef { + return { + text: snapshot.text, + label: `Session: ${snapshot.reference.sessionName}`, + sourceSessionId: snapshot.reference.sessionId, + sourceSessionName: snapshot.reference.sessionName, + sourceCapturedAt: snapshot.reference.capturedAt, + sourceTruncated: snapshot.truncated, + }; +} + +function formatSnapshotItem(item: Pick): string { + return `${item.role === 'user' ? 'User' : 'Assistant'}: ${item.text}`; +} + +/** Keep a UTF-16 slice from ending between the halves of a surrogate pair. */ +function sliceAtCodePointBoundary(value: string, maxCodeUnits: number): string { + const sliced = value.slice(0, maxCodeUnits); + const last = sliced.charCodeAt(sliced.length - 1); + return last >= 0xd800 && last <= 0xdbff ? sliced.slice(0, -1) : sliced; +} + +function clampPositiveInteger(value: number, min: number, max: number): number { + if (!Number.isSafeInteger(value)) return min; + return Math.max(min, Math.min(max, value)); +} diff --git a/packages/runtime/src/__tests__/directory-reference-model-context.test.ts b/packages/runtime/src/__tests__/directory-reference-model-context.test.ts index e3ff831b59..fa7ea84047 100644 --- a/packages/runtime/src/__tests__/directory-reference-model-context.test.ts +++ b/packages/runtime/src/__tests__/directory-reference-model-context.test.ts @@ -34,3 +34,24 @@ test('replay uses the same reference form and escapes path markup as untrusted d assert.equal(formatted.includes('"entries"'), false); assert.equal(formatted.includes('"status"'), false); }); + +test('replay preserves Session snapshot provenance without treating it as instructions', () => { + const formatted = formatTextWithInlineRefs('continue from this context', { + quotes: [ + { + text: 'Assistant: The runtime boundary is unchanged.', + label: 'Session: Runtime architecture ', + sourceSessionId: 'session-source-1', + sourceSessionName: 'Runtime architecture ', + sourceCapturedAt: 1_735_000_000_000, + sourceTruncated: true, + }, + ], + }); + assert.match(formatted, /'), false); +}); diff --git a/packages/runtime/src/model-history.ts b/packages/runtime/src/model-history.ts index 840e7392f0..68ee3f8090 100644 --- a/packages/runtime/src/model-history.ts +++ b/packages/runtime/src/model-history.ts @@ -1163,8 +1163,23 @@ function formatAttachmentRefs(attachments: readonly AttachmentRef[]): string { function formatQuoteRefs(quotes: readonly QuoteRef[]): string { return quotes .map((q) => { - const label = q.label === undefined ? '' : ` label="${q.label.replace(/"/g, "'")}"`; - return `\n${q.text}\n`; + const attributes = [ + q.label === undefined ? undefined : `label="${quoteAttribute(q.label)}"`, + q.sourceSessionId === undefined + ? undefined + : `source_session="${quoteAttribute(q.sourceSessionId)}"`, + q.sourceCapturedAt === undefined ? undefined : `captured_at="${q.sourceCapturedAt}"`, + q.sourceTruncated === undefined ? undefined : `truncated="${q.sourceTruncated}"`, + ].filter((attribute): attribute is string => attribute !== undefined); + const opening = + attributes.length > 0 ? `` : ''; + return `${opening}\n${q.text}\n`; }) .join('\n'); } + +function quoteAttribute(value: string): string { + return value.replace(/["<&>]/g, (character) => + character === '"' ? "'" : `\\u${character.charCodeAt(0).toString(16).padStart(4, '0')}`, + ); +} diff --git a/packages/ui/src/__tests__/composer-session-reference.test.ts b/packages/ui/src/__tests__/composer-session-reference.test.ts new file mode 100644 index 0000000000..dc53ac0b83 --- /dev/null +++ b/packages/ui/src/__tests__/composer-session-reference.test.ts @@ -0,0 +1,85 @@ +/* + * 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. + */ + +/** + * Contract for the narrow #4309 Composer seam. Session selection is a + * reference action, not an inline text token: the trigger query disappears, + * the host reads one bounded snapshot, and the resulting QuoteRef owns the + * actual context sent with the next turn. + */ + +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; + +function readSource(relativePath: string): string { + return readFileSync(fileURLToPath(new URL(`../../src/${relativePath}`, import.meta.url)), 'utf8'); +} + +function readRepoFile(relativePath: string): string { + return readFileSync(fileURLToPath(new URL(`../../../../${relativePath}`, import.meta.url)), 'utf8'); +} + +test('Composer exposes Session references through the @ trigger without serializing them as text', () => { + const source = readSource('composer.tsx'); + assert.match(source, /sessionReferences\?: ReadonlyArray/); + assert.match(source, /onPickSessionReference\?\(session: ComposerSessionReference\)/); + assert.match(source, /id: `session:\$\{session\.id\}`/); + assert.match(source, /MessagesSquare/); + assert.match( + source, + /onPickSessionReference\?\.\(suggestion\.session\)[\s\S]*?return '';/, + ); +}); + +test('Composer copy tells users that @ can reference files or Sessions', () => { + const source = readSource('conversation-copy.ts'); + assert.match(source, /@ 引用文件或会话/); + assert.match(source, /@ to reference files or sessions/); +}); + +test('Session search stays name-only and @ keeps the menu open after spaces', () => { + const composer = readSource('composer.tsx'); + const dependencyPatch = readRepoFile('patches/@astryxdesign+core+0.5.2.patch'); + + assert.match(composer, /const searchQuery = query\.trim\(\)/); + assert.match(composer, /mentionQueryMatches\(searchQuery, session\.name\)/); + assert.doesNotMatch(composer, /session\.lastMessagePreview \?\?/); + assert.match(dependencyPatch, /if \(trigger\.character !== '@' && \/\[ \\n\]\/u\.test\(query\)\) return null;/); +}); + +test('Session Quote chips use the conversation icon so they are distinct from pasted excerpts', () => { + const source = readSource('quote-ref-chip.tsx'); + assert.match(source, /props\.quote\.sourceSessionId \? MessagesSquare : TextQuote/); +}); + +test('Session-only context stays compact without bypassing the drawer disclosure contract', () => { + const composer = readSource('composer.tsx'); + const styles = readRepoFile('apps/desktop/src/renderer/styles/composer.css'); + const sessionStyles = styles.slice( + styles.indexOf('/* A Session reference follows'), + styles.indexOf('/* Astryx ChatComposerDrawer wraps'), + ); + + assert.match(composer, /count=\{sessionReferenceDrawer \? undefined : drawerTokenCount\}/); + assert.match(composer, /className=\{quote\.sourceSessionId \? 'maka-composer-session-token' : undefined\}/); + assert.doesNotMatch(sessionStyles, /\[role=|> div\[id\]|\.astryx-token/); + assert.match(sessionStyles, /\.maka-composer-session-token[\s\S]*max-width: min\(420px, 100%\)/); +}); diff --git a/packages/ui/src/components.tsx b/packages/ui/src/components.tsx index f528ca84c2..b7554e4f04 100644 --- a/packages/ui/src/components.tsx +++ b/packages/ui/src/components.tsx @@ -74,6 +74,7 @@ export type { ComposerGoalProps, ComposerProps, ComposerHandle, + ComposerSessionReference, ComposerSendMetadata, ComposerSlashCommandOption, } from './composer.js'; diff --git a/packages/ui/src/composer.tsx b/packages/ui/src/composer.tsx index fe19b9ea86..742fb4a486 100644 --- a/packages/ui/src/composer.tsx +++ b/packages/ui/src/composer.tsx @@ -39,6 +39,7 @@ import { ArrowUp, FileText, ListTodo, + MessagesSquare, Network, Pencil, Plus, @@ -138,6 +139,15 @@ export interface ComposerSkillOption { description?: string; } +/** Session metadata offered by the Composer's `@Session` reference picker. */ +export interface ComposerSessionReference { + id: string; + name: string; + status?: string; + lastMessageAt?: number; + lastMessagePreview?: string; +} + export interface ComposerSlashCommandOption { id: string; name: string; @@ -150,6 +160,11 @@ type ComposerSlashSuggestion = | { kind: 'command'; command: ComposerSlashCommandOption; group: string } | { kind: 'skill'; skill: ComposerSkillOption; group: string }; +type ComposerMentionSuggestion = { + kind: 'session'; + session: ComposerSessionReference; +}; + /** * The draft text a chosen Skill becomes. This is the product-wide invocation * grammar (`SKILL_INVOCATION_TOKEN_SOURCE` in `@maka/core`), the same one @@ -338,6 +353,10 @@ export const Composer = forwardRef< * case a large paste behaves like any other paste. */ onPasteAsQuote?(input: { text: string; label?: string }): void; + /** Other Sessions available for a read-only, bounded Composer reference. */ + sessionReferences?: ReadonlyArray; + /** Called when the user selects a Session from the `@` picker. */ + onPickSessionReference?(session: ComposerSessionReference): void | Promise; modelLabel?: string; activeSession?: SessionSummary; activeModelConnectionId?: string; @@ -897,6 +916,8 @@ export const Composer = forwardRef< mentionSkills: props.mentionSkills, slashCommands: props.slashCommands, onSearchMentionFiles: props.onSearchMentionFiles, + sessionReferences: props.sessionReferences, + onPickSessionReference: props.onPickSessionReference, commandsGroup: mentionCopy.commandsGroup, skillsGroup: mentionCopy.skillsGroup, }); @@ -904,22 +925,38 @@ export const Composer = forwardRef< mentionSkills: props.mentionSkills, slashCommands: props.slashCommands, onSearchMentionFiles: props.onSearchMentionFiles, + sessionReferences: props.sessionReferences, + onPickSessionReference: props.onPickSessionReference, commandsGroup: mentionCopy.commandsGroup, skillsGroup: mentionCopy.skillsGroup, }; const searchSourcesRef = useRef<{ files: SearchSource; skills: SearchSource }>(null); if (!searchSourcesRef.current) { - const runFileSearch = (query: string): Promise => { - const search = mentionSourceRef.current.onSearchMentionFiles; - return (search ? search(query) : Promise.resolve([])).then((files) => - files - .filter((file) => mentionQueryMatches(query, file.relativePath)) - .slice(0, 50) - .map((file) => ({ id: file.relativePath, label: file.relativePath })), - ); + const runMentionSearch = (query: string): Promise => { + const source = mentionSourceRef.current; + const searchQuery = query.trim(); + const files = source.onSearchMentionFiles + ? source.onSearchMentionFiles(searchQuery).then((entries) => + entries + .filter((file) => mentionQueryMatches(searchQuery, file.relativePath)) + .slice(0, 25) + .map((file) => ({ id: file.relativePath, label: file.relativePath })), + ) + : Promise.resolve([]); + const sessions = source.onPickSessionReference + ? (source.sessionReferences ?? []) + .filter((session) => mentionQueryMatches(searchQuery, session.name)) + .slice(0, 25) + .map((session) => ({ + id: `session:${session.id}`, + label: session.name, + auxiliaryData: { kind: 'session', session } satisfies ComposerMentionSuggestion, + })) + : []; + return files.then((fileItems) => [...fileItems, ...sessions].slice(0, 50)); }; - const files = createTriggerSearchSource(runFileSearch); + const files = createTriggerSearchSource(runMentionSearch); const listSlashSuggestions = (rawQuery: string): SearchableItem[] => { const source = mentionSourceRef.current; const skills = source.mentionSkills ?? []; @@ -991,35 +1028,57 @@ export const Composer = forwardRef< const triggers = useMemo(() => { const sources = searchSourcesRef.current!; const list: ChatComposerTrigger[] = []; - if (props.onSearchMentionFiles) { + if (props.onSearchMentionFiles || props.onPickSessionReference) { list.push({ character: '@', searchSource: sources.files, - menuLabel: mentionCopy.filesAriaLabel, - emptySearchResultsText: mentionCopy.noFiles, + menuLabel: + props.sessionReferences !== undefined && props.onPickSessionReference !== undefined + ? mentionCopy.filesAndSessionsAriaLabel + : mentionCopy.filesAriaLabel, + emptySearchResultsText: + props.sessionReferences !== undefined && props.onPickSessionReference !== undefined + ? mentionCopy.noFilesOrSessions + : mentionCopy.noFiles, loadingText: mentionCopy.loading, - renderItem: (item) => ( - <> -