Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
ef71e3b
fix(files): isAgentEditing flag passthrough
icecrasher321 Jun 20, 2026
eb5c1d0
use smooth streaming hook
icecrasher321 Jun 20, 2026
6773fe9
improve performance
icecrasher321 Jun 20, 2026
0e46052
remove comments
icecrasher321 Jun 20, 2026
7a20d8a
improvement(rich-md-editor): reveal bubble after drag-select, keep it…
waleedlatif1 Jun 20, 2026
56f319f
improvement(share-modal): use Send icon in the share file header
waleedlatif1 Jun 20, 2026
dffce52
improvement(rich-md-editor): pin the formatting toolbar so it stays p…
waleedlatif1 Jun 20, 2026
f3035ab
improvement(rich-md-editor): show the formatting toolbar in the mothe…
waleedlatif1 Jun 20, 2026
8842ad7
fix(sidebar): drive collapsed width from server-rendered attribute
waleedlatif1 Jun 20, 2026
ced099a
feat(rich-md-editor): let focused editors claim shortcuts from the gl…
waleedlatif1 Jun 20, 2026
98377cb
refactor(rich-md-editor): freeze the formatting toolbar on scroll and…
waleedlatif1 Jun 20, 2026
138a0c9
feat(rich-md-editor): add a link hover card and claim Cmd+K for the l…
waleedlatif1 Jun 20, 2026
f8d1841
fix(rich-md-editor): portal the toolbar + link card to body so a tran…
waleedlatif1 Jun 20, 2026
0834afe
fix(rich-md-editor): hide the code line-wrap toggle in read-only
waleedlatif1 Jun 20, 2026
d149313
fix(sidebar): pass isCollapsed to Sidebar in the error fallback
waleedlatif1 Jun 20, 2026
feb7861
address greptile comment
icecrasher321 Jun 20, 2026
b064503
docs(rich-md-editor): note why table cells escape only pipes (renderC…
waleedlatif1 Jun 20, 2026
871068a
fix(rich-md-editor): lock the editor immediately when an agent edit s…
waleedlatif1 Jun 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -91,8 +91,10 @@ interface FileViewerProps {
onSaveStatusChange?: (status: 'idle' | 'saving' | 'saved' | 'error') => void
saveRef?: React.MutableRefObject<(() => Promise<void>) | null>
streamingContent?: string
isAgentEditing?: boolean
disableStreamingAutoScroll?: boolean
previewContextKey?: string
showBubbleMenu?: boolean
}

export function FileViewer({
Expand All @@ -106,8 +108,10 @@ export function FileViewer({
onSaveStatusChange,
saveRef,
streamingContent,
isAgentEditing,
disableStreamingAutoScroll = false,
previewContextKey,
showBubbleMenu = true,
}: FileViewerProps) {
const category = resolveFileCategory(file.type, file.name)

Expand Down Expand Up @@ -147,8 +151,10 @@ export function FileViewer({
onSaveStatusChange={onSaveStatusChange}
saveRef={saveRef}
streamingContent={streamingContent}
isAgentEditing={isAgentEditing}
disableStreamingAutoScroll={disableStreamingAutoScroll}
previewContextKey={previewContextKey}
showBubbleMenu={showBubbleMenu}
/>
)
}
Expand All @@ -164,6 +170,7 @@ export function FileViewer({
onSaveStatusChange={onSaveStatusChange}
saveRef={saveRef}
streamingContent={streamingContent}
isAgentEditing={isAgentEditing}
disableStreamingAutoScroll={disableStreamingAutoScroll}
previewContextKey={previewContextKey}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ const EXTENSIONS = createMarkdownEditorExtensions({
placeholder: "Write something, or press '/' for commands…",
})

// Throttle the per-frame full re-parse above this body size so a large streaming file can't saturate the main thread.
const STREAM_REPARSE_THROTTLE_THRESHOLD = 40_000
const STREAM_REPARSE_THROTTLE_MS = 120

interface RichMarkdownEditorProps {
file: WorkspaceFileRecord
workspaceId: string
Expand All @@ -39,22 +43,13 @@ interface RichMarkdownEditorProps {
onSaveStatusChange?: (status: SaveStatus) => void
saveRef?: React.MutableRefObject<(() => Promise<void>) | null>
streamingContent?: string
isAgentEditing?: boolean
disableStreamingAutoScroll?: boolean
previewContextKey?: string
showBubbleMenu?: boolean
}

/**
* Inline WYSIWYG markdown editor (TipTap/ProseMirror) for markdown files — a single editing surface
* (markdown transformed inline as you type), no raw/preview split and no separate streaming preview.
* Owns the file lifecycle through a single {@link useEditableFileContent} engine, and the TipTap
* editor is the ONLY thing the user ever sees: while agent output streams in it renders that content
* read-only (synced per chunk), then the same editor instance becomes editable once the stream
* settles — so the stream→edit transition has no renderer swap or flash.
*
* The editor is keyed by file id (+ streaming context). A file opened outside a stream uses the plain
* create-time initial-content model (no sync). See {@link LoadedRichMarkdownEditor} for the
* read-only-stream → editable hand-off.
*/
/** Inline WYSIWYG markdown editor: agent output streams in read-only, then the same instance becomes editable on settle. */
export const RichMarkdownEditor = memo(function RichMarkdownEditor({
file,
workspaceId,
Expand All @@ -64,8 +59,10 @@ export const RichMarkdownEditor = memo(function RichMarkdownEditor({
onSaveStatusChange,
saveRef,
streamingContent,
isAgentEditing,
disableStreamingAutoScroll = false,
previewContextKey,
showBubbleMenu = true,
}: RichMarkdownEditorProps) {
const {
content,
Expand All @@ -79,6 +76,7 @@ export const RichMarkdownEditor = memo(function RichMarkdownEditor({
workspaceId,
canEdit,
streamingContent,
isAgentEditing,
onDirtyChange,
onSaveStatusChange,
saveRef,
Expand All @@ -105,6 +103,7 @@ export const RichMarkdownEditor = memo(function RichMarkdownEditor({
canEdit={canEdit}
autoFocus={autoFocus}
disableStreamingAutoScroll={disableStreamingAutoScroll}
showBubbleMenu={showBubbleMenu}
onChange={setDraftContent}
onSaveShortcut={saveImmediately}
/>
Expand All @@ -121,6 +120,7 @@ interface LoadedRichMarkdownEditorProps {
canEdit: boolean
autoFocus?: boolean
disableStreamingAutoScroll?: boolean
showBubbleMenu: boolean
onChange: (markdown: string) => void
onSaveShortcut: () => Promise<void>
}
Expand All @@ -130,25 +130,12 @@ interface SettledContent {
verdict: boolean
}

/**
* Lock the round-trip verdict + frontmatter on the content the editor "opens" with — once, at mount
* for a settled file or at the moment a stream settles. A round-trip-unsafe document (raw HTML,
* footnotes, >128KB, …) opens read-only so an edit can't corrupt it; a safe one stays editable. Never
* re-derived: a dirty document is safe by construction (the editor only emits safe markdown), so
* flipping editability off mid-edit would only strand edits.
*/
/** Locks the round-trip verdict + frontmatter once; a round-trip-unsafe doc (raw HTML, footnotes, >128KB) opens read-only. */
function lockSettled(content: string): SettledContent {
return { frontmatter: splitFrontmatter(content).frontmatter, verdict: isRoundTripSafe(content) }
}

/**
* The single TipTap editor for a markdown file — the only surface the user ever sees. While agent
* output streams in ({@link isStreaming}) it renders that content read-only and re-syncs each chunk;
* when the stream settles it locks the round-trip verdict + frontmatter on the final content and
* hands control to the user. A file opened outside a stream skips straight to that editable state via
* the initial-content model (no imperative sync). Frontmatter is held aside and re-applied on every
* change, so the editor only ever round-trips the body.
*/
/** The single TipTap editor: read-only while streaming, editable on settle; frontmatter is held aside and re-applied. */
export function LoadedRichMarkdownEditor({
file,
workspaceId,
Expand All @@ -157,28 +144,24 @@ export function LoadedRichMarkdownEditor({
canEdit,
autoFocus,
disableStreamingAutoScroll,
showBubbleMenu,
onChange,
onSaveShortcut,
}: LoadedRichMarkdownEditorProps) {
// Whether this editor mounted mid-stream — if so it starts empty and syncs streamed chunks until settle.
const streamingAtMountRef = useRef(isStreaming)

// Verdict + frontmatter locked once via {@link lockSettled} (at mount when settled, else when the
// stream settles below); null until then reads as read-only.
// Verdict + frontmatter, locked once (at mount if settled, else on settle); null reads as read-only.
const settledRef = useRef<SettledContent | null>(null)
if (!streamingAtMountRef.current && settledRef.current === null) {
settledRef.current = lockSettled(content)
}
const isEditable = canEdit && !isStreaming && (settledRef.current?.verdict ?? false)

// Seed the editor with the chunked-parsed doc (linear vs the editor's ~O(n²) markdown parse), computed
// once via lazy state init — `useRef(parseMarkdownToDoc(...))` would re-parse the whole body every render.
// Seed the doc once via lazy init — chunked parse is linear vs the editor's ~O(n²) whole-body markdown parse.
const [initialContent] = useState<JSONContent | string>(() =>
streamingAtMountRef.current ? '' : parseMarkdownToDoc(splitFrontmatter(content).body)
)
// Frontmatter held aside and re-attached on every change (the editor never shows it); re-derived per
// stream→settle in the settle effect, so a repeat stream uses the new doc's frontmatter, not a stale one.
const frontmatterRef = useRef(settledRef.current?.frontmatter ?? '')
const onChangeRef = useRef(onChange)
onChangeRef.current = onChange
const onSaveShortcutRef = useRef(onSaveShortcut)
Expand All @@ -191,12 +174,7 @@ export function LoadedRichMarkdownEditor({
const uploadFile = useUploadWorkspaceFile()
const editorInstanceRef = useRef<Editor | null>(null)

/**
* Upload each image to the workspace, then insert it at `at` (paste = caret, drop = cursor under
* the pointer). Sequential so multiple images stack in order; the upload hook surfaces its own
* success/error toasts, so a failed upload is skipped without interrupting the rest. Held in a ref
* (reassigned each render) so the once-built `editorProps` handlers always reach the latest values.
*/
// Upload then insert each image at `at` (paste caret / drop point), sequentially; held in a ref so handlers reach the latest.
const insertImagesRef = useRef<(images: File[], at: number) => Promise<void>>(() =>
Promise.resolve()
)
Expand Down Expand Up @@ -243,11 +221,9 @@ export function LoadedRichMarkdownEditor({
handleClick: (view, _pos, event) => {
const href = (event.target as HTMLElement | null)?.closest('a')?.getAttribute('href')
if (!href) return false
// Editing: require a modifier so a plain click can place the cursor. Read-only (a reader, e.g.
// the public share page): a plain click follows the link.
// Editing requires a modifier to follow a link (a plain click places the cursor); read-only follows it directly.
if (view.editable && !(event.metaKey || event.ctrlKey)) return false
// Same-page anchor (`[x](#slug)`): scroll to the matching heading instead of opening a tab,
// restoring the table-of-contents links that worked via rehype-slug in the old preview.
// Same-page anchor (`[x](#slug)`): scroll to the matching heading instead of opening a tab.
if (href.startsWith('#')) {
const pos = findHeadingPos(view.state.doc, href.slice(1))
if (pos < 0) return false
Expand All @@ -259,8 +235,7 @@ export function LoadedRichMarkdownEditor({
}
const normalized = normalizeLinkHref(href)
if (!normalized) return false
// A same-origin in-app path navigates within the SPA (same tab) — unless the reader
// modifier-clicked for a new tab. External URLs always open a new tab.
// A same-origin in-app path navigates within the SPA (same tab); external URLs open a new tab.
if (
!(event.metaKey || event.ctrlKey) &&
normalized.startsWith('/') &&
Expand Down Expand Up @@ -292,22 +267,18 @@ export function LoadedRichMarkdownEditor({
},
onUpdate: ({ editor }) => {
const md = postProcessSerializedMarkdown(editor.getMarkdown())
onChangeRef.current(applyFrontmatter(frontmatterRef.current, md))
onChangeRef.current(applyFrontmatter(settledRef.current?.frontmatter ?? '', md))
},
})
editorInstanceRef.current = editor

// Stream content in read-only until it settles, then lock the verdict + frontmatter and hand off; after
// that only `canEdit` touches the editor (it owns the content, so no sync can clobber a user edit).
const lastSyncedBodyRef = useRef<string | null>(null)
// Tracks whether the previous run was streaming so the settle branch re-locks on every stream→settle:
// one instance can receive several agent edits in a chat (kept mounted by `previewContextKey`), so the
// verdict/frontmatter must follow the latest stream, not the first settled snapshot.

const wasStreamingRef = useRef(streamingAtMountRef.current)
// Coalesce streamed chunks to one re-parse per animation frame — a fast agent emits many per frame and
// each would re-parse the whole accumulating body. Read-only while streaming, so only the latest renders.

const pendingStreamBodyRef = useRef<string | null>(null)
const streamRafRef = useRef<number | null>(null)
const lastStreamParseAtRef = useRef(0)
Comment thread
waleedlatif1 marked this conversation as resolved.
useEffect(() => {
if (!editor) return
if (isStreaming) {
Expand All @@ -316,11 +287,23 @@ export function LoadedRichMarkdownEditor({
if (body === lastSyncedBodyRef.current) return
pendingStreamBodyRef.current = body
if (streamRafRef.current !== null) return
streamRafRef.current = requestAnimationFrame(() => {
streamRafRef.current = null
// Self-re-arming tick: parse the latest pending body, but throttle a large one (cheap re-check, no parse) until due.
const tick = () => {
const pending = pendingStreamBodyRef.current
if (pending === null || pending === lastSyncedBodyRef.current) return
if (pending === null || pending === lastSyncedBodyRef.current) {
streamRafRef.current = null
return
}
if (
pending.length > STREAM_REPARSE_THROTTLE_THRESHOLD &&
performance.now() - lastStreamParseAtRef.current < STREAM_REPARSE_THROTTLE_MS
) {
streamRafRef.current = requestAnimationFrame(tick)
return
}
streamRafRef.current = null
lastSyncedBodyRef.current = pending
lastStreamParseAtRef.current = performance.now()
const el = containerRef.current
const pinnedToBottom = el ? el.scrollHeight - el.scrollTop - el.clientHeight < 80 : false
editor.setEditable(false)
Expand All @@ -329,24 +312,21 @@ export function LoadedRichMarkdownEditor({
emitUpdate: false,
})
if (!disableStreamingAutoScroll && el && pinnedToBottom) el.scrollTop = el.scrollHeight
})
}
streamRafRef.current = requestAnimationFrame(tick)
return
}
// Drop a frame scheduled just before settle so it can't land afterward and clobber the final content.
if (streamRafRef.current !== null) {
cancelAnimationFrame(streamRafRef.current)
streamRafRef.current = null
}
// Settle: re-lock the verdict + frontmatter on the freshly-settled content — on the first settle and
// every later stream→settle, so a repeat agent edit gates on the NEW content, not a stale snapshot.
// User edits never reach here (`isStreaming`/`wasStreamingRef` stay false), preserving don't-strand-edits.
// Settle: re-lock the verdict + frontmatter on the freshly-settled content (every stream→settle, not just the first).
const isInitialSettle = settledRef.current === null
if (isInitialSettle || wasStreamingRef.current) {
wasStreamingRef.current = false
settledRef.current = lockSettled(content)
frontmatterRef.current = settledRef.current.frontmatter
// Re-seed only if the settled body differs from the last streamed chunk — it usually doesn't,
// and an extra setContent would needlessly rebuild the doc and drop selection/scroll.
// Re-seed only if the settled body differs from the last streamed chunk (avoids a needless doc rebuild + selection loss).
const body = splitFrontmatter(content).body
if (body !== lastSyncedBodyRef.current) {
lastSyncedBodyRef.current = body
Expand Down Expand Up @@ -374,7 +354,7 @@ export function LoadedRichMarkdownEditor({
ref={containerRef}
className={cn('flex flex-1 flex-col overflow-y-auto', isEditable && 'cursor-text')}
>
{editor && <EditorBubbleMenu editor={editor} />}
{showBubbleMenu && editor && <EditorBubbleMenu editor={editor} />}
<EditorContent
editor={editor}
className='mx-auto flex w-full max-w-[48rem] flex-1 flex-col px-8 py-6 selection:bg-[var(--selection-bg)] selection:text-[var(--text-primary)] dark:selection:bg-[var(--selection-dark)] dark:selection:text-white'
Expand Down
Loading
Loading