diff --git a/components/session.ts b/components/session.ts index 7ce0431..b0244bb 100644 --- a/components/session.ts +++ b/components/session.ts @@ -61,6 +61,24 @@ export function useChatSession({ chatId, initialMessages, initiallyInFlight }: U // The view + state, derived per model change from the model. const state = useMemo(() => model.getState(), [model, version] as const); + // THE TURN-STATE BROADCAST (owner, sidebar round 2026-10-02): the same + // optimistic machine, propagated — the instant a turn starts locally + // (submitted: send, answer, session start, reattach, wake attach) the + // sidebar learns it without waiting for its 2.5s poll; at settle the + // event clears the row and triggers one immediate refetch. The model + // stays pure; this adapter owns the window seam (the lh:chat-renamed + // pattern). + const prevStatusRef = useRef(state.status); + useEffect(() => { + const was = prevStatusRef.current; + prevStatusRef.current = state.status; + if (was === state.status) return; + const running = state.status === "submitted" || state.status === "streaming"; + const wasRunning = was === "submitted" || was === "streaming"; + if (running === wasRunning) return; // submitted→streaming: still running; error↔ready: not + window.dispatchEvent(new CustomEvent("lh:turn-state", { detail: { id: chatId, running } })); + }, [state.status, chatId]); + // The reattach settled terminal → the sync-and-continue seam (chat.tsx). const setReattachSettled = useCallback( (fn: () => void) => { diff --git a/components/sidebar.tsx b/components/sidebar.tsx index 59af882..61dd073 100644 --- a/components/sidebar.tsx +++ b/components/sidebar.tsx @@ -373,6 +373,36 @@ function SessionsSidebar() { return () => window.removeEventListener("lh:chat-renamed", onRenamed); }, []); + /* INSTANT TURN-STATE (owner, sidebar round 2026-10-02): the open + session broadcasts lh:turn-state the instant its turn starts + (submitted — the optimistic machine's own flip) and at settle — + busy mirrors the server's own definition (isTurnRunning), so the + patch is exact: spinner up the moment the action happens, cleared + and refetched once at settle (title/order move with turns). The + 2.5s poll stays as the BACKSTOP for everything only the server + knows: other windows, wake turns in unopened sessions. A start on + a chat not yet in the snapshot (a brand-new session) = one + immediate refresh instead of a patch. */ + const chatsRef = useRef(chats); + chatsRef.current = chats; + useEffect(() => { + const onTurnState = (e: Event) => { + const { id, running } = (e as CustomEvent<{ id: string; running: boolean }>).detail; + if (running) { + if (chatsRef.current?.some((c) => c.id === id)) { + setChats((prev) => (prev ? prev.map((c) => (c.id === id ? { ...c, busy: true } : c)) : prev)); + } else { + void refresh(); // the brand-new session: it isn't in the snapshot yet + } + return; + } + setChats((prev) => (prev ? prev.map((c) => (c.id === id ? { ...c, busy: false } : c)) : prev)); + void refresh(); // settle: the server snapshot is now truthful — land it now + }; + window.addEventListener("lh:turn-state", onTurnState); + return () => window.removeEventListener("lh:turn-state", onTurnState); + }, [refresh]); + /* ── Keyboard seams (shortcut wave A, 2026-09-17) ── The sidebar owns every session-list interaction, so the shortcuts land here: ⌘⇧F focuses the search field; Alt+↑/↓ cycle the list (opening the sidebar