diff --git a/src/features/office/screens/OfficeScreen.tsx b/src/features/office/screens/OfficeScreen.tsx index 8a941cc..21cf77e 100644 --- a/src/features/office/screens/OfficeScreen.tsx +++ b/src/features/office/screens/OfficeScreen.tsx @@ -8,7 +8,7 @@ import { useRef, useState, } from "react"; -import { useRouter, useSearchParams } from "next/navigation"; +import { useRouter } from "next/navigation"; import { MessageSquare, ChevronDown, ChevronLeft, ChevronRight, Mic } from "lucide-react"; import { RetroOffice3D } from "@/features/retro-office/RetroOffice3D"; import type { OfficeAgent } from "@/features/retro-office/core/types"; @@ -953,8 +953,15 @@ type OfficeScreenProps = { export function OfficeScreen({ showOpenClawConsole = true, }: OfficeScreenProps) { - const searchParams = useSearchParams(); - const debugEnabled = searchParams.get("officeDebug") === "1"; + // Patch Hermes Phase 2: avoid useSearchParams() at component root — it + // suspends during hydration in Next.js dev mode and keeps the parent + // Suspense fallback stuck on "Loading...". Use a sync useMemo so + // debugEnabled is stable across renders (state+useEffect caused a + // re-render cascade through downstream useCallbacks). + const debugEnabled = useMemo(() => { + if (typeof window === "undefined") return false; + return new URLSearchParams(window.location.search).get("officeDebug") === "1"; + }, []); const [settingsCoordinator] = useState(() => createStudioSettingsCoordinator(), ); diff --git a/src/features/office/tasks/useTaskBoardController.ts b/src/features/office/tasks/useTaskBoardController.ts index 93fe18e..fddfd98 100644 --- a/src/features/office/tasks/useTaskBoardController.ts +++ b/src/features/office/tasks/useTaskBoardController.ts @@ -695,6 +695,7 @@ export const useTaskBoardController = ({ "unknown" | "supported" | "unsupported" >("unknown"); const sharedRefreshInFlightRef = useRef(false); + const lastPersistedTaskBoardSnapshotRef = useRef(null); useEffect(() => { stateRef.current = state; @@ -831,6 +832,15 @@ export const useTaskBoardController = ({ useEffect(() => { if (!hydratedRef.current || !gatewayUrl.trim()) return; + const nextSnapshot = JSON.stringify({ + gatewayUrl, + cards: state.cards, + selectedCardId: state.selectedCardId, + }); + if (lastPersistedTaskBoardSnapshotRef.current === nextSnapshot) { + return; + } + lastPersistedTaskBoardSnapshotRef.current = nextSnapshot; settingsCoordinator.schedulePatch( { taskBoard: { @@ -1141,8 +1151,26 @@ export const useTaskBoardController = ({ [applySharedTaskRecord], ); + const lastDedupeSnapshotRef = useRef(null); useEffect(() => { if (!hydratedRef.current) return; + // Patch Hermes Phase 2: snapshot guard prevents re-entering the dedupe + // loop when state.cards is replaced by a structurally-identical array + // (upstream dispatch churn). Without this, the effect dispatches + // `upsert` to mark duplicates archived → state.cards new ref → + // effect re-runs → "Maximum update depth exceeded" in dev mode. + const snapshot = JSON.stringify( + stateRef.current.cards + .filter((card) => !card.isArchived && card.source === "openclaw_event") + .map((card) => ({ + id: card.id, + title: card.title, + assignedAgentId: card.assignedAgentId ?? null, + externalThreadId: card.externalThreadId ?? null, + })), + ); + if (lastDedupeSnapshotRef.current === snapshot) return; + lastDedupeSnapshotRef.current = snapshot; const grouped = new Map(); for (const card of stateRef.current.cards) { if (card.isArchived || card.source !== "openclaw_event") continue; diff --git a/src/lib/gateway/GatewayClient.ts b/src/lib/gateway/GatewayClient.ts index 50b568e..1b643b6 100644 --- a/src/lib/gateway/GatewayClient.ts +++ b/src/lib/gateway/GatewayClient.ts @@ -739,6 +739,7 @@ export const useGatewayConnection = ( const [connectErrorCode, setConnectErrorCode] = useState(null); const [settingsLoaded, setSettingsLoaded] = useState(false); const [hasLastKnownGoodState, setHasLastKnownGoodState] = useState(false); + const lastScheduledGatewaySnapshotRef = useRef(null); const setSelectedAdapterType = useCallback( (value: StudioGatewayAdapterType) => { setSelectedAdapterTypeState(value); @@ -795,18 +796,26 @@ export const useGatewayConnection = ( resolveDefaultStudioGatewayProfile(nextAdapterType, normalizedDefaults); const nextGatewayUrl = selectedProfile.url ?? ""; const nextToken = selectedProfile.token ?? ""; + // Patch Hermes Phase 2: allow auto-connect for auto-managed adapters + // (hermes/openclaw/demo) when a persisted URL exists, even if + // gateway.lastKnownGood.adapterType doesn't match the currently + // selected adapter. Without this, switching to Hermes never + // auto-connects because lastKnownGood is still "openclaw". + const hasPersistedProfileForSelected = + Boolean(resolvedGatewayProfiles.lastKnownGoodForSelected?.url) || + (isAutoManagedAdapter(nextAdapterType) && nextGatewayUrl.trim().length > 0); loadedGatewaySettings.current = { gatewayUrl: nextGatewayUrl.trim(), token: nextToken, adapterType: nextAdapterType, profiles: resolvedGatewayProfiles.profiles, - hasLastKnownGood: Boolean(resolvedGatewayProfiles.lastKnownGoodForSelected?.url), + hasLastKnownGood: hasPersistedProfileForSelected, }; setGatewayUrl(nextGatewayUrl); setToken(nextToken); setSelectedAdapterTypeState(nextAdapterType); setAdapterProfiles(resolvedGatewayProfiles.profiles); - setHasLastKnownGoodState(Boolean(resolvedGatewayProfiles.lastKnownGoodForSelected?.url)); + setHasLastKnownGoodState(hasPersistedProfileForSelected); } catch (err) { if (!cancelled) { const message = err instanceof Error ? err.message : "Failed to load gateway settings."; @@ -1106,14 +1115,25 @@ export const useGatewayConnection = ( token: persistToken, }, }; + const nextSnapshot = JSON.stringify({ + gatewayUrl: nextGatewayUrl, + token, + selectedAdapterType, + profiles: nextProfiles, + }); + if (lastScheduledGatewaySnapshotRef.current === nextSnapshot) { + return; + } if ( nextGatewayUrl === baseline.gatewayUrl && token === baseline.token && selectedAdapterType === baseline.adapterType && JSON.stringify(nextProfiles) === JSON.stringify(baseline.profiles ?? {}) ) { + lastScheduledGatewaySnapshotRef.current = nextSnapshot; return; } + lastScheduledGatewaySnapshotRef.current = nextSnapshot; settingsCoordinator.schedulePatch( { gateway: { @@ -1125,6 +1145,13 @@ export const useGatewayConnection = ( }, 400 ); + loadedGatewaySettings.current = { + gatewayUrl: nextGatewayUrl, + token, + adapterType: selectedAdapterType, + profiles: nextProfiles, + hasLastKnownGood: baseline.hasLastKnownGood, + }; }, [adapterProfiles, gatewayUrl, selectedAdapterType, settingsCoordinator, settingsLoaded, token]); const useLocalGatewayDefaults = useCallback(() => { diff --git a/src/lib/studio/coordinator.ts b/src/lib/studio/coordinator.ts index 2c3aa24..e492334 100644 --- a/src/lib/studio/coordinator.ts +++ b/src/lib/studio/coordinator.ts @@ -353,6 +353,7 @@ const mergeStudioPatch = ( ...(next.voiceReplies ? { voiceReplies: { ...next.voiceReplies } } : {}), ...(next.office ? { office: { ...next.office } } : {}), ...(next.standup ? { standup: { ...next.standup } } : {}), + ...(next.taskBoard ? { taskBoard: { ...next.taskBoard } } : {}), ...(next.officeFloors ? { officeFloors: { ...next.officeFloors } } : {}), }; }