Compare commits

..

1 Commits

Author SHA1 Message Date
Jiayuan Zhang
f5d8daf902 feat(docs): add documentation site with Fumadocs
Set up a documentation site at apps/docs using Fumadocs (Next.js App Router).
Migrated existing docs (README, SELF_HOSTING, CLI_AND_DAEMON, CLI_INSTALL,
CONTRIBUTING, AGENTS) into structured MDX content with sidebar navigation
and full-text search.

Content structure:
- Getting Started: Cloud quickstart, self-hosting guide
- CLI & Daemon: Installation, full command reference
- Guides: Quickstart, agents overview
- Developers: Contributing guide, architecture docs

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 15:14:39 +08:00
177 changed files with 2149 additions and 8408 deletions

View File

@@ -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
View File

@@ -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
View File

@@ -41,9 +41,6 @@ apps/web/test-results/
# feature tracking
_features/
# runtime
*.pid
# platform specific
*.dmg
*.app

View File

@@ -11,7 +11,6 @@ builds:
- -s -w
- -X main.version={{.Version}}
- -X main.commit={{.ShortCommit}}
- -X main.date={{.Date}}
env:
- CGO_ENABLED=0
goos:

View File

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

View File

@@ -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>
```

View File

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

View File

@@ -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"]

View File

@@ -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"]

View File

@@ -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
View File

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

View File

@@ -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 agentClaude 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)。
## 快速上手

View File

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

View File

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

View File

@@ -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`

View File

@@ -11,10 +11,6 @@ export default defineConfig({
plugins: [externalizeDepsPlugin()],
},
renderer: {
server: {
port: 5173,
strictPort: true,
},
plugins: [react(), tailwindcss()],
resolve: {
alias: {

View File

@@ -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:",

View File

@@ -17,7 +17,6 @@ function createWindow(): void {
webPreferences: {
preload: join(__dirname, "../preload/index.js"),
sandbox: false,
webSecurity: false,
},
});

View File

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

View File

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

View File

@@ -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 />);

View File

@@ -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,

View File

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

View File

@@ -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: {

View File

@@ -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",

View File

@@ -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",

View File

@@ -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: "/",

View File

@@ -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:

View File

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

View File

@@ -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",

View File

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

View File

@@ -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",

View File

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

View File

@@ -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",

View File

@@ -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:

View File

@@ -1,8 +0,0 @@
#!/bin/sh
set -e
echo "Running database migrations..."
./migrate up
echo "Starting server..."
exec ./server

View File

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

View File

@@ -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?.();

View File

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

View File

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

View File

@@ -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),

View File

@@ -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());

View File

@@ -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,

View File

@@ -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());

View File

@@ -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());

View File

@@ -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(),
);

View File

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

View File

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

View File

@@ -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 }),
}
)

View File

@@ -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",

View File

@@ -1,2 +0,0 @@
export { pinKeys, pinListOptions } from "./queries";
export { useCreatePin, useDeletePin, useReorderPins } from "./mutations";

View File

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

View File

@@ -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(),
});
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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();

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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(),
});
}

View File

@@ -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: [] });
},
}));

View File

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

View File

@@ -53,7 +53,6 @@
"@types/linkify-it": "^5.0.0",
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
"rehype-sanitize": "^6.0.0",
"typescript": "catalog:"
}
}

View File

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

View File

@@ -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();
});
// -------------------------------------------------------------------------

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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">

View File

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

View File

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

View File

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

View File

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

View File

@@ -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(

View File

@@ -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 */}

View File

@@ -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 && (
<>

View File

@@ -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() }),

View File

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

View File

@@ -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">

View File

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

View File

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

View File

@@ -34,7 +34,6 @@ function commentToTimelineEntry(c: Comment): TimelineEntry {
updated_at: c.updated_at,
comment_type: c.type,
reactions: c.reactions ?? [],
attachments: c.attachments ?? [],
};
}

View File

@@ -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"]);
});
});

View File

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

View File

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

View File

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

View File

@@ -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 */}

View File

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