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;
+ }>;
+ }
+}