Skip to content

Commit 180cb55

Browse files
author
Test
committed
🤖 fix: persist chat processors across navigation to preserve streaming state
Change-Id: Id26c2f8f576aa25052337c08bd99979d3b6327ee Signed-off-by: Test <test@example.com>
1 parent 1d6ae92 commit 180cb55

File tree

4 files changed

+74
-12
lines changed

4 files changed

+74
-12
lines changed

‎mobile/app/_layout.tsx‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { SafeAreaProvider } from "react-native-safe-area-context";
66
import { StatusBar } from "expo-status-bar";
77
import { View } from "react-native";
88
import { ThemeProvider, useTheme } from "../src/theme";
9+
import { WorkspaceChatProvider } from "../src/contexts/WorkspaceChatContext";
910

1011
function AppFrame(): JSX.Element {
1112
const theme = useTheme();
@@ -71,7 +72,9 @@ export default function RootLayout(): JSX.Element {
7172
<QueryClientProvider client={client}>
7273
<SafeAreaProvider>
7374
<ThemeProvider>
74-
<AppFrame />
75+
<WorkspaceChatProvider>
76+
<AppFrame />
77+
</WorkspaceChatProvider>
7578
</ThemeProvider>
7679
</SafeAreaProvider>
7780
</QueryClientProvider>
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import type { JSX, ReactNode } from "react";
2+
import { createContext, useCallback, useContext, useRef } from "react";
3+
import { createChatEventExpander } from "../messages/normalizeChatEvent";
4+
import type { ChatEventExpander } from "../messages/normalizeChatEvent";
5+
6+
interface WorkspaceChatContextValue {
7+
/**
8+
* Get or create a ChatEventExpander for the given workspace.
9+
* Processors are cached per workspaceId to preserve streaming state across navigation.
10+
*/
11+
getExpander(workspaceId: string): ChatEventExpander;
12+
13+
/**
14+
* Clear the processor for a specific workspace (e.g., when workspace is deleted).
15+
*/
16+
clearExpander(workspaceId: string): void;
17+
18+
/**
19+
* Clear all processors (e.g., on logout).
20+
*/
21+
clearAll(): void;
22+
}
23+
24+
const WorkspaceChatContext = createContext<WorkspaceChatContextValue | null>(null);
25+
26+
export function WorkspaceChatProvider({ children }: { children: ReactNode }): JSX.Element {
27+
// Store processors keyed by workspaceId
28+
// Using ref to avoid re-renders when processors are created/destroyed
29+
const expandersRef = useRef<Map<string, ChatEventExpander>>(new Map());
30+
31+
const getExpander = useCallback((workspaceId: string): ChatEventExpander => {
32+
const existing = expandersRef.current.get(workspaceId);
33+
if (existing) {
34+
return existing;
35+
}
36+
37+
// Lazy-create processor on first access
38+
const newExpander = createChatEventExpander();
39+
expandersRef.current.set(workspaceId, newExpander);
40+
return newExpander;
41+
}, []);
42+
43+
const clearExpander = useCallback((workspaceId: string): void => {
44+
expandersRef.current.delete(workspaceId);
45+
}, []);
46+
47+
const clearAll = useCallback((): void => {
48+
expandersRef.current.clear();
49+
}, []);
50+
51+
return (
52+
<WorkspaceChatContext.Provider value={{ getExpander, clearExpander, clearAll }}>
53+
{children}
54+
</WorkspaceChatContext.Provider>
55+
);
56+
}
57+
58+
export function useWorkspaceChat(): WorkspaceChatContextValue {
59+
const context = useContext(WorkspaceChatContext);
60+
if (!context) {
61+
throw new Error("useWorkspaceChat must be used within WorkspaceChatProvider");
62+
}
63+
return context;
64+
}

‎mobile/src/messages/normalizeChatEvent.ts‎

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import { createChatEventProcessor } from "@/browser/utils/messages/ChatEventProc
1212

1313
type IncomingEvent = WorkspaceChatEvent | DisplayedMessage | string | number | null | undefined;
1414

15-
interface ChatEventExpander {
15+
export interface ChatEventExpander {
1616
expand(event: IncomingEvent | IncomingEvent[]): WorkspaceChatEvent[];
1717
}
1818

@@ -420,5 +420,3 @@ export function createChatEventExpander(): ChatEventExpander {
420420

421421
return { expand };
422422
}
423-
424-
export type { ChatEventExpander };

‎mobile/src/screens/WorkspaceScreen.tsx‎

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,8 @@ import { useWorkspaceSettings } from "../hooks/useWorkspaceSettings";
3030
import type { ThinkingLevel, WorkspaceMode } from "../types/settings";
3131
import { FloatingTodoCard } from "../components/FloatingTodoCard";
3232
import type { TodoItem } from "../components/TodoItemView";
33-
import { createChatEventExpander } from "../messages/normalizeChatEvent";
3433
import type { DisplayedMessage, FrontendWorkspaceMetadata, WorkspaceChatEvent } from "../types";
34+
import { useWorkspaceChat } from "../contexts/WorkspaceChatContext";
3535
import { applyChatEvent, TimelineEntry } from "./chatTimelineReducer";
3636
import type { SlashSuggestion } from "@/browser/utils/slashCommands/types";
3737
import { parseCommand } from "@/browser/utils/slashCommands/parser";
@@ -185,7 +185,7 @@ function WorkspaceScreenInner({
185185
const theme = useTheme();
186186
const spacing = theme.spacing;
187187
const insets = useSafeAreaInsets();
188-
const expanderRef = useRef(createChatEventExpander());
188+
const { getExpander } = useWorkspaceChat();
189189
const api = useApiClient();
190190
const {
191191
mode,
@@ -411,10 +411,6 @@ function WorkspaceScreenInner({
411411
const pendingTodosRef = useRef<TodoItem[] | null>(null);
412412
const [tokenDisplay, setTokenDisplay] = useState({ total: 0, tps: 0 });
413413

414-
useEffect(() => {
415-
expanderRef.current = createChatEventExpander();
416-
}, [workspaceId]);
417-
418414
// Load branches in creation mode
419415
useEffect(() => {
420416
if (!isCreationMode || !creationContext) return;
@@ -477,7 +473,8 @@ function WorkspaceScreenInner({
477473
hasCaughtUpRef.current = false;
478474
pendingTodosRef.current = null;
479475

480-
const expander = expanderRef.current;
476+
// Get persistent expander for this workspace (survives navigation)
477+
const expander = getExpander(workspaceId!);
481478
const subscription = api.workspace.subscribeChat(workspaceId!, (payload) => {
482479
// Track streaming state and tokens (60s trailing window like desktop)
483480
if (payload && typeof payload === "object" && "type" in payload) {
@@ -609,7 +606,7 @@ function WorkspaceScreenInner({
609606
subscription.close();
610607
wsRef.current = null;
611608
};
612-
}, [api, workspaceId, isCreationMode, recordStreamUsage]);
609+
}, [api, workspaceId, isCreationMode, recordStreamUsage, getExpander]);
613610

614611
// Reset timeline, todos, and editing state when workspace changes
615612
useEffect(() => {

0 commit comments

Comments
 (0)