diff --git a/apps/web/e2e/file-comments.spec.ts b/apps/web/e2e/file-comments.spec.ts index 92dde9e25..9ccc27365 100644 --- a/apps/web/e2e/file-comments.spec.ts +++ b/apps/web/e2e/file-comments.spec.ts @@ -3,7 +3,7 @@ import { expect, test } from '@playwright/test'; -import { openNewCanvas } from './helpers'; +import { openNewCanvas, readScreenshotPixels } from './helpers'; import type * as QuestionModule from '../src/components/Nodes/question/questionCompose'; import type * as CanvasModule from '../src/store/canvasStore'; @@ -13,7 +13,7 @@ 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.'; +const sourceText = `Before selected passage after. ${'Wrapped source text. '.repeat(16)}\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 ({ @@ -119,6 +119,9 @@ for (const mode of ['mouse', 'touch'] as const) { ); const editor = page.locator('.cm-content'); await expect(editor).toBeVisible(); + await page + .context() + .grantPermissions(['clipboard-read', 'clipboard-write']); const initialReads = reads; const before = await page.evaluate(async () => { const previewPath = '/src/store/previewWorkspace/store.ts'; @@ -142,25 +145,114 @@ for (const mode of ['mouse', 'touch'] as const) { ) 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 selectPassage = async () => { + const line = editor.locator('.cm-line').first(); + if (mode === 'touch') { + await line.tap(); + await line.evaluate((element) => { + const text = element.firstChild; + if (!text) throw new Error('Missing source text'); const range = document.createRange(); - range.selectNodeContents(line); + range.setStart(text, 7); + range.setEnd(text, 23); 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(); - } + } else { + const points = await line.evaluate((element) => { + const text = element.firstChild; + if (!text) throw new Error('Missing source text'); + const point = (offset: number) => { + const range = document.createRange(); + range.setStart(text, offset); + range.collapse(true); + const rect = range.getBoundingClientRect(); + return { x: rect.x, y: rect.y + rect.height / 2 }; + }; + return { from: point(7), to: point(23) }; + }); + await page.mouse.move(points.from.x, points.from.y); + await page.mouse.down(); + await page.mouse.move(points.to.x, points.to.y, { steps: 8 }); + await page.mouse.up(); + } + await expect( + page.getByRole('button', { name: 'Comment', exact: true }), + ).toBeVisible(); + }; + await selectPassage(); + await expect( + page.getByRole('button', { name: 'Copy', exact: true }), + ).toBeVisible(); + await expect( + page.getByRole('button', { name: 'Add as note', exact: true }), + ).toBeVisible(); + const paint = await editor.evaluate((content) => { + const line = content.querySelector('.cm-activeLine'); + const shell = content.closest('.cm-editor'); + const selection = shell?.querySelector('.cm-selectionBackground'); + if (!line || !selection) + throw new Error('Missing active line or drawn selection'); + const rect = selection.getBoundingClientRect(); + const style = getComputedStyle(line); + return { + lineBackground: style.backgroundColor, + lineHeight: line.getBoundingClientRect().height, + textLineHeight: Number.parseFloat(style.lineHeight), + backgroundLayer: getComputedStyle(line, '::before').zIndex, + point: { x: rect.x + rect.width / 2, y: rect.bottom - 2 }, + }; + }); + expect(paint.lineHeight).toBeGreaterThan(paint.textLineHeight * 2); + expect(paint.lineBackground).toBe('rgba(0, 0, 0, 0)'); + expect(paint.backgroundLayer).toBe('-2'); + const [pixel] = await readScreenshotPixels(page, [paint.point]); + expect(pixel[2] - pixel[0]).toBeGreaterThan(5); + await page.screenshot({ + path: test.info().outputPath(`file-selection-${mode}.png`), + }); + await editor.evaluate((content) => content.blur()); + await expect(page.locator('.cm-editor')).not.toHaveClass(/cm-focused/); + const [unfocusedPixel] = await readScreenshotPixels(page, [paint.point]); + expect(unfocusedPixel[2] - unfocusedPixel[0]).toBeGreaterThan(5); + const copy = page.getByRole('button', { name: 'Copy', exact: true }); + if (mode === 'touch') await copy.tap(); + else await copy.click(); + await expect + .poll(() => page.evaluate(() => navigator.clipboard.readText())) + .toBe('selected passage'); + await expect(copy).toBeHidden(); + await selectPassage(); + const save = page.getByRole('button', { name: 'Add as note', exact: true }); + if (mode === 'touch') await save.tap(); + else await save.click(); + await expect + .poll(() => + page.evaluate(async () => { + const canvasPath = '/src/store/canvasStore.ts'; + const { default: canvas } = (await import( + canvasPath + )) as typeof CanvasModule; + return canvas + .getState() + .nodes.filter( + (node) => + node.type === 'note' && + node.data.content === 'selected passage', + ) + .map((node) => node.data.origin); + }), + ) + .toEqual([ + { type: 'user-excerpt', excerptFromNodeId: 'node-file-source' }, + ]); + await selectPassage(); + const comment = page.getByRole('button', { name: 'Comment', exact: true }); + if (mode === 'touch') await comment.tap(); + else await comment.click(); await expect( page.getByText('Captured file snapshot', { exact: false }), ).toContainText('/canonical/source.tex'); @@ -199,7 +291,7 @@ for (const mode of ['mouse', 'touch'] as const) { expect(after.thread.draft).toBe('Keep instructions'); expect(after.thread.pendingQuotedComments).toMatchObject([ { - quote: 'Before selected passage after.', + quote: 'selected passage', comment: 'Explain the file', source: { kind: 'file-reference', diff --git a/apps/web/e2e/latex-preview.spec.ts b/apps/web/e2e/latex-preview.spec.ts index af451445f..fd9448312 100644 --- a/apps/web/e2e/latex-preview.spec.ts +++ b/apps/web/e2e/latex-preview.spec.ts @@ -21,6 +21,7 @@ However, execution traces alone do not establish whether a proposed change improves efficiency without compromising task performance.`; async function mount(page: Page, theme: string, input: 'mouse' | 'finger') { + await page.context().grantPermissions(['clipboard-read', 'clipboard-write']); await page.addInitScript(() => performance.setResourceTimingBufferSize(2000)); await page.route( (url) => url.pathname.startsWith('/api/'), @@ -177,6 +178,79 @@ for (const theme of ['light', 'dark']) { path: info.outputPath(`${theme}-${input}.png`), }); }); + + test('code source soft-wraps on container resize without changing copied text', async ({ + page, + }, info) => { + await mount(page, theme, input); + const host = page.locator('#latex-fixture'); + const body = ` ${'unbroken_token'.repeat(28)}\n\tindented source`; + for (const language of ['text', 'latex', 'tex']) { + const markdown = `\`\`\`${language}\n${body}\n\`\`\``; + await page.evaluate( + (content) => window.latexFixture.render(content, true), + markdown, + ); + const block = host.locator('.milkdown-code-block').first(); + await expect(block.locator('.cm-line')).toHaveText(body.split('\n')); + const heights: number[] = []; + for (const width of [640, 320, 520]) { + await host.evaluate((element, width) => { + element.style.width = `${width}px`; + }, width); + await expect + .poll(() => + block + .locator('.cm-scroller') + .evaluate( + (element) => element.scrollWidth - element.clientWidth, + ), + ) + .toBeLessThanOrEqual(1); + const line = block.locator('.cm-line').first(); + await expect + .poll(() => + line.evaluate( + (element) => + element.getBoundingClientRect().height / + Number.parseFloat(getComputedStyle(element).lineHeight), + ), + ) + .toBeGreaterThan(1.5); + heights.push( + await line.evaluate( + (element) => element.getBoundingClientRect().height, + ), + ); + await expect(block.locator('.cm-content')).toHaveClass( + /cm-lineWrapping/, + ); + await expect(block.locator('.cm-line')).toHaveText( + body.split('\n'), + ); + } + expect(heights[1]).toBeGreaterThan(heights[0]); + expect(heights[2]).toBeLessThan(heights[1]); + await page.evaluate( + (content) => window.latexFixture.render(content, false), + markdown, + ); + await block.hover(); + await block.locator('.copy-button').click(); + await expect + .poll(() => page.evaluate(() => navigator.clipboard.readText())) + .toBe(body); + await host + .getByRole('button', { name: 'Copy message', exact: true }) + .click(); + await expect + .poll(() => page.evaluate(() => navigator.clipboard.readText())) + .toBe(markdown); + } + await host.screenshot({ + path: info.outputPath(`wrapped-${theme}-${input}.png`), + }); + }); }); } } diff --git a/apps/web/src/components/CodeMirror/huabuLightTheme.ts b/apps/web/src/components/CodeMirror/huabuLightTheme.ts index 8d2a40997..0b0573841 100644 --- a/apps/web/src/components/CodeMirror/huabuLightTheme.ts +++ b/apps/web/src/components/CodeMirror/huabuLightTheme.ts @@ -95,7 +95,17 @@ const chrome = EditorView.theme( // Active line: very subtle to avoid competing with selection. '.cm-activeLine': { + backgroundColor: 'transparent', + position: 'relative', + }, + // CodeMirror draws selections behind content at z-index -1. + '.cm-activeLine::before': { + content: '""', + position: 'absolute', + inset: '0', backgroundColor: BG_HOVER, + zIndex: '-2', + pointerEvents: 'none', }, '.cm-activeLineGutter': { backgroundColor: BG_HOVER, diff --git a/apps/web/src/components/Milkdown/__tests__/latexPreview.test.tsx b/apps/web/src/components/Milkdown/__tests__/latexPreview.test.tsx index b9844a36f..04c5e10ea 100644 --- a/apps/web/src/components/Milkdown/__tests__/latexPreview.test.tsx +++ b/apps/web/src/components/Milkdown/__tests__/latexPreview.test.tsx @@ -17,7 +17,7 @@ change improves efficiency without compromising task performance.`; const instances: MilkdownInstance[] = []; const roots: HTMLElement[] = []; -async function mount(markdown: string, previewMode = true) { +async function mount(markdown: string, previewMode = true, editable = true) { const root = document.createElement('div'); document.body.append(root); roots.push(root); @@ -25,6 +25,7 @@ async function mount(markdown: string, previewMode = true) { root, initialMarkdown: markdown, previewMode, + editable, toolbarMode: 'none', }); instances.push(instance); @@ -37,6 +38,29 @@ afterEach(async () => { }); describe('LaTeX source fences versus explicit math', () => { + it.each([ + { previewMode: true, editable: true }, + { previewMode: true, editable: false }, + { previewMode: false, editable: true }, + { previewMode: false, editable: false }, + ])( + 'preserves long source lines and indentation with wrapping (preview=$previewMode, editable=$editable)', + async ({ previewMode, editable }) => { + const body = ` ${'unbroken_token'.repeat(40)}\n\tindented source`; + const instance = await mount('', previewMode, editable); + for (const language of ['text', 'latex', 'tex']) { + const source = `\`\`\`${language}\n${body}\n\`\`\``; + instance.setMarkdown(source); + const serialized = instance.getMarkdown(); + expect(serialized).toContain(source); + instance.setMarkdown(serialized); + expect(instance.getMarkdown()).toBe(serialized); + instance.__selectCurrentBlockForTest?.(); + expect(instance.getDragPayload()?.markdown).toContain(body); + } + }, + ); + it.each(['latex', 'LaTeX', 'LATEX', 'tex'])( 'retains %s document source and its fence language through serialization', async (language) => { diff --git a/apps/web/src/components/Milkdown/createMilkdown.ts b/apps/web/src/components/Milkdown/createMilkdown.ts index 129ed64a1..10ffcc495 100644 --- a/apps/web/src/components/Milkdown/createMilkdown.ts +++ b/apps/web/src/components/Milkdown/createMilkdown.ts @@ -12,6 +12,7 @@ * - Hides the async lifecycle: callers always receive a ready instance. */ +import { EditorView as CodeMirrorView } from '@codemirror/view'; import { editorViewCtx, editorViewOptionsCtx, @@ -1948,6 +1949,9 @@ export async function createMilkdown( : {}), }, featureConfigs: { + [Crepe.Feature.CodeMirror]: { + extensions: [CodeMirrorView.lineWrapping], + }, ...(placeholder ? { [Crepe.Feature.Placeholder]: { text: placeholder } } : {}), diff --git a/apps/web/src/components/Nodes/fileReference/FileCommentActions.test.tsx b/apps/web/src/components/Nodes/fileReference/FileCommentActions.test.tsx index 5d2f3ca1d..0f196d85c 100644 --- a/apps/web/src/components/Nodes/fileReference/FileCommentActions.test.tsx +++ b/apps/web/src/components/Nodes/fileReference/FileCommentActions.test.tsx @@ -14,6 +14,7 @@ import { useChatStore } from '@/store/chatStore'; import { usePanelStore } from '@/store/panelStore'; import { createEmptyWorkspace } from '@/store/previewWorkspace/model'; import { usePreviewWorkspaceStore } from '@/store/previewWorkspace/store'; +import { copyToClipboard } from '@/utils/io/clipboard'; import { captureFileComment, FileCommentActions } from './FileCommentActions'; @@ -26,6 +27,9 @@ const notices = vi.hoisted(() => ({ dismissToast: vi.fn(), })); vi.mock('@/components/Common/Toast', () => notices); +vi.mock('@/utils/io/clipboard', () => ({ + copyToClipboard: vi.fn().mockResolvedValue(undefined), +})); vi.mock('@/api/canvas', async (original) => ({ ...(await original()), getRecentCanvasConversation: vi.fn(), @@ -42,6 +46,7 @@ const file: FileReadResult = { nextCursor: 'more', }; const identity = { canvasId: 'space', nodeId: 'file', agentletId: 'device' }; +const originalAddNode = useCanvasStore.getState().addNode; let host: HTMLDivElement; let surface: HTMLDivElement; let root: Root; @@ -52,6 +57,7 @@ beforeEach(async () => { await i18n.changeLanguage('en'); notices.toast.mockClear(); notices.dismissToast.mockClear(); + vi.mocked(copyToClipboard).mockReset().mockResolvedValue(undefined); vi.mocked(getRecentCanvasConversation).mockResolvedValue({ conversation: null, }); @@ -83,6 +89,7 @@ beforeEach(async () => { if (request) request.write?.(request.read(view), view); }); useCanvasStore.setState({ + addNode: originalAddNode, canvasId: 'space', pendingForkThreadIds: {}, nodes: [ @@ -262,3 +269,88 @@ it('does not offer new captures while loading or showing a read failure', async expect(button('Comment')).toBeDefined(); expect(document.body.textContent).not.toContain('New Agent'); }); + +it('reuses Chat action order and copies only the current selected text', async () => { + await render(); + expect( + [...document.querySelectorAll('button')].map((item) => + item.getAttribute('aria-label'), + ), + ).toEqual(['Copy', 'Add as note', 'Comment']); + await act(async () => button('Copy').click()); + expect(copyToClipboard).toHaveBeenCalledExactlyOnceWith('selected passage'); + expect(view.state.selection.main.empty).toBe(true); + expect(document.querySelector('button[aria-label="Copy"]')).toBeNull(); + expect(useCanvasStore.getState().nodes).toHaveLength(2); + expect(useChatStore.getState().threadsById.thread.draft).toBe( + 'Keep instructions', + ); +}); + +it('keeps selection and reports clipboard failures for retry', async () => { + vi.mocked(copyToClipboard).mockRejectedValueOnce(new Error('Denied')); + await render(); + await act(async () => button('Copy').click()); + expect(document.querySelector('[role="alert"]')?.textContent).toContain( + 'Could not copy', + ); + expect(view.state.selection.main.empty).toBe(false); + expect(button('Copy').disabled).toBe(false); + await act(async () => button('Copy').click()); + expect(copyToClipboard).toHaveBeenCalledTimes(2); + expect(view.state.selection.main.empty).toBe(true); +}); + +it('saves only selected source text as a Note with reference provenance', async () => { + const addNode = vi + .spyOn(useCanvasStore.getState(), 'addNode') + .mockImplementation(() => {}); + const workspace = usePreviewWorkspaceStore.getState().workspace; + await render(); + await act(async () => button('Add as note').click()); + expect(addNode).toHaveBeenCalledExactlyOnceWith({ + nodeType: 'note', + data: { + content: 'selected passage', + origin: { type: 'user-excerpt', excerptFromNodeId: 'file' }, + }, + }); + expect(copyToClipboard).not.toHaveBeenCalled(); + expect(view.state.doc.toString()).toBe(file.text); + expect(view.state.readOnly).toBe(true); + expect(view.state.selection.main.empty).toBe(true); + expect(usePreviewWorkspaceStore.getState().workspace).toBe(workspace); + expect(usePanelStore.getState().focusChatInputRequest).toBeNull(); + expect( + useChatStore.getState().threadsById.thread.pendingQuotedComments, + ).toEqual([]); +}); + +it('rejects actions whose model selection expired after the toolbar appeared', async () => { + const addNode = vi + .spyOn(useCanvasStore.getState(), 'addNode') + .mockImplementation(() => {}); + await render(); + vi.spyOn(view, 'coordsAtPos').mockReturnValue(null); + await act(async () => button('Add as note').click()); + expect(addNode).not.toHaveBeenCalled(); + expect(notices.toast).toHaveBeenCalledWith(expect.any(String), { + tone: 'warning', + }); +}); + +it('preserves selected line breaks when saving a multiline source excerpt', async () => { + const addNode = vi + .spyOn(useCanvasStore.getState(), 'addNode') + .mockImplementation(() => {}); + view.dispatch({ selection: { anchor: 10, head: text.length } }); + await render(); + await act(async () => button('Add as note').click()); + expect(addNode).toHaveBeenCalledExactlyOnceWith({ + nodeType: 'note', + data: { + content: 'selected passage after\nsecond line', + origin: { type: 'user-excerpt', excerptFromNodeId: 'file' }, + }, + }); +}); diff --git a/apps/web/src/components/Nodes/fileReference/FileCommentActions.tsx b/apps/web/src/components/Nodes/fileReference/FileCommentActions.tsx index a911f884f..39f51ebc3 100644 --- a/apps/web/src/components/Nodes/fileReference/FileCommentActions.tsx +++ b/apps/web/src/components/Nodes/fileReference/FileCommentActions.tsx @@ -3,19 +3,20 @@ 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 { FragmentSelectionActions } from '@/components/Messages/AIMessage/FragmentSelectionActions'; import { PreviewCommentEditor, type PreviewCommentCapture, } from '@/components/Messages/PreviewCommentEditor'; import { useTrackAttention } from '@/hooks/useTrackAttention'; +import useCanvasStore from '@/store/canvasStore'; import { useAnyGlobalModalOpen } from '@/store/globalModalUi'; +import { copyToClipboard } from '@/utils/io/clipboard'; import type { FileReadResult } from '@huabu/shared'; import type { RefObject } from 'react'; @@ -80,6 +81,16 @@ export function FileCommentActions({ const [engaged, setEngaged] = useState(true); const [dismissed, setDismissed] = useState(false); const modalOpen = useAnyGlobalModalOpen(); + const readSelection = () => + view && file && !disabled + ? captureFileComment(view, { canvasId, nodeId, agentletId }, file) + : null; + const clearSelection = () => { + if (view) { + view.dispatch({ selection: { anchor: view.state.selection.main.head } }); + } + setSelection(null); + }; useTrackAttention( (target) => surfaceRef.current?.contains(target) ?? false, setEngaged, @@ -134,21 +145,34 @@ export function FileCommentActions({ className="p-1" onDismiss={() => setDismissed(true)} > - + /> ) : null} {capture ? ( diff --git a/docs/architecture/device-files.md b/docs/architecture/device-files.md index 00a12f5da..0ffdc7e26 100644 --- a/docs/architecture/device-files.md +++ b/docs/architecture/device-files.md @@ -39,7 +39,7 @@ Adding or dragging a file uses existing Canvas commands to create a `fileReferen 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. +A single nonempty contiguous text selection offers the shared Copy, Add as note and Comment actions in the same order as Chat and Note. Copy puts exactly the selected source text on the clipboard, with explicit retryable failure feedback. Add as note creates a Space Note containing the selected source text with `user-excerpt` provenance pointing to the File Reference node; it never writes back to the file. Both actions clear the selection after success. The active-line background stays below CodeMirror's selection layer, so selecting part of a wrapped line remains visible. Comment uses 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. diff --git a/docs/architecture/note-node.md b/docs/architecture/note-node.md index 7c3e152a7..bb2a639e7 100644 --- a/docs/architecture/note-node.md +++ b/docs/architecture/note-node.md @@ -79,6 +79,8 @@ The factory's [`latexCodeFence`](../../apps/web/src/components/Milkdown/latexCod Readonly code blocks reuse Crepe's lazy NodeView, but a text change invalidates that block's view so ProseMirror recreates it from the updated source. Crepe 7.21.2 otherwise rejects its own programmatic CodeMirror text updates through a readonly change filter, leaving touch-mode streaming source stale. Editable blocks retain the upstream incremental update path; the adapter never temporarily enables editing or replaces the math renderer, preview sanitizer, Copy handler or visibility lifecycle. +The shared factory adds CodeMirror's `EditorView.lineWrapping` through Crepe's CodeMirror feature configuration. Code-block source visually wraps to its container width and reflows when Chat or Note is resized, including long unbroken tokens. This applies to editable and readonly source views, including latex/tex fences and the source editor for explicit math, without adding line breaks to the document. Source Copy, fragment/drag export and Markdown serialization retain the original text; rendered math previews are unchanged. + Everything from here on is what happens **inside** the document. Pointer routing up to that point — which gesture the canvas claims before the event ever reaches a note — belongs to [canvas-input-interactions.md](./canvas-input-interactions.md). | Surface | Mount | Notes | diff --git a/docs/architecture/preview-workspace.md b/docs/architecture/preview-workspace.md index 52961da89..bb9292f67 100644 --- a/docs/architecture/preview-workspace.md +++ b/docs/architecture/preview-workspace.md @@ -42,6 +42,8 @@ Within a mounted Chat renderer, the thread-scoped composer owns the draft subscr Chat prose uses the shared Milkdown factory for both streaming updates and completed messages. Explicit `latex` fences (case-insensitive) behave like `tex`: highlighted source only, including formulas, custom macros and math delimiters inside the fence. They are not document previews or an alternative spelling of display math. Outside code, `$...$`, `$$...$$`, `\(...\)` and `\[...\]` retain mathematical rendering and the existing KaTeX/sanitization boundary. The factory redirects original code AST nodes before Crepe converts explicit display math into its internal LaTeX block representation; drag editability does not choose the syntax's meaning. Original fence language/body survive Markdown serialization for fragment extraction, dragging and Add as note; whole-response Copy still uses the original assistant Markdown, and block Copy uses the source body. See [the shared Note factory](./note-node.md#4-two-surfaces-one-factory). +Code-block source uses CodeMirror soft wrapping from that shared factory, following the available Chat or Note container width as it changes. Long lines and unbroken tokens wrap visually instead of requiring horizontal source scrolling; the source text, indentation, Copy output and serialized Markdown do not gain line breaks. Editable and readonly code views share this behavior, while rendered math previews remain unchanged. + The main chat textarea grows with its rendered content from a minimum of two text lines to a maximum of ten, including soft-wrapped lines. Content beyond ten lines scrolls inside the textarea without chaining scroll to the conversation. Content changes, restored or cleared drafts, and composer width changes remeasure its height; shrinking or clearing the draft reduces it back toward the two-line minimum. Main Chat and fragment-comment input share `useAutoSizeTextArea`, which measures content including field borders and observes parent width changes; each surface retains its own CSS height limits. Quoted-comment editors retain their separate sizing policy. 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. @@ -62,7 +64,7 @@ The optional `quotedComments` wire field contains quote, contextBefore, contextA 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. +File Reference Preview opts its read-only CodeMirror surface into the shared Copy / Add as note / Comment actions for one contiguous text selection. Copy uses the selected plain source text; Add as note saves that selected text in a Space Note with excerpt provenance pointing to the reference node, without modifying the source file. Its active-line background is painted below the text selection rather than covering it. 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.