Compare commits

..

1 Commits

Author SHA1 Message Date
Jiayuan
31c2476a7b feat(ios): add MVP iOS app with issue management and agent log viewing
SwiftUI app (iOS 17+) with zero third-party dependencies:
- Passwordless email authentication with Keychain token storage
- Workspace selection and multi-workspace support
- Issue list grouped by status with search and filtering
- Issue detail with inline status/priority/assignee editing
- Comments with threaded display and compose
- Agent task runs history and real-time execution log streaming
- WebSocket integration for live updates
2026-04-02 23:50:28 +08:00
242 changed files with 6631 additions and 16455 deletions

View File

@@ -29,7 +29,6 @@ RESEND_FROM_EMAIL=noreply@multica.ai
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
GOOGLE_REDIRECT_URI=http://localhost:3000/auth/callback
NEXT_PUBLIC_GOOGLE_CLIENT_ID=
# S3 / CloudFront
S3_BUCKET=

View File

@@ -1,34 +0,0 @@
## What
<!-- What does this PR do? Keep it to 1-3 sentences. -->
## Why
<!-- Why is this change needed? Link the related issue. -->
Closes #<!-- issue number -->
## Type of Change
- [ ] Bug fix
- [ ] New feature
- [ ] Refactor / code improvement
- [ ] Documentation
- [ ] CI / infrastructure
- [ ] Other (describe below)
## How to Test
<!-- How can a reviewer verify this works? Steps, commands, or screenshots. -->
## Checklist
- [ ] `make check` passes (typecheck, unit tests, Go tests, E2E)
- [ ] Changes follow existing code patterns and conventions
- [ ] No unrelated changes included
## AI Disclosure (optional)
<!-- 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. -->

View File

@@ -57,7 +57,7 @@ jobs:
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: "1.26.1"
go-version: "1.24"
cache-dependency-path: server/go.sum
- name: Build

1
.gitignore vendored
View File

@@ -36,7 +36,6 @@ apps/web/test-results/
# local settings
.claude/
.tool-versions
# feature tracking
_features/

View File

@@ -24,94 +24,65 @@ The frontend uses a **feature-based architecture** with four layers:
```
apps/web/
├── app/ # Routing layer (thin shells — import from features/)
├── core/ # Headless business logic (TanStack Query, zero JSX, zero react-dom)
├── features/ # UI business components, organized by domain
├── features/ # Business logic, organized by domain
├── shared/ # Cross-feature utilities (api client, types, logger)
```
**`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/`.
**`core/`** — Headless business logic. Query key factories, `queryOptions`, mutation hooks, WS cache updaters. **No JSX, no react-dom.** Designed for future extraction to `packages/core/` in a monorepo.
| Module | Purpose | Key exports |
|---|---|---|
| `core/issues/` | Issue queries, mutations, WS updaters | `issueListOptions`, `useUpdateIssue`, `onIssueUpdated` |
| `core/inbox/` | Inbox queries, mutations, WS updaters | `inboxListOptions`, `useMarkInboxRead` |
| `core/workspace/` | Member/agent/skill queries, workspace mutations | `memberListOptions`, `agentListOptions` |
| `core/runtimes/` | Runtime queries | `runtimeListOptions` |
| `core/query-client.ts` | QueryClient factory | `createQueryClient` |
| `core/provider.tsx` | QueryClientProvider wrapper | `QueryProvider` |
| `core/hooks.ts` | Shared hooks | `useWorkspaceId` |
**`features/`** — Domain modules with UI components, client-only stores, and config:
**`features/`** — Domain modules, each with its own components, hooks, stores, and config:
| Feature | Purpose | Exports |
|---|---|---|
| `features/auth/` | Authentication state | `useAuthStore`, `AuthInitializer` |
| `features/workspace/` | Workspace identity + UI | `useWorkspaceStore` (client-only: workspace/workspaces), `useActorName` |
| `features/issues/` | Issue UI components + client state | `useIssueStore` (client-only: activeIssueId), icons, pickers, config |
| `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 (will migrate to `core/` in Phase 5):
**`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
- **TanStack Query** for all server state — issues, inbox, members, agents, skills, runtimes. Query definitions live in `core/<domain>/queries.ts`, mutations in `core/<domain>/mutations.ts`.
- **Zustand** for client-only state — UI selections (`activeIssueId`), view filters, modal state, workspace identity, navigation. No API calls in Zustand stores.
- **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.
**TanStack Query conventions:**
- `staleTime: Infinity` — WS events handle cache freshness, no polling or refetch-on-focus.
- WS events trigger `queryClient.invalidateQueries()` (preferred) or `queryClient.setQueryData()` for granular updates.
- All workspace-scoped query keys include `wsId` — workspace switch automatically uses new cache.
- Mutations use `onMutate` for optimistic updates + `onError` for rollback + `onSettled` for invalidation.
- Components access QueryClient via `useQueryClient()` hook. Non-React contexts (e.g. Tiptap plugin callbacks) receive QueryClient via closure from the parent React component — never use module-level singletons.
**Zustand store conventions:**
- Stores hold only client state (UI selections, persisted preferences). Zero `api.*` calls in stores.
- Import via `useAuthStore(selector)` or `useWorkspaceStore(selector)`.
**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.
- `useWorkspaceStore` manages workspace identity (`workspace`, `workspaces`, `api.setWorkspaceId`, localStorage). Server data (members, agents, skills) is in TanStack Query, not the store.
- 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/`) and `@core/` alias (maps to `apps/web/core/`):
Use `@/` alias (maps to `apps/web/`):
```typescript
// Core (headless business logic)
import { issueListOptions, issueKeys } from "@core/issues/queries";
import { useUpdateIssue, useCreateIssue } from "@core/issues/mutations";
import { memberListOptions, agentListOptions } from "@core/workspace/queries";
import { useWorkspaceId } from "@core/hooks";
// Shared (api client, types)
import { api } from "@/shared/api";
import type { Issue } from "@/shared/types";
// Features (UI components, client stores)
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 `@/`. For core modules, use `@core/`.
Within a feature, use relative imports. Between features or to shared, use `@/`.
### Data Flow
```
Browser → useQuery (core/) → ApiClient (shared/api) → REST API (Chi handlers) → sqlc queries → PostgreSQL
Browser ← useQuery cache ← invalidateQueries ← WS event handlers ← WSClient ← Hub.Broadcast()
Browser → ApiClient (shared/api) → REST API (Chi handlers) → sqlc queries → PostgreSQL
Browser ← WSClient (shared/api) ← WebSocket ← Hub.Broadcast() ← Handlers/TaskService
```
Mutations: `useMutation (core/)` → optimistic cache update → API call → onSettled invalidation.
WS events: `use-realtime-sync.ts``queryClient.invalidateQueries()` for most events, `setQueryData()` for granular issue/inbox updates.
### Backend Structure (`server/`)
- **Entry points** (`cmd/`): `server` (HTTP API), `multica` (CLI — daemon, agent management, config), `migrate`
@@ -178,7 +149,7 @@ make db-down # Stop shared PostgreSQL
### CI Requirements
CI runs on Node 22 and Go 1.26.1 with a `pgvector/pgvector:pg17` PostgreSQL service. See `.github/workflows/ci.yml`.
CI runs on Node 22 and Go 1.24 with a `pgvector/pgvector:pg17` PostgreSQL service. See `.github/workflows/ci.yml`.
### Worktree Support
@@ -206,7 +177,7 @@ make start-worktree # Start using .env.worktree
- 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. Server data goes through TanStack Query (`core/`), client-only shared state through Zustand, React Context only for connection lifecycle.
- 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.

View File

@@ -8,7 +8,7 @@ The `multica` CLI connects your local machine to Multica. It handles authenticat
```bash
brew tap multica-ai/tap
brew install multica
brew install multica-cli
```
### Build from Source
@@ -289,23 +289,6 @@ multica issue comment add <issue-id> --parent <comment-id> --content "Thanks!"
multica issue comment delete <comment-id>
```
### Execution History
```bash
# List all execution runs for an issue
multica issue runs <issue-id>
multica issue runs <issue-id> --output json
# View messages for a specific execution run
multica issue run-messages <task-id>
multica issue run-messages <task-id> --output json
# Incremental fetch (only messages after a given sequence number)
multica issue run-messages <task-id> --since 42 --output json
```
The `runs` command shows all past and current executions for an issue, including running tasks. The `run-messages` command shows the detailed message log (tool calls, thinking, text, errors) for a single run. Use `--since` for efficient polling of in-progress runs.
## Configuration
### View Config

View File

@@ -1,171 +0,0 @@
# Multica CLI — Installation Guide for AI Agents
> **Audience:** This document is designed for AI agents (Claude Code, Codex, etc.) to read and execute step-by-step. Each step includes the exact shell commands to run, expected outcomes, and error handling.
## One-Line Instruction for Users
Give this instruction to your AI agent:
```
Fetch https://github.com/multica-ai/multica/blob/main/CLI_INSTALL.md and follow the instructions to install Multica CLI, log in, and start the daemon on this machine.
```
---
## Step 1: Check if Multica CLI is already installed
Run:
```bash
multica version
```
- **If it prints a version string** (e.g. `multica v0.x.x`): skip to **Step 3**.
- **If command not found**: continue to **Step 2**.
---
## Step 2: Install the Multica CLI
### Option A: Homebrew (preferred)
Check if Homebrew is available:
```bash
which brew
```
If `brew` is found, install via Homebrew:
```bash
brew tap multica-ai/tap && brew install multica
```
Then verify:
```bash
multica version
```
If the version prints successfully, skip to **Step 3**.
### Option B: Download from GitHub Releases (no Homebrew)
If Homebrew is not available, download the binary directly.
Detect OS and architecture, then download the correct archive:
```bash
OS=$(uname -s | tr '[:upper:]' '[:lower:]') # "darwin" or "linux"
ARCH=$(uname -m) # "x86_64" or "arm64"
# Normalize architecture name
if [ "$ARCH" = "x86_64" ]; then
ARCH="amd64"
fi
# Get the latest release tag from GitHub
LATEST=$(curl -sI https://github.com/multica-ai/multica/releases/latest | grep -i '^location:' | sed 's/.*tag\///' | tr -d '\r\n')
# Download and extract
curl -sL "https://github.com/multica-ai/multica/releases/download/${LATEST}/multica_${OS}_${ARCH}.tar.gz" -o /tmp/multica.tar.gz
tar -xzf /tmp/multica.tar.gz -C /tmp multica
sudo mv /tmp/multica /usr/local/bin/multica
rm /tmp/multica.tar.gz
```
Verify:
```bash
multica version
```
**If this fails:**
- Check that `/usr/local/bin` is in `$PATH`.
- 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`.
---
## Step 3: Log in
Run:
```bash
multica login
```
**Important:** This command opens a browser window for OAuth authentication. Tell the user:
> "A browser window will open for Multica login. Please complete the authentication in your browser, then come back here."
Wait for the command to complete. It will automatically discover and watch all workspaces the user belongs to.
Verify:
```bash
multica auth status
```
Expected output should show the authenticated user and server URL.
**If login fails:**
- If no browser is available (headless environment), the user can generate a Personal Access Token at `https://app.multica.ai/settings` and run: `multica login --token`
- If the server URL needs to be customized: `multica config set server_url <url>` before logging in.
---
## Step 4: Start the daemon
First, check if the daemon is already running:
```bash
multica daemon status
```
- **If status is "running"**: skip to **Step 5**.
- **If status is "stopped"**: start it:
```bash
multica daemon start
```
Wait 3 seconds, then verify:
```bash
multica daemon status
```
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` or `codex`) is installed and on the `$PATH`.
---
## Step 5: Verify everything is working
Run:
```bash
multica daemon status
```
Confirm:
1. Status is `running`
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: [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`."
---
## Summary
When all steps are complete, inform the user:
> "Multica CLI is installed and the daemon is running. Agents in your workspaces can now execute tasks on this machine. You can manage workspaces with `multica workspace list` and view daemon logs with `multica daemon logs -f`."

221
LICENSE
View File

@@ -1,44 +1,199 @@
# Open Source License
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
Multica is licensed under a modified version of the Apache License 2.0, with the following additional conditions:
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Multica may be utilized commercially, including as a backend service for
other applications or as a task management platform for enterprises.
Should the conditions below be met, a commercial license must be obtained
from the producer:
1. Definitions.
a. Hosted or embedded service: Unless explicitly authorized by Multica
in writing, you may not use the Multica source code to provide a
hosted service to third parties, or embed Multica as a component of
a product or service that is sold, licensed, or otherwise
commercially distributed to third parties.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
- This restriction applies to offering Multica (in whole or
substantial part) as a SaaS platform, a managed service, or as
an integrated component within another commercial offering.
- Internal use within a single organization (including multiple
workspaces) does not require a commercial license.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
b. LOGO and copyright information: In the process of using Multica's
frontend, you may not remove or modify the LOGO or copyright
information in the Multica console or applications. This restriction
is inapplicable to uses of Multica that do not involve its frontend.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
- Frontend Definition: For the purposes of this license, the
"frontend" of Multica includes all components located in the
`apps/web/` directory when running Multica from the raw source
code, or the "web" image when running Multica with Docker.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
2. As a contributor, you should agree that:
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
a. The producer can adjust the open-source agreement to be more strict
or relaxed as deemed necessary.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
b. Your contributed code may be used for commercial purposes, including
but not limited to its cloud business operations.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
Apart from the specific conditions mentioned above, all other rights and
restrictions follow the Apache License 2.0. Detailed information about the
Apache License 2.0 can be found at http://www.apache.org/licenses/LICENSE-2.0.
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
© 2025 Multica, Inc.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to the Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by the Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding any notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. Please also get an
"Implied Patent License" from your patent counsel.
Copyright 2025 Multica
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

View File

@@ -69,12 +69,7 @@ stop:
@echo "Stopping services..."
@-lsof -ti:$(PORT) | xargs kill -9 2>/dev/null
@-lsof -ti:$(FRONTEND_PORT) | xargs kill -9 2>/dev/null
@case "$(DATABASE_URL)" in \
""|*@localhost:*|*@localhost/*|*@127.0.0.1:*|*@127.0.0.1/*|*@\[::1\]:*|*@\[::1\]/*) \
echo "✓ App processes stopped. Shared PostgreSQL is still running on localhost:$(POSTGRES_PORT)." ;; \
*) \
echo "✓ App processes stopped. Remote PostgreSQL was not affected." ;; \
esac
@echo "✓ App processes stopped. Shared PostgreSQL is still running on localhost:5432."
# Full verification: typecheck + unit tests + Go tests + E2E
check:
@@ -103,12 +98,8 @@ check-main:
@ENV_FILE=$(MAIN_ENV_FILE) bash scripts/check.sh
setup-worktree:
@if [ ! -f "$(WORKTREE_ENV_FILE)" ]; then \
echo "==> Generating $(WORKTREE_ENV_FILE) with unique ports..."; \
bash scripts/init-worktree-env.sh $(WORKTREE_ENV_FILE); \
else \
echo "==> Using existing $(WORKTREE_ENV_FILE)"; \
fi
@echo "==> Generating $(WORKTREE_ENV_FILE) with unique ports..."
@FORCE=1 bash scripts/init-worktree-env.sh $(WORKTREE_ENV_FILE)
@$(MAKE) setup ENV_FILE=$(WORKTREE_ENV_FILE)
start-worktree:
@@ -143,12 +134,10 @@ COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown)
build:
cd server && go build -o bin/server ./cmd/server
cd server && go build -ldflags "-X main.version=$(VERSION) -X main.commit=$(COMMIT)" -o bin/multica ./cmd/multica
cd server && go build -o bin/migrate ./cmd/migrate
test:
$(REQUIRE_ENV)
@bash scripts/ensure-postgres.sh "$(ENV_FILE)"
cd server && go run ./cmd/migrate up
cd server && go test ./...
# Database

126
README.md
View File

@@ -1,57 +1,28 @@
<p align="center">
<img src="docs/assets/banner.jpg" alt="Multica — humans and agents, side by side" width="100%">
</p>
<div align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="docs/assets/logo-dark.svg">
<source media="(prefers-color-scheme: light)" srcset="docs/assets/logo-light.svg">
<img alt="Multica" src="docs/assets/logo-light.svg" width="50">
</picture>
# Multica
**Your next 10 hires won't be human.**
AI-native project management — like Linear, but with AI agents as first-class team members.
Open-source platform that turns coding agents into real teammates.<br/>
Assign tasks, track progress, compound skills — manage your human + agent workforce in one place.
[![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) · [Self-Hosting](SELF_HOSTING.md) · [Contributing](CONTRIBUTING.md)
**English | [简体中文](README.zh-CN.md)**
</div>
## What is Multica?
Multica turns coding agents into real teammates. Assign issues to an agent like you'd assign to a colleague — they'll pick up the work, write code, report blockers, and update statuses autonomously.
No more copy-pasting prompts. No more babysitting runs. Your agents show up on the board, participate in conversations, and compound reusable skills over time. Works with **Claude Code** and **Codex**.
<p align="center">
<img src="docs/assets/hero-screenshot.png" alt="Multica board view" width="800">
</p>
Multica lets you manage tasks and collaborate with AI agents the same way you work with human teammates. Agents can be assigned issues, post comments, update statuses, and execute work autonomously on your local machine.
## Features
- **Agents as Teammates** — assign to an agent like you'd assign to a colleague. They have profiles, show up on the board, post comments, create issues, and report blockers proactively.
- **Autonomous Execution** — set it and forget it. Full task lifecycle management (enqueue, claim, start, complete/fail) with real-time progress streaming via WebSocket.
- **Reusable Skills** — every solution becomes a reusable skill for the whole team. Deployments, migrations, code reviews — skills compound your team's capabilities over time.
- **Unified Runtimes** — one dashboard for all your compute. Local daemons and cloud runtimes, auto-detection of available CLIs, real-time monitoring.
- **Multi-Workspace** — organize work across teams with workspace-level isolation. Each workspace has its own agents, issues, and settings.
- **AI agents as teammates** — assign issues to agents, mention them in comments, and let them do the work
- **Local agent runtime** — agents run on your machine using Claude Code or Codex, with full access to your codebase
- **Real-time collaboration** — WebSocket-powered live updates across the board
- **Multi-workspace** — organize work across teams with workspace-level isolation
- **Familiar UX** — if you've used Linear, you'll feel right at home
## Getting Started
### Multica Cloud
### Use Multica Cloud
The fastest way to get started — no setup required: **[multica.ai](https://multica.ai)**
The fastest way to get started: [multica.ai](https://multica.ai)
### Self-Host with Docker
### Self-Host
Run Multica on your own infrastructure. See the [Self-Hosting Guide](SELF_HOSTING.md) for full instructions.
Quick start with Docker:
```bash
git clone https://github.com/multica-ai/multica.git
@@ -59,25 +30,18 @@ cd multica
cp .env.example .env
# Edit .env — at minimum, change JWT_SECRET
docker compose up -d # Start PostgreSQL
cd server && go run ./cmd/migrate up && cd .. # Run migrations
make start # Start the app
```
# Start PostgreSQL
docker compose up -d
See the [Self-Hosting Guide](SELF_HOSTING.md) for full instructions.
# Build and run the backend
cd server && go run ./cmd/migrate up && cd ..
make start
```
## CLI
The `multica` CLI connects your local machine to Multica — authenticate, manage workspaces, and run the agent daemon.
**Option A — paste this to your coding agent (Claude Code, Codex, etc.):**
```
Fetch https://github.com/multica-ai/multica/blob/main/CLI_INSTALL.md and follow the instructions to install Multica CLI, log in, and start the daemon on this machine.
```
**Option B — install manually:**
```bash
# Install
brew tap multica-ai/tap
@@ -92,35 +56,6 @@ The daemon auto-detects available agent CLIs (`claude`, `codex`) on your PATH. W
See the [CLI and Daemon Guide](CLI_AND_DAEMON.md) for the full command reference, daemon configuration, and advanced usage.
## Quickstart
Once you have the CLI installed (or signed up for [Multica Cloud](https://multica.ai)), follow these steps to assign your first task to an agent:
### 1. Log in and start the daemon
```bash
multica login # Authenticate with your Multica account
multica daemon start # Start the local agent runtime
```
The daemon runs in the background and keeps your machine connected to Multica. It auto-detects agent CLIs (`claude`, `codex`) available on your PATH.
### 2. Verify your runtime
Open your workspace in the Multica web app. Navigate to **Settings → Runtimes** — you should see your machine listed as an active **Runtime**.
> **What is a Runtime?** A Runtime is a compute environment that can execute agent tasks. It can be your local machine (via the daemon) or a cloud instance. Each runtime reports which agent CLIs are available, so Multica knows where to route work.
### 3. Create an agent
Go to **Settings → Agents** and click **New Agent**. Pick the runtime you just connected and choose a provider (Claude Code or Codex). Give your agent a name — this is how it will appear on the board, in comments, and in assignments.
### 4. Assign your first task
Create an issue from the board (or via `multica issue create`), then assign it to your new agent. The agent will automatically pick up the task, execute it on your runtime, and report progress — just like a human teammate.
That's it! Your agent is now part of the team. 🎉
## Architecture
```
@@ -135,18 +70,23 @@ That's it! Your agent is now part of the team. 🎉
└──────────────┘
```
| Layer | Stack |
|-------|-------|
| Frontend | Next.js 16 (App Router) |
| Backend | Go (Chi router, sqlc, gorilla/websocket) |
| Database | PostgreSQL 17 with pgvector |
| Agent Runtime | Local daemon executing Claude Code or Codex |
- **Frontend**: Next.js 16 (App Router)
- **Backend**: Go (Chi router, sqlc, gorilla/websocket)
- **Database**: PostgreSQL 17 with pgvector
- **Agent Runtime**: Local daemon executing Claude Code or Codex
## Development
For contributors working on the Multica codebase, see the [Contributing Guide](CONTRIBUTING.md).
**Prerequisites:** [Node.js](https://nodejs.org/) v20+, [pnpm](https://pnpm.io/) v10.28+, [Go](https://go.dev/) v1.26+, [Docker](https://www.docker.com/)
### Prerequisites
- [Node.js](https://nodejs.org/) (v20+)
- [pnpm](https://pnpm.io/) (v10.28+)
- [Go](https://go.dev/) (v1.26+)
- [Docker](https://www.docker.com/)
### Quick Start
```bash
pnpm install
@@ -159,4 +99,4 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for the full development workflow, worktr
## License
[Apache 2.0](LICENSE)
See [LICENSE](LICENSE) for details.

View File

@@ -1,162 +0,0 @@
<p align="center">
<img src="docs/assets/banner.jpg" alt="Multica — 人类与 AI并肩前行" width="100%">
</p>
<div align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="docs/assets/logo-dark.svg">
<source media="(prefers-color-scheme: light)" srcset="docs/assets/logo-light.svg">
<img alt="Multica" src="docs/assets/logo-light.svg" width="50">
</picture>
# Multica
**你的下一批员工,不是人类。**
开源平台,将编码 Agent 变成真正的队友。<br/>
分配任务、跟踪进度、积累技能——在一个地方管理你的人类 + Agent 团队。
[![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) · [自部署指南](SELF_HOSTING.md) · [参与贡献](CONTRIBUTING.md)
**[English](README.md) | 简体中文**
</div>
## Multica 是什么?
Multica 将编码 Agent 变成真正的队友。像分配给同事一样分配给 Agent——它们会自主接手工作、编写代码、报告阻塞问题、更新状态。
不再需要复制粘贴 prompt不再需要盯着运行过程。你的 Agent 出现在看板上、参与对话、随着时间积累可复用的技能。支持 **Claude Code****Codex**
<p align="center">
<img src="docs/assets/hero-screenshot.png" alt="Multica 看板视图" width="800">
</p>
## 功能特性
- **Agent 即队友** — 像分配给同事一样分配给 Agent。它们有个人档案、出现在看板上、发表评论、创建 Issue、主动报告阻塞问题。
- **自主执行** — 设置后无需管理。完整的任务生命周期管理(排队、认领、执行、完成/失败),通过 WebSocket 实时推送进度。
- **可复用技能** — 每个解决方案都成为全团队可复用的技能。部署、数据库迁移、代码审查——技能让团队能力随时间持续增长。
- **统一运行时** — 一个控制台管理所有算力。本地 daemon 和云端运行时,自动检测可用 CLI实时监控。
- **多工作区** — 按团队组织工作,工作区级别隔离。每个工作区有独立的 Agent、Issue 和设置。
## 快速开始
### Multica 云服务
最快的上手方式,无需任何配置:**[multica.ai](https://multica.ai)**
### Docker 自部署
```bash
git clone https://github.com/multica-ai/multica.git
cd multica
cp .env.example .env
# 编辑 .env — 至少修改 JWT_SECRET
docker compose up -d # 启动 PostgreSQL
cd server && go run ./cmd/migrate up && cd .. # 运行数据库迁移
make start # 启动应用
```
完整部署文档请参阅 [自部署指南](SELF_HOSTING.md)。
## CLI
`multica` CLI 将你的本地机器连接到 Multica — 用于认证、管理工作区和运行 Agent daemon。
**方式 A — 将以下指令粘贴给你的 coding agentClaude Code、Codex 等):**
```
Fetch https://github.com/multica-ai/multica/blob/main/CLI_INSTALL.md and follow the instructions to install Multica CLI, log in, and start the daemon on this machine.
```
**方式 B — 手动安装:**
```bash
# 安装
brew tap multica-ai/tap
brew install multica
# 认证并启动
multica login
multica daemon start
```
daemon 会自动检测 PATH 中可用的 Agent CLI`claude``codex`)。当 Agent 被分配任务时daemon 会创建隔离环境、运行 Agent、并将结果回传。
完整命令参考请参阅 [CLI 与 Daemon 指南](CLI_AND_DAEMON.md)。
## 快速上手
安装好 CLI或注册 [Multica 云服务](https://multica.ai))后,按以下步骤将第一个任务分配给 Agent
### 1. 登录并启动 daemon
```bash
multica login # 使用你的 Multica 账号认证
multica daemon start # 启动本地 Agent 运行时
```
daemon 在后台运行,保持你的机器与 Multica 的连接。它会自动检测 PATH 中可用的 Agent CLI`claude``codex`)。
### 2. 确认运行时已连接
在 Multica Web 端打开你的工作区,进入 **设置 → 运行时Runtimes**,你应该能看到你的机器已作为一个活跃的 **Runtime** 出现在列表中。
> **什么是 Runtime运行时** Runtime 是可以执行 Agent 任务的计算环境。它可以是你的本地机器(通过 daemon 连接),也可以是云端实例。每个 Runtime 会上报可用的 Agent CLIMultica 据此决定将任务路由到哪里执行。
### 3. 创建 Agent
进入 **设置 → Agents**,点击 **新建 Agent**。选择你刚连接的 Runtime选择 ProviderClaude Code 或 Codex并为 Agent 起个名字——它将以这个名字出现在看板、评论和任务分配中。
### 4. 分配你的第一个任务
在看板上创建一个 Issue或通过 `multica issue create` 命令创建),然后将其分配给你的新 Agent。Agent 会自动接手任务、在你的 Runtime 上执行、并实时汇报进度——就像一个真正的队友一样。
大功告成!你的 Agent 现在是团队的一员了。 🎉
## 架构
```
┌──────────────┐ ┌──────────────┐ ┌──────────────────┐
│ Next.js │────>│ Go 后端 │────>│ PostgreSQL │
│ 前端 │<────│ (Chi + WS) │<────│ (pgvector) │
└──────────────┘ └──────┬───────┘ └──────────────────┘
┌──────┴───────┐
│ Agent Daemon │ (运行在你的机器上)
│ Claude/Codex │
└──────────────┘
```
| 层级 | 技术栈 |
|------|--------|
| 前端 | Next.js 16 (App Router) |
| 后端 | Go (Chi router, sqlc, gorilla/websocket) |
| 数据库 | PostgreSQL 17 with pgvector |
| Agent 运行时 | 本地 daemon 执行 Claude Code 或 Codex |
## 开发
参与 Multica 代码贡献,请参阅 [贡献指南](CONTRIBUTING.md)。
**环境要求:** [Node.js](https://nodejs.org/) v20+, [pnpm](https://pnpm.io/) v10.28+, [Go](https://go.dev/) v1.26+, [Docker](https://www.docker.com/)
```bash
pnpm install
cp .env.example .env
make setup
make start
```
完整的开发流程、worktree 支持、测试和问题排查请参阅 [CONTRIBUTING.md](CONTRIBUTING.md)。
## 开源协议
[Apache 2.0](LICENSE)

View File

@@ -257,14 +257,8 @@ Each team member who wants to run AI agents locally needs to:
```bash
# Point CLI to your server
#
# For production deployments with TLS:
export MULTICA_APP_URL=https://app.example.com
export MULTICA_SERVER_URL=wss://api.example.com/ws
#
# For local deployments without TLS:
# export MULTICA_APP_URL=http://localhost:3000
# export MULTICA_SERVER_URL=ws://localhost:8080/ws
# Login (opens browser)
multica login
@@ -273,8 +267,6 @@ Each team member who wants to run AI agents locally needs to:
multica daemon start
```
> **Note:** Use `https://` and `wss://` for production deployments behind a TLS-terminating reverse proxy. For local or development deployments without TLS, use `http://` and `ws://` instead.
The daemon auto-detects installed agent CLIs and registers itself with the server. When an agent is assigned a task in Multica, the daemon picks it up, creates an isolated workspace, runs the agent, and reports results back.
## Upgrading

View File

@@ -0,0 +1,20 @@
{
"colors": [
{
"color": {
"color-space": "srgb",
"components": {
"alpha": "1.000",
"blue": "0.996",
"green": "0.388",
"red": "0.384"
}
},
"idiom": "universal"
}
],
"info": {
"author": "xcode",
"version": 1
}
}

View File

@@ -0,0 +1,13 @@
{
"images": [
{
"idiom": "universal",
"platform": "ios",
"size": "1024x1024"
}
],
"info": {
"author": "xcode",
"version": 1
}
}

View File

@@ -0,0 +1,6 @@
{
"info": {
"author": "xcode",
"version": 1
}
}

View File

@@ -0,0 +1,37 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<false/>
</dict>
<key>UILaunchScreen</key>
<dict/>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
</dict>
</plist>

View File

@@ -0,0 +1,29 @@
import Foundation
struct Agent: Codable, Identifiable, Hashable, Sendable {
let id: String
let workspaceId: String
let name: String
let description: String
let instructions: String?
let avatarURL: String?
let status: AgentStatus
let createdAt: String
let updatedAt: String
enum CodingKeys: String, CodingKey {
case id, name, description, instructions, status
case workspaceId = "workspace_id"
case avatarURL = "avatar_url"
case createdAt = "created_at"
case updatedAt = "updated_at"
}
}
enum AgentStatus: String, Codable, Sendable {
case idle
case working
case blocked
case error
case offline
}

View File

@@ -0,0 +1,61 @@
import Foundation
struct AgentTask: Codable, Identifiable, Sendable {
let id: String
let agentId: String
let runtimeId: String?
let issueId: String
let status: TaskStatus
let priority: Int?
let dispatchedAt: String?
let startedAt: String?
let completedAt: String?
let error: String?
let createdAt: String
enum CodingKeys: String, CodingKey {
case id, status, priority, error
case agentId = "agent_id"
case runtimeId = "runtime_id"
case issueId = "issue_id"
case dispatchedAt = "dispatched_at"
case startedAt = "started_at"
case completedAt = "completed_at"
case createdAt = "created_at"
}
}
enum TaskStatus: String, Codable, Sendable {
case queued
case dispatched
case running
case completed
case failed
case cancelled
var label: String {
switch self {
case .queued: "Queued"
case .dispatched: "Dispatched"
case .running: "Running"
case .completed: "Completed"
case .failed: "Failed"
case .cancelled: "Cancelled"
}
}
var iconName: String {
switch self {
case .queued: "clock"
case .dispatched: "arrow.right.circle"
case .running: "play.circle.fill"
case .completed: "checkmark.circle.fill"
case .failed: "xmark.circle.fill"
case .cancelled: "minus.circle.fill"
}
}
var isActive: Bool {
self == .queued || self == .dispatched || self == .running
}
}

View File

@@ -0,0 +1,46 @@
import Foundation
struct Comment: Codable, Identifiable, Sendable {
let id: String
let issueId: String
let authorType: String
let authorId: String
let content: String
let type: String
let parentId: String?
let attachments: [Attachment]?
let createdAt: String
let updatedAt: String
// Joined fields from server
let authorName: String?
let authorAvatarURL: String?
enum CodingKeys: String, CodingKey {
case id, content, type, attachments
case issueId = "issue_id"
case authorType = "author_type"
case authorId = "author_id"
case parentId = "parent_id"
case createdAt = "created_at"
case updatedAt = "updated_at"
case authorName = "author_name"
case authorAvatarURL = "author_avatar_url"
}
var isFromAgent: Bool {
authorType == "agent"
}
}
struct Attachment: Codable, Identifiable, Sendable {
let id: String
let filename: String
let contentType: String?
let url: String?
enum CodingKeys: String, CodingKey {
case id, filename, url
case contentType = "content_type"
}
}

View File

@@ -0,0 +1,152 @@
import Foundation
enum IssueStatus: String, Codable, CaseIterable, Sendable {
case backlog
case todo
case inProgress = "in_progress"
case inReview = "in_review"
case done
case blocked
case cancelled
var label: String {
switch self {
case .backlog: "Backlog"
case .todo: "Todo"
case .inProgress: "In Progress"
case .inReview: "In Review"
case .done: "Done"
case .blocked: "Blocked"
case .cancelled: "Cancelled"
}
}
var iconName: String {
switch self {
case .backlog: "circle.dashed"
case .todo: "circle"
case .inProgress: "circle.lefthalf.filled"
case .inReview: "eye.circle"
case .done: "checkmark.circle.fill"
case .blocked: "xmark.circle"
case .cancelled: "minus.circle"
}
}
var color: String {
switch self {
case .backlog: "gray"
case .todo: "gray"
case .inProgress: "yellow"
case .inReview: "blue"
case .done: "green"
case .blocked: "red"
case .cancelled: "gray"
}
}
}
enum IssuePriority: String, Codable, CaseIterable, Sendable {
case urgent
case high
case medium
case low
case none
var label: String {
switch self {
case .urgent: "Urgent"
case .high: "High"
case .medium: "Medium"
case .low: "Low"
case .none: "None"
}
}
var iconName: String {
switch self {
case .urgent: "exclamationmark.3"
case .high: "exclamationmark.2"
case .medium: "exclamationmark"
case .low: "minus"
case .none: "minus"
}
}
var sortOrder: Int {
switch self {
case .urgent: 0
case .high: 1
case .medium: 2
case .low: 3
case .none: 4
}
}
}
struct Issue: Codable, Identifiable, Hashable, Sendable {
let id: String
let workspaceId: String
let number: Int
let identifier: String
let title: String
let description: String?
let status: IssueStatus
let priority: IssuePriority
let assigneeType: String?
let assigneeId: String?
let creatorType: String
let creatorId: String
let parentIssueId: String?
let position: Int
let dueDate: String?
let createdAt: String
let updatedAt: String
enum CodingKeys: String, CodingKey {
case id, number, identifier, title, description, status, priority, position
case workspaceId = "workspace_id"
case assigneeType = "assignee_type"
case assigneeId = "assignee_id"
case creatorType = "creator_type"
case creatorId = "creator_id"
case parentIssueId = "parent_issue_id"
case dueDate = "due_date"
case createdAt = "created_at"
case updatedAt = "updated_at"
}
var isAssignedToAgent: Bool {
assigneeType == "agent"
}
}
struct CreateIssueRequest: Codable, Sendable {
let title: String
let description: String?
let status: String?
let priority: String?
let assigneeType: String?
let assigneeId: String?
enum CodingKeys: String, CodingKey {
case title, description, status, priority
case assigneeType = "assignee_type"
case assigneeId = "assignee_id"
}
}
struct UpdateIssueRequest: Codable, Sendable {
var title: String?
var description: String?
var status: String?
var priority: String?
var assigneeType: String?
var assigneeId: String?
enum CodingKeys: String, CodingKey {
case title, description, status, priority
case assigneeType = "assignee_type"
case assigneeId = "assignee_id"
}
}

View File

@@ -0,0 +1,55 @@
import Foundation
struct Member: Codable, Identifiable, Hashable, Sendable {
let id: String
let userId: String
let workspaceId: String
let role: String
let user: User?
let createdAt: String
enum CodingKeys: String, CodingKey {
case id, role, user
case userId = "user_id"
case workspaceId = "workspace_id"
case createdAt = "created_at"
}
var displayName: String {
user?.name ?? "Unknown"
}
}
/// Unified type for displaying assignees (either member or agent)
enum Assignee: Identifiable, Hashable, Sendable {
case member(Member)
case agent(Agent)
var id: String {
switch self {
case .member(let m): m.id
case .agent(let a): a.id
}
}
var name: String {
switch self {
case .member(let m): m.displayName
case .agent(let a): a.name
}
}
var typeName: String {
switch self {
case .member: "member"
case .agent: "agent"
}
}
var entityId: String {
switch self {
case .member(let m): m.userId
case .agent(let a): a.id
}
}
}

View File

@@ -0,0 +1,71 @@
import Foundation
struct TaskMessage: Codable, Identifiable, Sendable {
let taskId: String
let issueId: String?
let seq: Int
let type: MessageType
let tool: String?
let content: String?
let input: [String: AnyCodable]?
let output: String?
var id: String { "\(taskId)-\(seq)" }
enum CodingKeys: String, CodingKey {
case seq, type, tool, content, input, output
case taskId = "task_id"
case issueId = "issue_id"
}
}
enum MessageType: String, Codable, Sendable {
case text
case thinking
case toolUse = "tool_use"
case toolResult = "tool_result"
case error
}
// Simple wrapper for heterogeneous JSON values
struct AnyCodable: Codable, @unchecked Sendable {
let value: Any
init(_ value: Any) {
self.value = value
}
init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
if let string = try? container.decode(String.self) {
value = string
} else if let int = try? container.decode(Int.self) {
value = int
} else if let double = try? container.decode(Double.self) {
value = double
} else if let bool = try? container.decode(Bool.self) {
value = bool
} else if let dict = try? container.decode([String: AnyCodable].self) {
value = dict.mapValues(\.value)
} else if let array = try? container.decode([AnyCodable].self) {
value = array.map(\.value)
} else {
value = NSNull()
}
}
func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
if let string = value as? String {
try container.encode(string)
} else if let int = value as? Int {
try container.encode(int)
} else if let double = value as? Double {
try container.encode(double)
} else if let bool = value as? Bool {
try container.encode(bool)
} else {
try container.encodeNil()
}
}
}

View File

@@ -0,0 +1,22 @@
import Foundation
struct User: Codable, Identifiable, Hashable, Sendable {
let id: String
let name: String
let email: String
let avatarURL: String?
let createdAt: String
let updatedAt: String
enum CodingKeys: String, CodingKey {
case id, name, email
case avatarURL = "avatar_url"
case createdAt = "created_at"
case updatedAt = "updated_at"
}
}
struct AuthResponse: Codable, Sendable {
let token: String
let user: User
}

View File

@@ -0,0 +1,23 @@
import Foundation
struct Workspace: Codable, Identifiable, Sendable {
let id: String
let name: String
let slug: String
let description: String?
let issuePrefix: String
let createdAt: String
let updatedAt: String
enum CodingKeys: String, CodingKey {
case id, name, slug, description
case issuePrefix = "issue_prefix"
case createdAt = "created_at"
case updatedAt = "updated_at"
}
}
struct WorkspaceRepo: Codable, Sendable {
let url: String
let description: String?
}

View File

@@ -0,0 +1,10 @@
import SwiftUI
@main
struct MulticaApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}

View File

@@ -0,0 +1,274 @@
import Foundation
enum APIError: LocalizedError {
case invalidURL
case unauthorized
case serverError(String)
case networkError(Error)
case decodingError(Error)
var errorDescription: String? {
switch self {
case .invalidURL: "Invalid URL"
case .unauthorized: "Session expired. Please log in again."
case .serverError(let msg): msg
case .networkError(let err): err.localizedDescription
case .decodingError(let err): "Failed to parse response: \(err.localizedDescription)"
}
}
}
@MainActor
final class APIClient: Sendable {
static let shared = APIClient()
// Configure these for your server
#if DEBUG
let baseURL = "http://localhost:8080"
#else
let baseURL = "https://api.multica.ai"
#endif
private let session: URLSession
private let decoder: JSONDecoder
private init() {
let config = URLSessionConfiguration.default
config.timeoutIntervalForRequest = 30
session = URLSession(configuration: config)
decoder = JSONDecoder()
}
var token: String? {
get { KeychainHelper.read(key: "auth_token") }
set {
if let newValue {
KeychainHelper.save(key: "auth_token", value: newValue)
} else {
KeychainHelper.delete(key: "auth_token")
}
}
}
var workspaceId: String? {
get { UserDefaults.standard.string(forKey: "workspace_id") }
set { UserDefaults.standard.set(newValue, forKey: "workspace_id") }
}
// MARK: - Auth
func sendCode(email: String) async throws {
let body = ["email": email]
let _: EmptyResponse = try await post("/auth/send-code", body: body, authenticated: false)
}
func verifyCode(email: String, code: String) async throws -> AuthResponse {
let body = ["email": email, "code": code]
return try await post("/auth/verify-code", body: body, authenticated: false)
}
// MARK: - Workspaces
func listWorkspaces() async throws -> [Workspace] {
try await get("/api/workspaces")
}
func getWorkspace(_ id: String) async throws -> Workspace {
try await get("/api/workspaces/\(id)")
}
// MARK: - Issues
func listIssues(
status: String? = nil,
priority: String? = nil,
assigneeId: String? = nil,
limit: Int = 200,
offset: Int = 0
) async throws -> [Issue] {
var params: [(String, String)] = [
("limit", "\(limit)"),
("offset", "\(offset)"),
]
if let status { params.append(("status", status)) }
if let priority { params.append(("priority", priority)) }
if let assigneeId { params.append(("assignee_id", assigneeId)) }
return try await get("/api/issues", queryItems: params)
}
func getIssue(_ id: String) async throws -> Issue {
try await get("/api/issues/\(id)")
}
func createIssue(_ req: CreateIssueRequest) async throws -> Issue {
try await post("/api/issues", body: req)
}
func updateIssue(_ id: String, _ req: UpdateIssueRequest) async throws -> Issue {
try await put("/api/issues/\(id)", body: req)
}
func deleteIssue(_ id: String) async throws {
let _: EmptyResponse = try await request("DELETE", path: "/api/issues/\(id)")
}
// MARK: - Comments
func listComments(issueId: String) async throws -> [Comment] {
try await get("/api/issues/\(issueId)/comments")
}
func createComment(issueId: String, content: String, parentId: String? = nil) async throws -> Comment {
var body: [String: String] = ["content": content]
if let parentId { body["parent_id"] = parentId }
return try await post("/api/issues/\(issueId)/comments", body: body)
}
// MARK: - Members & Agents
func listMembers(workspaceId: String) async throws -> [Member] {
try await get("/api/workspaces/\(workspaceId)/members")
}
func listAgents() async throws -> [Agent] {
try await get("/api/agents")
}
// MARK: - Tasks
func getActiveTask(issueId: String) async throws -> AgentTask? {
do {
return try await get("/api/issues/\(issueId)/active-task")
} catch APIError.serverError {
return nil
}
}
func listTaskRuns(issueId: String) async throws -> [AgentTask] {
try await get("/api/issues/\(issueId)/task-runs")
}
func listTaskMessages(taskId: String) async throws -> [TaskMessage] {
try await get("/api/tasks/\(taskId)/messages")
}
func cancelTask(issueId: String, taskId: String) async throws {
let _: EmptyResponse = try await post("/api/issues/\(issueId)/tasks/\(taskId)/cancel", body: EmptyBody())
}
// MARK: - Timeline
func listTimeline(issueId: String) async throws -> [TimelineEntry] {
try await get("/api/issues/\(issueId)/timeline")
}
// MARK: - Networking Helpers
private func get<T: Decodable>(_ path: String, queryItems: [(String, String)] = []) async throws -> T {
try await request("GET", path: path, queryItems: queryItems)
}
private func post<T: Decodable, B: Encodable>(_ path: String, body: B, authenticated: Bool = true) async throws -> T {
try await request("POST", path: path, body: body, authenticated: authenticated)
}
private func put<T: Decodable, B: Encodable>(_ path: String, body: B) async throws -> T {
try await request("PUT", path: path, body: body)
}
private func request<T: Decodable>(
_ method: String,
path: String,
queryItems: [(String, String)] = [],
body: (any Encodable)? = nil,
authenticated: Bool = true
) async throws -> T {
guard var components = URLComponents(string: baseURL + path) else {
throw APIError.invalidURL
}
if !queryItems.isEmpty {
components.queryItems = queryItems.map { URLQueryItem(name: $0.0, value: $0.1) }
}
guard let url = components.url else {
throw APIError.invalidURL
}
var request = URLRequest(url: url)
request.httpMethod = method
if authenticated {
guard let token else { throw APIError.unauthorized }
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
}
if let workspaceId, authenticated {
request.setValue(workspaceId, forHTTPHeaderField: "X-Workspace-ID")
}
if let body {
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try JSONEncoder().encode(body)
}
let (data, response) : (Data, URLResponse)
do {
(data, response) = try await session.data(for: request)
} catch {
throw APIError.networkError(error)
}
guard let httpResponse = response as? HTTPURLResponse else {
throw APIError.serverError("Invalid response")
}
if httpResponse.statusCode == 401 {
throw APIError.unauthorized
}
if httpResponse.statusCode >= 400 {
if let errorBody = try? JSONDecoder().decode(ErrorResponse.self, from: data) {
throw APIError.serverError(errorBody.error)
}
throw APIError.serverError("Server error (\(httpResponse.statusCode))")
}
// Handle empty responses
if T.self == EmptyResponse.self {
return EmptyResponse() as! T
}
do {
return try decoder.decode(T.self, from: data)
} catch {
throw APIError.decodingError(error)
}
}
}
struct EmptyResponse: Decodable {}
struct EmptyBody: Encodable {}
struct ErrorResponse: Decodable {
let error: String
}
struct TimelineEntry: Codable, Identifiable, Sendable {
let id: String
let issueId: String?
let actorType: String?
let actorId: String?
let action: String?
let field: String?
let oldValue: String?
let newValue: String?
let createdAt: String
enum CodingKeys: String, CodingKey {
case id, action, field
case issueId = "issue_id"
case actorType = "actor_type"
case actorId = "actor_id"
case oldValue = "old_value"
case newValue = "new_value"
case createdAt = "created_at"
}
}

View File

@@ -0,0 +1,42 @@
import Foundation
import Security
enum KeychainHelper {
private static let service = "ai.multica.app"
static func save(key: String, value: String) {
guard let data = value.data(using: .utf8) else { return }
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key,
]
SecItemDelete(query as CFDictionary)
var newItem = query
newItem[kSecValueData as String] = data
SecItemAdd(newItem as CFDictionary, nil)
}
static func read(key: String) -> String? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne,
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess, let data = result as? Data else { return nil }
return String(data: data, encoding: .utf8)
}
static func delete(key: String) {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key,
]
SecItemDelete(query as CFDictionary)
}
}

View File

@@ -0,0 +1,122 @@
import Foundation
struct WSEvent: @unchecked Sendable {
let type: String
let payload: [String: Any]
let actorId: String?
var prefix: String {
String(type.prefix(while: { $0 != ":" }))
}
}
@MainActor
final class WebSocketClient: ObservableObject {
static let shared = WebSocketClient()
@Published var isConnected = false
private var webSocketTask: URLSessionWebSocketTask?
private var handlers: [(String, @Sendable (WSEvent) -> Void)] = []
private var reconnectTask: Task<Void, Never>?
private let session = URLSession(configuration: .default)
private init() {}
func connect() {
guard let token = APIClient.shared.token,
let workspaceId = APIClient.shared.workspaceId else { return }
let wsScheme: String
#if DEBUG
wsScheme = "ws"
#else
wsScheme = "wss"
#endif
let baseHost = APIClient.shared.baseURL
.replacingOccurrences(of: "http://", with: "")
.replacingOccurrences(of: "https://", with: "")
guard let url = URL(string: "\(wsScheme)://\(baseHost)/ws?token=\(token)&workspace_id=\(workspaceId)") else {
return
}
webSocketTask = session.webSocketTask(with: url)
webSocketTask?.resume()
isConnected = true
receiveMessage()
}
func disconnect() {
reconnectTask?.cancel()
reconnectTask = nil
webSocketTask?.cancel(with: .goingAway, reason: nil)
webSocketTask = nil
isConnected = false
handlers.removeAll()
}
func on(_ eventType: String, handler: @escaping @Sendable (WSEvent) -> Void) {
handlers.append((eventType, handler))
}
func onPrefix(_ prefix: String, handler: @escaping @Sendable (WSEvent) -> Void) {
handlers.append(("prefix:\(prefix)", handler))
}
private func receiveMessage() {
webSocketTask?.receive { [weak self] result in
Task { @MainActor in
guard let self else { return }
switch result {
case .success(let message):
switch message {
case .string(let text):
self.handleMessage(text)
case .data(let data):
if let text = String(data: data, encoding: .utf8) {
self.handleMessage(text)
}
@unknown default:
break
}
self.receiveMessage()
case .failure:
self.isConnected = false
self.scheduleReconnect()
}
}
}
}
private func handleMessage(_ text: String) {
guard let data = text.data(using: .utf8),
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let type = json["type"] as? String else { return }
let payload = json["payload"] as? [String: Any] ?? [:]
let actorId = json["actor_id"] as? String
let event = WSEvent(type: type, payload: payload, actorId: actorId)
for (pattern, handler) in handlers {
if pattern == type {
handler(event)
} else if pattern.hasPrefix("prefix:") {
let prefix = String(pattern.dropFirst(7))
if event.prefix == prefix {
handler(event)
}
}
}
}
private func scheduleReconnect() {
reconnectTask?.cancel()
reconnectTask = Task {
try? await Task.sleep(for: .seconds(3))
guard !Task.isCancelled else { return }
self.connect()
}
}
}

View File

@@ -0,0 +1,65 @@
import Foundation
@MainActor
@Observable
final class AuthViewModel {
var email = ""
var code = ""
var isLoading = false
var error: String?
var codeSent = false
var isAuthenticated = false
var user: User?
init() {
// Check for existing token
if APIClient.shared.token != nil {
isAuthenticated = true
}
}
func sendCode() async {
guard !email.isEmpty else {
error = "Please enter your email"
return
}
isLoading = true
error = nil
do {
try await APIClient.shared.sendCode(email: email)
codeSent = true
} catch {
self.error = error.localizedDescription
}
isLoading = false
}
func verifyCode() async {
guard !code.isEmpty else {
error = "Please enter the verification code"
return
}
isLoading = true
error = nil
do {
let response = try await APIClient.shared.verifyCode(email: email, code: code)
APIClient.shared.token = response.token
user = response.user
isAuthenticated = true
} catch {
self.error = error.localizedDescription
}
isLoading = false
}
func logout() {
APIClient.shared.token = nil
APIClient.shared.workspaceId = nil
WebSocketClient.shared.disconnect()
isAuthenticated = false
user = nil
email = ""
code = ""
codeSent = false
}
}

View File

@@ -0,0 +1,132 @@
import Foundation
@MainActor
@Observable
final class IssueDetailViewModel {
var issue: Issue
var comments: [Comment] = []
var taskRuns: [AgentTask] = []
var activeTask: AgentTask?
var taskMessages: [TaskMessage] = []
var isLoading = false
var error: String?
init(issue: Issue) {
self.issue = issue
}
func loadAll() async {
isLoading = true
do {
async let commentsResult = APIClient.shared.listComments(issueId: issue.id)
async let taskRunsResult = APIClient.shared.listTaskRuns(issueId: issue.id)
async let activeTaskResult = APIClient.shared.getActiveTask(issueId: issue.id)
comments = try await commentsResult
taskRuns = try await taskRunsResult
activeTask = try await activeTaskResult
// Load messages for active task
if let task = activeTask {
taskMessages = try await APIClient.shared.listTaskMessages(taskId: task.id)
}
} catch {
self.error = error.localizedDescription
}
isLoading = false
}
func updateStatus(_ status: IssueStatus) async {
do {
let updated = try await APIClient.shared.updateIssue(issue.id, UpdateIssueRequest(status: status.rawValue))
issue = updated
} catch {
self.error = error.localizedDescription
}
}
func updatePriority(_ priority: IssuePriority) async {
do {
let updated = try await APIClient.shared.updateIssue(issue.id, UpdateIssueRequest(priority: priority.rawValue))
issue = updated
} catch {
self.error = error.localizedDescription
}
}
func updateAssignee(type: String?, id: String?) async {
do {
let updated = try await APIClient.shared.updateIssue(issue.id, UpdateIssueRequest(
assigneeType: type ?? "",
assigneeId: id ?? ""
))
issue = updated
} catch {
self.error = error.localizedDescription
}
}
func updateTitle(_ title: String) async {
do {
let updated = try await APIClient.shared.updateIssue(issue.id, UpdateIssueRequest(title: title))
issue = updated
} catch {
self.error = error.localizedDescription
}
}
func addComment(_ content: String) async {
do {
let comment = try await APIClient.shared.createComment(issueId: issue.id, content: content)
comments.append(comment)
} catch {
self.error = error.localizedDescription
}
}
func loadTaskMessages(taskId: String) async {
do {
taskMessages = try await APIClient.shared.listTaskMessages(taskId: taskId)
} catch {
self.error = error.localizedDescription
}
}
func setupRealtimeUpdates() {
let issueId = issue.id
WebSocketClient.shared.on("task:message") { [weak self] event in
Task { @MainActor in
guard let self,
let payload = event.payload["issue_id"] as? String,
payload == issueId else { return }
// Reload messages for active task
if let task = self.activeTask {
await self.loadTaskMessages(taskId: task.id)
}
}
}
WebSocketClient.shared.onPrefix("task") { [weak self] event in
Task { @MainActor in
guard let self else { return }
await self.loadAll()
}
}
WebSocketClient.shared.onPrefix("comment") { [weak self] event in
Task { @MainActor in
guard let self else { return }
self.comments = (try? await APIClient.shared.listComments(issueId: issueId)) ?? self.comments
}
}
WebSocketClient.shared.on("issue:updated") { [weak self] event in
Task { @MainActor in
guard let self else { return }
if let updated = try? await APIClient.shared.getIssue(issueId) {
self.issue = updated
}
}
}
}
}

View File

@@ -0,0 +1,63 @@
import Foundation
@MainActor
@Observable
final class IssueListViewModel {
var issues: [Issue] = []
var isLoading = false
var error: String?
var statusFilter: IssueStatus?
var searchText = ""
var filteredIssues: [Issue] {
var result = issues
if let statusFilter {
result = result.filter { $0.status == statusFilter }
}
if !searchText.isEmpty {
result = result.filter {
$0.title.localizedCaseInsensitiveContains(searchText) ||
$0.identifier.localizedCaseInsensitiveContains(searchText)
}
}
return result
}
// Group issues by status for sectioned display
var issuesByStatus: [(IssueStatus, [Issue])] {
let grouped = Dictionary(grouping: filteredIssues, by: \.status)
let order: [IssueStatus] = [.inProgress, .todo, .inReview, .blocked, .backlog, .done, .cancelled]
return order.compactMap { status in
guard let issues = grouped[status], !issues.isEmpty else { return nil }
return (status, issues)
}
}
func loadIssues() async {
isLoading = true
error = nil
do {
issues = try await APIClient.shared.listIssues()
} catch {
self.error = error.localizedDescription
}
isLoading = false
}
func refresh() async {
do {
issues = try await APIClient.shared.listIssues()
} catch {
self.error = error.localizedDescription
}
}
func deleteIssue(_ issue: Issue) async {
do {
try await APIClient.shared.deleteIssue(issue.id)
issues.removeAll { $0.id == issue.id }
} catch {
self.error = error.localizedDescription
}
}
}

View File

@@ -0,0 +1,70 @@
import Foundation
@MainActor
@Observable
final class WorkspaceViewModel {
var workspaces: [Workspace] = []
var selectedWorkspace: Workspace?
var members: [Member] = []
var agents: [Agent] = []
var isLoading = false
var error: String?
var hasSelectedWorkspace: Bool {
selectedWorkspace != nil
}
func loadWorkspaces() async {
isLoading = true
error = nil
do {
workspaces = try await APIClient.shared.listWorkspaces()
// Auto-select if there's a saved workspace or only one
if let savedId = APIClient.shared.workspaceId,
let saved = workspaces.first(where: { $0.id == savedId }) {
await selectWorkspace(saved)
} else if workspaces.count == 1 {
await selectWorkspace(workspaces[0])
}
} catch {
self.error = error.localizedDescription
}
isLoading = false
}
func selectWorkspace(_ workspace: Workspace) async {
selectedWorkspace = workspace
APIClient.shared.workspaceId = workspace.id
WebSocketClient.shared.disconnect()
WebSocketClient.shared.connect()
await loadWorkspaceData()
}
func loadWorkspaceData() async {
guard let workspace = selectedWorkspace else { return }
do {
async let membersResult = APIClient.shared.listMembers(workspaceId: workspace.id)
async let agentsResult = APIClient.shared.listAgents()
members = try await membersResult
agents = try await agentsResult
} catch {
self.error = error.localizedDescription
}
}
/// All possible assignees (members + agents)
var assignees: [Assignee] {
let memberAssignees = members.map { Assignee.member($0) }
let agentAssignees = agents.map { Assignee.agent($0) }
return memberAssignees + agentAssignees
}
func assigneeName(type: String?, id: String?) -> String {
guard let type, let id else { return "Unassigned" }
if type == "agent" {
return agents.first(where: { $0.id == id })?.name ?? "Agent"
} else {
return members.first(where: { $0.userId == id })?.displayName ?? "Member"
}
}
}

View File

@@ -0,0 +1,105 @@
import SwiftUI
struct LoginView: View {
@Bindable var viewModel: AuthViewModel
var body: some View {
NavigationStack {
VStack(spacing: 32) {
Spacer()
VStack(spacing: 8) {
Image(systemName: "bolt.circle.fill")
.font(.system(size: 64))
.foregroundStyle(Color.accentColor)
Text("Multica")
.font(.largeTitle.bold())
Text("AI-native project management")
.font(.subheadline)
.foregroundStyle(.secondary)
}
if !viewModel.codeSent {
emailForm
} else {
codeForm
}
if let error = viewModel.error {
Text(error)
.font(.caption)
.foregroundStyle(.red)
.multilineTextAlignment(.center)
}
Spacer()
Spacer()
}
.padding(.horizontal, 32)
.navigationBarHidden(true)
}
}
private var emailForm: some View {
VStack(spacing: 16) {
TextField("Email address", text: $viewModel.email)
.textFieldStyle(.roundedBorder)
.textContentType(.emailAddress)
.keyboardType(.emailAddress)
.autocapitalization(.none)
.disableAutocorrection(true)
Button {
Task { await viewModel.sendCode() }
} label: {
if viewModel.isLoading {
ProgressView()
.frame(maxWidth: .infinity)
} else {
Text("Send Code")
.frame(maxWidth: .infinity)
}
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
.disabled(viewModel.email.isEmpty || viewModel.isLoading)
}
}
private var codeForm: some View {
VStack(spacing: 16) {
Text("Enter the 6-digit code sent to \(viewModel.email)")
.font(.subheadline)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
TextField("Verification code", text: $viewModel.code)
.textFieldStyle(.roundedBorder)
.keyboardType(.numberPad)
.multilineTextAlignment(.center)
.font(.title2.monospaced())
Button {
Task { await viewModel.verifyCode() }
} label: {
if viewModel.isLoading {
ProgressView()
.frame(maxWidth: .infinity)
} else {
Text("Verify")
.frame(maxWidth: .infinity)
}
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
.disabled(viewModel.code.isEmpty || viewModel.isLoading)
Button("Use a different email") {
viewModel.codeSent = false
viewModel.code = ""
viewModel.error = nil
}
.font(.caption)
}
}
}

View File

@@ -0,0 +1,111 @@
import SwiftUI
struct CommentListView: View {
@Bindable var viewModel: IssueDetailViewModel
@State private var newComment = ""
@State private var isSending = false
var body: some View {
LazyVStack(alignment: .leading, spacing: 0) {
if viewModel.comments.isEmpty && !viewModel.isLoading {
Text("No comments yet")
.font(.subheadline)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity)
.padding(.vertical, 32)
}
ForEach(viewModel.comments) { comment in
CommentRowView(comment: comment)
if comment.id != viewModel.comments.last?.id {
Divider().padding(.leading, 48)
}
}
// New comment input
VStack(spacing: 8) {
Divider()
HStack(alignment: .bottom, spacing: 8) {
TextField("Add a comment...", text: $newComment, axis: .vertical)
.textFieldStyle(.roundedBorder)
.lineLimit(1...5)
Button {
Task { await sendComment() }
} label: {
if isSending {
ProgressView()
.controlSize(.small)
} else {
Image(systemName: "arrow.up.circle.fill")
.font(.title2)
}
}
.disabled(newComment.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || isSending)
}
.padding(.horizontal)
.padding(.vertical, 8)
}
}
}
private func sendComment() async {
let text = newComment.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty else { return }
isSending = true
await viewModel.addComment(text)
newComment = ""
isSending = false
}
}
struct CommentRowView: View {
let comment: Comment
var body: some View {
HStack(alignment: .top, spacing: 10) {
AssigneeAvatar(
type: comment.authorType,
name: comment.authorName ?? "?",
size: 28
)
VStack(alignment: .leading, spacing: 4) {
HStack {
Text(comment.authorName ?? (comment.isFromAgent ? "Agent" : "User"))
.font(.subheadline.bold())
if comment.isFromAgent {
Text("BOT")
.font(.caption2.bold())
.padding(.horizontal, 4)
.padding(.vertical, 1)
.background(.purple.opacity(0.15))
.foregroundStyle(.purple)
.clipShape(RoundedRectangle(cornerRadius: 3))
}
Spacer()
Text(relativeDate(comment.createdAt))
.font(.caption2)
.foregroundStyle(.tertiary)
}
Text(comment.content)
.font(.subheadline)
.foregroundStyle(.primary)
}
}
.padding(.horizontal)
.padding(.vertical, 10)
}
private func relativeDate(_ isoString: String) -> String {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
guard let date = formatter.date(from: isoString) ?? ISO8601DateFormatter().date(from: isoString) else {
return ""
}
let relative = RelativeDateTimeFormatter()
relative.unitsStyle = .abbreviated
return relative.localizedString(for: date, relativeTo: Date())
}
}

View File

@@ -0,0 +1,33 @@
import SwiftUI
struct AssigneeAvatar: View {
let type: String?
let name: String
var size: CGFloat = 24
var body: some View {
ZStack {
Circle()
.fill(type == "agent" ? Color.purple.opacity(0.2) : Color.gray.opacity(0.2))
.frame(width: size, height: size)
if type == "agent" {
Image(systemName: "cpu")
.font(.system(size: size * 0.5))
.foregroundStyle(.purple)
} else {
Text(initials)
.font(.system(size: size * 0.4, weight: .medium))
.foregroundStyle(.secondary)
}
}
}
private var initials: String {
let parts = name.split(separator: " ")
if parts.count >= 2 {
return "\(parts[0].prefix(1))\(parts[1].prefix(1))".uppercased()
}
return String(name.prefix(2)).uppercased()
}
}

View File

@@ -0,0 +1,29 @@
import SwiftUI
struct PriorityIcon: View {
let priority: IssuePriority
var size: CGFloat = 14
var body: some View {
Group {
switch priority {
case .urgent:
Image(systemName: "exclamationmark.3")
.foregroundStyle(.red)
case .high:
Image(systemName: "exclamationmark.2")
.foregroundStyle(.orange)
case .medium:
Image(systemName: "exclamationmark")
.foregroundStyle(.yellow)
case .low:
Image(systemName: "arrow.down")
.foregroundStyle(.blue)
case .none:
Image(systemName: "minus")
.foregroundStyle(.gray)
}
}
.font(.system(size: size, weight: .medium))
}
}

View File

@@ -0,0 +1,46 @@
import SwiftUI
struct StatusBadge: View {
let status: IssueStatus
var body: some View {
Label(status.label, systemImage: status.iconName)
.font(.caption)
.foregroundStyle(statusColor)
}
private var statusColor: Color {
switch status {
case .backlog: .gray
case .todo: .primary
case .inProgress: .yellow
case .inReview: .blue
case .done: .green
case .blocked: .red
case .cancelled: .gray
}
}
}
struct StatusIcon: View {
let status: IssueStatus
var size: CGFloat = 16
var body: some View {
Image(systemName: status.iconName)
.font(.system(size: size))
.foregroundStyle(statusColor)
}
private var statusColor: Color {
switch status {
case .backlog: .gray
case .todo: .primary
case .inProgress: .yellow
case .inReview: .blue
case .done: .green
case .blocked: .red
case .cancelled: .gray
}
}
}

View File

@@ -0,0 +1,38 @@
import SwiftUI
struct ContentView: View {
@State private var authVM = AuthViewModel()
@State private var workspaceVM = WorkspaceViewModel()
@State private var issueListVM = IssueListViewModel()
var body: some View {
Group {
if !authVM.isAuthenticated {
LoginView(viewModel: authVM)
} else if !workspaceVM.hasSelectedWorkspace {
WorkspacePickerView(viewModel: workspaceVM)
} else {
IssueListView(viewModel: issueListVM, workspaceVM: workspaceVM)
}
}
.onReceive(NotificationCenter.default.publisher(for: .logout)) { _ in
authVM.logout()
workspaceVM.selectedWorkspace = nil
issueListVM.issues = []
}
.onChange(of: workspaceVM.hasSelectedWorkspace) { _, hasWorkspace in
if hasWorkspace {
Task { await issueListVM.loadIssues() }
setupRealtimeSync()
}
}
}
private func setupRealtimeSync() {
WebSocketClient.shared.onPrefix("issue") { _ in
Task { @MainActor in
await issueListVM.refresh()
}
}
}
}

View File

@@ -0,0 +1,96 @@
import SwiftUI
struct CreateIssueView: View {
@Environment(\.dismiss) private var dismiss
@Bindable var workspaceVM: WorkspaceViewModel
var onCreate: (Issue) -> Void
@State private var title = ""
@State private var description = ""
@State private var status: IssueStatus = .todo
@State private var priority: IssuePriority = .none
@State private var selectedAssignee: Assignee?
@State private var isSubmitting = false
@State private var error: String?
var body: some View {
NavigationStack {
Form {
Section {
TextField("Title", text: $title)
TextField("Description (optional)", text: $description, axis: .vertical)
.lineLimit(3...8)
}
Section("Properties") {
// Status picker
Picker("Status", selection: $status) {
ForEach(IssueStatus.allCases, id: \.self) { s in
Label(s.label, systemImage: s.iconName).tag(s)
}
}
// Priority picker
Picker("Priority", selection: $priority) {
ForEach(IssuePriority.allCases, id: \.self) { p in
Label(p.label, systemImage: p.iconName).tag(p)
}
}
// Assignee picker
Picker("Assignee", selection: $selectedAssignee) {
Text("Unassigned").tag(nil as Assignee?)
ForEach(workspaceVM.assignees) { assignee in
Label(
assignee.name,
systemImage: assignee.typeName == "agent" ? "cpu" : "person"
).tag(assignee as Assignee?)
}
}
}
if let error {
Section {
Text(error)
.foregroundStyle(.red)
.font(.caption)
}
}
}
.navigationTitle("New Issue")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button("Create") {
Task { await createIssue() }
}
.disabled(title.isEmpty || isSubmitting)
}
}
}
}
private func createIssue() async {
isSubmitting = true
error = nil
do {
let request = CreateIssueRequest(
title: title,
description: description.isEmpty ? nil : description,
status: status.rawValue,
priority: priority.rawValue,
assigneeType: selectedAssignee?.typeName,
assigneeId: selectedAssignee?.entityId
)
let issue = try await APIClient.shared.createIssue(request)
onCreate(issue)
dismiss()
} catch {
self.error = error.localizedDescription
}
isSubmitting = false
}
}

View File

@@ -0,0 +1,182 @@
import SwiftUI
struct IssueDetailView: View {
@Bindable var viewModel: IssueDetailViewModel
@Bindable var workspaceVM: WorkspaceViewModel
@State private var selectedTab = 0
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 0) {
issueHeader
Divider()
propertiesSection
Divider()
// Tab bar
Picker("", selection: $selectedTab) {
Text("Comments").tag(0)
Text("Activity").tag(1)
if viewModel.issue.isAssignedToAgent {
Text("Agent Runs").tag(2)
}
}
.pickerStyle(.segmented)
.padding()
switch selectedTab {
case 0:
CommentListView(viewModel: viewModel)
case 1:
activitySection
case 2:
TaskRunsView(viewModel: viewModel)
default:
EmptyView()
}
}
}
.navigationTitle(viewModel.issue.identifier)
.navigationBarTitleDisplayMode(.inline)
.task {
await viewModel.loadAll()
viewModel.setupRealtimeUpdates()
}
.alert("Error", isPresented: .init(
get: { viewModel.error != nil },
set: { if !$0 { viewModel.error = nil } }
)) {
Button("OK") { viewModel.error = nil }
} message: {
Text(viewModel.error ?? "")
}
}
private var issueHeader: some View {
VStack(alignment: .leading, spacing: 12) {
Text(viewModel.issue.title)
.font(.title2.bold())
if let desc = viewModel.issue.description, !desc.isEmpty {
Text(desc)
.font(.body)
.foregroundStyle(.secondary)
}
// Active task banner
if let task = viewModel.activeTask, task.status.isActive {
HStack(spacing: 8) {
ProgressView()
.controlSize(.small)
Text("Agent is working on this issue...")
.font(.caption)
.foregroundStyle(.secondary)
Spacer()
Button("View Logs") {
selectedTab = 2
}
.font(.caption)
}
.padding(10)
.background(.purple.opacity(0.1))
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}
.padding()
}
private var propertiesSection: some View {
VStack(spacing: 0) {
// Status
propertyRow(label: "Status") {
Menu {
ForEach(IssueStatus.allCases, id: \.self) { status in
Button {
Task { await viewModel.updateStatus(status) }
} label: {
Label(status.label, systemImage: status.iconName)
}
}
} label: {
StatusBadge(status: viewModel.issue.status)
}
}
Divider().padding(.leading)
// Priority
propertyRow(label: "Priority") {
Menu {
ForEach(IssuePriority.allCases, id: \.self) { priority in
Button {
Task { await viewModel.updatePriority(priority) }
} label: {
Label(priority.label, systemImage: priority.iconName)
}
}
} label: {
HStack(spacing: 4) {
PriorityIcon(priority: viewModel.issue.priority)
Text(viewModel.issue.priority.label)
.font(.subheadline)
}
}
}
Divider().padding(.leading)
// Assignee
propertyRow(label: "Assignee") {
Menu {
Button("Unassigned") {
Task { await viewModel.updateAssignee(type: nil, id: nil) }
}
Divider()
ForEach(workspaceVM.assignees) { assignee in
Button {
Task { await viewModel.updateAssignee(type: assignee.typeName, id: assignee.entityId) }
} label: {
Label(
assignee.name,
systemImage: assignee.typeName == "agent" ? "cpu" : "person"
)
}
}
} label: {
let name = workspaceVM.assigneeName(type: viewModel.issue.assigneeType, id: viewModel.issue.assigneeId)
HStack(spacing: 6) {
AssigneeAvatar(type: viewModel.issue.assigneeType, name: name, size: 20)
Text(name)
.font(.subheadline)
}
}
}
}
}
private func propertyRow<Content: View>(label: String, @ViewBuilder content: () -> Content) -> some View {
HStack {
Text(label)
.font(.subheadline)
.foregroundStyle(.secondary)
.frame(width: 80, alignment: .leading)
content()
Spacer()
}
.padding(.horizontal)
.padding(.vertical, 10)
}
private var activitySection: some View {
LazyVStack(alignment: .leading, spacing: 0) {
if viewModel.isLoading {
ProgressView()
.frame(maxWidth: .infinity)
.padding()
} else {
Text("Activity timeline")
.font(.caption)
.foregroundStyle(.secondary)
.padding()
}
}
}
}

View File

@@ -0,0 +1,113 @@
import SwiftUI
struct IssueListView: View {
@Bindable var viewModel: IssueListViewModel
@Bindable var workspaceVM: WorkspaceViewModel
@State private var showCreateIssue = false
var body: some View {
NavigationStack {
Group {
if viewModel.isLoading && viewModel.issues.isEmpty {
ProgressView("Loading issues...")
} else if viewModel.filteredIssues.isEmpty {
ContentUnavailableView.search(text: viewModel.searchText)
} else {
issueList
}
}
.navigationTitle(workspaceVM.selectedWorkspace?.name ?? "Issues")
.searchable(text: $viewModel.searchText, prompt: "Search issues...")
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button {
showCreateIssue = true
} label: {
Image(systemName: "plus")
}
}
ToolbarItem(placement: .topBarTrailing) {
Menu {
Button("All") { viewModel.statusFilter = nil }
Divider()
ForEach(IssueStatus.allCases, id: \.self) { status in
Button {
viewModel.statusFilter = status
} label: {
Label(status.label, systemImage: status.iconName)
}
}
} label: {
Image(systemName: viewModel.statusFilter != nil ? "line.3.horizontal.decrease.circle.fill" : "line.3.horizontal.decrease.circle")
}
}
ToolbarItem(placement: .topBarLeading) {
Menu {
Button("Switch Workspace") {
workspaceVM.selectedWorkspace = nil
}
Button("Logout", role: .destructive) {
NotificationCenter.default.post(name: .logout, object: nil)
}
} label: {
Image(systemName: "person.circle")
}
}
}
.refreshable {
await viewModel.refresh()
}
.sheet(isPresented: $showCreateIssue) {
CreateIssueView(workspaceVM: workspaceVM) { newIssue in
viewModel.issues.insert(newIssue, at: 0)
}
}
.task {
await viewModel.loadIssues()
}
}
}
private var issueList: some View {
List {
ForEach(viewModel.issuesByStatus, id: \.0) { status, issues in
Section {
ForEach(issues) { issue in
NavigationLink(value: issue) {
IssueRowView(
issue: issue,
assigneeName: workspaceVM.assigneeName(type: issue.assigneeType, id: issue.assigneeId)
)
}
}
.onDelete { indexSet in
for index in indexSet {
let issue = issues[index]
Task { await viewModel.deleteIssue(issue) }
}
}
} header: {
HStack(spacing: 6) {
StatusIcon(status: status, size: 14)
Text(status.label)
.font(.caption.weight(.semibold))
Text("\(issues.count)")
.font(.caption2)
.foregroundStyle(.secondary)
}
}
}
}
.listStyle(.insetGrouped)
.navigationDestination(for: Issue.self) { issue in
IssueDetailView(
viewModel: IssueDetailViewModel(issue: issue),
workspaceVM: workspaceVM
)
}
}
}
extension Notification.Name {
static let logout = Notification.Name("logout")
}

View File

@@ -0,0 +1,36 @@
import SwiftUI
struct IssueRowView: View {
let issue: Issue
let assigneeName: String
var body: some View {
HStack(spacing: 10) {
StatusIcon(status: issue.status)
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 6) {
Text(issue.identifier)
.font(.caption.monospaced())
.foregroundStyle(.secondary)
PriorityIcon(priority: issue.priority, size: 10)
}
Text(issue.title)
.font(.subheadline)
.lineLimit(2)
}
Spacer()
if issue.assigneeId != nil {
AssigneeAvatar(
type: issue.assigneeType,
name: assigneeName,
size: 24
)
}
}
.padding(.vertical, 2)
}
}

View File

@@ -0,0 +1,138 @@
import SwiftUI
struct TaskMessagesView: View {
let messages: [TaskMessage]
let isLive: Bool
var body: some View {
LazyVStack(alignment: .leading, spacing: 2) {
if messages.isEmpty {
Text("No log messages")
.font(.caption)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity)
.padding()
}
ForEach(messages) { message in
TaskMessageRow(message: message)
}
if isLive && !messages.isEmpty {
HStack(spacing: 6) {
ProgressView()
.controlSize(.mini)
Text("Streaming...")
.font(.caption2)
.foregroundStyle(.secondary)
}
.padding(.horizontal, 12)
.padding(.vertical, 4)
}
}
}
}
struct TaskMessageRow: View {
let message: TaskMessage
@State private var isExpanded = false
var body: some View {
VStack(alignment: .leading, spacing: 2) {
switch message.type {
case .text:
textMessage
case .thinking:
thinkingMessage
case .toolUse:
toolUseMessage
case .toolResult:
toolResultMessage
case .error:
errorMessage
}
}
.padding(.horizontal, 12)
.padding(.vertical, 3)
}
private var textMessage: some View {
Text(message.content ?? "")
.font(.caption.monospaced())
.foregroundStyle(.primary)
}
private var thinkingMessage: some View {
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 4) {
Image(systemName: "brain")
.font(.caption2)
Text("Thinking")
.font(.caption2.weight(.medium))
}
.foregroundStyle(.purple.opacity(0.7))
if let content = message.content, !content.isEmpty {
Text(content)
.font(.caption2.monospaced())
.foregroundStyle(.secondary)
.lineLimit(isExpanded ? nil : 3)
.onTapGesture { isExpanded.toggle() }
}
}
}
private var toolUseMessage: some View {
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 4) {
Image(systemName: "wrench")
.font(.caption2)
Text(message.tool ?? "Tool")
.font(.caption2.weight(.semibold).monospaced())
}
.foregroundStyle(.blue)
if let content = message.content, !content.isEmpty {
Text(content)
.font(.caption2.monospaced())
.foregroundStyle(.secondary)
.lineLimit(isExpanded ? nil : 2)
.onTapGesture { isExpanded.toggle() }
}
}
}
private var toolResultMessage: some View {
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 4) {
Image(systemName: "arrow.turn.down.left")
.font(.caption2)
Text(message.tool ?? "Result")
.font(.caption2.weight(.medium).monospaced())
}
.foregroundStyle(.green)
if let output = message.output, !output.isEmpty {
Text(output)
.font(.caption2.monospaced())
.foregroundStyle(.secondary)
.lineLimit(isExpanded ? nil : 3)
.onTapGesture { isExpanded.toggle() }
}
}
}
private var errorMessage: some View {
HStack(spacing: 4) {
Image(systemName: "exclamationmark.triangle")
.font(.caption2)
Text(message.content ?? "Error")
.font(.caption2.monospaced())
}
.foregroundStyle(.red)
}
}

View File

@@ -0,0 +1,206 @@
import SwiftUI
struct TaskRunsView: View {
@Bindable var viewModel: IssueDetailViewModel
var body: some View {
LazyVStack(alignment: .leading, spacing: 0) {
if viewModel.taskRuns.isEmpty && !viewModel.isLoading {
VStack(spacing: 8) {
Image(systemName: "cpu")
.font(.title)
.foregroundStyle(.secondary)
Text("No agent runs yet")
.font(.subheadline)
.foregroundStyle(.secondary)
Text("Assign this issue to an agent to trigger execution.")
.font(.caption)
.foregroundStyle(.tertiary)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 32)
}
// Active task with live logs
if let activeTask = viewModel.activeTask, activeTask.status.isActive {
VStack(alignment: .leading, spacing: 8) {
HStack {
ProgressView()
.controlSize(.small)
Text("Running")
.font(.subheadline.bold())
.foregroundStyle(.purple)
Spacer()
Text("Started \(relativeDate(activeTask.startedAt ?? activeTask.createdAt))")
.font(.caption2)
.foregroundStyle(.secondary)
}
.padding(.horizontal)
.padding(.top, 12)
TaskMessagesView(messages: viewModel.taskMessages, isLive: true)
}
.background(.purple.opacity(0.03))
Divider()
}
// Historical runs
ForEach(viewModel.taskRuns.filter { t in
viewModel.activeTask.map { $0.id != t.id } ?? true
}) { task in
NavigationLink {
TaskRunDetailView(task: task)
} label: {
TaskRunRow(task: task)
}
Divider().padding(.leading)
}
}
}
private func relativeDate(_ isoString: String) -> String {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
guard let date = formatter.date(from: isoString) ?? ISO8601DateFormatter().date(from: isoString) else {
return ""
}
let relative = RelativeDateTimeFormatter()
relative.unitsStyle = .abbreviated
return relative.localizedString(for: date, relativeTo: Date())
}
}
struct TaskRunRow: View {
let task: AgentTask
var body: some View {
HStack(spacing: 10) {
Image(systemName: task.status.iconName)
.foregroundStyle(taskColor)
.font(.body)
VStack(alignment: .leading, spacing: 3) {
HStack {
Text(task.status.label)
.font(.subheadline.weight(.medium))
if let error = task.error {
Text(error)
.font(.caption2)
.foregroundStyle(.red)
.lineLimit(1)
}
}
HStack(spacing: 12) {
Text(formatDate(task.createdAt))
.font(.caption2)
.foregroundStyle(.secondary)
if let started = task.startedAt, let completed = task.completedAt {
Text(duration(from: started, to: completed))
.font(.caption2)
.foregroundStyle(.secondary)
}
}
}
Spacer()
Image(systemName: "chevron.right")
.font(.caption)
.foregroundStyle(.tertiary)
}
.padding(.horizontal)
.padding(.vertical, 10)
}
private var taskColor: Color {
switch task.status {
case .completed: .green
case .failed: .red
case .running: .purple
case .cancelled: .gray
default: .secondary
}
}
private func formatDate(_ iso: String) -> String {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
guard let date = formatter.date(from: iso) ?? ISO8601DateFormatter().date(from: iso) else { return iso }
let df = DateFormatter()
df.dateStyle = .short
df.timeStyle = .short
return df.string(from: date)
}
private func duration(from start: String, to end: String) -> String {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
guard let s = formatter.date(from: start) ?? ISO8601DateFormatter().date(from: start),
let e = formatter.date(from: end) ?? ISO8601DateFormatter().date(from: end) else { return "" }
let interval = e.timeIntervalSince(s)
if interval < 60 { return "\(Int(interval))s" }
if interval < 3600 { return "\(Int(interval / 60))m \(Int(interval.truncatingRemainder(dividingBy: 60)))s" }
return "\(Int(interval / 3600))h \(Int((interval.truncatingRemainder(dividingBy: 3600)) / 60))m"
}
}
struct TaskRunDetailView: View {
let task: AgentTask
@State private var messages: [TaskMessage] = []
@State private var isLoading = true
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
// Task info header
HStack {
Image(systemName: task.status.iconName)
.foregroundStyle(taskColor)
Text(task.status.label)
.font(.headline)
Spacer()
}
.padding(.horizontal)
if let error = task.error {
Text(error)
.font(.caption)
.foregroundStyle(.red)
.padding(.horizontal)
}
Divider()
if isLoading {
ProgressView("Loading execution log...")
.frame(maxWidth: .infinity)
.padding()
} else {
TaskMessagesView(messages: messages, isLive: false)
}
}
.padding(.top)
}
.navigationTitle("Run Details")
.navigationBarTitleDisplayMode(.inline)
.task {
do {
messages = try await APIClient.shared.listTaskMessages(taskId: task.id)
} catch {
// silently fail
}
isLoading = false
}
}
private var taskColor: Color {
switch task.status {
case .completed: .green
case .failed: .red
case .running: .purple
case .cancelled: .gray
default: .secondary
}
}
}

View File

@@ -0,0 +1,47 @@
import SwiftUI
struct WorkspacePickerView: View {
@Bindable var viewModel: WorkspaceViewModel
var body: some View {
NavigationStack {
Group {
if viewModel.isLoading {
ProgressView("Loading workspaces...")
} else if viewModel.workspaces.isEmpty {
ContentUnavailableView(
"No Workspaces",
systemImage: "folder",
description: Text("You don't belong to any workspaces yet.")
)
} else {
List(viewModel.workspaces) { workspace in
Button {
Task { await viewModel.selectWorkspace(workspace) }
} label: {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text(workspace.name)
.font(.headline)
if let desc = workspace.description, !desc.isEmpty {
Text(desc)
.font(.caption)
.foregroundStyle(.secondary)
}
}
Spacer()
Image(systemName: "chevron.right")
.foregroundStyle(.tertiary)
}
}
.foregroundStyle(.primary)
}
}
}
.navigationTitle("Workspaces")
.task {
await viewModel.loadWorkspaces()
}
}
}
}

64
apps/ios/README.md Normal file
View File

@@ -0,0 +1,64 @@
# Multica iOS App
MVP iOS client for the Multica platform — issue management with AI agent execution log viewing.
## Requirements
- Xcode 16+
- iOS 17.0+
- [XcodeGen](https://github.com/yonaskolb/XcodeGen) (for generating the Xcode project)
## Setup
```bash
# Install XcodeGen if you don't have it
brew install xcodegen
# Generate Xcode project
cd apps/ios
xcodegen generate
# Open in Xcode
open Multica.xcodeproj
```
## Configuration
By default, the app connects to `http://localhost:8080` in debug builds. To change the API URL, edit `Multica/Services/APIClient.swift`.
## Features
- **Authentication** — Passwordless email login (send code → verify)
- **Workspace selection** — Pick from your workspaces
- **Issue list** — Grouped by status, searchable, with status filtering
- **Issue detail** — View/edit title, status, priority, and assignee
- **Comments** — View and add comments with threaded display
- **Agent task runs** — View all historical agent executions for an issue
- **Execution logs** — Real-time streaming of agent tool use, thinking, and output
- **Real-time sync** — WebSocket connection for live updates
## Architecture
- **SwiftUI** with `@Observable` (iOS 17+)
- **MVVM** — ViewModels use `@Observable` macro
- **URLSession** for HTTP networking
- **URLSessionWebSocketTask** for real-time
- **Keychain** for secure token storage
- No third-party dependencies
## Structure
```
Multica/
├── MulticaApp.swift # App entry point
├── Models/ # Codable data models
├── Services/ # API client, WebSocket, Keychain
├── ViewModels/ # @Observable view models
└── Views/
├── Auth/ # Login + code verification
├── Workspace/ # Workspace picker
├── Issues/ # List, detail, create
├── Comments/ # Comment list + input
├── Tasks/ # Agent runs + execution logs
└── Components/ # Shared UI (badges, icons, avatars)
```

24
apps/ios/project.yml Normal file
View File

@@ -0,0 +1,24 @@
name: Multica
options:
bundleIdPrefix: ai.multica
deploymentTarget:
iOS: "17.0"
xcodeVersion: "16.0"
generateEmptyDirectories: true
settings:
base:
SWIFT_VERSION: "5.9"
MARKETING_VERSION: "1.0.0"
CURRENT_PROJECT_VERSION: 1
DEVELOPMENT_TEAM: ""
targets:
Multica:
type: application
platform: iOS
sources:
- Multica
settings:
base:
INFOPLIST_FILE: Multica/Info.plist
PRODUCT_BUNDLE_IDENTIFIER: ai.multica.app
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon

View File

@@ -50,7 +50,7 @@ describe("LoginPage", () => {
render(<LoginPage />);
expect(screen.getByText("Multica")).toBeInTheDocument();
expect(screen.getByText("Turn coding agents into real teammates")).toBeInTheDocument();
expect(screen.getByText("AI-native task management")).toBeInTheDocument();
expect(screen.getByLabelText("Email")).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "Continue" })

View File

@@ -2,7 +2,7 @@
import { Suspense, useState, useEffect, useCallback } from "react";
import { useSearchParams, useRouter } from "next/navigation";
import { useAuthStore, setLoggedInCookie } from "@/features/auth";
import { useAuthStore } from "@/features/auth";
import { useWorkspaceStore } from "@/features/workspace";
import { api } from "@/shared/api";
import {
@@ -146,10 +146,6 @@ function LoginPageContent() {
return;
}
const { token } = await api.verifyCode(email, value);
// Persist session in the browser so the web app stays logged in
localStorage.setItem("multica_token", token);
api.setToken(token);
setLoggedInCookie();
const cliState = searchParams.get("cli_state") || "";
redirectToCliCallback(cliCallback, token, cliState);
return;
@@ -157,8 +153,7 @@ function LoginPageContent() {
await verifyCode(email, value);
const wsList = await api.listWorkspaces();
const lastWsId = localStorage.getItem("multica_workspace_id");
await hydrateWorkspace(wsList, lastWsId);
await hydrateWorkspace(wsList);
router.push(searchParams.get("next") || "/issues");
} catch (err) {
setError(
@@ -286,28 +281,12 @@ function LoginPageContent() {
);
}
const googleClientId = process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID;
const handleGoogleLogin = () => {
if (!googleClientId) return;
const redirectUri = `${window.location.origin}/auth/callback`;
const params = new URLSearchParams({
client_id: googleClientId,
redirect_uri: redirectUri,
response_type: "code",
scope: "openid email profile",
access_type: "offline",
prompt: "select_account",
});
window.location.href = `https://accounts.google.com/o/oauth2/v2/auth?${params}`;
};
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">Multica</CardTitle>
<CardDescription>Turn coding agents into real teammates</CardDescription>
<CardDescription>AI-native task management</CardDescription>
</CardHeader>
<CardContent>
<form id="login-form" onSubmit={handleSendCode} className="space-y-4">
@@ -327,7 +306,7 @@ function LoginPageContent() {
)}
</form>
</CardContent>
<CardFooter className="flex flex-col gap-3">
<CardFooter>
<Button
type="submit"
form="login-form"
@@ -337,46 +316,6 @@ function LoginPageContent() {
>
{submitting ? "Sending code..." : "Continue"}
</Button>
{googleClientId && (
<>
<div className="relative w-full">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-card px-2 text-muted-foreground">or</span>
</div>
</div>
<Button
type="button"
variant="outline"
className="w-full"
size="lg"
onClick={handleGoogleLogin}
disabled={submitting}
>
<svg className="mr-2 h-4 w-4" viewBox="0 0 24 24">
<path
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z"
fill="#4285F4"
/>
<path
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
fill="#34A853"
/>
<path
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
fill="#FBBC05"
/>
<path
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
fill="#EA4335"
/>
</svg>
Continue with Google
</Button>
</>
)}
</CardFooter>
</Card>
</div>

View File

@@ -1,6 +1,5 @@
"use client";
import React from "react";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import {
@@ -43,9 +42,7 @@ import {
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
import { useAuthStore } from "@/features/auth";
import { useWorkspaceStore } from "@/features/workspace";
import { useQuery } from "@tanstack/react-query";
import { inboxKeys, deduplicateInboxItems } from "@core/inbox/queries";
import { api } from "@/shared/api";
import { useInboxStore } from "@/features/inbox";
import { useModalStore } from "@/features/modals";
const primaryNav = [
@@ -76,16 +73,7 @@ export function AppSidebar() {
const workspaces = useWorkspaceStore((s) => s.workspaces);
const switchWorkspace = useWorkspaceStore((s) => s.switchWorkspace);
const wsId = workspace?.id;
const { data: inboxItems = [] } = useQuery({
queryKey: wsId ? inboxKeys.list(wsId) : ["inbox", "disabled"],
queryFn: () => api.listInbox(),
enabled: !!wsId,
});
const unreadCount = React.useMemo(
() => deduplicateInboxItems(inboxItems).filter((i) => !i.read).length,
[inboxItems],
);
const unreadCount = useInboxStore((s) => s.unreadCount());
const logout = () => {
router.push("/");
@@ -144,7 +132,6 @@ export function AppSidebar() {
key={ws.id}
onClick={() => {
if (ws.id !== workspace?.id) {
router.push("/issues");
switchWorkspace(ws.id);
}
}}

File diff suppressed because it is too large Load Diff

View File

@@ -1,22 +1,8 @@
"use client";
import { useState, useEffect, useCallback, useMemo } from "react";
import { useSearchParams } from "next/navigation";
import { useDefaultLayout } from "react-resizable-panels";
import { useQuery } from "@tanstack/react-query";
import { useWorkspaceId } from "@core/hooks";
import {
inboxListOptions,
deduplicateInboxItems,
} from "@core/inbox/queries";
import {
useMarkInboxRead,
useArchiveInbox,
useMarkAllInboxRead,
useArchiveAllInbox,
useArchiveAllReadInbox,
useArchiveCompletedInbox,
} from "@core/inbox/mutations";
import { useInboxStore } from "@/features/inbox";
import { IssueDetail, StatusIcon, PriorityIcon } from "@/features/issues/components";
import { STATUS_CONFIG, PRIORITY_CONFIG } from "@/features/issues/config";
import { useActorName } from "@/features/workspace";
@@ -46,6 +32,7 @@ import {
DropdownMenuItem,
DropdownMenuSeparator,
} from "@/components/ui/dropdown-menu";
import { api } from "@/shared/api";
// ---------------------------------------------------------------------------
// Helpers
@@ -232,24 +219,14 @@ function InboxListItem({
export default function InboxPage() {
const searchParams = useSearchParams();
const urlIssue = searchParams.get("issue") ?? "";
const [selectedKey, setSelectedKeyState] = useState(() => urlIssue);
// Sync from URL when searchParams change (e.g. Next.js navigation)
useEffect(() => {
setSelectedKeyState(urlIssue);
}, [urlIssue]);
const setSelectedKey = useCallback((key: string) => {
setSelectedKeyState(key);
const selectedKey = searchParams.get("issue") ?? "";
const setSelectedKey = (key: string) => {
const url = key ? `/inbox?issue=${key}` : "/inbox";
window.history.replaceState(null, "", url);
}, []);
};
const wsId = useWorkspaceId();
const { data: rawItems = [], isLoading: loading } = useQuery(inboxListOptions(wsId));
const items = useMemo(() => deduplicateInboxItems(rawItems), [rawItems]);
const items = useInboxStore((s) => s.dedupedItems());
const loading = useInboxStore((s) => s.loading);
const { defaultLayout, onLayoutChanged } = useDefaultLayout({
id: "multica_inbox_layout",
@@ -258,58 +235,74 @@ export default function InboxPage() {
const selected = items.find((i) => (i.issue_id ?? i.id) === selectedKey) ?? null;
const unreadCount = items.filter((i) => !i.read).length;
const markReadMutation = useMarkInboxRead();
const archiveMutation = useArchiveInbox();
const markAllReadMutation = useMarkAllInboxRead();
const archiveAllMutation = useArchiveAllInbox();
const archiveAllReadMutation = useArchiveAllReadInbox();
const archiveCompletedMutation = useArchiveCompletedInbox();
// Click-to-read: select + auto-mark-read
const handleSelect = (item: InboxItem) => {
const handleSelect = async (item: InboxItem) => {
setSelectedKey(item.issue_id ?? item.id);
if (!item.read) {
markReadMutation.mutate(item.id, {
onError: () => toast.error("Failed to mark as read"),
});
useInboxStore.getState().markRead(item.id);
try {
await api.markInboxRead(item.id);
} catch {
// Rollback: refetch to get server truth
useInboxStore.getState().fetch();
toast.error("Failed to mark as read");
}
}
};
const handleArchive = (id: string) => {
const archived = items.find((i) => i.id === id);
if (archived && (archived.issue_id ?? archived.id) === selectedKey) setSelectedKey("");
archiveMutation.mutate(id, {
onError: () => toast.error("Failed to archive"),
});
const handleArchive = async (id: string) => {
try {
await api.archiveInbox(id);
useInboxStore.getState().archive(id);
const archived = items.find((i) => i.id === id);
if (archived && (archived.issue_id ?? archived.id) === selectedKey) setSelectedKey("");
} catch {
toast.error("Failed to archive");
}
};
// Batch operations
const handleMarkAllRead = () => {
markAllReadMutation.mutate(undefined, {
onError: () => toast.error("Failed to mark all as read"),
});
const handleMarkAllRead = async () => {
try {
useInboxStore.getState().markAllRead();
await api.markAllInboxRead();
} catch {
toast.error("Failed to mark all as read");
useInboxStore.getState().fetch();
}
};
const handleArchiveAll = () => {
setSelectedKey("");
archiveAllMutation.mutate(undefined, {
onError: () => toast.error("Failed to archive all"),
});
const handleArchiveAll = async () => {
try {
useInboxStore.getState().archiveAll();
setSelectedKey("");
await api.archiveAllInbox();
} catch {
toast.error("Failed to archive all");
useInboxStore.getState().fetch();
}
};
const handleArchiveAllRead = () => {
const readKeys = items.filter((i) => i.read).map((i) => i.issue_id ?? i.id);
if (readKeys.includes(selectedKey)) setSelectedKey("");
archiveAllReadMutation.mutate(undefined, {
onError: () => toast.error("Failed to archive read items"),
});
const handleArchiveAllRead = async () => {
try {
const readKeys = items.filter((i) => i.read).map((i) => i.issue_id ?? i.id);
useInboxStore.getState().archiveAllRead();
if (readKeys.includes(selectedKey)) setSelectedKey("");
await api.archiveAllReadInbox();
} catch {
toast.error("Failed to archive read items");
useInboxStore.getState().fetch();
}
};
const handleArchiveCompleted = () => {
setSelectedKey("");
archiveCompletedMutation.mutate(undefined, {
onError: () => toast.error("Failed to archive completed"),
});
const handleArchiveCompleted = async () => {
try {
await api.archiveCompletedInbox();
setSelectedKey("");
await useInboxStore.getState().fetch();
} catch {
toast.error("Failed to archive completed");
}
};
if (loading) {
@@ -420,11 +413,10 @@ export default function InboxPage() {
<div className="flex flex-col min-h-0 h-full">
{selected?.issue_id ? (
<IssueDetail
key={selected.id}
key={selected.issue_id}
issueId={selected.issue_id}
defaultSidebarOpen={false}
layoutId="multica_inbox_issue_detail_layout"
highlightCommentId={selected.details?.comment_id ?? undefined}
onDelete={() => {
handleArchive(selected.id);
}}

View File

@@ -2,7 +2,6 @@ import { Suspense, forwardRef, useRef, useState, useImperativeHandle } from "rea
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor, act, fireEvent } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { Issue, Comment, TimelineEntry } from "@/shared/types";
// Mock next/navigation
@@ -63,11 +62,34 @@ vi.mock("@/features/workspace", () => ({
}),
}));
// Mock issue store — only client state remains (activeIssueId)
// Mock issue store — supply a stable full issue object so storeIssue
// doesn't create a new reference each render (avoids infinite effect loop)
// and has all required fields for rendering.
const stableStoreIssues = vi.hoisted(() => [
{
id: "issue-1",
workspace_id: "ws-1",
number: 1,
identifier: "TES-1",
title: "Implement authentication",
description: "Add JWT auth to the backend",
status: "in_progress",
priority: "high",
assignee_type: "member",
assignee_id: "user-1",
creator_type: "member",
creator_id: "user-1",
parent_issue_id: null,
position: 0,
due_date: "2026-06-01T00:00:00Z",
created_at: "2026-01-15T00:00:00Z",
updated_at: "2026-01-20T00:00:00Z",
},
]);
vi.mock("@/features/issues", () => ({
useIssueStore: Object.assign(
(selector: (s: any) => any) => selector({ activeIssueId: null }),
{ getState: () => ({ activeIssueId: null, setActiveIssue: vi.fn() }) },
(selector: (s: any) => any) => selector({ issues: stableStoreIssues }),
{ getState: () => ({ issues: stableStoreIssues, addIssue: vi.fn(), updateIssue: vi.fn(), removeIssue: vi.fn() }) },
),
}));
@@ -82,12 +104,9 @@ vi.mock("@/components/ui/calendar", () => ({
Calendar: () => null,
}));
// Mock ContentEditor (Tiptap needs real DOM)
vi.mock("@/features/editor", () => ({
ReadonlyContent: ({ content }: { content: string }) => (
<div data-testid="readonly-content">{content}</div>
),
ContentEditor: forwardRef(({ defaultValue, onUpdate, placeholder, onSubmit }: any, ref: any) => {
// Mock RichTextEditor (Tiptap needs real DOM)
vi.mock("@/components/common/rich-text-editor", () => ({
RichTextEditor: forwardRef(({ defaultValue, onUpdate, placeholder, onSubmit }: any, ref: any) => {
const valueRef = useRef(defaultValue || "");
const [value, setValue] = useState(defaultValue || "");
useImperativeHandle(ref, () => ({
@@ -113,27 +132,6 @@ vi.mock("@/features/editor", () => ({
/>
);
}),
TitleEditor: forwardRef(({ defaultValue, placeholder, onBlur, onChange }: any, ref: any) => {
const valueRef = useRef(defaultValue || "");
const [value, setValue] = useState(defaultValue || "");
useImperativeHandle(ref, () => ({
getText: () => valueRef.current,
focus: () => {},
}));
return (
<input
value={value}
onChange={(e) => {
valueRef.current = e.target.value;
setValue(e.target.value);
onChange?.(e.target.value);
}}
onBlur={() => onBlur?.(valueRef.current)}
placeholder={placeholder}
data-testid="title-editor"
/>
);
}),
}));
// Mock Markdown renderer
@@ -163,10 +161,9 @@ vi.mock("@/shared/api", () => ({
listIssueSubscribers: vi.fn().mockResolvedValue([]),
subscribeToIssue: vi.fn().mockResolvedValue(undefined),
unsubscribeFromIssue: vi.fn().mockResolvedValue(undefined),
getActiveTasksForIssue: vi.fn().mockResolvedValue({ tasks: [] }),
getActiveTaskForIssue: vi.fn().mockResolvedValue({ task: null }),
listTasksByIssue: vi.fn().mockResolvedValue([]),
listTaskMessages: vi.fn().mockResolvedValue([]),
listChildIssues: vi.fn().mockResolvedValue({ issues: [] }),
},
}));
@@ -217,26 +214,14 @@ const mockTimeline: TimelineEntry[] = [
import IssueDetailPage from "./page";
function createTestQueryClient() {
return new QueryClient({
defaultOptions: {
queries: { retry: false, gcTime: 0 },
mutations: { retry: false },
},
});
}
// React 19 use(Promise) needs the promise to resolve within act + Suspense
async function renderPage(id = "issue-1") {
const queryClient = createTestQueryClient();
let result: ReturnType<typeof render>;
await act(async () => {
result = render(
<QueryClientProvider client={queryClient}>
<Suspense fallback={<div>Suspense loading...</div>}>
<IssueDetailPage params={Promise.resolve({ id })} />
</Suspense>
</QueryClientProvider>,
<Suspense fallback={<div>Suspense loading...</div>}>
<IssueDetailPage params={Promise.resolve({ id })} />
</Suspense>,
);
});
return result!;

View File

@@ -1,7 +1,6 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { Issue } from "@/shared/types";
// Mock next/navigation
@@ -62,28 +61,36 @@ vi.mock("sonner", () => ({
// Mock api
const mockUpdateIssue = vi.fn();
const mockListIssues = vi.hoisted(() => vi.fn().mockResolvedValue({ issues: [], total: 0 }));
vi.mock("@/shared/api", () => ({
api: {
listIssues: (...args: any[]) => mockListIssues(...args),
listIssues: vi.fn().mockResolvedValue({ issues: [], total: 0 }),
updateIssue: (...args: any[]) => mockUpdateIssue(...args),
},
}));
// Mock issue store — only client state remains
const mockIssueClientState = { activeIssueId: null, setActiveIssue: vi.fn() };
// Mock the issue store
let mockStoreState: {
issues: Issue[];
loading: boolean;
fetch: () => Promise<void>;
setIssues: (issues: Issue[]) => void;
addIssue: (issue: Issue) => void;
updateIssue: (id: string, updates: Partial<Issue>) => void;
removeIssue: (id: string) => void;
};
vi.mock("@/features/issues/store", () => ({
useIssueStore: Object.assign(
(selector?: any) => (selector ? selector(mockIssueClientState) : mockIssueClientState),
{ getState: () => mockIssueClientState },
(selector?: any) => (selector ? selector(mockStoreState) : mockStoreState),
{ getState: () => mockStoreState },
),
}));
vi.mock("@/features/issues", () => ({
useIssueStore: Object.assign(
(selector?: any) => (selector ? selector(mockIssueClientState) : mockIssueClientState),
{ getState: () => mockIssueClientState },
(selector?: any) => (selector ? selector(mockStoreState) : mockStoreState),
{ getState: () => mockStoreState },
),
StatusIcon: () => null,
PriorityIcon: () => null,
@@ -275,80 +282,90 @@ const mockIssues: Issue[] = [
import IssuesPage from "./page";
function renderWithQuery(ui: React.ReactElement) {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 }, mutations: { retry: false } } });
return render(<QueryClientProvider client={qc}>{ui}</QueryClientProvider>);
}
describe("IssuesPage", () => {
beforeEach(() => {
vi.clearAllMocks();
mockListIssues.mockResolvedValue({ issues: [], total: 0 });
mockStoreState = {
issues: [],
loading: true,
fetch: vi.fn(),
setIssues: vi.fn(),
addIssue: vi.fn(),
updateIssue: vi.fn(),
removeIssue: vi.fn(),
};
mockViewState.viewMode = "board";
mockViewState.statusFilters = [];
mockViewState.priorityFilters = [];
});
it("shows loading state initially", () => {
renderWithQuery(<IssuesPage />);
mockStoreState.loading = true;
mockStoreState.issues = [];
render(<IssuesPage />);
expect(screen.getAllByRole("generic").some(el => el.getAttribute("data-slot") === "skeleton")).toBe(true);
});
it("renders issues in board view after loading", async () => {
// issueListOptions makes 2 calls: open_only + closed page. Return issues for open, empty for closed.
mockListIssues.mockImplementation((params: any) =>
Promise.resolve(params?.open_only ? { issues: mockIssues, total: mockIssues.length } : { issues: [], total: 0 }),
);
mockStoreState.loading = false;
mockStoreState.issues = mockIssues;
renderWithQuery(<IssuesPage />);
render(<IssuesPage />);
await screen.findByText("Implement auth");
expect(screen.getByText("Implement auth")).toBeInTheDocument();
expect(screen.getByText("Design landing page")).toBeInTheDocument();
expect(screen.getByText("Write tests")).toBeInTheDocument();
});
it("renders board columns", async () => {
mockListIssues.mockImplementation((params: any) =>
Promise.resolve(params?.open_only ? { issues: mockIssues, total: mockIssues.length } : { issues: [], total: 0 }),
);
mockStoreState.loading = false;
mockStoreState.issues = mockIssues;
renderWithQuery(<IssuesPage />);
render(<IssuesPage />);
await screen.findByText("Backlog");
expect(screen.getAllByText("Backlog").length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText("Todo").length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText("In Progress").length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText("In Review").length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText("Done").length).toBeGreaterThanOrEqual(1);
});
it("shows workspace breadcrumb", async () => {
renderWithQuery(<IssuesPage />);
it("shows workspace breadcrumb", () => {
mockStoreState.loading = false;
mockStoreState.issues = [];
await screen.findByText("Issues");
render(<IssuesPage />);
expect(screen.getByText("Issues")).toBeInTheDocument();
});
it("shows scope buttons", async () => {
renderWithQuery(<IssuesPage />);
it("shows scope buttons", () => {
mockStoreState.loading = false;
mockStoreState.issues = [];
await screen.findByText("All");
render(<IssuesPage />);
expect(screen.getByText("All")).toBeInTheDocument();
expect(screen.getByText("Members")).toBeInTheDocument();
expect(screen.getByText("Agents")).toBeInTheDocument();
});
it("shows filter and display icon buttons", async () => {
mockListIssues.mockImplementation((params: any) =>
Promise.resolve(params?.open_only ? { issues: mockIssues, total: mockIssues.length } : { issues: [], total: 0 }),
);
it("shows filter and display icon buttons", () => {
mockStoreState.loading = false;
mockStoreState.issues = mockIssues;
renderWithQuery(<IssuesPage />);
render(<IssuesPage />);
await screen.findByText("Implement auth");
// Filter and Display are now icon-only buttons, verify they render as buttons
const buttons = screen.getAllByRole("button");
expect(buttons.length).toBeGreaterThan(0);
});
it("shows empty board view when no issues exist", () => {
renderWithQuery(<IssuesPage />);
mockStoreState.loading = false;
mockStoreState.issues = [];
render(<IssuesPage />);
// Should still render the board/list view, not a "no issues" message
expect(screen.queryByText("No matching issues")).not.toBeInTheDocument();

View File

@@ -1,28 +0,0 @@
import { Skeleton } from "@/components/ui/skeleton";
export default function DashboardLoading() {
return (
<div className="flex flex-1 min-h-0 flex-col">
{/* Header skeleton */}
<div className="flex h-12 shrink-0 items-center gap-2 border-b px-4">
<Skeleton className="h-5 w-5 rounded" />
<Skeleton className="h-4 w-32" />
</div>
{/* Toolbar skeleton */}
<div className="flex h-12 shrink-0 items-center justify-between border-b px-4">
<Skeleton className="h-5 w-24" />
<Skeleton className="h-8 w-24" />
</div>
{/* Content skeleton */}
<div className="flex-1 p-4 space-y-3">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="flex items-center gap-3">
<Skeleton className="h-4 w-4 rounded" />
<Skeleton className="h-4 flex-1 max-w-md" />
<Skeleton className="h-4 w-16" />
</div>
))}
</div>
</div>
);
}

View File

@@ -36,11 +36,8 @@ import {
DropdownMenuSubContent,
} from "@/components/ui/dropdown-menu";
import { toast } from "sonner";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useAuthStore } from "@/features/auth";
import { useWorkspaceStore } from "@/features/workspace";
import { useWorkspaceId } from "@core/hooks";
import { memberListOptions, workspaceKeys } from "@core/workspace/queries";
import { api } from "@/shared/api";
const roleConfig: Record<MemberRole, { label: string; icon: typeof Crown; description: string }> = {
@@ -143,9 +140,8 @@ function MemberRow({
export function MembersTab() {
const user = useAuthStore((s) => s.user);
const workspace = useWorkspaceStore((s) => s.workspace);
const qc = useQueryClient();
const wsId = useWorkspaceId();
const { data: members = [] } = useQuery(memberListOptions(wsId));
const members = useWorkspaceStore((s) => s.members);
const refreshMembers = useWorkspaceStore((s) => s.refreshMembers);
const [inviteEmail, setInviteEmail] = useState("");
const [inviteRole, setInviteRole] = useState<MemberRole>("member");
@@ -172,7 +168,7 @@ export function MembersTab() {
});
setInviteEmail("");
setInviteRole("member");
qc.invalidateQueries({ queryKey: workspaceKeys.members(wsId) });
await refreshMembers();
toast.success("Member added");
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed to add member");
@@ -186,7 +182,7 @@ export function MembersTab() {
setMemberActionId(memberId);
try {
await api.updateMember(workspace.id, memberId, { role });
qc.invalidateQueries({ queryKey: workspaceKeys.members(wsId) });
await refreshMembers();
toast.success("Role updated");
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed to update member");
@@ -205,7 +201,7 @@ export function MembersTab() {
setMemberActionId(member.id);
try {
await api.deleteMember(workspace.id, member.id);
qc.invalidateQueries({ queryKey: workspaceKeys.members(wsId) });
await refreshMembers();
toast.success("Member removed");
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed to remove member");

View File

@@ -1,133 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import { Save, Plus, Trash2 } from "lucide-react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { toast } from "sonner";
import { useQuery } from "@tanstack/react-query";
import { useAuthStore } from "@/features/auth";
import { useWorkspaceStore } from "@/features/workspace";
import { useWorkspaceId } from "@core/hooks";
import { memberListOptions } from "@core/workspace/queries";
import { api } from "@/shared/api";
import type { WorkspaceRepo } from "@/shared/types";
export function RepositoriesTab() {
const user = useAuthStore((s) => s.user);
const workspace = useWorkspaceStore((s) => s.workspace);
const wsId = useWorkspaceId();
const { data: members = [] } = useQuery(memberListOptions(wsId));
const updateWorkspace = useWorkspaceStore((s) => s.updateWorkspace);
const [repos, setRepos] = useState<WorkspaceRepo[]>(workspace?.repos ?? []);
const [saving, setSaving] = useState(false);
const currentMember = members.find((m) => m.user_id === user?.id) ?? null;
const canManageWorkspace = currentMember?.role === "owner" || currentMember?.role === "admin";
useEffect(() => {
setRepos(workspace?.repos ?? []);
}, [workspace]);
const handleSave = async () => {
if (!workspace) return;
setSaving(true);
try {
const updated = await api.updateWorkspace(workspace.id, { repos });
updateWorkspace(updated);
toast.success("Repositories saved");
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed to save repositories");
} finally {
setSaving(false);
}
};
const handleAddRepo = () => {
setRepos([...repos, { url: "", description: "" }]);
};
const handleRemoveRepo = (index: number) => {
setRepos(repos.filter((_, i) => i !== index));
};
const handleRepoChange = (index: number, field: keyof WorkspaceRepo, value: string) => {
setRepos(repos.map((r, i) => (i === index ? { ...r, [field]: value } : r)));
};
if (!workspace) return null;
return (
<div className="space-y-8">
<section className="space-y-4">
<h2 className="text-sm font-semibold">Repositories</h2>
<Card>
<CardContent className="space-y-3">
<p className="text-xs text-muted-foreground">
GitHub repositories associated with this workspace. Agents use these to clone and work on code.
</p>
{repos.map((repo, index) => (
<div key={index} className="flex gap-2">
<div className="flex-1 space-y-1.5">
<Input
type="url"
value={repo.url}
onChange={(e) => handleRepoChange(index, "url", e.target.value)}
disabled={!canManageWorkspace}
placeholder="https://github.com/org/repo"
className="text-sm"
/>
<Input
type="text"
value={repo.description}
onChange={(e) => handleRepoChange(index, "description", e.target.value)}
disabled={!canManageWorkspace}
placeholder="Description (e.g. Go backend + Next.js frontend)"
className="text-sm"
/>
</div>
{canManageWorkspace && (
<Button
variant="ghost"
size="icon"
className="mt-0.5 shrink-0 text-muted-foreground hover:text-destructive"
onClick={() => handleRemoveRepo(index)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
)}
</div>
))}
{canManageWorkspace && (
<div className="flex items-center justify-between pt-1">
<Button variant="outline" size="sm" onClick={handleAddRepo}>
<Plus className="h-3 w-3" />
Add repository
</Button>
<Button
size="sm"
onClick={handleSave}
disabled={saving}
>
<Save className="h-3 w-3" />
{saving ? "Saving..." : "Save"}
</Button>
</div>
)}
{!canManageWorkspace && (
<p className="text-xs text-muted-foreground">
Only admins and owners can manage repositories.
</p>
)}
</CardContent>
</Card>
</section>
</div>
);
}

View File

@@ -22,17 +22,6 @@ import {
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Skeleton } from "@/components/ui/skeleton";
import { toast } from "sonner";
import { api } from "@/shared/api";
@@ -44,17 +33,13 @@ export function TokensTab() {
const [newToken, setNewToken] = useState<string | null>(null);
const [tokenCopied, setTokenCopied] = useState(false);
const [tokenRevoking, setTokenRevoking] = useState<string | null>(null);
const [revokeConfirmId, setRevokeConfirmId] = useState<string | null>(null);
const [tokensLoading, setTokensLoading] = useState(true);
const loadTokens = useCallback(async () => {
try {
const list = await api.listPersonalAccessTokens();
setTokens(list);
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed to load tokens");
} finally {
setTokensLoading(false);
} catch {
// ignore — tokens section simply stays empty
}
}, []);
@@ -132,21 +117,7 @@ export function TokensTab() {
</CardContent>
</Card>
{tokensLoading ? (
<div className="space-y-2">
{Array.from({ length: 2 }).map((_, i) => (
<Card key={i}>
<CardContent className="flex items-center gap-3">
<div className="flex-1 space-y-1.5">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-3 w-48" />
</div>
<Skeleton className="h-8 w-8 rounded" />
</CardContent>
</Card>
))}
</div>
) : tokens.length > 0 && (
{tokens.length > 0 && (
<div className="space-y-2">
{tokens.map((t) => (
<Card key={t.id}>
@@ -164,7 +135,7 @@ export function TokensTab() {
<Button
variant="ghost"
size="icon-sm"
onClick={() => setRevokeConfirmId(t.id)}
onClick={() => handleRevokeToken(t.id)}
disabled={tokenRevoking === t.id}
aria-label={`Revoke ${t.name}`}
>
@@ -181,29 +152,6 @@ export function TokensTab() {
)}
</section>
<AlertDialog open={!!revokeConfirmId} onOpenChange={(v) => { if (!v) setRevokeConfirmId(null); }}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Revoke token</AlertDialogTitle>
<AlertDialogDescription>
This token will be permanently revoked and can no longer be used. This cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={async () => {
if (revokeConfirmId) await handleRevokeToken(revokeConfirmId);
setRevokeConfirmId(null);
}}
>
Revoke
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<Dialog open={!!newToken} onOpenChange={(v) => { if (!v) { setNewToken(null); setTokenCopied(false); } }}>
<DialogContent>
<DialogHeader>

View File

@@ -1,7 +1,7 @@
"use client";
import { useEffect, useState } from "react";
import { Save, LogOut } from "lucide-react";
import { Save, LogOut, Plus, Trash2 } from "lucide-react";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
@@ -18,18 +18,15 @@ import {
AlertDialogAction,
} from "@/components/ui/alert-dialog";
import { toast } from "sonner";
import { useQuery } from "@tanstack/react-query";
import { useAuthStore } from "@/features/auth";
import { useWorkspaceStore } from "@/features/workspace";
import { useWorkspaceId } from "@core/hooks";
import { memberListOptions } from "@core/workspace/queries";
import { api } from "@/shared/api";
import type { WorkspaceRepo } from "@/shared/types";
export function WorkspaceTab() {
const user = useAuthStore((s) => s.user);
const workspace = useWorkspaceStore((s) => s.workspace);
const wsId = useWorkspaceId();
const { data: members = [] } = useQuery(memberListOptions(wsId));
const members = useWorkspaceStore((s) => s.members);
const updateWorkspace = useWorkspaceStore((s) => s.updateWorkspace);
const leaveWorkspace = useWorkspaceStore((s) => s.leaveWorkspace);
const deleteWorkspace = useWorkspaceStore((s) => s.deleteWorkspace);
@@ -37,6 +34,7 @@ export function WorkspaceTab() {
const [name, setName] = useState(workspace?.name ?? "");
const [description, setDescription] = useState(workspace?.description ?? "");
const [context, setContext] = useState(workspace?.context ?? "");
const [repos, setRepos] = useState<WorkspaceRepo[]>(workspace?.repos ?? []);
const [saving, setSaving] = useState(false);
const [actionId, setActionId] = useState<string | null>(null);
const [confirmAction, setConfirmAction] = useState<{
@@ -54,6 +52,7 @@ export function WorkspaceTab() {
setName(workspace?.name ?? "");
setDescription(workspace?.description ?? "");
setContext(workspace?.context ?? "");
setRepos(workspace?.repos ?? []);
}, [workspace]);
const handleSave = async () => {
@@ -64,6 +63,7 @@ export function WorkspaceTab() {
name,
description,
context,
repos,
});
updateWorkspace(updated);
toast.success("Workspace settings saved");
@@ -74,6 +74,18 @@ export function WorkspaceTab() {
}
};
const handleAddRepo = () => {
setRepos([...repos, { url: "", description: "" }]);
};
const handleRemoveRepo = (index: number) => {
setRepos(repos.filter((_, i) => i !== index));
};
const handleRepoChange = (index: number, field: keyof WorkspaceRepo, value: string) => {
setRepos(repos.map((r, i) => (i === index ? { ...r, [field]: value } : r)));
};
const handleLeaveWorkspace = () => {
if (!workspace) return;
setConfirmAction({
@@ -179,6 +191,69 @@ export function WorkspaceTab() {
</Card>
</section>
{/* Repositories */}
<section className="space-y-4">
<h2 className="text-sm font-semibold">Repositories</h2>
<Card>
<CardContent className="space-y-3">
<p className="text-xs text-muted-foreground">
GitHub repositories associated with this workspace. Agents use these to clone and work on code.
</p>
{repos.map((repo, index) => (
<div key={index} className="flex gap-2">
<div className="flex-1 space-y-1.5">
<Input
type="url"
value={repo.url}
onChange={(e) => handleRepoChange(index, "url", e.target.value)}
disabled={!canManageWorkspace}
placeholder="https://github.com/org/repo"
className="text-sm"
/>
<Input
type="text"
value={repo.description}
onChange={(e) => handleRepoChange(index, "description", e.target.value)}
disabled={!canManageWorkspace}
placeholder="Description (e.g. Go backend + Next.js frontend)"
className="text-sm"
/>
</div>
{canManageWorkspace && (
<Button
variant="ghost"
size="icon"
className="mt-0.5 shrink-0 text-muted-foreground hover:text-destructive"
onClick={() => handleRemoveRepo(index)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
)}
</div>
))}
{canManageWorkspace && (
<div className="flex items-center justify-between pt-1">
<Button variant="outline" size="sm" onClick={handleAddRepo}>
<Plus className="h-3 w-3" />
Add repository
</Button>
<Button
size="sm"
onClick={handleSave}
disabled={saving || !name.trim() || !canManageWorkspace}
>
<Save className="h-3 w-3" />
{saving ? "Saving..." : "Save"}
</Button>
</div>
)}
</CardContent>
</Card>
</section>
{/* Danger Zone */}
<section className="space-y-4">
<div className="flex items-center gap-2">

View File

@@ -1,6 +1,6 @@
"use client";
import { User, Palette, Key, Settings, Users, FolderGit2 } from "lucide-react";
import { User, Palette, Key, Settings, Users } from "lucide-react";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import { useWorkspaceStore } from "@/features/workspace";
import { AccountTab } from "./_components/account-tab";
@@ -8,7 +8,6 @@ import { AppearanceTab } from "./_components/general-tab";
import { TokensTab } from "./_components/tokens-tab";
import { WorkspaceTab } from "./_components/workspace-tab";
import { MembersTab } from "./_components/members-tab";
import { RepositoriesTab } from "./_components/repositories-tab";
const accountTabs = [
{ value: "profile", label: "Profile", icon: User },
@@ -18,7 +17,6 @@ const accountTabs = [
const workspaceTabs = [
{ value: "workspace", label: "General", icon: Settings },
{ value: "repositories", label: "Repositories", icon: FolderGit2 },
{ value: "members", label: "Members", icon: Users },
];
@@ -62,7 +60,6 @@ export default function SettingsPage() {
<TabsContent value="appearance"><AppearanceTab /></TabsContent>
<TabsContent value="tokens"><TokensTab /></TabsContent>
<TabsContent value="workspace"><WorkspaceTab /></TabsContent>
<TabsContent value="repositories"><RepositoriesTab /></TabsContent>
<TabsContent value="members"><MembersTab /></TabsContent>
</div>
</div>

View File

@@ -1,90 +0,0 @@
"use client";
import { Suspense, useEffect, useState } from "react";
import { useSearchParams, useRouter } from "next/navigation";
import { useAuthStore } from "@/features/auth";
import { useWorkspaceStore } from "@/features/workspace";
import { api } from "@/shared/api";
import {
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
} from "@/components/ui/card";
import { Loader2 } from "lucide-react";
function CallbackContent() {
const router = useRouter();
const searchParams = useSearchParams();
const loginWithGoogle = useAuthStore((s) => s.loginWithGoogle);
const hydrateWorkspace = useWorkspaceStore((s) => s.hydrateWorkspace);
const [error, setError] = useState("");
useEffect(() => {
const code = searchParams.get("code");
if (!code) {
setError("Missing authorization code");
return;
}
const errorParam = searchParams.get("error");
if (errorParam) {
setError(errorParam === "access_denied" ? "Access denied" : errorParam);
return;
}
const redirectUri = `${window.location.origin}/auth/callback`;
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 (
<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">Login Failed</CardTitle>
<CardDescription>{error}</CardDescription>
</CardHeader>
<CardContent className="flex justify-center">
<a href="/login" className="text-primary underline-offset-4 hover:underline">
Back to login
</a>
</CardContent>
</Card>
</div>
);
}
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">Signing in...</CardTitle>
<CardDescription>Please wait while we complete your login</CardDescription>
</CardHeader>
<CardContent className="flex justify-center">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</CardContent>
</Card>
</div>
);
}
export default function CallbackPage() {
return (
<Suspense fallback={null}>
<CallbackContent />
</Suspense>
);
}

View File

@@ -30,12 +30,3 @@
background-color: var(--sidebar-accent);
color: var(--sidebar-accent-foreground);
}
/* Sonner toast: align icon to first line of text, not vertically centered */
[data-sonner-toast] {
align-items: flex-start !important;
}
[data-sonner-toast] [data-icon] {
margin-top: 2.5px;
}

View File

@@ -1,13 +1,12 @@
import type { Metadata, Viewport } from "next";
import { cookies } from "next/headers";
import { Geist, Geist_Mono } from "next/font/google";
import { ThemeProvider } from "@/components/theme-provider";
import { Toaster } from "@/components/ui/sonner";
import { cn } from "@/lib/utils";
import { QueryProvider } from "@core/provider";
import { AuthInitializer } from "@/features/auth";
import { WSProvider } from "@/features/realtime";
import { ModalRegistry } from "@/features/modals";
import { LocaleSync } from "@/components/locale-sync";
import "./globals.css";
const geist = Geist({ subsets: ["latin"], variable: "--font-sans" });
@@ -42,36 +41,30 @@ export const metadata: Metadata = {
twitter: {
card: "summary_large_image",
},
alternates: {
canonical: "/",
},
robots: {
index: true,
follow: true,
},
};
export default function RootLayout({
export default async function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
const cookieStore = await cookies();
const locale = cookieStore.get("multica-locale")?.value;
const lang = locale === "zh" ? "zh" : "en";
return (
<html
lang="en"
lang={lang}
suppressHydrationWarning
className={cn("antialiased font-sans h-full", geist.variable, geistMono.variable)}
>
<body className="h-full overflow-hidden">
<LocaleSync />
<ThemeProvider>
<QueryProvider>
<AuthInitializer>
<WSProvider>{children}</WSProvider>
</AuthInitializer>
<ModalRegistry />
<Toaster />
</QueryProvider>
<AuthInitializer>
<WSProvider>{children}</WSProvider>
</AuthInitializer>
<ModalRegistry />
<Toaster />
</ThemeProvider>
</body>
</html>

View File

@@ -3,28 +3,33 @@
import { useRef } from "react";
import { Paperclip } from "lucide-react";
import { cn } from "@/lib/utils";
import type { UploadResult } from "@/shared/hooks/use-file-upload";
interface FileUploadButtonProps {
/** Called with the selected File — caller handles upload. */
onSelect: (file: File) => void;
onUpload: (file: File) => Promise<UploadResult | null>;
onInsert?: (result: UploadResult, isImage: boolean) => void;
disabled?: boolean;
className?: string;
size?: "sm" | "default";
}
function FileUploadButton({
onSelect,
onUpload,
onInsert,
disabled,
className,
size = "default",
}: FileUploadButtonProps) {
const inputRef = useRef<HTMLInputElement>(null);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const handleChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
e.target.value = "";
onSelect(file);
const result = await onUpload(file);
if (result && onInsert) {
onInsert(result, file.type.startsWith("image/"));
}
};
const iconSize = size === "sm" ? "h-3.5 w-3.5" : "h-4 w-4";

View File

@@ -2,11 +2,9 @@
import type { ReactNode } from "react";
import { Users } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { HoverCard, HoverCardTrigger, HoverCardContent } from "@/components/ui/hover-card";
import { ActorAvatar } from "@/components/common/actor-avatar";
import { useWorkspaceId } from "@core/hooks";
import { memberListOptions, agentListOptions } from "@core/workspace/queries";
import { useWorkspaceStore } from "@/features/workspace";
interface MentionHoverCardProps {
type: string;
@@ -15,9 +13,8 @@ interface MentionHoverCardProps {
}
function MentionHoverCard({ type, id, children }: MentionHoverCardProps) {
const wsId = useWorkspaceId();
const { data: members = [] } = useQuery(memberListOptions(wsId));
const { data: agents = [] } = useQuery(agentListOptions(wsId));
const members = useWorkspaceStore((s) => s.members);
const agents = useWorkspaceStore((s) => s.agents);
if (type === "all") {
return (

View File

@@ -8,17 +8,12 @@ import {
useRef,
useState,
} from "react";
import { Hash, Users } from "lucide-react";
import { ReactRenderer } from "@tiptap/react";
import { computePosition, offset, flip, shift } from "@floating-ui/dom";
import type { QueryClient } from "@tanstack/react-query";
import { useWorkspaceStore } from "@/features/workspace";
import { issueKeys } from "@core/issues/queries";
import { workspaceKeys } from "@core/workspace/queries";
import type { Issue, ListIssuesResponse, MemberWithUser, Agent } from "@/shared/types";
import { useIssueStore } from "@/features/issues";
import { ActorAvatar } from "@/components/common/actor-avatar";
import { StatusIcon } from "@/features/issues/components/status-icon";
import { Badge } from "@/components/ui/badge";
import type { IssueStatus } from "@/shared/types";
import type { SuggestionOptions, SuggestionProps } from "@tiptap/suggestion";
// ---------------------------------------------------------------------------
@@ -29,10 +24,8 @@ export interface MentionItem {
id: string;
label: string;
type: "member" | "agent" | "issue" | "all";
/** Secondary text shown beside the label (e.g. issue title) */
/** Secondary text shown below the label (e.g. issue title) */
description?: string;
/** Issue status for StatusIcon rendering */
status?: IssueStatus;
}
interface MentionListProps {
@@ -44,33 +37,6 @@ export interface MentionListRef {
onKeyDown: (props: { event: KeyboardEvent }) => boolean;
}
// ---------------------------------------------------------------------------
// Group items by section
// ---------------------------------------------------------------------------
interface MentionGroup {
label: string;
items: MentionItem[];
}
function groupItems(items: MentionItem[]): MentionGroup[] {
const users: MentionItem[] = [];
const issues: MentionItem[] = [];
for (const item of items) {
if (item.type === "issue") {
issues.push(item);
} else {
users.push(item);
}
}
const groups: MentionGroup[] = [];
if (users.length > 0) groups.push({ label: "Users", items: users });
if (issues.length > 0) groups.push({ label: "Issues", items: issues });
return groups;
}
// ---------------------------------------------------------------------------
// MentionList — the popup rendered inside the editor
// ---------------------------------------------------------------------------
@@ -122,116 +88,63 @@ const MentionList = forwardRef<MentionListRef, MentionListProps>(
);
}
const groups = groupItems(items);
// Build a flat index mapping: globalIndex → item
let globalIndex = 0;
return (
<div className="rounded-md border bg-popover py-1 shadow-md w-72 max-h-[300px] overflow-y-auto">
{groups.map((group) => (
<div key={group.label}>
<div className="px-3 py-1.5 text-xs font-medium text-muted-foreground">
{group.label}
<div className="rounded-md border bg-popover py-1 shadow-md min-w-[180px] max-h-[240px] overflow-y-auto">
{items.map((item, index) => (
<button
ref={(el) => { itemRefs.current[index] = el; }}
key={`${item.type}-${item.id}`}
className={`flex w-full items-center gap-2 px-2.5 py-1.5 text-left text-sm transition-colors ${
index === selectedIndex ? "bg-accent" : "hover:bg-accent/50"
}`}
onClick={() => selectItem(index)}
>
{item.type === "all" ? (
<span className="inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary">
<Users className="h-3 w-3" />
</span>
) : item.type === "issue" ? (
<span className="inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary">
<Hash className="h-3 w-3" />
</span>
) : (
<ActorAvatar
actorType={item.type}
actorId={item.id}
size={20}
/>
)}
<div className="flex flex-col min-w-0">
<span className="truncate">{item.label}</span>
{item.description && (
<span className="truncate text-xs text-muted-foreground">{item.description}</span>
)}
</div>
{group.items.map((item) => {
const idx = globalIndex++;
return (
<MentionRow
key={`${item.type}-${item.id}`}
item={item}
selected={idx === selectedIndex}
onSelect={() => selectItem(idx)}
buttonRef={(el) => { itemRefs.current[idx] = el; }}
/>
);
})}
</div>
</button>
))}
</div>
);
},
);
// ---------------------------------------------------------------------------
// MentionRow — single item in the list
// ---------------------------------------------------------------------------
function MentionRow({
item,
selected,
onSelect,
buttonRef,
}: {
item: MentionItem;
selected: boolean;
onSelect: () => void;
buttonRef: (el: HTMLButtonElement | null) => void;
}) {
if (item.type === "issue") {
return (
<button
ref={buttonRef}
className={`flex w-full items-center gap-2.5 px-3 py-1.5 text-left text-xs transition-colors ${
selected ? "bg-accent" : "hover:bg-accent/50"
}`}
onClick={onSelect}
>
{item.status && (
<StatusIcon status={item.status} className="h-3.5 w-3.5 shrink-0" />
)}
<span className="shrink-0 text-muted-foreground">{item.label}</span>
{item.description && (
<span className="truncate text-muted-foreground">{item.description}</span>
)}
</button>
);
}
return (
<button
ref={buttonRef}
className={`flex w-full items-center gap-2.5 px-3 py-1.5 text-left text-xs transition-colors ${
selected ? "bg-accent" : "hover:bg-accent/50"
}`}
onClick={onSelect}
>
<ActorAvatar
actorType={item.type === "all" ? "member" : item.type}
actorId={item.id}
size={20}
/>
<span className="truncate font-medium">{item.label}</span>
{item.type === "agent" && (
<Badge variant="outline" className="ml-auto text-[10px] h-4 px-1.5">Agent</Badge>
)}
</button>
);
}
// ---------------------------------------------------------------------------
// Suggestion config factory
// ---------------------------------------------------------------------------
export function createMentionSuggestion(qc: QueryClient): Omit<
export function createMentionSuggestion(): Omit<
SuggestionOptions<MentionItem>,
"editor"
> {
return {
items: ({ query }) => {
const wsId = useWorkspaceStore.getState().workspace?.id;
const members: MemberWithUser[] = wsId ? qc.getQueryData(workspaceKeys.members(wsId)) ?? [] : [];
const agents: Agent[] = wsId ? qc.getQueryData(workspaceKeys.agents(wsId)) ?? [] : [];
const issues: Issue[] = wsId
? qc.getQueryData<ListIssuesResponse>(issueKeys.list(wsId))?.issues ?? []
: [];
const { members, agents } = useWorkspaceStore.getState();
const { issues } = useIssueStore.getState();
const q = query.toLowerCase();
// Show "All members" option when query is empty or matches "all"
const allItem: MentionItem[] =
"all members".includes(q) || "all".includes(q)
? [{ id: "all", label: "All members", type: "all" as const }]
? [{ id: "all", label: "All members", type: "all" as const, description: "Notify all members" }]
: [];
const memberItems: MentionItem[] = members
@@ -243,7 +156,7 @@ export function createMentionSuggestion(qc: QueryClient): Omit<
}));
const agentItems: MentionItem[] = agents
.filter((a) => !a.archived_at && a.name.toLowerCase().includes(q))
.filter((a) => a.name.toLowerCase().includes(q))
.map((a) => ({ id: a.id, label: a.name, type: "agent" as const }));
const issueItems: MentionItem[] = issues
@@ -257,7 +170,6 @@ export function createMentionSuggestion(qc: QueryClient): Omit<
label: i.identifier,
type: "issue" as const,
description: i.title,
status: i.status as IssueStatus,
}));
return [...allItem, ...memberItems, ...agentItems, ...issueItems].slice(0, 10);

View File

@@ -0,0 +1,227 @@
/* Rich text editor: ProseMirror styles using shadcn design tokens */
.rich-text-editor.ProseMirror {
color: var(--foreground);
caret-color: var(--foreground);
}
.rich-text-editor.ProseMirror:focus {
outline: none;
}
/* Placeholder */
.rich-text-editor .is-editor-empty:first-child::before {
content: attr(data-placeholder);
float: left;
color: var(--muted-foreground);
pointer-events: none;
height: 0;
}
/* Headings */
.rich-text-editor h1 {
font-size: 1.125rem;
font-weight: 700;
margin-top: 1.25rem;
margin-bottom: 0.5rem;
line-height: 1.4;
}
.rich-text-editor h2 {
font-size: 1rem;
font-weight: 600;
margin-top: 1rem;
margin-bottom: 0.5rem;
line-height: 1.4;
}
.rich-text-editor h3 {
font-size: 0.875rem;
font-weight: 600;
margin-top: 0.75rem;
margin-bottom: 0.25rem;
line-height: 1.4;
}
/* Paragraphs */
.rich-text-editor p {
margin-top: 0.375rem;
margin-bottom: 0.375rem;
line-height: 1.625;
}
/* First child should not have top margin */
.rich-text-editor > *:first-child {
margin-top: 0;
}
/* Last child should not have bottom margin */
.rich-text-editor > *:last-child {
margin-bottom: 0;
}
/* Lists */
.rich-text-editor ul {
list-style-type: disc;
padding-inline-start: 1.25rem;
margin: 0.375rem 0;
}
.rich-text-editor ol {
list-style-type: decimal;
padding-inline-start: 1.25rem;
margin: 0.375rem 0;
}
.rich-text-editor li {
margin: 0.125rem 0;
line-height: 1.625;
}
.rich-text-editor li::marker {
color: var(--muted-foreground);
}
/* Inline code */
.rich-text-editor code {
font-family: var(--font-mono, ui-monospace, monospace);
font-size: 0.8em;
background: var(--muted);
color: var(--foreground);
padding: 0.15em 0.35em;
border-radius: calc(var(--radius) * 0.6);
}
/* Code blocks */
.rich-text-editor pre {
font-family: var(--font-mono, ui-monospace, monospace);
background: var(--muted);
border-radius: var(--radius);
padding: 0.75rem 1rem;
margin: 0.5rem 0;
overflow-x: auto;
}
.rich-text-editor pre code {
background: none;
padding: 0;
font-size: 0.8125rem;
line-height: 1.6;
}
/* Syntax highlighting — lowlight (hljs) */
.rich-text-editor .hljs-keyword,
.rich-text-editor .hljs-selector-tag,
.rich-text-editor .hljs-built_in { color: oklch(0.55 0.16 255); }
.rich-text-editor .hljs-string,
.rich-text-editor .hljs-addition { color: oklch(0.55 0.14 155); }
.rich-text-editor .hljs-comment,
.rich-text-editor .hljs-quote { color: var(--muted-foreground); font-style: italic; }
.rich-text-editor .hljs-number,
.rich-text-editor .hljs-literal { color: oklch(0.58 0.16 30); }
.rich-text-editor .hljs-title,
.rich-text-editor .hljs-section,
.rich-text-editor .hljs-title\.function_ { color: oklch(0.55 0.14 280); }
.rich-text-editor .hljs-attr,
.rich-text-editor .hljs-attribute { color: oklch(0.58 0.12 60); }
.rich-text-editor .hljs-variable,
.rich-text-editor .hljs-template-variable { color: oklch(0.58 0.14 20); }
.rich-text-editor .hljs-type,
.rich-text-editor .hljs-title\.class_ { color: oklch(0.55 0.14 200); }
.rich-text-editor .hljs-deletion { color: oklch(0.55 0.2 25); }
.rich-text-editor .hljs-meta { color: var(--muted-foreground); }
/* Dark mode overrides */
.dark .rich-text-editor .hljs-keyword,
.dark .rich-text-editor .hljs-selector-tag,
.dark .rich-text-editor .hljs-built_in { color: oklch(0.7 0.14 255); }
.dark .rich-text-editor .hljs-string,
.dark .rich-text-editor .hljs-addition { color: oklch(0.7 0.14 155); }
.dark .rich-text-editor .hljs-number,
.dark .rich-text-editor .hljs-literal { color: oklch(0.72 0.14 30); }
.dark .rich-text-editor .hljs-title,
.dark .rich-text-editor .hljs-section,
.dark .rich-text-editor .hljs-title\.function_ { color: oklch(0.72 0.12 280); }
.dark .rich-text-editor .hljs-attr,
.dark .rich-text-editor .hljs-attribute { color: oklch(0.72 0.1 60); }
.dark .rich-text-editor .hljs-variable,
.dark .rich-text-editor .hljs-template-variable { color: oklch(0.72 0.12 20); }
.dark .rich-text-editor .hljs-type,
.dark .rich-text-editor .hljs-title\.class_ { color: oklch(0.72 0.12 200); }
.dark .rich-text-editor .hljs-deletion { color: oklch(0.7 0.18 25); }
/* Blockquotes */
.rich-text-editor blockquote {
border-left: 2px solid var(--border);
padding-left: 0.75rem;
margin: 0.5rem 0;
color: var(--muted-foreground);
}
/* Horizontal rules */
.rich-text-editor hr {
border: none;
border-top: 1px solid var(--border);
margin: 1rem 0;
}
/* Links */
.rich-text-editor a {
color: var(--brand);
text-decoration: none;
}
.rich-text-editor a:hover {
text-decoration: underline;
text-underline-offset: 2px;
}
/* Mentions */
.rich-text-editor .mention {
color: var(--primary);
font-weight: 600;
text-decoration: none;
margin: 0 0.125rem;
}
/* Strong / emphasis */
.rich-text-editor strong {
font-weight: 600;
}
.rich-text-editor em {
font-style: italic;
}
.rich-text-editor s {
text-decoration: line-through;
color: var(--muted-foreground);
}
/* Uploading image placeholder (blob: URLs = in-flight uploads) */
.rich-text-editor img[src^="blob:"] {
opacity: 0.5;
border-radius: var(--radius);
animation: rte-pulse 1.5s ease-in-out infinite;
}
@keyframes rte-pulse {
0%, 100% { opacity: 0.5; }
50% { opacity: 0.3; }
}

View File

@@ -0,0 +1,460 @@
"use client";
import {
forwardRef,
useEffect,
useImperativeHandle,
useRef,
} from "react";
import { useEditor, EditorContent, ReactNodeViewRenderer } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import CodeBlockLowlight from "@tiptap/extension-code-block-lowlight";
import { common, createLowlight } from "lowlight";
import Placeholder from "@tiptap/extension-placeholder";
import Link from "@tiptap/extension-link";
import Typography from "@tiptap/extension-typography";
import Mention from "@tiptap/extension-mention";
import Image from "@tiptap/extension-image";
import { Markdown } from "@tiptap/markdown";
import { Extension, mergeAttributes } from "@tiptap/core";
import { Plugin, PluginKey } from "@tiptap/pm/state";
import { Slice } from "@tiptap/pm/model";
import { cn } from "@/lib/utils";
import type { UploadResult } from "@/shared/hooks/use-file-upload";
import { createMentionSuggestion } from "./mention-suggestion";
import { CodeBlockView } from "./code-block-view";
import "./rich-text-editor.css";
const lowlight = createLowlight(common);
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface RichTextEditorProps {
defaultValue?: string;
onUpdate?: (markdown: string) => void;
placeholder?: string;
editable?: boolean;
className?: string;
debounceMs?: number;
onSubmit?: () => void;
onBlur?: () => void;
onUploadFile?: (file: File) => Promise<UploadResult | null>;
}
interface RichTextEditorRef {
getMarkdown: () => string;
clearContent: () => void;
focus: () => void;
insertFile: (filename: string, url: string, isImage: boolean) => void;
}
const LinkExtension = Link.configure({
openOnClick: true,
autolink: true,
HTMLAttributes: {
class: "text-primary hover:underline cursor-pointer",
},
});
const MentionExtension = Mention.configure({
HTMLAttributes: { class: "mention" },
suggestion: createMentionSuggestion(),
}).extend({
renderHTML({ node, HTMLAttributes }) {
return [
"span",
mergeAttributes(
{ "data-type": "mention" },
this.options.HTMLAttributes,
HTMLAttributes,
{
"data-mention-type": node.attrs.type ?? "member",
"data-mention-id": node.attrs.id,
},
),
`@${node.attrs.label ?? node.attrs.id}`,
];
},
addAttributes() {
return {
...this.parent?.(),
type: {
default: "member",
parseHTML: (el: HTMLElement) =>
el.getAttribute("data-mention-type") ?? "member",
renderHTML: () => ({}),
},
};
},
// @tiptap/markdown: custom tokenizer to parse [@Label](mention://type/id)
markdownTokenizer: {
name: "mention",
level: "inline" as const,
start(src: string) {
return src.search(/\[@[^\]]+\]\(mention:\/\//);
},
tokenize(src: string) {
const match = src.match(
/^\[@([^\]]+)\]\(mention:\/\/(\w+)\/([^)]+)\)/,
);
if (!match) return undefined;
return {
type: "mention",
raw: match[0],
attributes: { label: match[1], type: match[2] ?? "member", id: match[3] },
};
},
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
parseMarkdown: (token: any, helpers: any) => {
return helpers.createNode("mention", token.attributes);
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
renderMarkdown: (node: any) => {
const { id, label, type = "member" } = node.attrs || {};
return `[@${label ?? id}](mention://${type}/${id})`;
},
});
// ---------------------------------------------------------------------------
// Submit shortcut extension (Mod+Enter)
// ---------------------------------------------------------------------------
function createSubmitExtension(onSubmit: () => void) {
return Extension.create({
name: "submitShortcut",
addKeyboardShortcuts() {
return {
"Mod-Enter": () => {
onSubmit();
return true;
},
};
},
});
}
// ---------------------------------------------------------------------------
// Markdown paste extension — parse pasted markdown text as rich text
// ---------------------------------------------------------------------------
function createMarkdownPasteExtension() {
return Extension.create({
name: "markdownPaste",
addProseMirrorPlugins() {
const { editor } = this;
return [
new Plugin({
key: new PluginKey("markdownPaste"),
props: {
clipboardTextParser(text, _context, plainText) {
if (!plainText && editor.markdown) {
const json = editor.markdown.parse(text);
const node = editor.schema.nodeFromJSON(json);
return Slice.maxOpen(node.content);
}
// Plain text fallback
const p = editor.schema.nodes.paragraph!;
const doc = editor.schema.nodes.doc!;
const paragraph = p.create(null, text ? editor.schema.text(text) : undefined);
return new Slice(doc.create(null, paragraph).content, 0, 0);
},
},
}),
];
},
});
}
// ---------------------------------------------------------------------------
// File upload extension (paste + drop) with blob URL instant preview
// ---------------------------------------------------------------------------
function removeImageBySrc(editor: ReturnType<typeof useEditor>, src: string) {
if (!editor) return;
const { tr } = editor.state;
let deleted = false;
editor.state.doc.descendants((node, pos) => {
if (deleted) return false;
if (node.type.name === "image" && node.attrs.src === src) {
tr.delete(pos, pos + node.nodeSize);
deleted = true;
return false;
}
});
if (deleted) editor.view.dispatch(tr);
}
function createFileUploadExtension(
onUploadFileRef: React.RefObject<((file: File) => Promise<UploadResult | null>) | undefined>,
) {
return Extension.create({
name: "fileUpload",
addProseMirrorPlugins() {
const { editor } = this;
const handleFiles = async (files: FileList, pos?: number) => {
const handler = onUploadFileRef.current;
if (!handler) return false;
let handled = false;
for (const file of Array.from(files)) {
handled = true;
const isImage = file.type.startsWith("image/");
if (isImage) {
// Instant preview via blob URL, then replace with real URL after upload
const blobUrl = URL.createObjectURL(file);
if (pos !== undefined) {
editor
.chain()
.focus()
.insertContentAt(pos, {
type: "image",
attrs: { src: blobUrl, alt: file.name },
})
.run();
} else {
editor
.chain()
.focus()
.setImage({ src: blobUrl, alt: file.name })
.run();
}
try {
const result = await handler(file);
if (result) {
const { tr } = editor.state;
editor.state.doc.descendants((node, nodePos) => {
if (
node.type.name === "image" &&
node.attrs.src === blobUrl
) {
tr.setNodeMarkup(nodePos, undefined, {
...node.attrs,
src: result.link,
alt: result.filename,
});
}
});
editor.view.dispatch(tr);
} else {
removeImageBySrc(editor, blobUrl);
}
} catch {
removeImageBySrc(editor, blobUrl);
} finally {
URL.revokeObjectURL(blobUrl);
}
} else {
// Non-image: upload first, then insert link
try {
const result = await handler(file);
if (!result) continue;
const linkText = `[${result.filename}](${result.link})`;
if (pos !== undefined) {
editor.chain().focus().insertContentAt(pos, linkText).run();
} else {
editor.chain().focus().insertContent(linkText).run();
}
} catch {
// Upload errors handled by the hook/caller via toast
}
}
}
return handled;
};
return [
new Plugin({
key: new PluginKey("fileUpload"),
props: {
handlePaste(_view, event) {
const files = event.clipboardData?.files;
if (!files?.length) return false;
if (!onUploadFileRef.current) return false;
handleFiles(files);
return true;
},
handleDrop(_view, event) {
const files = (event as DragEvent).dataTransfer?.files;
if (!files?.length) return false;
if (!onUploadFileRef.current) return false;
handleFiles(files);
return true;
},
},
}),
];
},
});
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
const RichTextEditor = forwardRef<RichTextEditorRef, RichTextEditorProps>(
function RichTextEditor(
{
defaultValue = "",
onUpdate,
placeholder: placeholderText = "",
editable = true,
className,
debounceMs = 300,
onSubmit,
onBlur,
onUploadFile,
},
ref,
) {
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const onUpdateRef = useRef(onUpdate);
const onSubmitRef = useRef(onSubmit);
const onBlurRef = useRef(onBlur);
const onUploadFileRef = useRef(onUploadFile);
// Helper to get markdown from @tiptap/markdown extension.
// Post-processes mention shortcodes [@ id="..." label="..."] → markdown
// links, using the Tiptap JSON doc for type info, in case the
// renderMarkdown override doesn't take effect.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const getEditorMarkdown = (ed: any): string => {
const md: string = ed?.getMarkdown?.() ?? "";
if (!md || !md.includes("[@ ")) return md;
// Build type map from editor JSON (which always has the type attr)
const json = ed?.getJSON?.();
const typeMap = new Map<string, string>();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function walk(node: any) {
if (node?.type === "mention" && node.attrs?.id) {
typeMap.set(node.attrs.id, node.attrs.type || "member");
}
if (node?.content) node.content.forEach(walk);
}
if (json) walk(json);
return md.replace(
/\[@\s+([^\]]*)\]/g,
(match: string, attrString: string) => {
const attrs: Record<string, string> = {};
const re = /(\w+)="([^"]*)"/g;
let m;
while ((m = re.exec(attrString)) !== null) {
if (m[1] && m[2] !== undefined) attrs[m[1]] = m[2];
}
const { id, label } = attrs;
if (!id || !label) return match;
const type = typeMap.get(id) || "member";
const display = type === "issue" ? label : `@${label}`;
return `[${display}](mention://${type}/${id})`;
},
);
};
// Keep refs in sync without recreating editor
onUpdateRef.current = onUpdate;
onSubmitRef.current = onSubmit;
onBlurRef.current = onBlur;
onUploadFileRef.current = onUploadFile;
const editor = useEditor({
immediatelyRender: false,
editable,
content: defaultValue || "",
contentType: defaultValue ? "markdown" : undefined,
extensions: [
StarterKit.configure({
heading: { levels: [1, 2, 3] },
link: false,
codeBlock: false,
}),
CodeBlockLowlight.extend({
addNodeView() {
return ReactNodeViewRenderer(CodeBlockView);
},
}).configure({ lowlight }),
Placeholder.configure({
placeholder: placeholderText,
}),
LinkExtension,
Typography,
MentionExtension,
Image.configure({
inline: false,
allowBase64: false,
HTMLAttributes: { style: "max-width: 100%; height: auto;" },
}),
Markdown,
createMarkdownPasteExtension(),
createSubmitExtension(() => onSubmitRef.current?.()),
createFileUploadExtension(onUploadFileRef),
],
onUpdate: ({ editor: ed }) => {
if (!onUpdateRef.current) return;
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
onUpdateRef.current?.(ed.getMarkdown());
}, debounceMs);
},
onBlur: () => {
onBlurRef.current?.();
},
editorProps: {
handleDOMEvents: {
click(_view, event) {
if (event.metaKey || event.ctrlKey) {
const link = (event.target as HTMLElement).closest("a");
const href = link?.getAttribute("href");
if (href && !href.startsWith("mention://")) {
window.open(href, "_blank", "noopener,noreferrer");
event.preventDefault();
return true;
}
}
return false;
},
},
attributes: {
class: cn("rich-text-editor text-sm outline-none", className),
},
},
});
// Cleanup debounce on unmount
useEffect(() => {
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, []);
useImperativeHandle(ref, () => ({
getMarkdown: () => editor?.getMarkdown() ?? "",
clearContent: () => {
editor?.commands.clearContent();
},
focus: () => {
editor?.commands.focus();
},
insertFile: (filename: string, url: string, isImage: boolean) => {
if (!editor) return;
if (isImage) {
editor.chain().focus().setImage({ src: url, alt: filename }).run();
} else {
editor.chain().focus().insertContent(`[${filename}](${url})`).run();
}
},
}));
if (!editor) return null;
return <EditorContent editor={editor} />;
},
);
export { RichTextEditor, type RichTextEditorProps, type RichTextEditorRef };

View File

@@ -1,20 +0,0 @@
"use client";
import { useEffect } from "react";
/**
* Reads the locale cookie on the client and updates <html lang>.
* This avoids calling cookies() in the root Server Component layout,
* which would mark the entire app as dynamic and disable the Router Cache.
*/
export function LocaleSync() {
useEffect(() => {
const match = document.cookie.match(/(?:^|;\s*)multica-locale=(\w+)/);
const locale = match?.[1];
if (locale === "zh") {
document.documentElement.lang = "zh";
}
}, []);
return null;
}

View File

@@ -5,7 +5,6 @@ import remarkGfm from 'remark-gfm'
import { cn } from '@/lib/utils'
import { CodeBlock, InlineCode } from './CodeBlock'
import { preprocessLinks } from './linkify'
import { preprocessMentionShortcodes } from './mentions'
import { IssueMentionCard } from '@/features/issues/components/issue-mention-card'
/**
@@ -54,6 +53,27 @@ function urlTransform(url: string): string {
return defaultUrlTransform(url)
}
/**
* Convert legacy mention shortcodes [@ id="UUID" label="LABEL"] to markdown
* link format [@LABEL](mention://member/UUID) so they render as styled mentions.
*/
function preprocessMentionShortcodes(text: string): string {
if (!text.includes('[@ ')) return text
return text.replace(
/\[@\s+([^\]]*)\]/g,
(match, attrString: string) => {
const attrs: Record<string, string> = {}
const re = /(\w+)="([^"]*)"/g
let m
while ((m = re.exec(attrString)) !== null) {
if (m[1] && m[2] !== undefined) attrs[m[1]] = m[2]
}
const { id, label } = attrs
if (!id || !label) return match
return `[@${label}](mention://member/${id})`
}
)
}
// File path detection regex - matches paths starting with /, ~/, or ./
const FILE_PATH_REGEX =

View File

@@ -2,4 +2,3 @@ export { Markdown, MemoizedMarkdown, type MarkdownProps, type RenderMode } from
export { CodeBlock, InlineCode, type CodeBlockProps } from './CodeBlock'
export { StreamingMarkdown, type StreamingMarkdownProps } from './StreamingMarkdown'
export { preprocessLinks, detectLinks, hasLinks } from './linkify'
export { preprocessMentionShortcodes } from './mentions'

View File

@@ -1,25 +0,0 @@
/**
* Convert legacy mention shortcodes [@ id="UUID" label="LABEL"] to the
* standard markdown link format [@LABEL](mention://member/UUID).
*
* These shortcodes exist in older database records from a previous mention
* serialization format. This function normalises them so downstream parsers
* (Tiptap @tiptap/markdown, react-markdown) only need to handle one syntax.
*/
export function preprocessMentionShortcodes(text: string): string {
if (!text.includes("[@ ")) return text;
return text.replace(
/\[@\s+([^\]]*)\]/g,
(match, attrString: string) => {
const attrs: Record<string, string> = {};
const re = /(\w+)="([^"]*)"/g;
let m;
while ((m = re.exec(attrString)) !== null) {
if (m[1] && m[2] !== undefined) attrs[m[1]] = m[2];
}
const { id, label } = attrs;
if (!id || !label) return match;
return `[@${label}](mention://member/${id})`;
},
);
}

View File

@@ -13,19 +13,19 @@ const Toaster = ({ ...props }: ToasterProps) => {
className="toaster group"
icons={{
success: (
<CircleCheckIcon className="size-4 text-success" />
<CircleCheckIcon className="size-4" />
),
info: (
<InfoIcon className="size-4 text-info" />
<InfoIcon className="size-4" />
),
warning: (
<TriangleAlertIcon className="size-4 text-warning" />
<TriangleAlertIcon className="size-4" />
),
error: (
<OctagonXIcon className="size-4 text-destructive" />
<OctagonXIcon className="size-4" />
),
loading: (
<Loader2Icon className="size-4 animate-spin text-brand" />
<Loader2Icon className="size-4 animate-spin" />
),
}}
style={

View File

@@ -1,17 +0,0 @@
"use client";
import { useWorkspaceStore } from "@/features/workspace";
/**
* Returns the current workspace ID.
*
* Bridge hook: reads from Zustand workspace store now.
* Phase 3 will switch to core/workspace/store.ts — signature stays the same.
*/
export function useWorkspaceId(): string {
const workspaceId = useWorkspaceStore((s) => s.workspace?.id);
if (!workspaceId) {
throw new Error("useWorkspaceId: no workspace selected");
}
return workspaceId;
}

View File

@@ -1,16 +0,0 @@
export {
inboxKeys,
inboxListOptions,
deduplicateInboxItems,
} from "./queries";
export {
useMarkInboxRead,
useArchiveInbox,
useMarkAllInboxRead,
useArchiveAllInbox,
useArchiveAllReadInbox,
useArchiveCompletedInbox,
} from "./mutations";
export { onInboxNew, onInboxInvalidate, onInboxIssueStatusChanged } from "./ws-updaters";

View File

@@ -1,113 +0,0 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { api } from "@/shared/api";
import { inboxKeys } from "./queries";
import { useWorkspaceId } from "@core/hooks";
import type { InboxItem } from "@/shared/types";
export function useMarkInboxRead() {
const qc = useQueryClient();
const wsId = useWorkspaceId();
return useMutation({
mutationFn: (id: string) => api.markInboxRead(id),
onMutate: async (id) => {
await qc.cancelQueries({ queryKey: inboxKeys.list(wsId) });
const prev = qc.getQueryData<InboxItem[]>(inboxKeys.list(wsId));
qc.setQueryData<InboxItem[]>(inboxKeys.list(wsId), (old) =>
old?.map((item) => (item.id === id ? { ...item, read: true } : item)),
);
return { prev };
},
onError: (_err, _id, ctx) => {
if (ctx?.prev) qc.setQueryData(inboxKeys.list(wsId), ctx.prev);
},
onSettled: () => {
qc.invalidateQueries({ queryKey: inboxKeys.list(wsId) });
},
});
}
export function useArchiveInbox() {
const qc = useQueryClient();
const wsId = useWorkspaceId();
return useMutation({
mutationFn: (id: string) => api.archiveInbox(id),
onMutate: async (id) => {
await qc.cancelQueries({ queryKey: inboxKeys.list(wsId) });
const prev = qc.getQueryData<InboxItem[]>(inboxKeys.list(wsId));
// Archive all items for the same issue (same behavior as store)
const target = prev?.find((i) => i.id === id);
const issueId = target?.issue_id;
qc.setQueryData<InboxItem[]>(inboxKeys.list(wsId), (old) =>
old?.map((item) =>
item.id === id || (issueId && item.issue_id === issueId)
? { ...item, archived: true }
: item,
),
);
return { prev };
},
onError: (_err, _id, ctx) => {
if (ctx?.prev) qc.setQueryData(inboxKeys.list(wsId), ctx.prev);
},
onSettled: () => {
qc.invalidateQueries({ queryKey: inboxKeys.list(wsId) });
},
});
}
export function useMarkAllInboxRead() {
const qc = useQueryClient();
const wsId = useWorkspaceId();
return useMutation({
mutationFn: () => api.markAllInboxRead(),
onMutate: async () => {
await qc.cancelQueries({ queryKey: inboxKeys.list(wsId) });
const prev = qc.getQueryData<InboxItem[]>(inboxKeys.list(wsId));
qc.setQueryData<InboxItem[]>(inboxKeys.list(wsId), (old) =>
old?.map((item) =>
!item.archived ? { ...item, read: true } : item,
),
);
return { prev };
},
onError: (_err, _vars, ctx) => {
if (ctx?.prev) qc.setQueryData(inboxKeys.list(wsId), ctx.prev);
},
onSettled: () => {
qc.invalidateQueries({ queryKey: inboxKeys.list(wsId) });
},
});
}
export function useArchiveAllInbox() {
const qc = useQueryClient();
const wsId = useWorkspaceId();
return useMutation({
mutationFn: () => api.archiveAllInbox(),
onSettled: () => {
qc.invalidateQueries({ queryKey: inboxKeys.list(wsId) });
},
});
}
export function useArchiveAllReadInbox() {
const qc = useQueryClient();
const wsId = useWorkspaceId();
return useMutation({
mutationFn: () => api.archiveAllReadInbox(),
onSettled: () => {
qc.invalidateQueries({ queryKey: inboxKeys.list(wsId) });
},
});
}
export function useArchiveCompletedInbox() {
const qc = useQueryClient();
const wsId = useWorkspaceId();
return useMutation({
mutationFn: () => api.archiveCompletedInbox(),
onSettled: () => {
qc.invalidateQueries({ queryKey: inboxKeys.list(wsId) });
},
});
}

View File

@@ -1,43 +0,0 @@
import { queryOptions } from "@tanstack/react-query";
import { api } from "@/shared/api";
import type { InboxItem } from "@/shared/types";
export const inboxKeys = {
all: (wsId: string) => ["inbox", wsId] as const,
list: (wsId: string) => [...inboxKeys.all(wsId), "list"] as const,
};
export function inboxListOptions(wsId: string) {
return queryOptions({
queryKey: inboxKeys.list(wsId),
queryFn: () => api.listInbox(),
});
}
/**
* Deduplicate inbox items by issue_id (one entry per issue, Linear-style).
* Exported for consumers to use in useMemo — not in queryOptions select
* (to avoid new array references on every cache update).
*/
export function deduplicateInboxItems(items: InboxItem[]): InboxItem[] {
const active = items.filter((i) => !i.archived);
const groups = new Map<string, InboxItem[]>();
for (const item of active) {
const key = item.issue_id ?? item.id;
const group = groups.get(key) ?? [];
group.push(item);
groups.set(key, group);
}
const merged: InboxItem[] = [];
for (const group of groups.values()) {
group.sort(
(a, b) =>
new Date(b.created_at).getTime() - new Date(a.created_at).getTime(),
);
if (group[0]) merged.push(group[0]);
}
return merged.sort(
(a, b) =>
new Date(b.created_at).getTime() - new Date(a.created_at).getTime(),
);
}

View File

@@ -1,30 +0,0 @@
import type { QueryClient } from "@tanstack/react-query";
import { inboxKeys } from "./queries";
import type { InboxItem, IssueStatus } from "@/shared/types";
export function onInboxNew(
qc: QueryClient,
wsId: string,
_item: InboxItem,
) {
// Use invalidateQueries instead of setQueryData — triggers a refetch that
// reliably notifies all observers. The inbox list is small so this is cheap.
qc.invalidateQueries({ queryKey: inboxKeys.list(wsId) });
}
export function onInboxIssueStatusChanged(
qc: QueryClient,
wsId: string,
issueId: string,
status: IssueStatus,
) {
qc.setQueryData<InboxItem[]>(inboxKeys.list(wsId), (old) =>
old?.map((i) =>
i.issue_id === issueId ? { ...i, issue_status: status } : i,
),
);
}
export function onInboxInvalidate(qc: QueryClient, wsId: string) {
qc.invalidateQueries({ queryKey: inboxKeys.list(wsId) });
}

View File

@@ -1,3 +0,0 @@
export { createQueryClient } from "./query-client";
export { QueryProvider } from "./provider";
export { useWorkspaceId } from "./hooks";

View File

@@ -1,29 +0,0 @@
export {
issueKeys,
issueListOptions,
issueDetailOptions,
issueTimelineOptions,
issueReactionsOptions,
issueSubscribersOptions,
} from "./queries";
export {
useLoadMoreDoneIssues,
useCreateIssue,
useUpdateIssue,
useDeleteIssue,
useBatchUpdateIssues,
useBatchDeleteIssues,
useCreateComment,
useUpdateComment,
useDeleteComment,
useToggleCommentReaction,
useToggleIssueReaction,
useToggleIssueSubscriber,
} from "./mutations";
export {
onIssueCreated,
onIssueUpdated,
onIssueDeleted,
} from "./ws-updaters";

View File

@@ -1,495 +0,0 @@
import { useState, useCallback } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { api } from "@/shared/api";
import { issueKeys, CLOSED_PAGE_SIZE } from "./queries";
import { useWorkspaceId } from "@core/hooks";
import type { Issue, IssueReaction } from "@/shared/types";
import type {
CreateIssueRequest,
UpdateIssueRequest,
ListIssuesResponse,
} from "@/shared/types";
import type { TimelineEntry, IssueSubscriber, Reaction } from "@/shared/types";
// ---------------------------------------------------------------------------
// Shared mutation variable types — used by both mutation hooks and
// useMutationState consumers to keep the type assertion in sync.
// ---------------------------------------------------------------------------
export type ToggleCommentReactionVars = {
commentId: string;
emoji: string;
existing: Reaction | undefined;
};
export type ToggleIssueReactionVars = {
emoji: string;
existing: IssueReaction | undefined;
};
// ---------------------------------------------------------------------------
// Done issue pagination
// ---------------------------------------------------------------------------
export function useLoadMoreDoneIssues() {
const qc = useQueryClient();
const wsId = useWorkspaceId();
const [isLoading, setIsLoading] = useState(false);
const cache = qc.getQueryData<ListIssuesResponse>(issueKeys.list(wsId));
const doneLoaded = cache
? cache.issues.filter((i) => i.status === "done").length
: 0;
const doneTotal = cache?.doneTotal ?? 0;
const hasMore = doneLoaded < doneTotal;
const loadMore = useCallback(async () => {
if (isLoading || !hasMore) return;
setIsLoading(true);
try {
const res = await api.listIssues({
status: "done",
limit: CLOSED_PAGE_SIZE,
offset: doneLoaded,
});
qc.setQueryData<ListIssuesResponse>(issueKeys.list(wsId), (old) => {
if (!old) return old;
const existingIds = new Set(old.issues.map((i) => i.id));
const newIssues = res.issues.filter((i) => !existingIds.has(i.id));
return {
...old,
issues: [...old.issues, ...newIssues],
doneTotal: res.total,
};
});
} finally {
setIsLoading(false);
}
}, [qc, wsId, doneLoaded, hasMore, isLoading]);
return { loadMore, hasMore, isLoading, doneTotal };
}
// ---------------------------------------------------------------------------
// Issue CRUD
// ---------------------------------------------------------------------------
export function useCreateIssue() {
const qc = useQueryClient();
const wsId = useWorkspaceId();
return useMutation({
mutationFn: (data: CreateIssueRequest) => api.createIssue(data),
onSuccess: (newIssue) => {
qc.setQueryData<ListIssuesResponse>(issueKeys.list(wsId), (old) =>
old && !old.issues.some((i) => i.id === newIssue.id)
? {
...old,
issues: [...old.issues, newIssue],
total: old.total + 1,
doneTotal: (old.doneTotal ?? 0) + (newIssue.status === "done" ? 1 : 0),
}
: old,
);
// 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) });
}
},
onSettled: () => {
qc.invalidateQueries({ queryKey: issueKeys.list(wsId) });
},
});
}
export function useUpdateIssue() {
const qc = useQueryClient();
const wsId = useWorkspaceId();
return useMutation({
mutationFn: ({ id, ...data }: { id: string } & UpdateIssueRequest) =>
api.updateIssue(id, data),
onMutate: ({ id, ...data }) => {
// Fire-and-forget cancelQueries — keeps onMutate synchronous so the
// cache update happens in the same tick as mutate(). Awaiting would
// yield to the event loop, letting @dnd-kit reset its visual state
// before the optimistic update lands.
qc.cancelQueries({ queryKey: issueKeys.list(wsId) });
const prevList = qc.getQueryData<ListIssuesResponse>(issueKeys.list(wsId));
const prevDetail = qc.getQueryData<Issue>(issueKeys.detail(wsId, id));
// Resolve parent_issue_id from the freshest source so we can keep the
// parent's children cache in sync (used by the parent issue's
// sub-issues list).
const parentId =
prevDetail?.parent_issue_id ??
prevList?.issues.find((i) => i.id === id)?.parent_issue_id ??
null;
const prevChildren = parentId
? qc.getQueryData<Issue[]>(issueKeys.children(wsId, parentId))
: undefined;
qc.setQueryData<ListIssuesResponse>(issueKeys.list(wsId), (old) =>
old
? {
...old,
issues: old.issues.map((i) =>
i.id === id ? { ...i, ...data } : i,
),
}
: old,
);
qc.setQueryData<Issue>(issueKeys.detail(wsId, id), (old) =>
old ? { ...old, ...data } : old,
);
if (parentId) {
qc.setQueryData<Issue[]>(
issueKeys.children(wsId, parentId),
(old) =>
old?.map((c) => (c.id === id ? { ...c, ...data } : c)),
);
}
return { prevList, prevDetail, prevChildren, parentId, id };
},
onError: (_err, _vars, ctx) => {
if (ctx?.prevList) qc.setQueryData(issueKeys.list(wsId), ctx.prevList);
if (ctx?.prevDetail)
qc.setQueryData(issueKeys.detail(wsId, ctx.id), ctx.prevDetail);
if (ctx?.parentId && ctx.prevChildren !== undefined) {
qc.setQueryData(
issueKeys.children(wsId, ctx.parentId),
ctx.prevChildren,
);
}
},
onSettled: (_data, _err, vars, ctx) => {
qc.invalidateQueries({ queryKey: issueKeys.detail(wsId, vars.id) });
qc.invalidateQueries({ queryKey: issueKeys.list(wsId) });
if (ctx?.parentId) {
qc.invalidateQueries({
queryKey: issueKeys.children(wsId, ctx.parentId),
});
}
},
});
}
export function useDeleteIssue() {
const qc = useQueryClient();
const wsId = useWorkspaceId();
return useMutation({
mutationFn: (id: string) => api.deleteIssue(id),
onMutate: async (id) => {
await qc.cancelQueries({ queryKey: issueKeys.list(wsId) });
const prevList = qc.getQueryData<ListIssuesResponse>(issueKeys.list(wsId));
qc.setQueryData<ListIssuesResponse>(issueKeys.list(wsId), (old) => {
if (!old) return old;
const deleted = old.issues.find((i) => i.id === id);
return {
...old,
issues: old.issues.filter((i) => i.id !== id),
total: old.total - 1,
doneTotal: (old.doneTotal ?? 0) - (deleted?.status === "done" ? 1 : 0),
};
});
qc.removeQueries({ queryKey: issueKeys.detail(wsId, id) });
return { prevList };
},
onError: (_err, _id, ctx) => {
if (ctx?.prevList) qc.setQueryData(issueKeys.list(wsId), ctx.prevList);
},
onSettled: () => {
qc.invalidateQueries({ queryKey: issueKeys.list(wsId) });
},
});
}
export function useBatchUpdateIssues() {
const qc = useQueryClient();
const wsId = useWorkspaceId();
return useMutation({
mutationFn: ({
ids,
updates,
}: {
ids: string[];
updates: UpdateIssueRequest;
}) => api.batchUpdateIssues(ids, updates),
onMutate: async ({ ids, updates }) => {
await qc.cancelQueries({ queryKey: issueKeys.list(wsId) });
const prevList = qc.getQueryData<ListIssuesResponse>(issueKeys.list(wsId));
qc.setQueryData<ListIssuesResponse>(issueKeys.list(wsId), (old) =>
old
? {
...old,
issues: old.issues.map((i) =>
ids.includes(i.id) ? { ...i, ...updates } : i,
),
}
: old,
);
return { prevList };
},
onError: (_err, _vars, ctx) => {
if (ctx?.prevList) qc.setQueryData(issueKeys.list(wsId), ctx.prevList);
},
onSettled: () => {
qc.invalidateQueries({ queryKey: issueKeys.list(wsId) });
},
});
}
export function useBatchDeleteIssues() {
const qc = useQueryClient();
const wsId = useWorkspaceId();
return useMutation({
mutationFn: (ids: string[]) => api.batchDeleteIssues(ids),
onMutate: async (ids) => {
await qc.cancelQueries({ queryKey: issueKeys.list(wsId) });
const prevList = qc.getQueryData<ListIssuesResponse>(issueKeys.list(wsId));
qc.setQueryData<ListIssuesResponse>(issueKeys.list(wsId), (old) => {
if (!old) return old;
const idSet = new Set(ids);
const doneDeleted = old.issues.filter(
(i) => idSet.has(i.id) && i.status === "done",
).length;
return {
...old,
issues: old.issues.filter((i) => !idSet.has(i.id)),
total: old.total - ids.length,
doneTotal: (old.doneTotal ?? 0) - doneDeleted,
};
});
return { prevList };
},
onError: (_err, _ids, ctx) => {
if (ctx?.prevList) qc.setQueryData(issueKeys.list(wsId), ctx.prevList);
},
onSettled: () => {
qc.invalidateQueries({ queryKey: issueKeys.list(wsId) });
},
});
}
// ---------------------------------------------------------------------------
// Comments / Timeline
// ---------------------------------------------------------------------------
export function useCreateComment(issueId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: ({
content,
type,
parentId,
attachmentIds,
}: {
content: string;
type?: string;
parentId?: string;
attachmentIds?: string[];
}) => api.createComment(issueId, content, type, parentId, attachmentIds),
onSuccess: (comment) => {
qc.setQueryData<TimelineEntry[]>(
issueKeys.timeline(issueId),
(old) => {
if (!old) return old;
const entry: TimelineEntry = {
type: "comment",
id: comment.id,
actor_type: comment.author_type,
actor_id: comment.author_id,
content: comment.content,
parent_id: comment.parent_id,
comment_type: comment.type,
reactions: comment.reactions ?? [],
attachments: comment.attachments ?? [],
created_at: comment.created_at,
updated_at: comment.updated_at,
};
if (old.some((e) => e.id === comment.id)) return old;
return [...old, entry];
},
);
},
onSettled: () => {
qc.invalidateQueries({ queryKey: issueKeys.timeline(issueId) });
},
});
}
export function useUpdateComment(issueId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ commentId, content }: { commentId: string; content: string }) =>
api.updateComment(commentId, content),
onMutate: async ({ commentId, content }) => {
await qc.cancelQueries({ queryKey: issueKeys.timeline(issueId) });
const prev = qc.getQueryData<TimelineEntry[]>(issueKeys.timeline(issueId));
qc.setQueryData<TimelineEntry[]>(
issueKeys.timeline(issueId),
(old) =>
old?.map((e) => (e.id === commentId ? { ...e, content } : e)),
);
return { prev };
},
onError: (_err, _vars, ctx) => {
if (ctx?.prev)
qc.setQueryData(issueKeys.timeline(issueId), ctx.prev);
},
onSettled: () => {
qc.invalidateQueries({ queryKey: issueKeys.timeline(issueId) });
},
});
}
export function useDeleteComment(issueId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (commentId: string) => api.deleteComment(commentId),
onMutate: async (commentId) => {
await qc.cancelQueries({ queryKey: issueKeys.timeline(issueId) });
const prev = qc.getQueryData<TimelineEntry[]>(issueKeys.timeline(issueId));
// Cascade: collect all child comment IDs
const toRemove = new Set<string>([commentId]);
if (prev) {
let changed = true;
while (changed) {
changed = false;
for (const e of prev) {
if (e.parent_id && toRemove.has(e.parent_id) && !toRemove.has(e.id)) {
toRemove.add(e.id);
changed = true;
}
}
}
}
qc.setQueryData<TimelineEntry[]>(
issueKeys.timeline(issueId),
(old) => old?.filter((e) => !toRemove.has(e.id)),
);
return { prev };
},
onError: (_err, _id, ctx) => {
if (ctx?.prev)
qc.setQueryData(issueKeys.timeline(issueId), ctx.prev);
},
onSettled: () => {
qc.invalidateQueries({ queryKey: issueKeys.timeline(issueId) });
},
});
}
export function useToggleCommentReaction(issueId: string) {
const qc = useQueryClient();
return useMutation({
mutationKey: ["toggleCommentReaction", issueId] as const,
mutationFn: async ({
commentId,
emoji,
existing,
}: ToggleCommentReactionVars) => {
if (existing) {
await api.removeReaction(commentId, emoji);
return null;
}
return api.addReaction(commentId, emoji);
},
onSettled: () => {
qc.invalidateQueries({ queryKey: issueKeys.timeline(issueId) });
},
});
}
// ---------------------------------------------------------------------------
// Issue-level Reactions
// ---------------------------------------------------------------------------
export function useToggleIssueReaction(issueId: string) {
const qc = useQueryClient();
return useMutation({
mutationKey: ["toggleIssueReaction", issueId] as const,
mutationFn: async ({
emoji,
existing,
}: ToggleIssueReactionVars) => {
if (existing) {
await api.removeIssueReaction(issueId, emoji);
return null;
}
return api.addIssueReaction(issueId, emoji);
},
onSettled: () => {
qc.invalidateQueries({ queryKey: issueKeys.reactions(issueId) });
},
});
}
// ---------------------------------------------------------------------------
// Issue Subscribers
// ---------------------------------------------------------------------------
export function useToggleIssueSubscriber(issueId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async ({
userId,
userType,
subscribed,
}: {
userId: string;
userType: "member" | "agent";
subscribed: boolean;
}) => {
if (subscribed) {
await api.unsubscribeFromIssue(issueId, userId, userType);
} else {
await api.subscribeToIssue(issueId, userId, userType);
}
},
onMutate: async ({ userId, userType, subscribed }) => {
await qc.cancelQueries({ queryKey: issueKeys.subscribers(issueId) });
const prev = qc.getQueryData<IssueSubscriber[]>(
issueKeys.subscribers(issueId),
);
if (subscribed) {
qc.setQueryData<IssueSubscriber[]>(
issueKeys.subscribers(issueId),
(old) =>
old?.filter(
(s) => !(s.user_id === userId && s.user_type === userType),
),
);
} else {
const temp: IssueSubscriber = {
issue_id: issueId,
user_type: userType,
user_id: userId,
reason: "manual",
created_at: new Date().toISOString(),
};
qc.setQueryData<IssueSubscriber[]>(
issueKeys.subscribers(issueId),
(old) => {
if (
old?.some(
(s) => s.user_id === userId && s.user_type === userType,
)
)
return old;
return [...(old ?? []), temp];
},
);
}
return { prev };
},
onError: (_err, _vars, ctx) => {
if (ctx?.prev)
qc.setQueryData(issueKeys.subscribers(issueId), ctx.prev);
},
onSettled: () => {
qc.invalidateQueries({ queryKey: issueKeys.subscribers(issueId) });
},
});
}

View File

@@ -1,81 +0,0 @@
import { queryOptions } from "@tanstack/react-query";
import { api } from "@/shared/api";
export const issueKeys = {
all: (wsId: string) => ["issues", wsId] as const,
list: (wsId: string) => [...issueKeys.all(wsId), "list"] as const,
detail: (wsId: string, id: string) =>
[...issueKeys.all(wsId), "detail", id] as const,
children: (wsId: string, id: string) =>
[...issueKeys.all(wsId), "children", id] as const,
timeline: (issueId: string) => ["issues", "timeline", issueId] as const,
reactions: (issueId: string) => ["issues", "reactions", issueId] as const,
subscribers: (issueId: string) =>
["issues", "subscribers", issueId] as const,
};
export const CLOSED_PAGE_SIZE = 50;
/**
* CACHE SHAPE NOTE: The raw cache stores ListIssuesResponse ({ issues, total, doneTotal }),
* but `select` transforms it to Issue[] for consumers. Mutations and ws-updaters
* must use setQueryData<ListIssuesResponse>(...) — NOT setQueryData<Issue[]>.
*
* Fetches all open issues + first page of done issues. Use useLoadMoreDoneIssues()
* to paginate additional done items into the cache.
*/
export function issueListOptions(wsId: string) {
return queryOptions({
queryKey: issueKeys.list(wsId),
queryFn: async () => {
const [openRes, closedRes] = await Promise.all([
api.listIssues({ open_only: true }),
api.listIssues({ status: "done", limit: CLOSED_PAGE_SIZE, offset: 0 }),
]);
return {
issues: [...openRes.issues, ...closedRes.issues],
total: openRes.total + closedRes.total,
doneTotal: closedRes.total,
};
},
select: (data) => data.issues,
});
}
export function issueDetailOptions(wsId: string, id: string) {
return queryOptions({
queryKey: issueKeys.detail(wsId, id),
queryFn: () => api.getIssue(id),
});
}
export function childIssuesOptions(wsId: string, id: string) {
return queryOptions({
queryKey: issueKeys.children(wsId, id),
queryFn: () => api.listChildIssues(id).then((r) => r.issues),
});
}
export function issueTimelineOptions(issueId: string) {
return queryOptions({
queryKey: issueKeys.timeline(issueId),
queryFn: () => api.listTimeline(issueId),
});
}
export function issueReactionsOptions(issueId: string) {
return queryOptions({
queryKey: issueKeys.reactions(issueId),
queryFn: async () => {
const issue = await api.getIssue(issueId);
return issue.reactions ?? [];
},
});
}
export function issueSubscribersOptions(issueId: string) {
return queryOptions({
queryKey: issueKeys.subscribers(issueId),
queryFn: () => api.listIssueSubscribers(issueId),
});
}

View File

@@ -1,97 +0,0 @@
import type { QueryClient } from "@tanstack/react-query";
import { issueKeys } from "./queries";
import type { Issue } from "@/shared/types";
import type { ListIssuesResponse } from "@/shared/types";
export function onIssueCreated(
qc: QueryClient,
wsId: string,
issue: Issue,
) {
qc.setQueryData<ListIssuesResponse>(issueKeys.list(wsId), (old) => {
if (!old || old.issues.some((i) => i.id === issue.id)) return old;
return {
...old,
issues: [...old.issues, issue],
total: old.total + 1,
doneTotal: (old.doneTotal ?? 0) + (issue.status === "done" ? 1 : 0),
};
});
if (issue.parent_issue_id) {
qc.invalidateQueries({ queryKey: issueKeys.children(wsId, issue.parent_issue_id) });
}
}
export function onIssueUpdated(
qc: QueryClient,
wsId: string,
issue: Partial<Issue> & { id: string },
) {
// Look up the parent before mutating list state, so we can also keep the
// parent's children cache in sync (powers the sub-issues list shown on
// the parent issue page).
const listData = qc.getQueryData<ListIssuesResponse>(issueKeys.list(wsId));
const detailData = qc.getQueryData<Issue>(issueKeys.detail(wsId, issue.id));
const parentId =
issue.parent_issue_id ??
detailData?.parent_issue_id ??
listData?.issues.find((i) => i.id === issue.id)?.parent_issue_id ??
null;
qc.setQueryData<ListIssuesResponse>(issueKeys.list(wsId), (old) => {
if (!old) return old;
const prev = old.issues.find((i) => i.id === issue.id);
const wasDone = prev?.status === "done";
const isDone = issue.status === "done";
// Only adjust doneTotal when status field is present and actually changed
let doneDelta = 0;
if (issue.status !== undefined) {
if (!wasDone && isDone) doneDelta = 1;
else if (wasDone && !isDone) doneDelta = -1;
}
return {
...old,
issues: old.issues.map((i) =>
i.id === issue.id ? { ...i, ...issue } : i,
),
doneTotal: (old.doneTotal ?? 0) + doneDelta,
};
});
qc.setQueryData<Issue>(issueKeys.detail(wsId, issue.id), (old) =>
old ? { ...old, ...issue } : old,
);
if (parentId) {
qc.setQueryData<Issue[]>(issueKeys.children(wsId, parentId), (old) =>
old?.map((c) => (c.id === issue.id ? { ...c, ...issue } : c)),
);
}
}
export function onIssueDeleted(
qc: QueryClient,
wsId: string,
issueId: string,
) {
// Look up the issue before removing it to check for parent_issue_id
const listData = qc.getQueryData<ListIssuesResponse>(issueKeys.list(wsId));
const deleted = listData?.issues.find((i) => i.id === issueId);
qc.setQueryData<ListIssuesResponse>(issueKeys.list(wsId), (old) => {
if (!old) return old;
const del = old.issues.find((i) => i.id === issueId);
return {
...old,
issues: old.issues.filter((i) => i.id !== issueId),
total: old.total - 1,
doneTotal: (old.doneTotal ?? 0) - (del?.status === "done" ? 1 : 0),
};
});
qc.removeQueries({ queryKey: issueKeys.detail(wsId, issueId) });
qc.removeQueries({ queryKey: issueKeys.timeline(issueId) });
qc.removeQueries({ queryKey: issueKeys.reactions(issueId) });
qc.removeQueries({ queryKey: issueKeys.subscribers(issueId) });
qc.removeQueries({ queryKey: issueKeys.children(wsId, issueId) });
if (deleted?.parent_issue_id) {
qc.invalidateQueries({ queryKey: issueKeys.children(wsId, deleted.parent_issue_id) });
}
}

View File

@@ -1,17 +0,0 @@
"use client";
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 }) {
const [queryClient] = useState(createQueryClient);
return (
<QueryClientProvider client={queryClient}>
{children}
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);
}

View File

@@ -1,18 +0,0 @@
import { QueryClient } from "@tanstack/react-query";
export function createQueryClient(): QueryClient {
return new QueryClient({
defaultOptions: {
queries: {
staleTime: Infinity,
gcTime: 10 * 60 * 1000, // 10 minutes
refetchOnWindowFocus: false,
refetchOnReconnect: true,
retry: 1,
},
mutations: {
retry: false,
},
},
});
}

View File

@@ -1 +0,0 @@
export { runtimeKeys, runtimeListOptions } from "./queries";

View File

@@ -1,14 +0,0 @@
import { queryOptions } from "@tanstack/react-query";
import { api } from "@/shared/api";
export const runtimeKeys = {
all: (wsId: string) => ["runtimes", wsId] as const,
list: (wsId: string) => [...runtimeKeys.all(wsId), "list"] as const,
};
export function runtimeListOptions(wsId: string) {
return queryOptions({
queryKey: runtimeKeys.list(wsId),
queryFn: () => api.listRuntimes({ workspace_id: wsId }),
});
}

View File

@@ -1,13 +0,0 @@
export {
workspaceKeys,
workspaceListOptions,
memberListOptions,
agentListOptions,
skillListOptions,
} from "./queries";
export {
useCreateWorkspace,
useLeaveWorkspace,
useDeleteWorkspace,
} from "./mutations";

View File

@@ -1,34 +0,0 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { api } from "@/shared/api";
import { workspaceKeys } from "./queries";
export function useCreateWorkspace() {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: { name: string; slug: string; description?: string }) =>
api.createWorkspace(data),
onSettled: () => {
qc.invalidateQueries({ queryKey: workspaceKeys.list() });
},
});
}
export function useLeaveWorkspace() {
const qc = useQueryClient();
return useMutation({
mutationFn: (workspaceId: string) => api.leaveWorkspace(workspaceId),
onSettled: () => {
qc.invalidateQueries({ queryKey: workspaceKeys.list() });
},
});
}
export function useDeleteWorkspace() {
const qc = useQueryClient();
return useMutation({
mutationFn: (workspaceId: string) => api.deleteWorkspace(workspaceId),
onSettled: () => {
qc.invalidateQueries({ queryKey: workspaceKeys.list() });
},
});
}

View File

@@ -1,39 +0,0 @@
import { queryOptions } from "@tanstack/react-query";
import { api } from "@/shared/api";
export const workspaceKeys = {
all: (wsId: string) => ["workspaces", wsId] as const,
list: () => ["workspaces", "list"] as const,
members: (wsId: string) => ["workspaces", wsId, "members"] as const,
agents: (wsId: string) => ["workspaces", wsId, "agents"] as const,
skills: (wsId: string) => ["workspaces", wsId, "skills"] as const,
};
export function workspaceListOptions() {
return queryOptions({
queryKey: workspaceKeys.list(),
queryFn: () => api.listWorkspaces(),
});
}
export function memberListOptions(wsId: string) {
return queryOptions({
queryKey: workspaceKeys.members(wsId),
queryFn: () => api.listMembers(wsId),
});
}
export function agentListOptions(wsId: string) {
return queryOptions({
queryKey: workspaceKeys.agents(wsId),
queryFn: () =>
api.listAgents({ workspace_id: wsId, include_archived: true }),
});
}
export function skillListOptions(wsId: string) {
return queryOptions({
queryKey: workspaceKeys.skills(wsId),
queryFn: () => api.listSkills(),
});
}

View File

@@ -1,3 +1,2 @@
export { useAuthStore } from "./store";
export { AuthInitializer } from "./initializer";
export { setLoggedInCookie } from "./auth-cookie";

View File

@@ -12,7 +12,6 @@ interface AuthState {
initialize: () => Promise<void>;
sendCode: (email: string) => Promise<void>;
verifyCode: (email: string, code: string) => Promise<User>;
loginWithGoogle: (code: string, redirectUri: string) => Promise<User>;
logout: () => void;
setUser: (user: User) => void;
}
@@ -37,6 +36,7 @@ export const useAuthStore = create<AuthState>((set) => ({
api.setToken(null);
api.setWorkspaceId(null);
localStorage.removeItem("multica_token");
localStorage.removeItem("multica_workspace_id");
set({ user: null, isLoading: false });
}
},
@@ -54,17 +54,9 @@ export const useAuthStore = create<AuthState>((set) => ({
return user;
},
loginWithGoogle: async (code: string, redirectUri: string) => {
const { token, user } = await api.googleLogin(code, redirectUri);
localStorage.setItem("multica_token", token);
api.setToken(token);
setLoggedInCookie();
set({ user });
return user;
},
logout: () => {
localStorage.removeItem("multica_token");
localStorage.removeItem("multica_workspace_id");
api.setToken(null);
api.setWorkspaceId(null);
clearLoggedInCookie();

View File

@@ -1,390 +0,0 @@
/*
* ContentEditor typography — ProseMirror styles using shadcn design tokens.
*
* Design tier: "Compact" (same tier as Linear, Slack). Optimized for short-form
* content (issue descriptions, comments) that users scan, not long-form reading.
*
* Typography values benchmarked against (April 2026):
* - github-markdown-css (GitHub's markdown renderer)
* - @tailwindcss/typography prose-sm preset
* - Linear's editor (Tiptap-based, 14px body)
*
* Key decisions:
* Body: 14px (text-sm), line-height 1.625 (between GitHub 1.5 and Tailwind 1.714)
* Headings: h1=22px (1.57x), h2=18px (1.29x), h3=15px (1.07x) — compact but
* with clear hierarchy. Previous h3 was 14px (same as body = no differentiation).
* Paragraph spacing: 10px (was 8px; GitHub uses 10px, Tailwind prose-sm uses 16px)
* List indent: 20px for ul (was 16px; standard is 22-32px)
* Code block margin: 12px (was 8px; gives breathing room between code and prose)
* Blockquote border: 3px (was 2px; GitHub/Tailwind both use 4px)
* Links: var(--brand) blue with 40% opacity underline (was var(--primary) near-black)
*
* Inline elements (mention cards, inline code) that exceed line-height:
* The browser auto-expands the line box for lines containing taller inline
* elements. Controlled via vertical-align on [data-node-view-wrapper] and
* box-decoration-break: clone on inline code.
*/
.rich-text-editor.ProseMirror {
color: var(--foreground);
caret-color: var(--foreground);
}
.rich-text-editor.ProseMirror:focus {
outline: none;
}
/* Placeholder */
.rich-text-editor .is-editor-empty:first-child::before {
content: attr(data-placeholder);
float: left;
color: var(--muted-foreground);
pointer-events: none;
height: 0;
}
/* Headings — compact but with clear visual hierarchy */
.rich-text-editor h1 {
font-size: 1.375rem;
font-weight: 700;
margin-top: 1.5rem;
margin-bottom: 0.5rem;
line-height: 1.3;
letter-spacing: -0.01em;
}
.rich-text-editor h2 {
font-size: 1.125rem;
font-weight: 600;
margin-top: 1.5rem;
margin-bottom: 0.5rem;
line-height: 1.35;
}
.rich-text-editor h3 {
font-size: 0.9375rem;
font-weight: 600;
margin-top: 1rem;
margin-bottom: 0.5rem;
line-height: 1.4;
}
/* Paragraphs */
.rich-text-editor p {
margin-top: 0.625rem;
margin-bottom: 0.625rem;
line-height: 1.625;
}
/* First child should not have top margin */
.rich-text-editor > *:first-child {
margin-top: 0;
}
/* Last child should not have bottom margin */
.rich-text-editor > *:last-child {
margin-bottom: 0;
}
/* Lists */
.rich-text-editor ul {
list-style-type: disc;
padding-inline-start: 1.25rem;
padding-inline-end: 0.5rem;
margin: 0.5rem 0;
}
.rich-text-editor ol {
list-style-type: decimal;
padding-inline-start: 1.5rem;
margin: 0.5rem 0;
}
.rich-text-editor li {
margin: 0.25rem 0;
line-height: 1.625;
}
.rich-text-editor li + li {
margin-top: 0.25rem;
}
.rich-text-editor li::marker {
color: var(--muted-foreground);
}
/* Remove paragraph margins inside list items (Tiptap wraps li content in <p>) */
.rich-text-editor li > p {
margin: 0;
}
.rich-text-editor li > p + p {
margin-top: 0.25rem;
}
/* Nested lists — bullet style progression and tighter spacing */
.rich-text-editor ul ul {
list-style-type: circle;
margin: 0.25rem 0;
}
.rich-text-editor ul ul ul {
list-style-type: square;
}
.rich-text-editor ol ol {
list-style-type: lower-alpha;
margin: 0.25rem 0;
}
.rich-text-editor ol ol ol {
list-style-type: lower-roman;
}
/* Inline code */
.rich-text-editor code {
font-family: var(--font-mono, ui-monospace, monospace);
font-size: 0.875rem;
background: color-mix(in srgb, var(--foreground) 3%, transparent);
border: 1px solid color-mix(in srgb, var(--foreground) 5%, transparent);
color: color-mix(in srgb, var(--foreground) 75%, transparent);
padding: 0.125rem 0.375rem;
border-radius: var(--radius-sm);
box-decoration-break: clone;
-webkit-box-decoration-break: clone;
line-height: 2;
}
/* Code blocks */
.rich-text-editor pre {
font-family: var(--font-mono, ui-monospace, monospace);
background: var(--muted);
border-radius: var(--radius);
padding: 0.75rem 1rem;
margin: 0.75rem 0;
overflow-x: auto;
}
.rich-text-editor pre code {
background: none;
border: none;
color: var(--foreground);
padding: 0;
font-size: 0.8125rem;
line-height: 1.6;
}
/* Syntax highlighting — lowlight (hljs) */
.rich-text-editor .hljs-keyword,
.rich-text-editor .hljs-selector-tag,
.rich-text-editor .hljs-built_in { color: oklch(0.55 0.16 255); }
.rich-text-editor .hljs-string,
.rich-text-editor .hljs-addition { color: oklch(0.55 0.14 155); }
.rich-text-editor .hljs-comment,
.rich-text-editor .hljs-quote { color: var(--muted-foreground); font-style: italic; }
.rich-text-editor .hljs-number,
.rich-text-editor .hljs-literal { color: oklch(0.58 0.16 30); }
.rich-text-editor .hljs-title,
.rich-text-editor .hljs-section,
.rich-text-editor .hljs-title\.function_ { color: oklch(0.55 0.14 280); }
.rich-text-editor .hljs-attr,
.rich-text-editor .hljs-attribute { color: oklch(0.58 0.12 60); }
.rich-text-editor .hljs-variable,
.rich-text-editor .hljs-template-variable { color: oklch(0.58 0.14 20); }
.rich-text-editor .hljs-type,
.rich-text-editor .hljs-title\.class_ { color: oklch(0.55 0.14 200); }
.rich-text-editor .hljs-deletion { color: oklch(0.55 0.2 25); }
.rich-text-editor .hljs-meta { color: var(--muted-foreground); }
/* Dark mode overrides */
.dark .rich-text-editor .hljs-keyword,
.dark .rich-text-editor .hljs-selector-tag,
.dark .rich-text-editor .hljs-built_in { color: oklch(0.7 0.14 255); }
.dark .rich-text-editor .hljs-string,
.dark .rich-text-editor .hljs-addition { color: oklch(0.7 0.14 155); }
.dark .rich-text-editor .hljs-number,
.dark .rich-text-editor .hljs-literal { color: oklch(0.72 0.14 30); }
.dark .rich-text-editor .hljs-title,
.dark .rich-text-editor .hljs-section,
.dark .rich-text-editor .hljs-title\.function_ { color: oklch(0.72 0.12 280); }
.dark .rich-text-editor .hljs-attr,
.dark .rich-text-editor .hljs-attribute { color: oklch(0.72 0.1 60); }
.dark .rich-text-editor .hljs-variable,
.dark .rich-text-editor .hljs-template-variable { color: oklch(0.72 0.12 20); }
.dark .rich-text-editor .hljs-type,
.dark .rich-text-editor .hljs-title\.class_ { color: oklch(0.72 0.12 200); }
.dark .rich-text-editor .hljs-deletion { color: oklch(0.7 0.18 25); }
/* Tables */
.rich-text-editor .tableWrapper {
overflow-x: auto;
margin: 1rem 0;
border: 1px solid var(--border);
border-radius: var(--radius);
}
.rich-text-editor table {
min-width: 100%;
border-collapse: collapse;
}
.rich-text-editor colgroup {
display: none;
}
.rich-text-editor thead {
background: color-mix(in srgb, var(--muted) 50%, transparent);
}
.rich-text-editor tbody tr {
border-top: 1px solid var(--border);
}
.rich-text-editor tr:hover td {
background: color-mix(in srgb, var(--muted) 30%, transparent);
transition: background 0.15s;
}
.rich-text-editor th,
.rich-text-editor td {
text-align: left;
padding: 0.625rem 1rem;
font-size: 0.875rem;
}
.rich-text-editor th {
font-weight: 600;
}
/* Remove paragraph margin inside table cells */
.rich-text-editor th p,
.rich-text-editor td p {
margin: 0;
}
/* Blockquotes */
.rich-text-editor blockquote {
border-left: 3px solid color-mix(in srgb, var(--muted-foreground) 30%, transparent);
padding-left: 0.75rem;
margin: 0.625rem 0;
color: var(--muted-foreground);
font-style: italic;
}
.rich-text-editor blockquote p {
margin-top: 0.25rem;
margin-bottom: 0.25rem;
}
.rich-text-editor blockquote > *:first-child {
margin-top: 0;
}
.rich-text-editor blockquote > *:last-child {
margin-bottom: 0;
}
.rich-text-editor blockquote blockquote {
margin-top: 0.25rem;
margin-bottom: 0.25rem;
border-left-color: color-mix(in srgb, var(--muted-foreground) 15%, transparent);
}
/* Horizontal rules */
.rich-text-editor hr {
border: none;
border-top: 1px solid var(--border);
margin: 1rem 0;
}
/* Links */
.rich-text-editor a {
color: var(--brand);
text-decoration: underline;
text-decoration-color: color-mix(in srgb, var(--brand) 40%, transparent);
text-underline-offset: 2px;
cursor: pointer;
}
.rich-text-editor a:hover {
text-decoration-color: var(--brand);
}
/* Issue mention cards — inline cards that sit within text flow */
.rich-text-editor a.issue-mention {
color: inherit;
text-decoration: none;
}
.rich-text-editor a.issue-mention:hover {
text-decoration: none;
}
/* Mentions */
.rich-text-editor .mention {
color: var(--primary);
font-weight: 600;
text-decoration: none;
margin: 0 0.125rem;
}
/* Strong / emphasis */
.rich-text-editor strong {
font-weight: 600;
}
.rich-text-editor em {
font-style: italic;
}
.rich-text-editor s,
.rich-text-editor del {
text-decoration: line-through;
color: var(--muted-foreground);
}
/* Readonly mode overrides */
.rich-text-editor.readonly.ProseMirror {
caret-color: transparent;
cursor: default;
}
/* Mention NodeView inline layout fix */
.rich-text-editor [data-node-view-wrapper] {
display: inline;
vertical-align: middle;
}
/* Images — shared styling for both editing and readonly */
.rich-text-editor img {
border-radius: var(--radius);
margin: 0.5rem 0;
}
/* Uploading image placeholder — data-uploading attribute managed by ProseMirror schema */
.rich-text-editor img[data-uploading] {
opacity: 0.5;
border-radius: var(--radius);
animation: rte-upload-pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
@keyframes rte-upload-pulse {
0%, 100% { opacity: 0.5; }
50% { opacity: 0.3; }
}

View File

@@ -1,202 +0,0 @@
"use client";
/**
* ContentEditor — the single rich-text editor for the entire application.
*
* Architecture decisions (April 2026 refactor):
*
* 1. ONE COMPONENT for both editing and readonly display. The `editable` prop
* controls the mode. Previously we had RichTextEditor + ReadonlyEditor as
* separate components with duplicated extension configs — this caused
* visual inconsistency between edit and display modes.
*
* 2. ONE MARKDOWN PIPELINE via @tiptap/markdown. Content is loaded with
* `contentType: 'markdown'` and saved with `editor.getMarkdown()`.
* Previously we had a custom `markdownToHtml()` pipeline (Marked library)
* for loading and regex post-processing for saving — two asymmetric paths
* that caused roundtrip inconsistencies. The @tiptap/markdown extension
* (v3.21.0+) handles table cell <p> wrapping and custom mention tokenizers
* natively, eliminating the need for the HTML detour.
*
* 3. PREPROCESSING is minimal: only legacy mention shortcode migration and
* URL linkification (preprocessMarkdown). No HTML conversion.
*
* Tech: Tiptap v3.22.1 (ProseMirror wrapper), @tiptap/markdown for
* bidirectional Markdown ↔ ProseMirror JSON conversion.
*/
import {
forwardRef,
useEffect,
useImperativeHandle,
useRef,
} from "react";
import { useEditor, EditorContent } from "@tiptap/react";
import { cn } from "@/lib/utils";
import type { UploadResult } from "@/shared/hooks/use-file-upload";
import { useQueryClient } from "@tanstack/react-query";
import { createEditorExtensions } from "./extensions";
import { uploadAndInsertFile } from "./extensions/file-upload";
import { preprocessMarkdown } from "./utils/preprocess";
import "./content-editor.css";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface ContentEditorProps {
defaultValue?: string;
onUpdate?: (markdown: string) => void;
placeholder?: string;
editable?: boolean;
className?: string;
debounceMs?: number;
onSubmit?: () => void;
onBlur?: () => void;
onUploadFile?: (file: File) => Promise<UploadResult | null>;
}
interface ContentEditorRef {
getMarkdown: () => string;
clearContent: () => void;
focus: () => void;
uploadFile: (file: File) => void;
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
const ContentEditor = forwardRef<ContentEditorRef, ContentEditorProps>(
function ContentEditor(
{
defaultValue = "",
onUpdate,
placeholder: placeholderText = "",
editable = true,
className,
debounceMs = 300,
onSubmit,
onBlur,
onUploadFile,
},
ref,
) {
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const onUpdateRef = useRef(onUpdate);
const onSubmitRef = useRef(onSubmit);
const onBlurRef = useRef(onBlur);
const onUploadFileRef = useRef(onUploadFile);
const prevContentRef = useRef(defaultValue);
// Keep refs in sync without recreating editor
onUpdateRef.current = onUpdate;
onSubmitRef.current = onSubmit;
onBlurRef.current = onBlur;
onUploadFileRef.current = onUploadFile;
const queryClient = useQueryClient();
const editor = useEditor({
immediatelyRender: false,
editable,
content: defaultValue ? preprocessMarkdown(defaultValue) : "",
contentType: defaultValue ? "markdown" : undefined,
extensions: createEditorExtensions({
editable,
placeholder: placeholderText,
queryClient,
onSubmitRef,
onUploadFileRef,
}),
onUpdate: ({ editor: ed }) => {
if (!onUpdateRef.current) return;
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
onUpdateRef.current?.(ed.getMarkdown());
}, debounceMs);
},
onBlur: () => {
onBlurRef.current?.();
},
editorProps: {
handleDOMEvents: {
click(_view, event) {
const target = event.target as HTMLElement;
// Skip links inside NodeView wrappers — they handle their own clicks
if (target.closest("[data-node-view-wrapper]")) return false;
const link = target.closest("a");
const href = link?.getAttribute("href");
if (!href || href.startsWith("mention://")) return false;
if (!editable) {
// Readonly: any click on link opens new tab
event.preventDefault();
window.open(href, "_blank", "noopener,noreferrer");
return true;
}
if (event.metaKey || event.ctrlKey) {
// Edit mode: Cmd/Ctrl+click opens link
window.open(href, "_blank", "noopener,noreferrer");
event.preventDefault();
return true;
}
return false;
},
},
attributes: {
class: cn(
"rich-text-editor text-sm outline-none",
!editable && "readonly",
className,
),
},
},
});
// Cleanup debounce on unmount
useEffect(() => {
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, []);
// Readonly content update: when defaultValue changes and editor is readonly,
// re-set the content (e.g. after editing a comment, the readonly view updates)
useEffect(() => {
if (!editor || editable) return;
if (defaultValue === prevContentRef.current) return;
prevContentRef.current = defaultValue;
const processed = defaultValue ? preprocessMarkdown(defaultValue) : "";
if (processed) {
editor.commands.setContent(processed, { contentType: "markdown" });
} else {
editor.commands.clearContent();
}
}, [editor, editable, defaultValue]);
useImperativeHandle(ref, () => ({
getMarkdown: () => editor?.getMarkdown() ?? "",
clearContent: () => {
editor?.commands.clearContent();
},
focus: () => {
editor?.commands.focus();
},
uploadFile: (file: File) => {
if (!editor || !onUploadFileRef.current) return;
const endPos = editor.state.doc.content.size;
uploadAndInsertFile(editor, file, onUploadFileRef.current, endPos);
},
}));
if (!editor) return null;
return <EditorContent editor={editor} />;
},
);
export { ContentEditor, type ContentEditorProps, type ContentEditorRef };

View File

@@ -1,119 +0,0 @@
import { Extension } from "@tiptap/core";
import { Plugin, PluginKey } from "@tiptap/pm/state";
import type { UploadResult } from "@/shared/hooks/use-file-upload";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function removeImageBySrc(editor: any, src: string) {
if (!editor) return;
const { tr } = editor.state;
let deleted = false;
editor.state.doc.descendants((node: any, pos: number) => {
if (deleted) return false;
if (node.type.name === "image" && node.attrs.src === src) {
tr.delete(pos, pos + node.nodeSize);
deleted = true;
return false;
}
});
if (deleted) editor.view.dispatch(tr);
}
/**
* Shared upload flow: insert blob preview → upload → replace with real URL.
* Used by both paste/drop (at cursor) and button upload (at end of doc).
*/
export async function uploadAndInsertFile(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
editor: any,
file: File,
handler: (file: File) => Promise<UploadResult | null>,
pos?: number,
) {
const isImage = file.type.startsWith("image/");
if (isImage) {
const blobUrl = URL.createObjectURL(file);
const imgAttrs = { src: blobUrl, alt: file.name, uploading: true };
if (pos !== undefined) {
editor.chain().focus().insertContentAt(pos, { type: "image", attrs: imgAttrs }).run();
} else {
editor.chain().focus().setImage(imgAttrs).run();
}
try {
const result = await handler(file);
if (result) {
const { tr } = editor.state;
editor.state.doc.descendants((node: { type: { name: string }; attrs: { src: string } }, nodePos: number) => {
if (node.type.name === "image" && node.attrs.src === blobUrl) {
tr.setNodeMarkup(nodePos, undefined, {
...node.attrs,
src: result.link,
alt: result.filename,
uploading: false,
});
}
});
editor.view.dispatch(tr);
} else {
removeImageBySrc(editor, blobUrl);
}
} catch {
removeImageBySrc(editor, blobUrl);
} finally {
URL.revokeObjectURL(blobUrl);
}
} else {
// Non-image: upload first, then insert link
const result = await handler(file);
if (!result) return;
const linkText = `[${result.filename}](${result.link})`;
if (pos !== undefined) {
editor.chain().focus().insertContentAt(pos, linkText).run();
} else {
editor.chain().focus().insertContent(linkText).run();
}
}
}
export function createFileUploadExtension(
onUploadFileRef: React.RefObject<((file: File) => Promise<UploadResult | null>) | undefined>,
) {
return Extension.create({
name: "fileUpload",
addProseMirrorPlugins() {
const { editor } = this;
const handleFiles = async (files: FileList) => {
const handler = onUploadFileRef.current;
if (!handler) return false;
for (const file of Array.from(files)) {
await uploadAndInsertFile(editor, file, handler);
}
return true;
};
return [
new Plugin({
key: new PluginKey("fileUpload"),
props: {
handlePaste(_view, event) {
const files = event.clipboardData?.files;
if (!files?.length) return false;
if (!onUploadFileRef.current) return false;
handleFiles(files);
return true;
},
handleDrop(_view, event) {
const files = (event as DragEvent).dataTransfer?.files;
if (!files?.length) return false;
if (!onUploadFileRef.current) return false;
handleFiles(files);
return true;
},
},
}),
];
},
});
}

View File

@@ -1,126 +0,0 @@
/**
* Shared extension factory for ContentEditor.
*
* One function builds the extension array for BOTH edit and readonly modes.
* This ensures visual consistency — the same extensions parse and render
* content identically regardless of mode.
*
* Split:
* - Both modes: StarterKit, CodeBlock, Link, Image, Table, Markdown, Mention
* - Edit only: Typography, Placeholder, markdownPaste, submitShortcut,
* fileUpload, Mention suggestion popup
*
* Link config differs: edit mode has autolink (detects URLs while typing),
* readonly does not (prevents false positives on display).
*
* Mention suggestion is only attached in edit mode — readonly doesn't need
* the autocomplete popup.
*
* All link styling is controlled by content-editor.css (var(--brand) color),
* not Tailwind HTMLAttributes, to keep a single source of truth.
*/
import type { RefObject } from "react";
import StarterKit from "@tiptap/starter-kit";
import CodeBlockLowlight from "@tiptap/extension-code-block-lowlight";
import { common, createLowlight } from "lowlight";
import Placeholder from "@tiptap/extension-placeholder";
import Link from "@tiptap/extension-link";
import Typography from "@tiptap/extension-typography";
import Image from "@tiptap/extension-image";
import TableRow from "@tiptap/extension-table-row";
import TableHeader from "@tiptap/extension-table-header";
import TableCell from "@tiptap/extension-table-cell";
import { Table } from "@tiptap/extension-table";
import { Markdown } from "@tiptap/markdown";
import { ReactNodeViewRenderer } from "@tiptap/react";
import type { AnyExtension } from "@tiptap/core";
import type { UploadResult } from "@/shared/hooks/use-file-upload";
import { BaseMentionExtension } from "./mention-extension";
import { createMentionSuggestion } from "./mention-suggestion";
import { CodeBlockView } from "./code-block-view";
import { createMarkdownPasteExtension } from "./markdown-paste";
import { createSubmitExtension } from "./submit-shortcut";
import { createFileUploadExtension } from "./file-upload";
const lowlight = createLowlight(common);
const LinkEditable = Link.extend({ inclusive: false }).configure({
openOnClick: true,
autolink: true,
linkOnPaste: false,
});
const LinkReadonly = Link.configure({
openOnClick: false,
autolink: false,
});
const ImageExtension = Image.extend({
addAttributes() {
return {
...this.parent?.(),
uploading: {
default: false,
renderHTML: (attrs: Record<string, unknown>) =>
attrs.uploading ? { "data-uploading": "" } : {},
parseHTML: (el: HTMLElement) => el.hasAttribute("data-uploading"),
},
};
},
}).configure({
inline: false,
allowBase64: false,
HTMLAttributes: { style: "max-width: 100%; height: auto;" },
});
export interface EditorExtensionsOptions {
editable: boolean;
placeholder?: string;
queryClient?: import("@tanstack/react-query").QueryClient;
onSubmitRef?: RefObject<(() => void) | undefined>;
onUploadFileRef?: RefObject<
((file: File) => Promise<UploadResult | null>) | undefined
>;
}
export function createEditorExtensions(
options: EditorExtensionsOptions,
): AnyExtension[] {
const { editable, placeholder: placeholderText } = options;
const extensions: AnyExtension[] = [
StarterKit.configure({
heading: { levels: [1, 2, 3] },
link: false,
codeBlock: false,
}),
CodeBlockLowlight.extend({
addNodeView() {
return ReactNodeViewRenderer(CodeBlockView);
},
}).configure({ lowlight }),
editable ? LinkEditable : LinkReadonly,
ImageExtension,
Table.configure({ resizable: false }),
TableRow,
TableHeader,
TableCell,
Markdown,
BaseMentionExtension.configure({
HTMLAttributes: { class: "mention" },
...(editable && options.queryClient ? { suggestion: createMentionSuggestion(options.queryClient) } : {}),
}),
];
if (editable) {
extensions.push(
Typography,
Placeholder.configure({ placeholder: placeholderText }),
createMarkdownPasteExtension(),
createSubmitExtension(() => options.onSubmitRef?.current?.()),
createFileUploadExtension(options.onUploadFileRef!),
);
}
return extensions;
}

View File

@@ -1,67 +0,0 @@
/**
* Markdown paste extension — ensures pasted text is parsed as Markdown.
*
* Problem: The browser clipboard can contain BOTH text/plain and text/html.
* ProseMirror always prefers text/html when present (hardcoded in
* parseFromClipboard: `let asText = !html`). When copying from VS Code,
* text editors, or .md files, the OS wraps text in <pre>/<div> HTML tags.
* ProseMirror parses these as code blocks — wrong.
*
* Solution: Use `handlePaste` (the only ProseMirror prop that runs for ALL
* paste events and has access to raw ClipboardEvent). We check for
* `data-pm-slice` in the HTML — this attribute is added by ProseMirror's
* own clipboard serializer. If present, the source is another ProseMirror
* editor and its HTML is structurally correct — let ProseMirror handle it.
* Otherwise, ignore the HTML and parse text/plain as Markdown.
*
* Why not clipboardTextParser? It only runs when there's NO text/html on
* the clipboard (ProseMirror source: `let asText = !!text && !html`).
*
* Why not heuristic detection (looksLikeMarkdown / hasRichHtml)? Unreliable.
* VS Code's HTML contains <code> tags that fool rich-content detectors.
* Markdown pattern matching has too many edge cases. The data-pm-slice
* check is deterministic — no false positives.
*/
import { Extension } from "@tiptap/core";
import { Plugin, PluginKey } from "@tiptap/pm/state";
import { Slice } from "@tiptap/pm/model";
export function createMarkdownPasteExtension() {
return Extension.create({
name: "markdownPaste",
addProseMirrorPlugins() {
const { editor } = this;
return [
new Plugin({
key: new PluginKey("markdownPaste"),
props: {
handlePaste(view, event) {
if (!editor.markdown) return false;
const clipboard = event.clipboardData;
if (!clipboard) return false;
const text = clipboard.getData("text/plain");
if (!text) return false;
const html = clipboard.getData("text/html");
// If HTML contains data-pm-slice, the source is another
// ProseMirror editor — let ProseMirror use its native HTML
// clipboard path to preserve exact node structure.
if (html && html.includes("data-pm-slice")) return false;
// Everything else (VS Code, text editors, .md files, terminals,
// web pages): parse text/plain as Markdown.
const json = editor.markdown.parse(text);
const node = editor.schema.nodeFromJSON(json);
const slice = Slice.maxOpen(node.content);
const tr = view.state.tr.replaceSelection(slice);
view.dispatch(tr);
return true;
},
},
}),
];
},
});
}

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