Compare commits

..

1 Commits

Author SHA1 Message Date
Jiayuan
58a8655b43 fix(web): remember last selected workspace after re-login
Stop clearing multica_workspace_id from localStorage on logout so it
persists as a preference hint. On fresh login, pass the stored ID to
hydrateWorkspace so the user returns to their last workspace instead
of always landing on the first one.
2026-04-06 00:59:35 +08:00
388 changed files with 5491 additions and 16784 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=

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. -->

201
CLAUDE.md
View File

@@ -12,162 +12,77 @@ Multica is an AI-native task management platform — like Linear, but with AI ag
## Architecture
**Go backend + monorepo frontend 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)
- `packages/core/` — Headless business logic (zero react-dom, all-platform reuse)
- `packages/ui/` — Atomic UI components (zero business logic)
- `packages/views/` — Shared business pages/components (zero next/* imports)
- `packages/tsconfig/` — Shared TypeScript configuration
- `apps/web/` — Next.js 16 frontend (App Router) — self-contained, no shared package dependencies
### Package Architecture
### Web App Structure (`apps/web/`)
Three shared packages with single-direction dependencies:
```
packages/
├── core/ # @multica/core — types, API client, stores, queries, mutations, realtime
├── ui/ # @multica/ui — 55 shadcn components, common components, markdown, hooks
├── views/ # @multica/views — issue pages, editor, modals, skills, runtimes, navigation
└── tsconfig/ # @multica/tsconfig — shared TS base configs
```
**Dependency direction:** `views/ → core/ + ui/`. Core and UI are independent of each other. No package imports from `next/*` or `apps/web/`.
**Platform bridge:** `apps/web/platform/` is the only place that touches `process.env`, `next/navigation`, and creates store/api singletons. Each future app (desktop, mobile) provides its own platform layer.
### packages/core/ (`@multica/core`)
Headless business logic. **Zero react-dom, zero localStorage, zero process.env.**
| Module | Purpose | Key exports |
|---|---|---|
| `core/types/` | Domain types + StorageAdapter interface | `Issue`, `Agent`, `Workspace`, `StorageAdapter` |
| `core/api/` | API client class + WS client | `ApiClient`, `WSClient`, `setApiInstance()` |
| `core/auth/` | Auth store factory | `createAuthStore(options)`, `registerAuthStore()` |
| `core/workspace/` | Workspace store factory + actor hooks | `createWorkspaceStore(api)`, `useActorName()` |
| `core/issues/` | Issue queries, mutations, stores, config | `issueListOptions`, `useUpdateIssue`, `useIssueStore` |
| `core/inbox/` | Inbox queries, mutations, WS updaters | `inboxListOptions`, `useMarkInboxRead` |
| `core/runtimes/` | Runtime queries + mutations | `runtimeListOptions`, `useDeleteRuntime` |
| `core/realtime/` | WS provider + sync hooks | `WSProvider`, `useWSEvent`, `useRealtimeSync` |
| `core/hooks.tsx` | Workspace ID context | `useWorkspaceId`, `WorkspaceIdProvider` |
| `core/modals/` | Modal state store | `useModalStore` |
| `core/navigation/` | Navigation state store | `useNavigationStore` |
**Store factory pattern:** Auth and workspace stores are created via factory functions that receive platform-specific dependencies:
```typescript
createAuthStore({ api, storage, onLogin?, onLogout? })
createWorkspaceStore(api, { storage?, onError? })
```
Each app creates its own instances in its platform layer and registers them via `registerAuthStore()` / `registerWorkspaceStore()`.
**StorageAdapter:** All persistent storage goes through a `StorageAdapter` interface (getItem/setItem/removeItem), injected by the platform. Web uses an SSR-safe localStorage wrapper.
### packages/ui/ (`@multica/ui`)
Atomic UI layer. **Zero business logic, zero `@multica/core` imports.**
- `components/ui/` — 55 shadcn components (button, dialog, card, tooltip, sidebar, etc.)
- `components/common/` — Pure-props components (actor-avatar, emoji-picker, reaction-bar, file-upload-button)
- `markdown/` — Markdown renderer with `renderMention` slot for platform-specific mention cards
- `hooks/` — DOM hooks (use-auto-scroll, use-mobile, use-scroll-fade)
- `lib/utils.ts``cn()` function (clsx + tailwind-merge)
- `styles/tokens.css` — Tailwind CSS v4 design tokens (@theme, :root, .dark variables)
### packages/views/ (`@multica/views`)
Shared business UI pages. **Zero `next/*` imports.** Uses `NavigationAdapter` for routing.
- `navigation/``NavigationAdapter` interface, `useNavigation()` hook, `AppLink` component
- `issues/components/` — IssuesPage, IssueDetail, BoardView, ListView, pickers, icons
- `editor/` — ContentEditor, TitleEditor, Tiptap extensions
- `modals/` — CreateIssueModal, CreateWorkspaceModal, ModalRegistry
- `my-issues/`, `skills/`, `runtimes/` — domain pages
- `common/` — Data-aware wrappers (ActorAvatar with useActorName, Markdown with IssueMentionCard)
### apps/web/ (Next.js App)
Thin routing shells + platform-specific code.
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 (api singleton, store instances, navigation, storage)
├── features/
│ ├── auth/ # Web-only: auth-cookie.ts, initializer.tsx
│ ├── landing/ # Web-only: landing pages (uses next/image, next/link)
│ └── search/ # Web-only: search dialog
└── components/ # App-level: theme-provider, multica-icon, locale-sync, loading-indicator
├── app/ # Routing layer (thin shells — import from features/)
├── features/ # Business logic, organized by domain
├── shared/ # Cross-feature utilities (api client, types, logger)
```
**`platform/`** — The only code that touches Next.js APIs and browser globals:
- `api.ts` — Creates `ApiClient` singleton with `onUnauthorized` redirect
- `auth.ts``createAuthStore({ api, storage: webStorage, onLogin: setLoggedInCookie })`
- `workspace.ts``createWorkspaceStore(api, { storage: webStorage, onError: toast.error })`
- `ws-provider.tsx` — Wraps `WSProvider` with web-specific WS URL and store instances
- `navigation.tsx``WebNavigationProvider` wrapping Next.js `useRouter`/`usePathname`
- `storage.ts` — SSR-safe `webStorage` adapter (guards `localStorage` with `typeof window` checks)
**`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/`.
**`features/`** — Domain modules, each with its own components, hooks, stores, and config:
| 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 |
**`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 singletons) — from @/platform
import { api } from "@/platform/api";
import { useAuthStore } from "@/platform/auth";
import { useWorkspaceStore } from "@/platform/workspace";
// Web-only features — from @/features
import { AuthInitializer } from "@/features/auth";
import { SearchCommand } from "@/features/search";
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/`. 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`
@@ -200,13 +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 build # Build frontend
pnpm typecheck # TypeScript check (all packages via turbo)
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)
@@ -227,9 +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
# 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
@@ -260,21 +172,12 @@ 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
- `packages/ui/` — zero `@multica/core` imports (pure UI, no business logic)
- `packages/views/` — zero `next/*` imports (use NavigationAdapter for routing)
- `apps/web/platform/` — the only place for Next.js APIs, env vars, and browser globals
## UI/UX Rules
- Prefer shadcn components over custom implementations. Install via `npx shadcn add <component> -c apps/web` (monorepo flag required).
- **Shared UI components** → `packages/ui/components/` — shadcn primitives and pure-props common components.
- **Shared business components** → `packages/views/<domain>/components/` — pages and domain-bound UI.
- **Web-only components** → `apps/web/features/` or `apps/web/components/`.
- 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.

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:
@@ -143,12 +138,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,8 +14,8 @@
**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)
@@ -31,7 +31,7 @@ Turn coding agents into real teammates — assign tasks, track progress, compoun
Multica turns coding agents into real teammates. Assign issues to an agent like you'd assign to a colleague — they'll pick up the work, write code, report blockers, and update statuses autonomously.
No more copy-pasting prompts. No more babysitting runs. Your agents show up on the board, participate in conversations, and compound reusable skills over time. Think of it as open-source infrastructure for managed agents — vendor-neutral, self-hosted, and designed for human + AI teams. Works with **Claude Code** and **Codex**.
No more copy-pasting prompts. No more babysitting runs. Your agents show up on the board, participate in conversations, and compound reusable skills over time. 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, 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
@@ -158,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,8 +14,8 @@
**你的下一批员工,不是人类。**
开源的 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)
@@ -31,7 +31,7 @@
Multica 将编码 Agent 变成真正的队友。像分配给同事一样分配给 Agent——它们会自主接手工作、编写代码、报告阻塞问题、更新状态。
不再需要复制粘贴 prompt不再需要盯着运行过程。你的 Agent 出现在看板上、参与对话、随着时间积累可复用的技能。可以理解为开源的 Managed Agents 基础设施——厂商中立、可自部署、专为人类 + AI 团队设计。支持 **Claude Code****Codex**
不再需要复制粘贴 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 等):**
```
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

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

@@ -12,7 +12,7 @@ vi.mock("next/navigation", () => ({
// Mock auth store
const mockSendCode = vi.fn();
const mockVerifyCode = vi.fn();
vi.mock("@/platform/auth", () => ({
vi.mock("@/features/auth", () => ({
useAuthStore: (selector: (s: any) => any) =>
selector({
sendCode: mockSendCode,
@@ -20,14 +20,9 @@ vi.mock("@/platform/auth", () => ({
}),
}));
// Mock auth-cookie
vi.mock("@/features/auth/auth-cookie", () => ({
setLoggedInCookie: vi.fn(),
}));
// Mock workspace store
const mockHydrateWorkspace = vi.fn();
vi.mock("@/platform/workspace", () => ({
vi.mock("@/features/workspace", () => ({
useWorkspaceStore: (selector: (s: any) => any) =>
selector({
hydrateWorkspace: mockHydrateWorkspace,
@@ -35,7 +30,7 @@ vi.mock("@/platform/workspace", () => ({
}));
// Mock api
vi.mock("@/platform/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 "@/platform/auth";
import { setLoggedInCookie } from "@/features/auth/auth-cookie";
import { useWorkspaceStore } from "@/platform/workspace";
import { api } from "@/platform/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;
@@ -287,22 +282,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 +307,7 @@ function LoginPageContent() {
)}
</form>
</CardContent>
<CardFooter className="flex flex-col gap-3">
<CardFooter>
<Button
type="submit"
form="login-form"
@@ -338,46 +317,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,6 +1,5 @@
"use client";
import React from "react";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import {
@@ -17,8 +16,8 @@ import {
SquarePen,
CircleUser,
} from "lucide-react";
import { WorkspaceAvatar } from "@multica/views/workspace/workspace-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,
@@ -30,7 +29,7 @@ import {
SidebarMenuButton,
SidebarMenuItem,
SidebarRail,
} from "@multica/ui/components/ui/sidebar";
} from "@/components/ui/sidebar";
import {
DropdownMenu,
DropdownMenuContent,
@@ -39,14 +38,12 @@ import {
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@multica/ui/components/ui/dropdown-menu";
import { Tooltip, TooltipTrigger, TooltipContent } from "@multica/ui/components/ui/tooltip";
import { useAuthStore } from "@/platform/auth";
import { useWorkspaceStore } from "@/platform/workspace";
import { useQuery } from "@tanstack/react-query";
import { inboxKeys, deduplicateInboxItems } from "@multica/core/inbox/queries";
import { api } from "@/platform/api";
import { useModalStore } from "@multica/core/modals";
} 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 primaryNav = [
{ href: "/inbox", label: "Inbox", icon: Inbox },
@@ -76,16 +73,7 @@ export function AppSidebar() {
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 unreadCount = useInboxStore((s) => s.unreadCount());
const logout = () => {
router.push("/");
@@ -144,7 +132,6 @@ export function AppSidebar() {
key={ws.id}
onClick={() => {
if (ws.id !== workspace?.id) {
router.push("/issues");
switchWorkspace(ws.id);
}
}}

View File

@@ -8,10 +8,15 @@ import {
Monitor,
Plus,
ListTodo,
Wrench,
FileText,
BookOpenText,
MessageSquare,
Timer,
Trash2,
Save,
Key,
Link2,
Clock,
CheckCircle2,
XCircle,
@@ -30,11 +35,14 @@ import type {
Agent,
AgentStatus,
AgentVisibility,
AgentTool,
AgentTrigger,
AgentTriggerType,
AgentTask,
RuntimeDevice,
CreateAgentRequest,
UpdateAgentRequest,
} from "@multica/core/types";
} from "@/shared/types";
import {
Dialog,
DialogContent,
@@ -42,38 +50,35 @@ import {
DialogTitle,
DialogDescription,
DialogFooter,
} from "@multica/ui/components/ui/dialog";
} from "@/components/ui/dialog";
import {
ResizablePanelGroup,
ResizablePanel,
ResizableHandle,
} from "@multica/ui/components/ui/resizable";
} from "@/components/ui/resizable";
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
} from "@multica/ui/components/ui/dropdown-menu";
} from "@/components/ui/dropdown-menu";
import {
Popover,
PopoverTrigger,
PopoverContent,
} from "@multica/ui/components/ui/popover";
import { Button } from "@multica/ui/components/ui/button";
import { Input } from "@multica/ui/components/ui/input";
import { Label } from "@multica/ui/components/ui/label";
} from "@/components/ui/popover";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { toast } from "sonner";
import { Skeleton } from "@multica/ui/components/ui/skeleton";
import { api } from "@/platform/api";
import { useAuthStore } from "@/platform/auth";
import { useWorkspaceStore } from "@/platform/workspace";
import { runtimeListOptions } from "@multica/core/runtimes/queries";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useWorkspaceId } from "@multica/core/hooks";
import { issueListOptions } from "@multica/core/issues/queries";
import { skillListOptions, agentListOptions, workspaceKeys } from "@multica/core/workspace/queries";
import { ActorAvatar } from "@multica/views/common/actor-avatar";
import { useFileUpload } from "@multica/core/hooks/use-file-upload";
import { Skeleton } from "@/components/ui/skeleton";
import { api } from "@/shared/api";
import { useAuthStore } from "@/features/auth";
import { useWorkspaceStore } from "@/features/workspace";
import { useRuntimeStore } from "@/features/runtimes";
import { useIssueStore } from "@/features/issues";
import { ActorAvatar } from "@/components/common/actor-avatar";
import { useFileUpload } from "@/shared/hooks/use-file-upload";
// ---------------------------------------------------------------------------
@@ -143,6 +148,10 @@ function CreateAgentDialog({
description: description.trim(),
runtime_id: selectedRuntime.id,
visibility,
triggers: [
{ id: generateId(), type: "on_assign", enabled: true, config: {} },
{ id: generateId(), type: "on_comment", enabled: true, config: {} },
],
});
onClose();
} catch (err) {
@@ -434,9 +443,8 @@ function SkillsTab({
}: {
agent: Agent;
}) {
const qc = useQueryClient();
const wsId = useWorkspaceId();
const { data: workspaceSkills = [] } = useQuery(skillListOptions(wsId));
const workspaceSkills = useWorkspaceStore((s) => s.skills);
const refreshAgents = useWorkspaceStore((s) => s.refreshAgents);
const [saving, setSaving] = useState(false);
const [showPicker, setShowPicker] = useState(false);
@@ -448,7 +456,7 @@ function SkillsTab({
try {
const newIds = [...agent.skills.map((s) => s.id), skillId];
await api.setAgentSkills(agent.id, { skill_ids: newIds });
qc.invalidateQueries({ queryKey: workspaceKeys.agents(wsId) });
await refreshAgents();
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed to add skill");
} finally {
@@ -462,7 +470,7 @@ function SkillsTab({
try {
const newIds = agent.skills.filter((s) => s.id !== skillId).map((s) => s.id);
await api.setAgentSkills(agent.id, { skill_ids: newIds });
qc.invalidateQueries({ queryKey: workspaceKeys.agents(wsId) });
await refreshAgents();
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed to remove skill");
} finally {
@@ -588,6 +596,459 @@ function SkillsTab({
);
}
// ---------------------------------------------------------------------------
// Tools Tab
// ---------------------------------------------------------------------------
function AddToolDialog({
onClose,
onAdd,
}: {
onClose: () => void;
onAdd: (tool: AgentTool) => void;
}) {
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [authType, setAuthType] = useState<"oauth" | "api_key" | "none">("api_key");
const handleAdd = () => {
if (!name.trim()) return;
onAdd({
id: generateId(),
name: name.trim(),
description: description.trim(),
auth_type: authType,
connected: false,
config: {},
});
onClose();
};
return (
<Dialog open onOpenChange={(v) => { if (!v) onClose(); }}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="text-sm">Add Tool</DialogTitle>
<DialogDescription className="text-xs">
Connect an external tool for this agent to use.
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div>
<Label className="text-xs text-muted-foreground">Tool Name</Label>
<Input
autoFocus
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g. Google Search, Slack, GitHub"
className="mt-1"
onKeyDown={(e) => e.key === "Enter" && handleAdd()}
/>
</div>
<div>
<Label className="text-xs text-muted-foreground">Description</Label>
<Input
type="text"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="What does this tool do?"
className="mt-1"
/>
</div>
<div>
<Label className="text-xs text-muted-foreground">Authentication</Label>
<div className="mt-1.5 flex gap-2">
{(["api_key", "oauth", "none"] as const).map((type) => (
<Button
key={type}
variant={authType === type ? "outline" : "ghost"}
size="xs"
onClick={() => setAuthType(type)}
className={`flex-1 ${
authType === type
? "border-primary bg-primary/5 font-medium"
: ""
}`}
>
{type === "api_key" ? "API Key" : type === "oauth" ? "OAuth" : "None"}
</Button>
))}
</div>
</div>
</div>
<DialogFooter>
<Button variant="ghost" onClick={onClose}>
Cancel
</Button>
<Button
onClick={handleAdd}
disabled={!name.trim()}
>
Add
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function ToolsTab({
agent,
onSave,
}: {
agent: Agent;
onSave: (tools: AgentTool[]) => Promise<void>;
}) {
const [tools, setTools] = useState<AgentTool[]>(agent.tools ?? []);
const [showAdd, setShowAdd] = useState(false);
const [saving, setSaving] = useState(false);
useEffect(() => {
setTools(agent.tools ?? []);
}, [agent.id, agent.tools]);
const isDirty = JSON.stringify(tools) !== JSON.stringify(agent.tools ?? []);
const handleSave = async () => {
setSaving(true);
try {
await onSave(tools);
} catch {
// toast handled by parent
} finally {
setSaving(false);
}
};
const toggleConnect = (toolId: string) => {
setTools((prev) =>
prev.map((t) => (t.id === toolId ? { ...t, connected: !t.connected } : t)),
);
};
const removeTool = (toolId: string) => {
setTools((prev) => prev.filter((t) => t.id !== toolId));
};
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h3 className="text-sm font-semibold">Tools</h3>
<p className="text-xs text-muted-foreground mt-0.5">
External tools and APIs this agent can use during task execution.
</p>
</div>
<div className="flex items-center gap-2">
{isDirty && (
<Button
onClick={handleSave}
disabled={saving}
size="xs"
>
<Save className="h-3 w-3" />
{saving ? "Saving..." : "Save"}
</Button>
)}
<Button
variant="outline"
size="xs"
onClick={() => setShowAdd(true)}
>
<Plus className="h-3 w-3" />
Add Tool
</Button>
</div>
</div>
{tools.length === 0 ? (
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed py-12">
<Wrench className="h-8 w-8 text-muted-foreground/40" />
<p className="mt-3 text-sm text-muted-foreground">No tools configured</p>
<Button
onClick={() => setShowAdd(true)}
size="xs"
className="mt-3"
>
<Plus className="h-3 w-3" />
Add Tool
</Button>
</div>
) : (
<div className="space-y-2">
{tools.map((tool) => (
<div
key={tool.id}
className="flex items-center gap-3 rounded-lg border px-4 py-3"
>
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-muted">
{tool.auth_type === "oauth" ? (
<Link2 className="h-4 w-4 text-muted-foreground" />
) : tool.auth_type === "api_key" ? (
<Key className="h-4 w-4 text-muted-foreground" />
) : (
<Wrench className="h-4 w-4 text-muted-foreground" />
)}
</div>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium">{tool.name}</div>
{tool.description && (
<div className="text-xs text-muted-foreground truncate">
{tool.description}
</div>
)}
</div>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="xs"
onClick={() => toggleConnect(tool.id)}
className={
tool.connected
? "bg-success/10 text-success"
: "bg-muted text-muted-foreground hover:bg-accent"
}
>
{tool.connected ? "Connected" : "Connect"}
</Button>
<Button
variant="ghost"
size="icon-xs"
onClick={() => removeTool(tool.id)}
className="text-muted-foreground hover:text-destructive"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</div>
))}
</div>
)}
{showAdd && (
<AddToolDialog
onClose={() => setShowAdd(false)}
onAdd={(tool) => setTools((prev) => [...prev, tool])}
/>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// Triggers Tab
// ---------------------------------------------------------------------------
function TriggersTab({
agent,
onSave,
}: {
agent: Agent;
onSave: (triggers: AgentTrigger[]) => Promise<void>;
}) {
const [triggers, setTriggers] = useState<AgentTrigger[]>(agent.triggers ?? []);
const [saving, setSaving] = useState(false);
useEffect(() => {
setTriggers(agent.triggers ?? []);
}, [agent.id, agent.triggers]);
const isDirty = JSON.stringify(triggers) !== JSON.stringify(agent.triggers ?? []);
const handleSave = async () => {
setSaving(true);
try {
await onSave(triggers);
} catch {
// toast handled by parent
} finally {
setSaving(false);
}
};
const toggleTrigger = (triggerId: string) => {
setTriggers((prev) =>
prev.map((t) => (t.id === triggerId ? { ...t, enabled: !t.enabled } : t)),
);
};
const removeTrigger = (triggerId: string) => {
setTriggers((prev) => prev.filter((t) => t.id !== triggerId));
};
const addTrigger = (type: AgentTriggerType) => {
const newTrigger: AgentTrigger = {
id: generateId(),
type,
enabled: true,
config: type === "scheduled" ? { cron: "0 9 * * 1-5", timezone: "UTC" } : {},
};
setTriggers((prev) => [...prev, newTrigger]);
};
const updateTriggerConfig = (triggerId: string, config: Record<string, unknown>) => {
setTriggers((prev) =>
prev.map((t) => (t.id === triggerId ? { ...t, config } : t)),
);
};
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h3 className="text-sm font-semibold">Triggers</h3>
<p className="text-xs text-muted-foreground mt-0.5">
Configure when this agent should start working.
</p>
</div>
<div className="flex items-center gap-2">
{isDirty && (
<Button
onClick={handleSave}
disabled={saving}
size="xs"
>
<Save className="h-3 w-3" />
{saving ? "Saving..." : "Save"}
</Button>
)}
</div>
</div>
<div className="space-y-2">
{triggers.map((trigger) => (
<div
key={trigger.id}
className="rounded-lg border px-4 py-3"
>
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-muted">
{trigger.type === "on_assign" ? (
<Bot className="h-4 w-4 text-muted-foreground" />
) : trigger.type === "on_comment" ? (
<MessageSquare className="h-4 w-4 text-muted-foreground" />
) : (
<Timer className="h-4 w-4 text-muted-foreground" />
)}
</div>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium">
{trigger.type === "on_assign"
? "On Issue Assign"
: trigger.type === "on_comment"
? "On Comment"
: "Scheduled"}
</div>
<div className="text-xs text-muted-foreground">
{trigger.type === "on_assign"
? "Runs when an issue is assigned to this agent"
: trigger.type === "on_comment"
? "Runs when a member comments on the agent's issue"
: `Cron: ${(trigger.config as { cron?: string }).cron ?? "Not set"}`}
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => toggleTrigger(trigger.id)}
className={`relative h-5 w-9 rounded-full transition-colors ${
trigger.enabled ? "bg-primary" : "bg-muted"
}`}
>
<span
className={`absolute top-0.5 h-4 w-4 rounded-full bg-white shadow-sm transition-transform ${
trigger.enabled ? "left-4.5" : "left-0.5"
}`}
/>
</button>
<Button
variant="ghost"
size="icon-xs"
onClick={() => removeTrigger(trigger.id)}
className="text-muted-foreground hover:text-destructive"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</div>
{trigger.type === "scheduled" && (
<div className="mt-3 grid grid-cols-2 gap-3 pl-12">
<div>
<Label className="text-xs text-muted-foreground">
Cron Expression
</Label>
<Input
type="text"
value={(trigger.config as { cron?: string }).cron ?? ""}
onChange={(e) =>
updateTriggerConfig(trigger.id, {
...trigger.config,
cron: e.target.value,
})
}
placeholder="0 9 * * 1-5"
className="mt-1 text-xs font-mono"
/>
</div>
<div>
<Label className="text-xs text-muted-foreground">
Timezone
</Label>
<Input
type="text"
value={(trigger.config as { timezone?: string }).timezone ?? ""}
onChange={(e) =>
updateTriggerConfig(trigger.id, {
...trigger.config,
timezone: e.target.value,
})
}
placeholder="UTC"
className="mt-1 text-xs"
/>
</div>
</div>
)}
</div>
))}
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="xs"
onClick={() => addTrigger("on_assign")}
className="border-dashed text-muted-foreground hover:text-foreground"
>
<Bot className="h-3 w-3" />
Add On Assign
</Button>
<Button
variant="outline"
size="xs"
onClick={() => addTrigger("on_comment")}
className="border-dashed text-muted-foreground hover:text-foreground"
>
<MessageSquare className="h-3 w-3" />
Add On Comment
</Button>
<Button
variant="outline"
size="xs"
onClick={() => addTrigger("scheduled")}
className="border-dashed text-muted-foreground hover:text-foreground"
>
<Timer className="h-3 w-3" />
Add Scheduled
</Button>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Tasks Tab
// ---------------------------------------------------------------------------
@@ -595,8 +1056,7 @@ function SkillsTab({
function TasksTab({ agent }: { agent: Agent }) {
const [tasks, setTasks] = useState<AgentTask[]>([]);
const [loading, setLoading] = useState(true);
const wsId = useWorkspaceId();
const { data: issues = [] } = useQuery(issueListOptions(wsId));
const issues = useIssueStore((s) => s.issues);
useEffect(() => {
setLoading(true);
@@ -734,7 +1194,7 @@ function SettingsTab({
const [visibility, setVisibility] = useState<AgentVisibility>(agent.visibility);
const [maxTasks, setMaxTasks] = useState(agent.max_concurrent_tasks);
const [saving, setSaving] = useState(false);
const { upload, uploading } = useFileUpload(api);
const { upload, uploading } = useFileUpload();
const fileInputRef = useRef<HTMLInputElement>(null);
const handleAvatarUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
@@ -899,11 +1359,13 @@ function SettingsTab({
// Agent Detail
// ---------------------------------------------------------------------------
type DetailTab = "instructions" | "skills" | "tasks" | "settings";
type DetailTab = "instructions" | "skills" | "tools" | "triggers" | "tasks" | "settings";
const detailTabs: { id: DetailTab; label: string; icon: typeof FileText }[] = [
{ id: "instructions", label: "Instructions", icon: FileText },
{ id: "skills", label: "Skills", icon: BookOpenText },
{ id: "tools", label: "Tools", icon: Wrench },
{ id: "triggers", label: "Triggers", icon: Timer },
{ id: "tasks", label: "Tasks", icon: ListTodo },
{ id: "settings", label: "Settings", icon: Settings },
];
@@ -1017,6 +1479,18 @@ function AgentDetail({
{activeTab === "skills" && (
<SkillsTab agent={agent} />
)}
{activeTab === "tools" && (
<ToolsTab
agent={agent}
onSave={(tools) => onUpdate(agent.id, { tools })}
/>
)}
{activeTab === "triggers" && (
<TriggersTab
agent={agent}
onSave={(triggers) => onUpdate(agent.id, { triggers })}
/>
)}
{activeTab === "tasks" && <TasksTab agent={agent} />}
{activeTab === "settings" && (
<SettingsTab
@@ -1070,17 +1544,21 @@ function AgentDetail({
export default function AgentsPage() {
const isLoading = useAuthStore((s) => s.isLoading);
const workspace = useWorkspaceStore((s) => s.workspace);
const qc = useQueryClient();
const wsId = useWorkspaceId();
const { data: agents = [] } = useQuery(agentListOptions(wsId));
const agents = useWorkspaceStore((s) => s.agents);
const refreshAgents = useWorkspaceStore((s) => s.refreshAgents);
const [selectedId, setSelectedId] = useState<string>("");
const [showArchived, setShowArchived] = useState(false);
const [showCreate, setShowCreate] = useState(false);
const { data: runtimes = [] } = useQuery(runtimeListOptions(wsId));
const runtimes = useRuntimeStore((s) => s.runtimes);
const fetchRuntimes = useRuntimeStore((s) => s.fetchRuntimes);
const { defaultLayout, onLayoutChanged } = useDefaultLayout({
id: "multica_agents_layout",
});
useEffect(() => {
if (workspace) fetchRuntimes();
}, [workspace, fetchRuntimes]);
const filteredAgents = useMemo(
() => showArchived ? agents.filter((a) => !!a.archived_at) : agents.filter((a) => !a.archived_at),
[agents, showArchived],
@@ -1097,14 +1575,14 @@ export default function AgentsPage() {
const handleCreate = async (data: CreateAgentRequest) => {
const agent = await api.createAgent(data);
qc.invalidateQueries({ queryKey: workspaceKeys.agents(wsId) });
await refreshAgents();
setSelectedId(agent.id);
};
const handleUpdate = async (id: string, data: Record<string, unknown>) => {
try {
await api.updateAgent(id, data as UpdateAgentRequest);
qc.invalidateQueries({ queryKey: workspaceKeys.agents(wsId) });
await refreshAgents();
toast.success("Agent updated");
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed to update agent");
@@ -1115,7 +1593,7 @@ export default function AgentsPage() {
const handleArchive = async (id: string) => {
try {
await api.archiveAgent(id);
qc.invalidateQueries({ queryKey: workspaceKeys.agents(wsId) });
await refreshAgents();
toast.success("Agent archived");
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed to archive agent");
@@ -1125,7 +1603,7 @@ export default function AgentsPage() {
const handleRestore = async (id: string) => {
try {
await api.restoreAgent(id);
qc.invalidateQueries({ queryKey: workspaceKeys.agents(wsId) });
await refreshAgents();
toast.success("Agent restored");
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed to restore agent");

View File

@@ -1,26 +1,13 @@
"use client";
import { useState, useEffect, useCallback, useMemo } from "react";
import { useState, useEffect, useCallback } from "react";
import { useSearchParams } from "next/navigation";
import { useDefaultLayout } from "react-resizable-panels";
import { useQuery } from "@tanstack/react-query";
import { useWorkspaceId } from "@multica/core/hooks";
import {
inboxListOptions,
deduplicateInboxItems,
} from "@multica/core/inbox/queries";
import {
useMarkInboxRead,
useArchiveInbox,
useMarkAllInboxRead,
useArchiveAllInbox,
useArchiveAllReadInbox,
useArchiveCompletedInbox,
} from "@multica/core/inbox/mutations";
import { IssueDetail, StatusIcon, PriorityIcon } from "@multica/views/issues/components";
import { STATUS_CONFIG, PRIORITY_CONFIG } from "@multica/core/issues/config";
import { useActorName } from "@multica/core/workspace/hooks";
import { ActorAvatar } from "@multica/views/common/actor-avatar";
import { 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,
@@ -31,21 +18,22 @@ import {
BookCheck,
ListChecks,
} from "lucide-react";
import type { InboxItem, InboxItemType, IssueStatus, IssuePriority } from "@multica/core/types";
import { Button } from "@multica/ui/components/ui/button";
import type { InboxItem, InboxItemType, IssueStatus, IssuePriority } from "@/shared/types";
import { Button } from "@/components/ui/button";
import {
ResizablePanelGroup,
ResizablePanel,
ResizableHandle,
} from "@multica/ui/components/ui/resizable";
import { Skeleton } from "@multica/ui/components/ui/skeleton";
} from "@/components/ui/resizable";
import { Skeleton } from "@/components/ui/skeleton";
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
} from "@multica/ui/components/ui/dropdown-menu";
} from "@/components/ui/dropdown-menu";
import { api } from "@/shared/api";
// ---------------------------------------------------------------------------
// Helpers
@@ -247,9 +235,8 @@ export default function InboxPage() {
window.history.replaceState(null, "", url);
}, []);
const wsId = useWorkspaceId();
const { data: rawItems = [], isLoading: loading } = useQuery(inboxListOptions(wsId));
const items = useMemo(() => deduplicateInboxItems(rawItems), [rawItems]);
const items = useInboxStore((s) => s.dedupedItems());
const loading = useInboxStore((s) => s.loading);
const { defaultLayout, onLayoutChanged } = useDefaultLayout({
id: "multica_inbox_layout",
@@ -258,58 +245,74 @@ export default function InboxPage() {
const selected = items.find((i) => (i.issue_id ?? i.id) === selectedKey) ?? null;
const unreadCount = items.filter((i) => !i.read).length;
const markReadMutation = useMarkInboxRead();
const archiveMutation = useArchiveInbox();
const markAllReadMutation = useMarkAllInboxRead();
const archiveAllMutation = useArchiveAllInbox();
const archiveAllReadMutation = useArchiveAllReadInbox();
const archiveCompletedMutation = useArchiveCompletedInbox();
// Click-to-read: select + auto-mark-read
const handleSelect = (item: InboxItem) => {
const handleSelect = async (item: InboxItem) => {
setSelectedKey(item.issue_id ?? item.id);
if (!item.read) {
markReadMutation.mutate(item.id, {
onError: () => toast.error("Failed to mark as 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 = (id: string) => {
const archived = items.find((i) => i.id === id);
if (archived && (archived.issue_id ?? archived.id) === selectedKey) setSelectedKey("");
archiveMutation.mutate(id, {
onError: () => toast.error("Failed to archive"),
});
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 = () => {
markAllReadMutation.mutate(undefined, {
onError: () => toast.error("Failed to mark all as read"),
});
const handleMarkAllRead = async () => {
try {
useInboxStore.getState().markAllRead();
await api.markAllInboxRead();
} catch {
toast.error("Failed to mark all as read");
useInboxStore.getState().fetch();
}
};
const handleArchiveAll = () => {
setSelectedKey("");
archiveAllMutation.mutate(undefined, {
onError: () => toast.error("Failed to archive all"),
});
const handleArchiveAll = async () => {
try {
useInboxStore.getState().archiveAll();
setSelectedKey("");
await api.archiveAllInbox();
} catch {
toast.error("Failed to archive all");
useInboxStore.getState().fetch();
}
};
const handleArchiveAllRead = () => {
const readKeys = items.filter((i) => i.read).map((i) => i.issue_id ?? i.id);
if (readKeys.includes(selectedKey)) setSelectedKey("");
archiveAllReadMutation.mutate(undefined, {
onError: () => toast.error("Failed to archive read items"),
});
const 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 = () => {
setSelectedKey("");
archiveCompletedMutation.mutate(undefined, {
onError: () => toast.error("Failed to archive completed"),
});
const handleArchiveCompleted = async () => {
try {
await api.archiveCompletedInbox();
setSelectedKey("");
await useInboxStore.getState().fetch();
} catch {
toast.error("Failed to archive completed");
}
};
if (loading) {

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,34 @@ 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,
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 +99,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 +106,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 +160,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 = {
@@ -418,28 +235,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!;
@@ -451,8 +254,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(() => {
@@ -467,8 +270,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(() => {
@@ -480,8 +283,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(() => {
@@ -496,8 +299,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(() => {
@@ -506,8 +309,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",
@@ -522,7 +325,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();
@@ -554,8 +357,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");
});
@@ -566,8 +369,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() }),
@@ -367,86 +282,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

@@ -3,13 +3,10 @@
import { useEffect } from "react";
import { useRouter, usePathname } from "next/navigation";
import { MulticaIcon } from "@/components/multica-icon";
import { useNavigationStore } from "@multica/core/navigation";
import { SidebarProvider, SidebarInset } from "@multica/ui/components/ui/sidebar";
import { useAuthStore } from "@/platform/auth";
import { useWorkspaceStore } from "@/platform/workspace";
import { WorkspaceIdProvider } from "@multica/core/hooks";
import { ModalRegistry } from "@multica/views/modals/registry";
import { SearchCommand } from "@/features/search";
import { 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({
@@ -48,17 +45,13 @@ export default function DashboardLayout({
<AppSidebar />
<SidebarInset className="overflow-hidden">
{workspace ? (
<WorkspaceIdProvider wsId={workspace.id}>
{children}
<ModalRegistry />
</WorkspaceIdProvider>
children
) : (
<div className="flex flex-1 items-center justify-center">
<MulticaIcon className="size-6 animate-pulse" />
</div>
)}
</SidebarInset>
<SearchCommand />
</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 +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 "@/platform/auth";
import { api } from "@/platform/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 "next-themes";
import { cn } from "@multica/ui/lib/utils";
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 "@multica/views/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 "@/platform/auth";
import { useWorkspaceStore } from "@/platform/workspace";
import { useWorkspaceId } from "@multica/core/hooks";
import { memberListOptions, workspaceKeys } from "@multica/core/workspace/queries";
import { api } from "@/platform/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 "@/platform/auth";
import { useWorkspaceStore } from "@/platform/workspace";
import { useWorkspaceId } from "@multica/core/hooks";
import { memberListOptions } from "@multica/core/workspace/queries";
import { api } from "@/platform/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 "@/platform/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 "@/platform/auth";
import { useWorkspaceStore } from "@/platform/workspace";
import { useWorkspaceId } from "@multica/core/hooks";
import { memberListOptions } from "@multica/core/workspace/queries";
import { api } from "@/platform/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,8 +1,8 @@
"use client";
import { User, Palette, Key, Settings, Users, FolderGit2 } from "lucide-react";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@multica/ui/components/ui/tabs";
import { useWorkspaceStore } from "@/platform/workspace";
import { 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";

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 "@/platform/auth";
import { useWorkspaceStore } from "@/platform/workspace";
import { api } from "@/platform/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,14 +1,149 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@import "@multica/ui/styles/tokens.css";
@import "./custom.css";
@custom-variant dark (&:is(.dark *));
@source "../../../packages/ui/**/*.tsx";
@source "../../../packages/core/**/*.tsx";
@source "../../../packages/views/**/*.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 {
* {
@@ -26,4 +161,4 @@
html {
@apply font-sans;
}
}
}

View File

@@ -1,13 +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 { QueryProvider } from "@multica/core/provider";
import { Toaster } from "@/components/ui/sonner";
import { cn } from "@/lib/utils";
import { AuthInitializer } from "@/features/auth";
import { WebWSProvider } from "@/platform/ws-provider";
import { WebNavigationProvider } from "@/platform/navigation";
import { LocaleSync } from "@/components/locale-sync";
import { WSProvider } from "@/features/realtime";
import { ModalRegistry } from "@/features/modals";
import "./globals.css";
const geist = Geist({ subsets: ["latin"], variable: "--font-sans" });
@@ -51,28 +50,28 @@ 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>
<QueryProvider>
<WebNavigationProvider>
<AuthInitializer>
<WebWSProvider>{children}</WebWSProvider>
</AuthInitializer>
</WebNavigationProvider>
<Toaster />
</QueryProvider>
<AuthInitializer>
<WSProvider>{children}</WSProvider>
</AuthInitializer>
<ModalRegistry />
<Toaster />
</ThemeProvider>
</body>
</html>

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 "@multica/ui/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,7 +1,7 @@
"use client"
import { ThemeProvider as NextThemesProvider } from "next-themes"
import { TooltipProvider } from "@multica/ui/components/ui/tooltip"
import { TooltipProvider } from "@/components/ui/tooltip"
function ThemeProvider({
children,

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) {

View File

@@ -3,14 +3,14 @@
import * as React from "react"
import { Combobox as ComboboxPrimitive } from "@base-ui/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 {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
} from "@multica/ui/components/ui/input-group"
} from "@/components/ui/input-group"
import { ChevronDownIcon, XIcon, CheckIcon } from "lucide-react"
const Combobox = ComboboxPrimitive.Root

View File

@@ -3,18 +3,18 @@
import * as React from "react"
import { Command as CommandPrimitive } from "cmdk"
import { cn } from "@multica/ui/lib/utils"
import { cn } from "@/lib/utils"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@multica/ui/components/ui/dialog"
} from "@/components/ui/dialog"
import {
InputGroup,
InputGroupAddon,
} from "@multica/ui/components/ui/input-group"
} from "@/components/ui/input-group"
import { SearchIcon, CheckIcon } from "lucide-react"
function Command({

View File

@@ -3,7 +3,7 @@
import * as React from "react"
import { ContextMenu as ContextMenuPrimitive } from "@base-ui/react/context-menu"
import { cn } from "@multica/ui/lib/utils"
import { cn } from "@/lib/utils"
import { ChevronRightIcon, CheckIcon } from "lucide-react"
function ContextMenu({ ...props }: ContextMenuPrimitive.Root.Props) {

View File

@@ -3,8 +3,8 @@
import * as React from "react"
import { Dialog as DialogPrimitive } from "@base-ui/react/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"
import { XIcon } from "lucide-react"
function Dialog({ ...props }: DialogPrimitive.Root.Props) {

View File

@@ -3,7 +3,7 @@
import * as React from "react"
import { Drawer as DrawerPrimitive } from "vaul"
import { cn } from "@multica/ui/lib/utils"
import { cn } from "@/lib/utils"
function Drawer({
...props

View File

@@ -3,7 +3,7 @@
import * as React from "react"
import { Menu as MenuPrimitive } from "@base-ui/react/menu"
import { cn } from "@multica/ui/lib/utils"
import { cn } from "@/lib/utils"
import { ChevronRightIcon, CheckIcon } from "lucide-react"
function DropdownMenu({ ...props }: MenuPrimitive.Root.Props) {

View File

@@ -1,6 +1,6 @@
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@multica/ui/lib/utils"
import { cn } from "@/lib/utils"
function Empty({ className, ...props }: React.ComponentProps<"div">) {
return (

View File

@@ -3,9 +3,9 @@
import { useMemo } from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@multica/ui/lib/utils"
import { Label } from "@multica/ui/components/ui/label"
import { Separator } from "@multica/ui/components/ui/separator"
import { cn } from "@/lib/utils"
import { Label } from "@/components/ui/label"
import { Separator } from "@/components/ui/separator"
function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
return (

View File

@@ -2,7 +2,7 @@
import { PreviewCard as PreviewCardPrimitive } from "@base-ui/react/preview-card"
import { cn } from "@multica/ui/lib/utils"
import { cn } from "@/lib/utils"
function HoverCard({ ...props }: PreviewCardPrimitive.Root.Props) {
return <PreviewCardPrimitive.Root data-slot="hover-card" {...props} />

View File

@@ -3,10 +3,10 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@multica/ui/lib/utils"
import { Button } from "@multica/ui/components/ui/button"
import { Input } from "@multica/ui/components/ui/input"
import { Textarea } from "@multica/ui/components/ui/textarea"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
return (

View File

@@ -3,7 +3,7 @@
import * as React from "react"
import { OTPInput, OTPInputContext } from "input-otp"
import { cn } from "@multica/ui/lib/utils"
import { cn } from "@/lib/utils"
import { MinusIcon } from "lucide-react"
function InputOTP({

View File

@@ -1,7 +1,7 @@
import * as React from "react"
import { Input as InputPrimitive } from "@base-ui/react/input"
import { cn } from "@multica/ui/lib/utils"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (

View File

@@ -3,8 +3,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"
function ItemGroup({ className, ...props }: React.ComponentProps<"div">) {
return (

View File

@@ -1,4 +1,4 @@
import { cn } from "@multica/ui/lib/utils"
import { cn } from "@/lib/utils"
function Kbd({ className, ...props }: React.ComponentProps<"kbd">) {
return (

View File

@@ -2,7 +2,7 @@
import * as React from "react"
import { cn } from "@multica/ui/lib/utils"
import { cn } from "@/lib/utils"
function Label({ className, ...props }: React.ComponentProps<"label">) {
return (

View File

@@ -4,7 +4,7 @@ import * as React from "react"
import { Menu as MenuPrimitive } from "@base-ui/react/menu"
import { Menubar as MenubarPrimitive } from "@base-ui/react/menubar"
import { cn } from "@multica/ui/lib/utils"
import { cn } from "@/lib/utils"
import {
DropdownMenu,
DropdownMenuContent,
@@ -19,7 +19,7 @@ import {
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@multica/ui/components/ui/dropdown-menu"
} from "@/components/ui/dropdown-menu"
import { CheckIcon } from "lucide-react"
function Menubar({ className, ...props }: MenubarPrimitive.Props) {

View File

@@ -1,6 +1,6 @@
import * as React from "react"
import { cn } from "@multica/ui/lib/utils"
import { cn } from "@/lib/utils"
import { ChevronDownIcon } from "lucide-react"
type NativeSelectProps = Omit<React.ComponentProps<"select">, "size"> & {

View File

@@ -1,7 +1,7 @@
import { NavigationMenu as NavigationMenuPrimitive } from "@base-ui/react/navigation-menu"
import { cva } from "class-variance-authority"
import { cn } from "@multica/ui/lib/utils"
import { cn } from "@/lib/utils"
import { ChevronDownIcon } from "lucide-react"
function NavigationMenu({

View File

@@ -1,7 +1,7 @@
import * as React from "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, MoreHorizontalIcon } from "lucide-react"
function Pagination({ className, ...props }: React.ComponentProps<"nav">) {

View File

@@ -3,7 +3,7 @@
import * as React from "react"
import { Popover as PopoverPrimitive } from "@base-ui/react/popover"
import { cn } from "@multica/ui/lib/utils"
import { cn } from "@/lib/utils"
function Popover({ ...props }: PopoverPrimitive.Root.Props) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />

View File

@@ -2,7 +2,7 @@
import { Progress as ProgressPrimitive } from "@base-ui/react/progress"
import { cn } from "@multica/ui/lib/utils"
import { cn } from "@/lib/utils"
function Progress({
className,

View File

@@ -3,7 +3,7 @@
import { Radio as RadioPrimitive } from "@base-ui/react/radio"
import { RadioGroup as RadioGroupPrimitive } from "@base-ui/react/radio-group"
import { cn } from "@multica/ui/lib/utils"
import { cn } from "@/lib/utils"
function RadioGroup({ className, ...props }: RadioGroupPrimitive.Props) {
return (

View File

@@ -2,7 +2,7 @@
import * as ResizablePrimitive from "react-resizable-panels"
import { cn } from "@multica/ui/lib/utils"
import { cn } from "@/lib/utils"
function ResizablePanelGroup({
className,

View File

@@ -3,7 +3,7 @@
import * as React from "react"
import { ScrollArea as ScrollAreaPrimitive } from "@base-ui/react/scroll-area"
import { cn } from "@multica/ui/lib/utils"
import { cn } from "@/lib/utils"
function ScrollArea({
className,

View File

@@ -3,7 +3,7 @@
import * as React from "react"
import { Select as SelectPrimitive } from "@base-ui/react/select"
import { cn } from "@multica/ui/lib/utils"
import { cn } from "@/lib/utils"
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
const Select = SelectPrimitive.Root

View File

@@ -2,7 +2,7 @@
import { Separator as SeparatorPrimitive } from "@base-ui/react/separator"
import { cn } from "@multica/ui/lib/utils"
import { cn } from "@/lib/utils"
function Separator({
className,

View File

@@ -3,8 +3,8 @@
import * as React from "react"
import { Dialog as SheetPrimitive } from "@base-ui/react/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"
import { XIcon } from "lucide-react"
function Sheet({ ...props }: SheetPrimitive.Root.Props) {

View File

@@ -5,24 +5,24 @@ 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 { useIsMobile } from "@multica/ui/hooks/use-mobile"
import { cn } from "@multica/ui/lib/utils"
import { Button } from "@multica/ui/components/ui/button"
import { Input } from "@multica/ui/components/ui/input"
import { Separator } from "@multica/ui/components/ui/separator"
import { useIsMobile } from "@/hooks/use-mobile"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@multica/ui/components/ui/sheet"
import { Skeleton } from "@multica/ui/components/ui/skeleton"
} from "@/components/ui/sheet"
import { Skeleton } from "@/components/ui/skeleton"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@multica/ui/components/ui/tooltip"
} from "@/components/ui/tooltip"
import { PanelLeftIcon } from "lucide-react"
const SIDEBAR_COOKIE_NAME = "sidebar_state"

View File

@@ -1,4 +1,4 @@
import { cn } from "@multica/ui/lib/utils"
import { cn } from "@/lib/utils"
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (

View File

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

View File

@@ -1,4 +1,4 @@
import { cn } from "@multica/ui/lib/utils"
import { cn } from "@/lib/utils"
import { Loader2Icon } from "lucide-react"
function Spinner({ className, ...props }: React.ComponentProps<"svg">) {

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