mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-26 17:09:14 +02:00
Compare commits
1 Commits
license/mo
...
agent/lamb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
31c2476a7b |
@@ -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=
|
||||
|
||||
34
.github/PULL_REQUEST_TEMPLATE.md
vendored
34
.github/PULL_REQUEST_TEMPLATE.md
vendored
@@ -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. -->
|
||||
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@@ -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
1
.gitignore
vendored
@@ -36,7 +36,6 @@ apps/web/test-results/
|
||||
|
||||
# local settings
|
||||
.claude/
|
||||
.tool-versions
|
||||
|
||||
# feature tracking
|
||||
_features/
|
||||
|
||||
69
CLAUDE.md
69
CLAUDE.md
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
171
CLI_INSTALL.md
171
CLI_INSTALL.md
@@ -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
221
LICENSE
@@ -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.
|
||||
|
||||
17
Makefile
17
Makefile
@@ -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
126
README.md
@@ -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.
|
||||
|
||||
[](https://github.com/multica-ai/multica/actions/workflows/ci.yml)
|
||||
[](https://opensource.org/licenses/Apache-2.0)
|
||||
[](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.
|
||||
|
||||
162
README.zh-CN.md
162
README.zh-CN.md
@@ -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 团队。
|
||||
|
||||
[](https://github.com/multica-ai/multica/actions/workflows/ci.yml)
|
||||
[](https://opensource.org/licenses/Apache-2.0)
|
||||
[](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 agent(Claude 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 CLI,Multica 据此决定将任务路由到哪里执行。
|
||||
|
||||
### 3. 创建 Agent
|
||||
|
||||
进入 **设置 → Agents**,点击 **新建 Agent**。选择你刚连接的 Runtime,选择 Provider(Claude 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)
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"images": [
|
||||
{
|
||||
"idiom": "universal",
|
||||
"platform": "ios",
|
||||
"size": "1024x1024"
|
||||
}
|
||||
],
|
||||
"info": {
|
||||
"author": "xcode",
|
||||
"version": 1
|
||||
}
|
||||
}
|
||||
6
apps/ios/Multica/Assets.xcassets/Contents.json
Normal file
6
apps/ios/Multica/Assets.xcassets/Contents.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info": {
|
||||
"author": "xcode",
|
||||
"version": 1
|
||||
}
|
||||
}
|
||||
37
apps/ios/Multica/Info.plist
Normal file
37
apps/ios/Multica/Info.plist
Normal 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>
|
||||
29
apps/ios/Multica/Models/Agent.swift
Normal file
29
apps/ios/Multica/Models/Agent.swift
Normal 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
|
||||
}
|
||||
61
apps/ios/Multica/Models/AgentTask.swift
Normal file
61
apps/ios/Multica/Models/AgentTask.swift
Normal 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
|
||||
}
|
||||
}
|
||||
46
apps/ios/Multica/Models/Comment.swift
Normal file
46
apps/ios/Multica/Models/Comment.swift
Normal 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"
|
||||
}
|
||||
}
|
||||
152
apps/ios/Multica/Models/Issue.swift
Normal file
152
apps/ios/Multica/Models/Issue.swift
Normal 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"
|
||||
}
|
||||
}
|
||||
55
apps/ios/Multica/Models/Member.swift
Normal file
55
apps/ios/Multica/Models/Member.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
71
apps/ios/Multica/Models/TaskMessage.swift
Normal file
71
apps/ios/Multica/Models/TaskMessage.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
22
apps/ios/Multica/Models/User.swift
Normal file
22
apps/ios/Multica/Models/User.swift
Normal 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
|
||||
}
|
||||
23
apps/ios/Multica/Models/Workspace.swift
Normal file
23
apps/ios/Multica/Models/Workspace.swift
Normal 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?
|
||||
}
|
||||
10
apps/ios/Multica/MulticaApp.swift
Normal file
10
apps/ios/Multica/MulticaApp.swift
Normal file
@@ -0,0 +1,10 @@
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct MulticaApp: App {
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
}
|
||||
}
|
||||
}
|
||||
274
apps/ios/Multica/Services/APIClient.swift
Normal file
274
apps/ios/Multica/Services/APIClient.swift
Normal 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"
|
||||
}
|
||||
}
|
||||
42
apps/ios/Multica/Services/KeychainHelper.swift
Normal file
42
apps/ios/Multica/Services/KeychainHelper.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
122
apps/ios/Multica/Services/WebSocketClient.swift
Normal file
122
apps/ios/Multica/Services/WebSocketClient.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
65
apps/ios/Multica/ViewModels/AuthViewModel.swift
Normal file
65
apps/ios/Multica/ViewModels/AuthViewModel.swift
Normal 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
|
||||
}
|
||||
}
|
||||
132
apps/ios/Multica/ViewModels/IssueDetailViewModel.swift
Normal file
132
apps/ios/Multica/ViewModels/IssueDetailViewModel.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
63
apps/ios/Multica/ViewModels/IssueListViewModel.swift
Normal file
63
apps/ios/Multica/ViewModels/IssueListViewModel.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
70
apps/ios/Multica/ViewModels/WorkspaceViewModel.swift
Normal file
70
apps/ios/Multica/ViewModels/WorkspaceViewModel.swift
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
105
apps/ios/Multica/Views/Auth/LoginView.swift
Normal file
105
apps/ios/Multica/Views/Auth/LoginView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
111
apps/ios/Multica/Views/Comments/CommentListView.swift
Normal file
111
apps/ios/Multica/Views/Comments/CommentListView.swift
Normal 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())
|
||||
}
|
||||
}
|
||||
33
apps/ios/Multica/Views/Components/AssigneeAvatar.swift
Normal file
33
apps/ios/Multica/Views/Components/AssigneeAvatar.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
29
apps/ios/Multica/Views/Components/PriorityIcon.swift
Normal file
29
apps/ios/Multica/Views/Components/PriorityIcon.swift
Normal 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))
|
||||
}
|
||||
}
|
||||
46
apps/ios/Multica/Views/Components/StatusBadge.swift
Normal file
46
apps/ios/Multica/Views/Components/StatusBadge.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
38
apps/ios/Multica/Views/ContentView.swift
Normal file
38
apps/ios/Multica/Views/ContentView.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
96
apps/ios/Multica/Views/Issues/CreateIssueView.swift
Normal file
96
apps/ios/Multica/Views/Issues/CreateIssueView.swift
Normal 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
|
||||
}
|
||||
}
|
||||
182
apps/ios/Multica/Views/Issues/IssueDetailView.swift
Normal file
182
apps/ios/Multica/Views/Issues/IssueDetailView.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
113
apps/ios/Multica/Views/Issues/IssueListView.swift
Normal file
113
apps/ios/Multica/Views/Issues/IssueListView.swift
Normal 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")
|
||||
}
|
||||
36
apps/ios/Multica/Views/Issues/IssueRowView.swift
Normal file
36
apps/ios/Multica/Views/Issues/IssueRowView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
138
apps/ios/Multica/Views/Tasks/TaskMessagesView.swift
Normal file
138
apps/ios/Multica/Views/Tasks/TaskMessagesView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
206
apps/ios/Multica/Views/Tasks/TaskRunsView.swift
Normal file
206
apps/ios/Multica/Views/Tasks/TaskRunsView.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
47
apps/ios/Multica/Views/Workspace/WorkspacePickerView.swift
Normal file
47
apps/ios/Multica/Views/Workspace/WorkspacePickerView.swift
Normal 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
64
apps/ios/README.md
Normal 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
24
apps/ios/project.yml
Normal 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
|
||||
@@ -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" })
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}}
|
||||
|
||||
@@ -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!;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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);
|
||||
227
apps/web/components/common/rich-text-editor.css
Normal file
227
apps/web/components/common/rich-text-editor.css
Normal 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; }
|
||||
}
|
||||
460
apps/web/components/common/rich-text-editor.tsx
Normal file
460
apps/web/components/common/rich-text-editor.tsx
Normal 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 };
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 =
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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})`;
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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={
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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) });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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(),
|
||||
);
|
||||
}
|
||||
@@ -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) });
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export { createQueryClient } from "./query-client";
|
||||
export { QueryProvider } from "./provider";
|
||||
export { useWorkspaceId } from "./hooks";
|
||||
@@ -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";
|
||||
@@ -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) });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
@@ -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) });
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export { runtimeKeys, runtimeListOptions } from "./queries";
|
||||
@@ -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 }),
|
||||
});
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
export {
|
||||
workspaceKeys,
|
||||
workspaceListOptions,
|
||||
memberListOptions,
|
||||
agentListOptions,
|
||||
skillListOptions,
|
||||
} from "./queries";
|
||||
|
||||
export {
|
||||
useCreateWorkspace,
|
||||
useLeaveWorkspace,
|
||||
useDeleteWorkspace,
|
||||
} from "./mutations";
|
||||
@@ -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() });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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(),
|
||||
});
|
||||
}
|
||||
@@ -1,3 +1,2 @@
|
||||
export { useAuthStore } from "./store";
|
||||
export { AuthInitializer } from "./initializer";
|
||||
export { setLoggedInCookie } from "./auth-cookie";
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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;
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user