mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-23 07:29:14 +02:00
Compare commits
83 Commits
agent/lamb
...
v0.2.29
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d4595ff8f | ||
|
|
b73a301bf9 | ||
|
|
807201086c | ||
|
|
d713b57072 | ||
|
|
f70105fb12 | ||
|
|
1d7aaf582c | ||
|
|
c57546159d | ||
|
|
003dfd9b4b | ||
|
|
3f20999597 | ||
|
|
9ded462ecc | ||
|
|
fd3cb4e5b3 | ||
|
|
4b8939e78e | ||
|
|
a2dd80d4f6 | ||
|
|
6d9ebb0fdd | ||
|
|
4872dc50bd | ||
|
|
f922673463 | ||
|
|
560e081d8f | ||
|
|
73b401d47a | ||
|
|
c926dfe44b | ||
|
|
46eed3b298 | ||
|
|
0eb23df234 | ||
|
|
c3832302b9 | ||
|
|
8d5a6138fe | ||
|
|
0cd50e14eb | ||
|
|
ce00e05169 | ||
|
|
bb3d2b70ea | ||
|
|
bf186504b0 | ||
|
|
b17f975a17 | ||
|
|
190ef87475 | ||
|
|
590ac7953e | ||
|
|
3b3be9d7bd | ||
|
|
bf0665a1a8 | ||
|
|
bda475cbba | ||
|
|
d1a6881707 | ||
|
|
97df9b90f5 | ||
|
|
61ce8a8090 | ||
|
|
fe8326fa0c | ||
|
|
f1dc3dc986 | ||
|
|
0b64f09c12 | ||
|
|
823f124d67 | ||
|
|
b1d874ef50 | ||
|
|
eb067ff077 | ||
|
|
6400868412 | ||
|
|
bbbbcf9b6e | ||
|
|
161194b86f | ||
|
|
9a3a99cef8 | ||
|
|
14ab487c95 | ||
|
|
6b7294aa5b | ||
|
|
d964d37f97 | ||
|
|
9650788709 | ||
|
|
00ba0aa4e6 | ||
|
|
de356561bc | ||
|
|
47aa32a04d | ||
|
|
a6e8ae964e | ||
|
|
cc527c34be | ||
|
|
250ada1fb3 | ||
|
|
d82a2d8a04 | ||
|
|
48e3131bf9 | ||
|
|
dce51e3a27 | ||
|
|
099dda0603 | ||
|
|
fe956fc670 | ||
|
|
f9cdd487e0 | ||
|
|
5d51a0c9df | ||
|
|
d07c7c2a15 | ||
|
|
0af67c8159 | ||
|
|
9c00ecfdb4 | ||
|
|
af971e1e5c | ||
|
|
d0ac67dea2 | ||
|
|
53a3b33c50 | ||
|
|
c3ddb57b82 | ||
|
|
d16c48172a | ||
|
|
11a6288cbd | ||
|
|
32740d0ee3 | ||
|
|
c784a6a9ee | ||
|
|
9306d60451 | ||
|
|
4a749f103b | ||
|
|
38f777d0ba | ||
|
|
2f979ac6f0 | ||
|
|
8d20a2f7bd | ||
|
|
e3dd31cbe5 | ||
|
|
5cf1d01076 | ||
|
|
6d59505575 | ||
|
|
58db751089 |
@@ -132,5 +132,8 @@ 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=
|
||||
|
||||
2
.github/PULL_REQUEST_TEMPLATE.md
vendored
2
.github/PULL_REQUEST_TEMPLATE.md
vendored
@@ -40,6 +40,8 @@ Closes #
|
||||
- [ ] I have added or updated tests where applicable
|
||||
- [ ] If this change affects the UI, I have included before/after screenshots
|
||||
- [ ] I have updated relevant documentation to reflect my changes
|
||||
- [ ] If I added a new runtime / coding tool / UI tab, I synced the change to **landing copy** (`apps/web/features/landing/i18n/`), **starter-content** (`packages/views/onboarding/utils/starter-content-content-*.ts`), and **relevant docs** (`apps/docs/content/docs/`)
|
||||
- [ ] If this PR touches Chinese product copy, I checked it against `apps/docs/content/docs/developers/conventions.zh.mdx` (terminology, mixed-rule for `task` / `issue` / `skill`)
|
||||
- [ ] I have considered and documented any risks above
|
||||
- [ ] I will address all reviewer comments before requesting merge
|
||||
|
||||
|
||||
13
.github/workflows/ci.yml
vendored
13
.github/workflows/ci.yml
vendored
@@ -29,8 +29,17 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Build, type check, and test
|
||||
run: pnpm exec turbo build typecheck test --filter='!@multica/docs'
|
||||
- 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'
|
||||
|
||||
backend:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
19
CLAUDE.md
19
CLAUDE.md
@@ -146,10 +146,27 @@ 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.
|
||||
- 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.
|
||||
- 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
|
||||
|
||||
|
||||
@@ -306,10 +306,11 @@ 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
|
||||
```
|
||||
|
||||
Available filters: `--status`, `--priority`, `--assignee` / `--assignee-id`, `--project`, `--limit`. Use `--assignee-id <uuid>` for unambiguous filtering when names overlap.
|
||||
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.
|
||||
|
||||
### Get Issue
|
||||
|
||||
@@ -393,17 +394,19 @@ 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. 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.
|
||||
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.
|
||||
|
||||
## Projects
|
||||
|
||||
@@ -513,9 +516,12 @@ 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: 121 KiB After Width: | Height: | Size: 136 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 491 KiB |
@@ -69,7 +69,7 @@ describe("loadRuntimeConfig", () => {
|
||||
schemaVersion: 1,
|
||||
apiUrl: "https://api.example.com",
|
||||
wsUrl: "wss://api.example.com/ws",
|
||||
appUrl: "https://api.example.com",
|
||||
appUrl: "https://example.com",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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";
|
||||
@@ -13,5 +14,9 @@ export function IssueDetailPage() {
|
||||
useDocumentTitle(issue ? `${issue.identifier}: ${issue.title}` : "Issue");
|
||||
|
||||
if (!id) return null;
|
||||
return <IssueDetail issueId={id} />;
|
||||
return (
|
||||
<ErrorBoundary resetKeys={[id]}>
|
||||
<IssueDetail issueId={id} />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ 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";
|
||||
@@ -83,7 +84,15 @@ export const appRoutes: RouteObject[] = [
|
||||
element: <WorkspaceRouteLayout />,
|
||||
children: [
|
||||
{ index: true, element: <Navigate to="issues" replace /> },
|
||||
{ path: "issues", element: <IssuesPage />, handle: { title: "Issues" } },
|
||||
{
|
||||
path: "issues",
|
||||
element: (
|
||||
<ErrorBoundary>
|
||||
<IssuesPage />
|
||||
</ErrorBoundary>
|
||||
),
|
||||
handle: { title: "Issues" },
|
||||
},
|
||||
{
|
||||
path: "issues/:id",
|
||||
element: <IssueDetailPage />,
|
||||
|
||||
@@ -32,6 +32,19 @@ 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");
|
||||
});
|
||||
@@ -96,4 +109,43 @@ 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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -44,10 +44,9 @@ export function runtimeConfigFromDevEnv(env: RuntimeConfigEnv): RuntimeConfig {
|
||||
wsUrl: env.wsUrl
|
||||
? normalizeWsUrl(env.wsUrl, "VITE_WS_URL")
|
||||
: deriveWsUrl(apiUrl),
|
||||
appUrl: normalizeHttpUrl(
|
||||
env.appUrl || LOCAL_DEV_RUNTIME_CONFIG.appUrl,
|
||||
"VITE_APP_URL",
|
||||
),
|
||||
appUrl: env.appUrl
|
||||
? normalizeHttpUrl(env.appUrl, "VITE_APP_URL")
|
||||
: deriveDevAppUrl(apiUrl),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -94,14 +93,37 @@ 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`);
|
||||
|
||||
@@ -9,6 +9,14 @@ import { notFound } from "next/navigation";
|
||||
import defaultMdxComponents from "fumadocs-ui/mdx";
|
||||
import type { Metadata } from "next";
|
||||
import { docsAlternates } from "@/lib/site";
|
||||
import { i18n, type Lang } from "@/lib/i18n";
|
||||
import { DocsLocaleProvider, LocaleLink } from "@/components/locale-link";
|
||||
|
||||
function asLang(lang: string): Lang {
|
||||
return (i18n.languages as readonly string[]).includes(lang)
|
||||
? (lang as Lang)
|
||||
: (i18n.defaultLanguage as Lang);
|
||||
}
|
||||
|
||||
export default async function Page(props: {
|
||||
params: Promise<{ lang: string; slug: string[] }>;
|
||||
@@ -18,13 +26,16 @@ export default async function Page(props: {
|
||||
if (!page) notFound();
|
||||
|
||||
const MDX = page.data.body;
|
||||
const lang = asLang(params.lang);
|
||||
|
||||
return (
|
||||
<DocsPage toc={page.data.toc}>
|
||||
<DocsTitle>{page.data.title}</DocsTitle>
|
||||
<DocsDescription>{page.data.description}</DocsDescription>
|
||||
<DocsBody>
|
||||
<MDX components={{ ...defaultMdxComponents }} />
|
||||
<DocsLocaleProvider lang={lang}>
|
||||
<MDX components={{ ...defaultMdxComponents, a: LocaleLink }} />
|
||||
</DocsLocaleProvider>
|
||||
</DocsBody>
|
||||
</DocsPage>
|
||||
);
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Byline, NumberedCards, NumberedCard, NumberedSteps, Step } from "@/comp
|
||||
import { i18n, type Lang } from "@/lib/i18n";
|
||||
import { homeCopy } from "@/lib/translations";
|
||||
import { docsAlternates } from "@/lib/site";
|
||||
import { DocsLocaleProvider, LocaleLink } from "@/components/locale-link";
|
||||
|
||||
function asLang(lang: string): Lang {
|
||||
return (i18n.languages as readonly string[]).includes(lang)
|
||||
@@ -52,15 +53,18 @@ export default async function Page({
|
||||
/>
|
||||
<Byline items={[...copy.byline]} />
|
||||
<DocsBody>
|
||||
<MDX
|
||||
components={{
|
||||
...defaultMdxComponents,
|
||||
NumberedCards,
|
||||
NumberedCard,
|
||||
NumberedSteps,
|
||||
Step,
|
||||
}}
|
||||
/>
|
||||
<DocsLocaleProvider lang={lang}>
|
||||
<MDX
|
||||
components={{
|
||||
...defaultMdxComponents,
|
||||
a: LocaleLink,
|
||||
NumberedCards,
|
||||
NumberedCard,
|
||||
NumberedSteps,
|
||||
Step,
|
||||
}}
|
||||
/>
|
||||
</DocsLocaleProvider>
|
||||
</DocsBody>
|
||||
</DocsPage>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import type { ReactNode } from "react";
|
||||
import { useDocsLocale } from "@/components/locale-link";
|
||||
import { prefixLocale } from "@/lib/locale-link";
|
||||
|
||||
/**
|
||||
* Byline — editorial metadata strip with ruled top + bottom borders.
|
||||
@@ -55,9 +59,10 @@ export function NumberedCard({
|
||||
tag?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const lang = useDocsLocale();
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
href={prefixLocale(href, lang)}
|
||||
className="group flex flex-col gap-2.5 border-r border-border px-0 py-5 pr-4 no-underline last:border-r-0 md:px-4 md:first:pl-0 md:last:pr-0"
|
||||
>
|
||||
<div className="font-mono text-[0.6875rem] uppercase tracking-[0.08em] text-muted-foreground">
|
||||
|
||||
48
apps/docs/components/locale-link.tsx
Normal file
48
apps/docs/components/locale-link.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
type AnchorHTMLAttributes,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { i18n, type Lang } from "@/lib/i18n";
|
||||
import { prefixLocale } from "@/lib/locale-link";
|
||||
|
||||
const DocsLocaleContext = createContext<Lang>(i18n.defaultLanguage as Lang);
|
||||
|
||||
// Wraps the rendered MDX subtree so descendant <LocaleLink>s and any
|
||||
// editorial component using `useDocsLocale()` know which language the page
|
||||
// was rendered in. Mounted at each docs page entry; never elsewhere.
|
||||
export function DocsLocaleProvider({
|
||||
lang,
|
||||
children,
|
||||
}: {
|
||||
lang: Lang;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<DocsLocaleContext.Provider value={lang}>
|
||||
{children}
|
||||
</DocsLocaleContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useDocsLocale(): Lang {
|
||||
return useContext(DocsLocaleContext);
|
||||
}
|
||||
|
||||
// Drop-in replacement for the MDX-rendered `<a>` element. Keeps the same
|
||||
// surface shape as the default `a` from `defaultMdxComponents` but routes
|
||||
// internal links through the locale prefixer + next/link so client-side
|
||||
// navigation stays inside the active locale.
|
||||
export function LocaleLink({
|
||||
href,
|
||||
...rest
|
||||
}: AnchorHTMLAttributes<HTMLAnchorElement> & { href?: string }) {
|
||||
const lang = useDocsLocale();
|
||||
if (!href) return <a {...rest} />;
|
||||
const final = prefixLocale(href, lang);
|
||||
return <Link href={final} {...rest} />;
|
||||
}
|
||||
@@ -40,20 +40,25 @@ For the difference between token types, see [Authentication and tokens](/auth-to
|
||||
| `multica workspace list` | List every workspace you can access |
|
||||
| `multica workspace get <slug>` | Show details for one workspace |
|
||||
| `multica workspace members` | List members of the current workspace |
|
||||
| `multica workspace update <id> --name "..." [--description "..."] [--context "..."] [--issue-prefix "..."]` | Update workspace metadata (admin/owner). Long fields accept `--description-stdin` / `--context-stdin`. |
|
||||
|
||||
## Issues and projects
|
||||
|
||||
<Callout type="info">
|
||||
`list` commands (`multica issue list`, `autopilot list`, `project list`, etc.) print short, copy-paste-ready IDs by default — issue keys like `MUL-123` for issues, short UUID prefixes for the rest. The `<id>` argument on the follow-up commands below accepts either the short ID or the full UUID, so the typical flow is `multica issue list` → copy the key → `multica issue get MUL-123`. Pass `--full-id` to a list command when you need the canonical UUID.
|
||||
</Callout>
|
||||
|
||||
| Command | Purpose |
|
||||
|---|---|
|
||||
| `multica issue list` | List issues |
|
||||
| `multica issue get <id>` | Show a single issue |
|
||||
| `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 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>` | Rerun the most recent agent task |
|
||||
| `multica issue rerun <id>` | Re-enqueue a fresh task for the issue's current agent assignee |
|
||||
| `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 |
|
||||
@@ -98,7 +103,6 @@ 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
|
||||
|
||||
@@ -40,20 +40,25 @@ Token 类型的详细区分见 [认证与令牌](/auth-tokens)。
|
||||
| `multica workspace list` | 列出你有权访问的所有工作区 |
|
||||
| `multica workspace get <slug>` | 查看一个工作区的详情 |
|
||||
| `multica workspace members` | 列出当前工作区的成员 |
|
||||
| `multica workspace update <id> --name "..." [--description "..."] [--context "..."] [--issue-prefix "..."]` | 修改 workspace 元数据(admin/owner 权限)。长文本可用 `--description-stdin` / `--context-stdin`。 |
|
||||
|
||||
## Issue 和 Project
|
||||
|
||||
<Callout type="info">
|
||||
`list` 类命令(`multica issue list`、`autopilot list`、`project list` 等)表格里默认显示**可直接复制**的短 ID:issue 是 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 |
|
||||
| `multica issue get <id>` | 查看单条 issue |
|
||||
| `multica issue list` | 列出 issue(默认显示可复制的 issue key) |
|
||||
| `multica issue get <id>` | 查看单条 issue(接受 issue key 或 UUID) |
|
||||
| `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>` | 重跑最近一次智能体任务 |
|
||||
| `multica issue rerun <id>` | 给该 issue 当前的智能体分配人重新创建一条任务 |
|
||||
| `multica issue comment <id> ...` | 嵌套:看 / 发评论 |
|
||||
| `multica issue subscriber <id> ...` | 嵌套:订阅 / 取消订阅 |
|
||||
| `multica project list/get/create/update/delete/status` | Project CRUD |
|
||||
@@ -98,7 +103,6 @@ Token 类型的详细区分见 [认证与令牌](/auth-tokens)。
|
||||
| `multica runtime list` | 列出当前工作区的 runtime |
|
||||
| `multica runtime usage` | 查看资源使用情况 |
|
||||
| `multica runtime activity` | 近期活动记录 |
|
||||
| `multica runtime ping <id>` | 立即戳一次 runtime 检查在线 |
|
||||
| `multica runtime update <id> ...` | 更新 runtime 配置 |
|
||||
|
||||
## 杂项
|
||||
|
||||
@@ -213,6 +213,28 @@ multica workspace get <workspace-id> --output json
|
||||
multica workspace members <workspace-id>
|
||||
```
|
||||
|
||||
### Update Workspace
|
||||
|
||||
需要 admin 或 owner 权限。所有字段都是部分更新(PATCH 语义):未传的字段保持不变。
|
||||
|
||||
```bash
|
||||
multica workspace update <workspace-id> --name "Acme Eng"
|
||||
multica workspace update <workspace-id> \
|
||||
--description "Engineering team workspace" \
|
||||
--issue-prefix ENG
|
||||
```
|
||||
|
||||
长文本走 stdin(保留换行/反斜杠):
|
||||
|
||||
```bash
|
||||
cat <<'CTX' | multica workspace update <workspace-id> --context-stdin
|
||||
我们是一支 5 人 AI-native 团队。
|
||||
工作语言:中文 + 英文混合。
|
||||
CTX
|
||||
```
|
||||
|
||||
可编辑字段:`--name`、`--description` / `--description-stdin`、`--context` / `--context-stdin`、`--issue-prefix`。`slug` 创建后只读,不暴露在 CLI。`--description` 与 `--description-stdin`(以及 `context` 同名对)互斥。未传任何字段 flag 时命令拒绝执行,避免空 PATCH 触发无意义的 workspace 更新事件。`--issue-prefix ""` 也会被拒绝:当前后端在 prefix 为空时静默跳过该字段,CLI 在本地拦下避免“看似成功的 no-op”。
|
||||
|
||||
## Issues
|
||||
|
||||
### List Issues
|
||||
@@ -222,18 +244,22 @@ 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
|
||||
```
|
||||
|
||||
Available filters: `--status`, `--priority`, `--assignee` / `--assignee-id`, `--project`, `--limit`. 在重名 workspace 下用 `--assignee-id <uuid>` 可以精确锁定一个成员或 agent。
|
||||
表格输出默认显示可直接复制到后续命令的 issue `KEY`(例如 `MUL-123`);需要完整 UUID 时使用 `--full-id`。Available filters: `--status`, `--priority`, `--assignee` / `--assignee-id`, `--project`, `--limit`. 在重名 workspace 下用 `--assignee-id <uuid>` 可以精确锁定一个成员或 agent。
|
||||
|
||||
### Get Issue
|
||||
|
||||
```bash
|
||||
multica issue get <id>
|
||||
multica issue get MUL-123
|
||||
multica issue get <uuid>
|
||||
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
|
||||
@@ -288,16 +314,20 @@ 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
|
||||
|
||||
@@ -37,7 +37,7 @@ Common commands:
|
||||
|
||||
Full CLI reference in [CLI commands](/cli).
|
||||
|
||||
**The desktop app ships with a daemon.** If you use the [desktop app](/desktop-app), you don't need to run `multica daemon start` manually — it launches the daemon automatically on startup.
|
||||
**The desktop app ships with a daemon.** If you use the [desktop app](/desktop-app), you don't need to run `multica daemon start` manually — it launches the daemon automatically on startup. See the [Desktop app](/desktop-app) page for which option fits your workflow.
|
||||
|
||||
## Why one machine has multiple runtimes
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ multica daemon start
|
||||
|
||||
完整 CLI 参考见 [CLI 命令速查](/cli)。
|
||||
|
||||
**桌面应用自带守护进程。**用 [桌面应用](/desktop-app) 就不必手动 `multica daemon start`——它启动时会自动拉起守护进程。
|
||||
**桌面应用自带守护进程。**用 [桌面应用](/desktop-app) 就不必手动 `multica daemon start`——它启动时会自动拉起守护进程。哪种方式更适合你的工作流,详见 [桌面应用](/desktop-app) 页面。
|
||||
|
||||
## 为什么一台机器会有多个运行时
|
||||
|
||||
|
||||
@@ -95,17 +95,26 @@ Multica's product nouns split into two categories:
|
||||
|
||||
This rule is aligned with `apps/docs/content/docs/*.zh.mdx` — the docs are the de facto Chinese voice standard and have been battle-tested across 20+ pages.
|
||||
|
||||
### Don't translate — entities (lowercase English)
|
||||
### Entities — mixed rule (`issue` / `skill` / `task`)
|
||||
|
||||
| Term | Render in Chinese | Example |
|
||||
`issue` / `skill` / `task` are Multica's core entities. They have schema columns, API fields, and product UI labels that are all English. In Chinese text, they follow a **mixed rule** — what to use depends on where the word appears:
|
||||
|
||||
| Context | Render | Example |
|
||||
| --- | --- | --- |
|
||||
| Issue | `issue` (lowercase) | "把 issue 分配给智能体"、"创建子 issue" |
|
||||
| Skill | `skill` (lowercase) | "为智能体注入 skill" |
|
||||
| Task | `task` (lowercase) | "排队中的 task" |
|
||||
| **UI strings, state names, code references** | lowercase English | "排队中的 task"、"创建子 issue"、"为智能体注入 skill" |
|
||||
| **Doc titles / section headings** | Title-case English **or** the Chinese term | "Issue 与 project"、"Skills"、"执行任务" |
|
||||
| **Long-form doc prose, when the entity is the running subject** | Chinese term, with English in parentheses on first mention | "**执行任务**(task)是智能体每一次工作的单位" |
|
||||
| **API / DB fields** | always `task` / `issue` / `skill` | `task_id`, `issue_status`, `skill_uuid` |
|
||||
|
||||
**Why `issue` / `skill` / `task` stay English while `project` / `autopilot` are translated**:
|
||||
Chinese term reference:
|
||||
|
||||
- **`issue` / `task`**: dev teams talk in English. The Chinese candidates ("任务" — too vague, almost synonymous with "工作"; "工单" — IT ticket connotation; "议题" — GitHub-style but doesn't match the product feel) all read worse than `issue`.
|
||||
- `task` ↔ `执行任务` (or shortened to `任务` once context is clear)
|
||||
- `issue` has no settled Chinese translation — leave English; titles may capitalize as `Issue`
|
||||
- `skill` has no settled Chinese translation — leave English; titles may capitalize as `Skills`
|
||||
|
||||
**Why `issue` / `skill` / `task` aren't forced into Chinese the way `project` / `autopilot` are**:
|
||||
|
||||
- **`issue` / `task`**: dev teams talk in English. The Chinese candidates ("任务" — too vague, almost synonymous with "工作"; "工单" — IT ticket connotation; "议题" — GitHub-style but doesn't match the product feel) all read worse than `issue`. **But** in long-form doc prose, repeating lowercase `task` 50× breaks the rhythm — so prose is allowed to use `执行任务`, while UI strings and state names stay lowercase English.
|
||||
- **`skill`**: Multica-specific concept with no established Chinese term.
|
||||
- **`project` → "项目"**: settled mainstream Chinese word. Feishu / Tower / Teambition / PingCode / GitHub Projects — every Chinese product translates it. No product keeps `project` in Chinese context.
|
||||
- **`autopilot` → "自动化"**: in Chinese, "autopilot" associates with Tesla's "自动驾驶" and doesn't match what the feature does (run tasks on a schedule). Notion and Feishu both use "自动化"; that's the industry consensus.
|
||||
@@ -242,7 +251,7 @@ Examples:
|
||||
- `issues.toolbar.batch_update_success`
|
||||
- `issues.detail.comment_form.placeholder`
|
||||
- `inbox.empty.title`
|
||||
- `settings.appearance.language.title`
|
||||
- `settings.preferences.language.title`
|
||||
|
||||
### Web-only / desktop-only copy
|
||||
|
||||
@@ -278,7 +287,7 @@ When the glossary doesn't cover a term, look at:
|
||||
1. `apps/docs/content/docs/*.zh.mdx` — the de facto Chinese voice standard, 20+ pages of consistent translation
|
||||
2. `packages/views/locales/zh-Hans/auth.json` and `editor.json` — JSON structure + selector API patterns
|
||||
3. `packages/views/auth/login-page.tsx` — component-level selector API call site
|
||||
4. `packages/views/settings/components/appearance-tab.tsx` — language switcher reference
|
||||
4. `packages/views/settings/components/preferences-tab.tsx` — language switcher reference
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -95,20 +95,29 @@ Multica 的产品名词分两类:
|
||||
|
||||
这套规则与 `apps/docs/content/docs/*.zh.mdx` 完全对齐 —— docs 是已经实战 20+ 篇的 CN voice 标准。
|
||||
|
||||
### 不翻 —— 实体(小写英文)
|
||||
### 实体词的混合规则(`issue` / `skill` / `task`)
|
||||
|
||||
| 词 | 中文中的写法 | 例 |
|
||||
`issue` / `skill` / `task` 是 Multica 的核心实体。schema 字段、API 字段、产品 UI 标签都用英文。中文里采用**混合规则** —— 词出现在哪里决定怎么写:
|
||||
|
||||
| 场景 | 写法 | 例 |
|
||||
| --- | --- | --- |
|
||||
| Issue | `issue`(小写) | "把 issue 分配给智能体"、"创建子 issue" |
|
||||
| Skill | `skill`(小写) | "为智能体注入 skill" |
|
||||
| Task | `task`(小写) | "排队中的 task" |
|
||||
| **UI 短句 / 状态名 / 代码上下文** | 小写英文 | "排队中的 task"、"创建子 issue"、"为智能体注入 skill" |
|
||||
| **doc 标题 / 章节标题** | 首字母大写英文,**或**对应中文术语 | "Issue 与 project"、"Skills"、"执行任务" |
|
||||
| **doc 正文长篇讨论中作为主语** | 中文术语,首次出现配括号英文 | "**执行任务**(task)是智能体每一次工作的单位" |
|
||||
| **API / DB 字段** | 永远 `task` / `issue` / `skill` | `task_id`、`issue_status`、`skill_uuid` |
|
||||
|
||||
**为什么 `issue` / `skill` / `task` 不翻而 `project` / `autopilot` 翻**:
|
||||
中文术语对照:
|
||||
|
||||
- **`issue` / `task`**:dev 团队习惯说英文,"任务"在中文里和"工作"几乎同义太空泛,"工单"是 IT 工单语义,"议题"是 GitHub 风格但用户场景不匹配。三个候选都不如 `issue` 准确。
|
||||
- `task` ↔ `执行任务`(上下文清楚后可简写为「任务」)
|
||||
- `issue` 没有公认中文译法 —— 保留英文;标题可大写为 `Issue`
|
||||
- `skill` 没有公认中文译法 —— 保留英文;标题可大写为 `Skills`
|
||||
|
||||
**为什么 `issue` / `skill` / `task` 不强制译,而 `project` / `autopilot` 必译**:
|
||||
|
||||
- **`issue` / `task`**:dev 团队习惯说英文,"任务"在中文里和"工作"几乎同义太空泛,"工单"是 IT 工单语义,"议题"是 GitHub 风格但用户场景不匹配 —— 三个候选都不如 `issue` 准确。**但**在长篇 doc 正文里,重复 50 次 `task` 节奏不顺,所以正文允许用 `执行任务`,UI 短句、状态名仍保持小写英文。
|
||||
- **`skill`**:Multica 特有概念,没有公认中文译法。
|
||||
- **`project` 翻成"项目"**:这是中文里早就稳定的日常词。飞书 / Tower / Teambition / PingCode / GitHub Projects 中文版 0 例外都翻译成"项目",没有产品保留 `project`。
|
||||
- **`autopilot` 翻成"自动化"**:autopilot 在中文里联想到特斯拉的"自动驾驶",跟产品功能(按周期跑 task)对应不上。Notion / 飞书都用"自动化",是行业共识。
|
||||
- **`project` 翻成「项目」**:中文里早就稳定的日常词。飞书 / Tower / Teambition / PingCode / GitHub Projects 中文版 0 例外都翻译成「项目」,没有产品保留 `project`。
|
||||
- **`autopilot` 翻成「自动化」**:autopilot 在中文里联想到特斯拉的「自动驾驶」,跟产品功能(按周期跑 task)对应不上。Notion / 飞书都用「自动化」,是行业共识。
|
||||
|
||||
### 完整翻译 —— 概念词
|
||||
|
||||
@@ -242,7 +251,7 @@ i18next 用 `_one` / `_other`;中文不区分语法单复数,只填 `_other`
|
||||
- `issues.toolbar.batch_update_success`
|
||||
- `issues.detail.comment_form.placeholder`
|
||||
- `inbox.empty.title`
|
||||
- `settings.appearance.language.title`
|
||||
- `settings.preferences.language.title`
|
||||
|
||||
### Web-only / Desktop-only 文案位置
|
||||
|
||||
@@ -278,7 +287,7 @@ i18next 用 `_one` / `_other`;中文不区分语法单复数,只填 `_other`
|
||||
1. `apps/docs/content/docs/*.zh.mdx` —— CN voice 事实标准,20+ 篇高度一致
|
||||
2. `packages/views/locales/zh-Hans/auth.json` 和 `editor.json` —— JSON 结构 + selector API 用法参考
|
||||
3. `packages/views/auth/login-page.tsx` —— 组件层 selector API 调用参考
|
||||
4. `packages/views/settings/components/appearance-tab.tsx` —— 语言切换器参考
|
||||
4. `packages/views/settings/components/preferences-tab.tsx` —— 语言切换器参考
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"workspaces",
|
||||
"members-roles",
|
||||
"issues",
|
||||
"projects",
|
||||
"comments",
|
||||
"project-resources",
|
||||
"---Agents---",
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"workspaces",
|
||||
"members-roles",
|
||||
"issues",
|
||||
"projects",
|
||||
"comments",
|
||||
"---智能体---",
|
||||
"agents",
|
||||
|
||||
49
apps/docs/content/docs/projects.mdx
Normal file
49
apps/docs/content/docs/projects.mdx
Normal file
@@ -0,0 +1,49 @@
|
||||
---
|
||||
title: Projects
|
||||
description: Group related issues and track them as one unit — with priority, status, progress, and an owner.
|
||||
---
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
A **project** in Multica is a container for related [issues](/issues). Use it when a body of work is bigger than one issue but smaller than a full workspace — a launch, a migration, a feature with multiple parts, an investigation that branches into several threads.
|
||||
|
||||
Each project has a name, an icon, a description, a **lead** (a member or an [agent](/agents)), a **status** (`planned` / `in_progress` / `paused` / `completed` / `cancelled`), a **priority** (`urgent` / `high` / `medium` / `low` / `none`), and a **progress** percentage that's auto-derived from the status of its linked issues.
|
||||
|
||||
## How projects relate to issues
|
||||
|
||||
Projects and issues are independent objects with a many-to-one relationship: an issue can belong to **at most one** project; a project holds **any number of** issues. Linking and unlinking is reversible at any time — drag in the board view, or use the project picker on the issue's right-side properties panel.
|
||||
|
||||
The progress bar on a project is computed from its linked issues — the more issues hit `done`, the further it fills. Issues that are `cancelled` are excluded from the count; issues in `backlog` count toward the denominator but not the numerator.
|
||||
|
||||
## Pinning to the sidebar
|
||||
|
||||
Click the pin icon in a project's top-right corner to add it to your sidebar's pinned list. Pinned projects stay one click away no matter where you are in the workspace; everyone on the team can pin independently — pins are personal.
|
||||
|
||||
The sidebar **Workspace → Projects** link always shows every project in the workspace; pinning is a personal shortcut on top of that.
|
||||
|
||||
## Attaching resources
|
||||
|
||||
Each project has a **Resources** section where you attach GitHub repositories. Once attached, any [agent](/agents) assigned to issues in this project can read and write to those repos when executing tasks — Multica passes the repo URLs as context to the [daemon](/daemon-runtimes).
|
||||
|
||||
Resources are per-project; if multiple projects share a repo, attach it to each one.
|
||||
|
||||
## Deleting a project
|
||||
|
||||
Deleting a project **does not delete its issues**. The linked issues are simply unlinked and revert to the workspace's flat issue list. This is intentional — work that was scoped to a project is rarely throwaway, even when the framing of the project changes.
|
||||
|
||||
<Callout type="info">
|
||||
If you want to delete the work too, archive or delete the issues first, then delete the project.
|
||||
</Callout>
|
||||
|
||||
## Project lead
|
||||
|
||||
The lead is the person — or agent — accountable for the project. It's a soft signal, not an access control: any workspace member can edit a project regardless of who's lead. A project's lead can be:
|
||||
|
||||
- A workspace member (human teammate)
|
||||
- An [agent](/agents) — useful when the project's work is mostly delegated to an agent (e.g., "Weekly bug triage" led by a triage agent)
|
||||
|
||||
## Next
|
||||
|
||||
- [Issues](/issues) — the unit of work that lives inside projects
|
||||
- [Agents as project lead](/agents) — when an agent is the right owner
|
||||
- [How Multica works](/how-multica-works) — the broader picture
|
||||
49
apps/docs/content/docs/projects.zh.mdx
Normal file
49
apps/docs/content/docs/projects.zh.mdx
Normal file
@@ -0,0 +1,49 @@
|
||||
---
|
||||
title: 项目
|
||||
description: 把相关的 issue 归为一组当成一个单元来跟进 —— 有优先级、状态、进度和负责人。
|
||||
---
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
Multica 里的**项目**(project)是相关 [issue](/issues) 的容器。当一摊工作比单个 issue 大、又比整个工作区小的时候用它 —— 一次发布、一次迁移、一个分多块做的功能、一个会拆出多个线索的调研。
|
||||
|
||||
每个项目有名字、图标、描述、**负责人**(lead,可以是成员,也可以是 [智能体](/agents))、**状态**(`planned` / `in_progress` / `paused` / `completed` / `cancelled`)、**优先级**(`urgent` / `high` / `medium` / `low` / `none`),以及一个根据关联 issue 状态自动算出来的**进度**百分比。
|
||||
|
||||
## 项目和 issue 的关系
|
||||
|
||||
项目和 issue 是独立对象,多对一关系:一个 issue **最多属于一个**项目;一个项目可以容纳**任意多个** issue。关联和解除关联随时可逆 —— 在看板视图里拖动,或者在 issue 右侧 properties 面板用项目选择器。
|
||||
|
||||
项目的进度条是按关联 issue 状态自动算出来的 —— 越多 issue 到 `done`,进度条越满。`cancelled` 的 issue 不计入分母;`backlog` 的 issue 计入分母但不计入分子。
|
||||
|
||||
## pin 到侧边栏
|
||||
|
||||
点项目右上角的 pin 图标,可以把这个项目加到侧边栏的固定区。pin 过的项目无论你在工作区哪里都一键可达;每个人独立 pin —— pin 是个人偏好。
|
||||
|
||||
侧边栏 **Workspace → Projects** 链接始终展示工作区里所有项目;pin 只是在这之上的个人快捷方式。
|
||||
|
||||
## 关联 resources
|
||||
|
||||
每个项目有一个 **Resources** 区,可以挂 GitHub 仓库。挂上之后,被分配到这个项目里 issue 的 [智能体](/agents) 在执行 task 时可以读写这些仓库 —— Multica 会把仓库 URL 作为上下文传给 [守护进程](/daemon-runtimes)。
|
||||
|
||||
Resources 是项目级别的;多个项目要共享同一个仓库,要分别挂上。
|
||||
|
||||
## 删除项目
|
||||
|
||||
删除项目**不会**删除它的 issue。关联的 issue 只是解除关联,回到工作区的扁平 issue 列表。这是刻意的 —— 即使项目本身的框架变了,里面的工作通常也不会是一次性的。
|
||||
|
||||
<Callout type="info">
|
||||
如果你确实想把工作也删掉,先归档或删除 issue,再删除项目。
|
||||
</Callout>
|
||||
|
||||
## 项目负责人
|
||||
|
||||
负责人是为这个项目负总责的人 —— 或者智能体。这是一个软信号,不是权限控制:工作区任何成员都可以编辑项目,不管谁是负责人。项目负责人可以是:
|
||||
|
||||
- 工作区里的成员(人)
|
||||
- [智能体](/agents) —— 当项目里的工作大部分要交给智能体时合适(例如"每周 bug 巡检"由一个巡检智能体担任 lead)
|
||||
|
||||
## 下一步
|
||||
|
||||
- [Issues](/issues) —— 项目里装的工作单元
|
||||
- [智能体担任项目负责人](/agents) —— 什么时候由智能体当 lead 合适
|
||||
- [Multica 怎么运转](/how-multica-works) —— 整体视图
|
||||
@@ -63,11 +63,13 @@ Automatic retry also has two extra conditions:
|
||||
|
||||
<Callout type="warning">
|
||||
**Autopilot tasks don't retry automatically** by design. An Autopilot has its own firing cadence (e.g. daily); automatic retries on failure would overlap with the next scheduled run. If you need an immediate re-run after failure, use a manual rerun (next section).
|
||||
|
||||
**How you'll know an Autopilot task failed**: a notification lands in your [Inbox](/inbox), and the associated issue's status reverts from `in_progress` back to `todo`. The [Autopilots](/autopilots) page also shows the latest run result per autopilot.
|
||||
</Callout>
|
||||
|
||||
## Manual rerun vs. automatic retry
|
||||
|
||||
A **manual rerun** is one you trigger from the UI or CLI:
|
||||
A **manual rerun** is one you trigger from the CLI or the API (`POST /api/issues/{id}/rerun`):
|
||||
|
||||
```bash
|
||||
multica issue rerun <issue-id>
|
||||
@@ -75,9 +77,10 @@ multica issue rerun <issue-id>
|
||||
|
||||
Behavior:
|
||||
|
||||
- **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
|
||||
- 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.)
|
||||
|
||||
Comparison:
|
||||
|
||||
@@ -85,8 +88,9 @@ Comparison:
|
||||
|---|---|---|
|
||||
| Trigger | System, based on failure reason | You, manually |
|
||||
| Ceiling | 2 attempts | No limit |
|
||||
| Applicable sources | Issues, chat | All sources |
|
||||
| Session inheritance | Yes | Yes |
|
||||
| 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) |
|
||||
|
||||
## How a failed task affects issue status
|
||||
|
||||
@@ -96,7 +100,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 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.
|
||||
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).
|
||||
|
||||
But **which AI coding tools actually support this** varies a lot:
|
||||
|
||||
|
||||
@@ -63,11 +63,13 @@ Multica 服务器每 30 秒扫描一次,有两种超时会触发失败:
|
||||
|
||||
<Callout type="warning">
|
||||
**Autopilots 任务不自动重试**是刻意设计。Autopilot 有自己的触发周期(例如每天一次);如果失败又自动重试,会和下一个周期的任务重叠。需要失败后立即重跑,用手动重跑(下一节)。
|
||||
|
||||
**怎么知道 Autopilot 失败了**:失败的 Autopilot 任务会在你的 [收件箱](/inbox) 里出现一条通知,关联的 issue 状态也会从 `in_progress` 退回 `todo`。直接打开 [Autopilots](/autopilots) 页面也能看到每条 autopilot 的最近运行结果。
|
||||
</Callout>
|
||||
|
||||
## 手动重跑和自动重试的区别
|
||||
|
||||
**手动重跑**(rerun)是你从 UI 或命令行主动发起的:
|
||||
**手动重跑**(rerun)是你通过命令行或 API(`POST /api/issues/{id}/rerun`)主动发起的:
|
||||
|
||||
```bash
|
||||
multica issue rerun <issue-id>
|
||||
@@ -75,9 +77,10 @@ multica issue rerun <issue-id>
|
||||
|
||||
行为:
|
||||
|
||||
- **取消**当前正在跑的任务(如果有)
|
||||
- 创建一个**全新**的执行任务——尝试次数重置为 1,即使原任务已达最大尝试
|
||||
- 继承上一次的会话 ID;如果对应的 AI 编程工具支持会话恢复,会接着上次的上下文继续
|
||||
- 跑的是 issue **当前的智能体分配人**——不是上一次跑过的 agent。如果分配人在上次运行后改了,rerun 会跟着新的分配人走。要重跑一个已经不再是分配人的智能体,先把 issue 改派回它,再 rerun。
|
||||
- **取消**该分配人在这条 issue 上 queued / running 的任务(如果有)。同 issue 上其它 agent 的任务(例如 @-mention 触发的并行任务)不会被一起取消。
|
||||
- 创建一个**全新**的执行任务——尝试次数重置为 1,即使原任务已达最大尝试。
|
||||
- 启动**全新的智能体会话**——**不**继承之前的会话 ID。手动重跑意味着你已经判定上一次的产出不行,再继续之前的对话只会重放被污染的上下文。(自动重试则相反,会继承会话——那条路径处理的是基础设施层面的失败,不是产出不好。)
|
||||
|
||||
对比:
|
||||
|
||||
@@ -85,8 +88,9 @@ multica issue rerun <issue-id>
|
||||
|---|---|---|
|
||||
| 触发 | 系统基于失败原因自动执行 | 你主动发起 |
|
||||
| 上限 | 2 次 | 无上限 |
|
||||
| 适用来源 | issue、聊天 | 所有来源 |
|
||||
| 会话继承 | 是 | 是 |
|
||||
| 适用来源 | issue、聊天 | 有智能体分配人的 issue |
|
||||
| 跑哪个 agent | 失败任务原本的 agent | issue 当前的分配人 |
|
||||
| 会话继承 | 是(接着上次会话) | 否(全新会话) |
|
||||
|
||||
## 失败的任务对 issue 状态有什么影响
|
||||
|
||||
@@ -96,7 +100,7 @@ multica issue rerun <issue-id>
|
||||
|
||||
可以——前提是对应的 AI 编程工具支持会话恢复。
|
||||
|
||||
Multica 在任务过程中**两次**保存会话 ID——任务一开始(AI 工具返回第一条系统消息时)pin 一次,任务结束(完成或失败)时再 pin 一次。前者让守护进程中途崩溃时也能恢复,后者给之后的重跑用。下次重跑或自动重试时把这个 ID 传回去,智能体就能接着上次的对话、文件状态继续。
|
||||
Multica 在任务过程中**两次**保存会话 ID——任务一开始(AI 工具返回第一条系统消息时)pin 一次,任务结束(完成或失败)时再 pin 一次。前者让守护进程中途崩溃时也能恢复,后者留给下一次**自动重试**——届时把这个 ID 传回去,智能体就能接着上次的对话和文件状态继续。**手动重跑会主动跳过这一步**,永远从全新会话开始——见 [手动重跑和自动重试的区别](#手动重跑和自动重试的区别)。
|
||||
|
||||
但**哪些 AI 编程工具真的支持**差别很大:
|
||||
|
||||
|
||||
52
apps/docs/lib/locale-link.test.ts
Normal file
52
apps/docs/lib/locale-link.test.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { prefixLocale } from "./locale-link";
|
||||
|
||||
describe("prefixLocale", () => {
|
||||
it("prefixes root-relative paths with the active non-default locale", () => {
|
||||
expect(prefixLocale("/workspaces", "zh")).toBe("/zh/workspaces");
|
||||
expect(prefixLocale("/agents-create", "zh")).toBe("/zh/agents-create");
|
||||
});
|
||||
|
||||
it("preserves anchors and query strings on prefixed paths", () => {
|
||||
expect(prefixLocale("/providers#claude-code", "zh")).toBe(
|
||||
"/zh/providers#claude-code",
|
||||
);
|
||||
expect(prefixLocale("/agents?from=docs", "zh")).toBe(
|
||||
"/zh/agents?from=docs",
|
||||
);
|
||||
});
|
||||
|
||||
it("rewrites the bare root path to the locale root", () => {
|
||||
expect(prefixLocale("/", "zh")).toBe("/zh");
|
||||
});
|
||||
|
||||
it("leaves the default language untouched (URLs are prefix-less)", () => {
|
||||
expect(prefixLocale("/workspaces", "en")).toBe("/workspaces");
|
||||
expect(prefixLocale("/", "en")).toBe("/");
|
||||
});
|
||||
|
||||
it("does not double-prefix paths that already carry a known locale", () => {
|
||||
expect(prefixLocale("/zh/workspaces", "zh")).toBe("/zh/workspaces");
|
||||
expect(prefixLocale("/en/workspaces", "zh")).toBe("/en/workspaces");
|
||||
});
|
||||
|
||||
it("leaves external URLs alone", () => {
|
||||
expect(prefixLocale("https://multica.ai/download", "zh")).toBe(
|
||||
"https://multica.ai/download",
|
||||
);
|
||||
expect(prefixLocale("mailto:hello@multica.ai", "zh")).toBe(
|
||||
"mailto:hello@multica.ai",
|
||||
);
|
||||
expect(prefixLocale("tel:+1234567890", "zh")).toBe("tel:+1234567890");
|
||||
});
|
||||
|
||||
it("leaves in-page anchors and relative paths alone", () => {
|
||||
expect(prefixLocale("#section", "zh")).toBe("#section");
|
||||
expect(prefixLocale("./sibling", "zh")).toBe("./sibling");
|
||||
expect(prefixLocale("../sibling", "zh")).toBe("../sibling");
|
||||
});
|
||||
|
||||
it("returns empty/undefined hrefs unchanged", () => {
|
||||
expect(prefixLocale("", "zh")).toBe("");
|
||||
});
|
||||
});
|
||||
31
apps/docs/lib/locale-link.ts
Normal file
31
apps/docs/lib/locale-link.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { i18n } from "./i18n";
|
||||
|
||||
// Add the active locale prefix to root-relative MDX links so internal
|
||||
// navigation inside Chinese (or any non-default-language) docs stays in
|
||||
// that language. Without this, `[xx](/workspaces)` written in a `*.zh.mdx`
|
||||
// renders as `<a href="/workspaces">`, which Next's basePath rewrites to
|
||||
// `/docs/workspaces` and the docs middleware then routes to English —
|
||||
// leaking the reader out of their chosen locale.
|
||||
//
|
||||
// We deliberately do NOT touch:
|
||||
// - external links (`https:`, `mailto:`, `tel:`, etc.)
|
||||
// - in-page anchors (`#section`)
|
||||
// - relative paths (`./foo`, `../bar`)
|
||||
// - paths already prefixed with a known locale
|
||||
// - the default language (URLs are intentionally prefix-less under
|
||||
// `hideLocale: 'default-locale'`)
|
||||
export function prefixLocale(href: string, lang: string): string {
|
||||
if (!href) return href;
|
||||
if (lang === i18n.defaultLanguage) return href;
|
||||
if (/^[a-z][a-z0-9+.-]*:/i.test(href)) return href;
|
||||
if (href.startsWith("#")) return href;
|
||||
if (!href.startsWith("/")) return href;
|
||||
|
||||
const segments = href.split("/").filter(Boolean);
|
||||
const first = segments[0];
|
||||
if (first && (i18n.languages as readonly string[]).includes(first)) {
|
||||
return href;
|
||||
}
|
||||
|
||||
return href === "/" ? `/${lang}` : `/${lang}${href}`;
|
||||
}
|
||||
@@ -8,6 +8,7 @@
|
||||
"build": "fumadocs-mdx && next build",
|
||||
"start": "next start",
|
||||
"typecheck": "fumadocs-mdx && tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"postinstall": "fumadocs-mdx"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -27,6 +28,7 @@
|
||||
"@types/react": "catalog:",
|
||||
"@types/react-dom": "catalog:",
|
||||
"tailwindcss": "catalog:",
|
||||
"typescript": "catalog:"
|
||||
"typescript": "catalog:",
|
||||
"vitest": "catalog:"
|
||||
}
|
||||
}
|
||||
|
||||
16
apps/docs/vitest.config.ts
Normal file
16
apps/docs/vitest.config.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
import path from "path";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: "node",
|
||||
globals: true,
|
||||
include: ["**/*.test.{ts,tsx}"],
|
||||
exclude: ["node_modules/**", ".next/**", ".source/**"],
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "."),
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
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,
|
||||
@@ -9,5 +10,9 @@ export default function IssueDetailPage({
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = use(params);
|
||||
return <IssueDetail issueId={id} />;
|
||||
return (
|
||||
<ErrorBoundary resetKeys={[id]}>
|
||||
<IssueDetail issueId={id} />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { IssuesPage } from "@multica/views/issues/components";
|
||||
import { ErrorBoundary } from "@multica/ui/components/common/error-boundary";
|
||||
|
||||
export default function Page() {
|
||||
return <IssuesPage />;
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<IssuesPage />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -39,7 +39,6 @@
|
||||
--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;
|
||||
|
||||
@@ -94,7 +94,7 @@ export function createEnDict(allowSignup: boolean): LandingDict {
|
||||
label: "RUNTIMES",
|
||||
title: "One dashboard for all your compute",
|
||||
description:
|
||||
"Local daemons and cloud runtimes, managed from a single panel. Real-time monitoring of online/offline status, usage charts, and activity heatmaps. Auto-detects local CLIs \u2014 plug in and go.",
|
||||
"Local daemons and cloud runtimes, managed from a single panel. Real-time monitoring of online/offline status, usage charts, and activity heatmaps. Auto-detects 11 supported coding tools on your machine.",
|
||||
cards: [
|
||||
{
|
||||
title: "Unified runtime panel",
|
||||
@@ -107,9 +107,9 @@ export function createEnDict(allowSignup: boolean): LandingDict {
|
||||
"Online/offline status, usage charts, and activity heatmaps. Know exactly what your compute is doing at any moment.",
|
||||
},
|
||||
{
|
||||
title: "Auto-detection & plug-and-play",
|
||||
title: "Auto-detection on first run",
|
||||
description:
|
||||
"Multica detects available CLIs like Claude Code, Codex, OpenClaw, and OpenCode automatically. Connect a machine, and it\u2019s ready to work.",
|
||||
"Multica scans for 11 supported coding tools \u2014 Claude Code, Codex, Cursor, Copilot, Gemini, Hermes, Kimi, Kiro CLI, OpenCode, OpenClaw, and Pi \u2014 and registers a runtime for each one it finds.",
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -129,7 +129,7 @@ export function createEnDict(allowSignup: boolean): LandingDict {
|
||||
{
|
||||
title: "Install the CLI & connect your machine",
|
||||
description:
|
||||
"Run multica setup to configure, authenticate, and start the daemon. It auto-detects Claude Code, Codex, OpenClaw, and OpenCode on your machine \u2014 plug in and go.",
|
||||
"Run multica setup \u2014 it walks you through OAuth, starts the daemon, and scans for the 11 supported coding tools (Claude Code, Codex, Cursor, Copilot, Gemini, Hermes, Kimi, Kiro CLI, OpenCode, OpenClaw, Pi). Whichever ones you already have installed get registered as runtimes automatically.",
|
||||
},
|
||||
{
|
||||
title: "Create your first agent",
|
||||
@@ -185,7 +185,7 @@ export function createEnDict(allowSignup: boolean): LandingDict {
|
||||
{
|
||||
question: "What coding agents does Multica support?",
|
||||
answer:
|
||||
"Multica currently supports Claude Code, Codex, OpenClaw, and OpenCode out of the box. The daemon auto-detects whichever CLIs you have installed. Since it\u2019s open source, you can also add your own backends.",
|
||||
"Multica supports 11 coding tools out of the box: Claude Code, Codex, Cursor, Copilot, Gemini, Hermes, Kimi, Kiro CLI, OpenCode, OpenClaw, and Pi. The daemon auto-detects whichever CLIs you already have installed and registers a runtime for each one. Since it's open source, you can also add your own backends.",
|
||||
},
|
||||
{
|
||||
question: "Do I need to self-host, or is there a cloud version?",
|
||||
@@ -283,6 +283,127 @@ 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",
|
||||
title: "Full i18n Rollout, Long-Issue Timeline & System Notifications Toggle",
|
||||
changes: [],
|
||||
features: [
|
||||
"Web app fully translated to Simplified Chinese (21 namespaces), with per-user locale",
|
||||
"System Notifications toggle in Settings",
|
||||
"Delete chat sessions; History panel surfaced on the chat header",
|
||||
"Runtime liveness backed by Redis, with DB fallback",
|
||||
"Desktop loads runtime self-host config",
|
||||
"CLI adds `--assignee-id` / `--to-id` / `--user-id` for unambiguous targeting",
|
||||
],
|
||||
improvements: [
|
||||
"Settings 'Appearance' tab is renamed to 'Preferences', and the active tab is reflected in the URL so deep links work",
|
||||
"Long issues open instantly — Timeline switched to cursor-based keyset pagination, and repeated `task_completed` / `task_failed` activity entries are coalesced",
|
||||
"Runtime poll and heartbeat schedules are isolated per-runtime, so one busy runtime can no longer starve others",
|
||||
"CLI update requests persist in Redis, so a server restart no longer drops them",
|
||||
"Runtime cost usage window narrowed from 180 days to 14 days, dropping query load",
|
||||
"Project list returns a `resource_count` instead of inlining all resources, keeping responses lean",
|
||||
"404 page redesigned, with the No-Access redirect loop fixed",
|
||||
"Quick Create exempts git-describe daemons from the CLI version gate",
|
||||
"CI now enforces lint on every PR, and the existing lint debt has been cleared",
|
||||
],
|
||||
fixes: [
|
||||
"Daemon cancels the running agent when the task is deleted server-side, eliminating orphan processes",
|
||||
"Daemon refreshes a stale Codex `auth.json` when reusing an exec env, fixing intermittent auth errors",
|
||||
"Daemon refuses to write `.gc_meta.json` when `issue_id` is empty",
|
||||
"Session / resume across ACP backends now trusts the agent-reported session id, fixing cross-session bleed",
|
||||
"OpenCode skills are written under `.opencode/skills/` so they are discovered natively",
|
||||
"404 task-not-found semantics tightened on both server and the final guard",
|
||||
"Pinned sidebar rows are auto-unpinned when the underlying entity disappears",
|
||||
"Project detail page splits desktop and mobile sidebar state",
|
||||
"Runtime detail page hides archived agents",
|
||||
"Already-attached repos in Add Resource show a URL tooltip; empty project state has a New Issue button",
|
||||
"S3 public URLs are region-qualified, fixing cross-region access",
|
||||
"Windows installer parses version numbers and decodes checksums correctly",
|
||||
"Quick Create submit button no longer shows a duplicate keyboard shortcut",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.2.24",
|
||||
date: "2026-05-03",
|
||||
|
||||
@@ -13,42 +13,42 @@ export function createZhDict(allowSignup: boolean): LandingDict {
|
||||
headlineLine1: "\u4f60\u7684\u4e0b\u4e00\u6279\u5458\u5de5",
|
||||
headlineLine2: "\u4e0d\u662f\u4eba\u7c7b\u3002",
|
||||
subheading:
|
||||
"Multica \u662f\u4e00\u4e2a\u5f00\u6e90\u5e73\u53f0\uff0c\u5c06\u7f16\u7801 Agent \u53d8\u6210\u771f\u6b63\u7684\u961f\u53cb\u3002\u5206\u914d\u4efb\u52a1\u3001\u8ddf\u8e2a\u8fdb\u5ea6\u3001\u79ef\u7d2f\u6280\u80fd\u2014\u2014\u5728\u4e00\u4e2a\u5730\u65b9\u7ba1\u7406\u4f60\u7684\u4eba\u7c7b + Agent \u56e2\u961f\u3002",
|
||||
"Multica \u662f\u4e00\u4e2a\u5f00\u6e90\u5e73\u53f0\uff0c\u5c06\u7f16\u7801 智能体 \u53d8\u6210\u771f\u6b63\u7684\u961f\u53cb\u3002\u5206\u914d\u4efb\u52a1\u3001\u8ddf\u8e2a\u8fdb\u5ea6\u3001\u79ef\u7d2f\u6280\u80fd\u2014\u2014\u5728\u4e00\u4e2a\u5730\u65b9\u7ba1\u7406\u4f60\u7684\u4eba\u7c7b + 智能体 \u56e2\u961f\u3002",
|
||||
cta: "免费开始",
|
||||
downloadDesktop: "下载桌面端",
|
||||
worksWith: "支持",
|
||||
imageAlt: "Multica \u770b\u677f\u89c6\u56fe\u2014\u2014\u4eba\u7c7b\u548c Agent \u534f\u540c\u7ba1\u7406\u4efb\u52a1",
|
||||
imageAlt: "Multica \u770b\u677f\u89c6\u56fe\u2014\u2014\u4eba\u7c7b\u548c 智能体 \u534f\u540c\u7ba1\u7406\u4efb\u52a1",
|
||||
},
|
||||
|
||||
features: {
|
||||
teammates: {
|
||||
label: "\u56e2\u961f\u534f\u4f5c",
|
||||
title: "\u50cf\u5206\u914d\u7ed9\u540c\u4e8b\u4e00\u6837\u5206\u914d\u7ed9 Agent",
|
||||
title: "\u50cf\u5206\u914d\u7ed9\u540c\u4e8b\u4e00\u6837\u5206\u914d\u7ed9 智能体",
|
||||
description:
|
||||
"Agent \u4e0d\u662f\u88ab\u52a8\u5de5\u5177\u2014\u2014\u5b83\u4eec\u662f\u4e3b\u52a8\u53c2\u4e0e\u8005\u3002\u5b83\u4eec\u62e5\u6709\u4e2a\u4eba\u8d44\u6599\u3001\u62a5\u544a\u72b6\u6001\u3001\u521b\u5efa Issue\u3001\u53d1\u8868\u8bc4\u8bba\u3001\u66f4\u65b0\u72b6\u6001\u3002\u4f60\u7684\u6d3b\u52a8\u6d41\u5c55\u793a\u4eba\u7c7b\u548c Agent \u5e76\u80a9\u5de5\u4f5c\u3002",
|
||||
"智能体 \u4e0d\u662f\u88ab\u52a8\u5de5\u5177\u2014\u2014\u5b83\u4eec\u662f\u4e3b\u52a8\u53c2\u4e0e\u8005\u3002\u5b83\u4eec\u62e5\u6709\u4e2a\u4eba\u8d44\u6599\u3001\u62a5\u544a\u72b6\u6001\u3001\u521b\u5efa Issue\u3001\u53d1\u8868\u8bc4\u8bba\u3001\u66f4\u65b0\u72b6\u6001\u3002\u4f60\u7684\u6d3b\u52a8\u6d41\u5c55\u793a\u4eba\u7c7b\u548c 智能体 \u5e76\u80a9\u5de5\u4f5c\u3002",
|
||||
cards: [
|
||||
{
|
||||
title: "Agent \u51fa\u73b0\u5728\u6307\u6d3e\u4eba\u9009\u62e9\u5668\u4e2d",
|
||||
title: "智能体 \u51fa\u73b0\u5728\u6307\u6d3e\u4eba\u9009\u62e9\u5668\u4e2d",
|
||||
description:
|
||||
"\u4eba\u7c7b\u548c Agent \u51fa\u73b0\u5728\u540c\u4e00\u4e2a\u4e0b\u62c9\u83dc\u5355\u91cc\u3002\u628a\u4efb\u52a1\u5206\u914d\u7ed9 Agent \u548c\u5206\u914d\u7ed9\u540c\u4e8b\u6ca1\u6709\u4efb\u4f55\u533a\u522b\u3002",
|
||||
"\u4eba\u7c7b\u548c 智能体 \u51fa\u73b0\u5728\u540c\u4e00\u4e2a\u4e0b\u62c9\u83dc\u5355\u91cc\u3002\u628a\u4efb\u52a1\u5206\u914d\u7ed9 智能体 \u548c\u5206\u914d\u7ed9\u540c\u4e8b\u6ca1\u6709\u4efb\u4f55\u533a\u522b\u3002",
|
||||
},
|
||||
{
|
||||
title: "\u81ea\u4e3b\u53c2\u4e0e",
|
||||
description:
|
||||
"Agent \u4e3b\u52a8\u521b\u5efa Issue\u3001\u53d1\u8868\u8bc4\u8bba\u3001\u66f4\u65b0\u72b6\u6001\u2014\u2014\u800c\u4e0d\u662f\u53ea\u5728\u88ab\u63d0\u793a\u65f6\u624d\u884c\u52a8\u3002",
|
||||
"智能体 \u4e3b\u52a8\u521b\u5efa Issue\u3001\u53d1\u8868\u8bc4\u8bba\u3001\u66f4\u65b0\u72b6\u6001\u2014\u2014\u800c\u4e0d\u662f\u53ea\u5728\u88ab\u63d0\u793a\u65f6\u624d\u884c\u52a8\u3002",
|
||||
},
|
||||
{
|
||||
title: "\u7edf\u4e00\u7684\u6d3b\u52a8\u65f6\u95f4\u7ebf",
|
||||
description:
|
||||
"\u6574\u4e2a\u56e2\u961f\u5171\u7528\u4e00\u4e2a\u6d3b\u52a8\u6d41\u3002\u4eba\u7c7b\u548c Agent \u7684\u64cd\u4f5c\u4ea4\u66ff\u5c55\u793a\uff0c\u4f60\u59cb\u7ec8\u77e5\u9053\u53d1\u751f\u4e86\u4ec0\u4e48\u3001\u662f\u8c01\u505a\u7684\u3002",
|
||||
"\u6574\u4e2a\u56e2\u961f\u5171\u7528\u4e00\u4e2a\u6d3b\u52a8\u6d41\u3002\u4eba\u7c7b\u548c 智能体 \u7684\u64cd\u4f5c\u4ea4\u66ff\u5c55\u793a\uff0c\u4f60\u59cb\u7ec8\u77e5\u9053\u53d1\u751f\u4e86\u4ec0\u4e48\u3001\u662f\u8c01\u505a\u7684\u3002",
|
||||
},
|
||||
],
|
||||
},
|
||||
autonomous: {
|
||||
label: "\u81ea\u4e3b\u6267\u884c",
|
||||
title: "\u8bbe\u7f6e\u540e\u65e0\u9700\u7ba1\u7406\u2014\u2014Agent \u5728\u4f60\u7761\u89c9\u65f6\u5de5\u4f5c",
|
||||
title: "\u8bbe\u7f6e\u540e\u65e0\u9700\u7ba1\u7406\u2014\u2014智能体 \u5728\u4f60\u7761\u89c9\u65f6\u5de5\u4f5c",
|
||||
description:
|
||||
"\u4e0d\u53ea\u662f\u63d0\u793a-\u54cd\u5e94\u3002\u5b8c\u6574\u7684\u4efb\u52a1\u751f\u547d\u5468\u671f\u7ba1\u7406\uff1a\u5165\u961f\u3001\u9886\u53d6\u3001\u542f\u52a8\u3001\u5b8c\u6210\u6216\u5931\u8d25\u3002Agent \u4e3b\u52a8\u62a5\u544a\u963b\u585e\uff0c\u4f60\u901a\u8fc7 WebSocket \u83b7\u53d6\u5b9e\u65f6\u8fdb\u5ea6\u3002",
|
||||
"\u4e0d\u53ea\u662f\u63d0\u793a-\u54cd\u5e94\u3002\u5b8c\u6574\u7684\u4efb\u52a1\u751f\u547d\u5468\u671f\u7ba1\u7406\uff1a\u5165\u961f\u3001\u9886\u53d6\u3001\u542f\u52a8\u3001\u5b8c\u6210\u6216\u5931\u8d25\u3002智能体 \u4e3b\u52a8\u62a5\u544a\u963b\u585e\uff0c\u4f60\u901a\u8fc7 WebSocket \u83b7\u53d6\u5b9e\u65f6\u8fdb\u5ea6\u3002",
|
||||
cards: [
|
||||
{
|
||||
title: "\u5b8c\u6574\u7684\u4efb\u52a1\u751f\u547d\u5468\u671f",
|
||||
@@ -58,12 +58,12 @@ export function createZhDict(allowSignup: boolean): LandingDict {
|
||||
{
|
||||
title: "\u4e3b\u52a8\u62a5\u544a\u963b\u585e",
|
||||
description:
|
||||
"\u5f53 Agent \u9047\u5230\u56f0\u96be\u65f6\uff0c\u4f1a\u7acb\u5373\u53d1\u51fa\u8b66\u62a5\u3002\u4e0d\u7528\u7b49\u51e0\u4e2a\u5c0f\u65f6\u540e\u624d\u53d1\u73b0\u4ec0\u4e48\u90fd\u6ca1\u53d1\u751f\u3002",
|
||||
"\u5f53 智能体 \u9047\u5230\u56f0\u96be\u65f6\uff0c\u4f1a\u7acb\u5373\u53d1\u51fa\u8b66\u62a5\u3002\u4e0d\u7528\u7b49\u51e0\u4e2a\u5c0f\u65f6\u540e\u624d\u53d1\u73b0\u4ec0\u4e48\u90fd\u6ca1\u53d1\u751f\u3002",
|
||||
},
|
||||
{
|
||||
title: "\u5b9e\u65f6\u8fdb\u5ea6\u63a8\u9001",
|
||||
description:
|
||||
"\u57fa\u4e8e WebSocket \u7684\u5b9e\u65f6\u66f4\u65b0\u3002\u5b9e\u65f6\u89c2\u770b Agent \u5de5\u4f5c\uff0c\u6216\u968f\u65f6\u67e5\u770b\u2014\u2014\u65f6\u95f4\u7ebf\u59cb\u7ec8\u662f\u6700\u65b0\u7684\u3002",
|
||||
"\u57fa\u4e8e WebSocket \u7684\u5b9e\u65f6\u66f4\u65b0\u3002\u5b9e\u65f6\u89c2\u770b 智能体 \u5de5\u4f5c\uff0c\u6216\u968f\u65f6\u67e5\u770b\u2014\u2014\u65f6\u95f4\u7ebf\u59cb\u7ec8\u662f\u6700\u65b0\u7684\u3002",
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -71,22 +71,22 @@ export function createZhDict(allowSignup: boolean): LandingDict {
|
||||
label: "\u6280\u80fd\u5e93",
|
||||
title: "\u6bcf\u4e2a\u89e3\u51b3\u65b9\u6848\u90fd\u6210\u4e3a\u5168\u56e2\u961f\u53ef\u590d\u7528\u7684\u6280\u80fd",
|
||||
description:
|
||||
"\u6280\u80fd\u662f\u53ef\u590d\u7528\u7684\u80fd\u529b\u5b9a\u4e49\u2014\u2014\u4ee3\u7801\u3001\u914d\u7f6e\u548c\u4e0a\u4e0b\u6587\u6253\u5305\u5728\u4e00\u8d77\u3002\u53ea\u9700\u7f16\u5199\u4e00\u6b21\uff0c\u56e2\u961f\u4e2d\u6bcf\u4e2a Agent \u90fd\u80fd\u4f7f\u7528\u3002\u4f60\u7684\u6280\u80fd\u5e93\u968f\u65f6\u95f4\u4e0d\u65ad\u79ef\u7d2f\u3002",
|
||||
"\u6280\u80fd\u662f\u53ef\u590d\u7528\u7684\u80fd\u529b\u5b9a\u4e49\u2014\u2014\u4ee3\u7801\u3001\u914d\u7f6e\u548c\u4e0a\u4e0b\u6587\u6253\u5305\u5728\u4e00\u8d77\u3002\u53ea\u9700\u7f16\u5199\u4e00\u6b21\uff0c\u56e2\u961f\u4e2d\u6bcf\u4e2a 智能体 \u90fd\u80fd\u4f7f\u7528\u3002\u4f60\u7684\u6280\u80fd\u5e93\u968f\u65f6\u95f4\u4e0d\u65ad\u79ef\u7d2f\u3002",
|
||||
cards: [
|
||||
{
|
||||
title: "\u53ef\u590d\u7528\u7684\u6280\u80fd\u5b9a\u4e49",
|
||||
description:
|
||||
"\u5c06\u77e5\u8bc6\u5c01\u88c5\u6210\u4efb\u4f55 Agent \u90fd\u80fd\u6267\u884c\u7684\u6280\u80fd\u3002\u90e8\u7f72\u5230\u6d4b\u8bd5\u73af\u5883\u3001\u7f16\u5199\u8fc1\u79fb\u3001\u5ba1\u67e5 PR\u2014\u2014\u5168\u90e8\u4ee3\u7801\u5316\u3002",
|
||||
"\u5c06\u77e5\u8bc6\u5c01\u88c5\u6210\u4efb\u4f55 智能体 \u90fd\u80fd\u6267\u884c\u7684\u6280\u80fd\u3002\u90e8\u7f72\u5230\u6d4b\u8bd5\u73af\u5883\u3001\u7f16\u5199\u8fc1\u79fb\u3001\u5ba1\u67e5 PR\u2014\u2014\u5168\u90e8\u4ee3\u7801\u5316\u3002",
|
||||
},
|
||||
{
|
||||
title: "\u5168\u56e2\u961f\u5171\u4eab",
|
||||
description:
|
||||
"\u4e00\u4e2a\u4eba\u7684\u6280\u80fd\u5c31\u662f\u6bcf\u4e2a Agent \u7684\u6280\u80fd\u3002\u7f16\u5199\u4e00\u6b21\uff0c\u5168\u56e2\u961f\u53d7\u76ca\u3002",
|
||||
"\u4e00\u4e2a\u4eba\u7684\u6280\u80fd\u5c31\u662f\u6bcf\u4e2a 智能体 \u7684\u6280\u80fd\u3002\u7f16\u5199\u4e00\u6b21\uff0c\u5168\u56e2\u961f\u53d7\u76ca\u3002",
|
||||
},
|
||||
{
|
||||
title: "\u590d\u5408\u589e\u957f",
|
||||
description:
|
||||
"\u7b2c 1 \u5929\uff1a\u4f60\u6559 Agent \u90e8\u7f72\u3002\u7b2c 30 \u5929\uff1a\u6bcf\u4e2a Agent \u90fd\u80fd\u90e8\u7f72\u3001\u5199\u6d4b\u8bd5\u3001\u505a\u4ee3\u7801\u5ba1\u67e5\u3002\u56e2\u961f\u80fd\u529b\u6307\u6570\u7ea7\u589e\u957f\u3002",
|
||||
"\u7b2c 1 \u5929\uff1a\u4f60\u6559 智能体 \u90e8\u7f72\u3002\u7b2c 30 \u5929\uff1a\u6bcf\u4e2a 智能体 \u90fd\u80fd\u90e8\u7f72\u3001\u5199\u6d4b\u8bd5\u3001\u505a\u4ee3\u7801\u5ba1\u67e5\u3002\u56e2\u961f\u80fd\u529b\u6307\u6570\u7ea7\u589e\u957f\u3002",
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -94,7 +94,7 @@ export function createZhDict(allowSignup: boolean): LandingDict {
|
||||
label: "\u8fd0\u884c\u65f6",
|
||||
title: "\u4e00\u4e2a\u63a7\u5236\u53f0\u7ba1\u7406\u6240\u6709\u7b97\u529b",
|
||||
description:
|
||||
"\u672c\u5730\u5b88\u62a4\u8fdb\u7a0b\u548c\u4e91\u7aef\u8fd0\u884c\u65f6\uff0c\u5728\u540c\u4e00\u4e2a\u9762\u677f\u4e2d\u7ba1\u7406\u3002\u5b9e\u65f6\u76d1\u63a7\u5728\u7ebf/\u79bb\u7ebf\u72b6\u6001\u3001\u4f7f\u7528\u91cf\u56fe\u8868\u548c\u6d3b\u52a8\u70ed\u529b\u56fe\u3002\u81ea\u52a8\u68c0\u6d4b\u672c\u5730 CLI\u2014\u2014\u63d2\u4e0a\u5c31\u7528\u3002",
|
||||
"\u672c\u5730\u5b88\u62a4\u8fdb\u7a0b\u548c\u4e91\u7aef\u8fd0\u884c\u65f6\uff0c\u5728\u540c\u4e00\u4e2a\u9762\u677f\u4e2d\u7ba1\u7406\u3002\u5b9e\u65f6\u76d1\u63a7\u5728\u7ebf/\u79bb\u7ebf\u72b6\u6001\u3001\u4f7f\u7528\u91cf\u56fe\u8868\u548c\u6d3b\u52a8\u70ed\u529b\u56fe\u3002\u81ea\u52a8\u68c0\u6d4b\u672c\u673a\u5df2\u5b89\u88c5\u7684 11 \u6b3e\u652f\u6301\u7684 AI \u7f16\u7a0b\u5de5\u5177\u3002",
|
||||
cards: [
|
||||
{
|
||||
title: "\u7edf\u4e00\u8fd0\u884c\u65f6\u9762\u677f",
|
||||
@@ -107,9 +107,9 @@ export function createZhDict(allowSignup: boolean): LandingDict {
|
||||
"\u5728\u7ebf/\u79bb\u7ebf\u72b6\u6001\u3001\u4f7f\u7528\u91cf\u56fe\u8868\u548c\u6d3b\u52a8\u70ed\u529b\u56fe\u3002\u968f\u65f6\u4e86\u89e3\u4f60\u7684\u7b97\u529b\u5728\u505a\u4ec0\u4e48\u3002",
|
||||
},
|
||||
{
|
||||
title: "\u81ea\u52a8\u68c0\u6d4b\u4e0e\u5373\u63d2\u5373\u7528",
|
||||
title: "\u9996\u6b21\u542f\u52a8\u81ea\u52a8\u6ce8\u518c",
|
||||
description:
|
||||
"Multica \u81ea\u52a8\u68c0\u6d4b Claude Code\u3001Codex\u3001OpenClaw \u548c OpenCode \u7b49\u53ef\u7528 CLI\u3002\u8fde\u63a5\u4e00\u53f0\u673a\u5668\uff0c\u5373\u53ef\u5f00\u59cb\u5de5\u4f5c\u3002",
|
||||
"Multica \u626b\u63cf\u672c\u673a\u7684 11 \u6b3e\u652f\u6301\u7684 AI \u7f16\u7a0b\u5de5\u5177\u2014\u2014Claude Code\u3001Codex\u3001Cursor\u3001Copilot\u3001Gemini\u3001Hermes\u3001Kimi\u3001Kiro CLI\u3001OpenCode\u3001OpenClaw\u3001Pi\u2014\u2014\u5e76\u4e3a\u6bcf\u6b3e\u5df2\u5b89\u88c5\u7684\u5de5\u5177\u6ce8\u518c\u4e00\u4e2a\u8fd0\u884c\u65f6\u3002",
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -129,17 +129,17 @@ export function createZhDict(allowSignup: boolean): LandingDict {
|
||||
{
|
||||
title: "\u5b89\u88c5 CLI \u5e76\u8fde\u63a5\u4f60\u7684\u673a\u5668",
|
||||
description:
|
||||
"运行 multica setup 一键完成配置、认证和启动。守护进程自动检测你机器上的 Claude Code、Codex、OpenClaw 和 OpenCode——插上就用。",
|
||||
"运行 multica setup——它会引导你完成 OAuth 登录、启动守护进程、并扫描 11 款支持的 AI 编程工具(Claude Code、Codex、Cursor、Copilot、Gemini、Hermes、Kimi、Kiro CLI、OpenCode、OpenClaw、Pi)。本机已安装的工具会被自动注册成运行时。",
|
||||
},
|
||||
{
|
||||
title: "\u521b\u5efa\u4f60\u7684\u7b2c\u4e00\u4e2a Agent",
|
||||
title: "\u521b\u5efa\u4f60\u7684\u7b2c\u4e00\u4e2a 智能体",
|
||||
description:
|
||||
"\u7ed9\u5b83\u8d77\u4e2a\u540d\u5b57\uff0c\u5199\u597d\u6307\u4ee4\uff0c\u9644\u52a0\u6280\u80fd\uff0c\u8bbe\u7f6e\u89e6\u53d1\u5668\u3002\u9009\u62e9\u5b83\u4f55\u65f6\u6fc0\u6d3b\uff1a\u88ab\u6307\u6d3e\u65f6\u3001\u6709\u8bc4\u8bba\u65f6\u3001\u88ab @\u63d0\u53ca\u65f6\u3002",
|
||||
},
|
||||
{
|
||||
title: "\u6307\u6d3e\u4e00\u4e2a Issue \u5e76\u89c2\u5bdf\u5b83\u5de5\u4f5c",
|
||||
description:
|
||||
"\u4ece\u6307\u6d3e\u4eba\u4e0b\u62c9\u83dc\u5355\u4e2d\u9009\u62e9\u4f60\u7684 Agent\u2014\u2014\u5c31\u50cf\u6307\u6d3e\u7ed9\u540c\u4e8b\u4e00\u6837\u3002\u4efb\u52a1\u81ea\u52a8\u5165\u961f\u3001\u9886\u53d6\u3001\u6267\u884c\u3002\u5b9e\u65f6\u89c2\u770b\u8fdb\u5ea6\u3002",
|
||||
"\u4ece\u6307\u6d3e\u4eba\u4e0b\u62c9\u83dc\u5355\u4e2d\u9009\u62e9\u4f60\u7684 智能体\u2014\u2014\u5c31\u50cf\u6307\u6d3e\u7ed9\u540c\u4e8b\u4e00\u6837\u3002\u4efb\u52a1\u81ea\u52a8\u5165\u961f\u3001\u9886\u53d6\u3001\u6267\u884c\u3002\u5b9e\u65f6\u89c2\u770b\u8fdb\u5ea6\u3002",
|
||||
},
|
||||
],
|
||||
cta: "\u5f00\u59cb\u4f7f\u7528",
|
||||
@@ -152,7 +152,7 @@ export function createZhDict(allowSignup: boolean): LandingDict {
|
||||
headlineLine1: "\u5f00\u6e90",
|
||||
headlineLine2: "\u4e3a\u6240\u6709\u4eba\u3002",
|
||||
description:
|
||||
"Multica \u5b8c\u5168\u5f00\u6e90\u3002\u5ba1\u67e5\u6bcf\u4e00\u884c\u4ee3\u7801\uff0c\u6309\u4f60\u7684\u65b9\u5f0f\u81ea\u6258\u7ba1\uff0c\u5851\u9020\u4eba\u7c7b + Agent \u534f\u4f5c\u7684\u672a\u6765\u3002",
|
||||
"Multica \u5b8c\u5168\u5f00\u6e90\u3002\u5ba1\u67e5\u6bcf\u4e00\u884c\u4ee3\u7801\uff0c\u6309\u4f60\u7684\u65b9\u5f0f\u81ea\u6258\u7ba1\uff0c\u5851\u9020\u4eba\u7c7b + 智能体 \u534f\u4f5c\u7684\u672a\u6765\u3002",
|
||||
cta: "\u5728 GitHub \u4e0a Star",
|
||||
highlights: [
|
||||
{
|
||||
@@ -163,17 +163,17 @@ export function createZhDict(allowSignup: boolean): LandingDict {
|
||||
{
|
||||
title: "\u65e0\u4f9b\u5e94\u5546\u9501\u5b9a",
|
||||
description:
|
||||
"\u81ea\u5e26 LLM \u63d0\u4f9b\u5546\u3001\u66f4\u6362 Agent \u540e\u7aef\u3001\u6269\u5c55 API\u3002\u4f60\u62e5\u6709\u6574\u4e2a\u6280\u672f\u6808\u7684\u63a7\u5236\u6743\u3002",
|
||||
"\u81ea\u5e26 LLM \u63d0\u4f9b\u5546\u3001\u66f4\u6362 智能体 \u540e\u7aef\u3001\u6269\u5c55 API\u3002\u4f60\u62e5\u6709\u6574\u4e2a\u6280\u672f\u6808\u7684\u63a7\u5236\u6743\u3002",
|
||||
},
|
||||
{
|
||||
title: "\u9ed8\u8ba4\u900f\u660e",
|
||||
description:
|
||||
"\u6bcf\u4e00\u884c\u4ee3\u7801\u90fd\u53ef\u5ba1\u8ba1\u3002\u786e\u5207\u4e86\u89e3\u4f60\u7684 Agent \u5982\u4f55\u505a\u51b3\u7b56\u3001\u4efb\u52a1\u5982\u4f55\u8def\u7531\u3001\u6570\u636e\u6d41\u5411\u4f55\u65b9\u3002",
|
||||
"\u6bcf\u4e00\u884c\u4ee3\u7801\u90fd\u53ef\u5ba1\u8ba1\u3002\u786e\u5207\u4e86\u89e3\u4f60\u7684 智能体 \u5982\u4f55\u505a\u51b3\u7b56\u3001\u4efb\u52a1\u5982\u4f55\u8def\u7531\u3001\u6570\u636e\u6d41\u5411\u4f55\u65b9\u3002",
|
||||
},
|
||||
{
|
||||
title: "\u793e\u533a\u9a71\u52a8",
|
||||
description:
|
||||
"\u4e0e\u793e\u533a\u4e00\u8d77\u5efa\u8bbe\uff0c\u800c\u4e0d\u4ec5\u4ec5\u662f\u4e3a\u793e\u533a\u5efa\u8bbe\u3002\u8d21\u732e\u6280\u80fd\u3001\u96c6\u6210\u548c Agent \u540e\u7aef\uff0c\u8ba9\u6bcf\u4e2a\u4eba\u53d7\u76ca\u3002",
|
||||
"\u4e0e\u793e\u533a\u4e00\u8d77\u5efa\u8bbe\uff0c\u800c\u4e0d\u4ec5\u4ec5\u662f\u4e3a\u793e\u533a\u5efa\u8bbe\u3002\u8d21\u732e\u6280\u80fd\u3001\u96c6\u6210\u548c 智能体 \u540e\u7aef\uff0c\u8ba9\u6bcf\u4e2a\u4eba\u53d7\u76ca\u3002",
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -183,9 +183,9 @@ export function createZhDict(allowSignup: boolean): LandingDict {
|
||||
headline: "\u95ee\u4e0e\u7b54\u3002",
|
||||
items: [
|
||||
{
|
||||
question: "Multica \u652f\u6301\u54ea\u4e9b\u7f16\u7801 Agent\uff1f",
|
||||
question: "Multica \u652f\u6301\u54ea\u4e9b\u7f16\u7801 智能体\uff1f",
|
||||
answer:
|
||||
"Multica \u76ee\u524d\u5f00\u7bb1\u5373\u7528\u652f\u6301 Claude Code\u3001Codex\u3001OpenClaw \u548c OpenCode\u3002\u5b88\u62a4\u8fdb\u7a0b\u81ea\u52a8\u68c0\u6d4b\u4f60\u5b89\u88c5\u7684 CLI\u3002\u56e0\u4e3a\u5f00\u6e90\uff0c\u4f60\u4e5f\u53ef\u4ee5\u81ea\u5df1\u6dfb\u52a0\u540e\u7aef\u3002",
|
||||
"Multica \u5f00\u7bb1\u5373\u7528\u652f\u6301 11 \u6b3e AI \u7f16\u7a0b\u5de5\u5177\uff1aClaude Code\u3001Codex\u3001Cursor\u3001Copilot\u3001Gemini\u3001Hermes\u3001Kimi\u3001Kiro CLI\u3001OpenCode\u3001OpenClaw\u3001Pi\u3002\u5b88\u62a4\u8fdb\u7a0b\u4f1a\u81ea\u52a8\u68c0\u6d4b\u672c\u673a\u5df2\u5b89\u88c5\u7684 CLI \u5e76\u4e3a\u6bcf\u6b3e\u6ce8\u518c\u4e00\u4e2a\u8fd0\u884c\u65f6\u3002\u56e0\u4e3a\u5f00\u6e90\uff0c\u4f60\u4e5f\u53ef\u4ee5\u81ea\u5df1\u6dfb\u52a0\u540e\u7aef\u3002",
|
||||
},
|
||||
{
|
||||
question: "\u9700\u8981\u81ea\u6258\u7ba1\u5417\uff0c\u8fd8\u662f\u6709\u4e91\u7248\u672c\uff1f",
|
||||
@@ -194,31 +194,31 @@ export function createZhDict(allowSignup: boolean): LandingDict {
|
||||
},
|
||||
{
|
||||
question:
|
||||
"\u8fd9\u548c\u76f4\u63a5\u7528\u7f16\u7801 Agent \u6709\u4ec0\u4e48\u533a\u522b\uff1f",
|
||||
"\u8fd9\u548c\u76f4\u63a5\u7528\u7f16\u7801 智能体 \u6709\u4ec0\u4e48\u533a\u522b\uff1f",
|
||||
answer:
|
||||
"\u7f16\u7801 Agent \u64c5\u957f\u6267\u884c\u3002Multica \u6dfb\u52a0\u7684\u662f\u7ba1\u7406\u5c42\uff1a\u4efb\u52a1\u961f\u5217\u3001\u56e2\u961f\u534f\u4f5c\u3001\u6280\u80fd\u590d\u7528\u3001\u8fd0\u884c\u65f6\u76d1\u63a7\uff0c\u4ee5\u53ca\u6bcf\u4e2a Agent \u5728\u505a\u4ec0\u4e48\u7684\u7edf\u4e00\u89c6\u56fe\u3002\u628a\u5b83\u60f3\u8c61\u6210\u4f60\u7684 Agent \u7684\u9879\u76ee\u7ecf\u7406\u3002",
|
||||
"\u7f16\u7801 智能体 \u64c5\u957f\u6267\u884c\u3002Multica \u6dfb\u52a0\u7684\u662f\u7ba1\u7406\u5c42\uff1a\u4efb\u52a1\u961f\u5217\u3001\u56e2\u961f\u534f\u4f5c\u3001\u6280\u80fd\u590d\u7528\u3001\u8fd0\u884c\u65f6\u76d1\u63a7\uff0c\u4ee5\u53ca\u6bcf\u4e2a 智能体 \u5728\u505a\u4ec0\u4e48\u7684\u7edf\u4e00\u89c6\u56fe\u3002\u628a\u5b83\u60f3\u8c61\u6210\u4f60\u7684 智能体 \u7684\u9879\u76ee\u7ecf\u7406\u3002",
|
||||
},
|
||||
{
|
||||
question: "Agent \u80fd\u81ea\u4e3b\u5904\u7406\u957f\u65f6\u95f4\u4efb\u52a1\u5417\uff1f",
|
||||
question: "智能体 \u80fd\u81ea\u4e3b\u5904\u7406\u957f\u65f6\u95f4\u4efb\u52a1\u5417\uff1f",
|
||||
answer:
|
||||
"\u53ef\u4ee5\u3002Multica \u7ba1\u7406\u5b8c\u6574\u7684\u4efb\u52a1\u751f\u547d\u5468\u671f\u2014\u2014\u5165\u961f\u3001\u9886\u53d6\u3001\u6267\u884c\u3001\u5b8c\u6210\u6216\u5931\u8d25\u3002Agent \u4e3b\u52a8\u62a5\u544a\u963b\u585e\u5e76\u5b9e\u65f6\u63a8\u9001\u8fdb\u5ea6\u3002\u4f60\u53ef\u4ee5\u968f\u65f6\u67e5\u770b\uff0c\u4e5f\u53ef\u4ee5\u8ba9\u5b83\u4eec\u8fd0\u884c\u6574\u665a\u3002",
|
||||
"\u53ef\u4ee5\u3002Multica \u7ba1\u7406\u5b8c\u6574\u7684\u4efb\u52a1\u751f\u547d\u5468\u671f\u2014\u2014\u5165\u961f\u3001\u9886\u53d6\u3001\u6267\u884c\u3001\u5b8c\u6210\u6216\u5931\u8d25\u3002智能体 \u4e3b\u52a8\u62a5\u544a\u963b\u585e\u5e76\u5b9e\u65f6\u63a8\u9001\u8fdb\u5ea6\u3002\u4f60\u53ef\u4ee5\u968f\u65f6\u67e5\u770b\uff0c\u4e5f\u53ef\u4ee5\u8ba9\u5b83\u4eec\u8fd0\u884c\u6574\u665a\u3002",
|
||||
},
|
||||
{
|
||||
question: "\u6211\u7684\u4ee3\u7801\u5b89\u5168\u5417\uff1fAgent \u5728\u54ea\u91cc\u6267\u884c\uff1f",
|
||||
question: "\u6211\u7684\u4ee3\u7801\u5b89\u5168\u5417\uff1f智能体 \u5728\u54ea\u91cc\u6267\u884c\uff1f",
|
||||
answer:
|
||||
"Agent \u5728\u4f60\u7684\u673a\u5668\uff08\u672c\u5730\u5b88\u62a4\u8fdb\u7a0b\uff09\u6216\u4f60\u81ea\u5df1\u7684\u4e91\u57fa\u7840\u8bbe\u65bd\u4e0a\u6267\u884c\u3002\u4ee3\u7801\u6c38\u8fdc\u4e0d\u4f1a\u7ecf\u8fc7 Multica \u670d\u52a1\u5668\u3002\u5e73\u53f0\u53ea\u534f\u8c03\u4efb\u52a1\u72b6\u6001\u548c\u5e7f\u64ad\u4e8b\u4ef6\u3002",
|
||||
"智能体 \u5728\u4f60\u7684\u673a\u5668\uff08\u672c\u5730\u5b88\u62a4\u8fdb\u7a0b\uff09\u6216\u4f60\u81ea\u5df1\u7684\u4e91\u57fa\u7840\u8bbe\u65bd\u4e0a\u6267\u884c\u3002\u4ee3\u7801\u6c38\u8fdc\u4e0d\u4f1a\u7ecf\u8fc7 Multica \u670d\u52a1\u5668\u3002\u5e73\u53f0\u53ea\u534f\u8c03\u4efb\u52a1\u72b6\u6001\u548c\u5e7f\u64ad\u4e8b\u4ef6\u3002",
|
||||
},
|
||||
{
|
||||
question: "\u6211\u53ef\u4ee5\u8fd0\u884c\u591a\u5c11\u4e2a Agent\uff1f",
|
||||
question: "\u6211\u53ef\u4ee5\u8fd0\u884c\u591a\u5c11\u4e2a 智能体\uff1f",
|
||||
answer:
|
||||
"\u53d6\u51b3\u4e8e\u4f60\u7684\u786c\u4ef6\u3002\u6bcf\u4e2a Agent \u6709\u53ef\u914d\u7f6e\u7684\u5e76\u53d1\u9650\u5236\uff0c\u4f60\u53ef\u4ee5\u8fde\u63a5\u591a\u53f0\u673a\u5668\u4f5c\u4e3a\u8fd0\u884c\u65f6\u3002\u5f00\u6e90\u7248\u672c\u6ca1\u6709\u4efb\u4f55\u4eba\u4e3a\u9650\u5236\u3002",
|
||||
"\u53d6\u51b3\u4e8e\u4f60\u7684\u786c\u4ef6\u3002\u6bcf\u4e2a 智能体 \u6709\u53ef\u914d\u7f6e\u7684\u5e76\u53d1\u9650\u5236\uff0c\u4f60\u53ef\u4ee5\u8fde\u63a5\u591a\u53f0\u673a\u5668\u4f5c\u4e3a\u8fd0\u884c\u65f6\u3002\u5f00\u6e90\u7248\u672c\u6ca1\u6709\u4efb\u4f55\u4eba\u4e3a\u9650\u5236\u3002",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
footer: {
|
||||
tagline:
|
||||
"\u4eba\u7c7b + Agent \u56e2\u961f\u7684\u9879\u76ee\u7ba1\u7406\u3002\u5f00\u6e90\u3001\u53ef\u81ea\u6258\u7ba1\u3001\u4e3a\u672a\u6765\u7684\u5de5\u4f5c\u65b9\u5f0f\u800c\u5efa\u3002",
|
||||
"\u4eba\u7c7b + 智能体 \u56e2\u961f\u7684\u9879\u76ee\u7ba1\u7406\u3002\u5f00\u6e90\u3001\u53ef\u81ea\u6258\u7ba1\u3001\u4e3a\u672a\u6765\u7684\u5de5\u4f5c\u65b9\u5f0f\u800c\u5efa\u3002",
|
||||
cta: "\u5f00\u59cb\u4f7f\u7528",
|
||||
groups: {
|
||||
product: {
|
||||
@@ -283,6 +283,127 @@ 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 前后端共享同一份 JSON,CI 守住漂移",
|
||||
"ACP 错误消息现在带上 JSON-RPC 的 `error.data` 字段,排错更友好",
|
||||
],
|
||||
fixes: [
|
||||
"429 / 余额不足的 agent run 现在被标记为 `failed` 而不是 `completed`",
|
||||
"因 poisoned image 卡死的 agent session 可以恢复,issue 不再卡住",
|
||||
"`pi --list-models` 表格格式可被正确解析,模型发现恢复",
|
||||
"`pi` colon-to-slash 归一化只作用于 legacy 格式,避免误伤新格式",
|
||||
"`kiro` 与 `kimi` 加入 inline-system-prompt provider 白名单",
|
||||
"Priority Dropdown 徽章颜色对齐 PriorityIcon 的 semantic token",
|
||||
"Agent 单行长消息可正常展开",
|
||||
"桌面端复制 issue link 使用当前连接环境,不再硬编码 localhost",
|
||||
"移动端 WebSocket 在没有 cookie 的情况下也能握手",
|
||||
"创建 workspace 时校验保留字,slug 错误提示已 i18n",
|
||||
"Timeline 在 falsy prop 切换时正确同步 around 状态",
|
||||
"DropdownMenu 弹层尺寸跟随内容",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.2.28",
|
||||
date: "2026-05-08",
|
||||
title: "Daemon 磁盘占用 CLI、Timeline 打磨与任务用量聚合提速",
|
||||
changes: [],
|
||||
features: [
|
||||
"新增 `multica daemon disk-usage` CLI,按 task / workspace 维度查看磁盘占用",
|
||||
"Skill Picker 弹窗新增搜索框,Agent 设置里挑技能更快",
|
||||
"Daemon GC 覆盖扩展到 chat、autopilot、quick-create 任务",
|
||||
"Issue 详情页面包屑直接显示 MUL-xxxx identifier",
|
||||
],
|
||||
improvements: [
|
||||
"Timeline 分页 size 提到 50,评论与活动按池独立 keyset 游标,长 Issue 翻页更顺",
|
||||
"Show older / newer 按钮在边界场景也能正确出现,且视觉上更明显是可点击的",
|
||||
"服务端 `task_usage` 聚合到每日 rollup 表,DB 负载明显下降",
|
||||
"Daemon health check 在 repo 查询时不再阻塞,始终保持响应",
|
||||
"Runtime 统计排除已归档的 agent,活跃数字更准",
|
||||
],
|
||||
fixes: [
|
||||
"Linux 上 daemon self-restart 改走 `brew prefix` 软链,Homebrew Cellar 删除后不再让 runtime 失联",
|
||||
"CLI 短 ID 现在可以正确路由,复制粘贴的短前缀不再 404",
|
||||
"Windows 上非 ASCII 字符评论 / 描述输入新增 `--content-file` / `--description-file`",
|
||||
"Windows / Linux 桌面端用 Multica asterisk 替换 Electron 默认占位图标",
|
||||
"Timeline 中孤立的 reply 现在会被正确捞回展示",
|
||||
"Timeline 评论分页预算不再把 activity 算进去,避免活动多时挤掉真实评论",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.2.27",
|
||||
date: "2026-05-07",
|
||||
title: "Chat 更顺手,Skill 支持 GitHub 导入,稳定性更好",
|
||||
changes: [],
|
||||
features: [
|
||||
"支持直接通过 GitHub 链接导入可复用 Skill",
|
||||
],
|
||||
improvements: [
|
||||
"Chat 和 Inbox 更顺手,历史更清晰,复制回复更方便,归档后能更快处理下一项",
|
||||
"Issue 操作会保留更多上下文,例如更容易找到对应本地文件夹,子 Issue 也会带上正确的项目和状态",
|
||||
"Autopilot 连续失败后会自动暂停,异常自动化更容易发现和修复",
|
||||
],
|
||||
fixes: [
|
||||
"中文输入、桌面端升级、长 Issue 时间线和实时状态展示更稳定",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.2.26",
|
||||
date: "2026-05-06",
|
||||
title: "i18n 全量铺开、长 Issue Timeline 提速与系统通知开关",
|
||||
changes: [],
|
||||
features: [
|
||||
"Web 端完成简中翻译,21 个命名空间齐全,语言偏好按账号同步",
|
||||
"Settings 新增 System Notifications 开关",
|
||||
"支持删除 Chat 会话,History 面板移至 chat header",
|
||||
"Runtime 在线判断改走 Redis(DB 兜底)",
|
||||
"Desktop 支持加载 runtime 自托管配置",
|
||||
"CLI 新增 `--assignee-id` / `--to-id` / `--user-id`,重名时定位更准",
|
||||
],
|
||||
improvements: [
|
||||
"Settings 的 Appearance Tab 改名为 Preferences,并把当前激活的 Tab 反映到 URL,深链可分享",
|
||||
"长 Issue 打开秒开 —— Timeline 改为基于游标的 keyset 分页,重复的 `task_completed` / `task_failed` 活动条目合并展示",
|
||||
"Runtime poll 与 heartbeat 调度按 runtime 隔离,单个忙碌 runtime 不再拖慢其他",
|
||||
"CLI 更新请求落 Redis,server 重启也不丢",
|
||||
"Runtime 用量统计窗口由 180 天收窄到 14 天,降低查询压力",
|
||||
"项目列表返回 `resource_count` 摘要,不再内联全部 resource,响应体更小",
|
||||
"404 页面重新设计,并修复 No-Access 重定向死循环",
|
||||
"Quick Create 对 git-describe 类 daemon 跳过 CLI 版本闸",
|
||||
"CI 启用 lint 强制门禁,历史 lint 债同步清理完毕",
|
||||
],
|
||||
fixes: [
|
||||
"Task 在服务端被删后,daemon 主动取消正在运行的 agent,避免孤儿进程",
|
||||
"复用 execenv 时刷新陈旧的 Codex `auth.json`,修复偶发鉴权失败",
|
||||
"`issue_id` 为空时拒绝写入 `.gc_meta.json`",
|
||||
"跨 ACP 后端的 session/resume 信任 agent 自报的 session id,修复串号问题",
|
||||
"OpenCode 的 skills 写到 `.opencode/skills/` 让其原生发现",
|
||||
"Daemon 对 task-not-found 的 404 语义在 server 和最终 guard 双重收紧",
|
||||
"侧边栏中失效的 Pin 自动取消挂载",
|
||||
"项目详情页桌面端与移动端侧边栏状态独立保存",
|
||||
"Runtime 详情页隐藏已归档的 agent",
|
||||
"Add Resource 列表中已挂载的 repo 显示 URL tooltip;空项目页加上 New Issue 入口",
|
||||
"S3 公开 URL 携带 region,修复跨区访问失败",
|
||||
"Windows 安装器修正版本号解析与 checksum 解码",
|
||||
"Quick Create 提交按钮去掉重复的快捷键提示",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.2.24",
|
||||
date: "2026-05-03",
|
||||
|
||||
@@ -22,6 +22,8 @@ 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>;
|
||||
|
||||
@@ -14,6 +14,7 @@ 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
|
||||
@@ -82,6 +83,50 @@ 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`
|
||||
@@ -128,6 +173,8 @@ 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. |
|
||||
@@ -137,6 +184,118 @@ 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
|
||||
@@ -149,6 +308,11 @@ 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
|
||||
@@ -165,6 +329,10 @@ 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.
|
||||
@@ -188,6 +356,17 @@ 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
|
||||
@@ -226,6 +405,7 @@ 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. |
|
||||
|
||||
@@ -241,7 +421,8 @@ which exit the user took.
|
||||
|
||||
| Property | Type | Description |
|
||||
|---|---|---|
|
||||
| `completion_path` | string | One of `full` / `runtime_skipped` / `cloud_waitlist` / `skip_existing` / `unknown`. See below. |
|
||||
| `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. |
|
||||
| `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`:
|
||||
@@ -256,6 +437,7 @@ 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`
|
||||
@@ -314,11 +496,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` (`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
|
||||
(`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
|
||||
intent" is now better served by `download_intent_expressed` below —
|
||||
`path: "download_desktop"` signals Step 3 path choice specifically,
|
||||
not actual download start.
|
||||
@@ -334,8 +516,9 @@ 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`: `step3_desktop` (literal; reserved for a future web
|
||||
emission under a different value).
|
||||
- `source`: `onboarding`.
|
||||
- `surface`: `step3_desktop`.
|
||||
- `workspace_id`: current onboarding workspace.
|
||||
- `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
|
||||
@@ -419,6 +602,38 @@ 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:
|
||||
|
||||
@@ -13,7 +13,8 @@
|
||||
"test": "turbo test",
|
||||
"lint": "turbo lint",
|
||||
"clean": "turbo clean && rm -rf node_modules",
|
||||
"ui:add": "cd packages/ui && npx shadcn@latest add"
|
||||
"ui:add": "cd packages/ui && npx shadcn@latest add",
|
||||
"generate:reserved-slugs": "node scripts/generate-reserved-slugs.mjs"
|
||||
},
|
||||
"packageManager": "pnpm@10.28.2",
|
||||
"pnpm": {
|
||||
|
||||
@@ -45,20 +45,33 @@ 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" });
|
||||
expect(posthog.register).toHaveBeenCalledWith({
|
||||
client_type: "web",
|
||||
environment: "dev",
|
||||
event_schema_version: 2,
|
||||
is_demo: false,
|
||||
});
|
||||
});
|
||||
|
||||
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" });
|
||||
expect(posthog.register).toHaveBeenCalledWith({
|
||||
client_type: "desktop",
|
||||
environment: "dev",
|
||||
event_schema_version: 2,
|
||||
is_demo: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -76,6 +89,9 @@ describe("resetAnalytics", () => {
|
||||
expect(posthog.register).toHaveBeenCalledWith({
|
||||
client_type: "web",
|
||||
app_version: "1.2.3",
|
||||
environment: "dev",
|
||||
event_schema_version: 2,
|
||||
is_demo: false,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
|
||||
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
|
||||
@@ -34,6 +36,8 @@ 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
|
||||
@@ -78,6 +82,7 @@ export interface AnalyticsConfig {
|
||||
* available.
|
||||
*/
|
||||
appVersion?: string;
|
||||
environment?: string;
|
||||
}
|
||||
|
||||
export type ClientType = "desktop" | "web";
|
||||
@@ -135,6 +140,7 @@ 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
|
||||
@@ -142,13 +148,19 @@ 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() };
|
||||
superProperties = {
|
||||
client_type: detectClientType(),
|
||||
event_schema_version: EVENT_SCHEMA_VERSION,
|
||||
environment: analyticsEnvironment,
|
||||
is_demo: false,
|
||||
};
|
||||
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;
|
||||
}
|
||||
@@ -164,7 +176,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, op.props);
|
||||
posthog.capture(op.name, withClientEventProperties(op.props));
|
||||
} else {
|
||||
capturePersonSet(op.props);
|
||||
}
|
||||
@@ -182,6 +194,7 @@ 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;
|
||||
@@ -194,6 +207,7 @@ 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;
|
||||
@@ -225,7 +239,7 @@ export function captureEvent(
|
||||
pendingOps.push({ kind: "event", name, props });
|
||||
return;
|
||||
}
|
||||
posthog.capture(name, props);
|
||||
posthog.capture(name, withClientEventProperties(props));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -253,6 +267,43 @@ 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
|
||||
|
||||
@@ -26,6 +26,7 @@ import type {
|
||||
MemberWithUser,
|
||||
User,
|
||||
Skill,
|
||||
SkillSummary,
|
||||
CreateSkillRequest,
|
||||
UpdateSkillRequest,
|
||||
SetAgentSkillsRequest,
|
||||
@@ -42,8 +43,7 @@ import type {
|
||||
RuntimeLocalSkillListRequest,
|
||||
CreateRuntimeLocalSkillImportRequest,
|
||||
RuntimeLocalSkillImportRequest,
|
||||
TimelinePage,
|
||||
TimelinePageParam,
|
||||
TimelineEntry,
|
||||
AssigneeFrequencyEntry,
|
||||
TaskMessagePayload,
|
||||
Attachment,
|
||||
@@ -86,6 +86,16 @@ 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 {
|
||||
ChildIssuesResponseSchema,
|
||||
CommentsListSchema,
|
||||
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 /
|
||||
@@ -323,6 +333,7 @@ export class ApiClient {
|
||||
|
||||
async markOnboardingComplete(payload?: {
|
||||
completion_path?: OnboardingCompletionPath;
|
||||
workspace_id?: string;
|
||||
}): Promise<User> {
|
||||
return this.fetch("/api/me/onboarding/complete", {
|
||||
method: "POST",
|
||||
@@ -397,7 +408,11 @@ 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");
|
||||
return this.fetch(`/api/issues?${search}`);
|
||||
const path = `/api/issues?${search}`;
|
||||
const raw = await this.fetch<unknown>(path);
|
||||
return parseWithFallback(raw, ListIssuesResponseSchema, EMPTY_LIST_ISSUES_RESPONSE, {
|
||||
endpoint: "GET /api/issues",
|
||||
});
|
||||
}
|
||||
|
||||
async searchIssues(params: { q: string; limit?: number; offset?: number; include_closed?: boolean; signal?: AbortSignal }): Promise<SearchIssuesResponse> {
|
||||
@@ -427,7 +442,7 @@ export class ApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
async quickCreateIssue(data: { agent_id: string; prompt: string }): Promise<{ task_id: string }> {
|
||||
async quickCreateIssue(data: { agent_id: string; prompt: string; project_id?: string | null }): Promise<{ task_id: string }> {
|
||||
return this.fetch("/api/issues/quick-create", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
@@ -453,7 +468,10 @@ export class ApiClient {
|
||||
}
|
||||
|
||||
async listChildIssues(id: string): Promise<{ issues: Issue[] }> {
|
||||
return this.fetch(`/api/issues/${id}/children`);
|
||||
const raw = await this.fetch<unknown>(`/api/issues/${id}/children`);
|
||||
return parseWithFallback(raw, ChildIssuesResponseSchema, { issues: [] }, {
|
||||
endpoint: "GET /api/issues/:id/children",
|
||||
});
|
||||
}
|
||||
|
||||
async getChildIssueProgress(): Promise<{ progress: { parent_issue_id: string; total: number; done: number }[] }> {
|
||||
@@ -480,7 +498,10 @@ export class ApiClient {
|
||||
|
||||
// Comments
|
||||
async listComments(issueId: string): Promise<Comment[]> {
|
||||
return this.fetch(`/api/issues/${issueId}/comments`);
|
||||
const raw = await this.fetch<unknown>(`/api/issues/${issueId}/comments`);
|
||||
return parseWithFallback(raw, CommentsListSchema, [], {
|
||||
endpoint: "GET /api/issues/:id/comments",
|
||||
});
|
||||
}
|
||||
|
||||
async createComment(issueId: string, content: string, type?: string, parentId?: string, attachmentIds?: string[]): Promise<Comment> {
|
||||
@@ -495,17 +516,13 @@ export class ApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
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 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 getAssigneeFrequency(): Promise<AssigneeFrequencyEntry[]> {
|
||||
@@ -523,6 +540,14 @@ 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",
|
||||
@@ -553,7 +578,10 @@ export class ApiClient {
|
||||
|
||||
// Subscribers
|
||||
async listIssueSubscribers(issueId: string): Promise<IssueSubscriber[]> {
|
||||
return this.fetch(`/api/issues/${issueId}/subscribers`);
|
||||
const raw = await this.fetch<unknown>(`/api/issues/${issueId}/subscribers`);
|
||||
return parseWithFallback(raw, SubscribersListSchema, [], {
|
||||
endpoint: "GET /api/issues/:id/subscribers",
|
||||
});
|
||||
}
|
||||
|
||||
async subscribeToIssue(issueId: string, userId?: string, userType?: string): Promise<void> {
|
||||
@@ -765,6 +793,12 @@ 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");
|
||||
@@ -817,6 +851,7 @@ export class ApiClient {
|
||||
google_client_id?: string;
|
||||
posthog_key?: string;
|
||||
posthog_host?: string;
|
||||
analytics_environment?: string;
|
||||
}> {
|
||||
return this.fetch("/api/config");
|
||||
}
|
||||
@@ -913,7 +948,7 @@ export class ApiClient {
|
||||
}
|
||||
|
||||
// Skills
|
||||
async listSkills(): Promise<Skill[]> {
|
||||
async listSkills(): Promise<SkillSummary[]> {
|
||||
return this.fetch("/api/skills");
|
||||
}
|
||||
|
||||
@@ -946,7 +981,7 @@ export class ApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
async listAgentSkills(agentId: string): Promise<Skill[]> {
|
||||
async listAgentSkills(agentId: string): Promise<SkillSummary[]> {
|
||||
return this.fetch(`/api/agents/${agentId}/skills`);
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ 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";
|
||||
|
||||
146
packages/core/api/schema.test.ts
Normal file
146
packages/core/api/schema.test.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
55
packages/core/api/schema.ts
Normal file
55
packages/core/api/schema.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
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;
|
||||
}
|
||||
137
packages/core/api/schemas.ts
Normal file
137
packages/core/api/schemas.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import { z } from "zod";
|
||||
import type { 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(),
|
||||
});
|
||||
|
||||
const AttachmentSchema = z.object({
|
||||
id: z.string(),
|
||||
}).loose();
|
||||
|
||||
// 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();
|
||||
@@ -15,6 +15,7 @@
|
||||
export function sanitizeNextUrl(raw: string | null): string | null {
|
||||
if (!raw) return null;
|
||||
if (!raw.startsWith("/") || raw.startsWith("//")) return null;
|
||||
// eslint-disable-next-line no-control-regex -- intentional: rejecting control chars is the whole point
|
||||
if (/[\x00-\x1f\\]/.test(raw)) return null;
|
||||
return raw;
|
||||
}
|
||||
|
||||
@@ -24,14 +24,13 @@ 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 lists so the FAB badge drops
|
||||
* has_unread to false in the cached list so the FAB badge drops
|
||||
* immediately. The server broadcasts chat:session_read so other devices
|
||||
* also sync.
|
||||
*/
|
||||
@@ -46,35 +45,30 @@ 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, prevAll };
|
||||
return { prevSessions };
|
||||
},
|
||||
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 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.
|
||||
* 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.
|
||||
*/
|
||||
export function useDeleteChatSession() {
|
||||
const qc = useQueryClient();
|
||||
@@ -87,27 +81,22 @@ 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, prevAll };
|
||||
return { prevSessions };
|
||||
},
|
||||
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) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,14 +24,6 @@ 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,
|
||||
});
|
||||
|
||||
@@ -87,7 +87,6 @@ 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>;
|
||||
/**
|
||||
@@ -104,7 +103,6 @@ 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;
|
||||
@@ -136,7 +134,6 @@ 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,
|
||||
@@ -167,10 +164,6 @@ 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 });
|
||||
|
||||
@@ -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-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" },
|
||||
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" },
|
||||
none: { label: "No priority", bars: 0, color: "text-muted-foreground", badgeBg: "bg-muted", badgeText: "text-muted-foreground" },
|
||||
};
|
||||
|
||||
@@ -23,12 +23,6 @@ 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
|
||||
@@ -303,6 +297,8 @@ export function useBatchDeleteIssues() {
|
||||
// Comments / Timeline
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type TimelineCache = TimelineEntry[];
|
||||
|
||||
export function useCreateComment(issueId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
@@ -318,11 +314,6 @@ 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,
|
||||
@@ -336,14 +327,22 @@ export function useCreateComment(issueId: string) {
|
||||
created_at: comment.created_at,
|
||||
updated_at: comment.updated_at,
|
||||
};
|
||||
qc.setQueriesData<TimelineCacheData>(
|
||||
{ queryKey: ["issues", "timeline", issueId] },
|
||||
(old) => prependToLatestPage(old, entry),
|
||||
);
|
||||
},
|
||||
onSettled: () => {
|
||||
qc.invalidateQueries({ queryKey: issueKeys.timeline(issueId) });
|
||||
// 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];
|
||||
});
|
||||
},
|
||||
// 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).
|
||||
});
|
||||
}
|
||||
|
||||
@@ -353,26 +352,16 @@ export function useUpdateComment(issueId: string) {
|
||||
mutationFn: ({ commentId, content }: { commentId: string; content: string }) =>
|
||||
api.updateComment(commentId, content),
|
||||
onMutate: async ({ commentId, content }) => {
|
||||
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,
|
||||
),
|
||||
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)),
|
||||
);
|
||||
return { prevSnapshots };
|
||||
return { prev };
|
||||
},
|
||||
onError: (_err, _vars, ctx) => {
|
||||
if (ctx?.prevSnapshots) {
|
||||
for (const [key, prev] of ctx.prevSnapshots) {
|
||||
qc.setQueryData(key, prev);
|
||||
}
|
||||
if (ctx?.prev !== undefined) {
|
||||
qc.setQueryData(issueKeys.timeline(issueId), ctx.prev);
|
||||
}
|
||||
},
|
||||
onSettled: () => {
|
||||
@@ -386,44 +375,69 @@ export function useDeleteComment(issueId: string) {
|
||||
return useMutation({
|
||||
mutationFn: (commentId: string) => api.deleteComment(commentId),
|
||||
onMutate: async (commentId) => {
|
||||
await qc.cancelQueries({ queryKey: ["issues", "timeline", issueId] });
|
||||
const prevSnapshots = qc.getQueriesData<TimelineCacheData>({
|
||||
queryKey: ["issues", "timeline", issueId],
|
||||
});
|
||||
await qc.cancelQueries({ queryKey: issueKeys.timeline(issueId) });
|
||||
const prev = qc.getQueryData<TimelineCache>(issueKeys.timeline(issueId));
|
||||
|
||||
// Cascade: collect all child comment IDs across every loaded page.
|
||||
// Cascade: collect all descendants of the deleted comment.
|
||||
const toRemove = new Set<string>([commentId]);
|
||||
for (const [, data] of prevSnapshots) {
|
||||
if (!data) continue;
|
||||
if (prev) {
|
||||
let changed = true;
|
||||
while (changed) {
|
||||
changed = false;
|
||||
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;
|
||||
}
|
||||
for (const e of prev) {
|
||||
if (
|
||||
e.parent_id &&
|
||||
toRemove.has(e.parent_id) &&
|
||||
!toRemove.has(e.id)
|
||||
) {
|
||||
toRemove.add(e.id);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
qc.setQueriesData<TimelineCacheData>(
|
||||
{ queryKey: ["issues", "timeline", issueId] },
|
||||
(old) => filterAllEntries(old, (e) => toRemove.has(e.id)),
|
||||
qc.setQueryData<TimelineCache>(issueKeys.timeline(issueId), (old) =>
|
||||
old?.filter((e) => !toRemove.has(e.id)),
|
||||
);
|
||||
return { prevSnapshots };
|
||||
return { prev };
|
||||
},
|
||||
onError: (_err, _id, ctx) => {
|
||||
if (ctx?.prevSnapshots) {
|
||||
for (const [key, prev] of ctx.prevSnapshots) {
|
||||
qc.setQueryData(key, prev);
|
||||
}
|
||||
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);
|
||||
}
|
||||
},
|
||||
onSettled: () => {
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { infiniteQueryOptions, queryOptions } from "@tanstack/react-query";
|
||||
import { queryOptions } from "@tanstack/react-query";
|
||||
import { api } from "../api";
|
||||
import type {
|
||||
IssueStatus,
|
||||
ListIssuesParams,
|
||||
ListIssuesCache,
|
||||
TimelinePage,
|
||||
TimelinePageParam,
|
||||
} from "../types";
|
||||
import { BOARD_STATUSES } from "./config";
|
||||
|
||||
@@ -23,15 +21,9 @@ export const issueKeys = {
|
||||
[...issueKeys.all(wsId), "children", id] as const,
|
||||
childProgress: (wsId: string) =>
|
||||
[...issueKeys.all(wsId), "child-progress"] 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),
|
||||
/** Full-issue timeline (single TanStack Query, no cursor). */
|
||||
timeline: (issueId: string) =>
|
||||
["issues", "timeline", issueId] as const,
|
||||
reactions: (issueId: string) => ["issues", "reactions", issueId] as const,
|
||||
subscribers: (issueId: string) =>
|
||||
["issues", "subscribers", issueId] as const,
|
||||
@@ -141,39 +133,16 @@ export function childIssuesOptions(wsId: string, id: string) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
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,
|
||||
export function issueTimelineOptions(issueId: string) {
|
||||
return queryOptions({
|
||||
queryKey: issueKeys.timeline(issueId),
|
||||
queryFn: () => api.listTimeline(issueId),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useQuickCreateStore } from "./quick-create-store";
|
||||
|
||||
const RESET_STATE = {
|
||||
lastAgentId: null,
|
||||
lastProjectId: null,
|
||||
prompt: "",
|
||||
keepOpen: false,
|
||||
};
|
||||
@@ -23,4 +24,14 @@ 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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,16 +5,19 @@ 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 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.
|
||||
// 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.
|
||||
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;
|
||||
@@ -27,6 +30,8 @@ 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: "" }),
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
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),
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -42,9 +42,12 @@ export async function saveQuestionnaire(
|
||||
*/
|
||||
export async function completeOnboarding(
|
||||
completionPath?: OnboardingCompletionPath,
|
||||
workspaceId?: string,
|
||||
): Promise<void> {
|
||||
await api.markOnboardingComplete(
|
||||
completionPath ? { completion_path: completionPath } : undefined,
|
||||
completionPath || workspaceId
|
||||
? { completion_path: completionPath, workspace_id: workspaceId }
|
||||
: undefined,
|
||||
);
|
||||
await useAuthStore.getState().refreshMe();
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"./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",
|
||||
@@ -24,7 +25,6 @@
|
||||
"./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",
|
||||
@@ -92,6 +92,7 @@
|
||||
"i18next": "catalog:",
|
||||
"posthog-js": "catalog:",
|
||||
"react-i18next": "catalog:",
|
||||
"zod": "catalog:",
|
||||
"zustand": "catalog:"
|
||||
},
|
||||
"peerDependencies": {
|
||||
|
||||
@@ -1,16 +1,22 @@
|
||||
// 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.
|
||||
*
|
||||
* Keep in sync with server/internal/handler/workspace_reserved_slugs.go.
|
||||
* Single source of truth: `server/internal/handler/reserved_slugs.json`.
|
||||
* The Go backend embeds that JSON; this file is regenerated from it.
|
||||
*
|
||||
* 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 = new Set([
|
||||
export const RESERVED_SLUGS: ReadonlySet<string> = new Set([
|
||||
// Auth flow
|
||||
// `onboarding` is historical, kept reserved post-removal of the route.
|
||||
"login",
|
||||
"logout",
|
||||
"signin",
|
||||
@@ -24,17 +30,21 @@ export const RESERVED_SLUGS = new Set([
|
||||
"verify",
|
||||
"reset",
|
||||
"password",
|
||||
"onboarding", // historical, kept reserved post-removal
|
||||
"onboarding",
|
||||
|
||||
// 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", // 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
|
||||
"multica",
|
||||
"www",
|
||||
"new",
|
||||
"home",
|
||||
"homepage",
|
||||
"dashboard",
|
||||
"help",
|
||||
"about",
|
||||
"pricing",
|
||||
@@ -52,7 +62,7 @@ export const RESERVED_SLUGS = 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",
|
||||
@@ -60,9 +70,11 @@ export const RESERVED_SLUGS = new Set([
|
||||
"search",
|
||||
"members",
|
||||
|
||||
// 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).
|
||||
// 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.
|
||||
"issues",
|
||||
"projects",
|
||||
"autopilots",
|
||||
@@ -72,12 +84,13 @@ export const RESERVED_SLUGS = new Set([
|
||||
"runtimes",
|
||||
"skills",
|
||||
"settings",
|
||||
"workspaces", // global `/workspaces/new` workspace creation page
|
||||
"teams", // reserved for future team management routes
|
||||
"workspaces",
|
||||
"teams",
|
||||
|
||||
// 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",
|
||||
@@ -86,10 +99,10 @@ export const RESERVED_SLUGS = 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",
|
||||
@@ -97,16 +110,18 @@ export const RESERVED_SLUGS = 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",
|
||||
@@ -116,12 +131,12 @@ export const RESERVED_SLUGS = 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",
|
||||
|
||||
@@ -73,6 +73,9 @@ 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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -59,6 +59,7 @@ export function AuthInitializer({
|
||||
key: cfg.posthog_key,
|
||||
host: cfg.posthog_host || "",
|
||||
appVersion: identity?.version,
|
||||
environment: cfg.analytics_environment,
|
||||
});
|
||||
}
|
||||
})
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { ApiClient } from "../api/client";
|
||||
import { setApiInstance } from "../api";
|
||||
import { setApiInstance, setSchemaLogger } from "../api";
|
||||
import { createAuthStore, registerAuthStore } from "../auth";
|
||||
import { createChatStore, registerChatStore } from "../chat";
|
||||
import {
|
||||
@@ -41,6 +41,7 @@ function initCore(
|
||||
identity,
|
||||
});
|
||||
setApiInstance(api);
|
||||
setSchemaLogger(createLogger("api-schema"));
|
||||
|
||||
// In token mode, hydrate token from storage.
|
||||
if (!cookieAuth) {
|
||||
|
||||
@@ -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-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" },
|
||||
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" },
|
||||
none: { label: "No priority", bars: 0, color: "text-muted-foreground", badgeBg: "bg-muted", badgeText: "text-muted-foreground" },
|
||||
};
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
} from "../issues/ws-updaters";
|
||||
import { onInboxNew, onInboxInvalidate, onInboxIssueStatusChanged, onInboxIssueDeleted } from "../inbox/ws-updaters";
|
||||
import { inboxKeys } from "../inbox/queries";
|
||||
import { notificationPreferenceOptions } from "../notification-preferences/queries";
|
||||
import { workspaceKeys, workspaceListOptions } from "../workspace/queries";
|
||||
import { chatKeys } from "../chat/queries";
|
||||
import { useChatStore } from "../chat";
|
||||
@@ -44,6 +45,8 @@ import type {
|
||||
CommentCreatedPayload,
|
||||
CommentUpdatedPayload,
|
||||
CommentDeletedPayload,
|
||||
CommentResolvedPayload,
|
||||
CommentUnresolvedPayload,
|
||||
ActivityCreatedPayload,
|
||||
ReactionAddedPayload,
|
||||
ReactionRemovedPayload,
|
||||
@@ -201,6 +204,7 @@ 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",
|
||||
@@ -268,7 +272,7 @@ export function useRealtimeSync(
|
||||
if (wsId) onIssueLabelsChanged(qc, wsId, issue_id, labels ?? []);
|
||||
});
|
||||
|
||||
const unsubInboxNew = ws.on("inbox:new", (p) => {
|
||||
const unsubInboxNew = ws.on("inbox:new", async (p) => {
|
||||
const { item } = p as InboxNewPayload;
|
||||
if (!item) return;
|
||||
const wsId = getCurrentWsId();
|
||||
@@ -278,6 +282,22 @@ export function useRealtimeSync(
|
||||
// styling is enough — no need to interrupt with a banner. `desktopAPI`
|
||||
// is injected by the preload script; its absence (web app) skips silently.
|
||||
if (typeof document !== "undefined" && document.hasFocus()) return;
|
||||
// Respect the user's system-notification preference. The Settings page
|
||||
// owns the only `useQuery` for this resource, so on a fresh app start
|
||||
// (or any session that hasn't visited Settings) the React Query cache
|
||||
// is empty — using `getQueryData` would silently default to "all" and
|
||||
// ignore the user's saved choice. `ensureQueryData` resolves to the
|
||||
// cached value if present and otherwise fetches once, populating the
|
||||
// cache for subsequent events. On network failure we fall through to
|
||||
// the default ("all") rather than swallow the banner entirely.
|
||||
if (wsId) {
|
||||
try {
|
||||
const prefData = await qc.ensureQueryData(notificationPreferenceOptions(wsId));
|
||||
if (prefData?.preferences?.system_notifications === "muted") return;
|
||||
} catch {
|
||||
// Fall through with default behavior.
|
||||
}
|
||||
}
|
||||
// Capture the source workspace slug at emit time. The user may switch
|
||||
// workspaces before clicking the banner (macOS Notification Center
|
||||
// holds banners), so routing must not read "current slug" at click
|
||||
@@ -312,12 +332,25 @@ export function useRealtimeSync(
|
||||
|
||||
// --- Timeline event handlers (global fallback) ---
|
||||
// These events are also handled granularly by useIssueTimeline when
|
||||
// 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).
|
||||
|
||||
// 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`.
|
||||
const invalidateTimeline = (issueId: string) => {
|
||||
qc.invalidateQueries({ queryKey: issueKeys.timeline(issueId) });
|
||||
qc.invalidateQueries({
|
||||
queryKey: issueKeys.timeline(issueId),
|
||||
refetchType: "none",
|
||||
});
|
||||
};
|
||||
|
||||
const unsubCommentCreated = ws.on("comment:created", (p) => {
|
||||
@@ -335,6 +368,16 @@ 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);
|
||||
@@ -499,10 +542,7 @@ export function useRealtimeSync(
|
||||
};
|
||||
const invalidateSessionLists = () => {
|
||||
const id = getCurrentWsId();
|
||||
if (id) {
|
||||
qc.invalidateQueries({ queryKey: chatKeys.sessions(id) });
|
||||
qc.invalidateQueries({ queryKey: chatKeys.allSessions(id) });
|
||||
}
|
||||
if (id) qc.invalidateQueries({ queryKey: chatKeys.sessions(id) });
|
||||
};
|
||||
|
||||
const unsubChatMessage = ws.on("chat:message", (p) => {
|
||||
@@ -639,7 +679,6 @@ 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) });
|
||||
@@ -661,6 +700,8 @@ export function useRealtimeSync(
|
||||
unsubCommentCreated();
|
||||
unsubCommentUpdated();
|
||||
unsubCommentDeleted();
|
||||
unsubCommentResolved();
|
||||
unsubCommentUnresolved();
|
||||
unsubActivityCreated();
|
||||
unsubReactionAdded();
|
||||
unsubReactionRemoved();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Reaction } from "./comment";
|
||||
import type { CommentAuthorType, Reaction } from "./comment";
|
||||
import type { Attachment } from "./attachment";
|
||||
|
||||
export interface AssigneeFrequencyEntry {
|
||||
@@ -23,27 +23,10 @@ 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 };
|
||||
|
||||
@@ -95,6 +95,11 @@ 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 {
|
||||
@@ -115,13 +120,26 @@ export interface Agent {
|
||||
max_concurrent_tasks: number;
|
||||
model: string;
|
||||
owner_id: string | null;
|
||||
skills: Skill[];
|
||||
skills: AgentSkillSummary[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
archived_at: string | null;
|
||||
archived_by: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal skill shape embedded in an Agent payload (`GET /api/agents`,
|
||||
* `GET /api/agents/:id`). Only id/name/description are populated — the
|
||||
* agent list batch query joins exactly those three columns. For full skill
|
||||
* info, use `GET /api/agents/:id/skills` (returns `SkillSummary[]`) or
|
||||
* `GET /api/skills/:id` (returns the full `Skill`).
|
||||
*/
|
||||
export interface AgentSkillSummary {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface CreateAgentRequest {
|
||||
name: string;
|
||||
description?: string;
|
||||
@@ -156,19 +174,30 @@ export interface UpdateAgentRequest {
|
||||
|
||||
// Skills
|
||||
|
||||
export interface Skill {
|
||||
/**
|
||||
* Lightweight skill shape returned by list endpoints (`GET /api/skills`,
|
||||
* `GET /api/agents/:id/skills`). The full SKILL.md `content` is intentionally
|
||||
* omitted — bodies routinely run 50–200KB each and shipping them in list
|
||||
* payloads tripped CLI timeouts on high-latency links (GH
|
||||
* multica-ai/multica#2174). Use `Skill` from a detail endpoint when you need
|
||||
* the body. For skills embedded in an `Agent` payload see `AgentSkillSummary`.
|
||||
*/
|
||||
export interface SkillSummary {
|
||||
id: string;
|
||||
workspace_id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
content: string;
|
||||
config: Record<string, unknown>;
|
||||
files: SkillFile[];
|
||||
created_by: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface Skill extends SkillSummary {
|
||||
content: string;
|
||||
files: SkillFile[];
|
||||
}
|
||||
|
||||
export interface SkillFile {
|
||||
id: string;
|
||||
skill_id: string;
|
||||
|
||||
@@ -23,4 +23,7 @@ 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;
|
||||
}
|
||||
|
||||
@@ -15,6 +15,8 @@ export type WSEventType =
|
||||
| "comment:created"
|
||||
| "comment:updated"
|
||||
| "comment:deleted"
|
||||
| "comment:resolved"
|
||||
| "comment:unresolved"
|
||||
| "agent:status"
|
||||
| "agent:created"
|
||||
| "agent:archived"
|
||||
@@ -143,6 +145,14 @@ export interface CommentDeletedPayload {
|
||||
issue_id: string;
|
||||
}
|
||||
|
||||
export interface CommentResolvedPayload {
|
||||
comment: Comment;
|
||||
}
|
||||
|
||||
export interface CommentUnresolvedPayload {
|
||||
comment: Comment;
|
||||
}
|
||||
|
||||
export interface WorkspaceUpdatedPayload {
|
||||
workspace: Workspace;
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@ export type {
|
||||
CreateAgentRequest,
|
||||
UpdateAgentRequest,
|
||||
Skill,
|
||||
SkillSummary,
|
||||
AgentSkillSummary,
|
||||
SkillFile,
|
||||
CreateSkillRequest,
|
||||
UpdateSkillRequest,
|
||||
@@ -43,8 +45,6 @@ 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";
|
||||
|
||||
@@ -3,7 +3,8 @@ export type NotificationGroupKey =
|
||||
| "status_changes"
|
||||
| "comments"
|
||||
| "updates"
|
||||
| "agent_activity";
|
||||
| "agent_activity"
|
||||
| "system_notifications";
|
||||
|
||||
export type NotificationGroupValue = "all" | "muted";
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createRequestId, createSafeId, generateUUID } from "./utils";
|
||||
import { createRequestId, createSafeId, generateUUID, isImeComposing } from "./utils";
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
@@ -31,3 +31,25 @@ 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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -48,3 +48,28 @@ 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;
|
||||
}
|
||||
|
||||
17
packages/eslint-config/react.js
vendored
17
packages/eslint-config/react.js
vendored
@@ -5,16 +5,13 @@ import reactHooksPlugin from "eslint-plugin-react-hooks";
|
||||
/** @type {import("eslint").Linter.Config[]} */
|
||||
export default [
|
||||
...baseConfig,
|
||||
// React rules (JSX only)
|
||||
{
|
||||
files: ["**/*.{jsx,tsx}"],
|
||||
plugins: {
|
||||
react: reactPlugin,
|
||||
"react-hooks": reactHooksPlugin,
|
||||
},
|
||||
plugins: { react: reactPlugin },
|
||||
rules: {
|
||||
...reactPlugin.configs.recommended.rules,
|
||||
...reactPlugin.configs["jsx-runtime"].rules,
|
||||
...reactHooksPlugin.configs["recommended-latest"].rules,
|
||||
"react/prop-types": "off",
|
||||
"react/no-unknown-property": "off",
|
||||
},
|
||||
@@ -22,4 +19,14 @@ export default [
|
||||
react: { version: "detect" },
|
||||
},
|
||||
},
|
||||
// React Hooks rules apply to .ts files too — hooks (useEffect, useCallback,
|
||||
// useMemo) can live in plain .ts modules and we want exhaustive-deps to
|
||||
// run + inline disable comments to resolve.
|
||||
{
|
||||
files: ["**/*.{ts,tsx,js,jsx}"],
|
||||
plugins: { "react-hooks": reactHooksPlugin },
|
||||
rules: {
|
||||
...reactHooksPlugin.configs["recommended-latest"].rules,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
99
packages/ui/components/common/error-boundary.tsx
Normal file
99
packages/ui/components/common/error-boundary.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -51,7 +51,7 @@ function DropdownMenuContent({
|
||||
e.stopPropagation()
|
||||
onClick?.(e)
|
||||
}}
|
||||
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 )}
|
||||
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 )}
|
||||
{...props}
|
||||
/>
|
||||
</MenuPrimitive.Positioner>
|
||||
|
||||
@@ -114,6 +114,69 @@
|
||||
animation: chat-text-shimmer 2.5s linear infinite;
|
||||
}
|
||||
|
||||
/* Border beam: a brand-tinted highlight sweeps continuously around the
|
||||
* element's rounded border, drawing the eye to a CTA that would otherwise
|
||||
* blend into the chrome (e.g. the "switch to agent" affordance in manual
|
||||
* create). Built with a conic-gradient on a ::before whose mask carves out a
|
||||
* 1px ring; an animated @property angle drives the rotation so only the
|
||||
* gradient repaints, not layout. The ring respects `border-radius: inherit`,
|
||||
* so any rounded host picks up the right curvature for free. Pair with a
|
||||
* subtle background tint on the host so the highlight has something to ride
|
||||
* on at low contrast. */
|
||||
@property --border-beam-angle {
|
||||
syntax: "<angle>";
|
||||
initial-value: 0deg;
|
||||
inherits: false;
|
||||
}
|
||||
|
||||
@keyframes border-beam-rotate {
|
||||
to { --border-beam-angle: 360deg; }
|
||||
}
|
||||
|
||||
.border-beam {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.border-beam::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
padding: 1px;
|
||||
background: conic-gradient(
|
||||
from var(--border-beam-angle),
|
||||
transparent 0deg,
|
||||
transparent 220deg,
|
||||
#ffbe7b 245deg,
|
||||
#ff777f 270deg,
|
||||
#ff8ab4 295deg,
|
||||
#a07cfe 320deg,
|
||||
#5b9dff 345deg,
|
||||
transparent 360deg
|
||||
);
|
||||
-webkit-mask:
|
||||
linear-gradient(#000 0 0) content-box,
|
||||
linear-gradient(#000 0 0);
|
||||
-webkit-mask-composite: xor;
|
||||
mask-composite: exclude;
|
||||
animation: border-beam-rotate 3.2s linear infinite;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.border-beam::before {
|
||||
animation: none;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
#ffbe7b,
|
||||
#ff777f,
|
||||
#ff8ab4,
|
||||
#a07cfe,
|
||||
#5b9dff
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/* Sidebar: open triggers (dropdown/popover) get active background */
|
||||
[data-sidebar="menu-button"][data-popup-open] {
|
||||
background-color: var(--sidebar-accent);
|
||||
|
||||
@@ -27,7 +27,6 @@
|
||||
--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);
|
||||
@@ -94,7 +93,6 @@
|
||||
--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;
|
||||
@@ -141,7 +139,6 @@
|
||||
--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;
|
||||
|
||||
@@ -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 { timeAgo } from "@multica/core/utils";
|
||||
import { isImeComposing, 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,7 +455,11 @@ function DescriptionEditorBody({
|
||||
placeholder={t(($) => $.inspector.description_placeholder)}
|
||||
rows={6}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
if (e.key === "Escape") {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
if (isImeComposing(e)) return;
|
||||
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
void commit();
|
||||
@@ -561,11 +565,14 @@ 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"
|
||||
@@ -580,7 +587,11 @@ function InlineEditPopover({
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") setOpen(false);
|
||||
if (e.key === "Escape") {
|
||||
setOpen(false);
|
||||
return;
|
||||
}
|
||||
if (isImeComposing(e)) return;
|
||||
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
void commit();
|
||||
|
||||
@@ -117,6 +117,10 @@ export function AgentOverviewPane({
|
||||
};
|
||||
|
||||
return (
|
||||
// On mobile the parent stacks the inspector and overview and scrolls the
|
||||
// page itself, so this pane has no inherited height. `min-h-[60vh]` keeps
|
||||
// the tab content area usably tall when content is short; `md:` restores
|
||||
// the grid-driven full-height behavior on tablet and up.
|
||||
<div className="flex min-h-[60vh] flex-col overflow-hidden rounded-lg border bg-background md:h-full md:min-h-0">
|
||||
<div className="flex shrink-0 items-center gap-0 overflow-x-auto border-b px-2 md:px-4">
|
||||
{detailTabs.map((tab) => (
|
||||
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
MemberWithUser,
|
||||
CreateAgentRequest,
|
||||
} from "@multica/core/types";
|
||||
import { isImeComposing } from "@multica/core/utils";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -172,7 +173,10 @@ export function CreateAgentDialog({
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder={t(($) => $.create_dialog.name_placeholder)}
|
||||
className="mt-1"
|
||||
onKeyDown={(e) => e.key === "Enter" && handleSubmit()}
|
||||
onKeyDown={(e) => {
|
||||
if (isImeComposing(e)) return;
|
||||
if (e.key === "Enter") handleSubmit();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { FileText } from "lucide-react";
|
||||
import { FileText, Search } from "lucide-react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import type { Agent } from "@multica/core/types";
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@multica/ui/components/ui/dialog";
|
||||
import { Input } from "@multica/ui/components/ui/input";
|
||||
import { useT } from "../../i18n";
|
||||
|
||||
/**
|
||||
@@ -46,11 +47,27 @@ 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);
|
||||
@@ -58,7 +75,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) });
|
||||
onOpenChange(false);
|
||||
handleOpenChange(false);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : t(($) => $.tab_body.skills.add_failed_toast));
|
||||
} finally {
|
||||
@@ -66,8 +83,11 @@ export function SkillAddDialog({
|
||||
}
|
||||
};
|
||||
|
||||
const showSearch = availableSkills.length > 0;
|
||||
const noMatch = showSearch && filteredSkills.length === 0;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-sm">{t(($) => $.tab_body.skills.add_dialog_title)}</DialogTitle>
|
||||
@@ -75,8 +95,21 @@ 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">
|
||||
{availableSkills.map((skill) => (
|
||||
{filteredSkills.map((skill) => (
|
||||
<button
|
||||
key={skill.id}
|
||||
onClick={() => handleAdd(skill.id)}
|
||||
@@ -99,9 +132,14 @@ 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={() => onOpenChange(false)}>
|
||||
<Button variant="ghost" onClick={() => handleOpenChange(false)}>
|
||||
{t(($) => $.tab_body.skills.add_dialog_cancel)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
||||
@@ -1,25 +1,34 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import { Skeleton } from "@multica/ui/components/ui/skeleton";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@multica/ui/components/ui/collapsible";
|
||||
import { ChevronRight, ChevronDown, Brain, AlertCircle, AlertTriangle } from "lucide-react";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipTrigger,
|
||||
TooltipContent,
|
||||
} from "@multica/ui/components/ui/tooltip";
|
||||
import { ChevronRight, ChevronDown, Brain, AlertCircle, AlertTriangle, Copy } from "lucide-react";
|
||||
import { useScrollFade } from "@multica/ui/hooks/use-scroll-fade";
|
||||
import { useAutoScroll } from "@multica/ui/hooks/use-auto-scroll";
|
||||
import { taskMessagesOptions } from "@multica/core/chat/queries";
|
||||
import { Markdown } from "@multica/views/common/markdown";
|
||||
import { copyMarkdown } from "../../editor";
|
||||
import type { AgentAvailability } from "@multica/core/agents";
|
||||
import type { ChatMessage, ChatPendingTask, TaskMessagePayload, TaskFailureReason } from "@multica/core/types";
|
||||
import type { ChatTimelineItem } from "@multica/core/chat";
|
||||
import { failureReasonLabel } from "../../agents/components/tabs/task-failure";
|
||||
import { TaskStatusPill } from "./task-status-pill";
|
||||
import { formatElapsedMs } from "../lib/format";
|
||||
import { splitTimeline, extractCopyText } from "../lib/copy-text";
|
||||
import { useT } from "../../i18n";
|
||||
|
||||
// ─── Public component ────────────────────────────────────────────────────
|
||||
@@ -73,11 +82,15 @@ export function ChatMessageList({
|
||||
* than issue-detail's px-8 because the chat window can be narrow. */}
|
||||
<div className="mx-auto w-full max-w-4xl px-5 py-4 space-y-4">
|
||||
{messages.map((msg) => (
|
||||
<MessageBubble key={msg.id} message={msg} />
|
||||
<MessageBubble
|
||||
key={msg.id}
|
||||
message={msg}
|
||||
isPending={!!pendingTaskId && msg.task_id === pendingTaskId}
|
||||
/>
|
||||
))}
|
||||
{hasLive && (
|
||||
<div className="w-full space-y-1.5">
|
||||
<TimelineView items={liveTimeline} />
|
||||
<TimelineView items={liveTimeline} isStreaming />
|
||||
</div>
|
||||
)}
|
||||
{showStatusPill && pendingTask && (
|
||||
@@ -132,7 +145,7 @@ function toTimelineItem(m: TaskMessagePayload): ChatTimelineItem {
|
||||
|
||||
// ─── Message bubbles ─────────────────────────────────────────────────────
|
||||
|
||||
function MessageBubble({ message }: { message: ChatMessage }) {
|
||||
function MessageBubble({ message, isPending }: { message: ChatMessage; isPending: boolean }) {
|
||||
if (message.role === "user") {
|
||||
return (
|
||||
<div className="flex justify-end">
|
||||
@@ -149,13 +162,15 @@ function MessageBubble({ message }: { message: ChatMessage }) {
|
||||
);
|
||||
}
|
||||
|
||||
return <AssistantMessage message={message} />;
|
||||
return <AssistantMessage message={message} isPending={isPending} />;
|
||||
}
|
||||
|
||||
function AssistantMessage({
|
||||
message,
|
||||
isPending,
|
||||
}: {
|
||||
message: ChatMessage;
|
||||
isPending: boolean;
|
||||
}) {
|
||||
const taskId = message.task_id;
|
||||
|
||||
@@ -193,13 +208,79 @@ function AssistantMessage({
|
||||
<Markdown>{message.content}</Markdown>
|
||||
</div>
|
||||
)}
|
||||
<MessageFooter
|
||||
message={message}
|
||||
timeline={timeline}
|
||||
isPending={isPending}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Inline footer row beneath the assistant reply: "Replied in 38s · [Copy]".
|
||||
// Action icons live here (not as a hover-floating overlay) so they're
|
||||
// discoverable on first read and don't shift content. Buttons stay quiet
|
||||
// (muted) until hover. Copy is suppressed during streaming because the
|
||||
// final text is still being appended.
|
||||
function MessageFooter({
|
||||
message,
|
||||
timeline,
|
||||
isPending,
|
||||
}: {
|
||||
message: ChatMessage;
|
||||
timeline: ChatTimelineItem[];
|
||||
isPending: boolean;
|
||||
}) {
|
||||
const showCopy = !isPending;
|
||||
if (message.elapsed_ms == null && !showCopy) return null;
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
{message.elapsed_ms != null && (
|
||||
<ElapsedCaption variant="replied" elapsedMs={message.elapsed_ms} />
|
||||
)}
|
||||
{showCopy && <MessageCopyButton message={message} timeline={timeline} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MessageCopyButton({
|
||||
message,
|
||||
timeline,
|
||||
}: {
|
||||
message: ChatMessage;
|
||||
timeline: ChatTimelineItem[];
|
||||
}) {
|
||||
const { t } = useT("chat");
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await copyMarkdown(extractCopyText(message, timeline));
|
||||
toast.success(t(($) => $.message_list.copied_toast));
|
||||
} catch {
|
||||
toast.error(t(($) => $.message_list.copy_failed_toast));
|
||||
}
|
||||
};
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="text-muted-foreground/70 hover:text-foreground"
|
||||
onClick={handleCopy}
|
||||
aria-label={t(($) => $.message_list.copy_action)}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Copy />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
{t(($) => $.message_list.copy_action)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
// Persisted "Replied in 38s" / "Failed after 12s" line under the assistant
|
||||
// bubble. Reads `elapsed_ms` straight off the chat_message — server computes
|
||||
// it once at task completion, so this caption is identical across reloads
|
||||
@@ -220,7 +301,7 @@ function ElapsedCaption({
|
||||
? t(($) => $.message_list.replied_in, { elapsed: formatElapsedMs(elapsedMs) })
|
||||
: t(($) => $.message_list.failed_after, { elapsed: formatElapsedMs(elapsedMs) });
|
||||
return (
|
||||
<div className={cn("text-[11px] text-muted-foreground/80", className)}>
|
||||
<div className={cn("text-xs text-muted-foreground/80", className)}>
|
||||
{text}
|
||||
</div>
|
||||
);
|
||||
@@ -259,7 +340,7 @@ function FailureBubble({
|
||||
<div className="text-destructive/90">{label}</div>
|
||||
{rawError.trim() && (
|
||||
<Collapsible open={open} onOpenChange={setOpen}>
|
||||
<CollapsibleTrigger className="mt-0.5 flex items-center gap-1 text-[11px] text-muted-foreground hover:text-foreground transition-colors">
|
||||
<CollapsibleTrigger className="mt-0.5 flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors">
|
||||
{open ? (
|
||||
<ChevronDown className="size-3" />
|
||||
) : (
|
||||
@@ -268,7 +349,7 @@ function FailureBubble({
|
||||
<span>{t(($) => $.message_list.show_details)}</span>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<pre className="mt-1 max-h-40 overflow-auto rounded bg-muted/40 p-2 text-[11px] text-muted-foreground whitespace-pre-wrap break-all">
|
||||
<pre className="mt-1 max-h-40 overflow-auto rounded bg-muted/40 p-2 text-xs text-muted-foreground whitespace-pre-wrap break-all">
|
||||
{rawError}
|
||||
</pre>
|
||||
</CollapsibleContent>
|
||||
@@ -284,70 +365,49 @@ function FailureBubble({
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Timeline: flat interleaved text + collapsible tool groups ───────────
|
||||
// ─── Timeline: outer process fold + final text (Conductor-style) ─────────
|
||||
//
|
||||
// splitTimeline (lib/copy-text.ts) carves the items into:
|
||||
// preface — text before the first thinking/tool item
|
||||
// middle — first → last non-text item (inclusive, may sandwich text)
|
||||
// final — text after the last non-text item
|
||||
//
|
||||
// We render preface + final outside an outer Collapsible ("X steps") that
|
||||
// wraps middle. The inner row Collapsibles (ThinkingRow / ToolCallRow /
|
||||
// ToolResultRow) are unchanged — clicking them toggles independently of
|
||||
// the outer fold. Copy mirrors what's visible when the outer fold is
|
||||
// closed: preface + final, never middle. See extractCopyText for the
|
||||
// authoritative copy logic.
|
||||
|
||||
interface TimelineSegment {
|
||||
kind: "text" | "tools";
|
||||
function TimelineView({
|
||||
items,
|
||||
isStreaming,
|
||||
}: {
|
||||
items: ChatTimelineItem[];
|
||||
}
|
||||
|
||||
/** Split items into segments: consecutive non-text → "tools", consecutive text → merged "text". */
|
||||
function segmentTimeline(items: ChatTimelineItem[]): TimelineSegment[] {
|
||||
const segments: TimelineSegment[] = [];
|
||||
let toolBuf: ChatTimelineItem[] = [];
|
||||
let textBuf: ChatTimelineItem[] = [];
|
||||
|
||||
const flushTools = () => {
|
||||
if (toolBuf.length > 0) {
|
||||
segments.push({ kind: "tools", items: toolBuf });
|
||||
toolBuf = [];
|
||||
}
|
||||
};
|
||||
|
||||
const flushText = () => {
|
||||
if (textBuf.length > 0) {
|
||||
segments.push({ kind: "text", items: textBuf });
|
||||
textBuf = [];
|
||||
}
|
||||
};
|
||||
|
||||
for (const item of items) {
|
||||
if (item.type === "text") {
|
||||
flushTools();
|
||||
textBuf.push(item);
|
||||
} else {
|
||||
flushText();
|
||||
toolBuf.push(item);
|
||||
}
|
||||
}
|
||||
flushText();
|
||||
flushTools();
|
||||
return segments;
|
||||
}
|
||||
|
||||
function TimelineView({ items }: { items: ChatTimelineItem[] }) {
|
||||
const segments = segmentTimeline(items);
|
||||
isStreaming?: boolean;
|
||||
}) {
|
||||
const { preface, middle, final } = splitTimeline(items);
|
||||
|
||||
return (
|
||||
<>
|
||||
{segments.map((seg, i) =>
|
||||
seg.kind === "text" ? (
|
||||
<div key={seg.items[0]!.seq} className="text-sm leading-relaxed prose prose-sm dark:prose-invert max-w-none">
|
||||
<Markdown>{seg.items.map((t) => t.content ?? "").join("")}</Markdown>
|
||||
</div>
|
||||
) : (
|
||||
<ToolGroupCollapsible
|
||||
key={seg.items[0]!.seq}
|
||||
items={seg.items}
|
||||
defaultOpen={i === segments.length - 1}
|
||||
/>
|
||||
),
|
||||
{preface.length > 0 && (
|
||||
<div className="text-sm leading-relaxed prose prose-sm dark:prose-invert max-w-none">
|
||||
<Markdown>{preface.map((t) => t.content ?? "").join("")}</Markdown>
|
||||
</div>
|
||||
)}
|
||||
{middle.length > 0 && (
|
||||
<OuterProcessFold items={middle} defaultOpen={!!isStreaming} />
|
||||
)}
|
||||
{final.length > 0 && (
|
||||
<div className="text-sm leading-relaxed prose prose-sm dark:prose-invert max-w-none">
|
||||
<Markdown>{final.map((t) => t.content ?? "").join("")}</Markdown>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolGroupCollapsible({
|
||||
function OuterProcessFold({
|
||||
items,
|
||||
defaultOpen,
|
||||
}: {
|
||||
@@ -355,27 +415,47 @@ function ToolGroupCollapsible({
|
||||
defaultOpen?: boolean;
|
||||
}) {
|
||||
const { t } = useT("chat");
|
||||
// useState seeds once at mount — subsequent renders never overwrite the
|
||||
// user's manual toggle. The streaming → completed transition unmounts
|
||||
// the live <TimelineView> and mounts the persisted AssistantMessage's
|
||||
// own <TimelineView>, so the persisted instance starts closed (default)
|
||||
// even if the live one was open. That's the desired collapsed-default.
|
||||
const [open, setOpen] = useState(defaultOpen ?? false);
|
||||
const toolCount = items.filter((i) => i.type === "tool_use").length;
|
||||
const label = t(($) => $.message_list.tools, { count: toolCount });
|
||||
const stepCount = items.length;
|
||||
|
||||
return (
|
||||
<Collapsible open={open} onOpenChange={setOpen}>
|
||||
<CollapsibleTrigger className="flex items-center gap-1 text-[11px] text-muted-foreground hover:text-foreground transition-colors">
|
||||
<CollapsibleTrigger className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors">
|
||||
{open ? <ChevronDown className="size-3" /> : <ChevronRight className="size-3" />}
|
||||
<span>{label}</span>
|
||||
<span>{t(($) => $.message_list.process_steps, { count: stepCount })}</span>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<div className="mt-1 rounded-lg border bg-muted/20 p-2 space-y-0.5">
|
||||
{items.map((item) => (
|
||||
<ItemRow key={item.seq} item={item} />
|
||||
))}
|
||||
{items.map((item) =>
|
||||
item.type === "text" ? (
|
||||
<MiddleTextRow key={item.seq} item={item} />
|
||||
) : (
|
||||
<ItemRow key={item.seq} item={item} />
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
|
||||
// Intermediate text segment rendered inside the outer fold. Visually
|
||||
// down-shifted (xs / muted) so it reads as part of the agent's process,
|
||||
// not the final answer — the final answer renders below the fold at full
|
||||
// prose size.
|
||||
function MiddleTextRow({ item }: { item: ChatTimelineItem }) {
|
||||
return (
|
||||
<div className="py-0.5 text-xs text-muted-foreground prose prose-sm dark:prose-invert max-w-none [&>*:first-child]:mt-0 [&>*:last-child]:mb-0">
|
||||
<Markdown>{item.content ?? ""}</Markdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Individual item rows ────────────────────────────────────────────────
|
||||
|
||||
function ItemRow({ item }: { item: ChatTimelineItem }) {
|
||||
@@ -442,7 +522,7 @@ function ToolCallRow({ item }: { item: ChatTimelineItem }) {
|
||||
</CollapsibleTrigger>
|
||||
{hasInput && (
|
||||
<CollapsibleContent>
|
||||
<pre className="ml-[18px] mt-0.5 max-h-32 overflow-auto rounded bg-muted/50 p-2 text-[11px] text-muted-foreground whitespace-pre-wrap break-all">
|
||||
<pre className="ml-[18px] mt-0.5 max-h-32 overflow-auto rounded bg-muted/50 p-2 text-xs text-muted-foreground whitespace-pre-wrap break-all">
|
||||
{JSON.stringify(item.input, null, 2)}
|
||||
</pre>
|
||||
</CollapsibleContent>
|
||||
@@ -473,7 +553,7 @@ function ToolResultRow({ item }: { item: ChatTimelineItem }) {
|
||||
</span>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<pre className="ml-[18px] mt-0.5 max-h-40 overflow-auto rounded bg-muted/50 p-2 text-[11px] text-muted-foreground whitespace-pre-wrap break-all">
|
||||
<pre className="ml-[18px] mt-0.5 max-h-40 overflow-auto rounded bg-muted/50 p-2 text-xs text-muted-foreground whitespace-pre-wrap break-all">
|
||||
{output.length > 4000 ? output.slice(0, 4000) + "\n... (truncated)" : output}
|
||||
</pre>
|
||||
</CollapsibleContent>
|
||||
@@ -495,7 +575,7 @@ function ThinkingRow({ item }: { item: ChatTimelineItem }) {
|
||||
<span className="text-muted-foreground italic truncate">{preview}</span>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<pre className="ml-[18px] mt-0.5 max-h-40 overflow-auto rounded bg-muted/30 p-2 text-[11px] text-muted-foreground whitespace-pre-wrap break-words">
|
||||
<pre className="ml-[18px] mt-0.5 max-h-40 overflow-auto rounded bg-muted/30 p-2 text-xs text-muted-foreground whitespace-pre-wrap break-words">
|
||||
{text}
|
||||
</pre>
|
||||
</CollapsibleContent>
|
||||
|
||||
@@ -1,244 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import React, { useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { motion } from "motion/react";
|
||||
import { Minus, Maximize2, Minimize2, ChevronDown, Plus, Check, History } from "lucide-react";
|
||||
import { Minus, Maximize2, Minimize2, ChevronDown, ChevronRight, Plus, Check, Trash2 } from "lucide-react";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import { Tooltip, TooltipTrigger, TooltipContent } from "@multica/ui/components/ui/tooltip";
|
||||
import {
|
||||
@@ -15,6 +15,16 @@ 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";
|
||||
@@ -26,17 +36,19 @@ 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, useMarkChatSessionRead } from "@multica/core/chat/mutations";
|
||||
import {
|
||||
useCreateChatSession,
|
||||
useDeleteChatSession,
|
||||
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,
|
||||
@@ -61,13 +73,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 ?? ""),
|
||||
);
|
||||
@@ -90,10 +102,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: history list keeps showing them, but ChatInput is disabled
|
||||
// and the server still rejects POST /messages for them.
|
||||
// read-only: dropdown keeps showing them under "archived", but ChatInput
|
||||
// is disabled and the server still rejects POST /messages for them.
|
||||
const currentSession = activeSessionId
|
||||
? allSessions.find((s) => s.id === activeSessionId)
|
||||
? sessions.find((s) => s.id === activeSessionId)
|
||||
: null;
|
||||
const isSessionArchived = currentSession?.status === "archived";
|
||||
|
||||
@@ -411,24 +423,6 @@ 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={
|
||||
@@ -464,67 +458,58 @@ export function ChatWindow() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 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 />
|
||||
{/* Messages / skeleton / empty state */}
|
||||
{showSkeleton ? (
|
||||
<ChatMessageSkeleton />
|
||||
) : hasMessages ? (
|
||||
<ChatMessageList
|
||||
messages={messages}
|
||||
pendingTask={pendingTask}
|
||||
availability={availability}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{/* 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} />
|
||||
)}
|
||||
|
||||
{/* 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 />}
|
||||
/>
|
||||
</>
|
||||
<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} />
|
||||
)}
|
||||
|
||||
{/* 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 />}
|
||||
/>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -636,8 +621,9 @@ function AgentMenuItem({
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 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 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.
|
||||
@@ -660,6 +646,22 @@ 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.
|
||||
@@ -682,93 +684,214 @@ function SessionDropdown({
|
||||
(s) => s.id !== activeSessionId && s.has_unread,
|
||||
);
|
||||
|
||||
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 && (
|
||||
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 (~50–200ms 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 ? (
|
||||
<ActorAvatar
|
||||
actorType="agent"
|
||||
actorId={triggerAgent.id}
|
||||
actorId={agent.id}
|
||||
size={24}
|
||||
enableHoverCard
|
||||
showStatusDot
|
||||
/>
|
||||
) : (
|
||||
<span className="size-6 shrink-0" />
|
||||
)}
|
||||
<span className="truncate text-sm font-medium">{title}</span>
|
||||
{otherSessionRunning ? (
|
||||
<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
|
||||
aria-label={t(($) => $.window.another_running)}
|
||||
title={t(($) => $.window.another_running)}
|
||||
aria-label={t(($) => $.window.running)}
|
||||
title={t(($) => $.window.running)}
|
||||
className="size-1.5 shrink-0 rounded-full bg-amber-500 animate-pulse"
|
||||
/>
|
||||
) : otherSessionUnread ? (
|
||||
) : session.has_unread ? (
|
||||
<span
|
||||
aria-label={t(($) => $.window.another_unread)}
|
||||
title={t(($) => $.window.another_unread)}
|
||||
aria-label={t(($) => $.window.unread)}
|
||||
title={t(($) => $.window.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-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>
|
||||
{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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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.
|
||||
|
||||
134
packages/views/chat/lib/copy-text.test.ts
Normal file
134
packages/views/chat/lib/copy-text.test.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import type { ChatMessage } from "@multica/core/types";
|
||||
import type { ChatTimelineItem } from "@multica/core/chat";
|
||||
import { splitTimeline, extractCopyText } from "./copy-text";
|
||||
|
||||
const text = (seq: number, content: string): ChatTimelineItem => ({
|
||||
seq,
|
||||
type: "text",
|
||||
content,
|
||||
});
|
||||
|
||||
const thinking = (seq: number, content = "..."): ChatTimelineItem => ({
|
||||
seq,
|
||||
type: "thinking",
|
||||
content,
|
||||
});
|
||||
|
||||
const tool = (seq: number, name = "Read"): ChatTimelineItem => ({
|
||||
seq,
|
||||
type: "tool_use",
|
||||
tool: name,
|
||||
input: { path: "/x" },
|
||||
});
|
||||
|
||||
const message = (content: string): ChatMessage => ({
|
||||
id: "m1",
|
||||
chat_session_id: "s1",
|
||||
role: "assistant",
|
||||
content,
|
||||
task_id: "t1",
|
||||
created_at: "2026-05-06T00:00:00Z",
|
||||
});
|
||||
|
||||
describe("splitTimeline", () => {
|
||||
it("treats an all-text timeline as final (no fold)", () => {
|
||||
const items = [text(1, "hello"), text(2, "world")];
|
||||
expect(splitTimeline(items)).toEqual({
|
||||
preface: [],
|
||||
middle: [],
|
||||
final: items,
|
||||
});
|
||||
});
|
||||
|
||||
it("treats an all-non-text timeline as middle with no final", () => {
|
||||
const items = [thinking(1), tool(2), thinking(3)];
|
||||
const out = splitTimeline(items);
|
||||
expect(out.preface).toEqual([]);
|
||||
expect(out.middle).toEqual(items);
|
||||
expect(out.final).toEqual([]);
|
||||
});
|
||||
|
||||
it("standard shape: thinking → tool → text → tool → final-text", () => {
|
||||
const t1 = thinking(1);
|
||||
const u1 = tool(2);
|
||||
const x1 = text(3, "intermediate");
|
||||
const u2 = tool(4);
|
||||
const f1 = text(5, "final answer");
|
||||
const out = splitTimeline([t1, u1, x1, u2, f1]);
|
||||
expect(out.preface).toEqual([]);
|
||||
expect(out.middle).toEqual([t1, u1, x1, u2]);
|
||||
expect(out.final).toEqual([f1]);
|
||||
});
|
||||
|
||||
it("collects multiple trailing text segments into final", () => {
|
||||
const u = tool(1);
|
||||
const f1 = text(2, "para 1");
|
||||
const f2 = text(3, "para 2");
|
||||
const out = splitTimeline([u, f1, f2]);
|
||||
expect(out.middle).toEqual([u]);
|
||||
expect(out.final).toEqual([f1, f2]);
|
||||
});
|
||||
|
||||
it("collects leading text into preface", () => {
|
||||
const p = text(1, "preface");
|
||||
const u = tool(2);
|
||||
const f = text(3, "final");
|
||||
const out = splitTimeline([p, u, f]);
|
||||
expect(out.preface).toEqual([p]);
|
||||
expect(out.middle).toEqual([u]);
|
||||
expect(out.final).toEqual([f]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractCopyText", () => {
|
||||
it("falls back to message.content when timeline is empty (legacy)", () => {
|
||||
expect(extractCopyText(message("legacy body"), [])).toBe("legacy body");
|
||||
});
|
||||
|
||||
it("returns concatenated text segments for an all-text timeline", () => {
|
||||
expect(
|
||||
extractCopyText(message(""), [text(1, "hello"), text(2, "world")]),
|
||||
).toBe("hello\n\nworld");
|
||||
});
|
||||
|
||||
it("returns only the final text for the standard tool-using shape", () => {
|
||||
expect(
|
||||
extractCopyText(message(""), [
|
||||
thinking(1),
|
||||
tool(2),
|
||||
text(3, "intermediate — should be excluded"),
|
||||
tool(4),
|
||||
text(5, "final answer"),
|
||||
]),
|
||||
).toBe("final answer");
|
||||
});
|
||||
|
||||
it("includes preface and final, excludes middle text", () => {
|
||||
expect(
|
||||
extractCopyText(message(""), [
|
||||
text(1, "preface"),
|
||||
tool(2),
|
||||
text(3, "middle — excluded"),
|
||||
tool(4),
|
||||
text(5, "final"),
|
||||
]),
|
||||
).toBe("preface\n\nfinal");
|
||||
});
|
||||
|
||||
it("falls back to message.content when timeline has no text items", () => {
|
||||
expect(
|
||||
extractCopyText(message("fallback body"), [thinking(1), tool(2)]),
|
||||
).toBe("fallback body");
|
||||
});
|
||||
|
||||
it("joins multiple trailing text segments with blank-line separators", () => {
|
||||
expect(
|
||||
extractCopyText(message(""), [
|
||||
tool(1),
|
||||
text(2, "para 1"),
|
||||
text(3, "para 2"),
|
||||
]),
|
||||
).toBe("para 1\n\npara 2");
|
||||
});
|
||||
});
|
||||
54
packages/views/chat/lib/copy-text.ts
Normal file
54
packages/views/chat/lib/copy-text.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import type { ChatMessage } from "@multica/core/types";
|
||||
import type { ChatTimelineItem } from "@multica/core/chat";
|
||||
|
||||
/**
|
||||
* Split an assistant timeline into three regions for the conductor-style fold:
|
||||
* preface — text items before the first thinking/tool/error item
|
||||
* middle — everything from the first to the last non-text item (inclusive),
|
||||
* including any text items sandwiched between them
|
||||
* final — text items after the last non-text item
|
||||
*
|
||||
* UI renders preface above the outer fold, middle inside the fold (with each
|
||||
* row keeping its existing inner Collapsible), and final below the fold.
|
||||
* Copy concatenates preface + final — the fold's contents are intentionally
|
||||
* omitted, mirroring what's visible when the fold is closed.
|
||||
*/
|
||||
export function splitTimeline(items: ChatTimelineItem[]): {
|
||||
preface: ChatTimelineItem[];
|
||||
middle: ChatTimelineItem[];
|
||||
final: ChatTimelineItem[];
|
||||
} {
|
||||
const firstNonTextIdx = items.findIndex((i) => i.type !== "text");
|
||||
if (firstNonTextIdx === -1) {
|
||||
return { preface: [], middle: [], final: items };
|
||||
}
|
||||
let lastNonTextIdx = items.length - 1;
|
||||
while (lastNonTextIdx >= 0 && items[lastNonTextIdx]!.type === "text") {
|
||||
lastNonTextIdx--;
|
||||
}
|
||||
return {
|
||||
preface: items.slice(0, firstNonTextIdx),
|
||||
middle: items.slice(firstNonTextIdx, lastNonTextIdx + 1),
|
||||
final: items.slice(lastNonTextIdx + 1),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Markdown source the Copy action puts on the clipboard. By design this is
|
||||
* the user-visible answer only — anything inside the outer fold (thinking,
|
||||
* tool calls, sandwiched intermediate text) is dropped. Falls back to
|
||||
* `message.content` for legacy messages without a timeline and for the
|
||||
* pathological all-non-text shape so Copy never produces an empty string.
|
||||
*/
|
||||
export function extractCopyText(
|
||||
message: ChatMessage,
|
||||
timeline: ChatTimelineItem[],
|
||||
): string {
|
||||
if (timeline.length === 0) return message.content ?? "";
|
||||
const { preface, final } = splitTimeline(timeline);
|
||||
const pieces = [...preface, ...final]
|
||||
.map((i) => i.content ?? "")
|
||||
.filter((s) => s.length > 0);
|
||||
if (pieces.length === 0) return message.content ?? "";
|
||||
return pieces.join("\n\n");
|
||||
}
|
||||
@@ -96,7 +96,7 @@ function getEventLabel(item: TimelineItem): string {
|
||||
function getEventSummary(item: TimelineItem): string {
|
||||
switch (item.type) {
|
||||
case "text":
|
||||
return item.content?.split("\n").filter(Boolean).pop() ?? "";
|
||||
return item.content?.split("\n").find((l) => l.trim().length > 0) ?? "";
|
||||
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.split("\n").length > 1) ||
|
||||
(item.type === "text" && item.content && item.content.length > 0) ||
|
||||
(item.type === "error" && item.content && item.content.length > 0);
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect, afterEach } from "vitest";
|
||||
import { describe, it, expect, afterEach, vi } from "vitest";
|
||||
import { Editor } from "@tiptap/core";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import { Markdown } from "@tiptap/markdown";
|
||||
@@ -60,10 +60,23 @@ 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 = "";
|
||||
@@ -127,4 +140,55 @@ 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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,20 +12,71 @@
|
||||
* `data-pm-slice` in the HTML — this attribute is added by ProseMirror's
|
||||
* own clipboard serializer. If present, the source is another ProseMirror
|
||||
* editor and its HTML is structurally correct — let ProseMirror handle it.
|
||||
* Otherwise, ignore the HTML and parse text/plain as Markdown.
|
||||
* 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
|
||||
*
|
||||
* Why not clipboardTextParser? It only runs when there's NO text/html on
|
||||
* the clipboard (ProseMirror source: `let asText = !!text && !html`).
|
||||
*
|
||||
* Why not heuristic detection (looksLikeMarkdown / hasRichHtml)? Unreliable.
|
||||
* VS Code's HTML contains <code> tags that fool rich-content detectors.
|
||||
* Markdown pattern matching has too many edge cases. The data-pm-slice
|
||||
* check is deterministic — no false positives.
|
||||
* 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.
|
||||
*/
|
||||
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",
|
||||
@@ -40,29 +91,23 @@ export function createMarkdownPasteExtension() {
|
||||
const clipboard = event.clipboardData;
|
||||
if (!clipboard) return false;
|
||||
|
||||
// If clipboard has files, defer to the fileUpload extension.
|
||||
if (clipboard.files?.length) return false;
|
||||
|
||||
const text = clipboard.getData("text/plain");
|
||||
if (!text) return false;
|
||||
|
||||
// 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 html = clipboard.getData("text/html");
|
||||
const { $from } = view.state.selection;
|
||||
if ($from.parent.type.name === "codeBlock") {
|
||||
const mode = classifyPaste({
|
||||
text,
|
||||
html,
|
||||
hasFiles: Boolean(clipboard.files?.length),
|
||||
isInsideCodeBlock: $from.parent.type.name === "codeBlock",
|
||||
});
|
||||
|
||||
if (mode === "native") return false;
|
||||
|
||||
if (mode === "literal") {
|
||||
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);
|
||||
|
||||
@@ -18,6 +18,7 @@ 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,
|
||||
@@ -204,6 +205,9 @@ 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(
|
||||
|
||||
@@ -330,6 +330,7 @@ function MermaidLightbox({
|
||||
}
|
||||
|
||||
function MermaidDiagram({ chart }: { chart: string }) {
|
||||
const { t } = useT("editor");
|
||||
const reactId = useId();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const diagramId = useMemo(
|
||||
@@ -386,7 +387,7 @@ function MermaidDiagram({ chart }: { chart: string }) {
|
||||
if (error) {
|
||||
return (
|
||||
<div ref={containerRef} className="mermaid-diagram mermaid-diagram-error">
|
||||
<p>Unable to render Mermaid diagram.</p>
|
||||
<p>{t(($) => $.mermaid.render_error)}</p>
|
||||
<pre>
|
||||
<code>{chart}</code>
|
||||
</pre>
|
||||
@@ -426,7 +427,7 @@ function MermaidDiagram({ chart }: { chart: string }) {
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="mermaid-diagram-loading">Rendering diagram…</div>
|
||||
<div className="mermaid-diagram-loading">{t(($) => $.mermaid.rendering)}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -22,6 +22,7 @@ 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 {
|
||||
@@ -144,8 +145,17 @@ export function InboxPage() {
|
||||
};
|
||||
|
||||
const handleArchive = (id: string) => {
|
||||
const archived = items.find((i) => i.id === id);
|
||||
if (archived && (archived.issue_id ?? archived.id) === selectedKey) setSelectedKey("");
|
||||
const idx = items.findIndex((i) => i.id === id);
|
||||
const archived = idx >= 0 ? items[idx] : null;
|
||||
const wasSelected =
|
||||
!!archived && (archived.issue_id ?? archived.id) === selectedKey;
|
||||
if (wasSelected) {
|
||||
// List is sorted newest-first; prefer the next (older) item, fall back
|
||||
// to the previous (newer) one when archiving at the bottom, and only
|
||||
// clear the selection when nothing else is left.
|
||||
const next = items[idx + 1] ?? items[idx - 1] ?? null;
|
||||
setSelectedKey(next ? (next.issue_id ?? next.id) : "");
|
||||
}
|
||||
archiveMutation.mutate(id, {
|
||||
onError: () => toast.error(t(($) => $.errors.archive_failed)),
|
||||
});
|
||||
@@ -251,26 +261,25 @@ 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.
|
||||
<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={() => {
|
||||
setSelectedKey("");
|
||||
archiveMutation.mutate(selected.id, {
|
||||
onError: () => toast.error(t(($) => $.errors.archive_failed)),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<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>
|
||||
) : selected ? (
|
||||
<div className="p-6">
|
||||
<h2 className="text-lg font-semibold">{getInboxDisplayTitle(selected)}</h2>
|
||||
|
||||
@@ -56,13 +56,20 @@ vi.mock("@multica/core/api", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
import { I18nProvider } from "@multica/core/i18n/react";
|
||||
import enCommon from "../locales/en/common.json";
|
||||
import enInvite from "../locales/en/invite.json";
|
||||
import { InvitationsPage } from "./invitations-page";
|
||||
|
||||
const TEST_RESOURCES = { en: { common: enCommon, invite: enInvite } };
|
||||
|
||||
function renderWithClient(client: QueryClient = new QueryClient()) {
|
||||
return render(
|
||||
<QueryClientProvider client={client}>
|
||||
<InvitationsPage />
|
||||
</QueryClientProvider>,
|
||||
<I18nProvider locale="en" resources={TEST_RESOURCES}>
|
||||
<QueryClientProvider client={client}>
|
||||
<InvitationsPage />
|
||||
</QueryClientProvider>
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -149,6 +156,7 @@ 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");
|
||||
|
||||
@@ -14,6 +14,7 @@ import type { Invitation } from "@multica/core/types";
|
||||
import { useNavigation } from "../navigation";
|
||||
import { useLogout } from "../auth";
|
||||
import { DragStrip } from "../platform";
|
||||
import { useT } from "../i18n";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import { Card, CardContent } from "@multica/ui/components/ui/card";
|
||||
import { Checkbox } from "@multica/ui/components/ui/checkbox";
|
||||
@@ -39,6 +40,7 @@ import { LogOut, Mail, Users } from "lucide-react";
|
||||
* action.
|
||||
*/
|
||||
export function InvitationsPage() {
|
||||
const { t } = useT("invite");
|
||||
const { push } = useNavigation();
|
||||
const qc = useQueryClient();
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
@@ -79,12 +81,19 @@ 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" });
|
||||
await api.markOnboardingComplete({
|
||||
completion_path: "invite_accept",
|
||||
workspace_id: firstAcceptedInvite?.workspace_id,
|
||||
});
|
||||
await useAuthStore.getState().refreshMe();
|
||||
|
||||
qc.invalidateQueries({ queryKey: workspaceKeys.myInvitations() });
|
||||
@@ -93,9 +102,6 @@ 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;
|
||||
@@ -112,7 +118,7 @@ export function InvitationsPage() {
|
||||
setError(
|
||||
e instanceof Error
|
||||
? e.message
|
||||
: "Failed to process invitations. Please try again.",
|
||||
: t(($) => $.batch.error_generic),
|
||||
);
|
||||
// Partial success: any accepts that landed before the failure ALREADY
|
||||
// set onboarded_at on the backend (the AcceptInvitation transaction
|
||||
@@ -157,12 +163,12 @@ export function InvitationsPage() {
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-muted">
|
||||
<Mail className="h-6 w-6 text-muted-foreground" />
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold">No pending invitations</h2>
|
||||
<h2 className="text-lg font-semibold">{t(($) => $.batch.empty_title)}</h2>
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
Continue to set up your own workspace.
|
||||
{t(($) => $.batch.empty_hint)}
|
||||
</p>
|
||||
<Button onClick={() => push(paths.onboarding())}>
|
||||
Continue to setup
|
||||
{t(($) => $.batch.empty_continue)}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -172,10 +178,8 @@ export function InvitationsPage() {
|
||||
|
||||
const submitLabel =
|
||||
selected.size === 0
|
||||
? "Skip and set up my own workspace"
|
||||
: selected.size === 1
|
||||
? "Join 1 workspace"
|
||||
: `Join ${selected.size} workspaces`;
|
||||
? t(($) => $.batch.submit_skip)
|
||||
: t(($) => $.batch.submit_join, { count: selected.size });
|
||||
|
||||
return (
|
||||
<InvitationsShell>
|
||||
@@ -187,11 +191,10 @@ export function InvitationsPage() {
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-xl font-semibold">
|
||||
You've been invited
|
||||
{t(($) => $.batch.title)}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Pick the workspaces you want to join. You can always handle the
|
||||
rest later from the sidebar.
|
||||
{t(($) => $.batch.subtitle)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -212,7 +215,7 @@ export function InvitationsPage() {
|
||||
onClick={handleSubmit}
|
||||
disabled={submitting}
|
||||
>
|
||||
{submitting ? "Joining..." : submitLabel}
|
||||
{submitting ? t(($) => $.batch.joining) : submitLabel}
|
||||
</Button>
|
||||
|
||||
{error && (
|
||||
@@ -233,7 +236,15 @@ function InvitationRow({
|
||||
checked: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
const inviter = invitation.inviter_name || invitation.inviter_email || "Someone";
|
||||
const { t } = useT("invite");
|
||||
const inviter =
|
||||
invitation.inviter_name ||
|
||||
invitation.inviter_email ||
|
||||
t(($) => $.batch.row_inviter_fallback);
|
||||
const roleLine =
|
||||
invitation.role === "admin"
|
||||
? t(($) => $.batch.row_invited_admin, { inviter })
|
||||
: t(($) => $.batch.row_invited_member, { inviter });
|
||||
return (
|
||||
<li>
|
||||
<label
|
||||
@@ -246,11 +257,10 @@ function InvitationRow({
|
||||
/>
|
||||
<div className="flex-1 min-w-0 space-y-1">
|
||||
<div className="font-medium truncate">
|
||||
{invitation.workspace_name ?? "Workspace"}
|
||||
{invitation.workspace_name ?? t(($) => $.batch.row_workspace_fallback)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground truncate">
|
||||
{inviter} invited you as{" "}
|
||||
{invitation.role === "admin" ? "an admin" : "a member"}
|
||||
{roleLine}
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
@@ -259,6 +269,7 @@ function InvitationRow({
|
||||
}
|
||||
|
||||
function InvitationsShell({ children }: { children: ReactNode }) {
|
||||
const { t } = useT("invite");
|
||||
const logout = useLogout();
|
||||
return (
|
||||
<div className="relative flex min-h-svh flex-col bg-background">
|
||||
@@ -270,7 +281,7 @@ function InvitationsShell({ children }: { children: ReactNode }) {
|
||||
onClick={logout}
|
||||
>
|
||||
<LogOut />
|
||||
Log out
|
||||
{t(($) => $.batch.log_out)}
|
||||
</Button>
|
||||
<div className="flex flex-1 flex-col items-center justify-center px-6 pb-12">
|
||||
{children}
|
||||
|
||||
@@ -69,7 +69,10 @@ 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" });
|
||||
await api.markOnboardingComplete({
|
||||
completion_path: "invite_accept",
|
||||
workspace_id: invitation?.workspace_id,
|
||||
});
|
||||
await useAuthStore.getState().refreshMe();
|
||||
setDone("accepted");
|
||||
// Fetch the refreshed workspace list so we know the joined workspace's slug.
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
Calendar,
|
||||
FolderOpen,
|
||||
Link2,
|
||||
MoreHorizontal,
|
||||
Pin,
|
||||
@@ -12,12 +16,14 @@ import {
|
||||
Trash2,
|
||||
UserMinus,
|
||||
} from "lucide-react";
|
||||
import type { Issue } from "@multica/core/types";
|
||||
import type { AgentTask, Issue } from "@multica/core/types";
|
||||
import { api } from "@multica/core/api";
|
||||
import {
|
||||
ALL_STATUSES,
|
||||
PRIORITY_ORDER,
|
||||
PRIORITY_CONFIG,
|
||||
} from "@multica/core/issues/config";
|
||||
import { issueKeys } from "@multica/core/issues/queries";
|
||||
import { StatusIcon } from "../components/status-icon";
|
||||
import { PriorityIcon } from "../components/priority-icon";
|
||||
import { ActorAvatar } from "../../common/actor-avatar";
|
||||
@@ -103,6 +109,37 @@ export function IssueActionsMenuItems({
|
||||
return d.toISOString();
|
||||
};
|
||||
|
||||
// Subscribe to the issue's task list so the cache is warm by the time the
|
||||
// user clicks "Copy local workdir path". The query only fires while the
|
||||
// menu is open (Base UI portals the menu content lazily) — list views
|
||||
// that wrap every row in IssueActionsContextMenu pay nothing until the
|
||||
// menu actually opens.
|
||||
//
|
||||
// The query shares its key with ExecutionLogSection, so navigating from
|
||||
// the issue detail page is a free cache hit.
|
||||
const { data: tasks } = useQuery({
|
||||
queryKey: issueKeys.tasks(issue.id),
|
||||
queryFn: () => api.listTasksByIssue(issue.id),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
// Synchronous click handler — the awaited fetch in the previous version
|
||||
// dropped the browser's transient user activation, which made
|
||||
// navigator.clipboard.writeText() reject from the menu when the cache
|
||||
// was cold. We now read straight from the cached query result and write
|
||||
// to the clipboard inside the same task as the click.
|
||||
const handleCopyWorkdirPath = useCallback(() => {
|
||||
const latestWorkDir = pickLatestWorkDir(tasks);
|
||||
if (!latestWorkDir) {
|
||||
toast.error(t(($) => $.detail.workdir_path_unavailable));
|
||||
return;
|
||||
}
|
||||
navigator.clipboard.writeText(latestWorkDir).then(
|
||||
() => toast.success(t(($) => $.detail.workdir_path_copied)),
|
||||
() => toast.error(t(($) => $.detail.workdir_path_copy_failed)),
|
||||
);
|
||||
}, [tasks, t]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Status */}
|
||||
@@ -238,6 +275,10 @@ export function IssueActionsMenuItems({
|
||||
<Link2 className="h-3.5 w-3.5" />
|
||||
{t(($) => $.actions.copy_link)}
|
||||
</P.Item>
|
||||
<P.Item onClick={handleCopyWorkdirPath}>
|
||||
<FolderOpen className="h-3.5 w-3.5" />
|
||||
{t(($) => $.actions.copy_workdir_path)}
|
||||
</P.Item>
|
||||
|
||||
<P.Separator />
|
||||
|
||||
@@ -276,3 +317,15 @@ export function IssueActionsMenuItems({
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function pickLatestWorkDir(tasks: AgentTask[] | undefined): string | undefined {
|
||||
if (!tasks?.length) return undefined;
|
||||
let latest: AgentTask | undefined;
|
||||
for (const task of tasks) {
|
||||
if (!task.work_dir) continue;
|
||||
if (!latest || task.created_at > latest.created_at) {
|
||||
latest = task;
|
||||
}
|
||||
}
|
||||
return latest?.work_dir;
|
||||
}
|
||||
|
||||
@@ -85,6 +85,7 @@ export function useIssueActions(issue: Issue | null): UseIssueActionsResult {
|
||||
const issueId = issue?.id ?? null;
|
||||
const issueStatus = issue?.status ?? null;
|
||||
const issueIdentifier = issue?.identifier ?? null;
|
||||
const issueProjectId = issue?.project_id ?? null;
|
||||
|
||||
const updateField = useCallback(
|
||||
(updates: Partial<UpdateIssueRequest>) => {
|
||||
@@ -119,12 +120,7 @@ export function useIssueActions(issue: Issue | null): UseIssueActionsResult {
|
||||
|
||||
const copyLink = useCallback(async () => {
|
||||
if (!issueId) return;
|
||||
const path = paths.issueDetail(issueId);
|
||||
const url = navigation.getShareableUrl
|
||||
? navigation.getShareableUrl(path)
|
||||
: typeof window !== "undefined"
|
||||
? window.location.origin + path
|
||||
: path;
|
||||
const url = navigation.getShareableUrl(paths.issueDetail(issueId));
|
||||
try {
|
||||
await navigator.clipboard.writeText(url);
|
||||
toast.success(t(($) => $.detail.link_copied));
|
||||
@@ -138,8 +134,9 @@ export function useIssueActions(issue: Issue | null): UseIssueActionsResult {
|
||||
openModal("create-issue", {
|
||||
parent_issue_id: issueId,
|
||||
parent_issue_identifier: issueIdentifier,
|
||||
...(issueProjectId ? { project_id: issueProjectId } : {}),
|
||||
});
|
||||
}, [openModal, issueId, issueIdentifier]);
|
||||
}, [openModal, issueId, issueIdentifier, issueProjectId]);
|
||||
|
||||
const openSetParent = useCallback(() => {
|
||||
if (!issueId) return;
|
||||
|
||||
279
packages/views/issues/components/agent-live-card.test.tsx
Normal file
279
packages/views/issues/components/agent-live-card.test.tsx
Normal file
@@ -0,0 +1,279 @@
|
||||
import { useEffect } from "react";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { act, render, screen, waitFor } from "@testing-library/react";
|
||||
import { I18nProvider } from "@multica/core/i18n/react";
|
||||
import type { AgentTask } from "@multica/core/types/agent";
|
||||
import enCommon from "../../locales/en/common.json";
|
||||
import enIssues from "../../locales/en/issues.json";
|
||||
|
||||
const TEST_RESOURCES = { en: { common: enCommon, issues: enIssues } };
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mocks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Capture WS event handlers so the test can drive them directly. The card
|
||||
// subscribes to task:queued, task:dispatch, task:completed, task:failed,
|
||||
// task:cancelled, and task:message via useWSEvent. We mirror the real
|
||||
// hook's useEffect-based subscription so stale subscriptions clean up
|
||||
// across re-renders (otherwise every render would stack a duplicate
|
||||
// handler and one event would fan out into many reconcile calls).
|
||||
type EventHandler = (payload: unknown) => void;
|
||||
const wsHandlers = vi.hoisted(() => new Map<string, Set<EventHandler>>());
|
||||
const wsReconnectCallbacks = vi.hoisted(() => new Set<() => void>());
|
||||
|
||||
vi.mock("@multica/core/realtime", () => ({
|
||||
useWSEvent: (event: string, handler: EventHandler) => {
|
||||
useEffect(() => {
|
||||
const set = wsHandlers.get(event) ?? new Set<EventHandler>();
|
||||
set.add(handler);
|
||||
wsHandlers.set(event, set);
|
||||
return () => {
|
||||
set.delete(handler);
|
||||
};
|
||||
}, [event, handler]);
|
||||
},
|
||||
useWSReconnect: (cb: () => void) => {
|
||||
useEffect(() => {
|
||||
wsReconnectCallbacks.add(cb);
|
||||
return () => {
|
||||
wsReconnectCallbacks.delete(cb);
|
||||
};
|
||||
}, [cb]);
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/workspace/hooks", () => ({
|
||||
useActorName: () => ({
|
||||
getActorName: (_: string, id: string) => (id ? `Agent ${id}` : "Agent"),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../common/actor-avatar", () => ({
|
||||
ActorAvatar: ({ actorId }: { actorId: string }) => (
|
||||
<span data-testid="actor-avatar">{actorId}</span>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("../../common/task-transcript", async () => {
|
||||
const buildTimeline = vi.fn().mockReturnValue([]);
|
||||
return {
|
||||
TranscriptButton: () => <button data-testid="transcript-button">transcript</button>,
|
||||
buildTimeline,
|
||||
};
|
||||
});
|
||||
|
||||
const mockApi = vi.hoisted(() => ({
|
||||
getActiveTasksForIssue: vi.fn(),
|
||||
listTaskMessages: vi.fn(),
|
||||
cancelTask: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/api", () => ({
|
||||
api: mockApi,
|
||||
}));
|
||||
|
||||
vi.mock("sonner", () => ({
|
||||
toast: { error: vi.fn(), success: vi.fn() },
|
||||
}));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
import { AgentLiveCard } from "./agent-live-card";
|
||||
|
||||
function makeTask(id: string, overrides: Partial<AgentTask> = {}): AgentTask {
|
||||
return {
|
||||
id,
|
||||
agent_id: "agent-1",
|
||||
runtime_id: "rt-1",
|
||||
issue_id: "issue-1",
|
||||
status: "running",
|
||||
priority: 0,
|
||||
dispatched_at: "2026-01-01T00:00:00Z",
|
||||
started_at: "2026-01-01T00:00:00Z",
|
||||
completed_at: null,
|
||||
result: null,
|
||||
error: null,
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
interface Deferred<T> {
|
||||
promise: Promise<T>;
|
||||
resolve: (value: T) => void;
|
||||
}
|
||||
|
||||
function deferred<T>(): Deferred<T> {
|
||||
let resolveFn!: (value: T) => void;
|
||||
const promise = new Promise<T>((res) => {
|
||||
resolveFn = res;
|
||||
});
|
||||
return { promise, resolve: resolveFn };
|
||||
}
|
||||
|
||||
function fireEvent(event: string, payload: unknown) {
|
||||
const handlers = wsHandlers.get(event) ?? [];
|
||||
for (const h of handlers) h(payload);
|
||||
}
|
||||
|
||||
function renderCard(issueId = "issue-1") {
|
||||
return render(
|
||||
<I18nProvider locale="en" resources={TEST_RESOURCES}>
|
||||
<AgentLiveCard issueId={issueId} />
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
wsHandlers.clear();
|
||||
wsReconnectCallbacks.clear();
|
||||
mockApi.getActiveTasksForIssue.mockReset();
|
||||
mockApi.listTaskMessages.mockReset();
|
||||
mockApi.listTaskMessages.mockResolvedValue([]);
|
||||
mockApi.cancelTask.mockReset();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("AgentLiveCard reconcile race", () => {
|
||||
it("does not re-add a banner when an older active-task response resolves after a newer empty one", async () => {
|
||||
const mountFetch = deferred<{ tasks: AgentTask[] }>();
|
||||
const queuedFetch = deferred<{ tasks: AgentTask[] }>();
|
||||
const completedFetch = deferred<{ tasks: AgentTask[] }>();
|
||||
|
||||
// The component issues three reconciles in this test:
|
||||
// 1. mount
|
||||
// 2. task:queued
|
||||
// 3. task:completed (after optimistic delete)
|
||||
// We control the order they resolve to reproduce the GPT-Boy race.
|
||||
mockApi.getActiveTasksForIssue
|
||||
.mockReturnValueOnce(mountFetch.promise)
|
||||
.mockReturnValueOnce(queuedFetch.promise)
|
||||
.mockReturnValueOnce(completedFetch.promise);
|
||||
|
||||
renderCard();
|
||||
|
||||
// Mount call resolves with empty — no banner yet.
|
||||
await act(async () => {
|
||||
mountFetch.resolve({ tasks: [] });
|
||||
});
|
||||
expect(screen.queryByText(/is working/)).toBeNull();
|
||||
|
||||
// task:queued fires; reconcile A is now in flight (queuedFetch).
|
||||
act(() => {
|
||||
fireEvent("task:queued", { issue_id: "issue-1", task_id: "task-1" });
|
||||
});
|
||||
|
||||
// task:completed fires; handler optimistically deletes (no-op since
|
||||
// the banner isn't rendered yet) then issues reconcile B (completedFetch).
|
||||
act(() => {
|
||||
fireEvent("task:completed", { issue_id: "issue-1", task_id: "task-1" });
|
||||
});
|
||||
|
||||
// Reconcile B resolves first with empty list — server truth says no
|
||||
// active tasks. State is empty.
|
||||
await act(async () => {
|
||||
completedFetch.resolve({ tasks: [] });
|
||||
});
|
||||
expect(screen.queryByText(/is working/)).toBeNull();
|
||||
|
||||
// Reconcile A (older, slow) resolves last with a stale snapshot that
|
||||
// still includes the task. With the generation guard, this response
|
||||
// must be dropped. Without the guard, the banner would re-appear.
|
||||
await act(async () => {
|
||||
queuedFetch.resolve({ tasks: [makeTask("task-1")] });
|
||||
});
|
||||
|
||||
// The banner must NOT come back.
|
||||
expect(screen.queryByText(/is working/)).toBeNull();
|
||||
expect(mockApi.getActiveTasksForIssue).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("WS reconnect refetch removes a stale banner whose end event was lost", async () => {
|
||||
const mountFetch = deferred<{ tasks: AgentTask[] }>();
|
||||
const reconnectFetch = deferred<{ tasks: AgentTask[] }>();
|
||||
|
||||
mockApi.getActiveTasksForIssue
|
||||
.mockReturnValueOnce(mountFetch.promise)
|
||||
.mockReturnValueOnce(reconnectFetch.promise);
|
||||
|
||||
renderCard();
|
||||
|
||||
// Mount sees the task as active — banner shows.
|
||||
await act(async () => {
|
||||
mountFetch.resolve({ tasks: [makeTask("task-1")] });
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/is working/)).toBeTruthy();
|
||||
});
|
||||
|
||||
// Simulate the WS dropping task:completed and then reconnecting.
|
||||
// The reconnect callback runs reconcile, which fetches and finds the
|
||||
// task is no longer active.
|
||||
expect(wsReconnectCallbacks.size).toBeGreaterThan(0);
|
||||
act(() => {
|
||||
for (const cb of wsReconnectCallbacks) cb();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
reconnectFetch.resolve({ tasks: [] });
|
||||
});
|
||||
|
||||
// The banner self-heals.
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText(/is working/)).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("AgentLiveCard queued rendering", () => {
|
||||
it("renders 'is queued' copy without transcript when status is queued", async () => {
|
||||
const queuedTask = makeTask("task-q", {
|
||||
status: "queued",
|
||||
dispatched_at: null,
|
||||
started_at: null,
|
||||
});
|
||||
mockApi.getActiveTasksForIssue.mockResolvedValueOnce({ tasks: [queuedTask] });
|
||||
|
||||
renderCard();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/is queued/)).toBeTruthy();
|
||||
});
|
||||
// No execution transcript while queued — no log to show yet.
|
||||
expect(screen.queryByTestId("transcript-button")).toBeNull();
|
||||
// Cancel button is still available so users can drop a queued task.
|
||||
expect(screen.getByText("Stop")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("running tasks sort above queued tasks so the sticky slot stays on the active one", async () => {
|
||||
const runningTask = makeTask("task-r", { status: "running" });
|
||||
const queuedTask = makeTask("task-q", {
|
||||
status: "queued",
|
||||
dispatched_at: null,
|
||||
started_at: null,
|
||||
});
|
||||
// Server returns queued first (created_at DESC), but the client must
|
||||
// re-sort so the running banner takes the sticky position.
|
||||
mockApi.getActiveTasksForIssue.mockResolvedValueOnce({
|
||||
tasks: [queuedTask, runningTask],
|
||||
});
|
||||
|
||||
renderCard();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/is working/)).toBeTruthy();
|
||||
expect(screen.getByText(/is queued/)).toBeTruthy();
|
||||
});
|
||||
|
||||
const working = screen.getByText(/is working/);
|
||||
const queued = screen.getByText(/is queued/);
|
||||
// Running banner appears earlier in the document order.
|
||||
expect(working.compareDocumentPosition(queued) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user