From 774ba97020c2d5e9f8042b4d6e6fc6451c4f2fd7 Mon Sep 17 00:00:00 2001 From: Birfy Date: Tue, 20 Oct 2026 11:00:20 +0800 Subject: [PATCH] Fit chat approvals and the Agent inbox to a phone-width screen (#362) Step 1 of #362 (owner decision 2026-10-20): below 640px the held-reply and floating approval buttons are 40px touch targets (Approve filled), the floating approval stack and chat pointers sit beside the 56px icon rail, and inbox cards wrap their buttons under the text. Wider screens are unchanged. No PWA, push or native app. --- changelog.d/362-mobile-layout.added.md | 1 + core/client.js | 26 ++ core/src/client/index.js | 2 + core/src/client/mobile.js | 31 ++ core/test/mobile-layout.test.js | 60 ++++ docs/subsystems/core-client.md | 8 + docs/subsystems/social.md | 1 + patches/ds-bot/0003-flybot-api-client.patch | 9 +- patches/ds-bot/0008-client-bundle.patch | 300 ++++++++++-------- patches/ds-bot/files/src/client/mobile.js | 31 ++ .../ds-bot/files/test/mobile-layout.test.js | 60 ++++ plugins/social/client.js | 1 + plugins/social/test/social.test.js | 8 + 13 files changed, 394 insertions(+), 144 deletions(-) create mode 100644 changelog.d/362-mobile-layout.added.md create mode 100644 core/src/client/mobile.js create mode 100644 core/test/mobile-layout.test.js create mode 100644 patches/ds-bot/files/src/client/mobile.js create mode 100644 patches/ds-bot/files/test/mobile-layout.test.js 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 07dc3570..7781048e 100644 --- a/core/client.js +++ b/core/client.js @@ -16265,6 +16265,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 }; @@ -17432,6 +17457,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 32e99044..0a0ece65 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' @@ -847,6 +848,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 8510da74..1395f17a 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 @@ -104,6 +105,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 09158a37..ceb6fa19 100644 --- a/docs/subsystems/social.md +++ b/docs/subsystems/social.md @@ -109,6 +109,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 bf587835..959b99ef 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' @@ -1294,7 +1295,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'), @@ -1308,8 +1309,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 466dc73a..77daedf3 100644 --- a/patches/ds-bot/0008-client-bundle.patch +++ b/patches/ds-bot/0008-client-bundle.patch @@ -1011,7 +1011,6 @@ 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"]; @@ -1048,21 +1047,7 @@ 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)); @@ -1088,35 +1073,30 @@ 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) } })); } --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); +-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 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 }) { @@ -1129,7 +1109,13 @@ diff --git a/client.js b/client.js + "aria-hidden": true + }, initial); } -- return { ...state, [sessionId]: own }; +- 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 (0, import_react.createElement)( + "span", + { className: "bt-person-mark", style: { width: size, height: size }, "aria-hidden": true }, @@ -1140,93 +1126,10 @@ 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", @@ -1272,10 +1175,15 @@ 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)); @@ -1308,7 +1216,13 @@ 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 } }); @@ -1320,7 +1234,8 @@ 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; @@ -1339,15 +1254,97 @@ 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); @@ -1534,7 +1531,7 @@ diff --git a/client.js b/client.js + const inOpenChat = (approval) => { + const chat = chatOf(approval, roster); + return Boolean(chat) && (claimed.chats?.has(chat) === true || claimed.chats?.has(roster?.byId?.[chat]?.sessionId) === true); - }; ++ }; + const away = open.filter((approval) => policies.has(approval.by) && approval.status !== "failed" && !inOpenChat(approval)); + const awayKey = away.map((approval) => approval.id).join(","); + (0, import_react2.useEffect)(() => { @@ -1558,7 +1555,7 @@ diff --git a/client.js b/client.js + setBusy(""); + await refresh(); + } -+ }; + }; + const byChat = /* @__PURE__ */ new Map(); + for (const approval of pointers) { + const key = chatOf(approval, roster) ?? approval.id; @@ -2329,13 +2326,10 @@ 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} -@@ -2793,288 +2793,270 @@ function installStyles(text, tag) { - } - var FRAGMENT_SELECTOR = /\[class\*="(_[\w-]+)"\]/g; - var CLASS_TOKEN = /\.((?:\\.|[\w-])+)/g; --function sheetClassNames(sheet) { -- const names = []; -- const visit = (rules) => { +@@ -2796,285 +2796,267 @@ var CLASS_TOKEN = /\.((?:\\.|[\w-])+)/g; + function sheetClassNames(sheet) { + const names = []; + const visit = (rules) => { - for (const rule of rules) { - if (rule.selectorText) for (const match of rule.selectorText.matchAll(CLASS_TOKEN)) names.push(match[1]); - if (rule.cssRules) visit(rule.cssRules); @@ -2471,9 +2465,6 @@ diff --git a/client.js b/client.js -} -function AdminStar() { - return (0, import_react2.createElement)("span", { className: "bt-admin-star", role: "img", "aria-label": t("Admin") }, (0, import_react2.createElement)(StarIcon, { size: 7 })); -+function sheetClassNames(sheet) { -+ const names = []; -+ const visit = (rules) => { + for (const rule of rules) { + if (rule.selectorText) for (const match of rule.selectorText.matchAll(CLASS_TOKEN)) names.push(match[1]); + if (rule.cssRules) visit(rule.cssRules); @@ -9870,13 +9861,13 @@ diff --git a/client.js b/client.js ), - (0, import_react23.createElement)( + face ? (0, import_react28.createElement)( -+ Card, + Card, +- { title: t("Members · {count}", { count: members.length }) }, + { title: t("Members · {count}", { count: people2.length }) }, + people2.map((person) => (0, import_react28.createElement)(FacePersonRow, { key: person.key, person, onOpenBot: openBot })) + ) : null, + face ? null : (0, import_react28.createElement)( - Card, -- { title: t("Members · {count}", { count: members.length }) }, ++ Card, + { title: t("Members · {count}", { count: members.length + humans.length + remotes.length }) }, + humans.map((person) => (0, import_react28.createElement)( + "div", @@ -13119,10 +13110,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 }; @@ -13680,7 +13696,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"), @@ -13694,8 +13710,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 4b61c920..5eb4d9ee 100644 --- a/plugins/social/test/social.test.js +++ b/plugins/social/test/social.test.js @@ -7863,3 +7863,11 @@ test('#458: waiting alone says so; Enter chat steps aside while Leave asks; Leav assert.ok(calls.some(([endpoint, payload]) => endpoint === 'social/face-leave' && payload.faceRoomId === 'frm_1')) 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/) +})