Skip to content

Commit 4eb61d6

Browse files
author
Test
committed
🤖 feat: align mobile TODO lifecycle with desktop behavior
Remove WorkspaceActionsContext and manual toggle from action menu Drive TODO visibility purely from stream events (caught-up boundary) Hide TODOs on stream-end and when reopening idle workspaces Add todoLifecycle utilities with defensive assertions and tests _Generated with mux_ Change-Id: I7a74e287009f8e178ce9d8f8b6f1dcd360411f73 Signed-off-by: Test <test@example.com>
1 parent bcbb3b1 commit 4eb61d6

File tree

6 files changed

+199
-107
lines changed

6 files changed

+199
-107
lines changed

apps/mobile/app/workspace/[id].tsx

Lines changed: 1 addition & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,6 @@ import { Ionicons } from "@expo/vector-icons";
66
import WorkspaceScreen from "../../src/screens/WorkspaceScreen";
77
import { WorkspaceActionSheet } from "../../src/components/WorkspaceActionSheet";
88
import { CostUsageSheet } from "../../src/components/CostUsageSheet";
9-
import {
10-
WorkspaceActionsProvider,
11-
useWorkspaceActions,
12-
} from "../../src/contexts/WorkspaceActionsContext";
139
import { WorkspaceCostProvider } from "../../src/contexts/WorkspaceCostContext";
1410

1511
function WorkspaceContent(): JSX.Element {
@@ -25,7 +21,6 @@ function WorkspaceContent(): JSX.Element {
2521

2622
const [showActionSheet, setShowActionSheet] = useState(false);
2723
const [showCostSheet, setShowCostSheet] = useState(false);
28-
const { toggleTodoCard, hasTodos } = useWorkspaceActions();
2924

3025
// Handle creation mode
3126
if (isCreationMode && projectPath && projectName) {
@@ -61,17 +56,6 @@ function WorkspaceContent(): JSX.Element {
6156
badge: undefined, // TODO: Add change count
6257
onPress: () => router.push(`/workspace/${id}/review`),
6358
},
64-
// Only show todo item if there are todos
65-
...(hasTodos
66-
? [
67-
{
68-
id: "todo",
69-
label: "Todo List",
70-
icon: "list-outline" as const,
71-
onPress: toggleTodoCard,
72-
},
73-
]
74-
: []),
7559
];
7660

7761
if (!id) {
@@ -108,9 +92,5 @@ function WorkspaceContent(): JSX.Element {
10892
}
10993

11094
export default function WorkspaceRoute(): JSX.Element {
111-
return (
112-
<WorkspaceActionsProvider>
113-
<WorkspaceContent />
114-
</WorkspaceActionsProvider>
115-
);
95+
return <WorkspaceContent />;
11696
}

apps/mobile/src/components/FloatingTodoCard.tsx

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,21 +3,19 @@ import { useState } from "react";
33
import { Pressable, ScrollView, Text, View } from "react-native";
44
import { Ionicons } from "@expo/vector-icons";
55
import { ThemedText } from "./ThemedText";
6-
import { IconButton } from "./IconButton";
76
import { TodoItemView, type TodoItem } from "./TodoItemView";
87
import { useTheme } from "../theme";
98

109
interface FloatingTodoCardProps {
1110
todos: TodoItem[];
12-
onDismiss: () => void;
1311
}
1412

1513
/**
1614
* Floating todo card that appears above the input area during streaming.
1715
* Shows current progress and updates in real-time as agent works.
18-
* Disappears when stream ends or user dismisses.
16+
* Disappears when stream ends.
1917
*/
20-
export function FloatingTodoCard({ todos, onDismiss }: FloatingTodoCardProps): JSX.Element | null {
18+
export function FloatingTodoCard({ todos }: FloatingTodoCardProps): JSX.Element | null {
2119
const theme = useTheme();
2220
const spacing = theme.spacing;
2321
const [isExpanded, setIsExpanded] = useState(true);
@@ -60,12 +58,6 @@ export function FloatingTodoCard({ todos, onDismiss }: FloatingTodoCardProps): J
6058
color={theme.colors.foregroundSecondary}
6159
/>
6260
</View>
63-
<IconButton
64-
icon={<Ionicons name="close" size={18} color={theme.colors.foregroundMuted} />}
65-
accessibilityLabel="Dismiss todos"
66-
variant="ghost"
67-
onPress={onDismiss}
68-
/>
6961
</Pressable>
7062

7163
{/* Todo Items */}

apps/mobile/src/contexts/WorkspaceActionsContext.tsx

Lines changed: 0 additions & 35 deletions
This file was deleted.

apps/mobile/src/screens/WorkspaceScreen.tsx

Lines changed: 53 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@ import { Picker } from "@react-native-picker/picker";
1919
import { useTheme } from "../theme";
2020
import { ThemedText } from "../components/ThemedText";
2121
import { useApiClient } from "../hooks/useApiClient";
22-
import { useWorkspaceActions } from "../contexts/WorkspaceActionsContext";
2322
import { useWorkspaceCost } from "../contexts/WorkspaceCostContext";
2423
import type { StreamAbortEvent, StreamEndEvent } from "@shared/types/stream.ts";
2524
import { MessageRenderer } from "../messages/MessageRenderer";
@@ -41,6 +40,7 @@ import { RUNTIME_MODE, parseRuntimeModeAndHost, buildRuntimeString } from "@shar
4140
import { loadRuntimePreference, saveRuntimePreference } from "../utils/workspacePreferences";
4241
import { RunSettingsSheet } from "../components/RunSettingsSheet";
4342
import { useModelHistory } from "../hooks/useModelHistory";
43+
import { areTodosEqual, extractTodosFromEvent } from "../utils/todoLifecycle";
4444
import {
4545
assertKnownModelId,
4646
formatModelSummary,
@@ -488,9 +488,6 @@ function WorkspaceScreenInner({
488488
// Track current todos for floating card (during streaming)
489489
const [currentTodos, setCurrentTodos] = useState<TodoItem[]>([]);
490490

491-
// Use context for todo card visibility
492-
const { todoCardVisible, toggleTodoCard, setHasTodos } = useWorkspaceActions();
493-
494491
// Track streaming state for indicator
495492
const [isStreaming, setIsStreaming] = useState(false);
496493
const [streamingModel, setStreamingModel] = useState<string | null>(null);
@@ -501,6 +498,9 @@ function WorkspaceScreenInner({
501498

502499
// Track deltas with timestamps for accurate TPS calculation (60s window like desktop)
503500
const deltasRef = useRef<Array<{ tokens: number; timestamp: number }>>([]);
501+
const isStreamActiveRef = useRef(false);
502+
const hasCaughtUpRef = useRef(false);
503+
const pendingTodosRef = useRef<TodoItem[] | null>(null);
504504
const [tokenDisplay, setTokenDisplay] = useState({ total: 0, tps: 0 });
505505

506506
useEffect(() => {
@@ -555,46 +555,36 @@ function WorkspaceScreenInner({
555555

556556
const metadata = metadataQuery.data ?? null;
557557

558-
// Extract most recent todos from timeline (timeline-based approach)
559-
useEffect(() => {
560-
// Find the most recent completed todo_write tool in timeline
561-
const toolMessages = timeline
562-
.filter(
563-
(entry): entry is Extract<TimelineEntry, { kind: "displayed" }> =>
564-
entry.kind === "displayed"
565-
)
566-
.map((entry) => entry.message)
567-
.filter((msg): msg is DisplayedMessage & { type: "tool" } => msg.type === "tool")
568-
.filter((msg) => msg.toolName === "todo_write");
569-
570-
// Get the most recent one (timeline is already sorted)
571-
const latestTodoTool = toolMessages[toolMessages.length - 1];
572-
573-
if (
574-
latestTodoTool &&
575-
latestTodoTool.args &&
576-
typeof latestTodoTool.args === "object" &&
577-
"todos" in latestTodoTool.args &&
578-
Array.isArray(latestTodoTool.args.todos)
579-
) {
580-
const todos = latestTodoTool.args.todos as TodoItem[];
581-
setCurrentTodos(todos);
582-
setHasTodos(todos.length > 0);
583-
} else if (toolMessages.length === 0) {
584-
// Only clear if no todo_write tools exist at all
585-
setCurrentTodos([]);
586-
setHasTodos(false);
587-
}
588-
}, [timeline, setHasTodos]);
589-
590558
useEffect(() => {
591559
// Skip WebSocket subscription in creation mode (no workspace yet)
592560
if (isCreationMode) return;
593561

562+
isStreamActiveRef.current = false;
563+
hasCaughtUpRef.current = false;
564+
pendingTodosRef.current = null;
565+
594566
const expander = expanderRef.current;
595567
const subscription = api.workspace.subscribeChat(workspaceId!, (payload) => {
596568
// Track streaming state and tokens (60s trailing window like desktop)
597569
if (payload && typeof payload === "object" && "type" in payload) {
570+
if (payload.type === "caught-up") {
571+
hasCaughtUpRef.current = true;
572+
573+
if (pendingTodosRef.current && pendingTodosRef.current.length > 0 && isStreamActiveRef.current) {
574+
const pending = pendingTodosRef.current;
575+
setCurrentTodos((prev) => (areTodosEqual(prev, pending) ? prev : pending));
576+
} else if (!isStreamActiveRef.current) {
577+
setCurrentTodos([]);
578+
}
579+
580+
pendingTodosRef.current = null;
581+
582+
if (__DEV__) {
583+
console.debug(`[WorkspaceScreen] caught up for workspace ${workspaceId}`);
584+
}
585+
return;
586+
}
587+
598588
const typedEvent = payload as StreamEndEvent | StreamAbortEvent | { type: string };
599589
if (typedEvent.type === "stream-end" || typedEvent.type === "stream-abort") {
600590
recordStreamUsage(typedEvent as StreamEndEvent | StreamAbortEvent);
@@ -605,6 +595,9 @@ function WorkspaceScreenInner({
605595
setStreamingModel(typeof payload.model === "string" ? payload.model : null);
606596
deltasRef.current = [];
607597
setTokenDisplay({ total: 0, tps: 0 });
598+
isStreamActiveRef.current = true;
599+
pendingTodosRef.current = null;
600+
setCurrentTodos([]);
608601
} else if (
609602
(payload.type === "stream-delta" ||
610603
payload.type === "reasoning-delta" ||
@@ -647,11 +640,30 @@ function WorkspaceScreenInner({
647640
setStreamingModel(null);
648641
deltasRef.current = [];
649642
setTokenDisplay({ total: 0, tps: 0 });
643+
isStreamActiveRef.current = false;
644+
pendingTodosRef.current = null;
645+
setCurrentTodos([]);
650646
}
651647
}
652648

653649
const expanded = expander.expand(payload);
654650

651+
let latestTodos: TodoItem[] | null = null;
652+
for (const event of expanded) {
653+
const todos = extractTodosFromEvent(event);
654+
if (todos) {
655+
latestTodos = todos;
656+
}
657+
}
658+
659+
if (latestTodos) {
660+
if (hasCaughtUpRef.current) {
661+
setCurrentTodos((prev) => (areTodosEqual(prev, latestTodos) ? prev : latestTodos));
662+
} else {
663+
pendingTodosRef.current = latestTodos;
664+
}
665+
}
666+
655667
// If expander returns [], it means the event was handled but nothing to display yet
656668
// (e.g., streaming deltas accumulating). Do NOT fall back to raw display.
657669
if (expanded.length === 0) {
@@ -684,10 +696,12 @@ function WorkspaceScreenInner({
684696
useEffect(() => {
685697
setTimeline([]);
686698
setCurrentTodos([]);
687-
setHasTodos(false);
688699
setEditingMessage(undefined);
689700
setInputWithSuggestionGuard("");
690-
}, [workspaceId, setHasTodos, setInputWithSuggestionGuard]);
701+
isStreamActiveRef.current = false;
702+
hasCaughtUpRef.current = false;
703+
pendingTodosRef.current = null;
704+
}, [workspaceId, setInputWithSuggestionGuard]);
691705

692706
const handleOpenRunSettings = useCallback(() => {
693707
if (settingsLoading) {
@@ -1063,9 +1077,7 @@ function WorkspaceScreenInner({
10631077
</View>
10641078

10651079
{/* Floating Todo Card */}
1066-
{currentTodos.length > 0 && todoCardVisible && (
1067-
<FloatingTodoCard todos={currentTodos} onDismiss={toggleTodoCard} />
1068-
)}
1080+
{currentTodos.length > 0 && <FloatingTodoCard todos={currentTodos} />}
10691081

10701082
{/* Streaming Indicator */}
10711083
{isStreaming && streamingModel && (
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import { describe, expect, it } from "bun:test";
2+
import type { WorkspaceChatEvent } from "../types";
3+
import type { TodoItem } from "../components/TodoItemView";
4+
import { areTodosEqual, extractTodosFromEvent } from "./todoLifecycle";
5+
6+
describe("todoLifecycle", () => {
7+
const baseToolEvent: WorkspaceChatEvent = {
8+
type: "tool",
9+
id: "event-1",
10+
historyId: "history-1",
11+
toolCallId: "call-1",
12+
toolName: "todo_write",
13+
args: { todos: [] },
14+
status: "completed",
15+
isPartial: false,
16+
historySequence: 1,
17+
} as const;
18+
19+
it("returns null for non todo_write events", () => {
20+
const nonTodoEvent: WorkspaceChatEvent = {
21+
type: "assistant",
22+
id: "assistant-1",
23+
historyId: "history-1",
24+
content: "Hello",
25+
isStreaming: false,
26+
isPartial: false,
27+
isCompacted: false,
28+
historySequence: 1,
29+
} as const;
30+
31+
expect(extractTodosFromEvent(nonTodoEvent)).toBeNull();
32+
});
33+
34+
it("extracts todos from completed todo_write tool", () => {
35+
const todos: TodoItem[] = [
36+
{ content: "Check logs", status: "in_progress" },
37+
{ content: "Fix bug", status: "pending" },
38+
];
39+
40+
const event: WorkspaceChatEvent = {
41+
...baseToolEvent,
42+
args: { todos },
43+
} as const;
44+
45+
const extracted = extractTodosFromEvent(event);
46+
expect(extracted).not.toBeNull();
47+
expect(extracted).toEqual(todos);
48+
});
49+
50+
it("throws when todo_write payload is malformed", () => {
51+
const missingTodos = {
52+
...baseToolEvent,
53+
args: {},
54+
} as WorkspaceChatEvent;
55+
56+
expect(() => extractTodosFromEvent(missingTodos)).toThrow("must be an array");
57+
58+
const invalidStatus = {
59+
...baseToolEvent,
60+
args: { todos: [{ content: "Item", status: "done" }] },
61+
} as unknown as WorkspaceChatEvent;
62+
63+
expect(() => extractTodosFromEvent(invalidStatus)).toThrow("invalid status");
64+
});
65+
66+
it("compares todo arrays by value", () => {
67+
const todos: TodoItem[] = [
68+
{ content: "A", status: "pending" },
69+
{ content: "B", status: "completed" },
70+
];
71+
72+
expect(areTodosEqual(todos, [...todos])).toBe(true);
73+
expect(
74+
areTodosEqual(todos, [
75+
{ content: "A", status: "pending" },
76+
{ content: "B", status: "pending" },
77+
])
78+
).toBe(false);
79+
expect(areTodosEqual(todos, todos.slice(0, 1))).toBe(false);
80+
});
81+
});

0 commit comments

Comments
 (0)