Rewind chat or files from any user message (#1154)

* Add Pi extension-launch plumbing for in-process tree primitives

* Add Rewind controls for agent sessions

* Preserve SDK checkpoint env var so Claude file rewind works

The SDK injects CLAUDE_CODE_ENABLE_SDK_FILE_CHECKPOINTING=true when file checkpointing is enabled. The earlier parent-env stripping from c4782ec71c was too broad and removed that child flag along with real parent-session markers.

Keep stripping parent markers, but preserve the checkpoint flag so the SDK rewind path can run directly. With that boundary fixed, the JSONL file-rewind fallback is no longer needed.

* Update OpenCode history replay tests for rewind state

* Show Rewind actions in an adaptive sheet

* Fix rewind semantics across providers

* Refactor Pi IDs through extension capture

* Restore user message to composer after rewind

* Fix Claude rewind tool result mapping

* Fix Claude rewind session switch roundtrip

* Fix rewind and optimistic message regressions

* Tighten rewind test discipline

* Fix OpenCode optimistic message reconciliation

* Reconcile optimistic user messages by marker

* Reorder user message actions

* Unify provider user message contract

* Add Claude rewind flow Playwright contract

* Add Codex rewind flow Playwright contract

* Add OpenCode rewind flow Playwright contract

* Add Pi rewind flow Playwright contract

* Remove real-provider rewind Playwright contracts

* Gate real-provider rewind specs from CI Playwright
This commit is contained in:
Mohamed Boudra
2026-05-25 17:11:34 +08:00
committed by GitHub
parent e966f70322
commit 132e572d6e
99 changed files with 7106 additions and 3425 deletions
+6 -1
View File
@@ -412,16 +412,21 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
(layoutItem: StreamLayoutItem, item: Extract<StreamItem, { kind: "user_message" }>) => {
return (
<UserMessage
serverId={resolvedServerId}
agentId={agentId}
messageId={item.id}
message={item.text}
images={item.images}
attachments={item.attachments}
timestamp={item.timestamp.getTime()}
capabilities={agent.capabilities}
client={client}
isFirstInGroup={layoutItem.isFirstInUserGroup}
isLastInGroup={layoutItem.isLastInUserGroup}
/>
);
},
[],
[agent.capabilities, agentId, client, resolvedServerId],
);
const renderAssistantMessageItem = useCallback(
@@ -353,6 +353,7 @@ describe("dispatchComposerAgentMessage", () => {
expect(userMessage.images).toEqual([image]);
expect(userMessage.attachments).toEqual(call.options.attachments);
expect(userMessage.id).toBe(call.options.messageId);
expect(userMessage.optimistic).toBe(true);
});
it("appends to the existing head when one is present", async () => {
@@ -135,6 +135,7 @@ export async function dispatchComposerAgentMessage(
id: messageId,
text: input.text,
timestamp: new Date(),
optimistic: true,
...(wirePayload.images.length > 0 ? { images: wirePayload.images } : {}),
...(wirePayload.attachments.length > 0 ? { attachments: wirePayload.attachments } : {}),
};
+29 -1
View File
@@ -102,15 +102,23 @@ import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-ur
import { persistAttachmentFromBytes, persistAttachmentFromDataUrl } from "@/attachments/service";
import type { DaemonClient } from "@server/client/daemon-client";
import { isWeb, isNative } from "@/constants/platform";
import type { AgentCapabilityFlags } from "@server/server/agent/agent-sdk-types";
import { RewindMenu, type RewindMode } from "@/components/rewind/rewind-menu";
import { useRewindAgentMutation } from "@/components/rewind/use-rewind-agent-mutation";
export type { InlinePathTarget } from "@/assistant-file-links";
type MarkdownStyles = Record<string, TextStyle & ViewStyle & { [key: string]: unknown }>;
interface UserMessageProps {
serverId?: string;
agentId?: string;
messageId?: string;
message: string;
images?: UserMessageImageAttachment[];
attachments?: AgentAttachment[];
timestamp: number;
capabilities?: AgentCapabilityFlags;
client?: DaemonClient | null;
isFirstInGroup?: boolean;
isLastInGroup?: boolean;
disableOuterSpacing?: boolean;
@@ -450,10 +458,15 @@ function getUserMessageAttachmentLabel(attachment: AgentAttachment): string {
}
export const UserMessage = memo(function UserMessage({
serverId,
agentId,
messageId,
message,
images = [],
attachments = [],
timestamp,
capabilities,
client,
isFirstInGroup = true,
isLastInGroup = true,
disableOuterSpacing,
@@ -469,10 +482,17 @@ export const UserMessage = memo(function UserMessage({
() => formatMessageTimestamp(new Date(timestamp)),
[timestamp],
);
const rewindMutation = useRewindAgentMutation({ serverId, agentId, client, messageId });
const handlePointerEnter = useCallback(() => setIsHovered(true), []);
const handlePointerLeave = useCallback(() => setIsHovered(false), []);
const getMessageContent = useCallback(() => message, [message]);
const handleRewind = useCallback(
(input: { mode: RewindMode; rewoundText: string }) => {
return rewindMutation.rewindAgent(input);
},
[rewindMutation],
);
const containerStyle = useMemo(
() => [
@@ -510,7 +530,7 @@ export const UserMessage = memo(function UserMessage({
);
return (
<View style={containerStyle}>
<View style={containerStyle} testID="user-message">
<View
style={userMessageStylesheet.content}
onPointerEnter={handlePointerEnter}
@@ -549,6 +569,14 @@ export const UserMessage = memo(function UserMessage({
{hasText ? (
<View style={trailingRowStyle} pointerEvents={showTrailingRow ? "auto" : "none"}>
<Text style={userMessageStylesheet.timestampText}>{formattedTimestamp}</Text>
{capabilities ? (
<RewindMenu
capabilities={capabilities}
isPending={rewindMutation.isPending}
rewoundText={message}
onRewind={handleRewind}
/>
) : null}
<TurnCopyButton
getContent={getMessageContent}
containerStyle={userMessageStylesheet.copyButton}
@@ -0,0 +1,31 @@
import { describe, expect, test } from "vitest";
import { restoreComposerTextIfEmpty } from "./composer-restore";
import { shouldRestoreComposerForRewindMode } from "./rewind-mode";
describe("restoreComposerTextIfEmpty", () => {
test("restores the rewound message when the composer is empty", () => {
expect(
restoreComposerTextIfEmpty({
currentText: "",
rewoundText: "message before rewind",
}),
).toBe("message before rewind");
});
test("preserves an existing composer draft", () => {
expect(
restoreComposerTextIfEmpty({
currentText: "keep this draft",
rewoundText: "message before rewind",
}),
).toBe("keep this draft");
});
});
describe("shouldRestoreComposerForRewindMode", () => {
test("restores only conversation-mutating rewind modes", () => {
expect(shouldRestoreComposerForRewindMode("conversation")).toBe(true);
expect(shouldRestoreComposerForRewindMode("files")).toBe(false);
expect(shouldRestoreComposerForRewindMode("both")).toBe(true);
});
});
@@ -0,0 +1,68 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
type ReactNode,
} from "react";
interface RewindComposerRestoreContextValue {
restoreTextIfComposerEmpty: (text: string) => void;
}
interface RewindComposerRestoreProviderProps {
text: string;
setText: (text: string) => void;
children: ReactNode;
}
const RewindComposerRestoreContext = createContext<RewindComposerRestoreContextValue | null>(null);
export function restoreComposerTextIfEmpty(input: {
currentText: string;
rewoundText: string;
}): string {
if (input.currentText.length > 0) {
return input.currentText;
}
return input.rewoundText;
}
export function RewindComposerRestoreProvider({
text,
setText,
children,
}: RewindComposerRestoreProviderProps) {
const textRef = useRef(text);
useEffect(() => {
textRef.current = text;
}, [text]);
const restoreTextIfComposerEmpty = useCallback(
(rewoundText: string) => {
const nextText = restoreComposerTextIfEmpty({
currentText: textRef.current,
rewoundText,
});
if (nextText !== textRef.current) {
setText(nextText);
}
},
[setText],
);
const value = useMemo(() => ({ restoreTextIfComposerEmpty }), [restoreTextIfComposerEmpty]);
return (
<RewindComposerRestoreContext.Provider value={value}>
{children}
</RewindComposerRestoreContext.Provider>
);
}
export function useRewindComposerRestore(): RewindComposerRestoreContextValue | null {
return useContext(RewindComposerRestoreContext);
}
@@ -0,0 +1,164 @@
import { memo, useCallback, useMemo, useState, type ComponentType } from "react";
import { Text, View } from "react-native";
import { FileText, Layers, MessageSquare, Undo2 } from "lucide-react-native";
import { StyleSheet } from "react-native-unistyles";
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import {
type RewindMenuItem,
type RewindMode,
useRewindCapabilities,
} from "./use-rewind-capabilities";
import type { AgentCapabilityFlags } from "@server/server/agent/agent-sdk-types";
export type { RewindMode };
interface RewindMenuProps {
capabilities: AgentCapabilityFlags;
rewoundText: string;
onRewind: (input: { mode: RewindMode; rewoundText: string }) => Promise<void> | void;
isPending?: boolean;
testID?: string;
}
const REWIND_HEADER: SheetHeader = { title: "Rewind to this message" };
const SNAP_POINTS: string[] = ["42%", "72%"];
function iconForItem(item: RewindMenuItem): ComponentType<{ color: string; size: number }> {
switch (item.mode) {
case "conversation":
return MessageSquare;
case "files":
return FileText;
case "both":
return Layers;
}
}
export const RewindMenu = memo(function RewindMenu({
capabilities,
rewoundText,
onRewind,
isPending = false,
testID = "rewind-menu",
}: RewindMenuProps) {
const items = useRewindCapabilities(capabilities);
const [isSheetVisible, setIsSheetVisible] = useState(false);
const [pendingMode, setPendingMode] = useState<RewindMode | null>(null);
const handleOpen = useCallback(() => setIsSheetVisible(true), []);
const handleClose = useCallback(() => {
if (!isPending) {
setIsSheetVisible(false);
}
}, [isPending]);
const handleSelect = useCallback(
(mode: RewindMode) => async () => {
if (isPending) {
return;
}
setPendingMode(mode);
try {
await onRewind({ mode, rewoundText });
} catch {
// useRewindAgentMutation owns the toast; the sheet only owns flow state.
} finally {
setPendingMode(null);
setIsSheetVisible(false);
}
},
[isPending, onRewind, rewoundText],
);
const triggerIcon = useCallback((color: string) => <Undo2 size={16} color={color} />, []);
const tooltipContent = useMemo(
() => (
<TooltipContent side="top" align="center" offset={8}>
<Text style={styles.tooltipText}>Rewind to this message</Text>
</TooltipContent>
),
[],
);
if (items.length === 0) {
return null;
}
return (
<>
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger asChild>
<Button
accessibilityLabel="Rewind to this message"
disabled={isPending}
leftIcon={triggerIcon}
onPress={handleOpen}
size="xs"
style={styles.trigger}
testID={`${testID}-trigger`}
variant="ghost"
/>
</TooltipTrigger>
{tooltipContent}
</Tooltip>
<AdaptiveModalSheet
desktopMaxWidth={420}
header={REWIND_HEADER}
onClose={handleClose}
scrollable={false}
snapPoints={SNAP_POINTS}
testID={`${testID}-content`}
visible={isSheetVisible}
>
<View style={styles.content}>
<Text style={styles.warningText}>This action cannot be undone</Text>
<View style={styles.actions}>
{items.map((item) => (
<Button
key={item.mode}
leftIcon={iconForItem(item)}
loading={pendingMode === item.mode}
disabled={isPending}
onPress={handleSelect(item.mode)}
size="md"
style={styles.actionButton}
testID={item.testID}
variant="secondary"
>
{item.label}
</Button>
))}
</View>
</View>
</AdaptiveModalSheet>
</>
);
});
const styles = StyleSheet.create((theme) => ({
trigger: {
width: 24,
height: 24,
minHeight: 24,
paddingHorizontal: 0,
paddingVertical: 0,
alignItems: "center",
justifyContent: "center",
},
tooltipText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.xs,
},
content: {
gap: theme.spacing[4],
},
warningText: {
color: theme.colors.statusWarning,
fontSize: theme.fontSize.sm,
},
actions: {
gap: theme.spacing[2],
},
actionButton: {
justifyContent: "flex-start",
},
}));
@@ -0,0 +1,5 @@
import type { RewindMode } from "./use-rewind-capabilities";
export function shouldRestoreComposerForRewindMode(mode: RewindMode): boolean {
return mode === "conversation" || mode === "both";
}
@@ -0,0 +1,80 @@
import { useCallback } from "react";
import { useMutation } from "@tanstack/react-query";
import { useToast } from "@/contexts/toast-context";
import type { DaemonClient } from "@server/client/daemon-client";
import type { RewindMode } from "./use-rewind-capabilities";
import { useRewindComposerRestore } from "./composer-restore";
import { useSessionStore } from "@/stores/session-store";
import { shouldRestoreComposerForRewindMode } from "./rewind-mode";
import { clearOptimisticUserMessages } from "@/types/stream";
interface UseRewindAgentMutationInput {
serverId?: string;
agentId?: string;
messageId?: string;
client?: DaemonClient | null;
}
interface RewindAgentInput {
mode: RewindMode;
rewoundText: string;
}
export function useRewindAgentMutation(input: UseRewindAgentMutationInput): {
rewindAgent: (input: RewindAgentInput) => Promise<void>;
isPending: boolean;
} {
const toast = useToast();
const composerRestore = useRewindComposerRestore();
const { isPending, mutateAsync } = useMutation({
mutationFn: async ({ mode }: RewindAgentInput) => {
if (!input.client || !input.agentId || !input.messageId) {
throw new Error("Daemon client not available");
}
await input.client.rewindAgent(input.agentId, input.messageId, mode);
if (mode !== "files") {
if (input.serverId) {
const session = useSessionStore.getState().sessions[input.serverId];
useSessionStore.getState().setAgentStreamState(input.serverId, input.agentId, {
tail: clearOptimisticUserMessages(session?.agentStreamTail.get(input.agentId) ?? []),
head: clearOptimisticUserMessages(session?.agentStreamHead.get(input.agentId) ?? []),
});
}
const cursor = input.serverId
? useSessionStore
.getState()
.sessions[input.serverId]?.agentTimelineCursor.get(input.agentId)
: undefined;
await input.client.fetchAgentTimeline(input.agentId, {
direction: "tail",
projection: "canonical",
...(cursor ? { cursor: { epoch: cursor.epoch, seq: cursor.endSeq } } : {}),
});
}
},
onSuccess: (_data, variables) => {
if (!shouldRestoreComposerForRewindMode(variables.mode)) {
return;
}
composerRestore?.restoreTextIfComposerEmpty(variables.rewoundText);
},
onError: (error) => {
toast.error(error instanceof Error ? error.message : "Failed to rewind agent");
},
});
const rewindAgent = useCallback(
async (rewindInput: RewindAgentInput) => {
if (isPending) {
return;
}
await mutateAsync(rewindInput);
},
[isPending, mutateAsync],
);
return {
rewindAgent,
isPending,
};
}
@@ -0,0 +1,36 @@
import { describe, expect, test } from "vitest";
import { resolveRewindMenuItems } from "./use-rewind-capabilities";
describe("resolveRewindMenuItems", () => {
test("returns no items when the provider declares no rewind capability", () => {
expect(
resolveRewindMenuItems({
supportsRewindConversation: false,
supportsRewindFiles: false,
supportsRewindBoth: false,
}),
).toEqual([]);
});
test("returns only the capabilities declared by the provider", () => {
expect(
resolveRewindMenuItems({
supportsRewindConversation: true,
supportsRewindFiles: false,
supportsRewindBoth: true,
}),
).toEqual([
{
mode: "conversation",
label: "Rewind conversation",
testID: "rewind-menu-conversation",
},
{
mode: "both",
label: "Rewind conversation and files",
testID: "rewind-menu-both",
},
]);
});
});
@@ -0,0 +1,53 @@
import { useMemo } from "react";
import type { AgentCapabilityFlags } from "@server/server/agent/agent-sdk-types";
export type RewindMode = "conversation" | "files" | "both";
export interface RewindMenuItem {
mode: RewindMode;
label: string;
testID: string;
}
export function resolveRewindMenuItems(
capabilities:
| Pick<
AgentCapabilityFlags,
"supportsRewindConversation" | "supportsRewindFiles" | "supportsRewindBoth"
>
| null
| undefined,
): RewindMenuItem[] {
if (!capabilities) {
return [];
}
const items: RewindMenuItem[] = [];
if (capabilities.supportsRewindConversation) {
items.push({
mode: "conversation",
label: "Rewind conversation",
testID: "rewind-menu-conversation",
});
}
if (capabilities.supportsRewindFiles) {
items.push({
mode: "files",
label: "Rewind files",
testID: "rewind-menu-files",
});
}
if (capabilities.supportsRewindBoth) {
items.push({
mode: "both",
label: "Rewind conversation and files",
testID: "rewind-menu-both",
});
}
return items;
}
export function useRewindCapabilities(
capabilities: Parameters<typeof resolveRewindMenuItems>[0],
): RewindMenuItem[] {
return useMemo(() => resolveRewindMenuItems(capabilities), [capabilities]);
}
@@ -1664,6 +1664,9 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
id: messageId,
text: message,
timestamp: new Date(),
optimistic: true,
...(images && images.length > 0 ? { images } : {}),
...(attachments && attachments.length > 0 ? { attachments } : {}),
};
// Append to head if streaming (keeps the user message with the current
@@ -50,7 +50,7 @@ type DraftComposerState = UseAgentFormStateResult & {
commandDraftConfig: DraftCommandConfig | undefined;
};
interface AgentInputDraft {
export interface AgentInputDraft {
text: string;
setText: (text: string) => void;
attachments: UserComposerAttachment[];
@@ -99,6 +99,16 @@ function expectSyncErrorSync(state: ReadyState): void {
expect(state.sync.status).toBe("sync_error");
}
function stateMachineLabel(state: AgentScreenViewState): "loading" | "resolving" | Agent["status"] {
if (state.tag === "boot") {
return state.reason;
}
if (state.tag === "ready") {
return state.agent.status;
}
return "error";
}
describe("deriveAgentScreenViewState", () => {
it("returns boot loading before first interactive paint", () => {
const memory = createBaseMemory();
@@ -383,6 +393,39 @@ describe("deriveAgentScreenViewState", () => {
expect(ready.agent.status).toBe("running");
});
it("keeps send lifecycle transitions forward-only across optimistic and authoritative handoff", () => {
let memory = createBaseMemory();
const transitions: Array<"loading" | "resolving" | Agent["status"]> = [];
for (const input of [
createBaseInput(),
{
...createBaseInput(),
agent: createAgentWithStatus({ id: "agent-1", status: "idle" }),
placeholderAgent: createAgent("agent-1"),
shouldUseOptimisticStream: true,
},
{
...createBaseInput(),
agent: createAgentWithStatus({ id: "agent-1", status: "running" }),
placeholderAgent: createAgent("agent-1"),
shouldUseOptimisticStream: true,
},
{
...createBaseInput(),
agent: createAgentWithStatus({ id: "agent-1", status: "idle" }),
},
] satisfies AgentScreenMachineInput[]) {
const result = deriveAgentScreenViewState({ input, memory });
memory = result.memory;
transitions.push(stateMachineLabel(result.state));
}
expect(transitions).toEqual(["loading", "running", "running", "idle"]);
expect(transitions.join(" -> ")).not.toContain("running -> loading");
expect(transitions.join(" -> ")).not.toContain("loading -> idle");
});
it("keeps optimistic running status while authoritative agent is initializing", () => {
const memory = createBaseMemory();
const input: AgentScreenMachineInput = {
@@ -1,10 +1,12 @@
import { useRef } from "react";
import type { AgentCapabilityFlags } from "@server/server/agent/agent-sdk-types";
export interface AgentScreenAgent {
serverId: string;
id: string;
status: "initializing" | "idle" | "running" | "error" | "closed";
cwd: string;
capabilities?: AgentCapabilityFlags;
lastError?: string | null;
projectPlacement?: {
checkout?: {
@@ -133,6 +133,7 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
id: machine.attempt.clientMessageId,
text: machine.attempt.text,
timestamp: machine.attempt.timestamp,
optimistic: true,
...(machine.attempt.images && machine.attempt.images.length > 0
? { images: machine.attempt.images }
: {}),
+159 -63
View File
@@ -11,6 +11,7 @@ import { AgentStreamView, type AgentStreamViewHandle } from "@/agent-stream/view
import { ArchivedAgentCallout } from "@/components/archived-agent-callout";
import { Composer } from "@/components/composer";
import { FileDropZone } from "@/components/file-drop-zone";
import { RewindComposerRestoreProvider } from "@/components/rewind/composer-restore";
import type { ImageAttachment } from "@/components/message-input";
import { getProviderIcon } from "@/components/provider-icons";
import { ToastViewport, useToastHost } from "@/components/toast-host";
@@ -23,7 +24,7 @@ import { useIsCompactFormFactor } from "@/constants/layout";
import { isNative, isWeb } from "@/constants/platform";
import { useAgentAttentionClear } from "@/hooks/use-agent-attention-clear";
import { useAgentInitialization } from "@/hooks/use-agent-initialization";
import { useAgentInputDraft } from "@/hooks/use-agent-input-draft";
import { useAgentInputDraft, type AgentInputDraft } from "@/hooks/use-agent-input-draft";
import {
type AgentScreenAgent,
type AgentScreenMissingState,
@@ -69,6 +70,7 @@ interface ChatAgentStateShape {
id: string | null;
status: Agent["status"] | null;
cwd: string | null;
capabilities?: Agent["capabilities"];
lastError?: Agent["lastError"] | null;
}
@@ -111,6 +113,7 @@ function selectChatAgentState(
id: agent.id,
status: agent.status,
cwd: agent.cwd,
capabilities: agent.capabilities,
lastError: agent.lastError ?? null,
archivedAt: agent.archivedAt ?? null,
requiresAttention: agent.requiresAttention ?? false,
@@ -130,6 +133,7 @@ function buildChatAgentFromState(
id: state.id,
status: state.status,
cwd: state.cwd,
capabilities: state.capabilities,
lastError: state.lastError ?? null,
projectPlacement,
};
@@ -945,70 +949,162 @@ function ChatAgentContent({
effectiveAgent,
});
if (nonReadyView) return nonReadyView;
invariant(agentId, "agent id is defined when agent content is ready");
invariant(effectiveAgent, "effectiveAgent is defined when the non-ready view is absent");
invariant(agentState.cwd, "agent cwd is defined when agent content is ready");
const agentCwd = agentState.cwd;
invariant(agentCwd, "agent cwd is defined when agent content is ready");
const showHistorySyncOverlay =
viewState.tag === "ready" &&
viewState.sync.status === "catching_up" &&
viewState.sync.ui === "overlay";
return (
<View style={styles.root}>
<FileDropZone onFilesDropped={handleFilesDropped} disabled={isArchivingCurrentAgent}>
<View style={styles.container}>
<View style={styles.contentContainer}>
<ReanimatedAnimated.View style={animatedContentStyle}>
<AgentStreamSection
streamViewRef={streamViewRef}
serverId={serverId}
agentId={agentId}
agent={effectiveAgent}
pendingCreate={pendingCreate}
shouldUseOptimisticStream={shouldUseOptimisticStream}
canFinalizePendingCreate={canFinalizePendingCreate}
routeBottomAnchorRequest={routeBottomAnchorRequest}
hasAppliedAuthoritativeHistory={hasAppliedAuthoritativeHistory}
toast={panelToast.api}
onOpenWorkspaceFile={onOpenWorkspaceFile}
/>
</ReanimatedAnimated.View>
</View>
<ChatAgentReadyContent
serverId={serverId}
agentId={agentId}
isPaneFocused={isPaneFocused}
isArchivingCurrentAgent={isArchivingCurrentAgent}
agentState={agentState}
effectiveAgent={effectiveAgent}
pendingCreate={pendingCreate}
shouldUseOptimisticStream={shouldUseOptimisticStream}
canFinalizePendingCreate={canFinalizePendingCreate}
routeBottomAnchorRequest={routeBottomAnchorRequest}
hasAppliedAuthoritativeHistory={hasAppliedAuthoritativeHistory}
panelToast={panelToast}
streamViewRef={streamViewRef}
animatedContentStyle={animatedContentStyle}
handleFilesDropped={handleFilesDropped}
handleAddImagesCallback={handleAddImagesCallback}
handleComposerHeightChange={handleComposerHeightChange}
handleMessageSent={handleMessageSent}
showPendingCreateSubmitLoading={showPendingCreateSubmitLoading}
showHistorySyncOverlay={showHistorySyncOverlay}
cwd={agentCwd}
attentionController={attentionController}
onOpenWorkspaceFile={onOpenWorkspaceFile}
/>
);
}
<AgentComposerSection
agentId={agentId}
serverId={serverId}
isPaneFocused={isPaneFocused}
isArchivingCurrentAgent={isArchivingCurrentAgent}
archivedAt={agentState.archivedAt}
cwd={agentState.cwd}
isSubmitLoading={showPendingCreateSubmitLoading}
onAttentionInputFocus={attentionController.clearOnInputFocus}
onAttentionPromptSend={attentionController.clearOnPromptSend}
onAddImages={handleAddImagesCallback}
onComposerHeightChange={handleComposerHeightChange}
onMessageSent={handleMessageSent}
/>
function ChatAgentReadyContent({
serverId,
agentId,
isPaneFocused,
isArchivingCurrentAgent,
agentState,
effectiveAgent,
pendingCreate,
shouldUseOptimisticStream,
canFinalizePendingCreate,
routeBottomAnchorRequest,
hasAppliedAuthoritativeHistory,
panelToast,
streamViewRef,
animatedContentStyle,
handleFilesDropped,
handleAddImagesCallback,
handleComposerHeightChange,
handleMessageSent,
showPendingCreateSubmitLoading,
showHistorySyncOverlay,
cwd,
attentionController,
onOpenWorkspaceFile,
}: {
serverId: string;
agentId: string;
isPaneFocused: boolean;
isArchivingCurrentAgent: boolean;
agentState: ChatAgentSelectedState;
effectiveAgent: AgentScreenAgent;
pendingCreate: PendingCreateAttempt | null;
shouldUseOptimisticStream: boolean;
canFinalizePendingCreate: boolean;
routeBottomAnchorRequest: RouteBottomAnchorRequest;
hasAppliedAuthoritativeHistory: boolean;
panelToast: ReturnType<typeof useToastHost>;
streamViewRef: React.RefObject<AgentStreamViewHandle | null>;
animatedContentStyle: object[];
handleFilesDropped: (files: ImageAttachment[]) => void;
handleAddImagesCallback: (addImages: (images: ImageAttachment[]) => void) => void;
handleComposerHeightChange: (height: number) => void;
handleMessageSent: () => void;
showPendingCreateSubmitLoading: boolean;
showHistorySyncOverlay: boolean;
cwd: string;
attentionController: ReturnType<typeof useAgentAttentionClear>;
onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void;
}) {
const agentInputDraft = useAgentInputDraft({
draftKey: buildDraftStoreKey({
serverId,
agentId,
}),
});
{viewState.tag === "ready" &&
viewState.sync.status === "catching_up" &&
viewState.sync.ui === "overlay" ? (
<View style={styles.historySyncOverlay} testID="agent-history-overlay">
<ThemedActivityIndicator size="large" uniProps={foregroundMutedColorMapping} />
return (
<RewindComposerRestoreProvider text={agentInputDraft.text} setText={agentInputDraft.setText}>
<View style={styles.root}>
<FileDropZone onFilesDropped={handleFilesDropped} disabled={isArchivingCurrentAgent}>
<View style={styles.container}>
<View style={styles.contentContainer}>
<ReanimatedAnimated.View style={animatedContentStyle}>
<AgentStreamSection
streamViewRef={streamViewRef}
serverId={serverId}
agentId={agentId}
agent={effectiveAgent}
pendingCreate={pendingCreate}
shouldUseOptimisticStream={shouldUseOptimisticStream}
canFinalizePendingCreate={canFinalizePendingCreate}
routeBottomAnchorRequest={routeBottomAnchorRequest}
hasAppliedAuthoritativeHistory={hasAppliedAuthoritativeHistory}
toast={panelToast.api}
onOpenWorkspaceFile={onOpenWorkspaceFile}
/>
</ReanimatedAnimated.View>
</View>
) : null}
<ToastViewport
toast={panelToast.toast}
onDismiss={panelToast.dismiss}
placement="panel"
/>
</View>
</FileDropZone>
<AgentComposerSection
agentId={agentId}
serverId={serverId}
isPaneFocused={isPaneFocused}
isArchivingCurrentAgent={isArchivingCurrentAgent}
archivedAt={agentState.archivedAt}
cwd={cwd}
isSubmitLoading={showPendingCreateSubmitLoading}
agentInputDraft={agentInputDraft}
onAttentionInputFocus={attentionController.clearOnInputFocus}
onAttentionPromptSend={attentionController.clearOnPromptSend}
onAddImages={handleAddImagesCallback}
onComposerHeightChange={handleComposerHeightChange}
onMessageSent={handleMessageSent}
/>
{isArchivingCurrentAgent ? (
<View style={styles.archivingOverlay} testID="agent-archiving-overlay">
<ThemedActivityIndicator size="large" uniProps={foregroundColorMapping} />
<Text style={styles.archivingTitle}>Archiving agent...</Text>
<Text style={styles.archivingSubtitle}>Please wait while we archive this agent.</Text>
</View>
) : null}
</View>
{showHistorySyncOverlay ? (
<View style={styles.historySyncOverlay} testID="agent-history-overlay">
<ThemedActivityIndicator size="large" uniProps={foregroundMutedColorMapping} />
</View>
) : null}
<ToastViewport
toast={panelToast.toast}
onDismiss={panelToast.dismiss}
placement="panel"
/>
</View>
</FileDropZone>
{isArchivingCurrentAgent ? (
<View style={styles.archivingOverlay} testID="agent-archiving-overlay">
<ThemedActivityIndicator size="large" uniProps={foregroundColorMapping} />
<Text style={styles.archivingTitle}>Archiving agent...</Text>
<Text style={styles.archivingSubtitle}>Please wait while we archive this agent.</Text>
</View>
) : null}
</View>
</RewindComposerRestoreProvider>
);
}
@@ -1081,6 +1177,7 @@ function AgentStreamSection({
id: pendingCreate.clientMessageId,
text: pendingCreate.text,
timestamp: new Date(pendingCreate.timestamp),
optimistic: true,
...(pendingCreate.images && pendingCreate.images.length > 0
? { images: pendingCreate.images }
: {}),
@@ -1183,6 +1280,7 @@ function AgentComposerSection({
archivedAt,
cwd,
isSubmitLoading,
agentInputDraft,
onAttentionInputFocus,
onAttentionPromptSend,
onAddImages,
@@ -1196,6 +1294,7 @@ function AgentComposerSection({
archivedAt: Date | null;
cwd: string;
isSubmitLoading: boolean;
agentInputDraft: AgentInputDraft;
onAttentionInputFocus: () => void;
onAttentionPromptSend: () => void;
onAddImages: (addImages: (images: ImageAttachment[]) => void) => void;
@@ -1219,6 +1318,7 @@ function AgentComposerSection({
isPaneFocused={isPaneFocused}
cwd={cwd}
isSubmitLoading={isSubmitLoading}
agentInputDraft={agentInputDraft}
onAttentionInputFocus={onAttentionInputFocus}
onAttentionPromptSend={onAttentionPromptSend}
onAddImages={onAddImages}
@@ -1234,6 +1334,7 @@ function ActiveAgentComposer({
isPaneFocused,
cwd,
isSubmitLoading,
agentInputDraft,
onAttentionInputFocus,
onAttentionPromptSend,
onAddImages,
@@ -1245,6 +1346,7 @@ function ActiveAgentComposer({
isPaneFocused: boolean;
cwd: string;
isSubmitLoading: boolean;
agentInputDraft: AgentInputDraft;
onAttentionInputFocus: () => void;
onAttentionPromptSend: () => void;
onAddImages: (addImages: (images: ImageAttachment[]) => void) => void;
@@ -1270,12 +1372,6 @@ function ActiveAgentComposer({
[serverId],
);
const handleArchiveSubagent = useArchiveSubagent({ serverId });
const agentInputDraft = useAgentInputDraft({
draftKey: buildDraftStoreKey({
serverId,
agentId,
}),
});
const workspaceAttachmentScopeKey = useWorkspaceAttachmentScopeKey({
serverId,
cwd,
@@ -276,8 +276,17 @@ describe("processTimelineResponse", () => {
});
it("sets cursor to null when reset=true but no cursors in payload", () => {
const existingTail: StreamItem[] = [
{
kind: "user_message",
id: "only-user",
text: "first turn",
timestamp: new Date(500),
},
];
const result = processTimelineResponse({
...baseTimelineInput,
currentTail: existingTail,
currentCursor: { epoch: "epoch-1", startSeq: 1, endSeq: 5 },
payload: {
...baseTimelineInput.payload,
@@ -288,6 +297,8 @@ describe("processTimelineResponse", () => {
expect(result.cursor).toBe(null);
expect(result.cursorChanged).toBe(true);
expect(result.tail).toEqual([]);
expect(getUserTexts(result.tail)).toHaveLength(0);
});
it("performs bootstrap tail init with catch-up side effect", () => {
@@ -933,6 +944,34 @@ describe("processAgentStreamEvent", () => {
expect(result.sideEffects).toEqual([]);
});
it("resets visible timeline when a new epoch starts at seq 1", () => {
const existingCursor: TimelineCursor = {
epoch: "epoch-1",
startSeq: 1,
endSeq: 5,
};
const currentTail = [makeAssistantItem("old timeline")];
const result = processAgentStreamEvent({
...baseStreamInput,
event: makeTimelineEvent("rewound start", "user_message"),
seq: 1,
epoch: "epoch-2",
currentCursor: existingCursor,
currentTail,
});
expect(result.cursorChanged).toBe(true);
expect(result.cursor).toEqual({
epoch: "epoch-2",
startSeq: 1,
endSeq: 1,
});
expect(getAssistantTexts(result.tail)).toEqual([]);
expect(getUserTexts(result.tail)).toEqual(["rewound start"]);
expect(result.sideEffects).toEqual([]);
});
it("initializes cursor when none exists", () => {
const result = processAgentStreamEvent({
...baseStreamInput,
@@ -623,6 +623,14 @@ export interface AgentStreamReducerEvent {
timestamp: Date;
}
interface TimelineSequencingGateResult {
shouldApplyStreamEvent: boolean;
nextTimelineCursor: TimelineCursor | null;
cursorChanged: boolean;
resetLiveTimeline: boolean;
sideEffects: AgentStreamReducerSideEffect[];
}
export interface AgentStreamReducerAgentSnapshot {
status: AgentLifecycleStatus;
updatedAt: Date;
@@ -672,61 +680,91 @@ function applyAgentPatch(
};
}
function processTimelineSequencingGate(input: {
event: AgentStreamEventPayload;
seq: number | undefined;
epoch: string | undefined;
currentCursor: TimelineCursor | undefined;
}): TimelineSequencingGateResult {
const { event, seq, epoch, currentCursor } = input;
const base: TimelineSequencingGateResult = {
shouldApplyStreamEvent: true,
nextTimelineCursor: null,
cursorChanged: false,
resetLiveTimeline: false,
sideEffects: [],
};
if (event.type !== "timeline" || typeof seq !== "number" || typeof epoch !== "string") {
return base;
}
const decision = classifySessionTimelineSeq({
cursor: currentCursor ? { epoch: currentCursor.epoch, endSeq: currentCursor.endSeq } : null,
epoch,
seq,
});
if (decision === "init") {
return {
...base,
nextTimelineCursor: { epoch, startSeq: seq, endSeq: seq },
cursorChanged: true,
};
}
if (decision === "accept") {
return {
...base,
nextTimelineCursor: {
...(currentCursor ?? { epoch, startSeq: seq, endSeq: seq }),
epoch,
endSeq: seq,
},
cursorChanged: true,
};
}
if (decision === "gap") {
return {
...base,
shouldApplyStreamEvent: false,
sideEffects: currentCursor
? [
{
type: "catch_up",
cursor: { epoch: currentCursor.epoch, endSeq: currentCursor.endSeq },
},
]
: [],
};
}
if (decision === "drop_epoch" && seq === 1) {
return {
...base,
nextTimelineCursor: { epoch, startSeq: seq, endSeq: seq },
cursorChanged: true,
resetLiveTimeline: true,
};
}
return {
...base,
shouldApplyStreamEvent: false,
};
}
export function processAgentStreamEvent(
input: ProcessAgentStreamEventInput,
): ProcessAgentStreamEventOutput {
const { event, seq, epoch, currentTail, currentHead, currentCursor, currentAgent, timestamp } =
input;
let shouldApplyStreamEvent = true;
let nextTimelineCursor: TimelineCursor | null = null;
let cursorChanged = false;
const sideEffects: AgentStreamReducerSideEffect[] = [];
// ------------------------------------------------------------------
// Timeline sequencing gate
// ------------------------------------------------------------------
if (event.type === "timeline" && typeof seq === "number" && typeof epoch === "string") {
const decision = classifySessionTimelineSeq({
cursor: currentCursor ? { epoch: currentCursor.epoch, endSeq: currentCursor.endSeq } : null,
epoch,
seq,
});
if (decision === "init") {
nextTimelineCursor = { epoch, startSeq: seq, endSeq: seq };
cursorChanged = true;
} else if (decision === "accept") {
nextTimelineCursor = {
...(currentCursor ?? { epoch, startSeq: seq, endSeq: seq }),
epoch,
endSeq: seq,
};
cursorChanged = true;
} else if (decision === "gap") {
shouldApplyStreamEvent = false;
if (currentCursor) {
sideEffects.push({
type: "catch_up",
cursor: {
epoch: currentCursor.epoch,
endSeq: currentCursor.endSeq,
},
});
}
} else {
// drop_stale or drop_epoch
shouldApplyStreamEvent = false;
}
}
const sequencing = processTimelineSequencingGate({ event, seq, epoch, currentCursor });
// ------------------------------------------------------------------
// Apply stream event to tail/head
// ------------------------------------------------------------------
const { tail, head, changedTail, changedHead } = shouldApplyStreamEvent
const { tail, head, changedTail, changedHead } = sequencing.shouldApplyStreamEvent
? applyStreamEvent({
tail: currentTail,
head: currentHead,
tail: sequencing.resetLiveTimeline ? [] : currentTail,
head: sequencing.resetLiveTimeline ? [] : currentHead,
event,
timestamp,
source: "live",
@@ -771,11 +809,11 @@ export function processAgentStreamEvent(
head,
changedTail,
changedHead,
cursor: nextTimelineCursor,
cursorChanged,
cursor: sequencing.nextTimelineCursor,
cursorChanged: sequencing.cursorChanged,
agent: agentPatch,
agentChanged,
sideEffects,
sideEffects: sequencing.sideEffects,
};
}
+271 -2
View File
@@ -1,8 +1,10 @@
import assert from "node:assert/strict";
import invariant from "tiny-invariant";
import { describe, it } from "vitest";
import { describe, expect, it } from "vitest";
import {
applyStreamEvent,
clearOptimisticUserMessages,
hydrateStreamState,
mergeToolCallDetail,
reduceStreamUpdate,
@@ -309,7 +311,7 @@ describe("stream reducer canonical tool calls", () => {
const first = hydrateStreamState(updates);
const second = hydrateStreamState(updates);
assert.strictEqual(JSON.stringify(first), JSON.stringify(second));
expect(first).toEqual(second);
const assistantMessage = first.find((item) => item.kind === "assistant_message");
assert.strictEqual(assistantMessage?.text, "Hello world");
});
@@ -735,6 +737,7 @@ describe("stream reducer canonical tool calls", () => {
id: messageId,
text: "Analyze this image",
timestamp: new Date("2025-01-01T11:10:00Z"),
optimistic: true,
images: optimisticImages,
},
];
@@ -935,4 +938,270 @@ describe("turn lifecycle events", () => {
["user_message", "user_message"],
);
});
it.each(["codex", "opencode", "pi"] satisfies AgentProvider[])(
"replaces an optimistic user message when a live %s provider-owned id echo arrives without text matching",
(provider) => {
const optimisticTimestamp = new Date("2025-01-01T15:02:00Z");
const serverTimestamp = new Date("2025-01-01T15:02:01Z");
const optimistic: StreamItem = {
kind: "user_message",
id: "msg_optimistic",
text: "same user text",
timestamp: optimisticTimestamp,
optimistic: true,
images: [
{
id: "image-1",
mimeType: "image/png",
storageType: "web-indexeddb",
storageKey: "image-1",
createdAt: optimisticTimestamp.getTime(),
},
],
attachments: [
{
type: "text",
mimeType: "text/plain",
text: "attached context",
title: "context.txt",
},
],
};
const state = reduceStreamUpdate(
[optimistic],
{
type: "timeline",
provider,
item: {
type: "user_message",
text: "server-owned rendered text",
messageId: "provider-owned-id",
},
},
serverTimestamp,
{ source: "live" },
);
const userMessages = state.filter((item) => item.kind === "user_message");
assert.strictEqual(userMessages.length, 1);
const userMessage = userMessages[0];
invariant(userMessage?.kind === "user_message");
assert.strictEqual(userMessage.id, "provider-owned-id");
assert.strictEqual(userMessage.text, "server-owned rendered text");
assert.deepStrictEqual(userMessage.images, optimistic.images);
assert.deepStrictEqual(userMessage.attachments, optimistic.attachments);
},
);
it("replaces one optimistic plain-text user message with the next live server user message", () => {
const optimisticTimestamp = new Date("2025-01-01T15:03:00Z");
const serverTimestamp = new Date("2025-01-01T15:03:01Z");
const optimistic: StreamItem = {
kind: "user_message",
id: "msg_optimistic",
text: "typed plain text",
timestamp: optimisticTimestamp,
optimistic: true,
};
const state = reduceStreamUpdate(
[optimistic],
{
type: "timeline",
provider: "opencode",
item: {
type: "user_message",
text: "typed plain text",
messageId: "msg_opencode_provider_owned",
},
},
serverTimestamp,
{ source: "live" },
);
const userMessages = state.filter((item) => item.kind === "user_message");
assert.strictEqual(userMessages.length, 1);
const userMessage = userMessages[0];
invariant(userMessage?.kind === "user_message");
assert.strictEqual(userMessage.id, "msg_opencode_provider_owned");
assert.strictEqual(userMessage.text, "typed plain text");
assert.strictEqual(userMessage.optimistic, undefined);
});
it("reconciles an optimistic user message that was pending in the streaming head", () => {
const optimistic: StreamItem = {
kind: "user_message",
id: "msg_head_optimistic",
text: "plain text in head",
timestamp: new Date("2025-01-01T15:03:02Z"),
optimistic: true,
};
const result = applyStreamEvent({
tail: [],
head: [optimistic],
event: {
type: "timeline",
provider: "opencode",
item: {
type: "user_message",
text: "plain text in head",
messageId: "provider-owned-head",
},
},
timestamp: new Date("2025-01-01T15:03:03Z"),
source: "live",
});
assert.strictEqual(result.head.length, 0);
const userMessages = result.tail.filter((item) => item.kind === "user_message");
assert.strictEqual(userMessages.length, 1);
assert.strictEqual(userMessages[0]?.id, "provider-owned-head");
assert.strictEqual(userMessages[0]?.optimistic, undefined);
});
it("replaces multiple optimistic user messages in FIFO order", () => {
const optimisticTimestamp = new Date("2025-01-01T15:04:00Z");
const serverTimestamp = new Date("2025-01-01T15:04:01Z");
const firstOptimistic: StreamItem = {
kind: "user_message",
id: "msg_optimistic_1",
text: "first typed text",
timestamp: optimisticTimestamp,
optimistic: true,
};
const secondOptimistic: StreamItem = {
kind: "user_message",
id: "msg_optimistic_2",
text: "second typed text",
timestamp: new Date("2025-01-01T15:04:00.500Z"),
optimistic: true,
};
const afterFirstEcho = reduceStreamUpdate(
[firstOptimistic, secondOptimistic],
{
type: "timeline",
provider: "opencode",
item: {
type: "user_message",
text: "first server text",
messageId: "provider-owned-first",
},
},
serverTimestamp,
{ source: "live" },
);
const state = reduceStreamUpdate(
afterFirstEcho,
{
type: "timeline",
provider: "opencode",
item: {
type: "user_message",
text: "second server text",
messageId: "provider-owned-second",
},
},
new Date("2025-01-01T15:04:02Z"),
{ source: "live" },
);
const userMessages = state.filter((item) => item.kind === "user_message");
assert.strictEqual(userMessages.length, 2);
assert.deepStrictEqual(
userMessages.map((item) => [item.id, item.text, item.optimistic]),
[
["provider-owned-first", "first server text", undefined],
["provider-owned-second", "second server text", undefined],
],
);
});
it("appends a live server user message when no optimistic user message is pending", () => {
const state = reduceStreamUpdate(
[],
{
type: "timeline",
provider: "opencode",
item: {
type: "user_message",
text: "resumed session text",
messageId: "provider-owned-resume",
},
},
new Date("2025-01-01T15:04:03Z"),
{ source: "live" },
);
const userMessages = state.filter((item) => item.kind === "user_message");
assert.strictEqual(userMessages.length, 1);
assert.strictEqual(userMessages[0]?.id, "provider-owned-resume");
assert.strictEqual(userMessages[0]?.optimistic, undefined);
});
it("does not match a server user message to an optimistic from a rewound turn after pending optimistics are cleared", () => {
const optimistic: StreamItem = {
kind: "user_message",
id: "msg_rewound_optimistic",
text: "rewound text",
timestamp: new Date("2025-01-01T15:04:04Z"),
optimistic: true,
};
const cleared = clearOptimisticUserMessages([optimistic]);
const state = reduceStreamUpdate(
cleared,
{
type: "timeline",
provider: "opencode",
item: {
type: "user_message",
text: "future server echo",
messageId: "provider-owned-after-rewind",
},
},
new Date("2025-01-01T15:04:05Z"),
{ source: "live" },
);
const userMessages = state.filter((item) => item.kind === "user_message");
assert.strictEqual(userMessages.length, 1);
assert.strictEqual(userMessages[0]?.id, "provider-owned-after-rewind");
assert.strictEqual(userMessages[0]?.text, "future server echo");
assert.strictEqual(userMessages[0]?.optimistic, undefined);
});
it("keeps canonical repeated user messages distinct during hydration", () => {
const state = hydrateStreamState(
[
{
event: {
type: "timeline",
provider: "codex",
item: { type: "user_message", text: "repeat", messageId: "native-1" },
},
timestamp: new Date("2025-01-01T15:03:00Z"),
},
{
event: {
type: "timeline",
provider: "codex",
item: { type: "user_message", text: "repeat", messageId: "native-2" },
},
timestamp: new Date("2025-01-01T15:03:01Z"),
},
],
{ source: "canonical" },
);
const userMessages = state.filter((item) => item.kind === "user_message");
assert.strictEqual(userMessages.length, 2);
assert.deepStrictEqual(
userMessages.map((item) => item.id),
["native-1", "native-2"],
);
});
});
+41 -15
View File
@@ -59,6 +59,7 @@ export interface UserMessageItem {
id: string;
text: string;
timestamp: Date;
optimistic?: true;
images?: UserMessageImageAttachment[];
attachments?: AgentAttachment[];
}
@@ -184,10 +185,32 @@ function markThoughtReady(item: ThoughtItem): ThoughtItem {
};
}
function buildUserMessageItem(input: {
id: string;
text: string;
timestamp: Date;
optimistic?: UserMessageItem | null;
}): UserMessageItem {
const preservedImages = input.optimistic?.images;
const preservedAttachments = input.optimistic?.attachments;
return {
kind: "user_message",
id: input.id,
text: input.text,
timestamp: input.timestamp,
...(preservedImages && preservedImages.length > 0 ? { images: preservedImages } : {}),
...(preservedAttachments && preservedAttachments.length > 0
? { attachments: preservedAttachments }
: {}),
};
}
function appendUserMessage(
state: StreamItem[],
text: string,
timestamp: Date,
source: StreamUpdateSource,
messageId?: string,
): StreamItem[] {
const { chunk, hasContent } = normalizeChunk(text);
@@ -197,32 +220,33 @@ function appendUserMessage(
const chunkSeed = chunk.trim() || chunk;
const entryId = messageId ?? createUniqueTimelineId(state, "user", chunkSeed, timestamp);
const existingIndex = state.findIndex(
(entry) => entry.kind === "user_message" && entry.id === entryId,
);
const existing =
existingIndex >= 0 && state[existingIndex]?.kind === "user_message"
? state[existingIndex]
: null;
const preservedImages = existing?.images;
const optimisticIndex =
source === "live"
? state.findIndex((entry) => entry.kind === "user_message" && entry.optimistic)
: -1;
const optimistic = optimisticIndex >= 0 ? (state[optimisticIndex] as UserMessageItem) : null;
const nextItem: UserMessageItem = {
kind: "user_message",
const nextItem = buildUserMessageItem({
id: entryId,
text: chunk,
timestamp,
...(preservedImages && preservedImages.length > 0 ? { images: preservedImages } : {}),
};
optimistic,
});
if (existingIndex >= 0) {
if (optimisticIndex >= 0) {
const next = [...state];
next[existingIndex] = nextItem;
next[optimisticIndex] = nextItem;
return next;
}
return [...state, nextItem];
}
export function clearOptimisticUserMessages(state: StreamItem[]): StreamItem[] {
const next = state.filter((item) => item.kind !== "user_message" || !item.optimistic);
return next.length === state.length ? state : next;
}
function appendAssistantMessage(
state: StreamItem[],
text: string,
@@ -664,7 +688,9 @@ function reduceTimelineEvent(
const item = event.item;
switch (item.type) {
case "user_message":
return finalizeActiveThoughts(appendUserMessage(state, item.text, timestamp, item.messageId));
return finalizeActiveThoughts(
appendUserMessage(state, item.text, timestamp, source, item.messageId),
);
case "assistant_message":
return finalizeActiveThoughts(
appendAssistantMessage(state, item.text, timestamp, source, item.messageId),