mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
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:
co-authored by
waleed
parent
55f432637f
commit
4bf791716e
@@ -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;
|
||||
|
||||
+23
-3
@@ -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}
|
||||
/>
|
||||
|
||||
+1
-1
@@ -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'
|
||||
|
||||
+3
@@ -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, '\\|'),
|
||||
})
|
||||
|
||||
+93
-43
@@ -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 ? (
|
||||
<>
|
||||
|
||||
+209
@@ -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(() => {})
|
||||
}
|
||||
+50
@@ -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)]' />
|
||||
}
|
||||
+33
-2
@@ -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;
|
||||
|
||||
+45
-66
@@ -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'
|
||||
|
||||
+78
-5
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
+35
-5
@@ -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,
|
||||
|
||||
+22
-3
@@ -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>
|
||||
|
||||
+57
-1
@@ -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}
|
||||
/>
|
||||
|
||||
+3
@@ -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
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user