@@ -19,7 +19,6 @@ import { Picker } from "@react-native-picker/picker";
1919import { useTheme } from "../theme" ;
2020import { ThemedText } from "../components/ThemedText" ;
2121import { useApiClient } from "../hooks/useApiClient" ;
22- import { useWorkspaceActions } from "../contexts/WorkspaceActionsContext" ;
2322import { useWorkspaceCost } from "../contexts/WorkspaceCostContext" ;
2423import type { StreamAbortEvent , StreamEndEvent } from "@shared/types/stream.ts" ;
2524import { MessageRenderer } from "../messages/MessageRenderer" ;
@@ -41,6 +40,7 @@ import { RUNTIME_MODE, parseRuntimeModeAndHost, buildRuntimeString } from "@shar
4140import { loadRuntimePreference , saveRuntimePreference } from "../utils/workspacePreferences" ;
4241import { RunSettingsSheet } from "../components/RunSettingsSheet" ;
4342import { useModelHistory } from "../hooks/useModelHistory" ;
43+ import { areTodosEqual , extractTodosFromEvent } from "../utils/todoLifecycle" ;
4444import {
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 && (
0 commit comments