multica/apps/web/features/realtime/use-realtime-sync.ts
Naiyuan Qing 2cf088ddf6 feat: resizable sidebar, issue detail rewrite, package consolidation
- Add drag-to-resize sidebar with localStorage persistence
- Rewrite issue detail page with Tiptap rich text editor, due date picker, acceptance criteria
- Redesign create-issue modal with pill-based property toolbar and expand/collapse
- Consolidate @multica/sdk and @multica/types into apps/web/shared/
- Simplify auth: remove verification codes, PATs, email service (dev-only login)
- Add 401 unauthorized handler to redirect expired sessions to login
- Fix due date format to send full RFC3339 timestamps
- Increase description editor debounce to 1500ms
- Remove arbitrary Tailwind values in create-issue modal
- Renumber migrations (inbox_actor 012→009), remove unused migrations
- UI polish across agents, settings, inbox, knowledge-base pages

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 16:47:04 +08:00

185 lines
6 KiB
TypeScript

"use client";
import { useEffect } from "react";
import type { WSClient } from "@/shared/api";
import { toast } from "sonner";
import { useIssueStore } from "@/features/issues";
import { useInboxStore } from "@/features/inbox";
import { useWorkspaceStore } from "@/features/workspace";
import { useAuthStore } from "@/features/auth";
import { createLogger } from "@/shared/logger";
import type {
IssueCreatedPayload,
IssueUpdatedPayload,
IssueDeletedPayload,
AgentStatusPayload,
AgentCreatedPayload,
InboxNewPayload,
InboxReadPayload,
InboxArchivedPayload,
WorkspaceUpdatedPayload,
WorkspaceDeletedPayload,
MemberAddedPayload,
MemberUpdatedPayload,
MemberRemovedPayload,
} from "@/shared/types";
const logger = createLogger("realtime-sync");
/**
* Centralized WS → store sync. Called once from WSProvider.
* Subscribes to all global WS events and dispatches to Zustand stores.
* Comment events are NOT handled here — they stay per-page on issue detail.
*/
export function useRealtimeSync(ws: WSClient | null) {
// Issue events → useIssueStore
useEffect(() => {
if (!ws) return;
const unsubs = [
ws.on("issue:created", (p) => {
const { issue } = p as IssueCreatedPayload;
useIssueStore.getState().addIssue(issue);
}),
ws.on("issue:updated", (p) => {
const { issue } = p as IssueUpdatedPayload;
useIssueStore.getState().updateIssue(issue.id, issue);
useInboxStore.getState().updateIssueStatus(issue.id, issue.status);
}),
ws.on("issue:deleted", (p) => {
const { issue_id } = p as IssueDeletedPayload;
useIssueStore.getState().removeIssue(issue_id);
}),
];
return () => unsubs.forEach((u) => u());
}, [ws]);
// Inbox events → useInboxStore
useEffect(() => {
if (!ws) return;
const unsubs = [
ws.on("inbox:new", (p) => {
const { item } = p as InboxNewPayload;
const myUserId = useAuthStore.getState().user?.id;
// Only add if I'm the recipient (WS broadcasts to all workspace members)
if (item.recipient_type === "member" && item.recipient_id === myUserId) {
useInboxStore.getState().addItem(item);
}
}),
ws.on("inbox:read", (p) => {
const { item_id } = p as InboxReadPayload;
useInboxStore.getState().markRead(item_id);
}),
ws.on("inbox:archived", (p) => {
const { item_id } = p as InboxArchivedPayload;
useInboxStore.getState().archive(item_id);
}),
ws.on("inbox:batch-read", () => {
useInboxStore.getState().markAllRead();
}),
ws.on("inbox:batch-archived", () => {
useInboxStore.getState().fetch();
}),
];
return () => unsubs.forEach((u) => u());
}, [ws]);
// Agent events → workspace store
useEffect(() => {
if (!ws) return;
const unsubs = [
ws.on("agent:status", (p) => {
const { agent } = p as AgentStatusPayload;
useWorkspaceStore.getState().updateAgent(agent.id, agent);
}),
ws.on("agent:created", (p) => {
const { agent } = p as AgentCreatedPayload;
const agents = useWorkspaceStore.getState().agents;
if (!agents.find((a) => a.id === agent.id)) {
useWorkspaceStore.getState().refreshAgents();
}
}),
ws.on("agent:deleted", () => {
useWorkspaceStore.getState().refreshAgents();
}),
];
return () => unsubs.forEach((u) => u());
}, [ws]);
// Workspace + member events → useWorkspaceStore
useEffect(() => {
if (!ws) return;
const unsubs = [
ws.on("workspace:updated", (p) => {
const { workspace } = p as WorkspaceUpdatedPayload;
logger.debug("workspace:updated", workspace.name);
useWorkspaceStore.getState().updateWorkspace(workspace);
}),
ws.on("workspace:deleted", (p) => {
const { workspace_id } = p as WorkspaceDeletedPayload;
const currentWs = useWorkspaceStore.getState().workspace;
if (currentWs?.id === workspace_id) {
logger.warn("current workspace deleted, switching");
toast.info("This workspace was deleted");
useWorkspaceStore.getState().refreshWorkspaces();
}
}),
ws.on("member:updated", (p) => {
const payload = p as MemberUpdatedPayload;
logger.debug("member:updated", payload.member.email, payload.member.role);
useWorkspaceStore.getState().refreshMembers();
}),
ws.on("member:added", (p) => {
const payload = p as MemberAddedPayload;
const myUserId = useAuthStore.getState().user?.id;
logger.debug("member:added", payload.member.email);
if (payload.member.user_id === myUserId) {
// I was invited to a workspace — refresh list so it appears
useWorkspaceStore.getState().refreshWorkspaces();
} else {
useWorkspaceStore.getState().refreshMembers();
}
}),
ws.on("member:removed", (p) => {
const payload = p as MemberRemovedPayload;
const myUserId = useAuthStore.getState().user?.id;
logger.debug("member:removed", payload.user_id);
if (payload.user_id === myUserId) {
logger.warn("removed from workspace, switching");
toast.info("You were removed from this workspace");
useWorkspaceStore.getState().refreshWorkspaces();
} else {
useWorkspaceStore.getState().refreshMembers();
}
}),
];
return () => unsubs.forEach((u) => u());
}, [ws]);
// Reconnect → refetch all data to recover missed events
useEffect(() => {
if (!ws) return;
const unsub = ws.onReconnect(async () => {
logger.info("reconnected, refetching all data");
try {
await Promise.all([
useIssueStore.getState().fetch(),
useInboxStore.getState().fetch(),
useWorkspaceStore.getState().refreshAgents(),
]);
} catch {
// Silently fail; next reconnect will retry
}
});
return unsub;
}, [ws]);
}