mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-20 13:18:56 +02:00
Compare commits
1 Commits
agent/lamb
...
fix/code-b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
565d45cdcc |
@@ -63,9 +63,6 @@ GOOGLE_CLIENT_SECRET=
|
||||
GOOGLE_REDIRECT_URI=http://localhost:3000/auth/callback
|
||||
|
||||
# S3 / CloudFront
|
||||
# S3_BUCKET — bucket NAME only (e.g. "my-bucket"). Do NOT include the
|
||||
# ".s3.<region>.amazonaws.com" suffix; the server builds the public URL
|
||||
# from S3_BUCKET + S3_REGION. S3_REGION must match the bucket's real region.
|
||||
S3_BUCKET=
|
||||
S3_REGION=us-west-2
|
||||
CLOUDFRONT_KEY_PAIR_ID=
|
||||
|
||||
4
.github/workflows/ci.yml
vendored
4
.github/workflows/ci.yml
vendored
@@ -29,8 +29,8 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Build, type check, lint, and test
|
||||
run: pnpm exec turbo build typecheck lint test --filter='!@multica/docs'
|
||||
- name: Build, type check, and test
|
||||
run: pnpm exec turbo build typecheck test --filter='!@multica/docs'
|
||||
|
||||
backend:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
15
CLAUDE.md
15
CLAUDE.md
@@ -2,21 +2,6 @@
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Conventions reference
|
||||
|
||||
The single source of truth for **code naming, the i18n translation glossary, and the Chinese voice guide** is the docs site:
|
||||
|
||||
- **`apps/docs/content/docs/developers/conventions.mdx`** (English)
|
||||
- **`apps/docs/content/docs/developers/conventions.zh.mdx`** (Chinese)
|
||||
|
||||
Read that page before:
|
||||
|
||||
- Writing or editing translations (`packages/views/locales/`)
|
||||
- Naming a new route, package, file, DB column, or TS type
|
||||
- Writing Chinese product copy (UI strings, error messages, docs)
|
||||
|
||||
The legacy `packages/views/locales/glossary.md` is now a stub redirecting to the docs page; do not rely on it.
|
||||
|
||||
## Project Context
|
||||
|
||||
Multica is an AI-native task management platform — like Linear, but with AI agents as first-class citizens.
|
||||
|
||||
@@ -305,11 +305,10 @@ multica workspace members <workspace-id>
|
||||
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 --limit 20 --output json
|
||||
```
|
||||
|
||||
Available filters: `--status`, `--priority`, `--assignee` / `--assignee-id`, `--project`, `--limit`. Use `--assignee-id <uuid>` for unambiguous filtering when names overlap.
|
||||
Available filters: `--status`, `--priority`, `--assignee`, `--project`, `--limit`.
|
||||
|
||||
### Get Issue
|
||||
|
||||
@@ -322,10 +321,9 @@ multica issue get <id> --output json
|
||||
|
||||
```bash
|
||||
multica issue create --title "Fix login bug" --description "..." --priority high --assignee "Lambda"
|
||||
multica issue create --title "Fix login bug" --assignee-id 5fb87ac7-23b5-4a7a-81fa-ed295a54545d
|
||||
```
|
||||
|
||||
Flags: `--title` (required), `--description`, `--status`, `--priority`, `--assignee` / `--assignee-id`, `--parent`, `--project`, `--due-date`. Pass `--assignee-id <uuid>` (mutually exclusive with `--assignee`) when scripting against the IDs returned by `multica workspace members --output json` / `multica agent list --output json`.
|
||||
Flags: `--title` (required), `--description`, `--status`, `--priority`, `--assignee`, `--parent`, `--project`, `--due-date`.
|
||||
|
||||
### Update Issue
|
||||
|
||||
@@ -337,12 +335,9 @@ multica issue update <id> --title "New title" --priority urgent
|
||||
|
||||
```bash
|
||||
multica issue assign <id> --to "Lambda"
|
||||
multica issue assign <id> --to-id 5fb87ac7-23b5-4a7a-81fa-ed295a54545d
|
||||
multica issue assign <id> --unassign
|
||||
```
|
||||
|
||||
Pass `--to-id <uuid>` to assign by canonical UUID (mutually exclusive with `--to`); useful when names overlap across members and agents.
|
||||
|
||||
### Change Status
|
||||
|
||||
```bash
|
||||
|
||||
@@ -56,15 +56,13 @@ Changes take effect after restarting the backend / compose stack. The web UI rea
|
||||
|
||||
### File Storage (Optional)
|
||||
|
||||
For file uploads and attachments, configure S3 and (optionally) CloudFront:
|
||||
For file uploads and attachments, configure S3 and CloudFront:
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `S3_BUCKET` | Bucket name only (e.g. `my-bucket`). Do **not** include the `.s3.<region>.amazonaws.com` suffix — the server constructs the public URL from `S3_BUCKET` + `S3_REGION` |
|
||||
| `S3_REGION` | AWS region (default: `us-west-2`). Must match the bucket's actual region — used for both SDK signing and public URLs |
|
||||
| `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` | Static credentials. When both are unset, the AWS SDK default credential chain is used |
|
||||
| `AWS_ENDPOINT_URL` | Custom S3-compatible endpoint (e.g. MinIO, R2, B2). Setting this switches the public URL to path-style |
|
||||
| `CLOUDFRONT_DOMAIN` | CloudFront distribution domain — when set, public URLs use this host instead of the S3 host |
|
||||
| `S3_BUCKET` | S3 bucket name |
|
||||
| `S3_REGION` | AWS region (default: `us-west-2`) |
|
||||
| `CLOUDFRONT_DOMAIN` | CloudFront distribution domain |
|
||||
| `CLOUDFRONT_KEY_PAIR_ID` | CloudFront key pair ID for signed URLs |
|
||||
| `CLOUDFRONT_PRIVATE_KEY` | CloudFront private key (PEM format) |
|
||||
|
||||
|
||||
12
apps/desktop/.env.production
Normal file
12
apps/desktop/.env.production
Normal file
@@ -0,0 +1,12 @@
|
||||
# Production environment for `pnpm package` / `pnpm build`.
|
||||
# electron-vite (Vite under the hood) reads this automatically in
|
||||
# production mode and inlines the values into the renderer bundle via
|
||||
# import.meta.env.VITE_*. These are public URLs, not secrets.
|
||||
|
||||
# Backend API + websocket the desktop app talks to.
|
||||
VITE_API_URL=https://api.multica.ai
|
||||
VITE_WS_URL=wss://api.multica.ai/ws
|
||||
|
||||
# Public web app URL — used to build shareable links like "Copy link to
|
||||
# issue" that users paste into Slack / messages. See platform/navigation.tsx.
|
||||
VITE_APP_URL=https://multica.ai
|
||||
@@ -8,8 +8,6 @@ import { setupDaemonManager } from "./daemon-manager";
|
||||
import { openExternalSafely } from "./external-url";
|
||||
import { installContextMenu } from "./context-menu";
|
||||
import { getAppVersion } from "./app-version";
|
||||
import { loadRuntimeConfig } from "./runtime-config-loader";
|
||||
import type { RuntimeConfigResult } from "../shared/runtime-config";
|
||||
|
||||
// Bundled icon used for dev-mode dock/taskbar branding. In production the
|
||||
// app bundle icon (from electron-builder) wins; this path is only consumed
|
||||
@@ -39,10 +37,6 @@ if (process.platform !== "win32") {
|
||||
const PROTOCOL = "multica";
|
||||
|
||||
let mainWindow: BrowserWindow | null = null;
|
||||
let runtimeConfigResult: RuntimeConfigResult = {
|
||||
ok: false,
|
||||
error: { message: "Runtime config has not loaded yet" },
|
||||
};
|
||||
|
||||
// --- Deep link helpers ---------------------------------------------------
|
||||
|
||||
@@ -78,25 +72,7 @@ function handleDeepLink(url: string): void {
|
||||
|
||||
// --- Window creation -----------------------------------------------------
|
||||
|
||||
// Tracks the OS-preferred language as last seen by the running process.
|
||||
// Updated on each window-focus check so we can emit a `locale:system-changed`
|
||||
// event to the renderer when the user changes their OS language without
|
||||
// quitting the app — without restart, app.getPreferredSystemLanguages()
|
||||
// would still report the boot value forever.
|
||||
let lastKnownSystemLocale = "en";
|
||||
|
||||
function getSystemLocale(): string {
|
||||
return app.getPreferredSystemLanguages()[0] ?? "en";
|
||||
}
|
||||
|
||||
function createWindow(): void {
|
||||
// Pass the OS-preferred language to the renderer via additionalArguments
|
||||
// instead of a sync IPC call. process.argv is available to the preload
|
||||
// script before the first network request, so the renderer's i18next
|
||||
// instance can initialize with the right locale on the very first paint.
|
||||
const systemLocale = getSystemLocale();
|
||||
lastKnownSystemLocale = systemLocale;
|
||||
|
||||
mainWindow = new BrowserWindow({
|
||||
width: 1280,
|
||||
height: 800,
|
||||
@@ -113,7 +89,6 @@ function createWindow(): void {
|
||||
preload: join(__dirname, "../preload/index.js"),
|
||||
sandbox: false,
|
||||
webSecurity: false,
|
||||
additionalArguments: [`--multica-locale=${systemLocale}`],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -131,18 +106,6 @@ function createWindow(): void {
|
||||
mainWindow?.show();
|
||||
});
|
||||
|
||||
// Detect OS language changes while the app is running. Electron has no
|
||||
// dedicated event for this on any platform, so we poll on focus regain —
|
||||
// catches the common case where users switch System Settings → Language
|
||||
// and bring the app back. The renderer decides whether to act (it ignores
|
||||
// the signal when the user has an explicit Settings choice).
|
||||
mainWindow.on("focus", () => {
|
||||
const current = getSystemLocale();
|
||||
if (current === lastKnownSystemLocale) return;
|
||||
lastKnownSystemLocale = current;
|
||||
mainWindow?.webContents.send("locale:system-changed", current);
|
||||
});
|
||||
|
||||
mainWindow.webContents.setWindowOpenHandler((details) => {
|
||||
openExternalSafely(details.url);
|
||||
return { action: "deny" };
|
||||
@@ -224,25 +187,7 @@ if (!gotTheLock) {
|
||||
if (deepLinkUrl) handleDeepLink(deepLinkUrl);
|
||||
});
|
||||
|
||||
app.whenReady().then(async () => {
|
||||
const viteEnv = import.meta.env as ImportMetaEnv & {
|
||||
readonly VITE_API_URL?: string;
|
||||
readonly VITE_WS_URL?: string;
|
||||
readonly VITE_APP_URL?: string;
|
||||
};
|
||||
|
||||
runtimeConfigResult = await loadRuntimeConfig({
|
||||
isDev: is.dev,
|
||||
// electron-vite exposes VITE_* on import.meta.env for the main process;
|
||||
// keep dev URL overrides on the same source the renderer used before
|
||||
// runtime config moved endpoint resolution into main/preload.
|
||||
env: {
|
||||
apiUrl: viteEnv.VITE_API_URL,
|
||||
wsUrl: viteEnv.VITE_WS_URL,
|
||||
appUrl: viteEnv.VITE_APP_URL,
|
||||
},
|
||||
});
|
||||
|
||||
app.whenReady().then(() => {
|
||||
electronApp.setAppUserModelId(
|
||||
is.dev ? "ai.multica.desktop.dev" : "ai.multica.desktop",
|
||||
);
|
||||
@@ -278,13 +223,6 @@ if (!gotTheLock) {
|
||||
event.returnValue = { version: getAppVersion(), os };
|
||||
});
|
||||
|
||||
// Sync IPC: preload exposes the validated runtime config before renderer
|
||||
// boot. If desktop.json exists but is invalid, renderer receives the
|
||||
// blocking error and must not silently fall back to the cloud defaults.
|
||||
ipcMain.on("runtime-config:get", (event) => {
|
||||
event.returnValue = runtimeConfigResult;
|
||||
});
|
||||
|
||||
// IPC: toggle immersive mode — hides the macOS traffic lights so full-screen
|
||||
// modals (e.g. create-workspace) can place UI in the top-left corner
|
||||
// without fighting the native window controls' hit-test.
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
import { mkdtemp, writeFile } from "fs/promises";
|
||||
import { join } from "path";
|
||||
import { tmpdir } from "os";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { loadRuntimeConfig } from "./runtime-config-loader";
|
||||
|
||||
describe("loadRuntimeConfig", () => {
|
||||
it("uses dev env and ignores desktop.json during electron-vite dev", async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "multica-desktop-config-"));
|
||||
const configPath = join(dir, "desktop.json");
|
||||
await writeFile(
|
||||
configPath,
|
||||
JSON.stringify({ schemaVersion: 1, apiUrl: "https://prod.example.com" }),
|
||||
);
|
||||
|
||||
await expect(
|
||||
loadRuntimeConfig({
|
||||
isDev: true,
|
||||
configPath,
|
||||
env: {
|
||||
apiUrl: "http://localhost:8080",
|
||||
wsUrl: "ws://localhost:8080/ws",
|
||||
appUrl: "http://localhost:3000",
|
||||
},
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
ok: true,
|
||||
config: {
|
||||
schemaVersion: 1,
|
||||
apiUrl: "http://localhost:8080",
|
||||
wsUrl: "ws://localhost:8080/ws",
|
||||
appUrl: "http://localhost:3000",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("uses cloud defaults when packaged config is absent", async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "multica-desktop-config-"));
|
||||
await expect(
|
||||
loadRuntimeConfig({
|
||||
isDev: false,
|
||||
configPath: join(dir, "missing.json"),
|
||||
env: {},
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
ok: true,
|
||||
config: {
|
||||
schemaVersion: 1,
|
||||
apiUrl: "https://api.multica.ai",
|
||||
wsUrl: "wss://api.multica.ai/ws",
|
||||
appUrl: "https://multica.ai",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("parses a valid packaged desktop.json", async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "multica-desktop-config-"));
|
||||
const configPath = join(dir, "desktop.json");
|
||||
await writeFile(
|
||||
configPath,
|
||||
JSON.stringify({ schemaVersion: 1, apiUrl: "https://api.example.com" }),
|
||||
);
|
||||
|
||||
await expect(
|
||||
loadRuntimeConfig({ isDev: false, configPath, env: {} }),
|
||||
).resolves.toEqual({
|
||||
ok: true,
|
||||
config: {
|
||||
schemaVersion: 1,
|
||||
apiUrl: "https://api.example.com",
|
||||
wsUrl: "wss://api.example.com/ws",
|
||||
appUrl: "https://api.example.com",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("fails closed when packaged desktop.json is invalid", async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "multica-desktop-config-"));
|
||||
const configPath = join(dir, "desktop.json");
|
||||
await writeFile(configPath, "{");
|
||||
|
||||
const result = await loadRuntimeConfig({ isDev: false, configPath, env: {} });
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error.message).toContain(configPath);
|
||||
expect(result.error.message).toContain("Invalid desktop runtime config JSON");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,60 +0,0 @@
|
||||
import { app } from "electron";
|
||||
import { readFile } from "fs/promises";
|
||||
import { join } from "path";
|
||||
import {
|
||||
DEFAULT_RUNTIME_CONFIG,
|
||||
parseRuntimeConfig,
|
||||
runtimeConfigFromDevEnv,
|
||||
type RuntimeConfig,
|
||||
type RuntimeConfigEnv,
|
||||
type RuntimeConfigResult,
|
||||
} from "../shared/runtime-config";
|
||||
|
||||
export async function loadRuntimeConfig(options: {
|
||||
isDev: boolean;
|
||||
env: RuntimeConfigEnv;
|
||||
configPath?: string;
|
||||
}): Promise<RuntimeConfigResult> {
|
||||
if (options.isDev) {
|
||||
try {
|
||||
return { ok: true, config: runtimeConfigFromDevEnv(options.env) };
|
||||
} catch (err) {
|
||||
return { ok: false, error: { message: errorMessage(err) } };
|
||||
}
|
||||
}
|
||||
|
||||
const configPath = options.configPath ?? desktopConfigPath();
|
||||
try {
|
||||
const raw = await readFile(configPath, "utf-8");
|
||||
return { ok: true, config: parseRuntimeConfig(raw) };
|
||||
} catch (err) {
|
||||
if (isMissingFileError(err)) {
|
||||
return { ok: true, config: { ...DEFAULT_RUNTIME_CONFIG } };
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
message: `Invalid ${configPath}: ${errorMessage(err)}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function desktopConfigPath(): string {
|
||||
return join(app.getPath("home"), ".multica", "desktop.json");
|
||||
}
|
||||
|
||||
function isMissingFileError(err: unknown): boolean {
|
||||
return Boolean(
|
||||
err &&
|
||||
typeof err === "object" &&
|
||||
"code" in err &&
|
||||
(err as NodeJS.ErrnoException).code === "ENOENT",
|
||||
);
|
||||
}
|
||||
|
||||
function errorMessage(err: unknown): string {
|
||||
return err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
|
||||
export type { RuntimeConfig, RuntimeConfigResult };
|
||||
7
apps/desktop/src/preload/index.d.ts
vendored
7
apps/desktop/src/preload/index.d.ts
vendored
@@ -1,5 +1,4 @@
|
||||
import { ElectronAPI } from "@electron-toolkit/preload";
|
||||
import type { RuntimeConfigResult } from "../shared/runtime-config";
|
||||
|
||||
interface DesktopAPI {
|
||||
/** App version + normalized OS, captured synchronously at preload time. */
|
||||
@@ -7,12 +6,6 @@ interface DesktopAPI {
|
||||
version: string;
|
||||
os: "macos" | "windows" | "linux" | "unknown";
|
||||
};
|
||||
/** OS-preferred locale (BCP 47) injected by main via additionalArguments. */
|
||||
systemLocale: string;
|
||||
/** Subscribe to OS language changes detected after boot. Returns an unsubscribe function. */
|
||||
onSystemLocaleChanged: (callback: (locale: string) => void) => () => void;
|
||||
/** Validated runtime endpoint config, or a blocking config error. */
|
||||
runtimeConfig: RuntimeConfigResult;
|
||||
/** Listen for auth token delivered via deep link. Returns an unsubscribe function. */
|
||||
onAuthToken: (callback: (token: string) => void) => () => void;
|
||||
/** Listen for invitation IDs delivered via deep link. Returns an unsubscribe function. */
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { contextBridge, ipcRenderer } from "electron";
|
||||
import { electronAPI } from "@electron-toolkit/preload";
|
||||
import type { RuntimeConfigResult } from "../shared/runtime-config";
|
||||
|
||||
// Synchronously fetch app metadata from main at preload time so the renderer
|
||||
// can pass it into CoreProvider during the initial render — the alternative
|
||||
@@ -22,53 +21,12 @@ function fetchAppInfo(): { version: string; os: "macos" | "windows" | "linux" |
|
||||
return { version: "unknown", os };
|
||||
}
|
||||
|
||||
function fetchRuntimeConfig(): RuntimeConfigResult {
|
||||
try {
|
||||
const result = ipcRenderer.sendSync("runtime-config:get") as RuntimeConfigResult | undefined;
|
||||
if (result && typeof result === "object" && "ok" in result) return result;
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
};
|
||||
}
|
||||
return { ok: false, error: { message: "Runtime config unavailable" } };
|
||||
}
|
||||
|
||||
const appInfo = fetchAppInfo();
|
||||
const runtimeConfig = fetchRuntimeConfig();
|
||||
|
||||
// Read the OS-preferred locale that main injected via additionalArguments.
|
||||
// Zero IPC, zero blocking — process.argv is populated before preload runs.
|
||||
function fetchSystemLocale(): string {
|
||||
const arg = process.argv.find((a) => a.startsWith("--multica-locale="));
|
||||
return arg?.split("=")[1] ?? "en";
|
||||
}
|
||||
|
||||
const systemLocale = fetchSystemLocale();
|
||||
|
||||
const desktopAPI = {
|
||||
/** App version + normalized OS. Read once at preload time so the renderer
|
||||
* can use it synchronously when initializing the API client. */
|
||||
appInfo,
|
||||
/** OS-preferred locale (BCP 47), passed from main via additionalArguments.
|
||||
* Used by the renderer's LocaleAdapter as the system-preference signal. */
|
||||
systemLocale,
|
||||
/** Subscribe to OS language changes detected after boot. The renderer
|
||||
* decides whether to act (no-op when the user has an explicit Settings
|
||||
* choice). Returns an unsubscribe function. */
|
||||
onSystemLocaleChanged: (callback: (locale: string) => void) => {
|
||||
const handler = (_event: Electron.IpcRendererEvent, locale: string) =>
|
||||
callback(locale);
|
||||
ipcRenderer.on("locale:system-changed", handler);
|
||||
return () => {
|
||||
ipcRenderer.removeListener("locale:system-changed", handler);
|
||||
};
|
||||
},
|
||||
/** Validated runtime endpoint config, or a blocking config error. */
|
||||
runtimeConfig,
|
||||
/** Listen for auth token delivered via deep link */
|
||||
onAuthToken: (callback: (token: string) => void) => {
|
||||
const handler = (_event: Electron.IpcRendererEvent, token: string) =>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { CoreProvider } from "@multica/core/platform";
|
||||
import { pickLocale } from "@multica/core/i18n";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
import { workspaceKeys, workspaceListOptions } from "@multica/core/workspace/queries";
|
||||
import { api } from "@multica/core/api";
|
||||
@@ -16,8 +15,6 @@ import { UpdateNotification } from "./components/update-notification";
|
||||
import { useTabStore } from "./stores/tab-store";
|
||||
import { useWindowOverlayStore } from "./stores/window-overlay-store";
|
||||
import { useDaemonIPCBridge } from "./platform/daemon-ipc-bridge";
|
||||
import { createDesktopLocaleAdapter } from "./platform/i18n-adapter";
|
||||
import { RESOURCES } from "@multica/views/locales";
|
||||
|
||||
|
||||
function AppContent() {
|
||||
@@ -33,16 +30,11 @@ function AppContent() {
|
||||
// first render.
|
||||
const [bootstrapping, setBootstrapping] = useState(false);
|
||||
|
||||
const runtimeConfig = window.desktopAPI.runtimeConfig.ok
|
||||
? window.desktopAPI.runtimeConfig.config
|
||||
: null;
|
||||
|
||||
// Tell the main process which backend URL we talk to, so daemon-manager
|
||||
// can pick the matching CLI profile (server_url from ~/.multica config).
|
||||
useEffect(() => {
|
||||
if (!runtimeConfig) return;
|
||||
window.daemonAPI.setTargetApiUrl(runtimeConfig.apiUrl);
|
||||
}, [runtimeConfig]);
|
||||
window.daemonAPI.setTargetApiUrl(DAEMON_TARGET_API_URL);
|
||||
}, []);
|
||||
|
||||
// Listen for invite IDs delivered via deep link (multica://invite/<id>).
|
||||
// We open the overlay regardless of login state — if the user isn't logged
|
||||
@@ -234,21 +226,9 @@ function AppContent() {
|
||||
);
|
||||
}
|
||||
|
||||
function BlockingRuntimeConfigError({ message }: { message: string }) {
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center bg-background p-8 text-foreground">
|
||||
<div className="max-w-xl rounded-lg border bg-card p-6 shadow-sm">
|
||||
<h1 className="text-lg font-semibold">Desktop configuration error</h1>
|
||||
<p className="mt-3 text-sm text-muted-foreground">
|
||||
Multica Desktop could not load <code>~/.multica/desktop.json</code>. Fix or remove the file and restart the app.
|
||||
</p>
|
||||
<pre className="mt-4 whitespace-pre-wrap rounded-md bg-muted p-3 text-xs text-muted-foreground">
|
||||
{message}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// Backend the daemon should connect to — same URL the renderer talks to.
|
||||
const DAEMON_TARGET_API_URL =
|
||||
import.meta.env.VITE_API_URL || "http://localhost:8080";
|
||||
|
||||
// On logout, wipe desktop-only in-memory state and stop the daemon so that
|
||||
// a subsequent login as a different user never inherits the previous user's
|
||||
@@ -272,61 +252,22 @@ async function handleDaemonLogout() {
|
||||
|
||||
export default function App() {
|
||||
const { version, os } = window.desktopAPI.appInfo;
|
||||
const systemLocale = window.desktopAPI.systemLocale;
|
||||
const runtimeConfigResult = window.desktopAPI.runtimeConfig;
|
||||
// Stable identity reference so downstream effects (WS reconnect) don't
|
||||
// tear down on every parent render.
|
||||
const identity = useMemo(
|
||||
() => ({ platform: "desktop", version, os }),
|
||||
[version, os],
|
||||
);
|
||||
// Locale resolution happens once at app boot. Switching language goes
|
||||
// through window.location.reload() to avoid hydration mismatch.
|
||||
const localeAdapter = useMemo(
|
||||
() => createDesktopLocaleAdapter(systemLocale),
|
||||
[systemLocale],
|
||||
);
|
||||
const locale = useMemo(() => pickLocale(localeAdapter), [localeAdapter]);
|
||||
const resources = useMemo(
|
||||
() => ({ [locale]: RESOURCES[locale] }),
|
||||
[locale],
|
||||
);
|
||||
|
||||
// React to OS-level language changes detected by main on focus regain.
|
||||
// Only act when the user is following the system signal (no explicit
|
||||
// Settings choice) — otherwise their preference wins. Cross-device sync
|
||||
// for the explicit-choice case is handled inside CoreProvider.
|
||||
useEffect(() => {
|
||||
return window.desktopAPI.onSystemLocaleChanged((nextSystemLocale) => {
|
||||
if (localeAdapter.getUserChoice()) return;
|
||||
const next = pickLocale({
|
||||
...localeAdapter,
|
||||
getSystemPreferences: () =>
|
||||
nextSystemLocale ? [nextSystemLocale] : [],
|
||||
});
|
||||
if (next === locale) return;
|
||||
localeAdapter.persist(next);
|
||||
window.location.reload();
|
||||
});
|
||||
}, [localeAdapter, locale]);
|
||||
|
||||
return (
|
||||
<ThemeProvider>
|
||||
{runtimeConfigResult.ok ? (
|
||||
<CoreProvider
|
||||
apiBaseUrl={runtimeConfigResult.config.apiUrl}
|
||||
wsUrl={runtimeConfigResult.config.wsUrl}
|
||||
onLogout={handleDaemonLogout}
|
||||
identity={identity}
|
||||
locale={locale}
|
||||
resources={resources}
|
||||
localeAdapter={localeAdapter}
|
||||
>
|
||||
<AppContent />
|
||||
</CoreProvider>
|
||||
) : (
|
||||
<BlockingRuntimeConfigError message={runtimeConfigResult.error.message} />
|
||||
)}
|
||||
<CoreProvider
|
||||
apiBaseUrl={import.meta.env.VITE_API_URL || "http://localhost:8080"}
|
||||
wsUrl={import.meta.env.VITE_WS_URL || "ws://localhost:8080/ws"}
|
||||
onLogout={handleDaemonLogout}
|
||||
identity={identity}
|
||||
>
|
||||
<AppContent />
|
||||
</CoreProvider>
|
||||
<Toaster />
|
||||
<UpdateNotification />
|
||||
</ThemeProvider>
|
||||
|
||||
@@ -2,23 +2,14 @@ import { LoginPage } from "@multica/views/auth";
|
||||
import { DragStrip } from "@multica/views/platform";
|
||||
import { MulticaIcon } from "@multica/ui/components/common/multica-icon";
|
||||
|
||||
function requireRuntimeAppUrl(): string {
|
||||
const runtimeConfig = window.desktopAPI.runtimeConfig;
|
||||
if (!runtimeConfig.ok) {
|
||||
throw new Error(
|
||||
"Invariant violated: DesktopLoginPage rendered before App accepted runtime config",
|
||||
);
|
||||
}
|
||||
return runtimeConfig.config.appUrl;
|
||||
}
|
||||
const WEB_URL = import.meta.env.VITE_APP_URL || "http://localhost:3000";
|
||||
|
||||
export function DesktopLoginPage() {
|
||||
const webUrl = requireRuntimeAppUrl();
|
||||
const handleGoogleLogin = () => {
|
||||
// Open web login page in the default browser with platform=desktop flag.
|
||||
// The web callback will redirect back via multica:// deep link with the token.
|
||||
window.desktopAPI.openExternal(
|
||||
`${webUrl}/login?platform=desktop`,
|
||||
`${WEB_URL}/login?platform=desktop`,
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
import type { LocaleAdapter, SupportedLocale } from "@multica/core/i18n";
|
||||
|
||||
const STORAGE_KEY = "multica-locale";
|
||||
|
||||
// Desktop adapter:
|
||||
// - User choice: localStorage (set by Settings switcher).
|
||||
// - System preference: locale main injected via additionalArguments
|
||||
// (read from preload, exposed on window.desktopAPI.systemLocale).
|
||||
// - Persist: localStorage. The Settings switcher additionally PATCHes
|
||||
// /api/me when logged in so user.language follows the user across devices.
|
||||
export function createDesktopLocaleAdapter(systemLocale: string): LocaleAdapter {
|
||||
return {
|
||||
getUserChoice() {
|
||||
try {
|
||||
return window.localStorage.getItem(STORAGE_KEY);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
getSystemPreferences() {
|
||||
return systemLocale ? [systemLocale] : [];
|
||||
},
|
||||
persist(locale: SupportedLocale) {
|
||||
try {
|
||||
window.localStorage.setItem(STORAGE_KEY, locale);
|
||||
} catch {
|
||||
// Best-effort
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -15,15 +15,11 @@ import {
|
||||
} from "@/stores/tab-store";
|
||||
import { useWindowOverlayStore } from "@/stores/window-overlay-store";
|
||||
|
||||
function requireRuntimeAppUrl(scope: string): string {
|
||||
const runtimeConfig = window.desktopAPI.runtimeConfig;
|
||||
if (!runtimeConfig.ok) {
|
||||
throw new Error(
|
||||
`Invariant violated: ${scope} rendered before App accepted runtime config`,
|
||||
);
|
||||
}
|
||||
return runtimeConfig.config.appUrl;
|
||||
}
|
||||
// Public web app URL — injected at build time via .env.production. In dev
|
||||
// (no VITE_APP_URL set) falls back to the local web dev server so "Copy
|
||||
// link" in a dev build yields a URL that points at the running dev
|
||||
// frontend, not the prod host. Matches the fallback used in pages/login.tsx.
|
||||
const APP_URL = import.meta.env.VITE_APP_URL || "http://localhost:3000";
|
||||
|
||||
/**
|
||||
* Extract the leading workspace slug from a path, or null if the path isn't
|
||||
@@ -120,7 +116,6 @@ export function DesktopNavigationProvider({
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const appUrl = requireRuntimeAppUrl("DesktopNavigationProvider");
|
||||
// Primitive-only subscriptions so this component doesn't re-render on
|
||||
// unrelated store updates (e.g. an inactive tab's router tick). We
|
||||
// resolve the active router here only to subscribe once per tab switch.
|
||||
@@ -191,9 +186,9 @@ export function DesktopNavigationProvider({
|
||||
const tabId = store.openTab(path, title ?? path, icon);
|
||||
if (tabId) store.setActiveTab(tabId);
|
||||
},
|
||||
getShareableUrl: (path: string) => `${appUrl}${path}`,
|
||||
getShareableUrl: (path: string) => `${APP_URL}${path}`,
|
||||
}),
|
||||
[appUrl, location],
|
||||
[location],
|
||||
);
|
||||
|
||||
return <NavigationProvider value={adapter}>{children}</NavigationProvider>;
|
||||
@@ -216,7 +211,6 @@ export function TabNavigationProvider({
|
||||
router: DataRouter;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const appUrl = requireRuntimeAppUrl("TabNavigationProvider");
|
||||
const [location, setLocation] = useState(router.state.location);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -252,9 +246,9 @@ export function TabNavigationProvider({
|
||||
const tabId = store.openTab(path, title ?? path, icon);
|
||||
if (tabId) store.setActiveTab(tabId);
|
||||
},
|
||||
getShareableUrl: (path: string) => `${appUrl}${path}`,
|
||||
getShareableUrl: (path: string) => `${APP_URL}${path}`,
|
||||
}),
|
||||
[appUrl, router, location],
|
||||
[router, location],
|
||||
);
|
||||
|
||||
return <NavigationProvider value={adapter}>{children}</NavigationProvider>;
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
DEFAULT_RUNTIME_CONFIG,
|
||||
deriveWsUrl,
|
||||
parseRuntimeConfig,
|
||||
runtimeConfigFromDevEnv,
|
||||
} from "./runtime-config";
|
||||
|
||||
describe("runtime config", () => {
|
||||
it("uses cloud defaults without a desktop.json file", () => {
|
||||
expect(DEFAULT_RUNTIME_CONFIG).toEqual({
|
||||
schemaVersion: 1,
|
||||
apiUrl: "https://api.multica.ai",
|
||||
wsUrl: "wss://api.multica.ai/ws",
|
||||
appUrl: "https://multica.ai",
|
||||
});
|
||||
});
|
||||
|
||||
it("derives https/wss compatible URLs from apiUrl", () => {
|
||||
expect(
|
||||
parseRuntimeConfig(
|
||||
JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
apiUrl: "https://congvc-x99.taila6fa8a.ts.net:18443",
|
||||
}),
|
||||
),
|
||||
).toEqual({
|
||||
schemaVersion: 1,
|
||||
apiUrl: "https://congvc-x99.taila6fa8a.ts.net:18443",
|
||||
wsUrl: "wss://congvc-x99.taila6fa8a.ts.net:18443/ws",
|
||||
appUrl: "https://congvc-x99.taila6fa8a.ts.net:18443",
|
||||
});
|
||||
});
|
||||
|
||||
it("derives ws for http api URLs", () => {
|
||||
expect(deriveWsUrl("http://localhost:8080")).toBe("ws://localhost:8080/ws");
|
||||
});
|
||||
|
||||
it("accepts explicit appUrl and wsUrl", () => {
|
||||
expect(
|
||||
parseRuntimeConfig(
|
||||
JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
apiUrl: "https://api.example.com/",
|
||||
wsUrl: "wss://ws.example.com/socket/",
|
||||
appUrl: "https://app.example.com/",
|
||||
}),
|
||||
),
|
||||
).toEqual({
|
||||
schemaVersion: 1,
|
||||
apiUrl: "https://api.example.com",
|
||||
wsUrl: "wss://ws.example.com/socket",
|
||||
appUrl: "https://app.example.com",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects invalid JSON", () => {
|
||||
expect(() => parseRuntimeConfig("{")).toThrow(/Invalid desktop runtime config JSON/);
|
||||
});
|
||||
|
||||
it("rejects unsupported schema versions", () => {
|
||||
expect(() =>
|
||||
parseRuntimeConfig(JSON.stringify({ schemaVersion: 2, apiUrl: "https://api.example.com" })),
|
||||
).toThrow(/schemaVersion/);
|
||||
});
|
||||
|
||||
it("rejects non-http api schemes", () => {
|
||||
expect(() =>
|
||||
parseRuntimeConfig(JSON.stringify({ schemaVersion: 1, apiUrl: "file:///tmp/multica" })),
|
||||
).toThrow(/apiUrl must use http or https/);
|
||||
});
|
||||
|
||||
it("rejects non-ws websocket schemes", () => {
|
||||
expect(() =>
|
||||
parseRuntimeConfig(
|
||||
JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
apiUrl: "https://api.example.com",
|
||||
wsUrl: "https://api.example.com/ws",
|
||||
}),
|
||||
),
|
||||
).toThrow(/wsUrl must use ws or wss/);
|
||||
});
|
||||
|
||||
it("preserves electron-vite dev env precedence", () => {
|
||||
expect(
|
||||
runtimeConfigFromDevEnv({
|
||||
apiUrl: "http://dev-api.example.test:8080/",
|
||||
wsUrl: "ws://dev-api.example.test:8080/ws/",
|
||||
appUrl: "http://dev-app.example.test:3000/",
|
||||
}),
|
||||
).toEqual({
|
||||
schemaVersion: 1,
|
||||
apiUrl: "http://dev-api.example.test:8080",
|
||||
wsUrl: "ws://dev-api.example.test:8080/ws",
|
||||
appUrl: "http://dev-app.example.test:3000",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,157 +0,0 @@
|
||||
export interface RuntimeConfig {
|
||||
schemaVersion: 1;
|
||||
apiUrl: string;
|
||||
wsUrl: string;
|
||||
appUrl: string;
|
||||
}
|
||||
|
||||
export interface RuntimeConfigError {
|
||||
message: string;
|
||||
}
|
||||
|
||||
export type RuntimeConfigResult =
|
||||
| { ok: true; config: RuntimeConfig }
|
||||
| { ok: false; error: RuntimeConfigError };
|
||||
|
||||
export const DEFAULT_RUNTIME_CONFIG: RuntimeConfig = Object.freeze({
|
||||
schemaVersion: 1,
|
||||
apiUrl: "https://api.multica.ai",
|
||||
wsUrl: "wss://api.multica.ai/ws",
|
||||
appUrl: "https://multica.ai",
|
||||
});
|
||||
|
||||
const LOCAL_DEV_RUNTIME_CONFIG: RuntimeConfig = Object.freeze({
|
||||
schemaVersion: 1,
|
||||
apiUrl: "http://localhost:8080",
|
||||
wsUrl: "ws://localhost:8080/ws",
|
||||
appUrl: "http://localhost:3000",
|
||||
});
|
||||
|
||||
export interface RuntimeConfigEnv {
|
||||
apiUrl?: string;
|
||||
wsUrl?: string;
|
||||
appUrl?: string;
|
||||
}
|
||||
|
||||
export function runtimeConfigFromDevEnv(env: RuntimeConfigEnv): RuntimeConfig {
|
||||
const apiUrl = normalizeHttpUrl(
|
||||
env.apiUrl || LOCAL_DEV_RUNTIME_CONFIG.apiUrl,
|
||||
"VITE_API_URL",
|
||||
);
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
apiUrl,
|
||||
wsUrl: env.wsUrl
|
||||
? normalizeWsUrl(env.wsUrl, "VITE_WS_URL")
|
||||
: deriveWsUrl(apiUrl),
|
||||
appUrl: normalizeHttpUrl(
|
||||
env.appUrl || LOCAL_DEV_RUNTIME_CONFIG.appUrl,
|
||||
"VITE_APP_URL",
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseRuntimeConfig(raw: string): RuntimeConfig {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
`Invalid desktop runtime config JSON: ${err instanceof Error ? err.message : "parse failed"}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
throw new Error("Invalid desktop runtime config: expected a JSON object");
|
||||
}
|
||||
|
||||
const obj = parsed as Record<string, unknown>;
|
||||
if (obj.schemaVersion !== 1) {
|
||||
throw new Error("Unsupported desktop runtime config schemaVersion: expected 1");
|
||||
}
|
||||
|
||||
const apiUrl = requiredString(obj.apiUrl, "apiUrl");
|
||||
const appUrl = optionalString(obj.appUrl, "appUrl");
|
||||
const wsUrl = optionalString(obj.wsUrl, "wsUrl");
|
||||
|
||||
const normalizedApiUrl = normalizeHttpUrl(apiUrl, "apiUrl");
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
apiUrl: normalizedApiUrl,
|
||||
wsUrl: wsUrl ? normalizeWsUrl(wsUrl, "wsUrl") : deriveWsUrl(normalizedApiUrl),
|
||||
appUrl: appUrl ? normalizeHttpUrl(appUrl, "appUrl") : deriveAppUrl(normalizedApiUrl),
|
||||
};
|
||||
}
|
||||
|
||||
export function deriveWsUrl(apiUrl: string): string {
|
||||
const url = new URL(apiUrl);
|
||||
if (url.protocol === "https:") url.protocol = "wss:";
|
||||
else if (url.protocol === "http:") url.protocol = "ws:";
|
||||
else throw new Error("apiUrl must use http or https");
|
||||
url.pathname = joinPath(url.pathname, "/ws");
|
||||
url.search = "";
|
||||
url.hash = "";
|
||||
return trimTrailingSlash(url.toString());
|
||||
}
|
||||
|
||||
export function deriveAppUrl(apiUrl: string): string {
|
||||
const url = new URL(apiUrl);
|
||||
url.pathname = "";
|
||||
url.search = "";
|
||||
url.hash = "";
|
||||
return trimTrailingSlash(url.toString());
|
||||
}
|
||||
|
||||
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`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function optionalString(value: unknown, field: string): string | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
if (typeof value !== "string" || value.trim().length === 0) {
|
||||
throw new Error(`Invalid desktop runtime config: ${field} must be a non-empty string when set`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeHttpUrl(value: string, field: string): string {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(value.trim());
|
||||
} catch {
|
||||
throw new Error(`Invalid desktop runtime config: ${field} must be a valid URL`);
|
||||
}
|
||||
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
||||
throw new Error(`Invalid desktop runtime config: ${field} must use http or https`);
|
||||
}
|
||||
url.search = "";
|
||||
url.hash = "";
|
||||
return trimTrailingSlash(url.toString());
|
||||
}
|
||||
|
||||
function normalizeWsUrl(value: string, field: string): string {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(value.trim());
|
||||
} catch {
|
||||
throw new Error(`Invalid desktop runtime config: ${field} must be a valid URL`);
|
||||
}
|
||||
if (url.protocol !== "ws:" && url.protocol !== "wss:") {
|
||||
throw new Error(`Invalid desktop runtime config: ${field} must use ws or wss`);
|
||||
}
|
||||
url.search = "";
|
||||
url.hash = "";
|
||||
return trimTrailingSlash(url.toString());
|
||||
}
|
||||
|
||||
function joinPath(base: string, suffix: string): string {
|
||||
const normalizedBase = base.endsWith("/") ? base.slice(0, -1) : base;
|
||||
return `${normalizedBase}${suffix}`;
|
||||
}
|
||||
|
||||
function trimTrailingSlash(value: string): string {
|
||||
return value.replace(/\/+$/, "");
|
||||
}
|
||||
@@ -32,10 +32,9 @@ The command-line equivalent:
|
||||
|
||||
```bash
|
||||
multica issue assign MUL-42 --to alice
|
||||
multica issue assign MUL-42 --to-id 5fb87ac7-23b5-4a7a-81fa-ed295a54545d
|
||||
```
|
||||
|
||||
`--to` takes a member username or an agent name (fuzzy match). When names overlap — e.g. an agent `J` alongside `Cursor - J` — pass `--to-id <uuid>` instead, using the `user_id` (member) or `id` (agent) from `multica workspace members --output json` / `multica agent list --output json`. UUID matching is strict and unambiguous, which is what you want from scripts and from agents driving the CLI. `--to` and `--to-id` are mutually exclusive.
|
||||
`--to` takes a member username or an agent name. Giving agents memorable names makes this step smoother — if multiple agents share a name in the workspace, the first one listed wins, so rename before assigning.
|
||||
|
||||
Unassign:
|
||||
|
||||
|
||||
@@ -32,10 +32,9 @@ import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
```bash
|
||||
multica issue assign MUL-42 --to alice
|
||||
multica issue assign MUL-42 --to-id 5fb87ac7-23b5-4a7a-81fa-ed295a54545d
|
||||
```
|
||||
|
||||
`--to` 后跟成员用户名或智能体名字(模糊匹配)。如果工作区里有同名 / 互相含子串的成员或智能体(例如 agent `J` 旁边还有 `Cursor - J`),改用 `--to-id <uuid>`:UUID 来自 `multica workspace members --output json` 的 `user_id` 或 `multica agent list --output json` 的 `id`,是唯一精确的方式,特别适合脚本和驱动 CLI 的智能体。`--to` 和 `--to-id` 互斥。
|
||||
`--to` 后跟成员用户名或智能体名字。给智能体起个好记的名字会让这一步顺很多——工作区里重名的会按列出顺序选第一个,建议先改名再分配。
|
||||
|
||||
取消分配:
|
||||
|
||||
|
||||
@@ -221,11 +221,10 @@ multica workspace members <workspace-id>
|
||||
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 --limit 20 --output json
|
||||
```
|
||||
|
||||
Available filters: `--status`, `--priority`, `--assignee` / `--assignee-id`, `--project`, `--limit`. 在重名 workspace 下用 `--assignee-id <uuid>` 可以精确锁定一个成员或 agent。
|
||||
Available filters: `--status`, `--priority`, `--assignee`, `--project`, `--limit`.
|
||||
|
||||
### Get Issue
|
||||
|
||||
@@ -238,10 +237,9 @@ multica issue get <id> --output json
|
||||
|
||||
```bash
|
||||
multica issue create --title "Fix login bug" --description "..." --priority high --assignee "Lambda"
|
||||
multica issue create --title "Fix login bug" --assignee-id 5fb87ac7-23b5-4a7a-81fa-ed295a54545d
|
||||
```
|
||||
|
||||
Flags: `--title` (required), `--description`, `--status`, `--priority`, `--assignee` / `--assignee-id`, `--parent`, `--project`, `--due-date`. 脚本里如果已经拿到了 UUID(例如来自 `multica workspace members --output json`),传 `--assignee-id <uuid>`(与 `--assignee` 互斥)以精确锁定。
|
||||
Flags: `--title` (required), `--description`, `--status`, `--priority`, `--assignee`, `--parent`, `--project`, `--due-date`.
|
||||
|
||||
### Update Issue
|
||||
|
||||
@@ -253,12 +251,9 @@ multica issue update <id> --title "New title" --priority urgent
|
||||
|
||||
```bash
|
||||
multica issue assign <id> --to "Lambda"
|
||||
multica issue assign <id> --to-id 5fb87ac7-23b5-4a7a-81fa-ed295a54545d
|
||||
multica issue assign <id> --unassign
|
||||
```
|
||||
|
||||
`--to-id <uuid>`(与 `--to` 互斥)按 UUID 精确分配;适合重名 workspace 下脚本化场景。
|
||||
|
||||
### Change Status
|
||||
|
||||
```bash
|
||||
|
||||
@@ -99,7 +99,7 @@ Assign the issue to the agent you just created — click its avatar in the web U
|
||||
multica issue assign MUL-1 --to my-agent-name
|
||||
```
|
||||
|
||||
`--to` takes the **name** of an agent or member. A substring match works — if the agent is called `my-code-reviewer`, `reviewer` resolves to it. If your workspace has overlapping names, pass `--to-id <uuid>` instead (mutually exclusive with `--to`); look up the UUID via `multica agent list --output json` or `multica workspace members --output json`.
|
||||
`--to` takes the **name** of an agent or member. A substring match works — if the agent is called `my-code-reviewer`, `reviewer` resolves to it.
|
||||
|
||||
**What happens next from the daemon**:
|
||||
|
||||
|
||||
@@ -99,7 +99,7 @@ multica issue create --title "给 README 加一段 ASCII 架构图"
|
||||
multica issue assign MUL-1 --to my-agent-name
|
||||
```
|
||||
|
||||
`--to` 后面填智能体或成员的**名字**,子串就行——如果智能体叫 `my-code-reviewer`,填 `reviewer` 也能命中。如果工作区里名字相互重叠或冲突,改用 `--to-id <uuid>`(与 `--to` 互斥);UUID 来自 `multica agent list --output json` 或 `multica workspace members --output json`。
|
||||
`--to` 后面填智能体或成员的**名字**,子串就行——如果智能体叫 `my-code-reviewer`,填 `reviewer` 也能命中。
|
||||
|
||||
**接下来守护进程会**:
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ description: What Multica Desktop is, how it differs from the web app, and when
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
Multica Desktop is a native desktop app for macOS, Windows, and Linux. For the environment it is configured for, it talks to the same backend as the web app and shows the same data. By default Desktop uses Multica Cloud; self-hosted instances can be configured with a local runtime config file. Desktop also adds a few things the browser can't: **independent tab groups per [workspace](/workspaces)**, **automatic [daemon](/daemon-runtimes) startup**, and **one-click upgrades**.
|
||||
Multica Desktop is a native desktop app for macOS, Windows, and Linux. It talks to the same backend as the web app and shows the same data, but it adds a few things the browser can't: **independent tab groups per [workspace](/workspaces)**, **automatic [daemon](/daemon-runtimes) startup**, and **one-click upgrades**.
|
||||
|
||||
## Desktop or web — which to pick
|
||||
|
||||
@@ -66,34 +66,25 @@ Grab the installer for your platform from the [Multica downloads page](https://m
|
||||
|
||||
On first launch you'll need to sign in — the same email + verification code flow as the web app. Once you're in, Desktop syncs your workspace list automatically.
|
||||
|
||||
<Callout type="info">
|
||||
**Desktop defaults to Multica Cloud, but can be pointed at a self-hosted instance with a local config file.** There is still no in-app "connect to self-host" picker. Desktop reads `~/.multica/desktop.json` before the renderer starts; if the file is missing, it uses the Cloud defaults.
|
||||
<Callout type="warning">
|
||||
**Released Desktop builds are pinned to Multica Cloud.** The backend, websocket, and web URLs are baked in at build time (`VITE_API_URL` / `VITE_WS_URL` / `VITE_APP_URL`) — there is no in-app option to point Desktop at a self-hosted instance. To use Desktop against a self-hosted backend you need to build it yourself:
|
||||
|
||||
Minimal self-host config:
|
||||
|
||||
```json
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"apiUrl": "https://api.your-domain"
|
||||
}
|
||||
```bash
|
||||
git clone https://github.com/multica-ai/multica.git
|
||||
cd multica
|
||||
# Edit apps/desktop/.env.production:
|
||||
# VITE_API_URL=https://api.your-domain
|
||||
# VITE_WS_URL=wss://api.your-domain/ws
|
||||
# VITE_APP_URL=https://your-domain
|
||||
pnpm install
|
||||
pnpm --filter @multica/desktop package
|
||||
```
|
||||
|
||||
`apiUrl` is required and must use `http` or `https`. Desktop derives `wsUrl` as `/ws` on the same origin (`wss` for `https`, `ws` for `http`) and derives `appUrl` from the API origin. If your deployment uses different origins, set them explicitly:
|
||||
|
||||
```json
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"apiUrl": "https://api.your-domain",
|
||||
"wsUrl": "wss://api.your-domain/ws",
|
||||
"appUrl": "https://your-domain"
|
||||
}
|
||||
```
|
||||
|
||||
If `desktop.json` exists but is invalid, Desktop fails closed and shows a blocking config error instead of silently falling back to Cloud. For development builds, `VITE_API_URL` / `VITE_WS_URL` / `VITE_APP_URL` still take precedence during `electron-vite dev`. Runtime Desktop self-host configuration was implemented for [issue #1371](https://github.com/multica-ai/multica/issues/1371).
|
||||
If you'd rather not build from source, the supported self-hosted path is **web frontend + CLI** — see [Self-host quickstart](/self-host-quickstart). Runtime backend configuration in Desktop is tracked in [issue #1371](https://github.com/multica-ai/multica/issues/1371).
|
||||
</Callout>
|
||||
|
||||
## Next steps
|
||||
|
||||
- [Cloud Quickstart](/cloud-quickstart) — the Cloud onboarding flow for Desktop
|
||||
- [Self-Host Quickstart](/self-host-quickstart) — running your own backend and connecting with the CLI or Desktop runtime config
|
||||
- [Self-Host Quickstart](/self-host-quickstart) — running your own backend (Desktop against self-host requires a custom build, see the callout above)
|
||||
- [Daemon and runtimes](/daemon-runtimes) — how the daemon works (Desktop starts it for you, but the behavior is the same)
|
||||
|
||||
@@ -5,7 +5,7 @@ description: Multica Desktop 是什么、和 Web 有什么区别、什么时候
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
Multica Desktop 是原生桌面应用——macOS / Windows / Linux 三个平台。对它当前配置的环境来说,它和 Web 版连同一个后端、看到的数据完全一样。Desktop 默认使用 Multica Cloud;自部署实例可以通过本地运行时配置文件接入。它还给了几个 Web 做不到的能力:**[工作区](/workspaces) 独立的多标签页**、**自动启动 [守护进程](/daemon-runtimes)**、**一键升级**。
|
||||
Multica Desktop 是原生桌面应用——macOS / Windows / Linux 三个平台。它和 Web 版连同一个后端,看到的数据完全一样,但给了几个 Web 做不到的能力:**[工作区](/workspaces) 独立的多标签页**、**自动启动 [守护进程](/daemon-runtimes)**、**一键升级**。
|
||||
|
||||
## Desktop 和 Web 该用哪个
|
||||
|
||||
@@ -66,34 +66,25 @@ macOS 版本已经签名 + 公证,第一次打开不会有"未知开发者"的
|
||||
|
||||
安装后第一次打开需要登录——和 Web 版一样的 email + 验证码流程。登录成功后 Desktop 自动把工作区列表同步下来。
|
||||
|
||||
<Callout type="info">
|
||||
**Desktop 默认连接 Multica Cloud,但可以通过本地配置文件指向自部署实例。** 应用内仍然没有“连接自部署”的切换入口。Desktop 会在 renderer 启动前读取 `~/.multica/desktop.json`;如果这个文件不存在,就使用 Cloud 默认值。
|
||||
<Callout type="warning">
|
||||
**发布版的 Desktop 是锁死连 Multica Cloud 的**。后端 / WebSocket / Web 前端 URL(`VITE_API_URL` / `VITE_WS_URL` / `VITE_APP_URL`)在构建时就写死了,应用内**没有切换后端的入口**。要让 Desktop 连自部署后端,需要你自己从源码 build:
|
||||
|
||||
最小自部署配置:
|
||||
|
||||
```json
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"apiUrl": "https://api.your-domain"
|
||||
}
|
||||
```bash
|
||||
git clone https://github.com/multica-ai/multica.git
|
||||
cd multica
|
||||
# 编辑 apps/desktop/.env.production:
|
||||
# VITE_API_URL=https://api.your-domain
|
||||
# VITE_WS_URL=wss://api.your-domain/ws
|
||||
# VITE_APP_URL=https://your-domain
|
||||
pnpm install
|
||||
pnpm --filter @multica/desktop package
|
||||
```
|
||||
|
||||
`apiUrl` 是必填项,必须使用 `http` 或 `https`。Desktop 会自动从它推导 `wsUrl`(同源 `/ws`,`https` 对应 `wss`,`http` 对应 `ws`)和 `appUrl`(API 的同源地址)。如果你的部署使用不同域名,可以显式设置:
|
||||
|
||||
```json
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"apiUrl": "https://api.your-domain",
|
||||
"wsUrl": "wss://api.your-domain/ws",
|
||||
"appUrl": "https://your-domain"
|
||||
}
|
||||
```
|
||||
|
||||
如果 `desktop.json` 存在但内容无效,Desktop 会 fail closed,显示阻塞式配置错误,而不是悄悄回退到 Cloud。开发构建里,`electron-vite dev` 仍然优先使用 `VITE_API_URL` / `VITE_WS_URL` / `VITE_APP_URL`。Desktop 运行时自部署配置能力对应 [issue #1371](https://github.com/multica-ai/multica/issues/1371)。
|
||||
不想自己 build 的话,自部署的官方路径是 **Web 前端 + CLI**——见 [自部署快速上手](/self-host-quickstart)。Desktop 运行时切换后端的能力跟踪在 [issue #1371](https://github.com/multica-ai/multica/issues/1371)。
|
||||
</Callout>
|
||||
|
||||
## 下一步
|
||||
|
||||
- [Cloud Quickstart](/cloud-quickstart) —— Desktop 版的 Cloud 接入流程
|
||||
- [Self-Host Quickstart](/self-host-quickstart) —— 自部署后端,并通过 CLI 或 Desktop 运行时配置连接
|
||||
- [Self-Host Quickstart](/self-host-quickstart) —— 自部署后端(Desktop 连自部署需要自行构建,见上方提示)
|
||||
- [守护进程与运行时](/daemon-runtimes) —— 守护进程机制(Desktop 自动起它,但行为一样)
|
||||
|
||||
@@ -1,292 +0,0 @@
|
||||
---
|
||||
title: Conventions
|
||||
description: Single source of truth for code naming, i18n translation glossary, and Chinese voice guide.
|
||||
---
|
||||
|
||||
This page is the single source of truth for code naming, the i18n translation glossary, and the Chinese voice guide. Anything that used to live in `packages/views/locales/glossary.md` or in scattered comments now lives here.
|
||||
|
||||
If you write Multica code, change a translation, or write Chinese product copy, this is the page to reference.
|
||||
|
||||
---
|
||||
|
||||
## 1. Code naming
|
||||
|
||||
### Routes
|
||||
|
||||
Pre-workspace routes (the routes that exist before the user is in a workspace) MUST use either a single word or the `/{noun}/{verb}` pattern.
|
||||
|
||||
- ✅ `/login`, `/inbox`, `/workspaces/new`
|
||||
- ❌ `/new-workspace`, `/create-team`, `/accept-invite`
|
||||
|
||||
Hyphenated word groups at the root collide with user-chosen workspace slugs and force endless reserved-slug audits. Reserving the noun (`workspaces`) automatically protects the entire `/workspaces/*` subtree.
|
||||
|
||||
### Workspace-scoped routes
|
||||
|
||||
Always live under `/{slug}/{section}` — `/{slug}/issues`, `/{slug}/agents`, `/{slug}/settings`. Never duplicate workspace routing logic; use `useNavigation().push()` from shared code, never framework-specific link APIs.
|
||||
|
||||
### Packages and modules
|
||||
|
||||
The monorepo enforces strict package boundaries:
|
||||
|
||||
| Package | May depend on | Must NOT depend on |
|
||||
| --- | --- | --- |
|
||||
| `packages/core` | nothing app-specific | `react-dom`, `localStorage`, `process.env`, `next/*`, UI libraries |
|
||||
| `packages/ui` | nothing | `@multica/core`, business logic |
|
||||
| `packages/views` | `core/`, `ui/` | `next/*`, `react-router-dom`, stores |
|
||||
| `apps/web/platform/` | `next/*` | other apps |
|
||||
| `apps/desktop/.../platform/` | `react-router-dom`, electron | other apps |
|
||||
|
||||
If logic appears in both apps, it MUST be extracted to a shared package. There are no exceptions for "small" duplication.
|
||||
|
||||
### Files and components
|
||||
|
||||
- Files: `kebab-case.tsx` / `kebab-case.ts` (e.g. `agent-row-actions.tsx`)
|
||||
- Components: `PascalCase` (e.g. `AgentRowActions`)
|
||||
- Hooks: `useCamelCase` (e.g. `useWorkspaceId`)
|
||||
- Tests: colocated as `<file>.test.ts(x)`
|
||||
- Stores (Zustand): `<feature>-store.ts`, exported as `use<Feature>Store`
|
||||
|
||||
### Database (Go + sqlc)
|
||||
|
||||
- Tables: `snake_case` singular (`user`, `workspace`, `agent_runtime`)
|
||||
- Columns: `snake_case` (`workspace_id`, `created_at`, `last_seen_at`)
|
||||
- Foreign keys: `<table>_id`
|
||||
- Booleans: `is_<state>` or `<state>_at` (timestamp form preferred for state changes)
|
||||
- Migration files: `NNN_descriptive_name.up.sql` + `.down.sql` — always provide both directions
|
||||
|
||||
### Go
|
||||
|
||||
- Standard `gofmt` + `go vet`. No exceptions.
|
||||
- Handler files mirror domain: `agent.go`, `auth.go`, `runtime.go`
|
||||
- Tests: `<file>_test.go` colocated
|
||||
- For UUID parsing in handlers, follow the rule in the root `CLAUDE.md` — `parseUUIDOrBadRequest` for boundary input, `parseUUID` (panicking) for trusted round-trips, never `util.ParseUUID` directly without checking the error.
|
||||
|
||||
### TypeScript
|
||||
|
||||
- API responses on the wire are `snake_case`; the api client converts to `camelCase` at the boundary. Inside TS code, **always camelCase**.
|
||||
- Types: `PascalCase` (`Issue`, `AgentRuntime`); never `IPrefix`, never `_t` suffix.
|
||||
- Enums: prefer string literal unions; reserve `enum` for runtime-iterable cases.
|
||||
- TanStack Query keys: factory functions in `<feature>/queries.ts`, e.g. `issueKeys.detail(id)`.
|
||||
|
||||
### Issue keys
|
||||
|
||||
Every issue has a human-readable key like `MUL-123`: workspace `issue_prefix` (3 letters, uppercase) + sequence number. The prefix is set at workspace creation and is never changed afterward.
|
||||
|
||||
### Comments in code
|
||||
|
||||
English only. The repo enforces this for both Go and TypeScript. If you find a Chinese comment in code, it's a bug — replace it.
|
||||
|
||||
### Commit messages
|
||||
|
||||
Conventional format: `feat(scope)`, `fix(scope)`, `refactor(scope)`, `docs`, `test(scope)`, `chore(scope)`. Atomic commits grouped by intent.
|
||||
|
||||
---
|
||||
|
||||
## 2. i18n translation glossary
|
||||
|
||||
This is the **mandatory** glossary for every translation PR. It used to live at `packages/views/locales/glossary.md`; that file is now a stub pointing here.
|
||||
|
||||
### The core distinction: entity vs concept
|
||||
|
||||
Multica's product nouns split into two categories:
|
||||
|
||||
- **Entity** — has a URL, a database row, an API type. In Chinese text, render as **lowercase English** so it visually reads like a type name and signals "this is a Multica system entity".
|
||||
- **Concept** — generic noun, not a database entity. **Translate fully** so Chinese users don't see jagged English embedded in flowing text.
|
||||
|
||||
This rule is aligned with `apps/docs/content/docs/*.zh.mdx` — the docs are the de facto Chinese voice standard and have been battle-tested across 20+ pages.
|
||||
|
||||
### Don't translate — entities (lowercase English)
|
||||
|
||||
| Term | Render in Chinese | Example |
|
||||
| --- | --- | --- |
|
||||
| Issue | `issue` (lowercase) | "把 issue 分配给智能体"、"创建子 issue" |
|
||||
| Skill | `skill` (lowercase) | "为智能体注入 skill" |
|
||||
| Task | `task` (lowercase) | "排队中的 task" |
|
||||
|
||||
**Why `issue` / `skill` / `task` stay English while `project` / `autopilot` are translated**:
|
||||
|
||||
- **`issue` / `task`**: dev teams talk in English. The Chinese candidates ("任务" — too vague, almost synonymous with "工作"; "工单" — IT ticket connotation; "议题" — GitHub-style but doesn't match the product feel) all read worse than `issue`.
|
||||
- **`skill`**: Multica-specific concept with no established Chinese term.
|
||||
- **`project` → "项目"**: settled mainstream Chinese word. Feishu / Tower / Teambition / PingCode / GitHub Projects — every Chinese product translates it. No product keeps `project` in Chinese context.
|
||||
- **`autopilot` → "自动化"**: in Chinese, "autopilot" associates with Tesla's "自动驾驶" and doesn't match what the feature does (run tasks on a schedule). Notion and Feishu both use "自动化"; that's the industry consensus.
|
||||
|
||||
### Don't translate — brands and acronyms
|
||||
|
||||
| Category | Terms |
|
||||
| --- | --- |
|
||||
| Brands | **Multica**, GitHub, Slack, Google, Anthropic, OpenAI, Claude, Codex, Cursor, Linear, Jira |
|
||||
| Acronyms | API, CLI, URL, SDK, OAuth, JWT, SSO, WebSocket, HTTP, JSON, YAML, SQL |
|
||||
|
||||
### Translate fully — concepts
|
||||
|
||||
| English | Chinese |
|
||||
| --- | --- |
|
||||
| Workspace | **工作区** |
|
||||
| Agent | **智能体** |
|
||||
| Project | **项目** |
|
||||
| Autopilot | **自动化** |
|
||||
| Daemon | **守护进程** |
|
||||
| Runtime | **运行时** |
|
||||
| Inbox | **收件箱** |
|
||||
| Comment | **评论** |
|
||||
| Reply | **回复** |
|
||||
| Notifications | **通知** |
|
||||
| Member | **成员** |
|
||||
| Label | **标签** |
|
||||
| Settings | **设置** |
|
||||
| Onboarding | **上手引导** |
|
||||
|
||||
### Translate fully — generic UI words
|
||||
|
||||
| English | Chinese |
|
||||
| --- | --- |
|
||||
| Invite / Invitation | 邀请 |
|
||||
| Search | 搜索 |
|
||||
| Email | 邮箱 (label) / 邮件 (action) |
|
||||
| Password | 密码 |
|
||||
| Sign in / Log in | 登录 |
|
||||
| Sign up | 注册 |
|
||||
| Sign out / Log out | 退出登录 |
|
||||
| Save / Cancel / Delete | 保存 / 取消 / 删除 |
|
||||
| Confirm / Continue / Back | 确认 / 继续 / 返回 |
|
||||
| Edit / New / Create / Add | 编辑 / 新建 / 创建 / 添加 |
|
||||
| Remove / Send / Open / Close | 移除 / 发送 / 打开 / 关闭 |
|
||||
| Done / Loading... | 完成 / 加载中... |
|
||||
| Profile / Account / Appearance | 个人资料 / 账号 / 外观 |
|
||||
| Theme / Language | 主题 / 语言 |
|
||||
| Light / Dark / System | 浅色 / 深色 / 跟随系统 |
|
||||
| Active / Archived | 活跃 (or 启用) / 已归档 |
|
||||
| Status / Priority | 状态 / 优先级 |
|
||||
| Assignee / Reporter | 负责人 / 报告人 |
|
||||
| Description / Title | 描述 / 标题 |
|
||||
| Date / Time | 日期 / 时间 |
|
||||
| Today / Yesterday / Tomorrow | 今天 / 昨天 / 明天 |
|
||||
| Empty / Failed / Success | 空 / 失败 / 成功 |
|
||||
| Error / Warning | 错误 / 警告 |
|
||||
|
||||
### Roles and status enums (lowercase English, not translated)
|
||||
|
||||
These are schema-level identifiers; render as lowercase English even in Chinese context.
|
||||
|
||||
- Roles: `owner` / `admin` / `member`
|
||||
- Issue status: `backlog` / `todo` / `in_progress` / `in_review` / `done` / `blocked` / `cancelled`
|
||||
|
||||
In UI, surface them in English (optionally `code-style` wrapped):
|
||||
|
||||
- "你需要 owner 权限"
|
||||
- "已切换到 in_progress"
|
||||
|
||||
### Word combination rules
|
||||
|
||||
Always put **a single space** between an English word (entity / brand / acronym) and surrounding Chinese:
|
||||
|
||||
- "Create new issue" → "新建 issue"
|
||||
- "Assign to agent" → "分配给智能体"
|
||||
- "Configure runtime" → "配置运行时"
|
||||
- "Stop daemon" → "停止守护进程"
|
||||
|
||||
### Plurals and counts
|
||||
|
||||
i18next uses `_one` / `_other`; Chinese has no grammatical number, only fill `_other`.
|
||||
|
||||
```json
|
||||
// en/issues.json
|
||||
{
|
||||
"issue_count_one": "{{count}} issue",
|
||||
"issue_count_other": "{{count}} issues"
|
||||
}
|
||||
|
||||
// zh-Hans/issues.json
|
||||
{
|
||||
"issue_count_other": "{{count}} 个 issue"
|
||||
}
|
||||
```
|
||||
|
||||
Common count formats:
|
||||
|
||||
- `{{count}} issues` → `{{count}} 个 issue`
|
||||
- `{{count}} agents` → `{{count}} 个智能体`
|
||||
- `{{count}} workspaces` → `{{count}} 个工作区`
|
||||
- `{{count}} comments` → `{{count}} 条评论`
|
||||
- `{{count}} members` → `{{count}} 位成员`
|
||||
- `{{count}} skills` → `{{count}} 个 skill`
|
||||
|
||||
### Interpolation
|
||||
|
||||
Use `{{var}}`. Chinese translations may reorder for natural sentence flow.
|
||||
|
||||
```json
|
||||
// en
|
||||
{ "welcome_message": "Welcome back, {{name}}!" }
|
||||
|
||||
// zh-Hans
|
||||
{ "welcome_message": "欢迎回来,{{name}}!" }
|
||||
```
|
||||
|
||||
### Translation key naming
|
||||
|
||||
Three-level nesting: `feature.component.action`.
|
||||
|
||||
```json
|
||||
{
|
||||
"feature_or_component": {
|
||||
"subcomponent_or_section": {
|
||||
"action_or_label": "..."
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Examples:
|
||||
|
||||
- `issues.toolbar.batch_update_success`
|
||||
- `issues.detail.comment_form.placeholder`
|
||||
- `inbox.empty.title`
|
||||
- `settings.preferences.language.title`
|
||||
|
||||
### Web-only / desktop-only copy
|
||||
|
||||
- Shared copy: top level of the namespace JSON
|
||||
- Web-only: `web` section
|
||||
- Desktop-only: `desktop` section
|
||||
|
||||
See `auth.json` for the canonical example (the `web` section contains `prefer_desktop` / `desktop_handoff.*`).
|
||||
|
||||
---
|
||||
|
||||
## 3. Chinese voice and style
|
||||
|
||||
### Punctuation
|
||||
|
||||
- Full-width punctuation in Chinese: `,。:;!?`
|
||||
- Quotes: straight double quotes `"..."` to match the English source. Do not use `「」` or curly quotes.
|
||||
- Ellipsis: three dots `...` not the single character `…`. Match the English source.
|
||||
- Mixed Chinese-English: a single space on each side of the English word (see Word combination rules).
|
||||
|
||||
### Style principles
|
||||
|
||||
- **Concise and direct.** Avoid translation-ese: "对于 X 来说"、"作为 X"、"我们的"。
|
||||
- **Error messages**: gentle but clear. "无法保存修改" beats "保存修改失败了!".
|
||||
- **Buttons**: verb first, 2–4 characters. "取消"、"保存修改"、"立即同步".
|
||||
- **Tooltips**: full short sentence. "复制链接到剪贴板".
|
||||
- **Placeholders**: example-style. "输入 issue 标题...".
|
||||
|
||||
### Where to look when in doubt
|
||||
|
||||
When the glossary doesn't cover a term, look at:
|
||||
|
||||
1. `apps/docs/content/docs/*.zh.mdx` — the de facto Chinese voice standard, 20+ pages of consistent translation
|
||||
2. `packages/views/locales/zh-Hans/auth.json` and `editor.json` — JSON structure + selector API patterns
|
||||
3. `packages/views/auth/login-page.tsx` — component-level selector API call site
|
||||
4. `packages/views/settings/components/preferences-tab.tsx` — language switcher reference
|
||||
|
||||
---
|
||||
|
||||
## Updating this page
|
||||
|
||||
If you change a rule here, also:
|
||||
|
||||
1. Apply it in the relevant locale JSONs / CLAUDE.md / docs page
|
||||
2. Note the change in the PR description so reviewers know to look for downstream sweep
|
||||
|
||||
This page is the contract; nothing else overrides it.
|
||||
@@ -1,292 +0,0 @@
|
||||
---
|
||||
title: 规范
|
||||
description: 代码命名规范、i18n 翻译术语表、中文风格指南的唯一权威来源。
|
||||
---
|
||||
|
||||
本页是代码命名规范、i18n 翻译术语表、中文风格指南的唯一权威来源。原本散落在 `packages/views/locales/glossary.md` 和各处注释里的规则现在都收拢到这里。
|
||||
|
||||
写 Multica 代码、改翻译、写中文产品文案,都从这一页查。
|
||||
|
||||
---
|
||||
|
||||
## 1. 代码命名
|
||||
|
||||
### 路由
|
||||
|
||||
工作区前置路由(用户进入工作区之前能访问的路由)必须用单个单词,或者 `/{noun}/{verb}` 格式。
|
||||
|
||||
- ✅ `/login`、`/inbox`、`/workspaces/new`
|
||||
- ❌ `/new-workspace`、`/create-team`、`/accept-invite`
|
||||
|
||||
根目录的连字符词组会跟用户自选 workspace slug 冲突,逼着团队不停审保留字列表。把名词(`workspaces`)保留下来,整个 `/workspaces/*` 子树自动受保护。
|
||||
|
||||
### 工作区路由
|
||||
|
||||
永远用 `/{slug}/{section}` —— `/{slug}/issues`、`/{slug}/agents`、`/{slug}/settings`。共享代码不要复制路由逻辑,统一走 `useNavigation().push()`,不要直接用框架的 link API。
|
||||
|
||||
### 包与模块
|
||||
|
||||
monorepo 的包边界是硬约束:
|
||||
|
||||
| 包 | 可依赖 | 不能依赖 |
|
||||
| --- | --- | --- |
|
||||
| `packages/core` | 仅平台无关基础库 | `react-dom`、`localStorage`、`process.env`、`next/*`、UI 库 |
|
||||
| `packages/ui` | 无业务依赖 | `@multica/core`、业务逻辑 |
|
||||
| `packages/views` | `core/`、`ui/` | `next/*`、`react-router-dom`、stores |
|
||||
| `apps/web/platform/` | `next/*` | 其他 app |
|
||||
| `apps/desktop/.../platform/` | `react-router-dom`、electron | 其他 app |
|
||||
|
||||
两个 app 都有的逻辑,**必须**抽到共享包。"小段重复"也不算例外。
|
||||
|
||||
### 文件与组件
|
||||
|
||||
- 文件名:`kebab-case.tsx` / `kebab-case.ts`(如 `agent-row-actions.tsx`)
|
||||
- 组件:`PascalCase`(如 `AgentRowActions`)
|
||||
- Hook:`useCamelCase`(如 `useWorkspaceId`)
|
||||
- 测试:与源文件同目录,命名 `<file>.test.ts(x)`
|
||||
- Zustand store:`<feature>-store.ts`,导出名 `use<Feature>Store`
|
||||
|
||||
### 数据库(Go + sqlc)
|
||||
|
||||
- 表名:`snake_case` 单数(`user`、`workspace`、`agent_runtime`)
|
||||
- 字段:`snake_case`(`workspace_id`、`created_at`、`last_seen_at`)
|
||||
- 外键:`<table>_id`
|
||||
- 布尔:`is_<state>` 或者 `<state>_at`(状态变化优先用时间戳形式)
|
||||
- 迁移文件:`NNN_descriptive_name.up.sql` + `.down.sql`,**永远写双向**
|
||||
|
||||
### Go
|
||||
|
||||
- 标准 `gofmt` + `go vet`,无例外
|
||||
- Handler 文件按域命名:`agent.go`、`auth.go`、`runtime.go`
|
||||
- 测试:`<file>_test.go` 同目录
|
||||
- handler 里 UUID 解析遵守根 `CLAUDE.md` 的规则:边界输入用 `parseUUIDOrBadRequest`,可信回环用 `parseUUID`(panic 版),永远不要直接用 `util.ParseUUID` 不查 error
|
||||
|
||||
### TypeScript
|
||||
|
||||
- 网络上 API 响应是 `snake_case`,api client 在边界处转成 `camelCase`。**TS 代码内部一律 camelCase**
|
||||
- 类型:`PascalCase`(`Issue`、`AgentRuntime`),不加 `IPrefix`,不加 `_t` 后缀
|
||||
- 枚举:优先用 string literal union,需要 runtime 迭代时才用 `enum`
|
||||
- TanStack Query key:用 `<feature>/queries.ts` 里的工厂函数,例如 `issueKeys.detail(id)`
|
||||
|
||||
### Issue 编号
|
||||
|
||||
每个 issue 有人类可读的编号,比如 `MUL-123`:工作区 `issue_prefix`(3 个大写字母)+ 流水号。前缀在工作区创建时定,之后不可改。
|
||||
|
||||
### 代码注释
|
||||
|
||||
**只允许英文**。Go 和 TypeScript 都强制。如果在代码里看到中文注释,那就是 bug,替换掉。
|
||||
|
||||
### Commit message
|
||||
|
||||
Conventional 格式:`feat(scope)`、`fix(scope)`、`refactor(scope)`、`docs`、`test(scope)`、`chore(scope)`。按意图原子化分组。
|
||||
|
||||
---
|
||||
|
||||
## 2. i18n 翻译术语表
|
||||
|
||||
这是每个翻译 PR 都必须遵守的术语表。原本在 `packages/views/locales/glossary.md`,那个文件现在是个 stub,指向这一页。
|
||||
|
||||
### 核心区分:实体 vs 概念
|
||||
|
||||
Multica 的产品名词分两类:
|
||||
|
||||
- **实体(typed entity)** —— 有 URL、有数据库 row、是 API 响应里某种 type 的东西。中文里**用小写英文**呈现,视觉上像类型名,告诉读者"这是 Multica 系统里的特定实体"。
|
||||
- **概念(concept)** —— 不是数据库实体的普通名词。**完整翻译成中文**,CN 用户看不到生硬的英文。
|
||||
|
||||
这套规则与 `apps/docs/content/docs/*.zh.mdx` 完全对齐 —— docs 是已经实战 20+ 篇的 CN voice 标准。
|
||||
|
||||
### 不翻 —— 实体(小写英文)
|
||||
|
||||
| 词 | 中文中的写法 | 例 |
|
||||
| --- | --- | --- |
|
||||
| Issue | `issue`(小写) | "把 issue 分配给智能体"、"创建子 issue" |
|
||||
| Skill | `skill`(小写) | "为智能体注入 skill" |
|
||||
| Task | `task`(小写) | "排队中的 task" |
|
||||
|
||||
**为什么 `issue` / `skill` / `task` 不翻而 `project` / `autopilot` 翻**:
|
||||
|
||||
- **`issue` / `task`**:dev 团队习惯说英文,"任务"在中文里和"工作"几乎同义太空泛,"工单"是 IT 工单语义,"议题"是 GitHub 风格但用户场景不匹配。三个候选都不如 `issue` 准确。
|
||||
- **`skill`**:Multica 特有概念,没有公认中文译法。
|
||||
- **`project` 翻成"项目"**:这是中文里早就稳定的日常词。飞书 / Tower / Teambition / PingCode / GitHub Projects 中文版 0 例外都翻译成"项目",没有产品保留 `project`。
|
||||
- **`autopilot` 翻成"自动化"**:autopilot 在中文里联想到特斯拉的"自动驾驶",跟产品功能(按周期跑 task)对应不上。Notion / 飞书都用"自动化",是行业共识。
|
||||
|
||||
### 完整翻译 —— 概念词
|
||||
|
||||
| 英 | 中 |
|
||||
| --- | --- |
|
||||
| Workspace | **工作区** |
|
||||
| Agent | **智能体** |
|
||||
| Project | **项目** |
|
||||
| Autopilot | **自动化** |
|
||||
| Daemon | **守护进程** |
|
||||
| Runtime | **运行时** |
|
||||
| Inbox | **收件箱** |
|
||||
| Comment | **评论** |
|
||||
| Reply | **回复** |
|
||||
| Notifications | **通知** |
|
||||
| Member | **成员** |
|
||||
| Label | **标签** |
|
||||
| Settings | **设置** |
|
||||
| Onboarding | **上手引导** |
|
||||
|
||||
### 不翻 —— 品牌名 + 通用缩写
|
||||
|
||||
| 类别 | 词 |
|
||||
| --- | --- |
|
||||
| 品牌 | **Multica**、GitHub、Slack、Google、Anthropic、OpenAI、Claude、Codex、Cursor、Linear、Jira |
|
||||
| 缩写 | API、CLI、URL、SDK、OAuth、JWT、SSO、WebSocket、HTTP、JSON、YAML、SQL |
|
||||
|
||||
### 完整翻译 —— 通用 UI 词
|
||||
|
||||
| 英 | 中 |
|
||||
| --- | --- |
|
||||
| Invite / Invitation | 邀请 |
|
||||
| Search | 搜索 |
|
||||
| Email | 邮箱(label)/ 邮件(action) |
|
||||
| Password | 密码 |
|
||||
| Sign in / Log in | 登录 |
|
||||
| Sign up | 注册 |
|
||||
| Sign out / Log out | 退出登录 |
|
||||
| Save / Cancel / Delete | 保存 / 取消 / 删除 |
|
||||
| Confirm / Continue / Back | 确认 / 继续 / 返回 |
|
||||
| Edit / New / Create / Add | 编辑 / 新建 / 创建 / 添加 |
|
||||
| Remove / Send / Open / Close | 移除 / 发送 / 打开 / 关闭 |
|
||||
| Done / Loading... | 完成 / 加载中... |
|
||||
| Profile / Account / Appearance | 个人资料 / 账号 / 外观 |
|
||||
| Theme / Language | 主题 / 语言 |
|
||||
| Light / Dark / System | 浅色 / 深色 / 跟随系统 |
|
||||
| Active / Archived | 活跃(或 启用)/ 已归档 |
|
||||
| Status / Priority | 状态 / 优先级 |
|
||||
| Assignee / Reporter | 负责人 / 报告人 |
|
||||
| Description / Title | 描述 / 标题 |
|
||||
| Date / Time | 日期 / 时间 |
|
||||
| Today / Yesterday / Tomorrow | 今天 / 昨天 / 明天 |
|
||||
| Empty / Failed / Success | 空 / 失败 / 成功 |
|
||||
| Error / Warning | 错误 / 警告 |
|
||||
|
||||
### 角色名 + 状态名(小写英文,不翻)
|
||||
|
||||
这些是 schema-level 标识符,中文环境也保持小写英文:
|
||||
|
||||
- 角色:`owner` / `admin` / `member`
|
||||
- Issue 状态:`backlog` / `todo` / `in_progress` / `in_review` / `done` / `blocked` / `cancelled`
|
||||
|
||||
UI 里展示这些值时保持英文(必要时用 code-style 包起来):
|
||||
|
||||
- "你需要 owner 权限"
|
||||
- "已切换到 in_progress"
|
||||
|
||||
### 词组组合规则
|
||||
|
||||
英文词(实体名 + 品牌名 + 缩写)与中文之间**加单空格**:
|
||||
|
||||
- "Create new issue" → "新建 issue"
|
||||
- "Assign to agent" → "分配给智能体"
|
||||
- "Configure runtime" → "配置运行时"
|
||||
- "Stop daemon" → "停止守护进程"
|
||||
|
||||
### 复数与计数
|
||||
|
||||
i18next 用 `_one` / `_other`;中文不区分语法单复数,只填 `_other`。
|
||||
|
||||
```json
|
||||
// en/issues.json
|
||||
{
|
||||
"issue_count_one": "{{count}} issue",
|
||||
"issue_count_other": "{{count}} issues"
|
||||
}
|
||||
|
||||
// zh-Hans/issues.json
|
||||
{
|
||||
"issue_count_other": "{{count}} 个 issue"
|
||||
}
|
||||
```
|
||||
|
||||
常见计数格式:
|
||||
|
||||
- `{{count}} issues` → `{{count}} 个 issue`
|
||||
- `{{count}} agents` → `{{count}} 个智能体`
|
||||
- `{{count}} workspaces` → `{{count}} 个工作区`
|
||||
- `{{count}} comments` → `{{count}} 条评论`
|
||||
- `{{count}} members` → `{{count}} 位成员`
|
||||
- `{{count}} skills` → `{{count}} 个 skill`
|
||||
|
||||
### 插值
|
||||
|
||||
用 `{{var}}` 形式。中文翻译可以调整位置以符合中文语序。
|
||||
|
||||
```json
|
||||
// en
|
||||
{ "welcome_message": "Welcome back, {{name}}!" }
|
||||
|
||||
// zh-Hans
|
||||
{ "welcome_message": "欢迎回来,{{name}}!" }
|
||||
```
|
||||
|
||||
### Key 命名约定
|
||||
|
||||
3 层嵌套:`feature.component.action`。
|
||||
|
||||
```json
|
||||
{
|
||||
"feature_or_component": {
|
||||
"subcomponent_or_section": {
|
||||
"action_or_label": "..."
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
实例:
|
||||
|
||||
- `issues.toolbar.batch_update_success`
|
||||
- `issues.detail.comment_form.placeholder`
|
||||
- `inbox.empty.title`
|
||||
- `settings.preferences.language.title`
|
||||
|
||||
### Web-only / Desktop-only 文案位置
|
||||
|
||||
- 共享文案:放 namespace JSON 顶层
|
||||
- Web-only:放 `web` 段
|
||||
- Desktop-only:放 `desktop` 段
|
||||
|
||||
参考 `auth.json`(`web` 段含 `prefer_desktop` / `desktop_handoff.*`)。
|
||||
|
||||
---
|
||||
|
||||
## 3. 中文风格
|
||||
|
||||
### 标点
|
||||
|
||||
- 中文用全角标点:`,。:;!?`
|
||||
- 引号:用 `"..."`(直引号),与英文 source 保持一致。**不要**用 `「」` 或弯引号
|
||||
- 省略号:用 `...`(三点)而非 `…`(单字符),与英文 source 保持一致
|
||||
- 中英混排:英文词左右各加 1 个空格(详见词组组合规则)
|
||||
|
||||
### 风格原则
|
||||
|
||||
- **简洁直白**:避免翻译腔,"对于 X 来说"、"作为 X"、"我们的"
|
||||
- **错误信息**:温和但明确,"无法保存修改" 优于 "保存修改失败了!"
|
||||
- **按钮**:动词开头,2-4 字最佳。"取消"、"保存修改"、"立即同步"
|
||||
- **Tooltip**:完整短句。"复制链接到剪贴板"
|
||||
- **placeholder**:示例性提示。"输入 issue 标题..."
|
||||
|
||||
### 拿不准的时候去哪查
|
||||
|
||||
术语表没覆盖的词,按这个顺序查:
|
||||
|
||||
1. `apps/docs/content/docs/*.zh.mdx` —— CN voice 事实标准,20+ 篇高度一致
|
||||
2. `packages/views/locales/zh-Hans/auth.json` 和 `editor.json` —— JSON 结构 + selector API 用法参考
|
||||
3. `packages/views/auth/login-page.tsx` —— 组件层 selector API 调用参考
|
||||
4. `packages/views/settings/components/preferences-tab.tsx` —— 语言切换器参考
|
||||
|
||||
---
|
||||
|
||||
## 修改这一页时
|
||||
|
||||
改本页规则的同时还要:
|
||||
|
||||
1. 把规则在相关 locale JSON / CLAUDE.md / docs 页面里同步落地
|
||||
2. PR 描述里写明改了什么,方便 reviewer 检查下游是否跟着改了
|
||||
|
||||
本页是契约,其他文档不能 override。
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"title": "Developers",
|
||||
"pages": ["conventions"]
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"title": "Developers",
|
||||
"pages": ["contributing", "architecture", "conventions"]
|
||||
"pages": ["contributing", "architecture"]
|
||||
}
|
||||
|
||||
@@ -66,19 +66,13 @@ Multica stores user-uploaded attachments (images and files in comments). **S3 is
|
||||
|
||||
| Variable | Default | Description |
|
||||
|---|---|---|
|
||||
| `S3_BUCKET` | empty | **Bucket name only** (for example `my-bucket`). Do **not** include the `.s3.<region>.amazonaws.com` suffix — the server constructs the public host from `S3_BUCKET` + `S3_REGION`. Setting this enables S3 storage |
|
||||
| `S3_REGION` | `us-west-2` | AWS region. Must match the bucket's actual region — it is used both for SDK signing and for building the public URL |
|
||||
| `S3_BUCKET` | empty | Setting this enables S3 storage |
|
||||
| `S3_REGION` | `us-west-2` | AWS region |
|
||||
| `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` | empty | Static credentials. When both are unset, the AWS SDK default credential chain is used (IAM role / environment credentials) |
|
||||
| `AWS_ENDPOINT_URL` | empty | Custom S3-compatible endpoint (for example [MinIO](https://min.io/)). Setting this switches to path-style URLs |
|
||||
|
||||
**When `S3_BUCKET` is unset**: the server logs `"S3_BUCKET not set, cloud upload disabled"` at startup, and all uploads fall back to local disk.
|
||||
|
||||
**Public URLs** are constructed in this order of priority:
|
||||
|
||||
1. `https://<CLOUDFRONT_DOMAIN>/<key>` if `CLOUDFRONT_DOMAIN` is set.
|
||||
2. `<AWS_ENDPOINT_URL>/<S3_BUCKET>/<key>` (path-style) if `AWS_ENDPOINT_URL` is set.
|
||||
3. `https://<S3_BUCKET>.s3.<S3_REGION>.amazonaws.com/<key>` (virtual-hosted-style). When `S3_BUCKET` contains dots, the server falls back to `https://s3.<S3_REGION>.amazonaws.com/<S3_BUCKET>/<key>` (path-style) because the AWS-issued wildcard TLS certificate does not validate dotted bucket hosts.
|
||||
|
||||
### Local disk (when S3 is not configured)
|
||||
|
||||
| Variable | Default | Description |
|
||||
|
||||
@@ -66,19 +66,13 @@ Multica 存储用户上传的附件(评论里的图片、文件等)。**优
|
||||
|
||||
| 环境变量 | 默认值 | 说明 |
|
||||
|---|---|---|
|
||||
| `S3_BUCKET` | 空 | **只填 bucket 名**(例如 `my-bucket`),**不要**带 `.s3.<region>.amazonaws.com` 后缀——server 会用 `S3_BUCKET` + `S3_REGION` 自己拼公开 host。设了就启用 S3 存储 |
|
||||
| `S3_REGION` | `us-west-2` | AWS 区域。必须和 bucket 所在区域一致——SDK 签名和公开 URL 都用它 |
|
||||
| `S3_BUCKET` | 空 | 设了就启用 S3 存储 |
|
||||
| `S3_REGION` | `us-west-2` | AWS 区域 |
|
||||
| `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` | 空 | 静态凭证。全未设时用 AWS SDK 默认凭证链(IAM role / 环境凭证)|
|
||||
| `AWS_ENDPOINT_URL` | 空 | 自定义 S3 兼容端点(例如 [MinIO](https://min.io/))。设了会切到 path-style URL |
|
||||
|
||||
**`S3_BUCKET` 未设时**:server 启动时打 info 日志 `"S3_BUCKET not set, cloud upload disabled"`,所有上传回落到本地磁盘。
|
||||
|
||||
**公开 URL** 按优先级拼装:
|
||||
|
||||
1. 设了 `CLOUDFRONT_DOMAIN` → `https://<CLOUDFRONT_DOMAIN>/<key>`
|
||||
2. 设了 `AWS_ENDPOINT_URL` → `<AWS_ENDPOINT_URL>/<S3_BUCKET>/<key>`(path-style)
|
||||
3. 默认走 AWS S3 → `https://<S3_BUCKET>.s3.<S3_REGION>.amazonaws.com/<key>`(virtual-hosted-style)。bucket 名含点时会回落到 `https://s3.<S3_REGION>.amazonaws.com/<S3_BUCKET>/<key>`(path-style),因为 AWS 通配证书无法覆盖含点 host。
|
||||
|
||||
### 本地磁盘(S3 未配时)
|
||||
|
||||
| 环境变量 | 默认值 | 说明 |
|
||||
|
||||
@@ -212,15 +212,13 @@ Changes take effect after restarting the backend / compose stack. The web UI rea
|
||||
|
||||
### File Storage (Optional)
|
||||
|
||||
For file uploads and attachments, configure S3 and (optionally) CloudFront:
|
||||
For file uploads and attachments, configure S3 and CloudFront:
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `S3_BUCKET` | Bucket name only (e.g. `my-bucket`). Do **not** include the `.s3.<region>.amazonaws.com` suffix — the server constructs the public URL from `S3_BUCKET` + `S3_REGION` |
|
||||
| `S3_REGION` | AWS region (default: `us-west-2`). Must match the bucket's actual region — used for both SDK signing and public URLs |
|
||||
| `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` | Static credentials. When both are unset, the AWS SDK default credential chain is used |
|
||||
| `AWS_ENDPOINT_URL` | Custom S3-compatible endpoint (e.g. MinIO, R2, B2). Setting this switches the public URL to path-style |
|
||||
| `CLOUDFRONT_DOMAIN` | CloudFront distribution domain — when set, public URLs use this host instead of the S3 host |
|
||||
| `S3_BUCKET` | S3 bucket name |
|
||||
| `S3_REGION` | AWS region (default: `us-west-2`) |
|
||||
| `CLOUDFRONT_DOMAIN` | CloudFront distribution domain |
|
||||
| `CLOUDFRONT_KEY_PAIR_ID` | CloudFront key pair ID for signed URLs |
|
||||
| `CLOUDFRONT_PRIVATE_KEY` | CloudFront private key (PEM format) |
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ The daemon auto-detects which CLIs are available on your PATH and registers them
|
||||
|
||||
Multica supports two layers of skills:
|
||||
|
||||
- **Local skills** — Skills already installed in your local runtime (e.g., `.claude/skills/`, `.opencode/skills/`) are automatically discovered and used by agents. You do **not** need to upload them to Multica.
|
||||
- **Local skills** — Skills already installed in your local runtime (e.g., `.claude/skills/`, `.config/opencode/skills/`) are automatically discovered and used by agents. You do **not** need to upload them to Multica.
|
||||
- **Workspace skills** — Skills created or imported in the Multica Skills page are shared across the workspace. They are automatically injected into agent runs as supplementary context, so every team member's agents benefit from them.
|
||||
|
||||
Workspace skills are designed for team-wide sharing and collaboration — codify your team's best practices once, and every agent can leverage them:
|
||||
|
||||
@@ -33,8 +33,6 @@
|
||||
"---Reference---",
|
||||
"cli",
|
||||
"auth-tokens",
|
||||
"desktop-app",
|
||||
"---Developers---",
|
||||
"developers"
|
||||
"desktop-app"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -32,8 +32,6 @@
|
||||
"---参考---",
|
||||
"cli",
|
||||
"auth-tokens",
|
||||
"desktop-app",
|
||||
"---开发者---",
|
||||
"developers"
|
||||
"desktop-app"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ For guidance on picking a tool when creating an agent, see [Creating and configu
|
||||
| **Hermes** | Nous Research | ✅ | ❌ | `.agent_context/skills/` (fallback) | Dynamic discovery |
|
||||
| **Kimi** | Moonshot | ✅ | ❌ | `.kimi/skills/` | Dynamic discovery |
|
||||
| **Kiro CLI** | Amazon | ✅ | ❌ | `.kiro/skills/` | Dynamic discovery |
|
||||
| **OpenCode** | SST | ✅ | ❌ | `.opencode/skills/` | Dynamic discovery |
|
||||
| **OpenCode** | SST | ✅ | ❌ | `.config/opencode/skills/` | Dynamic discovery |
|
||||
| **OpenClaw** | Open source | ✅ | ❌ | `.agent_context/skills/` (fallback) | Bound to the agent, can't be switched per task |
|
||||
| **Pi** | Inflection AI | ✅ (session is a file path) | ❌ | `.pi/skills/` | Dynamic discovery |
|
||||
|
||||
@@ -103,7 +103,7 @@ Each tool uses **its own** skill discovery path. Before a task runs, the Multica
|
||||
| Cursor | `.cursor/skills/` | ✅ Native |
|
||||
| Kimi | `.kimi/skills/` | ✅ Native |
|
||||
| Kiro CLI | `.kiro/skills/` | ✅ Native |
|
||||
| OpenCode | `.opencode/skills/` | ✅ Native |
|
||||
| OpenCode | `.config/opencode/skills/` | ✅ Native |
|
||||
| Pi | `.pi/skills/` | ✅ Native |
|
||||
| Gemini | `.agent_context/skills/` | ⚠️ Generic fallback |
|
||||
| Hermes | `.agent_context/skills/` | ⚠️ Generic fallback |
|
||||
|
||||
@@ -21,7 +21,7 @@ Multica 内置支持 **11 款 AI 编程工具**。它们都实现了同一套接
|
||||
| **Hermes** | Nous Research | ✅ | ❌ | `.agent_context/skills/` (fallback)| 动态发现 |
|
||||
| **Kimi** | Moonshot | ✅ | ❌ | `.kimi/skills/` | 动态发现 |
|
||||
| **Kiro CLI** | Amazon | ✅ | ❌ | `.kiro/skills/` | 动态发现 |
|
||||
| **OpenCode** | SST | ✅ | ❌ | `.opencode/skills/` | 动态发现 |
|
||||
| **OpenCode** | SST | ✅ | ❌ | `.config/opencode/skills/` | 动态发现 |
|
||||
| **OpenClaw** | 开源项目 | ✅ | ❌ | `.agent_context/skills/` (fallback)| 绑定在智能体上,不能在任务里切换 |
|
||||
| **Pi** | Inflection AI | ✅(session 为文件路径)| ❌ | `.pi/skills/` | 动态发现 |
|
||||
|
||||
@@ -103,7 +103,7 @@ Inflection AI 出品,极简主义。**会话恢复机制特殊**——session
|
||||
| Cursor | `.cursor/skills/` | ✅ 原生 |
|
||||
| Kimi | `.kimi/skills/` | ✅ 原生 |
|
||||
| Kiro CLI | `.kiro/skills/` | ✅ 原生 |
|
||||
| OpenCode | `.opencode/skills/` | ✅ 原生 |
|
||||
| OpenCode | `.config/opencode/skills/` | ✅ 原生 |
|
||||
| Pi | `.pi/skills/` | ✅ 原生 |
|
||||
| Gemini | `.agent_context/skills/` | ⚠️ 通用 fallback |
|
||||
| Hermes | `.agent_context/skills/` | ⚠️ 通用 fallback |
|
||||
|
||||
@@ -116,4 +116,4 @@ Same flow as Cloud — see [Cloud quickstart → Steps 5-6](/cloud-quickstart#5-
|
||||
- [Environment variables](/environment-variables) — full env reference
|
||||
- [Auth setup](/auth-setup) — Resend / OAuth / signup allowlist in detail
|
||||
- [Troubleshooting](/troubleshooting) — start here when things go wrong
|
||||
- [Desktop app](/desktop-app) — optional Desktop setup via `~/.multica/desktop.json`; the web frontend + CLI remains the quickest self-host path
|
||||
- [Desktop app](/desktop-app) — released Desktop builds connect to Multica Cloud only; using Desktop with self-host requires a custom build (see the callout in the desktop-app page)
|
||||
|
||||
@@ -115,4 +115,4 @@ multica setup self-host
|
||||
- [环境变量](/environment-variables) —— 完整 env 清单
|
||||
- [登录与注册配置](/auth-setup) —— Resend / OAuth / 注册白名单详细配置
|
||||
- [故障排查](/troubleshooting) —— 遇到问题先来这里
|
||||
- [桌面应用](/desktop-app) —— 可以通过 `~/.multica/desktop.json` 连接 Desktop;Web 前端 + CLI 仍然是最快的自部署路径
|
||||
- [桌面应用](/desktop-app) —— 发布版 Desktop 只连 Multica Cloud;要让 Desktop 连自部署后端需要自行构建(详见 desktop-app 页的提示)
|
||||
|
||||
@@ -2,22 +2,12 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { I18nProvider } from "@multica/core/i18n/react";
|
||||
import enCommon from "@multica/views/locales/en/common.json";
|
||||
import enAuth from "@multica/views/locales/en/auth.json";
|
||||
import enSettings from "@multica/views/locales/en/settings.json";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
const TEST_RESOURCES = {
|
||||
en: { common: enCommon, auth: enAuth, settings: enSettings },
|
||||
};
|
||||
|
||||
function createWrapper() {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return ({ children }: { children: ReactNode }) => (
|
||||
<I18nProvider locale="en" resources={TEST_RESOURCES}>
|
||||
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
|
||||
</I18nProvider>
|
||||
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -26,7 +26,6 @@ import { captureDownloadIntent } from "@multica/core/analytics";
|
||||
import { setLoggedInCookie } from "@/features/auth/auth-cookie";
|
||||
import Link from "next/link";
|
||||
import { LoginPage, validateCliCallback } from "@multica/views/auth";
|
||||
import { useT } from "@multica/views/i18n";
|
||||
|
||||
/**
|
||||
* Pick where a logged-in user with no explicit `?next=` should land.
|
||||
@@ -57,7 +56,6 @@ async function resolveLoggedInDestination(
|
||||
function LoginPageContent() {
|
||||
const router = useRouter();
|
||||
const qc = useQueryClient();
|
||||
const { t } = useT("auth");
|
||||
const googleClientId = useConfigStore((state) => state.googleClientId);
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const isLoading = useAuthStore((s) => s.isLoading);
|
||||
@@ -95,9 +93,7 @@ function LoginPageContent() {
|
||||
})
|
||||
.catch((err) => {
|
||||
setDesktopError(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: t(($) => $.web.desktop_handoff.prepare_failed),
|
||||
err instanceof Error ? err.message : "Failed to prepare Desktop sign-in",
|
||||
);
|
||||
});
|
||||
return;
|
||||
@@ -144,9 +140,7 @@ function LoginPageContent() {
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<Card className="w-full max-w-sm">
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle className="text-2xl">
|
||||
{t(($) => $.web.desktop_handoff.failed_title)}
|
||||
</CardTitle>
|
||||
<CardTitle className="text-2xl">Sign-in Failed</CardTitle>
|
||||
<CardDescription>{desktopError}</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
@@ -157,13 +151,11 @@ function LoginPageContent() {
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<Card className="w-full max-w-sm">
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle className="text-2xl">
|
||||
{t(($) => $.web.desktop_handoff.opening_title)}
|
||||
</CardTitle>
|
||||
<CardTitle className="text-2xl">Opening Multica</CardTitle>
|
||||
<CardDescription>
|
||||
{desktopToken
|
||||
? t(($) => $.web.desktop_handoff.opening_description)
|
||||
: t(($) => $.web.desktop_handoff.preparing)}
|
||||
? "You should see a prompt to open the Multica desktop app. If nothing happens, click the button below."
|
||||
: "Preparing Desktop sign-in..."}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex justify-center">
|
||||
@@ -174,7 +166,7 @@ function LoginPageContent() {
|
||||
window.location.href = `multica://auth/callback?token=${encodeURIComponent(desktopToken)}`;
|
||||
}}
|
||||
>
|
||||
{t(($) => $.web.desktop_handoff.open_button)}
|
||||
Open Multica Desktop
|
||||
</Button>
|
||||
) : (
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
@@ -204,14 +196,18 @@ function LoginPageContent() {
|
||||
}
|
||||
onTokenObtained={setLoggedInCookie}
|
||||
extra={
|
||||
// Web-only nudge toward the desktop app. Copy is hardcoded EN
|
||||
// for now because the login route sits outside the landing
|
||||
// group's LocaleProvider — if this page ever becomes
|
||||
// locale-aware, the strings live in positioning doc §3.3.
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t(($) => $.web.prefer_desktop)}{" "}
|
||||
Prefer the desktop app?{" "}
|
||||
<Link
|
||||
href="/download"
|
||||
onClick={() => captureDownloadIntent("login")}
|
||||
className="font-medium text-foreground underline decoration-foreground/30 underline-offset-4 hover:decoration-foreground/70"
|
||||
>
|
||||
{t(($) => $.web.download)}
|
||||
Download
|
||||
</Link>
|
||||
</span>
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { cookies, headers } from "next/headers";
|
||||
import { Instrument_Serif, Noto_Serif_SC } from "next/font/google";
|
||||
import { LOCALE_COOKIE } from "@multica/core/i18n";
|
||||
import { LocaleProvider } from "@/features/landing/i18n";
|
||||
import type { Locale } from "@/features/landing/i18n";
|
||||
|
||||
@@ -44,7 +43,7 @@ const jsonLd = {
|
||||
async function getInitialLocale(): Promise<Locale> {
|
||||
// 1. User's explicit preference (cookie set when they switch language)
|
||||
const cookieStore = await cookies();
|
||||
const stored = cookieStore.get(LOCALE_COOKIE)?.value;
|
||||
const stored = cookieStore.get("multica-locale")?.value;
|
||||
if (stored === "en" || stored === "zh") return stored;
|
||||
|
||||
// 2. Detect from Accept-Language header
|
||||
|
||||
@@ -1,16 +1,10 @@
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import { headers } from "next/headers";
|
||||
import { Inter, Geist_Mono, Source_Serif_4 } from "next/font/google";
|
||||
import { ThemeProvider } from "@/components/theme-provider";
|
||||
import { Toaster } from "@multica/ui/components/ui/sonner";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import { WebProviders } from "@/components/web-providers";
|
||||
import {
|
||||
DEFAULT_LOCALE,
|
||||
SUPPORTED_LOCALES,
|
||||
type SupportedLocale,
|
||||
} from "@multica/core/i18n";
|
||||
import { RESOURCES } from "@multica/views/locales";
|
||||
import { LocaleSync } from "@/components/locale-sync";
|
||||
import "./globals.css";
|
||||
|
||||
// Font stack: Inter for Latin UI text + system Chinese fonts for zh content.
|
||||
@@ -103,40 +97,21 @@ export const metadata: Metadata = {
|
||||
},
|
||||
};
|
||||
|
||||
function isSupportedLocale(value: string | null): value is SupportedLocale {
|
||||
return value !== null && (SUPPORTED_LOCALES as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
// HTML lang attribute uses BCP-47 region tags that screen readers and font
|
||||
// stacks recognize widely. i18next keeps `zh-Hans` as its internal locale
|
||||
// (script subtag is what we actually translate against), but the html element
|
||||
// expects a region-flavoured tag for accessibility tooling and CJK fallback.
|
||||
const HTML_LANG: Record<SupportedLocale, string> = {
|
||||
en: "en",
|
||||
"zh-Hans": "zh-CN",
|
||||
};
|
||||
|
||||
export default async function RootLayout({
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const h = await headers();
|
||||
const headerLocale = h.get("x-multica-locale");
|
||||
const locale: SupportedLocale = isSupportedLocale(headerLocale)
|
||||
? headerLocale
|
||||
: DEFAULT_LOCALE;
|
||||
const resources = { [locale]: RESOURCES[locale] };
|
||||
|
||||
return (
|
||||
<html
|
||||
lang={HTML_LANG[locale]}
|
||||
lang="en"
|
||||
suppressHydrationWarning
|
||||
className={cn("antialiased font-sans h-full", inter.variable, geistMono.variable, sourceSerif.variable)}
|
||||
>
|
||||
<body className="h-full overflow-hidden">
|
||||
<LocaleSync />
|
||||
<ThemeProvider>
|
||||
<WebProviders locale={locale} resources={resources}>
|
||||
<WebProviders>
|
||||
{children}
|
||||
</WebProviders>
|
||||
<Toaster />
|
||||
|
||||
@@ -1,19 +1,61 @@
|
||||
"use client";
|
||||
import { Instrument_Serif } from "next/font/google";
|
||||
|
||||
import Link from "next/link";
|
||||
import { buttonVariants } from "@multica/ui/components/ui/button";
|
||||
// Editorial-style 404. Cream + ink + terracotta palette is intentionally
|
||||
// inline — these brand experiments have not been promoted to design tokens.
|
||||
// The route lives outside the (landing) group's font scope, so we attach
|
||||
// Instrument Serif locally to match the editorial direction.
|
||||
const CREAM = "#faf9f6";
|
||||
const INK = "#1b1812";
|
||||
const TERRACOTTA = "#a64a2c";
|
||||
|
||||
const editorialSerif = Instrument_Serif({
|
||||
subsets: ["latin"],
|
||||
weight: "400",
|
||||
variable: "--font-serif",
|
||||
});
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<main className="flex min-h-screen flex-col items-center justify-center gap-6 px-6 py-24 text-center">
|
||||
<p className="text-sm font-medium text-muted-foreground">404</p>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Page not found</h1>
|
||||
<p className="max-w-md text-sm text-muted-foreground">
|
||||
The page you are looking for doesn’t exist or has been moved.
|
||||
<section
|
||||
className={`${editorialSerif.variable} relative flex min-h-screen flex-col items-center justify-center px-6 py-16`}
|
||||
style={{ backgroundColor: CREAM, color: INK }}
|
||||
>
|
||||
{/* tracking is wider than Tailwind's tracking-widest (0.1em) — editorial eyebrow detail, deliberate. */}
|
||||
<div
|
||||
className="flex items-center gap-3 text-xs uppercase tracking-[0.25em]"
|
||||
style={{ color: TERRACOTTA }}
|
||||
>
|
||||
<span aria-hidden="true" className="inline-block h-px w-10" style={{ background: TERRACOTTA }} />
|
||||
<span>error · not found</span>
|
||||
<span aria-hidden="true" className="inline-block h-px w-10" style={{ background: TERRACOTTA }} />
|
||||
</div>
|
||||
|
||||
{/* Fluid hero size + ultra-tight leading; outside the Tailwind type scale by design. */}
|
||||
<h1 className="mt-12 font-serif text-[clamp(7rem,16vw,15rem)] leading-[0.85] tracking-tight">
|
||||
404
|
||||
</h1>
|
||||
|
||||
<p className="mt-10 max-w-xl text-center font-serif text-3xl leading-tight">
|
||||
This page{" "}
|
||||
<em className="not-italic" style={{ color: TERRACOTTA }}>
|
||||
doesn’t exist
|
||||
</em>
|
||||
.
|
||||
</p>
|
||||
<Link href="/" className={buttonVariants({ className: "mt-2" })}>
|
||||
<p
|
||||
className="mt-5 max-w-md text-center text-sm leading-relaxed"
|
||||
style={{ color: INK, opacity: 0.6 }}
|
||||
>
|
||||
The URL may have changed, the resource may be deleted, or you arrived from a stale link.
|
||||
</p>
|
||||
|
||||
<a
|
||||
href="/"
|
||||
className="mt-12 inline-flex h-10 items-center rounded-full px-6 text-sm font-medium transition hover:opacity-90"
|
||||
style={{ background: INK, color: CREAM }}
|
||||
>
|
||||
Back to Multica
|
||||
</Link>
|
||||
</main>
|
||||
</a>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
20
apps/web/components/locale-sync.tsx
Normal file
20
apps/web/components/locale-sync.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
|
||||
/**
|
||||
* Reads the locale cookie on the client and updates <html lang>.
|
||||
* This avoids calling cookies() in the root Server Component layout,
|
||||
* which would mark the entire app as dynamic and disable the Router Cache.
|
||||
*/
|
||||
export function LocaleSync() {
|
||||
useEffect(() => {
|
||||
const match = document.cookie.match(/(?:^|;\s*)multica-locale=(\w+)/);
|
||||
const locale = match?.[1];
|
||||
if (locale === "zh") {
|
||||
document.documentElement.lang = "zh";
|
||||
}
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -2,8 +2,6 @@
|
||||
|
||||
import { Suspense, useMemo } from "react";
|
||||
import { CoreProvider } from "@multica/core/platform";
|
||||
import { createBrowserCookieLocaleAdapter } from "@multica/core/i18n/browser";
|
||||
import type { LocaleResources, SupportedLocale } from "@multica/core/i18n";
|
||||
import packageJson from "../package.json";
|
||||
import { WebNavigationProvider } from "@/platform/navigation";
|
||||
import {
|
||||
@@ -43,15 +41,7 @@ function deriveWsUrl(): string | undefined {
|
||||
const WEB_VERSION =
|
||||
process.env.NEXT_PUBLIC_APP_VERSION || packageJson.version || "dev";
|
||||
|
||||
export function WebProviders({
|
||||
children,
|
||||
locale,
|
||||
resources,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
locale: SupportedLocale;
|
||||
resources: Record<string, LocaleResources>;
|
||||
}) {
|
||||
export function WebProviders({ children }: { children: React.ReactNode }) {
|
||||
const cookieAuth = !hasLegacyToken();
|
||||
// Stable identity reference so downstream effects keyed on it don't see a
|
||||
// new object on every parent render.
|
||||
@@ -59,7 +49,6 @@ export function WebProviders({
|
||||
() => ({ platform: "web", version: WEB_VERSION }),
|
||||
[],
|
||||
);
|
||||
const localeAdapter = useMemo(() => createBrowserCookieLocaleAdapter(), []);
|
||||
return (
|
||||
<CoreProvider
|
||||
apiBaseUrl={process.env.NEXT_PUBLIC_API_URL}
|
||||
@@ -68,9 +57,6 @@ export function WebProviders({
|
||||
onLogin={setLoggedInCookie}
|
||||
onLogout={clearLoggedInCookie}
|
||||
identity={identity}
|
||||
locale={locale}
|
||||
resources={resources}
|
||||
localeAdapter={localeAdapter}
|
||||
>
|
||||
{/* Suspense boundary is required by Next.js for useSearchParams in
|
||||
a client component mounted this high in the tree. */}
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import { createContext, useContext, useState, useCallback, useMemo } from "react";
|
||||
import { useConfigStore } from "@multica/core/config";
|
||||
import { LOCALE_COOKIE } from "@multica/core/i18n";
|
||||
import { createEnDict } from "./en";
|
||||
import { createZhDict } from "./zh";
|
||||
import type { LandingDict, Locale } from "./types";
|
||||
@@ -12,6 +11,7 @@ const dictionaryFactories: Record<Locale, (allowSignup: boolean) => LandingDict>
|
||||
zh: createZhDict,
|
||||
};
|
||||
|
||||
const COOKIE_NAME = "multica-locale";
|
||||
const COOKIE_MAX_AGE = 60 * 60 * 24 * 365; // 1 year
|
||||
|
||||
type LocaleContextValue = {
|
||||
@@ -38,11 +38,7 @@ export function LocaleProvider({
|
||||
|
||||
const setLocale = useCallback((l: Locale) => {
|
||||
setLocaleState(l);
|
||||
const secure =
|
||||
typeof location !== "undefined" && location.protocol === "https:"
|
||||
? "; Secure"
|
||||
: "";
|
||||
document.cookie = `${LOCALE_COOKIE}=${l}; path=/; max-age=${COOKIE_MAX_AGE}; SameSite=Lax${secure}`;
|
||||
document.cookie = `${COOKIE_NAME}=${l}; path=/; max-age=${COOKIE_MAX_AGE}; SameSite=Lax`;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
|
||||
@@ -283,46 +283,6 @@ export function createEnDict(allowSignup: boolean): LandingDict {
|
||||
fixes: "Bug Fixes",
|
||||
},
|
||||
entries: [
|
||||
{
|
||||
version: "0.2.26",
|
||||
date: "2026-05-06",
|
||||
title: "Full i18n Rollout, Long-Issue Timeline & System Notifications Toggle",
|
||||
changes: [],
|
||||
features: [
|
||||
"Web app fully translated to Simplified Chinese (21 namespaces), with per-user locale",
|
||||
"System Notifications toggle in Settings",
|
||||
"Delete chat sessions; History panel surfaced on the chat header",
|
||||
"Runtime liveness backed by Redis, with DB fallback",
|
||||
"Desktop loads runtime self-host config",
|
||||
"CLI adds `--assignee-id` / `--to-id` / `--user-id` for unambiguous targeting",
|
||||
],
|
||||
improvements: [
|
||||
"Settings 'Appearance' tab is renamed to 'Preferences', and the active tab is reflected in the URL so deep links work",
|
||||
"Long issues open instantly — Timeline switched to cursor-based keyset pagination, and repeated `task_completed` / `task_failed` activity entries are coalesced",
|
||||
"Runtime poll and heartbeat schedules are isolated per-runtime, so one busy runtime can no longer starve others",
|
||||
"CLI update requests persist in Redis, so a server restart no longer drops them",
|
||||
"Runtime cost usage window narrowed from 180 days to 14 days, dropping query load",
|
||||
"Project list returns a `resource_count` instead of inlining all resources, keeping responses lean",
|
||||
"404 page redesigned, with the No-Access redirect loop fixed",
|
||||
"Quick Create exempts git-describe daemons from the CLI version gate",
|
||||
"CI now enforces lint on every PR, and the existing lint debt has been cleared",
|
||||
],
|
||||
fixes: [
|
||||
"Daemon cancels the running agent when the task is deleted server-side, eliminating orphan processes",
|
||||
"Daemon refreshes a stale Codex `auth.json` when reusing an exec env, fixing intermittent auth errors",
|
||||
"Daemon refuses to write `.gc_meta.json` when `issue_id` is empty",
|
||||
"Session / resume across ACP backends now trusts the agent-reported session id, fixing cross-session bleed",
|
||||
"OpenCode skills are written under `.opencode/skills/` so they are discovered natively",
|
||||
"404 task-not-found semantics tightened on both server and the final guard",
|
||||
"Pinned sidebar rows are auto-unpinned when the underlying entity disappears",
|
||||
"Project detail page splits desktop and mobile sidebar state",
|
||||
"Runtime detail page hides archived agents",
|
||||
"Already-attached repos in Add Resource show a URL tooltip; empty project state has a New Issue button",
|
||||
"S3 public URLs are region-qualified, fixing cross-region access",
|
||||
"Windows installer parses version numbers and decodes checksums correctly",
|
||||
"Quick Create submit button no longer shows a duplicate keyboard shortcut",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.2.24",
|
||||
date: "2026-05-03",
|
||||
|
||||
@@ -283,46 +283,6 @@ export function createZhDict(allowSignup: boolean): LandingDict {
|
||||
fixes: "问题修复",
|
||||
},
|
||||
entries: [
|
||||
{
|
||||
version: "0.2.26",
|
||||
date: "2026-05-06",
|
||||
title: "i18n 全量铺开、长 Issue Timeline 提速与系统通知开关",
|
||||
changes: [],
|
||||
features: [
|
||||
"Web 端完成简中翻译,21 个命名空间齐全,语言偏好按账号同步",
|
||||
"Settings 新增 System Notifications 开关",
|
||||
"支持删除 Chat 会话,History 面板移至 chat header",
|
||||
"Runtime 在线判断改走 Redis(DB 兜底)",
|
||||
"Desktop 支持加载 runtime 自托管配置",
|
||||
"CLI 新增 `--assignee-id` / `--to-id` / `--user-id`,重名时定位更准",
|
||||
],
|
||||
improvements: [
|
||||
"Settings 的 Appearance Tab 改名为 Preferences,并把当前激活的 Tab 反映到 URL,深链可分享",
|
||||
"长 Issue 打开秒开 —— Timeline 改为基于游标的 keyset 分页,重复的 `task_completed` / `task_failed` 活动条目合并展示",
|
||||
"Runtime poll 与 heartbeat 调度按 runtime 隔离,单个忙碌 runtime 不再拖慢其他",
|
||||
"CLI 更新请求落 Redis,server 重启也不丢",
|
||||
"Runtime 用量统计窗口由 180 天收窄到 14 天,降低查询压力",
|
||||
"项目列表返回 `resource_count` 摘要,不再内联全部 resource,响应体更小",
|
||||
"404 页面重新设计,并修复 No-Access 重定向死循环",
|
||||
"Quick Create 对 git-describe 类 daemon 跳过 CLI 版本闸",
|
||||
"CI 启用 lint 强制门禁,历史 lint 债同步清理完毕",
|
||||
],
|
||||
fixes: [
|
||||
"Task 在服务端被删后,daemon 主动取消正在运行的 agent,避免孤儿进程",
|
||||
"复用 execenv 时刷新陈旧的 Codex `auth.json`,修复偶发鉴权失败",
|
||||
"`issue_id` 为空时拒绝写入 `.gc_meta.json`",
|
||||
"跨 ACP 后端的 session/resume 信任 agent 自报的 session id,修复串号问题",
|
||||
"OpenCode 的 skills 写到 `.opencode/skills/` 让其原生发现",
|
||||
"Daemon 对 task-not-found 的 404 语义在 server 和最终 guard 双重收紧",
|
||||
"侧边栏中失效的 Pin 自动取消挂载",
|
||||
"项目详情页桌面端与移动端侧边栏状态独立保存",
|
||||
"Runtime 详情页隐藏已归档的 agent",
|
||||
"Add Resource 列表中已挂载的 repo 显示 URL tooltip;空项目页加上 New Issue 入口",
|
||||
"S3 公开 URL 携带 region,修复跨区访问失败",
|
||||
"Windows 安装器修正版本号解析与 checksum 解码",
|
||||
"Quick Create 提交按钮去掉重复的快捷键提示",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.2.24",
|
||||
date: "2026-05-03",
|
||||
|
||||
2
apps/web/next-env.d.ts
vendored
2
apps/web/next-env.d.ts
vendored
@@ -1,6 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/dev/types/routes.d.ts";
|
||||
import "./.next/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { NextResponse, type NextRequest } from "next/server";
|
||||
import { matchLocale, LOCALE_COOKIE } from "@multica/core/i18n";
|
||||
|
||||
// Old workspace-scoped route segments that existed before the URL refactor
|
||||
// (pre-#1131). Any URL with these as the FIRST segment is a legacy URL that
|
||||
@@ -17,34 +16,7 @@ const LEGACY_ROUTE_SEGMENTS = new Set([
|
||||
"settings",
|
||||
]);
|
||||
|
||||
// Resolve the active locale per request. Cookie wins over Accept-Language;
|
||||
// matchLocale() falls back to DEFAULT_LOCALE when neither yields a match.
|
||||
function resolveLocale(req: NextRequest): string {
|
||||
const cookieLocale = req.cookies.get(LOCALE_COOKIE)?.value;
|
||||
const acceptLanguage = req.headers.get("accept-language") ?? "";
|
||||
const candidates: string[] = [];
|
||||
if (cookieLocale) candidates.push(cookieLocale);
|
||||
for (const part of acceptLanguage.split(",")) {
|
||||
const tag = part.split(";")[0]?.trim();
|
||||
if (tag) candidates.push(tag);
|
||||
}
|
||||
return matchLocale(candidates);
|
||||
}
|
||||
|
||||
// Forward the resolved locale to RSC layouts via the `x-multica-locale`
|
||||
// request header. layout.tsx reads it through `await headers()`. The
|
||||
// `request: { headers }` form is what makes the header land on the upstream
|
||||
// request — without it the value would only sit on the response.
|
||||
function nextWithLocale(req: NextRequest): NextResponse {
|
||||
const headers = new Headers(req.headers);
|
||||
headers.set("x-multica-locale", resolveLocale(req));
|
||||
return NextResponse.next({ request: { headers } });
|
||||
}
|
||||
|
||||
// Next.js 16 renamed `middleware` → `proxy`. API surface (NextRequest /
|
||||
// NextResponse / cookies / matcher) is identical; the only behavioral
|
||||
// change is the runtime — proxy is forced to nodejs and cannot opt into
|
||||
// edge.
|
||||
// Next.js 16 renamed `middleware` → `proxy`. The runtime API is identical.
|
||||
export function proxy(req: NextRequest) {
|
||||
const { pathname } = req.nextUrl;
|
||||
const hasSession = req.cookies.has("multica_logged_in");
|
||||
@@ -76,21 +48,34 @@ export function proxy(req: NextRequest) {
|
||||
}
|
||||
|
||||
// --- Root path: redirect logged-in users to their last workspace ---
|
||||
if (pathname === "/" && hasSession && lastSlug) {
|
||||
const url = req.nextUrl.clone();
|
||||
url.pathname = `/${lastSlug}/issues`;
|
||||
return NextResponse.redirect(url);
|
||||
if (pathname === "/") {
|
||||
if (!hasSession) return NextResponse.next();
|
||||
|
||||
if (lastSlug) {
|
||||
const url = req.nextUrl.clone();
|
||||
url.pathname = `/${lastSlug}/issues`;
|
||||
return NextResponse.redirect(url);
|
||||
}
|
||||
|
||||
// No last_workspace_slug cookie → let landing page pick the first workspace
|
||||
// client-side (features/landing/components/redirect-if-authenticated.tsx).
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
// --- Default: forward locale header to RSC, no redirect/rewrite ---
|
||||
// Covers logged-out root path, /login, /:slug/*, and everything else.
|
||||
return nextWithLocale(req);
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
export const config = {
|
||||
// i18n header must land on every page request, so we use the standard
|
||||
// negative-lookahead pattern from Next's i18n guide: skip API routes
|
||||
// (Go backend), Next internals, and any path with a file extension
|
||||
// (favicons, sw.js, public/* assets).
|
||||
matcher: ["/((?!api|_next/static|_next/image|favicon.ico|.*\\.).*)"],
|
||||
matcher: [
|
||||
"/",
|
||||
"/issues/:path*",
|
||||
"/projects/:path*",
|
||||
"/agents/:path*",
|
||||
"/inbox/:path*",
|
||||
"/my-issues/:path*",
|
||||
"/autopilots/:path*",
|
||||
"/runtimes/:path*",
|
||||
"/skills/:path*",
|
||||
"/settings/:path*",
|
||||
],
|
||||
};
|
||||
|
||||
@@ -14,7 +14,6 @@ export const mockUser: User = {
|
||||
// Matches real server behavior for anyone who onboarded before this
|
||||
// field shipped — migration 054 backfills 'skipped_legacy'.
|
||||
starter_content_state: "skipped_legacy",
|
||||
language: null,
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-01T00:00:00Z",
|
||||
};
|
||||
|
||||
@@ -372,7 +372,7 @@ skill
|
||||
3. **注入**:当 agent 认领任务时,daemon 把挂载的 skill 内容写到任务工作目录的 **provider 原生位置**:
|
||||
- Claude Code → `.claude/skills/{name}/SKILL.md`
|
||||
- Codex → `CODEX_HOME/skills/{name}/`
|
||||
- OpenCode → `.opencode/skills/{name}/SKILL.md`
|
||||
- OpenCode → `.config/opencode/skills/{name}/SKILL.md`
|
||||
- Pi → `.pi/skills/{name}/SKILL.md`
|
||||
- Cursor → `.cursor/skills/{name}/SKILL.md`
|
||||
- GitHub Copilot → `.github/skills/{name}/SKILL.md`
|
||||
|
||||
@@ -42,8 +42,7 @@ import type {
|
||||
RuntimeLocalSkillListRequest,
|
||||
CreateRuntimeLocalSkillImportRequest,
|
||||
RuntimeLocalSkillImportRequest,
|
||||
TimelinePage,
|
||||
TimelinePageParam,
|
||||
TimelineEntry,
|
||||
AssigneeFrequencyEntry,
|
||||
TaskMessagePayload,
|
||||
Attachment,
|
||||
@@ -495,17 +494,8 @@ 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[]> {
|
||||
return this.fetch(`/api/issues/${issueId}/timeline`);
|
||||
}
|
||||
|
||||
async getAssigneeFrequency(): Promise<AssigneeFrequencyEntry[]> {
|
||||
@@ -1019,7 +1009,7 @@ export class ApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
async deleteChatSession(id: string): Promise<void> {
|
||||
async archiveChatSession(id: string): Promise<void> {
|
||||
await this.fetch(`/api/chat/sessions/${id}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,6 @@
|
||||
export function sanitizeNextUrl(raw: string | null): string | null {
|
||||
if (!raw) return null;
|
||||
if (!raw.startsWith("/") || raw.startsWith("//")) return null;
|
||||
// eslint-disable-next-line no-control-regex -- intentional: rejecting control chars is the whole point
|
||||
if (/[\x00-\x1f\\]/.test(raw)) return null;
|
||||
return raw;
|
||||
}
|
||||
|
||||
@@ -70,20 +70,14 @@ export function useMarkChatSessionRead() {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hard-deletes a chat session. Optimistically removes the row from both
|
||||
* the active and all-sessions lists so the history panel updates instantly;
|
||||
* rolls back on error. The matching `chat:session_deleted` WS event keeps
|
||||
* other tabs/devices in sync — see use-realtime-sync.ts.
|
||||
*/
|
||||
export function useDeleteChatSession() {
|
||||
export function useArchiveChatSession() {
|
||||
const qc = useQueryClient();
|
||||
const wsId = useWorkspaceId();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (sessionId: string) => {
|
||||
logger.info("deleteChatSession.start", { sessionId });
|
||||
return api.deleteChatSession(sessionId);
|
||||
logger.info("archiveChatSession.start", { sessionId });
|
||||
return api.archiveChatSession(sessionId);
|
||||
},
|
||||
onMutate: async (sessionId) => {
|
||||
await qc.cancelQueries({ queryKey: chatKeys.sessions(wsId) });
|
||||
@@ -92,20 +86,26 @@ export function useDeleteChatSession() {
|
||||
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);
|
||||
// Optimistic: remove from active, mark as archived in allSessions
|
||||
qc.setQueryData<ChatSession[]>(chatKeys.sessions(wsId), (old) =>
|
||||
old ? old.filter((s) => s.id !== sessionId) : old,
|
||||
);
|
||||
qc.setQueryData<ChatSession[]>(chatKeys.allSessions(wsId), (old) =>
|
||||
old?.map((s) =>
|
||||
s.id === sessionId ? { ...s, status: "archived" as const } : s,
|
||||
),
|
||||
);
|
||||
|
||||
logger.debug("deleteChatSession.optimistic", { sessionId });
|
||||
logger.debug("archiveChatSession.optimistic", { sessionId });
|
||||
return { prevSessions, prevAll };
|
||||
},
|
||||
onError: (err, sessionId, ctx) => {
|
||||
logger.error("deleteChatSession.error.rollback", { sessionId, err });
|
||||
logger.error("archiveChatSession.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 });
|
||||
logger.debug("archiveChatSession.settled", { sessionId });
|
||||
qc.invalidateQueries({ queryKey: chatKeys.sessions(wsId) });
|
||||
qc.invalidateQueries({ queryKey: chatKeys.allSessions(wsId) });
|
||||
},
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext, type ReactNode } from "react";
|
||||
import type { LocaleAdapter } from "./types";
|
||||
|
||||
const LocaleAdapterContext = createContext<LocaleAdapter | null>(null);
|
||||
|
||||
export function LocaleAdapterProvider({
|
||||
adapter,
|
||||
children,
|
||||
}: {
|
||||
adapter: LocaleAdapter;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<LocaleAdapterContext.Provider value={adapter}>
|
||||
{children}
|
||||
</LocaleAdapterContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useLocaleAdapter(): LocaleAdapter {
|
||||
const ctx = useContext(LocaleAdapterContext);
|
||||
if (!ctx) {
|
||||
throw new Error(
|
||||
"useLocaleAdapter must be used within <LocaleAdapterProvider>",
|
||||
);
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
LOCALE_COOKIE,
|
||||
createBrowserCookieLocaleAdapter,
|
||||
} from "./browser-cookie-adapter";
|
||||
|
||||
function clearCookies() {
|
||||
document.cookie
|
||||
.split(";")
|
||||
.map((c) => c.trim().split("=")[0])
|
||||
.filter(Boolean)
|
||||
.forEach((name) => {
|
||||
document.cookie = `${name}=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT`;
|
||||
});
|
||||
}
|
||||
|
||||
describe("createBrowserCookieLocaleAdapter", () => {
|
||||
beforeEach(clearCookies);
|
||||
afterEach(clearCookies);
|
||||
|
||||
it("getUserChoice returns null when no cookie is set", () => {
|
||||
const adapter = createBrowserCookieLocaleAdapter();
|
||||
expect(adapter.getUserChoice()).toBe(null);
|
||||
});
|
||||
|
||||
it("getUserChoice round-trips a persisted value", () => {
|
||||
const adapter = createBrowserCookieLocaleAdapter();
|
||||
adapter.persist("zh-Hans");
|
||||
expect(adapter.getUserChoice()).toBe("zh-Hans");
|
||||
});
|
||||
|
||||
it("getUserChoice decodes URI-encoded cookie values", () => {
|
||||
document.cookie = `${LOCALE_COOKIE}=${encodeURIComponent("zh-Hans")}; path=/`;
|
||||
expect(createBrowserCookieLocaleAdapter().getUserChoice()).toBe("zh-Hans");
|
||||
});
|
||||
|
||||
it("getUserChoice ignores unrelated cookies that share a prefix", () => {
|
||||
document.cookie = `${LOCALE_COOKIE}-other=should-not-match; path=/`;
|
||||
expect(createBrowserCookieLocaleAdapter().getUserChoice()).toBe(null);
|
||||
});
|
||||
|
||||
it("persist writes a cookie with SameSite=Lax", () => {
|
||||
const adapter = createBrowserCookieLocaleAdapter();
|
||||
adapter.persist("en");
|
||||
expect(document.cookie).toContain(`${LOCALE_COOKIE}=en`);
|
||||
});
|
||||
|
||||
it("getSystemPreferences mirrors navigator.languages", () => {
|
||||
Object.defineProperty(navigator, "languages", {
|
||||
value: ["zh-Hans-CN", "en-US"],
|
||||
configurable: true,
|
||||
});
|
||||
expect(createBrowserCookieLocaleAdapter().getSystemPreferences()).toEqual([
|
||||
"zh-Hans-CN",
|
||||
"en-US",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,35 +0,0 @@
|
||||
import type { LocaleAdapter, SupportedLocale } from "./types";
|
||||
|
||||
export const LOCALE_COOKIE = "multica-locale";
|
||||
const COOKIE_MAX_AGE = 60 * 60 * 24 * 365;
|
||||
|
||||
// Web-only adapter: persists via document.cookie so the Next.js proxy can
|
||||
// read the active locale on the next request. Desktop has no server-side
|
||||
// proxy and must use createDesktopLocaleAdapter (apps/desktop/.../i18n-adapter)
|
||||
// which persists via window.localStorage instead.
|
||||
export function createBrowserCookieLocaleAdapter(): LocaleAdapter {
|
||||
return {
|
||||
getUserChoice() {
|
||||
if (typeof document === "undefined") return null;
|
||||
const m = document.cookie.match(
|
||||
new RegExp(`(?:^|;\\s*)${LOCALE_COOKIE}=([^;]+)`),
|
||||
);
|
||||
const value = m?.[1];
|
||||
return value ? decodeURIComponent(value) : null;
|
||||
},
|
||||
getSystemPreferences() {
|
||||
if (typeof navigator === "undefined") return [];
|
||||
return [...navigator.languages];
|
||||
},
|
||||
persist(locale: SupportedLocale) {
|
||||
if (typeof document === "undefined") return;
|
||||
const secure =
|
||||
typeof location !== "undefined" && location.protocol === "https:"
|
||||
? ";Secure"
|
||||
: "";
|
||||
document.cookie =
|
||||
`${LOCALE_COOKIE}=${encodeURIComponent(locale)};` +
|
||||
`path=/;max-age=${COOKIE_MAX_AGE};SameSite=Lax${secure}`;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
// Browser-only entry: anything that touches `document` / `navigator` /
|
||||
// `window` lives here, so accidental imports from RSC, Edge middleware, or
|
||||
// nodejs proxy.ts crash at the import site instead of at first call.
|
||||
//
|
||||
// `LOCALE_COOKIE` (a plain string constant) is also exported from the
|
||||
// server-safe `@multica/core/i18n` entry — proxy.ts needs it to read the
|
||||
// cookie from a NextRequest. Only the adapter factory is browser-restricted.
|
||||
export { createBrowserCookieLocaleAdapter } from "./browser-cookie-adapter";
|
||||
@@ -1,23 +0,0 @@
|
||||
import i18next, { type i18n as I18n } from "i18next";
|
||||
import { initReactI18next } from "react-i18next";
|
||||
import type { LocaleResources, SupportedLocale } from "./types";
|
||||
|
||||
// Both server (RSC) and client must call this with the SAME locale + resources
|
||||
// to avoid hydration mismatch. `initAsync: false` forces synchronous init
|
||||
// (renamed from `initImmediate` in i18next v25+); `useSuspense: false`
|
||||
// prevents fallback rendering during hydration.
|
||||
export function createI18n(
|
||||
locale: SupportedLocale,
|
||||
resources: Record<string, LocaleResources>,
|
||||
): I18n {
|
||||
const instance = i18next.createInstance();
|
||||
instance.use(initReactI18next).init({
|
||||
lng: locale,
|
||||
fallbackLng: "en",
|
||||
resources,
|
||||
interpolation: { escapeValue: false },
|
||||
initAsync: false,
|
||||
react: { useSuspense: false },
|
||||
});
|
||||
return instance;
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
// Server-safe i18n entry: zero React imports + zero DOM/document/navigator
|
||||
// access anywhere in this transitive graph. Safe to import from proxy.ts /
|
||||
// RSC / Edge / nodejs middleware.
|
||||
//
|
||||
// React-side helpers (I18nProvider, useLocaleAdapter, createI18n) live in
|
||||
// "@multica/core/i18n/react" — split because Next.js gives RSC a vendored
|
||||
// React build that lacks createContext, and react-i18next's top-level
|
||||
// React.createContext() call would crash any non-client load of this file.
|
||||
//
|
||||
// Browser-only helpers (createBrowserCookieLocaleAdapter) live in
|
||||
// "@multica/core/i18n/browser" — they read document.cookie / navigator.languages
|
||||
// at construction time and would crash in any non-DOM context.
|
||||
export type {
|
||||
LocaleAdapter,
|
||||
LocaleResources,
|
||||
SupportedLocale,
|
||||
} from "./types";
|
||||
export { DEFAULT_LOCALE, SUPPORTED_LOCALES } from "./types";
|
||||
export { matchLocale, pickLocale } from "./pick-locale";
|
||||
export { LOCALE_COOKIE } from "./browser-cookie-adapter";
|
||||
@@ -1,80 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { matchLocale, pickLocale } from "./pick-locale";
|
||||
import type { LocaleAdapter } from "./types";
|
||||
|
||||
function makeAdapter(
|
||||
overrides: Partial<LocaleAdapter> = {},
|
||||
): LocaleAdapter {
|
||||
return {
|
||||
getUserChoice: () => null,
|
||||
getSystemPreferences: () => [],
|
||||
persist: () => {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("matchLocale", () => {
|
||||
it("returns DEFAULT_LOCALE when given an empty list", () => {
|
||||
expect(matchLocale([])).toBe("en");
|
||||
});
|
||||
|
||||
it("matches a clean supported tag", () => {
|
||||
expect(matchLocale(["zh-Hans"])).toBe("zh-Hans");
|
||||
expect(matchLocale(["en"])).toBe("en");
|
||||
});
|
||||
|
||||
it("collapses region-tagged BCP-47 to the supported base", () => {
|
||||
expect(matchLocale(["en-US"])).toBe("en");
|
||||
expect(matchLocale(["zh-Hans-CN"])).toBe("zh-Hans");
|
||||
});
|
||||
|
||||
it("falls back to DEFAULT_LOCALE when no candidate matches", () => {
|
||||
expect(matchLocale(["fr", "ja", "ko"])).toBe("en");
|
||||
});
|
||||
|
||||
it("zh-Hant (traditional) collapses to zh-Hans — same base subtag, better UX than English fallback", () => {
|
||||
expect(matchLocale(["zh-Hant"])).toBe("zh-Hans");
|
||||
});
|
||||
|
||||
it("uses the first supported candidate when multiple appear", () => {
|
||||
expect(matchLocale(["fr", "zh-Hans", "en"])).toBe("zh-Hans");
|
||||
});
|
||||
|
||||
it("returns DEFAULT_LOCALE for malformed BCP-47 tags rather than throwing", () => {
|
||||
expect(matchLocale(["----"])).toBe("en");
|
||||
expect(matchLocale(["x-private-only"])).toBe("en");
|
||||
});
|
||||
});
|
||||
|
||||
describe("pickLocale", () => {
|
||||
it("prefers explicit user choice over system signal", () => {
|
||||
const adapter = makeAdapter({
|
||||
getUserChoice: () => "zh-Hans",
|
||||
getSystemPreferences: () => ["en-US"],
|
||||
});
|
||||
expect(pickLocale(adapter)).toBe("zh-Hans");
|
||||
});
|
||||
|
||||
it("falls back to system preferences when no user choice", () => {
|
||||
const adapter = makeAdapter({
|
||||
getSystemPreferences: () => ["zh-Hans-CN", "en-US"],
|
||||
});
|
||||
expect(pickLocale(adapter)).toBe("zh-Hans");
|
||||
});
|
||||
|
||||
it("returns DEFAULT_LOCALE when neither choice nor preference yields a match", () => {
|
||||
const adapter = makeAdapter({
|
||||
getUserChoice: () => null,
|
||||
getSystemPreferences: () => ["fr", "ja"],
|
||||
});
|
||||
expect(pickLocale(adapter)).toBe("en");
|
||||
});
|
||||
|
||||
it("ignores empty-string user choice and falls through to system", () => {
|
||||
const adapter = makeAdapter({
|
||||
getUserChoice: () => "",
|
||||
getSystemPreferences: () => ["zh-Hans"],
|
||||
});
|
||||
expect(pickLocale(adapter)).toBe("zh-Hans");
|
||||
});
|
||||
});
|
||||
@@ -1,26 +0,0 @@
|
||||
import { match } from "@formatjs/intl-localematcher";
|
||||
import {
|
||||
DEFAULT_LOCALE,
|
||||
SUPPORTED_LOCALES,
|
||||
type LocaleAdapter,
|
||||
type SupportedLocale,
|
||||
} from "./types";
|
||||
|
||||
export function matchLocale(candidates: string[]): SupportedLocale {
|
||||
if (candidates.length === 0) return DEFAULT_LOCALE;
|
||||
try {
|
||||
return match(
|
||||
candidates,
|
||||
SUPPORTED_LOCALES,
|
||||
DEFAULT_LOCALE,
|
||||
) as SupportedLocale;
|
||||
} catch {
|
||||
return DEFAULT_LOCALE;
|
||||
}
|
||||
}
|
||||
|
||||
export function pickLocale(adapter: LocaleAdapter): SupportedLocale {
|
||||
const choice = adapter.getUserChoice();
|
||||
if (choice) return matchLocale([choice]);
|
||||
return matchLocale(adapter.getSystemPreferences());
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { I18nextProvider } from "react-i18next";
|
||||
import { createI18n } from "./create-i18n";
|
||||
import type { LocaleResources, SupportedLocale } from "./types";
|
||||
|
||||
export interface I18nProviderProps {
|
||||
locale: SupportedLocale;
|
||||
resources: Record<string, LocaleResources>;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function I18nProvider({
|
||||
locale,
|
||||
resources,
|
||||
children,
|
||||
}: I18nProviderProps) {
|
||||
// Lazy init via useState so the instance survives re-renders.
|
||||
// Locale + resources are determined at boot and never change at runtime —
|
||||
// language switching goes through window.location.reload().
|
||||
const [instance] = useState(() => createI18n(locale, resources));
|
||||
return <I18nextProvider i18n={instance}>{children}</I18nextProvider>;
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
// React-only i18n entry: depends on react-i18next, which calls
|
||||
// React.createContext() at module load. Importing this from a non-client
|
||||
// context (RSC / proxy.ts) will crash with "createContext is not a function"
|
||||
// because Next.js vendors a stripped React build for those contexts.
|
||||
// Always pair with "use client" or import only inside client trees.
|
||||
export { createI18n } from "./create-i18n";
|
||||
export { I18nProvider, type I18nProviderProps } from "./provider";
|
||||
export { LocaleAdapterProvider, useLocaleAdapter } from "./adapter-context";
|
||||
export { UserLocaleSync } from "./user-locale-sync";
|
||||
@@ -1,12 +0,0 @@
|
||||
export type SupportedLocale = "en" | "zh-Hans";
|
||||
|
||||
export const SUPPORTED_LOCALES: SupportedLocale[] = ["en", "zh-Hans"];
|
||||
export const DEFAULT_LOCALE: SupportedLocale = "en";
|
||||
|
||||
export type LocaleResources = Record<string, Record<string, unknown>>;
|
||||
|
||||
export interface LocaleAdapter {
|
||||
getUserChoice(): string | null;
|
||||
getSystemPreferences(): string[];
|
||||
persist(locale: SupportedLocale): void;
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAuthStore } from "../auth";
|
||||
import { useLocaleAdapter } from "./adapter-context";
|
||||
import { SUPPORTED_LOCALES, type SupportedLocale } from "./types";
|
||||
|
||||
// Pulls the server-stored `user.language` into the local locale adapter on
|
||||
// login. Without this, switching device (macOS → Windows, browser → desktop)
|
||||
// loses the user's language preference: pickLocale only consults the local
|
||||
// adapter (cookie / localStorage), never user.language.
|
||||
//
|
||||
// Mounts inside CoreProvider so it has access to the auth store + locale
|
||||
// adapter + i18n instance. Renders nothing.
|
||||
//
|
||||
// Loop safety: reload only fires when user.language is a supported locale AND
|
||||
// differs from the active i18n.language. After reload, pickLocale reads the
|
||||
// freshly-persisted value from the adapter, locales match, effect no-ops.
|
||||
export function UserLocaleSync() {
|
||||
const userLanguage = useAuthStore((s) => s.user?.language ?? null);
|
||||
const adapter = useLocaleAdapter();
|
||||
const { i18n } = useTranslation();
|
||||
|
||||
useEffect(() => {
|
||||
if (!userLanguage) return;
|
||||
if (!(SUPPORTED_LOCALES as readonly string[]).includes(userLanguage)) {
|
||||
return;
|
||||
}
|
||||
if (userLanguage === i18n.language) return;
|
||||
adapter.persist(userLanguage as SupportedLocale);
|
||||
if (typeof window !== "undefined") window.location.reload();
|
||||
}, [userLanguage, i18n.language, adapter]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -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
|
||||
@@ -318,27 +312,26 @@ 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,
|
||||
actor_type: comment.author_type,
|
||||
actor_id: comment.author_id,
|
||||
content: comment.content,
|
||||
parent_id: comment.parent_id,
|
||||
comment_type: comment.type,
|
||||
reactions: comment.reactions ?? [],
|
||||
attachments: comment.attachments ?? [],
|
||||
created_at: comment.created_at,
|
||||
updated_at: comment.updated_at,
|
||||
};
|
||||
qc.setQueriesData<TimelineCacheData>(
|
||||
{ queryKey: ["issues", "timeline", issueId] },
|
||||
(old) => prependToLatestPage(old, entry),
|
||||
qc.setQueryData<TimelineEntry[]>(
|
||||
issueKeys.timeline(issueId),
|
||||
(old) => {
|
||||
if (!old) return old;
|
||||
const entry: TimelineEntry = {
|
||||
type: "comment",
|
||||
id: comment.id,
|
||||
actor_type: comment.author_type,
|
||||
actor_id: comment.author_id,
|
||||
content: comment.content,
|
||||
parent_id: comment.parent_id,
|
||||
comment_type: comment.type,
|
||||
reactions: comment.reactions ?? [],
|
||||
attachments: comment.attachments ?? [],
|
||||
created_at: comment.created_at,
|
||||
updated_at: comment.updated_at,
|
||||
};
|
||||
if (old.some((e) => e.id === comment.id)) return old;
|
||||
return [...old, entry];
|
||||
},
|
||||
);
|
||||
},
|
||||
onSettled: () => {
|
||||
@@ -353,27 +346,18 @@ 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] },
|
||||
await qc.cancelQueries({ queryKey: issueKeys.timeline(issueId) });
|
||||
const prev = qc.getQueryData<TimelineEntry[]>(issueKeys.timeline(issueId));
|
||||
qc.setQueryData<TimelineEntry[]>(
|
||||
issueKeys.timeline(issueId),
|
||||
(old) =>
|
||||
mapAllEntries(old, (e) =>
|
||||
e.id === commentId ? { ...e, content } : e,
|
||||
),
|
||||
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)
|
||||
qc.setQueryData(issueKeys.timeline(issueId), ctx.prev);
|
||||
},
|
||||
onSettled: () => {
|
||||
qc.invalidateQueries({ queryKey: issueKeys.timeline(issueId) });
|
||||
@@ -386,45 +370,33 @@ 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<TimelineEntry[]>(issueKeys.timeline(issueId));
|
||||
|
||||
// Cascade: collect all child comment IDs across every loaded page.
|
||||
// Cascade: collect all child comment IDs
|
||||
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<TimelineEntry[]>(
|
||||
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)
|
||||
qc.setQueryData(issueKeys.timeline(issueId), ctx.prev);
|
||||
},
|
||||
onSettled: () => {
|
||||
qc.invalidateQueries({ queryKey: issueKeys.timeline(issueId) });
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
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 type { IssueStatus, ListIssuesParams, ListIssuesCache } from "../types";
|
||||
import { BOARD_STATUSES } from "./config";
|
||||
|
||||
export const issueKeys = {
|
||||
@@ -23,15 +17,7 @@ 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),
|
||||
timeline: (issueId: string) => ["issues", "timeline", issueId] as const,
|
||||
reactions: (issueId: string) => ["issues", "reactions", issueId] as const,
|
||||
subscribers: (issueId: string) =>
|
||||
["issues", "subscribers", issueId] as const,
|
||||
@@ -140,40 +126,10 @@ 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.
|
||||
*/
|
||||
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),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -24,7 +24,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",
|
||||
@@ -80,18 +79,12 @@
|
||||
"./utils": "./utils.ts",
|
||||
"./constants/*": "./constants/*.ts",
|
||||
"./platform": "./platform/index.ts",
|
||||
"./analytics": "./analytics/index.ts",
|
||||
"./i18n": "./i18n/index.ts",
|
||||
"./i18n/react": "./i18n/react.ts",
|
||||
"./i18n/browser": "./i18n/browser.ts"
|
||||
"./analytics": "./analytics/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@formatjs/intl-localematcher": "catalog:",
|
||||
"@tanstack/react-query": "catalog:",
|
||||
"@tanstack/react-query-devtools": "^5.96.2",
|
||||
"i18next": "catalog:",
|
||||
"posthog-js": "catalog:",
|
||||
"react-i18next": "catalog:",
|
||||
"zustand": "catalog:"
|
||||
},
|
||||
"peerDependencies": {
|
||||
@@ -100,7 +93,6 @@
|
||||
"devDependencies": {
|
||||
"@multica/tsconfig": "workspace:*",
|
||||
"@types/react": "catalog:",
|
||||
"jsdom": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
"vitest": "catalog:"
|
||||
}
|
||||
|
||||
@@ -5,11 +5,6 @@ import { ApiClient } from "../api/client";
|
||||
import { setApiInstance } from "../api";
|
||||
import { createAuthStore, registerAuthStore } from "../auth";
|
||||
import { createChatStore, registerChatStore } from "../chat";
|
||||
import {
|
||||
I18nProvider,
|
||||
LocaleAdapterProvider,
|
||||
UserLocaleSync,
|
||||
} from "../i18n/react";
|
||||
import { WSProvider } from "../realtime";
|
||||
import { QueryProvider } from "../provider";
|
||||
import { createLogger } from "../logger";
|
||||
@@ -70,19 +65,13 @@ export function CoreProvider({
|
||||
onLogin,
|
||||
onLogout,
|
||||
identity,
|
||||
locale,
|
||||
resources,
|
||||
localeAdapter,
|
||||
}: CoreProviderProps) {
|
||||
// Initialize singletons on first render only. Dependencies are read-once:
|
||||
// apiBaseUrl, storage, and callbacks are set at app boot and never change at runtime.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
useMemo(() => initCore(apiBaseUrl, storage, onLogin, onLogout, cookieAuth, identity), []);
|
||||
|
||||
// I18nProvider wraps everything else: server and client must use the same
|
||||
// (locale, resources) to avoid hydration mismatch. Language switching goes
|
||||
// through window.location.reload(), never client-side changeLanguage.
|
||||
const tree = (
|
||||
return (
|
||||
<QueryProvider>
|
||||
<AuthInitializer
|
||||
onLogin={onLogin}
|
||||
@@ -103,21 +92,4 @@ export function CoreProvider({
|
||||
</AuthInitializer>
|
||||
</QueryProvider>
|
||||
);
|
||||
|
||||
// UserLocaleSync requires a LocaleAdapter to persist; only mount it when
|
||||
// the host app provides one (web layout + desktop App both do).
|
||||
const withAdapter = localeAdapter ? (
|
||||
<LocaleAdapterProvider adapter={localeAdapter}>
|
||||
<UserLocaleSync />
|
||||
{tree}
|
||||
</LocaleAdapterProvider>
|
||||
) : (
|
||||
tree
|
||||
);
|
||||
|
||||
return (
|
||||
<I18nProvider locale={locale} resources={resources}>
|
||||
{withAdapter}
|
||||
</I18nProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,4 +5,3 @@ export { defaultStorage } from "./storage";
|
||||
export { createPersistStorage } from "./persist-storage";
|
||||
export { createWorkspaceAwareStorage, setCurrentWorkspace, getCurrentSlug, getCurrentWsId, subscribeToCurrentSlug, registerForWorkspaceRehydration } from "./workspace-storage";
|
||||
export { clearWorkspaceStorage } from "./storage-cleanup";
|
||||
export { isMac, modKey, enterKey, formatShortcut } from "./keyboard";
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
describe("keyboard platform helper", () => {
|
||||
it("renders Mac symbols when navigator.platform is MacIntel", async () => {
|
||||
vi.stubGlobal("navigator", { platform: "MacIntel" });
|
||||
const mod = await import("./keyboard");
|
||||
|
||||
expect(mod.isMac).toBe(true);
|
||||
expect(mod.modKey).toBe("⌘");
|
||||
expect(mod.enterKey).toBe("↵");
|
||||
expect(mod.formatShortcut(mod.modKey, "K")).toBe("⌘K");
|
||||
expect(mod.formatShortcut(mod.modKey, mod.enterKey)).toBe("⌘↵");
|
||||
});
|
||||
|
||||
it("renders Ctrl/Enter on Windows", async () => {
|
||||
vi.stubGlobal("navigator", { platform: "Win32" });
|
||||
const mod = await import("./keyboard");
|
||||
|
||||
expect(mod.isMac).toBe(false);
|
||||
expect(mod.modKey).toBe("Ctrl");
|
||||
expect(mod.enterKey).toBe("Enter");
|
||||
expect(mod.formatShortcut(mod.modKey, "K")).toBe("Ctrl+K");
|
||||
expect(mod.formatShortcut(mod.modKey, mod.enterKey)).toBe("Ctrl+Enter");
|
||||
});
|
||||
|
||||
it("renders Ctrl/Enter on Linux", async () => {
|
||||
vi.stubGlobal("navigator", { platform: "Linux x86_64" });
|
||||
const mod = await import("./keyboard");
|
||||
|
||||
expect(mod.isMac).toBe(false);
|
||||
expect(mod.modKey).toBe("Ctrl");
|
||||
expect(mod.formatShortcut("Ctrl", "Shift", "P")).toBe("Ctrl+Shift+P");
|
||||
});
|
||||
|
||||
it("falls back to non-Mac when navigator is unavailable (SSR)", async () => {
|
||||
vi.stubGlobal("navigator", undefined);
|
||||
const mod = await import("./keyboard");
|
||||
|
||||
expect(mod.isMac).toBe(false);
|
||||
expect(mod.modKey).toBe("Ctrl");
|
||||
});
|
||||
});
|
||||
@@ -1,24 +0,0 @@
|
||||
/**
|
||||
* Coarse platform detection for keyboard-shortcut display.
|
||||
*
|
||||
* Eagerly evaluated at module load. On the server (no `navigator`) this
|
||||
* resolves to `false`, so SSR always renders the non-Mac variant; on a
|
||||
* real Mac the value is true after hydration. Acceptable trade-off for
|
||||
* cosmetic shortcut hints — never gate functional behavior on this.
|
||||
*/
|
||||
export const isMac =
|
||||
typeof navigator !== "undefined" && /Mac/.test(navigator.platform);
|
||||
|
||||
/** Modifier key label — ⌘ on Mac, "Ctrl" elsewhere. */
|
||||
export const modKey: string = isMac ? "⌘" : "Ctrl";
|
||||
|
||||
/** Enter / return key label — ↵ on Mac, "Enter" elsewhere. */
|
||||
export const enterKey: string = isMac ? "↵" : "Enter";
|
||||
|
||||
/**
|
||||
* Join key labels for display. Mac compresses combos with no separator
|
||||
* ("⌘K", "⌘↵"); other platforms use "+" ("Ctrl+K", "Ctrl+Enter").
|
||||
*/
|
||||
export function formatShortcut(...keys: string[]): string {
|
||||
return keys.join(isMac ? "" : "+");
|
||||
}
|
||||
@@ -1,8 +1,3 @@
|
||||
import type {
|
||||
LocaleAdapter,
|
||||
LocaleResources,
|
||||
SupportedLocale,
|
||||
} from "../i18n";
|
||||
import type { StorageAdapter } from "../types/storage";
|
||||
|
||||
/** Identifies the calling client to the server. Threaded through to
|
||||
@@ -33,11 +28,4 @@ export interface CoreProviderProps {
|
||||
onLogout?: () => void;
|
||||
/** Identifies the calling client (web/desktop + version + os) to the server. */
|
||||
identity?: ClientIdentity;
|
||||
/** Active locale, determined server-side (web) or at app boot (desktop). */
|
||||
locale: SupportedLocale;
|
||||
/** i18next resources, server-preloaded for the active locale. */
|
||||
resources: Record<string, LocaleResources>;
|
||||
/** Locale adapter for persisting user choice (used by Settings switcher).
|
||||
* Optional because some shells (e.g. CLI auth pages) don't need switching. */
|
||||
localeAdapter?: LocaleAdapter;
|
||||
}
|
||||
|
||||
@@ -28,10 +28,8 @@ import {
|
||||
} from "../issues/ws-updaters";
|
||||
import { onInboxNew, onInboxInvalidate, onInboxIssueStatusChanged, onInboxIssueDeleted } from "../inbox/ws-updaters";
|
||||
import { inboxKeys } from "../inbox/queries";
|
||||
import { notificationPreferenceOptions } from "../notification-preferences/queries";
|
||||
import { workspaceKeys, workspaceListOptions } from "../workspace/queries";
|
||||
import { chatKeys } from "../chat/queries";
|
||||
import { useChatStore } from "../chat";
|
||||
import { resolvePostAuthDestination, useHasOnboarded } from "../paths";
|
||||
import type {
|
||||
MemberAddedPayload,
|
||||
@@ -208,7 +206,7 @@ export function useRealtimeSync(
|
||||
"subscriber:added", "subscriber:removed",
|
||||
"daemon:heartbeat",
|
||||
// Chat events are handled explicitly below; do not double-invalidate.
|
||||
"chat:message", "chat:done", "chat:session_read", "chat:session_deleted",
|
||||
"chat:message", "chat:done", "chat:session_read",
|
||||
// task:message stays out of the prefix path because it fires per
|
||||
// streamed message during a long run — invalidating the snapshot on
|
||||
// every message would flood the network. Specific chat handlers below
|
||||
@@ -269,7 +267,7 @@ export function useRealtimeSync(
|
||||
if (wsId) onIssueLabelsChanged(qc, wsId, issue_id, labels ?? []);
|
||||
});
|
||||
|
||||
const unsubInboxNew = ws.on("inbox:new", async (p) => {
|
||||
const unsubInboxNew = ws.on("inbox:new", (p) => {
|
||||
const { item } = p as InboxNewPayload;
|
||||
if (!item) return;
|
||||
const wsId = getCurrentWsId();
|
||||
@@ -279,22 +277,6 @@ export function useRealtimeSync(
|
||||
// styling is enough — no need to interrupt with a banner. `desktopAPI`
|
||||
// is injected by the preload script; its absence (web app) skips silently.
|
||||
if (typeof document !== "undefined" && document.hasFocus()) return;
|
||||
// Respect the user's system-notification preference. The Settings page
|
||||
// owns the only `useQuery` for this resource, so on a fresh app start
|
||||
// (or any session that hasn't visited Settings) the React Query cache
|
||||
// is empty — using `getQueryData` would silently default to "all" and
|
||||
// ignore the user's saved choice. `ensureQueryData` resolves to the
|
||||
// cached value if present and otherwise fetches once, populating the
|
||||
// cache for subsequent events. On network failure we fall through to
|
||||
// the default ("all") rather than swallow the banner entirely.
|
||||
if (wsId) {
|
||||
try {
|
||||
const prefData = await qc.ensureQueryData(notificationPreferenceOptions(wsId));
|
||||
if (prefData?.preferences?.system_notifications === "muted") return;
|
||||
} catch {
|
||||
// Fall through with default behavior.
|
||||
}
|
||||
}
|
||||
// Capture the source workspace slug at emit time. The user may switch
|
||||
// workspaces before clicking the banner (macOS Notification Center
|
||||
// holds banners), so routing must not read "current slug" at click
|
||||
@@ -643,31 +625,6 @@ export function useRealtimeSync(
|
||||
invalidateSessionLists();
|
||||
});
|
||||
|
||||
// chat:session_deleted fires after a hard delete. The originating tab has
|
||||
// already optimistically dropped the row via useDeleteChatSession; this
|
||||
// handler keeps OTHER tabs/devices in sync and also clears the active
|
||||
// session pointer so a deleted session doesn't keep the chat window
|
||||
// pointed at vanished messages.
|
||||
const unsubChatSessionDeleted = ws.on("chat:session_deleted", (p) => {
|
||||
const payload = p as { chat_session_id: string };
|
||||
chatWsLogger.info("chat:session_deleted (global)", payload);
|
||||
const id = getCurrentWsId();
|
||||
if (id) {
|
||||
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) });
|
||||
invalidatePendingAggregate();
|
||||
|
||||
const chatState = useChatStore.getState?.();
|
||||
if (chatState && chatState.activeSessionId === payload.chat_session_id) {
|
||||
chatState.setActiveSession(null);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubAny();
|
||||
unsubIssueUpdated();
|
||||
@@ -701,7 +658,6 @@ export function useRealtimeSync(
|
||||
unsubTaskCompleted();
|
||||
unsubTaskFailed();
|
||||
unsubChatSessionRead();
|
||||
unsubChatSessionDeleted();
|
||||
timers.forEach(clearTimeout);
|
||||
timers.clear();
|
||||
};
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { checkQuickCreateCliVersion } from "./cli-version";
|
||||
|
||||
describe("checkQuickCreateCliVersion", () => {
|
||||
it("returns ok for a tagged release at or above the minimum", () => {
|
||||
expect(checkQuickCreateCliVersion("v0.2.20").state).toBe("ok");
|
||||
expect(checkQuickCreateCliVersion("0.3.1").state).toBe("ok");
|
||||
});
|
||||
|
||||
it("returns too_old for a tagged release below the minimum", () => {
|
||||
expect(checkQuickCreateCliVersion("v0.2.15").state).toBe("too_old");
|
||||
});
|
||||
|
||||
it("returns missing for empty or unparsable input", () => {
|
||||
expect(checkQuickCreateCliVersion("").state).toBe("missing");
|
||||
expect(checkQuickCreateCliVersion(undefined).state).toBe("missing");
|
||||
expect(checkQuickCreateCliVersion("not-a-version").state).toBe("missing");
|
||||
});
|
||||
|
||||
it("treats git-describe dev builds as ok regardless of base tag", () => {
|
||||
expect(checkQuickCreateCliVersion("v0.2.15-235-gdaf0e935").state).toBe("ok");
|
||||
expect(checkQuickCreateCliVersion("v0.2.15-235-gdaf0e935-dirty").state).toBe("ok");
|
||||
expect(checkQuickCreateCliVersion("0.1.0-1-gabc1234").state).toBe("ok");
|
||||
});
|
||||
});
|
||||
@@ -24,14 +24,6 @@ export interface CliVersionCheck {
|
||||
|
||||
const SEMVER_RE = /v?(\d+)\.(\d+)\.(\d+)/;
|
||||
|
||||
// Matches the `git describe --tags --always --dirty` output for a build past
|
||||
// the latest tag, e.g. `v0.2.15-235-gdaf0e935` or `v0.2.15-235-gdaf0e935-dirty`.
|
||||
// Daemons built from source (Makefile `make build` / `make daemon`) report this
|
||||
// shape; tagged releases are bare semver. Treating dev-described daemons as OK
|
||||
// is what keeps `pnpm dev:desktop` + `make daemon` unblocked without weakening
|
||||
// the gate for staging or production users running stale stable releases.
|
||||
const DEV_DESCRIBE_RE = /^v?\d+\.\d+\.\d+-\d+-g[0-9a-fA-F]+/;
|
||||
|
||||
function parseSemver(raw: string): [number, number, number] | null {
|
||||
const m = SEMVER_RE.exec(raw.trim());
|
||||
if (!m) return null;
|
||||
@@ -48,14 +40,9 @@ function lessThan(a: [number, number, number], b: [number, number, number]) {
|
||||
* Check a daemon-reported CLI version string against the minimum. Returns
|
||||
* `"missing"` for empty/unparsable input (fail closed — same policy as the
|
||||
* server) and `"too_old"` for a parsable version below the threshold.
|
||||
* Dev-built daemons (git-describe shape) are always OK — the version string
|
||||
* itself is the shared signal, so frontend and server agree by construction.
|
||||
*/
|
||||
export function checkQuickCreateCliVersion(detected: string | undefined | null): CliVersionCheck {
|
||||
const current = (detected ?? "").trim();
|
||||
if (DEV_DESCRIBE_RE.test(current)) {
|
||||
return { state: "ok", current, min: MIN_QUICK_CREATE_CLI_VERSION };
|
||||
}
|
||||
const parsed = current ? parseSemver(current) : null;
|
||||
if (!parsed) {
|
||||
return { state: "missing", current, min: MIN_QUICK_CREATE_CLI_VERSION };
|
||||
|
||||
@@ -23,27 +23,4 @@ export interface TimelineEntry {
|
||||
comment_type?: string;
|
||||
reactions?: Reaction[];
|
||||
attachments?: Attachment[];
|
||||
/** 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 };
|
||||
|
||||
@@ -86,7 +86,6 @@ export interface SearchProjectsResponse {
|
||||
export interface UpdateMeRequest {
|
||||
name?: string;
|
||||
avatar_url?: string;
|
||||
language?: string;
|
||||
}
|
||||
|
||||
export interface CreateMemberRequest {
|
||||
|
||||
@@ -51,7 +51,6 @@ export type WSEventType =
|
||||
| "chat:message"
|
||||
| "chat:done"
|
||||
| "chat:session_read"
|
||||
| "chat:session_deleted"
|
||||
| "project:created"
|
||||
| "project:updated"
|
||||
| "project:deleted"
|
||||
@@ -281,10 +280,6 @@ export interface ChatSessionReadPayload {
|
||||
chat_session_id: string;
|
||||
}
|
||||
|
||||
export interface ChatSessionDeletedPayload {
|
||||
chat_session_id: string;
|
||||
}
|
||||
|
||||
export interface ProjectCreatedPayload {
|
||||
project: Project;
|
||||
}
|
||||
|
||||
@@ -41,12 +41,7 @@ export type { InboxItem, InboxSeverity, InboxItemType } from "./inbox";
|
||||
export type { NotificationGroupKey, NotificationGroupValue, NotificationPreferences, NotificationPreferenceResponse } from "./notification-preference";
|
||||
export type { Comment, CommentType, CommentAuthorType, Reaction } from "./comment";
|
||||
export type { Label, CreateLabelRequest, UpdateLabelRequest, ListLabelsResponse, IssueLabelsResponse } from "./label";
|
||||
export type {
|
||||
TimelineEntry,
|
||||
TimelinePage,
|
||||
TimelinePageParam,
|
||||
AssigneeFrequencyEntry,
|
||||
} from "./activity";
|
||||
export type { TimelineEntry, AssigneeFrequencyEntry } from "./activity";
|
||||
export type { IssueSubscriber } from "./subscriber";
|
||||
export type * from "./events";
|
||||
export type * from "./api";
|
||||
|
||||
@@ -3,8 +3,7 @@ export type NotificationGroupKey =
|
||||
| "status_changes"
|
||||
| "comments"
|
||||
| "updates"
|
||||
| "agent_activity"
|
||||
| "system_notifications";
|
||||
| "agent_activity";
|
||||
|
||||
export type NotificationGroupValue = "all" | "muted";
|
||||
|
||||
|
||||
@@ -16,7 +16,6 @@ export interface Project {
|
||||
updated_at: string;
|
||||
issue_count: number;
|
||||
done_count: number;
|
||||
resource_count: number;
|
||||
}
|
||||
|
||||
export interface CreateProjectRequest {
|
||||
|
||||
@@ -49,8 +49,6 @@ export interface User {
|
||||
* 'retry_after_error') can be added without churning this type.
|
||||
*/
|
||||
starter_content_state: string | null;
|
||||
/** Preferred UI language. null means "follow client/system". */
|
||||
language: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
17
packages/eslint-config/react.js
vendored
17
packages/eslint-config/react.js
vendored
@@ -5,13 +5,16 @@ import reactHooksPlugin from "eslint-plugin-react-hooks";
|
||||
/** @type {import("eslint").Linter.Config[]} */
|
||||
export default [
|
||||
...baseConfig,
|
||||
// React rules (JSX only)
|
||||
{
|
||||
files: ["**/*.{jsx,tsx}"],
|
||||
plugins: { react: reactPlugin },
|
||||
plugins: {
|
||||
react: reactPlugin,
|
||||
"react-hooks": reactHooksPlugin,
|
||||
},
|
||||
rules: {
|
||||
...reactPlugin.configs.recommended.rules,
|
||||
...reactPlugin.configs["jsx-runtime"].rules,
|
||||
...reactHooksPlugin.configs["recommended-latest"].rules,
|
||||
"react/prop-types": "off",
|
||||
"react/no-unknown-property": "off",
|
||||
},
|
||||
@@ -19,14 +22,4 @@ export default [
|
||||
react: { version: "detect" },
|
||||
},
|
||||
},
|
||||
// React Hooks rules apply to .ts files too — hooks (useEffect, useCallback,
|
||||
// useMemo) can live in plain .ts modules and we want exhaustive-deps to
|
||||
// run + inline disable comments to resolve.
|
||||
{
|
||||
files: ["**/*.{ts,tsx,js,jsx}"],
|
||||
plugins: { "react-hooks": reactHooksPlugin },
|
||||
rules: {
|
||||
...reactHooksPlugin.configs["recommended-latest"].rules,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -18,7 +18,6 @@ import { ActorAvatar } from "../../common/actor-avatar";
|
||||
import { availabilityConfig, workloadConfig } from "../presence";
|
||||
import { AgentRowActions } from "./agent-row-actions";
|
||||
import { Sparkline } from "./sparkline";
|
||||
import { useT } from "../../i18n";
|
||||
|
||||
// Per-row data shape. We assemble agent + runtime + presence + activity +
|
||||
// run count into one struct at the page level so the column cells just
|
||||
@@ -66,32 +65,22 @@ const COL_WIDTHS = {
|
||||
actions: 60,
|
||||
} as const;
|
||||
|
||||
type ColumnHeaderT = ReturnType<typeof useT<"agents">>["t"];
|
||||
|
||||
function makeHeaderRenderer(t: ColumnHeaderT, key: "agent" | "status" | "workload" | "runtime" | "activity_7d" | "runs") {
|
||||
return key === "runs"
|
||||
? () => <div className="text-right">{t(($) => $.columns.runs)}</div>
|
||||
: () => t(($) => $.columns[key]);
|
||||
}
|
||||
|
||||
export function createAgentColumns({
|
||||
onDuplicate,
|
||||
t,
|
||||
}: {
|
||||
onDuplicate: (agent: Agent) => void;
|
||||
t: ColumnHeaderT;
|
||||
}): ColumnDef<AgentRow>[] {
|
||||
return [
|
||||
{
|
||||
id: "agent",
|
||||
header: makeHeaderRenderer(t, "agent"),
|
||||
header: "Agent",
|
||||
size: COL_WIDTHS.agent,
|
||||
meta: { grow: true },
|
||||
cell: ({ row }) => <AgentNameCell row={row.original} />,
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
header: makeHeaderRenderer(t, "status"),
|
||||
header: "Status",
|
||||
size: COL_WIDTHS.status,
|
||||
cell: ({ row }) => {
|
||||
if (row.original.agent.archived_at) {
|
||||
@@ -102,7 +91,7 @@ export function createAgentColumns({
|
||||
},
|
||||
{
|
||||
id: "workload",
|
||||
header: makeHeaderRenderer(t, "workload"),
|
||||
header: "Workload",
|
||||
size: COL_WIDTHS.workload,
|
||||
cell: ({ row }) => {
|
||||
if (row.original.agent.archived_at) {
|
||||
@@ -113,20 +102,20 @@ export function createAgentColumns({
|
||||
},
|
||||
{
|
||||
id: "runtime",
|
||||
header: makeHeaderRenderer(t, "runtime"),
|
||||
header: "Runtime",
|
||||
size: COL_WIDTHS.runtime,
|
||||
meta: { grow: true },
|
||||
cell: ({ row }) => <RuntimeCell row={row.original} />,
|
||||
},
|
||||
{
|
||||
id: "activity",
|
||||
header: makeHeaderRenderer(t, "activity_7d"),
|
||||
header: "Activity (7d)",
|
||||
size: COL_WIDTHS.activity,
|
||||
cell: ({ row }) => <ActivityCell row={row.original} />,
|
||||
},
|
||||
{
|
||||
id: "runs",
|
||||
header: makeHeaderRenderer(t, "runs"),
|
||||
header: () => <div className="text-right">Runs</div>,
|
||||
size: COL_WIDTHS.runs,
|
||||
cell: ({ row }) => (
|
||||
<div className="text-right font-mono text-xs tabular-nums text-muted-foreground">
|
||||
@@ -165,7 +154,6 @@ export function createAgentColumns({
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function AgentNameCell({ row }: { row: AgentRow }) {
|
||||
const { t } = useT("agents");
|
||||
const { agent, ownerIdToShow, isOwnedByMe } = row;
|
||||
const isArchived = !!agent.archived_at;
|
||||
const isPrivate = agent.visibility === "private";
|
||||
@@ -202,7 +190,7 @@ function AgentNameCell({ row }: { row: AgentRow }) {
|
||||
)}
|
||||
{isOwnedByMe && !ownerIdToShow && (
|
||||
<span className="shrink-0 rounded bg-muted px-1 text-[10px] font-medium text-muted-foreground">
|
||||
{t(($) => $.row.you)}
|
||||
You
|
||||
</span>
|
||||
)}
|
||||
{ownerIdToShow && (
|
||||
@@ -214,7 +202,7 @@ function AgentNameCell({ row }: { row: AgentRow }) {
|
||||
)}
|
||||
{isArchived && (
|
||||
<span className="shrink-0 rounded-md bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground">
|
||||
{t(($) => $.row.archived)}
|
||||
Archived
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -225,7 +213,7 @@ function AgentNameCell({ row }: { row: AgentRow }) {
|
||||
: "italic text-muted-foreground/50"
|
||||
}`}
|
||||
>
|
||||
{agent.description || t(($) => $.row.no_description)}
|
||||
{agent.description || "No description"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -237,7 +225,6 @@ function AvailabilityCell({
|
||||
}: {
|
||||
presence: AgentPresenceDetail | null | undefined;
|
||||
}) {
|
||||
const { t } = useT("agents");
|
||||
if (!presence) {
|
||||
return (
|
||||
<span className="inline-flex h-3 w-16 animate-pulse rounded bg-muted/60" />
|
||||
@@ -247,7 +234,7 @@ function AvailabilityCell({
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span className={`h-1.5 w-1.5 shrink-0 rounded-full ${av.dotClass}`} />
|
||||
<span className={`text-xs ${av.textClass}`}>{t(($) => $.availability[presence.availability])}</span>
|
||||
<span className={`text-xs ${av.textClass}`}>{av.label}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -257,7 +244,6 @@ function WorkloadCell({
|
||||
}: {
|
||||
presence: AgentPresenceDetail | null | undefined;
|
||||
}) {
|
||||
const { t } = useT("agents");
|
||||
if (!presence) {
|
||||
return (
|
||||
<span className="inline-flex h-3 w-20 animate-pulse rounded bg-muted/60" />
|
||||
@@ -300,7 +286,7 @@ function WorkloadCell({
|
||||
className={`h-3 w-3 shrink-0 ${labelTone} ${isWorking ? "animate-spin" : ""}`}
|
||||
/>
|
||||
)}
|
||||
<span className={`shrink-0 ${labelTone}`}>{t(($) => $.workload[presence.workload])}</span>
|
||||
<span className={`shrink-0 ${labelTone}`}>{wl.label}</span>
|
||||
{counts && (
|
||||
<span className="truncate text-muted-foreground">{counts}</span>
|
||||
)}
|
||||
@@ -309,11 +295,10 @@ function WorkloadCell({
|
||||
}
|
||||
|
||||
function RuntimeCell({ row }: { row: AgentRow }) {
|
||||
const { t } = useT("agents");
|
||||
const { agent, runtime } = row;
|
||||
const isCloud = agent.runtime_mode === "cloud";
|
||||
const RuntimeIcon = isCloud ? Cloud : Monitor;
|
||||
const runtimeLabel = runtime?.name ?? (isCloud ? t(($) => $.row.fallback_runtime_cloud) : t(($) => $.row.fallback_runtime_local));
|
||||
const runtimeLabel = runtime?.name ?? (isCloud ? "Cloud" : "Local");
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-1.5 text-xs text-muted-foreground">
|
||||
@@ -361,31 +346,24 @@ function ActivityCell({ row }: { row: AgentRow }) {
|
||||
}
|
||||
|
||||
function ActivityTooltipBody({ activity }: { activity: AgentActivity }) {
|
||||
const { t } = useT("agents");
|
||||
const summary = summarizeActivityWindow(activity, 7);
|
||||
const { totalRuns, totalFailed } = summary;
|
||||
const { daysSinceCreated } = activity;
|
||||
|
||||
const isPartial = daysSinceCreated < 7;
|
||||
const headerText = isPartial
|
||||
? daysSinceCreated === 0
|
||||
? t(($) => $.activity_tooltip.created_today)
|
||||
: t(($) => $.activity_tooltip.created_days_ago, { count: daysSinceCreated })
|
||||
: t(($) => $.activity_tooltip.last_7_days);
|
||||
? `Created ${daysSinceCreated === 0 ? "today" : `${daysSinceCreated} day${daysSinceCreated === 1 ? "" : "s"} ago`}`
|
||||
: "Last 7 days";
|
||||
|
||||
let bodyText: string;
|
||||
if (totalRuns === 0) {
|
||||
bodyText = t(($) => $.activity_tooltip.no_activity);
|
||||
bodyText = "No activity";
|
||||
} else {
|
||||
const runsText = t(($) => $.activity_tooltip.runs, { count: totalRuns });
|
||||
const failedFragment =
|
||||
totalFailed > 0
|
||||
? t(($) => $.activity_tooltip.failed_suffix, {
|
||||
count: totalFailed,
|
||||
percent: Math.round((totalFailed / totalRuns) * 100),
|
||||
})
|
||||
? ` · ${totalFailed} failed (${Math.round((totalFailed / totalRuns) * 100)}%)`
|
||||
: "";
|
||||
bodyText = `${runsText}${failedFragment}`;
|
||||
bodyText = `${totalRuns} run${totalRuns === 1 ? "" : "s"}${failedFragment}`;
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -38,7 +38,6 @@ import {
|
||||
import { PropRow } from "../../common/prop-row";
|
||||
import { availabilityConfig } from "../presence";
|
||||
import { CharCounter } from "./char-counter";
|
||||
import { useT } from "../../i18n";
|
||||
import { ConcurrencyPicker } from "./inspector/concurrency-picker";
|
||||
import { ModelPicker } from "./inspector/model-picker";
|
||||
import { RuntimePicker } from "./inspector/runtime-picker";
|
||||
@@ -90,12 +89,11 @@ export function AgentDetailInspector({
|
||||
canEdit,
|
||||
onUpdate,
|
||||
}: InspectorProps) {
|
||||
const { t } = useT("agents");
|
||||
const update = (data: Record<string, unknown>) => onUpdate(agent.id, data);
|
||||
const isOnline = runtime?.status === "online";
|
||||
|
||||
return (
|
||||
<aside className="flex w-full flex-col rounded-lg border bg-background md:h-full md:min-h-0 md:overflow-y-auto">
|
||||
<aside className="flex h-full min-h-0 w-full flex-col overflow-y-auto rounded-lg border bg-background">
|
||||
{/* Identity */}
|
||||
<div className="flex flex-col gap-3 border-b px-5 pb-5 pt-5">
|
||||
<AvatarEditor agent={agent} canEdit={canEdit} onUpdate={update} />
|
||||
@@ -110,8 +108,8 @@ export function AgentDetailInspector({
|
||||
{/* Properties — editable when canEdit. When the current user lacks
|
||||
permission, each picker self-renders a static read-only display so
|
||||
the value is visible but not interactive. */}
|
||||
<Section label={t(($) => $.inspector.section_properties)}>
|
||||
<PropRow label={t(($) => $.inspector.prop_runtime)} interactive={false}>
|
||||
<Section label="Properties">
|
||||
<PropRow label="Runtime" interactive={false}>
|
||||
<RuntimePicker
|
||||
value={agent.runtime_id}
|
||||
runtimes={runtimes}
|
||||
@@ -121,7 +119,7 @@ export function AgentDetailInspector({
|
||||
onChange={(id) => update({ runtime_id: id })}
|
||||
/>
|
||||
</PropRow>
|
||||
<PropRow label={t(($) => $.inspector.prop_model)} interactive={false}>
|
||||
<PropRow label="Model" interactive={false}>
|
||||
<ModelPicker
|
||||
runtimeId={agent.runtime_id}
|
||||
runtimeOnline={!!isOnline}
|
||||
@@ -130,14 +128,14 @@ export function AgentDetailInspector({
|
||||
onChange={(m) => update({ model: m })}
|
||||
/>
|
||||
</PropRow>
|
||||
<PropRow label={t(($) => $.inspector.prop_visibility)} interactive={false}>
|
||||
<PropRow label="Visibility" interactive={false}>
|
||||
<VisibilityPicker
|
||||
value={agent.visibility}
|
||||
canEdit={canEdit}
|
||||
onChange={(v) => update({ visibility: v })}
|
||||
/>
|
||||
</PropRow>
|
||||
<PropRow label={t(($) => $.inspector.prop_concurrency)} interactive={false}>
|
||||
<PropRow label="Concurrency" interactive={false}>
|
||||
<ConcurrencyPicker
|
||||
value={agent.max_concurrent_tasks}
|
||||
canEdit={canEdit}
|
||||
@@ -147,9 +145,9 @@ export function AgentDetailInspector({
|
||||
</Section>
|
||||
|
||||
{/* Details — read-only (no hover, no chip styling — these aren't clickable) */}
|
||||
<Section label={t(($) => $.inspector.section_details)}>
|
||||
<Section label="Details">
|
||||
{owner && (
|
||||
<PropRow label={t(($) => $.inspector.prop_owner)} interactive={false}>
|
||||
<PropRow label="Owner" interactive={false}>
|
||||
<span className="flex min-w-0 items-center gap-1.5">
|
||||
<ActorAvatar
|
||||
actorType="member"
|
||||
@@ -160,12 +158,12 @@ export function AgentDetailInspector({
|
||||
</span>
|
||||
</PropRow>
|
||||
)}
|
||||
<PropRow label={t(($) => $.inspector.prop_created)} interactive={false}>
|
||||
<PropRow label="Created" interactive={false}>
|
||||
<span className="text-muted-foreground">
|
||||
{timeAgo(agent.created_at)}
|
||||
</span>
|
||||
</PropRow>
|
||||
<PropRow label={t(($) => $.inspector.prop_updated)} interactive={false}>
|
||||
<PropRow label="Updated" interactive={false}>
|
||||
<span className="text-muted-foreground">
|
||||
{timeAgo(agent.updated_at)}
|
||||
</span>
|
||||
@@ -176,7 +174,7 @@ export function AgentDetailInspector({
|
||||
<div className="flex flex-col border-b px-5 py-4">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<span className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
|
||||
{t(($) => $.inspector.section_skills)}
|
||||
Skills
|
||||
</span>
|
||||
<span className="font-mono text-[10px] tabular-nums text-muted-foreground/70">
|
||||
{agent.skills.length}
|
||||
@@ -234,7 +232,6 @@ function AvatarEditor({
|
||||
canEdit: boolean;
|
||||
onUpdate: (data: Record<string, unknown>) => Promise<void>;
|
||||
}) {
|
||||
const { t } = useT("agents");
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const { upload, uploading } = useFileUpload(api);
|
||||
|
||||
@@ -259,9 +256,9 @@ function AvatarEditor({
|
||||
const result = await upload(file);
|
||||
if (!result) return;
|
||||
await onUpdate({ avatar_url: result.link });
|
||||
toast.success(t(($) => $.inspector.avatar_updated_toast));
|
||||
toast.success("Avatar updated");
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : t(($) => $.inspector.avatar_upload_failed_toast));
|
||||
toast.error(err instanceof Error ? err.message : "Failed to upload avatar");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -274,7 +271,7 @@ function AvatarEditor({
|
||||
className="group relative h-14 w-14 shrink-0 overflow-hidden rounded-lg bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={uploading}
|
||||
aria-label={t(($) => $.inspector.change_avatar_aria)}
|
||||
aria-label="Change avatar"
|
||||
>
|
||||
<ActorAvatar
|
||||
actorType="agent"
|
||||
@@ -310,7 +307,6 @@ function NameAndDescription({
|
||||
canEdit: boolean;
|
||||
onUpdate: (data: Record<string, unknown>) => Promise<void>;
|
||||
}) {
|
||||
const { t } = useT("agents");
|
||||
if (!canEdit) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
@@ -323,7 +319,7 @@ function NameAndDescription({
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs italic leading-relaxed text-muted-foreground/50">
|
||||
{t(($) => $.inspector.no_description_placeholder)}
|
||||
No description
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -336,9 +332,9 @@ function NameAndDescription({
|
||||
value={agent.name}
|
||||
onSave={(v) => onUpdate({ name: v.trim() })}
|
||||
kind="input"
|
||||
title={t(($) => $.inspector.rename_title)}
|
||||
placeholder={t(($) => $.inspector.rename_placeholder)}
|
||||
validate={(v) => (v.trim().length > 0 ? null : t(($) => $.inspector.rename_required))}
|
||||
title="Rename agent"
|
||||
placeholder="Agent name"
|
||||
validate={(v) => (v.trim().length > 0 ? null : "Name is required")}
|
||||
>
|
||||
{(triggerProps) => (
|
||||
<button
|
||||
@@ -379,7 +375,6 @@ function DescriptionEditor({
|
||||
value: string;
|
||||
onSave: (next: string) => Promise<void>;
|
||||
}) {
|
||||
const { t } = useT("agents");
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
@@ -392,7 +387,7 @@ function DescriptionEditor({
|
||||
{value ? (
|
||||
<span className="text-muted-foreground">{value}</span>
|
||||
) : (
|
||||
<span className="italic text-muted-foreground/50">{t(($) => $.inspector.no_description_placeholder)}</span>
|
||||
<span className="italic text-muted-foreground/50">No description</span>
|
||||
)}
|
||||
<Pencil className="mt-0.5 h-3 w-3 shrink-0 text-muted-foreground/0 transition-colors group-hover:text-muted-foreground" />
|
||||
</button>
|
||||
@@ -421,7 +416,6 @@ function DescriptionEditorBody({
|
||||
onSave: (next: string) => Promise<void>;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useT("agents");
|
||||
const [draft, setDraft] = useState(initialValue);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
@@ -445,14 +439,14 @@ function DescriptionEditorBody({
|
||||
return (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t(($) => $.inspector.edit_description_title)}</DialogTitle>
|
||||
<DialogTitle>Edit description</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-2">
|
||||
<textarea
|
||||
autoFocus
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
placeholder={t(($) => $.inspector.description_placeholder)}
|
||||
placeholder="What does this agent do?"
|
||||
rows={6}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
@@ -472,14 +466,14 @@ function DescriptionEditorBody({
|
||||
onClick={onClose}
|
||||
disabled={saving}
|
||||
>
|
||||
{t(($) => $.inspector.cancel)}
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => void commit()}
|
||||
disabled={saving || overLimit || !dirty}
|
||||
>
|
||||
{saving ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : t(($) => $.inspector.save)}
|
||||
{saving ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : "Save"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
@@ -508,7 +502,6 @@ function InlineEditPopover({
|
||||
onClick: (e: React.MouseEvent) => void;
|
||||
}) => ReactNode;
|
||||
}) {
|
||||
const { t } = useT("agents");
|
||||
const [open, setOpen] = useState(false);
|
||||
const [draft, setDraft] = useState(value);
|
||||
const [saving, setSaving] = useState(false);
|
||||
@@ -598,7 +591,7 @@ function InlineEditPopover({
|
||||
onClick={() => setOpen(false)}
|
||||
disabled={saving}
|
||||
>
|
||||
{t(($) => $.inspector.cancel)}
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -608,7 +601,7 @@ function InlineEditPopover({
|
||||
{saving ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
t(($) => $.inspector.save)
|
||||
"Save"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -627,20 +620,22 @@ function PresenceBadge({
|
||||
}: {
|
||||
presence: AgentPresenceDetail | null | undefined;
|
||||
}) {
|
||||
const { t } = useT("agents");
|
||||
if (!presence) {
|
||||
return (
|
||||
<span className="inline-flex h-5 w-20 animate-pulse rounded-md bg-muted" />
|
||||
);
|
||||
}
|
||||
const av = availabilityConfig[presence.availability];
|
||||
// Last-task chip / failure copy intentionally omitted on the detail page
|
||||
// — the Recent work panel below shows the same data with full context.
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<span
|
||||
className={`inline-flex items-center gap-1.5 rounded-md border px-1.5 py-0.5 text-xs ${av.textClass}`}
|
||||
>
|
||||
<span className={`h-1.5 w-1.5 rounded-full ${av.dotClass}`} />
|
||||
{t(($) => $.availability[presence.availability])}
|
||||
{av.label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -47,14 +47,12 @@ import { PageHeader } from "../../layout/page-header";
|
||||
import { availabilityConfig } from "../presence";
|
||||
import { AgentDetailInspector } from "./agent-detail-inspector";
|
||||
import { AgentOverviewPane } from "./agent-overview-pane";
|
||||
import { useT } from "../../i18n";
|
||||
|
||||
interface AgentDetailPageProps {
|
||||
agentId: string;
|
||||
}
|
||||
|
||||
export function AgentDetailPage({ agentId }: AgentDetailPageProps) {
|
||||
const { t } = useT("agents");
|
||||
const wsId = useWorkspaceId();
|
||||
const paths = useWorkspacePaths();
|
||||
const navigation = useNavigation();
|
||||
@@ -90,9 +88,9 @@ export function AgentDetailPage({ agentId }: AgentDetailPageProps) {
|
||||
try {
|
||||
await api.updateAgent(id, data as UpdateAgentRequest);
|
||||
qc.invalidateQueries({ queryKey: workspaceKeys.agents(wsId) });
|
||||
toast.success(t(($) => $.detail.agent_updated_toast));
|
||||
toast.success("Agent updated");
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : t(($) => $.detail.update_failed_toast));
|
||||
toast.error(e instanceof Error ? e.message : "Failed to update agent");
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
@@ -101,9 +99,9 @@ export function AgentDetailPage({ agentId }: AgentDetailPageProps) {
|
||||
try {
|
||||
await api.archiveAgent(id);
|
||||
qc.invalidateQueries({ queryKey: workspaceKeys.agents(wsId) });
|
||||
toast.success(t(($) => $.detail.agent_archived_toast));
|
||||
toast.success("Agent archived");
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : t(($) => $.detail.archive_failed_toast));
|
||||
toast.error(e instanceof Error ? e.message : "Failed to archive agent");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -111,9 +109,9 @@ export function AgentDetailPage({ agentId }: AgentDetailPageProps) {
|
||||
try {
|
||||
await api.restoreAgent(id);
|
||||
qc.invalidateQueries({ queryKey: workspaceKeys.agents(wsId) });
|
||||
toast.success(t(($) => $.detail.agent_restored_toast));
|
||||
toast.success("Agent restored");
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : t(($) => $.detail.restore_failed_toast));
|
||||
toast.error(e instanceof Error ? e.message : "Failed to restore agent");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -126,15 +124,15 @@ export function AgentDetailPage({ agentId }: AgentDetailPageProps) {
|
||||
if (!agent) {
|
||||
return (
|
||||
<div className="flex flex-1 min-h-0 flex-col">
|
||||
<BackHeader paths={paths.agents()} title={t(($) => $.detail.back_to_agents)} />
|
||||
<BackHeader paths={paths.agents()} title="Agents" />
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-3 px-6 py-16 text-center">
|
||||
<AlertCircle className="h-8 w-8 text-destructive" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">{t(($) => $.detail.not_found_title)}</p>
|
||||
<p className="text-sm font-medium">Agent not found</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{agentsError instanceof Error
|
||||
? agentsError.message
|
||||
: t(($) => $.detail.not_found_default)}
|
||||
: "This agent may have been archived or deleted."}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -144,14 +142,14 @@ export function AgentDetailPage({ agentId }: AgentDetailPageProps) {
|
||||
size="sm"
|
||||
onClick={() => refetchAgents()}
|
||||
>
|
||||
{t(($) => $.detail.try_again)}
|
||||
Try again
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={() => navigation.push(paths.agents())}
|
||||
>
|
||||
{t(($) => $.detail.back_to_agents_full)}
|
||||
Back to agents
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -191,7 +189,7 @@ export function AgentDetailPage({ agentId }: AgentDetailPageProps) {
|
||||
<div className="flex shrink-0 items-center gap-2 border-b bg-muted/50 px-6 py-2 text-xs text-muted-foreground">
|
||||
<AlertCircle className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="flex-1">
|
||||
{t(($) => $.detail.archived_banner)}
|
||||
This agent is archived. It cannot be assigned or mentioned.
|
||||
</span>
|
||||
{canEdit.allowed && (
|
||||
<Button
|
||||
@@ -200,13 +198,13 @@ export function AgentDetailPage({ agentId }: AgentDetailPageProps) {
|
||||
className="h-6 text-xs"
|
||||
onClick={() => handleRestore(agent.id)}
|
||||
>
|
||||
{t(($) => $.detail.restore)}
|
||||
Restore
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-1 min-h-0 flex-col gap-3 overflow-y-auto p-3 md:grid md:grid-cols-[320px_minmax(0,1fr)] md:gap-4 md:overflow-hidden md:p-6">
|
||||
<div className="grid flex-1 min-h-0 grid-cols-[320px_minmax(0,1fr)] gap-4 p-6">
|
||||
<AgentDetailInspector
|
||||
agent={agent}
|
||||
runtime={runtime}
|
||||
@@ -240,10 +238,12 @@ export function AgentDetailPage({ agentId }: AgentDetailPageProps) {
|
||||
</div>
|
||||
<DialogHeader className="flex-1 gap-1">
|
||||
<DialogTitle className="text-sm font-semibold">
|
||||
{t(($) => $.detail.archive_dialog_title)}
|
||||
Archive agent?
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-xs">
|
||||
{t(($) => $.detail.archive_dialog_description, { name: agent.name })}
|
||||
"{agent.name}" will be archived. It won't be
|
||||
assignable or mentionable, but all history is preserved. You
|
||||
can restore it later.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
</div>
|
||||
@@ -252,7 +252,7 @@ export function AgentDetailPage({ agentId }: AgentDetailPageProps) {
|
||||
variant="ghost"
|
||||
onClick={() => setConfirmArchive(false)}
|
||||
>
|
||||
{t(($) => $.detail.archive_dialog_cancel)}
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
@@ -262,7 +262,7 @@ export function AgentDetailPage({ agentId }: AgentDetailPageProps) {
|
||||
navigation.push(paths.agents());
|
||||
}}
|
||||
>
|
||||
{t(($) => $.detail.archive_dialog_confirm)}
|
||||
Archive
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
@@ -285,11 +285,8 @@ function DetailHeader({
|
||||
canArchive: boolean;
|
||||
onArchive: () => void;
|
||||
}) {
|
||||
const { t } = useT("agents");
|
||||
const isArchived = !!agent.archived_at;
|
||||
const av = presence
|
||||
? { ...availabilityConfig[presence.availability], label: t(($) => $.availability[presence.availability]) }
|
||||
: null;
|
||||
const av = presence ? availabilityConfig[presence.availability] : null;
|
||||
// Last-task state is intentionally not surfaced in the header — the
|
||||
// Recent work section on this page already shows the same information
|
||||
// (and richer: titles, timestamps, error messages). Showing "Completed"
|
||||
@@ -303,7 +300,7 @@ function DetailHeader({
|
||||
className="inline-flex h-7 items-center gap-1 rounded-md px-2 text-xs text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<ArrowLeft className="h-3.5 w-3.5" />
|
||||
{t(($) => $.detail.back_to_agents)}
|
||||
Agents
|
||||
</AppLink>
|
||||
<span className="text-muted-foreground/40">/</span>
|
||||
<h1 className="truncate text-sm font-medium">{agent.name}</h1>
|
||||
@@ -330,7 +327,7 @@ function DetailHeader({
|
||||
onClick={onArchive}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
{t(($) => $.detail.more_archive)}
|
||||
Archive Agent
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
@@ -361,7 +358,7 @@ function DetailLoadingSkeleton() {
|
||||
<PageHeader className="px-5">
|
||||
<Skeleton className="h-5 w-48" />
|
||||
</PageHeader>
|
||||
<div className="flex flex-1 min-h-0 flex-col gap-3 overflow-y-auto p-3 md:grid md:grid-cols-[320px_minmax(0,1fr)] md:gap-4 md:overflow-hidden md:p-6">
|
||||
<div className="grid flex-1 min-h-0 grid-cols-[320px_minmax(0,1fr)] gap-4 p-6">
|
||||
<div className="flex flex-col gap-4 rounded-lg border p-5">
|
||||
<Skeleton className="h-14 w-14 rounded-lg" />
|
||||
<Skeleton className="h-5 w-40" />
|
||||
|
||||
@@ -24,7 +24,6 @@ import { InstructionsTab } from "./tabs/instructions-tab";
|
||||
import { SkillsTab } from "./tabs/skills-tab";
|
||||
import { EnvTab } from "./tabs/env-tab";
|
||||
import { CustomArgsTab } from "./tabs/custom-args-tab";
|
||||
import { useT } from "../../i18n";
|
||||
|
||||
type DetailTab =
|
||||
| "activity"
|
||||
@@ -33,23 +32,16 @@ type DetailTab =
|
||||
| "env"
|
||||
| "custom_args";
|
||||
|
||||
const TAB_LABEL_KEY: Record<DetailTab, "activity" | "instructions" | "skills" | "environment" | "custom_args"> = {
|
||||
activity: "activity",
|
||||
instructions: "instructions",
|
||||
skills: "skills",
|
||||
env: "environment",
|
||||
custom_args: "custom_args",
|
||||
};
|
||||
|
||||
const detailTabs: {
|
||||
id: DetailTab;
|
||||
label: string;
|
||||
icon: typeof FileText;
|
||||
}[] = [
|
||||
{ id: "activity", icon: Activity },
|
||||
{ id: "instructions", icon: FileText },
|
||||
{ id: "skills", icon: BookOpenText },
|
||||
{ id: "env", icon: KeyRound },
|
||||
{ id: "custom_args", icon: Terminal },
|
||||
{ id: "activity", label: "Activity", icon: Activity },
|
||||
{ id: "instructions", label: "Instructions", icon: FileText },
|
||||
{ id: "skills", label: "Skills", icon: BookOpenText },
|
||||
{ id: "env", label: "Environment", icon: KeyRound },
|
||||
{ id: "custom_args", label: "Custom Args", icon: Terminal },
|
||||
];
|
||||
|
||||
interface AgentOverviewPaneProps {
|
||||
@@ -85,7 +77,6 @@ export function AgentOverviewPane({
|
||||
runtimes,
|
||||
onUpdate,
|
||||
}: AgentOverviewPaneProps) {
|
||||
const { t } = useT("agents");
|
||||
const [activeTab, setActiveTab] = useState<DetailTab>("activity");
|
||||
const [activeDirty, setActiveDirty] = useState(false);
|
||||
// Holds the destination when a tab change is intercepted by the dirty
|
||||
@@ -117,25 +108,21 @@ export function AgentOverviewPane({
|
||||
};
|
||||
|
||||
return (
|
||||
// On mobile the parent stacks the inspector and overview and scrolls the
|
||||
// page itself, so this pane has no inherited height. `min-h-[60vh]` keeps
|
||||
// the tab content area usably tall when content is short; `md:` restores
|
||||
// the grid-driven full-height behavior on tablet and up.
|
||||
<div className="flex min-h-[60vh] flex-col overflow-hidden rounded-lg border bg-background md:h-full md:min-h-0">
|
||||
<div className="flex shrink-0 items-center gap-0 overflow-x-auto border-b px-2 md:px-4">
|
||||
<div className="flex h-full min-h-0 flex-col overflow-hidden rounded-lg border bg-background">
|
||||
<div className="flex shrink-0 items-center gap-0 border-b px-4">
|
||||
{detailTabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
onClick={() => requestTabChange(tab.id)}
|
||||
className={`flex shrink-0 items-center gap-1.5 whitespace-nowrap border-b-2 px-3 py-2.5 text-xs font-medium transition-colors ${
|
||||
className={`flex items-center gap-1.5 border-b-2 px-3 py-2.5 text-xs font-medium transition-colors ${
|
||||
activeTab === tab.id
|
||||
? "border-foreground text-foreground"
|
||||
: "border-transparent text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
<tab.icon className="h-3.5 w-3.5" />
|
||||
{t(($) => $.tabs[TAB_LABEL_KEY[tab.id]])}
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -187,18 +174,19 @@ export function AgentOverviewPane({
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t(($) => $.tabs.discard_dialog_title)}</AlertDialogTitle>
|
||||
<AlertDialogTitle>Discard unsaved changes?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t(($) => $.tabs.discard_dialog_description)}
|
||||
You have unsaved changes in this tab. Leaving now will discard
|
||||
them.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t(($) => $.tabs.discard_keep)}</AlertDialogCancel>
|
||||
<AlertDialogCancel>Keep editing</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
onClick={commitTabChange}
|
||||
>
|
||||
{t(($) => $.tabs.discard_confirm)}
|
||||
Discard changes
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
@@ -216,6 +204,6 @@ export function AgentOverviewPane({
|
||||
// list) still scrolls via the parent's overflow-y-auto.
|
||||
function TabContent({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="mx-auto flex h-full max-w-2xl flex-col p-4 md:p-6">{children}</div>
|
||||
<div className="mx-auto flex h-full max-w-2xl flex-col p-6">{children}</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import { Skeleton } from "@multica/ui/components/ui/skeleton";
|
||||
import type { AgentPresenceDetail } from "@multica/core/agents";
|
||||
import { availabilityConfig, workloadConfig } from "../presence";
|
||||
import { useT } from "../../i18n";
|
||||
|
||||
interface PresenceIndicatorProps {
|
||||
// null/undefined = still loading. Caller passes the detail computed at
|
||||
@@ -34,7 +33,6 @@ export function AgentPresenceIndicator({
|
||||
detail,
|
||||
compact,
|
||||
}: PresenceIndicatorProps) {
|
||||
const { t } = useT("agents");
|
||||
if (!detail) {
|
||||
return compact ? (
|
||||
<Skeleton className="h-1.5 w-1.5 rounded-full" />
|
||||
@@ -45,8 +43,6 @@ export function AgentPresenceIndicator({
|
||||
|
||||
const av = availabilityConfig[detail.availability];
|
||||
const wl = workloadConfig[detail.workload];
|
||||
const availabilityLabel = t(($) => $.availability[detail.availability]);
|
||||
const workloadLabel = t(($) => $.workload[detail.workload]);
|
||||
const isWorking = detail.workload === "working";
|
||||
const isQueued = detail.workload === "queued";
|
||||
const showQueueBadge = isWorking && detail.queuedCount > 0;
|
||||
@@ -63,7 +59,7 @@ export function AgentPresenceIndicator({
|
||||
return (
|
||||
<span
|
||||
className="inline-flex items-center"
|
||||
title={`${availabilityLabel}${detail.workload !== "idle" ? ` · ${workloadLabel}` : ""}`}
|
||||
title={`${av.label}${detail.workload !== "idle" ? ` · ${wl.label}` : ""}`}
|
||||
>
|
||||
<span className={`h-1.5 w-1.5 shrink-0 rounded-full ${av.dotClass}`} />
|
||||
</span>
|
||||
@@ -75,7 +71,7 @@ export function AgentPresenceIndicator({
|
||||
{/* Availability — dot + label. Single dimension, single colour. */}
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span className={`h-1.5 w-1.5 shrink-0 rounded-full ${av.dotClass}`} />
|
||||
<span className={`text-xs ${av.textClass}`}>{availabilityLabel}</span>
|
||||
<span className={`text-xs ${av.textClass}`}>{av.label}</span>
|
||||
</span>
|
||||
|
||||
{/* Workload — separator + label, with counts when working/queued.
|
||||
@@ -90,7 +86,7 @@ export function AgentPresenceIndicator({
|
||||
isQueued ? queuedTone : wl.textClass
|
||||
}`}
|
||||
>
|
||||
{workloadLabel}
|
||||
{wl.label}
|
||||
</span>
|
||||
{isWorking && (
|
||||
<span className="font-mono text-xs tabular-nums text-muted-foreground">
|
||||
@@ -99,7 +95,7 @@ export function AgentPresenceIndicator({
|
||||
)}
|
||||
{showQueueBadge && (
|
||||
<span className="rounded-md bg-muted px-1 py-0 text-xs font-medium text-muted-foreground">
|
||||
{t(($) => $.presence.queue_badge, { count: detail.queuedCount })}
|
||||
+{detail.queuedCount} queued
|
||||
</span>
|
||||
)}
|
||||
{/* Queued (no running) — show the queued count directly, since
|
||||
|
||||
@@ -17,14 +17,12 @@ import { AppLink } from "../../navigation";
|
||||
import { HealthIcon } from "../../runtimes/components/shared";
|
||||
import { availabilityConfig } from "../presence";
|
||||
import { VisibilityBadge } from "./visibility-badge";
|
||||
import { useT } from "../../i18n";
|
||||
|
||||
interface AgentProfileCardProps {
|
||||
agentId: string;
|
||||
}
|
||||
|
||||
export function AgentProfileCard({ agentId }: AgentProfileCardProps) {
|
||||
const { t } = useT("agents");
|
||||
const wsId = useWorkspaceId();
|
||||
const p = useWorkspacePaths();
|
||||
const { data: agents = [], isLoading: agentsLoading } = useQuery(agentListOptions(wsId));
|
||||
@@ -47,7 +45,7 @@ export function AgentProfileCard({ agentId }: AgentProfileCardProps) {
|
||||
|
||||
if (!agent) {
|
||||
return (
|
||||
<div className="text-xs text-muted-foreground">{t(($) => $.profile_card.unavailable)}</div>
|
||||
<div className="text-xs text-muted-foreground">Agent unavailable</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -87,7 +85,7 @@ export function AgentProfileCard({ agentId }: AgentProfileCardProps) {
|
||||
{!isArchived && <VisibilityBadge value={agent.visibility} compact />}
|
||||
{isArchived && (
|
||||
<span className="rounded-md bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground">
|
||||
{t(($) => $.row.archived)}
|
||||
Archived
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -100,7 +98,7 @@ export function AgentProfileCard({ agentId }: AgentProfileCardProps) {
|
||||
href={p.agentDetail(agent.id)}
|
||||
className="mr-1 mt-0.5 shrink-0 text-xs font-normal text-brand opacity-0 transition-opacity group-hover:opacity-100"
|
||||
>
|
||||
{t(($) => $.profile_card.detail_link)}
|
||||
Detail →
|
||||
</AppLink>
|
||||
)}
|
||||
</div>
|
||||
@@ -120,7 +118,7 @@ export function AgentProfileCard({ agentId }: AgentProfileCardProps) {
|
||||
{agent.skills.length > 0 && (
|
||||
<SkillsRow skills={agent.skills.map((s) => s.name)} />
|
||||
)}
|
||||
{owner && <MetaRow label={t(($) => $.profile_card.owner_label)} value={owner.name} />}
|
||||
{owner && <MetaRow label="Owner" value={owner.name} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -137,7 +135,6 @@ function AgentAvailabilityLine({
|
||||
wsId: string | undefined;
|
||||
agentId: string;
|
||||
}) {
|
||||
const { t } = useT("agents");
|
||||
const detail = useAgentPresenceDetail(wsId, agentId);
|
||||
if (detail === "loading") {
|
||||
return <Skeleton className="mt-0.5 h-3 w-16" />;
|
||||
@@ -146,7 +143,7 @@ function AgentAvailabilityLine({
|
||||
return (
|
||||
<div className="mt-0.5 inline-flex items-center gap-1.5">
|
||||
<span className={`h-1.5 w-1.5 rounded-full ${av.dotClass}`} />
|
||||
<span className={`text-xs ${av.textClass}`}>{t(($) => $.availability[detail.availability])}</span>
|
||||
<span className={`text-xs ${av.textClass}`}>{av.label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -165,21 +162,16 @@ function RuntimeRow({
|
||||
agent: Agent;
|
||||
runtime: AgentRuntime | null;
|
||||
}) {
|
||||
const { t } = useT("agents");
|
||||
const isCloud = agent.runtime_mode === "cloud";
|
||||
const health: RuntimeHealth = isCloud
|
||||
? "online"
|
||||
: runtime
|
||||
? deriveRuntimeHealth(runtime, Date.now())
|
||||
: "offline";
|
||||
const label =
|
||||
runtime?.name ??
|
||||
(isCloud
|
||||
? t(($) => $.row.fallback_runtime_cloud)
|
||||
: t(($) => $.profile_card.unknown_runtime));
|
||||
const label = runtime?.name ?? (isCloud ? "Cloud" : "Unknown runtime");
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="w-12 shrink-0 text-muted-foreground">{t(($) => $.profile_card.runtime_label)}</span>
|
||||
<span className="w-12 shrink-0 text-muted-foreground">Runtime</span>
|
||||
<HealthIcon health={health} className="h-3 w-3 shrink-0" />
|
||||
<span className="min-w-0 truncate" title={label}>
|
||||
{label}
|
||||
@@ -208,12 +200,11 @@ function MetaRow({
|
||||
}
|
||||
|
||||
function SkillsRow({ skills }: { skills: string[] }) {
|
||||
const { t } = useT("agents");
|
||||
const visible = skills.slice(0, 3);
|
||||
const overflow = skills.length - visible.length;
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="w-12 shrink-0 text-muted-foreground">{t(($) => $.profile_card.skills_label)}</span>
|
||||
<span className="w-12 shrink-0 text-muted-foreground">Skills</span>
|
||||
<div className="flex min-w-0 flex-wrap gap-1">
|
||||
{visible.map((s) => (
|
||||
<span
|
||||
|
||||
@@ -34,7 +34,6 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@multica/ui/components/ui/dropdown-menu";
|
||||
import { useT } from "../../i18n";
|
||||
|
||||
interface AgentRowActionsProps {
|
||||
agent: Agent;
|
||||
@@ -65,7 +64,6 @@ export function AgentRowActions({
|
||||
canManage,
|
||||
onDuplicate,
|
||||
}: AgentRowActionsProps) {
|
||||
const { t } = useT("agents");
|
||||
const wsId = useWorkspaceId();
|
||||
const qc = useQueryClient();
|
||||
|
||||
@@ -95,9 +93,9 @@ export function AgentRowActions({
|
||||
try {
|
||||
await api.archiveAgent(agent.id);
|
||||
invalidateAgents();
|
||||
toast.success(t(($) => $.row_actions.agent_archived_toast));
|
||||
toast.success("Agent archived");
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : t(($) => $.row_actions.archive_failed_toast));
|
||||
toast.error(e instanceof Error ? e.message : "Failed to archive agent");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -105,23 +103,27 @@ export function AgentRowActions({
|
||||
try {
|
||||
await api.restoreAgent(agent.id);
|
||||
invalidateAgents();
|
||||
toast.success(t(($) => $.row_actions.agent_restored_toast));
|
||||
toast.success("Agent restored");
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : t(($) => $.row_actions.restore_failed_toast));
|
||||
toast.error(e instanceof Error ? e.message : "Failed to restore agent");
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelTasks = async () => {
|
||||
try {
|
||||
const { cancelled } = await api.cancelAgentTasks(agent.id);
|
||||
// Server broadcasts task:cancelled per row; useRealtimeSync will
|
||||
// invalidate the agent-task-snapshot cache for us. We still kick
|
||||
// agents in case the back-end's ReconcileAgentStatus changed
|
||||
// agent.status.
|
||||
invalidateAgents();
|
||||
toast.success(
|
||||
cancelled === 0
|
||||
? t(($) => $.row_actions.no_tasks_to_cancel_toast)
|
||||
: t(($) => $.row_actions.cancelled_tasks_toast, { count: cancelled }),
|
||||
? "No active tasks to cancel"
|
||||
: `Cancelled ${cancelled} task${cancelled === 1 ? "" : "s"}`,
|
||||
);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : t(($) => $.row_actions.cancel_failed_toast));
|
||||
toast.error(e instanceof Error ? e.message : "Failed to cancel tasks");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -137,7 +139,7 @@ export function AgentRowActions({
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={t(($) => $.row.actions_aria)}
|
||||
aria-label="Row actions"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
/>
|
||||
@@ -157,19 +159,19 @@ export function AgentRowActions({
|
||||
onClick={() => setConfirmCancel(true)}
|
||||
>
|
||||
<Square className="h-3.5 w-3.5" />
|
||||
{t(($) => $.row_actions.cancel_all_tasks)}
|
||||
Cancel all tasks
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{showDuplicate && (
|
||||
<DropdownMenuItem onClick={() => onDuplicate(agent)}>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
{t(($) => $.row_actions.duplicate)}
|
||||
Duplicate
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{showRestore && (
|
||||
<DropdownMenuItem onClick={handleRestore}>
|
||||
<RotateCcw className="h-3.5 w-3.5" />
|
||||
{t(($) => $.row_actions.restore)}
|
||||
Restore
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{showArchive && (
|
||||
@@ -180,7 +182,7 @@ export function AgentRowActions({
|
||||
onClick={() => setConfirmArchive(true)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
{t(($) => $.row_actions.archive)}
|
||||
Archive
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
@@ -200,16 +202,20 @@ export function AgentRowActions({
|
||||
>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t(($) => $.row_actions.cancel_dialog_title, { name: agent.name })}
|
||||
Cancel all tasks for “{agent.name}”?
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{describeCancelImpact(runningCount, queuedCount, t)}
|
||||
{runningCount > 0 && t(($) => $.row_actions.cancel_dialog_running_note)}
|
||||
{t(($) => $.row_actions.cancel_dialog_irreversible)}
|
||||
{describeCancelImpact(runningCount, queuedCount)}
|
||||
{runningCount > 0 && (
|
||||
<>
|
||||
{" "}Running tasks may take up to 5 seconds to fully halt.
|
||||
</>
|
||||
)}{" "}
|
||||
Cancelled tasks cannot be resumed.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t(($) => $.row_actions.cancel_dialog_keep)}</AlertDialogCancel>
|
||||
<AlertDialogCancel>Keep them</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
@@ -217,7 +223,7 @@ export function AgentRowActions({
|
||||
void handleCancelTasks();
|
||||
}}
|
||||
>
|
||||
{t(($) => $.row_actions.cancel_dialog_confirm)}
|
||||
Cancel all tasks
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
@@ -239,16 +245,18 @@ export function AgentRowActions({
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<AlertDialogTitle>
|
||||
{t(($) => $.row_actions.archive_dialog_title, { name: agent.name })}
|
||||
Archive “{agent.name}”?
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t(($) => $.row_actions.archive_dialog_description)}
|
||||
The agent won't be assignable or mentionable, and any
|
||||
active tasks will be cancelled. All history is preserved
|
||||
and you can restore it later.
|
||||
</AlertDialogDescription>
|
||||
</div>
|
||||
</div>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t(($) => $.row_actions.archive_dialog_cancel)}</AlertDialogCancel>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
@@ -256,7 +264,7 @@ export function AgentRowActions({
|
||||
void handleArchive();
|
||||
}}
|
||||
>
|
||||
{t(($) => $.row_actions.archive_dialog_confirm)}
|
||||
Archive
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
@@ -266,17 +274,16 @@ export function AgentRowActions({
|
||||
);
|
||||
}
|
||||
|
||||
type AgentsT = ReturnType<typeof useT<"agents">>["t"];
|
||||
|
||||
function describeCancelImpact(running: number, queued: number, t: AgentsT): string {
|
||||
function describeCancelImpact(running: number, queued: number): string {
|
||||
// Both zero shouldn't happen — the menu item is gated on hasActiveWork —
|
||||
// but guarding anyway so the copy never reads "stop 0 tasks and 0 tasks".
|
||||
if (running === 0 && queued === 0) {
|
||||
return t(($) => $.row_actions.cancel_dialog_no_tasks);
|
||||
return "There are no active tasks to cancel.";
|
||||
}
|
||||
const parts: string[] = [];
|
||||
if (running > 0) parts.push(t(($) => $.row_actions.cancel_dialog_running, { count: running }));
|
||||
if (queued > 0) parts.push(t(($) => $.row_actions.cancel_dialog_queued, { count: queued }));
|
||||
return t(($) => $.row_actions.cancel_dialog_impact, {
|
||||
summary: parts.join(" + "),
|
||||
count: running + queued,
|
||||
});
|
||||
if (running > 0) parts.push(`${running} running`);
|
||||
if (queued > 0) parts.push(`${queued} queued`);
|
||||
return `This will cancel ${parts.join(" and ")} ${
|
||||
running + queued === 1 ? "task" : "tasks"
|
||||
}.`;
|
||||
}
|
||||
|
||||
@@ -45,7 +45,6 @@ import { PageHeader } from "../../layout/page-header";
|
||||
import { availabilityConfig, availabilityOrder } from "../presence";
|
||||
import { CreateAgentDialog } from "./create-agent-dialog";
|
||||
import { type AgentRow, createAgentColumns } from "./agent-columns";
|
||||
import { useT } from "../../i18n";
|
||||
|
||||
// Filter axes:
|
||||
//
|
||||
@@ -64,15 +63,14 @@ type AvailabilityFilter = "all" | AgentAvailability;
|
||||
|
||||
type SortKey = "recent" | "name" | "runs" | "created";
|
||||
const SORT_KEYS: SortKey[] = ["recent", "name", "runs", "created"];
|
||||
const SORT_LABEL_KEY: Record<SortKey, "label_recent" | "label_name" | "label_runs" | "label_created"> = {
|
||||
recent: "label_recent",
|
||||
name: "label_name",
|
||||
runs: "label_runs",
|
||||
created: "label_created",
|
||||
const SORT_LABEL: Record<SortKey, string> = {
|
||||
recent: "Recent activity",
|
||||
name: "Name",
|
||||
runs: "Most runs",
|
||||
created: "Recently created",
|
||||
};
|
||||
|
||||
export function AgentsPage() {
|
||||
const { t } = useT("agents");
|
||||
const wsId = useWorkspaceId();
|
||||
const paths = useWorkspacePaths();
|
||||
const navigation = useNavigation();
|
||||
@@ -353,8 +351,8 @@ export function AgentsPage() {
|
||||
]);
|
||||
|
||||
const columns = useMemo(
|
||||
() => createAgentColumns({ onDuplicate: handleDuplicate, t }),
|
||||
[handleDuplicate, t],
|
||||
() => createAgentColumns({ onDuplicate: handleDuplicate }),
|
||||
[handleDuplicate],
|
||||
);
|
||||
|
||||
const table = useReactTable({
|
||||
@@ -396,7 +394,30 @@ export function AgentsPage() {
|
||||
|
||||
// ---- List request error ----
|
||||
if (listError) {
|
||||
return <ListError onCreate={() => setShowCreate(true)} listError={listError} onRetry={refetchList} />;
|
||||
return (
|
||||
<div className="flex flex-1 min-h-0 flex-col">
|
||||
<PageHeaderBar totalCount={0} onCreate={() => setShowCreate(true)} />
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-3 px-6 py-16 text-center">
|
||||
<AlertCircle className="h-8 w-8 text-destructive" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">Couldn’t load agents</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{listError instanceof Error
|
||||
? listError.message
|
||||
: "Something went wrong fetching the agent list."}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => refetchList()}
|
||||
>
|
||||
Try again
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const showEmpty = totalActiveCount === 0 && archivedCount === 0;
|
||||
@@ -489,74 +510,41 @@ function PageHeaderBar({
|
||||
totalCount: number;
|
||||
onCreate: () => void;
|
||||
}) {
|
||||
const { t } = useT("agents");
|
||||
return (
|
||||
<PageHeader className="justify-between px-5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Bot className="h-4 w-4 text-muted-foreground" />
|
||||
<h1 className="text-sm font-medium">{t(($) => $.page.title)}</h1>
|
||||
<h1 className="text-sm font-medium">Agents</h1>
|
||||
{totalCount > 0 && (
|
||||
<span className="font-mono text-xs tabular-nums text-muted-foreground/70">
|
||||
{totalCount}
|
||||
</span>
|
||||
)}
|
||||
{/* Tagline next to the title — mirrors Runtimes / Skills. */}
|
||||
{/* Tagline next to the title — mirrors Runtimes / Skills. Single
|
||||
sentence + docs link, hidden below md so it never collides with
|
||||
the title on narrow screens. The presence chip row below carries
|
||||
the state-legend job, so the tagline only needs to anchor what
|
||||
an agent IS, not what each colour means. */}
|
||||
<p className="ml-2 hidden text-xs text-muted-foreground md:block">
|
||||
{t(($) => $.page.tagline)}{" "}
|
||||
AI teammates that pick up issues, comment, and update status.{" "}
|
||||
<a
|
||||
href="https://multica.ai/docs/agents"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline decoration-muted-foreground/30 underline-offset-4 transition-colors hover:text-foreground"
|
||||
>
|
||||
{t(($) => $.page.learn_more)}
|
||||
Learn more →
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
<Button type="button" size="sm" onClick={onCreate}>
|
||||
<Plus className="h-3 w-3" />
|
||||
{t(($) => $.page.new_agent)}
|
||||
New agent
|
||||
</Button>
|
||||
</PageHeader>
|
||||
);
|
||||
}
|
||||
|
||||
function ListError({
|
||||
onCreate,
|
||||
listError,
|
||||
onRetry,
|
||||
}: {
|
||||
onCreate: () => void;
|
||||
listError: unknown;
|
||||
onRetry: () => void;
|
||||
}) {
|
||||
const { t } = useT("agents");
|
||||
return (
|
||||
<div className="flex flex-1 min-h-0 flex-col">
|
||||
<PageHeaderBar totalCount={0} onCreate={onCreate} />
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-3 px-6 py-16 text-center">
|
||||
<AlertCircle className="h-8 w-8 text-destructive" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">{t(($) => $.page.list_load_failed)}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{listError instanceof Error
|
||||
? listError.message
|
||||
: t(($) => $.page.list_load_failed_default)}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onRetry}
|
||||
>
|
||||
{t(($) => $.page.try_again)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Active view — Layer 1: scope segment + sort + search + archived link + live
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -586,7 +574,11 @@ function ActiveToolbarRow({
|
||||
archivedCount: number;
|
||||
onShowArchived: () => void;
|
||||
}) {
|
||||
const { t } = useT("agents");
|
||||
// Layout: [Search] [Mine|All] ......... [Show archived] [N of M] [Sort ▼]
|
||||
// Filter chips were removed (status / workload chips on a small team
|
||||
// gain less than they cost), so the toolbar collapses to a single row.
|
||||
// Visible/total count and the archived link inherit their old position
|
||||
// from the deleted PresenceFilterRows.
|
||||
return (
|
||||
<div className="flex h-12 shrink-0 items-center gap-3 border-b px-4">
|
||||
<div className="relative">
|
||||
@@ -594,7 +586,7 @@ function ActiveToolbarRow({
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder={t(($) => $.page.search_placeholder)}
|
||||
placeholder="Search agents…"
|
||||
className="h-8 w-64 pl-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
@@ -606,11 +598,11 @@ function ActiveToolbarRow({
|
||||
onClick={onShowArchived}
|
||||
className="text-xs text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
{t(($) => $.page.show_archived, { count: archivedCount })}
|
||||
Show archived ({archivedCount}) →
|
||||
</button>
|
||||
)}
|
||||
<span className="font-mono text-xs tabular-nums text-muted-foreground/70">
|
||||
{t(($) => $.page.of_total, { visible: visibleCount, total: totalCount })}
|
||||
{visibleCount} of {totalCount}
|
||||
</span>
|
||||
<SortDropdown sort={sort} setSort={setSort} />
|
||||
</div>
|
||||
@@ -627,18 +619,19 @@ function ScopeSegment({
|
||||
setScope: (v: Scope) => void;
|
||||
counts: { all: number; mine: number };
|
||||
}) {
|
||||
const { t } = useT("agents");
|
||||
// Mine first — that's the more frequent scope (your own agents) and
|
||||
// also the default selection, so it lives in the leading slot.
|
||||
return (
|
||||
<div className="flex items-center gap-0.5 rounded-md bg-muted p-0.5">
|
||||
<ScopeButton
|
||||
active={scope === "mine"}
|
||||
label={t(($) => $.scope.mine)}
|
||||
label="Mine"
|
||||
count={counts.mine}
|
||||
onClick={() => setScope("mine")}
|
||||
/>
|
||||
<ScopeButton
|
||||
active={scope === "all"}
|
||||
label={t(($) => $.scope.all)}
|
||||
label="All"
|
||||
count={counts.all}
|
||||
onClick={() => setScope("all")}
|
||||
/>
|
||||
@@ -686,7 +679,6 @@ function SortDropdown({
|
||||
sort: SortKey;
|
||||
setSort: (v: SortKey) => void;
|
||||
}) {
|
||||
const { t } = useT("agents");
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
@@ -699,7 +691,7 @@ function SortDropdown({
|
||||
}
|
||||
>
|
||||
<ArrowUpDown className="h-3 w-3" />
|
||||
{t(($) => $.sort[SORT_LABEL_KEY[sort]])}
|
||||
{SORT_LABEL[sort]}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-auto">
|
||||
{SORT_KEYS.map((k) => (
|
||||
@@ -708,7 +700,7 @@ function SortDropdown({
|
||||
onClick={() => setSort(k)}
|
||||
className="text-xs"
|
||||
>
|
||||
{t(($) => $.sort[SORT_LABEL_KEY[k]])}
|
||||
{SORT_LABEL[k]}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
@@ -732,13 +724,12 @@ function AvailabilityFilterRow({
|
||||
counts: Record<AgentAvailability, number>;
|
||||
totalCount: number;
|
||||
}) {
|
||||
const { t } = useT("agents");
|
||||
return (
|
||||
<div className="flex h-11 shrink-0 items-center gap-2 border-b px-4">
|
||||
<AvailabilityChip
|
||||
active={value === "all"}
|
||||
onClick={() => onChange("all")}
|
||||
label={t(($) => $.availability.all)}
|
||||
label="All"
|
||||
count={totalCount}
|
||||
/>
|
||||
{availabilityOrder.map((a) => {
|
||||
@@ -748,7 +739,7 @@ function AvailabilityFilterRow({
|
||||
key={a}
|
||||
active={value === a}
|
||||
onClick={() => onChange(a)}
|
||||
label={t(($) => $.availability[a])}
|
||||
label={cfg.label}
|
||||
count={counts[a]}
|
||||
dotClass={cfg.dotClass}
|
||||
/>
|
||||
@@ -807,7 +798,6 @@ function ArchivedToolbarRow({
|
||||
sort: SortKey;
|
||||
setSort: (v: SortKey) => void;
|
||||
}) {
|
||||
const { t } = useT("agents");
|
||||
return (
|
||||
<div className="flex h-12 shrink-0 items-center gap-3 border-b px-4">
|
||||
<button
|
||||
@@ -816,10 +806,10 @@ function ArchivedToolbarRow({
|
||||
className="inline-flex items-center gap-1 text-xs text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
<ArrowLeft className="h-3 w-3" />
|
||||
{t(($) => $.archived.active_link)}
|
||||
Active agents
|
||||
</button>
|
||||
<span className="text-muted-foreground/40">/</span>
|
||||
<span className="text-xs font-medium">{t(($) => $.archived.title)}</span>
|
||||
<span className="text-xs font-medium">Archived agents</span>
|
||||
<span className="font-mono text-xs tabular-nums text-muted-foreground/70">
|
||||
{archivedCount}
|
||||
</span>
|
||||
@@ -835,19 +825,19 @@ function ArchivedToolbarRow({
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function EmptyState({ onCreate }: { onCreate: () => void }) {
|
||||
const { t } = useT("agents");
|
||||
return (
|
||||
<div className="flex flex-1 flex-col items-center justify-center px-6 py-16 text-center">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-muted">
|
||||
<Bot className="h-6 w-6 text-muted-foreground" />
|
||||
</div>
|
||||
<h2 className="mt-4 text-base font-semibold">{t(($) => $.empty.title)}</h2>
|
||||
<h2 className="mt-4 text-base font-semibold">No agents yet</h2>
|
||||
<p className="mt-1 max-w-md text-sm text-muted-foreground">
|
||||
{t(($) => $.empty.description)}
|
||||
Create an agent and assign it issues, like any teammate. Local agents
|
||||
run on your machine; cloud agents run on Multica’s runtime.
|
||||
</p>
|
||||
<Button type="button" onClick={onCreate} size="sm" className="mt-5">
|
||||
<Plus className="h-3 w-3" />
|
||||
{t(($) => $.page.new_agent)}
|
||||
New agent
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
@@ -862,27 +852,27 @@ function NoMatches({
|
||||
search: string;
|
||||
scope: Scope;
|
||||
}) {
|
||||
const { t } = useT("agents");
|
||||
const hasSearch = search.length > 0;
|
||||
// "mine" is the only remaining narrowing dimension after chip filters
|
||||
// were dropped — keep the wording aware of it so an empty Mine view
|
||||
// doesn't suggest the workspace itself is empty.
|
||||
const hasFilter = scope === "mine";
|
||||
|
||||
let body: string;
|
||||
if (view === "archived") {
|
||||
body = hasSearch
|
||||
? t(($) => $.no_matches.search_archived, { query: search })
|
||||
: t(($) => $.no_matches.no_archived);
|
||||
? `No archived agents match "${search}".`
|
||||
: "No archived agents yet.";
|
||||
} else if (hasSearch) {
|
||||
body = hasFilter
|
||||
? t(($) => $.no_matches.search_active_filtered, { query: search })
|
||||
: t(($) => $.no_matches.search_active, { query: search });
|
||||
body = `No agents match "${search}"${hasFilter ? " in this filter" : ""}.`;
|
||||
} else {
|
||||
body = t(($) => $.no_matches.no_filter_match);
|
||||
body = "No agents match this filter.";
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-2 px-4 py-16 text-center text-muted-foreground">
|
||||
<Search className="h-8 w-8 text-muted-foreground/40" />
|
||||
<p className="text-sm">{t(($) => $.no_matches.title)}</p>
|
||||
<p className="text-sm">No matches</p>
|
||||
<p className="max-w-xs text-xs">{body}</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,13 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useT } from "../../i18n";
|
||||
|
||||
// Soft warn at 90 % of the cap, hard error past it. Shared between the
|
||||
// description editor (modal) and the create-agent dialog so both surfaces
|
||||
// read the same way. Renders a single inline line so it can sit under any
|
||||
// textarea / input without disturbing surrounding spacing.
|
||||
export function CharCounter({ length, max }: { length: number; max: number }) {
|
||||
const { t } = useT("agents");
|
||||
const over = length > max;
|
||||
const near = !over && length >= Math.floor(max * 0.9);
|
||||
const tone = over
|
||||
@@ -18,7 +13,7 @@ export function CharCounter({ length, max }: { length: number; max: number }) {
|
||||
return (
|
||||
<div className={`text-right text-xs tabular-nums ${tone}`}>
|
||||
{length} / {max}
|
||||
{over && t(($) => $.char_counter.over_limit, { count: length - max })}
|
||||
{over && ` · ${length - max} over limit`}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -35,7 +35,6 @@ import {
|
||||
VISIBILITY_LABEL,
|
||||
} from "@multica/core/agents";
|
||||
import { CharCounter } from "./char-counter";
|
||||
import { useT } from "../../i18n";
|
||||
|
||||
type RuntimeFilter = "mine" | "all";
|
||||
|
||||
@@ -63,10 +62,9 @@ export function CreateAgentDialog({
|
||||
onClose: () => void;
|
||||
onCreate: (data: CreateAgentRequest) => Promise<void>;
|
||||
}) {
|
||||
const { t } = useT("agents");
|
||||
const isDuplicate = !!template;
|
||||
const [name, setName] = useState(
|
||||
template ? `${template.name}${t(($) => $.create_dialog.duplicate_copy_suffix)}` : "",
|
||||
template ? `${template.name} (Copy)` : "",
|
||||
);
|
||||
const [description, setDescription] = useState(template?.description ?? "");
|
||||
const [visibility, setVisibility] = useState<AgentVisibility>(
|
||||
@@ -143,7 +141,7 @@ export function CreateAgentDialog({
|
||||
await onCreate(data);
|
||||
onClose();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : t(($) => $.create_dialog.create_failed_toast));
|
||||
toast.error(err instanceof Error ? err.message : "Failed to create agent");
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
@@ -153,36 +151,36 @@ export function CreateAgentDialog({
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{isDuplicate ? t(($) => $.create_dialog.title_duplicate) : t(($) => $.create_dialog.title_create)}
|
||||
{isDuplicate ? "Duplicate Agent" : "Create Agent"}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isDuplicate
|
||||
? t(($) => $.create_dialog.description_duplicate, { name: template!.name })
|
||||
: t(($) => $.create_dialog.description_create)}
|
||||
? `Create a new agent based on "${template!.name}". Instructions, env, and skills are copied for you.`
|
||||
: "Create a new AI agent for your workspace."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 min-w-0">
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground">{t(($) => $.create_dialog.name_label)}</Label>
|
||||
<Label className="text-xs text-muted-foreground">Name</Label>
|
||||
<Input
|
||||
autoFocus
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder={t(($) => $.create_dialog.name_placeholder)}
|
||||
placeholder="e.g. Deep Research Agent"
|
||||
className="mt-1"
|
||||
onKeyDown={(e) => e.key === "Enter" && handleSubmit()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground">{t(($) => $.create_dialog.description_label)}</Label>
|
||||
<Label className="text-xs text-muted-foreground">Description</Label>
|
||||
<Input
|
||||
type="text"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder={t(($) => $.create_dialog.description_placeholder)}
|
||||
placeholder="What does this agent do?"
|
||||
maxLength={AGENT_DESCRIPTION_MAX_LENGTH}
|
||||
className="mt-1"
|
||||
/>
|
||||
@@ -195,7 +193,7 @@ export function CreateAgentDialog({
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground">{t(($) => $.create_dialog.visibility_label)}</Label>
|
||||
<Label className="text-xs text-muted-foreground">Visibility</Label>
|
||||
<div className="mt-1.5 flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
@@ -236,7 +234,7 @@ export function CreateAgentDialog({
|
||||
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs text-muted-foreground">{t(($) => $.create_dialog.runtime_label)}</Label>
|
||||
<Label className="text-xs text-muted-foreground">Runtime</Label>
|
||||
{hasOtherRuntimes && (
|
||||
<div className="flex items-center gap-0.5 rounded-md bg-muted p-0.5">
|
||||
<button
|
||||
@@ -248,7 +246,7 @@ export function CreateAgentDialog({
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{t(($) => $.create_dialog.runtime_filter_mine)}
|
||||
Mine
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -259,7 +257,7 @@ export function CreateAgentDialog({
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{t(($) => $.create_dialog.runtime_filter_all)}
|
||||
All
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -279,18 +277,18 @@ export function CreateAgentDialog({
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate font-medium">
|
||||
{runtimesLoading ? t(($) => $.create_dialog.runtime_loading) : (selectedRuntime?.name ?? t(($) => $.create_dialog.runtime_none))}
|
||||
{runtimesLoading ? "Loading runtimes..." : (selectedRuntime?.name ?? "No runtime available")}
|
||||
</span>
|
||||
{selectedRuntime?.runtime_mode === "cloud" && (
|
||||
<span className="shrink-0 rounded bg-info/10 px-1.5 py-0.5 text-xs font-medium text-info">
|
||||
{t(($) => $.create_dialog.runtime_cloud_badge)}
|
||||
Cloud
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="truncate text-xs text-muted-foreground">
|
||||
{selectedRuntime
|
||||
? (getOwnerMember(selectedRuntime.owner_id)?.name ?? selectedRuntime.device_info)
|
||||
: t(($) => $.create_dialog.runtime_register_first)}
|
||||
: "Register a runtime before creating an agent"}
|
||||
</div>
|
||||
</div>
|
||||
<ChevronDown className={`h-4 w-4 shrink-0 text-muted-foreground transition-transform ${runtimeOpen ? "rotate-180" : ""}`} />
|
||||
@@ -315,7 +313,7 @@ export function CreateAgentDialog({
|
||||
<span className="truncate font-medium">{device.name}</span>
|
||||
{device.runtime_mode === "cloud" && (
|
||||
<span className="shrink-0 rounded bg-info/10 px-1.5 py-0.5 text-xs font-medium text-info">
|
||||
{t(($) => $.create_dialog.runtime_cloud_badge)}
|
||||
Cloud
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -353,13 +351,13 @@ export function CreateAgentDialog({
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={onClose}>
|
||||
{t(($) => $.create_dialog.cancel)}
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={creating || !name.trim() || !selectedRuntime}
|
||||
>
|
||||
{creating ? t(($) => $.create_dialog.creating) : t(($) => $.create_dialog.create)}
|
||||
{creating ? "Creating..." : "Create"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -5,7 +5,6 @@ import { Button } from "@multica/ui/components/ui/button";
|
||||
import { Input } from "@multica/ui/components/ui/input";
|
||||
import { PropertyPicker } from "../../../issues/components/pickers";
|
||||
import { CHIP_CLASS } from "./chip";
|
||||
import { useT } from "../../../i18n";
|
||||
|
||||
const MIN = 1;
|
||||
const MAX = 50;
|
||||
@@ -20,19 +19,9 @@ export function ConcurrencyPicker({
|
||||
canEdit?: boolean;
|
||||
onChange: (next: number) => Promise<void> | void;
|
||||
}) {
|
||||
const { t } = useT("agents");
|
||||
const [open, setOpen] = useState(false);
|
||||
const [draft, setDraft] = useState(String(value));
|
||||
|
||||
// Reset draft from authoritative value whenever the popover (re-)opens or
|
||||
// the prop changes from elsewhere — protects against stale draft state if
|
||||
// the user closes mid-edit and reopens later. Hook MUST run unconditionally
|
||||
// (before the !canEdit early return) to keep call order stable across
|
||||
// renders where canEdit may flip.
|
||||
useEffect(() => {
|
||||
if (open) setDraft(String(value));
|
||||
}, [open, value]);
|
||||
|
||||
if (!canEdit) {
|
||||
return (
|
||||
<span className="font-mono text-xs tabular-nums text-muted-foreground">
|
||||
@@ -41,6 +30,13 @@ export function ConcurrencyPicker({
|
||||
);
|
||||
}
|
||||
|
||||
// Reset draft from authoritative value whenever the popover (re-)opens or
|
||||
// the prop changes from elsewhere — protects against stale draft state if
|
||||
// the user closes mid-edit and reopens later.
|
||||
useEffect(() => {
|
||||
if (open) setDraft(String(value));
|
||||
}, [open, value]);
|
||||
|
||||
const commit = async () => {
|
||||
const n = Number(draft);
|
||||
if (!Number.isFinite(n) || n < MIN || n > MAX) return;
|
||||
@@ -48,7 +44,7 @@ export function ConcurrencyPicker({
|
||||
if (n !== value) await onChange(n);
|
||||
};
|
||||
|
||||
const tooltip = t(($) => $.pickers.concurrency_tooltip, { value });
|
||||
const tooltip = `Concurrency · ${value} max concurrent tasks`;
|
||||
|
||||
return (
|
||||
<PropertyPicker
|
||||
@@ -66,7 +62,7 @@ export function ConcurrencyPicker({
|
||||
>
|
||||
<div className="space-y-2 p-2">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(($) => $.pickers.concurrency_range, { min: MIN, max: MAX })}
|
||||
Max concurrent tasks ({MIN}–{MAX})
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
@@ -85,7 +81,7 @@ export function ConcurrencyPicker({
|
||||
className="h-8 w-20 font-mono text-xs"
|
||||
/>
|
||||
<Button size="sm" onClick={() => void commit()}>
|
||||
{t(($) => $.inspector.save)}
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
PropertyPicker,
|
||||
} from "../../../issues/components/pickers";
|
||||
import { CHIP_CLASS } from "./chip";
|
||||
import { useT } from "../../../i18n";
|
||||
|
||||
/**
|
||||
* Inline model picker for the agent inspector. Lighter cousin of
|
||||
@@ -37,7 +36,6 @@ export function ModelPicker({
|
||||
canEdit?: boolean;
|
||||
onChange: (next: string) => Promise<void> | void;
|
||||
}) {
|
||||
const { t } = useT("agents");
|
||||
const [open, setOpen] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
@@ -68,9 +66,6 @@ export function ModelPicker({
|
||||
);
|
||||
const canCreate = trimmedSearch.length > 0 && !exactMatch;
|
||||
|
||||
const triggerLabel = value || t(($) => $.pickers.model_default);
|
||||
const triggerTitle = t(($) => $.pickers.model_tooltip, { value: triggerLabel });
|
||||
|
||||
const select = async (id: string) => {
|
||||
setOpen(false);
|
||||
setSearch("");
|
||||
@@ -80,11 +75,14 @@ export function ModelPicker({
|
||||
if (!supported && !modelsQuery.isLoading) {
|
||||
return (
|
||||
<span className="truncate italic text-muted-foreground">
|
||||
{t(($) => $.pickers.model_managed_by_runtime)}
|
||||
Managed by runtime
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const triggerLabel = value || "Default";
|
||||
const triggerTitle = `Model · ${triggerLabel}`;
|
||||
|
||||
if (!canEdit) {
|
||||
return (
|
||||
<span
|
||||
@@ -119,7 +117,7 @@ export function ModelPicker({
|
||||
<div className="p-1.5">
|
||||
<Input
|
||||
autoFocus
|
||||
placeholder={t(($) => $.pickers.model_search_placeholder)}
|
||||
placeholder="Search or type a model ID"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="h-7 text-xs"
|
||||
@@ -130,7 +128,7 @@ export function ModelPicker({
|
||||
{modelsQuery.isLoading && (
|
||||
<div className="flex items-center gap-2 p-3 text-xs text-muted-foreground">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
{t(($) => $.pickers.model_discovering)}
|
||||
Discovering models…
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -150,7 +148,7 @@ export function ModelPicker({
|
||||
<span className="truncate font-medium">{m.label}</span>
|
||||
{m.default && (
|
||||
<span className="shrink-0 rounded bg-primary/10 px-1 text-[10px] font-medium text-primary">
|
||||
{t(($) => $.pickers.model_default_badge)}
|
||||
default
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -165,7 +163,7 @@ export function ModelPicker({
|
||||
|
||||
{!modelsQuery.isLoading && filtered.length === 0 && !canCreate && (
|
||||
<p className="px-3 py-3 text-center text-xs text-muted-foreground">
|
||||
{t(($) => $.pickers.model_empty)}
|
||||
No models available
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -173,11 +171,11 @@ export function ModelPicker({
|
||||
<PickerItem
|
||||
selected={false}
|
||||
onClick={() => void select(trimmedSearch)}
|
||||
tooltip={t(($) => $.pickers.model_custom_tooltip, { value: trimmedSearch })}
|
||||
tooltip={`Use “${trimmedSearch}” as a custom model id`}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5 shrink-0 text-primary" />
|
||||
<span className="truncate text-primary">
|
||||
{t(($) => $.pickers.model_custom_use, { value: trimmedSearch })}
|
||||
Use “{trimmedSearch}”
|
||||
</span>
|
||||
</PickerItem>
|
||||
)}
|
||||
@@ -187,9 +185,9 @@ export function ModelPicker({
|
||||
type="button"
|
||||
onClick={() => void select("")}
|
||||
className="mt-1 flex w-full items-center border-t px-3 py-2 text-left text-xs text-muted-foreground transition-colors hover:bg-accent/50"
|
||||
title={t(($) => $.pickers.model_clear_title)}
|
||||
title="Clear and fall back to the runtime's provider default"
|
||||
>
|
||||
{t(($) => $.pickers.model_clear)}
|
||||
Clear (use provider default)
|
||||
</button>
|
||||
)}
|
||||
</PropertyPicker>
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
} from "../../../issues/components/pickers";
|
||||
import { ProviderLogo } from "../../../runtimes/components/provider-logo";
|
||||
import { CHIP_CLASS } from "./chip";
|
||||
import { useT } from "../../../i18n";
|
||||
|
||||
type Filter = "mine" | "all";
|
||||
|
||||
@@ -36,36 +35,19 @@ export function RuntimePicker({
|
||||
canEdit?: boolean;
|
||||
onChange: (runtimeId: string) => Promise<void> | void;
|
||||
}) {
|
||||
const { t } = useT("agents");
|
||||
const [open, setOpen] = useState(false);
|
||||
const [filter, setFilter] = useState<Filter>("mine");
|
||||
|
||||
const selected = runtimes.find((r) => r.id === value) ?? null;
|
||||
const Icon = selected?.runtime_mode === "cloud" ? Cloud : Monitor;
|
||||
|
||||
// Compute filtered list unconditionally — the early `!canEdit` return
|
||||
// below would otherwise re-order this hook across renders.
|
||||
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;
|
||||
return 0;
|
||||
});
|
||||
}, [runtimes, filter, currentUserId]);
|
||||
|
||||
if (!canEdit) {
|
||||
const isOnline = selected?.status === "online";
|
||||
return (
|
||||
<span className="inline-flex min-w-0 items-center gap-1.5 px-1.5 py-0.5 text-xs text-muted-foreground">
|
||||
<Icon className="h-3 w-3 shrink-0" />
|
||||
<span className="min-w-0 truncate font-mono">
|
||||
{selected?.name ?? t(($) => $.pickers.runtime_none)}
|
||||
{selected?.name ?? "No runtime"}
|
||||
</span>
|
||||
{selected && (
|
||||
<span
|
||||
@@ -82,17 +64,30 @@ export function RuntimePicker({
|
||||
// deliberately do NOT append `device_info` to the tooltip — that string
|
||||
// also leads with the host and would just repeat what's already in name,
|
||||
// producing the "Claude (host) (host · 2.1.121 (Claude Code))" mess.
|
||||
const triggerLabel = selected?.name ?? t(($) => $.pickers.runtime_none);
|
||||
// device_info still shows on each row in the popover (small mono line),
|
||||
// which is the right place for system detail.
|
||||
const triggerLabel = selected?.name ?? "No runtime";
|
||||
const isOnline = selected?.status === "online";
|
||||
const triggerTitle = selected
|
||||
? t(($) => $.pickers.runtime_tooltip, {
|
||||
name: selected.name,
|
||||
status: isOnline ? t(($) => $.pickers.runtime_online) : t(($) => $.pickers.runtime_offline),
|
||||
})
|
||||
: t(($) => $.pickers.runtime_tooltip_none);
|
||||
? `Runtime · ${selected.name} · ${isOnline ? "online" : "offline"}`
|
||||
: "Runtime · none selected";
|
||||
|
||||
const hasOtherRuntimes = runtimes.some((r) => r.owner_id !== currentUserId);
|
||||
|
||||
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;
|
||||
return 0;
|
||||
});
|
||||
}, [runtimes, filter, currentUserId]);
|
||||
|
||||
const getOwner = (id: string | null) =>
|
||||
id ? members.find((m) => m.user_id === id) ?? null : null;
|
||||
|
||||
@@ -136,13 +131,13 @@ export function RuntimePicker({
|
||||
active={filter === "mine"}
|
||||
onClick={() => setFilter("mine")}
|
||||
>
|
||||
{t(($) => $.scope.mine)}
|
||||
Mine
|
||||
</FilterButton>
|
||||
<FilterButton
|
||||
active={filter === "all"}
|
||||
onClick={() => setFilter("all")}
|
||||
>
|
||||
{t(($) => $.scope.all)}
|
||||
All
|
||||
</FilterButton>
|
||||
</div>
|
||||
</div>
|
||||
@@ -151,16 +146,20 @@ export function RuntimePicker({
|
||||
>
|
||||
{filtered.length === 0 ? (
|
||||
<p className="px-2 py-3 text-center text-xs text-muted-foreground">
|
||||
{t(($) => $.pickers.runtime_empty)}
|
||||
No runtimes
|
||||
</p>
|
||||
) : (
|
||||
filtered.map((rt) => {
|
||||
const owner = getOwner(rt.owner_id);
|
||||
const rtOnline = rt.status === "online";
|
||||
// Tooltip echoes the chip's structure: name + owner + status. Skip
|
||||
// device_info because rt.name already embeds the host (it'd just
|
||||
// repeat), and the row visually shows device_info on its second
|
||||
// line anyway for users who do need that detail.
|
||||
const tooltip = [
|
||||
rt.name,
|
||||
owner ? t(($) => $.pickers.runtime_owned_by, { name: owner.name }) : null,
|
||||
rtOnline ? t(($) => $.pickers.runtime_online) : t(($) => $.pickers.runtime_offline),
|
||||
owner ? `owned by ${owner.name}` : null,
|
||||
rtOnline ? "online" : "offline",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
@@ -182,7 +181,7 @@ export function RuntimePicker({
|
||||
</span>
|
||||
{rt.runtime_mode === "cloud" && (
|
||||
<span className="shrink-0 rounded bg-info/10 px-1 text-[10px] font-medium text-info">
|
||||
{t(($) => $.create_dialog.runtime_cloud_badge)}
|
||||
Cloud
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -211,7 +210,7 @@ export function RuntimePicker({
|
||||
className={`h-1.5 w-1.5 shrink-0 rounded-full ${
|
||||
rtOnline ? "bg-success" : "bg-muted-foreground/40"
|
||||
}`}
|
||||
aria-label={rtOnline ? t(($) => $.pickers.runtime_online) : t(($) => $.pickers.runtime_offline)}
|
||||
aria-label={rtOnline ? "online" : "offline"}
|
||||
/>
|
||||
</PickerItem>
|
||||
);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user