mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-17 19:59:20 +02:00
Compare commits
103 Commits
agent/lamb
...
fix/github
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
26698d0f58 | ||
|
|
837528e80a | ||
|
|
caeb146bac | ||
|
|
f08b2b4f50 | ||
|
|
91bdec9a54 | ||
|
|
a1c2d53939 | ||
|
|
da03c83251 | ||
|
|
23c05f13c4 | ||
|
|
b21f69f31a | ||
|
|
723489d2a9 | ||
|
|
86aa5199fc | ||
|
|
208f1ddb29 | ||
|
|
b58567ed6c | ||
|
|
bb312002d1 | ||
|
|
2eefa3b90b | ||
|
|
63d215e1c3 | ||
|
|
fb8ad8cc5e | ||
|
|
b7cd7e9adf | ||
|
|
046e4b1efa | ||
|
|
2e5e3a7189 | ||
|
|
352e838b01 | ||
|
|
702c48209b | ||
|
|
fae8558263 | ||
|
|
f5c2994aed | ||
|
|
02310d083e | ||
|
|
fb026f2607 | ||
|
|
34a7ba9865 | ||
|
|
d6349c16ec | ||
|
|
e79ffc0f01 | ||
|
|
6e3e6f714c | ||
|
|
72e89a74f3 | ||
|
|
a49222f37b | ||
|
|
b26f850d4e | ||
|
|
b2b20b291b | ||
|
|
4d11023680 | ||
|
|
ce32a99a5c | ||
|
|
39e57b870f | ||
|
|
15c3886302 | ||
|
|
a6968c7485 | ||
|
|
00415de463 | ||
|
|
448e75ce53 | ||
|
|
e076bbafcc | ||
|
|
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 |
@@ -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=
|
||||
|
||||
9
.github/workflows/ci.yml
vendored
9
.github/workflows/ci.yml
vendored
@@ -29,6 +29,15 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Verify reserved-slugs.ts is up to date
|
||||
# Re-runs the generator and fails on any drift from the
|
||||
# checked-in TypeScript output. The Go side embeds the JSON
|
||||
# source directly, so a passing diff here proves both sides
|
||||
# share one source of truth.
|
||||
run: |
|
||||
pnpm generate:reserved-slugs
|
||||
git diff --exit-code -- packages/core/paths/reserved-slugs.ts
|
||||
|
||||
- name: Build, type check, lint, and test
|
||||
run: pnpm exec turbo build typecheck lint test --filter='!@multica/docs'
|
||||
|
||||
|
||||
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
|
||||
|
||||
@@ -186,16 +186,47 @@ In production, put a reverse proxy in front of both the backend and frontend to
|
||||
|
||||
### Caddy (Recommended)
|
||||
|
||||
**Single-domain layout** — frontend and backend served on the same hostname (this is what `docker-compose.selfhost.yml` defaults to):
|
||||
|
||||
```
|
||||
multica.example.com {
|
||||
# WebSocket route — must come before the catch-all
|
||||
@multica_ws path /ws /ws/*
|
||||
handle @multica_ws {
|
||||
reverse_proxy localhost:8080 {
|
||||
flush_interval -1
|
||||
}
|
||||
}
|
||||
|
||||
# Everything else → frontend
|
||||
reverse_proxy localhost:3000
|
||||
}
|
||||
```
|
||||
|
||||
**Separate-domain layout** — frontend and backend on different hostnames:
|
||||
|
||||
```
|
||||
app.example.com {
|
||||
reverse_proxy localhost:3000
|
||||
}
|
||||
|
||||
api.example.com {
|
||||
@multica_ws path /ws /ws/*
|
||||
handle @multica_ws {
|
||||
reverse_proxy localhost:8080 {
|
||||
flush_interval -1
|
||||
}
|
||||
}
|
||||
|
||||
reverse_proxy localhost:8080
|
||||
}
|
||||
```
|
||||
|
||||
Two non-obvious bits inside the `/ws` block are worth calling out — both are common reasons real-time updates "stop working" on a Caddy-fronted self-host:
|
||||
|
||||
- **`path /ws /ws/*` (not `/ws*`)** — bare `handle /ws` is an exact match, so future path variants under `/ws/` fall through to the frontend block. The obvious shortcut `handle /ws*` overcorrects in the other direction: Caddy's `*` is a glob without a path-segment boundary, so it would also catch unrelated paths like `/ws-foo`, which is a legitimate workspace URL (only the exact slug `ws` is reserved). Listing `/ws` and `/ws/*` explicitly covers both real cases without overreach.
|
||||
- **`flush_interval -1`** — disables response buffering so WebSocket frames are forwarded as soon as they arrive. Without it, frames can sit behind Caddy's default flush window, which looks like delayed comments, missing typing indicators, or "comments only appear after a page refresh."
|
||||
|
||||
### Nginx
|
||||
|
||||
```nginx
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 121 KiB After Width: | Height: | Size: 136 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 491 KiB |
@@ -32,6 +32,34 @@ mac:
|
||||
dmg:
|
||||
artifactName: multica-desktop-${version}-mac-${arch}.${ext}
|
||||
linux:
|
||||
# Override the Linux executable name to avoid leaking the scoped npm
|
||||
# package name (`@multica/desktop`) into the installed binary, the
|
||||
# `.desktop` file, and the hicolor icon filename. Without this override
|
||||
# electron-builder defaults `executableName` to the package `name`,
|
||||
# which after slash-stripping becomes `@multicadesktop` — producing
|
||||
# `/usr/share/applications/@multicadesktop.desktop`,
|
||||
# `Icon=@multicadesktop`, and
|
||||
# `/usr/share/icons/hicolor/*/apps/@multicadesktop.png`. The leading `@`
|
||||
# violates the freedesktop desktop-entry naming guidance, so GNOME /
|
||||
# Ubuntu fail to associate the running window with the `.desktop` entry
|
||||
# and fall back to the theme's default app icon (the Settings gear on
|
||||
# Yaru). Forcing `multica` makes every Linux identity slot agree and
|
||||
# matches `StartupWMClass=Multica` (productName-derived).
|
||||
executableName: multica
|
||||
# Pin StartupWMClass explicitly to the WM_CLASS that Electron emits on
|
||||
# X11. Electron derives WM_CLASS from `app.getName()`, which in packaged
|
||||
# builds resolves to `productName` (`Multica`). Without an explicit
|
||||
# `StartupWMClass`, electron-builder writes `productName` as the default
|
||||
# — making this declaration redundant with current settings — but
|
||||
# pinning the value here turns a silent future drift (e.g. if anyone
|
||||
# renames productName or sets app.setName at boot) into a visible diff
|
||||
# against this file. The WM_CLASS ↔ StartupWMClass match is what lets
|
||||
# GNOME associate the running window with the `.desktop` entry and
|
||||
# therefore render the right icon. The post-build verification step in
|
||||
# PR #2437 is `xprop WM_CLASS` on a real Ubuntu install.
|
||||
desktop:
|
||||
entry:
|
||||
StartupWMClass: Multica
|
||||
target:
|
||||
- AppImage
|
||||
- deb
|
||||
|
||||
@@ -11,10 +11,25 @@ import { getAppVersion } from "./app-version";
|
||||
import { loadRuntimeConfig } from "./runtime-config-loader";
|
||||
import type { RuntimeConfigResult } from "../shared/runtime-config";
|
||||
|
||||
// Bundled icon used for dev-mode dock/taskbar branding. In production the
|
||||
// app bundle icon (from electron-builder) wins; this path is only consumed
|
||||
// by the `is.dev` branch below.
|
||||
const DEV_ICON_PATH = join(__dirname, "../../resources/icon.png");
|
||||
// Bundled icon used for dock/taskbar branding. macOS/Windows production
|
||||
// builds let the OS pick up the icon from the .app bundle / .exe resources,
|
||||
// but Linux production needs an explicit BrowserWindow `icon` — AppImage
|
||||
// direct-launch doesn't register the .desktop entry, so GNOME has no path
|
||||
// from the running window to the hicolor icon and falls back to the
|
||||
// theme default. Consumed in createWindow() (all platforms in dev, Linux
|
||||
// in prod) and the macOS dev dock branch.
|
||||
//
|
||||
// `asarUnpack: resources/**` in electron-builder.yml extracts the icon to
|
||||
// `app.asar.unpacked/`, but `__dirname` resolves into `app.asar/`. The
|
||||
// Linux native window-icon code path expects a real filesystem path
|
||||
// (unlike Electron's nativeImage loader which transparently reads from
|
||||
// asar), so swap the segment — same pattern as bundledCliPath() in
|
||||
// daemon-manager.ts. In dev `__dirname` has no `app.asar`, so the replace
|
||||
// is a no-op.
|
||||
const BUNDLED_ICON_PATH = join(__dirname, "../../resources/icon.png").replace(
|
||||
"app.asar",
|
||||
"app.asar.unpacked",
|
||||
);
|
||||
|
||||
// macOS/Linux GUI launches inherit a minimal PATH from launchd that omits
|
||||
// the user's shell config (~/.zshrc, Homebrew, nvm, ~/.local/bin, etc.).
|
||||
@@ -106,9 +121,14 @@ function createWindow(): void {
|
||||
trafficLightPosition: { x: 16, y: 13 },
|
||||
show: false,
|
||||
autoHideMenuBar: true,
|
||||
// Windows/Linux pick up the window/taskbar icon from this option in
|
||||
// dev — on macOS it's ignored (dock comes from app.dock.setIcon below).
|
||||
...(is.dev ? { icon: DEV_ICON_PATH } : {}),
|
||||
// Windows/Linux pick up the window/taskbar icon from this option.
|
||||
// On macOS it's ignored (dock comes from app.dock.setIcon below).
|
||||
// Linux production needs this explicitly because AppImage direct-launch
|
||||
// does not install a .desktop entry, so the WM has no other path to
|
||||
// the bundled icon; without it Ubuntu falls back to the theme default.
|
||||
...(is.dev || process.platform === "linux"
|
||||
? { icon: BUNDLED_ICON_PATH }
|
||||
: {}),
|
||||
webPreferences: {
|
||||
preload: join(__dirname, "../preload/index.js"),
|
||||
sandbox: false,
|
||||
@@ -251,7 +271,7 @@ if (!gotTheLock) {
|
||||
// so the Canary dev build is visually distinct from a stock Electron
|
||||
// run. `app.dock` is macOS-only — guard the call.
|
||||
if (is.dev && process.platform === "darwin" && app.dock) {
|
||||
const icon = nativeImage.createFromPath(DEV_ICON_PATH);
|
||||
const icon = nativeImage.createFromPath(BUNDLED_ICON_PATH);
|
||||
if (!icon.isEmpty()) app.dock.setIcon(icon);
|
||||
}
|
||||
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -25,10 +25,6 @@ An autopilot has two execution modes. **Start with "create issue" mode.**
|
||||
- **Create issue mode** (`create_issue`) — default, **recommended**. Each trigger first creates an issue in the workspace (the title supports interpolation like `{{date}}`), then assigns the issue to the agent through the normal assignment flow. All work lands on the issue board with the same history, comments, and status as a manually assigned issue.
|
||||
- **Run-only mode** (`run_only`) — skips issue creation and enqueues a `task` directly. The run is invisible on the board — you can only see it in the autopilot's run history.
|
||||
|
||||
<Callout type="warning">
|
||||
**Run-only mode is currently unstable.** The CLI labels it "not yet supported end-to-end," and the dispatch path has known issues. New users should stick to create issue mode and wait for run-only mode to ship a stable release before switching.
|
||||
</Callout>
|
||||
|
||||
## Run it on a schedule
|
||||
|
||||
Every autopilot needs at least one `schedule` trigger. Cron uses the **standard 5-field format** (minute hour day month weekday), with **1-minute** minimum granularity (no seconds). Timezone is IANA-formatted (for example, `Asia/Shanghai`) and determines which timezone the cron expression is interpreted in.
|
||||
|
||||
@@ -25,10 +25,6 @@ Autopilot 有两种执行模式,**建议从"先建 issue 模式"开始**:
|
||||
- **先建 issue 模式**(`create_issue`)—— 默认,**推荐**。每次触发先在工作区里建一个 issue(标题支持 `{{date}}` 这样的插值),再按分配流程把 issue 派给智能体。所有工作都落在 issue 看板上,历史、评论、状态和手动分配的 issue 完全一致。
|
||||
- **直跑模式**(`run_only`)—— 不建 issue,直接入队一个 `task`。看板上看不到这一次运行——只能在 Autopilot 的运行历史里看到。
|
||||
|
||||
<Callout type="warning">
|
||||
**直跑模式当前不稳定**——目前在 CLI 里被标注为"not yet supported end-to-end",派发路径有已知问题。新用户只使用先建 issue 模式,等直跑模式 ship 稳定版再切。
|
||||
</Callout>
|
||||
|
||||
## 让它按时间跑
|
||||
|
||||
每个 Autopilot 至少要一个 `schedule` 触发器。Cron 是**标准 5 字段格式**(分 时 日 月 周),最小粒度 **1 分钟**(没有秒级)。时区用 IANA 格式(例如 `Asia/Shanghai`),决定 cron 表达式按哪个时区解读。
|
||||
|
||||
@@ -44,17 +44,21 @@ For the difference between token types, see [Authentication and tokens](/auth-to
|
||||
|
||||
## Issues and projects
|
||||
|
||||
<Callout type="info">
|
||||
`list` commands (`multica issue list`, `autopilot list`, `project list`, etc.) print short, copy-paste-ready IDs by default — issue keys like `MUL-123` for issues, short UUID prefixes for the rest. The `<id>` argument on the follow-up commands below accepts either the short ID or the full UUID, so the typical flow is `multica issue list` → copy the key → `multica issue get MUL-123`. Pass `--full-id` to a list command when you need the canonical UUID.
|
||||
</Callout>
|
||||
|
||||
| Command | Purpose |
|
||||
|---|---|
|
||||
| `multica issue list` | List issues |
|
||||
| `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 |
|
||||
@@ -99,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
|
||||
|
||||
@@ -44,17 +44,21 @@ Token 类型的详细区分见 [认证与令牌](/auth-tokens)。
|
||||
|
||||
## 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 |
|
||||
@@ -99,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 配置 |
|
||||
|
||||
## 杂项
|
||||
|
||||
@@ -244,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
|
||||
@@ -310,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
|
||||
|
||||
@@ -337,16 +337,47 @@ In production, put a reverse proxy in front of both the backend and frontend to
|
||||
|
||||
### Caddy (Recommended)
|
||||
|
||||
**Single-domain layout** — frontend and backend served on the same hostname (this is what `docker-compose.selfhost.yml` defaults to):
|
||||
|
||||
```
|
||||
multica.example.com {
|
||||
# WebSocket route — must come before the catch-all
|
||||
@multica_ws path /ws /ws/*
|
||||
handle @multica_ws {
|
||||
reverse_proxy localhost:8080 {
|
||||
flush_interval -1
|
||||
}
|
||||
}
|
||||
|
||||
# Everything else → frontend
|
||||
reverse_proxy localhost:3000
|
||||
}
|
||||
```
|
||||
|
||||
**Separate-domain layout** — frontend and backend on different hostnames:
|
||||
|
||||
```
|
||||
app.example.com {
|
||||
reverse_proxy localhost:3000
|
||||
}
|
||||
|
||||
api.example.com {
|
||||
@multica_ws path /ws /ws/*
|
||||
handle @multica_ws {
|
||||
reverse_proxy localhost:8080 {
|
||||
flush_interval -1
|
||||
}
|
||||
}
|
||||
|
||||
reverse_proxy localhost:8080
|
||||
}
|
||||
```
|
||||
|
||||
Two non-obvious bits inside the `/ws` block are worth calling out — both are common reasons real-time updates "stop working" on a Caddy-fronted self-host:
|
||||
|
||||
- **`path /ws /ws/*` (not `/ws*`)** — bare `handle /ws` is an exact match, so future path variants under `/ws/` fall through to the frontend block. The obvious shortcut `handle /ws*` overcorrects in the other direction: Caddy's `*` is a glob without a path-segment boundary, so it would also catch unrelated paths like `/ws-foo`, which is a legitimate workspace URL (only the exact slug `ws` is reserved). Listing `/ws` and `/ws/*` explicitly covers both real cases without overreach.
|
||||
- **`flush_interval -1`** — disables response buffering so WebSocket frames are forwarded as soon as they arrive. Without it, frames can sit behind Caddy's default flush window, which looks like delayed comments, missing typing indicators, or "comments only appear after a page refresh."
|
||||
|
||||
### Nginx
|
||||
|
||||
```nginx
|
||||
|
||||
@@ -69,7 +69,7 @@ Automatic retry also has two extra conditions:
|
||||
|
||||
## Manual rerun vs. automatic retry
|
||||
|
||||
A **manual rerun** is one you trigger from the 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>
|
||||
@@ -77,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:
|
||||
|
||||
@@ -87,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
|
||||
|
||||
@@ -98,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:
|
||||
|
||||
|
||||
@@ -69,7 +69,7 @@ Multica 服务器每 30 秒扫描一次,有两种超时会触发失败:
|
||||
|
||||
## 手动重跑和自动重试的区别
|
||||
|
||||
**手动重跑**(rerun)是你从 UI 或命令行主动发起的:
|
||||
**手动重跑**(rerun)是你通过命令行或 API(`POST /api/issues/{id}/rerun`)主动发起的:
|
||||
|
||||
```bash
|
||||
multica issue rerun <issue-id>
|
||||
@@ -77,9 +77,10 @@ multica issue rerun <issue-id>
|
||||
|
||||
行为:
|
||||
|
||||
- **取消**当前正在跑的任务(如果有)
|
||||
- 创建一个**全新**的执行任务——尝试次数重置为 1,即使原任务已达最大尝试
|
||||
- 继承上一次的会话 ID;如果对应的 AI 编程工具支持会话恢复,会接着上次的上下文继续
|
||||
- 跑的是 issue **当前的智能体分配人**——不是上一次跑过的 agent。如果分配人在上次运行后改了,rerun 会跟着新的分配人走。要重跑一个已经不再是分配人的智能体,先把 issue 改派回它,再 rerun。
|
||||
- **取消**该分配人在这条 issue 上 queued / running 的任务(如果有)。同 issue 上其它 agent 的任务(例如 @-mention 触发的并行任务)不会被一起取消。
|
||||
- 创建一个**全新**的执行任务——尝试次数重置为 1,即使原任务已达最大尝试。
|
||||
- 启动**全新的智能体会话**——**不**继承之前的会话 ID。手动重跑意味着你已经判定上一次的产出不行,再继续之前的对话只会重放被污染的上下文。(自动重试则相反,会继承会话——那条路径处理的是基础设施层面的失败,不是产出不好。)
|
||||
|
||||
对比:
|
||||
|
||||
@@ -87,8 +88,9 @@ multica issue rerun <issue-id>
|
||||
|---|---|---|
|
||||
| 触发 | 系统基于失败原因自动执行 | 你主动发起 |
|
||||
| 上限 | 2 次 | 无上限 |
|
||||
| 适用来源 | issue、聊天 | 所有来源 |
|
||||
| 会话继承 | 是 | 是 |
|
||||
| 适用来源 | issue、聊天 | 有智能体分配人的 issue |
|
||||
| 跑哪个 agent | 失败任务原本的 agent | issue 当前的分配人 |
|
||||
| 会话继承 | 是(接着上次会话) | 否(全新会话) |
|
||||
|
||||
## 失败的任务对 issue 状态有什么影响
|
||||
|
||||
@@ -98,7 +100,7 @@ multica issue rerun <issue-id>
|
||||
|
||||
可以——前提是对应的 AI 编程工具支持会话恢复。
|
||||
|
||||
Multica 在任务过程中**两次**保存会话 ID——任务一开始(AI 工具返回第一条系统消息时)pin 一次,任务结束(完成或失败)时再 pin 一次。前者让守护进程中途崩溃时也能恢复,后者给之后的重跑用。下次重跑或自动重试时把这个 ID 传回去,智能体就能接着上次的对话、文件状态继续。
|
||||
Multica 在任务过程中**两次**保存会话 ID——任务一开始(AI 工具返回第一条系统消息时)pin 一次,任务结束(完成或失败)时再 pin 一次。前者让守护进程中途崩溃时也能恢复,后者留给下一次**自动重试**——届时把这个 ID 传回去,智能体就能接着上次的对话和文件状态继续。**手动重跑会主动跳过这一步**,永远从全新会话开始——见 [手动重跑和自动重试的区别](#手动重跑和自动重试的区别)。
|
||||
|
||||
但**哪些 AI 编程工具真的支持**差别很大:
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -44,6 +44,15 @@ export function LandingHeader({
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center gap-2.5 sm:gap-3">
|
||||
<Link
|
||||
href="/changelog"
|
||||
className={cn(
|
||||
headerButtonClassName("ghost", variant),
|
||||
"hidden sm:inline-flex",
|
||||
)}
|
||||
>
|
||||
{t.header.changelog}
|
||||
</Link>
|
||||
<Link
|
||||
href={githubUrl}
|
||||
target="_blank"
|
||||
|
||||
@@ -7,6 +7,7 @@ export function createEnDict(allowSignup: boolean): LandingDict {
|
||||
github: "GitHub",
|
||||
login: "Log in",
|
||||
dashboard: "Dashboard",
|
||||
changelog: "Changelog",
|
||||
},
|
||||
|
||||
hero: {
|
||||
@@ -283,6 +284,121 @@ export function createEnDict(allowSignup: boolean): LandingDict {
|
||||
fixes: "Bug Fixes",
|
||||
},
|
||||
entries: [
|
||||
{
|
||||
version: "0.2.30",
|
||||
date: "2026-05-11",
|
||||
title: "Mermaid in Issues, Per-Runtime Timezone & Workspace-Leave Runtime Revocation",
|
||||
changes: [],
|
||||
features: [
|
||||
"Mermaid diagrams render inline in issue descriptions",
|
||||
"Sub-issue rows gain inline status and assignee pickers, with batch select across rows",
|
||||
"Per-runtime timezone for token-usage aggregation, so daily rollups respect your local day",
|
||||
"Private agents are gated by an `allowed_principals` predicate, with fine-grained visibility",
|
||||
"A member leaving or being removed from a workspace now revokes their runtimes automatically",
|
||||
"Set custom per-token prices for unmaintained models so usage reflects real cost",
|
||||
"Landing page header gains a Changelog link",
|
||||
],
|
||||
improvements: [
|
||||
"Daemon self-heals when a runtime is deleted server-side — no more zombie local entries",
|
||||
"Chat and comment composer share the same `Mod+Enter` send shortcut",
|
||||
"Copilot CLI model catalog expanded with correct dotted IDs",
|
||||
"Copilot failure details now surface in the UI instead of a generic error",
|
||||
"Daemon brief is inlined into the system prompt for providers that need it",
|
||||
"Realtime WebSocket accepts same-origin upgrades from mobile and CLI",
|
||||
],
|
||||
fixes: [
|
||||
"Recent-issues list no longer leaks across workspaces",
|
||||
"CloudFront attachment download URLs are re-signed at click time, fixing expired previews",
|
||||
"Windows reply templates use `--content-file` across every provider so non-ASCII bodies survive",
|
||||
"Daemon suppresses extra git console pop-ups on Windows",
|
||||
"Pi extension tools are no longer filtered by a hardcoded `--tools` allowlist",
|
||||
"Inbox scrolls to the target comment once the issue finishes loading",
|
||||
"`autopilot create/update` accepts `--mode run_only`",
|
||||
"Changelog header link styled to match the GitHub ghost button",
|
||||
"OpenAI Codex / GPT model pricing populated — cost no longer shows $0",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.2.29",
|
||||
date: "2026-05-09",
|
||||
title: "Project Picker in Quick Create, Resolvable Comments & Timeline Performance",
|
||||
changes: [],
|
||||
features: [
|
||||
"Quick Create lets you pick a project, and remembers your last choice",
|
||||
"Comment threads can be resolved and collapsed, keeping long discussions tidy",
|
||||
"Issue live banner now shows agent tasks waiting in queue",
|
||||
"Failed or cancelled tasks can be rerun in one click from the Execution Log",
|
||||
"Agent Create modal gains an expand button for editing long descriptions",
|
||||
],
|
||||
improvements: [
|
||||
"Issue timeline no longer fully re-renders on every WebSocket event — long issues scroll smoothly",
|
||||
"Editor skips parsing very large or JSON pastes, eliminating freezes",
|
||||
"Autopilot skips dispatch when the assignee runtime is offline, avoiding empty runs",
|
||||
"Inbox auto-archives `task_failed` rows once they reach a terminal state",
|
||||
"Hermes sends agent instructions inline with each request",
|
||||
"Timeline and Comment switched to client-side virtualization, dropping server-side pagination",
|
||||
"Reserved slugs share a single JSON between front and back end, with CI guarding drift",
|
||||
"ACP error messages include the JSON-RPC `error.data` field for clearer debugging",
|
||||
],
|
||||
fixes: [
|
||||
"429 / insufficient-balance agent runs are now marked `failed` instead of `completed`",
|
||||
"Agent sessions stuck on poisoned images can recover, so the issue resumes",
|
||||
"`pi --list-models` table format parses correctly, restoring model discovery",
|
||||
"`pi` colon-to-slash normalization only applies to the legacy format",
|
||||
"`kiro` and `kimi` added to the inline-system-prompt provider allowlist",
|
||||
"Priority dropdown badge colors aligned with PriorityIcon semantic tokens",
|
||||
"Long single-line agent messages now expand correctly",
|
||||
"Desktop \"copy issue link\" uses the current connection URL instead of localhost",
|
||||
"Mobile WebSocket handshake succeeds without cookies",
|
||||
"Workspace slug creation validates reserved words; slug error messages are translated",
|
||||
"Timeline correctly syncs `around` state when props flip to falsy",
|
||||
"DropdownMenu popovers size to their content",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.2.28",
|
||||
date: "2026-05-08",
|
||||
title: "Daemon Disk-Usage CLI, Timeline Polish & Task Usage Rollup",
|
||||
changes: [],
|
||||
features: [
|
||||
"New `multica daemon disk-usage` CLI surfaces per-task and per-workspace disk footprint",
|
||||
"Skill picker in agent settings has a search box for fast lookup",
|
||||
"Daemon GC extends to chat, autopilot, and quick-create tasks",
|
||||
"Issue detail breadcrumb now shows the MUL-xxxx identifier for quick reference",
|
||||
],
|
||||
improvements: [
|
||||
"Timeline page size bumped to 50, with per-pool keyset cursors for comments and activities",
|
||||
"'Show older / newer' affordances now appear in edge cases and look clearly clickable",
|
||||
"Server `task_usage` rolls up into a daily aggregate table, dropping DB load significantly",
|
||||
"Daemon health check stays responsive while repo lookups are in flight",
|
||||
"Runtime stats exclude archived agents for accurate active counts",
|
||||
],
|
||||
fixes: [
|
||||
"Linux daemon self-restart uses `brew prefix` symlinks, so Homebrew Cellar deletion no longer orphans runtimes",
|
||||
"CLI short IDs now route correctly — copied prefixes no longer 404",
|
||||
"Windows non-ASCII comment / description input lands via new `--content-file` / `--description-file` flags",
|
||||
"Windows / Linux desktop replaces the Electron placeholder icon with the Multica asterisk",
|
||||
"Orphaned timeline replies are now correctly surfaced",
|
||||
"Timeline comment pagination budget excludes activities, so heavy activity no longer crowds out real comments",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.2.27",
|
||||
date: "2026-05-07",
|
||||
title: "Smoother Chat, GitHub Skill Import & Stability Fixes",
|
||||
changes: [],
|
||||
features: [
|
||||
"Import reusable skills directly from GitHub links",
|
||||
],
|
||||
improvements: [
|
||||
"Chat and Inbox feel smoother, with clearer history, easier reply copying, and faster triage after archiving",
|
||||
"Issue actions keep more context, from easier access to the local folder to sub-issues inheriting the right project and status",
|
||||
"Autopilots pause themselves after repeated failures, so noisy automations are easier to catch and fix",
|
||||
],
|
||||
fixes: [
|
||||
"Chinese input, desktop updates, long issue timelines, and live status updates are more reliable",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.2.26",
|
||||
date: "2026-05-06",
|
||||
|
||||
@@ -20,7 +20,7 @@ type FooterGroup = {
|
||||
};
|
||||
|
||||
export type LandingDict = {
|
||||
header: { github: string; login: string; dashboard: string };
|
||||
header: { github: string; login: string; dashboard: string; changelog: string };
|
||||
hero: {
|
||||
headlineLine1: string;
|
||||
headlineLine2: string;
|
||||
|
||||
@@ -7,6 +7,7 @@ export function createZhDict(allowSignup: boolean): LandingDict {
|
||||
github: "GitHub",
|
||||
login: "\u767b\u5f55",
|
||||
dashboard: "\u8fdb\u5165\u5de5\u4f5c\u53f0",
|
||||
changelog: "\u66f4\u65b0\u65e5\u5fd7",
|
||||
},
|
||||
|
||||
hero: {
|
||||
@@ -283,6 +284,121 @@ export function createZhDict(allowSignup: boolean): LandingDict {
|
||||
fixes: "问题修复",
|
||||
},
|
||||
entries: [
|
||||
{
|
||||
version: "0.2.30",
|
||||
date: "2026-05-11",
|
||||
title: "Issue 内 Mermaid、Runtime 时区聚合与离开 Workspace 自动吊销",
|
||||
changes: [],
|
||||
features: [
|
||||
"Issue 描述内联渲染 Mermaid 图表",
|
||||
"Sub-issue 行支持就地切换状态与 assignee,并支持跨行批量选中",
|
||||
"Token 用量按每个 runtime 自己的时区聚合,每日 rollup 与本地日期对齐",
|
||||
"私有 Agent 通过 `allowed_principals` 判定可见性,权限粒度更细",
|
||||
"成员离开或被移出 workspace 时,自动吊销其名下的 runtime",
|
||||
"对未维护的模型支持自定义 token 价格,使用量真实反映成本",
|
||||
"Landing 页面 header 加入 Changelog 入口",
|
||||
],
|
||||
improvements: [
|
||||
"服务端删除 runtime 时,daemon 端自我修复,不再留下僵尸条目",
|
||||
"Chat 与评论输入框统一使用 `Mod+Enter` 发送",
|
||||
"Copilot CLI 模型目录补齐正确的 dotted ID",
|
||||
"Copilot 失败详情直接在 UI 中透出,不再只是一个通用错误",
|
||||
"Daemon brief 直接内联进 system prompt,针对需要的 provider 生效",
|
||||
"Realtime WebSocket 放行同源升级,移动端与 CLI 可正常握手",
|
||||
],
|
||||
fixes: [
|
||||
"Recent issues 列表不再跨 workspace 串扰",
|
||||
"CloudFront 附件下载链接在点击时重新签名,过期预览的问题修复",
|
||||
"所有 provider 的 Windows reply 模板改用 `--content-file`,非 ASCII 内容不再丢失",
|
||||
"Daemon 抑制 Windows 上多余的 git 控制台弹窗",
|
||||
"Pi 插件工具不再被硬编码的 `--tools` allowlist 过滤掉",
|
||||
"Inbox 在 issue 加载完成后再滚动到目标评论",
|
||||
"`autopilot create/update` 允许 `--mode run_only`",
|
||||
"Changelog header 链接样式对齐 GitHub ghost button",
|
||||
"OpenAI Codex / GPT 模型价格补齐,使用成本不再显示为 $0",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.2.29",
|
||||
date: "2026-05-09",
|
||||
title: "Quick Create 项目选择器、评论可折叠与 Timeline 性能优化",
|
||||
changes: [],
|
||||
features: [
|
||||
"Quick Create 支持选择 project,并记住上一次的选项",
|
||||
"评论 thread 支持解决并折叠,长讨论看起来更清爽",
|
||||
"Issue Live Banner 显示 agent 队列中等待执行的任务",
|
||||
"失败 / 取消的任务可以在 Execution Log 一键重跑",
|
||||
"Agent Create 弹窗新增放大按钮,长描述编辑更舒服",
|
||||
],
|
||||
improvements: [
|
||||
"Issue Timeline 不再因每个 WS 事件做完整 re-render,长 Issue 滚动更顺",
|
||||
"Editor 跳过对超大文本 / JSON 粘贴的解析,避免卡顿",
|
||||
"Autopilot 在 assignee runtime 离线时跳过 dispatch,避免空跑",
|
||||
"Inbox 自动归档处于终态的 `task_failed` 行",
|
||||
"Hermes 把 agent instructions 直接随请求内联传入",
|
||||
"Timeline / Comment 改为纯客户端虚拟化,去掉服务端分页",
|
||||
"Reserved slugs 前后端共享同一份 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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
170
e2e/chat-attachments.spec.ts
Normal file
170
e2e/chat-attachments.spec.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* E2E: chat attachment upload + send back-fills the message link.
|
||||
*
|
||||
* Stays at the HTTP layer (auth → upload-file → send-chat-message → DB
|
||||
* check) so the test doesn't depend on a real agent runtime being online.
|
||||
* The UI wiring is covered by `chat-input.test.tsx` in @multica/views; this
|
||||
* spec is the end-to-end contract proof: the backend really does persist
|
||||
* chat_session_id at upload and back-fill chat_message_id at send.
|
||||
*/
|
||||
import "./env";
|
||||
import { test, expect } from "@playwright/test";
|
||||
import pg from "pg";
|
||||
import { createTestApi } from "./helpers";
|
||||
import type { TestApiClient } from "./fixtures";
|
||||
|
||||
const API_BASE =
|
||||
process.env.NEXT_PUBLIC_API_URL || `http://localhost:${process.env.PORT || "8080"}`;
|
||||
const DATABASE_URL =
|
||||
process.env.DATABASE_URL ?? "postgres://multica:multica@localhost:5432/multica?sslmode=disable";
|
||||
|
||||
interface UploadRow {
|
||||
id: string;
|
||||
url: string;
|
||||
chat_session_id: string | null;
|
||||
chat_message_id: string | null;
|
||||
}
|
||||
|
||||
async function authedFetch(api: TestApiClient, path: string, init?: RequestInit) {
|
||||
const token = api.getToken();
|
||||
if (!token) throw new Error("test api client not logged in");
|
||||
const headers: Record<string, string> = {
|
||||
Authorization: `Bearer ${token}`,
|
||||
...((init?.headers as Record<string, string>) ?? {}),
|
||||
};
|
||||
return fetch(`${API_BASE}${path}`, { ...init, headers });
|
||||
}
|
||||
|
||||
test.describe("Chat attachments", () => {
|
||||
let api: TestApiClient;
|
||||
let pgClient: pg.Client | null = null;
|
||||
let createdSessionId: string | null = null;
|
||||
let createdAgentId: string | null = null;
|
||||
let createdRuntimeId: string | null = null;
|
||||
|
||||
test.beforeEach(async () => {
|
||||
api = await createTestApi();
|
||||
pgClient = new pg.Client(DATABASE_URL);
|
||||
await pgClient.connect();
|
||||
});
|
||||
|
||||
test.afterEach(async () => {
|
||||
try {
|
||||
if (pgClient) {
|
||||
if (createdSessionId) {
|
||||
await pgClient.query(`DELETE FROM chat_session WHERE id = $1`, [createdSessionId]);
|
||||
}
|
||||
if (createdAgentId) {
|
||||
await pgClient.query(`DELETE FROM agent WHERE id = $1`, [createdAgentId]);
|
||||
}
|
||||
if (createdRuntimeId) {
|
||||
await pgClient.query(`DELETE FROM agent_runtime WHERE id = $1`, [createdRuntimeId]);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (pgClient) await pgClient.end();
|
||||
pgClient = null;
|
||||
createdSessionId = null;
|
||||
createdAgentId = null;
|
||||
createdRuntimeId = null;
|
||||
await api.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("upload-file binds attachment to the chat_session; send back-fills chat_message_id", async () => {
|
||||
expect(pgClient).not.toBeNull();
|
||||
const pgc = pgClient!;
|
||||
|
||||
// Resolve the workspace + caller so we can seed an agent/runtime/session
|
||||
// directly via SQL. Going through the HTTP API would require modelling
|
||||
// local-daemon ownership which isn't needed for this contract test.
|
||||
const workspaces = await api.getWorkspaces();
|
||||
const ws = workspaces[0]!;
|
||||
api.setWorkspaceSlug(ws.slug);
|
||||
api.setWorkspaceId(ws.id);
|
||||
|
||||
const userRow = await pgc.query(
|
||||
`SELECT id FROM "user" WHERE email = $1 LIMIT 1`,
|
||||
["e2e@multica.ai"],
|
||||
);
|
||||
if (userRow.rows.length === 0) throw new Error("e2e user missing");
|
||||
const userId = userRow.rows[0].id as string;
|
||||
|
||||
// Seed runtime + agent + chat_session.
|
||||
const runtimeIns = await pgc.query(
|
||||
`INSERT INTO agent_runtime (
|
||||
workspace_id, daemon_id, name, runtime_mode, provider, status,
|
||||
device_info, metadata, last_seen_at
|
||||
)
|
||||
VALUES ($1, NULL, $2, 'cloud', $3, 'online', $4, '{}'::jsonb, now())
|
||||
RETURNING id`,
|
||||
[ws.id, `e2e chat runtime ${Date.now()}`, "e2e_chat_runtime", "E2E chat runtime"],
|
||||
);
|
||||
createdRuntimeId = runtimeIns.rows[0].id as string;
|
||||
|
||||
const agentIns = await pgc.query(
|
||||
`INSERT INTO agent (
|
||||
workspace_id, name, description, runtime_mode, runtime_config,
|
||||
runtime_id, visibility, max_concurrent_tasks, owner_id
|
||||
)
|
||||
VALUES ($1, $2, '', 'cloud', '{}'::jsonb, $3, 'workspace', 1, $4)
|
||||
RETURNING id`,
|
||||
[ws.id, `E2E Chat Agent ${Date.now()}`, createdRuntimeId, userId],
|
||||
);
|
||||
createdAgentId = agentIns.rows[0].id as string;
|
||||
|
||||
const sessionIns = await pgc.query(
|
||||
`INSERT INTO chat_session (workspace_id, agent_id, creator_id, title, status)
|
||||
VALUES ($1, $2, $3, 'E2E Chat Attachment Session', 'active')
|
||||
RETURNING id`,
|
||||
[ws.id, createdAgentId, userId],
|
||||
);
|
||||
createdSessionId = sessionIns.rows[0].id as string;
|
||||
|
||||
// 1. Upload a small PNG against the chat session.
|
||||
const pngBytes = Buffer.from([
|
||||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, // PNG signature
|
||||
0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, // IHDR
|
||||
]);
|
||||
const form = new FormData();
|
||||
form.append("file", new Blob([new Uint8Array(pngBytes)], { type: "image/png" }), "e2e.png");
|
||||
form.append("chat_session_id", createdSessionId);
|
||||
const uploadRes = await authedFetch(api, "/api/upload-file", {
|
||||
method: "POST",
|
||||
body: form,
|
||||
headers: { "X-Workspace-Slug": ws.slug },
|
||||
});
|
||||
expect(uploadRes.status).toBe(200);
|
||||
const uploaded = (await uploadRes.json()) as UploadRow;
|
||||
expect(uploaded.chat_session_id).toBe(createdSessionId);
|
||||
expect(uploaded.chat_message_id).toBeNull();
|
||||
expect(uploaded.url).toBeTruthy();
|
||||
|
||||
// 2. Send a chat message that references the attachment.
|
||||
const sendRes = await authedFetch(api, `/api/chat/sessions/${createdSessionId}/messages`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-Workspace-Slug": ws.slug,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
content: `look at this `,
|
||||
attachment_ids: [uploaded.id],
|
||||
}),
|
||||
});
|
||||
expect(sendRes.status).toBe(201);
|
||||
const sendBody = (await sendRes.json()) as { message_id: string; task_id: string };
|
||||
expect(sendBody.message_id).toBeTruthy();
|
||||
|
||||
// 3. DB check: the attachment row's chat_message_id matches the new message.
|
||||
const after = await pgc.query<{ chat_message_id: string | null }>(
|
||||
`SELECT chat_message_id::text FROM attachment WHERE id = $1`,
|
||||
[uploaded.id],
|
||||
);
|
||||
expect(after.rows[0]?.chat_message_id).toBe(sendBody.message_id);
|
||||
|
||||
// 4. Clean up the attachment we created (chat_session cascade handles the
|
||||
// rest in afterEach via chat_session row deletion).
|
||||
await pgc.query(`DELETE FROM attachment WHERE id = $1`, [uploaded.id]);
|
||||
});
|
||||
});
|
||||
@@ -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": {
|
||||
|
||||
@@ -49,6 +49,8 @@ function makeRuntime(overrides: Partial<AgentRuntime> = {}): AgentRuntime {
|
||||
device_info: "",
|
||||
metadata: {},
|
||||
owner_id: null,
|
||||
visibility: "private",
|
||||
timezone: "UTC",
|
||||
last_seen_at: "2026-04-27T11:59:50Z",
|
||||
created_at: "2026-04-01T00:00:00Z",
|
||||
updated_at: "2026-04-01T00:00:00Z",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -144,4 +144,123 @@ describe("ApiClient", () => {
|
||||
expect(headers["X-Client-Version"]).toBeUndefined();
|
||||
expect(headers["X-Client-OS"]).toBeUndefined();
|
||||
});
|
||||
|
||||
describe("getAttachment", () => {
|
||||
it("returns the parsed attachment for a well-formed response", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
id: "att-1",
|
||||
workspace_id: "ws-1",
|
||||
issue_id: null,
|
||||
comment_id: null,
|
||||
uploader_type: "member",
|
||||
uploader_id: "u-1",
|
||||
filename: "report.md",
|
||||
url: "https://static.example.test/ws/att-1.md",
|
||||
download_url:
|
||||
"https://static.example.test/ws/att-1.md?Policy=p&Signature=s&Key-Pair-Id=k",
|
||||
content_type: "text/markdown",
|
||||
size_bytes: 123,
|
||||
created_at: "2026-05-11T00:00:00Z",
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const client = new ApiClient("https://api.example.test");
|
||||
const att = await client.getAttachment("att-1");
|
||||
|
||||
expect(att.id).toBe("att-1");
|
||||
expect(att.download_url).toContain("Policy=");
|
||||
});
|
||||
|
||||
it("falls back to an empty attachment when the response is missing download_url", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({ id: "att-1" }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const client = new ApiClient("https://api.example.test");
|
||||
const att = await client.getAttachment("att-1");
|
||||
|
||||
// parseWithFallback returns the EMPTY_ATTACHMENT record so callers can
|
||||
// safely read `download_url` without crashing — they'll see "" and
|
||||
// surface a user-facing error instead of opening `undefined`.
|
||||
expect(att.id).toBe("");
|
||||
expect(att.download_url).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("chat attachment wiring", () => {
|
||||
it("uploadFile includes chat_session_id in the FormData body", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({ id: "att-1", url: "https://cdn/x" }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const client = new ApiClient("https://api.example.test");
|
||||
const file = new File(["hi"], "hi.png", { type: "image/png" });
|
||||
await client.uploadFile(file, { chatSessionId: "session-123" });
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const [url, init] = fetchMock.mock.calls[0]!;
|
||||
expect(url).toBe("https://api.example.test/api/upload-file");
|
||||
expect(init?.method).toBe("POST");
|
||||
const body = init?.body as FormData;
|
||||
expect(body).toBeInstanceOf(FormData);
|
||||
expect(body.get("chat_session_id")).toBe("session-123");
|
||||
expect(body.get("issue_id")).toBeNull();
|
||||
expect(body.get("comment_id")).toBeNull();
|
||||
});
|
||||
|
||||
it("sendChatMessage serialises attachment_ids onto the JSON body when present", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({ message_id: "m1", task_id: "t1", created_at: "" }), {
|
||||
status: 201,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const client = new ApiClient("https://api.example.test");
|
||||
await client.sendChatMessage("session-1", "hello", ["att-1", "att-2"]);
|
||||
|
||||
const [, init] = fetchMock.mock.calls[0]!;
|
||||
expect(JSON.parse(init?.body as string)).toEqual({
|
||||
content: "hello",
|
||||
attachment_ids: ["att-1", "att-2"],
|
||||
});
|
||||
});
|
||||
|
||||
it("sendChatMessage omits attachment_ids when the list is empty or undefined", async () => {
|
||||
const fetchMock = vi.fn().mockImplementation(() =>
|
||||
Promise.resolve(
|
||||
new Response(JSON.stringify({ message_id: "m1", task_id: "t1", created_at: "" }), {
|
||||
status: 201,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const client = new ApiClient("https://api.example.test");
|
||||
await client.sendChatMessage("session-1", "hello");
|
||||
await client.sendChatMessage("session-1", "again", []);
|
||||
|
||||
expect(JSON.parse(fetchMock.mock.calls[0]![1]?.body as string)).toEqual({ content: "hello" });
|
||||
expect(JSON.parse(fetchMock.mock.calls[1]![1]?.body as string)).toEqual({ content: "again" });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -43,8 +43,7 @@ import type {
|
||||
RuntimeLocalSkillListRequest,
|
||||
CreateRuntimeLocalSkillImportRequest,
|
||||
RuntimeLocalSkillImportRequest,
|
||||
TimelinePage,
|
||||
TimelinePageParam,
|
||||
TimelineEntry,
|
||||
AssigneeFrequencyEntry,
|
||||
TaskMessagePayload,
|
||||
Attachment,
|
||||
@@ -82,11 +81,26 @@ import type {
|
||||
ListAutopilotRunsResponse,
|
||||
NotificationPreferenceResponse,
|
||||
NotificationPreferences,
|
||||
GitHubPullRequest,
|
||||
ListGitHubInstallationsResponse,
|
||||
GitHubConnectResponse,
|
||||
} from "../types";
|
||||
import type { OnboardingCompletionPath } from "../onboarding/types";
|
||||
import { type Logger, noopLogger } from "../logger";
|
||||
import { createRequestId } from "../utils";
|
||||
import { getCurrentSlug } from "../platform/workspace-storage";
|
||||
import { parseWithFallback } from "./schema";
|
||||
import {
|
||||
AttachmentResponseSchema,
|
||||
ChildIssuesResponseSchema,
|
||||
CommentsListSchema,
|
||||
EMPTY_ATTACHMENT,
|
||||
EMPTY_LIST_ISSUES_RESPONSE,
|
||||
EMPTY_TIMELINE_ENTRIES,
|
||||
ListIssuesResponseSchema,
|
||||
SubscribersListSchema,
|
||||
TimelineEntriesSchema,
|
||||
} from "./schemas";
|
||||
|
||||
/** Identifies the calling client to the server.
|
||||
* Sent on every HTTP request as X-Client-Platform / X-Client-Version /
|
||||
@@ -324,6 +338,7 @@ export class ApiClient {
|
||||
|
||||
async markOnboardingComplete(payload?: {
|
||||
completion_path?: OnboardingCompletionPath;
|
||||
workspace_id?: string;
|
||||
}): Promise<User> {
|
||||
return this.fetch("/api/me/onboarding/complete", {
|
||||
method: "POST",
|
||||
@@ -398,7 +413,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> {
|
||||
@@ -428,7 +447,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),
|
||||
@@ -454,7 +473,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 }[] }> {
|
||||
@@ -481,7 +503,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> {
|
||||
@@ -496,17 +521,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[]> {
|
||||
@@ -524,6 +545,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",
|
||||
@@ -554,7 +583,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> {
|
||||
@@ -630,6 +662,16 @@ export class ApiClient {
|
||||
await this.fetch(`/api/runtimes/${runtimeId}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
async updateRuntime(
|
||||
runtimeId: string,
|
||||
patch: { timezone?: string; visibility?: "private" | "public" },
|
||||
): Promise<AgentRuntime> {
|
||||
return this.fetch(`/api/runtimes/${runtimeId}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(patch),
|
||||
});
|
||||
}
|
||||
|
||||
async getRuntimeUsage(runtimeId: string, params?: { days?: number }): Promise<RuntimeUsage[]> {
|
||||
const search = new URLSearchParams();
|
||||
if (params?.days) search.set("days", String(params.days));
|
||||
@@ -766,6 +808,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");
|
||||
@@ -818,6 +866,7 @@ export class ApiClient {
|
||||
google_client_id?: string;
|
||||
posthog_key?: string;
|
||||
posthog_host?: string;
|
||||
analytics_environment?: string;
|
||||
}> {
|
||||
return this.fetch("/api/config");
|
||||
}
|
||||
@@ -975,11 +1024,15 @@ export class ApiClient {
|
||||
}
|
||||
|
||||
// File Upload & Attachments
|
||||
async uploadFile(file: File, opts?: { issueId?: string; commentId?: string }): Promise<Attachment> {
|
||||
async uploadFile(
|
||||
file: File,
|
||||
opts?: { issueId?: string; commentId?: string; chatSessionId?: string },
|
||||
): Promise<Attachment> {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
if (opts?.issueId) formData.append("issue_id", opts.issueId);
|
||||
if (opts?.commentId) formData.append("comment_id", opts.commentId);
|
||||
if (opts?.chatSessionId) formData.append("chat_session_id", opts.chatSessionId);
|
||||
|
||||
const rid = createRequestId();
|
||||
const start = Date.now();
|
||||
@@ -1000,7 +1053,10 @@ export class ApiClient {
|
||||
}
|
||||
|
||||
this.logger.info(`← ${res.status} /api/upload-file`, { rid, duration: `${Date.now() - start}ms` });
|
||||
return res.json() as Promise<Attachment>;
|
||||
const raw = (await res.json()) as unknown;
|
||||
return parseWithFallback(raw, AttachmentResponseSchema, EMPTY_ATTACHMENT, {
|
||||
endpoint: "POST /api/upload-file",
|
||||
});
|
||||
}
|
||||
|
||||
// Chat Sessions
|
||||
@@ -1028,10 +1084,18 @@ export class ApiClient {
|
||||
return this.fetch(`/api/chat/sessions/${sessionId}/messages`);
|
||||
}
|
||||
|
||||
async sendChatMessage(sessionId: string, content: string): Promise<SendChatMessageResponse> {
|
||||
async sendChatMessage(
|
||||
sessionId: string,
|
||||
content: string,
|
||||
attachmentIds?: string[],
|
||||
): Promise<SendChatMessageResponse> {
|
||||
const body: { content: string; attachment_ids?: string[] } = { content };
|
||||
if (attachmentIds && attachmentIds.length > 0) {
|
||||
body.attachment_ids = attachmentIds;
|
||||
}
|
||||
return this.fetch(`/api/chat/sessions/${sessionId}/messages`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ content }),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1055,6 +1119,17 @@ export class ApiClient {
|
||||
return this.fetch(`/api/issues/${issueId}/attachments`);
|
||||
}
|
||||
|
||||
// Fetches a fresh attachment metadata record. The server re-signs
|
||||
// `download_url` on every call (30 min expiry), so the click-time
|
||||
// download flow uses this endpoint to avoid handing the user a stale
|
||||
// signed URL cached in TanStack Query.
|
||||
async getAttachment(id: string): Promise<Attachment> {
|
||||
const raw = await this.fetch<unknown>(`/api/attachments/${id}`);
|
||||
return parseWithFallback(raw, AttachmentResponseSchema, EMPTY_ATTACHMENT, {
|
||||
endpoint: "GET /api/attachments/{id}",
|
||||
});
|
||||
}
|
||||
|
||||
async deleteAttachment(id: string): Promise<void> {
|
||||
await this.fetch(`/api/attachments/${id}`, { method: "DELETE" });
|
||||
}
|
||||
@@ -1238,4 +1313,23 @@ export class ApiClient {
|
||||
async deleteAutopilotTrigger(autopilotId: string, triggerId: string): Promise<void> {
|
||||
await this.fetch(`/api/autopilots/${autopilotId}/triggers/${triggerId}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
// GitHub integration
|
||||
async getGitHubConnectURL(workspaceId: string): Promise<GitHubConnectResponse> {
|
||||
return this.fetch(`/api/workspaces/${workspaceId}/github/connect`);
|
||||
}
|
||||
|
||||
async listGitHubInstallations(workspaceId: string): Promise<ListGitHubInstallationsResponse> {
|
||||
return this.fetch(`/api/workspaces/${workspaceId}/github/installations`);
|
||||
}
|
||||
|
||||
async deleteGitHubInstallation(workspaceId: string, installationId: string): Promise<void> {
|
||||
await this.fetch(`/api/workspaces/${workspaceId}/github/installations/${installationId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
async listIssuePullRequests(issueId: string): Promise<{ pull_requests: GitHubPullRequest[] }> {
|
||||
return this.fetch(`/api/issues/${issueId}/pull-requests`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
171
packages/core/api/schemas.ts
Normal file
171
packages/core/api/schemas.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
import { z } from "zod";
|
||||
import type { Attachment, ListIssuesResponse, TimelineEntry } from "../types";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Schemas for the highest-risk API endpoints — those whose responses drive
|
||||
// the issue detail page (timeline, comments, subscribers) and the issues
|
||||
// list. These are the surfaces that white-screened in #2143 / #2147 / #2192.
|
||||
//
|
||||
// These schemas are intentionally LENIENT:
|
||||
// - String enums are stored as `z.string()` rather than `z.enum([...])`.
|
||||
// A new server-side enum value should render as a generic fallback in
|
||||
// the UI, never crash a `safeParse`.
|
||||
// - Optional fields are unioned with `null` and given fallbacks where
|
||||
// existing UI code already coerces them.
|
||||
// - Arrays default to `[]` so a missing `reactions` / `attachments` /
|
||||
// `entries` field doesn't take the page down.
|
||||
// - Every object schema ends with `.loose()` so unknown server-side
|
||||
// fields pass through unchanged. zod 4's `.object()` defaults to STRIP,
|
||||
// which would silently delete fields the schema didn't explicitly list
|
||||
// — fine while the TS type doesn't claim them, but the moment a future
|
||||
// PR adds a TS field without updating the schema, the cast `as T` lies
|
||||
// and the field shows up as `undefined` at runtime. `.loose()` removes
|
||||
// that synchronisation hazard.
|
||||
//
|
||||
// These schemas are deliberately not typed as `z.ZodType<TimelineEntry>` /
|
||||
// `z.ZodType<Issue>` etc. — the strict TS types narrow string fields to
|
||||
// literal unions, which would defeat the leniency above. `parseWithFallback`
|
||||
// returns the parsed value cast to the caller-supplied `T`, so the strict
|
||||
// type still flows out at the call site; the schema only guards shape.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const ReactionSchema = z.object({
|
||||
id: z.string(),
|
||||
comment_id: z.string(),
|
||||
actor_type: z.string(),
|
||||
actor_id: z.string(),
|
||||
emoji: z.string(),
|
||||
created_at: z.string(),
|
||||
});
|
||||
|
||||
// Nested attachments embedded in timeline/comment responses stay lenient on
|
||||
// purpose: a single malformed attachment must not knock the whole timeline
|
||||
// into the fallback `[]`.
|
||||
const AttachmentSchema = z.object({
|
||||
id: z.string(),
|
||||
}).loose();
|
||||
|
||||
// Standalone attachment lookup (`GET /api/attachments/{id}`) is the source of
|
||||
// truth for click-time download URLs. The two fields the download flow opens
|
||||
// in a new tab — `download_url` and `url` — must be strings, otherwise we'd
|
||||
// happily `window.open(undefined)`. `filename` gates the toast/title and is
|
||||
// also enforced so a missing value falls back to the empty record below.
|
||||
export const AttachmentResponseSchema = z.object({
|
||||
id: z.string(),
|
||||
url: z.string(),
|
||||
download_url: z.string(),
|
||||
filename: z.string(),
|
||||
chat_session_id: z.string().nullable().optional(),
|
||||
chat_message_id: z.string().nullable().optional(),
|
||||
}).loose();
|
||||
|
||||
export const EMPTY_ATTACHMENT: Attachment = {
|
||||
id: "",
|
||||
workspace_id: "",
|
||||
issue_id: null,
|
||||
comment_id: null,
|
||||
chat_session_id: null,
|
||||
chat_message_id: null,
|
||||
uploader_type: "",
|
||||
uploader_id: "",
|
||||
filename: "",
|
||||
url: "",
|
||||
download_url: "",
|
||||
content_type: "",
|
||||
size_bytes: 0,
|
||||
created_at: "",
|
||||
};
|
||||
|
||||
// All object schemas use `.loose()` so unknown server-side fields pass
|
||||
// through unchanged. zod 4's `.object()` defaults to STRIP, which would
|
||||
// silently drop new fields and surface as a "field neither showed up in
|
||||
// the UI" mystery the next time the TS type adopted them but the schema
|
||||
// wasn't updated in lock-step. `.loose()` removes that synchronisation
|
||||
// hazard — the schema validates the shape it knows about and leaves the
|
||||
// rest alone.
|
||||
const TimelineEntrySchema = z.object({
|
||||
type: z.string(),
|
||||
id: z.string(),
|
||||
actor_type: z.string(),
|
||||
actor_id: z.string(),
|
||||
created_at: z.string(),
|
||||
action: z.string().optional(),
|
||||
details: z.record(z.string(), z.unknown()).optional(),
|
||||
content: z.string().optional(),
|
||||
parent_id: z.string().nullable().optional(),
|
||||
updated_at: z.string().optional(),
|
||||
comment_type: z.string().optional(),
|
||||
reactions: z.array(ReactionSchema).optional(),
|
||||
attachments: z.array(AttachmentSchema).optional(),
|
||||
coalesced_count: z.number().optional(),
|
||||
}).loose();
|
||||
|
||||
// /timeline returns a flat array of TimelineEntry, oldest first. The
|
||||
// previously cursor-paginated wrapper was removed (#1929) — at observed data
|
||||
// sizes (p99 ~30 entries per issue) paged delivery only created bugs.
|
||||
export const TimelineEntriesSchema = z.array(TimelineEntrySchema);
|
||||
|
||||
export const EMPTY_TIMELINE_ENTRIES: TimelineEntry[] = [];
|
||||
|
||||
export const CommentSchema = z.object({
|
||||
id: z.string(),
|
||||
issue_id: z.string(),
|
||||
author_type: z.string(),
|
||||
author_id: z.string(),
|
||||
content: z.string(),
|
||||
type: z.string(),
|
||||
parent_id: z.string().nullable(),
|
||||
reactions: z.array(ReactionSchema).default([]),
|
||||
attachments: z.array(AttachmentSchema).default([]),
|
||||
created_at: z.string(),
|
||||
updated_at: z.string(),
|
||||
}).loose();
|
||||
|
||||
export const CommentsListSchema = z.array(CommentSchema);
|
||||
|
||||
const IssueSchema = z.object({
|
||||
id: z.string(),
|
||||
workspace_id: z.string(),
|
||||
number: z.number(),
|
||||
identifier: z.string(),
|
||||
title: z.string(),
|
||||
description: z.string().nullable(),
|
||||
status: z.string(),
|
||||
priority: z.string(),
|
||||
assignee_type: z.string().nullable(),
|
||||
assignee_id: z.string().nullable(),
|
||||
creator_type: z.string(),
|
||||
creator_id: z.string(),
|
||||
parent_issue_id: z.string().nullable(),
|
||||
project_id: z.string().nullable(),
|
||||
position: z.number(),
|
||||
due_date: z.string().nullable(),
|
||||
reactions: z.array(z.unknown()).optional(),
|
||||
labels: z.array(z.unknown()).optional(),
|
||||
created_at: z.string(),
|
||||
updated_at: z.string(),
|
||||
}).loose();
|
||||
|
||||
export const ListIssuesResponseSchema = z.object({
|
||||
issues: z.array(IssueSchema).default([]),
|
||||
total: z.number().default(0),
|
||||
}).loose();
|
||||
|
||||
export const EMPTY_LIST_ISSUES_RESPONSE: ListIssuesResponse = {
|
||||
issues: [],
|
||||
total: 0,
|
||||
};
|
||||
|
||||
const SubscriberSchema = z.object({
|
||||
issue_id: z.string(),
|
||||
user_type: z.string(),
|
||||
user_id: z.string(),
|
||||
reason: z.string(),
|
||||
created_at: z.string(),
|
||||
}).loose();
|
||||
|
||||
export const SubscribersListSchema = z.array(SubscriberSchema);
|
||||
|
||||
export const ChildIssuesResponseSchema = z.object({
|
||||
issues: z.array(IssueSchema).default([]),
|
||||
}).loose();
|
||||
@@ -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 });
|
||||
|
||||
1
packages/core/github/index.ts
Normal file
1
packages/core/github/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./queries";
|
||||
22
packages/core/github/queries.ts
Normal file
22
packages/core/github/queries.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { queryOptions } from "@tanstack/react-query";
|
||||
import { api } from "../api";
|
||||
|
||||
export const githubKeys = {
|
||||
all: (wsId: string) => ["github", wsId] as const,
|
||||
installations: (wsId: string) => [...githubKeys.all(wsId), "installations"] as const,
|
||||
pullRequests: (issueId: string) => ["github", "pull-requests", issueId] as const,
|
||||
};
|
||||
|
||||
export const githubInstallationsOptions = (wsId: string) =>
|
||||
queryOptions({
|
||||
queryKey: githubKeys.installations(wsId),
|
||||
queryFn: () => api.listGitHubInstallations(wsId),
|
||||
enabled: !!wsId,
|
||||
});
|
||||
|
||||
export const issuePullRequestsOptions = (issueId: string) =>
|
||||
queryOptions({
|
||||
queryKey: githubKeys.pullRequests(issueId),
|
||||
queryFn: () => api.listIssuePullRequests(issueId),
|
||||
enabled: !!issueId,
|
||||
});
|
||||
@@ -14,6 +14,7 @@ export interface UploadResult {
|
||||
export interface UploadContext {
|
||||
issueId?: string;
|
||||
commentId?: string;
|
||||
chatSessionId?: string;
|
||||
}
|
||||
|
||||
export function useFileUpload(
|
||||
@@ -33,6 +34,7 @@ export function useFileUpload(
|
||||
const att: Attachment = await api.uploadFile(file, {
|
||||
issueId: ctx?.issueId,
|
||||
commentId: ctx?.commentId,
|
||||
chatSessionId: ctx?.chatSessionId,
|
||||
});
|
||||
return { id: att.id, filename: att.filename, link: att.url };
|
||||
} finally {
|
||||
|
||||
@@ -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
|
||||
@@ -115,7 +109,7 @@ export function useCreateIssue() {
|
||||
);
|
||||
// Surface the just-created issue in cmd+k's Recent list without
|
||||
// requiring the user to open it first.
|
||||
useRecentIssuesStore.getState().recordVisit(newIssue.id);
|
||||
useRecentIssuesStore.getState().recordVisit(wsId, newIssue.id);
|
||||
// Invalidate parent's children query so sub-issues list updates immediately
|
||||
if (newIssue.parent_issue_id) {
|
||||
qc.invalidateQueries({ queryKey: issueKeys.children(wsId, newIssue.parent_issue_id) });
|
||||
@@ -145,11 +139,26 @@ export function useUpdateIssue() {
|
||||
|
||||
// Resolve parent_issue_id from the freshest source so we can keep the
|
||||
// parent's children cache in sync (used by the parent issue's
|
||||
// sub-issues list).
|
||||
const parentId =
|
||||
// sub-issues list). Falls back to scanning loaded children caches —
|
||||
// when the user navigates straight to a parent's detail page, the
|
||||
// child may live only there, not in detail/list.
|
||||
let parentId: string | null =
|
||||
prevDetail?.parent_issue_id ??
|
||||
(prevList ? findIssueLocation(prevList, id)?.issue.parent_issue_id : null) ??
|
||||
null;
|
||||
if (!parentId) {
|
||||
const childrenCaches = qc.getQueriesData<Issue[]>({
|
||||
queryKey: [...issueKeys.all(wsId), "children"],
|
||||
});
|
||||
for (const [key, data] of childrenCaches) {
|
||||
if (!data?.some((c) => c.id === id)) continue;
|
||||
const candidate = key[key.length - 1];
|
||||
if (typeof candidate === "string") {
|
||||
parentId = candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
const prevChildren = parentId
|
||||
? qc.getQueryData<Issue[]>(issueKeys.children(wsId, parentId))
|
||||
: undefined;
|
||||
@@ -250,13 +259,46 @@ export function useBatchUpdateIssues() {
|
||||
for (const id of ids) next = patchIssueInBuckets(next, id, updates);
|
||||
return next;
|
||||
});
|
||||
return { prevList };
|
||||
|
||||
// Mirror the optimistic patch into any loaded children cache so
|
||||
// sub-issue rows on a parent's detail page reflect the change too.
|
||||
const idSet = new Set(ids);
|
||||
const childrenCaches = qc.getQueriesData<Issue[]>({
|
||||
queryKey: [...issueKeys.all(wsId), "children"],
|
||||
});
|
||||
const prevChildren = new Map<string, Issue[] | undefined>();
|
||||
const affectedParentIds = new Set<string>();
|
||||
for (const [key, data] of childrenCaches) {
|
||||
if (!data?.some((c) => idSet.has(c.id))) continue;
|
||||
const parentId = key[key.length - 1];
|
||||
if (typeof parentId !== "string") continue;
|
||||
affectedParentIds.add(parentId);
|
||||
prevChildren.set(parentId, data);
|
||||
qc.setQueryData<Issue[]>(issueKeys.children(wsId, parentId), (old) =>
|
||||
old?.map((c) => (idSet.has(c.id) ? { ...c, ...updates } : c)),
|
||||
);
|
||||
}
|
||||
|
||||
return { prevList, prevChildren, affectedParentIds };
|
||||
},
|
||||
onError: (_err, _vars, ctx) => {
|
||||
if (ctx?.prevList) qc.setQueryData(issueKeys.list(wsId), ctx.prevList);
|
||||
if (ctx?.prevChildren) {
|
||||
for (const [parentId, snapshot] of ctx.prevChildren) {
|
||||
qc.setQueryData(issueKeys.children(wsId, parentId), snapshot);
|
||||
}
|
||||
}
|
||||
},
|
||||
onSettled: () => {
|
||||
onSettled: (_data, _err, _vars, ctx) => {
|
||||
qc.invalidateQueries({ queryKey: issueKeys.list(wsId) });
|
||||
if (ctx?.affectedParentIds && ctx.affectedParentIds.size > 0) {
|
||||
for (const parentId of ctx.affectedParentIds) {
|
||||
qc.invalidateQueries({
|
||||
queryKey: issueKeys.children(wsId, parentId),
|
||||
});
|
||||
}
|
||||
qc.invalidateQueries({ queryKey: issueKeys.childProgress(wsId) });
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -276,16 +318,40 @@ export function useBatchDeleteIssues() {
|
||||
if (loc?.issue.parent_issue_id) parentIssueIds.add(loc.issue.parent_issue_id);
|
||||
}
|
||||
}
|
||||
// Children cache may be the only place sub-issues live when the user
|
||||
// operates from a parent's detail page. Collect affected parents and
|
||||
// optimistically filter the deleted ids out of each children cache so
|
||||
// the row disappears immediately, mirroring the list-cache behaviour.
|
||||
const idSet = new Set(ids);
|
||||
const childrenCaches = qc.getQueriesData<Issue[]>({
|
||||
queryKey: [...issueKeys.all(wsId), "children"],
|
||||
});
|
||||
const prevChildren = new Map<string, Issue[] | undefined>();
|
||||
for (const [key, data] of childrenCaches) {
|
||||
if (!data?.some((c) => idSet.has(c.id))) continue;
|
||||
const parentId = key[key.length - 1];
|
||||
if (typeof parentId !== "string") continue;
|
||||
parentIssueIds.add(parentId);
|
||||
prevChildren.set(parentId, data);
|
||||
qc.setQueryData<Issue[]>(issueKeys.children(wsId, parentId), (old) =>
|
||||
old?.filter((c) => !idSet.has(c.id)),
|
||||
);
|
||||
}
|
||||
qc.setQueryData<ListIssuesCache>(issueKeys.list(wsId), (old) => {
|
||||
if (!old) return old;
|
||||
let next = old;
|
||||
for (const id of ids) next = removeIssueFromBuckets(next, id);
|
||||
return next;
|
||||
});
|
||||
return { prevList, parentIssueIds };
|
||||
return { prevList, prevChildren, parentIssueIds };
|
||||
},
|
||||
onError: (_err, _ids, ctx) => {
|
||||
if (ctx?.prevList) qc.setQueryData(issueKeys.list(wsId), ctx.prevList);
|
||||
if (ctx?.prevChildren) {
|
||||
for (const [parentId, snapshot] of ctx.prevChildren) {
|
||||
qc.setQueryData(issueKeys.children(wsId, parentId), snapshot);
|
||||
}
|
||||
}
|
||||
},
|
||||
onSettled: (_data, _err, _ids, ctx) => {
|
||||
qc.invalidateQueries({ queryKey: issueKeys.list(wsId) });
|
||||
@@ -303,6 +369,8 @@ export function useBatchDeleteIssues() {
|
||||
// Comments / Timeline
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type TimelineCache = TimelineEntry[];
|
||||
|
||||
export function useCreateComment(issueId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
@@ -318,11 +386,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 +399,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 +424,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 +447,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,19 +21,17 @@ 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,
|
||||
usage: (issueId: string) => ["issues", "usage", issueId] as const,
|
||||
/** Issue-level attachments — used by the description editor so its
|
||||
* inline file-card / image NodeViews can re-sign download URLs at
|
||||
* click time. */
|
||||
attachments: (issueId: string) => ["issues", "attachments", issueId] as const,
|
||||
/** Per-issue task list (issue-detail Execution log section). */
|
||||
tasks: (issueId: string) => ["issues", "tasks", issueId] as const,
|
||||
/** Prefix-match key for invalidating tasks across all issues — used by
|
||||
@@ -141,39 +137,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),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -200,3 +173,14 @@ export function issueUsageOptions(issueId: string) {
|
||||
queryFn: () => api.getIssueUsage(issueId),
|
||||
});
|
||||
}
|
||||
|
||||
// Backs the description editor's fresh-sign download flow: NodeViews resolve
|
||||
// an attachment id by matching the markdown URL against this list. The list
|
||||
// is workspace-private metadata and lives on the same cache lifetime as the
|
||||
// rest of the issue detail surface.
|
||||
export function issueAttachmentsOptions(issueId: string) {
|
||||
return queryOptions({
|
||||
queryKey: issueKeys.attachments(issueId),
|
||||
queryFn: () => api.listAttachments(issueId),
|
||||
});
|
||||
}
|
||||
|
||||
80
packages/core/issues/stores/comment-draft-store.ts
Normal file
80
packages/core/issues/stores/comment-draft-store.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { create } from "zustand";
|
||||
import { createJSONStorage, persist } from "zustand/middleware";
|
||||
import { createWorkspaceAwareStorage, registerForWorkspaceRehydration } from "../../platform/workspace-storage";
|
||||
import { defaultStorage } from "../../platform/storage";
|
||||
|
||||
/**
|
||||
* Per-comment draft persistence — survives:
|
||||
* - virtualization unmount (the reason this exists: when a TipTap editor
|
||||
* scrolls out of the Virtuoso viewport, its in-memory state is lost)
|
||||
* - tab close / accidental Cmd-W
|
||||
* - reload
|
||||
*
|
||||
* Keys are issue-scoped because createWorkspaceAwareStorage only partitions
|
||||
* by workspace, not by issue. Without issueId in the key, two issues with
|
||||
* thread replies open in adjacent desktop tabs would collide.
|
||||
*/
|
||||
|
||||
export type CommentDraftKey =
|
||||
| `new:${string}` // top-level CommentInput, key = `new:${issueId}`
|
||||
| `reply:${string}:${string}` // ReplyInput inside a thread, key = `reply:${issueId}:${rootCommentId}`
|
||||
| `edit:${string}:${string}`; // inline edit on existing comment, key = `edit:${issueId}:${commentId}`
|
||||
|
||||
interface CommentDraft {
|
||||
content: string;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
interface CommentDraftStore {
|
||||
drafts: Record<string, CommentDraft>;
|
||||
getDraft: (key: CommentDraftKey) => string | undefined;
|
||||
setDraft: (key: CommentDraftKey, content: string) => void;
|
||||
clearDraft: (key: CommentDraftKey) => void;
|
||||
}
|
||||
|
||||
// Drafts older than 30 days are dropped on store init. Without TTL the store
|
||||
// would accumulate every edit attempt across every issue indefinitely and
|
||||
// slowly leak localStorage quota.
|
||||
const TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
||||
|
||||
function pruneStaleDrafts(drafts: Record<string, CommentDraft>): Record<string, CommentDraft> {
|
||||
const cutoff = Date.now() - TTL_MS;
|
||||
const out: Record<string, CommentDraft> = {};
|
||||
for (const [k, v] of Object.entries(drafts)) {
|
||||
if (v.updatedAt >= cutoff && v.content.trim().length > 0) {
|
||||
out[k] = v;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export const useCommentDraftStore = create<CommentDraftStore>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
drafts: {},
|
||||
getDraft: (key) => get().drafts[key]?.content,
|
||||
setDraft: (key, content) =>
|
||||
set((s) => ({
|
||||
drafts: { ...s.drafts, [key]: { content, updatedAt: Date.now() } },
|
||||
})),
|
||||
clearDraft: (key) =>
|
||||
set((s) => {
|
||||
if (!(key in s.drafts)) return s;
|
||||
const next = { ...s.drafts };
|
||||
delete next[key];
|
||||
return { drafts: next };
|
||||
}),
|
||||
}),
|
||||
{
|
||||
name: "multica_comment_drafts",
|
||||
storage: createJSONStorage(() => createWorkspaceAwareStorage(defaultStorage)),
|
||||
onRehydrateStorage: () => (state) => {
|
||||
if (state) {
|
||||
state.drafts = pruneStaleDrafts(state.drafts);
|
||||
}
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
registerForWorkspaceRehydration(() => useCommentDraftStore.persist.rehydrate());
|
||||
@@ -1,7 +1,11 @@
|
||||
export { useIssueSelectionStore } from "./selection-store";
|
||||
export { useCreateModeStore, type CreateMode } from "./create-mode-store";
|
||||
export { useIssueDraftStore } from "./draft-store";
|
||||
export { useRecentIssuesStore, type RecentIssueEntry } from "./recent-issues-store";
|
||||
export {
|
||||
useRecentIssuesStore,
|
||||
selectRecentIssues,
|
||||
type RecentIssueEntry,
|
||||
} from "./recent-issues-store";
|
||||
export {
|
||||
ViewStoreProvider,
|
||||
useViewStore,
|
||||
@@ -9,6 +13,7 @@ export {
|
||||
} from "./view-store-context";
|
||||
export { useIssuesScopeStore, type IssuesScope } from "./issues-scope-store";
|
||||
export { useCommentCollapseStore } from "./comment-collapse-store";
|
||||
export { useCommentDraftStore, type CommentDraftKey } from "./comment-draft-store";
|
||||
export {
|
||||
myIssuesViewStore,
|
||||
type MyIssuesViewState,
|
||||
|
||||
@@ -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: "" }),
|
||||
|
||||
74
packages/core/issues/stores/recent-issues-store.test.ts
Normal file
74
packages/core/issues/stores/recent-issues-store.test.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { useRecentIssuesStore, selectRecentIssues } from "./recent-issues-store";
|
||||
|
||||
beforeEach(() => {
|
||||
useRecentIssuesStore.setState({ byWorkspace: {} });
|
||||
});
|
||||
|
||||
describe("useRecentIssuesStore.recordVisit", () => {
|
||||
it("keeps visits namespaced by workspace id", () => {
|
||||
const { recordVisit } = useRecentIssuesStore.getState();
|
||||
recordVisit("ws-a", "issue-1");
|
||||
recordVisit("ws-b", "issue-2");
|
||||
|
||||
const state = useRecentIssuesStore.getState().byWorkspace;
|
||||
expect(state["ws-a"]?.map((e) => e.id)).toEqual(["issue-1"]);
|
||||
expect(state["ws-b"]?.map((e) => e.id)).toEqual(["issue-2"]);
|
||||
});
|
||||
|
||||
it("moves the most recent visit to the front and dedupes", () => {
|
||||
const { recordVisit } = useRecentIssuesStore.getState();
|
||||
recordVisit("ws-a", "issue-1");
|
||||
recordVisit("ws-a", "issue-2");
|
||||
recordVisit("ws-a", "issue-1");
|
||||
|
||||
const ids = useRecentIssuesStore
|
||||
.getState()
|
||||
.byWorkspace["ws-a"]?.map((e) => e.id);
|
||||
expect(ids).toEqual(["issue-1", "issue-2"]);
|
||||
});
|
||||
|
||||
it("caps each workspace's bucket at 20 entries", () => {
|
||||
const { recordVisit } = useRecentIssuesStore.getState();
|
||||
for (let i = 0; i < 25; i++) recordVisit("ws-a", `issue-${i}`);
|
||||
expect(useRecentIssuesStore.getState().byWorkspace["ws-a"]).toHaveLength(20);
|
||||
});
|
||||
});
|
||||
|
||||
describe("useRecentIssuesStore.pruneWorkspaces", () => {
|
||||
it("drops buckets for workspaces not in the active set", () => {
|
||||
const { recordVisit, pruneWorkspaces } = useRecentIssuesStore.getState();
|
||||
recordVisit("ws-a", "issue-1");
|
||||
recordVisit("ws-b", "issue-2");
|
||||
recordVisit("ws-c", "issue-3");
|
||||
|
||||
pruneWorkspaces(["ws-a", "ws-c"]);
|
||||
const state = useRecentIssuesStore.getState().byWorkspace;
|
||||
expect(Object.keys(state).sort()).toEqual(["ws-a", "ws-c"]);
|
||||
});
|
||||
|
||||
it("is a no-op when every bucket is still active", () => {
|
||||
const { recordVisit, pruneWorkspaces } = useRecentIssuesStore.getState();
|
||||
recordVisit("ws-a", "issue-1");
|
||||
const before = useRecentIssuesStore.getState().byWorkspace;
|
||||
pruneWorkspaces(["ws-a"]);
|
||||
expect(useRecentIssuesStore.getState().byWorkspace).toBe(before);
|
||||
});
|
||||
});
|
||||
|
||||
describe("selectRecentIssues", () => {
|
||||
it("returns the bucket for the given workspace", () => {
|
||||
useRecentIssuesStore.getState().recordVisit("ws-a", "issue-1");
|
||||
const items = selectRecentIssues("ws-a")(useRecentIssuesStore.getState());
|
||||
expect(items.map((e) => e.id)).toEqual(["issue-1"]);
|
||||
});
|
||||
|
||||
it("returns a stable empty array when wsId is null or unknown", () => {
|
||||
const a = selectRecentIssues(null)(useRecentIssuesStore.getState());
|
||||
const b = selectRecentIssues(null)(useRecentIssuesStore.getState());
|
||||
const c = selectRecentIssues("missing")(useRecentIssuesStore.getState());
|
||||
expect(a).toBe(b);
|
||||
expect(a).toBe(c);
|
||||
expect(a).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -2,13 +2,11 @@
|
||||
|
||||
import { create } from "zustand";
|
||||
import { createJSONStorage, persist } from "zustand/middleware";
|
||||
import {
|
||||
createWorkspaceAwareStorage,
|
||||
registerForWorkspaceRehydration,
|
||||
} from "../../platform/workspace-storage";
|
||||
import { defaultStorage } from "../../platform/storage";
|
||||
|
||||
const MAX_RECENT_ISSUES = 20;
|
||||
const MAX_WORKSPACES = 50;
|
||||
const EMPTY: RecentIssueEntry[] = [];
|
||||
|
||||
export interface RecentIssueEntry {
|
||||
id: string;
|
||||
@@ -16,33 +14,82 @@ export interface RecentIssueEntry {
|
||||
}
|
||||
|
||||
interface RecentIssuesState {
|
||||
items: RecentIssueEntry[];
|
||||
recordVisit: (id: string) => void;
|
||||
byWorkspace: Record<string, RecentIssueEntry[]>;
|
||||
recordVisit: (wsId: string, id: string) => void;
|
||||
pruneWorkspaces: (activeWsIds: string[]) => void;
|
||||
}
|
||||
|
||||
// Namespace by workspace id (UUID) instead of namespacing the storage key by
|
||||
// slug. The storage-key approach (see createWorkspaceAwareStorage) breaks when
|
||||
// a setter fires from a child component's mount-effect before
|
||||
// WorkspaceRouteLayout's effect has set the current slug — child effects run
|
||||
// before parent effects, so writes land in the un-namespaced bare key and
|
||||
// leak across workspaces (bug surfaced by /<slug>/issues firing per-id GETs
|
||||
// for recents from other workspaces, most returning 404).
|
||||
//
|
||||
// Keying on wsId (rather than slug) means the data survives workspace renames
|
||||
// and matches the wsId that callers already have via useWorkspaceId().
|
||||
export const useRecentIssuesStore = create<RecentIssuesState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
items: [],
|
||||
recordVisit: (id) =>
|
||||
byWorkspace: {},
|
||||
recordVisit: (wsId, id) =>
|
||||
set((state) => {
|
||||
const filtered = state.items.filter((i) => i.id !== id);
|
||||
const bucket = state.byWorkspace[wsId] ?? EMPTY;
|
||||
const filtered = bucket.filter((i) => i.id !== id);
|
||||
const updated: RecentIssueEntry = { id, visitedAt: Date.now() };
|
||||
return {
|
||||
items: [updated, ...filtered].slice(0, MAX_RECENT_ISSUES),
|
||||
const nextBucket = [updated, ...filtered].slice(0, MAX_RECENT_ISSUES);
|
||||
|
||||
let nextByWorkspace = {
|
||||
...state.byWorkspace,
|
||||
[wsId]: nextBucket,
|
||||
};
|
||||
|
||||
// LRU defense: if pruneWorkspaces never gets a chance to run (offline,
|
||||
// failed list query) and the user touches lots of workspaces, cap the
|
||||
// total to avoid unbounded growth. Evict the workspace whose most
|
||||
// recent visit is the oldest.
|
||||
const ids = Object.keys(nextByWorkspace);
|
||||
if (ids.length > MAX_WORKSPACES) {
|
||||
const oldest = ids.reduce((oldestId, candidateId) => {
|
||||
const a = nextByWorkspace[oldestId]?.[0]?.visitedAt ?? 0;
|
||||
const b = nextByWorkspace[candidateId]?.[0]?.visitedAt ?? 0;
|
||||
return b < a ? candidateId : oldestId;
|
||||
});
|
||||
const { [oldest]: _, ...rest } = nextByWorkspace;
|
||||
nextByWorkspace = rest;
|
||||
}
|
||||
|
||||
return { byWorkspace: nextByWorkspace };
|
||||
}),
|
||||
pruneWorkspaces: (activeWsIds) =>
|
||||
set((state) => {
|
||||
const allow = new Set(activeWsIds);
|
||||
let changed = false;
|
||||
const next: Record<string, RecentIssueEntry[]> = {};
|
||||
for (const [wsId, items] of Object.entries(state.byWorkspace)) {
|
||||
if (allow.has(wsId)) next[wsId] = items;
|
||||
else changed = true;
|
||||
}
|
||||
return changed ? { byWorkspace: next } : state;
|
||||
}),
|
||||
}),
|
||||
{
|
||||
name: "multica_recent_issues",
|
||||
storage: createJSONStorage(() =>
|
||||
createWorkspaceAwareStorage(defaultStorage),
|
||||
),
|
||||
partialize: (state) => ({ items: state.items }),
|
||||
storage: createJSONStorage(() => defaultStorage),
|
||||
partialize: (state) => ({ byWorkspace: state.byWorkspace }),
|
||||
// v0 stored a flat `items` array under the bare key (or, when the
|
||||
// workspace slug happened to be set at write time, under
|
||||
// `multica_recent_issues:<slug>`). Both shapes are unsafe to surface
|
||||
// because v0 entries don't know which workspace they belonged to —
|
||||
// drop them and let the cache repopulate as the user visits issues.
|
||||
version: 1,
|
||||
migrate: () => ({ byWorkspace: {} }),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
registerForWorkspaceRehydration(() =>
|
||||
useRecentIssuesStore.persist.rehydrate(),
|
||||
);
|
||||
export function selectRecentIssues(wsId: string | null) {
|
||||
return (state: RecentIssuesState) =>
|
||||
wsId ? (state.byWorkspace[wsId] ?? EMPTY) : EMPTY;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
@@ -46,6 +46,7 @@
|
||||
"./runtimes/queries": "./runtimes/queries.ts",
|
||||
"./runtimes/mutations": "./runtimes/mutations.ts",
|
||||
"./runtimes/hooks": "./runtimes/hooks.ts",
|
||||
"./runtimes/custom-pricing-store": "./runtimes/custom-pricing-store.ts",
|
||||
"./agents": "./agents/index.ts",
|
||||
"./agents/queries": "./agents/queries.ts",
|
||||
"./agents/derive-presence": "./agents/derive-presence.ts",
|
||||
@@ -65,6 +66,8 @@
|
||||
"./pins": "./pins/index.ts",
|
||||
"./pins/queries": "./pins/queries.ts",
|
||||
"./pins/mutations": "./pins/mutations.ts",
|
||||
"./github": "./github/index.ts",
|
||||
"./github/queries": "./github/queries.ts",
|
||||
"./feedback": "./feedback/index.ts",
|
||||
"./feedback/mutations": "./feedback/mutations.ts",
|
||||
"./realtime": "./realtime/index.ts",
|
||||
@@ -92,6 +95,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,
|
||||
};
|
||||
}
|
||||
@@ -90,6 +93,8 @@ function makeRuntime(ownerId: string | null): RuntimeDevice {
|
||||
device_info: "",
|
||||
metadata: {},
|
||||
owner_id: ownerId,
|
||||
visibility: "private",
|
||||
timezone: "UTC",
|
||||
last_seen_at: null,
|
||||
created_at: "2026-04-01T00:00:00Z",
|
||||
updated_at: "2026-04-01T00:00:00Z",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -20,13 +20,19 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("workspace-aware storage", () => {
|
||||
it("uses plain key when no workspace is set", () => {
|
||||
it("drops writes and returns null for reads when no workspace is set", () => {
|
||||
const adapter = mockAdapter();
|
||||
setCurrentWorkspace(null, null);
|
||||
const storage = createWorkspaceAwareStorage(adapter);
|
||||
|
||||
storage.setItem("draft", "data");
|
||||
expect(adapter.setItem).toHaveBeenCalledWith("draft", "data");
|
||||
expect(adapter.setItem).not.toHaveBeenCalled();
|
||||
|
||||
expect(storage.getItem("draft")).toBeNull();
|
||||
expect(adapter.getItem).not.toHaveBeenCalled();
|
||||
|
||||
storage.removeItem("draft");
|
||||
expect(adapter.removeItem).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("namespaces key with slug when workspace is set", () => {
|
||||
|
||||
@@ -94,14 +94,24 @@ export function registerForWorkspaceRehydration(fn: () => void) {
|
||||
/**
|
||||
* Storage that automatically namespaces keys with the current workspace slug.
|
||||
* Reads _currentSlug at call time, so it follows workspace switches dynamically.
|
||||
*
|
||||
* When no workspace is active (e.g. zustand persist's initial hydration before
|
||||
* WorkspaceRouteLayout has called setCurrentWorkspace, or a setter firing from
|
||||
* a child component's mount-effect before the parent layout's effect has run),
|
||||
* reads return null and writes are dropped — explicitly NOT a fallback to the
|
||||
* un-namespaced bare key, which used to leak workspace-scoped data across
|
||||
* workspaces. Persisted stores get a real read once setCurrentWorkspace
|
||||
* triggers their registered rehydrate fn.
|
||||
*/
|
||||
export function createWorkspaceAwareStorage(adapter: StorageAdapter): StateStorage {
|
||||
const resolve = (key: string) =>
|
||||
_currentSlug ? `${key}:${_currentSlug}` : key;
|
||||
|
||||
return {
|
||||
getItem: (key) => adapter.getItem(resolve(key)),
|
||||
setItem: (key, value) => adapter.setItem(resolve(key), value),
|
||||
removeItem: (key) => adapter.removeItem(resolve(key)),
|
||||
getItem: (key) =>
|
||||
_currentSlug ? adapter.getItem(`${key}:${_currentSlug}`) : null,
|
||||
setItem: (key, value) => {
|
||||
if (_currentSlug) adapter.setItem(`${key}:${_currentSlug}`, value);
|
||||
},
|
||||
removeItem: (key) => {
|
||||
if (_currentSlug) adapter.removeItem(`${key}:${_currentSlug}`);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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" },
|
||||
};
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
agentRunCountsKeys,
|
||||
agentTasksKeys,
|
||||
} from "../agents/queries";
|
||||
import { githubKeys } from "../github/queries";
|
||||
import {
|
||||
onIssueCreated,
|
||||
onIssueUpdated,
|
||||
@@ -45,6 +46,8 @@ import type {
|
||||
CommentCreatedPayload,
|
||||
CommentUpdatedPayload,
|
||||
CommentDeletedPayload,
|
||||
CommentResolvedPayload,
|
||||
CommentUnresolvedPayload,
|
||||
ActivityCreatedPayload,
|
||||
ReactionAddedPayload,
|
||||
ReactionRemovedPayload,
|
||||
@@ -155,6 +158,15 @@ export function useRealtimeSync(
|
||||
const wsId = getCurrentWsId();
|
||||
if (wsId) qc.invalidateQueries({ queryKey: autopilotKeys.all(wsId) });
|
||||
},
|
||||
github_installation: () => {
|
||||
const wsId = getCurrentWsId();
|
||||
if (wsId) qc.invalidateQueries({ queryKey: githubKeys.installations(wsId) });
|
||||
},
|
||||
pull_request: () => {
|
||||
// PR list is keyed by issue id, not workspace, so we invalidate all
|
||||
// PR queries — the open issue detail page will refetch its own list.
|
||||
qc.invalidateQueries({ queryKey: ["github", "pull-requests"] });
|
||||
},
|
||||
// Powers the agent presence cache: any task lifecycle change
|
||||
// (dispatch / completed / failed / cancelled) refreshes the
|
||||
// workspace-wide agent-task-snapshot query so per-agent presence
|
||||
@@ -202,6 +214,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",
|
||||
@@ -329,12 +342,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) => {
|
||||
@@ -352,6 +378,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);
|
||||
@@ -516,10 +552,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) => {
|
||||
@@ -656,7 +689,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) });
|
||||
@@ -678,6 +710,8 @@ export function useRealtimeSync(
|
||||
unsubCommentCreated();
|
||||
unsubCommentUpdated();
|
||||
unsubCommentDeleted();
|
||||
unsubCommentResolved();
|
||||
unsubCommentUnresolved();
|
||||
unsubActivityCreated();
|
||||
unsubReactionAdded();
|
||||
unsubReactionRemoved();
|
||||
|
||||
56
packages/core/runtimes/custom-pricing-store.ts
Normal file
56
packages/core/runtimes/custom-pricing-store.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
"use client";
|
||||
|
||||
import { create } from "zustand";
|
||||
import { createJSONStorage, persist, type StateStorage } from "zustand/middleware";
|
||||
import { defaultStorage } from "../platform/storage";
|
||||
|
||||
// User-supplied pricing for models we don't ship a maintained rate for.
|
||||
// We can't track every model OpenRouter / Codex / Hermes / Kimi etc. release,
|
||||
// so the empty-state diagnostic lets users fill in their own rates. Stored
|
||||
// globally (not workspace-scoped) because the rate of `gpt-5.5-mini` is the
|
||||
// same regardless of which workspace you're viewing.
|
||||
export interface CustomModelPricing {
|
||||
input: number;
|
||||
output: number;
|
||||
cacheRead: number;
|
||||
cacheWrite: number;
|
||||
}
|
||||
|
||||
export interface CustomPricingState {
|
||||
pricings: Record<string, CustomModelPricing>;
|
||||
setCustomPricing: (model: string, pricing: CustomModelPricing) => void;
|
||||
removeCustomPricing: (model: string) => void;
|
||||
}
|
||||
|
||||
// StorageAdapter (sync getItem returning string | null) is a structural subset
|
||||
// of zustand's StateStorage, so it can be handed in directly via cast.
|
||||
const stateStorage = defaultStorage as unknown as StateStorage;
|
||||
|
||||
export const useCustomPricingStore = create<CustomPricingState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
pricings: {},
|
||||
setCustomPricing: (model, pricing) =>
|
||||
set((state) => ({
|
||||
pricings: { ...state.pricings, [model]: pricing },
|
||||
})),
|
||||
removeCustomPricing: (model) =>
|
||||
set((state) => {
|
||||
if (!(model in state.pricings)) return state;
|
||||
const next = { ...state.pricings };
|
||||
delete next[model];
|
||||
return { pricings: next };
|
||||
}),
|
||||
}),
|
||||
{
|
||||
name: "multica_runtime_custom_pricing",
|
||||
storage: createJSONStorage(() => stateStorage),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Vanilla accessor for non-React callers (the `resolvePricing` helper in
|
||||
// packages/views/runtimes/utils.ts reads from here during cost estimation).
|
||||
export function getCustomPricing(model: string): CustomModelPricing | undefined {
|
||||
return useCustomPricingStore.getState().pricings[model];
|
||||
}
|
||||
@@ -17,6 +17,8 @@ function makeRuntime(overrides: Partial<AgentRuntime> = {}): AgentRuntime {
|
||||
device_info: "",
|
||||
metadata: {},
|
||||
owner_id: null,
|
||||
visibility: "private",
|
||||
timezone: "UTC",
|
||||
last_seen_at: new Date(FIXED_NOW - 10_000).toISOString(),
|
||||
created_at: "2026-04-01T00:00:00Z",
|
||||
updated_at: "2026-04-01T00:00:00Z",
|
||||
|
||||
@@ -7,3 +7,4 @@ export * from "./types";
|
||||
export * from "./derive-health";
|
||||
export * from "./use-runtime-health";
|
||||
export * from "./cli-version";
|
||||
export * from "./custom-pricing-store";
|
||||
|
||||
@@ -11,3 +11,38 @@ export function useDeleteRuntime(wsId: string) {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// useUpdateRuntime patches editable fields on a runtime (timezone, visibility).
|
||||
// Invalidates the runtime list AND any keys downstream of the updated runtime
|
||||
// — usage queries are bucketed by tz on the server, so a tz change must blow
|
||||
// away cached usage rows or the chart would lie for one polling cycle. A
|
||||
// visibility change only needs the runtime list to refetch so the picker
|
||||
// disabled-state recomputes.
|
||||
export function useUpdateRuntime(wsId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
runtimeId,
|
||||
patch,
|
||||
}: {
|
||||
runtimeId: string;
|
||||
patch: { timezone?: string; visibility?: "private" | "public" };
|
||||
}) => api.updateRuntime(runtimeId, patch),
|
||||
onSettled: (_data, _err, vars) => {
|
||||
qc.invalidateQueries({ queryKey: runtimeKeys.all(wsId) });
|
||||
if (vars && vars.patch.timezone !== undefined) {
|
||||
// Usage query keys are not workspace-scoped; invalidate only this
|
||||
// runtime's daily/by-agent/by-hour usage rows under the new tz buckets.
|
||||
qc.invalidateQueries({
|
||||
queryKey: ["runtimes", "usage", vars.runtimeId],
|
||||
});
|
||||
qc.invalidateQueries({
|
||||
queryKey: ["runtimes", "usage", "by-agent", vars.runtimeId],
|
||||
});
|
||||
qc.invalidateQueries({
|
||||
queryKey: ["runtimes", "usage", "by-hour", vars.runtimeId],
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -4,6 +4,14 @@ export type AgentRuntimeMode = "local" | "cloud";
|
||||
|
||||
export type AgentVisibility = "workspace" | "private";
|
||||
|
||||
// Runtime visibility is a separate axis from agent visibility — different
|
||||
// vocabulary because it gates a different action. "private" (default) means
|
||||
// only the runtime owner and workspace admins can bind agents to it;
|
||||
// "public" opens binding to any workspace member. Older backends that
|
||||
// haven't shipped MUL-2062 omit the field; the consumer must default to
|
||||
// "private" so the strictest behavior is the fallback.
|
||||
export type RuntimeVisibility = "private" | "public";
|
||||
|
||||
export interface RuntimeDevice {
|
||||
id: string;
|
||||
workspace_id: string;
|
||||
@@ -16,6 +24,9 @@ export interface RuntimeDevice {
|
||||
device_info: string;
|
||||
metadata: Record<string, unknown>;
|
||||
owner_id: string | null;
|
||||
/** Defaults to "private" when the backend predates the visibility flag. */
|
||||
visibility: RuntimeVisibility;
|
||||
timezone: string;
|
||||
last_seen_at: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
|
||||
@@ -3,6 +3,8 @@ export interface Attachment {
|
||||
workspace_id: string;
|
||||
issue_id: string | null;
|
||||
comment_id: string | null;
|
||||
chat_session_id: string | null;
|
||||
chat_message_id: string | null;
|
||||
uploader_type: string;
|
||||
uploader_id: string;
|
||||
filename: string;
|
||||
|
||||
@@ -28,6 +28,14 @@ export interface ChatMessage {
|
||||
content: string;
|
||||
task_id: string | null;
|
||||
created_at: string;
|
||||
/**
|
||||
* Attachments linked to this message via the attachment table's
|
||||
* chat_message_id FK. Populated by ListChatMessages. UI renders these
|
||||
* as file/image cards inside the bubble; the markdown URL inline in
|
||||
* `content` may have an expiring signature, while attachment metadata
|
||||
* here is stable and the source of truth for click-time download.
|
||||
*/
|
||||
attachments?: import("./attachment").Attachment[];
|
||||
/**
|
||||
* When set, this is an assistant message synthesized by the server's
|
||||
* FailTask fallback (mirrors the issue path's failure system comment).
|
||||
|
||||
@@ -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"
|
||||
@@ -65,7 +67,12 @@ export type WSEventType =
|
||||
| "invitation:created"
|
||||
| "invitation:accepted"
|
||||
| "invitation:declined"
|
||||
| "invitation:revoked";
|
||||
| "invitation:revoked"
|
||||
| "github_installation:created"
|
||||
| "github_installation:deleted"
|
||||
| "pull_request:linked"
|
||||
| "pull_request:updated"
|
||||
| "pull_request:unlinked";
|
||||
|
||||
export interface WSMessage<T = unknown> {
|
||||
type: WSEventType;
|
||||
@@ -143,6 +150,14 @@ export interface CommentDeletedPayload {
|
||||
issue_id: string;
|
||||
}
|
||||
|
||||
export interface CommentResolvedPayload {
|
||||
comment: Comment;
|
||||
}
|
||||
|
||||
export interface CommentUnresolvedPayload {
|
||||
comment: Comment;
|
||||
}
|
||||
|
||||
export interface WorkspaceUpdatedPayload {
|
||||
workspace: Workspace;
|
||||
}
|
||||
|
||||
41
packages/core/types/github.ts
Normal file
41
packages/core/types/github.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
export type GitHubPullRequestState = "open" | "closed" | "merged" | "draft";
|
||||
|
||||
export interface GitHubInstallation {
|
||||
id: string;
|
||||
workspace_id: string;
|
||||
installation_id: number;
|
||||
account_login: string;
|
||||
account_type: "User" | "Organization";
|
||||
account_avatar_url: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface GitHubPullRequest {
|
||||
id: string;
|
||||
workspace_id: string;
|
||||
repo_owner: string;
|
||||
repo_name: string;
|
||||
number: number;
|
||||
title: string;
|
||||
state: GitHubPullRequestState;
|
||||
html_url: string;
|
||||
branch: string | null;
|
||||
author_login: string | null;
|
||||
author_avatar_url: string | null;
|
||||
merged_at: string | null;
|
||||
closed_at: string | null;
|
||||
pr_created_at: string;
|
||||
pr_updated_at: string;
|
||||
}
|
||||
|
||||
export interface ListGitHubInstallationsResponse {
|
||||
installations: GitHubInstallation[];
|
||||
/** Whether the deployment has GitHub App credentials configured. When false, the Connect button is hidden / disabled. */
|
||||
configured: boolean;
|
||||
}
|
||||
|
||||
export interface GitHubConnectResponse {
|
||||
/** The GitHub App install URL the browser should open. Empty when `configured` is false. */
|
||||
url?: string;
|
||||
configured: boolean;
|
||||
}
|
||||
@@ -45,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";
|
||||
@@ -69,6 +67,13 @@ export type {
|
||||
ListProjectResourcesResponse,
|
||||
} from "./project";
|
||||
export type { PinnedItem, PinnedItemType, CreatePinRequest, ReorderPinsRequest } from "./pin";
|
||||
export type {
|
||||
GitHubInstallation,
|
||||
GitHubPullRequest,
|
||||
GitHubPullRequestState,
|
||||
ListGitHubInstallationsResponse,
|
||||
GitHubConnectResponse,
|
||||
} from "./github";
|
||||
export type {
|
||||
Autopilot,
|
||||
AutopilotStatus,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { ArrowUp, Loader2, Square } from "lucide-react";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@multica/ui/components/ui/tooltip";
|
||||
|
||||
interface SubmitButtonProps {
|
||||
onClick: () => void;
|
||||
@@ -9,26 +15,53 @@ interface SubmitButtonProps {
|
||||
loading?: boolean;
|
||||
running?: boolean;
|
||||
onStop?: () => void;
|
||||
/**
|
||||
* Tooltip shown over the send button when idle. Pass a string or a node
|
||||
* (e.g. `Send · ⌘↵`). Omit to render no tooltip.
|
||||
* Callers compose the shortcut hint themselves to keep this component
|
||||
* free of `@multica/core` (platform-detection) and i18n imports.
|
||||
*/
|
||||
tooltip?: ReactNode;
|
||||
/** Tooltip shown over the stop button while a run is in progress. */
|
||||
stopTooltip?: ReactNode;
|
||||
}
|
||||
|
||||
function SubmitButton({ onClick, disabled, loading, running, onStop }: SubmitButtonProps) {
|
||||
function SubmitButton({
|
||||
onClick,
|
||||
disabled,
|
||||
loading,
|
||||
running,
|
||||
onStop,
|
||||
tooltip,
|
||||
stopTooltip,
|
||||
}: SubmitButtonProps) {
|
||||
if (running) {
|
||||
return (
|
||||
const stopButton = (
|
||||
<Button size="icon-sm" onClick={onStop}>
|
||||
<Square className="fill-current" />
|
||||
</Button>
|
||||
);
|
||||
if (!stopTooltip) return stopButton;
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={stopButton} />
|
||||
<TooltipContent side="top">{stopTooltip}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
const submitButton = (
|
||||
<Button size="icon-sm" disabled={disabled || loading} onClick={onClick}>
|
||||
{loading ? (
|
||||
<Loader2 className="animate-spin" />
|
||||
) : (
|
||||
<ArrowUp />
|
||||
)}
|
||||
{loading ? <Loader2 className="animate-spin" /> : <ArrowUp />}
|
||||
</Button>
|
||||
);
|
||||
if (!tooltip) return submitButton;
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={submitButton} />
|
||||
<TooltipContent side="top">{tooltip}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
export { SubmitButton, type SubmitButtonProps };
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useState } from "react";
|
||||
import {
|
||||
AlertCircle,
|
||||
ArrowLeft,
|
||||
Lock,
|
||||
MoreHorizontal,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
@@ -14,7 +15,7 @@ import {
|
||||
type AgentPresenceDetail,
|
||||
useWorkspacePresenceMap,
|
||||
} from "@multica/core/agents";
|
||||
import { api } from "@multica/core/api";
|
||||
import { api, ApiError } from "@multica/core/api";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
import { useWorkspaceId } from "@multica/core/hooks";
|
||||
import { useWorkspacePaths } from "@multica/core/paths";
|
||||
@@ -78,6 +79,19 @@ export function AgentDetailPage({ agentId }: AgentDetailPageProps) {
|
||||
const presence: AgentPresenceDetail | null =
|
||||
agent ? presenceMap.get(agent.id) ?? null : null;
|
||||
|
||||
// Fallback fetch: when the agent is missing from the workspace list, hit
|
||||
// GET /api/agents/{id} directly to disambiguate "doesn't exist" (404) from
|
||||
// "you can't see this private agent" (403). Only fires after the list has
|
||||
// settled, so the common path makes zero extra requests.
|
||||
const { error: detailError } = useQuery({
|
||||
queryKey: ["agent-detail-probe", wsId, agentId],
|
||||
queryFn: () => api.getAgent(agentId),
|
||||
enabled: !agentsLoading && !agent && !!agentId,
|
||||
retry: false,
|
||||
});
|
||||
const isForbidden =
|
||||
detailError instanceof ApiError && detailError.status === 403;
|
||||
|
||||
// Permission hook MUST be called unconditionally — its `agent | null`
|
||||
// signature handles the not-found / loading case internally so the early
|
||||
// returns below don't violate the rules of hooks. Backend gates archive
|
||||
@@ -122,6 +136,31 @@ export function AgentDetailPage({ agentId }: AgentDetailPageProps) {
|
||||
return <DetailLoadingSkeleton />;
|
||||
}
|
||||
|
||||
// --- No permission (private agent the caller is not in allowed_principals for) ---
|
||||
if (!agent && isForbidden) {
|
||||
return (
|
||||
<div className="flex flex-1 min-h-0 flex-col">
|
||||
<BackHeader paths={paths.agents()} title={t(($) => $.detail.back_to_agents)} />
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-3 px-6 py-16 text-center">
|
||||
<Lock className="h-8 w-8 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">{t(($) => $.detail.no_access_title)}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{t(($) => $.detail.no_access_hint)}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={() => navigation.push(paths.agents())}
|
||||
>
|
||||
{t(($) => $.detail.back_to_agents_full)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Not found / error ---
|
||||
if (!agent) {
|
||||
return (
|
||||
|
||||
266
packages/views/agents/components/create-agent-dialog.test.tsx
Normal file
266
packages/views/agents/components/create-agent-dialog.test.tsx
Normal file
@@ -0,0 +1,266 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import type { Agent, MemberWithUser, RuntimeDevice } from "@multica/core/types";
|
||||
import { I18nProvider } from "@multica/core/i18n/react";
|
||||
import enCommon from "../../locales/en/common.json";
|
||||
import enAgents from "../../locales/en/agents.json";
|
||||
|
||||
const TEST_RESOURCES = { en: { common: enCommon, agents: enAgents } };
|
||||
|
||||
vi.mock("@multica/core/hooks", () => ({
|
||||
useWorkspaceId: () => "ws-1",
|
||||
}));
|
||||
|
||||
// ModelDropdown talks to the api; the create dialog only needs it as a
|
||||
// stand-in here, so swap it out.
|
||||
vi.mock("./model-dropdown", () => ({
|
||||
ModelDropdown: () => null,
|
||||
}));
|
||||
|
||||
// Provider logos don't matter for these assertions but they pull in SVGs.
|
||||
vi.mock("../../runtimes/components/provider-logo", () => ({
|
||||
ProviderLogo: () => null,
|
||||
}));
|
||||
|
||||
// Avatars hit the api for member metadata.
|
||||
vi.mock("../../common/actor-avatar", () => ({
|
||||
ActorAvatar: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("sonner", () => ({
|
||||
toast: { error: vi.fn(), success: vi.fn() },
|
||||
}));
|
||||
|
||||
import { CreateAgentDialog } from "./create-agent-dialog";
|
||||
|
||||
const ME = "user-me";
|
||||
const OTHER = "user-other";
|
||||
|
||||
const members: MemberWithUser[] = [
|
||||
{
|
||||
id: "m-me",
|
||||
user_id: ME,
|
||||
workspace_id: "ws-1",
|
||||
role: "member",
|
||||
name: "Me",
|
||||
email: "me@example.com",
|
||||
avatar_url: null,
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
},
|
||||
{
|
||||
id: "m-other",
|
||||
user_id: OTHER,
|
||||
workspace_id: "ws-1",
|
||||
role: "member",
|
||||
name: "Other",
|
||||
email: "other@example.com",
|
||||
avatar_url: null,
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
},
|
||||
];
|
||||
|
||||
function makeRuntime(overrides: Partial<RuntimeDevice>): RuntimeDevice {
|
||||
return {
|
||||
id: "rt",
|
||||
workspace_id: "ws-1",
|
||||
daemon_id: null,
|
||||
name: "Test Runtime",
|
||||
runtime_mode: "local",
|
||||
provider: "claude",
|
||||
launch_header: "",
|
||||
status: "online",
|
||||
device_info: "host.local",
|
||||
metadata: {},
|
||||
owner_id: ME,
|
||||
visibility: "private",
|
||||
timezone: "UTC",
|
||||
last_seen_at: "2026-04-27T11:59:50Z",
|
||||
created_at: "2026-04-01T00:00:00Z",
|
||||
updated_at: "2026-04-01T00:00:00Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeTemplate(runtimeId: string): Agent {
|
||||
return {
|
||||
id: "agent-template",
|
||||
workspace_id: "ws-1",
|
||||
runtime_id: runtimeId,
|
||||
name: "Template Agent",
|
||||
description: "",
|
||||
instructions: "",
|
||||
avatar_url: null,
|
||||
runtime_mode: "local",
|
||||
runtime_config: {},
|
||||
custom_env: {},
|
||||
custom_args: [],
|
||||
custom_env_redacted: false,
|
||||
visibility: "private",
|
||||
status: "idle",
|
||||
max_concurrent_tasks: 1,
|
||||
model: "",
|
||||
owner_id: ME,
|
||||
skills: [],
|
||||
created_at: "2026-04-01T00:00:00Z",
|
||||
updated_at: "2026-04-01T00:00:00Z",
|
||||
archived_at: null,
|
||||
archived_by: null,
|
||||
};
|
||||
}
|
||||
|
||||
function renderDialog(runtimes: RuntimeDevice[], template?: Agent) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
const onCreate = vi.fn().mockResolvedValue(undefined);
|
||||
const onClose = vi.fn();
|
||||
render(
|
||||
<I18nProvider locale="en" resources={TEST_RESOURCES}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<CreateAgentDialog
|
||||
runtimes={runtimes}
|
||||
members={members}
|
||||
currentUserId={ME}
|
||||
template={template}
|
||||
onClose={onClose}
|
||||
onCreate={onCreate}
|
||||
/>
|
||||
</QueryClientProvider>
|
||||
</I18nProvider>,
|
||||
);
|
||||
return { onCreate, onClose };
|
||||
}
|
||||
|
||||
describe("CreateAgentDialog runtime visibility gate", () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it("disables another member's private runtime in the picker", () => {
|
||||
const mine = makeRuntime({ id: "rt-mine", name: "My Runtime", owner_id: ME, visibility: "private" });
|
||||
const othersPrivate = makeRuntime({
|
||||
id: "rt-others-private",
|
||||
name: "Others Private",
|
||||
owner_id: OTHER,
|
||||
visibility: "private",
|
||||
});
|
||||
renderDialog([mine, othersPrivate]);
|
||||
|
||||
// Flip to "All" so other-owned runtimes show.
|
||||
fireEvent.click(screen.getByText("All"));
|
||||
// Open the picker.
|
||||
fireEvent.click(
|
||||
screen.getByText("My Runtime", { selector: "span.truncate" }),
|
||||
);
|
||||
|
||||
const disabledRow = screen
|
||||
.getByText("Others Private")
|
||||
.closest("button") as HTMLButtonElement;
|
||||
expect(disabledRow).not.toBeNull();
|
||||
expect(disabledRow.disabled).toBe(true);
|
||||
expect(disabledRow.title).toMatch(/Private runtime/i);
|
||||
});
|
||||
|
||||
it("lets a plain member pick another member's public runtime", () => {
|
||||
const mine = makeRuntime({ id: "rt-mine", name: "My Runtime", owner_id: ME, visibility: "private" });
|
||||
const othersPublic = makeRuntime({
|
||||
id: "rt-others-public",
|
||||
name: "Others Public",
|
||||
owner_id: OTHER,
|
||||
visibility: "public",
|
||||
});
|
||||
renderDialog([mine, othersPublic]);
|
||||
|
||||
fireEvent.click(screen.getByText("All"));
|
||||
fireEvent.click(
|
||||
screen.getByText("My Runtime", { selector: "span.truncate" }),
|
||||
);
|
||||
|
||||
const publicRow = screen
|
||||
.getByText("Others Public")
|
||||
.closest("button") as HTMLButtonElement;
|
||||
expect(publicRow).not.toBeNull();
|
||||
expect(publicRow.disabled).toBe(false);
|
||||
});
|
||||
|
||||
it("defaults the selected runtime to a usable one, not a locked private", () => {
|
||||
const othersPrivate = makeRuntime({
|
||||
id: "rt-others-private",
|
||||
name: "Others Private",
|
||||
owner_id: OTHER,
|
||||
visibility: "private",
|
||||
});
|
||||
const mine = makeRuntime({
|
||||
id: "rt-mine",
|
||||
name: "My Runtime",
|
||||
owner_id: ME,
|
||||
visibility: "private",
|
||||
});
|
||||
renderDialog([othersPrivate, mine]);
|
||||
|
||||
// The trigger label shows the selected runtime name. The picker must
|
||||
// not seed with the other-owned private runtime even if it sorted
|
||||
// first in the input list.
|
||||
expect(screen.queryByText("Others Private", { selector: "span.truncate" })).toBeNull();
|
||||
expect(screen.getByText("My Runtime", { selector: "span.truncate" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("in duplicate mode, does not pre-fill the template's runtime when it's now locked", async () => {
|
||||
// Template runtime is owned by someone else and now private — the
|
||||
// duplicate flow used to seed with it anyway, leaving the user with
|
||||
// a Create button that 403s server-side. Now we fall back to the
|
||||
// first usable runtime instead.
|
||||
const othersPrivate = makeRuntime({
|
||||
id: "rt-others-private",
|
||||
name: "Others Private",
|
||||
owner_id: OTHER,
|
||||
visibility: "private",
|
||||
});
|
||||
const mine = makeRuntime({
|
||||
id: "rt-mine",
|
||||
name: "My Runtime",
|
||||
owner_id: ME,
|
||||
visibility: "private",
|
||||
});
|
||||
const template = makeTemplate("rt-others-private");
|
||||
const { onCreate } = renderDialog([othersPrivate, mine], template);
|
||||
|
||||
expect(
|
||||
screen.getByText("My Runtime", { selector: "span.truncate" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText("Others Private", { selector: "span.truncate" }),
|
||||
).toBeNull();
|
||||
|
||||
// Sanity check: with a usable selection seeded, Create should submit.
|
||||
fireEvent.click(screen.getByText("Create"));
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
expect(onCreate).toHaveBeenCalledTimes(1);
|
||||
expect(onCreate.mock.calls[0]?.[0].runtime_id).toBe("rt-mine");
|
||||
});
|
||||
|
||||
it("disables Create when the selected runtime is locked (template + no usable fallback)", () => {
|
||||
// Edge case: template points at a locked runtime AND the workspace
|
||||
// has no usable alternatives in scope. The defense-in-depth gate on
|
||||
// the Create button must keep the user from submitting a 403.
|
||||
const onlyOthersPrivate = makeRuntime({
|
||||
id: "rt-only-others-private",
|
||||
name: "Only Others Private",
|
||||
owner_id: OTHER,
|
||||
visibility: "private",
|
||||
});
|
||||
// Flip the picker to "All" so the locked runtime is at least
|
||||
// visible — that's the scope where the selected-but-locked state
|
||||
// can persist after the initial seed search returns nothing.
|
||||
const template = makeTemplate("rt-only-others-private");
|
||||
renderDialog([onlyOthersPrivate], template);
|
||||
|
||||
// The Create button is rendered by lucide-free CTA text "Create".
|
||||
const createBtn = screen
|
||||
.getAllByRole("button")
|
||||
.find((b) => b.textContent === "Create");
|
||||
expect(createBtn).toBeDefined();
|
||||
expect((createBtn as HTMLButtonElement).disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
MemberWithUser,
|
||||
CreateAgentRequest,
|
||||
} from "@multica/core/types";
|
||||
import { isImeComposing } from "@multica/core/utils";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -84,33 +85,76 @@ export function CreateAgentDialog({
|
||||
|
||||
const hasOtherRuntimes = runtimes.some((r) => r.owner_id !== currentUserId);
|
||||
|
||||
// A runtime is disabled for the caller when it's owned by someone else
|
||||
// AND its visibility is not "public". Older backends that haven't shipped
|
||||
// MUL-2062 leave visibility undefined; we treat anything other than the
|
||||
// literal string "public" as private so the strict default holds (the
|
||||
// backend will reject the create anyway).
|
||||
const isRuntimeDisabledForUser = (r: RuntimeDevice): boolean => {
|
||||
if (!currentUserId) return false;
|
||||
if (r.owner_id === currentUserId) return false;
|
||||
return r.visibility !== "public";
|
||||
};
|
||||
|
||||
const filteredRuntimes = useMemo(() => {
|
||||
const filtered = runtimeFilter === "mine" && currentUserId
|
||||
? runtimes.filter((r) => r.owner_id === currentUserId)
|
||||
: runtimes;
|
||||
return [...filtered].sort((a, b) => {
|
||||
if (a.owner_id === currentUserId && b.owner_id !== currentUserId) return -1;
|
||||
if (a.owner_id !== currentUserId && b.owner_id === currentUserId) return 1;
|
||||
// Caller's own runtimes first; among the rest, usable (public) ones
|
||||
// come before unusable (private) ones so the picker doesn't lead
|
||||
// with greyed-out rows.
|
||||
const aMine = a.owner_id === currentUserId;
|
||||
const bMine = b.owner_id === currentUserId;
|
||||
if (aMine && !bMine) return -1;
|
||||
if (!aMine && bMine) return 1;
|
||||
const aDisabled = isRuntimeDisabledForUser(a);
|
||||
const bDisabled = isRuntimeDisabledForUser(b);
|
||||
if (!aDisabled && bDisabled) return -1;
|
||||
if (aDisabled && !bDisabled) return 1;
|
||||
return 0;
|
||||
});
|
||||
// currentUserId is the only external dep of isRuntimeDisabledForUser;
|
||||
// listing it in the deps array is enough.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [runtimes, runtimeFilter, currentUserId]);
|
||||
|
||||
// When duplicating, default to the template's runtime so the clone
|
||||
// lands on the same machine — caller can still switch in the picker.
|
||||
const [selectedRuntimeId, setSelectedRuntimeId] = useState(
|
||||
template?.runtime_id ?? filteredRuntimes[0]?.id ?? "",
|
||||
);
|
||||
// But never seed with a runtime the caller can't actually use (locked
|
||||
// by visibility); otherwise the dialog opens with a selected row the
|
||||
// user can't submit, and Create falls through to a backend 403. Falling
|
||||
// back to the first usable runtime is friendlier than the locked
|
||||
// pre-fill.
|
||||
const templateRuntime = template?.runtime_id
|
||||
? runtimes.find((r) => r.id === template.runtime_id)
|
||||
: undefined;
|
||||
const initialRuntime =
|
||||
templateRuntime && !isRuntimeDisabledForUser(templateRuntime)
|
||||
? templateRuntime.id
|
||||
: filteredRuntimes.find((r) => !isRuntimeDisabledForUser(r))?.id ?? "";
|
||||
const [selectedRuntimeId, setSelectedRuntimeId] = useState(initialRuntime);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedRuntimeId && filteredRuntimes[0]) {
|
||||
setSelectedRuntimeId(filteredRuntimes[0].id);
|
||||
if (!selectedRuntimeId) {
|
||||
const firstUsable = filteredRuntimes.find(
|
||||
(r) => !isRuntimeDisabledForUser(r),
|
||||
);
|
||||
if (firstUsable) setSelectedRuntimeId(firstUsable.id);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [filteredRuntimes, selectedRuntimeId]);
|
||||
|
||||
const selectedRuntime = runtimes.find((d) => d.id === selectedRuntimeId) ?? null;
|
||||
// Defense-in-depth: even if a locked runtime somehow ends up selected
|
||||
// (e.g. duplicate of an agent whose template runtime is now locked, and
|
||||
// the workspace has no usable fallback), gate Create on it so we don't
|
||||
// submit a request the backend will reject with 403.
|
||||
const selectedRuntimeLocked =
|
||||
selectedRuntime != null && isRuntimeDisabledForUser(selectedRuntime);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!name.trim() || !selectedRuntime) return;
|
||||
if (!name.trim() || !selectedRuntime || selectedRuntimeLocked) return;
|
||||
setCreating(true);
|
||||
try {
|
||||
// When duplicating, forward the hidden config fields the template
|
||||
@@ -172,7 +216,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>
|
||||
|
||||
@@ -298,15 +345,33 @@ export function CreateAgentDialog({
|
||||
<PopoverContent align="start" className="w-[var(--anchor-width)] p-1 max-h-60 overflow-y-auto">
|
||||
{filteredRuntimes.map((device) => {
|
||||
const ownerMember = getOwnerMember(device.owner_id);
|
||||
const disabled = isRuntimeDisabledForUser(device);
|
||||
// Use the native title for the disabled tooltip so we
|
||||
// don't have to wrap each row in a Tooltip primitive
|
||||
// inside a Popover (which has its own focus trap and
|
||||
// close-on-outside-click handling — adding another layer
|
||||
// makes the Popover dismiss when the tooltip portal
|
||||
// mounts).
|
||||
const disabledTitle = disabled
|
||||
? t(($) => $.create_dialog.runtime_private_locked_tooltip)
|
||||
: undefined;
|
||||
return (
|
||||
<button
|
||||
key={device.id}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
title={disabledTitle}
|
||||
onClick={() => {
|
||||
if (disabled) return;
|
||||
setSelectedRuntimeId(device.id);
|
||||
setRuntimeOpen(false);
|
||||
}}
|
||||
className={`flex w-full items-center gap-3 rounded-md px-3 py-2.5 text-left text-sm transition-colors ${
|
||||
device.id === selectedRuntimeId ? "bg-accent" : "hover:bg-accent/50"
|
||||
disabled
|
||||
? "cursor-not-allowed opacity-50"
|
||||
: device.id === selectedRuntimeId
|
||||
? "bg-accent"
|
||||
: "hover:bg-accent/50"
|
||||
}`}
|
||||
>
|
||||
<ProviderLogo provider={device.provider} className="h-4 w-4 shrink-0" />
|
||||
@@ -318,6 +383,12 @@ export function CreateAgentDialog({
|
||||
{t(($) => $.create_dialog.runtime_cloud_badge)}
|
||||
</span>
|
||||
)}
|
||||
{disabled && (
|
||||
<span className="shrink-0 inline-flex items-center gap-1 rounded bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground">
|
||||
<Lock className="h-3 w-3" />
|
||||
{t(($) => $.create_dialog.runtime_private_badge)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-0.5 flex items-center gap-1 text-xs text-muted-foreground">
|
||||
{ownerMember ? (
|
||||
@@ -357,7 +428,14 @@ export function CreateAgentDialog({
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={creating || !name.trim() || !selectedRuntime}
|
||||
disabled={
|
||||
creating || !name.trim() || !selectedRuntime || selectedRuntimeLocked
|
||||
}
|
||||
title={
|
||||
selectedRuntimeLocked
|
||||
? t(($) => $.create_dialog.runtime_private_locked_tooltip)
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{creating ? t(($) => $.create_dialog.creating) : t(($) => $.create_dialog.create)}
|
||||
</Button>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { Cloud, Monitor } from "lucide-react";
|
||||
import { Cloud, Lock, Monitor } from "lucide-react";
|
||||
import type { AgentRuntime, MemberWithUser } from "@multica/core/types";
|
||||
import { ActorAvatar } from "../../../common/actor-avatar";
|
||||
import {
|
||||
@@ -45,18 +45,28 @@ export function RuntimePicker({
|
||||
|
||||
// Compute filtered list unconditionally — the early `!canEdit` return
|
||||
// below would otherwise re-order this hook across renders.
|
||||
const isDisabled = (r: AgentRuntime): boolean => {
|
||||
if (!currentUserId) return false;
|
||||
if (r.owner_id === currentUserId) return false;
|
||||
return r.visibility !== "public";
|
||||
};
|
||||
const filtered = useMemo(() => {
|
||||
const list =
|
||||
filter === "mine" && currentUserId
|
||||
? runtimes.filter((r) => r.owner_id === currentUserId)
|
||||
: runtimes;
|
||||
return [...list].sort((a, b) => {
|
||||
if (a.owner_id === currentUserId && b.owner_id !== currentUserId)
|
||||
return -1;
|
||||
if (a.owner_id !== currentUserId && b.owner_id === currentUserId)
|
||||
return 1;
|
||||
const aMine = a.owner_id === currentUserId;
|
||||
const bMine = b.owner_id === currentUserId;
|
||||
if (aMine && !bMine) return -1;
|
||||
if (!aMine && bMine) return 1;
|
||||
const aDisabled = isDisabled(a);
|
||||
const bDisabled = isDisabled(b);
|
||||
if (!aDisabled && bDisabled) return -1;
|
||||
if (aDisabled && !bDisabled) return 1;
|
||||
return 0;
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [runtimes, filter, currentUserId]);
|
||||
|
||||
if (!canEdit) {
|
||||
@@ -157,10 +167,12 @@ export function RuntimePicker({
|
||||
filtered.map((rt) => {
|
||||
const owner = getOwner(rt.owner_id);
|
||||
const rtOnline = rt.status === "online";
|
||||
const locked = isDisabled(rt);
|
||||
const tooltip = [
|
||||
rt.name,
|
||||
owner ? t(($) => $.pickers.runtime_owned_by, { name: owner.name }) : null,
|
||||
rtOnline ? t(($) => $.pickers.runtime_online) : t(($) => $.pickers.runtime_offline),
|
||||
locked ? t(($) => $.create_dialog.runtime_private_locked_tooltip) : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
@@ -168,7 +180,11 @@ export function RuntimePicker({
|
||||
<PickerItem
|
||||
key={rt.id}
|
||||
selected={rt.id === value}
|
||||
onClick={() => void select(rt.id)}
|
||||
disabled={locked}
|
||||
onClick={() => {
|
||||
if (locked) return;
|
||||
void select(rt.id);
|
||||
}}
|
||||
tooltip={tooltip}
|
||||
>
|
||||
<ProviderLogo
|
||||
@@ -185,6 +201,12 @@ export function RuntimePicker({
|
||||
{t(($) => $.create_dialog.runtime_cloud_badge)}
|
||||
</span>
|
||||
)}
|
||||
{locked && (
|
||||
<span className="shrink-0 inline-flex items-center gap-0.5 rounded bg-muted px-1 text-[10px] font-medium text-muted-foreground">
|
||||
<Lock className="h-2.5 w-2.5" />
|
||||
{t(($) => $.create_dialog.runtime_private_badge)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-0.5 flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
{owner && (
|
||||
|
||||
@@ -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>
|
||||
|
||||
209
packages/views/chat/components/chat-input.test.tsx
Normal file
209
packages/views/chat/components/chat-input.test.tsx
Normal file
@@ -0,0 +1,209 @@
|
||||
import { forwardRef, useRef, useImperativeHandle } from "react";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { I18nProvider } from "@multica/core/i18n/react";
|
||||
import enCommon from "../../locales/en/common.json";
|
||||
import enChat from "../../locales/en/chat.json";
|
||||
|
||||
const TEST_RESOURCES = { en: { common: enCommon, chat: enChat } };
|
||||
|
||||
// Track drop-zone callbacks so the test can simulate a real drop.
|
||||
const dropHandlers = vi.hoisted(() => ({
|
||||
onDrop: null as null | ((files: File[]) => void),
|
||||
}));
|
||||
|
||||
vi.mock("../../editor", () => ({
|
||||
useFileDropZone: ({ onDrop }: { onDrop: (files: File[]) => void }) => {
|
||||
dropHandlers.onDrop = onDrop;
|
||||
return { isDragOver: false, dropZoneProps: { "data-testid": "drop-zone" } };
|
||||
},
|
||||
FileDropOverlay: () => null,
|
||||
ContentEditor: forwardRef(function MockContentEditor(
|
||||
{
|
||||
defaultValue,
|
||||
onUpdate,
|
||||
placeholder,
|
||||
onUploadFile,
|
||||
}: {
|
||||
defaultValue?: string;
|
||||
onUpdate?: (md: string) => void;
|
||||
placeholder?: string;
|
||||
onUploadFile?: (file: File) => Promise<{ id: string; link: string; filename: string } | null>;
|
||||
},
|
||||
ref: React.Ref<unknown>,
|
||||
) {
|
||||
const valueRef = useRef<string>(defaultValue ?? "");
|
||||
const uploadingRef = useRef(0);
|
||||
useImperativeHandle(ref, () => ({
|
||||
getMarkdown: () => valueRef.current,
|
||||
clearContent: () => {
|
||||
valueRef.current = "";
|
||||
},
|
||||
blur: () => {},
|
||||
focus: () => {},
|
||||
uploadFile: async (file: File) => {
|
||||
uploadingRef.current += 1;
|
||||
try {
|
||||
const result = await onUploadFile?.(file);
|
||||
if (result) {
|
||||
valueRef.current = `${valueRef.current}`.trim();
|
||||
onUpdate?.(valueRef.current);
|
||||
}
|
||||
} finally {
|
||||
uploadingRef.current = Math.max(0, uploadingRef.current - 1);
|
||||
}
|
||||
},
|
||||
hasActiveUploads: () => uploadingRef.current > 0,
|
||||
}));
|
||||
return (
|
||||
<textarea
|
||||
data-testid="editor"
|
||||
placeholder={placeholder}
|
||||
onChange={(e) => {
|
||||
valueRef.current = e.target.value;
|
||||
onUpdate?.(e.target.value);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock chat store with an in-memory implementation that supports both
|
||||
// (selector) calls and getState().
|
||||
vi.mock("@multica/core/chat", () => {
|
||||
const state = {
|
||||
activeSessionId: null as string | null,
|
||||
selectedAgentId: "agent-1",
|
||||
inputDrafts: {} as Record<string, string>,
|
||||
focusMode: false,
|
||||
setInputDraft: vi.fn(),
|
||||
clearInputDraft: vi.fn(),
|
||||
};
|
||||
return {
|
||||
DRAFT_NEW_SESSION: "__draft_new__",
|
||||
useChatStore: Object.assign(
|
||||
(selector?: (s: typeof state) => unknown) =>
|
||||
selector ? selector(state) : state,
|
||||
{ getState: () => state },
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
import { ChatInput } from "./chat-input";
|
||||
|
||||
function renderInput(props: Partial<React.ComponentProps<typeof ChatInput>> = {}) {
|
||||
const onSend = props.onSend ?? vi.fn();
|
||||
const onUploadFile =
|
||||
props.onUploadFile ??
|
||||
vi.fn(async (_file: File) => ({
|
||||
id: "att-1",
|
||||
link: "https://cdn.example/att-1.png",
|
||||
filename: "img.png",
|
||||
}));
|
||||
render(
|
||||
<I18nProvider locale="en" resources={TEST_RESOURCES}>
|
||||
<ChatInput onSend={onSend} onUploadFile={onUploadFile} agentName="Multica" {...props} />
|
||||
</I18nProvider>,
|
||||
);
|
||||
return { onSend, onUploadFile };
|
||||
}
|
||||
|
||||
describe("ChatInput attachment wiring", () => {
|
||||
it("routes dropped files through the editor's upload handler", async () => {
|
||||
const { onUploadFile } = renderInput();
|
||||
expect(dropHandlers.onDrop).not.toBeNull();
|
||||
const file = new File(["x"], "drop.png", { type: "image/png" });
|
||||
dropHandlers.onDrop?.([file]);
|
||||
// Microtask: the mock editor awaits onUploadFile before mutating its value.
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(onUploadFile).toHaveBeenCalledWith(file);
|
||||
});
|
||||
|
||||
it("passes attachment_ids to onSend for uploads still referenced in the content", async () => {
|
||||
const onSend = vi.fn();
|
||||
const onUploadFile = vi.fn(async (_file: File) => ({
|
||||
id: "att-42",
|
||||
link: "https://cdn.example/att-42.png",
|
||||
filename: "x.png",
|
||||
}));
|
||||
renderInput({ onSend, onUploadFile });
|
||||
|
||||
// Simulate the drop → editor.uploadFile → onUploadFile happy path. The
|
||||
// mock editor appends the markdown link into its value and calls
|
||||
// onUpdate so the input flips out of the empty state.
|
||||
const file = new File(["x"], "drop.png", { type: "image/png" });
|
||||
dropHandlers.onDrop?.([file]);
|
||||
|
||||
// Wait for the submit button to become enabled (onUpdate has fired and
|
||||
// React has re-rendered). SubmitButton has no aria-label, so we pick
|
||||
// the last action button on the bar (FileUploadButton, SubmitButton).
|
||||
let sendButton: HTMLElement;
|
||||
await waitFor(() => {
|
||||
const buttons = screen.getAllByRole("button");
|
||||
sendButton = buttons[buttons.length - 1]!;
|
||||
expect(sendButton).not.toBeDisabled();
|
||||
});
|
||||
fireEvent.click(sendButton!);
|
||||
|
||||
expect(onSend).toHaveBeenCalledTimes(1);
|
||||
const [, ids] = onSend.mock.calls[0]!;
|
||||
expect(ids).toEqual(["att-42"]);
|
||||
});
|
||||
|
||||
it("disables send while an upload is in flight, re-enables after it resolves", async () => {
|
||||
let resolveUpload: (v: { id: string; link: string; filename: string }) => void;
|
||||
const uploadPromise = new Promise<{ id: string; link: string; filename: string }>((res) => {
|
||||
resolveUpload = res;
|
||||
});
|
||||
const onSend = vi.fn();
|
||||
const onUploadFile = vi.fn(() => uploadPromise);
|
||||
renderInput({ onSend, onUploadFile });
|
||||
|
||||
// Give the editor some text so isEmpty=false — this isolates the
|
||||
// disabled state to the pending-upload condition (otherwise both
|
||||
// checks would fire and the test couldn't tell them apart).
|
||||
fireEvent.change(screen.getByTestId("editor"), { target: { value: "preview text" } });
|
||||
|
||||
const file = new File(["x"], "slow.png", { type: "image/png" });
|
||||
dropHandlers.onDrop?.([file]);
|
||||
|
||||
// While the upload is pending the SubmitButton must be disabled.
|
||||
// Bypassing this would send the message with the attachment id
|
||||
// missing from the body.
|
||||
await waitFor(() => {
|
||||
const buttons = screen.getAllByRole("button");
|
||||
const sendButton = buttons[buttons.length - 1]!;
|
||||
expect(sendButton).toBeDisabled();
|
||||
});
|
||||
|
||||
resolveUpload!({
|
||||
id: "att-slow",
|
||||
link: "https://cdn.example/att-slow.png",
|
||||
filename: "slow.png",
|
||||
});
|
||||
|
||||
let sendButton: HTMLElement;
|
||||
await waitFor(() => {
|
||||
const buttons = screen.getAllByRole("button");
|
||||
sendButton = buttons[buttons.length - 1]!;
|
||||
expect(sendButton).not.toBeDisabled();
|
||||
});
|
||||
fireEvent.click(sendButton!);
|
||||
expect(onSend).toHaveBeenCalledTimes(1);
|
||||
const [, ids] = onSend.mock.calls[0]!;
|
||||
expect(ids).toEqual(["att-slow"]);
|
||||
});
|
||||
|
||||
it("does not render the file upload button when onUploadFile is omitted", () => {
|
||||
renderInput({ onUploadFile: undefined });
|
||||
// FileUploadButton renders an icon button labelled by its tooltip — when
|
||||
// upload wiring is absent the chat input falls back to "submit + extras"
|
||||
// only. Probe by counting buttons: with no upload, only the submit
|
||||
// button is in the action row.
|
||||
const buttons = screen.getAllByRole("button");
|
||||
// The agent picker / context anchor adornments may render zero buttons
|
||||
// in this test (no leftAdornment passed). So a single button = submit.
|
||||
expect(buttons.length).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -1,18 +1,33 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { useRef, useState } from "react";
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import { ContentEditor, type ContentEditorRef } from "../../editor";
|
||||
import {
|
||||
ContentEditor,
|
||||
type ContentEditorRef,
|
||||
useFileDropZone,
|
||||
FileDropOverlay,
|
||||
} from "../../editor";
|
||||
import { FileUploadButton } from "@multica/ui/components/common/file-upload-button";
|
||||
import { SubmitButton } from "@multica/ui/components/common/submit-button";
|
||||
import { useChatStore, DRAFT_NEW_SESSION } from "@multica/core/chat";
|
||||
import { createLogger } from "@multica/core/logger";
|
||||
import { enterKey, formatShortcut, modKey } from "@multica/core/platform";
|
||||
import type { UploadResult } from "@multica/core/hooks/use-file-upload";
|
||||
import { useT } from "../../i18n";
|
||||
|
||||
const logger = createLogger("chat.ui");
|
||||
|
||||
interface ChatInputProps {
|
||||
onSend: (content: string) => void;
|
||||
onSend: (content: string, attachmentIds?: string[]) => void;
|
||||
/** Receives a File and returns the attachment row (with id + CDN link).
|
||||
* The wrapper owner (ChatWindow) lazy-creates a chat_session if needed
|
||||
* and forwards `chatSessionId` to the upload — chat-input only cares
|
||||
* about the upload result so it can map URL → id for back-fill on send.
|
||||
* When unset, paste/drag/button still type into the editor but no upload
|
||||
* fires (the editor's file-upload extension is a no-op without a handler). */
|
||||
onUploadFile?: (file: File) => Promise<UploadResult | null>;
|
||||
onStop?: () => void;
|
||||
isRunning?: boolean;
|
||||
disabled?: boolean;
|
||||
@@ -33,6 +48,7 @@ interface ChatInputProps {
|
||||
|
||||
export function ChatInput({
|
||||
onSend,
|
||||
onUploadFile,
|
||||
onStop,
|
||||
isRunning,
|
||||
disabled,
|
||||
@@ -46,19 +62,70 @@ export function ChatInput({
|
||||
const editorRef = useRef<ContentEditorRef>(null);
|
||||
const activeSessionId = useChatStore((s) => s.activeSessionId);
|
||||
const selectedAgentId = useChatStore((s) => s.selectedAgentId);
|
||||
// Scope the new-chat draft by agent:
|
||||
// 1. Switching agents while composing a brand-new chat gives each
|
||||
// agent its own draft (no cross-agent leakage).
|
||||
// 2. Tiptap's Placeholder extension is only applied at mount; this
|
||||
// key changes on agent switch so the editor remounts and the
|
||||
// `Tell {agent} what to do…` placeholder refreshes.
|
||||
// Two keys with deliberately different concerns:
|
||||
//
|
||||
// `draftKey` — zustand storage key. Scopes the in-progress draft per
|
||||
// session so different sessions don't bleed text into each other; for
|
||||
// brand-new chats it falls back to a per-agent slot so switching agents
|
||||
// mid-compose gives each agent its own draft. This is a STORAGE key, not
|
||||
// a React identity.
|
||||
//
|
||||
// `editorKey` — React `key` on the ContentEditor. Used ONLY to force a
|
||||
// remount when the user explicitly switches agent (so Tiptap's
|
||||
// Placeholder, which only reads on mount, refreshes to "Tell {agent}…").
|
||||
// Crucially this does NOT include `activeSessionId`: when the user
|
||||
// uploads a file in a brand-new chat, `handleUploadFile` first awaits
|
||||
// `ensureSession` which lazily creates the session and flips
|
||||
// `activeSessionId` from null → uuid mid-upload. If the editor key
|
||||
// depended on session id, that flip would unmount the editor right as
|
||||
// the blob preview was inserted, dropping the in-progress upload's
|
||||
// image node before file-upload.ts could swap it for the CDN URL — the
|
||||
// user would see the image flash on then disappear. Keeping editor
|
||||
// identity stable across the lazy-create event is what makes
|
||||
// first-upload-creates-session work the same as second-upload.
|
||||
const draftKey =
|
||||
activeSessionId ?? `${DRAFT_NEW_SESSION}:${selectedAgentId ?? ""}`;
|
||||
const editorKey = selectedAgentId ?? "no-agent";
|
||||
// Select a primitive — empty-string fallback keeps referential stability.
|
||||
const inputDraft = useChatStore((s) => s.inputDrafts[draftKey] ?? "");
|
||||
const setInputDraft = useChatStore((s) => s.setInputDraft);
|
||||
const clearInputDraft = useChatStore((s) => s.clearInputDraft);
|
||||
const [isEmpty, setIsEmpty] = useState(!inputDraft.trim());
|
||||
// Number of in-flight uploads. We track this explicitly (rather than
|
||||
// peeking at the editor on every render) so the SubmitButton visibly
|
||||
// disables the instant an upload starts and re-enables the instant it
|
||||
// finishes. handleSend ALSO checks `hasActiveUploads()` for paths that
|
||||
// bypass the button (Mod+Enter while paste is mid-stream, drag-drop
|
||||
// racing the keyboard) — defense in depth.
|
||||
const [pendingUploads, setPendingUploads] = useState(0);
|
||||
|
||||
// Maps "CDN URL inserted into the editor" → "attachment row id" so that
|
||||
// on send we can ask the server to bind only the attachments still
|
||||
// referenced in the message body. Cleared after every send. Mirrors the
|
||||
// comment-input flow exactly.
|
||||
const uploadMapRef = useRef<Map<string, string>>(new Map());
|
||||
|
||||
const handleUpload = useCallback(
|
||||
async (file: File): Promise<UploadResult | null> => {
|
||||
if (!onUploadFile) return null;
|
||||
setPendingUploads((n) => n + 1);
|
||||
try {
|
||||
const result = await onUploadFile(file);
|
||||
if (result) uploadMapRef.current.set(result.link, result.id);
|
||||
return result;
|
||||
} finally {
|
||||
setPendingUploads((n) => Math.max(0, n - 1));
|
||||
}
|
||||
},
|
||||
[onUploadFile],
|
||||
);
|
||||
|
||||
// Drop zone wraps the rounded card so a drop anywhere on the input
|
||||
// surface routes the file through the editor's upload extension (same
|
||||
// handler as the in-editor paste path).
|
||||
const { isDragOver, dropZoneProps } = useFileDropZone({
|
||||
onDrop: (files) => files.forEach((f) => editorRef.current?.uploadFile(f)),
|
||||
});
|
||||
|
||||
const handleSend = () => {
|
||||
const content = editorRef.current?.getMarkdown()?.replace(/(\n\s*)+$/, "").trim();
|
||||
@@ -71,12 +138,33 @@ export function ChatInput({
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Block the send while any file is still uploading. If we let it
|
||||
// through the attachment id is not yet in uploadMapRef (the upload
|
||||
// resolves later) and the attachment would only end up bound to the
|
||||
// session, not the message — the agent then can't `multica attachment
|
||||
// download <id>` the file. The SubmitButton is also disabled in this
|
||||
// state via `uploading`, but Mod+Enter bypasses the button so we
|
||||
// still gate here.
|
||||
if (editorRef.current?.hasActiveUploads()) {
|
||||
logger.debug("input.send skipped: uploads in flight");
|
||||
return;
|
||||
}
|
||||
// Only send attachment IDs for uploads still present in the content.
|
||||
// Edits / deletions that remove the markdown URL also drop the binding.
|
||||
const activeIds: string[] = [];
|
||||
for (const [url, id] of uploadMapRef.current) {
|
||||
if (content.includes(url)) activeIds.push(id);
|
||||
}
|
||||
// Capture draft key BEFORE onSend — creating a new session mutates
|
||||
// activeSessionId synchronously, so reading it after onSend would point
|
||||
// at the new session and leave the old draft orphaned.
|
||||
const keyAtSend = draftKey;
|
||||
logger.info("input.send", { contentLength: content.length, draftKey: keyAtSend });
|
||||
onSend(content);
|
||||
logger.info("input.send", {
|
||||
contentLength: content.length,
|
||||
draftKey: keyAtSend,
|
||||
attachmentCount: activeIds.length,
|
||||
});
|
||||
onSend(content, activeIds.length > 0 ? activeIds : undefined);
|
||||
editorRef.current?.clearContent();
|
||||
// Drop focus so the caret doesn't keep blinking under the StatusPill /
|
||||
// streaming reply that's about to take over the user's attention. The
|
||||
@@ -87,6 +175,7 @@ export function ChatInput({
|
||||
// a fair price for not stealing focus mid-action.
|
||||
editorRef.current?.blur();
|
||||
clearInputDraft(keyAtSend);
|
||||
uploadMapRef.current.clear();
|
||||
setIsEmpty(true);
|
||||
};
|
||||
|
||||
@@ -98,6 +187,8 @@ export function ChatInput({
|
||||
? t(($) => $.input.placeholder_named, { name: agentName })
|
||||
: t(($) => $.input.placeholder_default);
|
||||
|
||||
const uploadEnabled = !!onUploadFile && !disabled && !noAgent;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -110,6 +201,7 @@ export function ChatInput({
|
||||
)}
|
||||
>
|
||||
<div
|
||||
{...(uploadEnabled ? dropZoneProps : {})}
|
||||
className={cn(
|
||||
"relative mx-auto flex min-h-16 max-h-40 w-full max-w-4xl flex-col rounded-lg bg-card pb-9 border-1 border-border transition-colors focus-within:border-brand",
|
||||
// Visual + interaction lock when there's no agent. We don't
|
||||
@@ -125,9 +217,9 @@ export function ChatInput({
|
||||
{topSlot}
|
||||
<div className="flex-1 min-h-0 overflow-y-auto px-3 py-2">
|
||||
<ContentEditor
|
||||
// Remount the editor when the active session changes so its
|
||||
// uncontrolled defaultValue picks up the new session's draft.
|
||||
key={draftKey}
|
||||
// See the editorKey / draftKey split note above — editorKey
|
||||
// intentionally does not depend on activeSessionId.
|
||||
key={editorKey}
|
||||
ref={editorRef}
|
||||
defaultValue={inputDraft}
|
||||
placeholder={placeholder}
|
||||
@@ -136,12 +228,15 @@ export function ChatInput({
|
||||
setInputDraft(draftKey, md);
|
||||
}}
|
||||
onSubmit={handleSend}
|
||||
onUploadFile={uploadEnabled ? handleUpload : undefined}
|
||||
debounceMs={100}
|
||||
// Chat is short-form — the floating formatting toolbar is
|
||||
// more distraction than feature here.
|
||||
showBubbleMenu={false}
|
||||
// Enter sends; Shift-Enter inserts a hard break.
|
||||
submitOnEnter
|
||||
// Mod+Enter submits. Bare Enter falls through to Tiptap's
|
||||
// default, which continues lists/quotes and breaks paragraphs.
|
||||
// Without this, Enter-as-send would steal the only key that
|
||||
// continues a bullet list, leaving users stuck after one item.
|
||||
/>
|
||||
</div>
|
||||
{leftAdornment && (
|
||||
@@ -149,15 +244,24 @@ export function ChatInput({
|
||||
{leftAdornment}
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute bottom-1 right-1.5 flex items-center gap-2">
|
||||
<div className="absolute bottom-1 right-1.5 flex items-center gap-1">
|
||||
{rightAdornment}
|
||||
{uploadEnabled && (
|
||||
<FileUploadButton
|
||||
size="sm"
|
||||
onSelect={(file) => editorRef.current?.uploadFile(file)}
|
||||
/>
|
||||
)}
|
||||
<SubmitButton
|
||||
onClick={handleSend}
|
||||
disabled={isEmpty || !!disabled || !!noAgent}
|
||||
disabled={isEmpty || !!disabled || !!noAgent || pendingUploads > 0}
|
||||
running={isRunning}
|
||||
onStop={onStop}
|
||||
tooltip={`${t(($) => $.input.send_tooltip)} · ${formatShortcut(modKey, enterKey)}`}
|
||||
stopTooltip={t(($) => $.input.stop_tooltip)}
|
||||
/>
|
||||
</div>
|
||||
{uploadEnabled && isDragOver && <FileDropOverlay />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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,28 +15,41 @@ 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";
|
||||
import { canAssignAgent } from "@multica/views/issues/components";
|
||||
import { api } from "@multica/core/api";
|
||||
import { useAgentPresenceDetail, useWorkspaceAgentAvailability } from "@multica/core/agents";
|
||||
import { useFileUpload } from "@multica/core/hooks/use-file-upload";
|
||||
import { ActorAvatar } from "../../common/actor-avatar";
|
||||
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 +74,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 +103,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";
|
||||
|
||||
@@ -177,8 +190,62 @@ export function ChatWindow() {
|
||||
// (focus mode tracks the user's page, not a per-message attachment).
|
||||
const { candidate: anchorCandidate } = useRouteAnchorCandidate(wsId);
|
||||
|
||||
const { uploadWithToast } = useFileUpload(api);
|
||||
|
||||
// Lazy-creates a chat_session the first time the user needs an id —
|
||||
// either to send a message or to attach an uploaded file. Pulled out of
|
||||
// handleSend so the upload path (which fires before any text exists) can
|
||||
// get a session_id to hang the attachment on. Returns null when no agent
|
||||
// is available; callers must early-return in that case.
|
||||
//
|
||||
// Concurrent callers (e.g. user drops a file → handleUploadFile, then
|
||||
// quickly clicks send → handleSend) would each observe activeSessionId
|
||||
// === null and fire a separate createSession.mutateAsync, creating two
|
||||
// sessions and orphaning the attachment on the wrong one. The in-flight
|
||||
// promise ref dedupes those races: the first caller starts the create,
|
||||
// every subsequent caller awaits the same promise until it settles.
|
||||
//
|
||||
// titleSeed is the first 50 chars of the user's message when called from
|
||||
// send; the upload path passes "" and we leave the title empty so the
|
||||
// session-dropdown's existing localized `window.untitled` fallback kicks
|
||||
// in. A follow-up task may back-fill the real title from the first user
|
||||
// message — until then this keeps the session list scannable across locales.
|
||||
const sessionPromiseRef = useRef<Promise<string | null> | null>(null);
|
||||
const ensureSession = useCallback(
|
||||
async (titleSeed: string): Promise<string | null> => {
|
||||
if (activeSessionId) return activeSessionId;
|
||||
if (!activeAgent) return null;
|
||||
if (sessionPromiseRef.current) return sessionPromiseRef.current;
|
||||
|
||||
const promise = (async () => {
|
||||
try {
|
||||
const session = await createSession.mutateAsync({
|
||||
agent_id: activeAgent.id,
|
||||
title: titleSeed.slice(0, 50),
|
||||
});
|
||||
setActiveSession(session.id);
|
||||
return session.id;
|
||||
} finally {
|
||||
sessionPromiseRef.current = null;
|
||||
}
|
||||
})();
|
||||
sessionPromiseRef.current = promise;
|
||||
return promise;
|
||||
},
|
||||
[activeSessionId, activeAgent, createSession, setActiveSession],
|
||||
);
|
||||
|
||||
const handleUploadFile = useCallback(
|
||||
async (file: File) => {
|
||||
const sessionId = await ensureSession("");
|
||||
if (!sessionId) return null;
|
||||
return uploadWithToast(file, { chatSessionId: sessionId });
|
||||
},
|
||||
[ensureSession, uploadWithToast],
|
||||
);
|
||||
|
||||
const handleSend = useCallback(
|
||||
async (content: string) => {
|
||||
async (content: string, attachmentIds?: string[]) => {
|
||||
if (!activeAgent) {
|
||||
apiLogger.warn("sendChatMessage skipped: no active agent");
|
||||
return;
|
||||
@@ -189,24 +256,21 @@ export function ChatWindow() {
|
||||
? `${buildAnchorMarkdown(anchorCandidate)}\n\n${content}`
|
||||
: content;
|
||||
|
||||
let sessionId = activeSessionId;
|
||||
const isNewSession = !sessionId;
|
||||
const isNewSession = !activeSessionId;
|
||||
|
||||
apiLogger.info("sendChatMessage.start", {
|
||||
sessionId,
|
||||
sessionId: activeSessionId,
|
||||
isNewSession,
|
||||
agentId: activeAgent.id,
|
||||
contentLength: finalContent.length,
|
||||
hasAnchor: focusOn && !!anchorCandidate,
|
||||
attachmentCount: attachmentIds?.length ?? 0,
|
||||
});
|
||||
|
||||
const sessionId = await ensureSession(finalContent);
|
||||
if (!sessionId) {
|
||||
const session = await createSession.mutateAsync({
|
||||
agent_id: activeAgent.id,
|
||||
title: finalContent.slice(0, 50),
|
||||
});
|
||||
sessionId = session.id;
|
||||
setActiveSession(sessionId);
|
||||
apiLogger.warn("sendChatMessage aborted: ensureSession returned null");
|
||||
return;
|
||||
}
|
||||
|
||||
// Optimistic burst — everything that gives the user "I sent a message
|
||||
@@ -239,7 +303,7 @@ export function ChatWindow() {
|
||||
});
|
||||
apiLogger.debug("sendChatMessage.optimistic", { sessionId, optimisticId: optimistic.id });
|
||||
|
||||
const result = await api.sendChatMessage(sessionId, finalContent);
|
||||
const result = await api.sendChatMessage(sessionId, finalContent, attachmentIds);
|
||||
apiLogger.info("sendChatMessage.success", {
|
||||
sessionId,
|
||||
messageId: result.message_id,
|
||||
@@ -259,8 +323,7 @@ export function ChatWindow() {
|
||||
activeSessionId,
|
||||
activeAgent,
|
||||
anchorCandidate,
|
||||
createSession,
|
||||
setActiveSession,
|
||||
ensureSession,
|
||||
qc,
|
||||
],
|
||||
);
|
||||
@@ -411,24 +474,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 +509,59 @@ 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}
|
||||
onUploadFile={handleUploadFile}
|
||||
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 +673,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 +698,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 +736,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.
|
||||
|
||||
@@ -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 (
|
||||
|
||||
77
packages/views/editor/attachment-download-context.tsx
Normal file
77
packages/views/editor/attachment-download-context.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext, useMemo, type ReactNode } from "react";
|
||||
import type { Attachment } from "@multica/core/types";
|
||||
import { openExternal } from "../platform";
|
||||
import { useDownloadAttachment } from "./use-download-attachment";
|
||||
|
||||
interface ResolvedDownload {
|
||||
// Returns the attachment id for a URL referenced in the markdown, or
|
||||
// `undefined` if it's an external link we don't manage.
|
||||
resolveAttachmentId: (url: string) => string | undefined;
|
||||
// Called by NodeView click handlers. Re-signs through `getAttachment` when
|
||||
// the URL maps to a known attachment; falls back to `openExternal` for
|
||||
// external URLs so Electron still routes through the IPC bridge instead of
|
||||
// letting `window.open` hit the `setWindowOpenHandler` deny path.
|
||||
openByUrl: (url: string) => void;
|
||||
}
|
||||
|
||||
const AttachmentDownloadContext = createContext<ResolvedDownload | null>(null);
|
||||
|
||||
interface ProviderProps {
|
||||
attachments?: Attachment[];
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides a click-time download handler to Tiptap NodeViews mounted inside
|
||||
* `ContentEditor`. Without a provider the consumer falls back to opening the
|
||||
* raw URL via `openExternal` — same behaviour as before this hook existed.
|
||||
*/
|
||||
export function AttachmentDownloadProvider({ attachments, children }: ProviderProps) {
|
||||
const download = useDownloadAttachment();
|
||||
const value = useMemo<ResolvedDownload>(
|
||||
() => ({
|
||||
resolveAttachmentId: (url) => {
|
||||
if (!url || !attachments?.length) return undefined;
|
||||
return attachments.find((a) => a.url === url)?.id;
|
||||
},
|
||||
openByUrl: (url) => {
|
||||
const id = url && attachments?.length
|
||||
? attachments.find((a) => a.url === url)?.id
|
||||
: undefined;
|
||||
if (id) {
|
||||
download(id);
|
||||
return;
|
||||
}
|
||||
if (url) openExternal(url);
|
||||
},
|
||||
}),
|
||||
[attachments, download],
|
||||
);
|
||||
return (
|
||||
<AttachmentDownloadContext.Provider value={value}>
|
||||
{children}
|
||||
</AttachmentDownloadContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the click-time download handler installed by a surrounding
|
||||
* `AttachmentDownloadProvider`, or a fallback that just opens the raw URL
|
||||
* externally. Used by file-card and image NodeViews so they can stay
|
||||
* usable in editor surfaces that haven't been wired up yet.
|
||||
*/
|
||||
export function useAttachmentDownloadResolver(): ResolvedDownload {
|
||||
const ctx = useContext(AttachmentDownloadContext);
|
||||
// Hooks-must-be-unconditional: always create the fallback object, but
|
||||
// memoization is unnecessary here because each NodeView render also
|
||||
// re-runs the click handler closure.
|
||||
if (ctx) return ctx;
|
||||
return {
|
||||
resolveAttachmentId: () => undefined,
|
||||
openByUrl: (url) => {
|
||||
if (url) openExternal(url);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -28,7 +28,6 @@
|
||||
.rich-text-editor.ProseMirror {
|
||||
color: var(--foreground);
|
||||
caret-color: var(--foreground);
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.rich-text-editor.ProseMirror:focus {
|
||||
|
||||
@@ -42,12 +42,14 @@ import { cn } from "@multica/ui/lib/utils";
|
||||
import type { UploadResult } from "@multica/core/hooks/use-file-upload";
|
||||
import { useWorkspaceSlug } from "@multica/core/paths";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import type { Attachment } from "@multica/core/types";
|
||||
import { createEditorExtensions } from "./extensions";
|
||||
import { uploadAndInsertFile } from "./extensions/file-upload";
|
||||
import { preprocessMarkdown } from "./utils/preprocess";
|
||||
import { openLink, isMentionHref } from "./utils/link-handler";
|
||||
import { EditorBubbleMenu } from "./bubble-menu";
|
||||
import { useLinkHover, LinkHoverCard } from "./link-hover-card";
|
||||
import { AttachmentDownloadProvider } from "./attachment-download-context";
|
||||
import "katex/dist/katex.min.css";
|
||||
import "./content-editor.css";
|
||||
|
||||
@@ -92,6 +94,15 @@ interface ContentEditorProps {
|
||||
* system prompts, where the content is fed to an LLM as plain text).
|
||||
*/
|
||||
disableMentions?: boolean;
|
||||
/**
|
||||
* Attachments referenced by this content. The download buttons on file
|
||||
* cards and images inside the editor look up an attachment by `url` and
|
||||
* fetch a fresh CloudFront signature at click time, so a stale URL
|
||||
* persisted in markdown never opens. Pass `issue.attachments` /
|
||||
* `comment.attachments` etc.; omit when no attachment context is
|
||||
* available (NodeView buttons fall back to opening the raw URL).
|
||||
*/
|
||||
attachments?: Attachment[];
|
||||
}
|
||||
|
||||
interface ContentEditorRef {
|
||||
@@ -126,6 +137,7 @@ const ContentEditor = forwardRef<ContentEditorRef, ContentEditorProps>(
|
||||
submitOnEnter = false,
|
||||
currentIssueId,
|
||||
disableMentions = false,
|
||||
attachments,
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
@@ -199,7 +211,7 @@ const ContentEditor = forwardRef<ContentEditorRef, ContentEditorProps>(
|
||||
},
|
||||
},
|
||||
attributes: {
|
||||
class: cn("rich-text-editor text-sm outline-none", className),
|
||||
class: cn("flex-1 rich-text-editor text-sm outline-none", className),
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -257,17 +269,19 @@ const ContentEditor = forwardRef<ContentEditorRef, ContentEditorProps>(
|
||||
if (!editor) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={wrapperRef}
|
||||
className="relative flex min-h-full flex-col"
|
||||
onMouseDown={handleContainerMouseDown}
|
||||
>
|
||||
<EditorContent className="flex-1 min-h-full" editor={editor} />
|
||||
{showBubbleMenu && (
|
||||
<EditorBubbleMenu editor={editor} currentIssueId={currentIssueId} />
|
||||
)}
|
||||
<LinkHoverCard {...hover} />
|
||||
</div>
|
||||
<AttachmentDownloadProvider attachments={attachments}>
|
||||
<div
|
||||
ref={wrapperRef}
|
||||
className="relative flex flex-1 min-h-full flex-col"
|
||||
onMouseDown={handleContainerMouseDown}
|
||||
>
|
||||
<EditorContent className="flex flex-1 flex-col" editor={editor} />
|
||||
{showBubbleMenu && (
|
||||
<EditorBubbleMenu editor={editor} currentIssueId={currentIssueId} />
|
||||
)}
|
||||
<LinkHoverCard {...hover} />
|
||||
</div>
|
||||
</AttachmentDownloadProvider>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user