authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-03-05 15:43:51-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-03-05 15:56:40-08:00
log5c03804bac20c305159c2a42c6da7126166e8960
treeae63bde9e3838347fe7e8cc29b97d4497511e96d
parentd6ed393255c206008897529e8c006a40a9199a32

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

14 files changed, 203 insertions(+), 55 deletions(-)

apps/server/src/attachmentPaths.ts+8
...@@ -2,6 +2,14 @@ import path from "node:path";...@@ -2,6 +2,14 @@ import path from "node:path";
22
3export const ATTACHMENTS_ROUTE_PREFIX = "/attachments";3export const ATTACHMENTS_ROUTE_PREFIX = "/attachments";
44
5export function stripAttachmentRoutePrefix(pathname: string): string | null {
6 if (!pathname.startsWith(ATTACHMENTS_ROUTE_PREFIX)) {
7 return null;
8 }
9 const remainder = pathname.slice(ATTACHMENTS_ROUTE_PREFIX.length);
10 return remainder.startsWith("/") ? remainder.slice(1) : remainder;
11}
12
5export function normalizeAttachmentRelativePath(rawRelativePath: string): string | null {13export function normalizeAttachmentRelativePath(rawRelativePath: string): string | null {
6 const normalized = path.normalize(rawRelativePath).replace(/^[/\\]+/, "");14 const normalized = path.normalize(rawRelativePath).replace(/^[/\\]+/, "");
7 if (normalized.length === 0 || normalized.startsWith("..") || normalized.includes("\0")) {15 if (normalized.length === 0 || normalized.startsWith("..") || normalized.includes("\0")) {
apps/server/src/attachmentStore.ts+1
...@@ -14,6 +14,7 @@ const ATTACHMENT_ID_THREAD_SEGMENT_MAX_CHARS = 80;...@@ -14,6 +14,7 @@ const ATTACHMENT_ID_THREAD_SEGMENT_MAX_CHARS = 80;
14const ATTACHMENT_ID_THREAD_SEGMENT_PATTERN = "[a-z0-9_]+(?:-[a-z0-9_]+)*";14const ATTACHMENT_ID_THREAD_SEGMENT_PATTERN = "[a-z0-9_]+(?:-[a-z0-9_]+)*";
15const ATTACHMENT_ID_UUID_PATTERN =15const ATTACHMENT_ID_UUID_PATTERN =
16 "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}";16 "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}";
17// Keep this regex strict so attachment IDs cannot be used as path traversal vectors.
17const ATTACHMENT_ID_PATTERN = new RegExp(18const ATTACHMENT_ID_PATTERN = new RegExp(
18 `^(${ATTACHMENT_ID_THREAD_SEGMENT_PATTERN})-(${ATTACHMENT_ID_UUID_PATTERN})$`,19 `^(${ATTACHMENT_ID_THREAD_SEGMENT_PATTERN})-(${ATTACHMENT_ID_UUID_PATTERN})$`,
19 "i",20 "i",
apps/server/src/config.ts+4-1
...@@ -9,9 +9,12 @@...@@ -9,9 +9,12 @@
9import { Effect, FileSystem, Layer, Path, ServiceMap } from "effect";9import { Effect, FileSystem, Layer, Path, ServiceMap } from "effect";
1010
11export const DEFAULT_PORT = 3773;11export const DEFAULT_PORT = 3773;
12export const KEYBINDINGS_CONFIG_FILENAME = "keybindings.json" as const;
1213
13export type RuntimeMode = "web" | "desktop";14export type RuntimeMode = "web" | "desktop";
1415
16export const isDesktopMode = (mode: RuntimeMode): mode is "desktop" => mode === "desktop";
17
15/**18/**
16 * ServerConfigShape - Process/runtime configuration required by the server.19 * ServerConfigShape - Process/runtime configuration required by the server.
17 */20 */
...@@ -50,7 +53,7 @@ export class ServerConfig extends ServiceMap.Service<ServerConfig, ServerConfigS...@@ -50,7 +53,7 @@ export class ServerConfig extends ServiceMap.Service<ServerConfig, ServerConfigS
50 port: 0,53 port: 0,
51 host: undefined,54 host: undefined,
52 authToken: undefined,55 authToken: undefined,
53 keybindingsConfigPath: path.join(statedir, "keybindings.json"),56 keybindingsConfigPath: path.join(statedir, KEYBINDINGS_CONFIG_FILENAME),
54 staticDir: undefined,57 staticDir: undefined,
55 devUrl: undefined,58 devUrl: undefined,
56 noBrowser: false,59 noBrowser: false,
apps/server/src/git/Errors.ts+2
...@@ -59,6 +59,8 @@ export class GitManagerError extends Schema.TaggedErrorClass<GitManagerError>()(...@@ -59,6 +59,8 @@ export class GitManagerError extends Schema.TaggedErrorClass<GitManagerError>()(
5959
60/**60/**
61 * GitManagerServiceError - Errors emitted by stacked Git workflow orchestration.61 * GitManagerServiceError - Errors emitted by stacked Git workflow orchestration.
62 *
63 * Keep this union centralized so callers can pattern-match once at API boundaries.
62 */64 */
63export type GitManagerServiceError =65export type GitManagerServiceError =
64 | GitManagerError66 | GitManagerError
apps/server/src/git/Services/GitManager.ts+2
...@@ -38,6 +38,8 @@ export interface GitManagerShape {...@@ -38,6 +38,8 @@ export interface GitManagerShape {
3838
39/**39/**
40 * GitManager - Service tag for stacked Git workflow orchestration.40 * GitManager - Service tag for stacked Git workflow orchestration.
41 *
42 * Single tag keeps orchestration wiring swappable in Layer-based tests.
41 */43 */
42export class GitManager extends ServiceMap.Service<GitManager, GitManagerShape>()(44export class GitManager extends ServiceMap.Service<GitManager, GitManagerShape>()(
43 "t3/git/Services/GitManager",45 "t3/git/Services/GitManager",
apps/server/src/git/Services/TextGeneration.ts+2
...@@ -62,6 +62,8 @@ export interface TextGenerationService {...@@ -62,6 +62,8 @@ export interface TextGenerationService {
6262
63/**63/**
64 * TextGenerationShape - Service API for commit/PR text generation.64 * TextGenerationShape - Service API for commit/PR text generation.
65 *
66 * Interface isolates provider-specific model behavior behind deterministic method contracts.
65 */67 */
66export interface TextGenerationShape {68export interface TextGenerationShape {
67 /**69 /**
apps/server/src/imageMime.ts+26-4
...@@ -29,6 +29,28 @@ export const SAFE_IMAGE_FILE_EXTENSIONS = new Set([...@@ -29,6 +29,28 @@ export const SAFE_IMAGE_FILE_EXTENSIONS = new Set([
29 ".webp",29 ".webp",
30]);30]);
3131
32const normalizeImageExtension = (extension: string): string | null => {
33 const trimmed = extension.trim();
34 if (trimmed.length === 0) {
35 return null;
36 }
37
38 const lower = trimmed.toLowerCase();
39 return lower.startsWith(".") ? lower : `.${lower}`;
40};
41
42const coerceSafeImageExtension = (
43 extension: string | null | undefined,
44): string | null => {
45 if (!extension) return null;
46 const normalized = normalizeImageExtension(extension);
47 if (!normalized) return null;
48 return SAFE_IMAGE_FILE_EXTENSIONS.has(normalized) ? normalized : null;
49};
50
51export const isSafeImageExtension = (extension: string): boolean =>
52 coerceSafeImageExtension(extension) !== null;
53
32export function parseBase64DataUrl(54export function parseBase64DataUrl(
33 dataUrl: string,55 dataUrl: string,
34): { readonly mimeType: string; readonly base64: string } | null {56): { readonly mimeType: string; readonly base64: string } | null {
...@@ -63,15 +85,15 @@ export function inferImageExtension(input: { mimeType: string; fileName?: string...@@ -63,15 +85,15 @@ export function inferImageExtension(input: { mimeType: string; fileName?: string
63 return fromMime;85 return fromMime;
64 }86 }
6587
66 const fromMimeExtension = Mime.getExtension(input.mimeType);88 const fromMimeExtension = coerceSafeImageExtension(Mime.getExtension(input.mimeType));
67 if (fromMimeExtension && SAFE_IMAGE_FILE_EXTENSIONS.has(fromMimeExtension)) {89 if (fromMimeExtension) {
68 return fromMimeExtension;90 return fromMimeExtension;
69 }91 }
7092
71 const fileName = input.fileName?.trim() ?? "";93 const fileName = input.fileName?.trim() ?? "";
72 const extensionMatch = /\.([a-z0-9]{1,8})$/i.exec(fileName);94 const extensionMatch = /\.([a-z0-9]{1,8})$/i.exec(fileName);
73 const fileNameExtension = extensionMatch ? `.${extensionMatch[1]!.toLowerCase()}` : "";95 const fileNameExtension = extensionMatch ? coerceSafeImageExtension(extensionMatch[1]) : null;
74 if (SAFE_IMAGE_FILE_EXTENSIONS.has(fileNameExtension)) {96 if (fileNameExtension) {
75 return fileNameExtension;97 return fileNameExtension;
76 }98 }
7799
apps/server/src/index.ts+1
...@@ -10,6 +10,7 @@ import { version } from "../package.json" with { type: "json" };...@@ -10,6 +10,7 @@ import { version } from "../package.json" with { type: "json" };
10import { ServerLive } from "./wsServer";10import { ServerLive } from "./wsServer";
11import { NetService } from "@t3tools/shared/Net";11import { NetService } from "@t3tools/shared/Net";
1212
13// Compose all required services once so every CLI invocation shares the same runtime.
13const RuntimeLayer = Layer.empty.pipe(14const RuntimeLayer = Layer.empty.pipe(
14 Layer.provideMerge(CliConfig.layer),15 Layer.provideMerge(CliConfig.layer),
15 Layer.provideMerge(ServerLive),16 Layer.provideMerge(ServerLive),
apps/server/src/main.ts+11-7
...@@ -12,8 +12,10 @@ import { NetService } from "@t3tools/shared/Net";...@@ -12,8 +12,10 @@ import { NetService } from "@t3tools/shared/Net";
1212
13import {13import {
14 DEFAULT_PORT,14 DEFAULT_PORT,
15 KEYBINDINGS_CONFIG_FILENAME,
15 resolveStaticDir,16 resolveStaticDir,
16 ServerConfig,17 ServerConfig,
18 isDesktopMode,
17 type RuntimeMode,19 type RuntimeMode,
18 type ServerConfigShape,20 type ServerConfigShape,
19} from "./config";21} from "./config";
...@@ -133,6 +135,8 @@ const ServerConfigLive = (input: CliInput) =>...@@ -133,6 +135,8 @@ const ServerConfigLive = (input: CliInput) =>
133 );135 );
134136
135 const mode = Option.getOrElse(input.mode, () => env.mode);137 const mode = Option.getOrElse(input.mode, () => env.mode);
138 const isDesktop = isDesktopMode(mode);
139 const isWeb = !isDesktop;
136140
137 const port = yield* Option.match(input.port, {141 const port = yield* Option.match(input.port, {
138 onSome: (value) => Effect.succeed(value),142 onSome: (value) => Effect.succeed(value),
...@@ -140,7 +144,7 @@ const ServerConfigLive = (input: CliInput) =>...@@ -140,7 +144,7 @@ const ServerConfigLive = (input: CliInput) =>
140 if (env.port) {144 if (env.port) {
141 return Effect.succeed(env.port);145 return Effect.succeed(env.port);
142 }146 }
143 if (mode === "desktop") {147 if (isDesktop) {
144 return Effect.succeed(DEFAULT_PORT);148 return Effect.succeed(DEFAULT_PORT);
145 }149 }
146 return findAvailablePort(DEFAULT_PORT);150 return findAvailablePort(DEFAULT_PORT);
...@@ -153,15 +157,15 @@ const ServerConfigLive = (input: CliInput) =>...@@ -153,15 +157,15 @@ const ServerConfigLive = (input: CliInput) =>
153 const noBrowser = Option.match(input.noBrowser, {157 const noBrowser = Option.match(input.noBrowser, {
154 // effect/cli boolean flags parse to `false` when absent; in that case158 // effect/cli boolean flags parse to `false` when absent; in that case
155 // we still want env/mode fallbacks to apply.159 // we still want env/mode fallbacks to apply.
156 onSome: (value) => (value ? true : (env.noBrowser ?? mode === "desktop")),160 onSome: (value) => (value ? true : (env.noBrowser ?? isDesktop)),
157 onNone: () => env.noBrowser ?? mode === "desktop",161 onNone: () => env.noBrowser ?? isDesktop,
158 });162 });
159 const authToken = Option.getOrUndefined(input.authToken) ?? env.authToken;163 const authToken = Option.getOrUndefined(input.authToken) ?? env.authToken;
160 const autoBootstrapProjectFromCwd = Option.match(input.autoBootstrapProjectFromCwd, {164 const autoBootstrapProjectFromCwd = Option.match(input.autoBootstrapProjectFromCwd, {
161 // effect/cli boolean flags parse to `false` when absent; in that case165 // effect/cli boolean flags parse to `false` when absent; in that case
162 // we still want env/mode fallbacks to apply.166 // we still want env/mode fallbacks to apply.
163 onSome: (value) => (value ? true : (env.autoBootstrapProjectFromCwd ?? mode === "web")),167 onSome: (value) => (value ? true : (env.autoBootstrapProjectFromCwd ?? isWeb)),
164 onNone: () => env.autoBootstrapProjectFromCwd ?? mode === "web",168 onNone: () => env.autoBootstrapProjectFromCwd ?? isWeb,
165 });169 });
166 const logWebSocketEvents = Option.match(input.logWebSocketEvents, {170 const logWebSocketEvents = Option.match(input.logWebSocketEvents, {
167 // effect/cli boolean flags parse to `false` when absent; in that case171 // effect/cli boolean flags parse to `false` when absent; in that case
...@@ -171,11 +175,11 @@ const ServerConfigLive = (input: CliInput) =>...@@ -171,11 +175,11 @@ const ServerConfigLive = (input: CliInput) =>
171 });175 });
172 const staticDir = devUrl ? undefined : yield* cliConfig.resolveStaticDir;176 const staticDir = devUrl ? undefined : yield* cliConfig.resolveStaticDir;
173 const { join } = yield* Path.Path;177 const { join } = yield* Path.Path;
174 const keybindingsConfigPath = join(stateDir, "keybindings.json");178 const keybindingsConfigPath = join(stateDir, KEYBINDINGS_CONFIG_FILENAME);
175 const host =179 const host =
176 Option.getOrUndefined(input.host) ??180 Option.getOrUndefined(input.host) ??
177 env.host ??181 env.host ??
178 (mode === "desktop" ? "127.0.0.1" : undefined);182 (isDesktop ? "127.0.0.1" : undefined);
179183
180 return {184 return {
181 mode,185 mode,
apps/server/src/os-jank.ts+1
...@@ -3,6 +3,7 @@ import { Effect, Path } from "effect";...@@ -3,6 +3,7 @@ import { Effect, Path } from "effect";
3import { execFileSync } from "node:child_process";3import { execFileSync } from "node:child_process";
44
5export function fixPath(): void {5export function fixPath(): void {
6 // Only macOS shells need PATH hydration from interactive startup files.
6 if (process.platform !== "darwin") return;7 if (process.platform !== "darwin") return;
78
8 try {9 try {
apps/server/src/wsServer.ts+4-4
...@@ -60,9 +60,9 @@ import { ServerConfig } from "./config";...@@ -60,9 +60,9 @@ import { ServerConfig } from "./config";
60import { GitCore } from "./git/Services/GitCore.ts";60import { GitCore } from "./git/Services/GitCore.ts";
61import { tryHandleProjectFaviconRequest } from "./projectFaviconRoute";61import { tryHandleProjectFaviconRequest } from "./projectFaviconRoute";
62import {62import {
63 ATTACHMENTS_ROUTE_PREFIX,
64 normalizeAttachmentRelativePath,63 normalizeAttachmentRelativePath,
65 resolveAttachmentRelativePath,64 resolveAttachmentRelativePath,
65 stripAttachmentRoutePrefix,
66} from "./attachmentPaths";66} from "./attachmentPaths";
67import {67import {
68 createAttachmentId,68 createAttachmentId,
...@@ -350,9 +350,9 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<...@@ -350,9 +350,9 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
350 return;350 return;
351 }351 }
352352
353 if (url.pathname.startsWith(ATTACHMENTS_ROUTE_PREFIX)) {353 const routeRelativePath = stripAttachmentRoutePrefix(url.pathname);
354 const rawRelativePath = url.pathname.slice(ATTACHMENTS_ROUTE_PREFIX.length);354 if (routeRelativePath !== null) {
355 const normalizedRelativePath = normalizeAttachmentRelativePath(rawRelativePath);355 const normalizedRelativePath = normalizeAttachmentRelativePath(routeRelativePath);
356 if (!normalizedRelativePath) {356 if (!normalizedRelativePath) {
357 respond(400, { "Content-Type": "text/plain" }, "Invalid attachment path");357 respond(400, { "Content-Type": "text/plain" }, "Invalid attachment path");
358 return;358 return;
apps/web/src/components/ChatView.tsx+77-22
...@@ -340,6 +340,7 @@ const VscodeEntryIcon = memo(function VscodeEntryIcon(props: {...@@ -340,6 +340,7 @@ const VscodeEntryIcon = memo(function VscodeEntryIcon(props: {
340 pathValue: string;340 pathValue: string;
341 kind: "file" | "directory";341 kind: "file" | "directory";
342 theme: "light" | "dark";342 theme: "light" | "dark";
343 className?: string;
343}) {344}) {
344 const [failedIconUrl, setFailedIconUrl] = useState<string | null>(null);345 const [failedIconUrl, setFailedIconUrl] = useState<string | null>(null);
345 const iconUrl = useMemo(346 const iconUrl = useMemo(
...@@ -350,9 +351,9 @@ const VscodeEntryIcon = memo(function VscodeEntryIcon(props: {...@@ -350,9 +351,9 @@ const VscodeEntryIcon = memo(function VscodeEntryIcon(props: {
350351
351 if (failed) {352 if (failed) {
352 return props.kind === "directory" ? (353 return props.kind === "directory" ? (
353 <FolderIcon className="size-4 text-muted-foreground/80" />354 <FolderIcon className={cn("size-4 text-muted-foreground/80", props.className)} />
354 ) : (355 ) : (
355 <FileIcon className="size-4 text-muted-foreground/80" />356 <FileIcon className={cn("size-4 text-muted-foreground/80", props.className)} />
356 );357 );
357 }358 }
358359
...@@ -361,7 +362,7 @@ const VscodeEntryIcon = memo(function VscodeEntryIcon(props: {...@@ -361,7 +362,7 @@ const VscodeEntryIcon = memo(function VscodeEntryIcon(props: {
361 src={iconUrl}362 src={iconUrl}
362 alt=""363 alt=""
363 aria-hidden="true"364 aria-hidden="true"
364 className="size-4 shrink-0"365 className={cn("size-4 shrink-0", props.className)}
365 loading="lazy"366 loading="lazy"
366 onError={() => setFailedIconUrl(iconUrl)}367 onError={() => setFailedIconUrl(iconUrl)}
367 />368 />
...@@ -2554,6 +2555,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -2554,6 +2555,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
2554 isRevertingCheckpoint={isRevertingCheckpoint}2555 isRevertingCheckpoint={isRevertingCheckpoint}
2555 onImageExpand={onExpandTimelineImage}2556 onImageExpand={onExpandTimelineImage}
2556 markdownCwd={gitCwd ?? undefined}2557 markdownCwd={gitCwd ?? undefined}
2558 resolvedTheme={resolvedTheme}
2557 />2559 />
2558 </div>2560 </div>
25592561
...@@ -3154,14 +3156,23 @@ const DiffStatLabel = memo(function DiffStatLabel(props: {...@@ -3154,14 +3156,23 @@ const DiffStatLabel = memo(function DiffStatLabel(props: {
3154 );3156 );
3155});3157});
31563158
3157function buildInitiallyExpandedDirectoryState(3159function collectDirectoryPaths(nodes: ReadonlyArray<TurnDiffTreeNode>): string[] {
3158 nodes: ReadonlyArray<TurnDiffTreeNode>,3160 const paths: string[] = [];
3161 for (const node of nodes) {
3162 if (node.kind !== "directory") continue;
3163 paths.push(node.path);
3164 paths.push(...collectDirectoryPaths(node.children));
3165 }
3166 return paths;
3167}
3168
3169function buildDirectoryExpansionState(
3170 directoryPaths: ReadonlyArray<string>,
3171 expanded: boolean,
3159): Record<string, boolean> {3172): Record<string, boolean> {
3160 const expandedState: Record<string, boolean> = {};3173 const expandedState: Record<string, boolean> = {};
3161 for (const node of nodes) {3174 for (const directoryPath of directoryPaths) {
3162 if (node.kind === "directory") {3175 expandedState[directoryPath] = expanded;
3163 expandedState[node.path] = true;
3164 }
3165 }3176 }
3166 return expandedState;3177 return expandedState;
3167}3178}
...@@ -3169,13 +3180,27 @@ function buildInitiallyExpandedDirectoryState(...@@ -3169,13 +3180,27 @@ function buildInitiallyExpandedDirectoryState(
3169const ChangedFilesTree = memo(function ChangedFilesTree(props: {3180const ChangedFilesTree = memo(function ChangedFilesTree(props: {
3170 turnId: TurnId;3181 turnId: TurnId;
3171 files: ReadonlyArray<TurnDiffFileChange>;3182 files: ReadonlyArray<TurnDiffFileChange>;
3183 allDirectoriesExpanded: boolean;
3184 resolvedTheme: "light" | "dark";
3172 onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void;3185 onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void;
3173}) {3186}) {
3174 const { files, onOpenTurnDiff, turnId } = props;3187 const { files, allDirectoriesExpanded, onOpenTurnDiff, resolvedTheme, turnId } = props;
3175 const treeNodes = useMemo(() => buildTurnDiffTree(files), [files]);3188 const treeNodes = useMemo(() => buildTurnDiffTree(files), [files]);
3189 const directoryPathsKey = useMemo(() => collectDirectoryPaths(treeNodes).join("\u0000"), [treeNodes]);
3190 const allDirectoryExpansionState = useMemo(
3191 () =>
3192 buildDirectoryExpansionState(
3193 directoryPathsKey ? directoryPathsKey.split("\u0000") : [],
3194 allDirectoriesExpanded,
3195 ),
3196 [allDirectoriesExpanded, directoryPathsKey],
3197 );
3176 const [expandedDirectories, setExpandedDirectories] = useState<Record<string, boolean>>(3198 const [expandedDirectories, setExpandedDirectories] = useState<Record<string, boolean>>(
3177 () => buildInitiallyExpandedDirectoryState(treeNodes),3199 () => buildDirectoryExpansionState((directoryPathsKey ? directoryPathsKey.split("\u0000") : []), true),
3178 );3200 );
3201 useEffect(() => {
3202 setExpandedDirectories(allDirectoryExpansionState);
3203 }, [allDirectoryExpansionState]);
31793204
3180 const toggleDirectory = useCallback((pathValue: string) => {3205 const toggleDirectory = useCallback((pathValue: string) => {
3181 setExpandedDirectories((current) => ({3206 setExpandedDirectories((current) => ({
...@@ -3233,7 +3258,12 @@ const ChangedFilesTree = memo(function ChangedFilesTree(props: {...@@ -3233,7 +3258,12 @@ const ChangedFilesTree = memo(function ChangedFilesTree(props: {
3233 onClick={() => onOpenTurnDiff(turnId, node.path)}3258 onClick={() => onOpenTurnDiff(turnId, node.path)}
3234 >3259 >
3235 <span aria-hidden="true" className="size-3.5 shrink-0" />3260 <span aria-hidden="true" className="size-3.5 shrink-0" />
3236 <FileIcon className="size-3.5 shrink-0 text-muted-foreground/70" />3261 <VscodeEntryIcon
3262 pathValue={node.path}
3263 kind="file"
3264 theme={resolvedTheme}
3265 className="size-3.5 text-muted-foreground/70"
3266 />
3237 <span className="truncate font-mono text-[11px] text-muted-foreground/80 group-hover:text-foreground/90">3267 <span className="truncate font-mono text-[11px] text-muted-foreground/80 group-hover:text-foreground/90">
3238 {node.name}3268 {node.name}
3239 </span>3269 </span>
...@@ -3272,6 +3302,7 @@ interface MessagesTimelineProps {...@@ -3272,6 +3302,7 @@ interface MessagesTimelineProps {
3272 isRevertingCheckpoint: boolean;3302 isRevertingCheckpoint: boolean;
3273 onImageExpand: (preview: ExpandedImagePreview) => void;3303 onImageExpand: (preview: ExpandedImagePreview) => void;
3274 markdownCwd: string | undefined;3304 markdownCwd: string | undefined;
3305 resolvedTheme: "light" | "dark";
3275}3306}
32763307
3277type TimelineEntry = ReturnType<typeof deriveTimelineEntries>[number];3308type TimelineEntry = ReturnType<typeof deriveTimelineEntries>[number];
...@@ -3312,6 +3343,7 @@ const MessagesTimeline = memo(function MessagesTimeline({...@@ -3312,6 +3343,7 @@ const MessagesTimeline = memo(function MessagesTimeline({
3312 isRevertingCheckpoint,3343 isRevertingCheckpoint,
3313 onImageExpand,3344 onImageExpand,
3314 markdownCwd,3345 markdownCwd,
3346 resolvedTheme,
3315}: MessagesTimelineProps) {3347}: MessagesTimelineProps) {
3316 const timelineRootRef = useRef<HTMLDivElement | null>(null);3348 const timelineRootRef = useRef<HTMLDivElement | null>(null);
3317 const [timelineWidthPx, setTimelineWidthPx] = useState<number | null>(null);3349 const [timelineWidthPx, setTimelineWidthPx] = useState<number | null>(null);
...@@ -3484,6 +3516,15 @@ const MessagesTimeline = memo(function MessagesTimeline({...@@ -3484,6 +3516,15 @@ const MessagesTimeline = memo(function MessagesTimeline({
34843516
3485 const virtualRows = rowVirtualizer.getVirtualItems();3517 const virtualRows = rowVirtualizer.getVirtualItems();
3486 const nonVirtualizedRows = rows.slice(virtualizedRowCount);3518 const nonVirtualizedRows = rows.slice(virtualizedRowCount);
3519 const [allDirectoriesExpandedByTurnId, setAllDirectoriesExpandedByTurnId] = useState<
3520 Record<string, boolean>
3521 >({});
3522 const onToggleAllDirectories = useCallback((turnId: TurnId) => {
3523 setAllDirectoriesExpandedByTurnId((current) => ({
3524 ...current,
3525 [turnId]: !(current[turnId] ?? true),
3526 }));
3527 }, []);
34873528
3488 const renderRowContent = (row: TimelineRow) => (3529 const renderRowContent = (row: TimelineRow) => (
3489 <div3530 <div
...@@ -3659,6 +3700,8 @@ const MessagesTimeline = memo(function MessagesTimeline({...@@ -3659,6 +3700,8 @@ const MessagesTimeline = memo(function MessagesTimeline({
3659 if (checkpointFiles.length === 0) return null;3700 if (checkpointFiles.length === 0) return null;
3660 const summaryStat = summarizeTurnDiffStats(checkpointFiles);3701 const summaryStat = summarizeTurnDiffStats(checkpointFiles);
3661 const changedFileCountLabel = String(checkpointFiles.length);3702 const changedFileCountLabel = String(checkpointFiles.length);
3703 const allDirectoriesExpanded =
3704 allDirectoriesExpandedByTurnId[turnSummary.turnId] ?? true;
3662 return (3705 return (
3663 <div className="mt-2 rounded-lg border border-border/80 bg-card/45 p-2.5">3706 <div className="mt-2 rounded-lg border border-border/80 bg-card/45 p-2.5">
3664 <div className="mb-1.5 flex items-center justify-between gap-2">3707 <div className="mb-1.5 flex items-center justify-between gap-2">
...@@ -3674,21 +3717,33 @@ const MessagesTimeline = memo(function MessagesTimeline({...@@ -3674,21 +3717,33 @@ const MessagesTimeline = memo(function MessagesTimeline({
3674 </>3717 </>
3675 )}3718 )}
3676 </p>3719 </p>
3677 <Button3720 <div className="flex items-center gap-1.5">
3678 type="button"3721 <Button
3679 size="xs"3722 type="button"
3680 variant="outline"3723 size="xs"
3681 onClick={() =>3724 variant="outline"
3682 onOpenTurnDiff(turnSummary.turnId, checkpointFiles[0]?.path)3725 onClick={() => onToggleAllDirectories(turnSummary.turnId)}
3683 }3726 >
3684 >3727 {allDirectoriesExpanded ? "Collapse all" : "Expand all"}
3685 View diff3728 </Button>
3686 </Button>3729 <Button
3730 type="button"
3731 size="xs"
3732 variant="outline"
3733 onClick={() =>
3734 onOpenTurnDiff(turnSummary.turnId, checkpointFiles[0]?.path)
3735 }
3736 >
3737 View diff
3738 </Button>
3739 </div>
3687 </div>3740 </div>
3688 <ChangedFilesTree3741 <ChangedFilesTree
3689 key={`changed-files-tree:${turnSummary.turnId}`}3742 key={`changed-files-tree:${turnSummary.turnId}`}
3690 turnId={turnSummary.turnId}3743 turnId={turnSummary.turnId}
3691 files={checkpointFiles}3744 files={checkpointFiles}
3745 allDirectoriesExpanded={allDirectoriesExpanded}
3746 resolvedTheme={resolvedTheme}
3692 onOpenTurnDiff={onOpenTurnDiff}3747 onOpenTurnDiff={onOpenTurnDiff}
3693 />3748 />
3694 </div>3749 </div>
apps/web/src/lib/turnDiffTree.test.ts+37-15
...@@ -96,32 +96,54 @@ describe("buildTurnDiffTree", () => {...@@ -96,32 +96,54 @@ describe("buildTurnDiffTree", () => {
96 expect(tree).toEqual([96 expect(tree).toEqual([
97 {97 {
98 kind: "directory",98 kind: "directory",
99 name: "apps",99 name: "apps/web/src",
100 path: "apps",100 path: "apps/web/src",
101 stat: { additions: 2, deletions: 1 },101 stat: { additions: 2, deletions: 1 },
102 children: [
103 {
104 kind: "file",
105 name: "index.ts",
106 path: "apps/web/src/index.ts",
107 stat: { additions: 2, deletions: 1 },
108 },
109 ],
110 },
111 ]);
112 });
113
114 it("compacts only single-directory chains and stops at branch points", () => {
115 const tree = buildTurnDiffTree([
116 { path: "apps/server/src/index.ts", additions: 2, deletions: 1 },
117 { path: "apps/server/main.ts", additions: 4, deletions: 0 },
118 ]);
119
120 expect(tree).toEqual([
121 {
122 kind: "directory",
123 name: "apps/server",
124 path: "apps/server",
125 stat: { additions: 6, deletions: 1 },
102 children: [126 children: [
103 {127 {
104 kind: "directory",128 kind: "directory",
105 name: "web",129 name: "src",
106 path: "apps/web",130 path: "apps/server/src",
107 stat: { additions: 2, deletions: 1 },131 stat: { additions: 2, deletions: 1 },
108 children: [132 children: [
109 {133 {
110 kind: "directory",134 kind: "file",
111 name: "src",135 name: "index.ts",
112 path: "apps/web/src",136 path: "apps/server/src/index.ts",
113 stat: { additions: 2, deletions: 1 },137 stat: { additions: 2, deletions: 1 },
114 children: [
115 {
116 kind: "file",
117 name: "index.ts",
118 path: "apps/web/src/index.ts",
119 stat: { additions: 2, deletions: 1 },
120 },
121 ],
122 },138 },
123 ],139 ],
124 },140 },
141 {
142 kind: "file",
143 name: "main.ts",
144 path: "apps/server/main.ts",
145 stat: { additions: 4, deletions: 0 },
146 },
125 ],147 ],
126 },148 },
127 ]);149 ]);
apps/web/src/lib/turnDiffTree.ts+27-2
...@@ -54,10 +54,34 @@ function readStat(file: TurnDiffFileChange): TurnDiffStat | null {...@@ -54,10 +54,34 @@ function readStat(file: TurnDiffFileChange): TurnDiffStat | null {
54 };54 };
55}55}
5656
57function compactDirectoryNode(node: TurnDiffTreeDirectoryNode): TurnDiffTreeDirectoryNode {
58 const compactedChildren = node.children.map((child) =>
59 child.kind === "directory" ? compactDirectoryNode(child) : child,
60 );
61
62 let compactedNode: TurnDiffTreeDirectoryNode = {
63 ...node,
64 children: compactedChildren,
65 };
66
67 while (compactedNode.children.length === 1 && compactedNode.children[0]?.kind === "directory") {
68 const onlyChild = compactedNode.children[0];
69 compactedNode = {
70 kind: "directory",
71 name: `${compactedNode.name}/${onlyChild.name}`,
72 path: onlyChild.path,
73 stat: onlyChild.stat,
74 children: onlyChild.children,
75 };
76 }
77
78 return compactedNode;
79}
80
57function toTreeNodes(directory: MutableDirectoryNode): TurnDiffTreeNode[] {81function toTreeNodes(directory: MutableDirectoryNode): TurnDiffTreeNode[] {
58 const subdirectories: TurnDiffTreeDirectoryNode[] = Array.from(directory.directories.values())82 const subdirectories: TurnDiffTreeDirectoryNode[] = Array.from(directory.directories.values())
59 .toSorted(compareByName)83 .toSorted(compareByName)
60 .map((subdirectory) => ({84 .map<TurnDiffTreeDirectoryNode>((subdirectory) => ({
61 kind: "directory",85 kind: "directory",
62 name: subdirectory.name,86 name: subdirectory.name,
63 path: subdirectory.path,87 path: subdirectory.path,
...@@ -66,7 +90,8 @@ function toTreeNodes(directory: MutableDirectoryNode): TurnDiffTreeNode[] {...@@ -66,7 +90,8 @@ function toTreeNodes(directory: MutableDirectoryNode): TurnDiffTreeNode[] {
66 deletions: subdirectory.stat.deletions,90 deletions: subdirectory.stat.deletions,
67 },91 },
68 children: toTreeNodes(subdirectory),92 children: toTreeNodes(subdirectory),
69 }));93 }))
94 .map((subdirectory) => compactDirectoryNode(subdirectory));
7095
71 const files = directory.files.toSorted(compareByName);96 const files = directory.files.toSorted(compareByName);
72 return [...subdirectories, ...files];97 return [...subdirectories, ...files];