Skip to content
Merged
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
23 changes: 21 additions & 2 deletions app/api/files/[...path]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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__",
Expand All @@ -20,6 +21,10 @@ const TEXT_PREVIEW_MAX_BYTES = 256 * 1024;
const IMAGE_PREVIEW_MAX_BYTES = 10 * 1024 * 1024;
const DOCX_PREVIEW_MAX_BYTES = 10 * 1024 * 1024;

const FILE_REQUEST_TYPES = ["list", "read", "download", "meta", "preview", "watch"] as const;
type FileRequestType = typeof FILE_REQUEST_TYPES[number];
const FILE_REQUEST_TYPE_SET = new Set<string>(FILE_REQUEST_TYPES);

const IMAGE_EXT_TO_MIME: Record<string, string> = {
png: "image/png",
jpg: "image/jpeg",
Expand Down Expand Up @@ -99,6 +104,10 @@ function filePathFromSegments(segments: string[]): string {
return "/" + joined.replace(/^\/+/, "");
}

function parseFileRequestType(value: string): FileRequestType | null {
return FILE_REQUEST_TYPE_SET.has(value) ? (value as FileRequestType) : null;
}

function createFileBodyStream(filePath: string, range?: { start: number; end: number }): ReadableStream<Uint8Array> {
const fileStream = fs.createReadStream(filePath, range);
let closed = false;
Expand Down Expand Up @@ -283,10 +292,20 @@ export async function GET(
try {
const { path: segments } = await params;
const filePath = filePathFromSegments(segments);
const type = request.nextUrl.searchParams.get("type") ?? "list";
const rawType = request.nextUrl.searchParams.get("type") ?? "list";
const type = parseFileRequestType(rawType);
if (!type) {
return NextResponse.json({ error: "Invalid file request type" }, { status: 400 });
}
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) {
Comment on lines +304 to +308
return NextResponse.json({ error: "Access denied" }, { status: 403 });
}

Expand Down
20 changes: 16 additions & 4 deletions components/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -286,18 +287,24 @@ 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);
// On mobile the file panel is full-screen; close the drawer so it shows.
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);
Expand Down Expand Up @@ -977,6 +984,7 @@ export function AppShell() {
onSessionStatsChange={handleSessionStatsChange}
onSessionStatsPanelOpen={openSessionStatsPanel}
onContextUsageChange={handleContextUsageChange}
onOpenFile={handleOpenLinkedFile}
/>
) : showPlaceholder ? (
activeCwd ? (
Expand Down Expand Up @@ -1027,7 +1035,11 @@ export function AppShell() {
{/* File content */}
<div style={{ flex: 1, overflow: "hidden" }}>
{activeFileTab?.filePath ? (
<FileViewer filePath={activeFileTab.filePath} cwd={activeCwd ?? undefined} />
<FileViewer
filePath={activeFileTab.filePath}
cwd={activeCwd ?? undefined}
sourceSessionId={activeFileTab.sourceSessionId}
/>
) : (
<div style={{ height: "100%", display: "flex", alignItems: "center", justifyContent: "center", color: "var(--text-dim)", fontSize: 12 }}>
No file open
Expand Down
8 changes: 6 additions & 2 deletions components/ChatWindow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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();

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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]}
Expand All @@ -372,7 +376,7 @@ export function ChatWindow({ session, newSessionCwd, onAgentEnd, onSessionCreate
})()}

{streamState.isStreaming && streamState.streamingMessage && (
<MessageView message={streamState.streamingMessage as AgentMessage} isStreaming modelNames={modelNames} />
<MessageView message={streamState.streamingMessage as AgentMessage} isStreaming modelNames={modelNames} cwd={messageCwd} onOpenFile={onOpenFile} />
)}

{agentRunning && !streamState.streamingMessage && (
Expand Down
Loading