From d898b66374a50ad3f64efc84cb38f41244474678 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 5 Mar 2026 13:44:35 -0800 Subject: [PATCH 1/3] Render changed files as expandable tree with aggregated diff stats - Replace flat changed-files chips in ChatView with a nested, expandable tree - Extract diff tree/stat logic into turnDiffTree utilities - Add unit tests for stat aggregation, nesting, and Windows path normalization --- apps/web/src/components/ChatView.tsx | 191 +++++++++++++++++++------- apps/web/src/lib/turnDiffTree.test.ts | 129 +++++++++++++++++ apps/web/src/lib/turnDiffTree.ts | 148 ++++++++++++++++++++ 3 files changed, 415 insertions(+), 53 deletions(-) create mode 100644 apps/web/src/lib/turnDiffTree.test.ts create mode 100644 apps/web/src/lib/turnDiffTree.ts diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 9c24d883b508..4f9fe1574966 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -63,11 +63,17 @@ import { MAX_THREAD_TERMINAL_COUNT, type ChatMessage, type Thread, + type TurnDiffFileChange, type TurnDiffSummary, } from "../types"; import { basenameOfPath, getVscodeIconUrlForEntry } from "../vscode-icons"; import { useTheme } from "../hooks/useTheme"; import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; +import { + buildTurnDiffTree, + summarizeTurnDiffStats, + type TurnDiffTreeNode, +} from "../lib/turnDiffTree"; import BranchToolbar from "./BranchToolbar"; import GitActionsControl from "./GitActionsControl"; import { @@ -3126,6 +3132,126 @@ const MessageCopyButton = memo(function MessageCopyButton({ text }: { text: stri ); }); +function hasNonZeroStat(stat: { additions: number; deletions: number }): boolean { + return stat.additions > 0 || stat.deletions > 0; +} + +const DiffStatLabel = memo(function DiffStatLabel(props: { + additions: number; + deletions: number; + showParentheses?: boolean; +}) { + const { additions, deletions, showParentheses = false } = props; + return ( + <> + {showParentheses && (} + +{additions} + / + -{deletions} + {showParentheses && )} + + ); +}); + +function buildInitiallyExpandedDirectoryState( + nodes: ReadonlyArray, +): Record { + const expandedState: Record = {}; + for (const node of nodes) { + if (node.kind === "directory") { + expandedState[node.path] = true; + } + } + return expandedState; +} + +const ChangedFilesTree = memo(function ChangedFilesTree(props: { + turnId: TurnId; + files: ReadonlyArray; + onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; +}) { + const { files, onOpenTurnDiff, turnId } = props; + const treeNodes = useMemo(() => buildTurnDiffTree(files), [files]); + const [expandedDirectories, setExpandedDirectories] = useState>( + () => buildInitiallyExpandedDirectoryState(treeNodes), + ); + + const toggleDirectory = useCallback((pathValue: string) => { + setExpandedDirectories((current) => ({ + ...current, + [pathValue]: !current[pathValue], + })); + }, []); + + const renderTreeNode = (node: TurnDiffTreeNode, depth: number) => { + const leftPadding = 8 + depth * 14; + if (node.kind === "directory") { + const isExpanded = expandedDirectories[node.path] ?? depth === 0; + return ( +
+ + {isExpanded && ( +
+ {node.children.map((childNode) => renderTreeNode(childNode, depth + 1))} +
+ )} +
+ ); + } + + return ( + + ); + }; + + return ( +
+ {treeNodes.map((node) => renderTreeNode(node, 0))} +
+ ); +}); + interface MessagesTimelineProps { hasMessages: boolean; isWorking: boolean; @@ -3507,33 +3633,20 @@ const MessagesTimeline = memo(function MessagesTimeline({ if (!turnSummary) return null; const checkpointFiles = turnSummary.files; if (checkpointFiles.length === 0) return null; - const summaryStat = checkpointFiles.reduce( - (acc, file) => { - if ( - typeof file.additions !== "number" || - typeof file.deletions !== "number" - ) { - return acc; - } - return { - additions: acc.additions + file.additions, - deletions: acc.deletions + file.deletions, - }; - }, - { additions: 0, deletions: 0 }, - ); + const summaryStat = summarizeTurnDiffStats(checkpointFiles); const changedFileCountLabel = String(checkpointFiles.length); return (

Changed files ({changedFileCountLabel}) - {(summaryStat.additions > 0 || summaryStat.deletions > 0) && ( + {hasNonZeroStat(summaryStat) && ( <> • - +{summaryStat.additions} - / - -{summaryStat.deletions} + )}

@@ -3548,40 +3661,12 @@ const MessagesTimeline = memo(function MessagesTimeline({ View diff
-
- {checkpointFiles.map((file) => ( - - ))} -
+
); })()} diff --git a/apps/web/src/lib/turnDiffTree.test.ts b/apps/web/src/lib/turnDiffTree.test.ts new file mode 100644 index 000000000000..f852e40c61f8 --- /dev/null +++ b/apps/web/src/lib/turnDiffTree.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, it } from "vitest"; + +import { buildTurnDiffTree, summarizeTurnDiffStats } from "./turnDiffTree"; + +describe("summarizeTurnDiffStats", () => { + it("sums only files with numeric additions/deletions", () => { + const stat = summarizeTurnDiffStats([ + { path: "README.md", additions: 3, deletions: 1 }, + { path: "docs/notes.md" }, + { path: "src/index.ts", additions: 5, deletions: 2 }, + ]); + + expect(stat).toEqual({ additions: 8, deletions: 3 }); + }); +}); + +describe("buildTurnDiffTree", () => { + it("builds nested directory nodes with aggregated stats", () => { + const tree = buildTurnDiffTree([ + { path: "src/index.ts", additions: 2, deletions: 1 }, + { path: "src/components/Button.tsx", additions: 4, deletions: 2 }, + { path: "README.md", additions: 1, deletions: 0 }, + ]); + + expect(tree).toEqual([ + { + kind: "directory", + name: "src", + path: "src", + stat: { additions: 6, deletions: 3 }, + children: [ + { + kind: "directory", + name: "components", + path: "src/components", + stat: { additions: 4, deletions: 2 }, + children: [ + { + kind: "file", + name: "Button.tsx", + path: "src/components/Button.tsx", + stat: { additions: 4, deletions: 2 }, + }, + ], + }, + { + kind: "file", + name: "index.ts", + path: "src/index.ts", + stat: { additions: 2, deletions: 1 }, + }, + ], + }, + { + kind: "file", + name: "README.md", + path: "README.md", + stat: { additions: 1, deletions: 0 }, + }, + ]); + }); + + it("keeps files without stat values and excludes them from directory totals", () => { + const tree = buildTurnDiffTree([ + { path: "docs/notes.md" }, + { path: "docs/todo.md", additions: 1, deletions: 1 }, + ]); + + expect(tree).toEqual([ + { + kind: "directory", + name: "docs", + path: "docs", + stat: { additions: 1, deletions: 1 }, + children: [ + { + kind: "file", + name: "notes.md", + path: "docs/notes.md", + stat: null, + }, + { + kind: "file", + name: "todo.md", + path: "docs/todo.md", + stat: { additions: 1, deletions: 1 }, + }, + ], + }, + ]); + }); + + it("normalizes file paths with windows separators", () => { + const tree = buildTurnDiffTree([{ path: "apps\\web\\src\\index.ts", additions: 2, deletions: 1 }]); + + expect(tree).toEqual([ + { + kind: "directory", + name: "apps", + path: "apps", + stat: { additions: 2, deletions: 1 }, + children: [ + { + kind: "directory", + name: "web", + path: "apps/web", + stat: { additions: 2, deletions: 1 }, + children: [ + { + kind: "directory", + name: "src", + path: "apps/web/src", + stat: { additions: 2, deletions: 1 }, + children: [ + { + kind: "file", + name: "index.ts", + path: "apps/web/src/index.ts", + stat: { additions: 2, deletions: 1 }, + }, + ], + }, + ], + }, + ], + }, + ]); + }); +}); diff --git a/apps/web/src/lib/turnDiffTree.ts b/apps/web/src/lib/turnDiffTree.ts new file mode 100644 index 000000000000..126f96783af4 --- /dev/null +++ b/apps/web/src/lib/turnDiffTree.ts @@ -0,0 +1,148 @@ +import type { TurnDiffFileChange } from "../types"; + +export interface TurnDiffStat { + additions: number; + deletions: number; +} + +export interface TurnDiffTreeDirectoryNode { + kind: "directory"; + name: string; + path: string; + stat: TurnDiffStat; + children: TurnDiffTreeNode[]; +} + +export interface TurnDiffTreeFileNode { + kind: "file"; + name: string; + path: string; + stat: TurnDiffStat | null; +} + +export type TurnDiffTreeNode = TurnDiffTreeDirectoryNode | TurnDiffTreeFileNode; + +interface MutableDirectoryNode { + name: string; + path: string; + stat: TurnDiffStat; + directories: Map; + files: TurnDiffTreeFileNode[]; +} + +const SORT_LOCALE_OPTIONS: Intl.CollatorOptions = { numeric: true, sensitivity: "base" }; + +function normalizePathSegments(pathValue: string): string[] { + return pathValue + .replaceAll("\\", "/") + .split("/") + .map((segment) => segment.trim()) + .filter((segment) => segment.length > 0); +} + +function compareByName(a: { name: string }, b: { name: string }): number { + return a.name.localeCompare(b.name, undefined, SORT_LOCALE_OPTIONS); +} + +function readStat(file: TurnDiffFileChange): TurnDiffStat | null { + if (typeof file.additions !== "number" || typeof file.deletions !== "number") { + return null; + } + return { + additions: file.additions, + deletions: file.deletions, + }; +} + +function toTreeNodes(directory: MutableDirectoryNode): TurnDiffTreeNode[] { + const subdirectories: TurnDiffTreeDirectoryNode[] = Array.from(directory.directories.values()) + .toSorted(compareByName) + .map((subdirectory) => ({ + kind: "directory", + name: subdirectory.name, + path: subdirectory.path, + stat: { + additions: subdirectory.stat.additions, + deletions: subdirectory.stat.deletions, + }, + children: toTreeNodes(subdirectory), + })); + + const files = directory.files.toSorted(compareByName); + return [...subdirectories, ...files]; +} + +export function summarizeTurnDiffStats(files: ReadonlyArray): TurnDiffStat { + return files.reduce( + (acc, file) => { + const stat = readStat(file); + if (!stat) return acc; + return { + additions: acc.additions + stat.additions, + deletions: acc.deletions + stat.deletions, + }; + }, + { additions: 0, deletions: 0 }, + ); +} + +export function buildTurnDiffTree(files: ReadonlyArray): TurnDiffTreeNode[] { + const root: MutableDirectoryNode = { + name: "", + path: "", + stat: { additions: 0, deletions: 0 }, + directories: new Map(), + files: [], + }; + + for (const file of files) { + const segments = normalizePathSegments(file.path); + if (segments.length === 0) { + continue; + } + + const filePath = segments.join("/"); + const fileName = segments.at(-1); + if (!fileName) { + continue; + } + const stat = readStat(file); + const ancestors: MutableDirectoryNode[] = [root]; + let currentDirectory = root; + + for (const segment of segments.slice(0, -1)) { + const nextPath = currentDirectory.path ? `${currentDirectory.path}/${segment}` : segment; + const existing = currentDirectory.directories.get(segment); + if (existing) { + currentDirectory = existing; + } else { + const created: MutableDirectoryNode = { + name: segment, + path: nextPath, + stat: { additions: 0, deletions: 0 }, + directories: new Map(), + files: [], + }; + currentDirectory.directories.set(segment, created); + currentDirectory = created; + } + ancestors.push(currentDirectory); + } + + currentDirectory.files.push({ + kind: "file", + name: fileName, + path: filePath, + stat, + }); + + if (stat) { + for (const ancestor of ancestors) { + ancestor.stat.additions += stat.additions; + ancestor.stat.deletions += stat.deletions; + } + } + } + + return toTreeNodes(root); +} From 14acdb84e4936be05b90d4c92c76248715f50a92 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 5 Mar 2026 15:43:51 -0800 Subject: [PATCH 2/3] Improve changed-files tree UX and harden attachment path handling - Compact single-directory chains in turn diff trees and preserve branch points - Add per-turn expand/collapse-all controls and themed VS Code file icons in ChatView - Centralize attachment route prefix stripping and tighten image/extension normalization helpers --- apps/server/src/attachmentPaths.ts | 8 ++ apps/server/src/attachmentStore.ts | 1 + apps/server/src/config.ts | 5 +- apps/server/src/git/Errors.ts | 2 + apps/server/src/git/Services/GitManager.ts | 2 + .../server/src/git/Services/TextGeneration.ts | 2 + apps/server/src/imageMime.ts | 30 +++++- apps/server/src/index.ts | 1 + apps/server/src/main.ts | 18 ++-- apps/server/src/os-jank.ts | 1 + apps/server/src/wsServer.ts | 8 +- apps/web/src/components/ChatView.tsx | 99 ++++++++++++++----- apps/web/src/lib/turnDiffTree.test.ts | 52 +++++++--- apps/web/src/lib/turnDiffTree.ts | 29 +++++- 14 files changed, 203 insertions(+), 55 deletions(-) diff --git a/apps/server/src/attachmentPaths.ts b/apps/server/src/attachmentPaths.ts index c1680f6c082f..be404076bd6f 100644 --- a/apps/server/src/attachmentPaths.ts +++ b/apps/server/src/attachmentPaths.ts @@ -2,6 +2,14 @@ import path from "node:path"; export const ATTACHMENTS_ROUTE_PREFIX = "/attachments"; +export function stripAttachmentRoutePrefix(pathname: string): string | null { + if (!pathname.startsWith(ATTACHMENTS_ROUTE_PREFIX)) { + return null; + } + const remainder = pathname.slice(ATTACHMENTS_ROUTE_PREFIX.length); + return remainder.startsWith("/") ? remainder.slice(1) : remainder; +} + export function normalizeAttachmentRelativePath(rawRelativePath: string): string | null { const normalized = path.normalize(rawRelativePath).replace(/^[/\\]+/, ""); if (normalized.length === 0 || normalized.startsWith("..") || normalized.includes("\0")) { diff --git a/apps/server/src/attachmentStore.ts b/apps/server/src/attachmentStore.ts index 48be2df8a6cf..5fcebd64570f 100644 --- a/apps/server/src/attachmentStore.ts +++ b/apps/server/src/attachmentStore.ts @@ -14,6 +14,7 @@ const ATTACHMENT_ID_THREAD_SEGMENT_MAX_CHARS = 80; const ATTACHMENT_ID_THREAD_SEGMENT_PATTERN = "[a-z0-9_]+(?:-[a-z0-9_]+)*"; const ATTACHMENT_ID_UUID_PATTERN = "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"; +// Keep this regex strict so attachment IDs cannot be used as path traversal vectors. const ATTACHMENT_ID_PATTERN = new RegExp( `^(${ATTACHMENT_ID_THREAD_SEGMENT_PATTERN})-(${ATTACHMENT_ID_UUID_PATTERN})$`, "i", diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index ccbcea469d6f..ce226dc7b01e 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -9,9 +9,12 @@ import { Effect, FileSystem, Layer, Path, ServiceMap } from "effect"; export const DEFAULT_PORT = 3773; +export const KEYBINDINGS_CONFIG_FILENAME = "keybindings.json" as const; export type RuntimeMode = "web" | "desktop"; +export const isDesktopMode = (mode: RuntimeMode): mode is "desktop" => mode === "desktop"; + /** * ServerConfigShape - Process/runtime configuration required by the server. */ @@ -50,7 +53,7 @@ export class ServerConfig extends ServiceMap.Service()( /** * GitManagerServiceError - Errors emitted by stacked Git workflow orchestration. + * + * Keep this union centralized so callers can pattern-match once at API boundaries. */ export type GitManagerServiceError = | GitManagerError diff --git a/apps/server/src/git/Services/GitManager.ts b/apps/server/src/git/Services/GitManager.ts index e3f90202dd92..d917d4c1e918 100644 --- a/apps/server/src/git/Services/GitManager.ts +++ b/apps/server/src/git/Services/GitManager.ts @@ -38,6 +38,8 @@ export interface GitManagerShape { /** * GitManager - Service tag for stacked Git workflow orchestration. + * + * Single tag keeps orchestration wiring swappable in Layer-based tests. */ export class GitManager extends ServiceMap.Service()( "t3/git/Services/GitManager", diff --git a/apps/server/src/git/Services/TextGeneration.ts b/apps/server/src/git/Services/TextGeneration.ts index daae27fe66d4..877d1904ece2 100644 --- a/apps/server/src/git/Services/TextGeneration.ts +++ b/apps/server/src/git/Services/TextGeneration.ts @@ -62,6 +62,8 @@ export interface TextGenerationService { /** * TextGenerationShape - Service API for commit/PR text generation. + * + * Interface isolates provider-specific model behavior behind deterministic method contracts. */ export interface TextGenerationShape { /** diff --git a/apps/server/src/imageMime.ts b/apps/server/src/imageMime.ts index 814abbb32c1c..8131dcec3a29 100644 --- a/apps/server/src/imageMime.ts +++ b/apps/server/src/imageMime.ts @@ -29,6 +29,28 @@ export const SAFE_IMAGE_FILE_EXTENSIONS = new Set([ ".webp", ]); +const normalizeImageExtension = (extension: string): string | null => { + const trimmed = extension.trim(); + if (trimmed.length === 0) { + return null; + } + + const lower = trimmed.toLowerCase(); + return lower.startsWith(".") ? lower : `.${lower}`; +}; + +const coerceSafeImageExtension = ( + extension: string | null | undefined, +): string | null => { + if (!extension) return null; + const normalized = normalizeImageExtension(extension); + if (!normalized) return null; + return SAFE_IMAGE_FILE_EXTENSIONS.has(normalized) ? normalized : null; +}; + +export const isSafeImageExtension = (extension: string): boolean => + coerceSafeImageExtension(extension) !== null; + export function parseBase64DataUrl( dataUrl: string, ): { readonly mimeType: string; readonly base64: string } | null { @@ -63,15 +85,15 @@ export function inferImageExtension(input: { mimeType: string; fileName?: string return fromMime; } - const fromMimeExtension = Mime.getExtension(input.mimeType); - if (fromMimeExtension && SAFE_IMAGE_FILE_EXTENSIONS.has(fromMimeExtension)) { + const fromMimeExtension = coerceSafeImageExtension(Mime.getExtension(input.mimeType)); + if (fromMimeExtension) { return fromMimeExtension; } const fileName = input.fileName?.trim() ?? ""; const extensionMatch = /\.([a-z0-9]{1,8})$/i.exec(fileName); - const fileNameExtension = extensionMatch ? `.${extensionMatch[1]!.toLowerCase()}` : ""; - if (SAFE_IMAGE_FILE_EXTENSIONS.has(fileNameExtension)) { + const fileNameExtension = extensionMatch ? coerceSafeImageExtension(extensionMatch[1]) : null; + if (fileNameExtension) { return fileNameExtension; } diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 6eb26602cab9..8cdd090ec94e 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -10,6 +10,7 @@ import { version } from "../package.json" with { type: "json" }; import { ServerLive } from "./wsServer"; import { NetService } from "@t3tools/shared/Net"; +// Compose all required services once so every CLI invocation shares the same runtime. const RuntimeLayer = Layer.empty.pipe( Layer.provideMerge(CliConfig.layer), Layer.provideMerge(ServerLive), diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index 4f36e7ea1291..2ea89cb609b1 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -12,8 +12,10 @@ import { NetService } from "@t3tools/shared/Net"; import { DEFAULT_PORT, + KEYBINDINGS_CONFIG_FILENAME, resolveStaticDir, ServerConfig, + isDesktopMode, type RuntimeMode, type ServerConfigShape, } from "./config"; @@ -133,6 +135,8 @@ const ServerConfigLive = (input: CliInput) => ); const mode = Option.getOrElse(input.mode, () => env.mode); + const isDesktop = isDesktopMode(mode); + const isWeb = !isDesktop; const port = yield* Option.match(input.port, { onSome: (value) => Effect.succeed(value), @@ -140,7 +144,7 @@ const ServerConfigLive = (input: CliInput) => if (env.port) { return Effect.succeed(env.port); } - if (mode === "desktop") { + if (isDesktop) { return Effect.succeed(DEFAULT_PORT); } return findAvailablePort(DEFAULT_PORT); @@ -153,15 +157,15 @@ const ServerConfigLive = (input: CliInput) => const noBrowser = Option.match(input.noBrowser, { // effect/cli boolean flags parse to `false` when absent; in that case // we still want env/mode fallbacks to apply. - onSome: (value) => (value ? true : (env.noBrowser ?? mode === "desktop")), - onNone: () => env.noBrowser ?? mode === "desktop", + onSome: (value) => (value ? true : (env.noBrowser ?? isDesktop)), + onNone: () => env.noBrowser ?? isDesktop, }); const authToken = Option.getOrUndefined(input.authToken) ?? env.authToken; const autoBootstrapProjectFromCwd = Option.match(input.autoBootstrapProjectFromCwd, { // effect/cli boolean flags parse to `false` when absent; in that case // we still want env/mode fallbacks to apply. - onSome: (value) => (value ? true : (env.autoBootstrapProjectFromCwd ?? mode === "web")), - onNone: () => env.autoBootstrapProjectFromCwd ?? mode === "web", + onSome: (value) => (value ? true : (env.autoBootstrapProjectFromCwd ?? isWeb)), + onNone: () => env.autoBootstrapProjectFromCwd ?? isWeb, }); const logWebSocketEvents = Option.match(input.logWebSocketEvents, { // effect/cli boolean flags parse to `false` when absent; in that case @@ -171,11 +175,11 @@ const ServerConfigLive = (input: CliInput) => }); const staticDir = devUrl ? undefined : yield* cliConfig.resolveStaticDir; const { join } = yield* Path.Path; - const keybindingsConfigPath = join(stateDir, "keybindings.json"); + const keybindingsConfigPath = join(stateDir, KEYBINDINGS_CONFIG_FILENAME); const host = Option.getOrUndefined(input.host) ?? env.host ?? - (mode === "desktop" ? "127.0.0.1" : undefined); + (isDesktop ? "127.0.0.1" : undefined); return { mode, diff --git a/apps/server/src/os-jank.ts b/apps/server/src/os-jank.ts index 3f5e2129da06..978b2414e6ad 100644 --- a/apps/server/src/os-jank.ts +++ b/apps/server/src/os-jank.ts @@ -3,6 +3,7 @@ import { Effect, Path } from "effect"; import { execFileSync } from "node:child_process"; export function fixPath(): void { + // Only macOS shells need PATH hydration from interactive startup files. if (process.platform !== "darwin") return; try { diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index 50af3b8b10ef..310bf756288d 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -60,9 +60,9 @@ import { ServerConfig } from "./config"; import { GitCore } from "./git/Services/GitCore.ts"; import { tryHandleProjectFaviconRequest } from "./projectFaviconRoute"; import { - ATTACHMENTS_ROUTE_PREFIX, normalizeAttachmentRelativePath, resolveAttachmentRelativePath, + stripAttachmentRoutePrefix, } from "./attachmentPaths"; import { createAttachmentId, @@ -350,9 +350,9 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< return; } - if (url.pathname.startsWith(ATTACHMENTS_ROUTE_PREFIX)) { - const rawRelativePath = url.pathname.slice(ATTACHMENTS_ROUTE_PREFIX.length); - const normalizedRelativePath = normalizeAttachmentRelativePath(rawRelativePath); + const routeRelativePath = stripAttachmentRoutePrefix(url.pathname); + if (routeRelativePath !== null) { + const normalizedRelativePath = normalizeAttachmentRelativePath(routeRelativePath); if (!normalizedRelativePath) { respond(400, { "Content-Type": "text/plain" }, "Invalid attachment path"); return; diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 4f9fe1574966..9e4c8381ca1b 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -339,6 +339,7 @@ const VscodeEntryIcon = memo(function VscodeEntryIcon(props: { pathValue: string; kind: "file" | "directory"; theme: "light" | "dark"; + className?: string; }) { const [failedIconUrl, setFailedIconUrl] = useState(null); const iconUrl = useMemo( @@ -349,9 +350,9 @@ const VscodeEntryIcon = memo(function VscodeEntryIcon(props: { if (failed) { return props.kind === "directory" ? ( - + ) : ( - + ); } @@ -360,7 +361,7 @@ const VscodeEntryIcon = memo(function VscodeEntryIcon(props: { src={iconUrl} alt="" aria-hidden="true" - className="size-4 shrink-0" + className={cn("size-4 shrink-0", props.className)} loading="lazy" onError={() => setFailedIconUrl(iconUrl)} /> @@ -2553,6 +2554,7 @@ export default function ChatView({ threadId }: ChatViewProps) { isRevertingCheckpoint={isRevertingCheckpoint} onImageExpand={onExpandTimelineImage} markdownCwd={gitCwd ?? undefined} + resolvedTheme={resolvedTheme} /> @@ -3153,14 +3155,23 @@ const DiffStatLabel = memo(function DiffStatLabel(props: { ); }); -function buildInitiallyExpandedDirectoryState( - nodes: ReadonlyArray, +function collectDirectoryPaths(nodes: ReadonlyArray): string[] { + const paths: string[] = []; + for (const node of nodes) { + if (node.kind !== "directory") continue; + paths.push(node.path); + paths.push(...collectDirectoryPaths(node.children)); + } + return paths; +} + +function buildDirectoryExpansionState( + directoryPaths: ReadonlyArray, + expanded: boolean, ): Record { const expandedState: Record = {}; - for (const node of nodes) { - if (node.kind === "directory") { - expandedState[node.path] = true; - } + for (const directoryPath of directoryPaths) { + expandedState[directoryPath] = expanded; } return expandedState; } @@ -3168,13 +3179,27 @@ function buildInitiallyExpandedDirectoryState( const ChangedFilesTree = memo(function ChangedFilesTree(props: { turnId: TurnId; files: ReadonlyArray; + allDirectoriesExpanded: boolean; + resolvedTheme: "light" | "dark"; onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; }) { - const { files, onOpenTurnDiff, turnId } = props; + const { files, allDirectoriesExpanded, onOpenTurnDiff, resolvedTheme, turnId } = props; const treeNodes = useMemo(() => buildTurnDiffTree(files), [files]); + const directoryPathsKey = useMemo(() => collectDirectoryPaths(treeNodes).join("\u0000"), [treeNodes]); + const allDirectoryExpansionState = useMemo( + () => + buildDirectoryExpansionState( + directoryPathsKey ? directoryPathsKey.split("\u0000") : [], + allDirectoriesExpanded, + ), + [allDirectoriesExpanded, directoryPathsKey], + ); const [expandedDirectories, setExpandedDirectories] = useState>( - () => buildInitiallyExpandedDirectoryState(treeNodes), + () => buildDirectoryExpansionState((directoryPathsKey ? directoryPathsKey.split("\u0000") : []), true), ); + useEffect(() => { + setExpandedDirectories(allDirectoryExpansionState); + }, [allDirectoryExpansionState]); const toggleDirectory = useCallback((pathValue: string) => { setExpandedDirectories((current) => ({ @@ -3232,7 +3257,12 @@ const ChangedFilesTree = memo(function ChangedFilesTree(props: { onClick={() => onOpenTurnDiff(turnId, node.path)} >