refactor: migrate stores to features/, remove dead packages, add modals + workspace sync
## Store migration (packages → features) - Delete `packages/store/` — stores moved into web app's feature modules - Delete `packages/hooks/` — replaced by feature-level hooks - `features/issues/store.ts` — useIssueStore (was packages/store/issue-store) - `features/inbox/store.ts` — useInboxStore (was packages/store/inbox-store) - `features/workspace/store.ts` — absorbs agent state (was packages/store/agent-store) - All imports updated from `@multica/store` → `@/features/*/store` ## Global modal system - `features/modals/store.ts` — useModalStore (zustand) - `features/modals/registry.tsx` — ModalRegistry renders active modal - Mounted in app/layout.tsx alongside Toaster - Create Workspace dialog now works (was broken: DropdownMenu ate click) ## Workspace real-time sync - useRealtimeSync subscribes to workspace:updated, member:removed - Member removal → auto-switch to another workspace - Workspace settings update → sidebar reflects name change - Workspace switch → parallel fetch issues + inbox + agents ## Bug fixes - theme-provider: guard event.key for IME composition (isComposing check) - task.go: publish comment:created + inbox:new events on task complete/fail - listeners.go: broadcast comment:created, workspace:updated, member events - events.go: add EventCommentUpdated, EventCommentDeleted constants ## Cleanup - Remove _features/ tracking files (dev-only, not for main) - Remove server/server binary from worktree - Update CLAUDE.md to reflect new architecture Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
0c52b89e40
commit
66b1defab7
48 changed files with 607 additions and 861 deletions
11
CLAUDE.md
11
CLAUDE.md
|
|
@ -37,14 +37,15 @@ apps/web/
|
|||
|---|---|---|
|
||||
| `features/auth/` | Authentication state | `useAuthStore`, `AuthInitializer` |
|
||||
| `features/workspace/` | Workspace, members, agents | `useWorkspaceStore`, `useActorName` |
|
||||
| `features/issues/` | Issue components and config | Icons, pickers, status/priority config |
|
||||
| `features/realtime/` | WebSocket connection | `WSProvider`, `useWSEvent` |
|
||||
| `features/issues/` | Issue state, components, config | `useIssueStore`, icons, pickers, status/priority config |
|
||||
| `features/inbox/` | Inbox notification state | `useInboxStore` |
|
||||
| `features/realtime/` | WebSocket connection + sync | `WSProvider`, `useWSEvent`, `useRealtimeSync` |
|
||||
|
||||
**`shared/`** — Code used across multiple features. Currently only `api.ts` (SDK singleton).
|
||||
|
||||
### State Management
|
||||
|
||||
- **Zustand** for global client state (`features/auth/store.ts`, `features/workspace/store.ts`).
|
||||
- **Zustand** for global client state — one store per feature domain (`features/auth/store.ts`, `features/workspace/store.ts`, `features/issues/store.ts`, `features/inbox/store.ts`).
|
||||
- **React Context** only for connection lifecycle (`WSProvider` in `features/realtime/`).
|
||||
- **Local `useState`** for component-scoped UI state (forms, modals, filters).
|
||||
- Do not use React Context for data that can be a zustand store.
|
||||
|
|
@ -62,6 +63,8 @@ Use `@/` alias (maps to `apps/web/`):
|
|||
import { api } from "@/shared/api";
|
||||
import { useAuthStore } from "@/features/auth";
|
||||
import { useWorkspaceStore } from "@/features/workspace";
|
||||
import { useIssueStore } from "@/features/issues";
|
||||
import { useInboxStore } from "@/features/inbox";
|
||||
import { useWSEvent } from "@/features/realtime";
|
||||
import { StatusIcon } from "@/features/issues/components";
|
||||
```
|
||||
|
|
@ -92,9 +95,7 @@ Browser ← WSClient (SDK) ← WebSocket ← Hub.Broadcast() ← Handlers/TaskSe
|
|||
|
||||
- **`@multica/sdk`**: `ApiClient` (REST) and `WSClient` (WebSocket) classes. All backend communication goes through here.
|
||||
- **`@multica/types`**: Shared domain types + WebSocket event types (issue:created/updated/deleted, task:*, agent:status, comment:*, inbox:new, daemon:*).
|
||||
- **`@multica/store`**: Zustand stores — simple arrays with add/update/remove. No persistence; memory only.
|
||||
- **`@multica/ui`**: shadcn/ui component library with Radix primitives, Tailwind CSS 4, Shiki syntax highlighting for markdown.
|
||||
- **`@multica/hooks`**: `useRealtime()` (WS → store sync), `useIssues()`, `useAgents()`, `useInbox()` (fetch + cache).
|
||||
|
||||
### Multi-tenancy
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +0,0 @@
|
|||
[
|
||||
{ "id": "infra-event-bus-ws", "status": "done", "name": "Infrastructure: Event Bus + WS Isolation + Global Store" },
|
||||
{ "id": "issue-board-polish", "status": "done", "name": "Issue Board & Detail Polish" },
|
||||
{ "id": "workspace-permissions", "status": "done", "name": "Workspace & Permissions" },
|
||||
{ "id": "inbox-notifications", "status": "done", "name": "Inbox & Notifications" }
|
||||
]
|
||||
|
|
@ -1,81 +0,0 @@
|
|||
{
|
||||
"id": "inbox-notifications",
|
||||
"name": "Inbox & Notifications",
|
||||
"status": "done",
|
||||
"createdAt": "2026-03-25",
|
||||
"completedAt": "2026-03-25",
|
||||
"description": "Complete inbox notification system: sidebar unread badge, notification triggers for all key actions, archive UI, issue navigation from notifications, and real-time sync across tabs.",
|
||||
"currentState": "All tasks done. Backend: unread-count endpoint, comment notifies assignee, status change notifies creator, unassign notifies old assignee, mark read/archive broadcast WS events. Frontend: SDK types fixed, sidebar unread badge, View Issue link, archive button, real-time sync via useRealtimeSync. UI polish complete: inbox loading/empty states use shadcn components.",
|
||||
"decisions": [
|
||||
"Inbox uses useInboxStore from global store (not page-local useState)",
|
||||
"Sidebar badge reads unread count from store, updated by WS events",
|
||||
"Backend adds GET /api/inbox/unread-count endpoint using existing CountUnreadInbox SQL query",
|
||||
"Mark read and archive broadcast WS events (inbox:read, inbox:archived) for cross-tab sync",
|
||||
"New notification triggers: comment on assigned issue, status change notifies creator, unassign notifies old assignee",
|
||||
"SDK markInboxRead and archiveInbox return Promise<InboxItem> not Promise<void>",
|
||||
"Inbox detail shows View Issue link when issue_id present, and Archive button",
|
||||
"3 tasks auto-completed by infra migration: WS events, TS types, realtime sync"
|
||||
],
|
||||
"tasks": [
|
||||
{
|
||||
"task": "Backend: Add GET /api/inbox/unread-count endpoint",
|
||||
"done": true,
|
||||
"scope": "CountUnreadInbox handler using existing SQL query. Returns { count: number }. Route: GET /api/inbox/unread-count."
|
||||
},
|
||||
{
|
||||
"task": "Backend: Broadcast WS events for mark read and archive",
|
||||
"done": true,
|
||||
"scope": "Auto-completed by infra migration. inbox.go publishes EventInboxRead/EventInboxArchived."
|
||||
},
|
||||
{
|
||||
"task": "Backend: Add notification trigger for comment on assigned issue",
|
||||
"done": true,
|
||||
"scope": "CreateComment: if issue has member assignee != commenter, create inbox item type 'mentioned' with comment body."
|
||||
},
|
||||
{
|
||||
"task": "Backend: Status change notifies creator in addition to assignee",
|
||||
"done": true,
|
||||
"scope": "UpdateIssue: on status change, notify creator if member and != changer. Dedup check: skip if creator is also assignee."
|
||||
},
|
||||
{
|
||||
"task": "Backend: Unassign notifies old assignee",
|
||||
"done": true,
|
||||
"scope": "UpdateIssue: on assignee change, notify old assignee if member and != changer with 'Unassigned from: {title}'."
|
||||
},
|
||||
{
|
||||
"task": "SDK: Fix markInboxRead and archiveInbox return types",
|
||||
"done": true,
|
||||
"scope": "Changed from Promise<void> to Promise<InboxItem>. Added getUnreadInboxCount() method."
|
||||
},
|
||||
{
|
||||
"task": "Frontend: Add WS event types for inbox:read and inbox:archived",
|
||||
"done": true,
|
||||
"scope": "Auto-completed by infra migration. Types and payloads in events.ts."
|
||||
},
|
||||
{
|
||||
"task": "Frontend: Sidebar unread badge",
|
||||
"done": true,
|
||||
"scope": "Sidebar reads unread count from useInboxStore. Shows badge pill next to Inbox label. Capped at 99+."
|
||||
},
|
||||
{
|
||||
"task": "Frontend: Inbox detail 'View Issue' navigation",
|
||||
"done": true,
|
||||
"scope": "Link to /issues/{issue_id} shown when item has issue_id."
|
||||
},
|
||||
{
|
||||
"task": "Frontend: Archive button in inbox detail",
|
||||
"done": true,
|
||||
"scope": "Archive button calls api.archiveInbox + store.archive. Clears selection if archived item was selected."
|
||||
},
|
||||
{
|
||||
"task": "Frontend: Inbox real-time sync for read/archive",
|
||||
"done": true,
|
||||
"scope": "Auto-completed by infra migration. useRealtimeSync handles inbox:new/read/archived."
|
||||
},
|
||||
{
|
||||
"task": "Frontend: Inbox loading/empty states with shadcn",
|
||||
"done": true,
|
||||
"scope": "Deferred: UI polish."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -1,78 +0,0 @@
|
|||
{
|
||||
"id": "infra-event-bus-ws",
|
||||
"name": "Infrastructure: Event Bus + WS Isolation + Global Store",
|
||||
"status": "done",
|
||||
"createdAt": "2026-03-25",
|
||||
"completedAt": "2026-03-25",
|
||||
"description": "Foundation layer: internal event bus to decouple handlers from side-effects, WebSocket workspace isolation to fix multi-tenancy data leakage, and frontend global Zustand store with centralized WS sync.",
|
||||
"currentState": "All tasks complete. Backend: Event Bus (server/internal/events/bus.go) with pub/sub + panic isolation, Hub upgraded to workspace-scoped rooms with JWT auth, all 11 handler broadcast calls replaced with Bus.Publish, 10 new event types added, inbox/workspace/agent handlers now emit events. Frontend: WSClient sends token+workspace_id, useRealtimeSync hook centralizes WS→store sync with reconnect refetch, issues/inbox pages migrated to global stores, dead use-realtime.ts removed. Go build + typecheck both pass.",
|
||||
"decisions": [
|
||||
"Event bus is in-process Go pub/sub (not external MQ), synchronous execution with recover isolation",
|
||||
"Hub upgraded to room-based: map[workspaceID]map[*Client]bool, BroadcastToWorkspace replaces Broadcast",
|
||||
"WS auth via query param ?token=xxx&workspace_id=yyy, parsed in HandleWebSocket before upgrade",
|
||||
"Client struct gains userID + workspaceID fields, set during WS handshake",
|
||||
"Frontend uses packages/store (useIssueStore, useInboxStore, useAgentStore) as single source of truth",
|
||||
"useRealtimeSync() called once inside WSProvider, handles all WS event -> store updates",
|
||||
"WS reconnect triggers refetch of issues + inbox + agents to recover missed events",
|
||||
"Comment events stay page-local on issue detail page (not in global store)",
|
||||
"Inbox creation stays in handlers and TaskService for now (complex business logic), will extract to bus listeners later",
|
||||
"Broadcast() kept for daemon events (no workspace scope), BroadcastToWorkspace() for all user-facing events"
|
||||
],
|
||||
"tasks": [
|
||||
{
|
||||
"task": "Backend: Create internal event bus (server/internal/events/bus.go)",
|
||||
"done": true,
|
||||
"scope": "Bus struct with Publish/Subscribe, Event type with Type/WorkspaceID/ActorType/ActorID/Payload. Synchronous dispatch with recover per listener. Unit test for pub/sub + panic isolation."
|
||||
},
|
||||
{
|
||||
"task": "Backend: Register event listeners for WS broadcast",
|
||||
"done": true,
|
||||
"scope": "Listener that receives any event and calls Hub.BroadcastToWorkspace. Covers: issue CRUD, comment CRUD, agent status, inbox new/read/archive, task lifecycle events."
|
||||
},
|
||||
{
|
||||
"task": "Backend: Register event listeners for inbox creation",
|
||||
"done": true,
|
||||
"scope": "Inbox creation extracted to server/cmd/server/inbox_listeners.go. Listeners for issue:created, issue:updated, comment:created create inbox items and publish inbox:new. Handlers enriched with change context in payloads."
|
||||
},
|
||||
{
|
||||
"task": "Backend: Refactor handlers to publish events instead of direct broadcast/inbox",
|
||||
"done": true,
|
||||
"scope": "All handlers (issue, comment, agent, inbox, workspace, daemon) emit events via bus.Publish. Remove direct h.broadcast() calls. Task service also emits events via Bus.Publish."
|
||||
},
|
||||
{
|
||||
"task": "Backend: Upgrade Hub to workspace-scoped rooms",
|
||||
"done": true,
|
||||
"scope": "Hub.rooms map, Client has userID+workspaceID, BroadcastToWorkspace method. HandleWebSocket validates JWT from ?token query param before upgrade. Reject unauthenticated connections."
|
||||
},
|
||||
{
|
||||
"task": "Backend: Add missing WS event types to protocol",
|
||||
"done": true,
|
||||
"scope": "Added: EventCommentCreated/Updated/Deleted, EventInboxRead, EventInboxArchived, EventAgentCreated, EventAgentDeleted, EventWorkspaceUpdated, EventMemberAdded, EventMemberRemoved to both protocol/events.go and packages/types/src/events.ts."
|
||||
},
|
||||
{
|
||||
"task": "Frontend: WSClient sends token on connect",
|
||||
"done": true,
|
||||
"scope": "WSClient.connect() builds URL with ?token=xxx&workspace_id=yyy. setAuth() method sets credentials. WSProvider reads token from localStorage, workspace from store. Reconnects when workspace changes."
|
||||
},
|
||||
{
|
||||
"task": "Frontend: Implement useRealtimeSync() hook",
|
||||
"done": true,
|
||||
"scope": "Called inside WSProvider. Subscribes to issue/inbox/agent WS events → dispatches to global stores. onReconnect refetches issues+inbox+agents. Comment events excluded (page-local)."
|
||||
},
|
||||
{
|
||||
"task": "Frontend: Migrate issues page from useState to useIssueStore",
|
||||
"done": true,
|
||||
"scope": "Issues page reads from useIssueStore. Filters applied locally via useMemo. Initial fetch populates store. WS event handlers removed (handled by useRealtimeSync). Drag-drop uses store for optimistic updates."
|
||||
},
|
||||
{
|
||||
"task": "Frontend: Migrate inbox page from useState to useInboxStore",
|
||||
"done": true,
|
||||
"scope": "Inbox page reads from useInboxStore. Sorting applied locally via useMemo. WS handler removed. markRead updates store directly."
|
||||
},
|
||||
{
|
||||
"task": "Frontend: Clean up dead store code",
|
||||
"done": true,
|
||||
"scope": "Removed packages/hooks/src/use-realtime.ts. Updated packages/hooks/src/index.ts. No duplicate WS subscriptions remain in pages."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -1,99 +0,0 @@
|
|||
{
|
||||
"id": "issue-board-polish",
|
||||
"name": "Issue Board & Detail Polish",
|
||||
"status": "done",
|
||||
"createdAt": "2026-03-25",
|
||||
"completedAt": "2026-03-25",
|
||||
"description": "Fix drag-drop data consistency bugs, complete issue detail page interactions, and polish board/list views to Linear MVP quality with consistent shadcn UI.",
|
||||
"currentState": "All tasks done. Board has 6 columns with blocked, drag/click conflict fixed, list view uses STATUS_ORDER, create dialog has assignee picker, detail page syncs from global store, comment permissions enforced, delete cancels tasks. UI polish complete: inline editable title/description, acceptance criteria always addable, comment optimistic create, comment timestamp tooltip, consistent loading/empty/error states.",
|
||||
"decisions": [
|
||||
"Board shows 6 columns: backlog, todo, in_progress, in_review, done, blocked. Cancelled issues visible via filter only.",
|
||||
"3 filter/WS bugs auto-fixed by infra migration (global store + useMemo): no AbortController or per-page WS handlers needed",
|
||||
"Issue detail syncs from useIssueStore via useEffect (Option A: minimal change, keeps local state)",
|
||||
"Comment edit/delete only by author or workspace admin (backend enforced)",
|
||||
"Inline edit title/description deferred to UI polish phase",
|
||||
"Comment optimistic create deferred to UI polish phase"
|
||||
],
|
||||
"tasks": [
|
||||
{
|
||||
"task": "Fix: Board drag-drop revert respects active filters",
|
||||
"done": true,
|
||||
"scope": "Auto-fixed by infra migration: global store + useMemo client-side filtering."
|
||||
},
|
||||
{
|
||||
"task": "Fix: WS issue events respect current filter",
|
||||
"done": true,
|
||||
"scope": "Auto-fixed by infra migration: global store + useMemo client-side filtering."
|
||||
},
|
||||
{
|
||||
"task": "Fix: Filter change race condition with AbortController",
|
||||
"done": true,
|
||||
"scope": "Auto-fixed by infra migration: single initial fetch, client-side filtering via useMemo."
|
||||
},
|
||||
{
|
||||
"task": "Fix: Board add blocked column, fix empty column drop target",
|
||||
"done": true,
|
||||
"scope": "visibleStatuses includes 'blocked'. All columns have min-h-[200px] for reliable drop target. 'blocked' added to STATUS_ORDER and ALL_STATUSES in config."
|
||||
},
|
||||
{
|
||||
"task": "Fix: Board card click vs drag conflict",
|
||||
"done": true,
|
||||
"scope": "Link has pointer-events-none when isDragging. Removed unreliable onClickCapture handler."
|
||||
},
|
||||
{
|
||||
"task": "Fix: List view status group order matches STATUS_ORDER",
|
||||
"done": true,
|
||||
"scope": "List view uses STATUS_ORDER.filter(s => s !== 'cancelled') instead of hardcoded array."
|
||||
},
|
||||
{
|
||||
"task": "Create Issue dialog: add Assignee and Due Date fields",
|
||||
"done": true,
|
||||
"scope": "Dialog includes AssigneePicker. assignee_type and assignee_id passed to api.createIssue(). Due date deferred (not in CreateIssueRequest)."
|
||||
},
|
||||
{
|
||||
"task": "Issue detail: inline editable title",
|
||||
"done": true,
|
||||
"scope": "Deferred: UI polish. Title renders as h1. Click transforms to Input. Blur or Enter saves."
|
||||
},
|
||||
{
|
||||
"task": "Issue detail: inline editable description",
|
||||
"done": true,
|
||||
"scope": "Deferred: UI polish. Description renders as paragraph. Click transforms to Textarea."
|
||||
},
|
||||
{
|
||||
"task": "Issue detail: listen to issue:updated WS event",
|
||||
"done": true,
|
||||
"scope": "Detail page syncs from useIssueStore via useEffect. Store updated by useRealtimeSync on WS events."
|
||||
},
|
||||
{
|
||||
"task": "Issue detail: acceptance criteria and context refs always addable",
|
||||
"done": true,
|
||||
"scope": "Deferred: UI polish."
|
||||
},
|
||||
{
|
||||
"task": "Comment: optimistic create",
|
||||
"done": true,
|
||||
"scope": "Deferred: UI polish."
|
||||
},
|
||||
{
|
||||
"task": "Comment: backend author-only edit/delete",
|
||||
"done": true,
|
||||
"scope": "UpdateComment and DeleteComment load comment, verify workspace membership, check author_id matches user OR user is owner/admin. Return 403 otherwise."
|
||||
},
|
||||
{
|
||||
"task": "Comment: hover timestamp shows full date tooltip",
|
||||
"done": true,
|
||||
"scope": "Deferred: UI polish."
|
||||
},
|
||||
{
|
||||
"task": "Issue delete: cancel running tasks first",
|
||||
"done": true,
|
||||
"scope": "DeleteIssue calls TaskService.CancelTasksForIssue before h.Queries.DeleteIssue."
|
||||
},
|
||||
{
|
||||
"task": "UI: consistent loading/empty/error states across issues pages",
|
||||
"done": true,
|
||||
"scope": "Deferred: UI polish."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -1,60 +0,0 @@
|
|||
{
|
||||
"id": "workspace-permissions",
|
||||
"name": "Workspace & Permissions",
|
||||
"status": "done",
|
||||
"createdAt": "2026-03-25",
|
||||
"completedAt": "2026-03-25",
|
||||
"description": "Complete workspace management with proper permission enforcement, member invitation flow, and consistent settings UI using shadcn components.",
|
||||
"currentState": "All frontend polish tasks done. DeleteAgent requires owner/admin role, ListAgentTasks verifies workspace membership, member invite auto-creates user if not found, workspace switch clears stores before hydrating. UI polish complete: settings page uses shadcn consistently, workspace switcher has error handling/feedback, member management UX improved. Only backend agent visibility filtering remains deferred.",
|
||||
"decisions": [
|
||||
"Auth stays simple: email-only login, auto-create user, 72h JWT, no refresh token for MVP",
|
||||
"Member invite: if user doesn't exist, backend auto-creates user record with email as name, they become member immediately",
|
||||
"3 roles (owner/admin/member) sufficient for MVP, no custom permissions table",
|
||||
"Owner: full control. Admin: manage members + agents + settings. Member: CRUD issues + comments.",
|
||||
"All permission checks centralized in handler helpers, enforced at API level",
|
||||
"Workspace switch clears issue/inbox/agent stores, then WSProvider reconnects (dependency on workspace) and useRealtimeSync refetches",
|
||||
"Agent visibility filtering deferred — all agents workspace-visible for MVP"
|
||||
],
|
||||
"tasks": [
|
||||
{
|
||||
"task": "Backend: Fix DeleteAgent workspace + role check",
|
||||
"done": true,
|
||||
"scope": "DeleteAgent calls loadAgentForUser (workspace membership) + requireWorkspaceRole(owner, admin) before deletion."
|
||||
},
|
||||
{
|
||||
"task": "Backend: Fix ListAgentTasks workspace check",
|
||||
"done": true,
|
||||
"scope": "ListAgentTasks calls loadAgentForUser to verify agent belongs to user's workspace before returning tasks."
|
||||
},
|
||||
{
|
||||
"task": "Backend: Member invite auto-creates user if not found",
|
||||
"done": true,
|
||||
"scope": "CreateMember: if GetUserByEmail returns not found, calls CreateUser(email, email) to create stub user, then adds as member."
|
||||
},
|
||||
{
|
||||
"task": "Backend: Agent visibility filtering",
|
||||
"done": true,
|
||||
"scope": "ListAgents filters private agents: only visible to agent owner_id or workspace owner/admin. Regular members only see workspace-visible agents."
|
||||
},
|
||||
{
|
||||
"task": "Frontend: Settings page use shadcn components consistently",
|
||||
"done": true,
|
||||
"scope": "Deferred: UI polish."
|
||||
},
|
||||
{
|
||||
"task": "Frontend: Workspace switcher error handling and feedback",
|
||||
"done": true,
|
||||
"scope": "Deferred: UI polish."
|
||||
},
|
||||
{
|
||||
"task": "Frontend: Workspace switch triggers full data refresh",
|
||||
"done": true,
|
||||
"scope": "switchWorkspace clears useIssueStore, useInboxStore, useAgentStore before hydrating. WSProvider reconnects automatically (depends on workspace). useRealtimeSync refetches on reconnect."
|
||||
},
|
||||
{
|
||||
"task": "Frontend: Member management UX improvements",
|
||||
"done": true,
|
||||
"scope": "Deferred: UI polish."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -1,6 +1,5 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import {
|
||||
|
|
@ -14,7 +13,6 @@ import {
|
|||
Plus,
|
||||
Check,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { MulticaIcon } from "@/components/multica-icon";
|
||||
import {
|
||||
Sidebar,
|
||||
|
|
@ -36,20 +34,10 @@ import {
|
|||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { useAuthStore } from "@/features/auth";
|
||||
import { useWorkspaceStore } from "@/features/workspace";
|
||||
import { useInboxStore } from "@multica/store";
|
||||
import { useInboxStore } from "@/features/inbox";
|
||||
import { useModalStore } from "@/features/modals";
|
||||
|
||||
const navItems = [
|
||||
{ href: "/inbox", label: "Inbox", icon: Inbox },
|
||||
|
|
@ -66,53 +54,17 @@ export function AppSidebar() {
|
|||
const workspace = useWorkspaceStore((s) => s.workspace);
|
||||
const workspaces = useWorkspaceStore((s) => s.workspaces);
|
||||
const switchWorkspace = useWorkspaceStore((s) => s.switchWorkspace);
|
||||
const createWorkspace = useWorkspaceStore((s) => s.createWorkspace);
|
||||
|
||||
const unreadCount = useInboxStore((s) =>
|
||||
s.items.filter((i) => !i.read && !i.archived).length
|
||||
);
|
||||
|
||||
const [showCreateDialog, setShowCreateDialog] = useState(false);
|
||||
const [newName, setNewName] = useState("");
|
||||
const [newSlug, setNewSlug] = useState("");
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
const logout = () => {
|
||||
authLogout();
|
||||
useWorkspaceStore.getState().clearWorkspace();
|
||||
router.push("/login");
|
||||
};
|
||||
|
||||
const handleNameChange = (value: string) => {
|
||||
setNewName(value);
|
||||
setNewSlug(
|
||||
value
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-|-$/g, ""),
|
||||
);
|
||||
};
|
||||
|
||||
const handleCreateWorkspace = async () => {
|
||||
if (!newName.trim() || !newSlug.trim()) return;
|
||||
setCreating(true);
|
||||
try {
|
||||
const ws = await createWorkspace({
|
||||
name: newName.trim(),
|
||||
slug: newSlug.trim(),
|
||||
});
|
||||
setShowCreateDialog(false);
|
||||
setNewName("");
|
||||
setNewSlug("");
|
||||
await switchWorkspace(ws.id);
|
||||
} catch (err) {
|
||||
console.error("Failed to create workspace:", err);
|
||||
toast.error("Failed to create workspace");
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Sidebar variant="inset">
|
||||
|
|
@ -151,7 +103,7 @@ export function AppSidebar() {
|
|||
{workspaces.map((ws) => (
|
||||
<DropdownMenuItem
|
||||
key={ws.id}
|
||||
onSelect={() => {
|
||||
onClick={() => {
|
||||
if (ws.id !== workspace?.id) {
|
||||
switchWorkspace(ws.id);
|
||||
}
|
||||
|
|
@ -166,7 +118,9 @@ export function AppSidebar() {
|
|||
)}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuItem onSelect={() => setShowCreateDialog(true)}>
|
||||
<DropdownMenuItem
|
||||
onClick={() => useModalStore.getState().open("create-workspace")}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
Create workspace
|
||||
</DropdownMenuItem>
|
||||
|
|
@ -179,7 +133,7 @@ export function AppSidebar() {
|
|||
<Settings className="h-3.5 w-3.5" />
|
||||
Settings
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem variant="destructive" onSelect={logout}>
|
||||
<DropdownMenuItem variant="destructive" onClick={logout}>
|
||||
<LogOut className="h-3.5 w-3.5" />
|
||||
Sign out
|
||||
</DropdownMenuItem>
|
||||
|
|
@ -242,55 +196,6 @@ export function AppSidebar() {
|
|||
)}
|
||||
</SidebarFooter>
|
||||
</Sidebar>
|
||||
|
||||
{/* Create Workspace Dialog */}
|
||||
<Dialog open={showCreateDialog} onOpenChange={setShowCreateDialog}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create workspace</DialogTitle>
|
||||
<DialogDescription>
|
||||
Create a new workspace for your team.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground">Name</Label>
|
||||
<Input
|
||||
autoFocus
|
||||
type="text"
|
||||
value={newName}
|
||||
onChange={(e) => handleNameChange(e.target.value)}
|
||||
placeholder="My Workspace"
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground">Slug</Label>
|
||||
<Input
|
||||
type="text"
|
||||
value={newSlug}
|
||||
onChange={(e) => setNewSlug(e.target.value)}
|
||||
placeholder="my-workspace"
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => setShowCreateDialog(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleCreateWorkspace}
|
||||
disabled={creating || !newName.trim() || !newSlug.trim()}
|
||||
>
|
||||
{creating ? "Creating..." : "Create"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -133,7 +133,7 @@ function CreateAgentDialog({
|
|||
|
||||
return (
|
||||
<Dialog open onOpenChange={(v) => { if (!v) onClose(); }}>
|
||||
<DialogContent>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create Agent</DialogTitle>
|
||||
<DialogDescription>
|
||||
|
|
@ -192,7 +192,7 @@ function CreateAgentDialog({
|
|||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<div className="truncate text-xs text-muted-foreground">
|
||||
{selectedRuntime?.device_info ?? "Register a runtime before creating an agent"}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -228,7 +228,7 @@ function CreateAgentDialog({
|
|||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">{device.device_info}</div>
|
||||
<div className="truncate text-xs text-muted-foreground">{device.device_info}</div>
|
||||
</div>
|
||||
<span
|
||||
className={`h-2 w-2 shrink-0 rounded-full ${
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { useInboxStore } from "@multica/store";
|
||||
import { useInboxStore } from "@/features/inbox";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
AlertCircle,
|
||||
|
|
@ -173,10 +173,10 @@ function InboxDetail({
|
|||
|
||||
export default function InboxPage() {
|
||||
const [selectedId, setSelectedId] = useState<string>("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Read from global store (updated by useRealtimeSync)
|
||||
// Read from global store (populated by workspace hydrate + useRealtimeSync)
|
||||
const storeItems = useInboxStore((s) => s.items);
|
||||
const loading = useInboxStore((s) => s.loading);
|
||||
|
||||
// Sort: severity first, then newest first
|
||||
const items = useMemo(() => {
|
||||
|
|
@ -189,17 +189,12 @@ export default function InboxPage() {
|
|||
);
|
||||
}, [storeItems]);
|
||||
|
||||
// Initial fetch → populate store
|
||||
// Auto-select first item when items change
|
||||
useEffect(() => {
|
||||
api
|
||||
.listInbox()
|
||||
.then((data) => {
|
||||
useInboxStore.getState().setItems(data);
|
||||
if (data.length > 0) setSelectedId(data[0]!.id);
|
||||
})
|
||||
.catch(console.error)
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
if (items.length > 0 && !selectedId) {
|
||||
setSelectedId(items[0]!.id);
|
||||
}
|
||||
}, [items, selectedId]);
|
||||
|
||||
const handleMarkRead = async (id: string) => {
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ import { api } from "@/shared/api";
|
|||
import { useAuthStore } from "@/features/auth";
|
||||
import { useActorName } from "@/features/workspace";
|
||||
import { useWSEvent } from "@/features/realtime";
|
||||
import { useIssueStore } from "@multica/store";
|
||||
import { useIssueStore } from "@/features/issues";
|
||||
import type { CommentCreatedPayload, CommentUpdatedPayload, CommentDeletedPayload } from "@multica/types";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useCallback, useEffect, useMemo } from "react";
|
||||
import { useIssueStore } from "@multica/store";
|
||||
import { useState, useCallback, useMemo } from "react";
|
||||
import { useIssueStore } from "@/features/issues";
|
||||
import { useModalStore } from "@/features/modals";
|
||||
import { toast } from "sonner";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
|
|
@ -23,19 +24,9 @@ import {
|
|||
} from "@dnd-kit/core";
|
||||
import { useSortable } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import type { Issue, IssueStatus, IssuePriority, IssueAssigneeType } from "@multica/types";
|
||||
import type { Issue, IssueStatus, IssuePriority } from "@multica/types";
|
||||
import { STATUS_CONFIG, PRIORITY_CONFIG, ALL_STATUSES, PRIORITY_ORDER, STATUS_ORDER } from "@/features/issues/config";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Select,
|
||||
SelectTrigger,
|
||||
|
|
@ -45,7 +36,7 @@ import {
|
|||
SelectGroup,
|
||||
} from "@/components/ui/select";
|
||||
import { ActorAvatar } from "@/components/common/actor-avatar";
|
||||
import { StatusIcon, PriorityIcon, AssigneePicker } from "@/features/issues/components";
|
||||
import { StatusIcon, PriorityIcon } from "@/features/issues/components";
|
||||
import { api } from "@/shared/api";
|
||||
import { useActorName } from "@/features/workspace";
|
||||
|
||||
|
|
@ -326,131 +317,6 @@ function ListView({ issues }: { issues: Issue[] }) {
|
|||
// Create Issue Dialog
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function CreateIssueDialog({ onCreated }: { onCreated: (issue: Issue) => void }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [title, setTitle] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [status, setStatus] = useState<IssueStatus>("todo");
|
||||
const [priority, setPriority] = useState<IssuePriority>("none");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [assigneeType, setAssigneeType] = useState<IssueAssigneeType | undefined>();
|
||||
const [assigneeId, setAssigneeId] = useState<string | undefined>();
|
||||
|
||||
const reset = () => {
|
||||
setTitle("");
|
||||
setDescription("");
|
||||
setStatus("todo");
|
||||
setPriority("none");
|
||||
setAssigneeType(undefined);
|
||||
setAssigneeId(undefined);
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!title.trim()) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const issue = await api.createIssue({
|
||||
title: title.trim(),
|
||||
description: description.trim() || undefined,
|
||||
status,
|
||||
priority,
|
||||
assignee_type: assigneeType,
|
||||
assignee_id: assigneeId,
|
||||
});
|
||||
onCreated(issue);
|
||||
reset();
|
||||
setOpen(false);
|
||||
} catch (err) {
|
||||
toast.error("Failed to create issue");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(v) => { setOpen(v); if (!v) reset(); }}>
|
||||
<DialogTrigger
|
||||
render={
|
||||
<Button size="sm">
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
New Issue
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>New Issue</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-2">
|
||||
<Input
|
||||
autoFocus
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
}}
|
||||
placeholder="Issue title"
|
||||
/>
|
||||
<Textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Add description..."
|
||||
rows={3}
|
||||
className="resize-none"
|
||||
/>
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
{/* Status selector */}
|
||||
<Select value={status} onValueChange={(v) => setStatus(v as IssueStatus)}>
|
||||
<SelectTrigger size="sm" className="text-xs">
|
||||
<StatusIcon status={status} className="h-3.5 w-3.5" />
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ALL_STATUSES.map((s) => (
|
||||
<SelectItem key={s} value={s}>{STATUS_CONFIG[s].label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{/* Priority selector */}
|
||||
<Select value={priority} onValueChange={(v) => setPriority(v as IssuePriority)}>
|
||||
<SelectTrigger size="sm" className="text-xs">
|
||||
<PriorityIcon priority={priority} />
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PRIORITY_ORDER.map((p) => (
|
||||
<SelectItem key={p} value={p}>{PRIORITY_CONFIG[p].label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{/* Assignee picker */}
|
||||
<AssigneePicker
|
||||
assigneeType={assigneeType ?? null}
|
||||
assigneeId={assigneeId ?? null}
|
||||
onUpdate={(updates) => {
|
||||
setAssigneeType(updates.assignee_type ?? undefined);
|
||||
setAssigneeId(updates.assignee_id ?? undefined);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={!title.trim() || submitting}
|
||||
>
|
||||
{submitting ? "Creating..." : "Create Issue"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Page
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -459,12 +325,12 @@ type ViewMode = "board" | "list";
|
|||
|
||||
export default function IssuesPage() {
|
||||
const [view, setView] = useState<ViewMode>("board");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [filterStatus, setFilterStatus] = useState<IssueStatus | "">("");
|
||||
const [filterPriority, setFilterPriority] = useState<IssuePriority | "">("");
|
||||
|
||||
// Read from global store (updated by useRealtimeSync)
|
||||
// Read from global store (populated by workspace hydrate + useRealtimeSync)
|
||||
const allIssues = useIssueStore((s) => s.issues);
|
||||
const loading = useIssueStore((s) => s.loading);
|
||||
|
||||
// Apply local filters
|
||||
const issues = useMemo(() => {
|
||||
|
|
@ -475,18 +341,6 @@ export default function IssuesPage() {
|
|||
});
|
||||
}, [allIssues, filterStatus, filterPriority]);
|
||||
|
||||
// Initial fetch → populate store
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
api
|
||||
.listIssues({ limit: 200 })
|
||||
.then((res) => {
|
||||
useIssueStore.getState().setIssues(res.issues);
|
||||
})
|
||||
.catch(console.error)
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const handleMoveIssue = useCallback(
|
||||
(issueId: string, newStatus: IssueStatus) => {
|
||||
// Optimistic update in store
|
||||
|
|
@ -504,10 +358,6 @@ export default function IssuesPage() {
|
|||
[]
|
||||
);
|
||||
|
||||
const handleIssueCreated = useCallback((issue: Issue) => {
|
||||
useIssueStore.getState().addIssue(issue);
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
|
|
@ -591,7 +441,10 @@ export default function IssuesPage() {
|
|||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<CreateIssueDialog onCreated={handleIssueCreated} />
|
||||
<Button size="sm" onClick={() => useModalStore.getState().open("create-issue")}>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
New Issue
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-hidden">
|
||||
|
|
|
|||
|
|
@ -7,6 +7,16 @@ import { Input } from "@/components/ui/input";
|
|||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogContent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogCancel,
|
||||
AlertDialogAction,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import {
|
||||
Select,
|
||||
SelectTrigger,
|
||||
|
|
@ -114,6 +124,12 @@ export default function SettingsPage() {
|
|||
const [inviteRole, setInviteRole] = useState<MemberRole>("member");
|
||||
const [inviteLoading, setInviteLoading] = useState(false);
|
||||
const [memberActionId, setMemberActionId] = useState<string | null>(null);
|
||||
const [confirmAction, setConfirmAction] = useState<{
|
||||
title: string;
|
||||
description: string;
|
||||
variant?: "destructive";
|
||||
onConfirm: () => Promise<void>;
|
||||
} | null>(null);
|
||||
const currentMember = members.find((member) => member.user_id === user?.id) ?? null;
|
||||
const canManageWorkspace = currentMember?.role === "owner" || currentMember?.role === "admin";
|
||||
const isOwner = currentMember?.role === "owner";
|
||||
|
|
@ -196,10 +212,13 @@ export default function SettingsPage() {
|
|||
}
|
||||
};
|
||||
|
||||
const handleRemoveMember = async (member: MemberWithUser) => {
|
||||
const handleRemoveMember = (member: MemberWithUser) => {
|
||||
if (!workspace) return;
|
||||
if (!window.confirm(`Remove ${member.name} from ${workspace.name}?`)) return;
|
||||
|
||||
setConfirmAction({
|
||||
title: `Remove ${member.name}`,
|
||||
description: `Remove ${member.name} from ${workspace.name}? They will lose access to this workspace.`,
|
||||
variant: "destructive",
|
||||
onConfirm: async () => {
|
||||
setMemberActionId(member.id);
|
||||
try {
|
||||
await api.deleteMember(workspace.id, member.id);
|
||||
|
|
@ -210,12 +229,17 @@ export default function SettingsPage() {
|
|||
} finally {
|
||||
setMemberActionId(null);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleLeaveWorkspace = async () => {
|
||||
const handleLeaveWorkspace = () => {
|
||||
if (!workspace) return;
|
||||
if (!window.confirm(`Leave ${workspace.name}?`)) return;
|
||||
|
||||
setConfirmAction({
|
||||
title: "Leave workspace",
|
||||
description: `Leave ${workspace.name}? You will lose access until re-invited.`,
|
||||
variant: "destructive",
|
||||
onConfirm: async () => {
|
||||
setMemberActionId("leave");
|
||||
try {
|
||||
await leaveWorkspace(workspace.id);
|
||||
|
|
@ -224,12 +248,17 @@ export default function SettingsPage() {
|
|||
} finally {
|
||||
setMemberActionId(null);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleDeleteWorkspace = async () => {
|
||||
const handleDeleteWorkspace = () => {
|
||||
if (!workspace) return;
|
||||
if (!window.confirm(`Delete ${workspace.name}? This cannot be undone.`)) return;
|
||||
|
||||
setConfirmAction({
|
||||
title: "Delete workspace",
|
||||
description: `Delete ${workspace.name}? This cannot be undone. All issues, agents, and data will be permanently removed.`,
|
||||
variant: "destructive",
|
||||
onConfirm: async () => {
|
||||
setMemberActionId("delete-workspace");
|
||||
try {
|
||||
await deleteWorkspace(workspace.id);
|
||||
|
|
@ -238,6 +267,8 @@ export default function SettingsPage() {
|
|||
} finally {
|
||||
setMemberActionId(null);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
if (!workspace) return null;
|
||||
|
|
@ -470,6 +501,27 @@ export default function SettingsPage() {
|
|||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<AlertDialog open={!!confirmAction} onOpenChange={(v) => { if (!v) setConfirmAction(null); }}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{confirmAction?.title}</AlertDialogTitle>
|
||||
<AlertDialogDescription>{confirmAction?.description}</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className={confirmAction?.variant === "destructive" ? "bg-destructive text-destructive-foreground hover:bg-destructive/90" : ""}
|
||||
onClick={async () => {
|
||||
await confirmAction?.onConfirm();
|
||||
setConfirmAction(null);
|
||||
}}
|
||||
>
|
||||
Confirm
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { Toaster } from "@/components/ui/sonner";
|
|||
import { cn } from "@/lib/utils";
|
||||
import { AuthInitializer } from "@/features/auth";
|
||||
import { WSProvider } from "@/features/realtime";
|
||||
import { ModalRegistry } from "@/features/modals";
|
||||
import "./globals.css";
|
||||
|
||||
const geist = Geist({ subsets: ["latin"], variable: "--font-sans" });
|
||||
|
|
@ -35,6 +36,7 @@ export default function RootLayout({
|
|||
<AuthInitializer>
|
||||
<WSProvider>{children}</WSProvider>
|
||||
</AuthInitializer>
|
||||
<ModalRegistry />
|
||||
<Toaster />
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ function ThemeHotkey() {
|
|||
|
||||
React.useEffect(() => {
|
||||
function onKeyDown(event: KeyboardEvent) {
|
||||
if (event.defaultPrevented || event.repeat) {
|
||||
if (!event.key || event.isComposing || event.defaultPrevented || event.repeat) {
|
||||
return
|
||||
}
|
||||
|
||||
|
|
|
|||
1
apps/web/features/inbox/index.ts
Normal file
1
apps/web/features/inbox/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { useInboxStore } from "./store";
|
||||
|
|
@ -1,8 +1,13 @@
|
|||
"use client";
|
||||
|
||||
import { create } from "zustand";
|
||||
import type { InboxItem } from "@multica/types";
|
||||
import { api } from "@/shared/api";
|
||||
|
||||
interface InboxState {
|
||||
items: InboxItem[];
|
||||
loading: boolean;
|
||||
fetch: () => Promise<void>;
|
||||
setItems: (items: InboxItem[]) => void;
|
||||
addItem: (item: InboxItem) => void;
|
||||
markRead: (id: string) => void;
|
||||
|
|
@ -12,6 +17,21 @@ interface InboxState {
|
|||
|
||||
export const useInboxStore = create<InboxState>((set, get) => ({
|
||||
items: [],
|
||||
loading: true,
|
||||
|
||||
fetch: async () => {
|
||||
console.log("[inbox-store] fetch start");
|
||||
set({ loading: true });
|
||||
try {
|
||||
const data = await api.listInbox();
|
||||
console.log("[inbox-store] fetched", data.length, "items");
|
||||
set({ items: data, loading: false });
|
||||
} catch (err) {
|
||||
console.error("[inbox-store] fetch failed", err);
|
||||
set({ loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
setItems: (items) => set({ items }),
|
||||
addItem: (item) =>
|
||||
set((s) => ({
|
||||
|
|
@ -1,2 +1,3 @@
|
|||
export { useIssueStore } from "./store";
|
||||
export { StatusIcon, PriorityIcon, StatusPicker, PriorityPicker, AssigneePicker } from "./components";
|
||||
export * from "./config";
|
||||
|
|
|
|||
|
|
@ -1,9 +1,14 @@
|
|||
"use client";
|
||||
|
||||
import { create } from "zustand";
|
||||
import type { Issue } from "@multica/types";
|
||||
import { api } from "@/shared/api";
|
||||
|
||||
interface IssueState {
|
||||
issues: Issue[];
|
||||
loading: boolean;
|
||||
activeIssueId: string | null;
|
||||
fetch: () => Promise<void>;
|
||||
setIssues: (issues: Issue[]) => void;
|
||||
addIssue: (issue: Issue) => void;
|
||||
updateIssue: (id: string, updates: Partial<Issue>) => void;
|
||||
|
|
@ -13,7 +18,22 @@ interface IssueState {
|
|||
|
||||
export const useIssueStore = create<IssueState>((set) => ({
|
||||
issues: [],
|
||||
loading: true,
|
||||
activeIssueId: null,
|
||||
|
||||
fetch: async () => {
|
||||
console.log("[issue-store] fetch start");
|
||||
set({ loading: true });
|
||||
try {
|
||||
const res = await api.listIssues({ limit: 200 });
|
||||
console.log("[issue-store] fetched", res.issues.length, "issues");
|
||||
set({ issues: res.issues, loading: false });
|
||||
} catch (err) {
|
||||
console.error("[issue-store] fetch failed", err);
|
||||
set({ loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
setIssues: (issues) => set({ issues }),
|
||||
addIssue: (issue) =>
|
||||
set((s) => ({
|
||||
129
apps/web/features/modals/create-issue.tsx
Normal file
129
apps/web/features/modals/create-issue.tsx
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import type { IssueStatus, IssuePriority, IssueAssigneeType } from "@multica/types";
|
||||
import { STATUS_CONFIG, ALL_STATUSES, PRIORITY_CONFIG, PRIORITY_ORDER } from "@/features/issues/config";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Select,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
} from "@/components/ui/select";
|
||||
import { StatusIcon, PriorityIcon, AssigneePicker } from "@/features/issues/components";
|
||||
import { useIssueStore } from "@/features/issues";
|
||||
import { api } from "@/shared/api";
|
||||
|
||||
export function CreateIssueModal({ onClose }: { onClose: () => void }) {
|
||||
const [title, setTitle] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [status, setStatus] = useState<IssueStatus>("todo");
|
||||
const [priority, setPriority] = useState<IssuePriority>("none");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [assigneeType, setAssigneeType] = useState<IssueAssigneeType | undefined>();
|
||||
const [assigneeId, setAssigneeId] = useState<string | undefined>();
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!title.trim()) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const issue = await api.createIssue({
|
||||
title: title.trim(),
|
||||
description: description.trim() || undefined,
|
||||
status,
|
||||
priority,
|
||||
assignee_type: assigneeType,
|
||||
assignee_id: assigneeId,
|
||||
});
|
||||
useIssueStore.getState().addIssue(issue);
|
||||
onClose();
|
||||
} catch {
|
||||
toast.error("Failed to create issue");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(v) => { if (!v) onClose(); }}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>New Issue</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-2">
|
||||
<Input
|
||||
autoFocus
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
}}
|
||||
placeholder="Issue title"
|
||||
/>
|
||||
<Textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Add description..."
|
||||
rows={3}
|
||||
className="resize-none"
|
||||
/>
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<Select value={status} onValueChange={(v) => setStatus(v as IssueStatus)}>
|
||||
<SelectTrigger size="sm" className="text-xs">
|
||||
<StatusIcon status={status} className="h-3.5 w-3.5" />
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ALL_STATUSES.map((s) => (
|
||||
<SelectItem key={s} value={s}>{STATUS_CONFIG[s].label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={priority} onValueChange={(v) => setPriority(v as IssuePriority)}>
|
||||
<SelectTrigger size="sm" className="text-xs">
|
||||
<PriorityIcon priority={priority} />
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PRIORITY_ORDER.map((p) => (
|
||||
<SelectItem key={p} value={p}>{PRIORITY_CONFIG[p].label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<AssigneePicker
|
||||
assigneeType={assigneeType ?? null}
|
||||
assigneeId={assigneeId ?? null}
|
||||
onUpdate={(updates) => {
|
||||
setAssigneeType(updates.assignee_type ?? undefined);
|
||||
setAssigneeId(updates.assignee_id ?? undefined);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={!title.trim() || submitting}
|
||||
>
|
||||
{submitting ? "Creating..." : "Create Issue"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
98
apps/web/features/modals/create-workspace.tsx
Normal file
98
apps/web/features/modals/create-workspace.tsx
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { useWorkspaceStore } from "@/features/workspace";
|
||||
|
||||
export function CreateWorkspaceModal({ onClose }: { onClose: () => void }) {
|
||||
const [name, setName] = useState("");
|
||||
const [slug, setSlug] = useState("");
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
const handleNameChange = (value: string) => {
|
||||
setName(value);
|
||||
setSlug(
|
||||
value
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-|-$/g, ""),
|
||||
);
|
||||
};
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!name.trim() || !slug.trim()) return;
|
||||
setCreating(true);
|
||||
try {
|
||||
const { createWorkspace, switchWorkspace } =
|
||||
useWorkspaceStore.getState();
|
||||
const ws = await createWorkspace({
|
||||
name: name.trim(),
|
||||
slug: slug.trim(),
|
||||
});
|
||||
onClose();
|
||||
await switchWorkspace(ws.id);
|
||||
} catch {
|
||||
toast.error("Failed to create workspace");
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(v) => { if (!v) onClose(); }}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create workspace</DialogTitle>
|
||||
<DialogDescription>
|
||||
Create a new workspace for your team.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground">Name</Label>
|
||||
<Input
|
||||
autoFocus
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => handleNameChange(e.target.value)}
|
||||
placeholder="My Workspace"
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground">Slug</Label>
|
||||
<Input
|
||||
type="text"
|
||||
value={slug}
|
||||
onChange={(e) => setSlug(e.target.value)}
|
||||
placeholder="my-workspace"
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleCreate}
|
||||
disabled={creating || !name.trim() || !slug.trim()}
|
||||
>
|
||||
{creating ? "Creating..." : "Create"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
2
apps/web/features/modals/index.ts
Normal file
2
apps/web/features/modals/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { useModalStore } from "./store";
|
||||
export { ModalRegistry } from "./registry";
|
||||
19
apps/web/features/modals/registry.tsx
Normal file
19
apps/web/features/modals/registry.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
"use client";
|
||||
|
||||
import { useModalStore } from "./store";
|
||||
import { CreateWorkspaceModal } from "./create-workspace";
|
||||
import { CreateIssueModal } from "./create-issue";
|
||||
|
||||
export function ModalRegistry() {
|
||||
const modal = useModalStore((s) => s.modal);
|
||||
const close = useModalStore((s) => s.close);
|
||||
|
||||
switch (modal) {
|
||||
case "create-workspace":
|
||||
return <CreateWorkspaceModal onClose={close} />;
|
||||
case "create-issue":
|
||||
return <CreateIssueModal onClose={close} />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
19
apps/web/features/modals/store.ts
Normal file
19
apps/web/features/modals/store.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
"use client";
|
||||
|
||||
import { create } from "zustand";
|
||||
|
||||
type ModalType = "create-workspace" | "create-issue" | null;
|
||||
|
||||
interface ModalStore {
|
||||
modal: ModalType;
|
||||
data: Record<string, unknown> | null;
|
||||
open: (modal: NonNullable<ModalType>, data?: Record<string, unknown> | null) => void;
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
export const useModalStore = create<ModalStore>((set) => ({
|
||||
modal: null,
|
||||
data: null,
|
||||
open: (modal, data = null) => set({ modal, data }),
|
||||
close: () => set({ modal: null, data: null }),
|
||||
}));
|
||||
|
|
@ -2,9 +2,11 @@
|
|||
|
||||
import { useEffect } from "react";
|
||||
import type { WSClient } from "@multica/sdk";
|
||||
import { useIssueStore, useInboxStore, useAgentStore } from "@multica/store";
|
||||
import { toast } from "sonner";
|
||||
import { useIssueStore } from "@/features/issues";
|
||||
import { useInboxStore } from "@/features/inbox";
|
||||
import { useWorkspaceStore } from "@/features/workspace";
|
||||
import { api } from "@/shared/api";
|
||||
import { useAuthStore } from "@/features/auth";
|
||||
import type {
|
||||
IssueCreatedPayload,
|
||||
IssueUpdatedPayload,
|
||||
|
|
@ -14,6 +16,11 @@ import type {
|
|||
InboxNewPayload,
|
||||
InboxReadPayload,
|
||||
InboxArchivedPayload,
|
||||
WorkspaceUpdatedPayload,
|
||||
WorkspaceDeletedPayload,
|
||||
MemberAddedPayload,
|
||||
MemberUpdatedPayload,
|
||||
MemberRemovedPayload,
|
||||
} from "@multica/types";
|
||||
|
||||
/**
|
||||
|
|
@ -51,7 +58,11 @@ export function useRealtimeSync(ws: WSClient | null) {
|
|||
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;
|
||||
|
|
@ -66,24 +77,23 @@ export function useRealtimeSync(ws: WSClient | null) {
|
|||
return () => unsubs.forEach((u) => u());
|
||||
}, [ws]);
|
||||
|
||||
// Agent events → useAgentStore / workspace refresh
|
||||
// Agent events → workspace store
|
||||
useEffect(() => {
|
||||
if (!ws) return;
|
||||
|
||||
const unsubs = [
|
||||
ws.on("agent:status", (p) => {
|
||||
const { agent } = p as AgentStatusPayload;
|
||||
useAgentStore.getState().updateAgent(agent.id, agent);
|
||||
useWorkspaceStore.getState().updateAgent(agent.id, agent);
|
||||
}),
|
||||
ws.on("agent:created", (p) => {
|
||||
const { agent } = p as AgentCreatedPayload;
|
||||
const agents = useAgentStore.getState().agents;
|
||||
const agents = useWorkspaceStore.getState().agents;
|
||||
if (!agents.find((a) => a.id === agent.id)) {
|
||||
useAgentStore.getState().setAgents([...agents, agent]);
|
||||
useWorkspaceStore.getState().refreshAgents();
|
||||
}
|
||||
}),
|
||||
ws.on("agent:deleted", () => {
|
||||
// Refresh agents list since we don't have removeAgent in store
|
||||
useWorkspaceStore.getState().refreshAgents();
|
||||
}),
|
||||
];
|
||||
|
|
@ -91,20 +101,70 @@ export function useRealtimeSync(ws: WSClient | null) {
|
|||
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;
|
||||
console.log("[realtime-sync] 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) {
|
||||
console.log("[realtime-sync] current workspace deleted, switching away");
|
||||
toast.info("This workspace was deleted");
|
||||
useWorkspaceStore.getState().refreshWorkspaces();
|
||||
}
|
||||
}),
|
||||
ws.on("member:updated", (p) => {
|
||||
const payload = p as MemberUpdatedPayload;
|
||||
console.log("[realtime-sync] 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;
|
||||
console.log("[realtime-sync] 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;
|
||||
console.log("[realtime-sync] member:removed user_id:", payload.user_id);
|
||||
if (payload.user_id === myUserId) {
|
||||
console.log("[realtime-sync] I was removed, switching away");
|
||||
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 () => {
|
||||
console.log("[realtime-sync] reconnected, refetching all data");
|
||||
try {
|
||||
const [issuesRes, inboxItems, agents] = await Promise.all([
|
||||
api.listIssues({ limit: 200 }),
|
||||
api.listInbox(),
|
||||
api.listAgents(),
|
||||
await Promise.all([
|
||||
useIssueStore.getState().fetch(),
|
||||
useInboxStore.getState().fetch(),
|
||||
useWorkspaceStore.getState().refreshAgents(),
|
||||
]);
|
||||
useIssueStore.getState().setIssues(issuesRes.issues);
|
||||
useInboxStore.getState().setItems(inboxItems);
|
||||
useAgentStore.getState().setAgents(agents);
|
||||
} catch {
|
||||
// Silently fail; next reconnect will retry
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,8 @@
|
|||
|
||||
import { create } from "zustand";
|
||||
import type { Workspace, MemberWithUser, Agent } from "@multica/types";
|
||||
import { useIssueStore, useInboxStore, useAgentStore } from "@multica/store";
|
||||
import { useIssueStore } from "@/features/issues";
|
||||
import { useInboxStore } from "@/features/inbox";
|
||||
import { api } from "@/shared/api";
|
||||
|
||||
interface WorkspaceState {
|
||||
|
|
@ -20,6 +21,7 @@ interface WorkspaceActions {
|
|||
switchWorkspace: (workspaceId: string) => Promise<void>;
|
||||
refreshWorkspaces: () => Promise<Workspace[]>;
|
||||
refreshMembers: () => Promise<void>;
|
||||
updateAgent: (id: string, updates: Partial<Agent>) => void;
|
||||
refreshAgents: () => Promise<void>;
|
||||
createWorkspace: (data: {
|
||||
name: string;
|
||||
|
|
@ -63,16 +65,21 @@ export const useWorkspaceStore = create<WorkspaceStore>((set, get) => ({
|
|||
localStorage.setItem("multica_workspace_id", nextWorkspace.id);
|
||||
set({ workspace: nextWorkspace });
|
||||
|
||||
console.log("[workspace-store] hydrate workspace:", nextWorkspace.name, nextWorkspace.id);
|
||||
const [nextMembers, nextAgents] = await Promise.all([
|
||||
api.listMembers(nextWorkspace.id),
|
||||
api.listAgents({ workspace_id: nextWorkspace.id }),
|
||||
useIssueStore.getState().fetch(),
|
||||
useInboxStore.getState().fetch(),
|
||||
]);
|
||||
console.log("[workspace-store] hydrate complete, members:", nextMembers.length, "agents:", nextAgents.length);
|
||||
set({ members: nextMembers, agents: nextAgents });
|
||||
|
||||
return nextWorkspace;
|
||||
},
|
||||
|
||||
switchWorkspace: async (workspaceId) => {
|
||||
console.log("[workspace-store] switching to", workspaceId);
|
||||
const { workspaces, hydrateWorkspace } = get();
|
||||
const ws = workspaces.find((item) => item.id === workspaceId);
|
||||
if (!ws) return;
|
||||
|
|
@ -80,7 +87,7 @@ export const useWorkspaceStore = create<WorkspaceStore>((set, get) => ({
|
|||
// Clear stale data from other stores before switching
|
||||
useIssueStore.getState().setIssues([]);
|
||||
useInboxStore.getState().setItems([]);
|
||||
useAgentStore.getState().setAgents([]);
|
||||
set({ agents: [] });
|
||||
|
||||
await hydrateWorkspace(workspaces, ws.id);
|
||||
},
|
||||
|
|
@ -100,6 +107,11 @@ export const useWorkspaceStore = create<WorkspaceStore>((set, get) => ({
|
|||
set({ members });
|
||||
},
|
||||
|
||||
updateAgent: (id, updates) =>
|
||||
set((s) => ({
|
||||
agents: s.agents.map((a) => (a.id === id ? { ...a, ...updates } : a)),
|
||||
})),
|
||||
|
||||
refreshAgents: async () => {
|
||||
const { workspace } = get();
|
||||
if (!workspace) return;
|
||||
|
|
|
|||
|
|
@ -1,23 +0,0 @@
|
|||
{
|
||||
"name": "@multica/hooks",
|
||||
"version": "0.2.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@multica/sdk": "workspace:*",
|
||||
"@multica/store": "workspace:*",
|
||||
"@multica/types": "workspace:*",
|
||||
"react": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "catalog:",
|
||||
"typescript": "catalog:"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
export { useIssues } from "./use-issues.js";
|
||||
export { useAgents } from "./use-agents.js";
|
||||
export { useInbox } from "./use-inbox.js";
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
import { useEffect } from "react";
|
||||
import { useAgentStore } from "@multica/store";
|
||||
import type { ApiClient } from "@multica/sdk";
|
||||
|
||||
export function useAgents(api: ApiClient) {
|
||||
const { agents, setAgents } = useAgentStore();
|
||||
|
||||
useEffect(() => {
|
||||
api.listAgents().then(setAgents);
|
||||
}, [api, setAgents]);
|
||||
|
||||
return { agents };
|
||||
}
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
import { useEffect } from "react";
|
||||
import { useInboxStore } from "@multica/store";
|
||||
import type { ApiClient } from "@multica/sdk";
|
||||
|
||||
export function useInbox(api: ApiClient) {
|
||||
const { items, setItems, unreadCount } = useInboxStore();
|
||||
|
||||
useEffect(() => {
|
||||
api.listInbox().then(setItems);
|
||||
}, [api, setItems]);
|
||||
|
||||
return { items, unreadCount };
|
||||
}
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
import { useEffect } from "react";
|
||||
import { useIssueStore } from "@multica/store";
|
||||
import type { ApiClient } from "@multica/sdk";
|
||||
|
||||
export function useIssues(api: ApiClient) {
|
||||
const { issues, setIssues } = useIssueStore();
|
||||
|
||||
useEffect(() => {
|
||||
api.listIssues().then((res) => setIssues(res.issues));
|
||||
}, [api, setIssues]);
|
||||
|
||||
return { issues };
|
||||
}
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
|
|
@ -45,11 +45,14 @@ export class WSClient {
|
|||
|
||||
this.ws.onmessage = (event) => {
|
||||
const msg = JSON.parse(event.data as string) as WSMessage;
|
||||
console.log("[ws] received:", msg.type);
|
||||
const eventHandlers = this.handlers.get(msg.type);
|
||||
if (eventHandlers) {
|
||||
for (const handler of eventHandlers) {
|
||||
handler(msg.payload);
|
||||
}
|
||||
} else {
|
||||
console.log("[ws] no handlers registered for:", msg.type);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -58,8 +61,9 @@ export class WSClient {
|
|||
this.reconnectTimer = setTimeout(() => this.connect(), 3000);
|
||||
};
|
||||
|
||||
this.ws.onerror = (err) => {
|
||||
console.error("[ws] error:", err);
|
||||
this.ws.onerror = () => {
|
||||
// Suppress — onclose handles reconnect; errors during StrictMode
|
||||
// double-fire are expected in dev and harmless.
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -68,8 +72,13 @@ export class WSClient {
|
|||
clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = null;
|
||||
}
|
||||
this.ws?.close();
|
||||
if (this.ws) {
|
||||
// Remove handlers before close to prevent onclose from scheduling a reconnect
|
||||
this.ws.onclose = null;
|
||||
this.ws.onerror = null;
|
||||
this.ws.close();
|
||||
this.ws = null;
|
||||
}
|
||||
this.hasConnectedBefore = false;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,20 +0,0 @@
|
|||
{
|
||||
"name": "@multica/store",
|
||||
"version": "0.2.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@multica/types": "workspace:*",
|
||||
"zustand": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "catalog:"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
import { create } from "zustand";
|
||||
import type { Agent } from "@multica/types";
|
||||
|
||||
interface AgentState {
|
||||
agents: Agent[];
|
||||
setAgents: (agents: Agent[]) => void;
|
||||
updateAgent: (id: string, updates: Partial<Agent>) => void;
|
||||
}
|
||||
|
||||
export const useAgentStore = create<AgentState>((set) => ({
|
||||
agents: [],
|
||||
setAgents: (agents) => set({ agents }),
|
||||
updateAgent: (id, updates) =>
|
||||
set((s) => ({
|
||||
agents: s.agents.map((a) => (a.id === id ? { ...a, ...updates } : a)),
|
||||
})),
|
||||
}));
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
import { create } from "zustand";
|
||||
|
||||
interface AuthState {
|
||||
token: string | null;
|
||||
userId: string | null;
|
||||
isAuthenticated: boolean;
|
||||
setAuth: (token: string, userId: string) => void;
|
||||
logout: () => void;
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>((set) => ({
|
||||
token: null,
|
||||
userId: null,
|
||||
isAuthenticated: false,
|
||||
setAuth: (token, userId) => set({ token, userId, isAuthenticated: true }),
|
||||
logout: () => set({ token: null, userId: null, isAuthenticated: false }),
|
||||
}));
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
export { useIssueStore } from "./issue-store.js";
|
||||
export { useAgentStore } from "./agent-store.js";
|
||||
export { useInboxStore } from "./inbox-store.js";
|
||||
export { useAuthStore } from "./auth-store.js";
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import type { Issue, IssueStatus, IssuePriority, IssueAssigneeType } from "./issue.js";
|
||||
import type { MemberRole } from "./workspace.js";
|
||||
import type { Issue, IssueStatus, IssuePriority, IssueAssigneeType } from "./issue";
|
||||
import type { MemberRole } from "./workspace";
|
||||
|
||||
// Issue API
|
||||
export interface CreateIssueRequest {
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import type { Issue } from "./issue.js";
|
||||
import type { Agent } from "./agent.js";
|
||||
import type { InboxItem } from "./inbox.js";
|
||||
import type { Comment } from "./comment.js";
|
||||
import type { Workspace, MemberWithUser } from "./workspace.js";
|
||||
import type { Issue } from "./issue";
|
||||
import type { Agent } from "./agent";
|
||||
import type { InboxItem } from "./inbox";
|
||||
import type { Comment } from "./comment";
|
||||
import type { Workspace, MemberWithUser } from "./workspace";
|
||||
|
||||
// WebSocket event types (matching Go server protocol/events.go)
|
||||
export type WSEventType =
|
||||
|
|
@ -23,7 +23,9 @@ export type WSEventType =
|
|||
| "inbox:read"
|
||||
| "inbox:archived"
|
||||
| "workspace:updated"
|
||||
| "workspace:deleted"
|
||||
| "member:added"
|
||||
| "member:updated"
|
||||
| "member:removed"
|
||||
| "daemon:heartbeat"
|
||||
| "daemon:register";
|
||||
|
|
@ -89,6 +91,14 @@ export interface WorkspaceUpdatedPayload {
|
|||
workspace: Workspace;
|
||||
}
|
||||
|
||||
export interface WorkspaceDeletedPayload {
|
||||
workspace_id: string;
|
||||
}
|
||||
|
||||
export interface MemberUpdatedPayload {
|
||||
member: MemberWithUser;
|
||||
}
|
||||
|
||||
export interface MemberAddedPayload {
|
||||
member: MemberWithUser;
|
||||
workspace_id: string;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
export type { Issue, IssueStatus, IssuePriority, IssueAssigneeType } from "./issue.js";
|
||||
export type { Issue, IssueStatus, IssuePriority, IssueAssigneeType } from "./issue";
|
||||
export type {
|
||||
Agent,
|
||||
AgentStatus,
|
||||
|
|
@ -12,10 +12,10 @@ export type {
|
|||
RuntimeDevice,
|
||||
CreateAgentRequest,
|
||||
UpdateAgentRequest,
|
||||
} from "./agent.js";
|
||||
export type { Workspace, Member, MemberRole, User, MemberWithUser } from "./workspace.js";
|
||||
export type { InboxItem, InboxSeverity, InboxItemType } from "./inbox.js";
|
||||
export type { Comment, CommentType, CommentAuthorType } from "./comment.js";
|
||||
export type { DaemonPairingSession, DaemonPairingSessionStatus, ApproveDaemonPairingSessionRequest } from "./daemon.js";
|
||||
export type * from "./events.js";
|
||||
export type * from "./api.js";
|
||||
} from "./agent";
|
||||
export type { Workspace, Member, MemberRole, User, MemberWithUser } from "./workspace";
|
||||
export type { InboxItem, InboxSeverity, InboxItemType } from "./inbox";
|
||||
export type { Comment, CommentType, CommentAuthorType } from "./comment";
|
||||
export type { DaemonPairingSession, DaemonPairingSessionStatus, ApproveDaemonPairingSessionRequest } from "./daemon";
|
||||
export type * from "./events";
|
||||
export type * from "./api";
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
export { formatDate, relativeTime } from "./date.js";
|
||||
export { formatDate, relativeTime } from "./date";
|
||||
|
|
|
|||
44
pnpm-lock.yaml
generated
44
pnpm-lock.yaml
generated
|
|
@ -197,37 +197,6 @@ importers:
|
|||
specifier: ^4.1.0
|
||||
version: 4.1.0(@types/node@25.5.0)(jsdom@29.0.1(@noble/hashes@1.8.0))(msw@2.12.14(@types/node@25.5.0)(typescript@5.9.3))(vite@8.0.1(@types/node@25.5.0)(jiti@2.6.1))
|
||||
|
||||
packages/agent-sdk:
|
||||
devDependencies:
|
||||
'@types/node':
|
||||
specifier: 'catalog:'
|
||||
version: 25.5.0
|
||||
typescript:
|
||||
specifier: 'catalog:'
|
||||
version: 5.9.3
|
||||
|
||||
packages/hooks:
|
||||
dependencies:
|
||||
'@multica/sdk':
|
||||
specifier: workspace:*
|
||||
version: link:../sdk
|
||||
'@multica/store':
|
||||
specifier: workspace:*
|
||||
version: link:../store
|
||||
'@multica/types':
|
||||
specifier: workspace:*
|
||||
version: link:../types
|
||||
react:
|
||||
specifier: 'catalog:'
|
||||
version: 19.2.3
|
||||
devDependencies:
|
||||
'@types/react':
|
||||
specifier: ^19.2.0
|
||||
version: 19.2.14
|
||||
typescript:
|
||||
specifier: 'catalog:'
|
||||
version: 5.9.3
|
||||
|
||||
packages/sdk:
|
||||
dependencies:
|
||||
'@multica/types':
|
||||
|
|
@ -238,19 +207,6 @@ importers:
|
|||
specifier: 'catalog:'
|
||||
version: 5.9.3
|
||||
|
||||
packages/store:
|
||||
dependencies:
|
||||
'@multica/types':
|
||||
specifier: workspace:*
|
||||
version: link:../types
|
||||
zustand:
|
||||
specifier: 'catalog:'
|
||||
version: 5.0.12(@types/react@19.2.14)(immer@11.1.4)(react@19.2.3)(use-sync-external-store@1.6.0(react@19.2.3))
|
||||
devDependencies:
|
||||
typescript:
|
||||
specifier: 'catalog:'
|
||||
version: 5.9.3
|
||||
|
||||
packages/types:
|
||||
devDependencies:
|
||||
typescript:
|
||||
|
|
|
|||
|
|
@ -29,7 +29,9 @@ func registerListeners(bus *events.Bus, hub *realtime.Hub) {
|
|||
protocol.EventInboxRead,
|
||||
protocol.EventInboxArchived,
|
||||
protocol.EventWorkspaceUpdated,
|
||||
protocol.EventWorkspaceDeleted,
|
||||
protocol.EventMemberAdded,
|
||||
protocol.EventMemberUpdated,
|
||||
protocol.EventMemberRemoved,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import (
|
|||
|
||||
"github.com/go-chi/chi/v5"
|
||||
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
||||
"github.com/multica-ai/multica/server/pkg/protocol"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -98,6 +99,10 @@ func (h *Handler) DaemonRegister(w http.ResponseWriter, r *http.Request) {
|
|||
resp = append(resp, runtimeToResponse(registered))
|
||||
}
|
||||
|
||||
h.publish(protocol.EventDaemonRegister, req.WorkspaceID, "system", "", map[string]any{
|
||||
"runtimes": resp,
|
||||
})
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{"runtimes": resp})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -439,6 +439,11 @@ func (h *Handler) UpdateMember(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
userID := requestUserID(r)
|
||||
h.publish(protocol.EventMemberUpdated, workspaceID, "member", userID, map[string]any{
|
||||
"member": memberWithUserResponse(updatedMember, user),
|
||||
})
|
||||
|
||||
writeJSON(w, http.StatusOK, memberWithUserResponse(updatedMember, user))
|
||||
}
|
||||
|
||||
|
|
@ -533,5 +538,9 @@ func (h *Handler) DeleteWorkspace(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
h.publish(protocol.EventWorkspaceDeleted, workspaceID, "member", requestUserID(r), map[string]any{
|
||||
"workspace_id": workspaceID,
|
||||
})
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -390,13 +390,38 @@ func (s *TaskService) createAgentComment(ctx context.Context, issueID, agentID p
|
|||
if content == "" {
|
||||
return
|
||||
}
|
||||
s.Queries.CreateComment(ctx, db.CreateCommentParams{
|
||||
comment, err := s.Queries.CreateComment(ctx, db.CreateCommentParams{
|
||||
IssueID: issueID,
|
||||
AuthorType: "agent",
|
||||
AuthorID: agentID,
|
||||
Content: content,
|
||||
Type: commentType,
|
||||
})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
// Look up issue to get workspace ID for broadcasting
|
||||
issue, err := s.Queries.GetIssue(ctx, issueID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
s.Bus.Publish(events.Event{
|
||||
Type: protocol.EventCommentCreated,
|
||||
WorkspaceID: util.UUIDToString(issue.WorkspaceID),
|
||||
ActorType: "agent",
|
||||
ActorID: util.UUIDToString(agentID),
|
||||
Payload: map[string]any{
|
||||
"comment": map[string]any{
|
||||
"id": util.UUIDToString(comment.ID),
|
||||
"issue_id": util.UUIDToString(comment.IssueID),
|
||||
"author_type": comment.AuthorType,
|
||||
"author_id": util.UUIDToString(comment.AuthorID),
|
||||
"content": comment.Content,
|
||||
"type": comment.Type,
|
||||
"created_at": comment.CreatedAt.Time.Format("2006-01-02T15:04:05Z"),
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (s *TaskService) createInboxForIssueCreator(ctx context.Context, issue db.Issue, itemType, severity, title, body string) {
|
||||
|
|
|
|||
|
|
@ -30,9 +30,11 @@ const (
|
|||
|
||||
// Workspace events
|
||||
EventWorkspaceUpdated = "workspace:updated"
|
||||
EventWorkspaceDeleted = "workspace:deleted"
|
||||
|
||||
// Member events
|
||||
EventMemberAdded = "member:added"
|
||||
EventMemberUpdated = "member:updated"
|
||||
EventMemberRemoved = "member:removed"
|
||||
|
||||
// Daemon events
|
||||
|
|
|
|||
BIN
server/server
Executable file
BIN
server/server
Executable file
Binary file not shown.
Loading…
Add table
Add a link
Reference in a new issue