Skip to content
57 changes: 41 additions & 16 deletions apps/desktop/src/components/workpanel/FilesTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ function HighlightedText({ path, content }: { path: string; content: string }) {
<pre className="file-viewer-code">
{tokens
? tokens.tokens.map((row, i) => (
<div className="file-viewer-line" key={i}>
<div className="file-viewer-line" data-line={i + 1} key={i}>
{row.length === 0
? "\n"
: row.map((token, j) => (
Expand All @@ -137,7 +137,7 @@ function HighlightedText({ path, content }: { path: string; content: string }) {
</div>
))
: visible.split("\n").map((line, i) => (
<div className="file-viewer-line" key={i}>
<div className="file-viewer-line" data-line={i + 1} key={i}>
{line || "\n"}
</div>
))}
Expand All @@ -163,8 +163,10 @@ export function FilesTab() {
const [expanded, setExpanded] = useState<Set<string>>(new Set());
const [selected, setSelected] = useState<string | null>(null);
const [selectedMimeType, setSelectedMimeType] = useState<string | undefined>();
const [selectedLine, setSelectedLine] = useState<number | undefined>();
const [file, setFile] = useState<FsReadResult | null>(null);
const [fileError, setFileError] = useState(false);
const viewerBodyRef = useRef<HTMLDivElement>(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
Expand All @@ -178,6 +180,7 @@ export function FilesTab() {
setExpanded(new Set());
setSelected(null);
setSelectedMimeType(undefined);
setSelectedLine(undefined);
setFile(null);
setFileError(false);
}, [root]);
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 => {
Expand Down Expand Up @@ -337,6 +358,7 @@ export function FilesTab() {
onClick={() => {
setSelected(null);
setSelectedMimeType(undefined);
setSelectedLine(undefined);
setFile(null);
}}
>
Expand All @@ -356,12 +378,15 @@ export function FilesTab() {
<IconExternal size={14} />
</TooltipButton>
</div>
<div className="file-viewer-body">
<div className="file-viewer-body" ref={viewerBodyRef}>
{fileError ? (
<WorkTabEmpty icon={IconFileText} title={t("panel.files.error")} />
) : !file ? (
<div className="file-tree-note">{t("panel.files.loading")}</div>
) : file.kind === "text" && isMarkdownPath(selected) ? (
) :
file.kind === "text" &&
isMarkdownPath(selected) &&
selectedLine === undefined ? (
<div className="file-viewer-markdown prose-chat">
<Markdown source={file.content ?? ""} baseDir={fileDirOf(selected)} />
</div>
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 @@ -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);
Expand All @@ -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 })}
>
<span className="composer-chip-icon" aria-hidden>
Expand Down Expand Up @@ -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}
/>
Expand Down
37 changes: 29 additions & 8 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 @@ -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();
Expand All @@ -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 &&
Expand All @@ -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],
Expand Down
38 changes: 30 additions & 8 deletions apps/desktop/src/lib/chat-links.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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. */
Expand All @@ -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). */
Expand Down
34 changes: 32 additions & 2 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,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 {
Expand Down Expand Up @@ -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 } : {}),
};
}

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 @@ -368,7 +368,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 @@ -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;
};
Expand Down
Loading
Loading