From ff77648ca6f803aac8a43a625265f6d5b557aed2 Mon Sep 17 00:00:00 2001 From: Wen Date: Sat, 10 Oct 2026 23:49:33 +0800 Subject: [PATCH 1/2] fix(web): name every audit event in Chinese MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The audit log showed internal codes such as "auth login succeeded" for the 27 event types the Server writes but the Web did not name. They all have labels now, and an unknown type shows its category (登录事件, 其他事件) instead of the code. Co-Authored-By: Claude Opus 5.5 --- .../src/components/SettingsSections.test.tsx | 40 ++++++++++++++++++- apps/web/src/components/SettingsSections.tsx | 31 +++++++++++++- 2 files changed, 69 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/SettingsSections.test.tsx b/apps/web/src/components/SettingsSections.test.tsx index 30dfdc13..8674d9ea 100644 --- a/apps/web/src/components/SettingsSections.test.tsx +++ b/apps/web/src/components/SettingsSections.test.tsx @@ -72,7 +72,9 @@ it("lists audit events with tombstone labels and pages by the Server cursor", as expect.anything(), ); expect(view.container.querySelectorAll(".audit-list li")).toHaveLength(3); - expect(view.container.textContent).toContain("unknown kind"); + // An unknown type shows its category in Chinese, never the internal code. + expect(view.container.textContent).toContain("其他事件"); + expect(view.container.textContent).not.toContain("unknown kind"); expect(view.container.textContent).not.toContain("显示更早的记录"); } finally { await view.unmount(); @@ -202,3 +204,39 @@ it.each([ await view.unmount(); } }); + +it("names every audit event in Chinese, falling back to its category for an unknown type", async () => { + const event = (id: string, type: string, category: string) => ({ + id, + type, + category, + createdAt: "2026-10-10T00:00:00.000Z", + details: {}, + }); + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue( + respond({ + events: [ + event("a", "AUTH_LOGIN_SUCCEEDED", "authentication"), + event("b", "MODEL_CONNECTION_CREATED", "settings"), + event("c", "AUTOMATION_PAUSED", "other"), + event("d", "AUTH_PASSKEY_ENROLLED", "authentication"), + ], + }), + ), + ); + const view = await renderComponent(); + try { + await interact(() => undefined); + const titles = Array.from(view.container.querySelectorAll("strong")).map( + (item) => item.textContent, + ); + expect(titles).toEqual( + expect.arrayContaining(["登录成功", "添加模型服务", "暂停例行任务", "登录事件"]), + ); + expect(view.container.textContent).not.toMatch(/auth|passkey|automation/iu); + } finally { + await view.unmount(); + } +}); diff --git a/apps/web/src/components/SettingsSections.tsx b/apps/web/src/components/SettingsSections.tsx index f006edbc..26d2618e 100644 --- a/apps/web/src/components/SettingsSections.tsx +++ b/apps/web/src/components/SettingsSections.tsx @@ -91,7 +91,36 @@ export function ApprovalPolicySettings() { ); } +// Every event type the Server writes (apps/server/src) has a label; an unknown type falls back to +// its category, so the Owner never sees an internal code such as "auth login succeeded". const auditLabels: Record = { + AUTH_LOGIN_SUCCEEDED: "登录成功", + AUTH_LOGIN_FAILED: "登录失败", + AUTH_LOGOUT: "退出登录", + OWNER_PASSWORD_CHANGED: "修改密码", + OWNER_SESSIONS_REVOKED: "退出其他设备", + SETTINGS_OWNER_UPDATED: "更改偏好设置", + SETTINGS_APPROVAL_UPDATED: "更改审批规则", + SETTINGS_STORAGE_UPDATED: "更改存储设置", + SETTINGS_TRASH_AUTO_PURGE_RUN: "自动清空回收站", + MODEL_CONNECTION_CREATED: "添加模型服务", + MODEL_CONNECTION_UPDATED: "更新模型服务", + MODEL_CONNECTION_DELETED: "删除模型服务", + EMPLOYEE_MODEL_UPDATED: "更改 Bot 模型", + EMPLOYEE_APPEARANCE_UPDATED: "更新 Bot 头像", + BOT_GREETING_STARTED: "Bot 开始打招呼", + BOT_GREETING_FAILED: "Bot 打招呼失败", + AUTOMATION_CREATED: "创建例行任务", + AUTOMATION_PAUSED: "暂停例行任务", + AUTOMATION_RESUMED: "恢复例行任务", + AUTOMATION_DELETED: "删除例行任务", + AUTOMATION_OCCURRENCE: "执行例行任务", + CHANNEL_ATTACHMENT_PURGED: "清理频道附件", + BROWSER_COMMAND: "浏览器操作", + BROWSER_CONTROL_STATE: "切换浏览器控制", + BROWSER_HOST_BOUND: "绑定浏览器主机", + BROWSER_TRANSPORT_ADMITTED: "允许浏览器连接", + BROWSER_TRANSPORT_CLAIMED: "接管浏览器连接", CHANNEL_CREATED: "创建频道", CHANNEL_RENAMED: "重命名频道", CHANNEL_DELETED: "删除频道", @@ -130,7 +159,7 @@ function auditTitle(event: AuditEvent) { event.details.primaryBotId == null ? "未设置" : (event.details.to ?? "未知 Bot"); return `更改主 Bot:${previous} → ${current}`; } - const label = auditLabels[event.type] ?? event.type.replaceAll("_", " ").toLocaleLowerCase(); + const label = auditLabels[event.type] ?? `${auditCategoryLabels[event.category ?? "other"]}事件`; const from = event.details.from; const to = event.details.to; if (typeof from === "string" && typeof to === "string") return `${label}:${from} → ${to}`; From 434dc19073f25e76a23832a93e5c7fadf18e9b35 Mon Sep 17 00:00:00 2001 From: Wen Date: Sat, 10 Oct 2026 23:49:33 +0800 Subject: [PATCH 2/2] =?UTF-8?q?feat(web):=20nudge=20upgraded=20workspaces?= =?UTF-8?q?=20to=20choose=20a=20=E4=B8=BB=20Bot?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A workspace upgraded from before 主 Bot has none, so the crowned card never appears. The sidebar now shows a dismissible hint in its place until the Owner picks one; dismissal is remembered on the device. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/components/Sidebar.css | 44 ++++++++++++++++++++++++ apps/web/src/components/Sidebar.test.tsx | 43 +++++++++++++++++++++++ apps/web/src/components/Sidebar.tsx | 36 +++++++++++++++++++ 3 files changed, 123 insertions(+) diff --git a/apps/web/src/components/Sidebar.css b/apps/web/src/components/Sidebar.css index 07fa020f..c7801181 100644 --- a/apps/web/src/components/Sidebar.css +++ b/apps/web/src/components/Sidebar.css @@ -190,6 +190,50 @@ } /* 主 Bot leads the list as a raised card (PrimaryBot artboard); the hover and selection rules below still apply. */ +/* Upgraded workspaces without a 主 Bot get a dismissible nudge where the crowned card would be. */ +.sb .sb-primary-hint { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: start; + gap: 10px; + margin: 0 0 6px; + padding: 10px 10px 10px 12px; + border-radius: var(--ob-radius-row); + box-shadow: inset 0 0 0 1px var(--ob-divider); + color: var(--ob-text-secondary); +} +.sb .sb-primary-hint .ob-crown-glyph { + margin-top: 3px; +} +.sb .sb-primary-hint span { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} +.sb .sb-primary-hint strong { + color: var(--ob-text); + font-size: 14px; + font-weight: 600; +} +.sb .sb-primary-hint small { + font-size: 12px; + line-height: 1.45; +} +.sb .sb-primary-hint button { + display: grid; + width: 22px; + height: 22px; + place-items: center; + border: 0; + border-radius: 6px; + background: transparent; + color: inherit; + cursor: pointer; +} +.sb .sb-primary-hint button:hover { + background: var(--ob-fill-hover); +} .sb .sb-primary { padding-bottom: 2px; } diff --git a/apps/web/src/components/Sidebar.test.tsx b/apps/web/src/components/Sidebar.test.tsx index 75fb9c42..b88ea935 100644 --- a/apps/web/src/components/Sidebar.test.tsx +++ b/apps/web/src/components/Sidebar.test.tsx @@ -345,3 +345,46 @@ it("leads with the crowned 主 Bot and offers 设为主 Bot to the others", asyn await view.unmount(); } }); + +it("nudges an upgraded workspace without a 主 Bot until one is chosen or the hint is dismissed", async () => { + window.localStorage.removeItem("openbot.sidebar.primaryHintDismissed"); + const props = { + bots, + channels, + runs: [], + ownerName: "Owner", + onSelectChannel: vi.fn(), + onSelectBot: vi.fn(), + onCreateBot: vi.fn(), + onCreateChannel: vi.fn(), + onSetPrimaryBot: vi.fn(async () => undefined), + }; + const hint = (root: HTMLElement) => root.querySelector(".sb-primary-hint"); + const none = await renderComponent(); + try { + expect(hint(none.container)?.textContent).toContain("选一个主 Bot"); + await interact(() => + none.container.querySelector('[aria-label="不再提示"]')?.click(), + ); + expect(hint(none.container)).toBeNull(); + } finally { + await none.unmount(); + } + // Dismissal is remembered on this device. + const again = await renderComponent(); + try { + expect(hint(again.container)).toBeNull(); + } finally { + await again.unmount(); + } + window.localStorage.removeItem("openbot.sidebar.primaryHintDismissed"); + // A chosen 主 Bot, an older Server (undefined) or no Bots never show the hint. + for (const extra of [{ primaryBotId: "reviewer" }, { primaryBotId: undefined }, { bots: [] }]) { + const view = await renderComponent(); + try { + expect(hint(view.container)).toBeNull(); + } finally { + await view.unmount(); + } + } +}); diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 2f329e0d..2f633ac8 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -75,6 +75,15 @@ interface SidebarProps { } /** The shared sidebar of the design contract (docs/design/desktop-ui-2026-10/Sidebar.dc.html). */ +const primaryHintKey = "openbot.sidebar.primaryHintDismissed"; +function readPrimaryHintDismissed() { + try { + return window.localStorage.getItem(primaryHintKey) === "1"; + } catch { + return false; + } +} + export function Sidebar({ creatingBot, onWork, @@ -175,6 +184,22 @@ export function Sidebar({ const primaryEntry = primaryBotId ? entries.find((entry) => entry.key === `bot:${primaryBotId}`) : undefined; + // A workspace upgraded from before 主 Bot existed has none; nudge once, per device, until the + // Owner picks one or dismisses it. The Server never elects one silently during an upgrade. + const [primaryHintDismissed, setPrimaryHintDismissed] = useState(readPrimaryHintDismissed); + const suggestPrimary = + primaryBotId === null && + bots.length > 0 && + onSetPrimaryBot !== undefined && + !primaryHintDismissed; + function dismissPrimaryHint() { + setPrimaryHintDismissed(true); + try { + window.localStorage.setItem(primaryHintKey, "1"); + } catch { + // Storage can be unavailable (private window); the hint then hides for this session only. + } + } const sections = arrangeSidebar( primaryEntry ? entries.filter((entry) => entry !== primaryEntry) : entries, organization, @@ -438,6 +463,17 @@ export function Sidebar({ <> {primaryEntry ? (
{renderRow(primaryEntry, "", true)}
+ ) : suggestPrimary ? ( +
+ + + 选一个主 Bot + 没有 @ 人的消息会先交给它。右键任意 Bot,选「设为主 Bot」。 + + +
) : null} {sections.map((section) => (