From 209907a75210616f5b13f16d2d411f4215733edf Mon Sep 17 00:00:00 2001 From: milind-soni Date: Tue, 18 Aug 2026 13:04:44 +0530 Subject: [PATCH] fix desktop sync for phone-created bots --- src/state/store.test.ts | 38 ++++++++++++++++++++++++++++++++++++++ src/state/store.tsx | 23 ++++++++++++++--------- 2 files changed, 52 insertions(+), 9 deletions(-) create mode 100644 src/state/store.test.ts diff --git a/src/state/store.test.ts b/src/state/store.test.ts new file mode 100644 index 0000000000..2449aed512 --- /dev/null +++ b/src/state/store.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; + +import { initialState, reducer, type Bot, type Message } from "./store"; + +describe("cross-client bot creation", () => { + it("adds an announced bot before its greeting frames arrive", () => { + const announced = { + id: "phone-bot", + threadId: "phone-thread", + name: "Scout", + title: "", + description: "", + notifications: true, + color: "green", + unread: false, + modelSelection: { instanceId: "codex", model: "default" }, + } satisfies Omit; + + const added = reducer(initialState, { type: "botPatched", bot: announced }); + + expect(added.bots).toEqual([{ ...announced, messages: [] }]); + + const greeting = { + id: "greeting", + role: "bot", + kind: "text", + text: "Hey — I'm Scout. Nice to meet you.", + at: 2, + } satisfies Message; + const greeted = reducer(added, { + type: "messageAdded", + threadId: announced.threadId, + message: greeting, + }); + + expect(greeted.bots[0]?.messages).toEqual([greeting]); + }); +}); diff --git a/src/state/store.tsx b/src/state/store.tsx index 2d395ac005..3b881a456f 100644 --- a/src/state/store.tsx +++ b/src/state/store.tsx @@ -294,6 +294,8 @@ export interface AppState { } | null; } +type BotAnnouncement = Omit & { messages?: Message[] }; + export type Action = | { type: "hydrate"; bots: Bot[]; groups: Group[] } | { type: "showRoutines" } @@ -353,7 +355,7 @@ export type Action = | { type: "deleteBot"; botId: string } | { type: "duplicateBot"; botId: string } | { type: "markUnread"; botId: string } - | { type: "botPatched"; bot: Partial & { id: string } } + | { type: "botPatched"; bot: BotAnnouncement } | { type: "messageAdded"; threadId: string; message: Message } | { type: "messagePatched"; threadId: string; message: Message } | { type: "screenFrame"; botId: string; png: string; mime: string } @@ -423,7 +425,7 @@ function patchCard(state: AppState, botId: string, messageId: string, patch: Par })); } -function reducer(state: AppState, action: Action): AppState { +export function reducer(state: AppState, action: Action): AppState { switch (action.type) { case "hydrate": { const known = (id: string) => action.bots.some((b) => b.id === id) || action.groups.some((g) => g.id === id); @@ -545,12 +547,15 @@ function reducer(state: AppState, action: Action): AppState { return updateBot(withMascotMotion(state, action.botId, "surprise"), action.botId, (b) => ({ ...b, unread: true })); case "botPatched": { const before = state.bots.find((b) => b.id === action.bot.id); - // A bot event can announce a bot created by another app window (team - // import). Patch events for unknown partial records remain ignored. + // Bot frames are complete except for their transcript. An unknown one + // was created by another client (the phone, another app window, or a + // team import), so add it now; the following message frames will fill + // its greeting without waiting for a full-page hydration. if (!before) { - return Array.isArray(action.bot.messages) - ? { ...state, bots: [action.bot as Bot, ...state.bots] } - : state; + return { + ...state, + bots: [{ ...action.bot, messages: action.bot.messages ?? [] }, ...state.bots], + }; } const kind = action.bot.unread && !before?.unread @@ -832,7 +837,7 @@ function reducer(state: AppState, action: Action): AppState { /** Newest screen frames whose pixels stay in memory per thread. */ const MAX_KEPT_SCREEN_FRAMES = 8; -const initialState: AppState = { +export const initialState: AppState = { bots: [], groups: [], instances: [], @@ -1292,7 +1297,7 @@ export function StoreProvider({ children }: { children: ReactNode }) { clearStream(frame.threadId); break; case "bot": { - const bot = frame.bot as Partial & { id: string }; + const bot = frame.bot as BotAnnouncement; // reading the selected chat clears its badge immediately if (bot.unread && bot.id === stateRef.current.selectedId) { bot.unread = false;