diff --git a/apps/desktop/src/components/workpanel/FilesTab.tsx b/apps/desktop/src/components/workpanel/FilesTab.tsx index b73418a9e..f10f71b73 100644 --- a/apps/desktop/src/components/workpanel/FilesTab.tsx +++ b/apps/desktop/src/components/workpanel/FilesTab.tsx @@ -215,17 +215,28 @@ export function FilesTab() { [dirs, loadDir], ); - const openFile = useCallback(async (rel: string, mimeType?: string) => { - setSelected(rel); - setSelectedMimeType(mimeType); - setFile(null); - setFileError(false); - try { - setFile(await api.fsRead(rel, mimeType)); - } catch { - setFileError(true); - } - }, []); + const openFile = useCallback( + async (rel: string, mimeType?: string, position?: { line?: number; column?: number }) => { + setSelected(rel); + setSelectedMimeType(mimeType); + setFile(null); + setFileError(false); + try { + setFile(await api.fsRead(rel, mimeType)); + if (position?.line != null) { + requestAnimationFrame(() => { + const lineNode = document.querySelector( + `.work-files-preview [data-line="${position.line}"]`, + ); + lineNode?.scrollIntoView({ block: "center" }); + }); + } + } catch { + setFileError(true); + } + }, + [], + ); const openMp4 = useCallback(async () => { if (!selected) return; @@ -260,7 +271,10 @@ export function FilesTab() { setExpanded((prev) => new Set([...prev, ...ancestors])); for (const dir of ancestors) void loadDir(dir); } - void openFile(path, fileRequest.mimeType); + void openFile(path, fileRequest.mimeType, { + line: fileRequest.line, + column: fileRequest.column, + }); }, [fileRequest, root, loadDir, openFile]); const renderDir = (rel: string, depth: number): React.ReactNode => { diff --git a/apps/desktop/src/features/chat/transcript/shared.tsx b/apps/desktop/src/features/chat/transcript/shared.tsx index 91d82f7c8..a89d7428c 100644 --- a/apps/desktop/src/features/chat/transcript/shared.tsx +++ b/apps/desktop/src/features/chat/transcript/shared.tsx @@ -418,13 +418,22 @@ export function FileRefChip({ kind, mimeType, onOpen, + line, + column, ...position }: { name: string; path: string; kind?: "image" | "file"; mimeType?: string; - onOpen: (path: string, baseDir?: string, mimeType?: string) => void; + onOpen: ( + path: string, + baseDir?: string, + mimeType?: string, + position?: { line?: number; column?: number }, + ) => void; + line?: number; + column?: number; } & SourcePositionProps) { const { t } = useTranslation(); const Icon = fileChipIcon(name, kind); @@ -438,7 +447,7 @@ export function FileRefChip({ {...position} title={`${html ? t("chat.previewUrl") : t("chat.openFile")} — ${path}`} aria-label={`${name} — ${path}`} - onClick={() => onOpen(path, undefined, mimeType)} + onClick={() => onOpen(path, undefined, mimeType, { line, column })} onContextMenu={(event) => openFileMenu(event, { path })} > @@ -515,6 +524,8 @@ export function LinkifiedText({ text, attachments }: { text: string; attachments key={index} name={segment.label} path={segment.target.path} + line={segment.target.line} + column={segment.target.column} onOpen={openFileRef} {...position} /> diff --git a/apps/desktop/src/hooks/use-preview-target.ts b/apps/desktop/src/hooks/use-preview-target.ts index 7f6839289..4bfc5f74e 100644 --- a/apps/desktop/src/hooks/use-preview-target.ts +++ b/apps/desktop/src/hooks/use-preview-target.ts @@ -27,7 +27,12 @@ export function useOpenPreviewTarget() { const openFileRef = useOpenChatFileRef(); return useCallback( (target: ChatPreviewTarget) => - target.kind === "file" ? openFileRef(target.path) : openHttpUrl(target.url), + target.kind === "file" + ? openFileRef(target.path, undefined, undefined, { + line: target.line, + column: target.column, + }) + : openHttpUrl(target.url), [openFileRef], ); } @@ -144,7 +149,14 @@ export function useOpenChatFileRef() { ); return useCallback( - (path: string, baseDir?: string, mimeType?: string) => { + ( + path: string, + baseDir?: string, + mimeType?: string, + position?: { line?: number; column?: number }, + ) => { + const line = position?.line; + const column = position?.column; void (async () => { const resolved = await resolveRef(path, baseDir); if (!resolved) return; @@ -158,10 +170,12 @@ export function useOpenChatFileRef() { return; } if (resolved.inProject && fileViewAvailable) { - openTab(fileManagerPluginTab(resolved.path)); + // Thread path:line into the plugin tab so the bundled file view can + // scroll to the line (#681 / #1149 review). Host fallback uses openFile. + openTab(fileManagerPluginTab(resolved.path, { line, column })); return; } - openFile(resolved.path, mimeType); + openFile(resolved.path, mimeType, { line, column }); })(); }, [fileViewAvailable, openFile, openTab, openUrl, resolveRef], diff --git a/apps/desktop/src/lib/chat-links.ts b/apps/desktop/src/lib/chat-links.ts index 8dc8e2bc6..8a97cc47a 100644 --- a/apps/desktop/src/lib/chat-links.ts +++ b/apps/desktop/src/lib/chat-links.ts @@ -55,6 +55,20 @@ function stripLineRef(path: string): string { return path.replace(/:\d+(?::\d+)?$/, ""); } +/** Trailing `:line[:col]` on a file token, if any. */ +export function parseFileRefPosition( + text: string, +): { line: number; column?: number } | null { + const match = text.trim().match(/:(\d+)(?::(\d+))?$/); + if (!match) return null; + const line = Number(match[1]); + if (!Number.isFinite(line) || line < 1) return null; + const column = match[2] !== undefined ? Number(match[2]) : undefined; + return column !== undefined && Number.isFinite(column) && column >= 1 + ? { line, column } + : { line }; +} + function leafName(path: string): string { const normalized = path.replaceAll("\\", "/").replace(/\/+$/, ""); return normalized.slice(normalized.lastIndexOf("/") + 1) || path; @@ -182,7 +196,7 @@ export function toWorkspaceRel( } export type ChatPreviewTarget = - | { kind: "file"; path: string } + | { kind: "file"; path: string; line?: number; column?: number } | { kind: "url"; url: string }; /** Resolve one raw chat token into a previewable target, or null. */ @@ -195,15 +209,19 @@ export function resolvePreviewTarget( if (isHttpUrl(trimmed)) return { kind: "url", url: trimmed }; const at = unwrapAtFileRef(trimmed); if (at) { - // Scratch/attachment @refs stay absolute so fs/open can contain them. - if (at.startsWith("/")) return { kind: "file", path: at }; - const rel = toWorkspaceRel(at, root, baseDir); - return rel ? { kind: "file", path: rel } : null; + const position = parseFileRefPosition(at); + const cleaned = stripLineRef(at); + if (cleaned.startsWith("/")) { + return { kind: "file", path: cleaned, ...(position ?? {}) }; + } + const rel = toWorkspaceRel(cleaned, root, baseDir); + return rel ? { kind: "file", path: rel, ...(position ?? {}) } : null; } const file = parseFileRef(trimmed); if (!file) return null; const rel = toWorkspaceRel(file, root, baseDir); - return rel ? { kind: "file", path: rel } : null; + const position = parseFileRefPosition(trimmed); + return rel ? { kind: "file", path: rel, ...(position ?? {}) } : null; } /** Tool-call args → preview target (Read/Write/Edit paths, fetch URLs). */ diff --git a/apps/desktop/src/lib/work-panel-tabs.ts b/apps/desktop/src/lib/work-panel-tabs.ts index 2e57d28de..3fc3e240a 100644 --- a/apps/desktop/src/lib/work-panel-tabs.ts +++ b/apps/desktop/src/lib/work-panel-tabs.ts @@ -15,6 +15,9 @@ export type WorkPanelTab = { location?: string; /** Stored attachment mimeType for extension-less `attachments/` images. */ mimeType?: string; + /** Optional 1-based line/column for host file tabs (chat `path:line` refs). */ + line?: number; + column?: number; }; export type WorkPanelTabsState = { @@ -24,7 +27,13 @@ export type WorkPanelTabsState = { export type WorkPanelContext = WorkPanelTabsState & { open: boolean; - fileRequest: { path: string; seq: number; mimeType?: string } | null; + fileRequest: { + path: string; + seq: number; + mimeType?: string; + line?: number; + column?: number; + } | null; }; let newWorkPanelTabSequence = 0; @@ -145,14 +154,19 @@ export const FILE_MANAGER_PLUGIN_TAB = { viewId: "manager", } as const; -/** The file view, asked to show one file. */ -export function fileManagerPluginTab(location: string): WorkPanelTab { +/** The file view, asked to show one file (optional chat `path:line` position). */ +export function fileManagerPluginTab( + location: string, + position?: { line?: number; column?: number }, +): WorkPanelTab { return { ...pluginWorkPanelTab( FILE_MANAGER_PLUGIN_TAB.pluginId, FILE_MANAGER_PLUGIN_TAB.viewId, ), location, + ...(position?.line != null ? { line: position.line } : {}), + ...(position?.column != null ? { column: position.column } : {}), }; } @@ -267,13 +281,19 @@ export function normalizeWorkPanelFilePath(path: string): string { return absolute ? `/${normalized}` : normalized; } -export function fileWorkPanelTab(path: string, mimeType?: string): WorkPanelTab { +export function fileWorkPanelTab( + path: string, + mimeType?: string, + position?: { line?: number; column?: number }, +): WorkPanelTab { const resource = normalizeWorkPanelFilePath(path); return { id: `file:${resource}`, kind: "file", resource, ...(mimeType ? { mimeType } : {}), + ...(position?.line != null ? { line: position.line } : {}), + ...(position?.column != null ? { column: position.column } : {}), }; } diff --git a/apps/desktop/src/stores/app-state.ts b/apps/desktop/src/stores/app-state.ts index bc238da1c..9481ed840 100644 --- a/apps/desktop/src/stores/app-state.ts +++ b/apps/desktop/src/stores/app-state.ts @@ -357,7 +357,13 @@ export type AppState = { workPanelContexts: Record; workPanelWidth: number; /** Chat-initiated "preview this file" request consumed by the files viewer. */ - workPanelFileRequest: { path: string; seq: number; mimeType?: string } | null; + workPanelFileRequest: { + path: string; + seq: number; + mimeType?: string; + line?: number; + column?: number; + } | null; /** Open (or activate) the transcript tab of one delegated subagent. */ openSubagentTab: (delegationId: string, agentName?: string) => void; /** Abort one session's running turn, visible or not. */ @@ -381,7 +387,11 @@ export type AppState = { /** Hide the visible panel while retaining its session-owned context. */ resetWorkPanelContext: () => void; setWorkPanelWidth: (width: number) => void; - openFileInWorkPanel: (path: string, mimeType?: string) => void; + openFileInWorkPanel: ( + path: string, + mimeType?: string, + position?: { line?: number; column?: number }, + ) => void; openUrlInWorkPanel: (url: string) => void; updateBrowserWorkPanelTab: (state: BrowserState) => void; }; diff --git a/apps/desktop/src/stores/slices/work-panel-slice.ts b/apps/desktop/src/stores/slices/work-panel-slice.ts index 9f3afc9a5..cb19d4ffa 100644 --- a/apps/desktop/src/stores/slices/work-panel-slice.ts +++ b/apps/desktop/src/stores/slices/work-panel-slice.ts @@ -379,8 +379,8 @@ export function createWorkPanelSlice({ saveWorkPanelWidth(get().workPanelWidth); }, - openFileInWorkPanel: (path, mimeType) => { - get().openWorkPanelTab(fileWorkPanelTab(path, mimeType)); + openFileInWorkPanel: (path, mimeType, position) => { + get().openWorkPanelTab(fileWorkPanelTab(path, mimeType, position)); }, updateBrowserWorkPanelTab: (event) => { const sessionId = event.sessionId; diff --git a/apps/desktop/test/chat-links.test.mjs b/apps/desktop/test/chat-links.test.mjs index baa302d98..ea36c41c8 100644 --- a/apps/desktop/test/chat-links.test.mjs +++ b/apps/desktop/test/chat-links.test.mjs @@ -6,6 +6,7 @@ import { isHttpUrl, linkifyMdastTree, parseFileRef, + parseFileRefPosition, remarkChatFileLinks, resolvePreviewTarget, splitChatText, @@ -91,6 +92,7 @@ test("resolvePreviewTarget classifies urls and workspace files", () => { assert.deepEqual(resolvePreviewTarget("src/a.ts:10", ROOT), { kind: "file", path: "src/a.ts", + line: 10, }); assert.deepEqual(resolvePreviewTarget("./README.md", ROOT, "docs"), { kind: "file", @@ -350,3 +352,24 @@ test("adjacent parenthesis-wrapped URLs all remain independently linkable", () = assert.equal(segments.filter(s => s.kind === "target").length, 1000); assert.equal(segments.map(s => s.text).join(""), source); }); + +test("parseFileRefPosition keeps :line[:col] that parseFileRef strips", () => { + assert.deepEqual(parseFileRefPosition("src/main.rs:42"), { line: 42 }); + assert.deepEqual(parseFileRefPosition("src/main.rs:42:7"), { line: 42, column: 7 }); + assert.equal(parseFileRefPosition("src/main.rs"), null); + assert.equal(parseFileRefPosition("src/main.rs:0"), null); +}); + +test("resolvePreviewTarget carries line/col on file chips (#681)", () => { + assert.deepEqual(resolvePreviewTarget("src/a.ts:42", ROOT), { + kind: "file", + path: "src/a.ts", + line: 42, + }); + assert.deepEqual(resolvePreviewTarget("src/a.ts:42:7", ROOT), { + kind: "file", + path: "src/a.ts", + line: 42, + column: 7, + }); +}); diff --git a/apps/desktop/test/transcript-file-chips.test.mjs b/apps/desktop/test/transcript-file-chips.test.mjs index cd37a0945..75d27bd5f 100644 --- a/apps/desktop/test/transcript-file-chips.test.mjs +++ b/apps/desktop/test/transcript-file-chips.test.mjs @@ -25,7 +25,7 @@ test("sent user-message file refs render as composer-like chips", () => { assert.match(transcript, /composer-chip-name/); assert.match(styles, /\.chat-file-chip[\s\S]*?appearance: none/); assert.match(transcript, /mimeType=\{attachment\.mimeType\}/); - assert.match(transcript, /onOpen\(path, undefined, mimeType\)/); + assert.match(transcript, /onOpen\(path, undefined, mimeType, \{ line, column \}\)/); }); test("a file chip is routed by where the reference resolved, never optimistically", () => { @@ -47,7 +47,7 @@ test("a file chip is routed by where the reference resolved, never optimisticall // A project file prefers the bundled file view; without that plugin the // host file tab is the same surface this hook used before. assert.match(hook, /FILE_MANAGER_PLUGIN_TAB/); - assert.match(hook, /fileManagerPluginTab\(resolved\.path\)/); + assert.match(hook, /fileManagerPluginTab\(resolved\.path, \{ line, column \}\)/); // Session scratch and attachment files live outside the plugin's project // roots, so completion hands them back as an absolute path. assert.match(hook, /inProject: false/);