mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-17 19:59:20 +02:00
Compare commits
1 Commits
feat/issue
...
agent/lamb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c5709c8651 |
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@@ -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
1
.gitignore
vendored
@@ -36,7 +36,6 @@ apps/web/test-results/
|
||||
|
||||
# local settings
|
||||
.claude/
|
||||
.tool-versions
|
||||
|
||||
# feature tracking
|
||||
_features/
|
||||
|
||||
@@ -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
278
AGENTS.md
@@ -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 Go’s 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.
|
||||
|
||||
12
CLAUDE.md
12
CLAUDE.md
@@ -149,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
|
||||
|
||||
@@ -197,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
|
||||
|
||||
@@ -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-cli
|
||||
```
|
||||
|
||||
### 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
|
||||
```
|
||||
199
LICENSE
199
LICENSE
@@ -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.
|
||||
@@ -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:
|
||||
|
||||
331
README.md
331
README.md
@@ -1,154 +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).
|
||||
|
||||
[](https://github.com/multica-ai/multica/actions/workflows/ci.yml)
|
||||
[](https://opensource.org/licenses/Apache-2.0)
|
||||
[](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.
|
||||
|
||||
```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.
|
||||
|
||||
154
README.zh-CN.md
154
README.zh-CN.md
@@ -1,154 +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 团队。
|
||||
|
||||
[](https://github.com/multica-ai/multica/actions/workflows/ci.yml)
|
||||
[](https://opensource.org/licenses/Apache-2.0)
|
||||
[](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。
|
||||
|
||||
```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 CLI,Multica 据此决定将任务路由到哪里执行。
|
||||
|
||||
### 3. 创建 Agent
|
||||
|
||||
进入 **设置 → Agents**,点击 **新建 Agent**。选择你刚连接的 Runtime,选择 Provider(Claude 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)
|
||||
278
SELF_HOSTING.md
278
SELF_HOSTING.md
@@ -1,278 +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
|
||||
export MULTICA_APP_URL=https://app.example.com
|
||||
export MULTICA_SERVER_URL=wss://api.example.com/ws
|
||||
|
||||
# Login (opens browser)
|
||||
multica login
|
||||
|
||||
# Start the daemon
|
||||
multica daemon start
|
||||
```
|
||||
|
||||
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.
|
||||
@@ -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" })
|
||||
|
||||
@@ -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("");
|
||||
@@ -286,7 +277,7 @@ function LoginPageContent() {
|
||||
<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">
|
||||
|
||||
@@ -76,9 +76,9 @@ export function AppSidebar() {
|
||||
const unreadCount = useInboxStore((s) => s.unreadCount());
|
||||
|
||||
const logout = () => {
|
||||
router.push("/");
|
||||
authLogout();
|
||||
useWorkspaceStore.getState().clearWorkspace();
|
||||
router.push("/login");
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef, useMemo } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useDefaultLayout } from "react-resizable-panels";
|
||||
import {
|
||||
Bot,
|
||||
@@ -28,8 +28,6 @@ import {
|
||||
Globe,
|
||||
Lock,
|
||||
Settings,
|
||||
Camera,
|
||||
Archive,
|
||||
} from "lucide-react";
|
||||
import type {
|
||||
Agent,
|
||||
@@ -71,14 +69,11 @@ import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { toast } from "sonner";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { api } from "@/shared/api";
|
||||
import { useAuthStore } from "@/features/auth";
|
||||
import { useWorkspaceStore } from "@/features/workspace";
|
||||
import { useRuntimeStore } from "@/features/runtimes";
|
||||
import { useIssueStore } from "@/features/issues";
|
||||
import { ActorAvatar } from "@/components/common/actor-avatar";
|
||||
import { useFileUpload } from "@/shared/hooks/use-file-upload";
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -102,6 +97,14 @@ const taskStatusConfig: Record<string, { label: string; icon: typeof CheckCircle
|
||||
cancelled: { label: "Cancelled", icon: XCircle, color: "text-muted-foreground" },
|
||||
};
|
||||
|
||||
function getInitials(name: string): string {
|
||||
return name
|
||||
.split(/[\s-]+/)
|
||||
.map((w) => w[0])
|
||||
.join("")
|
||||
.toUpperCase()
|
||||
.slice(0, 2);
|
||||
}
|
||||
|
||||
function generateId(): string {
|
||||
return `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
|
||||
@@ -330,7 +333,6 @@ function AgentListItem({
|
||||
onClick: () => void;
|
||||
}) {
|
||||
const st = statusConfig[agent.status];
|
||||
const isArchived = !!agent.archived_at;
|
||||
|
||||
return (
|
||||
<button
|
||||
@@ -339,11 +341,13 @@ function AgentListItem({
|
||||
isSelected ? "bg-accent" : "hover:bg-accent/50"
|
||||
}`}
|
||||
>
|
||||
<ActorAvatar actorType="agent" actorId={agent.id} size={32} className={`rounded-lg ${isArchived ? "opacity-50 grayscale" : ""}`} />
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-muted text-xs font-semibold">
|
||||
{getInitials(agent.name)}
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`truncate text-sm font-medium ${isArchived ? "text-muted-foreground" : ""}`}>{agent.name}</span>
|
||||
<span className="truncate text-sm font-medium">{agent.name}</span>
|
||||
{agent.runtime_mode === "cloud" ? (
|
||||
<Cloud className="h-3 w-3 text-muted-foreground" />
|
||||
) : (
|
||||
@@ -351,14 +355,8 @@ function AgentListItem({
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 mt-0.5">
|
||||
{isArchived ? (
|
||||
<span className="text-xs text-muted-foreground">Archived</span>
|
||||
) : (
|
||||
<>
|
||||
<span className={`h-1.5 w-1.5 rounded-full ${st.dot}`} />
|
||||
<span className={`text-xs ${st.color}`}>{st.label}</span>
|
||||
</>
|
||||
)}
|
||||
<span className={`h-1.5 w-1.5 rounded-full ${st.dot}`} />
|
||||
<span className={`text-xs ${st.color}`}>{st.label}</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
@@ -389,8 +387,6 @@ function InstructionsTab({
|
||||
setSaving(true);
|
||||
try {
|
||||
await onSave(value);
|
||||
} catch {
|
||||
// toast handled by parent
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -457,8 +453,6 @@ function SkillsTab({
|
||||
const newIds = [...agent.skills.map((s) => s.id), skillId];
|
||||
await api.setAgentSkills(agent.id, { skill_ids: newIds });
|
||||
await refreshAgents();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Failed to add skill");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
setShowPicker(false);
|
||||
@@ -471,8 +465,6 @@ function SkillsTab({
|
||||
const newIds = agent.skills.filter((s) => s.id !== skillId).map((s) => s.id);
|
||||
await api.setAgentSkills(agent.id, { skill_ids: newIds });
|
||||
await refreshAgents();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Failed to remove skill");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -716,8 +708,6 @@ function ToolsTab({
|
||||
setSaving(true);
|
||||
try {
|
||||
await onSave(tools);
|
||||
} catch {
|
||||
// toast handled by parent
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -862,8 +852,6 @@ function TriggersTab({
|
||||
setSaving(true);
|
||||
try {
|
||||
await onSave(triggers);
|
||||
} catch {
|
||||
// toast handled by parent
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -1069,17 +1057,8 @@ function TasksTab({ agent }: { agent: Agent }) {
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-3 rounded-lg border px-4 py-3">
|
||||
<Skeleton className="h-4 w-4 rounded shrink-0" />
|
||||
<div className="flex-1 space-y-1.5">
|
||||
<Skeleton className="h-4 w-1/2" />
|
||||
<Skeleton className="h-3 w-1/3" />
|
||||
</div>
|
||||
<Skeleton className="h-4 w-16" />
|
||||
</div>
|
||||
))}
|
||||
<div className="flex items-center justify-center py-12 text-sm text-muted-foreground">
|
||||
Loading tasks...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1194,22 +1173,6 @@ function SettingsTab({
|
||||
const [visibility, setVisibility] = useState<AgentVisibility>(agent.visibility);
|
||||
const [maxTasks, setMaxTasks] = useState(agent.max_concurrent_tasks);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const { upload, uploading } = useFileUpload();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleAvatarUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
e.target.value = "";
|
||||
try {
|
||||
const result = await upload(file);
|
||||
if (!result) return;
|
||||
await onSave({ avatar_url: result.link });
|
||||
toast.success("Avatar updated");
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Failed to upload avatar");
|
||||
}
|
||||
};
|
||||
|
||||
const dirty =
|
||||
name !== agent.name ||
|
||||
@@ -1237,37 +1200,6 @@ function SettingsTab({
|
||||
|
||||
return (
|
||||
<div className="max-w-lg space-y-6">
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground">Avatar</Label>
|
||||
<div className="mt-1.5 flex items-center gap-4">
|
||||
<button
|
||||
type="button"
|
||||
className="group relative h-16 w-16 shrink-0 rounded-full bg-muted overflow-hidden focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={uploading}
|
||||
>
|
||||
<ActorAvatar actorType="agent" actorId={agent.id} size={64} className="rounded-none" />
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/40 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
{uploading ? (
|
||||
<Loader2 className="h-5 w-5 animate-spin text-white" />
|
||||
) : (
|
||||
<Camera className="h-5 w-5 text-white" />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={handleAvatarUpload}
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Click to upload avatar
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground">Name</Label>
|
||||
<Input
|
||||
@@ -1374,50 +1306,32 @@ function AgentDetail({
|
||||
agent,
|
||||
runtimes,
|
||||
onUpdate,
|
||||
onArchive,
|
||||
onRestore,
|
||||
onDelete,
|
||||
}: {
|
||||
agent: Agent;
|
||||
runtimes: RuntimeDevice[];
|
||||
onUpdate: (id: string, data: Partial<Agent>) => Promise<void>;
|
||||
onArchive: (id: string) => Promise<void>;
|
||||
onRestore: (id: string) => Promise<void>;
|
||||
onDelete: (id: string) => Promise<void>;
|
||||
}) {
|
||||
const st = statusConfig[agent.status];
|
||||
const runtimeDevice = getRuntimeDevice(agent, runtimes);
|
||||
const [activeTab, setActiveTab] = useState<DetailTab>("instructions");
|
||||
const [confirmArchive, setConfirmArchive] = useState(false);
|
||||
const isArchived = !!agent.archived_at;
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Archive Banner */}
|
||||
{isArchived && (
|
||||
<div className="flex items-center gap-2 bg-muted/50 px-4 py-2 text-xs text-muted-foreground border-b">
|
||||
<AlertCircle className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="flex-1">This agent is archived. It cannot be assigned or mentioned.</span>
|
||||
<Button variant="outline" size="sm" className="h-6 text-xs" onClick={() => onRestore(agent.id)}>
|
||||
Restore
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex h-12 shrink-0 items-center gap-3 border-b px-4">
|
||||
<ActorAvatar actorType="agent" actorId={agent.id} size={28} className={`rounded-md ${isArchived ? "opacity-50" : ""}`} />
|
||||
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-muted text-xs font-bold">
|
||||
{getInitials(agent.name)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className={`text-sm font-semibold truncate ${isArchived ? "text-muted-foreground" : ""}`}>{agent.name}</h2>
|
||||
{isArchived ? (
|
||||
<span className="rounded-md bg-muted px-1.5 py-0.5 text-xs font-medium text-muted-foreground">
|
||||
Archived
|
||||
</span>
|
||||
) : (
|
||||
<span className={`flex items-center gap-1.5 text-xs ${st.color}`}>
|
||||
<span className={`h-1.5 w-1.5 rounded-full ${st.dot}`} />
|
||||
{st.label}
|
||||
</span>
|
||||
)}
|
||||
<h2 className="text-sm font-semibold truncate">{agent.name}</h2>
|
||||
<span className={`flex items-center gap-1.5 text-xs ${st.color}`}>
|
||||
<span className={`h-1.5 w-1.5 rounded-full ${st.dot}`} />
|
||||
{st.label}
|
||||
</span>
|
||||
<span className="flex items-center gap-1 rounded-md bg-muted px-1.5 py-0.5 text-xs font-medium text-muted-foreground">
|
||||
{agent.runtime_mode === "cloud" ? (
|
||||
<Cloud className="h-3 w-3" />
|
||||
@@ -1428,26 +1342,24 @@ function AgentDetail({
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{!isArchived && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button variant="ghost" size="icon-sm" />
|
||||
}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button variant="ghost" size="icon-sm" />
|
||||
}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4 text-muted-foreground" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
className="text-destructive"
|
||||
onClick={() => setConfirmDelete(true)}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4 text-muted-foreground" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
className="text-destructive"
|
||||
onClick={() => setConfirmArchive(true)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
Archive Agent
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
Delete Agent
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
@@ -1501,33 +1413,33 @@ function AgentDetail({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Archive Confirmation */}
|
||||
{confirmArchive && (
|
||||
<Dialog open onOpenChange={(v) => { if (!v) setConfirmArchive(false); }}>
|
||||
{/* Delete Confirmation */}
|
||||
{confirmDelete && (
|
||||
<Dialog open onOpenChange={(v) => { if (!v) setConfirmDelete(false); }}>
|
||||
<DialogContent className="max-w-sm" showCloseButton={false}>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-destructive/10">
|
||||
<AlertCircle className="h-5 w-5 text-destructive" />
|
||||
</div>
|
||||
<DialogHeader className="flex-1 gap-1">
|
||||
<DialogTitle className="text-sm font-semibold">Archive agent?</DialogTitle>
|
||||
<DialogTitle className="text-sm font-semibold">Delete agent?</DialogTitle>
|
||||
<DialogDescription className="text-xs">
|
||||
"{agent.name}" will be archived. It won't be assignable or mentionable, but all history is preserved. You can restore it later.
|
||||
This will permanently delete "{agent.name}" and all its configuration.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => setConfirmArchive(false)}>
|
||||
<Button variant="ghost" onClick={() => setConfirmDelete(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
setConfirmArchive(false);
|
||||
onArchive(agent.id);
|
||||
setConfirmDelete(false);
|
||||
onDelete(agent.id);
|
||||
}}
|
||||
>
|
||||
Archive
|
||||
Delete
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
@@ -1547,7 +1459,6 @@ export default function AgentsPage() {
|
||||
const agents = useWorkspaceStore((s) => s.agents);
|
||||
const refreshAgents = useWorkspaceStore((s) => s.refreshAgents);
|
||||
const [selectedId, setSelectedId] = useState<string>("");
|
||||
const [showArchived, setShowArchived] = useState(false);
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const runtimes = useRuntimeStore((s) => s.runtimes);
|
||||
const fetchRuntimes = useRuntimeStore((s) => s.fetchRuntimes);
|
||||
@@ -1559,19 +1470,12 @@ export default function AgentsPage() {
|
||||
if (workspace) fetchRuntimes();
|
||||
}, [workspace, fetchRuntimes]);
|
||||
|
||||
const filteredAgents = useMemo(
|
||||
() => showArchived ? agents.filter((a) => !!a.archived_at) : agents.filter((a) => !a.archived_at),
|
||||
[agents, showArchived],
|
||||
);
|
||||
|
||||
const archivedCount = useMemo(() => agents.filter((a) => !!a.archived_at).length, [agents]);
|
||||
|
||||
// Select first agent on initial load or when filter changes
|
||||
// Select first agent on initial load
|
||||
useEffect(() => {
|
||||
if (filteredAgents.length > 0 && !filteredAgents.some((a) => a.id === selectedId)) {
|
||||
setSelectedId(filteredAgents[0]!.id);
|
||||
if (agents.length > 0 && !selectedId) {
|
||||
setSelectedId(agents[0]!.id);
|
||||
}
|
||||
}, [filteredAgents, selectedId]);
|
||||
}, [agents, selectedId]);
|
||||
|
||||
const handleCreate = async (data: CreateAgentRequest) => {
|
||||
const agent = await api.createAgent(data);
|
||||
@@ -1580,74 +1484,25 @@ export default function AgentsPage() {
|
||||
};
|
||||
|
||||
const handleUpdate = async (id: string, data: Record<string, unknown>) => {
|
||||
try {
|
||||
await api.updateAgent(id, data as UpdateAgentRequest);
|
||||
await refreshAgents();
|
||||
toast.success("Agent updated");
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Failed to update agent");
|
||||
throw e;
|
||||
}
|
||||
await api.updateAgent(id, data as UpdateAgentRequest);
|
||||
await refreshAgents();
|
||||
};
|
||||
|
||||
const handleArchive = async (id: string) => {
|
||||
try {
|
||||
await api.archiveAgent(id);
|
||||
await refreshAgents();
|
||||
toast.success("Agent archived");
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Failed to archive agent");
|
||||
}
|
||||
};
|
||||
|
||||
const handleRestore = async (id: string) => {
|
||||
try {
|
||||
await api.restoreAgent(id);
|
||||
await refreshAgents();
|
||||
toast.success("Agent restored");
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Failed to restore agent");
|
||||
const handleDelete = async (id: string) => {
|
||||
await api.deleteAgent(id);
|
||||
if (selectedId === id) {
|
||||
const remaining = agents.filter((a) => a.id !== id);
|
||||
setSelectedId(remaining[0]?.id ?? "");
|
||||
}
|
||||
await refreshAgents();
|
||||
};
|
||||
|
||||
const selected = agents.find((a) => a.id === selectedId) ?? null;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex flex-1 min-h-0">
|
||||
{/* List skeleton */}
|
||||
<div className="w-72 border-r">
|
||||
<div className="flex h-12 items-center justify-between border-b px-4">
|
||||
<Skeleton className="h-4 w-16" />
|
||||
<Skeleton className="h-6 w-6 rounded" />
|
||||
</div>
|
||||
<div className="divide-y">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-3 px-4 py-3">
|
||||
<Skeleton className="h-8 w-8 rounded-full" />
|
||||
<div className="flex-1 space-y-1.5">
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-3 w-16" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{/* Detail skeleton */}
|
||||
<div className="flex-1 p-6 space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="h-10 w-10 rounded-full" />
|
||||
<div className="space-y-1.5">
|
||||
<Skeleton className="h-5 w-32" />
|
||||
<Skeleton className="h-3 w-20" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-8 w-full rounded-lg" />
|
||||
<Skeleton className="h-8 w-full rounded-lg" />
|
||||
<Skeleton className="h-8 w-3/4 rounded-lg" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-1 min-h-0 items-center justify-center text-sm text-muted-foreground">
|
||||
Loading...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1664,46 +1519,30 @@ export default function AgentsPage() {
|
||||
<div className="overflow-y-auto h-full border-r">
|
||||
<div className="flex h-12 items-center justify-between border-b px-4">
|
||||
<h1 className="text-sm font-semibold">Agents</h1>
|
||||
<div className="flex items-center gap-1">
|
||||
{archivedCount > 0 && (
|
||||
<Button
|
||||
variant={showArchived ? "secondary" : "ghost"}
|
||||
size="icon-xs"
|
||||
onClick={() => setShowArchived(!showArchived)}
|
||||
title={showArchived ? "Show active agents" : "Show archived agents"}
|
||||
>
|
||||
<Archive className="h-4 w-4 text-muted-foreground" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
onClick={() => setShowCreate(true)}
|
||||
>
|
||||
<Plus className="h-4 w-4 text-muted-foreground" />
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
onClick={() => setShowCreate(true)}
|
||||
>
|
||||
<Plus className="h-4 w-4 text-muted-foreground" />
|
||||
</Button>
|
||||
</div>
|
||||
{filteredAgents.length === 0 ? (
|
||||
{agents.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center px-4 py-12">
|
||||
<Bot className="h-8 w-8 text-muted-foreground/40" />
|
||||
<p className="mt-3 text-sm text-muted-foreground">
|
||||
{showArchived ? "No archived agents" : archivedCount > 0 ? "No active agents" : "No agents yet"}
|
||||
</p>
|
||||
{!showArchived && (
|
||||
<Button
|
||||
onClick={() => setShowCreate(true)}
|
||||
size="xs"
|
||||
className="mt-3"
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
Create Agent
|
||||
</Button>
|
||||
)}
|
||||
<p className="mt-3 text-sm text-muted-foreground">No agents yet</p>
|
||||
<Button
|
||||
onClick={() => setShowCreate(true)}
|
||||
size="xs"
|
||||
className="mt-3"
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
Create Agent
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y">
|
||||
{filteredAgents.map((agent) => (
|
||||
{agents.map((agent) => (
|
||||
<AgentListItem
|
||||
key={agent.id}
|
||||
agent={agent}
|
||||
@@ -1722,12 +1561,10 @@ export default function AgentsPage() {
|
||||
{/* Right column — agent detail */}
|
||||
{selected ? (
|
||||
<AgentDetail
|
||||
key={selected.id}
|
||||
agent={selected}
|
||||
runtimes={runtimes}
|
||||
onUpdate={handleUpdate}
|
||||
onArchive={handleArchive}
|
||||
onRestore={handleRestore}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full flex-col items-center justify-center text-muted-foreground">
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useDefaultLayout } from "react-resizable-panels";
|
||||
import { useInboxStore } from "@/features/inbox";
|
||||
@@ -220,20 +219,11 @@ 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 items = useInboxStore((s) => s.dedupedItems());
|
||||
const loading = useInboxStore((s) => s.loading);
|
||||
@@ -319,11 +309,11 @@ export default function InboxPage() {
|
||||
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" />
|
||||
@@ -351,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 && (
|
||||
@@ -395,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" />
|
||||
@@ -414,7 +403,6 @@ export default function InboxPage() {
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
<ResizableHandle />
|
||||
@@ -423,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);
|
||||
}}
|
||||
|
||||
@@ -104,9 +104,9 @@ vi.mock("@/components/ui/calendar", () => ({
|
||||
Calendar: () => null,
|
||||
}));
|
||||
|
||||
// Mock ContentEditor (Tiptap needs real DOM)
|
||||
vi.mock("@/features/editor", () => ({
|
||||
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, () => ({
|
||||
@@ -132,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
|
||||
@@ -182,12 +161,6 @@ vi.mock("@/shared/api", () => ({
|
||||
listIssueSubscribers: vi.fn().mockResolvedValue([]),
|
||||
subscribeToIssue: vi.fn().mockResolvedValue(undefined),
|
||||
unsubscribeFromIssue: vi.fn().mockResolvedValue(undefined),
|
||||
getActiveTaskForIssue: vi.fn().mockResolvedValue({ task: null }),
|
||||
listTasksByIssue: vi.fn().mockResolvedValue([]),
|
||||
listTaskMessages: vi.fn().mockResolvedValue([]),
|
||||
listDependencies: vi.fn().mockResolvedValue([]),
|
||||
createDependency: vi.fn().mockResolvedValue({}),
|
||||
deleteDependency: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -360,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(() => {
|
||||
|
||||
@@ -150,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" },
|
||||
@@ -339,26 +331,23 @@ describe("IssuesPage", () => {
|
||||
expect(screen.getByText("Issues")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows scope buttons", () => {
|
||||
it("shows 'New Issue' button", () => {
|
||||
mockStoreState.loading = false;
|
||||
mockStoreState.issues = [];
|
||||
|
||||
render(<IssuesPage />);
|
||||
|
||||
expect(screen.getByText("All")).toBeInTheDocument();
|
||||
expect(screen.getByText("Members")).toBeInTheDocument();
|
||||
expect(screen.getByText("Agents")).toBeInTheDocument();
|
||||
expect(screen.getByText("New Issue")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows filter and display icon buttons", () => {
|
||||
it("shows filter buttons", () => {
|
||||
mockStoreState.loading = false;
|
||||
mockStoreState.issues = mockIssues;
|
||||
|
||||
render(<IssuesPage />);
|
||||
|
||||
// Filter and Display are now icon-only buttons, verify they render as buttons
|
||||
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", () => {
|
||||
|
||||
@@ -22,7 +22,7 @@ export default function DashboardLayout({
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && !user) {
|
||||
router.push("/");
|
||||
router.push("/login");
|
||||
}
|
||||
}, [user, isLoading, router]);
|
||||
|
||||
|
||||
@@ -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";
|
||||
@@ -71,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>
|
||||
|
||||
@@ -1,129 +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 { useAuthStore } from "@/features/auth";
|
||||
import { useWorkspaceStore } from "@/features/workspace";
|
||||
import { api } from "@/shared/api";
|
||||
import type { WorkspaceRepo } from "@/shared/types";
|
||||
|
||||
export function RepositoriesTab() {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const workspace = useWorkspaceStore((s) => s.workspace);
|
||||
const members = useWorkspaceStore((s) => s.members);
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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";
|
||||
@@ -21,6 +21,7 @@ import { toast } from "sonner";
|
||||
import { useAuthStore } from "@/features/auth";
|
||||
import { useWorkspaceStore } from "@/features/workspace";
|
||||
import { api } from "@/shared/api";
|
||||
import type { WorkspaceRepo } from "@/shared/types";
|
||||
|
||||
export function WorkspaceTab() {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
@@ -33,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<{
|
||||
@@ -50,6 +52,7 @@ export function WorkspaceTab() {
|
||||
setName(workspace?.name ?? "");
|
||||
setDescription(workspace?.description ?? "");
|
||||
setContext(workspace?.context ?? "");
|
||||
setRepos(workspace?.repos ?? []);
|
||||
}, [workspace]);
|
||||
|
||||
const handleSave = async () => {
|
||||
@@ -60,6 +63,7 @@ export function WorkspaceTab() {
|
||||
name,
|
||||
description,
|
||||
context,
|
||||
repos,
|
||||
});
|
||||
updateWorkspace(updated);
|
||||
toast.success("Workspace settings saved");
|
||||
@@ -70,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({
|
||||
@@ -175,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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import { cookies } from "next/headers";
|
||||
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";
|
||||
@@ -12,56 +11,23 @@ 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 async function RootLayout({
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const cookieStore = await cookies();
|
||||
const locale = cookieStore.get("multica-locale")?.value;
|
||||
const lang = locale === "zh" ? "zh" : "en";
|
||||
|
||||
return (
|
||||
<html
|
||||
lang={lang}
|
||||
lang="en"
|
||||
suppressHydrationWarning
|
||||
className={cn("antialiased font-sans h-full", geist.variable, geistMono.variable)}
|
||||
>
|
||||
|
||||
21
apps/web/app/page.tsx
Normal file
21
apps/web/app/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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`,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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 };
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { Users } from "lucide-react";
|
||||
import { Bot } from "lucide-react";
|
||||
import { HoverCard, HoverCardTrigger, HoverCardContent } from "@/components/ui/hover-card";
|
||||
import { ActorAvatar } from "@/components/common/actor-avatar";
|
||||
import { useWorkspaceStore } from "@/features/workspace";
|
||||
@@ -16,27 +16,6 @@ function MentionHoverCard({ type, id, children }: MentionHoverCardProps) {
|
||||
const members = useWorkspaceStore((s) => s.members);
|
||||
const agents = useWorkspaceStore((s) => s.agents);
|
||||
|
||||
if (type === "all") {
|
||||
return (
|
||||
<HoverCard>
|
||||
<HoverCardTrigger render={<span />} className="cursor-default">
|
||||
{children}
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent align="start" className="w-auto min-w-48 max-w-72">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-primary/10">
|
||||
<Users className="h-4 w-4 text-primary" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium">All members</p>
|
||||
<p className="text-xs text-muted-foreground">Notifies all workspace members</p>
|
||||
</div>
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "member") {
|
||||
const member = members.find((m) => m.user_id === id);
|
||||
if (!member) return <>{children}</>;
|
||||
@@ -70,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 && (
|
||||
|
||||
@@ -2,20 +2,15 @@
|
||||
|
||||
import {
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useRef,
|
||||
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 { 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";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -25,11 +20,9 @@ import type { SuggestionOptions, SuggestionProps } from "@tiptap/suggestion";
|
||||
export interface MentionItem {
|
||||
id: string;
|
||||
label: string;
|
||||
type: "member" | "agent" | "issue" | "all";
|
||||
/** Secondary text shown beside the label (e.g. issue title) */
|
||||
type: "member" | "agent" | "issue";
|
||||
/** Secondary text shown below the label (e.g. issue title) */
|
||||
description?: string;
|
||||
/** Issue status for StatusIcon rendering */
|
||||
status?: IssueStatus;
|
||||
}
|
||||
|
||||
interface MentionListProps {
|
||||
@@ -41,33 +34,6 @@ 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
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -75,23 +41,15 @@ function groupItems(items: MentionItem[]): MentionGroup[] {
|
||||
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],
|
||||
);
|
||||
const selectItem = (index: number) => {
|
||||
const item = items[index];
|
||||
if (item) command(item);
|
||||
};
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
onKeyDown: ({ event }) => {
|
||||
@@ -119,93 +77,47 @@ const MentionList = forwardRef<MentionListRef, MentionListProps>(
|
||||
);
|
||||
}
|
||||
|
||||
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 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>
|
||||
{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>
|
||||
</button>
|
||||
))}
|
||||
</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
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -220,12 +132,6 @@ export function createMentionSuggestion(): Omit<
|
||||
const { issues } = useIssueStore.getState();
|
||||
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) => ({
|
||||
@@ -235,7 +141,7 @@ export function createMentionSuggestion(): Omit<
|
||||
}));
|
||||
|
||||
const agentItems: MentionItem[] = agents
|
||||
.filter((a) => !a.archived_at && a.name.toLowerCase().includes(q))
|
||||
.filter((a) => a.name.toLowerCase().includes(q))
|
||||
.map((a) => ({ id: a.id, label: a.name, type: "agent" as const }));
|
||||
|
||||
const issueItems: MentionItem[] = issues
|
||||
@@ -249,10 +155,9 @@ export function createMentionSuggestion(): Omit<
|
||||
label: i.identifier,
|
||||
type: "issue" as const,
|
||||
description: i.title,
|
||||
status: i.status as IssueStatus,
|
||||
}));
|
||||
|
||||
return [...allItem, ...memberItems, ...agentItems, ...issueItems].slice(0, 10);
|
||||
return [...memberItems, ...agentItems, ...issueItems].slice(0, 10);
|
||||
},
|
||||
|
||||
render: () => {
|
||||
@@ -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 };
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
158
apps/web/components/common/rich-text-editor.css
Normal file
158
apps/web/components/common/rich-text-editor.css
Normal 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);
|
||||
}
|
||||
354
apps/web/components/common/rich-text-editor.tsx
Normal file
354
apps/web/components/common/rich-text-editor.tsx
Normal 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 };
|
||||
@@ -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]);
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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})`;
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
}, []);
|
||||
|
||||
@@ -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;
|
||||
@@ -49,7 +48,6 @@ 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;
|
||||
},
|
||||
@@ -59,7 +57,6 @@ export const useAuthStore = create<AuthState>((set) => ({
|
||||
localStorage.removeItem("multica_workspace_id");
|
||||
api.setToken(null);
|
||||
api.setWorkspaceId(null);
|
||||
clearLoggedInCookie();
|
||||
set({ user: null });
|
||||
},
|
||||
|
||||
|
||||
@@ -1,389 +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 {
|
||||
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; }
|
||||
}
|
||||
@@ -1,198 +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 { 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 editor = useEditor({
|
||||
immediatelyRender: false,
|
||||
editable,
|
||||
content: defaultValue ? preprocessMarkdown(defaultValue) : "",
|
||||
contentType: defaultValue ? "markdown" : undefined,
|
||||
extensions: createEditorExtensions({
|
||||
editable,
|
||||
placeholder: placeholderText,
|
||||
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 };
|
||||
@@ -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 };
|
||||
@@ -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;
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,125 +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;
|
||||
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 ? { suggestion: createMentionSuggestion() } : {}),
|
||||
}),
|
||||
];
|
||||
|
||||
if (editable) {
|
||||
extensions.push(
|
||||
Typography,
|
||||
Placeholder.configure({ placeholder: placeholderText }),
|
||||
createMarkdownPasteExtension(),
|
||||
createSubmitExtension(() => options.onSubmitRef?.current?.()),
|
||||
createFileUploadExtension(options.onUploadFileRef!),
|
||||
);
|
||||
}
|
||||
|
||||
return extensions;
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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})`;
|
||||
},
|
||||
});
|
||||
@@ -1,79 +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 { useIssueStore } from "@/features/issues/store";
|
||||
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 issue = useIssueStore((s) => s.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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,11 +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";
|
||||
@@ -1,6 +0,0 @@
|
||||
/**
|
||||
* Copy markdown content to the clipboard.
|
||||
*/
|
||||
export async function copyMarkdown(markdown: string): Promise<void> {
|
||||
await navigator.clipboard.writeText(markdown);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import { create } from "zustand";
|
||||
import type { InboxItem, IssueStatus } from "@/shared/types";
|
||||
import { toast } from "sonner";
|
||||
import { api } from "@/shared/api";
|
||||
import { createLogger } from "@/shared/logger";
|
||||
|
||||
@@ -73,7 +72,6 @@ export const useInboxStore = create<InboxState>((set, get) => ({
|
||||
set({ items: data, loading: false });
|
||||
} catch (err) {
|
||||
logger.error("fetch failed", err);
|
||||
toast.error("Failed to load inbox");
|
||||
if (isInitialLoad) set({ loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { Bot, ChevronRight, 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 { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
import { useActorName } from "@/features/workspace";
|
||||
import { redactSecrets } from "../utils/redact";
|
||||
|
||||
// ─── Shared types & helpers ─────────────────────────────────────────────────
|
||||
@@ -98,21 +96,26 @@ function buildTimeline(msgs: TaskMessagePayload[]): TimelineItem[] {
|
||||
|
||||
interface AgentLiveCardProps {
|
||||
issueId: string;
|
||||
assigneeType: string | null;
|
||||
assigneeId: string | null;
|
||||
agentName?: string;
|
||||
}
|
||||
|
||||
export function AgentLiveCard({ issueId, agentName }: AgentLiveCardProps) {
|
||||
const { getActorName } = useActorName();
|
||||
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 [cancelling, setCancelling] = useState(false);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const seenSeqs = useRef(new Set<string>());
|
||||
|
||||
// Check for active task on mount
|
||||
useEffect(() => {
|
||||
if (assigneeType !== "agent" || !assigneeId) {
|
||||
setActiveTask(null);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
api.getActiveTaskForIssue(issueId).then(({ task }) => {
|
||||
if (!cancelled) {
|
||||
@@ -124,13 +127,13 @@ export function AgentLiveCard({ issueId, agentName }: AgentLiveCardProps) {
|
||||
setItems(timeline);
|
||||
for (const m of msgs) seenSeqs.current.add(`${m.task_id}:${m.seq}`);
|
||||
}
|
||||
}).catch(console.error);
|
||||
}).catch(() => {});
|
||||
}
|
||||
}
|
||||
}).catch(console.error);
|
||||
}).catch(() => {});
|
||||
|
||||
return () => { cancelled = true; };
|
||||
}, [issueId]);
|
||||
}, [issueId, assigneeType, assigneeId]);
|
||||
|
||||
// Handle real-time task messages
|
||||
useWSEvent(
|
||||
@@ -167,7 +170,6 @@ export function AgentLiveCard({ issueId, agentName }: AgentLiveCardProps) {
|
||||
setActiveTask(null);
|
||||
setItems([]);
|
||||
seenSeqs.current.clear();
|
||||
setCancelling(false);
|
||||
}, [issueId]),
|
||||
);
|
||||
|
||||
@@ -179,37 +181,21 @@ export function AgentLiveCard({ issueId, agentName }: AgentLiveCardProps) {
|
||||
setActiveTask(null);
|
||||
setItems([]);
|
||||
seenSeqs.current.clear();
|
||||
setCancelling(false);
|
||||
}, [issueId]),
|
||||
);
|
||||
|
||||
useWSEvent(
|
||||
"task:cancelled",
|
||||
useCallback((payload: unknown) => {
|
||||
const p = payload as TaskCancelledPayload;
|
||||
if (p.issue_id !== issueId) return;
|
||||
setActiveTask(null);
|
||||
setItems([]);
|
||||
seenSeqs.current.clear();
|
||||
setCancelling(false);
|
||||
}, [issueId]),
|
||||
);
|
||||
|
||||
// Pick up new tasks — skip if we're already showing an active task to avoid
|
||||
// replacing its timeline mid-execution (per-issue serialization in the
|
||||
// backend prevents this race, but this is a defensive safeguard).
|
||||
// Pick up new tasks
|
||||
useWSEvent(
|
||||
"task:dispatch",
|
||||
useCallback(() => {
|
||||
if (activeTask) return;
|
||||
api.getActiveTaskForIssue(issueId).then(({ task }) => {
|
||||
if (task) {
|
||||
setActiveTask(task);
|
||||
setItems([]);
|
||||
seenSeqs.current.clear();
|
||||
}
|
||||
}).catch(console.error);
|
||||
}, [issueId, activeTask]),
|
||||
}).catch(() => {});
|
||||
}, [issueId]),
|
||||
);
|
||||
|
||||
// Elapsed time
|
||||
@@ -234,17 +220,6 @@ export function AgentLiveCard({ issueId, agentName }: AgentLiveCardProps) {
|
||||
setAutoScroll(scrollHeight - scrollTop - clientHeight < 40);
|
||||
}, []);
|
||||
|
||||
const handleCancel = useCallback(async () => {
|
||||
if (!activeTask || cancelling) return;
|
||||
setCancelling(true);
|
||||
try {
|
||||
await api.cancelTask(issueId, activeTask.id);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Failed to cancel task");
|
||||
setCancelling(false);
|
||||
}
|
||||
}, [activeTask, issueId, cancelling]);
|
||||
|
||||
if (!activeTask) return null;
|
||||
|
||||
const toolCount = items.filter((i) => i.type === "tool_use").length;
|
||||
@@ -258,7 +233,7 @@ export function AgentLiveCard({ issueId, agentName }: AgentLiveCardProps) {
|
||||
</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="truncate">{(activeTask?.agent_id ? getActorName("agent", activeTask.agent_id) : agentName) ?? "Agent"} is working</span>
|
||||
<span className="truncate">{agentName ?? "Agent"} is working</span>
|
||||
</div>
|
||||
<span className="ml-auto text-xs text-muted-foreground tabular-nums shrink-0">{elapsed}</span>
|
||||
{toolCount > 0 && (
|
||||
@@ -266,19 +241,6 @@ export function AgentLiveCard({ issueId, agentName }: AgentLiveCardProps) {
|
||||
{toolCount} tool {toolCount === 1 ? "call" : "calls"}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={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 shrink-0"
|
||||
title="Stop agent"
|
||||
>
|
||||
{cancelling ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<Square className="h-3 w-3" />
|
||||
)}
|
||||
<span>Stop</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Timeline content */}
|
||||
@@ -316,15 +278,17 @@ export function AgentLiveCard({ issueId, agentName }: AgentLiveCardProps) {
|
||||
|
||||
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(
|
||||
@@ -332,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]),
|
||||
);
|
||||
|
||||
@@ -341,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 (
|
||||
@@ -384,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(() => {
|
||||
|
||||
@@ -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,14 +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 { 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);
|
||||
@@ -43,7 +44,7 @@ export function BatchActionToolbar() {
|
||||
|
||||
const ids = Array.from(selectedIds);
|
||||
|
||||
const handleBatchUpdate = async (updates: Partial<UpdateIssueRequest>) => {
|
||||
const handleBatchUpdate = async (updates: UpdateIssueRequest) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await api.batchUpdateIssues(ids, updates);
|
||||
@@ -55,7 +56,7 @@ export function BatchActionToolbar() {
|
||||
toast.error("Failed to update issues");
|
||||
api.listIssues({ limit: 200 }).then((res) => {
|
||||
useIssueStore.getState().setIssues(res.issues);
|
||||
}).catch(console.error);
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -74,7 +75,7 @@ export function BatchActionToolbar() {
|
||||
toast.error("Failed to delete issues");
|
||||
api.listIssues({ limit: 200 }).then((res) => {
|
||||
useIssueStore.getState().setIssues(res.issues);
|
||||
}).catch(console.error);
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setDeleteOpen(false);
|
||||
@@ -160,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 */}
|
||||
@@ -210,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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,8 +13,7 @@ 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,7 +42,7 @@ 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 handleUpdate = useCallback(
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { STATUS_CONFIG } from "@/features/issues/config";
|
||||
import { useModalStore } from "@/features/modals";
|
||||
import { useViewStore, 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";
|
||||
@@ -29,9 +29,8 @@ export function BoardColumn({
|
||||
}) {
|
||||
const cfg = STATUS_CONFIG[status];
|
||||
const { setNodeRef, isOver } = useDroppable({ id: status });
|
||||
const viewStoreApi = useViewStoreApi();
|
||||
const sortBy = useViewStore((s) => s.sortBy);
|
||||
const sortDirection = useViewStore((s) => s.sortDirection);
|
||||
const sortBy = useIssueViewStore((s) => s.sortBy);
|
||||
const sortDirection = useIssueViewStore((s) => s.sortDirection);
|
||||
|
||||
const sortedIssues = useMemo(
|
||||
() => sortIssues(issues, sortBy, sortDirection),
|
||||
@@ -68,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>
|
||||
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
DropdownMenuItem,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { ALL_STATUSES, STATUS_CONFIG } from "@/features/issues/config";
|
||||
import { useViewStoreApi } from "@/features/issues/stores/view-store-context";
|
||||
import { useIssueViewStore } from "@/features/issues/stores/view-store";
|
||||
import { StatusIcon } from "./status-icon";
|
||||
import { BoardColumn } from "./board-column";
|
||||
import { BoardCardContent } from "./board-card";
|
||||
@@ -205,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">
|
||||
@@ -243,7 +242,7 @@ function HiddenColumnsPanel({
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
viewStoreApi.getState().showStatus(status)
|
||||
useIssueViewStore.getState().showStatus(status)
|
||||
}
|
||||
>
|
||||
<Eye className="size-3.5" />
|
||||
|
||||
@@ -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 } 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);
|
||||
@@ -170,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" />
|
||||
@@ -199,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>
|
||||
@@ -207,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">
|
||||
<ContentEditor defaultValue={entry.content ?? ""} editable={false} />
|
||||
<Markdown mode="minimal">{entry.content ?? ""}</Markdown>
|
||||
</div>
|
||||
{!isTemp && (
|
||||
<ReactionBar
|
||||
reactions={reactions}
|
||||
currentUserId={currentUserId}
|
||||
onToggle={(emoji) => onToggleReaction(entry.id, emoji)}
|
||||
hideAddButton
|
||||
className="mt-1.5 pl-8"
|
||||
/>
|
||||
)}
|
||||
@@ -275,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);
|
||||
@@ -331,10 +240,8 @@ function CommentCard({
|
||||
const contentPreview = (entry.content ?? "").replace(/\n/g, " ").slice(0, 80);
|
||||
const reactions = entry.reactions ?? [];
|
||||
|
||||
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">
|
||||
@@ -371,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" />
|
||||
@@ -400,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>
|
||||
@@ -408,13 +310,6 @@ function CommentCard({
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<DeleteCommentDialog
|
||||
open={confirmDelete}
|
||||
onOpenChange={setConfirmDelete}
|
||||
onConfirm={() => onDelete(entry.id)}
|
||||
hasReplies
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -428,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..."
|
||||
@@ -437,21 +332,15 @@ 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">
|
||||
<ContentEditor defaultValue={entry.content ?? ""} editable={false} />
|
||||
<Markdown mode="minimal">{entry.content ?? ""}</Markdown>
|
||||
</div>
|
||||
{!isTemp && (
|
||||
<ReactionBar
|
||||
@@ -467,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}
|
||||
|
||||
@@ -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,13 +12,27 @@ 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 { uploadWithToast } = useFileUpload();
|
||||
const { uploadWithToast, uploading } = useFileUpload();
|
||||
|
||||
const handleUpload = async (file: File) => {
|
||||
return await uploadWithToast(file, { issueId });
|
||||
const result = await uploadWithToast(file, { issueId });
|
||||
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 () => {
|
||||
@@ -27,8 +40,10 @@ function CommentInput({ issueId, onSubmit }: CommentInputProps) {
|
||||
if (!content || submitting) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await onSubmit(content);
|
||||
const ids = attachmentIdsRef.current.length > 0 ? [...attachmentIdsRef.current] : undefined;
|
||||
await onSubmit(content, ids);
|
||||
editorRef.current?.clearContent();
|
||||
attachmentIdsRef.current = [];
|
||||
setIsEmpty(true);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
@@ -36,9 +51,9 @@ function CommentInput({ issueId, onSubmit }: CommentInputProps) {
|
||||
};
|
||||
|
||||
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())}
|
||||
@@ -47,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>
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -1,26 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useRef, memo } from "react";
|
||||
import { useState, useEffect, useCallback, memo } from "react";
|
||||
import { useDefaultLayout, usePanelRef } from "react-resizable-panels";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
Bot,
|
||||
Calendar,
|
||||
Check,
|
||||
ChevronDown,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Link2,
|
||||
MoreHorizontal,
|
||||
PanelRight,
|
||||
Plus,
|
||||
Trash2,
|
||||
UserMinus,
|
||||
Users,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@@ -45,9 +43,8 @@ import {
|
||||
DropdownMenuSubContent,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from "@/components/ui/resizable";
|
||||
import { ContentEditor, type ContentEditorRef } from "@/features/editor";
|
||||
import { FileUploadButton } from "@/components/common/file-upload-button";
|
||||
import { TitleEditor } from "@/features/editor";
|
||||
import { RichTextEditor } from "@/components/common/rich-text-editor";
|
||||
import { TitleEditor } from "@/components/common/title-editor";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipTrigger,
|
||||
@@ -56,11 +53,11 @@ import {
|
||||
import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Command, CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem } from "@/components/ui/command";
|
||||
import { AvatarGroup, AvatarGroupCount } from "@/components/ui/avatar";
|
||||
import { Avatar, AvatarFallback, AvatarGroup, AvatarGroupCount } from "@/components/ui/avatar";
|
||||
import { ActorAvatar } from "@/components/common/actor-avatar";
|
||||
import type { UpdateIssueRequest, IssueStatus, IssuePriority, IssueDependency, IssueDependencyType, TimelineEntry } from "@/shared/types";
|
||||
import type { UpdateIssueRequest, IssueStatus, IssuePriority, TimelineEntry } from "@/shared/types";
|
||||
import { ALL_STATUSES, STATUS_CONFIG, PRIORITY_ORDER, PRIORITY_CONFIG } from "@/features/issues/config";
|
||||
import { StatusIcon, PriorityIcon, DueDatePicker, AssigneePicker, canAssignAgent } from "@/features/issues/components";
|
||||
import { StatusIcon, PriorityIcon, DueDatePicker } from "@/features/issues/components";
|
||||
import { CommentCard } from "./comment-card";
|
||||
import { CommentInput } from "./comment-input";
|
||||
import { AgentLiveCard, TaskRunHistory } from "./agent-live-card";
|
||||
@@ -126,42 +123,11 @@ function formatActivity(
|
||||
return "completed the task";
|
||||
case "task_failed":
|
||||
return "task failed";
|
||||
case "issue_relation_added": {
|
||||
const relType = details.relation_type ?? "";
|
||||
const identifier = details.related_issue_identifier ?? "?";
|
||||
const label = relationTypeLabel(relType);
|
||||
return `added relation: ${label} ${identifier}`;
|
||||
}
|
||||
case "issue_relation_removed": {
|
||||
const relType = details.relation_type ?? "";
|
||||
const identifier = details.related_issue_identifier ?? "?";
|
||||
const label = relationTypeLabel(relType);
|
||||
return `removed relation: ${label} ${identifier}`;
|
||||
}
|
||||
default:
|
||||
return entry.action ?? "";
|
||||
}
|
||||
}
|
||||
|
||||
function relationTypeLabel(type: string): string {
|
||||
switch (type) {
|
||||
case "blocks":
|
||||
return "blocks";
|
||||
case "blocked_by":
|
||||
return "blocked by";
|
||||
case "related":
|
||||
return "related to";
|
||||
default:
|
||||
return type;
|
||||
}
|
||||
}
|
||||
|
||||
function inverseRelType(type: string): string {
|
||||
if (type === "blocks") return "blocked_by";
|
||||
if (type === "blocked_by") return "blocks";
|
||||
return type;
|
||||
}
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Property row
|
||||
@@ -194,29 +160,26 @@ interface IssueDetailProps {
|
||||
onDelete?: () => void;
|
||||
defaultSidebarOpen?: boolean;
|
||||
layoutId?: string;
|
||||
/** When set, the issue detail will auto-scroll to this comment and briefly highlight it. */
|
||||
highlightCommentId?: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// IssueDetail
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layoutId = "multica_issue_detail_layout", highlightCommentId }: IssueDetailProps) {
|
||||
export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layoutId = "multica_issue_detail_layout" }: IssueDetailProps) {
|
||||
const id = issueId;
|
||||
const router = useRouter();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const workspace = useWorkspaceStore((s) => s.workspace);
|
||||
const members = useWorkspaceStore((s) => s.members);
|
||||
const agents = useWorkspaceStore((s) => s.agents);
|
||||
const currentMemberRole = members.find((m) => m.user_id === user?.id)?.role;
|
||||
|
||||
// Issue navigation
|
||||
const allIssues = useIssueStore((s) => s.issues);
|
||||
const currentIndex = allIssues.findIndex((i) => i.id === id);
|
||||
const prevIssue = currentIndex > 0 ? allIssues[currentIndex - 1] : null;
|
||||
const nextIssue = currentIndex < allIssues.length - 1 ? allIssues[currentIndex + 1] : null;
|
||||
const { getActorName } = useActorName();
|
||||
const { getActorName, getActorInitials } = useActorName();
|
||||
const { uploadWithToast } = useFileUpload();
|
||||
const { defaultLayout, onLayoutChanged } = useDefaultLayout({
|
||||
id: layoutId,
|
||||
@@ -226,16 +189,7 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [propertiesOpen, setPropertiesOpen] = useState(true);
|
||||
const [relationsOpen, setRelationsOpen] = useState(true);
|
||||
const [detailsOpen, setDetailsOpen] = useState(true);
|
||||
const [dependencies, setDependencies] = useState<IssueDependency[]>([]);
|
||||
const [addRelOpen, setAddRelOpen] = useState(false);
|
||||
const [relSearch, setRelSearch] = useState("");
|
||||
const [relType, setRelType] = useState<IssueDependencyType>("related");
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
const [showScrollBottom, setShowScrollBottom] = useState(false);
|
||||
const [highlightedId, setHighlightedId] = useState<string | null>(null);
|
||||
const didHighlightRef = useRef<string | null>(null);
|
||||
|
||||
// Single source of truth: read issue directly from global store
|
||||
const issue = useIssueStore((s) => s.issues.find((i) => i.id === id)) ?? null;
|
||||
@@ -253,89 +207,27 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
|
||||
.then((iss) => {
|
||||
useIssueStore.getState().addIssue(iss);
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
toast.error("Failed to load issue");
|
||||
})
|
||||
.catch(console.error)
|
||||
.finally(() => setIssueLoading(false));
|
||||
}, [id, !!issue]);
|
||||
|
||||
// Custom hooks — encapsulate timeline, reactions, subscribers
|
||||
const {
|
||||
timeline, loading: timelineLoading, submitting, submitComment, submitReply,
|
||||
timeline, submitting, submitComment, submitReply,
|
||||
editComment, deleteComment, toggleReaction: handleToggleReaction,
|
||||
} = useIssueTimeline(id, user?.id);
|
||||
|
||||
const {
|
||||
reactions: issueReactions, loading: reactionsLoading,
|
||||
reactions: issueReactions,
|
||||
toggleReaction: handleToggleIssueReaction,
|
||||
} = useIssueReactions(id, user?.id);
|
||||
|
||||
const {
|
||||
subscribers, loading: subscribersLoading, isSubscribed, toggleSubscribe: handleToggleSubscribe, toggleSubscriber,
|
||||
subscribers, isSubscribed, toggleSubscribe: handleToggleSubscribe, toggleSubscriber,
|
||||
} = useIssueSubscribers(id, user?.id);
|
||||
|
||||
const loading = issueLoading;
|
||||
|
||||
// Fetch issue dependencies
|
||||
const fetchDependencies = useCallback(() => {
|
||||
api.listDependencies(id).then(setDependencies).catch(() => {});
|
||||
}, [id]);
|
||||
useEffect(() => { fetchDependencies(); }, [fetchDependencies]);
|
||||
|
||||
const handleAddDependency = useCallback(async (targetIssueId: string, type: IssueDependencyType) => {
|
||||
try {
|
||||
await api.createDependency(id, targetIssueId, type);
|
||||
fetchDependencies();
|
||||
setAddRelOpen(false);
|
||||
setRelSearch("");
|
||||
} catch {
|
||||
toast.error("Failed to add relation");
|
||||
}
|
||||
}, [id, fetchDependencies]);
|
||||
|
||||
const handleRemoveDependency = useCallback(async (depId: string) => {
|
||||
try {
|
||||
await api.deleteDependency(id, depId);
|
||||
fetchDependencies();
|
||||
} catch {
|
||||
toast.error("Failed to remove relation");
|
||||
}
|
||||
}, [id, fetchDependencies]);
|
||||
|
||||
// Scroll to highlighted comment once timeline loads (fire only once per highlightCommentId)
|
||||
useEffect(() => {
|
||||
if (!highlightCommentId || timeline.length === 0) return;
|
||||
if (didHighlightRef.current === highlightCommentId) return;
|
||||
const el = document.getElementById(`comment-${highlightCommentId}`);
|
||||
if (el) {
|
||||
didHighlightRef.current = highlightCommentId;
|
||||
requestAnimationFrame(() => {
|
||||
el.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
setHighlightedId(highlightCommentId);
|
||||
const timer = setTimeout(() => setHighlightedId(null), 2000);
|
||||
return () => clearTimeout(timer);
|
||||
});
|
||||
}
|
||||
}, [highlightCommentId, timeline.length]);
|
||||
|
||||
// Track scroll position for jump-to-bottom button
|
||||
useEffect(() => {
|
||||
const container = scrollContainerRef.current;
|
||||
if (!container) return;
|
||||
const onScroll = () => {
|
||||
const { scrollTop, scrollHeight, clientHeight } = container;
|
||||
setShowScrollBottom(scrollHeight - scrollTop - clientHeight > 200);
|
||||
};
|
||||
container.addEventListener("scroll", onScroll, { passive: true });
|
||||
onScroll();
|
||||
return () => container.removeEventListener("scroll", onScroll);
|
||||
}, []);
|
||||
|
||||
const scrollToBottom = useCallback(() => {
|
||||
scrollContainerRef.current?.scrollTo({ top: scrollContainerRef.current.scrollHeight, behavior: "smooth" });
|
||||
}, []);
|
||||
|
||||
// Issue field updates — write directly to the global store (single source of truth)
|
||||
const handleUpdateField = useCallback(
|
||||
(updates: Partial<UpdateIssueRequest>) => {
|
||||
@@ -350,7 +242,6 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
|
||||
[issue, id],
|
||||
);
|
||||
|
||||
const descEditorRef = useRef<ContentEditorRef>(null);
|
||||
const handleDescriptionUpload = useCallback(
|
||||
(file: File) => uploadWithToast(file, { issueId: id }),
|
||||
[uploadWithToast, id],
|
||||
@@ -372,51 +263,8 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
|
||||
|
||||
if (loading) {
|
||||
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-4 w-16" />
|
||||
<Skeleton className="h-4 w-4" />
|
||||
<Skeleton className="h-4 w-24" />
|
||||
</div>
|
||||
<div className="flex flex-1 min-h-0">
|
||||
{/* Content skeleton */}
|
||||
<div className="flex-1 p-8 space-y-6">
|
||||
<Skeleton className="h-8 w-3/4" />
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-5/6" />
|
||||
<Skeleton className="h-4 w-2/3" />
|
||||
</div>
|
||||
<Skeleton className="h-px w-full" />
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-4 w-20" />
|
||||
<div className="flex items-start gap-3">
|
||||
<Skeleton className="h-8 w-8 rounded-full" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="h-16 w-full rounded-lg" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Sidebar skeleton */}
|
||||
<div className="w-64 border-l p-4 space-y-4">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<div key={i} className="flex items-center justify-between">
|
||||
<Skeleton className="h-3 w-16" />
|
||||
<Skeleton className="h-5 w-24" />
|
||||
</div>
|
||||
))}
|
||||
<Skeleton className="h-px w-full" />
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="flex items-center justify-between">
|
||||
<Skeleton className="h-3 w-16" />
|
||||
<Skeleton className="h-4 w-28" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-1 min-h-0 items-center justify-center text-sm text-muted-foreground">
|
||||
Loading...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -571,17 +419,21 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
|
||||
key={m.user_id}
|
||||
onClick={() => handleUpdateField({ assignee_type: "member", assignee_id: m.user_id })}
|
||||
>
|
||||
<ActorAvatar actorType="member" actorId={m.user_id} size={16} />
|
||||
<div className="inline-flex size-4 shrink-0 items-center justify-center rounded-full bg-muted text-[8px] font-medium text-muted-foreground">
|
||||
{getActorInitials("member", m.user_id)}
|
||||
</div>
|
||||
{m.name}
|
||||
{issue.assignee_type === "member" && issue.assignee_id === m.user_id && <span className="ml-auto text-xs text-muted-foreground">✓</span>}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
{agents.filter((a) => canAssignAgent(a, user?.id, currentMemberRole)).map((a) => (
|
||||
{agents.map((a) => (
|
||||
<DropdownMenuItem
|
||||
key={a.id}
|
||||
onClick={() => handleUpdateField({ assignee_type: "agent", assignee_id: a.id })}
|
||||
>
|
||||
<ActorAvatar actorType="agent" actorId={a.id} size={16} />
|
||||
<div className="inline-flex size-4 shrink-0 items-center justify-center rounded-full bg-info/10 text-info">
|
||||
<Bot className="size-2.5" />
|
||||
</div>
|
||||
{a.name}
|
||||
{issue.assignee_type === "agent" && issue.assignee_id === a.id && <span className="ml-auto text-xs text-muted-foreground">✓</span>}
|
||||
</DropdownMenuItem>
|
||||
@@ -691,7 +543,7 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
|
||||
</div>
|
||||
|
||||
{/* Content — scrollable */}
|
||||
<div ref={scrollContainerRef} className="relative flex-1 overflow-y-auto">
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="mx-auto w-full max-w-4xl px-8 py-8">
|
||||
<TitleEditor
|
||||
key={`title-${id}`}
|
||||
@@ -704,8 +556,7 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
|
||||
}}
|
||||
/>
|
||||
|
||||
<ContentEditor
|
||||
ref={descEditorRef}
|
||||
<RichTextEditor
|
||||
key={id}
|
||||
defaultValue={issue.description || ""}
|
||||
placeholder="Add description..."
|
||||
@@ -715,24 +566,12 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
|
||||
className="mt-5"
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-1 mt-3">
|
||||
{reactionsLoading ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<Skeleton className="h-7 w-14 rounded-full" />
|
||||
<Skeleton className="h-7 w-14 rounded-full" />
|
||||
</div>
|
||||
) : (
|
||||
<ReactionBar
|
||||
reactions={issueReactions}
|
||||
currentUserId={user?.id}
|
||||
onToggle={handleToggleIssueReaction}
|
||||
/>
|
||||
)}
|
||||
<FileUploadButton
|
||||
size="sm"
|
||||
onSelect={(file) => descEditorRef.current?.uploadFile(file)}
|
||||
/>
|
||||
</div>
|
||||
<ReactionBar
|
||||
reactions={issueReactions}
|
||||
currentUserId={user?.id}
|
||||
onToggle={handleToggleIssueReaction}
|
||||
className="mt-3"
|
||||
/>
|
||||
|
||||
<div className="my-8 border-t" />
|
||||
|
||||
@@ -743,15 +582,6 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
|
||||
<h2 className="text-base font-semibold">Activity</h2>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{subscribersLoading ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<Skeleton className="h-4 w-16" />
|
||||
<div className="flex -space-x-1">
|
||||
<Skeleton className="h-6 w-6 rounded-full" />
|
||||
<Skeleton className="h-6 w-6 rounded-full" />
|
||||
</div>
|
||||
</div>
|
||||
) : (<>
|
||||
<button
|
||||
onClick={handleToggleSubscribe}
|
||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
@@ -763,12 +593,9 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
|
||||
{subscribers.length > 0 ? (
|
||||
<AvatarGroup>
|
||||
{subscribers.slice(0, 4).map((sub) => (
|
||||
<ActorAvatar
|
||||
key={`${sub.user_type}-${sub.user_id}`}
|
||||
actorType={sub.user_type}
|
||||
actorId={sub.user_id}
|
||||
size={24}
|
||||
/>
|
||||
<Avatar key={`${sub.user_type}-${sub.user_id}`} size="sm">
|
||||
<AvatarFallback>{getActorInitials(sub.user_type, sub.user_id)}</AvatarFallback>
|
||||
</Avatar>
|
||||
))}
|
||||
{subscribers.length > 4 && (
|
||||
<AvatarGroupCount>+{subscribers.length - 4}</AvatarGroupCount>
|
||||
@@ -829,7 +656,6 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</>)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -837,30 +663,20 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
|
||||
<div className="mt-4">
|
||||
<AgentLiveCard
|
||||
issueId={id}
|
||||
assigneeType={issue.assignee_type}
|
||||
assigneeId={issue.assignee_id}
|
||||
agentName={issue.assignee_type === "agent" && issue.assignee_id ? getActorName("agent", issue.assignee_id) : undefined}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Agent execution history */}
|
||||
<div className="mt-3">
|
||||
<TaskRunHistory issueId={id} />
|
||||
<TaskRunHistory issueId={id} assigneeType={issue.assignee_type} />
|
||||
</div>
|
||||
|
||||
{/* Timeline entries */}
|
||||
<div className="mt-4 flex flex-col gap-3">
|
||||
{timelineLoading ? (
|
||||
<div className="space-y-4">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="flex items-start gap-3 px-4">
|
||||
<Skeleton className="h-8 w-8 rounded-full shrink-0" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="h-16 w-full rounded-lg" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (() => {
|
||||
{(() => {
|
||||
const topLevel = timeline.filter((e) => e.type === "activity" || !e.parent_id);
|
||||
const repliesByParent = new Map<string, TimelineEntry[]>();
|
||||
for (const e of timeline) {
|
||||
@@ -911,19 +727,17 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
|
||||
if (group.type === "comment") {
|
||||
const entry = group.entries[0]!;
|
||||
return (
|
||||
<div key={entry.id} id={`comment-${entry.id}`}>
|
||||
<CommentCard
|
||||
issueId={id}
|
||||
entry={entry}
|
||||
allReplies={repliesByParent}
|
||||
currentUserId={user?.id}
|
||||
onReply={submitReply}
|
||||
onEdit={editComment}
|
||||
onDelete={deleteComment}
|
||||
onToggleReaction={handleToggleReaction}
|
||||
highlightedCommentId={highlightedId}
|
||||
/>
|
||||
</div>
|
||||
<CommentCard
|
||||
key={entry.id}
|
||||
issueId={id}
|
||||
entry={entry}
|
||||
allReplies={repliesByParent}
|
||||
currentUserId={user?.id}
|
||||
onReply={submitReply}
|
||||
onEdit={editComment}
|
||||
onDelete={deleteComment}
|
||||
onToggleReaction={handleToggleReaction}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -934,7 +748,6 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
|
||||
const isStatusChange = entry.action === "status_changed";
|
||||
const isPriorityChange = entry.action === "priority_changed";
|
||||
const isDueDateChange = entry.action === "due_date_changed";
|
||||
const isRelationChange = entry.action === "issue_relation_added" || entry.action === "issue_relation_removed";
|
||||
|
||||
let leadIcon: React.ReactNode;
|
||||
if (isStatusChange && details.to) {
|
||||
@@ -943,8 +756,6 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
|
||||
leadIcon = <PriorityIcon priority={details.to as IssuePriority} className="h-4 w-4 shrink-0" />;
|
||||
} else if (isDueDateChange) {
|
||||
leadIcon = <Calendar className="h-4 w-4 shrink-0 text-muted-foreground" />;
|
||||
} else if (isRelationChange) {
|
||||
leadIcon = <Link2 className="h-4 w-4 shrink-0 text-muted-foreground" />;
|
||||
} else {
|
||||
leadIcon = <ActorAvatar actorType={entry.actor_type} actorId={entry.actor_id} size={16} />;
|
||||
}
|
||||
@@ -985,20 +796,6 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Jump to bottom button */}
|
||||
{showScrollBottom && (
|
||||
<div className="sticky bottom-4 flex justify-center pointer-events-none">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="pointer-events-auto shadow-md"
|
||||
onClick={scrollToBottom}
|
||||
>
|
||||
<ChevronDown className="mr-1 h-3.5 w-3.5" />
|
||||
Jump to bottom
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
@@ -1069,12 +866,60 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
|
||||
|
||||
{/* Assignee */}
|
||||
<PropRow label="Assignee">
|
||||
<AssigneePicker
|
||||
assigneeType={issue.assignee_type}
|
||||
assigneeId={issue.assignee_id}
|
||||
onUpdate={handleUpdateField}
|
||||
align="start"
|
||||
/>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger className="flex items-center gap-1.5 cursor-pointer rounded px-1 -mx-1 hover:bg-accent/30 transition-colors overflow-hidden">
|
||||
{issue.assignee_type && issue.assignee_id ? (
|
||||
<>
|
||||
<ActorAvatar
|
||||
actorType={issue.assignee_type}
|
||||
actorId={issue.assignee_id}
|
||||
size={18}
|
||||
/>
|
||||
<span className="truncate">{getActorName(issue.assignee_type, issue.assignee_id)}</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-muted-foreground">Unassigned</span>
|
||||
)}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-52">
|
||||
<DropdownMenuItem onClick={() => handleUpdateField({ assignee_type: null, assignee_id: null })}>
|
||||
<UserMinus className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
Unassigned
|
||||
</DropdownMenuItem>
|
||||
{members.length > 0 && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel>Members</DropdownMenuLabel>
|
||||
{members.map((m) => (
|
||||
<DropdownMenuItem key={m.user_id} onClick={() => handleUpdateField({ assignee_type: "member", assignee_id: m.user_id })}>
|
||||
<div className="inline-flex size-4 shrink-0 items-center justify-center rounded-full bg-muted text-[8px] font-medium text-muted-foreground">
|
||||
{getActorInitials("member", m.user_id)}
|
||||
</div>
|
||||
{m.name}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuGroup>
|
||||
</>
|
||||
)}
|
||||
{agents.length > 0 && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel>Agents</DropdownMenuLabel>
|
||||
{agents.map((a) => (
|
||||
<DropdownMenuItem key={a.id} onClick={() => handleUpdateField({ assignee_type: "agent", assignee_id: a.id })}>
|
||||
<div className="inline-flex size-4 shrink-0 items-center justify-center rounded-full bg-info/10 text-info">
|
||||
<Bot className="size-2.5" />
|
||||
</div>
|
||||
{a.name}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuGroup>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</PropRow>
|
||||
|
||||
{/* Due date */}
|
||||
@@ -1087,102 +932,6 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
|
||||
</div>}
|
||||
</div>
|
||||
|
||||
{/* Relations section */}
|
||||
<div>
|
||||
<div className="flex items-center mb-2">
|
||||
<button
|
||||
className={`flex flex-1 items-center gap-1 text-xs font-medium transition-colors ${relationsOpen ? "" : "text-muted-foreground hover:text-foreground"}`}
|
||||
onClick={() => setRelationsOpen(!relationsOpen)}
|
||||
>
|
||||
<ChevronRight className={`h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform ${relationsOpen ? "rotate-90" : ""}`} />
|
||||
Relations
|
||||
{dependencies.length > 0 && <span className="text-muted-foreground ml-1">({dependencies.length})</span>}
|
||||
</button>
|
||||
<Popover open={addRelOpen} onOpenChange={setAddRelOpen}>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<button className="p-0.5 rounded hover:bg-accent/50 text-muted-foreground hover:text-foreground transition-colors">
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<PopoverContent align="end" className="w-72 p-0">
|
||||
<div className="p-2 border-b">
|
||||
<div className="flex gap-1 mb-2">
|
||||
{(["related", "blocks", "blocked_by"] as const).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
className={`px-2 py-0.5 text-xs rounded-full transition-colors ${relType === t ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground hover:text-foreground"}`}
|
||||
onClick={() => setRelType(t)}
|
||||
>
|
||||
{relationTypeLabel(t)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<Command>
|
||||
<CommandInput placeholder="Search issues..." value={relSearch} onValueChange={setRelSearch} />
|
||||
<CommandList>
|
||||
<CommandEmpty>No issues found</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{allIssues
|
||||
.filter((i) => i.id !== id)
|
||||
.filter((i) => !dependencies.some(
|
||||
(d) => (d.issue_id === id ? d.depends_on_issue_id : d.issue_id) === i.id
|
||||
))
|
||||
.slice(0, 20)
|
||||
.map((i) => (
|
||||
<CommandItem
|
||||
key={i.id}
|
||||
value={`${i.identifier} ${i.title}`}
|
||||
onSelect={() => handleAddDependency(i.id, relType)}
|
||||
>
|
||||
<span className="text-muted-foreground shrink-0 mr-1.5">{i.identifier}</span>
|
||||
<span className="truncate">{i.title}</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
{relationsOpen && dependencies.length > 0 && (
|
||||
<div className="space-y-1 pl-2">
|
||||
{dependencies.map((dep) => {
|
||||
// Determine which side is the "other" issue
|
||||
const isSource = dep.issue_id === id;
|
||||
const otherIdentifier = isSource ? dep.depends_on_issue_identifier : dep.issue_identifier;
|
||||
const otherTitle = isSource ? dep.depends_on_issue_title : dep.issue_title;
|
||||
const otherIssueId = isSource ? dep.depends_on_issue_id : dep.issue_id;
|
||||
// Show the relation type from this issue's perspective
|
||||
const displayType = isSource ? dep.type : inverseRelType(dep.type);
|
||||
|
||||
return (
|
||||
<div key={dep.id} className="group flex items-center gap-1.5 text-xs rounded-md px-2 -mx-2 min-h-7 hover:bg-accent/50 transition-colors">
|
||||
<Link2 className="h-3 w-3 shrink-0 text-muted-foreground" />
|
||||
<span className="shrink-0 text-muted-foreground">{relationTypeLabel(displayType)}</span>
|
||||
<Link href={`/issues/${otherIssueId}`} className="flex items-center gap-1 min-w-0 hover:underline">
|
||||
<span className="shrink-0 text-muted-foreground">{otherIdentifier}</span>
|
||||
<span className="truncate">{otherTitle}</span>
|
||||
</Link>
|
||||
<button
|
||||
className="ml-auto shrink-0 opacity-0 group-hover:opacity-100 p-0.5 rounded hover:bg-accent transition-all text-muted-foreground hover:text-destructive"
|
||||
onClick={() => handleRemoveDependency(dep.id)}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{relationsOpen && dependencies.length === 0 && (
|
||||
<div className="pl-2 text-xs text-muted-foreground">No relations</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Details section */}
|
||||
<div>
|
||||
<button
|
||||
|
||||
@@ -4,12 +4,14 @@ import { useMemo, useState } from "react";
|
||||
import {
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
Bot,
|
||||
Check,
|
||||
ChevronDown,
|
||||
CircleDot,
|
||||
Columns3,
|
||||
Filter,
|
||||
List,
|
||||
Plus,
|
||||
SignalHigh,
|
||||
SlidersHorizontal,
|
||||
User,
|
||||
@@ -17,6 +19,7 @@ import {
|
||||
UserPen,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useIssueStore } from "@/features/issues/store";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
@@ -36,6 +39,7 @@ import {
|
||||
PopoverContent,
|
||||
} from "@/components/ui/popover";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { useModalStore } from "@/features/modals";
|
||||
import {
|
||||
ALL_STATUSES,
|
||||
STATUS_CONFIG,
|
||||
@@ -43,24 +47,20 @@ import {
|
||||
PRIORITY_CONFIG,
|
||||
} from "@/features/issues/config";
|
||||
import { StatusIcon, PriorityIcon } from "@/features/issues/components";
|
||||
import { useWorkspaceStore } from "@/features/workspace";
|
||||
import { ActorAvatar } from "@/components/common/actor-avatar";
|
||||
import { useWorkspaceStore, useActorName } from "@/features/workspace";
|
||||
import {
|
||||
useIssueViewStore,
|
||||
SORT_OPTIONS,
|
||||
CARD_PROPERTY_OPTIONS,
|
||||
type ActorFilterValue,
|
||||
} from "@/features/issues/stores/view-store";
|
||||
import {
|
||||
useIssuesScopeStore,
|
||||
type IssuesScope,
|
||||
} from "@/features/issues/stores/issues-scope-store";
|
||||
import { filterIssues } from "@/features/issues/utils/filter";
|
||||
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
|
||||
import type { Issue } from "@/shared/types";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HoverCheck — shadcn official pattern (PR #6862)
|
||||
// Uses data-selected attr instead of Checkbox component to avoid
|
||||
// DropdownMenuCheckboxItem's focus:**:text-accent-foreground cascade.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const FILTER_ITEM_CLASS =
|
||||
@@ -123,16 +123,6 @@ function useIssueCounts(allIssues: Issue[]) {
|
||||
}, [allIssues]);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scope config
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const SCOPES: { value: IssuesScope; label: string; description: string }[] = [
|
||||
{ value: "all", label: "All", description: "All issues in this workspace" },
|
||||
{ value: "members", label: "Members", description: "Issues assigned to team members" },
|
||||
{ value: "agents", label: "Agents", description: "Issues assigned to AI agents" },
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Actor sub-menu content (shared between Assignee and Creator)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -157,6 +147,8 @@ function ActorSubContent({
|
||||
const [search, setSearch] = useState("");
|
||||
const members = useWorkspaceStore((s) => s.members);
|
||||
const agents = useWorkspaceStore((s) => s.agents);
|
||||
const { getActorInitials } = useActorName();
|
||||
|
||||
const query = search.toLowerCase();
|
||||
const filteredMembers = members.filter((m) =>
|
||||
m.name.toLowerCase().includes(query),
|
||||
@@ -216,7 +208,9 @@ function ActorSubContent({
|
||||
className={FILTER_ITEM_CLASS}
|
||||
>
|
||||
<HoverCheck checked={checked} />
|
||||
<ActorAvatar actorType="member" actorId={m.user_id} size={18} />
|
||||
<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 className="truncate">{m.name}</span>
|
||||
{count > 0 && (
|
||||
<span className="ml-auto text-xs text-muted-foreground">
|
||||
@@ -245,7 +239,9 @@ function ActorSubContent({
|
||||
className={FILTER_ITEM_CLASS}
|
||||
>
|
||||
<HoverCheck checked={checked} />
|
||||
<ActorAvatar actorType="agent" actorId={a.id} size={18} />
|
||||
<div className="inline-flex size-4.5 shrink-0 items-center justify-center rounded-full bg-info/10 text-info">
|
||||
<Bot className="size-2.5" />
|
||||
</div>
|
||||
<span className="truncate">{a.name}</span>
|
||||
{count > 0 && (
|
||||
<span className="ml-auto text-xs text-muted-foreground">
|
||||
@@ -272,10 +268,7 @@ function ActorSubContent({
|
||||
// IssuesHeader
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function IssuesHeader({ scopedIssues }: { scopedIssues: Issue[] }) {
|
||||
const scope = useIssuesScopeStore((s) => s.scope);
|
||||
const setScope = useIssuesScopeStore((s) => s.setScope);
|
||||
|
||||
export function IssuesHeader() {
|
||||
const viewMode = useIssueViewStore((s) => s.viewMode);
|
||||
const statusFilters = useIssueViewStore((s) => s.statusFilters);
|
||||
const priorityFilters = useIssueViewStore((s) => s.priorityFilters);
|
||||
@@ -285,71 +278,85 @@ export function IssuesHeader({ scopedIssues }: { scopedIssues: Issue[] }) {
|
||||
const sortBy = useIssueViewStore((s) => s.sortBy);
|
||||
const sortDirection = useIssueViewStore((s) => s.sortDirection);
|
||||
const cardProperties = useIssueViewStore((s) => s.cardProperties);
|
||||
const act = useIssueViewStore.getState();
|
||||
const setViewMode = useIssueViewStore((s) => s.setViewMode);
|
||||
const toggleStatusFilter = useIssueViewStore((s) => s.toggleStatusFilter);
|
||||
const togglePriorityFilter = useIssueViewStore((s) => s.togglePriorityFilter);
|
||||
const toggleAssigneeFilter = useIssueViewStore((s) => s.toggleAssigneeFilter);
|
||||
const toggleNoAssignee = useIssueViewStore((s) => s.toggleNoAssignee);
|
||||
const toggleCreatorFilter = useIssueViewStore((s) => s.toggleCreatorFilter);
|
||||
const clearFilters = useIssueViewStore((s) => s.clearFilters);
|
||||
const setSortBy = useIssueViewStore((s) => s.setSortBy);
|
||||
const setSortDirection = useIssueViewStore((s) => s.setSortDirection);
|
||||
const toggleCardProperty = useIssueViewStore((s) => s.toggleCardProperty);
|
||||
|
||||
const counts = useIssueCounts(scopedIssues);
|
||||
const allIssues = useIssueStore((s) => s.issues);
|
||||
const counts = useIssueCounts(allIssues);
|
||||
|
||||
const hasActiveFilters =
|
||||
getActiveFilterCount({
|
||||
statusFilters,
|
||||
priorityFilters,
|
||||
assigneeFilters,
|
||||
includeNoAssignee,
|
||||
creatorFilters,
|
||||
}) > 0;
|
||||
const filteredCount = useMemo(
|
||||
() => filterIssues(allIssues, { statusFilters, priorityFilters, assigneeFilters, includeNoAssignee, creatorFilters }).length,
|
||||
[allIssues, statusFilters, priorityFilters, assigneeFilters, includeNoAssignee, creatorFilters],
|
||||
);
|
||||
|
||||
const filterCount = getActiveFilterCount({
|
||||
statusFilters,
|
||||
priorityFilters,
|
||||
assigneeFilters,
|
||||
includeNoAssignee,
|
||||
creatorFilters,
|
||||
});
|
||||
|
||||
const sortLabel =
|
||||
SORT_OPTIONS.find((o) => o.value === sortBy)?.label ?? "Manual";
|
||||
const hasActiveFilters = filterCount > 0;
|
||||
|
||||
return (
|
||||
<div className="flex h-12 shrink-0 items-center justify-between px-4">
|
||||
{/* Left: scope buttons */}
|
||||
<div className="flex items-center gap-1">
|
||||
{SCOPES.map((s) => (
|
||||
<Tooltip key={s.value}>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={
|
||||
scope === s.value
|
||||
? "bg-accent text-accent-foreground hover:bg-accent/80"
|
||||
: "text-muted-foreground"
|
||||
}
|
||||
onClick={() => setScope(s.value)}
|
||||
>
|
||||
{s.label}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<TooltipContent side="bottom">{s.description}</TooltipContent>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Right: filter + display + view toggle */}
|
||||
<div className="flex items-center gap-1">
|
||||
{/* Filter */}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* View toggle */}
|
||||
<DropdownMenu>
|
||||
<Tooltip>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button variant="outline" size="icon-sm" className="relative text-muted-foreground">
|
||||
<Filter className="size-4" />
|
||||
{hasActiveFilters && (
|
||||
<span className="absolute top-0 right-0 size-1.5 rounded-full bg-brand" />
|
||||
)}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<TooltipContent side="bottom">Filter</TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenuContent align="end" className="w-auto">
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button variant="outline" size="sm">
|
||||
{viewMode === "board" ? <Columns3 className="size-3.5" /> : <List className="size-3.5" />}
|
||||
{viewMode === "board" ? "Board" : "List"}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<DropdownMenuContent align="start" className="w-auto">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel>View</DropdownMenuLabel>
|
||||
<DropdownMenuItem onClick={() => setViewMode("board")}>
|
||||
<Columns3 />
|
||||
Board
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setViewMode("list")}>
|
||||
<List />
|
||||
List
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* Filter — DropdownMenu with sub-menus */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={hasActiveFilters ? "border-primary/50 text-primary" : ""}
|
||||
>
|
||||
<Filter className="size-3.5" />
|
||||
Filter
|
||||
{hasActiveFilters && (
|
||||
<span className="flex h-4 min-w-4 items-center justify-center rounded-full bg-primary px-1 text-[10px] font-medium text-primary-foreground">
|
||||
{filterCount}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<DropdownMenuContent align="start" className="w-44">
|
||||
{/* Status */}
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
@@ -369,7 +376,7 @@ export function IssuesHeader({ scopedIssues }: { scopedIssues: Issue[] }) {
|
||||
<DropdownMenuCheckboxItem
|
||||
key={s}
|
||||
checked={checked}
|
||||
onCheckedChange={() => act.toggleStatusFilter(s)}
|
||||
onCheckedChange={() => toggleStatusFilter(s)}
|
||||
className={FILTER_ITEM_CLASS}
|
||||
>
|
||||
<HoverCheck checked={checked} />
|
||||
@@ -405,7 +412,7 @@ export function IssuesHeader({ scopedIssues }: { scopedIssues: Issue[] }) {
|
||||
<DropdownMenuCheckboxItem
|
||||
key={p}
|
||||
checked={checked}
|
||||
onCheckedChange={() => act.togglePriorityFilter(p)}
|
||||
onCheckedChange={() => togglePriorityFilter(p)}
|
||||
className={FILTER_ITEM_CLASS}
|
||||
>
|
||||
<HoverCheck checked={checked} />
|
||||
@@ -437,10 +444,10 @@ export function IssuesHeader({ scopedIssues }: { scopedIssues: Issue[] }) {
|
||||
<ActorSubContent
|
||||
counts={counts.assignee}
|
||||
selected={assigneeFilters}
|
||||
onToggle={act.toggleAssigneeFilter}
|
||||
onToggle={toggleAssigneeFilter}
|
||||
showNoAssignee
|
||||
includeNoAssignee={includeNoAssignee}
|
||||
onToggleNoAssignee={act.toggleNoAssignee}
|
||||
onToggleNoAssignee={toggleNoAssignee}
|
||||
noAssigneeCount={counts.noAssignee}
|
||||
/>
|
||||
</DropdownMenuSubContent>
|
||||
@@ -461,7 +468,7 @@ export function IssuesHeader({ scopedIssues }: { scopedIssues: Issue[] }) {
|
||||
<ActorSubContent
|
||||
counts={counts.creator}
|
||||
selected={creatorFilters}
|
||||
onToggle={act.toggleCreatorFilter}
|
||||
onToggle={toggleCreatorFilter}
|
||||
/>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
@@ -470,7 +477,7 @@ export function IssuesHeader({ scopedIssues }: { scopedIssues: Issue[] }) {
|
||||
{hasActiveFilters && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={act.clearFilters}>
|
||||
<DropdownMenuItem onClick={clearFilters}>
|
||||
Reset all filters
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
@@ -480,21 +487,15 @@ export function IssuesHeader({ scopedIssues }: { scopedIssues: Issue[] }) {
|
||||
|
||||
{/* Display settings */}
|
||||
<Popover>
|
||||
<Tooltip>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button variant="outline" size="icon-sm" className="text-muted-foreground">
|
||||
<SlidersHorizontal className="size-4" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<TooltipContent side="bottom">Display settings</TooltipContent>
|
||||
</Tooltip>
|
||||
<PopoverContent align="end" className="w-64 p-0">
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<Button variant="outline" size="sm">
|
||||
<SlidersHorizontal className="size-3.5" />
|
||||
Display
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<PopoverContent align="start" className="w-64 p-0">
|
||||
<div className="border-b px-3 py-2.5">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
Ordering
|
||||
@@ -517,7 +518,7 @@ export function IssuesHeader({ scopedIssues }: { scopedIssues: Issue[] }) {
|
||||
{SORT_OPTIONS.map((opt) => (
|
||||
<DropdownMenuItem
|
||||
key={opt.value}
|
||||
onClick={() => act.setSortBy(opt.value)}
|
||||
onClick={() => setSortBy(opt.value)}
|
||||
>
|
||||
{opt.label}
|
||||
</DropdownMenuItem>
|
||||
@@ -528,7 +529,7 @@ export function IssuesHeader({ scopedIssues }: { scopedIssues: Issue[] }) {
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
onClick={() =>
|
||||
act.setSortDirection(sortDirection === "asc" ? "desc" : "asc")
|
||||
setSortDirection(sortDirection === "asc" ? "desc" : "asc")
|
||||
}
|
||||
title={sortDirection === "asc" ? "Ascending" : "Descending"}
|
||||
>
|
||||
@@ -555,7 +556,7 @@ export function IssuesHeader({ scopedIssues }: { scopedIssues: Issue[] }) {
|
||||
<Switch
|
||||
size="sm"
|
||||
checked={cardProperties[opt.key]}
|
||||
onCheckedChange={() => act.toggleCardProperty(opt.key)}
|
||||
onCheckedChange={() => toggleCardProperty(opt.key)}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
@@ -563,43 +564,19 @@ export function IssuesHeader({ scopedIssues }: { scopedIssues: Issue[] }) {
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
{/* View toggle */}
|
||||
<DropdownMenu>
|
||||
<Tooltip>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button variant="outline" size="icon-sm" className="text-muted-foreground">
|
||||
{viewMode === "board" ? (
|
||||
<Columns3 className="size-4" />
|
||||
) : (
|
||||
<List className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<TooltipContent side="bottom">
|
||||
{viewMode === "board" ? "Board view" : "List view"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenuContent align="end" className="w-auto">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel>View</DropdownMenuLabel>
|
||||
<DropdownMenuItem onClick={() => act.setViewMode("board")}>
|
||||
<Columns3 />
|
||||
Board
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => act.setViewMode("list")}>
|
||||
<List />
|
||||
List
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{filteredCount} {filteredCount === 1 ? "Issue" : "Issues"}
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => useModalStore.getState().open("create-issue")}
|
||||
>
|
||||
<Plus />
|
||||
New Issue
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -2,15 +2,12 @@
|
||||
|
||||
import { useCallback, useEffect, useMemo } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { ChevronRight, ListTodo } from "lucide-react";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import type { IssueStatus } from "@/shared/types";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useIssueStore } from "@/features/issues/store";
|
||||
import { useIssueViewStore, initFilterWorkspaceSync } from "@/features/issues/stores/view-store";
|
||||
import { useIssuesScopeStore } from "@/features/issues/stores/issues-scope-store";
|
||||
import { ViewStoreProvider } from "@/features/issues/stores/view-store-context";
|
||||
import { filterIssues } from "@/features/issues/utils/filter";
|
||||
import { BOARD_STATUSES } from "@/features/issues/config";
|
||||
import { useWorkspaceStore } from "@/features/workspace";
|
||||
import { WorkspaceAvatar } from "@/features/workspace";
|
||||
import { api } from "@/shared/api";
|
||||
@@ -20,11 +17,19 @@ import { BoardView } from "./board-view";
|
||||
import { ListView } from "./list-view";
|
||||
import { BatchActionToolbar } from "./batch-action-toolbar";
|
||||
|
||||
const BOARD_STATUSES: IssueStatus[] = [
|
||||
"backlog",
|
||||
"todo",
|
||||
"in_progress",
|
||||
"in_review",
|
||||
"done",
|
||||
"blocked",
|
||||
];
|
||||
|
||||
export function IssuesPage() {
|
||||
const allIssues = useIssueStore((s) => s.issues);
|
||||
const loading = useIssueStore((s) => s.loading);
|
||||
const workspace = useWorkspaceStore((s) => s.workspace);
|
||||
const scope = useIssuesScopeStore((s) => s.scope);
|
||||
const viewMode = useIssueViewStore((s) => s.viewMode);
|
||||
const statusFilters = useIssueViewStore((s) => s.statusFilters);
|
||||
const priorityFilters = useIssueViewStore((s) => s.priorityFilters);
|
||||
@@ -38,20 +43,11 @@ export function IssuesPage() {
|
||||
|
||||
useEffect(() => {
|
||||
useIssueSelectionStore.getState().clear();
|
||||
}, [viewMode, scope]);
|
||||
|
||||
// Scope pre-filter: narrow by assignee type
|
||||
const scopedIssues = useMemo(() => {
|
||||
if (scope === "members")
|
||||
return allIssues.filter((i) => i.assignee_type === "member");
|
||||
if (scope === "agents")
|
||||
return allIssues.filter((i) => i.assignee_type === "agent");
|
||||
return allIssues;
|
||||
}, [allIssues, scope]);
|
||||
}, [viewMode]);
|
||||
|
||||
const issues = useMemo(
|
||||
() => filterIssues(scopedIssues, { statusFilters, priorityFilters, assigneeFilters, includeNoAssignee, creatorFilters }),
|
||||
[scopedIssues, statusFilters, priorityFilters, assigneeFilters, includeNoAssignee, creatorFilters],
|
||||
() => filterIssues(allIssues, { statusFilters, priorityFilters, assigneeFilters, includeNoAssignee, creatorFilters }),
|
||||
[allIssues, statusFilters, priorityFilters, assigneeFilters, includeNoAssignee, creatorFilters],
|
||||
);
|
||||
|
||||
const visibleStatuses = useMemo(() => {
|
||||
@@ -67,10 +63,9 @@ export function IssuesPage() {
|
||||
const handleMoveIssue = useCallback(
|
||||
(issueId: string, newStatus: IssueStatus, newPosition?: number) => {
|
||||
// Auto-switch to manual sort so drag ordering is preserved
|
||||
const viewState = useIssueViewStore.getState();
|
||||
if (viewState.sortBy !== "position") {
|
||||
viewState.setSortBy("position");
|
||||
viewState.setSortDirection("asc");
|
||||
if (useIssueViewStore.getState().sortBy !== "position") {
|
||||
useIssueViewStore.getState().setSortBy("position");
|
||||
useIssueViewStore.getState().setSortDirection("asc");
|
||||
}
|
||||
|
||||
const updates: Partial<{ status: IssueStatus; position: number }> = {
|
||||
@@ -84,7 +79,7 @@ export function IssuesPage() {
|
||||
toast.error("Failed to move issue");
|
||||
api.listIssues({ limit: 200 }).then((res) => {
|
||||
useIssueStore.getState().setIssues(res.issues);
|
||||
}).catch(console.error);
|
||||
});
|
||||
});
|
||||
},
|
||||
[]
|
||||
@@ -126,34 +121,24 @@ export function IssuesPage() {
|
||||
<span className="text-sm font-medium">Issues</span>
|
||||
</div>
|
||||
|
||||
{/* Header 2: Scope tabs + filters */}
|
||||
<IssuesHeader scopedIssues={scopedIssues} />
|
||||
{/* Header 2: View toggle + filters */}
|
||||
<IssuesHeader />
|
||||
|
||||
{/* Content: scrollable */}
|
||||
<ViewStoreProvider store={useIssueViewStore}>
|
||||
{scopedIssues.length === 0 ? (
|
||||
<div className="flex flex-1 min-h-0 flex-col items-center justify-center gap-2 text-muted-foreground">
|
||||
<ListTodo className="h-10 w-10 text-muted-foreground/40" />
|
||||
<p className="text-sm">No issues yet</p>
|
||||
<p className="text-xs">Create an issue to get started.</p>
|
||||
</div>
|
||||
<div className="flex flex-col flex-1 min-h-0">
|
||||
{viewMode === "board" ? (
|
||||
<BoardView
|
||||
issues={issues}
|
||||
allIssues={allIssues}
|
||||
visibleStatuses={visibleStatuses}
|
||||
hiddenStatuses={hiddenStatuses}
|
||||
onMoveIssue={handleMoveIssue}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col flex-1 min-h-0">
|
||||
{viewMode === "board" ? (
|
||||
<BoardView
|
||||
issues={issues}
|
||||
allIssues={scopedIssues}
|
||||
visibleStatuses={visibleStatuses}
|
||||
hiddenStatuses={hiddenStatuses}
|
||||
onMoveIssue={handleMoveIssue}
|
||||
/>
|
||||
) : (
|
||||
<ListView issues={issues} visibleStatuses={visibleStatuses} />
|
||||
)}
|
||||
</div>
|
||||
<ListView issues={issues} visibleStatuses={visibleStatuses} />
|
||||
)}
|
||||
{viewMode === "list" && <BatchActionToolbar />}
|
||||
</ViewStoreProvider>
|
||||
</div>
|
||||
{viewMode === "list" && <BatchActionToolbar />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import { Button } from "@/components/ui/button";
|
||||
import type { Issue, IssueStatus } from "@/shared/types";
|
||||
import { STATUS_CONFIG } from "@/features/issues/config";
|
||||
import { useModalStore } from "@/features/modals";
|
||||
import { useViewStore } from "@/features/issues/stores/view-store-context";
|
||||
import { useIssueViewStore } from "@/features/issues/stores/view-store";
|
||||
import { useIssueSelectionStore } from "@/features/issues/stores/selection-store";
|
||||
import { sortIssues } from "@/features/issues/utils/sort";
|
||||
import { StatusIcon } from "./status-icon";
|
||||
@@ -21,12 +21,12 @@ export function ListView({
|
||||
issues: Issue[];
|
||||
visibleStatuses: IssueStatus[];
|
||||
}) {
|
||||
const sortBy = useViewStore((s) => s.sortBy);
|
||||
const sortDirection = useViewStore((s) => s.sortDirection);
|
||||
const listCollapsedStatuses = useViewStore(
|
||||
const sortBy = useIssueViewStore((s) => s.sortBy);
|
||||
const sortDirection = useIssueViewStore((s) => s.sortDirection);
|
||||
const listCollapsedStatuses = useIssueViewStore(
|
||||
(s) => s.listCollapsedStatuses
|
||||
);
|
||||
const toggleListCollapsed = useViewStore(
|
||||
const toggleListCollapsed = useIssueViewStore(
|
||||
(s) => s.toggleListCollapsed
|
||||
);
|
||||
const selectedIds = useIssueSelectionStore((s) => s.selectedIds);
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Lock, UserMinus } from "lucide-react";
|
||||
import { Bot, Lock, UserMinus } from "lucide-react";
|
||||
import type { Agent, IssueAssigneeType, UpdateIssueRequest } from "@/shared/types";
|
||||
import { useAuthStore } from "@/features/auth";
|
||||
import { useWorkspaceStore, useActorName } from "@/features/workspace";
|
||||
import { ActorAvatar } from "@/components/common/actor-avatar";
|
||||
import {
|
||||
PropertyPicker,
|
||||
PickerItem,
|
||||
@@ -13,7 +12,7 @@ import {
|
||||
PickerEmpty,
|
||||
} from "./property-picker";
|
||||
|
||||
export function canAssignAgent(agent: Agent, userId: string | undefined, memberRole: string | undefined): boolean {
|
||||
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;
|
||||
@@ -25,28 +24,18 @@ export function AssigneePicker({
|
||||
assigneeId,
|
||||
onUpdate,
|
||||
trigger: customTrigger,
|
||||
triggerRender,
|
||||
open: controlledOpen,
|
||||
onOpenChange: controlledOnOpenChange,
|
||||
align,
|
||||
}: {
|
||||
assigneeType: IssueAssigneeType | null;
|
||||
assigneeId: string | null;
|
||||
onUpdate: (updates: Partial<UpdateIssueRequest>) => void;
|
||||
trigger?: React.ReactNode;
|
||||
triggerRender?: React.ReactElement;
|
||||
open?: boolean;
|
||||
onOpenChange?: (v: boolean) => void;
|
||||
align?: "start" | "center" | "end";
|
||||
}) {
|
||||
const [internalOpen, setInternalOpen] = useState(false);
|
||||
const open = controlledOpen ?? internalOpen;
|
||||
const setOpen = controlledOnOpenChange ?? setInternalOpen;
|
||||
const [open, setOpen] = useState(false);
|
||||
const [filter, setFilter] = useState("");
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const members = useWorkspaceStore((s) => s.members);
|
||||
const agents = useWorkspaceStore((s) => s.agents);
|
||||
const { getActorName } = useActorName();
|
||||
const { getActorName, getActorInitials } = useActorName();
|
||||
|
||||
const currentMember = members.find((m) => m.user_id === user?.id);
|
||||
const memberRole = currentMember?.role;
|
||||
@@ -56,7 +45,7 @@ export function AssigneePicker({
|
||||
m.name.toLowerCase().includes(query),
|
||||
);
|
||||
const filteredAgents = agents.filter((a) =>
|
||||
!a.archived_at && a.name.toLowerCase().includes(query),
|
||||
a.name.toLowerCase().includes(query),
|
||||
);
|
||||
|
||||
const isSelected = (type: string, id: string) =>
|
||||
@@ -75,15 +64,25 @@ export function AssigneePicker({
|
||||
if (!v) setFilter("");
|
||||
}}
|
||||
width="w-52"
|
||||
align={align}
|
||||
searchable
|
||||
searchPlaceholder="Assign to..."
|
||||
onSearchChange={setFilter}
|
||||
triggerRender={triggerRender}
|
||||
trigger={
|
||||
customTrigger ? customTrigger : assigneeType && assigneeId ? (
|
||||
<>
|
||||
<ActorAvatar actorType={assigneeType} actorId={assigneeId} size={18} />
|
||||
<div
|
||||
className={`inline-flex shrink-0 items-center justify-center rounded-full font-medium text-[8px] size-4.5 ${
|
||||
assigneeType === "agent"
|
||||
? "bg-info/10 text-info"
|
||||
: "bg-muted text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{assigneeType === "agent" ? (
|
||||
<Bot className="size-2.5" />
|
||||
) : (
|
||||
getActorInitials(assigneeType, assigneeId)
|
||||
)}
|
||||
</div>
|
||||
<span className="truncate">{triggerLabel}</span>
|
||||
</>
|
||||
) : (
|
||||
@@ -118,7 +117,9 @@ export function AssigneePicker({
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<ActorAvatar actorType="member" actorId={m.user_id} size={18} />
|
||||
<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>
|
||||
</PickerItem>
|
||||
))}
|
||||
@@ -144,7 +145,9 @@ export function AssigneePicker({
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<ActorAvatar actorType="agent" actorId={a.id} size={18} />
|
||||
<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" />
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export { PropertyPicker, PickerItem, PickerSection, PickerEmpty } from "./property-picker";
|
||||
export { StatusPicker } from "./status-picker";
|
||||
export { PriorityPicker } from "./priority-picker";
|
||||
export { AssigneePicker, canAssignAgent } from "./assignee-picker";
|
||||
export { AssigneePicker } from "./assignee-picker";
|
||||
export { DueDatePicker } from "./due-date-picker";
|
||||
|
||||
@@ -16,7 +16,6 @@ export function PropertyPicker({
|
||||
open,
|
||||
onOpenChange,
|
||||
trigger,
|
||||
triggerRender,
|
||||
width = "w-48",
|
||||
align = "end",
|
||||
searchable = false,
|
||||
@@ -27,7 +26,6 @@ export function PropertyPicker({
|
||||
open: boolean;
|
||||
onOpenChange: (v: boolean) => void;
|
||||
trigger: React.ReactNode;
|
||||
triggerRender?: React.ReactElement;
|
||||
width?: string;
|
||||
align?: "start" | "center" | "end";
|
||||
searchable?: boolean;
|
||||
@@ -50,10 +48,7 @@ export function PropertyPicker({
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={handleOpenChange}>
|
||||
<PopoverTrigger
|
||||
className={triggerRender ? undefined : "flex items-center gap-1.5 cursor-pointer rounded px-1 -mx-1 hover:bg-accent/30 transition-colors overflow-hidden"}
|
||||
render={triggerRender}
|
||||
>
|
||||
<PopoverTrigger className="flex items-center gap-1.5 cursor-pointer rounded px-1 -mx-1 hover:bg-accent/30 transition-colors overflow-hidden">
|
||||
{trigger}
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align={align} className={`${width} gap-0 p-0`}>
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState, useEffect } from "react";
|
||||
import { ArrowUp, Loader2 } from "lucide-react";
|
||||
import { ContentEditor, type ContentEditorRef } from "@/features/editor";
|
||||
import { FileUploadButton } from "@/components/common/file-upload-button";
|
||||
import { useRef, useState } from "react";
|
||||
import { ArrowUp, Loader2, Paperclip } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { RichTextEditor, type RichTextEditorRef } from "@/components/common/rich-text-editor";
|
||||
import { ActorAvatar } from "@/components/common/actor-avatar";
|
||||
import { useFileUpload } from "@/shared/hooks/use-file-upload";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
@@ -33,26 +32,27 @@ function ReplyInput({
|
||||
onSubmit,
|
||||
size = "default",
|
||||
}: ReplyInputProps) {
|
||||
const editorRef = useRef<ContentEditorRef>(null);
|
||||
const measureRef = useRef<HTMLDivElement>(null);
|
||||
const editorRef = useRef<RichTextEditorRef>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const attachmentIdsRef = useRef<string[]>([]);
|
||||
const [isEmpty, setIsEmpty] = useState(true);
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const { uploadWithToast } = useFileUpload();
|
||||
|
||||
useEffect(() => {
|
||||
const el = measureRef.current;
|
||||
if (!el) return;
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
const entry = entries[0];
|
||||
if (entry) setIsExpanded(entry.contentRect.height > 32);
|
||||
});
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
const { uploadWithToast, uploading } = useFileUpload();
|
||||
|
||||
const handleUpload = async (file: File) => {
|
||||
return await uploadWithToast(file, { issueId });
|
||||
const result = await uploadWithToast(file, { issueId });
|
||||
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 () => {
|
||||
@@ -60,8 +60,10 @@ function ReplyInput({
|
||||
if (!content || submitting) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await onSubmit(content);
|
||||
const ids = attachmentIdsRef.current.length > 0 ? [...attachmentIdsRef.current] : undefined;
|
||||
await onSubmit(content, ids);
|
||||
editorRef.current?.clearContent();
|
||||
attachmentIdsRef.current = [];
|
||||
setIsEmpty(true);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
@@ -71,49 +73,61 @@ function ReplyInput({
|
||||
const avatarSize = size === "sm" ? 22 : 28;
|
||||
|
||||
return (
|
||||
<div className="group/editor flex items-start gap-2.5">
|
||||
<div className="flex items-start gap-2.5">
|
||||
<ActorAvatar
|
||||
actorType={avatarType}
|
||||
actorId={avatarId}
|
||||
size={avatarSize}
|
||||
className="mt-0.5 shrink-0"
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
"relative min-w-0 flex-1 flex flex-col",
|
||||
size === "sm" ? "max-h-40" : "max-h-56",
|
||||
isExpanded && "pb-7",
|
||||
)}
|
||||
>
|
||||
<div className="flex-1 min-h-0 overflow-y-auto pr-14">
|
||||
<div ref={measureRef}>
|
||||
<ContentEditor
|
||||
ref={editorRef}
|
||||
placeholder={placeholder}
|
||||
onUpdate={(md) => setIsEmpty(!md.trim())}
|
||||
onSubmit={handleSubmit}
|
||||
onUploadFile={handleUpload}
|
||||
debounceMs={100}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute bottom-0 right-0 flex items-center gap-1 text-muted-foreground transition-colors group-focus-within/editor:text-foreground">
|
||||
<FileUploadButton
|
||||
size="sm"
|
||||
onSelect={(file) => editorRef.current?.uploadFile(file)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div
|
||||
className={`overflow-y-auto text-sm ${
|
||||
size === "sm" ? "max-h-32" : "max-h-48"
|
||||
}`}
|
||||
>
|
||||
<RichTextEditor
|
||||
ref={editorRef}
|
||||
placeholder={placeholder}
|
||||
onUpdate={(md) => setIsEmpty(!md.trim())}
|
||||
onSubmit={handleSubmit}
|
||||
onUploadFile={handleUpload}
|
||||
debounceMs={100}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isEmpty || submitting}
|
||||
onClick={handleSubmit}
|
||||
className="inline-flex h-6 w-6 items-center justify-center rounded-full text-muted-foreground hover:bg-accent hover:text-foreground transition-colors disabled:opacity-50 disabled:pointer-events-none"
|
||||
>
|
||||
{submitting ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<ArrowUp className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
className={`grid transition-all duration-150 ${
|
||||
isEmpty ? "grid-rows-[0fr] opacity-0" : "grid-rows-[1fr] opacity-100"
|
||||
}`}
|
||||
>
|
||||
<div className="overflow-hidden">
|
||||
<div className="flex items-center justify-end gap-1 pt-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={uploading}
|
||||
tabIndex={isEmpty ? -1 : 0}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Paperclip className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
className="hidden"
|
||||
onChange={handleFileSelect}
|
||||
/>
|
||||
<Button
|
||||
size="icon-xs"
|
||||
disabled={isEmpty || submitting}
|
||||
onClick={handleSubmit}
|
||||
tabIndex={isEmpty ? -1 : 0}
|
||||
>
|
||||
{submitting ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <ArrowUp className="h-3.5 w-3.5" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
export { STATUS_ORDER, ALL_STATUSES, BOARD_STATUSES, STATUS_CONFIG } from "./status";
|
||||
export { STATUS_ORDER, ALL_STATUSES, STATUS_CONFIG } from "./status";
|
||||
export { PRIORITY_ORDER, PRIORITY_CONFIG } from "./priority";
|
||||
|
||||
@@ -20,16 +20,6 @@ export const ALL_STATUSES: IssueStatus[] = [
|
||||
"cancelled",
|
||||
];
|
||||
|
||||
/** Statuses shown as board columns (excludes cancelled). */
|
||||
export const BOARD_STATUSES: IssueStatus[] = [
|
||||
"backlog",
|
||||
"todo",
|
||||
"in_progress",
|
||||
"in_review",
|
||||
"done",
|
||||
"blocked",
|
||||
];
|
||||
|
||||
export const STATUS_CONFIG: Record<
|
||||
IssueStatus,
|
||||
{
|
||||
|
||||
@@ -7,8 +7,8 @@ import type {
|
||||
IssueReactionRemovedPayload,
|
||||
} from "@/shared/types";
|
||||
import { api } from "@/shared/api";
|
||||
import { toast } from "sonner";
|
||||
import { useWSEvent, useWSReconnect } from "@/features/realtime";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export function useIssueReactions(issueId: string, userId?: string) {
|
||||
const [reactions, setReactions] = useState<IssueReaction[]>([]);
|
||||
@@ -21,10 +21,7 @@ export function useIssueReactions(issueId: string, userId?: string) {
|
||||
api
|
||||
.getIssue(issueId)
|
||||
.then((iss) => setReactions(iss.reactions ?? []))
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
toast.error("Failed to load reactions");
|
||||
})
|
||||
.catch(console.error)
|
||||
.finally(() => setLoading(false));
|
||||
}, [issueId]);
|
||||
|
||||
|
||||
@@ -7,8 +7,8 @@ import type {
|
||||
SubscriberRemovedPayload,
|
||||
} from "@/shared/types";
|
||||
import { api } from "@/shared/api";
|
||||
import { toast } from "sonner";
|
||||
import { useWSEvent, useWSReconnect } from "@/features/realtime";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export function useIssueSubscribers(issueId: string, userId?: string) {
|
||||
const [subscribers, setSubscribers] = useState<IssueSubscriber[]>([]);
|
||||
@@ -21,10 +21,7 @@ export function useIssueSubscribers(issueId: string, userId?: string) {
|
||||
api
|
||||
.listIssueSubscribers(issueId)
|
||||
.then((subs) => setSubscribers(subs))
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
toast.error("Failed to load subscribers");
|
||||
})
|
||||
.catch(console.error)
|
||||
.finally(() => setLoading(false));
|
||||
}, [issueId]);
|
||||
|
||||
|
||||
@@ -41,10 +41,7 @@ export function useIssueTimeline(issueId: string, userId?: string) {
|
||||
api
|
||||
.listTimeline(issueId)
|
||||
.then((entries) => setTimeline(entries))
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
toast.error("Failed to load activity");
|
||||
})
|
||||
.catch(console.error)
|
||||
.finally(() => setLoading(false));
|
||||
}, [issueId]);
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
export { useIssueStore } from "./store";
|
||||
export { useIssueViewStore, createIssueViewStore } from "./stores/view-store";
|
||||
export { ViewStoreProvider, useViewStore } from "./stores/view-store-context";
|
||||
export { useIssueViewStore } from "./stores/view-store";
|
||||
export { StatusIcon, PriorityIcon, StatusPicker, PriorityPicker, AssigneePicker } from "./components";
|
||||
export * from "./config";
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import { create } from "zustand";
|
||||
import type { Issue } from "@/shared/types";
|
||||
import { toast } from "sonner";
|
||||
import { api } from "@/shared/api";
|
||||
import { createLogger } from "@/shared/logger";
|
||||
|
||||
@@ -35,7 +34,6 @@ export const useIssueStore = create<IssueState>((set, get) => ({
|
||||
set({ issues: res.issues, loading: false });
|
||||
} catch (err) {
|
||||
logger.error("fetch failed", err);
|
||||
toast.error("Failed to load issues");
|
||||
if (isInitialLoad) set({ loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { create } from "zustand";
|
||||
import { persist } from "zustand/middleware";
|
||||
|
||||
export type IssuesScope = "all" | "members" | "agents";
|
||||
|
||||
interface IssuesScopeState {
|
||||
scope: IssuesScope;
|
||||
setScope: (scope: IssuesScope) => void;
|
||||
}
|
||||
|
||||
export const useIssuesScopeStore = create<IssuesScopeState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
scope: "all",
|
||||
setScope: (scope) => set({ scope }),
|
||||
}),
|
||||
{ name: "multica_issues_scope" },
|
||||
),
|
||||
);
|
||||
@@ -1,35 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext } from "react";
|
||||
import { useStore, type StoreApi } from "zustand";
|
||||
import type { IssueViewState } from "./view-store";
|
||||
|
||||
const ViewStoreContext = createContext<StoreApi<IssueViewState> | null>(null);
|
||||
|
||||
export function ViewStoreProvider({
|
||||
store,
|
||||
children,
|
||||
}: {
|
||||
store: StoreApi<IssueViewState>;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<ViewStoreContext.Provider value={store}>
|
||||
{children}
|
||||
</ViewStoreContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useViewStore<T>(selector: (state: IssueViewState) => T): T {
|
||||
const store = useContext(ViewStoreContext);
|
||||
if (!store)
|
||||
throw new Error("useViewStore must be used within ViewStoreProvider");
|
||||
return useStore(store, selector);
|
||||
}
|
||||
|
||||
export function useViewStoreApi(): StoreApi<IssueViewState> {
|
||||
const store = useContext(ViewStoreContext);
|
||||
if (!store)
|
||||
throw new Error("useViewStoreApi must be used within ViewStoreProvider");
|
||||
return store;
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { create } from "zustand";
|
||||
import { createStore, type StoreApi } from "zustand/vanilla";
|
||||
import { persist } from "zustand/middleware";
|
||||
import type { IssueStatus, IssuePriority } from "@/shared/types";
|
||||
import { ALL_STATUSES } from "@/features/issues/config";
|
||||
@@ -37,7 +36,7 @@ export const CARD_PROPERTY_OPTIONS: { key: keyof CardProperties; label: string }
|
||||
{ key: "dueDate", label: "Due date" },
|
||||
];
|
||||
|
||||
export interface IssueViewState {
|
||||
interface IssueViewState {
|
||||
viewMode: ViewMode;
|
||||
statusFilters: IssueStatus[];
|
||||
priorityFilters: IssuePriority[];
|
||||
@@ -63,142 +62,130 @@ export interface IssueViewState {
|
||||
toggleListCollapsed: (status: IssueStatus) => void;
|
||||
}
|
||||
|
||||
export const viewStoreSlice = (set: StoreApi<IssueViewState>["setState"]): IssueViewState => ({
|
||||
viewMode: "board",
|
||||
statusFilters: [],
|
||||
priorityFilters: [],
|
||||
assigneeFilters: [],
|
||||
includeNoAssignee: false,
|
||||
creatorFilters: [],
|
||||
sortBy: "position",
|
||||
sortDirection: "asc",
|
||||
cardProperties: {
|
||||
priority: true,
|
||||
description: true,
|
||||
assignee: true,
|
||||
dueDate: true,
|
||||
},
|
||||
listCollapsedStatuses: [],
|
||||
|
||||
setViewMode: (mode) => set({ viewMode: mode }),
|
||||
toggleStatusFilter: (status) =>
|
||||
set((state) => ({
|
||||
statusFilters: state.statusFilters.includes(status)
|
||||
? state.statusFilters.filter((s) => s !== status)
|
||||
: [...state.statusFilters, status],
|
||||
})),
|
||||
togglePriorityFilter: (priority) =>
|
||||
set((state) => ({
|
||||
priorityFilters: state.priorityFilters.includes(priority)
|
||||
? state.priorityFilters.filter((p) => p !== priority)
|
||||
: [...state.priorityFilters, priority],
|
||||
})),
|
||||
toggleAssigneeFilter: (value) =>
|
||||
set((state) => {
|
||||
const exists = state.assigneeFilters.some(
|
||||
(f) => f.type === value.type && f.id === value.id,
|
||||
);
|
||||
return {
|
||||
assigneeFilters: exists
|
||||
? state.assigneeFilters.filter(
|
||||
(f) => !(f.type === value.type && f.id === value.id),
|
||||
)
|
||||
: [...state.assigneeFilters, value],
|
||||
};
|
||||
}),
|
||||
toggleNoAssignee: () =>
|
||||
set((state) => ({ includeNoAssignee: !state.includeNoAssignee })),
|
||||
toggleCreatorFilter: (value) =>
|
||||
set((state) => {
|
||||
const exists = state.creatorFilters.some(
|
||||
(f) => f.type === value.type && f.id === value.id,
|
||||
);
|
||||
return {
|
||||
creatorFilters: exists
|
||||
? state.creatorFilters.filter(
|
||||
(f) => !(f.type === value.type && f.id === value.id),
|
||||
)
|
||||
: [...state.creatorFilters, value],
|
||||
};
|
||||
}),
|
||||
hideStatus: (status) =>
|
||||
set((state) => {
|
||||
// If no filter active, activate filter with all EXCEPT this one
|
||||
if (state.statusFilters.length === 0) {
|
||||
return { statusFilters: ALL_STATUSES.filter((s) => s !== status) };
|
||||
}
|
||||
return {
|
||||
statusFilters: state.statusFilters.filter((s) => s !== status),
|
||||
};
|
||||
}),
|
||||
showStatus: (status) =>
|
||||
set((state) => {
|
||||
if (state.statusFilters.length === 0) return state;
|
||||
if (state.statusFilters.includes(status)) return state;
|
||||
return { statusFilters: [...state.statusFilters, status] };
|
||||
}),
|
||||
clearFilters: () =>
|
||||
set({
|
||||
export const useIssueViewStore = create<IssueViewState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
viewMode: "board",
|
||||
statusFilters: [],
|
||||
priorityFilters: [],
|
||||
assigneeFilters: [],
|
||||
includeNoAssignee: false,
|
||||
creatorFilters: [],
|
||||
}),
|
||||
setSortBy: (field) => set({ sortBy: field }),
|
||||
setSortDirection: (dir) => set({ sortDirection: dir }),
|
||||
toggleCardProperty: (key) =>
|
||||
set((state) => ({
|
||||
sortBy: "position",
|
||||
sortDirection: "asc",
|
||||
cardProperties: {
|
||||
...state.cardProperties,
|
||||
[key]: !state.cardProperties[key],
|
||||
priority: true,
|
||||
description: true,
|
||||
assignee: true,
|
||||
dueDate: true,
|
||||
},
|
||||
})),
|
||||
toggleListCollapsed: (status) =>
|
||||
set((state) => ({
|
||||
listCollapsedStatuses: state.listCollapsedStatuses.includes(status)
|
||||
? state.listCollapsedStatuses.filter((s) => s !== status)
|
||||
: [...state.listCollapsedStatuses, status],
|
||||
})),
|
||||
});
|
||||
listCollapsedStatuses: [],
|
||||
|
||||
export const viewStorePersistOptions = (name: string) => ({
|
||||
name,
|
||||
partialize: (state: IssueViewState) => ({
|
||||
viewMode: state.viewMode,
|
||||
statusFilters: state.statusFilters,
|
||||
priorityFilters: state.priorityFilters,
|
||||
assigneeFilters: state.assigneeFilters,
|
||||
includeNoAssignee: state.includeNoAssignee,
|
||||
creatorFilters: state.creatorFilters,
|
||||
sortBy: state.sortBy,
|
||||
sortDirection: state.sortDirection,
|
||||
cardProperties: state.cardProperties,
|
||||
listCollapsedStatuses: state.listCollapsedStatuses,
|
||||
}),
|
||||
});
|
||||
|
||||
/** Factory: creates a vanilla StoreApi for use with React Context. */
|
||||
export function createIssueViewStore(persistKey: string): StoreApi<IssueViewState> {
|
||||
return createStore<IssueViewState>()(
|
||||
persist(viewStoreSlice, viewStorePersistOptions(persistKey))
|
||||
);
|
||||
}
|
||||
|
||||
/** Global singleton for the /issues page. */
|
||||
export const useIssueViewStore = create<IssueViewState>()(
|
||||
persist(viewStoreSlice, viewStorePersistOptions("multica_issues_view"))
|
||||
setViewMode: (mode) => set({ viewMode: mode }),
|
||||
toggleStatusFilter: (status) =>
|
||||
set((state) => ({
|
||||
statusFilters: state.statusFilters.includes(status)
|
||||
? state.statusFilters.filter((s) => s !== status)
|
||||
: [...state.statusFilters, status],
|
||||
})),
|
||||
togglePriorityFilter: (priority) =>
|
||||
set((state) => ({
|
||||
priorityFilters: state.priorityFilters.includes(priority)
|
||||
? state.priorityFilters.filter((p) => p !== priority)
|
||||
: [...state.priorityFilters, priority],
|
||||
})),
|
||||
toggleAssigneeFilter: (value) =>
|
||||
set((state) => {
|
||||
const exists = state.assigneeFilters.some(
|
||||
(f) => f.type === value.type && f.id === value.id,
|
||||
);
|
||||
return {
|
||||
assigneeFilters: exists
|
||||
? state.assigneeFilters.filter(
|
||||
(f) => !(f.type === value.type && f.id === value.id),
|
||||
)
|
||||
: [...state.assigneeFilters, value],
|
||||
};
|
||||
}),
|
||||
toggleNoAssignee: () =>
|
||||
set((state) => ({ includeNoAssignee: !state.includeNoAssignee })),
|
||||
toggleCreatorFilter: (value) =>
|
||||
set((state) => {
|
||||
const exists = state.creatorFilters.some(
|
||||
(f) => f.type === value.type && f.id === value.id,
|
||||
);
|
||||
return {
|
||||
creatorFilters: exists
|
||||
? state.creatorFilters.filter(
|
||||
(f) => !(f.type === value.type && f.id === value.id),
|
||||
)
|
||||
: [...state.creatorFilters, value],
|
||||
};
|
||||
}),
|
||||
hideStatus: (status) =>
|
||||
set((state) => {
|
||||
// If no filter active, activate filter with all EXCEPT this one
|
||||
if (state.statusFilters.length === 0) {
|
||||
return { statusFilters: ALL_STATUSES.filter((s) => s !== status) };
|
||||
}
|
||||
return {
|
||||
statusFilters: state.statusFilters.filter((s) => s !== status),
|
||||
};
|
||||
}),
|
||||
showStatus: (status) =>
|
||||
set((state) => {
|
||||
if (state.statusFilters.length === 0) return state;
|
||||
if (state.statusFilters.includes(status)) return state;
|
||||
return { statusFilters: [...state.statusFilters, status] };
|
||||
}),
|
||||
clearFilters: () =>
|
||||
set({
|
||||
statusFilters: [],
|
||||
priorityFilters: [],
|
||||
assigneeFilters: [],
|
||||
includeNoAssignee: false,
|
||||
creatorFilters: [],
|
||||
}),
|
||||
setSortBy: (field) => set({ sortBy: field }),
|
||||
setSortDirection: (dir) => set({ sortDirection: dir }),
|
||||
toggleCardProperty: (key) =>
|
||||
set((state) => ({
|
||||
cardProperties: {
|
||||
...state.cardProperties,
|
||||
[key]: !state.cardProperties[key],
|
||||
},
|
||||
})),
|
||||
toggleListCollapsed: (status) =>
|
||||
set((state) => ({
|
||||
listCollapsedStatuses: state.listCollapsedStatuses.includes(status)
|
||||
? state.listCollapsedStatuses.filter((s) => s !== status)
|
||||
: [...state.listCollapsedStatuses, status],
|
||||
})),
|
||||
}),
|
||||
{
|
||||
name: "multica_issues_view",
|
||||
partialize: (state) => ({
|
||||
viewMode: state.viewMode,
|
||||
statusFilters: state.statusFilters,
|
||||
priorityFilters: state.priorityFilters,
|
||||
assigneeFilters: state.assigneeFilters,
|
||||
includeNoAssignee: state.includeNoAssignee,
|
||||
creatorFilters: state.creatorFilters,
|
||||
sortBy: state.sortBy,
|
||||
sortDirection: state.sortDirection,
|
||||
cardProperties: state.cardProperties,
|
||||
listCollapsedStatuses: state.listCollapsedStatuses,
|
||||
}),
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// Clear filters on all registered view stores when workspace switches.
|
||||
// Clear actor-based filters when workspace switches (IDs are workspace-scoped).
|
||||
// Deferred to avoid circular dependency: view-store → workspace → issues → view-store.
|
||||
const _syncedStores = new Set<StoreApi<IssueViewState>>();
|
||||
let _workspaceSyncInitialized = false;
|
||||
|
||||
export function registerViewStoreForWorkspaceSync(store: StoreApi<IssueViewState>) {
|
||||
_syncedStores.add(store);
|
||||
if (_workspaceSyncInitialized) return;
|
||||
_workspaceSyncInitialized = true;
|
||||
let _filterSubInitialized = false;
|
||||
export function initFilterWorkspaceSync() {
|
||||
if (_filterSubInitialized) return;
|
||||
_filterSubInitialized = true;
|
||||
|
||||
// Dynamic import breaks the circular module evaluation chain.
|
||||
import("@/features/workspace").then(({ useWorkspaceStore }) => {
|
||||
@@ -206,13 +193,9 @@ export function registerViewStoreForWorkspaceSync(store: StoreApi<IssueViewState
|
||||
useWorkspaceStore.subscribe((state) => {
|
||||
const id = state.workspace?.id;
|
||||
if (prevId && id !== prevId) {
|
||||
for (const s of _syncedStores) s.getState().clearFilters();
|
||||
useIssueViewStore.getState().clearFilters();
|
||||
}
|
||||
prevId = id;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** Backward-compatible alias — registers the global singleton for workspace sync. */
|
||||
export const initFilterWorkspaceSync = () =>
|
||||
registerViewStoreForWorkspaceSync(useIssueViewStore);
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { LandingHeader } from "./landing-header";
|
||||
import { LandingFooter } from "./landing-footer";
|
||||
import { GitHubMark, githubUrl } from "./shared";
|
||||
import { useLocale } from "../i18n";
|
||||
|
||||
export function AboutPageClient() {
|
||||
const { t } = useLocale();
|
||||
const n = t.about.nameLine;
|
||||
|
||||
return (
|
||||
<>
|
||||
<LandingHeader variant="light" />
|
||||
<main className="bg-white text-[#0a0d12]">
|
||||
<div className="mx-auto max-w-[720px] px-4 py-16 sm:px-6 sm:py-20 lg:py-24">
|
||||
<h1 className="font-[family-name:var(--font-serif)] text-[2.6rem] leading-[1.05] tracking-[-0.03em] sm:text-[3.4rem]">
|
||||
{t.about.title}
|
||||
</h1>
|
||||
<div className="mt-8 space-y-6 text-[15px] leading-[1.8] text-[#0a0d12]/70 sm:text-[16px]">
|
||||
<p>
|
||||
{n.prefix}
|
||||
<strong className="font-semibold text-[#0a0d12]">
|
||||
{n.mul}
|
||||
</strong>
|
||||
{n.tiplexed}
|
||||
<strong className="font-semibold text-[#0a0d12]">
|
||||
{n.i}
|
||||
</strong>
|
||||
{n.nformationAnd}
|
||||
<strong className="font-semibold text-[#0a0d12]">
|
||||
{n.c}
|
||||
</strong>
|
||||
{n.omputing}
|
||||
<strong className="font-semibold text-[#0a0d12]">
|
||||
{n.a}
|
||||
</strong>
|
||||
{n.gent}
|
||||
</p>
|
||||
{t.about.paragraphs.map((p, i) => (
|
||||
<p key={i}>{p}</p>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-12">
|
||||
<Link
|
||||
href={githubUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-2.5 rounded-[12px] bg-[#0a0d12] px-5 py-3 text-[14px] font-semibold text-white transition-colors hover:bg-[#0a0d12]/88"
|
||||
>
|
||||
<GitHubMark className="size-4" />
|
||||
{t.about.cta}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<LandingFooter />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { LandingHeader } from "./landing-header";
|
||||
import { LandingFooter } from "./landing-footer";
|
||||
import { useLocale } from "../i18n";
|
||||
|
||||
export function ChangelogPageClient() {
|
||||
const { t } = useLocale();
|
||||
|
||||
return (
|
||||
<>
|
||||
<LandingHeader variant="light" />
|
||||
<main className="bg-white text-[#0a0d12]">
|
||||
<div className="mx-auto max-w-[720px] px-4 py-16 sm:px-6 sm:py-20 lg:py-24">
|
||||
<h1 className="font-[family-name:var(--font-serif)] text-[2.6rem] leading-[1.05] tracking-[-0.03em] sm:text-[3.4rem]">
|
||||
{t.changelog.title}
|
||||
</h1>
|
||||
<p className="mt-4 text-[15px] leading-7 text-[#0a0d12]/60 sm:text-[16px]">
|
||||
{t.changelog.subtitle}
|
||||
</p>
|
||||
|
||||
<div className="mt-16 space-y-16">
|
||||
{t.changelog.entries.map((release) => (
|
||||
<div key={release.version} className="relative">
|
||||
<div className="flex items-baseline gap-3">
|
||||
<span className="text-[13px] font-semibold tabular-nums">
|
||||
v{release.version}
|
||||
</span>
|
||||
<span className="text-[13px] text-[#0a0d12]/40">
|
||||
{release.date}
|
||||
</span>
|
||||
</div>
|
||||
<h2 className="mt-2 text-[20px] font-semibold leading-snug sm:text-[22px]">
|
||||
{release.title}
|
||||
</h2>
|
||||
<ul className="mt-4 space-y-2">
|
||||
{release.changes.map((change) => (
|
||||
<li
|
||||
key={change}
|
||||
className="flex items-start gap-2.5 text-[14px] leading-[1.7] text-[#0a0d12]/60 sm:text-[15px]"
|
||||
>
|
||||
<span className="mt-2.5 h-1 w-1 shrink-0 rounded-full bg-[#0a0d12]/30" />
|
||||
{change}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<LandingFooter />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useLocale } from "../i18n";
|
||||
|
||||
export function FAQSection() {
|
||||
const { t } = useLocale();
|
||||
const [openIndex, setOpenIndex] = useState<number | null>(null);
|
||||
|
||||
return (
|
||||
<section id="faq" className="bg-[#f8f8f8] text-[#0a0d12]">
|
||||
<div className="mx-auto max-w-[860px] px-4 py-24 sm:px-6 sm:py-32 lg:py-40">
|
||||
<div className="text-center">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-[#0a0d12]/40">
|
||||
{t.faq.label}
|
||||
</p>
|
||||
<h2 className="mt-4 font-[family-name:var(--font-serif)] text-[2.6rem] leading-[1.05] tracking-[-0.03em] sm:text-[3.4rem] lg:text-[4.2rem]">
|
||||
{t.faq.headline}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="mt-14 divide-y divide-[#0a0d12]/10 sm:mt-16">
|
||||
{t.faq.items.map((faq, i) => (
|
||||
<div key={i}>
|
||||
<button
|
||||
onClick={() => setOpenIndex(openIndex === i ? null : i)}
|
||||
className="flex w-full items-start justify-between gap-4 py-6 text-left"
|
||||
>
|
||||
<span className="text-[16px] font-semibold leading-snug text-[#0a0d12] sm:text-[17px]">
|
||||
{faq.question}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"mt-0.5 flex size-6 shrink-0 items-center justify-center rounded-full border border-[#0a0d12]/12 text-[#0a0d12]/40 transition-transform",
|
||||
openIndex === i && "rotate-45",
|
||||
)}
|
||||
>
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 12 12"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
>
|
||||
<path d="M6 1v10M1 6h10" />
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
<div
|
||||
className={cn(
|
||||
"grid transition-[grid-template-rows] duration-200 ease-out",
|
||||
openIndex === i ? "grid-rows-[1fr]" : "grid-rows-[0fr]",
|
||||
)}
|
||||
>
|
||||
<div className="overflow-hidden">
|
||||
<p className="pb-6 pr-12 text-[14px] leading-[1.7] text-[#0a0d12]/56 sm:text-[15px]">
|
||||
{faq.answer}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,60 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useAuthStore } from "@/features/auth";
|
||||
import { useLocale } from "../i18n";
|
||||
import { GitHubMark, githubUrl, heroButtonClassName } from "./shared";
|
||||
|
||||
export function HowItWorksSection() {
|
||||
const { t } = useLocale();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
|
||||
return (
|
||||
<section id="how-it-works" className="bg-[#05070b] text-white">
|
||||
<div className="mx-auto max-w-[1320px] px-4 py-24 sm:px-6 sm:py-32 lg:px-8 lg:py-40">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-white/40">
|
||||
{t.howItWorks.label}
|
||||
</p>
|
||||
<h2 className="mt-4 font-[family-name:var(--font-serif)] text-[2.6rem] leading-[1.05] tracking-[-0.03em] sm:text-[3.4rem] lg:text-[4.2rem]">
|
||||
{t.howItWorks.headlineMain}
|
||||
<br />
|
||||
<span className="text-white/40">{t.howItWorks.headlineFaded}</span>
|
||||
</h2>
|
||||
|
||||
<div className="mt-20 grid gap-px overflow-hidden rounded-2xl border border-white/10 bg-white/10 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{t.howItWorks.steps.map((step, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex flex-col bg-[#05070b] p-8 lg:p-10"
|
||||
>
|
||||
<span className="text-[13px] font-semibold tabular-nums text-white/28">
|
||||
{String(i + 1).padStart(2, "0")}
|
||||
</span>
|
||||
<h3 className="mt-4 text-[17px] font-semibold leading-snug text-white sm:text-[18px]">
|
||||
{step.title}
|
||||
</h3>
|
||||
<p className="mt-3 text-[14px] leading-[1.7] text-white/50 sm:text-[15px]">
|
||||
{step.description}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-14 flex flex-wrap items-center gap-4">
|
||||
<Link href={user ? "/issues" : "/login"} className={heroButtonClassName("solid")}>
|
||||
{user ? t.header.dashboard : t.howItWorks.cta}
|
||||
</Link>
|
||||
<Link
|
||||
href={githubUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className={heroButtonClassName("ghost")}
|
||||
>
|
||||
<GitHubMark className="size-4" />
|
||||
{t.howItWorks.ctaGithub}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { MulticaIcon } from "@/components/multica-icon";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useAuthStore } from "@/features/auth";
|
||||
import { useLocale, locales, localeLabels } from "../i18n";
|
||||
|
||||
export function LandingFooter() {
|
||||
const { t, locale, setLocale } = useLocale();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const groups = Object.values(t.footer.groups);
|
||||
|
||||
return (
|
||||
<footer className="bg-[#0a0d12] text-white">
|
||||
<div className="mx-auto max-w-[1320px] px-4 sm:px-6 lg:px-8">
|
||||
{/* Top: CTA + link columns */}
|
||||
<div className="flex flex-col gap-12 border-b border-white/10 py-16 sm:py-20 lg:flex-row lg:gap-20">
|
||||
{/* Left — newsletter / CTA */}
|
||||
<div className="lg:w-[340px] lg:shrink-0">
|
||||
<Link href="#product" className="flex items-center gap-3">
|
||||
<MulticaIcon className="size-5 text-white" noSpin />
|
||||
<span className="text-[18px] font-semibold tracking-[0.04em] lowercase">
|
||||
multica
|
||||
</span>
|
||||
</Link>
|
||||
<p className="mt-4 max-w-[300px] text-[14px] leading-[1.7] text-white/50 sm:text-[15px]">
|
||||
{t.footer.tagline}
|
||||
</p>
|
||||
<div className="mt-6">
|
||||
<Link
|
||||
href={user ? "/issues" : "/login"}
|
||||
className="inline-flex items-center justify-center rounded-[11px] bg-white px-5 py-2.5 text-[13px] font-semibold text-[#0a0d12] transition-colors hover:bg-white/88"
|
||||
>
|
||||
{user ? t.header.dashboard : t.footer.cta}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right — link columns */}
|
||||
<div className="grid flex-1 grid-cols-2 gap-8 sm:grid-cols-4">
|
||||
{groups.map((group) => (
|
||||
<div key={group.label}>
|
||||
<h4 className="text-[12px] font-semibold uppercase tracking-[0.1em] text-white/40">
|
||||
{group.label}
|
||||
</h4>
|
||||
<ul className="mt-4 flex flex-col gap-2.5">
|
||||
{group.links.map((link) => (
|
||||
<li key={link.label}>
|
||||
<Link
|
||||
href={link.href}
|
||||
{...(link.href.startsWith("http")
|
||||
? { target: "_blank", rel: "noreferrer" }
|
||||
: {})}
|
||||
className="text-[14px] text-white/50 transition-colors hover:text-white"
|
||||
>
|
||||
{link.label}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom: copyright + language switcher */}
|
||||
<div className="flex items-center justify-between py-6">
|
||||
<p className="text-[13px] text-white/36">
|
||||
{t.footer.copyright.replace(
|
||||
"{year}",
|
||||
String(new Date().getFullYear()),
|
||||
)}
|
||||
</p>
|
||||
<div className="flex items-center">
|
||||
{locales.map((l, i) => (
|
||||
<button
|
||||
key={l}
|
||||
onClick={() => setLocale(l)}
|
||||
className={cn(
|
||||
"px-1.5 py-1 text-[12px] font-medium transition-colors",
|
||||
l === locale
|
||||
? "text-white/70"
|
||||
: "text-white/30 hover:text-white/50",
|
||||
i > 0 && "border-l border-white/16",
|
||||
)}
|
||||
>
|
||||
{localeLabels[l]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Giant logo */}
|
||||
<div className="relative overflow-hidden pb-4">
|
||||
<div className="flex items-end gap-6 sm:gap-8">
|
||||
<MulticaIcon
|
||||
className="size-[clamp(4rem,12vw,10rem)] shrink-0 text-white"
|
||||
noSpin
|
||||
/>
|
||||
<span className="font-[family-name:var(--font-serif)] text-[clamp(6rem,22vw,16rem)] font-normal leading-[0.82] tracking-[-0.04em] text-white lowercase">
|
||||
multica
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { MulticaIcon } from "@/components/multica-icon";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useAuthStore } from "@/features/auth";
|
||||
import { useLocale } from "../i18n";
|
||||
import { GitHubMark, githubUrl, headerButtonClassName } from "./shared";
|
||||
|
||||
export function LandingHeader({
|
||||
variant = "dark",
|
||||
}: {
|
||||
variant?: "dark" | "light";
|
||||
}) {
|
||||
const { t } = useLocale();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
|
||||
return (
|
||||
<header
|
||||
className={cn(
|
||||
"inset-x-0 top-0 z-30",
|
||||
variant === "dark"
|
||||
? "absolute bg-transparent"
|
||||
: "border-b border-[#0a0d12]/8 bg-white",
|
||||
)}
|
||||
>
|
||||
<div className="mx-auto flex h-[76px] max-w-[1320px] items-center justify-between px-4 sm:px-6 lg:px-8">
|
||||
<Link href="/" className="flex items-center gap-3">
|
||||
<MulticaIcon
|
||||
className={cn(
|
||||
"size-5",
|
||||
variant === "dark" ? "text-white" : "text-[#0a0d12]",
|
||||
)}
|
||||
noSpin
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
"text-[18px] font-semibold tracking-[0.04em] lowercase sm:text-[20px]",
|
||||
variant === "dark" ? "text-white/92" : "text-[#0a0d12]",
|
||||
)}
|
||||
>
|
||||
multica
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center gap-2.5 sm:gap-3">
|
||||
<Link
|
||||
href={githubUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className={headerButtonClassName("ghost", variant)}
|
||||
>
|
||||
<GitHubMark className="size-3.5" />
|
||||
{t.header.github}
|
||||
</Link>
|
||||
<Link
|
||||
href={user ? "/issues" : "/login"}
|
||||
className={headerButtonClassName("solid", variant)}
|
||||
>
|
||||
{user ? t.header.dashboard : t.header.login}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useAuthStore } from "@/features/auth";
|
||||
import { useLocale } from "../i18n";
|
||||
import {
|
||||
ClaudeCodeLogo,
|
||||
CodexLogo,
|
||||
GitHubMark,
|
||||
githubUrl,
|
||||
heroButtonClassName,
|
||||
} from "./shared";
|
||||
|
||||
export function LandingHero() {
|
||||
const { t } = useLocale();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
|
||||
return (
|
||||
<div className="relative min-h-full overflow-hidden bg-[#05070b] text-white">
|
||||
<LandingBackdrop />
|
||||
|
||||
<main className="relative z-10">
|
||||
<section
|
||||
id="product"
|
||||
className="mx-auto max-w-[1320px] px-4 pb-16 pt-28 sm:px-6 sm:pt-32 lg:px-8 lg:pb-24 lg:pt-36"
|
||||
>
|
||||
<div className="mx-auto max-w-[1120px] text-center">
|
||||
<h1 className="font-[family-name:var(--font-serif)] text-[3.65rem] leading-[0.93] tracking-[-0.038em] text-white drop-shadow-[0_10px_34px_rgba(0,0,0,0.32)] sm:text-[4.85rem] lg:text-[6.4rem]">
|
||||
{t.hero.headlineLine1}
|
||||
<br />
|
||||
{t.hero.headlineLine2}
|
||||
</h1>
|
||||
|
||||
<p className="mx-auto mt-7 max-w-[820px] text-[15px] leading-7 text-white/84 sm:text-[17px]">
|
||||
{t.hero.subheading}
|
||||
</p>
|
||||
|
||||
<div className="mt-8 flex flex-wrap items-center justify-center gap-3">
|
||||
<Link href={user ? "/issues" : "/login"} className={heroButtonClassName("solid")}>
|
||||
{user ? t.header.dashboard : t.hero.cta}
|
||||
</Link>
|
||||
<Link
|
||||
href={githubUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className={heroButtonClassName("ghost")}
|
||||
>
|
||||
<GitHubMark className="size-4" />
|
||||
GitHub
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-10 flex items-center justify-center gap-8">
|
||||
<span className="text-[15px] text-white/50">
|
||||
{t.hero.worksWith}
|
||||
</span>
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="flex items-center gap-2.5 text-white/80">
|
||||
<ClaudeCodeLogo className="size-5" />
|
||||
<span className="text-[15px] font-medium">Claude Code</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2.5 text-white/80">
|
||||
<CodexLogo className="size-5" />
|
||||
<span className="text-[15px] font-medium">Codex</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="preview" className="mt-10 sm:mt-12">
|
||||
<ProductImage alt={t.hero.imageAlt} />
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LandingBackdrop() {
|
||||
return (
|
||||
<div className="pointer-events-none absolute inset-0">
|
||||
<Image
|
||||
src="/images/landing-bg.jpg"
|
||||
alt=""
|
||||
fill
|
||||
priority
|
||||
className="object-cover object-center"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProductImage({ alt }: { alt: string }) {
|
||||
return (
|
||||
<div>
|
||||
<div className="relative overflow-hidden border border-white/14">
|
||||
<Image
|
||||
src="/images/landing-hero.png"
|
||||
alt={alt}
|
||||
width={3532}
|
||||
height={2382}
|
||||
className="block h-auto w-full"
|
||||
sizes="(max-width: 1320px) 100vw, 1320px"
|
||||
quality={85}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { LandingHeader } from "./landing-header";
|
||||
import { LandingHero } from "./landing-hero";
|
||||
import { FeaturesSection } from "./features-section";
|
||||
import { HowItWorksSection } from "./how-it-works-section";
|
||||
import { OpenSourceSection } from "./open-source-section";
|
||||
import { FAQSection } from "./faq-section";
|
||||
import { LandingFooter } from "./landing-footer";
|
||||
|
||||
export function MulticaLanding() {
|
||||
return (
|
||||
<>
|
||||
<div className="relative">
|
||||
<LandingHeader />
|
||||
<LandingHero />
|
||||
</div>
|
||||
|
||||
<FeaturesSection />
|
||||
<HowItWorksSection />
|
||||
<OpenSourceSection />
|
||||
<FAQSection />
|
||||
<LandingFooter />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useLocale } from "../i18n";
|
||||
import { GitHubMark, githubUrl } from "./shared";
|
||||
|
||||
export function OpenSourceSection() {
|
||||
const { t } = useLocale();
|
||||
|
||||
return (
|
||||
<section id="open-source" className="bg-white text-[#0a0d12]">
|
||||
<div className="mx-auto max-w-[1320px] px-4 py-24 sm:px-6 sm:py-32 lg:px-8 lg:py-40">
|
||||
<div className="flex flex-col gap-16 lg:flex-row lg:items-start lg:gap-24">
|
||||
{/* Left column — heading + CTA */}
|
||||
<div className="lg:w-[480px] lg:shrink-0">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-[#0a0d12]/40">
|
||||
{t.openSource.label}
|
||||
</p>
|
||||
<h2 className="mt-4 font-[family-name:var(--font-serif)] text-[2.6rem] leading-[1.05] tracking-[-0.03em] sm:text-[3.4rem] lg:text-[4.2rem]">
|
||||
{t.openSource.headlineLine1}
|
||||
<br />
|
||||
{t.openSource.headlineLine2}
|
||||
</h2>
|
||||
<p className="mt-6 max-w-[420px] text-[15px] leading-7 text-[#0a0d12]/60 sm:text-[16px]">
|
||||
{t.openSource.description}
|
||||
</p>
|
||||
<div className="mt-8 flex flex-wrap items-center gap-3">
|
||||
<Link
|
||||
href={githubUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center justify-center gap-2.5 rounded-[12px] bg-[#0a0d12] px-5 py-3 text-[14px] font-semibold text-white transition-colors hover:bg-[#0a0d12]/88"
|
||||
>
|
||||
<GitHubMark className="size-4" />
|
||||
{t.openSource.cta}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right column — highlight grid */}
|
||||
<div className="flex-1">
|
||||
<div className="grid gap-px overflow-hidden rounded-2xl border border-[#0a0d12]/8 bg-[#0a0d12]/8 sm:grid-cols-2">
|
||||
{t.openSource.highlights.map((item) => (
|
||||
<div key={item.title} className="bg-white p-8 lg:p-10">
|
||||
<h3 className="text-[17px] font-semibold leading-snug text-[#0a0d12] sm:text-[18px]">
|
||||
{item.title}
|
||||
</h3>
|
||||
<p className="mt-3 text-[14px] leading-[1.7] text-[#0a0d12]/56 sm:text-[15px]">
|
||||
{item.description}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export const githubUrl = "https://github.com/multica-ai/multica";
|
||||
|
||||
export function GitHubMark({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 16 16"
|
||||
aria-hidden="true"
|
||||
className={className}
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M8 0C3.58 0 0 3.58 0 8a8 8 0 0 0 5.47 7.59c.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2 .37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82A7.65 7.65 0 0 1 8 4.84c.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function ImageIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
className={className}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<rect x="3.5" y="5" width="17" height="14" rx="2.5" />
|
||||
<circle cx="9" cy="10" r="1.6" />
|
||||
<path d="m20.5 16-4.8-4.8a1 1 0 0 0-1.4 0L8 17.5" />
|
||||
<path d="m11.5 14.5 1.8-1.8a1 1 0 0 1 1.4 0l2.8 2.8" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function ClaudeCodeLogo({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 248 248"
|
||||
aria-hidden="true"
|
||||
className={className}
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M52.4285 162.873L98.7844 136.879L99.5485 134.602L98.7844 133.334H96.4921L88.7237 132.862L62.2346 132.153L39.3113 131.207L17.0249 130.026L11.4214 128.844L6.2 121.873L6.7094 118.447L11.4214 115.257L18.171 115.847L33.0711 116.911L55.485 118.447L71.6586 119.392L95.728 121.873H99.5485L100.058 120.337L98.7844 119.392L97.7656 118.447L74.5877 102.732L49.4995 86.1905L36.3823 76.62L29.3779 71.7757L25.8121 67.2858L24.2839 57.3608L30.6515 50.2716L39.3113 50.8623L41.4763 51.4531L50.2636 58.1879L68.9842 72.7209L93.4357 90.6804L97.0015 93.6343L98.4374 92.6652L98.6571 91.9801L97.0015 89.2625L83.757 65.2772L69.621 40.8192L63.2534 30.6579L61.5978 24.632C60.9565 22.1032 60.579 20.0111 60.579 17.4246L67.8381 7.49965L71.9133 6.19995L81.7193 7.49965L85.7946 11.0443L91.9074 24.9865L101.714 46.8451L116.996 76.62L121.453 85.4816L123.873 93.6343L124.764 96.1155H126.292V94.6976L127.566 77.9197L129.858 57.3608L132.15 30.8942L132.915 23.4505L136.608 14.4708L143.994 9.62643L149.725 12.344L154.437 19.0788L153.8 23.4505L150.998 41.6463L145.522 70.1215L141.957 89.2625H143.994L146.414 86.7813L156.093 74.0206L172.266 53.698L179.398 45.6635L187.803 36.802L193.152 32.5484H203.34L210.726 43.6549L207.415 55.1159L196.972 68.3492L188.312 79.5739L175.896 96.2095L168.191 109.585L168.882 110.689L170.738 110.53L198.755 104.504L213.91 101.787L231.994 98.7149L240.144 102.496L241.036 106.395L237.852 114.311L218.495 119.037L195.826 123.645L162.07 131.592L161.696 131.893L162.137 132.547L177.36 133.925L183.855 134.279H199.774L229.447 136.524L237.215 141.605L241.8 147.867L241.036 152.711L229.065 158.737L213.019 154.956L175.45 145.977L162.587 142.787H160.805V143.85L171.502 154.366L191.242 172.089L215.82 195.011L217.094 200.682L213.91 205.172L210.599 204.699L188.949 188.394L180.544 181.069L161.696 165.118H160.422V166.772L164.752 173.152L187.803 207.771L188.949 218.405L187.294 221.832L181.308 223.959L174.813 222.777L161.187 203.754L147.305 182.486L136.098 163.345L134.745 164.2L128.075 235.42L125.019 239.082L117.887 241.8L111.902 237.31L108.718 229.984L111.902 215.452L115.722 196.547L118.779 181.541L121.58 162.873L123.291 156.636L123.14 156.219L121.773 156.449L107.699 175.752L86.304 204.699L69.3663 222.777L65.291 224.431L58.2867 220.768L58.9235 214.27L62.8713 208.48L86.304 178.705L100.44 160.155L109.551 149.507L109.462 147.967L108.959 147.924L46.6977 188.512L35.6182 189.93L30.7788 185.44L31.4156 178.115L33.7079 175.752L52.4285 162.873Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function CodexLogo({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
className={className}
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M22.282 9.821a5.985 5.985 0 0 0-.516-4.91 6.046 6.046 0 0 0-6.51-2.9A6.065 6.065 0 0 0 4.981 4.18a5.985 5.985 0 0 0-3.998 2.9 6.046 6.046 0 0 0 .743 7.097 5.98 5.98 0 0 0 .51 4.911 6.051 6.051 0 0 0 6.515 2.9A5.985 5.985 0 0 0 13.26 24a6.056 6.056 0 0 0 5.772-4.206 5.99 5.99 0 0 0 3.997-2.9 6.056 6.056 0 0 0-.747-7.073ZM13.26 22.43a4.476 4.476 0 0 1-2.876-1.04l.141-.081 4.779-2.758a.795.795 0 0 0 .392-.681v-6.737l2.02 1.168a.071.071 0 0 1 .038.052v5.583a4.504 4.504 0 0 1-4.494 4.494ZM3.6 18.304a4.47 4.47 0 0 1-.535-3.014l.142.085 4.783 2.759a.771.771 0 0 0 .78 0l5.843-3.369v2.332a.08.08 0 0 1-.033.062L9.74 19.95a4.5 4.5 0 0 1-6.14-1.646ZM2.34 7.896a4.485 4.485 0 0 1 2.366-1.973V11.6a.766.766 0 0 0 .388.676l5.815 3.355-2.02 1.168a.076.076 0 0 1-.071 0l-4.83-2.786A4.504 4.504 0 0 1 2.34 7.872v.024Zm16.597 3.855-5.833-3.387L15.119 7.2a.076.076 0 0 1 .071 0l4.83 2.791a4.494 4.494 0 0 1-.676 8.105v-5.678a.79.79 0 0 0-.407-.667Zm2.01-3.023-.141-.085-4.774-2.782a.776.776 0 0 0-.785 0L9.409 9.23V6.897a.066.066 0 0 1 .028-.061l4.83-2.787a4.5 4.5 0 0 1 6.68 4.66v.018ZM8.318 12.898l-2.024-1.168a.074.074 0 0 1-.038-.052V6.095a4.494 4.494 0 0 1 7.37-3.456l-.14.081-4.78 2.758a.795.795 0 0 0-.392.681l-.014 6.739h.018Zm1.1-2.36 2.602-1.5 2.595 1.5v2.999l-2.595 1.5-2.602-1.5v-3Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function headerButtonClassName(
|
||||
tone: "ghost" | "solid",
|
||||
variant: "dark" | "light" = "dark",
|
||||
) {
|
||||
return cn(
|
||||
"inline-flex items-center justify-center gap-2 rounded-[11px] px-4 py-2.5 text-[13px] font-semibold transition-colors",
|
||||
variant === "dark"
|
||||
? tone === "solid"
|
||||
? "bg-white text-[#0a0d12] hover:bg-white/92"
|
||||
: "border border-white/18 bg-black/16 text-white backdrop-blur-sm hover:bg-black/24"
|
||||
: tone === "solid"
|
||||
? "bg-[#0a0d12] text-white hover:bg-[#0a0d12]/88"
|
||||
: "border border-[#0a0d12]/12 bg-white text-[#0a0d12] hover:bg-[#0a0d12]/5",
|
||||
);
|
||||
}
|
||||
|
||||
export function heroButtonClassName(tone: "ghost" | "solid") {
|
||||
return cn(
|
||||
"inline-flex items-center justify-center gap-2 rounded-[12px] px-5 py-3 text-[14px] font-semibold transition-colors",
|
||||
tone === "solid"
|
||||
? "bg-white text-[#0a0d12] hover:bg-white/92"
|
||||
: "border border-white/18 bg-black/16 text-white backdrop-blur-sm hover:bg-black/24",
|
||||
);
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext, useState, useCallback } from "react";
|
||||
import { en } from "./en";
|
||||
import { zh } from "./zh";
|
||||
import type { LandingDict, Locale } from "./types";
|
||||
|
||||
const dictionaries: Record<Locale, LandingDict> = { en, zh };
|
||||
|
||||
const COOKIE_NAME = "multica-locale";
|
||||
const COOKIE_MAX_AGE = 60 * 60 * 24 * 365; // 1 year
|
||||
|
||||
type LocaleContextValue = {
|
||||
locale: Locale;
|
||||
t: LandingDict;
|
||||
setLocale: (locale: Locale) => void;
|
||||
};
|
||||
|
||||
const LocaleContext = createContext<LocaleContextValue | null>(null);
|
||||
|
||||
export function LocaleProvider({
|
||||
children,
|
||||
initialLocale = "en",
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
initialLocale?: Locale;
|
||||
}) {
|
||||
const [locale, setLocaleState] = useState<Locale>(initialLocale);
|
||||
|
||||
const setLocale = useCallback((l: Locale) => {
|
||||
setLocaleState(l);
|
||||
document.cookie = `${COOKIE_NAME}=${l}; path=/; max-age=${COOKIE_MAX_AGE}; SameSite=Lax`;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<LocaleContext.Provider
|
||||
value={{ locale, t: dictionaries[locale], setLocale }}
|
||||
>
|
||||
{children}
|
||||
</LocaleContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useLocale() {
|
||||
const ctx = useContext(LocaleContext);
|
||||
if (!ctx) throw new Error("useLocale must be used within LocaleProvider");
|
||||
return ctx;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user