mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-26 08:59:31 +02:00
Compare commits
146 Commits
agent/lamb
...
docs/updat
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a9b3d4e6f4 | ||
|
|
711ab886e2 | ||
|
|
a092443a09 | ||
|
|
de73d39310 | ||
|
|
ff27a249cc | ||
|
|
4668aad039 | ||
|
|
b484b78cbd | ||
|
|
23136da34f | ||
|
|
5d1cc2a9bb | ||
|
|
f41a0cf423 | ||
|
|
35828492d5 | ||
|
|
e1e7f68330 | ||
|
|
e2da970344 | ||
|
|
b3fa5557ca | ||
|
|
19a1bbba4a | ||
|
|
f57cf44eba | ||
|
|
ae797811d2 | ||
|
|
7d01cf8c68 | ||
|
|
e79eabcc18 | ||
|
|
d2e4b9753d | ||
|
|
fab17b48b3 | ||
|
|
4f8969ef52 | ||
|
|
2e5b8b9a87 | ||
|
|
f4ba27f2f5 | ||
|
|
e6f840ca11 | ||
|
|
aa770f2333 | ||
|
|
bd6731525e | ||
|
|
68d052625c | ||
|
|
3d053345fd | ||
|
|
180c6966db | ||
|
|
0c45864ef0 | ||
|
|
c6ba954eb8 | ||
|
|
76354cd968 | ||
|
|
4bdb86057e | ||
|
|
a8a8ff6eca | ||
|
|
0dcaa60919 | ||
|
|
17e37ec4db | ||
|
|
060afc848c | ||
|
|
1903b886f6 | ||
|
|
240813c605 | ||
|
|
7d74b1f0b9 | ||
|
|
39ca8ed9e8 | ||
|
|
3c08395741 | ||
|
|
ec934f3a8b | ||
|
|
25cf64588d | ||
|
|
301a4a3882 | ||
|
|
102b19d948 | ||
|
|
a7afd4b959 | ||
|
|
8403c97688 | ||
|
|
7df5750979 | ||
|
|
990cc8b3ae | ||
|
|
7ee2450297 | ||
|
|
d58f6cdb33 | ||
|
|
af156040cb | ||
|
|
5e770b2e2f | ||
|
|
92e76dea81 | ||
|
|
4df32a853b | ||
|
|
fa0c0fe747 | ||
|
|
8a8d3ea20e | ||
|
|
88c2f4ddc4 | ||
|
|
98af9f442c | ||
|
|
34c39b765e | ||
|
|
efe131591f | ||
|
|
104bbbef41 | ||
|
|
eed8e36a69 | ||
|
|
8cf78b7a47 | ||
|
|
862b85e064 | ||
|
|
857ec7d4d4 | ||
|
|
7c79611309 | ||
|
|
99dad49052 | ||
|
|
6296629831 | ||
|
|
7ed565da6b | ||
|
|
030627c8c5 | ||
|
|
fe9479d6fc | ||
|
|
b94108768e | ||
|
|
348133b63d | ||
|
|
6032b5dfcb | ||
|
|
23198f3c26 | ||
|
|
e40341ab73 | ||
|
|
c695de5314 | ||
|
|
d6b59aade6 | ||
|
|
1d812bd446 | ||
|
|
abcc7bf3cd | ||
|
|
06fa65d4b5 | ||
|
|
9d1570b301 | ||
|
|
7f2ea9857d | ||
|
|
1ad057fb0f | ||
|
|
b85c068e83 | ||
|
|
30cda933bc | ||
|
|
b5537077bc | ||
|
|
638033c9ff | ||
|
|
7560f7be85 | ||
|
|
b84104b421 | ||
|
|
0c92fb2674 | ||
|
|
14fe8e9df9 | ||
|
|
f9c0fcba24 | ||
|
|
47917825d1 | ||
|
|
eab5f8e7e8 | ||
|
|
9495179923 | ||
|
|
f16b36fbc8 | ||
|
|
dd2ce90b1d | ||
|
|
88b87e2fa6 | ||
|
|
2be9f6cd2f | ||
|
|
5cf4ba803d | ||
|
|
cfb0365cb3 | ||
|
|
81d430d870 | ||
|
|
96d81f9836 | ||
|
|
5fe1ec806d | ||
|
|
2f63714dba | ||
|
|
4cf18e122d | ||
|
|
02a7598906 | ||
|
|
0263ecce9e | ||
|
|
d450b3d454 | ||
|
|
f1140222a1 | ||
|
|
66067a267a | ||
|
|
76c6b41033 | ||
|
|
29507a2e3a | ||
|
|
ceec6d3795 | ||
|
|
08ba74b399 | ||
|
|
ed7a288946 | ||
|
|
a26f9e965b | ||
|
|
6574d68d2b | ||
|
|
3bf094ebf7 | ||
|
|
72da372eba | ||
|
|
5fba76f010 | ||
|
|
09565bc40f | ||
|
|
4036d64996 | ||
|
|
5b0a537302 | ||
|
|
0d9d4e6b69 | ||
|
|
4c0dbbf1c8 | ||
|
|
52a9a6ae5f | ||
|
|
d6a5ba4d5e | ||
|
|
4afef09a03 | ||
|
|
0771c15a59 | ||
|
|
3a96567fc1 | ||
|
|
9d9e0317c0 | ||
|
|
5f2ac17129 | ||
|
|
4df3a52c4e | ||
|
|
9aee403ff9 | ||
|
|
7883fe7bd7 | ||
|
|
cbfb7d58b6 | ||
|
|
2832a06fe3 | ||
|
|
2787bd60be | ||
|
|
e879d82e7d | ||
|
|
ad0615a08f | ||
|
|
b1f7364097 |
@@ -29,6 +29,7 @@ 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=
|
||||
|
||||
34
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
34
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
@@ -0,0 +1,34 @@
|
||||
## 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
201
CLAUDE.md
@@ -12,77 +12,162 @@ Multica is an AI-native task management platform — like Linear, but with AI ag
|
||||
|
||||
## Architecture
|
||||
|
||||
**Go backend + standalone Next.js frontend.**
|
||||
**Go backend + monorepo frontend with shared packages.**
|
||||
|
||||
- `server/` — Go backend (Chi router, sqlc for DB, gorilla/websocket for real-time)
|
||||
- `apps/web/` — Next.js 16 frontend (App Router) — self-contained, no shared package dependencies
|
||||
- `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
|
||||
|
||||
### Web App Structure (`apps/web/`)
|
||||
### Package Architecture
|
||||
|
||||
The frontend uses a **feature-based architecture** with four layers:
|
||||
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.
|
||||
|
||||
```
|
||||
apps/web/
|
||||
├── app/ # Routing layer (thin shells — import from features/)
|
||||
├── features/ # Business logic, organized by domain
|
||||
├── shared/ # Cross-feature utilities (api client, types, logger)
|
||||
├── 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/`** — 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.
|
||||
**`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)
|
||||
|
||||
### State Management
|
||||
|
||||
- **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/`).
|
||||
- **TanStack Query** for all server state — issues, inbox, members, agents, skills, runtimes. Query definitions in `@multica/core/<domain>/queries.ts`, mutations in `mutations.ts`.
|
||||
- **Zustand** for client-only state — UI selections (`activeIssueId`), view filters, modal state. Auth and workspace stores use factory pattern with injected dependencies.
|
||||
- **React Context** for `WorkspaceIdProvider` (provides workspace ID to all dashboard children) and `NavigationProvider` (provides platform-agnostic routing).
|
||||
- **Local `useState`** for component-scoped UI state (forms, modals, filters).
|
||||
- Do not use React Context for data that can be a zustand store.
|
||||
|
||||
**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.
|
||||
**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.
|
||||
|
||||
### Import Aliases
|
||||
**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
|
||||
|
||||
Use `@/` alias (maps to `apps/web/`):
|
||||
```typescript
|
||||
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";
|
||||
// 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";
|
||||
```
|
||||
|
||||
Within a feature, use relative imports. Between features or to shared, use `@/`.
|
||||
`@/` maps to `apps/web/`. Within a package, use relative imports. Between packages, use `@multica/*`.
|
||||
|
||||
### Data Flow
|
||||
|
||||
```
|
||||
Browser → ApiClient (shared/api) → REST API (Chi handlers) → sqlc queries → PostgreSQL
|
||||
Browser ← WSClient (shared/api) ← WebSocket ← Hub.Broadcast() ← Handlers/TaskService
|
||||
Browser → useQuery (@multica/core) → ApiClient (@multica/core/api) → REST API → sqlc → PostgreSQL
|
||||
Browser ← useQuery cache ← invalidateQueries ← WS event handlers ← WSClient ← Hub.Broadcast()
|
||||
```
|
||||
|
||||
Mutations: `useMutation (@multica/core)` → optimistic cache update → API call → onSettled invalidation.
|
||||
WS events: `use-realtime-sync.ts` → `queryClient.invalidateQueries()` for most events, `setQueryData()` for granular issue/inbox updates.
|
||||
|
||||
### Backend Structure (`server/`)
|
||||
|
||||
- **Entry points** (`cmd/`): `server` (HTTP API), `multica` (CLI — daemon, agent management, config), `migrate`
|
||||
@@ -115,13 +200,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
|
||||
# Frontend (all commands go through Turborepo)
|
||||
pnpm install
|
||||
pnpm dev:web # Next.js dev server (port 3000)
|
||||
pnpm build # Build frontend
|
||||
pnpm typecheck # TypeScript check
|
||||
pnpm typecheck # TypeScript check (all packages via turbo)
|
||||
pnpm lint # ESLint via Next.js
|
||||
pnpm test # TS tests (Vitest)
|
||||
pnpm test # TS tests (Vitest, via turbo)
|
||||
|
||||
# Backend (Go)
|
||||
make dev # Run Go server (port 8080)
|
||||
@@ -142,6 +227,9 @@ 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
|
||||
@@ -172,12 +260,21 @@ 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 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.
|
||||
- Prefer shadcn components over custom implementations. Install via `npx shadcn add <component> -c apps/web` (monorepo flag required).
|
||||
- **Shared UI components** → `packages/ui/components/` — shadcn primitives and pure-props common components.
|
||||
- **Shared business components** → `packages/views/<domain>/components/` — pages and domain-bound UI.
|
||||
- **Web-only components** → `apps/web/features/` or `apps/web/components/`.
|
||||
- Use shadcn design tokens for styling (e.g. `bg-primary`, `text-muted-foreground`, `text-destructive`). Avoid hardcoded color values (e.g. `text-red-500`, `bg-gray-100`).
|
||||
- Do not introduce extra state (useState, context, reducers) unless explicitly required by the design. Prefer zustand stores for shared state over React Context.
|
||||
- Do not introduce extra state (useState, context, reducers) unless explicitly required by the design.
|
||||
- Pay close attention to **overflow** (truncate long text, scrollable containers), **alignment**, and **spacing** consistency.
|
||||
- When unsure about interaction or state design, ask — the user will provide direction.
|
||||
|
||||
|
||||
@@ -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-cli
|
||||
brew install multica
|
||||
```
|
||||
|
||||
### Build from Source
|
||||
|
||||
171
CLI_INSTALL.md
Normal file
171
CLI_INSTALL.md
Normal file
@@ -0,0 +1,171 @@
|
||||
# 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
221
LICENSE
@@ -1,199 +1,44 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
# Open Source License
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
Multica is licensed under a modified version of the Apache License 2.0, with the following additional conditions:
|
||||
|
||||
1. Definitions.
|
||||
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:
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
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.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
- 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.
|
||||
|
||||
"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.
|
||||
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.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
- 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.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
2. As a contributor, you should agree that:
|
||||
|
||||
"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.
|
||||
a. The producer can adjust the open-source agreement to be more strict
|
||||
or relaxed as deemed necessary.
|
||||
|
||||
"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).
|
||||
b. Your contributed code may be used for commercial purposes, including
|
||||
but not limited to its cloud business operations.
|
||||
|
||||
"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.
|
||||
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.
|
||||
|
||||
"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.
|
||||
© 2025 Multica, Inc.
|
||||
|
||||
17
Makefile
17
Makefile
@@ -69,7 +69,12 @@ stop:
|
||||
@echo "Stopping services..."
|
||||
@-lsof -ti:$(PORT) | xargs kill -9 2>/dev/null
|
||||
@-lsof -ti:$(FRONTEND_PORT) | xargs kill -9 2>/dev/null
|
||||
@echo "✓ App processes stopped. Shared PostgreSQL is still running on localhost:5432."
|
||||
@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
|
||||
|
||||
# Full verification: typecheck + unit tests + Go tests + E2E
|
||||
check:
|
||||
@@ -98,8 +103,12 @@ check-main:
|
||||
@ENV_FILE=$(MAIN_ENV_FILE) bash scripts/check.sh
|
||||
|
||||
setup-worktree:
|
||||
@echo "==> Generating $(WORKTREE_ENV_FILE) with unique ports..."
|
||||
@FORCE=1 bash scripts/init-worktree-env.sh $(WORKTREE_ENV_FILE)
|
||||
@if [ ! -f "$(WORKTREE_ENV_FILE)" ]; then \
|
||||
echo "==> Generating $(WORKTREE_ENV_FILE) with unique ports..."; \
|
||||
bash scripts/init-worktree-env.sh $(WORKTREE_ENV_FILE); \
|
||||
else \
|
||||
echo "==> Using existing $(WORKTREE_ENV_FILE)"; \
|
||||
fi
|
||||
@$(MAKE) setup ENV_FILE=$(WORKTREE_ENV_FILE)
|
||||
|
||||
start-worktree:
|
||||
@@ -134,10 +143,12 @@ 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
|
||||
|
||||
20
README.md
20
README.md
@@ -14,8 +14,8 @@
|
||||
|
||||
**Your next 10 hires won't be human.**
|
||||
|
||||
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.
|
||||
The open-source managed agents platform.<br/>
|
||||
Turn coding agents into real teammates — assign tasks, track progress, compound skills.
|
||||
|
||||
[](https://github.com/multica-ai/multica/actions/workflows/ci.yml)
|
||||
[](https://opensource.org/licenses/Apache-2.0)
|
||||
@@ -31,7 +31,7 @@ Assign tasks, track progress, compound skills — manage your human + agent work
|
||||
|
||||
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. Works with **Claude Code** and **Codex**.
|
||||
No more copy-pasting prompts. No more babysitting runs. Your agents show up on the board, participate in conversations, and compound reusable skills over time. Think of it as open-source infrastructure for managed agents — vendor-neutral, self-hosted, and designed for human + AI teams. Works with **Claude Code** and **Codex**.
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/assets/hero-screenshot.png" alt="Multica board view" width="800">
|
||||
@@ -39,6 +39,8 @@ 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.
|
||||
@@ -70,6 +72,14 @@ 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
|
||||
@@ -148,7 +158,3 @@ make start
|
||||
```
|
||||
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md) for the full development workflow, worktree support, testing, and troubleshooting.
|
||||
|
||||
## License
|
||||
|
||||
[Apache 2.0](LICENSE)
|
||||
|
||||
@@ -14,8 +14,8 @@
|
||||
|
||||
**你的下一批员工,不是人类。**
|
||||
|
||||
开源平台,将编码 Agent 变成真正的队友。<br/>
|
||||
分配任务、跟踪进度、积累技能——在一个地方管理你的人类 + Agent 团队。
|
||||
开源的 Managed Agents 平台。<br/>
|
||||
将编码 Agent 变成真正的队友——分配任务、跟踪进度、积累技能。
|
||||
|
||||
[](https://github.com/multica-ai/multica/actions/workflows/ci.yml)
|
||||
[](https://opensource.org/licenses/Apache-2.0)
|
||||
@@ -31,7 +31,7 @@
|
||||
|
||||
Multica 将编码 Agent 变成真正的队友。像分配给同事一样分配给 Agent——它们会自主接手工作、编写代码、报告阻塞问题、更新状态。
|
||||
|
||||
不再需要复制粘贴 prompt,不再需要盯着运行过程。你的 Agent 出现在看板上、参与对话、随着时间积累可复用的技能。支持 **Claude Code** 和 **Codex**。
|
||||
不再需要复制粘贴 prompt,不再需要盯着运行过程。你的 Agent 出现在看板上、参与对话、随着时间积累可复用的技能。可以理解为开源的 Managed Agents 基础设施——厂商中立、可自部署、专为人类 + AI 团队设计。支持 **Claude Code** 和 **Codex**。
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/assets/hero-screenshot.png" alt="Multica 看板视图" width="800">
|
||||
@@ -39,6 +39,8 @@ Multica 将编码 Agent 变成真正的队友。像分配给同事一样分配
|
||||
|
||||
## 功能特性
|
||||
|
||||
Multica 管理完整的 Agent 生命周期:从任务分配到执行监控再到技能复用。
|
||||
|
||||
- **Agent 即队友** — 像分配给同事一样分配给 Agent。它们有个人档案、出现在看板上、发表评论、创建 Issue、主动报告阻塞问题。
|
||||
- **自主执行** — 设置后无需管理。完整的任务生命周期管理(排队、认领、执行、完成/失败),通过 WebSocket 实时推送进度。
|
||||
- **可复用技能** — 每个解决方案都成为全团队可复用的技能。部署、数据库迁移、代码审查——技能让团队能力随时间持续增长。
|
||||
@@ -70,6 +72,14 @@ make start # 启动应用
|
||||
|
||||
`multica` CLI 将你的本地机器连接到 Multica — 用于认证、管理工作区和运行 Agent daemon。
|
||||
|
||||
**方式 A — 将以下指令粘贴给你的 coding agent(Claude 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
|
||||
|
||||
@@ -257,8 +257,14 @@ 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
|
||||
@@ -267,6 +273,8 @@ 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
|
||||
|
||||
@@ -12,7 +12,7 @@ vi.mock("next/navigation", () => ({
|
||||
// Mock auth store
|
||||
const mockSendCode = vi.fn();
|
||||
const mockVerifyCode = vi.fn();
|
||||
vi.mock("@/features/auth", () => ({
|
||||
vi.mock("@/platform/auth", () => ({
|
||||
useAuthStore: (selector: (s: any) => any) =>
|
||||
selector({
|
||||
sendCode: mockSendCode,
|
||||
@@ -20,9 +20,14 @@ vi.mock("@/features/auth", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock auth-cookie
|
||||
vi.mock("@/features/auth/auth-cookie", () => ({
|
||||
setLoggedInCookie: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock workspace store
|
||||
const mockHydrateWorkspace = vi.fn();
|
||||
vi.mock("@/features/workspace", () => ({
|
||||
vi.mock("@/platform/workspace", () => ({
|
||||
useWorkspaceStore: (selector: (s: any) => any) =>
|
||||
selector({
|
||||
hydrateWorkspace: mockHydrateWorkspace,
|
||||
@@ -30,7 +35,7 @@ vi.mock("@/features/workspace", () => ({
|
||||
}));
|
||||
|
||||
// Mock api
|
||||
vi.mock("@/shared/api", () => ({
|
||||
vi.mock("@/platform/api", () => ({
|
||||
api: {
|
||||
listWorkspaces: vi.fn().mockResolvedValue([]),
|
||||
verifyCode: vi.fn(),
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
|
||||
import { Suspense, useState, useEffect, useCallback } from "react";
|
||||
import { useSearchParams, useRouter } from "next/navigation";
|
||||
import { useAuthStore } from "@/features/auth";
|
||||
import { useWorkspaceStore } from "@/features/workspace";
|
||||
import { api } from "@/shared/api";
|
||||
import { useAuthStore } from "@/platform/auth";
|
||||
import { setLoggedInCookie } from "@/features/auth/auth-cookie";
|
||||
import { useWorkspaceStore } from "@/platform/workspace";
|
||||
import { api } from "@/platform/api";
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
@@ -12,16 +13,16 @@ import {
|
||||
CardDescription,
|
||||
CardContent,
|
||||
CardFooter,
|
||||
} from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
} from "@multica/ui/components/ui/card";
|
||||
import { Input } from "@multica/ui/components/ui/input";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import { Label } from "@multica/ui/components/ui/label";
|
||||
import {
|
||||
InputOTP,
|
||||
InputOTPGroup,
|
||||
InputOTPSlot,
|
||||
} from "@/components/ui/input-otp";
|
||||
import type { User } from "@/shared/types";
|
||||
} from "@multica/ui/components/ui/input-otp";
|
||||
import type { User } from "@multica/core/types";
|
||||
|
||||
function validateCliCallback(cliCallback: string): boolean {
|
||||
try {
|
||||
@@ -146,6 +147,10 @@ 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;
|
||||
@@ -153,7 +158,8 @@ function LoginPageContent() {
|
||||
|
||||
await verifyCode(email, value);
|
||||
const wsList = await api.listWorkspaces();
|
||||
await hydrateWorkspace(wsList);
|
||||
const lastWsId = localStorage.getItem("multica_workspace_id");
|
||||
await hydrateWorkspace(wsList, lastWsId);
|
||||
router.push(searchParams.get("next") || "/issues");
|
||||
} catch (err) {
|
||||
setError(
|
||||
@@ -281,6 +287,22 @@ 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">
|
||||
@@ -306,7 +328,7 @@ function LoginPageContent() {
|
||||
)}
|
||||
</form>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<CardFooter className="flex flex-col gap-3">
|
||||
<Button
|
||||
type="submit"
|
||||
form="login-form"
|
||||
@@ -316,6 +338,46 @@ 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>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import {
|
||||
@@ -16,8 +17,8 @@ import {
|
||||
SquarePen,
|
||||
CircleUser,
|
||||
} from "lucide-react";
|
||||
import { WorkspaceAvatar } from "@/features/workspace";
|
||||
import { useIssueDraftStore } from "@/features/issues/stores/draft-store";
|
||||
import { WorkspaceAvatar } from "@multica/views/workspace/workspace-avatar";
|
||||
import { useIssueDraftStore } from "@multica/core/issues/stores/draft-store";
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
@@ -29,7 +30,7 @@ import {
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarRail,
|
||||
} from "@/components/ui/sidebar";
|
||||
} from "@multica/ui/components/ui/sidebar";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -38,12 +39,14 @@ import {
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} 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";
|
||||
} 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";
|
||||
|
||||
const primaryNav = [
|
||||
{ href: "/inbox", label: "Inbox", icon: Inbox },
|
||||
@@ -73,7 +76,16 @@ export function AppSidebar() {
|
||||
const workspaces = useWorkspaceStore((s) => s.workspaces);
|
||||
const switchWorkspace = useWorkspaceStore((s) => s.switchWorkspace);
|
||||
|
||||
const unreadCount = useInboxStore((s) => s.unreadCount());
|
||||
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 logout = () => {
|
||||
router.push("/");
|
||||
@@ -132,6 +144,7 @@ export function AppSidebar() {
|
||||
key={ws.id}
|
||||
onClick={() => {
|
||||
if (ws.id !== workspace?.id) {
|
||||
router.push("/issues");
|
||||
switchWorkspace(ws.id);
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -8,15 +8,10 @@ import {
|
||||
Monitor,
|
||||
Plus,
|
||||
ListTodo,
|
||||
Wrench,
|
||||
FileText,
|
||||
BookOpenText,
|
||||
MessageSquare,
|
||||
Timer,
|
||||
Trash2,
|
||||
Save,
|
||||
Key,
|
||||
Link2,
|
||||
Clock,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
@@ -35,14 +30,11 @@ import type {
|
||||
Agent,
|
||||
AgentStatus,
|
||||
AgentVisibility,
|
||||
AgentTool,
|
||||
AgentTrigger,
|
||||
AgentTriggerType,
|
||||
AgentTask,
|
||||
RuntimeDevice,
|
||||
CreateAgentRequest,
|
||||
UpdateAgentRequest,
|
||||
} from "@/shared/types";
|
||||
} from "@multica/core/types";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -50,35 +42,38 @@ import {
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
} from "@multica/ui/components/ui/dialog";
|
||||
import {
|
||||
ResizablePanelGroup,
|
||||
ResizablePanel,
|
||||
ResizableHandle,
|
||||
} from "@/components/ui/resizable";
|
||||
} from "@multica/ui/components/ui/resizable";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
} from "@multica/ui/components/ui/dropdown-menu";
|
||||
import {
|
||||
Popover,
|
||||
PopoverTrigger,
|
||||
PopoverContent,
|
||||
} from "@/components/ui/popover";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
} 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";
|
||||
import { toast } from "sonner";
|
||||
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";
|
||||
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";
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -148,10 +143,6 @@ 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) {
|
||||
@@ -443,8 +434,9 @@ function SkillsTab({
|
||||
}: {
|
||||
agent: Agent;
|
||||
}) {
|
||||
const workspaceSkills = useWorkspaceStore((s) => s.skills);
|
||||
const refreshAgents = useWorkspaceStore((s) => s.refreshAgents);
|
||||
const qc = useQueryClient();
|
||||
const wsId = useWorkspaceId();
|
||||
const { data: workspaceSkills = [] } = useQuery(skillListOptions(wsId));
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [showPicker, setShowPicker] = useState(false);
|
||||
|
||||
@@ -456,7 +448,7 @@ function SkillsTab({
|
||||
try {
|
||||
const newIds = [...agent.skills.map((s) => s.id), skillId];
|
||||
await api.setAgentSkills(agent.id, { skill_ids: newIds });
|
||||
await refreshAgents();
|
||||
qc.invalidateQueries({ queryKey: workspaceKeys.agents(wsId) });
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Failed to add skill");
|
||||
} finally {
|
||||
@@ -470,7 +462,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 });
|
||||
await refreshAgents();
|
||||
qc.invalidateQueries({ queryKey: workspaceKeys.agents(wsId) });
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Failed to remove skill");
|
||||
} finally {
|
||||
@@ -596,459 +588,6 @@ 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
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1056,7 +595,8 @@ function TriggersTab({
|
||||
function TasksTab({ agent }: { agent: Agent }) {
|
||||
const [tasks, setTasks] = useState<AgentTask[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const issues = useIssueStore((s) => s.issues);
|
||||
const wsId = useWorkspaceId();
|
||||
const { data: issues = [] } = useQuery(issueListOptions(wsId));
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
@@ -1194,7 +734,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();
|
||||
const { upload, uploading } = useFileUpload(api);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleAvatarUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
@@ -1359,13 +899,11 @@ function SettingsTab({
|
||||
// Agent Detail
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type DetailTab = "instructions" | "skills" | "tools" | "triggers" | "tasks" | "settings";
|
||||
type DetailTab = "instructions" | "skills" | "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 },
|
||||
];
|
||||
@@ -1479,18 +1017,6 @@ 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
|
||||
@@ -1544,21 +1070,17 @@ function AgentDetail({
|
||||
export default function AgentsPage() {
|
||||
const isLoading = useAuthStore((s) => s.isLoading);
|
||||
const workspace = useWorkspaceStore((s) => s.workspace);
|
||||
const agents = useWorkspaceStore((s) => s.agents);
|
||||
const refreshAgents = useWorkspaceStore((s) => s.refreshAgents);
|
||||
const qc = useQueryClient();
|
||||
const wsId = useWorkspaceId();
|
||||
const { data: agents = [] } = useQuery(agentListOptions(wsId));
|
||||
const [selectedId, setSelectedId] = useState<string>("");
|
||||
const [showArchived, setShowArchived] = useState(false);
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const runtimes = useRuntimeStore((s) => s.runtimes);
|
||||
const fetchRuntimes = useRuntimeStore((s) => s.fetchRuntimes);
|
||||
const { data: runtimes = [] } = useQuery(runtimeListOptions(wsId));
|
||||
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],
|
||||
@@ -1575,14 +1097,14 @@ export default function AgentsPage() {
|
||||
|
||||
const handleCreate = async (data: CreateAgentRequest) => {
|
||||
const agent = await api.createAgent(data);
|
||||
await refreshAgents();
|
||||
qc.invalidateQueries({ queryKey: workspaceKeys.agents(wsId) });
|
||||
setSelectedId(agent.id);
|
||||
};
|
||||
|
||||
const handleUpdate = async (id: string, data: Record<string, unknown>) => {
|
||||
try {
|
||||
await api.updateAgent(id, data as UpdateAgentRequest);
|
||||
await refreshAgents();
|
||||
qc.invalidateQueries({ queryKey: workspaceKeys.agents(wsId) });
|
||||
toast.success("Agent updated");
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Failed to update agent");
|
||||
@@ -1593,7 +1115,7 @@ export default function AgentsPage() {
|
||||
const handleArchive = async (id: string) => {
|
||||
try {
|
||||
await api.archiveAgent(id);
|
||||
await refreshAgents();
|
||||
qc.invalidateQueries({ queryKey: workspaceKeys.agents(wsId) });
|
||||
toast.success("Agent archived");
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Failed to archive agent");
|
||||
@@ -1603,7 +1125,7 @@ export default function AgentsPage() {
|
||||
const handleRestore = async (id: string) => {
|
||||
try {
|
||||
await api.restoreAgent(id);
|
||||
await refreshAgents();
|
||||
qc.invalidateQueries({ queryKey: workspaceKeys.agents(wsId) });
|
||||
toast.success("Agent restored");
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Failed to restore agent");
|
||||
|
||||
@@ -1,13 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useState, useEffect, useCallback, useMemo } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useDefaultLayout } from "react-resizable-panels";
|
||||
import { useInboxStore } from "@/features/inbox";
|
||||
import { IssueDetail, StatusIcon, PriorityIcon } from "@/features/issues/components";
|
||||
import { STATUS_CONFIG, PRIORITY_CONFIG } from "@/features/issues/config";
|
||||
import { useActorName } from "@/features/workspace";
|
||||
import { ActorAvatar } from "@/components/common/actor-avatar";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useWorkspaceId } from "@multica/core/hooks";
|
||||
import {
|
||||
inboxListOptions,
|
||||
deduplicateInboxItems,
|
||||
} from "@multica/core/inbox/queries";
|
||||
import {
|
||||
useMarkInboxRead,
|
||||
useArchiveInbox,
|
||||
useMarkAllInboxRead,
|
||||
useArchiveAllInbox,
|
||||
useArchiveAllReadInbox,
|
||||
useArchiveCompletedInbox,
|
||||
} from "@multica/core/inbox/mutations";
|
||||
import { IssueDetail, StatusIcon, PriorityIcon } from "@multica/views/issues/components";
|
||||
import { STATUS_CONFIG, PRIORITY_CONFIG } from "@multica/core/issues/config";
|
||||
import { useActorName } from "@multica/core/workspace/hooks";
|
||||
import { ActorAvatar } from "@multica/views/common/actor-avatar";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
ArrowRight,
|
||||
@@ -18,22 +31,21 @@ import {
|
||||
BookCheck,
|
||||
ListChecks,
|
||||
} from "lucide-react";
|
||||
import type { InboxItem, InboxItemType, IssueStatus, IssuePriority } from "@/shared/types";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { InboxItem, InboxItemType, IssueStatus, IssuePriority } from "@multica/core/types";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import {
|
||||
ResizablePanelGroup,
|
||||
ResizablePanel,
|
||||
ResizableHandle,
|
||||
} from "@/components/ui/resizable";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
} from "@multica/ui/components/ui/resizable";
|
||||
import { Skeleton } from "@multica/ui/components/ui/skeleton";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { api } from "@/shared/api";
|
||||
} from "@multica/ui/components/ui/dropdown-menu";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
@@ -235,8 +247,9 @@ export default function InboxPage() {
|
||||
window.history.replaceState(null, "", url);
|
||||
}, []);
|
||||
|
||||
const items = useInboxStore((s) => s.dedupedItems());
|
||||
const loading = useInboxStore((s) => s.loading);
|
||||
const wsId = useWorkspaceId();
|
||||
const { data: rawItems = [], isLoading: loading } = useQuery(inboxListOptions(wsId));
|
||||
const items = useMemo(() => deduplicateInboxItems(rawItems), [rawItems]);
|
||||
|
||||
const { defaultLayout, onLayoutChanged } = useDefaultLayout({
|
||||
id: "multica_inbox_layout",
|
||||
@@ -245,74 +258,58 @@ 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 = async (item: InboxItem) => {
|
||||
const handleSelect = (item: InboxItem) => {
|
||||
setSelectedKey(item.issue_id ?? item.id);
|
||||
if (!item.read) {
|
||||
useInboxStore.getState().markRead(item.id);
|
||||
try {
|
||||
await api.markInboxRead(item.id);
|
||||
} catch {
|
||||
// Rollback: refetch to get server truth
|
||||
useInboxStore.getState().fetch();
|
||||
toast.error("Failed to mark as read");
|
||||
}
|
||||
markReadMutation.mutate(item.id, {
|
||||
onError: () => toast.error("Failed to mark as read"),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleArchive = async (id: string) => {
|
||||
try {
|
||||
await api.archiveInbox(id);
|
||||
useInboxStore.getState().archive(id);
|
||||
const archived = items.find((i) => i.id === id);
|
||||
if (archived && (archived.issue_id ?? archived.id) === selectedKey) setSelectedKey("");
|
||||
} catch {
|
||||
toast.error("Failed to archive");
|
||||
}
|
||||
const handleArchive = (id: string) => {
|
||||
const archived = items.find((i) => i.id === id);
|
||||
if (archived && (archived.issue_id ?? archived.id) === selectedKey) setSelectedKey("");
|
||||
archiveMutation.mutate(id, {
|
||||
onError: () => toast.error("Failed to archive"),
|
||||
});
|
||||
};
|
||||
|
||||
// Batch operations
|
||||
const handleMarkAllRead = async () => {
|
||||
try {
|
||||
useInboxStore.getState().markAllRead();
|
||||
await api.markAllInboxRead();
|
||||
} catch {
|
||||
toast.error("Failed to mark all as read");
|
||||
useInboxStore.getState().fetch();
|
||||
}
|
||||
const handleMarkAllRead = () => {
|
||||
markAllReadMutation.mutate(undefined, {
|
||||
onError: () => toast.error("Failed to mark all as read"),
|
||||
});
|
||||
};
|
||||
|
||||
const handleArchiveAll = async () => {
|
||||
try {
|
||||
useInboxStore.getState().archiveAll();
|
||||
setSelectedKey("");
|
||||
await api.archiveAllInbox();
|
||||
} catch {
|
||||
toast.error("Failed to archive all");
|
||||
useInboxStore.getState().fetch();
|
||||
}
|
||||
const handleArchiveAll = () => {
|
||||
setSelectedKey("");
|
||||
archiveAllMutation.mutate(undefined, {
|
||||
onError: () => toast.error("Failed to archive all"),
|
||||
});
|
||||
};
|
||||
|
||||
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 handleArchiveAllRead = () => {
|
||||
const readKeys = items.filter((i) => i.read).map((i) => i.issue_id ?? i.id);
|
||||
if (readKeys.includes(selectedKey)) setSelectedKey("");
|
||||
archiveAllReadMutation.mutate(undefined, {
|
||||
onError: () => toast.error("Failed to archive read items"),
|
||||
});
|
||||
};
|
||||
|
||||
const handleArchiveCompleted = async () => {
|
||||
try {
|
||||
await api.archiveCompletedInbox();
|
||||
setSelectedKey("");
|
||||
await useInboxStore.getState().fetch();
|
||||
} catch {
|
||||
toast.error("Failed to archive completed");
|
||||
}
|
||||
const handleArchiveCompleted = () => {
|
||||
setSelectedKey("");
|
||||
archiveCompletedMutation.mutate(undefined, {
|
||||
onError: () => toast.error("Failed to archive completed"),
|
||||
});
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
|
||||
@@ -2,7 +2,9 @@ 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 type { Issue, Comment, TimelineEntry } from "@/shared/types";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { Issue, Comment, TimelineEntry } from "@multica/core/types";
|
||||
import { WorkspaceIdProvider } from "@multica/core/hooks";
|
||||
|
||||
// Mock next/navigation
|
||||
vi.mock("next/navigation", () => ({
|
||||
@@ -28,7 +30,7 @@ vi.mock("next/link", () => ({
|
||||
}));
|
||||
|
||||
// Mock auth store
|
||||
vi.mock("@/features/auth", () => ({
|
||||
vi.mock("@/platform/auth", () => ({
|
||||
useAuthStore: (selector: (s: any) => any) =>
|
||||
selector({
|
||||
user: { id: "user-1", name: "Test User", email: "test@multica.ai" },
|
||||
@@ -36,6 +38,121 @@ vi.mock("@/features/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) =>
|
||||
@@ -62,34 +179,47 @@ vi.mock("@/features/workspace", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
// 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("@/platform/workspace", () => ({
|
||||
useWorkspaceStore: (selector: (s: any) => any) =>
|
||||
selector({
|
||||
workspace: { id: "ws-1", name: "Test WS" },
|
||||
workspaces: [{ id: "ws-1", name: "Test WS" }],
|
||||
members: [{ user_id: "user-1", name: "Test User", email: "test@multica.ai" }],
|
||||
agents: [{ id: "agent-1", name: "Claude Agent" }],
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock workspace hooks from core
|
||||
vi.mock("@multica/core/workspace/hooks", () => ({
|
||||
useActorName: () => ({
|
||||
getMemberName: (id: string) => (id === "user-1" ? "Test User" : "Unknown"),
|
||||
getAgentName: (id: string) => (id === "agent-1" ? "Claude Agent" : "Unknown Agent"),
|
||||
getActorName: (type: string, id: string) => {
|
||||
if (type === "member" && id === "user-1") return "Test User";
|
||||
if (type === "agent" && id === "agent-1") return "Claude Agent";
|
||||
return "Unknown";
|
||||
},
|
||||
getActorInitials: (type: string, id: string) => {
|
||||
if (type === "member") return "TU";
|
||||
if (type === "agent") return "CA";
|
||||
return "??";
|
||||
},
|
||||
getActorAvatarUrl: () => null,
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock issue store — only client state remains (activeIssueId)
|
||||
vi.mock("@/features/issues", () => ({
|
||||
useIssueStore: Object.assign(
|
||||
(selector: (s: any) => any) => selector({ issues: stableStoreIssues }),
|
||||
{ getState: () => ({ issues: stableStoreIssues, addIssue: vi.fn(), updateIssue: vi.fn(), removeIssue: vi.fn() }) },
|
||||
(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() }) },
|
||||
),
|
||||
}));
|
||||
|
||||
@@ -99,6 +229,15 @@ 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,
|
||||
@@ -106,6 +245,9 @@ 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 || "");
|
||||
@@ -160,32 +302,73 @@ vi.mock("@/components/markdown", () => ({
|
||||
Markdown: ({ children }: { children: string }) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
// 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());
|
||||
// Mock api (core queries/mutations use @multica/core/api, some components use @/platform/api)
|
||||
|
||||
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([]),
|
||||
const mockApiObj = vi.hoisted(() => ({
|
||||
getIssue: vi.fn(),
|
||||
listTimeline: vi.fn(),
|
||||
listComments: vi.fn().mockResolvedValue([]),
|
||||
createComment: vi.fn(),
|
||||
updateComment: vi.fn(),
|
||||
deleteComment: vi.fn(),
|
||||
deleteIssue: vi.fn(),
|
||||
updateIssue: vi.fn(),
|
||||
listIssueSubscribers: vi.fn().mockResolvedValue([]),
|
||||
subscribeToIssue: vi.fn().mockResolvedValue(undefined),
|
||||
unsubscribeFromIssue: vi.fn().mockResolvedValue(undefined),
|
||||
getActiveTasksForIssue: vi.fn().mockResolvedValue({ tasks: [] }),
|
||||
listTasksByIssue: vi.fn().mockResolvedValue([]),
|
||||
listTaskMessages: vi.fn().mockResolvedValue([]),
|
||||
listChildIssues: vi.fn().mockResolvedValue({ issues: [] }),
|
||||
listIssues: vi.fn().mockResolvedValue({ issues: [], total: 0 }),
|
||||
uploadFile: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/api", () => ({
|
||||
api: mockApiObj,
|
||||
getApi: () => mockApiObj,
|
||||
setApiInstance: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/platform/api", () => ({
|
||||
api: mockApiObj,
|
||||
}));
|
||||
|
||||
// Mock issue config from core
|
||||
vi.mock("@multica/core/issues/config", () => ({
|
||||
ALL_STATUSES: ["backlog", "todo", "in_progress", "in_review", "done", "blocked", "cancelled"],
|
||||
BOARD_STATUSES: ["backlog", "todo", "in_progress", "in_review", "done", "blocked"],
|
||||
STATUS_ORDER: ["backlog", "todo", "in_progress", "in_review", "done", "blocked", "cancelled"],
|
||||
STATUS_CONFIG: {
|
||||
backlog: { label: "Backlog", iconColor: "text-muted-foreground", hoverBg: "hover:bg-accent" },
|
||||
todo: { label: "Todo", iconColor: "text-muted-foreground", hoverBg: "hover:bg-accent" },
|
||||
in_progress: { label: "In Progress", iconColor: "text-warning", hoverBg: "hover:bg-warning/10" },
|
||||
in_review: { label: "In Review", iconColor: "text-success", hoverBg: "hover:bg-success/10" },
|
||||
done: { label: "Done", iconColor: "text-info", hoverBg: "hover:bg-info/10" },
|
||||
blocked: { label: "Blocked", iconColor: "text-destructive", hoverBg: "hover:bg-destructive/10" },
|
||||
cancelled: { label: "Cancelled", iconColor: "text-muted-foreground", hoverBg: "hover:bg-accent" },
|
||||
},
|
||||
PRIORITY_ORDER: ["urgent", "high", "medium", "low", "none"],
|
||||
PRIORITY_CONFIG: {
|
||||
urgent: { label: "Urgent", bars: 4, color: "text-destructive" },
|
||||
high: { label: "High", bars: 3, color: "text-warning" },
|
||||
medium: { label: "Medium", bars: 2, color: "text-warning" },
|
||||
low: { label: "Low", bars: 1, color: "text-info" },
|
||||
none: { label: "No priority", bars: 0, color: "text-muted-foreground" },
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock modals
|
||||
vi.mock("@multica/core/modals", () => ({
|
||||
useModalStore: Object.assign(
|
||||
() => ({ open: vi.fn() }),
|
||||
{ getState: () => ({ open: vi.fn() }) },
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock utils
|
||||
vi.mock("@multica/core/utils", () => ({
|
||||
timeAgo: (date: string) => "1d ago",
|
||||
}));
|
||||
|
||||
const mockIssue: Issue = {
|
||||
@@ -235,14 +418,28 @@ 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(
|
||||
<Suspense fallback={<div>Suspense loading...</div>}>
|
||||
<IssueDetailPage params={Promise.resolve({ id })} />
|
||||
</Suspense>,
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<WorkspaceIdProvider wsId="ws-1">
|
||||
<Suspense fallback={<div>Suspense loading...</div>}>
|
||||
<IssueDetailPage params={Promise.resolve({ id })} />
|
||||
</Suspense>
|
||||
</WorkspaceIdProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
return result!;
|
||||
@@ -254,8 +451,8 @@ describe("IssueDetailPage", () => {
|
||||
});
|
||||
|
||||
it("renders issue details after loading", async () => {
|
||||
mockGetIssue.mockResolvedValueOnce(mockIssue);
|
||||
mockListTimeline.mockResolvedValueOnce(mockTimeline);
|
||||
mockApiObj.getIssue.mockResolvedValueOnce(mockIssue);
|
||||
mockApiObj.listTimeline.mockResolvedValueOnce(mockTimeline);
|
||||
await renderPage();
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -270,8 +467,8 @@ describe("IssueDetailPage", () => {
|
||||
});
|
||||
|
||||
it("renders issue properties sidebar", async () => {
|
||||
mockGetIssue.mockResolvedValueOnce(mockIssue);
|
||||
mockListTimeline.mockResolvedValueOnce(mockTimeline);
|
||||
mockApiObj.getIssue.mockResolvedValueOnce(mockIssue);
|
||||
mockApiObj.listTimeline.mockResolvedValueOnce(mockTimeline);
|
||||
await renderPage();
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -283,8 +480,8 @@ describe("IssueDetailPage", () => {
|
||||
});
|
||||
|
||||
it("renders comments", async () => {
|
||||
mockGetIssue.mockResolvedValueOnce(mockIssue);
|
||||
mockListTimeline.mockResolvedValueOnce(mockTimeline);
|
||||
mockApiObj.getIssue.mockResolvedValueOnce(mockIssue);
|
||||
mockApiObj.listTimeline.mockResolvedValueOnce(mockTimeline);
|
||||
await renderPage();
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -299,8 +496,8 @@ describe("IssueDetailPage", () => {
|
||||
|
||||
it("shows 'Issue not found' for missing issue", async () => {
|
||||
// issue-detail fetches getIssue, useIssueReactions also fetches getIssue
|
||||
mockGetIssue.mockRejectedValue(new Error("Not found"));
|
||||
mockListTimeline.mockRejectedValue(new Error("Not found"));
|
||||
mockApiObj.getIssue.mockRejectedValue(new Error("Not found"));
|
||||
mockApiObj.listTimeline.mockRejectedValue(new Error("Not found"));
|
||||
await renderPage("nonexistent-id");
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -309,8 +506,8 @@ describe("IssueDetailPage", () => {
|
||||
});
|
||||
|
||||
it("submits a new comment", async () => {
|
||||
mockGetIssue.mockResolvedValueOnce(mockIssue);
|
||||
mockListTimeline.mockResolvedValueOnce(mockTimeline);
|
||||
mockApiObj.getIssue.mockResolvedValueOnce(mockIssue);
|
||||
mockApiObj.listTimeline.mockResolvedValueOnce(mockTimeline);
|
||||
|
||||
const newComment: Comment = {
|
||||
id: "comment-3",
|
||||
@@ -325,7 +522,7 @@ describe("IssueDetailPage", () => {
|
||||
created_at: "2026-01-18T00:00:00Z",
|
||||
updated_at: "2026-01-18T00:00:00Z",
|
||||
};
|
||||
mockCreateComment.mockResolvedValueOnce(newComment);
|
||||
mockApiObj.createComment.mockResolvedValueOnce(newComment);
|
||||
|
||||
const user = userEvent.setup();
|
||||
await renderPage();
|
||||
@@ -357,8 +554,8 @@ describe("IssueDetailPage", () => {
|
||||
await user.click(submitBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockCreateComment).toHaveBeenCalled();
|
||||
const [issueId, content] = mockCreateComment.mock.calls[0]!;
|
||||
expect(mockApiObj.createComment).toHaveBeenCalled();
|
||||
const [issueId, content] = mockApiObj.createComment.mock.calls[0]!;
|
||||
expect(issueId).toBe("issue-1");
|
||||
expect(content).toBe("New test comment");
|
||||
});
|
||||
@@ -369,8 +566,8 @@ describe("IssueDetailPage", () => {
|
||||
});
|
||||
|
||||
it("renders breadcrumb navigation", async () => {
|
||||
mockGetIssue.mockResolvedValueOnce(mockIssue);
|
||||
mockListTimeline.mockResolvedValueOnce(mockTimeline);
|
||||
mockApiObj.getIssue.mockResolvedValueOnce(mockIssue);
|
||||
mockApiObj.listTimeline.mockResolvedValueOnce(mockTimeline);
|
||||
await renderPage();
|
||||
|
||||
await waitFor(() => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { use } from "react";
|
||||
import { IssueDetail } from "@/features/issues/components";
|
||||
import { IssueDetail } from "@multica/views/issues/components";
|
||||
|
||||
export default function IssueDetailPage({
|
||||
params,
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import type { Issue } from "@/shared/types";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { Issue } from "@multica/core/types";
|
||||
import { WorkspaceIdProvider } from "@multica/core/hooks";
|
||||
|
||||
// Mock next/navigation
|
||||
vi.mock("next/navigation", () => ({
|
||||
@@ -46,6 +48,54 @@ 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(),
|
||||
@@ -59,38 +109,39 @@ vi.mock("sonner", () => ({
|
||||
toast: { error: vi.fn(), success: vi.fn() },
|
||||
}));
|
||||
|
||||
// Mock api
|
||||
// Mock api (core queries/mutations use @multica/core/api)
|
||||
const mockUpdateIssue = vi.fn();
|
||||
const mockListIssues = vi.hoisted(() => vi.fn().mockResolvedValue({ issues: [], total: 0 }));
|
||||
|
||||
vi.mock("@/shared/api", () => ({
|
||||
vi.mock("@multica/core/api", () => ({
|
||||
api: {
|
||||
listIssues: vi.fn().mockResolvedValue({ issues: [], total: 0 }),
|
||||
listIssues: (...args: any[]) => mockListIssues(...args),
|
||||
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 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", () => ({
|
||||
// Mock issue store — only client state remains
|
||||
const mockIssueClientState = { activeIssueId: null, setActiveIssue: vi.fn() };
|
||||
vi.mock("@multica/core/issues", () => ({
|
||||
useIssueStore: Object.assign(
|
||||
(selector?: any) => (selector ? selector(mockStoreState) : mockStoreState),
|
||||
{ getState: () => mockStoreState },
|
||||
(selector?: any) => (selector ? selector(mockIssueClientState) : mockIssueClientState),
|
||||
{ getState: () => mockIssueClientState },
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/features/issues", () => ({
|
||||
useIssueStore: Object.assign(
|
||||
(selector?: any) => (selector ? selector(mockStoreState) : mockStoreState),
|
||||
{ getState: () => mockStoreState },
|
||||
(selector?: any) => (selector ? selector(mockIssueClientState) : mockIssueClientState),
|
||||
{ getState: () => mockIssueClientState },
|
||||
),
|
||||
StatusIcon: () => null,
|
||||
PriorityIcon: () => null,
|
||||
@@ -129,12 +180,17 @@ const mockViewState = {
|
||||
toggleListCollapsed: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mock("@/features/issues/stores/view-store", () => ({
|
||||
vi.mock("@multica/core/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" },
|
||||
@@ -151,14 +207,36 @@ vi.mock("@/features/issues/stores/view-store", () => ({
|
||||
}));
|
||||
|
||||
// Mock view store context (shared components read from context)
|
||||
vi.mock("@/features/issues/stores/view-store-context", () => ({
|
||||
vi.mock("@multica/core/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("@/features/issues/config", () => ({
|
||||
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"],
|
||||
@@ -167,7 +245,7 @@ vi.mock("@/features/issues/config", () => ({
|
||||
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-done", hoverBg: "hover:bg-done/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" },
|
||||
},
|
||||
@@ -182,6 +260,13 @@ vi.mock("@/features/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() }),
|
||||
@@ -282,90 +367,86 @@ 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();
|
||||
mockStoreState = {
|
||||
issues: [],
|
||||
loading: true,
|
||||
fetch: vi.fn(),
|
||||
setIssues: vi.fn(),
|
||||
addIssue: vi.fn(),
|
||||
updateIssue: vi.fn(),
|
||||
removeIssue: vi.fn(),
|
||||
};
|
||||
mockListIssues.mockResolvedValue({ issues: [], total: 0 });
|
||||
mockViewState.viewMode = "board";
|
||||
mockViewState.statusFilters = [];
|
||||
mockViewState.priorityFilters = [];
|
||||
});
|
||||
|
||||
it("shows loading state initially", () => {
|
||||
mockStoreState.loading = true;
|
||||
mockStoreState.issues = [];
|
||||
render(<IssuesPage />);
|
||||
renderWithQuery(<IssuesPage />);
|
||||
expect(screen.getAllByRole("generic").some(el => el.getAttribute("data-slot") === "skeleton")).toBe(true);
|
||||
});
|
||||
|
||||
it("renders issues in board view after loading", async () => {
|
||||
mockStoreState.loading = false;
|
||||
mockStoreState.issues = mockIssues;
|
||||
// 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 }),
|
||||
);
|
||||
|
||||
render(<IssuesPage />);
|
||||
renderWithQuery(<IssuesPage />);
|
||||
|
||||
expect(screen.getByText("Implement auth")).toBeInTheDocument();
|
||||
await screen.findByText("Implement auth");
|
||||
expect(screen.getByText("Design landing page")).toBeInTheDocument();
|
||||
expect(screen.getByText("Write tests")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders board columns", async () => {
|
||||
mockStoreState.loading = false;
|
||||
mockStoreState.issues = mockIssues;
|
||||
mockListIssues.mockImplementation((params: any) =>
|
||||
Promise.resolve(params?.open_only ? { issues: mockIssues, total: mockIssues.length } : { issues: [], total: 0 }),
|
||||
);
|
||||
|
||||
render(<IssuesPage />);
|
||||
renderWithQuery(<IssuesPage />);
|
||||
|
||||
expect(screen.getAllByText("Backlog").length).toBeGreaterThanOrEqual(1);
|
||||
await screen.findByText("Backlog");
|
||||
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", () => {
|
||||
mockStoreState.loading = false;
|
||||
mockStoreState.issues = [];
|
||||
it("shows workspace breadcrumb", async () => {
|
||||
renderWithQuery(<IssuesPage />);
|
||||
|
||||
render(<IssuesPage />);
|
||||
|
||||
expect(screen.getByText("Issues")).toBeInTheDocument();
|
||||
await screen.findByText("Issues");
|
||||
});
|
||||
|
||||
it("shows scope buttons", () => {
|
||||
mockStoreState.loading = false;
|
||||
mockStoreState.issues = [];
|
||||
it("shows scope buttons", async () => {
|
||||
renderWithQuery(<IssuesPage />);
|
||||
|
||||
render(<IssuesPage />);
|
||||
|
||||
expect(screen.getByText("All")).toBeInTheDocument();
|
||||
await screen.findByText("All");
|
||||
expect(screen.getByText("Members")).toBeInTheDocument();
|
||||
expect(screen.getByText("Agents")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows filter and display icon buttons", () => {
|
||||
mockStoreState.loading = false;
|
||||
mockStoreState.issues = mockIssues;
|
||||
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 }),
|
||||
);
|
||||
|
||||
render(<IssuesPage />);
|
||||
renderWithQuery(<IssuesPage />);
|
||||
|
||||
// Filter and Display are now icon-only buttons, verify they render as buttons
|
||||
await screen.findByText("Implement auth");
|
||||
const buttons = screen.getAllByRole("button");
|
||||
expect(buttons.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("shows empty board view when no issues exist", () => {
|
||||
mockStoreState.loading = false;
|
||||
mockStoreState.issues = [];
|
||||
|
||||
render(<IssuesPage />);
|
||||
renderWithQuery(<IssuesPage />);
|
||||
|
||||
// Should still render the board/list view, not a "no issues" message
|
||||
expect(screen.queryByText("No matching issues")).not.toBeInTheDocument();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { IssuesPage } from "@/features/issues/components/issues-page";
|
||||
import { IssuesPage } from "@multica/views/issues/components";
|
||||
|
||||
export default function Page() {
|
||||
return <IssuesPage />;
|
||||
|
||||
@@ -3,10 +3,13 @@
|
||||
import { useEffect } from "react";
|
||||
import { useRouter, usePathname } from "next/navigation";
|
||||
import { MulticaIcon } from "@/components/multica-icon";
|
||||
import { useNavigationStore } from "@/features/navigation";
|
||||
import { SidebarProvider, SidebarInset } from "@/components/ui/sidebar";
|
||||
import { useAuthStore } from "@/features/auth";
|
||||
import { useWorkspaceStore } from "@/features/workspace";
|
||||
import { useNavigationStore } from "@multica/core/navigation";
|
||||
import { SidebarProvider, SidebarInset } from "@multica/ui/components/ui/sidebar";
|
||||
import { useAuthStore } from "@/platform/auth";
|
||||
import { useWorkspaceStore } from "@/platform/workspace";
|
||||
import { WorkspaceIdProvider } from "@multica/core/hooks";
|
||||
import { ModalRegistry } from "@multica/views/modals/registry";
|
||||
import { SearchCommand } from "@/features/search";
|
||||
import { AppSidebar } from "./_components/app-sidebar";
|
||||
|
||||
export default function DashboardLayout({
|
||||
@@ -45,13 +48,17 @@ export default function DashboardLayout({
|
||||
<AppSidebar />
|
||||
<SidebarInset className="overflow-hidden">
|
||||
{workspace ? (
|
||||
children
|
||||
<WorkspaceIdProvider wsId={workspace.id}>
|
||||
{children}
|
||||
<ModalRegistry />
|
||||
</WorkspaceIdProvider>
|
||||
) : (
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<MulticaIcon className="size-6 animate-pulse" />
|
||||
</div>
|
||||
)}
|
||||
</SidebarInset>
|
||||
<SearchCommand />
|
||||
</SidebarProvider>
|
||||
);
|
||||
}
|
||||
|
||||
28
apps/web/app/(dashboard)/loading.tsx
Normal file
28
apps/web/app/(dashboard)/loading.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { MyIssuesPage } from "@/features/my-issues";
|
||||
import { MyIssuesPage } from "@multica/views/my-issues";
|
||||
|
||||
export default function Page() {
|
||||
return <MyIssuesPage />;
|
||||
|
||||
@@ -1 +1 @@
|
||||
export { RuntimesPage as default } from "@/features/runtimes";
|
||||
export { RuntimesPage as default } from "@multica/views/runtimes";
|
||||
|
||||
@@ -2,14 +2,14 @@
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Camera, Loader2, Save } from "lucide-react";
|
||||
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 { 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 { toast } from "sonner";
|
||||
import { useAuthStore } from "@/features/auth";
|
||||
import { api } from "@/shared/api";
|
||||
import { useFileUpload } from "@/shared/hooks/use-file-upload";
|
||||
import { useAuthStore } from "@/platform/auth";
|
||||
import { api } from "@/platform/api";
|
||||
import { useFileUpload } from "@multica/core/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();
|
||||
const { upload, uploading } = useFileUpload(api);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useTheme } from "next-themes";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
|
||||
const LIGHT_COLORS = {
|
||||
titleBar: "#e8e8e8",
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { Crown, Shield, User, Plus, MoreHorizontal, UserMinus, Users } from "lucide-react";
|
||||
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 { 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 {
|
||||
AlertDialog,
|
||||
AlertDialogContent,
|
||||
@@ -17,14 +17,14 @@ import {
|
||||
AlertDialogFooter,
|
||||
AlertDialogCancel,
|
||||
AlertDialogAction,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
} from "@multica/ui/components/ui/alert-dialog";
|
||||
import {
|
||||
Select,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
} from "@/components/ui/select";
|
||||
} from "@multica/ui/components/ui/select";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
@@ -34,11 +34,14 @@ import {
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
} from "@multica/ui/components/ui/dropdown-menu";
|
||||
import { toast } from "sonner";
|
||||
import { useAuthStore } from "@/features/auth";
|
||||
import { useWorkspaceStore } from "@/features/workspace";
|
||||
import { api } from "@/shared/api";
|
||||
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";
|
||||
|
||||
const roleConfig: Record<MemberRole, { label: string; icon: typeof Crown; description: string }> = {
|
||||
owner: { label: "Owner", icon: Crown, description: "Full access, manage all settings" },
|
||||
@@ -140,8 +143,9 @@ function MemberRow({
|
||||
export function MembersTab() {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const workspace = useWorkspaceStore((s) => s.workspace);
|
||||
const members = useWorkspaceStore((s) => s.members);
|
||||
const refreshMembers = useWorkspaceStore((s) => s.refreshMembers);
|
||||
const qc = useQueryClient();
|
||||
const wsId = useWorkspaceId();
|
||||
const { data: members = [] } = useQuery(memberListOptions(wsId));
|
||||
|
||||
const [inviteEmail, setInviteEmail] = useState("");
|
||||
const [inviteRole, setInviteRole] = useState<MemberRole>("member");
|
||||
@@ -168,7 +172,7 @@ export function MembersTab() {
|
||||
});
|
||||
setInviteEmail("");
|
||||
setInviteRole("member");
|
||||
await refreshMembers();
|
||||
qc.invalidateQueries({ queryKey: workspaceKeys.members(wsId) });
|
||||
toast.success("Member added");
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Failed to add member");
|
||||
@@ -182,7 +186,7 @@ export function MembersTab() {
|
||||
setMemberActionId(memberId);
|
||||
try {
|
||||
await api.updateMember(workspace.id, memberId, { role });
|
||||
await refreshMembers();
|
||||
qc.invalidateQueries({ queryKey: workspaceKeys.members(wsId) });
|
||||
toast.success("Role updated");
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Failed to update member");
|
||||
@@ -201,7 +205,7 @@ export function MembersTab() {
|
||||
setMemberActionId(member.id);
|
||||
try {
|
||||
await api.deleteMember(workspace.id, member.id);
|
||||
await refreshMembers();
|
||||
qc.invalidateQueries({ queryKey: workspaceKeys.members(wsId) });
|
||||
toast.success("Member removed");
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Failed to remove member");
|
||||
|
||||
@@ -2,19 +2,23 @@
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Save, Plus, Trash2 } from "lucide-react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
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 { toast } from "sonner";
|
||||
import { useAuthStore } from "@/features/auth";
|
||||
import { useWorkspaceStore } from "@/features/workspace";
|
||||
import { api } from "@/shared/api";
|
||||
import type { WorkspaceRepo } from "@/shared/types";
|
||||
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";
|
||||
|
||||
export function RepositoriesTab() {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const workspace = useWorkspaceStore((s) => s.workspace);
|
||||
const members = useWorkspaceStore((s) => s.members);
|
||||
const wsId = useWorkspaceId();
|
||||
const { data: members = [] } = useQuery(memberListOptions(wsId));
|
||||
const updateWorkspace = useWorkspaceStore((s) => s.updateWorkspace);
|
||||
|
||||
const [repos, setRepos] = useState<WorkspaceRepo[]>(workspace?.repos ?? []);
|
||||
|
||||
@@ -2,18 +2,18 @@
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { Key, Trash2, Copy, Check } from "lucide-react";
|
||||
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 { 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 {
|
||||
Select,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
} from "@/components/ui/select";
|
||||
} from "@multica/ui/components/ui/select";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
} from "@multica/ui/components/ui/dialog";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@@ -31,10 +31,10 @@ import {
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
} from "@multica/ui/components/ui/alert-dialog";
|
||||
import { Skeleton } from "@multica/ui/components/ui/skeleton";
|
||||
import { toast } from "sonner";
|
||||
import { api } from "@/shared/api";
|
||||
import { api } from "@/platform/api";
|
||||
|
||||
export function TokensTab() {
|
||||
const [tokens, setTokens] = useState<PersonalAccessToken[]>([]);
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Save, LogOut } from "lucide-react";
|
||||
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 { 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 {
|
||||
AlertDialog,
|
||||
AlertDialogContent,
|
||||
@@ -16,16 +16,20 @@ import {
|
||||
AlertDialogFooter,
|
||||
AlertDialogCancel,
|
||||
AlertDialogAction,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
} from "@multica/ui/components/ui/alert-dialog";
|
||||
import { toast } from "sonner";
|
||||
import { useAuthStore } from "@/features/auth";
|
||||
import { useWorkspaceStore } from "@/features/workspace";
|
||||
import { api } from "@/shared/api";
|
||||
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";
|
||||
|
||||
export function WorkspaceTab() {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const workspace = useWorkspaceStore((s) => s.workspace);
|
||||
const members = useWorkspaceStore((s) => s.members);
|
||||
const wsId = useWorkspaceId();
|
||||
const { data: members = [] } = useQuery(memberListOptions(wsId));
|
||||
const updateWorkspace = useWorkspaceStore((s) => s.updateWorkspace);
|
||||
const leaveWorkspace = useWorkspaceStore((s) => s.leaveWorkspace);
|
||||
const deleteWorkspace = useWorkspaceStore((s) => s.deleteWorkspace);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { User, Palette, Key, Settings, Users, FolderGit2 } from "lucide-react";
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
||||
import { useWorkspaceStore } from "@/features/workspace";
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@multica/ui/components/ui/tabs";
|
||||
import { useWorkspaceStore } from "@/platform/workspace";
|
||||
import { AccountTab } from "./_components/account-tab";
|
||||
import { AppearanceTab } from "./_components/general-tab";
|
||||
import { TokensTab } from "./_components/tokens-tab";
|
||||
|
||||
@@ -1 +1 @@
|
||||
export { SkillsPage as default } from "@/features/skills";
|
||||
export { SkillsPage as default } from "@multica/views/skills";
|
||||
|
||||
90
apps/web/app/auth/callback/page.tsx
Normal file
90
apps/web/app/auth/callback/page.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -1,152 +1,14 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@import "shadcn/tailwind.css";
|
||||
@import "@multica/ui/styles/tokens.css";
|
||||
@import "./custom.css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@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-done: var(--done);
|
||||
--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);
|
||||
--done: oklch(0.55 0.18 300);
|
||||
--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);
|
||||
--done: oklch(0.65 0.18 300);
|
||||
--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;
|
||||
}
|
||||
@source "../../../packages/ui/**/*.tsx";
|
||||
@source "../../../packages/core/**/*.tsx";
|
||||
@source "../../../packages/views/**/*.tsx";
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@@ -164,4 +26,4 @@
|
||||
html {
|
||||
@apply font-sans;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
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 "@/components/ui/sonner";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Toaster } from "@multica/ui/components/ui/sonner";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import { QueryProvider } from "@multica/core/provider";
|
||||
import { AuthInitializer } from "@/features/auth";
|
||||
import { WSProvider } from "@/features/realtime";
|
||||
import { ModalRegistry } from "@/features/modals";
|
||||
import { WebWSProvider } from "@/platform/ws-provider";
|
||||
import { WebNavigationProvider } from "@/platform/navigation";
|
||||
import { LocaleSync } from "@/components/locale-sync";
|
||||
import "./globals.css";
|
||||
|
||||
const geist = Geist({ subsets: ["latin"], variable: "--font-sans" });
|
||||
@@ -50,28 +51,28 @@ export const metadata: Metadata = {
|
||||
},
|
||||
};
|
||||
|
||||
export default async function RootLayout({
|
||||
export default 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={lang}
|
||||
lang="en"
|
||||
suppressHydrationWarning
|
||||
className={cn("antialiased font-sans h-full", geist.variable, geistMono.variable)}
|
||||
>
|
||||
<body className="h-full overflow-hidden">
|
||||
<LocaleSync />
|
||||
<ThemeProvider>
|
||||
<AuthInitializer>
|
||||
<WSProvider>{children}</WSProvider>
|
||||
</AuthInitializer>
|
||||
<ModalRegistry />
|
||||
<Toaster />
|
||||
<QueryProvider>
|
||||
<WebNavigationProvider>
|
||||
<AuthInitializer>
|
||||
<WebWSProvider>{children}</WebWSProvider>
|
||||
</AuthInitializer>
|
||||
</WebNavigationProvider>
|
||||
<Toaster />
|
||||
</QueryProvider>
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -13,11 +13,11 @@
|
||||
"iconLibrary": "lucide",
|
||||
"rtl": false,
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
"components": "@multica/ui/components",
|
||||
"utils": "@multica/ui/lib/utils",
|
||||
"ui": "@multica/ui/components/ui",
|
||||
"lib": "@multica/ui/lib",
|
||||
"hooks": "@multica/ui/hooks"
|
||||
},
|
||||
"menuColor": "default",
|
||||
"menuAccent": "subtle",
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
"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 };
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { Spinner } from "@/components/spinner";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
|
||||
export type LoadingVariant = "generating" | "streaming";
|
||||
|
||||
|
||||
20
apps/web/components/locale-sync.tsx
Normal file
20
apps/web/components/locale-sync.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
"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;
|
||||
}
|
||||
@@ -1,243 +1 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
export { CodeBlock, InlineCode, type CodeBlockProps } from '@multica/ui/markdown'
|
||||
|
||||
@@ -1,332 +1,36 @@
|
||||
import * as React from 'react'
|
||||
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'
|
||||
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
|
||||
|
||||
/**
|
||||
* 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
|
||||
* Default renderMention that delegates to IssueMentionCard for issue mentions
|
||||
* and renders a styled span for other mention types.
|
||||
*/
|
||||
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
|
||||
function defaultRenderMention({ type, id }: { type: string; id: string }): React.ReactNode {
|
||||
if (type === 'issue') {
|
||||
return <IssueMentionCard issueId={id} />
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom URL transform that allows mention:// protocol (used for @mentions)
|
||||
* while keeping the default security for all other URLs.
|
||||
* App-level Markdown wrapper that injects IssueMentionCard via renderMention.
|
||||
* Callers that need custom mention rendering can pass their own renderMention prop.
|
||||
*/
|
||||
function urlTransform(url: string): string {
|
||||
if (url.startsWith('mention://')) return url
|
||||
return defaultUrlTransform(url)
|
||||
export function Markdown(props: MarkdownProps): React.JSX.Element {
|
||||
return <MarkdownBase renderMention={defaultRenderMention} {...props} />
|
||||
}
|
||||
|
||||
|
||||
// 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>
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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({
|
||||
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 &&
|
||||
@@ -334,7 +38,6 @@ 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'
|
||||
|
||||
@@ -1,225 +1,22 @@
|
||||
import * as React from 'react'
|
||||
import { Markdown, type RenderMode } from './Markdown'
|
||||
import {
|
||||
StreamingMarkdown as StreamingMarkdownBase,
|
||||
type StreamingMarkdownProps as StreamingMarkdownBaseProps
|
||||
} from '@multica/ui/markdown'
|
||||
import { IssueMentionCard } from '@multica/views/issues/components'
|
||||
|
||||
export interface StreamingMarkdownProps {
|
||||
content: string
|
||||
isStreaming: boolean
|
||||
mode?: RenderMode
|
||||
className?: string
|
||||
onUrlClick?: (url: string) => void
|
||||
onFileClick?: (path: string) => void
|
||||
}
|
||||
export type StreamingMarkdownProps = StreamingMarkdownBaseProps
|
||||
|
||||
interface Block {
|
||||
content: string
|
||||
isCodeBlock: boolean
|
||||
function defaultRenderMention({ type, id }: { type: string; id: string }): React.ReactNode {
|
||||
if (type === 'issue') {
|
||||
return <IssueMentionCard issueId={id} />
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* App-level StreamingMarkdown wrapper that injects IssueMentionCard via renderMention.
|
||||
*/
|
||||
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}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)
|
||||
export function StreamingMarkdown(props: StreamingMarkdownProps): React.JSX.Element {
|
||||
return <StreamingMarkdownBase renderMention={defaultRenderMention} {...props} />
|
||||
}
|
||||
|
||||
@@ -1,215 +1 @@
|
||||
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)
|
||||
}
|
||||
export { preprocessLinks, detectLinks, hasLinks } from '@multica/ui/markdown'
|
||||
|
||||
@@ -1,25 +1 @@
|
||||
/**
|
||||
* 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})`;
|
||||
},
|
||||
);
|
||||
}
|
||||
export { preprocessMentionShortcodes } from '@multica/ui/markdown'
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
|
||||
interface MulticaIconProps extends React.ComponentProps<"span"> {
|
||||
/**
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
* Inherits color from `currentColor` (use Tailwind `text-*`).
|
||||
* Scales with font-size (use Tailwind `text-*` for size).
|
||||
*/
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@multica/ui/lib/utils"
|
||||
|
||||
export interface SpinnerProps {
|
||||
/** Additional className for styling (color via text-*, size via Tailwind text-*) */
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { ThemeProvider as NextThemesProvider } from "next-themes"
|
||||
import { TooltipProvider } from "@/components/ui/tooltip"
|
||||
import { TooltipProvider } from "@multica/ui/components/ui/tooltip"
|
||||
|
||||
function ThemeProvider({
|
||||
children,
|
||||
|
||||
@@ -7,8 +7,8 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { SidebarMenuButton } from "@/components/ui/sidebar"
|
||||
} from "@multica/ui/components/ui/dropdown-menu"
|
||||
import { SidebarMenuButton } from "@multica/ui/components/ui/sidebar"
|
||||
|
||||
export function ThemeToggle() {
|
||||
const { setTheme } = useTheme()
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export { useAuthStore } from "./store";
|
||||
export { useAuthStore } from "@/platform/auth";
|
||||
export { AuthInitializer } from "./initializer";
|
||||
export { setLoggedInCookie } from "./auth-cookie";
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, type ReactNode } from "react";
|
||||
import { useAuthStore } from "./store";
|
||||
import { useWorkspaceStore } from "@/features/workspace";
|
||||
import { api } from "@/shared/api";
|
||||
import { createLogger } from "@/shared/logger";
|
||||
import { useAuthStore } from "@/platform/auth";
|
||||
import { useWorkspaceStore } from "@/platform/workspace";
|
||||
import { api } from "@/platform/api";
|
||||
import { createLogger } from "@multica/core/logger";
|
||||
import { setLoggedInCookie, clearLoggedInCookie } from "./auth-cookie";
|
||||
|
||||
const logger = createLogger("auth");
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { create } from "zustand";
|
||||
import type { User } from "@/shared/types";
|
||||
import { api } from "@/shared/api";
|
||||
import { setLoggedInCookie, clearLoggedInCookie } from "./auth-cookie";
|
||||
|
||||
interface AuthState {
|
||||
user: User | null;
|
||||
isLoading: boolean;
|
||||
|
||||
initialize: () => Promise<void>;
|
||||
sendCode: (email: string) => Promise<void>;
|
||||
verifyCode: (email: string, code: string) => Promise<User>;
|
||||
logout: () => void;
|
||||
setUser: (user: User) => void;
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>((set) => ({
|
||||
user: null,
|
||||
isLoading: true,
|
||||
|
||||
initialize: async () => {
|
||||
const token = localStorage.getItem("multica_token");
|
||||
if (!token) {
|
||||
set({ isLoading: false });
|
||||
return;
|
||||
}
|
||||
|
||||
api.setToken(token);
|
||||
|
||||
try {
|
||||
const user = await api.getMe();
|
||||
set({ user, isLoading: false });
|
||||
} catch {
|
||||
api.setToken(null);
|
||||
api.setWorkspaceId(null);
|
||||
localStorage.removeItem("multica_token");
|
||||
localStorage.removeItem("multica_workspace_id");
|
||||
set({ user: null, isLoading: false });
|
||||
}
|
||||
},
|
||||
|
||||
sendCode: async (email: string) => {
|
||||
await api.sendCode(email);
|
||||
},
|
||||
|
||||
verifyCode: async (email: string, code: string) => {
|
||||
const { token, user } = await api.verifyCode(email, code);
|
||||
localStorage.setItem("multica_token", token);
|
||||
api.setToken(token);
|
||||
setLoggedInCookie();
|
||||
set({ user });
|
||||
return user;
|
||||
},
|
||||
|
||||
logout: () => {
|
||||
localStorage.removeItem("multica_token");
|
||||
localStorage.removeItem("multica_workspace_id");
|
||||
api.setToken(null);
|
||||
api.setWorkspaceId(null);
|
||||
clearLoggedInCookie();
|
||||
set({ user: null });
|
||||
},
|
||||
|
||||
setUser: (user: User) => {
|
||||
set({ user });
|
||||
},
|
||||
}));
|
||||
@@ -1,24 +0,0 @@
|
||||
import { preprocessLinks } from "@/components/markdown/linkify";
|
||||
import { preprocessMentionShortcodes } from "@/components/markdown/mentions";
|
||||
|
||||
/**
|
||||
* Preprocess a markdown string before loading into Tiptap via contentType: 'markdown'.
|
||||
*
|
||||
* This is the ONLY transform applied before @tiptap/markdown parses the content.
|
||||
* It does NOT convert to HTML — that was the old markdownToHtml.ts pipeline which
|
||||
* was deleted in the April 2026 refactor.
|
||||
*
|
||||
* Two string→string transforms on raw Markdown:
|
||||
* 1. Legacy mention shortcodes [@ id="..." label="..."] → [@Label](mention://member/id)
|
||||
* (old serialization format in database, migrated on read)
|
||||
* 2. Raw URLs → markdown links via linkify-it (so they render as clickable Link nodes)
|
||||
*
|
||||
* After this, @tiptap/markdown's parse() handles everything else: headings, lists,
|
||||
* tables, code blocks, and our custom mention tokenizer ([@Name](mention://type/id)).
|
||||
*/
|
||||
export function preprocessMarkdown(markdown: string): string {
|
||||
if (!markdown) return "";
|
||||
const step1 = preprocessMentionShortcodes(markdown);
|
||||
const step2 = preprocessLinks(step1);
|
||||
return step2;
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export { useInboxStore } from "./store";
|
||||
@@ -1,127 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { create } from "zustand";
|
||||
import type { InboxItem, IssueStatus } from "@/shared/types";
|
||||
import { toast } from "sonner";
|
||||
import { api } from "@/shared/api";
|
||||
import { createLogger } from "@/shared/logger";
|
||||
|
||||
const logger = createLogger("inbox-store");
|
||||
|
||||
/**
|
||||
* Deduplicate inbox items by issue_id (one entry per issue, Linear-style),
|
||||
* keep latest, sort by time DESC.
|
||||
* Memoized by reference — returns the same array if `items` hasn't changed.
|
||||
*/
|
||||
let _prevItems: InboxItem[] = [];
|
||||
let _prevDeduped: InboxItem[] = [];
|
||||
|
||||
function deduplicateInboxItems(items: InboxItem[]): InboxItem[] {
|
||||
if (items === _prevItems) return _prevDeduped;
|
||||
_prevItems = items;
|
||||
|
||||
const active = items.filter((i) => !i.archived);
|
||||
const groups = new Map<string, InboxItem[]>();
|
||||
active.forEach((item) => {
|
||||
const key = item.issue_id ?? item.id;
|
||||
const group = groups.get(key) ?? [];
|
||||
group.push(item);
|
||||
groups.set(key, group);
|
||||
});
|
||||
const merged: InboxItem[] = [];
|
||||
groups.forEach((group) => {
|
||||
const sorted = group.sort(
|
||||
(a, b) =>
|
||||
new Date(b.created_at).getTime() - new Date(a.created_at).getTime(),
|
||||
);
|
||||
if (sorted[0]) merged.push(sorted[0]);
|
||||
});
|
||||
_prevDeduped = merged.sort(
|
||||
(a, b) =>
|
||||
new Date(b.created_at).getTime() - new Date(a.created_at).getTime(),
|
||||
);
|
||||
return _prevDeduped;
|
||||
}
|
||||
|
||||
interface InboxState {
|
||||
items: InboxItem[];
|
||||
loading: boolean;
|
||||
fetch: () => Promise<void>;
|
||||
setItems: (items: InboxItem[]) => void;
|
||||
addItem: (item: InboxItem) => void;
|
||||
markRead: (id: string) => void;
|
||||
archive: (id: string) => void;
|
||||
markAllRead: () => void;
|
||||
archiveAll: () => void;
|
||||
archiveAllRead: () => void;
|
||||
updateIssueStatus: (issueId: string, status: IssueStatus) => void;
|
||||
dedupedItems: () => InboxItem[];
|
||||
unreadCount: () => number;
|
||||
}
|
||||
|
||||
export const useInboxStore = create<InboxState>((set, get) => ({
|
||||
items: [],
|
||||
loading: true,
|
||||
|
||||
fetch: async () => {
|
||||
logger.debug("fetch start");
|
||||
const isInitialLoad = get().items.length === 0;
|
||||
if (isInitialLoad) set({ loading: true });
|
||||
try {
|
||||
const data = await api.listInbox();
|
||||
logger.info("fetched", data.length, "items");
|
||||
set({ items: data, loading: false });
|
||||
} catch (err) {
|
||||
logger.error("fetch failed", err);
|
||||
toast.error("Failed to load inbox");
|
||||
if (isInitialLoad) set({ loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
setItems: (items) => set({ items }),
|
||||
addItem: (item) =>
|
||||
set((s) => ({
|
||||
items: s.items.some((i) => i.id === item.id)
|
||||
? s.items
|
||||
: [item, ...s.items],
|
||||
})),
|
||||
markRead: (id) =>
|
||||
set((s) => ({
|
||||
items: s.items.map((i) => (i.id === id ? { ...i, read: true } : i)),
|
||||
})),
|
||||
archive: (id) =>
|
||||
set((s) => {
|
||||
const target = s.items.find((i) => i.id === id);
|
||||
const issueId = target?.issue_id;
|
||||
return {
|
||||
items: s.items.map((i) =>
|
||||
i.id === id || (issueId && i.issue_id === issueId)
|
||||
? { ...i, archived: true }
|
||||
: i,
|
||||
),
|
||||
};
|
||||
}),
|
||||
markAllRead: () =>
|
||||
set((s) => ({
|
||||
items: s.items.map((i) => (!i.archived ? { ...i, read: true } : i)),
|
||||
})),
|
||||
archiveAll: () =>
|
||||
set((s) => ({
|
||||
items: s.items.map((i) => (!i.archived ? { ...i, archived: true } : i)),
|
||||
})),
|
||||
archiveAllRead: () =>
|
||||
set((s) => ({
|
||||
items: s.items.map((i) =>
|
||||
i.read && !i.archived ? { ...i, archived: true } : i
|
||||
),
|
||||
})),
|
||||
updateIssueStatus: (issueId, status) =>
|
||||
set((s) => ({
|
||||
items: s.items.map((i) =>
|
||||
i.issue_id === issueId ? { ...i, issue_status: status } : i
|
||||
),
|
||||
})),
|
||||
dedupedItems: () => deduplicateInboxItems(get().items),
|
||||
unreadCount: () =>
|
||||
get().dedupedItems().filter((i) => !i.read).length,
|
||||
}));
|
||||
@@ -1,261 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useMemo } from "react";
|
||||
import {
|
||||
DndContext,
|
||||
DragOverlay,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
pointerWithin,
|
||||
closestCenter,
|
||||
type CollisionDetection,
|
||||
type DragStartEvent,
|
||||
type DragEndEvent,
|
||||
} from "@dnd-kit/core";
|
||||
import { Eye, MoreHorizontal } from "lucide-react";
|
||||
import type { Issue, IssueStatus } from "@/shared/types";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { ALL_STATUSES, STATUS_CONFIG } from "@/features/issues/config";
|
||||
import { useViewStoreApi } from "@/features/issues/stores/view-store-context";
|
||||
import { StatusIcon } from "./status-icon";
|
||||
import { BoardColumn } from "./board-column";
|
||||
import { BoardCardContent } from "./board-card";
|
||||
|
||||
const COLUMN_IDS = new Set<string>(ALL_STATUSES);
|
||||
|
||||
const kanbanCollision: CollisionDetection = (args) => {
|
||||
const pointer = pointerWithin(args);
|
||||
if (pointer.length > 0) {
|
||||
// Prefer card collisions over column collisions so that
|
||||
// dragging down within a column finds the target card
|
||||
// instead of the column droppable.
|
||||
const cards = pointer.filter((c) => !COLUMN_IDS.has(c.id as string));
|
||||
if (cards.length > 0) return cards;
|
||||
}
|
||||
// Fallback: closestCenter finds the nearest card even when
|
||||
// the pointer is in a gap between cards (common when dragging down).
|
||||
return closestCenter(args);
|
||||
};
|
||||
|
||||
/** Compute a float position to place an item at `targetIndex` within `siblings`. */
|
||||
function computePosition(siblings: Issue[], targetIndex: number): number {
|
||||
if (siblings.length === 0) return 0;
|
||||
if (targetIndex <= 0) return siblings[0]!.position - 1;
|
||||
if (targetIndex >= siblings.length)
|
||||
return siblings[siblings.length - 1]!.position + 1;
|
||||
return (siblings[targetIndex - 1]!.position + siblings[targetIndex]!.position) / 2;
|
||||
}
|
||||
|
||||
export function BoardView({
|
||||
issues,
|
||||
allIssues,
|
||||
visibleStatuses,
|
||||
hiddenStatuses,
|
||||
onMoveIssue,
|
||||
}: {
|
||||
issues: Issue[];
|
||||
allIssues: Issue[];
|
||||
visibleStatuses: IssueStatus[];
|
||||
hiddenStatuses: IssueStatus[];
|
||||
onMoveIssue: (
|
||||
issueId: string,
|
||||
newStatus: IssueStatus,
|
||||
newPosition?: number
|
||||
) => void;
|
||||
}) {
|
||||
const [activeIssue, setActiveIssue] = useState<Issue | null>(null);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: { distance: 5 },
|
||||
})
|
||||
);
|
||||
|
||||
// Pre-sort issues by position per status for position calculations
|
||||
const issuesByStatus = useMemo(() => {
|
||||
const map: Record<string, Issue[]> = {};
|
||||
for (const status of visibleStatuses) {
|
||||
map[status] = issues
|
||||
.filter((i) => i.status === status)
|
||||
.sort((a, b) => a.position - b.position);
|
||||
}
|
||||
return map;
|
||||
}, [issues, visibleStatuses]);
|
||||
|
||||
const handleDragStart = useCallback(
|
||||
(event: DragStartEvent) => {
|
||||
const issue = issues.find((i) => i.id === event.active.id);
|
||||
if (issue) setActiveIssue(issue);
|
||||
},
|
||||
[issues]
|
||||
);
|
||||
|
||||
const handleDragEnd = useCallback(
|
||||
(event: DragEndEvent) => {
|
||||
setActiveIssue(null);
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) return;
|
||||
|
||||
const issueId = active.id as string;
|
||||
const currentIssue = issues.find((i) => i.id === issueId);
|
||||
if (!currentIssue) return;
|
||||
|
||||
// Determine target status
|
||||
let targetStatus: IssueStatus;
|
||||
let overIsColumn = false;
|
||||
|
||||
if (visibleStatuses.includes(over.id as IssueStatus)) {
|
||||
targetStatus = over.id as IssueStatus;
|
||||
overIsColumn = true;
|
||||
} else {
|
||||
const targetIssue = issues.find((i) => i.id === over.id);
|
||||
if (!targetIssue) return;
|
||||
targetStatus = targetIssue.status;
|
||||
}
|
||||
|
||||
// Get sorted siblings in the target column (excluding the dragged item)
|
||||
const siblings = (issuesByStatus[targetStatus] ?? []).filter(
|
||||
(i) => i.id !== issueId
|
||||
);
|
||||
|
||||
// Compute new position
|
||||
let newPosition: number;
|
||||
|
||||
if (overIsColumn) {
|
||||
// Dropped on empty area of column → append to end
|
||||
newPosition = computePosition(siblings, siblings.length);
|
||||
} else {
|
||||
// Dropped on a specific card → insert at that card's index
|
||||
const overIndex = siblings.findIndex((i) => i.id === over.id);
|
||||
if (overIndex === -1) {
|
||||
newPosition = computePosition(siblings, siblings.length);
|
||||
} else {
|
||||
const isSameColumn = currentIssue.status === targetStatus;
|
||||
const overIssuePosition = siblings[overIndex]!.position;
|
||||
|
||||
if (isSameColumn && currentIssue.position < overIssuePosition) {
|
||||
// Moving down → insert after the over card
|
||||
newPosition = computePosition(siblings, overIndex + 1);
|
||||
} else {
|
||||
// Moving up or cross-column → insert before the over card
|
||||
newPosition = computePosition(siblings, overIndex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Skip if nothing changed
|
||||
if (
|
||||
currentIssue.status === targetStatus &&
|
||||
currentIssue.position === newPosition
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
onMoveIssue(issueId, targetStatus, newPosition);
|
||||
},
|
||||
[issues, issuesByStatus, onMoveIssue, visibleStatuses]
|
||||
);
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={kanbanCollision}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<div className="flex flex-1 min-h-0 gap-4 overflow-x-auto p-4">
|
||||
{visibleStatuses.map((status) => (
|
||||
<BoardColumn
|
||||
key={status}
|
||||
status={status}
|
||||
issues={issues.filter((i) => i.status === status)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{hiddenStatuses.length > 0 && (
|
||||
<HiddenColumnsPanel
|
||||
hiddenStatuses={hiddenStatuses}
|
||||
issues={allIssues}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DragOverlay>
|
||||
{activeIssue ? (
|
||||
<div className="w-[280px] rotate-1 cursor-grabbing opacity-95 shadow-md">
|
||||
<BoardCardContent issue={activeIssue} />
|
||||
</div>
|
||||
) : null}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
);
|
||||
}
|
||||
|
||||
function HiddenColumnsPanel({
|
||||
hiddenStatuses,
|
||||
issues,
|
||||
}: {
|
||||
hiddenStatuses: IssueStatus[];
|
||||
issues: Issue[];
|
||||
}) {
|
||||
const viewStoreApi = useViewStoreApi();
|
||||
return (
|
||||
<div className="flex w-[240px] shrink-0 flex-col">
|
||||
<div className="mb-2 flex items-center gap-2 px-1">
|
||||
<span className="text-sm font-medium text-muted-foreground">
|
||||
Hidden columns
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 space-y-0.5">
|
||||
{hiddenStatuses.map((status) => {
|
||||
const cfg = STATUS_CONFIG[status];
|
||||
const count = issues.filter((i) => i.status === status).length;
|
||||
return (
|
||||
<div
|
||||
key={status}
|
||||
className="flex items-center justify-between rounded-lg px-2.5 py-2 hover:bg-muted/50"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<StatusIcon status={status} className="h-3.5 w-3.5" />
|
||||
<span className="text-sm">{cfg.label}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-xs text-muted-foreground">{count}</span>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="rounded-full text-muted-foreground"
|
||||
>
|
||||
<MoreHorizontal className="size-3.5" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
viewStoreApi.getState().showStatus(status)
|
||||
}
|
||||
>
|
||||
<Eye className="size-3.5" />
|
||||
Show column
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useIssueStore } from "@/features/issues/store";
|
||||
import { StatusIcon } from "./status-icon";
|
||||
|
||||
interface IssueMentionCardProps {
|
||||
issueId: string;
|
||||
/** Fallback text when issue is not in store (e.g. "MUL-7") */
|
||||
fallbackLabel?: string;
|
||||
}
|
||||
|
||||
export function IssueMentionCard({ issueId, fallbackLabel }: IssueMentionCardProps) {
|
||||
const issue = useIssueStore((s) => s.issues.find((i) => i.id === issueId));
|
||||
|
||||
if (!issue) {
|
||||
return (
|
||||
<Link
|
||||
href={`/issues/${issueId}`}
|
||||
className="text-primary font-medium cursor-pointer hover:underline"
|
||||
>
|
||||
{fallbackLabel ?? issueId.slice(0, 8)}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={`/issues/${issueId}`}
|
||||
className="inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 text-sm hover:bg-accent transition-colors cursor-pointer no-underline"
|
||||
>
|
||||
<StatusIcon status={issue.status} className="h-3.5 w-3.5" />
|
||||
<span className="font-medium text-muted-foreground">{issue.identifier}</span>
|
||||
<span className="text-foreground">{issue.title}</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import type { IssueReaction } from "@/shared/types";
|
||||
import type {
|
||||
IssueReactionAddedPayload,
|
||||
IssueReactionRemovedPayload,
|
||||
} from "@/shared/types";
|
||||
import { api } from "@/shared/api";
|
||||
import { toast } from "sonner";
|
||||
import { useWSEvent, useWSReconnect } from "@/features/realtime";
|
||||
|
||||
export function useIssueReactions(issueId: string, userId?: string) {
|
||||
const [reactions, setReactions] = useState<IssueReaction[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Initial fetch
|
||||
useEffect(() => {
|
||||
setReactions([]);
|
||||
setLoading(true);
|
||||
api
|
||||
.getIssue(issueId)
|
||||
.then((iss) => setReactions(iss.reactions ?? []))
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
toast.error("Failed to load reactions");
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, [issueId]);
|
||||
|
||||
// Reconnect recovery
|
||||
useWSReconnect(
|
||||
useCallback(() => {
|
||||
api.getIssue(issueId).then((iss) => setReactions(iss.reactions ?? [])).catch(console.error);
|
||||
}, [issueId]),
|
||||
);
|
||||
|
||||
// --- WS event handlers ---
|
||||
|
||||
useWSEvent(
|
||||
"issue_reaction:added",
|
||||
useCallback(
|
||||
(payload: unknown) => {
|
||||
const { reaction, issue_id } = payload as IssueReactionAddedPayload;
|
||||
if (issue_id !== issueId) return;
|
||||
if (reaction.actor_type === "member" && reaction.actor_id === userId) return;
|
||||
setReactions((prev) => {
|
||||
if (prev.some((r) => r.id === reaction.id)) return prev;
|
||||
return [...prev, reaction];
|
||||
});
|
||||
},
|
||||
[issueId, userId],
|
||||
),
|
||||
);
|
||||
|
||||
useWSEvent(
|
||||
"issue_reaction:removed",
|
||||
useCallback(
|
||||
(payload: unknown) => {
|
||||
const p = payload as IssueReactionRemovedPayload;
|
||||
if (p.issue_id !== issueId) return;
|
||||
if (p.actor_type === "member" && p.actor_id === userId) return;
|
||||
setReactions((prev) =>
|
||||
prev.filter(
|
||||
(r) => !(r.emoji === p.emoji && r.actor_type === p.actor_type && r.actor_id === p.actor_id),
|
||||
),
|
||||
);
|
||||
},
|
||||
[issueId, userId],
|
||||
),
|
||||
);
|
||||
|
||||
// --- Mutation ---
|
||||
|
||||
const toggleReaction = useCallback(
|
||||
async (emoji: string) => {
|
||||
if (!userId) return;
|
||||
const existing = reactions.find(
|
||||
(r) => r.emoji === emoji && r.actor_type === "member" && r.actor_id === userId,
|
||||
);
|
||||
if (existing) {
|
||||
setReactions((prev) => prev.filter((r) => r.id !== existing.id));
|
||||
try {
|
||||
await api.removeIssueReaction(issueId, emoji);
|
||||
} catch {
|
||||
setReactions((prev) => [...prev, existing]);
|
||||
toast.error("Failed to remove reaction");
|
||||
}
|
||||
} else {
|
||||
const temp: IssueReaction = {
|
||||
id: `temp-${Date.now()}`,
|
||||
issue_id: issueId,
|
||||
actor_type: "member",
|
||||
actor_id: userId,
|
||||
emoji,
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
setReactions((prev) => [...prev, temp]);
|
||||
try {
|
||||
const reaction = await api.addIssueReaction(issueId, emoji);
|
||||
setReactions((prev) => prev.map((r) => (r.id === temp.id ? reaction : r)));
|
||||
} catch {
|
||||
setReactions((prev) => prev.filter((r) => r.id !== temp.id));
|
||||
toast.error("Failed to add reaction");
|
||||
}
|
||||
}
|
||||
},
|
||||
[issueId, userId, reactions],
|
||||
);
|
||||
|
||||
return { reactions, loading, toggleReaction };
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import type { IssueSubscriber } from "@/shared/types";
|
||||
import type {
|
||||
SubscriberAddedPayload,
|
||||
SubscriberRemovedPayload,
|
||||
} from "@/shared/types";
|
||||
import { api } from "@/shared/api";
|
||||
import { toast } from "sonner";
|
||||
import { useWSEvent, useWSReconnect } from "@/features/realtime";
|
||||
|
||||
export function useIssueSubscribers(issueId: string, userId?: string) {
|
||||
const [subscribers, setSubscribers] = useState<IssueSubscriber[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Initial fetch
|
||||
useEffect(() => {
|
||||
setSubscribers([]);
|
||||
setLoading(true);
|
||||
api
|
||||
.listIssueSubscribers(issueId)
|
||||
.then((subs) => setSubscribers(subs))
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
toast.error("Failed to load subscribers");
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, [issueId]);
|
||||
|
||||
// Reconnect recovery
|
||||
useWSReconnect(
|
||||
useCallback(() => {
|
||||
api.listIssueSubscribers(issueId).then(setSubscribers).catch(console.error);
|
||||
}, [issueId]),
|
||||
);
|
||||
|
||||
// --- WS event handlers ---
|
||||
|
||||
useWSEvent(
|
||||
"subscriber:added",
|
||||
useCallback(
|
||||
(payload: unknown) => {
|
||||
const p = payload as SubscriberAddedPayload;
|
||||
if (p.issue_id !== issueId) return;
|
||||
setSubscribers((prev) => {
|
||||
if (prev.some((s) => s.user_id === p.user_id && s.user_type === p.user_type)) return prev;
|
||||
return [
|
||||
...prev,
|
||||
{
|
||||
issue_id: p.issue_id,
|
||||
user_type: p.user_type as "member" | "agent",
|
||||
user_id: p.user_id,
|
||||
reason: p.reason as IssueSubscriber["reason"],
|
||||
created_at: new Date().toISOString(),
|
||||
},
|
||||
];
|
||||
});
|
||||
},
|
||||
[issueId],
|
||||
),
|
||||
);
|
||||
|
||||
useWSEvent(
|
||||
"subscriber:removed",
|
||||
useCallback(
|
||||
(payload: unknown) => {
|
||||
const p = payload as SubscriberRemovedPayload;
|
||||
if (p.issue_id !== issueId) return;
|
||||
setSubscribers((prev) =>
|
||||
prev.filter((s) => !(s.user_id === p.user_id && s.user_type === p.user_type)),
|
||||
);
|
||||
},
|
||||
[issueId],
|
||||
),
|
||||
);
|
||||
|
||||
// --- Mutations ---
|
||||
|
||||
const isSubscribed = subscribers.some(
|
||||
(s) => s.user_type === "member" && s.user_id === userId,
|
||||
);
|
||||
|
||||
const toggleSubscriber = useCallback(
|
||||
async (subUserId: string, userType: "member" | "agent", currentlySubscribed: boolean) => {
|
||||
if (currentlySubscribed) {
|
||||
// Optimistic remove + rollback on error
|
||||
const removed = subscribers.find(
|
||||
(s) => s.user_id === subUserId && s.user_type === userType,
|
||||
);
|
||||
setSubscribers((prev) =>
|
||||
prev.filter((s) => !(s.user_id === subUserId && s.user_type === userType)),
|
||||
);
|
||||
try {
|
||||
await api.unsubscribeFromIssue(issueId, subUserId, userType);
|
||||
} catch {
|
||||
if (removed) setSubscribers((prev) => [...prev, removed]);
|
||||
toast.error("Failed to update subscriber");
|
||||
}
|
||||
} else {
|
||||
// Optimistic add
|
||||
const tempSub: IssueSubscriber = {
|
||||
issue_id: issueId,
|
||||
user_type: userType,
|
||||
user_id: subUserId,
|
||||
reason: "manual" as const,
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
setSubscribers((prev) => {
|
||||
if (prev.some((s) => s.user_id === subUserId && s.user_type === userType)) return prev;
|
||||
return [...prev, tempSub];
|
||||
});
|
||||
try {
|
||||
await api.subscribeToIssue(issueId, subUserId, userType);
|
||||
} catch {
|
||||
setSubscribers((prev) =>
|
||||
prev.filter((s) => !(s.user_id === subUserId && s.user_type === userType && s.reason === "manual")),
|
||||
);
|
||||
toast.error("Failed to update subscriber");
|
||||
}
|
||||
}
|
||||
},
|
||||
[issueId, subscribers],
|
||||
);
|
||||
|
||||
const toggleSubscribe = useCallback(() => {
|
||||
if (userId) toggleSubscriber(userId, "member", isSubscribed);
|
||||
}, [userId, isSubscribed, toggleSubscriber]);
|
||||
|
||||
return { subscribers, loading, isSubscribed, toggleSubscribe, toggleSubscriber };
|
||||
}
|
||||
@@ -1,348 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import type { Comment, TimelineEntry } from "@/shared/types";
|
||||
import type {
|
||||
CommentCreatedPayload,
|
||||
CommentUpdatedPayload,
|
||||
CommentDeletedPayload,
|
||||
ActivityCreatedPayload,
|
||||
ReactionAddedPayload,
|
||||
ReactionRemovedPayload,
|
||||
} from "@/shared/types";
|
||||
import { api } from "@/shared/api";
|
||||
import { useWSEvent, useWSReconnect } from "@/features/realtime";
|
||||
import { toast } from "sonner";
|
||||
|
||||
function commentToTimelineEntry(c: Comment): TimelineEntry {
|
||||
return {
|
||||
type: "comment",
|
||||
id: c.id,
|
||||
actor_type: c.author_type,
|
||||
actor_id: c.author_id,
|
||||
content: c.content,
|
||||
parent_id: c.parent_id,
|
||||
created_at: c.created_at,
|
||||
updated_at: c.updated_at,
|
||||
comment_type: c.type,
|
||||
reactions: c.reactions ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
export function useIssueTimeline(issueId: string, userId?: string) {
|
||||
const [timeline, setTimeline] = useState<TimelineEntry[]>([]);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Initial fetch + reset on id change
|
||||
useEffect(() => {
|
||||
setTimeline([]);
|
||||
setLoading(true);
|
||||
api
|
||||
.listTimeline(issueId)
|
||||
.then((entries) => setTimeline(entries))
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
toast.error("Failed to load activity");
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, [issueId]);
|
||||
|
||||
// Reconnect recovery
|
||||
useWSReconnect(
|
||||
useCallback(() => {
|
||||
api.listTimeline(issueId).then(setTimeline).catch(console.error);
|
||||
}, [issueId]),
|
||||
);
|
||||
|
||||
// --- WS event handlers ---
|
||||
|
||||
useWSEvent(
|
||||
"comment:created",
|
||||
useCallback(
|
||||
(payload: unknown) => {
|
||||
const { comment } = payload as CommentCreatedPayload;
|
||||
if (comment.issue_id !== issueId) return;
|
||||
if (comment.author_type === "member" && comment.author_id === userId) return;
|
||||
setTimeline((prev) => {
|
||||
if (prev.some((e) => e.id === comment.id)) return prev;
|
||||
return [...prev, commentToTimelineEntry(comment)];
|
||||
});
|
||||
},
|
||||
[issueId, userId],
|
||||
),
|
||||
);
|
||||
|
||||
useWSEvent(
|
||||
"comment:updated",
|
||||
useCallback(
|
||||
(payload: unknown) => {
|
||||
const { comment } = payload as CommentUpdatedPayload;
|
||||
if (comment.issue_id === issueId) {
|
||||
setTimeline((prev) =>
|
||||
prev.map((e) => (e.id === comment.id ? commentToTimelineEntry(comment) : e)),
|
||||
);
|
||||
}
|
||||
},
|
||||
[issueId],
|
||||
),
|
||||
);
|
||||
|
||||
useWSEvent(
|
||||
"comment:deleted",
|
||||
useCallback(
|
||||
(payload: unknown) => {
|
||||
const { comment_id, issue_id } = payload as CommentDeletedPayload;
|
||||
if (issue_id === issueId) {
|
||||
setTimeline((prev) => {
|
||||
const idsToRemove = new Set<string>([comment_id]);
|
||||
let added = true;
|
||||
while (added) {
|
||||
added = false;
|
||||
for (const e of prev) {
|
||||
if (e.parent_id && idsToRemove.has(e.parent_id) && !idsToRemove.has(e.id)) {
|
||||
idsToRemove.add(e.id);
|
||||
added = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return prev.filter((e) => !idsToRemove.has(e.id));
|
||||
});
|
||||
}
|
||||
},
|
||||
[issueId],
|
||||
),
|
||||
);
|
||||
|
||||
useWSEvent(
|
||||
"activity:created",
|
||||
useCallback(
|
||||
(payload: unknown) => {
|
||||
const p = payload as ActivityCreatedPayload;
|
||||
if (p.issue_id !== issueId) return;
|
||||
const entry = p.entry;
|
||||
if (!entry || !entry.id) return;
|
||||
setTimeline((prev) => {
|
||||
if (prev.some((e) => e.id === entry.id)) return prev;
|
||||
return [...prev, entry];
|
||||
});
|
||||
},
|
||||
[issueId],
|
||||
),
|
||||
);
|
||||
|
||||
useWSEvent(
|
||||
"reaction:added",
|
||||
useCallback(
|
||||
(payload: unknown) => {
|
||||
const { reaction, issue_id } = payload as ReactionAddedPayload;
|
||||
if (issue_id !== issueId) return;
|
||||
if (reaction.actor_type === "member" && reaction.actor_id === userId) return;
|
||||
setTimeline((prev) =>
|
||||
prev.map((e) => {
|
||||
if (e.id !== reaction.comment_id) return e;
|
||||
const existing = e.reactions ?? [];
|
||||
if (existing.some((r) => r.id === reaction.id)) return e;
|
||||
return { ...e, reactions: [...existing, reaction] };
|
||||
}),
|
||||
);
|
||||
},
|
||||
[issueId, userId],
|
||||
),
|
||||
);
|
||||
|
||||
useWSEvent(
|
||||
"reaction:removed",
|
||||
useCallback(
|
||||
(payload: unknown) => {
|
||||
const p = payload as ReactionRemovedPayload;
|
||||
if (p.issue_id !== issueId) return;
|
||||
if (p.actor_type === "member" && p.actor_id === userId) return;
|
||||
setTimeline((prev) =>
|
||||
prev.map((e) => {
|
||||
if (e.id !== p.comment_id) return e;
|
||||
return {
|
||||
...e,
|
||||
reactions: (e.reactions ?? []).filter(
|
||||
(r) => !(r.emoji === p.emoji && r.actor_type === p.actor_type && r.actor_id === p.actor_id),
|
||||
),
|
||||
};
|
||||
}),
|
||||
);
|
||||
},
|
||||
[issueId, userId],
|
||||
),
|
||||
);
|
||||
|
||||
// --- Mutation functions ---
|
||||
|
||||
const submitComment = useCallback(
|
||||
async (content: string, attachmentIds?: string[]) => {
|
||||
if (!content.trim() || submitting || !userId) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const comment = await api.createComment(issueId, content, undefined, undefined, attachmentIds);
|
||||
setTimeline((prev) => {
|
||||
if (prev.some((e) => e.id === comment.id)) return prev;
|
||||
return [...prev, commentToTimelineEntry(comment)];
|
||||
});
|
||||
} catch {
|
||||
toast.error("Failed to send comment");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
},
|
||||
[issueId, userId],
|
||||
);
|
||||
|
||||
const submitReply = useCallback(
|
||||
async (parentId: string, content: string, attachmentIds?: string[]) => {
|
||||
if (!content.trim() || !userId) return;
|
||||
try {
|
||||
const comment = await api.createComment(issueId, content, "comment", parentId, attachmentIds);
|
||||
setTimeline((prev) => {
|
||||
if (prev.some((e) => e.id === comment.id)) return prev;
|
||||
return [...prev, commentToTimelineEntry(comment)];
|
||||
});
|
||||
} catch {
|
||||
toast.error("Failed to send reply");
|
||||
}
|
||||
},
|
||||
[issueId, userId],
|
||||
);
|
||||
|
||||
const editComment = useCallback(
|
||||
async (commentId: string, content: string) => {
|
||||
// Optimistic: update content immediately
|
||||
let prevContent: string | undefined;
|
||||
setTimeline((prev) =>
|
||||
prev.map((e) => {
|
||||
if (e.id !== commentId) return e;
|
||||
prevContent = e.content;
|
||||
return { ...e, content, updated_at: new Date().toISOString() };
|
||||
}),
|
||||
);
|
||||
try {
|
||||
const updated = await api.updateComment(commentId, content);
|
||||
setTimeline((prev) =>
|
||||
prev.map((e) => (e.id === updated.id ? commentToTimelineEntry(updated) : e)),
|
||||
);
|
||||
} catch {
|
||||
// Rollback
|
||||
if (prevContent !== undefined) {
|
||||
setTimeline((prev) =>
|
||||
prev.map((e) => (e.id === commentId ? { ...e, content: prevContent! } : e)),
|
||||
);
|
||||
}
|
||||
toast.error("Failed to update comment");
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const deleteComment = useCallback(
|
||||
async (commentId: string) => {
|
||||
// Capture entries for rollback
|
||||
let removedEntries: TimelineEntry[] = [];
|
||||
setTimeline((prev) => {
|
||||
const idsToRemove = new Set<string>([commentId]);
|
||||
let added = true;
|
||||
while (added) {
|
||||
added = false;
|
||||
for (const e of prev) {
|
||||
if (e.parent_id && idsToRemove.has(e.parent_id) && !idsToRemove.has(e.id)) {
|
||||
idsToRemove.add(e.id);
|
||||
added = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
removedEntries = prev.filter((e) => idsToRemove.has(e.id));
|
||||
return prev.filter((e) => !idsToRemove.has(e.id));
|
||||
});
|
||||
try {
|
||||
await api.deleteComment(commentId);
|
||||
} catch {
|
||||
// Rollback: re-add removed entries
|
||||
setTimeline((prev) => [...prev, ...removedEntries]);
|
||||
toast.error("Failed to delete comment");
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const toggleReaction = useCallback(
|
||||
async (commentId: string, emoji: string) => {
|
||||
if (!userId) return;
|
||||
const entry = timeline.find((e) => e.id === commentId);
|
||||
const existing = (entry?.reactions ?? []).find(
|
||||
(r) => r.emoji === emoji && r.actor_type === "member" && r.actor_id === userId,
|
||||
);
|
||||
if (existing) {
|
||||
setTimeline((prev) =>
|
||||
prev.map((e) => {
|
||||
if (e.id !== commentId) return e;
|
||||
return { ...e, reactions: (e.reactions ?? []).filter((r) => r.id !== existing.id) };
|
||||
}),
|
||||
);
|
||||
try {
|
||||
await api.removeReaction(commentId, emoji);
|
||||
} catch {
|
||||
setTimeline((prev) =>
|
||||
prev.map((e) => {
|
||||
if (e.id !== commentId) return e;
|
||||
return { ...e, reactions: [...(e.reactions ?? []), existing] };
|
||||
}),
|
||||
);
|
||||
toast.error("Failed to remove reaction");
|
||||
}
|
||||
} else {
|
||||
const tempReaction = {
|
||||
id: `temp-${Date.now()}`,
|
||||
comment_id: commentId,
|
||||
actor_type: "member",
|
||||
actor_id: userId,
|
||||
emoji,
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
setTimeline((prev) =>
|
||||
prev.map((e) => {
|
||||
if (e.id !== commentId) return e;
|
||||
return { ...e, reactions: [...(e.reactions ?? []), tempReaction] };
|
||||
}),
|
||||
);
|
||||
try {
|
||||
const reaction = await api.addReaction(commentId, emoji);
|
||||
setTimeline((prev) =>
|
||||
prev.map((e) => {
|
||||
if (e.id !== commentId) return e;
|
||||
return {
|
||||
...e,
|
||||
reactions: (e.reactions ?? []).map((r) => (r.id === tempReaction.id ? reaction : r)),
|
||||
};
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
setTimeline((prev) =>
|
||||
prev.map((e) => {
|
||||
if (e.id !== commentId) return e;
|
||||
return { ...e, reactions: (e.reactions ?? []).filter((r) => r.id !== tempReaction.id) };
|
||||
}),
|
||||
);
|
||||
toast.error("Failed to add reaction");
|
||||
}
|
||||
}
|
||||
},
|
||||
[userId, timeline],
|
||||
);
|
||||
|
||||
return {
|
||||
timeline,
|
||||
loading,
|
||||
submitting,
|
||||
submitComment,
|
||||
submitReply,
|
||||
editComment,
|
||||
deleteComment,
|
||||
toggleReaction,
|
||||
};
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
export { useIssueStore } from "./store";
|
||||
export { useIssueViewStore, createIssueViewStore } from "./stores/view-store";
|
||||
export { ViewStoreProvider, useViewStore } from "./stores/view-store-context";
|
||||
export { StatusIcon, PriorityIcon, StatusPicker, PriorityPicker, AssigneePicker } from "./components";
|
||||
export * from "./config";
|
||||
@@ -1,57 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { create } from "zustand";
|
||||
import type { Issue } from "@/shared/types";
|
||||
import { toast } from "sonner";
|
||||
import { api } from "@/shared/api";
|
||||
import { createLogger } from "@/shared/logger";
|
||||
|
||||
const logger = createLogger("issue-store");
|
||||
|
||||
interface IssueState {
|
||||
issues: Issue[];
|
||||
loading: boolean;
|
||||
activeIssueId: string | null;
|
||||
fetch: () => Promise<void>;
|
||||
setIssues: (issues: Issue[]) => void;
|
||||
addIssue: (issue: Issue) => void;
|
||||
updateIssue: (id: string, updates: Partial<Issue>) => void;
|
||||
removeIssue: (id: string) => void;
|
||||
setActiveIssue: (id: string | null) => void;
|
||||
}
|
||||
|
||||
export const useIssueStore = create<IssueState>((set, get) => ({
|
||||
issues: [],
|
||||
loading: true,
|
||||
activeIssueId: null,
|
||||
|
||||
fetch: async () => {
|
||||
logger.debug("fetch start");
|
||||
const isInitialLoad = get().issues.length === 0;
|
||||
if (isInitialLoad) set({ loading: true });
|
||||
try {
|
||||
const res = await api.listIssues({ limit: 200 });
|
||||
logger.info("fetched", res.issues.length, "issues");
|
||||
set({ issues: res.issues, loading: false });
|
||||
} catch (err) {
|
||||
logger.error("fetch failed", err);
|
||||
toast.error("Failed to load issues");
|
||||
if (isInitialLoad) set({ loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
setIssues: (issues) => set({ issues }),
|
||||
addIssue: (issue) =>
|
||||
set((s) => ({
|
||||
issues: s.issues.some((i) => i.id === issue.id)
|
||||
? s.issues
|
||||
: [...s.issues, issue],
|
||||
})),
|
||||
updateIssue: (id, updates) =>
|
||||
set((s) => ({
|
||||
issues: s.issues.map((i) => (i.id === id ? { ...i, ...updates } : i)),
|
||||
})),
|
||||
removeIssue: (id) =>
|
||||
set((s) => ({ issues: s.issues.filter((i) => i.id !== id) })),
|
||||
setActiveIssue: (id) => set({ activeIssueId: id }),
|
||||
}));
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import { useLocale } from "../i18n";
|
||||
|
||||
export function FAQSection() {
|
||||
|
||||
@@ -18,15 +18,14 @@ import {
|
||||
Sparkles,
|
||||
UserMinus,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import { ImageIcon } from "./shared";
|
||||
import { useLocale } from "../i18n";
|
||||
import type { LandingDict } from "../i18n";
|
||||
import { StatusIcon } from "@/features/issues/components/status-icon";
|
||||
import { PriorityIcon } from "@/features/issues/components/priority-icon";
|
||||
import { STATUS_CONFIG } from "@/features/issues/config/status";
|
||||
import { PRIORITY_CONFIG } from "@/features/issues/config/priority";
|
||||
import type { IssueStatus, IssuePriority } from "@/shared/types";
|
||||
import { StatusIcon, PriorityIcon } from "@multica/views/issues/components";
|
||||
import { STATUS_CONFIG } from "@multica/core/issues/config/status";
|
||||
import { PRIORITY_CONFIG } from "@multica/core/issues/config/priority";
|
||||
import type { IssueStatus, IssuePriority } from "@multica/core/types";
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Mock ActorAvatar — mirrors the real ActorAvatar styling exactly */
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import Link from "next/link";
|
||||
import { MulticaIcon } from "@/components/multica-icon";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import { useAuthStore } from "@/features/auth";
|
||||
import { useLocale, locales, localeLabels } from "../i18n";
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import Link from "next/link";
|
||||
import { MulticaIcon } from "@/components/multica-icon";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import { useAuthStore } from "@/features/auth";
|
||||
import { useLocale } from "../i18n";
|
||||
import { GitHubMark, githubUrl, headerButtonClassName } from "./shared";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
|
||||
export const githubUrl = "https://github.com/multica-ai/multica";
|
||||
|
||||
|
||||
@@ -131,7 +131,7 @@ export const en: LandingDict = {
|
||||
{
|
||||
title: "Create your first agent",
|
||||
description:
|
||||
"Give it a name, write instructions, attach skills, and set triggers. Choose when it activates: on assignment, on comment, or on mention.",
|
||||
"Give it a name, write instructions, and attach skills. Agents automatically activate on assignment, on comment, or on mention.",
|
||||
},
|
||||
{
|
||||
title: "Assign an issue and watch it work",
|
||||
@@ -272,6 +272,53 @@ export const en: LandingDict = {
|
||||
title: "Changelog",
|
||||
subtitle: "New updates and improvements to Multica.",
|
||||
entries: [
|
||||
{
|
||||
version: "0.1.9",
|
||||
date: "2026-04-08",
|
||||
title: "Sub-Issues, TanStack Query & Usage Tracking",
|
||||
changes: [
|
||||
"Sub-issue support — create, view, and manage child issues within any issue",
|
||||
"Full migration to TanStack Query for server state (issues, inbox, workspace, runtimes)",
|
||||
"Per-task token usage tracking across all agent providers",
|
||||
"Multiple agents can now run concurrently on the same issue",
|
||||
"Board view: Done column shows total count with infinite scroll",
|
||||
"ReadonlyContent component for lightweight Markdown display in comments",
|
||||
"Optimistic UI updates for reactions and mutations with rollback",
|
||||
"WebSocket-driven cache invalidation replaces polling and refetch-on-focus",
|
||||
"Browser session persists during CLI login flow",
|
||||
"Daemon reuses existing worktrees by updating to latest remote",
|
||||
"Fixed slow tab switching caused by dynamic root layout",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.1.8",
|
||||
date: "2026-04-07",
|
||||
title: "OAuth, OpenClaw & Issue Loading",
|
||||
changes: [
|
||||
"Google OAuth login",
|
||||
"OpenClaw runtime support for running agents on OpenClaw infrastructure",
|
||||
"Redesigned agent live card — always sticky with manual expand/collapse toggle",
|
||||
"Load all open issues without pagination limit; closed issues paginate on scroll",
|
||||
"JWT and CloudFront cookie expiration extended from 72 hours to 30 days",
|
||||
"Remember last selected workspace after re-login",
|
||||
"Daemon ensures multica CLI is on PATH in agent task environment",
|
||||
"PR template and CLI install guide for agent-driven setup",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.1.7",
|
||||
date: "2026-04-05",
|
||||
title: "Comment Pagination & CLI Polish",
|
||||
changes: [
|
||||
"Comment list pagination in both the API and CLI",
|
||||
"Inbox archive now dismisses all items for the same issue at once",
|
||||
"CLI help output overhauled to match gh CLI style with examples",
|
||||
"Attachments use UUIDv7 as S3 key and auto-link on issue/comment creation",
|
||||
"@mention assigned agents on done or cancelled issues",
|
||||
"Reply @mention inheritance skips when the reply only mentions members",
|
||||
"Worktree setup preserves existing .env.worktree variables",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.1.6",
|
||||
date: "2026-04-03",
|
||||
@@ -356,7 +403,7 @@ export const en: LandingDict = {
|
||||
title: "Core Platform",
|
||||
changes: [
|
||||
"Multi-workspace switching and creation",
|
||||
"Agent management UI with skills, tools, and triggers",
|
||||
"Agent management UI with skills",
|
||||
"Unified agent SDK supporting Claude Code and Codex backends",
|
||||
"Comment CRUD with real-time WebSocket updates",
|
||||
"Task service layer and daemon REST protocol",
|
||||
|
||||
@@ -272,6 +272,53 @@ export const zh: LandingDict = {
|
||||
title: "\u66f4\u65b0\u65e5\u5fd7",
|
||||
subtitle: "Multica \u7684\u6700\u65b0\u66f4\u65b0\u548c\u6539\u8fdb\u3002",
|
||||
entries: [
|
||||
{
|
||||
version: "0.1.9",
|
||||
date: "2026-04-08",
|
||||
title: "子 Issue、TanStack Query 与用量追踪",
|
||||
changes: [
|
||||
"子 Issue 支持——在任意 Issue 内创建、查看和管理子任务",
|
||||
"全面迁移至 TanStack Query 管理服务端状态(Issue、收件箱、工作区、运行时)",
|
||||
"按任务维度追踪所有 Agent 提供商的 token 用量",
|
||||
"同一 Issue 支持多个 Agent 并发执行",
|
||||
"看板视图:Done 列显示总数并支持无限滚动",
|
||||
"新增 ReadonlyContent 组件,轻量渲染评论中的 Markdown",
|
||||
"表情反应和变更操作支持乐观更新与回滚",
|
||||
"WebSocket 驱动缓存失效,替代轮询和焦点刷新",
|
||||
"CLI 登录流程中浏览器会话保持不丢失",
|
||||
"守护进程复用已有 worktree 时自动拉取最新远程代码",
|
||||
"修复动态根布局导致的标签页切换卡顿问题",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.1.8",
|
||||
date: "2026-04-07",
|
||||
title: "OAuth、OpenClaw 与 Issue 加载优化",
|
||||
changes: [
|
||||
"支持 Google OAuth 登录",
|
||||
"新增 OpenClaw 运行时,支持在 OpenClaw 基础设施上运行 Agent",
|
||||
"Agent 实时卡片重新设计——始终吸顶,支持手动展开/收起",
|
||||
"打开的 Issue 不再分页限制全量加载,已关闭的 Issue 滚动分页",
|
||||
"JWT 和 CloudFront Cookie 有效期从 72 小时延长至 30 天",
|
||||
"重新登录后记住上次选择的工作区",
|
||||
"守护进程确保 Agent 任务环境中 multica CLI 在 PATH 上",
|
||||
"新增 PR 模板和面向 Agent 的 CLI 安装指南",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.1.7",
|
||||
date: "2026-04-05",
|
||||
title: "评论分页与 CLI 优化",
|
||||
changes: [
|
||||
"评论列表支持分页,API 和 CLI 均已适配",
|
||||
"收件箱归档操作现在一次性归档同一 Issue 的所有通知",
|
||||
"CLI 帮助输出重新设计,匹配 gh CLI 风格并增加示例",
|
||||
"附件使用 UUIDv7 作为 S3 key,创建 Issue/评论时自动关联附件",
|
||||
"支持在已完成或已取消的 Issue 上 @提及已分配的 Agent",
|
||||
"回复仅 @提及成员时跳过父级提及继承逻辑",
|
||||
"Worktree 环境配置保留已有的 .env.worktree 变量",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.1.6",
|
||||
date: "2026-04-03",
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
export { useModalStore } from "./store";
|
||||
export { ModalRegistry } from "./registry";
|
||||
@@ -1,2 +0,0 @@
|
||||
export { WSProvider, useWS } from "./provider";
|
||||
export { useWSEvent, useWSReconnect } from "./hooks";
|
||||
@@ -1,91 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
useRef,
|
||||
useCallback,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { WSClient } from "@/shared/api";
|
||||
import type { WSEventType } from "@/shared/types";
|
||||
import { useAuthStore } from "@/features/auth";
|
||||
import { useWorkspaceStore } from "@/features/workspace";
|
||||
import { createLogger } from "@/shared/logger";
|
||||
import { useRealtimeSync } from "./use-realtime-sync";
|
||||
|
||||
const WS_URL =
|
||||
process.env.NEXT_PUBLIC_WS_URL ||
|
||||
(typeof window !== "undefined"
|
||||
? `${window.location.protocol === "https:" ? "wss:" : "ws:"}//${window.location.host}/ws`
|
||||
: "ws://localhost:8080/ws");
|
||||
|
||||
type EventHandler = (payload: unknown) => void;
|
||||
|
||||
interface WSContextValue {
|
||||
subscribe: (event: WSEventType, handler: EventHandler) => () => void;
|
||||
onReconnect: (callback: () => void) => () => void;
|
||||
}
|
||||
|
||||
const WSContext = createContext<WSContextValue | null>(null);
|
||||
|
||||
export function WSProvider({ children }: { children: ReactNode }) {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const workspace = useWorkspaceStore((s) => s.workspace);
|
||||
const [wsClient, setWsClient] = useState<WSClient | null>(null);
|
||||
const wsRef = useRef<WSClient | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user || !workspace) return;
|
||||
|
||||
const token = localStorage.getItem("multica_token");
|
||||
if (!token) return;
|
||||
|
||||
const ws = new WSClient(WS_URL, { logger: createLogger("ws") });
|
||||
ws.setAuth(token, workspace.id);
|
||||
wsRef.current = ws;
|
||||
setWsClient(ws);
|
||||
ws.connect();
|
||||
|
||||
return () => {
|
||||
ws.disconnect();
|
||||
wsRef.current = null;
|
||||
setWsClient(null);
|
||||
};
|
||||
}, [user, workspace]);
|
||||
|
||||
// Centralized WS → store sync (uses state so it re-subscribes when WS changes)
|
||||
useRealtimeSync(wsClient);
|
||||
|
||||
const subscribe = useCallback(
|
||||
(event: WSEventType, handler: EventHandler) => {
|
||||
const ws = wsRef.current;
|
||||
if (!ws) return () => {};
|
||||
return ws.on(event, handler);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const onReconnectCb = useCallback(
|
||||
(callback: () => void) => {
|
||||
const ws = wsRef.current;
|
||||
if (!ws) return () => {};
|
||||
return ws.onReconnect(callback);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<WSContext.Provider value={{ subscribe, onReconnect: onReconnectCb }}>
|
||||
{children}
|
||||
</WSContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useWS() {
|
||||
const ctx = useContext(WSContext);
|
||||
if (!ctx) throw new Error("useWS must be used within WSProvider");
|
||||
return ctx;
|
||||
}
|
||||
@@ -1,184 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import type { WSClient } from "@/shared/api";
|
||||
import { toast } from "sonner";
|
||||
import { useIssueStore } from "@/features/issues";
|
||||
import { useInboxStore } from "@/features/inbox";
|
||||
import { useWorkspaceStore } from "@/features/workspace";
|
||||
import { useAuthStore } from "@/features/auth";
|
||||
import { createLogger } from "@/shared/logger";
|
||||
import { api } from "@/shared/api";
|
||||
import type {
|
||||
MemberAddedPayload,
|
||||
WorkspaceDeletedPayload,
|
||||
MemberRemovedPayload,
|
||||
IssueUpdatedPayload,
|
||||
IssueCreatedPayload,
|
||||
IssueDeletedPayload,
|
||||
InboxNewPayload,
|
||||
} from "@/shared/types";
|
||||
|
||||
const logger = createLogger("realtime-sync");
|
||||
|
||||
/**
|
||||
* Centralized WS → store sync. Called once from WSProvider.
|
||||
*
|
||||
* Uses the "WS as invalidation signal + refetch" pattern:
|
||||
* - onAny handler extracts event prefix and calls the matching store refresh
|
||||
* - Debounce per-prefix prevents rapid-fire refetches (e.g. bulk issue updates)
|
||||
* - Precise handlers only for side effects (toast, navigation, self-check)
|
||||
*
|
||||
* Per-page events (comments, activity, subscribers, daemon) are still handled
|
||||
* by individual components via useWSEvent — not here.
|
||||
*/
|
||||
export function useRealtimeSync(ws: WSClient | null) {
|
||||
// Main sync: onAny → refreshMap with debounce
|
||||
useEffect(() => {
|
||||
if (!ws) return;
|
||||
|
||||
// Event types handled by specific handlers below — skip generic refresh
|
||||
const specificEvents = new Set([
|
||||
"issue:updated", "issue:created", "issue:deleted", "inbox:new",
|
||||
]);
|
||||
|
||||
const refreshMap: Record<string, () => void> = {
|
||||
inbox: () => void useInboxStore.getState().fetch(),
|
||||
agent: () => void useWorkspaceStore.getState().refreshAgents(),
|
||||
member: () => void useWorkspaceStore.getState().refreshMembers(),
|
||||
workspace: () => {
|
||||
// Lightweight: only re-fetch workspace list, don't hydrate everything.
|
||||
// workspace:deleted is handled by a precise side-effect handler below.
|
||||
api.listWorkspaces().then((wsList) => {
|
||||
const current = useWorkspaceStore.getState().workspace;
|
||||
const updated = current
|
||||
? wsList.find((w) => w.id === current.id)
|
||||
: null;
|
||||
if (updated) useWorkspaceStore.getState().updateWorkspace(updated);
|
||||
}).catch((err) => {
|
||||
logger.error("workspace refresh failed", err);
|
||||
});
|
||||
},
|
||||
skill: () => void useWorkspaceStore.getState().refreshSkills(),
|
||||
};
|
||||
|
||||
const timers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
const debouncedRefresh = (prefix: string, fn: () => void) => {
|
||||
const existing = timers.get(prefix);
|
||||
if (existing) clearTimeout(existing);
|
||||
timers.set(
|
||||
prefix,
|
||||
setTimeout(() => {
|
||||
timers.delete(prefix);
|
||||
fn();
|
||||
}, 100),
|
||||
);
|
||||
};
|
||||
|
||||
const unsubAny = ws.onAny((msg) => {
|
||||
const myUserId = useAuthStore.getState().user?.id;
|
||||
if (msg.actor_id && msg.actor_id === myUserId) {
|
||||
logger.debug("skipping self-event", msg.type);
|
||||
return;
|
||||
}
|
||||
if (specificEvents.has(msg.type)) return;
|
||||
const prefix = msg.type.split(":")[0] ?? "";
|
||||
const refresh = refreshMap[prefix];
|
||||
if (refresh) debouncedRefresh(prefix, refresh);
|
||||
});
|
||||
|
||||
// --- Specific event handlers (granular updates, no full refetch) ---
|
||||
|
||||
const unsubIssueUpdated = ws.on("issue:updated", (p) => {
|
||||
const { issue } = p as IssueUpdatedPayload;
|
||||
if (!issue?.id) return;
|
||||
useIssueStore.getState().updateIssue(issue.id, issue);
|
||||
if (issue.status) {
|
||||
useInboxStore.getState().updateIssueStatus(issue.id, issue.status);
|
||||
}
|
||||
});
|
||||
|
||||
const unsubIssueCreated = ws.on("issue:created", (p) => {
|
||||
const { issue } = p as IssueCreatedPayload;
|
||||
if (issue) useIssueStore.getState().addIssue(issue);
|
||||
});
|
||||
|
||||
const unsubIssueDeleted = ws.on("issue:deleted", (p) => {
|
||||
const { issue_id } = p as IssueDeletedPayload;
|
||||
if (issue_id) useIssueStore.getState().removeIssue(issue_id);
|
||||
});
|
||||
|
||||
const unsubInboxNew = ws.on("inbox:new", (p) => {
|
||||
const { item } = p as InboxNewPayload;
|
||||
if (item) useInboxStore.getState().addItem(item);
|
||||
});
|
||||
|
||||
// --- Side-effect handlers (toast, navigation) ---
|
||||
|
||||
const unsubWsDeleted = ws.on("workspace:deleted", (p) => {
|
||||
const { workspace_id } = p as WorkspaceDeletedPayload;
|
||||
const currentWs = useWorkspaceStore.getState().workspace;
|
||||
if (currentWs?.id === workspace_id) {
|
||||
logger.warn("current workspace deleted, switching");
|
||||
toast.info("This workspace was deleted");
|
||||
useWorkspaceStore.getState().refreshWorkspaces();
|
||||
}
|
||||
});
|
||||
|
||||
const unsubMemberRemoved = ws.on("member:removed", (p) => {
|
||||
const { user_id } = p as MemberRemovedPayload;
|
||||
const myUserId = useAuthStore.getState().user?.id;
|
||||
if (user_id === myUserId) {
|
||||
logger.warn("removed from workspace, switching");
|
||||
toast.info("You were removed from this workspace");
|
||||
useWorkspaceStore.getState().refreshWorkspaces();
|
||||
}
|
||||
});
|
||||
|
||||
const unsubMemberAdded = ws.on("member:added", (p) => {
|
||||
const { member, workspace_name } = p as MemberAddedPayload;
|
||||
const myUserId = useAuthStore.getState().user?.id;
|
||||
if (member.user_id === myUserId) {
|
||||
useWorkspaceStore.getState().refreshWorkspaces();
|
||||
toast.info(
|
||||
`You were invited to ${workspace_name ?? "a workspace"}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubAny();
|
||||
unsubIssueUpdated();
|
||||
unsubIssueCreated();
|
||||
unsubIssueDeleted();
|
||||
unsubInboxNew();
|
||||
unsubWsDeleted();
|
||||
unsubMemberRemoved();
|
||||
unsubMemberAdded();
|
||||
timers.forEach(clearTimeout);
|
||||
timers.clear();
|
||||
};
|
||||
}, [ws]);
|
||||
|
||||
// Reconnect → refetch all data to recover missed events
|
||||
useEffect(() => {
|
||||
if (!ws) return;
|
||||
|
||||
const unsub = ws.onReconnect(async () => {
|
||||
logger.info("reconnected, refetching all data");
|
||||
try {
|
||||
await Promise.all([
|
||||
useIssueStore.getState().fetch(),
|
||||
useInboxStore.getState().fetch(),
|
||||
useWorkspaceStore.getState().refreshAgents(),
|
||||
useWorkspaceStore.getState().refreshMembers(),
|
||||
useWorkspaceStore.getState().refreshSkills(),
|
||||
]);
|
||||
} catch (e) {
|
||||
logger.error("reconnect refetch failed", e);
|
||||
}
|
||||
});
|
||||
|
||||
return unsub;
|
||||
}, [ws]);
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { create } from "zustand";
|
||||
import type { AgentRuntime } from "@/shared/types";
|
||||
import { api } from "@/shared/api";
|
||||
import { useWorkspaceStore } from "@/features/workspace";
|
||||
|
||||
interface RuntimeState {
|
||||
runtimes: AgentRuntime[];
|
||||
selectedId: string;
|
||||
fetching: boolean;
|
||||
}
|
||||
|
||||
interface RuntimeActions {
|
||||
fetchRuntimes: () => Promise<void>;
|
||||
setSelectedId: (id: string) => void;
|
||||
/** Patch a single runtime in-place (e.g. status/last_seen_at from WS event). */
|
||||
patchRuntime: (id: string, updates: Partial<AgentRuntime>) => void;
|
||||
/** Replace the full runtimes list (used on daemon:register events). */
|
||||
setRuntimes: (runtimes: AgentRuntime[]) => void;
|
||||
}
|
||||
|
||||
type RuntimeStore = RuntimeState & RuntimeActions;
|
||||
|
||||
export const useRuntimeStore = create<RuntimeStore>((set, get) => ({
|
||||
// State
|
||||
runtimes: [],
|
||||
selectedId: "",
|
||||
fetching: true,
|
||||
|
||||
// Actions
|
||||
fetchRuntimes: async () => {
|
||||
const workspace = useWorkspaceStore.getState().workspace;
|
||||
if (!workspace) return;
|
||||
try {
|
||||
const data = await api.listRuntimes({ workspace_id: workspace.id });
|
||||
const { selectedId } = get();
|
||||
set({
|
||||
runtimes: data,
|
||||
fetching: false,
|
||||
// Auto-select first if nothing selected
|
||||
selectedId: selectedId && data.some((r) => r.id === selectedId)
|
||||
? selectedId
|
||||
: data[0]?.id ?? "",
|
||||
});
|
||||
} catch {
|
||||
set({ fetching: false });
|
||||
}
|
||||
},
|
||||
|
||||
setSelectedId: (id) => set({ selectedId: id }),
|
||||
|
||||
patchRuntime: (id, updates) => {
|
||||
set((state) => ({
|
||||
runtimes: state.runtimes.map((r) =>
|
||||
r.id === id ? { ...r, ...updates } : r,
|
||||
),
|
||||
}));
|
||||
},
|
||||
|
||||
setRuntimes: (runtimes) => {
|
||||
const { selectedId } = get();
|
||||
set({
|
||||
runtimes,
|
||||
selectedId: selectedId && runtimes.some((r) => r.id === selectedId)
|
||||
? selectedId
|
||||
: runtimes[0]?.id ?? "",
|
||||
});
|
||||
},
|
||||
}));
|
||||
198
apps/web/features/search/components/search-command.tsx
Normal file
198
apps/web/features/search/components/search-command.tsx
Normal file
@@ -0,0 +1,198 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Loader2, MessageSquare, SearchIcon } from "lucide-react";
|
||||
import { Command as CommandPrimitive } from "cmdk";
|
||||
import type { SearchIssueResult } from "@multica/core/types";
|
||||
import { api } from "@/platform/api";
|
||||
import { StatusIcon } from "@multica/views/issues/components";
|
||||
import { STATUS_CONFIG } from "@multica/core/issues/config";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from "@multica/ui/components/ui/dialog";
|
||||
|
||||
export function SearchCommand() {
|
||||
const router = useRouter();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
const [results, setResults] = useState<SearchIssueResult[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
// Global Cmd+K / Ctrl+K shortcut
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
setOpen((prev) => !prev);
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, []);
|
||||
|
||||
// Cleanup debounce/abort on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
if (abortRef.current) abortRef.current.abort();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Reset state when dialog closes
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setQuery("");
|
||||
setResults([]);
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const search = useCallback((q: string) => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
if (abortRef.current) abortRef.current.abort();
|
||||
|
||||
if (!q.trim()) {
|
||||
setResults([]);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
debounceRef.current = setTimeout(async () => {
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
try {
|
||||
const res = await api.searchIssues({
|
||||
q: q.trim(),
|
||||
limit: 20,
|
||||
include_closed: true,
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!controller.signal.aborted) {
|
||||
setResults(res.issues);
|
||||
setIsLoading(false);
|
||||
}
|
||||
} catch {
|
||||
if (!controller.signal.aborted) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
}, 300);
|
||||
}, []);
|
||||
|
||||
const handleValueChange = useCallback(
|
||||
(value: string) => {
|
||||
setQuery(value);
|
||||
search(value);
|
||||
},
|
||||
[search],
|
||||
);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(issueId: string) => {
|
||||
setOpen(false);
|
||||
router.push(`/issues/${issueId}`);
|
||||
},
|
||||
[router],
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent
|
||||
className="top-[20%] translate-y-0 overflow-hidden rounded-xl! p-0 sm:max-w-xl!"
|
||||
showCloseButton={false}
|
||||
>
|
||||
<DialogHeader className="sr-only">
|
||||
<DialogTitle>Search Issues</DialogTitle>
|
||||
<DialogDescription>
|
||||
Search issues by title, description, or comments
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<CommandPrimitive
|
||||
shouldFilter={false}
|
||||
className="flex size-full flex-col overflow-hidden rounded-xl bg-popover text-popover-foreground"
|
||||
>
|
||||
{/* Search input */}
|
||||
<div className="flex items-center gap-3 border-b px-4 py-3">
|
||||
<SearchIcon className="size-5 shrink-0 text-muted-foreground" />
|
||||
<CommandPrimitive.Input
|
||||
placeholder="Type a command or search..."
|
||||
value={query}
|
||||
onValueChange={handleValueChange}
|
||||
className="flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
|
||||
/>
|
||||
<kbd className="hidden shrink-0 rounded bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground sm:inline">
|
||||
ESC
|
||||
</kbd>
|
||||
</div>
|
||||
|
||||
{/* Results list */}
|
||||
<CommandPrimitive.List className="max-h-[min(400px,50vh)] overflow-y-auto overflow-x-hidden">
|
||||
{isLoading && (
|
||||
<div className="flex items-center justify-center py-10">
|
||||
<Loader2 className="size-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && query.trim() && results.length === 0 && (
|
||||
<CommandPrimitive.Empty className="py-10 text-center text-sm text-muted-foreground">
|
||||
No issues found.
|
||||
</CommandPrimitive.Empty>
|
||||
)}
|
||||
|
||||
{!isLoading && results.length > 0 && (
|
||||
<CommandPrimitive.Group className="p-2">
|
||||
{results.map((issue) => (
|
||||
<CommandPrimitive.Item
|
||||
key={issue.id}
|
||||
value={issue.id}
|
||||
onSelect={handleSelect}
|
||||
className="flex cursor-default select-none flex-col gap-1 rounded-lg px-3 py-2.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 data-selected:bg-accent"
|
||||
>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<StatusIcon
|
||||
status={issue.status}
|
||||
className="size-4 shrink-0"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground shrink-0">
|
||||
{issue.identifier}
|
||||
</span>
|
||||
<span className="truncate">{issue.title}</span>
|
||||
<span
|
||||
className={`ml-auto text-xs shrink-0 ${STATUS_CONFIG[issue.status].iconColor}`}
|
||||
>
|
||||
{STATUS_CONFIG[issue.status].label}
|
||||
</span>
|
||||
</div>
|
||||
{issue.match_source === "comment" &&
|
||||
issue.matched_snippet && (
|
||||
<div className="flex items-start gap-2 pl-[26px]">
|
||||
<MessageSquare className="size-3 shrink-0 text-muted-foreground mt-0.5" />
|
||||
<span className="text-xs text-muted-foreground truncate">
|
||||
{issue.matched_snippet}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</CommandPrimitive.Item>
|
||||
))}
|
||||
</CommandPrimitive.Group>
|
||||
)}
|
||||
|
||||
{!isLoading && !query.trim() && (
|
||||
<div className="py-10 text-center text-sm text-muted-foreground">
|
||||
Type to search issues...
|
||||
</div>
|
||||
)}
|
||||
</CommandPrimitive.List>
|
||||
</CommandPrimitive>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
1
apps/web/features/search/index.ts
Normal file
1
apps/web/features/search/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { SearchCommand } from "./components/search-command";
|
||||
@@ -1,3 +0,0 @@
|
||||
export { useWorkspaceStore } from "./store";
|
||||
export { useActorName } from "./hooks";
|
||||
export { WorkspaceAvatar } from "./components/workspace-avatar";
|
||||
@@ -1,239 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { create } from "zustand";
|
||||
import type { Workspace, MemberWithUser, Agent, Skill } from "@/shared/types";
|
||||
import { useIssueStore } from "@/features/issues";
|
||||
import { useInboxStore } from "@/features/inbox";
|
||||
import { useRuntimeStore } from "@/features/runtimes";
|
||||
import { toast } from "sonner";
|
||||
import { api } from "@/shared/api";
|
||||
import { createLogger } from "@/shared/logger";
|
||||
|
||||
const logger = createLogger("workspace-store");
|
||||
|
||||
interface WorkspaceState {
|
||||
workspace: Workspace | null;
|
||||
workspaces: Workspace[];
|
||||
members: MemberWithUser[];
|
||||
agents: Agent[];
|
||||
skills: Skill[];
|
||||
}
|
||||
|
||||
interface WorkspaceActions {
|
||||
hydrateWorkspace: (
|
||||
wsList: Workspace[],
|
||||
preferredWorkspaceId?: string | null,
|
||||
) => Promise<Workspace | null>;
|
||||
switchWorkspace: (workspaceId: string) => Promise<void>;
|
||||
refreshWorkspaces: () => Promise<Workspace[]>;
|
||||
refreshMembers: () => Promise<void>;
|
||||
updateAgent: (id: string, updates: Partial<Agent>) => void;
|
||||
refreshAgents: () => Promise<void>;
|
||||
refreshSkills: () => Promise<void>;
|
||||
upsertSkill: (skill: Skill) => void;
|
||||
removeSkill: (id: string) => void;
|
||||
createWorkspace: (data: {
|
||||
name: string;
|
||||
slug: string;
|
||||
description?: string;
|
||||
}) => Promise<Workspace>;
|
||||
updateWorkspace: (ws: Workspace) => void;
|
||||
leaveWorkspace: (workspaceId: string) => Promise<void>;
|
||||
deleteWorkspace: (workspaceId: string) => Promise<void>;
|
||||
clearWorkspace: () => void;
|
||||
}
|
||||
|
||||
type WorkspaceStore = WorkspaceState & WorkspaceActions;
|
||||
|
||||
export const useWorkspaceStore = create<WorkspaceStore>((set, get) => ({
|
||||
// State
|
||||
workspace: null,
|
||||
workspaces: [],
|
||||
members: [],
|
||||
agents: [],
|
||||
skills: [],
|
||||
|
||||
// Actions
|
||||
hydrateWorkspace: async (wsList, preferredWorkspaceId) => {
|
||||
set({ workspaces: wsList });
|
||||
|
||||
const nextWorkspace =
|
||||
(preferredWorkspaceId
|
||||
? wsList.find((item) => item.id === preferredWorkspaceId)
|
||||
: null) ??
|
||||
wsList[0] ??
|
||||
null;
|
||||
|
||||
if (!nextWorkspace) {
|
||||
api.setWorkspaceId(null);
|
||||
localStorage.removeItem("multica_workspace_id");
|
||||
set({ workspace: null, members: [], agents: [], skills: [] });
|
||||
return null;
|
||||
}
|
||||
|
||||
api.setWorkspaceId(nextWorkspace.id);
|
||||
localStorage.setItem("multica_workspace_id", nextWorkspace.id);
|
||||
set({ workspace: nextWorkspace });
|
||||
|
||||
logger.debug("hydrate workspace", nextWorkspace.name, nextWorkspace.id);
|
||||
const [nextMembers, nextAgents, nextSkills] = await Promise.all([
|
||||
api.listMembers(nextWorkspace.id).catch((e) => {
|
||||
logger.error("failed to load members", e);
|
||||
toast.error("Failed to load members");
|
||||
return [] as MemberWithUser[];
|
||||
}),
|
||||
api.listAgents({ workspace_id: nextWorkspace.id, include_archived: true }).catch((e) => {
|
||||
logger.error("failed to load agents", e);
|
||||
toast.error("Failed to load agents");
|
||||
return [] as Agent[];
|
||||
}),
|
||||
api.listSkills().catch(() => [] as Skill[]),
|
||||
useIssueStore.getState().fetch().catch(() => {}),
|
||||
useInboxStore.getState().fetch().catch(() => {}),
|
||||
]);
|
||||
logger.info("hydrate complete", "members:", nextMembers.length, "agents:", nextAgents.length);
|
||||
set({ members: nextMembers, agents: nextAgents, skills: nextSkills });
|
||||
|
||||
return nextWorkspace;
|
||||
},
|
||||
|
||||
switchWorkspace: async (workspaceId) => {
|
||||
logger.info("switching to", workspaceId);
|
||||
const { workspaces, hydrateWorkspace } = get();
|
||||
const ws = workspaces.find((item) => item.id === workspaceId);
|
||||
if (!ws) return;
|
||||
|
||||
// Switch identity FIRST — api client, localStorage, and the
|
||||
// workspace object in this store — so that any in-flight refetch
|
||||
// (e.g. triggered by a WS event during the async gap) already
|
||||
// targets the new workspace.
|
||||
api.setWorkspaceId(ws.id);
|
||||
localStorage.setItem("multica_workspace_id", ws.id);
|
||||
|
||||
// Clear ALL stale data across every store before hydrating.
|
||||
useIssueStore.getState().setIssues([]);
|
||||
useInboxStore.getState().setItems([]);
|
||||
useRuntimeStore.getState().setRuntimes([]);
|
||||
set({ workspace: ws, members: [], agents: [], skills: [] });
|
||||
|
||||
await hydrateWorkspace(workspaces, ws.id);
|
||||
},
|
||||
|
||||
refreshWorkspaces: async () => {
|
||||
const { workspace, hydrateWorkspace } = get();
|
||||
const storedWorkspaceId = localStorage.getItem("multica_workspace_id");
|
||||
try {
|
||||
const wsList = await api.listWorkspaces();
|
||||
await hydrateWorkspace(wsList, workspace?.id ?? storedWorkspaceId);
|
||||
return wsList;
|
||||
} catch (e) {
|
||||
logger.error("failed to refresh workspaces", e);
|
||||
toast.error("Failed to refresh workspaces");
|
||||
return get().workspaces;
|
||||
}
|
||||
},
|
||||
|
||||
refreshMembers: async () => {
|
||||
const { workspace } = get();
|
||||
if (!workspace) return;
|
||||
try {
|
||||
const members = await api.listMembers(workspace.id);
|
||||
set({ members });
|
||||
} catch (e) {
|
||||
logger.error("failed to refresh members", e);
|
||||
toast.error("Failed to refresh members");
|
||||
}
|
||||
},
|
||||
|
||||
updateAgent: (id, updates) =>
|
||||
set((s) => ({
|
||||
agents: s.agents.map((a) => (a.id === id ? { ...a, ...updates } : a)),
|
||||
})),
|
||||
|
||||
refreshAgents: async () => {
|
||||
const { workspace } = get();
|
||||
if (!workspace) return;
|
||||
try {
|
||||
const agents = await api.listAgents({ workspace_id: workspace.id, include_archived: true });
|
||||
set({ agents });
|
||||
} catch (e) {
|
||||
logger.error("failed to refresh agents", e);
|
||||
toast.error("Failed to refresh agents");
|
||||
}
|
||||
},
|
||||
|
||||
refreshSkills: async () => {
|
||||
const { workspace, skills: existing } = get();
|
||||
if (!workspace) return;
|
||||
try {
|
||||
const fetched = await api.listSkills();
|
||||
// listSkills doesn't include files — preserve files from existing entries
|
||||
const filesById = new Map(
|
||||
existing.filter((s) => s.files?.length).map((s) => [s.id, s.files]),
|
||||
);
|
||||
const merged = fetched.map((s) => ({
|
||||
...s,
|
||||
files: s.files ?? filesById.get(s.id) ?? [],
|
||||
}));
|
||||
set({ skills: merged });
|
||||
} catch (e) {
|
||||
logger.error("failed to refresh skills", e);
|
||||
toast.error("Failed to refresh skills");
|
||||
}
|
||||
},
|
||||
|
||||
upsertSkill: (skill) => {
|
||||
set((state) => {
|
||||
const idx = state.skills.findIndex((s) => s.id === skill.id);
|
||||
if (idx >= 0) {
|
||||
const next = [...state.skills];
|
||||
next[idx] = skill;
|
||||
return { skills: next };
|
||||
}
|
||||
return { skills: [...state.skills, skill] };
|
||||
});
|
||||
},
|
||||
|
||||
removeSkill: (id) => {
|
||||
set((state) => ({ skills: state.skills.filter((s) => s.id !== id) }));
|
||||
},
|
||||
|
||||
createWorkspace: async (data) => {
|
||||
const ws = await api.createWorkspace(data);
|
||||
set((state) => ({ workspaces: [...state.workspaces, ws] }));
|
||||
return ws;
|
||||
},
|
||||
|
||||
updateWorkspace: (ws) => {
|
||||
set((state) => ({
|
||||
workspace: state.workspace?.id === ws.id ? ws : state.workspace,
|
||||
workspaces: state.workspaces.map((item) =>
|
||||
item.id === ws.id ? ws : item,
|
||||
),
|
||||
}));
|
||||
},
|
||||
|
||||
leaveWorkspace: async (workspaceId) => {
|
||||
await api.leaveWorkspace(workspaceId);
|
||||
const { workspace, hydrateWorkspace } = get();
|
||||
const wsList = await api.listWorkspaces();
|
||||
const preferredWorkspaceId =
|
||||
workspace?.id === workspaceId ? null : (workspace?.id ?? null);
|
||||
await hydrateWorkspace(wsList, preferredWorkspaceId);
|
||||
},
|
||||
|
||||
deleteWorkspace: async (workspaceId) => {
|
||||
await api.deleteWorkspace(workspaceId);
|
||||
const { workspace, hydrateWorkspace } = get();
|
||||
const wsList = await api.listWorkspaces();
|
||||
const preferredWorkspaceId =
|
||||
workspace?.id === workspaceId ? null : (workspace?.id ?? null);
|
||||
await hydrateWorkspace(wsList, preferredWorkspaceId);
|
||||
},
|
||||
|
||||
clearWorkspace: () => {
|
||||
api.setWorkspaceId(null);
|
||||
localStorage.removeItem("multica_workspace_id");
|
||||
set({ workspace: null, workspaces: [], members: [], agents: [], skills: [] });
|
||||
},
|
||||
}));
|
||||
@@ -7,7 +7,25 @@ config({ path: resolve(__dirname, "../../.env") });
|
||||
|
||||
const remoteApiUrl = process.env.REMOTE_API_URL || "http://localhost:8080";
|
||||
|
||||
// Parse hostnames from CORS_ALLOWED_ORIGINS so that Next.js dev server
|
||||
// allows cross-origin HMR / webpack requests (e.g. from Tailscale IPs).
|
||||
const allowedDevOrigins = process.env.CORS_ALLOWED_ORIGINS
|
||||
? process.env.CORS_ALLOWED_ORIGINS.split(",")
|
||||
.map((origin) => {
|
||||
try {
|
||||
return new URL(origin.trim()).host;
|
||||
} catch {
|
||||
return origin.trim();
|
||||
}
|
||||
})
|
||||
.filter(Boolean)
|
||||
: undefined;
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
transpilePackages: ["@multica/core", "@multica/ui", "@multica/views"],
|
||||
...(allowedDevOrigins && allowedDevOrigins.length > 0
|
||||
? { allowedDevOrigins }
|
||||
: {}),
|
||||
images: {
|
||||
formats: ["image/avif", "image/webp"],
|
||||
qualities: [75, 80, 85],
|
||||
|
||||
@@ -12,17 +12,21 @@
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@multica/core": "workspace:*",
|
||||
"@multica/ui": "workspace:*",
|
||||
"@multica/views": "workspace:*",
|
||||
"@base-ui/react": "^1.3.0",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@emoji-mart/data": "^1.2.1",
|
||||
"@floating-ui/dom": "^1.7.6",
|
||||
"@tanstack/react-query": "^5.96.2",
|
||||
"@tanstack/react-query-devtools": "^5.96.2",
|
||||
"@tiptap/extension-code-block-lowlight": "^3.22.1",
|
||||
"@tiptap/extension-image": "^3.22.1",
|
||||
"@tiptap/extension-link": "^3.22.1",
|
||||
"@tiptap/extension-mention": "^3.22.1",
|
||||
"@tiptap/suggestion": "^3.22.1",
|
||||
"@tiptap/extension-placeholder": "^3.22.1",
|
||||
"@tiptap/extension-table": "^3.22.1",
|
||||
"@tiptap/extension-table-cell": "^3.22.1",
|
||||
@@ -33,6 +37,7 @@
|
||||
"@tiptap/pm": "^3.22.1",
|
||||
"@tiptap/react": "^3.22.1",
|
||||
"@tiptap/starter-kit": "^3.22.1",
|
||||
"@tiptap/suggestion": "^3.22.1",
|
||||
"@types/linkify-it": "^5.0.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
|
||||
29
apps/web/platform/api.ts
Normal file
29
apps/web/platform/api.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { ApiClient } from "@multica/core/api/client";
|
||||
import { setApiInstance } from "@multica/core/api";
|
||||
import { createLogger } from "@multica/core/logger";
|
||||
|
||||
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || "";
|
||||
|
||||
export const api = new ApiClient(API_BASE_URL, {
|
||||
logger: createLogger("api"),
|
||||
onUnauthorized: () => {
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.removeItem("multica_token");
|
||||
localStorage.removeItem("multica_workspace_id");
|
||||
if (window.location.pathname !== "/") {
|
||||
window.location.href = "/";
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Register as the global singleton for @multica/core queries/mutations
|
||||
setApiInstance(api);
|
||||
|
||||
// Hydrate from localStorage
|
||||
if (typeof window !== "undefined") {
|
||||
const token = localStorage.getItem("multica_token");
|
||||
if (token) api.setToken(token);
|
||||
const wsId = localStorage.getItem("multica_workspace_id");
|
||||
if (wsId) api.setWorkspaceId(wsId);
|
||||
}
|
||||
16
apps/web/platform/auth.ts
Normal file
16
apps/web/platform/auth.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { createAuthStore, registerAuthStore } from "@multica/core/auth";
|
||||
import { api } from "./api";
|
||||
import { webStorage } from "./storage";
|
||||
import {
|
||||
setLoggedInCookie,
|
||||
clearLoggedInCookie,
|
||||
} from "../features/auth/auth-cookie";
|
||||
|
||||
export const useAuthStore = createAuthStore({
|
||||
api,
|
||||
storage: webStorage,
|
||||
onLogin: setLoggedInCookie,
|
||||
onLogout: clearLoggedInCookie,
|
||||
});
|
||||
|
||||
registerAuthStore(useAuthStore);
|
||||
3
apps/web/platform/index.ts
Normal file
3
apps/web/platform/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { api } from "./api";
|
||||
export { useAuthStore } from "./auth";
|
||||
export { useWorkspaceStore } from "./workspace";
|
||||
40
apps/web/platform/navigation.tsx
Normal file
40
apps/web/platform/navigation.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
"use client";
|
||||
|
||||
import { Suspense } from "react";
|
||||
import { useRouter, usePathname, useSearchParams } from "next/navigation";
|
||||
import {
|
||||
NavigationProvider,
|
||||
type NavigationAdapter,
|
||||
} from "@multica/views/navigation";
|
||||
|
||||
function NavigationProviderInner({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const adapter: NavigationAdapter = {
|
||||
push: router.push,
|
||||
replace: router.replace,
|
||||
back: router.back,
|
||||
pathname,
|
||||
searchParams: new URLSearchParams(searchParams.toString()),
|
||||
};
|
||||
|
||||
return <NavigationProvider value={adapter}>{children}</NavigationProvider>;
|
||||
}
|
||||
|
||||
export function WebNavigationProvider({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Suspense>
|
||||
<NavigationProviderInner>{children}</NavigationProviderInner>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
16
apps/web/platform/storage.ts
Normal file
16
apps/web/platform/storage.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { StorageAdapter } from "@multica/core/types/storage";
|
||||
|
||||
/**
|
||||
* SSR-safe localStorage wrapper.
|
||||
* Returns null / no-ops when running on the server (typeof window === "undefined").
|
||||
*/
|
||||
export const webStorage: StorageAdapter = {
|
||||
getItem: (k) =>
|
||||
typeof window !== "undefined" ? localStorage.getItem(k) : null,
|
||||
setItem: (k, v) => {
|
||||
if (typeof window !== "undefined") localStorage.setItem(k, v);
|
||||
},
|
||||
removeItem: (k) => {
|
||||
if (typeof window !== "undefined") localStorage.removeItem(k);
|
||||
},
|
||||
};
|
||||
11
apps/web/platform/workspace.ts
Normal file
11
apps/web/platform/workspace.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { createWorkspaceStore, registerWorkspaceStore } from "@multica/core/workspace";
|
||||
import { toast } from "sonner";
|
||||
import { api } from "./api";
|
||||
import { webStorage } from "./storage";
|
||||
|
||||
export const useWorkspaceStore = createWorkspaceStore(api, {
|
||||
storage: webStorage,
|
||||
onError: (msg) => toast.error(msg),
|
||||
});
|
||||
|
||||
registerWorkspaceStore(useWorkspaceStore);
|
||||
26
apps/web/platform/ws-provider.tsx
Normal file
26
apps/web/platform/ws-provider.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import { WSProvider } from "@multica/core/realtime";
|
||||
import { useAuthStore } from "./auth";
|
||||
import { useWorkspaceStore } from "./workspace";
|
||||
import { webStorage } from "./storage";
|
||||
import { toast } from "sonner";
|
||||
|
||||
const WS_URL = process.env.NEXT_PUBLIC_WS_URL || "ws://localhost:8080/ws";
|
||||
|
||||
export function WebWSProvider({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<WSProvider
|
||||
wsUrl={WS_URL}
|
||||
authStore={useAuthStore}
|
||||
workspaceStore={useWorkspaceStore}
|
||||
storage={webStorage}
|
||||
onToast={(message, type) => {
|
||||
if (type === "error") toast.error(message);
|
||||
else toast.info(message);
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</WSProvider>
|
||||
);
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import { createLogger } from "@/shared/logger";
|
||||
import { ApiClient } from "./client";
|
||||
|
||||
export { ApiClient } from "./client";
|
||||
export type { LoginResponse } from "./client";
|
||||
export { WSClient } from "./ws-client";
|
||||
|
||||
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || "";
|
||||
|
||||
export const api = new ApiClient(API_BASE_URL, { logger: createLogger("api") });
|
||||
|
||||
// Initialize token from localStorage on load
|
||||
if (typeof window !== "undefined") {
|
||||
const token = localStorage.getItem("multica_token");
|
||||
if (token) {
|
||||
api.setToken(token);
|
||||
}
|
||||
const wsId = localStorage.getItem("multica_workspace_id");
|
||||
if (wsId) {
|
||||
api.setWorkspaceId(wsId);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from "react";
|
||||
import { vi } from "vitest";
|
||||
import { render, type RenderOptions } from "@testing-library/react";
|
||||
import type { User, Workspace, MemberWithUser, Agent } from "@/shared/types";
|
||||
import type { User, Workspace, MemberWithUser, Agent } from "@multica/core/types";
|
||||
|
||||
// Mock user
|
||||
export const mockUser: User = {
|
||||
@@ -58,8 +58,6 @@ export const mockAgents: Agent[] = [
|
||||
max_concurrent_tasks: 3,
|
||||
owner_id: null,
|
||||
skills: [],
|
||||
tools: [],
|
||||
triggers: [],
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-01T00:00:00Z",
|
||||
archived_at: null,
|
||||
@@ -85,8 +83,6 @@ export const mockAuthValue: Record<string, any> = {
|
||||
leaveWorkspace: vi.fn(),
|
||||
deleteWorkspace: vi.fn(),
|
||||
refreshWorkspaces: vi.fn(),
|
||||
refreshMembers: vi.fn(),
|
||||
refreshAgents: vi.fn(),
|
||||
getMemberName: (userId: string) => {
|
||||
const m = mockMembers.find((m) => m.user_id === userId);
|
||||
return m?.name ?? "Unknown";
|
||||
|
||||
@@ -13,6 +13,7 @@ export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "."),
|
||||
"@core": path.resolve(__dirname, "core"),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
421
docs/design.md
Normal file
421
docs/design.md
Normal file
@@ -0,0 +1,421 @@
|
||||
# Multica Design System
|
||||
|
||||
本文档定义 Multica 的视觉语言和交互规范。所有 UI 开发以此为准。
|
||||
|
||||
---
|
||||
|
||||
## 1. 设计哲学
|
||||
|
||||
三条核心原则:
|
||||
|
||||
1. **克制即高级。** 默认做减法。每个元素必须有存在的理由——多余的分割线、装饰性图标、"以防万一"的提示文字,都是噪音。留白本身就是设计。
|
||||
2. **层次靠灰度,颜色是信号。** 界面的主体是中性色。颜色只在需要传递语义时出现(状态、品牌、错误)。如果两个区域在视觉上竞争注意力,解法是让一个退后,而不是两个都加色。
|
||||
3. **一致性大于个性。** 同类交互必须有相同的视觉反馈。一个 hover 效果在 sidebar、dropdown、table row 里应该"感觉一样"。这种一致性通过 token 而非硬编码实现。
|
||||
|
||||
---
|
||||
|
||||
## 2. 颜色体系
|
||||
|
||||
基于 OKLCh 色彩空间,通过 CSS 变量定义。所有颜色使用 shadcn token,**禁止硬编码 Tailwind 色值**(如 `text-gray-500`、`bg-blue-600`)。
|
||||
|
||||
### 2.1 中性色阶梯
|
||||
|
||||
界面 90% 的面积由中性色构成。灰度等级即信息层级:
|
||||
|
||||
| 角色 | Light Token | Dark Token | 用途 |
|
||||
|------|-------------|------------|------|
|
||||
| 背景 | `background` | `background` | 页面底色 |
|
||||
| 卡片/浮层 | `card` / `popover` | `card` / `popover` | 容器表面 |
|
||||
| 次级表面 | `muted` / `secondary` | `muted` / `secondary` | hover 背景、标签底色 |
|
||||
| 边框 | `border` | `border` | 分隔线、输入框边框 |
|
||||
| 输入框边框 | `input` | `input` | 比 border 略重 |
|
||||
| 主要文字 | `foreground` | `foreground` | 标题、正文 |
|
||||
| 次要文字 | `muted-foreground` | `muted-foreground` | 描述、元数据、placeholder |
|
||||
| 最强调文字 | `primary` | `primary` | 按钮文字(反色)、关键标签 |
|
||||
|
||||
**规则:** 同一屏幕内,文字颜色最多使用 3 个层级(`foreground` / `muted-foreground` / 某个语义色)。超过 3 级说明层次设计有问题。
|
||||
|
||||
### 2.2 语义色
|
||||
|
||||
颜色只用于传递含义,不做装饰:
|
||||
|
||||
| Token | 含义 | 使用场景 |
|
||||
|-------|------|----------|
|
||||
| `brand` | 品牌标识 | Logo、品牌按钮、极少量强调 |
|
||||
| `destructive` | 危险/错误 | 删除按钮、表单校验错误、危险操作 |
|
||||
| `success` | 成功 | 状态标签(完成、已解决) |
|
||||
| `warning` | 警告 | 注意状态、到期提醒 |
|
||||
| `info` | 信息 | 提示、链接、次要信息标记 |
|
||||
| `priority` | 优先级 | 高优先级标签 |
|
||||
|
||||
**规则:**
|
||||
- 语义色主要用于小面积元素(badge、icon、border)。大面积着色用该色的 10%-20% 透明度变体(如 `bg-destructive/10`)。
|
||||
- 每屏同时出现的语义色不宜超过 2-3 种。如果一个界面同时有红黄绿蓝紫,说明信息密度过高,需要重新组织。
|
||||
|
||||
### 2.3 暗色模式
|
||||
|
||||
暗色模式不是简单的反转。它是独立设计的一套配色:
|
||||
|
||||
- 背景使用深灰(`oklch(0.18 ...)`),不是纯黑——纯黑在 LCD 屏上刺眼。
|
||||
- 边框使用 `oklch(1 0 0 / 10%)`(白色 10% 透明度),比 light 模式更微妙。
|
||||
- 语义色在 dark 模式下适当提亮(如 `success` 从 `0.55` 提到 `0.65`),保证对比度。
|
||||
- 所有 UI 变更必须同时在两个模式下验证。
|
||||
|
||||
---
|
||||
|
||||
## 3. 字体规范
|
||||
|
||||
### 3.1 字体家族
|
||||
|
||||
| 角色 | 字体 | 用途 |
|
||||
|------|------|------|
|
||||
| 正文/UI | Geist Sans (`--font-sans`) | 所有界面文字的默认字体 |
|
||||
| 代码/数据 | Geist Mono (`--font-mono`) | 代码块、ID、时间戳、等宽数据 |
|
||||
| 标题 | `--font-heading`(= `--font-sans`) | 页面标题、区块标题 |
|
||||
|
||||
### 3.2 字号纪律
|
||||
|
||||
**整个项目只使用 3 个核心字号 + 1 个特殊字号:**
|
||||
|
||||
| Tailwind Class | 大小 | 角色 | 使用场景 |
|
||||
|----------------|------|------|----------|
|
||||
| `text-base` (16px) | 正文 | 页面标题、主要内容 | 页面标题、编辑器正文、空状态说明 |
|
||||
| `text-sm` (14px) | 默认 | 界面的主力字号 | 菜单项、按钮、表单、列表项、正文 |
|
||||
| `text-xs` (12px) | 辅助 | 元数据、标签 | badge 文字、时间戳、状态栏、次要信息 |
|
||||
| `text-[0.8rem]` | 过渡 | 仅限 sm 按钮 | shadcn button size="sm" 专用 |
|
||||
|
||||
**禁止:**
|
||||
- 使用 `text-lg`、`text-xl`、`text-2xl` 等——任务管理工具追求信息密度,不需要大字号。
|
||||
- 使用任意像素值如 `text-[11px]`、`text-[13px]`——坚持 Tailwind 内置 scale。
|
||||
- 在同一个区块里混用超过 2 个字号。如果需要第 3 个字号来区分层次,先试试用 `font-medium` vs `font-normal` 或 `text-muted-foreground` 来解决。
|
||||
|
||||
### 3.3 字重
|
||||
|
||||
只使用两个:
|
||||
|
||||
| 字重 | 用途 |
|
||||
|------|------|
|
||||
| `font-normal` (400) | 正文、描述、大部分文字 |
|
||||
| `font-medium` (500) | 标签、按钮、导航项、标题、选中状态 |
|
||||
|
||||
**禁止** `font-bold` / `font-semibold`——它们在 Geist 字体下显得突兀,破坏界面的"轻"感。如果需要更强的强调,用更大的字号或 `foreground` 色值,而不是加粗。
|
||||
|
||||
---
|
||||
|
||||
## 4. 间距体系
|
||||
|
||||
基于 Tailwind 的 4px 基础网格。间距传递信息——它不只是"好看",而是告诉用户"什么属于什么"。
|
||||
|
||||
### 4.1 间距语义
|
||||
|
||||
| 间距 | Tailwind | 含义 |
|
||||
|------|----------|------|
|
||||
| 4px | `gap-1` / `p-1` | **紧密关联** — icon 与文字、label 与值 |
|
||||
| 6px | `gap-1.5` / `p-1.5` | **组件内部** — 按钮内部 padding、列表项间距 |
|
||||
| 8px | `gap-2` / `p-2` | **同组不同项** — 表单字段间、列表项间 |
|
||||
| 12px | `gap-3` / `p-3` | **小节内** — 卡片内部 padding |
|
||||
| 16px | `gap-4` / `p-4` | **组间分隔** — 不同区块之间 |
|
||||
| 24px | `gap-6` / `p-6` | **大节分隔** — 页面主要区域间 |
|
||||
|
||||
**规则:如果需要分割线,说明间距不够。** 优先通过增大间距来分隔内容,而不是加 `<Separator />`。分割线应该是最后手段。
|
||||
|
||||
### 4.2 容器策略(按优先级排序)
|
||||
|
||||
当需要在视觉上分隔两个区域时:
|
||||
|
||||
1. **仅间距** — 增大两个区域的间距(首选)
|
||||
2. **单条分割线** — 一根细线 `border-border`
|
||||
3. **背景色变化** — 一个区域用 `bg-muted` 或 `bg-card`
|
||||
4. **完整卡片** — border + radius + padding(最重手段)
|
||||
|
||||
用最轻的工具完成分隔。
|
||||
|
||||
---
|
||||
|
||||
## 5. 交互状态
|
||||
|
||||
这是设计一致性的核心。每种状态必须在所有组件中表现一致。
|
||||
|
||||
### 5.1 状态层级概览
|
||||
|
||||
```
|
||||
默认 (rest) → hover → active/pressed → selected/active → focused → disabled
|
||||
```
|
||||
|
||||
### 5.2 Hover 状态
|
||||
|
||||
Hover 是"我注意到你了",视觉变化应该轻微、即时:
|
||||
|
||||
| 元素类型 | Hover 效果 | Token |
|
||||
|----------|-----------|-------|
|
||||
| 列表项/菜单项 | 背景变浅灰 | `hover:bg-muted` |
|
||||
| Ghost 按钮 | 背景变浅灰 + 文字变前景色 | `hover:bg-muted hover:text-foreground` |
|
||||
| 次要按钮 | 背景加深 20% | `hover:bg-secondary/80` |
|
||||
| 主按钮 | 背景加深 20% | `hover:bg-primary/80` |
|
||||
| 文字链接 | 下划线出现 | `hover:underline` |
|
||||
| Tab 标签 | 文字从次要变主要 | `hover:text-foreground`(从 `text-muted-foreground`) |
|
||||
| 图标按钮 | 背景变浅灰 | `hover:bg-muted` |
|
||||
| 危险按钮 | 背景透明度加深 | `hover:bg-destructive/20` |
|
||||
|
||||
**规则:**
|
||||
- hover 时不改变尺寸(无 `scale`)、不加阴影(无 `shadow`)。
|
||||
- hover 的背景色永远比 selected/active 更淡。这样用户能区分"悬停"和"已选中"。
|
||||
- 所有 hover 使用 `transition-colors` 或 `transition-all`,时长由 Tailwind 默认值(150ms)处理,不需要自定义。
|
||||
|
||||
### 5.3 Active / Selected 状态
|
||||
|
||||
Active 是"我已经被选中了",视觉比 hover 更重:
|
||||
|
||||
| 元素类型 | Active 效果 | Token |
|
||||
|----------|------------|-------|
|
||||
| Sidebar 菜单项 | 背景 + 文字加重 + font-medium | `data-active:bg-sidebar-accent data-active:font-medium` |
|
||||
| Tab | 下方指示条 + 文字变前景色 + font-medium | `data-[state=active]:text-foreground` |
|
||||
| 列表选中行 | 背景加深 | `bg-muted` 或 `bg-accent` |
|
||||
| Toggle(开) | 背景反色 | `data-[state=on]:bg-primary data-[state=on]:text-primary-foreground` |
|
||||
|
||||
**关键区分:** Hover = `bg-muted`,Active = `bg-muted` + `font-medium` + `text-foreground`。Active 始终比 hover 多一个视觉维度(字重或颜色变化),而不仅仅是背景更深。
|
||||
|
||||
### 5.3.1 Active 不被 Hover 覆盖
|
||||
|
||||
这是最容易出 bug 的地方:用户 hover 到一个已选中的项目上,hover 样式覆盖了 active 样式,导致选中态"闪回"普通 hover 态,视觉上像取消了选中。
|
||||
|
||||
**原则:Active 状态在任何时候都必须保持可辨识——包括被 hover 时。**
|
||||
|
||||
实现方式:
|
||||
|
||||
**方式一:Active 使用 hover 不涉及的维度**
|
||||
|
||||
如果 hover 只改背景,那 active 用字重 + 文字颜色来区分。即使 hover 背景叠上去,字重和颜色不变,用户仍能识别"这个是选中的":
|
||||
|
||||
```
|
||||
// ✅ hover 只管背景,active 靠字重和颜色
|
||||
hover:bg-muted // hover:浅灰背景
|
||||
data-active:font-medium data-active:text-foreground // active:字重+颜色(hover 不会覆盖)
|
||||
```
|
||||
|
||||
**方式二:Active + Hover 组合样式**
|
||||
|
||||
当 active 也用了背景色时,需要显式定义 "active 且 hover" 的复合状态,确保 hover 不会把 active 的背景拉回低层级:
|
||||
|
||||
```tsx
|
||||
// ✅ 显式处理 active+hover 复合态
|
||||
cn(
|
||||
"hover:bg-muted/50", // 普通 hover
|
||||
"data-active:bg-muted data-active:text-foreground", // active
|
||||
"data-active:hover:bg-muted" // active+hover:保持 active 背景,不降级
|
||||
)
|
||||
```
|
||||
|
||||
```tsx
|
||||
// ❌ 反例:hover 覆盖 active
|
||||
cn(
|
||||
"hover:bg-muted/50", // hover 背景比 active 更淡
|
||||
"data-active:bg-muted", // active 背景
|
||||
// 没有处理复合态 → hover 到 active 项时背景从 muted 闪回 muted/50
|
||||
)
|
||||
```
|
||||
|
||||
**方式三:CSS 选择器优先级**
|
||||
|
||||
利用 `:not()` 让 hover 只作用于非 active 的元素:
|
||||
|
||||
```
|
||||
// ✅ hover 不作用于 active 项
|
||||
[data-active]:bg-muted [data-active]:text-foreground
|
||||
not-data-active:hover:bg-muted/50
|
||||
```
|
||||
|
||||
**检查方法:** 写完任何带 hover + active 状态的组件后,必须手动验证——先点击选中一项,然后鼠标移到该项上再移开,确认视觉不会"闪烁"或"降级"。
|
||||
|
||||
### 5.4 Pressed 状态
|
||||
|
||||
物理反馈感——按下按钮时有微小的位移:
|
||||
|
||||
```
|
||||
active:not-aria-[haspopup]:translate-y-px
|
||||
```
|
||||
|
||||
这个 1px 的下移在 shadcn button 上已全局配置。对于触发弹出菜单的按钮不添加(因为弹出即松开,位移会闪烁)。
|
||||
|
||||
### 5.5 Focus 状态
|
||||
|
||||
Focus 为键盘导航服务。所有可交互元素统一使用:
|
||||
|
||||
```
|
||||
focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50
|
||||
```
|
||||
|
||||
- 使用 `focus-visible`(非 `focus`),避免鼠标点击时出现 focus ring。
|
||||
- ring 颜色使用 `ring` token(中灰),不跟组件颜色走——保持全局一致。
|
||||
|
||||
### 5.6 Disabled 状态
|
||||
|
||||
```
|
||||
disabled:pointer-events-none disabled:opacity-50
|
||||
```
|
||||
|
||||
简单统一。不需要为每个组件定制 disabled 样式。
|
||||
|
||||
### 5.7 Error / Invalid 状态
|
||||
|
||||
```
|
||||
aria-invalid:border-destructive aria-invalid:ring-destructive/20
|
||||
```
|
||||
|
||||
- 使用 `aria-invalid` 属性触发,与表单校验库自然对接。
|
||||
- 只改变边框和 ring,不改背景。错误信息用内联文字展示,不用 toast 或 alert banner。
|
||||
|
||||
---
|
||||
|
||||
## 6. 图标规范
|
||||
|
||||
### 6.1 图标库
|
||||
|
||||
统一使用 **Lucide React**(`lucide-react`)。
|
||||
|
||||
禁止混用其他图标库(Heroicons、Phosphor 等),也禁止自制 SVG 图标(除非 Lucide 确实没有合适的)。
|
||||
|
||||
### 6.2 图标尺寸
|
||||
|
||||
图标尺寸与组件尺寸绑定:
|
||||
|
||||
| 组件尺寸 | 图标尺寸 | 示例 |
|
||||
|----------|---------|------|
|
||||
| xs(h-6) | `size-3` (12px) | 紧凑按钮、badge 内图标 |
|
||||
| sm(h-7) | `size-3.5` (14px) | 小按钮、紧凑列表 |
|
||||
| default(h-8) | `size-4` (16px) | 标准按钮、菜单项、表格操作 |
|
||||
| lg(h-9) | `size-4` (16px) | 大按钮(图标不需要更大) |
|
||||
|
||||
**规则:**
|
||||
- 独立装饰性图标(如空状态插图)最大 `size-8` (32px)。
|
||||
- 所有图标默认继承父元素文字颜色。需要弱化时用 `text-muted-foreground`。
|
||||
- 图标与文字的间距:`gap-1`(xs)/ `gap-1.5`(sm/default)/ `gap-2`(宽松排列)。
|
||||
|
||||
### 6.3 图标颜色
|
||||
|
||||
- **导航/操作图标:** `text-muted-foreground`,hover 时跟随文字变为 `text-foreground`
|
||||
- **状态图标:** 使用对应语义色(如 `text-success`、`text-destructive`)
|
||||
- **Active 状态图标:** `text-foreground`
|
||||
|
||||
---
|
||||
|
||||
## 7. 圆角规范
|
||||
|
||||
基于 `--radius: 0.625rem`(10px)的动态 scale:
|
||||
|
||||
| Token | 值 | 用途 |
|
||||
|-------|-----|------|
|
||||
| `rounded-sm` | 6px | Checkbox、小标签 |
|
||||
| `rounded-md` | 8px | 输入框、小按钮、dropdown item |
|
||||
| `rounded-lg` | 10px | 标准按钮、卡片、dialog |
|
||||
| `rounded-xl` | 14px | 大卡片、sheet |
|
||||
| `rounded-full` | 999px | 头像、pill badge |
|
||||
|
||||
**禁止** 硬编码像素值如 `rounded-[6px]`(除非 shadcn 组件内部需要响应式计算如 `rounded-[min(var(--radius-md),12px)]`)。
|
||||
|
||||
---
|
||||
|
||||
## 8. 动效规范
|
||||
|
||||
### 8.1 原则
|
||||
|
||||
- **快速、克制。** 动效是为了帮助用户理解变化,不是展示技术。
|
||||
- **淡入淡出优先。** 元素出现/消失优先用 opacity 过渡,而不是滑动。
|
||||
- **无弹跳。** 不使用 spring / bounce 缓动。缓动曲线统一用 `ease-out`。
|
||||
|
||||
### 8.2 时长
|
||||
|
||||
| 场景 | 时长 | 示例 |
|
||||
|------|------|------|
|
||||
| 颜色/透明度变化 | 150ms | hover 背景变化、文字颜色变化 |
|
||||
| 展开/收起 | 200ms | accordion、collapsible |
|
||||
| 弹层出入 | 150-200ms | dialog、dropdown、popover |
|
||||
| 页面切换 | 无动效 | 路由跳转无过渡动画 |
|
||||
|
||||
### 8.3 使用的 transition
|
||||
|
||||
| Tailwind Class | 用途 |
|
||||
|----------------|------|
|
||||
| `transition-colors` | 纯颜色变化(hover、active)— 首选 |
|
||||
| `transition-all` | 多属性同时变化 |
|
||||
| `transition-opacity` | 元素淡入淡出 |
|
||||
| `transition-transform` | 位移动画(pressed 效果) |
|
||||
|
||||
---
|
||||
|
||||
## 9. 组件使用规范
|
||||
|
||||
### 9.1 shadcn 优先
|
||||
|
||||
所有 UI 组件优先使用已安装的 shadcn 组件(55 个可用)。新增 UI 需求时:
|
||||
|
||||
1. 先查 shadcn 是否有对应组件 → `npx shadcn add <component>`
|
||||
2. 需要变体 → 用 CVA 在现有组件上扩展
|
||||
3. 确实没有 → 自建组件,但必须遵循本规范的 token / 交互状态
|
||||
|
||||
### 9.2 按钮层级
|
||||
|
||||
从最强调到最弱:
|
||||
|
||||
| 变体 | 视觉重量 | 使用场景 |
|
||||
|------|---------|----------|
|
||||
| `default`(primary) | ██████ | 页面主操作(每屏最多 1 个) |
|
||||
| `outline` | ████░░ | 次要操作 |
|
||||
| `secondary` | ███░░░ | 辅助操作、工具栏 |
|
||||
| `ghost` | █░░░░░ | 图标按钮、内联操作、紧凑工具栏 |
|
||||
| `destructive` | ████░░ | 删除、危险操作(红色调) |
|
||||
| `link` | █░░░░░ | 内联文字链接 |
|
||||
|
||||
**规则:** 一个视图里的 primary 按钮最多 1 个。其他都用更弱的变体。如果有多个同等重要的操作,全部用 `outline` 或 `secondary`。
|
||||
|
||||
### 9.3 Dropdown / Popover
|
||||
|
||||
- 内容宽度使用 `w-auto`,**禁止** 固定宽度如 `w-52`、`w-56`(会导致文字换行)。
|
||||
- 菜单项统一 `text-sm`,图标 `size-4`。
|
||||
- 选中项通过 checkmark 图标或左侧指示条标记,不改变背景色。
|
||||
- 危险操作项使用 `text-destructive`,放在最底部,上方用分割线隔开。
|
||||
|
||||
### 9.4 表单输入
|
||||
|
||||
- 输入框统一使用 `border-input` 边框,focus 时 `border-ring` + ring。
|
||||
- Label 使用 `text-sm font-medium`。
|
||||
- 描述/帮助文字使用 `text-xs text-muted-foreground`。
|
||||
- 错误信息使用 `text-xs text-destructive`,放在输入框正下方。
|
||||
|
||||
---
|
||||
|
||||
## 10. 反模式清单
|
||||
|
||||
以下做法**禁止**出现在代码中:
|
||||
|
||||
| 禁止 | 原因 | 替代 |
|
||||
|------|------|------|
|
||||
| 硬编码颜色 `text-red-500`、`bg-gray-100` | 破坏主题一致性 | 使用 token:`text-destructive`、`bg-muted` |
|
||||
| 任意像素 `text-[11px]`、`w-[137px]` | 脱离设计系统 | 使用 Tailwind 内置 scale |
|
||||
| `font-bold` / `font-semibold` | 过重,破坏轻感 | `font-medium` + `text-foreground` |
|
||||
| `text-lg` / `text-xl` / `text-2xl` | 信息密度型工具不需要大字 | `text-base` 已是最大 |
|
||||
| `shadow-sm` / `shadow-md` / `shadow-lg` | 拟物风格,与扁平设计冲突 | 使用 `border` 分隔层级 |
|
||||
| hover 时 `scale-105` | 突兀,与克制风格冲突 | `hover:bg-muted` |
|
||||
| 多色 gradient 背景 | 装饰性,分散注意力 | 纯色 token |
|
||||
| Skeleton loading | 与简洁风格不匹配 | Spinner(`Loader2Icon animate-spin`)或内联 loading 文字 |
|
||||
| Toast 做操作确认 | 转瞬即逝,用户容易错过 | 内联状态文字或 Sonner 仅用于错误/重要提示 |
|
||||
| 固定宽度 dropdown `w-52` | 文字换行不可控 | `w-auto` |
|
||||
| 纯黑背景 `#000` / `oklch(0 0 0)` | LCD 上刺眼 | Dark 模式用深灰 `background` token |
|
||||
|
||||
---
|
||||
|
||||
## 11. 检查清单
|
||||
|
||||
在提交任何 UI 变更前,过一遍:
|
||||
|
||||
- [ ] 所有颜色是否使用 token?有没有硬编码?
|
||||
- [ ] 字号是否只在 `text-xs` / `text-sm` / `text-base` 范围内?
|
||||
- [ ] 字重是否只用了 `font-normal` 和 `font-medium`?
|
||||
- [ ] Hover 状态是否比 active 状态更淡?
|
||||
- [ ] Active 项被 hover 时,active 样式是否仍然可辨识(不被 hover 覆盖)?
|
||||
- [ ] 图标尺寸是否与组件尺寸匹配?
|
||||
- [ ] 间距是否使用 Tailwind 内置 scale(无任意值)?
|
||||
- [ ] Dark 模式下是否正常?
|
||||
- [ ] 有没有不必要的分割线(可以用间距替代)?
|
||||
- [ ] Dropdown / Popover 是否 `w-auto`?
|
||||
- [ ] 一个视图里 primary 按钮是否不超过 1 个?
|
||||
1772
docs/plans/2026-04-07-tanstack-query-migration.md
Normal file
1772
docs/plans/2026-04-07-tanstack-query-migration.md
Normal file
File diff suppressed because it is too large
Load Diff
511
docs/plans/2026-04-08-board-dnd-rewrite.md
Normal file
511
docs/plans/2026-04-08-board-dnd-rewrite.md
Normal file
@@ -0,0 +1,511 @@
|
||||
# Board DnD Rewrite — dnd-kit Multi-Container Sortable
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Rewrite the Kanban board drag-and-drop to use dnd-kit's multi-container sortable pattern correctly — onDragOver for live cross-column movement, local state during drag, insertion indicators, and smooth animations.
|
||||
|
||||
**Architecture:** Replace the current "TQ-cache-driven + pendingMove patch" with a "local-state-driven during drag, TQ sync on drop" model. During drag, a local `columns` state (Record<IssueStatus, string[]>) controls which IDs each SortableContext sees. onDragOver moves IDs between columns in real-time. onDragEnd computes final position and fires the mutation. Between drags, local state follows TQ data via useEffect.
|
||||
|
||||
**Tech Stack:** @dnd-kit/core ^6.3.1, @dnd-kit/sortable ^10.0.0, @dnd-kit/utilities ^3.2.2, TanStack Query, React useState
|
||||
|
||||
---
|
||||
|
||||
## Current State (files to modify)
|
||||
|
||||
| File | Current Role | Change |
|
||||
|------|-------------|--------|
|
||||
| `features/issues/components/board-view.tsx` | DndContext + onDragEnd only + pendingMove | **Rewrite**: local columns state, onDragOver, onDragEnd, improved DragOverlay |
|
||||
| `features/issues/components/board-column.tsx` | Receives Issue[], sorts internally, useDroppable | **Rewrite**: receives sorted Issue[] from parent, no internal sorting, insertion indicator |
|
||||
| `features/issues/components/board-card.tsx` | useSortable with defaults | **Modify**: custom animateLayoutChanges |
|
||||
| `features/issues/components/issues-page.tsx` | handleMoveIssue callback | **Minor**: adjust callback signature |
|
||||
|
||||
Files NOT changed: `mutations.ts`, `ws-updaters.ts`, `use-realtime-sync.ts`, `view-store.ts`, `sort.ts`
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Rewrite board-view.tsx — Local State + onDragOver + onDragEnd
|
||||
|
||||
**Files:**
|
||||
- Rewrite: `apps/web/features/issues/components/board-view.tsx`
|
||||
|
||||
This is the core task. The entire DnD orchestration logic changes.
|
||||
|
||||
### Data Model
|
||||
|
||||
```typescript
|
||||
// Local state: maps status → ordered array of issue IDs
|
||||
// This is the ONLY source of truth for card positions during drag
|
||||
type Columns = Record<IssueStatus, string[]>;
|
||||
```
|
||||
|
||||
### Step 1: Replace pendingMove with local columns state
|
||||
|
||||
Remove `pendingMove` + `displayIssues` + the clearing useEffect. Replace with:
|
||||
|
||||
```typescript
|
||||
// Build columns from TQ issues + view sort settings
|
||||
function buildColumns(
|
||||
issues: Issue[],
|
||||
visibleStatuses: IssueStatus[],
|
||||
sortBy: SortField,
|
||||
sortDirection: SortDirection,
|
||||
): Columns {
|
||||
const cols: Columns = {} as Columns;
|
||||
for (const status of visibleStatuses) {
|
||||
const sorted = sortIssues(
|
||||
issues.filter((i) => i.status === status),
|
||||
sortBy,
|
||||
sortDirection,
|
||||
);
|
||||
cols[status] = sorted.map((i) => i.id);
|
||||
}
|
||||
return cols;
|
||||
}
|
||||
```
|
||||
|
||||
In the component:
|
||||
|
||||
```typescript
|
||||
const sortBy = useViewStore((s) => s.sortBy);
|
||||
const sortDirection = useViewStore((s) => s.sortDirection);
|
||||
|
||||
// Local columns state — follows TQ between drags, local during drag
|
||||
const [columns, setColumns] = useState<Columns>(() =>
|
||||
buildColumns(issues, visibleStatuses, sortBy, sortDirection)
|
||||
);
|
||||
const isDragging = useRef(false);
|
||||
|
||||
// Sync from TQ when NOT dragging
|
||||
useEffect(() => {
|
||||
if (!isDragging.current) {
|
||||
setColumns(buildColumns(issues, visibleStatuses, sortBy, sortDirection));
|
||||
}
|
||||
}, [issues, visibleStatuses, sortBy, sortDirection]);
|
||||
```
|
||||
|
||||
`issueMap` for O(1) lookup (needed by BoardColumn to get Issue objects from IDs):
|
||||
|
||||
```typescript
|
||||
const issueMap = useMemo(() => {
|
||||
const map = new Map<string, Issue>();
|
||||
for (const issue of issues) map.set(issue.id, issue);
|
||||
return map;
|
||||
}, [issues]);
|
||||
```
|
||||
|
||||
### Step 2: Implement findColumn helper
|
||||
|
||||
```typescript
|
||||
/** Find which column (status) contains a given ID (issue or column). */
|
||||
function findColumn(columns: Columns, id: string, visibleStatuses: IssueStatus[]): IssueStatus | null {
|
||||
// Is it a column ID itself?
|
||||
if (visibleStatuses.includes(id as IssueStatus)) return id as IssueStatus;
|
||||
// Search columns for the item
|
||||
for (const [status, ids] of Object.entries(columns)) {
|
||||
if (ids.includes(id)) return status as IssueStatus;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
```
|
||||
|
||||
### Step 3: Implement onDragStart
|
||||
|
||||
```typescript
|
||||
const handleDragStart = useCallback((event: DragStartEvent) => {
|
||||
isDragging.current = true;
|
||||
const issue = issueMap.get(event.active.id as string) ?? null;
|
||||
setActiveIssue(issue);
|
||||
}, [issueMap]);
|
||||
```
|
||||
|
||||
### Step 4: Implement onDragOver — the key missing piece
|
||||
|
||||
This fires continuously during drag. When the pointer crosses into a different column or hovers over a different card, we move the dragged ID in local state. This makes SortableContext aware of the new item → cards shift to make room.
|
||||
|
||||
```typescript
|
||||
const handleDragOver = useCallback((event: DragOverEvent) => {
|
||||
const { active, over } = event;
|
||||
if (!over) return;
|
||||
|
||||
const activeId = active.id as string;
|
||||
const overId = over.id as string;
|
||||
|
||||
const activeCol = findColumn(columns, activeId, visibleStatuses);
|
||||
const overCol = findColumn(columns, overId, visibleStatuses);
|
||||
if (!activeCol || !overCol || activeCol === overCol) return;
|
||||
|
||||
// Cross-column move: remove from old column, insert into new column
|
||||
setColumns((prev) => {
|
||||
const oldIds = prev[activeCol]!.filter((id) => id !== activeId);
|
||||
const newIds = [...prev[overCol]!];
|
||||
|
||||
// Insert position: if over a card, insert at that index; if over column, append
|
||||
const overIndex = newIds.indexOf(overId);
|
||||
const insertIndex = overIndex >= 0 ? overIndex : newIds.length;
|
||||
newIds.splice(insertIndex, 0, activeId);
|
||||
|
||||
return { ...prev, [activeCol]: oldIds, [overCol]: newIds };
|
||||
});
|
||||
}, [columns, visibleStatuses]);
|
||||
```
|
||||
|
||||
### Step 5: Implement onDragEnd — persist to server
|
||||
|
||||
```typescript
|
||||
const handleDragEnd = useCallback((event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
isDragging.current = false;
|
||||
setActiveIssue(null);
|
||||
|
||||
if (!over) {
|
||||
// Cancelled — reset to TQ state
|
||||
setColumns(buildColumns(issues, visibleStatuses, sortBy, sortDirection));
|
||||
return;
|
||||
}
|
||||
|
||||
const activeId = active.id as string;
|
||||
const overId = over.id as string;
|
||||
|
||||
const activeCol = findColumn(columns, activeId, visibleStatuses);
|
||||
const overCol = findColumn(columns, overId, visibleStatuses);
|
||||
if (!activeCol || !overCol) return;
|
||||
|
||||
// Same column reorder
|
||||
if (activeCol === overCol) {
|
||||
const ids = columns[activeCol]!;
|
||||
const oldIndex = ids.indexOf(activeId);
|
||||
const newIndex = ids.indexOf(overId);
|
||||
if (oldIndex !== newIndex) {
|
||||
const reordered = arrayMove(ids, oldIndex, newIndex);
|
||||
setColumns((prev) => ({ ...prev, [activeCol]: reordered }));
|
||||
}
|
||||
}
|
||||
|
||||
// Compute final position from the local column order
|
||||
const finalCol = findColumn(columns, activeId, visibleStatuses);
|
||||
if (!finalCol) return;
|
||||
|
||||
// After potential same-col reorder, re-read columns
|
||||
// (for same-col we just did setColumns above, but it's async;
|
||||
// however we can compute from the intended final order)
|
||||
let finalIds: string[];
|
||||
if (activeCol === overCol) {
|
||||
const ids = columns[activeCol]!;
|
||||
const oldIndex = ids.indexOf(activeId);
|
||||
const newIndex = ids.indexOf(overId);
|
||||
finalIds = oldIndex !== newIndex ? arrayMove(ids, oldIndex, newIndex) : ids;
|
||||
} else {
|
||||
finalIds = columns[finalCol]!;
|
||||
}
|
||||
|
||||
const newPosition = computePosition(finalIds, activeId, issues);
|
||||
const currentIssue = issueMap.get(activeId);
|
||||
|
||||
// Skip if nothing changed
|
||||
if (currentIssue && currentIssue.status === finalCol && currentIssue.position === newPosition) return;
|
||||
|
||||
onMoveIssue(activeId, finalCol, newPosition);
|
||||
}, [columns, issues, visibleStatuses, sortBy, sortDirection, issueMap, onMoveIssue]);
|
||||
```
|
||||
|
||||
### Step 6: Update computePosition to work with ID arrays
|
||||
|
||||
The current `computePosition` takes `Issue[]` and a target index. Rewrite to take `string[]` (IDs) + the active ID + the issue map:
|
||||
|
||||
```typescript
|
||||
/** Compute a float position for `activeId` based on its neighbors in `ids`. */
|
||||
function computePosition(ids: string[], activeId: string, allIssues: Issue[]): number {
|
||||
const idx = ids.indexOf(activeId);
|
||||
if (idx === -1) return 0;
|
||||
|
||||
const getPos = (id: string) => allIssues.find((i) => i.id === id)?.position ?? 0;
|
||||
|
||||
if (ids.length === 1) return 0;
|
||||
if (idx === 0) return getPos(ids[1]!) - 1;
|
||||
if (idx === ids.length - 1) return getPos(ids[idx - 1]!) + 1;
|
||||
return (getPos(ids[idx - 1]!) + getPos(ids[idx + 1]!)) / 2;
|
||||
}
|
||||
```
|
||||
|
||||
### Step 7: Update DragOverlay styling
|
||||
|
||||
```typescript
|
||||
<DragOverlay dropAnimation={null}>
|
||||
{activeIssue ? (
|
||||
<div className="w-[280px] rotate-2 scale-105 cursor-grabbing opacity-90 shadow-lg shadow-black/10">
|
||||
<BoardCardContent issue={activeIssue} />
|
||||
</div>
|
||||
) : null}
|
||||
</DragOverlay>
|
||||
```
|
||||
|
||||
Key change: `dropAnimation={null}` prevents the overlay from animating back to origin on drop — the card is already in the right position via local state.
|
||||
|
||||
### Step 8: Wire it all together
|
||||
|
||||
Pass `columns` + `issueMap` to `BoardColumn` instead of `issues`:
|
||||
|
||||
```tsx
|
||||
{visibleStatuses.map((status) => (
|
||||
<BoardColumn
|
||||
key={status}
|
||||
status={status}
|
||||
issueIds={columns[status] ?? []}
|
||||
issueMap={issueMap}
|
||||
/>
|
||||
))}
|
||||
```
|
||||
|
||||
### Step 9: Run typecheck
|
||||
|
||||
Run: `pnpm typecheck`
|
||||
Expected: May have errors in board-column.tsx (prop changes) — that's Task 2.
|
||||
|
||||
### Step 10: Commit
|
||||
|
||||
```bash
|
||||
git add apps/web/features/issues/components/board-view.tsx
|
||||
git commit -m "refactor(board): rewrite DnD with local state + onDragOver for live cross-column sorting"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Rewrite board-column.tsx — Receive IDs + issueMap, Add Insertion Indicator
|
||||
|
||||
**Files:**
|
||||
- Rewrite: `apps/web/features/issues/components/board-column.tsx`
|
||||
|
||||
### Step 1: Change props from `issues: Issue[]` to `issueIds: string[]` + `issueMap: Map<string, Issue>`
|
||||
|
||||
The column no longer does its own sorting — the parent provides IDs in the correct order. The column just resolves IDs to Issue objects and renders them.
|
||||
|
||||
```typescript
|
||||
export function BoardColumn({
|
||||
status,
|
||||
issueIds,
|
||||
issueMap,
|
||||
}: {
|
||||
status: IssueStatus;
|
||||
issueIds: string[];
|
||||
issueMap: Map<string, Issue>;
|
||||
}) {
|
||||
const cfg = STATUS_CONFIG[status];
|
||||
const { setNodeRef, isOver } = useDroppable({ id: status });
|
||||
const viewStoreApi = useViewStoreApi();
|
||||
|
||||
// Resolve IDs to Issue objects (IDs are already sorted by parent)
|
||||
const resolvedIssues = useMemo(
|
||||
() => issueIds.flatMap((id) => {
|
||||
const issue = issueMap.get(id);
|
||||
return issue ? [issue] : [];
|
||||
}),
|
||||
[issueIds, issueMap],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={`flex w-[280px] shrink-0 flex-col rounded-xl ${cfg.columnBg} p-2`}>
|
||||
<div className="mb-2 flex items-center justify-between px-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`inline-flex items-center gap-1.5 rounded px-2 py-0.5 text-xs font-semibold ${cfg.badgeBg} ${cfg.badgeText}`}>
|
||||
<StatusIcon status={status} className="h-3 w-3" inheritColor />
|
||||
{cfg.label}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{issueIds.length}
|
||||
</span>
|
||||
</div>
|
||||
{/* Right: add + menu — keep as-is */}
|
||||
<div className="flex items-center gap-1">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button variant="ghost" size="icon-sm" className="rounded-full text-muted-foreground">
|
||||
<MoreHorizontal className="size-3.5" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => viewStoreApi.getState().hideStatus(status)}>
|
||||
<EyeOff className="size-3.5" />
|
||||
Hide column
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="rounded-full text-muted-foreground"
|
||||
onClick={() => useModalStore.getState().open("create-issue", { status })}
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<TooltipContent>Add issue</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
className={`min-h-[200px] flex-1 space-y-2 overflow-y-auto rounded-lg p-1 transition-colors ${
|
||||
isOver ? "bg-accent/60" : ""
|
||||
}`}
|
||||
>
|
||||
<SortableContext items={issueIds} strategy={verticalListSortingStrategy}>
|
||||
{resolvedIssues.map((issue) => (
|
||||
<DraggableBoardCard key={issue.id} issue={issue} />
|
||||
))}
|
||||
</SortableContext>
|
||||
{issueIds.length === 0 && (
|
||||
<p className="py-8 text-center text-xs text-muted-foreground">
|
||||
No issues
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Key changes:
|
||||
- No more `useViewStore` for sort — parent handles sorting
|
||||
- No more internal `sortIssues` call
|
||||
- Uses `issueIds` for SortableContext (already in correct order)
|
||||
- Count shows `issueIds.length` instead of `issues.length`
|
||||
|
||||
### Step 2: Run typecheck
|
||||
|
||||
Run: `pnpm typecheck`
|
||||
Expected: PASS (or errors in issues-page.tsx — Task 4)
|
||||
|
||||
### Step 3: Commit
|
||||
|
||||
```bash
|
||||
git add apps/web/features/issues/components/board-column.tsx
|
||||
git commit -m "refactor(board): BoardColumn receives sorted IDs from parent, no internal sorting"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Modify board-card.tsx — Custom animateLayoutChanges
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/web/features/issues/components/board-card.tsx`
|
||||
|
||||
### Step 1: Add custom animateLayoutChanges
|
||||
|
||||
When a card is dragged across containers, dnd-kit triggers a layout animation on the "entering" card. The default `defaultAnimateLayoutChanges` animates this, causing a jarring jump. We disable animation for the frame when `wasDragging` is true (the card just landed in a new container).
|
||||
|
||||
```typescript
|
||||
import { useSortable, defaultAnimateLayoutChanges } from "@dnd-kit/sortable";
|
||||
import type { AnimateLayoutChanges } from "@dnd-kit/sortable";
|
||||
|
||||
const animateLayoutChanges: AnimateLayoutChanges = (args) => {
|
||||
const { isSorting, wasDragging } = args;
|
||||
if (isSorting || wasDragging) return false;
|
||||
return defaultAnimateLayoutChanges(args);
|
||||
};
|
||||
```
|
||||
|
||||
Update useSortable call:
|
||||
|
||||
```typescript
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({
|
||||
id: issue.id,
|
||||
data: { status: issue.status },
|
||||
animateLayoutChanges,
|
||||
});
|
||||
```
|
||||
|
||||
### Step 2: Run typecheck
|
||||
|
||||
Run: `pnpm typecheck`
|
||||
Expected: PASS
|
||||
|
||||
### Step 3: Commit
|
||||
|
||||
```bash
|
||||
git add apps/web/features/issues/components/board-card.tsx
|
||||
git commit -m "refactor(board): custom animateLayoutChanges to prevent jarring cross-column animation"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Adjust issues-page.tsx — Minor Callback Cleanup
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/web/features/issues/components/issues-page.tsx`
|
||||
|
||||
### Step 1: Update handleMoveIssue
|
||||
|
||||
The callback shape stays the same (`issueId, newStatus, newPosition`), but the auto-switch-to-manual-sort logic should move into board-view or stay here. Keep it here for now since it's a view-level concern.
|
||||
|
||||
No functional change needed — the `onMoveIssue` prop signature is unchanged. Just verify that `BoardView`'s new props are correct:
|
||||
|
||||
```tsx
|
||||
<BoardView
|
||||
issues={issues}
|
||||
allIssues={scopedIssues}
|
||||
visibleStatuses={visibleStatuses}
|
||||
hiddenStatuses={hiddenStatuses}
|
||||
onMoveIssue={handleMoveIssue}
|
||||
/>
|
||||
```
|
||||
|
||||
`BoardView` still receives `issues` (filtered+scoped from TQ) and `onMoveIssue`. The internal state management changes are encapsulated.
|
||||
|
||||
### Step 2: Run full typecheck + test
|
||||
|
||||
Run: `pnpm typecheck && pnpm test`
|
||||
Expected: PASS
|
||||
|
||||
### Step 3: Commit
|
||||
|
||||
```bash
|
||||
git add apps/web/features/issues/components/issues-page.tsx
|
||||
git commit -m "refactor(board): verify issues-page props match new BoardView interface"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Manual QA Checklist
|
||||
|
||||
After all code changes, verify these scenarios in the browser:
|
||||
|
||||
1. **Same-column reorder**: Drag a card up/down within one column → cards shift to make room during drag → drop → position persists after refresh
|
||||
2. **Cross-column move**: Drag card from Todo to In Progress → card appears in target column DURING drag → target column cards shift → drop → status + position persist
|
||||
3. **Drop on empty column**: Drag card to an empty column → card lands there
|
||||
4. **Cancel drag**: Start dragging, press Escape → card returns to original position, no mutation fired
|
||||
5. **Rapid sequential drags**: Drag card A, drop, immediately drag card B → no flicker or stale state
|
||||
6. **WebSocket update during drag**: Have another user change an issue → board updates correctly after drag ends (not during)
|
||||
7. **Sort mode switch**: Drag should auto-switch to "Manual" sort → verify after drag, sort dropdown shows "Manual"
|
||||
8. **DragOverlay**: Dragged card should have visible shadow, slight rotation, slight scale up
|
||||
9. **Hidden columns panel**: Still shows correct counts, "Show column" still works
|
||||
|
||||
---
|
||||
|
||||
## Summary of Architecture Change
|
||||
|
||||
```
|
||||
BEFORE (broken):
|
||||
TQ cache → issues prop → displayIssues (with pendingMove patch) → BoardColumn sorts internally
|
||||
onDragEnd → pendingMove + mutate → TQ updates → useEffect clears pendingMove
|
||||
Problem: dual optimistic update, fire-and-forget cancelQueries race, no onDragOver
|
||||
|
||||
AFTER (correct):
|
||||
TQ cache → issues prop → buildColumns() → local columns state (when not dragging)
|
||||
onDragStart → isDragging=true, freeze local state
|
||||
onDragOver → move IDs between columns in local state → SortableContext sees new items → cards shift
|
||||
onDragEnd → compute position from local order → mutate → isDragging=false → TQ catches up → local follows
|
||||
Problem: none — single source of truth during drag (local), single source of truth between drags (TQ)
|
||||
```
|
||||
227
docs/plans/2026-04-08-drag-upload-enhancement.md
Normal file
227
docs/plans/2026-04-08-drag-upload-enhancement.md
Normal file
@@ -0,0 +1,227 @@
|
||||
# Drag & Drop Upload Enhancement — Revised Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Clean drag-and-drop upload with visual feedback. Images render inline, non-images show as file cards. No file type restrictions (match Linear). No separate attachment section (URLs live in markdown).
|
||||
|
||||
**Architecture:** Frontend-only. Images use existing `` markdown. Non-images use `[name](url)` markdown, rendered as a styled card via Tiptap NodeView when URL matches our CDN. Backend unchanged.
|
||||
|
||||
**Tech Stack:** Tiptap ProseMirror, React, Tailwind CSS, shadcn tokens
|
||||
|
||||
---
|
||||
|
||||
## What We Keep (from previous work)
|
||||
|
||||
- **Drag overlay** — `content-editor.tsx` drag handlers + `content-editor.css` overlay styles
|
||||
- **Image upload flow** — blob preview → upload → replace with real URL (existing `file-upload.ts`)
|
||||
- **Non-image upload placeholder** — `⏳ Uploading filename...` → replaced with link (existing `file-upload.ts`)
|
||||
- **`MAX_FILE_SIZE`** — 100MB limit
|
||||
|
||||
## What We Remove (redundant)
|
||||
|
||||
| File | What to remove |
|
||||
|------|----------------|
|
||||
| `attachment-section.tsx` | **Delete entire file** |
|
||||
| `issue-detail.tsx` | attachment query, delete mutation, handleImageRemoved, AttachmentSection JSX, onImageRemoved prop, all `["attachments"]` cache invalidation, onUploadSuccess on CommentInput, `api` import (if unused after) |
|
||||
| `content-editor.tsx` | `onImageRemoved` prop, `onImageRemovedRef` |
|
||||
| `extensions/index.ts` | `onImageRemovedRef` option |
|
||||
| `extensions/file-upload.ts` | `collectImageSrcs`, `imageRemovalTracker` plugin, `isAllowedFileType` check + import, `toast` import |
|
||||
| `shared/constants/upload.ts` | Everything except `MAX_FILE_SIZE` — remove `ALLOWED_MIME_PATTERNS`, `FILE_INPUT_ACCEPT`, `EXTENSION_MIME_MAP`, `isAllowedFileType`, `matchesMimePattern` |
|
||||
| `shared/constants/__tests__/upload.test.ts` | All tests except MAX_FILE_SIZE |
|
||||
| `shared/hooks/use-file-upload.ts` | `isAllowedFileType` import + check |
|
||||
| `components/common/file-upload-button.tsx` | `FILE_INPUT_ACCEPT` import + `accept` attribute |
|
||||
| `comment-input.tsx` | `onUploadSuccess` prop |
|
||||
|
||||
## What We Add (new)
|
||||
|
||||
**File Card Node** — a Tiptap custom node that renders `[name](url)` as a styled card when the URL matches our CDN (`multica-static.copilothub.ai` or S3 bucket domain).
|
||||
|
||||
```
|
||||
Editor view: ┌──────────────────────────┐
|
||||
│ 📄 report.pdf ⬇ │
|
||||
└──────────────────────────┘
|
||||
|
||||
Markdown storage: [report.pdf](https://multica-static.copilothub.ai/xxx.pdf)
|
||||
```
|
||||
|
||||
- Only for non-image CDN URLs (images stay as ``)
|
||||
- Regular external links (github.com, etc.) stay as normal links
|
||||
- Card shows: file type icon + filename + download button
|
||||
- Readonly mode shows the same card
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Remove Redundant Code
|
||||
|
||||
**Files to modify:**
|
||||
- Delete: `apps/web/features/issues/components/attachment-section.tsx`
|
||||
- Modify: `apps/web/features/issues/components/issue-detail.tsx`
|
||||
- Modify: `apps/web/features/issues/components/comment-input.tsx`
|
||||
- Modify: `apps/web/features/editor/content-editor.tsx`
|
||||
- Modify: `apps/web/features/editor/extensions/index.ts`
|
||||
- Modify: `apps/web/features/editor/extensions/file-upload.ts`
|
||||
- Modify: `apps/web/shared/constants/upload.ts`
|
||||
- Modify: `apps/web/shared/constants/__tests__/upload.test.ts`
|
||||
- Modify: `apps/web/shared/hooks/use-file-upload.ts`
|
||||
- Modify: `apps/web/components/common/file-upload-button.tsx`
|
||||
|
||||
**What to do:**
|
||||
1. Delete `attachment-section.tsx`
|
||||
2. `issue-detail.tsx`: Remove AttachmentSection import, attachment useQuery, deleteAttachment useMutation, handleImageRemoved, onImageRemoved prop, all `["attachments"]` invalidation in handleDescriptionUpload (revert to simple `uploadWithToast` call), remove onUploadSuccess from CommentInput
|
||||
3. `comment-input.tsx`: Remove `onUploadSuccess` prop
|
||||
4. `content-editor.tsx`: Remove `onImageRemoved` prop + ref + wiring
|
||||
5. `extensions/index.ts`: Remove `onImageRemovedRef` from interface + call
|
||||
6. `extensions/file-upload.ts`: Remove `collectImageSrcs`, `imageRemovalTracker` plugin, `onImageRemovedRef` param, `isAllowedFileType` import + check, `toast` import (keep `toast` if still used — check)
|
||||
7. `shared/constants/upload.ts`: Keep only `MAX_FILE_SIZE`. Delete everything else.
|
||||
8. `shared/constants/__tests__/upload.test.ts`: Keep only `MAX_FILE_SIZE` test
|
||||
9. `shared/hooks/use-file-upload.ts`: Remove `isAllowedFileType` import + check. Import `MAX_FILE_SIZE` stays.
|
||||
10. `file-upload-button.tsx`: Remove `FILE_INPUT_ACCEPT` import + `accept` attribute
|
||||
|
||||
**Verification:**
|
||||
```bash
|
||||
pnpm typecheck && pnpm test
|
||||
```
|
||||
|
||||
**Commit:** `refactor(upload): remove attachment section and file type whitelist`
|
||||
|
||||
---
|
||||
|
||||
## Task 2: File Card Tiptap Node
|
||||
|
||||
**Files:**
|
||||
- Create: `apps/web/features/editor/extensions/file-card.ts`
|
||||
- Create: `apps/web/features/editor/extensions/file-card-view.tsx`
|
||||
- Modify: `apps/web/features/editor/extensions/index.ts`
|
||||
- Modify: `apps/web/features/editor/content-editor.css`
|
||||
|
||||
**Design:**
|
||||
|
||||
The node intercepts markdown links `[name](url)` where URL matches our CDN, and renders them as a card NodeView.
|
||||
|
||||
```typescript
|
||||
// Detection: URL starts with CDN domain or known S3 bucket pattern
|
||||
function isCdnFileUrl(url: string): boolean {
|
||||
try {
|
||||
const u = new URL(url);
|
||||
return u.hostname.endsWith('.copilothub.ai') || u.hostname.endsWith('.amazonaws.com');
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Only match non-image files (images stay as )
|
||||
function isFileCardLink(url: string): boolean {
|
||||
return isCdnFileUrl(url) && !isImageUrl(url);
|
||||
}
|
||||
```
|
||||
|
||||
**Node spec:**
|
||||
- Node name: `fileCard`
|
||||
- Attrs: `href`, `filename`
|
||||
- Markdown serialize: `[filename](href)`
|
||||
- Markdown parse: detect `[text](cdnUrl)` where cdnUrl is non-image CDN link
|
||||
- NodeView: React component with file icon + name + download button
|
||||
|
||||
**Card UI (React NodeView):**
|
||||
```tsx
|
||||
<div className="file-card">
|
||||
<FileText className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="truncate text-sm">{filename}</span>
|
||||
<a href={href} download={filename} className="...">
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
</a>
|
||||
</div>
|
||||
```
|
||||
|
||||
**CSS:**
|
||||
```css
|
||||
.file-card {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.375rem 0.75rem;
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: var(--radius);
|
||||
background: hsl(var(--accent) / 0.1);
|
||||
margin: 0.25rem 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
```
|
||||
|
||||
**Verification:**
|
||||
```bash
|
||||
pnpm typecheck && pnpm test
|
||||
```
|
||||
|
||||
Manual:
|
||||
1. Upload a PDF → card appears in editor (not plain link)
|
||||
2. Upload a .go file → card appears
|
||||
3. Upload an image → still renders inline (not as card)
|
||||
4. Paste an external link → still renders as normal link (not card)
|
||||
5. Save and reload → card still displays correctly
|
||||
6. Switch to readonly mode → card still displays
|
||||
|
||||
**Commit:** `feat(editor): render CDN file links as styled cards`
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Update Non-Image Upload to Use File Card
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/web/features/editor/extensions/file-upload.ts`
|
||||
|
||||
Currently the non-image upload path inserts a markdown string `[name](url)`. After Task 2 adds the fileCard node, this should insert a `fileCard` node directly instead:
|
||||
|
||||
```typescript
|
||||
// Instead of:
|
||||
const linkText = `[${result.filename}](${result.link})`;
|
||||
replacePlaceholder(editor, placeholder, linkText);
|
||||
|
||||
// Insert fileCard node:
|
||||
replacePlaceholder(editor, placeholder, "");
|
||||
editor.chain().focus().insertContent({
|
||||
type: "fileCard",
|
||||
attrs: { href: result.link, filename: result.filename },
|
||||
}).run();
|
||||
```
|
||||
|
||||
**Verification:**
|
||||
```bash
|
||||
pnpm typecheck && pnpm test
|
||||
```
|
||||
|
||||
Manual: Upload a PDF → placeholder appears → replaced with file card (not plain text link)
|
||||
|
||||
**Commit:** `feat(upload): insert file card node for non-image uploads`
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Full Verification
|
||||
|
||||
```bash
|
||||
pnpm typecheck && pnpm test
|
||||
```
|
||||
|
||||
Manual test all upload flows:
|
||||
1. Drag image → overlay → drop → inline image with pulse → real image
|
||||
2. Drag PDF → overlay → drop → placeholder → file card
|
||||
3. Drag .mp4 → uploads normally (no type restriction) → file card
|
||||
4. Paste image → inline image
|
||||
5. Click 📎 → file picker shows all types → upload works
|
||||
6. Readonly mode → cards and images display correctly
|
||||
7. Save → reload → everything persists
|
||||
|
||||
**Commit:** fix any issues found
|
||||
|
||||
---
|
||||
|
||||
## Expected Outcome
|
||||
|
||||
| Before (current) | After |
|
||||
|-------------------|-------|
|
||||
| File type whitelist blocks .mp4/.zip/etc | All files accepted (like Linear) |
|
||||
| Attachment Section below description | Gone — files live in markdown |
|
||||
| Non-image files as plain `[name](url)` text | Styled file card with icon + download |
|
||||
| Image removal tracker + attachment cache | Gone — simpler code |
|
||||
| ~300 lines of attachment UI code | Deleted |
|
||||
| ~100 lines of whitelist code | Replaced by 1 line: `MAX_FILE_SIZE` |
|
||||
452
docs/plans/2026-04-08-image-view-enhancement.md
Normal file
452
docs/plans/2026-04-08-image-view-enhancement.md
Normal file
@@ -0,0 +1,452 @@
|
||||
# Image View Enhancement Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Add image hover toolbar (view/download/copy image/copy link/delete), lightbox preview, and smart sizing (centered, max-width capped) — matching Linear's image UX.
|
||||
|
||||
**Architecture:** Convert the Image extension from default `<img>` rendering to a React NodeView (`image-view.tsx`). The NodeView wraps `<img>` in a `<figure>` with a hover toolbar and lightbox portal. CSS handles centering and size cap. No new npm dependencies.
|
||||
|
||||
**Tech Stack:** Tiptap `ReactNodeViewRenderer`, lucide-react, sonner (toast), CSS, `createPortal` for lightbox
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Create Image NodeView Component
|
||||
|
||||
**Files:**
|
||||
- Create: `apps/web/features/editor/extensions/image-view.tsx`
|
||||
|
||||
**Step 1: Create the ImageView component**
|
||||
|
||||
```tsx
|
||||
// apps/web/features/editor/extensions/image-view.tsx
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { NodeViewWrapper } from "@tiptap/react";
|
||||
import type { NodeViewProps } from "@tiptap/react";
|
||||
import {
|
||||
Maximize2,
|
||||
Download,
|
||||
Copy,
|
||||
Link as LinkIcon,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lightbox — full-screen image preview (ESC or click backdrop to close)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function ImageLightbox({
|
||||
src,
|
||||
alt,
|
||||
onClose,
|
||||
}: {
|
||||
src: string;
|
||||
alt: string;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
document.addEventListener("keydown", handler);
|
||||
return () => document.removeEventListener("keydown", handler);
|
||||
}, [onClose]);
|
||||
|
||||
return createPortal(
|
||||
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 cursor-zoom-out"
|
||||
onClick={onClose}
|
||||
>
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
className="max-h-[90vh] max-w-[90vw] rounded-lg object-contain"
|
||||
/>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Image NodeView — renders <img> with hover toolbar + lightbox
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function ImageView({ node, editor, selected, deleteNode }: NodeViewProps) {
|
||||
const src = node.attrs.src as string;
|
||||
const alt = (node.attrs.alt as string) || "";
|
||||
const title = node.attrs.title as string | undefined;
|
||||
const uploading = node.attrs.uploading as boolean;
|
||||
|
||||
const [lightbox, setLightbox] = useState(false);
|
||||
const isEditable = editor.isEditable;
|
||||
|
||||
const handleView = () => setLightbox(true);
|
||||
|
||||
const handleDownload = async () => {
|
||||
try {
|
||||
const res = await fetch(src);
|
||||
const blob = await res.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = alt || "image";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
} catch {
|
||||
window.open(src, "_blank", "noopener,noreferrer");
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyImage = async () => {
|
||||
try {
|
||||
const res = await fetch(src);
|
||||
const blob = await res.blob();
|
||||
await navigator.clipboard.write([
|
||||
new ClipboardItem({ [blob.type]: blob }),
|
||||
]);
|
||||
toast.success("Image copied");
|
||||
} catch {
|
||||
// Fallback: copy link (Safari doesn't support async clipboard image)
|
||||
await navigator.clipboard.writeText(src);
|
||||
toast.success("Link copied");
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyLink = async () => {
|
||||
await navigator.clipboard.writeText(src);
|
||||
toast.success("Link copied");
|
||||
};
|
||||
|
||||
return (
|
||||
<NodeViewWrapper className="image-node">
|
||||
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
|
||||
<figure
|
||||
className={cn(
|
||||
"image-figure",
|
||||
selected && isEditable && "image-selected",
|
||||
)}
|
||||
contentEditable={false}
|
||||
onClick={!isEditable && !uploading ? handleView : undefined}
|
||||
>
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
title={title || undefined}
|
||||
className={cn("image-content", uploading && "image-uploading")}
|
||||
draggable={false}
|
||||
/>
|
||||
{!uploading && (
|
||||
<div
|
||||
className="image-toolbar"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<button type="button" onClick={handleView} title="View image">
|
||||
<Maximize2 className="size-3.5" />
|
||||
</button>
|
||||
<button type="button" onClick={handleDownload} title="Download">
|
||||
<Download className="size-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopyImage}
|
||||
title="Copy image"
|
||||
>
|
||||
<Copy className="size-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopyLink}
|
||||
title="Copy link"
|
||||
>
|
||||
<LinkIcon className="size-3.5" />
|
||||
</button>
|
||||
{isEditable && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => deleteNode()}
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</figure>
|
||||
{lightbox && (
|
||||
<ImageLightbox
|
||||
src={src}
|
||||
alt={alt}
|
||||
onClose={() => setLightbox(false)}
|
||||
/>
|
||||
)}
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export { ImageView };
|
||||
```
|
||||
|
||||
**Step 2: Verify file created**
|
||||
|
||||
Run: `ls apps/web/features/editor/extensions/image-view.tsx`
|
||||
Expected: file exists
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Wire Up NodeView in Image Extension
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/web/features/editor/extensions/index.ts:59-75`
|
||||
|
||||
**Step 1: Add import**
|
||||
|
||||
At the top of `index.ts`, after the existing imports, add:
|
||||
|
||||
```typescript
|
||||
import { ImageView } from "./image-view";
|
||||
```
|
||||
|
||||
**Step 2: Update ImageExtension — add NodeView, remove inline style**
|
||||
|
||||
Replace the `ImageExtension` definition (lines 59-75) with:
|
||||
|
||||
```typescript
|
||||
const ImageExtension = Image.extend({
|
||||
addAttributes() {
|
||||
return {
|
||||
...this.parent?.(),
|
||||
uploading: {
|
||||
default: false,
|
||||
renderHTML: (attrs: Record<string, unknown>) =>
|
||||
attrs.uploading ? { "data-uploading": "" } : {},
|
||||
parseHTML: (el: HTMLElement) => el.hasAttribute("data-uploading"),
|
||||
},
|
||||
};
|
||||
},
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(ImageView);
|
||||
},
|
||||
}).configure({
|
||||
inline: false,
|
||||
allowBase64: false,
|
||||
});
|
||||
```
|
||||
|
||||
Key changes:
|
||||
- Added `addNodeView()` — images now render via React component
|
||||
- Removed `HTMLAttributes: { style: "max-width: 100%; height: auto;" }` — sizing is now in CSS
|
||||
|
||||
**Step 3: Run typecheck**
|
||||
|
||||
Run: `pnpm typecheck`
|
||||
Expected: PASS
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/web/features/editor/extensions/image-view.tsx apps/web/features/editor/extensions/index.ts
|
||||
git commit -m "feat(editor): add Image NodeView with toolbar and lightbox
|
||||
|
||||
- React NodeView renders images with hover toolbar (view/download/copy/link/delete)
|
||||
- Lightbox portal for full-screen preview (ESC or click to close)
|
||||
- Copy image with clipboard API (fallback to copy link on Safari)
|
||||
- Delete button in edit mode only
|
||||
- Readonly: click image opens lightbox"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Update Image CSS — Centering, sizing, toolbar, lightbox
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/web/features/editor/content-editor.css:379-395`
|
||||
|
||||
**Step 1: Replace image CSS rules**
|
||||
|
||||
Replace lines 379-395 (from `/* Images — shared styling */` through the `@keyframes` block) with:
|
||||
|
||||
```css
|
||||
/* Images — generic fallback (non-NodeView contexts) */
|
||||
.rich-text-editor img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: var(--radius);
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
/* Image NodeView — centered block with max-width cap */
|
||||
.rich-text-editor .image-node {
|
||||
display: block !important;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.rich-text-editor .image-figure {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
max-width: min(100%, 640px);
|
||||
margin: 0.75rem 0;
|
||||
}
|
||||
|
||||
.rich-text-editor .image-figure.image-selected .image-content {
|
||||
outline: 2px solid var(--brand);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.rich-text-editor .image-content {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.rich-text-editor .image-uploading {
|
||||
opacity: 0.5;
|
||||
animation: rte-upload-pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||
}
|
||||
|
||||
@keyframes rte-upload-pulse {
|
||||
0%, 100% { opacity: 0.5; }
|
||||
50% { opacity: 0.3; }
|
||||
}
|
||||
|
||||
/* Readonly — zoom cursor on clickable images */
|
||||
.rich-text-editor.readonly .image-figure {
|
||||
cursor: zoom-in;
|
||||
}
|
||||
|
||||
/* Image toolbar — dark pill, top-right corner, appears on hover */
|
||||
.image-toolbar {
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
right: 0.5rem;
|
||||
display: flex;
|
||||
gap: 1px;
|
||||
padding: 0.25rem;
|
||||
background: color-mix(in srgb, black 75%, transparent);
|
||||
backdrop-filter: blur(8px);
|
||||
border-radius: var(--radius);
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.image-figure:hover .image-toolbar {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.image-toolbar button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
border-radius: calc(var(--radius) - 2px);
|
||||
color: white;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.image-toolbar button:hover {
|
||||
background: color-mix(in srgb, white 15%, transparent);
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Run typecheck**
|
||||
|
||||
Run: `pnpm typecheck`
|
||||
Expected: PASS
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/web/features/editor/content-editor.css
|
||||
git commit -m "style(editor): add image centering, sizing cap, and toolbar styles
|
||||
|
||||
- Images centered with max-width 640px cap (smart sizing)
|
||||
- Dark hover toolbar with blur backdrop
|
||||
- Selection outline for edit mode
|
||||
- Zoom cursor for readonly mode
|
||||
- Upload pulse animation preserved"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Full Verification
|
||||
|
||||
**Step 1: Run all checks**
|
||||
|
||||
Run: `pnpm typecheck && pnpm test`
|
||||
Expected: all pass
|
||||
|
||||
**Step 2: Manual verification checklist**
|
||||
|
||||
Test in browser:
|
||||
|
||||
| # | Test | Expected |
|
||||
|---|------|----------|
|
||||
| 1 | Upload large screenshot | Centered, max 640px wide |
|
||||
| 2 | Upload small image (< 300px) | Natural size, centered |
|
||||
| 3 | Drag image into editor | Blob preview with pulse → real image |
|
||||
| 4 | Hover image | Dark toolbar appears top-right (5 buttons edit, 4 readonly) |
|
||||
| 5 | Toolbar → View image | Full-screen lightbox opens |
|
||||
| 6 | Lightbox → ESC | Closes |
|
||||
| 7 | Lightbox → click backdrop | Closes |
|
||||
| 8 | Toolbar → Download | Browser downloads the image |
|
||||
| 9 | Toolbar → Copy image | Toast "Image copied", image in clipboard |
|
||||
| 10 | Toolbar → Copy link | Toast "Link copied", URL in clipboard |
|
||||
| 11 | Toolbar → Delete | Image removed from editor |
|
||||
| 12 | Click image (edit mode) | Blue selection outline appears |
|
||||
| 13 | Select image → Backspace | Image deleted |
|
||||
| 14 | Click image (readonly mode) | Opens lightbox directly |
|
||||
| 15 | Readonly toolbar | No Delete button, other 4 buttons work |
|
||||
| 16 | Save → reload | Images persist with correct styling |
|
||||
|
||||
**Step 3: Fix any issues, re-run checks**
|
||||
|
||||
Run: `pnpm typecheck && pnpm test`
|
||||
|
||||
**Step 4: Commit fixes (if any)**
|
||||
|
||||
---
|
||||
|
||||
## Architecture Notes
|
||||
|
||||
### Why NodeView instead of CSS-only?
|
||||
|
||||
The toolbar buttons (view/download/copy/delete) require interactive React components overlaid on the image. CSS-only can handle sizing/centering but cannot add click handlers. A NodeView is the standard Tiptap pattern for this — same as `CodeBlockView` (copy button) and `FileCardView` (download button) already in the codebase.
|
||||
|
||||
### Upload flow compatibility
|
||||
|
||||
The existing upload flow in `file-upload.ts` uses `tr.setNodeMarkup()` to update image attributes after upload. This works with NodeView because ProseMirror attribute changes trigger React re-renders via `ReactNodeViewRenderer`. Same mechanism used by `FileCardView`'s `finalizeFileCard()`.
|
||||
|
||||
### Markdown serialization
|
||||
|
||||
No changes needed. Images serialize as `` — standard markdown. The NodeView only affects editor rendering, not serialization. No width/height stored in markdown (sizing is purely CSS).
|
||||
|
||||
### Lightbox implementation
|
||||
|
||||
Uses `createPortal` to render outside the editor DOM tree, with a keyboard listener for ESC. Intentionally NOT using shadcn Dialog to keep it minimal — no focus trapping or complex accessibility needed for a simple image preview overlay.
|
||||
|
||||
### Browser compatibility: Copy image
|
||||
|
||||
`navigator.clipboard.write()` with `ClipboardItem` works in Chrome/Edge. Safari requires the clipboard write to be in the same user gesture (no async fetch before write), so it falls back to copying the link URL with a toast notification.
|
||||
|
||||
---
|
||||
|
||||
## Expected Outcome
|
||||
|
||||
| Before | After |
|
||||
|--------|-------|
|
||||
| Images stretch to 100% width, left-aligned | Centered, capped at 640px |
|
||||
| No hover actions on images | 5-button toolbar: View, Download, Copy, Link, Delete |
|
||||
| No image preview | Click-to-zoom lightbox (ESC/click to close) |
|
||||
| Readonly images are static | Click to zoom, hover for toolbar (minus Delete) |
|
||||
| Delete image: select + backspace only | Toolbar Delete button + keyboard |
|
||||
| No visual selection feedback | Blue outline on selected image |
|
||||
489
docs/plans/2026-04-08-monorepo-extraction.md
Normal file
489
docs/plans/2026-04-08-monorepo-extraction.md
Normal file
@@ -0,0 +1,489 @@
|
||||
# Monorepo Extraction Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Extract shared code into monorepo packages (`packages/core/`, `packages/ui/`, `packages/views/`), set up Turborepo, ensure `apps/web/` runs identically.
|
||||
|
||||
**Architecture:** Three packages, single-direction dependencies: `views/ → core/ + ui/`. Core is headless (zero react-dom). UI is atomic (zero business logic). Views is shared pages/components.
|
||||
|
||||
**Tech Stack:** pnpm workspaces + catalog, Turborepo, TypeScript internal packages (export TS source, no build), Tailwind CSS v4, shadcn/ui.
|
||||
|
||||
**Scope:** Monorepo extraction only. Desktop app is a separate future plan.
|
||||
|
||||
**Branch:** `feat/monorepo-extraction` (from latest `main` at f57cf44e)
|
||||
|
||||
---
|
||||
|
||||
## Work Breakdown
|
||||
|
||||
| Category | Files | Nature |
|
||||
|---|---|---|
|
||||
| Pure file moves | ~170 | Copy + fix relative imports |
|
||||
| Code changes needed | ~17 | ApiClient callback, store factories, props refactor, nav adapter |
|
||||
| Bulk import updates | ~140 consumer files | Mechanical find-and-replace |
|
||||
| New files to create | ~15 | package.json, tsconfig, turbo.json, platform layer, nav adapter |
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Infrastructure (Tasks 1-3)
|
||||
|
||||
### Task 1: Turborepo + workspace
|
||||
|
||||
**Files:**
|
||||
- Modify: `pnpm-workspace.yaml` — add `"packages/*"` to packages list, add `@tanstack/react-query` to catalog
|
||||
- Create: `turbo.json`
|
||||
- Modify: `package.json` (root) — add turbo devDep, update scripts to use turbo
|
||||
- Modify: `.gitignore` — add `.turbo`
|
||||
|
||||
**turbo.json:**
|
||||
```json
|
||||
{
|
||||
"$schema": "https://turbo.build/schema.json",
|
||||
"tasks": {
|
||||
"build": {
|
||||
"dependsOn": ["^build"],
|
||||
"inputs": ["src/**", "app/**", "**/*.ts", "**/*.tsx", "**/*.css"],
|
||||
"outputs": [".next/**", "!.next/cache/**", "dist/**"]
|
||||
},
|
||||
"dev": { "cache": false, "persistent": true },
|
||||
"typecheck": { "dependsOn": ["^typecheck"] },
|
||||
"test": { "dependsOn": ["^typecheck"] },
|
||||
"lint": { "dependsOn": ["^typecheck"] }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Verify:** `pnpm typecheck` passes through turbo.
|
||||
|
||||
**Commit:** `chore: add Turborepo and configure workspace for packages/*`
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Shared TypeScript config
|
||||
|
||||
**Files:**
|
||||
- Create: `packages/tsconfig/package.json`
|
||||
- Create: `packages/tsconfig/base.json`
|
||||
- Create: `packages/tsconfig/react-library.json`
|
||||
|
||||
**base.json** — strict, ESNext, bundler resolution, declaration maps.
|
||||
**react-library.json** — extends base, adds jsx: react-jsx and DOM lib.
|
||||
|
||||
All other packages will `"extends": "@multica/tsconfig/react-library.json"`.
|
||||
|
||||
**Commit:** `chore: add shared TypeScript config package`
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Clean up empty package dirs
|
||||
|
||||
**Action:** `rm -rf packages/sdk packages/types packages/utils packages/ui`
|
||||
|
||||
These are leftover empty dirs (only contain node_modules).
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: packages/core/ (Tasks 4-10)
|
||||
|
||||
### Task 4: Scaffold + move types/utils/logger
|
||||
|
||||
**Files:**
|
||||
- Create: `packages/core/package.json` (name: @multica/core, deps: react, zustand, @tanstack/react-query, sonner)
|
||||
- Create: `packages/core/tsconfig.json` (extends @multica/tsconfig/react-library.json)
|
||||
- Move: `apps/web/shared/types/` → `packages/core/types/` (11 files, no changes needed)
|
||||
- Move: `apps/web/shared/logger.ts` → `packages/core/logger.ts` (no changes)
|
||||
- Move: `apps/web/shared/utils.ts` → `packages/core/utils.ts` (no changes)
|
||||
|
||||
**Verify:** `cd packages/core && npx tsc --noEmit`
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Move API client (with onUnauthorized abstraction)
|
||||
|
||||
**Files:**
|
||||
- Move: `apps/web/shared/api/ws-client.ts` → `packages/core/api/ws-client.ts` (no changes)
|
||||
- Move: `apps/web/shared/api/client.ts` → `packages/core/api/client.ts` (**3 changes**)
|
||||
- Create: `packages/core/api/index.ts`
|
||||
|
||||
**Code changes in client.ts:**
|
||||
1. `import type { ... } from "@/shared/types"` → `from "../types"`
|
||||
2. `import { ... } from "@/shared/logger"` → `from "../logger"`
|
||||
3. Add `onUnauthorized?: () => void` to options, replace `handleUnauthorized()` body:
|
||||
```typescript
|
||||
// Before: localStorage.removeItem + window.location.href = "/"
|
||||
// After: this.token = null; this.workspaceId = null; this.options.onUnauthorized?.();
|
||||
```
|
||||
|
||||
**NOT moved:** `apps/web/shared/api/index.ts` (the singleton) — replaced by `apps/web/platform/api.ts` in Task 9.
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Move stores
|
||||
|
||||
**Pure moves (fix imports only):**
|
||||
- `features/issues/store.ts` → `packages/core/issues/store.ts`
|
||||
- `features/issues/config/*.ts` → `packages/core/issues/config/` — fix `@/shared/types` → `../../types`
|
||||
- `features/issues/stores/view-store.ts` → `packages/core/issues/stores/view-store.ts` — fix imports
|
||||
- `features/issues/stores/view-store-context.tsx` → `packages/core/issues/stores/view-store-context.tsx`
|
||||
- `features/issues/stores/draft-store.ts` → `packages/core/issues/stores/draft-store.ts`
|
||||
- `features/issues/stores/issues-scope-store.ts` → `packages/core/issues/stores/issues-scope-store.ts`
|
||||
- `features/issues/stores/selection-store.ts` → `packages/core/issues/stores/selection-store.ts`
|
||||
- `features/navigation/store.ts` → `packages/core/navigation/store.ts` (no changes)
|
||||
- `features/modals/store.ts` → `packages/core/modals/store.ts` (no changes)
|
||||
|
||||
**Factory refactor (code changes):**
|
||||
- `features/auth/store.ts` → `packages/core/auth/store.ts` — change to `createAuthStore({ api, onLogin?, onLogout? })` factory
|
||||
- `features/workspace/store.ts` → `packages/core/workspace/store.ts` — change to `createWorkspaceStore(api)` factory
|
||||
|
||||
**Also move:**
|
||||
- `features/workspace/hooks.ts` → `packages/core/workspace/hooks.ts` — fix imports to relative
|
||||
|
||||
**view-store.ts special handling:** The dynamic `import("@/features/workspace")` for workspace sync — change to accept workspace store instance via `registerViewStoreForWorkspaceSync(viewStore, workspaceStore)`.
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Move TanStack Query modules
|
||||
|
||||
**Pure moves (fix import paths only):**
|
||||
- `apps/web/core/issues/{queries,mutations,ws-updaters}.ts` → `packages/core/issues/`
|
||||
- `apps/web/core/inbox/{queries,mutations,ws-updaters}.ts` → `packages/core/inbox/`
|
||||
- `apps/web/core/workspace/{queries,mutations}.ts` → `packages/core/workspace/`
|
||||
- `apps/web/core/runtimes/queries.ts` → `packages/core/runtimes/`
|
||||
- `apps/web/core/query-client.ts` → `packages/core/query-client.ts`
|
||||
- `apps/web/core/provider.tsx` → `packages/core/provider.tsx`
|
||||
|
||||
All changes: `@/shared/api` → `../api`, `@/shared/types` → `../types`, `@core/xxx` → `./xxx` or `../xxx`
|
||||
|
||||
**Code change:**
|
||||
- `apps/web/core/hooks.ts` → `packages/core/hooks.ts` — refactor `useWorkspaceId()` to use React Context instead of importing workspace store directly:
|
||||
```typescript
|
||||
const WorkspaceIdContext = createContext<string | null>(null);
|
||||
export function WorkspaceIdProvider({ wsId, children }) { ... }
|
||||
export function useWorkspaceId() { return useContext(WorkspaceIdContext); }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: Move realtime + shared hooks
|
||||
|
||||
**Pure moves (fix imports):**
|
||||
- `features/realtime/hooks.ts` → `packages/core/realtime/hooks.ts`
|
||||
- `features/realtime/use-realtime-sync.ts` → `packages/core/realtime/use-realtime-sync.ts`
|
||||
- `shared/hooks/use-file-upload.ts` → `packages/core/hooks/use-file-upload.ts`
|
||||
|
||||
**Code change:**
|
||||
- `features/realtime/provider.tsx` → `packages/core/realtime/provider.tsx` — accept `wsUrl` prop instead of reading `process.env.NEXT_PUBLIC_WS_URL`
|
||||
|
||||
**Note:** `use-realtime-sync.ts` needs auth/workspace store access. Since these are now factories, the realtime provider should receive the store instances. Simplest: WSProvider accepts `authStore` and `workspaceStore` props, passes them to `useRealtimeSync`.
|
||||
|
||||
---
|
||||
|
||||
### Task 9: Create platform bridge in apps/web/
|
||||
|
||||
**New files (all new code):**
|
||||
- `apps/web/platform/api.ts` — creates api singleton with `NEXT_PUBLIC_API_URL`, `onUnauthorized` with `window.location.href`
|
||||
- `apps/web/platform/auth.ts` — `export const useAuthStore = createAuthStore({ api, onLogin: setLoggedInCookie, onLogout: clearLoggedInCookie })`
|
||||
- `apps/web/platform/workspace.ts` — `export const useWorkspaceStore = createWorkspaceStore(api)`
|
||||
- `apps/web/platform/index.ts` — re-exports
|
||||
|
||||
---
|
||||
|
||||
### Task 10: Update imports in apps/web/ + delete old files
|
||||
|
||||
**Bulk find-and-replace across ~94 files:**
|
||||
|
||||
| Pattern | Replacement |
|
||||
|---|---|
|
||||
| `@/shared/types` | `@multica/core/types` |
|
||||
| `@/shared/api"` (singleton usage) | `@/platform/api"` |
|
||||
| `@/shared/logger` | `@multica/core/logger` |
|
||||
| `@/shared/utils` | `@multica/core/utils` |
|
||||
| `@/shared/hooks/` | `@multica/core/hooks/` |
|
||||
| `@core/` | `@multica/core/` |
|
||||
| `@/features/auth"` (useAuthStore) | `@/platform/auth"` |
|
||||
| `@/features/workspace"` (useWorkspaceStore) | `@/platform/workspace"` |
|
||||
| `@/features/workspace"` (useActorName) | `@multica/core/workspace/hooks"` |
|
||||
| `@/features/realtime` | `@multica/core/realtime` |
|
||||
| `@/features/navigation` | `@multica/core/navigation` |
|
||||
| `@/features/modals"` (store) | `@multica/core/modals"` |
|
||||
| `@/features/issues/store` | `@multica/core/issues` |
|
||||
| `@/features/issues/stores/` | `@multica/core/issues/stores/` |
|
||||
| `@/features/issues/config` | `@multica/core/issues/config` |
|
||||
|
||||
**Also:**
|
||||
- Add `"@multica/core": "workspace:*"` to `apps/web/package.json`
|
||||
- Add `transpilePackages: ["@multica/core"]` to `next.config.ts`
|
||||
- Remove `"@core/*"` alias from `apps/web/tsconfig.json`
|
||||
|
||||
**Delete old files:**
|
||||
```
|
||||
apps/web/shared/types/, apps/web/shared/api/, apps/web/shared/logger.ts,
|
||||
apps/web/shared/utils.ts, apps/web/shared/hooks/, apps/web/core/,
|
||||
features/auth/store.ts, features/workspace/store.ts, features/workspace/hooks.ts,
|
||||
features/realtime/, features/navigation/store.ts, features/modals/store.ts,
|
||||
features/issues/store.ts, features/issues/stores/, features/issues/config/
|
||||
```
|
||||
|
||||
**Keep:** `features/auth/auth-cookie.ts`, `features/auth/initializer.tsx`, `features/landing/`
|
||||
|
||||
**Verify:** `pnpm typecheck && pnpm test`
|
||||
|
||||
**Commit:** `feat(core): extract packages/core — headless business logic layer`
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: packages/ui/ (Tasks 11-16)
|
||||
|
||||
### Task 11: Scaffold packages/ui/
|
||||
|
||||
**Files:**
|
||||
- Create: `packages/ui/package.json` (name: @multica/ui, deps: all @radix-ui/*, clsx, tailwind-merge, lucide-react, emoji-mart, react-markdown, shiki, etc.)
|
||||
- Create: `packages/ui/tsconfig.json` (extends shared config, with `@/lib/utils`, `@/hooks/*`, `@/components/ui/*` path aliases for internal shadcn imports)
|
||||
- Create: `packages/ui/components.json` (shadcn config for this package)
|
||||
|
||||
---
|
||||
|
||||
### Task 12: Move shadcn + lib + hooks
|
||||
|
||||
**Pure moves (no code changes):**
|
||||
- `apps/web/components/ui/*.tsx` (56 files) → `packages/ui/components/ui/`
|
||||
- `apps/web/lib/utils.ts` → `packages/ui/lib/utils.ts`
|
||||
- `apps/web/hooks/{use-auto-scroll,use-mobile,use-scroll-fade}.ts` → `packages/ui/hooks/`
|
||||
|
||||
---
|
||||
|
||||
### Task 13: Extract CSS tokens
|
||||
|
||||
- Copy `@theme inline { ... }` + `:root` + `.dark` blocks from `globals.css` → `packages/ui/styles/tokens.css`
|
||||
- Update `globals.css`: replace inline tokens with `@import "@multica/ui/styles/tokens.css"` + add `@source` directives for packages
|
||||
|
||||
---
|
||||
|
||||
### Task 14: Refactor + move common components
|
||||
|
||||
**Code changes (3 files):**
|
||||
- `actor-avatar.tsx` — remove `useActorName()`, accept `name/initials/avatarUrl/isAgent` props
|
||||
- `mention-hover-card.tsx` — remove `useQuery`, accept resolved data props
|
||||
- `reaction-bar.tsx` — remove `useActorName()`, add `getActorName` prop
|
||||
|
||||
**Pure moves (3 files):**
|
||||
- `file-upload-button.tsx`, `emoji-picker.tsx`, `quick-emoji-picker.tsx` → direct copy
|
||||
|
||||
All go to `packages/ui/components/common/`.
|
||||
|
||||
---
|
||||
|
||||
### Task 15: Move markdown components
|
||||
|
||||
**Code change (1 file):**
|
||||
- `Markdown.tsx` — add `renderMention?: (props: { type: string; id: string }) => ReactNode` prop, remove hardcoded `IssueMentionCard` import
|
||||
|
||||
**Pure moves (5 files):**
|
||||
- `CodeBlock.tsx`, `StreamingMarkdown.tsx`, `linkify.ts`, `mentions.ts`, `index.ts`
|
||||
|
||||
All go to `packages/ui/markdown/`.
|
||||
|
||||
---
|
||||
|
||||
### Task 16: Update imports + delete old files
|
||||
|
||||
**Bulk find-and-replace across ~118 files:**
|
||||
|
||||
| Pattern | Replacement |
|
||||
|---|---|
|
||||
| `@/components/ui/` | `@multica/ui/components/ui/` |
|
||||
| `@/components/common/` | `@multica/ui/components/common/` |
|
||||
| `@/components/markdown` | `@multica/ui/markdown` |
|
||||
| `@/lib/utils` | `@multica/ui/lib/utils` |
|
||||
| `@/hooks/use-mobile` | `@multica/ui/hooks/use-mobile` |
|
||||
| `@/hooks/use-auto-scroll` | `@multica/ui/hooks/use-auto-scroll` |
|
||||
| `@/hooks/use-scroll-fade` | `@multica/ui/hooks/use-scroll-fade` |
|
||||
|
||||
**Also:**
|
||||
- Add `"@multica/ui": "workspace:*"` to `apps/web/package.json`
|
||||
- Add `"@multica/ui"` to `transpilePackages` in `next.config.ts`
|
||||
- Update `apps/web/components.json` aliases to point to `@multica/ui`
|
||||
|
||||
**Delete:** `components/ui/`, `components/common/`, `components/markdown/`, `hooks/`, `lib/utils.ts`
|
||||
|
||||
**Keep:** `components/{theme-provider,theme-toggle,multica-icon,loading-indicator,spinner,locale-sync}.tsx`
|
||||
|
||||
**Verify:** `pnpm typecheck && pnpm test`
|
||||
|
||||
**Commit:** `feat(ui): extract packages/ui — shared atomic UI layer`
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: packages/views/ + navigation (Tasks 17-22)
|
||||
|
||||
### Task 17: Create navigation adapter
|
||||
|
||||
**New files (all new code, ~60 lines total):**
|
||||
- `packages/views/package.json` (deps: @multica/core, @multica/ui, @dnd-kit/*, @tiptap/*, sonner, recharts)
|
||||
- `packages/views/tsconfig.json`
|
||||
- `packages/views/navigation/types.ts` — `NavigationAdapter` interface (push, replace, back, pathname, searchParams)
|
||||
- `packages/views/navigation/context.tsx` — `NavigationProvider` + `useNavigation()` hook
|
||||
- `packages/views/navigation/app-link.tsx` — `<AppLink>` component (replaces `next/link`)
|
||||
- `packages/views/navigation/index.ts`
|
||||
|
||||
---
|
||||
|
||||
### Task 18: Create WebNavigationProvider
|
||||
|
||||
**New file:**
|
||||
- `apps/web/platform/navigation.tsx` — wraps `useRouter`/`usePathname`/`useSearchParams` into `NavigationAdapter`
|
||||
|
||||
Wire into dashboard layout.
|
||||
|
||||
---
|
||||
|
||||
### Task 19: Move feature UI components
|
||||
|
||||
**Next.js decouple (7 files, ~2 lines each):**
|
||||
|
||||
| File | Import change | JSX change |
|
||||
|---|---|---|
|
||||
| `issue-mention-card.tsx` | `next/link` → `../navigation` | `<Link` → `<AppLink` |
|
||||
| `board-card.tsx` | same | same |
|
||||
| `list-row.tsx` | same | same |
|
||||
| `issue-detail.tsx` | `next/link` + `next/navigation` → `../navigation` | `<Link` → `<AppLink`, `router.push` → `nav.push` |
|
||||
| `create-issue.tsx` | `next/navigation` → `../navigation` | `router.push` → `nav.push` |
|
||||
| `create-workspace.tsx` | same | same |
|
||||
|
||||
**Pure moves (~85 files, fix import paths only):**
|
||||
- `features/issues/components/` (24 files) → `packages/views/issues/components/`
|
||||
- `features/issues/hooks/` (3 files) → `packages/views/issues/hooks/`
|
||||
- `features/issues/utils/` (5 files) → `packages/views/issues/utils/`
|
||||
- `features/editor/` (16 files incl CSS) → `packages/views/editor/`
|
||||
- `features/modals/{create-issue,create-workspace,registry}.tsx` → `packages/views/modals/`
|
||||
- `features/my-issues/` (4 files) → `packages/views/my-issues/`
|
||||
- `features/skills/` (5 files) → `packages/views/skills/`
|
||||
- `features/runtimes/` (16 files) → `packages/views/runtimes/`
|
||||
- `features/workspace/components/workspace-avatar.tsx` → `packages/views/workspace/`
|
||||
|
||||
---
|
||||
|
||||
### Task 20: Extract fat pages
|
||||
|
||||
Move logic from page.tsx files into packages/views/:
|
||||
|
||||
| Page | Lines | Target |
|
||||
|---|---|---|
|
||||
| `(dashboard)/agents/page.tsx` | 1,280 | `packages/views/agents/agents-page.tsx` |
|
||||
| `(dashboard)/inbox/page.tsx` | 468 | `packages/views/inbox/inbox-page.tsx` |
|
||||
| `(auth)/login/page.tsx` | 389 | `packages/views/auth/login-page.tsx` |
|
||||
|
||||
Each original page.tsx becomes a 3-line thin shell:
|
||||
```typescript
|
||||
"use client";
|
||||
import { AgentsPage } from "@multica/views/agents";
|
||||
export default function Page() { return <AgentsPage />; }
|
||||
```
|
||||
|
||||
Login page: pass `googleClientId` as prop instead of reading env var.
|
||||
|
||||
---
|
||||
|
||||
### Task 21: Update imports + delete old files
|
||||
|
||||
**Bulk find-and-replace across ~18 files:**
|
||||
|
||||
| Pattern | Replacement |
|
||||
|---|---|
|
||||
| `@/features/issues/components` | `@multica/views/issues/components` |
|
||||
| `@/features/issues/hooks/` | `@multica/views/issues/hooks/` |
|
||||
| `@/features/editor` | `@multica/views/editor` |
|
||||
| `@/features/modals/` (components) | `@multica/views/modals/` |
|
||||
| `@/features/my-issues` | `@multica/views/my-issues` |
|
||||
| `@/features/skills` | `@multica/views/skills` |
|
||||
| `@/features/runtimes` | `@multica/views/runtimes` |
|
||||
|
||||
**Also:**
|
||||
- Add `"@multica/views": "workspace:*"` to `apps/web/package.json`
|
||||
- Add `"@multica/views"` to `transpilePackages`
|
||||
- Add `@source "../../packages/views/**/*.tsx"` to `globals.css`
|
||||
|
||||
**Delete old feature files.**
|
||||
|
||||
**Verify:** `pnpm typecheck && pnpm test`
|
||||
|
||||
**Commit:** `feat(views): extract packages/views — shared business UI + navigation adapter`
|
||||
|
||||
---
|
||||
|
||||
### Task 22: Final verification
|
||||
|
||||
```bash
|
||||
make check # typecheck + unit tests + Go tests + E2E
|
||||
cd apps/web && npx shadcn@latest add --dry-run badge # shadcn CLI works
|
||||
|
||||
# Package constraints
|
||||
grep -r "@multica/core" packages/ui/ || echo "PASS: ui/ has zero core imports"
|
||||
grep -r "react-dom" packages/core/ || echo "PASS: core/ has zero react-dom"
|
||||
grep -r "from \"next/" packages/views/ || echo "PASS: views/ has zero next/* imports"
|
||||
```
|
||||
|
||||
**Commit:** `chore: monorepo extraction complete — all checks pass`
|
||||
|
||||
---
|
||||
|
||||
## Final Directory Structure
|
||||
|
||||
```
|
||||
multica/
|
||||
├── packages/
|
||||
│ ├── tsconfig/ # Shared TS config
|
||||
│ ├── core/ # @multica/core — 三端共用 (零 react-dom)
|
||||
│ │ ├── api/ # ApiClient class + WSClient
|
||||
│ │ ├── types/ # 所有领域类型
|
||||
│ │ ├── auth/ # createAuthStore factory
|
||||
│ │ ├── workspace/ # createWorkspaceStore factory + useActorName
|
||||
│ │ ├── issues/ # stores, config, queries, mutations, ws-updaters
|
||||
│ │ ├── inbox/ # queries, mutations, ws-updaters
|
||||
│ │ ├── runtimes/ # queries
|
||||
│ │ ├── realtime/ # WSProvider, hooks, sync
|
||||
│ │ ├── navigation/ # useNavigationStore
|
||||
│ │ ├── modals/ # useModalStore
|
||||
│ │ └── hooks.ts # useWorkspaceId (Context-based)
|
||||
│ ├── ui/ # @multica/ui — Web+Desktop 共用 (零业务逻辑)
|
||||
│ │ ├── components/ui/ # 56 shadcn 组件
|
||||
│ │ ├── components/common/ # actor-avatar, emoji-picker... (纯 props)
|
||||
│ │ ├── markdown/ # Markdown, StreamingMarkdown (renderMention slot)
|
||||
│ │ ├── hooks/ # use-auto-scroll, use-mobile, use-scroll-fade
|
||||
│ │ ├── lib/utils.ts # cn()
|
||||
│ │ └── styles/tokens.css
|
||||
│ └── views/ # @multica/views — Web+Desktop 共用页面
|
||||
│ ├── navigation/ # NavigationAdapter + AppLink
|
||||
│ ├── issues/ # IssuesPage, IssueDetail, BoardView...
|
||||
│ ├── editor/ # ContentEditor, TitleEditor
|
||||
│ ├── modals/ # CreateIssue, CreateWorkspace
|
||||
│ ├── agents/ # AgentsPage (从 1280 行 page.tsx 提取)
|
||||
│ ├── inbox/ # InboxPage (从 468 行 page.tsx 提取)
|
||||
│ ├── auth/ # LoginPage (从 389 行 page.tsx 提取)
|
||||
│ ├── my-issues/ # MyIssuesPage
|
||||
│ ├── skills/ # SkillsPage
|
||||
│ └── runtimes/ # RuntimesPage
|
||||
├── apps/
|
||||
│ └── web/
|
||||
│ ├── app/ # Next.js 路由薄壳 (每个 page < 15 行)
|
||||
│ ├── platform/ # Web 平台适配 (api 单例, auth store, nav provider)
|
||||
│ ├── features/
|
||||
│ │ ├── auth/ # auth-cookie.ts (Web 独有) + initializer.tsx
|
||||
│ │ └── landing/ # Landing 页面 (Web 独有, 用 next/image)
|
||||
│ └── components/ # theme-provider, multica-icon 等 app 级组件
|
||||
├── turbo.json
|
||||
└── pnpm-workspace.yaml
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Execution Order & Commits
|
||||
|
||||
| # | Commit | 影响范围 | 风险 |
|
||||
|---|---|---|---|
|
||||
| 1 | `chore: Turborepo + workspace` | 配置文件 | 低 |
|
||||
| 2 | `chore: shared TypeScript config` | 新文件 | 低 |
|
||||
| 3 | `feat(core): extract packages/core` | 94 文件 import 变更 | 中 — 最大批量替换 |
|
||||
| 4 | `feat(ui): extract packages/ui` | 118 文件 import 变更 | 中 — 最多文件 |
|
||||
| 5 | `feat(views): extract packages/views` | 18 文件 + 3 胖壳 | 中 |
|
||||
| 6 | `chore: final verification` | 0 | 低 |
|
||||
13
package.json
13
package.json
@@ -4,12 +4,12 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev:web": "pnpm --filter @multica/web dev",
|
||||
"build": "pnpm --filter @multica/web build",
|
||||
"typecheck": "pnpm --filter @multica/web typecheck",
|
||||
"test": "pnpm --filter @multica/web test",
|
||||
"lint": "pnpm --filter @multica/web lint",
|
||||
"clean": "pnpm --filter @multica/web clean && rm -rf node_modules"
|
||||
"dev:web": "turbo dev --filter=@multica/web",
|
||||
"build": "turbo build",
|
||||
"typecheck": "turbo typecheck",
|
||||
"test": "turbo test",
|
||||
"lint": "turbo lint",
|
||||
"clean": "turbo clean && rm -rf node_modules"
|
||||
},
|
||||
"packageManager": "pnpm@10.28.2",
|
||||
"pnpm": {
|
||||
@@ -26,6 +26,7 @@
|
||||
"@types/node": "catalog:",
|
||||
"@types/pg": "^8.20.0",
|
||||
"pg": "^8.20.0",
|
||||
"turbo": "^2.5.4",
|
||||
"typescript": "catalog:"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
CreateIssueRequest,
|
||||
UpdateIssueRequest,
|
||||
ListIssuesResponse,
|
||||
SearchIssuesResponse,
|
||||
UpdateMeRequest,
|
||||
CreateMemberRequest,
|
||||
UpdateMemberRequest,
|
||||
@@ -35,8 +36,13 @@ import type {
|
||||
TimelineEntry,
|
||||
TaskMessagePayload,
|
||||
Attachment,
|
||||
} from "@/shared/types";
|
||||
import { type Logger, noopLogger } from "@/shared/logger";
|
||||
} from "../types";
|
||||
import { type Logger, noopLogger } from "../logger";
|
||||
|
||||
export interface ApiClientOptions {
|
||||
logger?: Logger;
|
||||
onUnauthorized?: () => void;
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
token: string;
|
||||
@@ -48,9 +54,11 @@ export class ApiClient {
|
||||
private token: string | null = null;
|
||||
private workspaceId: string | null = null;
|
||||
private logger: Logger;
|
||||
private options: ApiClientOptions;
|
||||
|
||||
constructor(baseUrl: string, options?: { logger?: Logger }) {
|
||||
constructor(baseUrl: string, options?: ApiClientOptions) {
|
||||
this.baseUrl = baseUrl;
|
||||
this.options = options ?? {};
|
||||
this.logger = options?.logger ?? noopLogger;
|
||||
}
|
||||
|
||||
@@ -70,15 +78,9 @@ export class ApiClient {
|
||||
}
|
||||
|
||||
private handleUnauthorized() {
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.removeItem("multica_token");
|
||||
localStorage.removeItem("multica_workspace_id");
|
||||
this.token = null;
|
||||
this.workspaceId = null;
|
||||
if (window.location.pathname !== "/") {
|
||||
window.location.href = "/";
|
||||
}
|
||||
}
|
||||
this.token = null;
|
||||
this.workspaceId = null;
|
||||
this.options.onUnauthorized?.();
|
||||
}
|
||||
|
||||
private async parseErrorMessage(res: Response, fallback: string): Promise<string> {
|
||||
@@ -114,7 +116,8 @@ export class ApiClient {
|
||||
if (!res.ok) {
|
||||
if (res.status === 401) this.handleUnauthorized();
|
||||
const message = await this.parseErrorMessage(res, `API error: ${res.status} ${res.statusText}`);
|
||||
this.logger.error(`← ${res.status} ${path}`, { rid, duration: `${Date.now() - start}ms`, error: message });
|
||||
const logLevel = res.status === 404 ? "warn" : "error";
|
||||
this.logger[logLevel](`← ${res.status} ${path}`, { rid, duration: `${Date.now() - start}ms`, error: message });
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
@@ -143,6 +146,13 @@ export class ApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
async googleLogin(code: string, redirectUri: string): Promise<LoginResponse> {
|
||||
return this.fetch("/auth/google", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ code, redirect_uri: redirectUri }),
|
||||
});
|
||||
}
|
||||
|
||||
async getMe(): Promise<User> {
|
||||
return this.fetch("/api/me");
|
||||
}
|
||||
@@ -164,9 +174,18 @@ export class ApiClient {
|
||||
if (params?.status) search.set("status", params.status);
|
||||
if (params?.priority) search.set("priority", params.priority);
|
||||
if (params?.assignee_id) search.set("assignee_id", params.assignee_id);
|
||||
if (params?.open_only) search.set("open_only", "true");
|
||||
return this.fetch(`/api/issues?${search}`);
|
||||
}
|
||||
|
||||
async searchIssues(params: { q: string; limit?: number; offset?: number; include_closed?: boolean; signal?: AbortSignal }): Promise<SearchIssuesResponse> {
|
||||
const search = new URLSearchParams({ q: params.q });
|
||||
if (params.limit !== undefined) search.set("limit", String(params.limit));
|
||||
if (params.offset !== undefined) search.set("offset", String(params.offset));
|
||||
if (params.include_closed) search.set("include_closed", "true");
|
||||
return this.fetch(`/api/issues/search?${search}`, params.signal ? { signal: params.signal } : undefined);
|
||||
}
|
||||
|
||||
async getIssue(id: string): Promise<Issue> {
|
||||
return this.fetch(`/api/issues/${id}`);
|
||||
}
|
||||
@@ -187,6 +206,10 @@ export class ApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
async listChildIssues(id: string): Promise<{ issues: Issue[] }> {
|
||||
return this.fetch(`/api/issues/${id}/children`);
|
||||
}
|
||||
|
||||
async deleteIssue(id: string): Promise<void> {
|
||||
await this.fetch(`/api/issues/${id}`, { method: "DELETE" });
|
||||
}
|
||||
@@ -325,13 +348,18 @@ export class ApiClient {
|
||||
return this.fetch(`/api/agents/${id}/restore`, { method: "POST" });
|
||||
}
|
||||
|
||||
async listRuntimes(params?: { workspace_id?: string }): Promise<AgentRuntime[]> {
|
||||
async listRuntimes(params?: { workspace_id?: string; owner?: "me" }): Promise<AgentRuntime[]> {
|
||||
const search = new URLSearchParams();
|
||||
const wsId = params?.workspace_id ?? this.workspaceId;
|
||||
if (wsId) search.set("workspace_id", wsId);
|
||||
if (params?.owner) search.set("owner", params.owner);
|
||||
return this.fetch(`/api/runtimes?${search}`);
|
||||
}
|
||||
|
||||
async deleteRuntime(runtimeId: string): Promise<void> {
|
||||
await this.fetch(`/api/runtimes/${runtimeId}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
async getRuntimeUsage(runtimeId: string, params?: { days?: number }): Promise<RuntimeUsage[]> {
|
||||
const search = new URLSearchParams();
|
||||
if (params?.days) search.set("days", String(params.days));
|
||||
@@ -371,7 +399,7 @@ export class ApiClient {
|
||||
return this.fetch(`/api/agents/${agentId}/tasks`);
|
||||
}
|
||||
|
||||
async getActiveTaskForIssue(issueId: string): Promise<{ task: AgentTask | null }> {
|
||||
async getActiveTasksForIssue(issueId: string): Promise<{ tasks: AgentTask[] }> {
|
||||
return this.fetch(`/api/issues/${issueId}/active-task`);
|
||||
}
|
||||
|
||||
31
packages/core/api/index.ts
Normal file
31
packages/core/api/index.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
export { ApiClient } from "./client";
|
||||
export type { ApiClientOptions } from "./client";
|
||||
export { WSClient } from "./ws-client";
|
||||
|
||||
import type { ApiClient as ApiClientType } from "./client";
|
||||
|
||||
/** Module-level singleton — set once at app boot via `setApiInstance()`. */
|
||||
let _api: ApiClientType | null = null;
|
||||
|
||||
export function setApiInstance(instance: ApiClientType) {
|
||||
_api = instance;
|
||||
}
|
||||
|
||||
/** Returns the shared ApiClient singleton. Throws if not yet initialised. */
|
||||
export function getApi(): ApiClientType {
|
||||
if (!_api) throw new Error("ApiClient not initialised — call setApiInstance() first");
|
||||
return _api;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience re-export: a proxy that forwards every property access to the
|
||||
* singleton so existing call-sites (`api.listIssues(...)`) keep working.
|
||||
*/
|
||||
export const api = new Proxy({} as ApiClientType, {
|
||||
get(_target, prop, receiver) {
|
||||
// Allow property inspection (HMR/React Refresh) before initialisation
|
||||
if (!_api) return undefined;
|
||||
const value = Reflect.get(_api, prop, receiver);
|
||||
return typeof value === "function" ? value.bind(_api) : value;
|
||||
},
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { WSMessage, WSEventType } from "@/shared/types";
|
||||
import { type Logger, noopLogger } from "@/shared/logger";
|
||||
import type { WSMessage, WSEventType } from "../types/events";
|
||||
import { type Logger, noopLogger } from "../logger";
|
||||
|
||||
type EventHandler = (payload: unknown) => void;
|
||||
type EventHandler = (payload: unknown, actorId?: string) => void;
|
||||
|
||||
export class WSClient {
|
||||
private ws: WebSocket | null = null;
|
||||
@@ -53,7 +53,7 @@ export class WSClient {
|
||||
const eventHandlers = this.handlers.get(msg.type);
|
||||
if (eventHandlers) {
|
||||
for (const handler of eventHandlers) {
|
||||
handler(msg.payload);
|
||||
handler(msg.payload, msg.actor_id);
|
||||
}
|
||||
}
|
||||
for (const handler of this.anyHandlers) {
|
||||
39
packages/core/auth/index.ts
Normal file
39
packages/core/auth/index.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
export { createAuthStore } from "./store";
|
||||
export type { AuthStoreOptions, AuthState } from "./store";
|
||||
|
||||
import type { createAuthStore as CreateAuthStoreFn } from "./store";
|
||||
|
||||
type AuthStoreInstance = ReturnType<typeof CreateAuthStoreFn>;
|
||||
|
||||
/** Module-level singleton — set once at app boot via `registerAuthStore()`. */
|
||||
let _store: AuthStoreInstance | null = null;
|
||||
|
||||
/**
|
||||
* Register the auth store instance created by the app.
|
||||
* Must be called at boot before any component renders.
|
||||
*/
|
||||
export function registerAuthStore(store: AuthStoreInstance) {
|
||||
_store = store;
|
||||
}
|
||||
|
||||
/**
|
||||
* Singleton accessor — a Zustand hook backed by the registered instance.
|
||||
* Supports `useAuthStore(selector)` and `useAuthStore.getState()`.
|
||||
*/
|
||||
export const useAuthStore: AuthStoreInstance = new Proxy(
|
||||
(() => {}) as unknown as AuthStoreInstance,
|
||||
{
|
||||
apply(_target, _thisArg, args) {
|
||||
if (!_store)
|
||||
throw new Error(
|
||||
"Auth store not initialised — call registerAuthStore() first",
|
||||
);
|
||||
return (_store as unknown as (...a: unknown[]) => unknown)(...args);
|
||||
},
|
||||
get(_target, prop) {
|
||||
// Allow property inspection (HMR/React Refresh) before registration
|
||||
if (!_store) return undefined;
|
||||
return Reflect.get(_store, prop);
|
||||
},
|
||||
},
|
||||
);
|
||||
85
packages/core/auth/store.ts
Normal file
85
packages/core/auth/store.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { create } from "zustand";
|
||||
import type { User, StorageAdapter } from "../types";
|
||||
import type { ApiClient } from "../api/client";
|
||||
|
||||
export interface AuthStoreOptions {
|
||||
api: ApiClient;
|
||||
storage: StorageAdapter;
|
||||
onLogin?: () => void;
|
||||
onLogout?: () => void;
|
||||
}
|
||||
|
||||
export interface AuthState {
|
||||
user: User | null;
|
||||
isLoading: boolean;
|
||||
|
||||
initialize: () => Promise<void>;
|
||||
sendCode: (email: string) => Promise<void>;
|
||||
verifyCode: (email: string, code: string) => Promise<User>;
|
||||
loginWithGoogle: (code: string, redirectUri: string) => Promise<User>;
|
||||
logout: () => void;
|
||||
setUser: (user: User) => void;
|
||||
}
|
||||
|
||||
export function createAuthStore(options: AuthStoreOptions) {
|
||||
const { api, storage, onLogin, onLogout } = options;
|
||||
|
||||
return create<AuthState>((set) => ({
|
||||
user: null,
|
||||
isLoading: true,
|
||||
|
||||
initialize: async () => {
|
||||
const token = storage.getItem("multica_token");
|
||||
if (!token) {
|
||||
set({ isLoading: false });
|
||||
return;
|
||||
}
|
||||
|
||||
api.setToken(token);
|
||||
|
||||
try {
|
||||
const user = await api.getMe();
|
||||
set({ user, isLoading: false });
|
||||
} catch {
|
||||
api.setToken(null);
|
||||
api.setWorkspaceId(null);
|
||||
storage.removeItem("multica_token");
|
||||
set({ user: null, isLoading: false });
|
||||
}
|
||||
},
|
||||
|
||||
sendCode: async (email: string) => {
|
||||
await api.sendCode(email);
|
||||
},
|
||||
|
||||
verifyCode: async (email: string, code: string) => {
|
||||
const { token, user } = await api.verifyCode(email, code);
|
||||
storage.setItem("multica_token", token);
|
||||
api.setToken(token);
|
||||
onLogin?.();
|
||||
set({ user });
|
||||
return user;
|
||||
},
|
||||
|
||||
loginWithGoogle: async (code: string, redirectUri: string) => {
|
||||
const { token, user } = await api.googleLogin(code, redirectUri);
|
||||
storage.setItem("multica_token", token);
|
||||
api.setToken(token);
|
||||
onLogin?.();
|
||||
set({ user });
|
||||
return user;
|
||||
},
|
||||
|
||||
logout: () => {
|
||||
storage.removeItem("multica_token");
|
||||
api.setToken(null);
|
||||
api.setWorkspaceId(null);
|
||||
onLogout?.();
|
||||
set({ user: null });
|
||||
},
|
||||
|
||||
setUser: (user: User) => {
|
||||
set({ user });
|
||||
},
|
||||
}));
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user