mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-17 11:48:42 +02:00
Compare commits
64 Commits
agent/j/27
...
agent/emac
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3c97efc2e4 | ||
|
|
ca7ba48934 | ||
|
|
63895343e3 | ||
|
|
88982ad23f | ||
|
|
7620a5a7e9 | ||
|
|
289e3c3ad0 | ||
|
|
e867076bde | ||
|
|
303a4b3144 | ||
|
|
0998a3a87d | ||
|
|
5878bddd6b | ||
|
|
102831919c | ||
|
|
1dd8ca86c3 | ||
|
|
aa6577c5b7 | ||
|
|
ef1db9e754 | ||
|
|
2d8c0a2d60 | ||
|
|
5647c129da | ||
|
|
254871635e | ||
|
|
cb81aa48d3 | ||
|
|
6340b560c7 | ||
|
|
cc5e2e1712 | ||
|
|
b067eee487 | ||
|
|
1f9ce6582c | ||
|
|
a4383e051f | ||
|
|
c1b1a55808 | ||
|
|
547b8839b2 | ||
|
|
4c88a1318d | ||
|
|
fb1554c0bf | ||
|
|
33768a2d3a | ||
|
|
05067f4960 | ||
|
|
715f196434 | ||
|
|
add8bf9f4f | ||
|
|
ba32f3a187 | ||
|
|
a8c3137f3b | ||
|
|
79b4c75303 | ||
|
|
18b16f2936 | ||
|
|
8567dacd55 | ||
|
|
a012d912fe | ||
|
|
042985d961 | ||
|
|
02cdfcb93f | ||
|
|
25080c6719 | ||
|
|
89fd2ce96e | ||
|
|
7d5db1ce8b | ||
|
|
825e40358b | ||
|
|
b5cccc8ac6 | ||
|
|
aec07456fc | ||
|
|
6209e2f3ae | ||
|
|
0a5a3b2450 | ||
|
|
90b2cb7848 | ||
|
|
bb34bd3db9 | ||
|
|
7950ac72af | ||
|
|
db55b79aa1 | ||
|
|
d911cdf5ac | ||
|
|
83769c4780 | ||
|
|
848d79df11 | ||
|
|
1caa7f6324 | ||
|
|
0e0c5f4cdb | ||
|
|
bea274492c | ||
|
|
f7c1ae4d77 | ||
|
|
784111a498 | ||
|
|
77f48d9f26 | ||
|
|
dafd51e327 | ||
|
|
f9eeafb568 | ||
|
|
4585306bfc | ||
|
|
74cc1d488e |
File diff suppressed because one or more lines are too long
385
CLAUDE.md
385
CLAUDE.md
@@ -12,184 +12,47 @@ Multica is an AI-native task management platform — like Linear, but with AI ag
|
||||
|
||||
## Architecture
|
||||
|
||||
**Go backend + monorepo frontend with shared packages.**
|
||||
**Go backend + monorepo frontend (pnpm workspaces + Turborepo) with shared packages.**
|
||||
|
||||
- `server/` — Go backend (Chi router, sqlc for DB, gorilla/websocket for real-time)
|
||||
- `apps/web/` — Next.js 16 frontend (App Router)
|
||||
- `apps/web/` — Next.js frontend (App Router)
|
||||
- `apps/desktop/` — Electron desktop app (electron-vite)
|
||||
- `packages/core/` — Headless business logic (zero react-dom, all-platform reuse)
|
||||
- `packages/ui/` — Atomic UI components (zero business logic)
|
||||
- `packages/views/` — Shared business pages/components (zero next/* imports)
|
||||
- `packages/views/` — Shared business pages/components (zero next/* imports, zero react-router imports)
|
||||
- `packages/tsconfig/` — Shared TypeScript configuration
|
||||
|
||||
### Package Architecture
|
||||
### Key Architectural Decisions
|
||||
|
||||
Three shared packages with single-direction dependencies:
|
||||
**Internal Packages pattern** — all shared packages export raw `.ts`/`.tsx` files (no pre-compilation). The consuming app's bundler compiles them directly. This gives zero-config HMR and instant go-to-definition.
|
||||
|
||||
```
|
||||
packages/
|
||||
├── core/ # @multica/core — types, API client, stores, queries, mutations, realtime
|
||||
├── ui/ # @multica/ui — 55 shadcn components, common components, markdown, hooks
|
||||
├── views/ # @multica/views — issue pages, editor, modals, skills, runtimes, navigation
|
||||
└── tsconfig/ # @multica/tsconfig — shared TS base configs
|
||||
```
|
||||
**Dependency direction:** `views/ → core/ + ui/`. Core and UI are independent of each other. No package imports from `next/*`, `react-router-dom`, or app-specific code.
|
||||
|
||||
**Dependency direction:** `views/ → core/ + ui/`. Core and UI are independent of each other. No package imports from `next/*` or `apps/web/`.
|
||||
**Platform bridge:** `packages/core/platform/` provides `CoreProvider` — initializes API client, auth/workspace stores, WS connection, and QueryClient. Each app wraps its root with `<CoreProvider>` and provides its own `NavigationAdapter` for routing.
|
||||
|
||||
**Platform bridge:** `apps/web/platform/` is the only place that touches `process.env`, `next/navigation`, and creates store/api singletons. Each future app (desktop, mobile) provides its own platform layer.
|
||||
|
||||
### packages/core/ (`@multica/core`)
|
||||
|
||||
Headless business logic. **Zero react-dom, zero localStorage, zero process.env.**
|
||||
|
||||
| Module | Purpose | Key exports |
|
||||
|---|---|---|
|
||||
| `core/types/` | Domain types + StorageAdapter interface | `Issue`, `Agent`, `Workspace`, `StorageAdapter` |
|
||||
| `core/api/` | API client class + WS client | `ApiClient`, `WSClient`, `setApiInstance()` |
|
||||
| `core/auth/` | Auth store factory | `createAuthStore(options)`, `registerAuthStore()` |
|
||||
| `core/workspace/` | Workspace store factory + actor hooks | `createWorkspaceStore(api)`, `useActorName()` |
|
||||
| `core/issues/` | Issue queries, mutations, stores, config | `issueListOptions`, `useUpdateIssue`, `useIssueStore` |
|
||||
| `core/inbox/` | Inbox queries, mutations, WS updaters | `inboxListOptions`, `useMarkInboxRead` |
|
||||
| `core/runtimes/` | Runtime queries + mutations | `runtimeListOptions`, `useDeleteRuntime` |
|
||||
| `core/realtime/` | WS provider + sync hooks | `WSProvider`, `useWSEvent`, `useRealtimeSync` |
|
||||
| `core/hooks.tsx` | Workspace ID context | `useWorkspaceId`, `WorkspaceIdProvider` |
|
||||
| `core/modals/` | Modal state store | `useModalStore` |
|
||||
| `core/navigation/` | Navigation state store | `useNavigationStore` |
|
||||
|
||||
**Store factory pattern:** Auth and workspace stores are created via factory functions that receive platform-specific dependencies:
|
||||
```typescript
|
||||
createAuthStore({ api, storage, onLogin?, onLogout? })
|
||||
createWorkspaceStore(api, { storage?, onError? })
|
||||
```
|
||||
Each app creates its own instances in its platform layer and registers them via `registerAuthStore()` / `registerWorkspaceStore()`.
|
||||
|
||||
**StorageAdapter:** All persistent storage goes through a `StorageAdapter` interface (getItem/setItem/removeItem), injected by the platform. Web uses an SSR-safe localStorage wrapper.
|
||||
|
||||
### packages/ui/ (`@multica/ui`)
|
||||
|
||||
Atomic UI layer. **Zero business logic, zero `@multica/core` imports.**
|
||||
|
||||
- `components/ui/` — 55 shadcn components (button, dialog, card, tooltip, sidebar, etc.)
|
||||
- `components/common/` — Pure-props components (actor-avatar, emoji-picker, reaction-bar, file-upload-button)
|
||||
- `markdown/` — Markdown renderer with `renderMention` slot for platform-specific mention cards
|
||||
- `hooks/` — DOM hooks (use-auto-scroll, use-mobile, use-scroll-fade)
|
||||
- `lib/utils.ts` — `cn()` function (clsx + tailwind-merge)
|
||||
- `styles/tokens.css` — Tailwind CSS v4 design tokens (@theme, :root, .dark variables)
|
||||
|
||||
### packages/views/ (`@multica/views`)
|
||||
|
||||
Shared business UI pages. **Zero `next/*` imports.** Uses `NavigationAdapter` for routing.
|
||||
|
||||
- `navigation/` — `NavigationAdapter` interface, `useNavigation()` hook, `AppLink` component
|
||||
- `issues/components/` — IssuesPage, IssueDetail, BoardView, ListView, pickers, icons
|
||||
- `editor/` — ContentEditor, TitleEditor, Tiptap extensions
|
||||
- `modals/` — CreateIssueModal, CreateWorkspaceModal, ModalRegistry
|
||||
- `my-issues/`, `skills/`, `runtimes/` — domain pages
|
||||
- `common/` — Data-aware wrappers (ActorAvatar with useActorName, Markdown with IssueMentionCard)
|
||||
|
||||
### apps/web/ (Next.js App)
|
||||
|
||||
Thin routing shells + platform-specific code.
|
||||
|
||||
```
|
||||
apps/web/
|
||||
├── app/ # Next.js route shells (< 15 lines each, import from @multica/views)
|
||||
├── platform/ # Web platform bridge (api singleton, store instances, navigation, storage)
|
||||
├── features/
|
||||
│ ├── auth/ # Web-only: auth-cookie.ts, initializer.tsx
|
||||
│ ├── landing/ # Web-only: landing pages (uses next/image, next/link)
|
||||
│ └── search/ # Web-only: search dialog
|
||||
└── components/ # App-level: theme-provider, multica-icon, locale-sync, loading-indicator
|
||||
```
|
||||
|
||||
**`platform/`** — The only code that touches Next.js APIs and browser globals:
|
||||
- `api.ts` — Creates `ApiClient` singleton with `onUnauthorized` redirect
|
||||
- `auth.ts` — `createAuthStore({ api, storage: webStorage, onLogin: setLoggedInCookie })`
|
||||
- `workspace.ts` — `createWorkspaceStore(api, { storage: webStorage, onError: toast.error })`
|
||||
- `ws-provider.tsx` — Wraps `WSProvider` with web-specific WS URL and store instances
|
||||
- `navigation.tsx` — `WebNavigationProvider` wrapping Next.js `useRouter`/`usePathname`
|
||||
- `storage.ts` — SSR-safe `webStorage` adapter (guards `localStorage` with `typeof window` checks)
|
||||
**pnpm catalog** — `pnpm-workspace.yaml` defines `catalog:` for version pinning. All shared deps use `catalog:` references to guarantee a single version across all packages. When adding new shared deps (including test deps), add to catalog first.
|
||||
|
||||
### State Management
|
||||
|
||||
- **TanStack Query** for all server state — issues, inbox, members, agents, skills, runtimes. Query definitions in `@multica/core/<domain>/queries.ts`, mutations in `mutations.ts`.
|
||||
- **Zustand** for client-only state — UI selections (`activeIssueId`), view filters, modal state. Auth and workspace stores use factory pattern with injected dependencies.
|
||||
- **React Context** for `WorkspaceIdProvider` (provides workspace ID to all dashboard children) and `NavigationProvider` (provides platform-agnostic routing).
|
||||
- **Local `useState`** for component-scoped UI state (forms, modals, filters).
|
||||
The architecture relies on a strict split between server state and client state. Mixing them is the most common way to break it.
|
||||
|
||||
**TanStack Query conventions:**
|
||||
- `staleTime: Infinity` — WS events handle cache freshness, no polling or refetch-on-focus.
|
||||
- WS events trigger `queryClient.invalidateQueries()` (preferred) or `queryClient.setQueryData()` for granular updates.
|
||||
- All workspace-scoped query keys include `wsId` — workspace switch automatically uses new cache.
|
||||
- Mutations use `onMutate` for optimistic updates + `onError` for rollback + `onSettled` for invalidation.
|
||||
- **TanStack Query owns all server state.** Issues, users, workspaces, inbox — anything fetched from the API lives in the Query cache. WS events keep it fresh via invalidation; no polling, no `staleTime` workarounds.
|
||||
- **Zustand owns all client state.** UI selections, filters, drafts, modal state, navigation history. Stores live in `packages/core/` (never in `packages/views/`) so both apps share them.
|
||||
- **React Context** is reserved for cross-cutting platform plumbing — `WorkspaceIdProvider`, `NavigationProvider`. Don't reach for it for general state.
|
||||
- **Auth and workspace stores are the only stores allowed to call `api.*` directly**, because they manage critical state that must exist before queries can run. They're created via factory + injected dependencies, registered by the platform layer.
|
||||
|
||||
**Zustand store conventions:**
|
||||
- Stores in `@multica/core` hold only client state. Zero direct `api.*` calls — API access is injected via factory.
|
||||
- Auth/workspace stores are created by platform layer and registered via `registerAuthStore()` / `registerWorkspaceStore()`.
|
||||
- Other stores (issue, modal, navigation) are plain Zustand stores exported directly.
|
||||
**Hard rules — these are how the architecture stays coherent:**
|
||||
|
||||
### Import Conventions
|
||||
- **Never duplicate server data into Zustand.** If it came from the API, it belongs in the Query cache. Copying it into a store creates two sources of truth and they will drift.
|
||||
- **Workspace-scoped queries must key on `wsId`.** This is what makes workspace switching automatic — the cache key changes, the right data appears, no manual invalidation needed.
|
||||
- **Mutations are optimistic by default.** Apply the change locally, send the request, roll back on failure, invalidate on settle. The user shouldn't wait for the server.
|
||||
- **WS events invalidate queries — they never write to stores directly.** This keeps the cache as the single source of truth and avoids race conditions.
|
||||
- **Persist what's worth preserving across restarts** (user preferences, drafts, tab layout). **Don't persist ephemeral UI state** (modal open/close, transient selections) or server data.
|
||||
|
||||
```typescript
|
||||
// Core (headless business logic) — from @multica/core
|
||||
import { issueListOptions } from "@multica/core/issues/queries";
|
||||
import { useUpdateIssue } from "@multica/core/issues/mutations";
|
||||
import { useWorkspaceId } from "@multica/core/hooks";
|
||||
import type { Issue } from "@multica/core/types";
|
||||
**Common Zustand footguns to avoid:**
|
||||
|
||||
// UI (atomic components) — from @multica/ui
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import { ActorAvatar } from "@multica/ui/components/common/actor-avatar";
|
||||
|
||||
// Views (shared pages) — from @multica/views
|
||||
import { IssuesPage } from "@multica/views/issues/components";
|
||||
import { useNavigation, AppLink } from "@multica/views/navigation";
|
||||
import { ModalRegistry } from "@multica/views/modals/registry";
|
||||
|
||||
// Platform (web-only singletons) — from @/platform
|
||||
import { api } from "@/platform/api";
|
||||
import { useAuthStore } from "@/platform/auth";
|
||||
import { useWorkspaceStore } from "@/platform/workspace";
|
||||
|
||||
// Web-only features — from @/features
|
||||
import { AuthInitializer } from "@/features/auth";
|
||||
import { SearchCommand } from "@/features/search";
|
||||
```
|
||||
|
||||
`@/` maps to `apps/web/`. Within a package, use relative imports. Between packages, use `@multica/*`.
|
||||
|
||||
### Data Flow
|
||||
|
||||
```
|
||||
Browser → useQuery (@multica/core) → ApiClient (@multica/core/api) → REST API → sqlc → PostgreSQL
|
||||
Browser ← useQuery cache ← invalidateQueries ← WS event handlers ← WSClient ← Hub.Broadcast()
|
||||
```
|
||||
|
||||
Mutations: `useMutation (@multica/core)` → optimistic cache update → API call → onSettled invalidation.
|
||||
WS events: `use-realtime-sync.ts` → `queryClient.invalidateQueries()` for most events, `setQueryData()` for granular issue/inbox updates.
|
||||
|
||||
### Backend Structure (`server/`)
|
||||
|
||||
- **Entry points** (`cmd/`): `server` (HTTP API), `multica` (CLI — daemon, agent management, config), `migrate`
|
||||
- **Handlers** (`internal/handler/`): One file per domain (issue, comment, agent, auth, daemon, etc.). Each handler holds `Queries`, `DB`, `Hub`, and `TaskService`.
|
||||
- **Real-time** (`internal/realtime/`): Hub manages WebSocket clients. Server broadcasts events; inbound WS message routing is still TODO.
|
||||
- **Auth** (`internal/auth/` + `internal/middleware/`): JWT (HS256). Middleware sets `X-User-ID` and `X-User-Email` headers. Login creates user on-the-fly if not found.
|
||||
- **Task lifecycle** (`internal/service/task.go`): Orchestrates agent work — enqueue → claim → start → complete/fail. Syncs issue status automatically and broadcasts WS events at each transition.
|
||||
- **Agent SDK** (`pkg/agent/`): Unified `Backend` interface for executing prompts via Claude Code or Codex. Each backend spawns its CLI and streams results via `Session.Messages` + `Session.Result` channels.
|
||||
- **Daemon** (`internal/daemon/`): Local agent runtime — auto-detects available CLIs (claude, codex), registers runtimes, polls for tasks, routes by provider.
|
||||
- **CLI** (`internal/cli/`): Shared helpers for the `multica` CLI — API client, config management, output formatting.
|
||||
- **Events** (`internal/events/`): Internal event bus for decoupled communication between handlers and services.
|
||||
- **Logging** (`internal/logger/`): Structured logging via slog. `LOG_LEVEL` env var controls level (debug, info, warn, error).
|
||||
- **Database**: PostgreSQL with pgvector extension (`pgvector/pgvector:pg17`). sqlc generates Go code from SQL in `pkg/db/queries/` → `pkg/db/generated/`. Migrations in `migrations/`.
|
||||
- **Routes** (`cmd/server/router.go`): Public routes (auth, health, ws) + protected routes (require JWT) + daemon routes (unauthenticated, separate auth model).
|
||||
|
||||
### Multi-tenancy
|
||||
|
||||
All queries filter by `workspace_id`. Membership checks gate access. `X-Workspace-ID` header routes requests to the correct workspace.
|
||||
|
||||
### Agent Assignees
|
||||
|
||||
Assignees are polymorphic — can be a member or an agent. `assignee_type` + `assignee_id` on issues. Agents render with distinct styling (purple background, robot icon).
|
||||
- Selectors must return stable references. Returning a freshly built object or array on every call (e.g. `s => ({ a: s.a, b: s.b })` or `s => s.items.map(...)`) triggers infinite re-renders. Either select primitives separately or use shallow comparison.
|
||||
- Hooks that need workspace context should accept `wsId` as a parameter, not call `useWorkspaceId()` internally — this lets them work outside the `WorkspaceIdProvider` (e.g. in a sidebar that renders before workspace is loaded).
|
||||
|
||||
## Commands
|
||||
|
||||
@@ -203,10 +66,11 @@ make db-down # Stop the shared PostgreSQL container
|
||||
# Frontend (all commands go through Turborepo)
|
||||
pnpm install
|
||||
pnpm dev:web # Next.js dev server (port 3000)
|
||||
pnpm build # Build frontend
|
||||
pnpm typecheck # TypeScript check (all packages via turbo)
|
||||
pnpm lint # ESLint via Next.js
|
||||
pnpm test # TS tests (Vitest, via turbo)
|
||||
pnpm dev:desktop # Electron dev (electron-vite, HMR)
|
||||
pnpm build # Build all frontend apps
|
||||
pnpm typecheck # TypeScript check (all packages + apps via turbo)
|
||||
pnpm lint # ESLint
|
||||
pnpm test # TS tests (Vitest, all packages + apps via turbo)
|
||||
|
||||
# Backend (Go)
|
||||
make dev # Run Go server (port 8080)
|
||||
@@ -218,17 +82,23 @@ make sqlc # Regenerate sqlc code after editing SQL in server/pkg/db/
|
||||
make migrate-up # Run database migrations
|
||||
make migrate-down # Rollback migrations
|
||||
|
||||
# Run a single TS test (works for any package with a test script)
|
||||
pnpm --filter @multica/views exec vitest run auth/login-page.test.tsx
|
||||
pnpm --filter @multica/core exec vitest run runtimes/version.test.ts
|
||||
pnpm --filter @multica/web exec vitest run app/\(auth\)/login/page.test.tsx
|
||||
|
||||
# Run a single Go test
|
||||
cd server && go test ./internal/handler/ -run TestName
|
||||
|
||||
# Run a single TS test
|
||||
pnpm --filter @multica/web exec vitest run src/path/to/file.test.ts
|
||||
|
||||
# Run a single E2E test (requires backend + frontend running)
|
||||
pnpm exec playwright test e2e/tests/specific-test.spec.ts
|
||||
|
||||
# shadcn (monorepo mode — must specify app)
|
||||
npx shadcn add badge -c apps/web
|
||||
# Desktop build & package
|
||||
pnpm --filter @multica/desktop build # Compile TS → JS (reads .env.production)
|
||||
pnpm --filter @multica/desktop package # Package into .app/.dmg/.exe (current platform only)
|
||||
|
||||
# shadcn — config lives in packages/ui/components.json (Base UI variant, base-nova style)
|
||||
pnpm ui:add badge # Adds component to packages/ui/components/ui/
|
||||
|
||||
# Infrastructure
|
||||
make db-up # Start shared PostgreSQL (pgvector/pg17 image)
|
||||
@@ -257,52 +127,129 @@ make start-worktree # Start using .env.worktree
|
||||
- Prefer existing patterns/components over introducing parallel abstractions.
|
||||
- Unless the user explicitly asks for backwards compatibility, do **not** add compatibility layers, fallback paths, dual-write logic, legacy adapters, or temporary shims.
|
||||
- If a flow or API is being replaced and the product is not yet live, prefer removing the old path instead of preserving both old and new behavior.
|
||||
- Treat compatibility code as a maintenance cost, not a default safety mechanism. Avoid "just in case" branches that make the codebase harder to reason about.
|
||||
- Avoid broad refactors unless required by the task.
|
||||
|
||||
### Package Boundary Rules
|
||||
|
||||
- `packages/core/` — zero react-dom, zero localStorage (use StorageAdapter), zero process.env, zero UI libraries
|
||||
- `packages/ui/` — zero `@multica/core` imports (pure UI, no business logic)
|
||||
- `packages/views/` — zero `next/*` imports (use NavigationAdapter for routing)
|
||||
- `apps/web/platform/` — the only place for Next.js APIs, env vars, and browser globals
|
||||
These are hard constraints. Violating them breaks the cross-platform architecture:
|
||||
|
||||
- `packages/core/` — zero react-dom, zero localStorage (use StorageAdapter), zero process.env, zero UI libraries. **All shared Zustand stores live here**, even view-related ones (filters, view modes) — stores are pure state, not UI.
|
||||
- `packages/ui/` — zero `@multica/core` imports (pure UI, no business logic).
|
||||
- `packages/views/` — zero `next/*` imports, zero `react-router-dom` imports, zero stores. Use `NavigationAdapter` for all routing.
|
||||
- `apps/web/platform/` — the only place for Next.js APIs (`next/navigation`).
|
||||
- `apps/desktop/src/renderer/src/platform/` — the only place for react-router-dom navigation wiring.
|
||||
|
||||
### The No-Duplication Rule
|
||||
|
||||
**If the same logic exists in both apps, it must be extracted to a shared package.**
|
||||
|
||||
This applies to everything: components, hooks, guards, providers, utility functions. The decision process:
|
||||
|
||||
1. Does this code depend on Next.js or Electron APIs? → Keep in the respective app.
|
||||
2. Does it depend on `react-router-dom` or `next/navigation`? → Keep in app's `platform/` layer.
|
||||
3. Everything else → belongs in `packages/core/` (headless logic) or `packages/views/` (UI components).
|
||||
|
||||
When the two apps need different behavior for the same concept (e.g., different loading UI), extract the shared logic into a component with props/slots for the differences. Don't duplicate the logic.
|
||||
|
||||
### Cross-Platform Development Rules
|
||||
|
||||
When adding a new page or feature:
|
||||
|
||||
1. **New page component** → add to `packages/views/<domain>/`. Never import from `next/*` or `react-router-dom`.
|
||||
2. **Wire it in both apps** → add a route in `apps/web/app/` (Next.js page file) AND in the desktop router.
|
||||
3. **Navigation** → use `useNavigation().push()` or `<AppLink>`. Never use framework-specific link/router APIs in shared code.
|
||||
4. **Shared guards/providers** → use `DashboardGuard` from `packages/views/layout/`. Don't create separate guard logic per app.
|
||||
5. **Platform-specific UI** → if a feature is web-only or desktop-only, keep it in the respective app. Use props slots (`extra`, `topSlot`) on shared layout components to inject platform-specific UI.
|
||||
6. **New hooks that need workspace context** → accept `wsId` as parameter instead of reading from `useWorkspaceId()` Context, so they work both inside and outside `WorkspaceIdProvider`.
|
||||
|
||||
### CSS Architecture
|
||||
|
||||
Both apps share the same CSS foundation from `packages/ui/styles/`.
|
||||
|
||||
- **Design tokens** → use semantic tokens (`bg-background`, `text-muted-foreground`). Never use hardcoded Tailwind colors (`text-red-500`, `bg-gray-100`).
|
||||
- **Shared styles** → `packages/ui/styles/`. Never duplicate scrollbar styling, keyframes, or base layer rules in app CSS.
|
||||
- **`@source` directives** → both apps scan shared packages so Tailwind sees all class names.
|
||||
|
||||
## UI/UX Rules
|
||||
|
||||
- Prefer shadcn components over custom implementations. Install via `npx shadcn add <component> -c apps/web` (monorepo flag required).
|
||||
- **Shared UI components** → `packages/ui/components/` — shadcn primitives and pure-props common components.
|
||||
- **Shared business components** → `packages/views/<domain>/components/` — pages and domain-bound UI.
|
||||
- **Web-only components** → `apps/web/features/` or `apps/web/components/`.
|
||||
- Use shadcn design tokens for styling (e.g. `bg-primary`, `text-muted-foreground`, `text-destructive`). Avoid hardcoded color values (e.g. `text-red-500`, `bg-gray-100`).
|
||||
- Prefer shadcn components over custom implementations. Install via `pnpm ui:add <component>` from project root — adds to `packages/ui/components/ui/`. All components use Base UI primitives (`@base-ui/react`), not Radix.
|
||||
- Use shadcn design tokens for styling. Avoid hardcoded color values.
|
||||
- Do not introduce extra state (useState, context, reducers) unless explicitly required by the design.
|
||||
- Pay close attention to **overflow** (truncate long text, scrollable containers), **alignment**, and **spacing** consistency.
|
||||
- When unsure about interaction or state design, ask — the user will provide direction.
|
||||
- **If a component is identical between web and desktop, it belongs in a shared package.** Do not copy-paste between apps.
|
||||
|
||||
## Testing Rules
|
||||
|
||||
- **TypeScript**: Vitest. Mock external/third-party dependencies only.
|
||||
- **Go**: Standard `go test`. Tests should create their own fixture data in a test database.
|
||||
### Where to write tests
|
||||
|
||||
Tests follow the code, not the app. This is the most important testing principle in this monorepo:
|
||||
|
||||
| What you're testing | Where the test lives | Why |
|
||||
|---|---|---|
|
||||
| Shared business logic (stores, queries, hooks) | `packages/core/*.test.ts` | No DOM needed, pure logic |
|
||||
| Shared UI components (pages, forms, modals) | `packages/views/*.test.tsx` | jsdom, no framework mocks |
|
||||
| Platform-specific wiring (cookies, redirects, searchParams) | `apps/web/*.test.tsx` or `apps/desktop/` | Needs framework-specific mocks |
|
||||
| End-to-end user flows | `e2e/*.spec.ts` | Real browser, real backend |
|
||||
|
||||
**Never test shared component behavior in an app's test file.** If a test requires mocking `next/navigation` or `react-router-dom` to test a component from `@multica/views`, the test is in the wrong place — move it to `packages/views/` and mock `@multica/core` instead.
|
||||
|
||||
### Test infrastructure
|
||||
|
||||
- `packages/core/` — Vitest, Node environment (no DOM)
|
||||
- `packages/views/` — Vitest, jsdom environment, `@testing-library/react`
|
||||
- `apps/web/` — Vitest, jsdom environment, framework-specific mocks
|
||||
- `e2e/` — Playwright
|
||||
- `server/` — Go standard `go test`
|
||||
|
||||
All test deps are in the pnpm catalog for unified versioning.
|
||||
|
||||
### Mocking conventions
|
||||
|
||||
- Mock `@multica/core` stores with `vi.hoisted()` + `Object.assign(selectorFn, { getState })` pattern (Zustand stores are both callable and have `.getState()`).
|
||||
- Mock `@multica/core/api` for API calls.
|
||||
- In `packages/views/` tests: never mock `next/*` or `react-router-dom` — those don't exist here.
|
||||
- In `apps/web/` tests: mock framework-specific APIs only for platform-specific behavior.
|
||||
|
||||
### TDD workflow
|
||||
|
||||
1. Write failing test in the **correct package** first.
|
||||
2. Write implementation.
|
||||
3. Run `pnpm test` (Turborepo discovers all packages).
|
||||
4. Green → done.
|
||||
|
||||
### Go tests
|
||||
|
||||
Standard `go test`. Tests should create their own fixture data in a test database.
|
||||
|
||||
### E2E tests
|
||||
|
||||
E2E tests should be self-contained. Use the `TestApiClient` fixture for data setup/teardown:
|
||||
|
||||
```typescript
|
||||
import { loginAsDefault, createTestApi } from "./helpers";
|
||||
import type { TestApiClient } from "./fixtures";
|
||||
|
||||
let api: TestApiClient;
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
api = await createTestApi();
|
||||
await loginAsDefault(page);
|
||||
});
|
||||
|
||||
test.afterEach(async () => {
|
||||
await api.cleanup();
|
||||
});
|
||||
|
||||
test("example", async ({ page }) => {
|
||||
const issue = await api.createIssue("Test Issue");
|
||||
await page.goto(`/issues/${issue.id}`);
|
||||
});
|
||||
```
|
||||
|
||||
## Commit Rules
|
||||
|
||||
- Use atomic commits grouped by logical intent.
|
||||
- Conventional format:
|
||||
- `feat(scope): ...`
|
||||
- `fix(scope): ...`
|
||||
- `refactor(scope): ...`
|
||||
- `docs: ...`
|
||||
- `test(scope): ...`
|
||||
- `chore(scope): ...`
|
||||
|
||||
## CLI Release
|
||||
|
||||
**Prerequisite:** A CLI release must accompany every Production deployment. When deploying to Production, always release a new CLI version as part of the process.
|
||||
|
||||
1. Create a tag on the `main` branch: `git tag v0.x.x`
|
||||
2. Push the tag: `git push origin v0.x.x`
|
||||
3. GitHub Actions automatically triggers `release.yml`: runs Go tests → GoReleaser builds multi-platform binaries → publishes to GitHub Releases + Homebrew tap
|
||||
|
||||
By default, bump the patch version each release (e.g. `v0.1.12` → `v0.1.13`), unless the user specifies a specific version.
|
||||
- Conventional format: `feat(scope)`, `fix(scope)`, `refactor(scope)`, `docs`, `test(scope)`, `chore(scope)`.
|
||||
|
||||
## Minimum Pre-Push Checks
|
||||
|
||||
@@ -315,7 +262,7 @@ Run verification only when the user explicitly asks for it.
|
||||
For targeted checks when requested:
|
||||
```bash
|
||||
pnpm typecheck # TypeScript type errors only
|
||||
pnpm test # TS unit tests only (Vitest)
|
||||
pnpm test # TS unit tests only (Vitest, all packages)
|
||||
make test # Go tests only
|
||||
pnpm exec playwright test # E2E only (requires backend + frontend running)
|
||||
```
|
||||
@@ -328,43 +275,29 @@ After writing or modifying code, always run the full verification pipeline:
|
||||
make check
|
||||
```
|
||||
|
||||
This runs all checks in sequence:
|
||||
1. TypeScript typecheck (`pnpm typecheck`)
|
||||
2. TypeScript unit tests (`pnpm test`)
|
||||
3. Go tests (`go test ./...`)
|
||||
4. E2E tests (auto-starts backend + frontend if needed, runs Playwright)
|
||||
|
||||
**Workflow:**
|
||||
- Write code to satisfy the requirement
|
||||
- Run `make check`
|
||||
- If any step fails, read the error output, fix the code, and re-run `make check`
|
||||
- If any step fails, read the error output, fix the code, and re-run
|
||||
- Repeat until all checks pass
|
||||
- Only then consider the task complete
|
||||
|
||||
**Quick iteration:** If you know only TypeScript or Go is affected, run individual checks first for faster feedback, then finish with a full `make check` before marking work complete.
|
||||
|
||||
## E2E Test Patterns
|
||||
## CLI Release
|
||||
|
||||
E2E tests should be self-contained. Use the `TestApiClient` fixture for data setup/teardown:
|
||||
**Prerequisite:** A CLI release must accompany every Production deployment.
|
||||
|
||||
```typescript
|
||||
import { loginAsDefault, createTestApi } from "./helpers";
|
||||
import type { TestApiClient } from "./fixtures";
|
||||
1. Create a tag on the `main` branch: `git tag v0.x.x`
|
||||
2. Push the tag: `git push origin v0.x.x`
|
||||
3. GitHub Actions automatically triggers `release.yml`: runs Go tests → GoReleaser builds multi-platform binaries → publishes to GitHub Releases + Homebrew tap
|
||||
|
||||
let api: TestApiClient;
|
||||
By default, bump the patch version each release (e.g. `v0.1.12` → `v0.1.13`), unless the user specifies a specific version.
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
api = await createTestApi(); // logged-in API client
|
||||
await loginAsDefault(page); // browser session
|
||||
});
|
||||
## Multi-tenancy
|
||||
|
||||
test.afterEach(async () => {
|
||||
await api.cleanup(); // delete any data created during the test
|
||||
});
|
||||
All queries filter by `workspace_id`. Membership checks gate access. `X-Workspace-ID` header routes requests to the correct workspace.
|
||||
|
||||
test("example", async ({ page }) => {
|
||||
const issue = await api.createIssue("Test Issue"); // create via API
|
||||
await page.goto(`/issues/${issue.id}`); // test via UI
|
||||
// api.cleanup() in afterEach removes the issue
|
||||
});
|
||||
```
|
||||
## Agent Assignees
|
||||
|
||||
Assignees are polymorphic — can be a member or an agent. `assignee_type` + `assignee_id` on issues. Agents render with distinct styling (purple background, robot icon).
|
||||
|
||||
15
README.md
15
README.md
@@ -31,7 +31,7 @@ Turn coding agents into real teammates — assign tasks, track progress, compoun
|
||||
|
||||
Multica turns coding agents into real teammates. Assign issues to an agent like you'd assign to a colleague — they'll pick up the work, write code, report blockers, and update statuses autonomously.
|
||||
|
||||
No more copy-pasting prompts. No more babysitting runs. Your agents show up on the board, participate in conversations, and compound reusable skills over time. Think of it as open-source infrastructure for managed agents — vendor-neutral, self-hosted, and designed for human + AI teams. Works with **Claude Code** and **Codex**.
|
||||
No more copy-pasting prompts. No more babysitting runs. Your agents show up on the board, participate in conversations, and compound reusable skills over time. Think of it as open-source infrastructure for managed agents — vendor-neutral, self-hosted, and designed for human + AI teams. Works with **Claude Code**, **Codex**, **OpenClaw**, and **OpenCode**.
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/assets/hero-screenshot.png" alt="Multica board view" width="800">
|
||||
@@ -72,7 +72,7 @@ See the [Self-Hosting Guide](SELF_HOSTING.md) for full instructions.
|
||||
|
||||
The `multica` CLI connects your local machine to Multica — authenticate, manage workspaces, and run the agent daemon.
|
||||
|
||||
**Option A — paste this to your coding agent (Claude Code, Codex, etc.):**
|
||||
**Option A — paste this to your coding agent (Claude Code, Codex, OpenClaw, OpenCode, etc.):**
|
||||
|
||||
```
|
||||
Fetch https://github.com/multica-ai/multica/blob/main/CLI_INSTALL.md and follow the instructions to install Multica CLI, log in, and start the daemon on this machine.
|
||||
@@ -90,7 +90,7 @@ multica login
|
||||
multica daemon start
|
||||
```
|
||||
|
||||
The daemon auto-detects available agent CLIs (`claude`, `codex`) on your PATH. When an agent is assigned a task, the daemon creates an isolated environment, runs the agent, and reports results back.
|
||||
The daemon auto-detects available agent CLIs (`claude`, `codex`, `openclaw`, `opencode`) on your PATH. When an agent is assigned a task, the daemon creates an isolated environment, runs the agent, and reports results back.
|
||||
|
||||
See the [CLI and Daemon Guide](CLI_AND_DAEMON.md) for the full command reference, daemon configuration, and advanced usage.
|
||||
|
||||
@@ -105,7 +105,7 @@ multica login # Authenticate with your Multica account
|
||||
multica daemon start # Start the local agent runtime
|
||||
```
|
||||
|
||||
The daemon runs in the background and keeps your machine connected to Multica. It auto-detects agent CLIs (`claude`, `codex`) available on your PATH.
|
||||
The daemon runs in the background and keeps your machine connected to Multica. It auto-detects agent CLIs (`claude`, `codex`, `openclaw`, `opencode`) available on your PATH.
|
||||
|
||||
### 2. Verify your runtime
|
||||
|
||||
@@ -115,7 +115,7 @@ Open your workspace in the Multica web app. Navigate to **Settings → Runtimes*
|
||||
|
||||
### 3. Create an agent
|
||||
|
||||
Go to **Settings → Agents** and click **New Agent**. Pick the runtime you just connected and choose a provider (Claude Code or Codex). Give your agent a name — this is how it will appear on the board, in comments, and in assignments.
|
||||
Go to **Settings → Agents** and click **New Agent**. Pick the runtime you just connected and choose a provider (Claude Code, Codex, OpenClaw, or OpenCode). Give your agent a name — this is how it will appear on the board, in comments, and in assignments.
|
||||
|
||||
### 4. Assign your first task
|
||||
|
||||
@@ -133,7 +133,8 @@ That's it! Your agent is now part of the team. 🎉
|
||||
│
|
||||
┌──────┴───────┐
|
||||
│ Agent Daemon │ (runs on your machine)
|
||||
│ Claude/Codex │
|
||||
│Claude/Codex/ │
|
||||
│OpenClaw/Code │
|
||||
└──────────────┘
|
||||
```
|
||||
|
||||
@@ -142,7 +143,7 @@ That's it! Your agent is now part of the team. 🎉
|
||||
| Frontend | Next.js 16 (App Router) |
|
||||
| Backend | Go (Chi router, sqlc, gorilla/websocket) |
|
||||
| Database | PostgreSQL 17 with pgvector |
|
||||
| Agent Runtime | Local daemon executing Claude Code or Codex |
|
||||
| Agent Runtime | Local daemon executing Claude Code, Codex, OpenClaw, or OpenCode |
|
||||
|
||||
## Development
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
|
||||
Multica 将编码 Agent 变成真正的队友。像分配给同事一样分配给 Agent——它们会自主接手工作、编写代码、报告阻塞问题、更新状态。
|
||||
|
||||
不再需要复制粘贴 prompt,不再需要盯着运行过程。你的 Agent 出现在看板上、参与对话、随着时间积累可复用的技能。可以理解为开源的 Managed Agents 基础设施——厂商中立、可自部署、专为人类 + AI 团队设计。支持 **Claude Code** 和 **Codex**。
|
||||
不再需要复制粘贴 prompt,不再需要盯着运行过程。你的 Agent 出现在看板上、参与对话、随着时间积累可复用的技能。可以理解为开源的 Managed Agents 基础设施——厂商中立、可自部署、专为人类 + AI 团队设计。支持 **Claude Code**、**Codex**、**OpenClaw** 和 **OpenCode**。
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/assets/hero-screenshot.png" alt="Multica 看板视图" width="800">
|
||||
@@ -72,7 +72,7 @@ make start # 启动应用
|
||||
|
||||
`multica` CLI 将你的本地机器连接到 Multica — 用于认证、管理工作区和运行 Agent daemon。
|
||||
|
||||
**方式 A — 将以下指令粘贴给你的 coding agent(Claude Code、Codex 等):**
|
||||
**方式 A — 将以下指令粘贴给你的 coding agent(Claude Code、Codex、OpenClaw、OpenCode 等):**
|
||||
|
||||
```
|
||||
Fetch https://github.com/multica-ai/multica/blob/main/CLI_INSTALL.md and follow the instructions to install Multica CLI, log in, and start the daemon on this machine.
|
||||
@@ -90,7 +90,7 @@ multica login
|
||||
multica daemon start
|
||||
```
|
||||
|
||||
daemon 会自动检测 PATH 中可用的 Agent CLI(`claude`、`codex`)。当 Agent 被分配任务时,daemon 会创建隔离环境、运行 Agent、并将结果回传。
|
||||
daemon 会自动检测 PATH 中可用的 Agent CLI(`claude`、`codex`、`openclaw`、`opencode`)。当 Agent 被分配任务时,daemon 会创建隔离环境、运行 Agent、并将结果回传。
|
||||
|
||||
完整命令参考请参阅 [CLI 与 Daemon 指南](CLI_AND_DAEMON.md)。
|
||||
|
||||
@@ -105,7 +105,7 @@ multica login # 使用你的 Multica 账号认证
|
||||
multica daemon start # 启动本地 Agent 运行时
|
||||
```
|
||||
|
||||
daemon 在后台运行,保持你的机器与 Multica 的连接。它会自动检测 PATH 中可用的 Agent CLI(`claude`、`codex`)。
|
||||
daemon 在后台运行,保持你的机器与 Multica 的连接。它会自动检测 PATH 中可用的 Agent CLI(`claude`、`codex`、`openclaw`、`opencode`)。
|
||||
|
||||
### 2. 确认运行时已连接
|
||||
|
||||
@@ -115,7 +115,7 @@ daemon 在后台运行,保持你的机器与 Multica 的连接。它会自动
|
||||
|
||||
### 3. 创建 Agent
|
||||
|
||||
进入 **设置 → Agents**,点击 **新建 Agent**。选择你刚连接的 Runtime,选择 Provider(Claude Code 或 Codex),并为 Agent 起个名字——它将以这个名字出现在看板、评论和任务分配中。
|
||||
进入 **设置 → Agents**,点击 **新建 Agent**。选择你刚连接的 Runtime,选择 Provider(Claude Code、Codex、OpenClaw 或 OpenCode),并为 Agent 起个名字——它将以这个名字出现在看板、评论和任务分配中。
|
||||
|
||||
### 4. 分配你的第一个任务
|
||||
|
||||
@@ -133,7 +133,8 @@ daemon 在后台运行,保持你的机器与 Multica 的连接。它会自动
|
||||
│
|
||||
┌──────┴───────┐
|
||||
│ Agent Daemon │ (运行在你的机器上)
|
||||
│ Claude/Codex │
|
||||
│Claude/Codex/ │
|
||||
│OpenClaw/Code │
|
||||
└──────────────┘
|
||||
```
|
||||
|
||||
@@ -142,7 +143,7 @@ daemon 在后台运行,保持你的机器与 Multica 的连接。它会自动
|
||||
| 前端 | Next.js 16 (App Router) |
|
||||
| 后端 | Go (Chi router, sqlc, gorilla/websocket) |
|
||||
| 数据库 | PostgreSQL 17 with pgvector |
|
||||
| Agent 运行时 | 本地 daemon 执行 Claude Code 或 Codex |
|
||||
| Agent 运行时 | 本地 daemon 执行 Claude Code、Codex、OpenClaw 或 OpenCode |
|
||||
|
||||
## 开发
|
||||
|
||||
|
||||
6
apps/desktop/.gitignore
vendored
Normal file
6
apps/desktop/.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
node_modules
|
||||
dist
|
||||
out
|
||||
.DS_Store
|
||||
.eslintcache
|
||||
*.log*
|
||||
31
apps/desktop/electron-builder.yml
Normal file
31
apps/desktop/electron-builder.yml
Normal file
@@ -0,0 +1,31 @@
|
||||
appId: ai.multica.desktop
|
||||
productName: Multica
|
||||
directories:
|
||||
buildResources: build
|
||||
files:
|
||||
- "!**/.vscode/*"
|
||||
- "!src/*"
|
||||
- "!electron.vite.config.*"
|
||||
- "!{.eslintignore,.eslintrc.cjs,.prettierignore,.prettierrc.yaml,dev-app-update.yml,CHANGELOG.md,README.md}"
|
||||
- "!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}"
|
||||
asarUnpack:
|
||||
- resources/**
|
||||
mac:
|
||||
entitlementsInherit: build/entitlements.mac.plist
|
||||
target:
|
||||
- dmg
|
||||
- zip
|
||||
artifactName: ${name}-${version}-${arch}.${ext}
|
||||
notarize: false
|
||||
dmg:
|
||||
artifactName: ${name}-${version}.${ext}
|
||||
linux:
|
||||
target:
|
||||
- AppImage
|
||||
- deb
|
||||
artifactName: ${name}-${version}-${arch}.${ext}
|
||||
win:
|
||||
target:
|
||||
- nsis
|
||||
artifactName: ${name}-${version}-setup.${ext}
|
||||
npmRebuild: false
|
||||
22
apps/desktop/electron.vite.config.ts
Normal file
22
apps/desktop/electron.vite.config.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { resolve } from "path";
|
||||
import { defineConfig, externalizeDepsPlugin } from "electron-vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
|
||||
export default defineConfig({
|
||||
main: {
|
||||
plugins: [externalizeDepsPlugin()],
|
||||
},
|
||||
preload: {
|
||||
plugins: [externalizeDepsPlugin()],
|
||||
},
|
||||
renderer: {
|
||||
plugins: [react(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": resolve("src/renderer/src"),
|
||||
},
|
||||
dedupe: ["react", "react-dom"],
|
||||
},
|
||||
},
|
||||
});
|
||||
6
apps/desktop/eslint.config.mjs
Normal file
6
apps/desktop/eslint.config.mjs
Normal file
@@ -0,0 +1,6 @@
|
||||
import reactConfig from "@multica/eslint-config/react";
|
||||
|
||||
export default [
|
||||
...reactConfig,
|
||||
{ ignores: ["out/", "dist/"] },
|
||||
];
|
||||
44
apps/desktop/package.json
Normal file
44
apps/desktop/package.json
Normal file
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"name": "@multica/desktop",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"main": "./out/main/index.js",
|
||||
"scripts": {
|
||||
"dev": "electron-vite dev",
|
||||
"build": "electron-vite build",
|
||||
"typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false",
|
||||
"typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false",
|
||||
"typecheck": "pnpm run typecheck:node && pnpm run typecheck:web",
|
||||
"preview": "electron-vite preview",
|
||||
"package": "electron-builder",
|
||||
"lint": "eslint .",
|
||||
"postinstall": "electron-builder install-app-deps"
|
||||
},
|
||||
"dependencies": {
|
||||
"@electron-toolkit/preload": "^3.0.2",
|
||||
"@electron-toolkit/utils": "^4.0.0",
|
||||
"@multica/core": "workspace:*",
|
||||
"@multica/ui": "workspace:*",
|
||||
"@multica/views": "workspace:*",
|
||||
"react-router-dom": "^7.6.0",
|
||||
"shadcn": "^4.1.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tw-animate-css": "^1.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@multica/tsconfig": "workspace:*",
|
||||
"@electron-toolkit/tsconfig": "^2.0.0",
|
||||
"@tailwindcss/vite": "^4",
|
||||
"@types/node": "catalog:",
|
||||
"@types/react": "catalog:",
|
||||
"@types/react-dom": "catalog:",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"electron": "^39.2.6",
|
||||
"electron-builder": "^26.0.12",
|
||||
"electron-vite": "^5.0.0",
|
||||
"react": "catalog:",
|
||||
"react-dom": "catalog:",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "catalog:"
|
||||
}
|
||||
}
|
||||
BIN
apps/desktop/resources/icon.png
Normal file
BIN
apps/desktop/resources/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 35 KiB |
55
apps/desktop/src/main/index.ts
Normal file
55
apps/desktop/src/main/index.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { app, shell, BrowserWindow } from "electron";
|
||||
import { join } from "path";
|
||||
import { electronApp, optimizer, is } from "@electron-toolkit/utils";
|
||||
|
||||
let mainWindow: BrowserWindow | null = null;
|
||||
|
||||
function createWindow(): void {
|
||||
mainWindow = new BrowserWindow({
|
||||
width: 1280,
|
||||
height: 800,
|
||||
minWidth: 900,
|
||||
minHeight: 600,
|
||||
titleBarStyle: "hiddenInset",
|
||||
trafficLightPosition: { x: 16, y: 13 },
|
||||
show: false,
|
||||
autoHideMenuBar: true,
|
||||
webPreferences: {
|
||||
preload: join(__dirname, "../preload/index.js"),
|
||||
sandbox: false,
|
||||
},
|
||||
});
|
||||
|
||||
mainWindow.on("ready-to-show", () => {
|
||||
mainWindow?.show();
|
||||
});
|
||||
|
||||
mainWindow.webContents.setWindowOpenHandler((details) => {
|
||||
shell.openExternal(details.url);
|
||||
return { action: "deny" };
|
||||
});
|
||||
|
||||
if (is.dev && process.env["ELECTRON_RENDERER_URL"]) {
|
||||
mainWindow.loadURL(process.env["ELECTRON_RENDERER_URL"]);
|
||||
} else {
|
||||
mainWindow.loadFile(join(__dirname, "../renderer/index.html"));
|
||||
}
|
||||
}
|
||||
|
||||
app.whenReady().then(() => {
|
||||
electronApp.setAppUserModelId("ai.multica.desktop");
|
||||
|
||||
app.on("browser-window-created", (_, window) => {
|
||||
optimizer.watchWindowShortcuts(window);
|
||||
});
|
||||
|
||||
createWindow();
|
||||
|
||||
app.on("activate", () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) createWindow();
|
||||
});
|
||||
});
|
||||
|
||||
app.on("window-all-closed", () => {
|
||||
if (process.platform !== "darwin") app.quit();
|
||||
});
|
||||
9
apps/desktop/src/preload/index.d.ts
vendored
Normal file
9
apps/desktop/src/preload/index.d.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
import { ElectronAPI } from "@electron-toolkit/preload";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
electron: ElectronAPI;
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
9
apps/desktop/src/preload/index.ts
Normal file
9
apps/desktop/src/preload/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { contextBridge } from "electron";
|
||||
import { electronAPI } from "@electron-toolkit/preload";
|
||||
|
||||
if (process.contextIsolated) {
|
||||
contextBridge.exposeInMainWorld("electron", electronAPI);
|
||||
} else {
|
||||
// @ts-expect-error - fallback for non-isolated context
|
||||
window.electron = electronAPI;
|
||||
}
|
||||
12
apps/desktop/src/renderer/index.html
Normal file
12
apps/desktop/src/renderer/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en" class="h-full">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Multica</title>
|
||||
</head>
|
||||
<body class="h-full overflow-hidden antialiased font-sans">
|
||||
<div id="root" class="h-full"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
37
apps/desktop/src/renderer/src/App.tsx
Normal file
37
apps/desktop/src/renderer/src/App.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { CoreProvider } from "@multica/core/platform";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
import { ThemeProvider } from "@multica/ui/components/common/theme-provider";
|
||||
import { MulticaIcon } from "@multica/ui/components/common/multica-icon";
|
||||
import { Toaster } from "sonner";
|
||||
import { DesktopLoginPage } from "./pages/login";
|
||||
import { DesktopShell } from "./components/desktop-layout";
|
||||
|
||||
function AppContent() {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const isLoading = useAuthStore((s) => s.isLoading);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center">
|
||||
<MulticaIcon className="size-6 animate-pulse" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) return <DesktopLoginPage />;
|
||||
return <DesktopShell />;
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<CoreProvider
|
||||
apiBaseUrl={import.meta.env.VITE_API_URL || "http://localhost:8080"}
|
||||
wsUrl={import.meta.env.VITE_WS_URL || "ws://localhost:8080/ws"}
|
||||
>
|
||||
<AppContent />
|
||||
</CoreProvider>
|
||||
<Toaster />
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
102
apps/desktop/src/renderer/src/components/desktop-layout.tsx
Normal file
102
apps/desktop/src/renderer/src/components/desktop-layout.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import { useEffect } from "react";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { useTabHistory } from "@/hooks/use-tab-history";
|
||||
import { useActiveTitleSync } from "@/hooks/use-tab-sync";
|
||||
import { useTabStore, resolveRouteIcon } from "@/stores/tab-store";
|
||||
import { SidebarProvider } from "@multica/ui/components/ui/sidebar";
|
||||
import { ModalRegistry } from "@multica/views/modals/registry";
|
||||
import { AppSidebar, DashboardGuard } from "@multica/views/layout";
|
||||
import { SearchCommand, SearchTrigger } from "@multica/views/search";
|
||||
import { ChatFab, ChatWindow } from "@multica/views/chat";
|
||||
import { DesktopNavigationProvider } from "@/platform/navigation";
|
||||
import { MulticaIcon } from "@multica/ui/components/common/multica-icon";
|
||||
import { TabBar } from "./tab-bar";
|
||||
import { TabContent } from "./tab-content";
|
||||
|
||||
function SidebarTopBar() {
|
||||
const { canGoBack, canGoForward, goBack, goForward } = useTabHistory();
|
||||
|
||||
return (
|
||||
<div
|
||||
className="h-12 shrink-0 flex items-center justify-end px-2"
|
||||
style={{ WebkitAppRegion: "drag" } as React.CSSProperties}
|
||||
>
|
||||
<div
|
||||
className="flex items-center gap-0.5"
|
||||
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
|
||||
>
|
||||
<button
|
||||
onClick={goBack}
|
||||
disabled={!canGoBack}
|
||||
className="flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-30 disabled:pointer-events-none"
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={goForward}
|
||||
disabled={!canGoForward}
|
||||
className="flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-30 disabled:pointer-events-none"
|
||||
>
|
||||
<ChevronRight className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function useInternalLinkHandler() {
|
||||
useEffect(() => {
|
||||
const handler = (e: Event) => {
|
||||
const path = (e as CustomEvent).detail?.path;
|
||||
if (!path) return;
|
||||
const icon = resolveRouteIcon(path);
|
||||
const store = useTabStore.getState();
|
||||
const tabId = store.openTab(path, path, icon);
|
||||
store.setActiveTab(tabId);
|
||||
};
|
||||
window.addEventListener("multica:navigate", handler);
|
||||
return () => window.removeEventListener("multica:navigate", handler);
|
||||
}, []);
|
||||
}
|
||||
|
||||
export function DesktopShell() {
|
||||
useInternalLinkHandler();
|
||||
useActiveTitleSync();
|
||||
|
||||
return (
|
||||
<DesktopNavigationProvider>
|
||||
<DashboardGuard
|
||||
loginPath="/login"
|
||||
loadingFallback={
|
||||
<div className="flex h-screen items-center justify-center">
|
||||
<MulticaIcon className="size-6 animate-pulse" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="flex h-screen">
|
||||
<SidebarProvider className="flex-1">
|
||||
<AppSidebar topSlot={<SidebarTopBar />} searchSlot={<SearchTrigger />} />
|
||||
{/* Right side: header + content container */}
|
||||
<div className="flex flex-1 min-w-0 flex-col">
|
||||
{/* Tab bar + drag region */}
|
||||
<header
|
||||
className="h-12 shrink-0"
|
||||
style={{ WebkitAppRegion: "drag" } as React.CSSProperties}
|
||||
>
|
||||
<TabBar />
|
||||
</header>
|
||||
{/* Content area with inset styling */}
|
||||
<div className="flex flex-1 min-h-0 flex-col overflow-hidden mr-2 mb-2 ml-0.5 rounded-xl shadow-sm bg-background">
|
||||
<TabContent />
|
||||
</div>
|
||||
</div>
|
||||
</SidebarProvider>
|
||||
</div>
|
||||
<ModalRegistry />
|
||||
<SearchCommand />
|
||||
<ChatWindow />
|
||||
<ChatFab />
|
||||
</DashboardGuard>
|
||||
</DesktopNavigationProvider>
|
||||
);
|
||||
}
|
||||
112
apps/desktop/src/renderer/src/components/tab-bar.tsx
Normal file
112
apps/desktop/src/renderer/src/components/tab-bar.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import {
|
||||
Inbox,
|
||||
CircleUser,
|
||||
ListTodo,
|
||||
Bot,
|
||||
Monitor,
|
||||
BookOpenText,
|
||||
Settings,
|
||||
X,
|
||||
Plus,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import { useTabStore, resolveRouteIcon, type Tab } from "@/stores/tab-store";
|
||||
|
||||
const TAB_ICONS: Record<string, LucideIcon> = {
|
||||
Inbox,
|
||||
CircleUser,
|
||||
ListTodo,
|
||||
Bot,
|
||||
Monitor,
|
||||
BookOpenText,
|
||||
Settings,
|
||||
};
|
||||
|
||||
function TabItem({ tab, isActive, isOnly }: { tab: Tab; isActive: boolean; isOnly: boolean }) {
|
||||
const setActiveTab = useTabStore((s) => s.setActiveTab);
|
||||
const closeTab = useTabStore((s) => s.closeTab);
|
||||
|
||||
const Icon = TAB_ICONS[tab.icon];
|
||||
|
||||
const handleClick = () => {
|
||||
if (isActive) return;
|
||||
setActiveTab(tab.id);
|
||||
// No navigate() — Activity handles visibility
|
||||
};
|
||||
|
||||
const handleClose = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
closeTab(tab.id);
|
||||
// No navigate() — store handles activeTabId switch
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleClick}
|
||||
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
|
||||
className={cn(
|
||||
"group flex h-7 w-40 items-center gap-1.5 rounded-md px-2 text-xs transition-colors",
|
||||
"select-none cursor-default",
|
||||
isActive
|
||||
? "bg-sidebar-accent font-medium text-sidebar-accent-foreground"
|
||||
: "bg-sidebar-accent/50 text-muted-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
|
||||
)}
|
||||
>
|
||||
{Icon && <Icon className="size-3.5 shrink-0" />}
|
||||
<span
|
||||
className="min-w-0 flex-1 overflow-hidden whitespace-nowrap text-left"
|
||||
style={{
|
||||
maskImage: "linear-gradient(to right, black calc(100% - 12px), transparent)",
|
||||
WebkitMaskImage: "linear-gradient(to right, black calc(100% - 12px), transparent)",
|
||||
}}
|
||||
>
|
||||
{tab.title}
|
||||
</span>
|
||||
{!isOnly && (
|
||||
<span
|
||||
onClick={handleClose}
|
||||
className="hidden size-3.5 shrink-0 items-center justify-center rounded-sm text-muted-foreground transition-colors group-hover:flex hover:bg-muted-foreground/20 hover:text-foreground"
|
||||
>
|
||||
<X className="size-2.5" />
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function NewTabButton() {
|
||||
const addTab = useTabStore((s) => s.addTab);
|
||||
const setActiveTab = useTabStore((s) => s.setActiveTab);
|
||||
|
||||
const handleClick = () => {
|
||||
const path = "/issues";
|
||||
const tabId = addTab(path, "Issues", resolveRouteIcon(path));
|
||||
setActiveTab(tabId);
|
||||
// No navigate() — new tab's router starts at /issues automatically
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleClick}
|
||||
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
|
||||
className="flex size-7 shrink-0 items-center justify-center rounded-md text-muted-foreground/70 transition-colors hover:bg-muted/50 hover:text-muted-foreground"
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function TabBar() {
|
||||
const tabs = useTabStore((s) => s.tabs);
|
||||
const activeTabId = useTabStore((s) => s.activeTabId);
|
||||
|
||||
return (
|
||||
<div className="flex h-full items-center gap-0.5 px-2 justify-start">
|
||||
{tabs.map((tab) => (
|
||||
<TabItem key={tab.id} tab={tab} isActive={tab.id === activeTabId} isOnly={tabs.length === 1} />
|
||||
))}
|
||||
<NewTabButton />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
43
apps/desktop/src/renderer/src/components/tab-content.tsx
Normal file
43
apps/desktop/src/renderer/src/components/tab-content.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { Activity, useEffect } from "react";
|
||||
import { RouterProvider } from "react-router-dom";
|
||||
import { useTabStore } from "@/stores/tab-store";
|
||||
import { TabNavigationProvider } from "@/platform/navigation";
|
||||
import { useTabRouterSync } from "@/hooks/use-tab-router-sync";
|
||||
|
||||
/** Inner wrapper rendered inside each tab's RouterProvider. */
|
||||
function TabRouterInner({ tabId }: { tabId: string }) {
|
||||
const tab = useTabStore((s) => s.tabs.find((t) => t.id === tabId));
|
||||
useTabRouterSync(tabId, tab!.router);
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders all tabs using Activity for state preservation.
|
||||
* Only the active tab is visible; hidden tabs keep their DOM and React state.
|
||||
*/
|
||||
export function TabContent() {
|
||||
const tabs = useTabStore((s) => s.tabs);
|
||||
const activeTabId = useTabStore((s) => s.activeTabId);
|
||||
|
||||
// Sync document.title when switching tabs
|
||||
useEffect(() => {
|
||||
const tab = tabs.find((t) => t.id === activeTabId);
|
||||
if (tab) document.title = tab.title;
|
||||
}, [activeTabId, tabs]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{tabs.map((tab) => (
|
||||
<Activity
|
||||
key={tab.id}
|
||||
mode={tab.id === activeTabId ? "visible" : "hidden"}
|
||||
>
|
||||
<TabNavigationProvider router={tab.router}>
|
||||
<RouterProvider router={tab.router} />
|
||||
<TabRouterInner tabId={tab.id} />
|
||||
</TabNavigationProvider>
|
||||
</Activity>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
1
apps/desktop/src/renderer/src/env.d.ts
vendored
Normal file
1
apps/desktop/src/renderer/src/env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
17
apps/desktop/src/renderer/src/globals.css
Normal file
17
apps/desktop/src/renderer/src/globals.css
Normal file
@@ -0,0 +1,17 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@import "shadcn/tailwind.css";
|
||||
@import "@multica/ui/styles/tokens.css";
|
||||
@import "@multica/ui/styles/base.css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@source "../../../../../packages/ui/**/*.tsx";
|
||||
@source "../../../../../packages/core/**/*.{ts,tsx}";
|
||||
@source "../../../../../packages/views/**/*.{ts,tsx}";
|
||||
@source "./**/*.tsx";
|
||||
|
||||
/* Desktop-specific: override sidebar container padding for traffic light layout */
|
||||
[data-slot="sidebar-container"] {
|
||||
padding: 0 !important;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { useEffect } from "react";
|
||||
|
||||
/** Sets document.title. The tab system observes this automatically. */
|
||||
export function useDocumentTitle(title: string) {
|
||||
useEffect(() => {
|
||||
if (title) document.title = title;
|
||||
}, [title]);
|
||||
}
|
||||
40
apps/desktop/src/renderer/src/hooks/use-tab-history.ts
Normal file
40
apps/desktop/src/renderer/src/hooks/use-tab-history.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { useCallback } from "react";
|
||||
import type { DataRouter } from "react-router-dom";
|
||||
import { useTabStore } from "@/stores/tab-store";
|
||||
|
||||
/**
|
||||
* Shared hint map so useTabRouterSync can distinguish back vs forward POP.
|
||||
* Set before calling router.navigate(-1 | 1), read in the synchronous subscription.
|
||||
*/
|
||||
export const popDirectionHints = new Map<DataRouter, "back" | "forward">();
|
||||
|
||||
/**
|
||||
* Per-tab back/forward navigation derived from the active tab's history state.
|
||||
* Replaces the old global useNavigationHistory() hook.
|
||||
*/
|
||||
export function useTabHistory() {
|
||||
// Return the actual tab object from the store — stable reference.
|
||||
// Do NOT create a new object in the selector (causes infinite re-renders).
|
||||
const activeTab = useTabStore((s) =>
|
||||
s.tabs.find((t) => t.id === s.activeTabId),
|
||||
);
|
||||
|
||||
const canGoBack = (activeTab?.historyIndex ?? 0) > 0;
|
||||
const canGoForward =
|
||||
(activeTab?.historyIndex ?? 0) < (activeTab?.historyLength ?? 1) - 1;
|
||||
|
||||
const goBack = useCallback(() => {
|
||||
if (!activeTab || activeTab.historyIndex <= 0) return;
|
||||
popDirectionHints.set(activeTab.router, "back");
|
||||
activeTab.router.navigate(-1);
|
||||
}, [activeTab]);
|
||||
|
||||
const goForward = useCallback(() => {
|
||||
if (!activeTab || activeTab.historyIndex >= activeTab.historyLength - 1)
|
||||
return;
|
||||
popDirectionHints.set(activeTab.router, "forward");
|
||||
activeTab.router.navigate(1);
|
||||
}, [activeTab]);
|
||||
|
||||
return { canGoBack, canGoForward, goBack, goForward };
|
||||
}
|
||||
49
apps/desktop/src/renderer/src/hooks/use-tab-router-sync.ts
Normal file
49
apps/desktop/src/renderer/src/hooks/use-tab-router-sync.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import type { DataRouter } from "react-router-dom";
|
||||
import { useTabStore, resolveRouteIcon } from "@/stores/tab-store";
|
||||
import { popDirectionHints } from "./use-tab-history";
|
||||
|
||||
/**
|
||||
* Subscribe to a tab's memory router and sync path + history tracking
|
||||
* back into the tab store.
|
||||
*
|
||||
* Called once per tab inside its RouterProvider subtree.
|
||||
*/
|
||||
export function useTabRouterSync(tabId: string, router: DataRouter) {
|
||||
const indexRef = useRef(0);
|
||||
const lengthRef = useRef(1);
|
||||
|
||||
useEffect(() => {
|
||||
// Sync initial state
|
||||
const initialPath = router.state.location.pathname;
|
||||
const store = useTabStore.getState();
|
||||
store.updateTab(tabId, { path: initialPath, icon: resolveRouteIcon(initialPath) });
|
||||
|
||||
const unsubscribe = router.subscribe((state) => {
|
||||
const { pathname } = state.location;
|
||||
const action = state.historyAction;
|
||||
|
||||
if (action === "PUSH") {
|
||||
indexRef.current += 1;
|
||||
lengthRef.current = indexRef.current + 1;
|
||||
} else if (action === "POP") {
|
||||
// Determine direction from the hint set by goBack/goForward
|
||||
const hint = popDirectionHints.get(router);
|
||||
popDirectionHints.delete(router);
|
||||
if (hint === "forward") {
|
||||
indexRef.current = Math.min(indexRef.current + 1, lengthRef.current - 1);
|
||||
} else {
|
||||
// Default to back
|
||||
indexRef.current = Math.max(0, indexRef.current - 1);
|
||||
}
|
||||
}
|
||||
// REPLACE: index and length stay the same
|
||||
|
||||
const store = useTabStore.getState();
|
||||
store.updateTab(tabId, { path: pathname, icon: resolveRouteIcon(pathname) });
|
||||
store.updateTabHistory(tabId, indexRef.current, lengthRef.current);
|
||||
});
|
||||
|
||||
return unsubscribe;
|
||||
}, [tabId, router]);
|
||||
}
|
||||
29
apps/desktop/src/renderer/src/hooks/use-tab-sync.ts
Normal file
29
apps/desktop/src/renderer/src/hooks/use-tab-sync.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { useEffect } from "react";
|
||||
import { useTabStore } from "@/stores/tab-store";
|
||||
|
||||
/**
|
||||
* Watches document.title via MutationObserver and updates the active tab's title.
|
||||
*
|
||||
* Pages set document.title via TitleSync (route handle.title) or useDocumentTitle().
|
||||
* This observer picks up the change and syncs it to the tab store.
|
||||
*/
|
||||
export function useActiveTitleSync() {
|
||||
useEffect(() => {
|
||||
const observer = new MutationObserver(() => {
|
||||
const title = document.title;
|
||||
if (!title) return;
|
||||
const { tabs, activeTabId } = useTabStore.getState();
|
||||
const activeTab = tabs.find((t) => t.id === activeTabId);
|
||||
if (activeTab && activeTab.title !== title) {
|
||||
useTabStore.getState().updateTab(activeTabId, { title });
|
||||
}
|
||||
});
|
||||
|
||||
const titleEl = document.querySelector("title");
|
||||
if (titleEl) {
|
||||
observer.observe(titleEl, { childList: true, characterData: true, subtree: true });
|
||||
}
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
}
|
||||
5
apps/desktop/src/renderer/src/main.tsx
Normal file
5
apps/desktop/src/renderer/src/main.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import ReactDOM from "react-dom/client";
|
||||
import App from "./App";
|
||||
import "./globals.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(<App />);
|
||||
17
apps/desktop/src/renderer/src/pages/issue-detail-page.tsx
Normal file
17
apps/desktop/src/renderer/src/pages/issue-detail-page.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { IssueDetail } from "@multica/views/issues/components";
|
||||
import { useWorkspaceId } from "@multica/core/hooks";
|
||||
import { issueDetailOptions } from "@multica/core/issues/queries";
|
||||
import { useDocumentTitle } from "@/hooks/use-document-title";
|
||||
|
||||
export function IssueDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const wsId = useWorkspaceId();
|
||||
const { data: issue } = useQuery(issueDetailOptions(wsId, id!));
|
||||
|
||||
useDocumentTitle(issue ? `${issue.identifier}: ${issue.title}` : "Issue");
|
||||
|
||||
if (!id) return null;
|
||||
return <IssueDetail issueId={id} />;
|
||||
}
|
||||
20
apps/desktop/src/renderer/src/pages/login.tsx
Normal file
20
apps/desktop/src/renderer/src/pages/login.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { LoginPage } from "@multica/views/auth";
|
||||
import { MulticaIcon } from "@multica/ui/components/common/multica-icon";
|
||||
|
||||
export function DesktopLoginPage() {
|
||||
return (
|
||||
<div className="flex h-screen flex-col">
|
||||
{/* Traffic light inset */}
|
||||
<div
|
||||
className="h-[38px] shrink-0"
|
||||
style={{ WebkitAppRegion: "drag" } as React.CSSProperties}
|
||||
/>
|
||||
<LoginPage
|
||||
logo={<MulticaIcon bordered size="lg" />}
|
||||
onSuccess={() => {
|
||||
// Auth store update triggers AppContent re-render → shows DesktopShell
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
17
apps/desktop/src/renderer/src/pages/project-detail-page.tsx
Normal file
17
apps/desktop/src/renderer/src/pages/project-detail-page.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ProjectDetail } from "@multica/views/projects/components";
|
||||
import { useWorkspaceId } from "@multica/core/hooks";
|
||||
import { projectDetailOptions } from "@multica/core/projects/queries";
|
||||
import { useDocumentTitle } from "@/hooks/use-document-title";
|
||||
|
||||
export function ProjectDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const wsId = useWorkspaceId();
|
||||
const { data: project } = useQuery(projectDetailOptions(wsId, id!));
|
||||
|
||||
useDocumentTitle(project ? `${project.icon || "📁"} ${project.title}` : "Project");
|
||||
|
||||
if (!id) return null;
|
||||
return <ProjectDetail projectId={id} />;
|
||||
}
|
||||
116
apps/desktop/src/renderer/src/platform/navigation.tsx
Normal file
116
apps/desktop/src/renderer/src/platform/navigation.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import type { DataRouter } from "react-router-dom";
|
||||
import {
|
||||
NavigationProvider,
|
||||
type NavigationAdapter,
|
||||
} from "@multica/views/navigation";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
import { useTabStore, resolveRouteIcon } from "@/stores/tab-store";
|
||||
|
||||
/**
|
||||
* Root-level navigation provider for components outside the per-tab RouterProviders
|
||||
* (sidebar, search dialog, modals, etc.).
|
||||
*
|
||||
* Reads from the active tab's memory router via router.subscribe().
|
||||
* Does NOT use any react-router hooks — it's above all RouterProviders.
|
||||
*/
|
||||
export function DesktopNavigationProvider({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const activeTab = useTabStore((s) => s.tabs.find((t) => t.id === s.activeTabId));
|
||||
const [pathname, setPathname] = useState(activeTab?.path ?? "/issues");
|
||||
|
||||
// Subscribe to the active tab's router for pathname updates
|
||||
useEffect(() => {
|
||||
if (!activeTab) return;
|
||||
setPathname(activeTab.router.state.location.pathname);
|
||||
return activeTab.router.subscribe((state) => {
|
||||
setPathname(state.location.pathname);
|
||||
});
|
||||
}, [activeTab?.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const adapter: NavigationAdapter = useMemo(
|
||||
() => ({
|
||||
push: (path: string) => {
|
||||
if (path === "/login") {
|
||||
// DashboardGuard token expired — force back to login screen
|
||||
useAuthStore.getState().logout();
|
||||
return;
|
||||
}
|
||||
const tab = useTabStore.getState().tabs.find(
|
||||
(t) => t.id === useTabStore.getState().activeTabId,
|
||||
);
|
||||
tab?.router.navigate(path);
|
||||
},
|
||||
replace: (path: string) => {
|
||||
const tab = useTabStore.getState().tabs.find(
|
||||
(t) => t.id === useTabStore.getState().activeTabId,
|
||||
);
|
||||
tab?.router.navigate(path, { replace: true });
|
||||
},
|
||||
back: () => {
|
||||
const tab = useTabStore.getState().tabs.find(
|
||||
(t) => t.id === useTabStore.getState().activeTabId,
|
||||
);
|
||||
tab?.router.navigate(-1);
|
||||
},
|
||||
pathname,
|
||||
searchParams: new URLSearchParams(),
|
||||
openInNewTab: (path: string, title?: string) => {
|
||||
const icon = resolveRouteIcon(path);
|
||||
const store = useTabStore.getState();
|
||||
const tabId = store.openTab(path, title ?? path, icon);
|
||||
store.setActiveTab(tabId);
|
||||
},
|
||||
getShareableUrl: (path: string) => `https://www.multica.ai${path}`,
|
||||
}),
|
||||
[pathname],
|
||||
);
|
||||
|
||||
return <NavigationProvider value={adapter}>{children}</NavigationProvider>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-tab navigation provider rendered inside each tab's Activity wrapper.
|
||||
* Subscribes to the tab's own router for up-to-date pathname.
|
||||
*
|
||||
* This is what @multica/views page components read via useNavigation().
|
||||
*/
|
||||
export function TabNavigationProvider({
|
||||
router,
|
||||
children,
|
||||
}: {
|
||||
router: DataRouter;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const [location, setLocation] = useState(router.state.location);
|
||||
|
||||
useEffect(() => {
|
||||
setLocation(router.state.location);
|
||||
return router.subscribe((state) => {
|
||||
setLocation(state.location);
|
||||
});
|
||||
}, [router]);
|
||||
|
||||
const adapter: NavigationAdapter = useMemo(
|
||||
() => ({
|
||||
push: (path: string) => router.navigate(path),
|
||||
replace: (path: string) => router.navigate(path, { replace: true }),
|
||||
back: () => router.navigate(-1),
|
||||
pathname: location.pathname,
|
||||
searchParams: new URLSearchParams(location.search),
|
||||
openInNewTab: (path: string, title?: string) => {
|
||||
const icon = resolveRouteIcon(path);
|
||||
const store = useTabStore.getState();
|
||||
const newTabId = store.openTab(path, title ?? path, icon);
|
||||
store.setActiveTab(newTabId);
|
||||
},
|
||||
getShareableUrl: (path: string) => `https://www.multica.ai${path}`,
|
||||
}),
|
||||
[router, location],
|
||||
);
|
||||
|
||||
return <NavigationProvider value={adapter}>{children}</NavigationProvider>;
|
||||
}
|
||||
99
apps/desktop/src/renderer/src/routes.tsx
Normal file
99
apps/desktop/src/renderer/src/routes.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import { useEffect } from "react";
|
||||
import {
|
||||
createMemoryRouter,
|
||||
Navigate,
|
||||
Outlet,
|
||||
useMatches,
|
||||
} from "react-router-dom";
|
||||
import type { RouteObject } from "react-router-dom";
|
||||
import { IssueDetailPage } from "./pages/issue-detail-page";
|
||||
import { ProjectDetailPage } from "./pages/project-detail-page";
|
||||
import { IssuesPage } from "@multica/views/issues/components";
|
||||
import { ProjectsPage } from "@multica/views/projects/components";
|
||||
import { MyIssuesPage } from "@multica/views/my-issues";
|
||||
import { RuntimesPage } from "@multica/views/runtimes";
|
||||
import { SkillsPage } from "@multica/views/skills";
|
||||
import { AgentsPage } from "@multica/views/agents";
|
||||
import { InboxPage } from "@multica/views/inbox";
|
||||
import { SettingsPage } from "@multica/views/settings";
|
||||
|
||||
/**
|
||||
* Sets document.title from the deepest matched route's handle.title.
|
||||
* The tab system observes document.title via MutationObserver.
|
||||
* Pages with dynamic titles (e.g. issue detail) override by setting
|
||||
* document.title directly via useDocumentTitle().
|
||||
*/
|
||||
function TitleSync() {
|
||||
const matches = useMatches();
|
||||
const title = [...matches]
|
||||
.reverse()
|
||||
.find((m) => (m.handle as { title?: string })?.title)
|
||||
?.handle as { title?: string } | undefined;
|
||||
|
||||
useEffect(() => {
|
||||
if (title?.title) document.title = title.title;
|
||||
}, [title?.title]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Wrapper that renders route children + TitleSync */
|
||||
function PageShell() {
|
||||
return (
|
||||
<>
|
||||
<TitleSync />
|
||||
<Outlet />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** Route definitions shared by all tabs (no layout wrapper). */
|
||||
export const appRoutes: RouteObject[] = [
|
||||
{
|
||||
element: <PageShell />,
|
||||
children: [
|
||||
{ index: true, element: <Navigate to="/issues" replace /> },
|
||||
{ path: "issues", element: <IssuesPage />, handle: { title: "Issues" } },
|
||||
{
|
||||
path: "issues/:id",
|
||||
element: <IssueDetailPage />,
|
||||
handle: { title: "Issue" },
|
||||
},
|
||||
{
|
||||
path: "projects",
|
||||
element: <ProjectsPage />,
|
||||
handle: { title: "Projects" },
|
||||
},
|
||||
{
|
||||
path: "projects/:id",
|
||||
element: <ProjectDetailPage />,
|
||||
handle: { title: "Project" },
|
||||
},
|
||||
{
|
||||
path: "my-issues",
|
||||
element: <MyIssuesPage />,
|
||||
handle: { title: "My Issues" },
|
||||
},
|
||||
{
|
||||
path: "runtimes",
|
||||
element: <RuntimesPage />,
|
||||
handle: { title: "Runtimes" },
|
||||
},
|
||||
{ path: "skills", element: <SkillsPage />, handle: { title: "Skills" } },
|
||||
{ path: "agents", element: <AgentsPage />, handle: { title: "Agents" } },
|
||||
{ path: "inbox", element: <InboxPage />, handle: { title: "Inbox" } },
|
||||
{
|
||||
path: "settings",
|
||||
element: <SettingsPage />,
|
||||
handle: { title: "Settings" },
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/** Create an independent memory router for a tab. */
|
||||
export function createTabRouter(initialPath: string) {
|
||||
return createMemoryRouter(appRoutes, {
|
||||
initialEntries: [initialPath],
|
||||
});
|
||||
}
|
||||
185
apps/desktop/src/renderer/src/stores/tab-store.ts
Normal file
185
apps/desktop/src/renderer/src/stores/tab-store.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
import { create } from "zustand";
|
||||
import { persist } from "zustand/middleware";
|
||||
import type { DataRouter } from "react-router-dom";
|
||||
import { createTabRouter } from "../routes";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface Tab {
|
||||
id: string;
|
||||
path: string;
|
||||
title: string;
|
||||
icon: string;
|
||||
router: DataRouter;
|
||||
historyIndex: number;
|
||||
historyLength: number;
|
||||
}
|
||||
|
||||
interface TabStore {
|
||||
tabs: Tab[];
|
||||
activeTabId: string;
|
||||
|
||||
/** Open a background tab. Deduplicates by path. Returns the tab id. */
|
||||
openTab: (path: string, title: string, icon: string) => string;
|
||||
/** Always create a new tab (no dedup). Returns the tab id. */
|
||||
addTab: (path: string, title: string, icon: string) => string;
|
||||
/** Close a tab. Disposes router. */
|
||||
closeTab: (tabId: string) => void;
|
||||
/** Switch to a tab by id. */
|
||||
setActiveTab: (tabId: string) => void;
|
||||
/** Update a tab's metadata (path, title, icon — partial). */
|
||||
updateTab: (tabId: string, patch: Partial<Pick<Tab, "path" | "title" | "icon">>) => void;
|
||||
/** Update a tab's history tracking. */
|
||||
updateTabHistory: (tabId: string, historyIndex: number, historyLength: number) => void;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Route → icon mapping (title comes from document.title, not from here)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const ROUTE_ICONS: Record<string, string> = {
|
||||
"/inbox": "Inbox",
|
||||
"/my-issues": "CircleUser",
|
||||
"/issues": "ListTodo",
|
||||
"/projects": "FolderKanban",
|
||||
"/agents": "Bot",
|
||||
"/runtimes": "Monitor",
|
||||
"/skills": "BookOpenText",
|
||||
"/settings": "Settings",
|
||||
};
|
||||
|
||||
/** Resolve a route icon. Title is NOT determined here — it comes from document.title. */
|
||||
export function resolveRouteIcon(pathname: string): string {
|
||||
return ROUTE_ICONS[pathname]
|
||||
?? (pathname.startsWith("/issues/") ? "ListTodo" : undefined)
|
||||
?? (pathname.startsWith("/projects/") ? "FolderKanban" : undefined)
|
||||
?? "ListTodo";
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Store
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const DEFAULT_PATH = "/issues";
|
||||
|
||||
function createId(): string {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
function makeTab(path: string, title: string, icon: string): Tab {
|
||||
return {
|
||||
id: createId(),
|
||||
path,
|
||||
title,
|
||||
icon,
|
||||
router: createTabRouter(path),
|
||||
historyIndex: 0,
|
||||
historyLength: 1,
|
||||
};
|
||||
}
|
||||
|
||||
const initialTab = makeTab(DEFAULT_PATH, "Issues", resolveRouteIcon(DEFAULT_PATH));
|
||||
|
||||
export const useTabStore = create<TabStore>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
tabs: [initialTab],
|
||||
activeTabId: initialTab.id,
|
||||
|
||||
openTab(path, title, icon) {
|
||||
const { tabs } = get();
|
||||
const existing = tabs.find((t) => t.path === path);
|
||||
if (existing) return existing.id;
|
||||
|
||||
const tab = makeTab(path, title, icon);
|
||||
set({ tabs: [...tabs, tab] });
|
||||
return tab.id;
|
||||
},
|
||||
|
||||
addTab(path, title, icon) {
|
||||
const tab = makeTab(path, title, icon);
|
||||
set((s) => ({ tabs: [...s.tabs, tab] }));
|
||||
return tab.id;
|
||||
},
|
||||
|
||||
closeTab(tabId) {
|
||||
const { tabs, activeTabId } = get();
|
||||
|
||||
const closingTab = tabs.find((t) => t.id === tabId);
|
||||
|
||||
// Never close the last tab — replace with default
|
||||
if (tabs.length === 1) {
|
||||
closingTab?.router.dispose();
|
||||
const fresh = makeTab(DEFAULT_PATH, "Issues", resolveRouteIcon(DEFAULT_PATH));
|
||||
set({ tabs: [fresh], activeTabId: fresh.id });
|
||||
return;
|
||||
}
|
||||
|
||||
const idx = tabs.findIndex((t) => t.id === tabId);
|
||||
if (idx === -1) return;
|
||||
|
||||
closingTab?.router.dispose();
|
||||
const next = tabs.filter((t) => t.id !== tabId);
|
||||
|
||||
if (tabId === activeTabId) {
|
||||
const newActive = next[Math.min(idx, next.length - 1)];
|
||||
set({ tabs: next, activeTabId: newActive.id });
|
||||
} else {
|
||||
set({ tabs: next });
|
||||
}
|
||||
},
|
||||
|
||||
setActiveTab(tabId) {
|
||||
set({ activeTabId: tabId });
|
||||
},
|
||||
|
||||
updateTab(tabId, patch) {
|
||||
set((s) => ({
|
||||
tabs: s.tabs.map((t) =>
|
||||
t.id === tabId ? { ...t, ...patch } : t,
|
||||
),
|
||||
}));
|
||||
},
|
||||
|
||||
updateTabHistory(tabId, historyIndex, historyLength) {
|
||||
set((s) => ({
|
||||
tabs: s.tabs.map((t) =>
|
||||
t.id === tabId ? { ...t, historyIndex, historyLength } : t,
|
||||
),
|
||||
}));
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: "multica_tabs",
|
||||
version: 1,
|
||||
partialize: (state) => ({
|
||||
tabs: state.tabs.map(
|
||||
({ router, historyIndex, historyLength, ...rest }) => rest,
|
||||
),
|
||||
activeTabId: state.activeTabId,
|
||||
}),
|
||||
merge: (persistedState, currentState) => {
|
||||
const persisted = persistedState as
|
||||
| Pick<TabStore, "tabs" | "activeTabId">
|
||||
| undefined;
|
||||
if (!persisted?.tabs?.length) return currentState;
|
||||
|
||||
const tabs: Tab[] = persisted.tabs.map((tab) => ({
|
||||
...tab,
|
||||
router: createTabRouter(tab.path),
|
||||
historyIndex: 0,
|
||||
historyLength: 1,
|
||||
}));
|
||||
|
||||
// Validate activeTabId — fall back to first tab if stale
|
||||
const activeTabId = tabs.some((t) => t.id === persisted.activeTabId)
|
||||
? persisted.activeTabId
|
||||
: tabs[0].id;
|
||||
|
||||
return { ...currentState, tabs, activeTabId };
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
4
apps/desktop/tsconfig.json
Normal file
4
apps/desktop/tsconfig.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [{ "path": "./tsconfig.node.json" }, { "path": "./tsconfig.web.json" }]
|
||||
}
|
||||
8
apps/desktop/tsconfig.node.json
Normal file
8
apps/desktop/tsconfig.node.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "@electron-toolkit/tsconfig/tsconfig.node.json",
|
||||
"include": ["electron.vite.config.*", "src/main/**/*", "src/preload/**/*"],
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"types": ["electron-vite/node"]
|
||||
}
|
||||
}
|
||||
20
apps/desktop/tsconfig.web.json
Normal file
20
apps/desktop/tsconfig.web.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"extends": "@electron-toolkit/tsconfig/tsconfig.web.json",
|
||||
"include": [
|
||||
"src/renderer/src/env.d.ts",
|
||||
"src/renderer/src/**/*",
|
||||
"src/renderer/src/**/*.tsx",
|
||||
"src/preload/*.d.ts"
|
||||
],
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"noImplicitAny": true,
|
||||
"jsx": "react-jsx",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"src/renderer/src/*"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,40 +2,54 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
|
||||
const { mockSendCode, mockVerifyCode, mockHydrateWorkspace } = vi.hoisted(
|
||||
() => ({
|
||||
mockSendCode: vi.fn(),
|
||||
mockVerifyCode: vi.fn(),
|
||||
mockHydrateWorkspace: vi.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
// Mock next/navigation
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: vi.fn() }),
|
||||
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
|
||||
usePathname: () => "/login",
|
||||
useSearchParams: () => new URLSearchParams(),
|
||||
}));
|
||||
|
||||
// Mock auth store
|
||||
const mockSendCode = vi.fn();
|
||||
const mockVerifyCode = vi.fn();
|
||||
vi.mock("@/platform/auth", () => ({
|
||||
useAuthStore: (selector: (s: any) => any) =>
|
||||
selector({
|
||||
sendCode: mockSendCode,
|
||||
verifyCode: mockVerifyCode,
|
||||
}),
|
||||
}));
|
||||
// Mock auth store — shared LoginPage uses getState().sendCode/verifyCode,
|
||||
// web wrapper uses useAuthStore((s) => s.user/isLoading)
|
||||
vi.mock("@multica/core/auth", () => {
|
||||
const authState = {
|
||||
sendCode: mockSendCode,
|
||||
verifyCode: mockVerifyCode,
|
||||
user: null,
|
||||
isLoading: false,
|
||||
};
|
||||
const useAuthStore = Object.assign(
|
||||
(selector: (s: typeof authState) => unknown) => selector(authState),
|
||||
{ getState: () => authState },
|
||||
);
|
||||
return { useAuthStore };
|
||||
});
|
||||
|
||||
// Mock auth-cookie
|
||||
vi.mock("@/features/auth/auth-cookie", () => ({
|
||||
setLoggedInCookie: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock workspace store
|
||||
const mockHydrateWorkspace = vi.fn();
|
||||
vi.mock("@/platform/workspace", () => ({
|
||||
useWorkspaceStore: (selector: (s: any) => any) =>
|
||||
selector({
|
||||
hydrateWorkspace: mockHydrateWorkspace,
|
||||
}),
|
||||
}));
|
||||
// Mock workspace store — shared LoginPage uses getState().hydrateWorkspace
|
||||
vi.mock("@multica/core/workspace", () => {
|
||||
const wsState = { hydrateWorkspace: mockHydrateWorkspace };
|
||||
const useWorkspaceStore = Object.assign(
|
||||
(selector: (s: typeof wsState) => unknown) => selector(wsState),
|
||||
{ getState: () => wsState },
|
||||
);
|
||||
return { useWorkspaceStore };
|
||||
});
|
||||
|
||||
// Mock api
|
||||
vi.mock("@/platform/api", () => ({
|
||||
vi.mock("@multica/core/api", () => ({
|
||||
api: {
|
||||
listWorkspaces: vi.fn().mockResolvedValue([]),
|
||||
verifyCode: vi.fn(),
|
||||
@@ -54,8 +68,8 @@ describe("LoginPage", () => {
|
||||
it("renders login form with email input and continue button", () => {
|
||||
render(<LoginPage />);
|
||||
|
||||
expect(screen.getByText("Multica")).toBeInTheDocument();
|
||||
expect(screen.getByText("Turn coding agents into real teammates")).toBeInTheDocument();
|
||||
expect(screen.getByText("Sign in to Multica")).toBeInTheDocument();
|
||||
expect(screen.getByText("Enter your email to get a login code")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Email")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Continue" })
|
||||
|
||||
@@ -1,390 +1,58 @@
|
||||
"use client";
|
||||
|
||||
import { Suspense, useState, useEffect, useCallback } from "react";
|
||||
import { Suspense, useEffect } from "react";
|
||||
import { useSearchParams, useRouter } from "next/navigation";
|
||||
import { useAuthStore } from "@/platform/auth";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
import { setLoggedInCookie } from "@/features/auth/auth-cookie";
|
||||
import { useWorkspaceStore } from "@/platform/workspace";
|
||||
import { api } from "@/platform/api";
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
CardFooter,
|
||||
} from "@multica/ui/components/ui/card";
|
||||
import { Input } from "@multica/ui/components/ui/input";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import { Label } from "@multica/ui/components/ui/label";
|
||||
import {
|
||||
InputOTP,
|
||||
InputOTPGroup,
|
||||
InputOTPSlot,
|
||||
} from "@multica/ui/components/ui/input-otp";
|
||||
import type { User } from "@multica/core/types";
|
||||
import { LoginPage, validateCliCallback } from "@multica/views/auth";
|
||||
|
||||
function validateCliCallback(cliCallback: string): boolean {
|
||||
try {
|
||||
const cbUrl = new URL(cliCallback);
|
||||
if (cbUrl.protocol !== "http:") return false;
|
||||
if (cbUrl.hostname !== "localhost" && cbUrl.hostname !== "127.0.0.1")
|
||||
return false;
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function redirectToCliCallback(
|
||||
cliCallback: string,
|
||||
token: string,
|
||||
cliState: string
|
||||
) {
|
||||
const separator = cliCallback.includes("?") ? "&" : "?";
|
||||
window.location.href = `${cliCallback}${separator}token=${encodeURIComponent(token)}&state=${encodeURIComponent(cliState)}`;
|
||||
}
|
||||
const googleClientId = process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID;
|
||||
|
||||
function LoginPageContent() {
|
||||
const router = useRouter();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const isLoading = useAuthStore((s) => s.isLoading);
|
||||
const sendCode = useAuthStore((s) => s.sendCode);
|
||||
const verifyCode = useAuthStore((s) => s.verifyCode);
|
||||
const hydrateWorkspace = useWorkspaceStore((s) => s.hydrateWorkspace);
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
// Already authenticated — redirect to dashboard
|
||||
const cliCallbackRaw = searchParams.get("cli_callback");
|
||||
const cliState = searchParams.get("cli_state") || "";
|
||||
const nextUrl = searchParams.get("next") || "/issues";
|
||||
|
||||
// Already authenticated — redirect to dashboard (skip if CLI callback)
|
||||
useEffect(() => {
|
||||
if (!isLoading && user && !searchParams.get("cli_callback")) {
|
||||
router.replace(searchParams.get("next") || "/issues");
|
||||
if (!isLoading && user && !cliCallbackRaw) {
|
||||
router.replace(nextUrl);
|
||||
}
|
||||
}, [isLoading, user, router, searchParams]);
|
||||
}, [isLoading, user, router, nextUrl, cliCallbackRaw]);
|
||||
|
||||
const [step, setStep] = useState<"email" | "code" | "cli_confirm">("email");
|
||||
const [email, setEmail] = useState("");
|
||||
const [code, setCode] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [cooldown, setCooldown] = useState(0);
|
||||
const [existingUser, setExistingUser] = useState<User | null>(null);
|
||||
|
||||
// Check for existing session when CLI callback is present.
|
||||
useEffect(() => {
|
||||
const cliCallback = searchParams.get("cli_callback");
|
||||
if (!cliCallback) return;
|
||||
|
||||
const token = localStorage.getItem("multica_token");
|
||||
if (!token) return;
|
||||
|
||||
if (!validateCliCallback(cliCallback)) return;
|
||||
|
||||
// Verify the existing token is still valid.
|
||||
api.setToken(token);
|
||||
api
|
||||
.getMe()
|
||||
.then((user) => {
|
||||
setExistingUser(user);
|
||||
setStep("cli_confirm");
|
||||
})
|
||||
.catch(() => {
|
||||
// Token expired/invalid — clear and fall through to normal login.
|
||||
api.setToken(null);
|
||||
localStorage.removeItem("multica_token");
|
||||
});
|
||||
}, [searchParams]);
|
||||
|
||||
useEffect(() => {
|
||||
if (cooldown <= 0) return;
|
||||
const timer = setTimeout(() => setCooldown((c) => c - 1), 1000);
|
||||
return () => clearTimeout(timer);
|
||||
}, [cooldown]);
|
||||
|
||||
const handleCliAuthorize = async () => {
|
||||
const cliCallback = searchParams.get("cli_callback");
|
||||
const token = localStorage.getItem("multica_token");
|
||||
if (!cliCallback || !token) return;
|
||||
const cliState = searchParams.get("cli_state") || "";
|
||||
setSubmitting(true);
|
||||
redirectToCliCallback(cliCallback, token, cliState);
|
||||
};
|
||||
|
||||
const handleSendCode = async (e?: React.FormEvent) => {
|
||||
e?.preventDefault();
|
||||
if (!email) {
|
||||
setError("Email is required");
|
||||
return;
|
||||
}
|
||||
setError("");
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await sendCode(email);
|
||||
setStep("code");
|
||||
setCode("");
|
||||
setCooldown(10);
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "Failed to send code. Make sure the server is running."
|
||||
);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleVerifyCode = useCallback(
|
||||
async (value: string) => {
|
||||
if (value.length !== 6) return;
|
||||
setError("");
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const cliCallback = searchParams.get("cli_callback");
|
||||
if (cliCallback) {
|
||||
if (!validateCliCallback(cliCallback)) {
|
||||
setError("Invalid callback URL");
|
||||
setSubmitting(false);
|
||||
return;
|
||||
}
|
||||
const { token } = await api.verifyCode(email, value);
|
||||
// Persist session in the browser so the web app stays logged in
|
||||
localStorage.setItem("multica_token", token);
|
||||
api.setToken(token);
|
||||
setLoggedInCookie();
|
||||
const cliState = searchParams.get("cli_state") || "";
|
||||
redirectToCliCallback(cliCallback, token, cliState);
|
||||
return;
|
||||
}
|
||||
|
||||
await verifyCode(email, value);
|
||||
const wsList = await api.listWorkspaces();
|
||||
const lastWsId = localStorage.getItem("multica_workspace_id");
|
||||
await hydrateWorkspace(wsList, lastWsId);
|
||||
router.push(searchParams.get("next") || "/issues");
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error ? err.message : "Invalid or expired code"
|
||||
);
|
||||
setCode("");
|
||||
setSubmitting(false);
|
||||
}
|
||||
},
|
||||
[email, verifyCode, hydrateWorkspace, router, searchParams]
|
||||
);
|
||||
|
||||
const handleResend = async () => {
|
||||
if (cooldown > 0) return;
|
||||
setError("");
|
||||
try {
|
||||
await sendCode(email);
|
||||
setCooldown(10);
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error ? err.message : "Failed to resend code"
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// CLI confirm step: user is already logged in, just authorize.
|
||||
if (step === "cli_confirm" && existingUser) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<Card className="w-full max-w-sm">
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle className="text-2xl">Authorize CLI</CardTitle>
|
||||
<CardDescription>
|
||||
Allow the CLI to access Multica as{" "}
|
||||
<span className="font-medium text-foreground">
|
||||
{existingUser.email}
|
||||
</span>
|
||||
?
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
<Button
|
||||
onClick={handleCliAuthorize}
|
||||
disabled={submitting}
|
||||
className="w-full"
|
||||
size="lg"
|
||||
>
|
||||
{submitting ? "Authorizing..." : "Authorize"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full"
|
||||
onClick={() => {
|
||||
setExistingUser(null);
|
||||
setStep("email");
|
||||
}}
|
||||
>
|
||||
Use a different account
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (step === "code") {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<Card className="w-full max-w-sm">
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle className="text-2xl">Check your email</CardTitle>
|
||||
<CardDescription>
|
||||
We sent a verification code to{" "}
|
||||
<span className="font-medium text-foreground">{email}</span>
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col items-center gap-4">
|
||||
<InputOTP
|
||||
maxLength={6}
|
||||
value={code}
|
||||
onChange={(value) => {
|
||||
setCode(value);
|
||||
if (value.length === 6) handleVerifyCode(value);
|
||||
}}
|
||||
disabled={submitting}
|
||||
>
|
||||
<InputOTPGroup>
|
||||
<InputOTPSlot index={0} />
|
||||
<InputOTPSlot index={1} />
|
||||
<InputOTPSlot index={2} />
|
||||
<InputOTPSlot index={3} />
|
||||
<InputOTPSlot index={4} />
|
||||
<InputOTPSlot index={5} />
|
||||
</InputOTPGroup>
|
||||
</InputOTP>
|
||||
{error && (
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
)}
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleResend}
|
||||
disabled={cooldown > 0}
|
||||
className="text-primary underline-offset-4 hover:underline disabled:text-muted-foreground disabled:no-underline disabled:cursor-not-allowed"
|
||||
>
|
||||
{cooldown > 0 ? `Resend in ${cooldown}s` : "Resend code"}
|
||||
</button>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full"
|
||||
onClick={() => {
|
||||
setStep("email");
|
||||
setCode("");
|
||||
setError("");
|
||||
}}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const googleClientId = process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID;
|
||||
|
||||
const handleGoogleLogin = () => {
|
||||
if (!googleClientId) return;
|
||||
const redirectUri = `${window.location.origin}/auth/callback`;
|
||||
const params = new URLSearchParams({
|
||||
client_id: googleClientId,
|
||||
redirect_uri: redirectUri,
|
||||
response_type: "code",
|
||||
scope: "openid email profile",
|
||||
access_type: "offline",
|
||||
prompt: "select_account",
|
||||
});
|
||||
window.location.href = `https://accounts.google.com/o/oauth2/v2/auth?${params}`;
|
||||
};
|
||||
const lastWorkspaceId =
|
||||
typeof window !== "undefined"
|
||||
? localStorage.getItem("multica_workspace_id")
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<Card className="w-full max-w-sm">
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle className="text-2xl">Multica</CardTitle>
|
||||
<CardDescription>Turn coding agents into real teammates</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form id="login-form" onSubmit={handleSendCode} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="you@example.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{error && (
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
)}
|
||||
</form>
|
||||
</CardContent>
|
||||
<CardFooter className="flex flex-col gap-3">
|
||||
<Button
|
||||
type="submit"
|
||||
form="login-form"
|
||||
disabled={submitting}
|
||||
className="w-full"
|
||||
size="lg"
|
||||
>
|
||||
{submitting ? "Sending code..." : "Continue"}
|
||||
</Button>
|
||||
{googleClientId && (
|
||||
<>
|
||||
<div className="relative w-full">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<span className="w-full border-t" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs uppercase">
|
||||
<span className="bg-card px-2 text-muted-foreground">or</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
size="lg"
|
||||
onClick={handleGoogleLogin}
|
||||
disabled={submitting}
|
||||
>
|
||||
<svg className="mr-2 h-4 w-4" viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z"
|
||||
fill="#4285F4"
|
||||
/>
|
||||
<path
|
||||
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
||||
fill="#34A853"
|
||||
/>
|
||||
<path
|
||||
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
|
||||
fill="#FBBC05"
|
||||
/>
|
||||
<path
|
||||
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
||||
fill="#EA4335"
|
||||
/>
|
||||
</svg>
|
||||
Continue with Google
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
<LoginPage
|
||||
onSuccess={() => router.push(nextUrl)}
|
||||
google={
|
||||
googleClientId
|
||||
? {
|
||||
clientId: googleClientId,
|
||||
redirectUri: `${window.location.origin}/auth/callback`,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
cliCallback={
|
||||
cliCallbackRaw && validateCliCallback(cliCallbackRaw)
|
||||
? { url: cliCallbackRaw, state: cliState }
|
||||
: undefined
|
||||
}
|
||||
lastWorkspaceId={lastWorkspaceId}
|
||||
onTokenObtained={setLoggedInCookie}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default function LoginPage() {
|
||||
export default function Page() {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<LoginPageContent />
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
export default function AgentDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<h1 className="text-2xl font-bold">Agent Detail</h1>
|
||||
<p className="mt-2 text-muted-foreground">Agent status and task history</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,10 +0,0 @@
|
||||
export default function BoardPage() {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<h1 className="text-2xl font-bold">Board</h1>
|
||||
<p className="mt-2 text-muted-foreground">
|
||||
Kanban board view coming soon.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,468 +1 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useDefaultLayout } from "react-resizable-panels";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useWorkspaceId } from "@multica/core/hooks";
|
||||
import {
|
||||
inboxListOptions,
|
||||
deduplicateInboxItems,
|
||||
} from "@multica/core/inbox/queries";
|
||||
import {
|
||||
useMarkInboxRead,
|
||||
useArchiveInbox,
|
||||
useMarkAllInboxRead,
|
||||
useArchiveAllInbox,
|
||||
useArchiveAllReadInbox,
|
||||
useArchiveCompletedInbox,
|
||||
} from "@multica/core/inbox/mutations";
|
||||
import { IssueDetail, StatusIcon, PriorityIcon } from "@multica/views/issues/components";
|
||||
import { STATUS_CONFIG, PRIORITY_CONFIG } from "@multica/core/issues/config";
|
||||
import { useActorName } from "@multica/core/workspace/hooks";
|
||||
import { ActorAvatar } from "@multica/views/common/actor-avatar";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
ArrowRight,
|
||||
MoreHorizontal,
|
||||
Inbox,
|
||||
CheckCheck,
|
||||
Archive,
|
||||
BookCheck,
|
||||
ListChecks,
|
||||
} from "lucide-react";
|
||||
import type { InboxItem, InboxItemType, IssueStatus, IssuePriority } from "@multica/core/types";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import {
|
||||
ResizablePanelGroup,
|
||||
ResizablePanel,
|
||||
ResizableHandle,
|
||||
} from "@multica/ui/components/ui/resizable";
|
||||
import { Skeleton } from "@multica/ui/components/ui/skeleton";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
} from "@multica/ui/components/ui/dropdown-menu";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const typeLabels: Record<InboxItemType, string> = {
|
||||
issue_assigned: "Assigned",
|
||||
unassigned: "Unassigned",
|
||||
assignee_changed: "Assignee changed",
|
||||
status_changed: "Status changed",
|
||||
priority_changed: "Priority changed",
|
||||
due_date_changed: "Due date changed",
|
||||
new_comment: "New comment",
|
||||
mentioned: "Mentioned",
|
||||
review_requested: "Review requested",
|
||||
task_completed: "Task completed",
|
||||
task_failed: "Task failed",
|
||||
agent_blocked: "Agent blocked",
|
||||
agent_completed: "Agent completed",
|
||||
reaction_added: "Reacted",
|
||||
};
|
||||
|
||||
function timeAgo(dateStr: string): string {
|
||||
const diff = Date.now() - new Date(dateStr).getTime();
|
||||
const minutes = Math.floor(diff / 60000);
|
||||
if (minutes < 1) return "just now";
|
||||
if (minutes < 60) return `${minutes}m`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours}h`;
|
||||
const days = Math.floor(hours / 24);
|
||||
return `${days}d`;
|
||||
}
|
||||
|
||||
function shortDate(dateStr: string): string {
|
||||
if (!dateStr) return "";
|
||||
return new Date(dateStr).toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// InboxDetailLabel — renders rich subtitle per notification type
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function InboxDetailLabel({ item }: { item: InboxItem }) {
|
||||
const { getActorName } = useActorName();
|
||||
const details = item.details ?? {};
|
||||
|
||||
switch (item.type) {
|
||||
case "status_changed": {
|
||||
if (!details.to) return <span>{typeLabels[item.type]}</span>;
|
||||
const label = STATUS_CONFIG[details.to as IssueStatus]?.label ?? details.to;
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
Set status to
|
||||
<StatusIcon status={details.to as IssueStatus} className="h-3 w-3" />
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
case "priority_changed": {
|
||||
if (!details.to) return <span>{typeLabels[item.type]}</span>;
|
||||
const label = PRIORITY_CONFIG[details.to as IssuePriority]?.label ?? details.to;
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
Set priority to
|
||||
<PriorityIcon priority={details.to as IssuePriority} className="h-3 w-3" />
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
case "issue_assigned": {
|
||||
if (details.new_assignee_id) {
|
||||
return <span>Assigned to {getActorName(details.new_assignee_type ?? "member", details.new_assignee_id)}</span>;
|
||||
}
|
||||
return <span>{typeLabels[item.type]}</span>;
|
||||
}
|
||||
case "unassigned":
|
||||
return <span>Removed assignee</span>;
|
||||
case "assignee_changed": {
|
||||
if (details.new_assignee_id) {
|
||||
return <span>Assigned to {getActorName(details.new_assignee_type ?? "member", details.new_assignee_id)}</span>;
|
||||
}
|
||||
return <span>{typeLabels[item.type]}</span>;
|
||||
}
|
||||
case "due_date_changed": {
|
||||
if (details.to) return <span>Set due date to {shortDate(details.to)}</span>;
|
||||
return <span>Removed due date</span>;
|
||||
}
|
||||
case "new_comment": {
|
||||
if (item.body) return <span>{item.body}</span>;
|
||||
return <span>{typeLabels[item.type]}</span>;
|
||||
}
|
||||
case "reaction_added": {
|
||||
const emoji = details.emoji;
|
||||
if (emoji) return <span>Reacted {emoji} to your comment</span>;
|
||||
return <span>{typeLabels[item.type]}</span>;
|
||||
}
|
||||
default:
|
||||
return <span>{typeLabels[item.type] ?? item.type}</span>;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// InboxListItem
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function InboxListItem({
|
||||
item,
|
||||
isSelected,
|
||||
onClick,
|
||||
onArchive,
|
||||
}: {
|
||||
item: InboxItem;
|
||||
isSelected: boolean;
|
||||
onClick: () => void;
|
||||
onArchive: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`group flex w-full items-center gap-3 px-4 py-2.5 text-left transition-colors ${
|
||||
isSelected ? "bg-accent" : "hover:bg-accent/50"
|
||||
}`}
|
||||
>
|
||||
<ActorAvatar
|
||||
actorType={item.actor_type ?? item.recipient_type}
|
||||
actorId={item.actor_id ?? item.recipient_id}
|
||||
size={28}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
{!item.read && (
|
||||
<span className="h-1.5 w-1.5 shrink-0 rounded-full bg-brand" />
|
||||
)}
|
||||
<span
|
||||
className={`truncate text-sm ${!item.read ? "font-medium" : "text-muted-foreground"}`}
|
||||
>
|
||||
{item.title}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={-1}
|
||||
title="Archive"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onArchive();
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.stopPropagation();
|
||||
onArchive();
|
||||
}
|
||||
}}
|
||||
className="hidden rounded p-0.5 text-muted-foreground hover:bg-accent hover:text-foreground group-hover:inline-flex"
|
||||
>
|
||||
<Archive className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
{item.issue_status && (
|
||||
<StatusIcon status={item.issue_status} className="h-3.5 w-3.5 shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-0.5 flex items-center justify-between gap-2">
|
||||
<p className={`min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-xs ${item.read ? "text-muted-foreground/60" : "text-muted-foreground"}`}>
|
||||
<InboxDetailLabel item={item} />
|
||||
</p>
|
||||
<span className={`shrink-0 text-xs ${item.read ? "text-muted-foreground/60" : "text-muted-foreground"}`}>
|
||||
{timeAgo(item.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Page
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default function InboxPage() {
|
||||
const searchParams = useSearchParams();
|
||||
const urlIssue = searchParams.get("issue") ?? "";
|
||||
|
||||
const [selectedKey, setSelectedKeyState] = useState(() => urlIssue);
|
||||
|
||||
// Sync from URL when searchParams change (e.g. Next.js navigation)
|
||||
useEffect(() => {
|
||||
setSelectedKeyState(urlIssue);
|
||||
}, [urlIssue]);
|
||||
|
||||
const setSelectedKey = useCallback((key: string) => {
|
||||
setSelectedKeyState(key);
|
||||
const url = key ? `/inbox?issue=${key}` : "/inbox";
|
||||
window.history.replaceState(null, "", url);
|
||||
}, []);
|
||||
|
||||
const wsId = useWorkspaceId();
|
||||
const { data: rawItems = [], isLoading: loading } = useQuery(inboxListOptions(wsId));
|
||||
const items = useMemo(() => deduplicateInboxItems(rawItems), [rawItems]);
|
||||
|
||||
const { defaultLayout, onLayoutChanged } = useDefaultLayout({
|
||||
id: "multica_inbox_layout",
|
||||
});
|
||||
|
||||
const selected = items.find((i) => (i.issue_id ?? i.id) === selectedKey) ?? null;
|
||||
const unreadCount = items.filter((i) => !i.read).length;
|
||||
|
||||
const markReadMutation = useMarkInboxRead();
|
||||
const archiveMutation = useArchiveInbox();
|
||||
const markAllReadMutation = useMarkAllInboxRead();
|
||||
const archiveAllMutation = useArchiveAllInbox();
|
||||
const archiveAllReadMutation = useArchiveAllReadInbox();
|
||||
const archiveCompletedMutation = useArchiveCompletedInbox();
|
||||
|
||||
// Click-to-read: select + auto-mark-read
|
||||
const handleSelect = (item: InboxItem) => {
|
||||
setSelectedKey(item.issue_id ?? item.id);
|
||||
if (!item.read) {
|
||||
markReadMutation.mutate(item.id, {
|
||||
onError: () => toast.error("Failed to mark as read"),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleArchive = (id: string) => {
|
||||
const archived = items.find((i) => i.id === id);
|
||||
if (archived && (archived.issue_id ?? archived.id) === selectedKey) setSelectedKey("");
|
||||
archiveMutation.mutate(id, {
|
||||
onError: () => toast.error("Failed to archive"),
|
||||
});
|
||||
};
|
||||
|
||||
// Batch operations
|
||||
const handleMarkAllRead = () => {
|
||||
markAllReadMutation.mutate(undefined, {
|
||||
onError: () => toast.error("Failed to mark all as read"),
|
||||
});
|
||||
};
|
||||
|
||||
const handleArchiveAll = () => {
|
||||
setSelectedKey("");
|
||||
archiveAllMutation.mutate(undefined, {
|
||||
onError: () => toast.error("Failed to archive all"),
|
||||
});
|
||||
};
|
||||
|
||||
const handleArchiveAllRead = () => {
|
||||
const readKeys = items.filter((i) => i.read).map((i) => i.issue_id ?? i.id);
|
||||
if (readKeys.includes(selectedKey)) setSelectedKey("");
|
||||
archiveAllReadMutation.mutate(undefined, {
|
||||
onError: () => toast.error("Failed to archive read items"),
|
||||
});
|
||||
};
|
||||
|
||||
const handleArchiveCompleted = () => {
|
||||
setSelectedKey("");
|
||||
archiveCompletedMutation.mutate(undefined, {
|
||||
onError: () => toast.error("Failed to archive completed"),
|
||||
});
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<ResizablePanelGroup orientation="horizontal" className="flex-1 min-h-0" defaultLayout={defaultLayout} onLayoutChanged={onLayoutChanged}>
|
||||
<ResizablePanel id="list" defaultSize={320} minSize={240} maxSize={480} groupResizeBehavior="preserve-pixel-size">
|
||||
<div className="flex flex-col border-r h-full">
|
||||
<div className="flex h-12 shrink-0 items-center border-b px-4">
|
||||
<Skeleton className="h-5 w-16" />
|
||||
</div>
|
||||
<div className="flex-1 min-h-0 overflow-y-auto space-y-1 p-2">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-3 px-4 py-2.5">
|
||||
<Skeleton className="h-7 w-7 shrink-0 rounded-full" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
<Skeleton className="h-3 w-1/2" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
<ResizableHandle />
|
||||
<ResizablePanel id="detail" minSize="40%">
|
||||
<div className="p-6">
|
||||
<Skeleton className="h-6 w-48" />
|
||||
<Skeleton className="mt-4 h-4 w-32" />
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ResizablePanelGroup orientation="horizontal" className="flex-1 min-h-0" defaultLayout={defaultLayout} onLayoutChanged={onLayoutChanged}>
|
||||
<ResizablePanel id="list" defaultSize={320} minSize={240} maxSize={480} groupResizeBehavior="preserve-pixel-size">
|
||||
{/* Left column — inbox list */}
|
||||
<div className="flex flex-col border-r h-full">
|
||||
<div className="flex h-12 shrink-0 items-center justify-between border-b px-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<h1 className="text-sm font-semibold">Inbox</h1>
|
||||
{unreadCount > 0 && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="text-muted-foreground"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-auto">
|
||||
<DropdownMenuItem onClick={handleMarkAllRead}>
|
||||
<CheckCheck className="h-4 w-4" />
|
||||
Mark all as read
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={handleArchiveAll}>
|
||||
<Archive className="h-4 w-4" />
|
||||
Archive all
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={handleArchiveAllRead}>
|
||||
<BookCheck className="h-4 w-4" />
|
||||
Archive all read
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={handleArchiveCompleted}>
|
||||
<ListChecks className="h-4 w-4" />
|
||||
Archive completed
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-y-auto">
|
||||
{items.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
|
||||
<Inbox className="mb-3 h-8 w-8 text-muted-foreground/50" />
|
||||
<p className="text-sm">No notifications</p>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
{items.map((item) => (
|
||||
<InboxListItem
|
||||
key={item.id}
|
||||
item={item}
|
||||
isSelected={(item.issue_id ?? item.id) === selectedKey}
|
||||
onClick={() => handleSelect(item)}
|
||||
onArchive={() => handleArchive(item.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
<ResizableHandle />
|
||||
<ResizablePanel id="detail" minSize="40%">
|
||||
{/* Right column — detail */}
|
||||
<div className="flex flex-col min-h-0 h-full">
|
||||
{selected?.issue_id ? (
|
||||
<IssueDetail
|
||||
key={selected.id}
|
||||
issueId={selected.issue_id}
|
||||
defaultSidebarOpen={false}
|
||||
layoutId="multica_inbox_issue_detail_layout"
|
||||
highlightCommentId={selected.details?.comment_id ?? undefined}
|
||||
onDelete={() => {
|
||||
handleArchive(selected.id);
|
||||
}}
|
||||
/>
|
||||
) : selected ? (
|
||||
<div className="p-6">
|
||||
<h2 className="text-lg font-semibold">{selected.title}</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{typeLabels[selected.type]} · {timeAgo(selected.created_at)}
|
||||
</p>
|
||||
{selected.body && (
|
||||
<div className="mt-4 whitespace-pre-wrap text-sm leading-relaxed text-foreground/80">
|
||||
{selected.body}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleArchive(selected.id)}
|
||||
>
|
||||
<Archive className="mr-1.5 h-3.5 w-3.5" />
|
||||
Archive
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-full flex-col items-center justify-center text-muted-foreground">
|
||||
<Inbox className="mb-3 h-10 w-10 text-muted-foreground/30" />
|
||||
<p className="text-sm">
|
||||
{items.length === 0
|
||||
? "Your inbox is empty"
|
||||
: "Select a notification to view details"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
);
|
||||
}
|
||||
export { InboxPage as default } from "@multica/views/inbox";
|
||||
|
||||
@@ -1,581 +0,0 @@
|
||||
import { Suspense, forwardRef, useRef, useState, useImperativeHandle } from "react";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor, act, fireEvent } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { Issue, Comment, TimelineEntry } from "@multica/core/types";
|
||||
import { WorkspaceIdProvider } from "@multica/core/hooks";
|
||||
|
||||
// Mock next/navigation
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: vi.fn() }),
|
||||
usePathname: () => "/issues/issue-1",
|
||||
}));
|
||||
|
||||
// Mock next/link
|
||||
vi.mock("next/link", () => ({
|
||||
default: ({
|
||||
children,
|
||||
href,
|
||||
...props
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
href: string;
|
||||
[key: string]: any;
|
||||
}) => (
|
||||
<a href={href} {...props}>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock auth store
|
||||
vi.mock("@/platform/auth", () => ({
|
||||
useAuthStore: (selector: (s: any) => any) =>
|
||||
selector({
|
||||
user: { id: "user-1", name: "Test User", email: "test@multica.ai" },
|
||||
isLoading: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock @multica/core/workspace (used by @multica/views components)
|
||||
vi.mock("@multica/core/workspace", () => ({
|
||||
useWorkspaceStore: Object.assign(
|
||||
(selector: (s: any) => any) =>
|
||||
selector({
|
||||
workspace: { id: "ws-1", name: "Test WS" },
|
||||
workspaces: [{ id: "ws-1", name: "Test WS" }],
|
||||
members: [{ user_id: "user-1", name: "Test User", email: "test@multica.ai" }],
|
||||
agents: [{ id: "agent-1", name: "Claude Agent" }],
|
||||
}),
|
||||
{ getState: () => ({
|
||||
workspace: { id: "ws-1", name: "Test WS" },
|
||||
workspaces: [{ id: "ws-1", name: "Test WS" }],
|
||||
members: [{ user_id: "user-1", name: "Test User", email: "test@multica.ai" }],
|
||||
agents: [{ id: "agent-1", name: "Claude Agent" }],
|
||||
}),
|
||||
},
|
||||
),
|
||||
registerWorkspaceStore: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock @multica/core/auth (used by @multica/views components)
|
||||
vi.mock("@multica/core/auth", () => ({
|
||||
useAuthStore: Object.assign(
|
||||
(selector: (s: any) => any) =>
|
||||
selector({
|
||||
user: { id: "user-1", name: "Test User", email: "test@multica.ai" },
|
||||
isLoading: false,
|
||||
}),
|
||||
{ getState: () => ({
|
||||
user: { id: "user-1", name: "Test User", email: "test@multica.ai" },
|
||||
isLoading: false,
|
||||
}),
|
||||
},
|
||||
),
|
||||
registerAuthStore: vi.fn(),
|
||||
createAuthStore: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock @multica/views/navigation (AppLink used by views components)
|
||||
vi.mock("@multica/views/navigation", () => ({
|
||||
AppLink: ({ children, href, ...props }: any) => <a href={href} {...props}>{children}</a>,
|
||||
useNavigation: () => ({ push: vi.fn(), pathname: "/issues/issue-1" }),
|
||||
NavigationProvider: ({ children }: { children: React.ReactNode }) => children,
|
||||
}));
|
||||
|
||||
// Mock @multica/views/editor (ContentEditor, TitleEditor used by IssueDetail)
|
||||
vi.mock("@multica/views/editor", () => ({
|
||||
ReadonlyContent: ({ content }: { content: string }) => (
|
||||
<div data-testid="readonly-content">{content}</div>
|
||||
),
|
||||
ContentEditor: forwardRef(({ defaultValue, onUpdate, placeholder, onSubmit }: any, ref: any) => {
|
||||
const valueRef = useRef(defaultValue || "");
|
||||
const [value, setValue] = useState(defaultValue || "");
|
||||
useImperativeHandle(ref, () => ({
|
||||
getMarkdown: () => valueRef.current,
|
||||
clearContent: () => { valueRef.current = ""; setValue(""); },
|
||||
focus: () => {},
|
||||
}));
|
||||
return (
|
||||
<textarea
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
valueRef.current = e.target.value;
|
||||
setValue(e.target.value);
|
||||
onUpdate?.(e.target.value);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
|
||||
onSubmit?.();
|
||||
}
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
data-testid="rich-text-editor"
|
||||
/>
|
||||
);
|
||||
}),
|
||||
TitleEditor: forwardRef(({ defaultValue, placeholder, onBlur, onChange }: any, ref: any) => {
|
||||
const valueRef = useRef(defaultValue || "");
|
||||
const [value, setValue] = useState(defaultValue || "");
|
||||
useImperativeHandle(ref, () => ({
|
||||
getText: () => valueRef.current,
|
||||
focus: () => {},
|
||||
}));
|
||||
return (
|
||||
<input
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
valueRef.current = e.target.value;
|
||||
setValue(e.target.value);
|
||||
onChange?.(e.target.value);
|
||||
}}
|
||||
onBlur={() => onBlur?.(valueRef.current)}
|
||||
placeholder={placeholder}
|
||||
data-testid="title-editor"
|
||||
/>
|
||||
);
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock @multica/views/workspace/workspace-avatar
|
||||
vi.mock("@multica/views/workspace/workspace-avatar", () => ({
|
||||
WorkspaceAvatar: ({ name }: { name: string }) => <span>{name.charAt(0)}</span>,
|
||||
}));
|
||||
|
||||
// Mock @multica/views/common/actor-avatar
|
||||
vi.mock("@multica/views/common/actor-avatar", () => ({
|
||||
ActorAvatar: ({ actorType, actorId }: any) => <span data-testid="actor-avatar">{actorType}:{actorId}</span>,
|
||||
}));
|
||||
|
||||
// Mock @multica/views/common/markdown
|
||||
vi.mock("@multica/views/common/markdown", () => ({
|
||||
Markdown: ({ children }: { children: string }) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
// Mock workspace feature
|
||||
vi.mock("@/features/workspace", () => ({
|
||||
useWorkspaceStore: (selector: (s: any) => any) =>
|
||||
selector({
|
||||
workspace: { id: "ws-1", name: "Test WS" },
|
||||
workspaces: [{ id: "ws-1", name: "Test WS" }],
|
||||
members: [{ user_id: "user-1", name: "Test User", email: "test@multica.ai" }],
|
||||
agents: [{ id: "agent-1", name: "Claude Agent" }],
|
||||
}),
|
||||
useActorName: () => ({
|
||||
getMemberName: (id: string) => (id === "user-1" ? "Test User" : "Unknown"),
|
||||
getAgentName: (id: string) => (id === "agent-1" ? "Claude Agent" : "Unknown Agent"),
|
||||
getActorName: (type: string, id: string) => {
|
||||
if (type === "member" && id === "user-1") return "Test User";
|
||||
if (type === "agent" && id === "agent-1") return "Claude Agent";
|
||||
return "Unknown";
|
||||
},
|
||||
getActorInitials: (type: string, id: string) => {
|
||||
if (type === "member") return "TU";
|
||||
if (type === "agent") return "CA";
|
||||
return "??";
|
||||
},
|
||||
getActorAvatarUrl: () => null,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/platform/workspace", () => ({
|
||||
useWorkspaceStore: (selector: (s: any) => any) =>
|
||||
selector({
|
||||
workspace: { id: "ws-1", name: "Test WS" },
|
||||
workspaces: [{ id: "ws-1", name: "Test WS" }],
|
||||
members: [{ user_id: "user-1", name: "Test User", email: "test@multica.ai" }],
|
||||
agents: [{ id: "agent-1", name: "Claude Agent" }],
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock workspace hooks from core
|
||||
vi.mock("@multica/core/workspace/hooks", () => ({
|
||||
useActorName: () => ({
|
||||
getMemberName: (id: string) => (id === "user-1" ? "Test User" : "Unknown"),
|
||||
getAgentName: (id: string) => (id === "agent-1" ? "Claude Agent" : "Unknown Agent"),
|
||||
getActorName: (type: string, id: string) => {
|
||||
if (type === "member" && id === "user-1") return "Test User";
|
||||
if (type === "agent" && id === "agent-1") return "Claude Agent";
|
||||
return "Unknown";
|
||||
},
|
||||
getActorInitials: (type: string, id: string) => {
|
||||
if (type === "member") return "TU";
|
||||
if (type === "agent") return "CA";
|
||||
return "??";
|
||||
},
|
||||
getActorAvatarUrl: () => null,
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock issue store — only client state remains (activeIssueId)
|
||||
vi.mock("@/features/issues", () => ({
|
||||
useIssueStore: Object.assign(
|
||||
(selector: (s: any) => any) => selector({ activeIssueId: null }),
|
||||
{ getState: () => ({ activeIssueId: null, setActiveIssue: vi.fn() }) },
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/issues", () => ({
|
||||
useIssueStore: Object.assign(
|
||||
(selector: (s: any) => any) => selector({ activeIssueId: null }),
|
||||
{ getState: () => ({ activeIssueId: null, setActiveIssue: vi.fn() }) },
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock ws-context
|
||||
vi.mock("@/features/realtime", () => ({
|
||||
useWSEvent: () => {},
|
||||
useWSReconnect: () => {},
|
||||
}));
|
||||
|
||||
// Mock core realtime (hooks now import from @multica/core/realtime)
|
||||
vi.mock("@multica/core/realtime", () => ({
|
||||
useWSEvent: () => {},
|
||||
useWSReconnect: () => {},
|
||||
useWS: () => ({ subscribe: vi.fn(() => () => {}), onReconnect: vi.fn(() => () => {}) }),
|
||||
WSProvider: ({ children }: { children: React.ReactNode }) => children,
|
||||
useRealtimeSync: () => {},
|
||||
}));
|
||||
|
||||
// Mock calendar (react-day-picker needs browser APIs)
|
||||
vi.mock("@/components/ui/calendar", () => ({
|
||||
Calendar: () => null,
|
||||
}));
|
||||
|
||||
// Mock ContentEditor (Tiptap needs real DOM)
|
||||
vi.mock("@/features/editor", () => ({
|
||||
ReadonlyContent: ({ content }: { content: string }) => (
|
||||
<div data-testid="readonly-content">{content}</div>
|
||||
),
|
||||
ContentEditor: forwardRef(({ defaultValue, onUpdate, placeholder, onSubmit }: any, ref: any) => {
|
||||
const valueRef = useRef(defaultValue || "");
|
||||
const [value, setValue] = useState(defaultValue || "");
|
||||
useImperativeHandle(ref, () => ({
|
||||
getMarkdown: () => valueRef.current,
|
||||
clearContent: () => { valueRef.current = ""; setValue(""); },
|
||||
focus: () => {},
|
||||
}));
|
||||
return (
|
||||
<textarea
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
valueRef.current = e.target.value;
|
||||
setValue(e.target.value);
|
||||
onUpdate?.(e.target.value);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
|
||||
onSubmit?.();
|
||||
}
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
data-testid="rich-text-editor"
|
||||
/>
|
||||
);
|
||||
}),
|
||||
TitleEditor: forwardRef(({ defaultValue, placeholder, onBlur, onChange }: any, ref: any) => {
|
||||
const valueRef = useRef(defaultValue || "");
|
||||
const [value, setValue] = useState(defaultValue || "");
|
||||
useImperativeHandle(ref, () => ({
|
||||
getText: () => valueRef.current,
|
||||
focus: () => {},
|
||||
}));
|
||||
return (
|
||||
<input
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
valueRef.current = e.target.value;
|
||||
setValue(e.target.value);
|
||||
onChange?.(e.target.value);
|
||||
}}
|
||||
onBlur={() => onBlur?.(valueRef.current)}
|
||||
placeholder={placeholder}
|
||||
data-testid="title-editor"
|
||||
/>
|
||||
);
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock Markdown renderer
|
||||
vi.mock("@/components/markdown", () => ({
|
||||
Markdown: ({ children }: { children: string }) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
// Mock api (core queries/mutations use @multica/core/api, some components use @/platform/api)
|
||||
|
||||
const mockApiObj = vi.hoisted(() => ({
|
||||
getIssue: vi.fn(),
|
||||
listTimeline: vi.fn(),
|
||||
listComments: vi.fn().mockResolvedValue([]),
|
||||
createComment: vi.fn(),
|
||||
updateComment: vi.fn(),
|
||||
deleteComment: vi.fn(),
|
||||
deleteIssue: vi.fn(),
|
||||
updateIssue: vi.fn(),
|
||||
listIssueSubscribers: vi.fn().mockResolvedValue([]),
|
||||
subscribeToIssue: vi.fn().mockResolvedValue(undefined),
|
||||
unsubscribeFromIssue: vi.fn().mockResolvedValue(undefined),
|
||||
getActiveTasksForIssue: vi.fn().mockResolvedValue({ tasks: [] }),
|
||||
listTasksByIssue: vi.fn().mockResolvedValue([]),
|
||||
listTaskMessages: vi.fn().mockResolvedValue([]),
|
||||
listChildIssues: vi.fn().mockResolvedValue({ issues: [] }),
|
||||
listIssues: vi.fn().mockResolvedValue({ issues: [], total: 0 }),
|
||||
uploadFile: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/api", () => ({
|
||||
api: mockApiObj,
|
||||
getApi: () => mockApiObj,
|
||||
setApiInstance: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/platform/api", () => ({
|
||||
api: mockApiObj,
|
||||
}));
|
||||
|
||||
// Mock issue config from core
|
||||
vi.mock("@multica/core/issues/config", () => ({
|
||||
ALL_STATUSES: ["backlog", "todo", "in_progress", "in_review", "done", "blocked", "cancelled"],
|
||||
BOARD_STATUSES: ["backlog", "todo", "in_progress", "in_review", "done", "blocked"],
|
||||
STATUS_ORDER: ["backlog", "todo", "in_progress", "in_review", "done", "blocked", "cancelled"],
|
||||
STATUS_CONFIG: {
|
||||
backlog: { label: "Backlog", iconColor: "text-muted-foreground", hoverBg: "hover:bg-accent" },
|
||||
todo: { label: "Todo", iconColor: "text-muted-foreground", hoverBg: "hover:bg-accent" },
|
||||
in_progress: { label: "In Progress", iconColor: "text-warning", hoverBg: "hover:bg-warning/10" },
|
||||
in_review: { label: "In Review", iconColor: "text-success", hoverBg: "hover:bg-success/10" },
|
||||
done: { label: "Done", iconColor: "text-info", hoverBg: "hover:bg-info/10" },
|
||||
blocked: { label: "Blocked", iconColor: "text-destructive", hoverBg: "hover:bg-destructive/10" },
|
||||
cancelled: { label: "Cancelled", iconColor: "text-muted-foreground", hoverBg: "hover:bg-accent" },
|
||||
},
|
||||
PRIORITY_ORDER: ["urgent", "high", "medium", "low", "none"],
|
||||
PRIORITY_CONFIG: {
|
||||
urgent: { label: "Urgent", bars: 4, color: "text-destructive" },
|
||||
high: { label: "High", bars: 3, color: "text-warning" },
|
||||
medium: { label: "Medium", bars: 2, color: "text-warning" },
|
||||
low: { label: "Low", bars: 1, color: "text-info" },
|
||||
none: { label: "No priority", bars: 0, color: "text-muted-foreground" },
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock modals
|
||||
vi.mock("@multica/core/modals", () => ({
|
||||
useModalStore: Object.assign(
|
||||
() => ({ open: vi.fn() }),
|
||||
{ getState: () => ({ open: vi.fn() }) },
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock utils
|
||||
vi.mock("@multica/core/utils", () => ({
|
||||
timeAgo: (date: string) => "1d ago",
|
||||
}));
|
||||
|
||||
const mockIssue: Issue = {
|
||||
id: "issue-1",
|
||||
workspace_id: "ws-1",
|
||||
number: 1,
|
||||
identifier: "TES-1",
|
||||
title: "Implement authentication",
|
||||
description: "Add JWT auth to the backend",
|
||||
status: "in_progress",
|
||||
priority: "high",
|
||||
assignee_type: "member",
|
||||
assignee_id: "user-1",
|
||||
creator_type: "member",
|
||||
creator_id: "user-1",
|
||||
parent_issue_id: null,
|
||||
project_id: null,
|
||||
position: 0,
|
||||
due_date: "2026-06-01T00:00:00Z",
|
||||
created_at: "2026-01-15T00:00:00Z",
|
||||
updated_at: "2026-01-20T00:00:00Z",
|
||||
};
|
||||
|
||||
const mockTimeline: TimelineEntry[] = [
|
||||
{
|
||||
type: "comment",
|
||||
id: "comment-1",
|
||||
actor_type: "member",
|
||||
actor_id: "user-1",
|
||||
content: "Started working on this",
|
||||
parent_id: null,
|
||||
created_at: "2026-01-16T00:00:00Z",
|
||||
updated_at: "2026-01-16T00:00:00Z",
|
||||
comment_type: "comment",
|
||||
},
|
||||
{
|
||||
type: "comment",
|
||||
id: "comment-2",
|
||||
actor_type: "agent",
|
||||
actor_id: "agent-1",
|
||||
content: "I can help with this",
|
||||
parent_id: null,
|
||||
created_at: "2026-01-17T00:00:00Z",
|
||||
updated_at: "2026-01-17T00:00:00Z",
|
||||
comment_type: "comment",
|
||||
},
|
||||
];
|
||||
|
||||
import IssueDetailPage from "./page";
|
||||
|
||||
function createTestQueryClient() {
|
||||
return new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false, gcTime: 0 },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// React 19 use(Promise) needs the promise to resolve within act + Suspense
|
||||
async function renderPage(id = "issue-1") {
|
||||
const queryClient = createTestQueryClient();
|
||||
let result: ReturnType<typeof render>;
|
||||
await act(async () => {
|
||||
result = render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<WorkspaceIdProvider wsId="ws-1">
|
||||
<Suspense fallback={<div>Suspense loading...</div>}>
|
||||
<IssueDetailPage params={Promise.resolve({ id })} />
|
||||
</Suspense>
|
||||
</WorkspaceIdProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
return result!;
|
||||
}
|
||||
|
||||
describe("IssueDetailPage", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders issue details after loading", async () => {
|
||||
mockApiObj.getIssue.mockResolvedValueOnce(mockIssue);
|
||||
mockApiObj.listTimeline.mockResolvedValueOnce(mockTimeline);
|
||||
await renderPage();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getAllByText("Implement authentication").length,
|
||||
).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.getByText("Add JWT auth to the backend"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders issue properties sidebar", async () => {
|
||||
mockApiObj.getIssue.mockResolvedValueOnce(mockIssue);
|
||||
mockApiObj.listTimeline.mockResolvedValueOnce(mockTimeline);
|
||||
await renderPage();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Properties")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByText("In Progress")).toBeInTheDocument();
|
||||
expect(screen.getByText("High")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders comments", async () => {
|
||||
mockApiObj.getIssue.mockResolvedValueOnce(mockIssue);
|
||||
mockApiObj.listTimeline.mockResolvedValueOnce(mockTimeline);
|
||||
await renderPage();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText("Started working on this"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByText("I can help with this")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("Activity").length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("shows 'Issue not found' for missing issue", async () => {
|
||||
// issue-detail fetches getIssue, useIssueReactions also fetches getIssue
|
||||
mockApiObj.getIssue.mockRejectedValue(new Error("Not found"));
|
||||
mockApiObj.listTimeline.mockRejectedValue(new Error("Not found"));
|
||||
await renderPage("nonexistent-id");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("This issue does not exist or has been deleted in this workspace.")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("submits a new comment", async () => {
|
||||
mockApiObj.getIssue.mockResolvedValueOnce(mockIssue);
|
||||
mockApiObj.listTimeline.mockResolvedValueOnce(mockTimeline);
|
||||
|
||||
const newComment: Comment = {
|
||||
id: "comment-3",
|
||||
issue_id: "issue-1",
|
||||
content: "New test comment",
|
||||
type: "comment",
|
||||
author_type: "member",
|
||||
author_id: "user-1",
|
||||
parent_id: null,
|
||||
reactions: [],
|
||||
attachments: [],
|
||||
created_at: "2026-01-18T00:00:00Z",
|
||||
updated_at: "2026-01-18T00:00:00Z",
|
||||
};
|
||||
mockApiObj.createComment.mockResolvedValueOnce(newComment);
|
||||
|
||||
const user = userEvent.setup();
|
||||
await renderPage();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByPlaceholderText("Leave a comment..."),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const commentInput = screen.getByPlaceholderText("Leave a comment...");
|
||||
|
||||
// Use fireEvent to update the textarea value and trigger onUpdate
|
||||
await act(async () => {
|
||||
fireEvent.change(commentInput, { target: { value: "New test comment" } });
|
||||
});
|
||||
|
||||
// Find the submit button associated with the "Leave a comment..." input.
|
||||
// Multiple ArrowUp buttons exist (one per ReplyInput), so we find the
|
||||
// button within the same ReplyInput container as our textarea.
|
||||
const allArrowUpBtns = screen.getAllByRole("button").filter(
|
||||
(btn) => btn.querySelector(".lucide-arrow-up") !== null,
|
||||
);
|
||||
// The bottom "Leave a comment..." ReplyInput renders last, so its button is last
|
||||
const submitBtn = allArrowUpBtns[allArrowUpBtns.length - 1]!;
|
||||
await waitFor(() => {
|
||||
expect(submitBtn).not.toBeDisabled();
|
||||
});
|
||||
await user.click(submitBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockApiObj.createComment).toHaveBeenCalled();
|
||||
const [issueId, content] = mockApiObj.createComment.mock.calls[0]!;
|
||||
expect(issueId).toBe("issue-1");
|
||||
expect(content).toBe("New test comment");
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("New test comment")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders breadcrumb navigation", async () => {
|
||||
mockApiObj.getIssue.mockResolvedValueOnce(mockIssue);
|
||||
mockApiObj.listTimeline.mockResolvedValueOnce(mockTimeline);
|
||||
await renderPage();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Test WS")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const wsLink = screen.getByText("Test WS");
|
||||
expect(wsLink.closest("a")).toHaveAttribute("href", "/issues");
|
||||
});
|
||||
});
|
||||
@@ -1,69 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useRouter, usePathname } from "next/navigation";
|
||||
import { MulticaIcon } from "@/components/multica-icon";
|
||||
import { useNavigationStore } from "@multica/core/navigation";
|
||||
import { SidebarProvider, SidebarInset } from "@multica/ui/components/ui/sidebar";
|
||||
import { useAuthStore } from "@/platform/auth";
|
||||
import { useWorkspaceStore } from "@/platform/workspace";
|
||||
import { WorkspaceIdProvider } from "@multica/core/hooks";
|
||||
import { ModalRegistry } from "@multica/views/modals/registry";
|
||||
import { SearchCommand } from "@/features/search";
|
||||
import { AppSidebar } from "./_components/app-sidebar";
|
||||
import { ChatFab, ChatWindow } from "@/features/chat";
|
||||
|
||||
export default function DashboardLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const isLoading = useAuthStore((s) => s.isLoading);
|
||||
const workspace = useWorkspaceStore((s) => s.workspace);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && !user) {
|
||||
router.push("/");
|
||||
}
|
||||
}, [user, isLoading, router]);
|
||||
|
||||
useEffect(() => {
|
||||
useNavigationStore.getState().onPathChange(pathname);
|
||||
}, [pathname]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center">
|
||||
<MulticaIcon className="size-6" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
if (!workspace) {
|
||||
return (
|
||||
<div className="flex h-svh items-center justify-center">
|
||||
<MulticaIcon className="size-6 animate-pulse" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
import { DashboardLayout } from "@multica/views/layout";
|
||||
import { MulticaIcon } from "@multica/ui/components/common/multica-icon";
|
||||
import { SearchCommand, SearchTrigger } from "@multica/views/search";
|
||||
import { ChatFab, ChatWindow } from "@multica/views/chat";
|
||||
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<WorkspaceIdProvider wsId={workspace.id}>
|
||||
<SidebarProvider className="h-svh">
|
||||
<AppSidebar />
|
||||
<SidebarInset className="overflow-hidden">
|
||||
{children}
|
||||
<ModalRegistry />
|
||||
</SidebarInset>
|
||||
<ChatWindow />
|
||||
<ChatFab />
|
||||
<SearchCommand />
|
||||
</SidebarProvider>
|
||||
</WorkspaceIdProvider>
|
||||
<DashboardLayout
|
||||
loadingIndicator={<MulticaIcon className="size-6" />}
|
||||
searchSlot={<SearchTrigger />}
|
||||
extra={<><SearchCommand /><ChatWindow /><ChatFab /></>}
|
||||
>
|
||||
{children}
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,71 +1 @@
|
||||
"use client";
|
||||
|
||||
import { User, Palette, Key, Settings, Users, FolderGit2 } from "lucide-react";
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@multica/ui/components/ui/tabs";
|
||||
import { useWorkspaceStore } from "@/platform/workspace";
|
||||
import { AccountTab } from "./_components/account-tab";
|
||||
import { AppearanceTab } from "./_components/general-tab";
|
||||
import { TokensTab } from "./_components/tokens-tab";
|
||||
import { WorkspaceTab } from "./_components/workspace-tab";
|
||||
import { MembersTab } from "./_components/members-tab";
|
||||
import { RepositoriesTab } from "./_components/repositories-tab";
|
||||
|
||||
const accountTabs = [
|
||||
{ value: "profile", label: "Profile", icon: User },
|
||||
{ value: "appearance", label: "Appearance", icon: Palette },
|
||||
{ value: "tokens", label: "API Tokens", icon: Key },
|
||||
];
|
||||
|
||||
const workspaceTabs = [
|
||||
{ value: "workspace", label: "General", icon: Settings },
|
||||
{ value: "repositories", label: "Repositories", icon: FolderGit2 },
|
||||
{ value: "members", label: "Members", icon: Users },
|
||||
];
|
||||
|
||||
export default function SettingsPage() {
|
||||
const workspaceName = useWorkspaceStore((s) => s.workspace?.name);
|
||||
|
||||
return (
|
||||
<Tabs defaultValue="profile" orientation="vertical" className="flex-1 min-h-0 gap-0">
|
||||
{/* Left nav */}
|
||||
<div className="w-52 shrink-0 border-r overflow-y-auto p-4">
|
||||
<h1 className="text-sm font-semibold mb-4 px-2">Settings</h1>
|
||||
<TabsList variant="line" className="flex-col items-stretch">
|
||||
{/* My Account group */}
|
||||
<span className="px-2 pb-1 pt-2 text-xs font-medium text-muted-foreground">
|
||||
My Account
|
||||
</span>
|
||||
{accountTabs.map((tab) => (
|
||||
<TabsTrigger key={tab.value} value={tab.value}>
|
||||
<tab.icon className="h-4 w-4" />
|
||||
{tab.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
|
||||
{/* Workspace group */}
|
||||
<span className="px-2 pb-1 pt-4 text-xs font-medium text-muted-foreground truncate">
|
||||
{workspaceName ?? "Workspace"}
|
||||
</span>
|
||||
{workspaceTabs.map((tab) => (
|
||||
<TabsTrigger key={tab.value} value={tab.value}>
|
||||
<tab.icon className="h-4 w-4" />
|
||||
{tab.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
{/* Right content */}
|
||||
<div className="flex-1 min-w-0 overflow-y-auto">
|
||||
<div className="w-full max-w-3xl mx-auto p-6">
|
||||
<TabsContent value="profile"><AccountTab /></TabsContent>
|
||||
<TabsContent value="appearance"><AppearanceTab /></TabsContent>
|
||||
<TabsContent value="tokens"><TokensTab /></TabsContent>
|
||||
<TabsContent value="workspace"><WorkspaceTab /></TabsContent>
|
||||
<TabsContent value="repositories"><RepositoriesTab /></TabsContent>
|
||||
<TabsContent value="members"><MembersTab /></TabsContent>
|
||||
</div>
|
||||
</div>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
export { SettingsPage as default } from "@multica/views/settings";
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
import { Suspense, useEffect, useState } from "react";
|
||||
import { useSearchParams, useRouter } from "next/navigation";
|
||||
import { useAuthStore } from "@/platform/auth";
|
||||
import { useWorkspaceStore } from "@/platform/workspace";
|
||||
import { api } from "@/platform/api";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
import { useWorkspaceStore } from "@multica/core/workspace";
|
||||
import { api } from "@multica/core/api";
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
|
||||
@@ -1,41 +1,5 @@
|
||||
/* =============================================================================
|
||||
* Multica Web — Custom styles (non-shadcn)
|
||||
* Multica Web — Custom styles (non-shadcn, web-only)
|
||||
* Shared styles (shiki, entrance-spin, sidebar, sonner, scrollbar) are in
|
||||
* @multica/ui/styles/base.css
|
||||
* ============================================================================= */
|
||||
|
||||
/* Shiki dual themes: CSS-only light/dark switching via CSS variables */
|
||||
/* @see https://shiki.style/guide/dual-themes */
|
||||
.shiki,
|
||||
.shiki span {
|
||||
color: var(--shiki-light);
|
||||
}
|
||||
|
||||
.dark .shiki,
|
||||
.dark .shiki span {
|
||||
color: var(--shiki-dark) !important;
|
||||
}
|
||||
|
||||
/* Multica icon: entrance spin animation */
|
||||
@keyframes entrance-spin {
|
||||
0% { transform: rotate(0deg); opacity: 0; }
|
||||
50% { opacity: 1; }
|
||||
100% { transform: rotate(360deg); opacity: 1; }
|
||||
}
|
||||
|
||||
.animate-entrance-spin {
|
||||
animation: entrance-spin 0.6s ease-out forwards;
|
||||
}
|
||||
|
||||
/* Sidebar: open triggers (dropdown/popover) get active background */
|
||||
[data-sidebar="menu-button"][data-popup-open] {
|
||||
background-color: var(--sidebar-accent);
|
||||
color: var(--sidebar-accent-foreground);
|
||||
}
|
||||
|
||||
/* Sonner toast: align icon to first line of text, not vertically centered */
|
||||
[data-sonner-toast] {
|
||||
align-items: flex-start !important;
|
||||
}
|
||||
|
||||
[data-sonner-toast] [data-icon] {
|
||||
margin-top: 2.5px;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
@import "tw-animate-css";
|
||||
@import "shadcn/tailwind.css";
|
||||
@import "../../../packages/ui/styles/tokens.css";
|
||||
@import "../../../packages/ui/styles/base.css";
|
||||
@import "./custom.css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
@@ -9,21 +10,3 @@
|
||||
@source "../../../packages/ui/**/*.{ts,tsx}";
|
||||
@source "../../../packages/core/**/*.{ts,tsx}";
|
||||
@source "../../../packages/views/**/*.{ts,tsx}";
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track);
|
||||
}
|
||||
*::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||
*::-webkit-scrollbar-track { background: var(--scrollbar-track); }
|
||||
*::-webkit-scrollbar-thumb { background: var(--scrollbar-thumb); border-radius: 3px; }
|
||||
*::-webkit-scrollbar-thumb:hover { background: var(--scrollbar-thumb-hover); }
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
html {
|
||||
@apply font-sans;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,10 +3,7 @@ import { Geist, Geist_Mono } from "next/font/google";
|
||||
import { ThemeProvider } from "@/components/theme-provider";
|
||||
import { Toaster } from "@multica/ui/components/ui/sonner";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import { QueryProvider } from "@multica/core/provider";
|
||||
import { AuthInitializer } from "@/features/auth";
|
||||
import { WebWSProvider } from "@/platform/ws-provider";
|
||||
import { WebNavigationProvider } from "@/platform/navigation";
|
||||
import { WebProviders } from "@/components/web-providers";
|
||||
import { LocaleSync } from "@/components/locale-sync";
|
||||
import "./globals.css";
|
||||
|
||||
@@ -67,14 +64,10 @@ export default function RootLayout({
|
||||
<body className="h-full overflow-hidden">
|
||||
<LocaleSync />
|
||||
<ThemeProvider>
|
||||
<QueryProvider showDevtools={process.env.NEXT_PUBLIC_DEVTOOLS !== "false"}>
|
||||
<WebNavigationProvider>
|
||||
<AuthInitializer>
|
||||
<WebWSProvider>{children}</WebWSProvider>
|
||||
</AuthInitializer>
|
||||
</WebNavigationProvider>
|
||||
<Toaster />
|
||||
</QueryProvider>
|
||||
<WebProviders>
|
||||
{children}
|
||||
</WebProviders>
|
||||
<Toaster />
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Spinner } from "@/components/spinner";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
|
||||
export type LoadingVariant = "generating" | "streaming";
|
||||
|
||||
interface LoadingIndicatorProps {
|
||||
variant: LoadingVariant;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const VARIANT_TEXT: Record<LoadingVariant, string> = {
|
||||
generating: "Generating...",
|
||||
streaming: "Streaming...",
|
||||
};
|
||||
|
||||
/**
|
||||
* Unified loading indicator for chat.
|
||||
* Use "generating" when waiting for AI response (no content yet).
|
||||
* Use "streaming" when content is actively being received.
|
||||
*/
|
||||
export function LoadingIndicator({ variant, className }: LoadingIndicatorProps) {
|
||||
return (
|
||||
<div className={cn("flex items-center gap-2 py-1 text-muted-foreground", className)}>
|
||||
<Spinner className="text-xs" />
|
||||
<span className="text-xs">{VARIANT_TEXT[variant]}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export { CodeBlock, InlineCode, type CodeBlockProps } from '@multica/ui/markdown'
|
||||
@@ -1,43 +0,0 @@
|
||||
import * as React from 'react'
|
||||
import {
|
||||
Markdown as MarkdownBase,
|
||||
MemoizedMarkdown as MemoizedMarkdownBase,
|
||||
type MarkdownProps as MarkdownBaseProps,
|
||||
type RenderMode
|
||||
} from '@multica/ui/markdown'
|
||||
import { IssueMentionCard } from '@multica/views/issues/components'
|
||||
|
||||
export type { RenderMode }
|
||||
|
||||
export type MarkdownProps = MarkdownBaseProps
|
||||
|
||||
/**
|
||||
* Default renderMention that delegates to IssueMentionCard for issue mentions
|
||||
* and renders a styled span for other mention types.
|
||||
*/
|
||||
function defaultRenderMention({ type, id }: { type: string; id: string }): React.ReactNode {
|
||||
if (type === 'issue') {
|
||||
return <IssueMentionCard issueId={id} />
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* App-level Markdown wrapper that injects IssueMentionCard via renderMention.
|
||||
* Callers that need custom mention rendering can pass their own renderMention prop.
|
||||
*/
|
||||
export function Markdown(props: MarkdownProps): React.JSX.Element {
|
||||
return <MarkdownBase renderMention={defaultRenderMention} {...props} />
|
||||
}
|
||||
|
||||
export const MemoizedMarkdown = React.memo(Markdown, (prevProps, nextProps) => {
|
||||
if (prevProps.id && nextProps.id) {
|
||||
return (
|
||||
prevProps.id === nextProps.id &&
|
||||
prevProps.children === nextProps.children &&
|
||||
prevProps.mode === nextProps.mode
|
||||
)
|
||||
}
|
||||
return prevProps.children === nextProps.children && prevProps.mode === nextProps.mode
|
||||
})
|
||||
MemoizedMarkdown.displayName = 'MemoizedMarkdown'
|
||||
@@ -1,22 +0,0 @@
|
||||
import * as React from 'react'
|
||||
import {
|
||||
StreamingMarkdown as StreamingMarkdownBase,
|
||||
type StreamingMarkdownProps as StreamingMarkdownBaseProps
|
||||
} from '@multica/ui/markdown'
|
||||
import { IssueMentionCard } from '@multica/views/issues/components'
|
||||
|
||||
export type StreamingMarkdownProps = StreamingMarkdownBaseProps
|
||||
|
||||
function defaultRenderMention({ type, id }: { type: string; id: string }): React.ReactNode {
|
||||
if (type === 'issue') {
|
||||
return <IssueMentionCard issueId={id} />
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* App-level StreamingMarkdown wrapper that injects IssueMentionCard via renderMention.
|
||||
*/
|
||||
export function StreamingMarkdown(props: StreamingMarkdownProps): React.JSX.Element {
|
||||
return <StreamingMarkdownBase renderMention={defaultRenderMention} {...props} />
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
export { Markdown, MemoizedMarkdown, type MarkdownProps, type RenderMode } from './Markdown'
|
||||
export { CodeBlock, InlineCode, type CodeBlockProps } from './CodeBlock'
|
||||
export { StreamingMarkdown, type StreamingMarkdownProps } from './StreamingMarkdown'
|
||||
export { preprocessLinks, detectLinks, hasLinks } from './linkify'
|
||||
export { preprocessMentionShortcodes } from './mentions'
|
||||
@@ -1 +0,0 @@
|
||||
export { preprocessLinks, detectLinks, hasLinks } from '@multica/ui/markdown'
|
||||
@@ -1 +0,0 @@
|
||||
export { preprocessMentionShortcodes } from '@multica/ui/markdown'
|
||||
@@ -1,47 +0,0 @@
|
||||
/**
|
||||
* Spinner — 3x3 grid pulse for **active processing / execution** states.
|
||||
*
|
||||
* Use when the system is actively doing work or waiting for human action
|
||||
* (streaming content, generating responses, awaiting approval).
|
||||
* For passive content-loading states, use `<Loading />` instead.
|
||||
*
|
||||
* Inherits color from `currentColor` (use Tailwind `text-*`).
|
||||
* Scales with font-size (use Tailwind `text-*` for size).
|
||||
*/
|
||||
import { cn } from "@multica/ui/lib/utils"
|
||||
|
||||
export interface SpinnerProps {
|
||||
/** Additional className for styling (color via text-*, size via Tailwind text-*) */
|
||||
className?: string
|
||||
}
|
||||
|
||||
const DELAYS = [0.2, 0.3, 0.4, 0.1, 0.2, 0.3, 0, 0.1, 0.2]
|
||||
|
||||
const cubeStyle: React.CSSProperties = {
|
||||
backgroundColor: "currentColor",
|
||||
animation: "spinner-grid 1.3s infinite ease-in-out",
|
||||
transform: "scale3d(0.5, 0.5, 1)",
|
||||
}
|
||||
|
||||
export function Spinner({ className }: SpinnerProps) {
|
||||
return (
|
||||
<span
|
||||
className={cn(className)}
|
||||
role="status"
|
||||
aria-label="Loading"
|
||||
style={{
|
||||
display: "inline-grid",
|
||||
gridTemplateColumns: "repeat(3, 1fr)",
|
||||
width: "1em",
|
||||
height: "1em",
|
||||
gap: "0.08em",
|
||||
}}
|
||||
>
|
||||
{DELAYS.map((delay, i) => (
|
||||
<span key={i} style={{ ...cubeStyle, animationDelay: `${delay}s` }} />
|
||||
))}
|
||||
|
||||
<style>{`@keyframes spinner-grid{0%,70%,100%{transform:scale3d(.5,.5,1)}35%{transform:scale3d(0,0,1)}}`}</style>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -1,25 +1,16 @@
|
||||
"use client"
|
||||
|
||||
import { ThemeProvider as NextThemesProvider } from "next-themes"
|
||||
import { TooltipProvider } from "@multica/ui/components/ui/tooltip"
|
||||
// Re-export the shared ThemeProvider from @multica/ui
|
||||
export { ThemeProvider } from "@multica/ui/components/common/theme-provider"
|
||||
|
||||
function ThemeProvider({
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NextThemesProvider>) {
|
||||
return (
|
||||
<NextThemesProvider
|
||||
attribute="class"
|
||||
defaultTheme="system"
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
{...props}
|
||||
>
|
||||
<TooltipProvider delay={500}>
|
||||
{children}
|
||||
</TooltipProvider>
|
||||
</NextThemesProvider>
|
||||
)
|
||||
// Suppress React 19 false-positive about next-themes' inline <script>.
|
||||
// The script works correctly; React 19 just warns about any <script> in components.
|
||||
// See: https://github.com/pacocoursey/next-themes/issues/337
|
||||
if (typeof window !== "undefined" && process.env.NODE_ENV === "development") {
|
||||
const orig = console.error;
|
||||
console.error = (...args: unknown[]) => {
|
||||
if (typeof args[0] === "string" && args[0].includes("Encountered a script tag"))
|
||||
return;
|
||||
orig.apply(console, args);
|
||||
};
|
||||
}
|
||||
|
||||
export { ThemeProvider }
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useTheme } from "next-themes"
|
||||
import { Sun, Moon, Monitor } from "lucide-react"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
} from "@multica/ui/components/ui/dropdown-menu"
|
||||
import { SidebarMenuButton } from "@multica/ui/components/ui/sidebar"
|
||||
|
||||
export function ThemeToggle() {
|
||||
const { setTheme } = useTheme()
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<SidebarMenuButton>
|
||||
<Sun className="dark:hidden" />
|
||||
<Moon className="hidden dark:block" />
|
||||
<span>Theme</span>
|
||||
</SidebarMenuButton>
|
||||
}
|
||||
/>
|
||||
<DropdownMenuContent side="top" align="start">
|
||||
<DropdownMenuItem onClick={() => setTheme("light")}>
|
||||
<Sun /> Light
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setTheme("dark")}>
|
||||
<Moon /> Dark
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setTheme("system")}>
|
||||
<Monitor /> System
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
21
apps/web/components/web-providers.tsx
Normal file
21
apps/web/components/web-providers.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import { CoreProvider } from "@multica/core/platform";
|
||||
import { WebNavigationProvider } from "@/platform/navigation";
|
||||
import {
|
||||
setLoggedInCookie,
|
||||
clearLoggedInCookie,
|
||||
} from "@/features/auth/auth-cookie";
|
||||
|
||||
export function WebProviders({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<CoreProvider
|
||||
apiBaseUrl={process.env.NEXT_PUBLIC_API_URL}
|
||||
wsUrl={process.env.NEXT_PUBLIC_WS_URL}
|
||||
onLogin={setLoggedInCookie}
|
||||
onLogout={clearLoggedInCookie}
|
||||
>
|
||||
<WebNavigationProvider>{children}</WebNavigationProvider>
|
||||
</CoreProvider>
|
||||
);
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import type { QueryClient } from "@tanstack/react-query";
|
||||
import type { ChatMessage } from "@multica/core/types";
|
||||
import { chatKeys } from "./queries";
|
||||
|
||||
export function onChatMessageCreated(
|
||||
qc: QueryClient,
|
||||
sessionId: string,
|
||||
message: ChatMessage,
|
||||
) {
|
||||
qc.setQueryData<ChatMessage[]>(chatKeys.messages(sessionId), (old) => {
|
||||
if (!old) return old;
|
||||
if (old.some((m) => m.id === message.id)) return old;
|
||||
return [...old, message];
|
||||
});
|
||||
}
|
||||
|
||||
export function onChatDone(qc: QueryClient, sessionId: string) {
|
||||
qc.invalidateQueries({ queryKey: chatKeys.messages(sessionId) });
|
||||
}
|
||||
12
apps/web/eslint.config.mjs
Normal file
12
apps/web/eslint.config.mjs
Normal file
@@ -0,0 +1,12 @@
|
||||
import nextConfig from "@multica/eslint-config/next";
|
||||
|
||||
export default [
|
||||
...nextConfig,
|
||||
{ ignores: [".next/"] },
|
||||
{
|
||||
files: ["**/*.test.{ts,tsx}", "**/test/**/*.{ts,tsx}"],
|
||||
rules: {
|
||||
"react/display-name": "off",
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -1,3 +0,0 @@
|
||||
export { useAuthStore } from "@/platform/auth";
|
||||
export { AuthInitializer } from "./initializer";
|
||||
export { setLoggedInCookie } from "./auth-cookie";
|
||||
@@ -1,50 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, type ReactNode } from "react";
|
||||
import { useAuthStore } from "@/platform/auth";
|
||||
import { useWorkspaceStore } from "@/platform/workspace";
|
||||
import { api } from "@/platform/api";
|
||||
import { createLogger } from "@multica/core/logger";
|
||||
import { setLoggedInCookie, clearLoggedInCookie } from "./auth-cookie";
|
||||
|
||||
const logger = createLogger("auth");
|
||||
|
||||
/**
|
||||
* Initializes auth + workspace state from localStorage on mount.
|
||||
* Fires getMe() and listWorkspaces() in parallel when a cached token exists.
|
||||
*/
|
||||
export function AuthInitializer({ children }: { children: ReactNode }) {
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem("multica_token");
|
||||
if (!token) {
|
||||
clearLoggedInCookie();
|
||||
useAuthStore.setState({ isLoading: false });
|
||||
return;
|
||||
}
|
||||
|
||||
api.setToken(token);
|
||||
const wsId = localStorage.getItem("multica_workspace_id");
|
||||
|
||||
// Fire getMe and listWorkspaces in parallel
|
||||
const mePromise = api.getMe();
|
||||
const wsPromise = api.listWorkspaces();
|
||||
|
||||
Promise.all([mePromise, wsPromise])
|
||||
.then(([user, wsList]) => {
|
||||
setLoggedInCookie();
|
||||
useAuthStore.setState({ user, isLoading: false });
|
||||
useWorkspaceStore.getState().hydrateWorkspace(wsList, wsId);
|
||||
})
|
||||
.catch((err) => {
|
||||
logger.error("auth init failed", err);
|
||||
api.setToken(null);
|
||||
api.setWorkspaceId(null);
|
||||
localStorage.removeItem("multica_token");
|
||||
localStorage.removeItem("multica_workspace_id");
|
||||
clearLoggedInCookie();
|
||||
useAuthStore.setState({ user: null, isLoading: false });
|
||||
});
|
||||
}, []);
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
import { create } from "zustand";
|
||||
|
||||
const AGENT_STORAGE_KEY = "multica:chat:selectedAgentId";
|
||||
const SESSION_STORAGE_KEY = "multica:chat:activeSessionId";
|
||||
|
||||
function readStored(key: string): string | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
return localStorage.getItem(key);
|
||||
}
|
||||
|
||||
export interface ChatTimelineItem {
|
||||
seq: number;
|
||||
type: "tool_use" | "tool_result" | "thinking" | "text" | "error";
|
||||
tool?: string;
|
||||
content?: string;
|
||||
input?: Record<string, unknown>;
|
||||
output?: string;
|
||||
}
|
||||
|
||||
interface ChatState {
|
||||
isOpen: boolean;
|
||||
isFullscreen: boolean;
|
||||
activeSessionId: string | null;
|
||||
pendingTaskId: string | null;
|
||||
selectedAgentId: string | null;
|
||||
timelineItems: ChatTimelineItem[];
|
||||
setOpen: (open: boolean) => void;
|
||||
toggle: () => void;
|
||||
toggleFullscreen: () => void;
|
||||
setActiveSession: (id: string | null) => void;
|
||||
setPendingTask: (taskId: string | null) => void;
|
||||
setSelectedAgentId: (id: string) => void;
|
||||
addTimelineItem: (item: ChatTimelineItem) => void;
|
||||
clearTimeline: () => void;
|
||||
}
|
||||
|
||||
export const useChatStore = create<ChatState>((set) => ({
|
||||
isOpen: false,
|
||||
isFullscreen: false,
|
||||
activeSessionId: readStored(SESSION_STORAGE_KEY),
|
||||
pendingTaskId: null,
|
||||
selectedAgentId: readStored(AGENT_STORAGE_KEY),
|
||||
timelineItems: [],
|
||||
setOpen: (open) => set({ isOpen: open, ...(open ? {} : { isFullscreen: false }) }),
|
||||
toggle: () => set((s) => ({ isOpen: !s.isOpen, ...(s.isOpen ? { isFullscreen: false } : {}) })),
|
||||
toggleFullscreen: () => set((s) => ({ isFullscreen: !s.isFullscreen })),
|
||||
setActiveSession: (id) => {
|
||||
if (id) {
|
||||
localStorage.setItem(SESSION_STORAGE_KEY, id);
|
||||
} else {
|
||||
localStorage.removeItem(SESSION_STORAGE_KEY);
|
||||
}
|
||||
set({ activeSessionId: id });
|
||||
},
|
||||
setPendingTask: (taskId) => set({ pendingTaskId: taskId, timelineItems: [] }),
|
||||
setSelectedAgentId: (id) => {
|
||||
localStorage.setItem(AGENT_STORAGE_KEY, id);
|
||||
set({ selectedAgentId: id });
|
||||
},
|
||||
addTimelineItem: (item) =>
|
||||
set((s) => {
|
||||
if (s.timelineItems.some((t) => t.seq === item.seq)) return s;
|
||||
return {
|
||||
timelineItems: [...s.timelineItems, item].sort(
|
||||
(a, b) => a.seq - b.seq,
|
||||
),
|
||||
};
|
||||
}),
|
||||
clearTimeline: () => set({ timelineItems: [] }),
|
||||
}));
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useAuthStore } from "@/features/auth";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
import { useLocale } from "../i18n";
|
||||
import { GitHubMark, githubUrl, heroButtonClassName } from "./shared";
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { MulticaIcon } from "@/components/multica-icon";
|
||||
import { MulticaIcon } from "@multica/ui/components/common/multica-icon";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import { useAuthStore } from "@/features/auth";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
import { XMark, GitHubMark, githubUrl, twitterUrl } from "./shared";
|
||||
import { useLocale, locales, localeLabels } from "../i18n";
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { MulticaIcon } from "@/components/multica-icon";
|
||||
import { MulticaIcon } from "@multica/ui/components/common/multica-icon";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import { useAuthStore } from "@/features/auth";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
import { useLocale } from "../i18n";
|
||||
import { GitHubMark, XMark, githubUrl, twitterUrl, headerButtonClassName } from "./shared";
|
||||
import { GitHubMark, githubUrl, headerButtonClassName } from "./shared";
|
||||
|
||||
export function LandingHeader({
|
||||
variant = "dark",
|
||||
@@ -44,14 +44,6 @@ export function LandingHeader({
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center gap-2.5 sm:gap-3">
|
||||
<Link
|
||||
href={twitterUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className={headerButtonClassName("ghost", variant)}
|
||||
>
|
||||
<XMark className="size-3.5" />
|
||||
</Link>
|
||||
<Link
|
||||
href={githubUrl}
|
||||
target="_blank"
|
||||
|
||||
@@ -2,11 +2,13 @@
|
||||
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useAuthStore } from "@/features/auth";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
import { useLocale } from "../i18n";
|
||||
import {
|
||||
ClaudeCodeLogo,
|
||||
CodexLogo,
|
||||
OpenClawLogo,
|
||||
OpenCodeLogo,
|
||||
GitHubMark,
|
||||
githubUrl,
|
||||
heroButtonClassName,
|
||||
@@ -65,6 +67,14 @@ export function LandingHero() {
|
||||
<CodexLogo className="size-5" />
|
||||
<span className="text-[15px] font-medium">Codex</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2.5 text-white/80">
|
||||
<OpenClawLogo className="size-5" />
|
||||
<span className="text-[15px] font-medium">OpenClaw</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2.5 text-white/80">
|
||||
<OpenCodeLogo className="size-5" />
|
||||
<span className="text-[15px] font-medium">OpenCode</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -75,6 +75,81 @@ export function CodexLogo({ className }: { className?: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
export function OpenClawLogo({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 16 16"
|
||||
aria-hidden="true"
|
||||
className={className}
|
||||
fill="none"
|
||||
>
|
||||
<g fill="#3a0a0d">
|
||||
<rect x="1" y="5" width="1" height="3" />
|
||||
<rect x="2" y="4" width="1" height="1" />
|
||||
<rect x="2" y="8" width="1" height="1" />
|
||||
<rect x="3" y="3" width="1" height="1" />
|
||||
<rect x="3" y="9" width="1" height="1" />
|
||||
<rect x="4" y="2" width="1" height="1" />
|
||||
<rect x="4" y="10" width="1" height="1" />
|
||||
<rect x="5" y="2" width="6" height="1" />
|
||||
<rect x="11" y="2" width="1" height="1" />
|
||||
<rect x="12" y="3" width="1" height="1" />
|
||||
<rect x="12" y="9" width="1" height="1" />
|
||||
<rect x="13" y="4" width="1" height="1" />
|
||||
<rect x="13" y="8" width="1" height="1" />
|
||||
<rect x="14" y="5" width="1" height="3" />
|
||||
<rect x="5" y="11" width="6" height="1" />
|
||||
<rect x="4" y="12" width="1" height="1" />
|
||||
<rect x="11" y="12" width="1" height="1" />
|
||||
<rect x="3" y="13" width="1" height="1" />
|
||||
<rect x="12" y="13" width="1" height="1" />
|
||||
<rect x="5" y="14" width="6" height="1" />
|
||||
</g>
|
||||
<g fill="#ff4f40">
|
||||
<rect x="5" y="3" width="6" height="1" />
|
||||
<rect x="4" y="4" width="8" height="1" />
|
||||
<rect x="3" y="5" width="10" height="1" />
|
||||
<rect x="3" y="6" width="10" height="1" />
|
||||
<rect x="3" y="7" width="10" height="1" />
|
||||
<rect x="4" y="8" width="8" height="1" />
|
||||
<rect x="5" y="9" width="6" height="1" />
|
||||
<rect x="5" y="12" width="6" height="1" />
|
||||
<rect x="6" y="13" width="4" height="1" />
|
||||
</g>
|
||||
<g fill="#ff775f">
|
||||
<rect x="1" y="6" width="2" height="1" />
|
||||
<rect x="2" y="5" width="1" height="1" />
|
||||
<rect x="2" y="7" width="1" height="1" />
|
||||
<rect x="13" y="6" width="2" height="1" />
|
||||
<rect x="13" y="5" width="1" height="1" />
|
||||
<rect x="13" y="7" width="1" height="1" />
|
||||
</g>
|
||||
<g fill="#081016">
|
||||
<rect x="6" y="5" width="1" height="1" />
|
||||
<rect x="9" y="5" width="1" height="1" />
|
||||
</g>
|
||||
<g fill="#f5fbff">
|
||||
<rect x="6" y="4" width="1" height="1" />
|
||||
<rect x="9" y="4" width="1" height="1" />
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function OpenCodeLogo({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
className={className}
|
||||
fill="none"
|
||||
>
|
||||
<path d="M18 18H6V6H18V18Z" fill="#CFCECD" />
|
||||
<path d="M18 3H6V18H18V3ZM24 24H0V0H24V24Z" fill="#656363" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function headerButtonClassName(
|
||||
tone: "ghost" | "solid",
|
||||
variant: "dark" | "light" = "dark",
|
||||
|
||||
@@ -107,7 +107,7 @@ export const en: LandingDict = {
|
||||
{
|
||||
title: "Auto-detection & plug-and-play",
|
||||
description:
|
||||
"Multica detects available CLIs like Claude Code and Codex automatically. Connect a machine, and it\u2019s ready to work.",
|
||||
"Multica detects available CLIs like Claude Code, Codex, OpenClaw, and OpenCode automatically. Connect a machine, and it\u2019s ready to work.",
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -126,7 +126,7 @@ export const en: LandingDict = {
|
||||
{
|
||||
title: "Install the CLI & connect your machine",
|
||||
description:
|
||||
"Run multica login to authenticate, then multica daemon start. The daemon auto-detects Claude Code and Codex on your machine \u2014 plug in and go.",
|
||||
"Run multica login to authenticate, then multica daemon start. The daemon auto-detects Claude Code, Codex, OpenClaw, and OpenCode on your machine \u2014 plug in and go.",
|
||||
},
|
||||
{
|
||||
title: "Create your first agent",
|
||||
@@ -181,7 +181,7 @@ export const en: LandingDict = {
|
||||
{
|
||||
question: "What coding agents does Multica support?",
|
||||
answer:
|
||||
"Multica currently supports Claude Code and OpenAI Codex out of the box. The daemon auto-detects whichever CLIs you have installed. More backends are on the roadmap \u2014 and since it\u2019s open source, you can add your own.",
|
||||
"Multica currently supports Claude Code, Codex, OpenClaw, and OpenCode out of the box. The daemon auto-detects whichever CLIs you have installed. Since it\u2019s open source, you can also add your own backends.",
|
||||
},
|
||||
{
|
||||
question: "Do I need to self-host, or is there a cloud version?",
|
||||
@@ -190,7 +190,7 @@ export const en: LandingDict = {
|
||||
},
|
||||
{
|
||||
question:
|
||||
"How is this different from just using Claude Code or Codex directly?",
|
||||
"How is this different from just using coding agents directly?",
|
||||
answer:
|
||||
"Coding agents are great at executing. Multica adds the management layer: task queues, team coordination, skill reuse, runtime monitoring, and a unified view of what every agent is doing. Think of it as the project manager for your agents.",
|
||||
},
|
||||
@@ -273,7 +273,7 @@ export const en: LandingDict = {
|
||||
subtitle: "New updates and improvements to Multica.",
|
||||
entries: [
|
||||
{
|
||||
version: "0.1.10",
|
||||
version: "0.1.21",
|
||||
date: "2026-04-09",
|
||||
title: "Projects, Search & Monorepo",
|
||||
changes: [
|
||||
@@ -292,7 +292,7 @@ export const en: LandingDict = {
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.1.9",
|
||||
version: "0.1.20",
|
||||
date: "2026-04-08",
|
||||
title: "Sub-Issues, TanStack Query & Usage Tracking",
|
||||
changes: [
|
||||
@@ -310,7 +310,7 @@ export const en: LandingDict = {
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.1.8",
|
||||
version: "0.1.18",
|
||||
date: "2026-04-07",
|
||||
title: "OAuth, OpenClaw & Issue Loading",
|
||||
changes: [
|
||||
@@ -325,7 +325,7 @@ export const en: LandingDict = {
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.1.7",
|
||||
version: "0.1.17",
|
||||
date: "2026-04-05",
|
||||
title: "Comment Pagination & CLI Polish",
|
||||
changes: [
|
||||
@@ -339,7 +339,7 @@ export const en: LandingDict = {
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.1.6",
|
||||
version: "0.1.15",
|
||||
date: "2026-04-03",
|
||||
title: "Editor Overhaul & Agent Lifecycle",
|
||||
changes: [
|
||||
@@ -355,7 +355,7 @@ export const en: LandingDict = {
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.1.5",
|
||||
version: "0.1.14",
|
||||
date: "2026-04-02",
|
||||
title: "Mentions & Permissions",
|
||||
changes: [
|
||||
@@ -372,7 +372,7 @@ export const en: LandingDict = {
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.1.4",
|
||||
version: "0.1.13",
|
||||
date: "2026-04-01",
|
||||
title: "My Issues & i18n",
|
||||
changes: [
|
||||
|
||||
@@ -107,7 +107,7 @@ export const zh: LandingDict = {
|
||||
{
|
||||
title: "\u81ea\u52a8\u68c0\u6d4b\u4e0e\u5373\u63d2\u5373\u7528",
|
||||
description:
|
||||
"Multica \u81ea\u52a8\u68c0\u6d4b Claude Code \u548c Codex \u7b49\u53ef\u7528 CLI\u3002\u8fde\u63a5\u4e00\u53f0\u673a\u5668\uff0c\u5373\u53ef\u5f00\u59cb\u5de5\u4f5c\u3002",
|
||||
"Multica \u81ea\u52a8\u68c0\u6d4b Claude Code\u3001Codex\u3001OpenClaw \u548c OpenCode \u7b49\u53ef\u7528 CLI\u3002\u8fde\u63a5\u4e00\u53f0\u673a\u5668\uff0c\u5373\u53ef\u5f00\u59cb\u5de5\u4f5c\u3002",
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -126,7 +126,7 @@ export const zh: LandingDict = {
|
||||
{
|
||||
title: "\u5b89\u88c5 CLI \u5e76\u8fde\u63a5\u4f60\u7684\u673a\u5668",
|
||||
description:
|
||||
"\u8fd0\u884c multica login \u8fdb\u884c\u8ba4\u8bc1\uff0c\u7136\u540e multica daemon start\u3002\u5b88\u62a4\u8fdb\u7a0b\u81ea\u52a8\u68c0\u6d4b\u4f60\u673a\u5668\u4e0a\u7684 Claude Code \u548c Codex\u2014\u2014\u63d2\u4e0a\u5c31\u7528\u3002",
|
||||
"\u8fd0\u884c multica login \u8fdb\u884c\u8ba4\u8bc1\uff0c\u7136\u540e multica daemon start\u3002\u5b88\u62a4\u8fdb\u7a0b\u81ea\u52a8\u68c0\u6d4b\u4f60\u673a\u5668\u4e0a\u7684 Claude Code\u3001Codex\u3001OpenClaw \u548c OpenCode\u2014\u2014\u63d2\u4e0a\u5c31\u7528\u3002",
|
||||
},
|
||||
{
|
||||
title: "\u521b\u5efa\u4f60\u7684\u7b2c\u4e00\u4e2a Agent",
|
||||
@@ -181,7 +181,7 @@ export const zh: LandingDict = {
|
||||
{
|
||||
question: "Multica \u652f\u6301\u54ea\u4e9b\u7f16\u7801 Agent\uff1f",
|
||||
answer:
|
||||
"Multica \u76ee\u524d\u5f00\u7bb1\u5373\u7528\u652f\u6301 Claude Code \u548c OpenAI Codex\u3002\u5b88\u62a4\u8fdb\u7a0b\u81ea\u52a8\u68c0\u6d4b\u4f60\u5b89\u88c5\u7684 CLI\u3002\u66f4\u591a\u540e\u7aef\u5728\u8def\u7ebf\u56fe\u4e0a\u2014\u2014\u800c\u4e14\u56e0\u4e3a\u5f00\u6e90\uff0c\u4f60\u4e5f\u53ef\u4ee5\u81ea\u5df1\u6dfb\u52a0\u3002",
|
||||
"Multica \u76ee\u524d\u5f00\u7bb1\u5373\u7528\u652f\u6301 Claude Code\u3001Codex\u3001OpenClaw \u548c OpenCode\u3002\u5b88\u62a4\u8fdb\u7a0b\u81ea\u52a8\u68c0\u6d4b\u4f60\u5b89\u88c5\u7684 CLI\u3002\u56e0\u4e3a\u5f00\u6e90\uff0c\u4f60\u4e5f\u53ef\u4ee5\u81ea\u5df1\u6dfb\u52a0\u540e\u7aef\u3002",
|
||||
},
|
||||
{
|
||||
question: "\u9700\u8981\u81ea\u6258\u7ba1\u5417\uff0c\u8fd8\u662f\u6709\u4e91\u7248\u672c\uff1f",
|
||||
@@ -190,7 +190,7 @@ export const zh: LandingDict = {
|
||||
},
|
||||
{
|
||||
question:
|
||||
"\u8fd9\u548c\u76f4\u63a5\u7528 Claude Code \u6216 Codex \u6709\u4ec0\u4e48\u533a\u522b\uff1f",
|
||||
"\u8fd9\u548c\u76f4\u63a5\u7528\u7f16\u7801 Agent \u6709\u4ec0\u4e48\u533a\u522b\uff1f",
|
||||
answer:
|
||||
"\u7f16\u7801 Agent \u64c5\u957f\u6267\u884c\u3002Multica \u6dfb\u52a0\u7684\u662f\u7ba1\u7406\u5c42\uff1a\u4efb\u52a1\u961f\u5217\u3001\u56e2\u961f\u534f\u4f5c\u3001\u6280\u80fd\u590d\u7528\u3001\u8fd0\u884c\u65f6\u76d1\u63a7\uff0c\u4ee5\u53ca\u6bcf\u4e2a Agent \u5728\u505a\u4ec0\u4e48\u7684\u7edf\u4e00\u89c6\u56fe\u3002\u628a\u5b83\u60f3\u8c61\u6210\u4f60\u7684 Agent \u7684\u9879\u76ee\u7ecf\u7406\u3002",
|
||||
},
|
||||
@@ -273,7 +273,7 @@ export const zh: LandingDict = {
|
||||
subtitle: "Multica \u7684\u6700\u65b0\u66f4\u65b0\u548c\u6539\u8fdb\u3002",
|
||||
entries: [
|
||||
{
|
||||
version: "0.1.10",
|
||||
version: "0.1.21",
|
||||
date: "2026-04-09",
|
||||
title: "项目、搜索与 Monorepo",
|
||||
changes: [
|
||||
@@ -292,7 +292,7 @@ export const zh: LandingDict = {
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.1.9",
|
||||
version: "0.1.20",
|
||||
date: "2026-04-08",
|
||||
title: "子 Issue、TanStack Query 与用量追踪",
|
||||
changes: [
|
||||
@@ -310,7 +310,7 @@ export const zh: LandingDict = {
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.1.8",
|
||||
version: "0.1.18",
|
||||
date: "2026-04-07",
|
||||
title: "OAuth、OpenClaw 与 Issue 加载优化",
|
||||
changes: [
|
||||
@@ -325,7 +325,7 @@ export const zh: LandingDict = {
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.1.7",
|
||||
version: "0.1.17",
|
||||
date: "2026-04-05",
|
||||
title: "评论分页与 CLI 优化",
|
||||
changes: [
|
||||
@@ -339,7 +339,7 @@ export const zh: LandingDict = {
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.1.6",
|
||||
version: "0.1.15",
|
||||
date: "2026-04-03",
|
||||
title: "编辑器重构与 Agent 生命周期",
|
||||
changes: [
|
||||
@@ -355,7 +355,7 @@ export const zh: LandingDict = {
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.1.5",
|
||||
version: "0.1.14",
|
||||
date: "2026-04-02",
|
||||
title: "提及与权限",
|
||||
changes: [
|
||||
@@ -372,7 +372,7 @@ export const zh: LandingDict = {
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.1.4",
|
||||
version: "0.1.13",
|
||||
date: "2026-04-01",
|
||||
title: "\u6211\u7684 Issue \u4e0e\u56fd\u9645\u5316",
|
||||
changes: [
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export { SearchCommand } from "./components/search-command";
|
||||
@@ -8,7 +8,7 @@
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "next lint",
|
||||
"lint": "eslint .",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -69,15 +69,15 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "catalog:",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@testing-library/jest-dom": "catalog:",
|
||||
"@testing-library/react": "catalog:",
|
||||
"@testing-library/user-event": "catalog:",
|
||||
"@types/react": "catalog:",
|
||||
"@types/react-dom": "catalog:",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"jsdom": "^29.0.1",
|
||||
"@vitejs/plugin-react": "catalog:",
|
||||
"jsdom": "catalog:",
|
||||
"tailwindcss": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
"vitest": "^4.1.0"
|
||||
"vitest": "catalog:"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
import { ApiClient } from "@multica/core/api/client";
|
||||
import { setApiInstance } from "@multica/core/api";
|
||||
import { createLogger } from "@multica/core/logger";
|
||||
|
||||
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || "";
|
||||
|
||||
export const api = new ApiClient(API_BASE_URL, {
|
||||
logger: createLogger("api"),
|
||||
onUnauthorized: () => {
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.removeItem("multica_token");
|
||||
localStorage.removeItem("multica_workspace_id");
|
||||
if (window.location.pathname !== "/") {
|
||||
window.location.href = "/";
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Register as the global singleton for @multica/core queries/mutations
|
||||
setApiInstance(api);
|
||||
|
||||
// Hydrate from localStorage
|
||||
if (typeof window !== "undefined") {
|
||||
const token = localStorage.getItem("multica_token");
|
||||
if (token) api.setToken(token);
|
||||
const wsId = localStorage.getItem("multica_workspace_id");
|
||||
if (wsId) api.setWorkspaceId(wsId);
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import { createAuthStore, registerAuthStore } from "@multica/core/auth";
|
||||
import { api } from "./api";
|
||||
import { webStorage } from "./storage";
|
||||
import {
|
||||
setLoggedInCookie,
|
||||
clearLoggedInCookie,
|
||||
} from "../features/auth/auth-cookie";
|
||||
|
||||
export const useAuthStore = createAuthStore({
|
||||
api,
|
||||
storage: webStorage,
|
||||
onLogin: setLoggedInCookie,
|
||||
onLogout: clearLoggedInCookie,
|
||||
});
|
||||
|
||||
registerAuthStore(useAuthStore);
|
||||
@@ -1,3 +0,0 @@
|
||||
export { api } from "./api";
|
||||
export { useAuthStore } from "./auth";
|
||||
export { useWorkspaceStore } from "./workspace";
|
||||
@@ -1,11 +0,0 @@
|
||||
import { createWorkspaceStore, registerWorkspaceStore } from "@multica/core/workspace";
|
||||
import { toast } from "sonner";
|
||||
import { api } from "./api";
|
||||
import { webStorage } from "./storage";
|
||||
|
||||
export const useWorkspaceStore = createWorkspaceStore(api, {
|
||||
storage: webStorage,
|
||||
onError: (msg) => toast.error(msg),
|
||||
});
|
||||
|
||||
registerWorkspaceStore(useWorkspaceStore);
|
||||
@@ -1,26 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { WSProvider } from "@multica/core/realtime";
|
||||
import { useAuthStore } from "./auth";
|
||||
import { useWorkspaceStore } from "./workspace";
|
||||
import { webStorage } from "./storage";
|
||||
import { toast } from "sonner";
|
||||
|
||||
const WS_URL = process.env.NEXT_PUBLIC_WS_URL || "ws://localhost:8080/ws";
|
||||
|
||||
export function WebWSProvider({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<WSProvider
|
||||
wsUrl={WS_URL}
|
||||
authStore={useAuthStore}
|
||||
workspaceStore={useWorkspaceStore}
|
||||
storage={webStorage}
|
||||
onToast={(message, type) => {
|
||||
if (type === "error") toast.error(message);
|
||||
else toast.info(message);
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</WSProvider>
|
||||
);
|
||||
}
|
||||
@@ -66,7 +66,7 @@ export const mockAgents: Agent[] = [
|
||||
];
|
||||
|
||||
// Mock auth context value
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
|
||||
export const mockAuthValue: Record<string, any> = {
|
||||
user: mockUser,
|
||||
workspace: mockWorkspace,
|
||||
|
||||
1082
docs/plans/2026-04-09-desktop-app.md
Normal file
1082
docs/plans/2026-04-09-desktop-app.md
Normal file
File diff suppressed because it is too large
Load Diff
868
docs/plans/2026-04-09-monorepo-extraction.md
Normal file
868
docs/plans/2026-04-09-monorepo-extraction.md
Normal file
@@ -0,0 +1,868 @@
|
||||
# Monorepo Full Extraction Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** 让每个 app 只剩路由定义 + NavigationAdapter + 真正独有的功能(landing page、title bar、cookie)。所有业务逻辑、UI、状态管理、API、WS 全部在共享包里,零重复。
|
||||
|
||||
**核心洞察:** Electron renderer 就是浏览器。localStorage、fetch、WebSocket 和 Next.js 客户端页面完全一样。URL 是环境配置不是 app 差异。所以除了 NavigationAdapter(路由框架不同),没有任何东西需要在每个 app 里单独写。
|
||||
|
||||
**Architecture:** `@multica/core` 自带完整初始化(API、stores、WS),不需要每个 app 调用 factory。`@multica/views` 包含所有页面和 layout。每个 app 只提供路由壳子。
|
||||
|
||||
**Tech Stack:** React 19, TanStack Query, Zustand, Tailwind CSS v4, shadcn/ui, TypeScript strict mode.
|
||||
|
||||
**Branch:** `feat/monorepo-extraction` (from latest `feat/desktop-app`)
|
||||
|
||||
---
|
||||
|
||||
## Work Breakdown
|
||||
|
||||
| Phase | Tasks | What it achieves |
|
||||
|---|---|---|
|
||||
| Phase 1: Core 自包含初始化 | 1-2 | core 自己初始化 API/stores/WS,app 不需要写任何 platform 代码 |
|
||||
| Phase 2: Sidebar & Layout | 3-5 | 共享 AppSidebar + DashboardLayout,删除两端重复 |
|
||||
| Phase 3: Login | 6-7 | 共享 LoginPage + AuthInitializer |
|
||||
| Phase 4: Agents | 8-10 | 1,279 行 → 共享模块 |
|
||||
| Phase 5: Inbox | 11-13 | 468 行 → 共享模块 |
|
||||
| Phase 6: Settings | 14-16 | 1,277 行 → 共享模块 |
|
||||
| Phase 7: 清理 | 17-18 | 删除所有 platform 目录、placeholder、死代码 |
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Core 自包含初始化
|
||||
|
||||
### 设计思路
|
||||
|
||||
现在每个 app 都要手动调用 `new ApiClient()`、`createAuthStore()`、`createWorkspaceStore()`、包 `<WSProvider>`。但这些逻辑在两个 app 里完全一样。
|
||||
|
||||
方案:`@multica/core` 导出一个 `<CoreProvider>` 包裹整个应用。它内部自动完成所有初始化。配置通过环境变量(`VITE_API_URL` / `NEXT_PUBLIC_API_URL`)或 prop 注入。SSR-safe 的 localStorage wrapper 内置到 core 里作为默认 storage(`typeof window` 守卫对 Electron 无害)。
|
||||
|
||||
```tsx
|
||||
// 任何 app 的根组件,只需要这样:
|
||||
<CoreProvider
|
||||
apiBaseUrl={import.meta.env.VITE_API_URL ?? ""}
|
||||
wsUrl={import.meta.env.VITE_WS_URL ?? "ws://localhost:8080/ws"}
|
||||
onLogin={setLoggedInCookie} // 可选,Web 独有
|
||||
onLogout={clearLoggedInCookie} // 可选,Web 独有
|
||||
>
|
||||
{children}
|
||||
</CoreProvider>
|
||||
```
|
||||
|
||||
Desktop 更简单(没有可选回调):
|
||||
```tsx
|
||||
<CoreProvider
|
||||
apiBaseUrl={import.meta.env.VITE_API_URL ?? "http://localhost:8080"}
|
||||
wsUrl={import.meta.env.VITE_WS_URL ?? "ws://localhost:8080/ws"}
|
||||
>
|
||||
{children}
|
||||
</CoreProvider>
|
||||
```
|
||||
|
||||
### Task 1: 在 `@multica/core` 里创建 CoreProvider
|
||||
|
||||
**Files:**
|
||||
- Create: `packages/core/platform/storage.ts` — 内置 SSR-safe localStorage
|
||||
- Create: `packages/core/platform/core-provider.tsx` — CoreProvider 组件
|
||||
- Create: `packages/core/platform/auth-initializer.tsx` — 共享 AuthInitializer
|
||||
- Create: `packages/core/platform/types.ts` — CoreProviderProps
|
||||
- Create: `packages/core/platform/index.ts` — barrel export
|
||||
- Modify: `packages/core/package.json` — add `"./platform"` export
|
||||
|
||||
**Step 1: Create built-in SSR-safe storage**
|
||||
|
||||
```typescript
|
||||
// packages/core/platform/storage.ts
|
||||
import type { StorageAdapter } from "../types/storage";
|
||||
|
||||
/** SSR-safe localStorage. Works in both Next.js (SSR) and Electron (always client). */
|
||||
export const defaultStorage: StorageAdapter = {
|
||||
getItem: (k) => (typeof window !== "undefined" ? localStorage.getItem(k) : null),
|
||||
setItem: (k, v) => { if (typeof window !== "undefined") localStorage.setItem(k, v); },
|
||||
removeItem: (k) => { if (typeof window !== "undefined") localStorage.removeItem(k); },
|
||||
};
|
||||
```
|
||||
|
||||
**Step 2: Create types**
|
||||
|
||||
```typescript
|
||||
// packages/core/platform/types.ts
|
||||
export interface CoreProviderProps {
|
||||
children: React.ReactNode;
|
||||
/** API base URL. Default: "" (same-origin). */
|
||||
apiBaseUrl?: string;
|
||||
/** WebSocket URL. Default: "ws://localhost:8080/ws". */
|
||||
wsUrl?: string;
|
||||
/** Called after successful login (e.g. set cookie for Next.js middleware). */
|
||||
onLogin?: () => void;
|
||||
/** Called after logout (e.g. clear cookie). */
|
||||
onLogout?: () => void;
|
||||
}
|
||||
```
|
||||
|
||||
**Step 3: Create AuthInitializer**
|
||||
|
||||
Merge the identical logic from both apps. Uses `defaultStorage`, reads from existing singletons.
|
||||
|
||||
```typescript
|
||||
// packages/core/platform/auth-initializer.tsx
|
||||
import { useEffect, type ReactNode } from "react";
|
||||
import { getApi } from "../api";
|
||||
import { useAuthStore } from "../auth";
|
||||
import { useWorkspaceStore } from "../workspace";
|
||||
import { createLogger } from "../logger";
|
||||
import { defaultStorage } from "./storage";
|
||||
|
||||
const logger = createLogger("auth");
|
||||
|
||||
export function AuthInitializer({
|
||||
children,
|
||||
onLogin,
|
||||
onLogout,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
onLogin?: () => void;
|
||||
onLogout?: () => void;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
const token = defaultStorage.getItem("multica_token");
|
||||
if (!token) {
|
||||
onLogout?.();
|
||||
useAuthStore.setState({ isLoading: false });
|
||||
return;
|
||||
}
|
||||
|
||||
const api = getApi();
|
||||
api.setToken(token);
|
||||
const wsId = defaultStorage.getItem("multica_workspace_id");
|
||||
|
||||
Promise.all([api.getMe(), api.listWorkspaces()])
|
||||
.then(([user, wsList]) => {
|
||||
onLogin?.();
|
||||
useAuthStore.setState({ user, isLoading: false });
|
||||
useWorkspaceStore.getState().hydrateWorkspace(wsList, wsId);
|
||||
})
|
||||
.catch((err) => {
|
||||
logger.error("auth init failed", err);
|
||||
api.setToken(null);
|
||||
api.setWorkspaceId(null);
|
||||
defaultStorage.removeItem("multica_token");
|
||||
defaultStorage.removeItem("multica_workspace_id");
|
||||
onLogout?.();
|
||||
useAuthStore.setState({ user: null, isLoading: false });
|
||||
});
|
||||
}, []);
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
```
|
||||
|
||||
**Step 4: Create CoreProvider**
|
||||
|
||||
This is the one component that wires everything together. Each app wraps its root with this.
|
||||
|
||||
```typescript
|
||||
// packages/core/platform/core-provider.tsx
|
||||
"use client";
|
||||
|
||||
import { type ReactNode, useMemo } from "react";
|
||||
import { ApiClient } from "../api/client";
|
||||
import { setApiInstance } from "../api";
|
||||
import { createAuthStore, registerAuthStore } from "../auth";
|
||||
import { createWorkspaceStore, registerWorkspaceStore } from "../workspace";
|
||||
import { WSProvider } from "../realtime";
|
||||
import { QueryProvider } from "../provider";
|
||||
import { createLogger } from "../logger";
|
||||
import { defaultStorage } from "./storage";
|
||||
import { AuthInitializer } from "./auth-initializer";
|
||||
import type { CoreProviderProps } from "./types";
|
||||
|
||||
// Module-level singletons — created once, shared across renders.
|
||||
let initialized = false;
|
||||
let authStore: ReturnType<typeof createAuthStore>;
|
||||
let workspaceStore: ReturnType<typeof createWorkspaceStore>;
|
||||
|
||||
function initCore(apiBaseUrl: string) {
|
||||
if (initialized) return;
|
||||
|
||||
const api = new ApiClient(apiBaseUrl, {
|
||||
logger: createLogger("api"),
|
||||
onUnauthorized: () => {
|
||||
defaultStorage.removeItem("multica_token");
|
||||
defaultStorage.removeItem("multica_workspace_id");
|
||||
},
|
||||
});
|
||||
setApiInstance(api);
|
||||
|
||||
// Hydrate token from storage
|
||||
const token = defaultStorage.getItem("multica_token");
|
||||
if (token) api.setToken(token);
|
||||
const wsId = defaultStorage.getItem("multica_workspace_id");
|
||||
if (wsId) api.setWorkspaceId(wsId);
|
||||
|
||||
authStore = createAuthStore({ api, storage: defaultStorage });
|
||||
registerAuthStore(authStore);
|
||||
|
||||
workspaceStore = createWorkspaceStore(api, {
|
||||
storage: defaultStorage,
|
||||
});
|
||||
registerWorkspaceStore(workspaceStore);
|
||||
|
||||
initialized = true;
|
||||
}
|
||||
|
||||
export function CoreProvider({
|
||||
children,
|
||||
apiBaseUrl = "",
|
||||
wsUrl = "ws://localhost:8080/ws",
|
||||
onLogin,
|
||||
onLogout,
|
||||
}: CoreProviderProps) {
|
||||
// Initialize singletons on first render
|
||||
useMemo(() => initCore(apiBaseUrl), [apiBaseUrl]);
|
||||
|
||||
return (
|
||||
<QueryProvider>
|
||||
<AuthInitializer onLogin={onLogin} onLogout={onLogout}>
|
||||
<WSProvider
|
||||
wsUrl={wsUrl}
|
||||
authStore={authStore}
|
||||
workspaceStore={workspaceStore}
|
||||
storage={defaultStorage}
|
||||
>
|
||||
{children}
|
||||
</WSProvider>
|
||||
</AuthInitializer>
|
||||
</QueryProvider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Step 5: Barrel export + package.json**
|
||||
|
||||
```typescript
|
||||
// packages/core/platform/index.ts
|
||||
export { CoreProvider } from "./core-provider";
|
||||
export type { CoreProviderProps } from "./types";
|
||||
export { AuthInitializer } from "./auth-initializer";
|
||||
export { defaultStorage } from "./storage";
|
||||
```
|
||||
|
||||
Add to `packages/core/package.json` exports:
|
||||
```json
|
||||
"./platform": "./platform/index.ts"
|
||||
```
|
||||
|
||||
**Step 6: Run typecheck**
|
||||
|
||||
Run: `pnpm typecheck`
|
||||
Expected: PASS
|
||||
|
||||
**Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add packages/core/platform/ packages/core/package.json
|
||||
git commit -m "feat(core): add CoreProvider — single component for full app initialization"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Migrate both apps to CoreProvider
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/web/app/layout.tsx` — replace all providers with `<CoreProvider>`
|
||||
- Modify: `apps/desktop/src/renderer/src/App.tsx` — replace all providers with `<CoreProvider>`
|
||||
- Delete: `apps/web/platform/api.ts`
|
||||
- Delete: `apps/web/platform/auth.ts`
|
||||
- Delete: `apps/web/platform/workspace.ts`
|
||||
- Delete: `apps/web/platform/storage.ts`
|
||||
- Delete: `apps/web/platform/ws-provider.tsx`
|
||||
- Delete: `apps/web/features/auth/initializer.tsx`
|
||||
- Delete: `apps/desktop/src/renderer/src/platform/api.ts`
|
||||
- Delete: `apps/desktop/src/renderer/src/platform/auth.ts`
|
||||
- Delete: `apps/desktop/src/renderer/src/platform/workspace.ts`
|
||||
- Delete: `apps/desktop/src/renderer/src/platform/storage.ts`
|
||||
- Delete: `apps/desktop/src/renderer/src/platform/ws-provider.tsx`
|
||||
- Delete: `apps/desktop/src/renderer/src/platform/auth-initializer.tsx`
|
||||
- Keep: `apps/web/platform/navigation.tsx` — NavigationAdapter (唯一不可共享)
|
||||
- Keep: `apps/desktop/src/renderer/src/platform/navigation.tsx` — NavigationAdapter
|
||||
- Keep: `apps/web/features/auth/auth-cookie.ts` — Web 独有
|
||||
|
||||
**Step 1: Update web root layout**
|
||||
|
||||
```typescript
|
||||
// apps/web/app/layout.tsx
|
||||
import { CoreProvider } from "@multica/core/platform";
|
||||
import { WebNavigationProvider } from "@/platform/navigation";
|
||||
import { setLoggedInCookie, clearLoggedInCookie } from "@/features/auth/auth-cookie";
|
||||
import { ThemeProvider } from "@/components/theme-provider";
|
||||
import { Toaster } from "sonner";
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body>
|
||||
<ThemeProvider>
|
||||
<CoreProvider
|
||||
apiBaseUrl={process.env.NEXT_PUBLIC_API_URL}
|
||||
wsUrl={process.env.NEXT_PUBLIC_WS_URL}
|
||||
onLogin={setLoggedInCookie}
|
||||
onLogout={clearLoggedInCookie}
|
||||
>
|
||||
<WebNavigationProvider>
|
||||
{children}
|
||||
</WebNavigationProvider>
|
||||
</CoreProvider>
|
||||
<Toaster />
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Update desktop App.tsx**
|
||||
|
||||
```typescript
|
||||
// apps/desktop/src/renderer/src/App.tsx
|
||||
import { RouterProvider } from "react-router-dom";
|
||||
import { CoreProvider } from "@multica/core/platform";
|
||||
import { ThemeProvider } from "./components/theme-provider";
|
||||
import { Toaster } from "sonner";
|
||||
import { router } from "./router";
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<CoreProvider
|
||||
apiBaseUrl={import.meta.env.VITE_API_URL}
|
||||
wsUrl={import.meta.env.VITE_WS_URL}
|
||||
>
|
||||
<RouterProvider router={router} />
|
||||
</CoreProvider>
|
||||
<Toaster />
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Step 3: Fix all `@/platform/*` imports across both apps**
|
||||
|
||||
Search all files for:
|
||||
- `from "@/platform/api"` → `from "@multica/core/api"` (use singleton proxy `api`)
|
||||
- `from "@/platform/auth"` → `from "@multica/core/auth"` (use singleton `useAuthStore`)
|
||||
- `from "@/platform/workspace"` → `from "@multica/core/workspace"` (use singleton `useWorkspaceStore`)
|
||||
|
||||
These singletons already exist and are registered by CoreProvider on init. Every component can import them directly from core.
|
||||
|
||||
**Step 4: Delete all platform files except navigation**
|
||||
|
||||
Web — delete entire `apps/web/platform/` except `navigation.tsx`. Flatten:
|
||||
```
|
||||
apps/web/platform/navigation.tsx → keep (only file left)
|
||||
```
|
||||
|
||||
Desktop — delete entire `apps/desktop/.../platform/` except `navigation.tsx`. Flatten:
|
||||
```
|
||||
apps/desktop/.../platform/navigation.tsx → keep (only file left)
|
||||
```
|
||||
|
||||
**Step 5: Run typecheck + tests**
|
||||
|
||||
Run: `pnpm typecheck && pnpm test`
|
||||
Expected: PASS
|
||||
|
||||
**Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git commit -m "refactor: migrate both apps to CoreProvider — delete all platform duplication"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Sidebar & Layout
|
||||
|
||||
### Task 3: Extract `AppSidebar` to `@multica/views/layout`
|
||||
|
||||
**Why:** Web and Desktop sidebars are 99% identical (239 vs 236 lines). Only difference: `Link`/`usePathname`/`useRouter` (web) vs `AppLink`/`useNavigation` (desktop). Since `useNavigation` + `AppLink` is the abstraction in views, the desktop version is already the correct shared version.
|
||||
|
||||
**Files:**
|
||||
- Create: `packages/views/layout/app-sidebar.tsx` — copy from desktop version
|
||||
- Create: `packages/views/layout/index.ts`
|
||||
- Modify: `packages/views/package.json` (add `"./layout"` export)
|
||||
- Modify: `apps/web/app/(dashboard)/layout.tsx` — import from views
|
||||
- Modify: `apps/desktop/src/renderer/src/components/dashboard-shell.tsx` — import from views
|
||||
- Delete: `apps/web/app/(dashboard)/_components/app-sidebar.tsx`
|
||||
- Delete: `apps/desktop/src/renderer/src/components/app-sidebar.tsx`
|
||||
|
||||
**Step 1: Create shared AppSidebar**
|
||||
|
||||
Copy desktop `app-sidebar.tsx` into `packages/views/layout/app-sidebar.tsx`. Key changes:
|
||||
- `import { useAuthStore } from "@multica/core/auth"` (singleton)
|
||||
- `import { useWorkspaceStore } from "@multica/core/workspace"` (singleton)
|
||||
- `import { api } from "@multica/core/api"` (singleton proxy)
|
||||
- `import { useNavigation, AppLink } from "../navigation"` (relative within views)
|
||||
- `import { useModalStore } from "@multica/core/modals"`
|
||||
- All `@multica/ui` imports unchanged
|
||||
|
||||
**Step 2: Barrel export + package.json**
|
||||
|
||||
```typescript
|
||||
// packages/views/layout/index.ts
|
||||
export { AppSidebar } from "./app-sidebar";
|
||||
```
|
||||
|
||||
Add to `packages/views/package.json`:
|
||||
```json
|
||||
"./layout": "./layout/index.ts"
|
||||
```
|
||||
|
||||
**Step 3: Update both apps, delete old files**
|
||||
|
||||
**Step 4: Run typecheck**
|
||||
|
||||
Run: `pnpm typecheck`
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git commit -m "refactor(views): extract shared AppSidebar to @multica/views/layout"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Extract `DashboardLayout` to `@multica/views/layout`
|
||||
|
||||
**Why:** Both apps have identical dashboard shell: auth guard → loading → sidebar + workspace provider + content. Only differences: web has `SearchCommand`, desktop has `TitleBar`. These are slots.
|
||||
|
||||
**Files:**
|
||||
- Create: `packages/views/layout/dashboard-layout.tsx`
|
||||
- Modify: `packages/views/layout/index.ts` (add export)
|
||||
- Modify: `apps/web/app/(dashboard)/layout.tsx` (~10 lines after)
|
||||
- Modify: `apps/desktop/src/renderer/src/components/dashboard-shell.tsx` (~10 lines after)
|
||||
|
||||
**Step 1: Create shared DashboardLayout**
|
||||
|
||||
```typescript
|
||||
// packages/views/layout/dashboard-layout.tsx
|
||||
"use client";
|
||||
|
||||
import { useEffect, type ReactNode } from "react";
|
||||
import { useNavigationStore } from "@multica/core/navigation";
|
||||
import { SidebarProvider, SidebarInset } from "@multica/ui/components/ui/sidebar";
|
||||
import { WorkspaceIdProvider } from "@multica/core/hooks";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
import { useWorkspaceStore } from "@multica/core/workspace";
|
||||
import { ModalRegistry } from "../modals/registry";
|
||||
import { useNavigation } from "../navigation";
|
||||
import { AppSidebar } from "./app-sidebar";
|
||||
|
||||
interface DashboardLayoutProps {
|
||||
children: ReactNode;
|
||||
/** Above sidebar (e.g. desktop TitleBar) */
|
||||
header?: ReactNode;
|
||||
/** Sibling of SidebarInset (e.g. web SearchCommand) */
|
||||
extra?: ReactNode;
|
||||
/** Loading indicator */
|
||||
loadingIndicator?: ReactNode;
|
||||
/** Redirect path when not authenticated. Default: "/" */
|
||||
loginPath?: string;
|
||||
}
|
||||
|
||||
export function DashboardLayout({
|
||||
children, header, extra, loadingIndicator, loginPath = "/",
|
||||
}: DashboardLayoutProps) {
|
||||
const { pathname, push } = useNavigation();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const isLoading = useAuthStore((s) => s.isLoading);
|
||||
const workspace = useWorkspaceStore((s) => s.workspace);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && !user) push(loginPath);
|
||||
}, [user, isLoading, push, loginPath]);
|
||||
|
||||
useEffect(() => {
|
||||
useNavigationStore.getState().onPathChange(pathname);
|
||||
}, [pathname]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-screen flex-col">
|
||||
{header}
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
{loadingIndicator}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
return (
|
||||
<div className="flex h-screen flex-col">
|
||||
{header}
|
||||
<div className="flex flex-1 min-h-0">
|
||||
<SidebarProvider className="flex-1">
|
||||
<AppSidebar />
|
||||
<SidebarInset className="overflow-hidden">
|
||||
{workspace ? (
|
||||
<WorkspaceIdProvider wsId={workspace.id}>
|
||||
{children}
|
||||
<ModalRegistry />
|
||||
</WorkspaceIdProvider>
|
||||
) : (
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
{loadingIndicator}
|
||||
</div>
|
||||
)}
|
||||
</SidebarInset>
|
||||
{extra}
|
||||
</SidebarProvider>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Slim down web layout**
|
||||
|
||||
```typescript
|
||||
// apps/web/app/(dashboard)/layout.tsx
|
||||
"use client";
|
||||
import { DashboardLayout } from "@multica/views/layout";
|
||||
import { MulticaIcon } from "@/components/multica-icon";
|
||||
import { SearchCommand } from "@/features/search";
|
||||
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<DashboardLayout
|
||||
loadingIndicator={<MulticaIcon className="size-6" />}
|
||||
extra={<SearchCommand />}
|
||||
>
|
||||
{children}
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Step 3: Slim down desktop shell**
|
||||
|
||||
```typescript
|
||||
// apps/desktop/src/renderer/src/components/dashboard-shell.tsx
|
||||
import { Outlet } from "react-router-dom";
|
||||
import { DesktopNavigationProvider } from "@/platform/navigation";
|
||||
import { DashboardLayout } from "@multica/views/layout";
|
||||
import { TitleBar } from "./title-bar";
|
||||
import { MulticaIcon } from "./multica-icon";
|
||||
|
||||
export function DashboardShell() {
|
||||
return (
|
||||
<DesktopNavigationProvider>
|
||||
<DashboardLayout
|
||||
header={<TitleBar />}
|
||||
loginPath="/login"
|
||||
loadingIndicator={<MulticaIcon className="size-6" />}
|
||||
>
|
||||
<Outlet />
|
||||
</DashboardLayout>
|
||||
</DesktopNavigationProvider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Step 4: Run typecheck**
|
||||
|
||||
Run: `pnpm typecheck`
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git commit -m "refactor(views): extract shared DashboardLayout to @multica/views/layout"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Build + smoke test
|
||||
|
||||
Run: `pnpm build && make check`
|
||||
|
||||
Fix any issues, commit:
|
||||
```bash
|
||||
git commit -m "fix: fixups from layout extraction"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Shared Login Page
|
||||
|
||||
### Task 6: Extract `LoginPage` to `@multica/views/auth`
|
||||
|
||||
**Why:** Desktop login (139 lines) is a simple email/code form. Web login (393 lines) has extra: CLI callback, Google OAuth, OTP component. Strategy: extract the core email/code form to views. Desktop uses it directly. Web keeps its own richer version (too different to merge).
|
||||
|
||||
**Files:**
|
||||
- Create: `packages/views/auth/login-page.tsx`
|
||||
- Create: `packages/views/auth/index.ts`
|
||||
- Modify: `packages/views/package.json` (add `"./auth"` export)
|
||||
- Modify: `apps/desktop/src/renderer/src/pages/login.tsx` (~10 lines after)
|
||||
|
||||
**Step 1: Create shared LoginPage**
|
||||
|
||||
Props: `logo?: ReactNode`, `onSuccess: () => void`. Internally uses `useAuthStore`/`useWorkspaceStore`/`api` from core singletons.
|
||||
|
||||
**Step 2: Update desktop login**
|
||||
|
||||
```typescript
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { LoginPage } from "@multica/views/auth";
|
||||
import { MulticaIcon } from "../components/multica-icon";
|
||||
import { TitleBar } from "../components/title-bar";
|
||||
|
||||
export function DesktopLoginPage() {
|
||||
const navigate = useNavigate();
|
||||
return (
|
||||
<div className="flex h-screen flex-col">
|
||||
<TitleBar />
|
||||
<LoginPage
|
||||
logo={<MulticaIcon bordered size="lg" />}
|
||||
onSuccess={() => navigate("/issues", { replace: true })}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Web login stays as-is (CLI callback + Google OAuth = web-only features).
|
||||
|
||||
**Step 3: Run typecheck**
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git commit -m "feat(views): extract shared LoginPage to @multica/views/auth"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Verify login flow in both apps
|
||||
|
||||
Run: `pnpm typecheck && pnpm test`
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Extract Agents Page (1,279 lines → shared module)
|
||||
|
||||
### Task 8: Create `@multica/views/agents`
|
||||
|
||||
**Files:**
|
||||
- Create: `packages/views/agents/config.ts` — statusConfig, taskStatusConfig
|
||||
- Create: `packages/views/agents/components/agents-page.tsx` — main page
|
||||
- Create: `packages/views/agents/components/create-agent-dialog.tsx`
|
||||
- Create: `packages/views/agents/components/agent-list-item.tsx`
|
||||
- Create: `packages/views/agents/components/agent-detail.tsx`
|
||||
- Create: `packages/views/agents/components/tabs/instructions-tab.tsx`
|
||||
- Create: `packages/views/agents/components/tabs/skills-tab.tsx`
|
||||
- Create: `packages/views/agents/components/tabs/tasks-tab.tsx`
|
||||
- Create: `packages/views/agents/components/tabs/settings-tab.tsx`
|
||||
- Create: `packages/views/agents/components/index.ts`
|
||||
- Create: `packages/views/agents/index.ts`
|
||||
- Modify: `packages/views/package.json` (add `"./agents"` export)
|
||||
|
||||
**Key migration:** All `@/platform/*` imports → `@multica/core/*` singletons. All `@multica/ui` and `@multica/core` imports stay as-is. `@multica/views` imports become relative.
|
||||
|
||||
**Step 1:** Extract config → components → barrel
|
||||
**Step 2:** Run `pnpm typecheck`
|
||||
**Step 3:** Commit
|
||||
|
||||
```bash
|
||||
git commit -m "feat(views): extract agents page to @multica/views/agents"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 9: Wire web agents route
|
||||
|
||||
```typescript
|
||||
// apps/web/app/(dashboard)/agents/page.tsx — 1 line replaces 1,279
|
||||
export { AgentsPage as default } from "@multica/views/agents";
|
||||
```
|
||||
|
||||
Commit: `refactor(web): replace agents page with @multica/views/agents import`
|
||||
|
||||
---
|
||||
|
||||
### Task 10: Wire desktop agents route
|
||||
|
||||
```typescript
|
||||
// router.tsx
|
||||
import { AgentsPage } from "@multica/views/agents";
|
||||
{ path: "agents", element: <AgentsPage /> },
|
||||
```
|
||||
|
||||
Commit: `feat(desktop): wire agents page from @multica/views`
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Extract Inbox Page (468 lines → shared module)
|
||||
|
||||
### Task 11: Create `@multica/views/inbox`
|
||||
|
||||
**Files:**
|
||||
- Create: `packages/views/inbox/components/inbox-page.tsx`
|
||||
- Create: `packages/views/inbox/components/inbox-list-item.tsx`
|
||||
- Create: `packages/views/inbox/components/inbox-detail-label.tsx`
|
||||
- Create: `packages/views/inbox/components/index.ts`
|
||||
- Create: `packages/views/inbox/index.ts`
|
||||
- Modify: `packages/views/package.json` (add `"./inbox"` export)
|
||||
|
||||
**Key migration:**
|
||||
- `import { useSearchParams } from "next/navigation"` → `import { useNavigation } from "../navigation"` — use `searchParams` from adapter
|
||||
- `window.history.replaceState(null, "", url)` → `replace(url)` from `useNavigation()`
|
||||
- `@/platform/*` → `@multica/core/*` singletons
|
||||
|
||||
Commit: `feat(views): extract inbox page to @multica/views/inbox`
|
||||
|
||||
---
|
||||
|
||||
### Task 12: Wire web inbox route
|
||||
|
||||
```typescript
|
||||
// apps/web/app/(dashboard)/inbox/page.tsx — 1 line replaces 468
|
||||
export { InboxPage as default } from "@multica/views/inbox";
|
||||
```
|
||||
|
||||
Commit: `refactor(web): replace inbox page with @multica/views/inbox import`
|
||||
|
||||
---
|
||||
|
||||
### Task 13: Wire desktop inbox route
|
||||
|
||||
```typescript
|
||||
import { InboxPage } from "@multica/views/inbox";
|
||||
{ path: "inbox", element: <InboxPage /> },
|
||||
```
|
||||
|
||||
Commit: `feat(desktop): wire inbox page from @multica/views`
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Extract Settings Page (1,277 lines → shared module)
|
||||
|
||||
### Task 14: Create `@multica/views/settings`
|
||||
|
||||
**Files:**
|
||||
- Create: `packages/views/settings/components/settings-page.tsx`
|
||||
- Create: `packages/views/settings/components/account-tab.tsx`
|
||||
- Create: `packages/views/settings/components/appearance-tab.tsx`
|
||||
- Create: `packages/views/settings/components/tokens-tab.tsx`
|
||||
- Create: `packages/views/settings/components/workspace-tab.tsx`
|
||||
- Create: `packages/views/settings/components/members-tab.tsx`
|
||||
- Create: `packages/views/settings/components/repositories-tab.tsx`
|
||||
- Create: `packages/views/settings/components/index.ts`
|
||||
- Create: `packages/views/settings/index.ts`
|
||||
- Modify: `packages/views/package.json` (add `"./settings"` export)
|
||||
|
||||
**Key migration:** Same pattern — `@/platform/*` → `@multica/core/*` singletons.
|
||||
|
||||
Commit: `feat(views): extract settings page to @multica/views/settings`
|
||||
|
||||
---
|
||||
|
||||
### Task 15: Wire web settings route
|
||||
|
||||
```typescript
|
||||
// apps/web/app/(dashboard)/settings/page.tsx — 1 line replaces 1,277 (page + 6 tabs)
|
||||
export { SettingsPage as default } from "@multica/views/settings";
|
||||
```
|
||||
|
||||
Delete `apps/web/app/(dashboard)/settings/_components/` (all 6 files).
|
||||
|
||||
Commit: `refactor(web): replace settings page with @multica/views/settings import`
|
||||
|
||||
---
|
||||
|
||||
### Task 16: Wire desktop settings route
|
||||
|
||||
```typescript
|
||||
import { SettingsPage } from "@multica/views/settings";
|
||||
{ path: "settings", element: <SettingsPage /> },
|
||||
```
|
||||
|
||||
Commit: `feat(desktop): wire settings page from @multica/views`
|
||||
|
||||
---
|
||||
|
||||
## Phase 7: Cleanup
|
||||
|
||||
### Task 17: Delete dead code
|
||||
|
||||
- Delete `apps/desktop/src/renderer/src/pages/placeholder.tsx`
|
||||
- Delete `apps/web/platform/` directory entirely (only `navigation.tsx` remains — move to `apps/web/app/` or `apps/web/lib/`)
|
||||
- Delete `apps/desktop/src/renderer/src/platform/` directory (only `navigation.tsx` remains — move)
|
||||
- Remove unused imports across both apps
|
||||
- Clean up `apps/web/features/auth/` — only `auth-cookie.ts` should remain
|
||||
|
||||
Commit: `chore: delete dead platform code after monorepo extraction`
|
||||
|
||||
---
|
||||
|
||||
### Task 18: Full verification
|
||||
|
||||
Run: `make check`
|
||||
Expected: ALL PASS
|
||||
|
||||
---
|
||||
|
||||
## Final Architecture
|
||||
|
||||
### Each app after extraction
|
||||
|
||||
```
|
||||
apps/web/
|
||||
├── app/
|
||||
│ ├── layout.tsx # CoreProvider + WebNavigationProvider + ThemeProvider
|
||||
│ ├── (auth)/login/page.tsx # Web 独有:CLI callback, Google OAuth
|
||||
│ ├── (dashboard)/
|
||||
│ │ ├── layout.tsx # DashboardLayout + SearchCommand (10 行)
|
||||
│ │ ├── issues/page.tsx # 1 行 re-export
|
||||
│ │ ├── agents/page.tsx # 1 行 re-export
|
||||
│ │ ├── inbox/page.tsx # 1 行 re-export
|
||||
│ │ ├── settings/page.tsx # 1 行 re-export
|
||||
│ │ └── ... (all 1-line)
|
||||
│ └── (landing)/ # Web 独有
|
||||
├── lib/
|
||||
│ └── navigation.tsx # WebNavigationProvider(唯一平台代码)
|
||||
├── features/
|
||||
│ ├── auth/auth-cookie.ts # Web 独有
|
||||
│ ├── landing/ # Web 独有
|
||||
│ └── search/ # Web 独有
|
||||
└── components/ # theme, icon, loading (少量)
|
||||
|
||||
apps/desktop/
|
||||
├── src/main/ # Electron 主进程
|
||||
├── src/preload/ # preload bridge
|
||||
├── src/renderer/src/
|
||||
│ ├── App.tsx # CoreProvider + RouterProvider + ThemeProvider
|
||||
│ ├── router.tsx # 路由表(全部 @multica/views/*)
|
||||
│ ├── lib/
|
||||
│ │ └── navigation.tsx # DesktopNavigationProvider(唯一平台代码)
|
||||
│ ├── components/
|
||||
│ │ ├── dashboard-shell.tsx # DashboardLayout + TitleBar (10 行)
|
||||
│ │ ├── title-bar.tsx # Desktop 独有
|
||||
│ │ └── multica-icon.tsx # Desktop 独有
|
||||
│ └── pages/
|
||||
│ └── login.tsx # LoginPage + TitleBar (10 行)
|
||||
```
|
||||
|
||||
### 数字对比
|
||||
|
||||
| 指标 | 之前 | 之后 |
|
||||
|------|------|------|
|
||||
| Web platform 文件 | 6 个 | 1 个 (navigation.tsx) |
|
||||
| Desktop platform 文件 | 7 个 | 1 个 (navigation.tsx) |
|
||||
| Web agents/page.tsx | 1,279 行 | 1 行 |
|
||||
| Web inbox/page.tsx | 468 行 | 1 行 |
|
||||
| Web settings/ 总计 | 1,277 行 | 1 行 |
|
||||
| Web sidebar | 239 行 | 0 (共享) |
|
||||
| Desktop sidebar | 236 行 (重复) | 0 (共享) |
|
||||
| Desktop placeholders | 3 个 | 0 |
|
||||
| 共享 views 模块 | 7 个 | 12 个 |
|
||||
| 两端重复代码 | ~1,500 行 | 0 行 |
|
||||
@@ -5,16 +5,19 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev:web": "turbo dev --filter=@multica/web",
|
||||
"dev:desktop": "turbo dev --filter=@multica/desktop",
|
||||
"build": "turbo build",
|
||||
"typecheck": "turbo typecheck",
|
||||
"test": "turbo test",
|
||||
"lint": "turbo lint",
|
||||
"clean": "turbo clean && rm -rf node_modules"
|
||||
"clean": "turbo clean && rm -rf node_modules",
|
||||
"ui:add": "cd packages/ui && npx shadcn@latest add"
|
||||
},
|
||||
"packageManager": "pnpm@10.28.2",
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
"esbuild"
|
||||
"esbuild",
|
||||
"electron"
|
||||
],
|
||||
"overrides": {
|
||||
"@types/react": "catalog:",
|
||||
|
||||
@@ -30,6 +30,7 @@ import type {
|
||||
CreatePersonalAccessTokenRequest,
|
||||
CreatePersonalAccessTokenResponse,
|
||||
RuntimeUsage,
|
||||
IssueUsageSummary,
|
||||
RuntimeHourlyActivity,
|
||||
RuntimePing,
|
||||
RuntimeUpdate,
|
||||
@@ -418,6 +419,10 @@ export class ApiClient {
|
||||
return this.fetch(`/api/issues/${issueId}/task-runs`);
|
||||
}
|
||||
|
||||
async getIssueUsage(issueId: string): Promise<IssueUsageSummary> {
|
||||
return this.fetch(`/api/issues/${issueId}/usage`);
|
||||
}
|
||||
|
||||
async cancelTask(issueId: string, taskId: string): Promise<AgentTask> {
|
||||
return this.fetch(`/api/issues/${issueId}/tasks/${taskId}/cancel`, {
|
||||
method: "POST",
|
||||
@@ -608,8 +613,9 @@ export class ApiClient {
|
||||
}
|
||||
|
||||
// Chat Sessions
|
||||
async listChatSessions(): Promise<ChatSession[]> {
|
||||
return this.fetch("/api/chat/sessions");
|
||||
async listChatSessions(params?: { status?: string }): Promise<ChatSession[]> {
|
||||
const query = params?.status ? `?status=${params.status}` : "";
|
||||
return this.fetch(`/api/chat/sessions${query}`);
|
||||
}
|
||||
|
||||
async getChatSession(id: string): Promise<ChatSession> {
|
||||
|
||||
38
packages/core/chat/index.ts
Normal file
38
packages/core/chat/index.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
export { createChatStore } from "./store";
|
||||
export type { ChatStoreOptions, ChatState, ChatTimelineItem } from "./store";
|
||||
|
||||
import type { createChatStore as CreateChatStoreFn } from "./store";
|
||||
|
||||
type ChatStoreInstance = ReturnType<typeof CreateChatStoreFn>;
|
||||
|
||||
/** Module-level singleton — set once at app boot via `registerChatStore()`. */
|
||||
let _store: ChatStoreInstance | null = null;
|
||||
|
||||
/**
|
||||
* Register the chat store instance created by the app.
|
||||
* Must be called at boot before any component renders.
|
||||
*/
|
||||
export function registerChatStore(store: ChatStoreInstance) {
|
||||
_store = store;
|
||||
}
|
||||
|
||||
/**
|
||||
* Singleton accessor — a Zustand hook backed by the registered instance.
|
||||
* Supports `useChatStore(selector)` and `useChatStore.getState()`.
|
||||
*/
|
||||
export const useChatStore: ChatStoreInstance = new Proxy(
|
||||
(() => {}) as unknown as ChatStoreInstance,
|
||||
{
|
||||
apply(_target, _thisArg, args) {
|
||||
if (!_store)
|
||||
throw new Error(
|
||||
"Chat store not initialised — call registerChatStore() first",
|
||||
);
|
||||
return (_store as unknown as (...a: unknown[]) => unknown)(...args);
|
||||
},
|
||||
get(_target, prop) {
|
||||
if (!_store) return undefined;
|
||||
return Reflect.get(_store, prop);
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { api } from "@/platform/api";
|
||||
import { useWorkspaceId } from "@multica/core/hooks";
|
||||
import { api } from "../api";
|
||||
import { useWorkspaceId } from "../hooks";
|
||||
import { chatKeys } from "./queries";
|
||||
|
||||
export function useCreateChatSession() {
|
||||
@@ -12,6 +12,7 @@ export function useCreateChatSession() {
|
||||
api.createChatSession(data),
|
||||
onSettled: () => {
|
||||
qc.invalidateQueries({ queryKey: chatKeys.sessions(wsId) });
|
||||
qc.invalidateQueries({ queryKey: chatKeys.allSessions(wsId) });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -24,6 +25,7 @@ export function useArchiveChatSession() {
|
||||
mutationFn: (sessionId: string) => api.archiveChatSession(sessionId),
|
||||
onSettled: () => {
|
||||
qc.invalidateQueries({ queryKey: chatKeys.sessions(wsId) });
|
||||
qc.invalidateQueries({ queryKey: chatKeys.allSessions(wsId) });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,9 +1,17 @@
|
||||
import { queryOptions } from "@tanstack/react-query";
|
||||
import { api } from "@/platform/api";
|
||||
import { api } from "../api";
|
||||
|
||||
// NOTE on workspace scoping:
|
||||
// `wsId` is used only as part of queryKey for cache isolation per workspace.
|
||||
// The actual workspace context comes from ApiClient's X-Workspace-ID header,
|
||||
// which is set by useWorkspaceStore.switchWorkspace(). Callers must ensure the
|
||||
// header is in sync with the wsId they pass here — otherwise cache writes will
|
||||
// be misattributed during a workspace switch race window.
|
||||
|
||||
export const chatKeys = {
|
||||
all: (wsId: string) => ["chat", wsId] as const,
|
||||
sessions: (wsId: string) => [...chatKeys.all(wsId), "sessions"] as const,
|
||||
allSessions: (wsId: string) => [...chatKeys.all(wsId), "sessions", "all"] as const,
|
||||
session: (wsId: string, id: string) => [...chatKeys.all(wsId), "session", id] as const,
|
||||
messages: (sessionId: string) => ["chat", "messages", sessionId] as const,
|
||||
};
|
||||
@@ -16,6 +24,14 @@ export function chatSessionsOptions(wsId: string) {
|
||||
});
|
||||
}
|
||||
|
||||
export function allChatSessionsOptions(wsId: string) {
|
||||
return queryOptions({
|
||||
queryKey: chatKeys.allSessions(wsId),
|
||||
queryFn: () => api.listChatSessions({ status: "all" }),
|
||||
staleTime: Infinity,
|
||||
});
|
||||
}
|
||||
|
||||
export function chatSessionOptions(wsId: string, id: string) {
|
||||
return queryOptions({
|
||||
queryKey: chatKeys.session(wsId, id),
|
||||
83
packages/core/chat/store.ts
Normal file
83
packages/core/chat/store.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { create } from "zustand";
|
||||
import type { StorageAdapter } from "../types";
|
||||
|
||||
const AGENT_STORAGE_KEY = "multica:chat:selectedAgentId";
|
||||
const SESSION_STORAGE_KEY = "multica:chat:activeSessionId";
|
||||
|
||||
export interface ChatTimelineItem {
|
||||
seq: number;
|
||||
type: "tool_use" | "tool_result" | "thinking" | "text" | "error";
|
||||
tool?: string;
|
||||
content?: string;
|
||||
input?: Record<string, unknown>;
|
||||
output?: string;
|
||||
}
|
||||
|
||||
export interface ChatState {
|
||||
isOpen: boolean;
|
||||
isFullscreen: boolean;
|
||||
activeSessionId: string | null;
|
||||
pendingTaskId: string | null;
|
||||
selectedAgentId: string | null;
|
||||
showHistory: boolean;
|
||||
timelineItems: ChatTimelineItem[];
|
||||
setOpen: (open: boolean) => void;
|
||||
toggle: () => void;
|
||||
toggleFullscreen: () => void;
|
||||
setActiveSession: (id: string | null) => void;
|
||||
setPendingTask: (taskId: string | null) => void;
|
||||
setSelectedAgentId: (id: string) => void;
|
||||
setShowHistory: (show: boolean) => void;
|
||||
addTimelineItem: (item: ChatTimelineItem) => void;
|
||||
clearTimeline: () => void;
|
||||
}
|
||||
|
||||
export interface ChatStoreOptions {
|
||||
storage: StorageAdapter;
|
||||
}
|
||||
|
||||
export function createChatStore(options: ChatStoreOptions) {
|
||||
const { storage } = options;
|
||||
|
||||
return create<ChatState>((set) => ({
|
||||
isOpen: false,
|
||||
isFullscreen: false,
|
||||
activeSessionId: storage.getItem(SESSION_STORAGE_KEY),
|
||||
pendingTaskId: null,
|
||||
selectedAgentId: storage.getItem(AGENT_STORAGE_KEY),
|
||||
showHistory: false,
|
||||
timelineItems: [],
|
||||
setOpen: (open) =>
|
||||
set({ isOpen: open, ...(open ? {} : { isFullscreen: false }) }),
|
||||
toggle: () =>
|
||||
set((s) => ({
|
||||
isOpen: !s.isOpen,
|
||||
...(s.isOpen ? { isFullscreen: false } : {}),
|
||||
})),
|
||||
toggleFullscreen: () => set((s) => ({ isFullscreen: !s.isFullscreen })),
|
||||
setActiveSession: (id) => {
|
||||
if (id) {
|
||||
storage.setItem(SESSION_STORAGE_KEY, id);
|
||||
} else {
|
||||
storage.removeItem(SESSION_STORAGE_KEY);
|
||||
}
|
||||
set({ activeSessionId: id });
|
||||
},
|
||||
setPendingTask: (taskId) => set({ pendingTaskId: taskId, timelineItems: [] }),
|
||||
setSelectedAgentId: (id) => {
|
||||
storage.setItem(AGENT_STORAGE_KEY, id);
|
||||
set({ selectedAgentId: id });
|
||||
},
|
||||
setShowHistory: (show) => set({ showHistory: show }),
|
||||
addTimelineItem: (item) =>
|
||||
set((s) => {
|
||||
if (s.timelineItems.some((t) => t.seq === item.seq)) return s;
|
||||
return {
|
||||
timelineItems: [...s.timelineItems, item].sort(
|
||||
(a, b) => a.seq - b.seq,
|
||||
),
|
||||
};
|
||||
}),
|
||||
clearTimeline: () => set({ timelineItems: [] }),
|
||||
}));
|
||||
}
|
||||
3
packages/core/eslint.config.mjs
Normal file
3
packages/core/eslint.config.mjs
Normal file
@@ -0,0 +1,3 @@
|
||||
import reactConfig from "@multica/eslint-config/react";
|
||||
|
||||
export default [...reactConfig];
|
||||
@@ -12,6 +12,7 @@ export const issueKeys = {
|
||||
reactions: (issueId: string) => ["issues", "reactions", issueId] as const,
|
||||
subscribers: (issueId: string) =>
|
||||
["issues", "subscribers", issueId] as const,
|
||||
usage: (issueId: string) => ["issues", "usage", issueId] as const,
|
||||
};
|
||||
|
||||
export const CLOSED_PAGE_SIZE = 50;
|
||||
@@ -79,3 +80,10 @@ export function issueSubscribersOptions(issueId: string) {
|
||||
queryFn: () => api.listIssueSubscribers(issueId),
|
||||
});
|
||||
}
|
||||
|
||||
export function issueUsageOptions(issueId: string) {
|
||||
return queryOptions({
|
||||
queryKey: issueKeys.usage(issueId),
|
||||
queryFn: () => api.getIssueUsage(issueId),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -6,6 +6,11 @@ export {
|
||||
useViewStoreApi,
|
||||
} from "./view-store-context";
|
||||
export { useIssuesScopeStore, type IssuesScope } from "./issues-scope-store";
|
||||
export {
|
||||
myIssuesViewStore,
|
||||
type MyIssuesViewState,
|
||||
type MyIssuesScope,
|
||||
} from "./my-issues-view-store";
|
||||
export {
|
||||
useIssueViewStore,
|
||||
createIssueViewStore,
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
type IssueViewState,
|
||||
viewStoreSlice,
|
||||
viewStorePersistOptions,
|
||||
} from "@multica/core/issues/stores/view-store";
|
||||
} from "./view-store";
|
||||
|
||||
export type MyIssuesScope = "assigned" | "created" | "agents";
|
||||
|
||||
@@ -3,6 +3,11 @@
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "eslint .",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"exports": {
|
||||
".": "./index.ts",
|
||||
"./types": "./types/index.ts",
|
||||
@@ -29,6 +34,9 @@
|
||||
"./inbox/queries": "./inbox/queries.ts",
|
||||
"./inbox/mutations": "./inbox/mutations.ts",
|
||||
"./inbox/ws-updaters": "./inbox/ws-updaters.ts",
|
||||
"./chat": "./chat/index.ts",
|
||||
"./chat/queries": "./chat/queries.ts",
|
||||
"./chat/mutations": "./chat/mutations.ts",
|
||||
"./runtimes": "./runtimes/index.ts",
|
||||
"./runtimes/queries": "./runtimes/queries.ts",
|
||||
"./runtimes/mutations": "./runtimes/mutations.ts",
|
||||
@@ -46,7 +54,8 @@
|
||||
"./provider": "./provider.tsx",
|
||||
"./logger": "./logger.ts",
|
||||
"./utils": "./utils.ts",
|
||||
"./constants/*": "./constants/*.ts"
|
||||
"./constants/*": "./constants/*.ts",
|
||||
"./platform": "./platform/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "catalog:",
|
||||
@@ -59,6 +68,7 @@
|
||||
"devDependencies": {
|
||||
"@multica/tsconfig": "workspace:*",
|
||||
"@types/react": "catalog:",
|
||||
"typescript": "catalog:"
|
||||
"typescript": "catalog:",
|
||||
"vitest": "catalog:"
|
||||
}
|
||||
}
|
||||
|
||||
54
packages/core/platform/auth-initializer.tsx
Normal file
54
packages/core/platform/auth-initializer.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, type ReactNode } from "react";
|
||||
import { getApi } from "../api";
|
||||
import { useAuthStore } from "../auth";
|
||||
import { useWorkspaceStore } from "../workspace";
|
||||
import { createLogger } from "../logger";
|
||||
import { defaultStorage } from "./storage";
|
||||
import type { StorageAdapter } from "../types/storage";
|
||||
|
||||
const logger = createLogger("auth");
|
||||
|
||||
export function AuthInitializer({
|
||||
children,
|
||||
onLogin,
|
||||
onLogout,
|
||||
storage = defaultStorage,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
onLogin?: () => void;
|
||||
onLogout?: () => void;
|
||||
storage?: StorageAdapter;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
const token = storage.getItem("multica_token");
|
||||
if (!token) {
|
||||
onLogout?.();
|
||||
useAuthStore.setState({ isLoading: false });
|
||||
return;
|
||||
}
|
||||
|
||||
const api = getApi();
|
||||
api.setToken(token);
|
||||
const wsId = storage.getItem("multica_workspace_id");
|
||||
|
||||
Promise.all([api.getMe(), api.listWorkspaces()])
|
||||
.then(([user, wsList]) => {
|
||||
onLogin?.();
|
||||
useAuthStore.setState({ user, isLoading: false });
|
||||
useWorkspaceStore.getState().hydrateWorkspace(wsList, wsId);
|
||||
})
|
||||
.catch((err) => {
|
||||
logger.error("auth init failed", err);
|
||||
api.setToken(null);
|
||||
api.setWorkspaceId(null);
|
||||
storage.removeItem("multica_token");
|
||||
storage.removeItem("multica_workspace_id");
|
||||
onLogout?.();
|
||||
useAuthStore.setState({ user: null, isLoading: false });
|
||||
});
|
||||
}, []);
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
85
packages/core/platform/core-provider.tsx
Normal file
85
packages/core/platform/core-provider.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { ApiClient } from "../api/client";
|
||||
import { setApiInstance } from "../api";
|
||||
import { createAuthStore, registerAuthStore } from "../auth";
|
||||
import { createWorkspaceStore, registerWorkspaceStore } from "../workspace";
|
||||
import { createChatStore, registerChatStore } from "../chat";
|
||||
import { WSProvider } from "../realtime";
|
||||
import { QueryProvider } from "../provider";
|
||||
import { createLogger } from "../logger";
|
||||
import { defaultStorage } from "./storage";
|
||||
import { AuthInitializer } from "./auth-initializer";
|
||||
import type { CoreProviderProps } from "./types";
|
||||
import type { StorageAdapter } from "../types/storage";
|
||||
|
||||
// Module-level singletons — created once at first render, never recreated.
|
||||
// Vite HMR preserves module-level state, so these survive hot reloads.
|
||||
let initialized = false;
|
||||
let authStore: ReturnType<typeof createAuthStore>;
|
||||
let workspaceStore: ReturnType<typeof createWorkspaceStore>;
|
||||
let chatStore: ReturnType<typeof createChatStore>;
|
||||
function initCore(
|
||||
apiBaseUrl: string,
|
||||
storage: StorageAdapter,
|
||||
onLogin?: () => void,
|
||||
onLogout?: () => void,
|
||||
) {
|
||||
if (initialized) return;
|
||||
|
||||
const api = new ApiClient(apiBaseUrl, {
|
||||
logger: createLogger("api"),
|
||||
onUnauthorized: () => {
|
||||
storage.removeItem("multica_token");
|
||||
storage.removeItem("multica_workspace_id");
|
||||
},
|
||||
});
|
||||
setApiInstance(api);
|
||||
|
||||
// Hydrate token from storage
|
||||
const token = storage.getItem("multica_token");
|
||||
if (token) api.setToken(token);
|
||||
const wsId = storage.getItem("multica_workspace_id");
|
||||
if (wsId) api.setWorkspaceId(wsId);
|
||||
|
||||
authStore = createAuthStore({ api, storage, onLogin, onLogout });
|
||||
registerAuthStore(authStore);
|
||||
|
||||
workspaceStore = createWorkspaceStore(api, { storage });
|
||||
registerWorkspaceStore(workspaceStore);
|
||||
|
||||
chatStore = createChatStore({ storage });
|
||||
registerChatStore(chatStore);
|
||||
|
||||
initialized = true;
|
||||
}
|
||||
|
||||
export function CoreProvider({
|
||||
children,
|
||||
apiBaseUrl = "",
|
||||
wsUrl = "ws://localhost:8080/ws",
|
||||
storage = defaultStorage,
|
||||
onLogin,
|
||||
onLogout,
|
||||
}: CoreProviderProps) {
|
||||
// Initialize singletons on first render only. Dependencies are read-once:
|
||||
// apiBaseUrl, storage, and callbacks are set at app boot and never change at runtime.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
useMemo(() => initCore(apiBaseUrl, storage, onLogin, onLogout), []);
|
||||
|
||||
return (
|
||||
<QueryProvider>
|
||||
<AuthInitializer onLogin={onLogin} onLogout={onLogout} storage={storage}>
|
||||
<WSProvider
|
||||
wsUrl={wsUrl}
|
||||
authStore={authStore}
|
||||
workspaceStore={workspaceStore}
|
||||
storage={storage}
|
||||
>
|
||||
{children}
|
||||
</WSProvider>
|
||||
</AuthInitializer>
|
||||
</QueryProvider>
|
||||
);
|
||||
}
|
||||
4
packages/core/platform/index.ts
Normal file
4
packages/core/platform/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { CoreProvider } from "./core-provider";
|
||||
export type { CoreProviderProps } from "./types";
|
||||
export { AuthInitializer } from "./auth-initializer";
|
||||
export { defaultStorage } from "./storage";
|
||||
@@ -1,10 +1,7 @@
|
||||
import type { StorageAdapter } from "@multica/core/types/storage";
|
||||
import type { StorageAdapter } from "../types/storage";
|
||||
|
||||
/**
|
||||
* SSR-safe localStorage wrapper.
|
||||
* Returns null / no-ops when running on the server (typeof window === "undefined").
|
||||
*/
|
||||
export const webStorage: StorageAdapter = {
|
||||
/** SSR-safe localStorage. Works in both Next.js (SSR) and Electron (always client). */
|
||||
export const defaultStorage: StorageAdapter = {
|
||||
getItem: (k) =>
|
||||
typeof window !== "undefined" ? localStorage.getItem(k) : null,
|
||||
setItem: (k, v) => {
|
||||
15
packages/core/platform/types.ts
Normal file
15
packages/core/platform/types.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import type { StorageAdapter } from "../types/storage";
|
||||
|
||||
export interface CoreProviderProps {
|
||||
children: React.ReactNode;
|
||||
/** API base URL. Default: "" (same-origin). */
|
||||
apiBaseUrl?: string;
|
||||
/** WebSocket URL. Default: "ws://localhost:8080/ws". */
|
||||
wsUrl?: string;
|
||||
/** Storage adapter. Default: SSR-safe localStorage wrapper. */
|
||||
storage?: StorageAdapter;
|
||||
/** Called after successful login (e.g. set cookie for Next.js middleware). */
|
||||
onLogin?: () => void;
|
||||
/** Called after logout (e.g. clear cookie). */
|
||||
onLogout?: () => void;
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import type { WorkspaceStore } from "../workspace/store";
|
||||
import { createLogger } from "../logger";
|
||||
import { issueKeys } from "../issues/queries";
|
||||
import { projectKeys } from "../projects/queries";
|
||||
import { runtimeKeys } from "../runtimes/queries";
|
||||
import {
|
||||
onIssueCreated,
|
||||
onIssueUpdated,
|
||||
@@ -54,7 +55,8 @@ export interface RealtimeSyncStores {
|
||||
*
|
||||
* Per-issue events (comments, activity, reactions, subscribers) are handled
|
||||
* both here (invalidation fallback) and by per-page useWSEvent hooks (granular
|
||||
* updates). Daemon events are handled by individual components only.
|
||||
* updates). Daemon register events invalidate runtimes globally; heartbeats
|
||||
* are skipped to avoid excessive refetches.
|
||||
*
|
||||
* @param ws - WebSocket client instance (null when not yet connected)
|
||||
* @param stores - Platform-created Zustand store instances for auth and workspace
|
||||
@@ -95,6 +97,10 @@ export function useRealtimeSync(
|
||||
const wsId = workspaceStore.getState().workspace?.id;
|
||||
if (wsId) qc.invalidateQueries({ queryKey: projectKeys.all(wsId) });
|
||||
},
|
||||
daemon: () => {
|
||||
const wsId = workspaceStore.getState().workspace?.id;
|
||||
if (wsId) qc.invalidateQueries({ queryKey: runtimeKeys.all(wsId) });
|
||||
},
|
||||
};
|
||||
|
||||
const timers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
@@ -118,6 +124,7 @@ export function useRealtimeSync(
|
||||
"reaction:added", "reaction:removed",
|
||||
"issue_reaction:added", "issue_reaction:removed",
|
||||
"subscriber:added", "subscriber:removed",
|
||||
"daemon:heartbeat",
|
||||
]);
|
||||
|
||||
const unsubAny = ws.onAny((msg) => {
|
||||
@@ -300,6 +307,7 @@ export function useRealtimeSync(
|
||||
qc.invalidateQueries({ queryKey: workspaceKeys.members(wsId) });
|
||||
qc.invalidateQueries({ queryKey: workspaceKeys.skills(wsId) });
|
||||
qc.invalidateQueries({ queryKey: projectKeys.all(wsId) });
|
||||
qc.invalidateQueries({ queryKey: runtimeKeys.all(wsId) });
|
||||
}
|
||||
qc.invalidateQueries({ queryKey: workspaceKeys.list() });
|
||||
} catch (e) {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useMemo } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAuthStore } from "../auth";
|
||||
import { useWorkspaceId } from "../hooks";
|
||||
import type { AgentRuntime } from "../types";
|
||||
import { runtimeListOptions, latestCliVersionOptions } from "./queries";
|
||||
|
||||
@@ -39,11 +38,14 @@ function runtimeNeedsUpdate(
|
||||
|
||||
/**
|
||||
* Returns true if the current user has any local runtime with an outdated CLI version.
|
||||
* Accepts wsId as parameter so callers outside WorkspaceIdProvider can use it safely.
|
||||
*/
|
||||
export function useMyRuntimesNeedUpdate(): boolean {
|
||||
const wsId = useWorkspaceId();
|
||||
export function useMyRuntimesNeedUpdate(wsId: string | undefined): boolean {
|
||||
const userId = useAuthStore((s) => s.user?.id);
|
||||
const { data: runtimes } = useQuery(runtimeListOptions(wsId));
|
||||
const { data: runtimes } = useQuery({
|
||||
...runtimeListOptions(wsId ?? ""),
|
||||
enabled: !!wsId,
|
||||
});
|
||||
const { data: latestVersion } = useQuery(latestCliVersionOptions());
|
||||
|
||||
if (!runtimes || !latestVersion || !userId) return false;
|
||||
@@ -53,11 +55,14 @@ export function useMyRuntimesNeedUpdate(): boolean {
|
||||
|
||||
/**
|
||||
* Returns a Set of runtime IDs that belong to the current user and have updates available.
|
||||
* Accepts wsId as parameter so callers outside WorkspaceIdProvider can use it safely.
|
||||
*/
|
||||
export function useUpdatableRuntimeIds(): Set<string> {
|
||||
const wsId = useWorkspaceId();
|
||||
export function useUpdatableRuntimeIds(wsId: string | undefined): Set<string> {
|
||||
const userId = useAuthStore((s) => s.user?.id);
|
||||
const { data: runtimes } = useQuery(runtimeListOptions(wsId));
|
||||
const { data: runtimes } = useQuery({
|
||||
...runtimeListOptions(wsId ?? ""),
|
||||
enabled: !!wsId,
|
||||
});
|
||||
const { data: latestVersion } = useQuery(latestCliVersionOptions());
|
||||
|
||||
return useMemo(() => {
|
||||
|
||||
@@ -138,6 +138,14 @@ export interface RuntimePing {
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface IssueUsageSummary {
|
||||
total_input_tokens: number;
|
||||
total_output_tokens: number;
|
||||
total_cache_read_tokens: number;
|
||||
total_cache_write_tokens: number;
|
||||
task_count: number;
|
||||
}
|
||||
|
||||
export interface RuntimeUsage {
|
||||
runtime_id: string;
|
||||
date: string;
|
||||
|
||||
@@ -20,6 +20,7 @@ export type {
|
||||
RuntimePingStatus,
|
||||
RuntimeUpdate,
|
||||
RuntimeUpdateStatus,
|
||||
IssueUsageSummary,
|
||||
} from "./agent";
|
||||
export type { Workspace, WorkspaceRepo, Member, MemberRole, User, MemberWithUser } from "./workspace";
|
||||
export type { InboxItem, InboxSeverity, InboxItemType } from "./inbox";
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user