Compare commits

..

1 Commits

Author SHA1 Message Date
Jiayuan Zhang
509faab19f feat(cli): enhance version command with JSON output and build info
Add --output json flag, build date, Go version, and OS/arch to the
version command. Update Makefile and goreleaser to inject build date.
2026-04-12 02:12:35 +08:00
203 changed files with 1823 additions and 10434 deletions

View File

@@ -22,8 +22,6 @@ MULTICA_CODEX_WORKDIR=
MULTICA_CODEX_TIMEOUT=20m
# Email (Resend)
# For local/dev use, leave RESEND_API_KEY empty — codes print to stdout, and master code 888888 works.
# For production, set your Resend API key and change RESEND_FROM_EMAIL to a domain verified in your Resend account.
RESEND_API_KEY=
RESEND_FROM_EMAIL=noreply@multica.ai
@@ -42,16 +40,6 @@ CLOUDFRONT_PRIVATE_KEY=
CLOUDFRONT_DOMAIN=
COOKIE_DOMAIN=
# Local file storage (fallback when S3_BUCKET is not set)
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

@@ -1,39 +0,0 @@
name: "Bug Report"
description: Report a bug — something that's broken, crashes, or behaves incorrectly.
title: "[Bug]: "
labels: ["bug"]
body:
- type: textarea
id: description
attributes:
label: What happened?
description: Describe the bug and what you expected instead. Screenshots, error messages, or screen recordings are welcome.
placeholder: |
When I do X, Y happens. I expected Z instead.
validations:
required: true
- type: textarea
id: reproduction
attributes:
label: Steps to reproduce
description: How can we trigger this bug?
placeholder: |
1. Go to '...'
2. Click on '...'
3. See error
validations:
required: true
- type: textarea
id: screenshots
attributes:
label: Screenshots (optional)
description: If applicable, add screenshots or screen recordings to help explain the problem.
- type: textarea
id: context
attributes:
label: Additional context (optional)
description: Environment info, logs, or anything else that might help.
render: shell

View File

@@ -1 +0,0 @@
blank_issues_enabled: true

View File

@@ -1,26 +0,0 @@
name: "Feature Request"
description: Suggest a new feature or improvement.
title: "[Feature]: "
labels: ["enhancement"]
body:
- type: textarea
id: description
attributes:
label: What do you want and why?
description: Describe the problem you're trying to solve or the improvement you'd like to see.
placeholder: |
I'm trying to do X but there's no way to...
validations:
required: true
- type: textarea
id: solution
attributes:
label: Proposed solution (optional)
description: If you have an idea for how this should work, describe it here.
- type: textarea
id: screenshots
attributes:
label: Screenshots / mockups (optional)
description: If applicable, add screenshots, mockups, or sketches to illustrate your idea.

View File

@@ -1,56 +1,34 @@
## What does this PR do?
## What
<!-- Describe the change clearly. What problem does it solve? Why is this approach the right one? -->
<!-- What does this PR do? Keep it to 1-3 sentences. -->
## Why
<!-- Why is this change needed? Link the related issue. -->
## Related Issue
<!-- Link the issue this PR addresses. If no issue exists, consider creating one first. -->
Closes #
Closes #<!-- issue number -->
## Type of Change
- [ ] Bug fix (non-breaking change that fixes an issue)
- [ ] New feature (non-breaking change that adds functionality)
- [ ] Refactor / code improvement (no behavior change)
- [ ] Documentation update
- [ ] Tests (adding or improving test coverage)
- [ ] Bug fix
- [ ] New feature
- [ ] Refactor / code improvement
- [ ] Documentation
- [ ] CI / infrastructure
## Changes Made
<!-- List the specific changes. Include file paths for code changes. -->
-
- [ ] Other (describe below)
## How to Test
<!-- Steps to verify this change works. For bugs: reproduction steps + proof that the fix works. -->
1.
2.
3.
<!-- How can a reviewer verify this works? Steps, commands, or screenshots. -->
## Checklist
- [ ] 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
## AI Disclosure (optional)
<!-- Most PRs involve AI coding tools — that's totally fine! We're curious about your process. -->
**AI tool used:** <!-- e.g. Claude Code, Cursor, GitHub Copilot, Multica Agent, N/A -->
**Prompt / approach:**
<!-- How did you use AI to produce this code? Share your prompt, conversation link, or describe your approach. This helps the team learn from each other's AI workflows. -->
## Screenshots (optional)
<!-- If applicable, add screenshots showing the change in action. -->
<!-- If AI tools were used: -->
<!-- - Which tool? (e.g., Claude Code, Copilot, Cursor) -->
<!-- - What prompt did you use? Sharing your prompt helps others learn and lets reviewers understand intent. -->

2
.gitignore vendored
View File

@@ -48,5 +48,3 @@ _features/
*.dmg
*.app
server/server
data/
.kilo

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

@@ -18,9 +18,10 @@ The open-source managed agents platform.<br/>
Turn coding agents into real teammates — assign tasks, track progress, compound skills.
[![CI](https://github.com/multica-ai/multica/actions/workflows/ci.yml/badge.svg)](https://github.com/multica-ai/multica/actions/workflows/ci.yml)
[![License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](https://opensource.org/licenses/Apache-2.0)
[![GitHub stars](https://img.shields.io/github/stars/multica-ai/multica?style=flat)](https://github.com/multica-ai/multica/stargazers)
[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 +51,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 +77,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 +111,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

@@ -18,9 +18,10 @@
将编码 Agent 变成真正的队友——分配任务、跟踪进度、积累技能。
[![CI](https://github.com/multica-ai/multica/actions/workflows/ci.yml/badge.svg)](https://github.com/multica-ai/multica/actions/workflows/ci.yml)
[![License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](https://opensource.org/licenses/Apache-2.0)
[![GitHub stars](https://img.shields.io/github/stars/multica-ai/multica?style=flat)](https://github.com/multica-ai/multica/stargazers)
[官网](https://multica.ai) · [云服务](https://multica.ai/app) · [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 +51,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 +79,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
> ```
---
@@ -82,14 +77,11 @@ brew install multica-ai/tap/multica
You also need at least one AI agent CLI installed:
- [Claude Code](https://docs.anthropic.com/en/docs/claude-code) (`claude` on PATH)
- [Codex](https://github.com/openai/codex) (`codex` on PATH)
- [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 +90,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 +128,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 +180,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,7 @@
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 */}
@@ -23,11 +11,9 @@ export function DesktopLoginPage() {
/>
<LoginPage
logo={<MulticaIcon bordered size="lg" />}
lastWorkspaceId={lastWorkspaceId}
onSuccess={() => {
// Auth store update triggers AppContent re-render → shows DesktopShell
}}
onGoogleLogin={handleGoogleLogin}
/>
</div>
);

View File

@@ -2,7 +2,6 @@ import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
import { arrayMove } from "@dnd-kit/sortable";
import { createPersistStorage, defaultStorage } from "@multica/core/platform";
import { createSafeId } from "@multica/core/utils";
import type { DataRouter } from "react-router-dom";
import { createTabRouter } from "../routes";
@@ -70,7 +69,7 @@ export function resolveRouteIcon(pathname: string): string {
const DEFAULT_PATH = "/issues";
function createId(): string {
return createSafeId();
return crypto.randomUUID();
}
function makeTab(path: string, title: string, icon: string): Tab {

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,6 +1,5 @@
"use client";
import { useCallback, useState } from "react";
import Image from "next/image";
import Link from "next/link";
import { useAuthStore } from "@multica/core/auth";
@@ -53,8 +52,6 @@ export function LandingHero() {
GitHub
</Link>
</div>
<InstallCommand />
</div>
<div className="mt-10 flex items-center justify-center gap-8">
@@ -90,64 +87,6 @@ export function LandingHero() {
);
}
const INSTALL_COMMAND =
"curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.sh | bash";
function InstallCommand() {
const [copied, setCopied] = useState(false);
const handleCopy = useCallback(async () => {
try {
await navigator.clipboard.writeText(INSTALL_COMMAND);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
// ignore
}
}, []);
return (
<div className="mx-auto mt-6 max-w-fit">
<button
type="button"
onClick={handleCopy}
className="group flex items-center gap-3 rounded-lg border border-white/10 bg-white/5 px-4 py-2.5 font-mono text-[13px] text-white/70 backdrop-blur-sm transition-colors hover:border-white/20 hover:bg-white/8 hover:text-white/90"
>
<span className="text-white/40">$</span>
<span className="select-all">{INSTALL_COMMAND}</span>
<span className="ml-1 flex size-5 shrink-0 items-center justify-center text-white/40 transition-colors group-hover:text-white/70">
{copied ? (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="size-3.5 text-green-400"
>
<polyline points="20 6 9 17 4 12" />
</svg>
) : (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="size-3.5"
>
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
</svg>
)}
</span>
</button>
</div>
);
}
function LandingBackdrop() {
return (
<div className="pointer-events-none absolute inset-0">

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,46 +277,6 @@ export const en: LandingDict = {
fixes: "Bug Fixes",
},
entries: [
{
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,46 +277,6 @@ export const zh: LandingDict = {
fixes: "问题修复",
},
entries: [
{
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

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

@@ -1,13 +0,0 @@
import { existsSync } from "fs";
import { resolve } from "path";
import { config } from "dotenv";
const envCandidates = [".env.worktree", ".env"];
for (const filename of envCandidates) {
const path = resolve(process.cwd(), filename);
if (existsSync(path)) {
config({ path });
break;
}
}

View File

@@ -4,7 +4,6 @@
* Uses raw fetch so E2E tests have zero build-time coupling to the web app.
*/
import "./env";
import pg from "pg";
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? `http://localhost:${process.env.PORT ?? "8080"}`;
@@ -22,43 +21,39 @@ export class TestApiClient {
private createdIssueIds: string[] = [];
async login(email: string, name: string) {
// Step 1: Send verification code
const sendRes = await fetch(`${API_BASE}/auth/send-code`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email }),
});
if (!sendRes.ok) {
// Rate limited — code already sent recently, read it from DB
if (sendRes.status !== 429) {
throw new Error(`send-code failed: ${sendRes.status}`);
}
}
// Step 2: Read code from database
const client = new pg.Client(DATABASE_URL);
await client.connect();
try {
// Keep each E2E login isolated so previous test runs do not trip the
// per-email send-code rate limit.
await client.query("DELETE FROM verification_code WHERE email = $1", [email]);
// Step 1: Send verification code
const sendRes = await fetch(`${API_BASE}/auth/send-code`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email }),
});
if (!sendRes.ok) {
throw new Error(`send-code failed: ${sendRes.status}`);
}
// Step 2: Read code from database
const result = await client.query(
"SELECT code FROM verification_code WHERE email = $1 AND used = FALSE AND expires_at > now() ORDER BY created_at DESC LIMIT 1",
[email],
[email]
);
if (result.rows.length === 0) {
throw new Error(`No verification code found for ${email}`);
}
const code = result.rows[0].code;
// Step 3: Verify code to get JWT
const verifyRes = await fetch(`${API_BASE}/auth/verify-code`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, code: result.rows[0].code }),
body: JSON.stringify({ email, code }),
});
if (!verifyRes.ok) {
throw new Error(`verify-code failed: ${verifyRes.status}`);
}
const data = await verifyRes.json();
this.token = data.token;
// Update user name if needed
@@ -69,8 +64,6 @@ export class TestApiClient {
});
}
await client.query("DELETE FROM verification_code WHERE email = $1", [email]);
return data;
} finally {
await client.end();

View File

@@ -11,14 +11,11 @@ test.describe("Issues", () => {
});
test.afterEach(async () => {
if (api) {
await api.cleanup();
}
await api.cleanup();
});
test("issues page loads with board view", async ({ page }) => {
await api.createIssue("E2E Board View " + Date.now());
await page.reload();
await expect(page.locator("text=All Issues")).toBeVisible();
// Board columns should be visible
await expect(page.locator("text=Backlog")).toBeVisible();
@@ -26,36 +23,29 @@ test.describe("Issues", () => {
await expect(page.locator("text=In Progress")).toBeVisible();
});
test("can switch from board to list view", async ({ page }) => {
const title = "E2E List Switch " + Date.now();
await api.createIssue(title);
await page.reload();
await expect(page.locator("text=Backlog")).toBeVisible();
test("can switch between board and list view", async ({ page }) => {
await expect(page.locator("text=All Issues")).toBeVisible();
// Switch to list view
await page.click("text=List");
await expect(page.getByText(title)).toBeVisible();
await expect(page.locator("text=All Issues")).toBeVisible();
// Switch back to board view
await page.click("text=Board");
await expect(page.locator("text=Backlog")).toBeVisible();
});
test("can create a new issue", async ({ page }) => {
const newIssueButton = page.getByRole("button", { name: "New Issue" });
await expect(newIssueButton).toBeVisible();
await newIssueButton.click();
await page.click("text=New Issue");
const title = "E2E Created " + Date.now();
const titleInput = page.getByRole("textbox", { name: "Issue title" });
await expect(titleInput).toBeVisible();
await titleInput.fill(title);
await page.getByRole("button", { name: "Create Issue" }).click();
await page.fill('input[placeholder="Issue title..."]', title);
await page.click("text=Create");
await expect(page.getByText("Issue created")).toBeVisible({ timeout: 10000 });
await expect(
page.getByRole("region", { name: /Notifications/ }).getByText(title),
).toBeVisible();
await page.getByRole("button", { name: "View issue" }).click();
await page.waitForURL(/\/issues\/[\w-]+/);
await expect(page.locator("text=Properties")).toBeVisible();
// New issue should appear on the page
await expect(page.locator(`text=${title}`).first()).toBeVisible({
timeout: 10000,
});
});
test("can navigate to issue detail page", async ({ page }) => {
@@ -64,6 +54,7 @@ test.describe("Issues", () => {
// Reload to see the new issue
await page.reload();
await expect(page.locator("text=All Issues")).toBeVisible();
// Navigate to the issue detail
const issueLink = page.locator(`a[href="/issues/${issue.id}"]`);
@@ -80,15 +71,18 @@ test.describe("Issues", () => {
).toBeVisible();
});
test("can dismiss issue creation", async ({ page }) => {
await page.getByRole("button", { name: "New Issue" }).click();
test("can cancel issue creation", async ({ page }) => {
await page.click("text=New Issue");
const titleInput = page.getByRole("textbox", { name: "Issue title" });
await expect(titleInput).toBeVisible();
await expect(
page.locator('input[placeholder="Issue title..."]'),
).toBeVisible();
await page.keyboard.press("Escape");
await page.click("text=Cancel");
await expect(titleInput).not.toBeVisible();
await expect(page.getByRole("button", { name: "New Issue" })).toBeVisible();
await expect(
page.locator('input[placeholder="Issue title..."]'),
).not.toBeVisible();
await expect(page.locator("text=New Issue")).toBeVisible();
});
});

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

@@ -52,7 +52,6 @@ import type {
ReorderPinsRequest,
} from "../types";
import { type Logger, noopLogger } from "../logger";
import { createRequestId } from "../utils";
export interface ApiClientOptions {
logger?: Logger;
@@ -64,18 +63,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 +76,6 @@ export class ApiClient {
this.logger = options?.logger ?? noopLogger;
}
getBaseUrl(): string {
return this.baseUrl;
}
setToken(token: string | null) {
this.token = token;
}
@@ -101,20 +84,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;
}
@@ -135,7 +108,7 @@ export class ApiClient {
}
private async fetch<T>(path: string, init?: RequestInit): Promise<T> {
const rid = createRequestId();
const rid = crypto.randomUUID().slice(0, 8);
const start = Date.now();
const method = init?.method ?? "GET";
@@ -159,7 +132,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 +167,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 +234,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 +432,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[]> {
@@ -649,7 +610,7 @@ export class ApiClient {
if (opts?.issueId) formData.append("issue_id", opts.issueId);
if (opts?.commentId) formData.append("comment_id", opts.commentId);
const rid = createRequestId();
const rid = crypto.randomUUID().slice(0, 8);
const start = Date.now();
this.logger.info("→ POST /api/upload-file", { rid });

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,30 +63,16 @@ 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");
storage.removeItem("multica_workspace_id");
api.setToken(null);
api.setWorkspaceId(null);
onLogout?.();

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: () => {
@@ -172,7 +171,6 @@ export function useUpdateIssue() {
qc.invalidateQueries({
queryKey: issueKeys.children(wsId, ctx.parentId),
});
qc.invalidateQueries({ queryKey: issueKeys.childProgress(wsId) });
}
},
});
@@ -207,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) });
}
},
});
@@ -281,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) });
}
}
@@ -67,9 +66,6 @@ export function onIssueUpdated(
qc.setQueryData<Issue[]>(issueKeys.children(wsId, parentId), (old) =>
old?.map((c) => (c.id === issue.id ? { ...c, ...issue } : c)),
);
if (issue.status !== undefined || issue.parent_issue_id !== undefined) {
qc.invalidateQueries({ queryKey: issueKeys.childProgress(wsId) });
}
}
}
@@ -100,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,6 +1,5 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { api } from "../api";
import { useAuthStore } from "../auth";
import { pinKeys } from "./queries";
import { useWorkspaceId } from "../hooks";
import type { PinnedItem, PinnedItemType } from "../types";
@@ -8,17 +7,16 @@ import type { PinnedItem, PinnedItemType } from "../types";
export function useCreatePin() {
const qc = useQueryClient();
const wsId = useWorkspaceId();
const userId = useAuthStore((s) => s.user?.id ?? "");
return useMutation({
mutationFn: (data: { item_type: PinnedItemType; item_id: string }) =>
api.createPin(data),
onSuccess: (newPin) => {
qc.setQueryData<PinnedItem[]>(pinKeys.list(wsId, userId), (old) =>
qc.setQueryData<PinnedItem[]>(pinKeys.list(wsId), (old) =>
old ? [...old, newPin] : [newPin],
);
},
onSettled: () => {
qc.invalidateQueries({ queryKey: pinKeys.list(wsId, userId) });
qc.invalidateQueries({ queryKey: pinKeys.list(wsId) });
},
});
}
@@ -26,23 +24,22 @@ export function useCreatePin() {
export function useDeletePin() {
const qc = useQueryClient();
const wsId = useWorkspaceId();
const userId = useAuthStore((s) => s.user?.id ?? "");
return useMutation({
mutationFn: ({ itemType, itemId }: { itemType: PinnedItemType; itemId: string }) =>
api.deletePin(itemType, itemId),
onMutate: async ({ itemType, itemId }) => {
await qc.cancelQueries({ queryKey: pinKeys.list(wsId, userId) });
const prev = qc.getQueryData<PinnedItem[]>(pinKeys.list(wsId, userId));
qc.setQueryData<PinnedItem[]>(pinKeys.list(wsId, userId), (old) =>
await qc.cancelQueries({ queryKey: pinKeys.list(wsId) });
const prev = qc.getQueryData<PinnedItem[]>(pinKeys.list(wsId));
qc.setQueryData<PinnedItem[]>(pinKeys.list(wsId), (old) =>
old ? old.filter((p) => !(p.item_type === itemType && p.item_id === itemId)) : old,
);
return { prev };
},
onError: (_err, _vars, ctx) => {
if (ctx?.prev) qc.setQueryData(pinKeys.list(wsId, userId), ctx.prev);
if (ctx?.prev) qc.setQueryData(pinKeys.list(wsId), ctx.prev);
},
onSettled: () => {
qc.invalidateQueries({ queryKey: pinKeys.list(wsId, userId) });
qc.invalidateQueries({ queryKey: pinKeys.list(wsId) });
},
});
}
@@ -50,20 +47,19 @@ export function useDeletePin() {
export function useReorderPins() {
const qc = useQueryClient();
const wsId = useWorkspaceId();
const userId = useAuthStore((s) => s.user?.id ?? "");
return useMutation({
mutationFn: (reorderedPins: PinnedItem[]) => {
const items = reorderedPins.map((p, i) => ({ id: p.id, position: i + 1 }));
return api.reorderPins({ items });
},
onMutate: async (reorderedPins) => {
await qc.cancelQueries({ queryKey: pinKeys.list(wsId, userId) });
const prev = qc.getQueryData<PinnedItem[]>(pinKeys.list(wsId, userId));
qc.setQueryData<PinnedItem[]>(pinKeys.list(wsId, userId), reorderedPins);
await qc.cancelQueries({ queryKey: pinKeys.list(wsId) });
const prev = qc.getQueryData<PinnedItem[]>(pinKeys.list(wsId));
qc.setQueryData<PinnedItem[]>(pinKeys.list(wsId), reorderedPins);
return { prev };
},
onError: (_err, _vars, ctx) => {
if (ctx?.prev) qc.setQueryData(pinKeys.list(wsId, userId), ctx.prev);
if (ctx?.prev) qc.setQueryData(pinKeys.list(wsId), ctx.prev);
},
});
}

View File

@@ -2,13 +2,13 @@ import { queryOptions } from "@tanstack/react-query";
import { api } from "../api";
export const pinKeys = {
all: (wsId: string, userId: string) => ["pins", wsId, userId] as const,
list: (wsId: string, userId: string) => [...pinKeys.all(wsId, userId), "list"] as const,
all: (wsId: string) => ["pins", wsId] as const,
list: (wsId: string) => [...pinKeys.all(wsId), "list"] as const,
};
export function pinListOptions(wsId: string, userId: string) {
export function pinListOptions(wsId: string) {
return queryOptions({
queryKey: pinKeys.list(wsId, userId),
queryKey: pinKeys.list(wsId),
queryFn: () => api.listPins(),
});
}

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,
@@ -102,8 +102,7 @@ export function useRealtimeSync(
},
pin: () => {
const wsId = workspaceStore.getState().workspace?.id;
const userId = authStore.getState().user?.id;
if (wsId && userId) qc.invalidateQueries({ queryKey: pinKeys.all(wsId, userId) });
if (wsId) qc.invalidateQueries({ queryKey: pinKeys.all(wsId) });
},
daemon: () => {
const wsId = workspaceStore.getState().workspace?.id;
@@ -251,9 +250,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 +262,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 +270,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

@@ -1,33 +0,0 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { createRequestId, createSafeId, generateUUID } from "./utils";
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
describe("utils id helpers", () => {
it("generateUUID returns a valid UUID v4", () => {
const id = generateUUID();
expect(id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i);
});
it("createSafeId falls back when crypto.randomUUID is unavailable", () => {
vi.stubGlobal("crypto", {
getRandomValues: (arr: Uint8Array) => {
for (let i = 0; i < arr.length; i++) arr[i] = i;
return arr;
},
});
const id = createSafeId();
expect(id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i);
});
it("createRequestId defaults to length 8 and respects custom length", () => {
vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue("12345678-1234-4abc-8def-1234567890ab");
expect(createRequestId()).toBe("12345678");
expect(createRequestId(12)).toBe("123456781234");
});
});

View File

@@ -8,43 +8,3 @@ export function timeAgo(dateStr: string): string {
const days = Math.floor(hours / 24);
return `${days}d ago`;
}
export function generateUUID(): string {
const cryptoObj = globalThis.crypto;
if (!cryptoObj?.getRandomValues) {
throw new Error("Secure UUID generation requires crypto.getRandomValues");
}
const bytes = new Uint8Array(16);
cryptoObj.getRandomValues(bytes);
bytes[6] = ((bytes[6] ?? 0) & 0x0f) | 0x40; // version 4
bytes[8] = ((bytes[8] ?? 0) & 0x3f) | 0x80; // variant 1
const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
}
/**
* Generate an id that prefers crypto.randomUUID but falls back in non-secure contexts.
*/
export function createSafeId(): string {
const cryptoObj = globalThis.crypto;
if (cryptoObj?.randomUUID) {
try {
return cryptoObj.randomUUID();
} catch {
// Fall through to fallback.
}
}
return generateUUID();
}
/** Request id helper used for logs/tracing headers. */
export function createRequestId(length = 8): string {
return createSafeId().replace(/-/g, "").slice(0, length);
}

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

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

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;
}
// ---------------------------------------------------------------------------
@@ -92,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("");
@@ -102,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]);
@@ -162,7 +134,7 @@ export function LoginPage({
await useAuthStore.getState().sendCode(email);
setStep("code");
setCode("");
setCooldown(60);
setCooldown(10);
} catch (err) {
setError(
err instanceof Error
@@ -195,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();
@@ -207,7 +178,7 @@ export function LoginPage({
setLoading(false);
}
},
[email, onSuccess, cliCallback, lastWorkspaceId, onTokenObtained, qc],
[email, onSuccess, cliCallback, lastWorkspaceId, onTokenObtained],
);
const handleResend = async () => {
@@ -215,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",
@@ -223,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,
@@ -265,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}`;
};
@@ -424,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,498 +0,0 @@
"use client";
/**
* EditorBubbleMenu — floating formatting toolbar for text selection.
*
* Positioned with @floating-ui/react-dom (useFloating + autoUpdate) and
* portaled to document.body via createPortal. This escapes ALL overflow
* containers in the ancestor chain (Card overflow:hidden, scrollable
* containers, etc.) while autoUpdate monitors every ancestor scroll
* container to keep the menu anchored to the selection.
*
* Previously used Tiptap's <BubbleMenu> component, but that plugin:
* - only supports a single scrollTarget (misses nested scroll)
* - shows the element before computing position (flash on first show)
* - uses position:absolute which gets clipped by overflow:hidden
*/
import { useState, useEffect, useCallback, useRef, useMemo } from "react";
import { createPortal } from "react-dom";
import { useFloating, offset, flip, shift, autoUpdate } from "@floating-ui/react-dom";
import { getOverflowAncestors } from "@floating-ui/dom";
import type { Editor } from "@tiptap/core";
import { posToDOMRect } from "@tiptap/core";
import { NodeSelection } from "@tiptap/pm/state";
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 {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
} from "@multica/ui/components/ui/dropdown-menu";
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";
// ---------------------------------------------------------------------------
// Visibility logic
// ---------------------------------------------------------------------------
function shouldShowBubbleMenu(editor: Editor): boolean {
if (!editor.isEditable) return false;
// Don't check hasFocus() here — it's unreliable during transaction events.
// Focus loss is handled separately by the debounced blur handler.
const { state } = editor;
const { selection } = state;
if (selection.empty) return false;
const { from, to } = selection;
if (!state.doc.textBetween(from, to).length) return false;
if (selection instanceof NodeSelection) return false;
const $from = state.doc.resolve(from);
if ($from.parent.type.name === "codeBlock") return false;
return true;
}
/** Detect macOS for keyboard shortcut labels */
const isMac =
typeof navigator !== "undefined" && /Mac/.test(navigator.platform);
const mod = isMac ? "\u2318" : "Ctrl";
// ---------------------------------------------------------------------------
// 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@]+$/;
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 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() },
];
return (
<DropdownMenu onOpenChange={onOpenChange}>
<DropdownMenuTrigger
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" />
</DropdownMenuTrigger>
<DropdownMenuContent side="bottom" sideOffset={8} align="start" className="w-auto">
{items.map((item) => (
<DropdownMenuItem key={item.label} onClick={item.action} className="gap-2 text-xs">
<item.icon className="size-3.5" />
{item.label}
{item.active && <Check className="ml-auto size-3.5" />}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}
// ---------------------------------------------------------------------------
// List Dropdown
// ---------------------------------------------------------------------------
function ListDropdown({
editor,
onOpenChange,
}: {
editor: Editor;
onOpenChange: (open: boolean) => void;
}) {
const isBullet = editor.isActive("bulletList");
const isOrdered = editor.isActive("orderedList");
return (
<DropdownMenu onOpenChange={onOpenChange}>
<Tooltip>
<TooltipTrigger
render={
<DropdownMenuTrigger
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>
<DropdownMenuContent side="bottom" sideOffset={8} align="start" className="w-auto">
<DropdownMenuItem onClick={() => editor.chain().focus().toggleBulletList().run()} className="gap-2 text-xs">
<List className="size-3.5" />
Bullet List
{isBullet && <Check className="ml-auto size-3.5" />}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => editor.chain().focus().toggleOrderedList().run()} className="gap-2 text-xs">
<ListOrdered className="size-3.5" />
Ordered List
{isOrdered && <Check className="ml-auto size-3.5" />}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
// ---------------------------------------------------------------------------
// Main Bubble Menu — useFloating + portal to body
// ---------------------------------------------------------------------------
function EditorBubbleMenu({ editor }: { editor: Editor }) {
const [visible, setVisible] = useState(false);
const [mode, setMode] = useState<"toolbar" | "link-edit">("toolbar");
// Virtual reference that tracks the text selection.
// contextElement tells autoUpdate where to find scroll ancestors.
const virtualRef = useMemo(
() => ({
getBoundingClientRect: () => {
const { from, to } = editor.state.selection;
return posToDOMRect(editor.view, from, to);
},
contextElement: editor.view.dom,
}),
[editor],
);
const { refs, floatingStyles, isPositioned, update } = useFloating({
strategy: "fixed",
placement: "top",
open: visible,
middleware: [offset(8), flip(), shift({ padding: 8 })],
elements: { reference: virtualRef },
whileElementsMounted: autoUpdate,
});
// Show/hide based on selection state — no blur/focus handling.
useEffect(() => {
const onTransaction = () => {
const show = shouldShowBubbleMenu(editor);
setVisible(show);
// Must call update() manually — autoUpdate can't detect virtual
// reference movement (it's not a real DOM element).
if (show) update();
};
editor.on("transaction", onTransaction);
return () => { editor.off("transaction", onTransaction); };
}, [editor, update]);
// Close on outside click (mousedown not in editor and not in menu)
useEffect(() => {
if (!visible) return;
const handle = (e: MouseEvent) => {
const target = e.target as HTMLElement;
if (editor.view.dom.contains(target)) return;
if (target.closest(".bubble-menu") || target.closest(".bubble-menu-link-edit")) return;
setVisible(false);
};
document.addEventListener("mousedown", handle);
return () => document.removeEventListener("mousedown", handle);
}, [visible, editor]);
// Close on any ancestor scroll or window resize
useEffect(() => {
if (!visible) return;
const close = () => {
setVisible(false);
};
const ancestors = getOverflowAncestors(editor.view.dom);
ancestors.forEach((el) => el.addEventListener("scroll", close, { passive: true }));
window.addEventListener("resize", close);
return () => {
ancestors.forEach((el) => el.removeEventListener("scroll", close));
window.removeEventListener("resize", close);
};
}, [visible, editor]);
// Reset mode on selection change
useEffect(() => {
const handler = () => setMode("toolbar");
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],
);
const openLinkEdit = useCallback(() => setMode("link-edit"), []);
const closeLinkEdit = useCallback(() => {
setMode("toolbar");
editor.commands.focus();
}, [editor]);
return createPortal(
<div
ref={refs.setFloating}
style={{
...floatingStyles,
zIndex: 50,
// display:none when hidden — no residual animations from children.
// Also hide until Floating UI has computed position (isPositioned)
// to avoid a flash at top:0 left:0.
display: visible && isPositioned ? undefined : "none",
}}
onMouseDown={(e) => e.preventDefault()}
>
{mode === "link-edit" ? (
<LinkEditBar editor={editor} onClose={closeLinkEdit} />
) : (
<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={openLinkEdit}
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>
)}
</div>,
document.body,
);
}
export { EditorBubbleMenu };

View File

@@ -457,50 +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 preview card — portaled to body with position: fixed to escape
* overflow:hidden containers (Card component, scrollable editors, etc.). */
.link-preview-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

@@ -38,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 { EditorLinkPreview } from "./link-preview";
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
// ---------------------------------------------------------------------------
@@ -75,8 +61,6 @@ interface ContentEditorRef {
clearContent: () => void;
focus: () => void;
uploadFile: (file: File) => void;
/** True when file uploads are still in progress. */
hasActiveUploads: () => boolean;
}
// ---------------------------------------------------------------------------
@@ -129,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: () => {
@@ -146,25 +130,33 @@ const ContentEditor = forwardRef<ContentEditorRef, ContentEditorProps>(
const href = link?.getAttribute("href");
if (!href || href.startsWith("mention://")) return false;
if (editable) {
// Edit mode: don't open link on click. ProseMirror's
// mousedown/mouseup cycle (already completed by the time
// this click handler runs) places the cursor on the link.
// LinkBubbleMenu detects cursor-on-link and shows the
// preview card with Open / Copy / Edit actions.
return false;
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;
}
// Readonly mode: open immediately.
event.preventDefault();
if (href.startsWith("/")) {
window.dispatchEvent(
new CustomEvent("multica:navigate", { detail: { path: href } }),
);
} else {
window.open(href, "_blank", "noopener,noreferrer");
if (event.metaKey || event.ctrlKey) {
// Edit mode: Cmd/Ctrl+click opens link
event.preventDefault();
openLink();
return true;
}
return true;
return false;
},
},
attributes: {
@@ -199,7 +191,7 @@ const ContentEditor = forwardRef<ContentEditorRef, ContentEditorProps>(
}, [editor, editable, defaultValue]);
useImperativeHandle(ref, () => ({
getMarkdown: () => stripBlobUrls(editor?.getMarkdown() ?? ""),
getMarkdown: () => editor?.getMarkdown() ?? "",
clearContent: () => {
editor?.commands.clearContent();
},
@@ -211,15 +203,6 @@ 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;
},
}));
if (!editor) return null;
@@ -227,12 +210,6 @@ const ContentEditor = forwardRef<ContentEditorRef, ContentEditorProps>(
return (
<div className="relative min-h-full">
<EditorContent editor={editor} />
{editable && (
<>
<EditorBubbleMenu editor={editor} />
<EditorLinkPreview editor={editor} />
</>
)}
</div>
);
},

View File

@@ -1,7 +1,6 @@
import { Extension } from "@tiptap/core";
import { Plugin, PluginKey } from "@tiptap/pm/state";
import type { UploadResult } from "@multica/core/hooks/use-file-upload";
import { createSafeId } from "@multica/core/utils";
/** Find and remove a fileCard node by uploadId. */
@@ -110,7 +109,7 @@ export async function uploadAndInsertFile(
}
} else {
// Non-image: insert skeleton fileCard → upload → finalize with real URL
const uploadId = createSafeId();
const uploadId = crypto.randomUUID();
const cardAttrs = { filename: file.name, href: "", fileSize: file.size, uploading: true, uploadId };
const insertContent = { type: "fileCard", attrs: cardAttrs };
if (pos !== undefined) {

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

@@ -53,7 +53,7 @@ function IssueMention({
}) {
const wsId = useWorkspaceId();
const { data: issues = [] } = useQuery(issueListOptions(wsId));
const { push, openInNewTab } = useNavigation();
const { openInNewTab } = useNavigation();
const issue = issues.find((i) => i.id === issueId);
const issuePath = `/issues/${issueId}`;
@@ -61,13 +61,11 @@ function IssueMention({
const handleClick = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (e.metaKey || e.ctrlKey || e.shiftKey) {
if (openInNewTab) {
openInNewTab(issuePath, tabTitle);
}
return;
if (openInNewTab) {
openInNewTab(issuePath, tabTitle);
} else {
window.open(issuePath, "_blank", "noopener,noreferrer");
}
push(issuePath);
};
const cardClass =

View File

@@ -1,307 +0,0 @@
"use client";
/**
* Link preview system — floating card for link inspection.
*
* Two entry points, same UI:
* - EditorLinkPreview: editable ContentEditor (cursor on link)
* - ReadonlyLinkWrapper: ReadonlyContent (click on link)
*
* Both use @floating-ui/react-dom (useFloating + autoUpdate) portaled
* to document.body. This escapes ALL overflow:hidden ancestors while
* autoUpdate keeps the card anchored across any ancestor scroll.
*/
import {
useState,
useEffect,
useCallback,
useRef,
type ReactNode,
} from "react";
import { createPortal } from "react-dom";
import { useFloating, offset, flip, shift, autoUpdate } from "@floating-ui/react-dom";
import { getOverflowAncestors } from "@floating-ui/dom";
import { ExternalLink, Copy } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@multica/ui/components/ui/button";
import type { Editor } from "@tiptap/core";
// ---------------------------------------------------------------------------
// 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)}`;
}
}
// ---------------------------------------------------------------------------
// LinkPreviewCard — pure UI
// ---------------------------------------------------------------------------
function LinkPreviewCard({
href,
onMouseDown,
}: {
href: string;
onMouseDown?: (e: React.MouseEvent) => void;
}) {
const handleCopy = useCallback(
async (e: React.MouseEvent) => {
e.stopPropagation();
try {
await navigator.clipboard.writeText(href);
toast.success("Link copied");
} catch {
toast.error("Failed to copy");
}
},
[href],
);
const handleOpen = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
openLink(href);
},
[href],
);
return (
<div className="link-preview-card" onMouseDown={onMouseDown}>
<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>
);
}
// ---------------------------------------------------------------------------
// Shared hooks
// ---------------------------------------------------------------------------
function useCloseOnOutsideClick(active: boolean, close: () => void) {
useEffect(() => {
if (!active) return;
const handle = (e: MouseEvent) => {
if ((e.target as HTMLElement).closest(".link-preview-card")) return;
close();
};
const t = setTimeout(() => document.addEventListener("mousedown", handle), 0);
return () => {
clearTimeout(t);
document.removeEventListener("mousedown", handle);
};
}, [active, close]);
}
function useCloseOnEscape(active: boolean, close: () => void) {
useEffect(() => {
if (!active) return;
const handle = (e: KeyboardEvent) => {
if (e.key === "Escape") close();
};
document.addEventListener("keydown", handle);
return () => document.removeEventListener("keydown", handle);
}, [active, close]);
}
// ---------------------------------------------------------------------------
// EditorLinkPreview — for editable ContentEditor
// ---------------------------------------------------------------------------
function EditorLinkPreview({ editor }: { editor: Editor }) {
const [visible, setVisible] = useState(false);
const [href, setHref] = useState("");
const close = useCallback(() => setVisible(false), []);
const virtualRef = useRef({
getBoundingClientRect: () => new DOMRect(),
contextElement: editor.view.dom,
});
const { refs, floatingStyles, isPositioned, update } = useFloating({
strategy: "fixed",
placement: "bottom",
open: visible,
middleware: [offset(4), flip(), shift({ padding: 8 })],
elements: { reference: virtualRef.current },
whileElementsMounted: autoUpdate,
});
useEffect(() => {
const check = () => {
if (!editor.isEditable) {
setVisible(false);
return;
}
if (!editor.state.selection.empty || !editor.isActive("link")) {
setVisible(false);
return;
}
const linkHref = (editor.getAttributes("link").href as string) || "";
if (!linkHref) {
setVisible(false);
return;
}
const coords = editor.view.coordsAtPos(editor.state.selection.from);
virtualRef.current = {
getBoundingClientRect: () =>
new DOMRect(coords.left, coords.top, 0, coords.bottom - coords.top),
contextElement: editor.view.dom,
};
setHref(linkHref);
setVisible(true);
update();
};
editor.on("selectionUpdate", check);
return () => { editor.off("selectionUpdate", check); };
}, [editor, update]);
// Close on any ancestor scroll or window resize
useEffect(() => {
if (!visible) return;
const close = () => {
setVisible(false);
};
const ancestors = getOverflowAncestors(editor.view.dom);
ancestors.forEach((el) => el.addEventListener("scroll", close, { passive: true }));
window.addEventListener("resize", close);
return () => {
ancestors.forEach((el) => el.removeEventListener("scroll", close));
window.removeEventListener("resize", close);
};
}, [visible, editor]);
useCloseOnOutsideClick(visible, close);
useCloseOnEscape(visible, close);
return createPortal(
<div
ref={refs.setFloating}
style={{
...floatingStyles,
zIndex: 50,
display: visible && isPositioned ? undefined : "none",
}}
>
<LinkPreviewCard href={href} onMouseDown={(e) => e.preventDefault()} />
</div>,
document.body,
);
}
// ---------------------------------------------------------------------------
// ReadonlyLinkWrapper — for ReadonlyContent (react-markdown)
// ---------------------------------------------------------------------------
function ReadonlyLinkWrapper({
href,
children,
}: {
href: string;
children: ReactNode;
}) {
const [open, setOpen] = useState(false);
const anchorRef = useRef<HTMLAnchorElement>(null);
const close = useCallback(() => setOpen(false), []);
const { refs, floatingStyles } = useFloating({
strategy: "fixed",
placement: "bottom-start",
middleware: [offset(4), flip(), shift({ padding: 8 })],
elements: { reference: anchorRef.current },
open,
});
const toggle = useCallback(
(e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
setOpen((v) => !v);
},
[],
);
// Close on any ancestor scroll
useEffect(() => {
if (!open || !anchorRef.current) return;
const hide = () => setOpen(false);
const ancestors = getOverflowAncestors(anchorRef.current);
ancestors.forEach((el) => el.addEventListener("scroll", hide, { passive: true }));
return () => {
ancestors.forEach((el) => el.removeEventListener("scroll", hide));
};
}, [open]);
useCloseOnOutsideClick(open, close);
useCloseOnEscape(open, close);
return (
<>
<a
ref={anchorRef}
href={href}
onClick={toggle}
role="button"
aria-expanded={open}
>
{children}
</a>
{open &&
createPortal(
<div ref={refs.setFloating} style={{ ...floatingStyles, zIndex: 50 }}>
<LinkPreviewCard href={href} />
</div>,
document.body,
)}
</>
);
}
export { EditorLinkPreview, ReadonlyLinkWrapper, openLink };

View File

@@ -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 { ReadonlyLinkWrapper } from "./link-preview";
import { preprocessMarkdown } from "./utils/preprocess";
import "./content-editor.css";
@@ -87,7 +86,7 @@ function urlTransform(url: string): string {
// ---------------------------------------------------------------------------
function IssueMentionLink({ issueId, label }: { issueId: string; label?: string }) {
const { push, openInNewTab } = useNavigation();
const { openInNewTab } = useNavigation();
const path = `/issues/${issueId}`;
return (
<span
@@ -95,13 +94,11 @@ function IssueMentionLink({ issueId, label }: { issueId: string; label?: string
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
if (e.metaKey || e.ctrlKey || e.shiftKey) {
if (openInNewTab) {
openInNewTab(path, label);
}
return;
if (openInNewTab) {
openInNewTab(path, label);
} else {
window.open(path, "_blank", "noopener,noreferrer");
}
push(path);
}}
>
<IssueMentionCard issueId={issueId} fallbackLabel={label} />
@@ -110,7 +107,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,9 +126,18 @@ const components: Partial<Components> = {
return <span className="mention">{children}</span>;
}
// Regular links — show preview card on click
if (!href) return <a>{children}</a>;
return <ReadonlyLinkWrapper href={href}>{children}</ReadonlyLinkWrapper>;
// Regular links — open in new tab
return (
<a
href={href}
onClick={(e) => {
e.preventDefault();
if (href) window.open(href, "_blank", "noopener,noreferrer");
}}
>
{children}
</a>
);
},
// Images — centered with toolbar + lightbox (matches Tiptap ImageView NodeView)

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

@@ -94,10 +94,7 @@ vi.mock("../../editor", () => ({
ReadonlyContent: ({ content }: { content: string }) => (
<div data-testid="readonly-content">{content}</div>
),
ContentEditor: forwardRef(function MockContentEditor(
{ defaultValue, onUpdate, placeholder }: any,
ref: any,
) {
ContentEditor: forwardRef(({ defaultValue, onUpdate, placeholder }: any, ref: any) => {
const valueRef = useRef(defaultValue || "");
const [value, setValue] = useState(defaultValue || "");
useImperativeHandle(ref, () => ({
@@ -119,10 +116,7 @@ vi.mock("../../editor", () => ({
/>
);
}),
TitleEditor: forwardRef(function MockTitleEditor(
{ defaultValue, placeholder, onBlur, onChange }: any,
ref: any,
) {
TitleEditor: forwardRef(({ defaultValue, placeholder, onBlur, onChange }: any, ref: any) => {
const valueRef = useRef(defaultValue || "");
const [value, setValue] = useState(defaultValue || "");
useImperativeHandle(ref, () => ({

View File

@@ -195,7 +195,6 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
const id = issueId;
const router = useNavigation();
const user = useAuthStore((s) => s.user);
const userId = useAuthStore((s) => s.user?.id);
const workspace = useWorkspaceStore((s) => s.workspace);
// Issue navigation — read from TQ list cache
@@ -265,10 +264,7 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
const { data: usage } = useQuery(issueUsageOptions(id));
// Pinned state
const { data: pinnedItems = [] } = useQuery({
...pinListOptions(wsId, userId ?? ""),
enabled: !!userId,
});
const { data: pinnedItems = [] } = useQuery(pinListOptions(wsId));
const isPinned = pinnedItems.some((p) => p.item_type === "issue" && p.item_id === id);
const createPin = useCreatePin();
const deletePin = useDeletePin();
@@ -505,7 +501,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>
}
/>

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