From b08c588e2be4c3bd57cd173bd8346543f66c2324 Mon Sep 17 00:00:00 2001 From: ZxlDragonDoctor <152901610+ZxlDragonDoctor@users.noreply.github.com> Date: Sun, 27 Sep 2026 22:49:54 +0800 Subject: [PATCH 1/6] fix(chat): thread path:line refs through file open (#681) parseFileRef still strips :line[:col] for labels, but resolvePreviewTarget keeps the position on the target. FileRefChip and useOpenChatFileRef pass it to the host file request so FilesTab can scroll to the line. Plugin-view locations stay plain paths. --- .../src/components/workpanel/FilesTab.tsx | 38 +++++++++++++------ .../src/features/chat/transcript/shared.tsx | 15 +++++++- apps/desktop/src/hooks/use-preview-target.ts | 18 +++++++-- apps/desktop/src/lib/chat-links.ts | 29 +++++++++++--- apps/desktop/src/lib/work-panel-tabs.ts | 19 +++++++++- apps/desktop/src/stores/app-state.ts | 14 ++++++- .../src/stores/slices/work-panel-slice.ts | 4 +- apps/desktop/test/chat-links.test.mjs | 22 +++++++++++ 8 files changed, 131 insertions(+), 28 deletions(-) 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 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..0f7a9f858 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], ); } @@ -149,7 +154,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; @@ -166,7 +178,7 @@ export function useOpenChatFileRef() { 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..e3dbf0c1d 100644 --- a/apps/desktop/src/lib/chat-links.ts +++ b/apps/desktop/src/lib/chat-links.ts @@ -52,6 +52,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; @@ -188,7 +202,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 +213,22 @@ export function resolvePreviewTarget( ): ChatPreviewTarget | null { const trimmed = text.trim(); if (isHttpUrl(trimmed)) return { kind: "url", url: trimmed }; + const position = parseFileRefPosition(trimmed); const at = unwrapAtFileRef(trimmed); 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); if (!file) return null; if (isAbsoluteFilePath(file)) return { kind: "file", path: file }; 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..37b6200f0 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; @@ -267,13 +276,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..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 b9de2110c..58f2be7b9 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, @@ -729,3 +730,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, + }); +}); From 1676e721e5fccc4208f3953ad1819bfc1538ab9b Mon Sep 17 00:00:00 2001 From: ZxlDragonDoctor <152901610+ZxlDragonDoctor@users.noreply.github.com> Date: Tue, 29 Sep 2026 23:06:39 +0800 Subject: [PATCH 2/6] fix(chat): thread path:line through the plugin file-manager open Review follow-up on #1149: - fileManagerPluginTab accepts optional { line, column } and stores them on WorkPanelTab (same fields the host file tab already uses). - useOpenChatFileRef passes the chat position into that plugin tab instead of dropping line/column whenever the bundled file view is available. - Stale assertions updated to the new onOpen(..., { line, column }) contract and to resolvePreviewTarget carrying line on path:line tokens. --- apps/desktop/src/hooks/use-preview-target.ts | 4 +++- apps/desktop/src/lib/work-panel-tabs.ts | 9 +++++++-- apps/desktop/test/chat-links.test.mjs | 1 + apps/desktop/test/transcript-file-chips.test.mjs | 4 ++-- 4 files changed, 13 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src/hooks/use-preview-target.ts b/apps/desktop/src/hooks/use-preview-target.ts index 0f7a9f858..81a8c4338 100644 --- a/apps/desktop/src/hooks/use-preview-target.ts +++ b/apps/desktop/src/hooks/use-preview-target.ts @@ -175,7 +175,9 @@ 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, { line, column }); diff --git a/apps/desktop/src/lib/work-panel-tabs.ts b/apps/desktop/src/lib/work-panel-tabs.ts index 37b6200f0..3fc3e240a 100644 --- a/apps/desktop/src/lib/work-panel-tabs.ts +++ b/apps/desktop/src/lib/work-panel-tabs.ts @@ -154,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 } : {}), }; } diff --git a/apps/desktop/test/chat-links.test.mjs b/apps/desktop/test/chat-links.test.mjs index 58f2be7b9..ad6ece572 100644 --- a/apps/desktop/test/chat-links.test.mjs +++ b/apps/desktop/test/chat-links.test.mjs @@ -108,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", 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/); From d8ac99d0b855afabb88aa0bc5a5326dde61649f2 Mon Sep 17 00:00:00 2001 From: vastsa Date: Sat, 3 Oct 2026 19:24:44 +0800 Subject: [PATCH 3/6] fix(chat): honor line refs in host file view Route positioned references through the file view that supports line navigation, and scroll to a scoped line marker instead of a missing global selector. --- .../src/components/workpanel/FilesTab.tsx | 20 +++++++++----- apps/desktop/src/hooks/use-preview-target.ts | 20 ++++++++------ apps/desktop/src/lib/chat-links.ts | 3 +-- apps/desktop/src/lib/work-panel-tabs.ts | 9 ++----- apps/desktop/test/chat-links.test.mjs | 6 +++++ .../test/transcript-file-chips.test.mjs | 26 ++++++++++++------- docs/spec/04-ux/08-component-spec.md | 4 +++ docs/spec/06-delivery/04-e2e-test-plan.md | 10 ++++--- 8 files changed, 62 insertions(+), 36 deletions(-) diff --git a/apps/desktop/src/components/workpanel/FilesTab.tsx b/apps/desktop/src/components/workpanel/FilesTab.tsx index f10f71b73..200adfcbc 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]); @@ -219,14 +222,15 @@ export function FilesTab() { 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)); if (position?.line != null) { requestAnimationFrame(() => { - const lineNode = document.querySelector( - `.work-files-preview [data-line="${position.line}"]`, + const lineNode = viewerBodyRef.current?.querySelector( + `[data-line="${position.line}"]`, ); lineNode?.scrollIntoView({ block: "center" }); }); @@ -351,6 +355,7 @@ export function FilesTab() { onClick={() => { setSelected(null); setSelectedMimeType(undefined); + setSelectedLine(undefined); setFile(null); }} > @@ -370,12 +375,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/hooks/use-preview-target.ts b/apps/desktop/src/hooks/use-preview-target.ts index 81a8c4338..47285f514 100644 --- a/apps/desktop/src/hooks/use-preview-target.ts +++ b/apps/desktop/src/hooks/use-preview-target.ts @@ -136,10 +136,10 @@ 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. + * Scratch and attachment files also use the host file tab. */ export function useOpenChatFileRef() { const resolveRef = useResolveChatFileRef(); @@ -165,7 +165,9 @@ export function useOpenChatFileRef() { 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 && @@ -174,10 +176,12 @@ export function useOpenChatFileRef() { openUrl(resolved.relativePath); return; } - if (resolved.inProject && fileViewAvailable) { - // 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 })); + if ( + resolved.inProject && + fileViewAvailable && + !hasPosition + ) { + openTab(fileManagerPluginTab(resolved.path)); return; } openFile(resolved.path, mimeType, { line, column }); diff --git a/apps/desktop/src/lib/chat-links.ts b/apps/desktop/src/lib/chat-links.ts index e3dbf0c1d..5a81f485d 100644 --- a/apps/desktop/src/lib/chat-links.ts +++ b/apps/desktop/src/lib/chat-links.ts @@ -225,9 +225,8 @@ export function resolvePreviewTarget( } const file = parseFileRef(trimmed); 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); - const position = parseFileRefPosition(trimmed); return rel ? { kind: "file", path: rel, ...(position ?? {}) } : null; } diff --git a/apps/desktop/src/lib/work-panel-tabs.ts b/apps/desktop/src/lib/work-panel-tabs.ts index 3fc3e240a..37b6200f0 100644 --- a/apps/desktop/src/lib/work-panel-tabs.ts +++ b/apps/desktop/src/lib/work-panel-tabs.ts @@ -154,19 +154,14 @@ export const FILE_MANAGER_PLUGIN_TAB = { viewId: "manager", } as const; -/** The file view, asked to show one file (optional chat `path:line` position). */ -export function fileManagerPluginTab( - location: string, - position?: { line?: number; column?: number }, -): WorkPanelTab { +/** The file view, asked to show one file. */ +export function fileManagerPluginTab(location: string): 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 } : {}), }; } diff --git a/apps/desktop/test/chat-links.test.mjs b/apps/desktop/test/chat-links.test.mjs index ad6ece572..1db6959bd 100644 --- a/apps/desktop/test/chat-links.test.mjs +++ b/apps/desktop/test/chat-links.test.mjs @@ -751,4 +751,10 @@ test("resolvePreviewTarget carries line/col on file chips (#681)", () => { 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/transcript-file-chips.test.mjs b/apps/desktop/test/transcript-file-chips.test.mjs index 75d27bd5f..df44d82f2 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", () => { @@ -44,14 +45,21 @@ 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, /fileManagerPluginTab\(resolved\.path, \{ line, column \}\)/); + 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, /data-line="\$\{position\.line\}"/); + 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 +73,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/docs/spec/04-ux/08-component-spec.md b/docs/spec/04-ux/08-component-spec.md index 20ba98958..e2e59552e 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 diff --git a/docs/spec/06-delivery/04-e2e-test-plan.md b/docs/spec/06-delivery/04-e2e-test-plan.md index 62e544ba0..b6f1d80b4 100644 --- a/docs/spec/06-delivery/04-e2e-test-plan.md +++ b/docs/spec/06-delivery/04-e2e-test-plan.md @@ -8768,16 +8768,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, From eb4a0aaa3c23adeb895cdf8094842160fccd1765 Mon Sep 17 00:00:00 2001 From: vastsa Date: Sat, 3 Oct 2026 22:10:44 +0800 Subject: [PATCH 4/6] fix(chat): preserve file ref line in work panel requests File tabs retained the parsed line and column, but the main-process requests emitted when opening, replacing, activating, or closing tabs dropped those positions. Centralize request construction so every transition carries the selected location through to the host viewer. Positioned references use the host's existing read-only viewer because the bundled file-manager view has no line-navigation contract. Add regression coverage for request transitions, punctuation parsing, and the real isolated Electron scroll path. --- apps/desktop/src/hooks/use-preview-target.ts | 5 +- apps/desktop/src/lib/chat-links.ts | 10 +- apps/desktop/src/lib/work-panel-tabs.ts | 15 ++ .../src/stores/slices/work-panel-slice.ts | 42 ++--- apps/desktop/test/chat-links.test.mjs | 8 + apps/desktop/test/work-panel-tabs.test.mjs | 17 ++ apps/desktop/test/work-panel.test.mjs | 8 +- ...62-chat-file-refs-open-in-the-file-view.md | 18 +- docs/spec/04-ux/08-component-spec.md | 2 + docs/spec/06-delivery/04-e2e-test-plan.md | 18 ++ docs/zh-CN/spec/04-ux/08-component-spec.md | 2 + .../spec/06-delivery/04-e2e-test-plan.md | 8 + scripts/e2e-file-ref-line-scroll.mjs | 97 +++++++++++ scripts/e2e/file-ref-line-scroll.tsx | 162 ++++++++++++++++++ 14 files changed, 367 insertions(+), 45 deletions(-) create mode 100644 scripts/e2e-file-ref-line-scroll.mjs create mode 100644 scripts/e2e/file-ref-line-scroll.tsx diff --git a/apps/desktop/src/hooks/use-preview-target.ts b/apps/desktop/src/hooks/use-preview-target.ts index 47285f514..633a2e62f 100644 --- a/apps/desktop/src/hooks/use-preview-target.ts +++ b/apps/desktop/src/hooks/use-preview-target.ts @@ -139,7 +139,10 @@ function useResolveChatFileRef() { * (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. - * Scratch and attachment files also use the host file tab. + * 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(); diff --git a/apps/desktop/src/lib/chat-links.ts b/apps/desktop/src/lib/chat-links.ts index 5a81f485d..02a39b9df 100644 --- a/apps/desktop/src/lib/chat-links.ts +++ b/apps/desktop/src/lib/chat-links.ts @@ -56,7 +56,8 @@ function stripLineRef(path: string): string { export function parseFileRefPosition( text: string, ): { line: number; column?: number } | null { - const match = text.trim().match(/:(\d+)(?::(\d+))?$/); + 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; @@ -214,7 +215,10 @@ export function resolvePreviewTarget( const trimmed = text.trim(); if (isHttpUrl(trimmed)) return { kind: "url", url: trimmed }; const position = parseFileRefPosition(trimmed); - const at = unwrapAtFileRef(trimmed); + const pathText = position + ? trimmed.replace(/[.,!?;:,。!?;:]+$/u, "") + : trimmed; + const at = unwrapAtFileRef(pathText); if (at) { const cleaned = stripLineRef(at); if (isAbsoluteFilePath(cleaned)) { @@ -223,7 +227,7 @@ export function resolvePreviewTarget( 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, ...(position ?? {}) }; const rel = toWorkspaceRel(file, root, baseDir); diff --git a/apps/desktop/src/lib/work-panel-tabs.ts b/apps/desktop/src/lib/work-panel-tabs.ts index 37b6200f0..dfa6b65b1 100644 --- a/apps/desktop/src/lib/work-panel-tabs.ts +++ b/apps/desktop/src/lib/work-panel-tabs.ts @@ -36,6 +36,21 @@ export type WorkPanelContext = WorkPanelTabsState & { } | 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 { diff --git a/apps/desktop/src/stores/slices/work-panel-slice.ts b/apps/desktop/src/stores/slices/work-panel-slice.ts index cb19d4ffa..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. diff --git a/apps/desktop/test/chat-links.test.mjs b/apps/desktop/test/chat-links.test.mjs index 1db6959bd..ad23dd4df 100644 --- a/apps/desktop/test/chat-links.test.mjs +++ b/apps/desktop/test/chat-links.test.mjs @@ -735,6 +735,8 @@ test("adjacent parenthesis-wrapped URLs all remain independently linkable", () = 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); }); @@ -751,6 +753,12 @@ test("resolvePreviewTarget carries line/col on file chips (#681)", () => { 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`, 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 e2e59552e..2eeaf8676 100644 --- a/docs/spec/04-ux/08-component-spec.md +++ b/docs/spec/04-ux/08-component-spec.md @@ -3418,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 b6f1d80b4..81114a86f 100644 --- a/docs/spec/06-delivery/04-e2e-test-plan.md +++ b/docs/spec/06-delivery/04-e2e-test-plan.md @@ -16364,3 +16364,21 @@ renderer's durable transcript reads. No real model or provider is contacted. - Installed Electron, real account/paid API and cross-version rollback are 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. 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 93c998360..d4e743f0d 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 @@ -9259,3 +9259,11 @@ the latest destination. These assertions measure work counts, not device FPS. - **验收:** 缓存路径、迁移和清理单测通过;Windows task-candidate 验证应覆盖更新源传输、安装器交接和文件系统行为,且不连接真实发布源。 - **里程碑:** 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 提供文件内容;不访问真实项目文件或模型服务。 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; + }>; + } +} From 096c864d641104c983fb9e47c638c13fd8180456 Mon Sep 17 00:00:00 2001 From: vastsa Date: Sat, 3 Oct 2026 22:23:33 +0800 Subject: [PATCH 5/6] fix(chat): scroll after file viewer renders The file read promise could resolve before React committed the new viewer lines. Scheduling the scroll from openFile immediately after setFile therefore missed the target intermittently. Wait for the rendered file and requested line state before scheduling the scroll, and cancel the frame on replacement or unmount. Keep the source contract test aligned with the post-render scroll path; the isolated Electron scenario now passes repeatedly on the merged-main candidate. --- .../src/components/workpanel/FilesTab.tsx | 19 +++++++++++-------- .../test/transcript-file-chips.test.mjs | 4 +++- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/apps/desktop/src/components/workpanel/FilesTab.tsx b/apps/desktop/src/components/workpanel/FilesTab.tsx index 200adfcbc..5f5418231 100644 --- a/apps/desktop/src/components/workpanel/FilesTab.tsx +++ b/apps/desktop/src/components/workpanel/FilesTab.tsx @@ -227,14 +227,6 @@ export function FilesTab() { setFileError(false); try { setFile(await api.fsRead(rel, mimeType)); - if (position?.line != null) { - requestAnimationFrame(() => { - const lineNode = viewerBodyRef.current?.querySelector( - `[data-line="${position.line}"]`, - ); - lineNode?.scrollIntoView({ block: "center" }); - }); - } } catch { setFileError(true); } @@ -242,6 +234,17 @@ export function FilesTab() { [], ); + 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; try { diff --git a/apps/desktop/test/transcript-file-chips.test.mjs b/apps/desktop/test/transcript-file-chips.test.mjs index df44d82f2..ae133e087 100644 --- a/apps/desktop/test/transcript-file-chips.test.mjs +++ b/apps/desktop/test/transcript-file-chips.test.mjs @@ -54,7 +54,9 @@ test("a file chip is routed by where the reference resolved, never optimisticall assert.match(hook, /fileManagerPluginTab\(resolved\.path\)/); assert.match(filesTab, /data-line=\{i \+ 1\}/); assert.match(filesTab, /viewerBodyRef\.current\?\.querySelector/); - assert.match(filesTab, /data-line="\$\{position\.line\}"/); + 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, and positioned references need the host viewer's line navigation. From 52bf1ed50b7bbd4c4551f2b93901ae2f82886f8c Mon Sep 17 00:00:00 2001 From: vastsa Date: Sat, 3 Oct 2026 22:45:02 +0800 Subject: [PATCH 6/6] fix(chat): update preview routing assertion The transcript file opener now carries optional line and column positions, so the old source assertion rejects the valid route. Match the file opener arguments while keeping the URL destination check. --- apps/desktop/test/open-http-url.test.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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\)/, ); });