From 4edf3aba237e3a6db774fca6e128189adb3e1401 Mon Sep 17 00:00:00 2001 From: BlitzOS Upstream Prep Date: Wed, 2 Sep 2026 00:27:17 +0000 Subject: [PATCH] fix(components): disable Side Chat until there is an assistant turn to fork MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In a session the agent has not answered yet, the Side Chat entry accepts a click and nothing visible happens. The launcher forks the active conversation, a fork needs a completed assistant turn, and with none `forkActiveConversation` returns after a `toast.error` — so the entry reads as broken rather than as refused. Say it before the click, the way the same launcher already says it for an offline machine: `disabled`. Hiding the entry is worse — a session that has not answered yet would look like one where Side Chat does not exist, and the option comes back a second later. The fork target has to be a value a render can read. `chatRefsMap` is a ref, so `getLastAssistantTurnId()` answers when somebody asks, which is right for a click and useless for a rendered state. `setChatTabRef` mirrors it into state on ATTACH; `useImperativeHandle` already carries `lastCompletedAssistantMessageId` in its dependency list, so React re-attaches the ref on the commit that first has a turn. The detach is ignored deliberately. Every render hands each chat surface a fresh ref arrow, so React calls it with null and then with the handle inside one commit; taking the null would queue a state change on every commit and the page would re-render for ever. Cost, against what it fixes: a session that HAS answered shows the launcher disabled for the moment its history takes to paint. That is a control nobody is looking at during a page load, and it replaces a control a member clicks that answers with an error. Fork semantics are untouched — what changes is whether the entry offers the click. Model: claude-opus-5[1m] --- .../components/sessions/session-detail.tsx | 32 ++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/packages/components/src/components/sessions/session-detail.tsx b/packages/components/src/components/sessions/session-detail.tsx index cf08bfa3a..d37e2151a 100644 --- a/packages/components/src/components/sessions/session-detail.tsx +++ b/packages/components/src/components/sessions/session-detail.tsx @@ -1077,6 +1077,18 @@ const SessionDetail = ({ draftTabs.some((draft) => draft.id === activeTabSessionIdRaw); return stillExists ? activeTabSessionIdRaw : sessionId; }, [activeTabSessionIdRaw, visibleChildSessions, draftTabs, sessionId]); + /** + * The active conversation's fork target, as a value a RENDER can read. + * + * `chatRefsMap` is a ref, so `getLastAssistantTurnId()` answers only when + * somebody asks — which is fine for a click and useless for a disabled state. + * `setChatTabRef` mirrors it into state below; this ref is how that callback + * knows which tab is the active one without taking a dependency and changing + * its identity on every tab switch. + */ + const activeTabSessionIdForForkRef = useRef(activeTabSessionId); + activeTabSessionIdForForkRef.current = activeTabSessionId; + const [activeTabAssistantTurnId, setActiveTabAssistantTurnId] = useState(null); const activeSessionTabId = useMemo(() => { if (activeTabSessionId === sessionId) return sessionId; return visibleChildSessions.find((s) => s.id === activeTabSessionId)?.id ?? null; @@ -1689,6 +1701,17 @@ const SessionDetail = ({ const setChatTabRef = useCallback( (tabId: string, ref: SessionChatInterfaceHandle | DraftSessionChatInterfaceHandle | null) => { chatRefsMap.current.set(tabId, ref); + // A DETACH IS IGNORED, and that is what keeps this from looping. Every + // render of this page hands each surface a fresh ref arrow, so React + // calls it with `null` and then with the handle inside one commit; taking + // the `null` would queue a state change on every commit for ever. + // `useImperativeHandle` re-attaches whenever the handle's own + // dependencies change — `lastCompletedAssistantMessageId` among them — so + // the attach alone carries every value this needs. + if (ref === null || tabId !== activeTabSessionIdForForkRef.current) return; + setActiveTabAssistantTurnId( + 'getLastAssistantTurnId' in ref ? ref.getLastAssistantTurnId() : null + ); }, [] ); @@ -3355,10 +3378,17 @@ const SessionDetail = ({ label: t('sessions.detailTabs.sideSession', 'Side Chat'), kind: 'session', pending: isCreatingSideSession, - disabled: launcherState === 'disabled' || isCreatingSideSession, + // The third reason there is nothing to launch, beside an offline machine + // and a launch already in flight: a fork needs a completed assistant turn, + // and `forkActiveConversation` refuses with a toast without one. + disabled: + launcherState === 'disabled' || + isCreatingSideSession || + activeTabAssistantTurnId === null, }; }, [ activeDraftTab, + activeTabAssistantTurnId, activeTabSession, activeTabSessionMachineOnlineStatus, canForkSession,