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", () => {...@@ -288,6 +288,13 @@ describe("ChatMarkdown", () => {
288 ).toMatchObject({288 ).toMatchObject({
289 isOpen: true,289 isOpen: true,
290 activeSurfaceId: "file:apps/web/src/components/ChatMarkdown.tsx",290 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 ],
291 });298 });
292 expect(openInPreferredEditorMock).not.toHaveBeenCalled();299 expect(openInPreferredEditorMock).not.toHaveBeenCalled();
293 expect(openFileInPreviewMock).not.toHaveBeenCalled();300 expect(openFileInPreviewMock).not.toHaveBeenCalled();
apps/web/src/components/ChatMarkdown.tsx+6-2
...@@ -671,6 +671,7 @@ interface MarkdownFileLinkProps {...@@ -671,6 +671,7 @@ interface MarkdownFileLinkProps {
671 iconPath: string;671 iconPath: string;
672 displayPath: string;672 displayPath: string;
673 workspaceRelativePath: string | null;673 workspaceRelativePath: string | null;
674 line?: number | undefined;
674 label: string;675 label: string;
675 copyMarkdown: string;676 copyMarkdown: string;
676 theme: "light" | "dark";677 theme: "light" | "dark";
...@@ -995,6 +996,7 @@ const MarkdownFileLink = memo(function MarkdownFileLink({...@@ -995,6 +996,7 @@ const MarkdownFileLink = memo(function MarkdownFileLink({
995 iconPath,996 iconPath,
996 displayPath,997 displayPath,
997 workspaceRelativePath,998 workspaceRelativePath,
999 line,
998 label,1000 label,
999 copyMarkdown,1001 copyMarkdown,
1000 theme,1002 theme,
...@@ -1027,8 +1029,8 @@ const MarkdownFileLink = memo(function MarkdownFileLink({...@@ -1027,8 +1029,8 @@ const MarkdownFileLink = memo(function MarkdownFileLink({
1027 handleOpenInEditor();1029 handleOpenInEditor();
1028 return;1030 return;
1029 }1031 }
1030 useRightPanelStore.getState().openFile(threadRef, workspaceRelativePath);1032 useRightPanelStore.getState().openFile(threadRef, workspaceRelativePath, line);
1031 }, [handleOpenInEditor, threadRef, workspaceRelativePath]);1033 }, [handleOpenInEditor, line, threadRef, workspaceRelativePath]);
10321034
1033 const handleOpenInBrowser = useCallback(() => {1035 const handleOpenInBrowser = useCallback(() => {
1034 if (!threadRef) return;1036 if (!threadRef) return;
...@@ -1169,6 +1171,7 @@ function areMarkdownFileLinkPropsEqual(...@@ -1169,6 +1171,7 @@ function areMarkdownFileLinkPropsEqual(
1169 previous.iconPath === next.iconPath &&1171 previous.iconPath === next.iconPath &&
1170 previous.displayPath === next.displayPath &&1172 previous.displayPath === next.displayPath &&
1171 previous.workspaceRelativePath === next.workspaceRelativePath &&1173 previous.workspaceRelativePath === next.workspaceRelativePath &&
1174 previous.line === next.line &&
1172 previous.label === next.label &&1175 previous.label === next.label &&
1173 previous.copyMarkdown === next.copyMarkdown &&1176 previous.copyMarkdown === next.copyMarkdown &&
1174 previous.theme === next.theme &&1177 previous.theme === next.theme &&
...@@ -1331,6 +1334,7 @@ function ChatMarkdown({...@@ -1331,6 +1334,7 @@ function ChatMarkdown({
1331 iconPath={fileLinkMeta.filePath}1334 iconPath={fileLinkMeta.filePath}
1332 displayPath={fileLinkMeta.displayPath}1335 displayPath={fileLinkMeta.displayPath}
1333 workspaceRelativePath={fileLinkMeta.workspaceRelativePath}1336 workspaceRelativePath={fileLinkMeta.workspaceRelativePath}
1337 line={fileLinkMeta.line}
1334 label={labelParts.join(" · ")}1338 label={labelParts.join(" · ")}
1335 copyMarkdown={`[${fileLinkMeta.basename}](${normalizedHref})`}1339 copyMarkdown={`[${fileLinkMeta.basename}](${normalizedHref})`}
1336 theme={resolvedTheme}1340 theme={resolvedTheme}
apps/web/src/components/ChatView.browser.tsx+89-5
...@@ -2700,13 +2700,97 @@ describe("ChatView timeline estimator parity (full app)", () => {...@@ -2700,13 +2700,97 @@ describe("ChatView timeline estimator parity (full app)", () => {
2700 expect(fileTree.shadowRoot?.activeElement).toBe(fileSearchInput);2700 expect(fileTree.shadowRoot?.activeElement).toBe(fileSearchInput);
2701 expect(useComposerDraftStore.getState().draftsByThreadKey[THREAD_KEY]?.prompt ?? "").toBe("");2701 expect(useComposerDraftStore.getState().draftsByThreadKey[THREAD_KEY]?.prompt ?? "").toBe("");
27022702
2703 useRightPanelStore.getState().openFile(THREAD_REF, "src/large.ts");2703 const previousCodeVirtualizer = document.querySelector<HTMLElement>(
2704 const codeVirtualizer = await waitForElement(2704 ".file-preview-virtualizer",
2705 () => document.querySelector<HTMLElement>(".file-preview-virtualizer"),2705 );
2706 "Unable to find the virtualized file preview.",2706 useRightPanelStore.getState().openFile(THREAD_REF, "src/large.ts", 4_000);
2707 );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.");
2708 expect(codeVirtualizer.querySelector("diffs-container")).not.toBeNull();2711 expect(codeVirtualizer.querySelector("diffs-container")).not.toBeNull();
2709 expect(codeVirtualizer.classList.contains("overflow-auto")).toBe(true);2712 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 );
2710 } finally {2794 } finally {
2711 await mounted.cleanup();2795 await mounted.cleanup();
2712 }2796 }
apps/web/src/components/ChatView.tsx+4
...@@ -1292,6 +1292,8 @@ function ChatViewContent(props: ChatViewProps) {...@@ -1292,6 +1292,8 @@ function ChatViewContent(props: ChatViewProps) {
1292 const activeRightPanelSurface = useRightPanelStore((store) =>1292 const activeRightPanelSurface = useRightPanelStore((store) =>
1293 selectActiveRightPanelSurface(store.byThreadKey, activeThreadRef),1293 selectActiveRightPanelSurface(store.byThreadKey, activeThreadRef),
1294 );1294 );
1295 const activeFileSurface =
1296 activeRightPanelSurface?.kind === "file" ? activeRightPanelSurface : null;
1295 const activePreviewState = usePreviewStateStore((state) =>1297 const activePreviewState = usePreviewStateStore((state) =>
1296 selectThreadPreviewState(state.byThreadKey, activeThreadRef),1298 selectThreadPreviewState(state.byThreadKey, activeThreadRef),
1297 );1299 );
...@@ -4768,6 +4770,8 @@ function ChatViewContent(props: ChatViewProps) {...@@ -4768,6 +4770,8 @@ function ChatViewContent(props: ChatViewProps) {
4768 relativePath={4770 relativePath={
4769 activeRightPanelSurface.kind === "file" ? activeRightPanelSurface.relativePath : null4771 activeRightPanelSurface.kind === "file" ? activeRightPanelSurface.relativePath : null
4770 }4772 }
4773 revealLine={activeFileSurface?.revealLine ?? null}
4774 revealRequestId={activeFileSurface?.revealRequestId ?? 0}
4771 onOpenFile={openFileSurface}4775 onOpenFile={openFileSurface}
4772 onPendingChange={handleFilePendingChange}4776 onPendingChange={handleFilePendingChange}
4773 />4777 />
apps/web/src/components/files/FilePreviewPanel.tsx+242-13
...@@ -4,9 +4,9 @@ import type {...@@ -4,9 +4,9 @@ import type {
4 ResolvedKeybindingsConfig,4 ResolvedKeybindingsConfig,
5 ScopedThreadRef,5 ScopedThreadRef,
6} from "@t3tools/contracts";6} from "@t3tools/contracts";
7import type { SelectedLineRange } from "@pierre/diffs";7import { VirtualizedFile, type SelectedLineRange } from "@pierre/diffs";
8import { Editor } from "@pierre/diffs/editor";8import { Editor } from "@pierre/diffs/editor";
9import { EditorProvider, File, Virtualizer } from "@pierre/diffs/react";9import { EditorProvider, File, type FileOptions, Virtualizer } from "@pierre/diffs/react";
10import { ChevronRight, Code2, Eye, FolderTree, Globe2, LoaderCircle } from "lucide-react";10import { ChevronRight, Code2, Eye, FolderTree, Globe2, LoaderCircle } from "lucide-react";
11import { useCallback, useEffect, useMemo, useRef, useState } from "react";11import { useCallback, useEffect, useMemo, useRef, useState } from "react";
1212
...@@ -59,12 +59,176 @@ interface FilePreviewPanelProps {...@@ -59,12 +59,176 @@ interface FilePreviewPanelProps {
59 composerDraftTarget: ScopedThreadRef | DraftId;59 composerDraftTarget: ScopedThreadRef | DraftId;
60 keybindings: ResolvedKeybindingsConfig;60 keybindings: ResolvedKeybindingsConfig;
61 availableEditors: ReadonlyArray<EditorId>;61 availableEditors: ReadonlyArray<EditorId>;
62 revealLine: number | null;
63 revealRequestId: number;
62 onOpenFile: (relativePath: string) => void;64 onOpenFile: (relativePath: string) => void;
63 onPendingChange: (relativePath: string, pending: boolean) => void;65 onPendingChange: (relativePath: string, pending: boolean) => void;
64}66}
6567
66const FILE_EXPLORER_STORAGE_KEY = "t3code.fileExplorerOpen";68const FILE_EXPLORER_STORAGE_KEY = "t3code.fileExplorerOpen";
67const FILE_SAVE_DEBOUNCE_MS = 500;69const 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
69interface EditableFileSurfaceProps {233interface EditableFileSurfaceProps {
70 environmentId: EnvironmentId;234 environmentId: EnvironmentId;
...@@ -73,9 +237,16 @@ interface EditableFileSurfaceProps {...@@ -73,9 +237,16 @@ interface EditableFileSurfaceProps {
73 composerDraftTarget: ScopedThreadRef | DraftId;237 composerDraftTarget: ScopedThreadRef | DraftId;
74 contents: string;238 contents: string;
75 resolvedTheme: "light" | "dark";239 resolvedTheme: "light" | "dark";
240 revealRequestId: number;
241 onPostRender: FilePostRender;
76 onPendingChange: (relativePath: string, pending: boolean) => void;242 onPendingChange: (relativePath: string, pending: boolean) => void;
77}243}
78244
245interface FileSelectionOverride {
246 revealRequestId: number;
247 range: SelectedLineRange | null;
248}
249
79function useFileSaveCoordinator({250function useFileSaveCoordinator({
80 environmentId,251 environmentId,
81 cwd,252 cwd,
...@@ -115,13 +286,24 @@ function EditableFileSurface({...@@ -115,13 +286,24 @@ function EditableFileSurface({
115 composerDraftTarget,286 composerDraftTarget,
116 contents,287 contents,
117 resolvedTheme,288 resolvedTheme,
289 revealRequestId,
290 onPostRender,
118 onPendingChange,291 onPendingChange,
119}: EditableFileSurfaceProps) {292}: EditableFileSurfaceProps) {
120 const addReviewComment = useComposerDraftStore((store) => store.addReviewComment);293 const addReviewComment = useComposerDraftStore((store) => store.addReviewComment);
121 const removeReviewComment = useComposerDraftStore((store) => store.removeReviewComment);294 const removeReviewComment = useComposerDraftStore((store) => store.removeReviewComment);
122 const [lineAnnotations, setLineAnnotations] = useState<FileCommentLineAnnotation[]>([]);295 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 );
124 const surfaceRef = useRef<HTMLDivElement>(null);305 const surfaceRef = useRef<HTMLDivElement>(null);
306 const selectionFrameRef = useRef<number | null>(null);
125 const saveCoordinator = useFileSaveCoordinator({307 const saveCoordinator = useFileSaveCoordinator({
126 environmentId,308 environmentId,
127 cwd,309 cwd,
...@@ -179,7 +361,7 @@ function EditableFileSurface({...@@ -179,7 +361,7 @@ function EditableFileSurface({
179 });361 });
180 });362 });
181 },363 },
182 [composerDraftTarget, removeReviewComment],364 [composerDraftTarget, removeReviewComment, setSelectedRange],
183 );365 );
184366
185 const submitAnnotationEntry = useCallback(367 const submitAnnotationEntry = useCallback(
...@@ -214,7 +396,14 @@ function EditableFileSurface({...@@ -214,7 +396,14 @@ function EditableFileSurface({
214 })),396 })),
215 );397 );
216 },398 },
217 [addReviewComment, composerDraftTarget, contents, lineAnnotations, relativePath],399 [
400 addReviewComment,
401 composerDraftTarget,
402 contents,
403 lineAnnotations,
404 relativePath,
405 setSelectedRange,
406 ],
218 );407 );
219408
220 const beginComment = useCallback((range: SelectedLineRange) => {409 const beginComment = useCallback((range: SelectedLineRange) => {
...@@ -265,7 +454,7 @@ function EditableFileSurface({...@@ -265,7 +454,7 @@ function EditableFileSurface({
265 isBlocked: () => hasOpenCommentForm,454 isBlocked: () => hasOpenCommentForm,
266 onDismiss: () => setSelectedRange(null),455 onDismiss: () => setSelectedRange(null),
267 });456 });
268 }, [editor, hasOpenCommentForm]);457 }, [editor, hasOpenCommentForm, setSelectedRange]);
269 const handleLineSelectionEnd = useCallback(458 const handleLineSelectionEnd = useCallback(
270 (range: SelectedLineRange | null) => {459 (range: SelectedLineRange | null) => {
271 setSelectedRange(range);460 setSelectedRange(range);
...@@ -273,7 +462,26 @@ function EditableFileSurface({...@@ -273,7 +462,26 @@ function EditableFileSurface({
273 beginComment(range);462 beginComment(range);
274 }463 }
275 },464 },
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],
277 );485 );
278486
279 return (487 return (
...@@ -302,6 +510,8 @@ function EditableFileSurface({...@@ -302,6 +510,8 @@ function EditableFileSurface({
302 overflow: "scroll",510 overflow: "scroll",
303 theme: resolveDiffThemeName(resolvedTheme),511 theme: resolveDiffThemeName(resolvedTheme),
304 themeType: resolvedTheme,512 themeType: resolvedTheme,
513 unsafeCSS: FILE_LINK_REVEAL_UNSAFE_CSS,
514 onPostRender: handlePostRender,
305 }}515 }}
306 selectedLines={selectedRange}516 selectedLines={selectedRange}
307 lineAnnotations={lineAnnotations}517 lineAnnotations={lineAnnotations}
...@@ -336,7 +546,10 @@ function RenderedMarkdownSurface({...@@ -336,7 +546,10 @@ function RenderedMarkdownSurface({
336 contents,546 contents,
337 threadRef,547 threadRef,
338 onPendingChange,548 onPendingChange,
339}: Omit<EditableFileSurfaceProps, "resolvedTheme" | "composerDraftTarget"> & {549}: Omit<
550 EditableFileSurfaceProps,
551 "resolvedTheme" | "composerDraftTarget" | "revealLine" | "revealRequestId" | "onPostRender"
552> & {
340 threadRef: ScopedThreadRef;553 threadRef: ScopedThreadRef;
341}) {554}) {
342 const saveCoordinator = useFileSaveCoordinator({555 const saveCoordinator = useFileSaveCoordinator({
...@@ -384,6 +597,8 @@ export default function FilePreviewPanel({...@@ -384,6 +597,8 @@ export default function FilePreviewPanel({
384 composerDraftTarget,597 composerDraftTarget,
385 keybindings,598 keybindings,
386 availableEditors,599 availableEditors,
600 revealLine,
601 revealRequestId,
387 onOpenFile,602 onOpenFile,
388 onPendingChange,603 onPendingChange,
389}: FilePreviewPanelProps) {604}: FilePreviewPanelProps) {
...@@ -391,10 +606,16 @@ export default function FilePreviewPanel({...@@ -391,10 +606,16 @@ export default function FilePreviewPanel({
391 const primaryEnvironmentId = usePrimaryEnvironmentId();606 const primaryEnvironmentId = usePrimaryEnvironmentId();
392 const file = useProjectFileQuery(environmentId, cwd, relativePath);607 const file = useProjectFileQuery(environmentId, cwd, relativePath);
393 const [explorerOpen, setExplorerOpen] = useState(initialExplorerOpen);608 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 });
395 const breadcrumbRef = useRef<HTMLDivElement>(null);613 const breadcrumbRef = useRef<HTMLDivElement>(null);
396 const isMarkdown = relativePath ? isMarkdownPreviewFile(relativePath) : false;614 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);
398 const canOpenInBrowser =619 const canOpenInBrowser =
399 relativePath !== null && isPreviewSupportedInRuntime() && isBrowserPreviewFile(relativePath);620 relativePath !== null && isPreviewSupportedInRuntime() && isBrowserPreviewFile(relativePath);
400 const absolutePath = relativePath ? resolvePathLinkTarget(relativePath, cwd) : null;621 const absolutePath = relativePath ? resolvePathLinkTarget(relativePath, cwd) : null;
...@@ -402,6 +623,7 @@ export default function FilePreviewPanel({...@@ -402,6 +623,7 @@ export default function FilePreviewPanel({
402 () => (relativePath ? fileBreadcrumbs(projectName, relativePath) : []),623 () => (relativePath ? fileBreadcrumbs(projectName, relativePath) : []),
403 [projectName, relativePath],624 [projectName, relativePath],
404 );625 );
626 const onFilePostRender = useFileLineReveal(relativePath, revealLine, revealRequestId);
405627
406 useEffect(() => {628 useEffect(() => {
407 const currentCrumb = breadcrumbRef.current?.querySelector<HTMLElement>(629 const currentCrumb = breadcrumbRef.current?.querySelector<HTMLElement>(
...@@ -485,9 +707,12 @@ export default function FilePreviewPanel({...@@ -485,9 +707,12 @@ export default function FilePreviewPanel({
485 <Toggle707 <Toggle
486 className="shrink-0"708 className="shrink-0"
487 pressed={renderMarkdown}709 pressed={renderMarkdown}
488 onPressedChange={(pressed) =>710 onPressedChange={(pressed) => {
489 setRenderedMarkdownPath(pressed ? relativePath : null)711 setMarkdownView({
490 }712 path: pressed ? relativePath : null,
713 revealRequestId: pressed ? revealRequestId : null,
714 });
715 }}
491 aria-label={renderMarkdown ? "Show markdown source" : "Show rendered markdown"}716 aria-label={renderMarkdown ? "Show markdown source" : "Show rendered markdown"}
492 variant="ghost"717 variant="ghost"
493 size="sm"718 size="sm"
...@@ -591,6 +816,8 @@ export default function FilePreviewPanel({...@@ -591,6 +816,8 @@ export default function FilePreviewPanel({
591 overflow: "scroll",816 overflow: "scroll",
592 theme: resolveDiffThemeName(resolvedTheme),817 theme: resolveDiffThemeName(resolvedTheme),
593 themeType: resolvedTheme,818 themeType: resolvedTheme,
819 unsafeCSS: FILE_LINK_REVEAL_UNSAFE_CSS,
820 onPostRender: onFilePostRender,
594 }}821 }}
595 className="min-h-full"822 className="min-h-full"
596 />823 />
...@@ -604,6 +831,8 @@ export default function FilePreviewPanel({...@@ -604,6 +831,8 @@ export default function FilePreviewPanel({
604 composerDraftTarget={composerDraftTarget}831 composerDraftTarget={composerDraftTarget}
605 contents={file.data.contents}832 contents={file.data.contents}
606 resolvedTheme={resolvedTheme}833 resolvedTheme={resolvedTheme}
834 revealRequestId={revealRequestId}
835 onPostRender={onFilePostRender}
607 onPendingChange={onPendingChange}836 onPendingChange={onPendingChange}
608 />837 />
609 )838 )
apps/web/src/rightPanelStore.test.ts+88-3
...@@ -73,6 +73,36 @@ describe("rightPanelStore", () => {...@@ -73,6 +73,36 @@ describe("rightPanelStore", () => {
73 });73 });
74 });74 });
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
76 it("open sets the active panel for a thread", () => {106 it("open sets the active panel for a thread", () => {
77 useRightPanelStore.getState().open(refA, "preview");107 useRightPanelStore.getState().open(refA, "preview");
78 expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBe("preview");108 expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBe("preview");
...@@ -108,8 +138,55 @@ describe("rightPanelStore", () => {...@@ -108,8 +138,55 @@ describe("rightPanelStore", () => {
108 isOpen: true,138 isOpen: true,
109 activeSurfaceId: "file:README.md",139 activeSurfaceId: "file:README.md",
110 surfaces: [140 surfaces: [
111 { id: "file:src/index.ts", kind: "file", relativePath: "src/index.ts" },141 {
112 { id: "file:README.md", kind: "file", relativePath: "README.md" },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 },
113 ],190 ],
114 });191 });
115 });192 });
...@@ -314,7 +391,15 @@ describe("rightPanelStore", () => {...@@ -314,7 +391,15 @@ describe("rightPanelStore", () => {
314 expect(selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA)).toEqual({391 expect(selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA)).toEqual({
315 isOpen: true,392 isOpen: true,
316 activeSurfaceId: "file:src/index.ts",393 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 ],
318 });403 });
319 });404 });
320405
apps/web/src/rightPanelStore.ts+54-8
...@@ -30,11 +30,17 @@ export type RightPanelSurface =...@@ -30,11 +30,17 @@ export type RightPanelSurface =
30 }30 }
31 | { id: "diff"; kind: "diff" }31 | { id: "diff"; kind: "diff" }
32 | { id: "files"; kind: "files" }32 | { 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 }
34 | { id: "plan"; kind: "plan" };40 | { id: "plan"; kind: "plan" };
3541
36const RIGHT_PANEL_STORAGE_KEY = "t3code:right-panel-state:v2";42const RIGHT_PANEL_STORAGE_KEY = "t3code:right-panel-state:v2";
37const RIGHT_PANEL_STORAGE_VERSION = 6;43const RIGHT_PANEL_STORAGE_VERSION = 7;
3844
39export interface ThreadRightPanelState {45export interface ThreadRightPanelState {
40 isOpen: boolean;46 isOpen: boolean;
...@@ -46,7 +52,7 @@ interface RightPanelStoreState {...@@ -46,7 +52,7 @@ interface RightPanelStoreState {
46 byThreadKey: Record<string, ThreadRightPanelState>;52 byThreadKey: Record<string, ThreadRightPanelState>;
47 open: (ref: ScopedThreadRef, kind: Exclude<RightPanelKind, "file" | "terminal">) => void;53 open: (ref: ScopedThreadRef, kind: Exclude<RightPanelKind, "file" | "terminal">) => void;
48 openBrowser: (ref: ScopedThreadRef, tabId: string | null) => void;54 openBrowser: (ref: ScopedThreadRef, tabId: string | null) => void;
49 openFile: (ref: ScopedThreadRef, relativePath: string) => void;55 openFile: (ref: ScopedThreadRef, relativePath: string, line?: number) => void;
50 openTerminal: (ref: ScopedThreadRef, terminalId: string) => void;56 openTerminal: (ref: ScopedThreadRef, terminalId: string) => void;
51 splitTerminal: (57 splitTerminal: (
52 ref: ScopedThreadRef,58 ref: ScopedThreadRef,
...@@ -94,10 +100,16 @@ const browserSurface = (tabId: string | null): RightPanelSurface =>...@@ -94,10 +100,16 @@ const browserSurface = (tabId: string | null): RightPanelSurface =>
94 ? { id: `browser:${tabId}`, kind: "preview", resourceId: tabId }100 ? { id: `browser:${tabId}`, kind: "preview", resourceId: tabId }
95 : { id: "browser:new", kind: "preview", resourceId: null };101 : { 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 => ({
98 id: `file:${relativePath}`,108 id: `file:${relativePath}`,
99 kind: "file",109 kind: "file",
100 relativePath,110 relativePath,
111 revealLine,
112 revealRequestId,
101});113});
102114
103const terminalSurface = (terminalId: string): RightPanelSurface => ({115const terminalSurface = (terminalId: string): RightPanelSurface => ({
...@@ -136,6 +148,11 @@ const updateThread = (...@@ -136,6 +148,11 @@ const updateThread = (
136 return { ...byThreadKey, [threadKey]: next };148 return { ...byThreadKey, [threadKey]: next };
137};149};
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
139export function migratePersistedRightPanelState(persistedState: unknown): {156export function migratePersistedRightPanelState(persistedState: unknown): {
140 byThreadKey: Record<string, ThreadRightPanelState>;157 byThreadKey: Record<string, ThreadRightPanelState>;
141} {158} {
...@@ -153,6 +170,20 @@ export function migratePersistedRightPanelState(persistedState: unknown): {...@@ -153,6 +170,20 @@ export function migratePersistedRightPanelState(persistedState: unknown): {
153 threadState && typeof threadState === "object" ? threadState : null;170 threadState && typeof threadState === "object" ? threadState : null;
154 const surfaces = Array.isArray(validThreadState?.surfaces)171 const surfaces = Array.isArray(validThreadState?.surfaces)
155 ? validThreadState.surfaces.flatMap<RightPanelSurface>((surface) => {172 ? 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 }
156 if (surface.kind !== "terminal") return [surface];187 if (surface.kind !== "terminal") return [surface];
157 if (188 if (
158 !("resourceId" in surface) ||189 !("resourceId" in surface) ||
...@@ -228,16 +259,31 @@ export const useRightPanelStore = create<RightPanelStoreState>()(...@@ -228,16 +259,31 @@ export const useRightPanelStore = create<RightPanelStoreState>()(
228 return upsertSurface({ ...current, surfaces: withoutPlaceholder }, surface);259 return upsertSurface({ ...current, surfaces: withoutPlaceholder }, surface);
229 }),260 }),
230 })),261 })),
231 openFile: (ref, relativePath) =>262 openFile: (ref, relativePath, line) =>
232 set((state) => ({263 set((state) => ({
233 byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => {264 byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => {
234 const withoutStandaloneExplorer = current.surfaces.filter(265 const withoutStandaloneExplorer = current.surfaces.filter(
235 (surface) => surface.kind !== "files",266 (surface) => surface.kind !== "files",
236 );267 );
237 return upsertSurface(268 const surfaceId = `file:${relativePath}` as const;
238 { ...current, surfaces: withoutStandaloneExplorer },269 const existing = withoutStandaloneExplorer.find(
239 fileSurface(relativePath),270 (surface): surface is Extract<RightPanelSurface, { kind: "file" }> =>
271 surface.id === surfaceId && surface.kind === "file",
240 );272 );
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 };
241 }),287 }),
242 })),288 })),
243 openTerminal: (ref, terminalId) =>289 openTerminal: (ref, terminalId) =>