Skip to content

Commit 16e0a2b

Browse files
authored
v0.7.63: chat pins, ui improvements, bump deps, file improvements
]
2 parents d5ce247 + d2964af commit 16e0a2b

26 files changed

Lines changed: 872 additions & 144 deletions

File tree

apps/sim/app/api/files/serve/[...path]/route.test.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,13 @@ import { hybridAuthMockFns, storageServiceMock, storageServiceMockFns } from '@s
77
import { NextRequest } from 'next/server'
88
import { beforeEach, describe, expect, it, vi } from 'vitest'
99

10+
vi.mock('@sim/logger', () => ({
11+
createLogger: vi.fn(() => serveLogger),
12+
logger: serveLogger,
13+
runWithRequestContext: vi.fn(<T>(_ctx: unknown, fn: () => T): T => fn()),
14+
getRequestContext: vi.fn(() => undefined),
15+
}))
16+
1017
const {
1118
mockVerifyFileAccess,
1219
mockReadFile,
@@ -18,6 +25,7 @@ const {
1825
mockCreateFileResponse,
1926
mockCreateErrorResponse,
2027
FileNotFoundError,
28+
serveLogger,
2129
} = vi.hoisted(() => {
2230
class FileNotFoundErrorClass extends Error {
2331
constructor(message: string) {
@@ -26,6 +34,7 @@ const {
2634
}
2735
}
2836
return {
37+
serveLogger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
2938
mockVerifyFileAccess: vi.fn(),
3039
mockReadFile: vi.fn(),
3140
mockIsUsingCloudStorage: vi.fn(),
@@ -232,4 +241,32 @@ describe('File Serve API Route', () => {
232241
})
233242
}
234243
})
244+
245+
describe('failure log level', () => {
246+
it('records a missing file at info, not error', async () => {
247+
/** A superseded key is an ordinary 404, not a server fault. */
248+
const req = new NextRequest('http://localhost:3000/api/files/serve/')
249+
const response = await GET(req, { params: Promise.resolve({ path: [] }) })
250+
251+
expect(response.status).toBe(404)
252+
expect(serveLogger.info).toHaveBeenCalledWith(
253+
'Error serving file:',
254+
expect.objectContaining({ reason: expect.any(String) })
255+
)
256+
expect(serveLogger.error).not.toHaveBeenCalled()
257+
})
258+
259+
it('still records a genuine failure at error', async () => {
260+
mockVerifyFileAccess.mockRejectedValueOnce(new Error('permission backend down'))
261+
262+
const req = new NextRequest(
263+
'http://localhost:3000/api/files/serve/workspace/ws/test-file.txt'
264+
)
265+
await GET(req, {
266+
params: Promise.resolve({ path: ['workspace', 'ws', 'test-file.txt'] }),
267+
}).catch(() => undefined)
268+
269+
expect(serveLogger.error).toHaveBeenCalled()
270+
})
271+
})
235272
})

apps/sim/app/api/files/serve/[...path]/route.ts

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,23 @@ import {
2626

2727
const logger = createLogger('FilesServeAPI')
2828

29+
/**
30+
* Records a failed serve at a level that matches whose fault it is.
31+
*
32+
* A file that is not there is an ordinary answer rather than a server fault: a
33+
* workspace file is rewritten under a new key on every content update, so a reader
34+
* holding the previous key lands here routinely and correctly receives a 404. Each
35+
* handler rethrows into the outer one, so logging those at `error` reports the same
36+
* expected 404 twice and buries the failures that do warrant attention.
37+
*/
38+
function logServeFailure(message: string, error: unknown): void {
39+
if (error instanceof FileNotFoundError) {
40+
logger.info(message, { reason: error.message })
41+
return
42+
}
43+
logger.error(message, error)
44+
}
45+
2946
interface ServeOptions {
3047
/** `raw=1` — bypass all resolution and serve the stored source as-is. */
3148
raw: boolean
@@ -179,7 +196,7 @@ export const GET = withRouteHandler(
179196
return NextResponse.json({ error: 'Document is still being generated' }, { status: 409 })
180197
}
181198

182-
logger.error('Error serving file:', error)
199+
logServeFailure('Error serving file:', error)
183200

184201
if (error instanceof FileNotFoundError) {
185202
return createErrorResponse(error)
@@ -244,7 +261,7 @@ async function handleLocalFile(
244261
cacheControl: resolveServeCacheControl(options.versioned, contextParam),
245262
})
246263
} catch (error) {
247-
logger.error('Error reading local file:', error)
264+
logServeFailure('Error reading local file:', error)
248265
throw error
249266
}
250267
}
@@ -311,7 +328,7 @@ async function handleCloudProxy(
311328
cacheControl: resolveServeCacheControl(options.versioned, context),
312329
})
313330
} catch (error) {
314-
logger.error('Error downloading from cloud storage:', error)
331+
logServeFailure('Error downloading from cloud storage:', error)
315332
throw error
316333
}
317334
}
@@ -348,7 +365,7 @@ async function handleCloudProxyPublic(
348365
cacheControl: PUBLIC_ASSET_CACHE_CONTROL,
349366
})
350367
} catch (error) {
351-
logger.error('Error serving public cloud file:', error)
368+
logServeFailure('Error serving public cloud file:', error)
352369
throw error
353370
}
354371
}
@@ -373,7 +390,7 @@ async function handleLocalFilePublic(filename: string): Promise<NextResponse> {
373390
cacheControl: PUBLIC_ASSET_CACHE_CONTROL,
374391
})
375392
} catch (error) {
376-
logger.error('Error reading public local file:', error)
393+
logServeFailure('Error reading public local file:', error)
377394
throw error
378395
}
379396
}

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

Lines changed: 47 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import { memo, useCallback, useEffect, useRef, useState } from 'react'
44
import { cn, toast } from '@sim/emcn'
55
import { FILE_DOC_SEED, type JoinFileDocError } from '@sim/realtime-protocol/file-doc'
6-
import { type Extensions, generateHTML, type JSONContent } from '@tiptap/core'
6+
import type { Extensions, JSONContent } from '@tiptap/core'
77
import { isChangeOrigin } from '@tiptap/extension-collaboration'
88
import { Fragment, Slice } from '@tiptap/pm/model'
99
import { NodeSelection } from '@tiptap/pm/state'
@@ -81,6 +81,44 @@ const STREAM_REPARSE_THROTTLE_MS = 120
8181
/** Debounce before naming a still-untitled file after its leading heading, so it fires once typing settles. */
8282
const DERIVE_TITLE_DEBOUNCE_MS = 600
8383

84+
/**
85+
* The editor's reading column — the centered, padded surface both the live editor and the read-only
86+
* {@link ReadOnlyPlaceholder} render into, so the two are geometrically identical and the placeholder →
87+
* live swap never reflows. Shared as one constant to keep them in lockstep.
88+
*/
89+
const EDITOR_SURFACE_CLASS =
90+
'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'
91+
92+
/**
93+
* Read-only editor that renders the already-fetched markdown while a collaborative doc waits for its
94+
* server seed, so the pane shows content instantly instead of blocking blank on the socket round-trip
95+
* (the seed IS the same markdown, so the swap on `collabReady` is seamless). It shares the live
96+
* editor's extension set ({@link EXTENSIONS}) — and therefore its node views and decoration plugins
97+
* (syntax highlighting, mention chips, images, mermaid diagrams, media embeds) — so the content is
98+
* pixel-identical to the live editor and the swap neither repaints nor reflows. It carries no
99+
* Collaboration extension, Y.Doc, or awareness, so it structurally cannot write to the shared document
100+
* (a client seed would duplicate it), and `editable={false}` disables every editing affordance. Mounted
101+
* only while the placeholder shows, so no second editor lingers once the live one takes over.
102+
*/
103+
interface ReadOnlyPlaceholderProps {
104+
content: JSONContent
105+
}
106+
107+
function ReadOnlyPlaceholder({ content }: ReadOnlyPlaceholderProps) {
108+
const editor = useEditor({
109+
extensions: EXTENSIONS,
110+
editable: false,
111+
// Render synchronously on first paint (safe — this surface is client-only, never SSR'd) so the
112+
// placeholder appears instantly like the static HTML it replaced, instead of blanking for a frame
113+
// while the editor mounts.
114+
immediatelyRender: true,
115+
shouldRerenderOnTransaction: false,
116+
content,
117+
editorProps: { attributes: { class: 'rich-markdown-prose' } },
118+
})
119+
return <EditorContent editor={editor} className={EDITOR_SURFACE_CLASS} />
120+
}
121+
84122
interface RichMarkdownEditorProps {
85123
file: WorkspaceFileRecord
86124
workspaceId: string
@@ -332,16 +370,12 @@ export function LoadedRichMarkdownEditor({
332370
: parseMarkdownToDoc(splitFrontmatter(content).body)
333371
)
334372
/**
335-
* A read-only placeholder rendered from the already-fetched markdown while a collaborative doc waits
336-
* for its server seed, so the pane shows content instantly instead of blocking blank on the socket
337-
* round-trip (the seed IS the same markdown, so the swap on {@link collabReady} is seamless). Static
338-
* HTML — it holds no editor, doc, or awareness, so it structurally cannot write to the Y.Doc, which
339-
* is the invariant that keeps seeding out of the client (a client seed duplicates the doc).
373+
* The already-fetched markdown, parsed once, for the read-only {@link ReadOnlyPlaceholder} shown while
374+
* a collaborative doc waits for its server seed. Held only when collaborating; the local path seeds
375+
* the live editor directly, so it needs no placeholder.
340376
*/
341-
const [placeholderHtml] = useState<string | null>(() =>
342-
collaborationEnabled
343-
? generateHTML(parseMarkdownToDoc(splitFrontmatter(content).body), EXTENSIONS)
344-
: null
377+
const [placeholderContent] = useState<JSONContent | null>(() =>
378+
collaborationEnabled ? parseMarkdownToDoc(splitFrontmatter(content).body) : null
345379
)
346380
/**
347381
* The body currently shown in the editor: seeded from a settled mount, updated on local edits (via
@@ -1197,22 +1231,12 @@ export function LoadedRichMarkdownEditor({
11971231
if (images.length > 0) void insertImagesRef.current(images, at)
11981232
}}
11991233
/>
1200-
{showPlaceholder && placeholderHtml && (
1201-
// Instant read-only content while the collaborative doc seeds, swapped for the live editor
1202-
// once ready. The `ProseMirror` class is load-bearing: it gives the placeholder the same base
1203-
// text layout as the live editable (prosemirror-view sets `white-space: break-spaces` and
1204-
// disables ligatures), so a line wraps identically and never re-wraps on the swap.
1205-
<div
1206-
className='ProseMirror rich-markdown-prose mx-auto w-full max-w-[48rem] px-8 py-6'
1207-
dangerouslySetInnerHTML={{ __html: placeholderHtml }}
1208-
/>
1234+
{showPlaceholder && placeholderContent && (
1235+
<ReadOnlyPlaceholder content={placeholderContent} />
12091236
)}
12101237
<EditorContent
12111238
editor={editor}
1212-
className={cn(
1213-
'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',
1214-
showPlaceholder && placeholderHtml && 'hidden'
1215-
)}
1239+
className={cn(EDITOR_SURFACE_CLASS, showPlaceholder && 'hidden')}
12161240
/>
12171241
</div>
12181242
)

0 commit comments

Comments
 (0)