Compare commits

..

2 Commits

Author SHA1 Message Date
Jiayuan Zhang
890635e958 refactor(daemon): reclassify Repositories as per-issue dynamic suffix
handler/daemon.go overrides task.Repos with the issue's project github_repo
resources when the project has any attached, so the rendered Repositories
block can vary per issue (not per workspace). Reflect that in the section
ordering comments and tighten the prompt-cache test to match the real claim
path.

- Update buildMetaSkillContent doc + section comments to classify
  Repositories alongside Project Context and Workflow as dynamic suffix.
- Replace TestInjectRuntimeConfigPrefixIsByteAlignedAcrossIssues with
  TestInjectRuntimeConfigStablePrefixIsByteAligned, which boundaries on
  the first dynamic section header (whichever appears first among
  Repositories / Project Context / Workflow) and adds a sub-test where
  two issues render with different project repos and projects — the
  bytes before the first dynamic section must still be byte-identical
  and per-issue values must not leak into the stable prefix.
- TestInjectRuntimeConfigStablePrefixOrdering now uses the same
  first-dynamic-section boundary and asserts the dynamic suffix order
  (Repositories → Project Context → Workflow).

Co-authored-by: multica-agent <github@multica.ai>
2026-05-07 12:19:09 +08:00
Jiayuan Zhang
38d0f71d1a refactor(daemon): reorder meta-skill sections so per-issue Workflow comes last
Current section order interleaves the per-issue Workflow block (which embeds
the issue ID) between the stable CLI command listing and the equally-stable
Skills/Mentions/Attachments/Output sections. Any issue switch invalidates the
provider's prompt prefix cache from the Workflow onward, throwing away
~2.5k tokens of cached prefix.

Move the Workflow to the end and group all stable sections (Available
Commands, Skills, Codex-specific notes, Mentions, Attachments, Important,
Output, Repositories) before any per-issue content. The same agent
processing consecutive issues in the same workspace now produces a
byte-identical CLAUDE.md prefix up to the Workflow header.

Lock the property in with two new tests:
- TestInjectRuntimeConfigStablePrefixOrdering — every stable section's
  offset is < the Workflow offset.
- TestInjectRuntimeConfigPrefixIsByteAlignedAcrossIssues — the bytes before
  ### Workflow are byte-identical for two different IssueIDs.

See MUL-1824.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-07 12:11:57 +08:00
315 changed files with 4507 additions and 22516 deletions

View File

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

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

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

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

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

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

@@ -21,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";
@@ -84,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 />,

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

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

@@ -44,21 +44,17 @@ For the difference between token types, see [Authentication and tokens](/auth-to
## 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 +99,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

@@ -44,21 +44,17 @@ Token 类型的详细区分见 [认证与令牌](/auth-tokens)。
## 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 +99,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

@@ -244,22 +244,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 +310,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

@@ -69,7 +69,7 @@ Automatic retry also has two extra conditions:
## 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 +77,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 +87,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 +98,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

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

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

@@ -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: {
@@ -284,87 +283,6 @@ export function createEnDict(allowSignup: boolean): LandingDict {
fixes: "Bug Fixes",
},
entries: [
{
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",

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,7 +7,6 @@ export function createZhDict(allowSignup: boolean): LandingDict {
github: "GitHub",
login: "\u767b\u5f55",
dashboard: "\u8fdb\u5165\u5de5\u4f5c\u53f0",
changelog: "\u66f4\u65b0\u65e5\u5fd7",
},
hero: {
@@ -284,87 +283,6 @@ export function createZhDict(allowSignup: boolean): LandingDict {
fixes: "问题修复",
},
entries: [
{
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",

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

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

@@ -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,7 +49,6 @@ function makeRuntime(overrides: Partial<AgentRuntime> = {}): AgentRuntime {
device_info: "",
metadata: {},
owner_id: null,
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

@@ -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,59 +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("");
});
});
});

View File

@@ -43,7 +43,8 @@ import type {
RuntimeLocalSkillListRequest,
CreateRuntimeLocalSkillImportRequest,
RuntimeLocalSkillImportRequest,
TimelineEntry,
TimelinePage,
TimelinePageParam,
AssigneeFrequencyEntry,
TaskMessagePayload,
Attachment,
@@ -86,18 +87,6 @@ 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 {
AttachmentResponseSchema,
ChildIssuesResponseSchema,
CommentsListSchema,
EMPTY_ATTACHMENT,
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 /
@@ -335,7 +324,6 @@ export class ApiClient {
async markOnboardingComplete(payload?: {
completion_path?: OnboardingCompletionPath;
workspace_id?: string;
}): Promise<User> {
return this.fetch("/api/me/onboarding/complete", {
method: "POST",
@@ -410,11 +398,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> {
@@ -444,7 +428,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),
@@ -470,10 +454,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 }[] }> {
@@ -500,10 +481,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> {
@@ -518,13 +496,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[]> {
@@ -542,14 +524,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",
@@ -580,10 +554,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> {
@@ -659,16 +630,6 @@ export class ApiClient {
await this.fetch(`/api/runtimes/${runtimeId}`, { method: "DELETE" });
}
async updateRuntime(
runtimeId: string,
patch: { timezone?: string },
): 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));
@@ -805,12 +766,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");
@@ -863,7 +818,6 @@ export class ApiClient {
google_client_id?: string;
posthog_key?: string;
posthog_host?: string;
analytics_environment?: string;
}> {
return this.fetch("/api/config");
}
@@ -1101,17 +1055,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" });
}

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,146 +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: [] });
});
});
});
// 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,167 +0,0 @@
import { z } from "zod";
import type { Attachment, 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(),
}).loose();
export const EMPTY_ATTACHMENT: Attachment = {
id: "",
workspace_id: "",
issue_id: null,
comment_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();

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

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

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,7 +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",
"./agents": "./agents/index.ts",
"./agents/queries": "./agents/queries.ts",
"./agents/derive-presence": "./agents/derive-presence.ts",
@@ -93,7 +92,6 @@
"i18next": "catalog:",
"posthog-js": "catalog:",
"react-i18next": "catalog:",
"zod": "catalog:",
"zustand": "catalog:"
},
"peerDependencies": {

View File

@@ -1,22 +1,16 @@
// AUTO-GENERATED by scripts/generate-reserved-slugs.mjs.
// Do not edit by hand — edit server/internal/handler/reserved_slugs.json
// and run `pnpm generate:reserved-slugs`.
/**
* Slugs reserved because they collide with frontend top-level routes,
* platform features, or web standards.
*
* Single source of truth: `server/internal/handler/reserved_slugs.json`.
* The Go backend embeds that JSON; this file is regenerated from it.
* Keep in sync with server/internal/handler/workspace_reserved_slugs.go.
*
* Convention for new global routes (CLAUDE.md): use a single word
* (`/login`, `/inbox`) or `/{noun}/{verb}` (`/workspaces/new`). Hyphenated
* root-level word groups (`/new-workspace`, `/create-team`) collide with
* common user workspace names — see PR for full discussion.
*/
export const RESERVED_SLUGS: ReadonlySet<string> = new Set([
export const RESERVED_SLUGS = new Set([
// Auth flow
// `onboarding` is historical, kept reserved post-removal of the route.
"login",
"logout",
"signin",
@@ -30,21 +24,17 @@ export const RESERVED_SLUGS: ReadonlySet<string> = new Set([
"verify",
"reset",
"password",
"onboarding",
"onboarding", // historical, kept reserved post-removal
// Platform / marketing routes (current + likely-future)
// `multica` is reserved as the brand name to block impersonation workspaces.
// `www`, `new`, `home`, `homepage`, `dashboard` are confusables or
// likely-future global landing/entry routes; `homepage` matches the existing
// `/homepage` landing variant in apps/web.
"api",
"admin",
"multica",
"www",
"new",
"home",
"homepage",
"dashboard",
"multica", // brand name — prevent impersonation workspaces
"www", // hostname confusable; never a legitimate workspace slug
"new", // ambiguous verb-as-slug; reserved for future global create routes
"home", // likely-future marketing/landing entry
"homepage", // existing /homepage landing variant in apps/web
"dashboard", // standard SaaS entry; likely-future global route
"help",
"about",
"pricing",
@@ -62,7 +52,7 @@ export const RESERVED_SLUGS: ReadonlySet<string> = new Set([
"press",
"download",
// Account / billing (likely-future global routes in the avatar menu)
// Account / billing (likely-future global routes in the avatar menu).
"profile",
"account",
"billing",
@@ -70,11 +60,9 @@ export const RESERVED_SLUGS: ReadonlySet<string> = new Set([
"search",
"members",
// Dashboard / workspace route segments
// Reserving each segment name prevents `/{slug}/{view}` from being visually
// ambiguous (e.g. a workspace named `issues` would make `/issues/abc` mean two
// things). `workspaces` covers the global `/workspaces/new` workspace-creation
// page; `teams` is reserved for future team management.
// Dashboard / workspace route segments. Reserving the segment name
// prevents `/{slug}/{view}` from being visually ambiguous (e.g. a
// workspace named "issues" makes `/issues/abc` mean two things).
"issues",
"projects",
"autopilots",
@@ -84,13 +72,12 @@ export const RESERVED_SLUGS: ReadonlySet<string> = new Set([
"runtimes",
"skills",
"settings",
"workspaces",
"teams",
"workspaces", // global `/workspaces/new` workspace creation page
"teams", // reserved for future team management routes
// API / integration prefixes
// `api` above already covers `/api/*`; these guard against future top-level
// API alias routes (e.g. `/v1`, `/graphql`) and against accidental workspace
// slugs that read like API identifiers.
// API / integration prefixes. `api` above already covers /api/*; these
// guard against future top-level API alias routes (e.g. /v1, /graphql)
// and against accidental workspace slugs that read like API identifiers.
"v1",
"v2",
"graphql",
@@ -99,10 +86,10 @@ export const RESERVED_SLUGS: ReadonlySet<string> = new Set([
"tokens",
"cli",
// Backend ops / observability
// `/health`, `/readyz`, `/healthz`, and `/ws` exist on the backend host;
// reserving them on the workspace slug space prevents naming confusion if/when
// these paths are ever proxied through the web origin.
// Backend ops / observability. `/health`, `/readyz`, `/healthz`, and `/ws`
// exist on the backend
// host; reserving them on the workspace slug space prevents naming
// confusion if/when these paths are ever proxied through the web origin.
"health",
"readyz",
"healthz",
@@ -110,18 +97,16 @@ export const RESERVED_SLUGS: ReadonlySet<string> = new Set([
"metrics",
"ping",
// RFC 2142 — privileged email mailboxes
// Allowing user workspaces with these slugs would let attackers spoof system
// messaging.
// RFC 2142 — privileged email mailboxes. Allowing user workspaces with
// these slugs would let attackers spoof system messaging.
"postmaster",
"abuse",
"noreply",
"webmaster",
"hostmaster",
// Hostname / subdomain confusables
// Even on path-based routing these names attract phishing and
// subdomain-takeover attempts.
// Hostname / subdomain confusables. Even on path-based routing these
// names attract phishing and subdomain-takeover attempts.
"mail",
"ftp",
"static",
@@ -131,12 +116,12 @@ export const RESERVED_SLUGS: ReadonlySet<string> = new Set([
"files",
"uploads",
// Next.js / web standards
// These entries contain characters (dots, underscores) that today's slug regex
// `^[a-z0-9]+(?:-[a-z0-9]+)*$` already rejects at the format-validation step —
// so `isReservedSlug` never actually matches them. They are kept as
// defense-in-depth so that if the slug regex is ever relaxed (e.g. to support
// dotted corporate slugs like `acme.io`), these system paths stay protected.
// Next.js / web standards. These entries contain characters (dots,
// underscores) that today's slug regex `^[a-z0-9]+(?:-[a-z0-9]+)*$`
// already rejects at the format-validation step — so `isReservedSlug`
// never actually matches them. They are kept as defense-in-depth so
// that if the slug regex is ever relaxed (e.g. to support dotted
// corporate slugs like `acme.io`), these system paths stay protected.
"_next",
"favicon.ico",
"robots.txt",

View File

@@ -73,9 +73,6 @@ function makeComment(overrides: Partial<Comment> = {}): Comment {
attachments: [],
created_at: "2026-04-01T00:00:00Z",
updated_at: "2026-04-01T00:00:00Z",
resolved_at: null,
resolved_by_type: null,
resolved_by_id: null,
...overrides,
};
}
@@ -93,7 +90,6 @@ function makeRuntime(ownerId: string | null): RuntimeDevice {
device_info: "",
metadata: {},
owner_id: ownerId,
timezone: "UTC",
last_seen_at: null,
created_at: "2026-04-01T00:00:00Z",
updated_at: "2026-04-01T00:00:00Z",

View File

@@ -59,7 +59,6 @@ export function AuthInitializer({
key: cfg.posthog_key,
host: cfg.posthog_host || "",
appVersion: identity?.version,
environment: cfg.analytics_environment,
});
}
})

View File

@@ -2,7 +2,7 @@
import { useMemo } from "react";
import { ApiClient } from "../api/client";
import { setApiInstance, setSchemaLogger } from "../api";
import { setApiInstance } from "../api";
import { createAuthStore, registerAuthStore } from "../auth";
import { createChatStore, registerChatStore } from "../chat";
import {
@@ -41,7 +41,6 @@ function initCore(
identity,
});
setApiInstance(api);
setSchemaLogger(createLogger("api-schema"));
// In token mode, hydrate token from storage.
if (!cookieAuth) {

View File

@@ -20,19 +20,13 @@ afterEach(() => {
});
describe("workspace-aware storage", () => {
it("drops writes and returns null for reads when no workspace is set", () => {
it("uses plain key when no workspace is set", () => {
const adapter = mockAdapter();
setCurrentWorkspace(null, null);
const storage = createWorkspaceAwareStorage(adapter);
storage.setItem("draft", "data");
expect(adapter.setItem).not.toHaveBeenCalled();
expect(storage.getItem("draft")).toBeNull();
expect(adapter.getItem).not.toHaveBeenCalled();
storage.removeItem("draft");
expect(adapter.removeItem).not.toHaveBeenCalled();
expect(adapter.setItem).toHaveBeenCalledWith("draft", "data");
});
it("namespaces key with slug when workspace is set", () => {

View File

@@ -94,24 +94,14 @@ export function registerForWorkspaceRehydration(fn: () => void) {
/**
* Storage that automatically namespaces keys with the current workspace slug.
* Reads _currentSlug at call time, so it follows workspace switches dynamically.
*
* When no workspace is active (e.g. zustand persist's initial hydration before
* WorkspaceRouteLayout has called setCurrentWorkspace, or a setter firing from
* a child component's mount-effect before the parent layout's effect has run),
* reads return null and writes are dropped — explicitly NOT a fallback to the
* un-namespaced bare key, which used to leak workspace-scoped data across
* workspaces. Persisted stores get a real read once setCurrentWorkspace
* triggers their registered rehydrate fn.
*/
export function createWorkspaceAwareStorage(adapter: StorageAdapter): StateStorage {
const resolve = (key: string) =>
_currentSlug ? `${key}:${_currentSlug}` : key;
return {
getItem: (key) =>
_currentSlug ? adapter.getItem(`${key}:${_currentSlug}`) : null,
setItem: (key, value) => {
if (_currentSlug) adapter.setItem(`${key}:${_currentSlug}`, value);
},
removeItem: (key) => {
if (_currentSlug) adapter.removeItem(`${key}:${_currentSlug}`);
},
getItem: (key) => adapter.getItem(resolve(key)),
setItem: (key, value) => adapter.setItem(resolve(key), value),
removeItem: (key) => adapter.removeItem(resolve(key)),
};
}

View File

@@ -31,9 +31,9 @@ export const PROJECT_PRIORITY_CONFIG: Record<
ProjectPriority,
{ 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

@@ -45,8 +45,6 @@ import type {
CommentCreatedPayload,
CommentUpdatedPayload,
CommentDeletedPayload,
CommentResolvedPayload,
CommentUnresolvedPayload,
ActivityCreatedPayload,
ReactionAddedPayload,
ReactionRemovedPayload,
@@ -204,7 +202,6 @@ export function useRealtimeSync(
const specificEvents = new Set([
"issue:updated", "issue:created", "issue:deleted", "issue_labels:changed", "inbox:new",
"comment:created", "comment:updated", "comment:deleted",
"comment:resolved", "comment:unresolved",
"activity:created",
"reaction:added", "reaction:removed",
"issue_reaction:added", "issue_reaction:removed",
@@ -332,25 +329,12 @@ export function useRealtimeSync(
// --- Timeline event handlers (global fallback) ---
// These events are also handled granularly by useIssueTimeline when
// IssueDetail is mounted. This global handler exists to mark the
// timeline cache stale for issues whose IssueDetail is *not* mounted,
// so stale data isn't served on next mount (staleTime: Infinity, set on
// the QueryClient default, relies on this).
//
// `refetchType: "none"` is the load-bearing detail: without it, an
// active IssueDetail observer would refetch the entire timeline on
// every comment / activity / reaction event. The refetch replaces
// every entry's reference and busts React.memo on every CommentCard
// subtree (visible during AI streaming as a flash across all sibling
// threads, MUL-1941). Inactive observers don't refetch either way;
// when IssueDetail mounts later, the stale flag triggers the refetch
// through `refetchOnMount`. Active observers stay fresh via the
// granular setQueryData handlers in `useIssueTimeline`.
// IssueDetail is mounted. This global handler ensures the timeline cache
// is invalidated even when IssueDetail is unmounted, so stale data
// isn't served on next mount (staleTime: Infinity relies on this).
const invalidateTimeline = (issueId: string) => {
qc.invalidateQueries({
queryKey: issueKeys.timeline(issueId),
refetchType: "none",
});
qc.invalidateQueries({ queryKey: issueKeys.timeline(issueId) });
};
const unsubCommentCreated = ws.on("comment:created", (p) => {
@@ -368,16 +352,6 @@ export function useRealtimeSync(
if (issue_id) invalidateTimeline(issue_id);
});
const unsubCommentResolved = ws.on("comment:resolved", (p) => {
const { comment } = p as CommentResolvedPayload;
if (comment?.issue_id) invalidateTimeline(comment.issue_id);
});
const unsubCommentUnresolved = ws.on("comment:unresolved", (p) => {
const { comment } = p as CommentUnresolvedPayload;
if (comment?.issue_id) invalidateTimeline(comment.issue_id);
});
const unsubActivityCreated = ws.on("activity:created", (p) => {
const { issue_id } = p as ActivityCreatedPayload;
if (issue_id) invalidateTimeline(issue_id);
@@ -542,7 +516,10 @@ export function useRealtimeSync(
};
const invalidateSessionLists = () => {
const id = getCurrentWsId();
if (id) qc.invalidateQueries({ queryKey: chatKeys.sessions(id) });
if (id) {
qc.invalidateQueries({ queryKey: chatKeys.sessions(id) });
qc.invalidateQueries({ queryKey: chatKeys.allSessions(id) });
}
};
const unsubChatMessage = ws.on("chat:message", (p) => {
@@ -679,6 +656,7 @@ export function useRealtimeSync(
const drop = (old?: { id: string }[]) =>
old?.filter((s) => s.id !== payload.chat_session_id);
qc.setQueryData(chatKeys.sessions(id), drop);
qc.setQueryData(chatKeys.allSessions(id), drop);
}
qc.removeQueries({ queryKey: chatKeys.messages(payload.chat_session_id) });
qc.removeQueries({ queryKey: chatKeys.pendingTask(payload.chat_session_id) });
@@ -700,8 +678,6 @@ export function useRealtimeSync(
unsubCommentCreated();
unsubCommentUpdated();
unsubCommentDeleted();
unsubCommentResolved();
unsubCommentUnresolved();
unsubActivityCreated();
unsubReactionAdded();
unsubReactionRemoved();

View File

@@ -1,56 +0,0 @@
"use client";
import { create } from "zustand";
import { createJSONStorage, persist, type StateStorage } from "zustand/middleware";
import { defaultStorage } from "../platform/storage";
// User-supplied pricing for models we don't ship a maintained rate for.
// We can't track every model OpenRouter / Codex / Hermes / Kimi etc. release,
// so the empty-state diagnostic lets users fill in their own rates. Stored
// globally (not workspace-scoped) because the rate of `gpt-5.5-mini` is the
// same regardless of which workspace you're viewing.
export interface CustomModelPricing {
input: number;
output: number;
cacheRead: number;
cacheWrite: number;
}
export interface CustomPricingState {
pricings: Record<string, CustomModelPricing>;
setCustomPricing: (model: string, pricing: CustomModelPricing) => void;
removeCustomPricing: (model: string) => void;
}
// StorageAdapter (sync getItem returning string | null) is a structural subset
// of zustand's StateStorage, so it can be handed in directly via cast.
const stateStorage = defaultStorage as unknown as StateStorage;
export const useCustomPricingStore = create<CustomPricingState>()(
persist(
(set) => ({
pricings: {},
setCustomPricing: (model, pricing) =>
set((state) => ({
pricings: { ...state.pricings, [model]: pricing },
})),
removeCustomPricing: (model) =>
set((state) => {
if (!(model in state.pricings)) return state;
const next = { ...state.pricings };
delete next[model];
return { pricings: next };
}),
}),
{
name: "multica_runtime_custom_pricing",
storage: createJSONStorage(() => stateStorage),
},
),
);
// Vanilla accessor for non-React callers (the `resolvePricing` helper in
// packages/views/runtimes/utils.ts reads from here during cost estimation).
export function getCustomPricing(model: string): CustomModelPricing | undefined {
return useCustomPricingStore.getState().pricings[model];
}

View File

@@ -17,7 +17,6 @@ function makeRuntime(overrides: Partial<AgentRuntime> = {}): AgentRuntime {
device_info: "",
metadata: {},
owner_id: null,
timezone: "UTC",
last_seen_at: new Date(FIXED_NOW - 10_000).toISOString(),
created_at: "2026-04-01T00:00:00Z",
updated_at: "2026-04-01T00:00:00Z",

View File

@@ -7,4 +7,3 @@ export * from "./types";
export * from "./derive-health";
export * from "./use-runtime-health";
export * from "./cli-version";
export * from "./custom-pricing-store";

View File

@@ -11,37 +11,3 @@ export function useDeleteRuntime(wsId: string) {
},
});
}
// useUpdateRuntime patches editable fields on a runtime (currently the
// reporting timezone). Invalidates the runtime list AND any keys downstream
// of the updated runtime — usage queries are bucketed by tz on the server,
// so a tz change must blow away cached usage rows or the chart would lie
// for one polling cycle.
export function useUpdateRuntime(wsId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: ({
runtimeId,
patch,
}: {
runtimeId: string;
patch: { timezone?: string };
}) => api.updateRuntime(runtimeId, patch),
onSettled: (_data, _err, vars) => {
qc.invalidateQueries({ queryKey: runtimeKeys.all(wsId) });
if (vars) {
// Usage query keys are not workspace-scoped; invalidate only this
// runtime's daily/by-agent/by-hour usage rows under the new tz buckets.
qc.invalidateQueries({
queryKey: ["runtimes", "usage", vars.runtimeId],
});
qc.invalidateQueries({
queryKey: ["runtimes", "usage", "by-agent", vars.runtimeId],
});
qc.invalidateQueries({
queryKey: ["runtimes", "usage", "by-hour", vars.runtimeId],
});
}
},
});
}

View File

@@ -1,4 +1,4 @@
import type { CommentAuthorType, Reaction } from "./comment";
import type { Reaction } from "./comment";
import type { Attachment } from "./attachment";
export interface AssigneeFrequencyEntry {
@@ -23,10 +23,27 @@ export interface TimelineEntry {
comment_type?: string;
reactions?: Reaction[];
attachments?: Attachment[];
resolved_at?: string | null;
resolved_by_type?: CommentAuthorType | null;
resolved_by_id?: string | null;
/** Set by frontend coalescing when consecutive identical activities are merged. */
coalesced_count?: number;
}
/**
* Cursor-paginated timeline page. Entries are newest-first
* (created_at DESC, id DESC). Cursors are opaque base64 strings — pass them
* back unchanged via TimelinePageParam.
*/
export interface TimelinePage {
entries: TimelineEntry[];
next_cursor: string | null;
prev_cursor: string | null;
has_more_before: boolean;
has_more_after: boolean;
/** Set only in around-id mode; index of the anchor entry within `entries`. */
target_index?: number;
}
export type TimelinePageParam =
| { mode: "latest" }
| { mode: "before"; cursor: string }
| { mode: "after"; cursor: string }
| { mode: "around"; id: string };

View File

@@ -16,7 +16,6 @@ export interface RuntimeDevice {
device_info: string;
metadata: Record<string, unknown>;
owner_id: string | null;
timezone: string;
last_seen_at: string | null;
created_at: string;
updated_at: string;
@@ -96,11 +95,6 @@ export interface AgentTask {
* with a meaningful title instead of falling through to "Untracked").
*/
kind?: "comment" | "autopilot" | "chat" | "quick_create" | "direct";
/**
* Local working directory pinned for this task by the daemon. Empty until
* the daemon reports a work_dir (typically once execution starts).
*/
work_dir?: string;
}
export interface Agent {

View File

@@ -23,7 +23,4 @@ export interface Comment {
attachments: import("./attachment").Attachment[];
created_at: string;
updated_at: string;
resolved_at: string | null;
resolved_by_type: CommentAuthorType | null;
resolved_by_id: string | null;
}

View File

@@ -15,8 +15,6 @@ export type WSEventType =
| "comment:created"
| "comment:updated"
| "comment:deleted"
| "comment:resolved"
| "comment:unresolved"
| "agent:status"
| "agent:created"
| "agent:archived"
@@ -145,14 +143,6 @@ export interface CommentDeletedPayload {
issue_id: string;
}
export interface CommentResolvedPayload {
comment: Comment;
}
export interface CommentUnresolvedPayload {
comment: Comment;
}
export interface WorkspaceUpdatedPayload {
workspace: Workspace;
}

View File

@@ -45,6 +45,8 @@ export type { Comment, CommentType, CommentAuthorType, Reaction } from "./commen
export type { Label, CreateLabelRequest, UpdateLabelRequest, ListLabelsResponse, IssueLabelsResponse } from "./label";
export type {
TimelineEntry,
TimelinePage,
TimelinePageParam,
AssigneeFrequencyEntry,
} from "./activity";
export type { IssueSubscriber } from "./subscriber";

View File

@@ -1,5 +1,5 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { createRequestId, createSafeId, generateUUID, isImeComposing } from "./utils";
import { createRequestId, createSafeId, generateUUID } from "./utils";
afterEach(() => {
vi.unstubAllGlobals();
@@ -31,25 +31,3 @@ describe("utils id helpers", () => {
expect(createRequestId(12)).toBe("123456781234");
});
});
describe("isImeComposing", () => {
it("returns true when nativeEvent.isComposing is set (React synthetic event)", () => {
expect(isImeComposing({ nativeEvent: { isComposing: true, keyCode: 13 } })).toBe(true);
});
it("returns true when nativeEvent.keyCode is 229 (Safari edge case)", () => {
// Safari clears isComposing on the keydown that ends composition; keyCode
// stays 229 throughout, which is the only reliable signal in that browser.
expect(isImeComposing({ nativeEvent: { isComposing: false, keyCode: 229 } })).toBe(true);
});
it("returns true for native KeyboardEvent without nativeEvent wrapper", () => {
expect(isImeComposing({ isComposing: true, keyCode: 13 })).toBe(true);
expect(isImeComposing({ isComposing: false, keyCode: 229 })).toBe(true);
});
it("returns false when not composing", () => {
expect(isImeComposing({ nativeEvent: { isComposing: false, keyCode: 13 } })).toBe(false);
expect(isImeComposing({ isComposing: false, keyCode: 13 })).toBe(false);
});
});

View File

@@ -48,28 +48,3 @@ export function createSafeId(): string {
export function createRequestId(length = 8): string {
return createSafeId().replace(/-/g, "").slice(0, length);
}
/**
* True when the keyboard event fires while an IME is composing a multi-key
* input (e.g. Chinese pinyin, Japanese kana). The Enter that commits the
* composition must NOT trigger submit/send/create handlers.
*
* Accepts both React synthetic events and native DOM `KeyboardEvent`s.
*
* Why both `isComposing` and `keyCode === 229`:
* - `isComposing` is the standard signal but Safari clears it on the keydown
* that ends composition, so a bare check misses the very Enter that submits.
* - During composition the browser reports `keyCode === 229` regardless of
* the actual key, which keeps working in Safari's edge case.
*
* Always read from `nativeEvent` when present — React's synthetic event is
* normalized but the native event reflects the browser's real state.
*/
export function isImeComposing(event: {
isComposing?: boolean;
keyCode?: number;
nativeEvent?: { isComposing?: boolean; keyCode?: number };
}): boolean {
const e = event.nativeEvent ?? event;
return Boolean(e.isComposing) || e.keyCode === 229;
}

View File

@@ -1,99 +0,0 @@
"use client";
import { Component, type ErrorInfo, type ReactNode } from "react";
import { Button } from "../ui/button";
export interface ErrorBoundaryProps {
children: ReactNode;
/** Element rendered when the boundary catches. Receives `reset` so the
* fallback can offer a "try again" button. Defaults to a small inline
* panel suitable for a section, not a full-page takeover. */
fallback?: (args: { error: Error; reset: () => void }) => ReactNode;
/** Hook for telemetry/logging. Called with the captured error and the
* React error info (component stack). */
onError?: (error: Error, info: ErrorInfo) => void;
/** When any value in this array changes between renders, the boundary
* resets. Use this to auto-recover when navigating to a new resource
* (e.g. a different issueId) without forcing the user to click "retry". */
resetKeys?: ReadonlyArray<unknown>;
}
interface ErrorBoundaryState {
error: Error | null;
}
const INITIAL_STATE: ErrorBoundaryState = { error: null };
/**
* Section-level error boundary. Wrap individual UI sections (the timeline,
* the comment list, a sidebar panel) so a render-time crash in one section
* does not blank the whole page. See CLAUDE.md "API Response Compatibility".
*
* For full-page takeovers prefer route-level error UIs (Next.js error.tsx,
* router error elements). This component is for the in-page recovery case.
*/
export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
state: ErrorBoundaryState = INITIAL_STATE;
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { error };
}
override componentDidCatch(error: Error, info: ErrorInfo): void {
this.props.onError?.(error, info);
// Log unconditionally so a missing onError doesn't swallow the trace.
// Console is fine here — the platform logger isn't bound to UI yet.
console.error("ErrorBoundary caught:", error, info.componentStack);
}
override componentDidUpdate(prevProps: ErrorBoundaryProps): void {
if (this.state.error == null) return;
const prev = prevProps.resetKeys;
const next = this.props.resetKeys;
if (!prev || !next) return;
if (prev.length !== next.length) {
this.reset();
return;
}
for (let i = 0; i < prev.length; i++) {
if (!Object.is(prev[i], next[i])) {
this.reset();
return;
}
}
}
reset = (): void => {
this.setState(INITIAL_STATE);
};
override render(): ReactNode {
const { error } = this.state;
if (error == null) return this.props.children;
if (this.props.fallback) {
return this.props.fallback({ error, reset: this.reset });
}
return <DefaultFallback error={error} reset={this.reset} />;
}
}
function DefaultFallback({ error, reset }: { error: Error; reset: () => void }) {
return (
<div
role="alert"
className="flex flex-col items-start gap-3 rounded-md border border-dashed border-border bg-muted/30 p-4 text-sm"
>
<div className="space-y-1">
<p className="font-medium text-foreground">
Something went wrong displaying this section.
</p>
<p className="text-muted-foreground">
{error.message || "An unexpected error occurred."}
</p>
</div>
<Button size="sm" variant="outline" onClick={reset}>
Try again
</Button>
</div>
);
}

View File

@@ -1,13 +1,7 @@
"use client";
import type { ReactNode } from "react";
import { ArrowUp, Loader2, Square } from "lucide-react";
import { Button } from "@multica/ui/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@multica/ui/components/ui/tooltip";
interface SubmitButtonProps {
onClick: () => void;
@@ -15,52 +9,25 @@ interface SubmitButtonProps {
loading?: boolean;
running?: boolean;
onStop?: () => void;
/**
* Tooltip shown over the send button when idle. Pass a string or a node
* (e.g. `Send · ⌘↵`). Omit to render no tooltip.
* Callers compose the shortcut hint themselves to keep this component
* free of `@multica/core` (platform-detection) and i18n imports.
*/
tooltip?: ReactNode;
/** Tooltip shown over the stop button while a run is in progress. */
stopTooltip?: ReactNode;
}
function SubmitButton({
onClick,
disabled,
loading,
running,
onStop,
tooltip,
stopTooltip,
}: SubmitButtonProps) {
function SubmitButton({ onClick, disabled, loading, running, onStop }: SubmitButtonProps) {
if (running) {
const stopButton = (
return (
<Button size="icon-sm" onClick={onStop}>
<Square className="fill-current" />
</Button>
);
if (!stopTooltip) return stopButton;
return (
<Tooltip>
<TooltipTrigger render={stopButton} />
<TooltipContent side="top">{stopTooltip}</TooltipContent>
</Tooltip>
);
}
const submitButton = (
<Button size="icon-sm" disabled={disabled || loading} onClick={onClick}>
{loading ? <Loader2 className="animate-spin" /> : <ArrowUp />}
</Button>
);
if (!tooltip) return submitButton;
return (
<Tooltip>
<TooltipTrigger render={submitButton} />
<TooltipContent side="top">{tooltip}</TooltipContent>
</Tooltip>
<Button size="icon-sm" disabled={disabled || loading} onClick={onClick}>
{loading ? (
<Loader2 className="animate-spin" />
) : (
<ArrowUp />
)}
</Button>
);
}

View File

@@ -51,7 +51,7 @@ function DropdownMenuContent({
e.stopPropagation()
onClick?.(e)
}}
className={cn("z-50 max-h-(--available-height) min-w-32 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:overflow-hidden data-closed:fade-out-0 data-closed:zoom-out-95", className )}
className={cn("z-50 max-h-(--available-height) w-(--anchor-width) min-w-32 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:overflow-hidden data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
/>
</MenuPrimitive.Positioner>

View File

@@ -27,6 +27,7 @@
--color-info: var(--info);
--color-brand: var(--brand);
--color-brand-foreground: var(--brand-foreground);
--color-priority: var(--priority);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
@@ -93,6 +94,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;
@@ -139,6 +141,7 @@
--success: oklch(0.65 0.15 145);
--warning: oklch(0.70 0.16 85);
--info: oklch(0.65 0.18 250);
--priority: oklch(0.70 0.18 50);
--scrollbar-thumb: oklch(1 0 0 / 8%);
--scrollbar-thumb-hover: oklch(1 0 0 / 18%);
--scrollbar-track: transparent;

View File

@@ -19,7 +19,7 @@ import {
} from "@multica/core/agents";
import { api } from "@multica/core/api";
import { useFileUpload } from "@multica/core/hooks/use-file-upload";
import { isImeComposing, timeAgo } from "@multica/core/utils";
import { timeAgo } from "@multica/core/utils";
import { Button } from "@multica/ui/components/ui/button";
import { ActorAvatar } from "../../common/actor-avatar";
import { Input } from "@multica/ui/components/ui/input";
@@ -455,11 +455,7 @@ function DescriptionEditorBody({
placeholder={t(($) => $.inspector.description_placeholder)}
rows={6}
onKeyDown={(e) => {
if (e.key === "Escape") {
onClose();
return;
}
if (isImeComposing(e)) return;
if (e.key === "Escape") onClose();
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
void commit();
@@ -565,14 +561,11 @@ function InlineEditPopover({
}}
placeholder={placeholder}
onKeyDown={(e) => {
if (e.key === "Escape") {
setOpen(false);
return;
}
if (isImeComposing(e)) return;
if (e.key === "Enter") {
e.preventDefault();
void commit();
} else if (e.key === "Escape") {
setOpen(false);
}
}}
className="h-8"
@@ -587,11 +580,7 @@ function InlineEditPopover({
}}
placeholder={placeholder}
onKeyDown={(e) => {
if (e.key === "Escape") {
setOpen(false);
return;
}
if (isImeComposing(e)) return;
if (e.key === "Escape") setOpen(false);
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
void commit();

View File

@@ -4,7 +4,6 @@ import { useState } from "react";
import {
AlertCircle,
ArrowLeft,
Lock,
MoreHorizontal,
Trash2,
} from "lucide-react";
@@ -15,7 +14,7 @@ import {
type AgentPresenceDetail,
useWorkspacePresenceMap,
} from "@multica/core/agents";
import { api, ApiError } from "@multica/core/api";
import { api } from "@multica/core/api";
import { useAuthStore } from "@multica/core/auth";
import { useWorkspaceId } from "@multica/core/hooks";
import { useWorkspacePaths } from "@multica/core/paths";
@@ -79,19 +78,6 @@ export function AgentDetailPage({ agentId }: AgentDetailPageProps) {
const presence: AgentPresenceDetail | null =
agent ? presenceMap.get(agent.id) ?? null : null;
// Fallback fetch: when the agent is missing from the workspace list, hit
// GET /api/agents/{id} directly to disambiguate "doesn't exist" (404) from
// "you can't see this private agent" (403). Only fires after the list has
// settled, so the common path makes zero extra requests.
const { error: detailError } = useQuery({
queryKey: ["agent-detail-probe", wsId, agentId],
queryFn: () => api.getAgent(agentId),
enabled: !agentsLoading && !agent && !!agentId,
retry: false,
});
const isForbidden =
detailError instanceof ApiError && detailError.status === 403;
// Permission hook MUST be called unconditionally — its `agent | null`
// signature handles the not-found / loading case internally so the early
// returns below don't violate the rules of hooks. Backend gates archive
@@ -136,31 +122,6 @@ export function AgentDetailPage({ agentId }: AgentDetailPageProps) {
return <DetailLoadingSkeleton />;
}
// --- No permission (private agent the caller is not in allowed_principals for) ---
if (!agent && isForbidden) {
return (
<div className="flex flex-1 min-h-0 flex-col">
<BackHeader paths={paths.agents()} title={t(($) => $.detail.back_to_agents)} />
<div className="flex flex-1 flex-col items-center justify-center gap-3 px-6 py-16 text-center">
<Lock className="h-8 w-8 text-muted-foreground" />
<div>
<p className="text-sm font-medium">{t(($) => $.detail.no_access_title)}</p>
<p className="mt-1 text-xs text-muted-foreground">
{t(($) => $.detail.no_access_hint)}
</p>
</div>
<Button
type="button"
size="sm"
onClick={() => navigation.push(paths.agents())}
>
{t(($) => $.detail.back_to_agents_full)}
</Button>
</div>
</div>
);
}
// --- Not found / error ---
if (!agent) {
return (

View File

@@ -12,7 +12,6 @@ import type {
MemberWithUser,
CreateAgentRequest,
} from "@multica/core/types";
import { isImeComposing } from "@multica/core/utils";
import {
Dialog,
DialogContent,
@@ -173,10 +172,7 @@ export function CreateAgentDialog({
onChange={(e) => setName(e.target.value)}
placeholder={t(($) => $.create_dialog.name_placeholder)}
className="mt-1"
onKeyDown={(e) => {
if (isImeComposing(e)) return;
if (e.key === "Enter") handleSubmit();
}}
onKeyDown={(e) => e.key === "Enter" && handleSubmit()}
/>
</div>

View File

@@ -1,7 +1,7 @@
"use client";
import { useState } from "react";
import { FileText, Search } from "lucide-react";
import { FileText } from "lucide-react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import type { Agent } from "@multica/core/types";
@@ -20,7 +20,6 @@ import {
DialogHeader,
DialogTitle,
} from "@multica/ui/components/ui/dialog";
import { Input } from "@multica/ui/components/ui/input";
import { useT } from "../../i18n";
/**
@@ -47,27 +46,11 @@ export function SkillAddDialog({
const qc = useQueryClient();
const { data: workspaceSkills = [] } = useQuery(skillListOptions(wsId));
const [saving, setSaving] = useState(false);
const [query, setQuery] = useState("");
const agentSkillIds = new Set(agent.skills.map((s) => s.id));
const availableSkills = workspaceSkills.filter(
(s) => !agentSkillIds.has(s.id),
);
const trimmedQuery = query.trim().toLowerCase();
const filteredSkills = trimmedQuery
? availableSkills.filter((s) => {
const name = s.name.toLowerCase();
const description = s.description?.toLowerCase() ?? "";
return (
name.includes(trimmedQuery) || description.includes(trimmedQuery)
);
})
: availableSkills;
const handleOpenChange = (v: boolean) => {
if (!v) setQuery("");
onOpenChange(v);
};
const handleAdd = async (skillId: string) => {
setSaving(true);
@@ -75,7 +58,7 @@ export function SkillAddDialog({
const newIds = [...agent.skills.map((s) => s.id), skillId];
await api.setAgentSkills(agent.id, { skill_ids: newIds });
qc.invalidateQueries({ queryKey: workspaceKeys.agents(wsId) });
handleOpenChange(false);
onOpenChange(false);
} catch (e) {
toast.error(e instanceof Error ? e.message : t(($) => $.tab_body.skills.add_failed_toast));
} finally {
@@ -83,11 +66,8 @@ export function SkillAddDialog({
}
};
const showSearch = availableSkills.length > 0;
const noMatch = showSearch && filteredSkills.length === 0;
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="text-sm">{t(($) => $.tab_body.skills.add_dialog_title)}</DialogTitle>
@@ -95,21 +75,8 @@ export function SkillAddDialog({
{t(($) => $.tab_body.skills.add_dialog_description)}
</DialogDescription>
</DialogHeader>
{showSearch && (
<div className="relative">
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
autoFocus
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={t(($) => $.tab_body.skills.add_dialog_search_placeholder)}
aria-label={t(($) => $.tab_body.skills.add_dialog_search_placeholder)}
className="pl-7"
/>
</div>
)}
<div className="max-h-64 space-y-1 overflow-y-auto">
{filteredSkills.map((skill) => (
{availableSkills.map((skill) => (
<button
key={skill.id}
onClick={() => handleAdd(skill.id)}
@@ -132,14 +99,9 @@ export function SkillAddDialog({
{t(($) => $.tab_body.skills.add_dialog_empty)}
</p>
)}
{noMatch && (
<p className="py-6 text-center text-xs text-muted-foreground">
{t(($) => $.tab_body.skills.add_dialog_no_match)}
</p>
)}
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => handleOpenChange(false)}>
<Button variant="ghost" onClick={() => onOpenChange(false)}>
{t(($) => $.tab_body.skills.add_dialog_cancel)}
</Button>
</DialogFooter>

View File

@@ -7,7 +7,6 @@ import { ContentEditor, type ContentEditorRef } from "../../editor";
import { SubmitButton } from "@multica/ui/components/common/submit-button";
import { useChatStore, DRAFT_NEW_SESSION } from "@multica/core/chat";
import { createLogger } from "@multica/core/logger";
import { enterKey, formatShortcut, modKey } from "@multica/core/platform";
import { useT } from "../../i18n";
const logger = createLogger("chat.ui");
@@ -141,10 +140,8 @@ export function ChatInput({
// Chat is short-form — the floating formatting toolbar is
// more distraction than feature here.
showBubbleMenu={false}
// Mod+Enter submits. Bare Enter falls through to Tiptap's
// default, which continues lists/quotes and breaks paragraphs.
// Without this, Enter-as-send would steal the only key that
// continues a bullet list, leaving users stuck after one item.
// Enter sends; Shift-Enter inserts a hard break.
submitOnEnter
/>
</div>
{leftAdornment && (
@@ -159,8 +156,6 @@ export function ChatInput({
disabled={isEmpty || !!disabled || !!noAgent}
running={isRunning}
onStop={onStop}
tooltip={`${t(($) => $.input.send_tooltip)} · ${formatShortcut(modKey, enterKey)}`}
stopTooltip={t(($) => $.input.stop_tooltip)}
/>
</div>
</div>

View File

@@ -0,0 +1,244 @@
"use client";
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { ArrowLeft, MessageSquare, Bot, Trash2 } from "lucide-react";
import { cn } from "@multica/ui/lib/utils";
import { Button } from "@multica/ui/components/ui/button";
import { Tooltip, TooltipTrigger, TooltipContent } from "@multica/ui/components/ui/tooltip";
import { Avatar, AvatarFallback, AvatarImage } from "@multica/ui/components/ui/avatar";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@multica/ui/components/ui/alert-dialog";
import { useWorkspaceId } from "@multica/core/hooks";
import { agentListOptions } from "@multica/core/workspace/queries";
import { allChatSessionsOptions } from "@multica/core/chat/queries";
import { useChatStore } from "@multica/core/chat";
import { useDeleteChatSession } from "@multica/core/chat/mutations";
import { createLogger } from "@multica/core/logger";
import type { ChatSession, Agent } from "@multica/core/types";
import { useT } from "../../i18n";
const logger = createLogger("chat.ui");
export function ChatSessionHistory() {
const { t } = useT("chat");
const wsId = useWorkspaceId();
const setShowHistory = useChatStore((s) => s.setShowHistory);
const setActiveSession = useChatStore((s) => s.setActiveSession);
const activeSessionId = useChatStore((s) => s.activeSessionId);
const { data: sessions = [] } = useQuery(allChatSessionsOptions(wsId));
const { data: agents = [] } = useQuery(agentListOptions(wsId));
const deleteSession = useDeleteChatSession();
const [pendingDelete, setPendingDelete] = useState<ChatSession | null>(null);
const agentMap = new Map(agents.map((a) => [a.id, a]));
const handleSelectSession = (session: ChatSession) => {
logger.info("selectSession", {
from: activeSessionId,
to: session.id,
agentId: session.agent_id,
status: session.status,
});
// Changing activeSessionId flips the query keys for messages +
// pending-task; no manual clear needed.
setActiveSession(session.id);
setShowHistory(false);
};
const handleConfirmDelete = () => {
if (!pendingDelete) return;
const sessionId = pendingDelete.id;
logger.info("deleteSession.confirm", { sessionId });
// Clear the active pointer locally so the chat window doesn't keep
// pointing at a session we're about to remove. Other tabs are handled
// by the chat:session_deleted WS handler.
if (activeSessionId === sessionId) {
setActiveSession(null);
}
deleteSession.mutate(sessionId, {
onSettled: () => setPendingDelete(null),
});
};
return (
<div className="flex flex-1 flex-col overflow-hidden">
{/* Header */}
<div className="flex items-center gap-2 border-b px-4 py-2.5">
<Tooltip>
<TooltipTrigger
render={
<Button
variant="ghost"
size="icon-sm"
className="text-muted-foreground"
onClick={() => setShowHistory(false)}
/>
}
>
<ArrowLeft />
</TooltipTrigger>
<TooltipContent side="bottom">{t(($) => $.session_history.back_tooltip)}</TooltipContent>
</Tooltip>
<span className="text-sm font-medium">{t(($) => $.session_history.header)}</span>
</div>
{/* Session list */}
<div className="flex-1 overflow-y-auto">
{sessions.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-2 py-12 text-muted-foreground">
<MessageSquare className="size-6" />
<span className="text-sm">{t(($) => $.session_history.empty)}</span>
</div>
) : (
<div>
{sessions.map((session) => (
<SessionItem
key={session.id}
session={session}
agent={agentMap.get(session.agent_id) ?? null}
isActive={session.id === activeSessionId}
onSelect={() => handleSelectSession(session)}
onRequestDelete={() => setPendingDelete(session)}
/>
))}
</div>
)}
</div>
<AlertDialog
open={!!pendingDelete}
onOpenChange={(open) => {
if (!open && !deleteSession.isPending) setPendingDelete(null);
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t(($) => $.session_history.delete_dialog.title)}</AlertDialogTitle>
<AlertDialogDescription>
{pendingDelete?.title
? t(($) => $.session_history.delete_dialog.description_with_title, { title: pendingDelete.title })
: t(($) => $.session_history.delete_dialog.description_default)}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleteSession.isPending}>
{t(($) => $.session_history.delete_dialog.cancel)}
</AlertDialogCancel>
<AlertDialogAction
onClick={handleConfirmDelete}
disabled={deleteSession.isPending}
className="bg-destructive text-white hover:bg-destructive/90"
>
{deleteSession.isPending
? t(($) => $.session_history.delete_dialog.confirming)
: t(($) => $.session_history.delete_dialog.confirm)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
function useFormatTimeAgo(): (dateStr: string) => string {
const { t } = useT("chat");
return (dateStr: string) => {
const date = new Date(dateStr);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) return t(($) => $.session_history.time.just_now);
if (diffMins < 60) return t(($) => $.session_history.time.minutes, { count: diffMins });
if (diffHours < 24) return t(($) => $.session_history.time.hours, { count: diffHours });
if (diffDays < 7) return t(($) => $.session_history.time.days, { count: diffDays });
return date.toLocaleDateString();
};
}
function SessionItem({
session,
agent,
isActive,
onSelect,
onRequestDelete,
}: {
session: ChatSession;
agent: Agent | null;
isActive: boolean;
onSelect: () => void;
onRequestDelete: () => void;
}) {
const { t } = useT("chat");
const formatTimeAgo = useFormatTimeAgo();
const timeAgo = formatTimeAgo(session.updated_at);
return (
<div
className={cn(
"group relative flex w-full items-start gap-3 px-4 py-2.5 text-left transition-colors hover:bg-accent/50",
isActive && "bg-accent/30",
)}
>
<button
type="button"
onClick={onSelect}
className="flex flex-1 items-start gap-3 min-w-0 text-left"
>
<Avatar className="size-6 shrink-0 mt-0.5">
{agent?.avatar_url && <AvatarImage src={agent.avatar_url} />}
<AvatarFallback className="bg-purple-100 text-purple-700">
<Bot className="size-3" />
</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate text-sm font-medium">
{session.title || t(($) => $.session_history.untitled)}
</span>
</div>
<div className="flex items-center gap-1.5 mt-0.5">
{agent && (
<span className="text-xs text-muted-foreground truncate">
{agent.name}
</span>
)}
<span className="text-xs text-muted-foreground/60">{timeAgo}</span>
</div>
</div>
</button>
<Tooltip>
<TooltipTrigger
render={
<Button
variant="ghost"
size="icon-sm"
className="shrink-0 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100 hover:text-destructive"
onClick={(e) => {
e.stopPropagation();
onRequestDelete();
}}
aria-label={t(($) => $.session_history.row_delete_aria)}
/>
}
>
<Trash2 className="size-3.5" />
</TooltipTrigger>
<TooltipContent side="left">{t(($) => $.session_history.row_delete_tooltip)}</TooltipContent>
</Tooltip>
</div>
);
}

View File

@@ -1,9 +1,9 @@
"use client";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import React, { useCallback, useEffect, useMemo, useRef } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { motion } from "motion/react";
import { Minus, Maximize2, Minimize2, ChevronDown, ChevronRight, Plus, Check, Trash2 } from "lucide-react";
import { Minus, Maximize2, Minimize2, ChevronDown, Plus, Check, History } from "lucide-react";
import { Button } from "@multica/ui/components/ui/button";
import { Tooltip, TooltipTrigger, TooltipContent } from "@multica/ui/components/ui/tooltip";
import {
@@ -15,16 +15,6 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@multica/ui/components/ui/dropdown-menu";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@multica/ui/components/ui/alert-dialog";
import { useWorkspaceId } from "@multica/core/hooks";
import { useAuthStore } from "@multica/core/auth";
import { agentListOptions, memberListOptions } from "@multica/core/workspace/queries";
@@ -36,19 +26,17 @@ import { OfflineBanner } from "./offline-banner";
import { NoAgentBanner } from "./no-agent-banner";
import {
chatSessionsOptions,
allChatSessionsOptions,
chatMessagesOptions,
pendingChatTaskOptions,
pendingChatTasksOptions,
chatKeys,
} from "@multica/core/chat/queries";
import {
useCreateChatSession,
useDeleteChatSession,
useMarkChatSessionRead,
} from "@multica/core/chat/mutations";
import { useCreateChatSession, useMarkChatSessionRead } from "@multica/core/chat/mutations";
import { useChatStore } from "@multica/core/chat";
import { ChatMessageList, ChatMessageSkeleton } from "./chat-message-list";
import { ChatInput } from "./chat-input";
import { ChatSessionHistory } from "./chat-session-history";
import {
ContextAnchorButton,
ContextAnchorCard,
@@ -73,13 +61,13 @@ export function ChatWindow() {
const setOpen = useChatStore((s) => s.setOpen);
const setActiveSession = useChatStore((s) => s.setActiveSession);
const setSelectedAgentId = useChatStore((s) => s.setSelectedAgentId);
const showHistory = useChatStore((s) => s.showHistory);
const setShowHistory = useChatStore((s) => s.setShowHistory);
const user = useAuthStore((s) => s.user);
const { data: agents = [] } = useQuery(agentListOptions(wsId));
const { data: members = [] } = useQuery(memberListOptions(wsId));
// Single sessions cache. The dropdown groups locally into "active" /
// "archived" — eliminating the separate active/all queries that used
// to drift during the WS-invalidate window.
const { data: sessions = [] } = useQuery(chatSessionsOptions(wsId));
const { data: allSessions = [] } = useQuery(allChatSessionsOptions(wsId));
const { data: rawMessages, isLoading: messagesLoading } = useQuery(
chatMessagesOptions(activeSessionId ?? ""),
);
@@ -102,10 +90,10 @@ export function ChatWindow() {
// Legacy archived sessions (the old soft-archive feature was removed but
// pre-existing rows with status='archived' may still exist) render as
// read-only: dropdown keeps showing them under "archived", but ChatInput
// is disabled and the server still rejects POST /messages for them.
// read-only: history list keeps showing them, but ChatInput is disabled
// and the server still rejects POST /messages for them.
const currentSession = activeSessionId
? sessions.find((s) => s.id === activeSessionId)
? allSessions.find((s) => s.id === activeSessionId)
: null;
const isSessionArchived = currentSession?.status === "archived";
@@ -423,6 +411,24 @@ export function ChatWindow() {
/>
</div>
<div className="flex items-center gap-0.5 shrink-0">
<Tooltip>
<TooltipTrigger
render={
<Button
variant="ghost"
size="icon-sm"
className="text-muted-foreground data-[active=true]:bg-accent"
data-active={showHistory ? "true" : undefined}
onClick={() => setShowHistory(!showHistory)}
/>
}
>
<History />
</TooltipTrigger>
<TooltipContent side="top">
{showHistory ? t(($) => $.window.history_back_tooltip) : t(($) => $.window.history_show_tooltip)}
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger
render={
@@ -458,58 +464,67 @@ export function ChatWindow() {
</div>
</div>
{/* Messages / skeleton / empty state */}
{showSkeleton ? (
<ChatMessageSkeleton />
) : hasMessages ? (
<ChatMessageList
messages={messages}
pendingTask={pendingTask}
availability={availability}
/>
{/* History panel takes over the body when toggled — surfaces the
* per-row delete button. Hidden by default; the input + banners
* are skipped here because the panel has its own affordances. */}
{showHistory ? (
<ChatSessionHistory />
) : (
<EmptyState
hasSessions={sessions.length > 0}
agentName={activeAgent?.name}
onPickPrompt={(text) => handleSend(text)}
/>
)}
<>
{/* Messages / skeleton / empty state */}
{showSkeleton ? (
<ChatMessageSkeleton />
) : hasMessages ? (
<ChatMessageList
messages={messages}
pendingTask={pendingTask}
availability={availability}
/>
) : (
<EmptyState
hasSessions={sessions.length > 0}
agentName={activeAgent?.name}
onPickPrompt={(text) => handleSend(text)}
/>
)}
{/* Status banner above the input — single mutually-exclusive slot.
* Priority: no-agent > offline / unstable. Agent presence is the
* hard prerequisite (you can't send anything without one), so it
* always wins over a presence hint. ContextAnchorCard stays in
* topSlot because that's per-message context, not session state.
*
* We key off `noAgent` (the resolved-empty state) rather than
* `!activeAgent`, so the loading window between mount and the
* first agent-list response stays banner-free. */}
{noAgent ? (
<NoAgentBanner />
) : (
<OfflineBanner agentName={activeAgent?.name} availability={availability} />
)}
{/* Status banner above the input — single mutually-exclusive slot.
* Priority: no-agent > offline / unstable. Agent presence is the
* hard prerequisite (you can't send anything without one), so it
* always wins over a presence hint. ContextAnchorCard stays in
* topSlot because that's per-message context, not session state.
*
* We key off `noAgent` (the resolved-empty state) rather than
* `!activeAgent`, so the loading window between mount and the
* first agent-list response stays banner-free. */}
{noAgent ? (
<NoAgentBanner />
) : (
<OfflineBanner agentName={activeAgent?.name} availability={availability} />
)}
{/* Input — disabled for legacy archived sessions; locked out entirely
* when there's no agent (the EmptyState above carries the CTA). */}
<ChatInput
onSend={handleSend}
onStop={handleStop}
isRunning={!!pendingTaskId}
disabled={isSessionArchived}
noAgent={noAgent}
agentName={activeAgent?.name}
topSlot={<ContextAnchorCard />}
leftAdornment={
<AgentDropdown
agents={availableAgents}
activeAgent={activeAgent}
userId={user?.id}
onSelect={handleSelectAgent}
{/* Input — disabled for legacy archived sessions; locked out entirely
* when there's no agent (the EmptyState above carries the CTA). */}
<ChatInput
onSend={handleSend}
onStop={handleStop}
isRunning={!!pendingTaskId}
disabled={isSessionArchived}
noAgent={noAgent}
agentName={activeAgent?.name}
topSlot={<ContextAnchorCard />}
leftAdornment={
<AgentDropdown
agents={availableAgents}
activeAgent={activeAgent}
userId={user?.id}
onSelect={handleSelectAgent}
/>
}
rightAdornment={<ContextAnchorButton />}
/>
}
rightAdornment={<ContextAnchorButton />}
/>
</>
)}
</motion.div>
);
}
@@ -621,9 +636,8 @@ function AgentMenuItem({
}
/**
* Session dropdown: groups all sessions into "active" and "archived". The
* archived branch is collapsed by default and only mounts on demand to
* keep the menu compact when the user has many old chats. Selecting a
* Session dropdown: lists ALL sessions across agents. Each row carries the
* owning agent's avatar so the user can tell them apart. Selecting a
* session from a different agent implicitly switches the agent too
* (sessions are bound 1:1 to an agent). "New chat" lives in the header's
* ⊕ button, not inside this dropdown.
@@ -646,22 +660,6 @@ function SessionDropdown({
const title = activeSession?.title?.trim() || t(($) => $.window.untitled);
const triggerAgent = activeSession ? agentById.get(activeSession.agent_id) ?? null : null;
const { active, archived } = useMemo(() => {
const active: ChatSession[] = [];
const archived: ChatSession[] = [];
for (const s of sessions) {
if (s.status === "archived") archived.push(s);
else active.push(s);
}
return { active, archived };
}, [sessions]);
const [showArchived, setShowArchived] = useState(false);
const [pendingDelete, setPendingDelete] = useState<ChatSession | null>(null);
const deleteSession = useDeleteChatSession();
const setActiveSession = useChatStore((s) => s.setActiveSession);
const formatTimeAgo = useFormatTimeAgo();
// Aggregate "which sessions have an in-flight task right now". Reuses
// the same workspace-scoped query the FAB consumes, so toggling the chat
// window doesn't fire a second request — TanStack dedupes by key.
@@ -684,214 +682,93 @@ function SessionDropdown({
(s) => s.id !== activeSessionId && s.has_unread,
);
const handleConfirmDelete = () => {
if (!pendingDelete) return;
const sessionId = pendingDelete.id;
// Eager local clear when the user is deleting the session they're
// currently looking at — otherwise messages / pendingTask queries
// keep rendering the now-deleted session until chat:session_deleted
// arrives over WS (~50200ms gap).
if (activeSessionId === sessionId) setActiveSession(null);
deleteSession.mutate(sessionId, {
onSettled: () => setPendingDelete(null),
});
};
const renderRow = (session: ChatSession) => {
const isCurrent = session.id === activeSessionId;
const agent = agentById.get(session.agent_id) ?? null;
const isRunning = inFlightSessionIds.has(session.id);
return (
<DropdownMenuItem
key={session.id}
onClick={() => onSelectSession(session)}
className="group flex min-w-0 items-center gap-2"
>
{agent ? (
return (
<DropdownMenu>
<DropdownMenuTrigger className="flex items-center gap-1.5 min-w-0 rounded-md px-1.5 py-1 transition-colors hover:bg-accent aria-expanded:bg-accent">
{triggerAgent && (
<ActorAvatar
actorType="agent"
actorId={agent.id}
actorId={triggerAgent.id}
size={24}
enableHoverCard
showStatusDot
/>
) : (
<span className="size-6 shrink-0" />
)}
<div className="min-w-0 flex-1">
<div className="truncate text-sm">
{session.title?.trim() || t(($) => $.window.untitled)}
</div>
<div className="truncate text-xs text-muted-foreground/70">
{formatTimeAgo(session.updated_at)}
</div>
</div>
{/* Right-edge status pip: in-flight wins over unread because
* "still working" is more actionable than "has reply" — and
* the two rarely coexist in practice (the unread flag fires
* on chat_message write, by which point the task has just
* finished). Same pip shape as unread for visual rhythm,
* amber + pulse to read as activity. */}
{isRunning ? (
<span className="truncate text-sm font-medium">{title}</span>
{otherSessionRunning ? (
<span
aria-label={t(($) => $.window.running)}
title={t(($) => $.window.running)}
aria-label={t(($) => $.window.another_running)}
title={t(($) => $.window.another_running)}
className="size-1.5 shrink-0 rounded-full bg-amber-500 animate-pulse"
/>
) : session.has_unread ? (
) : otherSessionUnread ? (
<span
aria-label={t(($) => $.window.unread)}
title={t(($) => $.window.unread)}
aria-label={t(($) => $.window.another_unread)}
title={t(($) => $.window.another_unread)}
className="size-1.5 shrink-0 rounded-full bg-brand"
/>
) : null}
{isCurrent && <Check className="size-3.5 text-muted-foreground shrink-0" />}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
setPendingDelete(session);
}}
className="shrink-0 rounded p-1 text-muted-foreground opacity-0 transition-opacity hover:bg-destructive/10 hover:text-destructive focus-visible:opacity-100 group-hover:opacity-100"
aria-label={t(($) => $.session_history.row_delete_aria)}
>
<Trash2 className="size-3.5" />
</button>
</DropdownMenuItem>
);
};
return (
<>
<DropdownMenu>
<DropdownMenuTrigger className="flex items-center gap-1.5 min-w-0 rounded-md px-1.5 py-1 transition-colors hover:bg-accent aria-expanded:bg-accent">
{triggerAgent && (
<ActorAvatar
actorType="agent"
actorId={triggerAgent.id}
size={24}
enableHoverCard
showStatusDot
/>
)}
<span className="truncate text-sm font-medium">{title}</span>
{otherSessionRunning ? (
<span
aria-label={t(($) => $.window.another_running)}
title={t(($) => $.window.another_running)}
className="size-1.5 shrink-0 rounded-full bg-amber-500 animate-pulse"
/>
) : otherSessionUnread ? (
<span
aria-label={t(($) => $.window.another_unread)}
title={t(($) => $.window.another_unread)}
className="size-1.5 shrink-0 rounded-full bg-brand"
/>
) : null}
<ChevronDown className="size-3 text-muted-foreground shrink-0" />
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="max-h-96 w-auto min-w-64 max-w-80 overflow-y-auto">
{sessions.length === 0 ? (
<div className="px-2 py-1.5 text-xs text-muted-foreground">
{t(($) => $.window.no_previous)}
</div>
) : (
<>
{active.length > 0 && (
<DropdownMenuGroup>
<DropdownMenuLabel>{t(($) => $.window.active_group)}</DropdownMenuLabel>
{active.map(renderRow)}
</DropdownMenuGroup>
)}
{archived.length > 0 && (
<>
{active.length > 0 && <DropdownMenuSeparator />}
<DropdownMenuItem
onClick={(e) => {
e.preventDefault();
setShowArchived((v) => !v);
}}
className="flex items-center gap-1.5 text-xs text-muted-foreground"
>
{showArchived ? (
<ChevronDown className="size-3" />
) : (
<ChevronRight className="size-3" />
)}
<span>
{t(($) => $.window.archived_group, { count: archived.length })}
</span>
</DropdownMenuItem>
{showArchived && (
<DropdownMenuGroup>
{archived.map(renderRow)}
</DropdownMenuGroup>
)}
</>
)}
</>
)}
</DropdownMenuContent>
</DropdownMenu>
<AlertDialog
open={!!pendingDelete}
onOpenChange={(open) => {
if (!open && !deleteSession.isPending) setPendingDelete(null);
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t(($) => $.session_history.delete_dialog.title)}
</AlertDialogTitle>
<AlertDialogDescription>
{pendingDelete?.title
? t(($) => $.session_history.delete_dialog.description_with_title, {
title: pendingDelete.title,
})
: t(($) => $.session_history.delete_dialog.description_default)}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleteSession.isPending}>
{t(($) => $.session_history.delete_dialog.cancel)}
</AlertDialogCancel>
<AlertDialogAction
onClick={handleConfirmDelete}
disabled={deleteSession.isPending}
className="bg-destructive text-white hover:bg-destructive/90"
>
{deleteSession.isPending
? t(($) => $.session_history.delete_dialog.confirming)
: t(($) => $.session_history.delete_dialog.confirm)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
<ChevronDown className="size-3 text-muted-foreground shrink-0" />
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="max-h-80 w-auto min-w-56 max-w-80">
{sessions.length === 0 ? (
<div className="px-2 py-1.5 text-xs text-muted-foreground">
{t(($) => $.window.no_previous)}
</div>
) : (
sessions.map((session) => {
const isCurrent = session.id === activeSessionId;
const agent = agentById.get(session.agent_id) ?? null;
const isRunning = inFlightSessionIds.has(session.id);
return (
<DropdownMenuItem
key={session.id}
onClick={() => onSelectSession(session)}
className="flex min-w-0 items-center gap-2"
>
{agent ? (
<ActorAvatar
actorType="agent"
actorId={agent.id}
size={24}
enableHoverCard
showStatusDot
/>
) : (
<span className="size-6 shrink-0" />
)}
<span className="truncate flex-1 text-sm">
{session.title?.trim() || t(($) => $.window.untitled)}
</span>
{/* Right-edge status pip: in-flight wins over unread because
* "still working" is more actionable than "has reply" — and
* the two rarely coexist in practice (the unread flag fires
* on chat_message write, by which point the task has just
* finished). Same pip shape as unread for visual rhythm,
* amber + pulse to read as activity. */}
{isRunning ? (
<span
aria-label={t(($) => $.window.running)}
title={t(($) => $.window.running)}
className="size-1.5 shrink-0 rounded-full bg-amber-500 animate-pulse"
/>
) : session.has_unread ? (
<span
aria-label={t(($) => $.window.unread)}
title={t(($) => $.window.unread)}
className="size-1.5 shrink-0 rounded-full bg-brand"
/>
) : null}
{isCurrent && <Check className="size-3.5 text-muted-foreground shrink-0" />}
</DropdownMenuItem>
);
})
)}
</DropdownMenuContent>
</DropdownMenu>
);
}
function useFormatTimeAgo(): (dateStr: string) => string {
const { t } = useT("chat");
return (dateStr: string) => {
const date = new Date(dateStr);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) return t(($) => $.session_history.time.just_now);
if (diffMins < 60) return t(($) => $.session_history.time.minutes, { count: diffMins });
if (diffHours < 24) return t(($) => $.session_history.time.hours, { count: diffHours });
if (diffDays < 7) return t(($) => $.session_history.time.days, { count: diffDays });
return date.toLocaleDateString();
};
}
// Three starter prompts shown on the empty state. Each is keyed into the
// chat namespace so labels translate per locale; the icon stays raw since
// emojis are locale-neutral.

View File

@@ -96,7 +96,7 @@ function getEventLabel(item: TimelineItem): string {
function getEventSummary(item: TimelineItem): string {
switch (item.type) {
case "text":
return item.content?.split("\n").find((l) => l.trim().length > 0) ?? "";
return item.content?.split("\n").filter(Boolean).pop() ?? "";
case "thinking":
return item.content?.slice(0, 200) ?? "";
case "tool_use": {
@@ -592,7 +592,7 @@ const TranscriptEventRow = ({
(item.type === "tool_use" && item.input && Object.keys(item.input).length > 0) ||
(item.type === "tool_result" && item.output && item.output.length > 0) ||
(item.type === "thinking" && item.content && item.content.length > 0) ||
(item.type === "text" && item.content && item.content.length > 0) ||
(item.type === "text" && item.content && item.content.split("\n").length > 1) ||
(item.type === "error" && item.content && item.content.length > 0);
return (

View File

@@ -1,77 +0,0 @@
"use client";
import { createContext, useContext, useMemo, type ReactNode } from "react";
import type { Attachment } from "@multica/core/types";
import { openExternal } from "../platform";
import { useDownloadAttachment } from "./use-download-attachment";
interface ResolvedDownload {
// Returns the attachment id for a URL referenced in the markdown, or
// `undefined` if it's an external link we don't manage.
resolveAttachmentId: (url: string) => string | undefined;
// Called by NodeView click handlers. Re-signs through `getAttachment` when
// the URL maps to a known attachment; falls back to `openExternal` for
// external URLs so Electron still routes through the IPC bridge instead of
// letting `window.open` hit the `setWindowOpenHandler` deny path.
openByUrl: (url: string) => void;
}
const AttachmentDownloadContext = createContext<ResolvedDownload | null>(null);
interface ProviderProps {
attachments?: Attachment[];
children: ReactNode;
}
/**
* Provides a click-time download handler to Tiptap NodeViews mounted inside
* `ContentEditor`. Without a provider the consumer falls back to opening the
* raw URL via `openExternal` — same behaviour as before this hook existed.
*/
export function AttachmentDownloadProvider({ attachments, children }: ProviderProps) {
const download = useDownloadAttachment();
const value = useMemo<ResolvedDownload>(
() => ({
resolveAttachmentId: (url) => {
if (!url || !attachments?.length) return undefined;
return attachments.find((a) => a.url === url)?.id;
},
openByUrl: (url) => {
const id = url && attachments?.length
? attachments.find((a) => a.url === url)?.id
: undefined;
if (id) {
download(id);
return;
}
if (url) openExternal(url);
},
}),
[attachments, download],
);
return (
<AttachmentDownloadContext.Provider value={value}>
{children}
</AttachmentDownloadContext.Provider>
);
}
/**
* Returns the click-time download handler installed by a surrounding
* `AttachmentDownloadProvider`, or a fallback that just opens the raw URL
* externally. Used by file-card and image NodeViews so they can stay
* usable in editor surfaces that haven't been wired up yet.
*/
export function useAttachmentDownloadResolver(): ResolvedDownload {
const ctx = useContext(AttachmentDownloadContext);
// Hooks-must-be-unconditional: always create the fallback object, but
// memoization is unnecessary here because each NodeView render also
// re-runs the click handler closure.
if (ctx) return ctx;
return {
resolveAttachmentId: () => undefined,
openByUrl: (url) => {
if (url) openExternal(url);
},
};
}

View File

@@ -42,14 +42,12 @@ import { cn } from "@multica/ui/lib/utils";
import type { UploadResult } from "@multica/core/hooks/use-file-upload";
import { useWorkspaceSlug } from "@multica/core/paths";
import { useQueryClient } from "@tanstack/react-query";
import type { Attachment } from "@multica/core/types";
import { createEditorExtensions } from "./extensions";
import { uploadAndInsertFile } from "./extensions/file-upload";
import { preprocessMarkdown } from "./utils/preprocess";
import { openLink, isMentionHref } from "./utils/link-handler";
import { EditorBubbleMenu } from "./bubble-menu";
import { useLinkHover, LinkHoverCard } from "./link-hover-card";
import { AttachmentDownloadProvider } from "./attachment-download-context";
import "katex/dist/katex.min.css";
import "./content-editor.css";
@@ -94,15 +92,6 @@ interface ContentEditorProps {
* system prompts, where the content is fed to an LLM as plain text).
*/
disableMentions?: boolean;
/**
* Attachments referenced by this content. The download buttons on file
* cards and images inside the editor look up an attachment by `url` and
* fetch a fresh CloudFront signature at click time, so a stale URL
* persisted in markdown never opens. Pass `issue.attachments` /
* `comment.attachments` etc.; omit when no attachment context is
* available (NodeView buttons fall back to opening the raw URL).
*/
attachments?: Attachment[];
}
interface ContentEditorRef {
@@ -137,7 +126,6 @@ const ContentEditor = forwardRef<ContentEditorRef, ContentEditorProps>(
submitOnEnter = false,
currentIssueId,
disableMentions = false,
attachments,
},
ref,
) {
@@ -269,19 +257,17 @@ const ContentEditor = forwardRef<ContentEditorRef, ContentEditorProps>(
if (!editor) return null;
return (
<AttachmentDownloadProvider attachments={attachments}>
<div
ref={wrapperRef}
className="relative flex min-h-full flex-col"
onMouseDown={handleContainerMouseDown}
>
<EditorContent className="flex-1 min-h-full" editor={editor} />
{showBubbleMenu && (
<EditorBubbleMenu editor={editor} currentIssueId={currentIssueId} />
)}
<LinkHoverCard {...hover} />
</div>
</AttachmentDownloadProvider>
<div
ref={wrapperRef}
className="relative flex min-h-full flex-col"
onMouseDown={handleContainerMouseDown}
>
<EditorContent className="flex-1 min-h-full" editor={editor} />
{showBubbleMenu && (
<EditorBubbleMenu editor={editor} currentIssueId={currentIssueId} />
)}
<LinkHoverCard {...hover} />
</div>
);
},
);

View File

@@ -1,38 +1,15 @@
"use client";
import { useEffect, useState } from "react";
import { useState } from "react";
import { NodeViewWrapper, NodeViewContent } from "@tiptap/react";
import type { NodeViewProps } from "@tiptap/react";
import { Copy, Check } from "lucide-react";
import { useT } from "../../i18n";
import { MermaidDiagram } from "../mermaid-diagram";
// Coalesces fast keystrokes before re-rendering the Mermaid preview.
// `mermaid.initialize()` mutates a process-global config, so back-to-back
// renders during typing can race a concurrent ReadonlyContent render
// (e.g. a comment card) and clobber its theme variables. 200ms keeps the
// "live preview" feel while making concurrent inits unlikely in practice.
const MERMAID_PREVIEW_DEBOUNCE_MS = 200;
function useDebouncedValue<T>(value: T, delayMs: number): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const id = setTimeout(() => setDebounced(value), delayMs);
return () => clearTimeout(id);
}, [value, delayMs]);
return debounced;
}
function CodeBlockView({ node }: NodeViewProps) {
const { t } = useT("editor");
const [copied, setCopied] = useState(false);
const language = node.attrs.language || "";
const isMermaid = language === "mermaid";
const chart = node.textContent;
const debouncedChart = useDebouncedValue(
isMermaid ? chart : "",
MERMAID_PREVIEW_DEBOUNCE_MS,
);
const handleCopy = async () => {
const text = node.textContent;
@@ -44,14 +21,6 @@ function CodeBlockView({ node }: NodeViewProps) {
return (
<NodeViewWrapper className="code-block-wrapper group/code relative my-2">
{isMermaid && debouncedChart.trim() && (
<div
contentEditable={false}
className="mermaid-diagram-preview mb-1"
>
<MermaidDiagram chart={debouncedChart} />
</div>
)}
<div
contentEditable={false}
className="code-block-header absolute top-0 right-0 z-10 flex items-center gap-1.5 px-2 py-1.5 opacity-0 transition-opacity group-hover/code:opacity-100"

View File

@@ -19,7 +19,6 @@ import { ReactNodeViewRenderer, NodeViewWrapper } from "@tiptap/react";
import type { NodeViewProps } from "@tiptap/react";
import { FileText, Loader2, Download } from "lucide-react";
import { useT } from "../../i18n";
import { useAttachmentDownloadResolver } from "../attachment-download-context";
// ---------------------------------------------------------------------------
@@ -35,10 +34,9 @@ function FileCardView({ node }: NodeViewProps) {
const href = (node.attrs.href as string) || "";
const filename = (node.attrs.filename as string) || "";
const uploading = node.attrs.uploading as boolean;
const { openByUrl } = useAttachmentDownloadResolver();
const openFile = () => {
openByUrl(href);
window.open(href, "_blank", "noopener,noreferrer");
};
return (

View File

@@ -13,7 +13,6 @@ import {
import { toast } from "sonner";
import { cn } from "@multica/ui/lib/utils";
import { useT } from "../../i18n";
import { useAttachmentDownloadResolver } from "../attachment-download-context";
// ---------------------------------------------------------------------------
// Lightbox — full-screen image preview (ESC or click backdrop to close)
@@ -62,7 +61,6 @@ function ImageView({ node, editor, selected, deleteNode }: NodeViewProps) {
const alt = (node.attrs.alt as string) || "";
const title = node.attrs.title as string | undefined;
const uploading = node.attrs.uploading as boolean;
const { openByUrl } = useAttachmentDownloadResolver();
const [lightbox, setLightbox] = useState(false);
const isEditable = editor.isEditable;
@@ -72,9 +70,8 @@ function ImageView({ node, editor, selected, deleteNode }: NodeViewProps) {
const handleDownload = () => {
// Cross-origin CDN images can't be fetched as blob (CORS),
// and <a download> is ignored for cross-origin URLs.
// Re-sign through the provider when the src maps to a known
// attachment; otherwise just open externally.
openByUrl(src);
// Open in new tab — user can right-click → Save As.
window.open(src, "_blank", "noopener,noreferrer");
};
const handleCopyLink = async () => {

View File

@@ -1,4 +1,4 @@
import { describe, it, expect, afterEach, vi } from "vitest";
import { describe, it, expect, afterEach } from "vitest";
import { Editor } from "@tiptap/core";
import StarterKit from "@tiptap/starter-kit";
import { Markdown } from "@tiptap/markdown";
@@ -60,23 +60,10 @@ function nodeText(node: JsonNode): string {
return (node.content ?? []).map(nodeText).join("");
}
function expectLiteralPaste(editor: Editor, text: string) {
editor.commands.setTextSelection(1);
const parseSpy = vi.spyOn(editor.markdown!, "parse");
const handled = paste(editor, text);
expect(handled).toBe(true);
expect(parseSpy).not.toHaveBeenCalled();
expect(editor.getText()).toBe(text);
expect(editor.getMarkdown()).toBe(text);
}
describe("markdownPaste — code block context", () => {
let editor: Editor | null = null;
afterEach(() => {
vi.restoreAllMocks();
editor?.destroy();
editor = null;
document.body.innerHTML = "";
@@ -140,55 +127,4 @@ describe("markdownPaste — code block context", () => {
// Markdown parsing produced a heading at the top.
expect(types).toContain("heading");
});
it("inserts JSON clipboard text without running the Markdown parser", () => {
editor = makeEditor({
type: "doc",
content: [{ type: "paragraph" }],
});
const json = JSON.stringify(
{
type: "issue.comment",
payload: {
title: "Paste JSON into a reply",
nested: { ok: true, count: 3 },
items: ["alpha", "beta", "gamma"],
},
},
null,
2,
);
expectLiteralPaste(editor, json);
});
it("inserts very large plain text without running the Markdown parser", () => {
editor = makeEditor({
type: "doc",
content: [{ type: "paragraph" }],
});
const text = Array.from(
{ length: 1600 },
(_, index) => `log ${index}: ${"payload".repeat(6)}`,
).join("\n");
expect(text.length).toBeGreaterThan(50_000);
expectLiteralPaste(editor, text);
});
it("does not parse oversized bracketed plain text as JSON", () => {
editor = makeEditor({
type: "doc",
content: [{ type: "paragraph" }],
});
const parseJsonSpy = vi.spyOn(JSON, "parse");
const text = `{${"not-json".repeat(7_000)}}`;
expect(text.length).toBeGreaterThan(50_000);
expectLiteralPaste(editor, text);
expect(parseJsonSpy).not.toHaveBeenCalled();
});
});

View File

@@ -12,71 +12,20 @@
* `data-pm-slice` in the HTML — this attribute is added by ProseMirror's
* own clipboard serializer. If present, the source is another ProseMirror
* editor and its HTML is structurally correct — let ProseMirror handle it.
* Otherwise, classify text/plain into one of three paths:
* - native: let ProseMirror or another extension handle it
* - literal: insert exact text without Markdown parsing
* - markdown: parse text/plain as Markdown
* Otherwise, ignore the HTML and parse text/plain as Markdown.
*
* Why not clipboardTextParser? It only runs when there's NO text/html on
* the clipboard (ProseMirror source: `let asText = !!text && !html`).
*
* Why not heuristic detection (looksLikeMarkdown / hasRichHtml)? Unreliable.
* VS Code's HTML contains <code> tags that fool rich-content detectors.
* Markdown pattern matching has too many edge cases. Instead, the classifier
* only keeps narrow deterministic exits for editor-owned slices, code block
* context, structured plain text, and large payloads.
* Markdown pattern matching has too many edge cases. The data-pm-slice
* check is deterministic — no false positives.
*/
import { Extension } from "@tiptap/core";
import { Plugin, PluginKey } from "@tiptap/pm/state";
import { Slice } from "@tiptap/pm/model";
const LARGE_PASTE_TEXT_THRESHOLD = 50_000;
type PasteMode = "native" | "literal" | "markdown";
interface PasteClassificationInput {
text: string;
html: string;
hasFiles: boolean;
isInsideCodeBlock: boolean;
}
function isJsonDocumentText(text: string): boolean {
const trimmed = text.trim();
if (!trimmed) return false;
const startsLikeJson =
(trimmed.startsWith("{") && trimmed.endsWith("}")) ||
(trimmed.startsWith("[") && trimmed.endsWith("]"));
if (!startsLikeJson) return false;
try {
JSON.parse(trimmed);
return true;
} catch {
return false;
}
}
function isStructuredPlainText(text: string): boolean {
return isJsonDocumentText(text);
}
function classifyPaste({
text,
html,
hasFiles,
isInsideCodeBlock,
}: PasteClassificationInput): PasteMode {
if (hasFiles) return "native";
if (!text) return "native";
if (isInsideCodeBlock) return "literal";
if (html && html.includes("data-pm-slice")) return "native";
if (text.length > LARGE_PASTE_TEXT_THRESHOLD) return "literal";
if (isStructuredPlainText(text)) return "literal";
return "markdown";
}
export function createMarkdownPasteExtension() {
return Extension.create({
name: "markdownPaste",
@@ -91,23 +40,29 @@ export function createMarkdownPasteExtension() {
const clipboard = event.clipboardData;
if (!clipboard) return false;
// If clipboard has files, defer to the fileUpload extension.
if (clipboard.files?.length) return false;
const text = clipboard.getData("text/plain");
const html = clipboard.getData("text/html");
if (!text) return false;
// If the caret is inside a code block, insert the text as-is.
// Code blocks must keep newlines literal; running Markdown
// parsing here would split a blank line (\n\n) into two
// paragraphs and tear the code block open. (#1982)
const { $from } = view.state.selection;
const mode = classifyPaste({
text,
html,
hasFiles: Boolean(clipboard.files?.length),
isInsideCodeBlock: $from.parent.type.name === "codeBlock",
});
if (mode === "native") return false;
if (mode === "literal") {
if ($from.parent.type.name === "codeBlock") {
view.dispatch(view.state.tr.insertText(text));
return true;
}
const html = clipboard.getData("text/html");
// If HTML contains data-pm-slice, the source is another
// ProseMirror editor — let ProseMirror use its native HTML
// clipboard path to preserve exact node structure.
if (html && html.includes("data-pm-slice")) return false;
// Everything else (VS Code, text editors, .md files, terminals,
// web pages): parse text/plain as Markdown.
const json = editor.markdown.parse(text);

View File

@@ -18,7 +18,6 @@ import { workspaceKeys } from "@multica/core/workspace/queries";
import { useAuthStore } from "@multica/core/auth";
import { canAssignAgentToIssue } from "@multica/core/permissions";
import { api } from "@multica/core/api";
import { isImeComposing } from "@multica/core/utils";
import type {
Issue,
ListIssuesCache,
@@ -205,9 +204,6 @@ export const MentionList = forwardRef<MentionListRef, MentionListProps>(
useImperativeHandle(ref, () => ({
onKeyDown: ({ event }) => {
// IME is composing — don't intercept Enter/Arrow as picker actions;
// those keys belong to the IME (Enter commits composition, etc).
if (isImeComposing(event)) return false;
if (event.key === "ArrowUp") {
if (displayItems.length === 0) return true;
setSelectedIndex(

View File

@@ -1,90 +0,0 @@
import { describe, it, expect, vi } from "vitest";
import { getExtensionField } from "@tiptap/core";
import type { Editor } from "@tiptap/core";
import { createSubmitExtension } from "./submit-shortcut";
function getShortcuts(
ext: ReturnType<typeof createSubmitExtension>,
editor: Partial<Editor>,
): Record<string, () => boolean> {
const fn = getExtensionField<
() => Record<string, () => boolean>
>(ext, "addKeyboardShortcuts", {
name: "submitShortcut",
options: {},
storage: {},
editor: editor as Editor,
type: null,
});
return fn?.() ?? {};
}
describe("createSubmitExtension", () => {
const baseEditor = {
view: { composing: false } as unknown as Editor["view"],
isActive: () => false,
} as Partial<Editor>;
it("Mod-Enter always submits", () => {
const onSubmit = vi.fn(() => true);
const shortcuts = getShortcuts(
createSubmitExtension(onSubmit, { submitOnEnter: false }),
baseEditor,
);
expect(shortcuts["Mod-Enter"]).toBeDefined();
shortcuts["Mod-Enter"]!();
expect(onSubmit).toHaveBeenCalledTimes(1);
});
it("bare Enter is not bound when submitOnEnter is false", () => {
const onSubmit = vi.fn(() => true);
const shortcuts = getShortcuts(
createSubmitExtension(onSubmit, { submitOnEnter: false }),
baseEditor,
);
expect(shortcuts.Enter).toBeUndefined();
expect(onSubmit).not.toHaveBeenCalled();
});
it("bare Enter submits when submitOnEnter is true", () => {
const onSubmit = vi.fn(() => true);
const shortcuts = getShortcuts(
createSubmitExtension(onSubmit, { submitOnEnter: true }),
baseEditor,
);
expect(shortcuts.Enter).toBeDefined();
expect(shortcuts.Enter!()).toBe(true);
expect(onSubmit).toHaveBeenCalledTimes(1);
});
it("Enter is suppressed during IME composition", () => {
const onSubmit = vi.fn(() => true);
const shortcuts = getShortcuts(
createSubmitExtension(onSubmit, { submitOnEnter: true }),
{
view: { composing: true } as unknown as Editor["view"],
isActive: () => false,
},
);
expect(shortcuts.Enter!()).toBe(false);
expect(onSubmit).not.toHaveBeenCalled();
});
it("Enter is suppressed inside a code block", () => {
const onSubmit = vi.fn(() => true);
const shortcuts = getShortcuts(
createSubmitExtension(onSubmit, { submitOnEnter: true }),
{
view: { composing: false } as unknown as Editor["view"],
isActive: (name: string) => name === "codeBlock",
},
);
expect(shortcuts.Enter!()).toBe(false);
expect(onSubmit).not.toHaveBeenCalled();
});
});

View File

@@ -12,5 +12,3 @@ export { copyMarkdown } from "./utils/clipboard";
export { ReadonlyContent } from "./readonly-content";
export { useFileDropZone } from "./use-file-drop-zone";
export { FileDropOverlay } from "./file-drop-overlay";
export { useDownloadAttachment } from "./use-download-attachment";
export { AttachmentDownloadProvider } from "./attachment-download-context";

View File

@@ -1,294 +0,0 @@
"use client";
/**
* MermaidDiagram — sandboxed Mermaid diagram renderer.
*
* Extracted from `readonly-content.tsx` so the Tiptap CodeBlock NodeView
* (`code-block-view.tsx`) can render the same component when a code block's
* language is `mermaid`. Previously Mermaid only worked in read-only
* markdown surfaces (comment cards) — issue descriptions, which always
* stay in the Tiptap editor, never rendered diagrams.
*
* Theme variables are detected from the host's CSS custom properties so the
* diagram colors match light/dark mode. The SVG is rendered inside a
* sandboxed iframe to keep Mermaid's runtime stylesheet from leaking into
* the page.
*/
import { useEffect, useId, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { Maximize2 } from "lucide-react";
import { useT } from "../i18n";
type MermaidAPI = typeof import("mermaid").default;
type MermaidLayout = {
width?: number;
height?: number;
};
let mermaidPromise: Promise<MermaidAPI> | null = null;
function getMermaid(): Promise<MermaidAPI> {
mermaidPromise ??= import("mermaid").then(({ default: mermaid }) => mermaid);
return mermaidPromise;
}
function toLegacyColor(color: string, fallback: string, ownerDocument: Document): string {
const canvas = ownerDocument.createElement("canvas");
canvas.width = 1;
canvas.height = 1;
const context = canvas.getContext("2d", { willReadFrequently: true });
if (!context) return fallback;
// Mermaid's color parser only supports legacy color syntax. Canvas can parse
// modern CSS Color 4 values such as oklch(), then getImageData gives concrete
// 8-bit sRGB bytes that Mermaid can consume safely.
context.fillStyle = "#000";
context.fillStyle = color || fallback;
context.fillRect(0, 0, 1, 1);
const [red, green, blue] = context.getImageData(0, 0, 1, 1).data;
return `rgb(${red}, ${green}, ${blue})`;
}
function resolveCssColor(
host: HTMLElement,
variableName: string,
fallback: string,
): string {
const probe = host.ownerDocument.createElement("span");
probe.style.color = `var(${variableName})`;
probe.style.display = "none";
host.appendChild(probe);
const color = getComputedStyle(probe).color;
probe.remove();
return toLegacyColor(color || fallback, fallback, host.ownerDocument);
}
function getMermaidThemeVariables(host: HTMLElement | null) {
if (!host) {
return {
primaryColor: "rgb(245, 245, 245)",
primaryBorderColor: "rgb(59, 130, 246)",
primaryTextColor: "rgb(17, 24, 39)",
lineColor: "rgb(107, 114, 128)",
fontFamily: "inherit",
};
}
return {
primaryColor: resolveCssColor(host, "--muted", "rgb(245, 245, 245)"),
primaryBorderColor: resolveCssColor(host, "--primary", "rgb(59, 130, 246)"),
primaryTextColor: resolveCssColor(host, "--foreground", "rgb(17, 24, 39)"),
lineColor: resolveCssColor(host, "--muted-foreground", "rgb(107, 114, 128)"),
fontFamily: "inherit",
};
}
function getSandboxCssVariables(host: HTMLElement | null): string {
const styles = host ? getComputedStyle(host) : null;
return ["--muted", "--primary", "--foreground", "--muted-foreground"]
.map((name) => `${name}: ${styles?.getPropertyValue(name).trim() || "initial"};`)
.join(" ");
}
function getMermaidLayout(svg: string): MermaidLayout {
const viewBoxMatch = svg.match(
/viewBox=["']\s*([\d.-]+)\s+([\d.-]+)\s+([\d.-]+)\s+([\d.-]+)\s*["']/i,
);
const [, , , widthValue, heightValue] = viewBoxMatch ?? [];
const width = widthValue ? Number.parseFloat(widthValue) : undefined;
const height = heightValue ? Number.parseFloat(heightValue) : undefined;
if (width && height && width > 0 && height > 0) {
return {
width: Math.ceil(width),
height: Math.ceil(height),
};
}
return {};
}
function buildSandboxedMermaidDocument(svg: string, host: HTMLElement | null): string {
const cssVariables = getSandboxCssVariables(host);
return `<!doctype html><html><head><style>:root { ${cssVariables} } body { margin: 0; display: flex; justify-content: center; background: transparent; } svg { max-width: 100%; height: auto; }</style></head><body>${svg}</body></html>`;
}
function buildExpandedMermaidDocument(svg: string, host: HTMLElement | null): string {
const cssVariables = getSandboxCssVariables(host);
return `<!doctype html><html><head><style>:root { ${cssVariables} } html, body { width: 100%; height: 100%; } body { margin: 0; display: flex; align-items: center; justify-content: center; background: transparent; } svg { max-width: 100%; max-height: 100%; width: auto; height: auto; }</style></head><body>${svg}</body></html>`;
}
function useThemeVersion() {
const [themeVersion, setThemeVersion] = useState(0);
useEffect(() => {
const bumpThemeVersion = () => setThemeVersion((version) => version + 1);
const observer = new MutationObserver(bumpThemeVersion);
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class", "style", "data-theme"],
});
if (document.body) {
observer.observe(document.body, {
attributes: true,
attributeFilter: ["class", "style", "data-theme"],
});
}
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
mediaQuery.addEventListener("change", bumpThemeVersion);
return () => {
observer.disconnect();
mediaQuery.removeEventListener("change", bumpThemeVersion);
};
}, []);
return themeVersion;
}
function MermaidLightbox({
srcDoc,
onClose,
}: {
srcDoc: string;
onClose: () => void;
}) {
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
document.addEventListener("keydown", handler);
return () => document.removeEventListener("keydown", handler);
}, [onClose]);
return createPortal(
<div
className="mermaid-diagram-lightbox"
role="dialog"
aria-modal="true"
aria-label="Mermaid diagram fullscreen view"
onClick={onClose}
>
<iframe
className="mermaid-diagram-lightbox-frame"
sandbox=""
srcDoc={srcDoc}
title="Mermaid diagram fullscreen"
onClick={(e) => e.stopPropagation()}
/>
</div>,
document.body,
);
}
export function MermaidDiagram({ chart }: { chart: string }) {
const { t } = useT("editor");
const reactId = useId();
const containerRef = useRef<HTMLDivElement>(null);
const diagramId = useMemo(
() => `mermaid-${reactId.replace(/[^a-zA-Z0-9_-]/g, "")}`,
[reactId],
);
const themeVersion = useThemeVersion();
const [sandboxedDocument, setSandboxedDocument] = useState<string | null>(null);
const [expandedDocument, setExpandedDocument] = useState<string | null>(null);
const [layout, setLayout] = useState<MermaidLayout>({});
const [error, setError] = useState<string | null>(null);
const [lightboxOpen, setLightboxOpen] = useState(false);
useEffect(() => {
let cancelled = false;
async function renderDiagram() {
try {
setError(null);
setSandboxedDocument(null);
setExpandedDocument(null);
setLayout({});
const mermaid = await getMermaid();
mermaid.initialize({
startOnLoad: false,
securityLevel: "strict",
theme: "base",
themeVariables: getMermaidThemeVariables(containerRef.current),
});
const { svg: renderedSvg } = await mermaid.render(diagramId, chart);
if (!cancelled) {
setLayout(getMermaidLayout(renderedSvg));
setSandboxedDocument(
buildSandboxedMermaidDocument(renderedSvg, containerRef.current),
);
setExpandedDocument(
buildExpandedMermaidDocument(renderedSvg, containerRef.current),
);
}
} catch (err) {
if (!cancelled) {
setError(err instanceof Error ? err.message : "Failed to render Mermaid diagram");
}
}
}
void renderDiagram();
return () => {
cancelled = true;
};
}, [chart, diagramId, themeVersion]);
if (error) {
return (
<div ref={containerRef} className="mermaid-diagram mermaid-diagram-error">
<p>{t(($) => $.mermaid.render_error)}</p>
<pre>
<code>{chart}</code>
</pre>
</div>
);
}
return (
<div ref={containerRef} className="mermaid-diagram" aria-label="Mermaid diagram">
{sandboxedDocument ? (
<>
<iframe
className="mermaid-diagram-frame"
sandbox=""
srcDoc={sandboxedDocument}
style={{
height: layout.height ? `${layout.height}px` : undefined,
width: layout.width ? `${layout.width}px` : undefined,
}}
title="Mermaid diagram"
/>
<div className="mermaid-diagram-toolbar">
<button
type="button"
onClick={() => setLightboxOpen(true)}
title="Open fullscreen"
aria-label="Open Mermaid diagram fullscreen"
>
<Maximize2 className="size-3.5" />
</button>
</div>
{lightboxOpen && expandedDocument && (
<MermaidLightbox
srcDoc={expandedDocument}
onClose={() => setLightboxOpen(false)}
/>
)}
</>
) : (
<div className="mermaid-diagram-loading">{t(($) => $.mermaid.rendering)}</div>
)}
</div>
);
}

View File

@@ -16,7 +16,8 @@
* - Rendering mentions with the same IssueMentionCard component and .mention class
*/
import { isValidElement, memo, useCallback, useMemo, useRef, useState } from "react";
import { isValidElement, memo, useEffect, useId, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import ReactMarkdown, {
defaultUrlTransform,
type Components,
@@ -34,17 +35,13 @@ import { Maximize2, Download, Link as LinkIcon, FileText } from "lucide-react";
import { toast } from "sonner";
import { cn } from "@multica/ui/lib/utils";
import { useWorkspacePaths, useWorkspaceSlug } from "@multica/core/paths";
import type { Attachment } from "@multica/core/types";
import { useNavigation } from "../navigation";
import { useT } from "../i18n";
import { openExternal } from "../platform";
import { IssueMentionCard } from "../issues/components/issue-mention-card";
import { ImageLightbox } from "./extensions/image-view";
import { useLinkHover, LinkHoverCard } from "./link-hover-card";
import { openLink, isMentionHref } from "./utils/link-handler";
import { preprocessMarkdown } from "./utils/preprocess";
import { MermaidDiagram } from "./mermaid-diagram";
import { useDownloadAttachment } from "./use-download-attachment";
import "katex/dist/katex.min.css";
import "./content-editor.css";
@@ -54,6 +51,140 @@ import "./content-editor.css";
const lowlight = createLowlight(common);
type MermaidAPI = typeof import("mermaid").default;
type MermaidLayout = {
width?: number;
height?: number;
};
let mermaidPromise: Promise<MermaidAPI> | null = null;
function getMermaid(): Promise<MermaidAPI> {
mermaidPromise ??= import("mermaid").then(({ default: mermaid }) => mermaid);
return mermaidPromise;
}
function toLegacyColor(color: string, fallback: string, ownerDocument: Document): string {
const canvas = ownerDocument.createElement("canvas");
canvas.width = 1;
canvas.height = 1;
const context = canvas.getContext("2d", { willReadFrequently: true });
if (!context) return fallback;
// Mermaid's color parser only supports legacy color syntax. Canvas can parse
// modern CSS Color 4 values such as oklch(), then getImageData gives concrete
// 8-bit sRGB bytes that Mermaid can consume safely.
context.fillStyle = "#000";
context.fillStyle = color || fallback;
context.fillRect(0, 0, 1, 1);
const [red, green, blue] = context.getImageData(0, 0, 1, 1).data;
return `rgb(${red}, ${green}, ${blue})`;
}
function resolveCssColor(
host: HTMLElement,
variableName: string,
fallback: string,
): string {
const probe = host.ownerDocument.createElement("span");
probe.style.color = `var(${variableName})`;
probe.style.display = "none";
host.appendChild(probe);
const color = getComputedStyle(probe).color;
probe.remove();
return toLegacyColor(color || fallback, fallback, host.ownerDocument);
}
function getMermaidThemeVariables(host: HTMLElement | null) {
if (!host) {
return {
primaryColor: "rgb(245, 245, 245)",
primaryBorderColor: "rgb(59, 130, 246)",
primaryTextColor: "rgb(17, 24, 39)",
lineColor: "rgb(107, 114, 128)",
fontFamily: "inherit",
};
}
return {
primaryColor: resolveCssColor(host, "--muted", "rgb(245, 245, 245)"),
primaryBorderColor: resolveCssColor(host, "--primary", "rgb(59, 130, 246)"),
primaryTextColor: resolveCssColor(host, "--foreground", "rgb(17, 24, 39)"),
lineColor: resolveCssColor(host, "--muted-foreground", "rgb(107, 114, 128)"),
fontFamily: "inherit",
};
}
function getSandboxCssVariables(host: HTMLElement | null): string {
const styles = host ? getComputedStyle(host) : null;
return ["--muted", "--primary", "--foreground", "--muted-foreground"]
.map((name) => `${name}: ${styles?.getPropertyValue(name).trim() || "initial"};`)
.join(" ");
}
function getMermaidLayout(svg: string): MermaidLayout {
const viewBoxMatch = svg.match(
/viewBox=["']\s*([\d.-]+)\s+([\d.-]+)\s+([\d.-]+)\s+([\d.-]+)\s*["']/i,
);
const [, , , widthValue, heightValue] = viewBoxMatch ?? [];
const width = widthValue ? Number.parseFloat(widthValue) : undefined;
const height = heightValue ? Number.parseFloat(heightValue) : undefined;
if (width && height && width > 0 && height > 0) {
return {
width: Math.ceil(width),
height: Math.ceil(height),
};
}
return {};
}
function buildSandboxedMermaidDocument(svg: string, host: HTMLElement | null): string {
const cssVariables = getSandboxCssVariables(host);
return `<!doctype html><html><head><style>:root { ${cssVariables} } body { margin: 0; display: flex; justify-content: center; background: transparent; } svg { max-width: 100%; height: auto; }</style></head><body>${svg}</body></html>`;
}
function buildExpandedMermaidDocument(svg: string, host: HTMLElement | null): string {
const cssVariables = getSandboxCssVariables(host);
return `<!doctype html><html><head><style>:root { ${cssVariables} } html, body { width: 100%; height: 100%; } body { margin: 0; display: flex; align-items: center; justify-content: center; background: transparent; } svg { max-width: 100%; max-height: 100%; width: auto; height: auto; }</style></head><body>${svg}</body></html>`;
}
function useThemeVersion() {
const [themeVersion, setThemeVersion] = useState(0);
useEffect(() => {
const bumpThemeVersion = () => setThemeVersion((version) => version + 1);
const observer = new MutationObserver(bumpThemeVersion);
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class", "style", "data-theme"],
});
if (document.body) {
observer.observe(document.body, {
attributes: true,
attributeFilter: ["class", "style", "data-theme"],
});
}
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
mediaQuery.addEventListener("change", bumpThemeVersion);
return () => {
observer.disconnect();
mediaQuery.removeEventListener("change", bumpThemeVersion);
};
}, []);
return themeVersion;
}
// ---------------------------------------------------------------------------
// Sanitization schema — extends GitHub defaults to allow file-card data attrs
// ---------------------------------------------------------------------------
@@ -163,209 +294,278 @@ function ReadonlyLink({
);
}
// Image renderer with a download button that prefers fresh-signed URLs.
// Lifted out of the components map so it can call hooks; receives the
// attachment lookup as props so the components map can stay a pure
// data-build inside `ReadonlyContent`'s `useMemo`.
function ReadonlyImage({
src,
alt,
resolveAttachmentId,
onDownload,
function MermaidLightbox({
srcDoc,
onClose,
}: {
src?: string;
alt?: string;
resolveAttachmentId: (url: string) => string | undefined;
onDownload: (attachmentId: string) => void;
srcDoc: string;
onClose: () => void;
}) {
const { t } = useT("editor");
const [lightbox, setLightbox] = useState(false);
const imgSrc = typeof src === "string" ? src : "";
const imgAlt = alt ?? "";
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
document.addEventListener("keydown", handler);
return () => document.removeEventListener("keydown", handler);
}, [onClose]);
const handleView = () => setLightbox(true);
const handleDownload = () => {
const id = imgSrc ? resolveAttachmentId(imgSrc) : undefined;
if (id) {
onDownload(id);
return;
}
// External image — no attachment record to re-sign through. Falling back
// to `openExternal` keeps us off `window.open(...)` (which Electron's
// setWindowOpenHandler would route through openExternalSafely anyway,
// but only after rejecting non-http schemes loudly).
if (imgSrc) openExternal(imgSrc);
};
const handleCopyLink = async () => {
try {
await navigator.clipboard.writeText(imgSrc);
toast.success(t(($) => $.image.link_copied));
} catch {
toast.error(t(($) => $.image.copy_link_failed));
}
};
return (
<span className="image-node">
<span className="image-figure" onClick={handleView}>
<img src={imgSrc} alt={imgAlt} className="image-content" draggable={false} />
<span
className="image-toolbar"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
>
<button type="button" onClick={handleView} title={t(($) => $.image.view)}>
<Maximize2 className="size-3.5" />
</button>
<button type="button" onClick={handleDownload} title={t(($) => $.image.download)}>
<Download className="size-3.5" />
</button>
<button type="button" onClick={handleCopyLink} title={t(($) => $.image.copy_link)}>
<LinkIcon className="size-3.5" />
</button>
</span>
</span>
{lightbox && (
<ImageLightbox src={imgSrc} alt={imgAlt} onClose={() => setLightbox(false)} />
)}
</span>
return createPortal(
<div
className="mermaid-diagram-lightbox"
role="dialog"
aria-modal="true"
aria-label="Mermaid diagram fullscreen view"
onClick={onClose}
>
<iframe
className="mermaid-diagram-lightbox-frame"
sandbox=""
srcDoc={srcDoc}
title="Mermaid diagram fullscreen"
onClick={(e) => e.stopPropagation()}
/>
</div>,
document.body,
);
}
// Inline file card — same download semantics as the standalone attachment
// list: fresh-sign through `useDownloadAttachment` when the href matches a
// known attachment, otherwise hand the raw URL to the platform's external
// opener.
function ReadonlyFileCard({
href,
filename,
resolveAttachmentId,
onDownload,
}: {
href: string;
filename: string;
resolveAttachmentId: (url: string) => string | undefined;
onDownload: (attachmentId: string) => void;
}) {
const handleClick = () => {
const id = resolveAttachmentId(href);
if (id) {
onDownload(id);
return;
function MermaidDiagram({ chart }: { chart: string }) {
const { t } = useT("editor");
const reactId = useId();
const containerRef = useRef<HTMLDivElement>(null);
const diagramId = useMemo(
() => `mermaid-${reactId.replace(/[^a-zA-Z0-9_-]/g, "")}`,
[reactId],
);
const themeVersion = useThemeVersion();
const [sandboxedDocument, setSandboxedDocument] = useState<string | null>(null);
const [expandedDocument, setExpandedDocument] = useState<string | null>(null);
const [layout, setLayout] = useState<MermaidLayout>({});
const [error, setError] = useState<string | null>(null);
const [lightboxOpen, setLightboxOpen] = useState(false);
useEffect(() => {
let cancelled = false;
async function renderDiagram() {
try {
setError(null);
setSandboxedDocument(null);
setExpandedDocument(null);
setLayout({});
const mermaid = await getMermaid();
mermaid.initialize({
startOnLoad: false,
securityLevel: "strict",
theme: "base",
themeVariables: getMermaidThemeVariables(containerRef.current),
});
const { svg: renderedSvg } = await mermaid.render(diagramId, chart);
if (!cancelled) {
setLayout(getMermaidLayout(renderedSvg));
setSandboxedDocument(
buildSandboxedMermaidDocument(renderedSvg, containerRef.current),
);
setExpandedDocument(
buildExpandedMermaidDocument(renderedSvg, containerRef.current),
);
}
} catch (err) {
if (!cancelled) {
setError(err instanceof Error ? err.message : "Failed to render Mermaid diagram");
}
}
}
openExternal(href);
};
return (
<div className="my-1 flex items-center gap-2 rounded-md border border-border bg-muted/50 px-2.5 py-1 transition-colors hover:bg-muted">
<FileText className="size-4 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1">
<p className="truncate text-sm">{filename}</p>
void renderDiagram();
return () => {
cancelled = true;
};
}, [chart, diagramId, themeVersion]);
if (error) {
return (
<div ref={containerRef} className="mermaid-diagram mermaid-diagram-error">
<p>{t(($) => $.mermaid.render_error)}</p>
<pre>
<code>{chart}</code>
</pre>
</div>
{href && (
<button
type="button"
className="shrink-0 rounded-md p-1 text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
onClick={handleClick}
>
<Download className="size-3.5" />
</button>
);
}
return (
<div ref={containerRef} className="mermaid-diagram" aria-label="Mermaid diagram">
{sandboxedDocument ? (
<>
<iframe
className="mermaid-diagram-frame"
sandbox=""
srcDoc={sandboxedDocument}
style={{
height: layout.height ? `${layout.height}px` : undefined,
width: layout.width ? `${layout.width}px` : undefined,
}}
title="Mermaid diagram"
/>
<div className="mermaid-diagram-toolbar">
<button
type="button"
onClick={() => setLightboxOpen(true)}
title="Open fullscreen"
aria-label="Open Mermaid diagram fullscreen"
>
<Maximize2 className="size-3.5" />
</button>
</div>
{lightboxOpen && expandedDocument && (
<MermaidLightbox
srcDoc={expandedDocument}
onClose={() => setLightboxOpen(false)}
/>
)}
</>
) : (
<div className="mermaid-diagram-loading">{t(($) => $.mermaid.rendering)}</div>
)}
</div>
);
}
function buildComponents(
resolveAttachmentId: (url: string) => string | undefined,
onDownload: (attachmentId: string) => void,
): Partial<Components> {
return {
// Links — route mention:// to mention components, others show preview card
a: ReadonlyLink,
const components: Partial<Components> = {
// Links — route mention:// to mention components, others show preview card
a: ReadonlyLink,
// Images — centered with toolbar + lightbox (matches Tiptap ImageView NodeView)
img: ({ src, alt }) => (
<ReadonlyImage
src={typeof src === "string" ? src : undefined}
alt={alt ?? undefined}
resolveAttachmentId={resolveAttachmentId}
onDownload={onDownload}
/>
),
// Images — centered with toolbar + lightbox (matches Tiptap ImageView NodeView)
img: function ReadonlyImage({ src, alt }) {
const { t } = useT("editor");
const [lightbox, setLightbox] = useState(false);
const imgSrc = typeof src === "string" ? src : "";
const imgAlt = alt ?? "";
// FileCard — intercept <div data-type="fileCard"> from preprocessMarkdown
div: ({ node, children, ...props }) => {
const dataType = node?.properties?.dataType as string | undefined;
if (dataType === "fileCard") {
const rawHref = (node?.properties?.dataHref as string) || "";
// Only allow http(s) URLs to prevent javascript: and other dangerous schemes.
const href = /^https?:\/\//i.test(rawHref) ? rawHref : "";
const filename = (node?.properties?.dataFilename as string) || "";
return (
<ReadonlyFileCard
href={href}
filename={filename}
resolveAttachmentId={resolveAttachmentId}
onDownload={onDownload}
/>
);
}
return <div {...props}>{children}</div>;
},
// Tables — wrap in tableWrapper div for border/radius/scroll (matches Tiptap)
table: ({ children }) => (
<div className="tableWrapper">
<table>{children}</table>
</div>
),
// Code — lowlight highlighting for blocks, plain render for inline
code: ({ className, children, node, ...props }) => {
const lang = /language-(\w+)/.exec(className || "")?.[1];
const isBlock =
node?.position &&
node.position.start.line !== node.position.end.line;
if (isBlock && lang === "mermaid") {
return <MermaidDiagram chart={String(children).replace(/\n$/, "")} />;
}
if (!isBlock && !lang) {
// Inline code — CSS handles styling via .rich-text-editor code
return <code {...props}>{children}</code>;
}
// Block code — highlight with lowlight, output hljs classes
const code = String(children).replace(/\n$/, "");
const handleView = () => setLightbox(true);
const handleDownload = () => {
window.open(imgSrc, "_blank", "noopener,noreferrer");
};
const handleCopyLink = async () => {
try {
const tree = lang
? lowlight.highlight(lang, code)
: lowlight.highlightAuto(code);
return (
<code
className={cn("hljs", lang && `language-${lang}`)}
dangerouslySetInnerHTML={{ __html: toHtml(tree) }}
/>
);
await navigator.clipboard.writeText(imgSrc);
toast.success(t(($) => $.image.link_copied));
} catch {
// Fallback — render without highlighting
return (
<code className={className} {...props}>
{children}
</code>
);
toast.error(t(($) => $.image.copy_link_failed));
}
},
};
// Pre — pass through (CSS handles styling via .rich-text-editor pre)
pre: ({ children }) => {
if (isValidElement(children) && children.type === MermaidDiagram) {
return <>{children}</>;
}
return <pre>{children}</pre>;
},
};
}
return (
<span className="image-node">
<span className="image-figure" onClick={handleView}>
<img src={imgSrc} alt={imgAlt} className="image-content" draggable={false} />
<span
className="image-toolbar"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
>
<button type="button" onClick={handleView} title={t(($) => $.image.view)}>
<Maximize2 className="size-3.5" />
</button>
<button type="button" onClick={handleDownload} title={t(($) => $.image.download)}>
<Download className="size-3.5" />
</button>
<button type="button" onClick={handleCopyLink} title={t(($) => $.image.copy_link)}>
<LinkIcon className="size-3.5" />
</button>
</span>
</span>
{lightbox && (
<ImageLightbox src={imgSrc} alt={imgAlt} onClose={() => setLightbox(false)} />
)}
</span>
);
},
// FileCard — intercept <div data-type="fileCard"> from preprocessMarkdown
div: ({ node, children, ...props }) => {
const dataType = node?.properties?.dataType as string | undefined;
if (dataType === "fileCard") {
const rawHref = (node?.properties?.dataHref as string) || "";
// Only allow http(s) URLs to prevent javascript: and other dangerous schemes.
const href = /^https?:\/\//i.test(rawHref) ? rawHref : "";
const filename = (node?.properties?.dataFilename as string) || "";
return (
<div className="my-1 flex items-center gap-2 rounded-md border border-border bg-muted/50 px-2.5 py-1 transition-colors hover:bg-muted">
<FileText className="size-4 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1">
<p className="truncate text-sm">{filename}</p>
</div>
{href && (
<button
type="button"
className="shrink-0 rounded-md p-1 text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
onClick={() => window.open(href, "_blank", "noopener,noreferrer")}
>
<Download className="size-3.5" />
</button>
)}
</div>
);
}
return <div {...props}>{children}</div>;
},
// Tables — wrap in tableWrapper div for border/radius/scroll (matches Tiptap)
table: ({ children }) => (
<div className="tableWrapper">
<table>{children}</table>
</div>
),
// Code — lowlight highlighting for blocks, plain render for inline
code: ({ className, children, node, ...props }) => {
const lang = /language-(\w+)/.exec(className || "")?.[1];
const isBlock =
node?.position &&
node.position.start.line !== node.position.end.line;
if (isBlock && lang === "mermaid") {
return <MermaidDiagram chart={String(children).replace(/\n$/, "")} />;
}
if (!isBlock && !lang) {
// Inline code — CSS handles styling via .rich-text-editor code
return <code {...props}>{children}</code>;
}
// Block code — highlight with lowlight, output hljs classes
const code = String(children).replace(/\n$/, "");
try {
const tree = lang
? lowlight.highlight(lang, code)
: lowlight.highlightAuto(code);
return (
<code
className={cn("hljs", lang && `language-${lang}`)}
dangerouslySetInnerHTML={{ __html: toHtml(tree) }}
/>
);
} catch {
// Fallback — render without highlighting
return (
<code className={className} {...props}>
{children}
</code>
);
}
},
// Pre — pass through (CSS handles styling via .rich-text-editor pre)
pre: ({ children }) => {
if (isValidElement(children) && children.type === MermaidDiagram) {
return <>{children}</>;
}
return <pre>{children}</pre>;
},
};
// ---------------------------------------------------------------------------
// Component
@@ -374,46 +574,19 @@ function buildComponents(
interface ReadonlyContentProps {
content: string;
className?: string;
/**
* Attachments associated with the surrounding entity (comment / issue
* body). When the markdown contains an inline `<img>` or file card whose
* URL matches one of these attachments, the download button re-signs the
* URL at click time via `useDownloadAttachment` instead of opening the
* potentially stale link embedded in the markdown.
*
* Callers SHOULD pass a stable reference (e.g. the field on a memoized
* timeline entry); a fresh array on every parent render busts the memo.
*/
attachments?: Attachment[];
}
// Memoized so a long timeline of comments (Inbox + IssueDetail) does not
// re-run the full react-markdown + rehype-* + lowlight pipeline on every
// parent re-render. Props are `content`/`className`/`attachments`, all
// shallow-comparable; stability is the caller's responsibility for the
// array.
// parent re-render. Props are `content` and `className` (both strings), so
// React.memo's default shallow comparison is value-equality here.
export const ReadonlyContent = memo(function ReadonlyContent({
content,
className,
attachments,
}: ReadonlyContentProps) {
const processed = useMemo(() => preprocessMarkdown(content), [content]);
const wrapperRef = useRef<HTMLDivElement>(null);
const hover = useLinkHover(wrapperRef);
const download = useDownloadAttachment();
const resolveAttachmentId = useCallback(
(url: string): string | undefined => {
if (!url || !attachments?.length) return undefined;
return attachments.find((a) => a.url === url)?.id;
},
[attachments],
);
const components = useMemo(
() => buildComponents(resolveAttachmentId, download),
[resolveAttachmentId, download],
);
return (
<div ref={wrapperRef} className={cn("rich-text-editor readonly text-sm", className)}>

View File

@@ -1,108 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, renderHook, waitFor } from "@testing-library/react";
// Hoisted mock for the API singleton: vi.mock factories cannot reference
// outside-of-scope vars, but vi.hoisted runs before the import graph.
const getAttachmentMock = vi.hoisted(() => vi.fn());
vi.mock("@multica/core/api", () => ({
api: { getAttachment: getAttachmentMock },
}));
vi.mock("../platform", () => ({
openExternal: vi.fn(),
}));
vi.mock("sonner", () => ({
toast: { error: vi.fn(), success: vi.fn() },
}));
vi.mock("../i18n", () => ({
useT: () => ({ t: (sel: (s: { attachment: { download_failed: string } }) => string) => sel({ attachment: { download_failed: "Couldn't fetch a download link. Try again in a moment." } }) }),
}));
import { useDownloadAttachment } from "./use-download-attachment";
import { openExternal } from "../platform";
import { toast } from "sonner";
const SIGNED_URL =
"https://static.example.test/file.md?Policy=p&Signature=s&Key-Pair-Id=k";
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
// Scrub the desktop bridge between tests so suites don't leak state.
delete (window as unknown as { desktopAPI?: unknown }).desktopAPI;
});
describe("useDownloadAttachment (web)", () => {
it("opens a placeholder tab synchronously, then navigates it to the freshly signed URL", async () => {
getAttachmentMock.mockResolvedValueOnce({
id: "att-1",
url: "https://static.example.test/file.md",
download_url: SIGNED_URL,
filename: "file.md",
});
const placeholder = { opener: window, location: { href: "about:blank" }, close: vi.fn() };
const openSpy = vi.spyOn(window, "open").mockReturnValue(placeholder as unknown as Window);
const { result } = renderHook(() => useDownloadAttachment());
await act(async () => {
await result.current("att-1");
});
// Placeholder MUST be opened synchronously during the click — otherwise
// popup blockers won't honour the gesture.
expect(openSpy).toHaveBeenCalledWith("about:blank", "_blank");
expect(getAttachmentMock).toHaveBeenCalledWith("att-1");
// Disown the opener and redirect to the signed URL.
expect(placeholder.opener).toBeNull();
expect(placeholder.location.href).toBe(SIGNED_URL);
});
it("closes the placeholder and shows a toast when the fetch fails", async () => {
getAttachmentMock.mockRejectedValueOnce(new Error("boom"));
const placeholder = { opener: window, location: { href: "about:blank" }, close: vi.fn() };
vi.spyOn(window, "open").mockReturnValue(placeholder as unknown as Window);
const { result } = renderHook(() => useDownloadAttachment());
await act(async () => {
await result.current("att-1");
});
expect(placeholder.close).toHaveBeenCalled();
await waitFor(() => expect(toast.error).toHaveBeenCalled());
});
});
describe("useDownloadAttachment (desktop)", () => {
it("skips the placeholder tab and hands the signed URL to openExternal", async () => {
(window as unknown as { desktopAPI: { openExternal: () => void } }).desktopAPI = {
openExternal: vi.fn(),
};
getAttachmentMock.mockResolvedValueOnce({
id: "att-1",
url: "https://static.example.test/file.md",
download_url: SIGNED_URL,
filename: "file.md",
});
const openSpy = vi.spyOn(window, "open");
const { result } = renderHook(() => useDownloadAttachment());
await act(async () => {
await result.current("att-1");
});
// No placeholder — Electron's setWindowOpenHandler would reject
// about:blank, so we go straight to the platform's IPC bridge.
expect(openSpy).not.toHaveBeenCalled();
expect(openExternal).toHaveBeenCalledWith(SIGNED_URL);
});
});

View File

@@ -1,92 +0,0 @@
"use client";
import { useCallback } from "react";
import { toast } from "sonner";
import { api } from "@multica/core/api";
import { openExternal } from "../platform";
import { useT } from "../i18n";
interface DesktopBridge {
openExternal?: (u: string) => Promise<void> | void;
}
// Detected at call time, not module load — the bridge is injected by the
// Electron preload after `window` exists, and reading it lazily lets the
// same hook work in both renderers without a build-time fork.
function hasDesktopBridge(): boolean {
if (typeof window === "undefined") return false;
const bridge = (window as unknown as { desktopAPI?: DesktopBridge }).desktopAPI;
return Boolean(bridge?.openExternal);
}
/**
* Returns a callback that downloads an attachment by ID through a freshly
* signed CloudFront URL. The server re-signs `download_url` on every
* `GET /api/attachments/{id}` call, so this flow sidesteps stale signatures
* cached in TanStack Query / inlined in markdown.
*
* Two execution shapes, picked at call time:
*
* - **Web**: open a same-origin `about:blank` tab *synchronously* inside
* the click handler — popup blockers (Safari especially) only consider
* the gesture frame, not the later async settle. The placeholder tab
* keeps the user activation receipt; after the fetch resolves we navigate
* it. We can NOT pass `"noopener"` to `window.open` because the HTML
* spec (`dom-open` step 17) makes that return `null`, which would leave
* us nothing to navigate. We disown the opener manually after the fetch.
*
* - **Desktop**: `window.open` is intercepted by Electron's
* `setWindowOpenHandler` and routed through `openExternalSafely`, which
* rejects `about:blank`. So on desktop we fetch first, then hand the URL
* to `openExternal()` which IPCs into `shell.openExternal` and opens the
* system browser.
*/
export function useDownloadAttachment(): (attachmentId: string) => Promise<void> {
const { t } = useT("editor");
return useCallback(
async (attachmentId: string) => {
const failed = () => toast.error(t(($) => $.attachment.download_failed));
if (hasDesktopBridge()) {
try {
const fresh = await api.getAttachment(attachmentId);
if (!fresh.download_url) {
failed();
return;
}
openExternal(fresh.download_url);
} catch {
failed();
}
return;
}
// Web: claim the popup permission synchronously, then hydrate the URL.
// `window.open` here returns a WindowProxy because we deliberately
// omit `noopener`; we revoke the back-channel ourselves once we have
// the real URL.
const placeholder = typeof window !== "undefined"
? window.open("about:blank", "_blank")
: null;
try {
const fresh = await api.getAttachment(attachmentId);
if (!fresh.download_url) {
placeholder?.close();
failed();
return;
}
if (placeholder) {
placeholder.opener = null;
placeholder.location.href = fresh.download_url;
} else if (typeof window !== "undefined") {
// Popup blocked outright — last-resort navigate the current tab.
window.location.href = fresh.download_url;
}
} catch {
placeholder?.close();
failed();
}
},
[t],
);
}

View File

@@ -22,7 +22,6 @@ import {
} from "@multica/core/inbox/mutations";
import { IssueDetail } from "../../issues/components";
import { ErrorBoundary } from "@multica/ui/components/common/error-boundary";
import { useNavigation } from "../../navigation";
import { toast } from "sonner";
import {
@@ -261,25 +260,23 @@ export function InboxPage() {
// new inbox notification for the same issue, and the dedup helper picks the
// newest one — keying on its id would remount IssueDetail on every event,
// wiping the comment composer draft and resetting scroll position.
<ErrorBoundary resetKeys={[selected.issue_id]}>
<IssueDetail
key={selected.issue_id}
issueId={selected.issue_id}
defaultSidebarOpen={false}
layoutId="multica_inbox_issue_detail_layout"
highlightCommentId={selected.details?.comment_id ?? undefined}
onDelete={() => {
// Issue deletion CASCADE-deletes the inbox item server-side, and the
// issue:deleted WS event prunes it from the inbox cache. Just clear
// the selection — calling archive here would 404 on a row that no
// longer exists.
setSelectedKey("");
}}
onDone={() => {
handleArchive(selected.id);
}}
/>
</ErrorBoundary>
<IssueDetail
key={selected.issue_id}
issueId={selected.issue_id}
defaultSidebarOpen={false}
layoutId="multica_inbox_issue_detail_layout"
highlightCommentId={selected.details?.comment_id ?? undefined}
onDelete={() => {
// Issue deletion CASCADE-deletes the inbox item server-side, and the
// issue:deleted WS event prunes it from the inbox cache. Just clear
// the selection — calling archive here would 404 on a row that no
// longer exists.
setSelectedKey("");
}}
onDone={() => {
handleArchive(selected.id);
}}
/>
) : selected ? (
<div className="p-6">
<h2 className="text-lg font-semibold">{getInboxDisplayTitle(selected)}</h2>

View File

@@ -156,7 +156,6 @@ describe("InvitationsPage", () => {
expect(acceptInvitation).toHaveBeenCalledWith("inv-1");
expect(markOnboardingComplete).toHaveBeenCalledWith({
completion_path: "invite_accept",
workspace_id: "ws-1",
});
expect(refreshMe).toHaveBeenCalled();
expect(navigate).toHaveBeenCalledWith("/acme/issues");

View File

@@ -81,19 +81,12 @@ export function InvitationsPage() {
acceptedIds.push(id);
}
const firstAcceptedInvite = invitations?.find(
(inv) => inv.id === acceptedIds[0],
);
// markOnboardingComplete is a frontend-side belt to the backend braces:
// each AcceptInvitation transaction already sets onboarded_at via
// MarkUserOnboarded, but calling this from the client makes sure the
// returned `User` is freshly written and gives refreshMe something
// canonical to read.
await api.markOnboardingComplete({
completion_path: "invite_accept",
workspace_id: firstAcceptedInvite?.workspace_id,
});
await api.markOnboardingComplete({ completion_path: "invite_accept" });
await useAuthStore.getState().refreshMe();
qc.invalidateQueries({ queryKey: workspaceKeys.myInvitations() });
@@ -102,6 +95,9 @@ export function InvitationsPage() {
staleTime: 0,
});
const firstAcceptedInvite = invitations?.find(
(inv) => inv.id === acceptedIds[0],
);
const targetWs = firstAcceptedInvite
? wsList.find((w) => w.id === firstAcceptedInvite.workspace_id)
: undefined;

View File

@@ -69,10 +69,7 @@ export function InvitePage({ invitationId, onBack }: InvitePageProps) {
// onboarded_at inside the same transaction, but explicitly calling
// markOnboardingComplete + refreshMe here keeps local user state in
// sync immediately so downstream guards don't see stale `null`.
await api.markOnboardingComplete({
completion_path: "invite_accept",
workspace_id: invitation?.workspace_id,
});
await api.markOnboardingComplete({ completion_path: "invite_accept" });
await useAuthStore.getState().refreshMe();
setDone("accepted");
// Fetch the refreshed workspace list so we know the joined workspace's slug.

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