Compare commits

..

1 Commits

Author SHA1 Message Date
Jiayuan Zhang
3b734239fa fix(quick-create): remove duplicate keyboard shortcut on agent submit button
The agent submit button rendered the shortcut hint twice — the i18n
string already contained '(⌘↵)' and the JSX appended another
formatShortcut() suffix. Drop the hardcoded shortcut from the
translations and rely on the platform-aware formatShortcut() in JSX.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-06 16:42:09 +08:00
26 changed files with 71 additions and 323 deletions

View File

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

View File

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

View File

@@ -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` —— 语言切换器参考
---

View File

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

View File

@@ -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 在线判断改走 RedisDB 兜底)",
"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 更新请求落 Redisserver 重启也不丢",
"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",

View File

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

View File

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

View File

@@ -3,8 +3,7 @@ export type NotificationGroupKey =
| "status_changes"
| "comments"
| "updates"
| "agent_activity"
| "system_notifications";
| "agent_activity";
export type NotificationGroupValue = "all" | "muted";

View File

@@ -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,
},
},
];

View File

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

View File

@@ -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>,
);
}

View File

@@ -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&apos;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}

View File

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

View File

@@ -54,9 +54,5 @@
},
"title_editor": {
"title_aria_label": "Title"
},
"mermaid": {
"render_error": "Unable to render Mermaid diagram.",
"rendering": "Rendering diagram…"
}
}

View File

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

View File

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

View File

@@ -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": {

View File

@@ -54,9 +54,5 @@
},
"title_editor": {
"title_aria_label": "标题"
},
"mermaid": {
"render_error": "无法渲染 Mermaid 图。",
"rendering": "渲染中…"
}
}

View File

@@ -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}} 邀请你以成员身份加入"
}
}

View File

@@ -121,7 +121,6 @@
"members_group": "成员",
"agents_group": "智能体",
"mark_done_tooltip": "标记为已完成",
"archive_tooltip": "归档",
"pin_tooltip": "固定到侧边栏",
"unpin_tooltip": "从侧边栏取消固定",
"sidebar_tooltip": "切换侧边栏",

View File

@@ -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": {

View File

@@ -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: "中文" }));

View File

@@ -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) => {

View File

@@ -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>
);
}

View File

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

View File

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