mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-25 08:29:18 +02:00
Compare commits
1 Commits
v0.2.26
...
agent/lamb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3b734239fa |
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
|
||||
|
||||
@@ -242,7 +242,7 @@ Examples:
|
||||
- `issues.toolbar.batch_update_success`
|
||||
- `issues.detail.comment_form.placeholder`
|
||||
- `inbox.empty.title`
|
||||
- `settings.preferences.language.title`
|
||||
- `settings.appearance.language.title`
|
||||
|
||||
### Web-only / desktop-only copy
|
||||
|
||||
@@ -278,7 +278,7 @@ When the glossary doesn't cover a term, look at:
|
||||
1. `apps/docs/content/docs/*.zh.mdx` — the de facto Chinese voice standard, 20+ pages of consistent translation
|
||||
2. `packages/views/locales/zh-Hans/auth.json` and `editor.json` — JSON structure + selector API patterns
|
||||
3. `packages/views/auth/login-page.tsx` — component-level selector API call site
|
||||
4. `packages/views/settings/components/preferences-tab.tsx` — language switcher reference
|
||||
4. `packages/views/settings/components/appearance-tab.tsx` — language switcher reference
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -242,7 +242,7 @@ i18next 用 `_one` / `_other`;中文不区分语法单复数,只填 `_other`
|
||||
- `issues.toolbar.batch_update_success`
|
||||
- `issues.detail.comment_form.placeholder`
|
||||
- `inbox.empty.title`
|
||||
- `settings.preferences.language.title`
|
||||
- `settings.appearance.language.title`
|
||||
|
||||
### Web-only / Desktop-only 文案位置
|
||||
|
||||
@@ -278,7 +278,7 @@ i18next 用 `_one` / `_other`;中文不区分语法单复数,只填 `_other`
|
||||
1. `apps/docs/content/docs/*.zh.mdx` —— CN voice 事实标准,20+ 篇高度一致
|
||||
2. `packages/views/locales/zh-Hans/auth.json` 和 `editor.json` —— JSON 结构 + selector API 用法参考
|
||||
3. `packages/views/auth/login-page.tsx` —— 组件层 selector API 调用参考
|
||||
4. `packages/views/settings/components/preferences-tab.tsx` —— 语言切换器参考
|
||||
4. `packages/views/settings/components/appearance-tab.tsx` —— 语言切换器参考
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -28,7 +28,6 @@ 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";
|
||||
@@ -269,7 +268,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 +278,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
|
||||
|
||||
@@ -3,8 +3,7 @@ export type NotificationGroupKey =
|
||||
| "status_changes"
|
||||
| "comments"
|
||||
| "updates"
|
||||
| "agent_activity"
|
||||
| "system_notifications";
|
||||
| "agent_activity";
|
||||
|
||||
export type NotificationGroupValue = "all" | "muted";
|
||||
|
||||
|
||||
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,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -330,7 +330,6 @@ function MermaidLightbox({
|
||||
}
|
||||
|
||||
function MermaidDiagram({ chart }: { chart: string }) {
|
||||
const { t } = useT("editor");
|
||||
const reactId = useId();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const diagramId = useMemo(
|
||||
@@ -387,7 +386,7 @@ function MermaidDiagram({ chart }: { chart: string }) {
|
||||
if (error) {
|
||||
return (
|
||||
<div ref={containerRef} className="mermaid-diagram mermaid-diagram-error">
|
||||
<p>{t(($) => $.mermaid.render_error)}</p>
|
||||
<p>Unable to render Mermaid diagram.</p>
|
||||
<pre>
|
||||
<code>{chart}</code>
|
||||
</pre>
|
||||
@@ -427,7 +426,7 @@ function MermaidDiagram({ chart }: { chart: string }) {
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="mermaid-diagram-loading">{t(($) => $.mermaid.rendering)}</div>
|
||||
<div className="mermaid-diagram-loading">Rendering diagram…</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -56,20 +56,13 @@ vi.mock("@multica/core/api", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
import { I18nProvider } from "@multica/core/i18n/react";
|
||||
import enCommon from "../locales/en/common.json";
|
||||
import enInvite from "../locales/en/invite.json";
|
||||
import { InvitationsPage } from "./invitations-page";
|
||||
|
||||
const TEST_RESOURCES = { en: { common: enCommon, invite: enInvite } };
|
||||
|
||||
function renderWithClient(client: QueryClient = new QueryClient()) {
|
||||
return render(
|
||||
<I18nProvider locale="en" resources={TEST_RESOURCES}>
|
||||
<QueryClientProvider client={client}>
|
||||
<InvitationsPage />
|
||||
</QueryClientProvider>
|
||||
</I18nProvider>,
|
||||
<QueryClientProvider client={client}>
|
||||
<InvitationsPage />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,6 @@ import type { Invitation } from "@multica/core/types";
|
||||
import { useNavigation } from "../navigation";
|
||||
import { useLogout } from "../auth";
|
||||
import { DragStrip } from "../platform";
|
||||
import { useT } from "../i18n";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import { Card, CardContent } from "@multica/ui/components/ui/card";
|
||||
import { Checkbox } from "@multica/ui/components/ui/checkbox";
|
||||
@@ -40,7 +39,6 @@ import { LogOut, Mail, Users } from "lucide-react";
|
||||
* action.
|
||||
*/
|
||||
export function InvitationsPage() {
|
||||
const { t } = useT("invite");
|
||||
const { push } = useNavigation();
|
||||
const qc = useQueryClient();
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
@@ -114,7 +112,7 @@ export function InvitationsPage() {
|
||||
setError(
|
||||
e instanceof Error
|
||||
? e.message
|
||||
: t(($) => $.batch.error_generic),
|
||||
: "Failed to process invitations. Please try again.",
|
||||
);
|
||||
// Partial success: any accepts that landed before the failure ALREADY
|
||||
// set onboarded_at on the backend (the AcceptInvitation transaction
|
||||
@@ -159,12 +157,12 @@ export function InvitationsPage() {
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-muted">
|
||||
<Mail className="h-6 w-6 text-muted-foreground" />
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold">{t(($) => $.batch.empty_title)}</h2>
|
||||
<h2 className="text-lg font-semibold">No pending invitations</h2>
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
{t(($) => $.batch.empty_hint)}
|
||||
Continue to set up your own workspace.
|
||||
</p>
|
||||
<Button onClick={() => push(paths.onboarding())}>
|
||||
{t(($) => $.batch.empty_continue)}
|
||||
Continue to setup
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -174,8 +172,10 @@ export function InvitationsPage() {
|
||||
|
||||
const submitLabel =
|
||||
selected.size === 0
|
||||
? t(($) => $.batch.submit_skip)
|
||||
: t(($) => $.batch.submit_join, { count: selected.size });
|
||||
? "Skip and set up my own workspace"
|
||||
: selected.size === 1
|
||||
? "Join 1 workspace"
|
||||
: `Join ${selected.size} workspaces`;
|
||||
|
||||
return (
|
||||
<InvitationsShell>
|
||||
@@ -187,10 +187,11 @@ export function InvitationsPage() {
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-xl font-semibold">
|
||||
{t(($) => $.batch.title)}
|
||||
You've been invited
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(($) => $.batch.subtitle)}
|
||||
Pick the workspaces you want to join. You can always handle the
|
||||
rest later from the sidebar.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -211,7 +212,7 @@ export function InvitationsPage() {
|
||||
onClick={handleSubmit}
|
||||
disabled={submitting}
|
||||
>
|
||||
{submitting ? t(($) => $.batch.joining) : submitLabel}
|
||||
{submitting ? "Joining..." : submitLabel}
|
||||
</Button>
|
||||
|
||||
{error && (
|
||||
@@ -232,15 +233,7 @@ function InvitationRow({
|
||||
checked: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
const { t } = useT("invite");
|
||||
const inviter =
|
||||
invitation.inviter_name ||
|
||||
invitation.inviter_email ||
|
||||
t(($) => $.batch.row_inviter_fallback);
|
||||
const roleLine =
|
||||
invitation.role === "admin"
|
||||
? t(($) => $.batch.row_invited_admin, { inviter })
|
||||
: t(($) => $.batch.row_invited_member, { inviter });
|
||||
const inviter = invitation.inviter_name || invitation.inviter_email || "Someone";
|
||||
return (
|
||||
<li>
|
||||
<label
|
||||
@@ -253,10 +246,11 @@ function InvitationRow({
|
||||
/>
|
||||
<div className="flex-1 min-w-0 space-y-1">
|
||||
<div className="font-medium truncate">
|
||||
{invitation.workspace_name ?? t(($) => $.batch.row_workspace_fallback)}
|
||||
{invitation.workspace_name ?? "Workspace"}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground truncate">
|
||||
{roleLine}
|
||||
{inviter} invited you as{" "}
|
||||
{invitation.role === "admin" ? "an admin" : "a member"}
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
@@ -265,7 +259,6 @@ function InvitationRow({
|
||||
}
|
||||
|
||||
function InvitationsShell({ children }: { children: ReactNode }) {
|
||||
const { t } = useT("invite");
|
||||
const logout = useLogout();
|
||||
return (
|
||||
<div className="relative flex min-h-svh flex-col bg-background">
|
||||
@@ -277,7 +270,7 @@ function InvitationsShell({ children }: { children: ReactNode }) {
|
||||
onClick={logout}
|
||||
>
|
||||
<LogOut />
|
||||
{t(($) => $.batch.log_out)}
|
||||
Log out
|
||||
</Button>
|
||||
<div className="flex flex-1 flex-col items-center justify-center px-6 pb-12">
|
||||
{children}
|
||||
|
||||
@@ -666,7 +666,7 @@ export function IssueDetail({ issueId, onDelete, onDone, defaultSidebarOpen = tr
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<TooltipContent side="bottom">{t(($) => $.detail.archive_tooltip)}</TooltipContent>
|
||||
<TooltipContent side="bottom">Archive</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip>
|
||||
|
||||
@@ -54,9 +54,5 @@
|
||||
},
|
||||
"title_editor": {
|
||||
"title_aria_label": "Title"
|
||||
},
|
||||
"mermaid": {
|
||||
"render_error": "Unable to render Mermaid diagram.",
|
||||
"rendering": "Rendering diagram…"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,22 +33,5 @@
|
||||
"errors": {
|
||||
"accept_failed": "Failed to accept invitation",
|
||||
"decline_failed": "Failed to decline invitation"
|
||||
},
|
||||
"batch": {
|
||||
"log_out": "Log out",
|
||||
"empty_title": "No pending invitations",
|
||||
"empty_hint": "Continue to set up your own workspace.",
|
||||
"empty_continue": "Continue to setup",
|
||||
"title": "You've been invited",
|
||||
"subtitle": "Pick the workspaces you want to join. You can always handle the rest later from the sidebar.",
|
||||
"submit_skip": "Skip and set up my own workspace",
|
||||
"submit_join_one": "Join 1 workspace",
|
||||
"submit_join_other": "Join {{count}} workspaces",
|
||||
"joining": "Joining...",
|
||||
"error_generic": "Failed to process invitations. Please try again.",
|
||||
"row_workspace_fallback": "Workspace",
|
||||
"row_inviter_fallback": "Someone",
|
||||
"row_invited_admin": "{{inviter}} invited you as an admin",
|
||||
"row_invited_member": "{{inviter}} invited you as a member"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,7 +122,6 @@
|
||||
"members_group": "Members",
|
||||
"agents_group": "Agents",
|
||||
"mark_done_tooltip": "Mark as done",
|
||||
"archive_tooltip": "Archive",
|
||||
"pin_tooltip": "Pin to sidebar",
|
||||
"unpin_tooltip": "Unpin from sidebar",
|
||||
"sidebar_tooltip": "Toggle sidebar",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"preferences": {
|
||||
"appearance": {
|
||||
"theme": {
|
||||
"title": "Theme",
|
||||
"light": "Light",
|
||||
@@ -19,7 +19,7 @@
|
||||
"workspace_fallback": "Workspace",
|
||||
"tabs": {
|
||||
"profile": "Profile",
|
||||
"preferences": "Preferences",
|
||||
"appearance": "Appearance",
|
||||
"notifications": "Notifications",
|
||||
"tokens": "API Tokens",
|
||||
"general": "General",
|
||||
@@ -64,12 +64,6 @@
|
||||
"label": "Agent activity",
|
||||
"description": "When an agent task completes or fails"
|
||||
}
|
||||
},
|
||||
"system": {
|
||||
"title": "System Notifications",
|
||||
"description": "Control native OS notification banners shown when Multica is in the background.",
|
||||
"label": "Show system notifications",
|
||||
"hint": "Show a banner from your operating system for new inbox items when the app isn't focused."
|
||||
}
|
||||
},
|
||||
"tokens": {
|
||||
|
||||
@@ -54,9 +54,5 @@
|
||||
},
|
||||
"title_editor": {
|
||||
"title_aria_label": "标题"
|
||||
},
|
||||
"mermaid": {
|
||||
"render_error": "无法渲染 Mermaid 图。",
|
||||
"rendering": "渲染中…"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,21 +33,5 @@
|
||||
"errors": {
|
||||
"accept_failed": "接受邀请失败",
|
||||
"decline_failed": "拒绝邀请失败"
|
||||
},
|
||||
"batch": {
|
||||
"log_out": "退出登录",
|
||||
"empty_title": "没有待处理的邀请",
|
||||
"empty_hint": "继续创建你自己的工作区。",
|
||||
"empty_continue": "继续创建",
|
||||
"title": "你被邀请加入工作区",
|
||||
"subtitle": "选择你想加入的工作区。其余的可以之后在侧边栏处理。",
|
||||
"submit_skip": "跳过,创建我自己的工作区",
|
||||
"submit_join_other": "加入 {{count}} 个工作区",
|
||||
"joining": "加入中...",
|
||||
"error_generic": "处理邀请失败,请重试。",
|
||||
"row_workspace_fallback": "工作区",
|
||||
"row_inviter_fallback": "某人",
|
||||
"row_invited_admin": "{{inviter}} 邀请你以管理员身份加入",
|
||||
"row_invited_member": "{{inviter}} 邀请你以成员身份加入"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,7 +121,6 @@
|
||||
"members_group": "成员",
|
||||
"agents_group": "智能体",
|
||||
"mark_done_tooltip": "标记为已完成",
|
||||
"archive_tooltip": "归档",
|
||||
"pin_tooltip": "固定到侧边栏",
|
||||
"unpin_tooltip": "从侧边栏取消固定",
|
||||
"sidebar_tooltip": "切换侧边栏",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"preferences": {
|
||||
"appearance": {
|
||||
"theme": {
|
||||
"title": "主题",
|
||||
"light": "浅色",
|
||||
@@ -19,7 +19,7 @@
|
||||
"workspace_fallback": "工作区",
|
||||
"tabs": {
|
||||
"profile": "个人资料",
|
||||
"preferences": "偏好设置",
|
||||
"appearance": "外观",
|
||||
"notifications": "通知",
|
||||
"tokens": "API Token",
|
||||
"general": "通用",
|
||||
@@ -64,12 +64,6 @@
|
||||
"label": "智能体活动",
|
||||
"description": "智能体 task 完成或失败时"
|
||||
}
|
||||
},
|
||||
"system": {
|
||||
"title": "系统通知",
|
||||
"description": "控制 Multica 在后台时是否显示操作系统的原生通知横幅。",
|
||||
"label": "显示系统通知",
|
||||
"hint": "App 未获得焦点时,新的收件箱条目通过操作系统弹出通知横幅。"
|
||||
}
|
||||
},
|
||||
"tokens": {
|
||||
|
||||
@@ -55,7 +55,7 @@ vi.mock("@multica/core/auth", async () => {
|
||||
return { ...actual, useAuthStore };
|
||||
});
|
||||
|
||||
import { PreferencesTab } from "./preferences-tab";
|
||||
import { AppearanceTab } from "./appearance-tab";
|
||||
|
||||
const TEST_RESOURCES = {
|
||||
en: { common: enCommon, auth: enAuth, settings: enSettings },
|
||||
@@ -69,7 +69,7 @@ function I18nWrapper({ children }: { children: ReactNode }) {
|
||||
);
|
||||
}
|
||||
|
||||
describe("PreferencesTab — Language switcher", () => {
|
||||
describe("AppearanceTab — Language switcher", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
userRef.current = null;
|
||||
@@ -87,7 +87,7 @@ describe("PreferencesTab — Language switcher", () => {
|
||||
|
||||
it("does nothing when clicking the current locale", async () => {
|
||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
||||
render(<PreferencesTab />, { wrapper: I18nWrapper });
|
||||
render(<AppearanceTab />, { wrapper: I18nWrapper });
|
||||
|
||||
await user.click(screen.getByRole("radio", { name: "English" }));
|
||||
|
||||
@@ -98,7 +98,7 @@ describe("PreferencesTab — Language switcher", () => {
|
||||
|
||||
it("when not logged in: persists + reloads, no PATCH", async () => {
|
||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
||||
render(<PreferencesTab />, { wrapper: I18nWrapper });
|
||||
render(<AppearanceTab />, { wrapper: I18nWrapper });
|
||||
|
||||
await user.click(screen.getByRole("radio", { name: "中文" }));
|
||||
|
||||
@@ -112,7 +112,7 @@ describe("PreferencesTab — Language switcher", () => {
|
||||
userRef.current = { id: "user-1" };
|
||||
mockUpdateMe.mockResolvedValueOnce({});
|
||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
||||
render(<PreferencesTab />, { wrapper: I18nWrapper });
|
||||
render(<AppearanceTab />, { wrapper: I18nWrapper });
|
||||
|
||||
await user.click(screen.getByRole("radio", { name: "中文" }));
|
||||
|
||||
@@ -126,7 +126,7 @@ describe("PreferencesTab — Language switcher", () => {
|
||||
userRef.current = { id: "user-1" };
|
||||
mockUpdateMe.mockRejectedValueOnce(new Error("network"));
|
||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
||||
render(<PreferencesTab />, { wrapper: I18nWrapper });
|
||||
render(<AppearanceTab />, { wrapper: I18nWrapper });
|
||||
|
||||
await user.click(screen.getByRole("radio", { name: "中文" }));
|
||||
|
||||
@@ -88,7 +88,7 @@ function WindowMockup({
|
||||
);
|
||||
}
|
||||
|
||||
export function PreferencesTab() {
|
||||
export function AppearanceTab() {
|
||||
const { theme, setTheme } = useTheme();
|
||||
const { t, i18n } = useT("settings");
|
||||
const localeAdapter = useLocaleAdapter();
|
||||
@@ -104,14 +104,14 @@ export function PreferencesTab() {
|
||||
: DEFAULT_LOCALE;
|
||||
|
||||
const themeOptions = [
|
||||
{ value: "light" as const, label: t(($) => $.preferences.theme.light) },
|
||||
{ value: "dark" as const, label: t(($) => $.preferences.theme.dark) },
|
||||
{ value: "system" as const, label: t(($) => $.preferences.theme.system) },
|
||||
{ value: "light" as const, label: t(($) => $.appearance.theme.light) },
|
||||
{ value: "dark" as const, label: t(($) => $.appearance.theme.dark) },
|
||||
{ value: "system" as const, label: t(($) => $.appearance.theme.system) },
|
||||
];
|
||||
|
||||
const languageOptions: { value: SupportedLocale; label: string }[] = [
|
||||
{ value: "en", label: t(($) => $.preferences.language.english) },
|
||||
{ value: "zh-Hans", label: t(($) => $.preferences.language.chinese) },
|
||||
{ value: "en", label: t(($) => $.appearance.language.english) },
|
||||
{ value: "zh-Hans", label: t(($) => $.appearance.language.chinese) },
|
||||
];
|
||||
|
||||
// Persist locally → sync to user.language → reload. Reload (vs in-place
|
||||
@@ -137,7 +137,7 @@ export function PreferencesTab() {
|
||||
}
|
||||
|
||||
if (syncFailed) {
|
||||
toast.warning(t(($) => $.preferences.language.sync_failed));
|
||||
toast.warning(t(($) => $.appearance.language.sync_failed));
|
||||
// Give the toast 2.5s of visible time before navigating away.
|
||||
setTimeout(() => window.location.reload(), 2500);
|
||||
return;
|
||||
@@ -149,7 +149,7 @@ export function PreferencesTab() {
|
||||
<div className="space-y-8">
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-sm font-semibold">
|
||||
{t(($) => $.preferences.theme.title)}
|
||||
{t(($) => $.appearance.theme.title)}
|
||||
</h2>
|
||||
<div className="flex gap-6" role="radiogroup">
|
||||
{themeOptions.map((opt) => {
|
||||
@@ -203,7 +203,7 @@ export function PreferencesTab() {
|
||||
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-sm font-semibold">
|
||||
{t(($) => $.preferences.language.title)}
|
||||
{t(($) => $.appearance.language.title)}
|
||||
</h2>
|
||||
<div className="flex gap-3" role="radiogroup">
|
||||
{languageOptions.map((opt) => {
|
||||
@@ -10,16 +10,13 @@ import { Switch } from "@multica/ui/components/ui/switch";
|
||||
import { toast } from "sonner";
|
||||
import { useT } from "../../i18n";
|
||||
|
||||
// Inbox event groups rendered in the per-event toggle list. `system_notifications`
|
||||
// is a sibling preference key but lives in its own section below.
|
||||
const INBOX_GROUP_KEYS = [
|
||||
const NOTIFICATION_GROUP_KEYS: NotificationGroupKey[] = [
|
||||
"assignments",
|
||||
"status_changes",
|
||||
"comments",
|
||||
"updates",
|
||||
"agent_activity",
|
||||
] as const;
|
||||
type InboxGroupKey = (typeof INBOX_GROUP_KEYS)[number];
|
||||
];
|
||||
|
||||
export function NotificationsTab() {
|
||||
const { t } = useT("settings");
|
||||
@@ -43,10 +40,8 @@ export function NotificationsTab() {
|
||||
});
|
||||
};
|
||||
|
||||
const systemEnabled = preferences.system_notifications !== "muted";
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="space-y-4">
|
||||
<section className="space-y-4">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold">{t(($) => $.notifications.title)}</h2>
|
||||
@@ -57,7 +52,7 @@ export function NotificationsTab() {
|
||||
|
||||
<Card>
|
||||
<CardContent className="divide-y">
|
||||
{INBOX_GROUP_KEYS.map((key: InboxGroupKey) => {
|
||||
{NOTIFICATION_GROUP_KEYS.map((key) => {
|
||||
const enabled = preferences[key] !== "muted";
|
||||
return (
|
||||
<div
|
||||
@@ -80,32 +75,6 @@ export function NotificationsTab() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
<section className="space-y-4">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold">{t(($) => $.notifications.system.title)}</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{t(($) => $.notifications.system.description)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5 pr-4">
|
||||
<p className="text-sm font-medium">{t(($) => $.notifications.system.label)}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(($) => $.notifications.system.hint)}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={systemEnabled}
|
||||
onCheckedChange={(checked) => handleToggle("system_notifications", checked)}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,21 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import {
|
||||
User,
|
||||
SlidersHorizontal,
|
||||
Key,
|
||||
Settings,
|
||||
Users,
|
||||
FolderGit2,
|
||||
FlaskConical,
|
||||
Bell,
|
||||
} from "lucide-react";
|
||||
import { User, Palette, Key, Settings, Users, FolderGit2, FlaskConical, Bell } from "lucide-react";
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@multica/ui/components/ui/tabs";
|
||||
import { useCurrentWorkspace } from "@multica/core/paths";
|
||||
import { useNavigation } from "../../navigation";
|
||||
import { AccountTab } from "./account-tab";
|
||||
import { PreferencesTab } from "./preferences-tab";
|
||||
import { AppearanceTab } from "./appearance-tab";
|
||||
import { TokensTab } from "./tokens-tab";
|
||||
import { WorkspaceTab } from "./workspace-tab";
|
||||
import { MembersTab } from "./members-tab";
|
||||
@@ -24,10 +14,10 @@ import { LabsTab } from "./labs-tab";
|
||||
import { NotificationsTab } from "./notifications-tab";
|
||||
import { useT } from "../../i18n";
|
||||
|
||||
const ACCOUNT_TAB_KEYS = ["profile", "preferences", "notifications", "tokens"] as const;
|
||||
const ACCOUNT_TAB_KEYS = ["profile", "appearance", "notifications", "tokens"] as const;
|
||||
const ACCOUNT_TAB_ICONS = {
|
||||
profile: User,
|
||||
preferences: SlidersHorizontal,
|
||||
appearance: Palette,
|
||||
notifications: Bell,
|
||||
tokens: Key,
|
||||
} as const;
|
||||
@@ -46,9 +36,6 @@ const WORKSPACE_TAB_ICONS = {
|
||||
members: Users,
|
||||
} as const;
|
||||
|
||||
const DEFAULT_TAB = "profile";
|
||||
const TAB_QUERY_KEY = "tab";
|
||||
|
||||
export interface ExtraSettingsTab {
|
||||
value: string;
|
||||
label: string;
|
||||
@@ -64,37 +51,10 @@ interface SettingsPageProps {
|
||||
export function SettingsPage({ extraAccountTabs }: SettingsPageProps = {}) {
|
||||
const { t } = useT("settings");
|
||||
const workspaceName = useCurrentWorkspace()?.name;
|
||||
const navigation = useNavigation();
|
||||
|
||||
// Whitelist of valid tab values; unknown ?tab=… values silently fall back to
|
||||
// the default. Whitelisting also blocks junk like ?tab=<script> from
|
||||
// surfacing in the DOM via Radix Tabs internals.
|
||||
const validTabs = React.useMemo(
|
||||
() =>
|
||||
new Set<string>([
|
||||
...ACCOUNT_TAB_KEYS,
|
||||
...Object.values(WORKSPACE_TAB_VALUES),
|
||||
...(extraAccountTabs?.map((tab) => tab.value) ?? []),
|
||||
]),
|
||||
[extraAccountTabs],
|
||||
);
|
||||
|
||||
const tabFromUrl = navigation.searchParams.get(TAB_QUERY_KEY);
|
||||
const activeTab =
|
||||
tabFromUrl && validTabs.has(tabFromUrl) ? tabFromUrl : DEFAULT_TAB;
|
||||
|
||||
// replace (not push) so settings tab switches don't pollute browser history.
|
||||
// Preserve any other query params the page may carry.
|
||||
const handleTabChange = (next: string) => {
|
||||
const params = new URLSearchParams(navigation.searchParams);
|
||||
params.set(TAB_QUERY_KEY, next);
|
||||
navigation.replace(`${navigation.pathname}?${params.toString()}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={handleTabChange}
|
||||
defaultValue="profile"
|
||||
orientation="vertical"
|
||||
className="flex-1 min-h-0 gap-0 flex flex-col md:flex-row md:overflow-hidden overflow-y-auto"
|
||||
>
|
||||
@@ -142,7 +102,7 @@ export function SettingsPage({ extraAccountTabs }: SettingsPageProps = {}) {
|
||||
<div className="flex-1 min-w-0 md:overflow-y-auto">
|
||||
<div className="w-full max-w-3xl mx-auto p-4 md:p-6">
|
||||
<TabsContent value="profile"><AccountTab /></TabsContent>
|
||||
<TabsContent value="preferences"><PreferencesTab /></TabsContent>
|
||||
<TabsContent value="appearance"><AppearanceTab /></TabsContent>
|
||||
<TabsContent value="notifications"><NotificationsTab /></TabsContent>
|
||||
<TabsContent value="tokens"><TokensTab /></TabsContent>
|
||||
<TabsContent value="workspace"><WorkspaceTab /></TabsContent>
|
||||
|
||||
@@ -12,18 +12,13 @@ import (
|
||||
)
|
||||
|
||||
// validNotifGroups is the set of notification preference group keys that the
|
||||
// API accepts. Keys not in this set are rejected. `system_notifications` is
|
||||
// not an inbox event group — it's a delivery-channel toggle controlling
|
||||
// whether native OS notification banners fire — but it shares the same
|
||||
// preferences map so a single endpoint covers all user notification
|
||||
// preferences.
|
||||
// API accepts. Keys not in this set are rejected.
|
||||
var validNotifGroups = map[string]bool{
|
||||
"assignments": true,
|
||||
"status_changes": true,
|
||||
"comments": true,
|
||||
"updates": true,
|
||||
"agent_activity": true,
|
||||
"system_notifications": true,
|
||||
"assignments": true,
|
||||
"status_changes": true,
|
||||
"comments": true,
|
||||
"updates": true,
|
||||
"agent_activity": true,
|
||||
}
|
||||
|
||||
// validNotifValues is the set of allowed preference values per group.
|
||||
|
||||
Reference in New Issue
Block a user