Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
9ecb00e
feat(files): inline rich markdown editor
waleedlatif1 Jun 19, 2026
89eadf0
fix(files): chain autosave unmount flush after in-flight save
waleedlatif1 Jun 19, 2026
ae9e331
fix(files): read pasted images from clipboard items, not just files
waleedlatif1 Jun 19, 2026
959a560
fix(files): destroy round-trip probe editor on serialization error
waleedlatif1 Jun 19, 2026
5df2666
fix(resource): hold breadcrumb nav latch across the route swap
waleedlatif1 Jun 19, 2026
4022c9e
chore(files): drop platform references and non-essential inline comments
waleedlatif1 Jun 19, 2026
24641a3
fix(files): scope inline markdown editor to the files view
waleedlatif1 Jun 19, 2026
0776152
fix(mothership): use the inline markdown editor in the chat resource …
waleedlatif1 Jun 19, 2026
312b5ee
refactor(files): collapse the duplicate raw-editor fallback branch in…
waleedlatif1 Jun 19, 2026
59eedeb
fix(mothership): swap to the inline editor once a file preview finish…
waleedlatif1 Jun 19, 2026
dc48ea8
Revert "fix(mothership): swap to the inline editor once a file previe…
waleedlatif1 Jun 19, 2026
ce582c0
Revert "fix(mothership): use the inline markdown editor in the chat r…
waleedlatif1 Jun 19, 2026
f0bd78b
feat(files): rich markdown editor across files + chat, read-only for …
waleedlatif1 Jun 19, 2026
511fc6b
chore(files): remove dead code (unused FileViewer logger + EmbeddedWo…
waleedlatif1 Jun 19, 2026
462cd81
fix(files): derive markdown round-trip verdict from live content, not…
waleedlatif1 Jun 19, 2026
c308124
test(files): guard the rich editor dirty signal — open is never dirty…
waleedlatif1 Jun 19, 2026
f844a6a
fix(files): lock the markdown round-trip verdict on opened content, n…
waleedlatif1 Jun 19, 2026
89a269e
improvement(file-viewer): reuse shared copy hook, lazy frontmatter split
waleedlatif1 Jun 19, 2026
b7d87c8
feat(file-viewer): linked images, typed-link input rule, drag-to-reor…
waleedlatif1 Jun 19, 2026
55860f6
improvement(file-viewer): Backspace at start of a heading reverts it …
waleedlatif1 Jun 19, 2026
f86f400
fix(file-viewer): don't upload pasted/dropped images into a read-only…
waleedlatif1 Jun 19, 2026
f8ac591
fix(file-viewer): sanitize linked-image href; drop global leading-new…
waleedlatif1 Jun 19, 2026
1d0fee9
feat(file-viewer): stream agent output directly into the rich editor;…
waleedlatif1 Jun 19, 2026
0eb6ce2
fix(sidebar): hydrate collapse state before paint to stop refresh flash
waleedlatif1 Jun 19, 2026
6439396
refactor(file-viewer): audit fixes — stale docs, DRY settle-lock, lan…
waleedlatif1 Jun 19, 2026
63a4f67
refactor(file-viewer): remove dead markdown-preview renderer now supe…
waleedlatif1 Jun 19, 2026
49868aa
refactor(file-viewer): drop dead streamingMode/append path, align nam…
waleedlatif1 Jun 19, 2026
6e8bd21
fix(file-viewer): re-lock round-trip verdict + frontmatter on each st…
waleedlatif1 Jun 19, 2026
249be95
test(file-viewer): lock link href sanitization for dangerous schemes …
waleedlatif1 Jun 19, 2026
95416f3
perf(file-viewer): cap the round-trip probe at 24KB and coalesce stre…
waleedlatif1 Jun 19, 2026
e2c7f1e
perf(file-viewer): chunked markdown parsing to remove the O(n2) mount…
waleedlatif1 Jun 19, 2026
2eaf3aa
fix(sidebar): render collapse state from a cookie so SSR matches
waleedlatif1 Jun 19, 2026
f8fa284
refactor(sidebar): make the cookie the single source of truth for col…
waleedlatif1 Jun 19, 2026
443b8e4
refactor(file-viewer): simplify + cleanup chunked-parse (linear merge…
waleedlatif1 Jun 19, 2026
d8966b6
refactor(sidebar): drop orphaned sidebar-collapse-btn class
waleedlatif1 Jun 19, 2026
535798b
test(file-viewer): consolidate split test files into one per module
waleedlatif1 Jun 19, 2026
fc2dd87
fix(file-viewer): make all editor controls respect read-only permissions
waleedlatif1 Jun 19, 2026
691d228
fix(sidebar): honor collapsed cookie even when localStorage is corrupt
waleedlatif1 Jun 19, 2026
ebf4986
docs(sidebar): convert inline comments to TSDoc
waleedlatif1 Jun 19, 2026
8c0b5bb
fix(file-viewer): resolve in-app workspace image URLs in the rich editor
waleedlatif1 Jun 19, 2026
77fc0f3
fix(files): restore same-page anchor links in the rich markdown editor
waleedlatif1 Jun 19, 2026
34fad14
feat(files): render mermaid diagrams in the rich markdown editor
waleedlatif1 Jun 20, 2026
be81caf
fix(files): harden the markdown editor (CRLF chunking, href allowlist…
waleedlatif1 Jun 20, 2026
10dcd94
test(files): cover the code-highlight incremental re-tokenization gate
waleedlatif1 Jun 20, 2026
26f9fc5
fix(files): keep relative links relative, navigate in-app links withi…
waleedlatif1 Jun 20, 2026
dd3fe50
fix(files): linked images don't open a tab on a plain click in the ed…
waleedlatif1 Jun 20, 2026
af66d9f
fix(sidebar): match the collapse cookie value strictly (not a substring)
waleedlatif1 Jun 20, 2026
cc2d84c
fix(sidebar): reconcile migrated-legacy collapse before paint
waleedlatif1 Jun 20, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 1 addition & 28 deletions apps/sim/app/_styles/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -66,38 +66,11 @@
opacity: 0;
}

html[data-sidebar-collapsed] .sidebar-container span,
html[data-sidebar-collapsed] .sidebar-container .text-small {
opacity: 0;
}

.sidebar-container .sidebar-collapse-hide {
transition: opacity 60ms ease;
}

.sidebar-container .sidebar-collapse-show {
opacity: 0;
pointer-events: none;
transition: opacity 120ms ease-out;
}

.sidebar-container[data-collapsed] .sidebar-collapse-hide,
html[data-sidebar-collapsed] .sidebar-container .sidebar-collapse-hide {
opacity: 0;
}

.sidebar-container[data-collapsed] .sidebar-collapse-show,
html[data-sidebar-collapsed] .sidebar-container .sidebar-collapse-show {
opacity: 1;
pointer-events: auto;
}

html[data-sidebar-collapsed] .sidebar-container .sidebar-collapse-remove {
display: none;
}

html[data-sidebar-collapsed] .sidebar-container .sidebar-collapse-btn {
width: 0;
.sidebar-container[data-collapsed] .sidebar-collapse-hide {
opacity: 0;
}

Expand Down
48 changes: 29 additions & 19 deletions apps/sim/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,26 +78,36 @@ export default function RootLayout({ children }: { children: React.ReactNode })
// window yields a width >= MIN instead of a sub-minimum sliver.
var defaultSidebarWidth = 248;
try {
var stored = localStorage.getItem('sidebar-state');
if (stored) {
var parsed = JSON.parse(stored);
var state = parsed && parsed.state;
var isCollapsed = state && state.isCollapsed;

if (isCollapsed) {
document.documentElement.style.setProperty('--sidebar-width', '51px');
document.documentElement.setAttribute('data-sidebar-collapsed', '');
} else {
var width = state && state.sidebarWidth;
var maxSidebarWidth = Math.max(248, window.innerWidth * 0.3);
var finalWidth =
typeof width === 'number' && isFinite(width)
? Math.min(Math.max(width, 248), maxSidebarWidth)
: defaultSidebarWidth;
document.documentElement.style.setProperty('--sidebar-width', finalWidth + 'px');
}
// Collapse comes from the cookie (independent of localStorage
// parsing); the persisted width is read defensively below. Match the
// value strictly so 'sidebar_collapsed=10' isn't read as collapsed.
var cookieMatch = document.cookie.match(/(?:^|;\s*)sidebar_collapsed=([^;]*)/);
var hasCookie = cookieMatch !== null;
var collapsed = cookieMatch !== null && cookieMatch[1] === '1';

var state = null;
try {
var stored = localStorage.getItem('sidebar-state');
state = stored ? JSON.parse(stored).state : null;
} catch (e) {}

// One-time migration: seed the cookie from the legacy localStorage
// flag for users who collapsed before the cookie existed.
if (!hasCookie && state && typeof state.isCollapsed === 'boolean') {
collapsed = state.isCollapsed;
document.cookie = 'sidebar_collapsed=' + (collapsed ? '1' : '0') + '; path=/; max-age=31536000; samesite=lax';
}

if (collapsed) {
document.documentElement.style.setProperty('--sidebar-width', '51px');
} else {
document.documentElement.style.setProperty('--sidebar-width', defaultSidebarWidth + 'px');
var width = state && state.sidebarWidth;
var maxSidebarWidth = Math.max(248, window.innerWidth * 0.3);
var finalWidth =
typeof width === 'number' && isFinite(width)
? Math.min(Math.max(width, 248), maxSidebarWidth)
: defaultSidebarWidth;
document.documentElement.style.setProperty('--sidebar-width', finalWidth + 'px');
Comment thread
waleedlatif1 marked this conversation as resolved.
}
} catch (e) {
document.documentElement.style.setProperty('--sidebar-width', defaultSidebarWidth + 'px');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -371,6 +371,14 @@ interface BreadcrumbLocationPopoverProps {
veilBoundaryRef: React.RefObject<HTMLDivElement | null>
}

/**
* Grace period before a hover-out dismisses the path popover. Covers the gap
* the pointer crosses between the trigger and the popover content (and brief
* jitter at their edges); re-entering either within this window cancels the
* close. Standard hover-intent close delay — not tied to any navigation timing.
*/
const POPOVER_CLOSE_DELAY_MS = 120

function BreadcrumbLocationPopover({
icon: Icon,
breadcrumbs,
Expand All @@ -381,22 +389,44 @@ function BreadcrumbLocationPopover({
const closeTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const rootBreadcrumb = breadcrumbs[0]

const openPopover = () => {
const cancelScheduledClose = () => {
if (closeTimeoutRef.current) {
clearTimeout(closeTimeoutRef.current)
closeTimeoutRef.current = null
}
}

/**
* Hover-intent open. Driven only by pointer-/keyboard-enter — never by
* pointer movement. This is what makes the popover dismiss cleanly on a
* click-to-navigate: a stationary click fires no enter event, so once
* {@link navigateAndClose} sets `open` false nothing re-opens it before the
* route swaps. (A move-driven open would re-fire under the resting cursor and
* flash the popover/veil back in mid-navigation.)
*/
const openPopover = () => {
cancelScheduledClose()
setOpen(true)
}

const scheduleClose = () => {
if (closeTimeoutRef.current) {
clearTimeout(closeTimeoutRef.current)
}
cancelScheduledClose()
closeTimeoutRef.current = setTimeout(() => {
setOpen(false)
closeTimeoutRef.current = null
}, 120)
}, POPOVER_CLOSE_DELAY_MS)
}

/**
* Closes the popover up front, then runs the crumb's handler. Closing first
* lets the veil fade and the popover play its exit animation instead of
* snapping away when navigation unmounts the header.
*/
const navigateAndClose = (onClick?: () => void) => {
if (!onClick) return
cancelScheduledClose()
setOpen(false)
onClick()
}

useEffect(() => {
Expand All @@ -413,15 +443,11 @@ function BreadcrumbLocationPopover({
<button
type='button'
aria-label={rootBreadcrumb?.label ?? 'Path'}
onClick={rootBreadcrumb?.onClick}
Comment thread
waleedlatif1 marked this conversation as resolved.
onClick={() => navigateAndClose(rootBreadcrumb?.onClick)}
onFocus={openPopover}
onBlur={scheduleClose}
onMouseEnter={openPopover}
onMouseLeave={scheduleClose}
onMouseMove={openPopover}
onPointerEnter={openPopover}
onPointerLeave={scheduleClose}
onPointerMove={openPopover}
className={cn(
chipVariants({ flush: true }),
'max-w-none gap-1.5 px-2 transition-colors',
Expand Down Expand Up @@ -457,10 +483,6 @@ function BreadcrumbLocationPopover({
)}
onMouseEnter={openPopover}
onMouseLeave={scheduleClose}
onMouseMove={openPopover}
onPointerEnter={openPopover}
onPointerLeave={scheduleClose}
onPointerMove={openPopover}
>
<PopoverSection className='px-1.5 py-0.5 text-[var(--text-muted)] text-xs'>
<span className='inline-flex items-center gap-1'>
Expand All @@ -474,7 +496,7 @@ function BreadcrumbLocationPopover({
key={`${crumb.label}-${index}`}
icon={crumb.icon || (index === 0 ? Icon : undefined)}
label={crumb.label}
onClick={crumb.onClick}
onClick={crumb.onClick ? () => navigateAndClose(crumb.onClick) : undefined}
active={index === breadcrumbs.length - 1}
/>
))}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ const SLIDE_TRANSITION =

interface WorkspaceChromeProps {
children: React.ReactNode
/** Cookie-derived collapse state from the server layout; seeds the sidebar's first render. */
initialSidebarCollapsed?: boolean
}

function isFullscreenPath(pathname: string | null): boolean {
Expand All @@ -41,7 +43,7 @@ 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 }: WorkspaceChromeProps) {
export function WorkspaceChrome({ children, initialSidebarCollapsed }: WorkspaceChromeProps) {
const pathname = usePathname()
const isFullscreen = isFullscreenPath(pathname)

Expand Down Expand Up @@ -103,7 +105,7 @@ export function WorkspaceChrome({ children }: WorkspaceChromeProps) {
isFullscreen && '-translate-x-full'
)}
>
<Sidebar />
<Sidebar initialCollapsed={initialSidebarCollapsed} />
</div>
</div>
<div
Expand Down
Original file line number Diff line number Diff line change
@@ -1,20 +1,14 @@
'use client'

import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { Music } from 'lucide-react'
import dynamic from 'next/dynamic'
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
import { getFileExtension } from '@/lib/uploads/utils/file-utils'
import { useWorkspaceFileBinary, useWorkspaceFileContent } from '@/hooks/queries/workspace-files'
import { resolveFileCategory } from './file-category'
import type { StreamingMode } from './text-editor-state'
import { useDocPreviewBinary } from './use-doc-preview-binary'

export type { StreamingMode } from './text-editor-state'

import { CsvTablePreview } from './csv-table-preview'
import { DocxPreview } from './docx-preview'
import { resolveFileCategory } from './file-category'
import { ImagePreview } from './image-preview'
import type { PdfDocumentSource } from './pdf-viewer'
import { PptxPreview } from './pptx-preview'
Expand All @@ -27,13 +21,17 @@ import {
resolvePreviewError,
} from './preview-shared'
import { TextEditor } from './text-editor'
import { useDocPreviewBinary } from './use-doc-preview-binary'
import { XlsxPreview } from './xlsx-preview'

const PdfViewerCore = dynamic(() => import('./pdf-viewer').then((m) => m.PdfViewerCore), {
ssr: false,
})

const logger = createLogger('FileViewer')
const RichMarkdownEditor = dynamic(
() => import('./rich-markdown-editor/rich-markdown-editor').then((m) => m.RichMarkdownEditor),
{ ssr: false, loading: () => <PreviewLoadingFrame className='flex flex-1 flex-col' /> }
)

/**
* CSVs at or below this size load fully into the editor (editable, with an inline preview).
Expand All @@ -50,6 +48,15 @@ export function isPreviewable(file: { type: string; name: string }): boolean {
return resolvePreviewType(file.type, file.name) !== null
}

/**
* Markdown files render in the inline rich editor ({@link RichMarkdownEditor}) rather than
* the raw Monaco editor. Toolbars use this to hide the raw/split/preview mode controls,
* which don't apply to the single-surface editor.
*/
export function isMarkdownFile(file: { type: string; name: string }): boolean {
return resolvePreviewType(file.type, file.name) === 'markdown'
}

/**
* A CSV larger than {@link CSV_INLINE_EDIT_MAX_BYTES} is shown as a streamed, read-only preview —
* the editor would OOM loading the whole file. The viewer renders {@link CsvTablePreview} for it,
Expand Down Expand Up @@ -84,7 +91,6 @@ interface FileViewerProps {
onSaveStatusChange?: (status: 'idle' | 'saving' | 'saved' | 'error') => void
saveRef?: React.MutableRefObject<(() => Promise<void>) | null>
streamingContent?: string
streamingMode?: StreamingMode
disableStreamingAutoScroll?: boolean
previewContextKey?: string
}
Expand All @@ -100,7 +106,6 @@ export function FileViewer({
onSaveStatusChange,
saveRef,
streamingContent,
streamingMode,
disableStreamingAutoScroll = false,
previewContextKey,
}: FileViewerProps) {
Expand All @@ -114,6 +119,14 @@ export function FileViewer({
if (isCsvStreamOnly(file)) {
return <UnsupportedPreview file={file} />
}
// Markdown renders through the inline rich editor (non-editable) so the public share
// surface matches the in-app reading experience; canEdit={false} disables autosave,
// the bubble menu, and every other editing affordance.
if (isMarkdownFile(file)) {
return (
<RichMarkdownEditor key={file.id} file={file} workspaceId={workspaceId} canEdit={false} />
)
}
return <ReadOnlyTextPreview file={file} workspaceId={workspaceId} />
}
// A large CSV can't be loaded whole into the editor (the browser OOMs on the full text).
Expand All @@ -122,6 +135,24 @@ export function FileViewer({
return <CsvTablePreview key={file.id} file={file} workspaceId={workspaceId} />
}

if (isMarkdownFile(file)) {
return (
<RichMarkdownEditor
key={file.id}
file={file}
workspaceId={workspaceId}
canEdit={canEdit}
autoFocus={autoFocus}
onDirtyChange={onDirtyChange}
onSaveStatusChange={onSaveStatusChange}
Comment thread
waleedlatif1 marked this conversation as resolved.
saveRef={saveRef}
streamingContent={streamingContent}
disableStreamingAutoScroll={disableStreamingAutoScroll}
previewContextKey={previewContextKey}
/>
)
Comment thread
waleedlatif1 marked this conversation as resolved.
}
Comment thread
waleedlatif1 marked this conversation as resolved.

return (
<TextEditor
file={file}
Expand All @@ -133,7 +164,6 @@ export function FileViewer({
onSaveStatusChange={onSaveStatusChange}
saveRef={saveRef}
streamingContent={streamingContent}
streamingMode={streamingMode}
disableStreamingAutoScroll={disableStreamingAutoScroll}
previewContextKey={previewContextKey}
/>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
export { resolveFileCategory } from './file-category'
export type { PreviewMode } from './file-viewer'
export { FileViewer, isCsvStreamOnly, isPreviewable, isTextEditable } from './file-viewer'
export {
FileViewer,
isCsvStreamOnly,
isMarkdownFile,
isPreviewable,
isTextEditable,
} from './file-viewer'
export { PreviewPanel, RICH_PREVIEWABLE_EXTENSIONS, resolvePreviewType } from './preview-panel'
Loading
Loading