Skip to content
Open
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
19 changes: 14 additions & 5 deletions apps/web/src/components/chat/ChatOverlayHosts.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,7 @@ export function BranchRestoreConfirmHost(props: {
interface PendingRevert {
readonly turnCount: number;
readonly messageId: MessageId;
readonly hasFileChanges: boolean;
}

const revertConfirmAtom = makeThreadOverlayAtom<PendingRevert>("revert-confirm");
Expand Down Expand Up @@ -212,15 +213,23 @@ export function RevertConfirmHost(props: {
<AlertDialogTitle>Edit from here?</AlertDialogTitle>
<AlertDialogDescription>
Rewind chat to before this message. Your prompt and attachments return to the composer.
“Revert files too” also undoes your own edits.
{pending?.hasFileChanges === false
? " The agent hasn’t changed any files since then."
: " “Revert files too” also undoes your own edits."}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogClose render={<Button variant="outline" />}>Cancel</AlertDialogClose>
<Button variant="destructive-outline" onClick={() => confirm(true)}>
Revert files too
</Button>
<Button onClick={() => confirm(false)}>Revert and keep changes</Button>
{pending?.hasFileChanges === false ? (
<Button onClick={() => confirm(false)}>Revert</Button>
) : (
<>
<Button variant="destructive-outline" onClick={() => confirm(true)}>
Revert files too
</Button>
<Button onClick={() => confirm(false)}>Revert and keep changes</Button>
</>
)}
</AlertDialogFooter>
</AlertDialogPopup>
</AlertDialog>
Expand Down
26 changes: 24 additions & 2 deletions apps/web/src/components/chat/useTimelineRewind.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import {
squashAtomCommandFailure,
} from "@t3tools/client-runtime/state/runtime";
import { readLocalApi } from "../../localApi";
import { findRevertCheckpoint } from "../../session-logic";
import { findRevertCheckpoint, revertFileCheck } from "../../session-logic";
import { type ChatMessage } from "../../types";
import { newThreadId } from "~/lib/utils";
import { buildThreadRouteParams } from "../../threadRoutes";
Expand All @@ -27,6 +27,7 @@ import { threadEnvironment } from "../../state/threads";
import { waitForThreadShell } from "../../state/entities";
import { openRevertConfirm } from "./ChatOverlayHosts";
import { assetEnvironment } from "../../state/assets";
import { orchestrationEnvironment } from "../../state/orchestration";
import { readEnvironmentScope } from "../../state/session";
import { useAtomCommand } from "../../state/use-atom-command";
import { useOrchestrationCommand } from "../../state/use-orchestration-command";
Expand Down Expand Up @@ -83,6 +84,9 @@ export function useTimelineRewind(input: TimelineRewindInput) {
refresh: true,
});

const loadTurnDiff = useAtomQueryRunner(orchestrationEnvironment.turnDiff, {
reportFailure: false,
});
const revertThreadCheckpoint = useOrchestrationCommand(threadEnvironment.revertCheckpoint, {
reportFailure: false,
});
Expand Down Expand Up @@ -125,7 +129,25 @@ export function useTimelineRewind(input: TimelineRewindInput) {
return;
}
if (restoreFiles === undefined) {
openRevertConfirm(routeThreadKey, { turnCount, messageId });
const check = revertFileCheck(
serverProjection?.runs ?? [],
serverProjection?.checkpoints ?? [],
turnCount,
);
let hasFileChanges = true;
if (check.kind === "diff") {
const result = await loadTurnDiff({
environmentId,
input: {
threadId: activeThread.id,
fromTurnCount: turnCount,
toTurnCount: check.toTurnCount,
ignoreWhitespace: false,
},
});
hasFileChanges = result._tag !== "Success" || result.value.diff.trim() !== "";
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
}
openRevertConfirm(routeThreadKey, { turnCount, messageId, hasFileChanges });
return;
}

Expand Down
50 changes: 50 additions & 0 deletions apps/web/src/session-logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {
selectMessageImageResources,
createMessageAttachmentPreviewProjector,
providerErrorPresentation,
revertFileCheck,
type TimelineEntry,
workEntryIndicatesToolFailure,
workEntryDisplayIndicatesToolFailure,
Expand Down Expand Up @@ -1787,3 +1788,52 @@ it("renders automatic completion as a work entry instead of a user bubble", () =
})[0]?.kind,
).toBe("message");
});

describe("revertFileCheck", () => {
type Run = Parameters<typeof revertFileCheck>[0][number];
type Checkpoint = Parameters<typeof revertFileCheck>[1][number];
const run = (ordinal: number, status: Run["status"] = "completed"): Run => ({
id: RunId.make(`run-${ordinal}`),
ordinal,
status,
});
const checkpoint = (ordinal: number, overrides: Partial<Checkpoint> = {}): Checkpoint => ({
runId: RunId.make(`run-${ordinal}`),
status: "ready",
files: [],
...overrides,
});
const file = { path: "a.ts", kind: "modified", additions: 1, deletions: 0 };

it("asks for the diff to the latest run when summaries show no changes", () => {
// Run 3 has no chat message (a notification wake), so only the run list knows it.
expect(
revertFileCheck(
[run(1), run(2), run(3), run(4, "rolled_back"), run(5, "queued")],
[checkpoint(1), checkpoint(2), checkpoint(3)],
1,
),
).toEqual({ kind: "diff", toTurnCount: 3 });
});

it("reports changes without a diff when summaries or checkpoints say so", () => {
expect(revertFileCheck([run(1), run(2)], [checkpoint(2, { files: [file] })], 0)).toEqual({
kind: "changed",
});
expect(
revertFileCheck(
[run(1)],
[checkpoint(1, { filesTotal: { count: 3, additions: 1, deletions: 1 } })],
0,
),
).toEqual({ kind: "changed" });
// The latest run stopped or failed to capture, so its edits are in no checkpoint.
expect(revertFileCheck([run(1), run(2, "interrupted")], [checkpoint(1)], 0)).toEqual({
kind: "changed",
});
expect(revertFileCheck([run(1)], [checkpoint(1, { status: "error" })], 0)).toEqual({
kind: "changed",
});
expect(revertFileCheck([], [], 0)).toEqual({ kind: "changed" });
});
});
46 changes: 46 additions & 0 deletions apps/web/src/session-logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { resolveThreadWorkingStartedAt } from "@t3tools/client-runtime/state/mod
import {
type AssetResource,
type OrchestrationV2Checkpoint,
type OrchestrationV2Run,
type OrchestrationV2ExecutionNode,
type OrchestrationV2PlanArtifact,
type OrchestrationV2ProjectedTurnItem,
Expand Down Expand Up @@ -1121,6 +1122,51 @@ export function findRevertCheckpoint(
);
}

export type RevertFileCheck =
| { readonly kind: "changed" }
| { readonly kind: "diff"; readonly toTurnCount: number };

/**
* Whether runs after `turnCount` changed files; later hand edits are unseen, so keep files on
* "unchanged". Empty summaries can hide changes (failed summary, git imports), hence the real
* diff. Checkpoints snapshot the worktree, so it also covers runs that never captured one.
*/
export function revertFileCheck(
runs: ReadonlyArray<Pick<OrchestrationV2Run, "id" | "ordinal" | "status">>,
checkpoints: ReadonlyArray<
Pick<OrchestrationV2Checkpoint, "runId" | "status" | "files" | "filesTotal">
>,
turnCount: number,
): RevertFileCheck {
const laterRuns = runs.filter(
(run) => run.ordinal > turnCount && run.status !== "rolled_back" && run.status !== "queued",
);
const latestRun = laterRuns.reduce<(typeof laterRuns)[number] | null>(
(latest, run) => (latest === null || run.ordinal > latest.ordinal ? run : latest),
null,
);
const readyCheckpoint = (runId: RunId) =>
checkpoints.findLast(
(checkpoint) => checkpoint.runId === runId && checkpoint.status === "ready",
);
// The turn diff query only resolves checkpoints of completed runs.
if (
latestRun === null ||
latestRun.status !== "completed" ||
readyCheckpoint(latestRun.id) === undefined
) {
return { kind: "changed" };
}
const summarizedChanges = laterRuns.some((run) => {
const checkpoint = readyCheckpoint(run.id);
return (
checkpoint !== undefined &&
(checkpoint.files.length > 0 || (checkpoint.filesTotal?.count ?? 0) > 0)
);
});
return summarizedChanges ? { kind: "changed" } : { kind: "diff", toTurnCount: latestRun.ordinal };
}

export function derivePhase(runtime: ThreadStructureRuntime | null): SessionPhase {
if (runtime === null) return "disconnected";
if (
Expand Down
Loading