diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index c9ac1b3cd77..f6582ab142d 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -226,6 +226,13 @@ "ownerSymbol": "AppUpdateProvider", "count": 1 }, + { + "implementation": "src/renderer/features/conversation/controller/use-composer-attachments.ts", + "symbol": "useComposerAttachments", + "owner": "src/renderer/features/conversation/ui/composer-staging-provider.tsx", + "ownerSymbol": "ComposerStagingProvider", + "count": 1 + }, { "implementation": "src/renderer/features/conversation/controller/use-conversation-controller.ts", "symbol": "useConversationController", @@ -306,13 +313,16 @@ ], "featurePrivateModules": [ "src/renderer/features/conversation/controller/transcript-reading-position-controller.tsx", + "src/renderer/features/conversation/controller/use-composer-quotes.ts", "src/renderer/features/conversation/controller/use-conversation-observation.ts", + "src/renderer/features/conversation/model/composer-staging-binding.ts", "src/renderer/features/conversation/model/conversation-workspace.ts", "src/renderer/features/conversation/model/session-events.ts", "src/renderer/features/conversation/model/session-ui-reads.ts", "src/renderer/features/conversation/model/session-ui-state.ts", "src/renderer/features/conversation/model/session-workspace-actions.ts", "src/renderer/features/conversation/model/transcript-commands.ts", + "src/renderer/features/conversation/ui/composer-staging-context.ts", "src/renderer/features/conversation/ui/conversation-context.ts" ], "legacyAppShell": { @@ -620,7 +630,6 @@ "src/renderer/app-shell.tsx": { "importDeclarations": 53, "bridgePaths": { - "window.maka.attachments": 1, "window.maka.attachments.readBytes": 1, "window.maka.diagnostics.copyPreviousMainProcessInterruption": 1, "window.maka.diagnostics.copyReport": 1, @@ -644,7 +653,6 @@ "useAppShellSessionUiReads": 1, "useAppShellSessionWorkspace": 1, "useAppShellTurnPresentation": 1, - "useComposerAttachments": 1, "useEffect": 3, "useLayoutEffect": 1, "useNewTaskChoice": 1, @@ -743,7 +751,7 @@ "react": 1 }, "importSpecifiers": 80, - "nonTriviaTokens": 9769 + "nonTriviaTokens": 9614 }, "src/renderer/use-app-shell-session-list.ts": { "importDeclarations": 4, @@ -945,6 +953,7 @@ "./application/contracts/new-task-reload-intent.js": 1, "./application/contracts/session-inspector/latest-request-usage.js": 1, "./composer-mentions.js": 1, + "./features/conversation/index.js": 1, "@maka/ui": 1, "react": 1 } diff --git a/apps/desktop/src/main/__tests__/app-shell-revision-resend.test.ts b/apps/desktop/src/main/__tests__/app-shell-revision-resend.test.ts index 6ea6916e2c6..06a0196dfa4 100644 --- a/apps/desktop/src/main/__tests__/app-shell-revision-resend.test.ts +++ b/apps/desktop/src/main/__tests__/app-shell-revision-resend.test.ts @@ -32,6 +32,7 @@ * the production submit path must fail this test. */ +import { ComposerStagingFixture } from './composer-staging-fixture.js'; import assert from 'node:assert/strict'; import { afterEach, test } from 'node:test'; import { act, createElement, createRef } from 'react'; @@ -39,7 +40,6 @@ import { createRoot, type Root } from 'react-dom/client'; import { parseHTML } from 'linkedom'; import type { StoredMessage } from '@maka/core/session'; import { - AstryxLocaleProvider, LocaleProvider, type ComposerHandle, type ComposerSendMetadata, @@ -247,13 +247,16 @@ async function mountRevisionWorld(): Promise { revisionDraftRef, composerRef: composer, retractedWorkspaceReferencesRef: { current: {} }, - hasPendingContext: false, - hasStagedQuotes: false, - submittableAttachments: undefined, - directoryOptions: {}, - quotesForSend: () => undefined, - clearSubmittedContext: () => {}, - clearQuotes: () => {}, + captureStaging: () => ({ + draftKey: 'draft', + hasPendingContext: false, + hasStagedQuotes: false, + submittableAttachments: undefined, + directoryOptions: {}, + quotesForSend: () => undefined, + clearSubmittedContext: () => {}, + clearQuotes: () => {}, + }), prepareRevisionSend: (text: string) => revisionActions.prepareRevisionSend(text), completeRevisionCopyAttempt: completeTurnRevisionCopyAttempt, parseSlashCommand: parseDesktopSlashCommand, @@ -308,7 +311,8 @@ async function mountRevisionWorld(): Promise { root.render( createElement(LocaleProvider, { locale: 'en', - children: createElement(AstryxLocaleProvider, { + children: createElement(ComposerStagingFixture, { + draftKey: SESSION_1, children: createElement(ChatComposerRegion, { composerRef: composer, active: true, @@ -325,7 +329,8 @@ async function mountRevisionWorld(): Promise { respondToUserForm: () => undefined, stop: () => undefined, onOpenContextUsage: () => undefined, - directoryComposerProps: {}, + canStageContext: true, + contextPickEnabled: true, directoryPickerEnabled: false, onSend: (text: string, metadata?: ComposerSendMetadata) => { submittedTexts.push(text); diff --git a/apps/desktop/src/main/__tests__/chat-composer-region-draft-handoff.test.ts b/apps/desktop/src/main/__tests__/chat-composer-region-draft-handoff.test.ts index 15dfa76c70c..983bccf116d 100644 --- a/apps/desktop/src/main/__tests__/chat-composer-region-draft-handoff.test.ts +++ b/apps/desktop/src/main/__tests__/chat-composer-region-draft-handoff.test.ts @@ -17,12 +17,13 @@ * under the License. */ +import { ComposerStagingFixture } from './composer-staging-fixture.js'; import assert from 'node:assert/strict'; import { afterEach, test } from 'node:test'; import { act, createElement, createRef } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { parseHTML } from 'linkedom'; -import { AstryxLocaleProvider, type ComposerHandle, LocaleProvider } from '@maka/ui'; +import { type ComposerHandle, LocaleProvider } from '@maka/ui'; import { ChatComposerRegion } from '../../renderer/chat-composer-region.js'; import { markNewTaskReloadIntent, @@ -138,11 +139,13 @@ async function mountRegion(): Promise<{ LocaleProvider, { locale: 'en', - children: createElement(AstryxLocaleProvider, { + children: createElement(ComposerStagingFixture, { + draftKey: activeId ?? 'new-task', children: createElement(ChatComposerRegion, { composerRef: composer, onOpenContextUsage: () => undefined, - directoryComposerProps: {}, + canStageContext: true, + contextPickEnabled: true, directoryPickerEnabled: false, active: true, diff --git a/apps/desktop/src/main/__tests__/composer-directories.test.ts b/apps/desktop/src/main/__tests__/composer-directories.test.ts index f7272a52109..4b751890d8f 100644 --- a/apps/desktop/src/main/__tests__/composer-directories.test.ts +++ b/apps/desktop/src/main/__tests__/composer-directories.test.ts @@ -17,13 +17,13 @@ * under the License. */ +import { useComposerAttachments } from '../../renderer/features/conversation/testing.js'; import assert from 'node:assert/strict'; import { afterEach, test } from 'node:test'; import { act, createElement } from 'react'; import { LocaleProvider } from '@maka/ui'; import { normalizeSessionSendCommand } from '../permission-response-guard.js'; import { - useComposerAttachments, type ComposerAttachmentService, } from '../../renderer/features/conversation/index.js'; import { cleanupFakeDom, installReactRenderer } from './fake-dom.js'; diff --git a/apps/desktop/src/main/__tests__/composer-mentions.test.ts b/apps/desktop/src/main/__tests__/composer-mentions.test.ts index 9904821b557..8b45fdbe89c 100644 --- a/apps/desktop/src/main/__tests__/composer-mentions.test.ts +++ b/apps/desktop/src/main/__tests__/composer-mentions.test.ts @@ -17,6 +17,7 @@ * under the License. */ +import { ComposerStagingFixture } from './composer-staging-fixture.js'; import { strict as assert } from 'node:assert'; import { test, type TestContext } from 'node:test'; import { parseHTML } from 'linkedom'; @@ -132,11 +133,14 @@ function installCatalogRenderer(t: TestContext) { services, children: createElement(SessionCatalogContext.Provider, { value: sessionCatalog, - children: createElement(ComposerMentionsProvider, { + children: createElement(ComposerStagingFixture, { + draftKey: sessionId, + children: createElement(ComposerMentionsProvider, { sessionId, projectPath, skillCatalogRevision, children: createElement(Consumer, { sessionId }), + }), }), }), }), diff --git a/apps/desktop/src/main/__tests__/composer-staging-fixture.ts b/apps/desktop/src/main/__tests__/composer-staging-fixture.ts new file mode 100644 index 00000000000..8d131f9f013 --- /dev/null +++ b/apps/desktop/src/main/__tests__/composer-staging-fixture.ts @@ -0,0 +1,54 @@ +/* + * 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 { createElement, useMemo, type ReactNode } from 'react'; +import { AstryxLocaleProvider, ToastProvider } from '@maka/ui'; +import { + ComposerStagingProvider, ComposerStagingServicesProvider, createComposerStagingCommands, + type ComposerStagingCommands, type ComposerStagingServices, +} from '../../renderer/features/conversation/index.js'; + +const emptyServices: ComposerStagingServices = { + pickFiles: async () => ({ ok: false, reason: 'cancelled' }), + previewApproval: async () => ({ ok: false, reason: 'unavailable' }), +}; + +/** Real owner with inert I/O, shared by renderer integration tests. */ +export function ComposerStagingFixture(props: { + readonly draftKey: string; + readonly directoryHostId?: string; + readonly commands?: ComposerStagingCommands; + readonly services?: ComposerStagingServices; + readonly children?: ReactNode; +}) { + const fallbackCommands = useMemo(createComposerStagingCommands, []); + return createElement(AstryxLocaleProvider, { + children: createElement(ToastProvider, { + children: createElement(ComposerStagingServicesProvider, { + services: props.services ?? emptyServices, + children: createElement(ComposerStagingProvider, { + commands: props.commands ?? fallbackCommands, + draftKey: props.draftKey, + directoryHostId: props.directoryHostId, + children: props.children, + }), + }), + }), + }); +} diff --git a/apps/desktop/src/main/__tests__/composer-staging-owner.test.ts b/apps/desktop/src/main/__tests__/composer-staging-owner.test.ts new file mode 100644 index 00000000000..b4d7add2b60 --- /dev/null +++ b/apps/desktop/src/main/__tests__/composer-staging-owner.test.ts @@ -0,0 +1,393 @@ +/* + * 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, createRef, Fragment, Profiler, StrictMode, useLayoutEffect } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { parseHTML } from 'linkedom'; +import { ChatSurfaceLayout, LocaleProvider, type ComposerHandle } from '@maka/ui'; +import type { AttachmentRef, DirectoryReference } from '@maka/core/events'; +import { + createComposerStagingCommands, createRevisionAwareOnSend, createStagedFollowUp, + StagedComposer, StagedQuoteChatView, PlanProvider, PlanServicesProvider, + type PlanServices, type RevisionSendPorts, type ComposerStagingSubmission, +} from '../../renderer/features/conversation/index.js'; +import { useComposerStaging } from '../../renderer/features/conversation/testing.js'; +import { createAppShellChatActions } from '../../renderer/app-shell-chat-actions.js'; +import { createAppShellRevisionActions, type TurnRevisionDraft } from '../../renderer/app-shell-revision-actions.js'; +import { createActionsDeps, createTransientState, EMPTY_SKILL_INVOCATION } from './app-shell-chat-actions-fixture.js'; +import { ComposerStagingFixture } from './composer-staging-fixture.js'; + +const saved = Object.fromEntries([ + 'window', 'document', 'Element', 'HTMLBRElement', 'sessionStorage', 'HTMLElement', 'HTMLIFrameElement', 'Event', 'Node', 'CSS', + 'ResizeObserver', 'MutationObserver', 'IntersectionObserver', 'matchMedia', + 'requestAnimationFrame', 'cancelAnimationFrame', 'IS_REACT_ACT_ENVIRONMENT', +].map((key) => [key, Object.getOwnPropertyDescriptor(globalThis, key)])); +let root: Root | undefined; +afterEach(async () => { + if (root) await act(() => root?.unmount()); + root = undefined; + for (const [key, descriptor] of Object.entries(saved)) { + if (descriptor) Object.defineProperty(globalThis, key, descriptor); + else Reflect.deleteProperty(globalThis, key); + } +}); + +const unusedPlanCall = async (): Promise => { throw new Error('No Session Plan should be queried'); }; +const planServices: PlanServices = { + getPlanState: unusedPlanCall, requestPlanRevision: unusedPlanCall, + approvePlan: unusedPlanCall, resumePlan: unusedPlanCall, abandonPlanExecution: unusedPlanCall, + subscribeEvents: () => () => {}, subscribePlanChanges: () => () => {}, +}; +const attachment: AttachmentRef = { + kind: 'doc', name: 'first.txt', mimeType: 'text/plain', bytes: 3, + ref: { kind: 'session_file', sessionId: 'source', relativePath: 'first.txt' }, +}; + +async function mount() { + const { window, document } = parseHTML('
'); + const matchMedia = (media: string) => ({ + media, matches: false, onchange: null, + addListener() {}, removeListener() {}, addEventListener() {}, removeEventListener() {}, + dispatchEvent: () => false, + }); + const getSelection = () => ({ + rangeCount: 0, isCollapsed: true, anchorNode: null, focusNode: null, + removeAllRanges() {}, addRange() {}, getRangeAt() { throw new Error('no range'); }, + }) as unknown as Selection; + Object.assign(document, { getSelection }); + document.createRange = () => ({ + selectNodeContents() {}, collapse() {}, cloneRange() { return this; }, + }) as unknown as Range; + const storage = new Map(); + Object.assign(window, { + matchMedia, scrollTo() {}, getSelection, + getComputedStyle: () => ({ direction: 'ltr', writingMode: 'horizontal-tb', getPropertyValue: () => '' }), + }); + Object.assign(globalThis, { + window, document, matchMedia, Element: window.Element, HTMLBRElement: window.HTMLBRElement, + sessionStorage: { getItem: (key: string) => storage.get(key) ?? null, + setItem: (key: string, value: string) => storage.set(key, value), + removeItem: (key: string) => storage.delete(key) }, + HTMLElement: window.HTMLElement, + MutationObserver: window.MutationObserver, + ResizeObserver: class { observe() {} unobserve() {} disconnect() {} }, + IntersectionObserver: class { observe() {} unobserve() {} disconnect() {} }, + HTMLIFrameElement: window.HTMLIFrameElement ?? class HTMLIFrameElement {}, + Event: window.Event, Node: window.Node, CSS: { escape: (value: string) => value }, + requestAnimationFrame: (callback: FrameRequestCallback) => setTimeout(callback, 0), + cancelAnimationFrame: (id: number) => clearTimeout(id), IS_REACT_ACT_ENVIRONMENT: true, + }); + const container = document.querySelector('#root')!; + root = createRoot(container); + const commands = createComposerStagingCommands(); + const composer = createRef(); + let staging!: ReturnType; + function StagingProbe() { + const current = useComposerStaging(); + useLayoutEffect(() => { staging = current; }); + return null; + } + let frameRenders = 0; + let siblingRenders = 0; + let transcriptCommits = 0; + function Sibling() { + siblingRenders += 1; + return createElement('aside', null, 'unrelated shell chrome'); + } + function Frame({ draftKey, visible }: { draftKey: string; visible: boolean }) { + frameRenders += 1; + return createElement(ChatSurfaceLayout, { + scrollToBottomLabel: 'Scroll to bottom', + composer: createElement(StagedComposer, { + ref: composer, draftKey, hidden: !visible, onSend: () => {}, onStop: () => {}, + stagingEnabled: true, canStageContext: true, contextPickEnabled: true, + directoryPickerEnabled: true, allowAttachmentOnlySend: true, + }), + children: createElement('div', null, createElement(Sibling), visible && createElement(Profiler, { + id: 'transcript', onRender: () => { transcriptCommits += 1; }, + children: createElement(StagedQuoteChatView, { messages: [], onNew: () => {}, scrollBehavior: 'auto' }), + })), + }); + } + const render = async (draftKey = 'draft-a', hostId = 'host-a', visible = true) => { + await act(async () => root!.render(createElement(StrictMode, { + children: createElement(LocaleProvider, { locale: 'en', children: + createElement(ComposerStagingFixture, { commands, draftKey, directoryHostId: hostId, children: + createElement(PlanServicesProvider, { services: planServices, children: + createElement(PlanProvider, { session: undefined, children: createElement(Fragment, null, + createElement(StagingProbe), createElement(Frame, { draftKey, visible }), + ) }), + }), + }), + }), + }))); + }; + await render(); + return { commands, composer, container, render, + stage(content: { attachments?: readonly AttachmentRef[]; directoryReferences?: readonly DirectoryReference[] }) { + const key = commands.captureSubmission().draftKey; + staging.restoreAttachments(key, content.attachments ?? []); + staging.restoreDirectories(key, content.directoryReferences ?? []); + }, + editQuote: (index: number, note: string) => staging.composerQuoteProps(true).onEditQuoteComment!(index, note), + counts: () => [frameRenders, siblingRenders], transcriptCommits: () => transcriptCommits }; +} + +test('real staging readers update without rendering shell/frame; Composer survives section and Session switches', async () => { + const view = await mount(); + const before = view.counts(); + const transcriptBefore = view.transcriptCommits(); + const input = view.container.querySelector('[data-maka-contract="composer-input"] [role="textbox"]'); + assert.ok(input, 'production Composer editor is mounted'); + await act(() => view.composer.current!.setText('unfinished draft')); + await act(() => view.commands.addQuote({ text: 'quoted passage', label: 'Quote A' })); + assert.match(view.container.querySelector('.maka-composer-quote-token')?.textContent ?? '', /Quote A/); + assert.ok(view.transcriptCommits() > transcriptBefore, 'the real transcript quote reader receives updates'); + await act(() => view.stage({ + attachments: [attachment], directoryReferences: [{ hostId: 'host-a', path: '/work/source' }], + })); + assert.match([...view.container.querySelectorAll('.maka-composer-attachment-token')].map((node) => node.textContent).join(' '), /first.txt/); + assert.match(view.container.textContent ?? '', /source/); + assert.deepEqual(view.counts(), before, 'staging has no subscription in the ancestor or unrelated sibling'); + assert.ok(view.container.querySelector('[data-maka-contract="composer-input"] [role="textbox"]') === input, 'Composer editor node must survive'); + assert.equal(view.composer.current!.getText(), 'unfinished draft'); + await view.render('draft-a', 'host-a', false); + assert.ok(view.container.querySelector('[data-maka-contract="composer-input"] [role="textbox"]') === input, 'Composer editor node must survive'); + await view.render('draft-b'); + assert.ok(view.container.querySelector('[data-maka-contract="composer-input"] [role="textbox"]') === input, 'Composer editor node must survive'); + assert.equal(view.container.querySelector('.maka-composer-quote-token'), null); + await view.render('draft-a'); + assert.ok(view.container.querySelector('[data-maka-contract="composer-input"] [role="textbox"]') === input, 'Composer editor node must survive'); + assert.equal(view.composer.current!.getText(), 'unfinished draft'); + assert.match(view.container.querySelector('.maka-composer-quote-token')?.textContent ?? '', /Quote A/); +}); + +test('captured staging clears its original draft and Host, preserving newer context', async () => { + const view = await mount(); + await act(() => view.stage({ + attachments: [attachment], directoryReferences: [{ hostId: 'host-a', path: '/work/first' }], + })); + let submitted!: ComposerStagingSubmission; + await act(() => { + view.commands.addQuote({ text: 'same-tick quote' }); + submitted = view.commands.captureSubmission(); + view.commands.addQuote({ text: 'later quote' }); + }); + assert.deepEqual(submitted.quotesForSend()?.map((quote) => quote.text), ['same-tick quote']); + await act(() => view.stage({ + attachments: [{ ...attachment, name: 'later.txt', ref: { kind: 'session_file', sessionId: 'source', relativePath: 'later.txt' } }], + directoryReferences: [{ hostId: 'host-a', path: '/work/later' }], + })); + await view.render('draft-b', 'host-b'); + await act(() => view.commands.addQuote({ text: 'other draft' })); + await act(() => { + submitted.clearSubmittedContext(submitted.submittableAttachments); + submitted.clearQuotes(); + }); + assert.deepEqual(view.commands.captureSubmission().quotesForSend()?.map((quote) => quote.text), ['other draft']); + await view.render('draft-a', 'host-b'); + assert.deepEqual(view.commands.captureSubmission().directoryOptions, {}, 'Host B cannot see Host A directories'); + await view.render('draft-a', 'host-a'); + const remaining = view.commands.captureSubmission(); + assert.deepEqual(remaining.submittableAttachments?.map((item) => item.displayName), ['later.txt']); + assert.deepEqual(remaining.quotesForSend()?.map((quote) => quote.text), ['later quote']); + assert.deepEqual(remaining.directoryOptions.directoryReferences?.map((item) => item.path), ['/work/later']); + await act(() => root!.unmount()); + root = undefined; + assert.throws(() => view.commands.captureSubmission(), /not mounted/); +}); + +for (const accepted of [true, false]) { + test(`production send captures staging before awaiting; accepted=${accepted}`, async () => { + const view = await mount(); + await act(() => view.commands.addQuote({ text: 'submitted quote', comment: 'original note' })); + await act(() => view.commands.addQuote({ text: 'unchanged quote' })); + let resolve!: (accepted: boolean) => void; + const sendResult = new Promise((done) => { resolve = done; }); + let sentQuotes: readonly { text: string; comment?: string }[] | undefined; + const ports: RevisionSendPorts<{ sourceSessionId: string; draftSessionId: string }> = { + shellCopy: { + sideChatUnavailableTitle: '', sideChatUnavailableDescription: '', + sideChatContextPendingTitle: '', sideChatContextPendingDescription: '', + swarmModeEnabledTitle: '', swarmModeDisabledTitle: '', swarmModeStatusDescription: '', + graphModeEnabledTitle: '', graphModeDisabledTitle: '', graphModeStatusDescription: '', + graphHistoryTitle: '', graphHistoryDescription: '', + }, + toastApi: { info() {} }, activeIdRef: { current: 'draft-a' }, revisionDraftRef: { current: null }, + composerRef: view.composer, retractedWorkspaceReferencesRef: { current: {} }, + captureStaging: view.commands.captureSubmission, + prepareRevisionSend: async () => true, + send: async (_text, _files, options) => { sentQuotes = options?.quotes; return sendResult; }, + enqueueFollowUp: async () => false, settleNewTaskImageNoticeOwner() {}, + commitRevisionDraft() {}, completeRevisionCopyAttempt() {}, parseSlashCommand: () => null, + mergeWorkspaceReferences: () => [], rebaseWorkspaceFileReferences: () => [], + revisionUnavailableCopy: { revisionUnavailableTitle: '', revisionAttachmentsUnsupported: '', revisionCommandUnsupported: '' }, + compactSession: async () => false, resolveNewTaskSessionHandler: () => () => {}, openSideChat() {}, + getActiveOrchestrationMode: () => 'default', setOrchestrationModeActive: async () => false, + }; + const send = createRevisionAwareOnSend({ ...ports, setNewTaskSendPending() {} }); + const pending = send('message'); + await act(() => { view.editQuote(0, 'edited during send'); view.commands.addQuote({ text: 'later quote' }); }); + await view.render('draft-b'); + await act(() => view.commands.addQuote({ text: 'other draft' })); + await act(async () => { resolve(accepted); await pending; }); + assert.deepEqual(sentQuotes, [{ text: 'submitted quote', comment: 'original note' }, { text: 'unchanged quote' }]); + assert.deepEqual(view.commands.captureSubmission().quotesForSend()?.map((quote) => quote.text), ['other draft']); + await view.render('draft-a'); + assert.deepEqual(view.commands.captureSubmission().quotesForSend(), [ + { text: 'submitted quote', comment: 'edited during send' }, + ...accepted ? [] : [{ text: 'unchanged quote' }], + { text: 'later quote' }, + ]); + }); +} + +for (const mode of ['queue', 'steer'] as const) { + for (const outcome of ['accepted', 'refused', 'error'] as const) { + test(`Shell follow-up uses captured staging through production enqueue: ${mode}/${outcome}`, async () => { + const view = await mount(); + const activeIdRef = { current: 'draft-a' }; + const transient = createTransientState(); + const errors: unknown[] = []; + const submitted: Array> = []; + let release!: () => void; + const admission = new Promise((resolve) => { release = resolve; }); + Object.assign(window, { maka: { sessions: { + submitMessage: async (...args: Parameters) => { + submitted.push(args); + await admission; + if (outcome === 'error') throw new Error('Host disconnected'); + if (outcome === 'refused') return { ok: false, reason: 'skill_invocation_failed', skillInvocation: { + loaded: [], failed: [{ request: 'missing', reason: 'not_found' }], receipts: [], + } }; + return { ok: true, disposition: mode === 'queue' ? 'followup' : 'steering', + attachments: [], inlineReferences: [], skillInvocation: EMPTY_SKILL_INVOCATION }; + }, + } } }); + const actions = createAppShellChatActions({ + ...createActionsDeps(), ...transient.deps, activeIdRef, getRunningTurnId: () => 'running-turn', + }); + const enqueue = createStagedFollowUp({ + captureStaging: view.commands.captureSubmission, enqueueMessage: actions.enqueueMessage, + onError: (sessionId, error) => errors.push({ sessionId, error }), + }); + await act(() => { + view.stage({ attachments: [attachment], directoryReferences: [{ hostId: 'host-a', path: '/work/original' }] }); + view.commands.addQuote({ text: 'edited quote', comment: 'sent note' }); + view.commands.addQuote({ text: 'unchanged quote' }); + }); + const pending = enqueue('draft-a', 'read @src/app.ts', mode, { + workspaceFileReferences: [{ value: '@src/app.ts', start: 5 }], + }); + assert.equal(submitted.length, 1); + const [sessionId, placement, command] = submitted[0]!; + assert.equal(sessionId, 'draft-a'); + assert.equal(placement, mode === 'queue' ? 'next_turn' : 'current_turn'); + assert.deepEqual(command.retainedAttachments, [attachment]); + assert.deepEqual(command.directoryReferences, [{ hostId: 'host-a', path: '/work/original' }]); + assert.deepEqual(command.quotes, [{ text: 'edited quote', comment: 'sent note' }, { text: 'unchanged quote' }]); + assert.deepEqual(command.workspaceFileReferences, [{ value: '@src/app.ts', start: 5 }]); + await act(() => { + view.editQuote(0, 'unsent edited note'); + view.commands.addQuote({ text: 'later quote' }); + }); + await view.render('draft-b', 'host-b'); + activeIdRef.current = 'draft-b'; + await act(() => view.commands.addQuote({ text: 'other draft' })); + await act(async () => { + release(); + assert.equal(await pending, outcome === 'accepted'); + }); + assert.equal(errors.length, outcome === 'error' ? 1 : 0); + assert.deepEqual(view.commands.captureSubmission().quotesForSend(), [{ text: 'other draft' }]); + assert.deepEqual(command.quotes, [{ text: 'edited quote', comment: 'sent note' }, { text: 'unchanged quote' }]); + await view.render('draft-a', 'host-a'); + const remaining = view.commands.captureSubmission(); + assert.deepEqual(remaining.quotesForSend(), [ + { text: 'edited quote', comment: 'unsent edited note' }, + ...outcome === 'accepted' ? [] : [{ text: 'unchanged quote' }], + { text: 'later quote' }, + ]); + assert.equal(remaining.submittableAttachments?.length ?? 0, outcome === 'accepted' ? 0 : 1); + assert.equal(remaining.directoryOptions.directoryReferences?.length ?? 0, outcome === 'accepted' ? 0 : 1); + assert.equal(transient.rows.size, outcome === 'accepted' ? 1 : 0); + }); + } +} + +test('Shell follow-up without quotes still submits through the production enqueue action', async () => { + const view = await mount(); + const submitted: Array> = []; + Object.assign(window, { maka: { sessions: { + submitMessage: async (...args: Parameters) => { + submitted.push(args); + return { ok: true, disposition: 'followup', attachments: [], inlineReferences: [], skillInvocation: EMPTY_SKILL_INVOCATION }; + }, + } } }); + const transient = createTransientState(); + const actions = createAppShellChatActions({ ...createActionsDeps(), ...transient.deps, activeIdRef: { current: 'draft-a' } }); + const enqueue = createStagedFollowUp({ + captureStaging: view.commands.captureSubmission, enqueueMessage: actions.enqueueMessage, + onError: (_sessionId, error) => assert.fail(String(error)), + }); + await act(async () => { assert.equal(await enqueue('draft-a', 'plain follow-up', 'queue'), true); }); + assert.equal(submitted[0]?.[2].quotes, undefined); + assert.equal([...transient.rows.values()][0]?.quotes, undefined); + assert.equal(view.commands.captureSubmission().quotesForSend(), undefined); +}); + +for (const context of ['attachment', 'directory'] as const) { + test(`revision entry checks the current owner after ${context} staging and draft switches`, async () => { + const view = await mount(); + const activeIdRef = { current: 'draft-a' }; + const revisionDraftRef = { current: null as TurnRevisionDraft | null }; + const currentRevision = () => revisionDraftRef.current; + const actions = createAppShellRevisionActions({ + uiLocale: 'en', activeIdRef, captureSelection: () => () => true, composerRef: view.composer, + readMessages: () => [{ type: 'user', id: 'message', turnId: `guard-${context}`, text: 'original', ts: 1 }], + hasPendingAttachments: () => view.commands.captureSubmission().hasPendingContext, + openSessionInChat() {}, refreshSessions: async () => [], + commitRevisionDraft: (draft) => { revisionDraftRef.current = draft; }, revisionDraftRef, + toastApi: { info() {}, error: () => assert.fail('unexpected revision error') }, + }); + // The same action instance must read staging at invocation, not at construction. + await act(() => view.stage(context === 'attachment' + ? { attachments: [attachment] } + : { directoryReferences: [{ hostId: 'host-a', path: '/work/source' }] })); + await act(() => actions.beginEditUserMessage(`guard-${context}`)); + assert.equal(revisionDraftRef.current, null, 'pending context blocks revision entry'); + await view.render('draft-b', 'host-b'); + activeIdRef.current = 'draft-b'; + await act(() => actions.beginEditUserMessage(`guard-${context}`)); + assert.equal(currentRevision()?.sourceSessionId, 'draft-b', 'another draft is not blocked'); + revisionDraftRef.current = null; + await view.render('draft-a', 'host-a'); + activeIdRef.current = 'draft-a'; + await act(() => actions.beginEditUserMessage(`guard-${context}`)); + assert.equal(revisionDraftRef.current, null, 'original draft is still blocked'); + await act(() => { + const captured = view.commands.captureSubmission(); + captured.clearSubmittedContext(captured.submittableAttachments); + }); + await act(() => actions.beginEditUserMessage(`guard-${context}`)); + assert.equal(currentRevision()?.sourceSessionId, 'draft-a', 'cleanup releases the guard'); + }); +} diff --git a/apps/desktop/src/main/__tests__/guest-turn-requests.test.ts b/apps/desktop/src/main/__tests__/guest-turn-requests.test.ts index 27f9a6f9275..463528cca00 100644 --- a/apps/desktop/src/main/__tests__/guest-turn-requests.test.ts +++ b/apps/desktop/src/main/__tests__/guest-turn-requests.test.ts @@ -17,13 +17,14 @@ * under the License. */ +import { ComposerStagingFixture } from './composer-staging-fixture.js'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import { afterEach, test } from 'node:test'; import { act, createElement, createRef } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { parseHTML } from 'linkedom'; -import { AstryxLocaleProvider, type ComposerHandle, LocaleProvider, ToastProvider } from '@maka/ui'; +import { type ComposerHandle, LocaleProvider, ToastProvider } from '@maka/ui'; import type { SessionTurnAccessRequest } from '@maka/runtime-host/protocol'; import { ChatComposerRegion } from '../../renderer/chat-composer-region.js'; import { @@ -156,7 +157,8 @@ async function mountShell(services: SessionCollaborationServices) { await act(async () => { root.render(createElement(LocaleProvider, { locale: 'en', - children: createElement(AstryxLocaleProvider, { + children: createElement(ComposerStagingFixture, { + draftKey: sessionId, children: createElement(ToastProvider, { children: createElement(SessionCollaborationServicesProvider, { services, @@ -167,7 +169,8 @@ async function mountShell(services: SessionCollaborationServices) { composerRef: composer, guest, onOpenContextUsage: () => undefined, - directoryComposerProps: {}, + canStageContext: true, + contextPickEnabled: true, directoryPickerEnabled: false, active: true, onboardingComposerHidden: false, @@ -186,7 +189,6 @@ async function mountShell(services: SessionCollaborationServices) { throw new Error('an owner send must not run for a Guest Session'); }, onStop: () => {}, - onPickAttachments: () => {}, }), }), }), 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 3fe25747c1c..3ec86613cc7 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 @@ -17,6 +17,7 @@ * under the License. */ +import { useComposerAttachments, useComposerQuotes } from '../../renderer/features/conversation/testing.js'; import assert from 'node:assert/strict'; import { afterEach, test } from 'node:test'; import { act, createElement } from 'react'; @@ -28,8 +29,6 @@ import { LocaleProvider } from '@maka/ui'; import { NEW_TASK_PENDING_KEY } from '../../renderer/pending-items.js'; import { getDesktopConversationCopy } from '../../renderer/application/contracts/conversation-copy.js'; import { - useComposerAttachments, - useComposerQuotes, type ComposerAttachmentService, } from '../../renderer/features/conversation/index.js'; import { diff --git a/apps/desktop/src/main/__tests__/session-query-gate.test.ts b/apps/desktop/src/main/__tests__/session-query-gate.test.ts index 41a3e6f4dae..13a4b094f49 100644 --- a/apps/desktop/src/main/__tests__/session-query-gate.test.ts +++ b/apps/desktop/src/main/__tests__/session-query-gate.test.ts @@ -18,6 +18,7 @@ */ import { PlanServicesProvider } from '../../renderer/features/conversation/index.js'; +import { ComposerStagingFixture } from './composer-staging-fixture.js'; import assert from 'node:assert/strict'; import { afterEach, test } from 'node:test'; import { deferred } from '@maka/core/test-only/async-primitives'; @@ -108,10 +109,13 @@ test('query blocking pauses, resumes, and fences automatic Skills and Plan reads services, children: createElement(SessionCatalogContext.Provider, { value: catalog, - children: createElement(ComposerMentionsProvider, { + children: createElement(ComposerStagingFixture, { + draftKey: session.id, + children: createElement(ComposerMentionsProvider, { skillCatalogRevision: 0, sessionId: session.id, children: createElement(PlanServicesProvider, { services: window.maka.sessions, children: createElement(QueryProbe) }), + }), }), }), }), diff --git a/apps/desktop/src/main/__tests__/session-reference-composer.test.ts b/apps/desktop/src/main/__tests__/session-reference-composer.test.ts index 4f0a19e3c79..c01db7c48bd 100644 --- a/apps/desktop/src/main/__tests__/session-reference-composer.test.ts +++ b/apps/desktop/src/main/__tests__/session-reference-composer.test.ts @@ -17,7 +17,7 @@ * under the License. */ -import { stubConversationServices } from '../../renderer/features/conversation/testing.js'; +import { stubConversationServices, useComposerQuotes } from '../../renderer/features/conversation/testing.js'; import assert from 'node:assert/strict'; import { afterEach, test } from 'node:test'; import { act, createElement } from 'react'; @@ -27,7 +27,6 @@ import type { SessionSnapshot } from '@maka/core/session-reference'; import { ConversationServicesProvider, type ConversationServices, - useComposerQuotes, useSessionReferenceComposer, } from '../../renderer/features/conversation/index.js'; diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index 80c2e2018cb..a9da03d28cb 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -30,7 +30,6 @@ import { type SetStateAction, } from 'react'; import type { - FollowUpMode, InlineReference, QuoteRef, } from '@maka/core/events'; @@ -45,7 +44,6 @@ import { hasSettledInitialOnboarding } from '@maka/core/onboarding-milestone'; import { ChatSurfaceLayout, type ComposerHandle, - type ComposerSendMetadata, type ComposerSlashCommandOption, type MakaUriDest, MakaUriContext, @@ -157,7 +155,6 @@ import { useTurnActionRegistry } from './use-turn-action-registry'; import { desktopSlashCommandPresentation, useActiveExecutionBoundary, - useComposerAttachments, useNewTaskChoice, useShellChatModel, } from './features/conversation/index.js'; @@ -349,37 +346,7 @@ function AppShellContent({ : (taskEntry.selectors.selectedHost?.kind === 'local' ? taskEntry.selectors.target?.hostId : undefined); - const { - pendingAttachments, - submittableAttachments, - hasPendingContext, - directoryOptions, - directoryComposerProps, - pickAttachments, - attachFilePaths, - restoreAttachments, - restoreDirectories, - restoreQuotes, - removeAttachment, - clearSubmittedContext, - imageNoticeLifecycle, - pendingQuotes, - hasStagedQuotes, - quotesForSend, - addQuote, - clearQuotes, - composerQuoteProps, - chatViewQuoteProps, - } = useComposerAttachments({ - draftKey: attachmentDraftKey, - directoryHostId, - toastApi, - service: window.maka.attachments, - imageNotice: { - supportsVision: () => composerSupportsVision, - notify: toastApi.info, - }, - }); + const composerStaging = useMemo(Conversation.createComposerStagingCommands, []); // Held for the whole of sendOwningItsTarget; see ChatComposerRegion. const [newTaskSendPending, setNewTaskSendPending] = useState(false); @@ -1015,7 +982,7 @@ function AppShellContent({ projectPath: projectInfo?.projectPath, }); const openNewTaskSurface = useCallback(() => { - imageNoticeLifecycle.reset(NEW_TASK_PENDING_KEY); + composerStaging.resetImageNotice(NEW_TASK_PENDING_KEY); const ownerToken = startNewSession(); // Only Plan resets: a new task starts out of Plan, in whatever // orchestration the last one was set to. @@ -1026,7 +993,7 @@ function AppShellContent({ // there so the user can start typing immediately. window.requestAnimationFrame(() => composerRef.current?.focus()); return ownerToken; - }, [imageNoticeLifecycle, setNavSelection, setSearchScrollTarget, startNewSession]); + }, [composerStaging, setNavSelection, setSearchScrollTarget, startNewSession]); const createSession = useCallback(async () => { openNewTaskSurface(); @@ -1065,8 +1032,6 @@ function AppShellContent({ // Refresh only; Desktop Main re-reads the authoritative default before // constructing the Runtime Host preview target. newSessionPermissionMode, - onAddQuote: addQuote, - pendingQuotes, }; const hasModalOpen = overlays.selectors.anyModalOpen || sharedSessionDialog.isOpen; @@ -1226,7 +1191,7 @@ function AppShellContent({ captureSelection, composerRef, readMessages, - hasPendingAttachments: () => hasPendingContext, + hasPendingAttachments: () => composerStaging.captureSubmission().hasPendingContext, openSessionInChat, refreshSessions, commitRevisionDraft, @@ -1241,34 +1206,20 @@ function AppShellContent({ function settleNewTaskImageNoticeOwner(sourceSessionId?: string) { const createdSessionId = activeIdRef.current; if (!sourceSessionId && createdSessionId) - imageNoticeLifecycle.transfer(NEW_TASK_PENDING_KEY, createdSessionId); + composerStaging.transferImageNotice(NEW_TASK_PENDING_KEY, createdSessionId); } - async function enqueueFollowUp( - sessionId: string, - text: string, - mode: FollowUpMode, - metadata?: ComposerSendMetadata, - ): Promise { - try { - const sent = await enqueueMessage(sessionId, text, - mode === 'steer' ? 'current_turn' : 'next_turn', submittableAttachments, { - ...directoryOptions, quotes: pendingQuotes, - workspaceFileReferences: metadata?.workspaceFileReferences, - }); - if (!sent) return false; - clearSubmittedContext(submittableAttachments); - clearQuotes(); - return true; - } catch (error) { + const enqueueFollowUp = Conversation.createStagedFollowUp({ + captureStaging: composerStaging.captureSubmission, + enqueueMessage, + onError(sessionId, error) { if (activeIdRef.current === sessionId) { const copy = getDesktopConversationCopy(uiLocale).actions; showSessionError(sessionId, copy.operationFailedTitle, localizedShellErrorMessage(error, copy.operationFailedFallback, uiLocale)); } - return false; - } - } + }, + }); async function compactSession(sessionId: string): Promise { try { @@ -1305,13 +1256,7 @@ function AppShellContent({ revisionDraftRef, composerRef, retractedWorkspaceReferencesRef, - hasPendingContext, - hasStagedQuotes, - submittableAttachments, - directoryOptions, - quotesForSend, - clearSubmittedContext, - clearQuotes, + captureStaging: composerStaging.captureSubmission, prepareRevisionSend, send, completeRevisionCopyAttempt: completeTurnRevisionCopyAttempt, @@ -1582,6 +1527,8 @@ function AppShellContent({ // readers. Composer mentions still wrap the frame so one projection serves // every composer, including side-chat panels, without rebuilding the frame // on catalog moves. + openSettingsSection('external-agents'), onNewTask: openNewTaskSurface})} activeSession={activeSessionForView} {...{ executorTarget, onExecutorTargetChange }} @@ -1988,7 +1931,6 @@ function AppShellContent({ > {sessionsSelected ? ( { - addQuote(selection); + composerStaging.addQuote(selection); composerRef.current?.focus(); } } @@ -2150,5 +2092,6 @@ function AppShellContent({ + ); } diff --git a/apps/desktop/src/renderer/chat-composer-region.tsx b/apps/desktop/src/renderer/chat-composer-region.tsx index 21a6b23ec6e..b8d61e0da15 100644 --- a/apps/desktop/src/renderer/chat-composer-region.tsx +++ b/apps/desktop/src/renderer/chat-composer-region.tsx @@ -29,6 +29,7 @@ import { SandboxBoundaryPrompt, UserQuestionPrompt, } from '@maka/ui'; +import { StagedComposer, type ComposerStagingProp } from './features/conversation/index.js'; import type { ComposerHandle } from '@maka/ui'; export { selectLatestRequestUsage } from './application/contracts/session-inspector/latest-request-usage.js'; import { useComposerMentionsContext } from './composer-mentions.js'; @@ -100,9 +101,7 @@ interface ChatComposerRegionProps | 'pendingSessionReferences' | 'onRemovePendingSessionReference' | 'waitForSessionReference' - | 'pendingDirectories' - | 'onRemoveDirectory' - | 'onPickDirectory' + | ComposerStagingProp > { composerRef: RefObject; /** Set while the active Session is a Guest's: the same Composer sends Turn requests. */ @@ -155,10 +154,8 @@ interface ChatComposerRegionProps usage: { readonly usageTokens: number; readonly contextWindow?: number } | undefined, ) => ReactNode; }>; - directoryComposerProps: Pick< - ComponentProps, - 'pendingDirectories' | 'onRemoveDirectory' | 'onPickDirectory' - >; + canStageContext: boolean; + contextPickEnabled: boolean; directoryPickerEnabled: boolean; } @@ -182,7 +179,8 @@ export function ChatComposerRegion({ latestRequestUsageTokens, onOpenContextUsage, LiveContextUsageProbe, - directoryComposerProps, + canStageContext, + contextPickEnabled, directoryPickerEnabled, ...composerRest }: ChatComposerRegionProps) { @@ -275,7 +273,11 @@ export function ChatComposerRegion({ ) => ( {(goalProjection) => ( - { /** * #1985: the live projection and the shell-run records are the only session @@ -195,7 +198,7 @@ export function ChatMessageSurface({ <> {(goalProjection) => ( - + @@ -125,6 +128,7 @@ export function DesktopFeatureServicesProvider(props: { + diff --git a/apps/desktop/src/renderer/features/conversation/README.md b/apps/desktop/src/renderer/features/conversation/README.md index d2f6f1a7376..56bffe2d550 100644 --- a/apps/desktop/src/renderer/features/conversation/README.md +++ b/apps/desktop/src/renderer/features/conversation/README.md @@ -152,3 +152,38 @@ confirmation ownership, and exact approval/resume retry inputs. Plan remains Session-scoped and uses the existing observer/control APIs. Revisit this boundary if an accepted architecture decision changes that target or moves Plan into an independent domain; do not restore a full-model export to adapt callers. + + +## Composer staging ownership (R2 M3, first slice) + +`ComposerStagingProvider` is the sole owner of the Desktop staging controller. +It stays mounted across Session and section switches. `StagedComposer` reads +files, directory references and quote chips at the actual Composer; +`StagedQuoteChatView` reads quote annotations at the transcript, and +`ComposerMentionsProvider` reads the same quotes for the session-reference limit. +No staging state or reactive read port is returned to AppShell. The private +context/binding modules and the controller owner entry seal this boundary. +The Desktop attachment service is injected at the composition root. + +The shell holds only stable commands. Submission captures a draft-bound snapshot +before awaiting revision preparation or delivery. Cleanup stays bound to that +draft; directory references retain their originating Host. Quotes +are copied at invocation, including session references added in the same tick. +Accepted sends remove only captured quote entries, preserving later additions +and edits: the submitted note is sent once, while a note edited during delivery +remains an unsent draft for the user's next send. Failed sends keep their staging. +`createStagedFollowUp` applies the same capture/cleanup rule to the Shell's actual +follow-up callback; tests exercise it through the production enqueue action. +There is no public restore command without a production consumer. Delivery +recovery may introduce one when that later M3 slice defines its ownership. +This does not change Host admission, +queue routing, revision-copy ordering or the new-task text handoff. + +The existing file-picker rule still targets the visible draft when I/O completes; +directory pickers still require the original draft and Host to remain current. +Staging uses `activeId ?? NEW_TASK_PENDING_KEY`; the editor's new-task persistence +key remains distinct. Do not key this provider or the Composer's parent by Session. + +Readiness, revision draft state, send-pending state, delivery recovery and the +remaining send orchestration are later M3 work. They can use captured submission +commands without restoring root subscriptions or acquiring the private controller. diff --git a/apps/desktop/src/renderer/features/conversation/controller/composer-staging-commands.ts b/apps/desktop/src/renderer/features/conversation/controller/composer-staging-commands.ts new file mode 100644 index 00000000000..df80b89d6d5 --- /dev/null +++ b/apps/desktop/src/renderer/features/conversation/controller/composer-staging-commands.ts @@ -0,0 +1,37 @@ +/* + * 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 { ComposerStagingCommands } from '../model/composer-staging-contract.js'; +import { stagingBindings } from '../model/composer-staging-binding.js'; + +export function createComposerStagingCommands(): ComposerStagingCommands { + const binding: { current?: ComposerStagingCommands } = {}; + const requireOwner = () => { + if (!binding.current) throw new Error('ComposerStagingProvider is not mounted'); + return binding.current; + }; + const commands: ComposerStagingCommands = { + captureSubmission: () => requireOwner().captureSubmission(), + addQuote: (quote) => requireOwner().addQuote(quote), + resetImageNotice: (key) => requireOwner().resetImageNotice(key), + transferImageNotice: (from, to) => requireOwner().transferImageNotice(from, to), + }; + stagingBindings.set(commands, binding); + return commands; +} diff --git a/apps/desktop/src/renderer/features/conversation/controller/composer-submit.ts b/apps/desktop/src/renderer/features/conversation/controller/composer-submit.ts index d8c09bc5ead..f26988ef184 100644 --- a/apps/desktop/src/renderer/features/conversation/controller/composer-submit.ts +++ b/apps/desktop/src/renderer/features/conversation/controller/composer-submit.ts @@ -28,6 +28,7 @@ import type { TransientUserMessageProjection, } from '@maka/ui'; import type { PendingAttachment } from '@maka/ui/composer-attachments'; +import type { ComposerStagingSubmission } from '../model/composer-staging-contract.js'; type RefBox = { current: T }; type WorkspaceFileReference = NonNullable[number]; @@ -83,19 +84,7 @@ export interface RevisionSendPorts { revisionDraftRef: RefBox; composerRef: RefBox; retractedWorkspaceReferencesRef: RefBox>; - hasPendingContext: boolean; - hasStagedQuotes: boolean; - submittableAttachments: readonly PendingAttachment[] | undefined; - directoryOptions: { - directoryReferences?: NonNullable< - TransientUserMessageProjection['directoryReferences'] - >; - }; - quotesForSend: () => QuoteRef[] | undefined; - clearSubmittedContext: ( - submitted?: readonly PendingAttachment[], - ) => void; - clearQuotes: () => void; + captureStaging(): ComposerStagingSubmission; prepareRevisionSend: (text: string) => Promise; send: (text: string, pending?: readonly PendingAttachment[], options?: SubmitOptions) => Promise; enqueueFollowUp: ( @@ -140,6 +129,37 @@ export interface RevisionAwareOnSendPorts setNewTaskSendPending: (pending: boolean) => void; } +/** Shared by Shell's follow-up callback and its production-owner tests. */ +export function createStagedFollowUp(ports: { + captureStaging(): ComposerStagingSubmission; + enqueueMessage( + sessionId: string, + text: string, + placement: 'current_turn' | 'next_turn', + pending: readonly PendingAttachment[] | undefined, + options: Pick, + ): Promise; + onError(sessionId: string, error: unknown): void; +}): RevisionSendPorts['enqueueFollowUp'] { + return async (sessionId, text, mode, metadata) => { + const staging = ports.captureStaging(); + try { + const sent = await ports.enqueueMessage(sessionId, text, + mode === 'steer' ? 'current_turn' : 'next_turn', staging.submittableAttachments, { + ...staging.directoryOptions, quotes: staging.quotesForSend(), + workspaceFileReferences: metadata?.workspaceFileReferences, + }); + if (!sent) return false; + staging.clearSubmittedContext(staging.submittableAttachments); + staging.clearQuotes(); + return true; + } catch (error) { + ports.onError(sessionId, error); + return false; + } + }; +} + /** * The exact callback AppShell hands to the composer, built by the same * factory in production and in tests. It wraps {@link revisionAwareSend} so @@ -180,6 +200,7 @@ export async function revisionAwareSend( text: string, metadata?: ComposerSendMetadata, ): Promise { + const staging = ports.captureStaging(); const revision = ports.revisionDraftRef.current; const revisionSend = Boolean( revision && ports.activeIdRef.current === revision.draftSessionId, @@ -203,7 +224,7 @@ export async function revisionAwareSend( } if (revisionSend && revision) { const actionCopy = ports.revisionUnavailableCopy; - if (ports.hasPendingContext) { + if (staging.hasPendingContext) { ports.toastApi.info(actionCopy.revisionUnavailableTitle, actionCopy.revisionAttachmentsUnsupported); return false; } @@ -227,8 +248,8 @@ export async function revisionAwareSend( return false; } if ( - ports.hasPendingContext || - ports.hasStagedQuotes || + staging.hasPendingContext || + staging.hasStagedQuotes || metadata?.workspaceFileReferences?.length ) { ports.toastApi.info( @@ -266,11 +287,11 @@ export async function revisionAwareSend( } return changed; } - const pending = ports.submittableAttachments; - const quotes = ports.quotesForSend(); + const pending = staging.submittableAttachments; + const quotes = staging.quotesForSend(); const ok = await ports.send(swarmCommand.task, pending, { turnOrchestration: { mode: 'swarm', source: 'slash_command' }, - ...ports.directoryOptions, + ...staging.directoryOptions, ...(quotes ? { quotes } : {}), ...(metadata?.workspaceFileReferences?.length ? { @@ -283,8 +304,8 @@ export async function revisionAwareSend( : {}), }); if (ok !== false) { - ports.clearSubmittedContext(pending); - if (quotes) ports.clearQuotes(); + staging.clearSubmittedContext(pending); + if (quotes) staging.clearQuotes(); ports.settleNewTaskImageNoticeOwner(sessionId); } return ok; @@ -315,11 +336,11 @@ export async function revisionAwareSend( } return changed; } - const pending = ports.submittableAttachments; - const quotes = ports.quotesForSend(); + const pending = staging.submittableAttachments; + const quotes = staging.quotesForSend(); const ok = await ports.send(graphCommand.task, pending, { turnOrchestration: { mode: 'graph', source: 'slash_command' }, - ...ports.directoryOptions, + ...staging.directoryOptions, ...(quotes ? { quotes } : {}), ...(metadata?.workspaceFileReferences?.length ? { @@ -332,30 +353,30 @@ export async function revisionAwareSend( : {}), }); if (ok !== false) { - ports.clearSubmittedContext(pending); - if (quotes) ports.clearQuotes(); + staging.clearSubmittedContext(pending); + if (quotes) staging.clearQuotes(); ports.settleNewTaskImageNoticeOwner(sessionId); } return ok; } - const pending = ports.submittableAttachments; + const pending = staging.submittableAttachments; const expectedRevisionDraft = revisionSend ? ports.revisionDraftRef.current : undefined; - const quotes = ports.quotesForSend(); + const quotes = staging.quotesForSend(); const ok = await ports.send(text, pending, { waitForHostAdmission: revisionSend, targetSessionId: expectedRevisionDraft?.draftSessionId, onSessionResolved: ports.resolveNewTaskSessionHandler(), - ...ports.directoryOptions, + ...staging.directoryOptions, ...(quotes ? { quotes } : {}), ...(workspaceFileReferences.length ? { workspaceFileReferences } : {}), }); if (ok !== false) { - ports.clearSubmittedContext(pending); - if (quotes) ports.clearQuotes(); + staging.clearSubmittedContext(pending); + if (quotes) staging.clearQuotes(); ports.settleNewTaskImageNoticeOwner(sessionId); if (sessionId) delete ports.retractedWorkspaceReferencesRef.current[sessionId]; } diff --git a/apps/desktop/src/renderer/features/conversation/controller/use-composer-attachments.ts b/apps/desktop/src/renderer/features/conversation/controller/use-composer-attachments.ts index 6cb7b537000..fdbe63da5da 100644 --- a/apps/desktop/src/renderer/features/conversation/controller/use-composer-attachments.ts +++ b/apps/desktop/src/renderer/features/conversation/controller/use-composer-attachments.ts @@ -22,12 +22,10 @@ import { useComposerAttachments as useSharedComposerAttachments } from '@maka/ui import { getDesktopConversationCopy } from '../../../application/contracts/conversation-copy.js'; import { localizedShellErrorMessage } from '../../../locales/shell-copy.js'; import { useComposerQuotes } from './use-composer-quotes.js'; -export type { ComposerAttachmentService } from '@maka/ui/use-composer-attachments'; /** Desktop staging surface for everything the composer carries into a send: - * attachments, directory picks, and staged transcript quotes. One hook keeps - * the quote bucket out of AppShell's hook-call ledger; all three share the - * same draft key. */ + * attachments, directory picks, and staged transcript quotes. Called only by + * ComposerStagingProvider; all three share the same draft key. */ export function useComposerAttachments(options: Omit[0], 'copy' | 'formatError'>) { const locale = useUiLocale(); const attachments = useSharedComposerAttachments({ 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 index 136621041af..5fd8bf6b9d6 100644 --- a/apps/desktop/src/renderer/features/conversation/controller/use-composer-quotes.ts +++ b/apps/desktop/src/renderer/features/conversation/controller/use-composer-quotes.ts @@ -83,6 +83,13 @@ export function useComposerQuotes(options: { readonly draftKey: string }) { publish(); }, [bucket, publish]); + const clearSubmittedQuotes = useCallback((submitted: readonly QuoteRef[]): void => { + for (let index = bucket.length - 1; index >= 0; index -= 1) { + if (submitted.includes(bucket[index]!)) bucket.splice(index, 1); + } + publish(); + }, [bucket, publish]); + const restoreQuotes = useCallback((ownerKey: string, quotes: readonly QuoteRef[]): void => { if (quotes.length === 0) return; const ownerBucket = pendingByKeyRef.current[ownerKey] ?? @@ -107,6 +114,7 @@ export function useComposerQuotes(options: { readonly draftKey: string }) { updateQuoteComment, removeQuote, clearQuotes, + clearSubmittedQuotes, restoreQuotes, quotesForSend, composerQuoteProps: (canStage: boolean) => ({ diff --git a/apps/desktop/src/renderer/features/conversation/index.ts b/apps/desktop/src/renderer/features/conversation/index.ts index 11a46dceec4..02f8d2c1c7d 100644 --- a/apps/desktop/src/renderer/features/conversation/index.ts +++ b/apps/desktop/src/renderer/features/conversation/index.ts @@ -34,7 +34,7 @@ export { ConversationServicesProvider } from './services.js'; export { SessionLocalMessages } from './controller/session-local-messages.js'; -export { useComposerAttachments, type ComposerAttachmentService } from './controller/use-composer-attachments.js'; +export type { ComposerAttachmentService } from '@maka/ui/use-composer-attachments'; export { type PendingAttachment, toSubmittedAttachments } from '@maka/ui/composer-attachments'; export { NEW_TASK_PENDING_KEY, @@ -45,7 +45,6 @@ export { clearPending, } from '@maka/ui/pending-items'; export { desktopSlashCommandPresentation } from './model/slash-command-presentation.js'; -export { useComposerQuotes } from './controller/use-composer-quotes.js'; export { useActiveExecutionBoundary } from './controller/use-active-execution-boundary.js'; export { useSessionReferenceComposer } from './controller/use-session-reference-composer.js'; export { @@ -85,3 +84,10 @@ export { useConversationTarget as useAppShellSessionUiState } from './controller export { ConversationLifecycle } from './ui/conversation-lifecycle.js'; export { ConversationTranscriptRegion, ConversationComposerRegion, ConversationMessageConsumer } from './ui/conversation-readers.js'; +export { createComposerStagingCommands } from './controller/composer-staging-commands.js'; +export { createStagedFollowUp } from './controller/composer-submit.js'; +export type { ComposerStagingCommands, ComposerStagingSubmission } from './model/composer-staging-contract.js'; +export { ComposerStagingServicesProvider, type ComposerStagingServices } from './staging-services.js'; +export { ComposerStagingProvider } from './ui/composer-staging-provider.js'; +export { StagedComposer, type ComposerStagingProp } from './ui/staged-composer.js'; +export { StagedQuoteChatView } from './ui/staged-quote-chat-view.js'; diff --git a/apps/desktop/src/renderer/features/conversation/model/composer-staging-binding.ts b/apps/desktop/src/renderer/features/conversation/model/composer-staging-binding.ts new file mode 100644 index 00000000000..e8ca4bcb17c --- /dev/null +++ b/apps/desktop/src/renderer/features/conversation/model/composer-staging-binding.ts @@ -0,0 +1,26 @@ +/* + * 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 { ComposerStagingCommands } from './composer-staging-contract.js'; + +// Binding is feature-private. The shell cannot publish a controller or acquire +// the provider's reactive model through the public command handle. +export const stagingBindings = new WeakMap(); diff --git a/apps/desktop/src/renderer/features/conversation/model/composer-staging-contract.ts b/apps/desktop/src/renderer/features/conversation/model/composer-staging-contract.ts new file mode 100644 index 00000000000..9d8a9fabeeb --- /dev/null +++ b/apps/desktop/src/renderer/features/conversation/model/composer-staging-contract.ts @@ -0,0 +1,46 @@ +/* + * 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 { DirectoryReference, QuoteRef } from '@maka/core/events'; +import type { PendingAttachment } from '@maka/ui/composer-attachments'; + +/** A submission owns the staging it captured, even after navigation or another edit. */ +export interface ComposerStagingSubmission { + readonly draftKey: string; + readonly hasPendingContext: boolean; + readonly hasStagedQuotes: boolean; + readonly submittableAttachments: readonly PendingAttachment[] | undefined; + readonly directoryOptions: { readonly directoryReferences?: readonly DirectoryReference[] }; + quotesForSend(): QuoteRef[] | undefined; + clearSubmittedContext(submitted?: readonly PendingAttachment[]): void; + clearQuotes(): void; +} + +/** Commands only: there is deliberately no subscription or controller getter. */ +export interface ComposerStagingCommands { + captureSubmission(): ComposerStagingSubmission; + addQuote(input: { + text: string; + turnId?: string; + label?: string; + comment?: string; + }): void; + resetImageNotice(draftKey: string): void; + transferImageNotice(from: string, to: string): void; +} diff --git a/apps/desktop/src/renderer/features/conversation/staging-services.ts b/apps/desktop/src/renderer/features/conversation/staging-services.ts new file mode 100644 index 00000000000..97fcf2145b7 --- /dev/null +++ b/apps/desktop/src/renderer/features/conversation/staging-services.ts @@ -0,0 +1,26 @@ +/* + * 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 { createServicesContext } from '../../application/contracts/feature-services.js'; +import type { ComposerAttachmentService } from '@maka/ui/use-composer-attachments'; + +export type ComposerStagingServices = ComposerAttachmentService; +const context = createServicesContext('ComposerStagingServicesProvider'); +export const ComposerStagingServicesProvider = context.Provider; +export const useComposerStagingServices = context.useServices; diff --git a/apps/desktop/src/renderer/features/conversation/testing.ts b/apps/desktop/src/renderer/features/conversation/testing.ts index e8dd5c1626a..a25e9bb052f 100644 --- a/apps/desktop/src/renderer/features/conversation/testing.ts +++ b/apps/desktop/src/renderer/features/conversation/testing.ts @@ -101,3 +101,6 @@ export { useConversationQueue } from './ui/conversation-provider.js'; export { LiveTurnReconciler } from './controller/live-turn-reconciler.js'; export { TranscriptReadingPositionController, type TranscriptReadingPositionCommands } from './controller/transcript-reading-position-controller.js'; +export { useComposerAttachments } from './controller/use-composer-attachments.js'; +export { useComposerQuotes } from './controller/use-composer-quotes.js'; +export { useComposerStaging } from './ui/composer-staging-context.js'; 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 index c2bbbfdd01f..1f98ee5ef55 100644 --- a/apps/desktop/src/renderer/features/conversation/ui/composer-mentions-provider.tsx +++ b/apps/desktop/src/renderer/features/conversation/ui/composer-mentions-provider.tsx @@ -20,8 +20,8 @@ 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 { useComposerStaging } from './composer-staging-context.js'; import type { ConversationSession } from '../ports.js'; import { useConversationServices } from '../services.js'; import { @@ -49,8 +49,6 @@ export interface ComposerMentionsSurface { readonly hostId: string; readonly projectId: string | null; }; - readonly onAddQuote?: (quote: QuoteRef) => void; - readonly pendingQuotes?: readonly QuoteRef[]; } @@ -124,6 +122,7 @@ function conversationSessionListsEqual( function useConversationMentions(surface: ComposerMentionsSurface): ComposerMentions { const services = useConversationServices(); + const staging = useComposerStaging(); const locale = useUiLocale(); const mentionCopy = getConversationCopy(locale).mentions; const sessionCatalog = useSessionCatalogController(); @@ -271,8 +270,8 @@ function useConversationMentions(surface: ComposerMentionsSurface): ComposerMent sessions, activeId: surface.sessionId, hostId: activeHostId, - addQuote: surface.onAddQuote, - pendingQuotes: surface.pendingQuotes, + addQuote: staging.addQuote, + pendingQuotes: staging.pendingQuotes, errorCopy: useMemo( () => ({ unavailableTitle: mentionCopy.sessionReferenceUnavailableTitle, @@ -300,9 +299,9 @@ function useConversationMentions(surface: ComposerMentionsSurface): ComposerMent mentionSkillsUnavailable: liveCatalog.settled === 'empty', mentionSkillsLoading: liveCatalog.loading, searchMentionFiles, - sessionReferences: surface.onAddQuote && referenceEnabled ? reference.references : [], + sessionReferences: referenceEnabled ? reference.references : [], onPickSessionReference: - surface.onAddQuote && referenceEnabled ? reference.pick : undefined, + referenceEnabled ? reference.pick : undefined, pendingSessionReferences: reference.pendingReferences, onRemovePendingSessionReference: reference.removePendingReference, sessionReferenceError: reference.error, @@ -318,7 +317,6 @@ function useConversationMentions(surface: ComposerMentionsSurface): ComposerMent reference.references, reference.waitForPending, searchMentionFiles, - surface.onAddQuote, ]); } diff --git a/apps/desktop/src/renderer/features/conversation/ui/composer-staging-context.ts b/apps/desktop/src/renderer/features/conversation/ui/composer-staging-context.ts new file mode 100644 index 00000000000..ff0452db8bb --- /dev/null +++ b/apps/desktop/src/renderer/features/conversation/ui/composer-staging-context.ts @@ -0,0 +1,28 @@ +/* + * 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 } from 'react'; +import type { useComposerAttachments } from '../controller/use-composer-attachments.js'; + +export const ComposerStagingContext = createContext | undefined>(undefined); +export function useComposerStaging() { + const staging = useContext(ComposerStagingContext); + if (!staging) throw new Error('ComposerStagingProvider is required'); + return staging; +} diff --git a/apps/desktop/src/renderer/features/conversation/ui/composer-staging-provider.tsx b/apps/desktop/src/renderer/features/conversation/ui/composer-staging-provider.tsx new file mode 100644 index 00000000000..eaadf607dc0 --- /dev/null +++ b/apps/desktop/src/renderer/features/conversation/ui/composer-staging-provider.tsx @@ -0,0 +1,72 @@ +/* + * 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 { useLayoutEffect, type ReactNode } from 'react'; +import { useToast } from '@maka/ui'; +import { useComposerAttachments } from '../controller/use-composer-attachments.js'; +import { stagingBindings } from '../model/composer-staging-binding.js'; +import type { ComposerStagingCommands } from '../model/composer-staging-contract.js'; +import { useComposerStagingServices } from '../staging-services.js'; +import { ComposerStagingContext } from './composer-staging-context.js'; + +/** Persistent draft owner. Its children are composed by the shell, never keyed + * by Session, so staging updates only reach the regional context readers. */ +export function ComposerStagingProvider(props: { + readonly commands: ComposerStagingCommands; + readonly draftKey: string; + readonly directoryHostId?: string; + readonly supportsVision?: boolean; + readonly children?: ReactNode; +}) { + const toastApi = useToast(); + const staging = useComposerAttachments({ + draftKey: props.draftKey, + directoryHostId: props.directoryHostId, + toastApi, + service: useComposerStagingServices(), + imageNotice: { supportsVision: () => props.supportsVision, notify: toastApi.info }, + }); + useLayoutEffect(() => { + const binding = stagingBindings.get(props.commands); + if (!binding) throw new Error('Unknown Composer staging commands'); + const commands: ComposerStagingCommands = { + captureSubmission: () => { + // Copy the mutable quote bucket at invocation, including synchronous + // session-reference additions before React has rendered again. + const quotes = staging.quotesForSend()?.slice(); + return { + draftKey: props.draftKey, + hasPendingContext: staging.hasPendingContext, + hasStagedQuotes: Boolean(quotes?.length), + submittableAttachments: staging.submittableAttachments, + directoryOptions: staging.directoryOptions, + quotesForSend: () => quotes, + clearSubmittedContext: staging.clearSubmittedContext, + clearQuotes: () => staging.clearSubmittedQuotes(quotes ?? []), + }; + }, + addQuote: staging.addQuote, + resetImageNotice: (key) => staging.imageNoticeLifecycle.reset(key), + transferImageNotice: (from, to) => staging.imageNoticeLifecycle.transfer(from, to), + }; + binding.current = commands; + return () => { if (binding.current === commands) binding.current = undefined; }; + }, [props.commands, props.draftKey, staging]); + return {props.children}; +} diff --git a/apps/desktop/src/renderer/features/conversation/ui/staged-composer.tsx b/apps/desktop/src/renderer/features/conversation/ui/staged-composer.tsx new file mode 100644 index 00000000000..2399c70100d --- /dev/null +++ b/apps/desktop/src/renderer/features/conversation/ui/staged-composer.tsx @@ -0,0 +1,48 @@ +/* + * 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 { ComponentProps } from 'react'; +import { Composer } from '@maka/ui'; +import { useComposerStaging } from './composer-staging-context.js'; + +export type ComposerStagingProp = + | 'pendingAttachments' | 'onRemoveAttachment' | 'onPickAttachments' | 'onAttachFilePaths' + | 'pendingDirectories' | 'onRemoveDirectory' | 'onPickDirectory' + | 'pendingQuotes' | 'onRemoveQuote' | 'onEditQuoteComment' | 'onAnnotateQuote'; + +/** Actual Composer reader; no staging projection travels through AppShell. */ +export function StagedComposer({ + stagingEnabled, canStageContext, contextPickEnabled, directoryPickerEnabled, ...props +}: Omit, ComposerStagingProp> & { + readonly stagingEnabled: boolean; + readonly canStageContext: boolean; + readonly contextPickEnabled: boolean; + readonly directoryPickerEnabled: boolean; +}) { + const staging = useComposerStaging(); + return ; +} diff --git a/apps/desktop/src/renderer/features/conversation/ui/staged-quote-chat-view.tsx b/apps/desktop/src/renderer/features/conversation/ui/staged-quote-chat-view.tsx new file mode 100644 index 00000000000..9b21768117c --- /dev/null +++ b/apps/desktop/src/renderer/features/conversation/ui/staged-quote-chat-view.tsx @@ -0,0 +1,28 @@ +/* + * 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 { ComponentProps } from 'react'; +import { PlanChatView } from './plan-surfaces.js'; +import { useComposerStaging } from './composer-staging-context.js'; + +export function StagedQuoteChatView(props: Omit, + 'handleRef' | 'pendingQuotes' | 'onQuoteAnnotationSubmit'>) { + const staging = useComposerStaging(); + return ; +} diff --git a/apps/desktop/src/renderer/platform/desktop/create-composer-staging-services.ts b/apps/desktop/src/renderer/platform/desktop/create-composer-staging-services.ts new file mode 100644 index 00000000000..2c263ce7986 --- /dev/null +++ b/apps/desktop/src/renderer/platform/desktop/create-composer-staging-services.ts @@ -0,0 +1,27 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { MakaBridge } from '../../../preload/bridge-contract.js'; +import type { ComposerStagingServices } from '../../features/conversation/index.js'; + +export function createDesktopComposerStagingServices( + bridge: Pick = window.maka, +): ComposerStagingServices { + return bridge.attachments; +} diff --git a/apps/desktop/stories/composer-slash-menu.stories.tsx b/apps/desktop/stories/composer-slash-menu.stories.tsx index 42ade72135d..3ca29080d77 100644 --- a/apps/desktop/stories/composer-slash-menu.stories.tsx +++ b/apps/desktop/stories/composer-slash-menu.stories.tsx @@ -34,17 +34,19 @@ */ import { stubConversationServices } from '../src/renderer/features/conversation/testing.js'; -import { useMemo, useState } from 'react'; +import { useMemo, useState, type ReactNode } 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 { Composer } from '@maka/ui'; +import { Composer, ToastProvider } from '@maka/ui'; import { ComposerMentionsProvider, useComposerMentionsContext, } from '../src/renderer/composer-mentions'; import { ConversationServicesProvider, + ComposerStagingProvider, ComposerStagingServicesProvider, createComposerStagingCommands, + NEW_TASK_PENDING_KEY, type ComposerStagingServices, type ConversationServices, } from '../src/renderer/features/conversation'; import { @@ -205,6 +207,28 @@ function SlashMenuComposer({ ); } +const stagingServices: ComposerStagingServices = { + pickFiles: async () => ({ ok: false, reason: 'cancelled' }), + previewApproval: async () => ({ ok: false, reason: 'unavailable' }), +}; + +// Match AppShell's persistent staging scope around the real mention reader. +function StagingScope(props: { hasSession: boolean; children: ReactNode }) { + const commands = useMemo(createComposerStagingCommands, []); + return ( + + + + {props.children} + + + + ); +} + function SlashMenuHarness({ hasSession = true, streaming = false, @@ -216,18 +240,20 @@ function SlashMenuHarness({
- - - + + + + +
@@ -250,16 +276,18 @@ function ContextSwitchHarness(): React.ReactElement {
- - - + + + + +
diff --git a/apps/desktop/stories/shared-session-guest.stories.tsx b/apps/desktop/stories/shared-session-guest.stories.tsx index 1e32f52549a..437723ecf28 100644 --- a/apps/desktop/stories/shared-session-guest.stories.tsx +++ b/apps/desktop/stories/shared-session-guest.stories.tsx @@ -17,7 +17,7 @@ * under the License. */ -import { useRef } from 'react'; +import { useMemo, useRef } from 'react'; import type { Meta, StoryObj } from '@storybook/react-vite'; import { expect, waitFor } from 'storybook/test'; import type { SessionTurnAccessRequest } from '@maka/runtime-host/protocol'; @@ -31,6 +31,16 @@ import { } from '../src/renderer/features/session-collaboration/index.js'; import { createFakeSessionCollaborationServices } from '../src/renderer/features/session-collaboration/testing.js'; +import { + ComposerStagingProvider, ComposerStagingServicesProvider, createComposerStagingCommands, + type ComposerStagingServices, +} from '../src/renderer/features/conversation/index.js'; + +const stagingServices: ComposerStagingServices = { + pickFiles: async () => ({ ok: false, reason: 'cancelled' }), + previewApproval: async () => ({ ok: false, reason: 'unavailable' }), +}; + const SESSION_ID = 'shared-session-story'; const SHARED_SESSION: SessionSummary = { id: SESSION_ID, @@ -143,7 +153,10 @@ const reconnectServices = services(async () => { // ChatComposerRegion, which sends Turn requests while the Session is shared. function GuestComposer(props: { sessionId: string }) { const composerRef = useRef(null); + const stagingCommands = useMemo(createComposerStagingCommands, []); return ( + + {(guest) => ( undefined} stop={() => undefined} onOpenContextUsage={() => undefined} - directoryComposerProps={{}} + canStageContext={false} + contextPickEnabled={false} directoryPickerEnabled={false} onSend={() => false} onStop={() => undefined} /> )} + + ); } diff --git a/docs/astryx-surface-file-inventory.md b/docs/astryx-surface-file-inventory.md index 0d1bd31dc16..86a797da748 100644 --- a/docs/astryx-surface-file-inventory.md +++ b/docs/astryx-surface-file-inventory.md @@ -6,7 +6,7 @@ Generated against `@astryxdesign/core@0.6.3` (195 component exports). Wiki bar: Design Conventions · API Use-the-System · Theming · Container Padding. -**Totals:** 318 files — blocker 0, reimplementation 0, polish 4, aligned 314. +**Totals:** 321 files — blocker 0, reimplementation 0, polish 4, aligned 317. ## Exclusions (explicit) @@ -66,12 +66,15 @@ Wiki bar: Design Conventions · API Use-the-System · Theming · Container Paddi | `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/conversation/ui/composer-staging-provider.tsx` | shell-chrome-or-panel | none | aligned — no raw controls; no Astryx JSX usage | aligned | | `apps/desktop/src/renderer/features/conversation/ui/conversation-lifecycle.tsx` | other | none | aligned — no raw controls; no Astryx JSX usage | aligned | | `apps/desktop/src/renderer/features/conversation/ui/conversation-provider.tsx` | other | none | aligned — no raw controls; no Astryx JSX usage | aligned | | `apps/desktop/src/renderer/features/conversation/ui/conversation-readers.tsx` | other | none | aligned — no raw controls; no Astryx JSX usage | aligned | | `apps/desktop/src/renderer/features/conversation/ui/plan-panels.tsx` | shell-chrome-or-panel | Badge, Banner, Button, Collapsible | aligned — uses Astryx (Badge, Banner, Button, Collapsible) | aligned | | `apps/desktop/src/renderer/features/conversation/ui/plan-provider.tsx` | other | none | aligned — no raw controls; no Astryx JSX usage | aligned | | `apps/desktop/src/renderer/features/conversation/ui/plan-surfaces.tsx` | other | none | aligned — no raw controls; no Astryx JSX usage | aligned | +| `apps/desktop/src/renderer/features/conversation/ui/staged-composer.tsx` | shell-chrome-or-panel | none | aligned — no raw controls; no Astryx JSX usage | aligned | +| `apps/desktop/src/renderer/features/conversation/ui/staged-quote-chat-view.tsx` | shell-chrome-or-panel | none | aligned — no raw controls; no Astryx JSX usage | aligned | | `apps/desktop/src/renderer/features/external-agent-settings/page.tsx` | other | Badge, Banner, Button, HStack, Link, List, ListItem, Text | aligned — uses Astryx (Badge, Banner, Button, HStack, Link, List, ListItem, Text) | 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 | diff --git a/docs/astryx-surface-file-inventory.paths b/docs/astryx-surface-file-inventory.paths index 8a769e6d755..387f1d8a9c5 100644 --- a/docs/astryx-surface-file-inventory.paths +++ b/docs/astryx-surface-file-inventory.paths @@ -36,12 +36,15 @@ apps/desktop/src/renderer/features/conversation/controller/live-turn-reconciler. 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/conversation/ui/composer-staging-provider.tsx apps/desktop/src/renderer/features/conversation/ui/conversation-lifecycle.tsx apps/desktop/src/renderer/features/conversation/ui/conversation-provider.tsx apps/desktop/src/renderer/features/conversation/ui/conversation-readers.tsx apps/desktop/src/renderer/features/conversation/ui/plan-panels.tsx apps/desktop/src/renderer/features/conversation/ui/plan-provider.tsx apps/desktop/src/renderer/features/conversation/ui/plan-surfaces.tsx +apps/desktop/src/renderer/features/conversation/ui/staged-composer.tsx +apps/desktop/src/renderer/features/conversation/ui/staged-quote-chat-view.tsx apps/desktop/src/renderer/features/external-agent-settings/page.tsx apps/desktop/src/renderer/features/goals/services-context.tsx apps/desktop/src/renderer/features/goals/ui/goal-dialog.tsx diff --git a/scripts/check-app-shell-hooks.mjs b/scripts/check-app-shell-hooks.mjs index 25cfe60ba6a..08774441133 100644 --- a/scripts/check-app-shell-hooks.mjs +++ b/scripts/check-app-shell-hooks.mjs @@ -114,7 +114,6 @@ export const ALLOWED = { useAppShellSessionUiReads: 1, useAppShellSessionWorkspace: 1, useAppShellTurnPresentation: 1, - useComposerAttachments: 1, useEffect: 3, useLayoutEffect: 1, useNewTaskChoice: 1,