Compare commits

..

1 Commits

Author SHA1 Message Date
Jiayuan
c5709c8651 docs: add multica CLI quick start to README
The Quick Start section only covered local development setup. Added a
"Using Multica (CLI)" subsection showing install, login, and daemon
start so users can get up and running without setting up the dev
environment.
2026-03-31 17:03:27 +08:00
261 changed files with 4786 additions and 23334 deletions

View File

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

View File

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

View File

@@ -57,7 +57,7 @@ jobs:
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: "1.26.1"
go-version: "1.24"
cache-dependency-path: server/go.sum
- name: Build

1
.gitignore vendored
View File

@@ -36,7 +36,6 @@ apps/web/test-results/
# local settings
.claude/
.tool-versions
# feature tracking
_features/

View File

@@ -47,7 +47,7 @@ brews:
directory: Formula
homepage: "https://github.com/multica-ai/multica"
description: "Multica CLI — local agent runtime and management tool for the Multica platform"
license: "Apache-2.0"
license: "MIT"
install: |
bin.install "multica"
test: |

278
AGENTS.md
View File

@@ -1,274 +1,16 @@
# Repository Guidelines
This file provides guidance to AI agents when working with code in this repository.
## Project Structure & Module Organization
`apps/web/` contains the Next.js 16 frontend: routes live in `app/`, reusable UI in `components/`, feature code in `features/`, test utilities in `test/`, and static assets in `public/`. `server/` contains the Go backend: entry points are in `cmd/{server,multica,migrate}`, application logic lives in `internal/`, migrations are in `migrations/`, and SQL lives under `pkg/db/queries/` with generated sqlc output in `pkg/db/generated/`. `e2e/` holds Playwright coverage. `scripts/` and the root `Makefile` drive local setup and verification.
## Project Context
## Build, Test, and Development Commands
Use `make setup` for first-time setup: it installs dependencies, ensures PostgreSQL is running, and applies migrations. Use `make start` to run backend and frontend together with `.env` or `.env.worktree`. For single-surface work, use `pnpm dev:web` for the frontend and `make dev` for the Go server. Run `pnpm test` for Vitest, `make test` for Go tests, and `make check` for the full pipeline: typecheck, frontend unit tests, Go tests, then Playwright. After changing SQL in `server/pkg/db/queries/*.sql`, run `make sqlc`.
Multica is an AI-native task management platform — like Linear, but with AI agents as first-class citizens.
## Coding Style & Naming Conventions
TypeScript in `apps/web` uses 2-space indentation, double quotes, and semicolons. Prefer PascalCase for React components, camelCase for hooks and helpers, and colocated test files such as `page.test.tsx`. Go code should stay `gofmt`-clean and use domain-oriented filenames like `issue.go` or `cmd_issue.go`. Do not hand-edit generated code in `server/pkg/db/generated/`.
- Agents can be assigned issues, create issues, comment, and change status
- Supports local (daemon) and cloud agent runtimes
- Built for 2-10 person AI-native teams
## Testing Guidelines
Frontend unit tests use Vitest with Testing Library and shared setup from `apps/web/test/`. End-to-end tests live in `e2e/*.spec.ts`; `make check` will start missing services automatically, while direct Playwright runs expect the app to already be running. Backend tests use Gos standard `*_test.go` pattern. Add or update tests whenever you change handlers, CLI commands, daemon behavior, or SQL-backed flows.
## Architecture
**Go backend + standalone Next.js frontend.**
- `server/` — Go backend (Chi router, sqlc for DB, gorilla/websocket for real-time)
- `apps/web/` — Next.js 16 frontend (App Router) — self-contained, no shared package dependencies
- `e2e/` — Playwright end-to-end tests
- `scripts/` and root `Makefile` — local setup and verification
### Web App Structure (`apps/web/`)
The frontend uses a **feature-based architecture** with four layers:
```
apps/web/
├── app/ # Routing layer (thin shells — import from features/)
├── features/ # Business logic, organized by domain
├── shared/ # Cross-feature utilities (api client, types, logger)
├── test/ # Shared test utilities and setup
├── public/ # Static assets
```
**`app/`** — Next.js App Router pages. Route files should be thin: import and re-export from `features/`. Layout components and route-specific glue (redirects, auth guards) live here. Shared layout components (e.g. `app-sidebar`) stay in `app/(dashboard)/_components/`.
**`features/`** — Domain modules, each with its own components, hooks, stores, and config:
| Feature | Purpose | Exports |
|---|---|---|
| `features/auth/` | Authentication state | `useAuthStore`, `AuthInitializer` |
| `features/workspace/` | Workspace, members, agents | `useWorkspaceStore`, `useActorName` |
| `features/issues/` | Issue state, components, config | `useIssueStore`, icons, pickers, status/priority config |
| `features/inbox/` | Inbox notification state | `useInboxStore` |
| `features/realtime/` | WebSocket connection + sync | `WSProvider`, `useWSEvent`, `useRealtimeSync` |
| `features/modals/` | Modal registry and state | Modal store and components |
| `features/skills/` | Skill management | Skill components |
**`shared/`** — Code used across multiple features:
- `shared/api/``ApiClient` (REST) and `WSClient` (WebSocket) for backend communication, plus the `api` singleton.
- `shared/types/` — Domain types (Issue, Agent, Workspace, etc.) and WebSocket event types.
- `shared/logger.ts` — Logger utility.
### State Management
- **Zustand** for global client state — one store per feature domain (`features/auth/store.ts`, `features/workspace/store.ts`, `features/issues/store.ts`, `features/inbox/store.ts`).
- **React Context** only for connection lifecycle (`WSProvider` in `features/realtime/`).
- **Local `useState`** for component-scoped UI state (forms, modals, filters).
- Do not use React Context for data that can be a zustand store.
**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.
### Import Aliases
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";
```
Within a feature, use relative imports. Between features or to shared, use `@/`.
### Data Flow
```
Browser → ApiClient (shared/api) → REST API (Chi handlers) → sqlc queries → PostgreSQL
Browser ← WSClient (shared/api) ← WebSocket ← Hub.Broadcast() ← Handlers/TaskService
```
### Backend Structure (`server/`)
- **Entry points** (`cmd/`): `server` (HTTP API), `multica` (CLI — daemon, agent management, config), `migrate`
- **Handlers** (`internal/handler/`): One file per domain (issue, comment, agent, auth, daemon, etc.). Each handler holds `Queries`, `DB`, `Hub`, and `TaskService`.
- **Real-time** (`internal/realtime/`): Hub manages WebSocket clients. Server broadcasts events; inbound WS message routing is still TODO.
- **Auth** (`internal/auth/` + `internal/middleware/`): JWT (HS256). Middleware sets `X-User-ID` and `X-User-Email` headers. Login creates user on-the-fly if not found.
- **Task lifecycle** (`internal/service/task.go`): Orchestrates agent work — enqueue → claim → start → complete/fail. Syncs issue status automatically and broadcasts WS events at each transition.
- **Agent SDK** (`pkg/agent/`): Unified `Backend` interface for executing prompts via Claude Code or Codex. Each backend spawns its CLI and streams results via `Session.Messages` + `Session.Result` channels.
- **Daemon** (`internal/daemon/`): Local agent runtime — auto-detects available CLIs (claude, codex), registers runtimes, polls for tasks, routes by provider.
- **CLI** (`internal/cli/`): Shared helpers for the `multica` CLI — API client, config management, output formatting.
- **Events** (`internal/events/`): Internal event bus for decoupled communication between handlers and services.
- **Logging** (`internal/logger/`): Structured logging via slog. `LOG_LEVEL` env var controls level (debug, info, warn, error).
- **Database**: PostgreSQL with pgvector extension (`pgvector/pgvector:pg17`). sqlc generates Go code from SQL in `pkg/db/queries/``pkg/db/generated/`. Migrations in `migrations/`.
- **Routes** (`cmd/server/router.go`): Public routes (auth, health, ws) + protected routes (require JWT) + daemon routes (unauthenticated, separate auth model).
### Multi-tenancy
All queries filter by `workspace_id`. Membership checks gate access. `X-Workspace-ID` header routes requests to the correct workspace.
### Agent Assignees
Assignees are polymorphic — can be a member or an agent. `assignee_type` + `assignee_id` on issues. Agents render with distinct styling (purple background, robot icon).
## Commands
```bash
# One-click setup & run
make setup # First-time: ensure shared DB, create app DB, migrate
make start # Start backend + frontend together
make stop # Stop app processes for the current checkout
make db-down # Stop the shared PostgreSQL container
# Frontend
pnpm install
pnpm dev:web # Next.js dev server (port 3000)
pnpm build # Build frontend
pnpm typecheck # TypeScript check
pnpm lint # ESLint via Next.js
pnpm test # TS tests (Vitest)
# Backend (Go)
make dev # Run Go server (port 8080)
make daemon # Run local daemon
make build # Build server + CLI binaries to server/bin/
make cli ARGS="..." # Run multica CLI (e.g. make cli ARGS="config")
make test # Go tests
make sqlc # Regenerate sqlc code after editing SQL in server/pkg/db/queries/
make migrate-up # Run database migrations
make migrate-down # Rollback migrations
# Run a single Go test
cd server && go test ./internal/handler/ -run TestName
# Run a single TS test
pnpm --filter @multica/web exec vitest run src/path/to/file.test.ts
# Run a single E2E test (requires backend + frontend running)
pnpm exec playwright test e2e/tests/specific-test.spec.ts
# Infrastructure
make db-up # Start shared PostgreSQL (pgvector/pg17 image)
make db-down # Stop shared PostgreSQL
```
### CI Requirements
CI runs on Node 22 and Go 1.24 with a `pgvector/pgvector:pg17` PostgreSQL service. See `.github/workflows/ci.yml`.
### Worktree Support
All checkouts share one PostgreSQL container. Isolation is at the database level — each worktree gets its own DB name and unique ports via `.env.worktree`. Main checkouts use `.env`.
```bash
make worktree-env # Generate .env.worktree with unique DB/ports
make setup-worktree # Setup using .env.worktree
make start-worktree # Start using .env.worktree
```
## Coding Rules
- TypeScript strict mode is enabled; keep types explicit.
- TypeScript in `apps/web` uses 2-space indentation, double quotes, and semicolons.
- Prefer PascalCase for React components, camelCase for hooks and helpers, and colocated test files such as `page.test.tsx`.
- Go code follows standard Go conventions (gofmt, go vet). Use domain-oriented filenames like `issue.go` or `cmd_issue.go`.
- Do not hand-edit generated code in `server/pkg/db/generated/`.
- Keep comments in code **English only**.
- Prefer existing patterns/components over introducing parallel abstractions.
- Unless the user explicitly asks for backwards compatibility, do **not** add compatibility layers, fallback paths, dual-write logic, legacy adapters, or temporary shims.
- If a flow or API is being replaced and the product is not yet live, prefer removing the old path instead of preserving both old and new behavior.
- Treat compatibility code as a maintenance cost, not a default safety mechanism. Avoid "just in case" branches that make the codebase harder to reason about.
- Avoid broad refactors unless required by the task.
## 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.
- 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.
- 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.
## Testing Rules
- **TypeScript**: Vitest with Testing Library. Shared test setup lives in `apps/web/test/`. Mock external/third-party dependencies only.
- **Go**: Standard `go test`. Tests should create their own fixture data in a test database.
- End-to-end tests live in `e2e/*.spec.ts`; `make check` will start missing services automatically, while direct Playwright runs expect the app to already be running.
- Add or update tests whenever you change handlers, CLI commands, daemon behavior, or SQL-backed flows.
## Commit & Pull Request Rules
- Use atomic commits grouped by logical intent.
- Conventional format with scopes:
- `feat(web): ...`, `feat(cli): ...`
- `fix(web): ...`, `fix(cli): ...`
- `refactor(daemon): ...`
- `test(cli): ...`
- `docs: ...`
- `chore(scope): ...`
- Keep PRs focused and include a short description, linked issue or PR number when relevant, screenshots for UI work, and notes for migrations, env changes, or CLI surface changes.
- Before opening a PR, run `make check` or the relevant frontend/backend subset.
## Minimum Pre-Push Checks
```bash
make check # Runs all checks: typecheck, unit tests, Go tests, E2E
```
Run verification only when the user explicitly asks for it.
For targeted checks when requested:
```bash
pnpm typecheck # TypeScript type errors only
pnpm test # TS unit tests only (Vitest)
make test # Go tests only
pnpm exec playwright test # E2E only (requires backend + frontend running)
```
## AI Agent Verification Loop
After writing or modifying code, always run the full verification pipeline:
```bash
make check
```
This runs all checks in sequence:
1. TypeScript typecheck (`pnpm typecheck`)
2. TypeScript unit tests (`pnpm test`)
3. Go tests (`go test ./...`)
4. E2E tests (auto-starts backend + frontend if needed, runs Playwright)
**Workflow:**
- Write code to satisfy the requirement
- Run `make check`
- If any step fails, read the error output, fix the code, and re-run `make check`
- Repeat until all checks pass
- Only then consider the task complete
**Quick iteration:** If you know only TypeScript or Go is affected, run individual checks first for faster feedback, then finish with a full `make check` before marking work complete.
## E2E Test Patterns
E2E tests should be self-contained. Use the `TestApiClient` fixture for data setup/teardown:
```typescript
import { loginAsDefault, createTestApi } from "./helpers";
import type { TestApiClient } from "./fixtures";
let api: TestApiClient;
test.beforeEach(async ({ page }) => {
api = await createTestApi(); // logged-in API client
await loginAsDefault(page); // browser session
});
test.afterEach(async () => {
await api.cleanup(); // delete any data created during the test
});
test("example", async ({ page }) => {
const issue = await api.createIssue("Test Issue"); // create via API
await page.goto(`/issues/${issue.id}`); // test via UI
// api.cleanup() in afterEach removes the issue
});
```
## Commit & Pull Request Guidelines
Recent history follows conventional commits with scopes, for example `feat(web): ...`, `fix(cli): ...`, `refactor(daemon): ...`, `test(cli): ...`, and `docs: ...`. Keep PRs focused and include a short description, linked issue or PR number when relevant, screenshots for UI work, and notes for migrations, env changes, or CLI surface changes. Before opening a PR, run `make check` or the relevant frontend/backend subset.

View File

@@ -24,94 +24,65 @@ The frontend uses a **feature-based architecture** with four layers:
```
apps/web/
├── app/ # Routing layer (thin shells — import from features/)
├── core/ # Headless business logic (TanStack Query, zero JSX, zero react-dom)
├── features/ # UI business components, organized by domain
├── features/ # Business logic, organized by domain
├── shared/ # Cross-feature utilities (api client, types, logger)
```
**`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/`.
**`core/`** — Headless business logic. Query key factories, `queryOptions`, mutation hooks, WS cache updaters. **No JSX, no react-dom.** Designed for future extraction to `packages/core/` in a monorepo.
| Module | Purpose | Key exports |
|---|---|---|
| `core/issues/` | Issue queries, mutations, WS updaters | `issueListOptions`, `useUpdateIssue`, `onIssueUpdated` |
| `core/inbox/` | Inbox queries, mutations, WS updaters | `inboxListOptions`, `useMarkInboxRead` |
| `core/workspace/` | Member/agent/skill queries, workspace mutations | `memberListOptions`, `agentListOptions` |
| `core/runtimes/` | Runtime queries | `runtimeListOptions` |
| `core/query-client.ts` | QueryClient factory | `createQueryClient` |
| `core/provider.tsx` | QueryClientProvider wrapper | `QueryProvider` |
| `core/hooks.ts` | Shared hooks | `useWorkspaceId` |
**`features/`** — Domain modules with UI components, client-only stores, and config:
**`features/`** — Domain modules, each with its own components, hooks, stores, and config:
| Feature | Purpose | Exports |
|---|---|---|
| `features/auth/` | Authentication state | `useAuthStore`, `AuthInitializer` |
| `features/workspace/` | Workspace identity + UI | `useWorkspaceStore` (client-only: workspace/workspaces), `useActorName` |
| `features/issues/` | Issue UI components + client state | `useIssueStore` (client-only: activeIssueId), icons, pickers, config |
| `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 (will migrate to `core/` in Phase 5):
**`shared/`** — Code used across multiple features:
- `shared/api/``ApiClient` (REST) and `WSClient` (WebSocket) for backend communication, plus the `api` singleton.
- `shared/types/` — Domain types (Issue, Agent, Workspace, etc.) and WebSocket event types.
- `shared/logger.ts` — Logger utility.
### State Management
- **TanStack Query** for all server state — issues, inbox, members, agents, skills, runtimes. Query definitions live in `core/<domain>/queries.ts`, mutations in `core/<domain>/mutations.ts`.
- **Zustand** for client-only state — UI selections (`activeIssueId`), view filters, modal state, workspace identity, navigation. No API calls in Zustand stores.
- **Zustand** for global client state — one store per feature domain (`features/auth/store.ts`, `features/workspace/store.ts`, `features/issues/store.ts`, `features/inbox/store.ts`).
- **React Context** only for connection lifecycle (`WSProvider` in `features/realtime/`).
- **Local `useState`** for component-scoped UI state (forms, modals, filters).
- Do not use React Context for data that can be a zustand store.
**TanStack Query conventions:**
- `staleTime: Infinity` — WS events handle cache freshness, no polling or refetch-on-focus.
- WS events trigger `queryClient.invalidateQueries()` (preferred) or `queryClient.setQueryData()` for granular updates.
- All workspace-scoped query keys include `wsId` — workspace switch automatically uses new cache.
- Mutations use `onMutate` for optimistic updates + `onError` for rollback + `onSettled` for invalidation.
- Components access QueryClient via `useQueryClient()` hook. Non-React contexts (e.g. Tiptap plugin callbacks) receive QueryClient via closure from the parent React component — never use module-level singletons.
**Zustand store conventions:**
- Stores hold only client state (UI selections, persisted preferences). Zero `api.*` calls in stores.
- Import via `useAuthStore(selector)` or `useWorkspaceStore(selector)`.
**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.
- `useWorkspaceStore` manages workspace identity (`workspace`, `workspaces`, `api.setWorkspaceId`, localStorage). Server data (members, agents, skills) is in TanStack Query, not the store.
- Cross-store reads use `useOtherStore.getState()` inside actions (not hooks).
- Dependency direction: `workspace``auth`, `realtime``auth`, `issues``workspace`. Never reverse.
### Import Aliases
Use `@/` alias (maps to `apps/web/`) and `@core/` alias (maps to `apps/web/core/`):
Use `@/` alias (maps to `apps/web/`):
```typescript
// Core (headless business logic)
import { issueListOptions, issueKeys } from "@core/issues/queries";
import { useUpdateIssue, useCreateIssue } from "@core/issues/mutations";
import { memberListOptions, agentListOptions } from "@core/workspace/queries";
import { useWorkspaceId } from "@core/hooks";
// Shared (api client, types)
import { api } from "@/shared/api";
import type { Issue } from "@/shared/types";
// Features (UI components, client stores)
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";
```
Within a feature, use relative imports. Between features or to shared, use `@/`. For core modules, use `@core/`.
Within a feature, use relative imports. Between features or to shared, use `@/`.
### Data Flow
```
Browser → useQuery (core/) → ApiClient (shared/api) → REST API (Chi handlers) → sqlc queries → PostgreSQL
Browser ← useQuery cache ← invalidateQueries ← WS event handlers ← WSClient ← Hub.Broadcast()
Browser → ApiClient (shared/api) → REST API (Chi handlers) → sqlc queries → PostgreSQL
Browser ← WSClient (shared/api) ← WebSocket ← Hub.Broadcast() ← Handlers/TaskService
```
Mutations: `useMutation (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`
@@ -178,7 +149,7 @@ make db-down # Stop shared PostgreSQL
### CI Requirements
CI runs on Node 22 and Go 1.26.1 with a `pgvector/pgvector:pg17` PostgreSQL service. See `.github/workflows/ci.yml`.
CI runs on Node 22 and Go 1.24 with a `pgvector/pgvector:pg17` PostgreSQL service. See `.github/workflows/ci.yml`.
### Worktree Support
@@ -206,7 +177,7 @@ make start-worktree # Start using .env.worktree
- Prefer shadcn components over custom implementations. Install missing components via `npx shadcn add`.
- **Feature-specific components** → `features/<domain>/components/` — issue icons, pickers, and other domain-bound UI live inside their feature module.
- Use shadcn design tokens for styling (e.g. `bg-primary`, `text-muted-foreground`, `text-destructive`). Avoid hardcoded color values (e.g. `text-red-500`, `bg-gray-100`).
- Do not introduce extra state (useState, context, reducers) unless explicitly required by the design. Server data goes through TanStack Query (`core/`), client-only shared state through Zustand, React Context only for connection lifecycle.
- Do not introduce extra state (useState, context, reducers) unless explicitly required by the design. Prefer zustand stores for shared state over React Context.
- Pay close attention to **overflow** (truncate long text, scrollable containers), **alignment**, and **spacing** consistency.
- When unsure about interaction or state design, ask — the user will provide direction.
@@ -226,16 +197,6 @@ make start-worktree # Start using .env.worktree
- `test(scope): ...`
- `chore(scope): ...`
## CLI Release
**Prerequisite:** A CLI release must accompany every Production deployment. When deploying to Production, always release a new CLI version as part of the process.
1. Create a tag on the `main` branch: `git tag v0.x.x`
2. Push the tag: `git push origin v0.x.x`
3. GitHub Actions automatically triggers `release.yml`: runs Go tests → GoReleaser builds multi-platform binaries → publishes to GitHub Releases + Homebrew tap
By default, bump the patch version each release (e.g. `v0.1.12``v0.1.13`), unless the user specifies a specific version.
## Minimum Pre-Push Checks
```bash

View File

@@ -1,345 +0,0 @@
# CLI and Agent Daemon Guide
The `multica` CLI connects your local machine to Multica. It handles authentication, workspace management, issue tracking, and runs the agent daemon that executes AI tasks locally.
## Installation
### Homebrew (macOS/Linux)
```bash
brew tap multica-ai/tap
brew install multica
```
### Build from Source
```bash
git clone https://github.com/multica-ai/multica.git
cd multica
make build
cp server/bin/multica /usr/local/bin/multica
```
### Update
```bash
multica update
```
This auto-detects your installation method (Homebrew or manual) and upgrades accordingly.
## Quick Start
```bash
# 1. Authenticate (opens browser for login)
multica login
# 2. Start the agent daemon
multica daemon start
# 3. Done — agents in your watched workspaces can now execute tasks on your machine
```
`multica login` automatically discovers all workspaces you belong to and adds them to the daemon watch list.
## Authentication
### Browser Login
```bash
multica login
```
Opens your browser for OAuth authentication, creates a 90-day personal access token, and auto-configures your workspaces.
### Token Login
```bash
multica login --token
```
Authenticate by pasting a personal access token directly. Useful for headless environments.
### Check Status
```bash
multica auth status
```
Shows your current server, user, and token validity.
### Logout
```bash
multica auth logout
```
Removes the stored authentication token.
## Agent Daemon
The daemon is the local agent runtime. It detects available AI CLIs on your machine, registers them with the Multica server, and executes tasks when agents are assigned work.
### Start
```bash
multica daemon start
```
By default, the daemon runs in the background and logs to `~/.multica/daemon.log`.
To run in the foreground (useful for debugging):
```bash
multica daemon start --foreground
```
### Stop
```bash
multica daemon stop
```
### Status
```bash
multica daemon status
multica daemon status --output json
```
Shows PID, uptime, detected agents, and watched workspaces.
### Logs
```bash
multica daemon logs # Last 50 lines
multica daemon logs -f # Follow (tail -f)
multica daemon logs -n 100 # Last 100 lines
```
### Supported Agents
The daemon auto-detects these AI CLIs on your PATH:
| CLI | Command | Description |
|-----|---------|-------------|
| [Claude Code](https://docs.anthropic.com/en/docs/claude-code) | `claude` | Anthropic's coding agent |
| [Codex](https://github.com/openai/codex) | `codex` | OpenAI's coding agent |
You need at least one installed. The daemon registers each detected CLI as an available runtime.
### How It Works
1. On start, the daemon detects installed agent CLIs and registers a runtime for each agent in each watched workspace
2. It polls the server at a configurable interval (default: 3s) for claimed tasks
3. When a task arrives, it creates an isolated workspace directory, spawns the agent CLI, and streams results back
4. Heartbeats are sent periodically (default: 15s) so the server knows the daemon is alive
5. On shutdown, all runtimes are deregistered
### Configuration
Daemon behavior is configured via flags or environment variables:
| Setting | Flag | Env Variable | Default |
|---------|------|--------------|---------|
| Poll interval | `--poll-interval` | `MULTICA_DAEMON_POLL_INTERVAL` | `3s` |
| Heartbeat interval | `--heartbeat-interval` | `MULTICA_DAEMON_HEARTBEAT_INTERVAL` | `15s` |
| Agent timeout | `--agent-timeout` | `MULTICA_AGENT_TIMEOUT` | `2h` |
| Max concurrent tasks | `--max-concurrent-tasks` | `MULTICA_DAEMON_MAX_CONCURRENT_TASKS` | `20` |
| Daemon ID | `--daemon-id` | `MULTICA_DAEMON_ID` | hostname |
| Device name | `--device-name` | `MULTICA_DAEMON_DEVICE_NAME` | hostname |
| Runtime name | `--runtime-name` | `MULTICA_AGENT_RUNTIME_NAME` | `Local Agent` |
| Workspaces root | — | `MULTICA_WORKSPACES_ROOT` | `~/multica_workspaces` |
Agent-specific overrides:
| Variable | Description |
|----------|-------------|
| `MULTICA_CLAUDE_PATH` | Custom path to the `claude` binary |
| `MULTICA_CLAUDE_MODEL` | Override the Claude model used |
| `MULTICA_CODEX_PATH` | Custom path to the `codex` binary |
| `MULTICA_CODEX_MODEL` | Override the Codex model used |
### Self-Hosted Server
When connecting to a self-hosted Multica instance, point the CLI to your server before logging in:
```bash
export MULTICA_APP_URL=https://app.example.com
export MULTICA_SERVER_URL=wss://api.example.com/ws
multica login
multica daemon start
```
Or set them persistently:
```bash
multica config set app_url https://app.example.com
multica config set server_url wss://api.example.com/ws
```
### Profiles
Profiles let you run multiple daemons on the same machine — for example, one for production and one for a staging server.
```bash
# Start a daemon for the staging server
multica --profile staging login
multica --profile staging daemon start
# Default profile runs separately
multica daemon start
```
Each profile gets its own config directory (`~/.multica/profiles/<name>/`), daemon state, health port, and workspace root.
## Workspaces
### List Workspaces
```bash
multica workspace list
```
Watched workspaces are marked with `*`. The daemon only processes tasks for watched workspaces.
### Watch / Unwatch
```bash
multica workspace watch <workspace-id>
multica workspace unwatch <workspace-id>
```
### Get Details
```bash
multica workspace get <workspace-id>
multica workspace get <workspace-id> --output json
```
### List Members
```bash
multica workspace members <workspace-id>
```
## Issues
### List Issues
```bash
multica issue list
multica issue list --status in_progress
multica issue list --priority urgent --assignee "Agent Name"
multica issue list --limit 20 --output json
```
Available filters: `--status`, `--priority`, `--assignee`, `--limit`.
### Get Issue
```bash
multica issue get <id>
multica issue get <id> --output json
```
### Create Issue
```bash
multica issue create --title "Fix login bug" --description "..." --priority high --assignee "Lambda"
```
Flags: `--title` (required), `--description`, `--status`, `--priority`, `--assignee`, `--parent`, `--due-date`.
### Update Issue
```bash
multica issue update <id> --title "New title" --priority urgent
```
### Assign Issue
```bash
multica issue assign <id> --to "Lambda"
multica issue assign <id> --unassign
```
### Change Status
```bash
multica issue status <id> in_progress
```
Valid statuses: `backlog`, `todo`, `in_progress`, `in_review`, `done`, `blocked`, `cancelled`.
### Comments
```bash
# List comments
multica issue comment list <issue-id>
# Add a comment
multica issue comment add <issue-id> --content "Looks good, merging now"
# Reply to a specific comment
multica issue comment add <issue-id> --parent <comment-id> --content "Thanks!"
# Delete a comment
multica issue comment delete <comment-id>
```
### Execution History
```bash
# List all execution runs for an issue
multica issue runs <issue-id>
multica issue runs <issue-id> --output json
# View messages for a specific execution run
multica issue run-messages <task-id>
multica issue run-messages <task-id> --output json
# Incremental fetch (only messages after a given sequence number)
multica issue run-messages <task-id> --since 42 --output json
```
The `runs` command shows all past and current executions for an issue, including running tasks. The `run-messages` command shows the detailed message log (tool calls, thinking, text, errors) for a single run. Use `--since` for efficient polling of in-progress runs.
## Configuration
### View Config
```bash
multica config show
```
Shows config file path, server URL, app URL, and default workspace.
### Set Values
```bash
multica config set server_url wss://api.example.com/ws
multica config set app_url https://app.example.com
multica config set workspace_id <workspace-id>
```
## Other Commands
```bash
multica version # Show CLI version and commit hash
multica update # Update to latest version
multica agent list # List agents in the current workspace
```
## Output Formats
Most commands support `--output` with two formats:
- `table` — human-readable table (default for list commands)
- `json` — structured JSON (useful for scripting and automation)
```bash
multica issue list --output json
multica daemon status --output json
```

View File

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

199
LICENSE
View File

@@ -1,199 +0,0 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the 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.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"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.
"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).
"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.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to the Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by the Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding any notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. Please also get an
"Implied Patent License" from your patent counsel.
Copyright 2025 Multica
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

View File

@@ -1,6 +1,6 @@
# Contributing Guide
# Local Development Guide
This guide documents the local development workflow for contributors working on the Multica codebase.
This guide documents the intended local development workflow for Multica.
It covers:

View File

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

339
README.md
View File

@@ -1,162 +1,237 @@
<p align="center">
<img src="docs/assets/banner.jpg" alt="Multica — humans and agents, side by side" width="100%">
</p>
<div align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="docs/assets/logo-dark.svg">
<source media="(prefers-color-scheme: light)" srcset="docs/assets/logo-light.svg">
<img alt="Multica" src="docs/assets/logo-light.svg" width="50">
</picture>
# Multica
**Your next 10 hires won't be human.**
AI-native task management platform — like Linear, but with AI agents as first-class citizens.
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.
For the full local development workflow, see [Local Development Guide](LOCAL_DEVELOPMENT.md).
[![CI](https://github.com/multica-ai/multica/actions/workflows/ci.yml/badge.svg)](https://github.com/multica-ai/multica/actions/workflows/ci.yml)
[![License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](https://opensource.org/licenses/Apache-2.0)
[![GitHub stars](https://img.shields.io/github/stars/multica-ai/multica?style=flat)](https://github.com/multica-ai/multica/stargazers)
## Prerequisites
[Website](https://multica.ai) · [Cloud](https://multica.ai/app) · [Self-Hosting](SELF_HOSTING.md) · [Contributing](CONTRIBUTING.md)
- [Node.js](https://nodejs.org/) (v20+)
- [pnpm](https://pnpm.io/) (v10.28+)
- [Go](https://go.dev/) (v1.26+)
- [Docker](https://www.docker.com/)
**English | [简体中文](README.zh-CN.md)**
## Quick Start
</div>
## What is Multica?
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**.
<p align="center">
<img src="docs/assets/hero-screenshot.png" alt="Multica board view" width="800">
</p>
## Features
- **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.
- **Unified Runtimes** — one dashboard for all your compute. Local daemons and cloud runtimes, auto-detection of available CLIs, real-time monitoring.
- **Multi-Workspace** — organize work across teams with workspace-level isolation. Each workspace has its own agents, issues, and settings.
## Getting Started
### Multica Cloud
The fastest way to get started — no setup required: **[multica.ai](https://multica.ai)**
### Self-Host with Docker
### Using Multica (CLI)
```bash
git clone https://github.com/multica-ai/multica.git
cd multica
cp .env.example .env
# Edit .env — at minimum, change JWT_SECRET
docker compose up -d # Start PostgreSQL
cd server && go run ./cmd/migrate up && cd .. # Run migrations
make start # Start the app
```
See the [Self-Hosting Guide](SELF_HOSTING.md) for full instructions.
## CLI
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
# 1. Install the CLI
brew tap multica-ai/tap
brew install multica
brew install multica-cli
# Authenticate and start
# 2. Login and auto-watch your workspaces
multica login
# 3. Start the local agent daemon
multica daemon start
```
The daemon auto-detects available agent CLIs (`claude`, `codex`) on your PATH. When an agent is assigned a task, the daemon creates an isolated environment, runs the agent, and reports results back.
The daemon auto-detects available agent CLIs (`claude`, `codex`) on your PATH and begins polling your watched workspaces for tasks.
See the [CLI and Daemon Guide](CLI_AND_DAEMON.md) for the full command reference, daemon configuration, and advanced usage.
## Quickstart
Once you have the CLI installed (or signed up for [Multica Cloud](https://multica.ai)), follow these steps to assign your first task to an agent:
### 1. Log in and start the daemon
Manage which workspaces the daemon monitors:
```bash
multica login # Authenticate with your Multica account
multica daemon start # Start the local agent runtime
multica workspace list # List all workspaces (* = watched)
multica workspace watch <workspace-id> # Add a workspace to the watch list
multica workspace unwatch <workspace-id> # Remove a workspace from the watch list
```
The daemon runs in the background and keeps your machine connected to Multica. It auto-detects agent CLIs (`claude`, `codex`) available on your PATH.
### 2. Verify your runtime
Open your workspace in the Multica web app. Navigate to **Settings → Runtimes** — you should see your machine listed as an active **Runtime**.
> **What is a Runtime?** A Runtime is a compute environment that can execute agent tasks. It can be your local machine (via the daemon) or a cloud instance. Each runtime reports which agent CLIs are available, so Multica knows where to route work.
### 3. Create an agent
Go to **Settings → Agents** and click **New Agent**. Pick the runtime you just connected and choose a provider (Claude Code or Codex). Give your agent a name — this is how it will appear on the board, in comments, and in assignments.
### 4. Assign your first task
Create an issue from the board (or via `multica issue create`), then assign it to your new agent. The agent will automatically pick up the task, execute it on your runtime, and report progress — just like a human teammate.
That's it! Your agent is now part of the team. 🎉
## Architecture
```
┌──────────────┐ ┌──────────────┐ ┌──────────────────┐
│ Next.js │────>│ Go Backend │────>│ PostgreSQL │
│ Frontend │<────│ (Chi + WS) │<────│ (pgvector) │
└──────────────┘ └──────┬───────┘ └──────────────────┘
┌──────┴───────┐
│ Agent Daemon │ (runs on your machine)
│ Claude/Codex │
└──────────────┘
```
| Layer | Stack |
|-------|-------|
| Frontend | Next.js 16 (App Router) |
| Backend | Go (Chi router, sqlc, gorilla/websocket) |
| Database | PostgreSQL 17 with pgvector |
| Agent Runtime | Local daemon executing Claude Code or Codex |
## Development
For contributors working on the Multica codebase, see the [Contributing Guide](CONTRIBUTING.md).
**Prerequisites:** [Node.js](https://nodejs.org/) v20+, [pnpm](https://pnpm.io/) v10.28+, [Go](https://go.dev/) v1.26+, [Docker](https://www.docker.com/)
### Local Development
```bash
# 1. Install dependencies
pnpm install
# 2. Copy environment variables for the shared main environment
cp .env.example .env
# 3. One-time setup: ensure shared PostgreSQL, create the app DB, run migrations
make setup
# 4. Start backend + frontend
make start
```
See [CONTRIBUTING.md](CONTRIBUTING.md) for the full development workflow, worktree support, testing, and troubleshooting.
Open your configured `FRONTEND_ORIGIN` in the browser. By default that is [http://localhost:3000](http://localhost:3000).
## License
Main checkout uses `.env`. A Git worktree should generate its own `.env.worktree` and use the explicit worktree targets:
[Apache 2.0](LICENSE)
```bash
make worktree-env
make setup-worktree
make start-worktree
```
Every checkout shares the same PostgreSQL container on `localhost:5432`. Isolation now happens at the database level:
- `.env` typically uses `POSTGRES_DB=multica`
- each `.env.worktree` gets its own `POSTGRES_DB`, such as `multica_my_feature_702`
- backend/frontend ports still stay unique per worktree
That keeps one Docker container and one volume, while still isolating schema and data per worktree.
## Project Structure
```
├── server/ # Go backend (Chi + sqlc + gorilla/websocket)
│ ├── cmd/ # server, daemon, migrate
│ ├── internal/ # Core business logic
│ ├── migrations/ # SQL migrations
│ └── sqlc.yaml # sqlc config
├── apps/
│ └── web/ # Next.js 16 frontend
├── packages/ # Shared TypeScript packages
│ ├── ui/ # Component library (shadcn/ui + Radix)
│ ├── types/ # Shared type definitions
│ ├── sdk/ # API client SDK
│ ├── store/ # State management
│ ├── hooks/ # Shared React hooks
│ └── utils/ # Utility functions
├── Makefile # Backend commands
├── docker-compose.yml # PostgreSQL + pgvector
└── .env.example # Environment variable template
```
## Commands
### Frontend
| Command | Description |
|---------|-------------|
| `pnpm dev:web` | Start Next.js dev server (uses `FRONTEND_PORT`, default `3000`) |
| `pnpm build` | Build all TypeScript packages |
| `pnpm typecheck` | Run TypeScript type checking |
| `pnpm test` | Run TypeScript tests |
### Backend
| Command | Description |
|---------|-------------|
| `make dev` | Run Go server (uses `PORT`, default `8080`) |
| `make daemon` | Run local agent daemon |
| `make multica ARGS="version"` | Run the local `multica` CLI without installing it |
| `make test` | Run Go tests |
| `make build` | Build server & daemon binaries |
| `make sqlc` | Regenerate sqlc code from SQL |
### Database
| Command | Description |
|---------|-------------|
| `make db-up` | Start the shared PostgreSQL container |
| `make db-down` | Stop the shared PostgreSQL container |
| `make migrate-up` | Ensure the current DB exists, then run migrations |
| `make migrate-down` | Rollback database migrations for the current DB |
| `make worktree-env` | Generate an isolated `.env.worktree` for the current worktree |
| `make setup-main` / `make start-main` | Force use of the shared main `.env` |
| `make setup-worktree` / `make start-worktree` | Force use of isolated `.env.worktree` |
## CLI (`multica`)
The CLI manages authentication, workspace configuration, and the local agent daemon.
### Install
```bash
brew tap multica-ai/tap
brew install multica-cli
```
Or build from source:
```bash
make build
cp server/bin/multica /usr/local/bin/multica # or ~/.local/bin/multica
```
For local development, you can also run the CLI directly from the repo:
```bash
make multica ARGS="version"
make multica ARGS="auth status"
```
For browser-based auth from source, make sure the local frontend is running at `FRONTEND_ORIGIN` first, for example with `make start`, `make start-main`, or `make start-worktree`.
### Authentication
```bash
multica login # Authenticate and auto-watch your workspaces
multica auth login # Legacy auth-only flow
multica auth login --token # Legacy token-only auth flow
multica auth status # Show current auth status
multica auth logout # Remove stored token
```
Credentials are saved to `~/.multica/config.json`.
### Workspaces
```bash
multica workspace list # List all workspaces you belong to
multica workspace get # Show the current workspace details/context
```
### Daemon Watch List
The daemon monitors one or more workspaces for tasks. Manage which workspaces are watched:
```bash
multica workspace watch <workspace-id> # Add a workspace to the watch list
multica workspace unwatch <workspace-id> # Remove a workspace from the watch list
multica workspace list # Show all workspaces (watched ones marked with *)
```
The watch list is stored in `~/.multica/config.json`. Changes are picked up by a running daemon within 5 seconds (hot-reload).
### Local Agent Daemon
The daemon polls watched workspaces for tasks and executes them using locally installed AI agents (Claude Code, Codex).
```bash
# 1. Authenticate
multica login
# 2. Add workspaces to watch
multica workspace watch <workspace-id>
# 3. Start the daemon
multica daemon start
```
The daemon auto-detects available agent CLIs (`claude`, `codex`) on your PATH. When a task is claimed, it creates an isolated execution environment, runs the agent, and reports results back to the server.
### Other Commands
```bash
multica agent list # List agents in the current workspace
multica daemon status # Show local daemon status
multica config # Show CLI configuration
multica config show # Compatibility alias for config display
multica version # Show CLI version
```
## Environment Variables
See [`.env.example`](.env.example) for all available variables:
- `DATABASE_URL` — PostgreSQL connection string
- `POSTGRES_DB` — Database name for the current checkout or worktree
- `POSTGRES_PORT` — Shared PostgreSQL host port (fixed to `5432`)
- `PORT` — Backend server port (default: 8080)
- `FRONTEND_PORT` / `FRONTEND_ORIGIN` — Frontend port and browser origin
- `JWT_SECRET` — JWT signing secret
- `MULTICA_APP_URL` — Browser origin for CLI login callback (default: `http://localhost:3000`)
- `MULTICA_DAEMON_ID` / `MULTICA_DAEMON_DEVICE_NAME` — Stable daemon identity for runtime registration
- `MULTICA_CLAUDE_PATH` / `MULTICA_CLAUDE_MODEL` — Claude Code executable and optional model override
- `MULTICA_CODEX_PATH` / `MULTICA_CODEX_MODEL` — Codex executable and optional model override
- `MULTICA_WORKSPACES_ROOT` — Base directory for agent execution environments (default: `~/multica_workspaces`)
- `NEXT_PUBLIC_API_URL` — Frontend → backend API URL
- `NEXT_PUBLIC_WS_URL` — Frontend → backend WebSocket URL
## Local Development Notes
- `make setup`, `make start`, `make dev`, and `make test` now require an env file. They fail fast if `.env` or `.env.worktree` is missing.
- `make stop` only stops the backend/frontend processes for the current checkout. It does not stop the shared PostgreSQL container.
- Use `make db-down` only when you explicitly want to shut down the shared local PostgreSQL instance for every checkout.

View File

@@ -1,162 +0,0 @@
<p align="center">
<img src="docs/assets/banner.jpg" alt="Multica — 人类与 AI并肩前行" width="100%">
</p>
<div align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="docs/assets/logo-dark.svg">
<source media="(prefers-color-scheme: light)" srcset="docs/assets/logo-light.svg">
<img alt="Multica" src="docs/assets/logo-light.svg" width="50">
</picture>
# Multica
**你的下一批员工,不是人类。**
开源平台,将编码 Agent 变成真正的队友。<br/>
分配任务、跟踪进度、积累技能——在一个地方管理你的人类 + Agent 团队。
[![CI](https://github.com/multica-ai/multica/actions/workflows/ci.yml/badge.svg)](https://github.com/multica-ai/multica/actions/workflows/ci.yml)
[![License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](https://opensource.org/licenses/Apache-2.0)
[![GitHub stars](https://img.shields.io/github/stars/multica-ai/multica?style=flat)](https://github.com/multica-ai/multica/stargazers)
[官网](https://multica.ai) · [云服务](https://multica.ai/app) · [自部署指南](SELF_HOSTING.md) · [参与贡献](CONTRIBUTING.md)
**[English](README.md) | 简体中文**
</div>
## Multica 是什么?
Multica 将编码 Agent 变成真正的队友。像分配给同事一样分配给 Agent——它们会自主接手工作、编写代码、报告阻塞问题、更新状态。
不再需要复制粘贴 prompt不再需要盯着运行过程。你的 Agent 出现在看板上、参与对话、随着时间积累可复用的技能。支持 **Claude Code****Codex**
<p align="center">
<img src="docs/assets/hero-screenshot.png" alt="Multica 看板视图" width="800">
</p>
## 功能特性
- **Agent 即队友** — 像分配给同事一样分配给 Agent。它们有个人档案、出现在看板上、发表评论、创建 Issue、主动报告阻塞问题。
- **自主执行** — 设置后无需管理。完整的任务生命周期管理(排队、认领、执行、完成/失败),通过 WebSocket 实时推送进度。
- **可复用技能** — 每个解决方案都成为全团队可复用的技能。部署、数据库迁移、代码审查——技能让团队能力随时间持续增长。
- **统一运行时** — 一个控制台管理所有算力。本地 daemon 和云端运行时,自动检测可用 CLI实时监控。
- **多工作区** — 按团队组织工作,工作区级别隔离。每个工作区有独立的 Agent、Issue 和设置。
## 快速开始
### Multica 云服务
最快的上手方式,无需任何配置:**[multica.ai](https://multica.ai)**
### Docker 自部署
```bash
git clone https://github.com/multica-ai/multica.git
cd multica
cp .env.example .env
# 编辑 .env — 至少修改 JWT_SECRET
docker compose up -d # 启动 PostgreSQL
cd server && go run ./cmd/migrate up && cd .. # 运行数据库迁移
make start # 启动应用
```
完整部署文档请参阅 [自部署指南](SELF_HOSTING.md)。
## CLI
`multica` CLI 将你的本地机器连接到 Multica — 用于认证、管理工作区和运行 Agent daemon。
**方式 A — 将以下指令粘贴给你的 coding agentClaude Code、Codex 等):**
```
Fetch https://github.com/multica-ai/multica/blob/main/CLI_INSTALL.md and follow the instructions to install Multica CLI, log in, and start the daemon on this machine.
```
**方式 B — 手动安装:**
```bash
# 安装
brew tap multica-ai/tap
brew install multica
# 认证并启动
multica login
multica daemon start
```
daemon 会自动检测 PATH 中可用的 Agent CLI`claude``codex`)。当 Agent 被分配任务时daemon 会创建隔离环境、运行 Agent、并将结果回传。
完整命令参考请参阅 [CLI 与 Daemon 指南](CLI_AND_DAEMON.md)。
## 快速上手
安装好 CLI或注册 [Multica 云服务](https://multica.ai))后,按以下步骤将第一个任务分配给 Agent
### 1. 登录并启动 daemon
```bash
multica login # 使用你的 Multica 账号认证
multica daemon start # 启动本地 Agent 运行时
```
daemon 在后台运行,保持你的机器与 Multica 的连接。它会自动检测 PATH 中可用的 Agent CLI`claude``codex`)。
### 2. 确认运行时已连接
在 Multica Web 端打开你的工作区,进入 **设置 → 运行时Runtimes**,你应该能看到你的机器已作为一个活跃的 **Runtime** 出现在列表中。
> **什么是 Runtime运行时** Runtime 是可以执行 Agent 任务的计算环境。它可以是你的本地机器(通过 daemon 连接),也可以是云端实例。每个 Runtime 会上报可用的 Agent CLIMultica 据此决定将任务路由到哪里执行。
### 3. 创建 Agent
进入 **设置 → Agents**,点击 **新建 Agent**。选择你刚连接的 Runtime选择 ProviderClaude Code 或 Codex并为 Agent 起个名字——它将以这个名字出现在看板、评论和任务分配中。
### 4. 分配你的第一个任务
在看板上创建一个 Issue或通过 `multica issue create` 命令创建),然后将其分配给你的新 Agent。Agent 会自动接手任务、在你的 Runtime 上执行、并实时汇报进度——就像一个真正的队友一样。
大功告成!你的 Agent 现在是团队的一员了。 🎉
## 架构
```
┌──────────────┐ ┌──────────────┐ ┌──────────────────┐
│ Next.js │────>│ Go 后端 │────>│ PostgreSQL │
│ 前端 │<────│ (Chi + WS) │<────│ (pgvector) │
└──────────────┘ └──────┬───────┘ └──────────────────┘
┌──────┴───────┐
│ Agent Daemon │ (运行在你的机器上)
│ Claude/Codex │
└──────────────┘
```
| 层级 | 技术栈 |
|------|--------|
| 前端 | Next.js 16 (App Router) |
| 后端 | Go (Chi router, sqlc, gorilla/websocket) |
| 数据库 | PostgreSQL 17 with pgvector |
| Agent 运行时 | 本地 daemon 执行 Claude Code 或 Codex |
## 开发
参与 Multica 代码贡献,请参阅 [贡献指南](CONTRIBUTING.md)。
**环境要求:** [Node.js](https://nodejs.org/) v20+, [pnpm](https://pnpm.io/) v10.28+, [Go](https://go.dev/) v1.26+, [Docker](https://www.docker.com/)
```bash
pnpm install
cp .env.example .env
make setup
make start
```
完整的开发流程、worktree 支持、测试和问题排查请参阅 [CONTRIBUTING.md](CONTRIBUTING.md)。
## 开源协议
[Apache 2.0](LICENSE)

View File

@@ -1,286 +0,0 @@
# Self-Hosting Guide
This guide walks you through deploying Multica on your own infrastructure.
## Architecture Overview
Multica has three components:
| Component | Description | Technology |
|-----------|-------------|------------|
| **Backend** | REST API + WebSocket server | Go (single binary) |
| **Frontend** | Web application | Next.js 16 |
| **Database** | Primary data store | PostgreSQL 17 with pgvector |
Additionally, each user who wants to run AI agents locally installs the **`multica` CLI** and runs the **agent daemon** on their own machine.
## Prerequisites
- Docker and Docker Compose (recommended), or:
- Go 1.26+ (to build from source)
- Node.js 20+ and pnpm 10.28+ (to build the frontend)
- PostgreSQL 17 with the pgvector extension
## Quick Start (Docker Compose)
```bash
git clone https://github.com/multica-ai/multica.git
cd multica
cp .env.example .env
```
Edit `.env` with your production values (see [Configuration](#configuration) below), then:
```bash
# Start PostgreSQL
docker compose up -d
# Build the backend
make build
# Run database migrations
DATABASE_URL="your-database-url" ./server/bin/migrate up
# Start the backend server
DATABASE_URL="your-database-url" PORT=8080 ./server/bin/server
```
For the frontend:
```bash
pnpm install
pnpm build
# Start the frontend (production mode)
cd apps/web
REMOTE_API_URL=http://localhost:8080 pnpm start
```
## Configuration
All configuration is done via environment variables. Copy `.env.example` as a starting point.
### Required Variables
| Variable | Description | Example |
|----------|-------------|---------|
| `DATABASE_URL` | PostgreSQL connection string | `postgres://multica:multica@localhost:5432/multica?sslmode=disable` |
| `JWT_SECRET` | **Must change from default.** Secret key for signing JWT tokens. Use a long random string. | `openssl rand -hex 32` |
| `FRONTEND_ORIGIN` | URL where the frontend is served (used for CORS) | `https://app.example.com` |
### Email (Required for Authentication)
Multica uses email-based magic link authentication via [Resend](https://resend.com).
| Variable | Description |
|----------|-------------|
| `RESEND_API_KEY` | Your Resend API key |
| `RESEND_FROM_EMAIL` | Sender email address (default: `noreply@multica.ai`) |
### Google OAuth (Optional)
| Variable | Description |
|----------|-------------|
| `GOOGLE_CLIENT_ID` | Google OAuth client ID |
| `GOOGLE_CLIENT_SECRET` | Google OAuth client secret |
| `GOOGLE_REDIRECT_URI` | OAuth callback URL (e.g. `https://app.example.com/auth/callback`) |
### File Storage (Optional)
For file uploads and attachments, configure S3 and CloudFront:
| Variable | Description |
|----------|-------------|
| `S3_BUCKET` | S3 bucket name |
| `S3_REGION` | AWS region (default: `us-west-2`) |
| `CLOUDFRONT_DOMAIN` | CloudFront distribution domain |
| `CLOUDFRONT_KEY_PAIR_ID` | CloudFront key pair ID for signed URLs |
| `CLOUDFRONT_PRIVATE_KEY` | CloudFront private key (PEM format) |
| `COOKIE_DOMAIN` | Domain for CloudFront auth cookies |
### Server
| Variable | Default | Description |
|----------|---------|-------------|
| `PORT` | `8080` | Backend server port |
| `FRONTEND_PORT` | `3000` | Frontend port |
| `CORS_ALLOWED_ORIGINS` | Value of `FRONTEND_ORIGIN` | Comma-separated list of allowed origins |
| `LOG_LEVEL` | `info` | Log level: `debug`, `info`, `warn`, `error` |
### CLI / Daemon
These are configured on each user's machine, not on the server:
| Variable | Default | Description |
|----------|---------|-------------|
| `MULTICA_SERVER_URL` | `ws://localhost:8080/ws` | WebSocket URL for daemon → server connection |
| `MULTICA_APP_URL` | `http://localhost:3000` | Frontend URL for CLI login flow |
| `MULTICA_DAEMON_POLL_INTERVAL` | `3s` | How often the daemon polls for tasks |
| `MULTICA_DAEMON_HEARTBEAT_INTERVAL` | `15s` | Heartbeat frequency |
## Database Setup
Multica requires PostgreSQL 17 with the pgvector extension.
### Using the Included Docker Compose
```bash
docker compose up -d postgres
```
This starts a `pgvector/pgvector:pg17` container on port 5432 with default credentials (`multica`/`multica`).
### Using Your Own PostgreSQL
Ensure the pgvector extension is available:
```sql
CREATE EXTENSION IF NOT EXISTS vector;
```
### Running Migrations
Migrations must be run before starting the server:
```bash
# Using the built binary
./server/bin/migrate up
# Or from source
cd server && go run ./cmd/migrate up
```
## Reverse Proxy
In production, put a reverse proxy in front of both the backend and frontend to handle TLS and routing.
### Caddy (Recommended)
```
app.example.com {
reverse_proxy localhost:3000
}
api.example.com {
reverse_proxy localhost:8080
}
```
### Nginx
```nginx
# Frontend
server {
listen 443 ssl;
server_name app.example.com;
ssl_certificate /path/to/cert.pem;
ssl_certificate_key /path/to/key.pem;
location / {
proxy_pass http://localhost:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
# Backend API
server {
listen 443 ssl;
server_name api.example.com;
ssl_certificate /path/to/cert.pem;
ssl_certificate_key /path/to/key.pem;
location / {
proxy_pass http://localhost:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# WebSocket support
location /ws {
proxy_pass http://localhost:8080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_read_timeout 86400;
}
}
```
When using separate domains for frontend and backend, set these environment variables accordingly:
```bash
# Backend
FRONTEND_ORIGIN=https://app.example.com
CORS_ALLOWED_ORIGINS=https://app.example.com
# Frontend
REMOTE_API_URL=https://api.example.com
NEXT_PUBLIC_API_URL=https://api.example.com
NEXT_PUBLIC_WS_URL=wss://api.example.com/ws
```
## Health Check
The backend exposes a health check endpoint:
```
GET /health
→ {"status":"ok"}
```
Use this for load balancer health checks or monitoring.
## Setting Up the Agent Daemon
Each team member who wants to run AI agents locally needs to:
1. **Install the CLI**
```bash
brew tap multica-ai/tap
brew install multica-cli
```
2. **Install an AI agent CLI** — at least one of:
- [Claude Code](https://docs.anthropic.com/en/docs/claude-code) (`claude` on PATH)
- [Codex](https://github.com/openai/codex) (`codex` on PATH)
3. **Authenticate and start**
```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
# Start the daemon
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
1. Pull the latest code or image
2. Run migrations: `./server/bin/migrate up`
3. Restart the backend and frontend
Migrations are forward-only and safe to run on a live database. They are idempotent — running them multiple times has no effect.

View File

@@ -50,7 +50,7 @@ describe("LoginPage", () => {
render(<LoginPage />);
expect(screen.getByText("Multica")).toBeInTheDocument();
expect(screen.getByText("Turn coding agents into real teammates")).toBeInTheDocument();
expect(screen.getByText("AI-native task management")).toBeInTheDocument();
expect(screen.getByLabelText("Email")).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "Continue" })

View File

@@ -2,7 +2,7 @@
import { Suspense, useState, useEffect, useCallback } from "react";
import { useSearchParams, useRouter } from "next/navigation";
import { useAuthStore, setLoggedInCookie } from "@/features/auth";
import { useAuthStore } from "@/features/auth";
import { useWorkspaceStore } from "@/features/workspace";
import { api } from "@/shared/api";
import {
@@ -46,20 +46,11 @@ function redirectToCliCallback(
function LoginPageContent() {
const router = useRouter();
const user = useAuthStore((s) => s.user);
const isLoading = useAuthStore((s) => s.isLoading);
const sendCode = useAuthStore((s) => s.sendCode);
const verifyCode = useAuthStore((s) => s.verifyCode);
const hydrateWorkspace = useWorkspaceStore((s) => s.hydrateWorkspace);
const searchParams = useSearchParams();
// Already authenticated — redirect to dashboard
useEffect(() => {
if (!isLoading && user && !searchParams.get("cli_callback")) {
router.replace(searchParams.get("next") || "/issues");
}
}, [isLoading, user, router, searchParams]);
const [step, setStep] = useState<"email" | "code" | "cli_confirm">("email");
const [email, setEmail] = useState("");
const [code, setCode] = useState("");
@@ -146,10 +137,6 @@ function LoginPageContent() {
return;
}
const { token } = await api.verifyCode(email, value);
// Persist session in the browser so the web app stays logged in
localStorage.setItem("multica_token", token);
api.setToken(token);
setLoggedInCookie();
const cliState = searchParams.get("cli_state") || "";
redirectToCliCallback(cliCallback, token, cliState);
return;
@@ -157,8 +144,7 @@ function LoginPageContent() {
await verifyCode(email, value);
const wsList = await api.listWorkspaces();
const lastWsId = localStorage.getItem("multica_workspace_id");
await hydrateWorkspace(wsList, lastWsId);
await hydrateWorkspace(wsList);
router.push(searchParams.get("next") || "/issues");
} catch (err) {
setError(
@@ -286,28 +272,12 @@ 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">
<CardHeader className="text-center">
<CardTitle className="text-2xl">Multica</CardTitle>
<CardDescription>Turn coding agents into real teammates</CardDescription>
<CardDescription>AI-native task management</CardDescription>
</CardHeader>
<CardContent>
<form id="login-form" onSubmit={handleSendCode} className="space-y-4">
@@ -327,7 +297,7 @@ function LoginPageContent() {
)}
</form>
</CardContent>
<CardFooter className="flex flex-col gap-3">
<CardFooter>
<Button
type="submit"
form="login-form"
@@ -337,46 +307,6 @@ function LoginPageContent() {
>
{submitting ? "Sending code..." : "Continue"}
</Button>
{googleClientId && (
<>
<div className="relative w-full">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-card px-2 text-muted-foreground">or</span>
</div>
</div>
<Button
type="button"
variant="outline"
className="w-full"
size="lg"
onClick={handleGoogleLogin}
disabled={submitting}
>
<svg className="mr-2 h-4 w-4" viewBox="0 0 24 24">
<path
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z"
fill="#4285F4"
/>
<path
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
fill="#34A853"
/>
<path
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
fill="#FBBC05"
/>
<path
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
fill="#EA4335"
/>
</svg>
Continue with Google
</Button>
</>
)}
</CardFooter>
</Card>
</div>

View File

@@ -1,6 +1,5 @@
"use client";
import React from "react";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import {
@@ -43,9 +42,7 @@ import {
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
import { useAuthStore } from "@/features/auth";
import { useWorkspaceStore } from "@/features/workspace";
import { useQuery } from "@tanstack/react-query";
import { inboxKeys, deduplicateInboxItems } from "@core/inbox/queries";
import { api } from "@/shared/api";
import { useInboxStore } from "@/features/inbox";
import { useModalStore } from "@/features/modals";
const primaryNav = [
@@ -76,21 +73,12 @@ export function AppSidebar() {
const workspaces = useWorkspaceStore((s) => s.workspaces);
const switchWorkspace = useWorkspaceStore((s) => s.switchWorkspace);
const wsId = workspace?.id;
const { data: inboxItems = [] } = useQuery({
queryKey: wsId ? inboxKeys.list(wsId) : ["inbox", "disabled"],
queryFn: () => api.listInbox(),
enabled: !!wsId,
});
const unreadCount = React.useMemo(
() => deduplicateInboxItems(inboxItems).filter((i) => !i.read).length,
[inboxItems],
);
const unreadCount = useInboxStore((s) => s.unreadCount());
const logout = () => {
router.push("/");
authLogout();
useWorkspaceStore.getState().clearWorkspace();
router.push("/login");
};
return (
@@ -144,7 +132,6 @@ export function AppSidebar() {
key={ws.id}
onClick={() => {
if (ws.id !== workspace?.id) {
router.push("/issues");
switchWorkspace(ws.id);
}
}}

File diff suppressed because it is too large Load Diff

View File

@@ -1,22 +1,8 @@
"use client";
import { useState, useEffect, useCallback, useMemo } from "react";
import { useSearchParams } from "next/navigation";
import { useDefaultLayout } from "react-resizable-panels";
import { useQuery } from "@tanstack/react-query";
import { useWorkspaceId } from "@core/hooks";
import {
inboxListOptions,
deduplicateInboxItems,
} from "@core/inbox/queries";
import {
useMarkInboxRead,
useArchiveInbox,
useMarkAllInboxRead,
useArchiveAllInbox,
useArchiveAllReadInbox,
useArchiveCompletedInbox,
} from "@core/inbox/mutations";
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";
@@ -46,6 +32,7 @@ import {
DropdownMenuItem,
DropdownMenuSeparator,
} from "@/components/ui/dropdown-menu";
import { api } from "@/shared/api";
// ---------------------------------------------------------------------------
// Helpers
@@ -232,24 +219,14 @@ function InboxListItem({
export default function InboxPage() {
const searchParams = useSearchParams();
const urlIssue = searchParams.get("issue") ?? "";
const [selectedKey, setSelectedKeyState] = useState(() => urlIssue);
// Sync from URL when searchParams change (e.g. Next.js navigation)
useEffect(() => {
setSelectedKeyState(urlIssue);
}, [urlIssue]);
const setSelectedKey = useCallback((key: string) => {
setSelectedKeyState(key);
const selectedKey = searchParams.get("issue") ?? "";
const setSelectedKey = (key: string) => {
const url = key ? `/inbox?issue=${key}` : "/inbox";
window.history.replaceState(null, "", url);
}, []);
};
const wsId = useWorkspaceId();
const { data: rawItems = [], isLoading: loading } = useQuery(inboxListOptions(wsId));
const items = useMemo(() => deduplicateInboxItems(rawItems), [rawItems]);
const items = useInboxStore((s) => s.dedupedItems());
const loading = useInboxStore((s) => s.loading);
const { defaultLayout, onLayoutChanged } = useDefaultLayout({
id: "multica_inbox_layout",
@@ -258,69 +235,85 @@ export default function InboxPage() {
const selected = items.find((i) => (i.issue_id ?? i.id) === selectedKey) ?? null;
const unreadCount = items.filter((i) => !i.read).length;
const markReadMutation = useMarkInboxRead();
const archiveMutation = useArchiveInbox();
const markAllReadMutation = useMarkAllInboxRead();
const archiveAllMutation = useArchiveAllInbox();
const archiveAllReadMutation = useArchiveAllReadInbox();
const archiveCompletedMutation = useArchiveCompletedInbox();
// Click-to-read: select + auto-mark-read
const handleSelect = (item: InboxItem) => {
const handleSelect = async (item: InboxItem) => {
setSelectedKey(item.issue_id ?? item.id);
if (!item.read) {
markReadMutation.mutate(item.id, {
onError: () => toast.error("Failed to mark as read"),
});
useInboxStore.getState().markRead(item.id);
try {
await api.markInboxRead(item.id);
} catch {
// Rollback: refetch to get server truth
useInboxStore.getState().fetch();
toast.error("Failed to mark as read");
}
}
};
const handleArchive = (id: string) => {
const archived = items.find((i) => i.id === id);
if (archived && (archived.issue_id ?? archived.id) === selectedKey) setSelectedKey("");
archiveMutation.mutate(id, {
onError: () => toast.error("Failed to archive"),
});
const handleArchive = async (id: string) => {
try {
await api.archiveInbox(id);
useInboxStore.getState().archive(id);
const archived = items.find((i) => i.id === id);
if (archived && (archived.issue_id ?? archived.id) === selectedKey) setSelectedKey("");
} catch {
toast.error("Failed to archive");
}
};
// Batch operations
const handleMarkAllRead = () => {
markAllReadMutation.mutate(undefined, {
onError: () => toast.error("Failed to mark all as read"),
});
const handleMarkAllRead = async () => {
try {
useInboxStore.getState().markAllRead();
await api.markAllInboxRead();
} catch {
toast.error("Failed to mark all as read");
useInboxStore.getState().fetch();
}
};
const handleArchiveAll = () => {
setSelectedKey("");
archiveAllMutation.mutate(undefined, {
onError: () => toast.error("Failed to archive all"),
});
const handleArchiveAll = async () => {
try {
useInboxStore.getState().archiveAll();
setSelectedKey("");
await api.archiveAllInbox();
} catch {
toast.error("Failed to archive all");
useInboxStore.getState().fetch();
}
};
const handleArchiveAllRead = () => {
const readKeys = items.filter((i) => i.read).map((i) => i.issue_id ?? i.id);
if (readKeys.includes(selectedKey)) setSelectedKey("");
archiveAllReadMutation.mutate(undefined, {
onError: () => toast.error("Failed to archive read items"),
});
const handleArchiveAllRead = async () => {
try {
const readKeys = items.filter((i) => i.read).map((i) => i.issue_id ?? i.id);
useInboxStore.getState().archiveAllRead();
if (readKeys.includes(selectedKey)) setSelectedKey("");
await api.archiveAllReadInbox();
} catch {
toast.error("Failed to archive read items");
useInboxStore.getState().fetch();
}
};
const handleArchiveCompleted = () => {
setSelectedKey("");
archiveCompletedMutation.mutate(undefined, {
onError: () => toast.error("Failed to archive completed"),
});
const handleArchiveCompleted = async () => {
try {
await api.archiveCompletedInbox();
setSelectedKey("");
await useInboxStore.getState().fetch();
} catch {
toast.error("Failed to archive completed");
}
};
if (loading) {
return (
<ResizablePanelGroup orientation="horizontal" className="flex-1 min-h-0" defaultLayout={defaultLayout} onLayoutChanged={onLayoutChanged}>
<ResizablePanel id="list" defaultSize={320} minSize={240} maxSize={480} groupResizeBehavior="preserve-pixel-size">
<div className="flex flex-col border-r h-full">
<div className="flex h-12 shrink-0 items-center border-b px-4">
<div className="overflow-y-auto border-r h-full">
<div className="flex h-12 items-center border-b px-4">
<Skeleton className="h-5 w-16" />
</div>
<div className="flex-1 min-h-0 overflow-y-auto space-y-1 p-2">
<div className="space-y-1 p-2">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex items-center gap-3 px-4 py-2.5">
<Skeleton className="h-7 w-7 shrink-0 rounded-full" />
@@ -348,8 +341,8 @@ export default function InboxPage() {
<ResizablePanelGroup orientation="horizontal" className="flex-1 min-h-0" defaultLayout={defaultLayout} onLayoutChanged={onLayoutChanged}>
<ResizablePanel id="list" defaultSize={320} minSize={240} maxSize={480} groupResizeBehavior="preserve-pixel-size">
{/* Left column — inbox list */}
<div className="flex flex-col border-r h-full">
<div className="flex h-12 shrink-0 items-center justify-between border-b px-4">
<div className="overflow-y-auto border-r h-full">
<div className="flex h-12 items-center justify-between border-b px-4">
<div className="flex items-center gap-2">
<h1 className="text-sm font-semibold">Inbox</h1>
{unreadCount > 0 && (
@@ -392,7 +385,6 @@ export default function InboxPage() {
</DropdownMenu>
</div>
<div className="flex-1 min-h-0 overflow-y-auto">
{items.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
<Inbox className="mb-3 h-8 w-8 text-muted-foreground/50" />
@@ -411,7 +403,6 @@ export default function InboxPage() {
))}
</div>
)}
</div>
</div>
</ResizablePanel>
<ResizableHandle />
@@ -420,11 +411,9 @@ export default function InboxPage() {
<div className="flex flex-col min-h-0 h-full">
{selected?.issue_id ? (
<IssueDetail
key={selected.id}
issueId={selected.issue_id}
defaultSidebarOpen={false}
layoutId="multica_inbox_issue_detail_layout"
highlightCommentId={selected.details?.comment_id ?? undefined}
onDelete={() => {
handleArchive(selected.id);
}}

View File

@@ -2,7 +2,6 @@ import { Suspense, forwardRef, useRef, useState, useImperativeHandle } from "rea
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor, act, fireEvent } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { Issue, Comment, TimelineEntry } from "@/shared/types";
// Mock next/navigation
@@ -63,11 +62,34 @@ vi.mock("@/features/workspace", () => ({
}),
}));
// Mock issue store — only client state remains (activeIssueId)
// Mock issue store — supply a stable full issue object so storeIssue
// doesn't create a new reference each render (avoids infinite effect loop)
// and has all required fields for rendering.
const stableStoreIssues = vi.hoisted(() => [
{
id: "issue-1",
workspace_id: "ws-1",
number: 1,
identifier: "TES-1",
title: "Implement authentication",
description: "Add JWT auth to the backend",
status: "in_progress",
priority: "high",
assignee_type: "member",
assignee_id: "user-1",
creator_type: "member",
creator_id: "user-1",
parent_issue_id: null,
position: 0,
due_date: "2026-06-01T00:00:00Z",
created_at: "2026-01-15T00:00:00Z",
updated_at: "2026-01-20T00:00:00Z",
},
]);
vi.mock("@/features/issues", () => ({
useIssueStore: Object.assign(
(selector: (s: any) => any) => selector({ activeIssueId: null }),
{ getState: () => ({ activeIssueId: null, setActiveIssue: vi.fn() }) },
(selector: (s: any) => any) => selector({ issues: stableStoreIssues }),
{ getState: () => ({ issues: stableStoreIssues, addIssue: vi.fn(), updateIssue: vi.fn(), removeIssue: vi.fn() }) },
),
}));
@@ -82,12 +104,9 @@ vi.mock("@/components/ui/calendar", () => ({
Calendar: () => null,
}));
// Mock ContentEditor (Tiptap needs real DOM)
vi.mock("@/features/editor", () => ({
ReadonlyContent: ({ content }: { content: string }) => (
<div data-testid="readonly-content">{content}</div>
),
ContentEditor: forwardRef(({ defaultValue, onUpdate, placeholder, onSubmit }: any, ref: any) => {
// Mock RichTextEditor (Tiptap needs real DOM)
vi.mock("@/components/common/rich-text-editor", () => ({
RichTextEditor: forwardRef(({ defaultValue, onUpdate, placeholder, onSubmit }: any, ref: any) => {
const valueRef = useRef(defaultValue || "");
const [value, setValue] = useState(defaultValue || "");
useImperativeHandle(ref, () => ({
@@ -113,27 +132,6 @@ vi.mock("@/features/editor", () => ({
/>
);
}),
TitleEditor: forwardRef(({ defaultValue, placeholder, onBlur, onChange }: any, ref: any) => {
const valueRef = useRef(defaultValue || "");
const [value, setValue] = useState(defaultValue || "");
useImperativeHandle(ref, () => ({
getText: () => valueRef.current,
focus: () => {},
}));
return (
<input
value={value}
onChange={(e) => {
valueRef.current = e.target.value;
setValue(e.target.value);
onChange?.(e.target.value);
}}
onBlur={() => onBlur?.(valueRef.current)}
placeholder={placeholder}
data-testid="title-editor"
/>
);
}),
}));
// Mock Markdown renderer
@@ -163,10 +161,6 @@ vi.mock("@/shared/api", () => ({
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: [] }),
},
}));
@@ -217,26 +211,14 @@ const mockTimeline: TimelineEntry[] = [
import IssueDetailPage from "./page";
function createTestQueryClient() {
return new QueryClient({
defaultOptions: {
queries: { retry: false, gcTime: 0 },
mutations: { retry: false },
},
});
}
// React 19 use(Promise) needs the promise to resolve within act + Suspense
async function renderPage(id = "issue-1") {
const queryClient = createTestQueryClient();
let result: ReturnType<typeof render>;
await act(async () => {
result = render(
<QueryClientProvider client={queryClient}>
<Suspense fallback={<div>Suspense loading...</div>}>
<IssueDetailPage params={Promise.resolve({ id })} />
</Suspense>
</QueryClientProvider>,
<Suspense fallback={<div>Suspense loading...</div>}>
<IssueDetailPage params={Promise.resolve({ id })} />
</Suspense>,
);
});
return result!;
@@ -351,10 +333,10 @@ describe("IssueDetailPage", () => {
await user.click(submitBtn);
await waitFor(() => {
expect(mockCreateComment).toHaveBeenCalled();
const [issueId, content] = mockCreateComment.mock.calls[0]!;
expect(issueId).toBe("issue-1");
expect(content).toBe("New test comment");
expect(mockCreateComment).toHaveBeenCalledWith(
"issue-1",
"New test comment",
);
});
await waitFor(() => {

View File

@@ -1,7 +1,6 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { Issue } from "@/shared/types";
// Mock next/navigation
@@ -62,28 +61,36 @@ vi.mock("sonner", () => ({
// Mock api
const mockUpdateIssue = vi.fn();
const mockListIssues = vi.hoisted(() => vi.fn().mockResolvedValue({ issues: [], total: 0 }));
vi.mock("@/shared/api", () => ({
api: {
listIssues: (...args: any[]) => mockListIssues(...args),
listIssues: vi.fn().mockResolvedValue({ issues: [], total: 0 }),
updateIssue: (...args: any[]) => mockUpdateIssue(...args),
},
}));
// Mock issue store — only client state remains
const mockIssueClientState = { activeIssueId: null, setActiveIssue: 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", () => ({
useIssueStore: Object.assign(
(selector?: any) => (selector ? selector(mockIssueClientState) : mockIssueClientState),
{ getState: () => mockIssueClientState },
(selector?: any) => (selector ? selector(mockStoreState) : mockStoreState),
{ getState: () => mockStoreState },
),
}));
vi.mock("@/features/issues", () => ({
useIssueStore: Object.assign(
(selector?: any) => (selector ? selector(mockIssueClientState) : mockIssueClientState),
{ getState: () => mockIssueClientState },
(selector?: any) => (selector ? selector(mockStoreState) : mockStoreState),
{ getState: () => mockStoreState },
),
StatusIcon: () => null,
PriorityIcon: () => null,
@@ -143,17 +150,9 @@ vi.mock("@/features/issues/stores/view-store", () => ({
],
}));
// Mock view store context (shared components read from context)
vi.mock("@/features/issues/stores/view-store-context", () => ({
ViewStoreProvider: ({ children }: { children: React.ReactNode }) => children,
useViewStore: (selector?: any) => (selector ? selector(mockViewState) : mockViewState),
useViewStoreApi: () => ({ getState: () => mockViewState, setState: vi.fn(), subscribe: vi.fn() }),
}));
// Mock issue config
vi.mock("@/features/issues/config", () => ({
ALL_STATUSES: ["backlog", "todo", "in_progress", "in_review", "done", "blocked", "cancelled"],
BOARD_STATUSES: ["backlog", "todo", "in_progress", "in_review", "done", "blocked"],
STATUS_ORDER: ["backlog", "todo", "in_progress", "in_review", "done", "blocked", "cancelled"],
STATUS_CONFIG: {
backlog: { label: "Backlog", iconColor: "text-muted-foreground", hoverBg: "hover:bg-accent" },
@@ -275,80 +274,87 @@ 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}>{ui}</QueryClientProvider>);
}
describe("IssuesPage", () => {
beforeEach(() => {
vi.clearAllMocks();
mockListIssues.mockResolvedValue({ issues: [], total: 0 });
mockStoreState = {
issues: [],
loading: true,
fetch: vi.fn(),
setIssues: vi.fn(),
addIssue: vi.fn(),
updateIssue: vi.fn(),
removeIssue: vi.fn(),
};
mockViewState.viewMode = "board";
mockViewState.statusFilters = [];
mockViewState.priorityFilters = [];
});
it("shows loading state initially", () => {
renderWithQuery(<IssuesPage />);
mockStoreState.loading = true;
mockStoreState.issues = [];
render(<IssuesPage />);
expect(screen.getAllByRole("generic").some(el => el.getAttribute("data-slot") === "skeleton")).toBe(true);
});
it("renders issues in board view after loading", async () => {
// issueListOptions makes 2 calls: open_only + closed page. Return issues for open, empty for closed.
mockListIssues.mockImplementation((params: any) =>
Promise.resolve(params?.open_only ? { issues: mockIssues, total: mockIssues.length } : { issues: [], total: 0 }),
);
mockStoreState.loading = false;
mockStoreState.issues = mockIssues;
renderWithQuery(<IssuesPage />);
render(<IssuesPage />);
await screen.findByText("Implement auth");
expect(screen.getByText("Implement auth")).toBeInTheDocument();
expect(screen.getByText("Design landing page")).toBeInTheDocument();
expect(screen.getByText("Write tests")).toBeInTheDocument();
});
it("renders board columns", async () => {
mockListIssues.mockImplementation((params: any) =>
Promise.resolve(params?.open_only ? { issues: mockIssues, total: mockIssues.length } : { issues: [], total: 0 }),
);
mockStoreState.loading = false;
mockStoreState.issues = mockIssues;
renderWithQuery(<IssuesPage />);
render(<IssuesPage />);
await screen.findByText("Backlog");
expect(screen.getAllByText("Backlog").length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText("Todo").length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText("In Progress").length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText("In Review").length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText("Done").length).toBeGreaterThanOrEqual(1);
});
it("shows workspace breadcrumb", async () => {
renderWithQuery(<IssuesPage />);
it("shows workspace breadcrumb", () => {
mockStoreState.loading = false;
mockStoreState.issues = [];
await screen.findByText("Issues");
render(<IssuesPage />);
expect(screen.getByText("Issues")).toBeInTheDocument();
});
it("shows scope buttons", async () => {
renderWithQuery(<IssuesPage />);
it("shows 'New Issue' button", () => {
mockStoreState.loading = false;
mockStoreState.issues = [];
await screen.findByText("All");
expect(screen.getByText("Members")).toBeInTheDocument();
expect(screen.getByText("Agents")).toBeInTheDocument();
render(<IssuesPage />);
expect(screen.getByText("New Issue")).toBeInTheDocument();
});
it("shows filter and display icon buttons", async () => {
mockListIssues.mockImplementation((params: any) =>
Promise.resolve(params?.open_only ? { issues: mockIssues, total: mockIssues.length } : { issues: [], total: 0 }),
);
it("shows filter buttons", () => {
mockStoreState.loading = false;
mockStoreState.issues = mockIssues;
renderWithQuery(<IssuesPage />);
render(<IssuesPage />);
await screen.findByText("Implement auth");
const buttons = screen.getAllByRole("button");
expect(buttons.length).toBeGreaterThan(0);
expect(screen.getByText("Filter")).toBeInTheDocument();
expect(screen.getByText("Display")).toBeInTheDocument();
});
it("shows empty board view when no issues exist", () => {
renderWithQuery(<IssuesPage />);
mockStoreState.loading = false;
mockStoreState.issues = [];
render(<IssuesPage />);
// Should still render the board/list view, not a "no issues" message
expect(screen.queryByText("No matching issues")).not.toBeInTheDocument();

View File

@@ -22,7 +22,7 @@ export default function DashboardLayout({
useEffect(() => {
if (!isLoading && !user) {
router.push("/");
router.push("/login");
}
}, [user, isLoading, router]);

View File

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

View File

@@ -2,7 +2,6 @@
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";
@@ -36,11 +35,8 @@ import {
DropdownMenuSubContent,
} from "@/components/ui/dropdown-menu";
import { toast } from "sonner";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useAuthStore } from "@/features/auth";
import { useWorkspaceStore } from "@/features/workspace";
import { useWorkspaceId } from "@core/hooks";
import { memberListOptions, workspaceKeys } from "@core/workspace/queries";
import { api } from "@/shared/api";
const roleConfig: Record<MemberRole, { label: string; icon: typeof Crown; description: string }> = {
@@ -74,7 +70,14 @@ function MemberRow({
return (
<div className="flex items-center gap-3 px-4 py-3">
<ActorAvatar actorType="member" actorId={member.user_id} size={32} />
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-muted text-xs font-semibold">
{member.name
.split(" ")
.map((w) => w[0])
.join("")
.toUpperCase()
.slice(0, 2)}
</div>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium truncate">{member.name}</div>
<div className="text-xs text-muted-foreground truncate">{member.email}</div>
@@ -143,9 +146,8 @@ function MemberRow({
export function MembersTab() {
const user = useAuthStore((s) => s.user);
const workspace = useWorkspaceStore((s) => s.workspace);
const qc = useQueryClient();
const wsId = useWorkspaceId();
const { data: members = [] } = useQuery(memberListOptions(wsId));
const members = useWorkspaceStore((s) => s.members);
const refreshMembers = useWorkspaceStore((s) => s.refreshMembers);
const [inviteEmail, setInviteEmail] = useState("");
const [inviteRole, setInviteRole] = useState<MemberRole>("member");
@@ -172,7 +174,7 @@ export function MembersTab() {
});
setInviteEmail("");
setInviteRole("member");
qc.invalidateQueries({ queryKey: workspaceKeys.members(wsId) });
await refreshMembers();
toast.success("Member added");
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed to add member");
@@ -186,7 +188,7 @@ export function MembersTab() {
setMemberActionId(memberId);
try {
await api.updateMember(workspace.id, memberId, { role });
qc.invalidateQueries({ queryKey: workspaceKeys.members(wsId) });
await refreshMembers();
toast.success("Role updated");
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed to update member");
@@ -205,7 +207,7 @@ export function MembersTab() {
setMemberActionId(member.id);
try {
await api.deleteMember(workspace.id, member.id);
qc.invalidateQueries({ queryKey: workspaceKeys.members(wsId) });
await refreshMembers();
toast.success("Member removed");
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed to remove member");

View File

@@ -1,133 +0,0 @@
"use client";
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 { toast } from "sonner";
import { useQuery } from "@tanstack/react-query";
import { useAuthStore } from "@/features/auth";
import { useWorkspaceStore } from "@/features/workspace";
import { useWorkspaceId } from "@core/hooks";
import { memberListOptions } from "@core/workspace/queries";
import { api } from "@/shared/api";
import type { WorkspaceRepo } from "@/shared/types";
export function RepositoriesTab() {
const user = useAuthStore((s) => s.user);
const workspace = useWorkspaceStore((s) => s.workspace);
const wsId = useWorkspaceId();
const { data: members = [] } = useQuery(memberListOptions(wsId));
const updateWorkspace = useWorkspaceStore((s) => s.updateWorkspace);
const [repos, setRepos] = useState<WorkspaceRepo[]>(workspace?.repos ?? []);
const [saving, setSaving] = useState(false);
const currentMember = members.find((m) => m.user_id === user?.id) ?? null;
const canManageWorkspace = currentMember?.role === "owner" || currentMember?.role === "admin";
useEffect(() => {
setRepos(workspace?.repos ?? []);
}, [workspace]);
const handleSave = async () => {
if (!workspace) return;
setSaving(true);
try {
const updated = await api.updateWorkspace(workspace.id, { repos });
updateWorkspace(updated);
toast.success("Repositories saved");
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed to save repositories");
} finally {
setSaving(false);
}
};
const handleAddRepo = () => {
setRepos([...repos, { url: "", description: "" }]);
};
const handleRemoveRepo = (index: number) => {
setRepos(repos.filter((_, i) => i !== index));
};
const handleRepoChange = (index: number, field: keyof WorkspaceRepo, value: string) => {
setRepos(repos.map((r, i) => (i === index ? { ...r, [field]: value } : r)));
};
if (!workspace) return null;
return (
<div className="space-y-8">
<section className="space-y-4">
<h2 className="text-sm font-semibold">Repositories</h2>
<Card>
<CardContent className="space-y-3">
<p className="text-xs text-muted-foreground">
GitHub repositories associated with this workspace. Agents use these to clone and work on code.
</p>
{repos.map((repo, index) => (
<div key={index} className="flex gap-2">
<div className="flex-1 space-y-1.5">
<Input
type="url"
value={repo.url}
onChange={(e) => handleRepoChange(index, "url", e.target.value)}
disabled={!canManageWorkspace}
placeholder="https://github.com/org/repo"
className="text-sm"
/>
<Input
type="text"
value={repo.description}
onChange={(e) => handleRepoChange(index, "description", e.target.value)}
disabled={!canManageWorkspace}
placeholder="Description (e.g. Go backend + Next.js frontend)"
className="text-sm"
/>
</div>
{canManageWorkspace && (
<Button
variant="ghost"
size="icon"
className="mt-0.5 shrink-0 text-muted-foreground hover:text-destructive"
onClick={() => handleRemoveRepo(index)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
)}
</div>
))}
{canManageWorkspace && (
<div className="flex items-center justify-between pt-1">
<Button variant="outline" size="sm" onClick={handleAddRepo}>
<Plus className="h-3 w-3" />
Add repository
</Button>
<Button
size="sm"
onClick={handleSave}
disabled={saving}
>
<Save className="h-3 w-3" />
{saving ? "Saving..." : "Save"}
</Button>
</div>
)}
{!canManageWorkspace && (
<p className="text-xs text-muted-foreground">
Only admins and owners can manage repositories.
</p>
)}
</CardContent>
</Card>
</section>
</div>
);
}

View File

@@ -22,17 +22,6 @@ import {
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Skeleton } from "@/components/ui/skeleton";
import { toast } from "sonner";
import { api } from "@/shared/api";
@@ -44,17 +33,13 @@ export function TokensTab() {
const [newToken, setNewToken] = useState<string | null>(null);
const [tokenCopied, setTokenCopied] = useState(false);
const [tokenRevoking, setTokenRevoking] = useState<string | null>(null);
const [revokeConfirmId, setRevokeConfirmId] = useState<string | null>(null);
const [tokensLoading, setTokensLoading] = useState(true);
const loadTokens = useCallback(async () => {
try {
const list = await api.listPersonalAccessTokens();
setTokens(list);
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed to load tokens");
} finally {
setTokensLoading(false);
} catch {
// ignore — tokens section simply stays empty
}
}, []);
@@ -132,21 +117,7 @@ export function TokensTab() {
</CardContent>
</Card>
{tokensLoading ? (
<div className="space-y-2">
{Array.from({ length: 2 }).map((_, i) => (
<Card key={i}>
<CardContent className="flex items-center gap-3">
<div className="flex-1 space-y-1.5">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-3 w-48" />
</div>
<Skeleton className="h-8 w-8 rounded" />
</CardContent>
</Card>
))}
</div>
) : tokens.length > 0 && (
{tokens.length > 0 && (
<div className="space-y-2">
{tokens.map((t) => (
<Card key={t.id}>
@@ -164,7 +135,7 @@ export function TokensTab() {
<Button
variant="ghost"
size="icon-sm"
onClick={() => setRevokeConfirmId(t.id)}
onClick={() => handleRevokeToken(t.id)}
disabled={tokenRevoking === t.id}
aria-label={`Revoke ${t.name}`}
>
@@ -181,29 +152,6 @@ export function TokensTab() {
)}
</section>
<AlertDialog open={!!revokeConfirmId} onOpenChange={(v) => { if (!v) setRevokeConfirmId(null); }}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Revoke token</AlertDialogTitle>
<AlertDialogDescription>
This token will be permanently revoked and can no longer be used. This cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={async () => {
if (revokeConfirmId) await handleRevokeToken(revokeConfirmId);
setRevokeConfirmId(null);
}}
>
Revoke
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<Dialog open={!!newToken} onOpenChange={(v) => { if (!v) { setNewToken(null); setTokenCopied(false); } }}>
<DialogContent>
<DialogHeader>

View File

@@ -1,7 +1,7 @@
"use client";
import { useEffect, useState } from "react";
import { Save, LogOut } from "lucide-react";
import { Save, LogOut, Plus, Trash2 } from "lucide-react";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
@@ -18,18 +18,15 @@ import {
AlertDialogAction,
} from "@/components/ui/alert-dialog";
import { toast } from "sonner";
import { useQuery } from "@tanstack/react-query";
import { useAuthStore } from "@/features/auth";
import { useWorkspaceStore } from "@/features/workspace";
import { useWorkspaceId } from "@core/hooks";
import { memberListOptions } from "@core/workspace/queries";
import { api } from "@/shared/api";
import type { WorkspaceRepo } from "@/shared/types";
export function WorkspaceTab() {
const user = useAuthStore((s) => s.user);
const workspace = useWorkspaceStore((s) => s.workspace);
const wsId = useWorkspaceId();
const { data: members = [] } = useQuery(memberListOptions(wsId));
const members = useWorkspaceStore((s) => s.members);
const updateWorkspace = useWorkspaceStore((s) => s.updateWorkspace);
const leaveWorkspace = useWorkspaceStore((s) => s.leaveWorkspace);
const deleteWorkspace = useWorkspaceStore((s) => s.deleteWorkspace);
@@ -37,6 +34,7 @@ export function WorkspaceTab() {
const [name, setName] = useState(workspace?.name ?? "");
const [description, setDescription] = useState(workspace?.description ?? "");
const [context, setContext] = useState(workspace?.context ?? "");
const [repos, setRepos] = useState<WorkspaceRepo[]>(workspace?.repos ?? []);
const [saving, setSaving] = useState(false);
const [actionId, setActionId] = useState<string | null>(null);
const [confirmAction, setConfirmAction] = useState<{
@@ -54,6 +52,7 @@ export function WorkspaceTab() {
setName(workspace?.name ?? "");
setDescription(workspace?.description ?? "");
setContext(workspace?.context ?? "");
setRepos(workspace?.repos ?? []);
}, [workspace]);
const handleSave = async () => {
@@ -64,6 +63,7 @@ export function WorkspaceTab() {
name,
description,
context,
repos,
});
updateWorkspace(updated);
toast.success("Workspace settings saved");
@@ -74,6 +74,18 @@ export function WorkspaceTab() {
}
};
const handleAddRepo = () => {
setRepos([...repos, { url: "", description: "" }]);
};
const handleRemoveRepo = (index: number) => {
setRepos(repos.filter((_, i) => i !== index));
};
const handleRepoChange = (index: number, field: keyof WorkspaceRepo, value: string) => {
setRepos(repos.map((r, i) => (i === index ? { ...r, [field]: value } : r)));
};
const handleLeaveWorkspace = () => {
if (!workspace) return;
setConfirmAction({
@@ -179,6 +191,69 @@ export function WorkspaceTab() {
</Card>
</section>
{/* Repositories */}
<section className="space-y-4">
<h2 className="text-sm font-semibold">Repositories</h2>
<Card>
<CardContent className="space-y-3">
<p className="text-xs text-muted-foreground">
GitHub repositories associated with this workspace. Agents use these to clone and work on code.
</p>
{repos.map((repo, index) => (
<div key={index} className="flex gap-2">
<div className="flex-1 space-y-1.5">
<Input
type="url"
value={repo.url}
onChange={(e) => handleRepoChange(index, "url", e.target.value)}
disabled={!canManageWorkspace}
placeholder="https://github.com/org/repo"
className="text-sm"
/>
<Input
type="text"
value={repo.description}
onChange={(e) => handleRepoChange(index, "description", e.target.value)}
disabled={!canManageWorkspace}
placeholder="Description (e.g. Go backend + Next.js frontend)"
className="text-sm"
/>
</div>
{canManageWorkspace && (
<Button
variant="ghost"
size="icon"
className="mt-0.5 shrink-0 text-muted-foreground hover:text-destructive"
onClick={() => handleRemoveRepo(index)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
)}
</div>
))}
{canManageWorkspace && (
<div className="flex items-center justify-between pt-1">
<Button variant="outline" size="sm" onClick={handleAddRepo}>
<Plus className="h-3 w-3" />
Add repository
</Button>
<Button
size="sm"
onClick={handleSave}
disabled={saving || !name.trim() || !canManageWorkspace}
>
<Save className="h-3 w-3" />
{saving ? "Saving..." : "Save"}
</Button>
</div>
)}
</CardContent>
</Card>
</section>
{/* Danger Zone */}
<section className="space-y-4">
<div className="flex items-center gap-2">

View File

@@ -1,6 +1,6 @@
"use client";
import { User, Palette, Key, Settings, Users, FolderGit2 } from "lucide-react";
import { User, Palette, Key, Settings, Users } from "lucide-react";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import { useWorkspaceStore } from "@/features/workspace";
import { AccountTab } from "./_components/account-tab";
@@ -8,7 +8,6 @@ import { AppearanceTab } from "./_components/general-tab";
import { TokensTab } from "./_components/tokens-tab";
import { WorkspaceTab } from "./_components/workspace-tab";
import { MembersTab } from "./_components/members-tab";
import { RepositoriesTab } from "./_components/repositories-tab";
const accountTabs = [
{ value: "profile", label: "Profile", icon: User },
@@ -18,7 +17,6 @@ const accountTabs = [
const workspaceTabs = [
{ value: "workspace", label: "General", icon: Settings },
{ value: "repositories", label: "Repositories", icon: FolderGit2 },
{ value: "members", label: "Members", icon: Users },
];
@@ -62,7 +60,6 @@ export default function SettingsPage() {
<TabsContent value="appearance"><AppearanceTab /></TabsContent>
<TabsContent value="tokens"><TokensTab /></TabsContent>
<TabsContent value="workspace"><WorkspaceTab /></TabsContent>
<TabsContent value="repositories"><RepositoriesTab /></TabsContent>
<TabsContent value="members"><MembersTab /></TabsContent>
</div>
</div>

View File

@@ -1,21 +0,0 @@
import type { Metadata } from "next";
import { AboutPageClient } from "@/features/landing/components/about-page-client";
export const metadata: Metadata = {
title: "About",
description:
"Learn about Multica — multiplexed information and computing agent. An open-source AI-native task management platform.",
openGraph: {
title: "About Multica",
description:
"The story behind Multica and why we're building AI-native task management.",
url: "/about",
},
alternates: {
canonical: "/about",
},
};
export default function AboutPage() {
return <AboutPageClient />;
}

View File

@@ -1,20 +0,0 @@
import type { Metadata } from "next";
import { ChangelogPageClient } from "@/features/landing/components/changelog-page-client";
export const metadata: Metadata = {
title: "Changelog",
description:
"See what's new in Multica — latest features, improvements, and fixes.",
openGraph: {
title: "Changelog | Multica",
description: "Latest updates and releases from Multica.",
url: "/changelog",
},
alternates: {
canonical: "/changelog",
},
};
export default function ChangelogPage() {
return <ChangelogPageClient />;
}

View File

@@ -1,21 +0,0 @@
import type { Metadata } from "next";
import { MulticaLanding } from "@/features/landing/components/multica-landing";
export const metadata: Metadata = {
title: "Homepage",
description:
"Multica — open-source platform that turns coding agents into real teammates. Assign tasks, track progress, compound skills.",
openGraph: {
title: "Multica — AI-Native Task Management",
description:
"Manage your human + agent workforce in one place.",
url: "/homepage",
},
alternates: {
canonical: "/homepage",
},
};
export default function HomepagePage() {
return <MulticaLanding />;
}

View File

@@ -1,75 +0,0 @@
import { cookies, headers } from "next/headers";
import { Instrument_Serif, Noto_Serif_SC } from "next/font/google";
import { LocaleProvider } from "@/features/landing/i18n";
import type { Locale } from "@/features/landing/i18n";
const instrumentSerif = Instrument_Serif({
subsets: ["latin"],
weight: "400",
variable: "--font-serif",
});
const notoSerifSC = Noto_Serif_SC({
subsets: ["latin"],
weight: "400",
variable: "--font-serif-zh",
});
const jsonLd = {
"@context": "https://schema.org",
"@graph": [
{
"@type": "Organization",
name: "Multica",
url: "https://www.multica.ai",
sameAs: ["https://github.com/multica-ai/multica"],
},
{
"@type": "SoftwareApplication",
name: "Multica",
applicationCategory: "ProjectManagement",
operatingSystem: "Web",
description:
"AI-native task management platform that turns coding agents into real teammates.",
offers: {
"@type": "Offer",
price: "0",
priceCurrency: "USD",
},
},
],
};
async function getInitialLocale(): Promise<Locale> {
// 1. User's explicit preference (cookie set when they switch language)
const cookieStore = await cookies();
const stored = cookieStore.get("multica-locale")?.value;
if (stored === "en" || stored === "zh") return stored;
// 2. Detect from Accept-Language header
const headersList = await headers();
const acceptLang = headersList.get("accept-language") ?? "";
if (acceptLang.includes("zh")) return "zh";
return "en";
}
export default async function LandingLayout({
children,
}: {
children: React.ReactNode;
}) {
const initialLocale = await getInitialLocale();
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<div className={`${instrumentSerif.variable} ${notoSerifSC.variable} h-full overflow-x-hidden overflow-y-auto bg-white`}>
<LocaleProvider initialLocale={initialLocale}>{children}</LocaleProvider>
</div>
</>
);
}

View File

@@ -1,23 +0,0 @@
import type { Metadata } from "next";
import { MulticaLanding } from "@/features/landing/components/multica-landing";
export const metadata: Metadata = {
title: {
absolute: "Multica — AI-Native Task Management",
},
description:
"Open-source platform that turns coding agents into real teammates. Assign tasks, track progress, compound skills.",
openGraph: {
title: "Multica — AI-Native Task Management",
description:
"Manage your human + agent workforce in one place.",
url: "/",
},
alternates: {
canonical: "/",
},
};
export default function LandingPage() {
return <MulticaLanding />;
}

View File

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

View File

@@ -30,12 +30,3 @@
background-color: var(--sidebar-accent);
color: var(--sidebar-accent-foreground);
}
/* Sonner toast: align icon to first line of text, not vertically centered */
[data-sonner-toast] {
align-items: flex-start !important;
}
[data-sonner-toast] [data-icon] {
margin-top: 2.5px;
}

View File

@@ -1,54 +1,23 @@
import type { Metadata, Viewport } from "next";
import type { Metadata } from "next";
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 { QueryProvider } from "@core/provider";
import { AuthInitializer } from "@/features/auth";
import { WSProvider } from "@/features/realtime";
import { ModalRegistry } from "@/features/modals";
import { LocaleSync } from "@/components/locale-sync";
import "./globals.css";
const geist = Geist({ subsets: ["latin"], variable: "--font-sans" });
const geistMono = Geist_Mono({ subsets: ["latin"], variable: "--font-mono" });
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
themeColor: [
{ media: "(prefers-color-scheme: light)", color: "#ffffff" },
{ media: "(prefers-color-scheme: dark)", color: "#05070b" },
],
};
export const metadata: Metadata = {
metadataBase: new URL("https://www.multica.ai"),
title: {
default: "Multica — AI-Native Task Management",
template: "%s | Multica",
},
description:
"Open-source platform that turns coding agents into real teammates. Assign tasks, track progress, compound skills.",
title: "Multica",
description: "AI-native task management",
icons: {
icon: [{ url: "/favicon.svg", type: "image/svg+xml" }],
shortcut: ["/favicon.svg"],
},
openGraph: {
type: "website",
siteName: "Multica",
locale: "en_US",
},
twitter: {
card: "summary_large_image",
},
alternates: {
canonical: "/",
},
robots: {
index: true,
follow: true,
},
};
export default function RootLayout({
@@ -63,15 +32,12 @@ export default function RootLayout({
className={cn("antialiased font-sans h-full", geist.variable, geistMono.variable)}
>
<body className="h-full overflow-hidden">
<LocaleSync />
<ThemeProvider>
<QueryProvider>
<AuthInitializer>
<WSProvider>{children}</WSProvider>
</AuthInitializer>
<ModalRegistry />
<Toaster />
</QueryProvider>
<AuthInitializer>
<WSProvider>{children}</WSProvider>
</AuthInitializer>
<ModalRegistry />
<Toaster />
</ThemeProvider>
</body>
</html>

21
apps/web/app/page.tsx Normal file
View File

@@ -0,0 +1,21 @@
"use client";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { useNavigationStore } from "@/features/navigation";
import { MulticaIcon } from "@/components/multica-icon";
export default function Home() {
const router = useRouter();
useEffect(() => {
const lastPath = useNavigationStore.getState().lastPath;
router.replace(lastPath);
}, [router]);
return (
<div className="flex h-screen items-center justify-center">
<MulticaIcon className="size-6" />
</div>
);
}

View File

@@ -1,28 +0,0 @@
import type { MetadataRoute } from "next";
export default function robots(): MetadataRoute.Robots {
const baseUrl = "https://www.multica.ai";
return {
rules: [
{
userAgent: "*",
allow: ["/", "/about", "/changelog"],
disallow: [
"/api/",
"/ws",
"/auth/",
"/issues",
"/board",
"/inbox",
"/agents",
"/settings",
"/my-issues",
"/runtimes",
"/skills",
],
},
],
sitemap: `${baseUrl}/sitemap.xml`,
};
}

View File

@@ -1,26 +0,0 @@
import type { MetadataRoute } from "next";
export default function sitemap(): MetadataRoute.Sitemap {
const baseUrl = "https://www.multica.ai";
return [
{
url: baseUrl,
lastModified: new Date("2026-04-01"),
changeFrequency: "weekly",
priority: 1.0,
},
{
url: `${baseUrl}/about`,
lastModified: new Date("2026-04-01"),
changeFrequency: "monthly",
priority: 0.7,
},
{
url: `${baseUrl}/changelog`,
lastModified: new Date("2026-04-01"),
changeFrequency: "weekly",
priority: 0.6,
},
];
}

View File

@@ -45,7 +45,6 @@ function ActorAvatar({
return (
<div
data-slot="avatar"
className={cn(
"inline-flex shrink-0 items-center justify-center rounded-full font-medium overflow-hidden",
"bg-muted text-muted-foreground",

View File

@@ -1,57 +0,0 @@
"use client";
import { useRef } from "react";
import { Paperclip } from "lucide-react";
import { cn } from "@/lib/utils";
interface FileUploadButtonProps {
/** Called with the selected File — caller handles upload. */
onSelect: (file: File) => void;
disabled?: boolean;
className?: string;
size?: "sm" | "default";
}
function FileUploadButton({
onSelect,
disabled,
className,
size = "default",
}: FileUploadButtonProps) {
const inputRef = useRef<HTMLInputElement>(null);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
e.target.value = "";
onSelect(file);
};
const iconSize = size === "sm" ? "h-3.5 w-3.5" : "h-4 w-4";
const btnSize = size === "sm" ? "h-6 w-6" : "h-7 w-7";
return (
<>
<button
type="button"
onClick={() => inputRef.current?.click()}
disabled={disabled}
className={cn(
"inline-flex items-center justify-center rounded-full text-muted-foreground hover:bg-accent hover:text-foreground transition-colors disabled:opacity-50 disabled:pointer-events-none",
btnSize,
className,
)}
>
<Paperclip className={iconSize} />
</button>
<input
ref={inputRef}
type="file"
className="hidden"
onChange={handleChange}
/>
</>
);
}
export { FileUploadButton, type FileUploadButtonProps };

View File

@@ -1,12 +1,10 @@
"use client";
import type { ReactNode } from "react";
import { Users } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { Bot } from "lucide-react";
import { HoverCard, HoverCardTrigger, HoverCardContent } from "@/components/ui/hover-card";
import { ActorAvatar } from "@/components/common/actor-avatar";
import { useWorkspaceId } from "@core/hooks";
import { memberListOptions, agentListOptions } from "@core/workspace/queries";
import { useWorkspaceStore } from "@/features/workspace";
interface MentionHoverCardProps {
type: string;
@@ -15,30 +13,8 @@ interface MentionHoverCardProps {
}
function MentionHoverCard({ type, id, children }: MentionHoverCardProps) {
const wsId = useWorkspaceId();
const { data: members = [] } = useQuery(memberListOptions(wsId));
const { data: agents = [] } = useQuery(agentListOptions(wsId));
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>
);
}
const members = useWorkspaceStore((s) => s.members);
const agents = useWorkspaceStore((s) => s.agents);
if (type === "member") {
const member = members.find((m) => m.user_id === id);
@@ -73,7 +49,9 @@ function MentionHoverCard({ type, id, children }: MentionHoverCardProps) {
</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="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-muted">
<Bot className="h-4 w-4 text-muted-foreground" />
</div>
<div className="min-w-0">
<p className="text-sm font-medium truncate">{agent.name}</p>
{agent.description && (

View File

@@ -0,0 +1,230 @@
"use client";
import {
forwardRef,
useEffect,
useImperativeHandle,
useState,
} from "react";
import { Bot, Hash } from "lucide-react";
import { ReactRenderer } from "@tiptap/react";
import { computePosition, offset, flip, shift } from "@floating-ui/dom";
import { useWorkspaceStore } from "@/features/workspace";
import { useIssueStore } from "@/features/issues";
import type { SuggestionOptions, SuggestionProps } from "@tiptap/suggestion";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface MentionItem {
id: string;
label: string;
type: "member" | "agent" | "issue";
/** Secondary text shown below the label (e.g. issue title) */
description?: string;
}
interface MentionListProps {
items: MentionItem[];
command: (item: MentionItem) => void;
}
export interface MentionListRef {
onKeyDown: (props: { event: KeyboardEvent }) => boolean;
}
// ---------------------------------------------------------------------------
// MentionList — the popup rendered inside the editor
// ---------------------------------------------------------------------------
const MentionList = forwardRef<MentionListRef, MentionListProps>(
function MentionList({ items, command }, ref) {
const [selectedIndex, setSelectedIndex] = useState(0);
useEffect(() => {
setSelectedIndex(0);
}, [items]);
const selectItem = (index: number) => {
const item = items[index];
if (item) command(item);
};
useImperativeHandle(ref, () => ({
onKeyDown: ({ event }) => {
if (event.key === "ArrowUp") {
setSelectedIndex((i) => (i + items.length - 1) % items.length);
return true;
}
if (event.key === "ArrowDown") {
setSelectedIndex((i) => (i + 1) % items.length);
return true;
}
if (event.key === "Enter") {
selectItem(selectedIndex);
return true;
}
return false;
},
}));
if (items.length === 0) {
return (
<div className="rounded-md border bg-popover p-2 text-xs text-muted-foreground shadow-md">
No results
</div>
);
}
return (
<div className="rounded-md border bg-popover py-1 shadow-md min-w-[180px] max-h-[240px] overflow-y-auto">
{items.map((item, index) => (
<button
key={`${item.type}-${item.id}`}
className={`flex w-full items-center gap-2 px-2.5 py-1.5 text-left text-sm transition-colors ${
index === selectedIndex ? "bg-accent" : "hover:bg-accent/50"
}`}
onClick={() => selectItem(index)}
>
{item.type === "agent" ? (
<span className="inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-info/10 text-info">
<Bot className="h-3 w-3" />
</span>
) : item.type === "issue" ? (
<span className="inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary">
<Hash className="h-3 w-3" />
</span>
) : (
<span className="inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground text-[9px] font-medium">
{item.label
.split(" ")
.map((w) => w[0])
.join("")
.toUpperCase()
.slice(0, 2)}
</span>
)}
<div className="flex flex-col min-w-0">
<span className="truncate">{item.label}</span>
{item.description && (
<span className="truncate text-xs text-muted-foreground">{item.description}</span>
)}
</div>
</button>
))}
</div>
);
},
);
// ---------------------------------------------------------------------------
// Suggestion config factory
// ---------------------------------------------------------------------------
export function createMentionSuggestion(): Omit<
SuggestionOptions<MentionItem>,
"editor"
> {
return {
items: ({ query }) => {
const { members, agents } = useWorkspaceStore.getState();
const { issues } = useIssueStore.getState();
const q = query.toLowerCase();
const memberItems: MentionItem[] = members
.filter((m) => m.name.toLowerCase().includes(q))
.map((m) => ({
id: m.user_id,
label: m.name,
type: "member" as const,
}));
const agentItems: MentionItem[] = agents
.filter((a) => a.name.toLowerCase().includes(q))
.map((a) => ({ id: a.id, label: a.name, type: "agent" as const }));
const issueItems: MentionItem[] = issues
.filter(
(i) =>
i.identifier.toLowerCase().includes(q) ||
i.title.toLowerCase().includes(q),
)
.map((i) => ({
id: i.id,
label: i.identifier,
type: "issue" as const,
description: i.title,
}));
return [...memberItems, ...agentItems, ...issueItems].slice(0, 10);
},
render: () => {
let renderer: ReactRenderer<MentionListRef> | null = null;
let popup: HTMLDivElement | null = null;
return {
onStart: (props: SuggestionProps<MentionItem>) => {
renderer = new ReactRenderer(MentionList, {
props: { items: props.items, command: props.command },
editor: props.editor,
});
popup = document.createElement("div");
popup.style.position = "fixed";
popup.style.zIndex = "50";
popup.appendChild(renderer.element);
document.body.appendChild(popup);
updatePosition(popup, props.clientRect);
},
onUpdate: (props: SuggestionProps<MentionItem>) => {
renderer?.updateProps({
items: props.items,
command: props.command,
});
if (popup) updatePosition(popup, props.clientRect);
},
onKeyDown: (props: { event: KeyboardEvent }) => {
if (props.event.key === "Escape") {
cleanup();
return true;
}
return renderer?.ref?.onKeyDown(props) ?? false;
},
onExit: () => {
cleanup();
},
};
function updatePosition(
el: HTMLDivElement,
clientRect: (() => DOMRect | null) | null | undefined,
) {
if (!clientRect) return;
const virtualEl = {
getBoundingClientRect: () => clientRect() ?? new DOMRect(),
};
computePosition(virtualEl, el, {
placement: "bottom-start",
strategy: "fixed",
middleware: [offset(4), flip(), shift({ padding: 8 })],
}).then(({ x, y }) => {
el.style.left = `${x}px`;
el.style.top = `${y}px`;
});
}
function cleanup() {
renderer?.destroy();
renderer = null;
popup?.remove();
popup = null;
}
},
};
}

View File

@@ -1,79 +0,0 @@
"use client";
import { useState, lazy, Suspense } from "react";
import { SmilePlus } from "lucide-react";
import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover";
const EmojiPicker = lazy(() =>
import("@/components/common/emoji-picker").then((m) => ({ default: m.EmojiPicker })),
);
const QUICK_EMOJIS = ["👍", "👌", "❤️", "😄", "🎉", "😕", "🚀", "👀"];
interface QuickEmojiPickerProps {
onSelect: (emoji: string) => void;
align?: "start" | "end";
className?: string;
}
function QuickEmojiPicker({ onSelect, align = "start", className }: QuickEmojiPickerProps) {
const [open, setOpen] = useState(false);
const [showFull, setShowFull] = useState(false);
const handleOpenChange = (v: boolean) => {
setOpen(v);
if (!v) setShowFull(false);
};
const handleSelect = (emoji: string) => {
onSelect(emoji);
setOpen(false);
setShowFull(false);
};
return (
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger
render={
<button
type="button"
className={`inline-flex items-center justify-center h-6 w-6 rounded-full text-muted-foreground hover:bg-accent hover:text-foreground transition-colors ${className ?? ""}`}
>
<SmilePlus className="h-3.5 w-3.5" />
</button>
}
/>
<PopoverContent align={align} className="w-auto p-0">
{showFull ? (
<Suspense fallback={<div className="p-4 text-sm text-muted-foreground">Loading...</div>}>
<EmojiPicker onSelect={handleSelect} />
</Suspense>
) : (
<div className="p-2">
<div className="flex gap-1">
{QUICK_EMOJIS.map((emoji) => (
<button
key={emoji}
type="button"
onClick={() => handleSelect(emoji)}
className="h-8 w-8 flex items-center justify-center rounded hover:bg-accent text-base transition-colors"
>
{emoji}
</button>
))}
</div>
<button
type="button"
onClick={() => setShowFull(true)}
className="mt-1.5 w-full text-xs text-muted-foreground hover:text-foreground text-center py-1 rounded hover:bg-accent transition-colors"
>
More emojis...
</button>
</div>
)}
</PopoverContent>
</Popover>
);
}
export { QuickEmojiPicker };

View File

@@ -1,9 +1,17 @@
"use client";
import { useState, lazy, Suspense } from "react";
import { SmilePlus } from "lucide-react";
import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover";
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
import { QuickEmojiPicker } from "@/components/common/quick-emoji-picker";
import { useActorName } from "@/features/workspace";
const EmojiPicker = lazy(() =>
import("@/components/common/emoji-picker").then((m) => ({ default: m.EmojiPicker })),
);
const QUICK_EMOJIS = ["👍", "👌", "❤️", "😄", "🎉", "😕", "🚀", "👀"];
interface ReactionItem {
id: string;
actor_type: string;
@@ -40,17 +48,22 @@ export function ReactionBar({
currentUserId,
onToggle,
className,
hideAddButton,
}: {
reactions: ReactionItem[];
currentUserId?: string;
onToggle: (emoji: string) => void;
className?: string;
hideAddButton?: boolean;
}) {
const [pickerOpen, setPickerOpen] = useState(false);
const [showFullPicker, setShowFullPicker] = useState(false);
const grouped = groupReactions(reactions, currentUserId);
const { getActorName } = useActorName();
const handlePickerOpenChange = (open: boolean) => {
setPickerOpen(open);
if (!open) setShowFullPicker(false);
};
return (
<div className={`flex flex-wrap items-center gap-1.5 ${className ?? ""}`}>
{grouped.map((g) => (
@@ -60,10 +73,10 @@ export function ReactionBar({
<button
type="button"
onClick={() => onToggle(g.emoji)}
className={`inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs transition-colors hover:bg-brand/15 ${
className={`inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs transition-colors hover:bg-accent ${
g.reacted
? "border-brand/30 bg-brand/8 text-brand"
: "border-brand/10 bg-brand/4 text-muted-foreground"
? "border-primary/40 bg-primary/10 text-primary"
: "border-border text-muted-foreground"
}`}
>
<span>{g.emoji}</span>
@@ -76,7 +89,56 @@ export function ReactionBar({
</TooltipContent>
</Tooltip>
))}
{!hideAddButton && <QuickEmojiPicker onSelect={onToggle} />}
<Popover open={pickerOpen} onOpenChange={handlePickerOpenChange}>
<PopoverTrigger
render={
<button
type="button"
className="inline-flex items-center justify-center h-6 w-6 rounded-full text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
>
<SmilePlus className="h-3.5 w-3.5" />
</button>
}
/>
<PopoverContent align="start" className="w-auto p-0">
{showFullPicker ? (
<Suspense fallback={<div className="p-4 text-sm text-muted-foreground">Loading...</div>}>
<EmojiPicker
onSelect={(emoji) => {
onToggle(emoji);
setPickerOpen(false);
setShowFullPicker(false);
}}
/>
</Suspense>
) : (
<div className="p-2">
<div className="flex gap-1">
{QUICK_EMOJIS.map((emoji) => (
<button
key={emoji}
type="button"
onClick={() => {
onToggle(emoji);
setPickerOpen(false);
}}
className="h-8 w-8 flex items-center justify-center rounded hover:bg-accent text-base transition-colors"
>
{emoji}
</button>
))}
</div>
<button
type="button"
onClick={() => setShowFullPicker(true)}
className="mt-1.5 w-full text-xs text-muted-foreground hover:text-foreground text-center py-1 rounded hover:bg-accent transition-colors"
>
More emojis...
</button>
</div>
)}
</PopoverContent>
</Popover>
</div>
);
}

View File

@@ -0,0 +1,158 @@
/* Rich text editor: ProseMirror styles using shadcn design tokens */
.rich-text-editor.ProseMirror {
color: var(--foreground);
caret-color: var(--foreground);
}
.rich-text-editor.ProseMirror:focus {
outline: none;
}
/* Placeholder */
.rich-text-editor .is-editor-empty:first-child::before {
content: attr(data-placeholder);
float: left;
color: var(--muted-foreground);
pointer-events: none;
height: 0;
}
/* Headings */
.rich-text-editor h1 {
font-size: 1.125rem;
font-weight: 700;
margin-top: 1.25rem;
margin-bottom: 0.5rem;
line-height: 1.4;
}
.rich-text-editor h2 {
font-size: 1rem;
font-weight: 600;
margin-top: 1rem;
margin-bottom: 0.5rem;
line-height: 1.4;
}
.rich-text-editor h3 {
font-size: 0.875rem;
font-weight: 600;
margin-top: 0.75rem;
margin-bottom: 0.25rem;
line-height: 1.4;
}
/* Paragraphs */
.rich-text-editor p {
margin-top: 0.375rem;
margin-bottom: 0.375rem;
line-height: 1.625;
}
/* First child should not have top margin */
.rich-text-editor > *:first-child {
margin-top: 0;
}
/* Last child should not have bottom margin */
.rich-text-editor > *:last-child {
margin-bottom: 0;
}
/* Lists */
.rich-text-editor ul {
list-style-type: disc;
padding-inline-start: 1.25rem;
margin: 0.375rem 0;
}
.rich-text-editor ol {
list-style-type: decimal;
padding-inline-start: 1.25rem;
margin: 0.375rem 0;
}
.rich-text-editor li {
margin: 0.125rem 0;
line-height: 1.625;
}
.rich-text-editor li::marker {
color: var(--muted-foreground);
}
/* Inline code */
.rich-text-editor code {
font-family: var(--font-mono, ui-monospace, monospace);
font-size: 0.8em;
background: var(--muted);
color: var(--foreground);
padding: 0.15em 0.35em;
border-radius: calc(var(--radius) * 0.6);
}
/* Code blocks */
.rich-text-editor pre {
font-family: var(--font-mono, ui-monospace, monospace);
background: var(--muted);
border-radius: var(--radius);
padding: 0.75rem 1rem;
margin: 0.5rem 0;
overflow-x: auto;
}
.rich-text-editor pre code {
background: none;
padding: 0;
font-size: 0.8125rem;
line-height: 1.6;
}
/* Blockquotes */
.rich-text-editor blockquote {
border-left: 2px solid var(--border);
padding-left: 0.75rem;
margin: 0.5rem 0;
color: var(--muted-foreground);
}
/* Horizontal rules */
.rich-text-editor hr {
border: none;
border-top: 1px solid var(--border);
margin: 1rem 0;
}
/* Links */
.rich-text-editor a {
color: var(--brand);
text-decoration: none;
}
.rich-text-editor a:hover {
text-decoration: underline;
text-underline-offset: 2px;
}
/* Mentions */
.rich-text-editor .mention {
color: var(--primary);
font-weight: 600;
text-decoration: none;
margin: 0 0.125rem;
}
/* Strong / emphasis */
.rich-text-editor strong {
font-weight: 600;
}
.rich-text-editor em {
font-style: italic;
}
.rich-text-editor s {
text-decoration: line-through;
color: var(--muted-foreground);
}

View File

@@ -0,0 +1,354 @@
"use client";
import {
forwardRef,
useEffect,
useImperativeHandle,
useRef,
} from "react";
import { useEditor, EditorContent } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import Placeholder from "@tiptap/extension-placeholder";
import Link from "@tiptap/extension-link";
import Typography from "@tiptap/extension-typography";
import Mention from "@tiptap/extension-mention";
import Image from "@tiptap/extension-image";
import { Markdown } from "@tiptap/markdown";
import { Extension, mergeAttributes } from "@tiptap/core";
import { Plugin, PluginKey } from "@tiptap/pm/state";
import { cn } from "@/lib/utils";
import type { UploadResult } from "@/shared/hooks/use-file-upload";
import { createMentionSuggestion } from "./mention-suggestion";
import "./rich-text-editor.css";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface RichTextEditorProps {
defaultValue?: string;
onUpdate?: (markdown: string) => void;
placeholder?: string;
editable?: boolean;
className?: string;
debounceMs?: number;
onSubmit?: () => void;
onUploadFile?: (file: File) => Promise<UploadResult | null>;
}
interface RichTextEditorRef {
getMarkdown: () => string;
clearContent: () => void;
focus: () => void;
insertFile: (filename: string, url: string, isImage: boolean) => void;
}
const LinkExtension = Link.configure({
openOnClick: true,
autolink: true,
HTMLAttributes: {
class: "text-primary hover:underline cursor-pointer",
},
});
const MentionExtension = Mention.configure({
HTMLAttributes: { class: "mention" },
suggestion: createMentionSuggestion(),
}).extend({
renderHTML({ node, HTMLAttributes }) {
return [
"span",
mergeAttributes(
{ "data-type": "mention" },
this.options.HTMLAttributes,
HTMLAttributes,
{
"data-mention-type": node.attrs.type ?? "member",
"data-mention-id": node.attrs.id,
},
),
`@${node.attrs.label ?? node.attrs.id}`,
];
},
addAttributes() {
return {
...this.parent?.(),
type: {
default: "member",
parseHTML: (el: HTMLElement) =>
el.getAttribute("data-mention-type") ?? "member",
renderHTML: () => ({}),
},
};
},
// @tiptap/markdown: custom tokenizer to parse [@Label](mention://type/id)
markdownTokenizer: {
name: "mention",
level: "inline" as const,
start(src: string) {
return src.search(/\[@[^\]]+\]\(mention:\/\//);
},
tokenize(src: string) {
const match = src.match(
/^\[@([^\]]+)\]\(mention:\/\/(\w+)\/([^)]+)\)/,
);
if (!match) return undefined;
return {
type: "mention",
raw: match[0],
attributes: { label: match[1], type: match[2], id: match[3] },
};
},
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
parseMarkdown: (token: any, helpers: any) => {
return helpers.createNode("mention", token.attributes);
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
renderMarkdown: (node: any) => {
const { id, label, type = "member" } = node.attrs || {};
return `[@${label ?? id}](mention://${type}/${id})`;
},
});
// ---------------------------------------------------------------------------
// Submit shortcut extension (Mod+Enter)
// ---------------------------------------------------------------------------
function createSubmitExtension(onSubmit: () => void) {
return Extension.create({
name: "submitShortcut",
addKeyboardShortcuts() {
return {
"Mod-Enter": () => {
onSubmit();
return true;
},
};
},
});
}
// ---------------------------------------------------------------------------
// File upload extension (paste + drop)
// ---------------------------------------------------------------------------
function createFileUploadExtension(
onUploadFileRef: React.RefObject<((file: File) => Promise<UploadResult | null>) | undefined>,
) {
return Extension.create({
name: "fileUpload",
addProseMirrorPlugins() {
const { editor } = this;
const handleFiles = async (files: FileList, pos?: number) => {
const handler = onUploadFileRef.current;
if (!handler) return false;
let handled = false;
for (const file of Array.from(files)) {
handled = true;
try {
const result = await handler(file);
if (!result) continue;
const isImage = file.type.startsWith("image/");
if (isImage) {
editor
.chain()
.focus()
.setImage({ src: result.link, alt: result.filename })
.run();
} else {
// Insert as a markdown link
const linkText = `[${result.filename}](${result.link})`;
if (pos !== undefined) {
editor.chain().focus().insertContentAt(pos, linkText).run();
} else {
editor.chain().focus().insertContent(linkText).run();
}
}
} catch {
// Upload errors handled by the hook/caller via toast
}
}
return handled;
};
return [
new Plugin({
key: new PluginKey("fileUpload"),
props: {
handlePaste(_view, event) {
const files = event.clipboardData?.files;
if (!files?.length) return false;
if (!onUploadFileRef.current) return false;
handleFiles(files);
return true;
},
handleDrop(_view, event) {
const files = (event as DragEvent).dataTransfer?.files;
if (!files?.length) return false;
if (!onUploadFileRef.current) return false;
handleFiles(files);
return true;
},
},
}),
];
},
});
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
const RichTextEditor = forwardRef<RichTextEditorRef, RichTextEditorProps>(
function RichTextEditor(
{
defaultValue = "",
onUpdate,
placeholder: placeholderText = "",
editable = true,
className,
debounceMs = 300,
onSubmit,
onUploadFile,
},
ref,
) {
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const onUpdateRef = useRef(onUpdate);
const onSubmitRef = useRef(onSubmit);
const onUploadFileRef = useRef(onUploadFile);
// Helper to get markdown from @tiptap/markdown extension.
// Post-processes mention shortcodes [@ id="..." label="..."] → markdown
// links, using the Tiptap JSON doc for type info, in case the
// renderMarkdown override doesn't take effect.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const getEditorMarkdown = (ed: any): string => {
const md: string = ed?.getMarkdown?.() ?? "";
if (!md || !md.includes("[@ ")) return md;
// Build type map from editor JSON (which always has the type attr)
const json = ed?.getJSON?.();
const typeMap = new Map<string, string>();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function walk(node: any) {
if (node?.type === "mention" && node.attrs?.id) {
typeMap.set(node.attrs.id, node.attrs.type || "member");
}
if (node?.content) node.content.forEach(walk);
}
if (json) walk(json);
return md.replace(
/\[@\s+([^\]]*)\]/g,
(match: string, 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;
const type = typeMap.get(id) || "member";
const display = type === "issue" ? label : `@${label}`;
return `[${display}](mention://${type}/${id})`;
},
);
};
// Keep refs in sync without recreating editor
onUpdateRef.current = onUpdate;
onSubmitRef.current = onSubmit;
onUploadFileRef.current = onUploadFile;
const editor = useEditor({
immediatelyRender: false,
editable,
content: defaultValue || "",
contentType: defaultValue ? "markdown" : undefined,
extensions: [
StarterKit.configure({
heading: { levels: [1, 2, 3] },
link: false,
}),
Placeholder.configure({
placeholder: placeholderText,
}),
LinkExtension,
Typography,
MentionExtension,
Image.configure({
inline: false,
allowBase64: false,
HTMLAttributes: { style: "max-width: 100%; height: auto;" },
}),
Markdown,
createSubmitExtension(() => onSubmitRef.current?.()),
createFileUploadExtension(onUploadFileRef),
],
onUpdate: ({ editor: ed }) => {
if (!onUpdateRef.current) return;
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
onUpdateRef.current?.(ed.getMarkdown());
}, debounceMs);
},
editorProps: {
handleDOMEvents: {
click(_view, event) {
if (event.metaKey || event.ctrlKey) {
const link = (event.target as HTMLElement).closest("a");
const href = link?.getAttribute("href");
if (href && !href.startsWith("mention://")) {
window.open(href, "_blank", "noopener,noreferrer");
event.preventDefault();
return true;
}
}
return false;
},
},
attributes: {
class: cn("rich-text-editor text-sm outline-none", className),
},
},
});
// Cleanup debounce on unmount
useEffect(() => {
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, []);
useImperativeHandle(ref, () => ({
getMarkdown: () => editor?.getMarkdown() ?? "",
clearContent: () => {
editor?.commands.clearContent();
},
focus: () => {
editor?.commands.focus();
},
insertFile: (filename: string, url: string, isImage: boolean) => {
if (!editor) return;
if (isImage) {
editor.chain().focus().setImage({ src: url, alt: filename }).run();
} else {
editor.chain().focus().insertContent(`[${filename}](${url})`).run();
}
},
}));
if (!editor) return null;
return <EditorContent editor={editor} />;
},
);
export { RichTextEditor, type RichTextEditorProps, type RichTextEditorRef };

View File

@@ -117,13 +117,11 @@ const TitleEditor = forwardRef<TitleEditorRef, TitleEditorProps>(
},
});
// Auto-focus after mount — delay to wait for Dialog open animation
// Auto-focus after mount
useEffect(() => {
if (autoFocus && editor) {
const timer = setTimeout(() => {
editor.commands.focus("end");
}, 50);
return () => clearTimeout(timer);
// Move cursor to end
editor.commands.focus("end");
}
}, [autoFocus, editor]);

View File

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

View File

@@ -5,7 +5,6 @@ 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'
/**
@@ -54,6 +53,27 @@ function urlTransform(url: string): string {
return defaultUrlTransform(url)
}
/**
* Convert legacy mention shortcodes [@ id="UUID" label="LABEL"] to markdown
* link format [@LABEL](mention://member/UUID) so they render as styled mentions.
*/
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})`
}
)
}
// File path detection regex - matches paths starting with /, ~/, or ./
const FILE_PATH_REGEX =
@@ -79,9 +99,9 @@ function createComponents(
),
// 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
// Mention links: mention://member/id, mention://agent/id, mention://issue/id
if (href?.startsWith('mention://')) {
const mentionMatch = href.match(/^mention:\/\/(member|agent|issue|all)\/(.+)$/)
const mentionMatch = href.match(/^mention:\/\/(member|agent|issue)\/(.+)$/)
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} />

View File

@@ -2,4 +2,3 @@ export { Markdown, MemoizedMarkdown, type MarkdownProps, type RenderMode } from
export { CodeBlock, InlineCode, type CodeBlockProps } from './CodeBlock'
export { StreamingMarkdown, type StreamingMarkdownProps } from './StreamingMarkdown'
export { preprocessLinks, detectLinks, hasLinks } from './linkify'
export { preprocessMentionShortcodes } from './mentions'

View File

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

View File

@@ -13,19 +13,19 @@ const Toaster = ({ ...props }: ToasterProps) => {
className="toaster group"
icons={{
success: (
<CircleCheckIcon className="size-4 text-success" />
<CircleCheckIcon className="size-4" />
),
info: (
<InfoIcon className="size-4 text-info" />
<InfoIcon className="size-4" />
),
warning: (
<TriangleAlertIcon className="size-4 text-warning" />
<TriangleAlertIcon className="size-4" />
),
error: (
<OctagonXIcon className="size-4 text-destructive" />
<OctagonXIcon className="size-4" />
),
loading: (
<Loader2Icon className="size-4 animate-spin text-brand" />
<Loader2Icon className="size-4 animate-spin" />
),
}}
style={

View File

@@ -1,17 +0,0 @@
"use client";
import { useWorkspaceStore } from "@/features/workspace";
/**
* Returns the current workspace ID.
*
* Bridge hook: reads from Zustand workspace store now.
* Phase 3 will switch to core/workspace/store.ts — signature stays the same.
*/
export function useWorkspaceId(): string {
const workspaceId = useWorkspaceStore((s) => s.workspace?.id);
if (!workspaceId) {
throw new Error("useWorkspaceId: no workspace selected");
}
return workspaceId;
}

View File

@@ -1,16 +0,0 @@
export {
inboxKeys,
inboxListOptions,
deduplicateInboxItems,
} from "./queries";
export {
useMarkInboxRead,
useArchiveInbox,
useMarkAllInboxRead,
useArchiveAllInbox,
useArchiveAllReadInbox,
useArchiveCompletedInbox,
} from "./mutations";
export { onInboxNew, onInboxInvalidate, onInboxIssueStatusChanged } from "./ws-updaters";

View File

@@ -1,113 +0,0 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { api } from "@/shared/api";
import { inboxKeys } from "./queries";
import { useWorkspaceId } from "@core/hooks";
import type { InboxItem } from "@/shared/types";
export function useMarkInboxRead() {
const qc = useQueryClient();
const wsId = useWorkspaceId();
return useMutation({
mutationFn: (id: string) => api.markInboxRead(id),
onMutate: async (id) => {
await qc.cancelQueries({ queryKey: inboxKeys.list(wsId) });
const prev = qc.getQueryData<InboxItem[]>(inboxKeys.list(wsId));
qc.setQueryData<InboxItem[]>(inboxKeys.list(wsId), (old) =>
old?.map((item) => (item.id === id ? { ...item, read: true } : item)),
);
return { prev };
},
onError: (_err, _id, ctx) => {
if (ctx?.prev) qc.setQueryData(inboxKeys.list(wsId), ctx.prev);
},
onSettled: () => {
qc.invalidateQueries({ queryKey: inboxKeys.list(wsId) });
},
});
}
export function useArchiveInbox() {
const qc = useQueryClient();
const wsId = useWorkspaceId();
return useMutation({
mutationFn: (id: string) => api.archiveInbox(id),
onMutate: async (id) => {
await qc.cancelQueries({ queryKey: inboxKeys.list(wsId) });
const prev = qc.getQueryData<InboxItem[]>(inboxKeys.list(wsId));
// Archive all items for the same issue (same behavior as store)
const target = prev?.find((i) => i.id === id);
const issueId = target?.issue_id;
qc.setQueryData<InboxItem[]>(inboxKeys.list(wsId), (old) =>
old?.map((item) =>
item.id === id || (issueId && item.issue_id === issueId)
? { ...item, archived: true }
: item,
),
);
return { prev };
},
onError: (_err, _id, ctx) => {
if (ctx?.prev) qc.setQueryData(inboxKeys.list(wsId), ctx.prev);
},
onSettled: () => {
qc.invalidateQueries({ queryKey: inboxKeys.list(wsId) });
},
});
}
export function useMarkAllInboxRead() {
const qc = useQueryClient();
const wsId = useWorkspaceId();
return useMutation({
mutationFn: () => api.markAllInboxRead(),
onMutate: async () => {
await qc.cancelQueries({ queryKey: inboxKeys.list(wsId) });
const prev = qc.getQueryData<InboxItem[]>(inboxKeys.list(wsId));
qc.setQueryData<InboxItem[]>(inboxKeys.list(wsId), (old) =>
old?.map((item) =>
!item.archived ? { ...item, read: true } : item,
),
);
return { prev };
},
onError: (_err, _vars, ctx) => {
if (ctx?.prev) qc.setQueryData(inboxKeys.list(wsId), ctx.prev);
},
onSettled: () => {
qc.invalidateQueries({ queryKey: inboxKeys.list(wsId) });
},
});
}
export function useArchiveAllInbox() {
const qc = useQueryClient();
const wsId = useWorkspaceId();
return useMutation({
mutationFn: () => api.archiveAllInbox(),
onSettled: () => {
qc.invalidateQueries({ queryKey: inboxKeys.list(wsId) });
},
});
}
export function useArchiveAllReadInbox() {
const qc = useQueryClient();
const wsId = useWorkspaceId();
return useMutation({
mutationFn: () => api.archiveAllReadInbox(),
onSettled: () => {
qc.invalidateQueries({ queryKey: inboxKeys.list(wsId) });
},
});
}
export function useArchiveCompletedInbox() {
const qc = useQueryClient();
const wsId = useWorkspaceId();
return useMutation({
mutationFn: () => api.archiveCompletedInbox(),
onSettled: () => {
qc.invalidateQueries({ queryKey: inboxKeys.list(wsId) });
},
});
}

View File

@@ -1,43 +0,0 @@
import { queryOptions } from "@tanstack/react-query";
import { api } from "@/shared/api";
import type { InboxItem } from "@/shared/types";
export const inboxKeys = {
all: (wsId: string) => ["inbox", wsId] as const,
list: (wsId: string) => [...inboxKeys.all(wsId), "list"] as const,
};
export function inboxListOptions(wsId: string) {
return queryOptions({
queryKey: inboxKeys.list(wsId),
queryFn: () => api.listInbox(),
});
}
/**
* Deduplicate inbox items by issue_id (one entry per issue, Linear-style).
* Exported for consumers to use in useMemo — not in queryOptions select
* (to avoid new array references on every cache update).
*/
export function deduplicateInboxItems(items: InboxItem[]): InboxItem[] {
const active = items.filter((i) => !i.archived);
const groups = new Map<string, InboxItem[]>();
for (const item of active) {
const key = item.issue_id ?? item.id;
const group = groups.get(key) ?? [];
group.push(item);
groups.set(key, group);
}
const merged: InboxItem[] = [];
for (const group of groups.values()) {
group.sort(
(a, b) =>
new Date(b.created_at).getTime() - new Date(a.created_at).getTime(),
);
if (group[0]) merged.push(group[0]);
}
return merged.sort(
(a, b) =>
new Date(b.created_at).getTime() - new Date(a.created_at).getTime(),
);
}

View File

@@ -1,30 +0,0 @@
import type { QueryClient } from "@tanstack/react-query";
import { inboxKeys } from "./queries";
import type { InboxItem, IssueStatus } from "@/shared/types";
export function onInboxNew(
qc: QueryClient,
wsId: string,
_item: InboxItem,
) {
// Use invalidateQueries instead of setQueryData — triggers a refetch that
// reliably notifies all observers. The inbox list is small so this is cheap.
qc.invalidateQueries({ queryKey: inboxKeys.list(wsId) });
}
export function onInboxIssueStatusChanged(
qc: QueryClient,
wsId: string,
issueId: string,
status: IssueStatus,
) {
qc.setQueryData<InboxItem[]>(inboxKeys.list(wsId), (old) =>
old?.map((i) =>
i.issue_id === issueId ? { ...i, issue_status: status } : i,
),
);
}
export function onInboxInvalidate(qc: QueryClient, wsId: string) {
qc.invalidateQueries({ queryKey: inboxKeys.list(wsId) });
}

View File

@@ -1,3 +0,0 @@
export { createQueryClient } from "./query-client";
export { QueryProvider } from "./provider";
export { useWorkspaceId } from "./hooks";

View File

@@ -1,29 +0,0 @@
export {
issueKeys,
issueListOptions,
issueDetailOptions,
issueTimelineOptions,
issueReactionsOptions,
issueSubscribersOptions,
} from "./queries";
export {
useLoadMoreDoneIssues,
useCreateIssue,
useUpdateIssue,
useDeleteIssue,
useBatchUpdateIssues,
useBatchDeleteIssues,
useCreateComment,
useUpdateComment,
useDeleteComment,
useToggleCommentReaction,
useToggleIssueReaction,
useToggleIssueSubscriber,
} from "./mutations";
export {
onIssueCreated,
onIssueUpdated,
onIssueDeleted,
} from "./ws-updaters";

View File

@@ -1,495 +0,0 @@
import { useState, useCallback } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { api } from "@/shared/api";
import { issueKeys, CLOSED_PAGE_SIZE } from "./queries";
import { useWorkspaceId } from "@core/hooks";
import type { Issue, IssueReaction } from "@/shared/types";
import type {
CreateIssueRequest,
UpdateIssueRequest,
ListIssuesResponse,
} from "@/shared/types";
import type { TimelineEntry, IssueSubscriber, Reaction } from "@/shared/types";
// ---------------------------------------------------------------------------
// Shared mutation variable types — used by both mutation hooks and
// useMutationState consumers to keep the type assertion in sync.
// ---------------------------------------------------------------------------
export type ToggleCommentReactionVars = {
commentId: string;
emoji: string;
existing: Reaction | undefined;
};
export type ToggleIssueReactionVars = {
emoji: string;
existing: IssueReaction | undefined;
};
// ---------------------------------------------------------------------------
// Done issue pagination
// ---------------------------------------------------------------------------
export function useLoadMoreDoneIssues() {
const qc = useQueryClient();
const wsId = useWorkspaceId();
const [isLoading, setIsLoading] = useState(false);
const cache = qc.getQueryData<ListIssuesResponse>(issueKeys.list(wsId));
const doneLoaded = cache
? cache.issues.filter((i) => i.status === "done").length
: 0;
const doneTotal = cache?.doneTotal ?? 0;
const hasMore = doneLoaded < doneTotal;
const loadMore = useCallback(async () => {
if (isLoading || !hasMore) return;
setIsLoading(true);
try {
const res = await api.listIssues({
status: "done",
limit: CLOSED_PAGE_SIZE,
offset: doneLoaded,
});
qc.setQueryData<ListIssuesResponse>(issueKeys.list(wsId), (old) => {
if (!old) return old;
const existingIds = new Set(old.issues.map((i) => i.id));
const newIssues = res.issues.filter((i) => !existingIds.has(i.id));
return {
...old,
issues: [...old.issues, ...newIssues],
doneTotal: res.total,
};
});
} finally {
setIsLoading(false);
}
}, [qc, wsId, doneLoaded, hasMore, isLoading]);
return { loadMore, hasMore, isLoading, doneTotal };
}
// ---------------------------------------------------------------------------
// Issue CRUD
// ---------------------------------------------------------------------------
export function useCreateIssue() {
const qc = useQueryClient();
const wsId = useWorkspaceId();
return useMutation({
mutationFn: (data: CreateIssueRequest) => api.createIssue(data),
onSuccess: (newIssue) => {
qc.setQueryData<ListIssuesResponse>(issueKeys.list(wsId), (old) =>
old && !old.issues.some((i) => i.id === newIssue.id)
? {
...old,
issues: [...old.issues, newIssue],
total: old.total + 1,
doneTotal: (old.doneTotal ?? 0) + (newIssue.status === "done" ? 1 : 0),
}
: old,
);
// Invalidate parent's children query so sub-issues list updates immediately
if (newIssue.parent_issue_id) {
qc.invalidateQueries({ queryKey: issueKeys.children(wsId, newIssue.parent_issue_id) });
}
},
onSettled: () => {
qc.invalidateQueries({ queryKey: issueKeys.list(wsId) });
},
});
}
export function useUpdateIssue() {
const qc = useQueryClient();
const wsId = useWorkspaceId();
return useMutation({
mutationFn: ({ id, ...data }: { id: string } & UpdateIssueRequest) =>
api.updateIssue(id, data),
onMutate: ({ id, ...data }) => {
// Fire-and-forget cancelQueries — keeps onMutate synchronous so the
// cache update happens in the same tick as mutate(). Awaiting would
// yield to the event loop, letting @dnd-kit reset its visual state
// before the optimistic update lands.
qc.cancelQueries({ queryKey: issueKeys.list(wsId) });
const prevList = qc.getQueryData<ListIssuesResponse>(issueKeys.list(wsId));
const prevDetail = qc.getQueryData<Issue>(issueKeys.detail(wsId, id));
// Resolve parent_issue_id from the freshest source so we can keep the
// parent's children cache in sync (used by the parent issue's
// sub-issues list).
const parentId =
prevDetail?.parent_issue_id ??
prevList?.issues.find((i) => i.id === id)?.parent_issue_id ??
null;
const prevChildren = parentId
? qc.getQueryData<Issue[]>(issueKeys.children(wsId, parentId))
: undefined;
qc.setQueryData<ListIssuesResponse>(issueKeys.list(wsId), (old) =>
old
? {
...old,
issues: old.issues.map((i) =>
i.id === id ? { ...i, ...data } : i,
),
}
: old,
);
qc.setQueryData<Issue>(issueKeys.detail(wsId, id), (old) =>
old ? { ...old, ...data } : old,
);
if (parentId) {
qc.setQueryData<Issue[]>(
issueKeys.children(wsId, parentId),
(old) =>
old?.map((c) => (c.id === id ? { ...c, ...data } : c)),
);
}
return { prevList, prevDetail, prevChildren, parentId, id };
},
onError: (_err, _vars, ctx) => {
if (ctx?.prevList) qc.setQueryData(issueKeys.list(wsId), ctx.prevList);
if (ctx?.prevDetail)
qc.setQueryData(issueKeys.detail(wsId, ctx.id), ctx.prevDetail);
if (ctx?.parentId && ctx.prevChildren !== undefined) {
qc.setQueryData(
issueKeys.children(wsId, ctx.parentId),
ctx.prevChildren,
);
}
},
onSettled: (_data, _err, vars, ctx) => {
qc.invalidateQueries({ queryKey: issueKeys.detail(wsId, vars.id) });
qc.invalidateQueries({ queryKey: issueKeys.list(wsId) });
if (ctx?.parentId) {
qc.invalidateQueries({
queryKey: issueKeys.children(wsId, ctx.parentId),
});
}
},
});
}
export function useDeleteIssue() {
const qc = useQueryClient();
const wsId = useWorkspaceId();
return useMutation({
mutationFn: (id: string) => api.deleteIssue(id),
onMutate: async (id) => {
await qc.cancelQueries({ queryKey: issueKeys.list(wsId) });
const prevList = qc.getQueryData<ListIssuesResponse>(issueKeys.list(wsId));
qc.setQueryData<ListIssuesResponse>(issueKeys.list(wsId), (old) => {
if (!old) return old;
const deleted = old.issues.find((i) => i.id === id);
return {
...old,
issues: old.issues.filter((i) => i.id !== id),
total: old.total - 1,
doneTotal: (old.doneTotal ?? 0) - (deleted?.status === "done" ? 1 : 0),
};
});
qc.removeQueries({ queryKey: issueKeys.detail(wsId, id) });
return { prevList };
},
onError: (_err, _id, ctx) => {
if (ctx?.prevList) qc.setQueryData(issueKeys.list(wsId), ctx.prevList);
},
onSettled: () => {
qc.invalidateQueries({ queryKey: issueKeys.list(wsId) });
},
});
}
export function useBatchUpdateIssues() {
const qc = useQueryClient();
const wsId = useWorkspaceId();
return useMutation({
mutationFn: ({
ids,
updates,
}: {
ids: string[];
updates: UpdateIssueRequest;
}) => api.batchUpdateIssues(ids, updates),
onMutate: async ({ ids, updates }) => {
await qc.cancelQueries({ queryKey: issueKeys.list(wsId) });
const prevList = qc.getQueryData<ListIssuesResponse>(issueKeys.list(wsId));
qc.setQueryData<ListIssuesResponse>(issueKeys.list(wsId), (old) =>
old
? {
...old,
issues: old.issues.map((i) =>
ids.includes(i.id) ? { ...i, ...updates } : i,
),
}
: old,
);
return { prevList };
},
onError: (_err, _vars, ctx) => {
if (ctx?.prevList) qc.setQueryData(issueKeys.list(wsId), ctx.prevList);
},
onSettled: () => {
qc.invalidateQueries({ queryKey: issueKeys.list(wsId) });
},
});
}
export function useBatchDeleteIssues() {
const qc = useQueryClient();
const wsId = useWorkspaceId();
return useMutation({
mutationFn: (ids: string[]) => api.batchDeleteIssues(ids),
onMutate: async (ids) => {
await qc.cancelQueries({ queryKey: issueKeys.list(wsId) });
const prevList = qc.getQueryData<ListIssuesResponse>(issueKeys.list(wsId));
qc.setQueryData<ListIssuesResponse>(issueKeys.list(wsId), (old) => {
if (!old) return old;
const idSet = new Set(ids);
const doneDeleted = old.issues.filter(
(i) => idSet.has(i.id) && i.status === "done",
).length;
return {
...old,
issues: old.issues.filter((i) => !idSet.has(i.id)),
total: old.total - ids.length,
doneTotal: (old.doneTotal ?? 0) - doneDeleted,
};
});
return { prevList };
},
onError: (_err, _ids, ctx) => {
if (ctx?.prevList) qc.setQueryData(issueKeys.list(wsId), ctx.prevList);
},
onSettled: () => {
qc.invalidateQueries({ queryKey: issueKeys.list(wsId) });
},
});
}
// ---------------------------------------------------------------------------
// Comments / Timeline
// ---------------------------------------------------------------------------
export function useCreateComment(issueId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: ({
content,
type,
parentId,
attachmentIds,
}: {
content: string;
type?: string;
parentId?: string;
attachmentIds?: string[];
}) => api.createComment(issueId, content, type, parentId, attachmentIds),
onSuccess: (comment) => {
qc.setQueryData<TimelineEntry[]>(
issueKeys.timeline(issueId),
(old) => {
if (!old) return old;
const entry: TimelineEntry = {
type: "comment",
id: comment.id,
actor_type: comment.author_type,
actor_id: comment.author_id,
content: comment.content,
parent_id: comment.parent_id,
comment_type: comment.type,
reactions: comment.reactions ?? [],
attachments: comment.attachments ?? [],
created_at: comment.created_at,
updated_at: comment.updated_at,
};
if (old.some((e) => e.id === comment.id)) return old;
return [...old, entry];
},
);
},
onSettled: () => {
qc.invalidateQueries({ queryKey: issueKeys.timeline(issueId) });
},
});
}
export function useUpdateComment(issueId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ commentId, content }: { commentId: string; content: string }) =>
api.updateComment(commentId, content),
onMutate: async ({ commentId, content }) => {
await qc.cancelQueries({ queryKey: issueKeys.timeline(issueId) });
const prev = qc.getQueryData<TimelineEntry[]>(issueKeys.timeline(issueId));
qc.setQueryData<TimelineEntry[]>(
issueKeys.timeline(issueId),
(old) =>
old?.map((e) => (e.id === commentId ? { ...e, content } : e)),
);
return { prev };
},
onError: (_err, _vars, ctx) => {
if (ctx?.prev)
qc.setQueryData(issueKeys.timeline(issueId), ctx.prev);
},
onSettled: () => {
qc.invalidateQueries({ queryKey: issueKeys.timeline(issueId) });
},
});
}
export function useDeleteComment(issueId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (commentId: string) => api.deleteComment(commentId),
onMutate: async (commentId) => {
await qc.cancelQueries({ queryKey: issueKeys.timeline(issueId) });
const prev = qc.getQueryData<TimelineEntry[]>(issueKeys.timeline(issueId));
// Cascade: collect all child comment IDs
const toRemove = new Set<string>([commentId]);
if (prev) {
let changed = true;
while (changed) {
changed = false;
for (const e of prev) {
if (e.parent_id && toRemove.has(e.parent_id) && !toRemove.has(e.id)) {
toRemove.add(e.id);
changed = true;
}
}
}
}
qc.setQueryData<TimelineEntry[]>(
issueKeys.timeline(issueId),
(old) => old?.filter((e) => !toRemove.has(e.id)),
);
return { prev };
},
onError: (_err, _id, ctx) => {
if (ctx?.prev)
qc.setQueryData(issueKeys.timeline(issueId), ctx.prev);
},
onSettled: () => {
qc.invalidateQueries({ queryKey: issueKeys.timeline(issueId) });
},
});
}
export function useToggleCommentReaction(issueId: string) {
const qc = useQueryClient();
return useMutation({
mutationKey: ["toggleCommentReaction", issueId] as const,
mutationFn: async ({
commentId,
emoji,
existing,
}: ToggleCommentReactionVars) => {
if (existing) {
await api.removeReaction(commentId, emoji);
return null;
}
return api.addReaction(commentId, emoji);
},
onSettled: () => {
qc.invalidateQueries({ queryKey: issueKeys.timeline(issueId) });
},
});
}
// ---------------------------------------------------------------------------
// Issue-level Reactions
// ---------------------------------------------------------------------------
export function useToggleIssueReaction(issueId: string) {
const qc = useQueryClient();
return useMutation({
mutationKey: ["toggleIssueReaction", issueId] as const,
mutationFn: async ({
emoji,
existing,
}: ToggleIssueReactionVars) => {
if (existing) {
await api.removeIssueReaction(issueId, emoji);
return null;
}
return api.addIssueReaction(issueId, emoji);
},
onSettled: () => {
qc.invalidateQueries({ queryKey: issueKeys.reactions(issueId) });
},
});
}
// ---------------------------------------------------------------------------
// Issue Subscribers
// ---------------------------------------------------------------------------
export function useToggleIssueSubscriber(issueId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async ({
userId,
userType,
subscribed,
}: {
userId: string;
userType: "member" | "agent";
subscribed: boolean;
}) => {
if (subscribed) {
await api.unsubscribeFromIssue(issueId, userId, userType);
} else {
await api.subscribeToIssue(issueId, userId, userType);
}
},
onMutate: async ({ userId, userType, subscribed }) => {
await qc.cancelQueries({ queryKey: issueKeys.subscribers(issueId) });
const prev = qc.getQueryData<IssueSubscriber[]>(
issueKeys.subscribers(issueId),
);
if (subscribed) {
qc.setQueryData<IssueSubscriber[]>(
issueKeys.subscribers(issueId),
(old) =>
old?.filter(
(s) => !(s.user_id === userId && s.user_type === userType),
),
);
} else {
const temp: IssueSubscriber = {
issue_id: issueId,
user_type: userType,
user_id: userId,
reason: "manual",
created_at: new Date().toISOString(),
};
qc.setQueryData<IssueSubscriber[]>(
issueKeys.subscribers(issueId),
(old) => {
if (
old?.some(
(s) => s.user_id === userId && s.user_type === userType,
)
)
return old;
return [...(old ?? []), temp];
},
);
}
return { prev };
},
onError: (_err, _vars, ctx) => {
if (ctx?.prev)
qc.setQueryData(issueKeys.subscribers(issueId), ctx.prev);
},
onSettled: () => {
qc.invalidateQueries({ queryKey: issueKeys.subscribers(issueId) });
},
});
}

View File

@@ -1,81 +0,0 @@
import { queryOptions } from "@tanstack/react-query";
import { api } from "@/shared/api";
export const issueKeys = {
all: (wsId: string) => ["issues", wsId] as const,
list: (wsId: string) => [...issueKeys.all(wsId), "list"] as const,
detail: (wsId: string, id: string) =>
[...issueKeys.all(wsId), "detail", id] as const,
children: (wsId: string, id: string) =>
[...issueKeys.all(wsId), "children", id] as const,
timeline: (issueId: string) => ["issues", "timeline", issueId] as const,
reactions: (issueId: string) => ["issues", "reactions", issueId] as const,
subscribers: (issueId: string) =>
["issues", "subscribers", issueId] as const,
};
export const CLOSED_PAGE_SIZE = 50;
/**
* CACHE SHAPE NOTE: The raw cache stores ListIssuesResponse ({ issues, total, doneTotal }),
* but `select` transforms it to Issue[] for consumers. Mutations and ws-updaters
* must use setQueryData<ListIssuesResponse>(...) — NOT setQueryData<Issue[]>.
*
* Fetches all open issues + first page of done issues. Use useLoadMoreDoneIssues()
* to paginate additional done items into the cache.
*/
export function issueListOptions(wsId: string) {
return queryOptions({
queryKey: issueKeys.list(wsId),
queryFn: async () => {
const [openRes, closedRes] = await Promise.all([
api.listIssues({ open_only: true }),
api.listIssues({ status: "done", limit: CLOSED_PAGE_SIZE, offset: 0 }),
]);
return {
issues: [...openRes.issues, ...closedRes.issues],
total: openRes.total + closedRes.total,
doneTotal: closedRes.total,
};
},
select: (data) => data.issues,
});
}
export function issueDetailOptions(wsId: string, id: string) {
return queryOptions({
queryKey: issueKeys.detail(wsId, id),
queryFn: () => api.getIssue(id),
});
}
export function childIssuesOptions(wsId: string, id: string) {
return queryOptions({
queryKey: issueKeys.children(wsId, id),
queryFn: () => api.listChildIssues(id).then((r) => r.issues),
});
}
export function issueTimelineOptions(issueId: string) {
return queryOptions({
queryKey: issueKeys.timeline(issueId),
queryFn: () => api.listTimeline(issueId),
});
}
export function issueReactionsOptions(issueId: string) {
return queryOptions({
queryKey: issueKeys.reactions(issueId),
queryFn: async () => {
const issue = await api.getIssue(issueId);
return issue.reactions ?? [];
},
});
}
export function issueSubscribersOptions(issueId: string) {
return queryOptions({
queryKey: issueKeys.subscribers(issueId),
queryFn: () => api.listIssueSubscribers(issueId),
});
}

View File

@@ -1,97 +0,0 @@
import type { QueryClient } from "@tanstack/react-query";
import { issueKeys } from "./queries";
import type { Issue } from "@/shared/types";
import type { ListIssuesResponse } from "@/shared/types";
export function onIssueCreated(
qc: QueryClient,
wsId: string,
issue: Issue,
) {
qc.setQueryData<ListIssuesResponse>(issueKeys.list(wsId), (old) => {
if (!old || old.issues.some((i) => i.id === issue.id)) return old;
return {
...old,
issues: [...old.issues, issue],
total: old.total + 1,
doneTotal: (old.doneTotal ?? 0) + (issue.status === "done" ? 1 : 0),
};
});
if (issue.parent_issue_id) {
qc.invalidateQueries({ queryKey: issueKeys.children(wsId, issue.parent_issue_id) });
}
}
export function onIssueUpdated(
qc: QueryClient,
wsId: string,
issue: Partial<Issue> & { id: string },
) {
// Look up the parent before mutating list state, so we can also keep the
// parent's children cache in sync (powers the sub-issues list shown on
// the parent issue page).
const listData = qc.getQueryData<ListIssuesResponse>(issueKeys.list(wsId));
const detailData = qc.getQueryData<Issue>(issueKeys.detail(wsId, issue.id));
const parentId =
issue.parent_issue_id ??
detailData?.parent_issue_id ??
listData?.issues.find((i) => i.id === issue.id)?.parent_issue_id ??
null;
qc.setQueryData<ListIssuesResponse>(issueKeys.list(wsId), (old) => {
if (!old) return old;
const prev = old.issues.find((i) => i.id === issue.id);
const wasDone = prev?.status === "done";
const isDone = issue.status === "done";
// Only adjust doneTotal when status field is present and actually changed
let doneDelta = 0;
if (issue.status !== undefined) {
if (!wasDone && isDone) doneDelta = 1;
else if (wasDone && !isDone) doneDelta = -1;
}
return {
...old,
issues: old.issues.map((i) =>
i.id === issue.id ? { ...i, ...issue } : i,
),
doneTotal: (old.doneTotal ?? 0) + doneDelta,
};
});
qc.setQueryData<Issue>(issueKeys.detail(wsId, issue.id), (old) =>
old ? { ...old, ...issue } : old,
);
if (parentId) {
qc.setQueryData<Issue[]>(issueKeys.children(wsId, parentId), (old) =>
old?.map((c) => (c.id === issue.id ? { ...c, ...issue } : c)),
);
}
}
export function onIssueDeleted(
qc: QueryClient,
wsId: string,
issueId: string,
) {
// Look up the issue before removing it to check for parent_issue_id
const listData = qc.getQueryData<ListIssuesResponse>(issueKeys.list(wsId));
const deleted = listData?.issues.find((i) => i.id === issueId);
qc.setQueryData<ListIssuesResponse>(issueKeys.list(wsId), (old) => {
if (!old) return old;
const del = old.issues.find((i) => i.id === issueId);
return {
...old,
issues: old.issues.filter((i) => i.id !== issueId),
total: old.total - 1,
doneTotal: (old.doneTotal ?? 0) - (del?.status === "done" ? 1 : 0),
};
});
qc.removeQueries({ queryKey: issueKeys.detail(wsId, issueId) });
qc.removeQueries({ queryKey: issueKeys.timeline(issueId) });
qc.removeQueries({ queryKey: issueKeys.reactions(issueId) });
qc.removeQueries({ queryKey: issueKeys.subscribers(issueId) });
qc.removeQueries({ queryKey: issueKeys.children(wsId, issueId) });
if (deleted?.parent_issue_id) {
qc.invalidateQueries({ queryKey: issueKeys.children(wsId, deleted.parent_issue_id) });
}
}

View File

@@ -1,17 +0,0 @@
"use client";
import { useState } from "react";
import { QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { createQueryClient } from "./query-client";
import type { ReactNode } from "react";
export function QueryProvider({ children }: { children: ReactNode }) {
const [queryClient] = useState(createQueryClient);
return (
<QueryClientProvider client={queryClient}>
{children}
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);
}

View File

@@ -1,18 +0,0 @@
import { QueryClient } from "@tanstack/react-query";
export function createQueryClient(): QueryClient {
return new QueryClient({
defaultOptions: {
queries: {
staleTime: Infinity,
gcTime: 10 * 60 * 1000, // 10 minutes
refetchOnWindowFocus: false,
refetchOnReconnect: true,
retry: 1,
},
mutations: {
retry: false,
},
},
});
}

View File

@@ -1 +0,0 @@
export { runtimeKeys, runtimeListOptions } from "./queries";

View File

@@ -1,14 +0,0 @@
import { queryOptions } from "@tanstack/react-query";
import { api } from "@/shared/api";
export const runtimeKeys = {
all: (wsId: string) => ["runtimes", wsId] as const,
list: (wsId: string) => [...runtimeKeys.all(wsId), "list"] as const,
};
export function runtimeListOptions(wsId: string) {
return queryOptions({
queryKey: runtimeKeys.list(wsId),
queryFn: () => api.listRuntimes({ workspace_id: wsId }),
});
}

View File

@@ -1,13 +0,0 @@
export {
workspaceKeys,
workspaceListOptions,
memberListOptions,
agentListOptions,
skillListOptions,
} from "./queries";
export {
useCreateWorkspace,
useLeaveWorkspace,
useDeleteWorkspace,
} from "./mutations";

View File

@@ -1,34 +0,0 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { api } from "@/shared/api";
import { workspaceKeys } from "./queries";
export function useCreateWorkspace() {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: { name: string; slug: string; description?: string }) =>
api.createWorkspace(data),
onSettled: () => {
qc.invalidateQueries({ queryKey: workspaceKeys.list() });
},
});
}
export function useLeaveWorkspace() {
const qc = useQueryClient();
return useMutation({
mutationFn: (workspaceId: string) => api.leaveWorkspace(workspaceId),
onSettled: () => {
qc.invalidateQueries({ queryKey: workspaceKeys.list() });
},
});
}
export function useDeleteWorkspace() {
const qc = useQueryClient();
return useMutation({
mutationFn: (workspaceId: string) => api.deleteWorkspace(workspaceId),
onSettled: () => {
qc.invalidateQueries({ queryKey: workspaceKeys.list() });
},
});
}

View File

@@ -1,39 +0,0 @@
import { queryOptions } from "@tanstack/react-query";
import { api } from "@/shared/api";
export const workspaceKeys = {
all: (wsId: string) => ["workspaces", wsId] as const,
list: () => ["workspaces", "list"] as const,
members: (wsId: string) => ["workspaces", wsId, "members"] as const,
agents: (wsId: string) => ["workspaces", wsId, "agents"] as const,
skills: (wsId: string) => ["workspaces", wsId, "skills"] as const,
};
export function workspaceListOptions() {
return queryOptions({
queryKey: workspaceKeys.list(),
queryFn: () => api.listWorkspaces(),
});
}
export function memberListOptions(wsId: string) {
return queryOptions({
queryKey: workspaceKeys.members(wsId),
queryFn: () => api.listMembers(wsId),
});
}
export function agentListOptions(wsId: string) {
return queryOptions({
queryKey: workspaceKeys.agents(wsId),
queryFn: () =>
api.listAgents({ workspace_id: wsId, include_archived: true }),
});
}
export function skillListOptions(wsId: string) {
return queryOptions({
queryKey: workspaceKeys.skills(wsId),
queryFn: () => api.listSkills(),
});
}

View File

@@ -1,9 +0,0 @@
const COOKIE_NAME = "multica_logged_in";
export function setLoggedInCookie() {
document.cookie = `${COOKIE_NAME}=1; path=/; max-age=31536000; samesite=lax`;
}
export function clearLoggedInCookie() {
document.cookie = `${COOKIE_NAME}=; path=/; max-age=0`;
}

View File

@@ -1,3 +1,2 @@
export { useAuthStore } from "./store";
export { AuthInitializer } from "./initializer";
export { setLoggedInCookie } from "./auth-cookie";

View File

@@ -5,7 +5,6 @@ import { useAuthStore } from "./store";
import { useWorkspaceStore } from "@/features/workspace";
import { api } from "@/shared/api";
import { createLogger } from "@/shared/logger";
import { setLoggedInCookie, clearLoggedInCookie } from "./auth-cookie";
const logger = createLogger("auth");
@@ -17,7 +16,6 @@ export function AuthInitializer({ children }: { children: ReactNode }) {
useEffect(() => {
const token = localStorage.getItem("multica_token");
if (!token) {
clearLoggedInCookie();
useAuthStore.setState({ isLoading: false });
return;
}
@@ -31,7 +29,6 @@ export function AuthInitializer({ children }: { children: ReactNode }) {
Promise.all([mePromise, wsPromise])
.then(([user, wsList]) => {
setLoggedInCookie();
useAuthStore.setState({ user, isLoading: false });
useWorkspaceStore.getState().hydrateWorkspace(wsList, wsId);
})
@@ -41,7 +38,6 @@ export function AuthInitializer({ children }: { children: ReactNode }) {
api.setWorkspaceId(null);
localStorage.removeItem("multica_token");
localStorage.removeItem("multica_workspace_id");
clearLoggedInCookie();
useAuthStore.setState({ user: null, isLoading: false });
});
}, []);

View File

@@ -3,7 +3,6 @@
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;
@@ -12,7 +11,6 @@ interface AuthState {
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;
}
@@ -37,6 +35,7 @@ export const useAuthStore = create<AuthState>((set) => ({
api.setToken(null);
api.setWorkspaceId(null);
localStorage.removeItem("multica_token");
localStorage.removeItem("multica_workspace_id");
set({ user: null, isLoading: false });
}
},
@@ -49,25 +48,15 @@ export const useAuthStore = create<AuthState>((set) => ({
const { token, user } = await api.verifyCode(email, code);
localStorage.setItem("multica_token", token);
api.setToken(token);
setLoggedInCookie();
set({ user });
return user;
},
loginWithGoogle: async (code: string, redirectUri: string) => {
const { token, user } = await api.googleLogin(code, redirectUri);
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 });
},

View File

@@ -1,390 +0,0 @@
/*
* ContentEditor typography — ProseMirror styles using shadcn design tokens.
*
* Design tier: "Compact" (same tier as Linear, Slack). Optimized for short-form
* content (issue descriptions, comments) that users scan, not long-form reading.
*
* Typography values benchmarked against (April 2026):
* - github-markdown-css (GitHub's markdown renderer)
* - @tailwindcss/typography prose-sm preset
* - Linear's editor (Tiptap-based, 14px body)
*
* Key decisions:
* Body: 14px (text-sm), line-height 1.625 (between GitHub 1.5 and Tailwind 1.714)
* Headings: h1=22px (1.57x), h2=18px (1.29x), h3=15px (1.07x) — compact but
* with clear hierarchy. Previous h3 was 14px (same as body = no differentiation).
* Paragraph spacing: 10px (was 8px; GitHub uses 10px, Tailwind prose-sm uses 16px)
* List indent: 20px for ul (was 16px; standard is 22-32px)
* Code block margin: 12px (was 8px; gives breathing room between code and prose)
* Blockquote border: 3px (was 2px; GitHub/Tailwind both use 4px)
* Links: var(--brand) blue with 40% opacity underline (was var(--primary) near-black)
*
* Inline elements (mention cards, inline code) that exceed line-height:
* The browser auto-expands the line box for lines containing taller inline
* elements. Controlled via vertical-align on [data-node-view-wrapper] and
* box-decoration-break: clone on inline code.
*/
.rich-text-editor.ProseMirror {
color: var(--foreground);
caret-color: var(--foreground);
}
.rich-text-editor.ProseMirror:focus {
outline: none;
}
/* Placeholder */
.rich-text-editor .is-editor-empty:first-child::before {
content: attr(data-placeholder);
float: left;
color: var(--muted-foreground);
pointer-events: none;
height: 0;
}
/* Headings — compact but with clear visual hierarchy */
.rich-text-editor h1 {
font-size: 1.375rem;
font-weight: 700;
margin-top: 1.5rem;
margin-bottom: 0.5rem;
line-height: 1.3;
letter-spacing: -0.01em;
}
.rich-text-editor h2 {
font-size: 1.125rem;
font-weight: 600;
margin-top: 1.5rem;
margin-bottom: 0.5rem;
line-height: 1.35;
}
.rich-text-editor h3 {
font-size: 0.9375rem;
font-weight: 600;
margin-top: 1rem;
margin-bottom: 0.5rem;
line-height: 1.4;
}
/* Paragraphs */
.rich-text-editor p {
margin-top: 0.625rem;
margin-bottom: 0.625rem;
line-height: 1.625;
}
/* First child should not have top margin */
.rich-text-editor > *:first-child {
margin-top: 0;
}
/* Last child should not have bottom margin */
.rich-text-editor > *:last-child {
margin-bottom: 0;
}
/* Lists */
.rich-text-editor ul {
list-style-type: disc;
padding-inline-start: 1.25rem;
padding-inline-end: 0.5rem;
margin: 0.5rem 0;
}
.rich-text-editor ol {
list-style-type: decimal;
padding-inline-start: 1.5rem;
margin: 0.5rem 0;
}
.rich-text-editor li {
margin: 0.25rem 0;
line-height: 1.625;
}
.rich-text-editor li + li {
margin-top: 0.25rem;
}
.rich-text-editor li::marker {
color: var(--muted-foreground);
}
/* Remove paragraph margins inside list items (Tiptap wraps li content in <p>) */
.rich-text-editor li > p {
margin: 0;
}
.rich-text-editor li > p + p {
margin-top: 0.25rem;
}
/* Nested lists — bullet style progression and tighter spacing */
.rich-text-editor ul ul {
list-style-type: circle;
margin: 0.25rem 0;
}
.rich-text-editor ul ul ul {
list-style-type: square;
}
.rich-text-editor ol ol {
list-style-type: lower-alpha;
margin: 0.25rem 0;
}
.rich-text-editor ol ol ol {
list-style-type: lower-roman;
}
/* Inline code */
.rich-text-editor code {
font-family: var(--font-mono, ui-monospace, monospace);
font-size: 0.875rem;
background: color-mix(in srgb, var(--foreground) 3%, transparent);
border: 1px solid color-mix(in srgb, var(--foreground) 5%, transparent);
color: color-mix(in srgb, var(--foreground) 75%, transparent);
padding: 0.125rem 0.375rem;
border-radius: var(--radius-sm);
box-decoration-break: clone;
-webkit-box-decoration-break: clone;
line-height: 2;
}
/* Code blocks */
.rich-text-editor pre {
font-family: var(--font-mono, ui-monospace, monospace);
background: var(--muted);
border-radius: var(--radius);
padding: 0.75rem 1rem;
margin: 0.75rem 0;
overflow-x: auto;
}
.rich-text-editor pre code {
background: none;
border: none;
color: var(--foreground);
padding: 0;
font-size: 0.8125rem;
line-height: 1.6;
}
/* Syntax highlighting — lowlight (hljs) */
.rich-text-editor .hljs-keyword,
.rich-text-editor .hljs-selector-tag,
.rich-text-editor .hljs-built_in { color: oklch(0.55 0.16 255); }
.rich-text-editor .hljs-string,
.rich-text-editor .hljs-addition { color: oklch(0.55 0.14 155); }
.rich-text-editor .hljs-comment,
.rich-text-editor .hljs-quote { color: var(--muted-foreground); font-style: italic; }
.rich-text-editor .hljs-number,
.rich-text-editor .hljs-literal { color: oklch(0.58 0.16 30); }
.rich-text-editor .hljs-title,
.rich-text-editor .hljs-section,
.rich-text-editor .hljs-title\.function_ { color: oklch(0.55 0.14 280); }
.rich-text-editor .hljs-attr,
.rich-text-editor .hljs-attribute { color: oklch(0.58 0.12 60); }
.rich-text-editor .hljs-variable,
.rich-text-editor .hljs-template-variable { color: oklch(0.58 0.14 20); }
.rich-text-editor .hljs-type,
.rich-text-editor .hljs-title\.class_ { color: oklch(0.55 0.14 200); }
.rich-text-editor .hljs-deletion { color: oklch(0.55 0.2 25); }
.rich-text-editor .hljs-meta { color: var(--muted-foreground); }
/* Dark mode overrides */
.dark .rich-text-editor .hljs-keyword,
.dark .rich-text-editor .hljs-selector-tag,
.dark .rich-text-editor .hljs-built_in { color: oklch(0.7 0.14 255); }
.dark .rich-text-editor .hljs-string,
.dark .rich-text-editor .hljs-addition { color: oklch(0.7 0.14 155); }
.dark .rich-text-editor .hljs-number,
.dark .rich-text-editor .hljs-literal { color: oklch(0.72 0.14 30); }
.dark .rich-text-editor .hljs-title,
.dark .rich-text-editor .hljs-section,
.dark .rich-text-editor .hljs-title\.function_ { color: oklch(0.72 0.12 280); }
.dark .rich-text-editor .hljs-attr,
.dark .rich-text-editor .hljs-attribute { color: oklch(0.72 0.1 60); }
.dark .rich-text-editor .hljs-variable,
.dark .rich-text-editor .hljs-template-variable { color: oklch(0.72 0.12 20); }
.dark .rich-text-editor .hljs-type,
.dark .rich-text-editor .hljs-title\.class_ { color: oklch(0.72 0.12 200); }
.dark .rich-text-editor .hljs-deletion { color: oklch(0.7 0.18 25); }
/* Tables */
.rich-text-editor .tableWrapper {
overflow-x: auto;
margin: 1rem 0;
border: 1px solid var(--border);
border-radius: var(--radius);
}
.rich-text-editor table {
min-width: 100%;
border-collapse: collapse;
}
.rich-text-editor colgroup {
display: none;
}
.rich-text-editor thead {
background: color-mix(in srgb, var(--muted) 50%, transparent);
}
.rich-text-editor tbody tr {
border-top: 1px solid var(--border);
}
.rich-text-editor tr:hover td {
background: color-mix(in srgb, var(--muted) 30%, transparent);
transition: background 0.15s;
}
.rich-text-editor th,
.rich-text-editor td {
text-align: left;
padding: 0.625rem 1rem;
font-size: 0.875rem;
}
.rich-text-editor th {
font-weight: 600;
}
/* Remove paragraph margin inside table cells */
.rich-text-editor th p,
.rich-text-editor td p {
margin: 0;
}
/* Blockquotes */
.rich-text-editor blockquote {
border-left: 3px solid color-mix(in srgb, var(--muted-foreground) 30%, transparent);
padding-left: 0.75rem;
margin: 0.625rem 0;
color: var(--muted-foreground);
font-style: italic;
}
.rich-text-editor blockquote p {
margin-top: 0.25rem;
margin-bottom: 0.25rem;
}
.rich-text-editor blockquote > *:first-child {
margin-top: 0;
}
.rich-text-editor blockquote > *:last-child {
margin-bottom: 0;
}
.rich-text-editor blockquote blockquote {
margin-top: 0.25rem;
margin-bottom: 0.25rem;
border-left-color: color-mix(in srgb, var(--muted-foreground) 15%, transparent);
}
/* Horizontal rules */
.rich-text-editor hr {
border: none;
border-top: 1px solid var(--border);
margin: 1rem 0;
}
/* Links */
.rich-text-editor a {
color: var(--brand);
text-decoration: underline;
text-decoration-color: color-mix(in srgb, var(--brand) 40%, transparent);
text-underline-offset: 2px;
cursor: pointer;
}
.rich-text-editor a:hover {
text-decoration-color: var(--brand);
}
/* Issue mention cards — inline cards that sit within text flow */
.rich-text-editor a.issue-mention {
color: inherit;
text-decoration: none;
}
.rich-text-editor a.issue-mention:hover {
text-decoration: none;
}
/* Mentions */
.rich-text-editor .mention {
color: var(--primary);
font-weight: 600;
text-decoration: none;
margin: 0 0.125rem;
}
/* Strong / emphasis */
.rich-text-editor strong {
font-weight: 600;
}
.rich-text-editor em {
font-style: italic;
}
.rich-text-editor s,
.rich-text-editor del {
text-decoration: line-through;
color: var(--muted-foreground);
}
/* Readonly mode overrides */
.rich-text-editor.readonly.ProseMirror {
caret-color: transparent;
cursor: default;
}
/* Mention NodeView inline layout fix */
.rich-text-editor [data-node-view-wrapper] {
display: inline;
vertical-align: middle;
}
/* Images — shared styling for both editing and readonly */
.rich-text-editor img {
border-radius: var(--radius);
margin: 0.5rem 0;
}
/* Uploading image placeholder — data-uploading attribute managed by ProseMirror schema */
.rich-text-editor img[data-uploading] {
opacity: 0.5;
border-radius: var(--radius);
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; }
}

View File

@@ -1,202 +0,0 @@
"use client";
/**
* ContentEditor — the single rich-text editor for the entire application.
*
* Architecture decisions (April 2026 refactor):
*
* 1. ONE COMPONENT for both editing and readonly display. The `editable` prop
* controls the mode. Previously we had RichTextEditor + ReadonlyEditor as
* separate components with duplicated extension configs — this caused
* visual inconsistency between edit and display modes.
*
* 2. ONE MARKDOWN PIPELINE via @tiptap/markdown. Content is loaded with
* `contentType: 'markdown'` and saved with `editor.getMarkdown()`.
* Previously we had a custom `markdownToHtml()` pipeline (Marked library)
* for loading and regex post-processing for saving — two asymmetric paths
* that caused roundtrip inconsistencies. The @tiptap/markdown extension
* (v3.21.0+) handles table cell <p> wrapping and custom mention tokenizers
* natively, eliminating the need for the HTML detour.
*
* 3. PREPROCESSING is minimal: only legacy mention shortcode migration and
* URL linkification (preprocessMarkdown). No HTML conversion.
*
* Tech: Tiptap v3.22.1 (ProseMirror wrapper), @tiptap/markdown for
* bidirectional Markdown ↔ ProseMirror JSON conversion.
*/
import {
forwardRef,
useEffect,
useImperativeHandle,
useRef,
} from "react";
import { useEditor, EditorContent } from "@tiptap/react";
import { cn } from "@/lib/utils";
import type { UploadResult } from "@/shared/hooks/use-file-upload";
import { useQueryClient } from "@tanstack/react-query";
import { createEditorExtensions } from "./extensions";
import { uploadAndInsertFile } from "./extensions/file-upload";
import { preprocessMarkdown } from "./utils/preprocess";
import "./content-editor.css";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface ContentEditorProps {
defaultValue?: string;
onUpdate?: (markdown: string) => void;
placeholder?: string;
editable?: boolean;
className?: string;
debounceMs?: number;
onSubmit?: () => void;
onBlur?: () => void;
onUploadFile?: (file: File) => Promise<UploadResult | null>;
}
interface ContentEditorRef {
getMarkdown: () => string;
clearContent: () => void;
focus: () => void;
uploadFile: (file: File) => void;
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
const ContentEditor = forwardRef<ContentEditorRef, ContentEditorProps>(
function ContentEditor(
{
defaultValue = "",
onUpdate,
placeholder: placeholderText = "",
editable = true,
className,
debounceMs = 300,
onSubmit,
onBlur,
onUploadFile,
},
ref,
) {
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const onUpdateRef = useRef(onUpdate);
const onSubmitRef = useRef(onSubmit);
const onBlurRef = useRef(onBlur);
const onUploadFileRef = useRef(onUploadFile);
const prevContentRef = useRef(defaultValue);
// Keep refs in sync without recreating editor
onUpdateRef.current = onUpdate;
onSubmitRef.current = onSubmit;
onBlurRef.current = onBlur;
onUploadFileRef.current = onUploadFile;
const queryClient = useQueryClient();
const editor = useEditor({
immediatelyRender: false,
editable,
content: defaultValue ? preprocessMarkdown(defaultValue) : "",
contentType: defaultValue ? "markdown" : undefined,
extensions: createEditorExtensions({
editable,
placeholder: placeholderText,
queryClient,
onSubmitRef,
onUploadFileRef,
}),
onUpdate: ({ editor: ed }) => {
if (!onUpdateRef.current) return;
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
onUpdateRef.current?.(ed.getMarkdown());
}, debounceMs);
},
onBlur: () => {
onBlurRef.current?.();
},
editorProps: {
handleDOMEvents: {
click(_view, event) {
const target = event.target as HTMLElement;
// Skip links inside NodeView wrappers — they handle their own clicks
if (target.closest("[data-node-view-wrapper]")) return false;
const link = target.closest("a");
const href = link?.getAttribute("href");
if (!href || href.startsWith("mention://")) return false;
if (!editable) {
// Readonly: any click on link opens new tab
event.preventDefault();
window.open(href, "_blank", "noopener,noreferrer");
return true;
}
if (event.metaKey || event.ctrlKey) {
// Edit mode: Cmd/Ctrl+click opens link
window.open(href, "_blank", "noopener,noreferrer");
event.preventDefault();
return true;
}
return false;
},
},
attributes: {
class: cn(
"rich-text-editor text-sm outline-none",
!editable && "readonly",
className,
),
},
},
});
// Cleanup debounce on unmount
useEffect(() => {
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, []);
// Readonly content update: when defaultValue changes and editor is readonly,
// re-set the content (e.g. after editing a comment, the readonly view updates)
useEffect(() => {
if (!editor || editable) return;
if (defaultValue === prevContentRef.current) return;
prevContentRef.current = defaultValue;
const processed = defaultValue ? preprocessMarkdown(defaultValue) : "";
if (processed) {
editor.commands.setContent(processed, { contentType: "markdown" });
} else {
editor.commands.clearContent();
}
}, [editor, editable, defaultValue]);
useImperativeHandle(ref, () => ({
getMarkdown: () => editor?.getMarkdown() ?? "",
clearContent: () => {
editor?.commands.clearContent();
},
focus: () => {
editor?.commands.focus();
},
uploadFile: (file: File) => {
if (!editor || !onUploadFileRef.current) return;
const endPos = editor.state.doc.content.size;
uploadAndInsertFile(editor, file, onUploadFileRef.current, endPos);
},
}));
if (!editor) return null;
return <EditorContent editor={editor} />;
},
);
export { ContentEditor, type ContentEditorProps, type ContentEditorRef };

View File

@@ -1,52 +0,0 @@
"use client";
import { useState } from "react";
import { NodeViewWrapper, NodeViewContent } from "@tiptap/react";
import type { NodeViewProps } from "@tiptap/react";
import { Copy, Check } from "lucide-react";
function CodeBlockView({ node }: NodeViewProps) {
const [copied, setCopied] = useState(false);
const language = node.attrs.language || "";
const handleCopy = async () => {
const text = node.textContent;
if (!text) return;
await navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<NodeViewWrapper className="code-block-wrapper group/code relative my-2">
<div
contentEditable={false}
className="code-block-header absolute top-0 right-0 z-10 flex items-center gap-1.5 px-2 py-1.5 opacity-0 transition-opacity group-hover/code:opacity-100"
>
{language && (
<span className="text-xs text-muted-foreground select-none">
{language}
</span>
)}
<button
type="button"
onClick={handleCopy}
className="flex h-6 w-6 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
title="Copy code"
>
{copied ? (
<Check className="h-3.5 w-3.5" />
) : (
<Copy className="h-3.5 w-3.5" />
)}
</button>
</div>
<pre spellCheck={false}>
{/* @ts-expect-error -- NodeViewContent supports as="code" at runtime */}
<NodeViewContent as="code" />
</pre>
</NodeViewWrapper>
);
}
export { CodeBlockView };

View File

@@ -1,119 +0,0 @@
import { Extension } from "@tiptap/core";
import { Plugin, PluginKey } from "@tiptap/pm/state";
import type { UploadResult } from "@/shared/hooks/use-file-upload";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function removeImageBySrc(editor: any, src: string) {
if (!editor) return;
const { tr } = editor.state;
let deleted = false;
editor.state.doc.descendants((node: any, pos: number) => {
if (deleted) return false;
if (node.type.name === "image" && node.attrs.src === src) {
tr.delete(pos, pos + node.nodeSize);
deleted = true;
return false;
}
});
if (deleted) editor.view.dispatch(tr);
}
/**
* Shared upload flow: insert blob preview → upload → replace with real URL.
* Used by both paste/drop (at cursor) and button upload (at end of doc).
*/
export async function uploadAndInsertFile(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
editor: any,
file: File,
handler: (file: File) => Promise<UploadResult | null>,
pos?: number,
) {
const isImage = file.type.startsWith("image/");
if (isImage) {
const blobUrl = URL.createObjectURL(file);
const imgAttrs = { src: blobUrl, alt: file.name, uploading: true };
if (pos !== undefined) {
editor.chain().focus().insertContentAt(pos, { type: "image", attrs: imgAttrs }).run();
} else {
editor.chain().focus().setImage(imgAttrs).run();
}
try {
const result = await handler(file);
if (result) {
const { tr } = editor.state;
editor.state.doc.descendants((node: { type: { name: string }; attrs: { src: string } }, nodePos: number) => {
if (node.type.name === "image" && node.attrs.src === blobUrl) {
tr.setNodeMarkup(nodePos, undefined, {
...node.attrs,
src: result.link,
alt: result.filename,
uploading: false,
});
}
});
editor.view.dispatch(tr);
} else {
removeImageBySrc(editor, blobUrl);
}
} catch {
removeImageBySrc(editor, blobUrl);
} finally {
URL.revokeObjectURL(blobUrl);
}
} else {
// Non-image: upload first, then insert link
const result = await handler(file);
if (!result) return;
const linkText = `[${result.filename}](${result.link})`;
if (pos !== undefined) {
editor.chain().focus().insertContentAt(pos, linkText).run();
} else {
editor.chain().focus().insertContent(linkText).run();
}
}
}
export function createFileUploadExtension(
onUploadFileRef: React.RefObject<((file: File) => Promise<UploadResult | null>) | undefined>,
) {
return Extension.create({
name: "fileUpload",
addProseMirrorPlugins() {
const { editor } = this;
const handleFiles = async (files: FileList) => {
const handler = onUploadFileRef.current;
if (!handler) return false;
for (const file of Array.from(files)) {
await uploadAndInsertFile(editor, file, handler);
}
return true;
};
return [
new Plugin({
key: new PluginKey("fileUpload"),
props: {
handlePaste(_view, event) {
const files = event.clipboardData?.files;
if (!files?.length) return false;
if (!onUploadFileRef.current) return false;
handleFiles(files);
return true;
},
handleDrop(_view, event) {
const files = (event as DragEvent).dataTransfer?.files;
if (!files?.length) return false;
if (!onUploadFileRef.current) return false;
handleFiles(files);
return true;
},
},
}),
];
},
});
}

View File

@@ -1,126 +0,0 @@
/**
* Shared extension factory for ContentEditor.
*
* One function builds the extension array for BOTH edit and readonly modes.
* This ensures visual consistency — the same extensions parse and render
* content identically regardless of mode.
*
* Split:
* - Both modes: StarterKit, CodeBlock, Link, Image, Table, Markdown, Mention
* - Edit only: Typography, Placeholder, markdownPaste, submitShortcut,
* fileUpload, Mention suggestion popup
*
* Link config differs: edit mode has autolink (detects URLs while typing),
* readonly does not (prevents false positives on display).
*
* Mention suggestion is only attached in edit mode — readonly doesn't need
* the autocomplete popup.
*
* All link styling is controlled by content-editor.css (var(--brand) color),
* not Tailwind HTMLAttributes, to keep a single source of truth.
*/
import type { RefObject } from "react";
import StarterKit from "@tiptap/starter-kit";
import CodeBlockLowlight from "@tiptap/extension-code-block-lowlight";
import { common, createLowlight } from "lowlight";
import Placeholder from "@tiptap/extension-placeholder";
import Link from "@tiptap/extension-link";
import Typography from "@tiptap/extension-typography";
import Image from "@tiptap/extension-image";
import TableRow from "@tiptap/extension-table-row";
import TableHeader from "@tiptap/extension-table-header";
import TableCell from "@tiptap/extension-table-cell";
import { Table } from "@tiptap/extension-table";
import { Markdown } from "@tiptap/markdown";
import { ReactNodeViewRenderer } from "@tiptap/react";
import type { AnyExtension } from "@tiptap/core";
import type { UploadResult } from "@/shared/hooks/use-file-upload";
import { BaseMentionExtension } from "./mention-extension";
import { createMentionSuggestion } from "./mention-suggestion";
import { CodeBlockView } from "./code-block-view";
import { createMarkdownPasteExtension } from "./markdown-paste";
import { createSubmitExtension } from "./submit-shortcut";
import { createFileUploadExtension } from "./file-upload";
const lowlight = createLowlight(common);
const LinkEditable = Link.extend({ inclusive: false }).configure({
openOnClick: true,
autolink: true,
linkOnPaste: false,
});
const LinkReadonly = Link.configure({
openOnClick: false,
autolink: false,
});
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"),
},
};
},
}).configure({
inline: false,
allowBase64: false,
HTMLAttributes: { style: "max-width: 100%; height: auto;" },
});
export interface EditorExtensionsOptions {
editable: boolean;
placeholder?: string;
queryClient?: import("@tanstack/react-query").QueryClient;
onSubmitRef?: RefObject<(() => void) | undefined>;
onUploadFileRef?: RefObject<
((file: File) => Promise<UploadResult | null>) | undefined
>;
}
export function createEditorExtensions(
options: EditorExtensionsOptions,
): AnyExtension[] {
const { editable, placeholder: placeholderText } = options;
const extensions: AnyExtension[] = [
StarterKit.configure({
heading: { levels: [1, 2, 3] },
link: false,
codeBlock: false,
}),
CodeBlockLowlight.extend({
addNodeView() {
return ReactNodeViewRenderer(CodeBlockView);
},
}).configure({ lowlight }),
editable ? LinkEditable : LinkReadonly,
ImageExtension,
Table.configure({ resizable: false }),
TableRow,
TableHeader,
TableCell,
Markdown,
BaseMentionExtension.configure({
HTMLAttributes: { class: "mention" },
...(editable && options.queryClient ? { suggestion: createMentionSuggestion(options.queryClient) } : {}),
}),
];
if (editable) {
extensions.push(
Typography,
Placeholder.configure({ placeholder: placeholderText }),
createMarkdownPasteExtension(),
createSubmitExtension(() => options.onSubmitRef?.current?.()),
createFileUploadExtension(options.onUploadFileRef!),
);
}
return extensions;
}

View File

@@ -1,67 +0,0 @@
/**
* Markdown paste extension — ensures pasted text is parsed as Markdown.
*
* Problem: The browser clipboard can contain BOTH text/plain and text/html.
* ProseMirror always prefers text/html when present (hardcoded in
* parseFromClipboard: `let asText = !html`). When copying from VS Code,
* text editors, or .md files, the OS wraps text in <pre>/<div> HTML tags.
* ProseMirror parses these as code blocks — wrong.
*
* Solution: Use `handlePaste` (the only ProseMirror prop that runs for ALL
* paste events and has access to raw ClipboardEvent). We check for
* `data-pm-slice` in the HTML — this attribute is added by ProseMirror's
* own clipboard serializer. If present, the source is another ProseMirror
* editor and its HTML is structurally correct — let ProseMirror handle it.
* Otherwise, ignore the HTML and parse text/plain as Markdown.
*
* Why not clipboardTextParser? It only runs when there's NO text/html on
* the clipboard (ProseMirror source: `let asText = !!text && !html`).
*
* Why not heuristic detection (looksLikeMarkdown / hasRichHtml)? Unreliable.
* VS Code's HTML contains <code> tags that fool rich-content detectors.
* Markdown pattern matching has too many edge cases. The data-pm-slice
* check is deterministic — no false positives.
*/
import { Extension } from "@tiptap/core";
import { Plugin, PluginKey } from "@tiptap/pm/state";
import { Slice } from "@tiptap/pm/model";
export function createMarkdownPasteExtension() {
return Extension.create({
name: "markdownPaste",
addProseMirrorPlugins() {
const { editor } = this;
return [
new Plugin({
key: new PluginKey("markdownPaste"),
props: {
handlePaste(view, event) {
if (!editor.markdown) return false;
const clipboard = event.clipboardData;
if (!clipboard) return false;
const text = clipboard.getData("text/plain");
if (!text) return false;
const html = clipboard.getData("text/html");
// If HTML contains data-pm-slice, the source is another
// ProseMirror editor — let ProseMirror use its native HTML
// clipboard path to preserve exact node structure.
if (html && html.includes("data-pm-slice")) return false;
// Everything else (VS Code, text editors, .md files, terminals,
// web pages): parse text/plain as Markdown.
const json = editor.markdown.parse(text);
const node = editor.schema.nodeFromJSON(json);
const slice = Slice.maxOpen(node.content);
const tr = view.state.tr.replaceSelection(slice);
view.dispatch(tr);
return true;
},
},
}),
];
},
});
}

View File

@@ -1,64 +0,0 @@
import Mention from "@tiptap/extension-mention";
import { mergeAttributes } from "@tiptap/core";
import { ReactNodeViewRenderer } from "@tiptap/react";
import { MentionView } from "./mention-view";
export const BaseMentionExtension = Mention.extend({
addNodeView() {
return ReactNodeViewRenderer(MentionView);
},
renderHTML({ node, HTMLAttributes }) {
const type = node.attrs.type ?? "member";
const prefix = type === "issue" ? "" : "@";
return [
"span",
mergeAttributes(
{ "data-type": "mention" },
this.options.HTMLAttributes,
HTMLAttributes,
{
"data-mention-type": node.attrs.type ?? "member",
"data-mention-id": node.attrs.id,
},
),
`${prefix}${node.attrs.label ?? node.attrs.id}`,
];
},
addAttributes() {
return {
...this.parent?.(),
type: {
default: "member",
parseHTML: (el: HTMLElement) =>
el.getAttribute("data-mention-type") ?? "member",
renderHTML: () => ({}),
},
};
},
markdownTokenizer: {
name: "mention",
level: "inline" as const,
start(src: string) {
return src.search(/\[@?[^\]]+\]\(mention:\/\//);
},
tokenize(src: string) {
const match = src.match(
/^\[@?([^\]]+)\]\(mention:\/\/(\w+)\/([^)]+)\)/,
);
if (!match) return undefined;
return {
type: "mention",
raw: match[0],
attributes: { label: match[1], type: match[2] ?? "member", id: match[3] },
};
},
},
parseMarkdown: (token: any, helpers: any) => {
return helpers.createNode("mention", token.attributes);
},
renderMarkdown: (node: any) => {
const { id, label, type = "member" } = node.attrs || {};
const prefix = type === "issue" ? "" : "@";
return `[${prefix}${label ?? id}](mention://${type}/${id})`;
},
});

View File

@@ -1,333 +0,0 @@
"use client";
import {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useRef,
useState,
} from "react";
import { ReactRenderer } from "@tiptap/react";
import { computePosition, offset, flip, shift } from "@floating-ui/dom";
import type { QueryClient } from "@tanstack/react-query";
import { useWorkspaceStore } from "@/features/workspace";
import { issueKeys } from "@core/issues/queries";
import { workspaceKeys } from "@core/workspace/queries";
import type { Issue, ListIssuesResponse, MemberWithUser, Agent } from "@/shared/types";
import { ActorAvatar } from "@/components/common/actor-avatar";
import { StatusIcon } from "@/features/issues/components/status-icon";
import { Badge } from "@/components/ui/badge";
import type { IssueStatus } from "@/shared/types";
import type { SuggestionOptions, SuggestionProps } from "@tiptap/suggestion";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface MentionItem {
id: string;
label: string;
type: "member" | "agent" | "issue" | "all";
/** Secondary text shown beside the label (e.g. issue title) */
description?: string;
/** Issue status for StatusIcon rendering */
status?: IssueStatus;
}
interface MentionListProps {
items: MentionItem[];
command: (item: MentionItem) => void;
}
export interface MentionListRef {
onKeyDown: (props: { event: KeyboardEvent }) => boolean;
}
// ---------------------------------------------------------------------------
// Group items by section
// ---------------------------------------------------------------------------
interface MentionGroup {
label: string;
items: MentionItem[];
}
function groupItems(items: MentionItem[]): MentionGroup[] {
const users: MentionItem[] = [];
const issues: MentionItem[] = [];
for (const item of items) {
if (item.type === "issue") {
issues.push(item);
} else {
users.push(item);
}
}
const groups: MentionGroup[] = [];
if (users.length > 0) groups.push({ label: "Users", items: users });
if (issues.length > 0) groups.push({ label: "Issues", items: issues });
return groups;
}
// ---------------------------------------------------------------------------
// MentionList — the popup rendered inside the editor
// ---------------------------------------------------------------------------
const MentionList = forwardRef<MentionListRef, MentionListProps>(
function MentionList({ items, command }, ref) {
const [selectedIndex, setSelectedIndex] = useState(0);
const itemRefs = useRef<(HTMLButtonElement | null)[]>([]);
useEffect(() => {
setSelectedIndex(0);
}, [items]);
useEffect(() => {
itemRefs.current[selectedIndex]?.scrollIntoView({ block: "nearest" });
}, [selectedIndex]);
const selectItem = useCallback(
(index: number) => {
const item = items[index];
if (item) command(item);
},
[items, command],
);
useImperativeHandle(ref, () => ({
onKeyDown: ({ event }) => {
if (event.key === "ArrowUp") {
setSelectedIndex((i) => (i + items.length - 1) % items.length);
return true;
}
if (event.key === "ArrowDown") {
setSelectedIndex((i) => (i + 1) % items.length);
return true;
}
if (event.key === "Enter") {
selectItem(selectedIndex);
return true;
}
return false;
},
}));
if (items.length === 0) {
return (
<div className="rounded-md border bg-popover p-2 text-xs text-muted-foreground shadow-md">
No results
</div>
);
}
const groups = groupItems(items);
// Build a flat index mapping: globalIndex → item
let globalIndex = 0;
return (
<div className="rounded-md border bg-popover py-1 shadow-md w-72 max-h-[300px] overflow-y-auto">
{groups.map((group) => (
<div key={group.label}>
<div className="px-3 py-1.5 text-xs font-medium text-muted-foreground">
{group.label}
</div>
{group.items.map((item) => {
const idx = globalIndex++;
return (
<MentionRow
key={`${item.type}-${item.id}`}
item={item}
selected={idx === selectedIndex}
onSelect={() => selectItem(idx)}
buttonRef={(el) => { itemRefs.current[idx] = el; }}
/>
);
})}
</div>
))}
</div>
);
},
);
// ---------------------------------------------------------------------------
// MentionRow — single item in the list
// ---------------------------------------------------------------------------
function MentionRow({
item,
selected,
onSelect,
buttonRef,
}: {
item: MentionItem;
selected: boolean;
onSelect: () => void;
buttonRef: (el: HTMLButtonElement | null) => void;
}) {
if (item.type === "issue") {
return (
<button
ref={buttonRef}
className={`flex w-full items-center gap-2.5 px-3 py-1.5 text-left text-xs transition-colors ${
selected ? "bg-accent" : "hover:bg-accent/50"
}`}
onClick={onSelect}
>
{item.status && (
<StatusIcon status={item.status} className="h-3.5 w-3.5 shrink-0" />
)}
<span className="shrink-0 text-muted-foreground">{item.label}</span>
{item.description && (
<span className="truncate text-muted-foreground">{item.description}</span>
)}
</button>
);
}
return (
<button
ref={buttonRef}
className={`flex w-full items-center gap-2.5 px-3 py-1.5 text-left text-xs transition-colors ${
selected ? "bg-accent" : "hover:bg-accent/50"
}`}
onClick={onSelect}
>
<ActorAvatar
actorType={item.type === "all" ? "member" : item.type}
actorId={item.id}
size={20}
/>
<span className="truncate font-medium">{item.label}</span>
{item.type === "agent" && (
<Badge variant="outline" className="ml-auto text-[10px] h-4 px-1.5">Agent</Badge>
)}
</button>
);
}
// ---------------------------------------------------------------------------
// Suggestion config factory
// ---------------------------------------------------------------------------
export function createMentionSuggestion(qc: QueryClient): Omit<
SuggestionOptions<MentionItem>,
"editor"
> {
return {
items: ({ query }) => {
const wsId = useWorkspaceStore.getState().workspace?.id;
const members: MemberWithUser[] = wsId ? qc.getQueryData(workspaceKeys.members(wsId)) ?? [] : [];
const agents: Agent[] = wsId ? qc.getQueryData(workspaceKeys.agents(wsId)) ?? [] : [];
const issues: Issue[] = wsId
? qc.getQueryData<ListIssuesResponse>(issueKeys.list(wsId))?.issues ?? []
: [];
const q = query.toLowerCase();
// Show "All members" option when query is empty or matches "all"
const allItem: MentionItem[] =
"all members".includes(q) || "all".includes(q)
? [{ id: "all", label: "All members", type: "all" as const }]
: [];
const memberItems: MentionItem[] = members
.filter((m) => m.name.toLowerCase().includes(q))
.map((m) => ({
id: m.user_id,
label: m.name,
type: "member" as const,
}));
const agentItems: MentionItem[] = agents
.filter((a) => !a.archived_at && a.name.toLowerCase().includes(q))
.map((a) => ({ id: a.id, label: a.name, type: "agent" as const }));
const issueItems: MentionItem[] = issues
.filter(
(i) =>
i.identifier.toLowerCase().includes(q) ||
i.title.toLowerCase().includes(q),
)
.map((i) => ({
id: i.id,
label: i.identifier,
type: "issue" as const,
description: i.title,
status: i.status as IssueStatus,
}));
return [...allItem, ...memberItems, ...agentItems, ...issueItems].slice(0, 10);
},
render: () => {
let renderer: ReactRenderer<MentionListRef> | null = null;
let popup: HTMLDivElement | null = null;
return {
onStart: (props: SuggestionProps<MentionItem>) => {
renderer = new ReactRenderer(MentionList, {
props: { items: props.items, command: props.command },
editor: props.editor,
});
popup = document.createElement("div");
popup.style.position = "fixed";
popup.style.zIndex = "50";
popup.appendChild(renderer.element);
document.body.appendChild(popup);
updatePosition(popup, props.clientRect);
},
onUpdate: (props: SuggestionProps<MentionItem>) => {
renderer?.updateProps({
items: props.items,
command: props.command,
});
if (popup) updatePosition(popup, props.clientRect);
},
onKeyDown: (props: { event: KeyboardEvent }) => {
if (props.event.key === "Escape") {
cleanup();
return true;
}
return renderer?.ref?.onKeyDown(props) ?? false;
},
onExit: () => {
cleanup();
},
};
function updatePosition(
el: HTMLDivElement,
clientRect: (() => DOMRect | null) | null | undefined,
) {
if (!clientRect) return;
const virtualEl = {
getBoundingClientRect: () => clientRect() ?? new DOMRect(),
};
computePosition(virtualEl, el, {
placement: "bottom-start",
strategy: "fixed",
middleware: [offset(4), flip(), shift({ padding: 8 })],
}).then(({ x, y }) => {
el.style.left = `${x}px`;
el.style.top = `${y}px`;
});
}
function cleanup() {
renderer?.destroy();
renderer = null;
popup?.remove();
popup = null;
}
},
};
}

View File

@@ -1,83 +0,0 @@
"use client";
/**
* MentionView — NodeView for rendering @mentions inline in the editor.
*
* Member/agent mentions: plain "@Name" text with .mention class styling.
* Issue mentions: inline card with StatusIcon + identifier + title.
*
* Issue card sizing: must fit within the paragraph line box (14px * 1.625
* = 22.75px). Card uses text-xs (12px) + py-0.5 + border ≈ 22px total.
* vertical-align: middle is set on the [data-node-view-wrapper] in CSS
* (not on the <a> tag) because the wrapper is the outermost inline element
* that participates in line box calculation. Setting it on the inner <a>
* had no effect since the wrapper was already positioned.
*
* Fallback: when issue is not in the Zustand store (deleted or other
* workspace), the same card style is used with just the identifier from
* fallbackLabel — no visual degradation to a plain text link.
*/
import { NodeViewWrapper } from "@tiptap/react";
import type { NodeViewProps } from "@tiptap/react";
import { useQuery } from "@tanstack/react-query";
import { issueListOptions } from "@core/issues/queries";
import { useWorkspaceId } from "@core/hooks";
import { StatusIcon } from "@/features/issues/components/status-icon";
export function MentionView({ node }: NodeViewProps) {
const { type, id, label } = node.attrs;
if (type === "issue") {
return (
<NodeViewWrapper as="span" className="inline">
<IssueMention issueId={id} fallbackLabel={label} />
</NodeViewWrapper>
);
}
return (
<NodeViewWrapper as="span" className="inline">
<span className="mention">@{label ?? id}</span>
</NodeViewWrapper>
);
}
function IssueMention({
issueId,
fallbackLabel,
}: {
issueId: string;
fallbackLabel?: string;
}) {
const wsId = useWorkspaceId();
const { data: issues = [] } = useQuery(issueListOptions(wsId));
const issue = issues.find((i) => i.id === issueId);
const handleClick = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
window.open(`/issues/${issueId}`, "_blank", "noopener,noreferrer");
};
const cardClass =
"issue-mention inline-flex items-center gap-1.5 rounded-md border mx-0.5 px-2 py-0.5 text-xs hover:bg-accent transition-colors cursor-pointer max-w-72";
if (!issue) {
return (
<a href={`/issues/${issueId}`} onClick={handleClick} className={cardClass}>
<span className="font-medium text-muted-foreground">
{fallbackLabel ?? issueId.slice(0, 8)}
</span>
</a>
);
}
return (
<a href={`/issues/${issueId}`} onClick={handleClick} className={cardClass}>
<StatusIcon status={issue.status} className="h-3.5 w-3.5 shrink-0" />
<span className="font-medium text-muted-foreground shrink-0">{issue.identifier}</span>
<span className="text-foreground truncate">{issue.title}</span>
</a>
);
}

View File

@@ -1,15 +0,0 @@
import { Extension } from "@tiptap/core";
export function createSubmitExtension(onSubmit: () => void) {
return Extension.create({
name: "submitShortcut",
addKeyboardShortcuts() {
return {
"Mod-Enter": () => {
onSubmit();
return true;
},
};
},
});
}

View File

@@ -1,12 +0,0 @@
export {
ContentEditor,
type ContentEditorProps,
type ContentEditorRef,
} from "./content-editor";
export {
TitleEditor,
type TitleEditorProps,
type TitleEditorRef,
} from "./title-editor";
export { copyMarkdown } from "./utils/clipboard";
export { ReadonlyContent } from "./readonly-content";

View File

@@ -1,177 +0,0 @@
"use client";
/**
* ReadonlyContent — lightweight markdown renderer for readonly content display.
*
* Replaces <ContentEditor editable={false}> for comment cards and other
* read-only surfaces. Uses react-markdown instead of a full Tiptap/ProseMirror
* instance, eliminating EditorView, Plugin, and NodeView overhead.
*
* Visual parity with ContentEditor is achieved by:
* - Wrapping output in <div class="rich-text-editor readonly"> so the same
* content-editor.css rules apply to standard HTML tags
* - Using the same preprocessMarkdown pipeline (mention shortcodes + linkify)
* - Using lowlight for code highlighting (same engine as Tiptap's CodeBlockLowlight)
* so .hljs-* CSS rules from content-editor.css produce identical colors
* - Rendering mentions with the same IssueMentionCard component and .mention class
*/
import { useMemo } from "react";
import ReactMarkdown, {
defaultUrlTransform,
type Components,
} from "react-markdown";
import remarkGfm from "remark-gfm";
import rehypeRaw from "rehype-raw";
import { createLowlight, common } from "lowlight";
import { toHtml } from "hast-util-to-html";
import { cn } from "@/lib/utils";
import { IssueMentionCard } from "@/features/issues/components/issue-mention-card";
import { preprocessMarkdown } from "./utils/preprocess";
import "./content-editor.css";
// ---------------------------------------------------------------------------
// Lowlight — same engine + language set as Tiptap's CodeBlockLowlight
// ---------------------------------------------------------------------------
const lowlight = createLowlight(common);
// ---------------------------------------------------------------------------
// URL transform — allow mention:// protocol through react-markdown's sanitizer
// ---------------------------------------------------------------------------
function urlTransform(url: string): string {
if (url.startsWith("mention://")) return url;
return defaultUrlTransform(url);
}
// ---------------------------------------------------------------------------
// Custom react-markdown components
// ---------------------------------------------------------------------------
const components: Partial<Components> = {
// Links — route mention:// to mention components, others open in new tab
a: ({ href, children }) => {
if (href?.startsWith("mention://")) {
const match = href.match(
/^mention:\/\/(member|agent|issue|all)\/(.+)$/,
);
if (match?.[1] === "issue" && match[2]) {
const label =
typeof children === "string"
? children
: Array.isArray(children)
? children.join("")
: undefined;
// Wrap in inline span for vertical alignment (mimics Tiptap's NodeViewWrapper)
return (
<span
className="inline align-middle"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
window.open(`/issues/${match[2]}`, "_blank", "noopener,noreferrer");
}}
>
<IssueMentionCard issueId={match[2]} fallbackLabel={label} />
</span>
);
}
// Member / agent / all mentions
return <span className="mention">{children}</span>;
}
// Regular links — open in new tab (matches ContentEditor readonly behavior)
return (
<a
href={href}
onClick={(e) => {
e.preventDefault();
if (href) window.open(href, "_blank", "noopener,noreferrer");
}}
>
{children}
</a>
);
},
// Images — constrain width (matches Tiptap Image extension inline style)
img: ({ src, alt, ...props }) => (
<img
src={src}
alt={alt ?? ""}
style={{ maxWidth: "100%", height: "auto" }}
{...props}
/>
),
// Tables — wrap in tableWrapper div for border/radius/scroll (matches Tiptap)
table: ({ children }) => (
<div className="tableWrapper">
<table>{children}</table>
</div>
),
// Code — lowlight highlighting for blocks, plain render for inline
code: ({ className, children, node, ...props }) => {
const lang = /language-(\w+)/.exec(className || "")?.[1];
const isBlock =
node?.position &&
node.position.start.line !== node.position.end.line;
if (!isBlock && !lang) {
// Inline code — CSS handles styling via .rich-text-editor code
return <code {...props}>{children}</code>;
}
// Block code — highlight with lowlight, output hljs classes
const code = String(children).replace(/\n$/, "");
try {
const tree = lang
? lowlight.highlight(lang, code)
: lowlight.highlightAuto(code);
return (
<code
className={cn("hljs", lang && `language-${lang}`)}
dangerouslySetInnerHTML={{ __html: toHtml(tree) }}
/>
);
} catch {
// Fallback — render without highlighting
return (
<code className={className} {...props}>
{children}
</code>
);
}
},
// Pre — pass through (CSS handles styling via .rich-text-editor pre)
pre: ({ children }) => <pre>{children}</pre>,
};
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
interface ReadonlyContentProps {
content: string;
className?: string;
}
export function ReadonlyContent({ content, className }: ReadonlyContentProps) {
const processed = useMemo(() => preprocessMarkdown(content), [content]);
return (
<div className={cn("rich-text-editor readonly text-sm", className)}>
<ReactMarkdown
remarkPlugins={[[remarkGfm, { singleTilde: false }]]}
rehypePlugins={[rehypeRaw]}
urlTransform={urlTransform}
components={components}
>
{processed}
</ReactMarkdown>
</div>
);
}

View File

@@ -1,6 +0,0 @@
/**
* Copy markdown content to the clipboard.
*/
export async function copyMarkdown(markdown: string): Promise<void> {
await navigator.clipboard.writeText(markdown);
}

View File

@@ -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;
}

View File

@@ -1,13 +1 @@
// Inbox server state is managed by TanStack Query.
// See core/inbox/ for queries, mutations, and WS updaters.
export {
inboxKeys,
inboxListOptions,
deduplicateInboxItems,
useMarkInboxRead,
useArchiveInbox,
useMarkAllInboxRead,
useArchiveAllInbox,
useArchiveAllReadInbox,
useArchiveCompletedInbox,
} from "@core/inbox";
export { useInboxStore } from "./store";

View File

@@ -0,0 +1,117 @@
"use client";
import { create } from "zustand";
import type { InboxItem, IssueStatus } from "@/shared/types";
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);
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) => ({
items: s.items.map((i) => (i.id === id ? { ...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,
}));

View File

@@ -1,16 +1,13 @@
"use client";
import { useState, useEffect, useCallback, useRef } from "react";
import { Bot, ChevronRight, ChevronDown, Loader2, ArrowDown, Brain, AlertCircle, Clock, CheckCircle2, XCircle, Square } from "lucide-react";
import { Bot, ChevronRight, Loader2, ArrowDown, Brain, AlertCircle, Clock, CheckCircle2, XCircle } from "lucide-react";
import { api } from "@/shared/api";
import { useWSEvent } from "@/features/realtime";
import type { TaskMessagePayload, TaskCompletedPayload, TaskFailedPayload, TaskCancelledPayload } from "@/shared/types/events";
import type { TaskMessagePayload, TaskCompletedPayload, TaskFailedPayload } from "@/shared/types/events";
import type { AgentTask } from "@/shared/types/agent";
import { cn } from "@/lib/utils";
import { toast } from "sonner";
import { ActorAvatar } from "@/components/common/actor-avatar";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { useActorName } from "@/features/workspace";
import { redactSecrets } from "../utils/redact";
// ─── Shared types & helpers ─────────────────────────────────────────────────
@@ -95,51 +92,50 @@ function buildTimeline(msgs: TaskMessagePayload[]): TimelineItem[] {
return items.sort((a, b) => a.seq - b.seq);
}
// ─── Per-task state ─────────────────────────────────────────────────────────
interface TaskState {
task: AgentTask;
items: TimelineItem[];
}
// ─── AgentLiveCard (real-time view for multiple agents) ───────────────────
// ─── AgentLiveCard (real-time view) ────────────────────────────────────────
interface AgentLiveCardProps {
issueId: string;
/** Scroll container ref — used to auto-collapse timeline on outer scroll. */
scrollContainerRef?: React.RefObject<HTMLDivElement | null>;
assigneeType: string | null;
assigneeId: string | null;
agentName?: string;
}
export function AgentLiveCard({ issueId, scrollContainerRef }: AgentLiveCardProps) {
const { getActorName } = useActorName();
const [taskStates, setTaskStates] = useState<Map<string, TaskState>>(new Map());
export function AgentLiveCard({ issueId, assigneeType, assigneeId, agentName }: AgentLiveCardProps) {
const [activeTask, setActiveTask] = useState<AgentTask | null>(null);
const [items, setItems] = useState<TimelineItem[]>([]);
const [elapsed, setElapsed] = useState("");
const [autoScroll, setAutoScroll] = useState(true);
const scrollRef = useRef<HTMLDivElement>(null);
const seenSeqs = useRef(new Set<string>());
// Fetch active tasks on mount
// Check for active task on mount
useEffect(() => {
if (assigneeType !== "agent" || !assigneeId) {
setActiveTask(null);
return;
}
let cancelled = false;
api.getActiveTasksForIssue(issueId).then(({ tasks }) => {
if (cancelled || tasks.length === 0) return;
const newStates = new Map<string, TaskState>();
const loadPromises = tasks.map(async (task) => {
try {
const msgs = await api.listTaskMessages(task.id);
const timeline = buildTimeline(msgs);
for (const m of msgs) seenSeqs.current.add(`${m.task_id}:${m.seq}`);
newStates.set(task.id, { task, items: timeline });
} catch {
newStates.set(task.id, { task, items: [] });
api.getActiveTaskForIssue(issueId).then(({ task }) => {
if (!cancelled) {
setActiveTask(task);
if (task) {
api.listTaskMessages(task.id).then((msgs) => {
if (!cancelled) {
const timeline = buildTimeline(msgs);
setItems(timeline);
for (const m of msgs) seenSeqs.current.add(`${m.task_id}:${m.seq}`);
}
}).catch(() => {});
}
});
Promise.all(loadPromises).then(() => {
if (!cancelled) setTaskStates(newStates);
});
}).catch(console.error);
}
}).catch(() => {});
return () => { cancelled = true; };
}, [issueId]);
}, [issueId, assigneeType, assigneeId]);
// Handle real-time task messages — route by task_id
// Handle real-time task messages
useWSEvent(
"task:message",
useCallback((payload: unknown) => {
@@ -149,123 +145,69 @@ export function AgentLiveCard({ issueId, scrollContainerRef }: AgentLiveCardProp
if (seenSeqs.current.has(key)) return;
seenSeqs.current.add(key);
const item: TimelineItem = {
seq: msg.seq,
type: msg.type,
tool: msg.tool,
content: msg.content,
input: msg.input,
output: msg.output,
};
setTaskStates((prev) => {
const next = new Map(prev);
const existing = next.get(msg.task_id);
if (existing) {
const items = [...existing.items, item].sort((a, b) => a.seq - b.seq);
next.set(msg.task_id, { ...existing, items });
}
// If we don't have this task yet, the dispatch handler will pick it up
setItems((prev) => {
const item: TimelineItem = {
seq: msg.seq,
type: msg.type,
tool: msg.tool,
content: msg.content,
input: msg.input,
output: msg.output,
};
const next = [...prev, item];
next.sort((a, b) => a.seq - b.seq);
return next;
});
}, [issueId]),
);
// Handle task end events — remove only the specific task
const handleTaskEnd = useCallback((payload: unknown) => {
const p = payload as { task_id: string; issue_id: string };
if (p.issue_id !== issueId) return;
setTaskStates((prev) => {
const next = new Map(prev);
next.delete(p.task_id);
return next;
});
}, [issueId]);
useWSEvent("task:completed", handleTaskEnd);
useWSEvent("task:failed", handleTaskEnd);
useWSEvent("task:cancelled", handleTaskEnd);
// Pick up newly dispatched tasks
// Handle task completion/failure
useWSEvent(
"task:dispatch",
useCallback(() => {
api.getActiveTasksForIssue(issueId).then(({ tasks }) => {
setTaskStates((prev) => {
const next = new Map(prev);
for (const task of tasks) {
if (!next.has(task.id)) {
next.set(task.id, { task, items: [] });
}
}
return next;
});
}).catch(console.error);
"task:completed",
useCallback((payload: unknown) => {
const p = payload as TaskCompletedPayload;
if (p.issue_id !== issueId) return;
setActiveTask(null);
setItems([]);
seenSeqs.current.clear();
}, [issueId]),
);
if (taskStates.size === 0) return null;
const entries = Array.from(taskStates.values());
return (
<div className="mt-4 space-y-2">
{entries.map(({ task, items }) => (
<SingleAgentLiveCard
key={task.id}
task={task}
items={items}
issueId={issueId}
agentName={task.agent_id ? getActorName("agent", task.agent_id) : "Agent"}
scrollContainerRef={scrollContainerRef}
/>
))}
</div>
useWSEvent(
"task:failed",
useCallback((payload: unknown) => {
const p = payload as TaskFailedPayload;
if (p.issue_id !== issueId) return;
setActiveTask(null);
setItems([]);
seenSeqs.current.clear();
}, [issueId]),
);
}
// ─── SingleAgentLiveCard (one card per running task) ──────────────────────
interface SingleAgentLiveCardProps {
task: AgentTask;
items: TimelineItem[];
issueId: string;
agentName: string;
scrollContainerRef?: React.RefObject<HTMLDivElement | null>;
}
function SingleAgentLiveCard({ task, items, issueId, agentName, scrollContainerRef }: SingleAgentLiveCardProps) {
const [elapsed, setElapsed] = useState("");
const [open, setOpen] = useState(false);
const [autoScroll, setAutoScroll] = useState(true);
const [cancelling, setCancelling] = useState(false);
const scrollRef = useRef<HTMLDivElement>(null);
const ignoreScrollRef = useRef(false);
// Pick up new tasks
useWSEvent(
"task:dispatch",
useCallback(() => {
api.getActiveTaskForIssue(issueId).then(({ task }) => {
if (task) {
setActiveTask(task);
setItems([]);
seenSeqs.current.clear();
}
}).catch(() => {});
}, [issueId]),
);
// Elapsed time
useEffect(() => {
if (!task.started_at && !task.dispatched_at) return;
const startRef = task.started_at ?? task.dispatched_at!;
setElapsed(formatElapsed(startRef));
const interval = setInterval(() => setElapsed(formatElapsed(startRef)), 1000);
if (!activeTask?.started_at && !activeTask?.dispatched_at) return;
const ref = activeTask.started_at ?? activeTask.dispatched_at!;
setElapsed(formatElapsed(ref));
const interval = setInterval(() => setElapsed(formatElapsed(ref)), 1000);
return () => clearInterval(interval);
}, [task.started_at, task.dispatched_at]);
}, [activeTask?.started_at, activeTask?.dispatched_at]);
// Auto-collapse timeline when outer scroll container scrolls
useEffect(() => {
const container = scrollContainerRef?.current;
if (!container) return;
const handleOuterScroll = () => {
if (ignoreScrollRef.current) return;
setOpen(false);
};
container.addEventListener("scroll", handleOuterScroll, { passive: true });
return () => container.removeEventListener("scroll", handleOuterScroll);
}, [scrollContainerRef]);
// Auto-scroll timeline to bottom
// Auto-scroll
useEffect(() => {
if (autoScroll && scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
@@ -278,109 +220,56 @@ function SingleAgentLiveCard({ task, items, issueId, agentName, scrollContainerR
setAutoScroll(scrollHeight - scrollTop - clientHeight < 40);
}, []);
const toggleOpen = useCallback(() => {
if (!open) {
ignoreScrollRef.current = true;
setTimeout(() => { ignoreScrollRef.current = false; }, 300);
}
setOpen(!open);
}, [open]);
const handleCancel = useCallback(async () => {
if (cancelling) return;
setCancelling(true);
try {
await api.cancelTask(issueId, task.id);
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed to cancel task");
setCancelling(false);
}
}, [task.id, issueId, cancelling]);
if (!activeTask) return null;
const toolCount = items.filter((i) => i.type === "tool_use").length;
return (
<div className="sticky top-4 z-10 rounded-lg border border-info/20 bg-info/5 backdrop-blur-sm">
{/* Header — click to toggle timeline */}
<div
className="group flex items-center gap-2 px-3 py-2 cursor-pointer select-none text-muted-foreground hover:text-foreground transition-colors"
role="button"
tabIndex={0}
aria-expanded={open}
onClick={toggleOpen}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
toggleOpen();
}
}}
>
{task.agent_id ? (
<ActorAvatar actorType="agent" actorId={task.agent_id} size={20} />
) : (
<div className="flex items-center justify-center h-5 w-5 rounded-full shrink-0 bg-info/10 text-info">
<Bot className="h-3 w-3" />
</div>
)}
<div className="flex items-center gap-1.5 text-xs min-w-0">
<div className="rounded-lg border border-info/20 bg-info/5">
{/* Header */}
<div className="flex items-center gap-2 px-3 py-2">
<div className="flex items-center justify-center h-5 w-5 rounded-full bg-info/10 text-info shrink-0">
<Bot className="h-3 w-3" />
</div>
<div className="flex items-center gap-1.5 text-xs font-medium min-w-0">
<Loader2 className="h-3 w-3 animate-spin text-info shrink-0" />
<span className="font-medium text-foreground truncate">{agentName} is working</span>
<span className="text-muted-foreground tabular-nums shrink-0">{elapsed}</span>
{toolCount > 0 && (
<span className="text-muted-foreground shrink-0">{toolCount} tools</span>
)}
<span className="truncate">{agentName ?? "Agent"} is working</span>
</div>
<div className="ml-auto flex items-center gap-1 shrink-0">
<button
onClick={(e) => { e.stopPropagation(); handleCancel(); }}
disabled={cancelling}
className="flex items-center gap-1 rounded px-1.5 py-0.5 text-xs text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors disabled:opacity-50"
title="Stop agent"
>
{cancelling ? <Loader2 className="h-3 w-3 animate-spin" /> : <Square className="h-3 w-3" />}
<span>Stop</span>
</button>
<ChevronDown className={cn("h-3.5 w-3.5 transition-transform", open && "rotate-180")} />
</div>
</div>
{/* Timeline — grid-rows animation for smooth collapse/expand */}
<div
className={cn(
"grid transition-[grid-template-rows] duration-200 ease-out",
open ? "grid-rows-[1fr]" : "grid-rows-[0fr]",
<span className="ml-auto text-xs text-muted-foreground tabular-nums shrink-0">{elapsed}</span>
{toolCount > 0 && (
<span className="text-xs text-muted-foreground shrink-0">
{toolCount} tool {toolCount === 1 ? "call" : "calls"}
</span>
)}
>
<div className="overflow-hidden">
{items.length > 0 && (
<div
ref={scrollRef}
onScroll={handleScroll}
className="relative max-h-80 overflow-y-auto overscroll-y-contain border-t border-info/10 px-3 py-2 space-y-0.5"
>
{items.map((item, idx) => (
<TimelineRow key={`${item.seq}-${idx}`} item={item} />
))}
</div>
{!autoScroll && (
<button
onClick={(e) => {
e.stopPropagation();
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
setAutoScroll(true);
}
}}
className="sticky bottom-0 left-1/2 -translate-x-1/2 flex items-center gap-1 rounded-full bg-background border px-2 py-0.5 text-xs text-muted-foreground hover:text-foreground shadow-sm"
>
<ArrowDown className="h-3 w-3" />
Latest
</button>
)}
</div>
{/* Timeline content */}
{items.length > 0 && (
<div
ref={scrollRef}
onScroll={handleScroll}
className="relative max-h-80 overflow-y-auto border-t border-info/10 px-3 py-2 space-y-0.5"
>
{items.map((item, idx) => (
<TimelineRow key={`${item.seq}-${idx}`} item={item} />
))}
{!autoScroll && (
<button
onClick={() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
setAutoScroll(true);
}
}}
className="sticky bottom-0 left-1/2 -translate-x-1/2 flex items-center gap-1 rounded-full bg-background border px-2 py-0.5 text-xs text-muted-foreground hover:text-foreground shadow-sm"
>
<ArrowDown className="h-3 w-3" />
Latest
</button>
)}
</div>
</div>
)}
</div>
);
}
@@ -389,15 +278,17 @@ function SingleAgentLiveCard({ task, items, issueId, agentName, scrollContainerR
interface TaskRunHistoryProps {
issueId: string;
assigneeType: string | null;
}
export function TaskRunHistory({ issueId }: TaskRunHistoryProps) {
export function TaskRunHistory({ issueId, assigneeType }: TaskRunHistoryProps) {
const [tasks, setTasks] = useState<AgentTask[]>([]);
const [open, setOpen] = useState(false);
useEffect(() => {
api.listTasksByIssue(issueId).then(setTasks).catch(console.error);
}, [issueId]);
if (assigneeType !== "agent") return;
api.listTasksByIssue(issueId).then(setTasks).catch(() => {});
}, [issueId, assigneeType]);
// Refresh when a task completes
useWSEvent(
@@ -405,7 +296,7 @@ export function TaskRunHistory({ issueId }: TaskRunHistoryProps) {
useCallback((payload: unknown) => {
const p = payload as TaskCompletedPayload;
if (p.issue_id !== issueId) return;
api.listTasksByIssue(issueId).then(setTasks).catch(console.error);
api.listTasksByIssue(issueId).then(setTasks).catch(() => {});
}, [issueId]),
);
@@ -414,21 +305,11 @@ export function TaskRunHistory({ issueId }: TaskRunHistoryProps) {
useCallback((payload: unknown) => {
const p = payload as TaskFailedPayload;
if (p.issue_id !== issueId) return;
api.listTasksByIssue(issueId).then(setTasks).catch(console.error);
api.listTasksByIssue(issueId).then(setTasks).catch(() => {});
}, [issueId]),
);
// Refresh when a task is cancelled
useWSEvent(
"task:cancelled",
useCallback((payload: unknown) => {
const p = payload as TaskCancelledPayload;
if (p.issue_id !== issueId) return;
api.listTasksByIssue(issueId).then(setTasks).catch(console.error);
}, [issueId]),
);
const completedTasks = tasks.filter((t) => t.status === "completed" || t.status === "failed" || t.status === "cancelled");
const completedTasks = tasks.filter((t) => t.status === "completed" || t.status === "failed");
if (completedTasks.length === 0) return null;
return (
@@ -457,10 +338,7 @@ function TaskRunEntry({ task }: { task: AgentTask }) {
if (items !== null) return; // already loaded
api.listTaskMessages(task.id).then((msgs) => {
setItems(buildTimeline(msgs));
}).catch((e) => {
console.error(e);
setItems([]);
});
}).catch(() => setItems([]));
}, [task.id, items]);
useEffect(() => {

View File

@@ -1,7 +1,7 @@
"use client";
import { useState } from "react";
import { X, Trash2 } from "lucide-react";
import { X, Trash2, Bot, Lock, UserMinus } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
@@ -19,13 +19,15 @@ import {
PopoverTrigger,
PopoverContent,
} from "@/components/ui/popover";
import type { UpdateIssueRequest } from "@/shared/types";
import type { Agent, UpdateIssueRequest } from "@/shared/types";
import { ALL_STATUSES, STATUS_CONFIG, PRIORITY_ORDER, PRIORITY_CONFIG } from "@/features/issues/config";
import { useAuthStore } from "@/features/auth";
import { useWorkspaceStore, useActorName } from "@/features/workspace";
import { useIssueStore } from "@/features/issues/store";
import { useIssueSelectionStore } from "@/features/issues/stores/selection-store";
import { useBatchUpdateIssues, useBatchDeleteIssues } from "@core/issues/mutations";
import { api } from "@/shared/api";
import { StatusIcon } from "./status-icon";
import { PriorityIcon } from "./priority-icon";
import { AssigneePicker } from "./pickers";
export function BatchActionToolbar() {
const selectedIds = useIssueSelectionStore((s) => s.selectedIds);
@@ -36,31 +38,46 @@ export function BatchActionToolbar() {
const [priorityOpen, setPriorityOpen] = useState(false);
const [assigneeOpen, setAssigneeOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
const batchUpdate = useBatchUpdateIssues();
const batchDelete = useBatchDeleteIssues();
const loading = batchUpdate.isPending || batchDelete.isPending;
const [loading, setLoading] = useState(false);
if (count === 0) return null;
const ids = Array.from(selectedIds);
const handleBatchUpdate = async (updates: Partial<UpdateIssueRequest>) => {
const handleBatchUpdate = async (updates: UpdateIssueRequest) => {
setLoading(true);
try {
await batchUpdate.mutateAsync({ ids, updates });
await api.batchUpdateIssues(ids, updates);
for (const id of ids) {
useIssueStore.getState().updateIssue(id, updates);
}
toast.success(`Updated ${count} issue${count > 1 ? "s" : ""}`);
} catch {
toast.error("Failed to update issues");
api.listIssues({ limit: 200 }).then((res) => {
useIssueStore.getState().setIssues(res.issues);
});
} finally {
setLoading(false);
}
};
const handleBatchDelete = async () => {
setLoading(true);
try {
await batchDelete.mutateAsync(ids);
await api.batchDeleteIssues(ids);
for (const id of ids) {
useIssueStore.getState().removeIssue(id);
}
clear();
toast.success(`Deleted ${count} issue${count > 1 ? "s" : ""}`);
} catch {
toast.error("Failed to delete issues");
api.listIssues({ limit: 200 }).then((res) => {
useIssueStore.getState().setIssues(res.issues);
});
} finally {
setLoading(false);
setDeleteOpen(false);
}
};
@@ -144,15 +161,11 @@ export function BatchActionToolbar() {
</Popover>
{/* Assignee */}
<AssigneePicker
assigneeType={null}
assigneeId={null}
onUpdate={handleBatchUpdate}
<BatchAssigneePicker
open={assigneeOpen}
onOpenChange={setAssigneeOpen}
triggerRender={<Button variant="ghost" size="sm" disabled={loading} />}
trigger="Assignee"
align="center"
onUpdate={handleBatchUpdate}
loading={loading}
/>
{/* Delete */}
@@ -194,3 +207,136 @@ export function BatchActionToolbar() {
);
}
function canAssignAgent(agent: Agent, userId: string | undefined, memberRole: string | undefined): boolean {
if (agent.visibility !== "private") return true;
if (agent.owner_id === userId) return true;
if (memberRole === "owner" || memberRole === "admin") return true;
return false;
}
function BatchAssigneePicker({
open,
onOpenChange,
onUpdate,
loading,
}: {
open: boolean;
onOpenChange: (v: boolean) => void;
onUpdate: (updates: UpdateIssueRequest) => void;
loading: boolean;
}) {
const [filter, setFilter] = useState("");
const user = useAuthStore((s) => s.user);
const members = useWorkspaceStore((s) => s.members);
const agents = useWorkspaceStore((s) => s.agents);
const { getActorInitials } = useActorName();
const currentMember = members.find((m) => m.user_id === user?.id);
const memberRole = currentMember?.role;
const query = filter.toLowerCase();
const filteredMembers = members.filter((m) =>
m.name.toLowerCase().includes(query),
);
const filteredAgents = agents.filter((a) =>
a.name.toLowerCase().includes(query),
);
return (
<Popover
open={open}
onOpenChange={(v) => {
onOpenChange(v);
if (!v) setFilter("");
}}
>
<PopoverTrigger
render={
<Button variant="ghost" size="sm" disabled={loading} />
}
>
Assignee
</PopoverTrigger>
<PopoverContent align="center" className="w-52 p-0">
<div className="px-2 py-1.5 border-b">
<input
type="text"
value={filter}
onChange={(e) => setFilter(e.target.value)}
placeholder="Assign to..."
className="w-full bg-transparent text-sm placeholder:text-muted-foreground outline-none"
/>
</div>
<div className="p-1 max-h-60 overflow-y-auto">
<button
type="button"
onClick={() => {
onUpdate({ assignee_type: null, assignee_id: null });
onOpenChange(false);
}}
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent transition-colors"
>
<UserMinus className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-muted-foreground">Unassigned</span>
</button>
{filteredMembers.length > 0 && (
<div>
<div className="px-2 pt-2 pb-1 text-xs font-medium text-muted-foreground uppercase tracking-wider">
Members
</div>
{filteredMembers.map((m) => (
<button
key={m.user_id}
type="button"
onClick={() => {
onUpdate({ assignee_type: "member", assignee_id: m.user_id });
onOpenChange(false);
}}
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent transition-colors"
>
<div className="inline-flex size-4.5 shrink-0 items-center justify-center rounded-full bg-muted text-[8px] font-medium text-muted-foreground">
{getActorInitials("member", m.user_id)}
</div>
<span>{m.name}</span>
</button>
))}
</div>
)}
{filteredAgents.length > 0 && (
<div>
<div className="px-2 pt-2 pb-1 text-xs font-medium text-muted-foreground uppercase tracking-wider">
Agents
</div>
{filteredAgents.map((a) => {
const allowed = canAssignAgent(a, user?.id, memberRole);
return (
<button
key={a.id}
type="button"
disabled={!allowed}
onClick={() => {
if (!allowed) return;
onUpdate({ assignee_type: "agent", assignee_id: a.id });
onOpenChange(false);
}}
className={`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors ${allowed ? "hover:bg-accent" : "opacity-50 cursor-not-allowed"}`}
>
<div className={`inline-flex size-4.5 shrink-0 items-center justify-center rounded-full ${allowed ? "bg-info/10 text-info" : "bg-muted text-muted-foreground"}`}>
<Bot className="size-2.5" />
</div>
<span className={allowed ? "" : "text-muted-foreground"}>{a.name}</span>
{a.visibility === "private" && (
<Lock className="ml-auto h-3 w-3 text-muted-foreground" />
)}
</button>
);
})}
</div>
)}
</div>
</PopoverContent>
</Popover>
);
}

View File

@@ -2,19 +2,18 @@
import { useCallback, memo } from "react";
import Link from "next/link";
import { useSortable, defaultAnimateLayoutChanges } from "@dnd-kit/sortable";
import type { AnimateLayoutChanges } from "@dnd-kit/sortable";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { toast } from "sonner";
import type { Issue, UpdateIssueRequest } from "@/shared/types";
import { CalendarDays } from "lucide-react";
import { ActorAvatar } from "@/components/common/actor-avatar";
import { useUpdateIssue } from "@core/issues/mutations";
import { api } from "@/shared/api";
import { useIssueStore } from "@/features/issues/store";
import { PriorityIcon } from "./priority-icon";
import { PriorityPicker, AssigneePicker, DueDatePicker } from "./pickers";
import { PRIORITY_CONFIG } from "@/features/issues/config";
import type { CardProperties } from "@/features/issues/stores/view-store";
import { useViewStore } from "@/features/issues/stores/view-store-context";
import { useIssueViewStore, type CardProperties } from "@/features/issues/stores/view-store";
function formatDate(date: string): string {
return new Date(date).toLocaleDateString("en-US", {
@@ -43,18 +42,19 @@ export const BoardCardContent = memo(function BoardCardContent({
issue: Issue;
editable?: boolean;
}) {
const storeProperties = useViewStore((s) => s.cardProperties);
const storeProperties = useIssueViewStore((s) => s.cardProperties);
const priorityCfg = PRIORITY_CONFIG[issue.priority];
const updateIssueMutation = useUpdateIssue();
const handleUpdate = useCallback(
(updates: Partial<UpdateIssueRequest>) => {
updateIssueMutation.mutate(
{ id: issue.id, ...updates },
{ onError: () => toast.error("Failed to update issue") },
);
const prev = { ...issue };
useIssueStore.getState().updateIssue(issue.id, updates);
api.updateIssue(issue.id, updates).catch(() => {
useIssueStore.getState().updateIssue(issue.id, prev);
toast.error("Failed to update issue");
});
},
[issue.id, updateIssueMutation],
[issue],
);
const showPriority = storeProperties.priority;
@@ -167,12 +167,6 @@ export const BoardCardContent = memo(function BoardCardContent({
);
});
const animateLayoutChanges: AnimateLayoutChanges = (args) => {
const { isSorting, wasDragging } = args;
if (isSorting || wasDragging) return false;
return defaultAnimateLayoutChanges(args);
};
export const DraggableBoardCard = memo(function DraggableBoardCard({ issue }: { issue: Issue }) {
const {
attributes,
@@ -184,7 +178,6 @@ export const DraggableBoardCard = memo(function DraggableBoardCard({ issue }: {
} = useSortable({
id: issue.id,
data: { status: issue.status },
animateLayoutChanges,
});
const style = {

View File

@@ -1,6 +1,6 @@
"use client";
import { useMemo, type ReactNode } from "react";
import { useMemo } from "react";
import { EyeOff, MoreHorizontal, Plus } from "lucide-react";
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
import { useDroppable } from "@dnd-kit/core";
@@ -15,35 +15,31 @@ import {
} from "@/components/ui/dropdown-menu";
import { STATUS_CONFIG } from "@/features/issues/config";
import { useModalStore } from "@/features/modals";
import { useViewStoreApi } from "@/features/issues/stores/view-store-context";
import { useIssueViewStore } from "@/features/issues/stores/view-store";
import { sortIssues } from "@/features/issues/utils/sort";
import { StatusIcon } from "./status-icon";
import { DraggableBoardCard } from "./board-card";
export function BoardColumn({
status,
issueIds,
issueMap,
totalCount,
footer,
issues,
}: {
status: IssueStatus;
issueIds: string[];
issueMap: Map<string, Issue>;
totalCount?: number;
footer?: ReactNode;
issues: Issue[];
}) {
const cfg = STATUS_CONFIG[status];
const { setNodeRef, isOver } = useDroppable({ id: status });
const viewStoreApi = useViewStoreApi();
const sortBy = useIssueViewStore((s) => s.sortBy);
const sortDirection = useIssueViewStore((s) => s.sortDirection);
// Resolve IDs to Issue objects, preserving parent-provided order
const resolvedIssues = useMemo(
() =>
issueIds.flatMap((id) => {
const issue = issueMap.get(id);
return issue ? [issue] : [];
}),
[issueIds, issueMap],
const sortedIssues = useMemo(
() => sortIssues(issues, sortBy, sortDirection),
[issues, sortBy, sortDirection]
);
const sortedIds = useMemo(
() => sortedIssues.map((i) => i.id),
[sortedIssues]
);
return (
@@ -56,7 +52,7 @@ export function BoardColumn({
{cfg.label}
</span>
<span className="text-xs text-muted-foreground">
{totalCount ?? issueIds.length}
{issues.length}
</span>
</div>
@@ -71,7 +67,7 @@ export function BoardColumn({
}
/>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => viewStoreApi.getState().hideStatus(status)}>
<DropdownMenuItem onClick={() => useIssueViewStore.getState().hideStatus(status)}>
<EyeOff className="size-3.5" />
Hide column
</DropdownMenuItem>
@@ -100,17 +96,16 @@ export function BoardColumn({
isOver ? "bg-accent/60" : ""
}`}
>
<SortableContext items={issueIds} strategy={verticalListSortingStrategy}>
{resolvedIssues.map((issue) => (
<SortableContext items={sortedIds} strategy={verticalListSortingStrategy}>
{sortedIssues.map((issue) => (
<DraggableBoardCard key={issue.id} issue={issue} />
))}
</SortableContext>
{issueIds.length === 0 && (
{issues.length === 0 && (
<p className="py-8 text-center text-xs text-muted-foreground">
No issues
</p>
)}
{footer}
</div>
</div>
);

View File

@@ -1,6 +1,6 @@
"use client";
import { useState, useCallback, useMemo, useEffect, useRef } from "react";
import { useState, useCallback, useMemo } from "react";
import {
DndContext,
DragOverlay,
@@ -12,13 +12,10 @@ import {
type CollisionDetection,
type DragStartEvent,
type DragEndEvent,
type DragOverEvent,
} from "@dnd-kit/core";
import { arrayMove } from "@dnd-kit/sortable";
import { Eye, Loader2, MoreHorizontal } from "lucide-react";
import { Eye, MoreHorizontal } from "lucide-react";
import type { Issue, IssueStatus } from "@/shared/types";
import { Button } from "@/components/ui/button";
import { useLoadMoreDoneIssues } from "@core/issues/mutations";
import {
DropdownMenu,
DropdownMenuTrigger,
@@ -26,37 +23,11 @@ import {
DropdownMenuItem,
} from "@/components/ui/dropdown-menu";
import { ALL_STATUSES, STATUS_CONFIG } from "@/features/issues/config";
import { useViewStoreApi, useViewStore } from "@/features/issues/stores/view-store-context";
import type { SortField, SortDirection } from "@/features/issues/stores/view-store";
import { sortIssues } from "@/features/issues/utils/sort";
import { useIssueViewStore } from "@/features/issues/stores/view-store";
import { StatusIcon } from "./status-icon";
import { BoardColumn } from "./board-column";
import { BoardCardContent } from "./board-card";
/** Sentinel that triggers `onVisible` when scrolled into view. */
function InfiniteScrollSentinel({ onVisible, loading }: { onVisible: () => void; loading: boolean }) {
const sentinelRef = useRef<HTMLDivElement>(null);
const onVisibleRef = useRef(onVisible);
onVisibleRef.current = onVisible;
useEffect(() => {
const node = sentinelRef.current;
if (!node) return;
const observer = new IntersectionObserver(
([entry]) => { if (entry?.isIntersecting) onVisibleRef.current(); },
{ rootMargin: "100px" },
);
observer.observe(node);
return () => observer.disconnect();
}, []);
return (
<div ref={sentinelRef} className="flex items-center justify-center py-2">
{loading && <Loader2 className="size-3 animate-spin text-muted-foreground" />}
</div>
);
}
const COLUMN_IDS = new Set<string>(ALL_STATUSES);
const kanbanCollision: CollisionDetection = (args) => {
@@ -73,47 +44,13 @@ const kanbanCollision: CollisionDetection = (args) => {
return closestCenter(args);
};
/** Build column ID arrays from TQ issue data, respecting current sort. */
function buildColumns(
issues: Issue[],
visibleStatuses: IssueStatus[],
sortBy: SortField,
sortDirection: SortDirection,
): Record<IssueStatus, string[]> {
const cols = {} as Record<IssueStatus, string[]>;
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;
}
/** Compute a float position for `activeId` based on its neighbors in `ids`. */
function computePosition(ids: string[], activeId: string, issueMap: Map<string, Issue>): number {
const idx = ids.indexOf(activeId);
if (idx === -1) return 0;
const getPos = (id: string) => issueMap.get(id)?.position ?? 0;
if (ids.length === 1) return issueMap.get(activeId)?.position ?? 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;
}
/** Find which column (status) contains a given ID (issue or column droppable). */
function findColumn(
columns: Record<IssueStatus, string[]>,
id: string,
visibleStatuses: IssueStatus[],
): IssueStatus | null {
if (visibleStatuses.includes(id as IssueStatus)) return id as IssueStatus;
for (const [status, ids] of Object.entries(columns)) {
if (ids.includes(id)) return status as IssueStatus;
}
return null;
/** 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({
@@ -133,53 +70,7 @@ export function BoardView({
newPosition?: number
) => void;
}) {
const sortBy = useViewStore((s) => s.sortBy);
const sortDirection = useViewStore((s) => s.sortDirection);
const { loadMore, hasMore, isLoading: loadingMore, doneTotal } = useLoadMoreDoneIssues();
// --- Drag state ---
const [activeIssue, setActiveIssue] = useState<Issue | null>(null);
const isDraggingRef = useRef(false);
// --- Local columns state ---
// Between drags: follows TQ via useEffect.
// During drag: local-only, driven by onDragOver/onDragEnd.
const [columns, setColumns] = useState<Record<IssueStatus, string[]>>(() =>
buildColumns(issues, visibleStatuses, sortBy, sortDirection),
);
const columnsRef = useRef(columns);
columnsRef.current = columns;
useEffect(() => {
if (!isDraggingRef.current) {
setColumns(buildColumns(issues, visibleStatuses, sortBy, sortDirection));
}
}, [issues, visibleStatuses, sortBy, sortDirection]);
// After a cross-column move, lock for one animation frame so dnd-kit's
// collision detection can stabilize before processing the next move.
// Without this, collision oscillates: A→B→A→B… until React bails out.
const recentlyMovedRef = useRef(false);
useEffect(() => {
const id = requestAnimationFrame(() => {
recentlyMovedRef.current = false;
});
return () => cancelAnimationFrame(id);
}, [columns]);
// --- Issue map ---
// Frozen during drag so BoardColumn/DraggableBoardCard props stay
// referentially stable even if a TQ refetch lands mid-drag.
const issueMap = useMemo(() => {
const map = new Map<string, Issue>();
for (const issue of issues) map.set(issue.id, issue);
return map;
}, [issues]);
const issueMapRef = useRef(issueMap);
if (!isDraggingRef.current) {
issueMapRef.current = issueMap;
}
const sensors = useSensors(
useSensor(PointerSensor, {
@@ -187,100 +78,89 @@ export function BoardView({
})
);
// 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) => {
isDraggingRef.current = true;
const issue = issueMapRef.current.get(event.active.id as string) ?? null;
setActiveIssue(issue);
const issue = issues.find((i) => i.id === event.active.id);
if (issue) setActiveIssue(issue);
},
[],
);
const handleDragOver = useCallback(
(event: DragOverEvent) => {
const { active, over } = event;
if (!over || recentlyMovedRef.current) return;
const activeId = active.id as string;
const overId = over.id as string;
setColumns((prev) => {
const activeCol = findColumn(prev, activeId, visibleStatuses);
const overCol = findColumn(prev, overId, visibleStatuses);
if (!activeCol || !overCol || activeCol === overCol) return prev;
recentlyMovedRef.current = true;
const oldIds = prev[activeCol]!.filter((id) => id !== activeId);
const newIds = [...prev[overCol]!];
const overIndex = newIds.indexOf(overId);
const insertIndex = overIndex >= 0 ? overIndex : newIds.length;
newIds.splice(insertIndex, 0, activeId);
return { ...prev, [activeCol]: oldIds, [overCol]: newIds };
});
},
[visibleStatuses],
[issues]
);
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
const { active, over } = event;
isDraggingRef.current = false;
setActiveIssue(null);
const { active, over } = event;
if (!over || active.id === over.id) return;
const resetColumns = () =>
setColumns(buildColumns(issues, visibleStatuses, sortBy, sortDirection));
const issueId = active.id as string;
const currentIssue = issues.find((i) => i.id === issueId);
if (!currentIssue) return;
if (!over) {
resetColumns();
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;
}
const activeId = active.id as string;
const overId = over.id as string;
// Get sorted siblings in the target column (excluding the dragged item)
const siblings = (issuesByStatus[targetStatus] ?? []).filter(
(i) => i.id !== issueId
);
const cols = columnsRef.current;
const activeCol = findColumn(cols, activeId, visibleStatuses);
const overCol = findColumn(cols, overId, visibleStatuses);
if (!activeCol || !overCol) {
resetColumns();
return;
}
// Compute new position
let newPosition: number;
// Same-column reorder
let finalColumns = cols;
if (activeCol === overCol) {
const ids = cols[activeCol]!;
const oldIndex = ids.indexOf(activeId);
const newIndex = ids.indexOf(overId);
if (oldIndex !== -1 && newIndex !== -1 && oldIndex !== newIndex) {
const reordered = arrayMove(ids, oldIndex, newIndex);
finalColumns = { ...cols, [activeCol]: reordered };
setColumns(finalColumns);
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);
}
}
}
const finalCol = findColumn(finalColumns, activeId, visibleStatuses);
if (!finalCol) {
resetColumns();
return;
}
const map = issueMapRef.current;
const finalIds = finalColumns[finalCol]!;
const newPosition = computePosition(finalIds, activeId, map);
const currentIssue = map.get(activeId);
// Skip if nothing changed
if (
currentIssue &&
currentIssue.status === finalCol &&
currentIssue.status === targetStatus &&
currentIssue.position === newPosition
) {
return;
}
onMoveIssue(activeId, finalCol, newPosition);
onMoveIssue(issueId, targetStatus, newPosition);
},
[issues, visibleStatuses, sortBy, sortDirection, onMoveIssue],
[issues, issuesByStatus, onMoveIssue, visibleStatuses]
);
return (
@@ -288,7 +168,6 @@ export function BoardView({
sensors={sensors}
collisionDetection={kanbanCollision}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}
>
<div className="flex flex-1 min-h-0 gap-4 overflow-x-auto p-4">
@@ -296,14 +175,7 @@ export function BoardView({
<BoardColumn
key={status}
status={status}
issueIds={columns[status] ?? []}
issueMap={issueMapRef.current}
totalCount={status === "done" ? doneTotal : undefined}
footer={
status === "done" && hasMore ? (
<InfiniteScrollSentinel onVisible={loadMore} loading={loadingMore} />
) : undefined
}
issues={issues.filter((i) => i.status === status)}
/>
))}
@@ -315,9 +187,9 @@ export function BoardView({
)}
</div>
<DragOverlay dropAnimation={null}>
<DragOverlay>
{activeIssue ? (
<div className="w-[280px] rotate-2 scale-105 cursor-grabbing opacity-90 shadow-lg shadow-black/10">
<div className="w-[280px] rotate-1 cursor-grabbing opacity-95 shadow-md">
<BoardCardContent issue={activeIssue} />
</div>
) : null}
@@ -333,7 +205,6 @@ function HiddenColumnsPanel({
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">
@@ -371,7 +242,7 @@ function HiddenColumnsPanel({
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() =>
viewStoreApi.getState().showStatus(status)
useIssueViewStore.getState().showStatus(status)
}
>
<Eye className="size-3.5" />

View File

@@ -13,26 +13,14 @@ import {
DropdownMenuSeparator,
} from "@/components/ui/dropdown-menu";
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from "@/components/ui/collapsible";
import { ActorAvatar } from "@/components/common/actor-avatar";
import { ReactionBar } from "@/components/common/reaction-bar";
import { QuickEmojiPicker } from "@/components/common/quick-emoji-picker";
import { cn } from "@/lib/utils";
import { useActorName } from "@/features/workspace";
import { timeAgo } from "@/shared/utils";
import { ContentEditor, type ContentEditorRef, copyMarkdown, ReadonlyContent } from "@/features/editor";
import { FileUploadButton } from "@/components/common/file-upload-button";
import { useFileUpload } from "@/shared/hooks/use-file-upload";
import { RichTextEditor, type RichTextEditorRef } from "@/components/common/rich-text-editor";
import { Markdown } from "@/components/markdown";
import { ReplyInput } from "./reply-input";
import type { TimelineEntry } from "@/shared/types";
@@ -49,45 +37,6 @@ interface CommentCardProps {
onEdit: (commentId: string, content: string) => Promise<void>;
onDelete: (commentId: string) => void;
onToggleReaction: (commentId: string, emoji: string) => void;
/** ID of the comment to highlight (flash animation). */
highlightedCommentId?: string | null;
}
// ---------------------------------------------------------------------------
// Shared delete confirmation dialog
// ---------------------------------------------------------------------------
function DeleteCommentDialog({
open,
onOpenChange,
onConfirm,
hasReplies,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
onConfirm: () => void;
hasReplies?: boolean;
}) {
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete comment</AlertDialogTitle>
<AlertDialogDescription>
{hasReplies
? "This comment and all its replies will be permanently deleted. This cannot be undone."
: "This comment will be permanently deleted. This cannot be undone."}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction variant="destructive" onClick={onConfirm}>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
// ---------------------------------------------------------------------------
@@ -95,14 +44,12 @@ function DeleteCommentDialog({
// ---------------------------------------------------------------------------
function CommentRow({
issueId,
entry,
currentUserId,
onEdit,
onDelete,
onToggleReaction,
}: {
issueId: string;
entry: TimelineEntry;
currentUserId?: string;
onEdit: (commentId: string, content: string) => Promise<void>;
@@ -111,34 +58,25 @@ function CommentRow({
}) {
const { getActorName } = useActorName();
const [editing, setEditing] = useState(false);
const editEditorRef = useRef<ContentEditorRef>(null);
const cancelledRef = useRef(false);
const { uploadWithToast } = useFileUpload();
const editEditorRef = useRef<RichTextEditorRef>(null);
const isOwn = entry.actor_type === "member" && entry.actor_id === currentUserId;
const isTemp = entry.id.startsWith("temp-");
const [confirmDelete, setConfirmDelete] = useState(false);
const startEdit = () => {
cancelledRef.current = false;
setEditing(true);
};
const cancelEdit = () => {
cancelledRef.current = true;
setEditing(false);
};
const saveEdit = async () => {
if (cancelledRef.current) return;
const trimmed = editEditorRef.current
?.getMarkdown()
?.replace(/(\n\s*)+$/, "")
.trim();
if (!trimmed || trimmed === (entry.content ?? "").trim()) {
setEditing(false);
return;
}
if (!trimmed) return;
try {
await onEdit(entry.id, trimmed);
setEditing(false);
@@ -148,8 +86,6 @@ function CommentRow({
};
const reactions = entry.reactions ?? [];
const contentText = entry.content ?? "";
const isLongContent = contentText.length > 500 || contentText.split("\n").length > 8;
return (
<div className={`py-3${isTemp ? " opacity-60" : ""}`}>
@@ -172,22 +108,17 @@ function CommentRow({
</Tooltip>
{!isTemp && (
<div className="ml-auto flex items-center gap-0.5">
<QuickEmojiPicker
onSelect={(emoji) => onToggleReaction(entry.id, emoji)}
align="end"
/>
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button variant="ghost" size="icon-xs" className="text-muted-foreground">
<Button variant="ghost" size="icon-xs" className="ml-auto text-muted-foreground">
<MoreHorizontal className="h-4 w-4" />
</Button>
}
/>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => {
copyMarkdown(entry.content ?? "");
navigator.clipboard.writeText(entry.content ?? "");
toast.success("Copied");
}}>
<Copy className="h-3.5 w-3.5" />
@@ -201,7 +132,7 @@ function CommentRow({
Edit
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => setConfirmDelete(true)} variant="destructive">
<DropdownMenuItem onClick={() => onDelete(entry.id)} variant="destructive">
<Trash2 className="h-3.5 w-3.5" />
Delete
</DropdownMenuItem>
@@ -209,52 +140,38 @@ function CommentRow({
)}
</DropdownMenuContent>
</DropdownMenu>
<DeleteCommentDialog
open={confirmDelete}
onOpenChange={setConfirmDelete}
onConfirm={() => onDelete(entry.id)}
/>
</div>
)}
</div>
{editing ? (
<div
className="mt-1.5 pl-8"
className="mt-2 pl-8"
onKeyDown={(e) => { if (e.key === "Escape") cancelEdit(); }}
>
<div className="max-h-48 overflow-y-auto text-sm leading-relaxed">
<ContentEditor
<div className="max-h-48 overflow-y-auto rounded-md border border-border px-3 py-2">
<RichTextEditor
ref={editEditorRef}
defaultValue={entry.content ?? ""}
placeholder="Edit comment..."
onSubmit={saveEdit}
onUploadFile={(file) => uploadWithToast(file, { issueId })}
debounceMs={100}
/>
</div>
<div className="flex items-center justify-between mt-2">
<FileUploadButton
size="sm"
onSelect={(file) => editEditorRef.current?.uploadFile(file)}
/>
<div className="flex items-center gap-2">
<Button size="sm" variant="ghost" onClick={cancelEdit}>Cancel</Button>
<Button size="sm" variant="outline" onClick={saveEdit}>Save</Button>
</div>
<div className="flex gap-2 mt-1.5">
<Button size="sm" onClick={saveEdit}>Save</Button>
<Button size="sm" variant="ghost" onClick={cancelEdit}>Cancel</Button>
</div>
</div>
) : (
<>
<div className="mt-1.5 pl-8 text-sm leading-relaxed text-foreground/85">
<ReadonlyContent content={entry.content ?? ""} />
<Markdown mode="minimal">{entry.content ?? ""}</Markdown>
</div>
{!isTemp && (
<ReactionBar
reactions={reactions}
currentUserId={currentUserId}
onToggle={(emoji) => onToggleReaction(entry.id, emoji)}
hideAddButton={!isLongContent}
className="mt-1.5 pl-8"
/>
)}
@@ -277,39 +194,29 @@ function CommentCard({
onEdit,
onDelete,
onToggleReaction,
highlightedCommentId,
}: CommentCardProps) {
const { getActorName } = useActorName();
const { uploadWithToast } = useFileUpload();
const [open, setOpen] = useState(true);
const [editing, setEditing] = useState(false);
const editEditorRef = useRef<ContentEditorRef>(null);
const cancelledRef = useRef(false);
const editEditorRef = useRef<RichTextEditorRef>(null);
const isOwn = entry.actor_type === "member" && entry.actor_id === currentUserId;
const isTemp = entry.id.startsWith("temp-");
const [confirmDelete, setConfirmDelete] = useState(false);
const startEdit = () => {
cancelledRef.current = false;
setEditing(true);
};
const cancelEdit = () => {
cancelledRef.current = true;
setEditing(false);
};
const saveEdit = async () => {
if (cancelledRef.current) return;
const trimmed = editEditorRef.current
?.getMarkdown()
?.replace(/(\n\s*)+$/, "")
.trim();
if (!trimmed || trimmed === (entry.content ?? "").trim()) {
setEditing(false);
return;
}
if (!trimmed) return;
try {
await onEdit(entry.id, trimmed);
setEditing(false);
@@ -332,13 +239,9 @@ function CommentCard({
const replyCount = allNestedReplies.length;
const contentPreview = (entry.content ?? "").replace(/\n/g, " ").slice(0, 80);
const reactions = entry.reactions ?? [];
const contentText = entry.content ?? "";
const isLongContent = contentText.length > 500 || contentText.split("\n").length > 8;
const isHighlighted = highlightedCommentId === entry.id;
return (
<Card className={cn("!py-0 !gap-0 overflow-hidden transition-colors duration-700", isTemp && "opacity-60", isHighlighted && "ring-2 ring-brand/50 bg-brand/5")}>
<Card className={`!py-0 !gap-0 overflow-hidden${isTemp ? " opacity-60" : ""}`}>
<Collapsible open={open} onOpenChange={setOpen}>
{/* Header — always visible, acts as toggle */}
<div className="px-4 py-3">
@@ -375,22 +278,17 @@ function CommentCard({
)}
{open && !isTemp && (
<div className="ml-auto flex items-center gap-0.5">
<QuickEmojiPicker
onSelect={(emoji) => onToggleReaction(entry.id, emoji)}
align="end"
/>
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button variant="ghost" size="icon-xs" className="text-muted-foreground">
<Button variant="ghost" size="icon-xs" className="ml-auto text-muted-foreground">
<MoreHorizontal className="h-4 w-4" />
</Button>
}
/>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => {
copyMarkdown(entry.content ?? "");
navigator.clipboard.writeText(entry.content ?? "");
toast.success("Copied");
}}>
<Copy className="h-3.5 w-3.5" />
@@ -404,7 +302,7 @@ function CommentCard({
Edit
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => setConfirmDelete(true)} variant="destructive">
<DropdownMenuItem onClick={() => onDelete(entry.id)} variant="destructive">
<Trash2 className="h-3.5 w-3.5" />
Delete
</DropdownMenuItem>
@@ -412,13 +310,6 @@ function CommentCard({
)}
</DropdownMenuContent>
</DropdownMenu>
<DeleteCommentDialog
open={confirmDelete}
onOpenChange={setConfirmDelete}
onConfirm={() => onDelete(entry.id)}
hasReplies
/>
</div>
)}
</div>
</div>
@@ -432,8 +323,8 @@ function CommentCard({
className="pl-10"
onKeyDown={(e) => { if (e.key === "Escape") cancelEdit(); }}
>
<div className="max-h-48 overflow-y-auto text-sm leading-relaxed">
<ContentEditor
<div className="max-h-48 overflow-y-auto rounded-md border border-border px-3 py-2">
<RichTextEditor
ref={editEditorRef}
defaultValue={entry.content ?? ""}
placeholder="Edit comment..."
@@ -441,28 +332,21 @@ function CommentCard({
debounceMs={100}
/>
</div>
<div className="flex items-center justify-between mt-2">
<FileUploadButton
size="sm"
onSelect={(file) => editEditorRef.current?.uploadFile(file)}
/>
<div className="flex items-center gap-2">
<Button size="sm" variant="ghost" onClick={cancelEdit}>Cancel</Button>
<Button size="sm" variant="outline" onClick={saveEdit}>Save</Button>
</div>
<div className="flex gap-2 mt-1.5">
<Button size="sm" onClick={saveEdit}>Save</Button>
<Button size="sm" variant="ghost" onClick={cancelEdit}>Cancel</Button>
</div>
</div>
) : (
<>
<div className="pl-10 text-sm leading-relaxed text-foreground/85">
<ReadonlyContent content={entry.content ?? ""} />
<Markdown mode="minimal">{entry.content ?? ""}</Markdown>
</div>
{!isTemp && (
<ReactionBar
reactions={reactions}
currentUserId={currentUserId}
onToggle={(emoji) => onToggleReaction(entry.id, emoji)}
hideAddButton={!isLongContent}
className="mt-1.5 pl-10"
/>
)}
@@ -472,9 +356,8 @@ function CommentCard({
{/* Replies */}
{allNestedReplies.map((reply) => (
<div key={reply.id} id={`comment-${reply.id}`} className={cn("border-t border-border/50 px-4 transition-colors duration-700", highlightedCommentId === reply.id && "bg-brand/5")}>
<div key={reply.id} className="border-t border-border/50 px-4">
<CommentRow
issueId={issueId}
entry={reply}
currentUserId={currentUserId}
onEdit={onEdit}

View File

@@ -1,10 +1,9 @@
"use client";
import { useRef, useState } from "react";
import { ArrowUp, Loader2 } from "lucide-react";
import { ArrowUp, Loader2, Paperclip } from "lucide-react";
import { Button } from "@/components/ui/button";
import { ContentEditor, type ContentEditorRef } from "@/features/editor";
import { FileUploadButton } from "@/components/common/file-upload-button";
import { RichTextEditor, type RichTextEditorRef } from "@/components/common/rich-text-editor";
import { useFileUpload } from "@/shared/hooks/use-file-upload";
interface CommentInputProps {
@@ -13,38 +12,48 @@ interface CommentInputProps {
}
function CommentInput({ issueId, onSubmit }: CommentInputProps) {
const editorRef = useRef<ContentEditorRef>(null);
const editorRef = useRef<RichTextEditorRef>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const attachmentIdsRef = useRef<string[]>([]);
const [isEmpty, setIsEmpty] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [attachmentIds, setAttachmentIds] = useState<string[]>([]);
const { uploadWithToast } = useFileUpload();
const { uploadWithToast, uploading } = useFileUpload();
const handleUpload = async (file: File) => {
const result = await uploadWithToast(file, { issueId });
if (result) {
setAttachmentIds((prev) => [...prev, result.id]);
}
if (result) attachmentIdsRef.current.push(result.id);
return result;
};
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
e.target.value = "";
const result = await handleUpload(file);
if (result) {
editorRef.current?.insertFile(result.filename, result.link, file.type.startsWith("image/"));
}
};
const handleSubmit = async () => {
const content = editorRef.current?.getMarkdown()?.replace(/(\n\s*)+$/, "").trim();
if (!content || submitting) return;
setSubmitting(true);
try {
await onSubmit(content, attachmentIds.length > 0 ? attachmentIds : undefined);
const ids = attachmentIdsRef.current.length > 0 ? [...attachmentIdsRef.current] : undefined;
await onSubmit(content, ids);
editorRef.current?.clearContent();
attachmentIdsRef.current = [];
setIsEmpty(true);
setAttachmentIds([]);
} finally {
setSubmitting(false);
}
};
return (
<div className="relative flex max-h-56 flex-col rounded-lg bg-card pb-8 ring-1 ring-border">
<div className="flex-1 min-h-0 overflow-y-auto px-3 py-2">
<ContentEditor
<div className="relative rounded-lg bg-card ring-1 ring-border">
<div className="min-h-20 max-h-48 overflow-y-auto px-3 py-2 pb-8">
<RichTextEditor
ref={editorRef}
placeholder="Leave a comment..."
onUpdate={(md) => setIsEmpty(!md.trim())}
@@ -53,21 +62,28 @@ function CommentInput({ issueId, onSubmit }: CommentInputProps) {
debounceMs={100}
/>
</div>
<div className="absolute bottom-1 right-1.5 flex items-center gap-1">
<FileUploadButton
size="sm"
onSelect={(file) => editorRef.current?.uploadFile(file)}
<div className="absolute bottom-1.5 right-1.5 flex items-center gap-1">
<Button
variant="ghost"
size="icon-sm"
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
className="text-muted-foreground hover:text-foreground"
>
<Paperclip className="h-4 w-4" />
</Button>
<input
ref={fileInputRef}
type="file"
className="hidden"
onChange={handleFileSelect}
/>
<Button
size="icon-xs"
size="icon-sm"
disabled={isEmpty || submitting}
onClick={handleSubmit}
>
{submitting ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<ArrowUp className="h-3.5 w-3.5" />
)}
{submitting ? <Loader2 className="h-4 w-4 animate-spin" /> : <ArrowUp className="h-4 w-4" />}
</Button>
</div>
</div>

View File

@@ -1,6 +1,6 @@
export { StatusIcon } from "./status-icon";
export { PriorityIcon } from "./priority-icon";
export { StatusPicker, PriorityPicker, AssigneePicker, canAssignAgent, DueDatePicker } from "./pickers";
export { StatusPicker, PriorityPicker, AssigneePicker, DueDatePicker } from "./pickers";
export { IssueDetail } from "./issue-detail";
export { IssuesPage } from "./issues-page";
export { CommentCard } from "./comment-card";

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