From 964b7cb4b0dc691614c90fbd58f477c46df7acfa Mon Sep 17 00:00:00 2001 From: testikun Date: Tue, 1 Sep 2026 15:29:34 +0800 Subject: [PATCH 01/22] 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 --- .../conversation-services-adapter.test.ts | 50 +++ .../__tests__/new-task-staged-content.test.ts | 17 + .../permission-response-ipc-boundary.test.ts | 22 +- .../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 | 4 +- .../controller/use-composer-quotes.ts | 97 +++++ .../use-session-reference-composer.ts | 152 ++++++++ .../conversation/services-context.tsx | 40 ++ .../ui/composer-mentions-provider.tsx | 281 ++++++++++++++ .../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 | 19 +- packages/ui/src/quote-ref-chip.tsx | 5 +- 26 files changed, 1729 insertions(+), 113 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/services-context.tsx create mode 100644 apps/desktop/src/renderer/features/conversation/ui/composer-mentions-provider.tsx 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/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 b5e9755d52..f31a7f9b1b 100644 --- a/apps/desktop/src/main/__tests__/new-task-staged-content.test.ts +++ b/apps/desktop/src/main/__tests__/new-task-staged-content.test.ts @@ -206,6 +206,23 @@ test('a Session keeps its own staged quotes, and the new-task bucket keeps its o ['quoted for the Session'], ); + await act(() => probe.latest().addQuote({ + text: 'bounded session context', + label: 'Session: Research', + sourceSessionId: 'source-session', + sourceSessionName: 'Research', + sourceCapturedAt: 123, + sourceTruncated: true, + })); + assert.deepEqual(probe.latest().pendingQuotes.at(-1), { + text: 'bounded session context', + label: 'Session: Research', + sourceSessionId: 'source-session', + sourceSessionName: 'Research', + sourceCapturedAt: 123, + sourceTruncated: true, + }); + await probe.render(NEW_TASK_PENDING_KEY); assert.deepEqual( probe.latest().pendingQuotes.map((quote) => quote.text), diff --git a/apps/desktop/src/main/__tests__/permission-response-ipc-boundary.test.ts b/apps/desktop/src/main/__tests__/permission-response-ipc-boundary.test.ts index 5b186c081d..3eca0d0263 100644 --- a/apps/desktop/src/main/__tests__/permission-response-ipc-boundary.test.ts +++ b/apps/desktop/src/main/__tests__/permission-response-ipc-boundary.test.ts @@ -190,7 +190,16 @@ describe('permission response IPC boundary', () => { ], turnOrchestration: { mode: 'swarm', source: 'slash_command', ignored: true }, quotes: [ - { text: 'the excerpt', label: ' Assistant ', sourceTurnId: 'turn-9', extra: true }, + { + text: 'the excerpt', + label: ' Assistant ', + sourceTurnId: 'turn-9', + sourceSessionId: 'source-session', + sourceSessionName: 'Research', + sourceCapturedAt: 123, + sourceTruncated: false, + extra: true, + }, ], workspaceFileReferences: [ { @@ -223,7 +232,15 @@ describe('permission response IPC boundary', () => { }, ], turnOrchestration: { mode: 'swarm', source: 'slash_command' }, - quotes: [{ text: 'the excerpt', label: 'Assistant', sourceTurnId: 'turn-9' }], + quotes: [{ + text: 'the excerpt', + label: 'Assistant', + sourceTurnId: 'turn-9', + sourceSessionId: 'source-session', + sourceSessionName: 'Research', + sourceCapturedAt: 123, + sourceTruncated: false, + }], workspaceFileReferences: [ { value: '@packages/ui/src/chat turn.tsx', @@ -253,6 +270,7 @@ describe('permission response IPC boundary', () => { { type: 'send', text: 'hello', quotes: Array(17).fill({ text: 'x' }) }, { type: 'send', text: 'hello', quotes: [{ text: '' }] }, { type: 'send', text: 'hello', quotes: [{ text: 'x', sourceTurnId: 1 }] }, + { type: 'send', text: 'hello', quotes: [{ text: 'x', sourceSessionId: 'source-session' }] }, { type: 'send', text: 'hello', workspaceFileReferences: {} }, { type: 'send', diff --git a/apps/desktop/src/main/__tests__/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 9fc25de20e..cab0d1785d 100644 --- a/apps/desktop/src/main/permission-response-guard.ts +++ b/apps/desktop/src/main/permission-response-guard.ts @@ -45,6 +45,8 @@ const MAX_SESSION_SEND_TEXT_LENGTH = 128_000; const MAX_QUOTE_COUNT = 16; const MAX_QUOTE_TEXT_LENGTH = 32_000; const MAX_QUOTE_LABEL_LENGTH = 200; +const MAX_QUOTE_SOURCE_SESSION_ID_LENGTH = 512; +const MAX_QUOTE_SOURCE_SESSION_NAME_LENGTH = 200; const MAX_INLINE_REFERENCE_COUNT = 32; const MAX_INLINE_REFERENCE_VALUE_LENGTH = 4_096; @@ -321,10 +323,48 @@ function normalizeOptionalQuotes(input: unknown): { quotes?: QuoteRef[] } { 'Invalid send quote sourceTurnId', MAX_TURN_ID_LENGTH, ); + const sourceSessionId = + value.sourceSessionId === undefined + ? undefined + : normalizeRequiredString( + value.sourceSessionId, + 'Invalid send quote sourceSessionId', + MAX_QUOTE_SOURCE_SESSION_ID_LENGTH, + ); + const sourceSessionName = + value.sourceSessionName === undefined + ? undefined + : normalizeRequiredString( + value.sourceSessionName, + 'Invalid send quote sourceSessionName', + MAX_QUOTE_SOURCE_SESSION_NAME_LENGTH, + ); + const sourceCapturedAt = value.sourceCapturedAt; + const sourceTruncated = value.sourceTruncated; + const hasSourceMetadata = + sourceSessionId !== undefined || + sourceSessionName !== undefined || + sourceCapturedAt !== undefined || + sourceTruncated !== undefined; + if ( + hasSourceMetadata && + (sourceSessionId === undefined || + sourceSessionName === undefined || + typeof sourceCapturedAt !== 'number' || + !Number.isFinite(sourceCapturedAt) || + sourceCapturedAt < 0 || + typeof sourceTruncated !== 'boolean') + ) { + throw new Error('Invalid send quote Session provenance'); + } return { text: normalizeRequiredString(value.text, 'Invalid send quote text', MAX_QUOTE_TEXT_LENGTH), ...(label ? { label } : {}), ...(sourceTurnId ? { sourceTurnId } : {}), + ...(sourceSessionId ? { sourceSessionId } : {}), + ...(sourceSessionName ? { sourceSessionName } : {}), + ...(hasSourceMetadata ? { sourceCapturedAt: sourceCapturedAt as number } : {}), + ...(hasSourceMetadata ? { sourceTruncated: sourceTruncated as boolean } : {}), }; }); return quotes.length > 0 ? { quotes } : {}; diff --git a/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts b/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts index fa1f0d0504..b6cc48b0e8 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 { decodeInteractionFormResponse } from '@maka/core/interaction'; import { type SandboxBoundaryResponse } from '@maka/core/sandbox-boundary'; @@ -95,6 +101,7 @@ type RuntimeHostSessionExecutionClient = Pick< | "getSession" | "ingestAttachment" | "interruptTurn" + | "openSession" | 'listSessionTurns' | 'listSessionTurnLandmarks' | 'queryMessageExecutions' @@ -277,6 +284,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', @@ -984,6 +1037,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 68465dac92..bf7073b732 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -68,6 +68,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 { @@ -1165,6 +1166,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 23164f1de7..909f2e5bcf 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -2238,6 +2238,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 cdd11d6866..a4d2ba1bc9 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -416,6 +416,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 @@ -1424,9 +1425,10 @@ function AppShellContent({ // Refresh only; Desktop Main re-reads the authoritative default before // constructing the Runtime Host preview target. newSessionPermissionMode: newTaskPermissionMode, + onAddQuote: addQuote, }; - const hasModalOpen = helpOpen || paletteOpen || searchModalOpen || sharedSessionDialog.isOpen; + const hasModalOpen = helpOpen || paletteOpen || searchModalOpen || sharedSessionDialog.target !== undefined; const shellObscured = hasModalOpen || settingsOpen; const contextCompactionPresentation = useMemo( () => 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/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/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 df82465aa7..c6e4f5eae5 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -29,6 +29,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 889069e08a..844a7d330c 100644 --- a/packages/core/src/events.ts +++ b/packages/core/src/events.ts @@ -127,6 +127,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; } /** @@ -171,7 +179,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'], [], @@ -219,6 +239,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 } + : {}), })), } : {}), @@ -323,12 +359,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')) ); } @@ -490,7 +547,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 9075969977..eda89407c9 100644 --- a/packages/runtime/src/model-history.ts +++ b/packages/runtime/src/model-history.ts @@ -1142,8 +1142,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 4c8d2ffe35..132ff4a5b1 100644 --- a/packages/ui/src/components.tsx +++ b/packages/ui/src/components.tsx @@ -79,6 +79,7 @@ export type { ComposerGoalProps, ComposerProps, ComposerHandle, + ComposerSessionReference, ComposerSendMetadata, ComposerSlashCommandOption, } from './composer.js'; diff --git a/packages/ui/src/composer.tsx b/packages/ui/src/composer.tsx index 2692348122..fe8df7c593 100644 --- a/packages/ui/src/composer.tsx +++ b/packages/ui/src/composer.tsx @@ -38,6 +38,7 @@ import { ICON_SIZE, ArrowUp, CircleGauge, + BookOpen, FileText, ListTodo, Network, @@ -139,6 +140,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; @@ -151,6 +161,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 @@ -341,6 +356,10 @@ export const Composer = forwardRef< * case a large paste behaves like any other paste. */ onPasteAsQuote?(input: { text: string; label?: string }): void; + /** Other Sessions available for a read-only, bounded Composer reference. */ + sessionReferences?: ReadonlyArray; + /** Called when the user selects a Session from the `@` picker. */ + onPickSessionReference?(session: ComposerSessionReference): void | Promise; modelLabel?: string; activeSession?: SessionSummary; activeModelConnectionId?: string; @@ -914,6 +933,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, }); @@ -921,22 +942,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 ?? []; @@ -1008,22 +1049,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) => ( - <> -