diff --git a/apps/desktop/src/components/workpanel/FilesTab.tsx b/apps/desktop/src/components/workpanel/FilesTab.tsx index b73418a9e..5f5418231 100644 --- a/apps/desktop/src/components/workpanel/FilesTab.tsx +++ b/apps/desktop/src/components/workpanel/FilesTab.tsx @@ -126,7 +126,7 @@ function HighlightedText({ path, content }: { path: string; content: string }) {
       {tokens
         ? tokens.tokens.map((row, i) => (
-            
+
{row.length === 0 ? "\n" : row.map((token, j) => ( @@ -137,7 +137,7 @@ function HighlightedText({ path, content }: { path: string; content: string }) {
)) : visible.split("\n").map((line, i) => ( -
+
{line || "\n"}
))} @@ -163,8 +163,10 @@ export function FilesTab() { const [expanded, setExpanded] = useState>(new Set()); const [selected, setSelected] = useState(null); const [selectedMimeType, setSelectedMimeType] = useState(); + const [selectedLine, setSelectedLine] = useState(); const [file, setFile] = useState(null); const [fileError, setFileError] = useState(false); + const viewerBodyRef = useRef(null); // Workspace switches reset all browsing state. Guarded so it only fires on // an actual root change: an unconditional [root] effect also runs on the @@ -178,6 +180,7 @@ export function FilesTab() { setExpanded(new Set()); setSelected(null); setSelectedMimeType(undefined); + setSelectedLine(undefined); setFile(null); setFileError(false); }, [root]); @@ -215,17 +218,32 @@ 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); + setSelectedLine(position?.line); + setFile(null); + setFileError(false); + try { + setFile(await api.fsRead(rel, mimeType)); + } catch { + setFileError(true); + } + }, + [], + ); + + useEffect(() => { + if (!file || selectedLine == null) return; + const frame = requestAnimationFrame(() => { + const lineNode = viewerBodyRef.current?.querySelector( + `[data-line="${selectedLine}"]`, + ); + lineNode?.scrollIntoView({ block: "center" }); + }); + return () => cancelAnimationFrame(frame); + }, [file, selectedLine]); const openMp4 = useCallback(async () => { if (!selected) return; @@ -260,7 +278,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 => { @@ -337,6 +358,7 @@ export function FilesTab() { onClick={() => { setSelected(null); setSelectedMimeType(undefined); + setSelectedLine(undefined); setFile(null); }} > @@ -356,12 +378,15 @@ export function FilesTab() {
-
+
{fileError ? ( ) : !file ? (
{t("panel.files.loading")}
- ) : file.kind === "text" && isMarkdownPath(selected) ? ( + ) : + file.kind === "text" && + isMarkdownPath(selected) && + selectedLine === undefined ? (
diff --git a/apps/desktop/src/features/chat/transcript/shared.tsx b/apps/desktop/src/features/chat/transcript/shared.tsx index 431bcf198..fa227cb8f 100644 --- a/apps/desktop/src/features/chat/transcript/shared.tsx +++ b/apps/desktop/src/features/chat/transcript/shared.tsx @@ -423,13 +423,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); @@ -443,7 +452,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 })} > @@ -556,6 +565,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 2cbac818b..633a2e62f 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], ); } @@ -131,10 +136,13 @@ function useResolveChatFileRef() { * Open a file reference the conversation mentioned. * * A workspace `.html` page in the primary folder stays with the side browser - * (ADR 0163): it is a page to run, not a file to read. A project file opens in - * the bundled file view when that view is installed and on the host file tab - * otherwise; scratch and attachment files live outside the project and always - * take the host file tab. + * (ADR 0163): it is a page to run, not a file to read. A plain project file + * opens in the bundled file view when available; a positioned `path:line` + * reference uses the host file tab, which can scroll to the requested line. + * The plugin view accepts opaque path locations and has no line-navigation + * contract, so positioned references keep their path unchanged and use the + * host viewer's existing scroll support. Scratch and attachment files also use + * the host file tab. */ export function useOpenChatFileRef() { const resolveRef = useResolveChatFileRef(); @@ -149,11 +157,20 @@ 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; + const hasPosition = line !== undefined || column !== undefined; if ( + !hasPosition && resolved.inProject && resolved.primary && resolved.relativePath && @@ -162,11 +179,15 @@ export function useOpenChatFileRef() { openUrl(resolved.relativePath); return; } - if (resolved.inProject && fileViewAvailable) { + if ( + resolved.inProject && + fileViewAvailable && + !hasPosition + ) { openTab(fileManagerPluginTab(resolved.path)); 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 d2c412d87..02a39b9df 100644 --- a/apps/desktop/src/lib/chat-links.ts +++ b/apps/desktop/src/lib/chat-links.ts @@ -52,6 +52,21 @@ 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 token = text.trim().replace(/[.,!?;:,。!?;:]+$/u, ""); + const match = token.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; @@ -188,7 +203,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. */ @@ -199,17 +214,24 @@ export function resolvePreviewTarget( ): ChatPreviewTarget | null { const trimmed = text.trim(); if (isHttpUrl(trimmed)) return { kind: "url", url: trimmed }; - const at = unwrapAtFileRef(trimmed); + const position = parseFileRefPosition(trimmed); + const pathText = position + ? trimmed.replace(/[.,!?;:,。!?;:]+$/u, "") + : trimmed; + const at = unwrapAtFileRef(pathText); if (at) { - if (isAbsoluteFilePath(at)) return { kind: "file", path: at }; - const rel = toWorkspaceRel(at, root, baseDir); - return rel ? { kind: "file", path: rel } : null; + const cleaned = stripLineRef(at); + if (isAbsoluteFilePath(cleaned)) { + return { kind: "file", path: cleaned, ...(position ?? {}) }; + } + const rel = toWorkspaceRel(cleaned, root, baseDir); + return rel ? { kind: "file", path: rel, ...(position ?? {}) } : null; } - const file = parseFileRef(trimmed); + const file = parseFileRef(pathText); if (!file) return null; - if (isAbsoluteFilePath(file)) return { kind: "file", path: file }; + if (isAbsoluteFilePath(file)) return { kind: "file", path: file, ...(position ?? {}) }; const rel = toWorkspaceRel(file, root, baseDir); - return rel ? { kind: "file", path: rel } : null; + 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..dfa6b65b1 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,9 +27,30 @@ 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; }; +/** Build the file-view request represented by an active file tab. */ +export function createWorkPanelFileRequest( + tab: WorkPanelTab, + seq: number, +): WorkPanelContext["fileRequest"] { + if (tab.kind !== "file" || !tab.resource) return null; + return { + path: tab.resource, + seq, + ...(tab.mimeType ? { mimeType: tab.mimeType } : {}), + ...(tab.line != null ? { line: tab.line } : {}), + ...(tab.column != null ? { column: tab.column } : {}), + }; +} + let newWorkPanelTabSequence = 0; export function emptyWorkPanelContext(): WorkPanelContext { @@ -267,13 +291,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 a2103ea17..b509f07cb 100644 --- a/apps/desktop/src/stores/app-state.ts +++ b/apps/desktop/src/stores/app-state.ts @@ -368,7 +368,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. */ @@ -392,7 +398,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..2020c880c 100644 --- a/apps/desktop/src/stores/slices/work-panel-slice.ts +++ b/apps/desktop/src/stores/slices/work-panel-slice.ts @@ -4,6 +4,7 @@ import { browserPluginTab, browserTabLabel, closeWorkPanelTabState, + createWorkPanelFileRequest, emptyWorkPanelContext, fileWorkPanelTab, newWorkPanelTab, @@ -14,6 +15,7 @@ import { subagentWorkPanelTab, switchWorkPanelContextState, type WorkPanelContext, + type WorkPanelTab, } from "../../lib/work-panel-tabs"; import { WORK_PANEL_COMPACT_MIN_WIDTH, @@ -116,6 +118,10 @@ export function createWorkPanelSlice({ | "updateBrowserWorkPanelTab" > { let workPanelFileRequestSeq = 0; + const nextFileRequest = (tab?: WorkPanelTab): WorkPanelContext["fileRequest"] => { + if (!tab || tab.kind !== "file" || !tab.resource) return null; + return createWorkPanelFileRequest(tab, ++workPanelFileRequestSeq); + }; return { openSubagentTab: (delegationId, agentName) => { @@ -163,14 +169,7 @@ export function createWorkPanelSlice({ }, tab, ); - const fileRequest = - tab.kind === "file" && tab.resource - ? { - path: tab.resource, - seq: ++workPanelFileRequestSeq, - ...(tab.mimeType ? { mimeType: tab.mimeType } : {}), - } - : context.fileRequest; + const fileRequest = nextFileRequest(tab) ?? context.fileRequest; const nextContext: WorkPanelContext = { open: true, tabs: next.tabs, @@ -216,14 +215,7 @@ export function createWorkPanelSlice({ tab, ); const activeTab = next.tabs.find((item) => item.id === next.activeTabId); - const fileRequest = - activeTab?.kind === "file" && activeTab.resource - ? { - path: activeTab.resource, - seq: ++workPanelFileRequestSeq, - ...(activeTab.mimeType ? { mimeType: activeTab.mimeType } : {}), - } - : state.workPanelFileRequest; + const fileRequest = nextFileRequest(activeTab) ?? state.workPanelFileRequest; const nextContext: WorkPanelContext = { open: true, tabs: next.tabs, @@ -254,14 +246,7 @@ export function createWorkPanelSlice({ tabId, ); const activeTab = next.tabs.find((tab) => tab.id === next.activeTabId); - const fileRequest = - activeTab?.kind === "file" && activeTab.resource - ? { - path: activeTab.resource, - seq: ++workPanelFileRequestSeq, - ...(activeTab.mimeType ? { mimeType: activeTab.mimeType } : {}), - } - : state.workPanelFileRequest; + const fileRequest = nextFileRequest(activeTab) ?? state.workPanelFileRequest; const nextContext: WorkPanelContext = { open: state.workPanelOpen, tabs: next.tabs, @@ -325,14 +310,7 @@ export function createWorkPanelSlice({ tabId, ); const activeTab = next.tabs.find((tab) => tab.id === next.activeTabId); - const fileRequest = - activeTab?.kind === "file" && activeTab.resource - ? { - path: activeTab.resource, - seq: ++workPanelFileRequestSeq, - ...(activeTab.mimeType ? { mimeType: activeTab.mimeType } : {}), - } - : state.workPanelFileRequest; + const fileRequest = nextFileRequest(activeTab) ?? state.workPanelFileRequest; const nextContext: WorkPanelContext = { // Closing the final tab leaves the panel open so the user can choose // another tool from the new-tab launcher instead of losing the dock. @@ -379,8 +357,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 b9de2110c..ad23dd4df 100644 --- a/apps/desktop/test/chat-links.test.mjs +++ b/apps/desktop/test/chat-links.test.mjs @@ -9,6 +9,7 @@ import { isHttpUrl, linkifyMdastTree, parseFileRef, + parseFileRefPosition, remarkChatFileLinks, resolvePreviewTarget, splitChatText, @@ -107,6 +108,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", @@ -729,3 +731,38 @@ 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.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, + }); + assert.deepEqual(resolvePreviewTarget("src/a.ts:42:7.", ROOT), { + kind: "file", + path: "src/a.ts", + line: 42, + column: 7, + }); + assert.deepEqual(resolvePreviewTarget(`${ROOT}/src/a.ts:42:7`, ROOT), { + kind: "file", + path: `${ROOT}/src/a.ts`, + line: 42, + column: 7, + }); +}); diff --git a/apps/desktop/test/open-http-url.test.mjs b/apps/desktop/test/open-http-url.test.mjs index 5afb76ce1..92f5475f3 100644 --- a/apps/desktop/test/open-http-url.test.mjs +++ b/apps/desktop/test/open-http-url.test.mjs @@ -164,7 +164,7 @@ test("previewable transcript URLs follow the link-open setting", () => { assert.match(previewSource, /import \{ openHttpUrl \} from "\.\.\/lib\/open-http-url"/); assert.match( previewSource, - /target\.kind === "file" \? openFileRef\(target\.path\) : openHttpUrl\(target\.url\)/, + /target\.kind === "file"\s*\?\s*openFileRef\(target\.path,[\s\S]*?\)\s*:\s*openHttpUrl\(target\.url\)/, ); }); diff --git a/apps/desktop/test/transcript-file-chips.test.mjs b/apps/desktop/test/transcript-file-chips.test.mjs index cd37a0945..ae133e087 100644 --- a/apps/desktop/test/transcript-file-chips.test.mjs +++ b/apps/desktop/test/transcript-file-chips.test.mjs @@ -8,13 +8,14 @@ import test from "node:test"; const read = (path) => readFile(new URL(path, import.meta.url), "utf8"); -const [transcript, styles, hook, api, toolDetails, toolRow] = await Promise.all([ +const [transcript, styles, hook, api, toolDetails, toolRow, filesTab] = await Promise.all([ readTranscriptSource(), read("../src/styles/chat-links.css"), read("../src/hooks/use-preview-target.ts"), read("../src/lib/api.ts"), read("../src/components/ToolDetails.tsx"), readTranscriptModule("ToolRow.tsx"), + read("../src/components/workpanel/FilesTab.tsx"), ]); test("sent user-message file refs render as composer-like chips", () => { @@ -25,7 +26,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", () => { @@ -44,14 +45,23 @@ test("a file chip is routed by where the reference resolved, never optimisticall assert.match(hook, /resolved\.primary &&/); assert.match(hook, /isHtmlFilePath\(resolved\.relativePath\)/); assert.match(hook, /openUrl\(resolved\.relativePath\)/); - // A project file prefers the bundled file view; without that plugin the - // host file tab is the same surface this hook used before. + // Plain project files prefer the bundled file view. A path:line reference + // uses the host file tab because the bundled view has no line-navigation API. assert.match(hook, /FILE_MANAGER_PLUGIN_TAB/); + assert.match(hook, /const hasPosition = line !== undefined \|\| column !== undefined/); + assert.match(hook, /!hasPosition[\s\S]*?isHtmlFilePath/); + assert.match(hook, /!hasPosition[\s\S]*?fileViewAvailable/); assert.match(hook, /fileManagerPluginTab\(resolved\.path\)/); + assert.match(filesTab, /data-line=\{i \+ 1\}/); + assert.match(filesTab, /viewerBodyRef\.current\?\.querySelector/); + assert.match(filesTab, /if \(!file \|\| selectedLine == null\) return/); + assert.match(filesTab, /data-line="\$\{selectedLine\}"/); + assert.match(filesTab, /return \(\) => cancelAnimationFrame\(frame\)/); + assert.match(filesTab, /selectedLine === undefined/); // Session scratch and attachment files live outside the plugin's project - // roots, so completion hands them back as an absolute path. + // roots, and positioned references need the host viewer's line navigation. assert.match(hook, /inProject: false/); - assert.match(hook, /openFile\(resolved\.path, mimeType\)/); + assert.match(hook, /openFile\(resolved\.path, mimeType, \{ line, column \}\)/); // The OS handoff is no longer what a chat click does; the channel itself // stays part of the public IPC surface. assert.doesNotMatch(hook, /api\.fsOpen\(/); @@ -65,10 +75,8 @@ test("a tool row and a tool result row open a file where the message body does", // row land in the bundled file view too (ADR 0262). The call this replaces is // the one that let those surfaces pick the destination themselves. assert.match(hook, /const openFileRef = useOpenChatFileRef\(\);/); - assert.match( - hook, - /target\.kind === "file" \? openFileRef\(target\.path\) : openHttpUrl\(target\.url\)/, - ); + assert.match(hook, /target\.kind === "file"[\s\S]*?openFileRef\(target\.path, undefined, undefined, \{/); + assert.match(hook, /line: target\.line,[\s\S]*?column: target\.column/); assert.doesNotMatch(hook, /openFile\(target\.path\)/); // Both surfaces still call that opener, and neither reaches the host viewer's // store action directly: the tool row summary carries the call's own path, diff --git a/apps/desktop/test/work-panel-tabs.test.mjs b/apps/desktop/test/work-panel-tabs.test.mjs index 98e1015e9..96fe3a9f9 100644 --- a/apps/desktop/test/work-panel-tabs.test.mjs +++ b/apps/desktop/test/work-panel-tabs.test.mjs @@ -6,6 +6,7 @@ const { activateWorkPanelTabState, browserPluginTab, closeWorkPanelTabState, + createWorkPanelFileRequest, emptyWorkPanelContext, fileWorkPanelTab, hasPluginView, @@ -84,6 +85,22 @@ test("file tabs normalize lexical paths and remain distinct by resource", () => assert.equal(normalizeWorkPanelFilePath("/repo/./src/../App.tsx"), "/repo/App.tsx"); }); +test("file-view requests preserve a positioned file tab", () => { + const tab = fileWorkPanelTab("src/App.tsx", "text/typescript", { + line: 65, + column: 4, + }); + + assert.deepEqual(createWorkPanelFileRequest(tab, 7), { + path: "src/App.tsx", + seq: 7, + mimeType: "text/typescript", + line: 65, + column: 4, + }); + assert.equal(createWorkPanelFileRequest(toolWorkPanelTab("review"), 8), null); +}); + test("closing the active tab selects its right neighbor then its left", () => { const state = { tabs: [ diff --git a/apps/desktop/test/work-panel.test.mjs b/apps/desktop/test/work-panel.test.mjs index 12d599bf8..432a146be 100644 --- a/apps/desktop/test/work-panel.test.mjs +++ b/apps/desktop/test/work-panel.test.mjs @@ -526,9 +526,13 @@ test("work panel context is retained by session instead of cleared on selection" test("file preview request ids stay unique across session contexts", () => { assert.match(storeSource, /let workPanelFileRequestSeq = 0/); + assert.match( + storeSource, + /const nextFileRequest = \(tab\?: WorkPanelTab\)[\s\S]*?createWorkPanelFileRequest\(tab, \+\+workPanelFileRequestSeq\)/, + ); assert.ok( - storeSource.match(/seq:\s*\+\+workPanelFileRequestSeq/g)?.length >= 3, - "open and activation paths must use the shared request sequence", + storeSource.match(/nextFileRequest\((?:tab|activeTab)\)/g)?.length === 4, + "open, replace, activation, and close paths must share the position-preserving request", ); assert.doesNotMatch(storeSource, /seq:\s*\([^)]*fileRequest\?\.seq[^)]*\) \+ 1/); }); diff --git a/docs/adr/0262-chat-file-refs-open-in-the-file-view.md b/docs/adr/0262-chat-file-refs-open-in-the-file-view.md index 98ef6b0f3..840af419a 100644 --- a/docs/adr/0262-chat-file-refs-open-in-the-file-view.md +++ b/docs/adr/0262-chat-file-refs-open-in-the-file-view.md @@ -48,13 +48,17 @@ work-panel tab already carries was honoured by main for `pi.browser` only. - a reference that matches nothing opens **nothing** and reports itself. No empty panel, no blank browser page. 2. **The destination follows where the reference resolved.** A project file - opens in the `pi.file-manager` view. A file in the session scratch or - attachment store opens in the host `file:` tab, because it lives outside that - view's project root — see point 4. A workspace `.html` / `.htm` file still - opens in the side browser (ADR 0163), for an agent reply and a user chip - alike: it is a page to run, not a file to read. When the file view is not - loaded, a project file falls back to the host `file:` tab, so the click never - regresses to nothing. + opens in the `pi.file-manager` view, except a `path:line` reference, which + opens in the host's read-only `file:` tab so the requested source line can + be brought into view. The bundled view accepts opaque path locations and has + no line-navigation contract; this keeps the path unchanged and uses the + host viewer's existing scroll support. A file in the session scratch or + attachment store opens in the + host `file:` tab, because it lives outside that view's project root — see + point 4. A workspace `.html` / `.htm` file still opens in the side browser + (ADR 0163), for an agent reply and a user chip alike: it is a page to run, + not a file to read. When the file view is not loaded, a project file falls + back to the host `file:` tab, so the click never regresses to nothing. 3. **A contributed view's `location` stops being browser-only.** It travels as the view entry URL's `piViewOpen` query parameter on creation — the only channel that cannot race a document that has not run yet — and as the diff --git a/docs/spec/04-ux/08-component-spec.md b/docs/spec/04-ux/08-component-spec.md index 903dc5cdd..07c2baf10 100644 --- a/docs/spec/04-ux/08-component-spec.md +++ b/docs/spec/04-ux/08-component-spec.md @@ -1537,6 +1537,10 @@ storage but compose into one assistant turn until the next user message. content's left rule is itself a pointer and keyboard-focusable collapse control. - Hover code block: copy button appears +- Click a transcript file reference with `:line[:column]`: resolve the file + first, then open it in the host `file:` tab and scroll the requested line into + view. Plain project-file references still prefer the bundled file view, whose + open contract does not carry a line position. - Hover or focus a minimap marker: show the localized sender and a bounded plaintext preview; multiple assistant fragments produced within one user turn are combined into one AI-response marker and preview; nearby markers @@ -3414,6 +3418,8 @@ Anatomy: in the bundled `pi.file-manager` view (the host `file:` tab when that view is unavailable), a session-scratch or attachment file in the host `file:` tab, and a `.html`/`.htm` page of the primary folder in the side browser. A + positioned `path:line` reference uses the host read-only `file:` tab and + scrolls the requested line into view. A primary-folder file is addressed to the view as a project-relative path and a sibling-folder file as an absolute one, which is also how scratch and attachment files are addressed. A reference that matches no file opens nothing diff --git a/docs/spec/06-delivery/04-e2e-test-plan.md b/docs/spec/06-delivery/04-e2e-test-plan.md index c0efd920a..de013f2b5 100644 --- a/docs/spec/06-delivery/04-e2e-test-plan.md +++ b/docs/spec/06-delivery/04-e2e-test-plan.md @@ -8789,16 +8789,20 @@ must keep splitting are covered by `markdown-blocks.test.mjs`. 6. Click a project file path in the conversation. Confirm it opens in this view on that file — a chat click now prefers the file view over the host `file:` tab. - 7. Disable the File Manager plugin. Confirm the view disappears from the menu + 7. Click a conversation reference to `src/example.ts:42`. Confirm the host + `file:` tab opens at line 42 even though the File Manager plugin is enabled. + 8. Disable the File Manager plugin. Confirm the view disappears from the menu and the panel, and that a clicked conversation file path falls back to the host `file:` tab under Open resources. - 8. Re-enable it, then restart the app. Confirm the enabled state and the tree + 9. Re-enable it, then restart the app. Confirm the enabled state and the tree return, and that the registry did not gain a duplicate row. - **Expected**: A panel surface runs entirely on the public plugin contribution channel, is user-disableable, cannot be uninstalled, and survives restart. Its host-mediated actions obey the declared `fs.read` scope, and its own reads and writes stay inside the jail of the one project folder it is browsing - (ADR 0241, ADR 0263). + (ADR 0241, ADR 0263). Plain project-file links open in the bundled view; a + positioned `path:line` reference opens the host file tab and scrolls the + requested line even while the plugin view is available. - **Specs linked**: `07-plugins/03-plugin-api.md` §3, `07-plugins/13-plugin-permissions-matrix.md` §2, `04-ux/08-component-spec.md` §5, ADR 0104, ADR 0109, ADR 0111, @@ -16383,6 +16387,24 @@ renderer's durable transcript reads. No real model or provider is contacted. separate release qualification. No MCP, Codemode or virtual-router migration is included. See `docs/project/pi-0991-adoption.md` for candidate evidence. +#### E2E-262: Transcript path:line opens and scrolls the host file viewer + +- **Preconditions:** Isolated Electron/Chromium, an active workspace and session, + the bundled file-manager view available, and deterministic filesystem IPC + fixtures. +- **Steps:** Render a real transcript `path:line:column` reference followed by + sentence punctuation; click its verified file chip and wait for the host file + viewer to load. +- **Expected:** Despite the bundled file-manager view being available, the + positioned reference opens in the host's read-only file tab. The file request + retains its line and column, and the viewer scrolls the requested line into + the visible center area. The path and file contents remain the same as the + reference target. +- **Specs:** `04-ux/08` §11.8; ADR 0262. +- **Status:** `node scripts/e2e-file-ref-line-scroll.mjs` mounts production + `LinkifiedText` and `FilesTab` in isolated Electron with filesystem IPC + fixtures; no real project files or provider are used. + ### Imported-extension GUI executable discovery regression (#1173) Start dependency installation with a GUI-like PATH that cannot resolve Node/npm diff --git a/docs/zh-CN/spec/04-ux/08-component-spec.md b/docs/zh-CN/spec/04-ux/08-component-spec.md index cc9b60205..035ed757c 100644 --- a/docs/zh-CN/spec/04-ux/08-component-spec.md +++ b/docs/zh-CN/spec/04-ux/08-component-spec.md @@ -2340,6 +2340,8 @@ Live Voice 受现有开发者模式和开发构建门控约束,包含四种展 - 发送的模板调用在记录中呈现为等宽命令 来自消息的 `command` 字段的芯片而不是扩展的正文。 - 已发送的 `@path` 文件引用(带引号或不带引号)画成与草稿相同的叶子名芯片。点击芯片先经 `pi-desktop/fs/resolveRef` 补全引用——搜索整个打开的项目,按项目组文件夹顺序、主文件夹优先(ADR 0263)——再按解析结果打开:项目文件在随应用打包的 `pi.file-manager` 视图中打开(该视图不可用时退回宿主 `file:` 选项卡),会话临时目录或附件文件在宿主 `file:` 选项卡中打开,主文件夹中的 `.html`/`.htm` 在侧边浏览器打开。交给该视图的地址跟随应答的文件夹:主文件夹中的文件用项目内相对路径传递,同一项目的同级文件夹中的文件用绝对路径传递,与会话临时目录和附件文件一致。什么都没匹配到时既不打开任何东西,也会自己报告出来,右键该芯片也一样;引用菜单除了在系统文件管理器中显示该文件,还提供复制完整地址与复制相对地址,发送的 `@path` 芯片、消息 Markdown 中的行内代码、本地链接与本地图片、工具行自己的文件路径、工具结果的文件列表或匹配列表中的路径、图片附件缩略图都提供同样的几项,并同样经这次补全与这套寻址规则;项目外的文件没有相对地址,会直接说明。系统默认应用不再由这次点击触发。HTTP(S) URL 仍是侧边浏览器的文本链接。 +带行号的 `path:line` 引用使用宿主只读 `file:` 选项卡,并将目标行滚动到可见区域。 + - 状态:键盘活动行使用共享 `kb-active` 处理;空的 查询列出所有内容(斜杠)/最近索引的顺序(文件);零 匹配呈现本地化的空行并且菜单计为关闭 diff --git a/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md b/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md index d2f08da03..abaecc5f1 100644 --- a/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md +++ b/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md @@ -9270,6 +9270,14 @@ the latest destination. These assertions measure work counts, not device FPS. - **里程碑:** M6+ - **状态:** 单测和源码契约覆盖(`update-cache.test.mjs`、`auto-update.test.mjs`);仍需 Windows 安装器/E2E 验证。 +#### E2E-262:聊天 path:line 引用打开文件并滚动到目标行 + +- **前提:** 隔离 Electron/Chromium、活动工作区和会话、可用的随应用打包文件管理器视图,以及确定性的文件系统 IPC fixture。 +- **步骤:** 渲染真实聊天中的 `path:line:column` 引用并在末尾加句末标点;点击已验证的文件芯片,等待宿主文件查看器加载。 +- **预期:** 即使文件管理器视图可用,带位置的引用仍打开宿主只读文件选项卡。文件请求保留行列号,查看器滚动到视口中间的目标行;打开路径和文件内容与引用目标一致。 +- **规格:** `04-ux/08` §11.8;ADR 0262。 +- **状态:** `node scripts/e2e-file-ref-line-scroll.mjs` 在隔离 Electron 中挂载生产 `LinkifiedText` 与 `FilesTab`,并通过文件系统 IPC fixture 提供文件内容;不访问真实项目文件或模型服务。 + ### 导入扩展时发现 GUI 环境下的可执行文件回归(#1173) 使用无法解析 Node/npm 的 GUI 风格 `PATH` 启动依赖安装,并在 `~/.local/bin` 准备可信 fixture 安装。 diff --git a/scripts/e2e-file-ref-line-scroll.mjs b/scripts/e2e-file-ref-line-scroll.mjs new file mode 100644 index 000000000..c0603c7df --- /dev/null +++ b/scripts/e2e-file-ref-line-scroll.mjs @@ -0,0 +1,97 @@ +#!/usr/bin/env node +/** Follow a real transcript path:line chip into the production host file viewer. */ +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { createRequire } from "node:module"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { repositoryRoot, resolveElectronBinary } from "./e2e/boot.mjs"; + +const root = repositoryRoot(); +const require = createRequire(join(root, "packages/agent-runtime/package.json")); +const { build } = require("esbuild"); +const temp = await mkdtemp(join(tmpdir(), "pi-file-ref-line-scroll-")); +try { + await build({ + entryPoints: [join(root, "scripts/e2e/file-ref-line-scroll.tsx")], + outfile: join(temp, "renderer.js"), + bundle: true, + platform: "browser", + format: "iife", + jsx: "automatic", + define: { "process.env.NODE_ENV": '"production"', "import.meta.env.DEV": "false" }, + loader: { ".css": "empty" }, + alias: { + "@pi-desktop/i18n": join(root, "packages/i18n/src/index.ts"), + react: join(root, "apps/desktop/node_modules/react"), + "react-dom": join(root, "apps/desktop/node_modules/react-dom"), + }, + nodePaths: [join(root, "apps/desktop/node_modules")], + }); + const viewerStyles = await readFile(join(root, "apps/desktop/src/styles/work-panel.css"), "utf8"); + await writeFile(join(temp, "work-panel.css"), `${viewerStyles}\nhtml,body,#root{height:100%;margin:0}#root{display:flex;flex-direction:column}.fixture-chat{flex:0 0 auto;padding:8px}`); + await writeFile( + join(temp, "index.html"), + '
', + ); + await writeFile( + join(temp, "main.cjs"), + ` +const { app, BrowserWindow } = require("electron"); +const path = require("node:path"); +app.disableHardwareAcceleration(); +app.setPath("userData", path.join(__dirname, "profile")); +app.whenReady().then(async () => { + const window = new BrowserWindow({ show: false, width: 900, height: 420, + webPreferences: { sandbox: true, contextIsolation: true, nodeIntegration: false, backgroundThrottling: false } }); + window.webContents.on("console-message", (event) => console.error(event.message)); + try { + await window.loadFile(path.join(__dirname, "index.html")); + const result = await window.webContents.executeJavaScript("window.fileRefLineScrollProbe()"); + console.log("FILE_REF_LINE_SCROLL " + JSON.stringify(result)); + app.exit(0); + } catch (error) { + console.error("FILE_REF_LINE_SCROLL " + (error?.stack ?? String(error))); + app.exit(1); + } +}); +`, + ); + const env = { ...process.env }; + delete env.ELECTRON_RUN_AS_NODE; + const child = spawn(resolveElectronBinary(root).electronBinary, [ + "--headless=new", + "--disable-gpu", + join(temp, "main.cjs"), + ], { env, stdio: ["ignore", "pipe", "pipe"] }); + let output = ""; + for (const stream of [child.stdout, child.stderr]) { + stream.on("data", (chunk) => { output += chunk; }); + } + const timeout = setTimeout(() => child.kill("SIGKILL"), 45_000); + let code; + try { + code = await new Promise((resolve, reject) => { + child.once("error", reject); + child.once("close", resolve); + }); + } finally { + clearTimeout(timeout); + } + const line = output.split(/\r?\n/).find((item) => item.startsWith("FILE_REF_LINE_SCROLL {")); + assert(line, `renderer returned no file reference result (exit=${code}): ${output.slice(-4000)}`); + const result = JSON.parse(line.slice("FILE_REF_LINE_SCROLL ".length)); + console.log(line); + assert.equal(code, 0, output.slice(-4000)); + assert.equal(result.fileManagerAvailable, true, "fixture must use the normal project-file setup"); + assert.deepEqual(result.resolveRefs, ["src/scroll-target.txt", "src/scroll-target.txt"]); + assert.deepEqual(result.readPaths, ["src/scroll-target.txt"]); + assert.equal(result.selectedPath, "src/scroll-target.txt"); + assert.deepEqual(result.request, { path: "src/scroll-target.txt", seq: 1, line: 65, column: 4 }); + assert.deepEqual(result.scrollCall, { line: "65", block: "center" }); + assert.ok(result.scrollTop > 0, "host file viewer did not scroll away from the top"); + assert.equal(result.targetLineVisible, true, "requested line is outside the visible viewer area"); +} finally { + await rm(temp, { recursive: true, force: true }); +} diff --git a/scripts/e2e/file-ref-line-scroll.tsx b/scripts/e2e/file-ref-line-scroll.tsx new file mode 100644 index 000000000..4ae88a117 --- /dev/null +++ b/scripts/e2e/file-ref-line-scroll.tsx @@ -0,0 +1,162 @@ +import { createRoot } from "react-dom/client"; +import { flushSync } from "react-dom"; +import { createInstance } from "i18next"; +import { I18nextProvider, initReactI18next } from "react-i18next"; +import { IPC } from "@pi-desktop/shared"; +import { catalogs, flattenCatalog } from "@pi-desktop/i18n"; +import { FilesTab } from "../../apps/desktop/src/components/workpanel/FilesTab"; +import { LinkifiedText } from "../../apps/desktop/src/features/chat/transcript/shared"; +import { useAppStore } from "../../apps/desktop/src/stores/app-store"; +import { splitChatText } from "../../apps/desktop/src/lib/chat-links"; + +const targetPath = "src/scroll-target.txt"; +const targetLine = 65; +const content = Array.from({ length: 90 }, (_, index) => `line ${index + 1}`).join("\n"); +const resolveRefs: string[] = []; +const readPaths: string[] = []; +const scrollCalls: Array<{ line: string | undefined; block?: string }> = []; + +window.piDesktop = { + platform: "darwin", + channels: IPC, + on: () => () => {}, + async invoke(channel, ...args) { + const input = args[0] as Record | undefined; + if (channel === IPC.invoke.fsResolveRef) { + const ref = String(input?.ref ?? ""); + resolveRefs.push(ref); + return { + ok: true, + data: { + match: { + root: "workspace", + relativePath: targetPath, + absolutePath: `/workspace/${targetPath}`, + matchedBy: "exact-relative", + projectRoot: { path: "/workspace", name: "Fixture", primary: true }, + }, + }, + }; + } + if (channel === IPC.invoke.fsList) { + return { ok: true, data: { entries: [] } }; + } + if (channel === IPC.invoke.fsRead) { + const path = String(input?.path ?? ""); + readPaths.push(path); + return { ok: true, data: { kind: "text", content, size: content.length } }; + } + throw new Error(`Unexpected file viewer IPC: ${channel}`); + }, +}; + +useAppStore.setState({ + workspace: { path: "/workspace", name: "Fixture" }, + activeSessionId: "fixture-session", + pluginViews: [{ pluginId: "pi.file-manager", viewId: "manager" }], + workPanelOpen: false, + workPanelTabs: [], + activeWorkPanelTabId: null, + workPanelFileRequest: null, +}); + +const nativeScrollIntoView = HTMLElement.prototype.scrollIntoView; +if (!nativeScrollIntoView) throw new Error("Chromium scrollIntoView is unavailable"); +HTMLElement.prototype.scrollIntoView = function (options?: ScrollIntoViewOptions) { + scrollCalls.push({ + line: this.getAttribute("data-line") ?? undefined, + block: options?.block, + }); + nativeScrollIntoView.call(this, options); +}; + +const i18n = createInstance(); +void i18n + .use(initReactI18next) + .init({ + lng: "en", + fallbackLng: "en", + keySeparator: false, + resources: { en: { translation: flattenCatalog(catalogs.en) } }, + interpolation: { escapeValue: false }, + }) + .then(() => { + const root = createRoot(document.getElementById("root")!); + flushSync(() => + root.render( + +
+ +
+ +
, + ), + ); + + window.fileRefLineScrollProbe = async () => { + const until = async (predicate: () => boolean, label: string) => { + for (let frame = 0; frame < 180; frame++) { + if (predicate()) return true; + await new Promise((resolve) => requestAnimationFrame(() => resolve())); + } + return false; + }; + + if (!(await until( + () => Boolean(document.querySelector(".chat-file-chip")), + "verified transcript chip", + ))) throw new Error("The verified file reference chip did not render"); + document.querySelector(".chat-file-chip")!.click(); + if (!(await until( + () => Boolean(document.querySelector(".file-viewer-path")) && readPaths.length > 0, + "host viewer file load", + ))) throw new Error(`Host file viewer did not load: ${JSON.stringify({ resolveRefs, readPaths, request: useAppStore.getState().workPanelFileRequest })}`); + await until(() => scrollCalls.length > 0, "host viewer line scroll"); + + const viewer = document.querySelector(".file-viewer-body"); + const line = viewer?.querySelector(`[data-line="${targetLine}"]`); + if (!viewer || !line) throw new Error("The requested source line was not rendered"); + const viewerRect = viewer.getBoundingClientRect(); + const lineRect = line.getBoundingClientRect(); + const request = useAppStore.getState().workPanelFileRequest; + return { + fileManagerAvailable: useAppStore.getState().pluginViews.some( + (view) => view.pluginId === "pi.file-manager" && view.viewId === "manager", + ), + selectedPath: document.querySelector(".file-viewer-path")?.textContent, + segments: splitChatText(`Review ${targetPath}:${targetLine}:4.`, "/workspace"), + resolveRefs, + readPaths, + request, + scrollCall: scrollCalls.at(-1) ?? null, + scrollTop: viewer.scrollTop, + targetLineVisible: lineRect.top >= viewerRect.top && lineRect.bottom <= viewerRect.bottom, + }; + }; + }) + .catch((error: unknown) => { + console.error(error); + throw error; + }); + +declare global { + interface Window { + piDesktop?: { + platform: NodeJS.Platform; + channels: typeof IPC; + on: (channel: string, listener: (...args: unknown[]) => void) => () => void; + invoke: (channel: string, ...args: unknown[]) => Promise; + }; + fileRefLineScrollProbe: () => Promise<{ + fileManagerAvailable: boolean; + selectedPath: string | null | undefined; + segments: unknown[]; + resolveRefs: string[]; + readPaths: string[]; + request: { path: string; line?: number; column?: number } | null; + scrollCall: { line: string | undefined; block?: string } | undefined; + scrollTop: number; + targetLineVisible: boolean; + }>; + } +}