refactor(web): unify hub + agent state into single Zustand store

Replace useState-based useHub hook and separate useActiveAgent store
with a single useHubStore (Zustand). This fixes the bug where
HubSidebar and Chat held independent state copies, causing stale data
and duplicate 30s polling. Agent create/delete logic now lives in the
store with automatic activeAgentId management.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Naiyuan Qing 2026-01-30 23:15:11 +08:00
parent cb28fbecc0
commit 403c44f536
6 changed files with 111 additions and 94 deletions

View file

@ -11,8 +11,7 @@ import { UserIcon, Copy01Icon, CheckmarkCircle02Icon } from "@hugeicons/core-fre
import { toast } from "@multica/ui/components/ui/sonner";
import { useMessages } from "../hooks/use-messages";
import { useGateway } from "../hooks/use-gateway";
import { useHub } from "../hooks/use-hub";
import { useActiveAgent } from "../hooks/use-active-agent";
import { useHubStore } from "../hooks/use-hub-store";
import { useDeviceId } from "../hooks/use-device-id";
import { useScrollFade } from "../hooks/use-scroll-fade";
import { cn } from "@multica/ui/lib/utils";
@ -25,9 +24,9 @@ const STATE_VARIANT: Record<string, "default" | "secondary" | "destructive" | "o
}
export function Chat() {
const activeAgentId = useActiveAgent((s) => s.activeAgentId)
const activeAgentId = useHubStore((s) => s.activeAgentId)
const hub = useHubStore((s) => s.hub)
const { messages, addUserMessage, addAssistantMessage } = useMessages()
const { hub } = useHub()
const { state: gwState, send } = useGateway({
onMessage: (msg) => {

View file

@ -11,8 +11,8 @@ import {
import { Button } from "@multica/ui/components/ui/button"
import { HugeiconsIcon } from "@hugeicons/react"
import { PlusSignIcon, Delete02Icon } from "@hugeicons/core-free-icons"
import { useHub } from "../hooks/use-hub"
import { useActiveAgent } from "../hooks/use-active-agent"
import { useHubStore } from "../hooks/use-hub-store"
import { useHubInit } from "../hooks/use-hub-init"
const STATUS_DOT: Record<string, string> = {
connected: "bg-green-500/60",
@ -29,9 +29,16 @@ const STATUS_LABEL: Record<string, string> = {
}
export function HubSidebar() {
const { status, hub, agents, fetchHub, createAgent, deleteAgent } = useHub()
const activeAgentId = useActiveAgent((s) => s.activeAgentId)
const setActiveAgentId = useActiveAgent((s) => s.setActiveAgentId)
useHubInit()
const status = useHubStore((s) => s.status)
const hub = useHubStore((s) => s.hub)
const agents = useHubStore((s) => s.agents)
const activeAgentId = useHubStore((s) => s.activeAgentId)
const fetchHub = useHubStore((s) => s.fetchHub)
const createAgent = useHubStore((s) => s.createAgent)
const deleteAgent = useHubStore((s) => s.deleteAgent)
const setActiveAgentId = useHubStore((s) => s.setActiveAgentId)
return (
<>
@ -60,13 +67,10 @@ export function HubSidebar() {
{status === "connected" && (
<SidebarGroup>
<SidebarGroupLabel>Agents</SidebarGroupLabel>
<SidebarGroupAction onClick={async () => {
const id = await createAgent()
if (id) setActiveAgentId(id)
}} title="Create agent">
<SidebarGroupAction onClick={createAgent} title="Create agent">
<HugeiconsIcon icon={PlusSignIcon} strokeWidth={2} className="size-4" />
</SidebarGroupAction>
<SidebarGroupContent className="flex flex-col gap-1">
<SidebarGroupContent>
<SidebarMenu>
{agents.length === 0 && (
<div className="px-2 py-2 text-xs text-muted-foreground/60">
@ -87,7 +91,6 @@ export function HubSidebar() {
<button
onClick={(e) => {
e.stopPropagation()
if (activeAgentId === agent.id) setActiveAgentId(null)
deleteAgent(agent.id)
}}
title="Delete agent"

View file

@ -1,11 +0,0 @@
import { create } from "zustand"
interface ActiveAgentState {
activeAgentId: string | null
setActiveAgentId: (id: string | null) => void
}
export const useActiveAgent = create<ActiveAgentState>()((set) => ({
activeAgentId: null,
setActiveAgentId: (id: string | null) => set({ activeAgentId: id }),
}))

View file

@ -0,0 +1,19 @@
"use client"
import { useEffect } from "react"
import { useHubStore } from "./use-hub-store"
export function useHubInit() {
const fetchHub = useHubStore((s) => s.fetchHub)
const status = useHubStore((s) => s.status)
const fetchAgents = useHubStore((s) => s.fetchAgents)
useEffect(() => { fetchHub() }, [fetchHub])
useEffect(() => {
if (status === "connected") fetchAgents()
}, [status, fetchAgents])
useEffect(() => {
const id = setInterval(fetchHub, 30_000)
return () => clearInterval(id)
}, [fetchHub])
}

View file

@ -0,0 +1,75 @@
import { create } from "zustand"
import { CONSOLE_URL } from "../lib/config"
interface HubInfo {
hubId: string
url: string
connectionState: string
agentCount: number
}
interface Agent {
id: string
closed: boolean
}
type HubStatus = "idle" | "loading" | "connected" | "error"
interface HubStore {
status: HubStatus
hub: HubInfo | null
agents: Agent[]
activeAgentId: string | null
setActiveAgentId: (id: string | null) => void
fetchHub: () => Promise<void>
fetchAgents: () => Promise<void>
createAgent: () => Promise<void>
deleteAgent: (id: string) => Promise<void>
}
export const useHubStore = create<HubStore>()((set, get) => ({
status: "idle",
hub: null,
agents: [],
activeAgentId: null,
setActiveAgentId: (id) => set({ activeAgentId: id }),
fetchHub: async () => {
set({ status: "loading" })
try {
const res = await fetch(`${CONSOLE_URL}/api/hub`)
if (!res.ok) throw new Error(res.statusText)
const data: HubInfo = await res.json()
set({
hub: data,
status: data.connectionState === "registered" ? "connected" : "error",
})
} catch {
set({ status: "error", hub: null })
}
},
fetchAgents: async () => {
try {
const res = await fetch(`${CONSOLE_URL}/api/agents`)
if (res.ok) set({ agents: await res.json() })
} catch { /* silent */ }
},
createAgent: async () => {
const res = await fetch(`${CONSOLE_URL}/api/agents`, { method: "POST" })
await get().fetchAgents()
if (res.ok) {
const data = await res.json()
if (data.id) set({ activeAgentId: data.id })
}
},
deleteAgent: async (id) => {
if (get().activeAgentId === id) set({ activeAgentId: null })
await fetch(`${CONSOLE_URL}/api/agents/${id}`, { method: "DELETE" })
await get().fetchAgents()
},
}))

View file

@ -1,68 +0,0 @@
import { useState, useCallback, useEffect } from "react"
import { CONSOLE_URL } from "../lib/config"
interface HubInfo {
hubId: string
url: string
connectionState: string
agentCount: number
}
interface Agent {
id: string
closed: boolean
}
type HubStatus = "idle" | "loading" | "connected" | "error"
export function useHub() {
const [status, setStatus] = useState<HubStatus>("idle")
const [hub, setHub] = useState<HubInfo | null>(null)
const [agents, setAgents] = useState<Agent[]>([])
const fetchHub = useCallback(async () => {
setStatus("loading")
try {
const res = await fetch(`${CONSOLE_URL}/api/hub`)
if (!res.ok) throw new Error(res.statusText)
const data: HubInfo = await res.json()
setHub(data)
setStatus(data.connectionState === "registered" ? "connected" : "error")
} catch {
setStatus("error")
setHub(null)
}
}, [])
const fetchAgents = useCallback(async () => {
try {
const res = await fetch(`${CONSOLE_URL}/api/agents`)
if (res.ok) setAgents(await res.json())
} catch { /* silent */ }
}, [])
const createAgent = useCallback(async (): Promise<string | null> => {
const res = await fetch(`${CONSOLE_URL}/api/agents`, { method: "POST" })
await fetchAgents()
if (!res.ok) return null
const data = await res.json()
return data.id ?? null
}, [fetchAgents])
const deleteAgent = useCallback(async (id: string) => {
await fetch(`${CONSOLE_URL}/api/agents/${id}`, { method: "DELETE" })
await fetchAgents()
}, [fetchAgents])
// Auto-fetch hub on mount, agents when connected, poll every 30s
useEffect(() => { fetchHub() }, [fetchHub])
useEffect(() => {
if (status === "connected") fetchAgents()
}, [status, fetchAgents])
useEffect(() => {
const id = setInterval(fetchHub, 30_000)
return () => clearInterval(id)
}, [fetchHub])
return { status, hub, agents, fetchHub, createAgent, deleteAgent }
}