Compare commits

..

1 Commits

Author SHA1 Message Date
Jiang Bohan
80956e71b6 fix(handler): add cycle detection to BatchUpdateIssues parent_issue_id handling
BatchUpdateIssues was missing the ancestor-walk cycle detection that
single UpdateIssue has. This allowed creating circular parent
relationships (e.g. A→B→A) via the batch API. Added the same
depth-limited walk (up to 10 ancestors) to detect and skip issues
that would create cycles, consistent with UpdateIssue behavior.
2026-04-12 23:01:41 +08:00
198 changed files with 1896 additions and 11150 deletions

View File

@@ -46,12 +46,6 @@ COOKIE_DOMAIN=
LOCAL_UPLOAD_DIR=./data/uploads
LOCAL_UPLOAD_BASE_URL=http://localhost:8080
# Security
# Comma-separated list of allowed origins for CORS and WebSocket connections.
# Defaults to localhost dev origins when unset.
# Example: ALLOWED_ORIGINS=https://app.multica.ai,https://staging.multica.ai
ALLOWED_ORIGINS=
# Frontend
FRONTEND_PORT=3000
FRONTEND_ORIGIN=http://localhost:3000

View File

@@ -35,13 +35,11 @@ Closes #
## Checklist
- [ ] I have included a thinking path that traces from project context to this change
- [ ] I have run tests locally and they pass
- [ ] I have added or updated tests where applicable
- [ ] If this change affects the UI, I have included before/after screenshots
- [ ] I have updated relevant documentation to reflect my changes
- [ ] I have considered and documented any risks above
- [ ] I will address all reviewer comments before requesting merge
- [ ] I searched for [existing PRs](https://github.com/multica-ai/multica/pulls) to make sure this isn't a duplicate
- [ ] My commit messages follow [Conventional Commits](https://www.conventionalcommits.org/) (`fix(scope):`, `feat(scope):`, etc.)
- [ ] `make check` passes (typecheck, unit tests, Go tests, E2E)
- [ ] Changes follow existing code patterns and conventions
- [ ] No unrelated changes included
## AI Disclosure

View File

@@ -17,22 +17,14 @@ builds:
goos:
- darwin
- linux
- windows
goarch:
- amd64
- arm64
ignore:
- goos: windows
goarch: arm64
archives:
- id: default
formats:
- tar.gz
format_overrides:
- goos: windows
formats:
- zip
name_template: "{{ .ProjectName }}_{{ .Os }}_{{ .Arch }}"
checksum:

293
AGENTS.md
View File

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

View File

@@ -7,7 +7,8 @@ The `multica` CLI connects your local machine to Multica. It handles authenticat
### Homebrew (macOS/Linux)
```bash
brew install multica-ai/tap/multica
brew tap multica-ai/tap
brew install multica
```
### Build from Source
@@ -21,17 +22,11 @@ cp server/bin/multica /usr/local/bin/multica
### Update
```bash
brew upgrade multica-ai/tap/multica
```
For install script or manual installs, use:
```bash
multica update
```
`multica update` auto-detects your installation method and upgrades accordingly.
This auto-detects your installation method (Homebrew or manual) and upgrades accordingly.
## Quick Start
@@ -40,7 +35,7 @@ multica update
multica setup
# For self-hosted (local) deployments:
multica setup self-host
multica setup --local
```
Or step by step:
@@ -140,9 +135,6 @@ The daemon auto-detects these AI CLIs on your PATH:
|-----|---------|-------------|
| [Claude Code](https://docs.anthropic.com/en/docs/claude-code) | `claude` | Anthropic's coding agent |
| [Codex](https://github.com/openai/codex) | `codex` | OpenAI's coding agent |
| OpenCode | `opencode` | Open-source coding agent |
| OpenClaw | `openclaw` | Open-source coding agent |
| Hermes | `hermes` | Nous Research coding agent |
You need at least one installed. The daemon registers each detected CLI as an available runtime.
@@ -177,35 +169,29 @@ Agent-specific overrides:
| `MULTICA_CLAUDE_MODEL` | Override the Claude model used |
| `MULTICA_CODEX_PATH` | Custom path to the `codex` binary |
| `MULTICA_CODEX_MODEL` | Override the Codex model used |
| `MULTICA_OPENCODE_PATH` | Custom path to the `opencode` binary |
| `MULTICA_OPENCODE_MODEL` | Override the OpenCode model used |
| `MULTICA_OPENCLAW_PATH` | Custom path to the `openclaw` binary |
| `MULTICA_OPENCLAW_MODEL` | Override the OpenClaw model used |
| `MULTICA_HERMES_PATH` | Custom path to the `hermes` binary |
| `MULTICA_HERMES_MODEL` | Override the Hermes model used |
### Self-Hosted Server
When connecting to a self-hosted Multica instance, the easiest approach is:
```bash
# One command — configures for localhost, authenticates, starts daemon
multica setup self-host
# Or for on-premise with custom domains:
multica setup self-host --server-url https://api.example.com --app-url https://app.example.com
# One command — auto-detects local server, configures, authenticates, starts daemon
multica setup --local
```
Or configure manually:
```bash
# Set URLs individually
multica config set server_url http://localhost:8080
multica config set app_url http://localhost:3000
# 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 server_url https://api.example.com
# multica config set app_url https://app.example.com
# multica config set server_url https://api.example.com
multica login
multica daemon start
@@ -216,11 +202,9 @@ multica daemon start
Profiles let you run multiple daemons on the same machine — for example, one for production and one for a staging server.
```bash
# Set up a staging profile
multica setup self-host --profile staging --server-url https://api-staging.example.com --app-url https://staging.example.com
# Start its daemon
multica daemon start --profile staging
# Start a daemon for the staging server
multica --profile staging login
multica --profile staging daemon start
# Default profile runs separately
multica daemon start
@@ -343,20 +327,17 @@ The `runs` command shows all past and current executions for an issue, including
## Setup
```bash
# One-command setup for Multica Cloud: configure, authenticate, and start the daemon
# One-command setup: configure, authenticate, and start the daemon
multica setup
# For local self-hosted deployments
multica setup self-host
# For local self-hosted deployments (auto-detects or forces local mode)
multica setup --local
# Custom ports
multica setup self-host --port 9090 --frontend-port 4000
# On-premise with custom domains
multica setup self-host --server-url https://api.example.com --app-url https://app.example.com
multica setup --local --port 9090 --frontend-port 4000
```
`multica setup` configures the CLI, opens your browser for authentication, and starts the daemon — all in one step. Use `multica setup self-host` to connect to a self-hosted server instead of Multica Cloud.
`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
@@ -368,6 +349,15 @@ 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

View File

@@ -27,9 +27,7 @@ multica version
## Step 2: Install the Multica CLI
> **Windows users:** Skip to [Option C: Windows (PowerShell)](#option-c-windows-powershell) below.
### Option A: Homebrew (preferred — macOS/Linux)
### Option A: Homebrew (preferred)
Check if Homebrew is available:
@@ -40,7 +38,7 @@ which brew
If `brew` is found, install via Homebrew:
```bash
brew install multica-ai/tap/multica
brew tap multica-ai/tap && brew install multica
```
Then verify:
@@ -51,13 +49,7 @@ multica version
If the version prints successfully, skip to **Step 3**.
To upgrade later, run:
```bash
brew upgrade multica-ai/tap/multica
```
### Option B: Download from GitHub Releases (macOS/Linux, no Homebrew)
### Option B: Download from GitHub Releases (no Homebrew)
If Homebrew is not available, download the binary directly.
@@ -93,27 +85,6 @@ multica version
- On Linux, you may need `chmod +x /usr/local/bin/multica`.
- If `sudo` is not available, install to a user-writable directory: `mv /tmp/multica ~/.local/bin/multica` and ensure `~/.local/bin` is in `$PATH`.
### Option C: Windows (PowerShell)
Run in PowerShell (no admin required):
```powershell
irm https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.ps1 | iex
```
This downloads the latest Windows binary from GitHub Releases, installs it to `%USERPROFILE%\.multica\bin\`, and adds it to your user PATH.
Verify:
```powershell
multica version
```
**If this fails:**
- Restart your terminal so the updated PATH takes effect.
- If you use Scoop, the installer will use it automatically: `scoop bucket add multica https://github.com/multica-ai/scoop-bucket.git && scoop install multica`
- If your execution policy blocks the script: `Set-ExecutionPolicy -Scope CurrentUser -ExecutionPolicy RemoteSigned` then re-run.
---
## Step 3: Log in
@@ -165,12 +136,12 @@ Wait 3 seconds, then verify:
multica daemon status
```
Expected output should show `running` status with detected agents (e.g. `claude`, `codex`, `opencode`, `openclaw`, `hermes`).
Expected output should show `running` status with detected agents (e.g. `claude`, `codex`).
**If daemon fails to start:**
- Check logs: `multica daemon logs`
- If a port conflict occurs, the daemon may already be running under a different profile.
- If no agents are detected, ensure at least one AI CLI (`claude`, `codex`, `opencode`, `openclaw`, or `hermes`) is installed and on the `$PATH`.
- If no agents are detected, ensure at least one AI CLI (`claude` or `codex`) is installed and on the `$PATH`.
---
@@ -184,12 +155,12 @@ multica daemon status
Confirm:
1. Status is `running`
2. At least one agent is listed (e.g. `claude`, `codex`, `opencode`, `openclaw`, or `hermes`)
2. At least one agent is listed (e.g. `claude`, `codex`)
3. At least one workspace is being watched
If the agents list is empty, tell the user:
> "The Multica daemon is running but no AI agent CLIs were detected. Please install at least one supported CLI (`claude`, `codex`, `opencode`, `openclaw`, or `hermes`), then restart the daemon with `multica daemon stop && multica daemon start`."
> "The Multica daemon is running but no AI agent CLIs were detected. Please install at least one: [Claude Code](https://docs.anthropic.com/en/docs/claude-code) (`claude`) or [Codex](https://github.com/openai/codex) (`codex`), then restart the daemon with `multica daemon stop && multica daemon start`."
---

View File

@@ -37,10 +37,8 @@ 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
ARG NEXT_PUBLIC_WS_URL
ENV REMOTE_API_URL=$REMOTE_API_URL
ENV NEXT_PUBLIC_GOOGLE_CLIENT_ID=$NEXT_PUBLIC_GOOGLE_CLIENT_ID
ENV NEXT_PUBLIC_WS_URL=$NEXT_PUBLIC_WS_URL
ENV STANDALONE=true
# Build the web app (standalone output for minimal runtime)

View File

@@ -70,7 +70,7 @@ selfhost:
echo ""; \
echo "Next — install the CLI and connect your machine:"; \
echo " brew install multica-ai/tap/multica"; \
echo " multica setup self-host"; \
echo " multica setup --local"; \
else \
echo ""; \
echo "Services are still starting. Check logs:"; \

View File

@@ -20,7 +20,7 @@ Turn coding agents into real teammates — assign tasks, track progress, compoun
[![CI](https://github.com/multica-ai/multica/actions/workflows/ci.yml/badge.svg)](https://github.com/multica-ai/multica/actions/workflows/ci.yml)
[![GitHub stars](https://img.shields.io/github/stars/multica-ai/multica?style=flat)](https://github.com/multica-ai/multica/stargazers)
[Website](https://multica.ai) · [Cloud](https://multica.ai/app) · [X](https://x.com/MulticaAI) · [Self-Hosting](SELF_HOSTING.md) · [Contributing](CONTRIBUTING.md)
[Website](https://multica.ai) · [Cloud](https://multica.ai/app) · [X](https://x.com/multica_hq) · [Self-Hosting](SELF_HOSTING.md) · [Contributing](CONTRIBUTING.md)
**English | [简体中文](README.zh-CN.md)**
@@ -50,39 +50,24 @@ Multica manages the full agent lifecycle: from task assignment to execution moni
## Quick Install
### macOS / Linux (Homebrew - recommended)
```bash
brew install multica-ai/tap/multica
```
Use `brew upgrade multica-ai/tap/multica` to keep the CLI current.
### macOS / Linux (install script)
```bash
curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.sh | bash
```
Use this if Homebrew is not available. The script installs the Multica CLI on macOS and Linux by using Homebrew when it is on `PATH`, otherwise it downloads the binary directly.
Installs the Multica CLI on macOS and Linux. Works with Homebrew or downloads the binary directly.
### Windows (PowerShell)
```powershell
irm https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.ps1 | iex
```
Then configure, authenticate, and start the daemon in one command:
After installation:
```bash
multica setup # Connect to Multica Cloud, log in, start daemon
multica login # Authenticate (opens browser)
multica daemon start # Start the local agent runtime
multica daemon stop # Stop the daemon when done
```
> **Self-hosting?** Add `--with-server` to deploy a full Multica server on your machine:
> **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 -- --with-server
> multica setup self-host
> 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.
@@ -91,10 +76,11 @@ multica setup # Connect to Multica Cloud, log in, start daemon
## Getting Started
### 1. Set up and start the daemon
### 1. Log in and start the daemon
```bash
multica setup # Configure, authenticate, and start the daemon
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.
@@ -124,8 +110,9 @@ The `multica` CLI connects your local machine to Multica — authenticate, manag
| `multica login` | Authenticate (opens browser) |
| `multica daemon start` | Start the local agent runtime |
| `multica daemon status` | Check daemon status |
| `multica setup` | One-command setup for Multica Cloud (configure + login + start daemon) |
| `multica setup self-host` | Same, but for self-hosted deployments |
| `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 |

View File

@@ -20,7 +20,7 @@
[![CI](https://github.com/multica-ai/multica/actions/workflows/ci.yml/badge.svg)](https://github.com/multica-ai/multica/actions/workflows/ci.yml)
[![GitHub stars](https://img.shields.io/github/stars/multica-ai/multica?style=flat)](https://github.com/multica-ai/multica/stargazers)
[官网](https://multica.ai) · [云服务](https://multica.ai/app) · [X](https://x.com/MulticaAI) · [自部署指南](SELF_HOSTING.md) · [参与贡献](CONTRIBUTING.md)
[官网](https://multica.ai) · [云服务](https://multica.ai/app) · [X](https://x.com/multica_hq) · [自部署指南](SELF_HOSTING.md) · [参与贡献](CONTRIBUTING.md)
**[English](README.md) | 简体中文**
@@ -50,39 +50,24 @@ Multica 管理完整的 Agent 生命周期:从任务分配到执行监控再
## 快速安装
### macOS / Linux推荐 Homebrew
```bash
brew install multica-ai/tap/multica
```
后续可用 `brew upgrade multica-ai/tap/multica` 更新 CLI。
### macOS / Linux安装脚本
```bash
curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.sh | bash
```
如果没有 Homebrew可以使用安装脚本。脚本会安装 Multica CLI检测到 `brew` 时通过 Homebrew 安装,否则直接下载二进制。
安装 Multica CLI支持 macOS 和 Linux。有 Homebrew 用 Homebrew,没有则直接下载二进制。
### Windows (PowerShell)
```powershell
irm https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.ps1 | iex
```
安装完成后,一条命令完成配置、认证和启动:
安装完成后:
```bash
multica setup # 连接 Multica Cloud登录启动 daemon
multica login # 认证(打开浏览器)
multica daemon start # 启动本地 Agent 运行时
multica daemon stop # 停止 daemon
```
> **自部署?** 加上 `--with-server` 在本地部署完整的 Multica 服务:
> **自部署?** 加上 `--local` 在本地部署完整的 Multica 服务:
>
> ```bash
> curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.sh | bash -s -- --with-server
> multica setup self-host
> curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.sh | bash -s -- --local
> ```
>
> 需要 Docker。详见 [自部署指南](SELF_HOSTING.md)。
@@ -93,10 +78,11 @@ multica setup # 连接 Multica Cloud登录启动 daemon
安装好 CLI或注册 [Multica 云服务](https://multica.ai))后,按以下步骤将第一个任务分配给 Agent
### 1. 配置并启动 daemon
### 1. 登录并启动 daemon
```bash
multica setup # 配置、认证、启动 daemon一条命令搞定
multica login # 使用你的 Multica 账号认证
multica daemon start # 启动本地 Agent 运行时
```
daemon 在后台运行,保持你的机器与 Multica 的连接。它会自动检测 PATH 中可用的 Agent CLI`claude``codex``openclaw``opencode`)。

View File

@@ -14,27 +14,22 @@ Each user who runs AI agents locally also installs the **`multica` CLI** and run
## Quick Install (Recommended)
Two commands to set up everything — server, CLI, and configuration:
One command to set up everything — server, CLI, and configuration:
```bash
# 1. Install CLI + provision the self-host server
curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.sh | bash -s -- --with-server
# 2. Configure CLI, authenticate, and start the daemon
multica setup self-host
curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.sh | bash -s -- --local
```
This clones the repository, starts all services via Docker Compose, installs the `multica` CLI, then configures it for localhost.
This automatically clones the repository, starts all services via Docker Compose, and installs the `multica` CLI.
Open http://localhost:3000, log in with any email + verification code **`888888`**.
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.
>
> **CLI only?** If the self-host server is already running and you only need the CLI on a macOS/Linux machine, install it with Homebrew:
>
> ```bash
> brew install multica-ai/tap/multica
> ```
---
@@ -84,12 +79,11 @@ You also need at least one AI agent CLI installed:
- [Codex](https://github.com/openai/codex) (`codex` on PATH)
- [OpenClaw](https://github.com/openclaw/openclaw) (`openclaw` on PATH)
- [OpenCode](https://github.com/anomalyco/opencode) (`opencode` on PATH)
- [Hermes](https://github.com/NousResearch/hermes) (`hermes` on PATH)
### b) One-command setup
```bash
multica setup self-host
multica setup --local
```
This automatically:
@@ -98,12 +92,6 @@ This automatically:
3. Discovers your workspaces
4. Starts the daemon in the background
For on-premise deployments with custom domains:
```bash
multica setup self-host --server-url https://api.example.com --app-url https://app.example.com
```
To verify the daemon is running:
```bash
@@ -142,10 +130,16 @@ multica daemon stop
If you've been self-hosting and want to switch your CLI to [Multica Cloud](https://multica.ai):
```bash
multica setup
multica config set server_url https://api.multica.ai
multica config set app_url https://multica.ai
multica login
```
This reconfigures the CLI for multica.ai, re-authenticates, and restarts the daemon. You will be prompted before overwriting the existing configuration.
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.
@@ -188,8 +182,11 @@ If you prefer configuring the CLI step by step instead of `multica setup`:
```bash
# Point CLI to your local server
multica config set server_url http://localhost:8080
multica config set app_url http://localhost:3000
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

View File

@@ -66,21 +66,6 @@ These are configured on each user's machine, not on the server:
| `MULTICA_DAEMON_POLL_INTERVAL` | `3s` | How often the daemon polls for tasks |
| `MULTICA_DAEMON_HEARTBEAT_INTERVAL` | `15s` | Heartbeat frequency |
Agent-specific overrides:
| Variable | Description |
|----------|-------------|
| `MULTICA_CLAUDE_PATH` | Custom path to the `claude` binary |
| `MULTICA_CLAUDE_MODEL` | Override the Claude model used |
| `MULTICA_CODEX_PATH` | Custom path to the `codex` binary |
| `MULTICA_CODEX_MODEL` | Override the Codex model used |
| `MULTICA_OPENCODE_PATH` | Custom path to the `opencode` binary |
| `MULTICA_OPENCODE_MODEL` | Override the OpenCode model used |
| `MULTICA_OPENCLAW_PATH` | Custom path to the `openclaw` binary |
| `MULTICA_OPENCLAW_MODEL` | Override the OpenClaw model used |
| `MULTICA_HERMES_PATH` | Custom path to the `hermes` binary |
| `MULTICA_HERMES_MODEL` | Override the Hermes model used |
## Database Setup
Multica requires PostgreSQL 17 with the pgvector extension.
@@ -218,26 +203,6 @@ NEXT_PUBLIC_API_URL=https://api.example.com
NEXT_PUBLIC_WS_URL=wss://api.example.com/ws
```
## LAN / Non-localhost Access
By default, Multica works on `localhost`. If you access it from another machine on the LAN (e.g. `http://192.168.1.100:3000`), you need to tell the backend to accept that origin:
```bash
# .env — replace with your server's LAN IP
FRONTEND_ORIGIN=http://192.168.1.100:3000
CORS_ALLOWED_ORIGINS=http://192.168.1.100:3000
```
Then rebuild:
```bash
docker compose -f docker-compose.selfhost.yml up -d --build
```
The frontend automatically derives the WebSocket URL from the page address, so real-time features (chat streaming, live issue updates, notifications) work over LAN without extra configuration.
> **Note:** If you need to override the WebSocket URL explicitly (e.g. when using a separate backend domain), set `NEXT_PUBLIC_WS_URL` in `.env` and rebuild the frontend image.
## Health Check
The backend exposes a health check endpoint:

View File

@@ -8,17 +8,15 @@ This document is designed for AI agents to execute. Follow these steps exactly t
- Homebrew installed (for CLI)
- At least one AI agent CLI on PATH: `claude` or `codex`
## Install
## One-Command Install
```bash
# Install CLI + provision self-host server
curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.sh | bash -s -- --with-server
# Configure CLI for localhost, authenticate, and start daemon
multica setup self-host
curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.sh | bash -s -- --local
```
Wait for the server output `✓ Multica server is running and CLI is ready!` before running `multica setup self-host`.
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
@@ -32,10 +30,10 @@ git clone https://github.com/multica-ai/multica.git
cd multica
make selfhost
brew install multica-ai/tap/multica
multica setup self-host
multica setup --local
```
The `multica setup self-host` command will:
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
@@ -66,7 +64,7 @@ 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 self-host --port <PORT> --frontend-port <FRONTEND_PORT>`
3. Run `multica setup --local --port <PORT> --frontend-port <FRONTEND_PORT>`
## Troubleshooting

View File

@@ -8,10 +8,6 @@ files:
- "!electron.vite.config.*"
- "!{.eslintignore,.eslintrc.cjs,.prettierignore,.prettierrc.yaml,dev-app-update.yml,CHANGELOG.md,README.md}"
- "!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}"
protocols:
- name: Multica
schemes:
- multica
asarUnpack:
- resources/**
mac:
@@ -32,8 +28,4 @@ win:
target:
- nsis
artifactName: ${name}-${version}-setup.${ext}
publish:
provider: github
owner: multica-ai
repo: multica
npmRebuild: false

View File

@@ -1,41 +1,26 @@
import { resolve } from "path";
import { defineConfig, externalizeDepsPlugin } from "electron-vite";
import { loadEnv } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), "");
const remoteApi = env.VITE_REMOTE_API;
const remoteWs = remoteApi?.replace(/^https/, "wss").replace(/^http/, "ws");
return {
main: {
plugins: [externalizeDepsPlugin()],
export default defineConfig({
main: {
plugins: [externalizeDepsPlugin()],
},
preload: {
plugins: [externalizeDepsPlugin()],
},
renderer: {
server: {
port: 5173,
strictPort: true,
},
preload: {
plugins: [externalizeDepsPlugin()],
},
renderer: {
server: {
port: 5173,
strictPort: true,
...(remoteApi && {
proxy: {
"/api": { target: remoteApi, changeOrigin: true },
"/auth": { target: remoteApi, changeOrigin: true },
"/uploads": { target: remoteApi, changeOrigin: true },
"/ws": { target: remoteWs, changeOrigin: true, ws: true },
},
}),
},
plugins: [react(), tailwindcss()],
resolve: {
alias: {
"@": resolve("src/renderer/src"),
},
dedupe: ["react", "react-dom"],
plugins: [react(), tailwindcss()],
resolve: {
alias: {
"@": resolve("src/renderer/src"),
},
dedupe: ["react", "react-dom"],
},
};
},
});

View File

@@ -5,7 +5,6 @@
"main": "./out/main/index.js",
"scripts": {
"dev": "electron-vite dev",
"dev:remote": "electron-vite dev --mode remote",
"build": "electron-vite build",
"typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false",
"typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false",
@@ -22,12 +21,11 @@
"@dnd-kit/utilities": "^3.2.2",
"@electron-toolkit/preload": "^3.0.2",
"@electron-toolkit/utils": "^4.0.0",
"@fontsource/geist-mono": "^5.2.7",
"@fontsource/geist-sans": "^5.2.5",
"@multica/core": "workspace:*",
"@multica/ui": "workspace:*",
"@multica/views": "workspace:*",
"electron-updater": "^6.8.3",
"@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",

View File

@@ -1,33 +1,9 @@
import { app, shell, BrowserWindow, ipcMain } from "electron";
import { app, shell, BrowserWindow } from "electron";
import { join } from "path";
import { electronApp, optimizer, is } from "@electron-toolkit/utils";
import { setupAutoUpdater } from "./updater";
const PROTOCOL = "multica";
let mainWindow: BrowserWindow | null = null;
// --- Deep link helpers ---------------------------------------------------
function handleDeepLink(url: string): void {
try {
const parsed = new URL(url);
if (parsed.protocol !== `${PROTOCOL}:`) return;
// multica://auth/callback?token=<jwt>
if (parsed.hostname === "auth" && parsed.pathname === "/callback") {
const token = parsed.searchParams.get("token");
if (token && mainWindow) {
mainWindow.webContents.send("auth:token", token);
}
}
} catch {
// Ignore malformed URLs
}
}
// --- Window creation -----------------------------------------------------
function createWindow(): void {
mainWindow = new BrowserWindow({
width: 1280,
@@ -45,16 +21,6 @@ function createWindow(): void {
},
});
// Strip Origin header from WebSocket upgrade requests so the server's
// origin whitelist doesn't reject connections from localhost dev origins.
mainWindow.webContents.session.webRequest.onBeforeSendHeaders(
{ urls: ["wss://*/*", "ws://*/*"] },
(details, callback) => {
delete details.requestHeaders["Origin"];
callback({ requestHeaders: details.requestHeaders });
},
);
mainWindow.on("ready-to-show", () => {
mainWindow?.show();
});
@@ -71,74 +37,19 @@ function createWindow(): void {
}
}
// --- Protocol registration -----------------------------------------------
app.whenReady().then(() => {
electronApp.setAppUserModelId("ai.multica.desktop");
if (process.defaultApp) {
// In dev, register with the path to the electron binary + app path
app.setAsDefaultProtocolClient(PROTOCOL, process.execPath, [
app.getAppPath(),
]);
} else {
app.setAsDefaultProtocolClient(PROTOCOL);
}
// --- Single instance lock ------------------------------------------------
const gotTheLock = app.requestSingleInstanceLock();
if (!gotTheLock) {
app.quit();
} else {
// Windows/Linux: second instance passes deep link via argv
app.on("second-instance", (_event, argv) => {
if (mainWindow) {
if (mainWindow.isMinimized()) mainWindow.restore();
mainWindow.focus();
}
// On Windows the deep link URL is the last argv entry
const deepLinkUrl = argv.find((arg) => arg.startsWith(`${PROTOCOL}://`));
if (deepLinkUrl) handleDeepLink(deepLinkUrl);
app.on("browser-window-created", (_, window) => {
optimizer.watchWindowShortcuts(window);
});
app.whenReady().then(() => {
electronApp.setAppUserModelId("ai.multica.desktop");
createWindow();
app.on("browser-window-created", (_, window) => {
optimizer.watchWindowShortcuts(window);
});
// IPC: open URL in default browser (used by renderer for Google login)
ipcMain.handle("shell:openExternal", (_event, url: string) => {
return shell.openExternal(url);
});
createWindow();
setupAutoUpdater(() => mainWindow);
// macOS: deep link arrives via open-url event
app.on("open-url", (_event, url) => {
if (mainWindow) {
if (mainWindow.isMinimized()) mainWindow.restore();
mainWindow.focus();
}
handleDeepLink(url);
});
app.on("activate", () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow();
});
app.on("activate", () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow();
});
// Check argv for deep link on cold start (Windows/Linux)
const deepLinkArg = process.argv.find((arg) =>
arg.startsWith(`${PROTOCOL}://`),
);
if (deepLinkArg) {
app.whenReady().then(() => handleDeepLink(deepLinkArg));
}
}
});
app.on("window-all-closed", () => {
if (process.platform !== "darwin") app.quit();

View File

@@ -1,46 +0,0 @@
import { autoUpdater } from "electron-updater";
import { BrowserWindow, ipcMain } from "electron";
autoUpdater.autoDownload = false;
autoUpdater.autoInstallOnAppQuit = true;
export function setupAutoUpdater(getMainWindow: () => BrowserWindow | null): void {
autoUpdater.on("update-available", (info) => {
const win = getMainWindow();
win?.webContents.send("updater:update-available", {
version: info.version,
releaseNotes: info.releaseNotes,
});
});
autoUpdater.on("download-progress", (progress) => {
const win = getMainWindow();
win?.webContents.send("updater:download-progress", {
percent: progress.percent,
});
});
autoUpdater.on("update-downloaded", () => {
const win = getMainWindow();
win?.webContents.send("updater:update-downloaded");
});
autoUpdater.on("error", (err) => {
console.error("Auto-updater error:", err);
});
ipcMain.handle("updater:download", () => {
return autoUpdater.downloadUpdate();
});
ipcMain.handle("updater:install", () => {
autoUpdater.quitAndInstall(false, true);
});
// Check for updates after a short delay to avoid blocking startup
setTimeout(() => {
autoUpdater.checkForUpdates().catch((err) => {
console.error("Failed to check for updates:", err);
});
}, 5000);
}

View File

@@ -1,25 +1,8 @@
import { ElectronAPI } from "@electron-toolkit/preload";
interface DesktopAPI {
/** Listen for auth token delivered via deep link. Returns an unsubscribe function. */
onAuthToken: (callback: (token: string) => void) => () => void;
/** Open a URL in the default browser. */
openExternal: (url: string) => Promise<void>;
}
interface UpdaterAPI {
onUpdateAvailable: (callback: (info: { version: string; releaseNotes?: string }) => void) => () => void;
onDownloadProgress: (callback: (progress: { percent: number }) => void) => () => void;
onUpdateDownloaded: (callback: () => void) => () => void;
downloadUpdate: () => Promise<void>;
installUpdate: () => Promise<void>;
}
declare global {
interface Window {
electron: ElectronAPI;
desktopAPI: DesktopAPI;
updater: UpdaterAPI;
}
}

View File

@@ -1,49 +1,9 @@
import { contextBridge, ipcRenderer } from "electron";
import { contextBridge } from "electron";
import { electronAPI } from "@electron-toolkit/preload";
const desktopAPI = {
/** Listen for auth token delivered via deep link */
onAuthToken: (callback: (token: string) => void) => {
const handler = (_event: Electron.IpcRendererEvent, token: string) =>
callback(token);
ipcRenderer.on("auth:token", handler);
return () => {
ipcRenderer.removeListener("auth:token", handler);
};
},
/** Open a URL in the default browser */
openExternal: (url: string) => ipcRenderer.invoke("shell:openExternal", url),
};
const updaterAPI = {
onUpdateAvailable: (callback: (info: { version: string; releaseNotes?: string }) => void) => {
const handler = (_: unknown, info: { version: string; releaseNotes?: string }) => callback(info);
ipcRenderer.on("updater:update-available", handler);
return () => ipcRenderer.removeListener("updater:update-available", handler);
},
onDownloadProgress: (callback: (progress: { percent: number }) => void) => {
const handler = (_: unknown, progress: { percent: number }) => callback(progress);
ipcRenderer.on("updater:download-progress", handler);
return () => ipcRenderer.removeListener("updater:download-progress", handler);
},
onUpdateDownloaded: (callback: () => void) => {
const handler = () => callback();
ipcRenderer.on("updater:update-downloaded", handler);
return () => ipcRenderer.removeListener("updater:update-downloaded", handler);
},
downloadUpdate: () => ipcRenderer.invoke("updater:download"),
installUpdate: () => ipcRenderer.invoke("updater:install"),
};
if (process.contextIsolated) {
contextBridge.exposeInMainWorld("electron", electronAPI);
contextBridge.exposeInMainWorld("desktopAPI", desktopAPI);
contextBridge.exposeInMainWorld("updater", updaterAPI);
} else {
// @ts-expect-error - fallback for non-isolated context
window.electron = electronAPI;
// @ts-expect-error - fallback for non-isolated context
window.desktopAPI = desktopAPI;
// @ts-expect-error - fallback for non-isolated context
window.updater = updaterAPI;
}

View File

@@ -1,33 +1,15 @@
import { useEffect } from "react";
import { CoreProvider } from "@multica/core/platform";
import { useAuthStore } from "@multica/core/auth";
import { useWorkspaceStore } from "@multica/core/workspace";
import { api } from "@multica/core/api";
import { ThemeProvider } from "@multica/ui/components/common/theme-provider";
import { MulticaIcon } from "@multica/ui/components/common/multica-icon";
import { Toaster } from "sonner";
import { DesktopLoginPage } from "./pages/login";
import { DesktopShell } from "./components/desktop-layout";
import { UpdateNotification } from "./components/update-notification";
function AppContent() {
const user = useAuthStore((s) => s.user);
const isLoading = useAuthStore((s) => s.isLoading);
// Listen for auth token delivered via deep link (multica://auth/callback?token=...)
useEffect(() => {
return window.desktopAPI.onAuthToken(async (token) => {
try {
await useAuthStore.getState().loginWithToken(token);
const wsList = await api.listWorkspaces();
const lastWsId = localStorage.getItem("multica_workspace_id");
useWorkspaceStore.getState().hydrateWorkspace(wsList, lastWsId);
} catch {
// Token invalid or expired — user stays on login page
}
});
}, []);
if (isLoading) {
return (
<div className="flex h-screen items-center justify-center">
@@ -40,19 +22,16 @@ function AppContent() {
return <DesktopShell />;
}
const remoteProxy = Boolean(import.meta.env.VITE_REMOTE_API);
export default function App() {
return (
<ThemeProvider>
<CoreProvider
apiBaseUrl={remoteProxy ? "" : (import.meta.env.VITE_API_URL || "http://localhost:8080")}
wsUrl={remoteProxy ? "ws://localhost:5173/ws" : (import.meta.env.VITE_WS_URL || "ws://localhost:8080/ws")}
apiBaseUrl={import.meta.env.VITE_API_URL || "http://localhost:8080"}
wsUrl={import.meta.env.VITE_WS_URL || "ws://localhost:8080/ws"}
>
<AppContent />
</CoreProvider>
<Toaster />
<UpdateNotification />
</ThemeProvider>
);
}

View File

@@ -85,17 +85,17 @@ export function DesktopShell() {
>
<TabBar />
</header>
{/* Content area with inset styling — relative so ChatWindow/ChatFab are constrained here */}
<div className="relative flex flex-1 min-h-0 flex-col overflow-hidden mr-2 mb-2 ml-0.5 rounded-xl shadow-sm bg-background">
{/* Content area with inset styling */}
<div className="flex flex-1 min-h-0 flex-col overflow-hidden mr-2 mb-2 ml-0.5 rounded-xl shadow-sm bg-background">
<TabContent />
<ChatWindow />
<ChatFab />
</div>
</div>
</SidebarProvider>
</div>
<ModalRegistry />
<SearchCommand />
<ChatWindow />
<ChatFab />
</DashboardGuard>
</DesktopNavigationProvider>
);

View File

@@ -1,124 +0,0 @@
import { useCallback, useEffect, useState } from "react";
import { ArrowDownToLine, RefreshCw, X } from "lucide-react";
type UpdateState =
| { status: "idle" }
| { status: "available"; version: string }
| { status: "downloading"; percent: number }
| { status: "ready" };
export function UpdateNotification() {
const [state, setState] = useState<UpdateState>({ status: "idle" });
const [dismissed, setDismissed] = useState(false);
useEffect(() => {
const cleanups: (() => void)[] = [];
cleanups.push(
window.updater.onUpdateAvailable((info) => {
setState({ status: "available", version: info.version });
setDismissed(false);
}),
);
cleanups.push(
window.updater.onDownloadProgress((progress) => {
setState({ status: "downloading", percent: progress.percent });
}),
);
cleanups.push(
window.updater.onUpdateDownloaded(() => {
setState({ status: "ready" });
}),
);
return () => cleanups.forEach((fn) => fn());
}, []);
const handleDownload = useCallback(() => {
// Prevent double-click: immediately transition to downloading state
if (state.status !== "available") return;
setState({ status: "downloading", percent: 0 });
window.updater.downloadUpdate();
}, [state.status]);
const handleInstall = useCallback(() => {
window.updater.installUpdate();
}, []);
// Only allow dismiss when update is available (not during download or ready)
if (state.status === "idle") return null;
if (dismissed && state.status === "available") return null;
return (
<div className="fixed bottom-4 right-4 z-50 w-80 rounded-lg border border-border bg-background p-4 shadow-lg animate-in slide-in-from-bottom-2 fade-in duration-300">
<button
onClick={() => setDismissed(true)}
className="absolute top-2 right-2 rounded-md p-1 text-muted-foreground hover:text-foreground transition-colors"
>
<X className="size-3.5" />
</button>
{state.status === "available" && (
<div className="flex items-start gap-3">
<div className="mt-0.5 rounded-md bg-primary/10 p-1.5">
<ArrowDownToLine className="size-4 text-primary" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium">New version available</p>
<p className="text-xs text-muted-foreground mt-0.5">
v{state.version} is ready to download
</p>
<button
onClick={handleDownload}
className="mt-2 inline-flex items-center rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 transition-colors"
>
Download update
</button>
</div>
</div>
)}
{state.status === "downloading" && (
<div className="flex items-start gap-3">
<div className="mt-0.5 rounded-md bg-primary/10 p-1.5">
<ArrowDownToLine className="size-4 text-primary animate-pulse" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium">Downloading update...</p>
<div className="mt-2 h-1.5 w-full rounded-full bg-muted overflow-hidden">
<div
className="h-full rounded-full bg-primary transition-all duration-300"
style={{ width: `${Math.round(state.percent)}%` }}
/>
</div>
<p className="text-xs text-muted-foreground mt-1">
{Math.round(state.percent)}%
</p>
</div>
</div>
)}
{state.status === "ready" && (
<div className="flex items-start gap-3">
<div className="mt-0.5 rounded-md bg-success/10 p-1.5">
<RefreshCw className="size-4 text-success" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium">Update ready</p>
<p className="text-xs text-muted-foreground mt-0.5">
Restart to apply the update
</p>
<button
onClick={handleInstall}
className="mt-2 inline-flex items-center rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 transition-colors"
>
Restart now
</button>
</div>
</div>
)}
</div>
);
}

View File

@@ -1,19 +1,9 @@
import { LoginPage } from "@multica/views/auth";
import { MulticaIcon } from "@multica/ui/components/common/multica-icon";
const WEB_URL = import.meta.env.VITE_WEB_URL || "http://localhost:3000";
export function DesktopLoginPage() {
const lastWorkspaceId = localStorage.getItem("multica_workspace_id");
const handleGoogleLogin = () => {
// Open web login page in the default browser with platform=desktop flag.
// The web callback will redirect back via multica:// deep link with the token.
window.desktopAPI.openExternal(
`${WEB_URL}/login?platform=desktop`,
);
};
return (
<div className="flex h-screen flex-col">
{/* Traffic light inset */}
@@ -27,7 +17,6 @@ export function DesktopLoginPage() {
onSuccess={() => {
// Auth store update triggers AppContent re-render → shows DesktopShell
}}
onGoogleLogin={handleGoogleLogin}
/>
</div>
);

View File

@@ -8,7 +8,8 @@ description: Install the Multica CLI and start the agent daemon.
### Homebrew (macOS/Linux)
```bash
brew install multica-ai/tap/multica
brew tap multica-ai/tap
brew install multica
```
### Build from Source
@@ -47,28 +48,25 @@ rm /tmp/multica.tar.gz
### Update
```bash
brew upgrade multica-ai/tap/multica
```
For install script or manual installs, use:
```bash
multica update
```
`multica update` auto-detects your installation method and upgrades accordingly.
This auto-detects your installation method (Homebrew or manual) and upgrades accordingly.
## Quick Start
```bash
# One command: configure, authenticate, and start the daemon
multica setup
# 1. Authenticate (opens browser for login)
multica login
# 2. Start the agent daemon
multica daemon start
# 3. Done — agents in your watched workspaces can now execute tasks on your machine
```
This configures the CLI for Multica Cloud, opens your browser for login, discovers your workspaces, and starts the agent daemon.
For self-hosted servers, use `multica setup self-host` instead. See [Self-Hosting](/docs/getting-started/self-hosting) for details.
`multica login` automatically discovers all workspaces you belong to and adds them to the daemon watch list.
## Verify
@@ -78,15 +76,12 @@ multica daemon status
Confirm:
1. Status is `running`
2. At least one agent is listed (e.g. `claude`, `codex`, `opencode`, `openclaw`, or `hermes`)
2. At least one agent is listed (e.g. `claude`, `codex`)
3. At least one workspace is being watched
If the agents list is empty, install at least one supported AI agent CLI:
If the agents list is empty, install at least one AI agent CLI:
- [Claude Code](https://docs.anthropic.com/en/docs/claude-code) (`claude`)
- [Codex](https://github.com/openai/codex) (`codex`)
- OpenCode (`opencode`)
- OpenClaw (`openclaw`)
- Hermes (`hermes`)
Then restart the daemon:

View File

@@ -88,9 +88,6 @@ The daemon auto-detects these AI CLIs on your PATH:
|-----|---------|-------------|
| [Claude Code](https://docs.anthropic.com/en/docs/claude-code) | `claude` | Anthropic's coding agent |
| [Codex](https://github.com/openai/codex) | `codex` | OpenAI's coding agent |
| OpenCode | `opencode` | Open-source coding agent |
| OpenClaw | `openclaw` | Open-source coding agent |
| Hermes | `hermes` | Nous Research coding agent |
You need at least one installed. The daemon registers each detected CLI as an available runtime.
@@ -125,12 +122,6 @@ Agent-specific overrides:
| `MULTICA_CLAUDE_MODEL` | Override the Claude model used |
| `MULTICA_CODEX_PATH` | Custom path to the `codex` binary |
| `MULTICA_CODEX_MODEL` | Override the Codex model used |
| `MULTICA_OPENCODE_PATH` | Custom path to the `opencode` binary |
| `MULTICA_OPENCODE_MODEL` | Override the OpenCode model used |
| `MULTICA_OPENCLAW_PATH` | Custom path to the `openclaw` binary |
| `MULTICA_OPENCLAW_MODEL` | Override the OpenClaw model used |
| `MULTICA_HERMES_PATH` | Custom path to the `hermes` binary |
| `MULTICA_HERMES_MODEL` | Override the Hermes model used |
### Self-Hosted Server
@@ -156,11 +147,9 @@ multica config set server_url wss://api.example.com/ws
Profiles let you run multiple daemons on the same machine — for example, one for production and one for a staging server.
```bash
# Set up a staging profile
multica setup self-host --profile staging --server-url https://api-staging.example.com --app-url https://staging.example.com
# Start its daemon
multica daemon start --profile staging
# Start a daemon for the staging server
multica --profile staging login
multica --profile staging daemon start
# Default profile runs separately
multica daemon start

View File

@@ -19,30 +19,14 @@ Fetch https://github.com/multica-ai/multica/blob/main/CLI_INSTALL.md and follow
Or install manually:
### macOS / Linux (Homebrew - recommended)
```bash
brew install multica-ai/tap/multica
```
# Install
brew tap multica-ai/tap
brew install multica
### macOS / Linux (install script)
```bash
# Install the CLI
curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.sh | bash
```
### Windows (PowerShell)
```powershell
irm https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.ps1 | iex
```
Then configure, authenticate, and start the daemon:
```bash
# Configure, authenticate, and start the daemon
multica setup
# 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.

View File

@@ -21,21 +21,16 @@ Each user who wants to run AI agents locally also installs the **`multica` CLI**
## Quick Install
Two commands to set up everything:
One command to set up everything:
```bash
# Install CLI + provision self-host server
curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.sh | bash -s -- --with-server
# Configure CLI, authenticate, and start the daemon
multica setup self-host
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 it for localhost. Then open http://localhost:3000 — log in with any email + code **`888888`**.
This clones the repo, starts all services, installs the CLI, and configures everything. Then:
<Callout>
If the self-host server is already running and you only need the CLI on a macOS/Linux machine, install it with Homebrew: `brew install multica-ai/tap/multica`.
</Callout>
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.
@@ -83,14 +78,11 @@ brew install multica-ai/tap/multica
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)
- OpenCode (`opencode` on PATH)
- OpenClaw (`openclaw` on PATH)
- Hermes (`hermes` on PATH)
### b) One-command setup
```bash
multica setup self-host
multica setup --local
```
This automatically:
@@ -99,12 +91,6 @@ This automatically:
3. Discovers your workspaces
4. Starts the daemon in the background
For on-premise deployments with custom domains:
```bash
multica setup self-host --server-url https://api.example.com --app-url https://app.example.com
```
Verify the daemon is running:
```bash
@@ -112,7 +98,7 @@ multica daemon status
```
<Callout>
Alternatively, configure step by step: `multica config set server_url http://localhost:8080 && multica config set app_url http://localhost:3000 && multica login && multica daemon start`
Alternatively, configure manually: `multica config local && multica login && multica daemon start`
</Callout>
### Step 4 — Verify & Start Using
@@ -137,10 +123,16 @@ multica daemon stop
If you've been self-hosting and want to switch your CLI to [Multica Cloud](https://multica.ai):
```bash
multica setup
multica config set server_url https://api.multica.ai
multica config set app_url https://multica.ai
multica login
```
This reconfigures the CLI for multica.ai, re-authenticates, and restarts the daemon. You will be prompted before overwriting the existing configuration.
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.
@@ -219,21 +211,6 @@ These are configured on each user's machine, not on the server:
| `MULTICA_DAEMON_POLL_INTERVAL` | `3s` | How often the daemon polls for tasks |
| `MULTICA_DAEMON_HEARTBEAT_INTERVAL` | `15s` | Heartbeat frequency |
Agent-specific overrides:
| Variable | Description |
|----------|-------------|
| `MULTICA_CLAUDE_PATH` | Custom path to the `claude` binary |
| `MULTICA_CLAUDE_MODEL` | Override the Claude model used |
| `MULTICA_CODEX_PATH` | Custom path to the `codex` binary |
| `MULTICA_CODEX_MODEL` | Override the Codex model used |
| `MULTICA_OPENCODE_PATH` | Custom path to the `opencode` binary |
| `MULTICA_OPENCODE_MODEL` | Override the OpenCode model used |
| `MULTICA_OPENCLAW_PATH` | Custom path to the `openclaw` binary |
| `MULTICA_OPENCLAW_MODEL` | Override the OpenClaw model used |
| `MULTICA_HERMES_PATH` | Custom path to the `hermes` binary |
| `MULTICA_HERMES_MODEL` | Override the Hermes model used |
## Database Setup
Multica requires PostgreSQL 17 with the pgvector extension.

View File

@@ -5,13 +5,14 @@ description: Assign your first task to an agent in under 5 minutes.
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. Set up and start the daemon
## 1. Log in and start the daemon
```bash
multica setup # Configure, authenticate, and start the daemon
multica login # Authenticate with your Multica account
multica daemon start # Start the local agent runtime
```
This configures the CLI, opens your browser for login, discovers your workspaces, and starts the agent daemon in the background. It auto-detects agent CLIs (`claude`, `codex`, `openclaw`, `opencode`) available 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

View File

@@ -1,15 +1,6 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ReactNode } from "react";
function createWrapper() {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
);
}
const { mockSendCode, mockVerifyCode, mockHydrateWorkspace } = vi.hoisted(
() => ({
@@ -75,7 +66,7 @@ describe("LoginPage", () => {
});
it("renders login form with email input and continue button", () => {
render(<LoginPage />, { wrapper: createWrapper() });
render(<LoginPage />);
expect(screen.getByText("Sign in to Multica")).toBeInTheDocument();
expect(screen.getByText("Enter your email to get a login code")).toBeInTheDocument();
@@ -87,7 +78,7 @@ describe("LoginPage", () => {
it("does not call sendCode when email is empty", async () => {
const user = userEvent.setup();
render(<LoginPage />, { wrapper: createWrapper() });
render(<LoginPage />);
await user.click(screen.getByRole("button", { name: "Continue" }));
expect(mockSendCode).not.toHaveBeenCalled();
@@ -96,7 +87,7 @@ describe("LoginPage", () => {
it("calls sendCode with email on submit", async () => {
mockSendCode.mockResolvedValueOnce(undefined);
const user = userEvent.setup();
render(<LoginPage />, { wrapper: createWrapper() });
render(<LoginPage />);
await user.type(screen.getByLabelText("Email"), "test@multica.ai");
await user.click(screen.getByRole("button", { name: "Continue" }));
@@ -109,7 +100,7 @@ describe("LoginPage", () => {
it("shows 'Sending code...' while submitting", async () => {
mockSendCode.mockReturnValueOnce(new Promise(() => {}));
const user = userEvent.setup();
render(<LoginPage />, { wrapper: createWrapper() });
render(<LoginPage />);
await user.type(screen.getByLabelText("Email"), "test@multica.ai");
await user.click(screen.getByRole("button", { name: "Continue" }));
@@ -122,7 +113,7 @@ describe("LoginPage", () => {
it("shows verification code step after sending code", async () => {
mockSendCode.mockResolvedValueOnce(undefined);
const user = userEvent.setup();
render(<LoginPage />, { wrapper: createWrapper() });
render(<LoginPage />);
await user.type(screen.getByLabelText("Email"), "test@multica.ai");
await user.click(screen.getByRole("button", { name: "Continue" }));
@@ -135,7 +126,7 @@ describe("LoginPage", () => {
it("shows error when sendCode fails", async () => {
mockSendCode.mockRejectedValueOnce(new Error("Network error"));
const user = userEvent.setup();
render(<LoginPage />, { wrapper: createWrapper() });
render(<LoginPage />);
await user.type(screen.getByLabelText("Email"), "test@multica.ai");
await user.click(screen.getByRole("button", { name: "Continue" }));

View File

@@ -3,7 +3,6 @@
import { Suspense, useEffect } from "react";
import { useSearchParams, useRouter } from "next/navigation";
import { useAuthStore } from "@multica/core/auth";
import { useWorkspaceStore } from "@multica/core/workspace";
import { setLoggedInCookie } from "@/features/auth/auth-cookie";
import { LoginPage, validateCliCallback } from "@multica/views/auth";
@@ -17,7 +16,6 @@ function LoginPageContent() {
const cliCallbackRaw = searchParams.get("cli_callback");
const cliState = searchParams.get("cli_state") || "";
const platform = searchParams.get("platform");
const nextUrl = searchParams.get("next") || "/issues";
// Already authenticated — redirect to dashboard (skip if CLI callback)
@@ -32,20 +30,14 @@ function LoginPageContent() {
? localStorage.getItem("multica_workspace_id")
: null;
const handleSuccess = () => {
const ws = useWorkspaceStore.getState().workspace;
router.push(ws ? nextUrl : "/onboarding");
};
return (
<LoginPage
onSuccess={handleSuccess}
onSuccess={() => router.push(nextUrl)}
google={
googleClientId
? {
clientId: googleClientId,
redirectUri: `${window.location.origin}/auth/callback`,
state: platform === "desktop" ? "platform:desktop" : undefined,
}
: undefined
}

View File

@@ -1,23 +0,0 @@
"use client";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { useAuthStore } from "@multica/core/auth";
import { OnboardingWizard } from "@multica/views/onboarding";
export default function OnboardingPage() {
const router = useRouter();
const user = useAuthStore((s) => s.user);
const isLoading = useAuthStore((s) => s.isLoading);
// Redirect to login if not authenticated
useEffect(() => {
if (!isLoading && !user) router.replace("/login");
}, [isLoading, user, router]);
if (isLoading || !user) return null;
return (
<OnboardingWizard onComplete={() => router.push("/issues")} />
);
}

View File

@@ -11,8 +11,6 @@ export default function Layout({ children }: { children: React.ReactNode }) {
loadingIndicator={<MulticaIcon className="size-6" />}
searchSlot={<SearchTrigger />}
extra={<><SearchCommand /><ChatWindow /><ChatFab /></>}
onboardingPath="/onboarding"
loginPath="/login"
>
{children}
</DashboardLayout>

View File

@@ -2,10 +2,8 @@
import { Suspense, useEffect, useState } from "react";
import { useSearchParams, useRouter } from "next/navigation";
import { useQueryClient } from "@tanstack/react-query";
import { useAuthStore } from "@multica/core/auth";
import { useWorkspaceStore } from "@multica/core/workspace";
import { workspaceKeys } from "@multica/core/workspace/queries";
import { api } from "@multica/core/api";
import {
Card,
@@ -14,17 +12,14 @@ import {
CardDescription,
CardContent,
} from "@multica/ui/components/ui/card";
import { Button } from "@multica/ui/components/ui/button";
import { Loader2 } from "lucide-react";
function CallbackContent() {
const router = useRouter();
const searchParams = useSearchParams();
const qc = useQueryClient();
const loginWithGoogle = useAuthStore((s) => s.loginWithGoogle);
const hydrateWorkspace = useWorkspaceStore((s) => s.hydrateWorkspace);
const [error, setError] = useState("");
const [desktopToken, setDesktopToken] = useState<string | null>(null);
useEffect(() => {
const code = searchParams.get("code");
@@ -39,63 +34,19 @@ function CallbackContent() {
return;
}
const state = searchParams.get("state");
const isDesktop = state === "platform:desktop";
const redirectUri = `${window.location.origin}/auth/callback`;
if (isDesktop) {
// Desktop flow: exchange code for token, then redirect via deep link
api
.googleLogin(code, redirectUri)
.then(({ token }) => {
setDesktopToken(token);
window.location.href = `multica://auth/callback?token=${encodeURIComponent(token)}`;
})
.catch((err) => {
setError(err instanceof Error ? err.message : "Login failed");
});
} else {
// Normal web flow
loginWithGoogle(code, redirectUri)
.then(async () => {
const wsList = await api.listWorkspaces();
qc.setQueryData(workspaceKeys.list(), wsList);
const lastWsId = localStorage.getItem("multica_workspace_id");
const ws = await hydrateWorkspace(wsList, lastWsId);
router.push(ws ? "/issues" : "/onboarding");
})
.catch((err) => {
setError(err instanceof Error ? err.message : "Login failed");
});
}
}, [searchParams, loginWithGoogle, hydrateWorkspace, router, qc]);
if (desktopToken) {
return (
<div className="flex min-h-screen items-center justify-center">
<Card className="w-full max-w-sm">
<CardHeader className="text-center">
<CardTitle className="text-2xl">Opening Multica</CardTitle>
<CardDescription>
You should see a prompt to open the Multica desktop app. If
nothing happens, click the button below.
</CardDescription>
</CardHeader>
<CardContent className="flex justify-center">
<Button
variant="outline"
onClick={() => {
window.location.href = `multica://auth/callback?token=${encodeURIComponent(desktopToken)}`;
}}
>
Open Multica Desktop
</Button>
</CardContent>
</Card>
</div>
);
}
loginWithGoogle(code, redirectUri)
.then(async () => {
const wsList = await api.listWorkspaces();
const lastWsId = localStorage.getItem("multica_workspace_id");
await hydrateWorkspace(wsList, lastWsId);
router.push("/issues");
})
.catch((err) => {
setError(err instanceof Error ? err.message : "Login failed");
});
}, [searchParams, loginWithGoogle, hydrateWorkspace, router]);
if (error) {
return (

View File

@@ -7,38 +7,11 @@ import {
clearLoggedInCookie,
} from "@/features/auth/auth-cookie";
// Legacy token in localStorage → keep this session in token mode so users who
// logged in before the cookie-auth migration stay authed. They migrate to
// cookie mode on their next logout/login cycle (logout clears multica_token).
// Sunset: once telemetry shows <1% of sessions still carry multica_token,
// delete this branch and hard-code `cookieAuth` — the localStorage token is
// XSS-exposed and is the exact thing the cookie migration exists to remove.
function hasLegacyToken(): boolean {
if (typeof window === "undefined") return false;
try {
return Boolean(window.localStorage.getItem("multica_token"));
} catch {
return false;
}
}
// Derive WebSocket URL from the page origin so self-hosted / LAN deployments
// work without explicit NEXT_PUBLIC_WS_URL. The Next.js rewrite rule
// (/ws → backend) handles proxying.
function deriveWsUrl(): string | undefined {
if (process.env.NEXT_PUBLIC_WS_URL) return process.env.NEXT_PUBLIC_WS_URL;
if (typeof window === "undefined") return undefined;
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
return `${proto}//${window.location.host}/ws`;
}
export function WebProviders({ children }: { children: React.ReactNode }) {
const cookieAuth = !hasLegacyToken();
return (
<CoreProvider
apiBaseUrl={process.env.NEXT_PUBLIC_API_URL}
wsUrl={deriveWsUrl()}
cookieAuth={cookieAuth}
wsUrl={process.env.NEXT_PUBLIC_WS_URL}
onLogin={setLoggedInCookie}
onLogout={clearLoggedInCookie}
>

View File

@@ -1,7 +1,7 @@
import { cn } from "@multica/ui/lib/utils";
export const githubUrl = "https://github.com/multica-ai/multica";
export const twitterUrl = "https://x.com/MulticaAI";
export const twitterUrl = "https://x.com/multica_hq";
export function GitHubMark({ className }: { className?: string }) {
return (

View File

@@ -126,7 +126,7 @@ export const en: LandingDict = {
{
title: "Install the CLI & connect your machine",
description:
"Run multica setup to configure, authenticate, and start the daemon. It auto-detects Claude Code, Codex, OpenClaw, and OpenCode on your machine \u2014 plug in and go.",
"Run multica login to authenticate, then multica daemon start. The daemon auto-detects Claude Code, Codex, OpenClaw, and OpenCode on your machine \u2014 plug in and go.",
},
{
title: "Create your first agent",
@@ -230,7 +230,7 @@ export const en: LandingDict = {
links: [
{ label: "Documentation", href: githubUrl },
{ label: "API", href: githubUrl },
{ label: "X (Twitter)", href: "https://x.com/MulticaAI" },
{ label: "X (Twitter)", href: "https://x.com/multica_hq" },
],
},
company: {
@@ -277,72 +277,6 @@ export const en: LandingDict = {
fixes: "Bug Fixes",
},
entries: [
{
version: "0.1.33",
date: "2026-04-14",
title: "Gemini CLI & Agent Env Vars",
changes: [],
features: [
"Google Gemini CLI as a new agent runtime with live log streaming",
"Custom environment variables for agents (router/proxy mode) with dedicated settings tab",
"\"Set parent issue\" and \"Add sub-issue\" actions in issue context menu",
"CLI `--parent` flag for issue update and `--content-stdin` for piping comment content",
"Sub-issues inherit parent project automatically",
],
improvements: [
"Editor bubble menu and link preview rewritten for reliability",
"OpenClaw backend P0+P1 improvements (multi-line JSON, incremental parsing)",
"Self-hosted WebSocket URL auto-derived for LAN access",
],
fixes: [
"S3 upload keys scoped by workspace (security)",
"Workspace membership validation for subscriptions and uploads (security)",
"Active tasks auto-cancelled when issue status changes to cancelled",
"Agent task stall when process hangs on stdout",
"Daemon trigger prompt now embeds the actual triggering comment content",
"Login and dashboard redirect stability improvements",
],
},
{
version: "0.1.28",
date: "2026-04-13",
title: "Windows Support, Auth & Onboarding",
changes: [],
features: [
"Windows support — CLI installation, daemon, and release builds",
"Auth migrated to HttpOnly Cookie with WebSocket Origin whitelist",
"Full-screen onboarding wizard for new workspaces",
"Resizable Master Agent chat window with session history improvements",
"Token usage log scanning for OpenCode, OpenClaw, and Hermes runtimes",
],
fixes: [
"WebSocket first-message authentication security fix",
"Content-Security-Policy response header",
"Sub-issue progress computed from database instead of paginated client cache",
],
},
{
version: "0.1.27",
date: "2026-04-12",
title: "One-Click Setup, Self-Hosting & Stability",
changes: [],
features: [
"One-click install & setup — `curl | bash` installs CLI, `--with-server` bootstraps full self-hosting, `multica setup` configures your environment",
"Self-hosted storage — local file fallback when S3 is unavailable, plus custom S3 endpoint support (MinIO)",
"Inline property editing (priority, status, lead) on project list page",
],
improvements: [
"Stale agent tasks auto-swept; agent live card shows immediately without waiting for first message",
"Comment attachments uploaded via CLI now visible in the UI",
"Pinned items scoped per user with fixed sidebar pin action",
],
fixes: [
"Workspace ownership checks on daemon API routes and attachment uploads",
"Markdown sanitizer preserves code blocks from HTML entity escaping",
"Next.js upgraded to ^16.2.3 for CVE-2026-23869",
"OpenClaw backend rewritten to match actual CLI interface",
],
},
{
version: "0.1.24",
date: "2026-04-11",

View File

@@ -126,7 +126,7 @@ export const zh: LandingDict = {
{
title: "\u5b89\u88c5 CLI \u5e76\u8fde\u63a5\u4f60\u7684\u673a\u5668",
description:
"运行 multica setup 一键完成配置、认证和启动。守护进程自动检测你机器上的 Claude Code、Codex、OpenClaw OpenCode——插上就用。",
"\u8fd0\u884c multica login \u8fdb\u884c\u8ba4\u8bc1\uff0c\u7136\u540e multica daemon start\u3002\u5b88\u62a4\u8fdb\u7a0b\u81ea\u52a8\u68c0\u6d4b\u4f60\u673a\u5668\u4e0a\u7684 Claude Code\u3001Codex\u3001OpenClaw \u548c OpenCode\u2014\u2014\u63d2\u4e0a\u5c31\u7528\u3002",
},
{
title: "\u521b\u5efa\u4f60\u7684\u7b2c\u4e00\u4e2a Agent",
@@ -230,7 +230,7 @@ export const zh: LandingDict = {
links: [
{ label: "\u6587\u6863", href: githubUrl },
{ label: "API", href: githubUrl },
{ label: "X (Twitter)", href: "https://x.com/MulticaAI" },
{ label: "X (Twitter)", href: "https://x.com/multica_hq" },
],
},
company: {
@@ -277,72 +277,6 @@ export const zh: LandingDict = {
fixes: "问题修复",
},
entries: [
{
version: "0.1.33",
date: "2026-04-14",
title: "Gemini CLI 与 Agent 环境变量",
changes: [],
features: [
"Google Gemini CLI 作为新的 Agent 运行时,支持实时日志流",
"Agent 自定义环境变量router/proxy 模式),新增专用设置标签页",
"Issue 右键菜单新增「设置父 Issue」和「添加子 Issue」",
"CLI `--parent` 更新父 Issue`--content-stdin` 管道输入评论内容",
"子 Issue 自动继承父级项目",
],
improvements: [
"编辑器气泡菜单和链接预览重写",
"OpenClaw 后端 P0+P1 优化(多行 JSON、增量解析",
"自部署 WebSocket URL 自动适配局域网访问",
],
fixes: [
"S3 上传路径按工作区隔离(安全)",
"订阅和上传新增工作区成员身份校验(安全)",
"Issue 状态改为已取消时自动终止进行中的任务",
"Agent 进程 stdout 挂起导致任务卡住",
"Daemon 触发提示现在嵌入实际的触发评论内容",
"登录和仪表盘跳转稳定性改进",
],
},
{
version: "0.1.28",
date: "2026-04-13",
title: "Windows 支持、认证与引导",
changes: [],
features: [
"Windows 支持——CLI 安装、Daemon 运行和发布构建",
"认证迁移至 HttpOnly CookieWebSocket 新增 Origin 白名单",
"新工作区全屏引导向导",
"Master Agent 聊天窗口可调整大小,会话历史体验优化",
"OpenCode、OpenClaw 和 Hermes 运行时 Token 用量日志扫描",
],
fixes: [
"WebSocket 首条消息认证安全修复",
"新增 Content-Security-Policy 响应头",
"子 Issue 进度改为从数据库计算而非分页客户端缓存",
],
},
{
version: "0.1.27",
date: "2026-04-12",
title: "一键安装、自部署与稳定性",
changes: [],
features: [
"一键安装与配置——`curl | bash` 安装 CLI`--with-server` 完整自部署,`multica setup` 配置连接环境",
"自部署存储——无 S3 时本地文件存储回退,支持自定义 S3 端点MinIO",
"项目列表页支持行内编辑属性(优先级、状态、负责人)",
],
improvements: [
"过期 Agent 任务自动清扫;执行卡片立即显示,无需等待首条消息",
"通过 CLI 上传的评论附件现在可在 UI 中显示",
"置顶项按用户隔离,修复侧边栏置顶操作",
],
fixes: [
"Daemon API 路由和附件上传新增工作区所有权校验",
"Markdown 清洗器保留代码块不被 HTML 实体转义",
"Next.js 升级至 ^16.2.3 修复 CVE-2026-23869",
"OpenClaw 后端重写以匹配实际 CLI 接口",
],
},
{
version: "0.1.24",
date: "2026-04-11",

View File

@@ -1,7 +1,11 @@
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
export function proxy(_request: NextRequest) {
export function proxy(request: NextRequest) {
const loggedIn = request.cookies.has("multica_logged_in");
if (loggedIn) {
return NextResponse.redirect(new URL("/issues", request.url));
}
return NextResponse.next();
}

View File

@@ -54,7 +54,6 @@ export const mockAgents: Agent[] = [
status: "idle",
runtime_mode: "cloud",
runtime_config: {},
custom_env: {},
visibility: "workspace",
max_concurrent_tasks: 3,
owner_id: null,
@@ -76,9 +75,14 @@ export const mockAuthValue: Record<string, any> = {
isLoading: false,
login: vi.fn(),
logout: vi.fn(),
workspaces: [mockWorkspace],
switchWorkspace: vi.fn(),
createWorkspace: vi.fn(),
updateWorkspace: vi.fn(),
updateCurrentUser: vi.fn(),
leaveWorkspace: vi.fn(),
deleteWorkspace: vi.fn(),
refreshWorkspaces: vi.fn(),
getMemberName: (userId: string) => {
const m = mockMembers.find((m) => m.user_id === userId);
return m?.name ?? "Unknown";

View File

@@ -62,7 +62,6 @@ services:
args:
REMOTE_API_URL: http://backend:8080
NEXT_PUBLIC_GOOGLE_CLIENT_ID: ${NEXT_PUBLIC_GOOGLE_CLIENT_ID:-}
NEXT_PUBLIC_WS_URL: ${NEXT_PUBLIC_WS_URL:-}
depends_on:
- backend
ports:

View File

@@ -6,7 +6,6 @@
"scripts": {
"dev:web": "turbo dev --filter=@multica/web",
"dev:desktop": "turbo dev --filter=@multica/desktop",
"dev:desktop:remote": "pnpm --filter @multica/desktop dev:remote",
"build": "turbo build",
"typecheck": "turbo typecheck",
"test": "turbo test",

View File

@@ -1,35 +0,0 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { ApiClient, ApiError } from "./client";
afterEach(() => {
vi.unstubAllGlobals();
});
describe("ApiClient", () => {
it("preserves HTTP status on failed requests", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(
new Response(JSON.stringify({ error: "workspace slug already exists" }), {
status: 409,
statusText: "Conflict",
headers: { "Content-Type": "application/json" },
}),
),
);
const client = new ApiClient("https://api.example.test");
try {
await client.createWorkspace({ name: "Test", slug: "test" });
throw new Error("expected createWorkspace to fail");
} catch (error) {
expect(error).toBeInstanceOf(ApiError);
expect(error).toMatchObject({
message: "workspace slug already exists",
status: 409,
statusText: "Conflict",
});
}
});
});

View File

@@ -64,18 +64,6 @@ export interface LoginResponse {
user: User;
}
export class ApiError extends Error {
readonly status: number;
readonly statusText: string;
constructor(message: string, status: number, statusText: string) {
super(message);
this.name = "ApiError";
this.status = status;
this.statusText = statusText;
}
}
export class ApiClient {
private baseUrl: string;
private token: string | null = null;
@@ -89,10 +77,6 @@ export class ApiClient {
this.logger = options?.logger ?? noopLogger;
}
getBaseUrl(): string {
return this.baseUrl;
}
setToken(token: string | null) {
this.token = token;
}
@@ -101,20 +85,10 @@ export class ApiClient {
this.workspaceId = id;
}
private readCsrfToken(): string | null {
if (typeof document === "undefined") return null;
const match = document.cookie
.split("; ")
.find((c) => c.startsWith("multica_csrf="));
return match ? match.split("=")[1] ?? null : null;
}
private authHeaders(): Record<string, string> {
const headers: Record<string, string> = {};
if (this.token) headers["Authorization"] = `Bearer ${this.token}`;
if (this.workspaceId) headers["X-Workspace-ID"] = this.workspaceId;
const csrf = this.readCsrfToken();
if (csrf) headers["X-CSRF-Token"] = csrf;
return headers;
}
@@ -159,7 +133,7 @@ export class ApiClient {
const message = await this.parseErrorMessage(res, `API error: ${res.status} ${res.statusText}`);
const logLevel = res.status === 404 ? "warn" : "error";
this.logger[logLevel](`${res.status} ${path}`, { rid, duration: `${Date.now() - start}ms`, error: message });
throw new ApiError(message, res.status, res.statusText);
throw new Error(message);
}
this.logger.info(`${res.status} ${path}`, { rid, duration: `${Date.now() - start}ms` });
@@ -194,14 +168,6 @@ export class ApiClient {
});
}
async logout(): Promise<void> {
await this.fetch("/auth/logout", { method: "POST" });
}
async issueCliToken(): Promise<{ token: string }> {
return this.fetch("/api/cli-token", { method: "POST" });
}
async getMe(): Promise<User> {
return this.fetch("/api/me");
}
@@ -269,10 +235,6 @@ export class ApiClient {
return this.fetch(`/api/issues/${id}/children`);
}
async getChildIssueProgress(): Promise<{ progress: { parent_issue_id: string; total: number; done: number }[] }> {
return this.fetch("/api/issues/child-progress");
}
async deleteIssue(id: string): Promise<void> {
await this.fetch(`/api/issues/${id}`, { method: "DELETE" });
}
@@ -471,7 +433,7 @@ export class ApiClient {
}
async listTaskMessages(taskId: string): Promise<TaskMessagePayload[]> {
return this.fetch(`/api/tasks/${taskId}/messages`);
return this.fetch(`/api/daemon/tasks/${taskId}/messages`);
}
async listTasksByIssue(issueId: string): Promise<AgentTask[]> {

View File

@@ -1,4 +1,4 @@
export { ApiClient, ApiError } from "./client";
export { ApiClient } from "./client";
export type { ApiClientOptions } from "./client";
export { WSClient } from "./ws-client";

View File

@@ -8,7 +8,6 @@ export class WSClient {
private baseUrl: string;
private token: string | null = null;
private workspaceId: string | null = null;
private cookieAuth = false;
private handlers = new Map<WSEventType, Set<EventHandler>>();
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
private hasConnectedBefore = false;
@@ -16,45 +15,40 @@ export class WSClient {
private anyHandlers = new Set<(msg: WSMessage) => void>();
private logger: Logger;
constructor(url: string, options?: { logger?: Logger; cookieAuth?: boolean }) {
constructor(url: string, options?: { logger?: Logger }) {
this.baseUrl = url;
this.logger = options?.logger ?? noopLogger;
this.cookieAuth = options?.cookieAuth ?? false;
}
setAuth(token: string | null, workspaceId: string) {
setAuth(token: string, workspaceId: string) {
this.token = token;
this.workspaceId = workspaceId;
}
connect() {
const url = new URL(this.baseUrl);
// Token is never sent as a URL query parameter — it would be logged by
// proxies, CDNs, and browser history. In cookie mode the HttpOnly cookie
// is sent automatically with the upgrade request. In token mode the token
// is delivered as the first WebSocket message after the connection opens.
if (this.token) url.searchParams.set("token", this.token);
if (this.workspaceId)
url.searchParams.set("workspace_id", this.workspaceId);
this.ws = new WebSocket(url.toString());
this.ws.onopen = () => {
if (!this.cookieAuth && this.token) {
this.ws!.send(
JSON.stringify({ type: "auth", payload: { token: this.token } }),
);
return;
this.logger.info("connected");
if (this.hasConnectedBefore) {
for (const cb of this.onReconnectCallbacks) {
try {
cb();
} catch {
// ignore reconnect callback errors
}
}
}
this.onAuthenticated();
this.hasConnectedBefore = true;
};
this.ws.onmessage = (event) => {
const msg = JSON.parse(event.data as string) as WSMessage;
if ((msg as any).type === "auth_ack") {
this.onAuthenticated();
return;
}
this.logger.debug("received", msg.type);
const eventHandlers = this.handlers.get(msg.type);
if (eventHandlers) {
@@ -78,20 +72,6 @@ export class WSClient {
};
}
private onAuthenticated() {
this.logger.info("connected");
if (this.hasConnectedBefore) {
for (const cb of this.onReconnectCallbacks) {
try {
cb();
} catch {
// ignore reconnect callback errors
}
}
}
this.hasConnectedBefore = true;
}
disconnect() {
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);

View File

@@ -7,8 +7,6 @@ export interface AuthStoreOptions {
storage: StorageAdapter;
onLogin?: () => void;
onLogout?: () => void;
/** When true, rely on HttpOnly cookies instead of localStorage for auth tokens. */
cookieAuth?: boolean;
}
export interface AuthState {
@@ -19,32 +17,18 @@ export interface AuthState {
sendCode: (email: string) => Promise<void>;
verifyCode: (email: string, code: string) => Promise<User>;
loginWithGoogle: (code: string, redirectUri: string) => Promise<User>;
loginWithToken: (token: string) => Promise<User>;
logout: () => void;
setUser: (user: User) => void;
}
export function createAuthStore(options: AuthStoreOptions) {
const { api, storage, onLogin, onLogout, cookieAuth } = options;
const { api, storage, onLogin, onLogout } = options;
return create<AuthState>((set) => ({
user: null,
isLoading: true,
initialize: async () => {
if (cookieAuth) {
// In cookie mode, the HttpOnly cookie is sent automatically.
// Try to fetch the current user — if the cookie exists the server will accept it.
try {
const user = await api.getMe();
set({ user, isLoading: false });
} catch {
set({ user: null, isLoading: false });
}
return;
}
// Token mode: read from localStorage (Electron / legacy).
const token = storage.getItem("multica_token");
if (!token) {
set({ isLoading: false });
@@ -70,11 +54,8 @@ export function createAuthStore(options: AuthStoreOptions) {
verifyCode: async (email: string, code: string) => {
const { token, user } = await api.verifyCode(email, code);
if (!cookieAuth) {
// Token mode: persist for Electron / legacy.
storage.setItem("multica_token", token);
api.setToken(token);
}
storage.setItem("multica_token", token);
api.setToken(token);
onLogin?.();
set({ user });
return user;
@@ -82,29 +63,14 @@ export function createAuthStore(options: AuthStoreOptions) {
loginWithGoogle: async (code: string, redirectUri: string) => {
const { token, user } = await api.googleLogin(code, redirectUri);
if (!cookieAuth) {
storage.setItem("multica_token", token);
api.setToken(token);
}
storage.setItem("multica_token", token);
api.setToken(token);
onLogin?.();
set({ user });
return user;
},
loginWithToken: async (token: string) => {
storage.setItem("multica_token", token);
api.setToken(token);
const user = await api.getMe();
onLogin?.();
set({ user, isLoading: false });
return user;
},
logout: () => {
if (cookieAuth) {
// Clear server-side HttpOnly cookie.
api.logout().catch(() => {});
}
storage.removeItem("multica_token");
api.setToken(null);
api.setWorkspaceId(null);

View File

@@ -1,4 +1,4 @@
export { createChatStore, CHAT_MIN_W, CHAT_MIN_H, CHAT_DEFAULT_W, CHAT_DEFAULT_H } from "./store";
export { createChatStore } from "./store";
export type { ChatStoreOptions, ChatState, ChatTimelineItem } from "./store";
import type { createChatStore as CreateChatStoreFn } from "./store";

View File

@@ -2,7 +2,6 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
import { api } from "../api";
import { useWorkspaceId } from "../hooks";
import { chatKeys } from "./queries";
import type { ChatSession } from "../types";
export function useCreateChatSession() {
const qc = useQueryClient();
@@ -24,29 +23,6 @@ export function useArchiveChatSession() {
return useMutation({
mutationFn: (sessionId: string) => api.archiveChatSession(sessionId),
onMutate: async (sessionId) => {
await qc.cancelQueries({ queryKey: chatKeys.sessions(wsId) });
await qc.cancelQueries({ queryKey: chatKeys.allSessions(wsId) });
const prevSessions = qc.getQueryData<ChatSession[]>(chatKeys.sessions(wsId));
const prevAll = qc.getQueryData<ChatSession[]>(chatKeys.allSessions(wsId));
// Optimistic: remove from active, mark as archived in allSessions
qc.setQueryData<ChatSession[]>(chatKeys.sessions(wsId), (old) =>
old ? old.filter((s) => s.id !== sessionId) : old,
);
qc.setQueryData<ChatSession[]>(chatKeys.allSessions(wsId), (old) =>
old?.map((s) =>
s.id === sessionId ? { ...s, status: "archived" as const } : s,
),
);
return { prevSessions, prevAll };
},
onError: (_err, _id, ctx) => {
if (ctx?.prevSessions) qc.setQueryData(chatKeys.sessions(wsId), ctx.prevSessions);
if (ctx?.prevAll) qc.setQueryData(chatKeys.allSessions(wsId), ctx.prevAll);
},
onSettled: () => {
qc.invalidateQueries({ queryKey: chatKeys.sessions(wsId) });
qc.invalidateQueries({ queryKey: chatKeys.allSessions(wsId) });

View File

@@ -4,15 +4,6 @@ import { getCurrentWorkspaceId, registerForWorkspaceRehydration } from "../platf
const AGENT_STORAGE_KEY = "multica:chat:selectedAgentId";
const SESSION_STORAGE_KEY = "multica:chat:activeSessionId";
const DRAFT_KEY = "multica:chat:draft";
const CHAT_WIDTH_KEY = "multica:chat:width";
const CHAT_HEIGHT_KEY = "multica:chat:height";
const CHAT_EXPANDED_KEY = "multica:chat:expanded";
export const CHAT_MIN_W = 360;
export const CHAT_MIN_H = 480;
export const CHAT_DEFAULT_W = 420;
export const CHAT_DEFAULT_H = 600;
export interface ChatTimelineItem {
seq: number;
@@ -25,29 +16,21 @@ export interface ChatTimelineItem {
export interface ChatState {
isOpen: boolean;
isFullscreen: boolean;
activeSessionId: string | null;
pendingTaskId: string | null;
selectedAgentId: string | null;
showHistory: boolean;
timelineItems: ChatTimelineItem[];
inputDraft: string;
/** Raw user-chosen size — no clamp applied. UI layer clamps at render time. */
chatWidth: number;
chatHeight: number;
isExpanded: boolean;
setOpen: (open: boolean) => void;
toggle: () => void;
toggleFullscreen: () => void;
setActiveSession: (id: string | null) => void;
setPendingTask: (taskId: string | null) => void;
setSelectedAgentId: (id: string) => void;
setShowHistory: (show: boolean) => void;
addTimelineItem: (item: ChatTimelineItem) => void;
clearTimeline: () => void;
setInputDraft: (draft: string) => void;
clearInputDraft: () => void;
/** Persist raw size and auto-exit expanded mode. */
setChatSize: (width: number, height: number) => void;
setExpanded: (expanded: boolean) => void;
}
export interface ChatStoreOptions {
@@ -64,17 +47,20 @@ export function createChatStore(options: ChatStoreOptions) {
const store = create<ChatState>((set) => ({
isOpen: false,
isFullscreen: false,
activeSessionId: storage.getItem(wsKey(SESSION_STORAGE_KEY)),
pendingTaskId: null,
selectedAgentId: storage.getItem(wsKey(AGENT_STORAGE_KEY)),
showHistory: false,
timelineItems: [],
inputDraft: storage.getItem(wsKey(DRAFT_KEY)) ?? "",
chatWidth: Number(storage.getItem(CHAT_WIDTH_KEY)) || CHAT_DEFAULT_W,
chatHeight: Number(storage.getItem(CHAT_HEIGHT_KEY)) || CHAT_DEFAULT_H,
isExpanded: storage.getItem(wsKey(CHAT_EXPANDED_KEY)) === "true",
setOpen: (open) => set({ isOpen: open }),
toggle: () => set((s) => ({ isOpen: !s.isOpen })),
setOpen: (open) =>
set({ isOpen: open, ...(open ? {} : { isFullscreen: false }) }),
toggle: () =>
set((s) => ({
isOpen: !s.isOpen,
...(s.isOpen ? { isFullscreen: false } : {}),
})),
toggleFullscreen: () => set((s) => ({ isFullscreen: !s.isFullscreen })),
setActiveSession: (id) => {
if (id) {
storage.setItem(wsKey(SESSION_STORAGE_KEY), id);
@@ -89,18 +75,6 @@ export function createChatStore(options: ChatStoreOptions) {
set({ selectedAgentId: id });
},
setShowHistory: (show) => set({ showHistory: show }),
setInputDraft: (draft) => {
if (draft) {
storage.setItem(wsKey(DRAFT_KEY), draft);
} else {
storage.removeItem(wsKey(DRAFT_KEY));
}
set({ inputDraft: draft });
},
clearInputDraft: () => {
storage.removeItem(wsKey(DRAFT_KEY));
set({ inputDraft: "" });
},
addTimelineItem: (item) =>
set((s) => {
if (s.timelineItems.some((t) => t.seq === item.seq)) return s;
@@ -111,28 +85,12 @@ export function createChatStore(options: ChatStoreOptions) {
};
}),
clearTimeline: () => set({ timelineItems: [] }),
setChatSize: (w, h) => {
storage.setItem(CHAT_WIDTH_KEY, String(w));
storage.setItem(CHAT_HEIGHT_KEY, String(h));
// Dragging = user chose a manual size → exit expanded mode
storage.removeItem(wsKey(CHAT_EXPANDED_KEY));
set({ chatWidth: w, chatHeight: h, isExpanded: false });
},
setExpanded: (expanded) => {
if (expanded) {
storage.setItem(wsKey(CHAT_EXPANDED_KEY), "true");
} else {
storage.removeItem(wsKey(CHAT_EXPANDED_KEY));
}
set({ isExpanded: expanded });
},
}));
registerForWorkspaceRehydration(() => {
store.setState({
activeSessionId: storage.getItem(wsKey(SESSION_STORAGE_KEY)),
selectedAgentId: storage.getItem(wsKey(AGENT_STORAGE_KEY)),
inputDraft: storage.getItem(wsKey(DRAFT_KEY)) ?? "",
timelineItems: [],
});
});

View File

@@ -97,7 +97,6 @@ export function useCreateIssue() {
// Invalidate parent's children query so sub-issues list updates immediately
if (newIssue.parent_issue_id) {
qc.invalidateQueries({ queryKey: issueKeys.children(wsId, newIssue.parent_issue_id) });
qc.invalidateQueries({ queryKey: issueKeys.childProgress(wsId) });
}
},
onSettled: () => {
@@ -168,20 +167,10 @@ export function useUpdateIssue() {
onSettled: (_data, _err, vars, ctx) => {
qc.invalidateQueries({ queryKey: issueKeys.detail(wsId, vars.id) });
qc.invalidateQueries({ queryKey: issueKeys.list(wsId) });
// Invalidate old parent's children cache
if (ctx?.parentId) {
qc.invalidateQueries({
queryKey: issueKeys.children(wsId, ctx.parentId),
});
qc.invalidateQueries({ queryKey: issueKeys.childProgress(wsId) });
}
// Invalidate new parent's children cache when parent_issue_id changed
const newParentId = vars.parent_issue_id;
if (newParentId && newParentId !== ctx?.parentId) {
qc.invalidateQueries({
queryKey: issueKeys.children(wsId, newParentId),
});
qc.invalidateQueries({ queryKey: issueKeys.childProgress(wsId) });
}
},
});
@@ -216,7 +205,6 @@ export function useDeleteIssue() {
qc.invalidateQueries({ queryKey: issueKeys.list(wsId) });
if (ctx?.parentIssueId) {
qc.invalidateQueries({ queryKey: issueKeys.children(wsId, ctx.parentIssueId) });
qc.invalidateQueries({ queryKey: issueKeys.childProgress(wsId) });
}
},
});
@@ -290,11 +278,10 @@ export function useBatchDeleteIssues() {
},
onSettled: (_data, _err, _ids, ctx) => {
qc.invalidateQueries({ queryKey: issueKeys.list(wsId) });
if (ctx?.parentIssueIds && ctx.parentIssueIds.size > 0) {
if (ctx?.parentIssueIds) {
for (const parentId of ctx.parentIssueIds) {
qc.invalidateQueries({ queryKey: issueKeys.children(wsId, parentId) });
}
qc.invalidateQueries({ queryKey: issueKeys.childProgress(wsId) });
}
},
});

View File

@@ -14,8 +14,6 @@ export const issueKeys = {
[...issueKeys.all(wsId), "detail", id] as const,
children: (wsId: string, id: string) =>
[...issueKeys.all(wsId), "children", id] as const,
childProgress: (wsId: string) =>
[...issueKeys.all(wsId), "child-progress"] as const,
timeline: (issueId: string) => ["issues", "timeline", issueId] as const,
reactions: (issueId: string) => ["issues", "reactions", issueId] as const,
subscribers: (issueId: string) =>
@@ -91,20 +89,6 @@ export function issueDetailOptions(wsId: string, id: string) {
});
}
export function childIssueProgressOptions(wsId: string) {
return queryOptions({
queryKey: issueKeys.childProgress(wsId),
queryFn: () => api.getChildIssueProgress(),
select: (data) => {
const map = new Map<string, { done: number; total: number }>();
for (const entry of data.progress) {
map.set(entry.parent_issue_id, { done: entry.done, total: entry.total });
}
return map;
},
});
}
export function childIssuesOptions(wsId: string, id: string) {
return queryOptions({
queryKey: issueKeys.children(wsId, id),

View File

@@ -20,7 +20,6 @@ export function onIssueCreated(
qc.invalidateQueries({ queryKey: issueKeys.myAll(wsId) });
if (issue.parent_issue_id) {
qc.invalidateQueries({ queryKey: issueKeys.children(wsId, issue.parent_issue_id) });
qc.invalidateQueries({ queryKey: issueKeys.childProgress(wsId) });
}
}
@@ -29,19 +28,16 @@ export function onIssueUpdated(
wsId: string,
issue: Partial<Issue> & { id: string },
) {
// Look up the OLD parent before mutating list state, so we can keep
// the parent's children cache in sync (powers the sub-issues list
// shown on the parent issue page).
// Look up the parent before mutating list state, so we can also keep the
// parent's children cache in sync (powers the sub-issues list shown on
// the parent issue page).
const listData = qc.getQueryData<ListIssuesResponse>(issueKeys.list(wsId));
const detailData = qc.getQueryData<Issue>(issueKeys.detail(wsId, issue.id));
const oldParentId =
const parentId =
issue.parent_issue_id ??
detailData?.parent_issue_id ??
listData?.issues.find((i) => i.id === issue.id)?.parent_issue_id ??
null;
// The NEW parent comes from the WS payload when parent_issue_id changed
const newParentId = issue.parent_issue_id ?? null;
const parentChanged =
issue.parent_issue_id !== undefined && newParentId !== oldParentId;
qc.setQueryData<ListIssuesResponse>(issueKeys.list(wsId), (old) => {
if (!old) return old;
@@ -66,25 +62,10 @@ export function onIssueUpdated(
qc.setQueryData<Issue>(issueKeys.detail(wsId, issue.id), (old) =>
old ? { ...old, ...issue } : old,
);
// Invalidate old parent's children (issue was removed from it)
if (oldParentId) {
if (parentChanged) {
qc.invalidateQueries({ queryKey: issueKeys.children(wsId, oldParentId) });
} else {
qc.setQueryData<Issue[]>(issueKeys.children(wsId, oldParentId), (old) =>
old?.map((c) => (c.id === issue.id ? { ...c, ...issue } : c)),
);
}
}
// Invalidate new parent's children (issue was added to it)
if (newParentId && parentChanged) {
qc.invalidateQueries({ queryKey: issueKeys.children(wsId, newParentId) });
}
if (oldParentId || newParentId) {
if (issue.status !== undefined || issue.parent_issue_id !== undefined) {
qc.invalidateQueries({ queryKey: issueKeys.childProgress(wsId) });
}
if (parentId) {
qc.setQueryData<Issue[]>(issueKeys.children(wsId, parentId), (old) =>
old?.map((c) => (c.id === issue.id ? { ...c, ...issue } : c)),
);
}
}
@@ -115,6 +96,5 @@ export function onIssueDeleted(
qc.removeQueries({ queryKey: issueKeys.children(wsId, issueId) });
if (deleted?.parent_issue_id) {
qc.invalidateQueries({ queryKey: issueKeys.children(wsId, deleted.parent_issue_id) });
qc.invalidateQueries({ queryKey: issueKeys.childProgress(wsId) });
}
}

View File

@@ -1,11 +1,9 @@
"use client";
import { useEffect, type ReactNode } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { getApi } from "../api";
import { useAuthStore } from "../auth";
import { useWorkspaceStore } from "../workspace";
import { workspaceKeys } from "../workspace/queries";
import { createLogger } from "../logger";
import { defaultStorage } from "./storage";
import type { StorageAdapter } from "../types/storage";
@@ -17,39 +15,13 @@ export function AuthInitializer({
onLogin,
onLogout,
storage = defaultStorage,
cookieAuth,
}: {
children: ReactNode;
onLogin?: () => void;
onLogout?: () => void;
storage?: StorageAdapter;
cookieAuth?: boolean;
}) {
const qc = useQueryClient();
useEffect(() => {
const api = getApi();
const wsId = storage.getItem("multica_workspace_id");
if (cookieAuth) {
// Cookie mode: the HttpOnly cookie is sent automatically by the browser.
// Call the API to check if the session is still valid.
Promise.all([api.getMe(), api.listWorkspaces()])
.then(([user, wsList]) => {
onLogin?.();
useAuthStore.setState({ user, isLoading: false });
qc.setQueryData(workspaceKeys.list(), wsList);
useWorkspaceStore.getState().hydrateWorkspace(wsList, wsId);
})
.catch((err) => {
logger.error("cookie auth init failed", err);
onLogout?.();
useAuthStore.setState({ user: null, isLoading: false });
});
return;
}
// Token mode: read from localStorage (Electron / legacy).
const token = storage.getItem("multica_token");
if (!token) {
onLogout?.();
@@ -57,14 +29,14 @@ export function AuthInitializer({
return;
}
const api = getApi();
api.setToken(token);
const wsId = storage.getItem("multica_workspace_id");
Promise.all([api.getMe(), api.listWorkspaces()])
.then(([user, wsList]) => {
onLogin?.();
useAuthStore.setState({ user, isLoading: false });
// Seed React Query cache so components don't need a second fetch
qc.setQueryData(workspaceKeys.list(), wsList);
useWorkspaceStore.getState().hydrateWorkspace(wsList, wsId);
})
.catch((err) => {

View File

@@ -25,7 +25,6 @@ function initCore(
storage: StorageAdapter,
onLogin?: () => void,
onLogout?: () => void,
cookieAuth?: boolean,
) {
if (initialized) return;
@@ -38,15 +37,13 @@ function initCore(
});
setApiInstance(api);
// In token mode, hydrate token from storage.
if (!cookieAuth) {
const token = storage.getItem("multica_token");
if (token) api.setToken(token);
}
// Hydrate token from storage
const token = storage.getItem("multica_token");
if (token) api.setToken(token);
const wsId = storage.getItem("multica_workspace_id");
if (wsId) api.setWorkspaceId(wsId);
authStore = createAuthStore({ api, storage, onLogin, onLogout, cookieAuth });
authStore = createAuthStore({ api, storage, onLogin, onLogout });
registerAuthStore(authStore);
workspaceStore = createWorkspaceStore(api, { storage });
@@ -63,24 +60,22 @@ export function CoreProvider({
apiBaseUrl = "",
wsUrl = "ws://localhost:8080/ws",
storage = defaultStorage,
cookieAuth,
onLogin,
onLogout,
}: CoreProviderProps) {
// Initialize singletons on first render only. Dependencies are read-once:
// apiBaseUrl, storage, and callbacks are set at app boot and never change at runtime.
// eslint-disable-next-line react-hooks/exhaustive-deps
useMemo(() => initCore(apiBaseUrl, storage, onLogin, onLogout, cookieAuth), []);
useMemo(() => initCore(apiBaseUrl, storage, onLogin, onLogout), []);
return (
<QueryProvider>
<AuthInitializer onLogin={onLogin} onLogout={onLogout} storage={storage} cookieAuth={cookieAuth}>
<AuthInitializer onLogin={onLogin} onLogout={onLogout} storage={storage}>
<WSProvider
wsUrl={wsUrl}
authStore={authStore}
workspaceStore={workspaceStore}
storage={storage}
cookieAuth={cookieAuth}
>
{children}
</WSProvider>

View File

@@ -8,8 +8,6 @@ export interface CoreProviderProps {
wsUrl?: string;
/** Storage adapter. Default: SSR-safe localStorage wrapper. */
storage?: StorageAdapter;
/** Use HttpOnly cookies for auth instead of localStorage tokens. Default: false. */
cookieAuth?: boolean;
/** Called after successful login (e.g. set cookie for Next.js middleware). */
onLogin?: () => void;
/** Called after logout (e.g. clear cookie). */

View File

@@ -2,14 +2,16 @@
import { useState } from "react";
import { QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { createQueryClient } from "./query-client";
import type { ReactNode } from "react";
export function QueryProvider({ children }: { children: ReactNode }) {
export function QueryProvider({ children, showDevtools = true }: { children: ReactNode; showDevtools?: boolean }) {
const [queryClient] = useState(createQueryClient);
return (
<QueryClientProvider client={queryClient}>
{children}
{showDevtools && <ReactQueryDevtools initialIsOpen={false} />}
</QueryClientProvider>
);
}

View File

@@ -35,8 +35,6 @@ export interface WSProviderProps {
workspaceStore: UseBoundStore<StoreApi<WorkspaceStore>>;
/** Platform-specific storage adapter for reading auth tokens */
storage: StorageAdapter;
/** When true, use HttpOnly cookies instead of token query param for WS auth. */
cookieAuth?: boolean;
/** Optional callback for showing toast messages (platform-specific, e.g. sonner) */
onToast?: (message: string, type?: "info" | "error") => void;
}
@@ -47,7 +45,6 @@ export function WSProvider({
authStore,
workspaceStore,
storage,
cookieAuth,
onToast,
}: WSProviderProps) {
const user = authStore((s) => s.user);
@@ -57,15 +54,10 @@ export function WSProvider({
useEffect(() => {
if (!user || !workspace) return;
// In token mode we need a token from storage; in cookie mode the HttpOnly
// cookie is sent automatically with the WS upgrade request.
const token = cookieAuth ? null : storage.getItem("multica_token");
if (!cookieAuth && !token) return;
const token = storage.getItem("multica_token");
if (!token) return;
const ws = new WSClient(wsUrl, {
logger: createLogger("ws"),
cookieAuth,
});
const ws = new WSClient(wsUrl, { logger: createLogger("ws") });
ws.setAuth(token, workspace.id);
setWsClient(ws);
ws.connect();
@@ -74,7 +66,7 @@ export function WSProvider({
ws.disconnect();
setWsClient(null);
};
}, [user, workspace, wsUrl, storage, cookieAuth]);
}, [user, workspace, wsUrl, storage]);
const stores: RealtimeSyncStores = { authStore, workspaceStore };

View File

@@ -20,7 +20,7 @@ import {
} from "../issues/ws-updaters";
import { onInboxNew, onInboxInvalidate, onInboxIssueStatusChanged } from "../inbox/ws-updaters";
import { inboxKeys } from "../inbox/queries";
import { workspaceKeys, workspaceListOptions } from "../workspace/queries";
import { workspaceKeys } from "../workspace/queries";
import type {
MemberAddedPayload,
WorkspaceDeletedPayload,
@@ -251,9 +251,7 @@ export function useRealtimeSync(
if (currentWs?.id === workspace_id) {
logger.warn("current workspace deleted, switching");
onToast?.("This workspace was deleted", "info");
qc.fetchQuery({ ...workspaceListOptions(), staleTime: 0 }).then((wsList) => {
workspaceStore.getState().hydrateWorkspace(wsList);
});
workspaceStore.getState().refreshWorkspaces();
}
});
@@ -265,9 +263,7 @@ export function useRealtimeSync(
if (wsId) clearWorkspaceStorage(defaultStorage, wsId);
logger.warn("removed from workspace, switching");
onToast?.("You were removed from this workspace", "info");
qc.fetchQuery({ ...workspaceListOptions(), staleTime: 0 }).then((wsList) => {
workspaceStore.getState().hydrateWorkspace(wsList);
});
workspaceStore.getState().refreshWorkspaces();
}
});
@@ -275,7 +271,7 @@ export function useRealtimeSync(
const { member, workspace_name } = p as MemberAddedPayload;
const myUserId = authStore.getState().user?.id;
if (member.user_id === myUserId) {
qc.invalidateQueries({ queryKey: workspaceKeys.list() });
workspaceStore.getState().refreshWorkspaces();
onToast?.(
`You were invited to ${workspace_name ?? "a workspace"}`,
"info",

View File

@@ -47,7 +47,6 @@ export interface Agent {
avatar_url: string | null;
runtime_mode: AgentRuntimeMode;
runtime_config: Record<string, unknown>;
custom_env: Record<string, string>;
visibility: AgentVisibility;
status: AgentStatus;
max_concurrent_tasks: number;
@@ -66,7 +65,6 @@ export interface CreateAgentRequest {
avatar_url?: string;
runtime_id: string;
runtime_config?: Record<string, unknown>;
custom_env?: Record<string, string>;
visibility?: AgentVisibility;
max_concurrent_tasks?: number;
}
@@ -78,7 +76,6 @@ export interface UpdateAgentRequest {
avatar_url?: string;
runtime_id?: string;
runtime_config?: Record<string, unknown>;
custom_env?: Record<string, string>;
visibility?: AgentVisibility;
status?: AgentStatus;
max_concurrent_tasks?: number;

View File

@@ -1,19 +1,12 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import type { Workspace } from "../types";
import { api } from "../api";
import { workspaceKeys, workspaceListOptions } from "./queries";
import { useWorkspaceStore } from "./index";
import { workspaceKeys } from "./queries";
export function useCreateWorkspace() {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: { name: string; slug: string; description?: string }) =>
api.createWorkspace(data),
onSuccess: (newWs) => {
// Add to cache before switching so sidebar list is consistent on first render
qc.setQueryData(workspaceKeys.list(), (old: Workspace[] = []) => [...old, newWs]);
useWorkspaceStore.getState().switchWorkspace(newWs);
},
onSettled: () => {
qc.invalidateQueries({ queryKey: workspaceKeys.list() });
},
@@ -24,14 +17,6 @@ export function useLeaveWorkspace() {
const qc = useQueryClient();
return useMutation({
mutationFn: (workspaceId: string) => api.leaveWorkspace(workspaceId),
onSuccess: async (_, workspaceId) => {
const currentWsId = useWorkspaceStore.getState().workspace?.id;
if (currentWsId === workspaceId) {
// staleTime: 0 forces a real network fetch — cache still has the left workspace
const wsList = await qc.fetchQuery({ ...workspaceListOptions(), staleTime: 0 });
useWorkspaceStore.getState().hydrateWorkspace(wsList);
}
},
onSettled: () => {
qc.invalidateQueries({ queryKey: workspaceKeys.list() });
},
@@ -42,14 +27,6 @@ export function useDeleteWorkspace() {
const qc = useQueryClient();
return useMutation({
mutationFn: (workspaceId: string) => api.deleteWorkspace(workspaceId),
onSuccess: async (_, workspaceId) => {
const currentWsId = useWorkspaceStore.getState().workspace?.id;
if (currentWsId === workspaceId) {
// staleTime: 0 forces a real network fetch — cache still has the deleted workspace
const wsList = await qc.fetchQuery({ ...workspaceListOptions(), staleTime: 0 });
useWorkspaceStore.getState().hydrateWorkspace(wsList);
}
},
onSettled: () => {
qc.invalidateQueries({ queryKey: workspaceKeys.list() });
},

View File

@@ -8,25 +8,29 @@ const logger = createLogger("workspace-store");
interface WorkspaceStoreOptions {
storage?: StorageAdapter;
onError?: (message: string) => void;
}
interface WorkspaceState {
workspace: Workspace | null;
workspaces: Workspace[];
}
interface WorkspaceActions {
/**
* Pick a workspace from a list and set it as current.
* The list itself is NOT stored here — it lives in React Query.
*/
hydrateWorkspace: (
wsList: Workspace[],
preferredWorkspaceId?: string | null,
) => Workspace | null;
/** Switch to a workspace. Caller provides the full object (from React Query). */
switchWorkspace: (ws: Workspace) => void;
/** Update current workspace data in place (e.g. after rename). */
switchWorkspace: (workspaceId: string) => void;
refreshWorkspaces: () => Promise<Workspace[]>;
updateWorkspace: (ws: Workspace) => void;
createWorkspace: (data: {
name: string;
slug: string;
description?: string;
}) => Promise<Workspace>;
leaveWorkspace: (workspaceId: string) => Promise<void>;
deleteWorkspace: (workspaceId: string) => Promise<void>;
clearWorkspace: () => void;
}
@@ -34,13 +38,17 @@ export type WorkspaceStore = WorkspaceState & WorkspaceActions;
export function createWorkspaceStore(api: ApiClient, options?: WorkspaceStoreOptions) {
const storage = options?.storage;
const onError = options?.onError;
return create<WorkspaceStore>((set) => ({
// Only the currently selected workspace (UI state).
// The workspace list is server state and lives in React Query.
return create<WorkspaceStore>((set, get) => ({
// State
workspace: null,
workspaces: [],
// Actions
hydrateWorkspace: (wsList, preferredWorkspaceId) => {
set({ workspaces: wsList });
const nextWorkspace =
(preferredWorkspaceId
? wsList.find((item) => item.id === preferredWorkspaceId)
@@ -64,29 +72,80 @@ export function createWorkspaceStore(api: ApiClient, options?: WorkspaceStoreOpt
set({ workspace: nextWorkspace });
logger.debug("hydrate workspace", nextWorkspace.name, nextWorkspace.id);
// Members, agents, skills, issues, inbox are all managed by TanStack Query.
// They auto-fetch when components mount with the workspace ID in their query key.
return nextWorkspace;
},
switchWorkspace: (ws) => {
logger.info("switching to", ws.id);
switchWorkspace: (workspaceId) => {
logger.info("switching to", workspaceId);
const { workspaces, hydrateWorkspace } = get();
const ws = workspaces.find((item) => item.id === workspaceId);
if (!ws) return;
api.setWorkspaceId(ws.id);
setCurrentWorkspaceId(ws.id);
rehydrateAllWorkspaceStores();
storage?.setItem("multica_workspace_id", ws.id);
// All data caches (issues, inbox, members, agents, skills, runtimes)
// are managed by TanStack Query, keyed by wsId — auto-refetch on switch.
set({ workspace: ws });
hydrateWorkspace(workspaces, ws.id);
},
refreshWorkspaces: async () => {
const { workspace, hydrateWorkspace } = get();
const storedWorkspaceId = storage?.getItem("multica_workspace_id") ?? null;
try {
const wsList = await api.listWorkspaces();
hydrateWorkspace(wsList, workspace?.id ?? storedWorkspaceId);
return wsList;
} catch (e) {
logger.error("failed to refresh workspaces", e);
onError?.("Failed to refresh workspaces");
return get().workspaces;
}
},
updateWorkspace: (ws) => {
set((state) => ({
workspace: state.workspace?.id === ws.id ? ws : state.workspace,
workspaces: state.workspaces.map((item) =>
item.id === ws.id ? ws : item,
),
}));
},
createWorkspace: async (data) => {
const ws = await api.createWorkspace(data);
set((state) => ({ workspaces: [...state.workspaces, ws] }));
return ws;
},
leaveWorkspace: async (workspaceId) => {
await api.leaveWorkspace(workspaceId);
const { workspace, hydrateWorkspace } = get();
const wsList = await api.listWorkspaces();
const preferredWorkspaceId =
workspace?.id === workspaceId ? null : (workspace?.id ?? null);
hydrateWorkspace(wsList, preferredWorkspaceId);
},
deleteWorkspace: async (workspaceId) => {
await api.deleteWorkspace(workspaceId);
const { workspace, hydrateWorkspace } = get();
const wsList = await api.listWorkspaces();
const preferredWorkspaceId =
workspace?.id === workspaceId ? null : (workspace?.id ?? null);
hydrateWorkspace(wsList, preferredWorkspaceId);
},
clearWorkspace: () => {
api.setWorkspaceId(null);
setCurrentWorkspaceId(null);
rehydrateAllWorkspaceStores();
set({ workspace: null });
set({ workspace: null, workspaces: [] });
},
}));
}

View File

@@ -1,34 +0,0 @@
"use client";
import { ArrowUp, Loader2, Square } from "lucide-react";
import { Button } from "@multica/ui/components/ui/button";
interface SubmitButtonProps {
onClick: () => void;
disabled?: boolean;
loading?: boolean;
running?: boolean;
onStop?: () => void;
}
function SubmitButton({ onClick, disabled, loading, running, onStop }: SubmitButtonProps) {
if (running) {
return (
<Button size="icon-sm" onClick={onStop}>
<Square className="fill-current" />
</Button>
);
}
return (
<Button size="icon-sm" disabled={disabled || loading} onClick={onClick}>
{loading ? (
<Loader2 className="animate-spin" />
) : (
<ArrowUp />
)}
</Button>
);
}
export { SubmitButton, type SubmitButtonProps };

View File

@@ -11,7 +11,6 @@ import {
AlertCircle,
MoreHorizontal,
Settings,
KeyRound,
} from "lucide-react";
import type { Agent, RuntimeDevice } from "@multica/core/types";
import {
@@ -35,19 +34,17 @@ import { InstructionsTab } from "./tabs/instructions-tab";
import { SkillsTab } from "./tabs/skills-tab";
import { TasksTab } from "./tabs/tasks-tab";
import { SettingsTab } from "./tabs/settings-tab";
import { EnvTab } from "./tabs/env-tab";
function getRuntimeDevice(agent: Agent, runtimes: RuntimeDevice[]): RuntimeDevice | undefined {
return runtimes.find((runtime) => runtime.id === agent.runtime_id);
}
type DetailTab = "instructions" | "skills" | "tasks" | "env" | "settings";
type DetailTab = "instructions" | "skills" | "tasks" | "settings";
const detailTabs: { id: DetailTab; label: string; icon: typeof FileText }[] = [
{ id: "instructions", label: "Instructions", icon: FileText },
{ id: "skills", label: "Skills", icon: BookOpenText },
{ id: "tasks", label: "Tasks", icon: ListTodo },
{ id: "env", label: "Environment", icon: KeyRound },
{ id: "settings", label: "Settings", icon: Settings },
];
@@ -161,12 +158,6 @@ export function AgentDetail({
<SkillsTab agent={agent} />
)}
{activeTab === "tasks" && <TasksTab agent={agent} />}
{activeTab === "env" && (
<EnvTab
agent={agent}
onSave={(updates) => onUpdate(agent.id, updates)}
/>
)}
{activeTab === "settings" && (
<SettingsTab
agent={agent}

View File

@@ -1,191 +0,0 @@
"use client";
import { useState } from "react";
import {
Loader2,
Save,
Plus,
Trash2,
Eye,
EyeOff,
} from "lucide-react";
import type { Agent } from "@multica/core/types";
import { Button } from "@multica/ui/components/ui/button";
import { Input } from "@multica/ui/components/ui/input";
import { Label } from "@multica/ui/components/ui/label";
import { toast } from "sonner";
let nextEnvId = 0;
interface EnvEntry {
id: number;
key: string;
value: string;
visible: boolean;
}
function envMapToEntries(env: Record<string, string>): EnvEntry[] {
return Object.entries(env).map(([key, value]) => ({
id: nextEnvId++,
key,
value,
visible: false,
}));
}
function entriesToEnvMap(entries: EnvEntry[]): Record<string, string> {
const map: Record<string, string> = {};
for (const entry of entries) {
const key = entry.key.trim();
if (key) {
map[key] = entry.value;
}
}
return map;
}
export function EnvTab({
agent,
onSave,
}: {
agent: Agent;
onSave: (updates: Partial<Agent>) => Promise<void>;
}) {
const [envEntries, setEnvEntries] = useState<EnvEntry[]>(
envMapToEntries(agent.custom_env ?? {}),
);
const [saving, setSaving] = useState(false);
const currentEnvMap = entriesToEnvMap(envEntries);
const originalEnvMap = agent.custom_env ?? {};
const dirty =
JSON.stringify(currentEnvMap) !== JSON.stringify(originalEnvMap);
const addEnvEntry = () => {
setEnvEntries([
...envEntries,
{ id: nextEnvId++, key: "", value: "", visible: true },
]);
};
const removeEnvEntry = (index: number) => {
setEnvEntries(envEntries.filter((_, i) => i !== index));
};
const updateEnvEntry = (
index: number,
field: "key" | "value",
val: string,
) => {
setEnvEntries(
envEntries.map((entry, i) =>
i === index ? { ...entry, [field]: val } : entry,
),
);
};
const toggleEnvVisibility = (index: number) => {
setEnvEntries(
envEntries.map((entry, i) =>
i === index ? { ...entry, visible: !entry.visible } : entry,
),
);
};
const handleSave = async () => {
const keys = envEntries.filter((e) => e.key.trim()).map((e) => e.key.trim());
const uniqueKeys = new Set(keys);
if (uniqueKeys.size < keys.length) {
toast.error("Duplicate environment variable keys");
return;
}
setSaving(true);
try {
await onSave({ custom_env: currentEnvMap });
toast.success("Environment variables saved");
} catch {
toast.error("Failed to save environment variables");
} finally {
setSaving(false);
}
};
return (
<div className="max-w-lg space-y-4">
<div className="flex items-center justify-between">
<div>
<Label className="text-xs text-muted-foreground">
Environment Variables
</Label>
<p className="text-xs text-muted-foreground mt-0.5">
Injected into the agent process at launch (e.g. ANTHROPIC_API_KEY,
ANTHROPIC_BASE_URL)
</p>
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={addEnvEntry}
className="h-7 gap-1 text-xs"
>
<Plus className="h-3 w-3" />
Add
</Button>
</div>
{envEntries.length > 0 && (
<div className="space-y-2">
{envEntries.map((entry, index) => (
<div key={entry.id} className="flex items-center gap-2">
<Input
value={entry.key}
onChange={(e) => updateEnvEntry(index, "key", e.target.value)}
placeholder="KEY"
className="w-[40%] font-mono text-xs"
/>
<div className="relative flex-1">
<Input
type={entry.visible ? "text" : "password"}
value={entry.value}
onChange={(e) =>
updateEnvEntry(index, "value", e.target.value)
}
placeholder="value"
className="pr-8 font-mono text-xs"
/>
<button
type="button"
onClick={() => toggleEnvVisibility(index)}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
{entry.visible ? (
<EyeOff className="h-3.5 w-3.5" />
) : (
<Eye className="h-3.5 w-3.5" />
)}
</button>
</div>
<button
type="button"
onClick={() => removeEnvEntry(index)}
className="shrink-0 text-muted-foreground hover:text-destructive"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
))}
</div>
)}
<Button onClick={handleSave} disabled={!dirty || saving} size="sm">
{saving ? (
<Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />
) : (
<Save className="h-3.5 w-3.5 mr-1.5" />
)}
Save
</Button>
</div>
);
}

View File

@@ -72,7 +72,6 @@ export function SettingsTab({
toast.error("Name is required");
return;
}
setSaving(true);
try {
await onSave({

View File

@@ -13,15 +13,6 @@ const mockApiListWorkspaces = vi.hoisted(() => vi.fn());
const mockApiVerifyCode = vi.hoisted(() => vi.fn());
const mockApiSetToken = vi.hoisted(() => vi.fn());
const mockApiGetMe = vi.hoisted(() => vi.fn());
const mockApiIssueCliToken = vi.hoisted(() => vi.fn());
const mockSetQueryData = vi.hoisted(() => vi.fn());
vi.mock("@tanstack/react-query", async () => {
const actual = await vi.importActual<typeof import("@tanstack/react-query")>(
"@tanstack/react-query",
);
return { ...actual, useQueryClient: () => ({ setQueryData: mockSetQueryData }) };
});
vi.mock("@multica/core/auth", () => ({
useAuthStore: Object.assign(
@@ -59,7 +50,6 @@ vi.mock("@multica/core/api", () => ({
verifyCode: mockApiVerifyCode,
setToken: mockApiSetToken,
getMe: mockApiGetMe,
issueCliToken: mockApiIssueCliToken,
},
}));
@@ -90,8 +80,7 @@ describe("LoginPage", () => {
beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true });
vi.clearAllMocks();
// Default: no existing session (getMe rejects when no auth)
mockApiGetMe.mockRejectedValue(new Error("unauthorized"));
// Default: no existing session
localStorage.clear();
// Reset window.location for tests that change it
Object.defineProperty(window, "location", {
@@ -304,7 +293,7 @@ describe("LoginPage", () => {
).toBeInTheDocument();
});
// After transitioning to code step, cooldown is 60s
// After transitioning to code step, cooldown is 10s
const resendBtn = screen.getByRole("button", { name: /resend in/i });
expect(resendBtn).toBeDisabled();
});
@@ -340,9 +329,9 @@ describe("LoginPage", () => {
// sendCode was called once for the initial send
expect(mockSendCode).toHaveBeenCalledTimes(1);
// Advance past the 60s cooldown one second at a time so React can
// Advance past the 10s cooldown one second at a time so React can
// process each setCooldown state update between ticks.
for (let i = 0; i < 61; i++) {
for (let i = 0; i < 11; i++) {
await act(async () => {
vi.advanceTimersByTime(1_000);
});
@@ -388,14 +377,11 @@ describe("LoginPage", () => {
it("shows cli_confirm step when existing session + cliCallback", async () => {
localStorage.setItem("multica_token", "existing-jwt");
// Cookie attempt fails first, then localStorage fallback succeeds
mockApiGetMe
.mockRejectedValueOnce(new Error("no cookie"))
.mockResolvedValueOnce({
id: "u-1",
email: "user@example.com",
name: "Test User",
});
mockApiGetMe.mockResolvedValueOnce({
id: "u-1",
email: "user@example.com",
name: "Test User",
});
render(
<LoginPage
@@ -420,14 +406,11 @@ describe("LoginPage", () => {
it("CLI authorize button redirects to callback URL", async () => {
localStorage.setItem("multica_token", "existing-jwt");
// Cookie attempt fails, localStorage fallback succeeds
mockApiGetMe
.mockRejectedValueOnce(new Error("no cookie"))
.mockResolvedValueOnce({
id: "u-1",
email: "user@example.com",
name: "Test User",
});
mockApiGetMe.mockResolvedValueOnce({
id: "u-1",
email: "user@example.com",
name: "Test User",
});
const onTokenObtained = vi.fn();
render(
@@ -455,14 +438,11 @@ describe("LoginPage", () => {
it("'Use a different account' returns to email step", async () => {
localStorage.setItem("multica_token", "existing-jwt");
// Cookie attempt fails, localStorage fallback succeeds
mockApiGetMe
.mockRejectedValueOnce(new Error("no cookie"))
.mockResolvedValueOnce({
id: "u-1",
email: "user@example.com",
name: "Test User",
});
mockApiGetMe.mockResolvedValueOnce({
id: "u-1",
email: "user@example.com",
name: "Test User",
});
render(
<LoginPage
@@ -487,65 +467,6 @@ describe("LoginPage", () => {
).toBeInTheDocument();
});
// -------------------------------------------------------------------------
// CLI callback — cookie-based session (no localStorage token)
// -------------------------------------------------------------------------
it("detects cookie-based session and shows cli_confirm when no localStorage token", async () => {
// No localStorage token — getMe succeeds via HttpOnly cookie
mockApiGetMe.mockResolvedValueOnce({
id: "u-1",
email: "cookie@example.com",
name: "Cookie User",
});
render(
<LoginPage
onSuccess={onSuccess}
cliCallback={{ url: "http://localhost:9876/callback", state: "abc" }}
/>,
);
await waitFor(() => {
expect(screen.getByText(/authorize cli/i)).toBeInTheDocument();
});
expect(screen.getByText(/cookie@example.com/)).toBeInTheDocument();
});
it("CLI authorize with cookie session calls issueCliToken and redirects", async () => {
// No localStorage token — getMe succeeds via cookie
mockApiGetMe.mockResolvedValueOnce({
id: "u-1",
email: "cookie@example.com",
name: "Cookie User",
});
mockApiIssueCliToken.mockResolvedValueOnce({ token: "fresh-jwt" });
const onTokenObtained = vi.fn();
render(
<LoginPage
onSuccess={onSuccess}
onTokenObtained={onTokenObtained}
cliCallback={{ url: "http://localhost:9876/callback", state: "abc" }}
/>,
);
await waitFor(() => {
expect(screen.getByText(/authorize cli/i)).toBeInTheDocument();
});
const user = userEvent.setup();
await user.click(screen.getByRole("button", { name: /^authorize$/i }));
await waitFor(() => {
expect(mockApiIssueCliToken).toHaveBeenCalled();
expect(onTokenObtained).toHaveBeenCalled();
expect(window.location.href).toContain(
"http://localhost:9876/callback?token=fresh-jwt&state=abc",
);
});
});
// -------------------------------------------------------------------------
// CLI callback — code verification redirects
// -------------------------------------------------------------------------
@@ -726,34 +647,12 @@ describe("validateCliCallback", () => {
expect(validateCliCallback("http://127.0.0.1:8080/cb")).toBe(true);
});
it("accepts 10.x.x.x private IPs", () => {
expect(validateCliCallback("http://10.0.0.5:9876/callback")).toBe(true);
expect(validateCliCallback("http://10.255.255.255:1234/cb")).toBe(true);
});
it("accepts 172.16-31.x.x private IPs", () => {
expect(validateCliCallback("http://172.16.0.1:9876/callback")).toBe(true);
expect(validateCliCallback("http://172.31.255.255:1234/cb")).toBe(true);
});
it("rejects 172.x outside 16-31 range", () => {
expect(validateCliCallback("http://172.15.0.1:9876/callback")).toBe(false);
expect(validateCliCallback("http://172.32.0.1:9876/callback")).toBe(false);
});
it("accepts 192.168.x.x private IPs", () => {
expect(validateCliCallback("http://192.168.1.131:41117/callback")).toBe(true);
expect(validateCliCallback("http://192.168.0.1:8080/cb")).toBe(true);
});
it("rejects https:// URLs", () => {
expect(validateCliCallback("https://localhost:9876/callback")).toBe(false);
});
it("rejects public IPs and domains", () => {
it("rejects non-localhost hosts", () => {
expect(validateCliCallback("http://evil.com:9876/callback")).toBe(false);
expect(validateCliCallback("http://8.8.8.8:9876/callback")).toBe(false);
expect(validateCliCallback("http://192.169.1.1:9876/callback")).toBe(false);
});
it("rejects invalid URLs", () => {

View File

@@ -1,7 +1,6 @@
"use client";
import { useState, useEffect, useCallback, useRef, type ReactNode } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { useState, useEffect, useCallback, type ReactNode } from "react";
import {
Card,
CardHeader,
@@ -20,7 +19,6 @@ import {
} from "@multica/ui/components/ui/input-otp";
import { useAuthStore } from "@multica/core/auth";
import { useWorkspaceStore } from "@multica/core/workspace";
import { workspaceKeys } from "@multica/core/workspace/queries";
import { api } from "@multica/core/api";
import type { User } from "@multica/core/types";
@@ -31,8 +29,6 @@ import type { User } from "@multica/core/types";
interface GoogleAuthConfig {
clientId: string;
redirectUri: string;
/** Opaque state passed through Google OAuth (e.g. "platform:desktop"). */
state?: string;
}
interface CliCallbackConfig {
@@ -55,8 +51,6 @@ interface LoginPageProps {
lastWorkspaceId?: string | null;
/** Called after a token is obtained (e.g. to set cookies). */
onTokenObtained?: () => void;
/** Override Google login handler (e.g. desktop opens browser externally). When provided, renders the Google button even if `google` config is omitted. */
onGoogleLogin?: () => void;
}
// ---------------------------------------------------------------------------
@@ -68,22 +62,14 @@ function redirectToCliCallback(url: string, token: string, state: string) {
window.location.href = `${url}${separator}token=${encodeURIComponent(token)}&state=${encodeURIComponent(state)}`;
}
/**
* Validate that a CLI callback URL points to a safe host over HTTP.
* Allows localhost and private/LAN IPs (RFC 1918) to support self-hosted setups
* on local VMs while blocking arbitrary public hosts.
*/
/** Validate that a CLI callback URL points to localhost over HTTP. */
export function validateCliCallback(cliCallback: string): boolean {
try {
const cbUrl = new URL(cliCallback);
if (cbUrl.protocol !== "http:") return false;
const h = cbUrl.hostname;
if (h === "localhost" || h === "127.0.0.1") return true;
// Allow RFC 1918 private IPs: 10.x.x.x, 172.16-31.x.x, 192.168.x.x
if (/^10\./.test(h)) return true;
if (/^172\.(1[6-9]|2\d|3[01])\./.test(h)) return true;
if (/^192\.168\./.test(h)) return true;
return false;
if (cbUrl.hostname !== "localhost" && cbUrl.hostname !== "127.0.0.1")
return false;
return true;
} catch {
return false;
}
@@ -100,9 +86,7 @@ export function LoginPage({
cliCallback,
lastWorkspaceId,
onTokenObtained,
onGoogleLogin,
}: LoginPageProps) {
const qc = useQueryClient();
const [step, setStep] = useState<"email" | "code" | "cli_confirm">("email");
const [email, setEmail] = useState("");
const [code, setCode] = useState("");
@@ -110,43 +94,23 @@ export function LoginPage({
const [loading, setLoading] = useState(false);
const [cooldown, setCooldown] = useState(0);
const [existingUser, setExistingUser] = useState<User | null>(null);
// Tracks how the existing session was detected so handleCliAuthorize
// uses the matching token source (cookie → issueCliToken, localStorage → direct).
const authSourceRef = useRef<"cookie" | "localStorage">("cookie");
// Check for existing session when CLI callback is present.
// Prioritises cookie auth (= current browser session) to avoid authorising
// the CLI with a stale or mismatched localStorage token.
// Check for existing session when CLI callback is present
useEffect(() => {
if (!cliCallback) return;
const token = localStorage.getItem("multica_token");
if (!token) return;
// Ensure no stale bearer token interferes — we want to test the cookie first.
api.setToken(null);
api.setToken(token);
api
.getMe()
.then((user) => {
authSourceRef.current = "cookie";
setExistingUser(user);
setStep("cli_confirm");
})
.catch(() => {
// Cookie auth failed — fall back to localStorage token
const token = localStorage.getItem("multica_token");
if (!token) return;
api.setToken(token);
api
.getMe()
.then((user) => {
authSourceRef.current = "localStorage";
setExistingUser(user);
setStep("cli_confirm");
})
.catch(() => {
api.setToken(null);
localStorage.removeItem("multica_token");
});
api.setToken(null);
localStorage.removeItem("multica_token");
});
}, [cliCallback]);
@@ -170,7 +134,7 @@ export function LoginPage({
await useAuthStore.getState().sendCode(email);
setStep("code");
setCode("");
setCooldown(60);
setCooldown(10);
} catch (err) {
setError(
err instanceof Error
@@ -203,7 +167,6 @@ export function LoginPage({
// Normal path
await useAuthStore.getState().verifyCode(email, value);
const wsList = await api.listWorkspaces();
qc.setQueryData(workspaceKeys.list(), wsList);
useWorkspaceStore.getState().hydrateWorkspace(wsList, lastWorkspaceId);
onTokenObtained?.();
onSuccess();
@@ -215,7 +178,7 @@ export function LoginPage({
setLoading(false);
}
},
[email, onSuccess, cliCallback, lastWorkspaceId, onTokenObtained, qc],
[email, onSuccess, cliCallback, lastWorkspaceId, onTokenObtained],
);
const handleResend = async () => {
@@ -223,7 +186,7 @@ export function LoginPage({
setError("");
try {
await useAuthStore.getState().sendCode(email);
setCooldown(60);
setCooldown(10);
} catch (err) {
setError(
err instanceof Error ? err.message : "Failed to resend code",
@@ -231,39 +194,16 @@ export function LoginPage({
}
};
const handleCliAuthorize = async () => {
const handleCliAuthorize = () => {
if (!cliCallback) return;
const token = localStorage.getItem("multica_token");
if (!token) return;
setLoading(true);
try {
let token: string;
if (authSourceRef.current === "localStorage") {
// Session was detected via localStorage — reuse that token directly.
const stored = localStorage.getItem("multica_token");
if (!stored) throw new Error("token missing");
token = stored;
} else {
// Session was detected via cookie — obtain a bearer token from the server.
const res = await api.issueCliToken();
token = res.token;
}
onTokenObtained?.();
redirectToCliCallback(cliCallback.url, token, cliCallback.state);
} catch {
setError("Failed to authorize CLI. Please log in again.");
setExistingUser(null);
setStep("email");
setLoading(false);
}
onTokenObtained?.();
redirectToCliCallback(cliCallback.url, token, cliCallback.state);
};
const handleGoogleLogin = () => {
if (onGoogleLogin) {
onGoogleLogin();
return;
}
if (!google) return;
const params = new URLSearchParams({
client_id: google.clientId,
@@ -273,7 +213,6 @@ export function LoginPage({
access_type: "offline",
prompt: "select_account",
});
if (google.state) params.set("state", google.state);
window.location.href = `https://accounts.google.com/o/oauth2/v2/auth?${params}`;
};
@@ -432,7 +371,7 @@ export function LoginPage({
>
{loading ? "Sending code..." : "Continue"}
</Button>
{(google || onGoogleLogin) && (
{google && (
<>
<div className="relative w-full">
<div className="absolute inset-0 flex items-center">

View File

@@ -1,12 +1,7 @@
"use client";
import { MessageCircle } from "lucide-react";
import { Send } from "lucide-react";
import { useChatStore } from "@multica/core/chat";
import {
Tooltip,
TooltipTrigger,
TooltipContent,
} from "@multica/ui/components/ui/tooltip";
export function ChatFab() {
const isOpen = useChatStore((s) => s.isOpen);
@@ -15,14 +10,12 @@ export function ChatFab() {
if (isOpen) return null;
return (
<Tooltip>
<TooltipTrigger
onClick={toggle}
className="absolute bottom-2 right-2 z-50 flex size-10 cursor-pointer items-center justify-center rounded-full ring-1 ring-foreground/10 bg-card text-muted-foreground shadow-sm transition-transform hover:scale-110 hover:text-accent-foreground active:scale-95"
>
<MessageCircle className="size-5" />
</TooltipTrigger>
<TooltipContent side="top" sideOffset={10}>Ask Multica</TooltipContent>
</Tooltip>
<button
onClick={toggle}
className="fixed bottom-4 right-4 z-50 flex items-center gap-2 rounded-full border bg-background px-4 py-2 text-sm font-medium text-muted-foreground shadow-sm transition-colors hover:bg-accent hover:text-accent-foreground"
>
<Send className="size-3.5" />
Ask Multica
</button>
);
}

View File

@@ -1,9 +1,7 @@
"use client";
import { useRef, useState } from "react";
import { ContentEditor, type ContentEditorRef } from "../../editor";
import { SubmitButton } from "@multica/ui/components/common/submit-button";
import { useChatStore } from "@multica/core/chat";
import { useState, useRef, useCallback } from "react";
import { ArrowUp, Square } from "lucide-react";
interface ChatInputProps {
onSend: (content: string) => void;
@@ -13,44 +11,70 @@ interface ChatInputProps {
}
export function ChatInput({ onSend, onStop, isRunning, disabled }: ChatInputProps) {
const editorRef = useRef<ContentEditorRef>(null);
const inputDraft = useChatStore((s) => s.inputDraft);
const setInputDraft = useChatStore((s) => s.setInputDraft);
const clearInputDraft = useChatStore((s) => s.clearInputDraft);
const [isEmpty, setIsEmpty] = useState(!inputDraft.trim());
const [value, setValue] = useState("");
const textareaRef = useRef<HTMLTextAreaElement>(null);
const handleSend = () => {
const content = editorRef.current?.getMarkdown()?.replace(/(\n\s*)+$/, "").trim();
if (!content || isRunning || disabled) return;
onSend(content);
editorRef.current?.clearContent();
clearInputDraft();
setIsEmpty(true);
};
const handleSend = useCallback(() => {
const trimmed = value.trim();
if (!trimmed || isRunning || disabled) return;
onSend(trimmed);
setValue("");
if (textareaRef.current) {
textareaRef.current.style.height = "auto";
}
textareaRef.current?.focus();
}, [value, isRunning, disabled, onSend]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSend();
}
},
[handleSend],
);
const handleInput = useCallback(() => {
const el = textareaRef.current;
if (!el) return;
el.style.height = "auto";
el.style.height = Math.min(el.scrollHeight, 120) + "px";
}, []);
return (
<div className="p-2 pt-0">
<div className="relative flex min-h-16 max-h-40 flex-col rounded-lg bg-card pb-8 border-1 border-border transition-colors focus-within:border-brand">
<div className="flex-1 min-h-0 overflow-y-auto px-3 py-2">
<ContentEditor
ref={editorRef}
defaultValue={inputDraft}
placeholder={disabled ? "This session is archived" : "Ask Multica..."}
onUpdate={(md) => {
setIsEmpty(!md.trim());
setInputDraft(md);
}}
onSubmit={handleSend}
debounceMs={100}
/>
</div>
<div className="absolute bottom-1 right-1.5 flex items-center gap-1">
<SubmitButton
onClick={handleSend}
disabled={isEmpty || !!disabled}
running={isRunning}
onStop={onStop}
/>
<div className="border-t bg-muted/30 p-3">
<div className="rounded-lg border bg-background">
<textarea
ref={textareaRef}
value={value}
onChange={(e) => {
setValue(e.target.value);
handleInput();
}}
onKeyDown={handleKeyDown}
placeholder={disabled ? "This session is archived" : "Ask Multica..."}
disabled={isRunning || disabled}
className="block w-full resize-none bg-transparent px-3 pt-3 pb-2 text-sm placeholder:text-muted-foreground focus:outline-none disabled:opacity-50"
rows={1}
/>
<div className="flex items-center justify-end px-2 pb-2">
{isRunning ? (
<button
onClick={onStop}
className="flex size-7 items-center justify-center rounded-full bg-foreground text-background transition-opacity hover:opacity-80"
>
<Square className="size-3 fill-current" />
</button>
) : (
<button
onClick={handleSend}
disabled={!value.trim() || disabled}
className="flex size-7 items-center justify-center rounded-full bg-foreground text-background transition-opacity hover:opacity-80 disabled:opacity-30"
>
<ArrowUp className="size-4" />
</button>
)}
</div>
</div>
</div>

View File

@@ -1,82 +1,98 @@
"use client";
import { useState, useRef } from "react";
import { useState, useEffect, useRef } from "react";
import { useQuery } from "@tanstack/react-query";
import { cn } from "@multica/ui/lib/utils";
import { Avatar, AvatarFallback, AvatarImage } from "@multica/ui/components/ui/avatar";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@multica/ui/components/ui/collapsible";
import { Loader2, ChevronRight, ChevronDown, Brain, AlertCircle } from "lucide-react";
import { useScrollFade } from "@multica/ui/hooks/use-scroll-fade";
import { useAutoScroll } from "@multica/ui/hooks/use-auto-scroll";
import { Bot, Loader2, ChevronRight, ChevronDown, Brain, AlertCircle } from "lucide-react";
import { api } from "@multica/core/api";
import { Markdown } from "@multica/views/common/markdown";
import type { ChatMessage, TaskMessagePayload } from "@multica/core/types";
import type { ChatMessage, Agent, TaskMessagePayload } from "@multica/core/types";
import type { ChatTimelineItem } from "@multica/core/chat";
// ─── Public component ────────────────────────────────────────────────────
interface ChatMessageListProps {
messages: ChatMessage[];
agent: Agent | null;
timelineItems: ChatTimelineItem[];
isWaiting: boolean;
}
export function ChatMessageList({
messages,
agent,
timelineItems,
isWaiting,
}: ChatMessageListProps) {
const scrollRef = useRef<HTMLDivElement>(null);
const fadeStyle = useScrollFade(scrollRef);
useAutoScroll(scrollRef);
const bottomRef = useRef<HTMLDivElement>(null);
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages, timelineItems]);
const hasTimeline = timelineItems.length > 0;
return (
<div
ref={scrollRef}
style={fadeStyle}
className="flex-1 overflow-y-auto px-4 py-3 space-y-4"
>
<div className="flex-1 overflow-y-auto px-4 py-3 space-y-4">
{messages.map((msg) => (
<MessageBubble key={msg.id} message={msg} />
<MessageBubble key={msg.id} message={msg} agent={agent} />
))}
{/* Live streaming timeline */}
{hasTimeline && (
<div className="w-full space-y-1.5">
<TimelineView items={timelineItems} />
<div className="flex items-start gap-3">
<AgentAvatar agent={agent} />
<div className="min-w-0 flex-1 space-y-1.5">
<TimelineView items={timelineItems} />
</div>
</div>
)}
{isWaiting && !hasTimeline && (
<Loader2 className="size-4 animate-spin text-muted-foreground" />
<div className="flex items-start gap-3">
<AgentAvatar agent={agent} />
<div className="flex items-center pt-1">
<Loader2 className="size-4 animate-spin text-muted-foreground" />
</div>
</div>
)}
<div ref={bottomRef} />
</div>
);
}
// ─── Message bubbles ─────────────────────────────────────────────────────
function MessageBubble({ message }: { message: ChatMessage }) {
function MessageBubble({
message,
agent,
}: {
message: ChatMessage;
agent: Agent | null;
}) {
if (message.role === "user") {
return (
<div className="flex justify-end">
<div className="rounded-2xl bg-muted px-3.5 py-2 text-sm max-w-[80%] whitespace-pre-wrap break-words">
<div className="rounded-2xl bg-primary px-3.5 py-2 text-sm text-primary-foreground max-w-[85%] whitespace-pre-wrap break-words">
{message.content}
</div>
</div>
);
}
return <AssistantMessage message={message} />;
return <AssistantMessage message={message} agent={agent} />;
}
function AssistantMessage({
message,
agent,
}: {
message: ChatMessage;
agent: Agent | null;
}) {
const taskId = message.task_id;
@@ -100,14 +116,17 @@ function AssistantMessage({
);
return (
<div className="w-full space-y-1.5">
{timeline.length > 0 ? (
<TimelineView items={timeline} />
) : (
<div className="text-sm leading-relaxed prose prose-sm dark:prose-invert max-w-none">
<Markdown>{message.content}</Markdown>
</div>
)}
<div className="flex items-start gap-3">
<AgentAvatar agent={agent} />
<div className="min-w-0 flex-1 space-y-1.5">
{timeline.length > 0 ? (
<TimelineView items={timeline} />
) : (
<div className="text-sm leading-relaxed prose prose-sm dark:prose-invert max-w-none">
<Markdown>{message.content}</Markdown>
</div>
)}
</div>
</div>
);
}
@@ -337,3 +356,13 @@ function ErrorRow({ item }: { item: ChatTimelineItem }) {
// ─── Shared ──────────────────────────────────────────────────────────────
function AgentAvatar({ agent }: { agent: Agent | null }) {
return (
<Avatar className="size-6 shrink-0 mt-0.5">
{agent?.avatar_url && <AvatarImage src={agent.avatar_url} />}
<AvatarFallback className="bg-purple-100 text-purple-700">
<Bot className="size-3" />
</AvatarFallback>
</Avatar>
);
}

View File

@@ -1,34 +0,0 @@
"use client";
import React from "react";
type DragDir = "left" | "top" | "corner";
interface ChatResizeHandlesProps {
onDragStart: (e: React.PointerEvent, dir: DragDir) => void;
}
export function ChatResizeHandles({ onDragStart }: ChatResizeHandlesProps) {
return (
<>
{/* Left edge — expands width when dragged left */}
<div
aria-hidden
onPointerDown={(e) => onDragStart(e, "left")}
className="absolute left-0 top-4 bottom-0 w-1 z-10 cursor-col-resize"
/>
{/* Top edge — expands height when dragged up */}
<div
aria-hidden
onPointerDown={(e) => onDragStart(e, "top")}
className="absolute top-0 left-4 right-0 h-1 z-10 cursor-row-resize"
/>
{/* Top-left corner — expands both width and height */}
<div
aria-hidden
onPointerDown={(e) => onDragStart(e, "corner")}
className="absolute top-0 left-0 size-4 z-20 cursor-nw-resize"
/>
</>
);
}

View File

@@ -1,14 +1,13 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { ArrowLeft, MessageSquare, Bot } from "lucide-react";
import { cn } from "@multica/ui/lib/utils";
import { Button } from "@multica/ui/components/ui/button";
import { Tooltip, TooltipTrigger, TooltipContent } from "@multica/ui/components/ui/tooltip";
import { ArrowLeft, MessageSquare, Archive, Trash2 } from "lucide-react";
import { Avatar, AvatarFallback, AvatarImage } from "@multica/ui/components/ui/avatar";
import { Bot } from "lucide-react";
import { useWorkspaceId } from "@multica/core/hooks";
import { agentListOptions } from "@multica/core/workspace/queries";
import { allChatSessionsOptions } from "@multica/core/chat/queries";
import { useArchiveChatSession } from "@multica/core/chat/mutations";
import { useChatStore } from "@multica/core/chat";
import type { ChatSession, Agent } from "@multica/core/types";
@@ -22,6 +21,7 @@ export function ChatSessionHistory() {
const { data: sessions = [] } = useQuery(allChatSessionsOptions(wsId));
const { data: agents = [] } = useQuery(agentListOptions(wsId));
const archiveSession = useArchiveChatSession();
const agentMap = new Map(agents.map((a) => [a.id, a]));
@@ -32,25 +32,27 @@ export function ChatSessionHistory() {
setShowHistory(false);
};
const handleArchive = (e: React.MouseEvent, sessionId: string) => {
e.stopPropagation();
archiveSession.mutate(sessionId);
if (activeSessionId === sessionId) {
setActiveSession(null);
}
};
const activeSessions = sessions.filter((s) => s.status === "active");
const archivedSessions = sessions.filter((s) => s.status === "archived");
return (
<div className="flex flex-1 flex-col overflow-hidden">
{/* Header */}
<div className="flex items-center gap-2 border-b px-4 py-2.5">
<Tooltip>
<TooltipTrigger
render={
<Button
variant="ghost"
size="icon-sm"
className="text-muted-foreground"
onClick={() => setShowHistory(false)}
/>
}
>
<ArrowLeft />
</TooltipTrigger>
<TooltipContent side="bottom">Back</TooltipContent>
</Tooltip>
<button
onClick={() => setShowHistory(false)}
className="flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
>
<ArrowLeft className="size-3.5" />
</button>
<span className="text-sm font-medium">Chat History</span>
</div>
@@ -62,47 +64,94 @@ export function ChatSessionHistory() {
<span className="text-sm">No chat sessions yet</span>
</div>
) : (
<div>
{sessions.map((session) => (
<SessionItem
key={session.id}
session={session}
agent={agentMap.get(session.agent_id) ?? null}
isActive={session.id === activeSessionId}
onSelect={() => handleSelectSession(session)}
<>
{activeSessions.length > 0 && (
<SessionGroup
label="Active"
sessions={activeSessions}
agentMap={agentMap}
activeSessionId={activeSessionId}
onSelect={handleSelectSession}
onArchive={handleArchive}
/>
))}
</div>
)}
{archivedSessions.length > 0 && (
<SessionGroup
label="Archived"
sessions={archivedSessions}
agentMap={agentMap}
activeSessionId={activeSessionId}
onSelect={handleSelectSession}
/>
)}
</>
)}
</div>
</div>
);
}
function SessionGroup({
label,
sessions,
agentMap,
activeSessionId,
onSelect,
onArchive,
}: {
label: string;
sessions: ChatSession[];
agentMap: Map<string, Agent>;
activeSessionId: string | null;
onSelect: (session: ChatSession) => void;
onArchive?: (e: React.MouseEvent, sessionId: string) => void;
}) {
return (
<div>
<div className="px-4 pt-3 pb-1">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
{label}
</span>
</div>
{sessions.map((session) => (
<SessionItem
key={session.id}
session={session}
agent={agentMap.get(session.agent_id) ?? null}
isActive={session.id === activeSessionId}
onSelect={() => onSelect(session)}
onArchive={onArchive ? (e) => onArchive(e, session.id) : undefined}
/>
))}
</div>
);
}
function SessionItem({
session,
agent,
isActive,
onSelect,
onArchive,
}: {
session: ChatSession;
agent: Agent | null;
isActive: boolean;
onSelect: () => void;
onArchive?: (e: React.MouseEvent) => void;
}) {
const timeAgo = formatTimeAgo(session.updated_at);
return (
<button
onClick={onSelect}
className={cn(
"flex w-full items-start gap-3 px-4 py-2.5 text-left transition-colors hover:bg-accent/50",
isActive && "bg-accent/30",
)}
className={`group flex w-full items-start gap-3 px-4 py-2.5 text-left transition-colors hover:bg-accent/50 ${
isActive ? "bg-accent/30" : ""
}`}
>
<Avatar className="size-6 shrink-0 mt-0.5">
{agent?.avatar_url && <AvatarImage src={agent.avatar_url} />}
<AvatarFallback className="bg-purple-100 text-purple-700">
<AvatarFallback className="bg-purple-100 text-purple-700 text-[10px]">
<Bot className="size-3" />
</AvatarFallback>
</Avatar>
@@ -111,6 +160,9 @@ function SessionItem({
<span className="truncate text-sm font-medium">
{session.title || "Untitled"}
</span>
{session.status === "archived" && (
<Archive className="size-3 shrink-0 text-muted-foreground" />
)}
</div>
<div className="flex items-center gap-1.5 mt-0.5">
{agent && (
@@ -121,6 +173,15 @@ function SessionItem({
<span className="text-xs text-muted-foreground/60">{timeAgo}</span>
</div>
</div>
{onArchive && (
<button
onClick={onArchive}
title="Archive"
className="invisible group-hover:visible flex size-6 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-destructive shrink-0 mt-0.5"
>
<Trash2 className="size-3" />
</button>
)}
</button>
);
}

View File

@@ -1,18 +1,13 @@
"use client";
import React, { useCallback, useEffect, useRef } from "react";
import { useCallback, useEffect, useRef } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Minus, Maximize2, Minimize2, Send, ChevronDown, Bot, Plus, History } from "lucide-react";
import { Avatar, AvatarFallback, AvatarImage } from "@multica/ui/components/ui/avatar";
import { Button } from "@multica/ui/components/ui/button";
import { Tooltip, TooltipTrigger, TooltipContent } from "@multica/ui/components/ui/tooltip";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@multica/ui/components/ui/dropdown-menu";
import { useWorkspaceId } from "@multica/core/hooks";
@@ -31,19 +26,19 @@ import { useChatStore } from "@multica/core/chat";
import { ChatMessageList } from "./chat-message-list";
import { ChatInput } from "./chat-input";
import { ChatSessionHistory } from "./chat-session-history";
import { ChatResizeHandles } from "./chat-resize-handles";
import { useChatResize } from "./use-chat-resize";
import { useWS } from "@multica/core/realtime";
import type { TaskMessagePayload, ChatDonePayload, Agent, ChatMessage } from "@multica/core/types";
export function ChatWindow() {
const wsId = useWorkspaceId();
const isOpen = useChatStore((s) => s.isOpen);
const isFullscreen = useChatStore((s) => s.isFullscreen);
const activeSessionId = useChatStore((s) => s.activeSessionId);
const pendingTaskId = useChatStore((s) => s.pendingTaskId);
const timelineItems = useChatStore((s) => s.timelineItems);
const selectedAgentId = useChatStore((s) => s.selectedAgentId);
const setOpen = useChatStore((s) => s.setOpen);
const toggleFullscreen = useChatStore((s) => s.toggleFullscreen);
const showHistory = useChatStore((s) => s.showHistory);
const setActiveSession = useChatStore((s) => s.setActiveSession);
const setPendingTask = useChatStore((s) => s.setPendingTask);
@@ -51,6 +46,7 @@ export function ChatWindow() {
const clearTimeline = useChatStore((s) => s.clearTimeline);
const setSelectedAgentId = useChatStore((s) => s.setSelectedAgentId);
const setShowHistory = useChatStore((s) => s.setShowHistory);
const user = useAuthStore((s) => s.user);
const { data: agents = [] } = useQuery(agentListOptions(wsId));
const { data: members = [] } = useQuery(memberListOptions(wsId));
@@ -225,105 +221,57 @@ export function ChatWindow() {
[setSelectedAgentId, setActiveSession],
);
const windowRef = useRef<HTMLDivElement>(null);
const { renderWidth, renderHeight, isAtMax, boundsReady, isDragging, toggleExpand, startDrag } = useChatResize(windowRef);
if (!isOpen) return null;
const hasMessages = messages.length > 0 || timelineItems.length > 0;
const isVisible = isOpen && boundsReady;
const containerClass = "absolute bottom-2 right-2 z-50 flex flex-col rounded-xl ring-1 ring-foreground/10 bg-sidebar shadow-2xl overflow-hidden";
const containerStyle: React.CSSProperties = {
width: `${renderWidth}px`,
height: `${renderHeight}px`,
opacity: isVisible ? 1 : 0,
transform: isVisible ? "scale(1)" : "scale(0.95)",
transformOrigin: "bottom right",
pointerEvents: isOpen ? "auto" : "none",
transition: isDragging
? "none"
: "width 200ms ease-out, height 200ms ease-out, opacity 150ms ease-out, transform 150ms ease-out",
};
const containerClass = isFullscreen
? "fixed inset-y-0 right-0 z-50 flex flex-col w-[50%] border-l bg-background shadow-2xl"
: "fixed bottom-4 right-4 z-50 flex flex-col w-[420px] h-[600px] rounded-xl border bg-background shadow-2xl overflow-hidden";
return (
<div ref={windowRef} className={containerClass} style={containerStyle}>
<ChatResizeHandles onDragStart={startDrag} />
<div className={containerClass}>
{/* Header */}
{!showHistory && (
<div className="flex items-center justify-between border-b px-4 py-2.5">
<AgentSelector
agents={availableAgents}
activeAgent={activeAgent}
userId={user?.id}
onSelect={handleSelectAgent}
/>
<div className="flex items-center gap-0.5">
<Tooltip>
<TooltipTrigger
render={
<Button
variant="ghost"
size="icon-sm"
className="text-muted-foreground"
onClick={() => setShowHistory(true)}
/>
}
>
<History />
</TooltipTrigger>
<TooltipContent side="bottom">Chat history</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger
render={
<Button
variant="ghost"
size="icon-sm"
className="text-muted-foreground"
onClick={() => {
setActiveSession(null);
clearTimeline();
setPendingTask(null);
}}
/>
}
>
<Plus />
</TooltipTrigger>
<TooltipContent side="bottom">New chat</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger
render={
<Button
variant="ghost"
size="icon-sm"
className="text-muted-foreground"
onClick={toggleExpand}
/>
}
>
{isAtMax ? <Minimize2 /> : <Maximize2 />}
</TooltipTrigger>
<TooltipContent side="bottom">
{isAtMax ? "Restore" : "Expand"}
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger
render={
<Button
variant="ghost"
size="icon-sm"
className="text-muted-foreground"
onClick={() => setOpen(false)}
/>
}
>
<Minus />
</TooltipTrigger>
<TooltipContent side="bottom">Minimize</TooltipContent>
</Tooltip>
<button
onClick={() => setShowHistory(true)}
title="Chat history"
className="flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
>
<History className="size-3.5" />
</button>
<button
onClick={() => {
setActiveSession(null);
clearTimeline();
setPendingTask(null);
}}
title="New chat"
className="flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
>
<Plus className="size-3.5" />
</button>
<button
onClick={toggleFullscreen}
title={isFullscreen ? "Exit fullscreen" : "Fullscreen"}
className="flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
>
{isFullscreen ? <Minimize2 className="size-3.5" /> : <Maximize2 className="size-3.5" />}
</button>
<button
onClick={() => setOpen(false)}
title="Minimize"
className="flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
>
<Minus className="size-3.5" />
</button>
</div>
</div>
)}
@@ -336,6 +284,7 @@ export function ChatWindow() {
{hasMessages ? (
<ChatMessageList
messages={messages}
agent={activeAgent}
timelineItems={timelineItems}
isWaiting={!!pendingTaskId}
/>
@@ -359,12 +308,10 @@ export function ChatWindow() {
function AgentSelector({
agents,
activeAgent,
userId,
onSelect,
}: {
agents: Agent[];
activeAgent: Agent | null;
userId: string | undefined;
onSelect: (agent: Agent) => void;
}) {
if (!activeAgent) {
@@ -380,48 +327,24 @@ function AgentSelector({
);
}
const myAgents = agents.filter((a) => a.owner_id === userId);
const othersAgents = agents.filter((a) => a.owner_id !== userId);
return (
<DropdownMenu>
<DropdownMenuTrigger className="flex items-center gap-2 rounded-md px-1.5 py-1 -ml-1.5 transition-colors hover:bg-accent aria-expanded:bg-accent">
<DropdownMenuTrigger className="flex items-center gap-2 rounded-md px-1.5 py-1 -ml-1.5 transition-colors hover:bg-accent">
<AgentAvatarSmall agent={activeAgent} />
<span className="text-sm font-medium">{activeAgent.name}</span>
<ChevronDown className="size-3 text-muted-foreground" />
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="max-h-60 w-auto max-w-56">
{myAgents.length > 0 && (
<DropdownMenuGroup>
<DropdownMenuLabel>My Agents</DropdownMenuLabel>
{myAgents.map((agent) => (
<DropdownMenuItem
key={agent.id}
onClick={() => onSelect(agent)}
className="flex min-w-0 items-center gap-2"
>
<AgentAvatarSmall agent={agent} />
<span className="truncate">{agent.name}</span>
</DropdownMenuItem>
))}
</DropdownMenuGroup>
)}
{myAgents.length > 0 && othersAgents.length > 0 && <DropdownMenuSeparator />}
{othersAgents.length > 0 && (
<DropdownMenuGroup>
<DropdownMenuLabel>Others</DropdownMenuLabel>
{othersAgents.map((agent) => (
<DropdownMenuItem
key={agent.id}
onClick={() => onSelect(agent)}
className="flex min-w-0 items-center gap-2"
>
<AgentAvatarSmall agent={agent} />
<span className="truncate">{agent.name}</span>
</DropdownMenuItem>
))}
</DropdownMenuGroup>
)}
<DropdownMenuContent align="start">
{agents.map((agent) => (
<DropdownMenuItem
key={agent.id}
onClick={() => onSelect(agent)}
className="flex items-center gap-2"
>
<AgentAvatarSmall agent={agent} />
<span>{agent.name}</span>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
@@ -431,7 +354,7 @@ function AgentAvatarSmall({ agent }: { agent: Agent }) {
return (
<Avatar className="size-5">
{agent.avatar_url && <AvatarImage src={agent.avatar_url} />}
<AvatarFallback className="bg-purple-100 text-purple-700">
<AvatarFallback className="bg-purple-100 text-purple-700 text-[10px]">
<Bot className="size-3" />
</AvatarFallback>
</Avatar>

View File

@@ -1,135 +0,0 @@
"use client";
import React, { useRef, useCallback, useState, useEffect } from "react";
import { CHAT_MIN_W, CHAT_MIN_H, useChatStore } from "@multica/core/chat";
type DragDir = "left" | "top" | "corner";
const MAX_RATIO = 0.9;
const FALLBACK_MAX_W = 800;
const FALLBACK_MAX_H = 700;
function clamp(v: number, min: number, max: number) {
return Math.max(min, Math.min(max, v));
}
export function useChatResize(
windowRef: React.RefObject<HTMLDivElement | null>,
) {
const chatWidth = useChatStore((s) => s.chatWidth);
const chatHeight = useChatStore((s) => s.chatHeight);
const isExpanded = useChatStore((s) => s.isExpanded);
const setChatSize = useChatStore((s) => s.setChatSize);
const setExpanded = useChatStore((s) => s.setExpanded);
// ── Container bounds via ResizeObserver ────────────────────────────────
const boundsRef = useRef({ maxW: FALLBACK_MAX_W, maxH: FALLBACK_MAX_H });
const [boundsReady, setBoundsReady] = useState(false);
const [isDragging, setIsDragging] = useState(false);
const [, setRevision] = useState(0);
useEffect(() => {
const el = windowRef.current;
const parent = el?.parentElement;
if (!parent) return;
const update = () => {
boundsRef.current = {
maxW: Math.floor(parent.clientWidth * MAX_RATIO),
maxH: Math.floor(parent.clientHeight * MAX_RATIO),
};
setBoundsReady(true);
setRevision((r) => r + 1);
};
// Measure immediately (parent is already in DOM at this point)
update();
const ro = new ResizeObserver(update);
ro.observe(parent);
return () => ro.disconnect();
}, [windowRef]);
// ── Derive rendered size ──────────────────────────────────────────────
const { maxW, maxH } = boundsRef.current;
const renderWidth = isExpanded ? maxW : clamp(chatWidth, CHAT_MIN_W, maxW);
const renderHeight = isExpanded ? maxH : clamp(chatHeight, CHAT_MIN_H, maxH);
// ── Expand / Restore ──────────────────────────────────────────────────
const isAtMax = renderWidth >= maxW && renderHeight >= maxH;
const toggleExpand = useCallback(() => {
if (isExpanded || isAtMax) {
setChatSize(CHAT_MIN_W, CHAT_MIN_H);
} else {
setExpanded(true);
}
}, [isExpanded, isAtMax, setChatSize, setExpanded]);
// ── Drag ──────────────────────────────────────────────────────────────
const dragRef = useRef<{
startX: number;
startY: number;
startW: number;
startH: number;
dir: DragDir;
} | null>(null);
const startDrag = useCallback(
(e: React.PointerEvent, dir: DragDir) => {
e.preventDefault();
(e.target as HTMLElement).setPointerCapture(e.pointerId);
dragRef.current = {
startX: e.clientX,
startY: e.clientY,
startW: renderWidth,
startH: renderHeight,
dir,
};
setIsDragging(true);
const onPointerMove = (ev: PointerEvent) => {
const d = dragRef.current;
if (!d) return;
const { maxW: mw, maxH: mh } = boundsRef.current;
const rawW =
dir === "left" || dir === "corner"
? d.startW - (ev.clientX - d.startX)
: d.startW;
const rawH =
dir === "top" || dir === "corner"
? d.startH - (ev.clientY - d.startY)
: d.startH;
setChatSize(clamp(rawW, CHAT_MIN_W, mw), clamp(rawH, CHAT_MIN_H, mh));
};
const onPointerUp = () => {
dragRef.current = null;
setIsDragging(false);
document.removeEventListener("pointermove", onPointerMove);
document.removeEventListener("pointerup", onPointerUp);
document.body.style.cursor = "";
document.body.style.userSelect = "";
};
document.addEventListener("pointermove", onPointerMove);
document.addEventListener("pointerup", onPointerUp);
const cursorMap: Record<DragDir, string> = {
left: "col-resize",
top: "row-resize",
corner: "nw-resize",
};
document.body.style.cursor = cursorMap[dir];
document.body.style.userSelect = "none";
},
[renderWidth, renderHeight, setChatSize],
);
return { renderWidth, renderHeight, isAtMax, boundsReady, isDragging, toggleExpand, startDrag };
}

View File

@@ -1,474 +0,0 @@
"use client";
/**
* EditorBubbleMenu — floating formatting toolbar for text selection.
*
* Uses Tiptap's native <BubbleMenu> component which has battle-tested
* focus management (preventHide flag, relatedTarget checks, mousedown
* capture). We only add scroll-container visibility detection on top,
* because the plugin's hide middleware can't detect nested scroll
* container clipping (virtual element has no contextElement).
*/
import { useState, useEffect, useCallback, useRef } from "react";
import { BubbleMenu } from "@tiptap/react/menus";
import type { Editor } from "@tiptap/core";
import { NodeSelection } from "@tiptap/pm/state";
import type { EditorState } from "@tiptap/pm/state";
import type { EditorView } from "@tiptap/pm/view";
import { Toggle } from "@multica/ui/components/ui/toggle";
import { Separator } from "@multica/ui/components/ui/separator";
import {
Tooltip,
TooltipTrigger,
TooltipContent,
TooltipProvider,
} from "@multica/ui/components/ui/tooltip";
import {
Popover,
PopoverTrigger,
PopoverContent,
} from "@multica/ui/components/ui/popover";
import { Input } from "@multica/ui/components/ui/input";
import { Button } from "@multica/ui/components/ui/button";
import {
Bold,
Italic,
Strikethrough,
Code,
Link2,
List,
ListOrdered,
Quote,
ChevronDown,
Check,
X,
Unlink,
Type,
Heading1,
Heading2,
Heading3,
} from "lucide-react";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function shouldShowBubbleMenu({
editor,
view,
state,
from,
to,
}: {
editor: Editor;
view: EditorView;
state: EditorState;
oldState?: EditorState;
from: number;
to: number;
}) {
if (!editor.isEditable) return false;
if (state.selection.empty) return false;
if (!state.doc.textBetween(from, to).trim().length) return false;
if (state.selection instanceof NodeSelection) return false;
if (!view.hasFocus()) return false;
const $from = state.doc.resolve(from);
if ($from.parent.type.name === "codeBlock") return false;
return true;
}
const isMac =
typeof navigator !== "undefined" && /Mac/.test(navigator.platform);
const mod = isMac ? "\u2318" : "Ctrl";
/** Walk up from `el` to find the nearest ancestor with overflow: auto/scroll. */
function getScrollParent(el: HTMLElement): HTMLElement | Window {
let parent = el.parentElement;
while (parent) {
const style = getComputedStyle(parent);
if (/(auto|scroll)/.test(style.overflow + style.overflowY)) return parent;
parent = parent.parentElement;
}
return window;
}
// ---------------------------------------------------------------------------
// Mark Toggle Button
// ---------------------------------------------------------------------------
type InlineMark = "bold" | "italic" | "strike" | "code";
const toggleMarkActions: Record<InlineMark, (editor: Editor) => void> = {
bold: (e) => e.chain().focus().toggleBold().run(),
italic: (e) => e.chain().focus().toggleItalic().run(),
strike: (e) => e.chain().focus().toggleStrike().run(),
code: (e) => e.chain().focus().toggleCode().run(),
};
function MarkButton({
editor,
mark,
icon: Icon,
label,
shortcut,
}: {
editor: Editor;
mark: InlineMark;
icon: React.ComponentType<{ className?: string }>;
label: string;
shortcut: string;
}) {
return (
<Tooltip>
<TooltipTrigger
render={
<Toggle
size="sm"
pressed={editor.isActive(mark)}
onPressedChange={() => toggleMarkActions[mark](editor)}
onMouseDown={(e) => e.preventDefault()}
/>
}
>
<Icon className="size-3.5" />
</TooltipTrigger>
<TooltipContent side="top" sideOffset={8}>
{label}
<span className="ml-1.5 text-muted-foreground">{shortcut}</span>
</TooltipContent>
</Tooltip>
);
}
// ---------------------------------------------------------------------------
// URL normalisation
// ---------------------------------------------------------------------------
/** Protocols that can execute code in the browser — the only ones we block. */
const DANGEROUS_PROTOCOL_RE = /^(javascript|data|vbscript):/i;
const HAS_PROTOCOL_RE = /^[a-z][a-z0-9+.-]*:\/?\/?/i;
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
/**
* Normalise a user-entered URL: add protocol, detect mailto, block XSS.
*
* Uses a blocklist (not allowlist) for protocols — only `javascript:`,
* `data:`, and `vbscript:` are blocked. All other protocols pass through
* because they can't execute code in the browser and are legitimate
* deep-link targets in a team tool (slack://, vscode://, figma://).
* Tiptap's `isAllowedUri` in the `setLink` command provides a second
* safety layer.
*/
function normalizeUrl(input: string): string {
const trimmed = input.trim();
if (!trimmed) return "";
if (trimmed.startsWith("/")) return trimmed;
if (DANGEROUS_PROTOCOL_RE.test(trimmed)) return "";
if (HAS_PROTOCOL_RE.test(trimmed)) return trimmed;
if (EMAIL_RE.test(trimmed)) return `mailto:${trimmed}`;
if (trimmed.startsWith("//")) return `https:${trimmed}`;
return `https://${trimmed}`;
}
// ---------------------------------------------------------------------------
// Link Edit Bar
// ---------------------------------------------------------------------------
function LinkEditBar({
editor,
onClose,
}: {
editor: Editor;
onClose: () => void;
}) {
const existingHref = editor.getAttributes("link").href as string | undefined;
const [url, setUrl] = useState(existingHref ?? "");
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
const t = setTimeout(() => inputRef.current?.focus(), 0);
return () => clearTimeout(t);
}, []);
const apply = useCallback(() => {
const href = normalizeUrl(url);
if (!href) {
editor.chain().focus().extendMarkRange("link").unsetLink().run();
} else {
editor.chain().focus().extendMarkRange("link").setLink({ href }).run();
}
onClose();
}, [editor, url, onClose]);
const remove = useCallback(() => {
editor.chain().focus().extendMarkRange("link").unsetLink().run();
onClose();
}, [editor, onClose]);
return (
<div className="bubble-menu-link-edit" onMouseDown={(e) => e.preventDefault()}>
<Input
ref={inputRef}
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="https://..."
aria-label="URL"
className="h-7 flex-1 text-xs"
onKeyDown={(e) => {
if (e.key === "Enter") { e.preventDefault(); apply(); }
if (e.key === "Escape") { e.preventDefault(); onClose(); editor.commands.focus(); }
}}
/>
<Button size="icon-xs" variant="ghost" onClick={apply} onMouseDown={(e) => e.preventDefault()}>
<Check className="size-3.5" />
</Button>
{existingHref && (
<Button size="icon-xs" variant="ghost" onClick={remove} onMouseDown={(e) => e.preventDefault()}>
<Unlink className="size-3.5" />
</Button>
)}
<Button size="icon-xs" variant="ghost" onClick={() => { onClose(); editor.commands.focus(); }} onMouseDown={(e) => e.preventDefault()}>
<X className="size-3.5" />
</Button>
</div>
);
}
// ---------------------------------------------------------------------------
// Heading Dropdown
// ---------------------------------------------------------------------------
function HeadingDropdown({ editor, onOpenChange }: { editor: Editor; onOpenChange: (open: boolean) => void }) {
const [open, setOpen] = useState(false);
const activeLevel = [1, 2, 3].find((l) => editor.isActive("heading", { level: l }));
const label = activeLevel ? `H${activeLevel}` : "Text";
const items = [
{ label: "Normal Text", icon: Type, active: !activeLevel, action: () => editor.chain().focus().setParagraph().run() },
{ label: "Heading 1", icon: Heading1, active: activeLevel === 1, action: () => editor.chain().focus().toggleHeading({ level: 1 }).run() },
{ label: "Heading 2", icon: Heading2, active: activeLevel === 2, action: () => editor.chain().focus().toggleHeading({ level: 2 }).run() },
{ label: "Heading 3", icon: Heading3, active: activeLevel === 3, action: () => editor.chain().focus().toggleHeading({ level: 3 }).run() },
];
const handleOpenChange = useCallback((next: boolean) => {
setOpen(next);
onOpenChange(next);
}, [onOpenChange]);
return (
<Popover modal={false} open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger
className="inline-flex h-7 items-center gap-0.5 rounded-md px-1.5 text-xs font-medium hover:bg-muted"
onMouseDown={(e) => e.preventDefault()}
>
{label}
<ChevronDown className="size-3" />
</PopoverTrigger>
<PopoverContent
side="bottom"
sideOffset={8}
align="start"
className="w-auto min-w-32 p-1"
initialFocus={false}
finalFocus={false}
>
{items.map((item) => (
<button
key={item.label}
className="flex w-full cursor-default items-center gap-2 rounded-md px-1.5 py-1 text-xs outline-hidden select-none hover:bg-accent hover:text-accent-foreground"
onMouseDown={(e) => {
e.preventDefault();
item.action();
handleOpenChange(false);
}}
>
<item.icon className="size-3.5" />
{item.label}
{item.active && <Check className="ml-auto size-3.5" />}
</button>
))}
</PopoverContent>
</Popover>
);
}
// ---------------------------------------------------------------------------
// List Dropdown
// ---------------------------------------------------------------------------
function ListDropdown({ editor, onOpenChange }: { editor: Editor; onOpenChange: (open: boolean) => void }) {
const [open, setOpen] = useState(false);
const isBullet = editor.isActive("bulletList");
const isOrdered = editor.isActive("orderedList");
const handleOpenChange = useCallback((next: boolean) => {
setOpen(next);
onOpenChange(next);
}, [onOpenChange]);
return (
<Popover modal={false} open={open} onOpenChange={handleOpenChange}>
<Tooltip>
<TooltipTrigger render={
<PopoverTrigger className="inline-flex h-7 items-center gap-0.5 rounded-md px-1.5 text-xs font-medium hover:bg-muted aria-pressed:bg-muted" aria-pressed={isBullet || isOrdered} onMouseDown={(e) => e.preventDefault()} />
}>
<List className="size-3.5" />
<ChevronDown className="size-3" />
</TooltipTrigger>
<TooltipContent side="top" sideOffset={8}>List</TooltipContent>
</Tooltip>
<PopoverContent
side="bottom"
sideOffset={8}
align="start"
className="w-auto min-w-32 p-1"
initialFocus={false}
finalFocus={false}
>
<button
className="flex w-full cursor-default items-center gap-2 rounded-md px-1.5 py-1 text-xs outline-hidden select-none hover:bg-accent hover:text-accent-foreground"
onMouseDown={(e) => {
e.preventDefault();
editor.chain().focus().toggleBulletList().run();
handleOpenChange(false);
}}
>
<List className="size-3.5" /> Bullet List
{isBullet && <Check className="ml-auto size-3.5" />}
</button>
<button
className="flex w-full cursor-default items-center gap-2 rounded-md px-1.5 py-1 text-xs outline-hidden select-none hover:bg-accent hover:text-accent-foreground"
onMouseDown={(e) => {
e.preventDefault();
editor.chain().focus().toggleOrderedList().run();
handleOpenChange(false);
}}
>
<ListOrdered className="size-3.5" /> Ordered List
{isOrdered && <Check className="ml-auto size-3.5" />}
</button>
</PopoverContent>
</Popover>
);
}
// ---------------------------------------------------------------------------
// Main Bubble Menu — native Tiptap <BubbleMenu>
// ---------------------------------------------------------------------------
function EditorBubbleMenu({ editor }: { editor: Editor }) {
const [mode, setMode] = useState<"toolbar" | "link-edit">("toolbar");
const [scrollTarget, setScrollTarget] = useState<HTMLElement | Window>(window);
// Find the real scroll container once on mount
useEffect(() => {
setScrollTarget(getScrollParent(editor.view.dom));
}, [editor]);
// Hide when the selection scrolls outside the scroll container's
// visible area. The plugin's hide middleware can't detect this because
// its virtual reference element has no contextElement — Floating UI
// only checks viewport bounds. We use `display` (not managed by the
// plugin) as an additive visibility layer.
const scrollHiddenRef = useRef(false);
const [, forceRender] = useState(0);
useEffect(() => {
if (scrollTarget === window) return;
const el = scrollTarget as HTMLElement;
const onScroll = () => {
if (editor.state.selection.empty) {
if (scrollHiddenRef.current) {
scrollHiddenRef.current = false;
forceRender((n) => n + 1);
}
return;
}
const coords = editor.view.coordsAtPos(editor.state.selection.from);
const rect = el.getBoundingClientRect();
const visible = coords.top >= rect.top && coords.top <= rect.bottom;
if (scrollHiddenRef.current !== !visible) {
scrollHiddenRef.current = !visible;
forceRender((n) => n + 1);
}
};
el.addEventListener("scroll", onScroll, { passive: true });
return () => el.removeEventListener("scroll", onScroll);
}, [editor, scrollTarget]);
// Reset scroll-hidden and mode when selection changes
useEffect(() => {
const handler = () => {
setMode("toolbar");
if (scrollHiddenRef.current) {
scrollHiddenRef.current = false;
forceRender((n) => n + 1);
}
};
editor.on("selectionUpdate", handler);
return () => { editor.off("selectionUpdate", handler); };
}, [editor]);
// Refocus editor when Base UI dropdown closes
const handleMenuOpenChange = useCallback(
(open: boolean) => { if (!open) editor.commands.focus(); },
[editor],
);
return (
<BubbleMenu
editor={editor}
shouldShow={shouldShowBubbleMenu}
updateDelay={0}
style={{
zIndex: 50,
display: scrollHiddenRef.current ? "none" : undefined,
}}
options={{
strategy: "fixed",
placement: "top",
offset: 8,
flip: true,
shift: { padding: 8 },
hide: true,
scrollTarget,
}}
>
{mode === "link-edit" ? (
<LinkEditBar editor={editor} onClose={() => { setMode("toolbar"); editor.commands.focus(); }} />
) : (
<TooltipProvider delay={300}>
<div className="bubble-menu">
<MarkButton editor={editor} mark="bold" icon={Bold} label="Bold" shortcut={`${mod}+B`} />
<MarkButton editor={editor} mark="italic" icon={Italic} label="Italic" shortcut={`${mod}+I`} />
<MarkButton editor={editor} mark="strike" icon={Strikethrough} label="Strikethrough" shortcut={`${mod}+Shift+S`} />
<MarkButton editor={editor} mark="code" icon={Code} label="Code" shortcut={`${mod}+E`} />
<Separator orientation="vertical" className="mx-0.5 h-5" />
<Tooltip>
<TooltipTrigger render={
<Toggle size="sm" pressed={editor.isActive("link")} onPressedChange={() => setMode("link-edit")} onMouseDown={(e) => e.preventDefault()} />
}>
<Link2 className="size-3.5" />
</TooltipTrigger>
<TooltipContent side="top" sideOffset={8}>Link</TooltipContent>
</Tooltip>
<Separator orientation="vertical" className="mx-0.5 h-5" />
<HeadingDropdown editor={editor} onOpenChange={handleMenuOpenChange} />
<ListDropdown editor={editor} onOpenChange={handleMenuOpenChange} />
<Tooltip>
<TooltipTrigger render={
<Toggle size="sm" pressed={editor.isActive("blockquote")} onPressedChange={() => editor.chain().focus().toggleBlockquote().run()} onMouseDown={(e) => e.preventDefault()} />
}>
<Quote className="size-3.5" />
</TooltipTrigger>
<TooltipContent side="top" sideOffset={8}>Quote</TooltipContent>
</Tooltip>
</div>
</TooltipProvider>
)}
</BubbleMenu>
);
}
export { EditorBubbleMenu };

View File

@@ -28,7 +28,6 @@
.rich-text-editor.ProseMirror {
color: var(--foreground);
caret-color: var(--foreground);
min-height: 100%;
}
.rich-text-editor.ProseMirror:focus {
@@ -458,51 +457,4 @@
.rich-text-editor .image-toolbar button:hover {
background: color-mix(in srgb, white 15%, transparent);
}
/* Bubble menu — floating toolbar pill */
.bubble-menu {
display: flex;
align-items: center;
gap: 1px;
padding: 0.25rem;
background: var(--popover);
border: 1px solid color-mix(in srgb, var(--foreground) 10%, transparent);
border-radius: var(--radius);
box-shadow:
0 4px 12px color-mix(in srgb, black 12%, transparent),
0 0 0 1px color-mix(in srgb, black 4%, transparent);
}
/* Link edit mode — inline URL input */
.bubble-menu-link-edit {
display: flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem;
background: var(--popover);
border: 1px solid color-mix(in srgb, var(--foreground) 10%, transparent);
border-radius: var(--radius);
box-shadow:
0 4px 12px color-mix(in srgb, black 12%, transparent),
0 0 0 1px color-mix(in srgb, black 4%, transparent);
min-width: 300px;
}
/* Link hover card — shows URL + actions on link hover */
.link-hover-card {
display: flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0.25rem 0.25rem 0.5rem;
background: var(--popover);
border: 1px solid color-mix(in srgb, var(--foreground) 10%, transparent);
border-radius: var(--radius);
box-shadow:
0 4px 12px color-mix(in srgb, black 12%, transparent),
0 0 0 1px color-mix(in srgb, black 4%, transparent);
max-width: min(360px, calc(100vw - 2rem));
white-space: nowrap;
}
}

View File

@@ -1,76 +0,0 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react";
const mockFocus = vi.hoisted(() => vi.fn());
vi.mock("@tanstack/react-query", () => ({
useQueryClient: () => ({}),
}));
vi.mock("./extensions", () => ({
createEditorExtensions: () => [],
}));
vi.mock("./extensions/file-upload", () => ({
uploadAndInsertFile: vi.fn(),
}));
vi.mock("./utils/preprocess", () => ({
preprocessMarkdown: (value: string) => value,
}));
vi.mock("./bubble-menu", () => ({
EditorBubbleMenu: () => null,
}));
vi.mock("@tiptap/react", () => ({
useEditor: () => ({
commands: {
focus: mockFocus,
clearContent: vi.fn(),
},
getMarkdown: () => "",
state: {
doc: {
content: {
size: 0,
},
},
selection: {
empty: true,
},
},
}),
EditorContent: ({ className }: { className?: string }) => (
<div className={className} data-testid="editor-content">
<div className="ProseMirror rich-text-editor" data-testid="prosemirror" />
</div>
),
}));
import { ContentEditor } from "./content-editor";
describe("ContentEditor", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("focuses the editor when clicking the empty container area", () => {
render(<ContentEditor placeholder="Add description..." />);
const shell = screen.getByTestId("editor-content").parentElement;
expect(shell).not.toBeNull();
fireEvent.mouseDown(shell!);
expect(mockFocus).toHaveBeenCalledWith("end");
});
it("does not hijack clicks that land inside the ProseMirror node", () => {
render(<ContentEditor placeholder="Add description..." />);
fireEvent.mouseDown(screen.getByTestId("prosemirror"));
expect(mockFocus).not.toHaveBeenCalled();
});
});

View File

@@ -30,7 +30,6 @@ import {
useEffect,
useImperativeHandle,
useRef,
type MouseEvent as ReactMouseEvent,
} from "react";
import { useEditor, EditorContent } from "@tiptap/react";
import { cn } from "@multica/ui/lib/utils";
@@ -39,22 +38,8 @@ import { useQueryClient } from "@tanstack/react-query";
import { createEditorExtensions } from "./extensions";
import { uploadAndInsertFile } from "./extensions/file-upload";
import { preprocessMarkdown } from "./utils/preprocess";
import { EditorBubbleMenu } from "./bubble-menu";
import { useLinkHover, LinkHoverCard } from "./link-hover-card";
import "./content-editor.css";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/** Blob URLs (blob:http://…) are process-local and expire on reload. Strip them
* from serialised markdown so they never reach the database. */
const BLOB_IMAGE_RE = /!\[[^\]]*\]\(blob:[^)]*\)\n?/g;
function stripBlobUrls(md: string): string {
return md.replace(BLOB_IMAGE_RE, "");
}
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
@@ -76,8 +61,6 @@ interface ContentEditorRef {
clearContent: () => void;
focus: () => void;
uploadFile: (file: File) => void;
/** True when file uploads are still in progress. */
hasActiveUploads: () => boolean;
}
// ---------------------------------------------------------------------------
@@ -130,7 +113,7 @@ const ContentEditor = forwardRef<ContentEditorRef, ContentEditorProps>(
if (!onUpdateRef.current) return;
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
onUpdateRef.current?.(stripBlobUrls(ed.getMarkdown()));
onUpdateRef.current?.(ed.getMarkdown());
}, debounceMs);
},
onBlur: () => {
@@ -147,17 +130,33 @@ const ContentEditor = forwardRef<ContentEditorRef, ContentEditorProps>(
const href = link?.getAttribute("href");
if (!href || href.startsWith("mention://")) return false;
// Open the link. Internal paths use multica:navigate
// (Electron hash-router safe), external open in new tab.
event.preventDefault();
if (href.startsWith("/")) {
window.dispatchEvent(
new CustomEvent("multica:navigate", { detail: { path: href } }),
);
} else {
window.open(href, "_blank", "noopener,noreferrer");
const openLink = () => {
if (href.startsWith("/")) {
// Internal path — dispatch custom event so the app can handle it
// (direct window.open breaks in Electron hash router)
window.dispatchEvent(
new CustomEvent("multica:navigate", { detail: { path: href } }),
);
} else {
window.open(href, "_blank", "noopener,noreferrer");
}
};
if (!editable) {
// Readonly: any click on link opens new tab
event.preventDefault();
openLink();
return true;
}
return true;
if (event.metaKey || event.ctrlKey) {
// Edit mode: Cmd/Ctrl+click opens link
event.preventDefault();
openLink();
return true;
}
return false;
},
},
attributes: {
@@ -192,7 +191,7 @@ const ContentEditor = forwardRef<ContentEditorRef, ContentEditorProps>(
}, [editor, editable, defaultValue]);
useImperativeHandle(ref, () => ({
getMarkdown: () => stripBlobUrls(editor?.getMarkdown() ?? ""),
getMarkdown: () => editor?.getMarkdown() ?? "",
clearContent: () => {
editor?.commands.clearContent();
},
@@ -204,44 +203,13 @@ const ContentEditor = forwardRef<ContentEditorRef, ContentEditorProps>(
const endPos = editor.state.doc.content.size;
uploadAndInsertFile(editor, file, onUploadFileRef.current, endPos);
},
hasActiveUploads: () => {
if (!editor) return false;
let uploading = false;
editor.state.doc.descendants((node) => {
if (node.attrs.uploading) uploading = true;
return !uploading;
});
return uploading;
},
}));
// Link hover card — disabled when BubbleMenu is active (has selection)
const wrapperRef = useRef<HTMLDivElement>(null);
const hoverDisabled = !editor?.state.selection.empty;
const hover = useLinkHover(wrapperRef, hoverDisabled);
const handleContainerMouseDown = (event: ReactMouseEvent<HTMLDivElement>) => {
if (!editable || !editor) return;
const target = event.target as HTMLElement;
if (target.closest(".ProseMirror")) return;
if (target.closest("a, button, input, textarea, [role='button'], [data-node-view-wrapper]")) return;
event.preventDefault();
editor.commands.focus("end");
};
if (!editor) return null;
return (
<div
ref={wrapperRef}
className="relative flex min-h-full flex-col"
onMouseDown={handleContainerMouseDown}
>
<EditorContent className="flex-1 min-h-full" editor={editor} />
{editable && <EditorBubbleMenu editor={editor} />}
<LinkHoverCard {...hover} />
<div className="relative min-h-full">
<EditorContent editor={editor} />
</div>
);
},

View File

@@ -47,10 +47,9 @@ import { ImageView } from "./image-view";
const lowlight = createLowlight(common);
const LinkEditable = Link.extend({ inclusive: false }).configure({
openOnClick: false,
openOnClick: true,
autolink: true,
linkOnPaste: true,
defaultProtocol: "https",
linkOnPaste: false,
});
const LinkReadonly = Link.configure({
@@ -104,9 +103,6 @@ export function createEditorExtensions(
return ReactNodeViewRenderer(CodeBlockView);
},
}).configure({ lowlight }),
// ⚠️ Link MUST appear before markdownPaste in this array.
// linkOnPaste relies on Link's handlePaste plugin firing first;
// markdownPaste's handlePaste is a catch-all that returns true.
editable ? LinkEditable : LinkReadonly,
ImageExtension,
Table.configure({ resizable: false }),

View File

@@ -40,9 +40,6 @@ export function createMarkdownPasteExtension() {
const clipboard = event.clipboardData;
if (!clipboard) return false;
// If clipboard has files, defer to the fileUpload extension.
if (clipboard.files?.length) return false;
const text = clipboard.getData("text/plain");
if (!text) return false;

View File

@@ -21,7 +21,7 @@
import { NodeViewWrapper } from "@tiptap/react";
import type { NodeViewProps } from "@tiptap/react";
import { useQuery } from "@tanstack/react-query";
import { issueListOptions, issueDetailOptions } from "@multica/core/issues/queries";
import { issueListOptions } from "@multica/core/issues/queries";
import { useWorkspaceId } from "@multica/core/hooks";
import { useNavigation } from "../../navigation";
import { StatusIcon } from "../../issues/components/status-icon";
@@ -54,14 +54,7 @@ function IssueMention({
const wsId = useWorkspaceId();
const { data: issues = [] } = useQuery(issueListOptions(wsId));
const { push, openInNewTab } = useNavigation();
const listIssue = issues.find((i) => i.id === issueId);
const { data: detailIssue } = useQuery({
...issueDetailOptions(wsId, issueId),
enabled: !listIssue,
});
const issue = listIssue ?? detailIssue;
const issue = issues.find((i) => i.id === issueId);
const issuePath = `/issues/${issueId}`;
const tabTitle = issue ? `${issue.identifier}: ${issue.title}` : undefined;

View File

@@ -1,236 +0,0 @@
"use client";
/**
* LinkHoverCard — floating card shown on link hover.
*
* Displays the URL with Copy and Open actions. Portaled to body
* with position:fixed to escape overflow:hidden containers.
* Shows after 300ms hover delay, hides after 150ms mouse-out
* (cancelled if mouse enters the card).
*/
import { useState, useEffect, useCallback, useRef } from "react";
import { createPortal } from "react-dom";
import { computePosition, offset, flip, shift } from "@floating-ui/dom";
import { ExternalLink, Copy } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@multica/ui/components/ui/button";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function openLink(href: string) {
if (href.startsWith("/")) {
window.dispatchEvent(
new CustomEvent("multica:navigate", { detail: { path: href } }),
);
} else {
window.open(href, "_blank", "noopener,noreferrer");
}
}
function truncateUrl(url: string, max = 48): string {
if (url.length <= max) return url;
try {
const u = new URL(url);
const origin = u.origin;
const rest = url.slice(origin.length);
if (rest.length <= 10) return url;
return `${origin}${rest.slice(0, max - origin.length - 1)}`;
} catch {
return `${url.slice(0, max - 1)}`;
}
}
// ---------------------------------------------------------------------------
// Hook — manages hover state with enter/leave delays
// ---------------------------------------------------------------------------
const SHOW_DELAY = 300;
const HIDE_DELAY = 150;
interface HoverState {
visible: boolean;
href: string;
anchorEl: HTMLAnchorElement | null;
}
function useLinkHover(containerRef: React.RefObject<HTMLElement | null>, disabled?: boolean) {
const [state, setState] = useState<HoverState>({ visible: false, href: "", anchorEl: null });
const showTimer = useRef(0);
const hideTimer = useRef(0);
const cardRef = useRef<HTMLDivElement>(null);
const clearTimers = useCallback(() => {
clearTimeout(showTimer.current);
clearTimeout(hideTimer.current);
}, []);
// Container mouse events — detect <a> hover
useEffect(() => {
const container = containerRef.current;
if (!container || disabled) return;
const onMouseOver = (e: MouseEvent) => {
const target = e.target as HTMLElement;
const link = target.closest("a") as HTMLAnchorElement | null;
if (!link) return;
const href = link.getAttribute("href");
if (!href || href.startsWith("mention://")) return;
clearTimeout(hideTimer.current);
showTimer.current = window.setTimeout(() => {
setState({ visible: true, href, anchorEl: link });
}, SHOW_DELAY);
};
const onMouseOut = (e: MouseEvent) => {
const related = e.relatedTarget as HTMLElement | null;
// Don't hide if mouse moved to the hover card
if (related && cardRef.current?.contains(related)) return;
// Don't hide if mouse moved to another part of the same link
const link = (e.target as HTMLElement).closest("a");
if (link && link.contains(related)) return;
clearTimeout(showTimer.current);
hideTimer.current = window.setTimeout(() => {
setState((s) => ({ ...s, visible: false }));
}, HIDE_DELAY);
};
container.addEventListener("mouseover", onMouseOver);
container.addEventListener("mouseout", onMouseOut);
return () => {
container.removeEventListener("mouseover", onMouseOver);
container.removeEventListener("mouseout", onMouseOut);
clearTimers();
};
}, [containerRef, disabled, clearTimers]);
// Card mouse events — keep visible while hovering the card
const onCardEnter = useCallback(() => {
clearTimeout(hideTimer.current);
}, []);
const onCardLeave = useCallback(() => {
hideTimer.current = window.setTimeout(() => {
setState((s) => ({ ...s, visible: false }));
}, HIDE_DELAY);
}, []);
return { ...state, cardRef, onCardEnter, onCardLeave };
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
function LinkHoverCard({
visible,
href,
anchorEl,
cardRef,
onCardEnter,
onCardLeave,
}: {
visible: boolean;
href: string;
anchorEl: HTMLAnchorElement | null;
cardRef: React.RefObject<HTMLDivElement | null>;
onCardEnter: () => void;
onCardLeave: () => void;
}) {
const [pos, setPos] = useState({ top: 0, left: 0 });
const [positioned, setPositioned] = useState(false);
// Position the card when the portal div is mounted (ref callback).
// Using useEffect would race with portal rendering — the div might
// not be in the DOM yet when the effect runs.
const setCardRef = useCallback(
(node: HTMLDivElement | null) => {
(cardRef as React.MutableRefObject<HTMLDivElement | null>).current = node;
if (!node || !anchorEl) {
setPositioned(false);
return;
}
computePosition(anchorEl, node, {
placement: "bottom-start",
strategy: "fixed",
middleware: [offset(4), flip(), shift({ padding: 8 })],
}).then(({ x, y }) => {
setPos({ top: y, left: x });
setPositioned(true);
});
},
[anchorEl, cardRef],
);
// Reset positioned when hidden
useEffect(() => {
if (!visible) setPositioned(false);
}, [visible]);
if (!visible || !anchorEl) return null;
const handleCopy = async (e: React.MouseEvent) => {
e.stopPropagation();
e.preventDefault();
try {
await navigator.clipboard.writeText(href);
toast.success("Link copied");
} catch {
toast.error("Failed to copy");
}
};
const handleOpen = (e: React.MouseEvent) => {
e.stopPropagation();
e.preventDefault();
openLink(href);
};
return createPortal(
<div
ref={setCardRef}
className="link-hover-card"
style={{
position: "fixed",
top: pos.top,
left: pos.left,
zIndex: 50,
display: positioned ? undefined : "none",
}}
onMouseEnter={onCardEnter}
onMouseLeave={onCardLeave}
>
<span
className="min-w-0 flex-1 truncate text-xs text-muted-foreground px-1"
title={href}
>
{truncateUrl(href)}
</span>
<Button
size="icon-xs"
variant="ghost"
className="text-muted-foreground"
onClick={handleCopy}
title="Copy link"
>
<Copy className="size-3.5" />
</Button>
<Button
size="icon-xs"
variant="ghost"
className="text-muted-foreground"
onClick={handleOpen}
title="Open link"
>
<ExternalLink className="size-3.5" />
</Button>
</div>,
document.body,
);
}
export { useLinkHover, LinkHoverCard };

View File

@@ -16,7 +16,7 @@
* - Rendering mentions with the same IssueMentionCard component and .mention class
*/
import { useMemo, useRef, useState } from "react";
import { useMemo, useState } from "react";
import ReactMarkdown, {
defaultUrlTransform,
type Components,
@@ -33,7 +33,6 @@ import { cn } from "@multica/ui/lib/utils";
import { useNavigation } from "../navigation";
import { IssueMentionCard } from "../issues/components/issue-mention-card";
import { ImageLightbox } from "./extensions/image-view";
import { useLinkHover, LinkHoverCard } from "./link-hover-card";
import { preprocessMarkdown } from "./utils/preprocess";
import "./content-editor.css";
@@ -110,7 +109,7 @@ function IssueMentionLink({ issueId, label }: { issueId: string; label?: string
}
const components: Partial<Components> = {
// Links — route mention:// to mention components, others show preview card
// Links — route mention:// to mention components, others open in new tab
a: ({ href, children }) => {
if (href?.startsWith("mention://")) {
const match = href.match(
@@ -129,20 +128,13 @@ const components: Partial<Components> = {
return <span className="mention">{children}</span>;
}
// Regular links — open directly on click
// Regular links — open in new tab
return (
<a
href={href}
onClick={(e) => {
e.preventDefault();
if (!href) return;
if (href.startsWith("/")) {
window.dispatchEvent(
new CustomEvent("multica:navigate", { detail: { path: href } }),
);
} else {
window.open(href, "_blank", "noopener,noreferrer");
}
if (href) window.open(href, "_blank", "noopener,noreferrer");
}}
>
{children}
@@ -281,11 +273,9 @@ interface ReadonlyContentProps {
export function ReadonlyContent({ content, className }: ReadonlyContentProps) {
const processed = useMemo(() => preprocessMarkdown(content), [content]);
const wrapperRef = useRef<HTMLDivElement>(null);
const hover = useLinkHover(wrapperRef);
return (
<div ref={wrapperRef} className={cn("rich-text-editor readonly text-sm", className)}>
<div className={cn("rich-text-editor readonly text-sm", className)}>
<ReactMarkdown
remarkPlugins={[[remarkGfm, { singleTilde: false }]]}
rehypePlugins={[rehypeRaw, [rehypeSanitize, sanitizeSchema]]}
@@ -294,7 +284,6 @@ export function ReadonlyContent({ content, className }: ReadonlyContentProps) {
>
{processed}
</ReactMarkdown>
<LinkHoverCard {...hover} />
</div>
);
}

View File

@@ -90,9 +90,7 @@ const TitleEditor = forwardRef<TitleEditorRef, TitleEditorProps>(
const editor = useEditor({
immediatelyRender: false,
content: defaultValue
? { type: "doc", content: [{ type: "paragraph", content: [{ type: "text", text: defaultValue }] }] }
: "",
content: `<p>${defaultValue}</p>`,
extensions: [
SingleLineDocument,
Paragraph,

View File

@@ -32,10 +32,6 @@ export function preprocessMarkdown(markdown: string): string {
*/
const FILE_LINK_LINE = /^\[([^\]]+)\]\((https?:\/\/[^)]+)\)$/;
function escapeAttr(s: string): string {
return s.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;");
}
function preprocessFileCards(markdown: string): string {
return markdown
.split("\n")
@@ -46,7 +42,7 @@ function preprocessFileCards(markdown: string): string {
const filename = match[1]!;
const url = match[2]!;
if (!isFileCardUrl(url)) return line;
return `<div data-type="fileCard" data-href="${escapeAttr(url)}" data-filename="${escapeAttr(filename)}"></div>`;
return `<div data-type="fileCard" data-href="${url}" data-filename="${filename}"></div>`;
})
.join("\n");
}

View File

@@ -208,9 +208,7 @@ export function AgentLiveCard({ issueId }: AgentLiveCardProps) {
// Pick up newly dispatched tasks
useWSEvent(
"task:dispatch",
useCallback((payload: unknown) => {
const p = payload as { issue_id?: string };
if (p.issue_id && p.issue_id !== issueId) return;
useCallback(() => {
api.getActiveTasksForIssue(issueId).then(({ tasks }) => {
setTaskStates((prev) => {
const next = new Map(prev);

View File

@@ -267,7 +267,7 @@ function CommentRow({
className="relative mt-1.5 pl-8"
onKeyDown={(e) => { if (e.key === "Escape") cancelEdit(); }}
>
<div className="text-sm leading-relaxed">
<div className="max-h-48 overflow-y-auto text-sm leading-relaxed">
<ContentEditor
ref={editEditorRef}
defaultValue={entry.content ?? ""}
@@ -484,7 +484,7 @@ function CommentCard({
className="relative pl-10"
onKeyDown={(e) => { if (e.key === "Escape") cancelEdit(); }}
>
<div className="text-sm leading-relaxed">
<div className="max-h-48 overflow-y-auto text-sm leading-relaxed">
<ContentEditor
ref={editEditorRef}
defaultValue={entry.content ?? ""}

View File

@@ -66,14 +66,14 @@ function CommentInput({ issueId, onSubmit }: CommentInputProps) {
onSelect={(file) => editorRef.current?.uploadFile(file)}
/>
<Button
size="icon-sm"
size="icon-xs"
disabled={isEmpty || submitting}
onClick={handleSubmit}
>
{submitting ? (
<Loader2 className="animate-spin" />
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<ArrowUp />
<ArrowUp className="h-3.5 w-3.5" />
)}
</Button>
</div>

View File

@@ -5,8 +5,6 @@ import { useDefaultLayout, usePanelRef } from "react-resizable-panels";
import { AppLink } from "../../navigation";
import { useNavigation } from "../../navigation";
import {
ArrowDown,
ArrowUp,
Calendar,
ChevronDown,
ChevronLeft,
@@ -54,10 +52,10 @@ import {
} from "@multica/ui/components/ui/tooltip";
import { Popover, PopoverTrigger, PopoverContent } from "@multica/ui/components/ui/popover";
import { Checkbox } from "@multica/ui/components/ui/checkbox";
import { Command, CommandDialog, CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem } from "@multica/ui/components/ui/command";
import { Command, CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem } from "@multica/ui/components/ui/command";
import { AvatarGroup, AvatarGroupCount } from "@multica/ui/components/ui/avatar";
import { ActorAvatar } from "../../common/actor-avatar";
import type { UpdateIssueRequest, IssueStatus, IssuePriority, TimelineEntry, Issue } from "@multica/core/types";
import type { UpdateIssueRequest, IssueStatus, IssuePriority, TimelineEntry } from "@multica/core/types";
import { ALL_STATUSES, STATUS_CONFIG, PRIORITY_ORDER, PRIORITY_CONFIG } from "@multica/core/issues/config";
import { StatusIcon, PriorityIcon, StatusPicker, PriorityPicker, DueDatePicker, AssigneePicker, canAssignAgent } from ".";
import { ProjectPicker } from "../../projects/components/project-picker";
@@ -176,132 +174,6 @@ function PropRow({
}
// ---------------------------------------------------------------------------
// Issue Picker Dialog
// ---------------------------------------------------------------------------
function IssuePickerDialog({
open,
onOpenChange,
title,
description,
excludeIds,
onSelect,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
title: string;
description: string;
excludeIds: string[];
onSelect: (issue: Issue) => void;
}) {
const [query, setQuery] = useState("");
const [results, setResults] = useState<Issue[]>([]);
const [isLoading, setIsLoading] = useState(false);
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const abortRef = useRef<AbortController>(undefined);
// Reset state when dialog opens/closes
useEffect(() => {
if (!open) {
setQuery("");
setResults([]);
setIsLoading(false);
}
}, [open]);
const search = useCallback(
(q: string) => {
if (debounceRef.current) clearTimeout(debounceRef.current);
if (abortRef.current) abortRef.current.abort();
if (!q.trim()) {
setResults([]);
setIsLoading(false);
return;
}
setIsLoading(true);
debounceRef.current = setTimeout(async () => {
const controller = new AbortController();
abortRef.current = controller;
try {
const res = await api.searchIssues({
q: q.trim(),
limit: 20,
include_closed: true,
signal: controller.signal,
});
if (!controller.signal.aborted) {
setResults(
res.issues.filter((i) => !excludeIds.includes(i.id)),
);
setIsLoading(false);
}
} catch {
if (!controller.signal.aborted) {
setIsLoading(false);
}
}
}, 300);
},
[excludeIds],
);
return (
<CommandDialog
open={open}
onOpenChange={onOpenChange}
title={title}
description={description}
>
<Command shouldFilter={false}>
<CommandInput
placeholder="Search issues..."
value={query}
onValueChange={(v) => {
setQuery(v);
search(v);
}}
/>
<CommandList>
{isLoading && (
<div className="py-6 text-center text-sm text-muted-foreground">
Searching...
</div>
)}
{!isLoading && query.trim() && results.length === 0 && (
<CommandEmpty>No issues found.</CommandEmpty>
)}
{!isLoading && !query.trim() && (
<div className="py-6 text-center text-sm text-muted-foreground">
Type to search issues
</div>
)}
{results.length > 0 && (
<CommandGroup>
{results.map((issue) => (
<CommandItem
key={issue.id}
value={issue.id}
onSelect={() => {
onSelect(issue);
onOpenChange(false);
}}
>
<StatusIcon status={issue.status} className="h-3.5 w-3.5 shrink-0" />
<span className="text-muted-foreground shrink-0">{issue.identifier}</span>
<span className="truncate">{issue.title}</span>
</CommandItem>
))}
</CommandGroup>
)}
</CommandList>
</Command>
</CommandDialog>
);
}
// ---------------------------------------------------------------------------
// Props
// ---------------------------------------------------------------------------
@@ -349,8 +221,6 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
const scrollContainerRef = useRef<HTMLDivElement>(null);
const [highlightedId, setHighlightedId] = useState<string | null>(null);
const didHighlightRef = useRef<string | null>(null);
const [parentPickerOpen, setParentPickerOpen] = useState(false);
const [childPickerOpen, setChildPickerOpen] = useState(false);
// Issue data from TQ — uses detail query, seeded from list cache if available.
// Only seed when description is present; list API omits it, and ContentEditor
@@ -635,7 +505,7 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
}
}}
>
{isPinned ? <PinOff /> : <Pin />}
{isPinned ? <PinOff className="h-4 w-4" /> : <Pin className="h-4 w-4" />}
</Button>
}
/>
@@ -775,18 +645,6 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
Create sub-issue
</DropdownMenuItem>
{/* Add as sub-issue of another issue */}
<DropdownMenuItem onClick={() => setParentPickerOpen(true)}>
<ArrowUp className="h-3.5 w-3.5" />
Set parent issue...
</DropdownMenuItem>
{/* Add another issue as sub-issue */}
<DropdownMenuItem onClick={() => setChildPickerOpen(true)}>
<ArrowDown className="h-3.5 w-3.5" />
Add sub-issue...
</DropdownMenuItem>
{/* Pin / Unpin */}
<DropdownMenuItem onClick={() => {
if (isPinned) {
@@ -866,35 +724,6 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* Set parent issue picker */}
<IssuePickerDialog
open={parentPickerOpen}
onOpenChange={setParentPickerOpen}
title="Set parent issue"
description="Search for an issue to set as the parent of this issue"
excludeIds={[id, ...childIssues.map((c) => c.id)]}
onSelect={(selected) => {
handleUpdateField({ parent_issue_id: selected.id });
toast.success(`Set ${selected.identifier} as parent issue`);
}}
/>
{/* Add sub-issue picker */}
<IssuePickerDialog
open={childPickerOpen}
onOpenChange={setChildPickerOpen}
title="Add sub-issue"
description="Search for an issue to add as a sub-issue"
excludeIds={[id, ...(parentIssueId ? [parentIssueId] : []), ...childIssues.map((c) => c.id)]}
onSelect={(selected) => {
updateIssueMutation.mutate(
{ id: selected.id, parent_issue_id: id },
{ onError: () => toast.error("Failed to add sub-issue") },
);
toast.success(`Added ${selected.identifier} as sub-issue`);
}}
/>
</div>
{/* Content — scrollable */}

View File

@@ -2,7 +2,7 @@
import { AppLink } from "../../navigation";
import { useQuery } from "@tanstack/react-query";
import { issueListOptions, issueDetailOptions } from "@multica/core/issues/queries";
import { issueListOptions } from "@multica/core/issues/queries";
import { useWorkspaceId } from "@multica/core/hooks";
import { StatusIcon } from "./status-icon";
@@ -15,16 +15,7 @@ interface IssueMentionCardProps {
export function IssueMentionCard({ issueId, fallbackLabel }: IssueMentionCardProps) {
const wsId = useWorkspaceId();
const { data: issues = [] } = useQuery(issueListOptions(wsId));
const listIssue = issues.find((i) => i.id === issueId);
// Fetch individual issue when not found in the list (e.g. done issues beyond
// the first page). Only fires when listIssue is undefined.
const { data: detailIssue } = useQuery({
...issueDetailOptions(wsId, issueId),
enabled: !listIssue,
});
const issue = listIssue ?? detailIssue;
const issue = issues.find((i) => i.id === issueId);
if (!issue) {
return (

View File

@@ -14,7 +14,7 @@ import { BOARD_STATUSES } from "@multica/core/issues/config";
import { useWorkspaceStore } from "@multica/core/workspace";
import { WorkspaceAvatar } from "../../workspace/workspace-avatar";
import { useWorkspaceId } from "@multica/core/hooks";
import { issueListOptions, childIssueProgressOptions } from "@multica/core/issues/queries";
import { issueListOptions } from "@multica/core/issues/queries";
import { useUpdateIssue } from "@multica/core/issues/mutations";
import { useIssueSelectionStore } from "@multica/core/issues/stores/selection-store";
import { IssuesHeader } from "./issues-header";
@@ -38,7 +38,7 @@ export function IssuesPage() {
const includeNoProject = useIssueViewStore((s) => s.includeNoProject);
useEffect(() => {
initFilterWorkspaceSync((cb) => useWorkspaceStore.subscribe((s) => cb(s.workspace?.id)));
initFilterWorkspaceSync();
}, []);
useEffect(() => {
@@ -59,9 +59,22 @@ export function IssuesPage() {
[scopedIssues, statusFilters, priorityFilters, assigneeFilters, includeNoAssignee, creatorFilters, projectFilters, includeNoProject],
);
// Fetch sub-issue progress from the backend so counts are accurate
// regardless of client-side pagination or filtering of done issues.
const { data: childProgressMap = new Map() } = useQuery(childIssueProgressOptions(wsId));
// Compute sub-issue progress for each parent from the full (unfiltered) issue list
const childProgressMap = useMemo(() => {
const map = new Map<string, { done: number; total: number }>();
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";
if (entry) {
entry.total++;
if (isDone) entry.done++;
} else {
map.set(issue.parent_issue_id, { done: isDone ? 1 : 0, total: 1 });
}
}
return map;
}, [allIssues]);
const visibleStatuses = useMemo(() => {
if (statusFilters.length > 0)

View File

@@ -107,19 +107,17 @@ export function AssigneePicker({
)
}
>
{/* Unassigned option — hidden when search is active */}
{!query && (
<PickerItem
selected={!assigneeType && !assigneeId}
onClick={() => {
onUpdate({ assignee_type: null, assignee_id: null });
setOpen(false);
}}
>
<UserMinus className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-muted-foreground">Unassigned</span>
</PickerItem>
)}
{/* Unassigned option */}
<PickerItem
selected={!assigneeType && !assigneeId}
onClick={() => {
onUpdate({ assignee_type: null, assignee_id: null });
setOpen(false);
}}
>
<UserMinus className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-muted-foreground">Unassigned</span>
</PickerItem>
{/* Members */}
{filteredMembers.length > 0 && (

View File

@@ -1,6 +1,6 @@
"use client";
import { useState, useCallback, useRef, useEffect } from "react";
import { useState, useCallback } from "react";
import { Check } from "lucide-react";
import {
Popover,
@@ -8,9 +8,6 @@ import {
PopoverContent,
} from "@multica/ui/components/ui/popover";
const HIGHLIGHT_CLASS = "bg-accent";
const ITEM_SELECTOR = "button[data-picker-item]:not(:disabled)";
// ---------------------------------------------------------------------------
// PropertyPicker — generic Popover shell with optional search
// ---------------------------------------------------------------------------
@@ -39,71 +36,18 @@ export function PropertyPicker({
children: React.ReactNode;
}) {
const [query, setQuery] = useState("");
const [highlightedIndex, setHighlightedIndex] = useState(-1);
const listRef = useRef<HTMLDivElement>(null);
const getItems = useCallback(() => {
if (!listRef.current) return [];
return Array.from(
listRef.current.querySelectorAll<HTMLButtonElement>(ITEM_SELECTOR),
);
}, []);
// Apply/remove highlight class via DOM when index changes
useEffect(() => {
const items = getItems();
for (const item of items) {
item.classList.remove(HIGHLIGHT_CLASS);
}
if (highlightedIndex >= 0 && highlightedIndex < items.length) {
items[highlightedIndex]?.classList.add(HIGHLIGHT_CLASS);
}
}, [highlightedIndex, getItems, children]); // re-run when children change (filtered list updates)
const handleOpenChange = useCallback(
(v: boolean) => {
onOpenChange(v);
if (!v) {
setQuery("");
setHighlightedIndex(-1);
onSearchChange?.("");
}
},
[onOpenChange, onSearchChange],
);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
const items = getItems();
if (items.length === 0) return;
if (e.key === "ArrowDown") {
e.preventDefault();
setHighlightedIndex((prev) => {
const next = prev < items.length - 1 ? prev + 1 : 0;
items[next]?.scrollIntoView({ block: "nearest" });
return next;
});
} else if (e.key === "ArrowUp") {
e.preventDefault();
setHighlightedIndex((prev) => {
const next = prev > 0 ? prev - 1 : items.length - 1;
items[next]?.scrollIntoView({ block: "nearest" });
return next;
});
} else if (e.key === "Enter") {
e.preventDefault();
if (highlightedIndex >= 0 && highlightedIndex < items.length) {
items[highlightedIndex]?.click();
} else if (items.length === 1) {
// Auto-select when only one result
items[0]?.click();
}
}
},
[getItems, highlightedIndex],
);
return (
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger
@@ -120,17 +64,15 @@ export function PropertyPicker({
value={query}
onChange={(e) => {
setQuery(e.target.value);
setHighlightedIndex(0);
onSearchChange?.(e.target.value);
}}
onKeyDown={handleKeyDown}
placeholder={searchPlaceholder}
aria-label="Filter options"
className="w-full bg-transparent text-sm placeholder:text-muted-foreground outline-none"
/>
</div>
)}
<div ref={listRef} className="p-1 max-h-60 overflow-y-auto">{children}</div>
<div className="p-1 max-h-60 overflow-y-auto">{children}</div>
</PopoverContent>
</Popover>
);
@@ -156,7 +98,6 @@ export function PickerItem({
return (
<button
type="button"
data-picker-item
disabled={disabled}
onClick={onClick}
className={`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm ${disabled ? "opacity-50 cursor-not-allowed" : hoverClassName ?? "hover:bg-accent"} transition-colors`}

View File

@@ -56,9 +56,9 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@multica/ui/components/ui/dropdown-menu";
import { Tooltip, TooltipTrigger, TooltipContent } from "@multica/ui/components/ui/tooltip";
import { useAuthStore } from "@multica/core/auth";
import { useWorkspaceStore } from "@multica/core/workspace";
import { workspaceListOptions } from "@multica/core/workspace/queries";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { inboxKeys, deduplicateInboxItems } from "@multica/core/inbox/queries";
import { api } from "@multica/core/api";
@@ -162,8 +162,8 @@ export function AppSidebar({ topSlot, searchSlot, headerClassName, headerStyle }
const userId = useAuthStore((s) => s.user?.id);
const authLogout = useAuthStore((s) => s.logout);
const workspace = useWorkspaceStore((s) => s.workspace);
const workspaces = useWorkspaceStore((s) => s.workspaces);
const switchWorkspace = useWorkspaceStore((s) => s.switchWorkspace);
const { data: workspaces = [] } = useQuery(workspaceListOptions());
const wsId = workspace?.id;
const { data: inboxItems = [] } = useQuery({
@@ -257,9 +257,20 @@ export function AppSidebar({ topSlot, searchSlot, headerClassName, headerStyle }
</DropdownMenuLabel>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuLabel className="text-xs text-muted-foreground">
<DropdownMenuGroup className="group/ws-section">
<DropdownMenuLabel className="flex items-center text-xs text-muted-foreground">
Workspaces
<Tooltip>
<TooltipTrigger
className="ml-auto opacity-0 group-hover/ws-section:opacity-100 transition-opacity rounded hover:bg-accent p-0.5"
onClick={() => useModalStore.getState().open("create-workspace")}
>
<Plus className="h-3.5 w-3.5" />
</TooltipTrigger>
<TooltipContent side="right">
Create workspace
</TooltipContent>
</Tooltip>
</DropdownMenuLabel>
{workspaces.map((ws) => (
<DropdownMenuItem
@@ -267,7 +278,7 @@ export function AppSidebar({ topSlot, searchSlot, headerClassName, headerStyle }
onClick={() => {
if (ws.id !== workspace?.id) {
push("/issues");
switchWorkspace(ws);
switchWorkspace(ws.id);
}
}}
>
@@ -278,14 +289,6 @@ export function AppSidebar({ topSlot, searchSlot, headerClassName, headerStyle }
)}
</DropdownMenuItem>
))}
<DropdownMenuItem
onClick={() =>
useModalStore.getState().open("create-workspace")
}
>
<Plus className="h-3.5 w-3.5" />
Create workspace
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>

View File

@@ -8,8 +8,6 @@ interface DashboardGuardProps {
children: ReactNode;
/** Path to redirect to when user is not authenticated */
loginPath?: string;
/** Path to redirect to when user has no workspace (onboarding) */
onboardingPath?: string;
/** Rendered when auth or workspace is loading */
loadingFallback?: ReactNode;
}
@@ -23,10 +21,9 @@ interface DashboardGuardProps {
export function DashboardGuard({
children,
loginPath = "/",
onboardingPath,
loadingFallback = null,
}: DashboardGuardProps) {
const { user, isLoading, workspace } = useDashboardGuard(loginPath, onboardingPath);
const { user, isLoading, workspace } = useDashboardGuard(loginPath);
if (isLoading || !workspace) return <>{loadingFallback}</>;
if (!user) return null;

View File

@@ -8,16 +8,12 @@ import { DashboardGuard } from "./dashboard-guard";
interface DashboardLayoutProps {
children: ReactNode;
/** Rendered inside SidebarInset (e.g. ChatWindow, ChatFab — absolute-positioned overlays) */
/** Sibling of SidebarInset (e.g. SearchCommand, ChatWindow) */
extra?: ReactNode;
/** Rendered inside sidebar header as a search trigger */
searchSlot?: ReactNode;
/** Loading indicator */
loadingIndicator?: ReactNode;
/** Path to redirect when user is not authenticated */
loginPath?: string;
/** Path to redirect when user has no workspace */
onboardingPath?: string;
}
export function DashboardLayout({
@@ -25,13 +21,10 @@ export function DashboardLayout({
extra,
searchSlot,
loadingIndicator,
loginPath,
onboardingPath,
}: DashboardLayoutProps) {
return (
<DashboardGuard
loginPath={loginPath}
onboardingPath={onboardingPath}
loginPath="/"
loadingFallback={
<div className="flex h-svh items-center justify-center">
{loadingIndicator}
@@ -40,14 +33,14 @@ export function DashboardLayout({
>
<SidebarProvider className="h-svh">
<AppSidebar searchSlot={searchSlot} />
<SidebarInset className="relative overflow-hidden">
<SidebarInset className="overflow-hidden">
<div className="flex h-10 shrink-0 items-center border-b px-2 md:hidden">
<SidebarTrigger />
</div>
{children}
<ModalRegistry />
{extra}
</SidebarInset>
{extra}
</SidebarProvider>
</DashboardGuard>
);

View File

@@ -6,22 +6,15 @@ import { useAuthStore } from "@multica/core/auth";
import { useWorkspaceStore } from "@multica/core/workspace";
import { useNavigation } from "../navigation";
export function useDashboardGuard(loginPath = "/", onboardingPath?: string) {
const { pathname, replace } = useNavigation();
export function useDashboardGuard(loginPath = "/") {
const { pathname, push } = useNavigation();
const user = useAuthStore((s) => s.user);
const isLoading = useAuthStore((s) => s.isLoading);
const workspace = useWorkspaceStore((s) => s.workspace);
useEffect(() => {
if (isLoading) return;
if (!user) {
replace(loginPath);
return;
}
if (!workspace && onboardingPath) {
replace(onboardingPath);
}
}, [user, isLoading, workspace, replace, loginPath, onboardingPath]);
if (!isLoading && !user) push(loginPath);
}, [user, isLoading, push, loginPath]);
useEffect(() => {
useNavigationStore.getState().onPathChange(pathname);

View File

@@ -59,8 +59,10 @@ vi.mock("@multica/core/api", () => ({
api: {},
}));
vi.mock("../editor", () => {
const ContentEditor = forwardRef(({ defaultValue, onUpdate, placeholder }: any, ref: any) => {
vi.mock("../editor", () => ({
useFileDropZone: () => ({ isDragOver: false, dropZoneProps: {} }),
FileDropOverlay: () => null,
ContentEditor: forwardRef(({ defaultValue, onUpdate, placeholder }: any, ref: any) => {
const valueRef = useRef(defaultValue || "");
const [value, setValue] = useState(defaultValue || "");
useImperativeHandle(ref, () => ({
@@ -78,31 +80,24 @@ vi.mock("../editor", () => {
}}
/>
);
});
ContentEditor.displayName = "ContentEditor";
return {
useFileDropZone: () => ({ isDragOver: false, dropZoneProps: {} }),
FileDropOverlay: () => null,
ContentEditor,
TitleEditor: ({ defaultValue, placeholder, onChange, onSubmit }: any) => {
const [value, setValue] = useState(defaultValue || "");
return (
<input
value={value}
placeholder={placeholder}
onChange={(e) => {
setValue(e.target.value);
onChange?.(e.target.value);
}}
onKeyDown={(e) => {
if (e.key === "Enter") onSubmit?.();
}}
/>
);
},
};
});
}),
TitleEditor: ({ defaultValue, placeholder, onChange, onSubmit }: any) => {
const [value, setValue] = useState(defaultValue || "");
return (
<input
value={value}
placeholder={placeholder}
onChange={(e) => {
setValue(e.target.value);
onChange?.(e.target.value);
}}
onKeyDown={(e) => {
if (e.key === "Enter") onSubmit?.();
}}
/>
);
},
}));
vi.mock("../issues/components", () => ({
StatusIcon: ({ status }: { status: string }) => <span data-testid="status-icon">{status}</span>,

View File

@@ -1,109 +0,0 @@
import type { ReactNode } from "react";
import { describe, expect, it, beforeEach, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
const mockPush = vi.hoisted(() => vi.fn());
const mockCreateWorkspaceMutate = vi.hoisted(() => vi.fn());
const mockToastError = vi.hoisted(() => vi.fn());
vi.mock("../navigation", () => ({
useNavigation: () => ({ push: mockPush }),
}));
vi.mock("@multica/core/workspace/mutations", () => ({
useCreateWorkspace: () => ({
mutate: mockCreateWorkspaceMutate,
isPending: false,
}),
}));
vi.mock("@multica/ui/components/ui/dialog", () => ({
Dialog: ({ children }: { children: ReactNode }) => <div>{children}</div>,
DialogContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
DialogTitle: ({ children }: { children: ReactNode }) => <h1>{children}</h1>,
DialogDescription: ({ children }: { children: ReactNode }) => (
<p>{children}</p>
),
}));
vi.mock("sonner", () => ({
toast: {
error: mockToastError,
},
}));
import { CreateWorkspaceModal } from "./create-workspace";
describe("CreateWorkspaceModal", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("auto-generates the slug until the user edits it", async () => {
const user = userEvent.setup();
render(<CreateWorkspaceModal onClose={vi.fn()} />);
const nameInput = screen.getByPlaceholderText("My Workspace");
const slugInput = screen.getByPlaceholderText("my-workspace");
await user.type(nameInput, "My Team");
expect(slugInput).toHaveValue("my-team");
await user.clear(slugInput);
await user.type(slugInput, "custom-team");
await user.clear(nameInput);
await user.type(nameInput, "Renamed Team");
expect(slugInput).toHaveValue("custom-team");
});
it("shows a specific slug conflict error on 409 responses", async () => {
const user = userEvent.setup();
mockCreateWorkspaceMutate.mockImplementation(
(
_data: unknown,
options: { onError: (error: unknown) => void },
) => {
options.onError({ status: 409 });
},
);
render(<CreateWorkspaceModal onClose={vi.fn()} />);
await user.type(screen.getByPlaceholderText("My Workspace"), "My Team");
await user.click(screen.getByRole("button", { name: "Create workspace" }));
await waitFor(() => {
expect(
screen.getByText("That workspace URL is already taken."),
).toBeInTheDocument();
});
expect(mockToastError).toHaveBeenCalledWith(
"Choose a different workspace URL",
);
expect(mockCreateWorkspaceMutate).toHaveBeenCalledWith(
{ name: "My Team", slug: "my-team" },
expect.any(Object),
);
});
it("continues onboarding after successful creation", async () => {
const user = userEvent.setup();
const onClose = vi.fn();
mockCreateWorkspaceMutate.mockImplementation(
(_data: unknown, options: { onSuccess: () => void }) => {
options.onSuccess();
},
);
render(<CreateWorkspaceModal onClose={onClose} />);
await user.type(screen.getByPlaceholderText("My Workspace"), "My Team");
await user.click(screen.getByRole("button", { name: "Create workspace" }));
expect(onClose).toHaveBeenCalled();
expect(mockPush).toHaveBeenCalledWith("/onboarding");
});
});

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