mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-25 16:39:33 +02:00
Compare commits
1 Commits
feat/cli-v
...
agent/emac
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f5d8daf902 |
@@ -1,38 +0,0 @@
|
||||
# Dependencies
|
||||
node_modules
|
||||
.pnpm-store
|
||||
|
||||
# Build outputs
|
||||
.next
|
||||
dist
|
||||
server/bin
|
||||
server/tmp
|
||||
|
||||
# Git
|
||||
.git
|
||||
.gitignore
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# IDE
|
||||
.idea
|
||||
.vscode
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Test
|
||||
e2e/test-results
|
||||
coverage
|
||||
|
||||
# Docs
|
||||
docs/
|
||||
|
||||
# Desktop app (not needed for web self-hosting)
|
||||
apps/desktop
|
||||
6
.gitattributes
vendored
6
.gitattributes
vendored
@@ -1,6 +0,0 @@
|
||||
# Ensure shell scripts always use LF line endings (needed for Docker on Windows)
|
||||
*.sh text eol=lf
|
||||
docker/entrypoint.sh text eol=lf
|
||||
|
||||
# Default behavior
|
||||
* text=auto
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -41,9 +41,6 @@ apps/web/test-results/
|
||||
# feature tracking
|
||||
_features/
|
||||
|
||||
# runtime
|
||||
*.pid
|
||||
|
||||
# platform specific
|
||||
*.dmg
|
||||
*.app
|
||||
|
||||
@@ -11,7 +11,6 @@ builds:
|
||||
- -s -w
|
||||
- -X main.version={{.Version}}
|
||||
- -X main.commit={{.ShortCommit}}
|
||||
- -X main.date={{.Date}}
|
||||
env:
|
||||
- CGO_ENABLED=0
|
||||
goos:
|
||||
|
||||
@@ -57,10 +57,7 @@ The architecture relies on a strict split between server state and client state.
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
# One-command dev (auto-setup + start everything)
|
||||
make dev # Auto-creates env, installs deps, starts DB, migrates, launches app
|
||||
|
||||
# Explicit setup & run (if you prefer separate steps)
|
||||
# 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
|
||||
@@ -76,7 +73,7 @@ pnpm lint # ESLint
|
||||
pnpm test # TS tests (Vitest, all packages + apps via turbo)
|
||||
|
||||
# Backend (Go)
|
||||
make server # Run Go server only (port 8080)
|
||||
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")
|
||||
@@ -116,8 +113,6 @@ CI runs on Node 22 and Go 1.26.1 with a `pgvector/pgvector:pg17` PostgreSQL serv
|
||||
|
||||
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`.
|
||||
|
||||
`make dev` auto-detects worktrees and handles everything. For explicit control:
|
||||
|
||||
```bash
|
||||
make worktree-env # Generate .env.worktree with unique DB/ports
|
||||
make setup-worktree # Setup using .env.worktree
|
||||
|
||||
@@ -30,16 +30,6 @@ This auto-detects your installation method (Homebrew or manual) and upgrades acc
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# One-command setup: configure, authenticate, and start the daemon
|
||||
multica setup
|
||||
|
||||
# For self-hosted (local) deployments:
|
||||
multica setup --local
|
||||
```
|
||||
|
||||
Or step by step:
|
||||
|
||||
```bash
|
||||
# 1. Authenticate (opens browser for login)
|
||||
multica login
|
||||
@@ -172,31 +162,23 @@ Agent-specific overrides:
|
||||
|
||||
### Self-Hosted Server
|
||||
|
||||
When connecting to a self-hosted Multica instance, the easiest approach is:
|
||||
When connecting to a self-hosted Multica instance, point the CLI to your server before logging in:
|
||||
|
||||
```bash
|
||||
# One command — auto-detects local server, configures, authenticates, starts daemon
|
||||
multica setup --local
|
||||
```
|
||||
|
||||
Or configure manually:
|
||||
|
||||
```bash
|
||||
# Configure for local Docker Compose (default ports)
|
||||
multica config local
|
||||
|
||||
# Or set URLs individually:
|
||||
# multica config set app_url http://localhost:3000
|
||||
# multica config set server_url http://localhost:8080
|
||||
|
||||
# For production with TLS:
|
||||
# multica config set app_url https://app.example.com
|
||||
# multica config set server_url https://api.example.com
|
||||
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.
|
||||
@@ -324,21 +306,6 @@ 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.
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
# One-command setup: configure, authenticate, and start the daemon
|
||||
multica setup
|
||||
|
||||
# For local self-hosted deployments (auto-detects or forces local mode)
|
||||
multica setup --local
|
||||
|
||||
# Custom ports
|
||||
multica setup --local --port 9090 --frontend-port 4000
|
||||
```
|
||||
|
||||
`multica setup` detects whether a local Multica server is running, configures the CLI, opens your browser for authentication, and starts the daemon — all in one step.
|
||||
|
||||
## Configuration
|
||||
|
||||
### View Config
|
||||
@@ -349,19 +316,10 @@ multica config show
|
||||
|
||||
Shows config file path, server URL, app URL, and default workspace.
|
||||
|
||||
### Configure for Local Self-Hosted
|
||||
|
||||
```bash
|
||||
multica config local # Uses default ports (8080/3000)
|
||||
multica config local --port 9090 --frontend-port 4000 # Custom ports
|
||||
```
|
||||
|
||||
Sets `server_url` and `app_url` for a local Docker Compose deployment in one command.
|
||||
|
||||
### Set Values
|
||||
|
||||
```bash
|
||||
multica config set server_url https://api.example.com
|
||||
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>
|
||||
```
|
||||
|
||||
@@ -94,52 +94,59 @@ FORCE=1 make worktree-env
|
||||
|
||||
## First-Time Setup
|
||||
|
||||
### Quick Start (recommended)
|
||||
### Main Checkout
|
||||
|
||||
From any checkout (main or worktree):
|
||||
|
||||
```bash
|
||||
make dev
|
||||
```
|
||||
|
||||
This single command:
|
||||
|
||||
- auto-detects whether you're in a main checkout or a worktree
|
||||
- creates the appropriate env file (`.env` or `.env.worktree`) if it doesn't exist
|
||||
- checks that prerequisites (Node.js, pnpm, Go, Docker) are installed
|
||||
- installs JavaScript dependencies
|
||||
- ensures the shared PostgreSQL container is running
|
||||
- creates the application database if it does not exist
|
||||
- runs all migrations
|
||||
- starts both backend and frontend
|
||||
|
||||
### Explicit Setup (advanced)
|
||||
|
||||
If you prefer separate control over setup and startup:
|
||||
|
||||
#### Main Checkout
|
||||
From the main checkout:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
make setup-main
|
||||
```
|
||||
|
||||
What `make setup-main` does:
|
||||
|
||||
- installs JavaScript dependencies with `pnpm install`
|
||||
- ensures the shared PostgreSQL container is running
|
||||
- creates the application database if it does not exist
|
||||
- runs all migrations against that database
|
||||
|
||||
Start the app:
|
||||
|
||||
```bash
|
||||
make start-main
|
||||
```
|
||||
|
||||
Stop:
|
||||
Stop the app processes:
|
||||
|
||||
```bash
|
||||
make stop-main
|
||||
```
|
||||
|
||||
#### Worktree
|
||||
This does not stop PostgreSQL.
|
||||
|
||||
### Worktree
|
||||
|
||||
From the worktree directory:
|
||||
|
||||
```bash
|
||||
make worktree-env
|
||||
make setup-worktree
|
||||
```
|
||||
|
||||
What `make setup-worktree` does:
|
||||
|
||||
- uses `.env.worktree`
|
||||
- ensures the shared PostgreSQL container is running
|
||||
- creates the worktree database if it does not exist
|
||||
- runs migrations against the worktree database
|
||||
|
||||
Start the worktree app:
|
||||
|
||||
```bash
|
||||
make start-worktree
|
||||
```
|
||||
|
||||
Stop:
|
||||
Stop the worktree app processes:
|
||||
|
||||
```bash
|
||||
make stop-worktree
|
||||
@@ -164,15 +171,17 @@ Use a worktree when you want isolated data and separate app ports.
|
||||
```bash
|
||||
git worktree add ../multica-feature -b feat/my-change main
|
||||
cd ../multica-feature
|
||||
make dev
|
||||
make worktree-env
|
||||
make setup-worktree
|
||||
make start-worktree
|
||||
```
|
||||
|
||||
After that, day-to-day commands are:
|
||||
|
||||
```bash
|
||||
make dev # start (re-runs setup if needed, idempotent)
|
||||
make stop-worktree # stop
|
||||
make check-worktree # verify
|
||||
make start-worktree
|
||||
make stop-worktree
|
||||
make check-worktree
|
||||
```
|
||||
|
||||
## Running Main and Worktree at the Same Time
|
||||
@@ -415,7 +424,9 @@ Warning:
|
||||
### Stable Main Environment
|
||||
|
||||
```bash
|
||||
make dev
|
||||
cp .env.example .env
|
||||
make setup-main
|
||||
make start-main
|
||||
```
|
||||
|
||||
### Feature Worktree
|
||||
@@ -423,7 +434,9 @@ make dev
|
||||
```bash
|
||||
git worktree add ../multica-feature -b feat/my-change main
|
||||
cd ../multica-feature
|
||||
make dev
|
||||
make worktree-env
|
||||
make setup-worktree
|
||||
make start-worktree
|
||||
```
|
||||
|
||||
### Return to a Previously Configured Worktree
|
||||
|
||||
@@ -30,9 +30,7 @@ COPY --from=builder /src/server/bin/server .
|
||||
COPY --from=builder /src/server/bin/multica .
|
||||
COPY --from=builder /src/server/bin/migrate .
|
||||
COPY server/migrations/ ./migrations/
|
||||
COPY docker/entrypoint.sh .
|
||||
RUN sed -i 's/\r$//' entrypoint.sh && chmod +x entrypoint.sh
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
ENTRYPOINT ["./entrypoint.sh"]
|
||||
ENTRYPOINT ["./server"]
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
# --- Dependencies ---
|
||||
FROM node:22-alpine AS deps
|
||||
|
||||
RUN corepack enable && corepack prepare pnpm@10.28.2 --activate
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy workspace config and all package.json files for dependency resolution
|
||||
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json turbo.json .npmrc ./
|
||||
COPY apps/web/package.json apps/web/
|
||||
COPY packages/core/package.json packages/core/
|
||||
COPY packages/ui/package.json packages/ui/
|
||||
COPY packages/views/package.json packages/views/
|
||||
COPY packages/tsconfig/package.json packages/tsconfig/
|
||||
COPY packages/eslint-config/package.json packages/eslint-config/
|
||||
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
# --- Build ---
|
||||
FROM node:22-alpine AS builder
|
||||
|
||||
RUN corepack enable && corepack prepare pnpm@10.28.2 --activate
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy installed dependencies (preserves pnpm symlink structure)
|
||||
COPY --from=deps /app ./
|
||||
|
||||
# Copy source
|
||||
COPY package.json turbo.json pnpm-workspace.yaml ./
|
||||
COPY apps/web/ apps/web/
|
||||
COPY packages/ packages/
|
||||
|
||||
# Re-link after source overlay (fixes any symlinks overwritten by COPY)
|
||||
RUN pnpm install --frozen-lockfile --offline
|
||||
|
||||
# Set build-time env: tells Next.js rewrites to proxy API calls to the backend service
|
||||
ARG REMOTE_API_URL=http://backend:8080
|
||||
ARG NEXT_PUBLIC_GOOGLE_CLIENT_ID
|
||||
ENV REMOTE_API_URL=$REMOTE_API_URL
|
||||
ENV NEXT_PUBLIC_GOOGLE_CLIENT_ID=$NEXT_PUBLIC_GOOGLE_CLIENT_ID
|
||||
ENV STANDALONE=true
|
||||
|
||||
# Build the web app (standalone output for minimal runtime)
|
||||
RUN pnpm --filter @multica/web build
|
||||
|
||||
# --- Runtime ---
|
||||
FROM node:22-alpine AS runner
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
|
||||
RUN addgroup --system --gid 1001 nodejs && \
|
||||
adduser --system --uid 1001 nextjs
|
||||
|
||||
# Copy standalone output (includes traced node_modules)
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next/standalone ./
|
||||
# Copy static files (not included in standalone)
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next/static ./apps/web/.next/static
|
||||
# Copy public assets
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/public ./apps/web/public
|
||||
|
||||
USER nextjs
|
||||
|
||||
EXPOSE 3000
|
||||
ENV PORT=3000
|
||||
ENV HOSTNAME=0.0.0.0
|
||||
|
||||
CMD ["node", "apps/web/server.js"]
|
||||
58
Makefile
58
Makefile
@@ -1,4 +1,4 @@
|
||||
.PHONY: dev server daemon cli multica build test migrate-up migrate-down sqlc seed clean setup start stop check worktree-env setup-main start-main stop-main check-main setup-worktree start-worktree stop-worktree check-worktree db-up db-down selfhost selfhost-stop
|
||||
.PHONY: dev daemon cli multica build test migrate-up migrate-down sqlc seed clean setup start stop check worktree-env setup-main start-main stop-main check-main setup-worktree start-worktree stop-worktree check-worktree db-up db-down
|
||||
|
||||
MAIN_ENV_FILE ?= .env
|
||||
WORKTREE_ENV_FILE ?= .env.worktree
|
||||
@@ -36,53 +36,6 @@ define REQUIRE_ENV
|
||||
fi
|
||||
endef
|
||||
|
||||
# ---------- Self-hosting (Docker Compose) ----------
|
||||
|
||||
# One-command self-host: create env, start Docker Compose, wait for health
|
||||
selfhost:
|
||||
@if [ ! -f .env ]; then \
|
||||
echo "==> Creating .env from .env.example..."; \
|
||||
cp .env.example .env; \
|
||||
JWT=$$(openssl rand -hex 32); \
|
||||
if [ "$$(uname)" = "Darwin" ]; then \
|
||||
sed -i '' "s/^JWT_SECRET=.*/JWT_SECRET=$$JWT/" .env; \
|
||||
else \
|
||||
sed -i "s/^JWT_SECRET=.*/JWT_SECRET=$$JWT/" .env; \
|
||||
fi; \
|
||||
echo "==> Generated random JWT_SECRET"; \
|
||||
fi
|
||||
@echo "==> Starting Multica via Docker Compose..."
|
||||
docker compose -f docker-compose.selfhost.yml up -d --build
|
||||
@echo "==> Waiting for backend to be ready..."
|
||||
@for i in $$(seq 1 30); do \
|
||||
if curl -sf http://localhost:$${PORT:-8080}/health > /dev/null 2>&1; then \
|
||||
break; \
|
||||
fi; \
|
||||
sleep 2; \
|
||||
done
|
||||
@if curl -sf http://localhost:$${PORT:-8080}/health > /dev/null 2>&1; then \
|
||||
echo ""; \
|
||||
echo "✓ Multica is running!"; \
|
||||
echo " Frontend: http://localhost:$${FRONTEND_PORT:-3000}"; \
|
||||
echo " Backend: http://localhost:$${PORT:-8080}"; \
|
||||
echo ""; \
|
||||
echo "Log in with any email + verification code: 888888"; \
|
||||
echo ""; \
|
||||
echo "Next — install the CLI and connect your machine:"; \
|
||||
echo " brew install multica-ai/tap/multica"; \
|
||||
echo " multica setup --local"; \
|
||||
else \
|
||||
echo ""; \
|
||||
echo "Services are still starting. Check logs:"; \
|
||||
echo " docker compose -f docker-compose.selfhost.yml logs"; \
|
||||
fi
|
||||
|
||||
# Stop all Docker Compose self-host services
|
||||
selfhost-stop:
|
||||
@echo "==> Stopping Multica services..."
|
||||
docker compose -f docker-compose.selfhost.yml down
|
||||
@echo "✓ All services stopped."
|
||||
|
||||
# ---------- One-click commands ----------
|
||||
|
||||
# First-time setup: install deps, start DB, run migrations
|
||||
@@ -169,12 +122,8 @@ check-worktree:
|
||||
|
||||
# ---------- Individual commands ----------
|
||||
|
||||
# One-command dev: auto-setup env/deps/db/migrations, then start all services
|
||||
# Go server
|
||||
dev:
|
||||
@bash scripts/dev.sh
|
||||
|
||||
# Go server only
|
||||
server:
|
||||
$(REQUIRE_ENV)
|
||||
@bash scripts/ensure-postgres.sh "$(ENV_FILE)"
|
||||
cd server && go run ./cmd/server
|
||||
@@ -190,11 +139,10 @@ multica:
|
||||
|
||||
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
|
||||
COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown)
|
||||
DATE ?= $(shell date -u '+%Y-%m-%dT%H:%M:%SZ')
|
||||
|
||||
build:
|
||||
cd server && go build -o bin/server ./cmd/server
|
||||
cd server && go build -ldflags "-X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.date=$(DATE)" -o bin/multica ./cmd/multica
|
||||
cd server && go build -ldflags "-X main.version=$(VERSION) -X main.commit=$(COMMIT)" -o bin/multica ./cmd/multica
|
||||
cd server && go build -o bin/migrate ./cmd/migrate
|
||||
|
||||
test:
|
||||
|
||||
108
README.md
108
README.md
@@ -47,36 +47,57 @@ Multica manages the full agent lifecycle: from task assignment to execution moni
|
||||
- **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.
|
||||
|
||||
---
|
||||
|
||||
## Quick Install
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.sh | bash
|
||||
```
|
||||
|
||||
Installs the Multica CLI on macOS and Linux. Works with Homebrew or downloads the binary directly.
|
||||
|
||||
After installation:
|
||||
|
||||
```bash
|
||||
multica login # Authenticate (opens browser)
|
||||
multica daemon start # Start the local agent runtime
|
||||
multica daemon stop # Stop the daemon when done
|
||||
```
|
||||
|
||||
> **Self-hosting?** Add `--local` to deploy a full Multica server on your machine:
|
||||
>
|
||||
> ```bash
|
||||
> curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.sh | bash -s -- --local
|
||||
> ```
|
||||
>
|
||||
> Requires Docker. See the [Self-Hosting Guide](SELF_HOSTING.md) for details.
|
||||
|
||||
---
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Multica Cloud
|
||||
|
||||
The fastest way to get started — no setup required: **[multica.ai](https://multica.ai)**
|
||||
|
||||
### Self-Host with Docker
|
||||
|
||||
```bash
|
||||
git clone https://github.com/multica-ai/multica.git
|
||||
cd multica
|
||||
cp .env.example .env
|
||||
# Edit .env — at minimum, change JWT_SECRET
|
||||
|
||||
docker compose up -d # Start PostgreSQL
|
||||
cd server && go run ./cmd/migrate up && cd .. # Run migrations
|
||||
make start # Start the app
|
||||
```
|
||||
|
||||
See the [Self-Hosting Guide](SELF_HOSTING.md) for full instructions.
|
||||
|
||||
## CLI
|
||||
|
||||
The `multica` CLI connects your local machine to Multica — authenticate, manage workspaces, and run the agent daemon.
|
||||
|
||||
**Option A — paste this to your coding agent (Claude Code, Codex, OpenClaw, OpenCode, etc.):**
|
||||
|
||||
```
|
||||
Fetch https://github.com/multica-ai/multica/blob/main/CLI_INSTALL.md and follow the instructions to install Multica CLI, log in, and start the daemon on this machine.
|
||||
```
|
||||
|
||||
**Option B — install manually:**
|
||||
|
||||
```bash
|
||||
# Install
|
||||
brew tap multica-ai/tap
|
||||
brew install multica
|
||||
|
||||
# Authenticate and start
|
||||
multica login
|
||||
multica daemon start
|
||||
```
|
||||
|
||||
The daemon auto-detects available agent CLIs (`claude`, `codex`, `openclaw`, `opencode`) on your PATH. When an agent is assigned a task, the daemon creates an isolated environment, runs the agent, and reports results back.
|
||||
|
||||
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
|
||||
|
||||
```bash
|
||||
@@ -84,7 +105,7 @@ multica login # Authenticate with your Multica account
|
||||
multica daemon start # Start the local agent runtime
|
||||
```
|
||||
|
||||
The daemon runs in the background and auto-detects agent CLIs (`claude`, `codex`, `openclaw`, `opencode`) on your PATH.
|
||||
The daemon runs in the background and keeps your machine connected to Multica. It auto-detects agent CLIs (`claude`, `codex`, `openclaw`, `opencode`) available on your PATH.
|
||||
|
||||
### 2. Verify your runtime
|
||||
|
||||
@@ -100,27 +121,7 @@ Go to **Settings → Agents** and click **New Agent**. Pick the runtime you just
|
||||
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
## CLI
|
||||
|
||||
The `multica` CLI connects your local machine to Multica — authenticate, manage workspaces, and run the agent daemon.
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `multica login` | Authenticate (opens browser) |
|
||||
| `multica daemon start` | Start the local agent runtime |
|
||||
| `multica daemon status` | Check daemon status |
|
||||
| `multica setup` | One-command setup (configure + login + start daemon) |
|
||||
| `multica setup --local` | Same, but for self-hosted deployments |
|
||||
| `multica config local` | Configure CLI for a local self-hosted server |
|
||||
| `multica issue list` | List issues in your workspace |
|
||||
| `multica issue create` | Create a new issue |
|
||||
| `multica update` | Update to the latest version |
|
||||
|
||||
See the [CLI and Daemon Guide](CLI_AND_DAEMON.md) for the full command reference.
|
||||
|
||||
---
|
||||
That's it! Your agent is now part of the team. 🎉
|
||||
|
||||
## Architecture
|
||||
|
||||
@@ -151,9 +152,10 @@ For contributors working on the Multica codebase, see the [Contributing Guide](C
|
||||
**Prerequisites:** [Node.js](https://nodejs.org/) v20+, [pnpm](https://pnpm.io/) v10.28+, [Go](https://go.dev/) v1.26+, [Docker](https://www.docker.com/)
|
||||
|
||||
```bash
|
||||
make dev
|
||||
pnpm install
|
||||
cp .env.example .env
|
||||
make setup
|
||||
make start
|
||||
```
|
||||
|
||||
`make dev` auto-detects your environment (main checkout or worktree), creates the env file, installs dependencies, sets up the database, runs migrations, and starts all services.
|
||||
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md) for the full development workflow, worktree support, testing, and troubleshooting.
|
||||
|
||||
@@ -47,33 +47,52 @@ Multica 管理完整的 Agent 生命周期:从任务分配到执行监控再
|
||||
- **统一运行时** — 一个控制台管理所有算力。本地 daemon 和云端运行时,自动检测可用 CLI,实时监控。
|
||||
- **多工作区** — 按团队组织工作,工作区级别隔离。每个工作区有独立的 Agent、Issue 和设置。
|
||||
|
||||
---
|
||||
## 快速开始
|
||||
|
||||
## 快速安装
|
||||
### Multica 云服务
|
||||
|
||||
最快的上手方式,无需任何配置:**[multica.ai](https://multica.ai)**
|
||||
|
||||
### Docker 自部署
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.sh | 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 # 启动应用
|
||||
```
|
||||
|
||||
安装 Multica CLI,支持 macOS 和 Linux。有 Homebrew 用 Homebrew,没有则直接下载二进制。
|
||||
完整部署文档请参阅 [自部署指南](SELF_HOSTING.md)。
|
||||
|
||||
安装完成后:
|
||||
## CLI
|
||||
|
||||
`multica` CLI 将你的本地机器连接到 Multica — 用于认证、管理工作区和运行 Agent daemon。
|
||||
|
||||
**方式 A — 将以下指令粘贴给你的 coding agent(Claude Code、Codex、OpenClaw、OpenCode 等):**
|
||||
|
||||
```
|
||||
Fetch https://github.com/multica-ai/multica/blob/main/CLI_INSTALL.md and follow the instructions to install Multica CLI, log in, and start the daemon on this machine.
|
||||
```
|
||||
|
||||
**方式 B — 手动安装:**
|
||||
|
||||
```bash
|
||||
multica login # 认证(打开浏览器)
|
||||
multica daemon start # 启动本地 Agent 运行时
|
||||
multica daemon stop # 停止 daemon
|
||||
# 安装
|
||||
brew tap multica-ai/tap
|
||||
brew install multica
|
||||
|
||||
# 认证并启动
|
||||
multica login
|
||||
multica daemon start
|
||||
```
|
||||
|
||||
> **自部署?** 加上 `--local` 在本地部署完整的 Multica 服务:
|
||||
>
|
||||
> ```bash
|
||||
> curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.sh | bash -s -- --local
|
||||
> ```
|
||||
>
|
||||
> 需要 Docker。详见 [自部署指南](SELF_HOSTING.md)。
|
||||
daemon 会自动检测 PATH 中可用的 Agent CLI(`claude`、`codex`、`openclaw`、`opencode`)。当 Agent 被分配任务时,daemon 会创建隔离环境、运行 Agent、并将结果回传。
|
||||
|
||||
---
|
||||
完整命令参考请参阅 [CLI 与 Daemon 指南](CLI_AND_DAEMON.md)。
|
||||
|
||||
## 快速上手
|
||||
|
||||
|
||||
417
SELF_HOSTING.md
417
SELF_HOSTING.md
@@ -1,8 +1,10 @@
|
||||
# Self-Hosting Guide
|
||||
|
||||
Deploy Multica on your own infrastructure in minutes.
|
||||
This guide walks you through deploying Multica on your own infrastructure.
|
||||
|
||||
## Architecture
|
||||
## Architecture Overview
|
||||
|
||||
Multica has three components:
|
||||
|
||||
| Component | Description | Technology |
|
||||
|-----------|-------------|------------|
|
||||
@@ -10,151 +12,16 @@ Deploy Multica on your own infrastructure in minutes.
|
||||
| **Frontend** | Web application | Next.js 16 |
|
||||
| **Database** | Primary data store | PostgreSQL 17 with pgvector |
|
||||
|
||||
Each user who runs AI agents locally also installs the **`multica` CLI** and runs the **agent daemon** on their own machine.
|
||||
Additionally, each user who wants to run AI agents locally installs the **`multica` CLI** and runs the **agent daemon** on their own machine.
|
||||
|
||||
## Quick Install (Recommended)
|
||||
## Prerequisites
|
||||
|
||||
One command to set up everything — server, CLI, and configuration:
|
||||
- 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
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.sh | bash -s -- --local
|
||||
```
|
||||
|
||||
This automatically clones the repository, starts all services via Docker Compose, and installs the `multica` CLI.
|
||||
|
||||
Once complete, open http://localhost:3000, log in with any email + verification code **`888888`**, then:
|
||||
|
||||
```bash
|
||||
multica login # Authenticate (opens browser)
|
||||
multica daemon start # Start the agent daemon
|
||||
```
|
||||
|
||||
> **Prerequisites:** Docker and Docker Compose must be installed. The script checks for this and provides install links if missing.
|
||||
|
||||
---
|
||||
|
||||
## Step-by-Step Setup (Alternative)
|
||||
|
||||
If you prefer to run each step manually:
|
||||
|
||||
### Step 1 — Start the Server
|
||||
|
||||
**Prerequisites:** Docker and Docker Compose.
|
||||
|
||||
```bash
|
||||
git clone https://github.com/multica-ai/multica.git
|
||||
cd multica
|
||||
make selfhost
|
||||
```
|
||||
|
||||
`make selfhost` automatically creates `.env` from the example, generates a random `JWT_SECRET`, and starts all services via Docker Compose.
|
||||
|
||||
Once ready:
|
||||
|
||||
- **Frontend:** http://localhost:3000
|
||||
- **Backend API:** http://localhost:8080
|
||||
|
||||
> **Note:** If you prefer to run the Docker Compose steps manually, see [Manual Docker Compose Setup](#manual-docker-compose-setup) below.
|
||||
|
||||
### Step 2 — Log In
|
||||
|
||||
Open http://localhost:3000 in your browser. Enter any email address and use verification code **`888888`** to log in.
|
||||
|
||||
> This master code works in all non-production environments (i.e. when `APP_ENV` is not set to `production`). For production, configure an email provider — see [Advanced Configuration](SELF_HOSTING_ADVANCED.md#email-required-for-authentication).
|
||||
|
||||
### Step 3 — Install CLI & Start Daemon
|
||||
|
||||
The daemon runs on your local machine (not inside Docker). It detects installed AI agent CLIs, registers them with the server, and executes tasks when agents are assigned work.
|
||||
|
||||
Each team member who wants to run AI agents locally needs to:
|
||||
|
||||
### a) Install the CLI and an AI agent
|
||||
|
||||
```bash
|
||||
brew install multica-ai/tap/multica
|
||||
```
|
||||
|
||||
You also need at least one AI agent CLI installed:
|
||||
- [Claude Code](https://docs.anthropic.com/en/docs/claude-code) (`claude` on PATH)
|
||||
- [Codex](https://github.com/openai/codex) (`codex` on PATH)
|
||||
|
||||
### b) One-command setup
|
||||
|
||||
```bash
|
||||
multica setup --local
|
||||
```
|
||||
|
||||
This automatically:
|
||||
1. Configures the CLI to connect to `localhost` (ports 8080/3000)
|
||||
2. Opens your browser for authentication
|
||||
3. Discovers your workspaces
|
||||
4. Starts the daemon in the background
|
||||
|
||||
To verify the daemon is running:
|
||||
|
||||
```bash
|
||||
multica daemon status
|
||||
```
|
||||
|
||||
> **Alternative:** If you prefer manual steps, see [Manual CLI Configuration](#manual-cli-configuration) below.
|
||||
|
||||
### Step 4 — Verify & Start Using
|
||||
|
||||
1. Open your workspace in the web app at http://localhost:3000
|
||||
2. Navigate to **Settings → Runtimes** — you should see your machine listed
|
||||
3. Go to **Settings → Agents** and create a new agent
|
||||
4. Create an issue and assign it to your agent — it will pick up the task automatically
|
||||
|
||||
## Stopping Services
|
||||
|
||||
If you installed via the install script:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.sh | bash -s -- --stop
|
||||
```
|
||||
|
||||
If you cloned the repo manually:
|
||||
|
||||
```bash
|
||||
# Stop the Docker Compose services (backend, frontend, database)
|
||||
make selfhost-stop
|
||||
|
||||
# Stop the local daemon
|
||||
multica daemon stop
|
||||
```
|
||||
|
||||
## Switching to Multica Cloud
|
||||
|
||||
If you've been self-hosting and want to switch your CLI to [Multica Cloud](https://multica.ai):
|
||||
|
||||
```bash
|
||||
multica config set server_url https://api.multica.ai
|
||||
multica config set app_url https://multica.ai
|
||||
multica login
|
||||
```
|
||||
|
||||
Or re-run the install script without `--local` — it will reconfigure the CLI automatically:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.sh | bash
|
||||
```
|
||||
|
||||
> Your local Docker services are unaffected. Stop them separately if you no longer need them.
|
||||
|
||||
## Rebuilding After Updates
|
||||
|
||||
```bash
|
||||
git pull
|
||||
make selfhost
|
||||
```
|
||||
|
||||
Migrations run automatically on backend startup.
|
||||
|
||||
---
|
||||
|
||||
## Manual Docker Compose Setup
|
||||
|
||||
If you prefer running Docker Compose steps manually instead of `make selfhost`:
|
||||
## Quick Start (Docker Compose)
|
||||
|
||||
```bash
|
||||
git clone https://github.com/multica-ai/multica.git
|
||||
@@ -162,46 +29,258 @@ cd multica
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Edit `.env` — at minimum, change `JWT_SECRET`:
|
||||
Edit `.env` with your production values (see [Configuration](#configuration) below), then:
|
||||
|
||||
```bash
|
||||
JWT_SECRET=$(openssl rand -hex 32)
|
||||
# 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
|
||||
```
|
||||
|
||||
Then start everything:
|
||||
For the frontend:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.selfhost.yml up -d
|
||||
pnpm install
|
||||
pnpm build
|
||||
|
||||
# Start the frontend (production mode)
|
||||
cd apps/web
|
||||
REMOTE_API_URL=http://localhost:8080 pnpm start
|
||||
```
|
||||
|
||||
## Manual CLI Configuration
|
||||
## Configuration
|
||||
|
||||
If you prefer configuring the CLI step by step instead of `multica setup`:
|
||||
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
|
||||
# Point CLI to your local server
|
||||
multica config local
|
||||
|
||||
# Or set URLs manually:
|
||||
# multica config set app_url http://localhost:3000
|
||||
# multica config set server_url http://localhost:8080
|
||||
|
||||
# Login (opens browser)
|
||||
multica login
|
||||
|
||||
# Start the daemon
|
||||
multica daemon start
|
||||
docker compose up -d postgres
|
||||
```
|
||||
|
||||
For production deployments with TLS:
|
||||
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
|
||||
multica config set app_url https://app.example.com
|
||||
multica config set server_url https://api.example.com
|
||||
multica login
|
||||
multica daemon start
|
||||
# Using the built binary
|
||||
./server/bin/migrate up
|
||||
|
||||
# Or from source
|
||||
cd server && go run ./cmd/migrate up
|
||||
```
|
||||
|
||||
## Advanced Configuration
|
||||
## Reverse Proxy
|
||||
|
||||
For environment variables, manual setup (without Docker), reverse proxy configuration, database setup, and more, see the [Advanced Configuration Guide](SELF_HOSTING_ADVANCED.md).
|
||||
In production, put a reverse proxy in front of both the backend and frontend to handle TLS and routing.
|
||||
|
||||
### Caddy (Recommended)
|
||||
|
||||
```
|
||||
app.example.com {
|
||||
reverse_proxy localhost:3000
|
||||
}
|
||||
|
||||
api.example.com {
|
||||
reverse_proxy localhost:8080
|
||||
}
|
||||
```
|
||||
|
||||
### Nginx
|
||||
|
||||
```nginx
|
||||
# Frontend
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name app.example.com;
|
||||
|
||||
ssl_certificate /path/to/cert.pem;
|
||||
ssl_certificate_key /path/to/key.pem;
|
||||
|
||||
location / {
|
||||
proxy_pass http://localhost:3000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
|
||||
# Backend API
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name api.example.com;
|
||||
|
||||
ssl_certificate /path/to/cert.pem;
|
||||
ssl_certificate_key /path/to/key.pem;
|
||||
|
||||
location / {
|
||||
proxy_pass http://localhost:8080;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# WebSocket support
|
||||
location /ws {
|
||||
proxy_pass http://localhost:8080;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_read_timeout 86400;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
When using separate domains for frontend and backend, set these environment variables accordingly:
|
||||
|
||||
```bash
|
||||
# Backend
|
||||
FRONTEND_ORIGIN=https://app.example.com
|
||||
CORS_ALLOWED_ORIGINS=https://app.example.com
|
||||
|
||||
# Frontend
|
||||
REMOTE_API_URL=https://api.example.com
|
||||
NEXT_PUBLIC_API_URL=https://api.example.com
|
||||
NEXT_PUBLIC_WS_URL=wss://api.example.com/ws
|
||||
```
|
||||
|
||||
## Health Check
|
||||
|
||||
The backend exposes a health check endpoint:
|
||||
|
||||
```
|
||||
GET /health
|
||||
→ {"status":"ok"}
|
||||
```
|
||||
|
||||
Use this for load balancer health checks or monitoring.
|
||||
|
||||
## Setting Up the Agent Daemon
|
||||
|
||||
Each team member who wants to run AI agents locally needs to:
|
||||
|
||||
1. **Install the CLI**
|
||||
|
||||
```bash
|
||||
brew tap multica-ai/tap
|
||||
brew install multica-cli
|
||||
```
|
||||
|
||||
2. **Install an AI agent CLI** — at least one of:
|
||||
- [Claude Code](https://docs.anthropic.com/en/docs/claude-code) (`claude` on PATH)
|
||||
- [Codex](https://github.com/openai/codex) (`codex` on PATH)
|
||||
|
||||
3. **Authenticate and start**
|
||||
|
||||
```bash
|
||||
# Point CLI to your server
|
||||
#
|
||||
# For production deployments with TLS:
|
||||
export MULTICA_APP_URL=https://app.example.com
|
||||
export MULTICA_SERVER_URL=wss://api.example.com/ws
|
||||
#
|
||||
# For local deployments without TLS:
|
||||
# export MULTICA_APP_URL=http://localhost:3000
|
||||
# export MULTICA_SERVER_URL=ws://localhost:8080/ws
|
||||
|
||||
# Login (opens browser)
|
||||
multica login
|
||||
|
||||
# Start the daemon
|
||||
multica daemon start
|
||||
```
|
||||
|
||||
> **Note:** Use `https://` and `wss://` for production deployments behind a TLS-terminating reverse proxy. For local or development deployments without TLS, use `http://` and `ws://` instead.
|
||||
|
||||
The daemon auto-detects installed agent CLIs and registers itself with the server. When an agent is assigned a task in Multica, the daemon picks it up, creates an isolated workspace, runs the agent, and reports results back.
|
||||
|
||||
## Upgrading
|
||||
|
||||
1. Pull the latest code or image
|
||||
2. Run migrations: `./server/bin/migrate up`
|
||||
3. Restart the backend and frontend
|
||||
|
||||
Migrations are forward-only and safe to run on a live database. They are idempotent — running them multiple times has no effect.
|
||||
|
||||
@@ -1,224 +0,0 @@
|
||||
# Self-Hosting — Advanced Configuration
|
||||
|
||||
This document covers advanced configuration for self-hosted Multica deployments. For the quick start guide, see [SELF_HOSTING.md](SELF_HOSTING.md).
|
||||
|
||||
## 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`) |
|
||||
|
||||
> **Note:** For local/development deployments without email configured, you can use the master verification code `888888` to log in.
|
||||
|
||||
### 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 Docker Compose (Recommended)
|
||||
|
||||
The `docker-compose.selfhost.yml` includes PostgreSQL. No separate setup needed.
|
||||
|
||||
### Using Your Own PostgreSQL
|
||||
|
||||
If you prefer to use an existing PostgreSQL instance, ensure the pgvector extension is available:
|
||||
|
||||
```sql
|
||||
CREATE EXTENSION IF NOT EXISTS vector;
|
||||
```
|
||||
|
||||
Set `DATABASE_URL` in your `.env` and remove the `postgres` service from the compose file.
|
||||
|
||||
### Running Migrations Manually
|
||||
|
||||
The Docker Compose setup runs migrations automatically. If you need to run them manually:
|
||||
|
||||
```bash
|
||||
# Using the built binary
|
||||
./server/bin/migrate up
|
||||
|
||||
# Or from source
|
||||
cd server && go run ./cmd/migrate up
|
||||
```
|
||||
|
||||
## Manual Setup (Without Docker Compose)
|
||||
|
||||
If you prefer to build and run services manually:
|
||||
|
||||
**Prerequisites:** Go 1.26+, Node.js 20+, pnpm 10.28+, PostgreSQL 17 with pgvector.
|
||||
|
||||
```bash
|
||||
# Start your PostgreSQL (or use: docker compose up -d postgres)
|
||||
|
||||
# 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 JWT_SECRET="your-secret" ./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
|
||||
```
|
||||
|
||||
## 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 (set before building the frontend image)
|
||||
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.
|
||||
|
||||
## Upgrading
|
||||
|
||||
```bash
|
||||
git pull
|
||||
docker compose -f docker-compose.selfhost.yml up -d --build
|
||||
```
|
||||
|
||||
Migrations run automatically on backend startup. They are idempotent — running them multiple times has no effect.
|
||||
@@ -1,74 +0,0 @@
|
||||
# Self-Hosting Setup (for AI Agents)
|
||||
|
||||
This document is designed for AI agents to execute. Follow these steps exactly to deploy a local Multica instance and connect to it.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Docker and Docker Compose installed
|
||||
- Homebrew installed (for CLI)
|
||||
- At least one AI agent CLI on PATH: `claude` or `codex`
|
||||
|
||||
## One-Command Install
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.sh | bash -s -- --local
|
||||
```
|
||||
|
||||
This installs everything: clones the repo, starts Docker services, and installs the CLI.
|
||||
|
||||
Wait for the output `✓ Multica is installed and running!` before proceeding.
|
||||
|
||||
**Expected result:**
|
||||
- Frontend at http://localhost:3000
|
||||
- Backend at http://localhost:8080
|
||||
- `multica` CLI installed and configured for localhost
|
||||
|
||||
## Alternative: Manual Setup
|
||||
|
||||
```bash
|
||||
git clone https://github.com/multica-ai/multica.git
|
||||
cd multica
|
||||
make selfhost
|
||||
brew install multica-ai/tap/multica
|
||||
multica setup --local
|
||||
```
|
||||
|
||||
The `multica setup --local` command will:
|
||||
1. Configure CLI to connect to localhost:8080 / localhost:3000
|
||||
2. Open a browser for login — use verification code `888888` with any email
|
||||
3. Discover workspaces automatically
|
||||
4. Start the daemon in the background
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
multica daemon status
|
||||
```
|
||||
|
||||
Should show `running` with detected agents.
|
||||
|
||||
## Stopping
|
||||
|
||||
```bash
|
||||
# Stop the daemon
|
||||
multica daemon stop
|
||||
|
||||
# Stop all Docker services
|
||||
cd multica
|
||||
make selfhost-stop
|
||||
```
|
||||
|
||||
## Custom Ports
|
||||
|
||||
If the default ports (8080/3000) are in use:
|
||||
|
||||
1. Edit `.env` and change `PORT` and `FRONTEND_PORT`
|
||||
2. Run `make selfhost`
|
||||
3. Run `multica setup --local --port <PORT> --frontend-port <FRONTEND_PORT>`
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **Backend not ready:** `docker compose -f docker-compose.selfhost.yml logs backend`
|
||||
- **Frontend not ready:** `docker compose -f docker-compose.selfhost.yml logs frontend`
|
||||
- **Daemon issues:** `multica daemon logs`
|
||||
- **Health check:** `curl http://localhost:8080/health`
|
||||
@@ -11,10 +11,6 @@ export default defineConfig({
|
||||
plugins: [externalizeDepsPlugin()],
|
||||
},
|
||||
renderer: {
|
||||
server: {
|
||||
port: 5173,
|
||||
strictPort: true,
|
||||
},
|
||||
plugins: [react(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
|
||||
@@ -15,25 +15,19 @@
|
||||
"postinstall": "electron-builder install-app-deps"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/modifiers": "^9.0.0",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@electron-toolkit/preload": "^3.0.2",
|
||||
"@electron-toolkit/utils": "^4.0.0",
|
||||
"@multica/core": "workspace:*",
|
||||
"@multica/ui": "workspace:*",
|
||||
"@multica/views": "workspace:*",
|
||||
"@fontsource/geist-mono": "^5.2.7",
|
||||
"@fontsource/geist-sans": "^5.2.5",
|
||||
"react-router-dom": "^7.6.0",
|
||||
"shadcn": "^4.1.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tw-animate-css": "^1.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@electron-toolkit/tsconfig": "^2.0.0",
|
||||
"@multica/tsconfig": "workspace:*",
|
||||
"@electron-toolkit/tsconfig": "^2.0.0",
|
||||
"@tailwindcss/vite": "^4",
|
||||
"@types/node": "catalog:",
|
||||
"@types/react": "catalog:",
|
||||
|
||||
@@ -17,7 +17,6 @@ function createWindow(): void {
|
||||
webPreferences: {
|
||||
preload: join(__dirname, "../preload/index.js"),
|
||||
sandbox: false,
|
||||
webSecurity: false,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -10,24 +10,6 @@ import {
|
||||
Plus,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
DndContext,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
closestCenter,
|
||||
type DragEndEvent,
|
||||
} from "@dnd-kit/core";
|
||||
import {
|
||||
SortableContext,
|
||||
horizontalListSortingStrategy,
|
||||
useSortable,
|
||||
} from "@dnd-kit/sortable";
|
||||
import {
|
||||
restrictToHorizontalAxis,
|
||||
restrictToParentElement,
|
||||
} from "@dnd-kit/modifiers";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import { useTabStore, resolveRouteIcon, type Tab } from "@/stores/tab-store";
|
||||
|
||||
@@ -41,28 +23,12 @@ const TAB_ICONS: Record<string, LucideIcon> = {
|
||||
Settings,
|
||||
};
|
||||
|
||||
function SortableTabItem({ tab, isActive, isOnly }: { tab: Tab; isActive: boolean; isOnly: boolean }) {
|
||||
function TabItem({ tab, isActive, isOnly }: { tab: Tab; isActive: boolean; isOnly: boolean }) {
|
||||
const setActiveTab = useTabStore((s) => s.setActiveTab);
|
||||
const closeTab = useTabStore((s) => s.closeTab);
|
||||
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id: tab.id });
|
||||
|
||||
const Icon = TAB_ICONS[tab.icon];
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
WebkitAppRegion: "no-drag",
|
||||
zIndex: isDragging ? 10 : undefined,
|
||||
} as React.CSSProperties;
|
||||
|
||||
const handleClick = () => {
|
||||
if (isActive) return;
|
||||
setActiveTab(tab.id);
|
||||
@@ -75,25 +41,16 @@ function SortableTabItem({ tab, isActive, isOnly }: { tab: Tab; isActive: boolea
|
||||
// No navigate() — store handles activeTabId switch
|
||||
};
|
||||
|
||||
// Stop pointer down on close so it doesn't start a drag on the parent button.
|
||||
const stopDragOnClose = (e: React.PointerEvent) => {
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
onClick={handleClick}
|
||||
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
|
||||
className={cn(
|
||||
"group flex h-7 w-40 items-center gap-1.5 rounded-md px-2 text-xs transition-colors",
|
||||
"select-none cursor-default",
|
||||
isActive
|
||||
? "bg-sidebar-accent font-medium text-sidebar-accent-foreground"
|
||||
: "bg-sidebar-accent/50 text-muted-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
|
||||
isDragging && "opacity-60",
|
||||
)}
|
||||
>
|
||||
{Icon && <Icon className="size-3.5 shrink-0" />}
|
||||
@@ -109,7 +66,6 @@ function SortableTabItem({ tab, isActive, isOnly }: { tab: Tab; isActive: boolea
|
||||
{!isOnly && (
|
||||
<span
|
||||
onClick={handleClose}
|
||||
onPointerDown={stopDragOnClose}
|
||||
className="hidden size-3.5 shrink-0 items-center justify-center rounded-sm text-muted-foreground transition-colors group-hover:flex hover:bg-muted-foreground/20 hover:text-foreground"
|
||||
>
|
||||
<X className="size-2.5" />
|
||||
@@ -144,44 +100,12 @@ function NewTabButton() {
|
||||
export function TabBar() {
|
||||
const tabs = useTabStore((s) => s.tabs);
|
||||
const activeTabId = useTabStore((s) => s.activeTabId);
|
||||
const moveTab = useTabStore((s) => s.moveTab);
|
||||
|
||||
// distance: 5 — pointer must move 5px to start a drag, otherwise it's a click.
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: { distance: 5 },
|
||||
}),
|
||||
);
|
||||
|
||||
const tabIds = tabs.map((t) => t.id);
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) return;
|
||||
const from = tabs.findIndex((t) => t.id === active.id);
|
||||
const to = tabs.findIndex((t) => t.id === over.id);
|
||||
if (from !== -1 && to !== -1) moveTab(from, to);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full items-center gap-0.5 px-2 justify-start">
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
modifiers={[restrictToHorizontalAxis, restrictToParentElement]}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext items={tabIds} strategy={horizontalListSortingStrategy}>
|
||||
{tabs.map((tab) => (
|
||||
<SortableTabItem
|
||||
key={tab.id}
|
||||
tab={tab}
|
||||
isActive={tab.id === activeTabId}
|
||||
isOnly={tabs.length === 1}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
{tabs.map((tab) => (
|
||||
<TabItem key={tab.id} tab={tab} isActive={tab.id === activeTabId} isOnly={tabs.length === 1} />
|
||||
))}
|
||||
<NewTabButton />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -6,13 +6,6 @@
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
/* Geist font: define CSS variables that tokens.css @theme inline references.
|
||||
Web app gets these from next/font/google; desktop must set them explicitly. */
|
||||
:root {
|
||||
--font-sans: "Geist Sans", ui-sans-serif, system-ui, -apple-system, sans-serif;
|
||||
--font-mono: "Geist Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
}
|
||||
|
||||
@source "../../../../../packages/ui/**/*.tsx";
|
||||
@source "../../../../../packages/core/**/*.{ts,tsx}";
|
||||
@source "../../../../../packages/views/**/*.{ts,tsx}";
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
import ReactDOM from "react-dom/client";
|
||||
import App from "./App";
|
||||
import "@fontsource/geist-sans/400.css";
|
||||
import "@fontsource/geist-sans/500.css";
|
||||
import "@fontsource/geist-sans/600.css";
|
||||
import "@fontsource/geist-sans/700.css";
|
||||
import "@fontsource/geist-mono/400.css";
|
||||
import "@fontsource/geist-mono/700.css";
|
||||
import "./globals.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(<App />);
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { create } from "zustand";
|
||||
import { createJSONStorage, persist } from "zustand/middleware";
|
||||
import { arrayMove } from "@dnd-kit/sortable";
|
||||
import { createPersistStorage, defaultStorage } from "@multica/core/platform";
|
||||
import { persist } from "zustand/middleware";
|
||||
import type { DataRouter } from "react-router-dom";
|
||||
import { createTabRouter } from "../routes";
|
||||
|
||||
@@ -35,8 +33,6 @@ interface TabStore {
|
||||
updateTab: (tabId: string, patch: Partial<Pick<Tab, "path" | "title" | "icon">>) => void;
|
||||
/** Update a tab's history tracking. */
|
||||
updateTabHistory: (tabId: string, historyIndex: number, historyLength: number) => void;
|
||||
/** Reorder tabs by moving one from fromIndex to toIndex. Preserves router/history. */
|
||||
moveTab: (fromIndex: number, toIndex: number) => void;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -154,16 +150,10 @@ export const useTabStore = create<TabStore>()(
|
||||
),
|
||||
}));
|
||||
},
|
||||
|
||||
moveTab(fromIndex, toIndex) {
|
||||
if (fromIndex === toIndex) return;
|
||||
set((s) => ({ tabs: arrayMove(s.tabs, fromIndex, toIndex) }));
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: "multica_tabs",
|
||||
version: 1,
|
||||
storage: createJSONStorage(() => createPersistStorage(defaultStorage)),
|
||||
partialize: (state) => ({
|
||||
tabs: state.tabs.map(
|
||||
({ router, historyIndex, historyLength, ...rest }) => rest,
|
||||
|
||||
@@ -13,142 +13,50 @@ Multica has three components:
|
||||
| **Frontend** | Web application | Next.js 16 |
|
||||
| **Database** | Primary data store | PostgreSQL 17 with pgvector |
|
||||
|
||||
Each user who wants to run AI agents locally also installs the **`multica` CLI** and runs the **agent daemon** on their own machine.
|
||||
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
|
||||
- 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 Install
|
||||
|
||||
One command to set up everything:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.sh | bash -s -- --local
|
||||
```
|
||||
|
||||
This clones the repo, starts all services, installs the CLI, and configures everything. Then:
|
||||
|
||||
1. Open http://localhost:3000 — log in with any email + code **`888888`**
|
||||
2. Run `multica login` and `multica daemon start`
|
||||
|
||||
<Callout>
|
||||
For a step-by-step setup, see below.
|
||||
</Callout>
|
||||
|
||||
## Step-by-Step Setup
|
||||
|
||||
### Step 1 — Start the Server
|
||||
## Quick Start (Docker Compose)
|
||||
|
||||
```bash
|
||||
git clone https://github.com/multica-ai/multica.git
|
||||
cd multica
|
||||
make selfhost
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
`make selfhost` automatically creates `.env`, generates a random `JWT_SECRET`, and starts all services via Docker Compose.
|
||||
|
||||
Once ready:
|
||||
|
||||
- **Frontend:** http://localhost:3000
|
||||
- **Backend API:** http://localhost:8080
|
||||
|
||||
<Callout>
|
||||
If you prefer running the Docker Compose steps manually: `cp .env.example .env`, edit `JWT_SECRET`, then `docker compose -f docker-compose.selfhost.yml up -d`.
|
||||
</Callout>
|
||||
|
||||
### Step 2 — Log In
|
||||
|
||||
Open http://localhost:3000. Enter any email address and use verification code **`888888`** to log in.
|
||||
|
||||
<Callout>
|
||||
This master code works in all non-production environments (when `APP_ENV` is not set to `production`). For production, configure an email provider — see [Configuration](#configuration) below.
|
||||
</Callout>
|
||||
|
||||
### Step 3 — Install CLI & Start Daemon
|
||||
|
||||
The daemon runs on your local machine (not inside Docker). It detects installed AI agent CLIs, registers them with the server, and executes tasks.
|
||||
|
||||
### a) Install the CLI and an AI agent
|
||||
Edit `.env` with your production values (see [Configuration](#configuration) below), then:
|
||||
|
||||
```bash
|
||||
brew install multica-ai/tap/multica
|
||||
# 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
|
||||
```
|
||||
|
||||
You also need at least one AI agent CLI:
|
||||
- [Claude Code](https://docs.anthropic.com/en/docs/claude-code) (`claude` on PATH)
|
||||
- [Codex](https://github.com/openai/codex) (`codex` on PATH)
|
||||
|
||||
### b) One-command setup
|
||||
For the frontend:
|
||||
|
||||
```bash
|
||||
multica setup --local
|
||||
pnpm install
|
||||
pnpm build
|
||||
|
||||
# Start the frontend (production mode)
|
||||
cd apps/web
|
||||
REMOTE_API_URL=http://localhost:8080 pnpm start
|
||||
```
|
||||
|
||||
This automatically:
|
||||
1. Configures the CLI to connect to `localhost`
|
||||
2. Opens your browser for authentication
|
||||
3. Discovers your workspaces
|
||||
4. Starts the daemon in the background
|
||||
|
||||
Verify the daemon is running:
|
||||
|
||||
```bash
|
||||
multica daemon status
|
||||
```
|
||||
|
||||
<Callout>
|
||||
Alternatively, configure manually: `multica config local && multica login && multica daemon start`
|
||||
</Callout>
|
||||
|
||||
### Step 4 — Verify & Start Using
|
||||
|
||||
1. Open your workspace at http://localhost:3000
|
||||
2. Navigate to **Settings → Runtimes** — you should see your machine listed
|
||||
3. Go to **Settings → Agents** and create a new agent
|
||||
4. Create an issue and assign it to your agent
|
||||
|
||||
## Stopping Services
|
||||
|
||||
```bash
|
||||
# Stop Docker Compose services
|
||||
make selfhost-stop
|
||||
|
||||
# Stop the local daemon
|
||||
multica daemon stop
|
||||
```
|
||||
|
||||
## Switching to Multica Cloud
|
||||
|
||||
If you've been self-hosting and want to switch your CLI to [Multica Cloud](https://multica.ai):
|
||||
|
||||
```bash
|
||||
multica config set server_url https://api.multica.ai
|
||||
multica config set app_url https://multica.ai
|
||||
multica login
|
||||
```
|
||||
|
||||
Or re-run the install script without `--local` — it will reconfigure the CLI automatically:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.sh | bash
|
||||
```
|
||||
|
||||
<Callout>
|
||||
Your local Docker services are unaffected. Stop them separately if you no longer need them.
|
||||
</Callout>
|
||||
|
||||
## Rebuilding After Updates
|
||||
|
||||
```bash
|
||||
git pull
|
||||
make selfhost
|
||||
```
|
||||
|
||||
Migrations run automatically on backend startup.
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
All configuration is done via environment variables. Copy `.env.example` as a starting point.
|
||||
@@ -243,36 +151,6 @@ Migrations must be run before starting the server:
|
||||
cd server && go run ./cmd/migrate up
|
||||
```
|
||||
|
||||
## Manual Setup (Without Docker Compose)
|
||||
|
||||
If you prefer to build and run services manually:
|
||||
|
||||
**Prerequisites:** Go 1.26+, Node.js 20+, pnpm 10.28+, PostgreSQL 17 with pgvector.
|
||||
|
||||
```bash
|
||||
# Start your PostgreSQL (or use: docker compose up -d postgres)
|
||||
|
||||
# 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 JWT_SECRET="your-secret" ./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
|
||||
```
|
||||
|
||||
## Reverse Proxy
|
||||
|
||||
In production, put a reverse proxy in front of both the backend and frontend to handle TLS and routing.
|
||||
@@ -361,6 +239,39 @@ GET /health
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
> **Note:** Use `https://` and `wss://` for production deployments behind a TLS-terminating reverse proxy. For local or development deployments without TLS, use `http://` and `ws://` instead.
|
||||
|
||||
The daemon auto-detects installed agent CLIs and registers itself with the server.
|
||||
|
||||
## Upgrading
|
||||
|
||||
1. Pull the latest code or image
|
||||
|
||||
@@ -4,11 +4,11 @@ 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 project management platform for human + agent teams.",
|
||||
"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 project management for human + agent teams.",
|
||||
"The story behind Multica and why we're building AI-native task management.",
|
||||
url: "/about",
|
||||
},
|
||||
alternates: {
|
||||
|
||||
@@ -6,7 +6,7 @@ export const metadata: Metadata = {
|
||||
description:
|
||||
"Multica — open-source platform that turns coding agents into real teammates. Assign tasks, track progress, compound skills.",
|
||||
openGraph: {
|
||||
title: "Multica — Project Management for Human + Agent Teams",
|
||||
title: "Multica — AI-Native Task Management",
|
||||
description:
|
||||
"Manage your human + agent workforce in one place.",
|
||||
url: "/homepage",
|
||||
|
||||
@@ -30,7 +30,7 @@ const jsonLd = {
|
||||
applicationCategory: "ProjectManagement",
|
||||
operatingSystem: "Web",
|
||||
description:
|
||||
"Open-source project management platform that turns coding agents into real teammates.",
|
||||
"AI-native task management platform that turns coding agents into real teammates.",
|
||||
offers: {
|
||||
"@type": "Offer",
|
||||
price: "0",
|
||||
|
||||
@@ -3,12 +3,12 @@ import { MulticaLanding } from "@/features/landing/components/multica-landing";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
absolute: "Multica — Project Management for Human + Agent Teams",
|
||||
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 — Project Management for Human + Agent Teams",
|
||||
title: "Multica — AI-Native Task Management",
|
||||
description:
|
||||
"Manage your human + agent workforce in one place.",
|
||||
url: "/",
|
||||
|
||||
@@ -22,7 +22,7 @@ export const viewport: Viewport = {
|
||||
export const metadata: Metadata = {
|
||||
metadataBase: new URL("https://www.multica.ai"),
|
||||
title: {
|
||||
default: "Multica — Project Management for Human + Agent Teams",
|
||||
default: "Multica — AI-Native Task Management",
|
||||
template: "%s | Multica",
|
||||
},
|
||||
description:
|
||||
|
||||
@@ -4,25 +4,8 @@ import { LandingHeader } from "./landing-header";
|
||||
import { LandingFooter } from "./landing-footer";
|
||||
import { useLocale } from "../i18n";
|
||||
|
||||
function ChangeList({ items }: { items: string[] }) {
|
||||
return (
|
||||
<ul className="mt-2 space-y-2">
|
||||
{items.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>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChangelogPageClient() {
|
||||
const { t } = useLocale();
|
||||
const categoryLabels = t.changelog.categories;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -37,58 +20,32 @@ export function ChangelogPageClient() {
|
||||
</p>
|
||||
|
||||
<div className="mt-16 space-y-16">
|
||||
{t.changelog.entries.map((release) => {
|
||||
const hasCategorized =
|
||||
release.features || release.improvements || release.fixes;
|
||||
|
||||
return (
|
||||
<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>
|
||||
|
||||
{hasCategorized ? (
|
||||
<div className="mt-4 space-y-5">
|
||||
{release.features && release.features.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-[13px] font-semibold uppercase tracking-wide text-[#0a0d12]/50">
|
||||
{categoryLabels.features}
|
||||
</h3>
|
||||
<ChangeList items={release.features} />
|
||||
</div>
|
||||
)}
|
||||
{release.improvements &&
|
||||
release.improvements.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-[13px] font-semibold uppercase tracking-wide text-[#0a0d12]/50">
|
||||
{categoryLabels.improvements}
|
||||
</h3>
|
||||
<ChangeList items={release.improvements} />
|
||||
</div>
|
||||
)}
|
||||
{release.fixes && release.fixes.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-[13px] font-semibold uppercase tracking-wide text-[#0a0d12]/50">
|
||||
{categoryLabels.fixes}
|
||||
</h3>
|
||||
<ChangeList items={release.fixes} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<ChangeList items={release.changes} />
|
||||
)}
|
||||
{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>
|
||||
|
||||
@@ -271,94 +271,7 @@ export const en: LandingDict = {
|
||||
changelog: {
|
||||
title: "Changelog",
|
||||
subtitle: "New updates and improvements to Multica.",
|
||||
categories: {
|
||||
features: "New Features",
|
||||
improvements: "Improvements",
|
||||
fixes: "Bug Fixes",
|
||||
},
|
||||
entries: [
|
||||
{
|
||||
version: "0.1.24",
|
||||
date: "2026-04-11",
|
||||
title: "Security & Notifications",
|
||||
changes: [],
|
||||
features: [
|
||||
"Parent issue subscribers notified on sub-issue changes",
|
||||
"CLI `--project` filter for issue list",
|
||||
],
|
||||
improvements: [
|
||||
"Meta-skill workflow defers to agent Skills instead of hardcoded logic",
|
||||
],
|
||||
fixes: [
|
||||
"Workspace ownership checks on all daemon API routes",
|
||||
"Workspace ownership validation for attachment uploads and queries",
|
||||
"Reply mentions no longer inherit parent thread's agent mentions",
|
||||
"Agent comment creation missing workspace ID",
|
||||
"Self-hosting Docker build failures (file permissions, CRLF, missing deps)",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.1.23",
|
||||
date: "2026-04-11",
|
||||
title: "Pinning, Cmd+K & Projects",
|
||||
changes: [],
|
||||
features: [
|
||||
"Pin issues and projects to sidebar with drag-and-drop reordering",
|
||||
"Cmd+K command palette — recent issues, page navigation, and project search",
|
||||
"Project detail sidebar with properties panel (replaces overview tab)",
|
||||
"Project filter in Issues tab",
|
||||
"Project completion progress in project list",
|
||||
"Auto-fill project when creating issue via 'C' shortcut on project page",
|
||||
"Assignee dropdown sorted by user's assignment frequency",
|
||||
],
|
||||
fixes: [
|
||||
"Markdown XSS — sanitize HTML rendering in comments with rehype-sanitize and server-side bluemonday",
|
||||
"Project kanban issue counts incorrect",
|
||||
"Self-hosting Docker build missing tsconfig dependencies",
|
||||
"Cmd+K requiring double ESC to close",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.1.22",
|
||||
date: "2026-04-10",
|
||||
title: "Self-Hosting, ACP & Documentation",
|
||||
changes: [],
|
||||
features: [
|
||||
"Full-stack Docker Compose for one-command self-hosting",
|
||||
"Hermes Agent Provider via ACP protocol",
|
||||
"Documentation site with Fumadocs (Getting Started, CLI reference, Agents guide)",
|
||||
"Mobile-responsive sidebar and inbox layout",
|
||||
"Token usage display per issue in the detail sidebar",
|
||||
"Switch agent runtime from the UI",
|
||||
"'C' keyboard shortcut for quick issue creation",
|
||||
"Chat session history panel for archived conversations",
|
||||
"Minimum CLI version check in daemon for Claude Code and Codex",
|
||||
"OpenClaw and OpenCode added to landing page",
|
||||
"`make dev` one-command local development setup",
|
||||
],
|
||||
improvements: [
|
||||
"Sidebar redesign — Personal / Workspace grouping, user profile footer, ⌘K search input",
|
||||
"Search ranking — case-insensitive matching, identifier search (MUL-123), multi-word support",
|
||||
"Search result keyword highlighting",
|
||||
"Daily token usage chart with cleaner Y-axis and per-category tooltip",
|
||||
"Master Agent multiline input support",
|
||||
"Unified picker components (Status, Priority, DueDate, Project, Assignee) across all views",
|
||||
"Workspace-scoped storage isolation with auto-rehydration on switch",
|
||||
"Startup warnings for missing env vars in self-hosted deployments",
|
||||
],
|
||||
fixes: [
|
||||
"Sub-issue deletion not invalidating parent's children cache",
|
||||
"Search index compatibility with pg_bigm 1.2 on RDS",
|
||||
"Create Agent showing \"No runtime available\" when runtimes exist",
|
||||
"Claude stream-json startup hangs",
|
||||
"Multiple agents unable to queue tasks for the same issue",
|
||||
"Logout not clearing workspace and query cache",
|
||||
"Drag-drop overlay too small on empty editors",
|
||||
"Skills import hardcoding \"main\" as default branch",
|
||||
"PAT authentication not working on WebSocket endpoint",
|
||||
"Runtime deletion blocked when all bound agents are archived",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.1.21",
|
||||
date: "2026-04-09",
|
||||
|
||||
@@ -85,19 +85,11 @@ export type LandingDict = {
|
||||
changelog: {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
categories: {
|
||||
features: string;
|
||||
improvements: string;
|
||||
fixes: string;
|
||||
};
|
||||
entries: {
|
||||
version: string;
|
||||
date: string;
|
||||
title: string;
|
||||
changes: string[];
|
||||
features?: string[];
|
||||
improvements?: string[];
|
||||
fixes?: string[];
|
||||
}[];
|
||||
};
|
||||
};
|
||||
|
||||
@@ -271,94 +271,7 @@ export const zh: LandingDict = {
|
||||
changelog: {
|
||||
title: "\u66f4\u65b0\u65e5\u5fd7",
|
||||
subtitle: "Multica \u7684\u6700\u65b0\u66f4\u65b0\u548c\u6539\u8fdb\u3002",
|
||||
categories: {
|
||||
features: "新功能",
|
||||
improvements: "改进",
|
||||
fixes: "问题修复",
|
||||
},
|
||||
entries: [
|
||||
{
|
||||
version: "0.1.24",
|
||||
date: "2026-04-11",
|
||||
title: "安全加固与通知",
|
||||
changes: [],
|
||||
features: [
|
||||
"子 Issue 变更时通知父 Issue 的订阅者",
|
||||
"CLI `--project` 筛选 Issue 列表",
|
||||
],
|
||||
improvements: [
|
||||
"Meta-skill 工作流改为委托 Agent Skills 而非硬编码逻辑",
|
||||
],
|
||||
fixes: [
|
||||
"Daemon API 路由新增工作区所有权校验",
|
||||
"附件上传和查询新增工作区所有权验证",
|
||||
"回复评论不再继承父级线程的 Agent 提及",
|
||||
"Agent 创建评论缺少 workspace ID",
|
||||
"自部署 Docker 构建问题修复(文件权限、CRLF 换行、缺失依赖)",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.1.23",
|
||||
date: "2026-04-11",
|
||||
title: "置顶、Cmd+K 与项目增强",
|
||||
changes: [],
|
||||
features: [
|
||||
"Issue 和项目置顶到侧边栏,支持拖拽排序",
|
||||
"Cmd+K 命令面板——最近访问的 Issue、页面导航、项目搜索",
|
||||
"项目详情侧边栏属性面板(替代原概览标签页)",
|
||||
"Issues 列表新增项目筛选",
|
||||
"项目列表显示完成进度",
|
||||
"在项目页按 'C' 创建 Issue 时自动填充项目",
|
||||
"指派人下拉按用户分配频率排序",
|
||||
],
|
||||
fixes: [
|
||||
"Markdown XSS 漏洞——评论渲染增加 rehype-sanitize 和服务端 bluemonday 清洗",
|
||||
"项目看板 Issue 计数不正确",
|
||||
"自部署 Docker 构建缺少 tsconfig 依赖",
|
||||
"Cmd+K 需要按两次 ESC 才能关闭",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.1.22",
|
||||
date: "2026-04-10",
|
||||
title: "自部署、ACP 与文档站",
|
||||
changes: [],
|
||||
features: [
|
||||
"全栈 Docker Compose 一键自部署",
|
||||
"通过 ACP 协议接入 Hermes Agent Provider",
|
||||
"基于 Fumadocs 搭建文档站(快速入门、CLI 参考、Agent 指南)",
|
||||
"侧边栏和收件箱移动端响应式布局",
|
||||
"Issue 详情侧边栏展示 Token 用量",
|
||||
"支持在 UI 中切换 Agent 运行时",
|
||||
"'C' 快捷键快速创建 Issue",
|
||||
"聊天会话历史面板,查看已归档对话",
|
||||
"Daemon 新增 Claude Code 和 Codex 最低版本检查",
|
||||
"官网新增 OpenClaw 和 OpenCode 展示",
|
||||
"`make dev` 一键本地开发环境搭建",
|
||||
],
|
||||
improvements: [
|
||||
"侧边栏重新设计——个人/工作区分组、用户档案底栏、⌘K 搜索入口",
|
||||
"搜索排序优化——大小写无关匹配、标识符搜索(MUL-123)、多词匹配",
|
||||
"搜索结果关键词高亮",
|
||||
"每日 Token 用量图表优化,Y 轴标签更清晰,新增分类 Tooltip",
|
||||
"Master Agent 支持多行输入",
|
||||
"统一选择器组件(状态、优先级、截止日期、项目、指派人)",
|
||||
"工作区级别存储隔离,切换工作区时自动加载对应数据",
|
||||
"自部署环境变量缺失时给出启动警告",
|
||||
],
|
||||
fixes: [
|
||||
"删除子 Issue 后父级列表未刷新",
|
||||
"搜索索引兼容 RDS 上的 pg_bigm 1.2",
|
||||
"创建 Agent 对话框错误显示「无可用运行时」",
|
||||
"Claude stream-json 启动卡住",
|
||||
"多个 Agent 无法同时为同一 Issue 排队任务",
|
||||
"退出登录未清除工作区和查询缓存",
|
||||
"编辑器为空时拖放区域过小",
|
||||
"Skills 导入硬编码 main 分支导致 404",
|
||||
"WebSocket 端点不支持 PAT 认证",
|
||||
"所有 Agent 已归档时无法删除运行时",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.1.21",
|
||||
date: "2026-04-09",
|
||||
|
||||
@@ -22,7 +22,6 @@ const allowedDevOrigins = process.env.CORS_ALLOWED_ORIGINS
|
||||
: undefined;
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
...(process.env.STANDALONE === "true" ? { output: "standalone" as const } : {}),
|
||||
transpilePackages: ["@multica/core", "@multica/ui", "@multica/views"],
|
||||
...(allowedDevOrigins && allowedDevOrigins.length > 0
|
||||
? { allowedDevOrigins }
|
||||
|
||||
@@ -12,15 +12,15 @@
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@multica/core": "workspace:*",
|
||||
"@multica/ui": "workspace:*",
|
||||
"@multica/views": "workspace:*",
|
||||
"@base-ui/react": "^1.3.0",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@emoji-mart/data": "^1.2.1",
|
||||
"@floating-ui/dom": "^1.7.6",
|
||||
"@multica/core": "workspace:*",
|
||||
"@multica/ui": "workspace:*",
|
||||
"@multica/views": "workspace:*",
|
||||
"@tanstack/react-query": "^5.96.2",
|
||||
"@tanstack/react-query-devtools": "^5.96.2",
|
||||
"@tiptap/extension-code-block-lowlight": "^3.22.1",
|
||||
@@ -43,14 +43,13 @@
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"dotenv": "^17.4.1",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"emoji-mart": "^5.6.0",
|
||||
"input-otp": "^1.4.2",
|
||||
"linkify-it": "^5.0.0",
|
||||
"lowlight": "^3.3.0",
|
||||
"lucide-react": "catalog:",
|
||||
"next": "^16.2.3",
|
||||
"next": "^16.1.6",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "catalog:",
|
||||
"react-day-picker": "^9.14.0",
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
# Self-hosting Docker Compose — starts PostgreSQL, backend, and frontend.
|
||||
#
|
||||
# Usage:
|
||||
# cp .env.example .env
|
||||
# # Edit .env — change JWT_SECRET at minimum
|
||||
# docker compose -f docker-compose.selfhost.yml up -d
|
||||
#
|
||||
# Frontend: http://localhost:3000
|
||||
# Backend: http://localhost:8080 (also used by CLI/daemon)
|
||||
|
||||
name: multica
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: pgvector/pgvector:pg17
|
||||
environment:
|
||||
POSTGRES_DB: ${POSTGRES_DB:-multica}
|
||||
POSTGRES_USER: ${POSTGRES_USER:-multica}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-multica}
|
||||
ports:
|
||||
- "${POSTGRES_PORT:-5432}:5432"
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-multica} -d ${POSTGRES_DB:-multica}"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
backend:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- "${PORT:-8080}:8080"
|
||||
environment:
|
||||
DATABASE_URL: postgres://${POSTGRES_USER:-multica}:${POSTGRES_PASSWORD:-multica}@postgres:5432/${POSTGRES_DB:-multica}?sslmode=disable
|
||||
PORT: "8080"
|
||||
JWT_SECRET: ${JWT_SECRET:-change-me-in-production}
|
||||
FRONTEND_ORIGIN: ${FRONTEND_ORIGIN:-http://localhost:3000}
|
||||
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-}
|
||||
RESEND_API_KEY: ${RESEND_API_KEY:-}
|
||||
RESEND_FROM_EMAIL: ${RESEND_FROM_EMAIL:-noreply@multica.ai}
|
||||
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-}
|
||||
GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-}
|
||||
GOOGLE_REDIRECT_URI: ${GOOGLE_REDIRECT_URI:-http://localhost:3000/auth/callback}
|
||||
S3_BUCKET: ${S3_BUCKET:-}
|
||||
S3_REGION: ${S3_REGION:-us-west-2}
|
||||
CLOUDFRONT_DOMAIN: ${CLOUDFRONT_DOMAIN:-}
|
||||
CLOUDFRONT_KEY_PAIR_ID: ${CLOUDFRONT_KEY_PAIR_ID:-}
|
||||
CLOUDFRONT_PRIVATE_KEY: ${CLOUDFRONT_PRIVATE_KEY:-}
|
||||
COOKIE_DOMAIN: ${COOKIE_DOMAIN:-}
|
||||
MULTICA_APP_URL: ${MULTICA_APP_URL:-http://localhost:3000}
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.web
|
||||
args:
|
||||
REMOTE_API_URL: http://backend:8080
|
||||
NEXT_PUBLIC_GOOGLE_CLIENT_ID: ${NEXT_PUBLIC_GOOGLE_CLIENT_ID:-}
|
||||
depends_on:
|
||||
- backend
|
||||
ports:
|
||||
- "${FRONTEND_PORT:-3000}:3000"
|
||||
environment:
|
||||
HOSTNAME: "0.0.0.0"
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
@@ -1,8 +0,0 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
echo "Running database migrations..."
|
||||
./migrate up
|
||||
|
||||
echo "Starting server..."
|
||||
exec ./server
|
||||
@@ -4,7 +4,6 @@ import type {
|
||||
UpdateIssueRequest,
|
||||
ListIssuesResponse,
|
||||
SearchIssuesResponse,
|
||||
SearchProjectsResponse,
|
||||
UpdateMeRequest,
|
||||
CreateMemberRequest,
|
||||
UpdateMemberRequest,
|
||||
@@ -36,7 +35,6 @@ import type {
|
||||
RuntimePing,
|
||||
RuntimeUpdate,
|
||||
TimelineEntry,
|
||||
AssigneeFrequencyEntry,
|
||||
TaskMessagePayload,
|
||||
Attachment,
|
||||
ChatSession,
|
||||
@@ -46,10 +44,6 @@ import type {
|
||||
CreateProjectRequest,
|
||||
UpdateProjectRequest,
|
||||
ListProjectsResponse,
|
||||
PinnedItem,
|
||||
CreatePinRequest,
|
||||
PinnedItemType,
|
||||
ReorderPinsRequest,
|
||||
} from "../types";
|
||||
import { type Logger, noopLogger } from "../logger";
|
||||
|
||||
@@ -188,8 +182,6 @@ export class ApiClient {
|
||||
if (params?.status) search.set("status", params.status);
|
||||
if (params?.priority) search.set("priority", params.priority);
|
||||
if (params?.assignee_id) search.set("assignee_id", params.assignee_id);
|
||||
if (params?.assignee_ids?.length) search.set("assignee_ids", params.assignee_ids.join(","));
|
||||
if (params?.creator_id) search.set("creator_id", params.creator_id);
|
||||
if (params?.open_only) search.set("open_only", "true");
|
||||
return this.fetch(`/api/issues?${search}`);
|
||||
}
|
||||
@@ -202,14 +194,6 @@ export class ApiClient {
|
||||
return this.fetch(`/api/issues/search?${search}`, params.signal ? { signal: params.signal } : undefined);
|
||||
}
|
||||
|
||||
async searchProjects(params: { q: string; limit?: number; offset?: number; include_closed?: boolean; signal?: AbortSignal }): Promise<SearchProjectsResponse> {
|
||||
const search = new URLSearchParams({ q: params.q });
|
||||
if (params.limit !== undefined) search.set("limit", String(params.limit));
|
||||
if (params.offset !== undefined) search.set("offset", String(params.offset));
|
||||
if (params.include_closed) search.set("include_closed", "true");
|
||||
return this.fetch(`/api/projects/search?${search}`, params.signal ? { signal: params.signal } : undefined);
|
||||
}
|
||||
|
||||
async getIssue(id: string): Promise<Issue> {
|
||||
return this.fetch(`/api/issues/${id}`);
|
||||
}
|
||||
@@ -273,10 +257,6 @@ export class ApiClient {
|
||||
return this.fetch(`/api/issues/${issueId}/timeline`);
|
||||
}
|
||||
|
||||
async getAssigneeFrequency(): Promise<AssigneeFrequencyEntry[]> {
|
||||
return this.fetch("/api/assignee-frequency");
|
||||
}
|
||||
|
||||
async updateComment(commentId: string, content: string): Promise<Comment> {
|
||||
return this.fetch(`/api/comments/${commentId}`, {
|
||||
method: "PUT",
|
||||
@@ -706,27 +686,4 @@ export class ApiClient {
|
||||
async deleteProject(id: string): Promise<void> {
|
||||
await this.fetch(`/api/projects/${id}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
// Pins
|
||||
async listPins(): Promise<PinnedItem[]> {
|
||||
return this.fetch("/api/pins");
|
||||
}
|
||||
|
||||
async createPin(data: CreatePinRequest): Promise<PinnedItem> {
|
||||
return this.fetch("/api/pins", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
async deletePin(itemType: PinnedItemType, itemId: string): Promise<void> {
|
||||
await this.fetch(`/api/pins/${itemType}/${itemId}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
async reorderPins(data: ReorderPinsRequest): Promise<void> {
|
||||
await this.fetch("/api/pins/reorder", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,7 +72,6 @@ export function createAuthStore(options: AuthStoreOptions) {
|
||||
|
||||
logout: () => {
|
||||
storage.removeItem("multica_token");
|
||||
storage.removeItem("multica_workspace_id");
|
||||
api.setToken(null);
|
||||
api.setWorkspaceId(null);
|
||||
onLogout?.();
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { create } from "zustand";
|
||||
import type { StorageAdapter } from "../types";
|
||||
import { getCurrentWorkspaceId, registerForWorkspaceRehydration } from "../platform/workspace-storage";
|
||||
|
||||
const AGENT_STORAGE_KEY = "multica:chat:selectedAgentId";
|
||||
const SESSION_STORAGE_KEY = "multica:chat:activeSessionId";
|
||||
@@ -40,17 +39,12 @@ export interface ChatStoreOptions {
|
||||
export function createChatStore(options: ChatStoreOptions) {
|
||||
const { storage } = options;
|
||||
|
||||
const wsKey = (base: string) => {
|
||||
const wsId = getCurrentWorkspaceId();
|
||||
return wsId ? `${base}:${wsId}` : base;
|
||||
};
|
||||
|
||||
const store = create<ChatState>((set) => ({
|
||||
return create<ChatState>((set) => ({
|
||||
isOpen: false,
|
||||
isFullscreen: false,
|
||||
activeSessionId: storage.getItem(wsKey(SESSION_STORAGE_KEY)),
|
||||
activeSessionId: storage.getItem(SESSION_STORAGE_KEY),
|
||||
pendingTaskId: null,
|
||||
selectedAgentId: storage.getItem(wsKey(AGENT_STORAGE_KEY)),
|
||||
selectedAgentId: storage.getItem(AGENT_STORAGE_KEY),
|
||||
showHistory: false,
|
||||
timelineItems: [],
|
||||
setOpen: (open) =>
|
||||
@@ -63,15 +57,15 @@ export function createChatStore(options: ChatStoreOptions) {
|
||||
toggleFullscreen: () => set((s) => ({ isFullscreen: !s.isFullscreen })),
|
||||
setActiveSession: (id) => {
|
||||
if (id) {
|
||||
storage.setItem(wsKey(SESSION_STORAGE_KEY), id);
|
||||
storage.setItem(SESSION_STORAGE_KEY, id);
|
||||
} else {
|
||||
storage.removeItem(wsKey(SESSION_STORAGE_KEY));
|
||||
storage.removeItem(SESSION_STORAGE_KEY);
|
||||
}
|
||||
set({ activeSessionId: id });
|
||||
},
|
||||
setPendingTask: (taskId) => set({ pendingTaskId: taskId, timelineItems: [] }),
|
||||
setSelectedAgentId: (id) => {
|
||||
storage.setItem(wsKey(AGENT_STORAGE_KEY), id);
|
||||
storage.setItem(AGENT_STORAGE_KEY, id);
|
||||
set({ selectedAgentId: id });
|
||||
},
|
||||
setShowHistory: (show) => set({ showHistory: show }),
|
||||
@@ -86,14 +80,4 @@ export function createChatStore(options: ChatStoreOptions) {
|
||||
}),
|
||||
clearTimeline: () => set({ timelineItems: [] }),
|
||||
}));
|
||||
|
||||
registerForWorkspaceRehydration(() => {
|
||||
store.setState({
|
||||
activeSessionId: storage.getItem(wsKey(SESSION_STORAGE_KEY)),
|
||||
selectedAgentId: storage.getItem(wsKey(AGENT_STORAGE_KEY)),
|
||||
timelineItems: [],
|
||||
});
|
||||
});
|
||||
|
||||
return store;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { api } from "../api";
|
||||
import { issueKeys, CLOSED_PAGE_SIZE, type MyIssuesFilter } from "./queries";
|
||||
import { issueKeys, CLOSED_PAGE_SIZE } from "./queries";
|
||||
import { useWorkspaceId } from "../hooks";
|
||||
import type { Issue, IssueReaction } from "../types";
|
||||
import type {
|
||||
@@ -31,15 +31,12 @@ export type ToggleIssueReactionVars = {
|
||||
// Done issue pagination
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function useLoadMoreDoneIssues(myIssues?: { scope: string; filter: MyIssuesFilter }) {
|
||||
export function useLoadMoreDoneIssues() {
|
||||
const qc = useQueryClient();
|
||||
const wsId = useWorkspaceId();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const queryKey = myIssues
|
||||
? issueKeys.myList(wsId, myIssues.scope, myIssues.filter)
|
||||
: issueKeys.list(wsId);
|
||||
const cache = qc.getQueryData<ListIssuesResponse>(queryKey);
|
||||
const cache = qc.getQueryData<ListIssuesResponse>(issueKeys.list(wsId));
|
||||
const doneLoaded = cache
|
||||
? cache.issues.filter((i) => i.status === "done").length
|
||||
: 0;
|
||||
@@ -54,9 +51,8 @@ export function useLoadMoreDoneIssues(myIssues?: { scope: string; filter: MyIssu
|
||||
status: "done",
|
||||
limit: CLOSED_PAGE_SIZE,
|
||||
offset: doneLoaded,
|
||||
...myIssues?.filter,
|
||||
});
|
||||
qc.setQueryData<ListIssuesResponse>(queryKey, (old) => {
|
||||
qc.setQueryData<ListIssuesResponse>(issueKeys.list(wsId), (old) => {
|
||||
if (!old) return old;
|
||||
const existingIds = new Set(old.issues.map((i) => i.id));
|
||||
const newIssues = res.issues.filter((i) => !existingIds.has(i.id));
|
||||
@@ -69,7 +65,7 @@ export function useLoadMoreDoneIssues(myIssues?: { scope: string; filter: MyIssu
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [qc, queryKey, doneLoaded, hasMore, isLoading, myIssues?.filter]);
|
||||
}, [qc, wsId, doneLoaded, hasMore, isLoading]);
|
||||
|
||||
return { loadMore, hasMore, isLoading, doneTotal };
|
||||
}
|
||||
@@ -184,28 +180,24 @@ export function useDeleteIssue() {
|
||||
onMutate: async (id) => {
|
||||
await qc.cancelQueries({ queryKey: issueKeys.list(wsId) });
|
||||
const prevList = qc.getQueryData<ListIssuesResponse>(issueKeys.list(wsId));
|
||||
const deleted = prevList?.issues.find((i) => i.id === id);
|
||||
qc.setQueryData<ListIssuesResponse>(issueKeys.list(wsId), (old) => {
|
||||
if (!old) return old;
|
||||
const d = old.issues.find((i) => i.id === id);
|
||||
const deleted = old.issues.find((i) => i.id === id);
|
||||
return {
|
||||
...old,
|
||||
issues: old.issues.filter((i) => i.id !== id),
|
||||
total: old.total - 1,
|
||||
doneTotal: (old.doneTotal ?? 0) - (d?.status === "done" ? 1 : 0),
|
||||
doneTotal: (old.doneTotal ?? 0) - (deleted?.status === "done" ? 1 : 0),
|
||||
};
|
||||
});
|
||||
qc.removeQueries({ queryKey: issueKeys.detail(wsId, id) });
|
||||
return { prevList, parentIssueId: deleted?.parent_issue_id };
|
||||
return { prevList };
|
||||
},
|
||||
onError: (_err, _id, ctx) => {
|
||||
if (ctx?.prevList) qc.setQueryData(issueKeys.list(wsId), ctx.prevList);
|
||||
},
|
||||
onSettled: (_data, _err, _id, ctx) => {
|
||||
onSettled: () => {
|
||||
qc.invalidateQueries({ queryKey: issueKeys.list(wsId) });
|
||||
if (ctx?.parentIssueId) {
|
||||
qc.invalidateQueries({ queryKey: issueKeys.children(wsId, ctx.parentIssueId) });
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -253,14 +245,9 @@ export function useBatchDeleteIssues() {
|
||||
onMutate: async (ids) => {
|
||||
await qc.cancelQueries({ queryKey: issueKeys.list(wsId) });
|
||||
const prevList = qc.getQueryData<ListIssuesResponse>(issueKeys.list(wsId));
|
||||
const idSet = new Set(ids);
|
||||
const parentIssueIds = new Set(
|
||||
prevList?.issues
|
||||
.filter((i) => idSet.has(i.id) && i.parent_issue_id)
|
||||
.map((i) => i.parent_issue_id!) ?? [],
|
||||
);
|
||||
qc.setQueryData<ListIssuesResponse>(issueKeys.list(wsId), (old) => {
|
||||
if (!old) return old;
|
||||
const idSet = new Set(ids);
|
||||
const doneDeleted = old.issues.filter(
|
||||
(i) => idSet.has(i.id) && i.status === "done",
|
||||
).length;
|
||||
@@ -271,18 +258,13 @@ export function useBatchDeleteIssues() {
|
||||
doneTotal: (old.doneTotal ?? 0) - doneDeleted,
|
||||
};
|
||||
});
|
||||
return { prevList, parentIssueIds };
|
||||
return { prevList };
|
||||
},
|
||||
onError: (_err, _ids, ctx) => {
|
||||
if (ctx?.prevList) qc.setQueryData(issueKeys.list(wsId), ctx.prevList);
|
||||
},
|
||||
onSettled: (_data, _err, _ids, ctx) => {
|
||||
onSettled: () => {
|
||||
qc.invalidateQueries({ queryKey: issueKeys.list(wsId) });
|
||||
if (ctx?.parentIssueIds) {
|
||||
for (const parentId of ctx.parentIssueIds) {
|
||||
qc.invalidateQueries({ queryKey: issueKeys.children(wsId, parentId) });
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,15 +1,9 @@
|
||||
import { queryOptions } from "@tanstack/react-query";
|
||||
import { api } from "../api";
|
||||
import type { ListIssuesParams } from "../types";
|
||||
|
||||
export const issueKeys = {
|
||||
all: (wsId: string) => ["issues", wsId] as const,
|
||||
list: (wsId: string) => [...issueKeys.all(wsId), "list"] as const,
|
||||
/** All "my issues" queries — use for bulk invalidation. */
|
||||
myAll: (wsId: string) => [...issueKeys.all(wsId), "my"] as const,
|
||||
/** Per-scope "my issues" list with filter identity baked into the key. */
|
||||
myList: (wsId: string, scope: string, filter: MyIssuesFilter) =>
|
||||
[...issueKeys.myAll(wsId), scope, filter] as const,
|
||||
detail: (wsId: string, id: string) =>
|
||||
[...issueKeys.all(wsId), "detail", id] as const,
|
||||
children: (wsId: string, id: string) =>
|
||||
@@ -21,8 +15,6 @@ export const issueKeys = {
|
||||
usage: (issueId: string) => ["issues", "usage", issueId] as const,
|
||||
};
|
||||
|
||||
export type MyIssuesFilter = Pick<ListIssuesParams, "assignee_id" | "assignee_ids" | "creator_id">;
|
||||
|
||||
export const CLOSED_PAGE_SIZE = 50;
|
||||
|
||||
/**
|
||||
@@ -51,37 +43,6 @@ export function issueListOptions(wsId: string) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Server-filtered issue list for the My Issues page.
|
||||
* Each scope gets its own cache entry so switching tabs is instant after first load.
|
||||
*/
|
||||
export function myIssueListOptions(
|
||||
wsId: string,
|
||||
scope: string,
|
||||
filter: MyIssuesFilter,
|
||||
) {
|
||||
return queryOptions({
|
||||
queryKey: issueKeys.myList(wsId, scope, filter),
|
||||
queryFn: async () => {
|
||||
const [openRes, closedRes] = await Promise.all([
|
||||
api.listIssues({ open_only: true, ...filter }),
|
||||
api.listIssues({
|
||||
status: "done",
|
||||
limit: CLOSED_PAGE_SIZE,
|
||||
offset: 0,
|
||||
...filter,
|
||||
}),
|
||||
]);
|
||||
return {
|
||||
issues: [...openRes.issues, ...closedRes.issues],
|
||||
total: openRes.total + closedRes.total,
|
||||
doneTotal: closedRes.total,
|
||||
};
|
||||
},
|
||||
select: (data) => data.issues,
|
||||
});
|
||||
}
|
||||
|
||||
export function issueDetailOptions(wsId: string, id: string) {
|
||||
return queryOptions({
|
||||
queryKey: issueKeys.detail(wsId, id),
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { create } from "zustand";
|
||||
import { createJSONStorage, persist } from "zustand/middleware";
|
||||
import { persist } from "zustand/middleware";
|
||||
import type { IssueStatus, IssuePriority, IssueAssigneeType } from "../../types";
|
||||
import { createWorkspaceAwareStorage, registerForWorkspaceRehydration } from "../../platform/workspace-storage";
|
||||
import { defaultStorage } from "../../platform/storage";
|
||||
|
||||
interface IssueDraft {
|
||||
title: string;
|
||||
@@ -43,11 +41,6 @@ export const useIssueDraftStore = create<IssueDraftStore>()(
|
||||
return !!(draft.title || draft.description);
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: "multica_issue_draft",
|
||||
storage: createJSONStorage(() => createWorkspaceAwareStorage(defaultStorage)),
|
||||
},
|
||||
{ name: "multica_issue_draft" },
|
||||
),
|
||||
);
|
||||
|
||||
registerForWorkspaceRehydration(() => useIssueDraftStore.persist.rehydrate());
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
export { useIssueSelectionStore } from "./selection-store";
|
||||
export { useIssueDraftStore } from "./draft-store";
|
||||
export { useRecentIssuesStore, type RecentIssueEntry } from "./recent-issues-store";
|
||||
export {
|
||||
ViewStoreProvider,
|
||||
useViewStore,
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { create } from "zustand";
|
||||
import { createJSONStorage, persist } from "zustand/middleware";
|
||||
import { createWorkspaceAwareStorage, registerForWorkspaceRehydration } from "../../platform/workspace-storage";
|
||||
import { defaultStorage } from "../../platform/storage";
|
||||
import { persist } from "zustand/middleware";
|
||||
|
||||
export type IssuesScope = "all" | "members" | "agents";
|
||||
|
||||
@@ -18,11 +16,6 @@ export const useIssuesScopeStore = create<IssuesScopeState>()(
|
||||
scope: "all",
|
||||
setScope: (scope) => set({ scope }),
|
||||
}),
|
||||
{
|
||||
name: "multica_issues_scope",
|
||||
storage: createJSONStorage(() => createWorkspaceAwareStorage(defaultStorage)),
|
||||
},
|
||||
{ name: "multica_issues_scope" },
|
||||
),
|
||||
);
|
||||
|
||||
registerForWorkspaceRehydration(() => useIssuesScopeStore.persist.rehydrate());
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
viewStoreSlice,
|
||||
viewStorePersistOptions,
|
||||
} from "./view-store";
|
||||
import { registerForWorkspaceRehydration } from "../../platform/workspace-storage";
|
||||
|
||||
export type MyIssuesScope = "assigned" | "created" | "agents";
|
||||
|
||||
@@ -18,7 +17,7 @@ export interface MyIssuesViewState extends IssueViewState {
|
||||
|
||||
const basePersist = viewStorePersistOptions("multica_my_issues_view");
|
||||
|
||||
const _myIssuesViewStore = createStore<MyIssuesViewState>()(
|
||||
export const myIssuesViewStore: StoreApi<MyIssuesViewState> = createStore<MyIssuesViewState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
...viewStoreSlice(set as unknown as StoreApi<IssueViewState>["setState"]),
|
||||
@@ -27,7 +26,6 @@ const _myIssuesViewStore = createStore<MyIssuesViewState>()(
|
||||
}),
|
||||
{
|
||||
name: basePersist.name,
|
||||
storage: basePersist.storage,
|
||||
partialize: (state: MyIssuesViewState) => ({
|
||||
...basePersist.partialize(state),
|
||||
scope: state.scope,
|
||||
@@ -35,7 +33,3 @@ const _myIssuesViewStore = createStore<MyIssuesViewState>()(
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
export const myIssuesViewStore: StoreApi<MyIssuesViewState> = _myIssuesViewStore;
|
||||
|
||||
registerForWorkspaceRehydration(() => _myIssuesViewStore.persist.rehydrate());
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { create } from "zustand";
|
||||
import { createJSONStorage, persist } from "zustand/middleware";
|
||||
import type { IssueStatus } from "../../types";
|
||||
import {
|
||||
createWorkspaceAwareStorage,
|
||||
registerForWorkspaceRehydration,
|
||||
} from "../../platform/workspace-storage";
|
||||
import { defaultStorage } from "../../platform/storage";
|
||||
|
||||
const MAX_RECENT_ISSUES = 20;
|
||||
|
||||
export interface RecentIssueEntry {
|
||||
id: string;
|
||||
identifier: string;
|
||||
title: string;
|
||||
status: IssueStatus;
|
||||
visitedAt: number;
|
||||
}
|
||||
|
||||
interface RecentIssuesState {
|
||||
items: RecentIssueEntry[];
|
||||
recordVisit: (entry: Omit<RecentIssueEntry, "visitedAt">) => void;
|
||||
}
|
||||
|
||||
export const useRecentIssuesStore = create<RecentIssuesState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
items: [],
|
||||
recordVisit: (entry) =>
|
||||
set((state) => {
|
||||
const filtered = state.items.filter((i) => i.id !== entry.id);
|
||||
const updated: RecentIssueEntry = { ...entry, visitedAt: Date.now() };
|
||||
return {
|
||||
items: [updated, ...filtered].slice(0, MAX_RECENT_ISSUES),
|
||||
};
|
||||
}),
|
||||
}),
|
||||
{
|
||||
name: "multica_recent_issues",
|
||||
storage: createJSONStorage(() =>
|
||||
createWorkspaceAwareStorage(defaultStorage),
|
||||
),
|
||||
partialize: (state) => ({ items: state.items }),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
registerForWorkspaceRehydration(() =>
|
||||
useRecentIssuesStore.persist.rehydrate(),
|
||||
);
|
||||
@@ -2,11 +2,9 @@
|
||||
|
||||
import { create } from "zustand";
|
||||
import { createStore, type StoreApi } from "zustand/vanilla";
|
||||
import { createJSONStorage, persist } from "zustand/middleware";
|
||||
import { persist } from "zustand/middleware";
|
||||
import type { IssueStatus, IssuePriority } from "../../types";
|
||||
import { ALL_STATUSES } from "../config";
|
||||
import { createWorkspaceAwareStorage, registerForWorkspaceRehydration } from "../../platform/workspace-storage";
|
||||
import { defaultStorage } from "../../platform/storage";
|
||||
|
||||
export type ViewMode = "board" | "list";
|
||||
export type SortField = "position" | "priority" | "due_date" | "created_at" | "title";
|
||||
@@ -46,8 +44,6 @@ export interface IssueViewState {
|
||||
assigneeFilters: ActorFilterValue[];
|
||||
includeNoAssignee: boolean;
|
||||
creatorFilters: ActorFilterValue[];
|
||||
projectFilters: string[];
|
||||
includeNoProject: boolean;
|
||||
sortBy: SortField;
|
||||
sortDirection: SortDirection;
|
||||
cardProperties: CardProperties;
|
||||
@@ -58,8 +54,6 @@ export interface IssueViewState {
|
||||
toggleAssigneeFilter: (value: ActorFilterValue) => void;
|
||||
toggleNoAssignee: () => void;
|
||||
toggleCreatorFilter: (value: ActorFilterValue) => void;
|
||||
toggleProjectFilter: (projectId: string) => void;
|
||||
toggleNoProject: () => void;
|
||||
hideStatus: (status: IssueStatus) => void;
|
||||
showStatus: (status: IssueStatus) => void;
|
||||
clearFilters: () => void;
|
||||
@@ -76,8 +70,6 @@ export const viewStoreSlice = (set: StoreApi<IssueViewState>["setState"]): Issue
|
||||
assigneeFilters: [],
|
||||
includeNoAssignee: false,
|
||||
creatorFilters: [],
|
||||
projectFilters: [],
|
||||
includeNoProject: false,
|
||||
sortBy: "position",
|
||||
sortDirection: "asc",
|
||||
cardProperties: {
|
||||
@@ -129,14 +121,6 @@ export const viewStoreSlice = (set: StoreApi<IssueViewState>["setState"]): Issue
|
||||
: [...state.creatorFilters, value],
|
||||
};
|
||||
}),
|
||||
toggleProjectFilter: (projectId) =>
|
||||
set((state) => ({
|
||||
projectFilters: state.projectFilters.includes(projectId)
|
||||
? state.projectFilters.filter((id) => id !== projectId)
|
||||
: [...state.projectFilters, projectId],
|
||||
})),
|
||||
toggleNoProject: () =>
|
||||
set((state) => ({ includeNoProject: !state.includeNoProject })),
|
||||
hideStatus: (status) =>
|
||||
set((state) => {
|
||||
// If no filter active, activate filter with all EXCEPT this one
|
||||
@@ -160,8 +144,6 @@ export const viewStoreSlice = (set: StoreApi<IssueViewState>["setState"]): Issue
|
||||
assigneeFilters: [],
|
||||
includeNoAssignee: false,
|
||||
creatorFilters: [],
|
||||
projectFilters: [],
|
||||
includeNoProject: false,
|
||||
}),
|
||||
setSortBy: (field) => set({ sortBy: field }),
|
||||
setSortDirection: (dir) => set({ sortDirection: dir }),
|
||||
@@ -182,7 +164,6 @@ export const viewStoreSlice = (set: StoreApi<IssueViewState>["setState"]): Issue
|
||||
|
||||
export const viewStorePersistOptions = (name: string) => ({
|
||||
name,
|
||||
storage: createJSONStorage(() => createWorkspaceAwareStorage(defaultStorage)),
|
||||
partialize: (state: IssueViewState) => ({
|
||||
viewMode: state.viewMode,
|
||||
statusFilters: state.statusFilters,
|
||||
@@ -190,8 +171,6 @@ export const viewStorePersistOptions = (name: string) => ({
|
||||
assigneeFilters: state.assigneeFilters,
|
||||
includeNoAssignee: state.includeNoAssignee,
|
||||
creatorFilters: state.creatorFilters,
|
||||
projectFilters: state.projectFilters,
|
||||
includeNoProject: state.includeNoProject,
|
||||
sortBy: state.sortBy,
|
||||
sortDirection: state.sortDirection,
|
||||
cardProperties: state.cardProperties,
|
||||
@@ -201,11 +180,9 @@ export const viewStorePersistOptions = (name: string) => ({
|
||||
|
||||
/** Factory: creates a vanilla StoreApi for use with React Context. */
|
||||
export function createIssueViewStore(persistKey: string): StoreApi<IssueViewState> {
|
||||
const store = createStore<IssueViewState>()(
|
||||
return createStore<IssueViewState>()(
|
||||
persist(viewStoreSlice, viewStorePersistOptions(persistKey))
|
||||
);
|
||||
registerForWorkspaceRehydration(() => store.persist.rehydrate());
|
||||
return store;
|
||||
}
|
||||
|
||||
/** Global singleton for the /issues page. */
|
||||
@@ -213,8 +190,6 @@ export const useIssueViewStore = create<IssueViewState>()(
|
||||
persist(viewStoreSlice, viewStorePersistOptions("multica_issues_view"))
|
||||
);
|
||||
|
||||
registerForWorkspaceRehydration(() => useIssueViewStore.persist.rehydrate());
|
||||
|
||||
// Clear filters on all registered view stores when workspace switches.
|
||||
const _syncedStores = new Set<StoreApi<IssueViewState>>();
|
||||
let _workspaceSyncInitialized = false;
|
||||
|
||||
@@ -17,7 +17,6 @@ export function onIssueCreated(
|
||||
doneTotal: (old.doneTotal ?? 0) + (issue.status === "done" ? 1 : 0),
|
||||
};
|
||||
});
|
||||
qc.invalidateQueries({ queryKey: issueKeys.myAll(wsId) });
|
||||
if (issue.parent_issue_id) {
|
||||
qc.invalidateQueries({ queryKey: issueKeys.children(wsId, issue.parent_issue_id) });
|
||||
}
|
||||
@@ -58,7 +57,6 @@ export function onIssueUpdated(
|
||||
doneTotal: (old.doneTotal ?? 0) + doneDelta,
|
||||
};
|
||||
});
|
||||
qc.invalidateQueries({ queryKey: issueKeys.myAll(wsId) });
|
||||
qc.setQueryData<Issue>(issueKeys.detail(wsId, issue.id), (old) =>
|
||||
old ? { ...old, ...issue } : old,
|
||||
);
|
||||
@@ -88,7 +86,6 @@ export function onIssueDeleted(
|
||||
doneTotal: (old.doneTotal ?? 0) - (del?.status === "done" ? 1 : 0),
|
||||
};
|
||||
});
|
||||
qc.invalidateQueries({ queryKey: issueKeys.myAll(wsId) });
|
||||
qc.removeQueries({ queryKey: issueKeys.detail(wsId, issueId) });
|
||||
qc.removeQueries({ queryKey: issueKeys.timeline(issueId) });
|
||||
qc.removeQueries({ queryKey: issueKeys.reactions(issueId) });
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { create } from "zustand";
|
||||
import { createJSONStorage, persist } from "zustand/middleware";
|
||||
import { createPersistStorage } from "../platform/persist-storage";
|
||||
import { defaultStorage } from "../platform/storage";
|
||||
import { persist } from "zustand/middleware";
|
||||
|
||||
const EXCLUDED_PREFIXES = ["/login", "/pair/"];
|
||||
|
||||
@@ -25,7 +23,6 @@ export const useNavigationStore = create<NavigationState>()(
|
||||
}),
|
||||
{
|
||||
name: "multica_navigation",
|
||||
storage: createJSONStorage(() => createPersistStorage(defaultStorage)),
|
||||
partialize: (state) => ({ lastPath: state.lastPath }),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -45,9 +45,6 @@
|
||||
"./projects/queries": "./projects/queries.ts",
|
||||
"./projects/mutations": "./projects/mutations.ts",
|
||||
"./projects/config": "./projects/config.ts",
|
||||
"./pins": "./pins/index.ts",
|
||||
"./pins/queries": "./pins/queries.ts",
|
||||
"./pins/mutations": "./pins/mutations.ts",
|
||||
"./realtime": "./realtime/index.ts",
|
||||
"./navigation": "./navigation/index.ts",
|
||||
"./modals": "./modals/index.ts",
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
export { pinKeys, pinListOptions } from "./queries";
|
||||
export { useCreatePin, useDeletePin, useReorderPins } from "./mutations";
|
||||
@@ -1,65 +0,0 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { api } from "../api";
|
||||
import { pinKeys } from "./queries";
|
||||
import { useWorkspaceId } from "../hooks";
|
||||
import type { PinnedItem, PinnedItemType } from "../types";
|
||||
|
||||
export function useCreatePin() {
|
||||
const qc = useQueryClient();
|
||||
const wsId = useWorkspaceId();
|
||||
return useMutation({
|
||||
mutationFn: (data: { item_type: PinnedItemType; item_id: string }) =>
|
||||
api.createPin(data),
|
||||
onSuccess: (newPin) => {
|
||||
qc.setQueryData<PinnedItem[]>(pinKeys.list(wsId), (old) =>
|
||||
old ? [...old, newPin] : [newPin],
|
||||
);
|
||||
},
|
||||
onSettled: () => {
|
||||
qc.invalidateQueries({ queryKey: pinKeys.list(wsId) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeletePin() {
|
||||
const qc = useQueryClient();
|
||||
const wsId = useWorkspaceId();
|
||||
return useMutation({
|
||||
mutationFn: ({ itemType, itemId }: { itemType: PinnedItemType; itemId: string }) =>
|
||||
api.deletePin(itemType, itemId),
|
||||
onMutate: async ({ itemType, itemId }) => {
|
||||
await qc.cancelQueries({ queryKey: pinKeys.list(wsId) });
|
||||
const prev = qc.getQueryData<PinnedItem[]>(pinKeys.list(wsId));
|
||||
qc.setQueryData<PinnedItem[]>(pinKeys.list(wsId), (old) =>
|
||||
old ? old.filter((p) => !(p.item_type === itemType && p.item_id === itemId)) : old,
|
||||
);
|
||||
return { prev };
|
||||
},
|
||||
onError: (_err, _vars, ctx) => {
|
||||
if (ctx?.prev) qc.setQueryData(pinKeys.list(wsId), ctx.prev);
|
||||
},
|
||||
onSettled: () => {
|
||||
qc.invalidateQueries({ queryKey: pinKeys.list(wsId) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useReorderPins() {
|
||||
const qc = useQueryClient();
|
||||
const wsId = useWorkspaceId();
|
||||
return useMutation({
|
||||
mutationFn: (reorderedPins: PinnedItem[]) => {
|
||||
const items = reorderedPins.map((p, i) => ({ id: p.id, position: i + 1 }));
|
||||
return api.reorderPins({ items });
|
||||
},
|
||||
onMutate: async (reorderedPins) => {
|
||||
await qc.cancelQueries({ queryKey: pinKeys.list(wsId) });
|
||||
const prev = qc.getQueryData<PinnedItem[]>(pinKeys.list(wsId));
|
||||
qc.setQueryData<PinnedItem[]>(pinKeys.list(wsId), reorderedPins);
|
||||
return { prev };
|
||||
},
|
||||
onError: (_err, _vars, ctx) => {
|
||||
if (ctx?.prev) qc.setQueryData(pinKeys.list(wsId), ctx.prev);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import { queryOptions } from "@tanstack/react-query";
|
||||
import { api } from "../api";
|
||||
|
||||
export const pinKeys = {
|
||||
all: (wsId: string) => ["pins", wsId] as const,
|
||||
list: (wsId: string) => [...pinKeys.all(wsId), "list"] as const,
|
||||
};
|
||||
|
||||
export function pinListOptions(wsId: string) {
|
||||
return queryOptions({
|
||||
queryKey: pinKeys.list(wsId),
|
||||
queryFn: () => api.listPins(),
|
||||
});
|
||||
}
|
||||
@@ -2,6 +2,3 @@ export { CoreProvider } from "./core-provider";
|
||||
export type { CoreProviderProps } from "./types";
|
||||
export { AuthInitializer } from "./auth-initializer";
|
||||
export { defaultStorage } from "./storage";
|
||||
export { createPersistStorage } from "./persist-storage";
|
||||
export { createWorkspaceAwareStorage, setCurrentWorkspaceId, getCurrentWorkspaceId, registerForWorkspaceRehydration, rehydrateAllWorkspaceStores } from "./workspace-storage";
|
||||
export { clearWorkspaceStorage } from "./storage-cleanup";
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { createPersistStorage } from "./persist-storage";
|
||||
import type { StorageAdapter } from "../types/storage";
|
||||
|
||||
function mockAdapter(): StorageAdapter {
|
||||
const store = new Map<string, string>();
|
||||
return {
|
||||
getItem: vi.fn((k) => store.get(k) ?? null),
|
||||
setItem: vi.fn((k, v) => store.set(k, v)),
|
||||
removeItem: vi.fn((k) => store.delete(k)),
|
||||
};
|
||||
}
|
||||
|
||||
describe("createPersistStorage", () => {
|
||||
it("delegates to StorageAdapter", () => {
|
||||
const adapter = mockAdapter();
|
||||
const storage = createPersistStorage(adapter);
|
||||
|
||||
storage.setItem("key", JSON.stringify("value"));
|
||||
expect(adapter.setItem).toHaveBeenCalledWith(
|
||||
"key",
|
||||
JSON.stringify("value"),
|
||||
);
|
||||
|
||||
const result = storage.getItem("key");
|
||||
expect(adapter.getItem).toHaveBeenCalledWith("key");
|
||||
expect(result).toEqual(JSON.stringify("value"));
|
||||
});
|
||||
|
||||
it("returns null for missing keys", () => {
|
||||
const adapter = mockAdapter();
|
||||
const storage = createPersistStorage(adapter);
|
||||
|
||||
const result = storage.getItem("nonexistent");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("removeItem delegates correctly", () => {
|
||||
const adapter = mockAdapter();
|
||||
const storage = createPersistStorage(adapter);
|
||||
|
||||
storage.removeItem("key");
|
||||
expect(adapter.removeItem).toHaveBeenCalledWith("key");
|
||||
});
|
||||
});
|
||||
@@ -1,14 +0,0 @@
|
||||
import type { StateStorage } from "zustand/middleware";
|
||||
import type { StorageAdapter } from "../types/storage";
|
||||
|
||||
/**
|
||||
* Bridge between Zustand persist middleware and our StorageAdapter DI system.
|
||||
* For workspace-scoped stores, use createWorkspaceAwareStorage instead.
|
||||
*/
|
||||
export function createPersistStorage(adapter: StorageAdapter): StateStorage {
|
||||
return {
|
||||
getItem: (key) => adapter.getItem(key),
|
||||
setItem: (key, value) => adapter.setItem(key, value),
|
||||
removeItem: (key) => adapter.removeItem(key),
|
||||
};
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { clearWorkspaceStorage } from "./storage-cleanup";
|
||||
|
||||
describe("clearWorkspaceStorage", () => {
|
||||
it("removes all workspace-scoped keys for given wsId", () => {
|
||||
const adapter = {
|
||||
getItem: vi.fn(),
|
||||
setItem: vi.fn(),
|
||||
removeItem: vi.fn(),
|
||||
};
|
||||
|
||||
clearWorkspaceStorage(adapter, "ws_123");
|
||||
|
||||
expect(adapter.removeItem).toHaveBeenCalledWith("multica_issue_draft:ws_123");
|
||||
expect(adapter.removeItem).toHaveBeenCalledWith("multica_issues_view:ws_123");
|
||||
expect(adapter.removeItem).toHaveBeenCalledWith("multica_issues_scope:ws_123");
|
||||
expect(adapter.removeItem).toHaveBeenCalledWith("multica_my_issues_view:ws_123");
|
||||
expect(adapter.removeItem).toHaveBeenCalledWith("multica:chat:selectedAgentId:ws_123");
|
||||
expect(adapter.removeItem).toHaveBeenCalledWith("multica:chat:activeSessionId:ws_123");
|
||||
expect(adapter.removeItem).toHaveBeenCalledTimes(6);
|
||||
});
|
||||
});
|
||||
@@ -1,27 +0,0 @@
|
||||
import type { StorageAdapter } from "../types/storage";
|
||||
|
||||
/**
|
||||
* Keys that are namespaced per workspace (stored as `${key}:${wsId}`).
|
||||
*
|
||||
* IMPORTANT: When adding a new workspace-scoped persist store or storage key,
|
||||
* add its key here so that workspace deletion and logout properly clean it up.
|
||||
* Also ensure the store uses `createWorkspaceAwareStorage` for its persist config.
|
||||
*/
|
||||
const WORKSPACE_SCOPED_KEYS = [
|
||||
"multica_issue_draft",
|
||||
"multica_issues_view",
|
||||
"multica_issues_scope",
|
||||
"multica_my_issues_view",
|
||||
"multica:chat:selectedAgentId",
|
||||
"multica:chat:activeSessionId",
|
||||
];
|
||||
|
||||
/** Remove all workspace-scoped storage entries for the given workspace. */
|
||||
export function clearWorkspaceStorage(
|
||||
adapter: StorageAdapter,
|
||||
wsId: string,
|
||||
) {
|
||||
for (const key of WORKSPACE_SCOPED_KEYS) {
|
||||
adapter.removeItem(`${key}:${wsId}`);
|
||||
}
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { createWorkspaceAwareStorage, setCurrentWorkspaceId } from "./workspace-storage";
|
||||
import type { StorageAdapter } from "../types/storage";
|
||||
|
||||
function mockAdapter(): StorageAdapter {
|
||||
const store = new Map<string, string>();
|
||||
return {
|
||||
getItem: vi.fn((k) => store.get(k) ?? null),
|
||||
setItem: vi.fn((k, v) => store.set(k, v)),
|
||||
removeItem: vi.fn((k) => store.delete(k)),
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
setCurrentWorkspaceId(null);
|
||||
});
|
||||
|
||||
describe("workspace-aware storage", () => {
|
||||
it("uses plain key when no workspace is set", () => {
|
||||
const adapter = mockAdapter();
|
||||
setCurrentWorkspaceId(null);
|
||||
const storage = createWorkspaceAwareStorage(adapter);
|
||||
|
||||
storage.setItem("draft", "data");
|
||||
expect(adapter.setItem).toHaveBeenCalledWith("draft", "data");
|
||||
});
|
||||
|
||||
it("namespaces key when workspace is set", () => {
|
||||
const adapter = mockAdapter();
|
||||
setCurrentWorkspaceId("ws_abc");
|
||||
const storage = createWorkspaceAwareStorage(adapter);
|
||||
|
||||
storage.setItem("draft", "data");
|
||||
expect(adapter.setItem).toHaveBeenCalledWith("draft:ws_abc", "data");
|
||||
|
||||
storage.getItem("draft");
|
||||
expect(adapter.getItem).toHaveBeenCalledWith("draft:ws_abc");
|
||||
});
|
||||
|
||||
it("follows workspace changes dynamically", () => {
|
||||
const adapter = mockAdapter();
|
||||
const storage = createWorkspaceAwareStorage(adapter);
|
||||
|
||||
setCurrentWorkspaceId("ws_1");
|
||||
storage.setItem("draft", "v1");
|
||||
expect(adapter.setItem).toHaveBeenCalledWith("draft:ws_1", "v1");
|
||||
|
||||
setCurrentWorkspaceId("ws_2");
|
||||
storage.setItem("draft", "v2");
|
||||
expect(adapter.setItem).toHaveBeenCalledWith("draft:ws_2", "v2");
|
||||
});
|
||||
|
||||
it("removeItem uses current workspace", () => {
|
||||
const adapter = mockAdapter();
|
||||
setCurrentWorkspaceId("ws_x");
|
||||
const storage = createWorkspaceAwareStorage(adapter);
|
||||
|
||||
storage.removeItem("draft");
|
||||
expect(adapter.removeItem).toHaveBeenCalledWith("draft:ws_x");
|
||||
});
|
||||
});
|
||||
@@ -1,40 +0,0 @@
|
||||
import type { StateStorage } from "zustand/middleware";
|
||||
import type { StorageAdapter } from "../types/storage";
|
||||
|
||||
let _currentWsId: string | null = null;
|
||||
const _rehydrateFns: Array<() => void> = [];
|
||||
|
||||
export function setCurrentWorkspaceId(wsId: string | null) {
|
||||
_currentWsId = wsId;
|
||||
}
|
||||
|
||||
/** Register a persist store's rehydrate function to be called on workspace switch. */
|
||||
export function registerForWorkspaceRehydration(fn: () => void) {
|
||||
_rehydrateFns.push(fn);
|
||||
}
|
||||
|
||||
/** Rehydrate all registered workspace-scoped persist stores from the new namespace. */
|
||||
export function rehydrateAllWorkspaceStores() {
|
||||
for (const fn of _rehydrateFns) {
|
||||
fn();
|
||||
}
|
||||
}
|
||||
|
||||
export function getCurrentWorkspaceId(): string | null {
|
||||
return _currentWsId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Storage that automatically namespaces keys with the current workspace ID.
|
||||
* Reads _currentWsId at call time, so it follows workspace switches dynamically.
|
||||
*/
|
||||
export function createWorkspaceAwareStorage(adapter: StorageAdapter): StateStorage {
|
||||
const resolve = (key: string) =>
|
||||
_currentWsId ? `${key}:${_currentWsId}` : key;
|
||||
|
||||
return {
|
||||
getItem: (key) => adapter.getItem(resolve(key)),
|
||||
setItem: (key, value) => adapter.setItem(resolve(key), value),
|
||||
removeItem: (key) => adapter.removeItem(resolve(key)),
|
||||
};
|
||||
}
|
||||
@@ -7,11 +7,8 @@ import type { StoreApi, UseBoundStore } from "zustand";
|
||||
import type { AuthState } from "../auth/store";
|
||||
import type { WorkspaceStore } from "../workspace/store";
|
||||
import { createLogger } from "../logger";
|
||||
import { clearWorkspaceStorage } from "../platform/storage-cleanup";
|
||||
import { defaultStorage } from "../platform/storage";
|
||||
import { issueKeys } from "../issues/queries";
|
||||
import { projectKeys } from "../projects/queries";
|
||||
import { pinKeys } from "../pins/queries";
|
||||
import { runtimeKeys } from "../runtimes/queries";
|
||||
import {
|
||||
onIssueCreated,
|
||||
@@ -100,10 +97,6 @@ export function useRealtimeSync(
|
||||
const wsId = workspaceStore.getState().workspace?.id;
|
||||
if (wsId) qc.invalidateQueries({ queryKey: projectKeys.all(wsId) });
|
||||
},
|
||||
pin: () => {
|
||||
const wsId = workspaceStore.getState().workspace?.id;
|
||||
if (wsId) qc.invalidateQueries({ queryKey: pinKeys.all(wsId) });
|
||||
},
|
||||
daemon: () => {
|
||||
const wsId = workspaceStore.getState().workspace?.id;
|
||||
if (wsId) qc.invalidateQueries({ queryKey: runtimeKeys.all(wsId) });
|
||||
@@ -245,7 +238,6 @@ export function useRealtimeSync(
|
||||
|
||||
const unsubWsDeleted = ws.on("workspace:deleted", (p) => {
|
||||
const { workspace_id } = p as WorkspaceDeletedPayload;
|
||||
clearWorkspaceStorage(defaultStorage, workspace_id);
|
||||
const currentWs = workspaceStore.getState().workspace;
|
||||
if (currentWs?.id === workspace_id) {
|
||||
logger.warn("current workspace deleted, switching");
|
||||
@@ -258,8 +250,6 @@ export function useRealtimeSync(
|
||||
const { user_id } = p as MemberRemovedPayload;
|
||||
const myUserId = authStore.getState().user?.id;
|
||||
if (user_id === myUserId) {
|
||||
const wsId = workspaceStore.getState().workspace?.id;
|
||||
if (wsId) clearWorkspaceStorage(defaultStorage, wsId);
|
||||
logger.warn("removed from workspace, switching");
|
||||
onToast?.("You were removed from this workspace", "info");
|
||||
workspaceStore.getState().refreshWorkspaces();
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
import type { Reaction } from "./comment";
|
||||
import type { Attachment } from "./attachment";
|
||||
|
||||
export interface AssigneeFrequencyEntry {
|
||||
assignee_type: string;
|
||||
assignee_id: string;
|
||||
frequency: number;
|
||||
}
|
||||
|
||||
export interface TimelineEntry {
|
||||
type: "activity" | "comment";
|
||||
id: string;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { Issue, IssueStatus, IssuePriority, IssueAssigneeType } from "./issue";
|
||||
import type { MemberRole } from "./workspace";
|
||||
import type { Project } from "./project";
|
||||
|
||||
// Issue API
|
||||
export interface CreateIssueRequest {
|
||||
@@ -36,8 +35,6 @@ export interface ListIssuesParams {
|
||||
status?: IssueStatus;
|
||||
priority?: IssuePriority;
|
||||
assignee_id?: string;
|
||||
assignee_ids?: string[];
|
||||
creator_id?: string;
|
||||
open_only?: boolean;
|
||||
}
|
||||
|
||||
@@ -58,16 +55,6 @@ export interface SearchIssuesResponse {
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface SearchProjectResult extends Project {
|
||||
match_source: "title" | "description";
|
||||
matched_snippet?: string;
|
||||
}
|
||||
|
||||
export interface SearchProjectsResponse {
|
||||
projects: SearchProjectResult[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface UpdateMeRequest {
|
||||
name?: string;
|
||||
avatar_url?: string;
|
||||
|
||||
@@ -50,9 +50,7 @@ export type WSEventType =
|
||||
| "chat:done"
|
||||
| "project:created"
|
||||
| "project:updated"
|
||||
| "project:deleted"
|
||||
| "pin:created"
|
||||
| "pin:deleted";
|
||||
| "project:deleted";
|
||||
|
||||
export interface WSMessage<T = unknown> {
|
||||
type: WSEventType;
|
||||
|
||||
@@ -25,7 +25,7 @@ export type {
|
||||
export type { Workspace, WorkspaceRepo, Member, MemberRole, User, MemberWithUser } from "./workspace";
|
||||
export type { InboxItem, InboxSeverity, InboxItemType } from "./inbox";
|
||||
export type { Comment, CommentType, CommentAuthorType, Reaction } from "./comment";
|
||||
export type { TimelineEntry, AssigneeFrequencyEntry } from "./activity";
|
||||
export type { TimelineEntry } from "./activity";
|
||||
export type { IssueSubscriber } from "./subscriber";
|
||||
export type * from "./events";
|
||||
export type * from "./api";
|
||||
@@ -33,4 +33,3 @@ export type { Attachment } from "./attachment";
|
||||
export type { ChatSession, ChatMessage, SendChatMessageResponse } from "./chat";
|
||||
export type { StorageAdapter } from "./storage";
|
||||
export type { Project, ProjectStatus, ProjectPriority, CreateProjectRequest, UpdateProjectRequest, ListProjectsResponse } from "./project";
|
||||
export type { PinnedItem, PinnedItemType, CreatePinRequest, ReorderPinsRequest } from "./pin";
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
export type PinnedItemType = "issue" | "project";
|
||||
|
||||
export interface PinnedItem {
|
||||
id: string;
|
||||
workspace_id: string;
|
||||
user_id: string;
|
||||
item_type: PinnedItemType;
|
||||
item_id: string;
|
||||
position: number;
|
||||
created_at: string;
|
||||
title: string;
|
||||
identifier?: string;
|
||||
icon?: string;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export interface CreatePinRequest {
|
||||
item_type: PinnedItemType;
|
||||
item_id: string;
|
||||
}
|
||||
|
||||
export interface ReorderPinsRequest {
|
||||
items: { id: string; position: number }[];
|
||||
}
|
||||
@@ -14,8 +14,6 @@ export interface Project {
|
||||
lead_id: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
issue_count: number;
|
||||
done_count: number;
|
||||
}
|
||||
|
||||
export interface CreateProjectRequest {
|
||||
|
||||
@@ -7,7 +7,6 @@ export const workspaceKeys = {
|
||||
members: (wsId: string) => ["workspaces", wsId, "members"] as const,
|
||||
agents: (wsId: string) => ["workspaces", wsId, "agents"] as const,
|
||||
skills: (wsId: string) => ["workspaces", wsId, "skills"] as const,
|
||||
assigneeFrequency: (wsId: string) => ["workspaces", wsId, "assignee-frequency"] as const,
|
||||
};
|
||||
|
||||
export function workspaceListOptions() {
|
||||
@@ -38,10 +37,3 @@ export function skillListOptions(wsId: string) {
|
||||
queryFn: () => api.listSkills(),
|
||||
});
|
||||
}
|
||||
|
||||
export function assigneeFrequencyOptions(wsId: string) {
|
||||
return queryOptions({
|
||||
queryKey: workspaceKeys.assigneeFrequency(wsId),
|
||||
queryFn: () => api.getAssigneeFrequency(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ import { create } from "zustand";
|
||||
import type { Workspace, StorageAdapter } from "../types";
|
||||
import type { ApiClient } from "../api/client";
|
||||
import { createLogger } from "../logger";
|
||||
import { setCurrentWorkspaceId, rehydrateAllWorkspaceStores } from "../platform/workspace-storage";
|
||||
|
||||
const logger = createLogger("workspace-store");
|
||||
|
||||
@@ -58,16 +57,12 @@ export function createWorkspaceStore(api: ApiClient, options?: WorkspaceStoreOpt
|
||||
|
||||
if (!nextWorkspace) {
|
||||
api.setWorkspaceId(null);
|
||||
setCurrentWorkspaceId(null);
|
||||
rehydrateAllWorkspaceStores();
|
||||
storage?.removeItem("multica_workspace_id");
|
||||
set({ workspace: null });
|
||||
return null;
|
||||
}
|
||||
|
||||
api.setWorkspaceId(nextWorkspace.id);
|
||||
setCurrentWorkspaceId(nextWorkspace.id);
|
||||
rehydrateAllWorkspaceStores();
|
||||
storage?.setItem("multica_workspace_id", nextWorkspace.id);
|
||||
set({ workspace: nextWorkspace });
|
||||
logger.debug("hydrate workspace", nextWorkspace.name, nextWorkspace.id);
|
||||
@@ -143,8 +138,6 @@ export function createWorkspaceStore(api: ApiClient, options?: WorkspaceStoreOpt
|
||||
|
||||
clearWorkspace: () => {
|
||||
api.setWorkspaceId(null);
|
||||
setCurrentWorkspaceId(null);
|
||||
rehydrateAllWorkspaceStores();
|
||||
set({ workspace: null, workspaces: [] });
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import * as React from 'react'
|
||||
import ReactMarkdown, { type Components, defaultUrlTransform } from 'react-markdown'
|
||||
import rehypeRaw from 'rehype-raw'
|
||||
import rehypeSanitize, { defaultSchema } from 'rehype-sanitize'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import { cn } from '@multica/ui/lib/utils'
|
||||
import { CodeBlock, InlineCode } from './CodeBlock'
|
||||
@@ -50,28 +49,6 @@ export interface MarkdownProps {
|
||||
renderMention?: (props: { type: string; id: string }) => React.ReactNode
|
||||
}
|
||||
|
||||
// Sanitization schema — extends GitHub defaults to allow code highlighting classes
|
||||
// and the mention:// protocol used for @mentions.
|
||||
const sanitizeSchema = {
|
||||
...defaultSchema,
|
||||
protocols: {
|
||||
...defaultSchema.protocols,
|
||||
href: [...(defaultSchema.protocols?.href ?? []), 'mention'],
|
||||
},
|
||||
attributes: {
|
||||
...defaultSchema.attributes,
|
||||
code: [
|
||||
...(defaultSchema.attributes?.code ?? []),
|
||||
['className', /^language-/],
|
||||
['className', /^hljs/],
|
||||
],
|
||||
img: [
|
||||
...(defaultSchema.attributes?.img ?? []),
|
||||
'alt',
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom URL transform that allows mention:// protocol (used for @mentions)
|
||||
* while keeping the default security for all other URLs.
|
||||
@@ -350,7 +327,7 @@ export function Markdown({
|
||||
<div className={cn('markdown-content break-words', className)}>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[[remarkGfm, { singleTilde: false }]]}
|
||||
rehypePlugins={[rehypeRaw, [rehypeSanitize, sanitizeSchema]]}
|
||||
rehypePlugins={[rehypeRaw]}
|
||||
urlTransform={urlTransform}
|
||||
components={components}
|
||||
>
|
||||
|
||||
@@ -53,7 +53,6 @@
|
||||
"@types/linkify-it": "^5.0.0",
|
||||
"@types/react": "catalog:",
|
||||
"@types/react-dom": "catalog:",
|
||||
"rehype-sanitize": "^6.0.0",
|
||||
"typescript": "catalog:"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Cloud, ChevronDown, Globe, Lock, Loader2 } from "lucide-react";
|
||||
import { ProviderLogo } from "../../runtimes/components/provider-logo";
|
||||
import {
|
||||
Cloud,
|
||||
Monitor,
|
||||
ChevronDown,
|
||||
Globe,
|
||||
Lock,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
import type {
|
||||
AgentVisibility,
|
||||
RuntimeDevice,
|
||||
@@ -79,7 +85,7 @@ export function CreateAgentDialog({
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 min-w-0">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground">Name</Label>
|
||||
<Input
|
||||
@@ -140,19 +146,19 @@ export function CreateAgentDialog({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0">
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground">Runtime</Label>
|
||||
<Popover open={runtimeOpen} onOpenChange={setRuntimeOpen}>
|
||||
<PopoverTrigger
|
||||
disabled={runtimes.length === 0 && !runtimesLoading}
|
||||
className="flex w-full min-w-0 items-center gap-3 rounded-lg border border-border bg-background px-3 py-2.5 mt-1.5 text-left text-sm transition-colors hover:bg-muted disabled:pointer-events-none disabled:opacity-50"
|
||||
className="flex w-full items-center gap-3 rounded-lg border border-border bg-background px-3 py-2.5 mt-1.5 text-left text-sm transition-colors hover:bg-muted disabled:pointer-events-none disabled:opacity-50"
|
||||
>
|
||||
{runtimesLoading ? (
|
||||
<Loader2 className="h-4 w-4 shrink-0 animate-spin text-muted-foreground" />
|
||||
) : selectedRuntime ? (
|
||||
<ProviderLogo provider={selectedRuntime.provider} className="h-4 w-4 shrink-0" />
|
||||
) : (
|
||||
) : selectedRuntime?.runtime_mode === "cloud" ? (
|
||||
<Cloud className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
) : (
|
||||
<Monitor className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -183,7 +189,11 @@ export function CreateAgentDialog({
|
||||
device.id === selectedRuntimeId ? "bg-accent" : "hover:bg-accent/50"
|
||||
}`}
|
||||
>
|
||||
<ProviderLogo provider={device.provider} className="h-4 w-4 shrink-0" />
|
||||
{device.runtime_mode === "cloud" ? (
|
||||
<Cloud className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
) : (
|
||||
<Monitor className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate font-medium">{device.name}</span>
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor, act } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
|
||||
// jsdom doesn't provide elementFromPoint; input-otp calls it on a timer.
|
||||
if (typeof document.elementFromPoint !== "function") {
|
||||
document.elementFromPoint = () => null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hoisted mocks
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -78,7 +83,6 @@ describe("LoginPage", () => {
|
||||
const onSuccess = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
vi.clearAllMocks();
|
||||
// Default: no existing session
|
||||
localStorage.clear();
|
||||
@@ -89,10 +93,6 @@ describe("LoginPage", () => {
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Email step rendering
|
||||
// -------------------------------------------------------------------------
|
||||
@@ -315,6 +315,8 @@ describe("LoginPage", () => {
|
||||
});
|
||||
|
||||
it("calls sendCode again when resend is clicked after cooldown", async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
|
||||
mockSendCode.mockResolvedValue(undefined);
|
||||
render(<LoginPage onSuccess={onSuccess} />);
|
||||
|
||||
@@ -346,6 +348,8 @@ describe("LoginPage", () => {
|
||||
|
||||
await user.click(resendBtn);
|
||||
expect(mockSendCode).toHaveBeenCalledTimes(2);
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@@ -77,7 +77,7 @@ function MessageBubble({
|
||||
if (message.role === "user") {
|
||||
return (
|
||||
<div className="flex justify-end">
|
||||
<div className="rounded-2xl bg-primary px-3.5 py-2 text-sm text-primary-foreground max-w-[85%] whitespace-pre-wrap break-words">
|
||||
<div className="rounded-2xl bg-primary px-3.5 py-2 text-sm text-primary-foreground max-w-[85%]">
|
||||
{message.content}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -457,4 +457,24 @@
|
||||
|
||||
.rich-text-editor .image-toolbar button:hover {
|
||||
background: color-mix(in srgb, white 15%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
/* Drag-and-drop overlay */
|
||||
.editor-drop-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 2px dashed color-mix(in srgb, var(--brand) 40%, transparent);
|
||||
border-radius: var(--radius);
|
||||
background: color-mix(in srgb, var(--brand) 3%, transparent);
|
||||
z-index: 10;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.editor-drop-overlay p {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: color-mix(in srgb, var(--brand) 60%, transparent);
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useEditor, EditorContent } from "@tiptap/react";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
@@ -82,6 +83,7 @@ const ContentEditor = forwardRef<ContentEditorRef, ContentEditorProps>(
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
const onUpdateRef = useRef(onUpdate);
|
||||
const onSubmitRef = useRef(onSubmit);
|
||||
@@ -176,6 +178,17 @@ const ContentEditor = forwardRef<ContentEditorRef, ContentEditorProps>(
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Always clear drag overlay on any drop/dragend anywhere in the document
|
||||
useEffect(() => {
|
||||
const clear = () => setDragOver(false);
|
||||
document.addEventListener("drop", clear);
|
||||
document.addEventListener("dragend", clear);
|
||||
return () => {
|
||||
document.removeEventListener("drop", clear);
|
||||
document.removeEventListener("dragend", clear);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 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(() => {
|
||||
@@ -208,8 +221,44 @@ const ContentEditor = forwardRef<ContentEditorRef, ContentEditorProps>(
|
||||
if (!editor) return null;
|
||||
|
||||
return (
|
||||
<div className="relative min-h-full">
|
||||
<div
|
||||
className={cn("relative min-h-full", dragOver && "editor-drag-over")}
|
||||
onDragEnter={(e) => {
|
||||
e.preventDefault();
|
||||
if (editable && e.dataTransfer.types.includes("Files"))
|
||||
setDragOver(true);
|
||||
}}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
}}
|
||||
onDragLeave={(e) => {
|
||||
if (!e.currentTarget.contains(e.relatedTarget as Node))
|
||||
setDragOver(false);
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
const alreadyHandled = e.nativeEvent.defaultPrevented;
|
||||
e.preventDefault();
|
||||
setDragOver(false);
|
||||
// Only upload if ProseMirror didn't already handle the drop.
|
||||
// When drop lands on the editor area, ProseMirror's handleDrop
|
||||
// processes it and calls preventDefault on the native event.
|
||||
// This fallback only fires when the overlay intercepted the drop.
|
||||
if (alreadyHandled) return;
|
||||
const files = e.dataTransfer?.files;
|
||||
if (files?.length && editor && onUploadFileRef.current) {
|
||||
const endPos = editor.state.doc.content.size;
|
||||
for (const file of Array.from(files)) {
|
||||
uploadAndInsertFile(editor, file, onUploadFileRef.current, endPos);
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<EditorContent editor={editor} />
|
||||
{dragOver && (
|
||||
<div className="editor-drop-overlay">
|
||||
<p>Drop files to upload</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
function FileDropOverlay() {
|
||||
return (
|
||||
<div className="absolute inset-0 z-10 rounded-[inherit] border border-dashed border-brand/20 bg-brand/[0.02] pointer-events-none" />
|
||||
);
|
||||
}
|
||||
|
||||
export { FileDropOverlay };
|
||||
@@ -10,5 +10,3 @@ export {
|
||||
} from "./title-editor";
|
||||
export { copyMarkdown } from "./utils/clipboard";
|
||||
export { ReadonlyContent } from "./readonly-content";
|
||||
export { useFileDropZone } from "./use-file-drop-zone";
|
||||
export { FileDropOverlay } from "./file-drop-overlay";
|
||||
|
||||
@@ -23,7 +23,6 @@ import ReactMarkdown, {
|
||||
} from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import rehypeRaw from "rehype-raw";
|
||||
import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
|
||||
import { createLowlight, common } from "lowlight";
|
||||
// @ts-expect-error -- hast-util-to-html has no bundled type declarations
|
||||
import { toHtml } from "hast-util-to-html";
|
||||
@@ -42,36 +41,6 @@ import "./content-editor.css";
|
||||
|
||||
const lowlight = createLowlight(common);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sanitization schema — extends GitHub defaults to allow file-card data attrs
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const sanitizeSchema = {
|
||||
...defaultSchema,
|
||||
protocols: {
|
||||
...defaultSchema.protocols,
|
||||
href: [...(defaultSchema.protocols?.href ?? []), "mention"],
|
||||
},
|
||||
attributes: {
|
||||
...defaultSchema.attributes,
|
||||
div: [
|
||||
...(defaultSchema.attributes?.div ?? []),
|
||||
"dataType",
|
||||
"dataHref",
|
||||
"dataFilename",
|
||||
],
|
||||
code: [
|
||||
...(defaultSchema.attributes?.code ?? []),
|
||||
["className", /^language-/],
|
||||
["className", /^hljs/],
|
||||
],
|
||||
img: [
|
||||
...(defaultSchema.attributes?.img ?? []),
|
||||
"alt",
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// URL transform — allow mention:// protocol through react-markdown's sanitizer
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -190,9 +159,7 @@ const components: Partial<Components> = {
|
||||
div: ({ node, children, ...props }) => {
|
||||
const dataType = node?.properties?.dataType as string | undefined;
|
||||
if (dataType === "fileCard") {
|
||||
const rawHref = (node?.properties?.dataHref as string) || "";
|
||||
// Only allow http(s) URLs to prevent javascript: and other dangerous schemes.
|
||||
const href = /^https?:\/\//i.test(rawHref) ? rawHref : "";
|
||||
const href = (node?.properties?.dataHref as string) || "";
|
||||
const filename = (node?.properties?.dataFilename as string) || "";
|
||||
return (
|
||||
<div className="my-1 flex items-center gap-2 rounded-md border border-border bg-muted/50 px-2.5 py-1 transition-colors hover:bg-muted">
|
||||
@@ -276,7 +243,7 @@ export function ReadonlyContent({ content, className }: ReadonlyContentProps) {
|
||||
<div className={cn("rich-text-editor readonly text-sm", className)}>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[[remarkGfm, { singleTilde: false }]]}
|
||||
rehypePlugins={[rehypeRaw, [rehypeSanitize, sanitizeSchema]]}
|
||||
rehypePlugins={[rehypeRaw]}
|
||||
urlTransform={urlTransform}
|
||||
components={components}
|
||||
>
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
import { useState, useEffect, useCallback, useRef, type DragEvent } from "react";
|
||||
|
||||
interface UseFileDropZoneOptions {
|
||||
onDrop: (files: File[]) => void;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
function useFileDropZone({ onDrop, enabled = true }: UseFileDropZoneOptions) {
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const onDropRef = useRef(onDrop);
|
||||
onDropRef.current = onDrop;
|
||||
|
||||
// Clear on any document-level drop or dragend (e.g. user drops outside the zone)
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const clear = () => setIsDragOver(false);
|
||||
document.addEventListener("drop", clear);
|
||||
document.addEventListener("dragend", clear);
|
||||
return () => {
|
||||
document.removeEventListener("drop", clear);
|
||||
document.removeEventListener("dragend", clear);
|
||||
};
|
||||
}, [enabled]);
|
||||
|
||||
const handleDragEnter = useCallback(
|
||||
(e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
if (enabled && e.dataTransfer.types.includes("Files")) {
|
||||
setIsDragOver(true);
|
||||
}
|
||||
},
|
||||
[enabled],
|
||||
);
|
||||
|
||||
const handleDragOver = useCallback((e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
}, []);
|
||||
|
||||
const handleDragLeave = useCallback((e: DragEvent) => {
|
||||
if (!e.currentTarget.contains(e.relatedTarget as Node)) {
|
||||
setIsDragOver(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(e: DragEvent) => {
|
||||
const alreadyHandled = e.nativeEvent.defaultPrevented;
|
||||
e.preventDefault();
|
||||
setIsDragOver(false);
|
||||
if (alreadyHandled || !enabled) return;
|
||||
const files = e.dataTransfer?.files;
|
||||
if (files?.length) {
|
||||
onDropRef.current(Array.from(files));
|
||||
}
|
||||
},
|
||||
[enabled],
|
||||
);
|
||||
|
||||
const dropZoneProps = {
|
||||
onDragEnter: handleDragEnter,
|
||||
onDragOver: handleDragOver,
|
||||
onDragLeave: handleDragLeave,
|
||||
onDrop: handleDrop,
|
||||
};
|
||||
|
||||
return { isDragOver: enabled && isDragOver, dropZoneProps };
|
||||
}
|
||||
|
||||
export { useFileDropZone };
|
||||
@@ -26,7 +26,6 @@ import {
|
||||
Archive,
|
||||
BookCheck,
|
||||
ListChecks,
|
||||
ArrowLeft,
|
||||
} from "lucide-react";
|
||||
import type { InboxItem } from "@multica/core/types";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
@@ -43,7 +42,6 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
} from "@multica/ui/components/ui/dropdown-menu";
|
||||
import { useIsMobile } from "@multica/ui/hooks/use-mobile";
|
||||
import { InboxListItem, timeAgo } from "./inbox-list-item";
|
||||
import { typeLabels } from "./inbox-detail-label";
|
||||
|
||||
@@ -72,7 +70,6 @@ export function InboxPage() {
|
||||
id: "multica_inbox_layout",
|
||||
});
|
||||
|
||||
const isMobile = useIsMobile();
|
||||
const selected = items.find((i) => (i.issue_id ?? i.id) === selectedKey) ?? null;
|
||||
const unreadCount = items.filter((i) => !i.read).length;
|
||||
|
||||
@@ -130,166 +127,6 @@ export function InboxPage() {
|
||||
});
|
||||
};
|
||||
|
||||
// -- Shared sub-components --------------------------------------------------
|
||||
|
||||
const listHeader = (
|
||||
<div className="flex h-12 shrink-0 items-center justify-between border-b px-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<h1 className="text-sm font-semibold">Inbox</h1>
|
||||
{unreadCount > 0 && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="text-muted-foreground"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-auto">
|
||||
<DropdownMenuItem onClick={handleMarkAllRead}>
|
||||
<CheckCheck className="h-4 w-4" />
|
||||
Mark all as read
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={handleArchiveAll}>
|
||||
<Archive className="h-4 w-4" />
|
||||
Archive all
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={handleArchiveAllRead}>
|
||||
<BookCheck className="h-4 w-4" />
|
||||
Archive all read
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={handleArchiveCompleted}>
|
||||
<ListChecks className="h-4 w-4" />
|
||||
Archive completed
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
|
||||
const listBody = items.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
|
||||
<Inbox className="mb-3 h-8 w-8 text-muted-foreground/50" />
|
||||
<p className="text-sm">No notifications</p>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
{items.map((item) => (
|
||||
<InboxListItem
|
||||
key={item.id}
|
||||
item={item}
|
||||
isSelected={(item.issue_id ?? item.id) === selectedKey}
|
||||
onClick={() => handleSelect(item)}
|
||||
onArchive={() => handleArchive(item.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
const detailContent = selected?.issue_id ? (
|
||||
<IssueDetail
|
||||
key={selected.id}
|
||||
issueId={selected.issue_id}
|
||||
defaultSidebarOpen={false}
|
||||
layoutId="multica_inbox_issue_detail_layout"
|
||||
highlightCommentId={selected.details?.comment_id ?? undefined}
|
||||
onDelete={() => {
|
||||
handleArchive(selected.id);
|
||||
}}
|
||||
/>
|
||||
) : selected ? (
|
||||
<div className="p-6">
|
||||
<h2 className="text-lg font-semibold">{selected.title}</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{typeLabels[selected.type]} · {timeAgo(selected.created_at)}
|
||||
</p>
|
||||
{selected.body && (
|
||||
<div className="mt-4 whitespace-pre-wrap text-sm leading-relaxed text-foreground/80">
|
||||
{selected.body}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleArchive(selected.id)}
|
||||
>
|
||||
<Archive className="mr-1.5 h-3.5 w-3.5" />
|
||||
Archive
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
// -- Mobile layout: list / detail toggle -----------------------------------
|
||||
|
||||
if (isMobile) {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col min-h-0">
|
||||
<div className="flex h-12 shrink-0 items-center border-b px-4">
|
||||
<Skeleton className="h-5 w-16" />
|
||||
</div>
|
||||
<div className="flex-1 min-h-0 overflow-y-auto space-y-1 p-2">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-3 px-4 py-2.5">
|
||||
<Skeleton className="h-7 w-7 shrink-0 rounded-full" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
<Skeleton className="h-3 w-1/2" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Mobile: show detail full-screen when an item is selected
|
||||
if (selected) {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col min-h-0">
|
||||
<div className="flex h-12 shrink-0 items-center border-b px-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setSelectedKey("")}
|
||||
className="gap-1.5 text-muted-foreground"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Inbox
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex-1 min-h-0 overflow-y-auto">
|
||||
{detailContent}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Mobile: full-screen list
|
||||
return (
|
||||
<div className="flex flex-1 flex-col min-h-0">
|
||||
{listHeader}
|
||||
<div className="flex-1 min-h-0 overflow-y-auto">
|
||||
{listBody}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// -- Desktop layout: resizable two-panel -----------------------------------
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<ResizablePanelGroup orientation="horizontal" className="flex-1 min-h-0" defaultLayout={defaultLayout} onLayoutChanged={onLayoutChanged}>
|
||||
@@ -325,17 +162,111 @@ export 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">
|
||||
{/* Left column -- inbox list */}
|
||||
<div className="flex flex-col border-r h-full">
|
||||
{listHeader}
|
||||
<div className="flex h-12 shrink-0 items-center justify-between border-b px-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<h1 className="text-sm font-semibold">Inbox</h1>
|
||||
{unreadCount > 0 && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="text-muted-foreground"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-auto">
|
||||
<DropdownMenuItem onClick={handleMarkAllRead}>
|
||||
<CheckCheck className="h-4 w-4" />
|
||||
Mark all as read
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={handleArchiveAll}>
|
||||
<Archive className="h-4 w-4" />
|
||||
Archive all
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={handleArchiveAllRead}>
|
||||
<BookCheck className="h-4 w-4" />
|
||||
Archive all read
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={handleArchiveCompleted}>
|
||||
<ListChecks className="h-4 w-4" />
|
||||
Archive completed
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-y-auto">
|
||||
{listBody}
|
||||
{items.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
|
||||
<Inbox className="mb-3 h-8 w-8 text-muted-foreground/50" />
|
||||
<p className="text-sm">No notifications</p>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
{items.map((item) => (
|
||||
<InboxListItem
|
||||
key={item.id}
|
||||
item={item}
|
||||
isSelected={(item.issue_id ?? item.id) === selectedKey}
|
||||
onClick={() => handleSelect(item)}
|
||||
onArchive={() => handleArchive(item.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
<ResizableHandle />
|
||||
<ResizablePanel id="detail" minSize="40%">
|
||||
{/* Right column -- detail */}
|
||||
<div className="flex flex-col min-h-0 h-full">
|
||||
{detailContent ?? (
|
||||
{selected?.issue_id ? (
|
||||
<IssueDetail
|
||||
key={selected.id}
|
||||
issueId={selected.issue_id}
|
||||
defaultSidebarOpen={false}
|
||||
layoutId="multica_inbox_issue_detail_layout"
|
||||
highlightCommentId={selected.details?.comment_id ?? undefined}
|
||||
onDelete={() => {
|
||||
handleArchive(selected.id);
|
||||
}}
|
||||
/>
|
||||
) : selected ? (
|
||||
<div className="p-6">
|
||||
<h2 className="text-lg font-semibold">{selected.title}</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{typeLabels[selected.type]} · {timeAgo(selected.created_at)}
|
||||
</p>
|
||||
{selected.body && (
|
||||
<div className="mt-4 whitespace-pre-wrap text-sm leading-relaxed text-foreground/80">
|
||||
{selected.body}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleArchive(selected.id)}
|
||||
>
|
||||
<Archive className="mr-1.5 h-3.5 w-3.5" />
|
||||
Archive
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-full flex-col items-center justify-center text-muted-foreground">
|
||||
<Inbox className="mb-3 h-10 w-10 text-muted-foreground/30" />
|
||||
<p className="text-sm">
|
||||
|
||||
@@ -119,40 +119,20 @@ export function AgentLiveCard({ issueId }: AgentLiveCardProps) {
|
||||
let cancelled = false;
|
||||
api.getActiveTasksForIssue(issueId).then(({ tasks }) => {
|
||||
if (cancelled || tasks.length === 0) return;
|
||||
|
||||
// Show cards immediately with empty timeline
|
||||
setTaskStates((prev) => {
|
||||
const next = new Map(prev);
|
||||
for (const task of tasks) {
|
||||
if (!next.has(task.id)) {
|
||||
next.set(task.id, { task, items: [] });
|
||||
}
|
||||
}
|
||||
return next;
|
||||
});
|
||||
|
||||
// Load messages per task in the background
|
||||
for (const task of tasks) {
|
||||
api.listTaskMessages(task.id).then((msgs) => {
|
||||
if (cancelled) return;
|
||||
const newStates = new Map<string, TaskState>();
|
||||
const loadPromises = tasks.map(async (task) => {
|
||||
try {
|
||||
const msgs = await api.listTaskMessages(task.id);
|
||||
const timeline = buildTimeline(msgs);
|
||||
for (const m of msgs) seenSeqs.current.add(`${m.task_id}:${m.seq}`);
|
||||
setTaskStates((prev) => {
|
||||
const next = new Map(prev);
|
||||
const existing = next.get(task.id);
|
||||
if (existing) {
|
||||
// Merge: keep any WS-delivered items not in the loaded batch
|
||||
const loadedSeqs = new Set(timeline.map((i) => i.seq));
|
||||
const wsOnly = existing.items.filter((i) => !loadedSeqs.has(i.seq));
|
||||
const merged = [...timeline, ...wsOnly].sort((a, b) => a.seq - b.seq);
|
||||
next.set(task.id, { task: existing.task, items: merged });
|
||||
} else {
|
||||
next.set(task.id, { task, items: timeline });
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}).catch(console.error);
|
||||
}
|
||||
newStates.set(task.id, { task, items: timeline });
|
||||
} catch {
|
||||
newStates.set(task.id, { task, items: [] });
|
||||
}
|
||||
});
|
||||
Promise.all(loadPromises).then(() => {
|
||||
if (!cancelled) setTaskStates(newStates);
|
||||
});
|
||||
}).catch(console.error);
|
||||
|
||||
return () => { cancelled = true; };
|
||||
@@ -374,7 +354,7 @@ function SingleAgentLiveCard({ task, items, issueId, agentName }: SingleAgentLiv
|
||||
)}
|
||||
>
|
||||
<div className="overflow-hidden">
|
||||
{items.length > 0 ? (
|
||||
{items.length > 0 && (
|
||||
<div
|
||||
ref={scrollRef}
|
||||
onScroll={handleScroll}
|
||||
@@ -400,12 +380,6 @@ function SingleAgentLiveCard({ task, items, issueId, agentName }: SingleAgentLiv
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="border-t border-info/10 px-3 py-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Live log is not available for this agent provider. Results will appear when the task completes.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -19,7 +19,6 @@ import { Eye, MoreHorizontal } from "lucide-react";
|
||||
import type { Issue, IssueStatus } from "@multica/core/types";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import { useLoadMoreDoneIssues } from "@multica/core/issues/mutations";
|
||||
import type { MyIssuesFilter } from "@multica/core/issues/queries";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
@@ -104,9 +103,6 @@ export function BoardView({
|
||||
hiddenStatuses,
|
||||
onMoveIssue,
|
||||
childProgressMap = EMPTY_PROGRESS_MAP,
|
||||
doneTotal: doneTotalOverride,
|
||||
myIssuesScope,
|
||||
myIssuesFilter,
|
||||
}: {
|
||||
issues: Issue[];
|
||||
allIssues: Issue[];
|
||||
@@ -118,18 +114,10 @@ export function BoardView({
|
||||
newPosition?: number
|
||||
) => void;
|
||||
childProgressMap?: Map<string, ChildProgress>;
|
||||
/** Override the done-column count (e.g. with a server-filtered total). */
|
||||
doneTotal?: number;
|
||||
/** When set, use the My Issues load-more hook instead of the workspace one. */
|
||||
myIssuesScope?: string;
|
||||
myIssuesFilter?: MyIssuesFilter;
|
||||
}) {
|
||||
const sortBy = useViewStore((s) => s.sortBy);
|
||||
const sortDirection = useViewStore((s) => s.sortDirection);
|
||||
const myIssuesOpts = myIssuesScope ? { scope: myIssuesScope, filter: myIssuesFilter ?? {} } : undefined;
|
||||
const { loadMore, hasMore, isLoading: loadingMore, doneTotal: hookDoneTotal } =
|
||||
useLoadMoreDoneIssues(myIssuesOpts);
|
||||
const displayDoneTotal = doneTotalOverride ?? hookDoneTotal;
|
||||
const { loadMore, hasMore, isLoading: loadingMore, doneTotal } = useLoadMoreDoneIssues();
|
||||
|
||||
// --- Drag state ---
|
||||
const [activeIssue, setActiveIssue] = useState<Issue | null>(null);
|
||||
@@ -293,7 +281,7 @@ export function BoardView({
|
||||
issueIds={columns[status] ?? []}
|
||||
issueMap={issueMapRef.current}
|
||||
childProgressMap={childProgressMap}
|
||||
totalCount={status === "done" ? displayDoneTotal : undefined}
|
||||
totalCount={status === "done" ? doneTotal : undefined}
|
||||
footer={
|
||||
status === "done" && hasMore ? (
|
||||
<InfiniteScrollSentinel onVisible={loadMore} loading={loadingMore} />
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { ChevronRight, Copy, Download, FileText, MoreHorizontal, Pencil, Trash2 } from "lucide-react";
|
||||
import { ChevronRight, Copy, MoreHorizontal, Pencil, Trash2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Card } from "@multica/ui/components/ui/card";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
@@ -30,12 +30,12 @@ import { QuickEmojiPicker } from "@multica/ui/components/common/quick-emoji-pick
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import { useActorName } from "@multica/core/workspace/hooks";
|
||||
import { timeAgo } from "@multica/core/utils";
|
||||
import { ContentEditor, type ContentEditorRef, copyMarkdown, ReadonlyContent, useFileDropZone, FileDropOverlay } from "../../editor";
|
||||
import { ContentEditor, type ContentEditorRef, copyMarkdown, ReadonlyContent } from "../../editor";
|
||||
import { FileUploadButton } from "@multica/ui/components/common/file-upload-button";
|
||||
import { useFileUpload } from "@multica/core/hooks/use-file-upload";
|
||||
import { api } from "@multica/core/api";
|
||||
import { ReplyInput } from "./reply-input";
|
||||
import type { TimelineEntry, Attachment } from "@multica/core/types";
|
||||
import type { TimelineEntry } from "@multica/core/types";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
@@ -91,44 +91,6 @@ function DeleteCommentDialog({
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Standalone attachment list — renders attachments not already in the markdown
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function AttachmentList({ attachments, content, className }: { attachments?: Attachment[]; content?: string; className?: string }) {
|
||||
if (!attachments?.length) return null;
|
||||
// Skip attachments whose URL is already referenced in the markdown content
|
||||
const standalone = content
|
||||
? attachments.filter((a) => !content.includes(a.url))
|
||||
: attachments;
|
||||
if (!standalone.length) return null;
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-1", className)}>
|
||||
{standalone.map((a) => (
|
||||
<div
|
||||
key={a.id}
|
||||
className="flex items-center gap-2 rounded-md border border-border bg-muted/50 px-2.5 py-1 transition-colors hover:bg-muted"
|
||||
>
|
||||
<FileText className="size-4 shrink-0 text-muted-foreground" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm">{a.filename}</p>
|
||||
</div>
|
||||
{a.download_url && (
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 rounded-md p-1 text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
|
||||
onClick={() => window.open(a.download_url, "_blank", "noopener,noreferrer")}
|
||||
>
|
||||
<Download className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Single comment row (used for both parent and replies within the same Card)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -153,10 +115,6 @@ function CommentRow({
|
||||
const editEditorRef = useRef<ContentEditorRef>(null);
|
||||
const cancelledRef = useRef(false);
|
||||
const { uploadWithToast } = useFileUpload(api);
|
||||
const { isDragOver, dropZoneProps } = useFileDropZone({
|
||||
onDrop: (files) => files.forEach((f) => editEditorRef.current?.uploadFile(f)),
|
||||
enabled: editing,
|
||||
});
|
||||
|
||||
const isOwn = entry.actor_type === "member" && entry.actor_id === currentUserId;
|
||||
const isTemp = entry.id.startsWith("temp-");
|
||||
@@ -263,8 +221,7 @@ function CommentRow({
|
||||
|
||||
{editing ? (
|
||||
<div
|
||||
{...dropZoneProps}
|
||||
className="relative mt-1.5 pl-8"
|
||||
className="mt-1.5 pl-8"
|
||||
onKeyDown={(e) => { if (e.key === "Escape") cancelEdit(); }}
|
||||
>
|
||||
<div className="max-h-48 overflow-y-auto text-sm leading-relaxed">
|
||||
@@ -287,14 +244,12 @@ function CommentRow({
|
||||
<Button size="sm" variant="outline" onClick={saveEdit}>Save</Button>
|
||||
</div>
|
||||
</div>
|
||||
{isDragOver && <FileDropOverlay />}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="mt-1.5 pl-8 text-sm leading-relaxed text-foreground/85">
|
||||
<ReadonlyContent content={entry.content ?? ""} />
|
||||
</div>
|
||||
<AttachmentList attachments={entry.attachments} content={entry.content} className="mt-1.5 pl-8" />
|
||||
{!isTemp && (
|
||||
<ReactionBar
|
||||
reactions={reactions}
|
||||
@@ -332,10 +287,6 @@ function CommentCard({
|
||||
const [editing, setEditing] = useState(false);
|
||||
const editEditorRef = useRef<ContentEditorRef>(null);
|
||||
const cancelledRef = useRef(false);
|
||||
const { isDragOver: parentDragOver, dropZoneProps: parentDropZoneProps } = useFileDropZone({
|
||||
onDrop: (files) => files.forEach((f) => editEditorRef.current?.uploadFile(f)),
|
||||
enabled: editing,
|
||||
});
|
||||
|
||||
const isOwn = entry.actor_type === "member" && entry.actor_id === currentUserId;
|
||||
const isTemp = entry.id.startsWith("temp-");
|
||||
@@ -480,8 +431,7 @@ function CommentCard({
|
||||
<div className="px-4 pb-3">
|
||||
{editing ? (
|
||||
<div
|
||||
{...parentDropZoneProps}
|
||||
className="relative pl-10"
|
||||
className="pl-10"
|
||||
onKeyDown={(e) => { if (e.key === "Escape") cancelEdit(); }}
|
||||
>
|
||||
<div className="max-h-48 overflow-y-auto text-sm leading-relaxed">
|
||||
@@ -504,14 +454,12 @@ function CommentCard({
|
||||
<Button size="sm" variant="outline" onClick={saveEdit}>Save</Button>
|
||||
</div>
|
||||
</div>
|
||||
{parentDragOver && <FileDropOverlay />}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="pl-10 text-sm leading-relaxed text-foreground/85">
|
||||
<ReadonlyContent content={entry.content ?? ""} />
|
||||
</div>
|
||||
<AttachmentList attachments={entry.attachments} content={entry.content} className="mt-1.5 pl-10" />
|
||||
{!isTemp && (
|
||||
<ReactionBar
|
||||
reactions={reactions}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { ArrowUp, Loader2 } from "lucide-react";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import { ContentEditor, type ContentEditorRef, useFileDropZone, FileDropOverlay } from "../../editor";
|
||||
import { ContentEditor, type ContentEditorRef } from "../../editor";
|
||||
import { FileUploadButton } from "@multica/ui/components/common/file-upload-button";
|
||||
import { useFileUpload } from "@multica/core/hooks/use-file-upload";
|
||||
import { api } from "@multica/core/api";
|
||||
@@ -19,9 +19,6 @@ function CommentInput({ issueId, onSubmit }: CommentInputProps) {
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [attachmentIds, setAttachmentIds] = useState<string[]>([]);
|
||||
const { uploadWithToast } = useFileUpload(api);
|
||||
const { isDragOver, dropZoneProps } = useFileDropZone({
|
||||
onDrop: (files) => files.forEach((f) => editorRef.current?.uploadFile(f)),
|
||||
});
|
||||
|
||||
const handleUpload = async (file: File) => {
|
||||
const result = await uploadWithToast(file, { issueId });
|
||||
@@ -46,10 +43,7 @@ function CommentInput({ issueId, onSubmit }: CommentInputProps) {
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
{...dropZoneProps}
|
||||
className="relative flex max-h-56 flex-col rounded-lg bg-card pb-8 ring-1 ring-border"
|
||||
>
|
||||
<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
|
||||
ref={editorRef}
|
||||
@@ -77,7 +71,6 @@ function CommentInput({ issueId, onSubmit }: CommentInputProps) {
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
{isDragOver && <FileDropOverlay />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -70,10 +70,6 @@ vi.mock("@multica/core/workspace/queries", () => ({
|
||||
queryKey: ["workspaces", "ws-1", "agents"],
|
||||
queryFn: () => Promise.resolve([]),
|
||||
}),
|
||||
assigneeFrequencyOptions: () => ({
|
||||
queryKey: ["workspaces", "ws-1", "assignee-frequency"],
|
||||
queryFn: () => Promise.resolve([]),
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock navigation
|
||||
@@ -89,8 +85,6 @@ vi.mock("../../navigation", () => ({
|
||||
|
||||
// Mock editor components (Tiptap requires real DOM)
|
||||
vi.mock("../../editor", () => ({
|
||||
useFileDropZone: () => ({ isDragOver: false, dropZoneProps: {} }),
|
||||
FileDropOverlay: () => null,
|
||||
ReadonlyContent: ({ content }: { content: string }) => (
|
||||
<div data-testid="readonly-content">{content}</div>
|
||||
),
|
||||
@@ -210,18 +204,6 @@ vi.mock("@multica/core/issues/config", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock recent issues store
|
||||
const mockRecordVisit = vi.fn();
|
||||
vi.mock("@multica/core/issues/stores", () => ({
|
||||
useRecentIssuesStore: Object.assign(
|
||||
(selector?: any) => {
|
||||
const state = { items: [], recordVisit: mockRecordVisit };
|
||||
return selector ? selector(state) : state;
|
||||
},
|
||||
{ getState: () => ({ items: [], recordVisit: mockRecordVisit }) },
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock modals
|
||||
vi.mock("@multica/core/modals", () => ({
|
||||
useModalStore: Object.assign(
|
||||
|
||||
@@ -12,8 +12,6 @@ import {
|
||||
Link2,
|
||||
MoreHorizontal,
|
||||
PanelRight,
|
||||
Pin,
|
||||
PinOff,
|
||||
Plus,
|
||||
Trash2,
|
||||
UserMinus,
|
||||
@@ -43,8 +41,9 @@ import {
|
||||
DropdownMenuSubContent,
|
||||
} from "@multica/ui/components/ui/dropdown-menu";
|
||||
import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from "@multica/ui/components/ui/resizable";
|
||||
import { ContentEditor, type ContentEditorRef, TitleEditor, useFileDropZone, FileDropOverlay } from "../../editor";
|
||||
import { ContentEditor, type ContentEditorRef } from "../../editor";
|
||||
import { FileUploadButton } from "@multica/ui/components/common/file-upload-button";
|
||||
import { TitleEditor } from "../../editor";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipTrigger,
|
||||
@@ -70,7 +69,6 @@ import { useWorkspaceId } from "@multica/core/hooks";
|
||||
import { issueListOptions, issueDetailOptions, childIssuesOptions, issueUsageOptions } from "@multica/core/issues/queries";
|
||||
import { memberListOptions, agentListOptions } from "@multica/core/workspace/queries";
|
||||
import { useUpdateIssue, useDeleteIssue } from "@multica/core/issues/mutations";
|
||||
import { useRecentIssuesStore } from "@multica/core/issues/stores";
|
||||
import { useIssueTimeline } from "../hooks/use-issue-timeline";
|
||||
import { useIssueReactions } from "../hooks/use-issue-reactions";
|
||||
import { useIssueSubscribers } from "../hooks/use-issue-subscribers";
|
||||
@@ -80,8 +78,6 @@ import { api } from "@multica/core/api";
|
||||
import { useModalStore } from "@multica/core/modals";
|
||||
import { timeAgo } from "@multica/core/utils";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import { pinListOptions } from "@multica/core/pins";
|
||||
import { useCreatePin, useDeletePin } from "@multica/core/pins";
|
||||
|
||||
import { ProgressRing } from "./progress-ring";
|
||||
|
||||
@@ -232,19 +228,6 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
|
||||
},
|
||||
});
|
||||
|
||||
// Record recent visit
|
||||
const recordVisit = useRecentIssuesStore((s) => s.recordVisit);
|
||||
useEffect(() => {
|
||||
if (issue) {
|
||||
recordVisit({
|
||||
id: issue.id,
|
||||
identifier: issue.identifier,
|
||||
title: issue.title,
|
||||
status: issue.status,
|
||||
});
|
||||
}
|
||||
}, [issue?.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Custom hooks — encapsulate timeline, reactions, subscribers
|
||||
const {
|
||||
timeline, loading: timelineLoading, submitComment, submitReply,
|
||||
@@ -263,12 +246,6 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
|
||||
// Token usage
|
||||
const { data: usage } = useQuery(issueUsageOptions(id));
|
||||
|
||||
// Pinned state
|
||||
const { data: pinnedItems = [] } = useQuery(pinListOptions(wsId));
|
||||
const isPinned = pinnedItems.some((p) => p.item_type === "issue" && p.item_id === id);
|
||||
const createPin = useCreatePin();
|
||||
const deletePin = useDeletePin();
|
||||
|
||||
// Sub-issue queries
|
||||
const parentIssueId = issue?.parent_issue_id;
|
||||
const { data: parentIssue = null } = useQuery({
|
||||
@@ -320,9 +297,6 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
|
||||
);
|
||||
|
||||
const descEditorRef = useRef<ContentEditorRef>(null);
|
||||
const { isDragOver: descDragOver, dropZoneProps: descDropZoneProps } = useFileDropZone({
|
||||
onDrop: (files) => files.forEach((f) => descEditorRef.current?.uploadFile(f)),
|
||||
});
|
||||
// Description uploads don't pass issueId — the URL lives in the markdown.
|
||||
// This avoids stale attachment records when users delete images from the editor.
|
||||
const handleDescriptionUpload = useCallback(
|
||||
@@ -486,27 +460,6 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className={cn("text-muted-foreground", isPinned && "text-foreground")}
|
||||
onClick={() => {
|
||||
if (isPinned) {
|
||||
deletePin.mutate({ itemType: "issue", itemId: issue.id });
|
||||
} else {
|
||||
createPin.mutate({ item_type: "issue", item_id: issue.id });
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isPinned ? <PinOff className="h-4 w-4" /> : <Pin className="h-4 w-4" />}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<TooltipContent side="bottom">{isPinned ? "Unpin from sidebar" : "Pin to sidebar"}</TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
@@ -641,18 +594,6 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
|
||||
Create sub-issue
|
||||
</DropdownMenuItem>
|
||||
|
||||
{/* Pin / Unpin */}
|
||||
<DropdownMenuItem onClick={() => {
|
||||
if (isPinned) {
|
||||
deletePin.mutate({ itemType: "issue", itemId: issue.id });
|
||||
} else {
|
||||
createPin.mutate({ item_type: "issue", item_id: issue.id });
|
||||
}
|
||||
}}>
|
||||
{isPinned ? <PinOff className="h-3.5 w-3.5" /> : <Pin className="h-3.5 w-3.5" />}
|
||||
{isPinned ? "Unpin from sidebar" : "Pin to sidebar"}
|
||||
</DropdownMenuItem>
|
||||
|
||||
{/* Copy link */}
|
||||
<DropdownMenuItem onClick={() => {
|
||||
const url = router.getShareableUrl
|
||||
@@ -761,37 +702,35 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
|
||||
</AppLink>
|
||||
)}
|
||||
|
||||
<div {...descDropZoneProps} className="relative mt-5 rounded-lg">
|
||||
<ContentEditor
|
||||
ref={descEditorRef}
|
||||
key={id}
|
||||
defaultValue={issue.description || ""}
|
||||
placeholder="Add description..."
|
||||
onUpdate={(md) => handleUpdateField({ description: md || undefined })}
|
||||
onUploadFile={handleDescriptionUpload}
|
||||
debounceMs={1500}
|
||||
/>
|
||||
<ContentEditor
|
||||
ref={descEditorRef}
|
||||
key={id}
|
||||
defaultValue={issue.description || ""}
|
||||
placeholder="Add description..."
|
||||
onUpdate={(md) => handleUpdateField({ description: md || undefined })}
|
||||
onUploadFile={handleDescriptionUpload}
|
||||
debounceMs={1500}
|
||||
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}
|
||||
getActorName={getActorName}
|
||||
/>
|
||||
)}
|
||||
<FileUploadButton
|
||||
size="sm"
|
||||
onSelect={(file) => descEditorRef.current?.uploadFile(file)}
|
||||
<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}
|
||||
getActorName={getActorName}
|
||||
/>
|
||||
</div>
|
||||
{descDragOver && <FileDropOverlay />}
|
||||
)}
|
||||
<FileUploadButton
|
||||
size="sm"
|
||||
onSelect={(file) => descEditorRef.current?.uploadFile(file)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Sub-issues — Linear-style */}
|
||||
|
||||
@@ -9,8 +9,6 @@ import {
|
||||
CircleDot,
|
||||
Columns3,
|
||||
Filter,
|
||||
FolderKanban,
|
||||
FolderMinus,
|
||||
List,
|
||||
SignalHigh,
|
||||
SlidersHorizontal,
|
||||
@@ -48,7 +46,6 @@ import { StatusIcon, PriorityIcon } from ".";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useWorkspaceId } from "@multica/core/hooks";
|
||||
import { memberListOptions, agentListOptions } from "@multica/core/workspace/queries";
|
||||
import { projectListOptions } from "@multica/core/projects/queries";
|
||||
import { ActorAvatar } from "../../common/actor-avatar";
|
||||
import {
|
||||
SORT_OPTIONS,
|
||||
@@ -91,15 +88,12 @@ function getActiveFilterCount(state: {
|
||||
assigneeFilters: ActorFilterValue[];
|
||||
includeNoAssignee: boolean;
|
||||
creatorFilters: ActorFilterValue[];
|
||||
projectFilters: string[];
|
||||
includeNoProject: boolean;
|
||||
}) {
|
||||
let count = 0;
|
||||
if (state.statusFilters.length > 0) count++;
|
||||
if (state.priorityFilters.length > 0) count++;
|
||||
if (state.assigneeFilters.length > 0 || state.includeNoAssignee) count++;
|
||||
if (state.creatorFilters.length > 0) count++;
|
||||
if (state.projectFilters.length > 0 || state.includeNoProject) count++;
|
||||
return count;
|
||||
}
|
||||
|
||||
@@ -109,9 +103,7 @@ function useIssueCounts(allIssues: Issue[]) {
|
||||
const priority = new Map<string, number>();
|
||||
const assignee = new Map<string, number>();
|
||||
const creator = new Map<string, number>();
|
||||
const project = new Map<string, number>();
|
||||
let noAssignee = 0;
|
||||
let noProject = 0;
|
||||
|
||||
for (const issue of allIssues) {
|
||||
status.set(issue.status, (status.get(issue.status) ?? 0) + 1);
|
||||
@@ -126,15 +118,9 @@ function useIssueCounts(allIssues: Issue[]) {
|
||||
|
||||
const cKey = `${issue.creator_type}:${issue.creator_id}`;
|
||||
creator.set(cKey, (creator.get(cKey) ?? 0) + 1);
|
||||
|
||||
if (!issue.project_id) {
|
||||
noProject++;
|
||||
} else {
|
||||
project.set(issue.project_id, (project.get(issue.project_id) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
|
||||
return { status, priority, assignee, creator, noAssignee, project, noProject };
|
||||
return { status, priority, assignee, creator, noAssignee };
|
||||
}, [allIssues]);
|
||||
}
|
||||
|
||||
@@ -173,7 +159,7 @@ function ActorSubContent({
|
||||
const wsId = useWorkspaceId();
|
||||
const { data: members = [] } = useQuery(memberListOptions(wsId));
|
||||
const { data: agents = [] } = useQuery(agentListOptions(wsId));
|
||||
const query = search.trim().toLowerCase();
|
||||
const query = search.toLowerCase();
|
||||
const filteredMembers = members.filter((m) =>
|
||||
m.name.toLowerCase().includes(query),
|
||||
);
|
||||
@@ -284,98 +270,6 @@ function ActorSubContent({
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Project sub-menu content
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function ProjectSubContent({
|
||||
counts,
|
||||
selected,
|
||||
onToggle,
|
||||
includeNoProject,
|
||||
onToggleNoProject,
|
||||
noProjectCount,
|
||||
}: {
|
||||
counts: Map<string, number>;
|
||||
selected: string[];
|
||||
onToggle: (projectId: string) => void;
|
||||
includeNoProject: boolean;
|
||||
onToggleNoProject: () => void;
|
||||
noProjectCount: number;
|
||||
}) {
|
||||
const [search, setSearch] = useState("");
|
||||
const wsId = useWorkspaceId();
|
||||
const { data: projects = [] } = useQuery(projectListOptions(wsId));
|
||||
const query = search.trim().toLowerCase();
|
||||
const filtered = projects.filter((p) =>
|
||||
p.title.toLowerCase().includes(query),
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="px-2 py-1.5 border-b border-foreground/5">
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Filter..."
|
||||
className="w-full bg-transparent text-sm placeholder:text-muted-foreground outline-none"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="max-h-64 overflow-y-auto p-1">
|
||||
{(!query || "no project".includes(query) || "unassigned".includes(query)) && (
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={includeNoProject}
|
||||
onCheckedChange={() => onToggleNoProject()}
|
||||
className={FILTER_ITEM_CLASS}
|
||||
>
|
||||
<HoverCheck checked={includeNoProject} />
|
||||
<FolderMinus className="size-3.5 text-muted-foreground" />
|
||||
No project
|
||||
{noProjectCount > 0 && (
|
||||
<span className="ml-auto text-xs text-muted-foreground">
|
||||
{noProjectCount}
|
||||
</span>
|
||||
)}
|
||||
</DropdownMenuCheckboxItem>
|
||||
)}
|
||||
|
||||
{filtered.map((p) => {
|
||||
const checked = selected.includes(p.id);
|
||||
const count = counts.get(p.id) ?? 0;
|
||||
return (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={p.id}
|
||||
checked={checked}
|
||||
onCheckedChange={() => onToggle(p.id)}
|
||||
className={FILTER_ITEM_CLASS}
|
||||
>
|
||||
<HoverCheck checked={checked} />
|
||||
<span className="size-3.5 flex items-center justify-center shrink-0">
|
||||
{p.icon || <FolderKanban className="size-3.5 text-muted-foreground" />}
|
||||
</span>
|
||||
<span className="truncate">{p.title}</span>
|
||||
{count > 0 && (
|
||||
<span className="ml-auto text-xs text-muted-foreground">
|
||||
{count}
|
||||
</span>
|
||||
)}
|
||||
</DropdownMenuCheckboxItem>
|
||||
);
|
||||
})}
|
||||
|
||||
{filtered.length === 0 && search && (
|
||||
<div className="px-2 py-3 text-center text-sm text-muted-foreground">
|
||||
No results
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// IssuesHeader
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -390,8 +284,6 @@ export function IssuesHeader({ scopedIssues }: { scopedIssues: Issue[] }) {
|
||||
const assigneeFilters = useViewStore((s) => s.assigneeFilters);
|
||||
const includeNoAssignee = useViewStore((s) => s.includeNoAssignee);
|
||||
const creatorFilters = useViewStore((s) => s.creatorFilters);
|
||||
const projectFilters = useViewStore((s) => s.projectFilters);
|
||||
const includeNoProject = useViewStore((s) => s.includeNoProject);
|
||||
const sortBy = useViewStore((s) => s.sortBy);
|
||||
const sortDirection = useViewStore((s) => s.sortDirection);
|
||||
const cardProperties = useViewStore((s) => s.cardProperties);
|
||||
@@ -406,8 +298,6 @@ export function IssuesHeader({ scopedIssues }: { scopedIssues: Issue[] }) {
|
||||
assigneeFilters,
|
||||
includeNoAssignee,
|
||||
creatorFilters,
|
||||
projectFilters,
|
||||
includeNoProject,
|
||||
}) > 0;
|
||||
|
||||
const sortLabel =
|
||||
@@ -578,29 +468,6 @@ export function IssuesHeader({ scopedIssues }: { scopedIssues: Issue[] }) {
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
|
||||
{/* Project */}
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<FolderKanban className="size-3.5" />
|
||||
<span className="flex-1">Project</span>
|
||||
{(projectFilters.length > 0 || includeNoProject) && (
|
||||
<span className="text-xs text-primary font-medium">
|
||||
{projectFilters.length + (includeNoProject ? 1 : 0)}
|
||||
</span>
|
||||
)}
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent className="w-auto min-w-52 p-0">
|
||||
<ProjectSubContent
|
||||
counts={counts.project}
|
||||
selected={projectFilters}
|
||||
onToggle={act.toggleProjectFilter}
|
||||
includeNoProject={includeNoProject}
|
||||
onToggleNoProject={act.toggleNoProject}
|
||||
noProjectCount={counts.noProject}
|
||||
/>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
|
||||
{/* Reset */}
|
||||
{hasActiveFilters && (
|
||||
<>
|
||||
|
||||
@@ -110,8 +110,6 @@ const mockViewState = {
|
||||
assigneeFilters: [] as { type: string; id: string }[],
|
||||
includeNoAssignee: false,
|
||||
creatorFilters: [] as { type: string; id: string }[],
|
||||
projectFilters: [] as string[],
|
||||
includeNoProject: false,
|
||||
sortBy: "position" as const,
|
||||
sortDirection: "asc" as const,
|
||||
cardProperties: { priority: true, description: true, assignee: true, dueDate: true },
|
||||
@@ -122,8 +120,6 @@ const mockViewState = {
|
||||
toggleAssigneeFilter: vi.fn(),
|
||||
toggleNoAssignee: vi.fn(),
|
||||
toggleCreatorFilter: vi.fn(),
|
||||
toggleProjectFilter: vi.fn(),
|
||||
toggleNoProject: vi.fn(),
|
||||
hideStatus: vi.fn(),
|
||||
showStatus: vi.fn(),
|
||||
clearFilters: vi.fn(),
|
||||
@@ -135,9 +131,6 @@ const mockViewState = {
|
||||
|
||||
vi.mock("@multica/core/issues/stores/view-store", () => ({
|
||||
initFilterWorkspaceSync: vi.fn(),
|
||||
registerViewStoreForWorkspaceSync: vi.fn(),
|
||||
viewStorePersistOptions: () => ({ name: "test", storage: undefined, partialize: (s: any) => s }),
|
||||
viewStoreSlice: vi.fn(),
|
||||
useIssueViewStore: Object.assign(
|
||||
(selector?: any) => (selector ? selector(mockViewState) : mockViewState),
|
||||
{ getState: () => mockViewState, setState: vi.fn() },
|
||||
@@ -188,16 +181,6 @@ vi.mock("@multica/core/issues/stores/selection-store", () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/issues/stores/recent-issues-store", () => ({
|
||||
useRecentIssuesStore: Object.assign(
|
||||
(selector?: any) => {
|
||||
const state = { items: [], recordVisit: vi.fn() };
|
||||
return selector ? selector(state) : state;
|
||||
},
|
||||
{ getState: () => ({ items: [], recordVisit: vi.fn() }) },
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/modals", () => ({
|
||||
useModalStore: Object.assign(
|
||||
() => ({ open: vi.fn() }),
|
||||
|
||||
@@ -34,8 +34,6 @@ export function IssuesPage() {
|
||||
const assigneeFilters = useIssueViewStore((s) => s.assigneeFilters);
|
||||
const includeNoAssignee = useIssueViewStore((s) => s.includeNoAssignee);
|
||||
const creatorFilters = useIssueViewStore((s) => s.creatorFilters);
|
||||
const projectFilters = useIssueViewStore((s) => s.projectFilters);
|
||||
const includeNoProject = useIssueViewStore((s) => s.includeNoProject);
|
||||
|
||||
useEffect(() => {
|
||||
initFilterWorkspaceSync();
|
||||
@@ -55,8 +53,8 @@ export function IssuesPage() {
|
||||
}, [allIssues, scope]);
|
||||
|
||||
const issues = useMemo(
|
||||
() => filterIssues(scopedIssues, { statusFilters, priorityFilters, assigneeFilters, includeNoAssignee, creatorFilters, projectFilters, includeNoProject }),
|
||||
[scopedIssues, statusFilters, priorityFilters, assigneeFilters, includeNoAssignee, creatorFilters, projectFilters, includeNoProject],
|
||||
() => filterIssues(scopedIssues, { statusFilters, priorityFilters, assigneeFilters, includeNoAssignee, creatorFilters }),
|
||||
[scopedIssues, statusFilters, priorityFilters, assigneeFilters, includeNoAssignee, creatorFilters],
|
||||
);
|
||||
|
||||
// Compute sub-issue progress for each parent from the full (unfiltered) issue list
|
||||
|
||||
@@ -7,7 +7,6 @@ import { Tooltip, TooltipTrigger, TooltipContent } from "@multica/ui/components/
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import type { Issue, IssueStatus } from "@multica/core/types";
|
||||
import { useLoadMoreDoneIssues } from "@multica/core/issues/mutations";
|
||||
import type { MyIssuesFilter } from "@multica/core/issues/queries";
|
||||
import { STATUS_CONFIG } from "@multica/core/issues/config";
|
||||
import { useModalStore } from "@multica/core/modals";
|
||||
import { useViewStore } from "@multica/core/issues/stores/view-store-context";
|
||||
@@ -23,18 +22,10 @@ export function ListView({
|
||||
issues,
|
||||
visibleStatuses,
|
||||
childProgressMap = EMPTY_PROGRESS_MAP,
|
||||
doneTotal: doneTotalOverride,
|
||||
myIssuesScope,
|
||||
myIssuesFilter,
|
||||
}: {
|
||||
issues: Issue[];
|
||||
visibleStatuses: IssueStatus[];
|
||||
childProgressMap?: Map<string, ChildProgress>;
|
||||
/** Override the done-group count (e.g. with a server-filtered total). */
|
||||
doneTotal?: number;
|
||||
/** When set, use the My Issues load-more hook instead of the workspace one. */
|
||||
myIssuesScope?: string;
|
||||
myIssuesFilter?: MyIssuesFilter;
|
||||
}) {
|
||||
const sortBy = useViewStore((s) => s.sortBy);
|
||||
const sortDirection = useViewStore((s) => s.sortDirection);
|
||||
@@ -47,10 +38,7 @@ export function ListView({
|
||||
const selectedIds = useIssueSelectionStore((s) => s.selectedIds);
|
||||
const select = useIssueSelectionStore((s) => s.select);
|
||||
const deselect = useIssueSelectionStore((s) => s.deselect);
|
||||
const myIssuesOpts = myIssuesScope ? { scope: myIssuesScope, filter: myIssuesFilter ?? {} } : undefined;
|
||||
const { loadMore, hasMore, isLoading: loadingMore, doneTotal: hookDoneTotal } =
|
||||
useLoadMoreDoneIssues(myIssuesOpts);
|
||||
const displayDoneTotal = doneTotalOverride ?? hookDoneTotal;
|
||||
const { loadMore, hasMore, isLoading: loadingMore, doneTotal } = useLoadMoreDoneIssues();
|
||||
|
||||
const issuesByStatus = useMemo(() => {
|
||||
const map = new Map<IssueStatus, Issue[]>();
|
||||
@@ -120,7 +108,7 @@ export function ListView({
|
||||
{cfg.label}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{status === "done" ? displayDoneTotal : statusIssues.length}
|
||||
{status === "done" ? doneTotal : statusIssues.length}
|
||||
</span>
|
||||
</Accordion.Trigger>
|
||||
<div className="pr-2">
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import { Lock, UserMinus } from "lucide-react";
|
||||
import type { Agent, IssueAssigneeType, UpdateIssueRequest } from "@multica/core/types";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
import { useActorName } from "@multica/core/workspace/hooks";
|
||||
import { useWorkspaceId } from "@multica/core/hooks";
|
||||
import { memberListOptions, agentListOptions, assigneeFrequencyOptions } from "@multica/core/workspace/queries";
|
||||
import { memberListOptions, agentListOptions } from "@multica/core/workspace/queries";
|
||||
import { ActorAvatar } from "../../../common/actor-avatar";
|
||||
import {
|
||||
PropertyPicker,
|
||||
@@ -50,30 +50,18 @@ export function AssigneePicker({
|
||||
const wsId = useWorkspaceId();
|
||||
const { data: members = [] } = useQuery(memberListOptions(wsId));
|
||||
const { data: agents = [] } = useQuery(agentListOptions(wsId));
|
||||
const { data: frequency = [] } = useQuery(assigneeFrequencyOptions(wsId));
|
||||
const { getActorName } = useActorName();
|
||||
|
||||
const currentMember = members.find((m) => m.user_id === user?.id);
|
||||
const memberRole = currentMember?.role;
|
||||
|
||||
// Build a lookup map from frequency data for sorting.
|
||||
const freqMap = useMemo(() => {
|
||||
const map = new Map<string, number>();
|
||||
for (const entry of frequency) {
|
||||
map.set(`${entry.assignee_type}:${entry.assignee_id}`, entry.frequency);
|
||||
}
|
||||
return map;
|
||||
}, [frequency]);
|
||||
|
||||
const getFreq = (type: string, id: string) => freqMap.get(`${type}:${id}`) ?? 0;
|
||||
|
||||
const query = filter.trim().toLowerCase();
|
||||
const filteredMembers = members
|
||||
.filter((m) => m.name.toLowerCase().includes(query))
|
||||
.sort((a, b) => getFreq("member", b.user_id) - getFreq("member", a.user_id));
|
||||
const filteredAgents = agents
|
||||
.filter((a) => !a.archived_at && a.name.toLowerCase().includes(query))
|
||||
.sort((a, b) => getFreq("agent", b.id) - getFreq("agent", a.id));
|
||||
const query = filter.toLowerCase();
|
||||
const filteredMembers = members.filter((m) =>
|
||||
m.name.toLowerCase().includes(query),
|
||||
);
|
||||
const filteredAgents = agents.filter((a) =>
|
||||
!a.archived_at && a.name.toLowerCase().includes(query),
|
||||
);
|
||||
|
||||
const isSelected = (type: string, id: string) =>
|
||||
assigneeType === type && assigneeId === id;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useRef, useState, useEffect } from "react";
|
||||
import { ArrowUp, Loader2 } from "lucide-react";
|
||||
import { ContentEditor, type ContentEditorRef, useFileDropZone, FileDropOverlay } from "../../editor";
|
||||
import { ContentEditor, type ContentEditorRef } from "../../editor";
|
||||
import { FileUploadButton } from "@multica/ui/components/common/file-upload-button";
|
||||
import { ActorAvatar } from "../../common/actor-avatar";
|
||||
import { useFileUpload } from "@multica/core/hooks/use-file-upload";
|
||||
@@ -41,9 +41,6 @@ function ReplyInput({
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [attachmentIds, setAttachmentIds] = useState<string[]>([]);
|
||||
const { uploadWithToast } = useFileUpload(api);
|
||||
const { isDragOver, dropZoneProps } = useFileDropZone({
|
||||
onDrop: (files) => files.forEach((f) => editorRef.current?.uploadFile(f)),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const el = measureRef.current;
|
||||
@@ -89,7 +86,6 @@ function ReplyInput({
|
||||
className="mt-0.5 shrink-0"
|
||||
/>
|
||||
<div
|
||||
{...dropZoneProps}
|
||||
className={cn(
|
||||
"relative min-w-0 flex-1 flex flex-col",
|
||||
size === "sm" ? "max-h-40" : "max-h-56",
|
||||
@@ -126,7 +122,6 @@ function ReplyInput({
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{isDragOver && <FileDropOverlay />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -34,7 +34,6 @@ function commentToTimelineEntry(c: Comment): TimelineEntry {
|
||||
updated_at: c.updated_at,
|
||||
comment_type: c.type,
|
||||
reactions: c.reactions ?? [],
|
||||
attachments: c.attachments ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -8,8 +8,6 @@ const NO_FILTER: IssueFilters = {
|
||||
assigneeFilters: [],
|
||||
includeNoAssignee: false,
|
||||
creatorFilters: [],
|
||||
projectFilters: [],
|
||||
includeNoProject: false,
|
||||
};
|
||||
|
||||
function makeIssue(overrides: Partial<Issue> = {}): Issue {
|
||||
@@ -37,10 +35,10 @@ function makeIssue(overrides: Partial<Issue> = {}): Issue {
|
||||
}
|
||||
|
||||
const issues: Issue[] = [
|
||||
makeIssue({ id: "1", status: "todo", priority: "high", assignee_type: "member", assignee_id: "u-1", creator_type: "member", creator_id: "u-1", project_id: "p-1" }),
|
||||
makeIssue({ id: "2", status: "in_progress", priority: "medium", assignee_type: "agent", assignee_id: "a-1", creator_type: "agent", creator_id: "a-1", project_id: "p-2" }),
|
||||
makeIssue({ id: "3", status: "done", priority: "low", assignee_type: null, assignee_id: null, creator_type: "member", creator_id: "u-2", project_id: null }),
|
||||
makeIssue({ id: "4", status: "todo", priority: "urgent", assignee_type: "member", assignee_id: "u-2", creator_type: "member", creator_id: "u-1", project_id: "p-1" }),
|
||||
makeIssue({ id: "1", status: "todo", priority: "high", assignee_type: "member", assignee_id: "u-1", creator_type: "member", creator_id: "u-1" }),
|
||||
makeIssue({ id: "2", status: "in_progress", priority: "medium", assignee_type: "agent", assignee_id: "a-1", creator_type: "agent", creator_id: "a-1" }),
|
||||
makeIssue({ id: "3", status: "done", priority: "low", assignee_type: null, assignee_id: null, creator_type: "member", creator_id: "u-2" }),
|
||||
makeIssue({ id: "4", status: "todo", priority: "urgent", assignee_type: "member", assignee_id: "u-2", creator_type: "member", creator_id: "u-1" }),
|
||||
];
|
||||
|
||||
describe("filterIssues", () => {
|
||||
@@ -116,49 +114,4 @@ describe("filterIssues", () => {
|
||||
});
|
||||
expect(result.map((i) => i.id)).toEqual(["4"]);
|
||||
});
|
||||
|
||||
// --- Project ---
|
||||
it("filters by specific project", () => {
|
||||
const result = filterIssues(issues, {
|
||||
...NO_FILTER,
|
||||
projectFilters: ["p-1"],
|
||||
});
|
||||
expect(result.map((i) => i.id)).toEqual(["1", "4"]);
|
||||
});
|
||||
|
||||
it("filters by multiple projects", () => {
|
||||
const result = filterIssues(issues, {
|
||||
...NO_FILTER,
|
||||
projectFilters: ["p-1", "p-2"],
|
||||
});
|
||||
expect(result.map((i) => i.id)).toEqual(["1", "2", "4"]);
|
||||
});
|
||||
|
||||
it("filters by 'No project' only", () => {
|
||||
const result = filterIssues(issues, { ...NO_FILTER, includeNoProject: true });
|
||||
expect(result.map((i) => i.id)).toEqual(["3"]);
|
||||
});
|
||||
|
||||
it("filters by project + No project combined", () => {
|
||||
const result = filterIssues(issues, {
|
||||
...NO_FILTER,
|
||||
projectFilters: ["p-2"],
|
||||
includeNoProject: true,
|
||||
});
|
||||
expect(result.map((i) => i.id)).toEqual(["2", "3"]);
|
||||
});
|
||||
|
||||
it("hides project issues when only 'No project' is selected", () => {
|
||||
const result = filterIssues(issues, { ...NO_FILTER, includeNoProject: true });
|
||||
expect(result.every((i) => !i.project_id)).toBe(true);
|
||||
});
|
||||
|
||||
it("applies status + project filters together", () => {
|
||||
const result = filterIssues(issues, {
|
||||
...NO_FILTER,
|
||||
statusFilters: ["todo"],
|
||||
projectFilters: ["p-1"],
|
||||
});
|
||||
expect(result.map((i) => i.id)).toEqual(["1", "4"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,8 +7,6 @@ export interface IssueFilters {
|
||||
assigneeFilters: ActorFilterValue[];
|
||||
includeNoAssignee: boolean;
|
||||
creatorFilters: ActorFilterValue[];
|
||||
projectFilters: string[];
|
||||
includeNoProject: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -21,9 +19,8 @@ export interface IssueFilters {
|
||||
* - When both → show matching assignees + unassigned
|
||||
*/
|
||||
export function filterIssues(issues: Issue[], filters: IssueFilters): Issue[] {
|
||||
const { statusFilters, priorityFilters, assigneeFilters, includeNoAssignee, creatorFilters, projectFilters, includeNoProject } = filters;
|
||||
const { statusFilters, priorityFilters, assigneeFilters, includeNoAssignee, creatorFilters } = filters;
|
||||
const hasAssigneeFilter = assigneeFilters.length > 0 || includeNoAssignee;
|
||||
const hasProjectFilter = projectFilters.length > 0 || includeNoProject;
|
||||
|
||||
return issues.filter((issue) => {
|
||||
if (statusFilters.length > 0 && !statusFilters.includes(issue.status))
|
||||
@@ -56,17 +53,6 @@ export function filterIssues(issues: Issue[], filters: IssueFilters): Issue[] {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (hasProjectFilter) {
|
||||
if (!issue.project_id) {
|
||||
if (!includeNoProject) return false;
|
||||
} else if (projectFilters.length > 0) {
|
||||
if (!projectFilters.includes(issue.project_id)) return false;
|
||||
} else {
|
||||
// Only "No project" is checked → hide issues that have a project
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,18 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import React, { useCallback, useEffect, useRef } from "react";
|
||||
import React from "react";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import { AppLink, useNavigation } from "../navigation";
|
||||
import {
|
||||
DndContext,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
closestCenter,
|
||||
type DragEndEvent,
|
||||
} from "@dnd-kit/core";
|
||||
import { SortableContext, verticalListSortingStrategy, useSortable, arrayMove } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import {
|
||||
Inbox,
|
||||
ListTodo,
|
||||
@@ -28,7 +18,6 @@ import {
|
||||
CircleUser,
|
||||
FolderKanban,
|
||||
Ellipsis,
|
||||
PinOff,
|
||||
} from "lucide-react";
|
||||
import { WorkspaceAvatar } from "../workspace/workspace-avatar";
|
||||
import { ActorAvatar } from "@multica/ui/components/common/actor-avatar";
|
||||
@@ -58,14 +47,11 @@ import {
|
||||
import { Tooltip, TooltipTrigger, TooltipContent } from "@multica/ui/components/ui/tooltip";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
import { useWorkspaceStore } from "@multica/core/workspace";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { inboxKeys, deduplicateInboxItems } from "@multica/core/inbox/queries";
|
||||
import { api } from "@multica/core/api";
|
||||
import { useModalStore } from "@multica/core/modals";
|
||||
import { useMyRuntimesNeedUpdate } from "@multica/core/runtimes/hooks";
|
||||
import { pinKeys } from "@multica/core/pins/queries";
|
||||
import { useDeletePin, useReorderPins } from "@multica/core/pins/mutations";
|
||||
import type { PinnedItem } from "@multica/core/types";
|
||||
|
||||
const personalNav = [
|
||||
{ href: "/inbox", label: "Inbox", icon: Inbox },
|
||||
@@ -90,60 +76,6 @@ function DraftDot() {
|
||||
return <span className="absolute top-0 right-0 size-1.5 rounded-full bg-brand" />;
|
||||
}
|
||||
|
||||
function SortablePinItem({ pin, pathname, onUnpin }: { pin: PinnedItem; pathname: string; onUnpin: () => void }) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: pin.id });
|
||||
const wasDragged = useRef(false);
|
||||
const { push } = useNavigation();
|
||||
|
||||
useEffect(() => {
|
||||
if (isDragging) wasDragged.current = true;
|
||||
}, [isDragging]);
|
||||
|
||||
const style = { transform: CSS.Transform.toString(transform), transition };
|
||||
const href = pin.item_type === "issue" ? `/issues/${pin.item_id}` : `/projects/${pin.item_id}`;
|
||||
const isActive = pathname === href;
|
||||
const label = pin.item_type === "issue" && pin.identifier ? `${pin.identifier} ${pin.title}` : pin.title;
|
||||
|
||||
return (
|
||||
<SidebarMenuItem
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={cn("group/pin", isDragging && "opacity-30")}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
>
|
||||
<SidebarMenuButton
|
||||
isActive={isActive}
|
||||
onClick={() => {
|
||||
if (wasDragged.current) {
|
||||
wasDragged.current = false;
|
||||
return;
|
||||
}
|
||||
push(href);
|
||||
}}
|
||||
className="text-muted-foreground hover:not-data-active:bg-sidebar-accent/70 data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground"
|
||||
>
|
||||
{pin.item_type === "issue" ? (
|
||||
<ListTodo className="size-4 shrink-0" />
|
||||
) : (
|
||||
<FolderKanban className="size-4 shrink-0" />
|
||||
)}
|
||||
<span className="truncate">{label}</span>
|
||||
<button
|
||||
className="ml-auto opacity-0 group-hover/pin:opacity-100 transition-opacity p-0.5 rounded hover:bg-accent shrink-0"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onUnpin();
|
||||
}}
|
||||
>
|
||||
<PinOff className="size-3 text-muted-foreground" />
|
||||
</button>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
interface AppSidebarProps {
|
||||
/** Rendered above SidebarHeader (e.g. desktop traffic light spacer) */
|
||||
topSlot?: React.ReactNode;
|
||||
@@ -174,57 +106,12 @@ export function AppSidebar({ topSlot, searchSlot, headerClassName, headerStyle }
|
||||
[inboxItems],
|
||||
);
|
||||
const hasRuntimeUpdates = useMyRuntimesNeedUpdate(wsId);
|
||||
const { data: pinnedItems = [] } = useQuery<PinnedItem[]>({
|
||||
queryKey: wsId ? pinKeys.list(wsId) : ["pins", "disabled"],
|
||||
queryFn: () => api.listPins(),
|
||||
enabled: !!wsId,
|
||||
});
|
||||
const deletePin = useDeletePin();
|
||||
const reorderPins = useReorderPins();
|
||||
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 5 } }));
|
||||
const handleDragEnd = useCallback(
|
||||
(event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) return;
|
||||
const oldIndex = pinnedItems.findIndex((p) => p.id === active.id);
|
||||
const newIndex = pinnedItems.findIndex((p) => p.id === over.id);
|
||||
if (oldIndex === -1 || newIndex === -1) return;
|
||||
const reordered = arrayMove(pinnedItems, oldIndex, newIndex);
|
||||
reorderPins.mutate(reordered);
|
||||
},
|
||||
[pinnedItems, reorderPins],
|
||||
);
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const logout = () => {
|
||||
queryClient.clear();
|
||||
authLogout();
|
||||
useWorkspaceStore.getState().clearWorkspace();
|
||||
};
|
||||
|
||||
// Global "C" shortcut to open create-issue modal (like Linear)
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "c" && !e.metaKey && !e.ctrlKey && !e.altKey && !e.shiftKey) {
|
||||
const tag = (e.target as HTMLElement)?.tagName;
|
||||
const isEditable =
|
||||
tag === "INPUT" ||
|
||||
tag === "TEXTAREA" ||
|
||||
tag === "SELECT" ||
|
||||
(e.target as HTMLElement)?.isContentEditable;
|
||||
if (isEditable) return;
|
||||
if (useModalStore.getState().modal) return;
|
||||
e.preventDefault();
|
||||
// Auto-fill project when on a project detail page
|
||||
const projectMatch = pathname.match(/^\/projects\/([^/]+)$/);
|
||||
const data = projectMatch ? { project_id: projectMatch[1] } : undefined;
|
||||
useModalStore.getState().open("create-issue", data);
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [pathname]);
|
||||
|
||||
return (
|
||||
<Sidebar variant="inset">
|
||||
{topSlot}
|
||||
@@ -316,7 +203,6 @@ export function AppSidebar({ topSlot, searchSlot, headerClassName, headerStyle }
|
||||
<DraftDot />
|
||||
</span>
|
||||
<span>New Issue</span>
|
||||
<kbd className="pointer-events-none ml-auto inline-flex h-5 select-none items-center gap-0.5 rounded border bg-muted px-1.5 font-mono text-[10px] font-medium text-muted-foreground">C</kbd>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
@@ -351,28 +237,6 @@ export function AppSidebar({ topSlot, searchSlot, headerClassName, headerStyle }
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
|
||||
{pinnedItems.length > 0 && (
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>Pinned</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||
<SortableContext items={pinnedItems.map((p) => p.id)} strategy={verticalListSortingStrategy}>
|
||||
<SidebarMenu className="gap-0.5">
|
||||
{pinnedItems.map((pin: PinnedItem) => (
|
||||
<SortablePinItem
|
||||
key={pin.id}
|
||||
pin={pin}
|
||||
pathname={pathname}
|
||||
onUnpin={() => deletePin.mutate({ itemType: pin.item_type, itemId: pin.item_id })}
|
||||
/>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
)}
|
||||
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>Workspace</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { SidebarProvider, SidebarInset, SidebarTrigger } from "@multica/ui/components/ui/sidebar";
|
||||
import { SidebarProvider, SidebarInset } from "@multica/ui/components/ui/sidebar";
|
||||
import { ModalRegistry } from "../modals/registry";
|
||||
import { AppSidebar } from "./app-sidebar";
|
||||
import { DashboardGuard } from "./dashboard-guard";
|
||||
@@ -34,9 +34,6 @@ export function DashboardLayout({
|
||||
<SidebarProvider className="h-svh">
|
||||
<AppSidebar searchSlot={searchSlot} />
|
||||
<SidebarInset className="overflow-hidden">
|
||||
<div className="flex h-10 shrink-0 items-center border-b px-2 md:hidden">
|
||||
<SidebarTrigger />
|
||||
</div>
|
||||
{children}
|
||||
<ModalRegistry />
|
||||
</SidebarInset>
|
||||
|
||||
@@ -13,7 +13,8 @@ import {
|
||||
} from "@multica/ui/components/ui/dialog";
|
||||
import { Tooltip, TooltipTrigger, TooltipContent } from "@multica/ui/components/ui/tooltip";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import { ContentEditor, type ContentEditorRef, TitleEditor, useFileDropZone, FileDropOverlay } from "../editor";
|
||||
import { ContentEditor, type ContentEditorRef } from "../editor";
|
||||
import { TitleEditor } from "../editor";
|
||||
import { StatusIcon, StatusPicker, PriorityPicker, AssigneePicker, DueDatePicker } from "../issues/components";
|
||||
import { ProjectPicker } from "../projects/components/project-picker";
|
||||
import { useWorkspaceStore } from "@multica/core/workspace";
|
||||
@@ -61,9 +62,6 @@ export function CreateIssueModal({ onClose, data }: { onClose: () => void; data?
|
||||
|
||||
const [title, setTitle] = useState(draft.title);
|
||||
const descEditorRef = useRef<ContentEditorRef>(null);
|
||||
const { isDragOver: descDragOver, dropZoneProps: descDropZoneProps } = useFileDropZone({
|
||||
onDrop: (files) => files.forEach((f) => descEditorRef.current?.uploadFile(f)),
|
||||
});
|
||||
const [status, setStatus] = useState<IssueStatus>((data?.status as IssueStatus) || draft.status);
|
||||
const [priority, setPriority] = useState<IssuePriority>(draft.priority);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
@@ -217,7 +215,7 @@ export function CreateIssueModal({ onClose, data }: { onClose: () => void; data?
|
||||
</div>
|
||||
|
||||
{/* Description — takes remaining space */}
|
||||
<div {...descDropZoneProps} className="relative flex-1 min-h-0 overflow-y-auto px-5">
|
||||
<div className="flex-1 min-h-0 overflow-y-auto px-5">
|
||||
<ContentEditor
|
||||
ref={descEditorRef}
|
||||
defaultValue={draft.description}
|
||||
@@ -226,7 +224,6 @@ export function CreateIssueModal({ onClose, data }: { onClose: () => void; data?
|
||||
onUploadFile={handleUpload}
|
||||
debounceMs={500}
|
||||
/>
|
||||
{descDragOver && <FileDropOverlay />}
|
||||
</div>
|
||||
|
||||
{/* Property toolbar */}
|
||||
|
||||
@@ -20,8 +20,8 @@ import { ListView } from "../../issues/components/list-view";
|
||||
import { BatchActionToolbar } from "../../issues/components/batch-action-toolbar";
|
||||
import { registerViewStoreForWorkspaceSync } from "@multica/core/issues/stores/view-store";
|
||||
import { useWorkspaceId } from "@multica/core/hooks";
|
||||
import { myIssueListOptions, type MyIssuesFilter } from "@multica/core/issues/queries";
|
||||
import { useUpdateIssue, useLoadMoreDoneIssues } from "@multica/core/issues/mutations";
|
||||
import { issueListOptions } from "@multica/core/issues/queries";
|
||||
import { useUpdateIssue } from "@multica/core/issues/mutations";
|
||||
import { myIssuesViewStore } from "@multica/core/issues/stores/my-issues-view-store";
|
||||
import { MyIssuesHeader } from "./my-issues-header";
|
||||
|
||||
@@ -30,6 +30,7 @@ export function MyIssuesPage() {
|
||||
const workspace = useWorkspaceStore((s) => s.workspace);
|
||||
const wsId = useWorkspaceId();
|
||||
const { data: agents = [] } = useQuery(agentListOptions(wsId));
|
||||
const { data: allIssues = [], isLoading: loading } = useQuery(issueListOptions(wsId));
|
||||
|
||||
const viewMode = useStore(myIssuesViewStore, (s) => s.viewMode);
|
||||
const statusFilters = useStore(myIssuesViewStore, (s) => s.statusFilters);
|
||||
@@ -44,34 +45,46 @@ export function MyIssuesPage() {
|
||||
useIssueSelectionStore.getState().clear();
|
||||
}, [viewMode, scope]);
|
||||
|
||||
// Build server-side filter based on scope
|
||||
const myAgentIds = useMemo(() => {
|
||||
if (!user) return [] as string[];
|
||||
return agents
|
||||
.filter((a) => a.owner_id === user.id)
|
||||
.map((a) => a.id)
|
||||
.sort();
|
||||
if (!user) return new Set<string>();
|
||||
return new Set(
|
||||
agents.filter((a) => a.owner_id === user.id).map((a) => a.id),
|
||||
);
|
||||
}, [agents, user]);
|
||||
|
||||
const filter: MyIssuesFilter = useMemo(() => {
|
||||
if (!user) return {};
|
||||
// Per-scope issue lists
|
||||
const assignedToMe = useMemo(() => {
|
||||
if (!user) return [];
|
||||
return allIssues.filter(
|
||||
(i) => i.assignee_type === "member" && i.assignee_id === user.id,
|
||||
);
|
||||
}, [allIssues, user]);
|
||||
|
||||
const myAgentIssues = useMemo(() => {
|
||||
if (!user) return [];
|
||||
return allIssues.filter(
|
||||
(i) =>
|
||||
i.assignee_type === "agent" &&
|
||||
i.assignee_id &&
|
||||
myAgentIds.has(i.assignee_id),
|
||||
);
|
||||
}, [allIssues, user, myAgentIds]);
|
||||
|
||||
const createdByMe = useMemo(() => {
|
||||
if (!user) return [];
|
||||
return allIssues.filter(
|
||||
(i) => i.creator_type === "member" && i.creator_id === user.id,
|
||||
);
|
||||
}, [allIssues, user]);
|
||||
|
||||
const myIssues = useMemo(() => {
|
||||
switch (scope) {
|
||||
case "assigned":
|
||||
return { assignee_id: user.id };
|
||||
case "created":
|
||||
return { creator_id: user.id };
|
||||
case "agents":
|
||||
return { assignee_ids: myAgentIds };
|
||||
default:
|
||||
return { assignee_id: user.id };
|
||||
case "assigned": return assignedToMe;
|
||||
case "agents": return myAgentIssues;
|
||||
case "created": return createdByMe;
|
||||
default: return assignedToMe;
|
||||
}
|
||||
}, [scope, user, myAgentIds]);
|
||||
|
||||
const { data: myIssues = [], isLoading: loading } = useQuery(
|
||||
myIssueListOptions(wsId, scope, filter),
|
||||
);
|
||||
|
||||
const { doneTotal } = useLoadMoreDoneIssues({ scope, filter });
|
||||
}, [scope, assignedToMe, myAgentIssues, createdByMe]);
|
||||
|
||||
// Apply status/priority filters from view store
|
||||
const issues = useMemo(
|
||||
@@ -82,15 +95,13 @@ export function MyIssuesPage() {
|
||||
assigneeFilters: [],
|
||||
includeNoAssignee: false,
|
||||
creatorFilters: [],
|
||||
projectFilters: [],
|
||||
includeNoProject: false,
|
||||
}),
|
||||
[myIssues, statusFilters, priorityFilters],
|
||||
);
|
||||
|
||||
const childProgressMap = useMemo(() => {
|
||||
const map = new Map<string, { done: number; total: number }>();
|
||||
for (const issue of myIssues) {
|
||||
for (const issue of allIssues) {
|
||||
if (!issue.parent_issue_id) continue;
|
||||
const entry = map.get(issue.parent_issue_id);
|
||||
const isDone = issue.status === "done" || issue.status === "cancelled";
|
||||
@@ -102,7 +113,7 @@ export function MyIssuesPage() {
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}, [myIssues]);
|
||||
}, [allIssues]);
|
||||
|
||||
const visibleStatuses = useMemo(() => {
|
||||
if (statusFilters.length > 0)
|
||||
@@ -193,19 +204,9 @@ export function MyIssuesPage() {
|
||||
hiddenStatuses={hiddenStatuses}
|
||||
onMoveIssue={handleMoveIssue}
|
||||
childProgressMap={childProgressMap}
|
||||
doneTotal={doneTotal}
|
||||
myIssuesScope={scope}
|
||||
myIssuesFilter={filter}
|
||||
/>
|
||||
) : (
|
||||
<ListView
|
||||
issues={issues}
|
||||
visibleStatuses={visibleStatuses}
|
||||
childProgressMap={childProgressMap}
|
||||
doneTotal={doneTotal}
|
||||
myIssuesScope={scope}
|
||||
myIssuesFilter={filter}
|
||||
/>
|
||||
<ListView issues={issues} visibleStatuses={visibleStatuses} childProgressMap={childProgressMap} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user