diff --git a/apps/web/e2e/chat-activation.config.ts b/apps/web/e2e/chat-activation.config.ts index d07ad30a9..196e3a162 100644 --- a/apps/web/e2e/chat-activation.config.ts +++ b/apps/web/e2e/chat-activation.config.ts @@ -12,7 +12,7 @@ const baseURL = `http://127.0.0.1:${port}`; export default defineConfig({ testDir: '.', - testMatch: 'chat-activation.spec.ts', + testMatch: ['chat-activation.spec.ts', 'fragment-feedback.spec.ts'], metadata: { chatActivationProduction: true }, outputDir: '../test-results/chat-activation', timeout: 60_000, diff --git a/apps/web/e2e/fragment-feedback.spec.ts b/apps/web/e2e/fragment-feedback.spec.ts new file mode 100644 index 000000000..78355ca06 --- /dev/null +++ b/apps/web/e2e/fragment-feedback.spec.ts @@ -0,0 +1,262 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { expect, test, type Locator, type Page } from '@playwright/test'; + +function panel(page: Page, tab = 'a') { + return page.locator(`[data-activation-panel="${tab}"]`); +} + +async function selectText(surface: Locator, from: string, to = from) { + await surface.scrollIntoViewIfNeeded(); + await surface.evaluate( + (element, endpoints) => { + const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT); + const nodes: Node[] = []; + let text = ''; + let node: Node | null; + while ((node = walker.nextNode())) { + nodes.push(node); + text += node.textContent ?? ''; + } + const from = text.indexOf(endpoints.from); + const to = text.indexOf(endpoints.to, from); + if (from < 0 || to < 0) throw new Error('Selection endpoints not found'); + const range = document.createRange(); + let offset = 0; + const end = to + endpoints.to.length; + for (const node of nodes) { + const length = node.textContent?.length ?? 0; + if (from >= offset && from < offset + length) { + range.setStart(node, from - offset); + } + if (end > offset && end <= offset + length) { + range.setEnd(node, end - offset); + break; + } + offset += length; + } + element.focus(); + const selection = document.getSelection(); + selection?.removeAllRanges(); + selection?.addRange(range); + element.dispatchEvent(new MouseEvent('mouseup', { bubbles: true })); + }, + { from, to }, + ); +} + +async function comment(page: Page, text: string) { + await page.getByRole('button', { name: 'Comment', exact: true }).click(); + const editor = page.getByRole('textbox', { name: 'Comment', exact: true }); + await expect(editor).toBeFocused(); + await editor.fill(text); + await page.getByRole('button', { name: 'Add to input', exact: true }).click(); +} + +test.beforeEach(async ({ page }, info) => { + test.skip( + info.config.metadata.chatActivationProduction !== true, + 'Uses the isolated production chat fixture', + ); + await page.route('**/api/**', (route) => + route.fulfill({ + status: 503, + contentType: 'application/json', + body: JSON.stringify({ message: 'Fixture has no backend' }), + }), + ); + await page.goto('/playground/chat-performance?mode=activation&turns=3'); + await expect(panel(page).locator('.ProseMirror').first()).toBeVisible(); +}); + +test('collects inline and cross-paragraph comments, edits input and explicitly submits once', async ({ + page, +}) => { + const composer = panel(page).locator('textarea[name="agent-message"]'); + await composer.fill('Existing draft'); + const answer = panel(page) + .locator('[data-chat-message-id="a-turn-3-tool"] .ProseMirror') + .last(); + await answer.locator('strong').dblclick(); + await page.getByRole('button', { name: 'Comment', exact: true }).click(); + await expect( + page.getByRole('button', { name: 'Add to input' }), + ).toBeDisabled(); + await page + .getByRole('textbox', { name: 'Comment', exact: true }) + .fill('Keep this.'); + // The append must read the latest draft, not the one at selection time. + await composer.fill('Draft edited while commenting'); + await page.getByRole('button', { name: 'Cancel', exact: true }).click(); + // Reselect and add against the latest draft. + await selectText(answer, 'cached'); + await comment(page, 'Keep this.'); + await expect(composer).toBeFocused(); + await expect(composer).toHaveValue( + /Draft edited while commenting\n\nQuote from an earlier reply:\n> cached/, + ); + await selectText( + answer, + 'Preserve full history', + 'Older turns stay available.', + ); + await comment(page, '请展开'); + await expect(composer).toHaveValue( + /前面回答中的引用:\n> Preserve full history/, + ); + await expect(composer).toHaveValue( + /> \n> Render recent turns\n> \n> Older turns stay available\./, + ); + await expect(panel(page)).toHaveAttribute('data-submitted-draft', ''); + const draft = (await composer.inputValue()).replace( + 'Keep this.', + 'Keep the reasoning.', + ); + await composer.fill(`${draft}\n\nDiscuss only these points.`); + await composer.press('Enter'); + await expect(panel(page)).toHaveAttribute( + 'data-submitted-draft', + `${draft}\n\nDiscuss only these points.`, + ); + await expect(composer).toHaveValue(''); +}); + +test('offers three selection icons and copies only the selected text while retaining whole-response actions', async ({ + page, +}) => { + await page.context().grantPermissions(['clipboard-read', 'clipboard-write']); + const composer = panel(page).locator('textarea[name="agent-message"]'); + await composer.fill('Unchanged draft'); + const wholeNoteActions = panel(page).getByRole('button', { + name: 'Add as note', + exact: true, + }); + const originalCount = await wholeNoteActions.count(); + const answer = panel(page) + .locator('[data-chat-message-id="a-turn-3-tool"] .ProseMirror') + .last(); + await answer.locator('strong').dblclick(); + const commentButton = page.getByRole('button', { + name: 'Comment', + exact: true, + }); + await expect(commentButton).toBeVisible(); + await commentButton.hover(); + await expect(page.getByRole('tooltip')).toContainText('Comment'); + await expect( + page.getByRole('button', { name: 'Add as note', exact: true }), + ).toHaveCount(originalCount + 1); + const selectionCopy = page.getByLabel('Copy', { exact: true }); + await selectionCopy.click(); + await expect + .poll(() => page.evaluate(() => navigator.clipboard.readText())) + .toBe('cached'); + await expect(commentButton).toHaveCount(0); + await expect(wholeNoteActions).toHaveCount(originalCount); + await expect(composer).toHaveValue('Unchanged draft'); + await expect(answer.locator('strong')).toHaveText('cached'); +}); + +test('supports code and keyboard selection and does not change the original answer', async ({ + page, +}) => { + const answer = panel(page).locator( + '[data-chat-message-id="a-turn-3-answer"] .ProseMirror', + ); + const original = await answer.innerText(); + await selectText(answer, 'const cachedTurn = 3;'); + await comment(page, 'Explain this code.'); + await expect( + panel(page).locator('textarea[name="agent-message"]'), + ).toHaveValue(/> const cachedTurn = 3;/); + await expect(answer).toHaveText(original, { useInnerText: true }); + const paragraph = answer.locator('p').last(); + await paragraph.click(); + await page.keyboard.press('Home'); + await page.keyboard.press('Shift+End'); + await expect( + page.getByRole('button', { name: 'Comment', exact: true }), + ).toBeVisible(); + await page.getByRole('button', { name: 'Comment', exact: true }).click(); + await page.keyboard.press('Escape'); + await expect( + page.getByRole('textbox', { name: 'Comment', exact: true }), + ).toHaveCount(0); +}); + +test('rejects selections spanning messages and excludes streaming output', async ({ + page, +}) => { + const answers = panel(page).locator('.ProseMirror'); + await answers.last().scrollIntoViewIfNeeded(); + await answers.last().evaluate((last) => { + const first = document.querySelector( + '[data-activation-panel="a"] .ProseMirror', + ); + if (!first) throw new Error('Missing first answer'); + const range = document.createRange(); + range.setStart(first, 0); + range.setEnd(last, last.childNodes.length); + const selection = document.getSelection(); + selection?.removeAllRanges(); + selection?.addRange(range); + last.dispatchEvent(new MouseEvent('mouseup', { bubbles: true })); + }); + await expect( + page.getByRole('button', { name: 'Comment', exact: true }), + ).toHaveCount(0); + await page.getByRole('button', { name: 'Start turn', exact: true }).click(); + const streaming = panel(page).locator( + '[data-chat-message-id="a-turn-4-answer"] .ProseMirror', + ); + await selectText(streaming, 'const cachedTurn = 4;'); + await expect( + page.getByRole('button', { name: 'Comment', exact: true }), + ).toHaveCount(0); + await page + .getByRole('button', { name: 'Complete turn', exact: true }) + .click(); + await selectText(streaming, 'const cachedTurn = 4;'); + await comment(page, 'Explain.'); +}); + +test('keeps comments thread-local through tab switching and drops unsent drafts on reload', async ({ + page, +}) => { + await selectText( + panel(page).locator( + '[data-chat-message-id="a-turn-3-answer"] .ProseMirror', + ), + 'const cachedTurn = 3;', + ); + await comment(page, 'Keep in A.'); + const draft = await panel(page) + .locator('textarea[name="agent-message"]') + .inputValue(); + await page.getByRole('button', { name: 'Tab B', exact: true }).click(); + await expect( + panel(page, 'b').locator('textarea[name="agent-message"]'), + ).toHaveValue(''); + await selectText( + panel(page, 'b').locator( + '[data-chat-message-id="b-turn-3-answer"] .ProseMirror', + ), + 'const cachedTurn = 3;', + ); + await page.getByRole('button', { name: 'Comment', exact: true }).click(); + await page + .getByRole('textbox', { name: 'Comment', exact: true }) + .fill('Not appended.'); + await page.getByRole('button', { name: 'Tab A', exact: true }).click(); + await expect( + page.getByRole('textbox', { name: 'Comment', exact: true }), + ).toHaveCount(0); + await expect( + panel(page).locator('textarea[name="agent-message"]'), + ).toHaveValue(draft); + await page.reload(); + await expect( + panel(page).locator('textarea[name="agent-message"]'), + ).toHaveValue(''); +}); diff --git a/apps/web/src/components/Messages/AIMessage/FragmentCommentPopover.tsx b/apps/web/src/components/Messages/AIMessage/FragmentCommentPopover.tsx new file mode 100644 index 000000000..45fbb4dad --- /dev/null +++ b/apps/web/src/components/Messages/AIMessage/FragmentCommentPopover.tsx @@ -0,0 +1,140 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { Copy, MessageSquare } from 'lucide-react'; +import { useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { Button } from '@/components/Common/Button'; +import { Popover } from '@/components/Common/Popover'; +import { TextInput } from '@/components/Common/TextInput'; +import { NODE_ICON } from '@/config/nodeIcons'; + +import type { MilkdownPreviewSelection } from '@/components/Milkdown/MilkdownPreview'; + +interface FragmentCommentPopoverProps { + selection: MilkdownPreviewSelection; + onBegin: () => void; + onAdd: (comment: string) => void; + onCopy: () => Promise; + onSaveAsNote: () => void; + onDismiss: () => void; +} + +export function FragmentCommentPopover({ + selection, + onBegin, + onAdd, + onCopy, + onSaveAsNote, + onDismiss, +}: FragmentCommentPopoverProps) { + const { t } = useTranslation(); + const [editing, setEditing] = useState(false); + const [comment, setComment] = useState(''); + const [copyError, setCopyError] = useState(false); + const [copying, setCopying] = useState(false); + const inputRef = useRef(null); + const NoteIcon = NODE_ICON.note; + + return ( + inputRef.current?.focus({ preventScroll: true }) + : undefined + } + > + {editing ? ( +
{ + event.preventDefault(); + if (comment.trim()) onAdd(comment); + }} + > +
+ {selection.text} +
+ setComment(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Enter' && event.nativeEvent.isComposing) { + event.preventDefault(); + } + }} + /> +
+ + +
+ + ) : ( + <> +
+ + + +
+ {copyError ? ( +

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

+ ) : null} + + )} +
+ ); +} diff --git a/apps/web/src/components/Messages/AIMessage/MilkdownMessageCard.comments.test.tsx b/apps/web/src/components/Messages/AIMessage/MilkdownMessageCard.comments.test.tsx new file mode 100644 index 000000000..9f47fa340 --- /dev/null +++ b/apps/web/src/components/Messages/AIMessage/MilkdownMessageCard.comments.test.tsx @@ -0,0 +1,250 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { i18n } from '@/i18n'; +import { selectThreadDraft, useChatStore } from '@/store/chatStore'; +import { usePanelStore } from '@/store/panelStore'; +import { copyToClipboard } from '@/utils/io/clipboard'; + +import { MilkdownMessageCard } from './MilkdownMessageCard'; + +import type { MilkdownPreviewSelection } from '@/components/Milkdown/MilkdownPreview'; + +const preview = vi.hoisted(() => ({ + addNode: vi.fn(), + onTextSelection: undefined as + | ((selection: MilkdownPreviewSelection | null) => void) + | undefined, +})); +vi.mock('@/components/Milkdown', () => ({ + MilkdownPreview: ({ + onTextSelection, + }: { + onTextSelection?: typeof preview.onTextSelection; + }) => { + preview.onTextSelection = onTextSelection; + return
Answer
; + }, +})); +vi.mock('@/store/canvasStore', () => ({ + default: Object.assign( + (selector: (state: { canvasId: string | null }) => unknown) => + selector({ canvasId: null }), + { getState: () => ({ addNode: preview.addNode }) }, + ), +})); +vi.mock('@/utils/io/clipboard', () => ({ + copyToClipboard: vi.fn().mockResolvedValue(undefined), +})); + +const selection: MilkdownPreviewSelection = { + range: { from: 1, to: 8 }, + text: 'selected\n\npassage', + markdown: '**selected**\n\npassage', + contextBefore: 'Before ', + contextAfter: ' after', + position: { x: 20, y: 20 }, +}; +let root: Root; +let host: HTMLDivElement; + +beforeEach(async () => { + await i18n.changeLanguage('en'); + preview.addNode.mockClear(); + vi.mocked(copyToClipboard).mockReset().mockResolvedValue(undefined); + useChatStore.setState({ threadsById: {} }); + host = document.createElement('div'); + document.body.appendChild(host); + root = createRoot(host); +}); +afterEach(() => { + act(() => root.unmount()); + host.remove(); + vi.restoreAllMocks(); +}); + +function render(enabled = true, content = 'Original answer') { + act(() => + root.render( + , + ), + ); +} +function button(name: string) { + const found = [...document.querySelectorAll('button')].find( + (item) => + item.textContent?.trim() === name || + item.getAttribute('aria-label') === name, + ); + if (!found) throw new Error(`Missing button: ${name}`); + return found; +} +function click(name: string) { + act(() => button(name).click()); +} +function input() { + const element = document.querySelector( + 'input[aria-label="Comment"]', + ); + if (!element) throw new Error('Missing comment editor'); + return element; +} +function type(value: string) { + act(() => { + Object.getOwnPropertyDescriptor( + HTMLInputElement.prototype, + 'value', + )?.set?.call(input(), value); + input().dispatchEvent(new Event('input', { bubbles: true })); + }); +} +function select() { + act(() => preview.onTextSelection?.(selection)); + click('Comment'); +} + +describe('AI response comments', () => { + it('copies only the selected plain text without writing a note or changing the draft', async () => { + useChatStore.getState().setDraft('thread-a', 'Keep my draft.'); + render(); + act(() => preview.onTextSelection?.(selection)); + await act(async () => button('Copy').click()); + expect(copyToClipboard).toHaveBeenCalledExactlyOnceWith(selection.text); + expect(preview.addNode).not.toHaveBeenCalled(); + expect(selectThreadDraft(useChatStore.getState(), 'thread-a')).toBe( + 'Keep my draft.', + ); + expect(document.querySelector('button[aria-label="Comment"]')).toBeNull(); + }); + + it('surfaces clipboard failures and allows retrying the same snapshot', async () => { + vi.mocked(copyToClipboard).mockRejectedValueOnce( + new Error('Clipboard unavailable'), + ); + render(); + act(() => preview.onTextSelection?.(selection)); + await act(async () => button('Copy').click()); + expect(document.querySelector('[role="alert"]')?.textContent).toContain( + 'Could not copy', + ); + expect(button('Copy').disabled).toBe(false); + await act(async () => button('Copy').click()); + expect(copyToClipboard).toHaveBeenCalledTimes(2); + expect(document.querySelector('[role="alert"]')).toBeNull(); + }); + + it('saves the selected Markdown with the same note origin as the whole-response action', () => { + render(); + act(() => preview.onTextSelection?.(selection)); + click('Add as note'); + expect(preview.addNode).toHaveBeenCalledExactlyOnceWith({ + nodeType: 'note', + data: { + content: selection.markdown, + origin: { type: 'user-from-chat', threadId: 'thread-a' }, + }, + }); + expect(copyToClipboard).not.toHaveBeenCalled(); + expect(selectThreadDraft(useChatStore.getState(), 'thread-a')).toBe(''); + expect(document.querySelector('button[aria-label="Comment"]')).toBeNull(); + }); + + it('opens a snapshot editor, requires a comment and cancels without changing the draft', () => { + render(); + select(); + expect(document.querySelector('blockquote')?.textContent).toBe( + selection.text, + ); + expect(button('Add to input').disabled).toBe(true); + type(' '); + expect(button('Add to input').disabled).toBe(true); + type('Keep it.'); + click('Cancel'); + expect(selectThreadDraft(useChatStore.getState(), 'thread-a')).toBe(''); + expect(document.querySelector('blockquote')).toBeNull(); + }); + + it('appends to the latest owning-thread draft and supports multiple comments without sending', () => { + const addMessage = vi.spyOn(useChatStore.getState(), 'addMessage'); + useChatStore.getState().setDraft('thread-a', 'Before opening'); + useChatStore.getState().setDraft('thread-b', 'Other thread'); + render(); + select(); + type('Keep it.'); + act(() => + useChatStore.getState().setDraft('thread-a', 'Edited while commenting'), + ); + click('Add to input'); + expect(selectThreadDraft(useChatStore.getState(), 'thread-a')).toBe( + 'Edited while commenting\n\nQuote from an earlier reply:\n> selected\n> \n> passage\n\nNearby context ([…] marks the quoted passage):\nBefore […] after\n\nComment:\nKeep it.', + ); + expect(usePanelStore.getState().focusChatInputRequest?.threadId).toBe( + 'thread-a', + ); + select(); + type('请展开'); + click('Add to input'); + expect(selectThreadDraft(useChatStore.getState(), 'thread-a')).toContain( + '评论:\n请展开', + ); + expect(selectThreadDraft(useChatStore.getState(), 'thread-b')).toBe( + 'Other thread', + ); + expect(addMessage).not.toHaveBeenCalled(); + }); + + it('keeps capture-time text after focus and source changes and never rewrites emitted input', () => { + render(); + select(); + act(() => preview.onTextSelection?.(null)); + render(true, 'Replaced answer'); + type('Explore this.'); + click('Add to input'); + expect(selectThreadDraft(useChatStore.getState(), 'thread-a')).toContain( + '> selected', + ); + act(() => + useChatStore + .getState() + .setDraft('thread-a', 'Manually edited comment\nFinal instruction'), + ); + render(true, 'Changed again'); + expect(selectThreadDraft(useChatStore.getState(), 'thread-a')).toBe( + 'Manually edited comment\nFinal instruction', + ); + }); + + it('does not emit during Chinese IME composition', () => { + render(); + select(); + type('你好'); + const event = new KeyboardEvent('keydown', { + key: 'Enter', + bubbles: true, + cancelable: true, + }); + Object.defineProperty(event, 'isComposing', { value: true }); + act(() => input().dispatchEvent(event)); + expect(event.defaultPrevented).toBe(true); + expect(selectThreadDraft(useChatStore.getState(), 'thread-a')).toBe(''); + }); + + it('disables selection feedback on inactive or streaming surfaces and resets the editor', () => { + render(); + select(); + render(false); + expect(preview.onTextSelection).toBeUndefined(); + expect(document.querySelector('blockquote')).toBeNull(); + render(); + expect(document.querySelector('blockquote')).toBeNull(); + }); +}); diff --git a/apps/web/src/components/Messages/AIMessage/MilkdownMessageCard.tsx b/apps/web/src/components/Messages/AIMessage/MilkdownMessageCard.tsx index 5eb0dea53..aad2f042f 100644 --- a/apps/web/src/components/Messages/AIMessage/MilkdownMessageCard.tsx +++ b/apps/web/src/components/Messages/AIMessage/MilkdownMessageCard.tsx @@ -5,15 +5,27 @@ * Milkdown-backed renderer for AI chat messages. */ -import { memo, useCallback } from 'react'; +import { memo, useCallback, useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; import { parseArtifactUrl } from '@huabu/shared'; import { MilkdownPreview } from '@/components/Milkdown'; import useCanvasStore from '@/store/canvasStore'; +import { selectThreadDraft, useChatStore } from '@/store/chatStore'; +import { usePanelStore } from '@/store/panelStore'; +import { copyToClipboard } from '@/utils/io/clipboard'; import { setDragPayload } from '@/utils/io/dragDrop'; import { openDocumentLink } from '@/utils/openDocumentLink'; +import { + appendFragmentComment, + commentTemplateLanguage, + renderFragmentComment, +} from './fragmentComment'; +import { FragmentCommentPopover } from './FragmentCommentPopover'; + +import type { MilkdownPreviewSelection } from '@/components/Milkdown/MilkdownPreview'; import type { ImageDragPayload, NoteDragPayload } from '@/utils/io/dragDrop'; import type { NodeOrigin } from '@huabu/shared'; @@ -26,6 +38,9 @@ interface MilkdownMessageCardProps { * we don't subscribe N times for N rendered cards. */ threadId: string; + messageId?: string; + segmentIndex?: number; + enableComments?: boolean; } /** @@ -92,31 +107,108 @@ export function buildImageDragPayload( export const MilkdownMessageCard = memo(function MilkdownMessageCard({ content, threadId, + messageId, + segmentIndex = 0, + enableComments = false, }: MilkdownMessageCardProps) { + const { i18n } = useTranslation(); const canvasId = useCanvasStore((s) => s.canvasId); + const [selection, setSelection] = useState( + null, + ); + const editingRef = useRef(false); + const handleTextSelection = useCallback( + (next: MilkdownPreviewSelection | null) => { + if (!editingRef.current) setSelection(next); + }, + [], + ); + const dismissComment = useCallback(() => { + editingRef.current = false; + setSelection(null); + }, []); + useEffect(() => { + if (!enableComments) dismissComment(); + }, [enableComments, dismissComment]); const handleLinkClick = useCallback( (href: string) => openDocumentLink(href, { threadId }), [threadId], ); return ( - { - const built = - buildImageDragPayload(markdown, threadId, canvasId) ?? - buildNoteDragPayload(markdown, threadId); - if (!built) return; - - setDragPayload( - nativeEvent as unknown as React.DragEvent, - built.payload, - ); - }} - /> + <> + { + const built = + buildImageDragPayload(markdown, threadId, canvasId) ?? + buildNoteDragPayload(markdown, threadId); + if (!built) return; + + setDragPayload( + nativeEvent as unknown as React.DragEvent, + built.payload, + ); + }} + /> + {enableComments && messageId && selection ? ( + copyToClipboard(selection.text)} + onSaveAsNote={() => { + useCanvasStore.getState().addNode({ + nodeType: 'note', + data: { + content: selection.markdown, + origin: { type: 'user-from-chat', threadId }, + }, + }); + dismissComment(); + }} + onBegin={() => { + editingRef.current = true; + }} + onDismiss={dismissComment} + onAdd={(comment) => { + const templateT = i18n.getFixedT( + commentTemplateLanguage( + comment, + i18n.resolvedLanguage ?? i18n.language, + ), + ); + const rendered = renderFragmentComment( + { + ...selection, + sourceMessageId: messageId, + segmentIndex, + comment, + }, + { + quote: templateT('messages.fragmentQuoteLabel'), + comment: templateT('messages.fragmentCommentLabel'), + context: templateT('messages.fragmentContextLabel'), + }, + ); + const chat = useChatStore.getState(); + chat.setDraft( + threadId, + appendFragmentComment( + selectThreadDraft(chat, threadId), + rendered, + ), + ); + dismissComment(); + usePanelStore.getState().requestFocusChatInput(threadId); + }} + /> + ) : null} + ); }); diff --git a/apps/web/src/components/Messages/AIMessage/fragmentComment.test.ts b/apps/web/src/components/Messages/AIMessage/fragmentComment.test.ts new file mode 100644 index 000000000..25e902cb6 --- /dev/null +++ b/apps/web/src/components/Messages/AIMessage/fragmentComment.test.ts @@ -0,0 +1,83 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { describe, expect, it } from 'vitest'; + +import { + appendFragmentComment, + commentTemplateLanguage, + renderFragmentComment, + type FragmentComment, +} from './fragmentComment'; + +const labels = { quote: 'Quote:', comment: 'Comment:', context: 'Context:' }; +const fragment: FragmentComment = { + sourceMessageId: 'answer-1', + segmentIndex: 0, + range: { from: 1, to: 8 }, + text: 'one\n\ntwo', + markdown: 'one\n\ntwo', + contextBefore: '', + contextAfter: '', + comment: 'Keep this.', +}; + +describe('fragment comment templates', () => { + it.each([ + ['请保留 API', 'en', 'zh-CN'], + ['Please keep this', 'zh-CN', 'en'], + ['你好', 'en', 'zh-CN'], + ['123 👍', 'zh-CN', 'zh-CN'], + ['123 👍', 'en', 'en'], + ['/review', 'zh-CN', 'en'], + ])('chooses a template for %s with UI %s', (comment, ui, expected) => { + expect(commentTemplateLanguage(comment, ui)).toBe(expected); + }); + + it('quotes every line and preserves code, Unicode and user comments', () => { + expect( + renderFragmentComment( + { + ...fragment, + text: '/review\n\nconst value = "你好";', + comment: ' Keep\nthese lines. ', + }, + labels, + ), + ).toBe( + 'Quote:\n> /review\n> \n> const value = "你好";\n\nComment:\nKeep\nthese lines.', + ); + }); + + it('includes captured context without exposing local identity or offsets', () => { + const rendered = renderFragmentComment( + { + ...fragment, + contextBefore: 'first\noccurrence ', + contextAfter: ' continues', + }, + labels, + ); + expect(rendered).toContain('Context:\nfirst occurrence […] continues'); + expect(rendered).not.toContain('answer-1'); + expect(rendered).not.toContain('from'); + }); + + it.each(['', 'Draft', 'Draft\n', 'Draft\n\n'])( + 'appends to %j without losing text', + (draft) => { + const first = appendFragmentComment( + draft, + renderFragmentComment(fragment, labels), + ); + const second = appendFragmentComment( + first, + renderFragmentComment({ ...fragment, comment: 'Explore.' }, labels), + ); + expect(second.startsWith(draft)).toBe(true); + expect(second).toContain('Comment:\nKeep this.'); + expect(second).toContain('Comment:\nExplore.'); + expect(second).not.toContain('\n\n\nQuote:'); + }, + ); +}); diff --git a/apps/web/src/components/Messages/AIMessage/fragmentComment.ts b/apps/web/src/components/Messages/AIMessage/fragmentComment.ts new file mode 100644 index 000000000..b3db482e2 --- /dev/null +++ b/apps/web/src/components/Messages/AIMessage/fragmentComment.ts @@ -0,0 +1,45 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import type { MilkdownTextSelection } from '@/components/Milkdown/createMilkdown'; + +export interface FragmentComment extends MilkdownTextSelection { + sourceMessageId: string; + segmentIndex: number; + comment: string; +} + +export function commentTemplateLanguage( + comment: string, + uiLanguage: string, +): 'en' | 'zh-CN' { + if (/\p{Script=Han}/u.test(comment)) return 'zh-CN'; + if (/[a-z]/i.test(comment)) return 'en'; + return uiLanguage.toLowerCase().startsWith('zh') ? 'zh-CN' : 'en'; +} + +export function renderFragmentComment( + fragment: FragmentComment, + labels: { quote: string; comment: string; context: string }, +): string { + const quote = fragment.text + .split(/\r?\n/) + .map((line) => `> ${line}`) + .join('\n'); + const before = fragment.contextBefore.trim(); + const after = fragment.contextAfter.trim(); + const context = + before || after + ? `\n\n${labels.context}\n${[before, '[…]', after] + .filter(Boolean) + .map((part) => part.replace(/\s+/g, ' ')) + .join(' ')}` + : ''; + return `${labels.quote}\n${quote}${context}\n\n${labels.comment}\n${fragment.comment.trim()}`; +} + +export function appendFragmentComment(draft: string, rendered: string): string { + return draft + ? `${draft}${draft.endsWith('\n\n') ? '' : draft.endsWith('\n') ? '\n' : '\n\n'}${rendered}` + : rendered; +} diff --git a/apps/web/src/components/Messages/AIMessage/index.tsx b/apps/web/src/components/Messages/AIMessage/index.tsx index 7221b13b7..2a3ef0dd4 100644 --- a/apps/web/src/components/Messages/AIMessage/index.tsx +++ b/apps/web/src/components/Messages/AIMessage/index.tsx @@ -36,6 +36,7 @@ interface AIMessageProps { segments: AssistantSegment[]; isStreaming?: boolean; hideActions?: boolean; + enableComments?: boolean; } const NoteIcon = NODE_ICON.note; @@ -116,6 +117,7 @@ export const AIMessage = memo(function AIMessage({ segments, isStreaming, hideActions, + enableComments = false, }: AIMessageProps) { const { t } = useTranslation(); const addNode = useCanvasStore((state) => state.addNode); @@ -239,7 +241,13 @@ export const AIMessage = memo(function AIMessage({ className="text-fg-default bg-surface ml-1 rounded-2xl border border-none px-4 text-sm" >
- +
); diff --git a/apps/web/src/components/Messages/MessageList.tsx b/apps/web/src/components/Messages/MessageList.tsx index 776e2c26d..dcd46f367 100644 --- a/apps/web/src/components/Messages/MessageList.tsx +++ b/apps/web/src/components/Messages/MessageList.tsx @@ -674,6 +674,7 @@ export const MessageList = memo(function MessageList({ segments={msg.segments} isStreaming={msg.id === streamingAssistantId} hideActions={hideAIActions} + enableComments={isActive && !isHistoryLoading} /> , ); diff --git a/apps/web/src/components/Milkdown/MilkdownPreview.tsx b/apps/web/src/components/Milkdown/MilkdownPreview.tsx index bd7ef05e7..5c46b4612 100644 --- a/apps/web/src/components/Milkdown/MilkdownPreview.tsx +++ b/apps/web/src/components/Milkdown/MilkdownPreview.tsx @@ -28,7 +28,11 @@ import { resolveArtifactUrl } from '@/api/artifact'; import { useLinkFollowModifierHeld } from '@/hooks/useMultiSelectModifier'; import { attachBlockDragListeners } from './blockDrag'; -import { createMilkdown, type MilkdownInstance } from './createMilkdown'; +import { + createMilkdown, + type MilkdownInstance, + type MilkdownTextSelection, +} from './createMilkdown'; import { markdownEquals, normalizeMarkdown } from './markdownUtils'; import type { MilkdownBlockDragEvent, MilkdownLinkActivation } from './types'; @@ -52,6 +56,12 @@ export interface MilkdownPreviewProps { enableBlockDrag?: boolean; /** Fires alongside Crepe's native drag handler when a block drag begins. */ onBlockDragStart?: (event: MilkdownBlockDragEvent) => void; + /** Reports only native text selections wholly inside this preview. */ + onTextSelection?: (selection: MilkdownPreviewSelection | null) => void; +} + +export interface MilkdownPreviewSelection extends MilkdownTextSelection { + position: { x: number; y: number }; } /** @@ -105,6 +115,7 @@ export function MilkdownPreview( onBlockDragStart, linkActivation = 'plain', onLinkClick, + onTextSelection, } = props; const resolvedAriaLabel = ariaLabel ?? t('editor.readOnlyContent'); const followModifierHeld = useLinkFollowModifierHeld( @@ -127,6 +138,9 @@ export function MilkdownPreview( const onLinkClickRef = useRef(onLinkClick); onLinkClickRef.current = onLinkClick; const hasLinkCallback = !!onLinkClick; + const onTextSelectionRef = useRef(onTextSelection); + onTextSelectionRef.current = onTextSelection; + const reportsTextSelection = !!onTextSelection; useEffect(() => { let cancelled = false; @@ -203,6 +217,69 @@ export function MilkdownPreview( // Re-mount when drag mode toggles (rare, expected). }, [enableBlockDrag, linkActivation, hasLinkCallback]); + useEffect(() => { + const mountRoot = containerRef.current; + if (!reportsTextSelection || !mountRoot) return; + let frame: number | undefined; + let hasReportedSelection = false; + const report = () => { + frame = undefined; + const instance = instanceRef.current; + const native = mountRoot.ownerDocument.getSelection(); + const editor = mountRoot.querySelector('.ProseMirror'); + if ( + !instance || + !editor || + !native || + native.isCollapsed || + native.rangeCount !== 1 || + !native.anchorNode || + !native.focusNode || + !editor.contains(native.anchorNode) || + !editor.contains(native.focusNode) + ) { + hasReportedSelection = false; + onTextSelectionRef.current?.(null); + return; + } + const snapshot = instance.getTextSelection(); + const rect = instance.getSelectionClientRect(); + hasReportedSelection = !!snapshot && !!rect; + onTextSelectionRef.current?.( + snapshot && rect + ? { ...snapshot, position: { x: rect.left, y: rect.bottom } } + : null, + ); + }; + const schedule = () => { + const native = mountRoot.ownerDocument.getSelection(); + if ( + !hasReportedSelection && + !mountRoot.contains(native?.anchorNode ?? null) && + !mountRoot.contains(native?.focusNode ?? null) + ) { + return; + } + if (frame !== undefined) cancelAnimationFrame(frame); + frame = requestAnimationFrame(report); + }; + const doc = mountRoot.ownerDocument; + doc.addEventListener('selectionchange', schedule); + mountRoot.addEventListener('mouseup', schedule); + mountRoot.addEventListener('keyup', schedule); + window.addEventListener('scroll', schedule, true); + window.addEventListener('resize', schedule); + return () => { + if (frame !== undefined) cancelAnimationFrame(frame); + doc.removeEventListener('selectionchange', schedule); + mountRoot.removeEventListener('mouseup', schedule); + mountRoot.removeEventListener('keyup', schedule); + window.removeEventListener('scroll', schedule, true); + window.removeEventListener('resize', schedule); + onTextSelectionRef.current?.(null); + }; + }, [reportsTextSelection]); + useEffect(() => { if (markdownEquals(markdown, lastSyncedRef.current)) return; const next = normalizeMarkdown(markdown); diff --git a/apps/web/src/components/Milkdown/__tests__/previewAccessibility.test.tsx b/apps/web/src/components/Milkdown/__tests__/previewAccessibility.test.tsx index e699416da..7d7f51aae 100644 --- a/apps/web/src/components/Milkdown/__tests__/previewAccessibility.test.tsx +++ b/apps/web/src/components/Milkdown/__tests__/previewAccessibility.test.tsx @@ -23,6 +23,33 @@ afterEach(() => { }); describe('MilkdownPreview accessibility', () => { + it('does not remount the editor when fragment selection reporting toggles', async () => { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + const onTextSelection = vi.fn(); + const render = (enabled: boolean) => { + act(() => { + root?.render( + , + ); + }); + }; + render(false); + await vi.waitFor(() => { + expect(container?.querySelector('.ProseMirror')).not.toBeNull(); + }); + const editor = container.querySelector('.ProseMirror'); + render(true); + expect(container.querySelector('.ProseMirror')).toBe(editor); + render(false); + expect(container.querySelector('.ProseMirror')).toBe(editor); + }); + it('keeps links hit-testable when the preview host is not', async () => { container = document.createElement('div'); document.body.appendChild(container); diff --git a/apps/web/src/components/Milkdown/__tests__/textSelection.test.ts b/apps/web/src/components/Milkdown/__tests__/textSelection.test.ts new file mode 100644 index 000000000..099a6a79f --- /dev/null +++ b/apps/web/src/components/Milkdown/__tests__/textSelection.test.ts @@ -0,0 +1,120 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { afterEach, describe, expect, it } from 'vitest'; + +import { createMilkdown, type MilkdownInstance } from '../createMilkdown'; + +let instance: MilkdownInstance | undefined; +let root: HTMLDivElement | undefined; + +afterEach(async () => { + await instance?.destroy(); + root?.remove(); +}); + +async function mount(markdown: string) { + root = document.createElement('div'); + document.body.appendChild(root); + instance = await createMilkdown({ + root, + initialMarkdown: markdown, + editable: true, + previewMode: true, + toolbarMode: 'none', + }); + return instance; +} + +describe('Milkdown text selection snapshots', () => { + it('captures inline formatted text with its actual range and neighboring context', async () => { + const editor = await mount('Before **selected** after.'); + editor.__selectTextBetweenForTest?.('selected', 'selected'); + expect(editor.getTextSelection()).toMatchObject({ + text: 'selected', + markdown: '**selected**\n', + contextBefore: 'Before ', + contextAfter: ' after.', + }); + expect(editor.getTextSelection()?.range).toEqual( + editor.getSelectionRange(), + ); + expect(editor.getMarkdown()).toContain('**selected**'); + }); + + it.each([ + ['Before **selected** after.', 'lect', 'lect', '**lect**'], + [ + 'Before [selected](https://example.com) after.', + 'lect', + 'lect', + '[lect](https://example.com)', + ], + [ + '- First item\n- Second **item**\n- Third item\n\nEnd.', + 'Second', + 'item', + '* Second **item**', + ], + [ + '```ts\nconst first = 1;\nconst second = 2;\n```\n\nEnd.', + 'const second', + '2;', + '```ts\nconst second = 2;\n```', + ], + ])( + 'serializes only the selected Markdown in %s', + async (source, from, to, expected) => { + const editor = await mount(source); + const original = editor.getMarkdown(); + editor.__selectTextBetweenForTest?.(from, to); + expect(editor.getTextSelection()?.markdown).toBe(`${expected}\n`); + expect(editor.getMarkdown()).toBe(original); + }, + ); + + it('preserves cross-paragraph Markdown without including unselected boundary text', async () => { + const editor = await mount( + 'Before **first**.\n\nSecond [link](https://example.com) after.', + ); + editor.__selectTextBetweenForTest?.('first', 'link'); + expect(editor.getTextSelection()?.markdown).toBe( + '**first**.\n\nSecond [link](https://example.com)\n', + ); + }); + + it('preserves paragraph breaks and code line breaks without mutating the answer', async () => { + const editor = await mount( + 'First paragraph.\n\nSecond paragraph.\n\n```ts\nconst a = 1;\nconst b = 2;\n```\n\nEnd.', + ); + const original = editor.getMarkdown(); + editor.__selectTextBetweenForTest?.('First paragraph.', 'const b = 2;'); + expect(editor.getTextSelection()?.text).toBe( + 'First paragraph.\n\nSecond paragraph.\n\nconst a = 1;\nconst b = 2;', + ); + expect(editor.getMarkdown()).toBe(original); + }); + + it('distinguishes a repeated quote by range and surrounding text', async () => { + const editor = await mount('first same\n\nsecond same'); + editor.__selectTextBetweenForTest?.('second same', 'second same'); + expect(editor.getTextSelection()?.text).toBe('second same'); + expect(editor.getTextSelection()?.contextBefore).toContain('first same'); + }); + + it('preserves explicit line breaks within a paragraph', async () => { + const editor = await mount('First line. \nSecond line.'); + editor.__selectAllTextForTest?.(); + expect(editor.getTextSelection()?.text).toBe('First line.\nSecond line.'); + }); + + it('captures Unicode and rejects collapsed and block-node selections', async () => { + const editor = await mount('你好 👋 world.'); + editor.__selectAllTextForTest?.(); + expect(editor.getTextSelection()?.text).toBe('你好 👋 world.'); + editor.__setCursorAfterTextForTest?.('你好'); + expect(editor.getTextSelection()).toBeNull(); + editor.__selectCurrentBlockForTest?.(); + expect(editor.getTextSelection()).toBeNull(); + }); +}); diff --git a/apps/web/src/components/Milkdown/createMilkdown.ts b/apps/web/src/components/Milkdown/createMilkdown.ts index 8706482b2..8f862da91 100644 --- a/apps/web/src/components/Milkdown/createMilkdown.ts +++ b/apps/web/src/components/Milkdown/createMilkdown.ts @@ -277,6 +277,14 @@ export interface MilkdownTextRange { to: number; } +export interface MilkdownTextSelection { + range: MilkdownTextRange; + text: string; + markdown: string; + contextBefore: string; + contextAfter: string; +} + export interface MilkdownLinkState { href: string; range: MilkdownTextRange; @@ -341,6 +349,8 @@ export interface MilkdownInstance { getSelectionRange(includeEmpty?: boolean): MilkdownTextRange | null; /** Plain text in the current non-empty text selection. */ getSelectionText(): string | null; + /** Capture selected text and nearby context, preserving block separators. */ + getTextSelection(): MilkdownTextSelection | null; /** Active link under the current selection or cursor. */ getActiveLink(): MilkdownLinkState | null; /** Capture one whole link or unlinked selected text, without moving selection. */ @@ -2678,6 +2688,44 @@ export async function createMilkdown( }); return result; }, + getTextSelection: () => { + let result: MilkdownTextSelection | null = null; + crepe.editor.action((ctx) => { + const { selection, doc } = ctx.get(editorViewCtx).state; + if (selection.empty || selection instanceof NodeSelection) return; + const leafText = (node: ProseNode) => + node.type.name === 'hardbreak' ? '\n' : ''; + const text = doc.textBetween( + selection.from, + selection.to, + '\n\n', + leafText, + ); + if (!text.trim()) return; + const slice = doc.slice(selection.from, selection.to, true); + const markdown = ctx.get(serializerCtx)( + doc.type.create(null, slice.content), + ); + result = { + range: { from: selection.from, to: selection.to }, + text, + markdown, + contextBefore: doc.textBetween( + Math.max(0, selection.from - 40), + selection.from, + ' ', + leafText, + ), + contextAfter: doc.textBetween( + selection.to, + Math.min(doc.content.size, selection.to + 40), + ' ', + leafText, + ), + }; + }); + return result; + }, getActiveLink: () => { let result: MilkdownLinkState | null = null; crepe.editor.action((ctx) => { diff --git a/apps/web/src/i18n/resources/en/common.json b/apps/web/src/i18n/resources/en/common.json index 988a5a00d..a7a7df8fe 100644 --- a/apps/web/src/i18n/resources/en/common.json +++ b/apps/web/src/i18n/resources/en/common.json @@ -923,6 +923,13 @@ "addAsNote": "Add as note", "copyMessage": "Copy message", "copy": "Copy", + "fragmentComment": "Comment", + "fragmentCopyFailed": "Could not copy the selected text. Please try again.", + "fragmentCommentPlaceholder": "What would you like to say about this passage?", + "addCommentToInput": "Add to input", + "fragmentQuoteLabel": "Quote from an earlier reply:", + "fragmentCommentLabel": "Comment:", + "fragmentContextLabel": "Nearby context ([…] marks the quoted passage):", "thinking": "Thinking…", "plan": "Plan", "copyPlan": "Copy plan", diff --git a/apps/web/src/i18n/resources/zh-CN/common.json b/apps/web/src/i18n/resources/zh-CN/common.json index 9b4f14473..430c7789f 100644 --- a/apps/web/src/i18n/resources/zh-CN/common.json +++ b/apps/web/src/i18n/resources/zh-CN/common.json @@ -923,6 +923,13 @@ "addAsNote": "添加为笔记", "copyMessage": "复制消息", "copy": "复制", + "fragmentComment": "评论", + "fragmentCopyFailed": "无法复制所选文字,请重试。", + "fragmentCommentPlaceholder": "你想对这段内容说什么?", + "addCommentToInput": "加入输入框", + "fragmentQuoteLabel": "前面回答中的引用:", + "fragmentCommentLabel": "评论:", + "fragmentContextLabel": "邻近上下文([…] 表示被引用的片段):", "thinking": "正在思考…", "plan": "计划", "copyPlan": "复制计划", diff --git a/apps/web/src/pages/playground/ChatPerformancePlaygroundPage.tsx b/apps/web/src/pages/playground/ChatPerformancePlaygroundPage.tsx index e1a9e9e82..7a002876d 100644 --- a/apps/web/src/pages/playground/ChatPerformancePlaygroundPage.tsx +++ b/apps/web/src/pages/playground/ChatPerformancePlaygroundPage.tsx @@ -8,6 +8,7 @@ import { MessageList } from '@/components/Messages/MessageList'; import { ThreadChatInput } from '@/components/Panels/ChatPanel/ThreadChatInput'; import { selectRetainedPreviewTabs } from '@/components/Panels/PreviewWorkspace/retainedPreviewTabs'; import { ChatSessionProvider } from '@/hooks/useChatSession'; +import { useChatStore } from '@/store/chatStore'; import { activateTab } from '@/store/previewWorkspace/model'; import type { ChatMessage } from '@/store/chatTypes'; @@ -173,6 +174,7 @@ function ActivationPanel({ recentTurnCount: number | undefined; }) { const [mountId] = useState(() => crypto.randomUUID()); + const [submittedDraft, setSubmittedDraft] = useState(''); const session = useMemo( () => ({ ...SESSION, threadId: `activation-fixture-${tabId}` }), [tabId], @@ -185,6 +187,7 @@ function ActivationPanel({ data-mount-id={mountId} data-activation-id={activationId} data-cached-messages={thread.messages.length} + data-submitted-draft={submittedDraft} className="flex min-h-0 flex-1 flex-col" >
- + { + event.preventDefault(); + setSubmittedDraft(draft); + useChatStore.getState().setDraft(session.threadId, ''); + }} + onStop={NOOP} + mode="ask" + />
diff --git a/docs/architecture/agent-context.md b/docs/architecture/agent-context.md index a5a8e34c5..d25864d08 100644 --- a/docs/architecture/agent-context.md +++ b/docs/architecture/agent-context.md @@ -103,6 +103,8 @@ The envelope splits "where the user pointed this turn" into orthogonal parts ([e Key differences: **selection / anchor point at existing canvas nodes** (enriched with a `preview` via node-ref; content via tools); **attachment is a one-off off-canvas asset** (content inlined directly). Anchor is "a single focus point + neighbourhood"; selection is "a set of node metadata". +Chat fragment comments are distinct from all three Canvas pointing signals. The browser captures selected visible assistant text and neighboring context, then renders a quotation-plus-comment template into the owning thread's ordinary input draft on explicit **Add to input**. These templates become normal user text only when the user sends; they introduce no wire field, annotation sidecar, prompt-rewrite operation or permanent preference. Quoted response content is context, not an invoked slash command or elevated instruction. The existing text submission, binding/mode, attachment, permission and Stop behavior remains authoritative. A quotation is a capture-time snapshot, not a live reference: later source edits or message identity reconciliation do not remap the quote. See [Preview Workspace](./preview-workspace.md#1-ownership-boundaries) for editing, template language and draft lifetime. + ### 4.1 Anchored conversation ownership The web retains `AgentConversationView` for anchored Question conversations. Its `presentationAnchor` and `conversationOwner` identify the same Canvas and node, with the owner also carrying `threadId`. Validation requires that Canvas to be active and the live Question's thread to match. Legacy World `nodeRef` presentation and its source-reference resolver are removed; a Space Preview cannot open a source-owned conversation in the host Canvas. diff --git a/docs/architecture/note-node.md b/docs/architecture/note-node.md index 0556e0342..4c465c6ca 100644 --- a/docs/architecture/note-node.md +++ b/docs/architecture/note-node.md @@ -155,6 +155,8 @@ An anchor's `javascript:` URL can only be activated by a click — browsers refu ## 7. Note-specific behaviours +The shared `MilkdownPreview` optionally reports native text-selection snapshots for Chat selection actions. Reports require both selection endpoints inside the same ProseMirror surface; selected text preserves paragraph separators and code line breaks and includes its ProseMirror range, selected Markdown and short neighboring context. Markdown is serialized through the existing Milkdown serializer from a document slice with enclosing parents, preserving selected inline marks and block/list/code structure without unselected text. This callback does not change Note editing, link activation, block drag or provenance behavior. Chat owns Copy, Add as note and the Comment editor/input insertion; Note previews do not enable this callback. + These exist only for notes, and none of them are guessable from the node model above. When a later agent rewrite restores a modified block to its original user-owned baseline, its pending `modified` provenance record is removed. The first edit saves the full-document normalized block fingerprint as `baselineKey`, without a duplicate occurrence suffix, and later edits retain it alongside `baselineMarkdown`. This preserves reference-link and reference-image identity even though the stored Markdown fragment omits document-level definitions. Legacy records without `baselineKey` retain best-effort fragment comparison; missing original definitions cannot be reconstructed reliably, so those records may retain a pending marker. Other pending records and operation history are unchanged. diff --git a/docs/architecture/preview-workspace.md b/docs/architecture/preview-workspace.md index 21ab7ad65..917a7fea9 100644 --- a/docs/architecture/preview-workspace.md +++ b/docs/architecture/preview-workspace.md @@ -26,6 +26,12 @@ Within a mounted Chat renderer, the thread-scoped composer owns the draft subscr After history hydration, a conversation with no messages displays a single subdued, localized AI-content accuracy disclaimer centered in the otherwise empty message area. The disclaimer disappears as soon as loading starts or any message exists, and the composer retains no persistent disclaimer text or icon. +Completed, loaded assistant text supports fragment comments in the active Chat. A native text selection may span lines or paragraphs within one Milkdown text segment, but never separate messages or tool/thinking surfaces. Comment opens an editor with a capture-time quotation; its sole confirmation is **Add to input**, with Cancel and empty-comment prevention. Selection, comment editing and insertion never invoke generation. Add to input appends a readable quote/context/comment template to the latest draft of the owning thread, preserves existing text, closes the editor and requests that thread's composer focus. The input is the only editing and review authority after insertion; several comments and free-form instructions can be combined before ordinary Send. + +The selection popover offers **Copy**, **Add as note**, and **Comment** as icon buttons with localized tooltips and accessible names. Copy writes only the selected plain text and reports clipboard failures with retry available. Add as note serializes only the selected ProseMirror slice as Markdown, retaining marks, links and enclosing block/list/code structure, then uses the same `canvasStore.addNode` path and `user-from-chat` thread origin as the whole-response action. Neither action adds neighboring context or comments, modifies the source, changes the input draft, or invokes generation. Successful copy or note creation dismisses the popover. The existing response-bottom Copy and Add as note buttons remain unchanged and still act on the entire visible response. + +Each comment selects its template language independently: Han characters choose Chinese, otherwise ASCII letters choose English, otherwise the current UI language chooses the Chinese or English template. Original quotations and comments are never translated. The source message/segment, ProseMirror range and short neighboring context are transient capture data, not permanent annotation identities or raw Markdown offsets. Source changes and live/history ID reconciliation never rewrite inserted text or block Send. There is no separate saved-comment list, emitted-state tracking, two-way synchronization or durable annotation store. Unsent templates are ordinary runtime drafts and disappear on reload/cache eviction; sent templates follow existing transcript persistence and provider/harness handling. + `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. ```text