Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 26 additions & 12 deletions apps/desktop/src/components/workpanel/FilesTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 => {
Expand Down
15 changes: 13 additions & 2 deletions apps/desktop/src/features/chat/transcript/shared.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -418,13 +418,22 @@ export function FileRefChip({
kind,
mimeType,
onOpen,
line,
column,
...position
}: {
name: string;
path: string;
kind?: "image" | "file";
mimeType?: string;
onOpen: (path: string, baseDir?: string, mimeType?: string) => void;
onOpen: (
path: string,
baseDir?: string,
mimeType?: string,
position?: { line?: number; column?: number },
) => void;
line?: number;
column?: number;
} & SourcePositionProps) {
const { t } = useTranslation();
const Icon = fileChipIcon(name, kind);
Expand All @@ -438,7 +447,7 @@ export function FileRefChip({
{...position}
title={`${html ? t("chat.previewUrl") : t("chat.openFile")} — ${path}`}
aria-label={`${name} — ${path}`}
onClick={() => onOpen(path, undefined, mimeType)}
onClick={() => onOpen(path, undefined, mimeType, { line, column })}
onContextMenu={(event) => openFileMenu(event, { path })}
>
<span className="composer-chip-icon" aria-hidden>
Expand Down Expand Up @@ -515,6 +524,8 @@ export function LinkifiedText({ text, attachments }: { text: string; attachments
key={index}
name={segment.label}
path={segment.target.path}
line={segment.target.line}
column={segment.target.column}
onOpen={openFileRef}
{...position}
/>
Expand Down
22 changes: 18 additions & 4 deletions apps/desktop/src/hooks/use-preview-target.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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],
);
}
Expand Down Expand Up @@ -144,7 +149,14 @@ export function useOpenChatFileRef() {
);

return useCallback(
(path: string, baseDir?: string, mimeType?: string) => {
(
path: string,
baseDir?: string,
mimeType?: string,
position?: { line?: number; column?: number },
) => {
const line = position?.line;
const column = position?.column;
void (async () => {
const resolved = await resolveRef(path, baseDir);
if (!resolved) return;
Expand All @@ -158,10 +170,12 @@ export function useOpenChatFileRef() {
return;
}
if (resolved.inProject && fileViewAvailable) {
openTab(fileManagerPluginTab(resolved.path));
// Thread path:line into the plugin tab so the bundled file view can
// scroll to the line (#681 / #1149 review). Host fallback uses openFile.
openTab(fileManagerPluginTab(resolved.path, { line, column }));
return;
}
openFile(resolved.path, mimeType);
openFile(resolved.path, mimeType, { line, column });
})();
},
[fileViewAvailable, openFile, openTab, openUrl, resolveRef],
Expand Down
30 changes: 24 additions & 6 deletions apps/desktop/src/lib/chat-links.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,20 @@ function stripLineRef(path: string): string {
return path.replace(/:\d+(?::\d+)?$/, "");
}

/** Trailing `:line[:col]` on a file token, if any. */
export function parseFileRefPosition(
text: string,
): { line: number; column?: number } | null {
const match = text.trim().match(/:(\d+)(?::(\d+))?$/);
if (!match) return null;
const line = Number(match[1]);
if (!Number.isFinite(line) || line < 1) return null;
const column = match[2] !== undefined ? Number(match[2]) : undefined;
return column !== undefined && Number.isFinite(column) && column >= 1
? { line, column }
: { line };
}

function leafName(path: string): string {
const normalized = path.replaceAll("\\", "/").replace(/\/+$/, "");
return normalized.slice(normalized.lastIndexOf("/") + 1) || path;
Expand Down Expand Up @@ -182,7 +196,7 @@ export function toWorkspaceRel(
}

export type ChatPreviewTarget =
| { kind: "file"; path: string }
| { kind: "file"; path: string; line?: number; column?: number }
| { kind: "url"; url: string };

/** Resolve one raw chat token into a previewable target, or null. */
Expand All @@ -195,15 +209,19 @@ export function resolvePreviewTarget(
if (isHttpUrl(trimmed)) return { kind: "url", url: trimmed };
const at = unwrapAtFileRef(trimmed);
if (at) {
// Scratch/attachment @refs stay absolute so fs/open can contain them.
if (at.startsWith("/")) return { kind: "file", path: at };
const rel = toWorkspaceRel(at, root, baseDir);
return rel ? { kind: "file", path: rel } : null;
const position = parseFileRefPosition(at);
const cleaned = stripLineRef(at);
if (cleaned.startsWith("/")) {
return { kind: "file", path: cleaned, ...(position ?? {}) };
}
const rel = toWorkspaceRel(cleaned, root, baseDir);
return rel ? { kind: "file", path: rel, ...(position ?? {}) } : null;
}
const file = parseFileRef(trimmed);
if (!file) return null;
const rel = toWorkspaceRel(file, root, baseDir);
return rel ? { kind: "file", path: rel } : null;
const position = parseFileRefPosition(trimmed);
return rel ? { kind: "file", path: rel, ...(position ?? {}) } : null;
}

/** Tool-call args → preview target (Read/Write/Edit paths, fetch URLs). */
Expand Down
28 changes: 24 additions & 4 deletions apps/desktop/src/lib/work-panel-tabs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ export type WorkPanelTab = {
location?: string;
/** Stored attachment mimeType for extension-less `attachments/<sha256>` images. */
mimeType?: string;
/** Optional 1-based line/column for host file tabs (chat `path:line` refs). */
line?: number;
column?: number;
};

export type WorkPanelTabsState = {
Expand All @@ -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;
Expand Down Expand Up @@ -145,14 +154,19 @@ export const FILE_MANAGER_PLUGIN_TAB = {
viewId: "manager",
} as const;

/** The file view, asked to show one file. */
export function fileManagerPluginTab(location: string): WorkPanelTab {
/** The file view, asked to show one file (optional chat `path:line` position). */
export function fileManagerPluginTab(
location: string,
position?: { line?: number; column?: number },
): WorkPanelTab {
return {
...pluginWorkPanelTab(
FILE_MANAGER_PLUGIN_TAB.pluginId,
FILE_MANAGER_PLUGIN_TAB.viewId,
),
location,
...(position?.line != null ? { line: position.line } : {}),
...(position?.column != null ? { column: position.column } : {}),
};
}

Expand Down Expand Up @@ -267,13 +281,19 @@ export function normalizeWorkPanelFilePath(path: string): string {
return absolute ? `/${normalized}` : normalized;
}

export function fileWorkPanelTab(path: string, mimeType?: string): WorkPanelTab {
export function fileWorkPanelTab(
path: string,
mimeType?: string,
position?: { line?: number; column?: number },
): WorkPanelTab {
const resource = normalizeWorkPanelFilePath(path);
return {
id: `file:${resource}`,
kind: "file",
resource,
...(mimeType ? { mimeType } : {}),
...(position?.line != null ? { line: position.line } : {}),
...(position?.column != null ? { column: position.column } : {}),
};
}

Expand Down
14 changes: 12 additions & 2 deletions apps/desktop/src/stores/app-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -357,7 +357,13 @@ export type AppState = {
workPanelContexts: Record<string, WorkPanelContext>;
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. */
Expand All @@ -381,7 +387,11 @@ export type AppState = {
/** Hide the visible panel while retaining its session-owned context. */
resetWorkPanelContext: () => void;
setWorkPanelWidth: (width: number) => void;
openFileInWorkPanel: (path: string, mimeType?: string) => void;
openFileInWorkPanel: (
path: string,
mimeType?: string,
position?: { line?: number; column?: number },
) => void;
openUrlInWorkPanel: (url: string) => void;
updateBrowserWorkPanelTab: (state: BrowserState) => void;
};
Expand Down
4 changes: 2 additions & 2 deletions apps/desktop/src/stores/slices/work-panel-slice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
23 changes: 23 additions & 0 deletions apps/desktop/test/chat-links.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
isHttpUrl,
linkifyMdastTree,
parseFileRef,
parseFileRefPosition,
remarkChatFileLinks,
resolvePreviewTarget,
splitChatText,
Expand Down Expand Up @@ -91,6 +92,7 @@ test("resolvePreviewTarget classifies urls and workspace files", () => {
assert.deepEqual(resolvePreviewTarget("src/a.ts:10", ROOT), {
kind: "file",
path: "src/a.ts",
line: 10,
});
assert.deepEqual(resolvePreviewTarget("./README.md", ROOT, "docs"), {
kind: "file",
Expand Down Expand Up @@ -350,3 +352,24 @@ test("adjacent parenthesis-wrapped URLs all remain independently linkable", () =
assert.equal(segments.filter(s => s.kind === "target").length, 1000);
assert.equal(segments.map(s => s.text).join(""), source);
});

test("parseFileRefPosition keeps :line[:col] that parseFileRef strips", () => {
assert.deepEqual(parseFileRefPosition("src/main.rs:42"), { line: 42 });
assert.deepEqual(parseFileRefPosition("src/main.rs:42:7"), { line: 42, column: 7 });
assert.equal(parseFileRefPosition("src/main.rs"), null);
assert.equal(parseFileRefPosition("src/main.rs:0"), null);
});

test("resolvePreviewTarget carries line/col on file chips (#681)", () => {
assert.deepEqual(resolvePreviewTarget("src/a.ts:42", ROOT), {
kind: "file",
path: "src/a.ts",
line: 42,
});
assert.deepEqual(resolvePreviewTarget("src/a.ts:42:7", ROOT), {
kind: "file",
path: "src/a.ts",
line: 42,
column: 7,
});
});
4 changes: 2 additions & 2 deletions apps/desktop/test/transcript-file-chips.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand All @@ -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/);
Expand Down