diff --git a/apps/desktop/e2e-budget.json b/apps/desktop/e2e-budget.json index 8bd366b228..400ce8cdc5 100644 --- a/apps/desktop/e2e-budget.json +++ b/apps/desktop/e2e-budget.json @@ -9,6 +9,10 @@ "tests": 1, "electron": "the folder reference has to survive a renderer reload and still agree with the Host's session record" }, + "composer-session-reference.spec.ts": { + "tests": 1, + "electron": "the referenced transcript snapshot crosses renderer, preload, main process, and Runtime Host boundaries before the staged quote can be sent" + }, "context-window-save.spec.ts": { "tests": 1, "electron": "the saved window is read back from the Host's connection snapshot, not from renderer state" diff --git a/apps/desktop/e2e/composer-session-reference.spec.ts b/apps/desktop/e2e/composer-session-reference.spec.ts new file mode 100644 index 0000000000..ed94bb198d --- /dev/null +++ b/apps/desktop/e2e/composer-session-reference.spec.ts @@ -0,0 +1,80 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { ensureSidebarExpanded, expect, test, COMPOSER_INPUT } from './fixtures'; + +test('references another Session from @, including a trailing-space browse', async ({ + window: page, +}, testInfo) => { + const composer = page.locator(COMPOSER_INPUT); + const sourceName = 'Reference source'; + const sourcePrompt = 'source transcript marker'; + + await composer.fill(sourcePrompt); + await composer.press('Enter'); + await expect(page.getByText(`Fake backend received: ${sourcePrompt}`)).toBeVisible(); + await expect(page.getByRole('button', { name: '重新生成' })).toHaveCount(1, { + timeout: 20_000, + }); + + await page.evaluate(async (name) => { + const source = (await window.maka.sessions.list())[0]; + if (!source) throw new Error('the source Session was not created'); + await window.maka.sessions.rename(source.id, name); + }, sourceName); + + await ensureSidebarExpanded(page); + const sidebar = page.getByRole('navigation', { name: '任务列表' }); + await sidebar.getByRole('button', { name: '新任务', exact: true }).click(); + await expect(composer).toHaveText(''); + + await composer.click(); + await composer.pressSequentially('@'); + const menu = page.getByRole('listbox', { name: '工作区文件和会话' }); + await expect(menu).toBeVisible(); + + const sourceOption = menu.getByRole('option', { name: sourceName, exact: true }); + await expect(sourceOption).toBeVisible(); + await expect(sourceOption.locator('svg.lucide-messages-square')).toHaveCount(1); + + await composer.press('Space'); + await expect(menu).toBeVisible(); + await expect(sourceOption).toBeVisible(); + + await composer.fill('@reference'); + await expect(sourceOption).toBeVisible(); + await sourceOption.click(); + await expect(menu).not.toBeVisible(); + + const chip = page.locator('.maka-composer-session-token'); + await expect(chip).toContainText(sourceName); + await expect(chip.locator('svg.lucide-messages-square')).toHaveCount(1); + await page.screenshot({ path: testInfo.outputPath('session-reference-staged.png') }); + + const followUp = 'continue from the referenced session'; + await composer.fill(followUp); + await composer.press('Enter'); + const sent = page.getByLabel('你发送的消息').last(); + await expect(sent).toContainText(followUp); + await expect(sent).toContainText(sourceName); + await expect(chip).toHaveCount(0); + await expect(page.getByRole('button', { name: '重新生成' })).toHaveCount(1, { + timeout: 20_000, + }); +}); diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index d24cb05c29..5a89367acd 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -872,24 +872,21 @@ "react": 1 }, "importSpecifiers": 108, - "nonTriviaTokens": 13697 + "nonTriviaTokens": 13699 }, "src/renderer/use-app-shell-composer-quotes.ts": { - "importDeclarations": 2, + "importDeclarations": 0, "bridgePaths": {}, "environmentCapabilities": {}, - "hookCalls": { - "useState": 1 - }, + "hookCalls": {}, "lifecycleMethods": {}, "unresolvedDependencies": 0, "actionFactories": [], "dependencyPaths": { - "./pending-items.js": 1, - "react": 1 + "./features/conversation/index.js": 1 }, - "importSpecifiers": 5, - "nonTriviaTokens": 360 + "importSpecifiers": 0, + "nonTriviaTokens": 24 }, "src/renderer/use-app-shell-session-list.ts": { "importDeclarations": 8, @@ -1220,25 +1217,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": { - "react": 1 + "./features/conversation/index.js": 2 } }, "src/renderer/conversation-markdown.ts": { diff --git a/apps/desktop/src/main/__tests__/composer-mentions.test.ts b/apps/desktop/src/main/__tests__/composer-mentions.test.ts index 67cedc7608..4c076f2ecf 100644 --- a/apps/desktop/src/main/__tests__/composer-mentions.test.ts +++ b/apps/desktop/src/main/__tests__/composer-mentions.test.ts @@ -23,11 +23,16 @@ import { parseHTML } from 'linkedom'; import { act, createElement, useLayoutEffect } from 'react'; import { createRoot } from 'react-dom/client'; import type { InvocableSkillEntry } from '@maka/runtime/skill-invocation'; +import { LocaleProvider } from '@maka/ui'; import { ComposerMentionsProvider, useComposerMentionsContext, type ComposerMentions, } from '../../renderer/composer-mentions.js'; +import { + ConversationServicesProvider, + type ConversationServices, +} from '../../renderer/features/conversation/index.js'; interface CatalogObservation { sessionId: string; @@ -71,14 +76,30 @@ function installCatalogRenderer(t: TestContext) { sessionId: string; resolve(skills: InvocableSkillEntry[]): void; }> = []; - (window as unknown as { maka: unknown }).maka = { + const services: ConversationServices = { + listMessages: async () => [], + cancelMessage: async () => undefined, + reconcileMessage: async () => undefined, + subscribeChanges: () => () => undefined, skills: { listInvocable: (sessionId: string) => new Promise((resolve) => { pending.push({ sessionId, resolve }); }), }, - sessions: { subscribeChanges: () => () => {} }, - mcp: { subscribeChanges: () => () => {} }, + sessions: { + list: () => new Promise(() => undefined), + readSnapshot: async () => { + throw new Error('Session snapshot is not used in catalog tests'); + }, + subscribeChanges: () => () => undefined, + }, + workspace: { searchFiles: async () => ({ ok: false, reason: 'no_project' }) }, + newTasks: { + subscribeChanges: () => () => undefined, + listInvocableSkills: async () => [], + searchFiles: async () => ({ ok: false, reason: 'no_project' }), + }, + mcp: { subscribeChanges: () => () => undefined }, }; const observations: CatalogObservation[] = []; @@ -120,11 +141,17 @@ function installCatalogRenderer(t: TestContext) { return pending.length; }, async render(sessionId: string, skillCatalogRevision = 0, projectPath?: string) { - await act(() => root.render(createElement(ComposerMentionsProvider, { - sessionId, - projectPath, - skillCatalogRevision, - children: createElement(Consumer, { sessionId }), + await act(() => root.render(createElement(LocaleProvider, { + locale: 'en', + children: createElement(ConversationServicesProvider, { + services, + children: createElement(ComposerMentionsProvider, { + sessionId, + projectPath, + skillCatalogRevision, + children: createElement(Consumer, { sessionId }), + }), + }), }))); }, async settleNext(sessionId: string, skills: InvocableSkillEntry[]) { 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..c9aa67fe15 --- /dev/null +++ b/apps/desktop/src/main/__tests__/conversation-services-adapter.test.ts @@ -0,0 +1,56 @@ +/* + * 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 = { + sessionLocal: { + listMessages: async () => [], + cancelMessage: async () => undefined, + reconcileMessage: async () => undefined, + subscribeChanges: () => () => undefined, + }, + 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..d28abfc379 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', @@ -233,6 +250,16 @@ describe('permission response IPC boundary', () => { }, ); assert.equal(normalizeSessionSendCommand({ type: 'stop' }), undefined); + for (const sourceCapturedAt of [Number.MAX_VALUE, 8.64e15 + 1]) { + assert.throws(() => normalizeSessionSendCommand({ type: 'send', text: 'review', quotes: [{ + text: 'excerpt', sourceSessionId: 'source', sourceSessionName: 'Research', + sourceCapturedAt, sourceTruncated: false, + }] })); + } + assert.doesNotThrow(() => normalizeSessionSendCommand({ type: 'send', text: 'review', quotes: [{ + text: 'excerpt', sourceSessionId: 'source', sourceSessionName: 'Research', + sourceCapturedAt: 8.64e15, sourceTruncated: false, + }] })); assert.deepEqual(normalizeSessionSendCommand({ type: 'send', text: '', skillIds: ['writer'] }), { type: 'send', text: '', @@ -253,6 +280,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 e736c0787a..dcb7a6f891 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 @@ -1771,6 +1771,7 @@ function executionClient(overrides: Partial): ExecutionClient { queryMessages: unavailable, queryTurnResume: unavailable, readExecutionBoundary: unavailable, + openSession: unavailable, regenerateTurn: unavailable, retractQueueEntry: unavailable, promoteQueueEntry: 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..d8e25c98a8 --- /dev/null +++ b/apps/desktop/src/main/__tests__/session-reference-composer.test.ts @@ -0,0 +1,423 @@ +/* + * 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; + +const sessionLocalServices: Pick< + ConversationServices, + 'listMessages' | 'cancelMessage' | 'reconcileMessage' | 'subscribeChanges' +> = { + listMessages: async () => [], + cancelMessage: async () => undefined, + reconcileMessage: async () => undefined, + subscribeChanges: () => () => 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 = { + ...sessionLocalServices, + 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, + pendingQuotes: latestQuotes.pendingQuotes, + 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, false); + assert.deepEqual(latest?.pendingReferences.map((item) => item.id), ['source']); + let waiting!: Promise; + await act(() => { + 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, + }]); + + for (const mutation of ['remove', 'add'] as const) { + await act(async () => { + latestQuotes!.clearQuotes(); + await latest!.pick({ id: 'source' }); + }); + let resolveRead!: (snapshot: SessionSnapshot) => void; + services.sessions.readSnapshot = async () => new Promise((resolve) => { resolveRead = resolve; }); + await act(() => { waiting = latest!.waitForPending(); }); + await act(async () => { + if (mutation === 'remove') latest!.removePendingReference('source'); + else { + sessions.push(session('second-source', 'host-a')); + await latest!.pick({ id: 'second-source' }); + } + resolveRead({ reference: { sessionId: 'source', sessionName: 'source', capturedAt: 1 }, + items: [], text: 'stale excerpt', estimatedTokens: 3, maxChars: 12_000, truncated: false }); + assert.equal(await waiting, false); + }); + assert.equal(latestQuotes!.pendingQuotes.length, 0); + assert.deepEqual(latest!.pendingReferences.map((item) => item.id), mutation === 'remove' ? [] : ['source', 'second-source']); + await act(() => { + latest!.removePendingReference('source'); + latest!.removePendingReference('second-source'); + }); + } + + await act(() => { + for (let index = 0; index < 16; index++) latestQuotes!.addQuote({ text: `quote ${index}` }); + }); + await act(async () => { await latest!.pick({ id: 'source' }); }); + assert.equal(latest!.pendingReferences.length, 0); + assert.match(latest!.error!.detail, /16/); +}); + +test('send resolves the selected Session snapshot at the send boundary', 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, + }; + let reads = 0; + const services: ConversationServices = { + ...sessionLocalServices, + sessions: { + list: async () => [source], + subscribeChanges: () => () => undefined, + readSnapshot: async () => new Promise((resolve) => { + reads += 1; + 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 pick; + assert.equal(reads, 0); + await latest!.waitForPending(); + }); + + 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 = { + ...sessionLocalServices, + 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; + let waiting!: Promise; + await act(async () => { + pick = latest!.pick({ id: 'source' }); + await pick; + waiting = latest!.waitForPending(); + 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 waiting; + await pick; + }); + assert.deepEqual(latestQuotes?.pendingQuotes, []); +}); diff --git a/apps/desktop/src/main/__tests__/session-settings-controller.test.ts b/apps/desktop/src/main/__tests__/session-settings-controller.test.ts index bb9f477fe7..e773d60385 100644 --- a/apps/desktop/src/main/__tests__/session-settings-controller.test.ts +++ b/apps/desktop/src/main/__tests__/session-settings-controller.test.ts @@ -475,7 +475,7 @@ function Harness(props: { catalogRevision: 0, isActiveSession: () => true, sessions: props.sessions, - newTaskPermissionMode: 'ask', + newSessionPermissionMode: 'ask', refreshCatalog: async () => {}, saveComposerDefaults: props.saveComposerDefaults, writeFailureCopy: () => ({ title: 'failed', description: 'failed' }), @@ -499,7 +499,7 @@ function CausalRetirementHarness(props: { catalogRevision: props.catalogRevision, isActiveSession: () => true, sessions: props.sessions, - newTaskPermissionMode: 'ask', + newSessionPermissionMode: 'ask', refreshCatalog: async () => {}, saveComposerDefaults: () => {}, writeFailureCopy: () => ({ title: 'failed', description: 'failed' }), diff --git a/apps/desktop/src/main/permission-response-guard.ts b/apps/desktop/src/main/permission-response-guard.ts index 9fc25de20e..14e84716f2 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,49 @@ 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 || + sourceCapturedAt > 8.64e15 || + 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 e389b888b4..4a91c7c81d 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -69,6 +69,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 { @@ -1158,6 +1159,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 f3c89333c6..a4f9a7a110 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -2207,6 +2207,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 81cdfec668..6da4a43120 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -187,7 +187,10 @@ import { import * as liveContent from './live-content-seed'; import { loadComposerDefaults, saveComposerDefaults } from './composer-defaults'; import { useTurnActionRegistry } from './use-turn-action-registry'; -import { useComposerAttachments, desktopSlashCommandPresentation } from './features/conversation/index.js'; +import { + desktopSlashCommandPresentation, + useComposerAttachments, +} from './features/conversation/index.js'; import { useAppShellComposerQuotes } from './use-app-shell-composer-quotes'; import { type ComposerMentionsSurfaceInput, @@ -405,18 +408,19 @@ function AppShellContent({ }); const { pendingQuotes, - addQuote, + addQuote: onAddQuote, removeQuote, clearQuotes, restoreQuotes, } = useAppShellComposerQuotes({ draftKey: attachmentDraftKey }); + // Held for the whole of sendOwningItsTarget; see ChatComposerRegion. const [newTaskSendPending, setNewTaskSendPending] = useState(false); // What a new chat will start with, held the way the Session holds it: a // Plan toggle and one orchestration value, not one fused choice. const [newChatPlanModeActive, setNewChatPlanModeActive] = useState(false); const [newChatOrchestrationMode, setNewChatOrchestrationMode] = useState('default'); - const [newTaskPermissionChoice, setNewTaskPermissionChoice, clearNewTaskPermissionChoice] = + const [newTaskPermissionChoice, setNewTaskPermissionMode, clearNewTaskPermissionChoice] = useNewTaskChoice(currentNewTaskDraftKey); const [historyLoadPending, setHistoryLoadPending] = useState(); const transcriptReadingCommands = useRef(null); @@ -573,11 +577,10 @@ function AppShellContent({ * not a statement about every later task, so it is sent once on create and * never written back to `chatDefaults` — the Settings surface owns that. */ - const newTaskPermissionMode = + const newSessionPermissionMode = newTaskPermissionChoice ?? taskEntry.selectors.selectedHost?.chatDefaults.permissionMode ?? 'ask'; - const setNewTaskPermissionMode = setNewTaskPermissionChoice; useEffect(() => { if (!appearanceHydrated) return; let cancelled = false; @@ -660,7 +663,7 @@ function AppShellContent({ catalogRevision, isActiveSession: (sessionId) => activeIdRef.current === sessionId, sessions, - newTaskPermissionMode, + newSessionPermissionMode, refreshCatalog: refreshSessions, saveComposerDefaults: (model) => saveComposerDefaults({ model }), writeFailureCopy: (setting, error) => sessionSettingFailureCopy(uiLocale, setting, error), @@ -987,7 +990,7 @@ function AppShellContent({ ? pendingSessionView({ sessionId: activeId, name: shellCopy.newConversation, - permissionMode: newTaskPermissionMode, + permissionMode: newSessionPermissionMode, }) : undefined); // Each control reads its own field. There is nothing to project and nothing @@ -1060,7 +1063,7 @@ function AppShellContent({ const activeBoundarySurface = deriveDesktopExecutionBoundarySurface( activeId, activeExecutionBoundary, - activeId ? (activeSessionForView?.permissionMode ?? 'ask') : newTaskPermissionMode, + activeId ? (activeSessionForView?.permissionMode ?? 'ask') : newSessionPermissionMode, ); const activePermissionMode = activeId ? sessionSettingIntent.overlays.permissionMode[activeId] @@ -1343,10 +1346,12 @@ function AppShellContent({ newSessionCollaborationMode: newChatPlanModeActive ? 'plan' : 'agent', // Refresh only; Desktop Main re-reads the authoritative default before // constructing the Runtime Host preview target. - newSessionPermissionMode: newTaskPermissionMode, + newSessionPermissionMode, + onAddQuote, + pendingQuotes, }; - const hasModalOpen = helpOpen || paletteOpen || searchModalOpen || sharedSessionDialog.isOpen; + const hasModalOpen = helpOpen || paletteOpen || searchModalOpen || sharedSessionDialog.target !== undefined; const shellObscured = hasModalOpen || settingsOpen; const contextCompactionPresentation = useMemo( () => @@ -2596,7 +2601,7 @@ function AppShellContent({ onRemoveAttachment={removeAttachment} pendingQuotes={pendingQuotes} onRemoveQuote={removeQuote} - onPasteAsQuote={canStageComposerContext ? addQuote : undefined} + onPasteAsQuote={canStageComposerContext ? onAddQuote : undefined} onPickAttachments={ !canStageComposerContext || (revisionDraft && activeId === revisionDraft.draftSessionId) @@ -2773,7 +2778,7 @@ function AppShellContent({ sharedSessionActive ? undefined : (selection) => { - addQuote(selection); + onAddQuote(selection); composerRef.current?.focus(); } } diff --git a/apps/desktop/src/renderer/chat-composer-region.tsx b/apps/desktop/src/renderer/chat-composer-region.tsx index 18dfaca848..9bf3010ef2 100644 --- a/apps/desktop/src/renderer/chat-composer-region.tsx +++ b/apps/desktop/src/renderer/chat-composer-region.tsx @@ -93,6 +93,11 @@ interface ChatComposerRegionProps | 'mentionSkillsUnavailable' | 'mentionSkillsLoading' | 'onSearchMentionFiles' + | 'sessionReferences' + | 'onPickSessionReference' + | 'pendingSessionReferences' + | 'onRemovePendingSessionReference' + | 'waitForSessionReference' | 'pendingDirectories' | 'onRemoveDirectory' | 'onPickDirectory' @@ -331,6 +336,11 @@ export function ChatComposerRegion({ mentionSkillsUnavailable={mentions?.mentionSkillsUnavailable} mentionSkillsLoading={mentions?.mentionSkillsLoading} onSearchMentionFiles={mentions?.searchMentionFiles} + sessionReferences={mentions?.sessionReferences} + onPickSessionReference={mentions?.onPickSessionReference} + pendingSessionReferences={mentions?.pendingSessionReferences} + onRemovePendingSessionReference={mentions?.onRemovePendingSessionReference} + waitForSessionReference={mentions?.waitForSessionReference} {...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 a9803bed2e..849cf7a423 100644 --- a/apps/desktop/src/renderer/composer-mentions.tsx +++ b/apps/desktop/src/renderer/composer-mentions.tsx @@ -17,308 +17,24 @@ * 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'; +import type { ReactNode } from 'react'; +import { + ComposerMentionsProvider, + type ComposerMentionsSurface, +} from './features/conversation/index.js'; + +export { + ComposerMentionsProvider, + useComposerMentionsContext, + type ComposerMentions, + type ComposerMentionsSurface, +} from './features/conversation/index.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 { - /** Handed over by the Module Hub boundary to invalidate Runtime's projection. */ - skillCatalogRevision: number; - sessionId?: string; - projectPath?: string; - newSessionModel?: { llmConnectionSlug: string; model: string }; - newSessionCollaborationMode?: 'agent' | 'plan'; - newSessionPermissionMode?: ChatDefaultPermissionMode; - newTaskTarget?: DesktopNewTaskTarget; -} - -/** The surface AppShell assembles; the Module Hub boundary supplies the revision. */ export type ComposerMentionsSurfaceInput = Omit< ComposerMentionsSurface, 'skillCatalogRevision' >; -/** - * 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; - // Once a session exists, Runtime resolves its Skill projection from the - // session identity alone. AppShell can learn the session's project path (or - // update the defaults for a future task) on a later render; those new-task - // inputs must not turn the current session into a different catalog surface. - const newTaskProjectPath = sessionId ? undefined : projectPath; - const newTaskModel = sessionId ? undefined : newSessionModel; - const newTaskCollaborationMode = sessionId ? undefined : newSessionCollaborationMode; - const newTaskPermissionMode = sessionId ? undefined : newSessionPermissionMode; - const activeNewTaskTarget = sessionId ? undefined : newTaskTarget; - // 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 - ? ['session', sessionId].join('\u0000') - : [ - 'new-task', - newTaskProjectPath ?? '', - newTaskModel?.llmConnectionSlug ?? '', - newTaskModel?.model ?? '', - newTaskCollaborationMode ?? 'agent', - newTaskPermissionMode ?? '', - activeNewTaskTarget?.profileId ?? '', - activeNewTaskTarget?.hostId ?? '', - activeNewTaskTarget?.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 = { - ...(newTaskModel ?? {}), - collaborationMode: newTaskCollaborationMode ?? 'agent', - ...(newTaskPermissionMode - ? { permissionMode: newTaskPermissionMode } - : {}), - } as const; - const request = sessionId - ? window.maka.skills.listInvocable(sessionId) - : activeNewTaskTarget - ? window.maka.newTasks.listInvocableSkills(activeNewTaskTarget, 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(); - }; - }, [ - newTaskProjectPath, - sessionId, - skillCatalogRevision, - newTaskModel?.llmConnectionSlug, - newTaskModel?.model, - newTaskCollaborationMode, - newTaskPermissionMode, - activeNewTaskTarget?.profileId, - activeNewTaskTarget?.hostId, - activeNewTaskTarget?.projectId, - ]); - - const searchMentionFiles = useCallback( - async (query: string): Promise> => { - try { - const result = sessionId - ? await window.maka.workspace.searchFiles(query, { sessionId }) - : activeNewTaskTarget - ? await window.maka.newTasks.searchFiles(activeNewTaskTarget, 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, - activeNewTaskTarget?.profileId, - activeNewTaskTarget?.hostId, - activeNewTaskTarget?.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}; -} - -/** - * How the provider mounts under the Skill catalog revision the Module Hub - * boundary hands over: the shell passes the surface it assembled, the boundary - * supplies the revision and the frame it already built, and the provider's - * `skillCatalogRevision` stays a required, compiler-checked prop. - */ export function renderComposerMentionsProvider( surface: ComposerMentionsSurfaceInput, ): (skillCatalogRevision: number, children: ReactNode) => ReactNode { @@ -328,7 +44,3 @@ export function renderComposerMentionsProvider( ); } - -export function useComposerMentionsContext(): ComposerMentions | undefined { - return useContext(ComposerMentionsContext); -} diff --git a/apps/desktop/src/renderer/features/conversation/controller/use-composer-quotes.ts b/apps/desktop/src/renderer/features/conversation/controller/use-composer-quotes.ts new file mode 100644 index 0000000000..c6bc2c93a5 --- /dev/null +++ b/apps/desktop/src/renderer/features/conversation/controller/use-composer-quotes.ts @@ -0,0 +1,101 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { useCallback, useRef, useState } from 'react'; +import type { QuoteRef } from '@maka/core/events'; + +const MAX_QUOTE_CHARS = 32_000; + +type PendingQuotes = Record; + +export function useComposerQuotes(options: { readonly draftKey: string }) { + const [pendingByKey, setPendingByKey] = useState({}); + // React state triggers rendering, while each bucket is kept mutable so a + // send callback from the current render observes a quote selected in the + // same tick as the snapshot read. This avoids making AppShell reach into a + // second quote getter solely to bridge React's commit timing. + const pendingByKeyRef = useRef({}); + const bucket = pendingByKeyRef.current[options.draftKey] ?? + (pendingByKeyRef.current[options.draftKey] = []); + // This is intentionally a live bucket so a same-tick send can observe a + // snapshot selected before React commits the state update. Consumers must + // read its contents, not use the array identity as a useMemo/useEffect + // dependency; the identity is stable while the bucket is mutated in place. + const pendingQuotes = pendingByKey[options.draftKey] ?? bucket; + + const publish = useCallback((): void => { + setPendingByKey({ ...pendingByKeyRef.current }); + }, []); + + const addQuote = useCallback((input: { + text: string; + turnId?: string; + label?: string; + sourceSessionId?: string; + sourceSessionName?: string; + sourceCapturedAt?: number; + sourceTruncated?: boolean; + }): void => { + const text = input.text.slice(0, MAX_QUOTE_CHARS).trim(); + if (!text) return; + const quote: QuoteRef = { + text, + ...(input.label ? { label: input.label } : {}), + ...(input.turnId ? { sourceTurnId: input.turnId } : {}), + ...(input.sourceSessionId ? { sourceSessionId: input.sourceSessionId } : {}), + ...(input.sourceSessionName ? { sourceSessionName: input.sourceSessionName } : {}), + ...(input.sourceCapturedAt !== undefined ? { sourceCapturedAt: input.sourceCapturedAt } : {}), + ...(input.sourceTruncated !== undefined ? { sourceTruncated: input.sourceTruncated } : {}), + }; + bucket.push(quote); + publish(); + }, [bucket, options.draftKey, publish]); + + const removeQuote = useCallback((index: number): void => { + bucket.splice(index, 1); + publish(); + }, [bucket, publish]); + + const clearQuotes = useCallback((): void => { + bucket.splice(0, bucket.length); + publish(); + }, [bucket, publish]); + + const clearAllQuotes = useCallback((): void => { + for (const quotes of Object.values(pendingByKeyRef.current)) quotes.splice(0, quotes.length); + publish(); + }, [publish]); + + const restoreQuotes = useCallback((ownerKey: string, quotes: readonly QuoteRef[]): void => { + if (quotes.length === 0) return; + const ownerBucket = pendingByKeyRef.current[ownerKey] ?? + (pendingByKeyRef.current[ownerKey] = []); + ownerBucket.push(...quotes.map((quote) => ({ ...quote }))); + publish(); + }, [publish]); + + return { + pendingQuotes, + addQuote, + removeQuote, + clearQuotes, + clearAllQuotes, + restoreQuotes, + }; +} diff --git a/apps/desktop/src/renderer/features/conversation/controller/use-session-reference-composer.ts b/apps/desktop/src/renderer/features/conversation/controller/use-session-reference-composer.ts new file mode 100644 index 0000000000..4f541cea0b --- /dev/null +++ b/apps/desktop/src/renderer/features/conversation/controller/use-session-reference-composer.ts @@ -0,0 +1,217 @@ +/* + * 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.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; + readonly limitDetail?: string; +} + +export function useSessionReferenceComposer(options: { + readonly sessions: readonly ConversationSession[]; + readonly activeId?: string; + readonly hostId?: string; + readonly addQuote?: (quote: QuoteRef) => void; + readonly pendingQuotes?: readonly QuoteRef[]; + 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 [pendingReferences, setPendingReferences] = useState([]); + const pendingReferencesRef = useRef([]); + const pendingPromise = useRef | null>(null); + const pendingQuotesRef = useRef(options.pendingQuotes); + pendingQuotesRef.current = options.pendingQuotes; + 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; + pendingReferencesRef.current = []; + setPendingReferences([]); + 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 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); + const selected = { + id: source.id, + name: source.name, + status: source.status, + lastMessageAt: source.lastMessageAt, + lastMessagePreview: source.lastMessagePreview, + } satisfies SessionReferenceSession; + if (!pendingReferencesRef.current.some((reference) => reference.id === selected.id)) { + if (pendingReferencesRef.current.length + (pendingQuotesRef.current?.length ?? 0) >= 16) { + reportError(options.errorCopy.unavailableTitle, options.errorCopy.limitDetail ?? 'Remove a quote before adding another (maximum 16).'); + return; + } + generation.current += 1; + pendingPromise.current = null; + setPending(false); + const next = [...pendingReferencesRef.current, selected]; + pendingReferencesRef.current = next; + setPendingReferences(next); + } + }, [options.activeId, options.errorCopy, options.hostId, options.sessions, reportError]); + + const waitForPending = useCallback(async (): Promise => { + const operation = pendingPromise.current; + if (operation && pendingContextKey.current === contextKey) return operation; + const selected = pendingReferencesRef.current; + if (selected.length === 0) return true; + if (selected.length + (pendingQuotesRef.current?.length ?? 0) > 16) { + reportError(options.errorCopy.unavailableTitle, options.errorCopy.limitDetail ?? 'Remove a quote before sending (maximum 16).'); + return false; + } + const request = ++generation.current; + const requestContextKey = contextKey; + pendingContextKey.current = requestContextKey; + setPending(true); + const operationPromise = (async (): Promise => { + try { + const sources = selected.map((reference) => options.sessions.find((candidate) => + candidate.id === reference.id && + !candidate.isArchived && + candidate.shared !== true && + candidate.id !== options.activeId && + candidate.runtimeHostId === options.hostId, + )); + if (sources.some((source) => source === undefined)) { + reportError(options.errorCopy.unavailableTitle, options.errorCopy.unavailableDetail); + return false; + } + const snapshots = await Promise.all( + sources.map((source) => services.sessions.readSnapshot(source!.id)), + ); + if (request !== generation.current || requestContextKey !== contextKeyRef.current) return false; + if (selected.length + (pendingQuotesRef.current?.length ?? 0) > 16) { + reportError(options.errorCopy.unavailableTitle, options.errorCopy.limitDetail ?? 'Remove a quote before sending (maximum 16).'); + return false; + } + if (snapshots.some((snapshot) => !snapshot.text.trim())) { + reportError(options.errorCopy.emptyTitle, options.errorCopy.emptyDetail); + return false; + } + if (!options.addQuote) return false; + for (const snapshot of snapshots) options.addQuote(sessionSnapshotToQuote(snapshot)); + pendingReferencesRef.current = []; + setPendingReferences([]); + return true; + } 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 = operationPromise; + return operationPromise; + }, [contextKey, options.activeId, options.addQuote, options.errorCopy, options.hostId, options.sessions, reportError, services]); + + const removePendingReference = useCallback((sessionId: string): void => { + const next = pendingReferencesRef.current.filter((reference) => reference.id !== sessionId); + if (next.length === pendingReferencesRef.current.length) return; + generation.current += 1; + pendingPromise.current = null; + setPending(false); + pendingReferencesRef.current = next; + setPendingReferences(next); + }, []); + + return { + references, + pick, + pending, + pendingReferences, + removePendingReference, + error: error?.contextKey === contextKey + ? { title: error.title, detail: error.detail } + : undefined, + waitForPending, + }; +} diff --git a/apps/desktop/src/renderer/features/conversation/index.ts b/apps/desktop/src/renderer/features/conversation/index.ts index 141ad9ddc0..b55fca912b 100644 --- a/apps/desktop/src/renderer/features/conversation/index.ts +++ b/apps/desktop/src/renderer/features/conversation/index.ts @@ -58,3 +58,11 @@ export { clearPending, } from '@maka/ui/pending-items'; export { desktopSlashCommandPresentation } from './model/slash-command-presentation.js'; +export { useComposerQuotes } from './controller/use-composer-quotes.js'; +export { useSessionReferenceComposer } from './controller/use-session-reference-composer.js'; +export { + ComposerMentionsProvider, + useComposerMentionsContext, + type ComposerMentions, + type ComposerMentionsSurface, +} from './ui/composer-mentions-provider.js'; diff --git a/apps/desktop/src/renderer/features/conversation/ports.ts b/apps/desktop/src/renderer/features/conversation/ports.ts index e78b811fbf..a875aef72b 100644 --- a/apps/desktop/src/renderer/features/conversation/ports.ts +++ b/apps/desktop/src/renderer/features/conversation/ports.ts @@ -17,9 +17,71 @@ * under the License. */ +import type { SessionChangedEvent } from '@maka/core/session'; +import type { SessionSnapshot } from '@maka/core/session-reference'; +import type { ChatDefaultPermissionMode } from '@maka/core/settings'; +import type { InvocableSkillEntry } from '@maka/runtime/skill-invocation'; +import type { DesktopSessionSummary } from '../../../shared/desktop-session-projection.js'; import type { DesktopSessionLocalBridge } from '../../../shared/session-local-contract.js'; -export type ConversationServices = Pick< +export type ConversationSession = Pick< + DesktopSessionSummary, + | 'id' + | 'name' + | 'status' + | 'lastMessageAt' + | 'lastMessagePreview' + | 'isArchived' + | 'runtimeHostId' + | 'shared' +>; + +export interface ConversationNewTaskTarget { + readonly profileId: string; + readonly hostId: string; + readonly projectId: string | null; +} + +export type ConversationFileSearchResult = + | { readonly ok: true; readonly files: Array<{ readonly relativePath: string }> } + | { readonly ok: false; readonly reason: 'no_project' | 'search_failed' }; + +export interface ConversationServices extends Pick< DesktopSessionLocalBridge, 'listMessages' | 'cancelMessage' | 'reconcileMessage' | 'subscribeChanges' ->; +> { + readonly sessions: { + list(): Promise; + subscribeChanges(handler: (event: SessionChangedEvent) => void): () => void; + readSnapshot(sessionId: string, options?: { readonly maxChars?: number }): Promise; + }; + readonly skills: { + listInvocable(sessionId?: string): Promise; + }; + readonly workspace: { + searchFiles( + query: string, + options?: { readonly sessionId?: string; readonly limit?: number }, + ): Promise; + }; + readonly newTasks: { + subscribeChanges(handler: () => void): () => void; + listInvocableSkills( + target: ConversationNewTaskTarget, + context?: { + readonly llmConnectionSlug?: string; + readonly model?: string; + readonly collaborationMode?: 'agent' | 'plan'; + readonly permissionMode?: ChatDefaultPermissionMode; + }, + ): Promise; + searchFiles( + target: ConversationNewTaskTarget, + query: string, + options?: { readonly limit?: number }, + ): Promise; + }; + readonly mcp: { + subscribeChanges(handler: () => void): () => void; + }; +} 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..6ef746c9f1 --- /dev/null +++ b/apps/desktop/src/renderer/features/conversation/ui/composer-mentions-provider.tsx @@ -0,0 +1,322 @@ +/* + * 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.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; + readonly pendingQuotes?: readonly QuoteRef[]; +} + + +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 pendingSessionReferences: ReadonlyArray; + onRemovePendingSessionReference(sessionId: string): void; + readonly sessionReferenceError?: { title: string; detail: string }; + waitForSessionReference(): Promise; +} + +const EMPTY_SKILLS: InvocableSkillEntry[] = []; +const ComposerMentionsContext = createContext(undefined); + +function skillListsEqual( + current: readonly InvocableSkillEntry[], + next: readonly InvocableSkillEntry[], +): boolean { + return current.length === next.length && current.every((skill, index) => { + const other = next[index]; + return ( + other?.ref === skill.ref && + other?.id === skill.id && + other?.name === skill.name && + other?.description === skill.description + ); + }); +} + +function conversationSessionListsEqual( + current: readonly ConversationSession[], + next: readonly ConversationSession[], +): boolean { + if (current.length !== next.length) return false; + return current.every((session, index) => { + const other = next[index]; + return ( + other !== undefined && + session.id === other.id && + session.runtimeHostId === other.runtimeHostId && + session.name === other.name && + session.status === other.status && + session.lastMessageAt === other.lastMessageAt && + session.lastMessagePreview === other.lastMessagePreview && + session.isArchived === other.isArchived && + session.shared === other.shared + ); + }); +} + +function useConversationMentions(surface: ComposerMentionsSurface): ComposerMentions { + const services = useConversationServices(); + const locale = useUiLocale(); + const mentionCopy = getConversationCopy(locale).mentions; + const [catalog, setCatalog] = useState<{ + contextKey: string; + loading: boolean; + settled?: 'empty' | 'populated'; + skills: InvocableSkillEntry[]; + }>({ + contextKey: '', + loading: true, + skills: EMPTY_SKILLS, + }); + const [sessions, setSessions] = useState([]); + const contextKey = surface.sessionId ? `session\u0000${surface.sessionId}` : [ + surface.sessionId ?? '', + surface.projectPath ?? '', + surface.newSessionModel?.llmConnectionSlug ?? '', + surface.newSessionModel?.model ?? '', + surface.newSessionCollaborationMode ?? 'agent', + surface.newSessionPermissionMode ?? '', + surface.newTaskTarget?.profileId ?? '', + surface.newTaskTarget?.hostId ?? '', + surface.newTaskTarget?.projectId ?? '', + ].join('\u0000'); + const liveCatalog = catalog.contextKey === contextKey + ? catalog + : { contextKey, loading: true, settled: undefined, skills: EMPTY_SKILLS }; + const activeHostId = surface.sessionId + ? sessions.find((session) => session.id === surface.sessionId)?.runtimeHostId + : surface.newTaskTarget?.hostId; + + useEffect(() => { + let cancelled = false; + const refreshSessions = () => { + void services.sessions.list().then((next) => { + if (!cancelled) { + setSessions((previous) => + conversationSessionListsEqual(previous, next) ? previous : next, + ); + } + }).catch(() => { + if (!cancelled) { + setSessions((previous) => (previous.length === 0 ? previous : [])); + } + }); + }; + refreshSessions(); + const unsubscribe = services.sessions.subscribeChanges(refreshSessions); + return () => { + cancelled = true; + unsubscribe(); + }; + }, [services]); + + useEffect(() => { + let cancelled = false; + let requestVersion = 0; + const context = { + ...(surface.newSessionModel ?? {}), + collaborationMode: surface.newSessionCollaborationMode ?? 'agent', + ...(surface.newSessionPermissionMode + ? { permissionMode: surface.newSessionPermissionMode } + : {}), + }; + const refresh = () => { + const version = ++requestVersion; + const request = surface.sessionId + ? services.skills.listInvocable(surface.sessionId) + : surface.newTaskTarget + ? services.newTasks.listInvocableSkills(surface.newTaskTarget, context) + : Promise.resolve([]); + setCatalog((previous) => + previous.contextKey === contextKey + ? { ...previous, loading: true } + : { contextKey, loading: true, settled: undefined, skills: EMPTY_SKILLS }, + ); + void request.then((next) => { + if (cancelled || version !== requestVersion) return; + setCatalog((previous) => ({ + contextKey, + loading: false, + settled: next.length === 0 ? 'empty' : 'populated', + skills: + previous.contextKey === contextKey && skillListsEqual(previous.skills, next) + ? previous.skills + : [...next], + })); + }).catch(() => { + if (!cancelled && version === requestVersion) { + setCatalog({ 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.sessionId ? undefined : surface.newSessionModel?.llmConnectionSlug, + surface.sessionId ? undefined : surface.newSessionModel?.model, + surface.sessionId ? undefined : surface.newSessionCollaborationMode, + surface.sessionId ? undefined : surface.newSessionPermissionMode, + surface.sessionId, + surface.skillCatalogRevision, + surface.sessionId ? undefined : surface.newTaskTarget?.profileId, + surface.sessionId ? undefined : surface.newTaskTarget?.hostId, + surface.sessionId ? undefined : 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, + pendingQuotes: surface.pendingQuotes, + errorCopy: useMemo( + () => ({ + unavailableTitle: mentionCopy.sessionReferenceUnavailableTitle, + unavailableDetail: mentionCopy.sessionReferenceUnavailableDetail, + emptyTitle: mentionCopy.sessionReferenceEmptyTitle, + emptyDetail: mentionCopy.sessionReferenceEmptyDetail, + readFailedTitle: mentionCopy.sessionReferenceReadFailedTitle, + readFailedDetail: mentionCopy.sessionReferenceReadFailedDetail, + limitDetail: mentionCopy.sessionReferenceLimitDetail, + }), + [ + mentionCopy.sessionReferenceEmptyDetail, + mentionCopy.sessionReferenceEmptyTitle, + mentionCopy.sessionReferenceReadFailedDetail, + mentionCopy.sessionReferenceLimitDetail, + mentionCopy.sessionReferenceReadFailedTitle, + mentionCopy.sessionReferenceUnavailableDetail, + mentionCopy.sessionReferenceUnavailableTitle, + ], + ), + }); + const referenceEnabled = surface.sessionId !== undefined || surface.newTaskTarget !== undefined; + return useMemo(() => ({ + mentionSkills: liveCatalog.skills, + mentionSkillsUnavailable: liveCatalog.settled === 'empty', + mentionSkillsLoading: liveCatalog.loading, + searchMentionFiles, + sessionReferences: surface.onAddQuote && referenceEnabled ? reference.references : [], + onPickSessionReference: + surface.onAddQuote && referenceEnabled ? reference.pick : undefined, + pendingSessionReferences: reference.pendingReferences, + onRemovePendingSessionReference: reference.removePendingReference, + sessionReferenceError: reference.error, + waitForSessionReference: reference.waitForPending, + }), [ + liveCatalog.loading, + liveCatalog.settled, + liveCatalog.skills, + reference.error, + reference.pick, + reference.pendingReferences, + reference.removePendingReference, + reference.references, + reference.waitForPending, + searchMentionFiles, + surface.onAddQuote, + ]); +} + +export function ComposerMentionsProvider(props: ComposerMentionsSurface & { readonly children: ReactNode }) { + const mentions = useConversationMentions(props); + return {props.children}; +} + +export function useComposerMentionsContext(): ComposerMentions | undefined { + return useContext(ComposerMentionsContext); +} diff --git a/apps/desktop/src/renderer/features/session-settings/use-session-setting-intent.ts b/apps/desktop/src/renderer/features/session-settings/use-session-setting-intent.ts index d597ff60f9..4d12fffd34 100644 --- a/apps/desktop/src/renderer/features/session-settings/use-session-setting-intent.ts +++ b/apps/desktop/src/renderer/features/session-settings/use-session-setting-intent.ts @@ -48,7 +48,7 @@ export function useSessionSettingIntent(in catalogRevision: number; isActiveSession(sessionId: string): boolean; sessions: readonly DesktopSessionSummary[]; - newTaskPermissionMode: ChatDefaultPermissionMode; + newSessionPermissionMode: ChatDefaultPermissionMode; refreshCatalog(): Promise; saveComposerDefaults(model: SessionModelTarget): void; writeFailureCopy( @@ -160,7 +160,7 @@ export function useSessionSettingIntent(in const overlay = sessionId ? intent.overlayByChannel.permissionMode[sessionId] : undefined; const currentMode = sessionId ? overlay ?? input.sessions.find((session) => session.id === sessionId)?.permissionMode - : input.newTaskPermissionMode; + : input.newSessionPermissionMode; if (currentMode === mode) { return sessionId && overlay !== undefined ? intent.request('permissionMode', sessionId, mode) diff --git a/apps/desktop/src/renderer/pending-items.ts b/apps/desktop/src/renderer/pending-items.ts index 2c5bd8afac..a180e63353 100644 --- a/apps/desktop/src/renderer/pending-items.ts +++ b/apps/desktop/src/renderer/pending-items.ts @@ -17,4 +17,4 @@ * under the License. */ -export { NEW_TASK_PENDING_KEY, selectPending, appendPending, removePending, removePendingItems, clearPending, type PendingByKey } from './features/conversation/index.js'; +export { NEW_TASK_PENDING_KEY, selectPending, appendPending, removePending, removePendingItems, clearPending } from './features/conversation/index.js'; diff --git a/apps/desktop/src/renderer/platform/desktop/create-conversation-services.ts b/apps/desktop/src/renderer/platform/desktop/create-conversation-services.ts index c6a1d76954..1d571a780b 100644 --- a/apps/desktop/src/renderer/platform/desktop/create-conversation-services.ts +++ b/apps/desktop/src/renderer/platform/desktop/create-conversation-services.ts @@ -21,7 +21,19 @@ import type { MakaBridge } from '../../../preload/bridge-contract.js'; import type { ConversationServices } from '../../features/conversation/index.js'; export function createDesktopConversationServices( - bridge: Pick = window.maka, + bridge: Pick< + MakaBridge, + 'sessionLocal' | 'sessions' | 'skills' | 'workspace' | 'newTasks' | 'mcp' + > = window.maka, ): ConversationServices { - return bridge.sessionLocal; + return { + ...bridge.sessionLocal, + sessions: bridge.sessions, + skills: bridge.skills, + workspace: bridge.workspace, + newTasks: bridge.newTasks, + mcp: { + subscribeChanges: (handler) => bridge.mcp.subscribeChanges(handler), + }, + }; } diff --git a/apps/desktop/src/renderer/styles/composer.css b/apps/desktop/src/renderer/styles/composer.css index d3c7db42fe..da411e884b 100644 --- a/apps/desktop/src/renderer/styles/composer.css +++ b/apps/desktop/src/renderer/styles/composer.css @@ -146,6 +146,37 @@ padding: var(--space-1) var(--space-3) var(--space-2); } +/* A Session reference follows the compact Codex-style context rail: one + content-sized token directly above the input, with no extra disclosure band + or empty panel around it. Attachment and directory drawers keep the + standard collapsible layout below. */ +.maka-composer-astryx + .maka-composer-drawer[data-maka-session-reference-only='true'] { + margin: 0 0 var(--space-1); + padding: 0; + background: transparent; + border-radius: 0; +} + +.maka-composer-astryx + .maka-composer-drawer[data-maka-session-reference-only='true'] + .maka-composer-context-drawer { + gap: var(--space-1); + padding: 0; +} + +.maka-composer-astryx + .maka-composer-drawer[data-maka-session-reference-only='true'] + .maka-composer-session-token { + /* Keep a single reference compact like Codex's context chip. The token's + own Astryx surface supplies its height, padding, radius, and remove + affordance; this rule only constrains unusually long session names. */ + flex: 0 1 auto; + min-width: 0; + max-width: min(420px, 100%); + overflow: hidden; +} + /* Astryx ChatComposerDrawer wraps its content in a display:grid whose single implicit column sizes to the content's max-content contribution — with a long chip row that resolves WIDER than the grid (measured: 896px column in diff --git a/apps/desktop/src/renderer/use-app-shell-composer-quotes.ts b/apps/desktop/src/renderer/use-app-shell-composer-quotes.ts index 83eccbbe4d..5567e56a8b 100644 --- a/apps/desktop/src/renderer/use-app-shell-composer-quotes.ts +++ b/apps/desktop/src/renderer/use-app-shell-composer-quotes.ts @@ -17,74 +17,8 @@ * under the License. */ -import { useState } from 'react'; -import type { QuoteRef } from '@maka/core/events'; -import { - appendPending, - clearPending, - removePending, - selectPending, - type PendingByKey, -} from './pending-items.js'; +/** Compatibility entry point; quote state now belongs to Conversation. */ +import { useComposerQuotes } from './features/conversation/index.js'; -/** - * Excerpts longer than this are truncated before staging. Kept equal to the - * `sessions:send` normalizer's per-quote cap so the renderer can never stage - * something the IPC boundary would reject on send. - */ -const MAX_QUOTE_CHARS = 32_000; - -/** - * Quoted excerpts staged for the next send, keyed by draft key so each session - * keeps its own (mirrors pending attachments). Cleared once the turn is sent. - */ -export function useAppShellComposerQuotes(options: { draftKey: string }) { - const [pendingByKey, setPendingByKey] = useState>({}); - const pendingQuotes = selectPending(pendingByKey, options.draftKey); - - function addQuote(input: { text: string; turnId?: string; label?: string }): void { - const text = input.text.slice(0, MAX_QUOTE_CHARS).trim(); - if (!text) return; - const ownerKey = options.draftKey; - const quote: QuoteRef = { - text, - ...(input.label ? { label: input.label } : {}), - ...(input.turnId ? { sourceTurnId: input.turnId } : {}), - }; - setPendingByKey((map) => appendPending(map, ownerKey, [quote])); - } - - function removeQuote(index: number): void { - const ownerKey = options.draftKey; - setPendingByKey((map) => removePending(map, ownerKey, index)); - } - - function clearQuotes(): void { - const ownerKey = options.draftKey; - setPendingByKey((map) => clearPending(map, ownerKey)); - } - - function clearAllQuotes(): void { - setPendingByKey({}); - } - - function restoreQuotes(ownerKey: string, quotes: readonly QuoteRef[]): void { - if (quotes.length === 0) return; - setPendingByKey((map) => - appendPending( - map, - ownerKey, - quotes.map((quote) => ({ ...quote })), - ), - ); - } - - return { - pendingQuotes, - addQuote, - removeQuote, - clearQuotes, - clearAllQuotes, - restoreQuotes, - }; -} +export { useComposerQuotes }; +export const useAppShellComposerQuotes: typeof useComposerQuotes = useComposerQuotes; diff --git a/apps/desktop/stories/composer-slash-menu.stories.tsx b/apps/desktop/stories/composer-slash-menu.stories.tsx index 9d8decba28..bc31720b13 100644 --- a/apps/desktop/stories/composer-slash-menu.stories.tsx +++ b/apps/desktop/stories/composer-slash-menu.stories.tsx @@ -37,11 +37,16 @@ import { useMemo, useState } from 'react'; import type { Meta, StoryObj } from '@storybook/react-vite'; import { expect, userEvent, waitFor, within } from 'storybook/test'; import { slashCommandsForSurface } from '@maka/core/slash-command-catalog'; +import type { SessionChangedEvent } from '@maka/core/session'; import { Composer } from '@maka/ui'; import { ComposerMentionsProvider, useComposerMentionsContext, } from '../src/renderer/composer-mentions'; +import { + ConversationServicesProvider, + type ConversationServices, +} from '../src/renderer/features/conversation'; import { desktopSlashCommandAvailability } from '../src/renderer/desktop-slash-command'; import { getShellCopy } from '../src/renderer/locales/shell-copy'; import { withScopedMakaBridge } from './maka-bridge'; @@ -106,8 +111,8 @@ const makaBridge = { subscribeChanges: () => () => {}, }, sessions: { - subscribeChanges(listener: (event: { sessionId: string; reason: string }) => void) { - publishSessionUpdate = () => listener({ sessionId: SESSION_ID, reason: 'updated' }); + subscribeChanges(listener: (event: SessionChangedEvent) => void) { + publishSessionUpdate = () => listener({ sessionId: SESSION_ID, reason: 'updated', ts: Date.now() }); return () => { publishSessionUpdate = undefined; }; @@ -117,6 +122,33 @@ const makaBridge = { workspace: { searchFiles: async () => ({ ok: true, files: [] }) }, }; +const conversationServices: ConversationServices = { + listMessages: async () => [], + cancelMessage: async () => undefined, + reconcileMessage: async () => undefined, + subscribeChanges: () => () => undefined, + sessions: { + list: async () => [], + subscribeChanges(listener: (event: SessionChangedEvent) => void) { + publishSessionUpdate = () => listener({ sessionId: SESSION_ID, reason: 'updated', ts: Date.now() }); + return () => { + publishSessionUpdate = undefined; + }; + }, + readSnapshot: async () => { + throw new Error('Session snapshots are not used in slash menu stories'); + }, + }, + skills: { listInvocable: loadProjection }, + workspace: { searchFiles: async () => ({ ok: true, files: [] }) }, + newTasks: { + subscribeChanges: () => () => undefined, + listInvocableSkills: loadProjection, + searchFiles: async () => ({ ok: true, files: [] }), + }, + mcp: { subscribeChanges: () => () => undefined }, +}; + function SlashMenuComposer({ hasSession, streaming, @@ -154,18 +186,20 @@ function SlashMenuHarness({ }): React.ReactElement { return (
- - - + + + + +
); } @@ -184,16 +218,18 @@ function ContextSwitchHarness(): React.ReactElement { Switch to new task
- - - + + + + +
); diff --git a/docs/astryx-surface-file-inventory.md b/docs/astryx-surface-file-inventory.md index ec585597a3..549df7be74 100644 --- a/docs/astryx-surface-file-inventory.md +++ b/docs/astryx-surface-file-inventory.md @@ -6,7 +6,7 @@ Generated against `@astryxdesign/core@0.5.2` (194 component exports). Wiki bar: Design Conventions · API Use-the-System · Theming · Container Padding. -**Totals:** 267 files — blocker 0, reimplementation 0, polish 2, aligned 265. +**Totals:** 268 files — blocker 0, reimplementation 0, polish 2, aligned 266. ## Exclusions (explicit) @@ -51,6 +51,7 @@ Wiki bar: Design Conventions · API Use-the-System · Theming · Container Paddi | `apps/desktop/src/renderer/features/connection-settings/services-context.tsx` | other | none | aligned — no raw controls; no Astryx JSX usage | aligned | | `apps/desktop/src/renderer/features/conversation/controller/session-local-messages.tsx` | shell-chrome-or-panel | none | aligned — no raw controls; no Astryx JSX usage | aligned | | `apps/desktop/src/renderer/features/conversation/controller/transcript-reading-position-controller.tsx` | other | none | aligned — no raw controls; no Astryx JSX usage | aligned | +| `apps/desktop/src/renderer/features/conversation/ui/composer-mentions-provider.tsx` | shell-chrome-or-panel | none | aligned — no raw controls; no Astryx JSX usage | aligned | | `apps/desktop/src/renderer/features/goals/services-context.tsx` | other | none | aligned — no raw controls; no Astryx JSX usage | aligned | | `apps/desktop/src/renderer/features/goals/ui/goal-dialog.tsx` | dialog-overlay | Button, Dialog, DialogHeader, HStack, Layout, LayoutContent, LayoutFooter, Text, TextArea, TextInput, VStack | aligned — uses Astryx (Button, Dialog, DialogHeader, HStack, Layout, LayoutContent, LayoutFooter, Text) | aligned | | `apps/desktop/src/renderer/features/goals/ui/goal-host.tsx` | other | none | aligned — no raw controls; no Astryx JSX usage | aligned | diff --git a/docs/astryx-surface-file-inventory.paths b/docs/astryx-surface-file-inventory.paths index 822d7b76e4..9c3f17ef46 100644 --- a/docs/astryx-surface-file-inventory.paths +++ b/docs/astryx-surface-file-inventory.paths @@ -22,6 +22,7 @@ apps/desktop/src/renderer/features/connection-settings/onboarding-step-form.tsx apps/desktop/src/renderer/features/connection-settings/services-context.tsx apps/desktop/src/renderer/features/conversation/controller/session-local-messages.tsx apps/desktop/src/renderer/features/conversation/controller/transcript-reading-position-controller.tsx +apps/desktop/src/renderer/features/conversation/ui/composer-mentions-provider.tsx apps/desktop/src/renderer/features/goals/services-context.tsx apps/desktop/src/renderer/features/goals/ui/goal-dialog.tsx apps/desktop/src/renderer/features/goals/ui/goal-host.tsx diff --git a/packages/core/package.json b/packages/core/package.json index 1df9f904d0..c911bcb1e2 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..b56aa7cc0c 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,24 @@ 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); + assert.equal(isQuoteRef({ ...quote, sourceCapturedAt: Number.MAX_VALUE }), false); + assert.equal(isQuoteRef({ ...quote, sourceCapturedAt: 8.64e15 + 1 }), false); + assert.equal(isQuoteRef({ ...quote, sourceCapturedAt: 8.64e15 }), true); +}); + 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..a1f3be7115 --- /dev/null +++ b/packages/core/src/__tests__/session-reference.test.ts @@ -0,0 +1,182 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import test from 'node:test'; +import type { StoredMessage } from '../session.js'; +import { createSessionSnapshot, sessionSnapshotToQuote } from '../session-reference.js'; + +function user(id: string, text: string, ts = 1): StoredMessage { + return { type: 'user', id, turnId: `turn-${id}`, ts, text }; +} + +function assistant(id: string, text: string, ts = 2): StoredMessage { + return { type: 'assistant', id, turnId: `turn-${id}`, ts, text, modelId: 'model' }; +} + +test('creates a recent, redacted snapshot and excludes non-conversation messages', () => { + const snapshot = createSessionSnapshot( + [ + user('old-user', 'old context'), + assistant('old-assistant', 'old answer'), + { + type: 'tool_call', + id: 'tool-call', + turnId: 'turn-tool', + ts: 3, + toolName: 'Bash', + args: { command: 'cat secret.txt' }, + }, + { + type: 'system_note', + id: 'system-note', + ts: 4, + kind: 'error', + data: { secret: 'do-not-share' }, + }, + user('new-user', 'new question', 5), + assistant('new-assistant', 'new answer', 6), + ], + { + sessionId: 'session-source', + sessionName: 'Runtime research', + capturedAt: 123, + maxChars: 10_000, + }, + ); + + assert.deepEqual( + snapshot.items.map((item) => item.role), + ['user', 'assistant', 'user', 'assistant'], + ); + assert.match(snapshot.text, /new question/); + assert.match(snapshot.text, /new answer/); + assert.doesNotMatch(snapshot.text, /secret/); + assert.equal(snapshot.truncated, false); + assert.equal(snapshot.reference.sessionId, 'session-source'); + assert.equal(snapshot.reference.sessionName, 'Runtime research'); + assert.equal(snapshot.reference.capturedAt, 123); +}); + +test('redacts secrets from retained user and assistant messages before quoting them', () => { + const snapshot = createSessionSnapshot( + [ + user('user-secret', 'Use Authorization: Bearer sk-live-secret-token-value'), + assistant('assistant-secret', 'The key is sk-ant-api03-live-secret-token-value'), + ], + { + sessionId: 'session-source', + sessionName: 'Sensitive session', + }, + ); + + assert.doesNotMatch(snapshot.text, /sk-live-secret-token-value/); + assert.doesNotMatch(snapshot.text, /sk-ant-api03-live-secret-token-value/); + assert.match(snapshot.text, /\[redacted\]/); + assert.doesNotMatch(sessionSnapshotToQuote(snapshot).text, /sk-live-secret-token-value/); +}); + +test('redacts a credential-shaped title in both snapshot provenance and quote labels', () => { + const secret = 'sk-live-secret-token-value'; + const snapshot = createSessionSnapshot([user('secret', secret)], { + sessionId: 'source', + sessionName: secret, + }); + assert.doesNotMatch(JSON.stringify(snapshot), /sk-live-secret-token-value/); + // Older Hosts can still supply a raw name; the model-facing converter owns + // redaction independently of the snapshot producer. + snapshot.reference.sessionName = secret; + assert.doesNotMatch( + JSON.stringify(sessionSnapshotToQuote(snapshot)), + /sk-live-secret-token-value/, + ); +}); + +test('bounds a snapshot from the newest content and preserves truncation provenance', () => { + const snapshot = createSessionSnapshot( + [ + user('first', 'first message'), + assistant('second', 'second message'), + user('last', 'latest message'), + ], + { + sessionId: 'session-source', + sessionName: 'Long session', + capturedAt: 456, + maxChars: 24, + }, + ); + + assert.equal(snapshot.truncated, true); + assert.ok(snapshot.text.length <= 24); + assert.match(snapshot.text, /latest/); + assert.equal(sessionSnapshotToQuote(snapshot).sourceSessionId, 'session-source'); + assert.equal(sessionSnapshotToQuote(snapshot).sourceSessionName, 'Long session'); + assert.equal(sessionSnapshotToQuote(snapshot).sourceCapturedAt, 456); + assert.equal(sessionSnapshotToQuote(snapshot).sourceTruncated, true); +}); + +test('accounts for the role prefix when truncating a single item', () => { + const snapshot = createSessionSnapshot([user('last', 'latest message')], { + sessionId: 'session-source', + sessionName: 'Short budget', + maxChars: 10, + }); + + assert.equal(snapshot.items[0]?.text, 'late'); + assert.equal(snapshot.text, 'User: late'); + assert.ok(snapshot.text.length <= 10); + assert.doesNotMatch(snapshot.text, /User: User/); +}); + +test('does not emit a partial role prefix when no content fits', () => { + const snapshot = createSessionSnapshot([user('last', 'latest message')], { + sessionId: 'session-source', + sessionName: 'Tiny budget', + maxChars: 5, + }); + + assert.deepEqual(snapshot.items, []); + assert.equal(snapshot.text, ''); + assert.equal(snapshot.truncated, true); +}); + +test('does not split an emoji when truncating the first item', () => { + const snapshot = createSessionSnapshot([user('last', 'a😀b')], { + sessionId: 'session-source', + sessionName: 'Unicode boundary', + maxChars: 8, + }); + + assert.equal(snapshot.text, 'User: a'); + assert.equal(snapshot.items[0]?.text, 'a'); + assert.equal([...snapshot.text].join(''), snapshot.text); +}); + +test('does not emit a partial role prefix when an emoji cannot fit', () => { + const snapshot = createSessionSnapshot([user('last', '😀')], { + sessionId: 'session-source', + sessionName: 'Joined boundary', + maxChars: 7, + }); + + assert.equal(snapshot.text, ''); + assert.deepEqual(snapshot.items, []); + assert.equal([...snapshot.text].join(''), snapshot.text); +}); diff --git a/packages/core/src/events.ts b/packages/core/src/events.ts index 889069e08a..2865015b9d 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,34 @@ 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 && + record.sourceCapturedAt <= 8.64e15 && + typeof record.sourceTruncated === 'boolean')) ); } @@ -490,7 +548,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..303b84adca --- /dev/null +++ b/packages/core/src/session-reference.ts @@ -0,0 +1,178 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { QuoteRef } from './events.js'; +import { redactSecrets } from './redaction.js'; +import type { StoredMessage } from './session.js'; +import { userFacingText } from './session.js'; + +/** Default per-reference budget. It is deliberately small enough to leave room for the active task. */ +export const SESSION_SNAPSHOT_DEFAULT_MAX_CHARS = 12_000; +export const SESSION_SNAPSHOT_MAX_CHARS = 32_000; +export const SESSION_SNAPSHOT_MAX_ITEMS = 24; + +export interface SessionSnapshotReference { + sessionId: string; + sessionName: string; + capturedAt: number; +} + +export interface SessionSnapshotItem { + role: 'user' | 'assistant'; + text: string; + turnId: string; + ts: number; +} + +export interface SessionSnapshot { + reference: SessionSnapshotReference; + items: readonly SessionSnapshotItem[]; + text: string; + estimatedTokens: number; + maxChars: number; + truncated: boolean; +} + +export interface SessionSnapshotOptions { + sessionId: string; + sessionName: string; + capturedAt?: number; + maxChars?: number; + maxItems?: number; +} + +/** + * Build the model-safe portion of a Session transcript. + * + * Only user and assistant text is shareable. Tool calls/results, permission + * events, system notes, and other Runtime records remain outside this + * projection. Items are selected from the tail, then restored to transcript + * order so a large history cannot crowd the current request unexpectedly. + */ +export function createSessionSnapshot( + messages: readonly StoredMessage[], + options: SessionSnapshotOptions, +): SessionSnapshot { + const maxChars = clampPositiveInteger( + options.maxChars ?? SESSION_SNAPSHOT_DEFAULT_MAX_CHARS, + 1, + SESSION_SNAPSHOT_MAX_CHARS, + ); + const maxItems = clampPositiveInteger( + options.maxItems ?? SESSION_SNAPSHOT_MAX_ITEMS, + 1, + SESSION_SNAPSHOT_MAX_ITEMS, + ); + const candidates: SessionSnapshotItem[] = messages.flatMap((message) => { + if (message.type !== 'user' && message.type !== 'assistant') return []; + const text = message.type === 'user' ? userFacingText(message) : message.text; + const normalized = redactSecrets(text).trim(); + if (!normalized) return []; + return [ + { + role: message.type, + text: normalized, + turnId: message.turnId, + ts: message.ts, + }, + ]; + }); + + const selected: SessionSnapshotItem[] = []; + let usedChars = 0; + let truncated = false; + for (let index = candidates.length - 1; index >= 0 && selected.length < maxItems; index -= 1) { + const candidate = candidates[index]!; + const line = formatSnapshotItem(candidate); + const separator = selected.length > 0 ? 2 : 0; + const available = maxChars - usedChars - separator; + if (available <= 0) { + truncated = true; + break; + } + if (line.length <= available) { + selected.push(candidate); + usedChars += separator + line.length; + continue; + } + if (selected.length === 0) { + const prefixLength = formatSnapshotItem({ ...candidate, text: '' }).length; + const contentBudget = Math.max(0, available - prefixLength); + if (contentBudget > 0) { + const text = sliceAtCodePointBoundary(candidate.text, contentBudget).trimEnd(); + if (!text) { + truncated = true; + break; + } + selected.push({ + ...candidate, + text, + }); + usedChars = maxChars; + } + } + truncated = true; + break; + } + if (selected.length < candidates.length) truncated = true; + selected.reverse(); + + const text = sliceAtCodePointBoundary(selected.map(formatSnapshotItem).join('\n\n'), maxChars); + return { + reference: { + sessionId: options.sessionId, + sessionName: redactSecrets(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 { + const sessionName = redactSecrets(snapshot.reference.sessionName); + return { + text: snapshot.text, + label: `Session: ${sessionName}`, + sourceSessionId: snapshot.reference.sessionId, + sourceSessionName: sessionName, + sourceCapturedAt: snapshot.reference.capturedAt, + sourceTruncated: snapshot.truncated, + }; +} + +function formatSnapshotItem(item: Pick): string { + return `${item.role === 'user' ? 'User' : 'Assistant'}: ${item.text}`; +} + +/** Keep a UTF-16 slice from ending between the halves of a surrogate pair. */ +function sliceAtCodePointBoundary(value: string, maxCodeUnits: number): string { + const sliced = value.slice(0, maxCodeUnits); + const last = sliced.charCodeAt(sliced.length - 1); + return last >= 0xd800 && last <= 0xdbff ? sliced.slice(0, -1) : sliced; +} + +function clampPositiveInteger(value: number, min: number, max: number): number { + if (!Number.isSafeInteger(value)) return min; + return Math.max(min, Math.min(max, value)); +} diff --git a/packages/runtime-host/protocol-compatible-changes/session-reference-quote-ref.json b/packages/runtime-host/protocol-compatible-changes/session-reference-quote-ref.json new file mode 100644 index 0000000000..d177124459 --- /dev/null +++ b/packages/runtime-host/protocol-compatible-changes/session-reference-quote-ref.json @@ -0,0 +1,5 @@ +{ + "epoch": 137, + "files": ["packages/runtime-host/src/protocol/index.ts"], + "reason": "The epoch declaration is advanced for the QuoteRef strict-shape extension owned by the same change" +} diff --git a/packages/runtime-host/src/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts index c95ce0b669..593ca51134 100644 --- a/packages/runtime-host/src/protocol/index.ts +++ b/packages/runtime-host/src/protocol/index.ts @@ -101,7 +101,9 @@ export const RUNTIME_HOST_REGISTRATION_SCHEMA_VERSION = 1 as const; export const RUNTIME_HOST_PROTOCOL_VERSION = 0 as const; // Increment when the same protocol version no longer guarantees safe Client-Host // interoperability. Mismatches are rejected before domain commands are admitted. -export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 136 as const; +export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 137 as const; +// 137: QuoteRef accepts bounded Session-reference provenance fields. +// Epoch-136 peers reject these fields on the strict message-content shape. // 136: WorkHub transient proposals distinguish routing dispositions from linked // operations. Older peers expect replace/stop_work/resume_work dispositions. // 135: WorkHub model Turns replace direct action proposals with active-Turn task tools. 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..a1f0538cb5 --- /dev/null +++ b/packages/ui/src/__tests__/composer-session-reference.test.ts @@ -0,0 +1,87 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * Contract for the narrow #4309 Composer seam. Session selection is a + * reference action, not an inline text token: the trigger query disappears, + * the host reads one bounded snapshot, and the resulting QuoteRef owns the + * actual context sent with the next turn. + */ + +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; + +function readSource(relativePath: string): string { + return readFileSync(fileURLToPath(new URL(`../../src/${relativePath}`, import.meta.url)), 'utf8'); +} + +function readRepoFile(relativePath: string): string { + return readFileSync(fileURLToPath(new URL(`../../../../${relativePath}`, import.meta.url)), 'utf8'); +} + +test('Composer exposes Session references through the @ trigger without serializing them as text', () => { + const source = readSource('composer.tsx'); + assert.match(source, /sessionReferences\?: ReadonlyArray/); + assert.match(source, /onPickSessionReference\?\(session: ComposerSessionReference\)/); + assert.match(source, /id: `session:\$\{session\.id\}`/); + assert.match(source, /MessagesSquare/); + assert.match( + source, + /onPickSessionReference\?\.\(suggestion\.session\)[\s\S]*?return '';/, + ); +}); + +test('Composer copy tells users that @ can reference files or Sessions', () => { + const source = readSource('conversation-copy.ts'); + assert.match(source, /@ 引用文件或会话/); + assert.match(source, /@ to reference files or sessions/); +}); + +test('Session search stays name-only and @ keeps the menu open after spaces', () => { + const composer = readSource('composer.tsx'); + const dependencyPatch = readRepoFile('patches/@astryxdesign+core+0.5.2.patch'); + + assert.match(composer, /const searchQuery = query\.trim\(\)/); + assert.match(composer, /const sessionOnly = \/\\s\/u\.test\(query\)/); + assert.match(composer, /!sessionOnly && source\.onSearchMentionFiles/); + assert.match(composer, /mentionQueryMatches\(searchQuery, session\.name\)/); + assert.doesNotMatch(composer, /session\.lastMessagePreview \?\?/); + assert.match(dependencyPatch, /if \(trigger\.character !== '@' && \/\[ \\n\]\/u\.test\(query\)\) return null;/); +}); + +test('Session Quote chips use the conversation icon so they are distinct from pasted excerpts', () => { + const source = readSource('quote-ref-chip.tsx'); + assert.match(source, /props\.quote\.sourceSessionId \? MessagesSquare : TextQuote/); +}); + +test('Session-only context stays compact without bypassing the drawer disclosure contract', () => { + const composer = readSource('composer.tsx'); + const styles = readRepoFile('apps/desktop/src/renderer/styles/composer.css'); + const sessionStyles = styles.slice( + styles.indexOf('/* A Session reference follows'), + styles.indexOf('/* Astryx ChatComposerDrawer wraps'), + ); + + assert.match(composer, /count=\{sessionReferenceDrawer \? undefined : drawerTokenCount\}/); + assert.match(composer, /className=\{quote\.sourceSessionId \? 'maka-composer-session-token' : undefined\}/); + assert.doesNotMatch(sessionStyles, /\[role=|> div\[id\]|\.astryx-token/); + assert.match(sessionStyles, /\.maka-composer-session-token[\s\S]*max-width: min\(420px, 100%\)/); +}); diff --git a/packages/ui/src/components.tsx b/packages/ui/src/components.tsx index 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 e0a0fdaa64..9cdf5beaa9 100644 --- a/packages/ui/src/composer.tsx +++ b/packages/ui/src/composer.tsx @@ -40,6 +40,7 @@ import { CircleGauge, FileText, ListTodo, + MessagesSquare, Network, Pencil, Plus, @@ -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 @@ -342,6 +357,16 @@ 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; + /** Session references selected but not yet read at the send boundary. */ + pendingSessionReferences?: ReadonlyArray; + /** Remove a selected Session reference before it is resolved for send. */ + onRemovePendingSessionReference?(sessionId: string): void; + /** Wait for a picked Session reference to settle before committing a send. */ + waitForSessionReference?(): Promise; modelLabel?: string; activeSession?: SessionSummary; activeModelConnectionId?: string; @@ -921,6 +946,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, }); @@ -928,22 +955,39 @@ 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 sessionOnly = /\s/u.test(query); + const searchQuery = query.trim(); + const files = !sessionOnly && source.onSearchMentionFiles + ? source.onSearchMentionFiles(searchQuery).then((entries) => + entries + .filter((file) => mentionQueryMatches(searchQuery, file.relativePath)) + .slice(0, 25) + .map((file) => ({ id: file.relativePath, label: file.relativePath })), + ) + : Promise.resolve([]); + const sessions = source.onPickSessionReference + ? (source.sessionReferences ?? []) + .filter((session) => mentionQueryMatches(searchQuery, session.name)) + .slice(0, 25) + .map((session) => ({ + id: `session:${session.id}`, + label: session.name, + auxiliaryData: { kind: 'session', session } satisfies ComposerMentionSuggestion, + })) + : []; + return files.then((fileItems) => [...fileItems, ...sessions].slice(0, 50)); }; - const files = createTriggerSearchSource(runFileSearch); + const files = createTriggerSearchSource(runMentionSearch); const listSlashSuggestions = (rawQuery: string): SearchableItem[] => { const source = mentionSourceRef.current; const skills = source.mentionSkills ?? []; @@ -1015,35 +1059,57 @@ export const Composer = forwardRef< const triggers = useMemo(() => { const sources = searchSourcesRef.current!; const list: ChatComposerTrigger[] = []; - if (props.onSearchMentionFiles) { + if (props.onSearchMentionFiles || props.onPickSessionReference) { list.push({ character: '@', searchSource: sources.files, - menuLabel: mentionCopy.filesAriaLabel, - emptySearchResultsText: mentionCopy.noFiles, + menuLabel: + props.sessionReferences !== undefined && props.onPickSessionReference !== undefined + ? mentionCopy.filesAndSessionsAriaLabel + : mentionCopy.filesAriaLabel, + emptySearchResultsText: + props.sessionReferences !== undefined && props.onPickSessionReference !== undefined + ? mentionCopy.noFilesOrSessions + : mentionCopy.noFiles, loadingText: mentionCopy.loading, - renderItem: (item) => ( - <> -