* 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
252 lines
7.0 KiB
TypeScript
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,
|
|
},
|
|
}));
|