From bfd128c4ba972b3b09f7e24d4ab9035cbb579484 Mon Sep 17 00:00:00 2001 From: zhujunshen Date: Sat, 4 Jul 2026 07:03:56 -0700 Subject: [PATCH 1/2] feat: open local file links from chat --- app/api/files/[...path]/route.ts | 9 ++- components/AppShell.tsx | 20 ++++-- components/ChatWindow.tsx | 8 ++- components/FileViewer.tsx | 82 +++++++++++---------- components/MarkdownBody.tsx | 30 +++++++- components/MessageView.tsx | 34 +++++---- components/TabBar.tsx | 1 + lib/file-links.test.mjs | 55 ++++++++++++++ lib/file-links.ts | 104 +++++++++++++++++++++++++++ lib/session-file-references-core.ts | 68 ++++++++++++++++++ lib/session-file-references.test.mjs | 52 ++++++++++++++ lib/session-file-references.ts | 14 ++++ lib/session-reader.ts | 5 ++ 13 files changed, 423 insertions(+), 59 deletions(-) create mode 100644 lib/file-links.test.mjs create mode 100644 lib/file-links.ts create mode 100644 lib/session-file-references-core.ts create mode 100644 lib/session-file-references.test.mjs create mode 100644 lib/session-file-references.ts diff --git a/app/api/files/[...path]/route.ts b/app/api/files/[...path]/route.ts index c06cf693..5ab3d9d1 100644 --- a/app/api/files/[...path]/route.ts +++ b/app/api/files/[...path]/route.ts @@ -7,6 +7,7 @@ import { isWindowsAbsolutePath, normalizeSlashes, } from "@/lib/file-access"; +import { isFilePathReferencedBySession } from "@/lib/session-file-references"; const IGNORED_NAMES = new Set([ "node_modules", ".git", ".next", "dist", "build", "__pycache__", @@ -284,9 +285,15 @@ export async function GET( const { path: segments } = await params; const filePath = filePathFromSegments(segments); const type = request.nextUrl.searchParams.get("type") ?? "list"; + const sessionId = request.nextUrl.searchParams.get("sessionId"); const allowedRoots = await getAllowedFileRoots(); - if (!isFilePathAllowed(filePath, allowedRoots)) { + const allowedByRoot = isFilePathAllowed(filePath, allowedRoots); + const allowedBySessionReference = + !allowedByRoot && + type !== "list" && + await isFilePathReferencedBySession(filePath, sessionId); + if (!allowedByRoot && !allowedBySessionReference) { return NextResponse.json({ error: "Access denied" }, { status: 403 }); } diff --git a/components/AppShell.tsx b/components/AppShell.tsx index 7f7fc566..b050cfd7 100644 --- a/components/AppShell.tsx +++ b/components/AppShell.tsx @@ -12,6 +12,7 @@ import { PluginsConfig } from "./PluginsConfig"; import { BranchNavigator } from "./BranchNavigator"; import { useTheme } from "@/hooks/useTheme"; import { useIsMobile } from "@/hooks/useIsMobile"; +import { getFileName } from "@/lib/file-paths"; import { buildAtMentionText } from "@/lib/file-fuzzy"; import type { SessionInfo, SessionTreeNode } from "@/lib/types"; import type { ChatInputHandle } from "./ChatInput"; @@ -286,11 +287,13 @@ export function AppShell() { } }, [selectedSession, router]); - const handleOpenFile = useCallback((filePath: string, fileName: string) => { + const handleOpenFile = useCallback((filePath: string, fileName: string, sourceSessionId?: string | null) => { const tabId = `file:${filePath}`; setFileTabs((prev) => { - if (prev.find((t) => t.id === tabId)) return prev; - return [...prev, { id: tabId, label: fileName, filePath }]; + const existing = prev.find((t) => t.id === tabId); + if (!existing) return [...prev, { id: tabId, label: fileName, filePath, sourceSessionId }]; + if (!sourceSessionId || existing.sourceSessionId === sourceSessionId) return prev; + return prev.map((t) => t.id === tabId ? { ...t, sourceSessionId } : t); }); setActiveFileTabId(tabId); setRightPanelOpen(true); @@ -298,6 +301,10 @@ export function AppShell() { if (isMobile) setSidebarOpen(false); }, [isMobile]); + const handleOpenLinkedFile = useCallback((filePath: string) => { + handleOpenFile(filePath, getFileName(filePath), selectedSession?.id ?? null); + }, [handleOpenFile, selectedSession?.id]); + const handleCloseFileTab = useCallback((tabId: string) => { setFileTabs((prev) => { const next = prev.filter((t) => t.id !== tabId); @@ -977,6 +984,7 @@ export function AppShell() { onSessionStatsChange={handleSessionStatsChange} onSessionStatsPanelOpen={openSessionStatsPanel} onContextUsageChange={handleContextUsageChange} + onOpenFile={handleOpenLinkedFile} /> ) : showPlaceholder ? ( activeCwd ? ( @@ -1027,7 +1035,11 @@ export function AppShell() { {/* File content */}
{activeFileTab?.filePath ? ( - + ) : (
No file open diff --git a/components/ChatWindow.tsx b/components/ChatWindow.tsx index 25f16624..c98c79b4 100644 --- a/components/ChatWindow.tsx +++ b/components/ChatWindow.tsx @@ -24,6 +24,7 @@ interface Props { onSessionStatsChange?: (stats: SessionStatsInfo | null) => void; onSessionStatsPanelOpen?: () => void; onContextUsageChange?: (usage: { percent: number | null; contextWindow: number; tokens: number | null } | null) => void; + onOpenFile?: (filePath: string) => void; } function phaseLabel(phase: AgentPhase): string { @@ -43,7 +44,7 @@ const CHAT_MINIMAP_WIDTH = 36; const CHAT_COLUMN_PADDING = 16; const CHAT_INPUT_RIGHT_PADDING = CHAT_COLUMN_PADDING + CHAT_MINIMAP_WIDTH; -export function ChatWindow({ session, newSessionCwd, onAgentEnd, onSessionCreated, onSessionForked, modelsRefreshKey, chatInputRef, onBranchDataChange, onSystemPromptChange, onSessionStatsChange, onSessionStatsPanelOpen, onContextUsageChange }: Props) { +export function ChatWindow({ session, newSessionCwd, onAgentEnd, onSessionCreated, onSessionForked, modelsRefreshKey, chatInputRef, onBranchDataChange, onSystemPromptChange, onSessionStatsChange, onSessionStatsPanelOpen, onContextUsageChange, onOpenFile }: Props) { const { soundEnabled, onSoundToggle, playDoneSound, unlockAudio } = useAudio(); const isMobile = useIsMobile(); @@ -133,6 +134,7 @@ export function ChatWindow({ session, newSessionCwd, onAgentEnd, onSessionCreate const messageRefs = useMessageRefs(visibleMessages.length); const isEmptyNew = isNew && messages.length === 0 && !streamState.isStreaming && !agentRunning; + const messageCwd = session?.cwd ?? newSessionCwd ?? undefined; const availableThinkingLevels = displayModelValue ? (modelThinkingLevels[`${displayModelValue.provider}:${displayModelValue.modelId}`] ?? null) @@ -349,6 +351,8 @@ export function ChatWindow({ session, newSessionCwd, onAgentEnd, onSessionCreate message={msg} toolResults={toolResultsMap} modelNames={modelNames} + cwd={messageCwd} + onOpenFile={onOpenFile} entryId={entryIds[idx]} onFork={agentRunning || isNew || (idx === 0 && msg.role === "user") ? undefined : handleFork} forking={forkingEntryId === entryIds[idx]} @@ -372,7 +376,7 @@ export function ChatWindow({ session, newSessionCwd, onAgentEnd, onSessionCreate })()} {streamState.isStreaming && streamState.streamingMessage && ( - + )} {agentRunning && !streamState.streamingMessage && ( diff --git a/components/FileViewer.tsx b/components/FileViewer.tsx index 10db8605..1f596add 100644 --- a/components/FileViewer.tsx +++ b/components/FileViewer.tsx @@ -12,6 +12,7 @@ import { markdownPreviewRehypePlugins, markdownPreviewRemarkPlugins } from "@/li interface Props { filePath: string; cwd?: string; + sourceSessionId?: string | null; } interface FileData { @@ -45,11 +46,25 @@ function isDocumentPreviewPath(filePath: string): boolean { return DOCUMENT_PREVIEW_EXTS.has(getFileExt(filePath)); } -function DownloadLink({ filePath }: { filePath: string }) { +function getFileApiUrl( + filePath: string, + type: "read" | "download" | "meta" | "preview" | "watch", + sourceSessionId?: string | null, + params: Record = {}, +): string { const encoded = encodeFilePathForApi(filePath); + const searchParams = new URLSearchParams({ type }); + if (sourceSessionId) searchParams.set("sessionId", sourceSessionId); + for (const [key, value] of Object.entries(params)) { + if (value !== undefined) searchParams.set(key, String(value)); + } + return `/api/files/${encoded}?${searchParams.toString()}`; +} + +function DownloadLink({ filePath, sourceSessionId }: { filePath: string; sourceSessionId?: string | null }) { return ( (null); @@ -333,8 +348,7 @@ function ImageViewer({ filePath, cwd }: { filePath: string; cwd?: string }) { esRef.current = null; } - const encoded = encodeFilePathForApi(filePath); - const es = new EventSource(`/api/files/${encoded}?type=watch`); + const es = new EventSource(getFileApiUrl(filePath, "watch", sourceSessionId)); esRef.current = es; es.addEventListener("connected", () => setWatching(true)); @@ -352,10 +366,9 @@ function ImageViewer({ filePath, cwd }: { filePath: string; cwd?: string }) { es.close(); esRef.current = null; }; - }, [filePath]); + }, [filePath, sourceSessionId]); - const encoded = encodeFilePathForApi(filePath); - const src = `/api/files/${encoded}?type=read${bust ? `&v=${bust}` : ""}`; + const src = getFileApiUrl(filePath, "read", sourceSessionId, bust ? { v: bust } : undefined); const formatSizeStr = size != null ? formatSize(size) : null; @@ -396,7 +409,7 @@ function ImageViewer({ filePath, cwd }: { filePath: string; cwd?: string }) { /> {watching ? "live" : "static"} - +
(null); @@ -468,8 +481,7 @@ function AudioViewer({ filePath, cwd }: { filePath: string; cwd?: string }) { esRef.current = null; } - const encoded = encodeFilePathForApi(filePath); - const es = new EventSource(`/api/files/${encoded}?type=watch`); + const es = new EventSource(getFileApiUrl(filePath, "watch", sourceSessionId)); esRef.current = es; es.addEventListener("connected", () => setWatching(true)); @@ -489,10 +501,9 @@ function AudioViewer({ filePath, cwd }: { filePath: string; cwd?: string }) { es.close(); esRef.current = null; }; - }, [filePath]); + }, [filePath, sourceSessionId]); - const encoded = encodeFilePathForApi(filePath); - const src = `/api/files/${encoded}?type=read${bust ? `&v=${bust}` : ""}`; + const src = getFileApiUrl(filePath, "read", sourceSessionId, bust ? { v: bust } : undefined); return (
@@ -531,7 +542,7 @@ function AudioViewer({ filePath, cwd }: { filePath: string; cwd?: string }) { /> {watching ? "live" : "static"} - +
(null); @@ -572,11 +583,10 @@ function DocumentViewer({ filePath, cwd }: { filePath: string; cwd?: string }) { const esRef = useRef(null); const ext = getFileExt(filePath); - const encoded = encodeFilePathForApi(filePath); const isPdf = ext === "pdf"; const previewUrl = isPdf - ? `/api/files/${encoded}?type=read${bust ? `&v=${bust}` : ""}` - : `/api/files/${encoded}?type=preview${bust ? `&v=${bust}` : ""}`; + ? getFileApiUrl(filePath, "read", sourceSessionId, bust ? { v: bust } : undefined) + : getFileApiUrl(filePath, "preview", sourceSessionId, bust ? { v: bust } : undefined); useEffect(() => { setBust(0); @@ -589,7 +599,7 @@ function DocumentViewer({ filePath, cwd }: { filePath: string; cwd?: string }) { esRef.current = null; } - fetch(`/api/files/${encoded}?type=meta`) + fetch(getFileApiUrl(filePath, "meta", sourceSessionId)) .then((r) => r.json()) .then((d: { size?: number; error?: string }) => { if (d.error) setError(d.error); @@ -602,7 +612,7 @@ function DocumentViewer({ filePath, cwd }: { filePath: string; cwd?: string }) { }) .catch((e) => setError(String(e))); - const es = new EventSource(`/api/files/${encoded}?type=watch`); + const es = new EventSource(getFileApiUrl(filePath, "watch", sourceSessionId)); esRef.current = es; es.addEventListener("connected", () => setWatching(true)); @@ -627,7 +637,7 @@ function DocumentViewer({ filePath, cwd }: { filePath: string; cwd?: string }) { es.close(); esRef.current = null; }; - }, [encoded, isPdf]); + }, [filePath, isPdf, sourceSessionId]); return (
@@ -649,6 +659,7 @@ function DocumentViewer({ filePath, cwd }: { filePath: string; cwd?: string }) { {ext === "docx" ? "docx preview" : "pdf"} {size != null && {formatSize(size)}} + {watching ? "live" : "static"} -
{error ? ( @@ -686,20 +696,20 @@ function DocumentViewer({ filePath, cwd }: { filePath: string; cwd?: string }) { ); } -export function FileViewer({ filePath, cwd }: Props) { +export function FileViewer({ filePath, cwd, sourceSessionId }: Props) { if (isImagePath(filePath)) { - return ; + return ; } if (isAudioPath(filePath)) { - return ; + return ; } if (isDocumentPreviewPath(filePath)) { - return ; + return ; } - return ; + return ; } -function TextFileViewer({ filePath, cwd }: Props) { +function TextFileViewer({ filePath, cwd, sourceSessionId }: Props) { const { isDark } = useTheme(); const [data, setData] = useState(null); const [prevContent, setPrevContent] = useState(null); @@ -713,8 +723,7 @@ function TextFileViewer({ filePath, cwd }: Props) { const esRef = useRef(null); const fetchContent = useCallback((filePath: string, isRefresh = false) => { - const encoded = encodeFilePathForApi(filePath); - return fetch(`/api/files/${encoded}?type=read`) + return fetch(getFileApiUrl(filePath, "read", sourceSessionId)) .then((r) => r.json()) .then((d: FileData & { error?: string }) => { if (d.error) { @@ -736,7 +745,7 @@ function TextFileViewer({ filePath, cwd }: Props) { setError(String(e)); return null; }); - }, []); + }, [sourceSessionId]); // Initial load + SSE watch setup useEffect(() => { @@ -760,8 +769,7 @@ function TextFileViewer({ filePath, cwd }: Props) { }).finally(() => setLoading(false)); // Set up SSE watch - const encoded = encodeFilePathForApi(filePath); - const es = new EventSource(`/api/files/${encoded}?type=watch`); + const es = new EventSource(getFileApiUrl(filePath, "watch", sourceSessionId)); esRef.current = es; es.addEventListener("connected", () => { @@ -784,7 +792,7 @@ function TextFileViewer({ filePath, cwd }: Props) { es.close(); esRef.current = null; }; - }, [filePath, fetchContent]); + }, [filePath, fetchContent, sourceSessionId]); if (loading) { return ( @@ -950,7 +958,7 @@ function TextFileViewer({ filePath, cwd }: Props) {
)} - +
{/* Content area */} diff --git a/components/MarkdownBody.tsx b/components/MarkdownBody.tsx index 14364188..dba7500a 100644 --- a/components/MarkdownBody.tsx +++ b/components/MarkdownBody.tsx @@ -1,17 +1,20 @@ "use client"; -import { useEffect, useMemo, useState, type ReactNode } from "react"; +import { useEffect, useMemo, useState, type MouseEvent, type ReactNode } from "react"; import ReactMarkdown from "react-markdown"; import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; import { vs } from "react-syntax-highlighter/dist/cjs/styles/prism"; import { vscDarkPlus } from "react-syntax-highlighter/dist/cjs/styles/prism"; import { useTheme } from "@/hooks/useTheme"; +import { resolveLocalFileHref } from "@/lib/file-links"; import { markdownRehypePlugins, markdownRemarkPlugins } from "@/lib/markdown"; interface MarkdownBodyProps { children: string; className?: string; isStreaming?: boolean; + cwd?: string; + onOpenFile?: (filePath: string) => void; } function copyText(text: string): Promise { @@ -33,7 +36,7 @@ function copyText(text: string): Promise { } } -export function MarkdownBody({ children, className, isStreaming }: MarkdownBodyProps) { +export function MarkdownBody({ children, className, isStreaming, cwd, onOpenFile }: MarkdownBodyProps) { const normalizedMarkdown = useMemo(() => normalizeDisplayMath(children), [children]); return ( @@ -64,6 +67,29 @@ export function MarkdownBody({ children, className, isStreaming }: MarkdownBodyP pre({ children }) { return <>{children}; }, + a({ href, children, ...props }) { + const filePath = onOpenFile ? resolveLocalFileHref(href, cwd) : null; + const openFile = onOpenFile; + if (!filePath || !openFile) { + return ( +
+ {children} + + ); + } + + const handleClick = (event: MouseEvent) => { + if (event.defaultPrevented || event.button !== 0) return; + event.preventDefault(); + openFile(filePath); + }; + + return ( + + {children} + + ); + }, table({ children }) { return (
diff --git a/components/MessageView.tsx b/components/MessageView.tsx index 3425eb3c..c949e430 100644 --- a/components/MessageView.tsx +++ b/components/MessageView.tsx @@ -20,6 +20,8 @@ interface Props { isStreaming?: boolean; toolResults?: Map; modelNames?: Record; + cwd?: string; + onOpenFile?: (filePath: string) => void; entryId?: string; onFork?: (entryId: string) => void; forking?: boolean; @@ -62,25 +64,27 @@ function copyText(text: string): Promise { } } -export function MessageView({ message, isStreaming, toolResults, modelNames, entryId, onFork, forking, onNavigate, prevAssistantEntryId, onEditContent, showTimestamp, prevTimestamp }: Props) { +export function MessageView({ message, isStreaming, toolResults, modelNames, cwd, onOpenFile, entryId, onFork, forking, onNavigate, prevAssistantEntryId, onEditContent, showTimestamp, prevTimestamp }: Props) { if (message.role === "user") { - return ; + return ; } if (message.role === "assistant") { - return ; + return ; } if (message.role === "toolResult") { // Rendered inline under its toolCall — skip standalone rendering if paired return null; } if (message.role === "custom") { - return ; + return ; } return null; } -function UserMessageView({ message, entryId, onFork, forking, onNavigate, prevAssistantEntryId, onEditContent }: { +function UserMessageView({ message, cwd, onOpenFile, entryId, onFork, forking, onNavigate, prevAssistantEntryId, onEditContent }: { message: UserMessage; + cwd?: string; + onOpenFile?: (filePath: string) => void; entryId?: string; onFork?: (entryId: string) => void; forking?: boolean; @@ -161,7 +165,7 @@ function UserMessageView({ message, entryId, onFork, forking, onNavigate, prevAs })}
)} - {content && {content}} + {content && {content}}
@@ -282,6 +286,8 @@ function AssistantMessageView({ isStreaming, toolResults, modelNames, + cwd, + onOpenFile, showTimestamp, prevTimestamp, }: { @@ -289,6 +295,8 @@ function AssistantMessageView({ isStreaming?: boolean; toolResults?: Map; modelNames?: Record; + cwd?: string; + onOpenFile?: (filePath: string) => void; showTimestamp?: boolean; prevTimestamp?: number; }) { @@ -450,7 +458,7 @@ function AssistantMessageView({
{blocks.map((block, i) => ( - + ))}
@@ -503,9 +511,9 @@ function AssistantMessageView({ ); } -function BlockView({ block, toolResults, isStreaming, streamingDuration, toolCallDurations }: { block: AssistantContentBlock; toolResults?: Map; isStreaming?: boolean; streamingDuration?: number; toolCallDurations?: Map }) { +function BlockView({ block, toolResults, isStreaming, streamingDuration, toolCallDurations, cwd, onOpenFile }: { block: AssistantContentBlock; toolResults?: Map; isStreaming?: boolean; streamingDuration?: number; toolCallDurations?: Map; cwd?: string; onOpenFile?: (filePath: string) => void }) { if (block.type === "text") { - return ; + return ; } if (block.type === "thinking") { return ; @@ -519,8 +527,8 @@ function BlockView({ block, toolResults, isStreaming, streamingDuration, toolCal return null; } -function TextBlock({ block, isStreaming }: { block: TextContent; isStreaming?: boolean }) { - return {block.text}; +function TextBlock({ block, isStreaming, cwd, onOpenFile }: { block: TextContent; isStreaming?: boolean; cwd?: string; onOpenFile?: (filePath: string) => void }) { + return {block.text}; } function ThinkingBlock({ block, duration }: { block: ThinkingContent; duration?: number }) { @@ -1065,7 +1073,7 @@ function PairedResult({ text, isEmpty, isError }: { ); } -function CustomMessageView({ message }: { message: CustomMessage }) { +function CustomMessageView({ message, cwd, onOpenFile }: { message: CustomMessage; cwd?: string; onOpenFile?: (filePath: string) => void }) { const isHiddenDisplay = message.display === false; const [contentExpanded, setContentExpanded] = useState(!isHiddenDisplay); const [detailsExpanded, setDetailsExpanded] = useState(false); @@ -1133,7 +1141,7 @@ function CustomMessageView({ message }: { message: CustomMessage }) { })} )} - {text ? {text} : (no message)} + {text ? {text} : (no message)} ) : (