From f894584a65de7bec9ee95bed7704dd5ccfd63b6d Mon Sep 17 00:00:00 2001 From: testikun Date: Tue, 1 Sep 2026 15:29:34 +0800 Subject: [PATCH 01/19] feat(desktop): reference bounded Session snapshots from Composer Add a read-only same-Host Session picker to the Composer and carry bounded transcript snapshots as provenance-preserving QuoteRefs. Keep snapshot reads redacted, bounded, reconnectable, and safe across owner changes. Generated-by: OpenAI Codex --- apps/desktop/renderer-architecture.json | 36 +- .../conversation-services-adapter.test.ts | 50 +++ .../__tests__/new-task-staged-content.test.ts | 17 + .../permission-response-ipc-boundary.test.ts | 22 +- ...me-host-session-execution-ipc-main.test.ts | 231 +++++++++++ .../session-reference-composer.test.ts | 365 ++++++++++++++++++ .../src/main/permission-response-guard.ts | 40 ++ ...runtime-host-session-execution-ipc-main.ts | 71 ++++ apps/desktop/src/preload/bridge-contract.d.ts | 3 + apps/desktop/src/preload/preload.ts | 22 ++ apps/desktop/src/renderer/app-shell.tsx | 39 +- .../src/renderer/chat-composer-region.tsx | 14 + .../src/renderer/composer-mentions.tsx | 289 +------------- .../composition/desktop-feature-services.tsx | 5 + .../controller/use-composer-quotes.ts | 97 +++++ .../use-session-reference-composer.ts | 152 ++++++++ .../renderer/features/conversation/index.ts | 39 ++ .../renderer/features/conversation/ports.ts | 84 ++++ .../conversation/services-context.tsx | 40 ++ .../ui/composer-mentions-provider.tsx | 281 ++++++++++++++ .../desktop/create-conversation-services.ts | 50 +++ .../renderer/use-app-shell-composer-quotes.ts | 74 +--- packages/core/package.json | 1 + packages/core/src/__tests__/events.test.ts | 17 + .../src/__tests__/session-reference.test.ts | 99 +++++ packages/core/src/events.ts | 75 +++- packages/core/src/session-reference.ts | 160 ++++++++ .../directory-reference-model-context.test.ts | 21 + packages/runtime/src/model-history.ts | 19 +- .../composer-session-reference.test.ts | 57 +++ packages/ui/src/components.tsx | 1 + packages/ui/src/composer.tsx | 130 +++++-- packages/ui/src/conversation-copy.ts | 17 +- packages/ui/src/quote-ref-chip.tsx | 5 +- 34 files changed, 2182 insertions(+), 441 deletions(-) create mode 100644 apps/desktop/src/main/__tests__/conversation-services-adapter.test.ts create mode 100644 apps/desktop/src/main/__tests__/session-reference-composer.test.ts create mode 100644 apps/desktop/src/renderer/features/conversation/controller/use-composer-quotes.ts create mode 100644 apps/desktop/src/renderer/features/conversation/controller/use-session-reference-composer.ts create mode 100644 apps/desktop/src/renderer/features/conversation/index.ts create mode 100644 apps/desktop/src/renderer/features/conversation/ports.ts create mode 100644 apps/desktop/src/renderer/features/conversation/services-context.tsx create mode 100644 apps/desktop/src/renderer/features/conversation/ui/composer-mentions-provider.tsx create mode 100644 apps/desktop/src/renderer/platform/desktop/create-conversation-services.ts create mode 100644 packages/core/src/__tests__/session-reference.test.ts create mode 100644 packages/core/src/session-reference.ts create mode 100644 packages/ui/src/__tests__/composer-session-reference.test.ts diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index ea302284f7..3f674f3e80 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -1061,25 +1061,21 @@ "react": 1 }, "importSpecifiers": 187, - "nonTriviaTokens": 15882 + "nonTriviaTokens": 15881 }, "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, @@ -1466,28 +1462,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 0eba968b64..3780fb5d56 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 @@ -169,6 +169,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 e5c6a2585f..93d4639a54 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 @@ -149,7 +149,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: [ { @@ -182,7 +191,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', @@ -212,6 +229,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 65c8e81cd1..bcd934c7a5 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(); @@ -1582,6 +1812,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/permission-response-guard.ts b/apps/desktop/src/main/permission-response-guard.ts index b3f9ec0861..1d0d7d6d69 100644 --- a/apps/desktop/src/main/permission-response-guard.ts +++ b/apps/desktop/src/main/permission-response-guard.ts @@ -44,6 +44,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; @@ -292,10 +294,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 2d7f849e3b..496cc55dd1 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"; @@ -93,6 +99,7 @@ type RuntimeHostSessionExecutionClient = Pick< | "getSession" | "ingestAttachment" | "interruptTurn" + | "openSession" | 'listSessionTurns' | 'listSessionTurnLandmarks' | 'queryMessageExecutions' @@ -268,6 +275,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', @@ -919,6 +972,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 05138dcb67..90875aee04 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -65,6 +65,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 { @@ -1159,6 +1160,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 c6eaf64a81..1a68d94fc8 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -2044,6 +2044,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 c96cccdc8f..3719e0d595 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -444,6 +444,7 @@ function AppShellContent({ 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 @@ -1640,27 +1641,6 @@ function AppShellContent({ ], ); - // Composer mention popups: `/` uses Runtime's session/project-aware, - // host-compatible projection; `@` uses workspace file search. Keep the - // resolved project path as a refresh key for new-chat project changes. Only - // the SURFACE is named here — the projection itself is owned by - // `ComposerMentionsProvider` below, so its reloads do not re-render the shell. - const composerMentionsSurface: ComposerMentionsSurface = { - skillCatalogRevision: moduleHub.selectors.skillCatalogRevision, - sessionId: ownerActiveId, - projectPath: activeId - ? ownerActiveId - ? projectInfo?.projectPath - : undefined - : taskEntry.selectors.projectPath, - newTaskTarget: activeId ? undefined : taskEntry.selectors.target, - newSessionModel: newChatModel, - newSessionCollaborationMode: newChatPlanModeActive ? 'plan' : 'agent', - // Refresh only; Desktop Main re-reads the authoritative default before - // constructing the Runtime Host preview target. - newSessionPermissionMode: newTaskPermissionMode, - }; - const hasModalOpen = helpOpen || paletteOpen || searchModalOpen || sharedSessionDialog.target !== undefined; const shellObscured = hasModalOpen || settingsOpen; const contextCompactionPresentation = useMemo( @@ -2631,6 +2611,23 @@ function AppShellContent({ const canStageComposerContext = activeId !== undefined || taskEntry.selectors.target !== undefined; + // Composer mention state is owned by the Conversation feature. The shell + // only supplies the active surface and the existing quote staging seam. + const composerMentionsSurface: ComposerMentionsSurface = { + skillCatalogRevision: moduleHub.selectors.skillCatalogRevision, + sessionId: ownerActiveId, + projectPath: activeId + ? ownerActiveId + ? projectInfo?.projectPath + : undefined + : taskEntry.selectors.projectPath, + newTaskTarget: activeId ? undefined : taskEntry.selectors.target, + newSessionModel: newChatModel, + newSessionCollaborationMode: newChatPlanModeActive ? 'plan' : 'agent', + newSessionPermissionMode: newTaskPermissionMode, + onAddQuote: addQuote, + }; + const activeMessageLoadError = activeId ? messageLoadErrorBySession[activeId] : undefined; let activeTranscriptRange; try { diff --git a/apps/desktop/src/renderer/chat-composer-region.tsx b/apps/desktop/src/renderer/chat-composer-region.tsx index 0ac9850d59..f91b04be77 100644 --- a/apps/desktop/src/renderer/chat-composer-region.tsx +++ b/apps/desktop/src/renderer/chat-composer-region.tsx @@ -210,6 +210,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. @@ -236,6 +248,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..53365134b8 100644 --- a/apps/desktop/src/renderer/composer-mentions.tsx +++ b/apps/desktop/src/renderer/composer-mentions.tsx @@ -17,285 +17,10 @@ * 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 ComposerMentions, + 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 47c188d6e6..c8c9c1d51d 100644 --- a/apps/desktop/src/renderer/composition/desktop-feature-services.tsx +++ b/apps/desktop/src/renderer/composition/desktop-feature-services.tsx @@ -25,6 +25,7 @@ import { SessionCollaborationServicesProvider } from '../features/session-collab import { SessionNavigationServicesProvider } from '../features/session-navigation'; import { TaskEntryServicesProvider } from '../features/task-entry'; import { WorkbarServicesProvider } from '../features/workbar'; +import { ConversationServicesProvider } from '../features/conversation'; import { createDesktopGoalServices } from '../platform/desktop/create-goal-services'; import { createDesktopModuleHubServices } from '../platform/desktop/create-module-hub-services'; import { createDesktopRuntimeHostManagementServices } from '../platform/desktop/create-runtime-host-management-services'; @@ -32,10 +33,12 @@ import { createDesktopSessionCollaborationServices } from '../platform/desktop/c import { createDesktopSessionNavigationServices } from '../platform/desktop/create-session-navigation-services'; import { createDesktopTaskEntryServices } from '../platform/desktop/create-task-entry-services'; import { createDesktopWorkbarServices } from '../platform/desktop/create-workbar-services'; +import { createDesktopConversationServices } from '../platform/desktop/create-conversation-services'; export function createDesktopFeatureServices() { return { goal: createDesktopGoalServices(), + conversation: createDesktopConversationServices(), moduleHub: createDesktopModuleHubServices(), runtimeHostManagement: createDesktopRuntimeHostManagementServices(), sessionCollaboration: createDesktopSessionCollaborationServices(), @@ -51,6 +54,7 @@ export function DesktopFeatureServicesProvider(props: { }) { return ( + @@ -64,6 +68,7 @@ export function DesktopFeatureServicesProvider(props: { + ); } 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..033a56eb70 --- /dev/null +++ b/apps/desktop/src/renderer/features/conversation/controller/use-composer-quotes.ts @@ -0,0 +1,97 @@ +/* + * 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] = []); + 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 new file mode 100644 index 0000000000..17ce532401 --- /dev/null +++ b/apps/desktop/src/renderer/features/conversation/index.ts @@ -0,0 +1,39 @@ +/* + * 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. + */ + +export { ConversationServicesProvider } from './services-context.js'; +export type { + ConversationServices, + ConversationSession, + ConversationTaskTarget, +} from './ports.js'; +export { + useSessionReferenceComposer, + type SessionReferenceSession, +} from './controller/use-session-reference-composer.js'; +export { + ComposerMentionsProvider, + useComposerMentionsContext, + type ComposerMentions, + type ComposerMentionsSurface, +} from './ui/composer-mentions-provider.js'; +export { + useComposerQuotes, + useComposerQuotes as useAppShellComposerQuotes, +} from './controller/use-composer-quotes.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..fd2beebc0c --- /dev/null +++ b/apps/desktop/src/renderer/features/conversation/ui/composer-mentions-provider.tsx @@ -0,0 +1,281 @@ +/* + * 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 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(next); + }).catch(() => { + if (!cancelled) setSessions([]); + }); + }; + 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/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/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/packages/core/package.json b/packages/core/package.json index bb0fcbd321..28e851af52 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..baa8a0ebb4 --- /dev/null +++ b/packages/core/src/__tests__/session-reference.test.ts @@ -0,0 +1,99 @@ +/* + * 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('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); +}); diff --git a/packages/core/src/events.ts b/packages/core/src/events.ts index e6f4534ee1..d1babdf6e6 100644 --- a/packages/core/src/events.ts +++ b/packages/core/src/events.ts @@ -121,6 +121,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; } /** @@ -165,7 +173,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'], [], @@ -213,6 +233,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 } + : {}), })), } : {}), @@ -317,12 +353,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')) ); } @@ -484,7 +541,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..77c41f43a1 --- /dev/null +++ b/packages/core/src/session-reference.ts @@ -0,0 +1,160 @@ +/* + * 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 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 = 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) { + selected.push({ + ...candidate, + text: line.slice(0, available).trimEnd(), + }); + usedChars = maxChars; + } + truncated = true; + break; + } + if (selected.length < candidates.length) truncated = true; + selected.reverse(); + + const text = selected.map(formatSnapshotItem).join('\n\n').slice(0, 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}`; +} + +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 94c76425b7..f4ced28160 100644 --- a/packages/runtime/src/model-history.ts +++ b/packages/runtime/src/model-history.ts @@ -1132,8 +1132,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..5e0e674776 --- /dev/null +++ b/packages/ui/src/__tests__/composer-session-reference.test.ts @@ -0,0 +1,57 @@ +/* + * 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'); +} + +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, /BookOpen/); + 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 Quote chips use the book icon so they are distinct from pasted excerpts', () => { + const source = readSource('quote-ref-chip.tsx'); + assert.match(source, /props\.quote\.sourceSessionId \? BookOpen : TextQuote/); +}); diff --git a/packages/ui/src/components.tsx b/packages/ui/src/components.tsx index bfde2290b7..3101d6361c 100644 --- a/packages/ui/src/components.tsx +++ b/packages/ui/src/components.tsx @@ -68,6 +68,7 @@ export { Composer } from './composer.js'; export type { ComposerProps, ComposerHandle, + ComposerSessionReference, ComposerSendMetadata, ComposerSlashCommandOption, } from './composer.js'; diff --git a/packages/ui/src/composer.tsx b/packages/ui/src/composer.tsx index 06d1988cb4..d89cf3a1ce 100644 --- a/packages/ui/src/composer.tsx +++ b/packages/ui/src/composer.tsx @@ -37,6 +37,7 @@ import { useMountedRef } from './use-mounted-ref.js'; import { ICON_SIZE, ArrowUp, + BookOpen, FileText, ListTodo, Network, @@ -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 @@ -323,6 +338,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; activeModel?: string; @@ -836,6 +855,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, }); @@ -843,22 +864,42 @@ 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 files = source.onSearchMentionFiles + ? source.onSearchMentionFiles(query).then((entries) => + entries + .filter((file) => mentionQueryMatches(query, file.relativePath)) + .slice(0, 25) + .map((file) => ({ id: file.relativePath, label: file.relativePath })), + ) + : Promise.resolve([]); + const sessions = source.onPickSessionReference + ? (source.sessionReferences ?? []) + .filter((session) => + mentionQueryMatches( + query, + `${session.name} ${session.lastMessagePreview ?? ''} ${session.status ?? ''}`, + ), + ) + .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 ?? []; @@ -930,22 +971,45 @@ 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) => ( - <> -