Open History to see the last commit
1import type { EnvironmentThreadShell, ThreadStructure } from "@t3tools/client-runtime/state/shell";
2import type { ComposerThreadTarget } from "../../composerDraftStore";
3import type { ComposerHandleRef } from "../../composerHandleContext";
4import type { SessionPhase } from "../../types";
5import { useStableCallback } from "../../hooks/useStableCallback";
6import {
7 AuthOrchestrationOperateScope,
8 type EnvironmentId,
9 type MessageId,
10 type ScopedThreadRef,
11 type ThreadId,
12 type RunId,
13} from "@t3tools/contracts";
14import { scopeThreadRef } from "@t3tools/client-runtime/environment";
15import { useNavigate } from "@tanstack/react-router";
16import {
17 isAtomCommandInterrupted,
18 squashAtomCommandFailure,
19} from "@t3tools/client-runtime/state/runtime";
20import { readLocalApi } from "../../localApi";
21import { findRevertCheckpoint } from "../../session-logic";
22import { type ChatMessage } from "../../types";
23import { newThreadId } from "~/lib/utils";
24import { buildThreadRouteParams } from "../../threadRoutes";
25import { useComposerDraftStore } from "../../composerDraftStore";
26import { threadEnvironment } from "../../state/threads";
27import { waitForThreadShell } from "../../state/entities";
28import { openRevertConfirm } from "./ChatOverlayHosts";
29import { assetEnvironment } from "../../state/assets";
30import { readEnvironmentScope } from "../../state/session";
31import { useAtomCommand } from "../../state/use-atom-command";
32import { useOrchestrationCommand } from "../../state/use-orchestration-command";
33import { useAtomQueryRunner } from "../../state/use-atom-query-runner";
34import { rewindToMessage } from "./chatViewSend";
35import { makeThreadErrorSetter } from "../ChatView.logic";
36interface TimelineRewindInput {
37 readonly environmentId: EnvironmentId;
38 readonly routeThreadRef: ScopedThreadRef;
39 readonly routeThreadKey: string;
40 readonly currentRouteThreadKeyRef: { readonly current: string | null };
41 readonly serverProjection: ThreadStructure | null;
42 readonly activeThread: EnvironmentThreadShell | undefined;
43 readonly timelineMessages: ReadonlyArray<ChatMessage>;
44 readonly phase: SessionPhase;
45 readonly isSendBusy: boolean;
46 readonly isRevertingCheckpoint: boolean;
47 readonly supportsConversationRollback: boolean;
48 readonly activeEnvironmentUnavailable: boolean;
49 readonly activeEnvironmentUnavailableLabel: string | null;
50 readonly promptRef: { current: string };
51 readonly composerRef: ComposerHandleRef;
52 readonly composerDraftTarget: ComposerThreadTarget;
53 readonly setThreadError: ReturnType<typeof makeThreadErrorSetter>;
54}
55
56/**
57 * Going back in a thread's timeline: reverting to an earlier turn (restoring that message into
58 * the composer) and forking a new thread from a run.
59 */
60export function useTimelineRewind(input: TimelineRewindInput) {
61 const {
62 environmentId,
63 routeThreadRef,
64 routeThreadKey,
65 currentRouteThreadKeyRef,
66 serverProjection,
67 activeThread,
68 timelineMessages,
69 phase,
70 isSendBusy,
71 isRevertingCheckpoint,
72 supportsConversationRollback,
73 activeEnvironmentUnavailable,
74 activeEnvironmentUnavailableLabel,
75 promptRef,
76 composerRef,
77 composerDraftTarget,
78 setThreadError,
79 } = input;
80 const navigate = useNavigate();
81 const createAttachmentAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, {
82 reportFailure: false,
83 refresh: true,
84 });
85
86 const revertThreadCheckpoint = useOrchestrationCommand(threadEnvironment.revertCheckpoint, {
87 reportFailure: false,
88 });
89 const forkThreadFromRun = useAtomCommand(threadEnvironment.forkFromRun, {
90 reportFailure: false,
91 });
92
93 const onRevertToTurnCount = async (
94 turnCount: number,
95 messageId: MessageId,
96 restoreFiles?: boolean,
97 ) => {
98 const localApi = readLocalApi();
99 if (
100 !localApi ||
101 !activeThread ||
102 isRevertingCheckpoint ||
103 !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope)
104 )
105 return;
106 const message = timelineMessages.find((message) => message.id === messageId);
107 if (!message || message.role !== "user") return;
108
109 if (!supportsConversationRollback) {
110 setThreadError(
111 activeThread.id,
112 "This provider does not support reverting conversation history. Start a new thread instead.",
113 );
114 return;
115 }
116 if (activeEnvironmentUnavailable && activeEnvironmentUnavailableLabel) {
117 setThreadError(
118 activeThread.id,
119 `Reconnect ${activeEnvironmentUnavailableLabel} before reverting checkpoints.`,
120 );
121 return;
122 }
123 if (phase === "running" || isSendBusy) {
124 setThreadError(activeThread.id, "Interrupt the current turn before reverting checkpoints.");
125 return;
126 }
127 if (restoreFiles === undefined) {
128 openRevertConfirm(routeThreadKey, { turnCount, messageId });
129 return;
130 }
131
132 useComposerDraftStore.setState((store) => ({
133 rewindingThreadKeys: new Set(store.rewindingThreadKeys).add(routeThreadKey),
134 }));
135 setThreadError(activeThread.id, null);
136 const rewind = await rewindToMessage({
137 message,
138 environmentId,
139 routeThreadRef,
140 threadId: activeThread.id,
141 turnCount,
142 restoreFiles,
143 // Keep the live checkpoint identity when command context refetches a bounded window.
144 checkpoint: findRevertCheckpoint(serverProjection?.checkpoints ?? [], turnCount),
145 draftTarget: composerDraftTarget,
146 composerHasPendingAttachments: composerRef.current?.hasPendingAttachments() === true,
147 createAssetUrl: createAttachmentAssetUrl,
148 revertThreadCheckpoint,
149 });
150 useComposerDraftStore.setState((store) => {
151 const remaining = new Set(store.rewindingThreadKeys);
152 remaining.delete(routeThreadKey);
153 return { rewindingThreadKeys: remaining };
154 });
155 if (rewind.error !== null) {
156 setThreadError(activeThread.id, rewind.error);
157 return;
158 }
159 if (currentRouteThreadKeyRef.current === routeThreadKey) {
160 const nextPrompt = rewind.prompt;
161 promptRef.current = nextPrompt;
162 composerRef.current?.resetCursorState({ prompt: nextPrompt, cursor: nextPrompt.length });
163 requestAnimationFrame(() => {
164 if (currentRouteThreadKeyRef.current === routeThreadKey) composerRef.current?.focusAtEnd();
165 });
166 }
167 };
168 const onRevertToTurnCountStable = useStableCallback(onRevertToTurnCount);
169 const onConfirmRevert = (turnCount: number, messageId: MessageId, restoreFiles: boolean) => {
170 void onRevertToTurnCount(turnCount, messageId, restoreFiles);
171 };
172 const onConfirmRevertStable = useStableCallback(onConfirmRevert);
173
174 const onForkFromRun = async (input: {
175 readonly sourceThreadId: ThreadId;
176 readonly runId: RunId;
177 }) => {
178 if (!activeThread || activeEnvironmentUnavailable) return;
179 const targetThreadId = newThreadId();
180 const targetThreadRef = scopeThreadRef(environmentId, targetThreadId);
181 const result = await forkThreadFromRun({
182 environmentId,
183 input: {
184 sourceThreadId: input.sourceThreadId,
185 targetThreadId,
186 runId: input.runId,
187 title: `${activeThread.title} fork`,
188 },
189 });
190 if (result._tag === "Failure") {
191 if (!isAtomCommandInterrupted(result)) {
192 const error = squashAtomCommandFailure(result);
193 setThreadError(
194 activeThread.id,
195 error instanceof Error ? error.message : "Failed to fork this response.",
196 );
197 }
198 return;
199 }
200 const targetThreadReady = await waitForThreadShell(targetThreadRef);
201 if (!targetThreadReady) {
202 setThreadError(
203 activeThread.id,
204 "The fork was created, but its thread data did not reach this client. Reconnect and try opening it from the sidebar.",
205 );
206 return;
207 }
208 await navigate({
209 to: "/$environmentId/$threadId",
210 params: buildThreadRouteParams(targetThreadRef),
211 });
212 };
213 const onForkFromRunStable = useStableCallback(onForkFromRun);
214 const onRevertTimelineTurn = (targetTurnCount: number, messageId: MessageId) => {
215 void onRevertToTurnCountStable(targetTurnCount, messageId);
216 };
217 const onRevertTimelineTurnStable = useStableCallback(onRevertTimelineTurn);
218 return {
219 onConfirmRevertStable,
220 onForkFromRunStable,
221 onRevertTimelineTurnStable,
222 };
223}