From dae51beee8ec00d97123ed1f695555e3d46e0a5f Mon Sep 17 00:00:00 2001 From: Simon Doba Date: Wed, 29 Jul 2026 18:07:10 +0200 Subject: [PATCH 1/3] fix(web): remember the rendered-markdown choice across threads The choice lived in panel state keyed to one file path, so leaving the thread tore it down and the panel came back showing source. It is a reading preference, not a property of a file: it now persists the way the panel already persists its explorer state. A line reveal still wins, since a line only exists in the source, and it no longer clears the preference on its way past. Closes #4645. Co-Authored-By: Claude Opus 5 --- .../src/components/files/FilePreviewPanel.tsx | 34 +++++++++++++------ 1 file changed, 24 insertions(+), 10 deletions(-) diff --git a/apps/web/src/components/files/FilePreviewPanel.tsx b/apps/web/src/components/files/FilePreviewPanel.tsx index 6ddd38e9d25..e88861ba52a 100644 --- a/apps/web/src/components/files/FilePreviewPanel.tsx +++ b/apps/web/src/components/files/FilePreviewPanel.tsx @@ -79,6 +79,7 @@ interface FilePreviewPanelProps { } const FILE_EXPLORER_STORAGE_KEY = "t3code.fileExplorerOpen"; +const RENDER_MARKDOWN_STORAGE_KEY = "t3code.renderMarkdown"; const FILE_SAVE_DEBOUNCE_MS = 500; const FILE_LINK_REVEAL_ATTRIBUTE = "data-file-link-reveal"; const FILE_LINK_REVEAL_UNSAFE_CSS = ` @@ -645,6 +646,15 @@ function initialExplorerOpen(): boolean { } } +function initialRenderMarkdown(): boolean { + try { + return getLocalStorageItem(RENDER_MARKDOWN_STORAGE_KEY, Schema.Boolean) ?? false; + } catch (error) { + console.error(error); + return false; + } +} + export default function FilePreviewPanel({ environmentId, cwd, @@ -672,16 +682,17 @@ export default function FilePreviewPanel({ const isImage = relativePath !== null && isWorkspaceImagePreviewPath(relativePath); const file = useProjectFileQuery(environmentId, cwd, relativePath, !isImage); const [explorerOpen, setExplorerOpen] = useState(initialExplorerOpen); - const [markdownView, setMarkdownView] = useState<{ - path: string | null; - revealRequestId: number | null; - }>({ path: null, revealRequestId: null }); + // Reading markdown rendered is a preference, not a property of one file. Keeping + // it on the panel meant a thread switch dropped it and forced source back. + const [renderMarkdownPreferred, setRenderMarkdownPreferred] = useState(initialRenderMarkdown); + const [revealHandledRequestId, setRevealHandledRequestId] = useState(null); const breadcrumbRef = useRef(null); const isMarkdown = relativePath ? isMarkdownPreviewFile(relativePath) : false; + // A reveal still wins over the preference: the line only exists in the source. const renderMarkdown = isMarkdown && - markdownView.path === relativePath && - (revealLine === null || markdownView.revealRequestId === revealRequestId); + renderMarkdownPreferred && + (revealLine === null || revealHandledRequestId === revealRequestId); const canOpenInBrowser = relativePath !== null && isPreviewSupportedInRuntime() && isBrowserPreviewFile(relativePath); const absolutePath = relativePath ? resolvePathLinkTarget(relativePath, cwd) : null; @@ -788,10 +799,13 @@ export default function FilePreviewPanel({ className="shrink-0" pressed={renderMarkdown} onPressedChange={(pressed) => { - setMarkdownView({ - path: pressed ? relativePath : null, - revealRequestId: pressed ? revealRequestId : null, - }); + setRenderMarkdownPreferred(pressed); + setRevealHandledRequestId(pressed ? revealRequestId : null); + try { + setLocalStorageItem(RENDER_MARKDOWN_STORAGE_KEY, pressed, Schema.Boolean); + } catch (error) { + console.error(error); + } }} aria-label={renderMarkdown ? "Show markdown source" : "Show rendered markdown"} variant="ghost" From a533f3a8d5f7ca6482ec60bb9089259231010afe Mon Sep 17 00:00:00 2001 From: Simon Doba Date: Wed, 29 Jul 2026 18:30:37 +0200 Subject: [PATCH 2/3] fix(web): key a handled reveal by file, not by request id alone Review feedback, and a regression I introduced: each file surface counts its reveals from one, so after dismissing a reveal on one file the first line link to another file carried the same id, was taken for already handled, and never left the rendered view. Pair the id with the path again, the way the state it replaced did. Co-Authored-By: Claude Opus 5 --- .../src/components/files/FilePreviewPanel.tsx | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/files/FilePreviewPanel.tsx b/apps/web/src/components/files/FilePreviewPanel.tsx index e88861ba52a..c5f70f53618 100644 --- a/apps/web/src/components/files/FilePreviewPanel.tsx +++ b/apps/web/src/components/files/FilePreviewPanel.tsx @@ -685,14 +685,20 @@ export default function FilePreviewPanel({ // Reading markdown rendered is a preference, not a property of one file. Keeping // it on the panel meant a thread switch dropped it and forced source back. const [renderMarkdownPreferred, setRenderMarkdownPreferred] = useState(initialRenderMarkdown); - const [revealHandledRequestId, setRevealHandledRequestId] = useState(null); + // Paired with the path on purpose: each file surface counts its reveals from + // one, so a bare id would let a dismissed reveal on one file swallow the first + // reveal on the next. + const [handledReveal, setHandledReveal] = useState<{ path: string; requestId: number } | null>( + null, + ); const breadcrumbRef = useRef(null); const isMarkdown = relativePath ? isMarkdownPreviewFile(relativePath) : false; // A reveal still wins over the preference: the line only exists in the source. const renderMarkdown = isMarkdown && renderMarkdownPreferred && - (revealLine === null || revealHandledRequestId === revealRequestId); + (revealLine === null || + (handledReveal?.path === relativePath && handledReveal.requestId === revealRequestId)); const canOpenInBrowser = relativePath !== null && isPreviewSupportedInRuntime() && isBrowserPreviewFile(relativePath); const absolutePath = relativePath ? resolvePathLinkTarget(relativePath, cwd) : null; @@ -800,7 +806,11 @@ export default function FilePreviewPanel({ pressed={renderMarkdown} onPressedChange={(pressed) => { setRenderMarkdownPreferred(pressed); - setRevealHandledRequestId(pressed ? revealRequestId : null); + setHandledReveal( + pressed && relativePath !== null + ? { path: relativePath, requestId: revealRequestId } + : null, + ); try { setLocalStorageItem(RENDER_MARKDOWN_STORAGE_KEY, pressed, Schema.Boolean); } catch (error) { From 3238bcb4f5763aa4568d9290f02b7b6711ae62ba Mon Sep 17 00:00:00 2001 From: Simon Doba Date: Wed, 29 Jul 2026 19:36:27 +0200 Subject: [PATCH 3/3] refactor(web): read the markdown preference through useLocalStorage The hook exists and does this better: it reads, writes, validates and keeps instances in sync, so the hand-rolled initializer and the try/catch around the write both go away. Co-Authored-By: Claude Opus 5 --- .../src/components/files/FilePreviewPanel.tsx | 22 +++++-------------- 1 file changed, 6 insertions(+), 16 deletions(-) diff --git a/apps/web/src/components/files/FilePreviewPanel.tsx b/apps/web/src/components/files/FilePreviewPanel.tsx index c5f70f53618..7f0d773c368 100644 --- a/apps/web/src/components/files/FilePreviewPanel.tsx +++ b/apps/web/src/components/files/FilePreviewPanel.tsx @@ -22,7 +22,7 @@ import ChatMarkdown from "~/components/ChatMarkdown"; import { OpenInPicker } from "~/components/chat/OpenInPicker"; import { useClientSettings } from "~/hooks/useSettings"; import { useTheme } from "~/hooks/useTheme"; -import { getLocalStorageItem, setLocalStorageItem } from "~/hooks/useLocalStorage"; +import { getLocalStorageItem, setLocalStorageItem, useLocalStorage } from "~/hooks/useLocalStorage"; import { resolveDiffThemeName } from "~/lib/diffRendering"; import { cn } from "~/lib/utils"; import { isPreviewSupportedInRuntime } from "~/previewStateStore"; @@ -646,15 +646,6 @@ function initialExplorerOpen(): boolean { } } -function initialRenderMarkdown(): boolean { - try { - return getLocalStorageItem(RENDER_MARKDOWN_STORAGE_KEY, Schema.Boolean) ?? false; - } catch (error) { - console.error(error); - return false; - } -} - export default function FilePreviewPanel({ environmentId, cwd, @@ -684,7 +675,11 @@ export default function FilePreviewPanel({ const [explorerOpen, setExplorerOpen] = useState(initialExplorerOpen); // Reading markdown rendered is a preference, not a property of one file. Keeping // it on the panel meant a thread switch dropped it and forced source back. - const [renderMarkdownPreferred, setRenderMarkdownPreferred] = useState(initialRenderMarkdown); + const [renderMarkdownPreferred, setRenderMarkdownPreferred] = useLocalStorage( + RENDER_MARKDOWN_STORAGE_KEY, + false, + Schema.Boolean, + ); // Paired with the path on purpose: each file surface counts its reveals from // one, so a bare id would let a dismissed reveal on one file swallow the first // reveal on the next. @@ -811,11 +806,6 @@ export default function FilePreviewPanel({ ? { path: relativePath, requestId: revealRequestId } : null, ); - try { - setLocalStorageItem(RENDER_MARKDOWN_STORAGE_KEY, pressed, Schema.Boolean); - } catch (error) { - console.error(error); - } }} aria-label={renderMarkdown ? "Show markdown source" : "Show rendered markdown"} variant="ghost"