From 9ccce986374bbd18bfce3889087f1995e9e628d5 Mon Sep 17 00:00:00 2001 From: Yuqing Yang Date: Thu, 29 Oct 2026 02:00:13 +0000 Subject: [PATCH] feat: stage attributed comments from file previews (#351) Reuse the existing Preview Comment staging and destination workflow for readonly CodeMirror selections. Preserve source-device path, filesystem version, loaded range and context without importing or rereading the file. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agent/conversation/envelope.test.ts | 19 ++ .../conversation/prompt/build-prompt.test.ts | 45 +++ .../conversation/prompt/quoted-comments.ts | 17 +- .../conversation/transcript/history.test.ts | 19 ++ apps/web/e2e/file-comments.spec.ts | 231 +++++++++++++++ .../CodeMirror/RawMarkdownEditor.tsx | 7 + .../AIMessage/FragmentCommentPopover.tsx | 7 +- .../Messages/PreviewCommentEditor.tsx | 152 ++++++++++ .../fileReference/FileCommentActions.test.tsx | 264 ++++++++++++++++++ .../fileReference/FileCommentActions.tsx | 177 ++++++++++++ .../FileReferencePreview.test.tsx | 6 +- .../fileReference/FileReferencePreview.tsx | 25 +- .../Nodes/note/NoteCommentActions.tsx | 147 +--------- .../web/src/hooks/agentTurnController.test.ts | 19 ++ apps/web/src/hooks/useChatHistory.test.tsx | 19 ++ apps/web/src/i18n/resources/en/common.json | 1 + apps/web/src/i18n/resources/zh-CN/common.json | 1 + apps/web/src/store/chatStore.test.ts | 51 ++++ apps/web/src/store/chatStore.ts | 7 +- docs/architecture/agent-context.md | 2 + docs/architecture/api-design.md | 2 + docs/architecture/device-files.md | 29 +- docs/architecture/preview-workspace.md | 4 +- docs/architecture/web-architecture.md | 2 + packages/shared/src/types/api/agent.test.ts | 47 ++++ packages/shared/src/types/api/agent.ts | 34 ++- 26 files changed, 1165 insertions(+), 169 deletions(-) create mode 100644 apps/web/e2e/file-comments.spec.ts create mode 100644 apps/web/src/components/Messages/PreviewCommentEditor.tsx create mode 100644 apps/web/src/components/Nodes/fileReference/FileCommentActions.test.tsx create mode 100644 apps/web/src/components/Nodes/fileReference/FileCommentActions.tsx diff --git a/apps/server/src/modules/agent/conversation/envelope.test.ts b/apps/server/src/modules/agent/conversation/envelope.test.ts index aa9572fdf..3f3d81c45 100644 --- a/apps/server/src/modules/agent/conversation/envelope.test.ts +++ b/apps/server/src/modules/agent/conversation/envelope.test.ts @@ -337,6 +337,25 @@ describe('buildChatEnvelope selection records', () => { snapshotRevision: 'captured', }, }, + { + quote: 'file passage', + contextBefore: 'before ', + contextAfter: ' after', + comment: 'Explain this file.', + source: { + kind: 'file-reference' as const, + canvasId: 'space', + nodeId: 'file', + agentletId: 'device', + path: '/source.tex', + contentVersion: 'a'.repeat(64), + offsetUnit: 'utf-16' as const, + from: 7, + to: 19, + loadedLength: 40, + complete: false, + }, + }, ]; const envelope = await buildChatEnvelope({ content: '', diff --git a/apps/server/src/modules/agent/conversation/prompt/build-prompt.test.ts b/apps/server/src/modules/agent/conversation/prompt/build-prompt.test.ts index d8be2329d..589492a5e 100644 --- a/apps/server/src/modules/agent/conversation/prompt/build-prompt.test.ts +++ b/apps/server/src/modules/agent/conversation/prompt/build-prompt.test.ts @@ -90,6 +90,51 @@ const NO_CANVAS = { canvasId: null }; // ─── Tests ─────────────────────────────────────────────────────────────────── describe('renderEnvelopeMessages', () => { + it('renders file paths and frozen UTF-16 ranges as escaped source evidence through replay', async () => { + const envelope = makeEnvelope({ text: 'Review the excerpt.' }); + envelope.user.quotedComments = [ + { + quote: '', + contextBefore: 'before ', + contextAfter: ' after', + comment: 'Explain.', + source: { + kind: 'file-reference', + canvasId: 'space', + nodeId: 'file', + agentletId: 'device&source', + path: '/project/.tex', + contentVersion: 'a'.repeat(64), + offsetUnit: 'utf-16', + from: 7, + to: 18, + loadedLength: 40, + complete: false, + }, + }, + ]; + const rendered = await renderEnvelopeMessages(envelope, NO_CANVAS); + const text = textOf(rendered.messages[0].content); + expect(text).toContain('file-reference'); + expect(text).toContain('/project/<file>.tex'); + expect(text).toContain('device&source'); + expect(text).toContain('file</source>'); + expect(text).toContain('<file text>'); + expect(text).toContain( + 'offset_unit="utf-16" from="7" to="18" loaded_length="40" file_fully_loaded="false"', + ); + expect(text).toContain('not necessarily the Agent execution device'); + expect(text).not.toContain('captured from a Note'); + expect(envelope.user.text).toBe('Review the excerpt.'); + const replay = await rebuildContextMessages( + [makeTurn(envelope, [])], + NO_CANVAS, + ); + expect( + replay.map((message) => textOf(message.content)).join('\n'), + ).toContain('/project/<file>.tex'); + }); + it('renders multiple quoted comments with escaped context without mutating user text', async () => { const envelope = makeEnvelope({ text: 'Compare both.' }); envelope.user.quotedComments = [ diff --git a/apps/server/src/modules/agent/conversation/prompt/quoted-comments.ts b/apps/server/src/modules/agent/conversation/prompt/quoted-comments.ts index af4fbd614..db998a544 100644 --- a/apps/server/src/modules/agent/conversation/prompt/quoted-comments.ts +++ b/apps/server/src/modules/agent/conversation/prompt/quoted-comments.ts @@ -15,7 +15,7 @@ export function renderQuotedComments( ...comments.map((item) => [ '', - ...(item.source + ...(item.source?.kind === 'note' ? [ 'This passage was captured from a Note, not from an assistant reply. The revision fingerprints the document at capture time; it is not a server save acknowledgement. The Note may have changed since capture.', '', @@ -25,7 +25,20 @@ export function renderQuotedComments( `${escapeXmlText(item.source.snapshotRevision)}`, '', ] - : ['This passage was captured from an earlier assistant reply.']), + : item.source?.kind === 'file-reference' + ? [ + 'This passage was captured from a read-only file preview on the source device. The path belongs to that device, not necessarily the Agent execution device. The version identifies the captured filesystem state, not a retained historical file or a content hash. Only loaded text was available; the file may have changed since capture.', + '', + 'file-reference', + `${escapeXmlText(item.source.canvasId)}`, + `${escapeXmlText(item.source.nodeId)}`, + `${escapeXmlText(item.source.agentletId)}`, + `${escapeXmlText(item.source.path)}`, + `${escapeXmlText(item.source.contentVersion)}`, + ``, + '', + ] + : ['This passage was captured from an earlier assistant reply.']), `${escapeXmlText(item.quote)}`, ...(item.contextBefore || item.contextAfter ? [ diff --git a/apps/server/src/modules/agent/conversation/transcript/history.test.ts b/apps/server/src/modules/agent/conversation/transcript/history.test.ts index b0e53c8d2..2d23e4f32 100644 --- a/apps/server/src/modules/agent/conversation/transcript/history.test.ts +++ b/apps/server/src/modules/agent/conversation/transcript/history.test.ts @@ -186,6 +186,25 @@ describe('buildHistoryFromTurns', () => { snapshotRevision: 'captured', }, }, + { + quote: 'file excerpt', + contextBefore: 'before ', + contextAfter: ' after', + comment: 'Explain this file.', + source: { + kind: 'file-reference', + canvasId: 'space', + nodeId: 'file', + agentletId: 'device', + path: '/source.tex', + contentVersion: 'a'.repeat(64), + offsetUnit: 'utf-16', + from: 7, + to: 19, + loadedLength: 40, + complete: true, + }, + }, ]; const messages = build([ { request: createChatSubmission(envelope), transcript: [] }, diff --git a/apps/web/e2e/file-comments.spec.ts b/apps/web/e2e/file-comments.spec.ts new file mode 100644 index 000000000..92dde9e25 --- /dev/null +++ b/apps/web/e2e/file-comments.spec.ts @@ -0,0 +1,231 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { expect, test } from '@playwright/test'; + +import { openNewCanvas } from './helpers'; + +import type * as QuestionModule from '../src/components/Nodes/question/questionCompose'; +import type * as CanvasModule from '../src/store/canvasStore'; +import type * as ChatModule from '../src/store/chatStore'; +import type * as PanelModule from '../src/store/panelStore'; +import type * as PreviewActions from '../src/store/previewWorkspace/actions'; +import type * as PreviewModule from '../src/store/previewWorkspace/store'; +import type * as ToolModule from '../src/store/toolStore'; + +const sourceText = 'Before selected passage after.\n\nSecond line.'; + +for (const mode of ['mouse', 'touch'] as const) { + test(`file preview ${mode} stages a frozen partial-file Comment without a read, send or navigation`, async ({ + page, + }) => { + const pageErrors: string[] = []; + page.on('pageerror', (error) => pageErrors.push(error.message)); + await openNewCanvas(page); + await page.keyboard.press('Escape'); + let reads = 0; + await page.route('**/api/acp/files', async (route) => { + reads++; + expect(route.request().postDataJSON()).toMatchObject({ + agentletId: 'source-device', + operation: 'fs-read', + args: { path: '/source.tex' }, + }); + await route.fulfill({ + json: { + operation: 'fs-read', + result: { + path: '/canonical/source.tex', + text: sourceText, + encoding: 'utf-8', + byteLength: sourceText.length, + size: 400_000, + contentVersion: 'a'.repeat(64), + nextCursor: 'more', + }, + }, + }); + }); + const canvasId = page.url().split('/canvas/')[1]?.split(/[?#]/)[0]; + if (!canvasId) throw new Error('Missing Space'); + const response = await page.request.post( + `/api/canvas/${canvasId}/execute`, + { + data: { + commands: [ + { + type: 'CREATE_NODES', + nodes: [ + { + id: 'node-file-source', + nodeType: 'fileReference', + data: { + label: 'Source file', + fileReference: { + agentletId: 'source-device', + path: '/source.tex', + }, + }, + position: { x: 100, y: 100 }, + }, + ], + }, + ], + originator: { source: 'agent', threadId: 'e2e-file-comments' }, + }, + }, + ); + expect(response.ok(), await response.text()).toBe(true); + await expect(page.locator('.react-flow__node-fileReference')).toHaveCount( + 1, + ); + const destination = await page.evaluate( + async ({ canvasId, mode }) => { + const canvasPath = '/src/store/canvasStore.ts'; + const questionPath = + '/src/components/Nodes/question/questionCompose.ts'; + const previewPath = '/src/store/previewWorkspace/actions.ts'; + const chatPath = '/src/store/chatStore.ts'; + const toolPath = '/src/store/toolStore.ts'; + const { default: canvas, awaitQuestionCreation } = (await import( + canvasPath + )) as typeof CanvasModule; + const { createQuestionNode } = (await import( + questionPath + )) as typeof QuestionModule; + const { openPreviewNode } = (await import( + previewPath + )) as typeof PreviewActions; + const { useChatStore } = (await import(chatPath)) as typeof ChatModule; + const { useToolStore } = (await import(toolPath)) as typeof ToolModule; + useToolStore.getState().setInputModePreference(mode); + const created = createQuestionNode({ + canvasId, + addNode: canvas.getState().addNode, + placementPoint: { x: 600, y: 100 }, + binding: { kind: 'internal' }, + mode: 'ask', + label: 'File Reader', + }); + await awaitQuestionCreation(canvasId, created.nodeId); + useChatStore.getState().setDraft(created.threadId, 'Keep instructions'); + openPreviewNode('node-file-source', { + transient: false, + focusEditor: false, + }); + return created; + }, + { canvasId, mode }, + ); + const editor = page.locator('.cm-content'); + await expect(editor).toBeVisible(); + const initialReads = reads; + const before = await page.evaluate(async () => { + const previewPath = '/src/store/previewWorkspace/store.ts'; + const panelPath = '/src/store/panelStore.ts'; + const { usePreviewWorkspaceStore } = (await import( + previewPath + )) as typeof PreviewModule; + const { usePanelStore } = (await import(panelPath)) as typeof PanelModule; + return { + workspace: usePreviewWorkspaceStore.getState().workspace, + collapsed: usePanelStore.getState().isRightCollapsed, + viewport: document.querySelector('.react-flow__viewport') + ?.style.transform, + }; + }); + let turns = 0; + page.on('request', (request) => { + if ( + request.method() === 'POST' && + /\/api\/agent(?:\?|$)/.test(request.url()) + ) + turns++; + }); + if (mode === 'mouse') { + await editor.locator('.cm-line').first().click({ clickCount: 3 }); + await page.getByRole('button', { name: 'Comment', exact: true }).click(); + } else { + await editor.locator('.cm-line').first().tap(); + await editor + .locator('.cm-line') + .first() + .evaluate((line) => { + const range = document.createRange(); + range.selectNodeContents(line); + const selection = window.getSelection(); + if (!selection) throw new Error('Missing document selection'); + selection.removeAllRanges(); + selection.addRange(range); + document.dispatchEvent(new Event('selectionchange')); + }); + await page.getByRole('button', { name: 'Comment', exact: true }).tap(); + } + await expect( + page.getByText('Captured file snapshot', { exact: false }), + ).toContainText('/canonical/source.tex'); + await page + .getByRole('textbox', { name: 'Comment', exact: true }) + .fill('Explain the file'); + const add = page.getByRole('button', { name: 'Add to input', exact: true }); + await expect(add).toBeEnabled(); + if (mode === 'touch') await add.tap(); + else await add.click(); + await expect( + page.getByRole('button', { name: 'View input', exact: true }), + ).toBeVisible(); + const after = await page.evaluate(async ({ threadId }) => { + const previewPath = '/src/store/previewWorkspace/store.ts'; + const panelPath = '/src/store/panelStore.ts'; + const chatPath = '/src/store/chatStore.ts'; + const { usePreviewWorkspaceStore } = (await import( + previewPath + )) as typeof PreviewModule; + const { usePanelStore } = (await import(panelPath)) as typeof PanelModule; + const { useChatStore } = (await import(chatPath)) as typeof ChatModule; + return { + workspace: usePreviewWorkspaceStore.getState().workspace, + collapsed: usePanelStore.getState().isRightCollapsed, + viewport: document.querySelector('.react-flow__viewport') + ?.style.transform, + focus: usePanelStore.getState().focusChatInputRequest, + thread: useChatStore.getState().threadsById[threadId], + }; + }, destination); + expect(after.workspace).toEqual(before.workspace); + expect(after.collapsed).toBe(before.collapsed); + expect(after.viewport).toBe(before.viewport); + expect(after.focus).toBeNull(); + expect(after.thread.draft).toBe('Keep instructions'); + expect(after.thread.pendingQuotedComments).toMatchObject([ + { + quote: 'Before selected passage after.', + comment: 'Explain the file', + source: { + kind: 'file-reference', + canvasId, + nodeId: 'node-file-source', + agentletId: 'source-device', + path: '/canonical/source.tex', + contentVersion: 'a'.repeat(64), + offsetUnit: 'utf-16', + complete: false, + loadedLength: sourceText.length, + }, + }, + ]); + expect(reads).toBe(initialReads); + expect(turns).toBe(0); + expect(pageErrors).toEqual([]); + await page.getByRole('button', { name: 'View input', exact: true }).click(); + const composer = page.locator('textarea[name="agent-message"]'); + await expect(composer).toHaveValue('Keep instructions'); + await expect(composer).toBeFocused(); + await expect( + page.locator('[data-chat-input-surface] [data-quoted-comment]'), + ).toHaveCount(1); + await expect(page.locator('[data-chat-input-surface]')).not.toContainText( + 'contentVersion', + ); + }); +} diff --git a/apps/web/src/components/CodeMirror/RawMarkdownEditor.tsx b/apps/web/src/components/CodeMirror/RawMarkdownEditor.tsx index cc790aea7..29d60dab2 100644 --- a/apps/web/src/components/CodeMirror/RawMarkdownEditor.tsx +++ b/apps/web/src/components/CodeMirror/RawMarkdownEditor.tsx @@ -85,6 +85,8 @@ export interface RawMarkdownEditorProps { ariaLabel?: string; searchNodeId?: string; plainText?: boolean; + /** Reports the mounted view and null on teardown for opt-in integrations. */ + onReady?: (view: EditorView | null) => void; } export default function RawMarkdownEditor({ @@ -95,6 +97,7 @@ export default function RawMarkdownEditor({ ariaLabel, searchNodeId, plainText = false, + onReady, }: RawMarkdownEditorProps): React.JSX.Element { const hostRef = useRef(null); const viewRef = useRef(null); @@ -102,6 +105,8 @@ export default function RawMarkdownEditor({ /** Latest `onChange` accessed by the update listener without remount. */ const onChangeRef = useRef(onChange); onChangeRef.current = onChange; + const onReadyRef = useRef(onReady); + onReadyRef.current = onReady; /** Compartment that lets us hot-swap the read-only facet at runtime. */ const readOnlyCompartmentRef = useRef(new Compartment()); const query = usePreviewSearchStore((state) => @@ -211,10 +216,12 @@ export default function RawMarkdownEditor({ if (ariaLabel) { view.contentDOM.setAttribute('aria-label', ariaLabel); } + onReadyRef.current?.(view); return () => { view.destroy(); viewRef.current = null; + onReadyRef.current?.(null); host.removeAttribute('data-search-source-ready'); }; // Intentionally mount-only — see dedicated reconciliation effects. diff --git a/apps/web/src/components/Messages/AIMessage/FragmentCommentPopover.tsx b/apps/web/src/components/Messages/AIMessage/FragmentCommentPopover.tsx index 7b4467d24..0008a3a65 100644 --- a/apps/web/src/components/Messages/AIMessage/FragmentCommentPopover.tsx +++ b/apps/web/src/components/Messages/AIMessage/FragmentCommentPopover.tsx @@ -15,8 +15,13 @@ import { FragmentSelectionActions } from './FragmentSelectionActions'; import type { MilkdownPreviewSelection } from '@/components/Milkdown/MilkdownPreview'; import type { ReactNode } from 'react'; +export type FragmentCommentSelection = Pick< + MilkdownPreviewSelection, + 'text' | 'contextBefore' | 'contextAfter' | 'position' +>; + interface FragmentCommentPopoverProps { - selection: MilkdownPreviewSelection; + selection: FragmentCommentSelection; onBegin: () => void; onAdd: (comment: string) => void; onCopy: () => Promise; diff --git a/apps/web/src/components/Messages/PreviewCommentEditor.tsx b/apps/web/src/components/Messages/PreviewCommentEditor.tsx new file mode 100644 index 000000000..24c8dee1e --- /dev/null +++ b/apps/web/src/components/Messages/PreviewCommentEditor.tsx @@ -0,0 +1,152 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { ChevronDown } from 'lucide-react'; +import { useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { Button } from '@/components/Common/Button'; +import { + DropdownMenu, + DropdownMenuItem, +} from '@/components/Common/DropdownMenu'; +import { useCommentDestination } from '@/hooks/useCommentDestination'; +import useCanvasStore from '@/store/canvasStore'; +import { useChatStore } from '@/store/chatStore'; +import { conversationViewForNode } from '@/store/conversationOwner'; +import { copyToClipboard } from '@/utils/io/clipboard'; + +import { FragmentCommentPopover } from './AIMessage/FragmentCommentPopover'; +import { revealQuotedCommentDraft } from './quotedCommentNotification'; + +import type { FragmentCommentSelection } from './AIMessage/FragmentCommentPopover'; +import type { QuotedComment } from '@huabu/shared'; + +export interface PreviewCommentCapture { + selection: FragmentCommentSelection; + source: NonNullable; +} + +export function PreviewCommentEditor({ + capture, + onDismiss, +}: { + capture: PreviewCommentCapture; + onDismiss: () => void; +}) { + const { t } = useTranslation(); + const target = useCommentDestination(capture.source.canvasId); + const [stagingError, setStagingError] = useState(false); + const [open, setOpen] = useState(false); + const staged = useRef(false); + const selected = target.candidates.find( + (item) => item.nodeId === target.destination?.nodeId, + ); + const label = selected + ? selected.title || t('messages.commentUntitledDestination') + : t( + target.loading + ? 'messages.commentDestinationLoading' + : target.destination + ? 'messages.commentDestinationUnavailable' + : 'messages.commentChooseDestination', + ); + return ( + undefined} + onCopy={() => copyToClipboard(capture.selection.text)} + onSaveAsNote={() => undefined} + onDismiss={onDismiss} + addDisabled={!target.available || target.loading} + onAdd={(comment) => { + if (staged.current) return; + const canvas = useCanvasStore.getState(); + const owner = target.destination; + const node = canvas.nodes.find((item) => item.id === owner?.nodeId); + const view = node + ? conversationViewForNode(node, capture.source.canvasId) + : null; + if ( + canvas.canvasId !== capture.source.canvasId || + !view || + view.conversationOwner.threadId !== owner?.threadId || + canvas.pendingForkThreadIds[view.conversationOwner.threadId] + ) { + setStagingError(true); + return; + } + staged.current = true; + useChatStore + .getState() + .addQuotedComment(view.conversationOwner.threadId, { + quote: capture.selection.text, + contextBefore: capture.selection.contextBefore, + contextAfter: capture.selection.contextAfter, + comment, + source: capture.source, + }); + onDismiss(); + revealQuotedCommentDraft(view, label); + }} + > + {capture.source.kind === 'file-reference' ? ( +

+ {t('messages.commentCapturedFile')} · {capture.source.path} +

+ ) : null} + + {label} + + + } + > + {target.candidates.map((item) => ( + { + target.choose({ nodeId: item.nodeId, threadId: item.threadId }); + setStagingError(false); + setOpen(false); + }} + > + {item.title || t('messages.commentUntitledDestination')} + + ))} + {!target.candidates.length ? ( +

+ {t('messages.commentNoDestinations')} +

+ ) : null} +
+ {target.error ? ( +
+ {t('messages.commentDestinationFailed')} + +
+ ) : null} + {stagingError || (target.destination && !target.available) ? ( +

+ {t('messages.commentDestinationUnavailable')} +

+ ) : null} +
+ ); +} diff --git a/apps/web/src/components/Nodes/fileReference/FileCommentActions.test.tsx b/apps/web/src/components/Nodes/fileReference/FileCommentActions.test.tsx new file mode 100644 index 000000000..5d2f3ca1d --- /dev/null +++ b/apps/web/src/components/Nodes/fileReference/FileCommentActions.test.tsx @@ -0,0 +1,264 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { EditorSelection, EditorState } from '@codemirror/state'; +import { EditorView } from '@codemirror/view'; +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; + +import { getRecentCanvasConversation } from '@/api/canvas'; +import { i18n } from '@/i18n'; +import useCanvasStore from '@/store/canvasStore'; +import { useChatStore } from '@/store/chatStore'; +import { usePanelStore } from '@/store/panelStore'; +import { createEmptyWorkspace } from '@/store/previewWorkspace/model'; +import { usePreviewWorkspaceStore } from '@/store/previewWorkspace/store'; + +import { captureFileComment, FileCommentActions } from './FileCommentActions'; + +import type * as CanvasApi from '@/api/canvas'; +import type { toast } from '@/components/Common/Toast'; +import type { FileReadResult } from '@huabu/shared'; + +const notices = vi.hoisted(() => ({ + toast: vi.fn(() => 'notice'), + dismissToast: vi.fn(), +})); +vi.mock('@/components/Common/Toast', () => notices); +vi.mock('@/api/canvas', async (original) => ({ + ...(await original()), + getRecentCanvasConversation: vi.fn(), +})); + +const text = 'before 😀 selected passage after\nsecond line'; +const file: FileReadResult = { + path: '/canonical/source.tex', + text, + encoding: 'utf-8', + contentVersion: 'a'.repeat(64), + size: 400_000, + byteLength: new TextEncoder().encode(text).length, + nextCursor: 'more', +}; +const identity = { canvasId: 'space', nodeId: 'file', agentletId: 'device' }; +let host: HTMLDivElement; +let surface: HTMLDivElement; +let root: Root; +let view: EditorView; + +beforeEach(async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + await i18n.changeLanguage('en'); + notices.toast.mockClear(); + notices.dismissToast.mockClear(); + vi.mocked(getRecentCanvasConversation).mockResolvedValue({ + conversation: null, + }); + host = document.createElement('div'); + surface = document.createElement('div'); + document.body.append(host, surface); + root = createRoot(host); + view = new EditorView({ + state: EditorState.create({ + doc: text, + selection: { + anchor: text.indexOf('selected'), + head: text.indexOf(' after'), + }, + extensions: [ + EditorState.readOnly.of(true), + EditorState.allowMultipleSelections.of(true), + ], + }), + parent: surface, + }); + vi.spyOn(view, 'coordsAtPos').mockReturnValue({ + left: 20, + right: 100, + top: 20, + bottom: 40, + }); + vi.spyOn(view, 'requestMeasure').mockImplementation((request) => { + if (request) request.write?.(request.read(view), view); + }); + useCanvasStore.setState({ + canvasId: 'space', + pendingForkThreadIds: {}, + nodes: [ + { + id: 'file', + type: 'fileReference', + position: { x: 0, y: 0 }, + data: { + fileReference: { agentletId: 'device', path: file.path }, + }, + }, + { + id: 'agent', + type: 'question', + position: { x: 0, y: 0 }, + data: { + label: 'Reader', + threadId: 'thread', + agentMode: 'ask', + status: 'running', + }, + }, + ], + }); + useChatStore.setState({ threadsById: {} }); + useChatStore.getState().setDraft('thread', 'Keep instructions'); + usePanelStore.setState({ + isRightCollapsed: false, + focusChatInputRequest: null, + }); + usePreviewWorkspaceStore.setState({ + canvasId: 'space', + workspace: createEmptyWorkspace('group'), + }); +}); +afterEach(async () => { + await act(async () => root.unmount()); + view.destroy(); + host.remove(); + surface.remove(); + act(() => useCanvasStore.setState({ canvasId: '', nodes: [] })); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +async function render(value = file, disabled = false) { + await act(async () => + root.render( + , + ), + ); +} +function button(name: string) { + const found = [...document.querySelectorAll('button')].find( + (item) => + item.getAttribute('aria-label') === name || + item.textContent?.trim() === name, + ); + if (!found) throw new Error(`Missing ${name}`); + return found; +} + +it('captures source-device identity, UTF-16 offsets and loaded-prefix context from the model', () => { + const capture = captureFileComment(view, identity, file); + expect(capture).toMatchObject({ + selection: { + text: 'selected passage', + contextBefore: 'before 😀 ', + contextAfter: ' after\nsecond line', + }, + source: { + kind: 'file-reference', + ...identity, + path: file.path, + contentVersion: file.contentVersion, + from: 10, + to: 26, + offsetUnit: 'utf-16', + loadedLength: text.length, + complete: false, + }, + }); + expect(capture?.source).not.toHaveProperty('snapshotRevision'); + expect( + captureFileComment(view, identity, { ...file, nextCursor: undefined }) + ?.source, + ).toMatchObject({ complete: true }); +}); + +it('rejects empty, whitespace, disjoint or stale-document selections', () => { + for (const selection of [ + EditorSelection.single(0), + EditorSelection.single(6, 7), + EditorSelection.create([ + EditorSelection.range(0, 3), + EditorSelection.range(10, 15), + ]), + ]) { + view.dispatch({ selection }); + expect(captureFileComment(view, identity, file)).toBeNull(); + } + view.dispatch({ selection: { anchor: 10, head: 26 } }); + expect( + captureFileComment(view, identity, { ...file, text: 'replacement' }), + ).toBeNull(); +}); + +it('bounds context to loaded text and 40 UTF-16 positions without depending on rendered lines', () => { + const long = `${'a'.repeat(200)}excerpt${'b'.repeat(200)}`; + view.dispatch({ + changes: { from: 0, to: view.state.doc.length, insert: long }, + selection: { anchor: 200, head: 207 }, + }); + const capture = captureFileComment(view, identity, { ...file, text: long }); + expect(capture?.selection.contextBefore).toBe('a'.repeat(40)); + expect(capture?.selection.contextAfter).toBe('b'.repeat(40)); + expect(capture?.selection.text).toBe('excerpt'); +}); + +it('keeps an opened snapshot frozen across refresh/error and stages without navigation, creation or send', async () => { + await render(); + const workspace = usePreviewWorkspaceStore.getState().workspace; + await act(async () => button('Comment').click()); + expect(document.body.textContent).toContain(file.path); + expect(document.body.textContent).toContain('Captured file snapshot'); + const input = document.querySelector( + 'textarea[aria-label="Comment"]', + ); + if (!input) throw new Error('Missing Comment input'); + const setter = Object.getOwnPropertyDescriptor( + HTMLTextAreaElement.prototype, + 'value', + )?.set; + if (!setter) throw new Error('Missing textarea setter'); + await act(async () => { + setter.call(input, 'Explain this file'); + input.dispatchEvent(new Event('input', { bubbles: true })); + }); + await render( + { ...file, text: 'replacement', contentVersion: 'b'.repeat(64) }, + true, + ); + await act(async () => { + button('Add to input').click(); + }); + expect(useChatStore.getState().threadsById.thread.draft).toBe( + 'Keep instructions', + ); + const [quote] = + useChatStore.getState().threadsById.thread.pendingQuotedComments; + expect(quote).toMatchObject({ + quote: 'selected passage', + comment: 'Explain this file', + source: { + contentVersion: file.contentVersion, + path: file.path, + agentletId: 'device', + }, + }); + expect(usePreviewWorkspaceStore.getState().workspace).toBe(workspace); + expect(usePanelStore.getState().focusChatInputRequest).toBeNull(); + expect(useCanvasStore.getState().nodes).toHaveLength(2); + expect(notices.toast).toHaveBeenCalledOnce(); + expect(view.state.readOnly).toBe(true); +}); + +it('does not offer new captures while loading or showing a read failure', async () => { + await render(file, true); + expect(document.querySelector('button[aria-label="Comment"]')).toBeNull(); + await render(file, false); + expect(button('Comment')).toBeDefined(); + expect(document.body.textContent).not.toContain('New Agent'); +}); diff --git a/apps/web/src/components/Nodes/fileReference/FileCommentActions.tsx b/apps/web/src/components/Nodes/fileReference/FileCommentActions.tsx new file mode 100644 index 000000000..a911f884f --- /dev/null +++ b/apps/web/src/components/Nodes/fileReference/FileCommentActions.tsx @@ -0,0 +1,177 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { Compartment, StateEffect } from '@codemirror/state'; +import { EditorView } from '@codemirror/view'; +import { MessageSquare } from 'lucide-react'; +import { useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { Button } from '@/components/Common/Button'; +import { Popover } from '@/components/Common/Popover'; +import { toast } from '@/components/Common/Toast'; +import { + PreviewCommentEditor, + type PreviewCommentCapture, +} from '@/components/Messages/PreviewCommentEditor'; +import { useTrackAttention } from '@/hooks/useTrackAttention'; +import { useAnyGlobalModalOpen } from '@/store/globalModalUi'; + +import type { FileReadResult } from '@huabu/shared'; +import type { RefObject } from 'react'; + +export function captureFileComment( + view: EditorView, + source: { canvasId: string; nodeId: string; agentletId: string }, + file: FileReadResult, +): PreviewCommentCapture | null { + const { doc, selection } = view.state; + if (doc.toString() !== file.text.replace(/\r\n?/g, '\n')) return null; + if (selection.ranges.length !== 1 || selection.main.empty) return null; + const { from, to } = selection.main; + const text = doc.sliceString(from, to); + if (!text.trim()) return null; + const rect = view.coordsAtPos(selection.main.head); + if (!rect) return null; + return { + selection: { + text, + contextBefore: doc.sliceString(Math.max(0, from - 40), from), + contextAfter: doc.sliceString(to, Math.min(doc.length, to + 40)), + position: { x: rect.right, y: rect.bottom }, + }, + source: { + kind: 'file-reference', + ...source, + path: file.path, + contentVersion: file.contentVersion, + offsetUnit: 'utf-16', + from, + to, + loadedLength: doc.length, + complete: !file.nextCursor, + }, + }; +} + +export function FileCommentActions({ + view, + surfaceRef, + canvasId, + nodeId, + agentletId, + file, + disabled, +}: { + view: EditorView | null; + surfaceRef: RefObject; + canvasId: string; + nodeId: string; + agentletId: string; + file: FileReadResult | null; + disabled: boolean; +}) { + const { t } = useTranslation(); + const compartment = useRef(new Compartment()).current; + const [selection, setSelection] = useState( + null, + ); + const [capture, setCapture] = useState(null); + const [engaged, setEngaged] = useState(true); + const [dismissed, setDismissed] = useState(false); + const modalOpen = useAnyGlobalModalOpen(); + useTrackAttention( + (target) => surfaceRef.current?.contains(target) ?? false, + setEngaged, + ); + useEffect(() => { + setSelection(null); + if (!view || !file || disabled) return; + let active = true; + const update = () => { + view.requestMeasure({ + key: compartment, + read: () => + active + ? captureFileComment(view, { canvasId, nodeId, agentletId }, file) + : null, + write: (next) => { + if (active) setSelection(next); + }, + }); + }; + const listener = EditorView.updateListener.of((event) => { + if (event.selectionSet || event.docChanged) setDismissed(false); + if (event.selectionSet || event.docChanged || event.geometryChanged) + update(); + }); + view.dispatch({ + effects: + compartment.get(view.state) === undefined + ? StateEffect.appendConfig.of(compartment.of(listener)) + : compartment.reconfigure(listener), + }); + update(); + const onScroll = () => setSelection(null); + window.addEventListener('scroll', onScroll, true); + return () => { + active = false; + view.dispatch({ effects: compartment.reconfigure([]) }); + window.removeEventListener('scroll', onScroll, true); + }; + }, [view, file, disabled, canvasId, nodeId, agentletId, compartment]); + + return ( + <> + {selection && + !capture && + !disabled && + engaged && + !modalOpen && + !dismissed ? ( + setDismissed(true)} + > + + + ) : null} + {capture ? ( + { + setCapture(null); + setDismissed(true); + }} + /> + ) : null} + + ); +} diff --git a/apps/web/src/components/Nodes/fileReference/FileReferencePreview.test.tsx b/apps/web/src/components/Nodes/fileReference/FileReferencePreview.test.tsx index cdebc6fd1..8bfd8c25d 100644 --- a/apps/web/src/components/Nodes/fileReference/FileReferencePreview.test.tsx +++ b/apps/web/src/components/Nodes/fileReference/FileReferencePreview.test.tsx @@ -8,6 +8,7 @@ import { afterEach, beforeEach, expect, it, vi } from 'vitest'; import { FileReferencePreview } from './FileReferencePreview'; import type { FileReadResult } from '@huabu/shared'; +import type * as ReactI18next from 'react-i18next'; const mocks = vi.hoisted(() => ({ read: vi.fn(), @@ -15,7 +16,10 @@ const mocks = vi.hoisted(() => ({ t: (key: string) => key, })); vi.mock('@/api/device-files', () => ({ readDeviceFile: mocks.read })); -vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: mocks.t }) })); +vi.mock('react-i18next', async (original) => ({ + ...(await original()), + useTranslation: () => ({ t: mocks.t }), +})); vi.mock('@/components/CodeMirror/RawMarkdownEditor', () => ({ default: (props: { value: string; diff --git a/apps/web/src/components/Nodes/fileReference/FileReferencePreview.tsx b/apps/web/src/components/Nodes/fileReference/FileReferencePreview.tsx index 938f0ef61..39b155e5c 100644 --- a/apps/web/src/components/Nodes/fileReference/FileReferencePreview.tsx +++ b/apps/web/src/components/Nodes/fileReference/FileReferencePreview.tsx @@ -8,13 +8,18 @@ import { isFileReference } from '@huabu/shared'; import { readDeviceFile } from '@/api/device-files'; import { Button } from '@/components/Common/Button'; +import useCanvasStore from '@/store/canvasStore'; import type { PreviewComponentProps } from '../previews'; +import type { EditorView } from '@codemirror/view'; import type { FileReadResult } from '@huabu/shared'; const SourceViewer = lazy( () => import('@/components/CodeMirror/RawMarkdownEditor'), ); +const CommentActions = lazy(async () => ({ + default: (await import('./FileCommentActions')).FileCommentActions, +})); const MAX_PREVIEW_BYTES = 262_144; export function FileReferencePreview({ id, data }: PreviewComponentProps) { @@ -31,6 +36,9 @@ export function FileReferencePreview({ id, data }: PreviewComponentProps) { const generation = useRef(0); const controller = useRef(null); const loadedBytes = useRef(0); + const surfaceRef = useRef(null); + const canvasId = useCanvasStore((state) => state.canvasId); + const [view, setView] = useState(null); useEffect(() => { const current = ++generation.current; controller.current?.abort(); @@ -105,7 +113,21 @@ export function FileReferencePreview({ id, data }: PreviewComponentProps) { ); return ( -
+
+ {id && canvasId && agentletId ? ( + + + + ) : null}
{reference.deviceName ?? agentletId} · {path} @@ -136,6 +158,7 @@ export function FileReferencePreview({ id, data }: PreviewComponentProps) { value={result.text} readOnly plainText + onReady={setView} searchNodeId={id} ariaLabel={t('files.readOnlyText')} className="min-h-0 flex-1 overflow-auto" diff --git a/apps/web/src/components/Nodes/note/NoteCommentActions.tsx b/apps/web/src/components/Nodes/note/NoteCommentActions.tsx index 0c88b577b..8e24e7afa 100644 --- a/apps/web/src/components/Nodes/note/NoteCommentActions.tsx +++ b/apps/web/src/components/Nodes/note/NoteCommentActions.tsx @@ -1,157 +1,24 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. -import { ChevronDown } from 'lucide-react'; -import { useRef, useState } from 'react'; +import { useState } from 'react'; import { useTranslation } from 'react-i18next'; import { nodeRevisionOf } from '@huabu/shared/canvas-engine'; -import { Button } from '@/components/Common/Button'; -import { - DropdownMenu, - DropdownMenuItem, -} from '@/components/Common/DropdownMenu'; import { toast } from '@/components/Common/Toast'; -import { FragmentCommentPopover } from '@/components/Messages/AIMessage/FragmentCommentPopover'; import { FragmentSelectionActions } from '@/components/Messages/AIMessage/FragmentSelectionActions'; -import { revealQuotedCommentDraft } from '@/components/Messages/quotedCommentNotification'; +import { + PreviewCommentEditor, + type PreviewCommentCapture, +} from '@/components/Messages/PreviewCommentEditor'; import { MilkdownFloatingToolbar } from '@/components/Milkdown/MilkdownFloatingToolbar'; -import { useCommentDestination } from '@/hooks/useCommentDestination'; import useCanvasStore from '@/store/canvasStore'; -import { useChatStore } from '@/store/chatStore'; -import { conversationViewForNode } from '@/store/conversationOwner'; import { copyToClipboard } from '@/utils/io/clipboard'; import type { MilkdownInstance } from '@/components/Milkdown'; -import type { MilkdownPreviewSelection } from '@/components/Milkdown/MilkdownPreview'; -import type { QuotedComment } from '@huabu/shared'; import type { RefObject } from 'react'; -interface Capture { - selection: MilkdownPreviewSelection; - source: NonNullable; -} - -function NoteCommentEditor({ - capture, - onDismiss, -}: { - capture: Capture; - onDismiss: () => void; -}) { - const { t } = useTranslation(); - const target = useCommentDestination(capture.source.canvasId); - const [stagingError, setStagingError] = useState(false); - const [open, setOpen] = useState(false); - const staged = useRef(false); - const selected = target.candidates.find( - (item) => item.nodeId === target.destination?.nodeId, - ); - const label = selected - ? selected.title || t('messages.commentUntitledDestination') - : t( - target.loading - ? 'messages.commentDestinationLoading' - : target.destination - ? 'messages.commentDestinationUnavailable' - : 'messages.commentChooseDestination', - ); - return ( - undefined} - onCopy={() => copyToClipboard(capture.selection.text)} - onSaveAsNote={() => undefined} - onDismiss={onDismiss} - addDisabled={!target.available || target.loading} - onAdd={(comment) => { - if (staged.current) return; - const canvas = useCanvasStore.getState(); - const owner = target.destination; - const node = canvas.nodes.find((item) => item.id === owner?.nodeId); - const view = node - ? conversationViewForNode(node, capture.source.canvasId) - : null; - if ( - canvas.canvasId !== capture.source.canvasId || - !view || - view.conversationOwner.threadId !== owner?.threadId || - canvas.pendingForkThreadIds[view.conversationOwner.threadId] - ) { - setStagingError(true); - return; - } - staged.current = true; - useChatStore - .getState() - .addQuotedComment(view.conversationOwner.threadId, { - quote: capture.selection.text, - contextBefore: capture.selection.contextBefore, - contextAfter: capture.selection.contextAfter, - comment, - source: capture.source, - }); - onDismiss(); - revealQuotedCommentDraft(view, label); - }} - > - - {label} - - - } - > - {target.candidates.map((item) => ( - { - target.choose({ nodeId: item.nodeId, threadId: item.threadId }); - setStagingError(false); - setOpen(false); - }} - > - {item.title || t('messages.commentUntitledDestination')} - - ))} - {!target.candidates.length ? ( -

- {t('messages.commentNoDestinations')} -

- ) : null} -
- {target.error ? ( -
- {t('messages.commentDestinationFailed')} - -
- ) : null} - {stagingError || (target.destination && !target.available) ? ( -

- {t('messages.commentDestinationUnavailable')} -

- ) : null} -
- ); -} - export function NoteCommentActions({ editor, surfaceRef, @@ -166,7 +33,7 @@ export function NoteCommentActions({ mode: 'edit' | 'selection'; }) { const { t } = useTranslation(); - const [capture, setCapture] = useState(null); + const [capture, setCapture] = useState(null); const readSelection = () => { const selection = editor?.getTextSelection(); const rect = editor?.getSelectionClientRect(); @@ -230,7 +97,7 @@ export function NoteCommentActions({ /> {capture ? ( - setCapture(null)} /> diff --git a/apps/web/src/hooks/agentTurnController.test.ts b/apps/web/src/hooks/agentTurnController.test.ts index f18d77e8a..a60fd2215 100644 --- a/apps/web/src/hooks/agentTurnController.test.ts +++ b/apps/web/src/hooks/agentTurnController.test.ts @@ -1118,6 +1118,25 @@ describe('shared Agent turn input', () => { snapshotRevision: 'snapshot', }, }, + { + quote: 'File passage', + contextBefore: 'Before ', + contextAfter: ' after', + comment: 'Explain file.', + source: { + kind: 'file-reference' as const, + canvasId: 'canvas-1', + nodeId: 'file', + agentletId: 'source-device', + path: '/source.tex', + contentVersion: 'a'.repeat(64), + offsetUnit: 'utf-16' as const, + from: 7, + to: 19, + loadedLength: 40, + complete: false, + }, + }, ]; const prepared = request(); prepared.content = ''; diff --git a/apps/web/src/hooks/useChatHistory.test.tsx b/apps/web/src/hooks/useChatHistory.test.tsx index f34ef914f..d280316a6 100644 --- a/apps/web/src/hooks/useChatHistory.test.tsx +++ b/apps/web/src/hooks/useChatHistory.test.tsx @@ -269,6 +269,25 @@ describe('useChatHistory paging', () => { snapshotRevision: 'captured', }, }, + { + quote: 'File passage', + contextBefore: 'Before ', + contextAfter: ' after', + comment: 'Explain file.', + source: { + kind: 'file-reference' as const, + canvasId: 'space', + nodeId: 'file', + agentletId: 'source-device', + path: '/source.tex', + contentVersion: 'a'.repeat(64), + offsetUnit: 'utf-16' as const, + from: 7, + to: 19, + loadedLength: 40, + complete: false, + }, + }, ]; apiMocks.fetchHistoryPage.mockResolvedValueOnce({ threadId: THREAD_ID, diff --git a/apps/web/src/i18n/resources/en/common.json b/apps/web/src/i18n/resources/en/common.json index 511502580..8cdaf6cac 100644 --- a/apps/web/src/i18n/resources/en/common.json +++ b/apps/web/src/i18n/resources/en/common.json @@ -1233,6 +1233,7 @@ "fragmentComment": "Comment", "commentChooseDestination": "Choose an existing conversation", "commentSelectionUnavailable": "The text selection is no longer available. Select the passage again.", + "commentCapturedFile": "Captured file snapshot", "commentDestinationLoading": "Loading conversation…", "commentDestinationUnavailable": "This conversation is unavailable. Choose another destination.", "commentNoDestinations": "Create a conversation separately before adding a comment.", diff --git a/apps/web/src/i18n/resources/zh-CN/common.json b/apps/web/src/i18n/resources/zh-CN/common.json index fc2be2b34..85833e8fb 100644 --- a/apps/web/src/i18n/resources/zh-CN/common.json +++ b/apps/web/src/i18n/resources/zh-CN/common.json @@ -1233,6 +1233,7 @@ "fragmentComment": "评论", "commentChooseDestination": "选择已有对话", "commentSelectionUnavailable": "文字选区已失效,请重新选择引用内容。", + "commentCapturedFile": "已捕获的文件快照", "commentDestinationLoading": "正在加载对话…", "commentDestinationUnavailable": "此对话不可用,请选择其他目标。", "commentNoDestinations": "请先单独创建对话,再添加评论。", diff --git a/apps/web/src/store/chatStore.test.ts b/apps/web/src/store/chatStore.test.ts index 8187c200f..ecf6f70be 100644 --- a/apps/web/src/store/chatStore.test.ts +++ b/apps/web/src/store/chatStore.test.ts @@ -36,6 +36,57 @@ describe('chatStore composer drafts', () => { }); }); + it('consumes only the exact file source and restores its frozen metadata on retry', () => { + const store = useChatStore.getState(); + const quote = { + quote: 'excerpt', + contextBefore: 'pre ', + contextAfter: ' after', + comment: 'Explain.', + source: { + kind: 'file-reference' as const, + canvasId: 'space', + nodeId: 'file', + agentletId: 'device', + path: '/source.tex', + contentVersion: 'a'.repeat(64), + offsetUnit: 'utf-16' as const, + from: 4, + to: 11, + loadedLength: 30, + complete: false, + }, + }; + store.addQuotedComment('thread', quote); + const sent = selectThreadQuotedComments(useChatStore.getState(), 'thread'); + const [entry] = sent; + for (const change of [ + { agentletId: 'other' }, + { path: '/other.tex' }, + { contentVersion: 'b'.repeat(64) }, + { from: 5 }, + { loadedLength: 40 }, + { complete: true }, + ]) { + store.consumeDraft('thread', '', [ + { ...entry, source: { ...quote.source, ...change } }, + ]); + expect( + selectThreadQuotedComments(useChatStore.getState(), 'thread'), + ).toHaveLength(1); + } + store.consumeDraft('thread', '', [ + { ...entry, source: { ...quote.source } }, + ]); + expect( + selectThreadQuotedComments(useChatStore.getState(), 'thread'), + ).toEqual([]); + store.restoreDraftIfEmpty('thread', '', sent); + expect( + selectThreadQuotedComments(useChatStore.getState(), 'thread'), + ).toEqual(sent); + }); + it('keeps quoted comments thread-local and supports editing and removal', () => { const store = useChatStore.getState(); const comment = { diff --git a/apps/web/src/store/chatStore.ts b/apps/web/src/store/chatStore.ts index e9995e4c4..24a07f894 100644 --- a/apps/web/src/store/chatStore.ts +++ b/apps/web/src/store/chatStore.ts @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. +import equal from 'fast-deep-equal'; import { create } from 'zustand'; import { persist } from 'zustand/middleware'; @@ -710,11 +711,7 @@ export const useChatStore = create()( sent.contextBefore === item.contextBefore && sent.contextAfter === item.contextAfter && sent.comment === item.comment && - sent.source?.kind === item.source?.kind && - sent.source?.canvasId === item.source?.canvasId && - sent.source?.nodeId === item.source?.nodeId && - sent.source?.snapshotRevision === - item.source?.snapshotRevision, + equal(sent.source, item.source), ), ), }); diff --git a/docs/architecture/agent-context.md b/docs/architecture/agent-context.md index 168933c31..0d3ae9b7f 100644 --- a/docs/architecture/agent-context.md +++ b/docs/architecture/agent-context.md @@ -23,6 +23,8 @@ The Chat composer lets users omit live Canvas selection from the next text messa Quoted Comments are independent of live Canvas sources. Chat captures earlier assistant passages in the current conversation; expanded Note rich text stages into an explicitly resolved existing conversation. Both retain quote, bounded neighboring context and user comment in the same structured `quotedComments` field. Note quotes additionally retain `source: { kind: 'note', canvasId, nodeId, snapshotRevision }`, frozen from the rendered document at excerpt capture rather than resolved again on Send/retry/history replay. The canonical escaped prompt renderer selects Chat or Note source wording and includes Note metadata as untrusted reference evidence, not in user-authored text or title/search text. A snapshot revision is a content fingerprint, not a save acknowledgement or edit capability. Adding a Comment never adds a whole-Note attachment or changes the live-selection inclusion control; a separately selected Note or parent Frame can still contribute context under existing rules. Sent metadata persists with the envelope/history; unsent drafts remain runtime-only. +File Reference Preview uses the same structured Comment staging with a `file-reference` source. The model receives the captured device/path, filesystem `contentVersion`, UTF-16 selection range, loaded-text extent/completeness and bounded selection context, not an automatic whole-file attachment or a fresh read on Send. Its escaped template explicitly distinguishes the source device from the Agent execution device and treats metadata and text as untrusted reference evidence. Refresh, read-more and source-file changes do not rewrite an opened or staged snapshot. File Comment does not change bootstrap machine identity, execution cwd, live Canvas inclusion or Ink submission. + --- ## 2. Chat path: the conversation module diff --git a/docs/architecture/api-design.md b/docs/architecture/api-design.md index 0b3db4dc7..74ee22763 100644 --- a/docs/architecture/api-design.md +++ b/docs/architecture/api-design.md @@ -120,6 +120,8 @@ export async function postEcho(body: EchoBody): Promise { `quotedCommentSchema` in `types/api/agent.ts`, reused by the Chat envelope, accepts optional `source: { kind: 'note', canvasId, nodeId, snapshotRevision }`. All source fields are nonempty strings; older Chat quotations omit `source` and retain assistant-passage rendering. Note source evidence describes the excerpt-time rendered document, not a saved-node CAS baseline or an authorization to access/edit that node. The server validates it with the existing Agent request/envelope schemas, preserves it in submitted history and renders escaped source metadata through the canonical source-specific Comment template shared by built-in and ACP Agents. No endpoint, recency write, new storage record or web runtime schema import is introduced. +The source is a discriminated union: File Reference Preview additionally accepts `{ kind: 'file-reference', canvasId, nodeId, agentletId, path, contentVersion, offsetUnit: 'utf-16', from, to, loadedLength, complete }`. Device/path validation reuses the device-files contract; contentVersion is a 64-digit lowercase hexadecimal filesystem-state fingerprint. Nonnegative integer offsets must satisfy `from < to <= loadedLength`; `complete` distinguishes the full file from a loaded prefix. Offsets refer to CodeMirror's normalized text, never raw UTF-8 bytes. File sources remain captured evidence rather than read/edit capabilities or assertions that the destination Agent can access the source device. The existing schema/envelope/history transport preserves the union, and draft consumption compares all source fields. + ## Agent Node owner-specific writes [`agent-node.ts`](../../packages/shared/src/types/api/agent-node.ts) defines the bounded editable node schema, launch overrides, association bodies, result acknowledgements, and internal projection shape. `PUT /api/canvas/:canvasId` accepts editable fields, not a complete replacement read model: omitted fields remain unchanged, Question FSM fields cannot be submitted, and the server composes from current state under the Canvas mutex. The same ownership guard applies to ordinary `MERGE_NODE_DATA`, regardless of the submitted originator. It does not reserve unrelated node types' `status` or thread-reference metadata. diff --git a/docs/architecture/device-files.md b/docs/architecture/device-files.md index 2d66ce450..00a12f5da 100644 --- a/docs/architecture/device-files.md +++ b/docs/architecture/device-files.md @@ -37,18 +37,23 @@ Files uses existing Common controls and `react-virtuoso` for a lazy expandable d Adding or dragging a file uses existing Canvas commands to create a `fileReference` node. Structural data stores only `{ agentletId, path, deviceName? }` under `data.fileReference`, plus ordinary presentation fields. The label uses a frontmatter-only node sidecar. Content/source mutation endpoints reject source bytes on these nodes. Duplication and bundle export preserve inert references, not external file contents. Loading a Canvas or processing/searching its nodes does not read remote files or import them into artifacts. -Opening a file-reference Preview reads supported UTF-8 text into component-local state. The view reuses read-only CodeMirror in plain-text mode; `.tex` remains source and is never compiled. Manual refresh restarts from the path, including a replacement made by atomic save. Read more continues up to a 256 KiB display cap; stale content and offline devices remain explicit errors until retry. No edit, download-as-import, AI ingestion, shell execution or automatic reference repair is provided. +Opening a file-reference Preview reads supported UTF-8 text into component-local state. The view reuses read-only CodeMirror in plain-text mode; `.tex` remains source and is never compiled. Manual refresh restarts from the path, including a replacement made by atomic save. Read more continues up to a 256 KiB display cap; stale content and offline devices remain explicit errors until retry. No edit, download-as-import, automatic AI ingestion, shell execution or automatic reference repair is provided. + +A single nonempty contiguous text selection offers Comment, using the existing Preview Comment editor and existing-conversation destination picker. Explicit staging shares only the captured quote and up to 40 UTF-16 positions of neighboring loaded text per side, not the full file or an implicit attachment. The structured file source freezes Space/reference-node ID, Agentlet ID, canonical returned path, `contentVersion`, UTF-16 selection offsets, loaded document length and completeness. Offsets address CodeMirror's line-ending-normalized text, not UTF-8 byte positions. `contentVersion` identifies observed filesystem state, not a content hash or a retained historical file; the source device may differ from the receiving Agent's execution device. Metadata appears in the escaped model reference template, not editable comment text. + +CodeMirror's model owns capture even when selected lines are virtualized. New capture is unavailable during loading or a read error; an already opened Comment retains its original labeled snapshot across refresh/read-more/failure. Switching reference identity closes that editor rather than retargeting it. Capture never reads more bytes, edits/imports the file, creates an Agent, submits a turn or navigates to a hidden conversation. Hidden drafts reuse the explicit View input notification described in [Preview Workspace](./preview-workspace.md). Chat, Note, Ink and other Preview types retain their existing behavior. ## Code entry points -| File | Responsibility | -| ------------------------------------------------------------------------------------ | ----------------------------------------------------------------- | -| [filesystem protocol](../../external/agentlet/packages/protocol/src/filesystem.ts) | Command vocabulary, request validation and resource constants | -| [native filesystem reader](../../external/agentlet/packages/local/src/filesystem.ts) | Read-only list/read, encoding, symlinks and stateless cursors | -| [ACP proxy](../../external/agentlet/packages/local/src/fs-proxy.ts) | Capability advertisement and deterministic ACP command dispatch | -| [proxy harness](../../external/agentlet/packages/local/src/harnesses/proxy.ts) | Bundled discovery and trusted launch compilation | -| [HTTP schemas](../../packages/shared/src/types/api/device-files.ts) | Canonical request/result and reference metadata schemas | -| [private session service](../../apps/server/src/modules/agent/acp/device-files.ts) | Exact-device routing, lifecycle, queueing and response validation | -| [HTTP route](../../apps/server/src/modules/agent/acp/device-files.route.ts) | Owner authorization and explicit HTTP errors | -| [Files panel](../../apps/web/src/components/Panels/FilesPanel.tsx) | Lazy directory disclosure, pagination, Add and copy-only drag | -| [file-reference renderers](../../apps/web/src/components/Nodes/fileReference/) | Metadata card and bounded read-only source Preview | +| File | Responsibility | +| ---------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | +| [filesystem protocol](../../external/agentlet/packages/protocol/src/filesystem.ts) | Command vocabulary, request validation and resource constants | +| [native filesystem reader](../../external/agentlet/packages/local/src/filesystem.ts) | Read-only list/read, encoding, symlinks and stateless cursors | +| [ACP proxy](../../external/agentlet/packages/local/src/fs-proxy.ts) | Capability advertisement and deterministic ACP command dispatch | +| [proxy harness](../../external/agentlet/packages/local/src/harnesses/proxy.ts) | Bundled discovery and trusted launch compilation | +| [HTTP schemas](../../packages/shared/src/types/api/device-files.ts) | Canonical request/result and reference metadata schemas | +| [private session service](../../apps/server/src/modules/agent/acp/device-files.ts) | Exact-device routing, lifecycle, queueing and response validation | +| [HTTP route](../../apps/server/src/modules/agent/acp/device-files.route.ts) | Owner authorization and explicit HTTP errors | +| [Files panel](../../apps/web/src/components/Panels/FilesPanel.tsx) | Lazy directory disclosure, pagination, Add and copy-only drag | +| [file-reference renderers](../../apps/web/src/components/Nodes/fileReference/) | Metadata card and bounded read-only source Preview | +| [FileCommentActions](../../apps/web/src/components/Nodes/fileReference/FileCommentActions.tsx) | Model-based CodeMirror selection and frozen file-source capture | diff --git a/docs/architecture/preview-workspace.md b/docs/architecture/preview-workspace.md index d51ea55d0..52961da89 100644 --- a/docs/architecture/preview-workspace.md +++ b/docs/architecture/preview-workspace.md @@ -58,10 +58,12 @@ The comment editor separates the selected passage from the input with a subdued The composer and user messages share `QuotedCommentCard`: a quotation and its associated comment grouped within one shallow neutral background, with no outer border. The quotation is a single-line ellipsis preview within a width-constrained, shrinkable wrapper, including in right-aligned sent messages. The full original text remains in its accessible content and the shared Common Tooltip, available on hover or keyboard focus. Quote tooltips are constrained to the viewport, remain open while the pointer enters their scrollable content, and support wheel scrolling; Arrow/Page/Home/End keys scroll the quote while its trigger is focused. The comment wraps in full without truncation. Neighboring context remains in the structured snapshot for agent rendering but is not shown in either UI surface. Composer comments display as wrapping text by default; clicking or keyboard-activating the comment opens a Common TextArea only for that edit, preserving existing line breaks. Blur or Enter finishes editing, Shift+Enter inserts a newline, and Escape restores the pre-edit value; Enter never sends and respects IME composition. An empty edit stays open with an inline validation message and blocks submission. Each composer quote has a circular neutral remove button overlaid at the top-right without taking layout space, revealed on card hover or keyboard focus and always visible on devices without hover; sent comments are read-only. Ordinary draft text stays in the textarea and quote-only messages can be sent. Prompt history navigation recalls both text and quoted comments and restores the unsent pair with its original local draft identities when navigating back. -The optional `quotedComments` wire field contains quote, contextBefore, contextAfter and comment strings, plus optional Note `source` metadata (`kind`, `canvasId`, `nodeId`, `snapshotRevision`). The shared zod schema trims outer quotation/comment whitespace and rejects empty values. The server preserves this field in `ChatEnvelope.user` and renders an escaped `` section through the canonical prompt builder shared by built-in and ACP agents; Chat quotes use assistant-passage wording, while Note quotes use Note-specific wording and source metadata. Quoted slash tokens never become invoked commands, and external commands typed in the ordinary textarea retain their lead-with-task path. The server history projection and browser hydration retain the same field, so reloaded user messages keep their cards without parsing generated template strings. Existing records without the field continue displaying their original text, including previously inserted plain-text comment templates; no backfill or new storage table is required. +The optional `quotedComments` wire field contains quote, contextBefore, contextAfter and comment strings, plus optional discriminated Note or File Reference `source` metadata as defined in [API Design](./api-design.md#quoted-comment-source-evidence). The shared zod schema trims outer quotation/comment whitespace and rejects empty values. The server preserves this field in `ChatEnvelope.user` and renders an escaped `` section through the canonical prompt builder shared by built-in and ACP agents; Chat quotes use assistant-passage wording, while Note and File quotes use their respective source-specific wording and metadata. Quoted slash tokens never become invoked commands, and external commands typed in the ordinary textarea retain their lead-with-task path. The server history projection and browser hydration retain the same field, so reloaded user messages keep their cards without parsing generated template strings. Existing records without the field continue displaying their original text, including previously inserted plain-text comment templates; no backfill or new storage table is required. Expanded Note rich text shares the Comment editor and selection actions, with an existing-conversation picker rather than Chat's fixed current thread. The picker reads the accepted recent-Canvas-conversation endpoint without updating it: a remembered owner is retained even when unavailable, a sole available owner defaults when no pointer exists, and ambiguous/empty choices require explicit resolution. Lookup errors expose retry and manual choice, never New. Internal Ask/Operate and existing external conversations are eligible regardless of streaming or removed Profile; missing/rebound owners and pending forks block staging. A Note Comment never creates or realizes an Agent. When the destination composer is already visible in an expanded Preview group, staging requests its focus without changing topology. Otherwise it leaves the source Note, tabs/groups, viewport and collapse state intact and shows a persistent dismissible notification naming the destination with **View input**. Only this explicit action opens/promotes the captured owner and focuses its composer. Notifications coalesce per Space/thread and retire when their pending draft entries are consumed/removed or the owner/Space becomes invalid. No hidden focus request is queued. +File Reference Preview opts its read-only CodeMirror surface into Comment for one contiguous text selection. It shares `PreviewCommentEditor`, destination validation and hidden-draft notifications with Note, preserving source tabs, focus and viewport when staging to a hidden destination. Its editor labels the captured file snapshot and path; refresh/read-more/errors do not silently rebind an opened Comment. New captures require successfully loaded text without a pending read or read error. The source template distinguishes source device/path and file version from Note snapshot evidence; only the selected loaded text and bounded context are shared. Raw Note source mode and PDF/Web/Office/media previews do not acquire Comment through this opt-in. + Original quotations and comments are never translated. The source message/segment and ProseMirror range remain transient capture data, not permanent annotation identities or raw Markdown offsets. Source changes and live/history ID reconciliation never rewrite staged or sent quotations. Unsent quoted comments are thread-local runtime draft state and disappear on reload; pending quote threads are pinned against inactive-thread cache eviction so hidden Note drafts remain available. Send captures a submission snapshot and immediately clears its text and quoted comments before asynchronous dispatch preparation, allowing the user to compose the next message. Durable turn acceptance never clears the composer again. A definitively rejected, busy or stopped submission before acceptance restores the snapshot only if both the current text and quoted-comment draft are empty; newer drafts are never overwritten. When restoration is blocked by a newer draft, the original submission remains a retryable local user message, including when preparation failed before the controller appended a user message; both paths share the same message projection so attachments, source selections and invoked skills remain intact. An ambiguous transport failure keeps the snapshot pending until acceptance reconciliation confirms acceptance or rejection, rather than restoring it and risking duplicate submission. Uploads and selection excerpts retain their existing acceptance-time consumption. Local quoted-comment draft identities are preserved on restoration but omitted from agent requests and history. Sent comments travel in the existing durable envelope and canonical rendered submission, with no separate annotation sidecar or two-way synchronization. Conversation titles and search use human-readable quotations/comments rather than model-only context. `panelStore` owns and persists the outer right-column collapse state, owns the transient Preview fullscreen state, and owns thread-addressed composer focus requests. Opening a Preview target expands the right column; closing a tab does not delete its underlying node or conversation history. `MainLayout` treats the persisted collapse state as authoritative whenever no panel motion is active. A settled collapsed slot is zero-width and clips overflow, while an active open/close motion temporarily releases that clipping; interrupted startup hydration therefore cannot leave translated panel content visible over the Canvas in either persisted state. Fullscreen is intentionally not persisted across reloads. diff --git a/docs/architecture/web-architecture.md b/docs/architecture/web-architecture.md index 0f4f1e926..7e5b165b9 100644 --- a/docs/architecture/web-architecture.md +++ b/docs/architecture/web-architecture.md @@ -120,6 +120,8 @@ The Lasso toolbar uses `InkAgentDestinationPicker` with real Profiles and curren Expanded Note Comment actions compose into `MilkdownFloatingToolbar` alongside editable formatting, or use its selection-only mode for rich-text reading. `FragmentSelectionActions` and `FragmentCommentPopover` are shared with Chat; the Note adapter freezes source evidence and adds an existing-conversation menu. `useCommentDestination` reuses the recent-conversation API and canonical owner projection without importing the Canvas Ink toolbar or its New/dispatch lifecycle. Hidden drafts reuse Common Toast and existing Preview/focus actions; no automatic destination navigation occurs on Comment staging. This Comment-only path does not change Ink/Lasso behavior. +`PreviewCommentEditor` owns the shared Note/File destination menu, final ownership validation and staging. File Reference Preview lazy-loads `FileCommentActions` alongside its CodeMirror viewer; `RawMarkdownEditor.onReady` exposes the view without adding Comment to raw Note mode. The adapter observes CodeMirror selection through a removable compartment, measures selection geometry outside editor updates, and captures from the loaded document model with file-specific source evidence. The viewer stays lazy, read-only and plain text; no synthetic Milkdown instance or global Preview plugin framework is introduced. + Toolbar numeric inputs commit on Enter or blur. Enter suppresses the following blur commit; Escape cancels the draft without applying a value. Inside a Popover, Escape closes the panel without committing the focused draft, while an outside press blurs focused content before dismissal so width, height, Frame counts, and Question scale edits are not lost when the panel unmounts. Inline font-size inputs follow the same commit/cancel rules. Production Canvas toolbars use the rounded design shown in the development-only `/playground/node-toolbars` gallery for all twelve current node types. Both reuse Common FloatingToolbar (ToggleButton, NumberInput, ColorPicker, SizePicker), DropdownMenu, Button, the shared accent palette, and SketchControls (including SketchSizePicker). The gallery keeps its own local-state fixtures; production retains real node commands, geometry gestures, history, and visibility gates. Each toolbar starts with one current-type drag control; Text/Note conversion is an explicit overflow command, closing the menu and updating the current-type icon after selection. B/I and numeric font size remain separate controls. diff --git a/packages/shared/src/types/api/agent.test.ts b/packages/shared/src/types/api/agent.test.ts index 0b293123c..5193f5eb8 100644 --- a/packages/shared/src/types/api/agent.test.ts +++ b/packages/shared/src/types/api/agent.test.ts @@ -26,6 +26,53 @@ describe('agentRequestSchema input kinds', () => { expect(agentRequestSchema.parse(request)).toEqual(request); }); + it('preserves file snapshot evidence and rejects invalid ranges and identities', () => { + const source = { + kind: 'file-reference', + canvasId: 'space', + nodeId: 'file', + agentletId: 'source-device', + path: '/source.tex', + contentVersion: 'a'.repeat(64), + offsetUnit: 'utf-16', + from: 4, + to: 11, + loadedLength: 30, + complete: false, + }; + const quote = { + quote: 'excerpt', + contextBefore: 'pre ', + contextAfter: ' after', + comment: 'explain', + }; + expect( + agentRequestSchema.parse({ + content: '', + quotedComments: [{ ...quote, source }], + }).quotedComments?.[0]?.source, + ).toEqual(source); + for (const invalid of [ + { ...source, path: 'relative.tex' }, + { ...source, agentletId: '' }, + { ...source, contentVersion: '' }, + { ...source, offsetUnit: 'bytes' }, + { ...source, from: -1 }, + { ...source, from: 11 }, + { ...source, to: 31 }, + { ...source, from: 4.5 }, + { ...source, loadedLength: 0 }, + { ...source, complete: undefined }, + ]) { + expect( + agentRequestSchema.safeParse({ + content: '', + quotedComments: [{ ...quote, source: invalid }], + }).success, + ).toBe(false); + } + }); + it.each([ { canvasId: undefined, selectedNodes: [{ id: 'text', type: 'text' }] }, { canvasId: 'canvas', selectedNodes: [] }, diff --git a/packages/shared/src/types/api/agent.ts b/packages/shared/src/types/api/agent.ts index b45a586f7..9d292adf7 100644 --- a/packages/shared/src/types/api/agent.ts +++ b/packages/shared/src/types/api/agent.ts @@ -19,6 +19,7 @@ import { z } from 'zod'; import { agentWorkingDirectorySchema } from './agent-profile.js'; +import { fileReferenceSchema } from './device-files.js'; import { inkResourceReferencesSchema } from './ink-sigil.js'; import { REASONING_EFFORT_VALUES } from './llm.js'; import { @@ -331,12 +332,33 @@ export const quotedCommentSchema = z.object({ contextAfter: z.string(), comment: z.string().trim().min(1), source: z - .object({ - kind: z.literal('note'), - canvasId: z.string().min(1), - nodeId: z.string().min(1), - snapshotRevision: z.string().min(1), - }) + .discriminatedUnion('kind', [ + z.object({ + kind: z.literal('note'), + canvasId: z.string().min(1), + nodeId: z.string().min(1), + snapshotRevision: z.string().min(1), + }), + z + .object({ + kind: z.literal('file-reference'), + canvasId: z.string().min(1), + nodeId: z.string().min(1), + agentletId: fileReferenceSchema.shape.agentletId, + path: fileReferenceSchema.shape.path, + contentVersion: z.string().regex(/^[a-f0-9]{64}$/), + offsetUnit: z.literal('utf-16'), + from: z.number().int().nonnegative(), + to: z.number().int().positive(), + loadedLength: z.number().int().nonnegative(), + complete: z.boolean(), + }) + .refine( + (source) => + source.from < source.to && source.to <= source.loadedLength, + 'Selection must be inside the loaded document', + ), + ]) .optional(), }); export type QuotedComment = z.infer;