Compare commits

..

2 Commits

Author SHA1 Message Date
Jiayuan Zhang
a6c80d65d3 fix(notifications): fetch system_notifications pref lazily
Settings is the only mounted reader of notificationPreferenceOptions,
so a fresh app start (or any session that never visits Settings) left
the cache empty and the muted preference silently fell back to default
"all". Switch the inbox:new handler to ensureQueryData so the value is
fetched on first use and cached for subsequent events.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-06 16:59:01 +08:00
Jiayuan Zhang
56b71d62ce feat(notifications): add system notifications toggle in settings
Add a per-user, per-workspace toggle to enable/disable native OS
notification banners. Reuses the existing notification-preferences
endpoint by introducing a `system_notifications` key alongside the
inbox event groups; the realtime handler reads the cached preference
and skips desktopAPI.showNotification when muted.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-06 16:50:58 +08:00
485 changed files with 5645 additions and 43383 deletions

View File

@@ -101,13 +101,6 @@ ALLOWED_ORIGINS=
# `Authorization: Bearer <token>`.
# REALTIME_METRICS_TOKEN=
# GitHub App integration (Settings → Integrations "Connect GitHub")
# Both must be set for the Connect button to enable and for webhooks to be
# accepted; leave empty to disable the integration. See docs/github-integration.
# GITHUB_APP_SLUG is the tail of https://github.com/apps/<slug>.
GITHUB_APP_SLUG=
GITHUB_WEBHOOK_SECRET=
# Frontend
FRONTEND_PORT=3000
FRONTEND_ORIGIN=http://localhost:3000
@@ -139,8 +132,5 @@ ALLOWED_EMAILS=
# will run a no-op analytics client and ship nothing. See docs/analytics.md.
POSTHOG_API_KEY=
POSTHOG_HOST=https://us.i.posthog.com
# Optional override for the `environment` PostHog event property.
# Defaults from APP_ENV and normalizes to production / staging / dev.
ANALYTICS_ENVIRONMENT=
# Force the no-op client even when POSTHOG_API_KEY is set (CI / opt-out).
ANALYTICS_DISABLED=

View File

@@ -40,8 +40,6 @@ Closes #
- [ ] I have added or updated tests where applicable
- [ ] If this change affects the UI, I have included before/after screenshots
- [ ] I have updated relevant documentation to reflect my changes
- [ ] If I added a new runtime / coding tool / UI tab, I synced the change to **landing copy** (`apps/web/features/landing/i18n/`), **starter-content** (`packages/views/onboarding/utils/starter-content-content-*.ts`), and **relevant docs** (`apps/docs/content/docs/`)
- [ ] If this PR touches Chinese product copy, I checked it against `apps/docs/content/docs/developers/conventions.zh.mdx` (terminology, mixed-rule for `task` / `issue` / `skill`)
- [ ] I have considered and documented any risks above
- [ ] I will address all reviewer comments before requesting merge

View File

@@ -29,15 +29,6 @@ jobs:
- name: Install dependencies
run: pnpm install
- name: Verify reserved-slugs.ts is up to date
# Re-runs the generator and fails on any drift from the
# checked-in TypeScript output. The Go side embeds the JSON
# source directly, so a passing diff here proves both sides
# share one source of truth.
run: |
pnpm generate:reserved-slugs
git diff --exit-code -- packages/core/paths/reserved-slugs.ts
- name: Build, type check, lint, and test
run: pnpm exec turbo build typecheck lint test --filter='!@multica/docs'

3
.gitignore vendored
View File

@@ -58,6 +58,3 @@ server/server
data/
.kilo
.idea
# git worktrees
.worktrees/

View File

@@ -146,27 +146,10 @@ make start-worktree # Start using .env.worktree
- Go code follows standard Go conventions (gofmt, go vet).
- Keep comments in code **English only**.
- Prefer existing patterns/components over introducing parallel abstractions.
- Unless the user explicitly asks for backwards compatibility, do **not** add compatibility layers, fallback paths, dual-write logic, legacy adapters, or temporary shims **for internal, non-boundary code** (a function calling another function in the same package, a component reading its own state, a store helper, etc.).
- This rule does **not** apply at API boundaries: the desktop app cannot assume the backend it talks to has the same shape as the one it was built against (older desktop installs will outlive any given server build). API response handling must follow the rules in **API Response Compatibility** below — that is a defensive boundary, not a legacy shim.
- Unless the user explicitly asks for backwards compatibility, do **not** add compatibility layers, fallback paths, dual-write logic, legacy adapters, or temporary shims.
- If a flow or API is being replaced and the product is not yet live, prefer removing the old path instead of preserving both old and new behavior.
- Avoid broad refactors unless required by the task.
- New global (pre-workspace) routes MUST use a single word (`/login`, `/inbox`) or a `/{noun}/{verb}` pair (`/workspaces/new`). NEVER add hyphenated word-group root routes (`/new-workspace`, `/create-team`) — they collide with common user workspace names and force endless reserved-slug audits. Reserving the noun (`workspaces`) automatically protects the entire `/workspaces/*` subtree.
- The reserved-slug list lives in **one** place: `server/internal/handler/reserved_slugs.json`. The Go side embeds the JSON; `packages/core/paths/reserved-slugs.ts` is generated from it by `pnpm generate:reserved-slugs`. Edit the JSON, run the generator, commit both. CI re-runs the generator and fails on any drift, so a stale TS file cannot land.
### API Response Compatibility
The desktop app installed on a user's machine is older than any backend it talks to: a user on 0.2.26 will hit a server running 0.3.x, then 0.4.x, then beyond. Every response shape is a contract that **will** drift, and the frontend must survive drift without white-screening. Three concrete incidents already happened from violating this — #2143, #2147, #2192.
When writing code that consumes an API response, follow these rules:
- **Parse, don't cast.** Untyped JSON crossing the network is not `T`. Use `parseWithFallback` in `packages/core/api/schema.ts` with a `zod` schema and an explicit fallback. On validation failure it logs a warning and returns the fallback; it never throws into the UI.
- **No bare `as` casts on response bodies.** Every endpoint method whose response is consumed by UI logic must run through a schema before returning.
- **Optional-chain and default everywhere downstream.** Treat every field as possibly missing. Use explicit boolean checks (`=== true`) over truthy/falsy negation, which silently treats `undefined` and `null` as `false`.
- **Don't pin a UI affordance to a single backend field.** If a button or indicator depends on exactly one boolean from the server, a backend bug deletes it. Combine signals (cursor presence, page length, etc.) so the affordance stays available in the worst case.
- **Enum drift downgrades, not crashes.** A new server-side enum value should render a generic fallback. `switch` statements on server-driven strings must have a `default` branch.
- **When you add or change an endpoint:** add the schema in the same PR, and write at least one test that feeds a malformed response through it (missing field, wrong type, `null` array). The test fails closed if a future change breaks the contract.
This is not premature defense — it is the *only* defense for an installed-app architecture. CSR-only browser apps can ship a fix in minutes; an Electron build sitting on a developer's laptop cannot.
### Backend Handler UUID Parsing Convention

View File

@@ -306,11 +306,10 @@ multica issue list
multica issue list --status in_progress
multica issue list --priority urgent --assignee "Agent Name"
multica issue list --assignee-id 5fb87ac7-23b5-4a7a-81fa-ed295a54545d
multica issue list --full-id
multica issue list --limit 20 --output json
```
Table output shows a routable issue `KEY` such as `MUL-123`; copy that key into follow-up commands like `issue get`, `issue comment list`, `issue status`, or `--parent`. Add `--full-id` when you need canonical UUIDs. Available filters: `--status`, `--priority`, `--assignee` / `--assignee-id`, `--project`, `--limit`. Use `--assignee-id <uuid>` for unambiguous filtering when names overlap.
Available filters: `--status`, `--priority`, `--assignee` / `--assignee-id`, `--project`, `--limit`. Use `--assignee-id <uuid>` for unambiguous filtering when names overlap.
### Get Issue
@@ -394,19 +393,17 @@ Subscribers receive notifications about issue activity (new comments, status cha
```bash
# List all execution runs for an issue
multica issue runs <issue-id>
multica issue runs <issue-id> --full-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 <short-task-id> --issue <issue-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. Table output uses short task UUID prefixes by default; pass `--full-id` to print canonical task UUIDs. The `run-messages` command accepts full task UUIDs directly; copied short task prefixes must be scoped with `--issue <issue-id>` so the CLI only checks that issue's runs. It shows the detailed message log (tool calls, thinking, text, errors) for a single run. Use `--since` for efficient polling of in-progress runs.
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.
## Projects
@@ -516,12 +513,9 @@ Autopilots are scheduled/triggered automations that dispatch agent tasks (either
```bash
multica autopilot list
multica autopilot list --full-id
multica autopilot list --status active --output json
```
Autopilot table IDs are short UUID prefixes; follow-up autopilot commands accept copied prefixes when they are unique in the current workspace. Use `--full-id` to print canonical UUIDs.
### Get Autopilot Details
```bash

View File

@@ -20,7 +20,7 @@ Turn coding agents into real teammates — assign tasks, track progress, compoun
[![CI](https://github.com/multica-ai/multica/actions/workflows/ci.yml/badge.svg)](https://github.com/multica-ai/multica/actions/workflows/ci.yml)
[![GitHub stars](https://img.shields.io/github/stars/multica-ai/multica?style=flat)](https://github.com/multica-ai/multica/stargazers)
[Website](https://multica.ai) · [Cloud](https://multica.ai) · [X](https://x.com/MulticaAI) · [Self-Hosting](SELF_HOSTING.md) · [Contributing](CONTRIBUTING.md)
[Website](https://multica.ai) · [Cloud](https://multica.ai/app) · [X](https://x.com/MulticaAI) · [Self-Hosting](SELF_HOSTING.md) · [Contributing](CONTRIBUTING.md)
**English | [简体中文](README.zh-CN.md)**

View File

@@ -20,7 +20,7 @@
[![CI](https://github.com/multica-ai/multica/actions/workflows/ci.yml/badge.svg)](https://github.com/multica-ai/multica/actions/workflows/ci.yml)
[![GitHub stars](https://img.shields.io/github/stars/multica-ai/multica?style=flat)](https://github.com/multica-ai/multica/stargazers)
[官网](https://multica.ai) · [云服务](https://multica.ai) · [X](https://x.com/MulticaAI) · [自部署指南](SELF_HOSTING.md) · [参与贡献](CONTRIBUTING.md)
[官网](https://multica.ai) · [云服务](https://multica.ai/app) · [X](https://x.com/MulticaAI) · [自部署指南](SELF_HOSTING.md) · [参与贡献](CONTRIBUTING.md)
**[English](README.md) | 简体中文**

View File

@@ -186,47 +186,16 @@ In production, put a reverse proxy in front of both the backend and frontend to
### Caddy (Recommended)
**Single-domain layout** — frontend and backend served on the same hostname (this is what `docker-compose.selfhost.yml` defaults to):
```
multica.example.com {
# WebSocket route — must come before the catch-all
@multica_ws path /ws /ws/*
handle @multica_ws {
reverse_proxy localhost:8080 {
flush_interval -1
}
}
# Everything else → frontend
reverse_proxy localhost:3000
}
```
**Separate-domain layout** — frontend and backend on different hostnames:
```
app.example.com {
reverse_proxy localhost:3000
}
api.example.com {
@multica_ws path /ws /ws/*
handle @multica_ws {
reverse_proxy localhost:8080 {
flush_interval -1
}
}
reverse_proxy localhost:8080
}
```
Two non-obvious bits inside the `/ws` block are worth calling out — both are common reasons real-time updates "stop working" on a Caddy-fronted self-host:
- **`path /ws /ws/*` (not `/ws*`)** — bare `handle /ws` is an exact match, so future path variants under `/ws/` fall through to the frontend block. The obvious shortcut `handle /ws*` overcorrects in the other direction: Caddy's `*` is a glob without a path-segment boundary, so it would also catch unrelated paths like `/ws-foo`, which is a legitimate workspace URL (only the exact slug `ws` is reserved). Listing `/ws` and `/ws/*` explicitly covers both real cases without overreach.
- **`flush_interval -1`** — disables response buffering so WebSocket frames are forwarded as soon as they arrive. Without it, frames can sit behind Caddy's default flush window, which looks like delayed comments, missing typing indicators, or "comments only appear after a page refresh."
### Nginx
```nginx

Binary file not shown.

Before

Width:  |  Height:  |  Size: 136 KiB

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 491 KiB

After

Width:  |  Height:  |  Size: 35 KiB

View File

@@ -32,34 +32,6 @@ mac:
dmg:
artifactName: multica-desktop-${version}-mac-${arch}.${ext}
linux:
# Override the Linux executable name to avoid leaking the scoped npm
# package name (`@multica/desktop`) into the installed binary, the
# `.desktop` file, and the hicolor icon filename. Without this override
# electron-builder defaults `executableName` to the package `name`,
# which after slash-stripping becomes `@multicadesktop` — producing
# `/usr/share/applications/@multicadesktop.desktop`,
# `Icon=@multicadesktop`, and
# `/usr/share/icons/hicolor/*/apps/@multicadesktop.png`. The leading `@`
# violates the freedesktop desktop-entry naming guidance, so GNOME /
# Ubuntu fail to associate the running window with the `.desktop` entry
# and fall back to the theme's default app icon (the Settings gear on
# Yaru). Forcing `multica` makes every Linux identity slot agree and
# matches `StartupWMClass=Multica` (productName-derived).
executableName: multica
# Pin StartupWMClass explicitly to the WM_CLASS that Electron emits on
# X11. Electron derives WM_CLASS from `app.getName()`, which in packaged
# builds resolves to `productName` (`Multica`). Without an explicit
# `StartupWMClass`, electron-builder writes `productName` as the default
# — making this declaration redundant with current settings — but
# pinning the value here turns a silent future drift (e.g. if anyone
# renames productName or sets app.setName at boot) into a visible diff
# against this file. The WM_CLASS ↔ StartupWMClass match is what lets
# GNOME associate the running window with the `.desktop` entry and
# therefore render the right icon. The post-build verification step in
# PR #2437 is `xprop WM_CLASS` on a real Ubuntu install.
desktop:
entry:
StartupWMClass: Multica
target:
- AppImage
- deb

View File

@@ -10,11 +10,10 @@ export default [
globals: { ...globals.node },
},
},
// Security: every renderer-controlled URL that reaches the OS shell or the
// native download system must flow through the safe wrappers in
// src/main/external-url.ts (scheme allowlist). Enforce it statically so
// direct shell.openExternal / webContents.downloadURL calls cannot silently
// regress the protection.
// Security: every renderer-controlled URL that reaches the OS shell must
// flow through openExternalSafely in src/main/external-url.ts (scheme
// allowlist). Enforce it statically so a direct shell.openExternal call
// cannot silently regress the protection.
{
files: ["src/main/**/*.ts"],
rules: {
@@ -26,12 +25,6 @@ export default [
message:
"Do not call shell.openExternal directly. Use openExternalSafely from './external-url' so the http/https allowlist stays enforced.",
},
{
selector:
"CallExpression[callee.object.property.name='webContents'][callee.property.name='downloadURL']",
message:
"Do not call webContents.downloadURL directly. Use downloadURLSafely from './external-url' so the http/https allowlist stays enforced.",
},
],
},
},

View File

@@ -1,4 +1,4 @@
import { shell, type BrowserWindow } from "electron";
import { shell } from "electron";
// True when the URL parses and uses http/https — the only schemes we let
// reach `shell.openExternal`. Scheme comparison is safe because the WHATWG
@@ -19,19 +19,6 @@ export function openExternalSafely(url: string): Promise<void> | void {
return shell.openExternal(url);
}
// Canonical wrapper around webContents.downloadURL. All renderer-controlled
// URLs that trigger a native download MUST flow through here; direct calls
// to `webContents.downloadURL` elsewhere in the main process are banned by
// the no-restricted-syntax rule in apps/desktop/eslint.config.mjs.
// Reuses the same http/https allowlist as openExternalSafely.
export function downloadURLSafely(win: BrowserWindow, url: string): void {
if (getHttpProtocol(url) === null) {
console.warn(`[security] blocked downloadURL: ${describeScheme(url)}`);
return;
}
win.webContents.downloadURL(url);
}
function getHttpProtocol(url: string): "http:" | "https:" | null {
try {
const { protocol } = new URL(url);

View File

@@ -5,31 +5,16 @@ import { electronApp, optimizer, is } from "@electron-toolkit/utils";
import fixPath from "fix-path";
import { setupAutoUpdater } from "./updater";
import { setupDaemonManager } from "./daemon-manager";
import { openExternalSafely, downloadURLSafely } from "./external-url";
import { openExternalSafely } from "./external-url";
import { installContextMenu } from "./context-menu";
import { getAppVersion } from "./app-version";
import { loadRuntimeConfig } from "./runtime-config-loader";
import type { RuntimeConfigResult } from "../shared/runtime-config";
// Bundled icon used for dock/taskbar branding. macOS/Windows production
// builds let the OS pick up the icon from the .app bundle / .exe resources,
// but Linux production needs an explicit BrowserWindow `icon` — AppImage
// direct-launch doesn't register the .desktop entry, so GNOME has no path
// from the running window to the hicolor icon and falls back to the
// theme default. Consumed in createWindow() (all platforms in dev, Linux
// in prod) and the macOS dev dock branch.
//
// `asarUnpack: resources/**` in electron-builder.yml extracts the icon to
// `app.asar.unpacked/`, but `__dirname` resolves into `app.asar/`. The
// Linux native window-icon code path expects a real filesystem path
// (unlike Electron's nativeImage loader which transparently reads from
// asar), so swap the segment — same pattern as bundledCliPath() in
// daemon-manager.ts. In dev `__dirname` has no `app.asar`, so the replace
// is a no-op.
const BUNDLED_ICON_PATH = join(__dirname, "../../resources/icon.png").replace(
"app.asar",
"app.asar.unpacked",
);
// Bundled icon used for dev-mode dock/taskbar branding. In production the
// app bundle icon (from electron-builder) wins; this path is only consumed
// by the `is.dev` branch below.
const DEV_ICON_PATH = join(__dirname, "../../resources/icon.png");
// macOS/Linux GUI launches inherit a minimal PATH from launchd that omits
// the user's shell config (~/.zshrc, Homebrew, nvm, ~/.local/bin, etc.).
@@ -121,14 +106,9 @@ function createWindow(): void {
trafficLightPosition: { x: 16, y: 13 },
show: false,
autoHideMenuBar: true,
// Windows/Linux pick up the window/taskbar icon from this option.
// On macOS it's ignored (dock comes from app.dock.setIcon below).
// Linux production needs this explicitly because AppImage direct-launch
// does not install a .desktop entry, so the WM has no other path to
// the bundled icon; without it Ubuntu falls back to the theme default.
...(is.dev || process.platform === "linux"
? { icon: BUNDLED_ICON_PATH }
: {}),
// Windows/Linux pick up the window/taskbar icon from this option in
// dev — on macOS it's ignored (dock comes from app.dock.setIcon below).
...(is.dev ? { icon: DEV_ICON_PATH } : {}),
webPreferences: {
preload: join(__dirname, "../preload/index.js"),
sandbox: false,
@@ -271,7 +251,7 @@ if (!gotTheLock) {
// so the Canary dev build is visually distinct from a stock Electron
// run. `app.dock` is macOS-only — guard the call.
if (is.dev && process.platform === "darwin" && app.dock) {
const icon = nativeImage.createFromPath(BUNDLED_ICON_PATH);
const icon = nativeImage.createFromPath(DEV_ICON_PATH);
if (!icon.isEmpty()) app.dock.setIcon(icon);
}
@@ -288,14 +268,6 @@ if (!gotTheLock) {
return openExternalSafely(url);
});
ipcMain.handle("file:download-url", (_event, url: string) => {
if (!mainWindow) {
console.warn("[download] ignored file:download-url — mainWindow torn down");
return;
}
downloadURLSafely(mainWindow, url);
});
// Sync IPC: app version + normalized OS for preload. Sync (not invoke) so
// preload can attach the values to `desktopAPI.appInfo` before any renderer
// code reads them, ensuring the very first HTTP request from the renderer

View File

@@ -69,7 +69,7 @@ describe("loadRuntimeConfig", () => {
schemaVersion: 1,
apiUrl: "https://api.example.com",
wsUrl: "wss://api.example.com/ws",
appUrl: "https://example.com",
appUrl: "https://api.example.com",
},
});
});

View File

@@ -19,9 +19,6 @@ interface DesktopAPI {
onInviteOpen: (callback: (invitationId: string) => void) => () => void;
/** Open a URL in the default browser. */
openExternal: (url: string) => Promise<void>;
/** Download a file by URL through Electron's native download system.
* Shows a native save dialog. On non-desktop platforms this is undefined. */
downloadURL: (url: string) => Promise<void>;
/** Hide macOS traffic lights for full-screen modals; restore when false. */
setImmersiveMode: (immersive: boolean) => Promise<void>;
/** Show a native OS notification for a new inbox item. */

View File

@@ -89,11 +89,6 @@ const desktopAPI = {
},
/** Open a URL in the default browser */
openExternal: (url: string) => ipcRenderer.invoke("shell:openExternal", url),
/** Download a file by URL through Electron's native download system.
* Shows a save dialog and saves to disk. Unlike openExternal, this
* avoids browser rendering of HTML files on Linux.
* On non-desktop platforms this property is undefined. */
downloadURL: (url: string) => ipcRenderer.invoke("file:download-url", url),
/** Toggle immersive mode — hide macOS traffic lights for full-screen modals */
setImmersiveMode: (immersive: boolean) =>
ipcRenderer.invoke("window:setImmersive", immersive),

View File

@@ -1,7 +1,6 @@
import { useParams } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { IssueDetail } from "@multica/views/issues/components";
import { ErrorBoundary } from "@multica/ui/components/common/error-boundary";
import { useWorkspaceId } from "@multica/core/hooks";
import { issueDetailOptions } from "@multica/core/issues/queries";
import { useDocumentTitle } from "@/hooks/use-document-title";
@@ -14,9 +13,5 @@ export function IssueDetailPage() {
useDocumentTitle(issue ? `${issue.identifier}: ${issue.title}` : "Issue");
if (!id) return null;
return (
<ErrorBoundary resetKeys={[id]}>
<IssueDetail issueId={id} />
</ErrorBoundary>
);
return <IssueDetail issueId={id} />;
}

View File

@@ -14,7 +14,6 @@ import { AgentDetailPage } from "./pages/agent-detail-page";
import { RuntimeDetailPage } from "./pages/runtime-detail-page";
import { IssuesPage } from "@multica/views/issues/components";
import { ProjectsPage } from "@multica/views/projects/components";
import { DashboardPage } from "@multica/views/dashboard";
import { AutopilotsPage } from "@multica/views/autopilots/components";
import { MyIssuesPage } from "@multica/views/my-issues";
import { SkillsPage } from "@multica/views/skills";
@@ -22,7 +21,6 @@ import { DesktopRuntimesPage } from "./components/desktop-runtimes-page";
import { AgentsPage } from "@multica/views/agents";
import { InboxPage } from "@multica/views/inbox";
import { SettingsPage } from "@multica/views/settings";
import { ErrorBoundary } from "@multica/ui/components/common/error-boundary";
import { Download, Server } from "lucide-react";
import { DaemonSettingsTab } from "./components/daemon-settings-tab";
import { UpdatesSettingsTab } from "./components/updates-settings-tab";
@@ -85,15 +83,7 @@ export const appRoutes: RouteObject[] = [
element: <WorkspaceRouteLayout />,
children: [
{ index: true, element: <Navigate to="issues" replace /> },
{
path: "issues",
element: (
<ErrorBoundary>
<IssuesPage />
</ErrorBoundary>
),
handle: { title: "Issues" },
},
{ path: "issues", element: <IssuesPage />, handle: { title: "Issues" } },
{
path: "issues/:id",
element: <IssueDetailPage />,
@@ -147,11 +137,6 @@ export const appRoutes: RouteObject[] = [
handle: { title: "Agent" },
},
{ path: "inbox", element: <InboxPage />, handle: { title: "Inbox" } },
{
path: "usage",
element: <DashboardPage />,
handle: { title: "Usage" },
},
{
path: "settings",
element: (

View File

@@ -180,61 +180,6 @@ describe("useTabStore actions", () => {
expect(s.byWorkspace.acme.tabs[0].id).not.toBe(onlyTabId); // fresh tab
});
it("defers disposing the closed tab router until after the store update", () => {
vi.useFakeTimers();
try {
const store = useTabStore.getState();
store.switchWorkspace("acme");
const closedTabId = store.addTab("/acme/settings", "Settings", "Settings");
const closingTab = useTabStore
.getState()
.byWorkspace.acme.tabs.find((t) => t.id === closedTabId);
const dispose = vi.mocked(closingTab!.router.dispose);
store.closeTab(closedTabId);
expect(dispose).not.toHaveBeenCalled();
expect(
useTabStore.getState().byWorkspace.acme.tabs.some((t) => t.id === closedTabId),
).toBe(false);
vi.runAllTimers();
expect(dispose).toHaveBeenCalledOnce();
} finally {
vi.useRealTimers();
}
});
it("ignores router-sync updates from a tab after it has been closed", () => {
const store = useTabStore.getState();
store.switchWorkspace("acme");
const closedTabId = store.addTab("/acme/settings", "Settings", "Settings");
store.closeTab(closedTabId);
const before = useTabStore.getState().byWorkspace.acme;
store.updateTab(closedTabId, { path: "/acme/runtimes", icon: "Monitor" });
store.updateTabHistory(closedTabId, 1, 2);
expect(useTabStore.getState().byWorkspace.acme).toBe(before);
expect(
useTabStore.getState().byWorkspace.acme.tabs.some((t) => t.id === closedTabId),
).toBe(false);
});
it("does not replace the tab group for no-op router-sync updates", () => {
const store = useTabStore.getState();
store.switchWorkspace("acme");
const tab = useTabStore.getState().byWorkspace.acme.tabs[0];
const before = useTabStore.getState().byWorkspace.acme;
store.updateTab(tab.id, { path: tab.path, icon: tab.icon, title: tab.title });
store.updateTabHistory(tab.id, tab.historyIndex, tab.historyLength);
expect(useTabStore.getState().byWorkspace.acme).toBe(before);
});
it("validateWorkspaceSlugs drops groups for slugs not in the valid set and repoints active", () => {
const store = useTabStore.getState();
store.switchWorkspace("acme");

View File

@@ -350,10 +350,7 @@ export const useTabStore = create<TabStore>()(
const { slug, group, index } = hit;
const closing = group.tabs[index];
const disposeClosingRouter = () => {
// Let React unmount the tab's RouterProvider before disposing it.
window.setTimeout(() => closing.router.dispose(), 0);
};
closing.router.dispose();
if (group.tabs.length === 1) {
// Last tab in this workspace — reseed a default so the workspace
@@ -366,7 +363,6 @@ export const useTabStore = create<TabStore>()(
[slug]: { tabs: [fresh], activeTabId: fresh.id },
},
});
disposeClosingRouter();
return;
}
@@ -382,7 +378,6 @@ export const useTabStore = create<TabStore>()(
[slug]: { tabs: nextTabs, activeTabId: nextActiveTabId },
},
});
disposeClosingRouter();
},
setActiveTab(tabId) {
@@ -407,13 +402,6 @@ export const useTabStore = create<TabStore>()(
const { slug, group, index } = hit;
const current = group.tabs[index];
const next: Tab = { ...current, ...patch };
if (
next.path === current.path &&
next.title === current.title &&
next.icon === current.icon
) {
return;
}
const nextTabs = [...group.tabs];
nextTabs[index] = next;
set({
@@ -430,12 +418,6 @@ export const useTabStore = create<TabStore>()(
if (!hit) return;
const { slug, group, index } = hit;
const current = group.tabs[index];
if (
current.historyIndex === historyIndex &&
current.historyLength === historyLength
) {
return;
}
const next: Tab = { ...current, historyIndex, historyLength };
const nextTabs = [...group.tabs];
nextTabs[index] = next;

View File

@@ -32,19 +32,6 @@ describe("runtime config", () => {
});
});
it("strips the leading api. label when deriving appUrl", () => {
expect(
parseRuntimeConfig(
JSON.stringify({ schemaVersion: 1, apiUrl: "https://api.multica.ai" }),
),
).toEqual({
schemaVersion: 1,
apiUrl: "https://api.multica.ai",
wsUrl: "wss://api.multica.ai/ws",
appUrl: "https://multica.ai",
});
});
it("derives ws for http api URLs", () => {
expect(deriveWsUrl("http://localhost:8080")).toBe("ws://localhost:8080/ws");
});
@@ -109,43 +96,4 @@ describe("runtime config", () => {
appUrl: "http://dev-app.example.test:3000",
});
});
it("falls back to local web URL when dev apiUrl is localhost", () => {
expect(runtimeConfigFromDevEnv({ apiUrl: "http://localhost:8080" })).toEqual({
schemaVersion: 1,
apiUrl: "http://localhost:8080",
wsUrl: "ws://localhost:8080/ws",
appUrl: "http://localhost:3000",
});
});
it("derives dev appUrl by stripping the leading api. label", () => {
// When the dev renderer is pointed at a remote backend (e.g. a test
// environment), copy-link / share URLs must reflect that environment's
// public web host, not the api host. Multica's convention exposes the
// api at `api.<web-host>`, so stripping the leading label gives the
// right web origin without a separate VITE_APP_URL.
expect(
runtimeConfigFromDevEnv({ apiUrl: "https://api.test.multica.ai" }),
).toEqual({
schemaVersion: 1,
apiUrl: "https://api.test.multica.ai",
wsUrl: "wss://api.test.multica.ai/ws",
appUrl: "https://test.multica.ai",
});
});
it("dev VITE_APP_URL still wins over apiUrl-derived value", () => {
expect(
runtimeConfigFromDevEnv({
apiUrl: "https://api.test.multica.ai",
appUrl: "https://staging.multica.ai",
}),
).toEqual({
schemaVersion: 1,
apiUrl: "https://api.test.multica.ai",
wsUrl: "wss://api.test.multica.ai/ws",
appUrl: "https://staging.multica.ai",
});
});
});

View File

@@ -44,9 +44,10 @@ export function runtimeConfigFromDevEnv(env: RuntimeConfigEnv): RuntimeConfig {
wsUrl: env.wsUrl
? normalizeWsUrl(env.wsUrl, "VITE_WS_URL")
: deriveWsUrl(apiUrl),
appUrl: env.appUrl
? normalizeHttpUrl(env.appUrl, "VITE_APP_URL")
: deriveDevAppUrl(apiUrl),
appUrl: normalizeHttpUrl(
env.appUrl || LOCAL_DEV_RUNTIME_CONFIG.appUrl,
"VITE_APP_URL",
),
};
}
@@ -93,37 +94,14 @@ export function deriveWsUrl(apiUrl: string): string {
return trimTrailingSlash(url.toString());
}
// Convention: api hosts are exposed at `api.<web-host>` (api.multica.ai →
// multica.ai, api.test.multica.ai → test.multica.ai). Strip the leading
// `api.` label so a single `apiUrl` configuration produces the right
// shareable web URL. Hosts that don't match the convention (no leading
// `api.` label, or short two-label hosts like `api.local`) fall through
// untouched — those deployments must set `appUrl` explicitly.
export function deriveAppUrl(apiUrl: string): string {
const url = new URL(apiUrl);
url.pathname = "";
url.search = "";
url.hash = "";
if (url.hostname.startsWith("api.") && url.hostname.split(".").length >= 3) {
url.hostname = url.hostname.slice("api.".length);
}
return trimTrailingSlash(url.toString());
}
// Dev variant: when the api host is the local backend (`localhost:8080` /
// `127.0.0.1:8080`), the renderer is served from a different port (3000),
// so deriving by host alone is wrong. Fall back to the local dev web URL
// in that case; for any non-local host (e.g. a remote test environment),
// trust the production-style derivation so `apiUrl=https://api.test.x`
// yields `appUrl=https://test.x` without a separate VITE_APP_URL.
export function deriveDevAppUrl(apiUrl: string): string {
const url = new URL(apiUrl);
if (url.hostname === "localhost" || url.hostname === "127.0.0.1") {
return LOCAL_DEV_RUNTIME_CONFIG.appUrl;
}
return deriveAppUrl(apiUrl);
}
function requiredString(value: unknown, field: string): string {
if (typeof value !== "string" || value.trim().length === 0) {
throw new Error(`Invalid desktop runtime config: ${field} must be a non-empty string`);

View File

@@ -9,14 +9,6 @@ import { notFound } from "next/navigation";
import defaultMdxComponents from "fumadocs-ui/mdx";
import type { Metadata } from "next";
import { docsAlternates } from "@/lib/site";
import { i18n, type Lang } from "@/lib/i18n";
import { DocsLocaleProvider, LocaleLink } from "@/components/locale-link";
function asLang(lang: string): Lang {
return (i18n.languages as readonly string[]).includes(lang)
? (lang as Lang)
: (i18n.defaultLanguage as Lang);
}
export default async function Page(props: {
params: Promise<{ lang: string; slug: string[] }>;
@@ -26,16 +18,13 @@ export default async function Page(props: {
if (!page) notFound();
const MDX = page.data.body;
const lang = asLang(params.lang);
return (
<DocsPage toc={page.data.toc}>
<DocsTitle>{page.data.title}</DocsTitle>
<DocsDescription>{page.data.description}</DocsDescription>
<DocsBody>
<DocsLocaleProvider lang={lang}>
<MDX components={{ ...defaultMdxComponents, a: LocaleLink }} />
</DocsLocaleProvider>
<MDX components={{ ...defaultMdxComponents }} />
</DocsBody>
</DocsPage>
);

View File

@@ -8,7 +8,6 @@ import { Byline, NumberedCards, NumberedCard, NumberedSteps, Step } from "@/comp
import { i18n, type Lang } from "@/lib/i18n";
import { homeCopy } from "@/lib/translations";
import { docsAlternates } from "@/lib/site";
import { DocsLocaleProvider, LocaleLink } from "@/components/locale-link";
function asLang(lang: string): Lang {
return (i18n.languages as readonly string[]).includes(lang)
@@ -53,18 +52,15 @@ export default async function Page({
/>
<Byline items={[...copy.byline]} />
<DocsBody>
<DocsLocaleProvider lang={lang}>
<MDX
components={{
...defaultMdxComponents,
a: LocaleLink,
NumberedCards,
NumberedCard,
NumberedSteps,
Step,
}}
/>
</DocsLocaleProvider>
<MDX
components={{
...defaultMdxComponents,
NumberedCards,
NumberedCard,
NumberedSteps,
Step,
}}
/>
</DocsBody>
</DocsPage>
);

View File

@@ -1,9 +1,5 @@
"use client";
import Link from "next/link";
import type { ReactNode } from "react";
import { useDocsLocale } from "@/components/locale-link";
import { prefixLocale } from "@/lib/locale-link";
/**
* Byline — editorial metadata strip with ruled top + bottom borders.
@@ -59,10 +55,9 @@ export function NumberedCard({
tag?: string;
children: ReactNode;
}) {
const lang = useDocsLocale();
return (
<Link
href={prefixLocale(href, lang)}
href={href}
className="group flex flex-col gap-2.5 border-r border-border px-0 py-5 pr-4 no-underline last:border-r-0 md:px-4 md:first:pl-0 md:last:pr-0"
>
<div className="font-mono text-[0.6875rem] uppercase tracking-[0.08em] text-muted-foreground">

View File

@@ -1,48 +0,0 @@
"use client";
import Link from "next/link";
import {
createContext,
useContext,
type AnchorHTMLAttributes,
type ReactNode,
} from "react";
import { i18n, type Lang } from "@/lib/i18n";
import { prefixLocale } from "@/lib/locale-link";
const DocsLocaleContext = createContext<Lang>(i18n.defaultLanguage as Lang);
// Wraps the rendered MDX subtree so descendant <LocaleLink>s and any
// editorial component using `useDocsLocale()` know which language the page
// was rendered in. Mounted at each docs page entry; never elsewhere.
export function DocsLocaleProvider({
lang,
children,
}: {
lang: Lang;
children: ReactNode;
}) {
return (
<DocsLocaleContext.Provider value={lang}>
{children}
</DocsLocaleContext.Provider>
);
}
export function useDocsLocale(): Lang {
return useContext(DocsLocaleContext);
}
// Drop-in replacement for the MDX-rendered `<a>` element. Keeps the same
// surface shape as the default `a` from `defaultMdxComponents` but routes
// internal links through the locale prefixer + next/link so client-side
// navigation stays inside the active locale.
export function LocaleLink({
href,
...rest
}: AnchorHTMLAttributes<HTMLAnchorElement> & { href?: string }) {
const lang = useDocsLocale();
if (!href) return <a {...rest} />;
const final = prefixLocale(href, lang);
return <Link href={final} {...rest} />;
}

View File

@@ -25,6 +25,10 @@ An autopilot has two execution modes. **Start with "create issue" mode.**
- **Create issue mode** (`create_issue`) — default, **recommended**. Each trigger first creates an issue in the workspace (the title supports interpolation like `{{date}}`), then assigns the issue to the agent through the normal assignment flow. All work lands on the issue board with the same history, comments, and status as a manually assigned issue.
- **Run-only mode** (`run_only`) — skips issue creation and enqueues a `task` directly. The run is invisible on the board — you can only see it in the autopilot's run history.
<Callout type="warning">
**Run-only mode is currently unstable.** The CLI labels it "not yet supported end-to-end," and the dispatch path has known issues. New users should stick to create issue mode and wait for run-only mode to ship a stable release before switching.
</Callout>
## Run it on a schedule
Every autopilot needs at least one `schedule` trigger. Cron uses the **standard 5-field format** (minute hour day month weekday), with **1-minute** minimum granularity (no seconds). Timezone is IANA-formatted (for example, `Asia/Shanghai`) and determines which timezone the cron expression is interpreted in.

View File

@@ -25,6 +25,10 @@ Autopilot 有两种执行模式,**建议从"先建 issue 模式"开始**
- **先建 issue 模式**`create_issue`)—— 默认,**推荐**。每次触发先在工作区里建一个 issue标题支持 `{{date}}` 这样的插值),再按分配流程把 issue 派给智能体。所有工作都落在 issue 看板上,历史、评论、状态和手动分配的 issue 完全一致。
- **直跑模式**`run_only`)—— 不建 issue直接入队一个 `task`。看板上看不到这一次运行——只能在 Autopilot 的运行历史里看到。
<Callout type="warning">
**直跑模式当前不稳定**——目前在 CLI 里被标注为"not yet supported end-to-end",派发路径有已知问题。新用户只使用先建 issue 模式,等直跑模式 ship 稳定版再切。
</Callout>
## 让它按时间跑
每个 Autopilot 至少要一个 `schedule` 触发器。Cron 是**标准 5 字段格式**(分 时 日 月 周),最小粒度 **1 分钟**(没有秒级)。时区用 IANA 格式(例如 `Asia/Shanghai`),决定 cron 表达式按哪个时区解读。

View File

@@ -40,25 +40,20 @@ For the difference between token types, see [Authentication and tokens](/auth-to
| `multica workspace list` | List every workspace you can access |
| `multica workspace get <slug>` | Show details for one workspace |
| `multica workspace members` | List members of the current workspace |
| `multica workspace update <id> --name "..." [--description "..."] [--context "..."] [--issue-prefix "..."]` | Update workspace metadata (admin/owner). Long fields accept `--description-stdin` / `--context-stdin`. |
## Issues and projects
<Callout type="info">
`list` commands (`multica issue list`, `autopilot list`, `project list`, etc.) print short, copy-paste-ready IDs by default — issue keys like `MUL-123` for issues, short UUID prefixes for the rest. The `<id>` argument on the follow-up commands below accepts either the short ID or the full UUID, so the typical flow is `multica issue list` → copy the key → `multica issue get MUL-123`. Pass `--full-id` to a list command when you need the canonical UUID.
</Callout>
| Command | Purpose |
|---|---|
| `multica issue list` | List issues (prints copy-paste-ready issue keys) |
| `multica issue get <id>` | Show a single issue (accepts an issue key or a UUID) |
| `multica issue list` | List issues |
| `multica issue get <id>` | Show a single issue |
| `multica issue create --title "..."` | Create a new issue |
| `multica issue update <id> ...` | Update an issue (status, priority, assignee, etc.) |
| `multica issue assign <id> --agent <slug>` | Assign to an agent (triggers a task immediately) |
| `multica issue status <id> --set <status>` | Shortcut to change status |
| `multica issue search <query>` | Keyword search |
| `multica issue runs <id>` | Show agent runs on an issue |
| `multica issue rerun <id>` | Re-enqueue a fresh task for the issue's current agent assignee |
| `multica issue rerun <id>` | Rerun the most recent agent task |
| `multica issue comment <id> ...` | Nested: view / post comments |
| `multica issue subscriber <id> ...` | Nested: subscribe / unsubscribe |
| `multica project list/get/create/update/delete/status` | Project CRUD |
@@ -103,6 +98,7 @@ For the difference between token types, see [Authentication and tokens](/auth-to
| `multica runtime list` | List runtimes in the current workspace |
| `multica runtime usage` | Show resource usage |
| `multica runtime activity` | Recent activity log |
| `multica runtime ping <id>` | Ping a runtime to check it's online |
| `multica runtime update <id> ...` | Update a runtime's configuration |
## Miscellaneous

View File

@@ -40,25 +40,20 @@ Token 类型的详细区分见 [认证与令牌](/auth-tokens)。
| `multica workspace list` | 列出你有权访问的所有工作区 |
| `multica workspace get <slug>` | 查看一个工作区的详情 |
| `multica workspace members` | 列出当前工作区的成员 |
| `multica workspace update <id> --name "..." [--description "..."] [--context "..."] [--issue-prefix "..."]` | 修改 workspace 元数据admin/owner 权限)。长文本可用 `--description-stdin` / `--context-stdin`。 |
## Issue 和 Project
<Callout type="info">
`list` 类命令(`multica issue list`、`autopilot list`、`project list` 等)表格里默认显示**可直接复制**的短 IDissue 是 key如 `MUL-123`),其余资源是 UUID 短前缀。下面表格里的 `<id>` 同时接受短 ID 和完整 UUID所以典型用法是 `multica issue list` → 复制 key → `multica issue get MUL-123`。需要完整 UUID 时给 `list` 加 `--full-id`。
</Callout>
| 命令 | 用途 |
|---|---|
| `multica issue list` | 列出 issue(默认显示可复制的 issue key |
| `multica issue get <id>` | 查看单条 issue(接受 issue key 或 UUID |
| `multica issue list` | 列出 issue |
| `multica issue get <id>` | 查看单条 issue |
| `multica issue create --title "..."` | 创建新 issue |
| `multica issue update <id> ...` | 修改 issue状态、优先级、分配人等 |
| `multica issue assign <id> --agent <slug>` | 分配给智能体(立即触发任务) |
| `multica issue status <id> --set <status>` | 快捷改状态 |
| `multica issue search <query>` | 关键字搜索 |
| `multica issue runs <id>` | 查看 issue 上智能体跑过的任务 |
| `multica issue rerun <id>` | 给该 issue 当前的智能体分配人重新创建一条任务 |
| `multica issue rerun <id>` | 重跑最近一次智能体任务 |
| `multica issue comment <id> ...` | 嵌套:看 / 发评论 |
| `multica issue subscriber <id> ...` | 嵌套:订阅 / 取消订阅 |
| `multica project list/get/create/update/delete/status` | Project CRUD |
@@ -103,6 +98,7 @@ Token 类型的详细区分见 [认证与令牌](/auth-tokens)。
| `multica runtime list` | 列出当前工作区的 runtime |
| `multica runtime usage` | 查看资源使用情况 |
| `multica runtime activity` | 近期活动记录 |
| `multica runtime ping <id>` | 立即戳一次 runtime 检查在线 |
| `multica runtime update <id> ...` | 更新 runtime 配置 |
## 杂项

View File

@@ -213,28 +213,6 @@ multica workspace get <workspace-id> --output json
multica workspace members <workspace-id>
```
### Update Workspace
需要 admin 或 owner 权限。所有字段都是部分更新PATCH 语义):未传的字段保持不变。
```bash
multica workspace update <workspace-id> --name "Acme Eng"
multica workspace update <workspace-id> \
--description "Engineering team workspace" \
--issue-prefix ENG
```
长文本走 stdin保留换行/反斜杠):
```bash
cat <<'CTX' | multica workspace update <workspace-id> --context-stdin
我们是一支 5 人 AI-native 团队。
工作语言:中文 + 英文混合。
CTX
```
可编辑字段:`--name`、`--description` / `--description-stdin`、`--context` / `--context-stdin`、`--issue-prefix`。`slug` 创建后只读,不暴露在 CLI。`--description` 与 `--description-stdin`(以及 `context` 同名对)互斥。未传任何字段 flag 时命令拒绝执行,避免空 PATCH 触发无意义的 workspace 更新事件。`--issue-prefix ""` 也会被拒绝:当前后端在 prefix 为空时静默跳过该字段CLI 在本地拦下避免“看似成功的 no-op”。
## Issues
### List Issues
@@ -244,22 +222,18 @@ multica issue list
multica issue list --status in_progress
multica issue list --priority urgent --assignee "Agent Name"
multica issue list --assignee-id 5fb87ac7-23b5-4a7a-81fa-ed295a54545d
multica issue list --full-id
multica issue list --limit 20 --output json
```
表格输出默认显示可直接复制到后续命令的 issue `KEY`(例如 `MUL-123`);需要完整 UUID 时使用 `--full-id`。Available filters: `--status`, `--priority`, `--assignee` / `--assignee-id`, `--project`, `--limit`. 在重名 workspace 下用 `--assignee-id <uuid>` 可以精确锁定一个成员或 agent。
Available filters: `--status`, `--priority`, `--assignee` / `--assignee-id`, `--project`, `--limit`. 在重名 workspace 下用 `--assignee-id <uuid>` 可以精确锁定一个成员或 agent。
### Get Issue
```bash
multica issue get MUL-123
multica issue get <uuid>
multica issue get <id>
multica issue get <id> --output json
```
`<id>` 同时接受 issue key`multica issue list` 表格里直接显示,例如 `MUL-123`)和完整 UUID给 `list` 加 `--full-id` 可显示)。同样的规则适用于下面 `update` / `assign` / `status` / `comment` / `subscriber` / `runs` 等接受 `<id>` 的命令。
### Create Issue
```bash
@@ -314,20 +288,16 @@ multica issue comment delete <comment-id>
```bash
# List all execution runs for an issue
multica issue runs <issue-id>
multica issue runs <issue-id> --full-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 <short-task-id> --issue <issue-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
```
`runs` 的表格输出默认显示 task UUID 短前缀;需要完整 task UUID 时使用 `--full-id`。`run-messages` 可直接接受完整 task UUID从 `runs` 表格复制短前缀时需要同时传 `--issue <issue-id>`CLI 只会在该 issue 的 runs 内解析。
## Projects
Projects group related issues (e.g. a sprint, an epic, a workstream). Every project

View File

@@ -37,7 +37,7 @@ Common commands:
Full CLI reference in [CLI commands](/cli).
**The desktop app ships with a daemon.** If you use the [desktop app](/desktop-app), you don't need to run `multica daemon start` manually — it launches the daemon automatically on startup. See the [Desktop app](/desktop-app) page for which option fits your workflow.
**The desktop app ships with a daemon.** If you use the [desktop app](/desktop-app), you don't need to run `multica daemon start` manually — it launches the daemon automatically on startup.
## Why one machine has multiple runtimes

View File

@@ -37,7 +37,7 @@ multica daemon start
完整 CLI 参考见 [CLI 命令速查](/cli)。
**桌面应用自带守护进程。**用 [桌面应用](/desktop-app) 就不必手动 `multica daemon start`——它启动时会自动拉起守护进程。哪种方式更适合你的工作流,详见 [桌面应用](/desktop-app) 页面。
**桌面应用自带守护进程。**用 [桌面应用](/desktop-app) 就不必手动 `multica daemon start`——它启动时会自动拉起守护进程。
## 为什么一台机器会有多个运行时

View File

@@ -95,26 +95,17 @@ Multica's product nouns split into two categories:
This rule is aligned with `apps/docs/content/docs/*.zh.mdx` — the docs are the de facto Chinese voice standard and have been battle-tested across 20+ pages.
### Entities — mixed rule (`issue` / `skill` / `task`)
### Don't translate — entities (lowercase English)
`issue` / `skill` / `task` are Multica's core entities. They have schema columns, API fields, and product UI labels that are all English. In Chinese text, they follow a **mixed rule** — what to use depends on where the word appears:
| Context | Render | Example |
| Term | Render in Chinese | Example |
| --- | --- | --- |
| **UI strings, state names, code references** | lowercase English | "排队中的 task"、"创建子 issue"、"为智能体注入 skill" |
| **Doc titles / section headings** | Title-case English **or** the Chinese term | "Issue 与 project"、"Skills"、"执行任务" |
| **Long-form doc prose, when the entity is the running subject** | Chinese term, with English in parentheses on first mention | "**执行任务**task是智能体每一次工作的单位" |
| **API / DB fields** | always `task` / `issue` / `skill` | `task_id`, `issue_status`, `skill_uuid` |
| Issue | `issue` (lowercase) | "把 issue 分配给智能体"、"创建子 issue" |
| Skill | `skill` (lowercase) | "为智能体注入 skill" |
| Task | `task` (lowercase) | "排队中的 task" |
Chinese term reference:
**Why `issue` / `skill` / `task` stay English while `project` / `autopilot` are translated**:
- `task` ↔ `执行任务` (or shortened to `任务` once context is clear)
- `issue` has no settled Chinese translation — leave English; titles may capitalize as `Issue`
- `skill` has no settled Chinese translation — leave English; titles may capitalize as `Skills`
**Why `issue` / `skill` / `task` aren't forced into Chinese the way `project` / `autopilot` are**:
- **`issue` / `task`**: dev teams talk in English. The Chinese candidates ("任务" — too vague, almost synonymous with "工作"; "工单" — IT ticket connotation; "议题" — GitHub-style but doesn't match the product feel) all read worse than `issue`. **But** in long-form doc prose, repeating lowercase `task` 50× breaks the rhythm — so prose is allowed to use `执行任务`, while UI strings and state names stay lowercase English.
- **`issue` / `task`**: dev teams talk in English. The Chinese candidates ("任务" — too vague, almost synonymous with "工作"; "工单" — IT ticket connotation; "议题" — GitHub-style but doesn't match the product feel) all read worse than `issue`.
- **`skill`**: Multica-specific concept with no established Chinese term.
- **`project` → "项目"**: settled mainstream Chinese word. Feishu / Tower / Teambition / PingCode / GitHub Projects — every Chinese product translates it. No product keeps `project` in Chinese context.
- **`autopilot` → "自动化"**: in Chinese, "autopilot" associates with Tesla's "自动驾驶" and doesn't match what the feature does (run tasks on a schedule). Notion and Feishu both use "自动化"; that's the industry consensus.
@@ -251,7 +242,7 @@ Examples:
- `issues.toolbar.batch_update_success`
- `issues.detail.comment_form.placeholder`
- `inbox.empty.title`
- `settings.preferences.language.title`
- `settings.appearance.language.title`
### Web-only / desktop-only copy
@@ -287,7 +278,7 @@ When the glossary doesn't cover a term, look at:
1. `apps/docs/content/docs/*.zh.mdx` — the de facto Chinese voice standard, 20+ pages of consistent translation
2. `packages/views/locales/zh-Hans/auth.json` and `editor.json` — JSON structure + selector API patterns
3. `packages/views/auth/login-page.tsx` — component-level selector API call site
4. `packages/views/settings/components/preferences-tab.tsx` — language switcher reference
4. `packages/views/settings/components/appearance-tab.tsx` — language switcher reference
---

View File

@@ -95,29 +95,20 @@ Multica 的产品名词分两类:
这套规则与 `apps/docs/content/docs/*.zh.mdx` 完全对齐 —— docs 是已经实战 20+ 篇的 CN voice 标准。
### 实体词的混合规则(`issue` / `skill` / `task`
### 不翻 —— 实体(小写英文
`issue` / `skill` / `task` 是 Multica 的核心实体。schema 字段、API 字段、产品 UI 标签都用英文。中文里采用**混合规则** —— 词出现在哪里决定怎么写:
| 场景 | 写法 | 例 |
| 词 | 中文中的写法 | 例 |
| --- | --- | --- |
| **UI 短句 / 状态名 / 代码上下文** | 小写英文 | "排队中的 task"、"创建子 issue"、"为智能体注入 skill" |
| **doc 标题 / 章节标题** | 首字母大写英文,**或**对应中文术语 | "Issue 与 project"、"Skills"、"执行任务" |
| **doc 正文长篇讨论中作为主语** | 中文术语,首次出现配括号英文 | "**执行任务**task是智能体每一次工作的单位" |
| **API / DB 字段** | 永远 `task` / `issue` / `skill` | `task_id`、`issue_status`、`skill_uuid` |
| Issue | `issue`(小写) | "把 issue 分配给智能体"、"创建子 issue" |
| Skill | `skill`(小写) | "为智能体注入 skill" |
| Task | `task`(小写) | "排队中的 task" |
中文术语对照
**为什么 `issue` / `skill` / `task` 不翻而 `project` / `autopilot` 翻**
- `task` ↔ `执行任务`(上下文清楚后可简写为「任务」)
- `issue` 没有公认中文译法 —— 保留英文;标题可大写为 `Issue`
- `skill` 没有公认中文译法 —— 保留英文;标题可大写为 `Skills`
**为什么 `issue` / `skill` / `task` 不强制译,而 `project` / `autopilot` 必译**
- **`issue` / `task`**dev 团队习惯说英文,"任务"在中文里和"工作"几乎同义太空泛,"工单"是 IT 工单语义,"议题"是 GitHub 风格但用户场景不匹配 —— 三个候选都不如 `issue` 准确。**但**在长篇 doc 正文里,重复 50 次 `task` 节奏不顺,所以正文允许用 `执行任务`UI 短句、状态名仍保持小写英文。
- **`issue` / `task`**dev 团队习惯说英文,"任务"在中文里和"工作"几乎同义太空泛,"工单"是 IT 工单语义,"议题"是 GitHub 风格但用户场景不匹配。三个候选都不如 `issue` 准确。
- **`skill`**Multica 特有概念,没有公认中文译法。
- **`project` 翻成项目**:中文里早就稳定的日常词。飞书 / Tower / Teambition / PingCode / GitHub Projects 中文版 0 例外都翻译成项目,没有产品保留 `project`。
- **`autopilot` 翻成自动化**autopilot 在中文里联想到特斯拉的自动驾驶,跟产品功能(按周期跑 task对应不上。Notion / 飞书都用自动化,是行业共识。
- **`project` 翻成"项目"**这是中文里早就稳定的日常词。飞书 / Tower / Teambition / PingCode / GitHub Projects 中文版 0 例外都翻译成"项目",没有产品保留 `project`。
- **`autopilot` 翻成"自动化"**autopilot 在中文里联想到特斯拉的"自动驾驶",跟产品功能(按周期跑 task对应不上。Notion / 飞书都用"自动化",是行业共识。
### 完整翻译 —— 概念词
@@ -251,7 +242,7 @@ i18next 用 `_one` / `_other`;中文不区分语法单复数,只填 `_other`
- `issues.toolbar.batch_update_success`
- `issues.detail.comment_form.placeholder`
- `inbox.empty.title`
- `settings.preferences.language.title`
- `settings.appearance.language.title`
### Web-only / Desktop-only 文案位置
@@ -287,7 +278,7 @@ i18next 用 `_one` / `_other`;中文不区分语法单复数,只填 `_other`
1. `apps/docs/content/docs/*.zh.mdx` —— CN voice 事实标准20+ 篇高度一致
2. `packages/views/locales/zh-Hans/auth.json` 和 `editor.json` —— JSON 结构 + selector API 用法参考
3. `packages/views/auth/login-page.tsx` —— 组件层 selector API 调用参考
4. `packages/views/settings/components/preferences-tab.tsx` —— 语言切换器参考
4. `packages/views/settings/components/appearance-tab.tsx` —— 语言切换器参考
---

View File

@@ -141,22 +141,6 @@ For a full explanation of how each parameter affects daemon behavior, see [Daemo
**Leaving `FRONTEND_ORIGIN` unset creates two silent failures**: (1) invite email links point at `https://app.multica.ai` (the hosted domain), and clicking them doesn't bring users back to your self-hosted instance; (2) WebSocket Origin checks fall back to `localhost:3000 / 5173 / 5174`, so every WebSocket connection in a production deployment is rejected and the frontend appears to "lose real-time updates."
</Callout>
## GitHub integration
The [GitHub PR ↔ issue integration](/github-integration) needs two variables. Set both to enable Connect GitHub in Settings and accept incoming webhooks.
| Variable | Default | Description |
|---|---|---|
| `GITHUB_APP_SLUG` | empty | The slug of your GitHub App (the tail of `https://github.com/apps/<slug>`). Drives the Settings → Integrations install button URL |
| `GITHUB_WEBHOOK_SECRET` | empty | The Webhook secret you set on the GitHub App. Used for HMAC-SHA256 verification of every `pull_request` / `installation` delivery, and as the HMAC key for the setup-callback state token |
**Behavior when either is unset:**
- `Connect GitHub` in Settings → Integrations is **disabled** and shows a "not configured" hint to admins.
- The `/api/webhooks/github` endpoint returns **`503 github webhooks not configured`** — Multica refuses to process events with no secret rather than treating every signature as valid.
**Note:** `GITHUB_WEBHOOK_SECRET` is reused as the signing key for the install-flow state token, so operators only need to manage one secret. It is **not** the GitHub App's *Client* secret — Client secrets are OAuth-related and not used by this integration. See [GitHub integration → Self-host setup](/github-integration#self-host-setup) for the full walkthrough.
## Usage analytics
By default, the server reports to Multica's official PostHog instance. To opt out, set `ANALYTICS_DISABLED=true`.
@@ -170,6 +154,5 @@ By default, the server reports to Multica's official PostHog instance. To opt ou
## Next
- [Sign-in and signup configuration](/auth-setup) — how to actually configure the auth-related variables above and where the traps are
- [GitHub integration](/github-integration) — how to set up the GitHub App that backs `GITHUB_APP_SLUG` / `GITHUB_WEBHOOK_SECRET`
- [Troubleshooting](/troubleshooting) — symptoms and fixes for common misconfigurations
- [Daemon and runtimes](/daemon-runtimes) — what the `MULTICA_DAEMON_*` parameters actually do

View File

@@ -141,22 +141,6 @@ Multica 存储用户上传的附件(评论里的图片、文件等)。**优
**`FRONTEND_ORIGIN` 不设就有两个静默失败**1邀请邮件里的链接指向 `https://app.multica.ai`(托管版的域名),用户点了跳不回你的 self-host 实例2WebSocket 连接的 Origin 校验回落到 `localhost:3000 / 5173 / 5174`,生产部署的 WebSocket 全部被拒,前端看起来「实时更新不工作」。
</Callout>
## GitHub 集成
[GitHub PR ↔ issue 集成](/github-integration) 依赖两个环境变量。两个都配上才会启用 Settings 里的 Connect GitHub 并接受 webhook。
| 环境变量 | 默认值 | 说明 |
|---|---|---|
| `GITHUB_APP_SLUG` | 空 | 你的 GitHub App slug`https://github.com/apps/<slug>` 的尾部。Settings → Integrations 里安装按钮的跳转 URL 用它拼 |
| `GITHUB_WEBHOOK_SECRET` | 空 | 你在 GitHub App 上设置的 Webhook secret。每条 `pull_request` / `installation` delivery 都用它做 HMAC-SHA256 校验;同一个值也用作 setup 回调里 state token 的签名密钥 |
**任一变量未设时:**
- Settings → Integrations 里 `Connect GitHub` 按钮 **disable**,对 admin 显示「not configured」提示
- `/api/webhooks/github` 直接返回 **`503 github webhooks not configured`**——secret 没配置时 Multica 拒绝处理任何 webhook 事件,而不是把所有签名当 valid
**注意:** `GITHUB_WEBHOOK_SECRET` 同时被复用为 install 流程里 state token 的签名密钥,所以运维只需要维护一个 secret。它**不是** GitHub App 的 *Client* secret——Client secret 是 OAuth 用的,和本集成无关。完整配置流程见 [GitHub 集成 → Self-Host 配置](/github-integration#self-host-配置)。
## 用量统计
默认上报到 Multica 官方 PostHog 实例。不想上报就把 `ANALYTICS_DISABLED=true`。
@@ -170,6 +154,5 @@ Multica 存储用户上传的附件(评论里的图片、文件等)。**优
## 下一步
- [登录与注册配置](/auth-setup) —— 上面 auth 相关的那几个环境变量怎么真的配、陷阱在哪
- [GitHub 集成](/github-integration) —— `GITHUB_APP_SLUG` / `GITHUB_WEBHOOK_SECRET` 背后的 GitHub App 怎么建
- [故障排查](/troubleshooting) —— 配错了常见的症状和修复
- [守护进程与运行时](/daemon-runtimes) —— `MULTICA_DAEMON_*` 参数的行为含义

View File

@@ -337,47 +337,16 @@ In production, put a reverse proxy in front of both the backend and frontend to
### Caddy (Recommended)
**Single-domain layout** — frontend and backend served on the same hostname (this is what `docker-compose.selfhost.yml` defaults to):
```
multica.example.com {
# WebSocket route — must come before the catch-all
@multica_ws path /ws /ws/*
handle @multica_ws {
reverse_proxy localhost:8080 {
flush_interval -1
}
}
# Everything else → frontend
reverse_proxy localhost:3000
}
```
**Separate-domain layout** — frontend and backend on different hostnames:
```
app.example.com {
reverse_proxy localhost:3000
}
api.example.com {
@multica_ws path /ws /ws/*
handle @multica_ws {
reverse_proxy localhost:8080 {
flush_interval -1
}
}
reverse_proxy localhost:8080
}
```
Two non-obvious bits inside the `/ws` block are worth calling out — both are common reasons real-time updates "stop working" on a Caddy-fronted self-host:
- **`path /ws /ws/*` (not `/ws*`)** — bare `handle /ws` is an exact match, so future path variants under `/ws/` fall through to the frontend block. The obvious shortcut `handle /ws*` overcorrects in the other direction: Caddy's `*` is a glob without a path-segment boundary, so it would also catch unrelated paths like `/ws-foo`, which is a legitimate workspace URL (only the exact slug `ws` is reserved). Listing `/ws` and `/ws/*` explicitly covers both real cases without overreach.
- **`flush_interval -1`** — disables response buffering so WebSocket frames are forwarded as soon as they arrive. Without it, frames can sit behind Caddy's default flush window, which looks like delayed comments, missing typing indicators, or "comments only appear after a page refresh."
### Nginx
```nginx

View File

@@ -1,183 +0,0 @@
---
title: GitHub integration
description: Connect a GitHub App once, then PRs whose branch, title, or body reference an issue identifier auto-attach to that issue — and merging the PR moves the issue to Done.
---
import { Callout } from "fumadocs-ui/components/callout";
Connect a GitHub account or organization once in **Settings → Integrations**. After that, any pull request whose branch name, title, or body contains an issue identifier (for example `MUL-123`) is **auto-linked** to that [issue](/issues), appears under **Pull requests** in the issue sidebar, and — when the PR is merged — moves the issue to **Done**.
There is no per-issue setup. The whole flow is identifier-driven.
## What the integration does
| Surface | Behavior |
|---|---|
| **Settings → Integrations** | Workspace admins see a GitHub card with a **Connect GitHub** button. Clicking it opens GitHub's App install page; after install you bounce back to Settings. |
| **Issue sidebar → Pull requests** | Every PR auto-linked to this issue, with title, repo, state (`Open` / `Draft` / `Merged` / `Closed`), and author. Click a row to jump to the PR on GitHub. |
| **Webhook (background)** | On every `pull_request` event, Multica upserts the PR row, scans the PR for issue identifiers, and (re)builds the link rows. Idempotent — replaying a delivery is a no-op. |
| **Auto-status on merge** | When a PR transitions to `merged`, every linked issue not already `Done` or `Cancelled` is moved to `Done`. The status change is timeline-logged with source `github_pr_merged`. |
Only the PR itself is mirrored. Commits, branch refs without an open PR, and CI check states are **not** modeled. The integration is intentionally narrow.
## How identifiers are matched
The webhook extracts identifiers from three fields, in this order: **PR head branch**, **PR title**, **PR body**. The matcher is:
- Case-insensitive — `mul-123`, `MUL-123`, `Mul-123` all match.
- Bounded — a `\b` on the left and a digit anchor on the right keep it from grabbing version numbers like `v1.2-3` or email-style strings.
- Workspace-scoped — only matches the workspace's own [issue prefix](/workspaces). `FOO-1` in a workspace whose prefix is `MUL` is ignored, even if the integer matches another issue.
- Deduplicated — listing `MUL-1, MUL-1` in the body links the issue once.
You can reference **multiple issues** in one PR. `Closes MUL-1, MUL-2` links the PR to both, and merging it advances both to `Done`.
## The auto-merge-to-Done rule
When a PR's `merged` field flips to `true`, every linked issue is evaluated:
| Issue current status | Result |
|---|---|
| `done` | No change (already terminal). |
| `cancelled` | **No change** — cancelled means the user explicitly abandoned the work; the integration does not override that signal. |
| Anything else (`todo`, `in_progress`, `in_review`, `blocked`, `backlog`) | Moved to `done`. |
Closing a PR **without** merging it only updates the PR card's state to `Closed`. The linked issues stay where they were — the user is the one who decides what closing-without-merge means.
<Callout type="info">
The action is attributed to the `system` actor on the timeline. Subscribers of the issue receive an inbox notification for the status change, the same way they would if a human had moved it.
</Callout>
## What's not auto-linked
- **Identifiers in commit messages** — only branch / title / body are scanned. A commit titled `MUL-123: fix login` does not auto-link unless the same string also appears in the PR title or body.
- **Identifiers in PR comments** — only the PR's own metadata is scanned; later GitHub comments are ignored.
- **PRs in repos the App isn't installed on** — without the App, Multica never receives the webhook.
- **Manually linking a PR to an issue** — there is no UI for this yet. If your team's convention puts identifiers in a place Multica isn't reading, add them to the PR title or body.
## Disconnecting
In **Settings → Integrations** there is no installation list — you manage existing installations from GitHub directly:
- **From GitHub** — uninstall the Multica GitHub App at `https://github.com/settings/installations` (personal) or `https://github.com/organizations/<org>/settings/installations` (org). Multica receives the `installation.deleted` webhook and drops the row in real time; any open Settings tab updates without a refresh.
- **Disconnect from inside Multica is admin-only** — the Settings card is hidden for non-admins.
After disconnect, mirrored PR rows stay in the database so historical issue sidebars still show what was linked, but no new webhook events from that installation will be accepted.
## Permissions and visibility
- **Connect / disconnect** require workspace **owner or admin**. Members see the card description but no Connect button.
- The **Pull requests** sidebar on an issue is visible to anyone who can read the issue — same permissions as the rest of issue detail.
- The GitHub App requests **read-only** access to pull requests and metadata. Multica never pushes commits, comments, or status checks back to GitHub.
## Self-host setup
If you're running Multica on Multica Cloud, the integration is already configured — skip this section.
For self-host, you create one GitHub App, point it at your server, and set two environment variables. The whole flow is below.
### 1. Create a GitHub App
Go to one of:
- Personal account → `https://github.com/settings/apps/new`
- Organization → `https://github.com/organizations/<org>/settings/apps/new`
Fill in:
| Field | Value |
|---|---|
| **GitHub App name** | Anything recognizable, e.g. `Multica` or `Multica (staging)`. |
| **Homepage URL** | Your Multica frontend, e.g. `https://multica.example.com`. |
| **Callback URL** | Leave blank — Multica doesn't use OAuth user identity. |
| **Setup URL** | `https://<api-host>/api/github/setup`. **Check "Redirect on update"**. |
| **Webhook → Active** | Enabled. |
| **Webhook URL** | `https://<api-host>/api/webhooks/github`. |
| **Webhook secret** | Generate a long random string (e.g. `openssl rand -hex 32`). You'll paste the same value into Multica's env in step 2. |
| **Permissions → Repository → Pull requests** | **Read-only**. |
| **Permissions → Repository → Metadata** | Read-only (mandatory). |
| **Subscribe to events** | Tick **Pull request**. |
| **Where can this GitHub App be installed?** | Your choice. `Only on this account` is fine for single-org setups. |
After **Create GitHub App**, note two things from the App's detail page:
- The **public link** at the top — its tail is the slug. `https://github.com/apps/multica-acme` → slug = `multica-acme`.
- The **webhook secret** you just generated (you can't read it back from GitHub later — save it now).
<Callout type="warning">
**Webhook secret ≠ Client secret.** The App settings page has both fields stacked together. The **Webhook secret** is what signs `pull_request` payloads — that's the one Multica needs. The **Client secret** is for OAuth and is not used by this integration. Mixing them up produces a confusing `401 invalid signature` on every webhook delivery.
</Callout>
### 2. Set environment variables
On the API server:
```dotenv
GITHUB_APP_SLUG=multica-acme
GITHUB_WEBHOOK_SECRET=<the webhook secret you generated>
```
Both variables are required. If either is missing:
- `Connect GitHub` in Settings is **disabled** and shows a "not configured" hint.
- The `/api/webhooks/github` endpoint returns **`503 github webhooks not configured`** — Multica refuses to process events with no secret, rather than silently treating every signature as valid.
`FRONTEND_ORIGIN` must also be set (it already is for any production self-host); the setup callback bounces the user back to `<FRONTEND_ORIGIN>/settings` after install.
Restart the API after setting the env vars.
### 3. Run migrations
The integration ships its tables in migration `079_github_integration`. If you're upgrading an older deployment:
```bash
make migrate-up
```
Three tables get created: `github_installation`, `github_pull_request`, `issue_pull_request`. They cascade-delete with their workspace, so removing a workspace cleans them up automatically.
### 4. Connect from the UI
In Multica:
1. Open **Settings → Integrations** as an owner or admin.
2. Click **Connect GitHub**. GitHub opens in a new tab.
3. Pick the repositories to grant access to and **Install**.
4. GitHub redirects back to `<api-host>/api/github/setup`, which records the installation and bounces you to `<FRONTEND_ORIGIN>/settings?github_connected=1`.
After that, open any PR whose branch / title / body contains an issue identifier — within a few seconds the Pull requests block appears on that issue's detail page.
### 5. Verify with a curl probe
If GitHub's **Recent Deliveries** page reports `401 invalid signature` after install, the two sides have different secrets. The fastest way to find out which side is wrong is to bypass GitHub:
```bash
SECRET="<the value you put in GITHUB_WEBHOOK_SECRET>"
BODY='{"zen":"test"}'
SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -hex | awk '{print $NF}')
curl -i -X POST https://<api-host>/api/webhooks/github \
-H "X-Hub-Signature-256: sha256=$SIG" \
-H "X-GitHub-Event: ping" \
-H "Content-Type: application/json" \
-d "$BODY"
```
| HTTP status | Meaning | Fix |
|---|---|---|
| `200` `{"ok":"pong"}` | Server's loaded secret matches your `$SECRET`. The mismatch is on GitHub. | Edit the App → Webhook secret → **paste the same value** → **Save changes** (clicking out of the field without Save keeps the old secret). Redeliver. |
| `401 invalid signature` | Server's loaded secret is **not** what you think it is. | Confirm the env var landed in the running process (e.g. `kubectl exec` → `echo -n "$GITHUB_WEBHOOK_SECRET" | wc -c`). Re-deploy. |
| `503 github webhooks not configured` | `GITHUB_WEBHOOK_SECRET` is empty in the process. | Set the env var, restart the API. |
## Limitations
A few rough edges to be aware of today:
- **No manual link UI yet** — the only way to link a PR is to have the identifier in its branch, title, or body.
- **No CI / check state** — only the PR itself is mirrored. Build status, review comments, and reviewers are not surfaced in Multica.
- **No workspace-level config** for the merge → Done rule — it's a fixed default (`merged → done`, unless `cancelled`). Workspace-customizable mappings are a future addition.
- **Multi-PR-to-one-issue is conservative on merge** — if two PRs both reference `MUL-123` and the first one merges, the issue is moved to `Done` immediately. A follow-up change to wait for all linked PRs to resolve before advancing is in progress.
## Next
- [Issues](/issues) — the issue identifiers (`MUL-123`) referenced from PRs
- [Workspaces](/workspaces) — where the workspace-specific issue prefix is set
- [Environment variables](/environment-variables) — full env reference, including the GitHub variables above

View File

@@ -1,183 +0,0 @@
---
title: GitHub 集成
description: 一次性连接 GitHub App之后 PR 的分支名、标题或正文里写了 issue 编号(例如 MUL-123就会自动挂到那个 issue 上——PR 合并时 issue 自动转 Done。
---
import { Callout } from "fumadocs-ui/components/callout";
在 **Settings → Integrations** 里一次性连一个 GitHub 账号或组织。之后任何 PR 只要分支名、标题或正文里出现 issue 编号(例如 `MUL-123`),就会**自动关联**到那个 [issue](/issues),出现在 issue 详情页右侧的 **Pull requests** 区块里——PR 合并时issue 自动转 **Done**。
没有 per-issue 的配置,整个流程是「编号驱动」的。
## 集成做了什么
| 出现位置 | 行为 |
|---|---|
| **Settings → Integrations** | 工作区 owner / admin 看到一个 GitHub 卡片,里面有 **Connect GitHub** 按钮。点击会打开 GitHub 的 App 安装页;装好后跳回 Settings。 |
| **Issue 详情侧栏 → Pull requests** | 列出所有自动关联到该 issue 的 PR含标题、仓库、状态`Open` / `Draft` / `Merged` / `Closed`)和作者。点一行跳到 GitHub。 |
| **Webhook后台** | 每次 `pull_request` 事件触发upsert PR 行 → 扫描里面的 issue 编号 →(重新)建立 link。幂等——重投 delivery 不会产生重复记录。 |
| **Merge 自动改 status** | PR 转 `merged` 时,所有已关联且状态不是 `Done` / `Cancelled` 的 issue 会被推到 `Done`。时间线里以 source 为 `github_pr_merged` 记录。 |
只镜像 PR 本身。Commit、没开 PR 的分支、CI 检查状态都**不**入库——集成有意保持窄边界。
## 编号是怎么匹配的
Webhook 从三个字段抽取编号,顺序是:**PR head 分支** → **PR 标题** → **PR 正文**。匹配规则:
- 大小写不敏感——`mul-123`、`MUL-123`、`Mul-123` 都能匹配
- 有边界——左侧 `\b`、右侧只接数字,避免误抓 `v1.2-3`、email 地址等
- 限定到本工作区——只匹配本工作区的 [issue prefix](/workspaces)。前缀是 `MUL` 的工作区里PR 出现 `FOO-1` 不会匹配,即使数字撞另一个 issue 也不会
- 自动去重——`Closes MUL-1, MUL-1` 只关联一次
一个 PR 里**可以同时引用多个 issue**。比如 `Closes MUL-1, MUL-2`PR 同时关联两个 issue合并时两个 issue 都会转 `Done`。
## Merge 自动转 Done 的规则
PR 的 `merged` 字段翻成 `true` 时,逐个评估关联的 issue
| Issue 当前状态 | 结果 |
|---|---|
| `done` | 不变(已经是终态)|
| `cancelled` | **不变**——cancelled 是用户明确放弃工作的信号,集成不覆盖 |
| 其他(`todo` / `in_progress` / `in_review` / `blocked` / `backlog`| 转成 `done` |
PR **关闭但没合并**——只更新 PR 卡片的状态为 `Closed`issue 状态不变。"关闭但不合并"语义因团队而异Multica 不替用户做决定。
<Callout type="info">
状态变更的 actor 是 `system`。订阅了该 issue 的成员会收到 inbox 通知,和成员手动改状态时一致。
</Callout>
## 哪些情况不会自动关联
- **Commit message 里的编号**——只扫 PR 的分支 / 标题 / 正文。一个 commit message 写 `MUL-123: fix login` 不会触发关联,除非同样的字符串也出现在 PR 标题或正文里
- **PR 评论里的编号**——只扫 PR 自己的元数据,后续的 GitHub comment 不读
- **App 没安装的仓库里的 PR**——没 AppMultica 收不到 webhook
- **手动把 PR 关联到 issue**——暂时没有这个 UI。如果你们的约定把编号放到 Multica 不扫的地方,请改放到 PR 标题或正文里
## 断开连接
**Settings → Integrations** 里没有 installation 列表——现有 installation 直接到 GitHub 上管理:
- **从 GitHub 卸载** —— 个人在 `https://github.com/settings/installations`、组织在 `https://github.com/organizations/<org>/settings/installations` 卸载 Multica App。Multica 收到 `installation.deleted` webhook 后立刻删行;任何已打开的 Settings tab 实时更新,不用刷新
- **Multica 这边的断开是 admin only** —— 卡片对非 admin 不显示连接操作
断开之后,已经镜像的 PR 行保留在数据库里——历史 issue 侧栏仍能显示当时关联的 PR但来自这个 installation 的新 webhook 事件不再被接受。
## 权限和可见性
- **Connect / Disconnect** 需要工作区 **owner 或 admin**。普通成员能看到卡片描述但看不到 Connect 按钮
- **Pull requests** 侧栏对所有能看到该 issue 的成员可见——和 issue 详情页其他部分权限一致
- GitHub App 申请的是 PR 和 Metadata 的 **只读** 权限。Multica 从不向 GitHub 推 commit、评论或 status check
## Self-Host 配置
如果你在 Multica Cloud 上,集成已经配好——跳过本节。
Self-Host 需要:建一个 GitHub App、指向你的 server、设两个环境变量。完整流程如下。
### 1. 创建一个 GitHub App
到下面其中一个页面:
- 个人账号 → `https://github.com/settings/apps/new`
- 组织 → `https://github.com/organizations/<org>/settings/apps/new`
按下表填写:
| 字段 | 值 |
|---|---|
| **GitHub App name** | 任何能辨识的名字,例如 `Multica` 或 `Multica (staging)` |
| **Homepage URL** | 你的 Multica 前端,例如 `https://multica.example.com` |
| **Callback URL** | 留空——本集成不使用 OAuth 用户身份 |
| **Setup URL** | `https://<api-host>/api/github/setup`。**勾选 "Redirect on update"** |
| **Webhook → Active** | 启用 |
| **Webhook URL** | `https://<api-host>/api/webhooks/github` |
| **Webhook secret** | 生成一个长随机字符串(例如 `openssl rand -hex 32`)。这个值会同样填到 step 2 的 env 里 |
| **Permissions → Repository → Pull requests** | **Read-only** |
| **Permissions → Repository → Metadata** | Read-only必填|
| **Subscribe to events** | 勾选 **Pull request** |
| **Where can this GitHub App be installed?** | 自选。单组织部署建议选 `Only on this account` |
点 **Create GitHub App** 之后,从详情页记下两件事:
- 顶部 **public link** 的尾部即 slug。`https://github.com/apps/multica-acme` → slug = `multica-acme`
- 你刚生成的 **webhook secret**GitHub 之后不会再让你读取这个值——现在就保存好)
<Callout type="warning">
**Webhook secret ≠ Client secret。** App 设置页里两个字段紧挨着。**Webhook secret** 用于签 `pull_request` payload这才是 Multica 需要的那个;**Client secret** 是 OAuth 用的,和本集成无关。混淆这两个会得到「每条 webhook 都 `401 invalid signature`」的诡异症状。
</Callout>
### 2. 配置环境变量
API server 上:
```dotenv
GITHUB_APP_SLUG=multica-acme
GITHUB_WEBHOOK_SECRET=<你刚生成的 webhook secret>
```
两个都必填。任何一个缺失:
- Settings 里 `Connect GitHub` 按钮会被 **disable**并显示「not configured」提示
- `/api/webhooks/github` 直接返回 **`503 github webhooks not configured`**——Multica 在 secret 没配置时拒绝处理事件,不会出现「没 secret 也接受 webhook」的安全坑
`FRONTEND_ORIGIN` 也必须设置(任何生产 self-host 都已经设了——setup 回调结束后用它把用户跳回 `<FRONTEND_ORIGIN>/settings`。
设完 env 重启 API。
### 3. 执行 migration
集成的表在 migration `079_github_integration` 里。如果是升级既有部署:
```bash
make migrate-up
```
会创建三张表:`github_installation`、`github_pull_request`、`issue_pull_request`。三张表都 cascade 跟随 workspace——删工作区会自动清理。
### 4. 在 UI 里连接
到 Multica
1. 以 owner 或 admin 身份打开 **Settings → Integrations**
2. 点 **Connect GitHub**GitHub 在新 tab 打开
3. 选择要授权的仓库,点 **Install**
4. GitHub 跳回 `<api-host>/api/github/setup`,落库后再跳到 `<FRONTEND_ORIGIN>/settings?github_connected=1`
之后在任意一个仓库开一个分支 / 标题 / 正文带本工作区 issue 编号的 PR——几秒内对应 issue 的详情页上就能看到 Pull requests 区块。
### 5. 用 curl 自检
如果 GitHub 的 **Recent Deliveries** 里第一次 PR 事件就报 `401 invalid signature`,说明两边的 secret 不一致。绕过 GitHub 直接测 server 是最快的定位方法:
```bash
SECRET="<你填给 GITHUB_WEBHOOK_SECRET 的值>"
BODY='{"zen":"test"}'
SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -hex | awk '{print $NF}')
curl -i -X POST https://<api-host>/api/webhooks/github \
-H "X-Hub-Signature-256: sha256=$SIG" \
-H "X-GitHub-Event: ping" \
-H "Content-Type: application/json" \
-d "$BODY"
```
| HTTP 状态 | 含义 | 修法 |
|---|---|---|
| `200` `{"ok":"pong"}` | server 加载的 secret 和你 `$SECRET` 一致——GitHub 那边的 secret 才是错的 | 编辑 App → Webhook secret 字段**粘相同的值** → **必须点 Save changes**(不点 Save 等于没改)→ Redeliver |
| `401 invalid signature` | server 加载的 secret **不是**你以为的那个 | 进容器确认 env 实际生效(例如 `kubectl exec` → `echo -n "$GITHUB_WEBHOOK_SECRET" \| wc -c`),重新部署 |
| `503 github webhooks not configured` | `GITHUB_WEBHOOK_SECRET` 在进程里是空的 | 配上 env重启 API |
## 已知限制
目前还没做的几个边界:
- **手动 link UI 暂未提供**——关联 PR 的唯一方法是把 issue 编号写到 PR 分支 / 标题 / 正文
- **不读 CI / check 状态**——只镜像 PR 本身构建状态、reviewer 评论、reviewer 列表都没接进 Multica
- **没有工作区级别的 merge → status 映射配置**——默认固定是 `merged → done`cancelled 除外)。可配置映射是后续迭代
- **同 issue 多 PR 时merge 行为偏激进**——两个 PR 都引用 `MUL-123` 时,第一个 merge 就把 issue 转 Done。"等所有关联 PR 都解决再推进 issue 状态"的优化已经在做了
## 下一步
- [Issues](/issues) —— PR 引用的 issue 编号(`MUL-123`)的来源
- [工作区](/workspaces) —— 工作区 issue prefix 的设置位置
- [环境变量](/environment-variables) —— 完整 env 清单,包含上面提到的 GitHub 变量

View File

@@ -9,7 +9,6 @@
"workspaces",
"members-roles",
"issues",
"projects",
"comments",
"project-resources",
"---Agents---",
@@ -27,8 +26,6 @@
"autopilots",
"---Inbox---",
"inbox",
"---Integrations---",
"github-integration",
"---Self-hosting & ops---",
"environment-variables",
"auth-setup",

View File

@@ -9,7 +9,6 @@
"workspaces",
"members-roles",
"issues",
"projects",
"comments",
"---智能体---",
"agents",
@@ -26,8 +25,6 @@
"autopilots",
"---收件箱---",
"inbox",
"---集成---",
"github-integration",
"---自部署运维---",
"environment-variables",
"auth-setup",

View File

@@ -1,49 +0,0 @@
---
title: Projects
description: Group related issues and track them as one unit — with priority, status, progress, and an owner.
---
import { Callout } from "fumadocs-ui/components/callout";
A **project** in Multica is a container for related [issues](/issues). Use it when a body of work is bigger than one issue but smaller than a full workspace — a launch, a migration, a feature with multiple parts, an investigation that branches into several threads.
Each project has a name, an icon, a description, a **lead** (a member or an [agent](/agents)), a **status** (`planned` / `in_progress` / `paused` / `completed` / `cancelled`), a **priority** (`urgent` / `high` / `medium` / `low` / `none`), and a **progress** percentage that's auto-derived from the status of its linked issues.
## How projects relate to issues
Projects and issues are independent objects with a many-to-one relationship: an issue can belong to **at most one** project; a project holds **any number of** issues. Linking and unlinking is reversible at any time — drag in the board view, or use the project picker on the issue's right-side properties panel.
The progress bar on a project is computed from its linked issues — the more issues hit `done`, the further it fills. Issues that are `cancelled` are excluded from the count; issues in `backlog` count toward the denominator but not the numerator.
## Pinning to the sidebar
Click the pin icon in a project's top-right corner to add it to your sidebar's pinned list. Pinned projects stay one click away no matter where you are in the workspace; everyone on the team can pin independently — pins are personal.
The sidebar **Workspace → Projects** link always shows every project in the workspace; pinning is a personal shortcut on top of that.
## Attaching resources
Each project has a **Resources** section where you attach GitHub repositories. Once attached, any [agent](/agents) assigned to issues in this project can read and write to those repos when executing tasks — Multica passes the repo URLs as context to the [daemon](/daemon-runtimes).
Resources are per-project; if multiple projects share a repo, attach it to each one.
## Deleting a project
Deleting a project **does not delete its issues**. The linked issues are simply unlinked and revert to the workspace's flat issue list. This is intentional — work that was scoped to a project is rarely throwaway, even when the framing of the project changes.
<Callout type="info">
If you want to delete the work too, archive or delete the issues first, then delete the project.
</Callout>
## Project lead
The lead is the person — or agent — accountable for the project. It's a soft signal, not an access control: any workspace member can edit a project regardless of who's lead. A project's lead can be:
- A workspace member (human teammate)
- An [agent](/agents) — useful when the project's work is mostly delegated to an agent (e.g., "Weekly bug triage" led by a triage agent)
## Next
- [Issues](/issues) — the unit of work that lives inside projects
- [Agents as project lead](/agents) — when an agent is the right owner
- [How Multica works](/how-multica-works) — the broader picture

View File

@@ -1,49 +0,0 @@
---
title: 项目
description: 把相关的 issue 归为一组当成一个单元来跟进 —— 有优先级、状态、进度和负责人。
---
import { Callout } from "fumadocs-ui/components/callout";
Multica 里的**项目**project是相关 [issue](/issues) 的容器。当一摊工作比单个 issue 大、又比整个工作区小的时候用它 —— 一次发布、一次迁移、一个分多块做的功能、一个会拆出多个线索的调研。
每个项目有名字、图标、描述、**负责人**lead可以是成员也可以是 [智能体](/agents))、**状态**`planned` / `in_progress` / `paused` / `completed` / `cancelled`)、**优先级**`urgent` / `high` / `medium` / `low` / `none`),以及一个根据关联 issue 状态自动算出来的**进度**百分比。
## 项目和 issue 的关系
项目和 issue 是独立对象,多对一关系:一个 issue **最多属于一个**项目;一个项目可以容纳**任意多个** issue。关联和解除关联随时可逆 —— 在看板视图里拖动,或者在 issue 右侧 properties 面板用项目选择器。
项目的进度条是按关联 issue 状态自动算出来的 —— 越多 issue 到 `done`,进度条越满。`cancelled` 的 issue 不计入分母;`backlog` 的 issue 计入分母但不计入分子。
## pin 到侧边栏
点项目右上角的 pin 图标可以把这个项目加到侧边栏的固定区。pin 过的项目无论你在工作区哪里都一键可达;每个人独立 pin —— pin 是个人偏好。
侧边栏 **Workspace → Projects** 链接始终展示工作区里所有项目pin 只是在这之上的个人快捷方式。
## 关联 resources
每个项目有一个 **Resources** 区,可以挂 GitHub 仓库。挂上之后,被分配到这个项目里 issue 的 [智能体](/agents) 在执行 task 时可以读写这些仓库 —— Multica 会把仓库 URL 作为上下文传给 [守护进程](/daemon-runtimes)。
Resources 是项目级别的;多个项目要共享同一个仓库,要分别挂上。
## 删除项目
删除项目**不会**删除它的 issue。关联的 issue 只是解除关联,回到工作区的扁平 issue 列表。这是刻意的 —— 即使项目本身的框架变了,里面的工作通常也不会是一次性的。
<Callout type="info">
如果你确实想把工作也删掉,先归档或删除 issue再删除项目。
</Callout>
## 项目负责人
负责人是为这个项目负总责的人 —— 或者智能体。这是一个软信号,不是权限控制:工作区任何成员都可以编辑项目,不管谁是负责人。项目负责人可以是:
- 工作区里的成员(人)
- [智能体](/agents) —— 当项目里的工作大部分要交给智能体时合适(例如"每周 bug 巡检"由一个巡检智能体担任 lead
## 下一步
- [Issues](/issues) —— 项目里装的工作单元
- [智能体担任项目负责人](/agents) —— 什么时候由智能体当 lead 合适
- [Multica 怎么运转](/how-multica-works) —— 整体视图

View File

@@ -115,6 +115,5 @@ Same flow as Cloud — see [Cloud quickstart → Steps 5-6](/cloud-quickstart#5-
- [Environment variables](/environment-variables) — full env reference
- [Auth setup](/auth-setup) — Resend / OAuth / signup allowlist in detail
- [GitHub integration](/github-integration) — connect a GitHub App so PRs auto-link to issues and merging closes them
- [Troubleshooting](/troubleshooting) — start here when things go wrong
- [Desktop app](/desktop-app) — optional Desktop setup via `~/.multica/desktop.json`; the web frontend + CLI remains the quickest self-host path

View File

@@ -114,6 +114,5 @@ multica setup self-host
- [环境变量](/environment-variables) —— 完整 env 清单
- [登录与注册配置](/auth-setup) —— Resend / OAuth / 注册白名单详细配置
- [GitHub 集成](/github-integration) —— 连一个 GitHub App让 PR 自动关联 issue、merge 时自动转 Done
- [故障排查](/troubleshooting) —— 遇到问题先来这里
- [桌面应用](/desktop-app) —— 可以通过 `~/.multica/desktop.json` 连接 DesktopWeb 前端 + CLI 仍然是最快的自部署路径

View File

@@ -63,13 +63,11 @@ Automatic retry also has two extra conditions:
<Callout type="warning">
**Autopilot tasks don't retry automatically** by design. An Autopilot has its own firing cadence (e.g. daily); automatic retries on failure would overlap with the next scheduled run. If you need an immediate re-run after failure, use a manual rerun (next section).
**How you'll know an Autopilot task failed**: a notification lands in your [Inbox](/inbox), and the associated issue's status reverts from `in_progress` back to `todo`. The [Autopilots](/autopilots) page also shows the latest run result per autopilot.
</Callout>
## Manual rerun vs. automatic retry
A **manual rerun** is one you trigger from the CLI or the API (`POST /api/issues/{id}/rerun`):
A **manual rerun** is one you trigger from the UI or CLI:
```bash
multica issue rerun <issue-id>
@@ -77,10 +75,9 @@ multica issue rerun <issue-id>
Behavior:
- Targets the issue's **current agent assignee** — not whoever ran the most recent task. If the assignee changed since the last run, rerun follows the current assignment. To rerun a specific agent that is no longer the assignee, reassign the issue first, then rerun.
- **Cancels** the assignee's queued or running task on this issue (if any). Tasks owned by other agents on the same issue (e.g. parallel @-mention runs) are left alone.
- Creates a **brand-new** task — attempt count resets to 1, even if the original task hit the attempt ceiling.
- Starts a **fresh agent session** — the prior session ID is **not** inherited. A manual rerun means you've judged the previous output bad, so resuming the same conversation would replay the same poisoned state. (Automatic retry, by contrast, does inherit the session — that path is for infrastructure failures, not bad output.)
- **Cancels** the currently running task (if any)
- Creates a **brand-new** task — attempt count resets to 1, even if the original task hit the attempt ceiling
- Inherits the previous session ID; if the corresponding AI coding tool supports session resumption, the new task continues from the previous context
Comparison:
@@ -88,9 +85,8 @@ Comparison:
|---|---|---|
| Trigger | System, based on failure reason | You, manually |
| Ceiling | 2 attempts | No limit |
| Applicable sources | Issues, chat | Issues with an agent assignee |
| Agent picked | Same agent as the failed task | Issue's current assignee |
| Session inheritance | Yes (resumes prior session) | No (fresh session) |
| Applicable sources | Issues, chat | All sources |
| Session inheritance | Yes | Yes |
## How a failed task affects issue status
@@ -100,7 +96,7 @@ If an issue-triggered task fails (and no automatic retry succeeds) because the i
Yes — as long as the AI coding tool supports session resumption.
Multica pins the session ID **twice** during a task: once at the start (when the AI tool returns its first system message), and once at the end (on completion or failure). The first lets the daemon recover if it crashes mid-run; the second is reserved for the next **automatic retry**, where that ID is passed back so the agent can pick up the previous conversation and file state. **Manual rerun deliberately skips this** and starts a fresh session — see [Manual rerun vs. automatic retry](#manual-rerun-vs-automatic-retry).
Multica pins the session ID **twice** during a task: once at the start (when the AI tool returns its first system message), and once at the end (on completion or failure). The first lets the daemon recover if it crashes mid-run; the second is reserved for future reruns. On the next rerun or automatic retry, that ID is passed back so the agent can pick up the previous conversation and file state.
But **which AI coding tools actually support this** varies a lot:

View File

@@ -63,13 +63,11 @@ Multica 服务器每 30 秒扫描一次,有两种超时会触发失败:
<Callout type="warning">
**Autopilots 任务不自动重试**是刻意设计。Autopilot 有自己的触发周期(例如每天一次);如果失败又自动重试,会和下一个周期的任务重叠。需要失败后立即重跑,用手动重跑(下一节)。
**怎么知道 Autopilot 失败了**:失败的 Autopilot 任务会在你的 [收件箱](/inbox) 里出现一条通知,关联的 issue 状态也会从 `in_progress` 退回 `todo`。直接打开 [Autopilots](/autopilots) 页面也能看到每条 autopilot 的最近运行结果。
</Callout>
## 手动重跑和自动重试的区别
**手动重跑**rerun是你通过命令行或 API`POST /api/issues/{id}/rerun`主动发起的:
**手动重跑**rerun是你从 UI 或命令行主动发起的:
```bash
multica issue rerun <issue-id>
@@ -77,10 +75,9 @@ multica issue rerun <issue-id>
行为:
- 跑的是 issue **当前的智能体分配人**——不是上一次跑过的 agent。如果分配人在上次运行后改了rerun 会跟着新的分配人走。要重跑一个已经不再是分配人的智能体,先把 issue 改派回它,再 rerun。
- **取消**该分配人在这条 issue 上 queued / running 的任务(如果有)。同 issue 上其它 agent 的任务(例如 @-mention 触发的并行任务)不会被一起取消。
- 创建一个**全新**的执行任务——尝试次数重置为 1即使原任务已达最大尝试。
- 启动**全新的智能体会话**——**不**继承之前的会话 ID。手动重跑意味着你已经判定上一次的产出不行再继续之前的对话只会重放被污染的上下文。自动重试则相反会继承会话——那条路径处理的是基础设施层面的失败不是产出不好。
- **取消**当前正在跑的任务(如果有)
- 创建一个**全新**的执行任务——尝试次数重置为 1即使原任务已达最大尝试
- 继承上一次的会话 ID如果对应的 AI 编程工具支持会话恢复,会接着上次的上下文继续
对比:
@@ -88,9 +85,8 @@ multica issue rerun <issue-id>
|---|---|---|
| 触发 | 系统基于失败原因自动执行 | 你主动发起 |
| 上限 | 2 次 | 无上限 |
| 适用来源 | issue、聊天 | 有智能体分配人的 issue |
| 跑哪个 agent | 失败任务原本的 agent | issue 当前的分配人 |
| 会话继承 | 是(接着上次会话) | 否(全新会话) |
| 适用来源 | issue、聊天 | 所有来源 |
| 会话继承 | 是 | 是 |
## 失败的任务对 issue 状态有什么影响
@@ -100,7 +96,7 @@ multica issue rerun <issue-id>
可以——前提是对应的 AI 编程工具支持会话恢复。
Multica 在任务过程中**两次**保存会话 ID——任务一开始AI 工具返回第一条系统消息时pin 一次,任务结束(完成或失败)时再 pin 一次。前者让守护进程中途崩溃时也能恢复,后者留给下一次**自动重试**——届时把这个 ID 传回去,智能体就能接着上次的对话文件状态继续。**手动重跑会主动跳过这一步**,永远从全新会话开始——见 [手动重跑和自动重试的区别](#手动重跑和自动重试的区别)。
Multica 在任务过程中**两次**保存会话 ID——任务一开始AI 工具返回第一条系统消息时pin 一次,任务结束(完成或失败)时再 pin 一次。前者让守护进程中途崩溃时也能恢复,后者给之后的重跑用。下次重跑或自动重试时把这个 ID 传回去,智能体就能接着上次的对话文件状态继续。
但**哪些 AI 编程工具真的支持**差别很大:

View File

@@ -1,52 +0,0 @@
import { describe, expect, it } from "vitest";
import { prefixLocale } from "./locale-link";
describe("prefixLocale", () => {
it("prefixes root-relative paths with the active non-default locale", () => {
expect(prefixLocale("/workspaces", "zh")).toBe("/zh/workspaces");
expect(prefixLocale("/agents-create", "zh")).toBe("/zh/agents-create");
});
it("preserves anchors and query strings on prefixed paths", () => {
expect(prefixLocale("/providers#claude-code", "zh")).toBe(
"/zh/providers#claude-code",
);
expect(prefixLocale("/agents?from=docs", "zh")).toBe(
"/zh/agents?from=docs",
);
});
it("rewrites the bare root path to the locale root", () => {
expect(prefixLocale("/", "zh")).toBe("/zh");
});
it("leaves the default language untouched (URLs are prefix-less)", () => {
expect(prefixLocale("/workspaces", "en")).toBe("/workspaces");
expect(prefixLocale("/", "en")).toBe("/");
});
it("does not double-prefix paths that already carry a known locale", () => {
expect(prefixLocale("/zh/workspaces", "zh")).toBe("/zh/workspaces");
expect(prefixLocale("/en/workspaces", "zh")).toBe("/en/workspaces");
});
it("leaves external URLs alone", () => {
expect(prefixLocale("https://multica.ai/download", "zh")).toBe(
"https://multica.ai/download",
);
expect(prefixLocale("mailto:hello@multica.ai", "zh")).toBe(
"mailto:hello@multica.ai",
);
expect(prefixLocale("tel:+1234567890", "zh")).toBe("tel:+1234567890");
});
it("leaves in-page anchors and relative paths alone", () => {
expect(prefixLocale("#section", "zh")).toBe("#section");
expect(prefixLocale("./sibling", "zh")).toBe("./sibling");
expect(prefixLocale("../sibling", "zh")).toBe("../sibling");
});
it("returns empty/undefined hrefs unchanged", () => {
expect(prefixLocale("", "zh")).toBe("");
});
});

View File

@@ -1,31 +0,0 @@
import { i18n } from "./i18n";
// Add the active locale prefix to root-relative MDX links so internal
// navigation inside Chinese (or any non-default-language) docs stays in
// that language. Without this, `[xx](/workspaces)` written in a `*.zh.mdx`
// renders as `<a href="/workspaces">`, which Next's basePath rewrites to
// `/docs/workspaces` and the docs middleware then routes to English —
// leaking the reader out of their chosen locale.
//
// We deliberately do NOT touch:
// - external links (`https:`, `mailto:`, `tel:`, etc.)
// - in-page anchors (`#section`)
// - relative paths (`./foo`, `../bar`)
// - paths already prefixed with a known locale
// - the default language (URLs are intentionally prefix-less under
// `hideLocale: 'default-locale'`)
export function prefixLocale(href: string, lang: string): string {
if (!href) return href;
if (lang === i18n.defaultLanguage) return href;
if (/^[a-z][a-z0-9+.-]*:/i.test(href)) return href;
if (href.startsWith("#")) return href;
if (!href.startsWith("/")) return href;
const segments = href.split("/").filter(Boolean);
const first = segments[0];
if (first && (i18n.languages as readonly string[]).includes(first)) {
return href;
}
return href === "/" ? `/${lang}` : `/${lang}${href}`;
}

View File

@@ -8,7 +8,6 @@
"build": "fumadocs-mdx && next build",
"start": "next start",
"typecheck": "fumadocs-mdx && tsc --noEmit",
"test": "vitest run",
"postinstall": "fumadocs-mdx"
},
"dependencies": {
@@ -28,7 +27,6 @@
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
"tailwindcss": "catalog:",
"typescript": "catalog:",
"vitest": "catalog:"
"typescript": "catalog:"
}
}

View File

@@ -1,16 +0,0 @@
import { defineConfig } from "vitest/config";
import path from "path";
export default defineConfig({
test: {
environment: "node",
globals: true,
include: ["**/*.test.{ts,tsx}"],
exclude: ["node_modules/**", ".next/**", ".source/**"],
},
resolve: {
alias: {
"@": path.resolve(__dirname, "."),
},
},
});

View File

@@ -2,7 +2,6 @@
import { use } from "react";
import { IssueDetail } from "@multica/views/issues/components";
import { ErrorBoundary } from "@multica/ui/components/common/error-boundary";
export default function IssueDetailPage({
params,
@@ -10,9 +9,5 @@ export default function IssueDetailPage({
params: Promise<{ id: string }>;
}) {
const { id } = use(params);
return (
<ErrorBoundary resetKeys={[id]}>
<IssueDetail issueId={id} />
</ErrorBoundary>
);
return <IssueDetail issueId={id} />;
}

View File

@@ -1,12 +1,7 @@
"use client";
import { IssuesPage } from "@multica/views/issues/components";
import { ErrorBoundary } from "@multica/ui/components/common/error-boundary";
export default function Page() {
return (
<ErrorBoundary>
<IssuesPage />
</ErrorBoundary>
);
return <IssuesPage />;
}

View File

@@ -1 +0,0 @@
export { DashboardPage as default } from "@multica/views/dashboard";

View File

@@ -39,6 +39,7 @@
--success: oklch(0.55 0.16 145);
--warning: oklch(0.75 0.16 85);
--info: oklch(0.55 0.18 250);
--priority: oklch(0.65 0.18 50);
--scrollbar-thumb: oklch(0 0 0 / 10%);
--scrollbar-thumb-hover: oklch(0 0 0 / 18%);
--scrollbar-track: transparent;

View File

@@ -44,15 +44,6 @@ export function LandingHeader({
</Link>
<div className="flex items-center gap-2.5 sm:gap-3">
<Link
href="/changelog"
className={cn(
headerButtonClassName("ghost", variant),
"hidden sm:inline-flex",
)}
>
{t.header.changelog}
</Link>
<Link
href={githubUrl}
target="_blank"

View File

@@ -7,7 +7,6 @@ export function createEnDict(allowSignup: boolean): LandingDict {
github: "GitHub",
login: "Log in",
dashboard: "Dashboard",
changelog: "Changelog",
},
hero: {
@@ -95,7 +94,7 @@ export function createEnDict(allowSignup: boolean): LandingDict {
label: "RUNTIMES",
title: "One dashboard for all your compute",
description:
"Local daemons and cloud runtimes, managed from a single panel. Real-time monitoring of online/offline status, usage charts, and activity heatmaps. Auto-detects 11 supported coding tools on your machine.",
"Local daemons and cloud runtimes, managed from a single panel. Real-time monitoring of online/offline status, usage charts, and activity heatmaps. Auto-detects local CLIs \u2014 plug in and go.",
cards: [
{
title: "Unified runtime panel",
@@ -108,9 +107,9 @@ export function createEnDict(allowSignup: boolean): LandingDict {
"Online/offline status, usage charts, and activity heatmaps. Know exactly what your compute is doing at any moment.",
},
{
title: "Auto-detection on first run",
title: "Auto-detection & plug-and-play",
description:
"Multica scans for 11 supported coding tools \u2014 Claude Code, Codex, Cursor, Copilot, Gemini, Hermes, Kimi, Kiro CLI, OpenCode, OpenClaw, and Pi \u2014 and registers a runtime for each one it finds.",
"Multica detects available CLIs like Claude Code, Codex, OpenClaw, and OpenCode automatically. Connect a machine, and it\u2019s ready to work.",
},
],
},
@@ -130,7 +129,7 @@ export function createEnDict(allowSignup: boolean): LandingDict {
{
title: "Install the CLI & connect your machine",
description:
"Run multica setup \u2014 it walks you through OAuth, starts the daemon, and scans for the 11 supported coding tools (Claude Code, Codex, Cursor, Copilot, Gemini, Hermes, Kimi, Kiro CLI, OpenCode, OpenClaw, Pi). Whichever ones you already have installed get registered as runtimes automatically.",
"Run multica setup to configure, authenticate, and start the daemon. It auto-detects Claude Code, Codex, OpenClaw, and OpenCode on your machine \u2014 plug in and go.",
},
{
title: "Create your first agent",
@@ -186,7 +185,7 @@ export function createEnDict(allowSignup: boolean): LandingDict {
{
question: "What coding agents does Multica support?",
answer:
"Multica supports 11 coding tools out of the box: Claude Code, Codex, Cursor, Copilot, Gemini, Hermes, Kimi, Kiro CLI, OpenCode, OpenClaw, and Pi. The daemon auto-detects whichever CLIs you already have installed and registers a runtime for each one. Since it's open source, you can also add your own backends.",
"Multica currently supports Claude Code, Codex, OpenClaw, and OpenCode out of the box. The daemon auto-detects whichever CLIs you have installed. Since it\u2019s open source, you can also add your own backends.",
},
{
question: "Do I need to self-host, or is there a cloud version?",
@@ -284,187 +283,6 @@ export function createEnDict(allowSignup: boolean): LandingDict {
fixes: "Bug Fixes",
},
entries: [
{
version: "0.2.31",
date: "2026-05-12",
title: "GitHub Integration, Chat Attachments & Safer Issue Navigation",
changes: [],
features: [
"Connect GitHub so linked pull requests appear on Multica issues, sync their status, and close the Multica issue automatically when the PR closes",
"Chat messages can include file attachments and image previews",
"Agents and runtimes can now be kept public or private for clearer team access",
"Stopping a single agent task now asks for confirmation before it is terminated",
"New GitHub integration docs cover both hosted and self-hosted setup",
],
improvements: [
"Issue links land more reliably on the exact comment or activity you opened",
"Long issue timelines scroll more smoothly",
"The feedback dialog now points contributors toward GitHub discussions and issues",
"Self-hosted Caddy guidance now calls out real-time connection requirements",
"Linux desktop packages show the Multica app icon again",
],
fixes: [
"Downloaded attachments keep their original filenames",
"Local attachments are served more reliably, and upload controls stay disabled until files are ready",
"Issue creation dialogs keep their text fields at the correct height",
"Runtime documentation links point to the correct page",
],
},
{
version: "0.2.30",
date: "2026-05-11",
title: "Mermaid in Issues, Per-Runtime Timezone & Workspace-Leave Runtime Revocation",
changes: [],
features: [
"Mermaid diagrams render inline in issue descriptions",
"Sub-issue rows gain inline status and assignee pickers, with batch select across rows",
"Per-runtime timezone for token-usage aggregation, so daily rollups respect your local day",
"Private agents are gated by an `allowed_principals` predicate, with fine-grained visibility",
"A member leaving or being removed from a workspace now revokes their runtimes automatically",
"Set custom per-token prices for unmaintained models so usage reflects real cost",
"Landing page header gains a Changelog link",
],
improvements: [
"Daemon self-heals when a runtime is deleted server-side — no more zombie local entries",
"Chat and comment composer share the same `Mod+Enter` send shortcut",
"Copilot CLI model catalog expanded with correct dotted IDs",
"Copilot failure details now surface in the UI instead of a generic error",
"Daemon brief is inlined into the system prompt for providers that need it",
"Realtime WebSocket accepts same-origin upgrades from mobile and CLI",
],
fixes: [
"Recent-issues list no longer leaks across workspaces",
"CloudFront attachment download URLs are re-signed at click time, fixing expired previews",
"Windows reply templates use `--content-file` across every provider so non-ASCII bodies survive",
"Daemon suppresses extra git console pop-ups on Windows",
"Pi extension tools are no longer filtered by a hardcoded `--tools` allowlist",
"Inbox scrolls to the target comment once the issue finishes loading",
"`autopilot create/update` accepts `--mode run_only`",
"Changelog header link styled to match the GitHub ghost button",
"OpenAI Codex / GPT model pricing populated — cost no longer shows $0",
],
},
{
version: "0.2.29",
date: "2026-05-09",
title: "Project Picker in Quick Create, Resolvable Comments & Timeline Performance",
changes: [],
features: [
"Quick Create lets you pick a project, and remembers your last choice",
"Comment threads can be resolved and collapsed, keeping long discussions tidy",
"Issue live banner now shows agent tasks waiting in queue",
"Failed or cancelled tasks can be rerun in one click from the Execution Log",
"Agent Create modal gains an expand button for editing long descriptions",
],
improvements: [
"Issue timeline no longer fully re-renders on every WebSocket event — long issues scroll smoothly",
"Editor skips parsing very large or JSON pastes, eliminating freezes",
"Autopilot skips dispatch when the assignee runtime is offline, avoiding empty runs",
"Inbox auto-archives `task_failed` rows once they reach a terminal state",
"Hermes sends agent instructions inline with each request",
"Timeline and Comment switched to client-side virtualization, dropping server-side pagination",
"Reserved slugs share a single JSON between front and back end, with CI guarding drift",
"ACP error messages include the JSON-RPC `error.data` field for clearer debugging",
],
fixes: [
"429 / insufficient-balance agent runs are now marked `failed` instead of `completed`",
"Agent sessions stuck on poisoned images can recover, so the issue resumes",
"`pi --list-models` table format parses correctly, restoring model discovery",
"`pi` colon-to-slash normalization only applies to the legacy format",
"`kiro` and `kimi` added to the inline-system-prompt provider allowlist",
"Priority dropdown badge colors aligned with PriorityIcon semantic tokens",
"Long single-line agent messages now expand correctly",
"Desktop \"copy issue link\" uses the current connection URL instead of localhost",
"Mobile WebSocket handshake succeeds without cookies",
"Workspace slug creation validates reserved words; slug error messages are translated",
"Timeline correctly syncs `around` state when props flip to falsy",
"DropdownMenu popovers size to their content",
],
},
{
version: "0.2.28",
date: "2026-05-08",
title: "Daemon Disk-Usage CLI, Timeline Polish & Task Usage Rollup",
changes: [],
features: [
"New `multica daemon disk-usage` CLI surfaces per-task and per-workspace disk footprint",
"Skill picker in agent settings has a search box for fast lookup",
"Daemon GC extends to chat, autopilot, and quick-create tasks",
"Issue detail breadcrumb now shows the MUL-xxxx identifier for quick reference",
],
improvements: [
"Timeline page size bumped to 50, with per-pool keyset cursors for comments and activities",
"'Show older / newer' affordances now appear in edge cases and look clearly clickable",
"Server `task_usage` rolls up into a daily aggregate table, dropping DB load significantly",
"Daemon health check stays responsive while repo lookups are in flight",
"Runtime stats exclude archived agents for accurate active counts",
],
fixes: [
"Linux daemon self-restart uses `brew prefix` symlinks, so Homebrew Cellar deletion no longer orphans runtimes",
"CLI short IDs now route correctly — copied prefixes no longer 404",
"Windows non-ASCII comment / description input lands via new `--content-file` / `--description-file` flags",
"Windows / Linux desktop replaces the Electron placeholder icon with the Multica asterisk",
"Orphaned timeline replies are now correctly surfaced",
"Timeline comment pagination budget excludes activities, so heavy activity no longer crowds out real comments",
],
},
{
version: "0.2.27",
date: "2026-05-07",
title: "Smoother Chat, GitHub Skill Import & Stability Fixes",
changes: [],
features: [
"Import reusable skills directly from GitHub links",
],
improvements: [
"Chat and Inbox feel smoother, with clearer history, easier reply copying, and faster triage after archiving",
"Issue actions keep more context, from easier access to the local folder to sub-issues inheriting the right project and status",
"Autopilots pause themselves after repeated failures, so noisy automations are easier to catch and fix",
],
fixes: [
"Chinese input, desktop updates, long issue timelines, and live status updates are more reliable",
],
},
{
version: "0.2.26",
date: "2026-05-06",
title: "Full i18n Rollout, Long-Issue Timeline & System Notifications Toggle",
changes: [],
features: [
"Web app fully translated to Simplified Chinese (21 namespaces), with per-user locale",
"System Notifications toggle in Settings",
"Delete chat sessions; History panel surfaced on the chat header",
"Runtime liveness backed by Redis, with DB fallback",
"Desktop loads runtime self-host config",
"CLI adds `--assignee-id` / `--to-id` / `--user-id` for unambiguous targeting",
],
improvements: [
"Settings 'Appearance' tab is renamed to 'Preferences', and the active tab is reflected in the URL so deep links work",
"Long issues open instantly — Timeline switched to cursor-based keyset pagination, and repeated `task_completed` / `task_failed` activity entries are coalesced",
"Runtime poll and heartbeat schedules are isolated per-runtime, so one busy runtime can no longer starve others",
"CLI update requests persist in Redis, so a server restart no longer drops them",
"Runtime cost usage window narrowed from 180 days to 14 days, dropping query load",
"Project list returns a `resource_count` instead of inlining all resources, keeping responses lean",
"404 page redesigned, with the No-Access redirect loop fixed",
"Quick Create exempts git-describe daemons from the CLI version gate",
"CI now enforces lint on every PR, and the existing lint debt has been cleared",
],
fixes: [
"Daemon cancels the running agent when the task is deleted server-side, eliminating orphan processes",
"Daemon refreshes a stale Codex `auth.json` when reusing an exec env, fixing intermittent auth errors",
"Daemon refuses to write `.gc_meta.json` when `issue_id` is empty",
"Session / resume across ACP backends now trusts the agent-reported session id, fixing cross-session bleed",
"OpenCode skills are written under `.opencode/skills/` so they are discovered natively",
"404 task-not-found semantics tightened on both server and the final guard",
"Pinned sidebar rows are auto-unpinned when the underlying entity disappears",
"Project detail page splits desktop and mobile sidebar state",
"Runtime detail page hides archived agents",
"Already-attached repos in Add Resource show a URL tooltip; empty project state has a New Issue button",
"S3 public URLs are region-qualified, fixing cross-region access",
"Windows installer parses version numbers and decodes checksums correctly",
"Quick Create submit button no longer shows a duplicate keyboard shortcut",
],
},
{
version: "0.2.24",
date: "2026-05-03",

View File

@@ -20,7 +20,7 @@ type FooterGroup = {
};
export type LandingDict = {
header: { github: string; login: string; dashboard: string; changelog: string };
header: { github: string; login: string; dashboard: string };
hero: {
headlineLine1: string;
headlineLine2: string;

View File

@@ -7,49 +7,48 @@ export function createZhDict(allowSignup: boolean): LandingDict {
github: "GitHub",
login: "\u767b\u5f55",
dashboard: "\u8fdb\u5165\u5de5\u4f5c\u53f0",
changelog: "\u66f4\u65b0\u65e5\u5fd7",
},
hero: {
headlineLine1: "\u4f60\u7684\u4e0b\u4e00\u6279\u5458\u5de5",
headlineLine2: "\u4e0d\u662f\u4eba\u7c7b\u3002",
subheading:
"Multica \u662f\u4e00\u4e2a\u5f00\u6e90\u5e73\u53f0\uff0c\u5c06\u7f16\u7801 智能体 \u53d8\u6210\u771f\u6b63\u7684\u961f\u53cb\u3002\u5206\u914d\u4efb\u52a1\u3001\u8ddf\u8e2a\u8fdb\u5ea6\u3001\u79ef\u7d2f\u6280\u80fd\u2014\u2014\u5728\u4e00\u4e2a\u5730\u65b9\u7ba1\u7406\u4f60\u7684\u4eba\u7c7b + 智能体 \u56e2\u961f\u3002",
"Multica \u662f\u4e00\u4e2a\u5f00\u6e90\u5e73\u53f0\uff0c\u5c06\u7f16\u7801 Agent \u53d8\u6210\u771f\u6b63\u7684\u961f\u53cb\u3002\u5206\u914d\u4efb\u52a1\u3001\u8ddf\u8e2a\u8fdb\u5ea6\u3001\u79ef\u7d2f\u6280\u80fd\u2014\u2014\u5728\u4e00\u4e2a\u5730\u65b9\u7ba1\u7406\u4f60\u7684\u4eba\u7c7b + Agent \u56e2\u961f\u3002",
cta: "免费开始",
downloadDesktop: "下载桌面端",
worksWith: "支持",
imageAlt: "Multica \u770b\u677f\u89c6\u56fe\u2014\u2014\u4eba\u7c7b\u548c 智能体 \u534f\u540c\u7ba1\u7406\u4efb\u52a1",
imageAlt: "Multica \u770b\u677f\u89c6\u56fe\u2014\u2014\u4eba\u7c7b\u548c Agent \u534f\u540c\u7ba1\u7406\u4efb\u52a1",
},
features: {
teammates: {
label: "\u56e2\u961f\u534f\u4f5c",
title: "\u50cf\u5206\u914d\u7ed9\u540c\u4e8b\u4e00\u6837\u5206\u914d\u7ed9 智能体",
title: "\u50cf\u5206\u914d\u7ed9\u540c\u4e8b\u4e00\u6837\u5206\u914d\u7ed9 Agent",
description:
"智能体 \u4e0d\u662f\u88ab\u52a8\u5de5\u5177\u2014\u2014\u5b83\u4eec\u662f\u4e3b\u52a8\u53c2\u4e0e\u8005\u3002\u5b83\u4eec\u62e5\u6709\u4e2a\u4eba\u8d44\u6599\u3001\u62a5\u544a\u72b6\u6001\u3001\u521b\u5efa Issue\u3001\u53d1\u8868\u8bc4\u8bba\u3001\u66f4\u65b0\u72b6\u6001\u3002\u4f60\u7684\u6d3b\u52a8\u6d41\u5c55\u793a\u4eba\u7c7b\u548c 智能体 \u5e76\u80a9\u5de5\u4f5c\u3002",
"Agent \u4e0d\u662f\u88ab\u52a8\u5de5\u5177\u2014\u2014\u5b83\u4eec\u662f\u4e3b\u52a8\u53c2\u4e0e\u8005\u3002\u5b83\u4eec\u62e5\u6709\u4e2a\u4eba\u8d44\u6599\u3001\u62a5\u544a\u72b6\u6001\u3001\u521b\u5efa Issue\u3001\u53d1\u8868\u8bc4\u8bba\u3001\u66f4\u65b0\u72b6\u6001\u3002\u4f60\u7684\u6d3b\u52a8\u6d41\u5c55\u793a\u4eba\u7c7b\u548c Agent \u5e76\u80a9\u5de5\u4f5c\u3002",
cards: [
{
title: "智能体 \u51fa\u73b0\u5728\u6307\u6d3e\u4eba\u9009\u62e9\u5668\u4e2d",
title: "Agent \u51fa\u73b0\u5728\u6307\u6d3e\u4eba\u9009\u62e9\u5668\u4e2d",
description:
"\u4eba\u7c7b\u548c 智能体 \u51fa\u73b0\u5728\u540c\u4e00\u4e2a\u4e0b\u62c9\u83dc\u5355\u91cc\u3002\u628a\u4efb\u52a1\u5206\u914d\u7ed9 智能体 \u548c\u5206\u914d\u7ed9\u540c\u4e8b\u6ca1\u6709\u4efb\u4f55\u533a\u522b\u3002",
"\u4eba\u7c7b\u548c Agent \u51fa\u73b0\u5728\u540c\u4e00\u4e2a\u4e0b\u62c9\u83dc\u5355\u91cc\u3002\u628a\u4efb\u52a1\u5206\u914d\u7ed9 Agent \u548c\u5206\u914d\u7ed9\u540c\u4e8b\u6ca1\u6709\u4efb\u4f55\u533a\u522b\u3002",
},
{
title: "\u81ea\u4e3b\u53c2\u4e0e",
description:
"智能体 \u4e3b\u52a8\u521b\u5efa Issue\u3001\u53d1\u8868\u8bc4\u8bba\u3001\u66f4\u65b0\u72b6\u6001\u2014\u2014\u800c\u4e0d\u662f\u53ea\u5728\u88ab\u63d0\u793a\u65f6\u624d\u884c\u52a8\u3002",
"Agent \u4e3b\u52a8\u521b\u5efa Issue\u3001\u53d1\u8868\u8bc4\u8bba\u3001\u66f4\u65b0\u72b6\u6001\u2014\u2014\u800c\u4e0d\u662f\u53ea\u5728\u88ab\u63d0\u793a\u65f6\u624d\u884c\u52a8\u3002",
},
{
title: "\u7edf\u4e00\u7684\u6d3b\u52a8\u65f6\u95f4\u7ebf",
description:
"\u6574\u4e2a\u56e2\u961f\u5171\u7528\u4e00\u4e2a\u6d3b\u52a8\u6d41\u3002\u4eba\u7c7b\u548c 智能体 \u7684\u64cd\u4f5c\u4ea4\u66ff\u5c55\u793a\uff0c\u4f60\u59cb\u7ec8\u77e5\u9053\u53d1\u751f\u4e86\u4ec0\u4e48\u3001\u662f\u8c01\u505a\u7684\u3002",
"\u6574\u4e2a\u56e2\u961f\u5171\u7528\u4e00\u4e2a\u6d3b\u52a8\u6d41\u3002\u4eba\u7c7b\u548c Agent \u7684\u64cd\u4f5c\u4ea4\u66ff\u5c55\u793a\uff0c\u4f60\u59cb\u7ec8\u77e5\u9053\u53d1\u751f\u4e86\u4ec0\u4e48\u3001\u662f\u8c01\u505a\u7684\u3002",
},
],
},
autonomous: {
label: "\u81ea\u4e3b\u6267\u884c",
title: "\u8bbe\u7f6e\u540e\u65e0\u9700\u7ba1\u7406\u2014\u2014智能体 \u5728\u4f60\u7761\u89c9\u65f6\u5de5\u4f5c",
title: "\u8bbe\u7f6e\u540e\u65e0\u9700\u7ba1\u7406\u2014\u2014Agent \u5728\u4f60\u7761\u89c9\u65f6\u5de5\u4f5c",
description:
"\u4e0d\u53ea\u662f\u63d0\u793a-\u54cd\u5e94\u3002\u5b8c\u6574\u7684\u4efb\u52a1\u751f\u547d\u5468\u671f\u7ba1\u7406\uff1a\u5165\u961f\u3001\u9886\u53d6\u3001\u542f\u52a8\u3001\u5b8c\u6210\u6216\u5931\u8d25\u3002智能体 \u4e3b\u52a8\u62a5\u544a\u963b\u585e\uff0c\u4f60\u901a\u8fc7 WebSocket \u83b7\u53d6\u5b9e\u65f6\u8fdb\u5ea6\u3002",
"\u4e0d\u53ea\u662f\u63d0\u793a-\u54cd\u5e94\u3002\u5b8c\u6574\u7684\u4efb\u52a1\u751f\u547d\u5468\u671f\u7ba1\u7406\uff1a\u5165\u961f\u3001\u9886\u53d6\u3001\u542f\u52a8\u3001\u5b8c\u6210\u6216\u5931\u8d25\u3002Agent \u4e3b\u52a8\u62a5\u544a\u963b\u585e\uff0c\u4f60\u901a\u8fc7 WebSocket \u83b7\u53d6\u5b9e\u65f6\u8fdb\u5ea6\u3002",
cards: [
{
title: "\u5b8c\u6574\u7684\u4efb\u52a1\u751f\u547d\u5468\u671f",
@@ -59,12 +58,12 @@ export function createZhDict(allowSignup: boolean): LandingDict {
{
title: "\u4e3b\u52a8\u62a5\u544a\u963b\u585e",
description:
"\u5f53 智能体 \u9047\u5230\u56f0\u96be\u65f6\uff0c\u4f1a\u7acb\u5373\u53d1\u51fa\u8b66\u62a5\u3002\u4e0d\u7528\u7b49\u51e0\u4e2a\u5c0f\u65f6\u540e\u624d\u53d1\u73b0\u4ec0\u4e48\u90fd\u6ca1\u53d1\u751f\u3002",
"\u5f53 Agent \u9047\u5230\u56f0\u96be\u65f6\uff0c\u4f1a\u7acb\u5373\u53d1\u51fa\u8b66\u62a5\u3002\u4e0d\u7528\u7b49\u51e0\u4e2a\u5c0f\u65f6\u540e\u624d\u53d1\u73b0\u4ec0\u4e48\u90fd\u6ca1\u53d1\u751f\u3002",
},
{
title: "\u5b9e\u65f6\u8fdb\u5ea6\u63a8\u9001",
description:
"\u57fa\u4e8e WebSocket \u7684\u5b9e\u65f6\u66f4\u65b0\u3002\u5b9e\u65f6\u89c2\u770b 智能体 \u5de5\u4f5c\uff0c\u6216\u968f\u65f6\u67e5\u770b\u2014\u2014\u65f6\u95f4\u7ebf\u59cb\u7ec8\u662f\u6700\u65b0\u7684\u3002",
"\u57fa\u4e8e WebSocket \u7684\u5b9e\u65f6\u66f4\u65b0\u3002\u5b9e\u65f6\u89c2\u770b Agent \u5de5\u4f5c\uff0c\u6216\u968f\u65f6\u67e5\u770b\u2014\u2014\u65f6\u95f4\u7ebf\u59cb\u7ec8\u662f\u6700\u65b0\u7684\u3002",
},
],
},
@@ -72,22 +71,22 @@ export function createZhDict(allowSignup: boolean): LandingDict {
label: "\u6280\u80fd\u5e93",
title: "\u6bcf\u4e2a\u89e3\u51b3\u65b9\u6848\u90fd\u6210\u4e3a\u5168\u56e2\u961f\u53ef\u590d\u7528\u7684\u6280\u80fd",
description:
"\u6280\u80fd\u662f\u53ef\u590d\u7528\u7684\u80fd\u529b\u5b9a\u4e49\u2014\u2014\u4ee3\u7801\u3001\u914d\u7f6e\u548c\u4e0a\u4e0b\u6587\u6253\u5305\u5728\u4e00\u8d77\u3002\u53ea\u9700\u7f16\u5199\u4e00\u6b21\uff0c\u56e2\u961f\u4e2d\u6bcf\u4e2a 智能体 \u90fd\u80fd\u4f7f\u7528\u3002\u4f60\u7684\u6280\u80fd\u5e93\u968f\u65f6\u95f4\u4e0d\u65ad\u79ef\u7d2f\u3002",
"\u6280\u80fd\u662f\u53ef\u590d\u7528\u7684\u80fd\u529b\u5b9a\u4e49\u2014\u2014\u4ee3\u7801\u3001\u914d\u7f6e\u548c\u4e0a\u4e0b\u6587\u6253\u5305\u5728\u4e00\u8d77\u3002\u53ea\u9700\u7f16\u5199\u4e00\u6b21\uff0c\u56e2\u961f\u4e2d\u6bcf\u4e2a Agent \u90fd\u80fd\u4f7f\u7528\u3002\u4f60\u7684\u6280\u80fd\u5e93\u968f\u65f6\u95f4\u4e0d\u65ad\u79ef\u7d2f\u3002",
cards: [
{
title: "\u53ef\u590d\u7528\u7684\u6280\u80fd\u5b9a\u4e49",
description:
"\u5c06\u77e5\u8bc6\u5c01\u88c5\u6210\u4efb\u4f55 智能体 \u90fd\u80fd\u6267\u884c\u7684\u6280\u80fd\u3002\u90e8\u7f72\u5230\u6d4b\u8bd5\u73af\u5883\u3001\u7f16\u5199\u8fc1\u79fb\u3001\u5ba1\u67e5 PR\u2014\u2014\u5168\u90e8\u4ee3\u7801\u5316\u3002",
"\u5c06\u77e5\u8bc6\u5c01\u88c5\u6210\u4efb\u4f55 Agent \u90fd\u80fd\u6267\u884c\u7684\u6280\u80fd\u3002\u90e8\u7f72\u5230\u6d4b\u8bd5\u73af\u5883\u3001\u7f16\u5199\u8fc1\u79fb\u3001\u5ba1\u67e5 PR\u2014\u2014\u5168\u90e8\u4ee3\u7801\u5316\u3002",
},
{
title: "\u5168\u56e2\u961f\u5171\u4eab",
description:
"\u4e00\u4e2a\u4eba\u7684\u6280\u80fd\u5c31\u662f\u6bcf\u4e2a 智能体 \u7684\u6280\u80fd\u3002\u7f16\u5199\u4e00\u6b21\uff0c\u5168\u56e2\u961f\u53d7\u76ca\u3002",
"\u4e00\u4e2a\u4eba\u7684\u6280\u80fd\u5c31\u662f\u6bcf\u4e2a Agent \u7684\u6280\u80fd\u3002\u7f16\u5199\u4e00\u6b21\uff0c\u5168\u56e2\u961f\u53d7\u76ca\u3002",
},
{
title: "\u590d\u5408\u589e\u957f",
description:
"\u7b2c 1 \u5929\uff1a\u4f60\u6559 智能体 \u90e8\u7f72\u3002\u7b2c 30 \u5929\uff1a\u6bcf\u4e2a 智能体 \u90fd\u80fd\u90e8\u7f72\u3001\u5199\u6d4b\u8bd5\u3001\u505a\u4ee3\u7801\u5ba1\u67e5\u3002\u56e2\u961f\u80fd\u529b\u6307\u6570\u7ea7\u589e\u957f\u3002",
"\u7b2c 1 \u5929\uff1a\u4f60\u6559 Agent \u90e8\u7f72\u3002\u7b2c 30 \u5929\uff1a\u6bcf\u4e2a Agent \u90fd\u80fd\u90e8\u7f72\u3001\u5199\u6d4b\u8bd5\u3001\u505a\u4ee3\u7801\u5ba1\u67e5\u3002\u56e2\u961f\u80fd\u529b\u6307\u6570\u7ea7\u589e\u957f\u3002",
},
],
},
@@ -95,7 +94,7 @@ export function createZhDict(allowSignup: boolean): LandingDict {
label: "\u8fd0\u884c\u65f6",
title: "\u4e00\u4e2a\u63a7\u5236\u53f0\u7ba1\u7406\u6240\u6709\u7b97\u529b",
description:
"\u672c\u5730\u5b88\u62a4\u8fdb\u7a0b\u548c\u4e91\u7aef\u8fd0\u884c\u65f6\uff0c\u5728\u540c\u4e00\u4e2a\u9762\u677f\u4e2d\u7ba1\u7406\u3002\u5b9e\u65f6\u76d1\u63a7\u5728\u7ebf/\u79bb\u7ebf\u72b6\u6001\u3001\u4f7f\u7528\u91cf\u56fe\u8868\u548c\u6d3b\u52a8\u70ed\u529b\u56fe\u3002\u81ea\u52a8\u68c0\u6d4b\u672c\u673a\u5df2\u5b89\u88c5\u7684 11 \u6b3e\u652f\u6301\u7684 AI \u7f16\u7a0b\u5de5\u5177\u3002",
"\u672c\u5730\u5b88\u62a4\u8fdb\u7a0b\u548c\u4e91\u7aef\u8fd0\u884c\u65f6\uff0c\u5728\u540c\u4e00\u4e2a\u9762\u677f\u4e2d\u7ba1\u7406\u3002\u5b9e\u65f6\u76d1\u63a7\u5728\u7ebf/\u79bb\u7ebf\u72b6\u6001\u3001\u4f7f\u7528\u91cf\u56fe\u8868\u548c\u6d3b\u52a8\u70ed\u529b\u56fe\u3002\u81ea\u52a8\u68c0\u6d4b\u672c\u5730 CLI\u2014\u2014\u63d2\u4e0a\u5c31\u7528\u3002",
cards: [
{
title: "\u7edf\u4e00\u8fd0\u884c\u65f6\u9762\u677f",
@@ -108,9 +107,9 @@ export function createZhDict(allowSignup: boolean): LandingDict {
"\u5728\u7ebf/\u79bb\u7ebf\u72b6\u6001\u3001\u4f7f\u7528\u91cf\u56fe\u8868\u548c\u6d3b\u52a8\u70ed\u529b\u56fe\u3002\u968f\u65f6\u4e86\u89e3\u4f60\u7684\u7b97\u529b\u5728\u505a\u4ec0\u4e48\u3002",
},
{
title: "\u9996\u6b21\u542f\u52a8\u81ea\u52a8\u6ce8\u518c",
title: "\u81ea\u52a8\u68c0\u6d4b\u4e0e\u5373\u63d2\u5373\u7528",
description:
"Multica \u626b\u63cf\u672c\u673a\u7684 11 \u6b3e\u652f\u6301\u7684 AI \u7f16\u7a0b\u5de5\u5177\u2014\u2014Claude Code\u3001Codex\u3001Cursor\u3001Copilot\u3001Gemini\u3001Hermes\u3001Kimi\u3001Kiro CLI\u3001OpenCode\u3001OpenClaw\u3001Pi\u2014\u2014\u5e76\u4e3a\u6bcf\u6b3e\u5df2\u5b89\u88c5\u7684\u5de5\u5177\u6ce8\u518c\u4e00\u4e2a\u8fd0\u884c\u65f6\u3002",
"Multica \u81ea\u52a8\u68c0\u6d4b Claude Code\u3001Codex\u3001OpenClaw \u548c OpenCode \u7b49\u53ef\u7528 CLI\u3002\u8fde\u63a5\u4e00\u53f0\u673a\u5668\uff0c\u5373\u53ef\u5f00\u59cb\u5de5\u4f5c\u3002",
},
],
},
@@ -130,17 +129,17 @@ export function createZhDict(allowSignup: boolean): LandingDict {
{
title: "\u5b89\u88c5 CLI \u5e76\u8fde\u63a5\u4f60\u7684\u673a\u5668",
description:
"运行 multica setup——它会引导你完成 OAuth 登录、启动守护进程、并扫描 11 款支持的 AI 编程工具Claude Code、Codex、Cursor、Copilot、Gemini、Hermes、Kimi、Kiro CLI、OpenCode、OpenClaw、Pi。本机已安装的工具会被自动注册成运行时。",
"运行 multica setup 一键完成配置、认证和启动守护进程自动检测你机器上的 Claude Code、Codex、OpenClaw 和 OpenCode——插上就用。",
},
{
title: "\u521b\u5efa\u4f60\u7684\u7b2c\u4e00\u4e2a 智能体",
title: "\u521b\u5efa\u4f60\u7684\u7b2c\u4e00\u4e2a Agent",
description:
"\u7ed9\u5b83\u8d77\u4e2a\u540d\u5b57\uff0c\u5199\u597d\u6307\u4ee4\uff0c\u9644\u52a0\u6280\u80fd\uff0c\u8bbe\u7f6e\u89e6\u53d1\u5668\u3002\u9009\u62e9\u5b83\u4f55\u65f6\u6fc0\u6d3b\uff1a\u88ab\u6307\u6d3e\u65f6\u3001\u6709\u8bc4\u8bba\u65f6\u3001\u88ab @\u63d0\u53ca\u65f6\u3002",
},
{
title: "\u6307\u6d3e\u4e00\u4e2a Issue \u5e76\u89c2\u5bdf\u5b83\u5de5\u4f5c",
description:
"\u4ece\u6307\u6d3e\u4eba\u4e0b\u62c9\u83dc\u5355\u4e2d\u9009\u62e9\u4f60\u7684 智能体\u2014\u2014\u5c31\u50cf\u6307\u6d3e\u7ed9\u540c\u4e8b\u4e00\u6837\u3002\u4efb\u52a1\u81ea\u52a8\u5165\u961f\u3001\u9886\u53d6\u3001\u6267\u884c\u3002\u5b9e\u65f6\u89c2\u770b\u8fdb\u5ea6\u3002",
"\u4ece\u6307\u6d3e\u4eba\u4e0b\u62c9\u83dc\u5355\u4e2d\u9009\u62e9\u4f60\u7684 Agent\u2014\u2014\u5c31\u50cf\u6307\u6d3e\u7ed9\u540c\u4e8b\u4e00\u6837\u3002\u4efb\u52a1\u81ea\u52a8\u5165\u961f\u3001\u9886\u53d6\u3001\u6267\u884c\u3002\u5b9e\u65f6\u89c2\u770b\u8fdb\u5ea6\u3002",
},
],
cta: "\u5f00\u59cb\u4f7f\u7528",
@@ -153,7 +152,7 @@ export function createZhDict(allowSignup: boolean): LandingDict {
headlineLine1: "\u5f00\u6e90",
headlineLine2: "\u4e3a\u6240\u6709\u4eba\u3002",
description:
"Multica \u5b8c\u5168\u5f00\u6e90\u3002\u5ba1\u67e5\u6bcf\u4e00\u884c\u4ee3\u7801\uff0c\u6309\u4f60\u7684\u65b9\u5f0f\u81ea\u6258\u7ba1\uff0c\u5851\u9020\u4eba\u7c7b + 智能体 \u534f\u4f5c\u7684\u672a\u6765\u3002",
"Multica \u5b8c\u5168\u5f00\u6e90\u3002\u5ba1\u67e5\u6bcf\u4e00\u884c\u4ee3\u7801\uff0c\u6309\u4f60\u7684\u65b9\u5f0f\u81ea\u6258\u7ba1\uff0c\u5851\u9020\u4eba\u7c7b + Agent \u534f\u4f5c\u7684\u672a\u6765\u3002",
cta: "\u5728 GitHub \u4e0a Star",
highlights: [
{
@@ -164,17 +163,17 @@ export function createZhDict(allowSignup: boolean): LandingDict {
{
title: "\u65e0\u4f9b\u5e94\u5546\u9501\u5b9a",
description:
"\u81ea\u5e26 LLM \u63d0\u4f9b\u5546\u3001\u66f4\u6362 智能体 \u540e\u7aef\u3001\u6269\u5c55 API\u3002\u4f60\u62e5\u6709\u6574\u4e2a\u6280\u672f\u6808\u7684\u63a7\u5236\u6743\u3002",
"\u81ea\u5e26 LLM \u63d0\u4f9b\u5546\u3001\u66f4\u6362 Agent \u540e\u7aef\u3001\u6269\u5c55 API\u3002\u4f60\u62e5\u6709\u6574\u4e2a\u6280\u672f\u6808\u7684\u63a7\u5236\u6743\u3002",
},
{
title: "\u9ed8\u8ba4\u900f\u660e",
description:
"\u6bcf\u4e00\u884c\u4ee3\u7801\u90fd\u53ef\u5ba1\u8ba1\u3002\u786e\u5207\u4e86\u89e3\u4f60\u7684 智能体 \u5982\u4f55\u505a\u51b3\u7b56\u3001\u4efb\u52a1\u5982\u4f55\u8def\u7531\u3001\u6570\u636e\u6d41\u5411\u4f55\u65b9\u3002",
"\u6bcf\u4e00\u884c\u4ee3\u7801\u90fd\u53ef\u5ba1\u8ba1\u3002\u786e\u5207\u4e86\u89e3\u4f60\u7684 Agent \u5982\u4f55\u505a\u51b3\u7b56\u3001\u4efb\u52a1\u5982\u4f55\u8def\u7531\u3001\u6570\u636e\u6d41\u5411\u4f55\u65b9\u3002",
},
{
title: "\u793e\u533a\u9a71\u52a8",
description:
"\u4e0e\u793e\u533a\u4e00\u8d77\u5efa\u8bbe\uff0c\u800c\u4e0d\u4ec5\u4ec5\u662f\u4e3a\u793e\u533a\u5efa\u8bbe\u3002\u8d21\u732e\u6280\u80fd\u3001\u96c6\u6210\u548c 智能体 \u540e\u7aef\uff0c\u8ba9\u6bcf\u4e2a\u4eba\u53d7\u76ca\u3002",
"\u4e0e\u793e\u533a\u4e00\u8d77\u5efa\u8bbe\uff0c\u800c\u4e0d\u4ec5\u4ec5\u662f\u4e3a\u793e\u533a\u5efa\u8bbe\u3002\u8d21\u732e\u6280\u80fd\u3001\u96c6\u6210\u548c Agent \u540e\u7aef\uff0c\u8ba9\u6bcf\u4e2a\u4eba\u53d7\u76ca\u3002",
},
],
},
@@ -184,9 +183,9 @@ export function createZhDict(allowSignup: boolean): LandingDict {
headline: "\u95ee\u4e0e\u7b54\u3002",
items: [
{
question: "Multica \u652f\u6301\u54ea\u4e9b\u7f16\u7801 智能体\uff1f",
question: "Multica \u652f\u6301\u54ea\u4e9b\u7f16\u7801 Agent\uff1f",
answer:
"Multica \u5f00\u7bb1\u5373\u7528\u652f\u6301 11 \u6b3e AI \u7f16\u7a0b\u5de5\u5177\uff1aClaude Code\u3001Codex\u3001Cursor\u3001Copilot\u3001Gemini\u3001Hermes\u3001Kimi\u3001Kiro CLI\u3001OpenCode\u3001OpenClaw\u3001Pi\u3002\u5b88\u62a4\u8fdb\u7a0b\u4f1a\u81ea\u52a8\u68c0\u6d4b\u672c\u673a\u5df2\u5b89\u88c5\u7684 CLI \u5e76\u4e3a\u6bcf\u6b3e\u6ce8\u518c\u4e00\u4e2a\u8fd0\u884c\u65f6\u3002\u56e0\u4e3a\u5f00\u6e90\uff0c\u4f60\u4e5f\u53ef\u4ee5\u81ea\u5df1\u6dfb\u52a0\u540e\u7aef\u3002",
"Multica \u76ee\u524d\u5f00\u7bb1\u5373\u7528\u652f\u6301 Claude Code\u3001Codex\u3001OpenClaw \u548c OpenCode\u3002\u5b88\u62a4\u8fdb\u7a0b\u81ea\u52a8\u68c0\u6d4b\u4f60\u5b89\u88c5\u7684 CLI\u3002\u56e0\u4e3a\u5f00\u6e90\uff0c\u4f60\u4e5f\u53ef\u4ee5\u81ea\u5df1\u6dfb\u52a0\u540e\u7aef\u3002",
},
{
question: "\u9700\u8981\u81ea\u6258\u7ba1\u5417\uff0c\u8fd8\u662f\u6709\u4e91\u7248\u672c\uff1f",
@@ -195,31 +194,31 @@ export function createZhDict(allowSignup: boolean): LandingDict {
},
{
question:
"\u8fd9\u548c\u76f4\u63a5\u7528\u7f16\u7801 智能体 \u6709\u4ec0\u4e48\u533a\u522b\uff1f",
"\u8fd9\u548c\u76f4\u63a5\u7528\u7f16\u7801 Agent \u6709\u4ec0\u4e48\u533a\u522b\uff1f",
answer:
"\u7f16\u7801 智能体 \u64c5\u957f\u6267\u884c\u3002Multica \u6dfb\u52a0\u7684\u662f\u7ba1\u7406\u5c42\uff1a\u4efb\u52a1\u961f\u5217\u3001\u56e2\u961f\u534f\u4f5c\u3001\u6280\u80fd\u590d\u7528\u3001\u8fd0\u884c\u65f6\u76d1\u63a7\uff0c\u4ee5\u53ca\u6bcf\u4e2a 智能体 \u5728\u505a\u4ec0\u4e48\u7684\u7edf\u4e00\u89c6\u56fe\u3002\u628a\u5b83\u60f3\u8c61\u6210\u4f60\u7684 智能体 \u7684\u9879\u76ee\u7ecf\u7406\u3002",
"\u7f16\u7801 Agent \u64c5\u957f\u6267\u884c\u3002Multica \u6dfb\u52a0\u7684\u662f\u7ba1\u7406\u5c42\uff1a\u4efb\u52a1\u961f\u5217\u3001\u56e2\u961f\u534f\u4f5c\u3001\u6280\u80fd\u590d\u7528\u3001\u8fd0\u884c\u65f6\u76d1\u63a7\uff0c\u4ee5\u53ca\u6bcf\u4e2a Agent \u5728\u505a\u4ec0\u4e48\u7684\u7edf\u4e00\u89c6\u56fe\u3002\u628a\u5b83\u60f3\u8c61\u6210\u4f60\u7684 Agent \u7684\u9879\u76ee\u7ecf\u7406\u3002",
},
{
question: "智能体 \u80fd\u81ea\u4e3b\u5904\u7406\u957f\u65f6\u95f4\u4efb\u52a1\u5417\uff1f",
question: "Agent \u80fd\u81ea\u4e3b\u5904\u7406\u957f\u65f6\u95f4\u4efb\u52a1\u5417\uff1f",
answer:
"\u53ef\u4ee5\u3002Multica \u7ba1\u7406\u5b8c\u6574\u7684\u4efb\u52a1\u751f\u547d\u5468\u671f\u2014\u2014\u5165\u961f\u3001\u9886\u53d6\u3001\u6267\u884c\u3001\u5b8c\u6210\u6216\u5931\u8d25\u3002智能体 \u4e3b\u52a8\u62a5\u544a\u963b\u585e\u5e76\u5b9e\u65f6\u63a8\u9001\u8fdb\u5ea6\u3002\u4f60\u53ef\u4ee5\u968f\u65f6\u67e5\u770b\uff0c\u4e5f\u53ef\u4ee5\u8ba9\u5b83\u4eec\u8fd0\u884c\u6574\u665a\u3002",
"\u53ef\u4ee5\u3002Multica \u7ba1\u7406\u5b8c\u6574\u7684\u4efb\u52a1\u751f\u547d\u5468\u671f\u2014\u2014\u5165\u961f\u3001\u9886\u53d6\u3001\u6267\u884c\u3001\u5b8c\u6210\u6216\u5931\u8d25\u3002Agent \u4e3b\u52a8\u62a5\u544a\u963b\u585e\u5e76\u5b9e\u65f6\u63a8\u9001\u8fdb\u5ea6\u3002\u4f60\u53ef\u4ee5\u968f\u65f6\u67e5\u770b\uff0c\u4e5f\u53ef\u4ee5\u8ba9\u5b83\u4eec\u8fd0\u884c\u6574\u665a\u3002",
},
{
question: "\u6211\u7684\u4ee3\u7801\u5b89\u5168\u5417\uff1f智能体 \u5728\u54ea\u91cc\u6267\u884c\uff1f",
question: "\u6211\u7684\u4ee3\u7801\u5b89\u5168\u5417\uff1fAgent \u5728\u54ea\u91cc\u6267\u884c\uff1f",
answer:
"智能体 \u5728\u4f60\u7684\u673a\u5668\uff08\u672c\u5730\u5b88\u62a4\u8fdb\u7a0b\uff09\u6216\u4f60\u81ea\u5df1\u7684\u4e91\u57fa\u7840\u8bbe\u65bd\u4e0a\u6267\u884c\u3002\u4ee3\u7801\u6c38\u8fdc\u4e0d\u4f1a\u7ecf\u8fc7 Multica \u670d\u52a1\u5668\u3002\u5e73\u53f0\u53ea\u534f\u8c03\u4efb\u52a1\u72b6\u6001\u548c\u5e7f\u64ad\u4e8b\u4ef6\u3002",
"Agent \u5728\u4f60\u7684\u673a\u5668\uff08\u672c\u5730\u5b88\u62a4\u8fdb\u7a0b\uff09\u6216\u4f60\u81ea\u5df1\u7684\u4e91\u57fa\u7840\u8bbe\u65bd\u4e0a\u6267\u884c\u3002\u4ee3\u7801\u6c38\u8fdc\u4e0d\u4f1a\u7ecf\u8fc7 Multica \u670d\u52a1\u5668\u3002\u5e73\u53f0\u53ea\u534f\u8c03\u4efb\u52a1\u72b6\u6001\u548c\u5e7f\u64ad\u4e8b\u4ef6\u3002",
},
{
question: "\u6211\u53ef\u4ee5\u8fd0\u884c\u591a\u5c11\u4e2a 智能体\uff1f",
question: "\u6211\u53ef\u4ee5\u8fd0\u884c\u591a\u5c11\u4e2a Agent\uff1f",
answer:
"\u53d6\u51b3\u4e8e\u4f60\u7684\u786c\u4ef6\u3002\u6bcf\u4e2a 智能体 \u6709\u53ef\u914d\u7f6e\u7684\u5e76\u53d1\u9650\u5236\uff0c\u4f60\u53ef\u4ee5\u8fde\u63a5\u591a\u53f0\u673a\u5668\u4f5c\u4e3a\u8fd0\u884c\u65f6\u3002\u5f00\u6e90\u7248\u672c\u6ca1\u6709\u4efb\u4f55\u4eba\u4e3a\u9650\u5236\u3002",
"\u53d6\u51b3\u4e8e\u4f60\u7684\u786c\u4ef6\u3002\u6bcf\u4e2a Agent \u6709\u53ef\u914d\u7f6e\u7684\u5e76\u53d1\u9650\u5236\uff0c\u4f60\u53ef\u4ee5\u8fde\u63a5\u591a\u53f0\u673a\u5668\u4f5c\u4e3a\u8fd0\u884c\u65f6\u3002\u5f00\u6e90\u7248\u672c\u6ca1\u6709\u4efb\u4f55\u4eba\u4e3a\u9650\u5236\u3002",
},
],
},
footer: {
tagline:
"\u4eba\u7c7b + 智能体 \u56e2\u961f\u7684\u9879\u76ee\u7ba1\u7406\u3002\u5f00\u6e90\u3001\u53ef\u81ea\u6258\u7ba1\u3001\u4e3a\u672a\u6765\u7684\u5de5\u4f5c\u65b9\u5f0f\u800c\u5efa\u3002",
"\u4eba\u7c7b + Agent \u56e2\u961f\u7684\u9879\u76ee\u7ba1\u7406\u3002\u5f00\u6e90\u3001\u53ef\u81ea\u6258\u7ba1\u3001\u4e3a\u672a\u6765\u7684\u5de5\u4f5c\u65b9\u5f0f\u800c\u5efa\u3002",
cta: "\u5f00\u59cb\u4f7f\u7528",
groups: {
product: {
@@ -284,187 +283,6 @@ export function createZhDict(allowSignup: boolean): LandingDict {
fixes: "问题修复",
},
entries: [
{
version: "0.2.31",
date: "2026-05-12",
title: "GitHub 集成、聊天附件与 Issue 定位优化",
changes: [],
features: [
"接入 GitHub 后,关联的 Pull Request 会显示在 Multica Issue 中,状态会同步到 Multica关闭 PR 后会自动关闭对应 Issue",
"聊天消息支持添加文件附件和图片预览",
"Agent 和 runtime 可以设置公开或私有,方便控制团队可见范围",
"停止单个 agent 任务前会先弹出确认,避免误操作",
"新增 GitHub 集成文档,覆盖托管版和自托管配置",
],
improvements: [
"打开 Issue 链接时,会更稳定地定位到指定评论或动态",
"很长的 Issue 时间线滚动更顺畅",
"反馈入口更明确地引导用户到 GitHub 参与讨论和反馈",
"自托管 Caddy 配置文档补充实时连接要求",
"Linux 桌面端安装包恢复显示 Multica 应用图标",
],
fixes: [
"下载附件时保留原始文件名",
"本地附件访问更稳定,上传按钮会等文件准备好后再可用",
"创建 Issue 弹窗里的文本框高度显示正确",
"Runtime 文档入口跳转到正确页面",
],
},
{
version: "0.2.30",
date: "2026-05-11",
title: "Issue 内 Mermaid、Runtime 时区聚合与离开 Workspace 自动吊销",
changes: [],
features: [
"Issue 描述内联渲染 Mermaid 图表",
"Sub-issue 行支持就地切换状态与 assignee并支持跨行批量选中",
"Token 用量按每个 runtime 自己的时区聚合,每日 rollup 与本地日期对齐",
"私有 Agent 通过 `allowed_principals` 判定可见性,权限粒度更细",
"成员离开或被移出 workspace 时,自动吊销其名下的 runtime",
"对未维护的模型支持自定义 token 价格,使用量真实反映成本",
"Landing 页面 header 加入 Changelog 入口",
],
improvements: [
"服务端删除 runtime 时daemon 端自我修复,不再留下僵尸条目",
"Chat 与评论输入框统一使用 `Mod+Enter` 发送",
"Copilot CLI 模型目录补齐正确的 dotted ID",
"Copilot 失败详情直接在 UI 中透出,不再只是一个通用错误",
"Daemon brief 直接内联进 system prompt针对需要的 provider 生效",
"Realtime WebSocket 放行同源升级,移动端与 CLI 可正常握手",
],
fixes: [
"Recent issues 列表不再跨 workspace 串扰",
"CloudFront 附件下载链接在点击时重新签名,过期预览的问题修复",
"所有 provider 的 Windows reply 模板改用 `--content-file`,非 ASCII 内容不再丢失",
"Daemon 抑制 Windows 上多余的 git 控制台弹窗",
"Pi 插件工具不再被硬编码的 `--tools` allowlist 过滤掉",
"Inbox 在 issue 加载完成后再滚动到目标评论",
"`autopilot create/update` 允许 `--mode run_only`",
"Changelog header 链接样式对齐 GitHub ghost button",
"OpenAI Codex / GPT 模型价格补齐,使用成本不再显示为 $0",
],
},
{
version: "0.2.29",
date: "2026-05-09",
title: "Quick Create 项目选择器、评论可折叠与 Timeline 性能优化",
changes: [],
features: [
"Quick Create 支持选择 project并记住上一次的选项",
"评论 thread 支持解决并折叠,长讨论看起来更清爽",
"Issue Live Banner 显示 agent 队列中等待执行的任务",
"失败 / 取消的任务可以在 Execution Log 一键重跑",
"Agent Create 弹窗新增放大按钮,长描述编辑更舒服",
],
improvements: [
"Issue Timeline 不再因每个 WS 事件做完整 re-render长 Issue 滚动更顺",
"Editor 跳过对超大文本 / JSON 粘贴的解析,避免卡顿",
"Autopilot 在 assignee runtime 离线时跳过 dispatch避免空跑",
"Inbox 自动归档处于终态的 `task_failed` 行",
"Hermes 把 agent instructions 直接随请求内联传入",
"Timeline / Comment 改为纯客户端虚拟化,去掉服务端分页",
"Reserved slugs 前后端共享同一份 JSONCI 守住漂移",
"ACP 错误消息现在带上 JSON-RPC 的 `error.data` 字段,排错更友好",
],
fixes: [
"429 / 余额不足的 agent run 现在被标记为 `failed` 而不是 `completed`",
"因 poisoned image 卡死的 agent session 可以恢复issue 不再卡住",
"`pi --list-models` 表格格式可被正确解析,模型发现恢复",
"`pi` colon-to-slash 归一化只作用于 legacy 格式,避免误伤新格式",
"`kiro` 与 `kimi` 加入 inline-system-prompt provider 白名单",
"Priority Dropdown 徽章颜色对齐 PriorityIcon 的 semantic token",
"Agent 单行长消息可正常展开",
"桌面端复制 issue link 使用当前连接环境,不再硬编码 localhost",
"移动端 WebSocket 在没有 cookie 的情况下也能握手",
"创建 workspace 时校验保留字slug 错误提示已 i18n",
"Timeline 在 falsy prop 切换时正确同步 around 状态",
"DropdownMenu 弹层尺寸跟随内容",
],
},
{
version: "0.2.28",
date: "2026-05-08",
title: "Daemon 磁盘占用 CLI、Timeline 打磨与任务用量聚合提速",
changes: [],
features: [
"新增 `multica daemon disk-usage` CLI按 task / workspace 维度查看磁盘占用",
"Skill Picker 弹窗新增搜索框Agent 设置里挑技能更快",
"Daemon GC 覆盖扩展到 chat、autopilot、quick-create 任务",
"Issue 详情页面包屑直接显示 MUL-xxxx identifier",
],
improvements: [
"Timeline 分页 size 提到 50评论与活动按池独立 keyset 游标,长 Issue 翻页更顺",
"Show older / newer 按钮在边界场景也能正确出现,且视觉上更明显是可点击的",
"服务端 `task_usage` 聚合到每日 rollup 表DB 负载明显下降",
"Daemon health check 在 repo 查询时不再阻塞,始终保持响应",
"Runtime 统计排除已归档的 agent活跃数字更准",
],
fixes: [
"Linux 上 daemon self-restart 改走 `brew prefix` 软链Homebrew Cellar 删除后不再让 runtime 失联",
"CLI 短 ID 现在可以正确路由,复制粘贴的短前缀不再 404",
"Windows 上非 ASCII 字符评论 / 描述输入新增 `--content-file` / `--description-file`",
"Windows / Linux 桌面端用 Multica asterisk 替换 Electron 默认占位图标",
"Timeline 中孤立的 reply 现在会被正确捞回展示",
"Timeline 评论分页预算不再把 activity 算进去,避免活动多时挤掉真实评论",
],
},
{
version: "0.2.27",
date: "2026-05-07",
title: "Chat 更顺手Skill 支持 GitHub 导入,稳定性更好",
changes: [],
features: [
"支持直接通过 GitHub 链接导入可复用 Skill",
],
improvements: [
"Chat 和 Inbox 更顺手,历史更清晰,复制回复更方便,归档后能更快处理下一项",
"Issue 操作会保留更多上下文,例如更容易找到对应本地文件夹,子 Issue 也会带上正确的项目和状态",
"Autopilot 连续失败后会自动暂停,异常自动化更容易发现和修复",
],
fixes: [
"中文输入、桌面端升级、长 Issue 时间线和实时状态展示更稳定",
],
},
{
version: "0.2.26",
date: "2026-05-06",
title: "i18n 全量铺开、长 Issue Timeline 提速与系统通知开关",
changes: [],
features: [
"Web 端完成简中翻译21 个命名空间齐全,语言偏好按账号同步",
"Settings 新增 System Notifications 开关",
"支持删除 Chat 会话History 面板移至 chat header",
"Runtime 在线判断改走 RedisDB 兜底)",
"Desktop 支持加载 runtime 自托管配置",
"CLI 新增 `--assignee-id` / `--to-id` / `--user-id`,重名时定位更准",
],
improvements: [
"Settings 的 Appearance Tab 改名为 Preferences并把当前激活的 Tab 反映到 URL深链可分享",
"长 Issue 打开秒开 —— Timeline 改为基于游标的 keyset 分页,重复的 `task_completed` / `task_failed` 活动条目合并展示",
"Runtime poll 与 heartbeat 调度按 runtime 隔离,单个忙碌 runtime 不再拖慢其他",
"CLI 更新请求落 Redisserver 重启也不丢",
"Runtime 用量统计窗口由 180 天收窄到 14 天,降低查询压力",
"项目列表返回 `resource_count` 摘要,不再内联全部 resource响应体更小",
"404 页面重新设计,并修复 No-Access 重定向死循环",
"Quick Create 对 git-describe 类 daemon 跳过 CLI 版本闸",
"CI 启用 lint 强制门禁,历史 lint 债同步清理完毕",
],
fixes: [
"Task 在服务端被删后daemon 主动取消正在运行的 agent避免孤儿进程",
"复用 execenv 时刷新陈旧的 Codex `auth.json`,修复偶发鉴权失败",
"`issue_id` 为空时拒绝写入 `.gc_meta.json`",
"跨 ACP 后端的 session/resume 信任 agent 自报的 session id修复串号问题",
"OpenCode 的 skills 写到 `.opencode/skills/` 让其原生发现",
"Daemon 对 task-not-found 的 404 语义在 server 和最终 guard 双重收紧",
"侧边栏中失效的 Pin 自动取消挂载",
"项目详情页桌面端与移动端侧边栏状态独立保存",
"Runtime 详情页隐藏已归档的 agent",
"Add Resource 列表中已挂载的 repo 显示 URL tooltip空项目页加上 New Issue 入口",
"S3 公开 URL 携带 region修复跨区访问失败",
"Windows 安装器修正版本号解析与 checksum 解码",
"Quick Create 提交按钮去掉重复的快捷键提示",
],
},
{
version: "0.2.24",
date: "2026-05-03",

View File

@@ -22,8 +22,6 @@ function NavigationProviderInner({
back: router.back,
pathname,
searchParams: new URLSearchParams(searchParams.toString()),
getShareableUrl: (path: string) =>
typeof window === "undefined" ? path : window.location.origin + path,
};
return <NavigationProvider value={adapter}>{children}</NavigationProvider>;

View File

@@ -58,11 +58,9 @@ services:
APP_ENV: ${APP_ENV:-production}
MULTICA_DEV_VERIFICATION_CODE: ${MULTICA_DEV_VERIFICATION_CODE:-}
MULTICA_APP_URL: ${MULTICA_APP_URL:-http://localhost:3000}
ALLOW_SIGNUP: ${ALLOW_SIGNUP:-true}
ALLOWED_EMAILS: ${ALLOWED_EMAILS:-}
ALLOWED_EMAIL_DOMAINS: ${ALLOWED_EMAIL_DOMAINS:-}
GITHUB_APP_SLUG: ${GITHUB_APP_SLUG:-}
GITHUB_WEBHOOK_SECRET: ${GITHUB_WEBHOOK_SECRET:-}
ALLOW_SIGNUP: ${ALLOW_SIGNUP:-true}
ALLOWED_EMAILS: ${ALLOWED_EMAILS:-}
ALLOWED_EMAIL_DOMAINS: ${ALLOWED_EMAIL_DOMAINS:-}
restart: unless-stopped
frontend:

View File

@@ -1,555 +0,0 @@
# Agent 快速创建 — 三阶段实施计划
> Status: Draft (设计阶段,未动工)
> Owner: TBD
> Last updated: 2026-05-13
## TL;DR
- **目标**:降低用户创建 Agent 的门槛,从「手工填表 + 一个个挑 skill」演进到「一键模板」「AI 推荐 skill」「AI 直接创建 agent」三档
- **三阶段**:Template(必做、独立)→ Skill Finder(AI 推荐 skill)→ AI Create Agent(AI 直接创建)
- **架构关键**:Phase 2/3 复用现有 Quick-create Issue 基础设施(派任务给 agent + tool calling + inbox 通知),不引入新 LLM 调用路径
- **不需要新基础设施**:无 SSE、无 server-side LLM、无新 WS channel
- **soft blocker**:两处 routine 重构(`createSkillWithFiles` TX 拆分、skill 同名 find-or-create)
- **不做**:接入 Anthropic 官方 marketplace(plugin 体系跟单体 skill 形态不匹配)、接入 ClawHub(战略对位错误 + 实际使用率低,见 §5)
---
## 1. 背景与目标
### 1.1 当前现状
当前用户创建一个 Agent 需要走的步骤:
1.`/agents` 页面 → 点 "Create Agent"
2. 手工填 name / description / runtime / model
3. 手工写 instructions(空白文本框,用户自己思考措辞)
4. 创建完后进 Agent 详情页 → 点 "Add Skill" → 一个一个挑 skill 关联
5. 如果 workspace 还没有需要的 skill,得先去别处建/导入 skill(`POST /api/skills/import` 支持 skills.sh / GitHub / ClawHub 三种 URL)
**痛点**:
- 用户得**预先知道**自己需要哪些 skill,这要求他对 skill 生态熟悉
- 写 instructions 是空白文本编辑,大多数用户不知道写什么
- 跨多页操作,体感上"创建一个能用的 Agent"是个项目,不是个动作
### 1.2 三阶段方案
| Phase | 提供给用户的能力 | 是否需要 AI | 独立可发布 |
|---|---|---|---|
| **1. Template** | 选模板 → 自动 import 模板带的 skill + 预填 instructions | 否 | ✅ |
| **2. Skill Finder** | 描述需求 → AI 推荐 skill 列表 → 一键导入到 workspace | ✅ | ✅(独立功能,任何场景都能用) |
| **3. AI Create Agent** | 描述需求 → AI 自己 find skill + 写 instructions + 创建 agent | ✅ | 依赖 Phase 2 |
每个 phase **本身有用户价值**,不需要等下一个 phase 才能用:
- Phase 1 用户能用模板创建 agent,即使后两阶段没做
- Phase 2 用户能在任何地方"用 AI 找 skill"(创建 agent 时、给现有 agent 加 skill 时、单纯逛 skill 时)
- Phase 3 是 1+2 的组合
### 1.3 不在范围内
明确不做的事(及理由,见 §5):
- 接入 Anthropic 官方 plugin marketplace(`anthropics/claude-plugins-official`)
- 接入 ClawHub 的"发现/搜索"层(import 路径已经存在,但是死代码,建议下线)
- 让 AI 直接装 skill 到用户本地 `~/.claude/skills/`(npx skills CLI 行为)
- Server-side LLM 调用(后端目前没有 LLM SDK,这条路引入新基础设施,而 Quick-create 模式可以避开)
---
## 2. 关键概念回顾
> 这一节给没参与前期讨论的同事看。已经熟悉 skill 系统的可跳到 §3。
### 2.1 Skill 是什么
Skill 是一个**按需加载的能力包**,本质是 SKILL.md 文件 + 可选附件。Anthropic 2025-12 把它发布为开放标准(agentskills.io),Cursor / OpenAI / GitHub Copilot 等都已采纳——同一份 SKILL.md 跨多个 agent 工具都能用。
每个 runtime(Claude Code / Cursor / Codex 等)启动时**自动扫**自己约定的目录(`~/.claude/skills/``.cursor/skills/` 等),读 SKILL.md 的 frontmatter 形成"我手上有这些 skill"的清单注入 system prompt。具体 skill 正文只在被触发时才进 context。
### 2.2 Multica 的 Skill 数据模型
3 张表(migration `008_structured_skills.up.sql`):
| 表 | 关键字段 |
|---|---|
| `skill` | `id, workspace_id, name, description, content (=SKILL.md 正文), config (含 origin 元数据)` |
| `skill_file` | `skill_id, path, content`(SKILL.md 的附件,如 examples/*.md、scripts/*.py) |
| `agent_skill` | `agent_id, skill_id`(M:N 关联) |
**关键约束**:`UNIQUE(workspace_id, name)` — 同 workspace 内 skill 名字必须唯一。
### 2.3 Skill 流转链路(数据库 → runtime)
任务运行时,skill 从 PG 到 runtime 的完整路径:
```
1. 数据库:skill + skill_file + agent_skill 三张表的行
2. Daemon claim 任务:
POST /api/runtimes/{runtimeId}/tasks/claim
handler/daemon.go:1018-1098 (ClaimTaskByRuntime)
→ service/task.go:1447-1463 (LoadAgentSkills)
→ 把 agent 关联的所有 skill 全文塞进 HTTP 响应
3. Daemon 算工作目录:
server/internal/daemon/execenv/execenv.go:114, 124
workDir = {WorkspacesRoot}/{wsID}/{shortTaskID}/workdir
4. Daemon 按 runtime 算 skill 目录:
server/internal/daemon/execenv/context.go:121-158 (resolveSkillsDir)
claude → {workDir}/.claude/skills
cursor → {workDir}/.cursor/skills
codex → 特殊:{codexHome}/skills
5. Daemon 把字符串写成磁盘文件:
context.go:175-204 (writeSkillFiles)
核心就两行 os.WriteFile
6. Daemon 启动 runtime,cwd = workDir
runtime 自己扫 .claude/skills/(等)→ 加载 frontmatter
7. 任务结束:os.RemoveAll(workDir)
PG 是真相源,workDir 是每次任务临时复印件
```
**核心 invariant**:Multica 不教 runtime 怎么用 skill,只把文件摆到 runtime 已经会扫的位置。
### 2.4 Template = Instructions + Skill 引用
Template 是个**静态 JSON 定义**,包含:
- 预写好的 instructions
- 一组 skill 引用(用 URL 指向 skills.sh / GitHub)
用户选模板时,后端:
1. 对每个 skill 引用,**复用现有 `/api/skills/import` 的 fetcher**(`fetchFromSkillsSh` / `fetchFromGitHub`)拉内容
2. 物化到 workspace(同名复用 / 新建)
3. CreateAgent + setAgentSkills
4. 整个流程一个事务
skill 引用为什么用 URL 而不是内联 SKILL.md 内容:
- 复用现有 import 基础设施,零新代码
- skill 内容跟 GitHub 同步,不需要 vendoring 进 multica 仓库
- 模板 JSON 体积小,git review 友好
### 2.5 Quick-create Issue 模式(Phase 2/3 复用的基础设施)
当前 `POST /api/issues/quick-create`(handler/issue.go:877-982)的流程:
```
1. 后端 enqueue 任务:
- agent_task_queue 加一行,issue_id = NULL,context JSONB = {type: "quick-create", prompt: ...}
- 立即返回 202 Accepted + task_id
2. Daemon claim 任务时识别 quick-create:
- 检查 task.Context != nil AND !task.IssueID.Valid
- 解析为 QuickCreateContext (service/task.go:1810-1811)
3. Daemon 构造 prompt:
- daemon/prompt.go:45-106 (buildQuickCreatePrompt)
- 把用户的自然语言 prompt 作为语义核心
- 加上"调用 multica issue create CLI 命令"的指令
4. Agent 跑 LLM + tool calling:
- LLM 输出形如 `multica issue create --title="..." --description="..."` 的命令
- daemon 执行 CLI 命令,CLI 调 POST /api/issues 创建 issue
- CLI 自动在请求里带上 MULTICA_QUICK_CREATE_TASK_ID env(daemon/daemon.go:2081)
→ 让创建出来的 issue 带 origin_type='quick_create' + origin_id=<task_id>
5. 后端 link + 通知:
- 完成检测:GetIssueByOrigin(workspace_id, "quick_create", task_id)
- LinkTaskToIssue(task_id, issue_id) 把任务行的 issue_id 补上
- 写 inbox_item 通知用户(notifyQuickCreateCompleted, service/task.go:1908-1920)
```
**关键洞察**:这个模式**完全通用化**了。复用它只需要:
1. 新的 context JSONB type(比如 `"skill-find"``"agent-create"`)
2. 新的 prompt builder
3. 新的"完成检测 + inbox 通知"
不需要任何 daemon / 任务队列层面的改动。
---
## 3. 三阶段详细设计
### Phase 1:Agent Template
**目标**:用户选模板 → 一键得到一个可用的 agent(自带 skill + instructions),不需要 AI 参与。
#### 设计
- **Template 定义存放**:静态 JSON,commit 在 `server/internal/agenttmpl/templates/*.json`
- **Template JSON 形态**:
```json
{
"slug": "code-reviewer",
"name": "Code Reviewer",
"description": "审代码用的 agent",
"instructions": "你审代码,关注 N+1 查询、错误处理、类型安全...",
"skills": [
{ "source_url": "https://skills.sh/obra/superpowers/tdd" },
{ "source_url": "https://github.com/foo/bar/tree/main/skills/code-style" }
]
}
```
- **新 endpoint**:`POST /api/agents/from-template`
- 请求:`{template_slug, name, runtime_id, ...overrides}`
- 后端流程(**全部在一个事务里**):
1. 加载 template JSON
2. 对每个 skill source_url:
- 调用 `detectImportSource(url)`(skill.go:586-617)分发到对应 fetcher
- 通过 GetSkillByWorkspaceAndName 检查 workspace 是否已有同名 skill
- 有 → 复用现有 skill_id
- 无 → 调 `createSkillWithFilesInTx`(待重构,见 §4)物化
3. `CreateAgent`(复用 agent.go:CreateAgent 的内部逻辑)
4. 批量 `AddAgentSkill` 关联
- 响应:`{agent: {...}, imported_skill_ids: [...], reused_skill_ids: [...]}`
- **前端**:`CreateAgentDialog`(packages/views/agents/components/create-agent-dialog.tsx)加 "From template" 模式,跟现有 manual / duplicate 模式并列
- 模板选择器 → 预览(instructions + skill 列表)→ 提交调新 endpoint
- 响应里的 `reused_skill_ids` 用 toast 提示"以下 skill 已存在,沿用了 workspace 现有版本"
#### 起步模板清单(初版,可调)
- `code-reviewer` — 代码审查
- `tdd-pair` — TDD 配对编程
- `db-reviewer` — 数据库 / SQL 审查
- `pr-summarizer` — PR 摘要
- `docs-writer` — 文档撰写
具体每个模板选哪些 skill URL,在 Phase 1 启动时单独决定(需要逛 skills.sh 选高质量 skill)。
#### Phase 1 改动清单
| 文件 / 位置 | 改动 |
|---|---|
| `server/internal/agenttmpl/`(新包) | 加载 JSON 模板的代码 |
| `server/internal/agenttmpl/templates/*.json`(新文件) | 5 个起步模板 |
| `server/internal/handler/agent.go` | 新 handler `CreateAgentFromTemplate` |
| `server/internal/handler/skill_create.go` | **重构**:拆出 `createSkillWithFilesInTx` 变体(见 §4) |
| `server/pkg/db/queries/skill.sql` | 加 `GetSkillByWorkspaceAndName`(见 §4) |
| `server/cmd/server/router.go` | 注册新 endpoint |
| `packages/views/agents/components/create-agent-dialog.tsx` | 加 template 模式 |
| `packages/core/api/agent.ts` | 加 `createAgentFromTemplate` API 调用 |
| `packages/views/agents/components/template-picker.tsx`(新文件) | 模板选择器组件 |
### Phase 2:Skill Finder
**目标**:用户用自然语言描述需求(如"我想审 SQL"),AI 推荐一组 skill,用户勾选一键导入到 workspace。
#### 设计
- **架构选型**:走 quick-create 模式,**不是后端直接调 LLM**
- **新 endpoint**:`POST /api/skills/find`
- 请求:`{prompt, agent_id}`(agent_id 是用来跑这个 LLM 任务的 agent,跟 Quick-create Issue 一样要求预先有 agent)
- 后端流程:
1. enqueue 任务:`agent_task_queue` 加一行,context JSONB = `{type: "skill-find", prompt}`
2. 返回 202 + task_id
- **Daemon prompt builder**:`daemon/prompt.go` 加 `buildSkillFindPrompt`(类比 buildQuickCreatePrompt)
- 喂给 agent 的 prompt 大致:
```
用户需求:{user_prompt}
你的任务:从以下 curated skill 清单里选 3-5 个最相关的推荐给用户。
可选 skill 清单(JSON):
{curated_skill_index}
输出:调用 `multica skill find --output-results '<JSON>'` 命令,
JSON 形态为 [{name, description, source_url, reason}, ...]
```
- **CLI 命令**(新):`multica skill find --output-results <JSON>`
- 不发起 HTTP 请求,只把 JSON 写到 daemon 通过 env 指定的临时文件
- daemon 读这个文件,把内容塞进 inbox notification 的 payload
- **Curated skill 索引**:`server/internal/agenttmpl/skill_index.json`(新文件)
- 几十到上百条精选 skill,每条:`{name, description, source_url, tags, install_count}`
- 维护方式:工程师/产品手工维护,代码 review 卡内容质量
- MVP **不做**实时 GitHub Code Search 或 skills.sh 爬虫
- **完成通知**:写 inbox_item,type = `skill_find_done`,payload 含推荐结果数组
- **前端**:
- 独立"Find Skill"页面(`/skills/find` 或 `/skills?ai=true`)
- skill list page 上"用 AI 找 skill"按钮入口
- 用户输入 prompt → 提交 → 等通知 → inbox item 里展示 skill 卡片(name + description + source_url + reason)
- 用户勾选 → 一键批量调现有 `POST /api/skills/import`(每个 skill 一次,可考虑加 batch endpoint 但 MVP 不必要)
#### Phase 2 改动清单
| 文件 / 位置 | 改动 |
|---|---|
| `server/internal/handler/skill.go` | 新 handler `FindSkill`(enqueue task) |
| `server/internal/service/task.go` | 加 `EnqueueSkillFindTask` + 完成检测 + inbox 通知 |
| `server/internal/daemon/prompt.go` | 加 `buildSkillFindPrompt` |
| `server/internal/daemon/daemon.go` | 加 `SkillFindContext` 识别 + env 注入 |
| `server/cmd/multica/cmd_skill.go` | 加 `find --output-results` 子命令 |
| `server/internal/agenttmpl/skill_index.json`(新文件) | curated 清单 |
| `packages/views/skills/components/find-skills-dialog.tsx`(新文件) | UI |
| `packages/core/api/skill.ts` | 加 `findSkills` API |
| `packages/views/inbox/items/skill-find-result.tsx`(新文件) | inbox item 渲染 |
### Phase 3:AI Create Agent
**目标**:用户描述需求,AI 自己 find skill + 写 instructions + 创建 agent。
#### 设计
- **架构选型**:走 quick-create 模式,**组合 Phase 2 的 find 能力 + 新的 agent create CLI**
- **新 endpoint**:`POST /api/agents/ai-draft`
- 请求:`{prompt, host_agent_id}`(host_agent_id 是跑这个元任务的 agent)
- 后端:enqueue 任务,context = `{type: "agent-create", prompt}`,返回 202 + task_id
- **Daemon prompt builder**:`buildAgentCreatePrompt` 指挥 agent 三步走:
```
1. 调用 `multica skill find --output-results ...` 选 skill
(或直接看 curated 清单选)
2. 基于选定 skill 写 instructions
3. 调用 `multica agent create --name ... --instructions ... --skill-ids ...`
创建 agent 并关联 skill
```
- **CLI 命令**(新):`multica agent create`
- 后端 handler 已存在(handler/agent.go:CreateAgent),只需要绑 CLI(~50 行)
- 创建时带 `MULTICA_AI_DRAFT_TASK_ID` env,服务端用它做 origin 标记 + LinkTaskToAgent
- **完成通知**:inbox_item type = `agent_draft_done`,payload 含 agent_id + 摘要
- **前端**:`CreateAgentDialog` 加 "AI" 模式
- 输入需求 → 提交 → 等通知 → inbox 通知里点击 → 跳新 agent 详情页(用户在那儿编辑/调整)
#### Phase 3 改动清单
| 文件 / 位置 | 改动 |
|---|---|
| `server/internal/handler/agent.go` | 新 handler `AIDraftAgent`(enqueue task) |
| `server/internal/service/task.go` | 加 `EnqueueAgentDraftTask` + 完成检测 + inbox 通知 |
| `server/internal/daemon/prompt.go` | 加 `buildAgentCreatePrompt` |
| `server/cmd/multica/cmd_agent.go` | 加 `create` 子命令(handler 已有) |
| `packages/views/agents/components/create-agent-dialog.tsx` | 加 "AI" 模式 |
| `packages/core/api/agent.ts` | 加 `aiDraftAgent` API |
| `packages/views/inbox/items/agent-draft-result.tsx`(新文件) | inbox item 渲染 |
---
## 4. Blocker 清单与修复方案
### 4.1 [SOFT] `createSkillWithFiles` 不可组合事务
**问题**:`server/internal/handler/skill_create.go:21-71` 这个函数自己 `Begin()` 一个事务,执行完 `Commit()`。Phase 1 需要在外层事务里**多次**调用它(import N 个 skill + createAgent + setAgentSkills 都在一个 TX),但现在没法这么用。
**影响范围**:Phase 1
**修复方案**:
```go
// 拆成两个函数(保持原 API 向后兼容):
// 新增:接受外部 qtx,不管事务
func createSkillWithFilesInTx(
ctx context.Context,
qtx *db.Queries,
input skillCreateInput,
) (*SkillWithFilesResponse, error) {
// 不 Begin/Commit,只调 qtx.CreateSkill + qtx.UpsertSkillFile loop
}
// 改造:原函数变成包装层,内部调 InTx 版
func (h *Handler) createSkillWithFiles(
ctx context.Context,
input skillCreateInput,
) (*SkillWithFilesResponse, error) {
tx, _ := h.TxStarter.Begin(ctx)
defer tx.Rollback()
qtx := h.Queries.WithTx(tx)
result, err := createSkillWithFilesInTx(ctx, qtx, input)
if err != nil { return nil, err }
tx.Commit()
return result, nil
}
```
旧调用方完全不变。Phase 1 新 endpoint 自己 Begin,然后多次调 `*InTx` 变体,最后统一 Commit。
**工作量**:小(< 100 行重构)
### 4.2 [SOFT] Skill 同名冲突
**问题**:`skill` 表有 `UNIQUE(workspace_id, name)` 约束。Phase 1 模板导入时,如果模板里的 skill 跟 workspace 已有 skill 同名,INSERT 会报 PG 错误 23505,整个 from-template 流程挂掉。
**影响范围**:Phase 1
**修复方案**:加 find-or-create 模式:
1. 新 query `GetSkillByWorkspaceAndName`(`server/pkg/db/queries/skill.sql`)
2. Phase 1 流程改成:
- 对每个模板 skill,先查 workspace 是否已有同名
- 有 → 复用现有 skill_id,跳过 import
- 无 → 调 `createSkillWithFilesInTx` 物化
3. 响应里返回 `reused_skill_ids: [...]`,前端 toast "以下 skill 已存在,沿用现有版本"
**不选择"覆盖"或"加后缀"的原因**:用户可能已经改过本地版本,覆盖会丢用户修改;加后缀污染 skill 列表。
**工作量**:小(< 50 行 + 1 条 sqlc query)
### 4.3 [SOFT] 缺 `multica skill find` CLI
**影响范围**:Phase 2
**方案**:加一个 CLI 子命令,模仿 `multica skill import` 的实现(`server/cmd/multica/cmd_skill.go:55-60, 323-357`)。**注意**:这个命令不发 HTTP 请求,只是 LLM agent 用来"输出推荐结果"的 channel——它把 LLM 推荐的 JSON 写到 daemon 指定的临时文件,daemon 读完塞进 inbox notification。
**工作量**:小(~80 行)
### 4.4 [SOFT] 缺 `multica agent create` CLI
**影响范围**:Phase 3
**方案**:后端 handler 已有(`handler/agent.go:CreateAgent`),只需在 `server/cmd/multica/cmd_agent.go` 加 `create` 子命令。
**工作量**:小(~50 行)
### 4.5 [非 blocker] System Agent 问题
**之前误判为 hard blocker,实际不是**:
Quick-create Issue 当前的设计就要求用户**预先有一个 agent** 才能用——AI 路径不为"零 agent 起步"服务。Phase 2/3 沿用这个前提,所以**新 workspace 没 agent 时 AI 功能不可用**是符合现有产品模型的,不需要 bootstrap 一个 system agent。
产品自然解锁路径:
1. 新用户进 workspace
2. 用 **Phase 1 Template**(无需 AI、无需现有 agent)创建第一个 agent
3. 之后 Phase 2/3 即可用,host_agent 就用刚创建的那个
---
## 5. 关键设计决策(及理由)
### 5.1 为什么不接 Anthropic 官方 marketplace?
**结构错配**。Anthropic 官方 marketplace(`anthropics/claude-plugins-official`)是 **plugin 体系**:每个 plugin 是个 bundle,包含 `.claude-plugin/plugin.json` + `skills/` + `agents/` + `hooks/` + `.mcp.json`。
Multica 只有**单体 skill**(SKILL.md + skill_file),没有 plugin / bundle 概念。要接入得新写 plugin parser + 拆分逻辑,工作量大,而 skills.sh 已经覆盖了同一批高质量内容(skills.sh 后端就是 GitHub raw,绝大多数 skill 作者就在 GitHub 上,Anthropic plugin 体系里的 skill 通常也在作者的 GitHub repo 里有单体副本)。
### 5.2 为什么走 quick-create 模式而不是后端直接调 LLM?
代码事实:`server/` 目前**完全没有任何 LLM SDK**(grep `anthropic-sdk-go` / `openai-go` / 任何 LLM provider 都是 0 命中)。所有 LLM 调用都通过 daemon → runtime → CLI 这条路。
走 quick-create 模式的优势:
- **不引入新基础设施**(SSE / LLM client / API key 管理)
- **复用 agent 的 instructions / model / runtime 配置**(用户已经在某个 agent 里配置过的偏好自动生效)
- **统一计费 / 用量监控**(LLM 调用都计在用户 agent 的 quota 里)
代价:
- 用户得**预先有一个 agent**(参见 §4.5,这跟 Quick-create Issue 现状一致)
- LLM 调用通过 daemon 多一跳,延迟略增(但不阻塞 202 响应)
### 5.3 为什么 Skill Finder 是 endpoint 不是 SKILL.md?
**Skill Finder 名字里的 "Skill" 是它的产物(找的是 skill),不是它自己实现成 SKILL.md**。
如果做成 SKILL.md 文件:
- 它得装进某个 agent 里才能用 → 单点功能变得需要前置配置
- skill 教 agent 调什么?调 `npx skills`(装到本地,目标错)?调 Multica API(那要写 tool channel,绕一大圈)
- AI 创建 Agent(Phase 3)那条路要"启动 agent → agent 调 skill → skill 调 tool",链路复杂三倍
做成 endpoint:
- 用户独立可用(独立 UI 入口)
- AI 创建 Agent 后端直接调 endpoint,两个功能共用一段逻辑
- 简单
### 5.4 Curated Skill 索引 vs 实时搜索
**MVP 用 curated 清单**(几十条精选 URL + 摘要 commit 在 repo 里)。理由:
- 质量可控
- 不踩 GitHub Code Search rate limit
- 不被 LLM 编 URL(LLM 知识 cutoff + hallucinate URL 是真问题)
- 维护成本低
进阶可加 `search_skills(query)` tool 实时打 GitHub Code Search,等用户反馈"清单太窄"再做。
### 5.5 不做 ClawHub(顺手清理建议)
**现状**:`POST /api/skills/import` 当前支持 3 个 source(`fetchFromClawHub` skill.go:642-744、`fetchFromSkillsSh` skill.go:757-879、`fetchFromGitHub` skill.go:1363-1463)。ClawHub 是个独立 HTTP 客户端,不复用 GitHub 基础设施。
**判断**(详见之前讨论):
- ClawHub 服务的是 OpenClaw 平台(Multica 同生态位竞品的内容生态)
- UI 没有发现/搜索层,用户只能粘 URL,而 ClawHub 装机量远低于 skills.sh,用户主动逛的概率极低
- 独立代码路径,API 演进时单独跟进
**建议**(独立于本计划,可以一起做也可以延后):
- 跑 `SELECT count(*) FROM skill WHERE config->'origin'->>'type' = 'clawhub'` 看实际使用量
- 接近 0 → 渐进下线(先去 UI SourceCard,后续 release 删 fetcher)
- 有量 → 留着,但仍不为它做新功能
---
## 6. 实施依赖与排期
```
[Phase 1] Template
└── 独立,无依赖
└── 包含 2 个 soft blocker 的修复(§4.1 §4.2)
[Phase 2] Skill Finder
└── 依赖 Phase 1 中的 skill import 路径(已存在,沿用)
└── 含 1 个 soft blocker(§4.3)
[Phase 3] AI Create Agent
└── 依赖 Phase 2(复用 find skill 能力)
└── 含 1 个 soft blocker(§4.4)
```
**真实排期建议**:
- Phase 1 可单独发版,有独立价值
- Phase 2 独立可发版(找 skill 是高频独立场景)
- Phase 3 等 Phase 2 ready 后开始
每个 phase 启动时单独开 PR 设计 doc,本文档只是路线图。
---
## 7. 风险与缓解
| 风险 | 缓解 |
|---|---|
| GitHub rate limit(模板 import 多个 skill 时) | 已有 `GITHUB_TOKEN` env 支持(skill.go:1163-1166),5000/h 配额够用。生产环境确保配置 |
| 模板里引用的 skill repo 被作者删除 | from-template handler 容错:某个 skill fetch 失败 → 整个事务回滚,前端展示具体哪个 URL 挂了。模板自己也定期 review |
| LLM 推荐编造 URL(Phase 2) | 用 curated 清单作为 context,**不让 LLM 自由发挥 URL**,推荐范围限定在清单内 |
| Phase 3 LLM 写出离谱 instructions | 用户在 inbox 通知里点击 → 跳新 agent 详情页**编辑模式**,不直接进入"已就绪"状态。用户必须确认 |
| 模板格式后续要演进(加字段) | Template JSON 加 `version` 字段,后端按 version 兼容老格式 |
| Curated skill 清单过时(作者改 repo / 删 skill) | 加 CI 任务定期跑一遍清单 URL,挂掉的报警通知维护者 |
---
## 8. 不在本文档范围(已识别的下一步话题)
- 跨 workspace 模板共享 / marketplace 化(用户能把自己的 agent 存成模板分享)
- 实时 GitHub Code Search tool(Phase 2 进阶)
- Server-side LLM 调用基础设施(如果未来需要 streaming 等场景)
- ClawHub 下线决策(独立讨论,见 §5.5)
- Skill 版本管理(workspace skill 版本号 / 升级提示)
---
## 附录 A:代码索引
> 给接手开发的同事的快速参考。每条 file:line 都在本计划里被引用过,记录在这里方便跳转。
| 主题 | 位置 |
|---|---|
| Skill DB 模型 | `server/migrations/008_structured_skills.up.sql:4-32` |
| Skill 创建 handler + 事务 | `server/internal/handler/skill.go:143-162` + `skill_create.go:21-71` |
| Skill import 入口(支持 3 个 source) | `server/internal/handler/skill.go:1538` |
| Skill import source 分发 | `server/internal/handler/skill.go:586-617` (`detectImportSource`) |
| Skills.sh fetcher | `server/internal/handler/skill.go:757-879` (`fetchFromSkillsSh`) |
| GitHub fetcher | `server/internal/handler/skill.go:1363-1463` (`fetchFromGitHub`) |
| ClawHub fetcher | `server/internal/handler/skill.go:642-744` (`fetchFromClawHub`) |
| Agent 创建 handler | `server/internal/handler/agent.go:380-399` (request) + `:422-564` (CreateAgent) |
| Agent 创建 sqlc | `server/pkg/db/queries/agent.sql:19-25` |
| Agent-Skill 关联 sqlc | `server/pkg/db/queries/agent.sql:86-103` |
| 当前 Agent Duplication(前端模式) | `packages/views/agents/components/agents-page.tsx:286-301`(post-create skill copy) |
| Agent 创建 dialog | `packages/views/agents/components/create-agent-dialog.tsx` |
| Skill add dialog | `packages/views/agents/components/skill-add-dialog.tsx` |
| Quick-create Issue handler | `server/internal/handler/issue.go:877-982` (`QuickCreateIssue`) |
| Quick-create task enqueue | `server/internal/service/task.go:488+` (`EnqueueQuickCreateTask`) |
| Daemon claim + load skills | `server/internal/handler/daemon.go:1018-1098` + `service/task.go:1447-1463` |
| Daemon prompt build | `server/internal/daemon/prompt.go:17-36` (dispatch) + `:45-106` (`buildQuickCreatePrompt`) |
| Daemon execenv prepare | `server/internal/daemon/execenv/execenv.go:103-176` |
| Skill 目录约定(runtime mapping) | `server/internal/daemon/execenv/context.go:121-158` (`resolveSkillsDir`) |
| Skill 文件落盘 | `server/internal/daemon/execenv/context.go:175-204` (`writeSkillFiles`) |
| Quick-create 完成检测 + inbox | `server/internal/service/task.go:1810-1949` |
| LinkTaskToIssue | `server/internal/handler/agent.go:97-105` |
| Quick-create Issue 前端 modal | `packages/views/modals/quick-create-issue.tsx:48-570+` |
| Multica CLI 入口 | `server/cmd/multica/main.go:62-79` |
| Skill CLI 命令 | `server/cmd/multica/cmd_skill.go:17-96`(已有 import,无 find) |
| Agent CLI 命令 | `server/cmd/multica/cmd_agent.go:101-112`(已有 list/get,无 create) |

View File

@@ -14,7 +14,6 @@ All analytics shipping is toggled by environment variables (see `.env.example`):
|---|---|---|
| `POSTHOG_API_KEY` | PostHog project API key. Empty = no events are shipped. | `""` |
| `POSTHOG_HOST` | PostHog host (US or EU cloud, or self-hosted URL). | `https://us.i.posthog.com` |
| `ANALYTICS_ENVIRONMENT` | Optional override for the standard `environment` event property. Normalized to `production`, `staging`, or `dev`; defaults from `APP_ENV`. | `APP_ENV` / `dev` |
| `ANALYTICS_DISABLED` | Set to `true`/`1` to force the no-op client even when `POSTHOG_API_KEY` is set. | `""` |
Local dev and self-hosted instances run with `POSTHOG_API_KEY=""`, so **no
@@ -83,50 +82,6 @@ handler → analytics.Client.Capture(Event) ← non-blocking, returns immediat
`$set_once` only for values that must never be overwritten (email,
initial attribution, first-completion timestamp).
## Taxonomy
Every event is assigned to one dashboard category:
| Category | Events |
|---|---|
| `core_loop` | `workspace_created`, `runtime_registered`, `runtime_ready`, `runtime_failed`, `runtime_offline`, `agent_created`, `issue_created`, `chat_message_sent`, `agent_task_queued`, `agent_task_dispatched`, `agent_task_started`, `agent_task_completed`, `agent_task_failed`, `agent_task_cancelled`, `autopilot_run_started`, `autopilot_run_completed`, `autopilot_run_failed` |
| `onboarding_support` | `onboarding_started`, `onboarding_questionnaire_submitted`, `onboarding_completed`, `onboarding_runtime_path_selected`, `onboarding_runtime_detected`, `starter_content_decided` |
| `acquisition` | `signup`, `download_intent_expressed`, `download_page_viewed`, `download_initiated`, `cloud_waitlist_joined` |
| `ops_feedback` | `feedback_opened`, `feedback_submitted` |
| `system/noise` | `$pageview`, `$set`, `$identify`, `$autocapture`, `$rageclick` |
The v0 core dashboard must use only `core_loop` plus the specific
`onboarding_support` steps used by the activation funnel. Acquisition,
feedback, and system/noise events stay in separate dashboards.
## Standard core properties
Canonical core events should carry these properties whenever the entity exists:
| Property | Type | Notes |
|---|---|---|
| `environment` | string | `production` / `staging` / `dev`; stamped by backend and frontend analytics clients. |
| `event_schema_version` | int | Current version: `2`. |
| `user_id` | string UUID | Human user ID when known. Agent/system events may omit it. |
| `workspace_id` | string UUID | Required for workspace-scoped events. |
| `agent_id` | string UUID | Required for agent/task events. |
| `task_id` | string UUID | Required for `agent_task_*` events. |
| `issue_id` / `chat_session_id` / `autopilot_run_id` | string UUID | Relevant source entity for the task/entry event. |
| `source` | string | Canonical values: `onboarding`, `manual`, `chat`, `autopilot`, `api`. UI surface details use `surface` or `trigger_source`. |
| `runtime_mode` | string | `cloud` / `local` when a runtime/agent task is involved. |
| `provider` | string | `claude`, `codex`, `cursor`, etc. when a runtime/agent task is involved. |
| `is_demo` | bool | Currently always `false`; reserved for future demo/test workspace filtering. |
Task terminal events additionally carry `duration_ms`; failures carry
`failure_reason`, `error_type`, and `will_retry`. Runtime failure events carry
`recoverable`; runtime ready events carry `runtime_id`, `ready_duration_ms`
only when it is actually measured, and `daemon_id` for local runtimes.
Schema v2 is the first canonical core-metrics schema. It replaces early v1
drafts that mirrored `failure_reason` into `error_type`, used `recoverable`
for task/autopilot failures, and emitted `ready_duration_ms: 0` before the
registration path had a measured duration.
## Event contract
### `signup`
@@ -173,8 +128,6 @@ extra query, no race.
| Property | Type | Description |
|---|---|---|
| `runtime_id` | string (UUID) | The newly created agent_runtime row id. |
| `daemon_id` | string | Local daemon identity when available. |
| `runtime_mode` | string | Currently `local`; reserved for cloud runtimes. |
| `provider` | string | e.g. `"codex"`, `"claude"`. |
| `runtime_version` | string | Version of the agent runtime binary. |
| `cli_version` | string | Version of the `multica` CLI that registered it. |
@@ -184,118 +137,6 @@ registered via a member's JWT/PAT; daemon-token registrations fall back to
`workspace:<workspace_id>` so PostHog doesn't bucket unrelated daemons
under a single "anonymous" person.
### `runtime_ready`
Fires when a runtime is first registered in an online/ready state. This is the
activation-funnel step that should replace treating `runtime_registered` as
proof of readiness. The backend emits this only on the INSERT path for a new
`agent_runtime` row; ordinary daemon reconnects update the existing row and do
not emit another `runtime_ready`. Dashboard funnels should still count
distinct `runtime_id`.
| Property | Type | Description |
|---|---|---|
| `runtime_id` | string (UUID) | The `agent_runtime` row id. |
| `daemon_id` | string | Local daemon identity when available. |
| `ready_duration_ms` | int64 | Optional. Time from registration start to ready; omitted until the registration path can measure it. |
| `runtime_mode` | string | `local` / `cloud`. |
| `provider` | string | Runtime provider. |
### `runtime_failed`
Fires when runtime setup/registration fails before a ready runtime can be
recorded. Today this is scoped to backend registration persistence failures;
future setup flows should reuse it for provider detection or daemon boot
failures.
| Property | Type | Description |
|---|---|---|
| `daemon_id` | string | Local daemon identity when available. |
| `provider` | string | Runtime provider attempted. |
| `failure_reason` | string | Stable coarse reason. |
| `error_type` | string | Stable error classifier. |
| `recoverable` | bool | Whether retrying setup may succeed. |
### `runtime_offline`
Fires when a runtime is explicitly deregistered or the backend sweeper marks it
offline after missed heartbeats. This is not an activation step; it supports
local runtime retention and drop-off diagnosis.
### `issue_created`
Fires after an issue row is created, including manual UI/API issue creation,
quick-create issue creation by an agent, and autopilot `create_issue` runs.
| Property | Type | Description |
|---|---|---|
| `issue_id` | string (UUID) | Created issue. |
| `agent_id` | string (UUID) | Agent assignee or creating agent when applicable. |
| `task_id` | string (UUID) | Present for quick-create issue creation. |
| `autopilot_run_id` | string (UUID) | Present for autopilot-created issues. |
| `source` | string | `manual`, `api`, or `autopilot`. |
### `chat_message_sent`
Fires after a user chat message is persisted and the corresponding agent task
is queued.
| Property | Type | Description |
|---|---|---|
| `chat_session_id` | string (UUID) | Chat session. |
| `task_id` | string (UUID) | Queued agent task. |
| `agent_id` | string (UUID) | Chat agent. |
| `source` | string | Always `chat`. |
### `agent_task_queued` / `agent_task_dispatched` / `agent_task_started` / `agent_task_completed`
Canonical task lifecycle events emitted from `agent_task_queue` state
transitions. `agent_task_dispatched` fires when the backend claims a queued
task for a runtime, before the daemon marks it running with
`agent_task_started`. These events replace `issue_executed` for core loop
success metrics and allow the activation funnel to split queue backlog from
claim/start handoff.
| Property | Type | Description |
|---|---|---|
| `task_id` | string (UUID) | `agent_task_queue.id`; required. |
| `agent_id` | string (UUID) | Owning agent. |
| `issue_id` | string (UUID) | Present for issue-linked tasks. |
| `chat_session_id` | string (UUID) | Present for chat tasks. |
| `autopilot_run_id` | string (UUID) | Present for run-only autopilot tasks. |
| `source` | string | `manual`, `chat`, or `autopilot`. |
| `runtime_mode` | string | `local` / `cloud`. |
| `provider` | string | Runtime provider. |
| `duration_ms` | int64 | Terminal events only; measured from `started_at` when available. |
### `agent_task_failed` / `agent_task_cancelled`
Terminal task lifecycle events. They use the same join fields as
`agent_task_completed`. `agent_task_failed` also carries:
| Property | Type | Description |
|---|---|---|
| `failure_reason` | string | Stable reason from `agent_task_queue.failure_reason`, default `agent_error`. |
| `error_type` | string | Stable coarse classifier, e.g. `runtime`, `timeout`, `agent_output`, `cancelled`, `agent_error`. |
| `will_retry` | bool | Whether the backend auto-retry policy will create another task attempt. |
### `autopilot_run_started` / `autopilot_run_completed` / `autopilot_run_failed`
Fires from `autopilot_run` lifecycle changes. `source` is always
`autopilot`; the trigger origin is carried in `trigger_source` (`manual`,
`schedule`, `webhook`, or `api`).
| Property | Type | Description |
|---|---|---|
| `autopilot_id` | string (UUID) | Autopilot definition. |
| `autopilot_run_id` | string (UUID) | Run row. |
| `agent_id` | string (UUID) | Assigned agent. |
| `trigger_source` | string | `manual`, `schedule`, `webhook`, or `api`. |
| `duration_ms` | int64 | Terminal events only. |
| `failure_reason` | string | Failed events only. |
| `error_type` | string | Failed events only; stable coarse classifier such as `configuration`, `issue_terminal`, `dispatch_error`, `task_error`, or `autopilot_error`. |
| `will_retry` | bool | Failed events only; currently `false` because autopilot retry cadence is owned by triggers/schedules. |
### `issue_executed`
Fires **at most once per issue** — when the first task on that issue
@@ -308,11 +149,6 @@ distinct issues, not tasks.
| Property | Type | Description |
|---|---|---|
| `issue_id` | string (UUID) | |
| `task_id` | string (UUID) | Completing task. |
| `agent_id` | string (UUID) | Completing agent. |
| `source` | string | `manual`, `chat`, or `autopilot`. |
| `runtime_mode` | string | `local` / `cloud`. |
| `provider` | string | Runtime provider. |
| `task_duration_ms` | int64 | Wall-clock time between `task.started_at` and `task.completed_at`. Zero when the task was created in a completed state (rare). |
`distinct_id` prefers the issue's human creator so agent-executed events
@@ -329,10 +165,6 @@ emit `n=1`. PostHog answers the same question at query time via
and funnel steps of the form "workspace has had ≥2 `issue_executed`
events" are expressible without the property. No information is lost.
Compatibility: `issue_executed` remains a historical compatibility event for
old dashboards. New core-loop success dashboards should use
`agent_task_completed` and filter by `source`/`issue_id` as needed.
### `team_invite_sent`
Fires from `CreateInvitation` after the DB row is written.
@@ -356,17 +188,6 @@ accepted and the member row is inserted in the same transaction.
`distinct_id` is the invitee's user id — this is the event that closes the
expansion funnel.
### `onboarding_started`
Fires once when the onboarding shell mounts and the initial workspace list has
resolved. Existing-workspace users carry `workspace_id`; brand-new users do
not have a workspace yet.
| Property | Type | Description |
|---|---|---|
| `workspace_id` | string (UUID) | Present only when the user already has a workspace. |
| `source` | string | Always `onboarding`. |
### `onboarding_questionnaire_submitted`
Fires on the first PatchOnboarding that transitions the user's
@@ -405,7 +226,6 @@ isolates the Step 4 signal from later agent additions.
|---|---|---|
| `agent_id` | string (UUID) | |
| `provider` | string | Runtime provider the agent is bound to (`claude`, `codex`, etc). |
| `runtime_mode` | string | Runtime mode copied from the bound runtime. |
| `template` | string | Template slug used to seed the agent (`coding` / `planning` / `writing` / `assistant`). Empty when the caller didn't come from a template picker. |
| `is_first_agent_in_workspace` | bool | `true` when the workspace had zero agents before this insert. |
@@ -421,8 +241,7 @@ which exit the user took.
| Property | Type | Description |
|---|---|---|
| `workspace_id` | string (UUID) | Present for workspace-linked onboarding completions. |
| `completion_path` | string | One of `full` / `runtime_skipped` / `cloud_waitlist` / `skip_existing` / `invite_accept` / `unknown`. See below. |
| `completion_path` | string | One of `full` / `runtime_skipped` / `cloud_waitlist` / `skip_existing` / `unknown`. See below. |
| `joined_cloud_waitlist` | bool | Derived from `user.cloud_waitlist_email`. Orthogonal to `completion_path` — a user may submit the waitlist form and still pick CLI. |
Person properties set with `$set_once`:
@@ -437,7 +256,6 @@ Person properties set with `$set_once`:
- `runtime_skipped` — Completed without connecting a runtime (user hit Skip in Step 3).
- `cloud_waitlist` — Submitted the cloud waitlist form and skipped Step 3.
- `skip_existing` — "I've done this before" from Welcome. The user already had a workspace.
- `invite_accept` — Accepted at least one workspace invitation.
- `unknown` — Legacy fallback when the client didn't send a path. Should stay near zero after rollout.
### `cloud_waitlist_joined`
@@ -496,11 +314,11 @@ request payload.
`packages/views/onboarding/steps/step-platform-fork.tsx` when the web
user clicks one of the three Step 3 fork cards (before any server
call happens, so it's frontend-only). Properties: `path`
(`download_desktop` / `cli` / `cloud_waitlist`), `source`
(`onboarding`), `surface` (`step3`), `workspace_id`, and `is_mac`.
Also writes `platform_preference` (`web` / `desktop`) to person
properties so every subsequent event on the user can be broken down
by chosen platform. **Note**: semantic "download
(`download_desktop` / `cli` / `cloud_waitlist`), `source` (`step3`;
literal today but reserved for future surfaces reusing this event),
`is_mac`. Also writes `platform_preference` (`web` / `desktop`) to
person properties so every subsequent event on the user can be
broken down by chosen platform. **Note**: semantic "download
intent" is now better served by `download_intent_expressed` below —
`path: "download_desktop"` signals Step 3 path choice specifically,
not actual download start.
@@ -516,9 +334,8 @@ request payload.
`runtime_registered` is silent on that cohort. Splits
`completion_path=runtime_skipped` into "had CLIs, skipped anyway"
vs "no CLIs available, had no choice". Properties:
- `source`: `onboarding`.
- `surface`: `step3_desktop`.
- `workspace_id`: current onboarding workspace.
- `source`: `step3_desktop` (literal; reserved for a future web
emission under a different value).
- `outcome`: `found` (at least one runtime registered before the
5 s grace window expired) or `empty` (none registered by then).
- `runtime_count`: number of runtimes visible to this user at
@@ -602,38 +419,6 @@ request payload.
`JSON.stringify`, and the entire payload is dropped if it still exceeds
512 chars. That way PostHog sees either intact JSON or nothing at all.
## Reconciliation
`agent_task_completed` is the canonical PostHog-side task success event. It
should reconcile daily against the operational source of truth:
```sql
SELECT date_trunc('day', completed_at AT TIME ZONE 'UTC') AS day,
count(*) AS db_completed_tasks
FROM agent_task_queue
WHERE status = 'completed'
AND completed_at >= now() - interval '30 days'
GROUP BY 1
ORDER BY 1;
```
Equivalent HogQL:
```sql
SELECT toStartOfDay(timestamp) AS day,
count() AS posthog_completed_tasks
FROM events
WHERE event = 'agent_task_completed'
AND properties.environment = 'production'
AND timestamp >= now() - interval 30 day
GROUP BY day
ORDER BY day
```
The expected difference should be near zero. Allow a small delay window for
PostHog ingestion and backend analytics queue drops; sustained drift means
either an emission site is missing or PostHog shipping is unhealthy.
## Governance
Before adding, renaming, or removing any event:

View File

@@ -1,170 +0,0 @@
/**
* E2E: chat attachment upload + send back-fills the message link.
*
* Stays at the HTTP layer (auth → upload-file → send-chat-message → DB
* check) so the test doesn't depend on a real agent runtime being online.
* The UI wiring is covered by `chat-input.test.tsx` in @multica/views; this
* spec is the end-to-end contract proof: the backend really does persist
* chat_session_id at upload and back-fill chat_message_id at send.
*/
import "./env";
import { test, expect } from "@playwright/test";
import pg from "pg";
import { createTestApi } from "./helpers";
import type { TestApiClient } from "./fixtures";
const API_BASE =
process.env.NEXT_PUBLIC_API_URL || `http://localhost:${process.env.PORT || "8080"}`;
const DATABASE_URL =
process.env.DATABASE_URL ?? "postgres://multica:multica@localhost:5432/multica?sslmode=disable";
interface UploadRow {
id: string;
url: string;
chat_session_id: string | null;
chat_message_id: string | null;
}
async function authedFetch(api: TestApiClient, path: string, init?: RequestInit) {
const token = api.getToken();
if (!token) throw new Error("test api client not logged in");
const headers: Record<string, string> = {
Authorization: `Bearer ${token}`,
...((init?.headers as Record<string, string>) ?? {}),
};
return fetch(`${API_BASE}${path}`, { ...init, headers });
}
test.describe("Chat attachments", () => {
let api: TestApiClient;
let pgClient: pg.Client | null = null;
let createdSessionId: string | null = null;
let createdAgentId: string | null = null;
let createdRuntimeId: string | null = null;
test.beforeEach(async () => {
api = await createTestApi();
pgClient = new pg.Client(DATABASE_URL);
await pgClient.connect();
});
test.afterEach(async () => {
try {
if (pgClient) {
if (createdSessionId) {
await pgClient.query(`DELETE FROM chat_session WHERE id = $1`, [createdSessionId]);
}
if (createdAgentId) {
await pgClient.query(`DELETE FROM agent WHERE id = $1`, [createdAgentId]);
}
if (createdRuntimeId) {
await pgClient.query(`DELETE FROM agent_runtime WHERE id = $1`, [createdRuntimeId]);
}
}
} finally {
if (pgClient) await pgClient.end();
pgClient = null;
createdSessionId = null;
createdAgentId = null;
createdRuntimeId = null;
await api.cleanup();
}
});
test("upload-file binds attachment to the chat_session; send back-fills chat_message_id", async () => {
expect(pgClient).not.toBeNull();
const pgc = pgClient!;
// Resolve the workspace + caller so we can seed an agent/runtime/session
// directly via SQL. Going through the HTTP API would require modelling
// local-daemon ownership which isn't needed for this contract test.
const workspaces = await api.getWorkspaces();
const ws = workspaces[0]!;
api.setWorkspaceSlug(ws.slug);
api.setWorkspaceId(ws.id);
const userRow = await pgc.query(
`SELECT id FROM "user" WHERE email = $1 LIMIT 1`,
["e2e@multica.ai"],
);
if (userRow.rows.length === 0) throw new Error("e2e user missing");
const userId = userRow.rows[0].id as string;
// Seed runtime + agent + chat_session.
const runtimeIns = await pgc.query(
`INSERT INTO agent_runtime (
workspace_id, daemon_id, name, runtime_mode, provider, status,
device_info, metadata, last_seen_at
)
VALUES ($1, NULL, $2, 'cloud', $3, 'online', $4, '{}'::jsonb, now())
RETURNING id`,
[ws.id, `e2e chat runtime ${Date.now()}`, "e2e_chat_runtime", "E2E chat runtime"],
);
createdRuntimeId = runtimeIns.rows[0].id as string;
const agentIns = await pgc.query(
`INSERT INTO agent (
workspace_id, name, description, runtime_mode, runtime_config,
runtime_id, visibility, max_concurrent_tasks, owner_id
)
VALUES ($1, $2, '', 'cloud', '{}'::jsonb, $3, 'workspace', 1, $4)
RETURNING id`,
[ws.id, `E2E Chat Agent ${Date.now()}`, createdRuntimeId, userId],
);
createdAgentId = agentIns.rows[0].id as string;
const sessionIns = await pgc.query(
`INSERT INTO chat_session (workspace_id, agent_id, creator_id, title, status)
VALUES ($1, $2, $3, 'E2E Chat Attachment Session', 'active')
RETURNING id`,
[ws.id, createdAgentId, userId],
);
createdSessionId = sessionIns.rows[0].id as string;
// 1. Upload a small PNG against the chat session.
const pngBytes = Buffer.from([
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, // PNG signature
0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, // IHDR
]);
const form = new FormData();
form.append("file", new Blob([new Uint8Array(pngBytes)], { type: "image/png" }), "e2e.png");
form.append("chat_session_id", createdSessionId);
const uploadRes = await authedFetch(api, "/api/upload-file", {
method: "POST",
body: form,
headers: { "X-Workspace-Slug": ws.slug },
});
expect(uploadRes.status).toBe(200);
const uploaded = (await uploadRes.json()) as UploadRow;
expect(uploaded.chat_session_id).toBe(createdSessionId);
expect(uploaded.chat_message_id).toBeNull();
expect(uploaded.url).toBeTruthy();
// 2. Send a chat message that references the attachment.
const sendRes = await authedFetch(api, `/api/chat/sessions/${createdSessionId}/messages`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Workspace-Slug": ws.slug,
},
body: JSON.stringify({
content: `look at this ![](${uploaded.url})`,
attachment_ids: [uploaded.id],
}),
});
expect(sendRes.status).toBe(201);
const sendBody = (await sendRes.json()) as { message_id: string; task_id: string };
expect(sendBody.message_id).toBeTruthy();
// 3. DB check: the attachment row's chat_message_id matches the new message.
const after = await pgc.query<{ chat_message_id: string | null }>(
`SELECT chat_message_id::text FROM attachment WHERE id = $1`,
[uploaded.id],
);
expect(after.rows[0]?.chat_message_id).toBe(sendBody.message_id);
// 4. Clean up the attachment we created (chat_session cascade handles the
// rest in afterEach via chat_session row deletion).
await pgc.query(`DELETE FROM attachment WHERE id = $1`, [uploaded.id]);
});
});

View File

@@ -13,8 +13,7 @@
"test": "turbo test",
"lint": "turbo lint",
"clean": "turbo clean && rm -rf node_modules",
"ui:add": "cd packages/ui && npx shadcn@latest add",
"generate:reserved-slugs": "node scripts/generate-reserved-slugs.mjs"
"ui:add": "cd packages/ui && npx shadcn@latest add"
},
"packageManager": "pnpm@10.28.2",
"pnpm": {

View File

@@ -49,8 +49,6 @@ function makeRuntime(overrides: Partial<AgentRuntime> = {}): AgentRuntime {
device_info: "",
metadata: {},
owner_id: null,
visibility: "private",
timezone: "UTC",
last_seen_at: "2026-04-27T11:59:50Z",
created_at: "2026-04-01T00:00:00Z",
updated_at: "2026-04-01T00:00:00Z",

View File

@@ -82,30 +82,3 @@ export function agentTasksOptions(wsId: string, agentId: string) {
refetchOnWindowFocus: true,
});
}
// Agent templates are workspace-independent: a static catalog served from
// the server's embedded JSON. Cache effectively forever — the only way the
// list / detail change is a server deploy, and a hard reload picks that up.
export const agentTemplateKeys = {
all: () => ["agent-templates"] as const,
list: () => [...agentTemplateKeys.all(), "list"] as const,
detail: (slug: string) => [...agentTemplateKeys.all(), "detail", slug] as const,
};
export function agentTemplateListOptions() {
return queryOptions({
queryKey: agentTemplateKeys.list(),
queryFn: () => api.listAgentTemplates(),
staleTime: Infinity,
gcTime: 30 * 60 * 1000,
});
}
export function agentTemplateDetailOptions(slug: string) {
return queryOptions({
queryKey: agentTemplateKeys.detail(slug),
queryFn: () => api.getAgentTemplate(slug),
staleTime: Infinity,
gcTime: 30 * 60 * 1000,
});
}

View File

@@ -45,33 +45,20 @@ describe("initAnalytics super-properties", () => {
expect(posthog.register).toHaveBeenCalledWith({
client_type: "web",
app_version: "1.2.3",
environment: "dev",
event_schema_version: 2,
is_demo: false,
});
});
it("omits app_version when not provided", async () => {
const { analytics, posthog } = await loadModule();
analytics.initAnalytics({ key: "k", host: "" });
expect(posthog.register).toHaveBeenCalledWith({
client_type: "web",
environment: "dev",
event_schema_version: 2,
is_demo: false,
});
expect(posthog.register).toHaveBeenCalledWith({ client_type: "web" });
});
it("detects desktop when window.electron is present", async () => {
vi.stubGlobal("window", { electron: {} });
const { analytics, posthog } = await loadModule();
analytics.initAnalytics({ key: "k", host: "" });
expect(posthog.register).toHaveBeenCalledWith({
client_type: "desktop",
environment: "dev",
event_schema_version: 2,
is_demo: false,
});
expect(posthog.register).toHaveBeenCalledWith({ client_type: "desktop" });
});
});
@@ -89,9 +76,6 @@ describe("resetAnalytics", () => {
expect(posthog.register).toHaveBeenCalledWith({
client_type: "web",
app_version: "1.2.3",
environment: "dev",
event_schema_version: 2,
is_demo: false,
});
});

View File

@@ -14,8 +14,6 @@
import posthog from "posthog-js";
export const EVENT_SCHEMA_VERSION = 2;
const SIGNUP_SOURCE_COOKIE = "multica_signup_source";
// Per-value cap keeps a long utm_content from blowing the budget. We drop
// the entire cookie if the JSON still exceeds the overall limit — partial
@@ -36,8 +34,6 @@ let initialized = false;
// most recent pending identify (only one matters, since it's per-session)
// and flush it inside initAnalytics.
let pendingIdentify: { userId: string; props?: Record<string, unknown> } | null = null;
let currentUserId: string | null = null;
let analyticsEnvironment = "dev";
// Likewise pageviews: the initial "/" pageview is the anchor of the
// acquisition funnel, and the Next.js router fires it on mount before the
// config fetch resolves. We keep the first pending pageview so that step
@@ -82,7 +78,6 @@ export interface AnalyticsConfig {
* available.
*/
appVersion?: string;
environment?: string;
}
export type ClientType = "desktop" | "web";
@@ -140,7 +135,6 @@ export function initAnalytics(config: AnalyticsConfig | null | undefined): boole
disable_session_recording: true,
disable_surveys: true,
});
analyticsEnvironment = normalizeEnvironment(config.environment);
// Register super-properties — attached to every event emitted from this
// client. `client_type` is the canonical split between desktop and web
// (PostHog's own `$lib` reports "web" for both because Electron renderers
@@ -148,19 +142,13 @@ export function initAnalytics(config: AnalyticsConfig | null | undefined): boole
// builds without a version don't pollute the property.
// We cache the set so resetAnalytics() can re-apply it after
// posthog.reset() — reset() clears persisted super-properties otherwise.
superProperties = {
client_type: detectClientType(),
event_schema_version: EVENT_SCHEMA_VERSION,
environment: analyticsEnvironment,
is_demo: false,
};
superProperties = { client_type: detectClientType() };
if (config.appVersion) superProperties.app_version = config.appVersion;
posthog.register(superProperties);
initialized = true;
// Flush any identify() that arrived before init resolved.
if (pendingIdentify) {
currentUserId = pendingIdentify.userId;
posthog.identify(pendingIdentify.userId, pendingIdentify.props);
pendingIdentify = null;
}
@@ -176,7 +164,7 @@ export function initAnalytics(config: AnalyticsConfig | null | undefined): boole
while (pendingOps.length > 0) {
const op = pendingOps.shift()!;
if (op.kind === "event") {
posthog.capture(op.name, withClientEventProperties(op.props));
posthog.capture(op.name, op.props);
} else {
capturePersonSet(op.props);
}
@@ -194,7 +182,6 @@ export function initAnalytics(config: AnalyticsConfig | null | undefined): boole
* config and user in parallel, so identify can arrive first.
*/
export function identify(userId: string, userProperties?: Record<string, unknown>): void {
currentUserId = userId;
if (!initialized) {
pendingIdentify = { userId, props: userProperties };
return;
@@ -207,7 +194,6 @@ export function identify(userId: string, userProperties?: Record<string, unknown
* and doesn't bleed the previous user's events into a new session.
*/
export function resetAnalytics(): void {
currentUserId = null;
pendingIdentify = null;
pendingPageview = null;
pendingOps.length = 0;
@@ -239,7 +225,7 @@ export function captureEvent(
pendingOps.push({ kind: "event", name, props });
return;
}
posthog.capture(name, withClientEventProperties(props));
posthog.capture(name, props);
}
/**
@@ -267,43 +253,6 @@ function capturePersonSet(props: Record<string, unknown>): void {
posthog.capture("$set", { $set: props });
}
function withClientEventProperties(
props?: Record<string, unknown>,
): Record<string, unknown> {
const next: Record<string, unknown> = { ...(props ?? {}) };
if (currentUserId && next.user_id === undefined) {
next.user_id = currentUserId;
}
if (next.event_schema_version === undefined) {
next.event_schema_version = EVENT_SCHEMA_VERSION;
}
if (next.environment === undefined) {
next.environment = analyticsEnvironment;
}
if (next.is_demo === undefined) {
next.is_demo = false;
}
return next;
}
function normalizeEnvironment(value: string | undefined): string {
switch ((value || "").trim().toLowerCase()) {
case "production":
case "prod":
return "production";
case "staging":
case "stage":
return "staging";
case "development":
case "dev":
case "test":
case "local":
return "dev";
default:
return "dev";
}
}
/**
* Capture a page view. Call once per client-side navigation. We disable
* posthog's automatic pageview tracking in init() so this module owns the

View File

@@ -144,123 +144,4 @@ describe("ApiClient", () => {
expect(headers["X-Client-Version"]).toBeUndefined();
expect(headers["X-Client-OS"]).toBeUndefined();
});
describe("getAttachment", () => {
it("returns the parsed attachment for a well-formed response", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(
new Response(
JSON.stringify({
id: "att-1",
workspace_id: "ws-1",
issue_id: null,
comment_id: null,
uploader_type: "member",
uploader_id: "u-1",
filename: "report.md",
url: "https://static.example.test/ws/att-1.md",
download_url:
"https://static.example.test/ws/att-1.md?Policy=p&Signature=s&Key-Pair-Id=k",
content_type: "text/markdown",
size_bytes: 123,
created_at: "2026-05-11T00:00:00Z",
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
),
),
);
const client = new ApiClient("https://api.example.test");
const att = await client.getAttachment("att-1");
expect(att.id).toBe("att-1");
expect(att.download_url).toContain("Policy=");
});
it("falls back to an empty attachment when the response is missing download_url", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(
new Response(JSON.stringify({ id: "att-1" }), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
),
);
const client = new ApiClient("https://api.example.test");
const att = await client.getAttachment("att-1");
// parseWithFallback returns the EMPTY_ATTACHMENT record so callers can
// safely read `download_url` without crashing — they'll see "" and
// surface a user-facing error instead of opening `undefined`.
expect(att.id).toBe("");
expect(att.download_url).toBe("");
});
});
describe("chat attachment wiring", () => {
it("uploadFile includes chat_session_id in the FormData body", async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ id: "att-1", url: "https://cdn/x" }), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
vi.stubGlobal("fetch", fetchMock);
const client = new ApiClient("https://api.example.test");
const file = new File(["hi"], "hi.png", { type: "image/png" });
await client.uploadFile(file, { chatSessionId: "session-123" });
expect(fetchMock).toHaveBeenCalledTimes(1);
const [url, init] = fetchMock.mock.calls[0]!;
expect(url).toBe("https://api.example.test/api/upload-file");
expect(init?.method).toBe("POST");
const body = init?.body as FormData;
expect(body).toBeInstanceOf(FormData);
expect(body.get("chat_session_id")).toBe("session-123");
expect(body.get("issue_id")).toBeNull();
expect(body.get("comment_id")).toBeNull();
});
it("sendChatMessage serialises attachment_ids onto the JSON body when present", async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ message_id: "m1", task_id: "t1", created_at: "" }), {
status: 201,
headers: { "Content-Type": "application/json" },
}),
);
vi.stubGlobal("fetch", fetchMock);
const client = new ApiClient("https://api.example.test");
await client.sendChatMessage("session-1", "hello", ["att-1", "att-2"]);
const [, init] = fetchMock.mock.calls[0]!;
expect(JSON.parse(init?.body as string)).toEqual({
content: "hello",
attachment_ids: ["att-1", "att-2"],
});
});
it("sendChatMessage omits attachment_ids when the list is empty or undefined", async () => {
const fetchMock = vi.fn().mockImplementation(() =>
Promise.resolve(
new Response(JSON.stringify({ message_id: "m1", task_id: "t1", created_at: "" }), {
status: 201,
headers: { "Content-Type": "application/json" },
}),
),
);
vi.stubGlobal("fetch", fetchMock);
const client = new ApiClient("https://api.example.test");
await client.sendChatMessage("session-1", "hello");
await client.sendChatMessage("session-1", "again", []);
expect(JSON.parse(fetchMock.mock.calls[0]![1]?.body as string)).toEqual({ content: "hello" });
expect(JSON.parse(fetchMock.mock.calls[1]![1]?.body as string)).toEqual({ content: "again" });
});
});
});

View File

@@ -11,10 +11,6 @@ import type {
ListIssuesParams,
Agent,
CreateAgentRequest,
AgentTemplate,
AgentTemplateSummary,
CreateAgentFromTemplateRequest,
CreateAgentFromTemplateResponse,
UpdateAgentRequest,
AgentTask,
AgentActivityBucket,
@@ -30,7 +26,6 @@ import type {
MemberWithUser,
User,
Skill,
SkillSummary,
CreateSkillRequest,
UpdateSkillRequest,
SetAgentSkillsRequest,
@@ -42,15 +37,13 @@ import type {
RuntimeHourlyActivity,
RuntimeUsageByAgent,
RuntimeUsageByHour,
DashboardUsageDaily,
DashboardUsageByAgent,
DashboardAgentRunTime,
RuntimeUpdate,
RuntimeModelListRequest,
RuntimeLocalSkillListRequest,
CreateRuntimeLocalSkillImportRequest,
RuntimeLocalSkillImportRequest,
TimelineEntry,
TimelinePage,
TimelinePageParam,
AssigneeFrequencyEntry,
TaskMessagePayload,
Attachment,
@@ -88,35 +81,11 @@ import type {
ListAutopilotRunsResponse,
NotificationPreferenceResponse,
NotificationPreferences,
GitHubPullRequest,
ListGitHubInstallationsResponse,
GitHubConnectResponse,
} from "../types";
import type { OnboardingCompletionPath } from "../onboarding/types";
import { type Logger, noopLogger } from "../logger";
import { createRequestId } from "../utils";
import { getCurrentSlug } from "../platform/workspace-storage";
import { parseWithFallback } from "./schema";
import {
AgentTemplateSchema,
AgentTemplateSummaryListSchema,
AttachmentResponseSchema,
ChildIssuesResponseSchema,
CommentsListSchema,
CreateAgentFromTemplateResponseSchema,
DashboardAgentRunTimeListSchema,
DashboardUsageByAgentListSchema,
DashboardUsageDailyListSchema,
EMPTY_AGENT_TEMPLATE_DETAIL,
EMPTY_AGENT_TEMPLATE_SUMMARY_LIST,
EMPTY_ATTACHMENT,
EMPTY_CREATE_AGENT_FROM_TEMPLATE_RESPONSE,
EMPTY_LIST_ISSUES_RESPONSE,
EMPTY_TIMELINE_ENTRIES,
ListIssuesResponseSchema,
SubscribersListSchema,
TimelineEntriesSchema,
} from "./schemas";
/** Identifies the calling client to the server.
* Sent on every HTTP request as X-Client-Platform / X-Client-Version /
@@ -354,7 +323,6 @@ export class ApiClient {
async markOnboardingComplete(payload?: {
completion_path?: OnboardingCompletionPath;
workspace_id?: string;
}): Promise<User> {
return this.fetch("/api/me/onboarding/complete", {
method: "POST",
@@ -429,11 +397,7 @@ export class ApiClient {
if (params?.creator_id) search.set("creator_id", params.creator_id);
if (params?.project_id) search.set("project_id", params.project_id);
if (params?.open_only) search.set("open_only", "true");
const path = `/api/issues?${search}`;
const raw = await this.fetch<unknown>(path);
return parseWithFallback(raw, ListIssuesResponseSchema, EMPTY_LIST_ISSUES_RESPONSE, {
endpoint: "GET /api/issues",
});
return this.fetch(`/api/issues?${search}`);
}
async searchIssues(params: { q: string; limit?: number; offset?: number; include_closed?: boolean; signal?: AbortSignal }): Promise<SearchIssuesResponse> {
@@ -463,7 +427,7 @@ export class ApiClient {
});
}
async quickCreateIssue(data: { agent_id: string; prompt: string; project_id?: string | null }): Promise<{ task_id: string }> {
async quickCreateIssue(data: { agent_id: string; prompt: string }): Promise<{ task_id: string }> {
return this.fetch("/api/issues/quick-create", {
method: "POST",
body: JSON.stringify(data),
@@ -489,10 +453,7 @@ export class ApiClient {
}
async listChildIssues(id: string): Promise<{ issues: Issue[] }> {
const raw = await this.fetch<unknown>(`/api/issues/${id}/children`);
return parseWithFallback(raw, ChildIssuesResponseSchema, { issues: [] }, {
endpoint: "GET /api/issues/:id/children",
});
return this.fetch(`/api/issues/${id}/children`);
}
async getChildIssueProgress(): Promise<{ progress: { parent_issue_id: string; total: number; done: number }[] }> {
@@ -519,10 +480,7 @@ export class ApiClient {
// Comments
async listComments(issueId: string): Promise<Comment[]> {
const raw = await this.fetch<unknown>(`/api/issues/${issueId}/comments`);
return parseWithFallback(raw, CommentsListSchema, [], {
endpoint: "GET /api/issues/:id/comments",
});
return this.fetch(`/api/issues/${issueId}/comments`);
}
async createComment(issueId: string, content: string, type?: string, parentId?: string, attachmentIds?: string[]): Promise<Comment> {
@@ -537,13 +495,17 @@ export class ApiClient {
});
}
async listTimeline(issueId: string): Promise<TimelineEntry[]> {
const raw = await this.fetch<unknown>(
`/api/issues/${issueId}/timeline`,
);
return parseWithFallback(raw, TimelineEntriesSchema, EMPTY_TIMELINE_ENTRIES, {
endpoint: "GET /api/issues/:id/timeline",
});
async listTimeline(
issueId: string,
pageParam: TimelinePageParam = { mode: "latest" },
limit = 50,
): Promise<TimelinePage> {
const params = new URLSearchParams();
params.set("limit", String(limit));
if (pageParam.mode === "before") params.set("before", pageParam.cursor);
else if (pageParam.mode === "after") params.set("after", pageParam.cursor);
else if (pageParam.mode === "around") params.set("around", pageParam.id);
return this.fetch(`/api/issues/${issueId}/timeline?${params.toString()}`);
}
async getAssigneeFrequency(): Promise<AssigneeFrequencyEntry[]> {
@@ -561,14 +523,6 @@ export class ApiClient {
await this.fetch(`/api/comments/${commentId}`, { method: "DELETE" });
}
async resolveComment(commentId: string): Promise<Comment> {
return this.fetch(`/api/comments/${commentId}/resolve`, { method: "POST" });
}
async unresolveComment(commentId: string): Promise<Comment> {
return this.fetch(`/api/comments/${commentId}/resolve`, { method: "DELETE" });
}
async addReaction(commentId: string, emoji: string): Promise<Reaction> {
return this.fetch(`/api/comments/${commentId}/reactions`, {
method: "POST",
@@ -599,10 +553,7 @@ export class ApiClient {
// Subscribers
async listIssueSubscribers(issueId: string): Promise<IssueSubscriber[]> {
const raw = await this.fetch<unknown>(`/api/issues/${issueId}/subscribers`);
return parseWithFallback(raw, SubscribersListSchema, [], {
endpoint: "GET /api/issues/:id/subscribers",
});
return this.fetch(`/api/issues/${issueId}/subscribers`);
}
async subscribeToIssue(issueId: string, userId?: string, userType?: string): Promise<void> {
@@ -644,51 +595,6 @@ export class ApiClient {
});
}
async listAgentTemplates(): Promise<AgentTemplateSummary[]> {
const raw = await this.fetch<unknown>("/api/agent-templates");
return parseWithFallback(
raw,
AgentTemplateSummaryListSchema,
EMPTY_AGENT_TEMPLATE_SUMMARY_LIST,
{ endpoint: "GET /api/agent-templates" },
);
}
async getAgentTemplate(slug: string): Promise<AgentTemplate> {
const raw = await this.fetch<unknown>(
`/api/agent-templates/${encodeURIComponent(slug)}`,
);
// Round-trip the requested slug into the fallback so a malformed
// detail response still produces a navigable record matching the URL
// the user clicked.
return parseWithFallback(
raw,
AgentTemplateSchema,
{ ...EMPTY_AGENT_TEMPLATE_DETAIL, slug },
{ endpoint: "GET /api/agent-templates/:slug" },
);
}
/** Creates an agent from a curated template. The server fetches every
* referenced skill URL in parallel, materializes them into the workspace
* (find-or-create by name), and writes the agent + skill bindings in a
* single transaction. On any upstream fetch failure, the entire write is
* rolled back and the API returns 422 with `failed_urls`. */
async createAgentFromTemplate(
data: CreateAgentFromTemplateRequest,
): Promise<CreateAgentFromTemplateResponse> {
const raw = await this.fetch<unknown>("/api/agents/from-template", {
method: "POST",
body: JSON.stringify(data),
});
return parseWithFallback(
raw,
CreateAgentFromTemplateResponseSchema,
EMPTY_CREATE_AGENT_FROM_TEMPLATE_RESPONSE,
{ endpoint: "POST /api/agents/from-template" },
);
}
async updateAgent(id: string, data: UpdateAgentRequest): Promise<Agent> {
return this.fetch(`/api/agents/${id}`, {
method: "PUT",
@@ -723,16 +629,6 @@ export class ApiClient {
await this.fetch(`/api/runtimes/${runtimeId}`, { method: "DELETE" });
}
async updateRuntime(
runtimeId: string,
patch: { timezone?: string; visibility?: "private" | "public" },
): Promise<AgentRuntime> {
return this.fetch(`/api/runtimes/${runtimeId}`, {
method: "PATCH",
body: JSON.stringify(patch),
});
}
async getRuntimeUsage(runtimeId: string, params?: { days?: number }): Promise<RuntimeUsage[]> {
const search = new URLSearchParams();
if (params?.days) search.set("days", String(params.days));
@@ -761,58 +657,6 @@ export class ApiClient {
return this.fetch(`/api/runtimes/${runtimeId}/usage/by-hour?${search}`);
}
// ---------------------------------------------------------------------------
// Workspace dashboard — three independent rollups for `/{slug}/dashboard`.
// Each accepts an optional `project_id` to narrow the scope to one project.
// Cost is computed client-side from the model pricing table (same contract
// as the per-runtime endpoints above).
// ---------------------------------------------------------------------------
async getDashboardUsageDaily(
params: { days?: number; project_id?: string | null },
): Promise<DashboardUsageDaily[]> {
const search = new URLSearchParams();
if (params.days) search.set("days", String(params.days));
if (params.project_id) search.set("project_id", params.project_id);
const raw = await this.fetch<unknown>(`/api/dashboard/usage/daily?${search}`);
return parseWithFallback<DashboardUsageDaily[]>(
raw,
DashboardUsageDailyListSchema,
[],
{ endpoint: "GET /api/dashboard/usage/daily" },
);
}
async getDashboardUsageByAgent(
params: { days?: number; project_id?: string | null },
): Promise<DashboardUsageByAgent[]> {
const search = new URLSearchParams();
if (params.days) search.set("days", String(params.days));
if (params.project_id) search.set("project_id", params.project_id);
const raw = await this.fetch<unknown>(`/api/dashboard/usage/by-agent?${search}`);
return parseWithFallback<DashboardUsageByAgent[]>(
raw,
DashboardUsageByAgentListSchema,
[],
{ endpoint: "GET /api/dashboard/usage/by-agent" },
);
}
async getDashboardAgentRunTime(
params: { days?: number; project_id?: string | null },
): Promise<DashboardAgentRunTime[]> {
const search = new URLSearchParams();
if (params.days) search.set("days", String(params.days));
if (params.project_id) search.set("project_id", params.project_id);
const raw = await this.fetch<unknown>(`/api/dashboard/agent-runtime?${search}`);
return parseWithFallback<DashboardAgentRunTime[]>(
raw,
DashboardAgentRunTimeListSchema,
[],
{ endpoint: "GET /api/dashboard/agent-runtime" },
);
}
async initiateUpdate(
runtimeId: string,
targetVersion: string,
@@ -921,12 +765,6 @@ export class ApiClient {
});
}
async rerunIssue(issueId: string): Promise<AgentTask> {
return this.fetch(`/api/issues/${issueId}/rerun`, {
method: "POST",
});
}
// Inbox
async listInbox(): Promise<InboxItem[]> {
return this.fetch("/api/inbox");
@@ -979,7 +817,6 @@ export class ApiClient {
google_client_id?: string;
posthog_key?: string;
posthog_host?: string;
analytics_environment?: string;
}> {
return this.fetch("/api/config");
}
@@ -1076,7 +913,7 @@ export class ApiClient {
}
// Skills
async listSkills(): Promise<SkillSummary[]> {
async listSkills(): Promise<Skill[]> {
return this.fetch("/api/skills");
}
@@ -1109,7 +946,7 @@ export class ApiClient {
});
}
async listAgentSkills(agentId: string): Promise<SkillSummary[]> {
async listAgentSkills(agentId: string): Promise<Skill[]> {
return this.fetch(`/api/agents/${agentId}/skills`);
}
@@ -1137,15 +974,11 @@ export class ApiClient {
}
// File Upload & Attachments
async uploadFile(
file: File,
opts?: { issueId?: string; commentId?: string; chatSessionId?: string },
): Promise<Attachment> {
async uploadFile(file: File, opts?: { issueId?: string; commentId?: string }): Promise<Attachment> {
const formData = new FormData();
formData.append("file", file);
if (opts?.issueId) formData.append("issue_id", opts.issueId);
if (opts?.commentId) formData.append("comment_id", opts.commentId);
if (opts?.chatSessionId) formData.append("chat_session_id", opts.chatSessionId);
const rid = createRequestId();
const start = Date.now();
@@ -1166,10 +999,7 @@ export class ApiClient {
}
this.logger.info(`${res.status} /api/upload-file`, { rid, duration: `${Date.now() - start}ms` });
const raw = (await res.json()) as unknown;
return parseWithFallback(raw, AttachmentResponseSchema, EMPTY_ATTACHMENT, {
endpoint: "POST /api/upload-file",
});
return res.json() as Promise<Attachment>;
}
// Chat Sessions
@@ -1197,18 +1027,10 @@ export class ApiClient {
return this.fetch(`/api/chat/sessions/${sessionId}/messages`);
}
async sendChatMessage(
sessionId: string,
content: string,
attachmentIds?: string[],
): Promise<SendChatMessageResponse> {
const body: { content: string; attachment_ids?: string[] } = { content };
if (attachmentIds && attachmentIds.length > 0) {
body.attachment_ids = attachmentIds;
}
async sendChatMessage(sessionId: string, content: string): Promise<SendChatMessageResponse> {
return this.fetch(`/api/chat/sessions/${sessionId}/messages`, {
method: "POST",
body: JSON.stringify(body),
body: JSON.stringify({ content }),
});
}
@@ -1232,17 +1054,6 @@ export class ApiClient {
return this.fetch(`/api/issues/${issueId}/attachments`);
}
// Fetches a fresh attachment metadata record. The server re-signs
// `download_url` on every call (30 min expiry), so the click-time
// download flow uses this endpoint to avoid handing the user a stale
// signed URL cached in TanStack Query.
async getAttachment(id: string): Promise<Attachment> {
const raw = await this.fetch<unknown>(`/api/attachments/${id}`);
return parseWithFallback(raw, AttachmentResponseSchema, EMPTY_ATTACHMENT, {
endpoint: "GET /api/attachments/{id}",
});
}
async deleteAttachment(id: string): Promise<void> {
await this.fetch(`/api/attachments/${id}`, { method: "DELETE" });
}
@@ -1426,23 +1237,4 @@ export class ApiClient {
async deleteAutopilotTrigger(autopilotId: string, triggerId: string): Promise<void> {
await this.fetch(`/api/autopilots/${autopilotId}/triggers/${triggerId}`, { method: "DELETE" });
}
// GitHub integration
async getGitHubConnectURL(workspaceId: string): Promise<GitHubConnectResponse> {
return this.fetch(`/api/workspaces/${workspaceId}/github/connect`);
}
async listGitHubInstallations(workspaceId: string): Promise<ListGitHubInstallationsResponse> {
return this.fetch(`/api/workspaces/${workspaceId}/github/installations`);
}
async deleteGitHubInstallation(workspaceId: string, installationId: string): Promise<void> {
await this.fetch(`/api/workspaces/${workspaceId}/github/installations/${installationId}`, {
method: "DELETE",
});
}
async listIssuePullRequests(issueId: string): Promise<{ pull_requests: GitHubPullRequest[] }> {
return this.fetch(`/api/issues/${issueId}/pull-requests`);
}
}

View File

@@ -6,8 +6,6 @@ export type {
ImportStarterIssuePayload,
ImportStarterWelcomeIssueTemplate,
} from "./client";
export { parseWithFallback, setSchemaLogger } from "./schema";
export type { ParseOptions } from "./schema";
export { WSClient } from "./ws-client";
import type { ApiClient as ApiClientType } from "./client";

View File

@@ -1,248 +0,0 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { z } from "zod";
import { ApiClient } from "./client";
import { parseWithFallback } from "./schema";
// Helper: stub fetch with a single JSON response. Status defaults to 200.
function stubFetchJson(body: unknown, status = 200) {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(
new Response(typeof body === "string" ? body : JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" },
}),
),
);
}
afterEach(() => {
vi.unstubAllGlobals();
});
// These tests cover the five failure modes that white-screened the desktop
// app in past incidents. The contract is: a malformed response degrades to
// an empty/safe shape, never throws into React.
describe("ApiClient schema fallback", () => {
describe("listTimeline", () => {
it("falls back to an empty array when the body is null", async () => {
stubFetchJson(null);
const client = new ApiClient("https://api.example.test");
const entries = await client.listTimeline("issue-1");
expect(entries).toEqual([]);
});
it("falls back when the body is not an array", async () => {
stubFetchJson({ wrong: "shape" });
const client = new ApiClient("https://api.example.test");
const entries = await client.listTimeline("issue-1");
expect(entries).toEqual([]);
});
it("accepts a new entry type rather than crashing on enum drift", async () => {
stubFetchJson([
{
type: "future_kind", // not in TS union
id: "e-1",
actor_type: "member",
actor_id: "u-1",
created_at: "2026-01-01T00:00:00Z",
},
]);
const client = new ApiClient("https://api.example.test");
const entries = await client.listTimeline("issue-1");
expect(entries).toHaveLength(1);
expect(entries[0]?.type).toBe("future_kind");
});
// Forward-compat: when the server adds a new field to an existing
// shape, `.loose()` lets it pass through unchanged. Without `.loose()`
// zod 4 strips it, which would silently break a future TS type that
// adopts the field — see schemas.ts header comment.
it("preserves unknown fields the schema didn't list", async () => {
stubFetchJson([
{
type: "comment",
id: "e-1",
actor_type: "member",
actor_id: "u-1",
created_at: "2026-01-01T00:00:00Z",
// New server-side field not present in TimelineEntrySchema:
future_field: { nested: "value" },
},
]);
const client = new ApiClient("https://api.example.test");
const entries = await client.listTimeline("issue-1");
const entry = entries[0] as unknown as Record<string, unknown>;
expect(entry.future_field).toEqual({ nested: "value" });
});
});
describe("listIssues", () => {
it("falls back to an empty list when the response is malformed", async () => {
// `issues` having the wrong type triggers the fallback. An object
// with only unexpected keys would *succeed* parsing now (every
// declared field has a default) and just pass the extras through
// via `.loose()`, so we use a wrong-type payload here instead.
stubFetchJson({ issues: "not-an-array", total: 0 });
const client = new ApiClient("https://api.example.test");
const res = await client.listIssues();
expect(res).toEqual({ issues: [], total: 0 });
});
});
describe("listComments", () => {
it("returns [] when the response is not an array", async () => {
stubFetchJson({ wrong: "shape" });
const client = new ApiClient("https://api.example.test");
const comments = await client.listComments("issue-1");
expect(comments).toEqual([]);
});
});
describe("listIssueSubscribers", () => {
it("returns [] when the response is null", async () => {
stubFetchJson(null);
const client = new ApiClient("https://api.example.test");
const subs = await client.listIssueSubscribers("issue-1");
expect(subs).toEqual([]);
});
});
describe("listChildIssues", () => {
it("returns { issues: [] } when the issues field is missing", async () => {
stubFetchJson({});
const client = new ApiClient("https://api.example.test");
const res = await client.listChildIssues("issue-1");
expect(res).toEqual({ issues: [] });
});
});
// Agent template catalog is hit by the desktop create-agent picker.
// Installed desktop builds outlive any given server, so the shape MUST
// survive future field renames / wrapping without crashing. Each test
// here mirrors a concrete future drift we want to absorb.
describe("listAgentTemplates", () => {
it("falls back to [] when the body is null", async () => {
stubFetchJson(null);
const client = new ApiClient("https://api.example.test");
const tmpls = await client.listAgentTemplates();
expect(tmpls).toEqual([]);
});
it("defaults skills to [] when the field is missing from a template", async () => {
// Future server: drops `skills` because the picker no longer reads
// them. Picker code calls `template.skills.length` — must not throw.
stubFetchJson([{ slug: "x", name: "X" }]);
const client = new ApiClient("https://api.example.test");
const tmpls = await client.listAgentTemplates();
expect(tmpls).toHaveLength(1);
expect(tmpls[0]?.skills).toEqual([]);
});
it("accepts the bare-array shape (current contract)", async () => {
stubFetchJson([
{ slug: "a", name: "A", description: "", skills: [] },
{ slug: "b", name: "B", description: "", skills: [] },
]);
const client = new ApiClient("https://api.example.test");
const tmpls = await client.listAgentTemplates();
expect(tmpls.map((t) => t.slug)).toEqual(["a", "b"]);
});
it("accepts a future {templates: [...]} envelope without breaking", async () => {
// Server migrates to a paginated envelope. We unwrap so the picker
// keeps working on the older bare-array consumer.
stubFetchJson({
templates: [{ slug: "a", name: "A", description: "", skills: [] }],
total: 1,
});
const client = new ApiClient("https://api.example.test");
const tmpls = await client.listAgentTemplates();
expect(tmpls).toHaveLength(1);
expect(tmpls[0]?.slug).toBe("a");
});
});
describe("getAgentTemplate", () => {
it("falls back to a minimal record carrying the requested slug", async () => {
// Slug is part of the URL the user clicked — the fallback round-
// trips it so the page header still makes sense after a parse miss.
stubFetchJson({ wrong: "shape" });
const client = new ApiClient("https://api.example.test");
const detail = await client.getAgentTemplate("code-reviewer");
expect(detail.slug).toBe("code-reviewer");
expect(detail.skills).toEqual([]);
expect(detail.instructions).toBe("");
});
it("defaults instructions to '' when the field is missing", async () => {
stubFetchJson({
slug: "code-reviewer",
name: "Code Reviewer",
description: "",
skills: [],
});
const client = new ApiClient("https://api.example.test");
const detail = await client.getAgentTemplate("code-reviewer");
expect(detail.instructions).toBe("");
});
});
describe("createAgentFromTemplate", () => {
it("falls back to an empty agent when the response is malformed", async () => {
// The agent was created server-side even though the client can't
// parse the response — UI code reads `agent.id === ""` and skips
// the navigation step rather than landing on `/agents/`.
stubFetchJson({ unexpected: "shape" });
const client = new ApiClient("https://api.example.test");
const resp = await client.createAgentFromTemplate({
template_slug: "x",
name: "X",
runtime_id: "rt-1",
});
expect(resp.agent.id).toBe("");
expect(resp.imported_skill_ids).toEqual([]);
expect(resp.reused_skill_ids).toEqual([]);
});
it("defaults imported_skill_ids / reused_skill_ids to [] when missing", async () => {
stubFetchJson({ agent: { id: "agent-1" } });
const client = new ApiClient("https://api.example.test");
const resp = await client.createAgentFromTemplate({
template_slug: "x",
name: "X",
runtime_id: "rt-1",
});
expect(resp.agent.id).toBe("agent-1");
expect(resp.imported_skill_ids).toEqual([]);
expect(resp.reused_skill_ids).toEqual([]);
});
});
});
// Direct tests for the helper, decoupled from any specific endpoint —
// guards against an endpoint refactor masking a regression in the helper.
describe("parseWithFallback", () => {
const opts = { endpoint: "TEST /unit" };
it("returns parsed data on success", () => {
const schema = z.object({ id: z.string() });
const out = parseWithFallback({ id: "x" }, schema, { id: "fallback" }, opts);
expect(out).toEqual({ id: "x" });
});
it("returns the fallback when validation fails", () => {
const schema = z.object({ id: z.string() });
const fallback = { id: "fallback" };
const out = parseWithFallback({ id: 123 }, schema, fallback, opts);
expect(out).toBe(fallback);
});
it("returns the fallback when data is null", () => {
const schema = z.object({ id: z.string() });
const fallback = { id: "fallback" };
const out = parseWithFallback(null, schema, fallback, opts);
expect(out).toBe(fallback);
});
});

View File

@@ -1,55 +0,0 @@
import type { ZodType } from "zod";
import { type Logger, noopLogger } from "../logger";
// Module-level logger for schema warnings. Defaults to no-op so test
// runs don't spam stderr; the platform layer wires a real logger via
// `setSchemaLogger` at app boot.
let schemaLogger: Logger = noopLogger;
export function setSchemaLogger(logger: Logger): void {
schemaLogger = logger;
}
export interface ParseOptions {
/** Endpoint identifier used in the warning log so we can grep for which
* contract drifted in production telemetry. */
endpoint: string;
}
/**
* Validate a JSON value parsed from an API response against a zod schema,
* returning the parsed value on success or `fallback` on failure.
*
* On failure we log a warning with the endpoint and zod's structured error,
* but never throw — the UI layer must keep rendering. This is the boundary
* defense that turns "API contract drifted" from a white-screen incident
* into a degraded-but-rendering page.
*
* The return type is anchored to `T` (inferred from `fallback`), not to the
* schema's `z.infer` type. Schemas are intentionally **lenient** — string
* enums kept as `z.string()` so an unknown enum value still parses, etc. —
* so the parsed runtime value can be wider than the strict TS type at the
* call site. The caller asserts compatibility by typing the fallback to the
* expected `T`; downstream code is already responsible for handling unknown
* enum values via `default`-bearing switches and optional chaining.
*
* See CLAUDE.md "API Response Compatibility" for when to reach for this.
*/
export function parseWithFallback<T>(
data: unknown,
schema: ZodType,
fallback: T,
opts: ParseOptions,
): T {
const result = schema.safeParse(data);
if (result.success) return result.data as T;
schemaLogger.warn(
`API response failed schema validation: ${opts.endpoint}`,
{
endpoint: opts.endpoint,
issues: result.error.issues,
received: data,
},
);
return fallback;
}

View File

@@ -1,308 +0,0 @@
import { z } from "zod";
import type {
Agent,
AgentTemplate,
AgentTemplateSummary,
Attachment,
CreateAgentFromTemplateResponse,
ListIssuesResponse,
TimelineEntry,
} from "../types";
// ---------------------------------------------------------------------------
// Schemas for the highest-risk API endpoints — those whose responses drive
// the issue detail page (timeline, comments, subscribers) and the issues
// list. These are the surfaces that white-screened in #2143 / #2147 / #2192.
//
// These schemas are intentionally LENIENT:
// - String enums are stored as `z.string()` rather than `z.enum([...])`.
// A new server-side enum value should render as a generic fallback in
// the UI, never crash a `safeParse`.
// - Optional fields are unioned with `null` and given fallbacks where
// existing UI code already coerces them.
// - Arrays default to `[]` so a missing `reactions` / `attachments` /
// `entries` field doesn't take the page down.
// - Every object schema ends with `.loose()` so unknown server-side
// fields pass through unchanged. zod 4's `.object()` defaults to STRIP,
// which would silently delete fields the schema didn't explicitly list
// — fine while the TS type doesn't claim them, but the moment a future
// PR adds a TS field without updating the schema, the cast `as T` lies
// and the field shows up as `undefined` at runtime. `.loose()` removes
// that synchronisation hazard.
//
// These schemas are deliberately not typed as `z.ZodType<TimelineEntry>` /
// `z.ZodType<Issue>` etc. — the strict TS types narrow string fields to
// literal unions, which would defeat the leniency above. `parseWithFallback`
// returns the parsed value cast to the caller-supplied `T`, so the strict
// type still flows out at the call site; the schema only guards shape.
// ---------------------------------------------------------------------------
const ReactionSchema = z.object({
id: z.string(),
comment_id: z.string(),
actor_type: z.string(),
actor_id: z.string(),
emoji: z.string(),
created_at: z.string(),
});
// Nested attachments embedded in timeline/comment responses stay lenient on
// purpose: a single malformed attachment must not knock the whole timeline
// into the fallback `[]`.
const AttachmentSchema = z.object({
id: z.string(),
}).loose();
// Standalone attachment lookup (`GET /api/attachments/{id}`) is the source of
// truth for click-time download URLs. The two fields the download flow opens
// in a new tab — `download_url` and `url` — must be strings, otherwise we'd
// happily `window.open(undefined)`. `filename` gates the toast/title and is
// also enforced so a missing value falls back to the empty record below.
export const AttachmentResponseSchema = z.object({
id: z.string(),
url: z.string(),
download_url: z.string(),
filename: z.string(),
chat_session_id: z.string().nullable().optional(),
chat_message_id: z.string().nullable().optional(),
}).loose();
export const EMPTY_ATTACHMENT: Attachment = {
id: "",
workspace_id: "",
issue_id: null,
comment_id: null,
chat_session_id: null,
chat_message_id: null,
uploader_type: "",
uploader_id: "",
filename: "",
url: "",
download_url: "",
content_type: "",
size_bytes: 0,
created_at: "",
};
// All object schemas use `.loose()` so unknown server-side fields pass
// through unchanged. zod 4's `.object()` defaults to STRIP, which would
// silently drop new fields and surface as a "field neither showed up in
// the UI" mystery the next time the TS type adopted them but the schema
// wasn't updated in lock-step. `.loose()` removes that synchronisation
// hazard — the schema validates the shape it knows about and leaves the
// rest alone.
const TimelineEntrySchema = z.object({
type: z.string(),
id: z.string(),
actor_type: z.string(),
actor_id: z.string(),
created_at: z.string(),
action: z.string().optional(),
details: z.record(z.string(), z.unknown()).optional(),
content: z.string().optional(),
parent_id: z.string().nullable().optional(),
updated_at: z.string().optional(),
comment_type: z.string().optional(),
reactions: z.array(ReactionSchema).optional(),
attachments: z.array(AttachmentSchema).optional(),
coalesced_count: z.number().optional(),
}).loose();
// /timeline returns a flat array of TimelineEntry, oldest first. The
// previously cursor-paginated wrapper was removed (#1929) — at observed data
// sizes (p99 ~30 entries per issue) paged delivery only created bugs.
export const TimelineEntriesSchema = z.array(TimelineEntrySchema);
export const EMPTY_TIMELINE_ENTRIES: TimelineEntry[] = [];
export const CommentSchema = z.object({
id: z.string(),
issue_id: z.string(),
author_type: z.string(),
author_id: z.string(),
content: z.string(),
type: z.string(),
parent_id: z.string().nullable(),
reactions: z.array(ReactionSchema).default([]),
attachments: z.array(AttachmentSchema).default([]),
created_at: z.string(),
updated_at: z.string(),
}).loose();
export const CommentsListSchema = z.array(CommentSchema);
const IssueSchema = z.object({
id: z.string(),
workspace_id: z.string(),
number: z.number(),
identifier: z.string(),
title: z.string(),
description: z.string().nullable(),
status: z.string(),
priority: z.string(),
assignee_type: z.string().nullable(),
assignee_id: z.string().nullable(),
creator_type: z.string(),
creator_id: z.string(),
parent_issue_id: z.string().nullable(),
project_id: z.string().nullable(),
position: z.number(),
due_date: z.string().nullable(),
reactions: z.array(z.unknown()).optional(),
labels: z.array(z.unknown()).optional(),
created_at: z.string(),
updated_at: z.string(),
}).loose();
export const ListIssuesResponseSchema = z.object({
issues: z.array(IssueSchema).default([]),
total: z.number().default(0),
}).loose();
export const EMPTY_LIST_ISSUES_RESPONSE: ListIssuesResponse = {
issues: [],
total: 0,
};
const SubscriberSchema = z.object({
issue_id: z.string(),
user_type: z.string(),
user_id: z.string(),
reason: z.string(),
created_at: z.string(),
}).loose();
export const SubscribersListSchema = z.array(SubscriberSchema);
export const ChildIssuesResponseSchema = z.object({
issues: z.array(IssueSchema).default([]),
}).loose();
// ---------------------------------------------------------------------------
// Workspace dashboard schemas
//
// The dashboard hits three independent rollup endpoints. Each returns a flat
// array, and every field is consumed by chart / KPI math — a missing number
// silently degrades to NaN downstream, so we coerce missing numbers to 0.
// String fields stay lenient (no enum narrowing) to survive future model /
// agent ID drift.
// ---------------------------------------------------------------------------
const DashboardUsageDailySchema = z.object({
date: z.string(),
model: z.string(),
input_tokens: z.number().default(0),
output_tokens: z.number().default(0),
cache_read_tokens: z.number().default(0),
cache_write_tokens: z.number().default(0),
task_count: z.number().default(0),
}).loose();
export const DashboardUsageDailyListSchema = z.array(DashboardUsageDailySchema);
const DashboardUsageByAgentSchema = z.object({
agent_id: z.string(),
model: z.string(),
input_tokens: z.number().default(0),
output_tokens: z.number().default(0),
cache_read_tokens: z.number().default(0),
cache_write_tokens: z.number().default(0),
task_count: z.number().default(0),
}).loose();
export const DashboardUsageByAgentListSchema = z.array(DashboardUsageByAgentSchema);
const DashboardAgentRunTimeSchema = z.object({
agent_id: z.string(),
total_seconds: z.number().default(0),
task_count: z.number().default(0),
failed_count: z.number().default(0),
}).loose();
export const DashboardAgentRunTimeListSchema = z.array(DashboardAgentRunTimeSchema);
// ---------------------------------------------------------------------------
// Agent template catalog — `/api/agent-templates*` and the
// create-from-template response. The desktop app's create-agent picker
// reaches these endpoints, and a future server change to the template shape
// would white-screen older installed builds (#2192 pattern) without these
// parsers. Lenient by the same rules as IssueSchema above: arrays default to
// `[]`, optional fields stay optional, `.loose()` lets unknown fields pass
// through unchanged.
// ---------------------------------------------------------------------------
const AgentTemplateSkillRefSchema = z.object({
source_url: z.string(),
cached_name: z.string().default(""),
cached_description: z.string().default(""),
}).loose();
const AgentTemplateSummarySchemaBase = z.object({
slug: z.string(),
name: z.string(),
description: z.string().default(""),
category: z.string().optional(),
icon: z.string().optional(),
accent: z.string().optional(),
// skills MUST default to [] — picker code reads `template.skills.length`
// and `.map(...)`, both of which crash on `undefined`. The most common
// future drift (field renamed / wrapped) lands here.
skills: z.array(AgentTemplateSkillRefSchema).default([]),
}).loose();
export const AgentTemplateSummarySchema = AgentTemplateSummarySchemaBase;
// List endpoint historically returns a bare array. Server could legitimately
// migrate to `{templates: [...]}` later — we accept either shape so an old
// desktop survives the upgrade.
export const AgentTemplateSummaryListSchema = z.union([
z.array(AgentTemplateSummarySchemaBase),
z.object({ templates: z.array(AgentTemplateSummarySchemaBase).default([]) })
.loose()
.transform((v) => v.templates),
]);
export const EMPTY_AGENT_TEMPLATE_SUMMARY_LIST: AgentTemplateSummary[] = [];
export const AgentTemplateSchema = AgentTemplateSummarySchemaBase.extend({
// Detail-only field. Default "" so a malformed detail still renders the
// header + skill list; the user just sees an empty Instructions block.
instructions: z.string().default(""),
}).loose();
// Used as the parse fallback for `GET /api/agent-templates/:slug`. Slug comes
// from the URL, so we round-trip the requested one back into the fallback
// at the call site (see `getAgentTemplate` in client.ts).
export const EMPTY_AGENT_TEMPLATE_DETAIL: AgentTemplate = {
slug: "",
name: "",
description: "",
skills: [],
instructions: "",
};
// `agent` is a full Agent record — schematising every field would duplicate
// a 50-field interface and bit-rot fast. We keep it loose and require only
// `id`, the one field the create-from-template flow consumes (used to
// navigate to the new agent's detail page). Downstream code already
// optional-chains the rest.
const MinimalAgentSchema = z.object({
id: z.string(),
}).loose();
export const CreateAgentFromTemplateResponseSchema = z.object({
agent: MinimalAgentSchema,
imported_skill_ids: z.array(z.string()).default([]),
reused_skill_ids: z.array(z.string()).default([]),
}).loose();
// Fallback when the success response fails to parse. The agent server-side
// has likely been created already, so we can't pretend nothing happened —
// the caller (`create-agent-dialog.tsx`) is responsible for noticing
// `agent.id === ""` and skipping navigation while keeping the list
// invalidation, so the user finds their new agent in the list.
export const EMPTY_CREATE_AGENT_FROM_TEMPLATE_RESPONSE: CreateAgentFromTemplateResponse = {
agent: { id: "" } as Agent,
imported_skill_ids: [],
reused_skill_ids: [],
};

View File

@@ -24,13 +24,14 @@ export function useCreateChatSession() {
},
onSettled: () => {
qc.invalidateQueries({ queryKey: chatKeys.sessions(wsId) });
qc.invalidateQueries({ queryKey: chatKeys.allSessions(wsId) });
},
});
}
/**
* Clears the session's unread state server-side. Optimistically flips
* has_unread to false in the cached list so the FAB badge drops
* has_unread to false in the cached lists so the FAB badge drops
* immediately. The server broadcasts chat:session_read so other devices
* also sync.
*/
@@ -45,30 +46,35 @@ export function useMarkChatSessionRead() {
},
onMutate: async (sessionId) => {
await qc.cancelQueries({ queryKey: chatKeys.sessions(wsId) });
await qc.cancelQueries({ queryKey: chatKeys.allSessions(wsId) });
const prevSessions = qc.getQueryData<ChatSession[]>(chatKeys.sessions(wsId));
const prevAll = qc.getQueryData<ChatSession[]>(chatKeys.allSessions(wsId));
const clear = (old?: ChatSession[]) =>
old?.map((s) => (s.id === sessionId ? { ...s, has_unread: false } : s));
qc.setQueryData<ChatSession[]>(chatKeys.sessions(wsId), clear);
qc.setQueryData<ChatSession[]>(chatKeys.allSessions(wsId), clear);
return { prevSessions };
return { prevSessions, prevAll };
},
onError: (err, sessionId, ctx) => {
logger.error("markChatSessionRead.error.rollback", { sessionId, err });
if (ctx?.prevSessions) qc.setQueryData(chatKeys.sessions(wsId), ctx.prevSessions);
if (ctx?.prevAll) qc.setQueryData(chatKeys.allSessions(wsId), ctx.prevAll);
},
onSettled: () => {
qc.invalidateQueries({ queryKey: chatKeys.sessions(wsId) });
qc.invalidateQueries({ queryKey: chatKeys.allSessions(wsId) });
},
});
}
/**
* Hard-deletes a chat session. Optimistically removes the row from the
* sessions list so the dropdown updates instantly; rolls back on error.
* The matching `chat:session_deleted` WS event keeps other tabs/devices
* in sync — see use-realtime-sync.ts.
* Hard-deletes a chat session. Optimistically removes the row from both
* the active and all-sessions lists so the history panel updates instantly;
* rolls back on error. The matching `chat:session_deleted` WS event keeps
* other tabs/devices in sync — see use-realtime-sync.ts.
*/
export function useDeleteChatSession() {
const qc = useQueryClient();
@@ -81,22 +87,27 @@ export function useDeleteChatSession() {
},
onMutate: async (sessionId) => {
await qc.cancelQueries({ queryKey: chatKeys.sessions(wsId) });
await qc.cancelQueries({ queryKey: chatKeys.allSessions(wsId) });
const prevSessions = qc.getQueryData<ChatSession[]>(chatKeys.sessions(wsId));
const prevAll = qc.getQueryData<ChatSession[]>(chatKeys.allSessions(wsId));
const drop = (old?: ChatSession[]) => old?.filter((s) => s.id !== sessionId);
qc.setQueryData<ChatSession[]>(chatKeys.sessions(wsId), drop);
qc.setQueryData<ChatSession[]>(chatKeys.allSessions(wsId), drop);
logger.debug("deleteChatSession.optimistic", { sessionId });
return { prevSessions };
return { prevSessions, prevAll };
},
onError: (err, sessionId, ctx) => {
logger.error("deleteChatSession.error.rollback", { sessionId, err });
if (ctx?.prevSessions) qc.setQueryData(chatKeys.sessions(wsId), ctx.prevSessions);
if (ctx?.prevAll) qc.setQueryData(chatKeys.allSessions(wsId), ctx.prevAll);
},
onSettled: (_data, _err, sessionId) => {
logger.debug("deleteChatSession.settled", { sessionId });
qc.invalidateQueries({ queryKey: chatKeys.sessions(wsId) });
qc.invalidateQueries({ queryKey: chatKeys.allSessions(wsId) });
},
});
}

View File

@@ -10,8 +10,8 @@ import { api } from "../api";
export const chatKeys = {
all: (wsId: string) => ["chat", wsId] as const,
/** Full sessions list (active + archived); the dropdown splits locally. */
sessions: (wsId: string) => [...chatKeys.all(wsId), "sessions"] as const,
allSessions: (wsId: string) => [...chatKeys.all(wsId), "sessions", "all"] as const,
session: (wsId: string, id: string) => [...chatKeys.all(wsId), "session", id] as const,
messages: (sessionId: string) => ["chat", "messages", sessionId] as const,
pendingTask: (sessionId: string) => ["chat", "pending-task", sessionId] as const,
@@ -24,6 +24,14 @@ export const chatKeys = {
export function chatSessionsOptions(wsId: string) {
return queryOptions({
queryKey: chatKeys.sessions(wsId),
queryFn: () => api.listChatSessions(),
staleTime: Infinity,
});
}
export function allChatSessionsOptions(wsId: string) {
return queryOptions({
queryKey: chatKeys.allSessions(wsId),
queryFn: () => api.listChatSessions({ status: "all" }),
staleTime: Infinity,
});

View File

@@ -87,6 +87,7 @@ export interface ChatState {
isOpen: boolean;
activeSessionId: string | null;
selectedAgentId: string | null;
showHistory: boolean;
/** Drafts per session: sessionId (or DRAFT_NEW_SESSION) → markdown text. */
inputDrafts: Record<string, string>;
/**
@@ -103,6 +104,7 @@ export interface ChatState {
toggle: () => void;
setActiveSession: (id: string | null) => void;
setSelectedAgentId: (id: string) => void;
setShowHistory: (show: boolean) => void;
/** sessionId accepts a real session UUID or DRAFT_NEW_SESSION. */
setInputDraft: (sessionId: string, draft: string) => void;
clearInputDraft: (sessionId: string) => void;
@@ -134,6 +136,7 @@ export function createChatStore(options: ChatStoreOptions) {
isOpen: initialIsOpen,
activeSessionId: storage.getItem(wsKey(SESSION_STORAGE_KEY)),
selectedAgentId: storage.getItem(wsKey(AGENT_STORAGE_KEY)),
showHistory: false,
inputDrafts: readDrafts(storage, wsKey(DRAFTS_KEY)),
focusMode: storage.getItem(FOCUS_MODE_KEY) === "true",
chatWidth: Number(storage.getItem(CHAT_WIDTH_KEY)) || CHAT_DEFAULT_W,
@@ -164,6 +167,10 @@ export function createChatStore(options: ChatStoreOptions) {
storage.setItem(wsKey(AGENT_STORAGE_KEY), id);
set({ selectedAgentId: id });
},
setShowHistory: (show) => {
logger.debug("setShowHistory", { to: show });
set({ showHistory: show });
},
setInputDraft: (sessionId, draft) => {
// Debug level — onUpdate fires on every keystroke.
logger.debug("setInputDraft", { sessionId, length: draft.length });

View File

@@ -1 +0,0 @@
export * from "./queries";

View File

@@ -1,72 +0,0 @@
import { queryOptions } from "@tanstack/react-query";
import { api } from "../api";
// Workspace dashboard query options. All three endpoints share the same
// (wsId, days, projectId) key shape so workspace switching, time-range
// changes, and the project filter each invalidate the cache cleanly.
//
// The cache key includes `wsId` explicitly: TanStack Query already isolates
// per workspace via the key, but threading wsId into the queryFn lets
// callers fail fast (return [] on empty wsId) instead of issuing a request
// the server would reject.
//
// `projectId` is normalised to `null` (not undefined / "all") so the
// queryKey shape is stable across renders even when the dropdown sits on
// "all projects".
export const dashboardKeys = {
all: (wsId: string) => ["dashboard", wsId] as const,
daily: (wsId: string, days: number, projectId: string | null) =>
[...dashboardKeys.all(wsId), "daily", days, projectId] as const,
byAgent: (wsId: string, days: number, projectId: string | null) =>
[...dashboardKeys.all(wsId), "by-agent", days, projectId] as const,
agentRuntime: (wsId: string, days: number, projectId: string | null) =>
[...dashboardKeys.all(wsId), "agent-runtime", days, projectId] as const,
};
// 60s staleTime matches the per-runtime usage queries — the data is rollup-
// driven on the server (5-min rollup cadence) and the dashboard isn't a
// real-time view, so background refetches every minute are plenty.
const STALE_TIME = 60 * 1000;
export function dashboardUsageDailyOptions(
wsId: string,
days: number,
projectId: string | null,
) {
return queryOptions({
queryKey: dashboardKeys.daily(wsId, days, projectId),
queryFn: () =>
api.getDashboardUsageDaily({ days, project_id: projectId ?? undefined }),
enabled: !!wsId,
staleTime: STALE_TIME,
});
}
export function dashboardUsageByAgentOptions(
wsId: string,
days: number,
projectId: string | null,
) {
return queryOptions({
queryKey: dashboardKeys.byAgent(wsId, days, projectId),
queryFn: () =>
api.getDashboardUsageByAgent({ days, project_id: projectId ?? undefined }),
enabled: !!wsId,
staleTime: STALE_TIME,
});
}
export function dashboardAgentRunTimeOptions(
wsId: string,
days: number,
projectId: string | null,
) {
return queryOptions({
queryKey: dashboardKeys.agentRuntime(wsId, days, projectId),
queryFn: () =>
api.getDashboardAgentRunTime({ days, project_id: projectId ?? undefined }),
enabled: !!wsId,
staleTime: STALE_TIME,
});
}

View File

@@ -1 +0,0 @@
export * from "./queries";

View File

@@ -1,22 +0,0 @@
import { queryOptions } from "@tanstack/react-query";
import { api } from "../api";
export const githubKeys = {
all: (wsId: string) => ["github", wsId] as const,
installations: (wsId: string) => [...githubKeys.all(wsId), "installations"] as const,
pullRequests: (issueId: string) => ["github", "pull-requests", issueId] as const,
};
export const githubInstallationsOptions = (wsId: string) =>
queryOptions({
queryKey: githubKeys.installations(wsId),
queryFn: () => api.listGitHubInstallations(wsId),
enabled: !!wsId,
});
export const issuePullRequestsOptions = (issueId: string) =>
queryOptions({
queryKey: githubKeys.pullRequests(issueId),
queryFn: () => api.listIssuePullRequests(issueId),
enabled: !!issueId,
});

View File

@@ -14,7 +14,6 @@ export interface UploadResult {
export interface UploadContext {
issueId?: string;
commentId?: string;
chatSessionId?: string;
}
export function useFileUpload(
@@ -34,7 +33,6 @@ export function useFileUpload(
const att: Attachment = await api.uploadFile(file, {
issueId: ctx?.issueId,
commentId: ctx?.commentId,
chatSessionId: ctx?.chatSessionId,
});
return { id: att.id, filename: att.filename, link: att.url };
} finally {

View File

@@ -12,9 +12,9 @@ export const PRIORITY_CONFIG: Record<
IssuePriority,
{ label: string; bars: number; color: string; badgeBg: string; badgeText: string }
> = {
urgent: { label: "Urgent", bars: 4, color: "text-destructive", badgeBg: "bg-destructive/10", badgeText: "text-destructive" },
high: { label: "High", bars: 3, color: "text-warning", badgeBg: "bg-warning/10", badgeText: "text-warning" },
medium: { label: "Medium", bars: 2, color: "text-warning", badgeBg: "bg-warning/10", badgeText: "text-warning" },
low: { label: "Low", bars: 1, color: "text-info", badgeBg: "bg-info/10", badgeText: "text-info" },
urgent: { label: "Urgent", bars: 4, color: "text-destructive", badgeBg: "bg-priority", badgeText: "text-white" },
high: { label: "High", bars: 3, color: "text-warning", badgeBg: "bg-priority/80", badgeText: "text-white" },
medium: { label: "Medium", bars: 2, color: "text-warning", badgeBg: "bg-priority/15", badgeText: "text-priority" },
low: { label: "Low", bars: 1, color: "text-info", badgeBg: "bg-priority/10", badgeText: "text-priority" },
none: { label: "No priority", bars: 0, color: "text-muted-foreground", badgeBg: "bg-muted", badgeText: "text-muted-foreground" },
};

View File

@@ -23,6 +23,12 @@ import type {
ListIssuesCache,
} from "../types";
import type { TimelineEntry, IssueSubscriber, Reaction } from "../types";
import {
mapAllEntries,
filterAllEntries,
prependToLatestPage,
type TimelineCacheData,
} from "./timeline-cache";
// ---------------------------------------------------------------------------
// Shared mutation variable types — used by both mutation hooks and
@@ -109,7 +115,7 @@ export function useCreateIssue() {
);
// Surface the just-created issue in cmd+k's Recent list without
// requiring the user to open it first.
useRecentIssuesStore.getState().recordVisit(wsId, newIssue.id);
useRecentIssuesStore.getState().recordVisit(newIssue.id);
// 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) });
@@ -139,26 +145,11 @@ export function useUpdateIssue() {
// 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). Falls back to scanning loaded children caches —
// when the user navigates straight to a parent's detail page, the
// child may live only there, not in detail/list.
let parentId: string | null =
// sub-issues list).
const parentId =
prevDetail?.parent_issue_id ??
(prevList ? findIssueLocation(prevList, id)?.issue.parent_issue_id : null) ??
null;
if (!parentId) {
const childrenCaches = qc.getQueriesData<Issue[]>({
queryKey: [...issueKeys.all(wsId), "children"],
});
for (const [key, data] of childrenCaches) {
if (!data?.some((c) => c.id === id)) continue;
const candidate = key[key.length - 1];
if (typeof candidate === "string") {
parentId = candidate;
break;
}
}
}
const prevChildren = parentId
? qc.getQueryData<Issue[]>(issueKeys.children(wsId, parentId))
: undefined;
@@ -259,46 +250,13 @@ export function useBatchUpdateIssues() {
for (const id of ids) next = patchIssueInBuckets(next, id, updates);
return next;
});
// Mirror the optimistic patch into any loaded children cache so
// sub-issue rows on a parent's detail page reflect the change too.
const idSet = new Set(ids);
const childrenCaches = qc.getQueriesData<Issue[]>({
queryKey: [...issueKeys.all(wsId), "children"],
});
const prevChildren = new Map<string, Issue[] | undefined>();
const affectedParentIds = new Set<string>();
for (const [key, data] of childrenCaches) {
if (!data?.some((c) => idSet.has(c.id))) continue;
const parentId = key[key.length - 1];
if (typeof parentId !== "string") continue;
affectedParentIds.add(parentId);
prevChildren.set(parentId, data);
qc.setQueryData<Issue[]>(issueKeys.children(wsId, parentId), (old) =>
old?.map((c) => (idSet.has(c.id) ? { ...c, ...updates } : c)),
);
}
return { prevList, prevChildren, affectedParentIds };
return { prevList };
},
onError: (_err, _vars, ctx) => {
if (ctx?.prevList) qc.setQueryData(issueKeys.list(wsId), ctx.prevList);
if (ctx?.prevChildren) {
for (const [parentId, snapshot] of ctx.prevChildren) {
qc.setQueryData(issueKeys.children(wsId, parentId), snapshot);
}
}
},
onSettled: (_data, _err, _vars, ctx) => {
onSettled: () => {
qc.invalidateQueries({ queryKey: issueKeys.list(wsId) });
if (ctx?.affectedParentIds && ctx.affectedParentIds.size > 0) {
for (const parentId of ctx.affectedParentIds) {
qc.invalidateQueries({
queryKey: issueKeys.children(wsId, parentId),
});
}
qc.invalidateQueries({ queryKey: issueKeys.childProgress(wsId) });
}
},
});
}
@@ -318,40 +276,16 @@ export function useBatchDeleteIssues() {
if (loc?.issue.parent_issue_id) parentIssueIds.add(loc.issue.parent_issue_id);
}
}
// Children cache may be the only place sub-issues live when the user
// operates from a parent's detail page. Collect affected parents and
// optimistically filter the deleted ids out of each children cache so
// the row disappears immediately, mirroring the list-cache behaviour.
const idSet = new Set(ids);
const childrenCaches = qc.getQueriesData<Issue[]>({
queryKey: [...issueKeys.all(wsId), "children"],
});
const prevChildren = new Map<string, Issue[] | undefined>();
for (const [key, data] of childrenCaches) {
if (!data?.some((c) => idSet.has(c.id))) continue;
const parentId = key[key.length - 1];
if (typeof parentId !== "string") continue;
parentIssueIds.add(parentId);
prevChildren.set(parentId, data);
qc.setQueryData<Issue[]>(issueKeys.children(wsId, parentId), (old) =>
old?.filter((c) => !idSet.has(c.id)),
);
}
qc.setQueryData<ListIssuesCache>(issueKeys.list(wsId), (old) => {
if (!old) return old;
let next = old;
for (const id of ids) next = removeIssueFromBuckets(next, id);
return next;
});
return { prevList, prevChildren, parentIssueIds };
return { prevList, parentIssueIds };
},
onError: (_err, _ids, ctx) => {
if (ctx?.prevList) qc.setQueryData(issueKeys.list(wsId), ctx.prevList);
if (ctx?.prevChildren) {
for (const [parentId, snapshot] of ctx.prevChildren) {
qc.setQueryData(issueKeys.children(wsId, parentId), snapshot);
}
}
},
onSettled: (_data, _err, _ids, ctx) => {
qc.invalidateQueries({ queryKey: issueKeys.list(wsId) });
@@ -369,8 +303,6 @@ export function useBatchDeleteIssues() {
// Comments / Timeline
// ---------------------------------------------------------------------------
type TimelineCache = TimelineEntry[];
export function useCreateComment(issueId: string) {
const qc = useQueryClient();
return useMutation({
@@ -386,6 +318,11 @@ export function useCreateComment(issueId: string) {
attachmentIds?: string[];
}) => api.createComment(issueId, content, type, parentId, attachmentIds),
onSuccess: (comment) => {
// Write into every paginated timeline cache that's currently at-latest
// (around-mode caches viewing older windows skip silently inside
// prependToLatestPage). Both the latest cache and any open around-mode
// window that has been scrolled all the way to the live tail get the
// optimistic entry; everything else falls back to invalidation.
const entry: TimelineEntry = {
type: "comment",
id: comment.id,
@@ -399,22 +336,14 @@ export function useCreateComment(issueId: string) {
created_at: comment.created_at,
updated_at: comment.updated_at,
};
// Dedupe by id: the `comment:created` WS event may have already added
// this entry from the broadcast path before this onSuccess fires. Skip
// the append if the entry is already in the cache.
qc.setQueryData<TimelineCache>(issueKeys.timeline(issueId), (old) => {
if (!old) return [entry];
if (old.some((e) => e.id === entry.id)) return old;
return [...old, entry];
});
qc.setQueriesData<TimelineCacheData>(
{ queryKey: ["issues", "timeline", issueId] },
(old) => prependToLatestPage(old, entry),
);
},
onSettled: () => {
qc.invalidateQueries({ queryKey: issueKeys.timeline(issueId) });
},
// No onSettled invalidate. The `comment:created` WS broadcast keeps
// the timeline cache fresh after a successful create, and reconnect
// recovery in useIssueTimeline already invalidates if the connection
// dropped. Re-fetching on every submit replaces every entry's
// reference, which forces every memoized CommentCard subtree to
// re-render (visible as a flash across sibling threads during AI
// streaming).
});
}
@@ -424,16 +353,26 @@ export function useUpdateComment(issueId: string) {
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<TimelineCache>(issueKeys.timeline(issueId));
qc.setQueryData<TimelineCache>(issueKeys.timeline(issueId), (old) =>
old?.map((e) => (e.id === commentId ? { ...e, content } : e)),
await qc.cancelQueries({ queryKey: ["issues", "timeline", issueId] });
// Snapshot every open timeline cache (latest + any around windows) so
// an error rollback restores them all atomically.
const prevSnapshots = qc.getQueriesData<TimelineCacheData>({
queryKey: ["issues", "timeline", issueId],
});
qc.setQueriesData<TimelineCacheData>(
{ queryKey: ["issues", "timeline", issueId] },
(old) =>
mapAllEntries(old, (e) =>
e.id === commentId ? { ...e, content } : e,
),
);
return { prev };
return { prevSnapshots };
},
onError: (_err, _vars, ctx) => {
if (ctx?.prev !== undefined) {
qc.setQueryData(issueKeys.timeline(issueId), ctx.prev);
if (ctx?.prevSnapshots) {
for (const [key, prev] of ctx.prevSnapshots) {
qc.setQueryData(key, prev);
}
}
},
onSettled: () => {
@@ -447,69 +386,44 @@ export function useDeleteComment(issueId: string) {
return useMutation({
mutationFn: (commentId: string) => api.deleteComment(commentId),
onMutate: async (commentId) => {
await qc.cancelQueries({ queryKey: issueKeys.timeline(issueId) });
const prev = qc.getQueryData<TimelineCache>(issueKeys.timeline(issueId));
await qc.cancelQueries({ queryKey: ["issues", "timeline", issueId] });
const prevSnapshots = qc.getQueriesData<TimelineCacheData>({
queryKey: ["issues", "timeline", issueId],
});
// Cascade: collect all descendants of the deleted comment.
// Cascade: collect all child comment IDs across every loaded page.
const toRemove = new Set<string>([commentId]);
if (prev) {
for (const [, data] of prevSnapshots) {
if (!data) continue;
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;
for (const page of data.pages) {
for (const e of page.entries) {
if (
e.parent_id &&
toRemove.has(e.parent_id) &&
!toRemove.has(e.id)
) {
toRemove.add(e.id);
changed = true;
}
}
}
}
}
qc.setQueryData<TimelineCache>(issueKeys.timeline(issueId), (old) =>
old?.filter((e) => !toRemove.has(e.id)),
qc.setQueriesData<TimelineCacheData>(
{ queryKey: ["issues", "timeline", issueId] },
(old) => filterAllEntries(old, (e) => toRemove.has(e.id)),
);
return { prev };
return { prevSnapshots };
},
onError: (_err, _id, ctx) => {
if (ctx?.prev !== undefined) {
qc.setQueryData(issueKeys.timeline(issueId), ctx.prev);
}
},
onSettled: () => {
qc.invalidateQueries({ queryKey: issueKeys.timeline(issueId) });
},
});
}
export function useResolveComment(issueId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ commentId, resolved }: { commentId: string; resolved: boolean }) =>
resolved ? api.resolveComment(commentId) : api.unresolveComment(commentId),
onMutate: async ({ commentId, resolved }) => {
await qc.cancelQueries({ queryKey: issueKeys.timeline(issueId) });
const prev = qc.getQueryData<TimelineCache>(issueKeys.timeline(issueId));
qc.setQueryData<TimelineCache>(issueKeys.timeline(issueId), (old) =>
old?.map((e) =>
e.id === commentId
? {
...e,
resolved_at: resolved ? new Date().toISOString() : null,
resolved_by_type: resolved ? e.resolved_by_type ?? null : null,
resolved_by_id: resolved ? e.resolved_by_id ?? null : null,
}
: e,
),
);
return { prev };
},
onError: (_err, _vars, ctx) => {
if (ctx?.prev !== undefined) {
qc.setQueryData(issueKeys.timeline(issueId), ctx.prev);
if (ctx?.prevSnapshots) {
for (const [key, prev] of ctx.prevSnapshots) {
qc.setQueryData(key, prev);
}
}
},
onSettled: () => {

View File

@@ -1,9 +1,11 @@
import { queryOptions } from "@tanstack/react-query";
import { infiniteQueryOptions, queryOptions } from "@tanstack/react-query";
import { api } from "../api";
import type {
IssueStatus,
ListIssuesParams,
ListIssuesCache,
TimelinePage,
TimelinePageParam,
} from "../types";
import { BOARD_STATUSES } from "./config";
@@ -21,17 +23,19 @@ export const issueKeys = {
[...issueKeys.all(wsId), "children", id] as const,
childProgress: (wsId: string) =>
[...issueKeys.all(wsId), "child-progress"] as const,
/** Full-issue timeline (single TanStack Query, no cursor). */
timeline: (issueId: string) =>
["issues", "timeline", issueId] as const,
/**
* Cursor-paginated timeline cache. Around-mode lookups use a separate cache
* (keyed by the anchor id) so an Inbox-jump fetch does not pollute the
* default latest-page cache that the regular issue list path consumes.
*/
timeline: (issueId: string, around?: string | null) =>
around
? (["issues", "timeline", issueId, "around", around] as const)
: (["issues", "timeline", issueId] as const),
reactions: (issueId: string) => ["issues", "reactions", issueId] as const,
subscribers: (issueId: string) =>
["issues", "subscribers", issueId] as const,
usage: (issueId: string) => ["issues", "usage", issueId] as const,
/** Issue-level attachments — used by the description editor so its
* inline file-card / image NodeViews can re-sign download URLs at
* click time. */
attachments: (issueId: string) => ["issues", "attachments", issueId] as const,
/** Per-issue task list (issue-detail Execution log section). */
tasks: (issueId: string) => ["issues", "tasks", issueId] as const,
/** Prefix-match key for invalidating tasks across all issues — used by
@@ -137,16 +141,39 @@ export function childIssuesOptions(wsId: string, id: string) {
}
/**
* Single-fetch timeline options. The endpoint returns the full ordered set of
* comments + activities for an issue (server caps at 2000 as a safety net).
* Cursor pagination was removed in #1929 — at observed data sizes (p99 ~30
* entries per issue) it added complexity without a UX win and broke reply
* threads at page boundaries.
* Infinite-query options for the cursor-paginated timeline. The first page is
* either the latest 50 entries (no `around`) or a 50-wide window centered on
* the given comment/activity id (Inbox jump path). `getNextPageParam` walks
* older; `getPreviousPageParam` walks newer.
*/
export function issueTimelineOptions(issueId: string) {
return queryOptions({
queryKey: issueKeys.timeline(issueId),
queryFn: () => api.listTimeline(issueId),
export function issueTimelineInfiniteOptions(
issueId: string,
around?: string | null,
) {
return infiniteQueryOptions<
TimelinePage,
Error,
{ pages: TimelinePage[]; pageParams: TimelinePageParam[] },
readonly unknown[],
TimelinePageParam
>({
queryKey: issueKeys.timeline(issueId, around ?? null),
initialPageParam: around
? ({ mode: "around", id: around } as TimelinePageParam)
: ({ mode: "latest" } as TimelinePageParam),
queryFn: ({ pageParam }) => api.listTimeline(issueId, pageParam),
// Walk older: append a page below the current oldest (last entry of the
// last loaded page). undefined = no more older entries.
getNextPageParam: (lastPage) =>
lastPage.has_more_before && lastPage.next_cursor
? ({ mode: "before", cursor: lastPage.next_cursor } as TimelinePageParam)
: undefined,
// Walk newer: prepend a page above the current newest (first entry of the
// first loaded page). undefined = at the latest, no newer to fetch.
getPreviousPageParam: (firstPage) =>
firstPage.has_more_after && firstPage.prev_cursor
? ({ mode: "after", cursor: firstPage.prev_cursor } as TimelinePageParam)
: undefined,
});
}
@@ -173,14 +200,3 @@ export function issueUsageOptions(issueId: string) {
queryFn: () => api.getIssueUsage(issueId),
});
}
// Backs the description editor's fresh-sign download flow: NodeViews resolve
// an attachment id by matching the markdown URL against this list. The list
// is workspace-private metadata and lives on the same cache lifetime as the
// rest of the issue detail surface.
export function issueAttachmentsOptions(issueId: string) {
return queryOptions({
queryKey: issueKeys.attachments(issueId),
queryFn: () => api.listAttachments(issueId),
});
}

View File

@@ -1,80 +0,0 @@
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
import { createWorkspaceAwareStorage, registerForWorkspaceRehydration } from "../../platform/workspace-storage";
import { defaultStorage } from "../../platform/storage";
/**
* Per-comment draft persistence — survives:
* - virtualization unmount (the reason this exists: when a TipTap editor
* scrolls out of the Virtuoso viewport, its in-memory state is lost)
* - tab close / accidental Cmd-W
* - reload
*
* Keys are issue-scoped because createWorkspaceAwareStorage only partitions
* by workspace, not by issue. Without issueId in the key, two issues with
* thread replies open in adjacent desktop tabs would collide.
*/
export type CommentDraftKey =
| `new:${string}` // top-level CommentInput, key = `new:${issueId}`
| `reply:${string}:${string}` // ReplyInput inside a thread, key = `reply:${issueId}:${rootCommentId}`
| `edit:${string}:${string}`; // inline edit on existing comment, key = `edit:${issueId}:${commentId}`
interface CommentDraft {
content: string;
updatedAt: number;
}
interface CommentDraftStore {
drafts: Record<string, CommentDraft>;
getDraft: (key: CommentDraftKey) => string | undefined;
setDraft: (key: CommentDraftKey, content: string) => void;
clearDraft: (key: CommentDraftKey) => void;
}
// Drafts older than 30 days are dropped on store init. Without TTL the store
// would accumulate every edit attempt across every issue indefinitely and
// slowly leak localStorage quota.
const TTL_MS = 30 * 24 * 60 * 60 * 1000;
function pruneStaleDrafts(drafts: Record<string, CommentDraft>): Record<string, CommentDraft> {
const cutoff = Date.now() - TTL_MS;
const out: Record<string, CommentDraft> = {};
for (const [k, v] of Object.entries(drafts)) {
if (v.updatedAt >= cutoff && v.content.trim().length > 0) {
out[k] = v;
}
}
return out;
}
export const useCommentDraftStore = create<CommentDraftStore>()(
persist(
(set, get) => ({
drafts: {},
getDraft: (key) => get().drafts[key]?.content,
setDraft: (key, content) =>
set((s) => ({
drafts: { ...s.drafts, [key]: { content, updatedAt: Date.now() } },
})),
clearDraft: (key) =>
set((s) => {
if (!(key in s.drafts)) return s;
const next = { ...s.drafts };
delete next[key];
return { drafts: next };
}),
}),
{
name: "multica_comment_drafts",
storage: createJSONStorage(() => createWorkspaceAwareStorage(defaultStorage)),
onRehydrateStorage: () => (state) => {
if (state) {
state.drafts = pruneStaleDrafts(state.drafts);
}
},
},
),
);
registerForWorkspaceRehydration(() => useCommentDraftStore.persist.rehydrate());

View File

@@ -1,11 +1,7 @@
export { useIssueSelectionStore } from "./selection-store";
export { useCreateModeStore, type CreateMode } from "./create-mode-store";
export { useIssueDraftStore } from "./draft-store";
export {
useRecentIssuesStore,
selectRecentIssues,
type RecentIssueEntry,
} from "./recent-issues-store";
export { useRecentIssuesStore, type RecentIssueEntry } from "./recent-issues-store";
export {
ViewStoreProvider,
useViewStore,
@@ -13,7 +9,6 @@ export {
} from "./view-store-context";
export { useIssuesScopeStore, type IssuesScope } from "./issues-scope-store";
export { useCommentCollapseStore } from "./comment-collapse-store";
export { useCommentDraftStore, type CommentDraftKey } from "./comment-draft-store";
export {
myIssuesViewStore,
type MyIssuesViewState,

View File

@@ -3,7 +3,6 @@ import { useQuickCreateStore } from "./quick-create-store";
const RESET_STATE = {
lastAgentId: null,
lastProjectId: null,
prompt: "",
keepOpen: false,
};
@@ -24,14 +23,4 @@ describe("quick create store", () => {
clearPrompt();
expect(useQuickCreateStore.getState().prompt).toBe("");
});
it("remembers the last project picked so frequent users skip the picker", () => {
const { setLastProjectId } = useQuickCreateStore.getState();
setLastProjectId("proj-1");
expect(useQuickCreateStore.getState().lastProjectId).toBe("proj-1");
setLastProjectId(null);
expect(useQuickCreateStore.getState().lastProjectId).toBeNull();
});
});

View File

@@ -5,19 +5,16 @@ import { createJSONStorage, persist } from "zustand/middleware";
import { createWorkspaceAwareStorage, registerForWorkspaceRehydration } from "../../platform/workspace-storage";
import { defaultStorage } from "../../platform/storage";
// Per-workspace memory of the last agent and project the user picked in the
// Quick Create modal. Defaulted to those values on next open so frequent
// users skip the pickers entirely — without this, anyone targeting a single
// project ends up retyping "in project A" on every prompt. Persisted with
// the workspace-aware StateStorage so switching workspaces shows the right
// default automatically. Per-user scoping comes for free from localStorage
// being browser-profile-local — matches how draft-store /
// issues-scope-store / comment-collapse-store already namespace themselves.
// Per-workspace memory of the last agent the user picked in the Quick Create
// modal. Defaulted to that agent on next open so frequent users skip the
// picker entirely. Persisted with the workspace-aware StateStorage so
// switching workspaces shows the right default automatically. Per-user
// scoping comes for free from localStorage being browser-profile-local —
// matches how draft-store / issues-scope-store / comment-collapse-store
// already namespace themselves.
interface QuickCreateState {
lastAgentId: string | null;
setLastAgentId: (id: string | null) => void;
lastProjectId: string | null;
setLastProjectId: (id: string | null) => void;
prompt: string;
setPrompt: (prompt: string) => void;
clearPrompt: () => void;
@@ -30,8 +27,6 @@ export const useQuickCreateStore = create<QuickCreateState>()(
(set) => ({
lastAgentId: null,
setLastAgentId: (id) => set({ lastAgentId: id }),
lastProjectId: null,
setLastProjectId: (id) => set({ lastProjectId: id }),
prompt: "",
setPrompt: (prompt) => set({ prompt }),
clearPrompt: () => set({ prompt: "" }),

View File

@@ -1,74 +0,0 @@
import { beforeEach, describe, expect, it } from "vitest";
import { useRecentIssuesStore, selectRecentIssues } from "./recent-issues-store";
beforeEach(() => {
useRecentIssuesStore.setState({ byWorkspace: {} });
});
describe("useRecentIssuesStore.recordVisit", () => {
it("keeps visits namespaced by workspace id", () => {
const { recordVisit } = useRecentIssuesStore.getState();
recordVisit("ws-a", "issue-1");
recordVisit("ws-b", "issue-2");
const state = useRecentIssuesStore.getState().byWorkspace;
expect(state["ws-a"]?.map((e) => e.id)).toEqual(["issue-1"]);
expect(state["ws-b"]?.map((e) => e.id)).toEqual(["issue-2"]);
});
it("moves the most recent visit to the front and dedupes", () => {
const { recordVisit } = useRecentIssuesStore.getState();
recordVisit("ws-a", "issue-1");
recordVisit("ws-a", "issue-2");
recordVisit("ws-a", "issue-1");
const ids = useRecentIssuesStore
.getState()
.byWorkspace["ws-a"]?.map((e) => e.id);
expect(ids).toEqual(["issue-1", "issue-2"]);
});
it("caps each workspace's bucket at 20 entries", () => {
const { recordVisit } = useRecentIssuesStore.getState();
for (let i = 0; i < 25; i++) recordVisit("ws-a", `issue-${i}`);
expect(useRecentIssuesStore.getState().byWorkspace["ws-a"]).toHaveLength(20);
});
});
describe("useRecentIssuesStore.pruneWorkspaces", () => {
it("drops buckets for workspaces not in the active set", () => {
const { recordVisit, pruneWorkspaces } = useRecentIssuesStore.getState();
recordVisit("ws-a", "issue-1");
recordVisit("ws-b", "issue-2");
recordVisit("ws-c", "issue-3");
pruneWorkspaces(["ws-a", "ws-c"]);
const state = useRecentIssuesStore.getState().byWorkspace;
expect(Object.keys(state).sort()).toEqual(["ws-a", "ws-c"]);
});
it("is a no-op when every bucket is still active", () => {
const { recordVisit, pruneWorkspaces } = useRecentIssuesStore.getState();
recordVisit("ws-a", "issue-1");
const before = useRecentIssuesStore.getState().byWorkspace;
pruneWorkspaces(["ws-a"]);
expect(useRecentIssuesStore.getState().byWorkspace).toBe(before);
});
});
describe("selectRecentIssues", () => {
it("returns the bucket for the given workspace", () => {
useRecentIssuesStore.getState().recordVisit("ws-a", "issue-1");
const items = selectRecentIssues("ws-a")(useRecentIssuesStore.getState());
expect(items.map((e) => e.id)).toEqual(["issue-1"]);
});
it("returns a stable empty array when wsId is null or unknown", () => {
const a = selectRecentIssues(null)(useRecentIssuesStore.getState());
const b = selectRecentIssues(null)(useRecentIssuesStore.getState());
const c = selectRecentIssues("missing")(useRecentIssuesStore.getState());
expect(a).toBe(b);
expect(a).toBe(c);
expect(a).toEqual([]);
});
});

View File

@@ -2,11 +2,13 @@
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
import {
createWorkspaceAwareStorage,
registerForWorkspaceRehydration,
} from "../../platform/workspace-storage";
import { defaultStorage } from "../../platform/storage";
const MAX_RECENT_ISSUES = 20;
const MAX_WORKSPACES = 50;
const EMPTY: RecentIssueEntry[] = [];
export interface RecentIssueEntry {
id: string;
@@ -14,82 +16,33 @@ export interface RecentIssueEntry {
}
interface RecentIssuesState {
byWorkspace: Record<string, RecentIssueEntry[]>;
recordVisit: (wsId: string, id: string) => void;
pruneWorkspaces: (activeWsIds: string[]) => void;
items: RecentIssueEntry[];
recordVisit: (id: string) => void;
}
// Namespace by workspace id (UUID) instead of namespacing the storage key by
// slug. The storage-key approach (see createWorkspaceAwareStorage) breaks when
// a setter fires from a child component's mount-effect before
// WorkspaceRouteLayout's effect has set the current slug — child effects run
// before parent effects, so writes land in the un-namespaced bare key and
// leak across workspaces (bug surfaced by /<slug>/issues firing per-id GETs
// for recents from other workspaces, most returning 404).
//
// Keying on wsId (rather than slug) means the data survives workspace renames
// and matches the wsId that callers already have via useWorkspaceId().
export const useRecentIssuesStore = create<RecentIssuesState>()(
persist(
(set) => ({
byWorkspace: {},
recordVisit: (wsId, id) =>
items: [],
recordVisit: (id) =>
set((state) => {
const bucket = state.byWorkspace[wsId] ?? EMPTY;
const filtered = bucket.filter((i) => i.id !== id);
const filtered = state.items.filter((i) => i.id !== id);
const updated: RecentIssueEntry = { id, visitedAt: Date.now() };
const nextBucket = [updated, ...filtered].slice(0, MAX_RECENT_ISSUES);
let nextByWorkspace = {
...state.byWorkspace,
[wsId]: nextBucket,
return {
items: [updated, ...filtered].slice(0, MAX_RECENT_ISSUES),
};
// LRU defense: if pruneWorkspaces never gets a chance to run (offline,
// failed list query) and the user touches lots of workspaces, cap the
// total to avoid unbounded growth. Evict the workspace whose most
// recent visit is the oldest.
const ids = Object.keys(nextByWorkspace);
if (ids.length > MAX_WORKSPACES) {
const oldest = ids.reduce((oldestId, candidateId) => {
const a = nextByWorkspace[oldestId]?.[0]?.visitedAt ?? 0;
const b = nextByWorkspace[candidateId]?.[0]?.visitedAt ?? 0;
return b < a ? candidateId : oldestId;
});
const { [oldest]: _, ...rest } = nextByWorkspace;
nextByWorkspace = rest;
}
return { byWorkspace: nextByWorkspace };
}),
pruneWorkspaces: (activeWsIds) =>
set((state) => {
const allow = new Set(activeWsIds);
let changed = false;
const next: Record<string, RecentIssueEntry[]> = {};
for (const [wsId, items] of Object.entries(state.byWorkspace)) {
if (allow.has(wsId)) next[wsId] = items;
else changed = true;
}
return changed ? { byWorkspace: next } : state;
}),
}),
{
name: "multica_recent_issues",
storage: createJSONStorage(() => defaultStorage),
partialize: (state) => ({ byWorkspace: state.byWorkspace }),
// v0 stored a flat `items` array under the bare key (or, when the
// workspace slug happened to be set at write time, under
// `multica_recent_issues:<slug>`). Both shapes are unsafe to surface
// because v0 entries don't know which workspace they belonged to —
// drop them and let the cache repopulate as the user visits issues.
version: 1,
migrate: () => ({ byWorkspace: {} }),
storage: createJSONStorage(() =>
createWorkspaceAwareStorage(defaultStorage),
),
partialize: (state) => ({ items: state.items }),
},
),
);
export function selectRecentIssues(wsId: string | null) {
return (state: RecentIssuesState) =>
wsId ? (state.byWorkspace[wsId] ?? EMPTY) : EMPTY;
}
registerForWorkspaceRehydration(() =>
useRecentIssuesStore.persist.rehydrate(),
);

View File

@@ -0,0 +1,73 @@
import type { InfiniteData } from "@tanstack/react-query";
import type {
TimelineEntry,
TimelinePage,
TimelinePageParam,
} from "../types";
/** Shape of the cursor-paginated timeline cache. Exported so consumers (the
* hook, mutations, tests) all reference the same type. */
export type TimelineCacheData = InfiniteData<TimelinePage, TimelinePageParam>;
/** Map fn over every entry across every page, preserving page identity for
* any page whose entries don't change so React.memo on CommentCard isn't
* defeated by gratuitous reference churn. */
export function mapAllEntries(
data: TimelineCacheData | undefined,
fn: (e: TimelineEntry) => TimelineEntry,
): TimelineCacheData | undefined {
if (!data) return data;
let pagesChanged = false;
const pages = data.pages.map((page) => {
let entriesChanged = false;
const entries = page.entries.map((e) => {
const next = fn(e);
if (next !== e) entriesChanged = true;
return next;
});
if (!entriesChanged) return page;
pagesChanged = true;
return { ...page, entries };
});
if (!pagesChanged) return data;
return { ...data, pages };
}
/** Filter out entries matching the predicate from every page. */
export function filterAllEntries(
data: TimelineCacheData | undefined,
predicate: (e: TimelineEntry) => boolean,
): TimelineCacheData | undefined {
if (!data) return data;
let pagesChanged = false;
const pages = data.pages.map((page) => {
const entries = page.entries.filter((e) => !predicate(e));
if (entries.length === page.entries.length) return page;
pagesChanged = true;
return { ...page, entries };
});
if (!pagesChanged) return data;
return { ...data, pages };
}
/** Prepend a new entry to the latest page (pages[0]). Caller must verify
* the cache is at-latest before calling — otherwise the entry is hidden
* behind a "show newer" gap and shouldn't be injected. Returns the data
* unchanged if the cache is not at-latest or the entry already exists. */
export function prependToLatestPage(
data: TimelineCacheData | undefined,
entry: TimelineEntry,
): TimelineCacheData | undefined {
if (!data || data.pages.length === 0) return data;
const first = data.pages[0];
if (!first) return data;
if (first.has_more_after) return data; // not at latest; skip silently
if (first.entries.some((e) => e.id === entry.id)) return data;
return {
...data,
pages: [
{ ...first, entries: [entry, ...first.entries] },
...data.pages.slice(1),
],
};
}

View File

@@ -42,12 +42,9 @@ export async function saveQuestionnaire(
*/
export async function completeOnboarding(
completionPath?: OnboardingCompletionPath,
workspaceId?: string,
): Promise<void> {
await api.markOnboardingComplete(
completionPath || workspaceId
? { completion_path: completionPath, workspace_id: workspaceId }
: undefined,
completionPath ? { completion_path: completionPath } : undefined,
);
await useAuthStore.getState().refreshMe();
}

View File

@@ -14,7 +14,6 @@
"./types/*": "./types/*.ts",
"./api": "./api/index.ts",
"./api/client": "./api/client.ts",
"./api/schema": "./api/schema.ts",
"./api/ws-client": "./api/ws-client.ts",
"./config": "./config/index.ts",
"./auth": "./auth/index.ts",
@@ -25,6 +24,7 @@
"./issues": "./issues/index.ts",
"./issues/queries": "./issues/queries.ts",
"./issues/mutations": "./issues/mutations.ts",
"./issues/timeline-cache": "./issues/timeline-cache.ts",
"./issues/ws-updaters": "./issues/ws-updaters.ts",
"./issues/config": "./issues/config/index.ts",
"./issues/config/status": "./issues/config/status.ts",
@@ -46,9 +46,6 @@
"./runtimes/queries": "./runtimes/queries.ts",
"./runtimes/mutations": "./runtimes/mutations.ts",
"./runtimes/hooks": "./runtimes/hooks.ts",
"./runtimes/custom-pricing-store": "./runtimes/custom-pricing-store.ts",
"./dashboard": "./dashboard/index.ts",
"./dashboard/queries": "./dashboard/queries.ts",
"./agents": "./agents/index.ts",
"./agents/queries": "./agents/queries.ts",
"./agents/derive-presence": "./agents/derive-presence.ts",
@@ -68,8 +65,6 @@
"./pins": "./pins/index.ts",
"./pins/queries": "./pins/queries.ts",
"./pins/mutations": "./pins/mutations.ts",
"./github": "./github/index.ts",
"./github/queries": "./github/queries.ts",
"./feedback": "./feedback/index.ts",
"./feedback/mutations": "./feedback/mutations.ts",
"./realtime": "./realtime/index.ts",
@@ -97,7 +92,6 @@
"i18next": "catalog:",
"posthog-js": "catalog:",
"react-i18next": "catalog:",
"zod": "catalog:",
"zustand": "catalog:"
},
"peerDependencies": {

View File

@@ -17,7 +17,6 @@ describe("paths.workspace() shape", () => {
expect(new Set(parameterlessRoutes)).toEqual(
new Set([
"root",
"usage",
"issues",
"projects",
"autopilots",
@@ -36,7 +35,6 @@ describe("paths.workspace() shape", () => {
// Check that none of the parameterless paths embed a leaked literal
// and that their second URL segment matches the method name's kebab-case.
const expectedSegments: Array<[string, string]> = [
["usage", "usage"],
["issues", "issues"],
["projects", "projects"],
["autopilots", "autopilots"],

View File

@@ -4,8 +4,7 @@ import { paths, isGlobalPath } from "./paths";
describe("paths.workspace(slug)", () => {
const ws = paths.workspace("acme");
it("builds workspace paths with slug prefix", () => {
expect(ws.usage()).toBe("/acme/usage");
it("builds dashboard paths with slug prefix", () => {
expect(ws.issues()).toBe("/acme/issues");
expect(ws.issueDetail("abc-123")).toBe("/acme/issues/abc-123");
expect(ws.projects()).toBe("/acme/projects");

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