From cb6d2f1459bd3398c495b9016994c772455bb54b Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 21 Jul 2026 21:34:20 +0200 Subject: [PATCH] Open chat file links at the referenced line (#2309) * fix(app): open chat file links at referenced lines Pass chat file locations through the editable CodeMirror path so opening a reference selects and scrolls to its requested line. Cover the real assistant-link flow with a browser regression. * fix(app): repeat chat file navigation Track file navigation separately from stable tab identity so reopening the same path and line recenters both editable and read-only panes. Extend the browser regression to cover moving away and clicking the same link again. * test(app): stabilize repeated file link navigation --- packages/app/e2e/file-editing.spec.ts | 63 +++++++++++++++++++ packages/app/src/file-pane/editor/view.tsx | 3 + .../app/src/file-pane/editor/view.web.tsx | 18 ++++++ packages/app/src/file-pane/pane.tsx | 16 ++++- packages/app/src/panels/file-panel.tsx | 11 +++- packages/app/src/panels/pane-context.tsx | 1 + .../workspace/workspace-pane-content.tsx | 5 ++ .../screens/workspace/workspace-screen.tsx | 17 +++++ 8 files changed, 131 insertions(+), 3 deletions(-) diff --git a/packages/app/e2e/file-editing.spec.ts b/packages/app/e2e/file-editing.spec.ts index e6d1cab4b..5dd0cd602 100644 --- a/packages/app/e2e/file-editing.spec.ts +++ b/packages/app/e2e/file-editing.spec.ts @@ -3,6 +3,7 @@ import path from "node:path"; import { expect, test, type Page } from "./fixtures"; import { openFileExplorer, openFileFromExplorer, expectFileTabOpen } from "./helpers/file-explorer"; import { installDaemonWebSocketGate } from "./helpers/daemon-websocket-gate"; +import { openAgentRoute, seedMockAgentWorkspace } from "./helpers/mock-agent"; const RED_PIXEL = Buffer.from( "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9ZQmcAAAAASUVORK5CYII=", @@ -31,7 +32,69 @@ async function openWorkspaceFile(page: Page, filename: string): Promise { await expectFileTabOpen(page, filename); } +async function seedAgentWithFileLink(target: string) { + const session = await seedMockAgentWorkspace({ + repoPrefix: "file-editing-chat-link-", + title: "Chat file link e2e", + initialPrompt: [ + "Generate a title and a git branch name for a coding agent from the user prompt and attachments.", + "Return JSON only with fields 'title' and 'branch'.", + "", + "", + `Open \`${target}\` now`, + "", + ].join("\n"), + }); + await writeFile( + path.join(session.cwd, "target.ts"), + Array.from({ length: 80 }, (_, index) => `export const line${index + 1} = ${index + 1};`).join( + "\n", + ), + "utf8", + ); + return session; +} + test.describe("CodeMirror workspace file editing", () => { + test("opens an assistant file link at its referenced line", async ({ page }) => { + const target = "target.ts:42"; + const session = await seedAgentWithFileLink(target); + + try { + await openAgentRoute(page, session); + + const fileLink = page.getByText(target, { exact: true }); + await expect(fileLink).toBeVisible({ timeout: 15_000 }); + await fileLink.click(); + + await expectFileTabOpen(page, "target.ts"); + await expect(page.getByTestId("file-source-editor")).toBeVisible(); + await expect(page.getByLabel("Line 42, column 1")).toBeVisible(); + await expect( + page.getByTestId("file-source-editor").locator(".cm-line", { hasText: "line42 = 42" }), + ).toBeVisible(); + + const sourceEditor = editor(page); + await sourceEditor.click(); + await sourceEditor.press("Control+Home"); + await expect(page.getByLabel(/^Line 1, column \d+$/)).toBeVisible(); + + await page + .getByTestId(`workspace-tab-agent_${session.agentId}`) + .filter({ visible: true }) + .click(); + await expect(fileLink).toBeVisible(); + await fileLink.click(); + + await expect(page.getByLabel("Line 42, column 1")).toBeVisible(); + await expect( + page.getByTestId("file-source-editor").locator(".cm-line", { hasText: "line42 = 42" }), + ).toBeVisible(); + } finally { + await session.cleanup(); + } + }); + test("shows the full file path and keeps editor controls stable", async ({ page, withWorkspace, diff --git a/packages/app/src/file-pane/editor/view.tsx b/packages/app/src/file-pane/editor/view.tsx index d84d0c4e8..086128637 100644 --- a/packages/app/src/file-pane/editor/view.tsx +++ b/packages/app/src/file-pane/editor/view.tsx @@ -1,11 +1,14 @@ import { Text, View } from "react-native"; import { StyleSheet } from "react-native-unistyles"; import type { HighlightStyle } from "@getpaseo/highlight"; +import type { WorkspaceFileLocation } from "@/workspace/file-open"; import type { FileEditorModel } from "./model"; export function FileEditorView(_props: { model: FileEditorModel; filename: string; + location: WorkspaceFileLocation; + navigationRevision: number; vimEnabled: boolean; theme: { background: string; diff --git a/packages/app/src/file-pane/editor/view.web.tsx b/packages/app/src/file-pane/editor/view.web.tsx index 6bca1a275..cc211c7a5 100644 --- a/packages/app/src/file-pane/editor/view.web.tsx +++ b/packages/app/src/file-pane/editor/view.web.tsx @@ -3,12 +3,15 @@ import { Annotation, Compartment, EditorState, Transaction } from "@codemirror/s import { EditorView } from "@codemirror/view"; import { getLanguageForFile } from "@getpaseo/highlight"; import { getCM, vim } from "@replit/codemirror-vim"; +import type { WorkspaceFileLocation } from "@/workspace/file-open"; import type { FileEditorModel } from "./model"; import { editorBaseExtensions, editorTheme, type EditorVisualTheme } from "./extensions.web"; interface FileEditorViewProps { model: FileEditorModel; filename: string; + location: WorkspaceFileLocation; + navigationRevision: number; vimEnabled: boolean; theme: EditorVisualTheme; onCursorChange(position: { line: number; column: number }): void; @@ -22,6 +25,8 @@ const vimCompartment = new Compartment(); export function FileEditorView({ model, filename, + location, + navigationRevision, vimEnabled, theme, onCursorChange, @@ -81,6 +86,19 @@ export function FileEditorView({ }); }, [snapshot.content]); + useEffect(() => { + const view = viewRef.current; + if (!view || !location.lineStart) return; + const lineStart = Math.min(location.lineStart, view.state.doc.lines); + const lineEnd = Math.min(location.lineEnd ?? lineStart, view.state.doc.lines); + const from = view.state.doc.line(lineStart).from; + const to = view.state.doc.line(Math.max(lineStart, lineEnd)).to; + view.dispatch({ + selection: { anchor: from, head: lineEnd > lineStart ? to : from }, + effects: EditorView.scrollIntoView(from, { y: "center" }), + }); + }, [location.lineEnd, location.lineStart, navigationRevision]); + useEffect(() => { viewRef.current?.dispatch({ effects: languageCompartment.reconfigure(getLanguageForFile(filename)?.extension ?? []), diff --git a/packages/app/src/file-pane/pane.tsx b/packages/app/src/file-pane/pane.tsx index ab00cbcdc..ef8a74987 100644 --- a/packages/app/src/file-pane/pane.tsx +++ b/packages/app/src/file-pane/pane.tsx @@ -57,6 +57,7 @@ interface FilePreviewBodyProps { isLoading: boolean; isMobile: boolean; location: WorkspaceFileLocation; + navigationRevision: number; imagePreviewUri: string | null; } @@ -207,6 +208,7 @@ function FilePreviewBody({ isLoading, isMobile, location, + navigationRevision, imagePreviewUri, }: FilePreviewBodyProps) { const theme = UnistylesRuntime.getTheme(); @@ -257,7 +259,7 @@ function FilePreviewBody({ }); }, 0); return () => clearTimeout(timeout); - }, [lineHeight, lineSelection]); + }, [lineHeight, lineSelection, navigationRevision]); if (isLoading && !preview) { return ( @@ -380,10 +382,12 @@ export function FilePane({ serverId, workspaceRoot, location, + navigationRevision, }: { serverId: string; workspaceRoot: string; location: WorkspaceFileLocation; + navigationRevision: number; }) { const { t } = useTranslation(); const isMobile = useIsCompactFormFactor(); @@ -476,6 +480,7 @@ export function FilePane({ isLoading={query.isFetching} isMobile={isMobile} location={location} + navigationRevision={navigationRevision} imagePreviewUri={imagePreviewUri} /> ); @@ -518,6 +523,7 @@ function FilePanePresentation({ isLoading, isMobile, location, + navigationRevision, imagePreviewUri, }: { serverId: string; @@ -535,6 +541,7 @@ function FilePanePresentation({ isLoading: boolean; isMobile: boolean; location: WorkspaceFileLocation; + navigationRevision: number; imagePreviewUri: string | null; }) { if (!client && readTarget) { @@ -562,6 +569,7 @@ function FilePanePresentation({ isLoading={isLoading} isMobile={isMobile} location={location} + navigationRevision={navigationRevision} /> ); } @@ -587,6 +595,7 @@ function FilePanePresentation({ isLoading={isLoading} isMobile={isMobile} location={location} + navigationRevision={navigationRevision} imagePreviewUri={imagePreviewUri} /> @@ -605,6 +614,7 @@ function EditableFilePane({ isLoading, isMobile, location, + navigationRevision, }: { client: DaemonClient; cwd: string; @@ -617,6 +627,7 @@ function EditableFilePane({ isLoading: boolean; isMobile: boolean; location: WorkspaceFileLocation; + navigationRevision: number; }) { const { settings } = useAppSettings(); const { t } = useTranslation(); @@ -742,6 +753,8 @@ function EditableFilePane({ )} diff --git a/packages/app/src/panels/file-panel.tsx b/packages/app/src/panels/file-panel.tsx index 13d8e417f..b4b6975e5 100644 --- a/packages/app/src/panels/file-panel.tsx +++ b/packages/app/src/panels/file-panel.tsx @@ -30,7 +30,7 @@ function useFilePanelDescriptor(target: { kind: "file"; path: string }) { function FilePanel() { const { t } = useTranslation(); - const { serverId, workspaceId, target } = usePaneContext(); + const { serverId, workspaceId, target, fileNavigationRevision } = usePaneContext(); const workspaceDirectory = useWorkspaceDirectory(serverId, workspaceId); invariant(target.kind === "file", "FilePanel requires file target"); if (!workspaceDirectory) { @@ -40,7 +40,14 @@ function FilePanel() { ); } - return ; + return ( + + ); } export const filePanelRegistration: PanelRegistration<"file"> = { diff --git a/packages/app/src/panels/pane-context.tsx b/packages/app/src/panels/pane-context.tsx index 6ec608db3..b49ca43b4 100644 --- a/packages/app/src/panels/pane-context.tsx +++ b/packages/app/src/panels/pane-context.tsx @@ -8,6 +8,7 @@ export interface PaneContextValue { workspaceId: string; tabId: string; target: WorkspaceTabTarget; + fileNavigationRevision?: number; openTab: (target: WorkspaceTabTarget) => void; closeCurrentTab: () => void; retargetCurrentTab: (target: WorkspaceTabTarget) => void; diff --git a/packages/app/src/screens/workspace/workspace-pane-content.tsx b/packages/app/src/screens/workspace/workspace-pane-content.tsx index fd40efeb0..19198ddcf 100644 --- a/packages/app/src/screens/workspace/workspace-pane-content.tsx +++ b/packages/app/src/screens/workspace/workspace-pane-content.tsx @@ -23,6 +23,7 @@ export interface BuildWorkspacePaneContentModelInput { tab: WorkspaceTabDescriptor; normalizedServerId: string; normalizedWorkspaceId: string; + fileNavigationRevision?: number; onOpenTab: (target: WorkspaceTabDescriptor["target"]) => void; onCloseCurrentTab: () => void; onRetargetCurrentTab: (target: WorkspaceTabDescriptor["target"]) => void; @@ -34,6 +35,7 @@ export function buildWorkspacePaneContentModel({ tab, normalizedServerId, normalizedWorkspaceId, + fileNavigationRevision, onOpenTab, onCloseCurrentTab, onRetargetCurrentTab, @@ -51,6 +53,7 @@ export function buildWorkspacePaneContentModel({ workspaceId: normalizedWorkspaceId, tabId: tab.tabId, target: tab.target, + fileNavigationRevision, openTab: onOpenTab, closeCurrentTab: onCloseCurrentTab, retargetCurrentTab: onRetargetCurrentTab, @@ -85,6 +88,7 @@ export function WorkspacePaneContent({ workspaceId: paneContextValue.workspaceId, tabId: paneContextValue.tabId, target: paneContextValue.target, + fileNavigationRevision: paneContextValue.fileNavigationRevision, openTab, closeCurrentTab, retargetCurrentTab, @@ -97,6 +101,7 @@ export function WorkspacePaneContent({ openImportSheet, openTab, paneContextValue.serverId, + paneContextValue.fileNavigationRevision, paneContextValue.tabId, paneContextValue.target, paneContextValue.workspaceId, diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index bfea0a7d3..65f4333af 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -1773,6 +1773,17 @@ function WorkspaceScreenContent({ const openWorkspaceChildTabFocused = useWorkspaceLayoutStore( (state) => state.openChildTabFocused, ); + // File targets stay identity-stable so the same path reuses its tab. Keep navigation + // requests separate so clicking an unchanged path:line can still recenter the pane. + const [fileNavigationRevisionByTabId, setFileNavigationRevisionByTabId] = useState< + Record + >({}); + const requestFileNavigation = useCallback((tabId: string) => { + setFileNavigationRevisionByTabId((current) => ({ + ...current, + [tabId]: (current[tabId] ?? 0) + 1, + })); + }, []); const focusWorkspacePane = useWorkspaceLayoutStore((state) => state.focusPane); const hasHydratedWorkspaces = useSessionStore( (state) => state.sessions[normalizedServerId]?.hasHydratedWorkspaces ?? false, @@ -2333,6 +2344,7 @@ function WorkspaceScreenContent({ ? openWorkspaceChildTabFocused(persistenceKey, target, options.parentTabId) : openWorkspaceTabFocused(persistenceKey, target); if (tabId) { + requestFileNavigation(tabId); navigateToTabId(tabId); } }, @@ -2342,6 +2354,7 @@ function WorkspaceScreenContent({ openWorkspaceChildTabFocused, openWorkspaceTabFocused, persistenceKey, + requestFileNavigation, showMobileAgent, ], ); @@ -2381,6 +2394,7 @@ function WorkspaceScreenContent({ ? openWorkspaceChildTabFocused(persistenceKey, target, input.parentTabId) : openWorkspaceTabFocused(persistenceKey, target); if (tabId) { + requestFileNavigation(tabId); navigateToTabId(tabId); } }, @@ -2392,6 +2406,7 @@ function WorkspaceScreenContent({ openWorkspaceChildTabFocused, openWorkspaceTabFocused, persistenceKey, + requestFileNavigation, splitWorkspacePaneEmpty, uiTabs, workspaceLayout, @@ -3211,6 +3226,7 @@ function WorkspaceScreenContent({ tab: input.tab, normalizedServerId, normalizedWorkspaceId, + fileNavigationRevision: fileNavigationRevisionByTabId[input.tab.tabId] ?? 0, onOpenTab: (target) => { if (!persistenceKey) { return; @@ -3245,6 +3261,7 @@ function WorkspaceScreenContent({ [ handleCloseTabById, focusWorkspacePane, + fileNavigationRevisionByTabId, handleOpenWorkspaceFileFromPane, navigateToTabId, normalizedServerId,