improvement(rich-md-editor): streaming, performance, minor bugfixes (#5148)

* fix(files): isAgentEditing flag passthrough

* use smooth streaming hook

* improve performance

* remove comments

* improvement(rich-md-editor): reveal bubble after drag-select, keep it on-screen for tall selections, restyle task-list checkbox

* improvement(share-modal): use Send icon in the share file header

* improvement(rich-md-editor): pin the formatting toolbar so it stays put while scrolling

* improvement(rich-md-editor): show the formatting toolbar in the mothership file view

* fix(sidebar): drive collapsed width from server-rendered attribute

A collapsed rail painted at the expanded width then animated to 51px on
refresh: structure came from the cookie (server) while width came from
independent cookie reads (blocking script + store), so any disagreement left
the collapsed structure at the persisted expanded width until the store
corrected it.

Unify collapse into one derivation in WorkspaceChrome and drive the collapsed
width from a server-rendered data-collapsed attribute via CSS
(.sidebar-shell-outer[data-collapsed]) — the same cookie source as the
structure, so width can never diverge from it. This is shadcn's documented
pattern (data-attribute selectors over JS ternaries for collapsed dimensions).

Also removes the redundant migratedCollapsed reconciliation (the store already
seeds from the migrated cookie and hasHydrated flips in the same pre-paint
effect) and the now-unused per-Sidebar derivation; Sidebar takes isCollapsed
as a prop.

* feat(rich-md-editor): let focused editors claim shortcuts from the global command registry

* refactor(rich-md-editor): freeze the formatting toolbar on scroll and extract the shared toolbar button

* feat(rich-md-editor): add a link hover card and claim Cmd+K for the link shortcut

* fix(rich-md-editor): portal the toolbar + link card to body so a transformed ancestor can't offset them; align fade with the tooltip

* fix(rich-md-editor): hide the code line-wrap toggle in read-only

* fix(sidebar): pass isCollapsed to Sidebar in the error fallback

The error UI renders Sidebar outside WorkspaceChrome, so it has no derived
collapse state; feed it the same source of truth via readCollapsedCookie() now
that isCollapsed is a required prop.

* address greptile comment

* docs(rich-md-editor): note why table cells escape only pipes (renderChildren pre-escapes backslashes)

* fix(rich-md-editor): lock the editor immediately when an agent edit starts, even if the body is unchanged

---------

Co-authored-by: waleed <walif6@gmail.com>
This commit is contained in:
Vikhyath Mondreti
2026-06-20 15:28:55 -07:00
committed by GitHub
co-authored by waleed
parent 55f432637f
commit 4bf791716e
24 changed files with 913 additions and 172 deletions
+11
View File
@@ -55,6 +55,17 @@
transition: width 200ms cubic-bezier(0.25, 0.1, 0.25, 1);
}
/**
* Collapsed width is driven by the server-rendered `data-collapsed` attribute —
* the same cookie source as the collapsed structure — so the rail can never paint
* at the expanded width and then snap narrow. Overrides `--sidebar-width` for the
* shell subtree (outer, inner, and the aside cascade from it). Must equal
* SIDEBAR_WIDTH.COLLAPSED in stores/constants.ts.
*/
.sidebar-shell-outer[data-collapsed] {
--sidebar-width: 51px;
}
.sidebar-container span,
.sidebar-container .text-small {
transition: opacity 120ms ease;
@@ -1,6 +1,6 @@
'use client'
import { useEffect } from 'react'
import { useEffect, useLayoutEffect } from 'react'
import { usePathname } from 'next/navigation'
import { cn } from '@/lib/core/utils/cn'
import { Sidebar } from '@/app/workspace/[workspaceId]/w/components/sidebar/sidebar'
@@ -43,15 +43,34 @@ function isFullscreenPath(pathname: string | null): boolean {
* On a direct load of a fullscreen route the wrapper mounts already collapsed,
* so no slide plays (CSS transitions don't run on mount).
*/
export function WorkspaceChrome({ children, initialSidebarCollapsed }: WorkspaceChromeProps) {
export function WorkspaceChrome({
children,
initialSidebarCollapsed = false,
}: WorkspaceChromeProps) {
const pathname = usePathname()
const isFullscreen = isFullscreenPath(pathname)
const setOrigin = useFullscreenOriginStore((s) => s.setOrigin)
const storeIsCollapsed = useSidebarStore((s) => s.isCollapsed)
const hasHydrated = useSidebarStore((s) => s._hasHydrated)
const syncSidebarWidth = useSidebarStore((s) => s.syncWidth)
/**
* Single source of collapse for the whole chrome, driving the rail's structure,
* labels, and width. The server renders from the `sidebar_collapsed` cookie
* (`initialSidebarCollapsed`) and the store seeds from the same cookie — after
* the pre-paint script migrates any legacy `localStorage` flag — so prop and
* store agree. The prop is used until the store hydrates (keeping the first
* client render identical to the server), then the store takes over.
*/
const isCollapsed = hasHydrated ? storeIsCollapsed : initialSidebarCollapsed
// Hydrate the persisted width before paint (collapse comes from the cookie/prop).
useLayoutEffect(() => {
void useSidebarStore.persist.rehydrate()
}, [])
// Remember the last non-fullscreen page so a fullscreen route's Back control
// can return there, deterministically and for any trigger.
useEffect(() => {
@@ -95,6 +114,7 @@ export function WorkspaceChrome({ children, initialSidebarCollapsed }: Workspace
SLIDE_TRANSITION,
isFullscreen ? 'w-0' : 'w-[var(--sidebar-width)]'
)}
data-collapsed={isCollapsed || undefined}
aria-hidden={isFullscreen || undefined}
suppressHydrationWarning
>
@@ -105,7 +125,7 @@ export function WorkspaceChrome({ children, initialSidebarCollapsed }: Workspace
isFullscreen && '-translate-x-full'
)}
>
<Sidebar initialCollapsed={initialSidebarCollapsed} />
<Sidebar isCollapsed={isCollapsed} />
</div>
</div>
<div
@@ -91,6 +91,7 @@ interface FileViewerProps {
onSaveStatusChange?: (status: 'idle' | 'saving' | 'saved' | 'error') => void
saveRef?: React.MutableRefObject<(() => Promise<void>) | null>
streamingContent?: string
isAgentEditing?: boolean
disableStreamingAutoScroll?: boolean
previewContextKey?: string
}
@@ -106,6 +107,7 @@ export function FileViewer({
onSaveStatusChange,
saveRef,
streamingContent,
isAgentEditing,
disableStreamingAutoScroll = false,
previewContextKey,
}: FileViewerProps) {
@@ -147,6 +149,7 @@ export function FileViewer({
onSaveStatusChange={onSaveStatusChange}
saveRef={saveRef}
streamingContent={streamingContent}
isAgentEditing={isAgentEditing}
disableStreamingAutoScroll={disableStreamingAutoScroll}
previewContextKey={previewContextKey}
/>
@@ -164,6 +167,7 @@ export function FileViewer({
onSaveStatusChange={onSaveStatusChange}
saveRef={saveRef}
streamingContent={streamingContent}
isAgentEditing={isAgentEditing}
disableStreamingAutoScroll={disableStreamingAutoScroll}
previewContextKey={previewContextKey}
/>
@@ -179,7 +179,7 @@ function CodeBlockView({ node, updateAttributes, editor, getPos }: ReactNodeView
{label}
</span>
))}
{!isMermaid && (
{!isMermaid && editor.isEditable && (
<button
type='button'
aria-label='Toggle line wrap'
@@ -42,6 +42,9 @@ const PipeSafeTable = Table.extend({
renderMarkdown: (node: JSONContent, h: MarkdownRendererHelpers) =>
renderTableToMarkdown(node, {
...h,
// `renderChildren` already markdown-escapes backslashes; here we only add the table-specific
// pipe escaping on top. (CodeQL flags the missing backslash escape, but escaping it again would
// double-escape and break round-trip idempotency — see the table round-trip tests.)
renderChildren: (nodes, separator) =>
h.renderChildren(nodes, separator).replace(/\|/g, '\\|'),
})
@@ -1,4 +1,6 @@
import { useEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { posToDOMRect } from '@tiptap/core'
import { PluginKey } from '@tiptap/pm/state'
import type { Editor } from '@tiptap/react'
import { useEditorState } from '@tiptap/react'
import { BubbleMenu } from '@tiptap/react/menus'
@@ -13,56 +15,35 @@ import {
List,
ListChecks,
ListOrdered,
type LucideIcon,
Strikethrough,
TextQuote,
Unlink,
} from 'lucide-react'
import { Tooltip } from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
import { normalizeLinkHref } from '../markdown-fidelity'
import { ToolbarButton, ToolbarDivider } from './toolbar-button'
interface ToolbarButtonProps {
icon: LucideIcon
label: string
shortcut?: string
isActive: boolean
onClick: () => void
/**
* Whether the formatting toolbar may show for the given range: the editor is editable, the range
* isn't inside a code block, and it covers some non-whitespace text. Single source of truth shared by
* `shouldShow` and the pointer-release reveal so the two can't drift apart.
*/
function hasFormattableSelection(editor: Editor, from: number, to: number): boolean {
if (!editor.isEditable || editor.isActive('codeBlock')) return false
return editor.state.doc.textBetween(from, to, ' ').trim().length > 0
}
function ToolbarButton({ icon: Icon, label, shortcut, isActive, onClick }: ToolbarButtonProps) {
return (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<button
type='button'
aria-label={label}
aria-pressed={isActive}
onMouseDown={(event) => event.preventDefault()}
onClick={onClick}
className={cn(
'flex size-[28px] items-center justify-center rounded-md text-[var(--text-icon)] outline-none transition-colors focus-visible:bg-[var(--surface-hover)] [&_svg]:size-[14px]',
isActive
? 'bg-[var(--surface-active)] text-[var(--text-body)]'
: 'hover-hover:bg-[var(--surface-hover)]'
)}
>
<Icon />
</button>
</Tooltip.Trigger>
<Tooltip.Content>
{shortcut ? <Tooltip.Shortcut keys={shortcut}>{label}</Tooltip.Shortcut> : label}
</Tooltip.Content>
</Tooltip.Root>
)
}
// Pin the toolbar to the viewport (fixed) and never attach a scroll listener, so once it's placed for
// a selection it stays put while the document scrolls instead of tracking the text — matching Linear.
const FLOATING_OPTIONS = { strategy: 'fixed' } as const
function ToolbarDivider() {
return <div className='mx-0.5 h-[18px] w-px bg-[var(--border-1)]' />
}
// Render into the body so a transformed/clipping ancestor (e.g. the mothership panels) can't reparent
// the fixed-positioned toolbar and shift it off the selection.
const APPEND_TO_BODY = () => document.body
interface EditorBubbleMenuProps {
editor: Editor
/** The editor's scrollable viewport, used to keep the toolbar on-screen for selections taller than it. */
scrollContainerRef: React.RefObject<HTMLDivElement | null>
}
/**
@@ -71,12 +52,16 @@ interface EditorBubbleMenuProps {
* live in the `/` slash menu. Active states are read through {@link useEditorState} so the bar
* stays correct without re-rendering the editor on every transaction.
*/
export function EditorBubbleMenu({ editor }: EditorBubbleMenuProps) {
export function EditorBubbleMenu({ editor, scrollContainerRef }: EditorBubbleMenuProps) {
const [linkValue, setLinkValue] = useState<string | null>(null)
const linkInputRef = useRef<HTMLInputElement>(null)
const linkRangeRef = useRef<{ from: number; to: number } | null>(null)
const isEditingLink = linkValue !== null
// Explicit key so `setMeta` can target this menu to reveal it after a drag-select.
const bubbleMenuKey = useMemo(() => new PluginKey('markdownBubbleMenu'), [])
const isPointerDownRef = useRef(false)
const active = useEditorState({
editor,
selector: ({ editor: e }) => ({
@@ -109,6 +94,38 @@ export function EditorBubbleMenu({ editor }: EditorBubbleMenuProps) {
}
}, [editor])
// Reveal the toolbar only once a drag-select finishes (Linear-style); `shouldShow` keeps it hidden
// while the pointer is down. Keyboard selection has no pointer, so it still shows live.
useEffect(() => {
const dom = editor.view.dom
const onPointerDown = () => {
isPointerDownRef.current = true
}
const onPointerUp = () => {
if (!isPointerDownRef.current || editor.isDestroyed) return
isPointerDownRef.current = false
const { from, to } = editor.state.selection
if (hasFormattableSelection(editor, from, to)) {
// `show` alone leaves the bar visible-but-unpositioned (its updatePosition no-ops until shown),
// so a second `updatePosition` anchors it. Both are step-free, so the doc isn't marked dirty.
editor.commands.setMeta(bubbleMenuKey, 'show')
editor.commands.setMeta(bubbleMenuKey, 'updatePosition')
}
}
// A release outside the window delivers no mouseup; clear the flag on blur so it can't stay wedged.
const onWindowBlur = () => {
isPointerDownRef.current = false
}
dom.addEventListener('mousedown', onPointerDown)
window.addEventListener('mouseup', onPointerUp)
window.addEventListener('blur', onWindowBlur)
return () => {
dom.removeEventListener('mousedown', onPointerDown)
window.removeEventListener('mouseup', onPointerUp)
window.removeEventListener('blur', onWindowBlur)
}
}, [editor, bubbleMenuKey])
const openLinkEditor = () => {
if (editor.isActive('codeBlock') || editor.isActive('code')) return
const { from, to } = editor.state.selection
@@ -158,9 +175,41 @@ export function EditorBubbleMenu({ editor }: EditorBubbleMenuProps) {
setLinkValue(null)
}
// Freeze the anchor per selection: the rect is computed once (in viewport coordinates) and reused on
// every scroll/resize reposition, so the toolbar stays where it first appeared instead of tracking
// the moving text — matching Linear. A new selection recomputes it. A selection taller than the
// viewport (e.g. select-all) is clamped into the visible area so the bar isn't placed off-screen.
const anchorCacheRef = useRef<{ key: string; rect: DOMRect } | null>(null)
const resolveAnchor = useCallback(() => {
const { view, state } = editor
if (!view.dom.isConnected) return null
const { from, to } = state.selection
const key = `${from}:${to}`
if (anchorCacheRef.current?.key !== key) {
const selection = posToDOMRect(view, from, to)
const viewport = scrollContainerRef.current?.getBoundingClientRect()
const rect =
viewport && selection.height > viewport.height
? new DOMRect(
selection.left,
Math.min(Math.max(selection.top, viewport.top), viewport.bottom),
selection.width,
0
)
: selection
anchorCacheRef.current = { key, rect }
}
const { rect } = anchorCacheRef.current
return { getBoundingClientRect: () => rect, getClientRects: () => [rect] }
}, [editor, scrollContainerRef])
return (
<BubbleMenu
editor={editor}
pluginKey={bubbleMenuKey}
getReferencedVirtualElement={resolveAnchor}
options={FLOATING_OPTIONS}
appendTo={APPEND_TO_BODY}
role='toolbar'
aria-label='Text formatting'
updateDelay={0}
@@ -169,10 +218,11 @@ export function EditorBubbleMenu({ editor }: EditorBubbleMenuProps) {
// can't be applied to a doc that must not mutate.
if (!e.isEditable) return false
if (isEditingLink) return true
if (e.isActive('codeBlock')) return false
return e.state.doc.textBetween(from, to, ' ').trim().length > 0
// Suppressed mid-drag; the pointer-release handler forces it back open once the selection sticks.
if (isPointerDownRef.current) return false
return hasFormattableSelection(e, from, to)
}}
className='fade-in-0 z-[var(--z-popover)] flex animate-in items-center gap-0.5 rounded-lg border border-[var(--border)] bg-[var(--bg)] p-1 shadow-sm duration-100 motion-reduce:animate-none'
className='fade-in-0 z-[var(--z-popover)] flex animate-in items-center gap-0.5 rounded-lg border border-[var(--border)] bg-[var(--bg)] p-1 shadow-sm duration-150 ease-out motion-reduce:animate-none'
>
{isEditingLink ? (
<>
@@ -0,0 +1,209 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { autoUpdate, computePosition, flip, offset, shift } from '@floating-ui/dom'
import { getMarkRange } from '@tiptap/core'
import type { Editor } from '@tiptap/react'
import { Check, Copy, Pencil, Unlink } from 'lucide-react'
import { createPortal } from 'react-dom'
import { normalizeLinkHref } from '../markdown-fidelity'
import { ToolbarButton } from './toolbar-button'
interface LinkHoverCardProps {
editor: Editor
}
interface LinkRange {
from: number
to: number
href: string
}
/** Resolves the document range and href of the link rendered by `el`, or null if it isn't a link. */
function resolveLinkRange(editor: Editor, el: HTMLElement): LinkRange | null {
const { state } = editor.view
const linkType = state.schema.marks.link
if (!linkType) return null
const pos = editor.view.posAtDOM(el, 0)
if (pos < 0) return null
const range =
getMarkRange(state.doc.resolve(pos), linkType) ??
getMarkRange(state.doc.resolve(pos + 1), linkType)
if (!range) return null
const href = el.getAttribute('href') ?? ''
return { from: range.from, to: range.to, href }
}
/**
* Floating card shown when hovering a link, so the destination is visible even when the link text
* differs from the URL. The URL opens in a new tab; Copy is always available, while Edit (inline) and
* Remove require an editable document. Positioned with Floating UI against the hovered anchor; a short
* close delay plus the card's own hover bridge let the pointer travel from the link into the card.
*/
export function LinkHoverCard({ editor }: LinkHoverCardProps) {
const [activeLink, setActiveLink] = useState<HTMLElement | null>(null)
const [draftHref, setDraftHref] = useState<string | null>(null)
const [position, setPosition] = useState<{ x: number; y: number } | null>(null)
const isEditing = draftHref !== null
const editInputRef = useRef<HTMLInputElement>(null)
const floatingRef = useRef<HTMLDivElement>(null)
const hideTimerRef = useRef<number | undefined>(undefined)
// Keep the card anchored to the hovered link with Floating UI's DOM core (the same primitive the
// bubble menu positions through) — no React wrapper, so the harness/app share one React instance.
useEffect(() => {
const floating = floatingRef.current
if (!activeLink || !floating) {
setPosition(null)
return
}
return autoUpdate(activeLink, floating, () => {
computePosition(activeLink, floating, {
strategy: 'fixed',
placement: 'top',
middleware: [offset(8), flip({ padding: 8 }), shift({ padding: 8 })],
}).then(({ x, y }) => setPosition({ x, y }))
})
}, [activeLink])
const cancelHide = useCallback(() => window.clearTimeout(hideTimerRef.current), [])
const dismiss = useCallback(() => {
cancelHide()
setActiveLink(null)
setDraftHref(null)
}, [cancelHide])
const scheduleHide = useCallback(() => {
cancelHide()
hideTimerRef.current = window.setTimeout(() => {
setActiveLink(null)
setDraftHref(null)
}, 120)
}, [cancelHide])
useEffect(() => {
const dom = editor.view.dom
const onOver = (event: Event) => {
// Don't compete with the selection toolbar while text is selected.
if (!editor.state.selection.empty) return
const link = (event.target as HTMLElement | null)?.closest('a')
if (link && dom.contains(link)) {
cancelHide()
setActiveLink(link)
}
}
const onOut = (event: MouseEvent) => {
const link = (event.target as HTMLElement | null)?.closest('a')
if (!link) return
// Ignore moves that stay within the same link.
if (link.contains(event.relatedTarget as Node | null)) return
scheduleHide()
}
dom.addEventListener('mouseover', onOver)
dom.addEventListener('mouseout', onOut)
return () => {
dom.removeEventListener('mouseover', onOver)
dom.removeEventListener('mouseout', onOut)
window.clearTimeout(hideTimerRef.current)
}
}, [editor, cancelHide, scheduleHide])
useEffect(() => {
if (isEditing) editInputRef.current?.focus()
}, [isEditing])
if (!activeLink) return null
const rawHref = activeLink.getAttribute('href') ?? ''
const safeHref = normalizeLinkHref(rawHref)
const canEdit = editor.isEditable
const startEdit = () => setDraftHref(rawHref)
const commitEdit = () => {
const range = resolveLinkRange(editor, activeLink)
if (range) {
const href = normalizeLinkHref((draftHref ?? '').trim())
const chain = editor.chain().focus().setTextSelection(range).extendMarkRange('link')
if (href) chain.setLink({ href })
else chain.unsetLink()
chain.run()
}
dismiss()
}
const removeLink = () => {
const range = resolveLinkRange(editor, activeLink)
if (range) {
editor.chain().focus().setTextSelection(range).extendMarkRange('link').unsetLink().run()
}
dismiss()
}
return createPortal(
<div
ref={floatingRef}
style={{
position: 'fixed',
top: 0,
left: 0,
transform: position ? `translate(${position.x}px, ${position.y}px)` : undefined,
opacity: position ? 1 : 0,
pointerEvents: position ? undefined : 'none',
}}
role='dialog'
aria-label='Link'
onMouseEnter={cancelHide}
onMouseLeave={scheduleHide}
className='z-[var(--z-popover)] flex items-center gap-0.5 rounded-lg border border-[var(--border)] bg-[var(--bg)] p-1 shadow-sm transition-opacity duration-150 ease-out'
>
{isEditing ? (
<>
<input
ref={editInputRef}
aria-label='Link URL'
type='text'
inputMode='url'
value={draftHref ?? ''}
onChange={(event) => setDraftHref(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault()
commitEdit()
} else if (event.key === 'Escape') {
event.preventDefault()
setDraftHref(null)
}
}}
placeholder='Paste or type a link…'
className='h-[28px] w-[220px] bg-transparent px-2 text-[var(--text-body)] text-small outline-none placeholder:text-[var(--text-subtle)]'
/>
<ToolbarButton icon={Check} label='Apply link' onClick={commitEdit} />
</>
) : (
<>
{safeHref ? (
<a
href={safeHref}
target='_blank'
rel='noopener noreferrer'
title={rawHref}
className='max-w-[260px] truncate px-2 text-[var(--text-body)] text-small hover:underline'
>
{rawHref}
</a>
) : (
<span className='max-w-[260px] truncate px-2 text-[var(--text-muted)] text-small'>
{rawHref}
</span>
)}
<ToolbarButton icon={Copy} label='Copy link' onClick={() => copyToClipboard(rawHref)} />
{canEdit && <ToolbarButton icon={Pencil} label='Edit link' onClick={startEdit} />}
{canEdit && <ToolbarButton icon={Unlink} label='Remove link' onClick={removeLink} />}
</>
)}
</div>,
document.body
)
}
function copyToClipboard(text: string) {
if (text) void navigator.clipboard?.writeText(text).catch(() => {})
}
@@ -0,0 +1,50 @@
import type { LucideIcon } from 'lucide-react'
import { Tooltip } from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
interface ToolbarButtonProps {
icon: LucideIcon
label: string
shortcut?: string
isActive?: boolean
onClick: () => void
}
/** A single icon button for the editor's floating toolbars (bubble menu, link hover card). */
export function ToolbarButton({
icon: Icon,
label,
shortcut,
isActive = false,
onClick,
}: ToolbarButtonProps) {
return (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<button
type='button'
aria-label={label}
aria-pressed={isActive}
onMouseDown={(event) => event.preventDefault()}
onClick={onClick}
className={cn(
'flex size-[28px] items-center justify-center rounded-md text-[var(--text-icon)] outline-none transition-colors focus-visible:bg-[var(--surface-hover)] [&_svg]:size-[14px]',
isActive
? 'bg-[var(--surface-active)] text-[var(--text-body)]'
: 'hover-hover:bg-[var(--surface-hover)]'
)}
>
<Icon />
</button>
</Tooltip.Trigger>
<Tooltip.Content>
{shortcut ? <Tooltip.Shortcut keys={shortcut}>{label}</Tooltip.Shortcut> : label}
</Tooltip.Content>
</Tooltip.Root>
)
}
/** Thin vertical separator between groups of {@link ToolbarButton}s. */
export function ToolbarDivider() {
return <div className='mx-0.5 h-[18px] w-px bg-[var(--border-1)]' />
}
@@ -153,8 +153,11 @@
gap: 0.5em;
}
/* One line tall with the box centered, so it aligns with the item's first line. */
.rich-markdown-prose ul[data-type="taskList"] li > label {
margin-top: 0.28em;
display: flex;
align-items: center;
height: 1.6667em; /* = the prose 25px line-height at 15px font */
flex-shrink: 0;
user-select: none;
}
@@ -164,11 +167,39 @@
min-width: 0;
}
/* TaskItem nests content as li > div > p, which the `li > p` reset misses, leaving UA margins. */
.rich-markdown-prose ul[data-type="taskList"] li > div > p {
margin: 0;
}
/* Match the design-system Checkbox (emcn) rather than the platform-native control. */
.rich-markdown-prose ul[data-type="taskList"] input[type="checkbox"] {
accent-color: var(--text-primary);
appearance: none;
-webkit-appearance: none;
display: inline-grid;
place-content: center;
width: 16px;
height: 16px;
margin: 0;
border: 1px solid var(--border-1);
border-radius: 3px;
background: transparent;
cursor: pointer;
}
.rich-markdown-prose ul[data-type="taskList"] input[type="checkbox"]:checked {
background-color: var(--text-primary);
border-color: var(--text-primary);
}
.rich-markdown-prose ul[data-type="taskList"] input[type="checkbox"]:checked::after {
content: "";
width: 10px;
height: 10px;
background-color: var(--surface-2);
clip-path: polygon(14% 44%, 0 65%, 50% 100%, 100% 16%, 80% 0%, 43% 62%);
}
.rich-markdown-prose blockquote {
border-left: 2px solid var(--divider);
padding-left: 1rem;
@@ -22,6 +22,7 @@ import {
} from './markdown-fidelity'
import { parseMarkdownToDoc } from './markdown-parse'
import { EditorBubbleMenu } from './menus/bubble-menu'
import { LinkHoverCard } from './menus/link-hover-card'
import { isRoundTripSafe } from './round-trip-safety'
import '@/components/emcn/components/code/code.css'
import './rich-markdown-editor.css'
@@ -30,6 +31,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
@@ -39,22 +44,12 @@ interface RichMarkdownEditorProps {
onSaveStatusChange?: (status: SaveStatus) => void
saveRef?: React.MutableRefObject<(() => Promise<void>) | null>
streamingContent?: string
isAgentEditing?: boolean
disableStreamingAutoScroll?: boolean
previewContextKey?: string
}
/**
* 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,
@@ -64,6 +59,7 @@ export const RichMarkdownEditor = memo(function RichMarkdownEditor({
onSaveStatusChange,
saveRef,
streamingContent,
isAgentEditing,
disableStreamingAutoScroll = false,
previewContextKey,
}: RichMarkdownEditorProps) {
@@ -79,6 +75,7 @@ export const RichMarkdownEditor = memo(function RichMarkdownEditor({
workspaceId,
canEdit,
streamingContent,
isAgentEditing,
onDirtyChange,
onSaveStatusChange,
saveRef,
@@ -130,25 +127,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,
@@ -163,22 +147,17 @@ export function LoadedRichMarkdownEditor({
// 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)
@@ -191,12 +170,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()
)
@@ -232,7 +206,8 @@ export function LoadedRichMarkdownEditor({
shouldRerenderOnTransaction: false,
content: initialContent,
editorProps: {
attributes: { class: 'rich-markdown-prose' },
// Claim Mod+K so the global command registry yields it to the editor's link shortcut.
attributes: { class: 'rich-markdown-prose', 'data-owned-shortcuts': 'Mod+K' },
handleKeyDown: (_view, event) => {
const isSaveShortcut = (event.metaKey || event.ctrlKey) && event.key?.toLowerCase() === 's'
if (!isSaveShortcut) return false
@@ -243,11 +218,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
@@ -259,8 +232,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('/') &&
@@ -292,35 +264,44 @@ 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)
useEffect(() => {
if (!editor) return
if (isStreaming) {
wasStreamingRef.current = true
if (editor.isEditable) editor.setEditable(false)
const body = splitFrontmatter(content).body
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)
@@ -329,7 +310,8 @@ 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.
@@ -337,16 +319,12 @@ export function LoadedRichMarkdownEditor({
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
@@ -374,7 +352,8 @@ export function LoadedRichMarkdownEditor({
ref={containerRef}
className={cn('flex flex-1 flex-col overflow-y-auto', isEditable && 'cursor-text')}
>
{editor && <EditorBubbleMenu editor={editor} />}
{editor && <EditorBubbleMenu editor={editor} scrollContainerRef={containerRef} />}
{editor && <LinkHoverCard 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'
@@ -10,19 +10,24 @@ import {
} from './text-editor-state'
function ready(content: string, savedContent = content): TextEditorContentState {
return { phase: 'ready', content, savedContent, lastStreamedContent: null }
return { phase: 'ready', content, savedContent, lastStreamedContent: null, hasBaseline: true }
}
function streaming(
content: string,
lastStreamedContent: string,
savedContent = ''
savedContent = '',
hasBaseline = true
): TextEditorContentState {
return { phase: 'streaming', content, savedContent, lastStreamedContent }
return { phase: 'streaming', content, savedContent, lastStreamedContent, hasBaseline }
}
function reconciling(content: string, savedContent = ''): TextEditorContentState {
return { phase: 'reconciling', content, savedContent, lastStreamedContent: null }
function reconciling(
content: string,
savedContent = '',
hasBaseline = true
): TextEditorContentState {
return { phase: 'reconciling', content, savedContent, lastStreamedContent: null, hasBaseline }
}
describe("reducer 'edit' action", () => {
@@ -144,6 +149,7 @@ describe('syncTextEditorContentState — static fetch updates', () => {
content: 'user edit',
savedContent: 'v1',
lastStreamedContent: null,
hasBaseline: true,
}
const next = syncTextEditorContentState(state, {
canReconcileToFetchedContent: true,
@@ -241,6 +247,7 @@ describe('syncTextEditorContentState — reconciling', () => {
content: 'streamed',
savedContent: 'v1',
lastStreamedContent: null,
hasBaseline: true,
}
const next = syncTextEditorContentState(state, {
canReconcileToFetchedContent: true,
@@ -431,3 +438,69 @@ describe('syncTextEditorContentState — mothership streamed-file lifecycle (rep
expect(state.content).toBe(state.savedContent)
})
})
/**
* When the user opens an existing, non-empty file's tab while the agent is already mid-stream on it,
* streaming begins from `uninitialized` before the content fetch resolves — so `savedContent` is the
* placeholder `''`. The first fetched value to arrive is the file's PRE-EDIT content, not the agent's
* write; it must be adopted as the baseline, never finalized to (which would flash stale content and,
* if the agent had stopped, let the user edit over the agent's write).
*/
describe('syncTextEditorContentState — stream begins before fetch on an existing file', () => {
it('adopts the first fetched content as the baseline instead of finalizing to it mid-stream', () => {
const preEdit = '# Original\n\nold content'
const agentWrite = '# Original\n\nold content, plus a new section.'
// 1. Editor mounts mid-stream: chunk arrives before the fetch resolves.
let state = syncTextEditorContentState(INITIAL_TEXT_EDITOR_CONTENT_STATE, {
canReconcileToFetchedContent: true,
fetchedContent: undefined,
streamingContent: '# Original\n\nold',
})
expect(state.phase).toBe('streaming')
expect(state.savedContent).toBe('')
expect(state.hasBaseline).toBe(false)
// 2. The fetch resolves to the file's pre-edit content WHILE streaming. Adopt it as the baseline;
// do NOT finalize (the agent hasn't persisted its write yet).
state = syncTextEditorContentState(state, {
canReconcileToFetchedContent: true,
fetchedContent: preEdit,
streamingContent: '# Original\n\nold content, plus',
})
expect(state.phase).toBe('streaming')
expect(state.content).toBe('# Original\n\nold content, plus')
expect(state.savedContent).toBe(preEdit)
expect(state.hasBaseline).toBe(true)
// 3. Stream ends; the refetch is still the pre-edit content → hold in reconciling, never finalize
// to stale (savedContent === fetched, so it has not "advanced").
state = syncTextEditorContentState(state, {
canReconcileToFetchedContent: true,
fetchedContent: preEdit,
streamingContent: undefined,
})
expect(state.phase).toBe('reconciling')
// 4. The agent's write lands (advanced past the adopted baseline) → finalize to it.
state = syncTextEditorContentState(state, {
canReconcileToFetchedContent: true,
fetchedContent: agentWrite,
streamingContent: undefined,
})
expect(state.phase).toBe('ready')
expect(state.content).toBe(agentWrite)
expect(state.savedContent).toBe(agentWrite)
})
it('still finalizes mid-stream once a real baseline is established (no regression)', () => {
// With hasBaseline=true, an advancing fetch finalizes immediately — the established-baseline path.
const next = syncTextEditorContentState(streaming('v1 chunk', 'v1 chunk', 'v1'), {
canReconcileToFetchedContent: true,
fetchedContent: 'v2',
streamingContent: 'chunk',
})
expect(next.phase).toBe('ready')
expect(next.content).toBe('v2')
})
})
@@ -5,6 +5,13 @@ export interface TextEditorContentState {
content: string
savedContent: string
lastStreamedContent: string | null
/**
* Whether `savedContent` is the file's real baseline (not the initial placeholder). False only
* before the first fetched content has been observed — e.g. a stream that began before the initial
* fetch resolved. While false, a fetched value is treated as the baseline to adopt, not as the
* agent's write advancing past the baseline (which would finalize the editor to stale content).
*/
hasBaseline: boolean
}
export interface SyncTextEditorContentStateOptions {
@@ -23,6 +30,7 @@ export const INITIAL_TEXT_EDITOR_CONTENT_STATE: TextEditorContentState = {
content: '',
savedContent: '',
lastStreamedContent: null,
hasBaseline: false,
}
function finalizeTextEditorContentState(
@@ -33,7 +41,8 @@ function finalizeTextEditorContentState(
state.phase === 'ready' &&
state.content === nextContent &&
state.savedContent === nextContent &&
state.lastStreamedContent === null
state.lastStreamedContent === null &&
state.hasBaseline
) {
return state
}
@@ -43,17 +52,30 @@ function finalizeTextEditorContentState(
content: nextContent,
savedContent: nextContent,
lastStreamedContent: null,
hasBaseline: true,
}
}
function moveTextEditorContentStateToStreaming(
state: TextEditorContentState,
nextContent: string
nextContent: string,
fetchedBaseline?: string
): TextEditorContentState {
// A stream that begins before the initial fetch resolves leaves `savedContent` at its placeholder.
// The first fetched value to arrive during the stream IS the file's pre-edit baseline (the agent
// hasn't persisted its write yet), so adopt it. Without this, a later refetch of that same pre-edit
// content would read as an "advance" past the placeholder and finalize the editor to stale content
// mid-stream. Empty-file creates are unaffected: their baseline genuinely is ''.
const adoptBaseline = !state.hasBaseline && fetchedBaseline !== undefined
const savedContent = adoptBaseline ? fetchedBaseline : state.savedContent
const hasBaseline = state.hasBaseline || adoptBaseline
if (
state.phase === 'streaming' &&
state.content === nextContent &&
state.lastStreamedContent === nextContent
state.lastStreamedContent === nextContent &&
state.savedContent === savedContent &&
state.hasBaseline === hasBaseline
) {
return state
}
@@ -63,6 +85,8 @@ function moveTextEditorContentStateToStreaming(
phase: 'streaming',
content: nextContent,
lastStreamedContent: nextContent,
savedContent,
hasBaseline,
}
}
@@ -92,7 +116,12 @@ export function syncTextEditorContentState(
fetchedContent !== undefined &&
state.lastStreamedContent !== null &&
fetchedContent === state.lastStreamedContent
const hasFetchedAdvanced = fetchedContent !== undefined && fetchedContent !== state.savedContent
// Only an ESTABLISHED baseline makes "fetched differs from savedContent" mean "the agent's write
// advanced". Before the baseline is established (stream started before the fetch resolved),
// savedContent is a placeholder, so the file's own pre-edit content would falsely read as an
// advance and finalize to stale content; instead it is adopted as the baseline in moveToStreaming.
const hasFetchedAdvanced =
fetchedContent !== undefined && state.hasBaseline && fetchedContent !== state.savedContent
if (
(state.phase === 'streaming' || state.phase === 'reconciling') &&
@@ -110,7 +139,7 @@ export function syncTextEditorContentState(
return finalizeTextEditorContentState(state, fetchedContent)
}
return moveTextEditorContentStateToStreaming(state, nextContent)
return moveTextEditorContentStateToStreaming(state, nextContent, fetchedContent)
}
if (state.phase === 'streaming' || state.phase === 'reconciling') {
@@ -182,6 +211,7 @@ export function textEditorContentReducer(
phase: 'ready',
savedContent: action.content,
lastStreamedContent: null,
hasBaseline: true,
}
default:
return state
@@ -331,6 +331,7 @@ interface TextEditorProps {
onSaveStatusChange?: (status: 'idle' | 'saving' | 'saved' | 'error') => void
saveRef?: React.MutableRefObject<(() => Promise<void>) | null>
streamingContent?: string
isAgentEditing?: boolean
disableStreamingAutoScroll: boolean
previewContextKey?: string
}
@@ -345,6 +346,7 @@ export const TextEditor = memo(function TextEditor({
onSaveStatusChange,
saveRef,
streamingContent,
isAgentEditing,
disableStreamingAutoScroll,
previewContextKey,
}: TextEditorProps) {
@@ -379,6 +381,7 @@ export const TextEditor = memo(function TextEditor({
workspaceId,
canEdit,
streamingContent,
isAgentEditing,
onDirtyChange,
onSaveStatusChange,
saveRef,
@@ -7,6 +7,7 @@ import {
useWorkspaceFileContent,
} from '@/hooks/queries/workspace-files'
import { type SaveStatus, useAutosave } from '@/hooks/use-autosave'
import { useSmoothText } from '@/hooks/use-smooth-text'
import {
INITIAL_TEXT_EDITOR_CONTENT_STATE,
type SyncTextEditorContentStateOptions,
@@ -31,6 +32,7 @@ interface UseEditableFileContentOptions {
workspaceId: string
canEdit: boolean
streamingContent?: string
isAgentEditing?: boolean
onDirtyChange?: (isDirty: boolean) => void
onSaveStatusChange?: (status: SaveStatus) => void
saveRef?: React.MutableRefObject<(() => Promise<void>) | null>
@@ -102,6 +104,7 @@ export function useEditableFileContent({
workspaceId,
canEdit,
streamingContent,
isAgentEditing,
onDirtyChange,
onSaveStatusChange,
saveRef,
@@ -130,7 +133,7 @@ export function useEditableFileContent({
content,
savedContent,
isInitialized,
isStreamInteractionLocked,
isStreamInteractionLocked: isStreamPhaseLocked,
setDraftContent,
markSavedContent,
} = useFileContentState({
@@ -139,6 +142,17 @@ export function useEditableFileContent({
streamingContent,
})
const isStreamInteractionLocked = isStreamPhaseLocked || Boolean(isAgentEditing)
// Pace the streamed reveal for DISPLAY only. The reducer above keeps the true content so
// reconciliation, dirty tracking, and saves are never thrown off by the paced prefix. Pacing is
// gated on the stream phase (not the agent-edit lock) and fed '' off-stream, so a user's own typing
// is never throttled; snapOnNonAppend shows in-place rewrites/patches in full, not re-revealed.
const pacedReveal = useSmoothText(isStreamPhaseLocked ? content : '', isStreamPhaseLocked, {
snapOnNonAppend: true,
})
const displayContent = isStreamPhaseLocked ? pacedReveal : content
const contentRef = useRef(content)
contentRef.current = content
@@ -174,11 +188,16 @@ export function useEditableFileContent({
}, [saveImmediately, saveRef])
return {
content,
content: displayContent,
setDraftContent,
isInitialized,
isStreamInteractionLocked,
isContentLoading: streamingContent === undefined && isLoading,
// `!isInitialized` mirrors `hasContentError`: once any content (fetched OR streamed) has
// initialized the editor, never fall back to the loading frame. A stream that finishes before the
// initial file fetch resolves flips `streamingContent` to undefined while `isLoading` is still
// true — without this guard that would unmount the settled editor (losing the read-only→editable
// hand-off, scroll, and parsed doc) until the fetch lands.
isContentLoading: streamingContent === undefined && isLoading && !isInitialized,
hasContentError: streamingContent === undefined && Boolean(error) && !isInitialized,
saveStatus,
saveImmediately,
@@ -13,7 +13,7 @@ import {
TagInput,
type TagItem,
} from '@/components/emcn'
import { Link } from '@/components/emcn/icons'
import { Send } from '@/components/emcn/icons'
import { GeneratedPasswordInput } from '@/components/ui'
import type { ShareAuthType, ShareRecord } from '@/lib/api/contracts/public-shares'
import { getEnv, isTruthy } from '@/lib/core/config/env'
@@ -200,7 +200,7 @@ export function ShareModal({
return (
<ChipModal open={open} onOpenChange={handleClose} size='sm' srTitle={`Share ${fileName}`}>
<ChipModalHeader icon={Link} onClose={handleClose}>
<ChipModalHeader icon={Send} onClose={handleClose}>
Share file
</ChipModalHeader>
<ChipModalBody>
@@ -1,6 +1,6 @@
'use client'
import { lazy, memo, Suspense, useEffect, useMemo, useRef } from 'react'
import { lazy, memo, Suspense, useEffect, useMemo, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { format } from 'date-fns'
import { useRouter } from 'next/navigation'
@@ -76,6 +76,7 @@ interface ResourceContentProps {
resource: MothershipResource
previewMode?: PreviewMode
previewSession?: FilePreviewSession | null
isAgentResponding?: boolean
genericResourceData?: GenericResourceData
previewContextKey?: string
onNotFound?: (resourceId: string) => void
@@ -88,11 +89,56 @@ interface ResourceContentProps {
*/
const STREAMING_EPOCH = new Date(0)
/**
* Grace window kept locked after the agent stops streaming into the file, so the lock bridges the
* gaps between the file subagent's sequential edit sections instead of flickering open between them.
*/
const AGENT_EDIT_LOCK_GRACE_MS = 1500
/**
* Holds the editor read-only while the agent is actively writing to the file, plus a short grace so
* brief gaps between edit sections don't unlock it. Releases as soon as the turn ends
* (`isAgentResponding` false) so the file becomes editable the moment the agent is done, even when
* the surrounding turn keeps running — the completed preview session otherwise lingers all turn.
*/
function useAgentFileEditLock(isStreamingToFile: boolean, isAgentResponding: boolean): boolean {
const [locked, setLocked] = useState(isStreamingToFile)
const graceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(() => {
if (graceTimerRef.current !== null) {
clearTimeout(graceTimerRef.current)
graceTimerRef.current = null
}
if (isStreamingToFile) {
setLocked(true)
return
}
if (!isAgentResponding) {
setLocked(false)
return
}
graceTimerRef.current = setTimeout(() => {
graceTimerRef.current = null
setLocked(false)
}, AGENT_EDIT_LOCK_GRACE_MS)
return () => {
if (graceTimerRef.current !== null) {
clearTimeout(graceTimerRef.current)
graceTimerRef.current = null
}
}
}, [isStreamingToFile, isAgentResponding])
return locked
}
export const ResourceContent = memo(function ResourceContent({
workspaceId,
resource,
previewMode,
previewSession,
isAgentResponding,
genericResourceData,
previewContextKey,
onNotFound,
@@ -134,6 +180,11 @@ export const ResourceContent = memo(function ResourceContent({
? previewSession.previewText
: undefined
const isAgentEditing = useAgentFileEditLock(
previewSession?.status === 'streaming',
Boolean(isAgentResponding)
)
if (resource.id === 'streaming-file') {
return (
<div className='flex h-full flex-col overflow-hidden'>
@@ -143,6 +194,7 @@ export const ResourceContent = memo(function ResourceContent({
canEdit={false}
previewMode={previewMode ?? 'preview'}
streamingContent={textStreamingContent}
isAgentEditing={isAgentEditing}
disableStreamingAutoScroll={disableStreamingAutoScroll}
previewContextKey={previewContextKey}
/>
@@ -165,6 +217,7 @@ export const ResourceContent = memo(function ResourceContent({
streamingContent={
previewSession?.fileId === resource.id ? textStreamingContent : undefined
}
isAgentEditing={isAgentEditing}
disableStreamingAutoScroll={disableStreamingAutoScroll}
previewContextKey={previewContextKey}
/>
@@ -550,6 +603,7 @@ interface EmbeddedFileProps {
filePath?: string
previewMode?: PreviewMode
streamingContent?: string
isAgentEditing?: boolean
disableStreamingAutoScroll?: boolean
previewContextKey?: string
}
@@ -560,6 +614,7 @@ function EmbeddedFile({
filePath,
previewMode,
streamingContent,
isAgentEditing,
disableStreamingAutoScroll = false,
previewContextKey,
}: EmbeddedFileProps) {
@@ -601,6 +656,7 @@ function EmbeddedFile({
canEdit={canEdit}
previewMode={previewMode}
streamingContent={streamingContent}
isAgentEditing={isAgentEditing}
disableStreamingAutoScroll={disableStreamingAutoScroll}
previewContextKey={previewContextKey}
/>
@@ -52,6 +52,7 @@ interface MothershipViewProps {
isCollapsed: boolean
className?: string
previewSession?: FilePreviewSession | null
isAgentResponding?: boolean
genericResourceData?: GenericResourceData
}
@@ -65,6 +66,7 @@ export const MothershipView = memo(
isCollapsed,
className,
previewSession,
isAgentResponding,
genericResourceData,
}: MothershipViewProps,
ref
@@ -136,6 +138,7 @@ export const MothershipView = memo(
resource={active}
previewMode={isActivePreviewable ? previewMode : undefined}
previewSession={previewForActive}
isAgentResponding={isAgentResponding}
genericResourceData={active.type === 'generic' ? genericResourceData : undefined}
previewContextKey={chatId}
onNotFound={(resourceId) => removeResource('log', resourceId)}
@@ -461,6 +461,7 @@ export function Home({ chatId, userName, userId, initialResourceId = null }: Hom
activeResourceId={activeResourceId}
isCollapsed={isResourceCollapsed}
previewSession={previewSession}
isAgentResponding={isSending}
genericResourceData={genericResourceData ?? undefined}
className={skipResourceTransition ? '!transition-none' : undefined}
/>
@@ -0,0 +1,89 @@
/**
* @vitest-environment jsdom
*/
import { act, type ReactNode } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { mockIsMac } = vi.hoisted(() => ({ mockIsMac: vi.fn(() => false) }))
vi.mock('@/lib/core/utils/platform', () => ({ isMacPlatform: mockIsMac }))
vi.mock('next/navigation', () => ({ useRouter: () => ({ push: vi.fn() }) }))
import {
GlobalCommandsProvider,
useRegisterGlobalCommands,
} from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
function RegisterModK({ handler }: { handler: () => void }) {
useRegisterGlobalCommands([{ id: 'search', shortcut: 'Mod+K', handler }])
return null
}
let container: HTMLDivElement
let root: Root
function mount(ui: ReactNode) {
act(() => {
root.render(ui)
})
}
/** Non-mac (mocked): `Mod` resolves to Ctrl, so Ctrl+K matches a `Mod+K` shortcut. */
function pressModK() {
window.dispatchEvent(
new KeyboardEvent('keydown', { key: 'k', ctrlKey: true, bubbles: true, cancelable: true })
)
}
beforeEach(() => {
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
})
afterEach(() => {
act(() => root.unmount())
container.remove()
vi.clearAllMocks()
})
describe('GlobalCommandsProvider owned-shortcut yielding', () => {
it('fires a global command when nothing owns the shortcut', () => {
const handler = vi.fn()
mount(
<GlobalCommandsProvider>
<RegisterModK handler={handler} />
</GlobalCommandsProvider>
)
pressModK()
expect(handler).toHaveBeenCalledTimes(1)
})
it('yields the shortcut to a focused element that declares it owns it', () => {
const handler = vi.fn()
mount(
<GlobalCommandsProvider>
<RegisterModK handler={handler} />
{/* biome-ignore lint/a11y/noNoninteractiveTabindex: focusable stand-in for the editor */}
<div data-owned-shortcuts='Mod+K' tabIndex={0} />
</GlobalCommandsProvider>
)
;(container.querySelector('[data-owned-shortcuts]') as HTMLElement).focus()
pressModK()
expect(handler).not.toHaveBeenCalled()
})
it('still fires when the focused element owns only a different shortcut', () => {
const handler = vi.fn()
mount(
<GlobalCommandsProvider>
<RegisterModK handler={handler} />
{/* biome-ignore lint/a11y/noNoninteractiveTabindex: focusable stand-in for the editor */}
<div data-owned-shortcuts='Mod+B' tabIndex={0} />
</GlobalCommandsProvider>
)
;(container.querySelector('[data-owned-shortcuts]') as HTMLElement).focus()
pressModK()
expect(handler).toHaveBeenCalledTimes(1)
})
})
@@ -86,6 +86,30 @@ function matchesShortcut(e: KeyboardEvent, parsed: ParsedShortcut): boolean {
)
}
/** Platform-resolved signature of a shortcut, so `Mod+K`, `Cmd+K`, and `Meta+K` compare equal on mac. */
function shortcutSignature(parsed: ParsedShortcut, isMac: boolean): string {
const ctrl = parsed.ctrl || (parsed.mod ? !isMac : false)
const meta = parsed.meta || (parsed.mod ? isMac : false)
return `${parsed.key}|${+ctrl}|${+meta}|${+!!parsed.shift}|${+!!parsed.alt}`
}
/**
* Whether the focused element (or an ancestor) declares it owns `parsed` via a comma-separated
* `data-owned-shortcuts` attribute (e.g. a rich-text editor that binds `Mod+K` to links). Such a
* shortcut is left for that element to handle instead of firing the global command.
*/
function focusedElementOwnsShortcut(parsed: ParsedShortcut, isMac: boolean): boolean {
const active = document.activeElement
const owner = active instanceof HTMLElement ? active.closest('[data-owned-shortcuts]') : null
if (!owner) return false
const target = shortcutSignature(parsed, isMac)
return (owner.getAttribute('data-owned-shortcuts') ?? '')
.split(',')
.map((entry) => entry.trim())
.filter(Boolean)
.some((entry) => shortcutSignature(parseShortcut(entry), isMac) === target)
}
export function GlobalCommandsProvider({ children }: { children: ReactNode }) {
const registryRef = useRef<Map<string, RegistryCommand>>(new Map())
const isMac = useMemo(() => isMacPlatform(), [])
@@ -127,6 +151,8 @@ export function GlobalCommandsProvider({ children }: { children: ReactNode }) {
}
if (matchesShortcut(e, cmd.parsed)) {
// A focused rich editor that owns this shortcut (e.g. Mod+K for links) handles it itself.
if (focusedElementOwnsShortcut(cmd.parsed, isMac)) continue
e.preventDefault()
e.stopPropagation()
try {
@@ -8,6 +8,7 @@ import { Button } from '@/components/emcn'
import { Panel } from '@/app/workspace/[workspaceId]/w/[workflowId]/components'
import { usePreventZoom } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
import { Sidebar } from '@/app/workspace/[workspaceId]/w/components/sidebar/sidebar'
import { readCollapsedCookie } from '@/stores/sidebar/store'
const logger = createLogger('ErrorBoundary')
@@ -48,7 +49,7 @@ export function ErrorUI({
return (
<div ref={preventZoomRef} className='flex h-screen w-full flex-col bg-[var(--surface-1)]'>
<Sidebar />
<Sidebar isCollapsed={readCollapsedCookie()} />
<div className='relative flex flex-1'>
<div className='pointer-events-none absolute inset-0 flex items-center justify-center'>
@@ -109,7 +109,7 @@ import { SIDEBAR_WIDTH } from '@/stores/constants'
import { useFolderStore } from '@/stores/folders/store'
import { useSearchModalStore } from '@/stores/modals/search/store'
import { useProvidersStore } from '@/stores/providers'
import { readCollapsedCookie, useSidebarStore } from '@/stores/sidebar/store'
import { useSidebarStore } from '@/stores/sidebar/store'
const logger = createLogger('Sidebar')
@@ -349,16 +349,14 @@ const HIDDEN_STYLE = { display: 'none' } as const
*/
interface SidebarProps {
/**
* Collapse state read from the `sidebar_collapsed` cookie in the server
* layout. Seeds the first (pre-hydration) render so the server emits the
* correct collapsed/expanded structure — without it the server can't read
* `localStorage` and always renders the expanded tree, which then paints
* skeletons and pinned-chat icons inside the 51px rail until React flips it.
* Authoritative collapse state, derived once in {@link WorkspaceChrome} from the
* `sidebar_collapsed` cookie (server prop → store after hydration) and passed in
* so the rail's structure, labels, and width all read a single source.
*/
initialCollapsed?: boolean
isCollapsed: boolean
}
export const Sidebar = memo(function Sidebar({ initialCollapsed = false }: SidebarProps) {
export const Sidebar = memo(function Sidebar({ isCollapsed }: SidebarProps) {
const params = useParams()
const workspaceId = params.workspaceId as string
const workflowId = params.workflowId as string | undefined
@@ -389,37 +387,9 @@ export const Sidebar = memo(function Sidebar({ initialCollapsed = false }: Sideb
}, [initializeSearchData, filterBlocks, providerModelSignature])
const setSidebarWidth = useSidebarStore((state) => state.setSidebarWidth)
const storeIsCollapsed = useSidebarStore((state) => state.isCollapsed)
const hasHydrated = useSidebarStore((state) => state._hasHydrated)
const toggleCollapsed = useSidebarStore((state) => state.toggleCollapsed)
const isOnWorkflowPage = !!workflowId
/**
* The server renders from the `sidebar_collapsed` cookie (via `initialCollapsed`)
* and the client store seeds from the same cookie, so both agree on the first
* paint. The prop is read until the store reports hydration, after which the
* store takes over.
*
* A legacy user whose collapse lived only in `localStorage` has no cookie at SSR
* (so `initialCollapsed` is false), but the pre-paint script migrates them to a
* cookie. Reconcile to that cookie synchronously before paint — the first render
* still matches the server, so there's no hydration mismatch and no narrow-rail flash.
*/
const [migratedCollapsed, setMigratedCollapsed] = useState<boolean | null>(null)
useLayoutEffect(() => {
const cookieCollapsed = readCollapsedCookie()
if (cookieCollapsed !== initialCollapsed) setMigratedCollapsed(cookieCollapsed)
}, [initialCollapsed])
const isCollapsed = hasHydrated ? storeIsCollapsed : (migratedCollapsed ?? initialCollapsed)
/**
* Hydrates the persisted width before paint (collapse already came from the
* cookie) so any width-dependent layout settles in the same commit.
*/
useLayoutEffect(() => {
void useSidebarStore.persist.rehydrate()
}, [])
const isCollapsedRef = useRef(isCollapsed)
useLayoutEffect(() => {
isCollapsedRef.current = isCollapsed
+91
View File
@@ -0,0 +1,91 @@
/**
* @vitest-environment jsdom
*/
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useSmoothText } from '@/hooks/use-smooth-text'
interface ProbeProps {
content: string
isStreaming: boolean
snapOnNonAppend?: boolean
}
/**
* Minimal dependency-free hook harness (the repo has no `@testing-library/react`). Mounts the hook in
* a real React root under jsdom so effects and refs run exactly as in the app. Fake timers keep the
* paced reveal from advancing, so each assertion observes the synchronous reveal decision only.
*/
function renderSmoothText(initial: ProbeProps) {
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
const container = document.createElement('div')
const root: Root = createRoot(container)
const props = { ...initial }
let latest = ''
function Probe(p: ProbeProps) {
latest = useSmoothText(p.content, p.isStreaming, { snapOnNonAppend: p.snapOnNonAppend })
return null
}
const render = () =>
act(() => {
root.render(<Probe {...props} />)
})
render()
return {
value: () => latest,
rerender: (next: Partial<ProbeProps>) => {
Object.assign(props, next)
render()
},
unmount: () => act(() => root.unmount()),
}
}
const LONG = `# Existing Document\n\n${'Lorem ipsum dolor sit amet, '.repeat(8)}`
describe('useSmoothText — streaming that begins on an already-open document', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.clearAllTimers()
vi.useRealTimers()
})
it('reveals a pre-existing document in full when an edit stream starts (no full-file replay)', () => {
// The editor mounts showing a static file (no stream yet).
const h = renderSmoothText({ content: '', isStreaming: false, snapOnNonAppend: true })
expect(h.value()).toBe('')
// The agent begins editing it: the first streamed value carries the whole existing document.
// It must appear instantly, not replay word-by-word from the first character.
h.rerender({ content: LONG, isStreaming: true })
expect(h.value()).toBe(LONG)
h.unmount()
})
it('still animates a brand-new file from the start (short content stays below the threshold)', () => {
// A create stream mounts already-streaming with a tiny first chunk → begins empty and paces in.
const h = renderSmoothText({ content: '# New file', isStreaming: true, snapOnNonAppend: true })
expect(h.value()).toBe('')
h.unmount()
})
it('shows content that is already large at mount in full (mount-time skip, unchanged)', () => {
const h = renderSmoothText({ content: LONG, isStreaming: true, snapOnNonAppend: true })
expect(h.value()).toBe(LONG)
h.unmount()
})
it('does not pre-reveal for chat (mounts already streaming with a small first chunk)', () => {
// Chat (no snapOnNonAppend) mounts streaming; the not-streaming→streaming edge never occurs, so
// the new transition skip cannot fire and ordinary paced reveal is preserved.
const h = renderSmoothText({ content: 'Hello', isStreaming: true })
expect(h.value()).toBe('')
h.unmount()
})
})
+26 -4
View File
@@ -101,24 +101,46 @@ export function useSmoothText(
const revealedRef = useRef(revealed)
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const prevContentRef = useRef(content)
const prevIsStreamingRef = useRef(isStreaming)
let effectiveRevealed = revealed
if (
snapOnNonAppend &&
content !== prevContentRef.current &&
!content.startsWith(prevContentRef.current) &&
isStreaming &&
!prevIsStreamingRef.current &&
content.length > RESUME_SKIP_THRESHOLD &&
revealed < content.length
) {
effectiveRevealed = content.length
revealedRef.current = content.length
setRevealed(content.length)
}
prevContentRef.current = content
if (
snapOnNonAppend &&
content !== prevContentRef.current &&
!content.startsWith(prevContentRef.current) &&
effectiveRevealed < content.length
) {
effectiveRevealed = content.length
revealedRef.current = content.length
setRevealed(content.length)
}
contentRef.current = content
const hasBacklog = effectiveRevealed < content.length
// Advance the previous-input trackers on commit, never during render. A concurrent render can be
// started and then thrown away before it commits (interrupted by a higher-priority update); a
// render-phase write persists on that discarded attempt, so the retried render would read a stale
// `prev` and skip the snap. Updating them in a committed effect keeps `prev` in lockstep with the
// render that actually committed, so the snap decision is identical across discarded attempts.
useEffect(() => {
prevContentRef.current = content
prevIsStreamingRef.current = isStreaming
}, [content, isStreaming])
useEffect(() => {
const run = () => {
timeoutRef.current = null