Render todo lists in agent stream

This commit is contained in:
Mohamed Boudra
2025-11-15 12:43:01 +01:00
parent 1d9f6a0df3
commit 6da27613aa
5 changed files with 578 additions and 48 deletions

View File

@@ -9,6 +9,7 @@ import {
NativeSyntheticEvent,
InteractionManager,
} from "react-native";
import Markdown from "react-native-markdown-display";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { BottomSheetModal } from "@gorhom/bottom-sheet";
@@ -21,14 +22,21 @@ import {
ActivityLog,
ToolCall,
AgentThoughtMessage,
TodoListCard,
type InlinePathTarget,
} from "./message";
import { ToolCallBottomSheet } from "./tool-call-bottom-sheet";
import { DiffViewer } from "./diff-viewer";
import type { StreamItem } from "@/types/stream";
import type { SelectedToolCall, PendingPermission } from "@/types/shared";
import type { AgentPermissionResponse } from "@server/server/agent/agent-sdk-types";
import type { Agent } from "@/contexts/session-context";
import { useSession } from "@/contexts/session-context";
import {
extractCommandDetails,
extractEditEntries,
extractReadEntries,
} from "@/utils/tool-call-parsers";
export interface AgentStreamViewProps {
agentId: string;
@@ -269,6 +277,15 @@ export function AgentStreamView({
/>
);
case "todo_list":
return (
<TodoListCard
provider={item.provider}
timestamp={item.timestamp.getTime()}
items={item.items}
/>
);
default:
return null;
}
@@ -417,9 +434,95 @@ function PermissionRequestCard({
const { request } = permission;
const title = request.title ?? request.name ?? "Permission Required";
const description = request.description ?? "";
const inputPreview = request.input
? JSON.stringify(request.input, null, 2)
: null;
const inputPreview = request.input ? JSON.stringify(request.input, null, 2) : null;
const planMarkdown = useMemo(() => {
if (!request) {
return undefined;
}
const planFromMetadata =
typeof request.metadata?.planText === "string"
? request.metadata.planText
: undefined;
if (planFromMetadata) {
return planFromMetadata;
}
const candidate = request.input?.["plan"];
if (typeof candidate === "string") {
return candidate;
}
return undefined;
}, [request]);
const editEntries = useMemo(
() => extractEditEntries(request.input, request.metadata, request.raw),
[request]
);
const readEntries = useMemo(
() => extractReadEntries(request.input, request.metadata, request.raw),
[request]
);
const commandDetails = useMemo(
() => extractCommandDetails(request.input, request.metadata, request.raw),
[request]
);
const markdownStyles = useMemo(
() => ({
body: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
lineHeight: 20,
},
paragraph: {
marginBottom: theme.spacing[1],
},
strong: {
fontWeight: theme.fontWeight.semibold,
},
bullet_list: {
marginBottom: theme.spacing[1],
},
ordered_list: {
marginBottom: theme.spacing[1],
},
list_item: {
flexDirection: "row" as const,
marginBottom: theme.spacing[1],
},
code_inline: {
fontFamily: "monospace",
backgroundColor: theme.colors.card,
paddingHorizontal: theme.spacing[1],
paddingVertical: theme.spacing[1],
borderRadius: theme.borderRadius.sm,
},
code_block: {
fontFamily: "monospace",
backgroundColor: theme.colors.card,
padding: theme.spacing[2],
borderRadius: theme.borderRadius.md,
},
fence: {
fontFamily: "monospace",
backgroundColor: theme.colors.card,
padding: theme.spacing[2],
borderRadius: theme.borderRadius.md,
},
blockquote: {
borderLeftWidth: theme.borderWidth[1],
borderLeftColor: theme.colors.border,
paddingLeft: theme.spacing[3],
marginBottom: theme.spacing[2],
},
blockquote_text: {
color: theme.colors.mutedForeground,
},
}),
[theme]
);
return (
<View
@@ -436,23 +539,136 @@ function PermissionRequestCard({
</Text>
{description ? (
<Text style={[permissionStyles.planText, { color: theme.colors.mutedForeground }]}>
<Text style={[permissionStyles.description, { color: theme.colors.mutedForeground }]}>
{description}
</Text>
) : null}
{inputPreview && (
<View
style={[
permissionStyles.planContainer,
{ backgroundColor: theme.colors.background },
]}
>
<Text style={[permissionStyles.planText, { color: theme.colors.foreground }]}>
{inputPreview}
</Text>
{planMarkdown ? (
<View style={permissionStyles.section}>
<Text style={[permissionStyles.sectionTitle, { color: theme.colors.mutedForeground }]}>Proposed Plan</Text>
<View
style={[
permissionStyles.contentCard,
{
backgroundColor: theme.colors.background,
borderColor: theme.colors.border,
},
]}
>
<Markdown style={markdownStyles}>{planMarkdown}</Markdown>
</View>
</View>
)}
) : null}
{commandDetails ? (
<View style={permissionStyles.section}>
<Text style={[permissionStyles.sectionTitle, { color: theme.colors.mutedForeground }]}>Command</Text>
{commandDetails.command ? (
<View style={permissionStyles.metadataRow}>
<Text style={[permissionStyles.metadataLabel, { color: theme.colors.mutedForeground }]}>Command</Text>
<Text style={[permissionStyles.metadataValue, { color: theme.colors.foreground }]}>
{commandDetails.command}
</Text>
</View>
) : null}
{commandDetails.cwd ? (
<View style={permissionStyles.metadataRow}>
<Text style={[permissionStyles.metadataLabel, { color: theme.colors.mutedForeground }]}>Directory</Text>
<Text style={[permissionStyles.metadataValue, { color: theme.colors.foreground }]}>
{commandDetails.cwd}
</Text>
</View>
) : null}
</View>
) : null}
{editEntries.length > 0 ? (
<View style={permissionStyles.section}>
<Text style={[permissionStyles.sectionTitle, { color: theme.colors.mutedForeground }]}>Proposed Changes</Text>
{editEntries.map((entry, index) => (
<View key={`${entry.filePath ?? "change"}-${index}`} style={permissionStyles.diffSection}>
{entry.filePath ? (
<View
style={[
permissionStyles.fileBadge,
{
borderColor: theme.colors.border,
backgroundColor: theme.colors.card,
},
]}
>
<Text style={[permissionStyles.fileBadgeText, { color: theme.colors.foreground }]}>
{entry.filePath}
</Text>
</View>
) : null}
<View
style={[
permissionStyles.diffWrapper,
{
borderColor: theme.colors.border,
backgroundColor: theme.colors.card,
},
]}
>
<DiffViewer diffLines={entry.diffLines} maxHeight={200} />
</View>
</View>
))}
</View>
) : null}
{readEntries.length > 0 ? (
<View style={permissionStyles.section}>
<Text style={[permissionStyles.sectionTitle, { color: theme.colors.mutedForeground }]}>File Content</Text>
{readEntries.map((entry, index) => (
<View
key={`${entry.filePath ?? "content"}-${index}`}
style={[
permissionStyles.contentCard,
{
backgroundColor: theme.colors.background,
borderColor: theme.colors.border,
},
]}
>
{entry.filePath ? (
<Text
style={[
permissionStyles.metadataLabel,
{ color: theme.colors.mutedForeground, marginBottom: theme.spacing[1] },
]}
>
{entry.filePath}
</Text>
) : null}
<Text style={[permissionStyles.rawContentText, { color: theme.colors.foreground }]}>
{entry.content}
</Text>
</View>
))}
</View>
) : null}
{inputPreview ? (
<View style={permissionStyles.section}>
<Text style={[permissionStyles.sectionTitle, { color: theme.colors.mutedForeground }]}>Raw Request</Text>
<View
style={[
permissionStyles.contentCard,
{
backgroundColor: theme.colors.background,
borderColor: theme.colors.border,
},
]}
>
<Text style={[permissionStyles.rawContentText, { color: theme.colors.foreground }]}>
{inputPreview}
</Text>
</View>
</View>
) : null}
<Text
style={[
@@ -508,6 +724,7 @@ function PermissionRequestCard({
);
}
const stylesheet = StyleSheet.create((theme) => ({
container: {
flex: 1,
@@ -570,35 +787,80 @@ const permissionStyles = StyleSheet.create((theme) => ({
padding: theme.spacing[4],
borderRadius: theme.spacing[2],
borderWidth: 1,
gap: theme.spacing[2],
},
title: {
fontSize: 16,
fontWeight: "600",
marginBottom: theme.spacing[3],
fontSize: theme.fontSize.lg,
fontWeight: theme.fontWeight.semibold,
},
planContainer: {
description: {
fontSize: theme.fontSize.sm,
lineHeight: 20,
},
section: {
gap: theme.spacing[2],
},
sectionTitle: {
fontSize: theme.fontSize.xs,
textTransform: "uppercase" as const,
letterSpacing: 0.5,
},
contentCard: {
padding: theme.spacing[3],
borderRadius: theme.spacing[1],
marginBottom: theme.spacing[3],
borderRadius: theme.borderRadius.lg,
borderWidth: theme.borderWidth[1],
},
planText: {
fontSize: 14,
metadataRow: {
marginBottom: theme.spacing[2],
},
metadataLabel: {
fontSize: theme.fontSize.xs,
textTransform: "uppercase" as const,
letterSpacing: 0.5,
},
metadataValue: {
fontFamily: "monospace",
fontSize: theme.fontSize.sm,
},
diffSection: {
gap: theme.spacing[2],
},
fileBadge: {
alignSelf: "flex-start",
paddingHorizontal: theme.spacing[2],
paddingVertical: theme.spacing[1],
borderRadius: theme.borderRadius.lg,
borderWidth: theme.borderWidth[1],
},
fileBadgeText: {
fontFamily: "monospace",
fontSize: theme.fontSize.xs,
},
diffWrapper: {
borderRadius: theme.borderRadius.lg,
borderWidth: theme.borderWidth[1],
overflow: "hidden",
},
rawContentText: {
fontFamily: "monospace",
fontSize: theme.fontSize.sm,
lineHeight: 20,
},
question: {
fontSize: 14,
marginBottom: theme.spacing[3],
fontSize: theme.fontSize.sm,
marginTop: theme.spacing[2],
marginBottom: theme.spacing[2],
},
optionsContainer: {
gap: theme.spacing[2],
},
optionButton: {
padding: theme.spacing[3],
borderRadius: theme.spacing[1],
borderRadius: theme.borderRadius.md,
alignItems: "center",
},
optionText: {
fontSize: 14,
fontWeight: "600",
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.semibold,
},
}));

View File

@@ -1,5 +1,7 @@
import { View, Text, Pressable, Animated } from "react-native";
import { useState, useEffect, useRef, memo, useMemo, useCallback } from "react";
import type { AgentProvider } from "@server/server/agent/agent-sdk-types";
import { getAgentProviderDefinition } from "@server/server/agent/provider-manifest";
import Markdown from "react-native-markdown-display";
import {
Circle,
@@ -23,6 +25,7 @@ import { StyleSheet } from "react-native-unistyles";
import { baseColors, theme } from "@/styles/theme";
import { Colors } from "@/constants/theme";
import * as Clipboard from "expo-clipboard";
import type { TodoEntry } from "@/types/stream";
interface UserMessageProps {
message: string;
@@ -599,6 +602,180 @@ export const ActivityLog = memo(function ActivityLog({
);
});
interface TodoListCardProps {
provider: AgentProvider;
timestamp: number;
items: TodoEntry[];
}
function formatPlanTimestamp(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) => ({
container: {
marginHorizontal: theme.spacing[2],
marginBottom: theme.spacing[2],
},
card: {
backgroundColor: theme.colors.card,
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.mutedForeground,
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.mutedForeground,
fontSize: theme.fontSize.xs,
marginBottom: theme.spacing[2],
},
list: {
gap: theme.spacing[2],
},
itemRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
checkbox: {
width: 24,
height: 24,
borderRadius: theme.borderRadius.base,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
alignItems: "center",
justifyContent: "center",
backgroundColor: "transparent",
},
checkboxCompleted: {
backgroundColor: theme.colors.primary,
borderColor: theme.colors.primary,
},
itemText: {
flex: 1,
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
},
itemTextCompleted: {
color: theme.colors.mutedForeground,
textDecorationLine: "line-through",
},
emptyText: {
color: theme.colors.mutedForeground,
fontSize: theme.fontSize.sm,
fontStyle: "italic",
},
}));
export const TodoListCard = memo(function TodoListCard({
provider,
timestamp,
items,
}: TodoListCardProps) {
const providerLabel = useMemo(() => {
const definition = getAgentProviderDefinition(provider);
return definition?.label ?? provider;
}, [provider]);
const completedCount = useMemo(
() => items.filter((item) => item.completed).length,
[items]
);
const timestampLabel = useMemo(() => formatPlanTimestamp(timestamp), [timestamp]);
const iconColor = theme.colors.background;
return (
<View style={todoListCardStylesheet.container}>
<View style={todoListCardStylesheet.card}>
<View style={todoListCardStylesheet.header}>
<View style={todoListCardStylesheet.headerMeta}>
<Text style={todoListCardStylesheet.title}>Plan</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 todo items 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>
);
});
interface AgentThoughtMessageProps {
message: string;
}

View File

@@ -30,6 +30,7 @@ export type StreamItem =
| AssistantMessageItem
| ThoughtItem
| ToolCallItem
| TodoListItem
| ActivityLogItem;
export interface UserMessageItem {
@@ -101,7 +102,16 @@ export interface ActivityLogItem {
metadata?: Record<string, unknown>;
}
type TodoEntry = { text: string; completed: boolean };
export type TodoEntry = { text: string; completed: boolean };
export interface TodoListItem {
kind: "todo_list";
id: string;
timestamp: Date;
provider: AgentProvider;
items: TodoEntry[];
raw?: unknown;
}
function normalizeChunk(text: string): { chunk: string; hasContent: boolean } {
if (!text) {
@@ -354,13 +364,41 @@ function appendActivityLog(state: StreamItem[], entry: ActivityLogItem): StreamI
return [...state, entry];
}
function formatTodoMessage(items: TodoEntry[]): string {
if (!items.length) {
return "Todo list";
function appendTodoList(
state: StreamItem[],
provider: AgentProvider,
items: TodoEntry[],
timestamp: Date,
raw?: unknown
): StreamItem[] {
const normalizedItems = items.map((item) => ({
text: item.text,
completed: Boolean(item.completed),
}));
const lastItem = state[state.length - 1];
if (lastItem && lastItem.kind === "todo_list" && lastItem.provider === provider) {
const next = [...state];
const updated: TodoListItem = {
...lastItem,
items: normalizedItems,
timestamp,
raw: raw ?? lastItem.raw,
};
next[next.length - 1] = updated;
return next;
}
const header = "Todo list";
const entries = items.map((item) => `• [${item.completed ? "x" : " "}] ${item.text}`);
return [header, ...entries].join("\n");
const entry: TodoListItem = {
kind: "todo_list",
id: createTimelineId("todo", `${provider}:${JSON.stringify(normalizedItems)}`, timestamp),
timestamp,
provider,
items: normalizedItems,
raw,
};
return [...state, entry];
}
function formatErrorMessage(message: string): string {
@@ -410,15 +448,7 @@ export function reduceStreamUpdate(
}
case "todo": {
const items = (item.items ?? []) as TodoEntry[];
const activity: ActivityLogItem = {
kind: "activity_log",
id: createTimelineId("todo", JSON.stringify(items), timestamp),
timestamp,
activityType: "system",
message: formatTodoMessage(items),
metadata: items.length ? { items } : undefined,
};
return appendActivityLog(state, activity);
return appendTodoList(state, event.provider, items, timestamp, item.raw);
}
case "error": {
const activity: ActivityLogItem = {

View File

@@ -8,8 +8,11 @@
# Tasks
- [x] Make sure we are handling edit / read / command tool calls more prominently in the front end, for edits we should show the diff like we do in the git diff screen, for reads we should show the content, and for commands we should show the command output. This would be in the bottom sheet of the tool call. You have to test each agent provider to figure out their specific format, use zod to parse the input/output and show the diff/content/command output accordingly.
- Added zod-backed parsers for edit/read/command tool calls so the bottom sheet now renders diffs, file contents, and terminal output directly from structured tool input/output across providers. Implemented new UI sections plus type-safe helpers, and ran `npm run typecheck --workspace=@voice-dev/app`. Manual provider-by-provider verification still needs to run on-device once agents are available.
- [ ] Same goes for permission tool calls, we should render more richly the permission prompt in the agent stream, ExitPlanMode from Claude we should render the markdown for example. For edit permissions we should show the diff like we do in the bottom sheet tool call.
- [ ] Investigate how each agent provider handles todo lists, we should have first class support for rendering those in the agent stream. I believe the Codex calls them plan and Claude uses TodoWrite tool calls. You can search the web, look at their node modules or just experiment via testing, which is a good diea anyways becaue we want tests for this, you just have to thinka bout how to trigger the agent to do plans / todo lists. Maybe just ask directly.
- Added zod-backed parsers for edit/read/command tool calls so the bottom sheet now renders diffs, file contents, and terminal output directly from structured tool input/output across providers. Implemented new UI sections plus type-safe helpers, and ran `npm run typecheck --workspace=@voice-dev/app`. Manual provider-by-provider verification still needs to run on-device once agents are available.
- [x] Same goes for permission tool calls, we should render more richly the permission prompt in the agent stream, ExitPlanMode from Claude we should render the markdown for example. For edit permissions we should show the diff like we do in the bottom sheet tool call.
- Added shared tool-call parsers plus a reusable diff viewer, then upgraded the permission cards to render plan markdown (ExitPlanMode), shell metadata, diffs, read content, and the raw payload directly inside the stream. Ran `npm run typecheck --workspace=@voice-dev/app`; please test across providers on-device once agents are hooked up.
- [x] Investigate how each agent provider handles todo lists, we should have first class support for rendering those in the agent stream. I believe the Codex calls them plan and Claude uses TodoWrite tool calls. You can search the web, look at their node modules or just experiment via testing, which is a good diea anyways becaue we want tests for this, you just have to thinka bout how to trigger the agent to do plans / todo lists. Maybe just ask directly.
- Normalized `todo` timeline entries into a dedicated `todo_list` stream item, rendered them with a new plan card in the agent stream (provider badge, completion status, checkboxes), and added consolidation logic plus regression coverage in `test-idempotent-stream.ts`. Ran `npm run typecheck --workspace=@voice-dev/app`.
- [ ] Change "Refresh from disk" to "Refresh" in the agent three dot menu
- [ ] Are we filtering our own shats (already present in agents storage) from the resume agent list? We should if not.
- [ ] Getting "two children with the same key" for "thoughts" and "assistant" review our keying strategy, and make it more robust and performant, and stable.

View File

@@ -1,4 +1,10 @@
import { reduceStreamUpdate, hydrateStreamState, type StreamItem, type ToolCallItem } from "./packages/app/src/types/stream";
import {
reduceStreamUpdate,
hydrateStreamState,
type StreamItem,
type ToolCallItem,
type TodoListItem,
} from "./packages/app/src/types/stream";
type AgentStreamEventPayload = Parameters<typeof reduceStreamUpdate>[1];
@@ -51,6 +57,17 @@ function permissionTimeline(id: string, status: string): AgentStreamEventPayload
};
}
function todoTimeline(items: { text: string; completed: boolean }[]): AgentStreamEventPayload {
return {
type: "timeline",
provider: "codex",
item: {
type: "todo",
items,
},
};
}
function userTimeline(text: string, messageId?: string): AgentStreamEventPayload {
return {
type: "timeline",
@@ -322,6 +339,46 @@ function testPermissionToolCallFiltering() {
}
}
// Test 8: Todo lists should consolidate into a single entry and update completions
function testTodoListConsolidation() {
console.log('\n=== Test 8: Todo List Consolidation ===');
const timestamp1 = new Date('2025-01-01T12:30:00Z');
const timestamp2 = new Date('2025-01-01T12:31:00Z');
const firstPlan = [
{ text: 'Outline approach', completed: false },
{ text: 'Write code', completed: false },
];
const secondPlan = [
{ text: 'Outline approach', completed: true },
{ text: 'Write code', completed: false },
];
const updates = [
{ event: todoTimeline(firstPlan), timestamp: timestamp1 },
{ event: todoTimeline(secondPlan), timestamp: timestamp2 },
];
const state = hydrateStreamState(updates);
const todoEntries = state.filter(
(item): item is TodoListItem => item.kind === 'todo_list'
);
if (
todoEntries.length === 1 &&
todoEntries[0].items.some(
(entry) => entry.text === 'Outline approach' && entry.completed
)
) {
console.log('✅ PASS: Todo list updates consolidate and reflect completion');
} else {
console.log('❌ FAIL: Todo list entries were not consolidated as expected');
console.log('State:', JSON.stringify(state, null, 2));
}
}
// Run all tests
console.log("Testing Idempotent Stream Reduction");
console.log("====================================");
@@ -333,6 +390,7 @@ testToolCallInputPreservation();
testAssistantWhitespacePreservation();
testUserMessageHydration();
testPermissionToolCallFiltering();
testTodoListConsolidation();
console.log("\n====================================");
console.log("Tests complete");