Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
? " No files have changed since."
: " “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
15 changes: 13 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, runsChangedFiles } from "../../session-logic";
import { type ChatMessage } from "../../types";
import { newThreadId } from "~/lib/utils";
import { buildThreadRouteParams } from "../../threadRoutes";
Expand Down Expand Up @@ -125,7 +125,18 @@ export function useTimelineRewind(input: TimelineRewindInput) {
return;
}
if (restoreFiles === undefined) {
openRevertConfirm(routeThreadKey, { turnCount, messageId });
openRevertConfirm(routeThreadKey, {
turnCount,
messageId,
hasFileChanges: runsChangedFiles(
serverProjection?.checkpoints ?? [],
new Set(
timelineMessages

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium chat/useTimelineRewind.ts:134

Reverting past a notification-only wake run that changed files incorrectly omits the file-restore option, leaving those changes in place. timelineMessages contains only message entries, so this run-ID set misses work-only runs; collect affected runs from the full timeline or run projection instead.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/components/chat/useTimelineRewind.ts around line 134:

Reverting past a notification-only wake run that changed files incorrectly omits the file-restore option, leaving those changes in place. `timelineMessages` contains only message entries, so this run-ID set misses work-only runs; collect affected runs from the full timeline or run projection instead.

.slice(timelineMessages.indexOf(message))
.flatMap((later) => (later.runId === null ? [] : [later.runId])),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
),
),
});
return;
}

Expand Down
41 changes: 41 additions & 0 deletions apps/web/src/session-logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
ThreadId,
TurnItemId,
type OrchestrationV2ProjectedTurnItem,
type OrchestrationV2Checkpoint,
type OrchestrationV2ExecutionNode,
type OrchestrationV2RunAttempt,
type OrchestrationV2TurnItem,
Expand All @@ -33,6 +34,7 @@ import {
selectMessageImageResources,
createMessageAttachmentPreviewProjector,
providerErrorPresentation,
runsChangedFiles,
type TimelineEntry,
workEntryIndicatesToolFailure,
workEntryDisplayIndicatesToolFailure,
Expand Down Expand Up @@ -1787,3 +1789,42 @@ it("renders automatic completion as a work entry instead of a user bubble", () =
})[0]?.kind,
).toBe("message");
});

describe("runsChangedFiles", () => {
const checkpoint = (
run: string,
overrides: Partial<OrchestrationV2Checkpoint> = {},
): OrchestrationV2Checkpoint => ({
id: CheckpointId.make(`checkpoint-${run}`),
threadId: ThreadId.make("thread"),
scopeId: CheckpointScopeId.make("scope"),
runId: RunId.make(run),
nodeId: NodeId.make("node"),
parentCheckpointId: null,
ordinalWithinScope: 1,
appRunOrdinal: 1,
ref: "ref" as never,
status: "ready",
files: [],
capturedAt: DateTime.makeUnsafe("2026-06-20T00:00:00.000Z"),
...overrides,
});
const file = { path: "a.ts", kind: "modified", additions: 1, deletions: 0 };
const runs = (...ids: string[]) => ids.map((id) => RunId.make(id));

it("is false only when every run has a ready checkpoint with no files", () => {
expect(runsChangedFiles([checkpoint("a"), checkpoint("b")], runs("a", "b"))).toBe(false);
expect(
runsChangedFiles([checkpoint("a"), checkpoint("b", { files: [file] })], runs("a", "b")),
).toBe(true);
expect(
runsChangedFiles(
[checkpoint("a", { filesTotal: { count: 3, additions: 1, deletions: 1 } })],
runs("a"),
),
).toBe(true);
// A stopped run without a checkpoint, or a failed capture, may still have edited files.
expect(runsChangedFiles([checkpoint("a")], runs("a", "b"))).toBe(true);
expect(runsChangedFiles([checkpoint("a", { status: "error" })], runs("a"))).toBe(true);
});
});
15 changes: 15 additions & 0 deletions apps/web/src/session-logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1121,6 +1121,21 @@ export function findRevertCheckpoint(
);
}

/** A run with no ready checkpoint (stopped, failed capture) may have touched files. */
export function runsChangedFiles(
checkpoints: ReadonlyArray<OrchestrationV2Checkpoint>,
runIds: Iterable<RunId>,
): boolean {
for (const runId of runIds) {
const checkpoint = checkpoints.findLast(
(candidate) => candidate.runId === runId && candidate.status === "ready",
);
if (checkpoint === undefined) return true;
if (checkpoint.files.length > 0 || (checkpoint.filesTotal?.count ?? 0) > 0) return true;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium src/session-logic.ts:1134

When a ready checkpoint has an empty diff summary despite changed files, runsChangedFiles returns false, causing the dialog to claim no files changed and hide “Revert files too.” Because summary failures and filtered Git-imported changes can both produce an empty files list, use a reliable snapshot comparison or preserve the revert choice when summary completeness is unknown.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/session-logic.ts around line 1134:

When a ready checkpoint has an empty diff summary despite changed files, `runsChangedFiles` returns `false`, causing the dialog to claim no files changed and hide “Revert files too.” Because summary failures and filtered Git-imported changes can both produce an empty `files` list, use a reliable snapshot comparison or preserve the revert choice when summary completeness is unknown.

}
return false;
}

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