Skip to content
Closed
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
2 changes: 1 addition & 1 deletion apps/mobile/src/features/review/reviewModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ function checkpointTitle(checkpoint: ThreadCheckpointSummary): string {
}

function checkpointSubtitle(checkpoint: ThreadCheckpointSummary): string {
const fileCount = checkpoint.files.length;
const fileCount = checkpoint.deferredFileCount ?? checkpoint.files.length;
if (checkpoint.status !== "ready") {
return `Diff ${checkpoint.status}`;
}
Expand Down
95 changes: 81 additions & 14 deletions apps/web/src/components/ChatView.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"
import { Atom, AsyncResult } from "effect/reactivity";
import { appAtomRegistry } from "../rpc/atomRegistry";
import { environmentThreadDetails } from "../state/threads";
import { applyOrchestrationV2ProjectionEvent } from "@t3tools/client-runtime/state/orchestration-v2-projection";

import type { Thread, TurnDiffSummary } from "../types";
import { makeThreadFixture, makeThreadProjectionFixture } from "../test-fixtures";
Expand Down Expand Up @@ -2268,23 +2269,89 @@ describe("waitForRevertedMessage", () => {
await expect(waiting).rejects.toThrow("The provider could not roll back.");
});

it("ignores a failure recorded for an earlier rollback", async () => {
vi.useFakeTimers();
it("rewinds a paged user prompt while ignoring a failure recorded for an earlier rollback", async () => {
const { state, projection } = projectionAtom();
const waiting = waitForRevertedMessage(threadRef, messageId, 1, requestId, async () => {}, 50);
const settled = expect(waiting).rejects.toThrow("Timed out waiting for the thread to rewind.");
appAtomRegistry.set(state, {
data: Option.some({
...projection,
thread: {
...projection.thread,
rollbackFailure: { requestId: CommandId.make("rollback-0"), message: "Old failure." },
const run = {
id: RunId.make("run-2"),
threadId: projection.thread.id,
ordinal: 2,
providerInstanceId: projection.thread.providerInstanceId,
modelSelection: projection.thread.modelSelection,
providerThreadId: null,
userMessageId: messageId,
rootNodeId: null,
activeAttemptId: null,
status: "completed" as const,
requestedAt: projection.updatedAt,
startedAt: projection.updatedAt,
completedAt: projection.updatedAt,
checkpointId: null,
contextHandoffId: null,
};
const item = {
id: TurnItemId.make("item-message-2"),
threadId: projection.thread.id,
runId: run.id,
nodeId: null,
providerThreadId: null,
providerTurnId: null,
nativeItemRef: null,
parentItemId: null,
ordinal: 2,
status: "completed" as const,
title: null,
startedAt: projection.updatedAt,
completedAt: projection.updatedAt,
updatedAt: projection.updatedAt,
createdBy: "user" as const,
creationSource: "web" as const,
type: "user_message" as const,
messageId,
inputIntent: "turn_start" as const,
text: "second",
attachments: [],
};
const pagedProjection = {
...projection,
messages: [],
runs: [run],
visibleTurnItems: [
{
position: 0,
visibility: "local" as const,
sourceThreadId: item.threadId,
sourceItemId: item.id,
item,
},
}),
],
thread: {
...projection.thread,
rollbackFailure: { requestId: CommandId.make("rollback-0"), message: "Old failure." },
},
};
appAtomRegistry.set(state, { data: Option.some(pagedProjection) });
let accept = () => {};
const accepted = new Promise<void>((resolve) => {
accept = resolve;
});
let settled = false;
const waiting = waitForRevertedMessage(threadRef, messageId, 1, requestId, async () => {
accept();
}).then(() => {
settled = true;
});
await Promise.race([accepted, waiting]);
expect(settled).toBe(false);
const rewound = applyOrchestrationV2ProjectionEvent(pagedProjection, {
id: EventId.make("run-rolled-back"),
type: "run.updated",
threadId: run.threadId,
runId: run.id,
occurredAt: projection.updatedAt,
payload: { ...run, status: "rolled_back" },
});
await vi.advanceTimersByTimeAsync(50);
await settled;
vi.useRealTimers();
appAtomRegistry.set(state, { data: Option.fromNullishOr(rewound) });
await expect(waiting).resolves.toBeUndefined();
});
});

Expand Down
15 changes: 13 additions & 2 deletions apps/web/src/components/ChatView.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1083,10 +1083,21 @@ export async function waitForRevertedMessage(
const threadAtom = environmentThreadDetails.stateAtom(threadRef);
const readProjection = () => Option.getOrNull(appAtomRegistry.get(threadAtom).data);
const initial = readProjection();
if (!initial?.messages.some((message) => message.id === messageId)) {
// Paged prompts live in turn items without a duplicate projection message.
const messageRunId =
initial?.messages.find((message) => message.id === messageId)?.runId ??
initial?.visibleTurnItems.find(
({ visibility, sourceThreadId, item }) =>
visibility === "local" &&
sourceThreadId === initial.thread.id &&
item.threadId === initial.thread.id &&
item.type === "user_message" &&
item.messageId === messageId &&
initial.runs.some((run) => run.id === item.runId && run.threadId === initial.thread.id),
)?.item.runId;
if (messageRunId == null) {
throw new Error("The message to rewind is no longer available.");
}
const messageRunId = initial.messages.find((message) => message.id === messageId)?.runId;
return new Promise<void>((resolve, reject) => {
let settled = false;
let accepted = false;
Expand Down
46 changes: 44 additions & 2 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -361,7 +361,8 @@ import {
primaryServerKeybindingsAtom,
serverEnvironment,
} from "../state/server";
import { threadEnvironment } from "../state/threads";
import { environmentThreadDetails, threadEnvironment } from "../state/threads";
import { waitForAtomValue } from "../state/waitForAtomValue";
import { resolveProviderSkillsForCwd } from "@t3tools/client-runtime/providerSkills";
import { vcsEnvironment } from "../state/vcs";
import { sourceControlEnvironment } from "../state/sourceControl";
Expand Down Expand Up @@ -1172,6 +1173,45 @@ export default function ChatView(props: ChatViewProps) {
},
};
})() as MessagesTimelineHistoryControls | undefined;
const getPromptHistory = useStableCallback(() => {
if (routeThreadDetailRef === null) return { messages: [], blocked: true };
const requests = appAtomRegistry.get(
environmentThreadDetails.pendingRequestsAtom(routeThreadDetailRef),
);
return {
messages: appAtomRegistry
.get(environmentThreadDetails.visibleTurnItemsAtom(routeThreadDetailRef))
.flatMap(({ item }) =>
item.type === "user_message" && !item.messageId.startsWith("async-answer:")
? [{ id: item.messageId, role: "user", text: item.text }]
: [],
),
blocked: requests === null || requests.approvals.length > 0 || requests.userInputs.length > 0,
};
});
const loadOlderPromptHistory = useStableCallback(() => {
if (routeThreadDetailRef === null) return null;
const historyAtom = environmentThreadDetails.historyAtom(routeThreadDetailRef);
const history = appAtomRegistry.get(historyAtom);
if (!history.hasMoreHistory || history.loading || history.historyCursor === null) return null;
return (async () => {
const result = await loadEarlierThreadHistory({
environmentId: routeThreadDetailRef.environmentId,
input: { threadId: routeThreadDetailRef.threadId },
});
if (!AsyncResult.isSuccess(result) || result.value._tag !== "loaded") {
throw new Error("Could not load earlier prompt history.");
}
// The command can finish before the atom subscription delivers its page.
const updated = await waitForAtomValue({
registry: appAtomRegistry,
atom: historyAtom,
predicate: (current) => !current.loading && current.historyCursor !== history.historyCursor,
timeoutMs: 6_000,
});
if (!updated) throw new Error("Earlier prompt history did not arrive.");
})();
});
const committedServerMessageIds = deriveCommittedServerUserMessageIds(serverVisibleTurnItems);
// Queued messages have no turn item until their run starts, so the
// turn-item-derived set alone can never retire their optimistic rows —
Expand Down Expand Up @@ -5712,7 +5752,7 @@ export default function ChatView(props: ChatViewProps) {
createdAt: DateTime.formatIso(sourceMessage.createdAt),
updatedAt: DateTime.formatIso(sourceMessage.updatedAt),
}
: undefined;
: timelineMessages.find((message) => message.id === messageId);
if (!message || message.role !== "user") return;

if (!supportsConversationRollback) {
Expand Down Expand Up @@ -8178,6 +8218,8 @@ export default function ChatView(props: ChatViewProps) {
activeThread={activeThread}
activeThreadShell={activeThreadShell}
promptHistoryMessages={timelineMessages}
getPromptHistory={isServerThread ? getPromptHistory : undefined}
onLoadOlderPromptHistory={loadOlderPromptHistory}
isServerThread={isServerThread}
isLocalDraftThread={isLocalDraftThread}
forceExpandedOnMobile={
Expand Down
Loading
Loading