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
7 changes: 5 additions & 2 deletions apps/mobile/src/features/review/reviewModel.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { parsePatchFiles } from "@pierre/diffs/utils/parsePatchFiles";
import type { ChangeTypes, FileDiffMetadata } from "@pierre/diffs/types";
import type { ThreadCheckpointSummary } from "@t3tools/client-runtime/state/thread-checkpoints";
import {
checkpointFileTotals,
type ThreadCheckpointSummary,
} from "@t3tools/client-runtime/state/thread-checkpoints";
import type { ReviewDiffPreviewSource } from "@t3tools/contracts";
import { quoteGitPatchPath } from "@t3tools/shared/gitPatchPath";
import * as Arr from "effect/Array";
Expand Down Expand Up @@ -107,7 +110,7 @@ function checkpointTitle(checkpoint: ThreadCheckpointSummary): string {
}

function checkpointSubtitle(checkpoint: ThreadCheckpointSummary): string {
const fileCount = checkpoint.files.length;
const fileCount = checkpointFileTotals(checkpoint).count;
if (checkpoint.status !== "ready") {
return `Diff ${checkpoint.status}`;
}
Expand Down
10 changes: 6 additions & 4 deletions apps/server/src/orchestration-v2/ThreadStream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,10 @@ import {
buildBoundedThreadProjection,
type BoundedProjectionResult,
} from "./threadHistoryPaging.ts";
import { projectThreadProjectionForWire } from "./WireProjection.ts";
import {
capProjectionCheckpointFilesForWire,
projectThreadProjectionForWire,
} from "./WireProjection.ts";

export const THREAD_RESUME_MAX_REPLAY_EVENTS = 128;

Expand Down Expand Up @@ -44,10 +47,9 @@ export function boundedSnapshotResponseFields(input: {
readonly compactIds: boolean;
}) {
const { bounded } = input;
const capped = capProjectionCheckpointFilesForWire(bounded.projection);
// Dedupe keeps shared items one object, which the compaction's identity check needs.
const projection = input.dedupeBodies
? dedupeSnapshotBodies(bounded.projection)
: bounded.projection;
const projection = input.dedupeBodies ? dedupeSnapshotBodies(capped) : capped;
const compactProjection = input.compactTurnItems ? omitLocalVisibleTurnItems(projection) : null;
return {
projection: compactProjection ?? projection,
Expand Down
43 changes: 43 additions & 0 deletions apps/server/src/orchestration-v2/WireProjection.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import {
CheckpointId,
CheckpointRef,
CheckpointScopeId,
MessageId,
ContextHandoffId,
ProviderThreadId,
Expand Down Expand Up @@ -381,6 +384,46 @@ describe("live body deduper", () => {
]);
});

it("caps a large checkpoint's files for the wire and still dedupes its item", () => {
const files = Array.from({ length: 250 }, (_, index) => ({
path: `vendor/file-${index}.ts`,
kind: "modified",
additions: 2,
deletions: 1,
}));
const checkpointId = CheckpointId.make("checkpoint-large");
const scopeId = CheckpointScopeId.make("scope-large");
const captured = event(1, {
type: "checkpoint.captured",
payload: {
id: checkpointId,
threadId: base.threadId,
scopeId,
runId: null,
nodeId: NodeId.make("node-large"),
parentCheckpointId: null,
ordinalWithinScope: 1,
appRunOrdinal: null,
ref: CheckpointRef.make("refs/t3/checkpoints/large"),
status: "ready",
files,
capturedAt: at,
},
});
const item = event(2, {
type: "turn-item.updated",
payload: { ...base, type: "checkpoint", checkpointId, scopeId, files },
});
const dedupe = makeLiveBodyDeduper();
const [record, card] = [captured, item].map((streamed) =>
dedupe({ ...streamed, event: projectDomainEventForWire(streamed.event) }),
);

const filesTotal = { count: 250, additions: 500, deletions: 250 };
expect(payloadOf(record!)).toMatchObject({ files: files.slice(0, 200), filesTotal });
expect(payloadOf(card!)).toMatchObject({ files: [], filesOmitted: true, filesTotal });
});

it("ships a reply whole when its message arrives after it", () => {
const first = reply(1, "a");
expect([first, message(2, "a")].map(makeLiveBodyDeduper())[0]).toBe(first);
Expand Down
58 changes: 57 additions & 1 deletion apps/server/src/orchestration-v2/WireProjection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ import {
toolOutputIndicatesFailure,
} from "@t3tools/shared/toolOutput";

// A checkpoint over vendored or generated trees can list 15k+ files; the card needs a prefix.
const MAX_WIRE_CHECKPOINT_FILES = 200;
const MAX_DETAIL_STRING_BYTES = 32_768;
const MAX_DYNAMIC_VALUE_BYTES = 16_384;
const MAX_ON_DEMAND_BYTES = 256 * 1024;
Expand Down Expand Up @@ -96,8 +98,59 @@ function summarizeDynamicValue(value: unknown): unknown {
};
}

type CheckpointFiles = Pick<OrchestrationV2Checkpoint, "files" | "filesTotal">;

// Same input, same output object: snapshot compaction relies on shared item identity.
const cappedCheckpointFiles = new WeakMap<object, object>();

function capCheckpointFiles<T extends CheckpointFiles>(value: T): T {
if (value.files.length <= MAX_WIRE_CHECKPOINT_FILES || value.filesTotal !== undefined) {
return value;
}
const cached = cappedCheckpointFiles.get(value);
if (cached !== undefined) return cached as T;
let additions = 0;
let deletions = 0;
for (const file of value.files) {
additions += file.additions;
deletions += file.deletions;
}
const capped = {
...value,
files: value.files.slice(0, MAX_WIRE_CHECKPOINT_FILES),
filesTotal: { count: value.files.length, additions, deletions },
};
cappedCheckpointFiles.set(value, capped);
return capped;
}

/** Idempotent; bounded snapshots that skip `projectThreadProjectionForWire` still need it. */
export function capProjectionCheckpointFilesForWire(
projection: OrchestrationV2ThreadProjection,
): OrchestrationV2ThreadProjection {
const checkpoints = projection.checkpoints.map(capCheckpointFiles);
const turnItems = projection.turnItems.map(capCheckpointItemFiles);
const visibleTurnItems = projection.visibleTurnItems.map((row) => {
const item = capCheckpointItemFiles(row.item);
return item === row.item ? row : { ...row, item };
});
const unchanged = (next: ReadonlyArray<unknown>, previous: ReadonlyArray<unknown>) =>
next.every((value, index) => value === previous[index]);
return unchanged(checkpoints, projection.checkpoints) &&
unchanged(turnItems, projection.turnItems) &&
unchanged(visibleTurnItems, projection.visibleTurnItems)
? projection
: { ...projection, checkpoints, turnItems, visibleTurnItems };
}

function capCheckpointItemFiles(item: OrchestrationV2TurnItem): OrchestrationV2TurnItem {
return item.type === "checkpoint" ? capCheckpointFiles(item) : item;
}

export function projectTurnItemForWire(item: OrchestrationV2TurnItem): OrchestrationV2TurnItem {
switch (item.type) {
case "checkpoint":
return capCheckpointFiles(item);
case "handoff": {
const { summary: _summary, ...projected } = item;
return projected;
Expand Down Expand Up @@ -241,6 +294,7 @@ export function projectThreadProjectionForWire(
};
return {
...projection,
checkpoints: projection.checkpoints.map(capCheckpointFiles),
contextHandoffs: projection.contextHandoffs.map(projectContextHandoffForWire),
turnItems: projection.turnItems.map(project),
visibleTurnItems: projection.visibleTurnItems.map((row) => ({
Expand All @@ -257,7 +311,9 @@ export function projectDomainEventForWire(
? { ...event, payload: projectTurnItemForWire(event.payload) }
: event.type === "context-handoff.updated"
? { ...event, payload: projectContextHandoffForWire(event.payload) }
: event;
: event.type === "checkpoint.captured"
? { ...event, payload: capCheckpointFiles(event.payload) }
: event;
}

const LIVE_DEDUPE_MEMORY = 32;
Expand Down
10 changes: 4 additions & 6 deletions apps/web/src/components/ChatView.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
deriveLatestThreadRun,
deriveThreadRuntime,
} from "@t3tools/client-runtime/state/thread-execution";
import { checkpointFileTotals } from "@t3tools/client-runtime/state/thread-checkpoints";
import { derivePendingBackgroundWork } from "@t3tools/shared/orchestrationV2PendingBackgroundWork";
import {
latestUnheldRun,
Expand Down Expand Up @@ -218,7 +219,7 @@ export function shouldOpenProactiveTurnDiff(input: {
}

export function resolveProactiveTurnDiffAction(input: {
checkpoint: Pick<TurnDiffSummary, "status" | "files"> | undefined;
checkpoint: Pick<TurnDiffSummary, "status" | "files" | "filesTotal"> | undefined;
isGitRepo: boolean | undefined;
}): "defer" | "ignore" | "open" {
if (input.checkpoint === undefined || input.checkpoint.status === "missing") return "defer";
Expand All @@ -230,11 +231,8 @@ export function resolveProactiveTurnDiffAction(input: {
) {
return "ignore";
}
const changedLines = input.checkpoint.files.reduce(
(total, file) => total + file.additions + file.deletions,
0,
);
return input.checkpoint.files.length >= 3 || changedLines >= 50 ? "open" : "ignore";
const totals = checkpointFileTotals(input.checkpoint);
return totals.count >= 3 || totals.additions + totals.deletions >= 50 ? "open" : "ignore";
}

/** Mark reactivated Diff tabs to preserve review scope. Already-active tabs must not leave a marker for
Expand Down
34 changes: 23 additions & 11 deletions apps/web/src/components/chat/ChangedFilesTree.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,7 @@
import { type RunId } from "@t3tools/contracts";
import { type OrchestrationV2CheckpointFileTotals, type RunId } from "@t3tools/contracts";
import { type MouseEvent, type ReactNode, memo, useLayoutEffect, useRef, useState } from "react";
import { type TurnDiffFileChange } from "../../types";
import {
buildTurnDiffTree,
summarizeTurnDiffStats,
type TurnDiffTreeNode,
} from "../../lib/turnDiffTree";
import { buildTurnDiffTree, type TurnDiffTreeNode } from "../../lib/turnDiffTree";
import { ChevronRightIcon, FileDiffIcon } from "lucide-react";
import { ChevronsDownUp, ChevronsUpDown, Folder, FolderClosed } from "lucide";
import { cn } from "~/lib/utils";
Expand All @@ -30,7 +26,9 @@ type ChangedFileContextMenuHandler = (filePath: string, event: MouseEvent) => vo

export const ChangedFilesCard = memo(function ChangedFilesCard(props: {
runId: RunId;
/** May be a prefix of the changed files; `totals` covers all of them. */
files: ReadonlyArray<TurnDiffFileChange>;
totals: OrchestrationV2CheckpointFileTotals;
allDirectoriesExpanded: boolean;
resolvedTheme: "light" | "dark";
onToggleAllDirectories: () => void;
Expand All @@ -40,13 +38,14 @@ export const ChangedFilesCard = memo(function ChangedFilesCard(props: {
const {
runId,
files,
totals,
allDirectoriesExpanded,
resolvedTheme,
onToggleAllDirectories,
onOpenTurnDiff,
onFileContextMenu,
} = props;
const summaryStat = summarizeTurnDiffStats(files);
const hiddenFileCount = totals.count - files.length;
const hasDirectories = files.some((file) => /[/\\]/.test(file.path));

return (
Expand All @@ -60,12 +59,12 @@ export const ChangedFilesCard = memo(function ChangedFilesCard(props: {
>
<div className="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1 text-xs font-medium text-foreground">
<span>
{files.length} changed file{files.length === 1 ? "" : "s"}
{totals.count.toLocaleString()} changed file{totals.count === 1 ? "" : "s"}
</span>
{hasNonZeroStat(summaryStat) && (
{hasNonZeroStat(totals) && (
<DiffStatLabel
additions={summaryStat.additions}
deletions={summaryStat.deletions}
additions={totals.additions}
deletions={totals.deletions}
layout="inline"
className="text-xs leading-4"
/>
Expand Down Expand Up @@ -126,6 +125,19 @@ export const ChangedFilesCard = memo(function ChangedFilesCard(props: {
onOpenTurnDiff={onOpenTurnDiff}
onFileContextMenu={onFileContextMenu}
/>
{hiddenFileCount > 0 && (
<div className="px-3 pb-2">
<Button
type="button"
size="xs"
variant="ghost-muted"
onClick={() => onOpenTurnDiff(runId, files[0]?.path)}
>
{hiddenFileCount.toLocaleString()} more file{hiddenFileCount === 1 ? "" : "s"} in the
full diff
</Button>
</div>
)}
</div>
);
});
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/components/chat/TimelineAssistantRows.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { Button } from "../ui/button";
import { ProposedPlanCard } from "./ProposedPlanCard";
import { HtmlRenderFrame } from "./HtmlRenderFrame";
import { McpAppFrame } from "./McpAppFrame";
import { checkpointFileTotals } from "@t3tools/client-runtime/state/thread-checkpoints";
import { ChangedFilesCard } from "./ChangedFilesTree";
import { useFileContextMenuHandler } from "../../fileContextMenu";
import { useProject, useThreadShell } from "../../state/entities";
Expand Down Expand Up @@ -347,6 +348,7 @@ function AssistantChangedFilesSectionInner({
<ChangedFilesCard
runId={turnSummary.runId}
files={checkpointFiles}
totals={checkpointFileTotals(turnSummary)}
allDirectoriesExpanded={allDirectoriesExpanded}
resolvedTheme={resolvedTheme}
onToggleAllDirectories={() =>
Expand Down
14 changes: 0 additions & 14 deletions apps/web/src/lib/turnDiffTree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,20 +98,6 @@ function toTreeNodes(directory: MutableDirectoryNode): TurnDiffTreeNode[] {
return [...subdirectories, ...files];
}

export function summarizeTurnDiffStats(files: ReadonlyArray<TurnDiffFileChange>): 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<TurnDiffFileChange>): TurnDiffTreeNode[] {
const root: MutableDirectoryNode = {
name: "",
Expand Down
17 changes: 17 additions & 0 deletions packages/client-runtime/src/state/threadCheckpoints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type {
CheckpointRef,
CheckpointScopeId,
MessageId,
OrchestrationV2CheckpointFileTotals,
OrchestrationV2ThreadProjection,
RunId,
} from "@t3tools/contracts";
Expand All @@ -15,12 +16,14 @@ export interface ThreadCheckpointSummary {
readonly checkpointTurnCount: number;
readonly checkpointRef: CheckpointRef;
readonly status: "ready" | "missing" | "error" | "stale";
/** A prefix of the changed files when `filesTotal` is set; see `checkpointFileTotals`. */
readonly files: ReadonlyArray<{
readonly path: string;
readonly kind: string;
readonly additions: number;
readonly deletions: number;
}>;
readonly filesTotal?: OrchestrationV2CheckpointFileTotals;
readonly assistantMessageId: MessageId | null;
readonly completedAt: string;
}
Expand All @@ -43,9 +46,23 @@ export function deriveThreadCheckpointSummaries(
checkpointRef: checkpoint.ref,
status: checkpoint.status,
files: checkpoint.files,
...(checkpoint.filesTotal === undefined ? {} : { filesTotal: checkpoint.filesTotal }),
assistantMessageId,
completedAt: DateTime.formatIso(checkpoint.capturedAt),
},
];
});
}

export function checkpointFileTotals(
checkpoint: Pick<ThreadCheckpointSummary, "files" | "filesTotal">,
): OrchestrationV2CheckpointFileTotals {
if (checkpoint.filesTotal !== undefined) return checkpoint.filesTotal;
let additions = 0;
let deletions = 0;
for (const file of checkpoint.files) {
additions += file.additions;
deletions += file.deletions;
}
return { count: checkpoint.files.length, additions, deletions };
}
Loading
Loading