authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-06-16 22:40:22-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-16 22:40:22-07:00
logb489ea52a85b6e6d698523a696f42d7fc730015f
tree76b1dbd6db792a7726de9cd524be272396a2e72b
parent3a5ec9464b4ba22be623abb3ceeaa28ab7c807e7
signature Signed by PGP key B5690EEEBB952194

Improve inline panel, file preview, and MCP session handling (#3121)


7 files changed, 490 insertions(+), 31 deletions(-)

apps/web/src/components/ChatMarkdown.browser.tsx+7
......@@ -288,6 +288,13 @@ describe("ChatMarkdown", () => {
288288 ).toMatchObject({
289289 isOpen: true,
290290 activeSurfaceId: "file:apps/web/src/components/ChatMarkdown.tsx",
291 surfaces: [
292 expect.objectContaining({
293 relativePath: "apps/web/src/components/ChatMarkdown.tsx",
294 revealLine: 978,
295 revealRequestId: 1,
296 }),
297 ],
291298 });
292299 expect(openInPreferredEditorMock).not.toHaveBeenCalled();
293300 expect(openFileInPreviewMock).not.toHaveBeenCalled();
apps/web/src/components/ChatMarkdown.tsx+6-2
......@@ -671,6 +671,7 @@ interface MarkdownFileLinkProps {
671671 iconPath: string;
672672 displayPath: string;
673673 workspaceRelativePath: string | null;
674 line?: number | undefined;
674675 label: string;
675676 copyMarkdown: string;
676677 theme: "light" | "dark";
......@@ -995,6 +996,7 @@ const MarkdownFileLink = memo(function MarkdownFileLink({
995996 iconPath,
996997 displayPath,
997998 workspaceRelativePath,
999 line,
9981000 label,
9991001 copyMarkdown,
10001002 theme,
......@@ -1027,8 +1029,8 @@ const MarkdownFileLink = memo(function MarkdownFileLink({
10271029 handleOpenInEditor();
10281030 return;
10291031 }
1030 useRightPanelStore.getState().openFile(threadRef, workspaceRelativePath);
1031 }, [handleOpenInEditor, threadRef, workspaceRelativePath]);
1032 useRightPanelStore.getState().openFile(threadRef, workspaceRelativePath, line);
1033 }, [handleOpenInEditor, line, threadRef, workspaceRelativePath]);
10321034
10331035 const handleOpenInBrowser = useCallback(() => {
10341036 if (!threadRef) return;
......@@ -1169,6 +1171,7 @@ function areMarkdownFileLinkPropsEqual(
11691171 previous.iconPath === next.iconPath &&
11701172 previous.displayPath === next.displayPath &&
11711173 previous.workspaceRelativePath === next.workspaceRelativePath &&
1174 previous.line === next.line &&
11721175 previous.label === next.label &&
11731176 previous.copyMarkdown === next.copyMarkdown &&
11741177 previous.theme === next.theme &&
......@@ -1331,6 +1334,7 @@ function ChatMarkdown({
13311334 iconPath={fileLinkMeta.filePath}
13321335 displayPath={fileLinkMeta.displayPath}
13331336 workspaceRelativePath={fileLinkMeta.workspaceRelativePath}
1337 line={fileLinkMeta.line}
13341338 label={labelParts.join(" · ")}
13351339 copyMarkdown={`[${fileLinkMeta.basename}](${normalizedHref})`}
13361340 theme={resolvedTheme}
apps/web/src/components/ChatView.browser.tsx+89-5
......@@ -2700,13 +2700,97 @@ describe("ChatView timeline estimator parity (full app)", () => {
27002700 expect(fileTree.shadowRoot?.activeElement).toBe(fileSearchInput);
27012701 expect(useComposerDraftStore.getState().draftsByThreadKey[THREAD_KEY]?.prompt ?? "").toBe("");
27022702
2703 useRightPanelStore.getState().openFile(THREAD_REF, "src/large.ts");
2704 const codeVirtualizer = await waitForElement(
2705 () => document.querySelector<HTMLElement>(".file-preview-virtualizer"),
2706 "Unable to find the virtualized file preview.",
2707 );
2703 const previousCodeVirtualizer = document.querySelector<HTMLElement>(
2704 ".file-preview-virtualizer",
2705 );
2706 useRightPanelStore.getState().openFile(THREAD_REF, "src/large.ts", 4_000);
2707 const codeVirtualizer = await waitForElement(() => {
2708 const current = document.querySelector<HTMLElement>(".file-preview-virtualizer");
2709 return current !== previousCodeVirtualizer ? current : null;
2710 }, "Unable to find the virtualized file preview.");
27082711 expect(codeVirtualizer.querySelector("diffs-container")).not.toBeNull();
27092712 expect(codeVirtualizer.classList.contains("overflow-auto")).toBe(true);
2713 await vi.waitFor(
2714 () => {
2715 const fileHost = codeVirtualizer.querySelector<HTMLElement>("diffs-container");
2716 const targetLine = fileHost?.shadowRoot?.querySelector<HTMLElement>('[data-line="4000"]');
2717 const targetLineNumber = fileHost?.shadowRoot?.querySelector<HTMLElement>(
2718 '[data-column-number="4000"]',
2719 );
2720 const previousLine =
2721 fileHost?.shadowRoot?.querySelector<HTMLElement>('[data-line="3999"]');
2722 const previousLineNumber = fileHost?.shadowRoot?.querySelector<HTMLElement>(
2723 '[data-column-number="3999"]',
2724 );
2725 expect(codeVirtualizer.scrollTop).toBeGreaterThan(0);
2726 expect(targetLine).not.toBeNull();
2727 expect(previousLine).not.toBeNull();
2728 expect(targetLine?.hasAttribute("data-file-link-reveal")).toBe(true);
2729 expect(targetLineNumber?.hasAttribute("data-file-link-reveal")).toBe(true);
2730 expect(targetLine?.hasAttribute("data-selected-line")).toBe(false);
2731 expect(targetLineNumber?.hasAttribute("data-selected-line")).toBe(false);
2732 expect(targetLineNumber?.querySelector("[data-gutter-utility-slot]")).toBeNull();
2733 expect(window.getComputedStyle(targetLine!).backgroundColor).not.toBe(
2734 window.getComputedStyle(previousLine!).backgroundColor,
2735 );
2736 expect(window.getComputedStyle(targetLineNumber!).backgroundColor).not.toBe(
2737 window.getComputedStyle(previousLineNumber!).backgroundColor,
2738 );
2739
2740 const viewportRect = codeVirtualizer.getBoundingClientRect();
2741 const lineRect = targetLine!.getBoundingClientRect();
2742 expect(lineRect.top).toBeGreaterThanOrEqual(viewportRect.top);
2743 expect(lineRect.bottom).toBeLessThanOrEqual(viewportRect.bottom);
2744 },
2745 { timeout: 8_000, interval: 16 },
2746 );
2747
2748 const fileHost = codeVirtualizer.querySelector<HTMLElement>("diffs-container");
2749 const targetLineNumber =
2750 fileHost?.shadowRoot?.querySelector<HTMLElement>('[data-column-number="4000"]') ?? null;
2751 const previousLineNumber =
2752 fileHost?.shadowRoot?.querySelector<HTMLElement>('[data-column-number="3999"]') ?? null;
2753 expect(targetLineNumber).not.toBeNull();
2754 expect(previousLineNumber).not.toBeNull();
2755
2756 targetLineNumber!.dispatchEvent(
2757 new PointerEvent("pointermove", {
2758 bubbles: true,
2759 cancelable: true,
2760 composed: true,
2761 pointerType: "mouse",
2762 }),
2763 );
2764 await vi.waitFor(() => {
2765 expect(targetLineNumber?.querySelector("[data-gutter-utility-slot]")).not.toBeNull();
2766 });
2767
2768 previousLineNumber!.dispatchEvent(
2769 new PointerEvent("pointermove", {
2770 bubbles: true,
2771 cancelable: true,
2772 composed: true,
2773 pointerType: "mouse",
2774 }),
2775 );
2776 await vi.waitFor(() => {
2777 expect(targetLineNumber?.querySelector("[data-gutter-utility-slot]")).toBeNull();
2778 expect(previousLineNumber?.querySelector("[data-gutter-utility-slot]")).not.toBeNull();
2779 });
2780
2781 codeVirtualizer.scrollTop = 0;
2782 useRightPanelStore.getState().openFile(THREAD_REF, "src/large.ts", 4_000);
2783 await vi.waitFor(
2784 () => {
2785 const fileHost = codeVirtualizer.querySelector<HTMLElement>("diffs-container");
2786 const targetLine = fileHost?.shadowRoot?.querySelector<HTMLElement>('[data-line="4000"]');
2787 expect(targetLine).not.toBeNull();
2788 expect(targetLine?.hasAttribute("data-file-link-reveal")).toBe(true);
2789 expect(targetLine?.hasAttribute("data-selected-line")).toBe(false);
2790 expect(codeVirtualizer.scrollTop).toBeGreaterThan(0);
2791 },
2792 { timeout: 8_000, interval: 16 },
2793 );
27102794 } finally {
27112795 await mounted.cleanup();
27122796 }
apps/web/src/components/ChatView.tsx+4
......@@ -1292,6 +1292,8 @@ function ChatViewContent(props: ChatViewProps) {
12921292 const activeRightPanelSurface = useRightPanelStore((store) =>
12931293 selectActiveRightPanelSurface(store.byThreadKey, activeThreadRef),
12941294 );
1295 const activeFileSurface =
1296 activeRightPanelSurface?.kind === "file" ? activeRightPanelSurface : null;
12951297 const activePreviewState = usePreviewStateStore((state) =>
12961298 selectThreadPreviewState(state.byThreadKey, activeThreadRef),
12971299 );
......@@ -4768,6 +4770,8 @@ function ChatViewContent(props: ChatViewProps) {
47684770 relativePath={
47694771 activeRightPanelSurface.kind === "file" ? activeRightPanelSurface.relativePath : null
47704772 }
4773 revealLine={activeFileSurface?.revealLine ?? null}
4774 revealRequestId={activeFileSurface?.revealRequestId ?? 0}
47714775 onOpenFile={openFileSurface}
47724776 onPendingChange={handleFilePendingChange}
47734777 />
apps/web/src/components/files/FilePreviewPanel.tsx+242-13
......@@ -4,9 +4,9 @@ import type {
44 ResolvedKeybindingsConfig,
55 ScopedThreadRef,
66} from "@t3tools/contracts";
7import type { SelectedLineRange } from "@pierre/diffs";
7import { VirtualizedFile, type SelectedLineRange } from "@pierre/diffs";
88import { Editor } from "@pierre/diffs/editor";
9import { EditorProvider, File, Virtualizer } from "@pierre/diffs/react";
9import { EditorProvider, File, type FileOptions, Virtualizer } from "@pierre/diffs/react";
1010import { ChevronRight, Code2, Eye, FolderTree, Globe2, LoaderCircle } from "lucide-react";
1111import { useCallback, useEffect, useMemo, useRef, useState } from "react";
1212
......@@ -59,12 +59,176 @@ interface FilePreviewPanelProps {
5959 composerDraftTarget: ScopedThreadRef | DraftId;
6060 keybindings: ResolvedKeybindingsConfig;
6161 availableEditors: ReadonlyArray<EditorId>;
62 revealLine: number | null;
63 revealRequestId: number;
6264 onOpenFile: (relativePath: string) => void;
6365 onPendingChange: (relativePath: string, pending: boolean) => void;
6466}
6567
6668const FILE_EXPLORER_STORAGE_KEY = "t3code.fileExplorerOpen";
6769const FILE_SAVE_DEBOUNCE_MS = 500;
70const FILE_LINK_REVEAL_ATTRIBUTE = "data-file-link-reveal";
71const FILE_LINK_REVEAL_UNSAFE_CSS = `
72 [${FILE_LINK_REVEAL_ATTRIBUTE}][data-line] {
73 background-color: light-dark(
74 color-mix(
75 in lab,
76 var(--diffs-computed-diff-line-bg) 82%,
77 var(--diffs-bg-selection-override, var(--diffs-selection-base))
78 ),
79 color-mix(
80 in lab,
81 var(--diffs-computed-diff-line-bg) 75%,
82 var(--diffs-bg-selection-override, var(--diffs-selection-base))
83 )
84 ) !important;
85 }
86
87 [${FILE_LINK_REVEAL_ATTRIBUTE}][data-column-number] {
88 background-color: light-dark(
89 color-mix(
90 in lab,
91 var(--diffs-computed-diff-line-bg) 75%,
92 var(--diffs-bg-selection-number-override, var(--diffs-selection-base))
93 ),
94 color-mix(
95 in lab,
96 var(--diffs-computed-diff-line-bg) 60%,
97 var(--diffs-bg-selection-number-override, var(--diffs-selection-base))
98 )
99 ) !important;
100 color: var(--diffs-selection-number-fg) !important;
101 }
102`;
103type FilePostRender = NonNullable<FileOptions<unknown>["onPostRender"]>;
104
105function clampFileLine(contents: string, requestedLine: number): number {
106 let lineCount = 1;
107 for (let index = 0; index < contents.length; index += 1) {
108 const character = contents.charCodeAt(index);
109 if (character === 10) {
110 lineCount += 1;
111 } else if (character === 13) {
112 lineCount += 1;
113 if (contents.charCodeAt(index + 1) === 10) index += 1;
114 }
115 }
116 return Math.min(Math.max(1, requestedLine), lineCount);
117}
118
119function updateFileLinkReveal(fileContainer: HTMLElement, line: number | null): void {
120 const root = fileContainer.shadowRoot ?? fileContainer;
121 for (const element of root.querySelectorAll<HTMLElement>(`[${FILE_LINK_REVEAL_ATTRIBUTE}]`)) {
122 element.removeAttribute(FILE_LINK_REVEAL_ATTRIBUTE);
123 }
124 if (line === null) return;
125
126 root
127 .querySelector<HTMLElement>(`[data-line="${line}"]`)
128 ?.setAttribute(FILE_LINK_REVEAL_ATTRIBUTE, "");
129 root
130 .querySelector<HTMLElement>(`[data-column-number="${line}"]`)
131 ?.setAttribute(FILE_LINK_REVEAL_ATTRIBUTE, "");
132}
133
134function useFileLineReveal(
135 relativePath: string | null,
136 revealLine: number | null,
137 revealRequestId: number,
138): FilePostRender {
139 const [handledRequestIdsByPath] = useState(() => new Map<string, number>());
140 const [latestRequestIdsByPath] = useState(() => new Map<string, number>());
141 const [pendingFramesByPath] = useState(() => new Map<string, number>());
142
143 return useCallback<FilePostRender>(
144 (fileContainer, instance, phase) => {
145 if (relativePath === null) return;
146
147 const cancelPendingReveal = () => {
148 const frameId = pendingFramesByPath.get(relativePath);
149 if (frameId !== undefined) {
150 cancelAnimationFrame(frameId);
151 pendingFramesByPath.delete(relativePath);
152 }
153 };
154
155 if (phase === "unmount") {
156 cancelPendingReveal();
157 return;
158 }
159
160 const targetLine =
161 revealLine === null ? null : clampFileLine(instance.file?.contents ?? "", revealLine);
162 updateFileLinkReveal(fileContainer, targetLine);
163
164 if (!(instance instanceof VirtualizedFile)) return;
165
166 if (latestRequestIdsByPath.get(relativePath) !== revealRequestId) {
167 cancelPendingReveal();
168 latestRequestIdsByPath.set(relativePath, revealRequestId);
169 }
170
171 if (targetLine === null) {
172 fileContainer.style.minHeight = "";
173 return;
174 }
175
176 const scrollContainer = fileContainer.closest<HTMLElement>(".file-preview-virtualizer");
177 if (!scrollContainer) return;
178 fileContainer.style.minHeight = `${Math.ceil(
179 Math.max(instance.height, scrollContainer.clientHeight),
180 )}px`;
181
182 if (
183 handledRequestIdsByPath.get(relativePath) === revealRequestId ||
184 pendingFramesByPath.has(relativePath)
185 ) {
186 return;
187 }
188
189 const reveal = () => {
190 pendingFramesByPath.delete(relativePath);
191 if (
192 latestRequestIdsByPath.get(relativePath) !== revealRequestId ||
193 !fileContainer.isConnected
194 ) {
195 return;
196 }
197
198 const linePosition = instance.getLinePosition(targetLine);
199 if (!linePosition) return;
200
201 const fileTop =
202 scrollContainer.scrollTop +
203 fileContainer.getBoundingClientRect().top -
204 scrollContainer.getBoundingClientRect().top;
205 const centeredTop = Math.max(
206 0,
207 fileTop +
208 linePosition.top -
209 Math.max(0, (scrollContainer.clientHeight - linePosition.height) / 2),
210 );
211 const maxScrollTop = Math.max(
212 0,
213 scrollContainer.scrollHeight - scrollContainer.clientHeight,
214 );
215
216 scrollContainer.scrollTop = Math.min(centeredTop, maxScrollTop);
217 handledRequestIdsByPath.set(relativePath, revealRequestId);
218 };
219
220 pendingFramesByPath.set(relativePath, requestAnimationFrame(reveal));
221 },
222 [
223 handledRequestIdsByPath,
224 latestRequestIdsByPath,
225 pendingFramesByPath,
226 relativePath,
227 revealLine,
228 revealRequestId,
229 ],
230 );
231}
68232
69233interface EditableFileSurfaceProps {
70234 environmentId: EnvironmentId;
......@@ -73,9 +237,16 @@ interface EditableFileSurfaceProps {
73237 composerDraftTarget: ScopedThreadRef | DraftId;
74238 contents: string;
75239 resolvedTheme: "light" | "dark";
240 revealRequestId: number;
241 onPostRender: FilePostRender;
76242 onPendingChange: (relativePath: string, pending: boolean) => void;
77243}
78244
245interface FileSelectionOverride {
246 revealRequestId: number;
247 range: SelectedLineRange | null;
248}
249
79250function useFileSaveCoordinator({
80251 environmentId,
81252 cwd,
......@@ -115,13 +286,24 @@ function EditableFileSurface({
115286 composerDraftTarget,
116287 contents,
117288 resolvedTheme,
289 revealRequestId,
290 onPostRender,
118291 onPendingChange,
119292}: EditableFileSurfaceProps) {
120293 const addReviewComment = useComposerDraftStore((store) => store.addReviewComment);
121294 const removeReviewComment = useComposerDraftStore((store) => store.removeReviewComment);
122295 const [lineAnnotations, setLineAnnotations] = useState<FileCommentLineAnnotation[]>([]);
123 const [selectedRange, setSelectedRange] = useState<SelectedLineRange | null>(null);
296 const [selectionOverride, setSelectionOverride] = useState<FileSelectionOverride | null>(null);
297 const selectedRange =
298 selectionOverride?.revealRequestId === revealRequestId ? selectionOverride.range : null;
299 const setSelectedRange = useCallback(
300 (range: SelectedLineRange | null) => {
301 setSelectionOverride({ revealRequestId, range });
302 },
303 [revealRequestId],
304 );
124305 const surfaceRef = useRef<HTMLDivElement>(null);
306 const selectionFrameRef = useRef<number | null>(null);
125307 const saveCoordinator = useFileSaveCoordinator({
126308 environmentId,
127309 cwd,
......@@ -179,7 +361,7 @@ function EditableFileSurface({
179361 });
180362 });
181363 },
182 [composerDraftTarget, removeReviewComment],
364 [composerDraftTarget, removeReviewComment, setSelectedRange],
183365 );
184366
185367 const submitAnnotationEntry = useCallback(
......@@ -214,7 +396,14 @@ function EditableFileSurface({
214396 })),
215397 );
216398 },
217 [addReviewComment, composerDraftTarget, contents, lineAnnotations, relativePath],
399 [
400 addReviewComment,
401 composerDraftTarget,
402 contents,
403 lineAnnotations,
404 relativePath,
405 setSelectedRange,
406 ],
218407 );
219408
220409 const beginComment = useCallback((range: SelectedLineRange) => {
......@@ -265,7 +454,7 @@ function EditableFileSurface({
265454 isBlocked: () => hasOpenCommentForm,
266455 onDismiss: () => setSelectedRange(null),
267456 });
268 }, [editor, hasOpenCommentForm]);
457 }, [editor, hasOpenCommentForm, setSelectedRange]);
269458 const handleLineSelectionEnd = useCallback(
270459 (range: SelectedLineRange | null) => {
271460 setSelectedRange(range);
......@@ -273,7 +462,26 @@ function EditableFileSurface({
273462 beginComment(range);
274463 }
275464 },
276 [beginComment],
465 [beginComment, setSelectedRange],
466 );
467
468 const handlePostRender = useCallback<FilePostRender>(
469 (fileContainer, instance, phase) => {
470 onPostRender(fileContainer, instance, phase);
471
472 if (selectionFrameRef.current !== null) {
473 cancelAnimationFrame(selectionFrameRef.current);
474 selectionFrameRef.current = null;
475 }
476 if (phase === "unmount") return;
477
478 selectionFrameRef.current = requestAnimationFrame(() => {
479 selectionFrameRef.current = null;
480 if (!fileContainer.isConnected) return;
481 instance.setSelectedLines(selectedRange, { notify: false });
482 });
483 },
484 [onPostRender, selectedRange],
277485 );
278486
279487 return (
......@@ -302,6 +510,8 @@ function EditableFileSurface({
302510 overflow: "scroll",
303511 theme: resolveDiffThemeName(resolvedTheme),
304512 themeType: resolvedTheme,
513 unsafeCSS: FILE_LINK_REVEAL_UNSAFE_CSS,
514 onPostRender: handlePostRender,
305515 }}
306516 selectedLines={selectedRange}
307517 lineAnnotations={lineAnnotations}
......@@ -336,7 +546,10 @@ function RenderedMarkdownSurface({
336546 contents,
337547 threadRef,
338548 onPendingChange,
339}: Omit<EditableFileSurfaceProps, "resolvedTheme" | "composerDraftTarget"> & {
549}: Omit<
550 EditableFileSurfaceProps,
551 "resolvedTheme" | "composerDraftTarget" | "revealLine" | "revealRequestId" | "onPostRender"
552> & {
340553 threadRef: ScopedThreadRef;
341554}) {
342555 const saveCoordinator = useFileSaveCoordinator({
......@@ -384,6 +597,8 @@ export default function FilePreviewPanel({
384597 composerDraftTarget,
385598 keybindings,
386599 availableEditors,
600 revealLine,
601 revealRequestId,
387602 onOpenFile,
388603 onPendingChange,
389604}: FilePreviewPanelProps) {
......@@ -391,10 +606,16 @@ export default function FilePreviewPanel({
391606 const primaryEnvironmentId = usePrimaryEnvironmentId();
392607 const file = useProjectFileQuery(environmentId, cwd, relativePath);
393608 const [explorerOpen, setExplorerOpen] = useState(initialExplorerOpen);
394 const [renderedMarkdownPath, setRenderedMarkdownPath] = useState<string | null>(null);
609 const [markdownView, setMarkdownView] = useState<{
610 path: string | null;
611 revealRequestId: number | null;
612 }>({ path: null, revealRequestId: null });
395613 const breadcrumbRef = useRef<HTMLDivElement>(null);
396614 const isMarkdown = relativePath ? isMarkdownPreviewFile(relativePath) : false;
397 const renderMarkdown = isMarkdown && renderedMarkdownPath === relativePath;
615 const renderMarkdown =
616 isMarkdown &&
617 markdownView.path === relativePath &&
618 (revealLine === null || markdownView.revealRequestId === revealRequestId);
398619 const canOpenInBrowser =
399620 relativePath !== null && isPreviewSupportedInRuntime() && isBrowserPreviewFile(relativePath);
400621 const absolutePath = relativePath ? resolvePathLinkTarget(relativePath, cwd) : null;
......@@ -402,6 +623,7 @@ export default function FilePreviewPanel({
402623 () => (relativePath ? fileBreadcrumbs(projectName, relativePath) : []),
403624 [projectName, relativePath],
404625 );
626 const onFilePostRender = useFileLineReveal(relativePath, revealLine, revealRequestId);
405627
406628 useEffect(() => {
407629 const currentCrumb = breadcrumbRef.current?.querySelector<HTMLElement>(
......@@ -485,9 +707,12 @@ export default function FilePreviewPanel({
485707 <Toggle
486708 className="shrink-0"
487709 pressed={renderMarkdown}
488 onPressedChange={(pressed) =>
489 setRenderedMarkdownPath(pressed ? relativePath : null)
490 }
710 onPressedChange={(pressed) => {
711 setMarkdownView({
712 path: pressed ? relativePath : null,
713 revealRequestId: pressed ? revealRequestId : null,
714 });
715 }}
491716 aria-label={renderMarkdown ? "Show markdown source" : "Show rendered markdown"}
492717 variant="ghost"
493718 size="sm"
......@@ -591,6 +816,8 @@ export default function FilePreviewPanel({
591816 overflow: "scroll",
592817 theme: resolveDiffThemeName(resolvedTheme),
593818 themeType: resolvedTheme,
819 unsafeCSS: FILE_LINK_REVEAL_UNSAFE_CSS,
820 onPostRender: onFilePostRender,
594821 }}
595822 className="min-h-full"
596823 />
......@@ -604,6 +831,8 @@ export default function FilePreviewPanel({
604831 composerDraftTarget={composerDraftTarget}
605832 contents={file.data.contents}
606833 resolvedTheme={resolvedTheme}
834 revealRequestId={revealRequestId}
835 onPostRender={onFilePostRender}
607836 onPendingChange={onPendingChange}
608837 />
609838 )
apps/web/src/rightPanelStore.test.ts+88-3
......@@ -73,6 +73,36 @@ describe("rightPanelStore", () => {
7373 });
7474 });
7575
76 it("upgrades saved file surfaces with neutral reveal state", () => {
77 expect(
78 migratePersistedRightPanelState({
79 byThreadKey: {
80 "env-1:thread-A": {
81 isOpen: true,
82 activeSurfaceId: "file:src/index.ts",
83 surfaces: [{ id: "file:src/index.ts", kind: "file", relativePath: "src/index.ts" }],
84 },
85 },
86 }),
87 ).toEqual({
88 byThreadKey: {
89 "env-1:thread-A": {
90 isOpen: true,
91 activeSurfaceId: "file:src/index.ts",
92 surfaces: [
93 {
94 id: "file:src/index.ts",
95 kind: "file",
96 relativePath: "src/index.ts",
97 revealLine: null,
98 revealRequestId: 0,
99 },
100 ],
101 },
102 },
103 });
104 });
105
76106 it("open sets the active panel for a thread", () => {
77107 useRightPanelStore.getState().open(refA, "preview");
78108 expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBe("preview");
......@@ -108,8 +138,55 @@ describe("rightPanelStore", () => {
108138 isOpen: true,
109139 activeSurfaceId: "file:README.md",
110140 surfaces: [
111 { id: "file:src/index.ts", kind: "file", relativePath: "src/index.ts" },
112 { id: "file:README.md", kind: "file", relativePath: "README.md" },
141 {
142 id: "file:src/index.ts",
143 kind: "file",
144 relativePath: "src/index.ts",
145 revealLine: null,
146 revealRequestId: 2,
147 },
148 {
149 id: "file:README.md",
150 kind: "file",
151 relativePath: "README.md",
152 revealLine: null,
153 revealRequestId: 1,
154 },
155 ],
156 });
157 });
158
159 it("updates line reveal requests when reopening a file surface", () => {
160 useRightPanelStore.getState().openFile(refA, "src/index.ts", 42);
161 useRightPanelStore.getState().openFile(refA, "src/index.ts", 87);
162
163 expect(selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA)).toEqual({
164 isOpen: true,
165 activeSurfaceId: "file:src/index.ts",
166 surfaces: [
167 {
168 id: "file:src/index.ts",
169 kind: "file",
170 relativePath: "src/index.ts",
171 revealLine: 87,
172 revealRequestId: 2,
173 },
174 ],
175 });
176
177 useRightPanelStore.getState().openFile(refA, "src/index.ts");
178
179 expect(selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA)).toEqual({
180 isOpen: true,
181 activeSurfaceId: "file:src/index.ts",
182 surfaces: [
183 {
184 id: "file:src/index.ts",
185 kind: "file",
186 relativePath: "src/index.ts",
187 revealLine: null,
188 revealRequestId: 3,
189 },
113190 ],
114191 });
115192 });
......@@ -314,7 +391,15 @@ describe("rightPanelStore", () => {
314391 expect(selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA)).toEqual({
315392 isOpen: true,
316393 activeSurfaceId: "file:src/index.ts",
317 surfaces: [{ id: "file:src/index.ts", kind: "file", relativePath: "src/index.ts" }],
394 surfaces: [
395 {
396 id: "file:src/index.ts",
397 kind: "file",
398 relativePath: "src/index.ts",
399 revealLine: null,
400 revealRequestId: 1,
401 },
402 ],
318403 });
319404 });
320405
apps/web/src/rightPanelStore.ts+54-8
......@@ -30,11 +30,17 @@ export type RightPanelSurface =
3030 }
3131 | { id: "diff"; kind: "diff" }
3232 | { id: "files"; kind: "files" }
33 | { id: `file:${string}`; kind: "file"; relativePath: string }
33 | {
34 id: `file:${string}`;
35 kind: "file";
36 relativePath: string;
37 revealLine: number | null;
38 revealRequestId: number;
39 }
3440 | { id: "plan"; kind: "plan" };
3541
3642const RIGHT_PANEL_STORAGE_KEY = "t3code:right-panel-state:v2";
37const RIGHT_PANEL_STORAGE_VERSION = 6;
43const RIGHT_PANEL_STORAGE_VERSION = 7;
3844
3945export interface ThreadRightPanelState {
4046 isOpen: boolean;
......@@ -46,7 +52,7 @@ interface RightPanelStoreState {
4652 byThreadKey: Record<string, ThreadRightPanelState>;
4753 open: (ref: ScopedThreadRef, kind: Exclude<RightPanelKind, "file" | "terminal">) => void;
4854 openBrowser: (ref: ScopedThreadRef, tabId: string | null) => void;
49 openFile: (ref: ScopedThreadRef, relativePath: string) => void;
55 openFile: (ref: ScopedThreadRef, relativePath: string, line?: number) => void;
5056 openTerminal: (ref: ScopedThreadRef, terminalId: string) => void;
5157 splitTerminal: (
5258 ref: ScopedThreadRef,
......@@ -94,10 +100,16 @@ const browserSurface = (tabId: string | null): RightPanelSurface =>
94100 ? { id: `browser:${tabId}`, kind: "preview", resourceId: tabId }
95101 : { id: "browser:new", kind: "preview", resourceId: null };
96102
97const fileSurface = (relativePath: string): RightPanelSurface => ({
103const fileSurface = (
104 relativePath: string,
105 revealLine: number | null,
106 revealRequestId: number,
107): RightPanelSurface => ({
98108 id: `file:${relativePath}`,
99109 kind: "file",
100110 relativePath,
111 revealLine,
112 revealRequestId,
101113});
102114
103115const terminalSurface = (terminalId: string): RightPanelSurface => ({
......@@ -136,6 +148,11 @@ const updateThread = (
136148 return { ...byThreadKey, [threadKey]: next };
137149};
138150
151function normalizeRevealLine(line: number | undefined): number | null {
152 if (line === undefined || !Number.isFinite(line)) return null;
153 return Math.max(1, Math.trunc(line));
154}
155
139156export function migratePersistedRightPanelState(persistedState: unknown): {
140157 byThreadKey: Record<string, ThreadRightPanelState>;
141158} {
......@@ -153,6 +170,20 @@ export function migratePersistedRightPanelState(persistedState: unknown): {
153170 threadState && typeof threadState === "object" ? threadState : null;
154171 const surfaces = Array.isArray(validThreadState?.surfaces)
155172 ? validThreadState.surfaces.flatMap<RightPanelSurface>((surface) => {
173 if (surface.kind === "file") {
174 const revealLine =
175 typeof surface.revealLine === "number" &&
176 Number.isFinite(surface.revealLine)
177 ? Math.max(1, Math.trunc(surface.revealLine))
178 : null;
179 const revealRequestId =
180 typeof surface.revealRequestId === "number" &&
181 Number.isSafeInteger(surface.revealRequestId) &&
182 surface.revealRequestId >= 0
183 ? surface.revealRequestId
184 : 0;
185 return [{ ...surface, revealLine, revealRequestId }];
186 }
156187 if (surface.kind !== "terminal") return [surface];
157188 if (
158189 !("resourceId" in surface) ||
......@@ -228,16 +259,31 @@ export const useRightPanelStore = create<RightPanelStoreState>()(
228259 return upsertSurface({ ...current, surfaces: withoutPlaceholder }, surface);
229260 }),
230261 })),
231 openFile: (ref, relativePath) =>
262 openFile: (ref, relativePath, line) =>
232263 set((state) => ({
233264 byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => {
234265 const withoutStandaloneExplorer = current.surfaces.filter(
235266 (surface) => surface.kind !== "files",
236267 );
237 return upsertSurface(
238 { ...current, surfaces: withoutStandaloneExplorer },
239 fileSurface(relativePath),
268 const surfaceId = `file:${relativePath}` as const;
269 const existing = withoutStandaloneExplorer.find(
270 (surface): surface is Extract<RightPanelSurface, { kind: "file" }> =>
271 surface.id === surfaceId && surface.kind === "file",
240272 );
273 const surface = fileSurface(
274 relativePath,
275 normalizeRevealLine(line),
276 (existing?.revealRequestId ?? 0) + 1,
277 );
278 return {
279 isOpen: true,
280 activeSurfaceId: surface.id,
281 surfaces: existing
282 ? withoutStandaloneExplorer.map((entry) =>
283 entry.id === surface.id ? surface : entry,
284 )
285 : [...withoutStandaloneExplorer, surface],
286 };
241287 }),
242288 })),
243289 openTerminal: (ref, terminalId) =>