|
|
||
|---|---|---|
| .. | ||
| app | ||
| components | ||
| lib | ||
| public | ||
| service | ||
| .gitignore | ||
| components.json | ||
| eslint.config.mjs | ||
| next.config.ts | ||
| package.json | ||
| postcss.config.mjs | ||
| README.md | ||
| tsconfig.json | ||
@multica/web
Next.js web client for Super Multica. This app is a thin shell — it contains only layout and page entry points. All business logic, state management, UI components, and network requests live in shared packages.
Architecture
apps/web/app/
├── layout.tsx — Root layout, setConfig(), providers
├── page.tsx — Page entry, renders <Chat />
└── icon.png — Favicon
Everything else comes from packages:
| Package | Responsibility | Examples |
|---|---|---|
@multica/store |
Global state (Zustand) | Hub, Messages, Gateway, DeviceId |
@multica/ui |
Components & UI hooks | Chat, HubSidebar, Skeleton, useScrollFade |
@multica/fetch |
HTTP client & URL config | consoleApi, setConfig() |
@multica/sdk |
WebSocket client | GatewayClient |
Where does new code go?
- Page-scoped UI hook (e.g. form toggle, scroll position) →
@multica/ui/hooks/ - Cross-component state (e.g. user preferences, notifications) →
@multica/store - Reusable component →
@multica/ui/components/ - HTTP request helper →
@multica/fetch - This app → Only if it's Next.js-specific (middleware, route handlers,
next.config)
Principle: desktop also consumes these packages, so anything reusable must NOT live in
apps/web.
Network Requests
Two communication channels, two packages:
HTTP → @multica/fetch (consoleApi) → Console :4000 (Hub, Agent CRUD)
WS → @multica/store (gateway) → Gateway :3000 (Chat messages)
Rules:
- Never hardcode URLs. Use
consoleApifor HTTP,useGatewayStorefor WS. Both read fromsetConfig()inlayout.tsx. - HTTP for management, WS for real-time. Creating/deleting agents is HTTP. Sending/receiving chat messages is WS.
- Future: gateway may proxy HTTP. The current two-endpoint setup may merge into one. Because all requests go through
@multica/fetchand@multica/store, business code won't need changes.
State Management
We use Zustand. Follow these rules:
Subscribe only to what you render
// Good — component re-renders only when status changes
const status = useHubStore((s) => s.status)
// Bad — re-renders on ANY store change
const { status } = useHubStore()
Use getState() in callbacks
Don't subscribe to state that's only used inside event handlers. Read it at call time instead.
// Good — no subscription, no re-render
const handleSend = useCallback((text: string) => {
const hub = useHubStore.getState().hub
const agentId = useHubStore.getState().activeAgentId
if (!hub?.hubId || !agentId) return
useMessagesStore.getState().addUserMessage(text, agentId)
useGatewayStore.getState().send(hub.hubId, "message", { agentId, content: text })
}, [])
// Bad — subscribes to hub and activeAgentId just to use them in onClick
const hub = useHubStore((s) => s.hub)
const activeAgentId = useHubStore((s) => s.activeAgentId)
Subscribe to derived values, not raw objects
// Good — re-renders only when the boolean flips
const isConnected = useHubStore((s) => s.status === "connected")
// Bad — re-renders when any field of hub changes
const hub = useHubStore((s) => s.hub)
const isConnected = hub !== null
Filter/derive with useMemo, not inside selectors
Selectors that return new references (.filter(), .map()) cause infinite re-renders. Derive outside the selector.
// Good
const messages = useMessagesStore((s) => s.messages)
const filtered = useMemo(
() => messages.filter((m) => m.agentId === activeAgentId),
[messages, activeAgentId]
)
// Bad — .filter() returns a new array every time, triggers infinite loop
const filtered = useMessagesStore((s) => s.messages.filter(...))
Initialize once
Side-effectful operations (WS connection, SDK init) must have guards to prevent double execution.
// Inside store
connect: (deviceId) => {
if (client) return // Already connected, skip
client = new GatewayClient(...)
client.connect()
}
Imports
Use direct paths for @multica/ui
// Good
import { Chat } from "@multica/ui/components/chat"
import { Button } from "@multica/ui/components/ui/button"
import { useScrollFade } from "@multica/ui/hooks/use-scroll-fade"
// Bad — barrel import pulls in everything
import { Chat, Button, useScrollFade } from "@multica/ui"
@multica/store barrel import is fine — it has few exports and all are lightweight Zustand stores.
Heavy components: use dynamic import
For large dependencies (code editors, chart libraries, PDF viewers), lazy-load to keep the initial bundle small.
import dynamic from "next/dynamic"
const CodeEditor = dynamic(
() => import("@multica/ui/components/code-editor"),
{ ssr: false }
)
Conditional Rendering
Use ternary expressions, not &&, to avoid rendering 0 or "" as visible content.
// Good
{status === "connected" ? <AgentList /> : null}
// Bad — if agents is 0, renders "0" on screen
{agents.length && <AgentList />}
Development
# Start web dev server (port 3001)
multica dev web
# Or start all services
multica dev
# Typecheck
cd apps/web && npx tsc --noEmit
Adding a New Feature — Checklist
- Does it need global state? → Create a store in
@multica/store - Does it need HTTP calls? → Use
consoleApifrom@multica/fetch - Does it need a UI component? → Add to
@multica/ui/components/ - Does it need a UI hook? → Add to
@multica/ui/hooks/ - Is it Next.js-specific? → Only then add to
apps/web - Is the component heavy (>50KB)? → Use
next/dynamicwith{ ssr: false }