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
224 changes: 29 additions & 195 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { useTimelineRewind } from "./chat/useTimelineRewind";
import { useThreadLifecycle } from "./chat/useThreadLifecycle";
import { useRightPanelActions } from "./chat/useRightPanelActions";
import { type TerminalLaunchContext, useChatTerminalActions } from "./chat/useChatTerminalActions";
Expand Down Expand Up @@ -119,7 +120,6 @@ import {
} from "@t3tools/client-runtime/state/runtime";
import { AsyncResult } from "effect/reactivity";
import { isElectron } from "../env";
import { readLocalApi } from "../localApi";
import { useDiffPanelStore } from "../diffPanelStore";
import { useActiveThreadRef } from "../hooks/useActiveThreadRef";
import { composerRequiresModifier } from "../composer-logic";
Expand Down Expand Up @@ -208,7 +208,7 @@ import {
deriveLogicalProjectKeyFromSettings,
selectProjectGroupingSettings,
} from "../logicalProject";
import { buildDraftThreadRouteParams, buildThreadRouteParams } from "../threadRoutes";
import { buildDraftThreadRouteParams } from "../threadRoutes";
import {
isSameSidebarThreadRef,
useSidebarPendingFileDropStore,
Expand Down Expand Up @@ -255,7 +255,6 @@ import {
useQuietThreadShell,
useThreadVisibleTurnItems,
useThreadCheckpointSummaries,
waitForThreadShell,
} from "../state/entities";
import { useEnvironmentShellHasSnapshot } from "../state/shell";
import { ChatComposer, type ChatComposerHandle } from "./chat/ChatComposer";
Expand All @@ -266,7 +265,6 @@ import {
DeviceSetupDialogHost,
openBranchRestoreConfirm,
openPullRequestThreadDialog,
openRevertConfirm,
PullRequestThreadDialogHost,
RevertConfirmHost,
} from "./chat/ChatOverlayHosts";
Expand Down Expand Up @@ -375,7 +373,7 @@ import {
} from "./chat/useSystemComposerBanners";
import { useDraftHeroLayoutTransition } from "./chat/useDraftHeroLayoutTransition";
import { useLocalDispatchState } from "./chat/useLocalDispatchState";
import { draftFanoutSelectionsAtom, draftSendState, rewindToMessage } from "./chat/chatViewSend";
import { draftFanoutSelectionsAtom, draftSendState } from "./chat/chatViewSend";
import {
isCompactCommandMessage,
type LocalThreadErrorEntry,
Expand Down Expand Up @@ -467,12 +465,6 @@ export default function ChatView(props: ChatViewProps) {
label: "load earlier thread history",
reportFailure: false,
});
const revertThreadCheckpoint = useOrchestrationCommand(threadEnvironment.revertCheckpoint, {
reportFailure: false,
});
const forkThreadFromRun = useAtomCommand(threadEnvironment.forkFromRun, {
reportFailure: false,
});
const { environments } = useEnvironments();
const providersByEnvironment = useEnvironmentProviders();
const primaryEnvironment = usePrimaryEnvironment();
Expand Down Expand Up @@ -3455,179 +3447,32 @@ export default function ChatView(props: ChatViewProps) {
window.removeEventListener("paste", handler, true);
};
}, [activeThreadId, composerRef]);

const onRevertToTurnCount = async (
turnCount: number,
messageId: MessageId,
restoreFiles?: boolean,
) => {
const localApi = readLocalApi();
if (
!localApi ||
!activeThread ||
isRevertingCheckpoint ||
!readEnvironmentScope(environmentId, AuthOrchestrationOperateScope)
)
return;
const message = timelineMessages.find((message) => message.id === messageId);
if (!message || message.role !== "user") return;

if (!supportsConversationRollback) {
setThreadError(
activeThread.id,
"This provider does not support reverting conversation history. Start a new thread instead.",
);
return;
}
if (activeEnvironmentUnavailable && activeEnvironmentUnavailableLabel) {
setThreadError(
activeThread.id,
`Reconnect ${activeEnvironmentUnavailableLabel} before reverting checkpoints.`,
);
return;
}
if (phase === "running" || isSendBusy || isConnecting) {
setThreadError(activeThread.id, "Interrupt the current turn before reverting checkpoints.");
return;
}
if (restoreFiles === undefined) {
openRevertConfirm(routeThreadKey, { turnCount, messageId });
return;
}

useComposerDraftStore.setState((store) => ({
rewindingThreadKeys: new Set(store.rewindingThreadKeys).add(routeThreadKey),
}));
setThreadError(activeThread.id, null);
const rewind = await rewindToMessage({
message,
environmentId,
routeThreadRef,
threadId: activeThread.id,
turnCount,
restoreFiles,
// Keep the live checkpoint identity when command context refetches a bounded window.
checkpoint: findRevertCheckpoint(serverProjection?.checkpoints ?? [], turnCount),
draftTarget: composerDraftTarget,
composerHasPendingAttachments: composerRef.current?.hasPendingAttachments() === true,
createAssetUrl: createAttachmentAssetUrl,
revertThreadCheckpoint,
});
useComposerDraftStore.setState((store) => {
const remaining = new Set(store.rewindingThreadKeys);
remaining.delete(routeThreadKey);
return { rewindingThreadKeys: remaining };
});
if (rewind.error !== null) {
setThreadError(activeThread.id, rewind.error);
return;
}
if (currentRouteThreadKeyRef.current === routeThreadKey) {
const nextPrompt = rewind.prompt;
promptRef.current = nextPrompt;
composerRef.current?.resetCursorState({ prompt: nextPrompt, cursor: nextPrompt.length });
requestAnimationFrame(() => {
if (currentRouteThreadKeyRef.current === routeThreadKey) composerRef.current?.focusAtEnd();
});
}
};
const onRevertToTurnCountStable = useStableCallback(onRevertToTurnCount);
const onConfirmRevert = (turnCount: number, messageId: MessageId, restoreFiles: boolean) => {
void onRevertToTurnCount(turnCount, messageId, restoreFiles);
};
const onConfirmRevertStable = useStableCallback(onConfirmRevert);

const onRollbackCheckpoint = async (input: {
readonly checkpointId: string;
readonly scopeId: string;
}) => {
if (!activeThread || isRevertingCheckpoint) return;
if (activeEnvironmentUnavailable && activeEnvironmentUnavailableLabel) {
setThreadError(
activeThread.id,
`Reconnect ${activeEnvironmentUnavailableLabel} before reverting checkpoints.`,
);
return;
}
if (phase === "running" || isSendBusy || isConnecting) {
setThreadError(activeThread.id, "Interrupt the current turn before reverting checkpoints.");
return;
}
const localApi = readLocalApi();
const confirmed =
localApi == null
? window.confirm("Roll back this thread to the selected checkpoint?")
: await localApi.dialogs.confirm(
"Roll back this thread to the selected checkpoint?\nThis action cannot be undone.",
);
if (!confirmed) return;

useComposerDraftStore.setState((store) => ({
rewindingThreadKeys: new Set(store.rewindingThreadKeys).add(routeThreadKey),
}));
setThreadError(activeThread.id, null);
const result = await revertThreadCheckpoint({
environmentId,
input: {
threadId: activeThread.id,
checkpointId: input.checkpointId,
scopeId: input.scopeId,
},
});
if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
const error = squashAtomCommandFailure(result);
setThreadError(
activeThread.id,
error instanceof Error ? error.message : "Failed to revert thread state.",
);
}
useComposerDraftStore.setState((store) => {
const remaining = new Set(store.rewindingThreadKeys);
remaining.delete(routeThreadKey);
return { rewindingThreadKeys: remaining };
});
};

const onForkFromRun = async (input: {
readonly sourceThreadId: ThreadId;
readonly runId: RunId;
}) => {
if (!activeThread || activeEnvironmentUnavailable) return;
const targetThreadId = newThreadId();
const targetThreadRef = scopeThreadRef(environmentId, targetThreadId);
const result = await forkThreadFromRun({
environmentId,
input: {
sourceThreadId: input.sourceThreadId,
targetThreadId,
runId: input.runId,
title: `${activeThread.title} fork`,
},
});
if (result._tag === "Failure") {
if (!isAtomCommandInterrupted(result)) {
const error = squashAtomCommandFailure(result);
setThreadError(
activeThread.id,
error instanceof Error ? error.message : "Failed to fork this response.",
);
}
return;
}
const targetThreadReady = await waitForThreadShell(targetThreadRef);
if (!targetThreadReady) {
setThreadError(
activeThread.id,
"The fork was created, but its thread data did not reach this client. Reconnect and try opening it from the sidebar.",
);
return;
}
await navigate({
to: "/$environmentId/$threadId",
params: buildThreadRouteParams(targetThreadRef),
});
};
const onForkFromRunStable = useStableCallback(onForkFromRun);
const {
onConfirmRevertStable,
onForkFromRunStable,
onRevertTimelineTurnStable,
onTimelineRollbackCheckpointStable,
} = useTimelineRewind({
environmentId,
routeThreadRef,
routeThreadKey,
currentRouteThreadKeyRef,
serverProjection,
activeThread,
timelineMessages,
paintOnlyDisplayedTimeline,
phase,
isConnecting,
isSendBusy,
isRevertingCheckpoint,
supportsConversationRollback,
activeEnvironmentUnavailable,
activeEnvironmentUnavailableLabel,
promptRef,
composerRef,
composerDraftTarget,
setThreadError,
});
const onCompactContext = () => {
if (compactDisabled) return;
void sendStandaloneCommand("/compact", "Failed to compact context.");
Expand Down Expand Up @@ -3933,17 +3778,6 @@ export default function ChatView(props: ChatViewProps) {
openExpandedImagePreview(routeThreadKey, preview);
};
const onExpandTimelineImageStable = useStableCallback(onExpandTimelineImage);
const onTimelineRollbackCheckpoint = (input: {
readonly checkpointId: string;
readonly scopeId: string;
}) => {
if (!paintOnlyDisplayedTimeline) void onRollbackCheckpoint(input);
};
const onTimelineRollbackCheckpointStable = useStableCallback(onTimelineRollbackCheckpoint);
const onRevertTimelineTurn = (targetTurnCount: number, messageId: MessageId) => {
void onRevertToTurnCountStable(targetTurnCount, messageId);
};
const onRevertTimelineTurnStable = useStableCallback(onRevertTimelineTurn);

const pendingSidebarFileDrops = useSidebarPendingFileDropStore((state) => state.pending);
const consumePendingFileDrop = useSidebarPendingFileDropStore(
Expand Down
Loading
Loading