Files
paseo/packages/app/src/agent-stream/turn-footer.tsx
T
Mohamed BoudraandGitHub ccb668b3b4 Improve sidebar workspace readability and host identity (#2790)
* fix(app): align turn footer actions

Keep running-turn actions in the same position as completed-turn actions and use the shared small icon size for both copy and fork controls.

* feat(app): improve sidebar workspace identity

Make dense sidebar workspace rows easier to scan and let each device control how connected hosts are identified.

* fix(app): keep host badges readable

Use a compact display menu and keep identity color on the host icon so translated controls fit and badge text retains standard contrast.

* fix(app): announce selected sidebar badge mode

* fix(app): retain icon-only host names for screen readers

* fix(app): name hoisted workspace rows

* fix(app): retain host identity in workspace labels

* fix(app): retain workspace status in row labels

* fix(app): defer host appearance until identity resolves

* fix(app): serialize host appearance writes

* fix(app): serialize host registry writes

* fix(app): bound sidebar host badge labels

* test(app): match workspace status labels

* fix(app): consolidate sidebar identity behavior

* test(app): assert status grouping behavior

* fix(app): keep sidebar row actions accessible

* fix(app): stabilize optimistic message layout
2026-08-02 16:58:20 +02:00

252 lines
7.0 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 { AssistantForkMenu } from "@/components/assistant-fork-menu";
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;
/**
* Fork handler for the turn that is still streaming. It deliberately takes no
* boundary: `selectForkContextRows` projects the entire timeline when neither
* boundary field is given, which is what captures the partially streamed text
* the user is watching. Pinning a boundary here would silently drop the live
* response — the opposite of what a fork button next to the loader promises.
*
* Kept separate from `AssistantTurnForkHandler` (whose `boundary` stays
* required) so the compiler keeps enforcing that completed turns always pin one.
*/
export type InFlightTurnForkHandler = (target: AssistantForkTarget) => Promise<void> | void;
export const TurnFooter = memo(function TurnFooter({
isRunning,
inFlightTurnStartedAt,
host,
strategy,
supportsTimelineCursor,
onForkAssistantTurn,
onForkInFlightTurn,
}: {
isRunning: boolean;
inFlightTurnStartedAt: Date | null;
host: TurnFooterHost | null;
strategy: TurnContentStrategy;
supportsTimelineCursor: boolean;
onForkAssistantTurn?: AssistantTurnForkHandler;
onForkInFlightTurn?: InFlightTurnForkHandler;
}) {
if (isRunning) {
return (
<TurnFooterRow>
<RunningTurnFooter
inFlightTurnStartedAt={inFlightTurnStartedAt}
onForkInFlightTurn={onForkInFlightTurn}
/>
</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,
onForkInFlightTurn,
}: {
inFlightTurnStartedAt?: Date | null;
onForkInFlightTurn?: InFlightTurnForkHandler;
}) {
const active = useRetainedPanelActive();
return (
<View style={stylesheet.turnFooterContent}>
<View style={stylesheet.workingLoader}>
<ThemedSyncedLoader size={14} uniProps={workingIndicatorColorMapping} />
</View>
{/* Match the completed-turn footer: actions precede timing metadata. */}
{onForkInFlightTurn ? <AssistantForkMenu onFork={onForkInFlightTurn} /> : null}
{inFlightTurnStartedAt ? (
<LiveElapsed
startedAt={inFlightTurnStartedAt}
active={active}
style={stylesheet.workingElapsed}
testID="turn-working-elapsed"
/>
) : null}
</View>
);
});
function RunningTurnFooter({
inFlightTurnStartedAt,
onForkInFlightTurn,
}: {
inFlightTurnStartedAt: Date | null;
onForkInFlightTurn?: InFlightTurnForkHandler;
}) {
return (
<View style={stylesheet.turnFooterSlot} testID="turn-working-indicator">
<WorkingIndicator
inFlightTurnStartedAt={inFlightTurnStartedAt}
onForkInFlightTurn={onForkInFlightTurn}
/>
</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,
},
}));