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,