Skip to content

Commit 4bf7917

Browse files
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>
1 parent 55f4326 commit 4bf7917

24 files changed

Lines changed: 911 additions & 170 deletions

File tree

apps/sim/app/_styles/globals.css

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,17 @@
5555
transition: width 200ms cubic-bezier(0.25, 0.1, 0.25, 1);
5656
}
5757

58+
/**
59+
* Collapsed width is driven by the server-rendered `data-collapsed` attribute —
60+
* the same cookie source as the collapsed structure — so the rail can never paint
61+
* at the expanded width and then snap narrow. Overrides `--sidebar-width` for the
62+
* shell subtree (outer, inner, and the aside cascade from it). Must equal
63+
* SIDEBAR_WIDTH.COLLAPSED in stores/constants.ts.
64+
*/
65+
.sidebar-shell-outer[data-collapsed] {
66+
--sidebar-width: 51px;
67+
}
68+
5869
.sidebar-container span,
5970
.sidebar-container .text-small {
6071
transition: opacity 120ms ease;

apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/workspace-chrome.tsx

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
'use client'
22

3-
import { useEffect } from 'react'
3+
import { useEffect, useLayoutEffect } from 'react'
44
import { usePathname } from 'next/navigation'
55
import { cn } from '@/lib/core/utils/cn'
66
import { Sidebar } from '@/app/workspace/[workspaceId]/w/components/sidebar/sidebar'
@@ -43,15 +43,34 @@ function isFullscreenPath(pathname: string | null): boolean {
4343
* On a direct load of a fullscreen route the wrapper mounts already collapsed,
4444
* so no slide plays (CSS transitions don't run on mount).
4545
*/
46-
export function WorkspaceChrome({ children, initialSidebarCollapsed }: WorkspaceChromeProps) {
46+
export function WorkspaceChrome({
47+
children,
48+
initialSidebarCollapsed = false,
49+
}: WorkspaceChromeProps) {
4750
const pathname = usePathname()
4851
const isFullscreen = isFullscreenPath(pathname)
4952

5053
const setOrigin = useFullscreenOriginStore((s) => s.setOrigin)
5154

55+
const storeIsCollapsed = useSidebarStore((s) => s.isCollapsed)
5256
const hasHydrated = useSidebarStore((s) => s._hasHydrated)
5357
const syncSidebarWidth = useSidebarStore((s) => s.syncWidth)
5458

59+
/**
60+
* Single source of collapse for the whole chrome, driving the rail's structure,
61+
* labels, and width. The server renders from the `sidebar_collapsed` cookie
62+
* (`initialSidebarCollapsed`) and the store seeds from the same cookie — after
63+
* the pre-paint script migrates any legacy `localStorage` flag — so prop and
64+
* store agree. The prop is used until the store hydrates (keeping the first
65+
* client render identical to the server), then the store takes over.
66+
*/
67+
const isCollapsed = hasHydrated ? storeIsCollapsed : initialSidebarCollapsed
68+
69+
// Hydrate the persisted width before paint (collapse comes from the cookie/prop).
70+
useLayoutEffect(() => {
71+
void useSidebarStore.persist.rehydrate()
72+
}, [])
73+
5574
// Remember the last non-fullscreen page so a fullscreen route's Back control
5675
// can return there, deterministically and for any trigger.
5776
useEffect(() => {
@@ -95,6 +114,7 @@ export function WorkspaceChrome({ children, initialSidebarCollapsed }: Workspace
95114
SLIDE_TRANSITION,
96115
isFullscreen ? 'w-0' : 'w-[var(--sidebar-width)]'
97116
)}
117+
data-collapsed={isCollapsed || undefined}
98118
aria-hidden={isFullscreen || undefined}
99119
suppressHydrationWarning
100120
>
@@ -105,7 +125,7 @@ export function WorkspaceChrome({ children, initialSidebarCollapsed }: Workspace
105125
isFullscreen && '-translate-x-full'
106126
)}
107127
>
108-
<Sidebar initialCollapsed={initialSidebarCollapsed} />
128+
<Sidebar isCollapsed={isCollapsed} />
109129
</div>
110130
</div>
111131
<div

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ interface FileViewerProps {
9191
onSaveStatusChange?: (status: 'idle' | 'saving' | 'saved' | 'error') => void
9292
saveRef?: React.MutableRefObject<(() => Promise<void>) | null>
9393
streamingContent?: string
94+
isAgentEditing?: boolean
9495
disableStreamingAutoScroll?: boolean
9596
previewContextKey?: string
9697
}
@@ -106,6 +107,7 @@ export function FileViewer({
106107
onSaveStatusChange,
107108
saveRef,
108109
streamingContent,
110+
isAgentEditing,
109111
disableStreamingAutoScroll = false,
110112
previewContextKey,
111113
}: FileViewerProps) {
@@ -147,6 +149,7 @@ export function FileViewer({
147149
onSaveStatusChange={onSaveStatusChange}
148150
saveRef={saveRef}
149151
streamingContent={streamingContent}
152+
isAgentEditing={isAgentEditing}
150153
disableStreamingAutoScroll={disableStreamingAutoScroll}
151154
previewContextKey={previewContextKey}
152155
/>
@@ -164,6 +167,7 @@ export function FileViewer({
164167
onSaveStatusChange={onSaveStatusChange}
165168
saveRef={saveRef}
166169
streamingContent={streamingContent}
170+
isAgentEditing={isAgentEditing}
167171
disableStreamingAutoScroll={disableStreamingAutoScroll}
168172
previewContextKey={previewContextKey}
169173
/>

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/code-block.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -179,7 +179,7 @@ function CodeBlockView({ node, updateAttributes, editor, getPos }: ReactNodeView
179179
{label}
180180
</span>
181181
))}
182-
{!isMermaid && (
182+
{!isMermaid && editor.isEditable && (
183183
<button
184184
type='button'
185185
aria-label='Toggle line wrap'

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/extensions.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,9 @@ const PipeSafeTable = Table.extend({
4242
renderMarkdown: (node: JSONContent, h: MarkdownRendererHelpers) =>
4343
renderTableToMarkdown(node, {
4444
...h,
45+
// `renderChildren` already markdown-escapes backslashes; here we only add the table-specific
46+
// pipe escaping on top. (CodeQL flags the missing backslash escape, but escaping it again would
47+
// double-escape and break round-trip idempotency — see the table round-trip tests.)
4548
renderChildren: (nodes, separator) =>
4649
h.renderChildren(nodes, separator).replace(/\|/g, '\\|'),
4750
})

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/bubble-menu.tsx

Lines changed: 93 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
import { useEffect, useRef, useState } from 'react'
1+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
2+
import { posToDOMRect } from '@tiptap/core'
3+
import { PluginKey } from '@tiptap/pm/state'
24
import type { Editor } from '@tiptap/react'
35
import { useEditorState } from '@tiptap/react'
46
import { BubbleMenu } from '@tiptap/react/menus'
@@ -13,56 +15,35 @@ import {
1315
List,
1416
ListChecks,
1517
ListOrdered,
16-
type LucideIcon,
1718
Strikethrough,
1819
TextQuote,
1920
Unlink,
2021
} from 'lucide-react'
21-
import { Tooltip } from '@/components/emcn'
22-
import { cn } from '@/lib/core/utils/cn'
2322
import { normalizeLinkHref } from '../markdown-fidelity'
23+
import { ToolbarButton, ToolbarDivider } from './toolbar-button'
2424

25-
interface ToolbarButtonProps {
26-
icon: LucideIcon
27-
label: string
28-
shortcut?: string
29-
isActive: boolean
30-
onClick: () => void
25+
/**
26+
* Whether the formatting toolbar may show for the given range: the editor is editable, the range
27+
* isn't inside a code block, and it covers some non-whitespace text. Single source of truth shared by
28+
* `shouldShow` and the pointer-release reveal so the two can't drift apart.
29+
*/
30+
function hasFormattableSelection(editor: Editor, from: number, to: number): boolean {
31+
if (!editor.isEditable || editor.isActive('codeBlock')) return false
32+
return editor.state.doc.textBetween(from, to, ' ').trim().length > 0
3133
}
3234

33-
function ToolbarButton({ icon: Icon, label, shortcut, isActive, onClick }: ToolbarButtonProps) {
34-
return (
35-
<Tooltip.Root>
36-
<Tooltip.Trigger asChild>
37-
<button
38-
type='button'
39-
aria-label={label}
40-
aria-pressed={isActive}
41-
onMouseDown={(event) => event.preventDefault()}
42-
onClick={onClick}
43-
className={cn(
44-
'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]',
45-
isActive
46-
? 'bg-[var(--surface-active)] text-[var(--text-body)]'
47-
: 'hover-hover:bg-[var(--surface-hover)]'
48-
)}
49-
>
50-
<Icon />
51-
</button>
52-
</Tooltip.Trigger>
53-
<Tooltip.Content>
54-
{shortcut ? <Tooltip.Shortcut keys={shortcut}>{label}</Tooltip.Shortcut> : label}
55-
</Tooltip.Content>
56-
</Tooltip.Root>
57-
)
58-
}
35+
// Pin the toolbar to the viewport (fixed) and never attach a scroll listener, so once it's placed for
36+
// a selection it stays put while the document scrolls instead of tracking the text — matching Linear.
37+
const FLOATING_OPTIONS = { strategy: 'fixed' } as const
5938

60-
function ToolbarDivider() {
61-
return <div className='mx-0.5 h-[18px] w-px bg-[var(--border-1)]' />
62-
}
39+
// Render into the body so a transformed/clipping ancestor (e.g. the mothership panels) can't reparent
40+
// the fixed-positioned toolbar and shift it off the selection.
41+
const APPEND_TO_BODY = () => document.body
6342

6443
interface EditorBubbleMenuProps {
6544
editor: Editor
45+
/** The editor's scrollable viewport, used to keep the toolbar on-screen for selections taller than it. */
46+
scrollContainerRef: React.RefObject<HTMLDivElement | null>
6647
}
6748

6849
/**
@@ -71,12 +52,16 @@ interface EditorBubbleMenuProps {
7152
* live in the `/` slash menu. Active states are read through {@link useEditorState} so the bar
7253
* stays correct without re-rendering the editor on every transaction.
7354
*/
74-
export function EditorBubbleMenu({ editor }: EditorBubbleMenuProps) {
55+
export function EditorBubbleMenu({ editor, scrollContainerRef }: EditorBubbleMenuProps) {
7556
const [linkValue, setLinkValue] = useState<string | null>(null)
7657
const linkInputRef = useRef<HTMLInputElement>(null)
7758
const linkRangeRef = useRef<{ from: number; to: number } | null>(null)
7859
const isEditingLink = linkValue !== null
7960

61+
// Explicit key so `setMeta` can target this menu to reveal it after a drag-select.
62+
const bubbleMenuKey = useMemo(() => new PluginKey('markdownBubbleMenu'), [])
63+
const isPointerDownRef = useRef(false)
64+
8065
const active = useEditorState({
8166
editor,
8267
selector: ({ editor: e }) => ({
@@ -109,6 +94,38 @@ export function EditorBubbleMenu({ editor }: EditorBubbleMenuProps) {
10994
}
11095
}, [editor])
11196

97+
// Reveal the toolbar only once a drag-select finishes (Linear-style); `shouldShow` keeps it hidden
98+
// while the pointer is down. Keyboard selection has no pointer, so it still shows live.
99+
useEffect(() => {
100+
const dom = editor.view.dom
101+
const onPointerDown = () => {
102+
isPointerDownRef.current = true
103+
}
104+
const onPointerUp = () => {
105+
if (!isPointerDownRef.current || editor.isDestroyed) return
106+
isPointerDownRef.current = false
107+
const { from, to } = editor.state.selection
108+
if (hasFormattableSelection(editor, from, to)) {
109+
// `show` alone leaves the bar visible-but-unpositioned (its updatePosition no-ops until shown),
110+
// so a second `updatePosition` anchors it. Both are step-free, so the doc isn't marked dirty.
111+
editor.commands.setMeta(bubbleMenuKey, 'show')
112+
editor.commands.setMeta(bubbleMenuKey, 'updatePosition')
113+
}
114+
}
115+
// A release outside the window delivers no mouseup; clear the flag on blur so it can't stay wedged.
116+
const onWindowBlur = () => {
117+
isPointerDownRef.current = false
118+
}
119+
dom.addEventListener('mousedown', onPointerDown)
120+
window.addEventListener('mouseup', onPointerUp)
121+
window.addEventListener('blur', onWindowBlur)
122+
return () => {
123+
dom.removeEventListener('mousedown', onPointerDown)
124+
window.removeEventListener('mouseup', onPointerUp)
125+
window.removeEventListener('blur', onWindowBlur)
126+
}
127+
}, [editor, bubbleMenuKey])
128+
112129
const openLinkEditor = () => {
113130
if (editor.isActive('codeBlock') || editor.isActive('code')) return
114131
const { from, to } = editor.state.selection
@@ -158,9 +175,41 @@ export function EditorBubbleMenu({ editor }: EditorBubbleMenuProps) {
158175
setLinkValue(null)
159176
}
160177

178+
// Freeze the anchor per selection: the rect is computed once (in viewport coordinates) and reused on
179+
// every scroll/resize reposition, so the toolbar stays where it first appeared instead of tracking
180+
// the moving text — matching Linear. A new selection recomputes it. A selection taller than the
181+
// viewport (e.g. select-all) is clamped into the visible area so the bar isn't placed off-screen.
182+
const anchorCacheRef = useRef<{ key: string; rect: DOMRect } | null>(null)
183+
const resolveAnchor = useCallback(() => {
184+
const { view, state } = editor
185+
if (!view.dom.isConnected) return null
186+
const { from, to } = state.selection
187+
const key = `${from}:${to}`
188+
if (anchorCacheRef.current?.key !== key) {
189+
const selection = posToDOMRect(view, from, to)
190+
const viewport = scrollContainerRef.current?.getBoundingClientRect()
191+
const rect =
192+
viewport && selection.height > viewport.height
193+
? new DOMRect(
194+
selection.left,
195+
Math.min(Math.max(selection.top, viewport.top), viewport.bottom),
196+
selection.width,
197+
0
198+
)
199+
: selection
200+
anchorCacheRef.current = { key, rect }
201+
}
202+
const { rect } = anchorCacheRef.current
203+
return { getBoundingClientRect: () => rect, getClientRects: () => [rect] }
204+
}, [editor, scrollContainerRef])
205+
161206
return (
162207
<BubbleMenu
163208
editor={editor}
209+
pluginKey={bubbleMenuKey}
210+
getReferencedVirtualElement={resolveAnchor}
211+
options={FLOATING_OPTIONS}
212+
appendTo={APPEND_TO_BODY}
164213
role='toolbar'
165214
aria-label='Text formatting'
166215
updateDelay={0}
@@ -169,10 +218,11 @@ export function EditorBubbleMenu({ editor }: EditorBubbleMenuProps) {
169218
// can't be applied to a doc that must not mutate.
170219
if (!e.isEditable) return false
171220
if (isEditingLink) return true
172-
if (e.isActive('codeBlock')) return false
173-
return e.state.doc.textBetween(from, to, ' ').trim().length > 0
221+
// Suppressed mid-drag; the pointer-release handler forces it back open once the selection sticks.
222+
if (isPointerDownRef.current) return false
223+
return hasFormattableSelection(e, from, to)
174224
}}
175-
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'
225+
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'
176226
>
177227
{isEditingLink ? (
178228
<>

0 commit comments

Comments
 (0)