diff --git a/changelog.d/362-mobile-layout.added.md b/changelog.d/362-mobile-layout.added.md new file mode 100644 index 00000000..403356ea --- /dev/null +++ b/changelog.d/362-mobile-layout.added.md @@ -0,0 +1 @@ +On a phone browser (about 400px wide) you can skim a chat, press **Approve** / **Reject** on a held reply and work the Agent inbox: approval buttons become full-size touch targets, floating approval cards span the screen beside the icon rail, and inbox cards put their buttons under the text so titles stay readable. Wider screens are unchanged ([#362](https://github.com/ThinkFlowLab/FlyBot/issues/362)). diff --git a/core/client.js b/core/client.js index a5051ae9..7385bb98 100644 --- a/core/client.js +++ b/core/client.js @@ -16410,6 +16410,31 @@ function serveBrowserReads({ browsers, post, clientId = globalThis.crypto.random }; } +// src/client/mobile.js +var MOBILE_MAX_WIDTH = 640; +var MOBILE_CSS = ` +@media (max-width:${MOBILE_MAX_WIDTH}px){ +.bt-held-actions{max-width:100%} +.bt-held-card{padding:10px 12px} +:is(.bt-held-card,.bt-flybot-approval) .bt-flybot-approval-actions{gap:8px;margin-top:8px} +:is(.bt-held-card,.bt-flybot-approval) .bt-flybot-approval-actions>button{flex:1 1 0;min-width:0;height:40px;padding:0 16px;border-radius:999px;border:0;background:var(--bt-hover);color:var(--bt-ink);font:inherit;font-size:14px;font-weight:500;cursor:pointer} +:is(.bt-held-card,.bt-flybot-approval) .bt-flybot-approval-actions>button.bt-flybot-approve{background:var(--bt-accent);color:var(--bt-accent-ink,#fff);font-weight:600} +:is(.bt-held-card,.bt-flybot-approval) .bt-flybot-approval-actions>button:disabled{opacity:.5;cursor:default} +.bt-held-retry{min-height:32px} +.bt-flybot-approvals{left:64px;right:8px;bottom:calc(8px + env(safe-area-inset-bottom,0px));max-width:none;max-height:min(60vh,calc(100vh - 16px))} +.bt-flybot-pointers{top:8px;right:8px;max-width:calc(100vw - 72px)} +.bt-flybot-pointer{max-width:100%;min-width:0} +.bt-flybot-pointer-text{flex:0 1 auto;min-width:0;max-width:none} +.bt-flybot-pointer-note{flex:0 100 auto;min-width:0;overflow:hidden;text-overflow:ellipsis} +.bt-flybot-pointer button{flex:none;min-height:28px;padding:4px 12px} +.bt-inbox-card{flex-wrap:wrap;row-gap:8px} +.bt-inbox-copy{flex-basis:calc(100% - 38px)} +.bt-inbox-title,.bt-inbox-reason{white-space:normal;overflow-wrap:anywhere;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2} +.bt-inbox-actions{flex:1 1 100%;justify-content:flex-end;flex-wrap:wrap;padding-left:38px} +.bt-inbox-actions :is(.bt-soft,.bt-send){height:36px;padding:0 16px;font-size:13px} +} +`; + // src/client/friend-lines.js function friendLineNews(bots, known, isOpen, selfId) { const next = { ...known }; @@ -17579,6 +17604,7 @@ function apply(ctx) { installCompiledStyles(FLYBOT_CSS, "flybot"), installCompiledStyles(MEMBER_CSS, "members"), installCompiledStyles(USER_MESSAGE_CSS, "user-messages"), + installCompiledStyles(MOBILE_CSS, "mobile"), browsers ? installCompiledStyles(BROWSER_CSS, "browser") : null, installTheme(), shadow("sidebar.workspaces", { priority: -10, inject: face }, TeamSidebar), diff --git a/core/src/client/index.js b/core/src/client/index.js index 13496f79..163570eb 100644 --- a/core/src/client/index.js +++ b/core/src/client/index.js @@ -29,6 +29,7 @@ import { FOLDER_CSS } from './folder-menu.js' import { USAGE_CSS } from './usage-page.js' import { SCHEDULE_CSS } from './schedules.js' import { INBOX_CSS } from './inbox.js' +import { MOBILE_CSS } from './mobile.js' import { STORAGE, migrateStorage } from './storage.js' import { friendLineNews } from './friend-lines.js' import { groupsFor, readMode, surfaceGroups, writeMode } from './surface.js' @@ -849,6 +850,7 @@ export function apply(ctx) { installCompiledStyles(FLYBOT_CSS, 'flybot'), installCompiledStyles(MEMBER_CSS, 'members'), installCompiledStyles(USER_MESSAGE_CSS, 'user-messages'), + installCompiledStyles(MOBILE_CSS, 'mobile'), browsers ? installCompiledStyles(BROWSER_CSS, 'browser') : null, installTheme(), shadow('sidebar.workspaces', { priority: -10, inject: face }, TeamSidebar), diff --git a/core/src/client/mobile.js b/core/src/client/mobile.js new file mode 100644 index 00000000..402576f4 --- /dev/null +++ b/core/src/client/mobile.js @@ -0,0 +1,31 @@ +// Phone-width layout, step 1 (#362): at about 400px a phone browser can skim a chat, press +// Approve / Reject on a held reply and work the Agent inbox. Only these surfaces change; +// the shell keeps its own icon rail (DSH collapses the sidebar below 1024px; the floating +// cards stay clear of its 56px, so the top + and the account stay reachable), and the inbox +// dialog already fills the screen at this width (styles.js). Installed after the shell +// group's other styles, so these rules win at equal specificity. No PWA, push or native +// app here: they come after #352, and a notification will only ever open the card. +export const MOBILE_MAX_WIDTH = 640 + +export const MOBILE_CSS = ` +@media (max-width:${MOBILE_MAX_WIDTH}px){ +.bt-held-actions{max-width:100%} +.bt-held-card{padding:10px 12px} +:is(.bt-held-card,.bt-flybot-approval) .bt-flybot-approval-actions{gap:8px;margin-top:8px} +:is(.bt-held-card,.bt-flybot-approval) .bt-flybot-approval-actions>button{flex:1 1 0;min-width:0;height:40px;padding:0 16px;border-radius:999px;border:0;background:var(--bt-hover);color:var(--bt-ink);font:inherit;font-size:14px;font-weight:500;cursor:pointer} +:is(.bt-held-card,.bt-flybot-approval) .bt-flybot-approval-actions>button.bt-flybot-approve{background:var(--bt-accent);color:var(--bt-accent-ink,#fff);font-weight:600} +:is(.bt-held-card,.bt-flybot-approval) .bt-flybot-approval-actions>button:disabled{opacity:.5;cursor:default} +.bt-held-retry{min-height:32px} +.bt-flybot-approvals{left:64px;right:8px;bottom:calc(8px + env(safe-area-inset-bottom,0px));max-width:none;max-height:min(60vh,calc(100vh - 16px))} +.bt-flybot-pointers{top:8px;right:8px;max-width:calc(100vw - 72px)} +.bt-flybot-pointer{max-width:100%;min-width:0} +.bt-flybot-pointer-text{flex:0 1 auto;min-width:0;max-width:none} +.bt-flybot-pointer-note{flex:0 100 auto;min-width:0;overflow:hidden;text-overflow:ellipsis} +.bt-flybot-pointer button{flex:none;min-height:28px;padding:4px 12px} +.bt-inbox-card{flex-wrap:wrap;row-gap:8px} +.bt-inbox-copy{flex-basis:calc(100% - 38px)} +.bt-inbox-title,.bt-inbox-reason{white-space:normal;overflow-wrap:anywhere;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2} +.bt-inbox-actions{flex:1 1 100%;justify-content:flex-end;flex-wrap:wrap;padding-left:38px} +.bt-inbox-actions :is(.bt-soft,.bt-send){height:36px;padding:0 16px;font-size:13px} +} +` diff --git a/core/test/mobile-layout.test.js b/core/test/mobile-layout.test.js new file mode 100644 index 00000000..9fdcce65 --- /dev/null +++ b/core/test/mobile-layout.test.js @@ -0,0 +1,60 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { test } from 'node:test' +import { MOBILE_CSS, MOBILE_MAX_WIDTH } from '../src/client/mobile.js' + +// Phone-width layout, step 1 (#362): chat, the held-reply Approve / Reject and the Agent +// inbox fit a ~400px phone browser. Only a max-width media query, so wider screens are as before. +const rule = (selector) => { + const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + return MOBILE_CSS.match(new RegExp(`(?:^|\\n)${escaped}\\{([^}]*)\\}`))?.[1] ?? '' +} + +test('every phone rule sits inside one max-width media query at 640px', () => { + assert.equal(MOBILE_MAX_WIDTH, 640) + const body = MOBILE_CSS.trim() + assert.ok(body.startsWith('@media (max-width:640px){'), 'opens with the phone media query') + assert.ok(body.endsWith('}'), 'closes it') + assert.equal((body.match(/@media/g) ?? []).length, 1) + // Balanced braces: nothing leaks out of the media block. + const open = body.indexOf('{') + let depth = 0 + for (const [i, ch] of [...body].entries()) { + if (ch === '{') depth++ + if (ch === '}') depth-- + if (i >= open && depth === 0 && i < body.length - 1) assert.fail(`rule outside the media query at ${i}`) + } + assert.equal(depth, 0) +}) + +test('held-reply and floating approval buttons are touch-sized, Approve filled', () => { + const button = rule(':is(.bt-held-card,.bt-flybot-approval) .bt-flybot-approval-actions>button') + assert.match(button, /height:40px/) + assert.match(button, /flex:1 1 0/) + assert.match(rule(':is(.bt-held-card,.bt-flybot-approval) .bt-flybot-approval-actions>button.bt-flybot-approve'), /background:var\(--bt-accent\)/) + assert.match(rule('.bt-held-actions'), /max-width:100%/) +}) + +test('floating cards span the screen beside the icon rail, not over the top + or the account', () => { + const stack = rule('.bt-flybot-approvals') + assert.match(stack, /left:64px/) + assert.match(stack, /right:8px/) + assert.match(stack, /max-width:none/) + assert.match(rule('.bt-flybot-pointers'), /max-width:calc\(100vw - 72px\)/) +}) + +test('inbox cards wrap their actions under the text so the title stays readable', () => { + assert.match(rule('.bt-inbox-card'), /flex-wrap:wrap/) + assert.match(rule('.bt-inbox-actions'), /flex:1 1 100%/) + assert.match(rule('.bt-inbox-actions :is(.bt-soft,.bt-send)'), /height:36px/) + assert.match(rule('.bt-inbox-title,.bt-inbox-reason'), /white-space:normal/) +}) + +test('the client installs the phone styles after the shell styles they override', () => { + const index = readFileSync(new URL('../src/client/index.js', import.meta.url), 'utf8') + const at = name => index.indexOf(`installCompiledStyles(${name}`) + assert.ok(at('MOBILE_CSS') > 0, 'MOBILE_CSS is installed') + for (const earlier of ['SHELL_CSS', 'INBOX_CSS', 'FLYBOT_CSS']) assert.ok(at(earlier) >= 0 && at(earlier) < at('MOBILE_CSS'), `${earlier} comes first`) + const bundle = readFileSync(new URL('../client.js', import.meta.url), 'utf8') + assert.ok(bundle.includes('.bt-inbox-actions :is(.bt-soft,.bt-send){height:36px'), 'client.js carries the phone styles') +}) diff --git a/docs/subsystems/core-client.md b/docs/subsystems/core-client.md index e07f093a..725dcb3c 100644 --- a/docs/subsystems/core-client.md +++ b/docs/subsystems/core-client.md @@ -31,6 +31,7 @@ The browser side of the patched `ds-bot` core (`core/`): sources in `core/src/cl | `core/test/new-chat.test.js` | Picker filters and the **+** short action menu (`data-menu`; New chat, New Bot, New group chat, plugin actions, then Add coding agent; #385 marks, verbs and hints). `ungrantedLocalFix`, `copiedToClipboard` (no clipboard or a refused write is a failure) and the refusal card (invite and leave-out actions, the copy-by-hand link field, no grant written by the client). | | `core/src/client/schedules.js` | Settings → Scheduled tasks, a Bot's card and the details line. Each row has **Edit** (pencil; the row turns into the same form that creates a task, filled in, with **Save changes**; an edited task keeps its time zone, a cron rule it cannot show is kept) and **Pause** / **Resume**; a paused row reads **Paused** (#354). With Automation tasks off, the hint reads "Switch on Automation tasks on the Plugins page." and the button **Open Plugins** closes the Settings dialog and opens DSH's Plugins panel (`actions.selectPanel('plugins')`); the Harness settings dialog has no such switch (#378). Tests: `core/test/schedules-client.test.js`. | | `core/src/client/inbox.js` | Agent inbox, step 1 (#349): the **Inbox** row at the top of the sidebar (`InboxRow`, count badge = cards in *Needs you*) and the inbox dialog (`InboxDialog`, `ui.inbox`, `actions.openInbox` / `closeInbox`) with the groups **Needs you**, **In progress** and **Done** (`inboxModel`). Tests: `core/test/inbox.test.js`. | +| `core/src/client/mobile.js` | Phone-width layout, step 1 (#362): `MOBILE_CSS` under `@media (max-width:640px)` for held-reply and floating approval buttons, the chat pointers and Agent inbox cards. Tests: `core/test/mobile-layout.test.js`. | ## Invariants @@ -108,6 +109,13 @@ The overlay (`flybot-slots.js` `FlyBotApprovals`) is the fallback, not a second - Each card has exactly one status, as a colored pill with an icon and a word (never color alone), the Bot, where and when, the title on one line with the full text on hover, and **Open** (closes the inbox, opens the chat). An approval the group chat's card can answer (`answerable`) also has **Reject** / **Allow once**, which call `actions.answerApproval`, the same answer as that card (no second approval path); the card leaves and a status line says what was done; an error stays on the card. No native confirm dialog. A held Bot message, an approval DSH's card answers in the Bot's own chat, and every task have **Open** only: Bot-sent messages are approved in their chat. Every action is a button, so the dialog is keyboard reachable; the close button takes focus and Escape closes. - Out of step 1: the three-column board, tool-call counts, failed turns without a task record (they need an event source, #353), team views (#51/#149). +### Phone-width layout (#362, step 1) + +- Below 640px (`MOBILE_MAX_WIDTH`, `core/src/client/mobile.js`, `MOBILE_CSS`, installed last in the shell group so it wins at equal specificity) a ~400px phone browser can skim a chat, press **Approve** / **Reject** on a held reply and work the Agent inbox. Wider screens are unchanged: every rule sits in that one media query. +- Held-reply and floating approval cards: **Reject** / **Approve** share the card width as 40px buttons, **Approve** filled with the accent; the held card uses the message's full width. The floating approval stack and the chat pointers span the screen beside the shell's 56px icon rail (DSH collapses the sidebar below 1024px), so the top **+** and the account stay reachable. +- Inbox cards put their buttons on a row under the text (36px), and the title and reason wrap to two lines instead of one truncated line. The inbox dialog already fills the screen at this width (`.bt-mem`). +- Out of step 1 (owner decision 2026-10-20): PWA install and push (after #352), native apps. A notification will only ever open the card; approval stays on the in-app buttons. + ### Approval wait badge on chat rows - A Bot row uses the same gold wait dot as a room row when `bot.pendingCount > 0` (Bot Social holds on that friend's chat) or a local hold is open. Shared-room overview pending lines stay one line with ellipsis (`bt-shared-wait`). diff --git a/docs/subsystems/social.md b/docs/subsystems/social.md index 2e838dda..5fd1c97e 100644 --- a/docs/subsystems/social.md +++ b/docs/subsystems/social.md @@ -110,6 +110,7 @@ pins the list. The authority for the list is Bot Social's README and `docs/FACE_ - Status chips on room details and Past rooms (`data-flybot=face-status`) read **Open** / **Closed** in sentence case with readable contrast, and the past row keeps its own spacing between avatars, title, time and chip (#170). - One card-placement rule (#418): a held message's decision sits where that message is. A Bot's reply to a friend has its buttons on the held reply bubble in place (no copy in the dock of that chat or the friend's chat); a message with no bubble of its own (a Bot's `message_friend_bot`, a typed composer line held by policy, a face-room line) has its card in the dock of the chat it belongs to, as below. - In-chat approval cards (`flybot.session.dock`) are right-aligned bubbles capped at ~460px: **Waiting for approval**, one `from → to` line, a word-wrapped preview with **Show more**, and **Approve** / **Reject** (one right-hand group) with a quiet **Always approve** link at the left of the same row, alone above them when the card is narrow (#166, #260, #271). One place per approval (#278): a Bot's direct message to a friend is drawn in the friend's chat once that chat exists (the sending Bot's own chat only until then), never in a Face-to-face room that has the friend in it; a Face-to-face room shows, counts as waiting, and gets a "Waiting for the owner's approval" notice for just the lines held for its own room id. The client also hides that old notice in a Face-to-face room's history. The held text shows once (#306): a Bot line held in a Face-to-face room keeps its text in the card, and the room draws no bubble for it until it is approved (`GroupMessage`, `core/src/client/cells.js`, matched by `room.heldLines`, #456, and by `room.pendingApprovals` as a fallback); a line the owner typed is never held and stays a plain bubble. A Bot's message to a friend (`message_friend_bot`) has no bubble in either chat, so the card is already its only place. #456: the held room line lives only on its card (greyed preview) until **Approve**: `onFaceLine` answers `{ held: true, approvalId }` for a Bot line Bot Social holds, `social/decide-approval` calls `flybot.rooms.settleHeld`, Approve makes it a room bubble and Reject a grey ` · Not sent` line, with no `Rejected.` note in the face room. A decided card leaves the dock at once (no **Approved** / **Rejected** stub, any card kind). A face card's `from` is the room's own #384 label for that local Bot (`Researcher (Alice) → the room`, `faceFromLabel`). `applyFaceSnapshot` posts a human or system event for a round only when the core room logged it now (`applyFaceLine` refuses an event id it has), so an event read twice starts no second round. +- On a phone-width screen (below 640px, #362) the hold card's **Approve** / **Reject** are 40px touch targets at the right of their row; wider screens keep the compact 28px buttons. - The composer **Message {name}** accessible name is set by core `HeaderPill` (placeholder CSS var plus `aria-label` on `[data-composer-input]`, restored on cleanup); chat-header tok jargon stays in pristine DSH / ds-bot. ### Face-room poll identity (#122) diff --git a/patches/ds-bot/0003-flybot-api-client.patch b/patches/ds-bot/0003-flybot-api-client.patch index e02c40ed..92277ac6 100644 --- a/patches/ds-bot/0003-flybot-api-client.patch +++ b/patches/ds-bot/0003-flybot-api-client.patch @@ -1009,7 +1009,7 @@ diff --git a/src/client/icons.js b/src/client/icons.js diff --git a/src/client/index.js b/src/client/index.js --- a/src/client/index.js +++ b/src/client/index.js -@@ -1,44 +1,61 @@ +@@ -1,44 +1,62 @@ +import { FLYBOT_CSS, FlyBotApprovals, FlyBotSessionDock, createClientApi as createFlyBotClient } from './flybot-slots.js' import { call } from './api.js' import { isHex, inkText, paintCss } from './inks.js' @@ -1046,6 +1046,7 @@ diff --git a/src/client/index.js b/src/client/index.js import { USAGE_CSS } from './usage-page.js' import { SCHEDULE_CSS } from './schedules.js' +import { INBOX_CSS } from './inbox.js' ++import { MOBILE_CSS } from './mobile.js' import { STORAGE, migrateStorage } from './storage.js' +import { friendLineNews } from './friend-lines.js' import { groupsFor, readMode, surfaceGroups, writeMode } from './surface.js' @@ -1296,7 +1297,7 @@ diff --git a/src/client/index.js b/src/client/index.js actions.referenceChat = (sessionId, span) => { const controller = atMenu?.controllerOf(sessionId) if (controller === undefined || span === undefined) return -@@ -752,11 +752,13 @@ export function apply(ctx) { +@@ -752,27 +752,33 @@ export function apply(ctx) { installCompiledStyles(SHELL_CSS, 'shell'), installCompiledStyles(SIDEBAR_CSS, 'sidebar'), installCompiledStyles(SECRET_CSS, 'secrets'), @@ -1310,8 +1311,10 @@ diff --git a/src/client/index.js b/src/client/index.js + installCompiledStyles(FLYBOT_CSS, 'flybot'), installCompiledStyles(MEMBER_CSS, 'members'), installCompiledStyles(USER_MESSAGE_CSS, 'user-messages'), ++ installCompiledStyles(MOBILE_CSS, 'mobile'), browsers ? installCompiledStyles(BROWSER_CSS, 'browser') : null, -@@ -765,14 +765,17 @@ export function apply(ctx) { + installTheme(), + shadow('sidebar.workspaces', { priority: -10, inject: face }, TeamSidebar), shadow('sidebar.brand.mark', { priority: -10 }, BrandMark), shadow('sidebar.brand.name', { priority: -10 }, BrandName), shadow('sidebar.footer.action', { id: 'bot-account', order: -200, inject: face }, FooterAccount), diff --git a/patches/ds-bot/0008-client-bundle.patch b/patches/ds-bot/0008-client-bundle.patch index c1f83416..f1930f49 100644 --- a/patches/ds-bot/0008-client-bundle.patch +++ b/patches/ds-bot/0008-client-bundle.patch @@ -1030,6 +1030,7 @@ diff --git a/client.js b/client.js - value = resolved; - for (const listener of [...listeners2]) listener(); - } +- }; +// src/client/mark.js +var import_react = require("react"); +var STATES = ["idle", "thinking", "searching", "working", "sending", "orbit", "alert", "done"]; @@ -1066,7 +1067,21 @@ diff --git a/client.js b/client.js + document.documentElement.removeEventListener("pointerleave", onLeave); + }; + }, [enabled]); -+} + } +-var EMPTY_ROSTER = { revision: -1, mainBotId: null, mainBotIds: [], bots: [], rooms: [], agents: [], exchangeTurns: {}, questions: {}, secrets: [], capabilities: [], capabilityStatuses: {}, secretRequests: {}, models: [], defaultModel: null, modelsKey: null, prefs: {}, byId: {}, roomsById: {}, agentsById: {}, agentOf: {}, ready: false }; +-var isMainOf = (roster, id) => id !== void 0 && (roster.mainBotIds ?? []).includes(id); +-function indexRoster(value) { +- const byId = {}; +- for (const bot of value.bots) { +- for (const id of [...bot.parts ?? [], bot.sessionId ?? bot.id, bot.id]) byId[id] = bot; +- } +- const roomsById = {}; +- for (const room2 of value.rooms) roomsById[room2.id] = room2; +- const agentsById = {}; +- const agentOf = {}; +- for (const agent of value.agents ?? []) { +- agentsById[agent.id] = agent; +- for (const sessionId of agent.sessions ?? []) agentOf[sessionId] = agent.id; +var FIDGETS = ["hop", "sway", "stretch", "look"]; +function BotMark({ bot, look: given, size = 36, state = "idle", live = false, gaze = false, pokeable = false, label }) { + const look = given ?? (bot?.remote ? lookAvoidingLocals(bot) : lookOf(bot)); @@ -1092,30 +1107,35 @@ diff --git a/client.js b/client.js + onAnimationEnd: poked ? (event) => { + if (/^bt-poke/.test(event.animationName)) setPoked(false); + } : void 0 - }; ++ }; + if (look.image) return (0, import_react.createElement)("span", common, (0, import_react.createElement)("img", { className: "bt-mark-img", src: look.image, alt: "", draggable: false })); + if (look.emoji) { + return (0, import_react.createElement)("span", common, (0, import_react.createElement)("span", { + className: "bt-mark-emoji", + style: { fontSize: Math.max(12, Math.round(size * 0.62)), lineHeight: 1 } + }, look.emoji)); -+ } + } +- return { ...value, agents: value.agents ?? [], byId, roomsById, agentsById, agentOf, ready: true }; + const box = shapeOf(look.shape).size; + return (0, import_react.createElement)("span", common, (0, import_react.createElement)("svg", { viewBox: `0 0 ${box} ${box}`, dangerouslySetInnerHTML: { __html: characterMarkup(look.shape, look.color).replaceAll(MASK_ID, maskId.current) } })); } --var EMPTY_ROSTER = { revision: -1, mainBotId: null, mainBotIds: [], bots: [], rooms: [], agents: [], exchangeTurns: {}, questions: {}, secrets: [], capabilities: [], capabilityStatuses: {}, secretRequests: {}, models: [], defaultModel: null, modelsKey: null, prefs: {}, byId: {}, roomsById: {}, agentsById: {}, agentOf: {}, ready: false }; --var isMainOf = (roster, id) => id !== void 0 && (roster.mainBotIds ?? []).includes(id); --function indexRoster(value) { -- const byId = {}; -- for (const bot of value.bots) { -- for (const id of [...bot.parts ?? [], bot.sessionId ?? bot.id, bot.id]) byId[id] = bot; +-function isAgentSession(roster, cache, sessionId) { +- if (sessionId === void 0 || sessionId === null || sessionId === "") return false; +- if (roster.ready) return roster.agentOf[sessionId] !== void 0; +- return cache.has(sessionId); +function StarIcon({ size = 8 }) { + return (0, import_react.createElement)( + "svg", + { width: size, height: size, viewBox: "0 0 10 10", "aria-hidden": true }, + (0, import_react.createElement)("path", { d: "M5 .6l1.3 2.8 3 .3-2.3 2 .7 3L5 7.2 2.3 8.7l.7-3L.7 3.7l3-.3z", fill: "#fff" }) + ); -+} + } +-function countCards(state, sessionId, callIds, delta) { +- const own = { ...state[sessionId] }; +- for (const callId of callIds) { +- const count = (own[callId] ?? 0) + delta; +- if (count > 0) own[callId] = count; +- else delete own[callId]; +var personInitial = (name) => (Array.from(String(name ?? "").trim())[0] ?? "").toUpperCase(); +var personColor = (name) => defaultColor(`person:${String(name ?? "").trim().toLowerCase()}`); +function PersonMark({ size = 22, name }) { @@ -1128,13 +1148,7 @@ diff --git a/client.js b/client.js + "aria-hidden": true + }, initial); } -- const roomsById = {}; -- for (const room2 of value.rooms) roomsById[room2.id] = room2; -- const agentsById = {}; -- const agentOf = {}; -- for (const agent of value.agents ?? []) { -- agentsById[agent.id] = agent; -- for (const sessionId of agent.sessions ?? []) agentOf[sessionId] = agent.id; +- return { ...state, [sessionId]: own }; + return (0, import_react.createElement)( + "span", + { className: "bt-person-mark", style: { width: size, height: size }, "aria-hidden": true }, @@ -1145,10 +1159,93 @@ diff --git a/client.js b/client.js + (0, import_react.createElement)("path", { d: "M3.5 21c0-4.7 3.8-7.6 8.5-7.6s8.5 2.9 8.5 7.6z" }) + ) + ); -+} + } +- +-// src/client/themes.js +-var THEME_KEYS = ["bg", "sidebar", "bubble", "card", "text", "user", "userText", "accent"]; +-var THEMES = { +- deepseek: { +- label: "DeepSeek", +- light: { bg: "#fcfcfc", sidebar: "#f7f7f7", bubble: "#eeeeee", card: "#ffffff", text: "#141414", user: "#070707", userText: "#fcfcfc", accent: "#4d6bfe" }, +- dark: { bg: "#141414", sidebar: "#1b1b1b", bubble: "#262626", card: "#1f1f1f", text: "#f2f2f2", user: "#f2f2f2", userText: "#141414", accent: "#5b7bff" } +- }, +- mono: { +- label: "Mono", +- light: { bg: "#ffffff", sidebar: "#f9f9f9", bubble: "#f0f0f0", card: "#ffffff", text: "#0d0d0d", user: "#0d0d0d", userText: "#ffffff", accent: "#0d0d0d" }, +- dark: { bg: "#0f0f0f", sidebar: "#171717", bubble: "#242424", card: "#1c1c1c", text: "#f4f4f4", user: "#f4f4f4", userText: "#0f0f0f", accent: "#f4f4f4" } +- }, +- paper: { +- label: "Paper", +- light: { bg: "#faf9f5", sidebar: "#f3f0e8", bubble: "#ebe7dc", card: "#ffffff", text: "#1f1e1b", user: "#2b2a26", userText: "#faf9f5", accent: "#d97757" }, +- dark: { bg: "#1f1e1b", sidebar: "#262521", bubble: "#34322d", card: "#2a2925", text: "#f3f0e8", user: "#f3f0e8", userText: "#1f1e1b", accent: "#e08a6d" } +- }, +- moonlight: { +- label: "Moonlight", +- light: { bg: "#f7f9fc", sidebar: "#eef2f8", bubble: "#e5ebf5", card: "#ffffff", text: "#121826", user: "#1e3a8a", userText: "#ffffff", accent: "#1e86ff" }, +- dark: { bg: "#0b1020", sidebar: "#10172a", bubble: "#1a2340", card: "#141c33", text: "#e8edf7", user: "#3b6fd8", userText: "#ffffff", accent: "#4f9dff" } +- } +-}; +-var rgba = (hex, alpha) => `rgba(${[1, 3, 5].map((index) => parseInt(hex.slice(index, index + 2), 16)).join(",")},${alpha})`; +-function themeVars(mode, dark, accent = mode.accent) { +- return { +- "--bt-ink": mode.text, +- "--bt-ink-2": rgba(mode.text, 0.6), +- "--bt-ink-3": rgba(mode.text, 0.36), +- "--bt-bubble": mode.bubble, +- "--bt-sidebar": mode.sidebar, +- "--bt-main": mode.bg, +- "--bt-card": mode.card, +- "--bt-line": rgba(mode.text, 0.08), +- "--bt-line-2": rgba(mode.text, 0.15), +- "--bt-hover": dark ? "rgba(160,160,160,.12)" : "rgba(119,119,119,.09)", +- "--bt-active": dark ? "rgba(160,160,160,.2)" : "rgba(119,119,119,.17)", +- "--bt-accent": accent, +- "--bt-accent-ink": luminance(accent) > 0.45 ? "#141414" : "#ffffff", +- "--bt-user": mode.user, +- "--bt-user-ink": mode.userText, +- "--bt-tag-bg": rgba(mode.text, dark ? 0.09 : 0.06), +- "--bt-tag-ink": rgba(mode.text, 0.58) +- }; +function AdminStar() { + return (0, import_react.createElement)("span", { className: "bt-admin-star", role: "img", "aria-label": t("Admin") }, (0, import_react.createElement)(StarIcon, { size: 7 })); -+} + } +-var declarations = (vars) => Object.entries(vars).map(([name, value]) => `${name}:${value}`).join(";"); +-var themeCss = (theme, accent) => `html body{${declarations(themeVars(theme.light, false, accent ?? theme.light.accent))}} +-html body[data-ds-dark-theme]{${declarations(themeVars(theme.dark, true, accent ?? theme.dark.accent))}} +-${theme.css ?? ""}`; +-var shellTokens = ({ light, dark }) => ({ +- "--dsw-alias-bg-base": { light: light.bg, dark: dark.bg }, +- "--dsw-specific-sidebar-fill": { light: light.sidebar, dark: dark.sidebar }, +- "--dsw-specific-input-major": { light: light.bg, dark: dark.sidebar }, +- "--dsw-alias-label-primary": { light: light.text, dark: dark.text }, +- "--dsw-alias-label-secondary": { light: rgba(light.text, 0.74), dark: rgba(dark.text, 0.74) }, +- "--dsw-alias-label-tertiary": { light: rgba(light.text, 0.6), dark: rgba(dark.text, 0.6) } +-}); +-var customThemes = {}; +-var registry = createSource(0); +-var isPoint = (point) => Array.isArray(point) && point.length === 2 && point.every(Number.isFinite); +-function registerShape(id, shape) { +- if (typeof id !== "string" || !/^[a-z][\w-]{0,31}$/.test(id)) throw new Error("Shape ids are lowercase words"); +- if (typeof shape?.body !== "function" || typeof shape.body("#000") !== "string") throw new Error("A shape needs body(paint) returning SVG markup"); +- const at = shape.eyes?.at; +- if (!Array.isArray(at) || at.length !== 2 || !at.every(isPoint)) throw new Error("A shape needs eyes.at: two [x, y] points"); +- const size = Number.isFinite(shape.size) && shape.size > 0 ? shape.size : 100; +- const pick2 = (key, fallback) => Number.isFinite(shape.eyes[key]) ? shape.eyes[key] : fallback; +- const eyes = at.map(([x, y]) => [x, y, pick2("w", size * 0.1), pick2("h", size * 0.23), pick2("tilt", -27)]); +- const previous = SHAPES[id]; +- SHAPES[id] = { +- label: String(shape.label ?? id), +- size, +- body: shape.body, +- eyes, +- ...typeof shape.wrap === "string" ? { wrap: shape.wrap } : {} +- }; +- const refresh = () => { +- markupCache.clear(); +- maskCache.delete(id); +- registry.set((value) => value + 1); +- }; +- refresh(); +function MainStar() { + return (0, import_react.createElement)( + "svg", @@ -1194,15 +1291,10 @@ diff --git a/client.js b/client.js + const size = frame * 5 / 9; + const rest = frame - size; + return [[rest / 2, 0, size], [0, rest, size], [rest, rest, size]]; - } -- return { ...value, agents: value.agents ?? [], byId, roomsById, agentsById, agentOf, ready: true }; ++ } + const half = frame / 2; + return [[0, 0, half], [half, 0, half], [0, half, half], [half, half, half]]; - } --function isAgentSession(roster, cache, sessionId) { -- if (sessionId === void 0 || sessionId === null || sessionId === "") return false; -- if (roster.ready) return roster.agentOf[sessionId] !== void 0; -- return cache.has(sessionId); ++} +function roomAvatarPeople(room2, roster) { + const members = (room2?.members ?? []).map((id) => roster?.byId?.[id]).filter(Boolean); + const seen = new Set(members.map((bot) => bot.id)); @@ -1235,13 +1327,7 @@ diff --git a/client.js b/client.js + } + if (members.length === 1 && remotes.length === 0) return [members[0], null]; + return [...members, ...remotes]; - } --function countCards(state, sessionId, callIds, delta) { -- const own = { ...state[sessionId] }; -- for (const callId of callIds) { -- const count = (own[callId] ?? 0) + delta; -- if (count > 0) own[callId] = count; -- else delete own[callId]; ++} +function RoomAvatar({ room: room2, roster, size = 36 }) { + const people2 = roomAvatarPeople(room2, roster); + const seat = (bot, seatSize, key) => bot ? (0, import_react.createElement)(BotMark, { key, bot, size: seatSize }) : (0, import_react.createElement)("span", { key, className: "bt-cluster-you", style: { width: seatSize * 0.92, height: seatSize * 0.92, margin: seatSize * 0.04 } }); @@ -1253,8 +1339,7 @@ diff --git a/client.js b/client.js + people2.slice(0, 3).map((bot, index) => seat(bot, size, bot?.id ?? `you-${index}`)), + people2.length > 3 ? (0, import_react.createElement)("span", { className: "bt-stack-more" }, `+${people2.length - 3}`) : null + ); - } -- return { ...state, [sessionId]: own }; ++ } + const seats = clusterSeats(Math.min(people2.length, 4), size); + const overflow = people2.length > 4 ? people2.length - 3 : 0; + const gap = size / 14; @@ -1273,97 +1358,15 @@ diff --git a/client.js b/client.js + return (0, import_react.createElement)("span", { key: bot?.id ?? "you", style }, seat(bot, s)); + }) + ); - } - --// src/client/themes.js --var THEME_KEYS = ["bg", "sidebar", "bubble", "card", "text", "user", "userText", "accent"]; --var THEMES = { -- deepseek: { -- label: "DeepSeek", -- light: { bg: "#fcfcfc", sidebar: "#f7f7f7", bubble: "#eeeeee", card: "#ffffff", text: "#141414", user: "#070707", userText: "#fcfcfc", accent: "#4d6bfe" }, -- dark: { bg: "#141414", sidebar: "#1b1b1b", bubble: "#262626", card: "#1f1f1f", text: "#f2f2f2", user: "#f2f2f2", userText: "#141414", accent: "#5b7bff" } -- }, -- mono: { -- label: "Mono", -- light: { bg: "#ffffff", sidebar: "#f9f9f9", bubble: "#f0f0f0", card: "#ffffff", text: "#0d0d0d", user: "#0d0d0d", userText: "#ffffff", accent: "#0d0d0d" }, -- dark: { bg: "#0f0f0f", sidebar: "#171717", bubble: "#242424", card: "#1c1c1c", text: "#f4f4f4", user: "#f4f4f4", userText: "#0f0f0f", accent: "#f4f4f4" } -- }, -- paper: { -- label: "Paper", -- light: { bg: "#faf9f5", sidebar: "#f3f0e8", bubble: "#ebe7dc", card: "#ffffff", text: "#1f1e1b", user: "#2b2a26", userText: "#faf9f5", accent: "#d97757" }, -- dark: { bg: "#1f1e1b", sidebar: "#262521", bubble: "#34322d", card: "#2a2925", text: "#f3f0e8", user: "#f3f0e8", userText: "#1f1e1b", accent: "#e08a6d" } -- }, -- moonlight: { -- label: "Moonlight", -- light: { bg: "#f7f9fc", sidebar: "#eef2f8", bubble: "#e5ebf5", card: "#ffffff", text: "#121826", user: "#1e3a8a", userText: "#ffffff", accent: "#1e86ff" }, -- dark: { bg: "#0b1020", sidebar: "#10172a", bubble: "#1a2340", card: "#141c33", text: "#e8edf7", user: "#3b6fd8", userText: "#ffffff", accent: "#4f9dff" } -- } ++} ++ +// src/client/approval-claims.js +var claims = /* @__PURE__ */ new Map(); +var claimListeners = /* @__PURE__ */ new Set(); +var openChats = /* @__PURE__ */ new Map(); +var changed = () => { + for (const fn of [...claimListeners]) fn(); - }; --var rgba = (hex, alpha) => `rgba(${[1, 3, 5].map((index) => parseInt(hex.slice(index, index + 2), 16)).join(",")},${alpha})`; --function themeVars(mode, dark, accent = mode.accent) { -- return { -- "--bt-ink": mode.text, -- "--bt-ink-2": rgba(mode.text, 0.6), -- "--bt-ink-3": rgba(mode.text, 0.36), -- "--bt-bubble": mode.bubble, -- "--bt-sidebar": mode.sidebar, -- "--bt-main": mode.bg, -- "--bt-card": mode.card, -- "--bt-line": rgba(mode.text, 0.08), -- "--bt-line-2": rgba(mode.text, 0.15), -- "--bt-hover": dark ? "rgba(160,160,160,.12)" : "rgba(119,119,119,.09)", -- "--bt-active": dark ? "rgba(160,160,160,.2)" : "rgba(119,119,119,.17)", -- "--bt-accent": accent, -- "--bt-accent-ink": luminance(accent) > 0.45 ? "#141414" : "#ffffff", -- "--bt-user": mode.user, -- "--bt-user-ink": mode.userText, -- "--bt-tag-bg": rgba(mode.text, dark ? 0.09 : 0.06), -- "--bt-tag-ink": rgba(mode.text, 0.58) -- }; --} --var declarations = (vars) => Object.entries(vars).map(([name, value]) => `${name}:${value}`).join(";"); --var themeCss = (theme, accent) => `html body{${declarations(themeVars(theme.light, false, accent ?? theme.light.accent))}} --html body[data-ds-dark-theme]{${declarations(themeVars(theme.dark, true, accent ?? theme.dark.accent))}} --${theme.css ?? ""}`; --var shellTokens = ({ light, dark }) => ({ -- "--dsw-alias-bg-base": { light: light.bg, dark: dark.bg }, -- "--dsw-specific-sidebar-fill": { light: light.sidebar, dark: dark.sidebar }, -- "--dsw-specific-input-major": { light: light.bg, dark: dark.sidebar }, -- "--dsw-alias-label-primary": { light: light.text, dark: dark.text }, -- "--dsw-alias-label-secondary": { light: rgba(light.text, 0.74), dark: rgba(dark.text, 0.74) }, -- "--dsw-alias-label-tertiary": { light: rgba(light.text, 0.6), dark: rgba(dark.text, 0.6) } --}); --var customThemes = {}; --var registry = createSource(0); --var isPoint = (point) => Array.isArray(point) && point.length === 2 && point.every(Number.isFinite); --function registerShape(id, shape) { -- if (typeof id !== "string" || !/^[a-z][\w-]{0,31}$/.test(id)) throw new Error("Shape ids are lowercase words"); -- if (typeof shape?.body !== "function" || typeof shape.body("#000") !== "string") throw new Error("A shape needs body(paint) returning SVG markup"); -- const at = shape.eyes?.at; -- if (!Array.isArray(at) || at.length !== 2 || !at.every(isPoint)) throw new Error("A shape needs eyes.at: two [x, y] points"); -- const size = Number.isFinite(shape.size) && shape.size > 0 ? shape.size : 100; -- const pick2 = (key, fallback) => Number.isFinite(shape.eyes[key]) ? shape.eyes[key] : fallback; -- const eyes = at.map(([x, y]) => [x, y, pick2("w", size * 0.1), pick2("h", size * 0.23), pick2("tilt", -27)]); -- const previous = SHAPES[id]; -- SHAPES[id] = { -- label: String(shape.label ?? id), -- size, -- body: shape.body, -- eyes, -- ...typeof shape.wrap === "string" ? { wrap: shape.wrap } : {} -- }; -- const refresh = () => { -- markupCache.clear(); -- maskCache.delete(id); -- registry.set((value) => value + 1); -- }; -- refresh(); ++}; +function chatOpened(sessionId, delta) { + const count = (openChats.get(sessionId) ?? 0) + delta; + if (count > 0) openChats.set(sessionId, count); @@ -1668,13 +1671,10 @@ diff --git a/client.js b/client.js // src/shared/mentions.js var ALL_WORDS = ["all", "everyone", "所有人", "全体"]; -@@ -1443,7 +1443,28 @@ function mentionSpans(text, bots, { references = [] } = {}) { - spans.push(bot ? { start: at, end, bot } : { start: at, end, all: true }); - at = end - 1; +@@ -1445,6 +1445,27 @@ function mentionSpans(text, bots, { references = [] } = {}) { } -- return spans; -+ return spans; -+} + return spans; + } +function unmatchedMentions(text, bots, { references = [] } = {}) { + const value = String(text ?? ""); + const skip = [ @@ -1695,9 +1695,10 @@ diff --git a/client.js b/client.js + if (word) missing.push(`@${word}`); + } + return missing; - } ++} // src/client/text.js + var MENTION_ORIGIN = "https://bots.local/"; @@ -1463,6 +1463,19 @@ function mentionTarget(event) { const anchor = event.target instanceof Element ? event.target.closest(`a[href^="${MENTION_ORIGIN}"]`) : null; return anchor ? anchor.getAttribute("href").slice(MENTION_ORIGIN.length) : null; @@ -1786,11 +1787,35 @@ diff --git a/client.js b/client.js var historyLoading = (snapshot) => snapshot?.openState === "cold" || snapshot?.openState === "loading"; function toolArgs(root) { if (typeof root.args?.text === "function") { -@@ -1597,14 +1597,21 @@ function commRunOf(order, peers, turn, hiddenTurns = []) { +@@ -1583,50 +1583,236 @@ function commRunOf(order, peers, turn, hiddenTurns = []) { + if (hidden && peer === void 0) continue; + turns.push({ turn: entry, peer, hidden }); } - return { count, peerIds }; - } --function previewOf(list, id, hiddenTurns = []) { +- const at = turns.findIndex((entry) => entry.turn === turn); +- if (at === -1 || turns[at].peer === void 0) return null; +- const previous = turns[at - 1]; +- if (previous?.peer !== void 0 && previous.hidden) return "folded"; +- let count = 0; +- const peerIds = []; +- for (let index = at; index < turns.length; index += 1) { +- const entry = turns[index]; +- if (entry.peer === void 0 || index > at && !turns[index - 1].hidden) break; +- count += entry.hidden ? 2 : 1; +- if (!peerIds.includes(entry.peer)) peerIds.push(entry.peer); ++ const at = turns.findIndex((entry) => entry.turn === turn); ++ if (at === -1 || turns[at].peer === void 0) return null; ++ const previous = turns[at - 1]; ++ if (previous?.peer !== void 0 && previous.hidden) return "folded"; ++ let count = 0; ++ const peerIds = []; ++ for (let index = at; index < turns.length; index += 1) { ++ const entry = turns[index]; ++ if (entry.peer === void 0 || index > at && !turns[index - 1].hidden) break; ++ count += entry.hidden ? 2 : 1; ++ if (!peerIds.includes(entry.peer)) peerIds.push(entry.peer); ++ } ++ return { count, peerIds }; ++} +function heldReplyOf(bot, sessionId, turn) { + const list = Array.isArray(bot?.heldReplies) ? bot.heldReplies : []; + let found; @@ -1799,22 +1824,32 @@ diff --git a/client.js b/client.js +} +var unsentReplyTurns = (bot, sessionId) => (Array.isArray(bot?.heldReplies) ? bot.heldReplies : []).filter((reply) => reply.sessionId === sessionId && reply.status !== "approved" && reply.status !== "auto").map((reply) => reply.turn); +function previewOf(list, id, hiddenTurns = [], unsentTurns = []) { - const outline = list.projectionsBySession?.[id]?.values?.turnOutline ?? list.byId[id]?.projectionValues?.turnOutline; - if (!Array.isArray(outline)) return ""; - for (let index = outline.length - 1; index >= 0; index -= 1) { - const entry = outline[index]; - if (hiddenTurns.includes(entry.turn)) continue; -- const text = entry.response || entry.prompt || ""; -- if (text !== "") return stripHeader(text).replace(/\s+/g, " ").trim(); ++ const outline = list.projectionsBySession?.[id]?.values?.turnOutline ?? list.byId[id]?.projectionValues?.turnOutline; ++ if (!Array.isArray(outline)) return ""; ++ for (let index = outline.length - 1; index >= 0; index -= 1) { ++ const entry = outline[index]; ++ if (hiddenTurns.includes(entry.turn)) continue; + const text = (unsentTurns.includes(entry.turn) ? "" : entry.response) || entry.prompt || ""; + if (text !== "") return stripTrace(stripHeader(text)).replace(/\s+/g, " ").trim().replace(/^[^\s→]{1,40}(?: → [^\s→]{1,40})+ (?=\S)/, ""); - } - return ""; - } -@@ -1624,9 +1624,188 @@ function usageOf(list, ids) { - const hit = input > 0 ? Math.round(cacheRead / input * 100) : 0; - return t("{tokens} tok · Cache hit {hit}%", { tokens: formatTokens(input + output), hit }); - } ++ } ++ return ""; ++} ++var formatTokens = (count) => count >= 1e6 ? `${(count / 1e6).toFixed(1)}M` : count >= 1e3 ? `${(count / 1e3).toFixed(1)}K` : String(count); ++function usageOf(list, ids) { ++ let input = 0; ++ let cacheRead = 0; ++ let output = 0; ++ for (const id of ids) { ++ const usage = list.projectionsBySession?.[id]?.values?.tokenUsage; ++ if (!usage) continue; ++ input += (usage.uncachedInputTokens ?? 0) + (usage.cacheReadTokens ?? 0) + (usage.cacheWriteTokens ?? 0); ++ cacheRead += usage.cacheReadTokens ?? 0; ++ output += usage.outputTokens ?? 0; ++ } ++ if (input + output === 0) return ""; ++ const hit = input > 0 ? Math.round(cacheRead / input * 100) : 0; ++ return t("{tokens} tok · Cache hit {hit}%", { tokens: formatTokens(input + output), hit }); ++} +function faceRowTitle(title) { + const text = String(title ?? ""); + const named = text.match(/^Face-to-face · (.+) · (\d{6})$/); @@ -1886,9 +1921,18 @@ diff --git a/client.js b/client.js + const count = (own[callId] ?? 0) + delta; + if (count > 0) own[callId] = count; + else delete own[callId]; -+ } + } +- return { count, peerIds }; + return { ...state, [sessionId]: own }; -+} + } +-function previewOf(list, id, hiddenTurns = []) { +- const outline = list.projectionsBySession?.[id]?.values?.turnOutline ?? list.byId[id]?.projectionValues?.turnOutline; +- if (!Array.isArray(outline)) return ""; +- for (let index = outline.length - 1; index >= 0; index -= 1) { +- const entry = outline[index]; +- if (hiddenTurns.includes(entry.turn)) continue; +- const text = entry.response || entry.prompt || ""; +- if (text !== "") return stripHeader(text).replace(/\s+/g, " ").trim(); + +// src/client/themes.js +var THEME_KEYS = ["bg", "sidebar", "bubble", "card", "text", "user", "userText", "accent"]; @@ -1912,7 +1956,8 @@ diff --git a/client.js b/client.js + label: "Moonlight", + light: { bg: "#f7f9fc", sidebar: "#eef2f8", bubble: "#e5ebf5", card: "#ffffff", text: "#121826", user: "#1e3a8a", userText: "#ffffff", accent: "#1e86ff" }, + dark: { bg: "#0b1020", sidebar: "#10172a", bubble: "#1a2340", card: "#141c33", text: "#e8edf7", user: "#3b6fd8", userText: "#ffffff", accent: "#4f9dff" } -+ } + } +- return ""; +}; +var rgba = (hex, alpha) => `rgba(${[1, 3, 5].map((index) => parseInt(hex.slice(index, index + 2), 16)).join(",")},${alpha})`; +function themeVars(mode, dark, accent = mode.accent) { @@ -1935,7 +1980,18 @@ diff --git a/client.js b/client.js + "--bt-tag-bg": rgba(mode.text, dark ? 0.09 : 0.06), + "--bt-tag-ink": rgba(mode.text, 0.58) + }; -+} + } +-var formatTokens = (count) => count >= 1e6 ? `${(count / 1e6).toFixed(1)}M` : count >= 1e3 ? `${(count / 1e3).toFixed(1)}K` : String(count); +-function usageOf(list, ids) { +- let input = 0; +- let cacheRead = 0; +- let output = 0; +- for (const id of ids) { +- const usage = list.projectionsBySession?.[id]?.values?.tokenUsage; +- if (!usage) continue; +- input += (usage.uncachedInputTokens ?? 0) + (usage.cacheReadTokens ?? 0) + (usage.cacheWriteTokens ?? 0); +- cacheRead += usage.cacheReadTokens ?? 0; +- output += usage.outputTokens ?? 0; +var declarations = (vars) => Object.entries(vars).map(([name, value]) => `${name}:${value}`).join(";"); +var themeCss = (theme, accent) => `html body{${declarations(themeVars(theme.light, false, accent ?? theme.light.accent))}} +html body[data-ds-dark-theme]{${declarations(themeVars(theme.dark, true, accent ?? theme.dark.accent))}} @@ -1983,7 +2039,10 @@ diff --git a/client.js b/client.js + if (typeof id !== "string" || !/^[a-z][\w-]{0,31}$/.test(id)) throw new Error("Theme ids are lowercase words"); + for (const mode of ["light", "dark"]) { + for (const key of THEME_KEYS) if (!isHex(theme?.[mode]?.[key])) throw new Error(`Theme ${mode}.${key} must be a #rrggbb color`); -+ } + } +- if (input + output === 0) return ""; +- const hit = input > 0 ? Math.round(cacheRead / input * 100) : 0; +- return t("{tokens} tok · Cache hit {hit}%", { tokens: formatTokens(input + output), hit }); + const previous = customThemes[id]; + customThemes[id] = { label: String(theme.label ?? id), light: theme.light, dark: theme.dark, css: typeof theme.css === "string" ? theme.css : "" }; + registry3.set((value) => value + 1); @@ -1992,7 +2051,7 @@ diff --git a/client.js b/client.js + else delete customThemes[id]; + registry3.set((value) => value + 1); + }; -+} + } +var allThemes = () => ({ ...THEMES, ...customThemes }); // src/client/motion.js @@ -2329,10 +2388,20 @@ diff --git a/client.js b/client.js .bt-ububble{max-width:min(560px,78%);background:var(--bt-user);color:var(--bt-user-ink);border-radius:18px;padding:7px 12px;font-size:14px;line-height:20px;box-sizing:border-box;overflow-wrap:anywhere} .bt-ububble [class*="_markdown"],.bt-ububble a{color:inherit!important} .bt-ububble p{margin:0;line-height:22px} -@@ -2852,229 +2852,211 @@ function installCompiledStyles(text, tag) { - document.removeEventListener("load", onLoad, true); - clearTimeout(timer); - style.remove(); +@@ -2842,239 +2842,221 @@ function installCompiledStyles(text, tag) { + timer = setTimeout(render, 120); + }; + const onLoad = (event) => { +- if (event.target instanceof HTMLLinkElement) schedule(); +- }; +- const observer = new MutationObserver(schedule); +- observer.observe(document.head, { childList: true }); +- document.addEventListener("load", onLoad, true); +- return () => { +- observer.disconnect(); +- document.removeEventListener("load", onLoad, true); +- clearTimeout(timer); +- style.remove(); - }; -} - @@ -2419,8 +2488,7 @@ diff --git a/client.js b/client.js - { viewBox: "0 0 20 20", "aria-hidden": true }, - (0, import_react2.createElement)("path", { d: "M10 2.4l2.29 5.04 5.51.63-4.09 3.74 1.11 5.42L10 14.5l-4.82 2.73 1.11-5.42L2.2 8.07l5.51-.63z" }) - ); -+ }; - } +-} -function useSettled(state) { - const prev = (0, import_react2.useRef)(state); - const [settled, setSettled] = (0, import_react2.useState)(false); @@ -2437,6 +2505,31 @@ diff --git a/client.js b/client.js - return () => clearTimeout(timer); - }, [state]); - return settled; ++ if (event.target instanceof HTMLLinkElement) schedule(); ++ }; ++ const observer = new MutationObserver(schedule); ++ observer.observe(document.head, { childList: true }); ++ document.addEventListener("load", onLoad, true); ++ return () => { ++ observer.disconnect(); ++ document.removeEventListener("load", onLoad, true); ++ clearTimeout(timer); ++ style.remove(); ++ }; + } +-function BotAvatar({ bot, size, state = "idle", main, admin, badge = true, live = true, gaze = false, pokeable = false }) { +- const settled = useSettled(state); +- if (bot === void 0) return (0, import_react2.createElement)(BotMark, { size }); +- const badgeKind = !badge ? null : state === "alert" ? "alert" : state !== "idle" ? "working" : admin ? "admin" : main ? "main" : null; +- return (0, import_react2.createElement)( +- "span", +- { className: "bt-mark", style: { width: size, height: size, "--bt-size": `${size}px` } }, +- (0, import_react2.createElement)(BotMark, { bot, size, state: settled ? "done" : state, live, gaze, pokeable }), +- badgeKind === "working" ? (0, import_react2.createElement)("span", { className: "bt-badge bt-badge-working", "aria-label": t("Working"), role: "img" }) : null, +- badgeKind === "alert" ? (0, import_react2.createElement)("span", { className: "bt-badge bt-badge-alert", "aria-label": t("Waiting for your answer"), role: "img" }, "?") : null, +- badgeKind === "admin" ? (0, import_react2.createElement)("span", { className: "bt-badge bt-badge-admin", "aria-label": t("Admin"), role: "img" }, (0, import_react2.createElement)(StarIcon)) : null, +- badgeKind === "main" ? (0, import_react2.createElement)("span", { className: "bt-badge bt-badge-main", "aria-label": t("Main Bot"), role: "img" }, (0, import_react2.createElement)(MainStar)) : null +- ); + +// src/shared/face-title.js +var MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; @@ -2469,19 +2562,13 @@ diff --git a/client.js b/client.js + rows.sort((a, b) => a.sort.localeCompare(b.sort)); + return rows.slice(0, 3).map((row) => row.owner); } --function BotAvatar({ bot, size, state = "idle", main, admin, badge = true, live = true, gaze = false, pokeable = false }) { -- const settled = useSettled(state); -- if (bot === void 0) return (0, import_react2.createElement)(BotMark, { size }); -- const badgeKind = !badge ? null : state === "alert" ? "alert" : state !== "idle" ? "working" : admin ? "admin" : main ? "main" : null; -- return (0, import_react2.createElement)( -- "span", -- { className: "bt-mark", style: { width: size, height: size, "--bt-size": `${size}px` } }, -- (0, import_react2.createElement)(BotMark, { bot, size, state: settled ? "done" : state, live, gaze, pokeable }), -- badgeKind === "working" ? (0, import_react2.createElement)("span", { className: "bt-badge bt-badge-working", "aria-label": t("Working"), role: "img" }) : null, -- badgeKind === "alert" ? (0, import_react2.createElement)("span", { className: "bt-badge bt-badge-alert", "aria-label": t("Waiting for your answer"), role: "img" }, "?") : null, -- badgeKind === "admin" ? (0, import_react2.createElement)("span", { className: "bt-badge bt-badge-admin", "aria-label": t("Admin"), role: "img" }, (0, import_react2.createElement)(StarIcon)) : null, -- badgeKind === "main" ? (0, import_react2.createElement)("span", { className: "bt-badge bt-badge-main", "aria-label": t("Main Bot"), role: "img" }, (0, import_react2.createElement)(MainStar)) : null -- ); +-function clusterSeats(count, frame) { +- if (count === 1) return [[0, 0, frame]]; +- if (count === 2) return [[0, 0, frame * 2 / 3], [frame / 3, frame / 3, frame * 2 / 3]]; +- if (count === 3) { +- const size = frame * 5 / 9; +- const rest = frame - size; +- return [[rest / 2, 0, size], [0, rest, size], [rest, rest, size]]; +function uniqueOwners(owners) { + const seen = /* @__PURE__ */ new Set(); + const out = []; @@ -2491,7 +2578,9 @@ diff --git a/client.js b/client.js + if (!owner || seen.has(key)) continue; + seen.add(key); + out.push(owner); -+ } + } +- const half = frame / 2; +- return [[0, 0, half], [half, 0, half], [0, half, half], [half, half, half]]; + out.sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase())); + return out.slice(0, 3); +} @@ -2516,13 +2605,18 @@ diff --git a/client.js b/client.js + bits.push(join.length === 6 ? join : when3); + return bits.join(" · "); } --function clusterSeats(count, frame) { -- if (count === 1) return [[0, 0, frame]]; -- if (count === 2) return [[0, 0, frame * 2 / 3], [frame / 3, frame / 3, frame * 2 / 3]]; -- if (count === 3) { -- const size = frame * 5 / 9; -- const rest = frame - size; -- return [[rest / 2, 0, size], [0, rest, size], [rest, rest, size]]; +-function RoomAvatar({ room: room2, roster, size = 36 }) { +- const members = room2.members.map((id) => roster.byId[id]).filter(Boolean); +- const people = members.length === 1 ? [members[0], null] : members; +- const seat = (bot, seatSize, key) => bot ? (0, import_react2.createElement)(BotMark, { key, bot, size: seatSize }) : (0, import_react2.createElement)("span", { key, className: "bt-cluster-you", style: { width: seatSize * 0.92, height: seatSize * 0.92, margin: seatSize * 0.04 } }); +- if (people.length === 0) return (0, import_react2.createElement)(BotMark, { size }); +- if (size <= 20 && people.length > 1) { +- return (0, import_react2.createElement)( +- "span", +- { className: "bt-stack", style: { "--bt-stack-size": `${size}px` } }, +- people.slice(0, 3).map((bot, index) => seat(bot, size, bot?.id ?? `you-${index}`)), +- people.length > 3 ? (0, import_react2.createElement)("span", { className: "bt-stack-more" }, `+${people.length - 3}`) : null +- ); + +// src/client/brand.js +var FLYBOT_WING = "M150 238 C 96 200, 40 196, 22 214 C 8 230, 40 262, 150 300 Z"; @@ -2550,24 +2644,28 @@ diff --git a/client.js b/client.js + isChief: bot.remote.isChief + }); } -- const half = frame / 2; -- return [[0, 0, half], [half, 0, half], [0, half, half], [half, half, half]]; +- const seats = clusterSeats(Math.min(people.length, 4), size); +- const overflow = people.length > 4 ? people.length - 3 : 0; +- const gap = size / 14; +- const rings = seats.map(([x, y, s]) => ({ cx: x + s / 2, cy: y + s / 2, r: s * 0.48 + gap })); +- return (0, import_react2.createElement)( +- "span", +- { className: "bt-cluster", style: { width: size, height: size } }, +- seats.map(([x, y, s], index) => { +- const holes2 = rings.slice(index + 1).filter((ring) => Math.hypot(ring.cx - x - s / 2, ring.cy - y - s / 2) < ring.r + s * 0.46).map((ring) => `radial-gradient(circle at ${ring.cx - x}px ${ring.cy - y}px,transparent ${ring.r}px,#000 ${ring.r + 0.5}px)`); +- const style = { left: x, top: y, width: s, height: s }; +- if (holes2.length > 0) Object.assign(style, { WebkitMaskImage: holes2.join(","), maskImage: holes2.join(","), WebkitMaskComposite: "source-in", maskComposite: "intersect" }); +- if (overflow > 0 && index === 3) { +- return (0, import_react2.createElement)("span", { key: "more", className: "bt-cluster-more", style: { ...style, width: s * 0.92, height: s * 0.92, margin: s * 0.04, fontSize: Math.max(8, Math.round(s * 0.36)) } }, `+${overflow}`); +- } +- const bot = people[index]; +- return (0, import_react2.createElement)("span", { key: bot?.id ?? "you", style }, seat(bot, s)); +- }) +- ); + if (room2 && room2.origin === "face-to-face" && (room2.face?.status === "closed" || room2.face?.status === "left") && isAutoFaceTitle(room2.name)) return "Face-to-face · closed"; + if (room2) return room2.displayName || (room2.sharedFrom ? sharedRoomTitle(room2.sharedFrom.inviterName) : room2.name) || ""; + return bot?.name || ""; - } --function RoomAvatar({ room: room2, roster, size = 36 }) { -- const members = room2.members.map((id) => roster.byId[id]).filter(Boolean); -- const people = members.length === 1 ? [members[0], null] : members; -- const seat = (bot, seatSize, key) => bot ? (0, import_react2.createElement)(BotMark, { key, bot, size: seatSize }) : (0, import_react2.createElement)("span", { key, className: "bt-cluster-you", style: { width: seatSize * 0.92, height: seatSize * 0.92, margin: seatSize * 0.04 } }); -- if (people.length === 0) return (0, import_react2.createElement)(BotMark, { size }); -- if (size <= 20 && people.length > 1) { -- return (0, import_react2.createElement)( -- "span", -- { className: "bt-stack", style: { "--bt-stack-size": `${size}px` } }, -- people.slice(0, 3).map((bot, index) => seat(bot, size, bot?.id ?? `you-${index}`)), -- people.length > 3 ? (0, import_react2.createElement)("span", { className: "bt-stack-more" }, `+${people.length - 3}`) : null -- ); ++} +function installTabTitle(sessions, roster, doc = globalThis.document) { + if (!doc || !sessions || !roster) return () => { + }; @@ -2597,25 +2695,7 @@ diff --git a/client.js b/client.js + link.rel = "icon"; + link.type = "image/svg+xml"; + document.head.appendChild(link); - } -- const seats = clusterSeats(Math.min(people.length, 4), size); -- const overflow = people.length > 4 ? people.length - 3 : 0; -- const gap = size / 14; -- const rings = seats.map(([x, y, s]) => ({ cx: x + s / 2, cy: y + s / 2, r: s * 0.48 + gap })); -- return (0, import_react2.createElement)( -- "span", -- { className: "bt-cluster", style: { width: size, height: size } }, -- seats.map(([x, y, s], index) => { -- const holes2 = rings.slice(index + 1).filter((ring) => Math.hypot(ring.cx - x - s / 2, ring.cy - y - s / 2) < ring.r + s * 0.46).map((ring) => `radial-gradient(circle at ${ring.cx - x}px ${ring.cy - y}px,transparent ${ring.r}px,#000 ${ring.r + 0.5}px)`); -- const style = { left: x, top: y, width: s, height: s }; -- if (holes2.length > 0) Object.assign(style, { WebkitMaskImage: holes2.join(","), maskImage: holes2.join(","), WebkitMaskComposite: "source-in", maskComposite: "intersect" }); -- if (overflow > 0 && index === 3) { -- return (0, import_react2.createElement)("span", { key: "more", className: "bt-cluster-more", style: { ...style, width: s * 0.92, height: s * 0.92, margin: s * 0.04, fontSize: Math.max(8, Math.round(s * 0.36)) } }, `+${overflow}`); -- } -- const bot = people[index]; -- return (0, import_react2.createElement)("span", { key: bot?.id ?? "you", style }, seat(bot, s)); -- }) -- ); ++ } + link.href = href; + const retitle = () => { + const next = brandTitle(document.title); @@ -3849,7 +3929,8 @@ diff --git a/client.js b/client.js { type: "button", className: "bt-lead", "aria-label": t("Open {name}'s chat", { name: bot.name }), onClick: () => actions.openSession(bot.id) }, - (0, import_react6.createElement)(BotMark, { bot, size: 22 }) + (0, import_react8.createElement)(BotMark, { bot, size: 22 }) -+ ); + ); +- return (0, import_react6.createElement)( + const name = (0, import_react8.createElement)( + "span", + { className: "bt-group-name", style: { color: inkText(colorOf(bot)) } }, @@ -3944,8 +4025,7 @@ diff --git a/client.js b/client.js + { width: 10, height: 10, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round", "aria-hidden": true }, + (0, import_react8.createElement)("circle", { cx: 12, cy: 12, r: 9 }), + (0, import_react8.createElement)("path", { d: "M3 12h18M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18" }) - ); -- return (0, import_react6.createElement)( ++ ); +} +function RemoteFiles({ files, plugin, botId }) { + const [busy, setBusy] = (0, import_react8.useState)(""); @@ -4372,8 +4452,7 @@ diff --git a/client.js b/client.js if (reply.kind === "stopped") { - return (0, import_react6.createElement)("div", { key: `${reply.botId}-${index}`, className: "bt-event" }, t("You stopped {name}'s work", { name: bot.name })); + return (0, import_react8.createElement)("div", { key: `${reply.botId}-${index}`, className: "bt-event" }, t("You stopped {name}'s work", { name: bot.name })); - } -- return (0, import_react6.createElement)(GroupMessage, { key: `${reply.botId}-${index}`, bot, text: reply.text, room: room2, roster, actions, msgKey: callId ? `${sessionId}:${callId}:${index}` : void 0, useUi }); ++ } + if (reply.kind === "notice" && room2?.origin === "face-to-face" && String(reply.text ?? "").trim() === WAITING_OTHERS_NOTICE) return null; + if (reply.kind === "notice" || reply.kind === "joined" || reply.kind === "left" || reply.kind === "started") { + return (0, import_react8.createElement)("div", { key: `notice-${index}`, className: "bt-event", "data-flybot": "face-notice" }, remoteMessageText(reply.text)); @@ -4390,7 +4469,8 @@ diff --git a/client.js b/client.js + msgKey: callId ? `${sessionId}:${callId}:${index}` : void 0, + useUi + }); -+ } + } +- return (0, import_react6.createElement)(GroupMessage, { key: `${reply.botId}-${index}`, bot, text: reply.text, room: room2, roster, actions, msgKey: callId ? `${sessionId}:${callId}:${index}` : void 0, useUi }); + return (0, import_react8.createElement)(GroupMessage, { key: `${reply.botId}-${index}`, bot, text: reply.text, room: room2, roster, actions, msgKey: callId ? `${sessionId}:${callId}:${index}` : void 0, useUi, seq: reply.seq }); }), pending.map((id) => { @@ -9818,13 +9898,13 @@ diff --git a/client.js b/client.js ), - (0, import_react23.createElement)( + face ? (0, import_react28.createElement)( - Card, -- { title: t("Members · {count}", { count: members.length }) }, ++ Card, + { title: t("Members · {count}", { count: people2.length }) }, + people2.map((person) => (0, import_react28.createElement)(FacePersonRow, { key: person.key, person, onOpenBot: openBot, lead: room2.face?.lead?.handle })) + ) : null, + face ? null : (0, import_react28.createElement)( -+ Card, + Card, +- { title: t("Members · {count}", { count: members.length }) }, + { title: t("Members · {count}", { count: members.length + humans.length + remotes.length }) }, + humans.map((person) => (0, import_react28.createElement)( + "div", @@ -13136,10 +13216,35 @@ diff --git a/client.js b/client.js } const end = body2.trimEnd().length; const marked = mark ? `${body2.slice(0, end)}${mark}${body2.slice(end)}` : body2; -@@ -13052,11 +13052,27 @@ function serveBrowserReads({ browsers, post, clientId = globalThis.crypto.random +@@ -13052,11 +13052,52 @@ function serveBrowserReads({ browsers, post, clientId = globalThis.crypto.random }; } ++// src/client/mobile.js ++var MOBILE_MAX_WIDTH = 640; ++var MOBILE_CSS = ` ++@media (max-width:${MOBILE_MAX_WIDTH}px){ ++.bt-held-actions{max-width:100%} ++.bt-held-card{padding:10px 12px} ++:is(.bt-held-card,.bt-flybot-approval) .bt-flybot-approval-actions{gap:8px;margin-top:8px} ++:is(.bt-held-card,.bt-flybot-approval) .bt-flybot-approval-actions>button{flex:1 1 0;min-width:0;height:40px;padding:0 16px;border-radius:999px;border:0;background:var(--bt-hover);color:var(--bt-ink);font:inherit;font-size:14px;font-weight:500;cursor:pointer} ++:is(.bt-held-card,.bt-flybot-approval) .bt-flybot-approval-actions>button.bt-flybot-approve{background:var(--bt-accent);color:var(--bt-accent-ink,#fff);font-weight:600} ++:is(.bt-held-card,.bt-flybot-approval) .bt-flybot-approval-actions>button:disabled{opacity:.5;cursor:default} ++.bt-held-retry{min-height:32px} ++.bt-flybot-approvals{left:64px;right:8px;bottom:calc(8px + env(safe-area-inset-bottom,0px));max-width:none;max-height:min(60vh,calc(100vh - 16px))} ++.bt-flybot-pointers{top:8px;right:8px;max-width:calc(100vw - 72px)} ++.bt-flybot-pointer{max-width:100%;min-width:0} ++.bt-flybot-pointer-text{flex:0 1 auto;min-width:0;max-width:none} ++.bt-flybot-pointer-note{flex:0 100 auto;min-width:0;overflow:hidden;text-overflow:ellipsis} ++.bt-flybot-pointer button{flex:none;min-height:28px;padding:4px 12px} ++.bt-inbox-card{flex-wrap:wrap;row-gap:8px} ++.bt-inbox-copy{flex-basis:calc(100% - 38px)} ++.bt-inbox-title,.bt-inbox-reason{white-space:normal;overflow-wrap:anywhere;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2} ++.bt-inbox-actions{flex:1 1 100%;justify-content:flex-end;flex-wrap:wrap;padding-left:38px} ++.bt-inbox-actions :is(.bt-soft,.bt-send){height:36px;padding:0 16px;font-size:13px} ++} ++`; ++ +// src/client/friend-lines.js +function friendLineNews(bots, known, isOpen, selfId) { + const next = { ...known }; @@ -13699,7 +13804,7 @@ diff --git a/client.js b/client.js actions.referenceChat = (sessionId, span) => { const controller = atMenu?.controllerOf(sessionId); if (controller === void 0 || span === void 0) return; -@@ -14019,11 +14019,13 @@ function apply(ctx) { +@@ -14019,27 +14019,33 @@ function apply(ctx) { installCompiledStyles(SHELL_CSS, "shell"), installCompiledStyles(SIDEBAR_CSS, "sidebar"), installCompiledStyles(SECRET_CSS, "secrets"), @@ -13713,8 +13818,10 @@ diff --git a/client.js b/client.js + installCompiledStyles(FLYBOT_CSS, "flybot"), installCompiledStyles(MEMBER_CSS, "members"), installCompiledStyles(USER_MESSAGE_CSS, "user-messages"), ++ installCompiledStyles(MOBILE_CSS, "mobile"), browsers ? installCompiledStyles(BROWSER_CSS, "browser") : null, -@@ -14032,14 +14032,17 @@ function apply(ctx) { + installTheme(), + shadow("sidebar.workspaces", { priority: -10, inject: face }, TeamSidebar), shadow("sidebar.brand.mark", { priority: -10 }, BrandMark), shadow("sidebar.brand.name", { priority: -10 }, BrandName), shadow("sidebar.footer.action", { id: "bot-account", order: -200, inject: face }, FooterAccount), diff --git a/patches/ds-bot/files/src/client/mobile.js b/patches/ds-bot/files/src/client/mobile.js new file mode 100644 index 00000000..402576f4 --- /dev/null +++ b/patches/ds-bot/files/src/client/mobile.js @@ -0,0 +1,31 @@ +// Phone-width layout, step 1 (#362): at about 400px a phone browser can skim a chat, press +// Approve / Reject on a held reply and work the Agent inbox. Only these surfaces change; +// the shell keeps its own icon rail (DSH collapses the sidebar below 1024px; the floating +// cards stay clear of its 56px, so the top + and the account stay reachable), and the inbox +// dialog already fills the screen at this width (styles.js). Installed after the shell +// group's other styles, so these rules win at equal specificity. No PWA, push or native +// app here: they come after #352, and a notification will only ever open the card. +export const MOBILE_MAX_WIDTH = 640 + +export const MOBILE_CSS = ` +@media (max-width:${MOBILE_MAX_WIDTH}px){ +.bt-held-actions{max-width:100%} +.bt-held-card{padding:10px 12px} +:is(.bt-held-card,.bt-flybot-approval) .bt-flybot-approval-actions{gap:8px;margin-top:8px} +:is(.bt-held-card,.bt-flybot-approval) .bt-flybot-approval-actions>button{flex:1 1 0;min-width:0;height:40px;padding:0 16px;border-radius:999px;border:0;background:var(--bt-hover);color:var(--bt-ink);font:inherit;font-size:14px;font-weight:500;cursor:pointer} +:is(.bt-held-card,.bt-flybot-approval) .bt-flybot-approval-actions>button.bt-flybot-approve{background:var(--bt-accent);color:var(--bt-accent-ink,#fff);font-weight:600} +:is(.bt-held-card,.bt-flybot-approval) .bt-flybot-approval-actions>button:disabled{opacity:.5;cursor:default} +.bt-held-retry{min-height:32px} +.bt-flybot-approvals{left:64px;right:8px;bottom:calc(8px + env(safe-area-inset-bottom,0px));max-width:none;max-height:min(60vh,calc(100vh - 16px))} +.bt-flybot-pointers{top:8px;right:8px;max-width:calc(100vw - 72px)} +.bt-flybot-pointer{max-width:100%;min-width:0} +.bt-flybot-pointer-text{flex:0 1 auto;min-width:0;max-width:none} +.bt-flybot-pointer-note{flex:0 100 auto;min-width:0;overflow:hidden;text-overflow:ellipsis} +.bt-flybot-pointer button{flex:none;min-height:28px;padding:4px 12px} +.bt-inbox-card{flex-wrap:wrap;row-gap:8px} +.bt-inbox-copy{flex-basis:calc(100% - 38px)} +.bt-inbox-title,.bt-inbox-reason{white-space:normal;overflow-wrap:anywhere;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2} +.bt-inbox-actions{flex:1 1 100%;justify-content:flex-end;flex-wrap:wrap;padding-left:38px} +.bt-inbox-actions :is(.bt-soft,.bt-send){height:36px;padding:0 16px;font-size:13px} +} +` diff --git a/patches/ds-bot/files/test/mobile-layout.test.js b/patches/ds-bot/files/test/mobile-layout.test.js new file mode 100644 index 00000000..9fdcce65 --- /dev/null +++ b/patches/ds-bot/files/test/mobile-layout.test.js @@ -0,0 +1,60 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { test } from 'node:test' +import { MOBILE_CSS, MOBILE_MAX_WIDTH } from '../src/client/mobile.js' + +// Phone-width layout, step 1 (#362): chat, the held-reply Approve / Reject and the Agent +// inbox fit a ~400px phone browser. Only a max-width media query, so wider screens are as before. +const rule = (selector) => { + const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + return MOBILE_CSS.match(new RegExp(`(?:^|\\n)${escaped}\\{([^}]*)\\}`))?.[1] ?? '' +} + +test('every phone rule sits inside one max-width media query at 640px', () => { + assert.equal(MOBILE_MAX_WIDTH, 640) + const body = MOBILE_CSS.trim() + assert.ok(body.startsWith('@media (max-width:640px){'), 'opens with the phone media query') + assert.ok(body.endsWith('}'), 'closes it') + assert.equal((body.match(/@media/g) ?? []).length, 1) + // Balanced braces: nothing leaks out of the media block. + const open = body.indexOf('{') + let depth = 0 + for (const [i, ch] of [...body].entries()) { + if (ch === '{') depth++ + if (ch === '}') depth-- + if (i >= open && depth === 0 && i < body.length - 1) assert.fail(`rule outside the media query at ${i}`) + } + assert.equal(depth, 0) +}) + +test('held-reply and floating approval buttons are touch-sized, Approve filled', () => { + const button = rule(':is(.bt-held-card,.bt-flybot-approval) .bt-flybot-approval-actions>button') + assert.match(button, /height:40px/) + assert.match(button, /flex:1 1 0/) + assert.match(rule(':is(.bt-held-card,.bt-flybot-approval) .bt-flybot-approval-actions>button.bt-flybot-approve'), /background:var\(--bt-accent\)/) + assert.match(rule('.bt-held-actions'), /max-width:100%/) +}) + +test('floating cards span the screen beside the icon rail, not over the top + or the account', () => { + const stack = rule('.bt-flybot-approvals') + assert.match(stack, /left:64px/) + assert.match(stack, /right:8px/) + assert.match(stack, /max-width:none/) + assert.match(rule('.bt-flybot-pointers'), /max-width:calc\(100vw - 72px\)/) +}) + +test('inbox cards wrap their actions under the text so the title stays readable', () => { + assert.match(rule('.bt-inbox-card'), /flex-wrap:wrap/) + assert.match(rule('.bt-inbox-actions'), /flex:1 1 100%/) + assert.match(rule('.bt-inbox-actions :is(.bt-soft,.bt-send)'), /height:36px/) + assert.match(rule('.bt-inbox-title,.bt-inbox-reason'), /white-space:normal/) +}) + +test('the client installs the phone styles after the shell styles they override', () => { + const index = readFileSync(new URL('../src/client/index.js', import.meta.url), 'utf8') + const at = name => index.indexOf(`installCompiledStyles(${name}`) + assert.ok(at('MOBILE_CSS') > 0, 'MOBILE_CSS is installed') + for (const earlier of ['SHELL_CSS', 'INBOX_CSS', 'FLYBOT_CSS']) assert.ok(at(earlier) >= 0 && at(earlier) < at('MOBILE_CSS'), `${earlier} comes first`) + const bundle = readFileSync(new URL('../client.js', import.meta.url), 'utf8') + assert.ok(bundle.includes('.bt-inbox-actions :is(.bt-soft,.bt-send){height:36px'), 'client.js carries the phone styles') +}) diff --git a/plugins/social/client.js b/plugins/social/client.js index b206641d..ee58832c 100644 --- a/plugins/social/client.js +++ b/plugins/social/client.js @@ -185,6 +185,7 @@ window.__ModuleLoader__.load({ .fs-hold .fs-hold-decide{display:flex;gap:8px;flex:none} .fs-hold .fs-actions .fs-hold-always{order:-1;margin-right:auto;white-space:nowrap;flex:0 0 auto} .fs-hold[data-state] .fs-name{font-size:13px;line-height:18px;font-weight:500;color:var(--bt-ink-2,rgba(20,20,20,.6))} +@media (max-width:640px){.fs-hold .fs-btn{height:40px;padding:0 16px;font-size:14px}.fs-hold .fs-hold-decide{flex:1 1 auto;justify-content:flex-end}} .fs-row-wrap{position:relative;display:flex;align-items:center;gap:2px;min-width:0} .fs-row-wrap .fs-row,.fs-row-wrap > :first-child{flex:1 1 auto;min-width:0} .fs-remove{flex:none;width:20px;height:20px;padding:0;border:0;border-radius:6px;background:transparent;color:var(--bt-ink-3,rgba(20,20,20,.36));font:inherit;font-size:14px;line-height:1;cursor:pointer;opacity:0} diff --git a/plugins/social/test/social.test.js b/plugins/social/test/social.test.js index 2b1dcc62..dc9469e4 100644 --- a/plugins/social/test/social.test.js +++ b/plugins/social/test/social.test.js @@ -7864,6 +7864,14 @@ test('#458: waiting alone says so; Enter chat steps aside while Leave asks; Leav assert.deepEqual(nav, [['closeOverlay'], ['open', 'helper']]) }) +test('on a phone-width screen the hold card keeps Approve/Reject touch-sized (#362)', () => { + const src = readFileSync(new URL('../client.js', import.meta.url), 'utf8') + const phone = src.match(/@media \(max-width:640px\)\{([^\n]*)\}\n/)?.[1] + assert.ok(phone, 'a phone-width rule for the hold card') + assert.match(phone, /\.fs-hold \.fs-btn\{[^}]*height:40px/) + assert.match(phone, /\.fs-hold \.fs-hold-decide\{[^}]*justify-content:flex-end/) +}) + // Bot Social with the room lead (#476): POST /lead and PUT /roles answer the room, as the server does. function leadFaceServer(features) { const server = attestingFaceServer({ features })