chore(store): remove unused Zustand stores and slim down package

After the chat refactoring moved state management to @multica/hooks,
the Zustand stores (useConnectionStore, useMessagesStore, useAutoConnect)
are no longer imported by any application code. This removes them along
with their unused dependencies (zustand, uuid, react).

- Delete connection-store.ts, messages.ts, use-auto-connect.ts
- Extract Message/ToolStatus types into types.ts (preserves UI imports)
- Remove saveConnection/loadConnection/clearConnection from connection.ts
- Drop zustand, uuid, react deps from package.json

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Naiyuan Qing 2026-02-05 18:41:31 +08:00
parent c2b5ada2ef
commit 1fe27a59d0
8 changed files with 27 additions and 624 deletions

View file

@ -8,13 +8,9 @@
"./*": "./src/*.ts"
},
"dependencies": {
"@multica/sdk": "workspace:*",
"react": "catalog:",
"uuid": "^13.0.0",
"zustand": "catalog:"
"@multica/sdk": "workspace:*"
},
"devDependencies": {
"@types/react": "catalog:",
"typescript": "catalog:"
}
}

View file

@ -1,324 +0,0 @@
/**
* Connection Store - manages WebSocket connection lifecycle
*
* Responsibilities:
* 1. Persist deviceId (auto-generated on first run, restored from localStorage)
* 2. Establish WebSocket connection to Gateway using connection code (from QR/paste)
* 3. Maintain connection state (disconnected connecting connected registered)
* 4. Route incoming stream messages from Hub to MessagesStore
* 5. Provide send() for MessagesStore to send messages
*
* Data flow:
* connection code connect() GatewayClient(Socket.io) Gateway server
*
* onMessage callback MessagesStore
*/
import { create } from "zustand"
import { persist } from "zustand/middleware"
import { v7 as uuidv7 } from "uuid"
import {
GatewayClient,
StreamAction,
type ConnectionState,
type StreamPayload,
type AgentEvent,
type CompactionEndEvent,
type GetAgentMessagesResult,
type ContentBlock,
} from "@multica/sdk"
import { useMessagesStore, type Message } from "./messages"
import { clearConnection, type ConnectionInfo } from "./connection"
interface ConnectionStoreState {
deviceId: string
gatewayUrl: string | null
hubId: string | null
agentId: string | null
connectionState: ConnectionState
lastError: { code: string; message: string } | null
/** Whether the current connection required Owner approval (new device) */
isNewDevice: boolean | null
}
interface ConnectionStoreActions {
connect: (code: ConnectionInfo) => void
disconnect: () => void
send: (to: string, action: string, payload: unknown) => void
}
export type ConnectionStore = ConnectionStoreState & ConnectionStoreActions
// Module-level singleton — only one WebSocket connection per app
let client: GatewayClient | null = null
/**
* Create a GatewayClient and bind message-handling callbacks.
*
* GatewayClient is defined in packages/sdk/src/client.ts
* It wraps Socket.io and exposes:
* - connect() establish WebSocket connection
* - disconnect() tear down connection
* - send(to, action, payload) send message to a specific device
* - request(to, method, params) send RPC request and await response
* - onStateChange(cb) listen for connection state changes
* - onMessage(cb) listen for incoming messages
* - onSendError(cb) listen for send failures
* - isRegistered / isConnected connection state checks
*
* Connection requires two params:
* - url: Gateway server address (from connection code's gateway field)
* - deviceId: unique device identifier (persisted in this store)
*
* Sending messages requires two routing params:
* - hubId: which Hub to send to (from connection code)
* - agentId: which Agent within the Hub (from connection code)
*/
function createClient(
url: string,
deviceId: string,
hubId: string,
token: string,
set: (s: Partial<ConnectionStoreState>) => void,
getState: () => ConnectionStoreState,
): GatewayClient {
return new GatewayClient({
url,
deviceId,
deviceType: "client",
hubId,
token,
})
// Sync connection state changes to the store
.onStateChange((connectionState) => {
set({ connectionState })
// Fetch message history after successful registration
if (connectionState === "registered") {
void fetchHistory(getState())
}
})
// Route incoming messages to MessagesStore
.onMessage((msg) => {
// Streaming messages: Agent replies arrive in chunks
if (msg.action === StreamAction) {
const payload = msg.payload as StreamPayload
const store = useMessagesStore.getState()
const { event } = payload
switch (event.type) {
case "message_start": {
store.startStream(payload.streamId, payload.agentId)
const content = extractContent(event)
if (content.length) store.appendStream(payload.streamId, content)
break
}
case "message_update": {
const content = extractContent(event)
store.appendStream(payload.streamId, content)
break
}
case "message_end": {
const content = extractContent(event)
const stopReason = "message" in event
? (event.message as { stopReason?: string })?.stopReason
: undefined
store.endStream(payload.streamId, content, stopReason)
break
}
case "tool_execution_start": {
store.startToolExecution(
payload.agentId,
event.toolCallId,
event.toolName,
event.args,
)
break
}
case "tool_execution_end": {
store.endToolExecution(
event.toolCallId,
event.result,
event.isError,
)
break
}
case "tool_execution_update":
// Partial results — not rendered yet, ignored for now
break
case "compaction_start": {
store.startCompaction()
break
}
case "compaction_end": {
const evt = event as CompactionEndEvent
store.endCompaction({
removed: evt.removed,
kept: evt.kept,
tokensRemoved: evt.tokensRemoved,
tokensKept: evt.tokensKept,
reason: evt.reason,
})
break
}
}
return
}
// Handle error messages from Hub (e.g. UNAUTHORIZED)
if (msg.action === "error") {
const payload = msg.payload as { code: string; message: string }
set({ lastError: { code: payload.code, message: payload.message } })
return
}
// Handle direct (non-streaming) messages
const payload = msg.payload as { agentId?: string; content?: string }
if (payload?.agentId && payload?.content) {
useMessagesStore.getState().addAssistantMessage(payload.content, payload.agentId)
}
})
.onVerified((result) => set({ isNewDevice: result.isNewDevice ?? false }))
.onError((error) => set({ lastError: { code: "VERIFY_ERROR", message: error.message } }))
.onSendError((error) => set({ lastError: { code: error.code, message: error.error } }))
}
/** Fetch message history from Hub via RPC after connection is established */
async function fetchHistory(state: ConnectionStoreState): Promise<void> {
const { hubId, agentId } = state
if (!client || !hubId || !agentId) return
try {
const result = await client.request<GetAgentMessagesResult>(
hubId, "getAgentMessages", { agentId, limit: 200 },
)
// Build a lookup map: toolCallId → { name, arguments } from assistant ToolCall blocks
const toolCallArgsMap = new Map<string, { name: string; args: Record<string, unknown> }>()
for (const m of result.messages) {
if (m.role === "assistant") {
for (const block of m.content) {
if (block.type === "toolCall") {
toolCallArgsMap.set(block.id, { name: block.name, args: block.arguments })
}
}
}
}
// Mirror the backend message array directly
const messages: Message[] = []
for (const m of result.messages) {
if (m.role === "user") {
messages.push({
id: uuidv7(),
role: "user",
content: toContentBlocks(m.content),
agentId,
})
} else if (m.role === "assistant") {
messages.push({
id: uuidv7(),
role: "assistant",
content: toContentBlocks(m.content),
agentId,
stopReason: m.stopReason,
})
} else if (m.role === "toolResult") {
const callInfo = toolCallArgsMap.get(m.toolCallId)
messages.push({
id: uuidv7(),
role: "toolResult",
content: toContentBlocks(m.content),
agentId,
toolCallId: m.toolCallId,
toolName: m.toolName,
toolArgs: callInfo?.args,
toolStatus: m.isError ? "error" : "success",
isError: m.isError,
})
}
}
if (messages.length > 0) {
useMessagesStore.getState().loadMessages(messages)
}
} catch {
// History fetch is best-effort — connection still works without it
}
}
/** Convert raw backend content (string or block array) to ContentBlock[] */
function toContentBlocks(content: string | ContentBlock[]): ContentBlock[] {
if (typeof content === "string") {
return content ? [{ type: "text", text: content }] : []
}
if (Array.isArray(content)) return content
return []
}
/** Extract content blocks from an AgentEvent that carries a message */
function extractContent(event: AgentEvent): ContentBlock[] {
if (!("message" in event)) return []
const msg = event.message
if (!msg || !("content" in msg)) return []
const content = msg.content
return Array.isArray(content) ? content as ContentBlock[] : []
}
export const useConnectionStore = create<ConnectionStore>()(
persist(
(set, get) => ({
deviceId: uuidv7(),
gatewayUrl: null,
hubId: null,
agentId: null,
connectionState: "disconnected",
lastError: null,
isNewDevice: null,
// Connect using a connection code (disconnect existing connection first)
connect: (code) => {
if (client) {
client.disconnect()
client = null
}
set({
gatewayUrl: code.gateway,
hubId: code.hubId,
agentId: code.agentId,
})
client = createClient(code.gateway, get().deviceId, code.hubId, code.token, set, get)
client.connect()
},
// Disconnect and clear all state (messages + saved connection code)
disconnect: () => {
if (client) {
client.disconnect()
client = null
}
useMessagesStore.getState().clearMessages()
clearConnection()
set({
connectionState: "disconnected",
gatewayUrl: null,
hubId: null,
agentId: null,
lastError: null,
isNewDevice: null,
})
},
// Send a message to a target device (called by MessagesStore.sendMessage)
send: (to, action, payload) => {
if (!client?.isRegistered) return
client.send(to, action, payload)
},
}),
{
name: "multica-device",
// Only persist deviceId — other fields are runtime state
partialize: (state) => ({ deviceId: state.deviceId }),
},
),
)

View file

@ -1,5 +1,3 @@
const STORAGE_KEY = "multica-connection"
export interface ConnectionInfo {
type: "multica-connect"
gateway: string
@ -88,29 +86,3 @@ export function parseConnectionCode(input: string): ConnectionInfo {
return parsed
}
export function saveConnection(info: ConnectionInfo): void {
localStorage.setItem(STORAGE_KEY, JSON.stringify(info))
}
export function loadConnection(): ConnectionInfo | null {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return null
try {
const info = JSON.parse(raw)
if (!isConnectionInfo(info)) return null
if (isExpired(info.expires)) {
localStorage.removeItem(STORAGE_KEY)
return null
}
return info
} catch {
localStorage.removeItem(STORAGE_KEY)
return null
}
}
export function clearConnection(): void {
localStorage.removeItem(STORAGE_KEY)
}

View file

@ -1,7 +1,3 @@
export { useConnectionStore } from "./connection-store"
export type { ConnectionStore } from "./connection-store"
export { useAutoConnect } from "./use-auto-connect"
export { useMessagesStore } from "./messages"
export type { Message, MessagesStore, SendContext, ToolStatus, CompactionStats } from "./messages"
export { parseConnectionCode, saveConnection, loadConnection, clearConnection } from "./connection"
export type { Message, ToolStatus } from "./types"
export { parseConnectionCode } from "./connection"
export type { ConnectionInfo } from "./connection"

View file

@ -1,208 +0,0 @@
/**
* Messages Store - manages chat messages and streaming state
*
* Data model mirrors the backend (pi-ai / pi-agent-core) exactly:
* - UserMessage: { role: "user", content: ContentBlock[] }
* - AssistantMessage: { role: "assistant", content: ContentBlock[] }
* - ToolResultMessage: { role: "toolResult", toolCallId, toolName, content, isError }
*
* Streaming simply updates the content of the current assistant message in-place.
* Tool execution events (start/end) create / update toolResult messages.
*/
import { create } from "zustand"
import { v7 as uuidv7 } from "uuid"
import type { ContentBlock } from "@multica/sdk"
export type ToolStatus = "running" | "success" | "error" | "interrupted"
export interface CompactionStats {
removed: number
kept: number
tokensRemoved?: number
tokensKept?: number
reason: string
}
export interface Message {
id: string
role: "user" | "assistant" | "toolResult"
content: ContentBlock[]
agentId: string
// AssistantMessage metadata
stopReason?: string
// ToolResult fields (only when role === "toolResult")
toolCallId?: string
toolName?: string
toolArgs?: Record<string, unknown>
toolStatus?: ToolStatus
isError?: boolean
}
/** Parameters needed to route a message through the gateway */
export interface SendContext {
hubId: string
agentId: string
send: (to: string, action: string, payload: unknown) => void
}
interface MessagesState {
messages: Message[]
streamingIds: Set<string>
compacting: boolean
lastCompaction: CompactionStats | null
}
interface MessagesActions {
sendMessage: (text: string, ctx: SendContext) => void
addUserMessage: (content: string, agentId: string) => void
addAssistantMessage: (content: string, agentId: string) => void
updateMessage: (id: string, content: ContentBlock[]) => void
loadMessages: (msgs: Message[]) => void
clearMessages: () => void
// Streaming
startStream: (streamId: string, agentId: string) => void
appendStream: (streamId: string, content: ContentBlock[]) => void
endStream: (streamId: string, content: ContentBlock[], stopReason?: string) => void
// Tool execution lifecycle
startToolExecution: (agentId: string, toolCallId: string, toolName: string, args?: unknown) => void
endToolExecution: (toolCallId: string, result?: unknown, isError?: boolean) => void
// Compaction lifecycle
startCompaction: () => void
endCompaction: (stats: CompactionStats) => void
}
export type MessagesStore = MessagesState & MessagesActions
export const useMessagesStore = create<MessagesStore>()((set, get) => ({
messages: [],
streamingIds: new Set<string>(),
compacting: false,
lastCompaction: null,
sendMessage: (text, ctx) => {
get().addUserMessage(text, ctx.agentId)
ctx.send(ctx.hubId, "message", { agentId: ctx.agentId, content: text })
},
addUserMessage: (content, agentId) => {
set((s) => ({
messages: [...s.messages, {
id: uuidv7(),
role: "user",
content: [{ type: "text" as const, text: content }],
agentId,
}],
}))
},
addAssistantMessage: (content, agentId) => {
set((s) => ({
messages: [...s.messages, {
id: uuidv7(),
role: "assistant",
content: [{ type: "text" as const, text: content }],
agentId,
}],
}))
},
updateMessage: (id, content) => {
set((s) => ({
messages: s.messages.map((m) => (m.id === id ? { ...m, content } : m)),
}))
},
loadMessages: (msgs) => {
set({ messages: msgs })
},
clearMessages: () => {
set({ messages: [], streamingIds: new Set(), compacting: false, lastCompaction: null })
},
// --- Streaming: build assistant message incrementally ---
startStream: (streamId, agentId) => {
set((s) => {
const ids = new Set(s.streamingIds)
ids.add(streamId)
return {
messages: [...s.messages, { id: streamId, role: "assistant" as const, content: [], agentId }],
streamingIds: ids,
}
})
},
// Replace the entire content array with the latest partial snapshot
appendStream: (streamId, content) => {
set((s) => ({
messages: s.messages.map((m) => (m.id === streamId ? { ...m, content } : m)),
}))
},
endStream: (streamId, content, stopReason) => {
set((s) => {
const ids = new Set(s.streamingIds)
ids.delete(streamId)
// Find the agentId of the stream being ended to scope tool interruption
const streamMsg = s.messages.find((m) => m.id === streamId)
const streamAgentId = streamMsg?.agentId
return {
messages: s.messages.map((m) => {
if (m.id === streamId) return { ...m, content, stopReason }
// Interrupt running tool executions belonging to the same agent
if (m.role === "toolResult" && m.toolStatus === "running" && m.agentId === streamAgentId) {
return { ...m, toolStatus: "interrupted" as ToolStatus }
}
return m
}),
streamingIds: ids,
}
})
},
// --- Tool execution: create / update toolResult messages ---
startToolExecution: (agentId, toolCallId, toolName, args) => {
set((s) => ({
messages: [...s.messages, {
id: uuidv7(),
role: "toolResult" as const,
content: [],
agentId,
toolCallId,
toolName,
toolArgs: args as Record<string, unknown> | undefined,
toolStatus: "running" as ToolStatus,
isError: false,
}],
}))
},
endToolExecution: (toolCallId, result, isError) => {
set((s) => ({
messages: s.messages.map((m) =>
m.role === "toolResult" && m.toolCallId === toolCallId
? {
...m,
toolStatus: (isError ? "error" : "success") as ToolStatus,
isError: isError ?? false,
content: result != null
? [{ type: "text" as const, text: typeof result === "string" ? result : JSON.stringify(result) }]
: [],
}
: m
),
}))
},
// --- Compaction lifecycle ---
startCompaction: () => {
set({ compacting: true })
},
endCompaction: (stats) => {
set({ compacting: false, lastCompaction: stats })
},
}))

View file

@ -0,0 +1,16 @@
import type { ContentBlock } from "@multica/sdk"
export type ToolStatus = "running" | "success" | "error" | "interrupted"
export interface Message {
id: string
role: "user" | "assistant" | "toolResult"
content: ContentBlock[]
agentId: string
stopReason?: string
toolCallId?: string
toolName?: string
toolArgs?: Record<string, unknown>
toolStatus?: ToolStatus
isError?: boolean
}

View file

@ -1,33 +0,0 @@
"use client"
import { useState, useEffect } from "react"
import { useConnectionStore } from "./connection-store"
import { loadConnection } from "./connection"
/** Auto-connect from saved connection code on mount, skip if already connected */
export function useAutoConnect(): { loading: boolean } {
const connectionState = useConnectionStore((s) => s.connectionState)
const [loading, setLoading] = useState(true)
useEffect(() => {
const state = useConnectionStore.getState()
if (state.connectionState !== "disconnected") {
setLoading(false)
return
}
const saved = loadConnection()
if (saved) {
state.connect(saved)
} else {
setLoading(false)
}
}, [])
useEffect(() => {
if (connectionState !== "disconnected") {
setLoading(false)
}
}, [connectionState])
return { loading }
}

28
pnpm-lock.yaml generated
View file

@ -34,13 +34,13 @@ importers:
dependencies:
'@mariozechner/pi-agent-core':
specifier: ^0.50.3
version: 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@4.3.6))(ws@8.18.3)(zod@4.3.6)
version: 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@3.25.76))(ws@8.18.3)(zod@3.25.76)
'@mariozechner/pi-ai':
specifier: ^0.50.3
version: 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@4.3.6))(ws@8.18.3)(zod@4.3.6)
version: 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@3.25.76))(ws@8.18.3)(zod@3.25.76)
'@mariozechner/pi-coding-agent':
specifier: ^0.50.3
version: 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@4.3.6))(ws@8.18.3)(zod@4.3.6)
version: 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@3.25.76))(ws@8.18.3)(zod@3.25.76)
'@mozilla/readability':
specifier: ^0.6.0
version: 0.6.0
@ -457,10 +457,10 @@ importers:
devDependencies:
'@mariozechner/pi-agent-core':
specifier: ^0.50.3
version: 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@3.25.76))(ws@8.18.3)(zod@3.25.76)
version: 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@4.3.6))(ws@8.18.3)(zod@4.3.6)
'@mariozechner/pi-ai':
specifier: ^0.50.3
version: 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@3.25.76))(ws@8.18.3)(zod@3.25.76)
version: 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@4.3.6))(ws@8.18.3)(zod@4.3.6)
'@types/uuid':
specifier: ^11.0.0
version: 11.0.0
@ -473,19 +473,7 @@ importers:
'@multica/sdk':
specifier: workspace:*
version: link:../sdk
react:
specifier: 'catalog:'
version: 19.2.3
uuid:
specifier: ^13.0.0
version: 13.0.0
zustand:
specifier: 'catalog:'
version: 5.0.10(@types/react@19.1.17)(react@19.2.3)(use-sync-external-store@1.6.0(react@19.2.3))
devDependencies:
'@types/react':
specifier: 'catalog:'
version: 19.1.17
typescript:
specifier: 'catalog:'
version: 5.9.3
@ -11596,12 +11584,12 @@ snapshots:
- ws
- zod
'@mariozechner/pi-coding-agent@0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@4.3.6))(ws@8.18.3)(zod@4.3.6)':
'@mariozechner/pi-coding-agent@0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@3.25.76))(ws@8.18.3)(zod@3.25.76)':
dependencies:
'@mariozechner/clipboard': 0.3.0
'@mariozechner/jiti': 2.6.5
'@mariozechner/pi-agent-core': 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@4.3.6))(ws@8.18.3)(zod@4.3.6)
'@mariozechner/pi-ai': 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@4.3.6))(ws@8.18.3)(zod@4.3.6)
'@mariozechner/pi-agent-core': 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@3.25.76))(ws@8.18.3)(zod@3.25.76)
'@mariozechner/pi-ai': 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@3.25.76))(ws@8.18.3)(zod@3.25.76)
'@mariozechner/pi-tui': 0.50.3
'@silvia-odwyer/photon-node': 0.3.4
chalk: 5.6.2