refactor: simplify tasks display and improve spacing in stream view
This commit is contained in:
@@ -54,7 +54,7 @@ import { isPerfLoggingEnabled, measurePayload, perfLog } from "@/utils/perf";
|
|||||||
|
|
||||||
const isUserMessageItem = (item?: StreamItem) => item?.kind === "user_message";
|
const isUserMessageItem = (item?: StreamItem) => item?.kind === "user_message";
|
||||||
const isToolSequenceItem = (item?: StreamItem) =>
|
const isToolSequenceItem = (item?: StreamItem) =>
|
||||||
item?.kind === "tool_call" || item?.kind === "thought";
|
item?.kind === "tool_call" || item?.kind === "thought" || item?.kind === "todo_list";
|
||||||
const AGENT_STREAM_LOG_TAG = "[AgentStreamView]";
|
const AGENT_STREAM_LOG_TAG = "[AgentStreamView]";
|
||||||
const STREAM_ITEM_LOG_MIN_COUNT = 200;
|
const STREAM_ITEM_LOG_MIN_COUNT = 200;
|
||||||
const STREAM_ITEM_LOG_DELTA_THRESHOLD = 50;
|
const STREAM_ITEM_LOG_DELTA_THRESHOLD = 50;
|
||||||
@@ -232,6 +232,16 @@ export function AgentStreamView({
|
|||||||
return tightGap;
|
return tightGap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Keep tool sequences visually connected to the preceding user message / tasks.
|
||||||
|
if (
|
||||||
|
isToolSequenceItem(item) &&
|
||||||
|
(aboveItem.kind === "user_message" ||
|
||||||
|
aboveItem.kind === "assistant_message" ||
|
||||||
|
aboveItem.kind === "todo_list")
|
||||||
|
) {
|
||||||
|
return tightGap;
|
||||||
|
}
|
||||||
|
|
||||||
// Different types get loose gap (16px)
|
// Different types get loose gap (16px)
|
||||||
return looseGap;
|
return looseGap;
|
||||||
},
|
},
|
||||||
@@ -328,8 +338,6 @@ export function AgentStreamView({
|
|||||||
case "todo_list":
|
case "todo_list":
|
||||||
return (
|
return (
|
||||||
<TodoListCard
|
<TodoListCard
|
||||||
provider={item.provider}
|
|
||||||
timestamp={item.timestamp.getTime()}
|
|
||||||
items={item.items}
|
items={item.items}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -368,6 +376,8 @@ export function AgentStreamView({
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const gapAbove = getGapAbove(item, index);
|
||||||
|
|
||||||
// Check if this is the end of a turn (before a user message or end of stream when not running)
|
// Check if this is the end of a turn (before a user message or end of stream when not running)
|
||||||
// In inverted list: index-1 is the next item (newer in time)
|
// In inverted list: index-1 is the next item (newer in time)
|
||||||
const nextItem = flatListData[index - 1];
|
const nextItem = flatListData[index - 1];
|
||||||
@@ -379,7 +389,7 @@ export function AgentStreamView({
|
|||||||
const getContent = () => collectTurnContent(index);
|
const getContent = () => collectTurnContent(index);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[stylesheet.streamItemWrapper]}>
|
<View style={[stylesheet.streamItemWrapper, { marginBottom: gapAbove }]}>
|
||||||
{content}
|
{content}
|
||||||
{isEndOfTurn ? <TurnCopyButton getContent={getContent} /> : null}
|
{isEndOfTurn ? <TurnCopyButton getContent={getContent} /> : null}
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -17,8 +17,6 @@ import {
|
|||||||
useContext,
|
useContext,
|
||||||
} from "react";
|
} from "react";
|
||||||
import type { ReactNode, ComponentType } from "react";
|
import type { ReactNode, ComponentType } from "react";
|
||||||
import type { AgentProvider } from "@server/server/agent/agent-sdk-types";
|
|
||||||
import { getAgentProviderDefinition } from "@server/server/agent/provider-manifest";
|
|
||||||
import Markdown, { MarkdownIt } from "react-native-markdown-display";
|
import Markdown, { MarkdownIt } from "react-native-markdown-display";
|
||||||
import * as Linking from "expo-linking";
|
import * as Linking from "expo-linking";
|
||||||
import {
|
import {
|
||||||
@@ -31,6 +29,7 @@ import {
|
|||||||
ChevronDown,
|
ChevronDown,
|
||||||
Loader2,
|
Loader2,
|
||||||
Check,
|
Check,
|
||||||
|
CheckSquare,
|
||||||
X,
|
X,
|
||||||
Wrench,
|
Wrench,
|
||||||
Pencil,
|
Pencil,
|
||||||
@@ -871,101 +870,38 @@ export const ActivityLog = memo(function ActivityLog({
|
|||||||
});
|
});
|
||||||
|
|
||||||
interface TodoListCardProps {
|
interface TodoListCardProps {
|
||||||
provider: AgentProvider;
|
|
||||||
timestamp: number;
|
|
||||||
items: TodoEntry[];
|
items: TodoEntry[];
|
||||||
disableOuterSpacing?: boolean;
|
disableOuterSpacing?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatTasksTimestamp(timestamp: number): string {
|
|
||||||
try {
|
|
||||||
return new Intl.DateTimeFormat("en-US", {
|
|
||||||
hour: "numeric",
|
|
||||||
minute: "2-digit",
|
|
||||||
second: "2-digit",
|
|
||||||
}).format(new Date(timestamp));
|
|
||||||
} catch {
|
|
||||||
return new Date(timestamp).toLocaleTimeString();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const todoListCardStylesheet = StyleSheet.create((theme) => ({
|
const todoListCardStylesheet = StyleSheet.create((theme) => ({
|
||||||
container: {
|
|
||||||
marginHorizontal: theme.spacing[2],
|
|
||||||
},
|
|
||||||
containerSpacing: {
|
|
||||||
marginBottom: theme.spacing[2],
|
|
||||||
},
|
|
||||||
card: {
|
|
||||||
backgroundColor: theme.colors.surface2,
|
|
||||||
borderRadius: theme.borderRadius.lg,
|
|
||||||
borderWidth: theme.borderWidth[1],
|
|
||||||
borderColor: theme.colors.border,
|
|
||||||
padding: theme.spacing[3],
|
|
||||||
},
|
|
||||||
header: {
|
|
||||||
flexDirection: "row",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
marginBottom: theme.spacing[2],
|
|
||||||
},
|
|
||||||
headerMeta: {
|
|
||||||
flexDirection: "column",
|
|
||||||
gap: theme.spacing[0],
|
|
||||||
},
|
|
||||||
title: {
|
|
||||||
color: theme.colors.foreground,
|
|
||||||
fontSize: theme.fontSize.sm,
|
|
||||||
fontWeight: theme.fontWeight.semibold,
|
|
||||||
textTransform: "uppercase",
|
|
||||||
letterSpacing: 0.6,
|
|
||||||
},
|
|
||||||
timestamp: {
|
|
||||||
color: theme.colors.foregroundMuted,
|
|
||||||
fontSize: theme.fontSize.xs,
|
|
||||||
},
|
|
||||||
providerBadge: {
|
|
||||||
backgroundColor: "rgba(59, 130, 246, 0.15)",
|
|
||||||
borderRadius: theme.borderRadius.full,
|
|
||||||
paddingHorizontal: theme.spacing[2],
|
|
||||||
paddingVertical: theme.spacing[1],
|
|
||||||
},
|
|
||||||
providerText: {
|
|
||||||
color: "#93c5fd",
|
|
||||||
fontSize: theme.fontSize.xs,
|
|
||||||
fontWeight: theme.fontWeight.semibold,
|
|
||||||
},
|
|
||||||
progressText: {
|
|
||||||
color: theme.colors.foregroundMuted,
|
|
||||||
fontSize: theme.fontSize.xs,
|
|
||||||
marginBottom: theme.spacing[2],
|
|
||||||
},
|
|
||||||
list: {
|
list: {
|
||||||
gap: theme.spacing[2],
|
gap: theme.spacing[1],
|
||||||
},
|
},
|
||||||
itemRow: {
|
itemRow: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
gap: theme.spacing[2],
|
gap: theme.spacing[2],
|
||||||
},
|
},
|
||||||
checkbox: {
|
radioOuter: {
|
||||||
width: 24,
|
width: 16,
|
||||||
height: 24,
|
height: 16,
|
||||||
borderRadius: theme.borderRadius.base,
|
borderRadius: 8,
|
||||||
borderWidth: theme.borderWidth[1],
|
borderWidth: theme.borderWidth[1],
|
||||||
borderColor: theme.colors.border,
|
borderColor: theme.colors.foregroundMuted,
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
backgroundColor: "transparent",
|
|
||||||
},
|
},
|
||||||
checkboxCompleted: {
|
radioInner: {
|
||||||
backgroundColor: theme.colors.primary,
|
width: 8,
|
||||||
borderColor: theme.colors.primary,
|
height: 8,
|
||||||
|
borderRadius: 4,
|
||||||
|
backgroundColor: theme.colors.foregroundMuted,
|
||||||
},
|
},
|
||||||
itemText: {
|
itemText: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
color: theme.colors.foreground,
|
color: theme.colors.foreground,
|
||||||
fontSize: theme.fontSize.sm,
|
fontSize: theme.fontSize.base,
|
||||||
},
|
},
|
||||||
itemTextCompleted: {
|
itemTextCompleted: {
|
||||||
color: theme.colors.foregroundMuted,
|
color: theme.colors.foregroundMuted,
|
||||||
@@ -973,95 +909,56 @@ const todoListCardStylesheet = StyleSheet.create((theme) => ({
|
|||||||
},
|
},
|
||||||
emptyText: {
|
emptyText: {
|
||||||
color: theme.colors.foregroundMuted,
|
color: theme.colors.foregroundMuted,
|
||||||
fontSize: theme.fontSize.sm,
|
fontSize: theme.fontSize.base,
|
||||||
fontStyle: "italic",
|
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const TodoListCard = memo(function TodoListCard({
|
export const TodoListCard = memo(function TodoListCard({
|
||||||
provider,
|
|
||||||
timestamp,
|
|
||||||
items,
|
items,
|
||||||
disableOuterSpacing,
|
disableOuterSpacing,
|
||||||
}: TodoListCardProps) {
|
}: TodoListCardProps) {
|
||||||
const resolvedDisableOuterSpacing =
|
const { theme: unistylesTheme } = useUnistyles();
|
||||||
useDisableOuterSpacing(disableOuterSpacing);
|
const [isExpanded, setIsExpanded] = useState(true);
|
||||||
const providerLabel = useMemo(() => {
|
|
||||||
const definition = getAgentProviderDefinition(provider);
|
|
||||||
return definition?.label ?? provider;
|
|
||||||
}, [provider]);
|
|
||||||
|
|
||||||
const completedCount = useMemo(
|
const handleToggle = useCallback(() => {
|
||||||
() => items.filter((item) => item.completed).length,
|
setIsExpanded((prev) => !prev);
|
||||||
[items]
|
}, []);
|
||||||
);
|
|
||||||
|
|
||||||
const timestampLabel = useMemo(
|
const renderDetails = useCallback(() => {
|
||||||
() => formatTasksTimestamp(timestamp),
|
return (
|
||||||
[timestamp]
|
<View style={todoListCardStylesheet.list}>
|
||||||
);
|
{items.length === 0 ? (
|
||||||
|
<Text style={todoListCardStylesheet.emptyText}>No tasks yet.</Text>
|
||||||
const iconColor = theme.colors.surface0;
|
) : (
|
||||||
|
items.map((item, idx) => (
|
||||||
|
<View key={`${item.text}-${idx}`} style={todoListCardStylesheet.itemRow}>
|
||||||
|
<View style={todoListCardStylesheet.radioOuter}>
|
||||||
|
{item.completed ? <View style={todoListCardStylesheet.radioInner} /> : null}
|
||||||
|
</View>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
todoListCardStylesheet.itemText,
|
||||||
|
item.completed && todoListCardStylesheet.itemTextCompleted,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{item.text}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}, [items]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<ExpandableBadge
|
||||||
style={[
|
label="Tasks"
|
||||||
todoListCardStylesheet.container,
|
icon={CheckSquare}
|
||||||
!resolvedDisableOuterSpacing && todoListCardStylesheet.containerSpacing,
|
isExpanded={isExpanded}
|
||||||
]}
|
onToggle={handleToggle}
|
||||||
>
|
renderDetails={renderDetails}
|
||||||
<View style={todoListCardStylesheet.card}>
|
disableOuterSpacing={disableOuterSpacing}
|
||||||
<View style={todoListCardStylesheet.header}>
|
/>
|
||||||
<View style={todoListCardStylesheet.headerMeta}>
|
|
||||||
<Text style={todoListCardStylesheet.title}>Tasks</Text>
|
|
||||||
<Text style={todoListCardStylesheet.timestamp}>
|
|
||||||
{timestampLabel}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<View style={todoListCardStylesheet.providerBadge}>
|
|
||||||
<Text style={todoListCardStylesheet.providerText}>
|
|
||||||
{providerLabel}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
<Text style={todoListCardStylesheet.progressText}>
|
|
||||||
{items.length > 0
|
|
||||||
? `${completedCount}/${items.length} completed`
|
|
||||||
: "Waiting for tasks..."}
|
|
||||||
</Text>
|
|
||||||
<View style={todoListCardStylesheet.list}>
|
|
||||||
{items.length === 0 ? (
|
|
||||||
<Text style={todoListCardStylesheet.emptyText}>
|
|
||||||
No tasks shared yet.
|
|
||||||
</Text>
|
|
||||||
) : (
|
|
||||||
items.map((item, idx) => (
|
|
||||||
<View
|
|
||||||
key={`${item.text}-${idx}`}
|
|
||||||
style={todoListCardStylesheet.itemRow}
|
|
||||||
>
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
todoListCardStylesheet.checkbox,
|
|
||||||
item.completed && todoListCardStylesheet.checkboxCompleted,
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
{item.completed && <Check size={14} color={iconColor} />}
|
|
||||||
</View>
|
|
||||||
<Text
|
|
||||||
style={[
|
|
||||||
todoListCardStylesheet.itemText,
|
|
||||||
item.completed && todoListCardStylesheet.itemTextCompleted,
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
{item.text}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1152,7 +1049,17 @@ const ExpandableBadge = memo(function ExpandableBadge({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[expandableBadgeStylesheet.container, style]} testID={testID}>
|
<View
|
||||||
|
style={[
|
||||||
|
expandableBadgeStylesheet.container,
|
||||||
|
!resolvedDisableOuterSpacing &&
|
||||||
|
(isLastInSequence
|
||||||
|
? expandableBadgeStylesheet.containerLastInSequence
|
||||||
|
: expandableBadgeStylesheet.containerSpacing),
|
||||||
|
style,
|
||||||
|
]}
|
||||||
|
testID={testID}
|
||||||
|
>
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={hasDetails ? onToggle : undefined}
|
onPress={hasDetails ? onToggle : undefined}
|
||||||
disabled={!hasDetails}
|
disabled={!hasDetails}
|
||||||
@@ -1352,7 +1259,6 @@ export const ToolCall = memo(function ToolCall({
|
|||||||
isLoading={status === "executing"}
|
isLoading={status === "executing"}
|
||||||
isError={status === "failed"}
|
isError={status === "failed"}
|
||||||
isLastInSequence={isLastInSequence}
|
isLastInSequence={isLastInSequence}
|
||||||
style={isLastInSequence ? undefined : { marginBottom: theme.spacing[1] }}
|
|
||||||
disableOuterSpacing={disableOuterSpacing}
|
disableOuterSpacing={disableOuterSpacing}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -797,6 +797,11 @@ export function reduceStreamUpdate(
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "todo": {
|
case "todo": {
|
||||||
|
if (event.provider === "claude") {
|
||||||
|
// Claude plan mode is rendered via permission prompts + TodoWrite tool calls.
|
||||||
|
// Avoid rendering legacy plan-mode todo timeline items as Tasks.
|
||||||
|
break;
|
||||||
|
}
|
||||||
const items = (item.items ?? []) as TodoEntry[];
|
const items = (item.items ?? []) as TodoEntry[];
|
||||||
nextState = appendTodoList(state, event.provider, items, timestamp);
|
nextState = appendTodoList(state, event.provider, items, timestamp);
|
||||||
break;
|
break;
|
||||||
|
|||||||
Reference in New Issue
Block a user