diff --git a/packages/app/src/components/agent-stream-render-model.ts b/packages/app/src/components/agent-stream-render-model.ts index 8ecf0fe8e..4ff510909 100644 --- a/packages/app/src/components/agent-stream-render-model.ts +++ b/packages/app/src/components/agent-stream-render-model.ts @@ -26,7 +26,7 @@ export interface StreamHistoryBoundary { export interface StreamRenderAuxiliary { pendingPermissions: ReactNode; - workingIndicator: ReactNode; + turnFooter: ReactNode; } export interface AgentStreamRenderModel { @@ -46,7 +46,7 @@ export interface BuildAgentStreamRenderModelInput { const EMPTY_STREAM_ITEMS: StreamItem[] = []; const EMPTY_AUXILIARY: StreamRenderAuxiliary = { pendingPermissions: null, - workingIndicator: null, + turnFooter: null, }; const orderedTailCache = new WeakMap>(); diff --git a/packages/app/src/components/agent-stream-turn-footer.tsx b/packages/app/src/components/agent-stream-turn-footer.tsx new file mode 100644 index 000000000..0e30c7990 --- /dev/null +++ b/packages/app/src/components/agent-stream-turn-footer.tsx @@ -0,0 +1,273 @@ +import React, { memo, useCallback, useEffect, useMemo, type ReactNode } from "react"; +import { View } from "react-native"; +import Animated, { + cancelAnimation, + Easing, + useAnimatedStyle, + useSharedValue, + withRepeat, + withTiming, +} from "react-native-reanimated"; +import { StyleSheet } from "react-native-unistyles"; +import { MAX_CONTENT_WIDTH } from "@/constants/layout"; +import { findTurnHeaderForAssistantTurn } from "@/timeline/turn-time"; +import type { StreamItem } from "@/types/stream"; +import { + getWorkingIndicatorDotStrength, + WORKING_INDICATOR_CYCLE_MS, + WORKING_INDICATOR_OFFSETS, +} from "@/utils/working-indicator"; +import { + collectAssistantTurnContentForStreamRenderStrategy, + type StreamStrategy, +} from "./agent-stream-render-strategy"; +import { AssistantTurnFooter, LiveElapsed, STREAM_METADATA_FONT_SIZE } from "./message"; + +export type TurnContentStrategy = StreamStrategy; + +export interface TurnFooterHost { + itemId: string; + items: StreamItem[]; + startIndex: number; +} + +export function resolveBottomTurnFooterHost(input: { + agentStatus: string; + history: StreamItem[]; + liveHead: StreamItem[]; + isInverted: boolean; +}): TurnFooterHost | null { + if (input.agentStatus === "running") { + return null; + } + const footerItems = input.liveHead.length > 0 ? input.liveHead : input.history; + const startIndex = input.isInverted ? 0 : footerItems.length - 1; + const item = footerItems[startIndex]; + if (!item || item.kind !== "assistant_message") { + return null; + } + return { + itemId: item.id, + items: footerItems, + startIndex, + }; +} + +export function shouldRenderCompletedTurnFooter(input: { + item: StreamItem; + belowItem: StreamItem | undefined; + agentStatus: string; + suppressTurnFooter: boolean | undefined; +}): boolean { + return ( + input.item.kind === "assistant_message" && + !input.suppressTurnFooter && + (input.belowItem?.kind === "user_message" || + (input.belowItem === undefined && input.agentStatus !== "running")) + ); +} + +export const TurnFooter = memo(function TurnFooter({ + isRunning, + inFlightTurnStartedAt, + host, + strategy, +}: { + isRunning: boolean; + inFlightTurnStartedAt: Date | null; + host: TurnFooterHost | null; + strategy: TurnContentStrategy; +}) { + if (isRunning) { + return ( + + + + ); + } + if (!host) { + return null; + } + return ( + + ); +}); + +export const CompletedTurnFooterRow = memo(function CompletedTurnFooterRow({ + strategy, + items, + startIndex, +}: { + strategy: TurnContentStrategy; + items: StreamItem[]; + startIndex: number; +}) { + return ( + + + + ); +}); + +const WorkingIndicator = memo(function WorkingIndicator({ + inFlightTurnStartedAt = null, +}: { + inFlightTurnStartedAt?: Date | null; +}) { + const progress = useSharedValue(0); + + useEffect(() => { + progress.value = 0; + progress.value = withRepeat( + withTiming(1, { + duration: WORKING_INDICATOR_CYCLE_MS, + easing: Easing.linear, + }), + -1, + false, + ); + + return () => { + cancelAnimation(progress); + progress.value = 0; + }; + }, [progress]); + + const translateDistance = -2; + const dotOneStyle = useAnimatedStyle(() => { + const strength = getWorkingIndicatorDotStrength(progress.value, WORKING_INDICATOR_OFFSETS[0]); + return { + opacity: 0.3 + strength * 0.7, + transform: [{ translateY: strength * translateDistance }], + }; + }); + + const dotTwoStyle = useAnimatedStyle(() => { + const strength = getWorkingIndicatorDotStrength(progress.value, WORKING_INDICATOR_OFFSETS[1]); + return { + opacity: 0.3 + strength * 0.7, + transform: [{ translateY: strength * translateDistance }], + }; + }); + + const dotThreeStyle = useAnimatedStyle(() => { + const strength = getWorkingIndicatorDotStrength(progress.value, WORKING_INDICATOR_OFFSETS[2]); + return { + opacity: 0.3 + strength * 0.7, + transform: [{ translateY: strength * translateDistance }], + }; + }); + + const dotOneCombinedStyle = useMemo(() => [stylesheet.workingDot, dotOneStyle], [dotOneStyle]); + const dotTwoCombinedStyle = useMemo(() => [stylesheet.workingDot, dotTwoStyle], [dotTwoStyle]); + const dotThreeCombinedStyle = useMemo( + () => [stylesheet.workingDot, dotThreeStyle], + [dotThreeStyle], + ); + + return ( + + + + + + + {inFlightTurnStartedAt ? ( + + ) : null} + + ); +}); + +function RunningTurnFooter({ inFlightTurnStartedAt }: { inFlightTurnStartedAt: Date | null }) { + return ( + + + + ); +} + +function CompletedTurnFooter({ + strategy, + items, + startIndex, +}: { + strategy: TurnContentStrategy; + items: StreamItem[]; + startIndex: number; +}) { + const getContent = useCallback( + () => + collectAssistantTurnContentForStreamRenderStrategy({ + strategy, + items, + startIndex, + }), + [strategy, items, startIndex], + ); + const header = useMemo( + () => findTurnHeaderForAssistantTurn({ strategy, items, startIndex }), + [strategy, items, startIndex], + ); + return ( + + + + ); +} + +function TurnFooterRow({ children }: { children: ReactNode }) { + const rowStyle = useMemo(() => [stylesheet.streamItemWrapper, stylesheet.turnFooterRow], []); + return {children}; +} + +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"], + }, + workingDotsRow: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[1], + transform: [{ translateY: 1 }], + }, + workingDot: { + width: 6, + height: 6, + borderRadius: 3, + backgroundColor: theme.colors.foregroundMuted, + }, +})); diff --git a/packages/app/src/components/agent-stream-view-data.test.ts b/packages/app/src/components/agent-stream-view-data.test.ts index df26365f0..dc9df12bc 100644 --- a/packages/app/src/components/agent-stream-view-data.test.ts +++ b/packages/app/src/components/agent-stream-view-data.test.ts @@ -1,20 +1,6 @@ import { describe, expect, it } from "vitest"; import type { StreamItem } from "@/types/stream"; -import type { NeighborResolver } from "./agent-stream-view-data"; -import { - getAssistantBlockSpacing, - isSameAssistantBlockGroup, - resolveInlineWorkingIndicatorItemId, -} from "./agent-stream-view-data"; - -// Minimal forward-order resolver: "below" = next item in array. -// Matches web strategy rendering order (chronological, top-to-bottom). -const forwardStrategy: NeighborResolver = { - getNeighborItem(items, index, relation) { - const neighborIndex = relation === "below" ? index + 1 : index - 1; - return items[neighborIndex]; - }, -}; +import { getAssistantBlockSpacing, isSameAssistantBlockGroup } from "./agent-stream-view-data"; function assistantBlock(params: { id: string; @@ -135,46 +121,3 @@ describe("getAssistantBlockSpacing", () => { ).toBe("compactTop"); }); }); - -describe("resolveInlineWorkingIndicatorItemId", () => { - it("returns null when the agent is not running", () => { - const head = assistantBlock({ id: "head", blockGroupId: "group-1", blockIndex: 0 }); - expect(resolveInlineWorkingIndicatorItemId("idle", [head], forwardStrategy)).toBeNull(); - }); - - it("returns the last assistant block id when running with a single head block", () => { - const head = assistantBlock({ id: "group-1:head", blockGroupId: "group-1", blockIndex: 0 }); - expect(resolveInlineWorkingIndicatorItemId("running", [head], forwardStrategy)).toBe( - "group-1:head", - ); - }); - - it("returns null when live head contains only a tool call (uses auxiliary indicator instead)", () => { - const tc = toolCallBlock("tool-1"); - expect(resolveInlineWorkingIndicatorItemId("running", [tc], forwardStrategy)).toBeNull(); - }); - - it("returns the footer assistant block when history and streaming head coexist", () => { - const historyBlock = assistantBlock({ - id: "group-1:block:0", - blockGroupId: "group-1", - blockIndex: 0, - }); - const streamingBlock = assistantBlock({ - id: "group-2:head", - blockGroupId: "group-2", - blockIndex: 0, - }); - // historyBlock is in streamItems (tail), not liveHead — liveHead holds only the streaming block - expect(resolveInlineWorkingIndicatorItemId("running", [streamingBlock], forwardStrategy)).toBe( - "group-2:head", - ); - expect( - resolveInlineWorkingIndicatorItemId( - "running", - [historyBlock, streamingBlock], - forwardStrategy, - ), - ).toBe("group-2:head"); - }); -}); diff --git a/packages/app/src/components/agent-stream-view-data.ts b/packages/app/src/components/agent-stream-view-data.ts index d77860b27..4d337debe 100644 --- a/packages/app/src/components/agent-stream-view-data.ts +++ b/packages/app/src/components/agent-stream-view-data.ts @@ -1,4 +1,5 @@ import type { StreamItem } from "@/types/stream"; +import { SPACING } from "@/styles/theme"; export function isSameAssistantBlockGroup(params: { item: StreamItem | null | undefined; @@ -28,24 +29,35 @@ export function getAssistantBlockSpacing(params: { return "default"; } -export interface NeighborResolver { - getNeighborItem( - items: StreamItem[], - index: number, - relation: "above" | "below", - ): StreamItem | undefined; -} +const isUserMessageItem = (item?: StreamItem | null) => item?.kind === "user_message"; +const isToolSequenceItem = (item?: StreamItem | null) => + item?.kind === "tool_call" || item?.kind === "thought" || item?.kind === "todo_list"; -// null → auxiliary working indicator; non-null → inline footer on that block. -export function resolveInlineWorkingIndicatorItemId( - status: string, - liveHeadItems: StreamItem[], - strategy: NeighborResolver, -): string | null { - if (status !== "running") return null; - const footerItem = liveHeadItems.find((item, index, items) => { - if (item.kind !== "assistant_message") return false; - return strategy.getNeighborItem(items, index, "below") === undefined; - }); - return footerItem?.id ?? null; +export function getGapBetweenStreamItems( + item: StreamItem | null, + belowItem: StreamItem | null, +): number { + if (!item || !belowItem) { + return 0; + } + + if (isUserMessageItem(item) && isUserMessageItem(belowItem)) { + return SPACING[1]; + } + if (isToolSequenceItem(item) && isToolSequenceItem(belowItem)) { + return 0; + } + if (item.kind === "user_message" && isToolSequenceItem(belowItem)) { + return SPACING[4]; + } + if (item.kind === "assistant_message" && isToolSequenceItem(belowItem)) { + return SPACING[1]; + } + if (isToolSequenceItem(item) && belowItem.kind === "assistant_message") { + return SPACING[1]; + } + if (isSameAssistantBlockGroup({ item, other: belowItem })) { + return SPACING[3]; + } + return SPACING[4]; } diff --git a/packages/app/src/components/agent-stream-view.tsx b/packages/app/src/components/agent-stream-view.tsx index 7fd7e2ed4..3fe2fae61 100644 --- a/packages/app/src/components/agent-stream-view.tsx +++ b/packages/app/src/components/agent-stream-view.tsx @@ -17,34 +17,24 @@ import { Platform, ActivityIndicator, type PressableStateCallbackType, + type StyleProp, + type ViewStyle, } from "react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { useIsCompactFormFactor } from "@/constants/layout"; import { useMutation } from "@tanstack/react-query"; -import Animated, { - FadeIn, - FadeOut, - cancelAnimation, - Easing, - useAnimatedStyle, - useSharedValue, - withRepeat, - withTiming, -} from "react-native-reanimated"; +import Animated, { FadeIn, FadeOut } from "react-native-reanimated"; import { Check, ChevronDown, X } from "lucide-react-native"; import { usePanelStore } from "@/stores/panel-store"; import { AssistantMessage, - AssistantTurnFooter, SpeakMessage, UserMessage, ActivityLog, ToolCall, TodoListCard, CompactionMarker, - LiveElapsed, MessageOuterSpacingProvider, - STREAM_METADATA_FONT_SIZE, type InlinePathTarget, } from "./message"; import { PlanCard } from "./plan-card"; @@ -65,18 +55,21 @@ import { QuestionFormCard } from "./question-form-card"; import { ToolCallSheetProvider } from "./tool-call-sheet"; import { buildAgentStreamRenderModel, - collectAssistantTurnContentForStreamRenderStrategy, getStreamNeighborItem, resolveStreamRenderStrategy, type AgentStreamRenderModel, type StreamSegmentRenderers, type StreamViewportHandle, } from "./agent-stream-render-strategy"; +import { getAssistantBlockSpacing, getGapBetweenStreamItems } from "./agent-stream-view-data"; import { - getAssistantBlockSpacing, - isSameAssistantBlockGroup, - resolveInlineWorkingIndicatorItemId, -} from "./agent-stream-view-data"; + CompletedTurnFooterRow, + resolveBottomTurnFooterHost, + shouldRenderCompletedTurnFooter, + TurnFooter, + type TurnContentStrategy, + type TurnFooterHost, +} from "./agent-stream-turn-footer"; import { type BottomAnchorLocalRequest, type BottomAnchorRouteRequest, @@ -86,22 +79,153 @@ import { normalizeInlinePathTarget } from "@/utils/inline-path"; import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution"; import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation"; import { useStableEvent } from "@/hooks/use-stable-event"; -import { - getWorkingIndicatorDotStrength, - WORKING_INDICATOR_CYCLE_MS, - WORKING_INDICATOR_OFFSETS, -} from "@/utils/working-indicator"; -import { findInFlightTurnStartedAt, findTurnHeaderForAssistantTurn } from "@/timeline/turn-time"; +import { findInFlightTurnStartedAt } from "@/timeline/turn-time"; import { isWeb } from "@/constants/platform"; -import { SPACING, type Theme } from "@/styles/theme"; - -const isUserMessageItem = (item?: StreamItem) => item?.kind === "user_message"; -const isToolSequenceItem = (item?: StreamItem) => - item?.kind === "tool_call" || item?.kind === "thought" || item?.kind === "todo_list"; +import type { Theme } from "@/styles/theme"; interface StreamItemBoundarySeams { aboveItem?: StreamItem | null; belowItem?: StreamItem | null; + suppressTurnFooter?: boolean; +} + +function renderLiveAuxiliaryNode(input: { + pendingPermissions: ReactNode; + turnFooter: ReactNode; +}): ReactNode { + if (!input.pendingPermissions && !input.turnFooter) { + return null; + } + return ( + <> + {input.turnFooter} + {input.pendingPermissions ? ( + + {input.pendingPermissions} + + ) : null} + + ); +} + +function renderPendingPermissionsNode(input: { + pendingPermissions: PendingPermission[]; + client: DaemonClient | null; +}): ReactNode { + if (input.pendingPermissions.length === 0) { + return null; + } + return ( + + {input.pendingPermissions.map((permission) => ( + + ))} + + ); +} + +function renderStreamItemWithTurnFooter(input: { + content: ReactNode; + item: StreamItem; + nextItem: StreamItem | undefined; + items: StreamItem[]; + index: number; + agentStatus: string; + suppressTurnFooter: boolean | undefined; + strategy: TurnContentStrategy; +}): ReactNode { + if (!input.content) { + return null; + } + + const showCompletedFooter = shouldRenderCompletedTurnFooter({ + item: input.item, + belowItem: input.nextItem, + agentStatus: input.agentStatus, + suppressTurnFooter: input.suppressTurnFooter, + }); + const gapBelow = showCompletedFooter + ? 0 + : getGapBetweenStreamItems(input.item, input.nextItem ?? null); + + return ( + <> + {input.content} + {showCompletedFooter ? ( + + ) : null} + + ); +} + +function renderListEmptyComponent(input: { + renderModel: AgentStreamRenderModel; + emptyStateStyle: StyleProp; +}): ReactNode { + if ( + input.renderModel.boundary.hasVirtualizedHistory || + input.renderModel.boundary.hasMountedHistory || + input.renderModel.boundary.hasLiveHead || + input.renderModel.auxiliary.pendingPermissions || + input.renderModel.auxiliary.turnFooter + ) { + return null; + } + + return ( + + Start chatting with this agent... + + ); +} + +function renderHistoryStreamItem(input: { + item: StreamItem; + historyIndexById: Map; + historyItems: StreamItem[]; + lastHistoryItem: StreamItem | null; + firstLiveHeadItem: StreamItem | null; + bottomTurnFooterHost: TurnFooterHost | null; + renderStreamItem: ( + item: StreamItem, + index: number, + items: StreamItem[], + seams?: StreamItemBoundarySeams, + ) => ReactNode; +}): ReactNode { + const historyIndex = input.historyIndexById.get(input.item.id); + if (historyIndex === undefined) { + return null; + } + const seamBelowItem = + input.item.id === input.lastHistoryItem?.id ? input.firstLiveHeadItem : null; + return input.renderStreamItem(input.item, historyIndex, input.historyItems, { + belowItem: seamBelowItem, + suppressTurnFooter: input.item.id === input.bottomTurnFooterHost?.itemId, + }); +} + +function renderLiveHeadStreamItem(input: { + item: StreamItem; + index: number; + items: StreamItem[]; + lastHistoryItem: StreamItem | null; + bottomTurnFooterHost: TurnFooterHost | null; + renderStreamItem: ( + item: StreamItem, + index: number, + items: StreamItem[], + seams?: StreamItemBoundarySeams, + ) => ReactNode; +}): ReactNode { + return input.renderStreamItem(input.item, input.index, input.items, { + aboveItem: input.index === 0 ? input.lastHistoryItem : null, + suppressTurnFooter: input.item.id === input.bottomTurnFooterHost?.itemId, + }); } export interface AgentStreamViewHandle { @@ -268,15 +392,6 @@ const AgentStreamViewComponent = forwardRef - resolveInlineWorkingIndicatorItemId( - agent.status, - baseRenderModel.segments.liveHead, - streamRenderStrategy, - ), - [agent.status, baseRenderModel.segments.liveHead, streamRenderStrategy], - ); const inFlightTurnStartedAt = useMemo( () => findInFlightTurnStartedAt({ @@ -303,39 +418,6 @@ const AgentStreamViewComponent = forwardRef { - if (!item || !belowItem) { - return 0; - } - - if (isUserMessageItem(item) && isUserMessageItem(belowItem)) { - return tightGap; - } - if (isToolSequenceItem(item) && isToolSequenceItem(belowItem)) { - return 0; - } - if (item.kind === "user_message" && isToolSequenceItem(belowItem)) { - return looseGap; - } - if (item.kind === "assistant_message" && isToolSequenceItem(belowItem)) { - return tightGap; - } - if (isToolSequenceItem(item) && belowItem.kind === "assistant_message") { - return tightGap; - } - if (isSameAssistantBlockGroup({ item, other: belowItem })) { - return assistantBlockGap; - } - return looseGap; - }, - [assistantBlockGap, looseGap, tightGap], - ); - const setInlineDetailsExpanded = useCallback( (itemId: string, expanded: boolean) => { if (!streamRenderStrategy.shouldDisableParentScrollOnInlineDetailsExpansion()) { @@ -563,7 +645,7 @@ const AgentStreamViewComponent = forwardRef { + return resolveBottomTurnFooterHost({ + agentStatus: agent.status, + history: baseRenderModel.history, + liveHead: baseRenderModel.segments.liveHead, + isInverted: streamRenderStrategy.getFlatListInverted(), + }); + }, [ + agent.status, + baseRenderModel.history, + baseRenderModel.segments.liveHead, + streamRenderStrategy, + ]); + const renderStreamItem = useCallback( ( item: StreamItem, @@ -582,47 +678,24 @@ const AgentStreamViewComponent = forwardRef { const content = renderStreamItemContent(item, index, items, seams); - if (!content) { - return null; - } - const nextItem = getStreamNeighborItem({ strategy: streamRenderStrategy, items, index, relation: "below", }); - const gapBelow = getGapBetween(item, nextItem ?? null); - const isEndOfAssistantTurn = - item.kind === "assistant_message" && - (nextItem?.kind === "user_message" || - (nextItem === undefined && agent.status !== "running")); - const isRunningAssistantTurnFooter = - item.kind === "assistant_message" && item.id === inlineWorkingIndicatorItemId; - let footer: ReactNode = null; - if (isRunningAssistantTurnFooter) { - footer = ; - } else if (isEndOfAssistantTurn) { - footer = ( - - ); - } - - return ( - - {content} - {footer} - - ); + return renderStreamItemWithTurnFooter({ + content, + item, + nextItem, + items, + index, + agentStatus: agent.status, + suppressTurnFooter: seams.suppressTurnFooter, + strategy: streamRenderStrategy, + }); }, - [ - getGapBetween, - renderStreamItemContent, - agent.status, - streamRenderStrategy, - inlineWorkingIndicatorItemId, - inFlightTurnStartedAt, - ], + [renderStreamItemContent, agent.status, streamRenderStrategy], ); const pendingPermissionItems = useMemo( @@ -630,63 +703,49 @@ const AgentStreamViewComponent = forwardRef - pendingPermissionItems.length > 0 ? ( - - {pendingPermissionItems.map((permission) => ( - - ))} - - ) : null, + renderPendingPermissionsNode({ + pendingPermissions: pendingPermissionItems, + client, + }), [client, pendingPermissionItems], ); - const workingIndicatorNode = useMemo( + const turnFooterNode = useMemo( () => - showAuxiliaryWorkingIndicator ? ( - - - + showRunningTurnFooter || bottomTurnFooterHost ? ( + ) : null, - [showAuxiliaryWorkingIndicator, inFlightTurnStartedAt], + [showRunningTurnFooter, inFlightTurnStartedAt, bottomTurnFooterHost, streamRenderStrategy], ); const renderModel = useMemo(() => { return { ...baseRenderModel, boundary: { ...baseRenderModel.boundary, - historyToHeadGap: getGapBetween( + historyToHeadGap: getGapBetweenStreamItems( baseRenderModel.history.at(-1) ?? null, baseRenderModel.segments.liveHead[0] ?? null, ), }, auxiliary: { pendingPermissions: pendingPermissionsNode, - workingIndicator: workingIndicatorNode, + turnFooter: turnFooterNode, }, }; - }, [baseRenderModel, getGapBetween, pendingPermissionsNode, workingIndicatorNode]); + }, [baseRenderModel, pendingPermissionsNode, turnFooterNode]); const emptyStateStyle = useMemo(() => [stylesheet.emptyState, stylesheet.contentWrapper], []); - const listEmptyComponent = useMemo(() => { - if ( - renderModel.boundary.hasVirtualizedHistory || - renderModel.boundary.hasMountedHistory || - renderModel.boundary.hasLiveHead || - renderModel.auxiliary.pendingPermissions || - renderModel.auxiliary.workingIndicator - ) { - return null; - } - - return ( - - Start chatting with this agent... - - ); - }, [renderModel, emptyStateStyle]); + const listEmptyComponent = useMemo( + () => renderListEmptyComponent({ renderModel, emptyStateStyle }), + [renderModel, emptyStateStyle], + ); const historyItems = renderModel.history; const _liveHeadItems = renderModel.segments.liveHead; @@ -703,17 +762,24 @@ const AgentStreamViewComponent = forwardRef { - const historyIndex = historyIndexById.get(item.id); - if (historyIndex === undefined) { - return null; - } - const seamBelowItem = item.id === lastHistoryItem?.id ? firstLiveHeadItem : null; - return renderStreamItem(item, historyIndex, historyItems, { - belowItem: seamBelowItem, - }); - }, - [firstLiveHeadItem, historyIndexById, historyItems, lastHistoryItem?.id, renderStreamItem], + (item: StreamItem) => + renderHistoryStreamItem({ + item, + historyIndexById, + historyItems, + lastHistoryItem, + firstLiveHeadItem, + bottomTurnFooterHost, + renderStreamItem, + }), + [ + bottomTurnFooterHost, + firstLiveHeadItem, + historyIndexById, + historyItems, + lastHistoryItem, + renderStreamItem, + ], ); const renderHistoryVirtualizedRow = useCallback< @@ -725,32 +791,22 @@ const AgentStreamViewComponent = forwardRef( (item, index, items) => - renderStreamItem(item, index, items, { - aboveItem: index === 0 ? lastHistoryItem : null, + renderLiveHeadStreamItem({ + item, + index, + items, + lastHistoryItem, + bottomTurnFooterHost, + renderStreamItem, }), - [lastHistoryItem, renderStreamItem], + [bottomTurnFooterHost, lastHistoryItem, renderStreamItem], ); - const liveAuxiliaryHeaderStyle = useMemo(() => { - let headerPadding: { paddingBottom: number } | { paddingTop: number } | null; - if (!boundary.hasLiveHead) headerPadding = null; - else if (streamRenderStrategy.getFlatListInverted()) - headerPadding = { paddingBottom: looseGap }; - else headerPadding = { paddingTop: looseGap }; - return [stylesheet.listHeaderContent, headerPadding]; - }, [boundary.hasLiveHead, streamRenderStrategy, looseGap]); const renderLiveAuxiliary = useCallback(() => { - if (!auxiliary.pendingPermissions && !auxiliary.workingIndicator) { - return null; - } - return ( - - - {auxiliary.pendingPermissions} - {auxiliary.workingIndicator} - - - ); - }, [auxiliary.pendingPermissions, auxiliary.workingIndicator, liveAuxiliaryHeaderStyle]); + return renderLiveAuxiliaryNode({ + pendingPermissions: auxiliary.pendingPermissions, + turnFooter: auxiliary.turnFooter, + }); + }, [auxiliary.pendingPermissions, auxiliary.turnFooter]); const renderers = useMemo( () => ({ @@ -822,135 +878,6 @@ const AgentStreamViewComponent = forwardRef { - progress.value = 0; - progress.value = withRepeat( - withTiming(1, { - duration: WORKING_INDICATOR_CYCLE_MS, - easing: Easing.linear, - }), - -1, - false, - ); - - return () => { - cancelAnimation(progress); - progress.value = 0; - }; - }, [progress]); - - const translateDistance = -2; - const dotOneStyle = useAnimatedStyle(() => { - const strength = getWorkingIndicatorDotStrength(progress.value, WORKING_INDICATOR_OFFSETS[0]); - return { - opacity: 0.3 + strength * 0.7, - transform: [{ translateY: strength * translateDistance }], - }; - }); - - const dotTwoStyle = useAnimatedStyle(() => { - const strength = getWorkingIndicatorDotStrength(progress.value, WORKING_INDICATOR_OFFSETS[1]); - return { - opacity: 0.3 + strength * 0.7, - transform: [{ translateY: strength * translateDistance }], - }; - }); - - const dotThreeStyle = useAnimatedStyle(() => { - const strength = getWorkingIndicatorDotStrength(progress.value, WORKING_INDICATOR_OFFSETS[2]); - return { - opacity: 0.3 + strength * 0.7, - transform: [{ translateY: strength * translateDistance }], - }; - }); - - const dotOneCombinedStyle = useMemo(() => [stylesheet.workingDot, dotOneStyle], [dotOneStyle]); - const dotTwoCombinedStyle = useMemo(() => [stylesheet.workingDot, dotTwoStyle], [dotTwoStyle]); - const dotThreeCombinedStyle = useMemo( - () => [stylesheet.workingDot, dotThreeStyle], - [dotThreeStyle], - ); - - const containerStyle = - variant === "inline" - ? stylesheet.inlineWorkingIndicatorFrame - : stylesheet.workingIndicatorBubble; - - return ( - - - - - - - {inFlightTurnStartedAt ? ( - - ) : null} - - ); -} - -function InlineWorkingIndicatorSlot({ - inFlightTurnStartedAt, -}: { - inFlightTurnStartedAt: Date | null; -}) { - return ( - - - - ); -} - -// Permission Request Card Component -type TurnContentStrategy = Parameters< - typeof collectAssistantTurnContentForStreamRenderStrategy ->[0]["strategy"]; - -interface TurnCopyButtonSlotProps { - strategy: TurnContentStrategy; - items: StreamItem[]; - startIndex: number; -} - -function TurnCopyButtonSlot({ strategy, items, startIndex }: TurnCopyButtonSlotProps) { - const getContent = useCallback( - () => - collectAssistantTurnContentForStreamRenderStrategy({ - strategy, - items, - startIndex, - }), - [strategy, items, startIndex], - ); - const header = useMemo( - () => findTurnHeaderForAssistantTurn({ strategy, items, startIndex }), - [strategy, items, startIndex], - ); - return ( - - - - ); -} - interface ToolCallSlotProps extends Omit< ComponentProps, "onInlineDetailsExpandedChange" @@ -1282,57 +1209,6 @@ const stylesheet = StyleSheet.create((theme) => ({ listHeaderContent: { gap: theme.spacing[3], }, - bottomBarWrapper: { - flexDirection: "row", - alignItems: "center", - justifyContent: "flex-start", - marginTop: theme.spacing[4], - paddingTop: theme.spacing[3], - paddingBottom: theme.spacing[2], - gap: theme.spacing[2], - }, - turnFooterSlot: { - flexDirection: "row", - alignItems: "center", - alignSelf: "flex-start", - minHeight: 24, - marginTop: theme.spacing[1], - paddingBottom: theme.spacing[6], - }, - inlineWorkingIndicatorFrame: { - 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"], - }, - workingIndicatorBubble: { - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[3], - paddingVertical: theme.spacing[1], - borderRadius: theme.borderRadius.full, - backgroundColor: "transparent", - borderWidth: 0, - alignSelf: "flex-start", - }, - workingDotsRow: { - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[1], - transform: [{ translateY: 1 }], - }, - workingDot: { - width: 6, - height: 6, - borderRadius: 3, - backgroundColor: theme.colors.foregroundMuted, - }, syncingIndicator: { flexDirection: "row", alignItems: "center", diff --git a/packages/app/src/utils/time.test.ts b/packages/app/src/utils/time.test.ts index fa242e9ae..f72900a1c 100644 --- a/packages/app/src/utils/time.test.ts +++ b/packages/app/src/utils/time.test.ts @@ -2,13 +2,10 @@ import { describe, it, expect } from "vitest"; import { formatDuration, formatMessageTimestamp } from "./time"; describe("formatDuration", () => { - it("renders 0-10s with one decimal", () => { - expect(formatDuration(0)).toBe("0.0s"); - expect(formatDuration(5_600)).toBe("5.6s"); - expect(formatDuration(9_900)).toBe("9.9s"); - }); - - it("renders 10s-60s as whole seconds", () => { + it("renders sub-minute durations as whole seconds", () => { + expect(formatDuration(0)).toBe("0s"); + expect(formatDuration(5_600)).toBe("5s"); + expect(formatDuration(9_900)).toBe("9s"); expect(formatDuration(10_400)).toBe("10s"); expect(formatDuration(12_340)).toBe("12s"); expect(formatDuration(47_000)).toBe("47s"); @@ -26,8 +23,8 @@ describe("formatDuration", () => { }); it("guards against negative and NaN", () => { - expect(formatDuration(-1)).toBe("0.0s"); - expect(formatDuration(Number.NaN)).toBe("0.0s"); + expect(formatDuration(-1)).toBe("0s"); + expect(formatDuration(Number.NaN)).toBe("0s"); }); }); diff --git a/packages/app/src/utils/time.ts b/packages/app/src/utils/time.ts index f750f9acf..cac9a884c 100644 --- a/packages/app/src/utils/time.ts +++ b/packages/app/src/utils/time.ts @@ -92,19 +92,15 @@ export function formatMessageTimestamp(date: Date, now: Date = new Date()): stri /** * Format a duration as a compact human-readable string. - * - 0-10s: one decimal ("3.4s") - * - 10s-60s: whole seconds ("47s") + * - 0-60s: whole seconds ("47s") * - Minutes/hours: integers only ("2m 12s", "1h 5m") */ export function formatDuration(durationMs: number): string { if (!Number.isFinite(durationMs) || durationMs < 0) { - return "0.0s"; + return "0s"; } const totalSeconds = durationMs / 1000; - if (totalSeconds < 10) { - return `${totalSeconds.toFixed(1)}s`; - } if (totalSeconds < 60) { return `${Math.floor(totalSeconds)}s`; }