From c2e4e9df3eb36f42c1e2e473d83f53d20803a843 Mon Sep 17 00:00:00 2001 From: Taki Koutsomitis Date: Fri, 11 Sep 2026 18:09:14 -0400 Subject: [PATCH 1/2] Render formatted note previews during generation --- components/AIMode/DocumentPane.tsx | 86 +++++++++++++++++--------- components/AIMode/useAIModeDocument.ts | 11 +++- components/AgentChat/ActivityFeed.tsx | 5 +- hooks/useAgentChat.ts | 15 ++++- types/agentChat.ts | 4 ++ 5 files changed, 88 insertions(+), 33 deletions(-) diff --git a/components/AIMode/DocumentPane.tsx b/components/AIMode/DocumentPane.tsx index 1847f8aa0..4fa234cd0 100644 --- a/components/AIMode/DocumentPane.tsx +++ b/components/AIMode/DocumentPane.tsx @@ -18,6 +18,7 @@ import { noteDiffPersistableDoc } from '@/components/Notebook/NoteReview/noteDif import { useNoteAgentReview } from '@/components/Notebook/NoteReview/useNoteAgentReview'; import { Button } from '@/components/ui/Button'; import { Loader } from '@/components/ui/Loader'; +import { MarkdownMessage } from '@/components/AgentChat/MarkdownMessage'; import { DocumentPaneSkeleton } from '@/components/skeletons/AIModeSkeleton'; import { useUpdateNote } from '@/hooks/useNote'; import type { AgentChat } from '@/types/agentChat'; @@ -60,7 +61,7 @@ export function DocumentPane({ readOnly = false, className, }: DocumentPaneProps) { - const { note, content, loading, error, status, draftText, phaseLabel } = document; + const { note, content, loading, error, status, draftText, draftMarkdown, phaseLabel } = document; const noteId = note?.id ?? null; const writing = status === 'drafting' || status === 'working'; @@ -196,19 +197,27 @@ export function DocumentPane({ {/* Mounted once per note: the editor's content prop is only read on creation, and later versions arrive through the review. */} - +
+ +
- {status === 'drafting' && draftText && } + {status === 'drafting' && draftText && ( + + )} {status === 'working' && document.hasWrittenVersion && ( @@ -268,26 +277,47 @@ function EmptyDocument() { } /** The section being written, appended below the settled content. */ -function DraftSection({ text }: { readonly text: string }) { - const paragraphs = text.split(/\n{2,}/).filter((paragraph) => paragraph.trim().length > 0); +function DraftSection({ + text, + markdown, + hasSavedContent, +}: { + readonly text: string; + readonly markdown: string | null; + readonly hasSavedContent: boolean; +}) { return (
-
- - Writing +
+ + + Drafting + + Preview updates live +
- {paragraphs.map((paragraph, index) => ( -

- {paragraph} - {index === paragraphs.length - 1 && ( - - )} -

- ))} + {markdown != null ? ( + + ) : ( +
+ {text + .split(/\n{2,}/) + .filter(Boolean) + .map((paragraph, index) => ( +

+ {paragraph} +

+ ))} +
+ )}
); } diff --git a/components/AIMode/useAIModeDocument.ts b/components/AIMode/useAIModeDocument.ts index 71ecaa4c4..ca25fa2be 100644 --- a/components/AIMode/useAIModeDocument.ts +++ b/components/AIMode/useAIModeDocument.ts @@ -6,6 +6,7 @@ import type { NoteWithContent } from '@/types/note'; import { isActiveExecutionStatus, type ChatExecution, + type ChatToolDraftActivity, type ChatNoteRef, type AgentChat, } from '@/types/agentChat'; @@ -40,6 +41,7 @@ export interface AIModeDocument { readonly hasWrittenVersion: boolean; /** Prose of the `edit_note` call being composed, paragraphs split by blank lines. */ readonly draftText: string | null; + readonly draftMarkdown: string | null; /** What the assistant is doing, for the in-progress row when there is no draft. */ readonly phaseLabel: string | null; /** Deep link to the note in the notebook, once its organization is known. */ @@ -64,13 +66,13 @@ function chatHasEditedNote(chat: AgentChat | null): boolean { } /** The `edit_note` draft the active turn is composing, if any. */ -function currentEditDraft(execution: ChatExecution | null): string | null { +function currentEditDraft(execution: ChatExecution | null): ChatToolDraftActivity | null { if (execution == null || !isActiveExecutionStatus(execution.status)) return null; const items = execution.stream?.items ?? []; for (let index = items.length - 1; index >= 0; index -= 1) { const item = items[index]; if (item.type === 'tool_draft' && item.tool === 'edit_note') { - return item.text.length > 0 ? item.text : null; + return item; } } return null; @@ -119,7 +121,9 @@ export function useAIModeDocument({ if (noteId != null) fetchNote(); }, [noteId, fetchNote]); - const draftText = currentEditDraft(latestExecution); + const draft = currentEditDraft(latestExecution); + const draftText = draft?.text || null; + const draftMarkdown = typeof draft?.markdown === 'string' ? draft.markdown : null; const turnActive = latestExecution != null && isActiveExecutionStatus(latestExecution.status); const phaseLabel = turnActive ? (latestExecution?.phase?.label ?? null) : null; @@ -146,6 +150,7 @@ export function useAIModeDocument({ status, hasWrittenVersion, draftText, + draftMarkdown, phaseLabel, notebookHref, reload: fetchNote, diff --git a/components/AgentChat/ActivityFeed.tsx b/components/AgentChat/ActivityFeed.tsx index 8dbd695ea..3529fbd79 100644 --- a/components/AgentChat/ActivityFeed.tsx +++ b/components/AgentChat/ActivityFeed.tsx @@ -215,6 +215,7 @@ function StreamedTextRow({ label, text, streaming, + autoExpand = true, className, bodyClassName, icon: Icon, @@ -222,13 +223,14 @@ function StreamedTextRow({ readonly label: string; readonly text: string; readonly streaming: boolean; + readonly autoExpand?: boolean; readonly className: string; readonly bodyClassName?: string; readonly icon?: ComponentType<{ className?: string }>; }) { const [userExpanded, setUserExpanded] = useState(null); const hasText = text.length > 0; - const expanded = hasText && (userExpanded ?? streaming); + const expanded = hasText && (userExpanded ?? (streaming && autoExpand)); // Settled rows re-render on every stream delta; strip once per text value. const preview = useMemo(() => stripMarkdown(text), [text]); @@ -400,6 +402,7 @@ function ActivityItemBody({ label={humanizeLabel(item.label)} text={item.text} streaming={streaming} + autoExpand={false} icon={TOOL_ICONS[item.tool] ?? Wrench} className="text-gray-800 hover:text-gray-600 [--shine:theme(colors.gray.800)]" bodyClassName="text-sm text-gray-500" diff --git a/hooks/useAgentChat.ts b/hooks/useAgentChat.ts index c7946dd87..38512d931 100644 --- a/hooks/useAgentChat.ts +++ b/hooks/useAgentChat.ts @@ -126,7 +126,13 @@ function phaseForDelta(delta: ChatStreamDelta | undefined): ExecutionPhase { function newStreamItem(delta: ChatStreamDelta, maximum: number): ChatStreamItem { const base = { id: delta.id, text: delta.delta.slice(0, maximum), at: delta.at }; return delta.type === 'tool_draft' - ? { ...base, type: 'tool_draft', tool: delta.tool, label: delta.label } + ? { + ...base, + type: 'tool_draft', + tool: delta.tool, + label: delta.label, + markdown: typeof delta.markdown === 'string' ? delta.markdown.slice(0, maximum) : undefined, + } : { ...base, type: delta.type }; } @@ -143,6 +149,13 @@ function appendStreamDeltas( items.push(newStreamItem(delta, maximum)); } else if (existing.type === delta.type) { existing.text = `${existing.text}${delta.delta}`.slice(0, maximum); + if ( + existing.type === 'tool_draft' && + delta.type === 'tool_draft' && + typeof delta.markdown === 'string' + ) { + existing.markdown = delta.markdown.slice(0, maximum); + } } else { // An id changing type indicates an incompatible/corrupt frame. Recover // from the server checkpoint instead of combining unlike content. diff --git a/types/agentChat.ts b/types/agentChat.ts index 85ba0d4df..0082b6915 100644 --- a/types/agentChat.ts +++ b/types/agentChat.ts @@ -79,6 +79,8 @@ export interface ChatToolCallActivity { */ export interface ChatToolDraftActivity { type: 'tool_draft'; + /** Complete formatted preview snapshot; replaces itself on each frame. */ + markdown?: string; /** * The prose extracted from the arguments so far. Empty for tools whose * arguments aren't prose — a search query is written in an instant, so only @@ -253,6 +255,8 @@ export type ChatStreamDelta = | (ChatStreamDeltaBase & { type: 'narration' | 'thinking' }) | (ChatStreamDeltaBase & { type: 'tool_draft'; + /** Complete formatted preview snapshot, not an appended delta. */ + markdown?: string; /** Machine name; empty when the provider skipped the block-start event. */ tool: string; /** Human copy supplied by the backend; always rendered verbatim. */ From a339b55fdfa6fae5abd5149b1aa5b9d22488c090 Mon Sep 17 00:00:00 2001 From: Taki Koutsomitis Date: Fri, 11 Sep 2026 19:12:51 -0400 Subject: [PATCH 2/2] Render streamed note blocks with the existing Tiptap extensions --- components/AIMode/DocumentPane.tsx | 32 +++++---------- components/AIMode/DraftBlockPreview.tsx | 50 ++++++++++++++++++++++++ components/AIMode/useAIModeDocument.ts | 16 +++++--- components/AgentChat/MarkdownMessage.tsx | 8 +++- hooks/useAgentChat.ts | 6 +-- types/agentChat.ts | 6 ++- 6 files changed, 84 insertions(+), 34 deletions(-) create mode 100644 components/AIMode/DraftBlockPreview.tsx diff --git a/components/AIMode/DocumentPane.tsx b/components/AIMode/DocumentPane.tsx index 4fa234cd0..f99dd8a7b 100644 --- a/components/AIMode/DocumentPane.tsx +++ b/components/AIMode/DocumentPane.tsx @@ -18,7 +18,7 @@ import { noteDiffPersistableDoc } from '@/components/Notebook/NoteReview/noteDif import { useNoteAgentReview } from '@/components/Notebook/NoteReview/useNoteAgentReview'; import { Button } from '@/components/ui/Button'; import { Loader } from '@/components/ui/Loader'; -import { MarkdownMessage } from '@/components/AgentChat/MarkdownMessage'; +import { DraftBlockPreview } from './DraftBlockPreview'; import { DocumentPaneSkeleton } from '@/components/skeletons/AIModeSkeleton'; import { useUpdateNote } from '@/hooks/useNote'; import type { AgentChat } from '@/types/agentChat'; @@ -61,7 +61,7 @@ export function DocumentPane({ readOnly = false, className, }: DocumentPaneProps) { - const { note, content, loading, error, status, draftText, draftMarkdown, phaseLabel } = document; + const { note, content, loading, error, status, draftText, draftBlocks, phaseLabel } = document; const noteId = note?.id ?? null; const writing = status === 'drafting' || status === 'working'; @@ -214,7 +214,9 @@ export function DocumentPane({ {status === 'drafting' && draftText && ( )} @@ -279,11 +281,13 @@ function EmptyDocument() { /** The section being written, appended below the settled content. */ function DraftSection({ text, - markdown, + blocks, + editor, hasSavedContent, }: { readonly text: string; - readonly markdown: string | null; + readonly blocks: AIModeDocument['draftBlocks']; + readonly editor: Editor | null; readonly hasSavedContent: boolean; }) { return ( @@ -301,23 +305,7 @@ function DraftSection({ Preview updates live - {markdown != null ? ( - - ) : ( -
- {text - .split(/\n{2,}/) - .filter(Boolean) - .map((paragraph, index) => ( -

- {paragraph} -

- ))} -
- )} + ); } diff --git a/components/AIMode/DraftBlockPreview.tsx b/components/AIMode/DraftBlockPreview.tsx new file mode 100644 index 000000000..c2d9d6265 --- /dev/null +++ b/components/AIMode/DraftBlockPreview.tsx @@ -0,0 +1,50 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { generateHTML, type Editor, type JSONContent } from '@tiptap/core'; +import sanitizeHtml from 'sanitize-html'; +import { ASSISTANT_HTML_SANITIZE_OPTIONS } from '@/components/AgentChat/MarkdownMessage'; + +/** A presentation-only snapshot: never apply partial blocks to the live editor. */ +export function DraftBlockPreview({ + blocks, + editor, + fallbackText, +}: { + readonly blocks: JSONContent[] | null; + readonly editor: Editor | null; + readonly fallbackText: string; +}) { + const [html, setHtml] = useState(null); + + useEffect(() => { + if (!editor || editor.isDestroyed || blocks == null) return; + try { + const rendered = generateHTML( + { type: 'doc', content: blocks }, + editor.extensionManager.extensions + ); + setHtml(sanitizeHtml(rendered, ASSISTANT_HTML_SANITIZE_OPTIONS)); + } catch { + // A node type, mark, or text value may still be incomplete. Keep the + // last renderable snapshot until the next fragment supplies it. + } + }, [blocks, editor]); + + if (html == null) { + return ( +
+ {fallbackText} +
+ ); + } + + return ( +
+ ); +} diff --git a/components/AIMode/useAIModeDocument.ts b/components/AIMode/useAIModeDocument.ts index ca25fa2be..f2f8a7647 100644 --- a/components/AIMode/useAIModeDocument.ts +++ b/components/AIMode/useAIModeDocument.ts @@ -1,12 +1,13 @@ 'use client'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import type { JSONContent } from '@tiptap/core'; import { NoteService } from '@/services/note.service'; import type { NoteWithContent } from '@/types/note'; import { isActiveExecutionStatus, type ChatExecution, - type ChatToolDraftActivity, + type ChatStreamItem, type ChatNoteRef, type AgentChat, } from '@/types/agentChat'; @@ -41,7 +42,8 @@ export interface AIModeDocument { readonly hasWrittenVersion: boolean; /** Prose of the `edit_note` call being composed, paragraphs split by blank lines. */ readonly draftText: string | null; - readonly draftMarkdown: string | null; + readonly draftBlocks: JSONContent[] | null; + readonly draftKey: string; /** What the assistant is doing, for the in-progress row when there is no draft. */ readonly phaseLabel: string | null; /** Deep link to the note in the notebook, once its organization is known. */ @@ -66,7 +68,9 @@ function chatHasEditedNote(chat: AgentChat | null): boolean { } /** The `edit_note` draft the active turn is composing, if any. */ -function currentEditDraft(execution: ChatExecution | null): ChatToolDraftActivity | null { +function currentEditDraft( + execution: ChatExecution | null +): Extract | null { if (execution == null || !isActiveExecutionStatus(execution.status)) return null; const items = execution.stream?.items ?? []; for (let index = items.length - 1; index >= 0; index -= 1) { @@ -123,7 +127,8 @@ export function useAIModeDocument({ const draft = currentEditDraft(latestExecution); const draftText = draft?.text || null; - const draftMarkdown = typeof draft?.markdown === 'string' ? draft.markdown : null; + const draftBlocks = Array.isArray(draft?.blocks) ? draft.blocks : null; + const draftKey = `${latestExecution?.stream?.id}:${draft?.id}`; const turnActive = latestExecution != null && isActiveExecutionStatus(latestExecution.status); const phaseLabel = turnActive ? (latestExecution?.phase?.label ?? null) : null; @@ -150,7 +155,8 @@ export function useAIModeDocument({ status, hasWrittenVersion, draftText, - draftMarkdown, + draftBlocks, + draftKey, phaseLabel, notebookHref, reload: fetchNote, diff --git a/components/AgentChat/MarkdownMessage.tsx b/components/AgentChat/MarkdownMessage.tsx index 9c041dec5..e7020e4ad 100644 --- a/components/AgentChat/MarkdownMessage.tsx +++ b/components/AgentChat/MarkdownMessage.tsx @@ -14,7 +14,7 @@ const md = new MarkdownIt({ breaks: true, }); -const SANITIZE_OPTIONS: sanitizeHtml.IOptions = { +export const ASSISTANT_HTML_SANITIZE_OPTIONS: sanitizeHtml.IOptions = { allowedTags: [ 'p', 'br', @@ -48,6 +48,7 @@ const SANITIZE_OPTIONS: sanitizeHtml.IOptions = { allowedAttributes: { a: ['href', 'title', 'target', 'rel'], code: ['class'], + ol: ['start'], th: ['align'], td: ['align'], }, @@ -111,7 +112,10 @@ export function MarkdownMessage({ revealCarryTo, }: MarkdownMessageProps) { const shown = useTextReveal(content, revealKey, revealCarryTo); - const html = useMemo(() => sanitizeHtml(md.render(shown), SANITIZE_OPTIONS), [shown]); + const html = useMemo( + () => sanitizeHtml(md.render(shown), ASSISTANT_HTML_SANITIZE_OPTIONS), + [shown] + ); return (