Compare commits

..

1 Commits

Author SHA1 Message Date
Jiayuan
2b2e9828ea feat(project): add Project feature with full CRUD and issue association
- Add `project` table with status, color, lead, dates, and sort_order
- Add `project_id` column to `issue` table (nullable FK, ON DELETE SET NULL)
- Implement project CRUD API (POST/GET/PUT/DELETE /api/projects)
- Auto-calculate project progress from issue completion ratio
- Add project_id support to issue create, update, list, and batch operations
- Add WebSocket events (project:created/updated/deleted) with realtime sync
- Add frontend: project types, API client, Zustand store, realtime integration
- Add projects list page (/projects) with create dialog
- Add project detail page (/projects/[id]) with issue list
- Add project picker to issue detail properties sidebar
- Add "Projects" nav item to sidebar
2026-04-05 04:13:07 +08:00
527 changed files with 9764 additions and 35604 deletions

View File

@@ -29,7 +29,6 @@ RESEND_FROM_EMAIL=noreply@multica.ai
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
GOOGLE_REDIRECT_URI=http://localhost:3000/auth/callback
NEXT_PUBLIC_GOOGLE_CLIENT_ID=
# S3 / CloudFront
S3_BUCKET=

1
.eslintcache Normal file

File diff suppressed because one or more lines are too long

View File

@@ -1,34 +0,0 @@
## What
<!-- What does this PR do? Keep it to 1-3 sentences. -->
## Why
<!-- Why is this change needed? Link the related issue. -->
Closes #<!-- issue number -->
## Type of Change
- [ ] Bug fix
- [ ] New feature
- [ ] Refactor / code improvement
- [ ] Documentation
- [ ] CI / infrastructure
- [ ] Other (describe below)
## How to Test
<!-- How can a reviewer verify this works? Steps, commands, or screenshots. -->
## Checklist
- [ ] `make check` passes (typecheck, unit tests, Go tests, E2E)
- [ ] Changes follow existing code patterns and conventions
- [ ] No unrelated changes included
## AI Disclosure (optional)
<!-- If AI tools were used: -->
<!-- - Which tool? (e.g., Claude Code, Copilot, Cursor) -->
<!-- - What prompt did you use? Sharing your prompt helps others learn and lets reviewers understand intent. -->

258
CLAUDE.md
View File

@@ -12,188 +12,77 @@ Multica is an AI-native task management platform — like Linear, but with AI ag
## Architecture
**Go backend + monorepo frontend (pnpm workspaces + Turborepo) with shared packages.**
**Go backend + standalone Next.js frontend.**
- `server/` — Go backend (Chi router, sqlc for DB, gorilla/websocket for real-time)
- `apps/web/` — Next.js 16 frontend (App Router)
- `apps/desktop/` — Electron 39 desktop app (electron-vite + react-router-dom)
- `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, zero react-router imports)
- `packages/tsconfig/` — Shared TypeScript configuration
- `apps/web/` — Next.js 16 frontend (App Router) — self-contained, no shared package dependencies
### Monorepo Tooling
### Web App Structure (`apps/web/`)
- **pnpm workspaces** for dependency management. `pnpm-workspace.yaml` defines a `catalog:` for version pinning — all shared deps (React, Zustand, TanStack Query, Tailwind, TypeScript) use `catalog:` references to guarantee a single version across all packages.
- **Turborepo** for task orchestration — build, typecheck, test, lint all respect the package dependency graph.
- **Internal Packages pattern** — all shared packages export raw `.ts`/`.tsx` files (no pre-compilation). The consuming app's bundler (Vite for desktop, Next.js for web) compiles them directly. This gives zero-config HMR and instant go-to-definition. If a package is ever published to npm, add a build step then.
### Package Architecture
Three shared packages with single-direction dependencies:
```
packages/
├── core/ # @multica/core — types, API client, stores, queries, mutations, realtime, platform
├── ui/ # @multica/ui — 55 shadcn components, common components, markdown, hooks, styles
├── views/ # @multica/views — issue pages, editor, modals, skills, runtimes, navigation, layout, auth, settings
└── 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.
**Platform bridge:** `packages/core/platform/` provides `CoreProvider` — a single component that initializes API client, auth/workspace stores, WS connection, and QueryClient. Each app wraps its root with `<CoreProvider apiBaseUrl wsUrl>` and provides its own `NavigationAdapter` for routing.
```
apps/web: ThemeProvider > CoreProvider(onLogin=cookie, onLogout=cookie) > WebNavigationProvider > pages
apps/desktop: ThemeProvider > CoreProvider(apiBaseUrl, wsUrl) > RouterProvider > DesktopNavigationProvider > pages
```
### 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` |
| `core/platform/` | CoreProvider + auth init + default storage | `CoreProvider`, `AuthInitializer`, `defaultStorage` |
**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, multica-icon, theme-provider)
- `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 inline, :root, .dark variables)
- `styles/base.css` — Shared base layer (scrollbar, shiki themes, entrance-spin animation, sidebar active state, sonner alignment, body/html defaults)
### packages/views/ (`@multica/views`)
Shared business UI pages. **Zero `next/*` imports. Zero `react-router-dom` imports.** Uses `NavigationAdapter` for routing.
- `navigation/``NavigationAdapter` interface, `useNavigation()` hook, `AppLink` component
- `layout/``DashboardLayout`, `AppSidebar`, `useDashboardGuard`
- `auth/``LoginPage` (shared login with optional Google OAuth via props)
- `issues/components/` — IssuesPage, IssueDetail, BoardView, ListView, pickers, icons
- `editor/` — ContentEditor, TitleEditor, Tiptap extensions
- `modals/` — CreateIssueModal, CreateWorkspaceModal, ModalRegistry
- `my-issues/`, `skills/`, `runtimes/`, `agents/`, `inbox/`, `settings/` — domain pages
- `common/` — Data-aware wrappers (ActorAvatar with useActorName, Markdown with IssueMentionCard)
**NavigationAdapter:** Platform-agnostic routing interface. All shared components use `useNavigation()` and `<AppLink>` — never import from `next/navigation` or `react-router-dom` directly. Optional methods (`openInNewTab`, `getShareableUrl`) are provided by desktop only; shared code checks their existence before calling.
### apps/web/ (Next.js App)
Thin routing shells + platform-specific code.
The frontend uses a **feature-based architecture** with four layers:
```
apps/web/
├── app/ # Next.js route shells (< 15 lines each, import from @multica/views)
├── platform/ # Web platform bridge — only navigation.tsx remains
├── features/
│ ├── auth/ # Web-only: auth-cookie.ts (cookie for Next.js middleware)
│ ├── landing/ # Web-only: landing pages (uses next/image, next/link)
│ └── search/ # Web-only: search dialog
└── components/ # App-level: web-providers.tsx, locale-sync, loading-indicator
├── app/ # Routing layer (thin shells — import from features/)
├── features/ # Business logic, organized by domain
├── shared/ # Cross-feature utilities (api client, types, logger)
```
**`platform/navigation.tsx`** — `WebNavigationProvider` wrapping Next.js `useRouter`/`usePathname`. The only web-platform-specific file remaining — core initialization is handled by `CoreProvider` in `packages/core/platform/`.
**`app/`** — Next.js App Router pages. Route files should be thin: import and re-export from `features/`. Layout components and route-specific glue (redirects, auth guards) live here. Shared layout components (e.g. `app-sidebar`) stay in `app/(dashboard)/_components/`.
### apps/desktop/ (Electron App)
**`features/`** — Domain modules, each with its own components, hooks, stores, and config:
Electron 39 + electron-vite + react-router-dom. Uses `createHashRouter` since there's no server for pushState.
| Feature | Purpose | Exports |
|---|---|---|
| `features/auth/` | Authentication state | `useAuthStore`, `AuthInitializer` |
| `features/workspace/` | Workspace, members, agents | `useWorkspaceStore`, `useActorName` |
| `features/issues/` | Issue state, components, config | `useIssueStore`, icons, pickers, status/priority config |
| `features/inbox/` | Inbox notification state | `useInboxStore` |
| `features/realtime/` | WebSocket connection + sync | `WSProvider`, `useWSEvent`, `useRealtimeSync` |
| `features/modals/` | Modal registry and state | Modal store and components |
| `features/skills/` | Skill management | Skill components |
Desktop shares all page components from `@multica/views` — the router imports `IssuesPage`, `InboxPage`, `AgentsPage`, etc. directly. Desktop-specific code is limited to: layout shell (tab bar, traffic light region), navigation adapter, and page wrappers for dynamic `document.title`.
**Key conventions:**
- New routes must include `handle: { title: "..." }` for automatic tab titles
- Pages with dynamic titles (e.g. issue detail) use `useDocumentTitle(title)` to override
- `platform/navigation.tsx` adapts react-router to `NavigationAdapter` — the only place that imports from `react-router-dom`
- Environment variables (`VITE_API_URL`, `VITE_WS_URL`) are baked in at build time via `.env.production`
**`shared/`** — Code used across multiple features:
- `shared/api/``ApiClient` (REST) and `WSClient` (WebSocket) for backend communication, plus the `api` singleton.
- `shared/types/` — Domain types (Issue, Agent, Workspace, etc.) and WebSocket event types.
- `shared/logger.ts` — Logger utility.
### 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).
- **Zustand** for global client state — one store per feature domain (`features/auth/store.ts`, `features/workspace/store.ts`, `features/issues/store.ts`, `features/inbox/store.ts`).
- **React Context** only for connection lifecycle (`WSProvider` in `features/realtime/`).
- **Local `useState`** for component-scoped UI state (forms, modals, filters).
- Do not use React Context for data that can be a zustand store.
**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.
**Store conventions:**
- One store per feature domain. Import via `useAuthStore(selector)` or `useWorkspaceStore(selector)`.
- Stores must not call `useRouter` or any React hooks — keep navigation in components.
- Cross-store reads use `useOtherStore.getState()` inside actions (not hooks).
- Dependency direction: `workspace``auth`, `realtime``auth`, `issues``workspace`. Never reverse.
**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.
### Import Conventions
### Import Aliases
Use `@/` alias (maps to `apps/web/`):
```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";
// 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) — from @/platform
import { WebNavigationProvider } from "@/platform/navigation";
// Platform (desktop-only) — from @/ (maps to apps/desktop/src/renderer/src/)
import { useTabStore } from "@/stores/tab-store";
import { useDocumentTitle } from "@/hooks/use-document-title";
import { DesktopNavigationProvider } from "@/platform/navigation";
// Web-only features — from @/features
import { SearchCommand } from "@/features/search";
import { api } from "@/shared/api";
import type { Issue } from "@/shared/types";
import { useAuthStore } from "@/features/auth";
import { useWorkspaceStore } from "@/features/workspace";
import { useIssueStore } from "@/features/issues";
import { useInboxStore } from "@/features/inbox";
import { useWSEvent } from "@/features/realtime";
import { StatusIcon } from "@/features/issues/components";
```
`@/` maps to `apps/web/` in the web app and `apps/desktop/src/renderer/src/` in the desktop app. Within a package, use relative imports. Between packages, use `@multica/*`.
Within a feature, use relative imports. Between features or to shared, use `@/`.
### Data Flow
```
Browser → useQuery (@multica/core) → ApiClient (@multica/core/api) → REST API → sqlc → PostgreSQL
Browser ← useQuery cache ← invalidateQueries ← WS event handlers ← WSClient ← Hub.Broadcast()
Browser → ApiClient (shared/api) → REST API (Chi handlers) → sqlc queries → PostgreSQL
Browser ← WSClient (shared/api) ← WebSocket ← Hub.Broadcast() ← Handlers/TaskService
```
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`
@@ -226,14 +115,13 @@ make start # Start backend + frontend together
make stop # Stop app processes for the current checkout
make db-down # Stop the shared PostgreSQL container
# Frontend (all commands go through Turborepo)
# Frontend
pnpm install
pnpm dev:web # Next.js dev server (port 3000)
pnpm dev:desktop # Electron dev (electron-vite, HMR)
pnpm build # Build all frontend apps
pnpm typecheck # TypeScript check (all packages + apps via turbo)
pnpm build # Build frontend
pnpm typecheck # TypeScript check
pnpm lint # ESLint via Next.js
pnpm test # TS tests (Vitest, via turbo)
pnpm test # TS tests (Vitest)
# Backend (Go)
make dev # Run Go server (port 8080)
@@ -254,13 +142,6 @@ 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
# 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 (monorepo mode — must specify app)
npx shadcn add badge -c apps/web
# Infrastructure
make db-up # Start shared PostgreSQL (pgvector/pg17 image)
make db-down # Stop shared PostgreSQL
@@ -291,53 +172,14 @@ make start-worktree # Start using .env.worktree
- 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. Exception: `core/platform/storage.ts` has an SSR-safe `defaultStorage` using `localStorage` behind `typeof window` guards.
- `packages/ui/` — zero `@multica/core` imports (pure UI, no business logic)
- `packages/views/` — zero `next/*` imports, zero `react-router-dom` imports. Use `NavigationAdapter` for all routing. Use `window.open()` only for external URLs, never for internal navigation.
- `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
### Cross-Platform Development Rules
When adding a new page or feature to the shared packages:
1. **New page component** → add to `packages/views/<domain>/`. Import shared components from `@multica/views` and `@multica/ui`. 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 `apps/desktop/src/renderer/src/router.tsx` (react-router route with `handle: { title }`).
3. **Navigation** → use `useNavigation().push()` or `<AppLink>`. Never use `next/link` or react-router's `<Link>` in shared code.
4. **Dynamic page titles** → desktop pages that need dynamic titles (from async data) should use `useDocumentTitle(title)`. Static titles are set automatically via route `handle.title`.
5. **Platform-specific UI** → if a feature is web-only (e.g. SearchCommand) or desktop-only (e.g. TabBar), keep it in the respective app. Use props slots (`extra`, `topSlot`) on shared layout components to inject platform-specific UI.
### CSS Architecture
Both apps share the same CSS foundation. Each app's `globals.css` follows the same import pattern:
```css
@import "tailwindcss"; /* Core framework */
@import "tw-animate-css"; /* Animation utilities for shadcn */
@import "shadcn/tailwind.css"; /* data-* custom variants + no-scrollbar */
@import "@multica/ui/styles/tokens.css"; /* Design tokens (colors, radius, fonts) */
@import "@multica/ui/styles/base.css"; /* Shared base styles (scrollbar, shiki, body) */
```
- **Shared styles** → `packages/ui/styles/`. Never duplicate scrollbar styling, keyframes, or base layer rules in app CSS.
- **App-specific styles** → keep in the app's own CSS. Web: `apps/web/app/custom.css`. Desktop: inline in `globals.css`.
- **Design tokens** → use semantic tokens (`bg-background`, `text-muted-foreground`, `border-border`). Never use hardcoded Tailwind colors (`text-red-500`, `bg-gray-100`).
- **`@source` directives** → both apps scan `packages/ui/**/*.tsx`, `packages/core/**/*.{ts,tsx}`, `packages/views/**/*.{ts,tsx}` so Tailwind sees all class names used in shared packages.
## 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 (multica-icon, theme-provider, actor-avatar, etc.).
- **Shared business components** → `packages/views/<domain>/components/` — pages and domain-bound UI.
- **Web-only components** → `apps/web/features/` or `apps/web/components/`.
- **Desktop-only components** → `apps/desktop/src/renderer/src/components/` (tab-bar, desktop-layout).
- Prefer shadcn components over custom implementations. Install missing components via `npx shadcn add`.
- **Feature-specific components** → `features/<domain>/components/` — issue icons, pickers, and other domain-bound UI live inside their feature module.
- 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`).
- Do not introduce extra state (useState, context, reducers) unless explicitly required by the design.
- Do not introduce extra state (useState, context, reducers) unless explicitly required by the design. Prefer zustand stores for shared state over React Context.
- 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

View File

@@ -8,7 +8,7 @@ The `multica` CLI connects your local machine to Multica. It handles authenticat
```bash
brew tap multica-ai/tap
brew install multica
brew install multica-cli
```
### Build from Source

View File

@@ -1,171 +0,0 @@
# Multica CLI — Installation Guide for AI Agents
> **Audience:** This document is designed for AI agents (Claude Code, Codex, etc.) to read and execute step-by-step. Each step includes the exact shell commands to run, expected outcomes, and error handling.
## One-Line Instruction for Users
Give this instruction to your AI agent:
```
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.
```
---
## Step 1: Check if Multica CLI is already installed
Run:
```bash
multica version
```
- **If it prints a version string** (e.g. `multica v0.x.x`): skip to **Step 3**.
- **If command not found**: continue to **Step 2**.
---
## Step 2: Install the Multica CLI
### Option A: Homebrew (preferred)
Check if Homebrew is available:
```bash
which brew
```
If `brew` is found, install via Homebrew:
```bash
brew tap multica-ai/tap && brew install multica
```
Then verify:
```bash
multica version
```
If the version prints successfully, skip to **Step 3**.
### Option B: Download from GitHub Releases (no Homebrew)
If Homebrew is not available, download the binary directly.
Detect OS and architecture, then download the correct archive:
```bash
OS=$(uname -s | tr '[:upper:]' '[:lower:]') # "darwin" or "linux"
ARCH=$(uname -m) # "x86_64" or "arm64"
# Normalize architecture name
if [ "$ARCH" = "x86_64" ]; then
ARCH="amd64"
fi
# Get the latest release tag from GitHub
LATEST=$(curl -sI https://github.com/multica-ai/multica/releases/latest | grep -i '^location:' | sed 's/.*tag\///' | tr -d '\r\n')
# Download and extract
curl -sL "https://github.com/multica-ai/multica/releases/download/${LATEST}/multica_${OS}_${ARCH}.tar.gz" -o /tmp/multica.tar.gz
tar -xzf /tmp/multica.tar.gz -C /tmp multica
sudo mv /tmp/multica /usr/local/bin/multica
rm /tmp/multica.tar.gz
```
Verify:
```bash
multica version
```
**If this fails:**
- Check that `/usr/local/bin` is in `$PATH`.
- On Linux, you may need `chmod +x /usr/local/bin/multica`.
- If `sudo` is not available, install to a user-writable directory: `mv /tmp/multica ~/.local/bin/multica` and ensure `~/.local/bin` is in `$PATH`.
---
## Step 3: Log in
Run:
```bash
multica login
```
**Important:** This command opens a browser window for OAuth authentication. Tell the user:
> "A browser window will open for Multica login. Please complete the authentication in your browser, then come back here."
Wait for the command to complete. It will automatically discover and watch all workspaces the user belongs to.
Verify:
```bash
multica auth status
```
Expected output should show the authenticated user and server URL.
**If login fails:**
- If no browser is available (headless environment), the user can generate a Personal Access Token at `https://app.multica.ai/settings` and run: `multica login --token`
- If the server URL needs to be customized: `multica config set server_url <url>` before logging in.
---
## Step 4: Start the daemon
First, check if the daemon is already running:
```bash
multica daemon status
```
- **If status is "running"**: skip to **Step 5**.
- **If status is "stopped"**: start it:
```bash
multica daemon start
```
Wait 3 seconds, then verify:
```bash
multica daemon status
```
Expected output should show `running` status with detected agents (e.g. `claude`, `codex`).
**If daemon fails to start:**
- Check logs: `multica daemon logs`
- If a port conflict occurs, the daemon may already be running under a different profile.
- If no agents are detected, ensure at least one AI CLI (`claude` or `codex`) is installed and on the `$PATH`.
---
## Step 5: Verify everything is working
Run:
```bash
multica daemon status
```
Confirm:
1. Status is `running`
2. At least one agent is listed (e.g. `claude`, `codex`)
3. At least one workspace is being watched
If the agents list is empty, tell the user:
> "The Multica daemon is running but no AI agent CLIs were detected. Please install at least one: [Claude Code](https://docs.anthropic.com/en/docs/claude-code) (`claude`) or [Codex](https://github.com/openai/codex) (`codex`), then restart the daemon with `multica daemon stop && multica daemon start`."
---
## Summary
When all steps are complete, inform the user:
> "Multica CLI is installed and the daemon is running. Agents in your workspaces can now execute tasks on this machine. You can manage workspaces with `multica workspace list` and view daemon logs with `multica daemon logs -f`."

221
LICENSE
View File

@@ -1,44 +1,199 @@
# Open Source License
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
Multica is licensed under a modified version of the Apache License 2.0, with the following additional conditions:
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Multica may be utilized commercially, including as a backend service for
other applications or as a task management platform for enterprises.
Should the conditions below be met, a commercial license must be obtained
from the producer:
1. Definitions.
a. Hosted or embedded service: Unless explicitly authorized by Multica
in writing, you may not use the Multica source code to provide a
hosted service to third parties, or embed Multica as a component of
a product or service that is sold, licensed, or otherwise
commercially distributed to third parties.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
- This restriction applies to offering Multica (in whole or
substantial part) as a SaaS platform, a managed service, or as
an integrated component within another commercial offering.
- Internal use within a single organization (including multiple
workspaces) does not require a commercial license.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
b. LOGO and copyright information: In the process of using Multica's
frontend, you may not remove or modify the LOGO or copyright
information in the Multica console or applications. This restriction
is inapplicable to uses of Multica that do not involve its frontend.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
- Frontend Definition: For the purposes of this license, the
"frontend" of Multica includes all components located in the
`apps/web/` directory when running Multica from the raw source
code, or the "web" image when running Multica with Docker.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
2. As a contributor, you should agree that:
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
a. The producer can adjust the open-source agreement to be more strict
or relaxed as deemed necessary.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
b. Your contributed code may be used for commercial purposes, including
but not limited to its cloud business operations.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
Apart from the specific conditions mentioned above, all other rights and
restrictions follow the Apache License 2.0. Detailed information about the
Apache License 2.0 can be found at http://www.apache.org/licenses/LICENSE-2.0.
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
© 2025 Multica, Inc.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to the Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by the Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding any notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. Please also get an
"Implied Patent License" from your patent counsel.
Copyright 2025 Multica
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

View File

@@ -69,12 +69,7 @@ stop:
@echo "Stopping services..."
@-lsof -ti:$(PORT) | xargs kill -9 2>/dev/null
@-lsof -ti:$(FRONTEND_PORT) | xargs kill -9 2>/dev/null
@case "$(DATABASE_URL)" in \
""|*@localhost:*|*@localhost/*|*@127.0.0.1:*|*@127.0.0.1/*|*@\[::1\]:*|*@\[::1\]/*) \
echo "✓ App processes stopped. Shared PostgreSQL is still running on localhost:$(POSTGRES_PORT)." ;; \
*) \
echo "✓ App processes stopped. Remote PostgreSQL was not affected." ;; \
esac
@echo "✓ App processes stopped. Shared PostgreSQL is still running on localhost:5432."
# Full verification: typecheck + unit tests + Go tests + E2E
check:
@@ -103,12 +98,8 @@ check-main:
@ENV_FILE=$(MAIN_ENV_FILE) bash scripts/check.sh
setup-worktree:
@if [ ! -f "$(WORKTREE_ENV_FILE)" ]; then \
echo "==> Generating $(WORKTREE_ENV_FILE) with unique ports..."; \
bash scripts/init-worktree-env.sh $(WORKTREE_ENV_FILE); \
else \
echo "==> Using existing $(WORKTREE_ENV_FILE)"; \
fi
@echo "==> Generating $(WORKTREE_ENV_FILE) with unique ports..."
@FORCE=1 bash scripts/init-worktree-env.sh $(WORKTREE_ENV_FILE)
@$(MAKE) setup ENV_FILE=$(WORKTREE_ENV_FILE)
start-worktree:
@@ -143,12 +134,10 @@ COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown)
build:
cd server && go build -o bin/server ./cmd/server
cd server && go build -ldflags "-X main.version=$(VERSION) -X main.commit=$(COMMIT)" -o bin/multica ./cmd/multica
cd server && go build -o bin/migrate ./cmd/migrate
test:
$(REQUIRE_ENV)
@bash scripts/ensure-postgres.sh "$(ENV_FILE)"
cd server && go run ./cmd/migrate up
cd server && go test ./...
# Database

View File

@@ -14,14 +14,14 @@
**Your next 10 hires won't be human.**
The open-source managed agents platform.<br/>
Turn coding agents into real teammates — assign tasks, track progress, compound skills.
Open-source platform that turns coding agents into real teammates.<br/>
Assign tasks, track progress, compound skills — manage your human + agent workforce in one place.
[![CI](https://github.com/multica-ai/multica/actions/workflows/ci.yml/badge.svg)](https://github.com/multica-ai/multica/actions/workflows/ci.yml)
[![License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](https://opensource.org/licenses/Apache-2.0)
[![GitHub stars](https://img.shields.io/github/stars/multica-ai/multica?style=flat)](https://github.com/multica-ai/multica/stargazers)
[Website](https://multica.ai) · [Cloud](https://multica.ai/app) · [X](https://x.com/multica_hq) · [Self-Hosting](SELF_HOSTING.md) · [Contributing](CONTRIBUTING.md)
[Website](https://multica.ai) · [Cloud](https://multica.ai/app) · [Self-Hosting](SELF_HOSTING.md) · [Contributing](CONTRIBUTING.md)
**English | [简体中文](README.zh-CN.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**, **Codex**, **OpenClaw**, and **OpenCode**.
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. Works with **Claude Code** and **Codex**.
<p align="center">
<img src="docs/assets/hero-screenshot.png" alt="Multica board view" width="800">
@@ -39,8 +39,6 @@ No more copy-pasting prompts. No more babysitting runs. Your agents show up on t
## Features
Multica manages the full agent lifecycle: from task assignment to execution monitoring to skill reuse.
- **Agents as Teammates** — assign to an agent like you'd assign to a colleague. They have profiles, show up on the board, post comments, create issues, and report blockers proactively.
- **Autonomous Execution** — set it and forget it. Full task lifecycle management (enqueue, claim, start, complete/fail) with real-time progress streaming via WebSocket.
- **Reusable Skills** — every solution becomes a reusable skill for the whole team. Deployments, migrations, code reviews — skills compound your team's capabilities over time.
@@ -72,14 +70,6 @@ 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, 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.
```
**Option B — install manually:**
```bash
# Install
brew tap multica-ai/tap
@@ -90,7 +80,7 @@ multica login
multica daemon start
```
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.
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.
See the [CLI and Daemon Guide](CLI_AND_DAEMON.md) for the full command reference, daemon configuration, and advanced usage.
@@ -105,7 +95,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`, `openclaw`, `opencode`) available on your PATH.
The daemon runs in the background and keeps your machine connected to Multica. It auto-detects agent CLIs (`claude`, `codex`) available on your PATH.
### 2. Verify your runtime
@@ -115,7 +105,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, Codex, OpenClaw, or OpenCode). 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 or Codex). 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,8 +123,7 @@ That's it! Your agent is now part of the team. 🎉
┌──────┴───────┐
│ Agent Daemon │ (runs on your machine)
│Claude/Codex/
│OpenClaw/Code │
Claude/Codex │
└──────────────┘
```
@@ -143,7 +132,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, Codex, OpenClaw, or OpenCode |
| Agent Runtime | Local daemon executing Claude Code or Codex |
## Development
@@ -159,3 +148,7 @@ make start
```
See [CONTRIBUTING.md](CONTRIBUTING.md) for the full development workflow, worktree support, testing, and troubleshooting.
## License
[Apache 2.0](LICENSE)

View File

@@ -14,14 +14,14 @@
**你的下一批员工,不是人类。**
开源的 Managed Agents 平台。<br/>
将编码 Agent 变成真正的队友——分配任务、跟踪进度、积累技能。
开源平台,将编码 Agent 变成真正的队友。<br/>
分配任务、跟踪进度、积累技能——在一个地方管理你的人类 + Agent 团队
[![CI](https://github.com/multica-ai/multica/actions/workflows/ci.yml/badge.svg)](https://github.com/multica-ai/multica/actions/workflows/ci.yml)
[![License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](https://opensource.org/licenses/Apache-2.0)
[![GitHub stars](https://img.shields.io/github/stars/multica-ai/multica?style=flat)](https://github.com/multica-ai/multica/stargazers)
[官网](https://multica.ai) · [云服务](https://multica.ai/app) · [X](https://x.com/multica_hq) · [自部署指南](SELF_HOSTING.md) · [参与贡献](CONTRIBUTING.md)
[官网](https://multica.ai) · [云服务](https://multica.ai/app) · [自部署指南](SELF_HOSTING.md) · [参与贡献](CONTRIBUTING.md)
**[English](README.md) | 简体中文**
@@ -31,7 +31,7 @@
Multica 将编码 Agent 变成真正的队友。像分配给同事一样分配给 Agent——它们会自主接手工作、编写代码、报告阻塞问题、更新状态。
不再需要复制粘贴 prompt不再需要盯着运行过程。你的 Agent 出现在看板上、参与对话、随着时间积累可复用的技能。可以理解为开源的 Managed Agents 基础设施——厂商中立、可自部署、专为人类 + AI 团队设计。支持 **Claude Code**、**Codex**、**OpenClaw****OpenCode**
不再需要复制粘贴 prompt不再需要盯着运行过程。你的 Agent 出现在看板上、参与对话、随着时间积累可复用的技能。支持 **Claude Code****Codex**
<p align="center">
<img src="docs/assets/hero-screenshot.png" alt="Multica 看板视图" width="800">
@@ -39,8 +39,6 @@ Multica 将编码 Agent 变成真正的队友。像分配给同事一样分配
## 功能特性
Multica 管理完整的 Agent 生命周期:从任务分配到执行监控再到技能复用。
- **Agent 即队友** — 像分配给同事一样分配给 Agent。它们有个人档案、出现在看板上、发表评论、创建 Issue、主动报告阻塞问题。
- **自主执行** — 设置后无需管理。完整的任务生命周期管理(排队、认领、执行、完成/失败),通过 WebSocket 实时推送进度。
- **可复用技能** — 每个解决方案都成为全团队可复用的技能。部署、数据库迁移、代码审查——技能让团队能力随时间持续增长。
@@ -72,14 +70,6 @@ make start # 启动应用
`multica` CLI 将你的本地机器连接到 Multica — 用于认证、管理工作区和运行 Agent daemon。
**方式 A — 将以下指令粘贴给你的 coding agentClaude 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.
```
**方式 B — 手动安装:**
```bash
# 安装
brew tap multica-ai/tap
@@ -90,7 +80,7 @@ multica login
multica daemon start
```
daemon 会自动检测 PATH 中可用的 Agent CLI`claude``codex``openclaw``opencode`)。当 Agent 被分配任务时daemon 会创建隔离环境、运行 Agent、并将结果回传。
daemon 会自动检测 PATH 中可用的 Agent CLI`claude``codex`)。当 Agent 被分配任务时daemon 会创建隔离环境、运行 Agent、并将结果回传。
完整命令参考请参阅 [CLI 与 Daemon 指南](CLI_AND_DAEMON.md)。
@@ -105,7 +95,7 @@ multica login # 使用你的 Multica 账号认证
multica daemon start # 启动本地 Agent 运行时
```
daemon 在后台运行,保持你的机器与 Multica 的连接。它会自动检测 PATH 中可用的 Agent CLI`claude``codex``openclaw``opencode`)。
daemon 在后台运行,保持你的机器与 Multica 的连接。它会自动检测 PATH 中可用的 Agent CLI`claude``codex`)。
### 2. 确认运行时已连接
@@ -115,7 +105,7 @@ daemon 在后台运行,保持你的机器与 Multica 的连接。它会自动
### 3. 创建 Agent
进入 **设置 → Agents**,点击 **新建 Agent**。选择你刚连接的 Runtime选择 ProviderClaude Code、Codex、OpenClaw 或 OpenCode并为 Agent 起个名字——它将以这个名字出现在看板、评论和任务分配中。
进入 **设置 → Agents**,点击 **新建 Agent**。选择你刚连接的 Runtime选择 ProviderClaude CodeCodex),并为 Agent 起个名字——它将以这个名字出现在看板、评论和任务分配中。
### 4. 分配你的第一个任务
@@ -133,8 +123,7 @@ daemon 在后台运行,保持你的机器与 Multica 的连接。它会自动
┌──────┴───────┐
│ Agent Daemon │ (运行在你的机器上)
│Claude/Codex/
│OpenClaw/Code │
Claude/Codex │
└──────────────┘
```
@@ -143,7 +132,7 @@ daemon 在后台运行,保持你的机器与 Multica 的连接。它会自动
| 前端 | Next.js 16 (App Router) |
| 后端 | Go (Chi router, sqlc, gorilla/websocket) |
| 数据库 | PostgreSQL 17 with pgvector |
| Agent 运行时 | 本地 daemon 执行 Claude Code、Codex、OpenClaw 或 OpenCode |
| Agent 运行时 | 本地 daemon 执行 Claude CodeCodex |
## 开发

View File

@@ -257,14 +257,8 @@ Each team member who wants to run AI agents locally needs to:
```bash
# Point CLI to your server
#
# For production deployments with TLS:
export MULTICA_APP_URL=https://app.example.com
export MULTICA_SERVER_URL=wss://api.example.com/ws
#
# For local deployments without TLS:
# export MULTICA_APP_URL=http://localhost:3000
# export MULTICA_SERVER_URL=ws://localhost:8080/ws
# Login (opens browser)
multica login
@@ -273,8 +267,6 @@ Each team member who wants to run AI agents locally needs to:
multica daemon start
```
> **Note:** Use `https://` and `wss://` for production deployments behind a TLS-terminating reverse proxy. For local or development deployments without TLS, use `http://` and `ws://` instead.
The daemon auto-detects installed agent CLIs and registers itself with the server. When an agent is assigned a task in Multica, the daemon picks it up, creates an isolated workspace, runs the agent, and reports results back.
## Upgrading

View File

@@ -1,6 +0,0 @@
node_modules
dist
out
.DS_Store
.eslintcache
*.log*

View File

@@ -1,31 +0,0 @@
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

View File

@@ -1,22 +0,0 @@
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"],
},
},
});

View File

@@ -1,6 +0,0 @@
import reactConfig from "@multica/eslint-config/react";
export default [
...reactConfig,
{ ignores: ["out/", "dist/"] },
];

View File

@@ -1,44 +0,0 @@
{
"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:"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

View File

@@ -1,55 +0,0 @@
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();
});

View File

@@ -1,9 +0,0 @@
import { ElectronAPI } from "@electron-toolkit/preload";
declare global {
interface Window {
electron: ElectronAPI;
}
}
export {};

View File

@@ -1,9 +0,0 @@
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;
}

View File

@@ -1,12 +0,0 @@
<!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>

View File

@@ -1,19 +0,0 @@
import { RouterProvider } from "react-router-dom";
import { CoreProvider } from "@multica/core/platform";
import { ThemeProvider } from "@multica/ui/components/common/theme-provider";
import { Toaster } from "sonner";
import { router } from "./router";
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"}
>
<RouterProvider router={router} />
</CoreProvider>
<Toaster />
</ThemeProvider>
);
}

View File

@@ -1,117 +0,0 @@
import { useEffect } from "react";
import { Outlet, useNavigate } from "react-router-dom";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { useNavigationHistory } from "@/hooks/use-history-stack";
import { useTabSync } from "@/hooks/use-tab-sync";
import { useTabStore, resolveRouteIcon } from "@/stores/tab-store";
import { SidebarProvider } from "@multica/ui/components/ui/sidebar";
import { WorkspaceIdProvider } from "@multica/core/hooks";
import { ModalRegistry } from "@multica/views/modals/registry";
import { AppSidebar, useDashboardGuard } from "@multica/views/layout";
import { DesktopNavigationProvider } from "@/platform/navigation";
import { MulticaIcon } from "@multica/ui/components/common/multica-icon";
import { TabBar } from "./tab-bar";
function SidebarTopBar() {
const { canGoBack, canGoForward, goBack, goForward } = useNavigationHistory();
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>
);
}
export function DesktopLayout() {
return (
<DesktopNavigationProvider>
<DesktopLayoutInner />
</DesktopNavigationProvider>
);
}
function useInternalLinkHandler() {
const navigate = useNavigate();
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);
navigate(path);
};
window.addEventListener("multica:navigate", handler);
return () => window.removeEventListener("multica:navigate", handler);
}, [navigate]);
}
function DesktopLayoutInner() {
useTabSync();
useInternalLinkHandler();
const { user, isLoading, workspace } = useDashboardGuard("/login");
if (isLoading) {
return (
<div className="flex h-screen items-center justify-center">
<MulticaIcon className="size-6" />
</div>
);
}
if (!user) return null;
return (
<div className="flex h-screen">
<SidebarProvider className="flex-1">
<AppSidebar topSlot={<SidebarTopBar />} />
{/* 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">
{workspace ? (
<WorkspaceIdProvider wsId={workspace.id}>
<Outlet />
<ModalRegistry />
</WorkspaceIdProvider>
) : (
<div className="flex h-full items-center justify-center">
<MulticaIcon className="size-6 animate-pulse" />
</div>
)}
</div>
</div>
</SidebarProvider>
</div>
);
}

View File

@@ -1,114 +0,0 @@
import { useNavigate } from "react-router-dom";
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 }: { tab: Tab; isActive: boolean }) {
const navigate = useNavigate();
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);
navigate(tab.path);
};
const handleClose = (e: React.MouseEvent) => {
e.stopPropagation();
const newPath = closeTab(tab.id);
if (newPath) navigate(newPath);
};
return (
<button
onClick={handleClick}
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 text-sidebar-accent-foreground"
: "bg-sidebar-accent/60 text-muted-foreground hover:bg-sidebar-accent/80",
)}
>
{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>
<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 navigate = useNavigate();
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);
navigate(path);
};
return (
<button
onClick={handleClick}
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"
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
>
{tabs.map((tab) => (
<TabItem key={tab.id} tab={tab} isActive={tab.id === activeTabId} />
))}
<NewTabButton />
</div>
);
}

View File

@@ -1 +0,0 @@
/// <reference types="vite/client" />

View File

@@ -1,17 +0,0 @@
@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;
}

View File

@@ -1,8 +0,0 @@
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]);
}

View File

@@ -1,57 +0,0 @@
import { useState, useEffect, useRef, useCallback } from "react";
import { useLocation } from "react-router-dom";
export function useNavigationHistory() {
const [canGoBack, setCanGoBack] = useState(false);
const [canGoForward, setCanGoForward] = useState(false);
const location = useLocation();
const seqRef = useRef(1);
const maxSeqRef = useRef(1);
const isNavRef = useRef(false);
// Seed initial entry + listen for popstate (browser back/forward)
useEffect(() => {
window.history.replaceState({ seq: 1 }, "");
const handlePopState = (event: PopStateEvent) => {
const seq = (event.state?.seq as number) ?? 0;
seqRef.current = seq;
setCanGoBack(seq > 1);
setCanGoForward(seq < maxSeqRef.current);
};
window.addEventListener("popstate", handlePopState);
return () => window.removeEventListener("popstate", handlePopState);
}, []);
// Stamp seq on each new navigation
useEffect(() => {
// Skip if this was our own goBack/goForward call
if (isNavRef.current) {
isNavRef.current = false;
return;
}
const nextSeq = seqRef.current + 1;
seqRef.current = nextSeq;
maxSeqRef.current = nextSeq;
window.history.replaceState({ seq: nextSeq }, "");
setCanGoBack(nextSeq > 1);
setCanGoForward(false);
}, [location]);
const goBack = useCallback(() => {
if (!canGoBack) return;
isNavRef.current = true;
window.history.back();
}, [canGoBack]);
const goForward = useCallback(() => {
if (!canGoForward) return;
isNavRef.current = true;
window.history.forward();
}, [canGoForward]);
return { canGoBack, canGoForward, goBack, goForward };
}

View File

@@ -1,61 +0,0 @@
import { useEffect, useRef } from "react";
import { useLocation } from "react-router-dom";
import { useTabStore, resolveRouteIcon } from "@/stores/tab-store";
/**
* Keeps the active tab in sync with the current URL and document.title.
*
* Two sync directions:
* 1. URL change → update active tab's path (and set a default title from the route)
* 2. document.title change → update active tab's title (pages set this naturally)
*
* Tab switches (clicking another tab) are ignored — the tab already has its metadata.
*/
export function useTabSync() {
const location = useLocation();
const isTabSwitch = useRef(false);
// Detect tab switches so we don't overwrite metadata
useEffect(() => {
return useTabStore.subscribe((state, prev) => {
if (state.activeTabId !== prev.activeTabId) {
isTabSwitch.current = true;
}
});
}, []);
// Sync URL → tab path
useEffect(() => {
if (isTabSwitch.current) {
isTabSwitch.current = false;
return;
}
const { tabs, activeTabId } = useTabStore.getState();
const activeTab = tabs.find((t) => t.id === activeTabId);
if (activeTab && activeTab.path !== location.pathname) {
const icon = resolveRouteIcon(location.pathname);
useTabStore.getState().updateActiveTab(location.pathname, document.title, icon);
}
}, [location.pathname]);
// Sync document.title → tab title
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().updateActiveTab(activeTab.path, title, activeTab.icon);
}
});
const titleEl = document.querySelector("title");
if (titleEl) {
observer.observe(titleEl, { childList: true, characterData: true, subtree: true });
}
return () => observer.disconnect();
}, []);
}

View File

@@ -1,5 +0,0 @@
import ReactDOM from "react-dom/client";
import App from "./App";
import "./globals.css";
ReactDOM.createRoot(document.getElementById("root")!).render(<App />);

View File

@@ -1,17 +0,0 @@
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} />;
}

View File

@@ -1,21 +0,0 @@
import { useNavigate } from "react-router-dom";
import { LoginPage } from "@multica/views/auth";
import { MulticaIcon } from "@multica/ui/components/common/multica-icon";
export function DesktopLoginPage() {
const navigate = useNavigate();
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={() => navigate("/issues", { replace: true })}
/>
</div>
);
}

View File

@@ -1,33 +0,0 @@
import { useNavigate, useLocation } from "react-router-dom";
import {
NavigationProvider,
type NavigationAdapter,
} from "@multica/views/navigation";
import { useTabStore, resolveRouteIcon } from "@/stores/tab-store";
export function DesktopNavigationProvider({
children,
}: {
children: React.ReactNode;
}) {
const navigate = useNavigate();
const location = useLocation();
const adapter: NavigationAdapter = {
push: (path) => navigate(path),
replace: (path) => navigate(path, { replace: true }),
back: () => navigate(-1),
pathname: location.pathname,
searchParams: new URLSearchParams(location.search),
openInNewTab: (path, title?) => {
const icon = resolveRouteIcon(path);
const store = useTabStore.getState();
const tabId = store.openTab(path, title ?? path, icon);
store.setActiveTab(tabId);
navigate(path);
},
getShareableUrl: (path) => `https://www.multica.ai${path}`,
};
return <NavigationProvider value={adapter}>{children}</NavigationProvider>;
}

View File

@@ -1,67 +0,0 @@
import { createHashRouter, Navigate, Outlet, useMatches } from "react-router-dom";
import { useEffect } from "react";
import { DesktopLayout } from "./components/desktop-layout";
import { DesktopLoginPage } from "./pages/login";
import { IssueDetailPage } from "./pages/issue-detail-page";
// Extracted pages from @multica/views
import { IssuesPage } from "@multica/views/issues/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 />
</>
);
}
export const router = createHashRouter([
{
path: "/",
element: <DesktopLayout />,
children: [
{
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: "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" } },
],
},
],
},
{ path: "/login", element: <DesktopLoginPage /> },
]);

View File

@@ -1,123 +0,0 @@
import { create } from "zustand";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface Tab {
id: string;
path: string;
title: string;
icon: string;
}
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. Returns the path to navigate to if active tab changed, or null. */
closeTab: (tabId: string) => string | null;
/** Switch to a tab by id. */
setActiveTab: (tabId: string) => void;
/** Update the active tab's metadata. */
updateActiveTab: (path: string, title: string, icon: string) => 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",
"/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" : "ListTodo");
}
// ---------------------------------------------------------------------------
// Store
// ---------------------------------------------------------------------------
const DEFAULT_PATH = "/issues";
function createId(): string {
return crypto.randomUUID();
}
const initialTab: Tab = {
id: createId(),
path: DEFAULT_PATH,
title: "Issues",
icon: resolveRouteIcon(DEFAULT_PATH),
};
export const useTabStore = create<TabStore>((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: Tab = { id: createId(), path, title, icon };
set({ tabs: [...tabs, tab] });
return tab.id;
},
addTab(path, title, icon) {
const tab: Tab = { id: createId(), path, title, icon };
set((s) => ({ tabs: [...s.tabs, tab] }));
return tab.id;
},
closeTab(tabId) {
const { tabs, activeTabId } = get();
// Never close the last tab — replace with default
if (tabs.length === 1) {
const fresh: Tab = { id: createId(), path: DEFAULT_PATH, title: "Issues", icon: resolveRouteIcon(DEFAULT_PATH) };
set({ tabs: [fresh], activeTabId: fresh.id });
return fresh.path;
}
const idx = tabs.findIndex((t) => t.id === tabId);
if (idx === -1) return null;
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 });
return newActive.path;
}
set({ tabs: next });
return null;
},
setActiveTab(tabId) {
set({ activeTabId: tabId });
},
updateActiveTab(path, title, icon) {
const { tabs, activeTabId } = get();
set({
tabs: tabs.map((t) =>
t.id === activeTabId ? { ...t, path, title, icon } : t,
),
});
},
}));

View File

@@ -1,4 +0,0 @@
{
"files": [],
"references": [{ "path": "./tsconfig.node.json" }, { "path": "./tsconfig.web.json" }]
}

View File

@@ -1,8 +0,0 @@
{
"extends": "@electron-toolkit/tsconfig/tsconfig.node.json",
"include": ["electron.vite.config.*", "src/main/**/*", "src/preload/**/*"],
"compilerOptions": {
"composite": true,
"types": ["electron-vite/node"]
}
}

View File

@@ -1,20 +0,0 @@
{
"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/*"
]
}
}
}

View File

@@ -12,7 +12,7 @@ vi.mock("next/navigation", () => ({
// Mock auth store
const mockSendCode = vi.fn();
const mockVerifyCode = vi.fn();
vi.mock("@multica/core/auth", () => ({
vi.mock("@/features/auth", () => ({
useAuthStore: (selector: (s: any) => any) =>
selector({
sendCode: mockSendCode,
@@ -20,14 +20,9 @@ vi.mock("@multica/core/auth", () => ({
}),
}));
// Mock auth-cookie
vi.mock("@/features/auth/auth-cookie", () => ({
setLoggedInCookie: vi.fn(),
}));
// Mock workspace store
const mockHydrateWorkspace = vi.fn();
vi.mock("@multica/core/workspace", () => ({
vi.mock("@/features/workspace", () => ({
useWorkspaceStore: (selector: (s: any) => any) =>
selector({
hydrateWorkspace: mockHydrateWorkspace,
@@ -35,7 +30,7 @@ vi.mock("@multica/core/workspace", () => ({
}));
// Mock api
vi.mock("@multica/core/api", () => ({
vi.mock("@/shared/api", () => ({
api: {
listWorkspaces: vi.fn().mockResolvedValue([]),
verifyCode: vi.fn(),

View File

@@ -2,10 +2,9 @@
import { Suspense, useState, useEffect, useCallback } from "react";
import { useSearchParams, useRouter } from "next/navigation";
import { useAuthStore } from "@multica/core/auth";
import { setLoggedInCookie } from "@/features/auth/auth-cookie";
import { useWorkspaceStore } from "@multica/core/workspace";
import { api } from "@multica/core/api";
import { useAuthStore } from "@/features/auth";
import { useWorkspaceStore } from "@/features/workspace";
import { api } from "@/shared/api";
import {
Card,
CardHeader,
@@ -13,16 +12,16 @@ import {
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";
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import {
InputOTP,
InputOTPGroup,
InputOTPSlot,
} from "@multica/ui/components/ui/input-otp";
import type { User } from "@multica/core/types";
} from "@/components/ui/input-otp";
import type { User } from "@/shared/types";
function validateCliCallback(cliCallback: string): boolean {
try {
@@ -147,10 +146,6 @@ function LoginPageContent() {
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;
@@ -158,8 +153,7 @@ function LoginPageContent() {
await verifyCode(email, value);
const wsList = await api.listWorkspaces();
const lastWsId = localStorage.getItem("multica_workspace_id");
await hydrateWorkspace(wsList, lastWsId);
await hydrateWorkspace(wsList);
router.push(searchParams.get("next") || "/issues");
} catch (err) {
setError(
@@ -287,22 +281,6 @@ function LoginPageContent() {
);
}
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}`;
};
return (
<div className="flex min-h-screen items-center justify-center">
<Card className="w-full max-w-sm">
@@ -328,7 +306,7 @@ function LoginPageContent() {
)}
</form>
</CardContent>
<CardFooter className="flex flex-col gap-3">
<CardFooter>
<Button
type="submit"
form="login-form"
@@ -338,46 +316,6 @@ function LoginPageContent() {
>
{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>

View File

@@ -1,8 +1,7 @@
"use client";
import React from "react";
import { cn } from "@multica/ui/lib/utils";
import { AppLink, useNavigation } from "../navigation";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import {
Inbox,
ListTodo,
@@ -17,24 +16,21 @@ import {
SquarePen,
CircleUser,
FolderKanban,
Ellipsis,
} from "lucide-react";
import { WorkspaceAvatar } from "../workspace/workspace-avatar";
import { ActorAvatar } from "@multica/ui/components/common/actor-avatar";
import { useIssueDraftStore } from "@multica/core/issues/stores/draft-store";
import { WorkspaceAvatar } from "@/features/workspace";
import { useIssueDraftStore } from "@/features/issues/stores/draft-store";
import {
Sidebar,
SidebarContent,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarFooter,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarRail,
} from "@multica/ui/components/ui/sidebar";
} from "@/components/ui/sidebar";
import {
DropdownMenu,
DropdownMenuContent,
@@ -43,28 +39,22 @@ import {
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@multica/ui/components/ui/dropdown-menu";
import { Tooltip, TooltipTrigger, TooltipContent } from "@multica/ui/components/ui/tooltip";
import { useAuthStore } from "@multica/core/auth";
import { useWorkspaceStore } from "@multica/core/workspace";
import { useQuery } from "@tanstack/react-query";
import { inboxKeys, deduplicateInboxItems } from "@multica/core/inbox/queries";
import { api } from "@multica/core/api";
import { useModalStore } from "@multica/core/modals";
import { useMyRuntimesNeedUpdate } from "@multica/core/runtimes/hooks";
} from "@/components/ui/dropdown-menu";
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
import { useAuthStore } from "@/features/auth";
import { useWorkspaceStore } from "@/features/workspace";
import { useInboxStore } from "@/features/inbox";
import { useModalStore } from "@/features/modals";
const personalNav = [
const primaryNav = [
{ href: "/inbox", label: "Inbox", icon: Inbox },
{ href: "/my-issues", label: "My Issues", icon: CircleUser },
{ href: "/issues", label: "Issues", icon: ListTodo },
{ href: "/projects", label: "Projects", icon: FolderKanban },
];
const workspaceNav = [
{ href: "/issues", label: "Issues", icon: ListTodo },
{ href: "/projects", label: "Projects", icon: FolderKanban },
{ href: "/agents", label: "Agents", icon: Bot },
];
const configureNav = [
{ href: "/runtimes", label: "Runtimes", icon: Monitor },
{ href: "/skills", label: "Skills", icon: BookOpenText },
{ href: "/settings", label: "Settings", icon: Settings },
@@ -76,64 +66,44 @@ function DraftDot() {
return <span className="absolute top-0 right-0 size-1.5 rounded-full bg-brand" />;
}
interface AppSidebarProps {
/** Rendered above SidebarHeader (e.g. desktop traffic light spacer) */
topSlot?: React.ReactNode;
/** Rendered in the header between workspace switcher and new-issue button (e.g. search trigger) */
searchSlot?: React.ReactNode;
/** Extra className for SidebarHeader */
headerClassName?: string;
/** Extra style for SidebarHeader */
headerStyle?: React.CSSProperties;
}
export function AppSidebar({ topSlot, searchSlot, headerClassName, headerStyle }: AppSidebarProps = {}) {
const { pathname, push } = useNavigation();
export function AppSidebar() {
const pathname = usePathname();
const router = useRouter();
const user = useAuthStore((s) => s.user);
const authLogout = useAuthStore((s) => s.logout);
const workspace = useWorkspaceStore((s) => s.workspace);
const workspaces = useWorkspaceStore((s) => s.workspaces);
const switchWorkspace = useWorkspaceStore((s) => s.switchWorkspace);
const wsId = workspace?.id;
const { data: inboxItems = [] } = useQuery({
queryKey: wsId ? inboxKeys.list(wsId) : ["inbox", "disabled"],
queryFn: () => api.listInbox(),
enabled: !!wsId,
});
const unreadCount = React.useMemo(
() => deduplicateInboxItems(inboxItems).filter((i) => !i.read).length,
[inboxItems],
);
const hasRuntimeUpdates = useMyRuntimesNeedUpdate();
const unreadCount = useInboxStore((s) => s.unreadCount());
const logout = () => {
push("/");
router.push("/");
authLogout();
useWorkspaceStore.getState().clearWorkspace();
};
return (
<Sidebar variant="inset">
{topSlot}
{/* Workspace Switcher */}
<SidebarHeader className={cn("py-3", headerClassName)} style={headerStyle}>
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger
render={
<SidebarMenuButton>
<WorkspaceAvatar name={workspace?.name ?? "M"} size="sm" />
<span className="flex-1 truncate font-medium">
{workspace?.name ?? "Multica"}
</span>
<ChevronDown className="size-3 text-muted-foreground" />
</SidebarMenuButton>
}
/>
<SidebarHeader className="py-3">
<div className="flex items-center gap-4">
<SidebarMenu className="min-w-0 flex-1">
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger
render={
<SidebarMenuButton>
<WorkspaceAvatar name={workspace?.name ?? "M"} size="sm" />
<span className="flex-1 truncate font-medium">
{workspace?.name ?? "Multica"}
</span>
<ChevronDown className="size-3 text-muted-foreground" />
</SidebarMenuButton>
}
/>
<DropdownMenuContent
className="w-auto"
className="w-52"
align="start"
side="bottom"
sideOffset={4}
@@ -164,7 +134,6 @@ export function AppSidebar({ topSlot, searchSlot, headerClassName, headerStyle }
key={ws.id}
onClick={() => {
if (ws.id !== workspace?.id) {
push("/issues");
switchWorkspace(ws.id);
}
}}
@@ -185,28 +154,20 @@ export function AppSidebar({ topSlot, searchSlot, headerClassName, headerStyle }
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
<SidebarMenu>
{searchSlot && (
<SidebarMenuItem>
{searchSlot}
</DropdownMenu>
</SidebarMenuItem>
)}
<SidebarMenuItem>
<SidebarMenuButton
className="text-muted-foreground"
</SidebarMenu>
<Tooltip>
<TooltipTrigger
className="relative flex h-7 w-7 items-center justify-center rounded-lg bg-background text-foreground shadow-sm hover:bg-accent"
onClick={() => useModalStore.getState().open("create-issue")}
>
<span className="relative">
<SquarePen />
<DraftDot />
</span>
<span>New Issue</span>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
<SquarePen className="size-3.5" />
<DraftDot />
</TooltipTrigger>
<TooltipContent side="bottom">New issue</TooltipContent>
</Tooltip>
</div>
</SidebarHeader>
{/* Navigation */}
@@ -214,13 +175,13 @@ export function AppSidebar({ topSlot, searchSlot, headerClassName, headerStyle }
<SidebarGroup>
<SidebarGroupContent>
<SidebarMenu className="gap-0.5">
{personalNav.map((item) => {
{primaryNav.map((item) => {
const isActive = pathname === item.href;
return (
<SidebarMenuItem key={item.href}>
<SidebarMenuButton
isActive={isActive}
render={<AppLink href={item.href} />}
render={<Link href={item.href} />}
className="text-muted-foreground hover:not-data-active:bg-sidebar-accent/70 data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground"
>
<item.icon />
@@ -239,7 +200,6 @@ export function AppSidebar({ topSlot, searchSlot, headerClassName, headerStyle }
</SidebarGroup>
<SidebarGroup>
<SidebarGroupLabel>Workspace</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu className="gap-0.5">
{workspaceNav.map((item) => {
@@ -248,7 +208,7 @@ export function AppSidebar({ topSlot, searchSlot, headerClassName, headerStyle }
<SidebarMenuItem key={item.href}>
<SidebarMenuButton
isActive={isActive}
render={<AppLink href={item.href} />}
render={<Link href={item.href} />}
className="text-muted-foreground hover:not-data-active:bg-sidebar-accent/70 data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground"
>
<item.icon />
@@ -260,65 +220,8 @@ export function AppSidebar({ topSlot, searchSlot, headerClassName, headerStyle }
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
<SidebarGroup>
<SidebarGroupLabel>Configure</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu className="gap-0.5">
{configureNav.map((item) => {
const isActive = pathname === item.href;
return (
<SidebarMenuItem key={item.href}>
<SidebarMenuButton
isActive={isActive}
render={<AppLink href={item.href} />}
className="text-muted-foreground hover:not-data-active:bg-sidebar-accent/70 data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground"
>
<item.icon />
<span>{item.label}</span>
{item.label === "Runtimes" && hasRuntimeUpdates && (
<span className="ml-auto size-1.5 rounded-full bg-destructive" />
)}
</SidebarMenuButton>
</SidebarMenuItem>
);
})}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
<SidebarFooter className="p-2">
<div className="border-t pt-2">
<div className="flex items-center gap-2.5 rounded-md px-2 py-1.5">
<ActorAvatar
name={user?.name ?? ""}
initials={(user?.name ?? "U").charAt(0).toUpperCase()}
avatarUrl={user?.avatar_url}
size={28}
/>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium leading-tight">
{user?.name}
</p>
<p className="truncate text-xs text-muted-foreground leading-tight">
{user?.email}
</p>
</div>
<DropdownMenu>
<DropdownMenuTrigger className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-colors">
<Ellipsis className="size-4" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" side="top" sideOffset={4}>
<DropdownMenuItem variant="destructive" onClick={logout}>
<LogOut className="h-3.5 w-3.5" />
Log out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</SidebarFooter>
<SidebarFooter />
<SidebarRail />
</Sidebar>
);

View File

@@ -0,0 +1,12 @@
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

View File

@@ -0,0 +1,10 @@
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>
);
}

View File

@@ -1 +1,471 @@
export { InboxPage as default } from "@multica/views/inbox";
"use client";
import { useState, useEffect, useCallback } from "react";
import { useSearchParams } from "next/navigation";
import { useDefaultLayout } from "react-resizable-panels";
import { useInboxStore } from "@/features/inbox";
import { IssueDetail, StatusIcon, PriorityIcon } from "@/features/issues/components";
import { STATUS_CONFIG, PRIORITY_CONFIG } from "@/features/issues/config";
import { useActorName } from "@/features/workspace";
import { ActorAvatar } from "@/components/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 "@/shared/types";
import { Button } from "@/components/ui/button";
import {
ResizablePanelGroup,
ResizablePanel,
ResizableHandle,
} from "@/components/ui/resizable";
import { Skeleton } from "@/components/ui/skeleton";
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
} from "@/components/ui/dropdown-menu";
import { api } from "@/shared/api";
// ---------------------------------------------------------------------------
// 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 items = useInboxStore((s) => s.dedupedItems());
const loading = useInboxStore((s) => s.loading);
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;
// Click-to-read: select + auto-mark-read
const handleSelect = async (item: InboxItem) => {
setSelectedKey(item.issue_id ?? item.id);
if (!item.read) {
useInboxStore.getState().markRead(item.id);
try {
await api.markInboxRead(item.id);
} catch {
// Rollback: refetch to get server truth
useInboxStore.getState().fetch();
toast.error("Failed to mark as read");
}
}
};
const handleArchive = async (id: string) => {
try {
await api.archiveInbox(id);
useInboxStore.getState().archive(id);
const archived = items.find((i) => i.id === id);
if (archived && (archived.issue_id ?? archived.id) === selectedKey) setSelectedKey("");
} catch {
toast.error("Failed to archive");
}
};
// Batch operations
const handleMarkAllRead = async () => {
try {
useInboxStore.getState().markAllRead();
await api.markAllInboxRead();
} catch {
toast.error("Failed to mark all as read");
useInboxStore.getState().fetch();
}
};
const handleArchiveAll = async () => {
try {
useInboxStore.getState().archiveAll();
setSelectedKey("");
await api.archiveAllInbox();
} catch {
toast.error("Failed to archive all");
useInboxStore.getState().fetch();
}
};
const handleArchiveAllRead = async () => {
try {
const readKeys = items.filter((i) => i.read).map((i) => i.issue_id ?? i.id);
useInboxStore.getState().archiveAllRead();
if (readKeys.includes(selectedKey)) setSelectedKey("");
await api.archiveAllReadInbox();
} catch {
toast.error("Failed to archive read items");
useInboxStore.getState().fetch();
}
};
const handleArchiveCompleted = async () => {
try {
await api.archiveCompletedInbox();
setSelectedKey("");
await useInboxStore.getState().fetch();
} catch {
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>
);
}

View File

@@ -2,9 +2,7 @@ import { Suspense, forwardRef, useRef, useState, useImperativeHandle } from "rea
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";
import type { Issue, Comment, TimelineEntry } from "@/shared/types";
// Mock next/navigation
vi.mock("next/navigation", () => ({
@@ -30,7 +28,7 @@ vi.mock("next/link", () => ({
}));
// Mock auth store
vi.mock("@/platform/auth", () => ({
vi.mock("@/features/auth", () => ({
useAuthStore: (selector: (s: any) => any) =>
selector({
user: { id: "user-1", name: "Test User", email: "test@multica.ai" },
@@ -38,121 +36,6 @@ vi.mock("@/platform/auth", () => ({
}),
}));
// 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) =>
@@ -179,47 +62,35 @@ vi.mock("@/features/workspace", () => ({
}),
}));
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)
// Mock issue store — supply a stable full issue object so storeIssue
// doesn't create a new reference each render (avoids infinite effect loop)
// and has all required fields for rendering.
const stableStoreIssues = vi.hoisted(() => [
{
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",
},
]);
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() }) },
(selector: (s: any) => any) => selector({ issues: stableStoreIssues }),
{ getState: () => ({ issues: stableStoreIssues, addIssue: vi.fn(), updateIssue: vi.fn(), removeIssue: vi.fn() }) },
),
}));
@@ -229,15 +100,6 @@ vi.mock("@/features/realtime", () => ({
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,
@@ -245,9 +107,6 @@ vi.mock("@/components/ui/calendar", () => ({
// 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 || "");
@@ -302,73 +161,32 @@ 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)
// Mock api
const mockGetIssue = vi.hoisted(() => vi.fn());
const mockListTimeline = vi.hoisted(() => vi.fn());
const mockCreateComment = vi.hoisted(() => vi.fn());
const mockUpdateComment = vi.hoisted(() => vi.fn());
const mockDeleteComment = vi.hoisted(() => vi.fn());
const mockDeleteIssue = vi.hoisted(() => vi.fn());
const mockUpdateIssue = vi.hoisted(() => vi.fn());
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" },
vi.mock("@/shared/api", () => ({
api: {
getIssue: (...args: any[]) => mockGetIssue(...args),
listTimeline: (...args: any[]) => mockListTimeline(...args),
listComments: vi.fn().mockResolvedValue([]),
createComment: (...args: any[]) => mockCreateComment(...args),
updateComment: (...args: any[]) => mockUpdateComment(...args),
deleteComment: (...args: any[]) => mockDeleteComment(...args),
deleteIssue: (...args: any[]) => mockDeleteIssue(...args),
updateIssue: (...args: any[]) => mockUpdateIssue(...args),
listIssueSubscribers: vi.fn().mockResolvedValue([]),
subscribeToIssue: vi.fn().mockResolvedValue(undefined),
unsubscribeFromIssue: vi.fn().mockResolvedValue(undefined),
getActiveTaskForIssue: vi.fn().mockResolvedValue({ task: null }),
listTasksByIssue: vi.fn().mockResolvedValue([]),
listTaskMessages: vi.fn().mockResolvedValue([]),
},
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 = {
@@ -419,28 +237,14 @@ const mockTimeline: TimelineEntry[] = [
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>,
<Suspense fallback={<div>Suspense loading...</div>}>
<IssueDetailPage params={Promise.resolve({ id })} />
</Suspense>,
);
});
return result!;
@@ -452,8 +256,8 @@ describe("IssueDetailPage", () => {
});
it("renders issue details after loading", async () => {
mockApiObj.getIssue.mockResolvedValueOnce(mockIssue);
mockApiObj.listTimeline.mockResolvedValueOnce(mockTimeline);
mockGetIssue.mockResolvedValueOnce(mockIssue);
mockListTimeline.mockResolvedValueOnce(mockTimeline);
await renderPage();
await waitFor(() => {
@@ -468,8 +272,8 @@ describe("IssueDetailPage", () => {
});
it("renders issue properties sidebar", async () => {
mockApiObj.getIssue.mockResolvedValueOnce(mockIssue);
mockApiObj.listTimeline.mockResolvedValueOnce(mockTimeline);
mockGetIssue.mockResolvedValueOnce(mockIssue);
mockListTimeline.mockResolvedValueOnce(mockTimeline);
await renderPage();
await waitFor(() => {
@@ -481,8 +285,8 @@ describe("IssueDetailPage", () => {
});
it("renders comments", async () => {
mockApiObj.getIssue.mockResolvedValueOnce(mockIssue);
mockApiObj.listTimeline.mockResolvedValueOnce(mockTimeline);
mockGetIssue.mockResolvedValueOnce(mockIssue);
mockListTimeline.mockResolvedValueOnce(mockTimeline);
await renderPage();
await waitFor(() => {
@@ -497,8 +301,8 @@ describe("IssueDetailPage", () => {
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"));
mockGetIssue.mockRejectedValue(new Error("Not found"));
mockListTimeline.mockRejectedValue(new Error("Not found"));
await renderPage("nonexistent-id");
await waitFor(() => {
@@ -507,8 +311,8 @@ describe("IssueDetailPage", () => {
});
it("submits a new comment", async () => {
mockApiObj.getIssue.mockResolvedValueOnce(mockIssue);
mockApiObj.listTimeline.mockResolvedValueOnce(mockTimeline);
mockGetIssue.mockResolvedValueOnce(mockIssue);
mockListTimeline.mockResolvedValueOnce(mockTimeline);
const newComment: Comment = {
id: "comment-3",
@@ -523,7 +327,7 @@ describe("IssueDetailPage", () => {
created_at: "2026-01-18T00:00:00Z",
updated_at: "2026-01-18T00:00:00Z",
};
mockApiObj.createComment.mockResolvedValueOnce(newComment);
mockCreateComment.mockResolvedValueOnce(newComment);
const user = userEvent.setup();
await renderPage();
@@ -555,8 +359,8 @@ describe("IssueDetailPage", () => {
await user.click(submitBtn);
await waitFor(() => {
expect(mockApiObj.createComment).toHaveBeenCalled();
const [issueId, content] = mockApiObj.createComment.mock.calls[0]!;
expect(mockCreateComment).toHaveBeenCalled();
const [issueId, content] = mockCreateComment.mock.calls[0]!;
expect(issueId).toBe("issue-1");
expect(content).toBe("New test comment");
});
@@ -567,8 +371,8 @@ describe("IssueDetailPage", () => {
});
it("renders breadcrumb navigation", async () => {
mockApiObj.getIssue.mockResolvedValueOnce(mockIssue);
mockApiObj.listTimeline.mockResolvedValueOnce(mockTimeline);
mockGetIssue.mockResolvedValueOnce(mockIssue);
mockListTimeline.mockResolvedValueOnce(mockTimeline);
await renderPage();
await waitFor(() => {

View File

@@ -1,7 +1,7 @@
"use client";
import { use } from "react";
import { IssueDetail } from "@multica/views/issues/components";
import { IssueDetail } from "@/features/issues/components";
export default function IssueDetailPage({
params,

View File

@@ -1,9 +1,7 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { Issue } from "@multica/core/types";
import { WorkspaceIdProvider } from "@multica/core/hooks";
import type { Issue } from "@/shared/types";
// Mock next/navigation
vi.mock("next/navigation", () => ({
@@ -48,54 +46,6 @@ vi.mock("@/features/workspace", () => ({
WorkspaceAvatar: ({ name }: { name: string }) => <span>{name.charAt(0)}</span>,
}));
// Mock @multica/core/auth (used by @multica/views pickers like AssigneePicker)
const mockAuthUser = { id: "user-1", email: "test@test.com", name: "Test User" };
vi.mock("@multica/core/auth", () => ({
useAuthStore: Object.assign(
(selector?: any) => {
const state = { user: mockAuthUser, isAuthenticated: true };
return selector ? selector(state) : state;
},
{ getState: () => ({ user: mockAuthUser, isAuthenticated: true }) },
),
registerAuthStore: vi.fn(),
createAuthStore: vi.fn(),
}));
// Mock @multica/core/workspace (used by @multica/views components)
vi.mock("@multica/core/workspace", () => ({
useWorkspaceStore: Object.assign(
(selector?: any) => {
const state = { workspace: { id: "ws-1", name: "Test", slug: "test" }, agents: [], members: [] };
return selector ? selector(state) : state;
},
{ getState: () => ({ workspace: { id: "ws-1", name: "Test", slug: "test" }, agents: [], members: [] }) },
),
registerWorkspaceStore: vi.fn(),
}));
vi.mock("@/platform/workspace", () => ({
useWorkspaceStore: Object.assign(
(selector?: any) => {
const state = { workspace: { id: "ws-1", name: "Test", slug: "test" }, agents: [], members: [] };
return selector ? selector(state) : state;
},
{ getState: () => ({ workspace: { id: "ws-1", name: "Test", slug: "test" }, agents: [], members: [] }) },
),
}));
// 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" }),
NavigationProvider: ({ children }: { children: React.ReactNode }) => children,
}));
// Mock @multica/views/workspace/workspace-avatar
vi.mock("@multica/views/workspace/workspace-avatar", () => ({
WorkspaceAvatar: ({ name }: { name: string }) => <span>{name.charAt(0)}</span>,
}));
// Mock WebSocket context
vi.mock("@/features/realtime", () => ({
useWSEvent: vi.fn(),
@@ -109,39 +59,38 @@ vi.mock("sonner", () => ({
toast: { error: vi.fn(), success: vi.fn() },
}));
// Mock api (core queries/mutations use @multica/core/api)
// Mock api
const mockUpdateIssue = vi.fn();
const mockListIssues = vi.hoisted(() => vi.fn().mockResolvedValue({ issues: [], total: 0 }));
vi.mock("@multica/core/api", () => ({
vi.mock("@/shared/api", () => ({
api: {
listIssues: (...args: any[]) => mockListIssues(...args),
listIssues: vi.fn().mockResolvedValue({ issues: [], total: 0 }),
updateIssue: (...args: any[]) => mockUpdateIssue(...args),
listMembers: () => Promise.resolve([]),
listAgents: () => Promise.resolve([]),
},
getApi: () => ({
listIssues: (...args: any[]) => mockListIssues(...args),
updateIssue: (...args: any[]) => mockUpdateIssue(...args),
listMembers: () => Promise.resolve([]),
listAgents: () => Promise.resolve([]),
}),
setApiInstance: vi.fn(),
}));
// Mock issue store — only client state remains
const mockIssueClientState = { activeIssueId: null, setActiveIssue: vi.fn() };
vi.mock("@multica/core/issues", () => ({
// Mock the issue store
let mockStoreState: {
issues: Issue[];
loading: boolean;
fetch: () => Promise<void>;
setIssues: (issues: Issue[]) => void;
addIssue: (issue: Issue) => void;
updateIssue: (id: string, updates: Partial<Issue>) => void;
removeIssue: (id: string) => void;
};
vi.mock("@/features/issues/store", () => ({
useIssueStore: Object.assign(
(selector?: any) => (selector ? selector(mockIssueClientState) : mockIssueClientState),
{ getState: () => mockIssueClientState },
(selector?: any) => (selector ? selector(mockStoreState) : mockStoreState),
{ getState: () => mockStoreState },
),
}));
vi.mock("@/features/issues", () => ({
useIssueStore: Object.assign(
(selector?: any) => (selector ? selector(mockIssueClientState) : mockIssueClientState),
{ getState: () => mockIssueClientState },
(selector?: any) => (selector ? selector(mockStoreState) : mockStoreState),
{ getState: () => mockStoreState },
),
StatusIcon: () => null,
PriorityIcon: () => null,
@@ -180,17 +129,12 @@ const mockViewState = {
toggleListCollapsed: vi.fn(),
};
vi.mock("@multica/core/issues/stores/view-store", () => ({
vi.mock("@/features/issues/stores/view-store", () => ({
initFilterWorkspaceSync: vi.fn(),
useIssueViewStore: Object.assign(
(selector?: any) => (selector ? selector(mockViewState) : mockViewState),
{ getState: () => mockViewState, setState: vi.fn() },
),
createIssueViewStore: () => ({
getState: () => mockViewState,
setState: vi.fn(),
subscribe: vi.fn(),
}),
SORT_OPTIONS: [
{ value: "position", label: "Manual" },
{ value: "priority", label: "Priority" },
@@ -207,36 +151,14 @@ vi.mock("@multica/core/issues/stores/view-store", () => ({
}));
// Mock view store context (shared components read from context)
vi.mock("@multica/core/issues/stores/view-store-context", () => ({
vi.mock("@/features/issues/stores/view-store-context", () => ({
ViewStoreProvider: ({ children }: { children: React.ReactNode }) => children,
useViewStore: (selector?: any) => (selector ? selector(mockViewState) : mockViewState),
useViewStoreApi: () => ({ getState: () => mockViewState, setState: vi.fn(), subscribe: vi.fn() }),
}));
// Mock issues scope store
vi.mock("@multica/core/issues/stores/issues-scope-store", () => ({
useIssuesScopeStore: Object.assign(
(selector?: any) => {
const state = { scope: "all", setScope: vi.fn() };
return selector ? selector(state) : state;
},
{ getState: () => ({ scope: "all", setScope: vi.fn() }) },
),
}));
// Mock selection store
vi.mock("@multica/core/issues/stores/selection-store", () => ({
useIssueSelectionStore: Object.assign(
(selector?: any) => {
const state = { selectedIds: new Set(), toggle: vi.fn(), clear: vi.fn(), setAll: vi.fn() };
return selector ? selector(state) : state;
},
{ getState: () => ({ selectedIds: new Set(), toggle: vi.fn(), clear: vi.fn(), setAll: vi.fn() }) },
),
}));
// Mock issue config
vi.mock("@multica/core/issues/config", () => ({
vi.mock("@/features/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"],
@@ -260,13 +182,6 @@ vi.mock("@multica/core/issues/config", () => ({
}));
// Mock modals
vi.mock("@multica/core/modals", () => ({
useModalStore: Object.assign(
() => ({ open: vi.fn() }),
{ getState: () => ({ open: vi.fn() }) },
),
}));
vi.mock("@/features/modals", () => ({
useModalStore: Object.assign(
() => ({ open: vi.fn() }),
@@ -368,86 +283,90 @@ const mockIssues: Issue[] = [
import IssuesPage from "./page";
function renderWithQuery(ui: React.ReactElement) {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 }, mutations: { retry: false } } });
return render(
<QueryClientProvider client={qc}>
<WorkspaceIdProvider wsId="ws-1">
{ui}
</WorkspaceIdProvider>
</QueryClientProvider>,
);
}
describe("IssuesPage", () => {
beforeEach(() => {
vi.clearAllMocks();
mockListIssues.mockResolvedValue({ issues: [], total: 0 });
mockStoreState = {
issues: [],
loading: true,
fetch: vi.fn(),
setIssues: vi.fn(),
addIssue: vi.fn(),
updateIssue: vi.fn(),
removeIssue: vi.fn(),
};
mockViewState.viewMode = "board";
mockViewState.statusFilters = [];
mockViewState.priorityFilters = [];
});
it("shows loading state initially", () => {
renderWithQuery(<IssuesPage />);
mockStoreState.loading = true;
mockStoreState.issues = [];
render(<IssuesPage />);
expect(screen.getAllByRole("generic").some(el => el.getAttribute("data-slot") === "skeleton")).toBe(true);
});
it("renders issues in board view after loading", async () => {
// issueListOptions makes 2 calls: open_only + closed page. Return issues for open, empty for closed.
mockListIssues.mockImplementation((params: any) =>
Promise.resolve(params?.open_only ? { issues: mockIssues, total: mockIssues.length } : { issues: [], total: 0 }),
);
mockStoreState.loading = false;
mockStoreState.issues = mockIssues;
renderWithQuery(<IssuesPage />);
render(<IssuesPage />);
await screen.findByText("Implement auth");
expect(screen.getByText("Implement auth")).toBeInTheDocument();
expect(screen.getByText("Design landing page")).toBeInTheDocument();
expect(screen.getByText("Write tests")).toBeInTheDocument();
});
it("renders board columns", async () => {
mockListIssues.mockImplementation((params: any) =>
Promise.resolve(params?.open_only ? { issues: mockIssues, total: mockIssues.length } : { issues: [], total: 0 }),
);
mockStoreState.loading = false;
mockStoreState.issues = mockIssues;
renderWithQuery(<IssuesPage />);
render(<IssuesPage />);
await screen.findByText("Backlog");
expect(screen.getAllByText("Backlog").length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText("Todo").length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText("In Progress").length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText("In Review").length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText("Done").length).toBeGreaterThanOrEqual(1);
});
it("shows workspace breadcrumb", async () => {
renderWithQuery(<IssuesPage />);
it("shows workspace breadcrumb", () => {
mockStoreState.loading = false;
mockStoreState.issues = [];
await screen.findByText("Issues");
render(<IssuesPage />);
expect(screen.getByText("Issues")).toBeInTheDocument();
});
it("shows scope buttons", async () => {
renderWithQuery(<IssuesPage />);
it("shows scope buttons", () => {
mockStoreState.loading = false;
mockStoreState.issues = [];
await screen.findByText("All");
render(<IssuesPage />);
expect(screen.getByText("All")).toBeInTheDocument();
expect(screen.getByText("Members")).toBeInTheDocument();
expect(screen.getByText("Agents")).toBeInTheDocument();
});
it("shows filter and display icon buttons", async () => {
mockListIssues.mockImplementation((params: any) =>
Promise.resolve(params?.open_only ? { issues: mockIssues, total: mockIssues.length } : { issues: [], total: 0 }),
);
it("shows filter and display icon buttons", () => {
mockStoreState.loading = false;
mockStoreState.issues = mockIssues;
renderWithQuery(<IssuesPage />);
render(<IssuesPage />);
await screen.findByText("Implement auth");
// Filter and Display are now icon-only buttons, verify they render as buttons
const buttons = screen.getAllByRole("button");
expect(buttons.length).toBeGreaterThan(0);
});
it("shows empty board view when no issues exist", () => {
renderWithQuery(<IssuesPage />);
mockStoreState.loading = false;
mockStoreState.issues = [];
render(<IssuesPage />);
// Should still render the board/list view, not a "no issues" message
expect(screen.queryByText("No matching issues")).not.toBeInTheDocument();

View File

@@ -1,6 +1,6 @@
"use client";
import { IssuesPage } from "@multica/views/issues/components";
import { IssuesPage } from "@/features/issues/components/issues-page";
export default function Page() {
return <IssuesPage />;

View File

@@ -1,35 +1,57 @@
"use client";
import { Search } from "lucide-react";
import { SidebarMenuButton } from "@multica/ui/components/ui/sidebar";
import { DashboardLayout } from "@multica/views/layout";
import { MulticaIcon } from "@multica/ui/components/common/multica-icon";
import { SearchCommand, useSearchStore } from "@/features/search";
import { ChatFab, ChatWindow } from "@/features/chat";
import { useEffect } from "react";
import { useRouter, usePathname } from "next/navigation";
import { MulticaIcon } from "@/components/multica-icon";
import { useNavigationStore } from "@/features/navigation";
import { SidebarProvider, SidebarInset } from "@/components/ui/sidebar";
import { useAuthStore } from "@/features/auth";
import { useWorkspaceStore } from "@/features/workspace";
import { AppSidebar } from "./_components/app-sidebar";
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;
function SearchTrigger() {
return (
<SidebarMenuButton
className="text-muted-foreground"
onClick={() => useSearchStore.getState().setOpen(true)}
>
<Search />
<span>Search...</span>
<kbd className="pointer-events-none ml-auto inline-flex h-5 select-none items-center gap-0.5 rounded border bg-muted px-1.5 font-mono text-[10px] font-medium text-muted-foreground">
<span className="text-xs"></span>K
</kbd>
</SidebarMenuButton>
);
}
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<DashboardLayout
loadingIndicator={<MulticaIcon className="size-6" />}
searchSlot={<SearchTrigger />}
extra={<><SearchCommand /><ChatWindow /><ChatFab /></>}
>
{children}
</DashboardLayout>
<SidebarProvider className="h-svh">
<AppSidebar />
<SidebarInset className="overflow-hidden">
{workspace ? (
children
) : (
<div className="flex flex-1 items-center justify-center">
<MulticaIcon className="size-6 animate-pulse" />
</div>
)}
</SidebarInset>
</SidebarProvider>
);
}

View File

@@ -1,28 +0,0 @@
import { Skeleton } from "@multica/ui/components/ui/skeleton";
export default function DashboardLoading() {
return (
<div className="flex flex-1 min-h-0 flex-col">
{/* Header skeleton */}
<div className="flex h-12 shrink-0 items-center gap-2 border-b px-4">
<Skeleton className="h-5 w-5 rounded" />
<Skeleton className="h-4 w-32" />
</div>
{/* Toolbar skeleton */}
<div className="flex h-12 shrink-0 items-center justify-between border-b px-4">
<Skeleton className="h-5 w-24" />
<Skeleton className="h-8 w-24" />
</div>
{/* Content skeleton */}
<div className="flex-1 p-4 space-y-3">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="flex items-center gap-3">
<Skeleton className="h-4 w-4 rounded" />
<Skeleton className="h-4 flex-1 max-w-md" />
<Skeleton className="h-4 w-16" />
</div>
))}
</div>
</div>
);
}

View File

@@ -1,6 +1,6 @@
"use client";
import { MyIssuesPage } from "@multica/views/my-issues";
import { MyIssuesPage } from "@/features/my-issues";
export default function Page() {
return <MyIssuesPage />;

View File

@@ -1,13 +1,9 @@
"use client";
import { use } from "react";
import { ProjectDetail } from "@multica/views/projects/components";
import { ProjectDetailPage } from "@/features/projects/components/project-detail-page";
export default function ProjectDetailPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
export default function Page({ params }: { params: Promise<{ id: string }> }) {
const { id } = use(params);
return <ProjectDetail projectId={id} />;
return <ProjectDetailPage projectId={id} />;
}

View File

@@ -1,6 +1,6 @@
"use client";
import { ProjectsPage } from "@multica/views/projects/components";
import { ProjectsPage } from "@/features/projects/components/projects-page";
export default function Page() {
return <ProjectsPage />;

View File

@@ -1 +1 @@
export { RuntimesPage as default } from "@multica/views/runtimes";
export { RuntimesPage as default } from "@/features/runtimes";

View File

@@ -2,14 +2,14 @@
import { useEffect, useRef, useState } from "react";
import { Camera, Loader2, Save } from "lucide-react";
import { Input } from "@multica/ui/components/ui/input";
import { Label } from "@multica/ui/components/ui/label";
import { Button } from "@multica/ui/components/ui/button";
import { Card, CardContent } from "@multica/ui/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { toast } from "sonner";
import { useAuthStore } from "@multica/core/auth";
import { api } from "@multica/core/api";
import { useFileUpload } from "@multica/core/hooks/use-file-upload";
import { useAuthStore } from "@/features/auth";
import { api } from "@/shared/api";
import { useFileUpload } from "@/shared/hooks/use-file-upload";
export function AccountTab() {
const user = useAuthStore((s) => s.user);
@@ -17,7 +17,7 @@ export function AccountTab() {
const [profileName, setProfileName] = useState(user?.name ?? "");
const [profileSaving, setProfileSaving] = useState(false);
const { upload, uploading } = useFileUpload(api);
const { upload, uploading } = useFileUpload();
const fileInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {

View File

@@ -1,7 +1,7 @@
"use client";
import { useTheme } from "@multica/ui/components/common/theme-provider";
import { cn } from "@multica/ui/lib/utils";
import { useTheme } from "next-themes";
import { cn } from "@/lib/utils";
const LIGHT_COLORS = {
titleBar: "#e8e8e8",

View File

@@ -2,12 +2,12 @@
import { useState } from "react";
import { Crown, Shield, User, Plus, MoreHorizontal, UserMinus, Users } from "lucide-react";
import { ActorAvatar } from "../../common/actor-avatar";
import type { MemberWithUser, MemberRole } from "@multica/core/types";
import { Input } from "@multica/ui/components/ui/input";
import { Button } from "@multica/ui/components/ui/button";
import { Card, CardContent } from "@multica/ui/components/ui/card";
import { Badge } from "@multica/ui/components/ui/badge";
import { ActorAvatar } from "@/components/common/actor-avatar";
import type { MemberWithUser, MemberRole } from "@/shared/types";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import {
AlertDialog,
AlertDialogContent,
@@ -17,14 +17,14 @@ import {
AlertDialogFooter,
AlertDialogCancel,
AlertDialogAction,
} from "@multica/ui/components/ui/alert-dialog";
} from "@/components/ui/alert-dialog";
import {
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
} from "@multica/ui/components/ui/select";
} from "@/components/ui/select";
import {
DropdownMenu,
DropdownMenuTrigger,
@@ -34,14 +34,11 @@ import {
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
} from "@multica/ui/components/ui/dropdown-menu";
} from "@/components/ui/dropdown-menu";
import { toast } from "sonner";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useAuthStore } from "@multica/core/auth";
import { useWorkspaceStore } from "@multica/core/workspace";
import { useWorkspaceId } from "@multica/core/hooks";
import { memberListOptions, workspaceKeys } from "@multica/core/workspace/queries";
import { api } from "@multica/core/api";
import { useAuthStore } from "@/features/auth";
import { useWorkspaceStore } from "@/features/workspace";
import { api } from "@/shared/api";
const roleConfig: Record<MemberRole, { label: string; icon: typeof Crown; description: string }> = {
owner: { label: "Owner", icon: Crown, description: "Full access, manage all settings" },
@@ -143,9 +140,8 @@ function MemberRow({
export function MembersTab() {
const user = useAuthStore((s) => s.user);
const workspace = useWorkspaceStore((s) => s.workspace);
const qc = useQueryClient();
const wsId = useWorkspaceId();
const { data: members = [] } = useQuery(memberListOptions(wsId));
const members = useWorkspaceStore((s) => s.members);
const refreshMembers = useWorkspaceStore((s) => s.refreshMembers);
const [inviteEmail, setInviteEmail] = useState("");
const [inviteRole, setInviteRole] = useState<MemberRole>("member");
@@ -172,7 +168,7 @@ export function MembersTab() {
});
setInviteEmail("");
setInviteRole("member");
qc.invalidateQueries({ queryKey: workspaceKeys.members(wsId) });
await refreshMembers();
toast.success("Member added");
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed to add member");
@@ -186,7 +182,7 @@ export function MembersTab() {
setMemberActionId(memberId);
try {
await api.updateMember(workspace.id, memberId, { role });
qc.invalidateQueries({ queryKey: workspaceKeys.members(wsId) });
await refreshMembers();
toast.success("Role updated");
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed to update member");
@@ -205,7 +201,7 @@ export function MembersTab() {
setMemberActionId(member.id);
try {
await api.deleteMember(workspace.id, member.id);
qc.invalidateQueries({ queryKey: workspaceKeys.members(wsId) });
await refreshMembers();
toast.success("Member removed");
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed to remove member");

View File

@@ -2,23 +2,19 @@
import { useEffect, useState } from "react";
import { Save, Plus, Trash2 } from "lucide-react";
import { Input } from "@multica/ui/components/ui/input";
import { Button } from "@multica/ui/components/ui/button";
import { Card, CardContent } from "@multica/ui/components/ui/card";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { toast } from "sonner";
import { useQuery } from "@tanstack/react-query";
import { useAuthStore } from "@multica/core/auth";
import { useWorkspaceStore } from "@multica/core/workspace";
import { useWorkspaceId } from "@multica/core/hooks";
import { memberListOptions } from "@multica/core/workspace/queries";
import { api } from "@multica/core/api";
import type { WorkspaceRepo } from "@multica/core/types";
import { useAuthStore } from "@/features/auth";
import { useWorkspaceStore } from "@/features/workspace";
import { api } from "@/shared/api";
import type { WorkspaceRepo } from "@/shared/types";
export function RepositoriesTab() {
const user = useAuthStore((s) => s.user);
const workspace = useWorkspaceStore((s) => s.workspace);
const wsId = useWorkspaceId();
const { data: members = [] } = useQuery(memberListOptions(wsId));
const members = useWorkspaceStore((s) => s.members);
const updateWorkspace = useWorkspaceStore((s) => s.updateWorkspace);
const [repos, setRepos] = useState<WorkspaceRepo[]>(workspace?.repos ?? []);

View File

@@ -2,18 +2,18 @@
import { useEffect, useState, useCallback } from "react";
import { Key, Trash2, Copy, Check } from "lucide-react";
import { Tooltip, TooltipTrigger, TooltipContent } from "@multica/ui/components/ui/tooltip";
import type { PersonalAccessToken } from "@multica/core/types";
import { Input } from "@multica/ui/components/ui/input";
import { Button } from "@multica/ui/components/ui/button";
import { Card, CardContent } from "@multica/ui/components/ui/card";
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
import type { PersonalAccessToken } from "@/shared/types";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import {
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
} from "@multica/ui/components/ui/select";
} from "@/components/ui/select";
import {
Dialog,
DialogContent,
@@ -21,7 +21,7 @@ import {
DialogTitle,
DialogDescription,
DialogFooter,
} from "@multica/ui/components/ui/dialog";
} from "@/components/ui/dialog";
import {
AlertDialog,
AlertDialogAction,
@@ -31,10 +31,10 @@ import {
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@multica/ui/components/ui/alert-dialog";
import { Skeleton } from "@multica/ui/components/ui/skeleton";
} from "@/components/ui/alert-dialog";
import { Skeleton } from "@/components/ui/skeleton";
import { toast } from "sonner";
import { api } from "@multica/core/api";
import { api } from "@/shared/api";
export function TokensTab() {
const [tokens, setTokens] = useState<PersonalAccessToken[]>([]);

View File

@@ -2,11 +2,11 @@
import { useEffect, useState } from "react";
import { Save, LogOut } from "lucide-react";
import { Input } from "@multica/ui/components/ui/input";
import { Textarea } from "@multica/ui/components/ui/textarea";
import { Label } from "@multica/ui/components/ui/label";
import { Button } from "@multica/ui/components/ui/button";
import { Card, CardContent } from "@multica/ui/components/ui/card";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import {
AlertDialog,
AlertDialogContent,
@@ -16,20 +16,16 @@ import {
AlertDialogFooter,
AlertDialogCancel,
AlertDialogAction,
} from "@multica/ui/components/ui/alert-dialog";
} from "@/components/ui/alert-dialog";
import { toast } from "sonner";
import { useQuery } from "@tanstack/react-query";
import { useAuthStore } from "@multica/core/auth";
import { useWorkspaceStore } from "@multica/core/workspace";
import { useWorkspaceId } from "@multica/core/hooks";
import { memberListOptions } from "@multica/core/workspace/queries";
import { api } from "@multica/core/api";
import { useAuthStore } from "@/features/auth";
import { useWorkspaceStore } from "@/features/workspace";
import { api } from "@/shared/api";
export function WorkspaceTab() {
const user = useAuthStore((s) => s.user);
const workspace = useWorkspaceStore((s) => s.workspace);
const wsId = useWorkspaceId();
const { data: members = [] } = useQuery(memberListOptions(wsId));
const members = useWorkspaceStore((s) => s.members);
const updateWorkspace = useWorkspaceStore((s) => s.updateWorkspace);
const leaveWorkspace = useWorkspaceStore((s) => s.leaveWorkspace);
const deleteWorkspace = useWorkspaceStore((s) => s.deleteWorkspace);

View File

@@ -1 +1,71 @@
export { SettingsPage as default } from "@multica/views/settings";
"use client";
import { User, Palette, Key, Settings, Users, FolderGit2 } from "lucide-react";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import { useWorkspaceStore } from "@/features/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>
);
}

View File

@@ -1 +1 @@
export { SkillsPage as default } from "@multica/views/skills";
export { SkillsPage as default } from "@/features/skills";

View File

@@ -1,90 +0,0 @@
"use client";
import { Suspense, useEffect, useState } from "react";
import { useSearchParams, useRouter } from "next/navigation";
import { useAuthStore } from "@multica/core/auth";
import { useWorkspaceStore } from "@multica/core/workspace";
import { api } from "@multica/core/api";
import {
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
} from "@multica/ui/components/ui/card";
import { Loader2 } from "lucide-react";
function CallbackContent() {
const router = useRouter();
const searchParams = useSearchParams();
const loginWithGoogle = useAuthStore((s) => s.loginWithGoogle);
const hydrateWorkspace = useWorkspaceStore((s) => s.hydrateWorkspace);
const [error, setError] = useState("");
useEffect(() => {
const code = searchParams.get("code");
if (!code) {
setError("Missing authorization code");
return;
}
const errorParam = searchParams.get("error");
if (errorParam) {
setError(errorParam === "access_denied" ? "Access denied" : errorParam);
return;
}
const redirectUri = `${window.location.origin}/auth/callback`;
loginWithGoogle(code, redirectUri)
.then(async () => {
const wsList = await api.listWorkspaces();
const lastWsId = localStorage.getItem("multica_workspace_id");
await hydrateWorkspace(wsList, lastWsId);
router.push("/issues");
})
.catch((err) => {
setError(err instanceof Error ? err.message : "Login failed");
});
}, [searchParams, loginWithGoogle, hydrateWorkspace, router]);
if (error) {
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">Login Failed</CardTitle>
<CardDescription>{error}</CardDescription>
</CardHeader>
<CardContent className="flex justify-center">
<a href="/login" className="text-primary underline-offset-4 hover:underline">
Back to login
</a>
</CardContent>
</Card>
</div>
);
}
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">Signing in...</CardTitle>
<CardDescription>Please wait while we complete your login</CardDescription>
</CardHeader>
<CardContent className="flex justify-center">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</CardContent>
</Card>
</div>
);
}
export default function CallbackPage() {
return (
<Suspense fallback={null}>
<CallbackContent />
</Suspense>
);
}

View File

@@ -1,5 +1,32 @@
/* =============================================================================
* Multica Web — Custom styles (non-shadcn, web-only)
* Shared styles (shiki, entrance-spin, sidebar, sonner, scrollbar) are in
* @multica/ui/styles/base.css
* Multica Web — Custom styles (non-shadcn)
* ============================================================================= */
/* 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);
}

View File

@@ -1,12 +1,164 @@
@import "tailwindcss";
@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 *));
@source "../../../packages/ui/**/*.{ts,tsx}";
@source "../../../packages/core/**/*.{ts,tsx}";
@source "../../../packages/views/**/*.{ts,tsx}";
@theme inline {
--font-heading: var(--font-sans);
--font-sans: var(--font-sans);
--font-mono: var(--font-mono);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-success: var(--success);
--color-warning: var(--warning);
--color-info: var(--info);
--color-brand: var(--brand);
--color-brand-foreground: var(--brand-foreground);
--color-priority: var(--priority);
--color-canvas: var(--canvas);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--color-foreground: var(--foreground);
--color-background: var(--background);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4);
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
}
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.141 0.005 285.823);
--card: oklch(1 0 0);
--card-foreground: oklch(0.141 0.005 285.823);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.141 0.005 285.823);
--primary: oklch(0.21 0.006 285.885);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.967 0.001 286.375);
--secondary-foreground: oklch(0.21 0.006 285.885);
--muted: oklch(0.967 0.001 286.375);
--muted-foreground: oklch(0.552 0.016 285.938);
--accent: oklch(0.967 0.001 286.375);
--accent-foreground: oklch(0.21 0.006 285.885);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.92 0.004 286.32);
--input: oklch(0.92 0.004 286.32);
--ring: oklch(0.705 0.015 286.067);
--chart-1: oklch(0.871 0.006 286.286);
--chart-2: oklch(0.552 0.016 285.938);
--chart-3: oklch(0.442 0.017 285.786);
--chart-4: oklch(0.37 0.013 285.805);
--chart-5: oklch(0.274 0.006 286.033);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.141 0.005 285.823);
--sidebar-primary: oklch(0.21 0.006 285.885);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.95 0.002 286.375);
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
--sidebar-border: oklch(0.92 0.004 286.32);
--sidebar-ring: oklch(0.705 0.015 286.067);
--brand: oklch(0.55 0.16 255);
--brand-foreground: oklch(0.985 0 0);
--canvas: oklch(0.95 0.002 286);
--success: oklch(0.55 0.16 145);
--warning: oklch(0.75 0.16 85);
--info: oklch(0.55 0.18 250);
--priority: oklch(0.65 0.18 50);
--scrollbar-thumb: oklch(0 0 0 / 10%);
--scrollbar-thumb-hover: oklch(0 0 0 / 18%);
--scrollbar-track: transparent;
}
.dark {
--background: oklch(0.18 0.005 285.823);
--foreground: oklch(0.985 0 0);
--card: oklch(0.21 0.006 285.885);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.21 0.006 285.885);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.92 0.004 286.32);
--primary-foreground: oklch(0.21 0.006 285.885);
--secondary: oklch(0.274 0.006 286.033);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.274 0.006 286.033);
--muted-foreground: oklch(0.705 0.015 286.067);
--accent: oklch(0.274 0.006 286.033);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.552 0.016 285.938);
--chart-1: oklch(0.871 0.006 286.286);
--chart-2: oklch(0.552 0.016 285.938);
--chart-3: oklch(0.442 0.017 285.786);
--chart-4: oklch(0.37 0.013 285.805);
--chart-5: oklch(0.274 0.006 286.033);
--sidebar: oklch(0.21 0.006 285.885);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.274 0.006 286.033);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.552 0.016 285.938);
--brand: oklch(0.65 0.16 255);
--brand-foreground: oklch(0.985 0 0);
--canvas: oklch(0.2 0.005 286);
--success: oklch(0.65 0.15 145);
--warning: oklch(0.70 0.16 85);
--info: oklch(0.65 0.18 250);
--priority: oklch(0.70 0.18 50);
--scrollbar-thumb: oklch(1 0 0 / 8%);
--scrollbar-thumb-hover: oklch(1 0 0 / 18%);
--scrollbar-track: transparent;
}
@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;
}
}

View File

@@ -1,10 +1,12 @@
import type { Metadata, Viewport } from "next";
import { cookies } from "next/headers";
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 { WebProviders } from "@/components/web-providers";
import { LocaleSync } from "@/components/locale-sync";
import { Toaster } from "@/components/ui/sonner";
import { cn } from "@/lib/utils";
import { AuthInitializer } from "@/features/auth";
import { WSProvider } from "@/features/realtime";
import { ModalRegistry } from "@/features/modals";
import "./globals.css";
const geist = Geist({ subsets: ["latin"], variable: "--font-sans" });
@@ -38,8 +40,6 @@ export const metadata: Metadata = {
},
twitter: {
card: "summary_large_image",
site: "@multica_hq",
creator: "@multica_hq",
},
alternates: {
canonical: "/",
@@ -50,23 +50,27 @@ export const metadata: Metadata = {
},
};
export default function RootLayout({
export default async function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
const cookieStore = await cookies();
const locale = cookieStore.get("multica-locale")?.value;
const lang = locale === "zh" ? "zh" : "en";
return (
<html
lang="en"
lang={lang}
suppressHydrationWarning
className={cn("antialiased font-sans h-full", geist.variable, geistMono.variable)}
>
<body className="h-full overflow-hidden">
<LocaleSync />
<ThemeProvider>
<WebProviders>
{children}
</WebProviders>
<AuthInitializer>
<WSProvider>{children}</WSProvider>
</AuthInitializer>
<ModalRegistry />
<Toaster />
</ThemeProvider>
</body>

View File

@@ -13,11 +13,11 @@
"iconLibrary": "lucide",
"rtl": false,
"aliases": {
"components": "@multica/ui/components",
"utils": "@multica/ui/lib/utils",
"ui": "@multica/ui/components/ui",
"lib": "@multica/ui/lib",
"hooks": "@multica/ui/hooks"
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"menuColor": "default",
"menuAccent": "subtle",

View File

@@ -2,31 +2,46 @@
import { useState, useEffect } from "react";
import { Bot } from "lucide-react";
import { cn } from "@multica/ui/lib/utils";
import { cn } from "@/lib/utils";
import { useActorName } from "@/features/workspace";
interface ActorAvatarProps {
name: string;
initials: string;
avatarUrl?: string | null;
isAgent?: boolean;
actorType: string;
actorId: string;
size?: number;
avatarUrl?: string | null;
getName?: (type: string, id: string) => string;
getInitials?: (type: string, id: string) => string;
getAvatarUrl?: (type: string, id: string) => string | null;
className?: string;
}
function ActorAvatar({
name,
initials,
avatarUrl,
isAgent,
actorType,
actorId,
size = 20,
avatarUrl,
getName,
getInitials,
getAvatarUrl,
className,
}: ActorAvatarProps) {
const actorNameHook = useActorName();
const resolveName = getName ?? actorNameHook.getActorName;
const resolveInitials = getInitials ?? actorNameHook.getActorInitials;
const resolveAvatarUrl = getAvatarUrl ?? actorNameHook.getActorAvatarUrl;
const name = resolveName(actorType, actorId);
const initials = resolveInitials(actorType, actorId);
const isAgent = actorType === "agent";
const resolvedUrl = avatarUrl !== undefined ? avatarUrl : resolveAvatarUrl(actorType, actorId);
const [imgError, setImgError] = useState(false);
// Reset error state when URL changes (e.g. user uploads new avatar)
useEffect(() => {
setImgError(false);
}, [avatarUrl]);
}, [resolvedUrl]);
return (
<div
@@ -39,9 +54,9 @@ function ActorAvatar({
style={{ width: size, height: size, fontSize: size * 0.45 }}
title={name}
>
{avatarUrl && !imgError ? (
{resolvedUrl && !imgError ? (
<img
src={avatarUrl}
src={resolvedUrl}
alt={name}
className="h-full w-full object-cover"
onError={() => setImgError(true)}

View File

@@ -2,7 +2,7 @@
import { useRef } from "react";
import { Paperclip } from "lucide-react";
import { cn } from "@multica/ui/lib/utils";
import { cn } from "@/lib/utils";
interface FileUploadButtonProps {
/** Called with the selected File — caller handles upload. */

View File

@@ -0,0 +1,89 @@
"use client";
import type { ReactNode } from "react";
import { Users } from "lucide-react";
import { HoverCard, HoverCardTrigger, HoverCardContent } from "@/components/ui/hover-card";
import { ActorAvatar } from "@/components/common/actor-avatar";
import { useWorkspaceStore } from "@/features/workspace";
interface MentionHoverCardProps {
type: string;
id: string;
children: ReactNode;
}
function MentionHoverCard({ type, id, children }: MentionHoverCardProps) {
const members = useWorkspaceStore((s) => s.members);
const agents = useWorkspaceStore((s) => s.agents);
if (type === "all") {
return (
<HoverCard>
<HoverCardTrigger render={<span />} className="cursor-default">
{children}
</HoverCardTrigger>
<HoverCardContent align="start" className="w-auto min-w-48 max-w-72">
<div className="flex items-center gap-2.5">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-primary/10">
<Users className="h-4 w-4 text-primary" />
</div>
<div className="min-w-0">
<p className="text-sm font-medium">All members</p>
<p className="text-xs text-muted-foreground">Notifies all workspace members</p>
</div>
</div>
</HoverCardContent>
</HoverCard>
);
}
if (type === "member") {
const member = members.find((m) => m.user_id === id);
if (!member) return <>{children}</>;
return (
<HoverCard>
<HoverCardTrigger render={<span />} className="cursor-default">
{children}
</HoverCardTrigger>
<HoverCardContent align="start" className="w-auto min-w-48 max-w-72">
<div className="flex items-center gap-2.5">
<ActorAvatar actorType="member" actorId={id} size={32} />
<div className="min-w-0">
<p className="text-sm font-medium truncate">{member.name}</p>
<p className="text-xs text-muted-foreground truncate">{member.email}</p>
</div>
</div>
</HoverCardContent>
</HoverCard>
);
}
if (type === "agent") {
const agent = agents.find((a) => a.id === id);
if (!agent) return <>{children}</>;
return (
<HoverCard>
<HoverCardTrigger render={<span />} className="cursor-default">
{children}
</HoverCardTrigger>
<HoverCardContent align="start" className="w-auto min-w-48 max-w-72">
<div className="flex items-center gap-2.5">
<ActorAvatar actorType="agent" actorId={id} size={32} />
<div className="min-w-0">
<p className="text-sm font-medium truncate">{agent.name}</p>
{agent.description && (
<p className="text-xs text-muted-foreground truncate">{agent.description}</p>
)}
</div>
</div>
</HoverCardContent>
</HoverCard>
);
}
return <>{children}</>;
}
export { MentionHoverCard };

View File

@@ -2,10 +2,10 @@
import { useState, lazy, Suspense } from "react";
import { SmilePlus } from "lucide-react";
import { Popover, PopoverTrigger, PopoverContent } from "@multica/ui/components/ui/popover";
import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover";
const EmojiPicker = lazy(() =>
import("./emoji-picker").then((m) => ({ default: m.EmojiPicker })),
import("@/components/common/emoji-picker").then((m) => ({ default: m.EmojiPicker })),
);
const QUICK_EMOJIS = ["👍", "👌", "❤️", "😄", "🎉", "😕", "🚀", "👀"];

View File

@@ -1,7 +1,8 @@
"use client";
import { Tooltip, TooltipTrigger, TooltipContent } from "@multica/ui/components/ui/tooltip";
import { QuickEmojiPicker } from "./quick-emoji-picker";
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
import { QuickEmojiPicker } from "@/components/common/quick-emoji-picker";
import { useActorName } from "@/features/workspace";
interface ReactionItem {
id: string;
@@ -34,24 +35,21 @@ function groupReactions(reactions: ReactionItem[], currentUserId?: string): Grou
return Array.from(map.values());
}
interface ReactionBarProps {
reactions: ReactionItem[];
currentUserId?: string;
onToggle: (emoji: string) => void;
getActorName: (type: string, id: string) => string;
className?: string;
hideAddButton?: boolean;
}
function ReactionBar({
export function ReactionBar({
reactions,
currentUserId,
onToggle,
getActorName,
className,
hideAddButton,
}: ReactionBarProps) {
}: {
reactions: ReactionItem[];
currentUserId?: string;
onToggle: (emoji: string) => void;
className?: string;
hideAddButton?: boolean;
}) {
const grouped = groupReactions(reactions, currentUserId);
const { getActorName } = useActorName();
return (
<div className={`flex flex-wrap items-center gap-1.5 ${className ?? ""}`}>
@@ -82,5 +80,3 @@ function ReactionBar({
</div>
);
}
export { ReactionBar, type ReactionBarProps, type ReactionItem };

View File

@@ -1,7 +1,7 @@
"use client";
import { Spinner } from "@/components/spinner";
import { cn } from "@multica/ui/lib/utils";
import { cn } from "@/lib/utils";
export type LoadingVariant = "generating" | "streaming";

View File

@@ -1,20 +0,0 @@
"use client";
import { useEffect } from "react";
/**
* Reads the locale cookie on the client and updates <html lang>.
* This avoids calling cookies() in the root Server Component layout,
* which would mark the entire app as dynamic and disable the Router Cache.
*/
export function LocaleSync() {
useEffect(() => {
const match = document.cookie.match(/(?:^|;\s*)multica-locale=(\w+)/);
const locale = match?.[1];
if (locale === "zh") {
document.documentElement.lang = "zh";
}
}, []);
return null;
}

View File

@@ -1 +1,243 @@
export { CodeBlock, InlineCode, type CodeBlockProps } from '@multica/ui/markdown'
import * as React from 'react'
import { codeToHtml, bundledLanguages, type BundledLanguage } from 'shiki'
import { Copy, Check } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip"
import { cn } from '@/lib/utils'
export interface CodeBlockProps {
code: string
language?: string
className?: string
/**
* Render mode affects code block styling:
* - 'terminal': Minimal, keeps control chars visible
* - 'minimal': Clean code, basic styling
* - 'full': Rich styling with background, copy button, etc.
*/
mode?: 'terminal' | 'minimal' | 'full'
}
// Map common aliases to Shiki language names
const LANGUAGE_ALIASES: Record<string, BundledLanguage> = {
js: 'javascript',
ts: 'typescript',
py: 'python',
sh: 'bash',
zsh: 'bash',
yml: 'yaml',
rb: 'ruby',
rs: 'rust',
kt: 'kotlin',
'objective-c': 'objc',
objc: 'objc'
}
// Simple LRU cache for highlighted code
const highlightCache = new Map<string, string>()
const CACHE_MAX_SIZE = 200
function getCacheKey(code: string, lang: string): string {
return `${lang}:${code}`
}
function isValidLanguage(lang: string): lang is BundledLanguage {
const normalized = LANGUAGE_ALIASES[lang] || lang
return normalized in bundledLanguages
}
/**
* CodeBlock - Syntax highlighted code block using Shiki
*
* Uses Shiki dual themes with CSS variables for light/dark switching.
* No JS-based dark mode detection needed — theme switching is handled
* entirely via CSS (see globals.css for .shiki/.dark .shiki rules).
*
* @see https://shiki.style/guide/dual-themes
*/
export function CodeBlock({
code,
language = 'text',
className,
mode = 'full'
}: CodeBlockProps): React.JSX.Element {
const [highlighted, setHighlighted] = React.useState<string | null>(null)
const [isLoading, setIsLoading] = React.useState(true)
const [copied, setCopied] = React.useState(false)
// Resolve language alias - keep as string to allow 'text' fallback
const langLower = language.toLowerCase()
const resolvedLang: string = LANGUAGE_ALIASES[langLower] || langLower
React.useEffect(() => {
let cancelled = false
async function highlight(): Promise<void> {
const cacheKey = getCacheKey(code, resolvedLang)
const cached = highlightCache.get(cacheKey)
if (cached) {
if (!cancelled) {
setHighlighted(cached)
setIsLoading(false)
}
return
}
try {
// Use valid language or fallback to plaintext
const lang = isValidLanguage(resolvedLang) ? resolvedLang : 'text'
// Dual themes: Shiki outputs CSS variables for both themes in one pass.
// CSS handles switching via .dark selector (see globals.css).
const html = await codeToHtml(code, {
lang,
themes: {
light: 'github-light',
dark: 'github-dark',
},
defaultColor: false,
})
// Cache the result
if (highlightCache.size >= CACHE_MAX_SIZE) {
const firstKey = highlightCache.keys().next().value
if (firstKey) highlightCache.delete(firstKey)
}
highlightCache.set(cacheKey, html)
if (!cancelled) {
setHighlighted(html)
setIsLoading(false)
}
} catch (error) {
// Fallback to plain text on error
console.warn(`Shiki highlighting failed for language "${resolvedLang}":`, error)
if (!cancelled) {
setHighlighted(null)
setIsLoading(false)
}
}
}
highlight()
return () => {
cancelled = true
}
}, [code, resolvedLang])
const handleCopy = React.useCallback(async () => {
try {
await navigator.clipboard.writeText(code)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
} catch (err) {
console.error('Failed to copy code:', err)
}
}, [code])
// Terminal mode: raw monospace with minimal styling
if (mode === 'terminal') {
return (
<pre className={cn('font-mono text-sm whitespace-pre-wrap', className)}>
<code className="font-mono">{code}</code>
</pre>
)
}
// Minimal mode: just syntax highlighting, no chrome
if (mode === 'minimal') {
if (isLoading || !highlighted) {
return (
<pre className={cn('font-mono text-sm whitespace-pre-wrap', className)}>
<code className="font-mono">{code}</code>
</pre>
)
}
return (
<div
className={cn(
'font-mono text-sm [&_pre]:!bg-transparent [&_pre]:!p-0 [&_pre]:whitespace-pre-wrap [&_pre]:break-all [&_code]:!bg-transparent [&_code]:font-mono [&_pre]:font-mono',
className
)}
dangerouslySetInnerHTML={{ __html: highlighted }}
/>
)
}
// Full mode: rich styling with header and copy button
return (
<div
className={cn(
'relative group rounded-lg overflow-hidden border bg-muted/30 mb-4 last:mb-0',
className
)}
>
{/* Language label + copy button */}
<div className="flex items-center justify-between px-3 py-1.5 bg-muted/50 border-b text-xs">
<span className="text-muted-foreground font-medium uppercase tracking-wide">
{resolvedLang !== 'text' ? resolvedLang : 'plain text'}
</span>
<Tooltip>
<TooltipTrigger
render={
<Button
variant="ghost"
size="icon-xs"
onClick={handleCopy}
className="opacity-0 group-hover:opacity-100 transition-opacity text-muted-foreground hover:text-foreground"
aria-label="Copy code"
>
{copied ? (
<Check className="size-3.5 text-success" />
) : (
<Copy className="size-3.5" />
)}
</Button>
}
/>
<TooltipContent>Copy code</TooltipContent>
</Tooltip>
</div>
{/* Code content */}
<div className="p-3 overflow-x-auto">
{isLoading || !highlighted ? (
<pre className="font-mono text-sm whitespace-pre-wrap break-all">
<code className="font-mono">{code}</code>
</pre>
) : (
<div
className="font-mono text-sm [&_pre]:!bg-transparent [&_pre]:!m-0 [&_pre]:!p-0 [&_pre]:whitespace-pre-wrap [&_pre]:break-all [&_code]:!bg-transparent [&_code]:font-mono [&_pre]:font-mono"
dangerouslySetInnerHTML={{ __html: highlighted }}
/>
)}
</div>
</div>
)
}
/**
* InlineCode - Styled inline code span
* Features: subtle background (3%), subtle border (5%), 75% opacity text
*/
export function InlineCode({
children,
className
}: {
children: React.ReactNode
className?: string
}): React.JSX.Element {
return (
<code
className={cn(
'px-1.5 py-0.5 rounded bg-foreground/[0.03] border border-foreground/[0.05] font-mono text-sm text-foreground/75',
className
)}
>
{children}
</code>
)
}

View File

@@ -1,36 +1,332 @@
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
import ReactMarkdown, { type Components, defaultUrlTransform } from 'react-markdown'
import rehypeRaw from 'rehype-raw'
import remarkGfm from 'remark-gfm'
import { cn } from '@/lib/utils'
import { CodeBlock, InlineCode } from './CodeBlock'
import { preprocessLinks } from './linkify'
import { preprocessMentionShortcodes } from './mentions'
import { IssueMentionCard } from '@/features/issues/components/issue-mention-card'
/**
* Default renderMention that delegates to IssueMentionCard for issue mentions
* and renders a styled span for other mention types.
* Render modes for markdown content:
*
* - 'terminal': Raw output with minimal formatting, control chars visible
* Best for: Debug output, raw logs, when you want to see exactly what's there
*
* - 'minimal': Clean rendering with syntax highlighting but no extra chrome
* Best for: Chat messages, inline content, when you want readability without clutter
*
* - 'full': Rich rendering with beautiful tables, styled code blocks, proper typography
* Best for: Documentation, long-form content, when presentation matters
*/
function defaultRenderMention({ type, id }: { type: string; id: string }): React.ReactNode {
if (type === 'issue') {
return <IssueMentionCard issueId={id} />
export type RenderMode = 'terminal' | 'minimal' | 'full'
export interface MarkdownProps {
children: string
/**
* Render mode controlling formatting level
* @default 'minimal'
*/
mode?: RenderMode
className?: string
/**
* Message ID for memoization (optional)
* When provided, memoizes parsed blocks to avoid re-parsing during streaming
*/
id?: string
/**
* Callback when a URL is clicked
*/
onUrlClick?: (url: string) => void
/**
* Callback when a file path is clicked
*/
onFileClick?: (path: string) => void
}
/**
* Custom URL transform that allows mention:// protocol (used for @mentions)
* while keeping the default security for all other URLs.
*/
function urlTransform(url: string): string {
if (url.startsWith('mention://')) return url
return defaultUrlTransform(url)
}
// File path detection regex - matches paths starting with /, ~/, or ./
const FILE_PATH_REGEX =
/^(?:\/|~\/|\.\/)[\w\-./@]+\.(?:ts|tsx|js|jsx|mjs|cjs|md|json|yaml|yml|py|go|rs|css|scss|less|html|htm|txt|log|sh|bash|zsh|swift|kt|java|c|cpp|h|hpp|rb|php|xml|toml|ini|cfg|conf|env|sql|graphql|vue|svelte|astro|prisma)$/i
/**
* Create custom components based on render mode
*/
function createComponents(
mode: RenderMode,
onUrlClick?: (url: string) => void,
onFileClick?: (path: string) => void
): Partial<Components> {
const baseComponents: Partial<Components> = {
// Images: render uploaded images with constrained sizing
img: ({ src, alt }) => (
<img
src={src}
alt={alt ?? ""}
className="max-w-full h-auto rounded-md my-2"
loading="lazy"
/>
),
// Links: Make clickable with callbacks, or render as mention
a: ({ href, children }) => {
// Mention links: mention://member/id, mention://agent/id, mention://issue/id, mention://all/all
if (href?.startsWith('mention://')) {
const mentionMatch = href.match(/^mention:\/\/(member|agent|issue|all)\/(.+)$/)
if (mentionMatch?.[1] === 'issue' && mentionMatch[2]) {
const label = typeof children === 'string' ? children : Array.isArray(children) ? children.join('') : undefined
return <IssueMentionCard issueId={mentionMatch[2]} fallbackLabel={label} />
}
return (
<span className="text-primary font-semibold mx-0.5">
{children}
</span>
)
}
const handleClick = (e: React.MouseEvent): void => {
e.preventDefault()
if (href) {
// Check if it's a file path
if (FILE_PATH_REGEX.test(href) && onFileClick) {
onFileClick(href)
} else if (onUrlClick) {
onUrlClick(href)
} else {
// Default: open in new window
window.open(href, '_blank', 'noopener,noreferrer')
}
}
}
return (
<a
href={href}
onClick={handleClick}
className="text-primary hover:underline cursor-pointer"
>
{children}
</a>
)
}
}
// Terminal mode: minimal formatting
if (mode === 'terminal') {
return {
...baseComponents,
// No special code handling - just monospace
code: ({ children }) => <code className="font-mono">{children}</code>,
pre: ({ children }) => <pre className="font-mono whitespace-pre-wrap my-2">{children}</pre>,
// Minimal paragraph spacing
p: ({ children }) => <p className="my-1">{children}</p>,
// Simple lists
ul: ({ children }) => <ul className="list-disc list-inside my-1">{children}</ul>,
ol: ({ children }) => <ol className="list-decimal list-inside my-1">{children}</ol>,
li: ({ children }) => <li className="my-0.5">{children}</li>,
// Plain tables
table: ({ children }) => <table className="my-2 font-mono text-sm">{children}</table>,
th: ({ children }) => <th className="text-left pr-4">{children}</th>,
td: ({ children }) => <td className="pr-4">{children}</td>
}
}
// Minimal mode: clean with syntax highlighting
if (mode === 'minimal') {
return {
...baseComponents,
// Inline code
code: ({ className, children, ...props }) => {
const match = /language-(\w+)/.exec(className || '')
const isBlock =
'node' in props && props.node?.position?.start.line !== props.node?.position?.end.line
// Block code - use CodeBlock with full mode
if (match || isBlock) {
const code = String(children).replace(/\n$/, '')
return <CodeBlock code={code} language={match?.[1]} mode="full" className="my-1" />
}
// Inline code
return <InlineCode>{children}</InlineCode>
},
pre: ({ children }) => <>{children}</>,
// Comfortable paragraph spacing
p: ({ children }) => <p className="my-2 leading-relaxed">{children}</p>,
// Styled lists
ul: ({ children }) => (
<ul className="my-2 space-y-1 ps-4 pe-2 list-disc marker:text-muted-foreground">
{children}
</ul>
),
ol: ({ children }) => <ol className="my-2 space-y-1 pl-6 list-decimal">{children}</ol>,
li: ({ children }) => <li>{children}</li>,
// Clean tables
table: ({ children }) => (
<div className="my-3 overflow-x-auto">
<table className="min-w-full text-sm">{children}</table>
</div>
),
thead: ({ children }) => <thead className="border-b">{children}</thead>,
th: ({ children }) => (
<th className="text-left py-2 px-3 font-semibold text-muted-foreground">{children}</th>
),
td: ({ children }) => <td className="py-2 px-3 border-b border-border/50">{children}</td>,
// Headings - H1/H2 same size, differentiated by weight
h1: ({ children }) => <h1 className="font-sans text-base font-bold mt-5 mb-3">{children}</h1>,
h2: ({ children }) => (
<h2 className="font-sans text-base font-semibold mt-4 mb-3">{children}</h2>
),
h3: ({ children }) => (
<h3 className="font-sans text-sm font-semibold mt-4 mb-2">{children}</h3>
),
// Blockquotes
blockquote: ({ children }) => (
<blockquote className="border-l-2 border-muted-foreground/30 pl-3 my-2 text-muted-foreground italic">
{children}
</blockquote>
),
// Horizontal rules
hr: () => <hr className="my-4 border-border" />,
// Strong/emphasis
strong: ({ children }) => <strong className="font-semibold">{children}</strong>,
em: ({ children }) => <em className="italic">{children}</em>
}
}
// Full mode: rich styling
return {
...baseComponents,
// Full code blocks with copy button
code: ({ className, children, ...props }) => {
const match = /language-(\w+)/.exec(className || '')
const isBlock =
'node' in props && props.node?.position?.start.line !== props.node?.position?.end.line
if (match || isBlock) {
const code = String(children).replace(/\n$/, '')
return <CodeBlock code={code} language={match?.[1]} mode="full" className="my-1" />
}
return <InlineCode>{children}</InlineCode>
},
pre: ({ children }) => <>{children}</>,
// Rich paragraph spacing
p: ({ children }) => <p className="my-3 leading-relaxed">{children}</p>,
// Styled lists
ul: ({ children }) => (
<ul className="my-3 space-y-1.5 ps-4 pe-2 list-disc marker:text-muted-foreground">
{children}
</ul>
),
ol: ({ children }) => <ol className="my-3 space-y-1.5 pl-6 list-decimal">{children}</ol>,
li: ({ children }) => <li className="leading-relaxed">{children}</li>,
// Beautiful tables
table: ({ children }) => (
<div className="my-4 overflow-x-auto rounded-md border">
<table className="min-w-full divide-y divide-border">{children}</table>
</div>
),
thead: ({ children }) => <thead className="bg-muted/50">{children}</thead>,
tbody: ({ children }) => <tbody className="divide-y divide-border">{children}</tbody>,
th: ({ children }) => <th className="text-left py-3 px-4 font-semibold text-sm">{children}</th>,
td: ({ children }) => <td className="py-3 px-4 text-sm">{children}</td>,
tr: ({ children }) => <tr className="hover:bg-muted/30 transition-colors">{children}</tr>,
// Rich headings
h1: ({ children }) => <h1 className="font-sans text-base font-bold mt-7 mb-4">{children}</h1>,
h2: ({ children }) => (
<h2 className="font-sans text-base font-semibold mt-6 mb-3">{children}</h2>
),
h3: ({ children }) => <h3 className="font-sans text-sm font-semibold mt-5 mb-3">{children}</h3>,
h4: ({ children }) => <h4 className="text-sm font-semibold mt-3 mb-1">{children}</h4>,
// Styled blockquotes
blockquote: ({ children }) => (
<blockquote className="border-l-4 border-foreground/30 bg-muted/30 pl-4 pr-3 py-2 my-3 rounded-r-md">
{children}
</blockquote>
),
// Task lists (GFM)
input: ({ type, checked }) => {
if (type === 'checkbox') {
return (
<input
type="checkbox"
checked={checked}
readOnly
className="mr-2 rounded border-muted-foreground"
/>
)
}
return <input type={type} />
},
// Horizontal rules
hr: () => <hr className="my-6 border-border" />,
// Strong/emphasis
strong: ({ children }) => <strong className="font-semibold">{children}</strong>,
em: ({ children }) => <em className="italic">{children}</em>,
del: ({ children }) => <del className="line-through text-muted-foreground">{children}</del>
}
return null
}
/**
* App-level Markdown wrapper that injects IssueMentionCard via renderMention.
* Callers that need custom mention rendering can pass their own renderMention prop.
* Markdown - Customizable markdown renderer with multiple render modes
*
* Features:
* - Three render modes: terminal, minimal, full
* - Syntax highlighting via Shiki
* - GFM support (tables, task lists, strikethrough)
* - Clickable links and file paths
* - Memoization for streaming performance
*/
export function Markdown(props: MarkdownProps): React.JSX.Element {
return <MarkdownBase renderMention={defaultRenderMention} {...props} />
export function Markdown({
children,
mode = 'minimal',
className,
onUrlClick,
onFileClick
}: MarkdownProps): React.JSX.Element {
const components = React.useMemo(
() => createComponents(mode, onUrlClick, onFileClick),
[mode, onUrlClick, onFileClick]
)
// Preprocess: convert mention shortcodes and raw URLs/file paths to markdown links
const processedContent = React.useMemo(
() => preprocessLinks(preprocessMentionShortcodes(children)),
[children]
)
return (
<div className={cn('markdown-content break-words', className)}>
<ReactMarkdown
remarkPlugins={[[remarkGfm, { singleTilde: false }]]}
rehypePlugins={[rehypeRaw]}
urlTransform={urlTransform}
components={components}
>
{processedContent}
</ReactMarkdown>
</div>
)
}
/**
* MemoizedMarkdown - Optimized for streaming scenarios
*
* Splits content into blocks and memoizes each block separately,
* so only new/changed blocks re-render during streaming.
*/
export const MemoizedMarkdown = React.memo(Markdown, (prevProps, nextProps) => {
// If id is provided, use it for memoization
if (prevProps.id && nextProps.id) {
return (
prevProps.id === nextProps.id &&
@@ -38,6 +334,7 @@ export const MemoizedMarkdown = React.memo(Markdown, (prevProps, nextProps) => {
prevProps.mode === nextProps.mode
)
}
// Otherwise compare content and mode
return prevProps.children === nextProps.children && prevProps.mode === nextProps.mode
})
MemoizedMarkdown.displayName = 'MemoizedMarkdown'

View File

@@ -1,22 +1,225 @@
import * as React from 'react'
import {
StreamingMarkdown as StreamingMarkdownBase,
type StreamingMarkdownProps as StreamingMarkdownBaseProps
} from '@multica/ui/markdown'
import { IssueMentionCard } from '@multica/views/issues/components'
import { Markdown, type RenderMode } from './Markdown'
export type StreamingMarkdownProps = StreamingMarkdownBaseProps
export interface StreamingMarkdownProps {
content: string
isStreaming: boolean
mode?: RenderMode
className?: string
onUrlClick?: (url: string) => void
onFileClick?: (path: string) => void
}
function defaultRenderMention({ type, id }: { type: string; id: string }): React.ReactNode {
if (type === 'issue') {
return <IssueMentionCard issueId={id} />
}
return null
interface Block {
content: string
isCodeBlock: boolean
}
/**
* App-level StreamingMarkdown wrapper that injects IssueMentionCard via renderMention.
* djb2 hash (XOR variant) by Daniel J. Bernstein.
* Used to generate stable React keys for completed content blocks.
*
* - 5381: empirically chosen initial value that produces fewer collisions
* - (hash << 5) + hash: equivalent to hash * 33
* - ^ charCode: XOR variant, favored by Bernstein over additive version
* - >>> 0: convert to unsigned 32-bit integer
*
* Not cryptographic — just fast with good distribution for short strings.
* @see http://www.cse.yorku.ca/~oz/hash.html
*/
export function StreamingMarkdown(props: StreamingMarkdownProps): React.JSX.Element {
return <StreamingMarkdownBase renderMention={defaultRenderMention} {...props} />
function simpleHash(str: string): string {
let hash = 5381
for (let i = 0; i < str.length; i++) {
hash = ((hash << 5) + hash) ^ str.charCodeAt(i)
}
return (hash >>> 0).toString(36)
}
/**
* Split content into blocks (paragraphs and code blocks)
*
* Block boundaries:
* - Double newlines (paragraph separators)
* - Code fences (```)
*
* This is intentionally simple - just string scanning, no regex per line.
*/
function splitIntoBlocks(content: string): Block[] {
const blocks: Block[] = []
const lines = content.split('\n')
let currentBlock = ''
let inCodeBlock = false
let inMathBlock = false
for (let i = 0; i < lines.length; i++) {
const line = lines[i] ?? ''
// Check for code fence (``` at start of line, optionally followed by language)
if (line.startsWith('```')) {
if (!inCodeBlock) {
// Starting a code block - flush current paragraph first
if (currentBlock.trim()) {
blocks.push({ content: currentBlock.trim(), isCodeBlock: false })
currentBlock = ''
}
inCodeBlock = true
currentBlock = line + '\n'
} else {
// Ending a code block
currentBlock += line
blocks.push({ content: currentBlock, isCodeBlock: true })
currentBlock = ''
inCodeBlock = false
}
} else if (inCodeBlock) {
// Inside code block - append line
currentBlock += line + '\n'
// Check for display math fence ($$)
} else if (line.trim() === '$$') {
if (!inMathBlock) {
// Starting a math block - flush current paragraph first
if (currentBlock.trim()) {
blocks.push({ content: currentBlock.trim(), isCodeBlock: false })
currentBlock = ''
}
inMathBlock = true
currentBlock = line + '\n'
} else {
// Ending a math block
currentBlock += line
blocks.push({ content: currentBlock, isCodeBlock: false })
currentBlock = ''
inMathBlock = false
}
} else if (inMathBlock) {
// Inside math block - append line (don't split on blank lines)
currentBlock += line + '\n'
} else if (line === '') {
// Empty line outside code block = paragraph boundary
if (currentBlock.trim()) {
blocks.push({ content: currentBlock.trim(), isCodeBlock: false })
currentBlock = ''
}
} else {
// Regular text line
if (currentBlock) {
currentBlock += '\n' + line
} else {
currentBlock = line
}
}
}
// Flush remaining content
if (currentBlock) {
blocks.push({
content: inCodeBlock || inMathBlock ? currentBlock : currentBlock.trim(),
isCodeBlock: inCodeBlock
})
}
return blocks
}
/**
* Memoized block component
*
* Only re-renders if content or mode changes.
* The key is assigned by the parent based on content hash,
* so identical content won't even attempt to render.
*/
const MemoizedBlock = React.memo(
function Block({
content,
mode,
className,
onUrlClick,
onFileClick
}: {
content: string
mode: RenderMode
className?: string
onUrlClick?: (url: string) => void
onFileClick?: (path: string) => void
}) {
return (
<Markdown mode={mode} className={className} onUrlClick={onUrlClick} onFileClick={onFileClick}>
{content}
</Markdown>
)
},
(prev, next) => {
// Only re-render if content actually changed
return prev.content === next.content && prev.mode === next.mode && prev.className === next.className
}
)
MemoizedBlock.displayName = 'MemoizedBlock'
/**
* StreamingMarkdown - Optimized markdown renderer for streaming content
*
* Splits content into blocks (paragraphs, code blocks) and memoizes each block
* independently. Only the last (active) block re-renders during streaming.
*
* Key insight: Completed blocks get a content-hash as their React key.
* Same content = same key = React skips re-render entirely.
*
* @example
* Content: "Hello\n\n```js\ncode\n```\n\nMore..."
*
* Block 1: "Hello" -> key="block-abc123" -> memoized
* Block 2: "```js\ncode\n```" -> key="block-xyz789" -> memoized
* Block 3: "More..." -> key="active-2" -> re-renders
*/
export function StreamingMarkdown({
content,
isStreaming,
mode = 'minimal',
className,
onUrlClick,
onFileClick
}: StreamingMarkdownProps): React.JSX.Element {
// Split into blocks - memoized to avoid recomputation
// Must be called unconditionally to satisfy Rules of Hooks
const blocks = React.useMemo(
() => (isStreaming ? splitIntoBlocks(content) : []),
[content, isStreaming]
)
// Not streaming - use simple Markdown (no block splitting needed)
if (!isStreaming) {
return (
<Markdown mode={mode} className={className} onUrlClick={onUrlClick} onFileClick={onFileClick}>
{content}
</Markdown>
)
}
// Empty content - return null, let parent handle loading indicator
if (blocks.length === 0) {
return <></>
}
return (
<>
{blocks.map((block, i) => {
const isLastBlock = i === blocks.length - 1
// Complete blocks use content hash as key -> stable identity -> memoized
// Last block uses "active" prefix -> always re-renders on content change
const key = isLastBlock ? `active-${i}` : `block-${i}-${simpleHash(block.content)}`
return (
<MemoizedBlock
key={key}
content={block.content}
mode={mode}
className={className}
onUrlClick={onUrlClick}
onFileClick={onFileClick}
/>
)
})}
</>
)
}

View File

@@ -1 +1,215 @@
export { preprocessLinks, detectLinks, hasLinks } from '@multica/ui/markdown'
import LinkifyIt from 'linkify-it'
/**
* Linkify - URL and file path detection for markdown preprocessing
*
* Uses linkify-it (12M downloads/week) for battle-tested URL detection,
* plus custom regex for local file paths.
*/
// Initialize linkify-it with default settings (fuzzy URLs, emails enabled)
const linkify = new LinkifyIt()
// File path regex - detects /path, ~/path, ./path with common extensions
// Matches paths that start with /, ~/, or ./ followed by path chars and a file extension
const FILE_PATH_REGEX =
/(?:^|[\s([{<])((\/|~\/|\.\/)[\w\-./@]+\.(?:ts|tsx|js|jsx|mjs|cjs|md|json|yaml|yml|py|go|rs|css|scss|less|html|htm|txt|log|sh|bash|zsh|swift|kt|java|c|cpp|h|hpp|rb|php|xml|toml|ini|cfg|conf|env|sql|graphql|vue|svelte|astro|prisma|dockerfile|makefile|gitignore))(?=[\s)\]}.,;:!?>]|$)/gi
interface DetectedLink {
type: 'url' | 'email' | 'file'
text: string
url: string
start: number
end: number
}
interface CodeRange {
start: number
end: number
}
/**
* Find all code block and inline code ranges in text
* These ranges should be excluded from link detection
*/
function findCodeRanges(text: string): CodeRange[] {
const ranges: CodeRange[] = []
// Find fenced code blocks (```...```)
const fencedRegex = /```[\s\S]*?```/g
let match
while ((match = fencedRegex.exec(text)) !== null) {
ranges.push({ start: match.index, end: match.index + match[0].length })
}
// Find display math blocks ($$...$$)
const displayMathRegex = /\$\$[\s\S]*?\$\$/g
while ((match = displayMathRegex.exec(text)) !== null) {
const pos = match.index
const insideOther = ranges.some((r) => pos >= r.start && pos < r.end)
if (!insideOther) {
ranges.push({ start: pos, end: pos + match[0].length })
}
}
// Find inline math ($...$)
const inlineMathRegex = /(?<!\$)\$(?!\$)([^\$\n]+)\$(?!\$)/g
while ((match = inlineMathRegex.exec(text)) !== null) {
const pos = match.index
const insideOther = ranges.some((r) => pos >= r.start && pos < r.end)
if (!insideOther) {
ranges.push({ start: pos, end: pos + match[0].length })
}
}
// Find inline code (`...`)
// But skip escaped backticks and code inside fenced blocks
const inlineRegex = /(?<!`)`(?!`)([^`\n]+)`(?!`)/g
while ((match = inlineRegex.exec(text)) !== null) {
const pos = match.index
// Check if this is inside a fenced block or math block
const insideOther = ranges.some((r) => pos >= r.start && pos < r.end)
if (!insideOther) {
ranges.push({ start: pos, end: pos + match[0].length })
}
}
return ranges
}
/**
* Check if a position is inside any code range
*/
function isInsideCode(pos: number, ranges: CodeRange[]): boolean {
return ranges.some((r) => pos >= r.start && pos < r.end)
}
/**
* Check if a link at given position is already a markdown link
* Looks for patterns like [text](url) or [text][ref]
*/
function isAlreadyLinked(text: string, linkStart: number, linkEnd: number): boolean {
// Check if preceded by ]( which indicates we're inside a markdown link href
// Pattern: [text](URL) - we're checking if URL is our link
const before = text.slice(Math.max(0, linkStart - 2), linkStart)
if (before.endsWith('](')) return true
// Check if preceded by ][ for reference links
if (before.endsWith('][')) return true
// Check if the link text is wrapped in []
// Pattern: [URL](href) - URL is being used as link text
const charBefore = text[linkStart - 1]
const charAfter = text[linkEnd]
if (charBefore === '[' && charAfter === ']') return true
return false
}
/**
* Check if ranges overlap
*/
function rangesOverlap(
a: { start: number; end: number },
b: { start: number; end: number }
): boolean {
return a.start < b.end && b.start < a.end
}
/**
* Detect all links (URLs, emails, file paths) in text
*/
export function detectLinks(text: string): DetectedLink[] {
const links: DetectedLink[] = []
// 1. Detect URLs and emails with linkify-it
const urlMatches = linkify.match(text) || []
for (const match of urlMatches) {
links.push({
type: match.schema === 'mailto:' ? 'email' : 'url',
text: match.text,
url: match.url,
start: match.index,
end: match.lastIndex
})
}
// 2. Detect file paths with custom regex
// Reset regex state
FILE_PATH_REGEX.lastIndex = 0
let fileMatch
while ((fileMatch = FILE_PATH_REGEX.exec(text)) !== null) {
const path = fileMatch[1]
if (!path) continue // Skip if no capture group
// Calculate actual start position (after any leading whitespace/punctuation)
const fullMatch = fileMatch[0]
const pathOffset = fullMatch.indexOf(path)
const start = fileMatch.index + pathOffset
// Check for overlaps with URL matches (URLs take precedence)
const pathRange = { start, end: start + path.length }
const overlapsUrl = links.some((link) => rangesOverlap(pathRange, link))
if (overlapsUrl) continue
links.push({
type: 'file',
text: path,
url: path, // File paths are passed as-is to onFileClick handler
start,
end: start + path.length
})
}
// Sort by position
return links.sort((a, b) => a.start - b.start)
}
/**
* Preprocess text to convert raw URLs and file paths into markdown links
* Skips code blocks and already-linked content
*/
export function preprocessLinks(text: string): string {
// Quick check - if no potential links, return early
if (!linkify.pretest(text) && !/[~/.]\//.test(text)) {
return text
}
const codeRanges = findCodeRanges(text)
const links = detectLinks(text)
if (links.length === 0) return text
// Build result, converting raw links to markdown links
let result = ''
let lastIndex = 0
for (const link of links) {
// Skip if inside code block
if (isInsideCode(link.start, codeRanges)) continue
// Skip if already a markdown link
if (isAlreadyLinked(text, link.start, link.end)) continue
// Add text before this link
result += text.slice(lastIndex, link.start)
// Convert to markdown link
result += `[${link.text}](${link.url})`
lastIndex = link.end
}
// Add remaining text
result += text.slice(lastIndex)
return result
}
/**
* Test if text contains any detectable links
* Useful for optimization - skip preprocessing if no links present
*/
export function hasLinks(text: string): boolean {
return linkify.pretest(text) || /[~/.]\/[\w]/.test(text)
}

View File

@@ -1 +1,25 @@
export { preprocessMentionShortcodes } from '@multica/ui/markdown'
/**
* Convert legacy mention shortcodes [@ id="UUID" label="LABEL"] to the
* standard markdown link format [@LABEL](mention://member/UUID).
*
* These shortcodes exist in older database records from a previous mention
* serialization format. This function normalises them so downstream parsers
* (Tiptap @tiptap/markdown, react-markdown) only need to handle one syntax.
*/
export function preprocessMentionShortcodes(text: string): string {
if (!text.includes("[@ ")) return text;
return text.replace(
/\[@\s+([^\]]*)\]/g,
(match, attrString: string) => {
const attrs: Record<string, string> = {};
const re = /(\w+)="([^"]*)"/g;
let m;
while ((m = re.exec(attrString)) !== null) {
if (m[1] && m[2] !== undefined) attrs[m[1]] = m[2];
}
const { id, label } = attrs;
if (!id || !label) return match;
return `[@${label}](mention://member/${id})`;
},
);
}

View File

@@ -1,5 +1,5 @@
import { useState, useEffect } from "react";
import { cn } from "../../lib/utils";
import { cn } from "@/lib/utils";
interface MulticaIconProps extends React.ComponentProps<"span"> {
/**

View File

@@ -8,7 +8,7 @@
* Inherits color from `currentColor` (use Tailwind `text-*`).
* Scales with font-size (use Tailwind `text-*` for size).
*/
import { cn } from "@multica/ui/lib/utils"
import { cn } from "@/lib/utils"
export interface SpinnerProps {
/** Additional className for styling (color via text-*, size via Tailwind text-*) */

View File

@@ -1,16 +1,25 @@
"use client"
// Re-export the shared ThemeProvider from @multica/ui
export { ThemeProvider } from "@multica/ui/components/common/theme-provider"
import { ThemeProvider as NextThemesProvider } from "next-themes"
import { TooltipProvider } from "@/components/ui/tooltip"
// 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);
};
function ThemeProvider({
children,
...props
}: React.ComponentProps<typeof NextThemesProvider>) {
return (
<NextThemesProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
{...props}
>
<TooltipProvider delay={500}>
{children}
</TooltipProvider>
</NextThemesProvider>
)
}
export { ThemeProvider }

View File

@@ -7,8 +7,8 @@ import {
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
} from "@multica/ui/components/ui/dropdown-menu"
import { SidebarMenuButton } from "@multica/ui/components/ui/sidebar"
} from "@/components/ui/dropdown-menu"
import { SidebarMenuButton } from "@/components/ui/sidebar"
export function ThemeToggle() {
const { setTheme } = useTheme()

View File

@@ -2,7 +2,7 @@
import { Accordion as AccordionPrimitive } from "@base-ui/react/accordion"
import { cn } from "@multica/ui/lib/utils"
import { cn } from "@/lib/utils"
import { ChevronDownIcon, ChevronUpIcon } from "lucide-react"
function Accordion({ className, ...props }: AccordionPrimitive.Root.Props) {

View File

@@ -3,8 +3,8 @@
import * as React from "react"
import { AlertDialog as AlertDialogPrimitive } from "@base-ui/react/alert-dialog"
import { cn } from "@multica/ui/lib/utils"
import { Button } from "@multica/ui/components/ui/button"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
function AlertDialog({ ...props }: AlertDialogPrimitive.Root.Props) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />

View File

@@ -1,7 +1,7 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@multica/ui/lib/utils"
import { cn } from "@/lib/utils"
const alertVariants = cva(
"group/alert relative grid w-full gap-0.5 rounded-lg border px-2.5 py-2 text-left text-sm has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-2 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-4",

View File

@@ -1,4 +1,4 @@
import { cn } from "@multica/ui/lib/utils"
import { cn } from "@/lib/utils"
function AspectRatio({
ratio,

View File

@@ -3,7 +3,7 @@
import * as React from "react"
import { Avatar as AvatarPrimitive } from "@base-ui/react/avatar"
import { cn } from "@multica/ui/lib/utils"
import { cn } from "@/lib/utils"
function Avatar({
className,

View File

@@ -2,7 +2,7 @@ import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@multica/ui/lib/utils"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",

View File

@@ -2,7 +2,7 @@ import * as React from "react"
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cn } from "@multica/ui/lib/utils"
import { cn } from "@/lib/utils"
import { ChevronRightIcon, MoreHorizontalIcon } from "lucide-react"
function Breadcrumb({ className, ...props }: React.ComponentProps<"nav">) {

View File

@@ -2,8 +2,8 @@ import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@multica/ui/lib/utils"
import { Separator } from "@multica/ui/components/ui/separator"
import { cn } from "@/lib/utils"
import { Separator } from "@/components/ui/separator"
const buttonGroupVariants = cva(
"flex w-fit items-stretch *:focus-visible:relative *:focus-visible:z-10 has-[>[data-slot=button-group]]:gap-2 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-lg [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1",

View File

@@ -3,7 +3,7 @@
import { Button as ButtonPrimitive } from "@base-ui/react/button"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@multica/ui/lib/utils"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",

View File

@@ -8,8 +8,8 @@ import {
type Locale,
} from "react-day-picker"
import { cn } from "@multica/ui/lib/utils"
import { Button, buttonVariants } from "@multica/ui/components/ui/button"
import { cn } from "@/lib/utils"
import { Button, buttonVariants } from "@/components/ui/button"
import { ChevronLeftIcon, ChevronRightIcon, ChevronDownIcon } from "lucide-react"
function Calendar({

View File

@@ -1,6 +1,6 @@
import * as React from "react"
import { cn } from "@multica/ui/lib/utils"
import { cn } from "@/lib/utils"
function Card({
className,

View File

@@ -5,8 +5,8 @@ import useEmblaCarousel, {
type UseEmblaCarouselType,
} from "embla-carousel-react"
import { cn } from "@multica/ui/lib/utils"
import { Button } from "@multica/ui/components/ui/button"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react"
type CarouselApi = UseEmblaCarouselType[1]

View File

@@ -4,7 +4,7 @@ import * as React from "react"
import * as RechartsPrimitive from "recharts"
import type { TooltipValueType } from "recharts"
import { cn } from "@multica/ui/lib/utils"
import { cn } from "@/lib/utils"
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: "", dark: ".dark" } as const

View File

@@ -2,7 +2,7 @@
import { Checkbox as CheckboxPrimitive } from "@base-ui/react/checkbox"
import { cn } from "@multica/ui/lib/utils"
import { cn } from "@/lib/utils"
import { CheckIcon } from "lucide-react"
function Checkbox({ className, ...props }: CheckboxPrimitive.Root.Props) {

Some files were not shown because too many files have changed in this diff Show More