* fix(chat): allow failed turns to be forked Failed turns can end in Paseo-generated timeline items without provider message IDs. Anchor forks to canonical timeline positions while retaining legacy boundaries for compatibility. Place the fork summary before the new prompt and delimit it as structured chat history. * fix(chat): tighten fork boundary handling
222 lines
5.8 KiB
TypeScript
222 lines
5.8 KiB
TypeScript
import React, { memo, useCallback, useMemo, type ReactNode } from "react";
|
|
import { View } from "react-native";
|
|
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
|
import { MAX_CONTENT_WIDTH } from "@/constants/layout";
|
|
import type { Theme } from "@/styles/theme";
|
|
import type { TurnTiming } from "@/timeline/turn-time";
|
|
import type { StreamItem } from "@/types/stream";
|
|
import {
|
|
collectAssistantTurnContentForStreamRenderStrategy,
|
|
type StreamStrategy,
|
|
} from "./strategy";
|
|
import { resolveAssistantTurnForkBoundary, type AssistantTurnForkBoundary } from "./turn-boundary";
|
|
import {
|
|
AssistantTurnFooter,
|
|
LiveElapsed,
|
|
STREAM_METADATA_FONT_SIZE,
|
|
type AssistantForkTarget,
|
|
} from "@/components/message";
|
|
import type { TurnFooterHost } from "./layout";
|
|
import { SyncedLoader } from "@/components/synced-loader";
|
|
import { useRetainedPanelActive } from "@/components/retained-panel";
|
|
|
|
const ThemedSyncedLoader = withUnistyles(SyncedLoader);
|
|
const workingIndicatorColorMapping = (theme: Theme) => ({
|
|
color:
|
|
theme.colorScheme === "light"
|
|
? theme.colors.palette.amber[700]
|
|
: theme.colors.palette.amber[500],
|
|
});
|
|
|
|
export type TurnContentStrategy = StreamStrategy;
|
|
export type AssistantTurnForkHandler = (input: {
|
|
target: AssistantForkTarget;
|
|
boundary: AssistantTurnForkBoundary;
|
|
}) => Promise<void> | void;
|
|
|
|
export const TurnFooter = memo(function TurnFooter({
|
|
isRunning,
|
|
inFlightTurnStartedAt,
|
|
host,
|
|
strategy,
|
|
supportsTimelineCursor,
|
|
onForkAssistantTurn,
|
|
}: {
|
|
isRunning: boolean;
|
|
inFlightTurnStartedAt: Date | null;
|
|
host: TurnFooterHost | null;
|
|
strategy: TurnContentStrategy;
|
|
supportsTimelineCursor: boolean;
|
|
onForkAssistantTurn?: AssistantTurnForkHandler;
|
|
}) {
|
|
if (isRunning) {
|
|
return (
|
|
<TurnFooterRow>
|
|
<RunningTurnFooter inFlightTurnStartedAt={inFlightTurnStartedAt} />
|
|
</TurnFooterRow>
|
|
);
|
|
}
|
|
if (!host) {
|
|
return null;
|
|
}
|
|
return (
|
|
<CompletedTurnFooterRow
|
|
strategy={strategy}
|
|
items={host.items}
|
|
timing={host.timing}
|
|
startIndex={host.startIndex}
|
|
supportsTimelineCursor={supportsTimelineCursor}
|
|
onForkAssistantTurn={onForkAssistantTurn}
|
|
/>
|
|
);
|
|
});
|
|
|
|
export const CompletedTurnFooterRow = memo(function CompletedTurnFooterRow({
|
|
strategy,
|
|
items,
|
|
timing,
|
|
startIndex,
|
|
supportsTimelineCursor,
|
|
onForkAssistantTurn,
|
|
}: {
|
|
strategy: TurnContentStrategy;
|
|
items: StreamItem[];
|
|
timing?: TurnTiming;
|
|
startIndex: number;
|
|
supportsTimelineCursor: boolean;
|
|
onForkAssistantTurn?: AssistantTurnForkHandler;
|
|
}) {
|
|
return (
|
|
<TurnFooterRow>
|
|
<CompletedTurnFooter
|
|
strategy={strategy}
|
|
items={items}
|
|
timing={timing}
|
|
startIndex={startIndex}
|
|
supportsTimelineCursor={supportsTimelineCursor}
|
|
onForkAssistantTurn={onForkAssistantTurn}
|
|
/>
|
|
</TurnFooterRow>
|
|
);
|
|
});
|
|
|
|
const WorkingIndicator = memo(function WorkingIndicator({
|
|
inFlightTurnStartedAt = null,
|
|
}: {
|
|
inFlightTurnStartedAt?: Date | null;
|
|
}) {
|
|
const active = useRetainedPanelActive();
|
|
return (
|
|
<View style={stylesheet.turnFooterContent}>
|
|
<View style={stylesheet.workingLoader}>
|
|
<ThemedSyncedLoader size={14} uniProps={workingIndicatorColorMapping} />
|
|
</View>
|
|
{inFlightTurnStartedAt ? (
|
|
<LiveElapsed
|
|
startedAt={inFlightTurnStartedAt}
|
|
active={active}
|
|
style={stylesheet.workingElapsed}
|
|
testID="turn-working-elapsed"
|
|
/>
|
|
) : null}
|
|
</View>
|
|
);
|
|
});
|
|
|
|
function RunningTurnFooter({ inFlightTurnStartedAt }: { inFlightTurnStartedAt: Date | null }) {
|
|
return (
|
|
<View style={stylesheet.turnFooterSlot} testID="turn-working-indicator">
|
|
<WorkingIndicator inFlightTurnStartedAt={inFlightTurnStartedAt} />
|
|
</View>
|
|
);
|
|
}
|
|
|
|
function CompletedTurnFooter({
|
|
strategy,
|
|
items,
|
|
timing,
|
|
startIndex,
|
|
supportsTimelineCursor,
|
|
onForkAssistantTurn,
|
|
}: {
|
|
strategy: TurnContentStrategy;
|
|
items: StreamItem[];
|
|
timing?: TurnTiming;
|
|
startIndex: number;
|
|
supportsTimelineCursor: boolean;
|
|
onForkAssistantTurn?: AssistantTurnForkHandler;
|
|
}) {
|
|
const getContent = useCallback(
|
|
() =>
|
|
collectAssistantTurnContentForStreamRenderStrategy({
|
|
strategy,
|
|
items,
|
|
startIndex,
|
|
}),
|
|
[strategy, items, startIndex],
|
|
);
|
|
const boundary = resolveAssistantTurnForkBoundary({
|
|
items,
|
|
startIndex,
|
|
supportsTimelineCursor,
|
|
});
|
|
const handleFork = useCallback(
|
|
(target: AssistantForkTarget) => {
|
|
if (!boundary) {
|
|
return;
|
|
}
|
|
return onForkAssistantTurn?.({ target, boundary });
|
|
},
|
|
[boundary, onForkAssistantTurn],
|
|
);
|
|
return (
|
|
<View style={stylesheet.turnFooterSlot}>
|
|
<AssistantTurnFooter
|
|
getContent={getContent}
|
|
completedAt={timing?.completedAt}
|
|
durationMs={timing?.durationMs}
|
|
onFork={boundary && onForkAssistantTurn ? handleFork : undefined}
|
|
/>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
function TurnFooterRow({ children }: { children: ReactNode }) {
|
|
const rowStyle = useMemo(() => [stylesheet.streamItemWrapper, stylesheet.turnFooterRow], []);
|
|
return <View style={rowStyle}>{children}</View>;
|
|
}
|
|
|
|
const stylesheet = StyleSheet.create((theme) => ({
|
|
streamItemWrapper: {
|
|
width: "100%",
|
|
maxWidth: MAX_CONTENT_WIDTH,
|
|
alignSelf: "center",
|
|
paddingHorizontal: theme.spacing[2],
|
|
},
|
|
turnFooterRow: {
|
|
marginTop: theme.spacing[4],
|
|
},
|
|
turnFooterSlot: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
alignSelf: "flex-start",
|
|
minHeight: 24,
|
|
paddingBottom: theme.spacing[6],
|
|
},
|
|
turnFooterContent: {
|
|
height: 24,
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
justifyContent: "flex-start",
|
|
gap: theme.spacing[3],
|
|
},
|
|
workingElapsed: {
|
|
color: theme.colors.foregroundMuted,
|
|
fontSize: STREAM_METADATA_FONT_SIZE,
|
|
fontVariant: ["tabular-nums"],
|
|
},
|
|
workingLoader: {
|
|
marginLeft: -2,
|
|
},
|
|
}));
|