From 7d7334a7c89246edaefd1960bdba93ad077320ed Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 27 Oct 2026 06:45:01 +0000 Subject: [PATCH] fix(ui): the drawer, previews, blank chats and failure copy a click-through found (#1347) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six interactive defects from a real mouse click-through of FlyBot web, ported onto current main: - Details/settings: DETAILS_WIDTH 380 (320 broke "Name" into "Nam"/"e"), a row's label and its controls never break their own words, and .bt-panel declares `container:bt-panel/inline-size` so `@container (max-width:300px)` stacks the row at the panel floor instead of clipping it. - Previews: one shared `visibleText` in src/shared/preview.js unwraps complete fences and strips the broken fragments an older write leaves (`from="User"> …`), used by previewOf, the sidebar group row and the host channel preview, which each did their own partial unwrap. - A blank local Bot chat draws `BotChatStrip` (name, role, empty hint, Details) on the same `blank` fact a group's strip uses, and never for a friend's Bot. - Copy conversation: named for what it does, announced on success, reported on failure. The write goes through `copiedToClipboard`, because `navigator.clipboard?.writeText` with no clipboard resolves undefined and the old shape read that as a successful copy. - The mode word beside the title reads as a status (no fill, no radius, cursor:help). - A provider refusal stored as a cut-off JSON body becomes a sentence keyed on the provider's own `code` (src/shared/failure-copy.js), read by the dead-turn lines and the Inbox. Stacking: `[class*="_centerCol"]` is now a stacking context (position:relative;z-index:0; isolation:isolate), so a chat descendant the browser promotes cannot sort in the root context above a dialog; dialogs are 120/121, popovers 130, the palette 140, each overlay layer isolates, and the scrim is opaque enough to separate the two surfaces. Verified in headless Chrome: with the bubble's own z-index inside an unisolated column it painted over the dialog scrim; with the column isolated the scrim is topmost. Tests: core/test/ui-clickthrough.test.js (new), plus narrow-pane and overlay-approvals. They fail on main — preview.js does not exist there, DETAILS_WIDTH is 320, and the dock draws no strip. Docs: core-client, core-host and plugin-runtime. patches/ds-bot regenerated from core/. Fixes #1347 Co-authored-by: Danyang Chen --- changelog.d/1347-ui-clickthrough.fixed.md | 1 + core/client.js | 233 ++- core/src/client/bot-settings.js | 17 +- core/src/client/browser-pane.js | 8 +- core/src/client/details-drawer.js | 22 +- core/src/client/flybot-slots.js | 40 +- core/src/client/i18n.js | 10 + core/src/client/inbox.js | 3 +- core/src/client/model-test.js | 8 + core/src/client/pane-width.js | 8 +- core/src/client/remote-bot-settings.js | 2 +- core/src/client/sidebar.js | 7 +- core/src/client/styles.js | 54 +- core/src/client/text.js | 6 +- core/src/host/channel.js | 6 +- core/src/shared/failure-copy.js | 52 + core/src/shared/preview.js | 42 + core/src/shared/turn-failures.js | 5 +- core/test/narrow-pane.test.js | 12 +- core/test/overlay-approvals.test.js | 36 + core/test/ui-clickthrough.test.js | 201 +++ docs/subsystems/core-client.md | 12 +- docs/subsystems/core-host.md | 2 + docs/subsystems/plugin-runtime.md | 2 +- patches/ds-bot/0001-core-fixes.patch | 44 +- patches/ds-bot/0003-flybot-api-client.patch | 159 +- patches/ds-bot/0005-social-seams.patch | 8 +- patches/ds-bot/0008-client-bundle.patch | 1296 ++++++++++------- patches/ds-bot/0012-room-membership.patch | 35 +- .../ds-bot/files/src/client/flybot-slots.js | 40 +- patches/ds-bot/files/src/client/inbox.js | 3 +- patches/ds-bot/files/src/client/model-test.js | 8 + patches/ds-bot/files/src/client/pane-width.js | 8 +- .../files/src/client/remote-bot-settings.js | 2 +- .../ds-bot/files/src/shared/failure-copy.js | 52 + patches/ds-bot/files/src/shared/preview.js | 42 + .../ds-bot/files/src/shared/turn-failures.js | 5 +- patches/ds-bot/files/test/narrow-pane.test.js | 12 +- .../files/test/overlay-approvals.test.js | 36 + .../ds-bot/files/test/ui-clickthrough.test.js | 201 +++ 40 files changed, 2100 insertions(+), 640 deletions(-) create mode 100644 changelog.d/1347-ui-clickthrough.fixed.md create mode 100644 core/src/shared/failure-copy.js create mode 100644 core/src/shared/preview.js create mode 100644 core/test/ui-clickthrough.test.js create mode 100644 patches/ds-bot/files/src/shared/failure-copy.js create mode 100644 patches/ds-bot/files/src/shared/preview.js create mode 100644 patches/ds-bot/files/test/ui-clickthrough.test.js diff --git a/changelog.d/1347-ui-clickthrough.fixed.md b/changelog.d/1347-ui-clickthrough.fixed.md new file mode 100644 index 000000000..2a4d61393 --- /dev/null +++ b/changelog.d/1347-ui-clickthrough.fixed.md @@ -0,0 +1 @@ +The Bot settings drawer opens wide enough for its own controls: at 320px the "Name" field's label broke into "Nam" and "e", the "Test connection" button into "Test connecti" and "on", and Main Bot's "Change" into "Ch", "an", "ge". The Details panel now opens at 380px, a row's label stays on one line, a button never breaks its own words, and below 300px — the panel's floor in a narrow window — the row stacks so nothing is clipped to fit a width that is not there. A preview in the sidebar shows the words of a line and never its untrusted fence or the fragment an older write left behind (`from="User"> …`). A Bot chat with nothing said yet, which the shell draws with no header at all, now names itself above the composer and says it is empty instead of being a blank column with a composer. The details drawer's copy button is named for what it does ("Copy conversation", not "Share conversation") and says "Conversation copied as Markdown" when the copy lands, or reports why it did not. The mode word beside the chat title no longer draws as a filled chip that looks clickable and is not. A provider failure stored as a cut-off JSON body — a real Inbox showed `404: {"code":"UnsupportedModel","message":"The requested model does not support …` — now reads as a sentence naming the feature and where to change the model, in the chat's failure lines and in the Inbox ([#1347](https://github.com/ThinkFlowLab/FlyBot/issues/1347)). A chat message can no longer paint over a dialog, a sheet or a scrim. The chat column had no stacking context of its own, so anything the browser gave its own composited layer — a new row's entrance animation, a filter, a sticky box — was ordered against the page root instead of against the column, and could sort above a dialog's layer. With the column made one stacking context that sits below the panel and the dialogs, a right-aligned message stays behind **Tasks**, **Settings** and the **Inbox** scrim. Dialogs and popovers also moved to separate tiers (dialogs 120/121, popovers 130, palette 140), because the context menu and the reaction strip shared z-index 60 with the Settings dialog and a later-opened popover could paint over it ([#1347](https://github.com/ThinkFlowLab/FlyBot/issues/1347)). A modal also covers what is behind it now: the scrim was translucent enough that the thread stayed legible through it, and each overlay layer is its own stacking context so the scrim and its dialog move as one ([#1347](https://github.com/ThinkFlowLab/FlyBot/issues/1347)). diff --git a/core/client.js b/core/client.js index 5ad3ec779..285c59c5f 100644 --- a/core/client.js +++ b/core/client.js @@ -467,6 +467,16 @@ var ZH = { "Close details": "关闭详情", "Copied conversation": "已复制对话", "Share conversation": "分享对话", + "Copy conversation": "复制对话", + "Nothing to copy yet": "还没有可复制的内容", + "Conversation copied as Markdown": "对话已复制为 Markdown", + "Could not copy the conversation": "无法复制对话", + "No messages yet. Say something to {name}.": "还没有消息。给 {name} 发点什么吧。", + "This model does not support that request. Pick another model in the Bot's settings.": "该模型不支持这项请求。请在 Bot 设置里换一个模型。", + "This model does not support {feature}. Pick another model in the Bot's settings.": "该模型不支持 {feature}。请在 Bot 设置里换一个模型。", + "The provider does not know this model. Check the model name in the Bot's settings.": "服务方不认识这个模型。请在 Bot 设置里检查模型名称。", + "The conversation is longer than this model can read. Condense it, or pick a model with a larger window.": "这段对话超出了该模型能读的长度。请压缩对话,或换一个上下文更大的模型。", + "The model service refused the request. Check the model and the endpoint in the Bot's settings.": "模型服务拒绝了这次请求。请在 Bot 设置里检查模型和接口地址。", "Copy conversation as Markdown": "把对话复制为 Markdown", "This conversation is not part of your Bot team.": "这个对话不属于你的 Bot 团队。", "Admin {name}": "管理员 {name}", @@ -2188,7 +2198,7 @@ function lookAvoidingLocals(bot, locals = localBots) { var SHAPES = {}; for (const [id, character] of Object.entries(CHARACTERS)) { const layers = [ - ...(character.paints ?? []).map(([d, fill2, opacity]) => ``), + ...(character.paints ?? []).map(([d, fill3, opacity]) => ``), ``, `` ].join(""); @@ -2320,6 +2330,32 @@ function unmatchedMentions(text, bots, { references = [] } = {}) { return missing; } +// src/shared/preview.js +var FENCE_TAG = /<\s*\/?\s*untrusted-(?:message|file)\b[^<>]*>/gi; +var FENCE_ATTR = /\bfrom\s*=\s*(?:"[^"]*"|'[^']*')\s*>/gi; +var FENCE_TAIL = /<[^<>]*\buntrusted-(?:message|file)\b[^<>]*$/i; +var FENCE_BARE = /(?:\/\s*)?untrusted-(?:message|file)\b\s*>?/gi; +function stripFence(text) { + let out = String(text ?? ""); + for (let previous; previous !== out; ) { + previous = out; + out = out.replace(FENCE_TAG, "").replace(FENCE_ATTR, ""); + } + return out.replace(FENCE_TAIL, "").replace(FENCE_BARE, ""); +} +function visibleText(text) { + let out = String(text ?? ""); + for (let previous; previous !== out; ) { + previous = out; + const open = out.indexOf(""); + const inner = close > open ? out.slice(out.indexOf(">", open) + 1, close) : out; + out = stripFence(inner); + } + return stripFence(out).replace(/\s+/g, " ").trim(); +} + // src/client/text.js var MENTION_ORIGIN = "https://bots.local/"; function linkMentions(text, bots, selfId) { @@ -2526,7 +2562,7 @@ function previewOf(list, id, hiddenTurns = [], unsentTurns = []) { 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)/, ""); + if (text !== "") return visibleText(stripTrace(stripHeader(text)).replace(/\s+/g, " ").trim().replace(/^[^\s→]{1,40}(?: → [^\s→]{1,40})+ (?=\S)/, "")); } return ""; } @@ -3082,11 +3118,38 @@ function FlyBotSessionDock({ sessionId, useRoster, actions, session }) { const room2 = roster?.roomsById?.[sessionId]; const bot = room2 ? void 0 : roster?.byId?.[sessionId]; const blank3 = session?.blank === true && session?.promptAttempted !== true; - return (0, import_react2.createElement)(import_react2.Fragment, null, entries2.map((entry) => (0, import_react2.createElement)( + const botRoom = blank3 && bot !== void 0 && !bot.remote ? bot : null; + return (0, import_react2.createElement)( + import_react2.Fragment, + null, + botRoom ? (0, import_react2.createElement)(BotChatStrip, { bot: botRoom, roster, actions, sessionId }) : null, + entries2.map((entry) => (0, import_react2.createElement)( + "div", + { key: entry.id, className: "bt-flybot-session-dock", "data-flybot": entry.id }, + (0, import_react2.createElement)(Safe, { entry, props: { sessionId, roster, room: room2, bot, actions, call: call2, t, blank: blank3 === true, claim: (ids) => claimApprovals(`${entry.id}:${sessionId}`, ids) } }) + )) + ); +} +function BotChatStrip({ bot, roster, actions, sessionId }) { + const main = isMainOf(roster, bot.id); + const subtitle = [bot.role, main ? t("Main Bot") : ""].filter(Boolean).join(" · ") || t("Bot"); + return (0, import_react2.createElement)( "div", - { key: entry.id, className: "bt-flybot-session-dock", "data-flybot": entry.id }, - (0, import_react2.createElement)(Safe, { entry, props: { sessionId, roster, room: room2, bot, actions, call: call2, t, blank: blank3 === true, claim: (ids) => claimApprovals(`${entry.id}:${sessionId}`, ids) } }) - ))); + { className: "bt-chat-strip", "data-flybot": "bot-chat-strip" }, + (0, import_react2.createElement)(BotAvatar, { bot, size: 44, main, live: false }), + (0, import_react2.createElement)( + "div", + { className: "bt-chat-strip-who" }, + (0, import_react2.createElement)("span", { className: "bt-chat-strip-name", title: bot.name }, bot.name), + (0, import_react2.createElement)("span", { className: "bt-chat-strip-sub" }, subtitle), + (0, import_react2.createElement)("span", { className: "bt-chat-strip-empty" }, t("No messages yet. Say something to {name}.", { name: bot.name })) + ), + (0, import_react2.createElement)("button", { + type: "button", + className: "bt-soft bt-soft-sm bt-chat-strip-go", + onClick: () => actions?.toggleDetails ? actions.toggleDetails(sessionId) : void 0 + }, t("Details")) + ); } function FlyBotRoomOverview({ room: room2, roster, actions }) { const entries2 = useSlot("flybot.room.overview"); @@ -3247,6 +3310,15 @@ function FlyBotApprovals({ useRoster, actions } = {}) { var fileSize = (n) => n < 1024 ? `${n} B` : n < 1048576 ? `${(n / 1024).toFixed(1)} KB` : `${(n / 1048576).toFixed(1)} MB`; var FLYBOT_CSS = ` .bt-flybot-session-dock{width:100%;max-width:min(992px,100%);margin:0 auto;padding:0 0 6px;box-sizing:border-box} +/* #1347: a Bot chat's own strip before its first turn, the same shape a group's uses (#998): one row + above the composer naming the chat, so a blank column is never nameless. */ +.bt-chat-strip{display:flex;align-items:center;gap:12px;width:100%;max-width:min(992px,100%);margin:0 auto 10px;padding:10px 12px;box-sizing:border-box;border:.5px solid var(--bt-line-2);border-radius:14px;background:var(--bt-card,var(--bt-main));animation:bt-rise .18s ease-out} +.bt-chat-strip-who{flex:1 1 auto;min-width:0;display:flex;flex-direction:column;gap:2px} +.bt-chat-strip-name{font-size:15px;line-height:20px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.bt-chat-strip-sub{font-size:12px;line-height:16px;color:var(--bt-ink-3);overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.bt-chat-strip-empty{font-size:12px;line-height:16px;color:var(--bt-ink-3);overflow-wrap:anywhere} +.bt-chat-strip-go{flex:none} +@media (prefers-reduced-motion:reduce){.bt-chat-strip{animation:none}} .bt-flybot-session-dock [data-flybot=chat-approvals]{align-items:flex-end;padding-bottom:4px} .bt-flybot-session-dock .fs-hold{margin-left:auto} .bt-flybot-approval-files { margin: 0 0 8px; padding-left: 18px; font-size: 12px; } @@ -3282,13 +3354,42 @@ var FLYBOT_CSS = ` .bt-flybot-pointer button { border: 0; border-radius: 999px; padding: 3px 10px; font-size: 12px; cursor: pointer; background: var(--bt-accent, #2563eb); color: #fff; } `; +// src/shared/failure-copy.js +var providerCode = (text) => { + const json = /"code"\s*:\s*"([A-Za-z0-9_.-]+)"/.exec(text); + if (json) return json[1]; + const plain = /^([A-Za-z][A-Za-z0-9_.-]{2,40}):\s/.exec(text); + return plain ? plain[1] : ""; +}; +var KNOWN = { + UnsupportedModel: "This model does not support that request. Pick another model in the Bot's settings.", + ModelNotFound: "The provider does not know this model. Check the model name in the Bot's settings.", + ContextLengthExceeded: "The conversation is longer than this model can read. Condense it, or pick a model with a larger window.", + InvalidRequest: "The model service refused the request. Check the model and the endpoint in the Bot's settings.", + InsufficientQuota: "The account is out of quota.", + RateLimitExceeded: "Too many requests right now. Try again in a moment." +}; +var fill2 = (text, params) => params === void 0 ? text : String(text).replace(/\{(\w+)\}/g, (match, name) => name in params ? String(params[name]) : match); +function failureSentence(raw, translate = (key) => key) { + const text = String(raw ?? "").trim(); + if (text === "") return text; + const key = KNOWN[providerCode(text)]; + if (key === void 0) return text; + const feature = /does not support\s+([a-z][a-z0-9 _-]{2,40})/i.exec(text)?.[1]?.trim(); + if (key !== KNOWN.UnsupportedModel || feature === void 0) return fill2(translate(key), void 0); + const withFeature = "This model does not support {feature}. Pick another model in the Bot's settings."; + return fill2(translate(withFeature, { feature }), { feature }); +} + // src/shared/turn-failures.js var FAILURE_LINES_SHOWN = 3; var failureLines = (snapshot, place, t2, limit = FAILURE_LINES_SHOWN) => { const ended = Array.isArray(snapshot?.ended) ? snapshot.ended : []; return ended.filter((entry) => entry?.silent === true && (entry.roomId ?? entry.sessionId) === place).slice(-limit).map((entry) => ({ id: `turn-failure-${entry.id}`, - text: t2("Could not run: {error}", { error: entry.failed ?? t2("the model call failed") }), + // The stored text can be a provider's JSON body cut mid-sentence; `failureSentence` says the + // same fact in words that name what to change (#1347). + text: t2("Could not run: {error}", { error: entry.failed === void 0 ? t2("the model call failed") : failureSentence(entry.failed, t2) }), ...Number(entry.streak) > 1 ? { streak: Number(entry.streak) } : {} })); }; @@ -4010,11 +4111,11 @@ button.bt-q-opt:active{background:var(--bt-active)} .bt-soft:hover{background:var(--bt-active)} .bt-time{display:block;text-align:center;font-size:12px;line-height:16px;color:var(--bt-ink-2);padding:6px 0} -.bt-panel{position:fixed;top:var(--dsh-frame-chrome-top,0px);right:0;bottom:0;width:var(--bt-panel-w,320px);pointer-events:auto;z-index:40;background:var(--bt-main);border-left:.5px solid var(--bt-line-2);display:flex;flex-direction:column;color:var(--bt-ink);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;box-sizing:border-box;animation:bt-pane-in .3s cubic-bezier(.2,0,0,1);transition:width ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing}} +.bt-panel{position:fixed;top:var(--dsh-frame-chrome-top,0px);right:0;bottom:0;width:var(--bt-panel-w,320px);pointer-events:auto;z-index:40;background:var(--bt-main);border-left:.5px solid var(--bt-line-2);display:flex;flex-direction:column;color:var(--bt-ink);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;box-sizing:border-box;animation:bt-pane-in .3s cubic-bezier(.2,0,0,1);transition:width ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing};container:bt-panel/inline-size} .bt-panel[data-leaving]{transform:translateX(100%);transition:transform .3s cubic-bezier(.2,0,0,1);pointer-events:none} .bt-panel[data-instant]{transition:none} @keyframes bt-pane-in{from{transform:translateX(100%)}} -[class*="_centerCol"]{transition:margin-right ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing}} +[class*="_centerCol"]{transition:margin-right ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing};position:relative;z-index:0;isolation:isolate} .bt-panel-grip{position:absolute;left:-5px;top:0;bottom:0;width:8px;cursor:col-resize;z-index:3;touch-action:none} .bt-panel-grip:hover,.bt-panel[data-dragging] .bt-panel-grip{background:linear-gradient(to right,transparent 4px,var(--bt-line-2) 4px,var(--bt-line-2) 6px,transparent 6px)} .bt-drag-shield{position:fixed;inset:0;z-index:45;cursor:col-resize} @@ -4059,7 +4160,7 @@ button.bt-q-opt:active{background:var(--bt-active)} .bt-avatar-anchor{position:relative} .bt-avatar-trigger{position:relative;width:64px;height:64px;border:0;border-radius:50%;background:none;cursor:pointer;padding:0} .bt-avatar-pencil{position:absolute;right:-2px;bottom:-2px;width:22px;height:22px;border-radius:50%;background:var(--bt-main);border:1px solid var(--bt-line-2);color:var(--bt-ink-2);display:flex;align-items:center;justify-content:center;box-shadow:0 1px 4px rgba(0,0,0,.12)} -.bt-avedit{position:absolute;top:calc(100% + 8px);left:50%;transform:translateX(-50%);width:300px;background:var(--bt-main);border:1px solid var(--bt-line);border-radius:12px;box-shadow:0 12px 40px rgba(0,0,0,.18);padding:10px;z-index:70;display:flex;flex-direction:column;gap:10px;color:var(--bt-ink);transform-origin:top center;animation:bt-pop-drop ${SPRING_TAP.duration}ms ${SPRING_TAP.easing},bt-veil-in .16s ease-out} +.bt-avedit{position:absolute;top:calc(100% + 8px);left:50%;transform:translateX(-50%);width:300px;background:var(--bt-main);border:1px solid var(--bt-line);border-radius:12px;box-shadow:0 12px 40px rgba(0,0,0,.18);padding:10px;z-index:130;display:flex;flex-direction:column;gap:10px;color:var(--bt-ink);transform-origin:top center;animation:bt-pop-drop ${SPRING_TAP.duration}ms ${SPRING_TAP.easing},bt-veil-in .16s ease-out} .bt-bs .bt-avedit{transform-origin:32px 0} .bt-avedit-top{display:flex;align-items:center;justify-content:space-between} .bt-avedit-tabs{position:relative;display:flex;gap:2px;background:var(--bt-hover);border-radius:999px;padding:2px} @@ -4099,9 +4200,23 @@ button.bt-q-opt:active{background:var(--bt-active)} .bt-seg button.bt-perm-danger,.bt-seg button.bt-perm-danger[aria-pressed=true]{color:#e02135} /* Settings dialog: a 198px page list beside the page. */ -.bt-settings-layer{position:fixed;inset:var(--dsh-frame-chrome-top,0px) 0 0;z-index:60;display:flex;align-items:center;justify-content:center;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif} +/* #1347: dialogs, sheets and scrims sit in a tier of their own, well clear of everything the + chat surface may do. They used to share 60/61 with the context menu, the reaction strip and the + remote-Bot sheet, so any body-level popover opened after them tied on z-index and won on DOM + order — and a chat element with a stray z-index could reach the same number. The tier is now + explicit: chat surface at most 50, dialogs 120/121, popovers 130, palette 140, toast 1000. The + chat column is additionally capped at z-index 0 inside its own stacking context (see the + center-column rule below), so nothing in the thread can outrank these whatever it does. */ +.bt-settings-layer{position:fixed;inset:var(--dsh-frame-chrome-top,0px) 0 0;z-index:120;isolation:isolate;display:flex;align-items:center;justify-content:center;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif} .bt-scrim{position:absolute;inset:0;background:rgba(0,0,0,.32);animation:bt-fade .22s ease-out} body[data-ds-dark-theme] .bt-scrim{background:rgba(0,0,0,.5)} +/* #1347: a modal is a modal. A 0.32 scrim leaves the thread legible behind it, and the owner read that + as the chat painting through the dialog ("assistant text readable across columns", a pill visible at + full opacity behind the Inbox). The scrim now separates the two surfaces outright: the thread is + dimmed to a backdrop, not left readable beside the thing being decided. It is still one layer and + still dismisses on a click, so nothing else about it changes. */ +body:not([data-ds-dark-theme]) .bt-scrim{background:rgba(12,12,14,.62)} +body[data-ds-dark-theme] .bt-scrim{background:rgba(0,0,0,.7)} .bt-settings{--bt-set-w:min(800px,calc(100vw - 48px));--bt-set-h:min(620px,calc(100vh - 2*max(32px,var(--dsh-frame-overlay-top,32px))));position:relative;width:var(--bt-set-w);height:var(--bt-set-h);background:var(--bt-main);border-radius:16px;border:.5px solid var(--bt-line-2);box-shadow:0 24px 80px rgba(0,0,0,.24);display:flex;overflow:hidden;color:var(--bt-ink);transition:${spring(SPRING_SHAPE, "width", "height")};animation:bt-dialog-rise ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing},bt-veil-in .2s ease-out} .bt-settings[data-wide]{--bt-set-w:min(1100px,calc(100vw - 48px));--bt-set-h:min(780px,calc(100vh - 2*max(32px,var(--dsh-frame-overlay-top,32px))))} :is(.bt-settings-layer,.bt-mem-layer)[data-leaving]{pointer-events:none} @@ -4154,6 +4269,10 @@ button.bt-set-row:hover{background:var(--bt-hover)} /* #892: a plugin's rows in the Members card (Invite by link) sit on the card's own padding, like a row. */ .bt-flybot-room-members{padding:10px 12px} .bt-set-copy{flex:1;display:flex;flex-direction:column;gap:2px;min-width:0} +/* #1347: a row's copy never breaks its own label. The label used to be a bare inline span inside a + column that flexed down to 36px, so "Name" came out as "Nam" + "e"; a single-word label is now one + line, and the hint below it is what wraps when the column is short. */ +.bt-set-copy>.bt-bs-label,.bt-set-copy>.bt-set-value{white-space:nowrap;overflow:hidden;text-overflow:ellipsis} .bt-set-hint{font-size:12px;line-height:16px;color:color-mix(in srgb,var(--bt-ink-2) 50%,var(--bt-ink-3))} .bt-set-control{flex:none;display:flex;align-items:center;gap:6px} .bt-set-value{font-size:13px;color:var(--bt-ink-2);text-align:right} @@ -4235,7 +4354,7 @@ button.bt-set-row:hover{background:var(--bt-hover)} .bt-tool:hover,.bt-tool[aria-expanded=true]{background:var(--bt-hover);color:var(--bt-ink)} .bt-tool:active{background:var(--bt-active)} .bt-tool:focus-visible{outline:2px solid var(--bt-accent);outline-offset:-2px} -.bt-react-strip{position:fixed;z-index:60;pointer-events:auto;display:flex;gap:2px;padding:4px;border-radius:999px;background:var(--bt-main);border:.5px solid var(--bt-line-2);box-shadow:0 10px 20px -3px rgba(0,0,0,.08),0 4px 6px -4px rgba(0,0,0,.06);transform:translate(-50%,-100%);transform-origin:bottom center;animation:bt-pop-lift ${SPRING_TAP.duration}ms ${SPRING_TAP.easing},bt-veil-in .14s ease-out} +.bt-react-strip{position:fixed;z-index:130;pointer-events:auto;display:flex;gap:2px;padding:4px;border-radius:999px;background:var(--bt-main);border:.5px solid var(--bt-line-2);box-shadow:0 10px 20px -3px rgba(0,0,0,.08),0 4px 6px -4px rgba(0,0,0,.06);transform:translate(-50%,-100%);transform-origin:bottom center;animation:bt-pop-lift ${SPRING_TAP.duration}ms ${SPRING_TAP.easing},bt-veil-in .14s ease-out} .bt-react-strip button{width:32px;height:32px;border:0;border-radius:50%;background:none;font-size:18px;line-height:1;cursor:pointer;transition:background-color .12s ease,${spring(SPRING_POP, "transform")};animation:bt-check-pop ${SPRING_POP.duration}ms ${SPRING_POP.easing} backwards} .bt-react-strip button:nth-child(2){animation-delay:20ms} .bt-react-strip button:nth-child(3){animation-delay:40ms} @@ -4254,7 +4373,7 @@ button:hover .bt-you{background:color-mix(in srgb,var(--bt-ink) 9%,var(--bt-side .bt-you-wrap{display:inline-flex;padding:0} .bt-connect-foot{width:100%;height:36px} -.bt-menu{position:fixed;pointer-events:auto;z-index:60;min-width:200px;padding:6px;border-radius:12px;background:var(--bt-main);border:.5px solid var(--bt-line-2);box-shadow:0 10px 20px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1),0 0 0 1px rgba(228,228,228,.04);display:flex;flex-direction:column;gap:2px;transform-origin:var(--bt-origin,top left);animation:bt-pop-drop ${SPRING_TAP.duration}ms ${SPRING_TAP.easing},bt-veil-in .14s ease-out;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;box-sizing:border-box} +.bt-menu{position:fixed;pointer-events:auto;z-index:130;min-width:200px;padding:6px;border-radius:12px;background:var(--bt-main);border:.5px solid var(--bt-line-2);box-shadow:0 10px 20px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1),0 0 0 1px rgba(228,228,228,.04);display:flex;flex-direction:column;gap:2px;transform-origin:var(--bt-origin,top left);animation:bt-pop-drop ${SPRING_TAP.duration}ms ${SPRING_TAP.easing},bt-veil-in .14s ease-out;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;box-sizing:border-box} .bt-menu button{display:flex;align-items:center;gap:4px;width:100%;min-height:30px;text-align:left;border:0;background:none;border-radius:6px;padding:6px 8px;font:inherit;font-size:13px;line-height:18px;color:var(--bt-ink);cursor:pointer;box-sizing:border-box} .bt-menu button:hover{background:var(--bt-hover)} .bt-menu button svg{flex:none;width:16px;height:16px;margin:0 1px} @@ -4264,7 +4383,7 @@ button:hover .bt-you{background:color-mix(in srgb,var(--bt-ink) 9%,var(--bt-side .bt-menu[data-leaving]{pointer-events:none;animation:bt-pop-out .12s cubic-bezier(.4,0,1,1) forwards} .bt-menu-label{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .bt-menu-hint{flex:none;color:var(--bt-ink-3);font-size:12px;font-variant-numeric:tabular-nums} -.bt-palette-layer{position:fixed;inset:var(--dsh-frame-chrome-top,0px) 0 0;z-index:70;pointer-events:auto;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif} +.bt-palette-layer{position:fixed;inset:var(--dsh-frame-chrome-top,0px) 0 0;z-index:140;isolation:isolate;pointer-events:auto;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif} .bt-backdrop{position:absolute;inset:0;background:rgba(0,0,0,.46);animation:bt-fade .22s ease-out} .bt-palette{position:absolute;top:50%;left:50%;width:min(560px,92vw);transform:translate(-50%,-50%);display:flex;flex-direction:column;border-radius:12px;background:var(--bt-main);border:1px solid var(--bt-line-2);box-shadow:0 24px 48px -12px rgba(0,0,0,.25);overflow:hidden;color:var(--bt-ink);animation:bt-palette-in ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing},bt-veil-in .2s ease-out} .bt-palette-layer[data-leaving]{pointer-events:none} @@ -4369,7 +4488,7 @@ body[data-ds-dark-theme] .bt-theme-chip i{background:var(--da)} .bt-mem-row>svg{flex:none;color:var(--bt-ink-2)} .bt-mem-row-copy{flex:1;text-align:left} .bt-mem-row-go{display:inline-flex;align-items:center;gap:2px;color:var(--bt-ink-3);font-size:12px} -.bt-mem-layer{position:fixed;inset:var(--dsh-frame-chrome-top,0px) 0 0;z-index:61;display:flex;align-items:center;justify-content:center;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif} +.bt-mem-layer{position:fixed;inset:var(--dsh-frame-chrome-top,0px) 0 0;z-index:121;isolation:isolate;display:flex;align-items:center;justify-content:center;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif} .bt-mem-scrim{animation:bt-fade .2s ease-out} .bt-mem{position:relative;width:min(680px,calc(100vw - 48px));height:min(760px,calc(100vh - 2*max(32px,var(--dsh-frame-overlay-top,32px))));background:var(--bt-main);border-radius:20px;border:.5px solid var(--bt-line-2);box-shadow:0 24px 80px rgba(0,0,0,.24);display:flex;flex-direction:column;overflow:hidden;color:var(--bt-ink);animation:bt-dialog-rise ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing},bt-veil-in .2s ease-out} .bt-mem-head{position:relative;display:flex;align-items:center;gap:8px;height:60px;flex:none;padding:0 14px 0 24px;border-bottom:.5px solid var(--bt-line);box-sizing:border-box} @@ -4516,11 +4635,31 @@ var CONVERSATION_CSS = ` [class*="_header"] [class*="switcherTitle"]{white-space:normal;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;line-clamp:2;overflow:hidden;text-overflow:ellipsis} [class*="_header"] [class*="switcherTrigger"]{height:auto;align-items:flex-start} [class*="_header"] .bt-head .bt-mark{width:22px!important;height:22px!important;--bt-size:22px} +/* #1347: "Standard mode" beside the title reads as a control — a filled chip with an icon — but the + shell draws it as a span with no click handler (dsh-client-ui-agent-preset, AgentPresetLabel): + clicking it does nothing, and the only thing behind it is a hover title. In a Bot chat the mode is + fixed for the whole Session anyway, so the chip becomes what it is: a status word on the title's + line. No fill, no radius, muted ink, and the description stays on hover. */ +[data-slot="conversation.session.header.actions"] [class*="_label"]{background:none;border-radius:0;color:var(--bt-ink-3);cursor:help;pointer-events:auto} +[data-slot="conversation.session.header.actions"] [class*="_label"] svg{opacity:.7} [class*="_body"]:has(> [class*="_scrollBody"]){--dsh-chat-content-width:min(960px,100%)} /* DSH's turn tail keeps what plugins put in it (delivered files, the Bot's separators), without its copy and branch row; a tail with nothing in it takes no room. */ [data-turn-tail]>:not([data-slot="conversation.chat.turnTail"]):not(.bt-typing){display:none!important} [class*="_flowItem"][data-chat-flow-kind="turn-tail"]:not(:has([data-slot="conversation.chat.turnTail"]:not(:empty))):not(:has([data-turn-tail]>.bt-typing)){display:none!important} +/* #1347: the thread must never paint above a dialog, a sheet or a scrim. + The chat column had no stacking context of its own (position:static, z-index:auto), so nothing + inside it was boxed in: anything the browser promoted to its own composited layer — an entrance + animation (bt-row-in animates a transform), a filter, a will-change, a scroll container's sticky + descendant — was ordered against the ROOT stacking context instead of against the column. A + dialog's layer is a z-index box at the root, so a promoted bubble could sort above it, which is + exactly what the owner's screenshots show: a right-aligned pill at full opacity across the Tasks + dialog and over the Inbox scrim, while the same pill sat under it in a plain DOM walk. Giving the + column its own stacking context (isolation:isolate) paints every descendant inside it, and its + z-index:0 keeps all of that below .bt-pane (45), .bt-panel (40) and the dialog layers at 120/121 + whatever any single bubble does. contain:paint is deliberately NOT used: it would clip the + popovers the thread legitimately renders (reaction strip, message menu), which portal to body + for exactly that reason. */ /* Every team Session belongs to a Bot or a group, so a blank one is an empty chat, not the shell's new-session hero: no headline, no workspace or mode picker, and the composer stays docked at the bottom. */ @@ -7888,7 +8027,7 @@ function InboxCard({ card, roster, now, busy, error, onAnswer, onOpen }) { ), (0, import_react13.createElement)("span", { className: "bt-inbox-title", title: card.title }, card.title), card.calls > 0 ? (0, import_react13.createElement)("span", { className: "bt-inbox-calls" }, t("{count} tool calls", { count: card.calls })) : null, - card.reason ? (0, import_react13.createElement)("span", { className: "bt-inbox-reason", title: card.reason }, card.reason) : null, + card.reason ? (0, import_react13.createElement)("span", { className: "bt-inbox-reason", title: card.reason }, failureSentence(card.reason, t)) : null, card.activity ? (0, import_react13.createElement)("span", { className: "bt-inbox-activity", title: card.activity }, t("Latest: {text}", { text: card.activity })) : null, card.hint ? (0, import_react13.createElement)("span", { className: "bt-inbox-hint" }, card.hint) : null, evidence.length > 0 ? (0, import_react13.createElement)("span", { className: "bt-inbox-evidence" }, ...evidence.map((href, index) => (0, import_react13.createElement)("a", { @@ -9831,7 +9970,7 @@ function FaceMark() { function RoomRow({ room: room2, roster, actions, useSessions, useSessionStatus, useSessionRetainInfo, useWork, wide, marked }) { const id = room2.id; const fallback = useSessions((list) => nameMentions(previewOf(list, id), roster)); - const preview = nameMentions(room2.preview?.replace(/\s+/g, " ").trim(), roster) || fallback; + const preview = nameMentions(visibleText(room2.preview), roster) || fallback; const active = useSessionRetainInfo(id, (info) => (info?.retainedBy?.mainView ?? 0) > 0); const unread = useSessionStatus((map) => map.get(id)?.completionUnread === true) || marked && !active; useMarkedUnread(id, active, marked, actions); @@ -10506,7 +10645,7 @@ var SIDEBAR_CSS = ` .bt-update[data-phase=installing] .bt-cloud-arrow{animation:bt-cloud-drop 1.1s cubic-bezier(.45,0,.55,1) infinite} @keyframes bt-cloud-drop{0%{transform:translateY(-3px);opacity:0}35%{opacity:1}70%{transform:translateY(1px);opacity:1}100%{transform:translateY(2px);opacity:0}} .bt-update-dot{position:absolute;top:6px;right:6px;width:7px;height:7px;border-radius:50%;background:#E5484D;box-shadow:0 0 0 1.5px var(--bt-sidebar)} -.bt-update-pop{position:fixed;z-index:60;width:280px;padding:14px 14px 12px;border-radius:14px;background:var(--bt-main);border:.5px solid var(--bt-line-2);box-shadow:0 10px 20px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1);color:var(--bt-ink);box-sizing:border-box;transform-origin:top left;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;animation:bt-update-pop ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing}} +.bt-update-pop{position:fixed;z-index:130;width:280px;padding:14px 14px 12px;border-radius:14px;background:var(--bt-main);border:.5px solid var(--bt-line-2);box-shadow:0 10px 20px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1);color:var(--bt-ink);box-sizing:border-box;transform-origin:top left;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;animation:bt-update-pop ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing}} @keyframes bt-update-pop{from{opacity:0;transform:scale(.96);filter:blur(4px)}} .bt-update-title{font-size:14px;line-height:20px;font-weight:600} .bt-update-text{margin-top:2px;font-size:12.5px;line-height:18px;color:var(--bt-ink-2)} @@ -11894,6 +12033,14 @@ var MODEL_TEST_CSS = ` .bt-model-test-result[data-ok=true]{color:#1a7f37} .bt-model-test-result[data-ok=false]{color:#c21d2e} .bt-model-test-code{margin-left:6px;font-size:11px;color:var(--bt-ink-3)} +/* #1347: the hint beside "Test connection" is prose and wraps; the button next to it keeps its own + words ("Test connection" on one line) and takes the room it needs. */ +.bt-model-test>.bt-set-copy{flex:1 1 12ch;min-width:12ch} +.bt-model-test>.bt-soft{flex:none;white-space:nowrap} +@container bt-panel (max-width:300px){ + .bt-model-test{flex-wrap:wrap;row-gap:6px} + .bt-model-test>.bt-set-copy{flex:1 1 100%} +} `; // src/client/folder-menu.js @@ -13651,9 +13798,16 @@ var BOT_SETTINGS_CSS = ` .bt-bs .bt-avedit{left:0;transform:none} .bt-bs-row{min-height:48px} label.bt-bs-row{cursor:text} -.bt-bs-row>.bt-set-copy{flex:1 1 auto} .bt-bs-label{font-size:13px;line-height:18px;font-weight:500} +/* #1347: a row keeps its label and its control side by side. The *label* is one line — "Name" used to + break into "Nam" + "e" inside a 36px column — and a control never breaks its own words: "Test + connection" came out as "Test connecti" / "on" and Main Bot's "Change" as "Ch/an/ge". The hint under + a label is prose, so it wraps and is never cut: the row grows instead. */ +.bt-bs-row>.bt-set-copy{flex:1 1 8ch;min-width:8ch} +.bt-bs-row>.bt-set-copy>.bt-bs-label{white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.bt-bs-row .bt-soft,.bt-bs-row .bt-mini{flex:none;white-space:nowrap} .bt-bs-row .bt-model-trigger{flex:none;max-width:58%} +.bt-bs-row .bt-model-trigger :is(.bt-model-name,.bt-model-provider){white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0} ${MODEL_TEST_CSS} .bt-bs-input{flex:0 1 220px;width:0;min-width:96px;height:30px;box-sizing:border-box;margin:-4px -6px -4px 0;padding:0 8px;border:1px solid transparent;border-radius:8px;background:none;color:var(--bt-ink);font:inherit;font-size:13px;text-align:right;outline:none;transition:background-color .12s ease,border-color .12s ease} .bt-bs-input:hover{background:var(--bt-hover)} @@ -13675,6 +13829,14 @@ ${MODEL_TEST_CSS} .bt-switch[aria-checked=true]:active:not(:disabled) .bt-switch-knob{transform:translateX(11px)} .bt-avedit :is(.bt-shapes,.bt-colors,.bt-dropzone){animation:bt-veil-in .18s ease-out} @media (prefers-reduced-motion:reduce){.bt-switch,.bt-switch-knob{transition:none}.bt-avedit :is(.bt-shapes,.bt-colors,.bt-dropzone){animation:none}} +/* #1347: the panel's floor is 240px, where a label and a 96px field cannot share a line honestly. Below + 300px the row stacks instead: the copy keeps its whole width on top, the control sits under it and + keeps its own words. Nothing is clipped to fit a width that is not there. */ +@container bt-panel (max-width:300px){ + .bt-bs-row{flex-wrap:wrap;row-gap:6px} + .bt-bs-row>.bt-set-copy{flex:1 1 100%;min-width:0} + .bt-bs-input{flex:1 1 100%;width:auto;min-width:0;margin:0;text-align:left} +} `; // src/client/group-settings.js @@ -15811,7 +15973,7 @@ function Chart({ range, columns, series, tipSeries, by, onBy, onPick }) { ) : null ); } -function Row2({ kind, glyph, name, sub, sum, whole, fill: fill2, index, onClick }) { +function Row2({ kind, glyph, name, sub, sum, whole, fill: fill3, index, onClick }) { const width = whole > 0 ? Math.max(sum.total > 0 ? 2 : 0, sum.total / whole * 100) : 0; return (0, import_react37.createElement)( onClick ? "button" : "div", @@ -15832,7 +15994,7 @@ function Row2({ kind, glyph, name, sub, sum, whole, fill: fill2, index, onClick (0, import_react37.createElement)("span", { className: "bt-us-row-name" }, name), sub ? (0, import_react37.createElement)("span", { className: "bt-us-row-sub" }, sub) : null ), - (0, import_react37.createElement)("span", { className: "bt-us-track" }, (0, import_react37.createElement)("span", { style: { width: `${width}%`, background: fill2 } })) + (0, import_react37.createElement)("span", { className: "bt-us-track" }, (0, import_react37.createElement)("span", { style: { width: `${width}%`, background: fill3 } })) ), (0, import_react37.createElement)("span", { className: "bt-us-row-num" }, (0, import_react37.createElement)("b", null, compact(sum.total)), (0, import_react37.createElement)("small", null, `${percent(sum.total, whole)}%`)) ); @@ -17584,7 +17746,7 @@ function AgentMenu({ menu, roster, actions, leaving }) { // src/client/pane-width.js var DEFAULT_WIDTH = 520; -var DETAILS_WIDTH = 320; +var DETAILS_WIDTH = 380; var DETAILS_MAX = 520; var MIN_WIDTH = 360; var MIN_CHAT = 420; @@ -17974,9 +18136,19 @@ function DetailsDrawer({ sessionId, startRenaming, startTab, hasStrip, roster, a return false; }); const share = () => { - void navigator.clipboard?.writeText(transcript).then(() => { + const text = transcript; + if (!text) { + actions.notify?.(t("Nothing to copy yet")); + return; + } + void copiedToClipboard(text).then((written) => { + if (!written) { + actions.reportError?.(new Error(t("Could not copy the conversation")), "share"); + return; + } setCopied(true); setTimeout(() => setCopied(false), 1600); + actions.notify?.(t("Conversation copied as Markdown")); }); }; const errorLine = error ? (0, import_react43.createElement)("div", { className: "bt-error", role: "alert", style: { padding: 0 } }, error) : null; @@ -18009,7 +18181,16 @@ function DetailsDrawer({ sessionId, startRenaming, startTab, hasStrip, roster, a onClick: () => bot?.remote ? actions.openSettings("bots", bot.id) : setView("settings") }, (0, import_react43.createElement)(GearIcon)) : null, bot && !bot.remote && roster.browser === true && actions.openBrowser ? (0, import_react43.createElement)("button", { type: "button", className: "bt-round", "aria-label": t("Open browser"), title: t("Open this Bot's browser"), onClick: () => actions.openBrowser(sessionId) }, (0, import_react43.createElement)(GlobeIcon)) : null, - bot || room2 ? (0, import_react43.createElement)("button", { type: "button", className: "bt-round", "aria-label": copied ? t("Copied conversation") : t("Share conversation"), title: copied ? t("Copied") : t("Copy conversation as Markdown"), onClick: share }, copied ? (0, import_react43.createElement)("span", { key: "copied", className: "bt-pop-in" }, "✓") : (0, import_react43.createElement)(ShareIcon)) : null, + bot || room2 ? (0, import_react43.createElement)("button", { + type: "button", + className: "bt-round", + // #1347: the accessible name says what the click does, which is copy — "Share conversation" + // promised a sharing step that never came. It still says "Copied" after a copy, so a screen + // reader hears the same confirmation the toast gives. + "aria-label": copied ? t("Copied conversation") : t("Copy conversation"), + title: copied ? t("Copied") : t("Copy conversation as Markdown"), + onClick: share + }, copied ? (0, import_react43.createElement)("span", { key: "copied", className: "bt-pop-in" }, "✓") : (0, import_react43.createElement)(ShareIcon)) : null, hasStrip ? null : (0, import_react43.createElement)("button", { type: "button", className: "bt-round", "aria-label": t("Close details"), onClick: close }, (0, import_react43.createElement)(CloseIcon)) ); if (!bot && !room2) { @@ -18133,7 +18314,7 @@ function Favicon({ src, className }) { var savedWidth = () => { try { const value = Number(localStorage.getItem(WIDTH_KEY)); - return Number.isFinite(value) && value >= DETAILS_WIDTH ? value : null; + return Number.isFinite(value) && value >= MIN_DETAILS ? value : null; } catch { return null; } @@ -18733,7 +18914,7 @@ var BROWSER_CSS = ` // src/client/remote-bot-settings.js var import_react45 = require("react"); var REMOTE_BOT_SETTINGS_CSS = ` -.bt-remote-sheet-layer{position:fixed;inset:0;z-index:60;display:flex;align-items:flex-end;justify-content:center;padding:16px;pointer-events:none} +.bt-remote-sheet-layer{position:fixed;inset:0;z-index:120;isolation:isolate;display:flex;align-items:flex-end;justify-content:center;padding:16px;pointer-events:none} .bt-remote-sheet-layer[data-leaving]{pointer-events:none} .bt-remote-sheet-layer .bt-scrim{position:absolute;inset:0;z-index:0;pointer-events:auto} .bt-remote-sheet-layer[data-leaving] .bt-scrim{opacity:0;transition:opacity .16s ease-in} diff --git a/core/src/client/bot-settings.js b/core/src/client/bot-settings.js index 862840292..a27eb87d7 100644 --- a/core/src/client/bot-settings.js +++ b/core/src/client/bot-settings.js @@ -276,9 +276,16 @@ export const BOT_SETTINGS_CSS = ` .bt-bs .bt-avedit{left:0;transform:none} .bt-bs-row{min-height:48px} label.bt-bs-row{cursor:text} -.bt-bs-row>.bt-set-copy{flex:1 1 auto} .bt-bs-label{font-size:13px;line-height:18px;font-weight:500} +/* #1347: a row keeps its label and its control side by side. The *label* is one line — "Name" used to + break into "Nam" + "e" inside a 36px column — and a control never breaks its own words: "Test + connection" came out as "Test connecti" / "on" and Main Bot's "Change" as "Ch/an/ge". The hint under + a label is prose, so it wraps and is never cut: the row grows instead. */ +.bt-bs-row>.bt-set-copy{flex:1 1 8ch;min-width:8ch} +.bt-bs-row>.bt-set-copy>.bt-bs-label{white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.bt-bs-row .bt-soft,.bt-bs-row .bt-mini{flex:none;white-space:nowrap} .bt-bs-row .bt-model-trigger{flex:none;max-width:58%} +.bt-bs-row .bt-model-trigger :is(.bt-model-name,.bt-model-provider){white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0} ${MODEL_TEST_CSS} .bt-bs-input{flex:0 1 220px;width:0;min-width:96px;height:30px;box-sizing:border-box;margin:-4px -6px -4px 0;padding:0 8px;border:1px solid transparent;border-radius:8px;background:none;color:var(--bt-ink);font:inherit;font-size:13px;text-align:right;outline:none;transition:background-color .12s ease,border-color .12s ease} .bt-bs-input:hover{background:var(--bt-hover)} @@ -300,4 +307,12 @@ ${MODEL_TEST_CSS} .bt-switch[aria-checked=true]:active:not(:disabled) .bt-switch-knob{transform:translateX(11px)} .bt-avedit :is(.bt-shapes,.bt-colors,.bt-dropzone){animation:bt-veil-in .18s ease-out} @media (prefers-reduced-motion:reduce){.bt-switch,.bt-switch-knob{transition:none}.bt-avedit :is(.bt-shapes,.bt-colors,.bt-dropzone){animation:none}} +/* #1347: the panel's floor is 240px, where a label and a 96px field cannot share a line honestly. Below + 300px the row stacks instead: the copy keeps its whole width on top, the control sits under it and + keeps its own words. Nothing is clipped to fit a width that is not there. */ +@container bt-panel (max-width:300px){ + .bt-bs-row{flex-wrap:wrap;row-gap:6px} + .bt-bs-row>.bt-set-copy{flex:1 1 100%;min-width:0} + .bt-bs-input{flex:1 1 100%;width:auto;min-width:0;margin:0;text-align:left} +} ` diff --git a/core/src/client/browser-pane.js b/core/src/client/browser-pane.js index a20070c44..0f360679a 100644 --- a/core/src/client/browser-pane.js +++ b/core/src/client/browser-pane.js @@ -4,7 +4,7 @@ // body-level layer by bot-browsers.js; the panel draws the strip and the toolbar // and reports the rectangle the shown webview covers. import { createElement as h, useEffect, useLayoutEffect, useRef, useState } from 'react' -import { DEFAULT_WIDTH, DETAILS_WIDTH, panelWidthRange } from './pane-width.js' +import { DEFAULT_WIDTH, DETAILS_WIDTH, MIN_DETAILS, panelWidthRange } from './pane-width.js' import { createPortal } from 'react-dom' import { BotMark } from './mark.js' import { STORAGE } from './storage.js' @@ -44,11 +44,13 @@ export function Favicon({ src, className }) { } // null until the user sets a width: the Bot page then opens at DETAILS_WIDTH and the -// browser at DEFAULT_WIDTH. +// browser at DEFAULT_WIDTH. A saved width narrower than DETAILS_WIDTH is a *stored* choice from a +// narrower policy (#1347 raised DETAILS_WIDTH from 320 to 380), so it is kept: only the floor of +// what the panel can be dragged to is enforced here. const savedWidth = () => { try { const value = Number(localStorage.getItem(WIDTH_KEY)) - return Number.isFinite(value) && value >= DETAILS_WIDTH ? value : null + return Number.isFinite(value) && value >= MIN_DETAILS ? value : null } catch { return null } } const fitWidth = (width, [low, high]) => Math.round(Math.max(low, Math.min(width, high))) diff --git a/core/src/client/details-drawer.js b/core/src/client/details-drawer.js index e30f299e5..13d01f317 100644 --- a/core/src/client/details-drawer.js +++ b/core/src/client/details-drawer.js @@ -14,7 +14,7 @@ import { ScheduleSummary } from './schedules.js' import { Segmented, useGlide, useMorph } from './motion.js' import { MemberWork, useMemberWork } from './member-status.js' import { t } from './i18n.js' -import { remoteBotTitle, roomTitle } from '../shared/remote-label.js' +import { remoteBotTitle, roomTitle, copiedToClipboard } from '../shared/remote-label.js' import { FlyBotRoomOverview, FlyBotDetails } from './flybot-slots.js' import { call } from './api.js' @@ -257,9 +257,17 @@ export function DetailsDrawer({ sessionId, startRenaming, startTab, hasStrip, ro .then(() => { setError(''); return true }) .catch(failure => { setError(failure?.message ?? String(failure)); return false }) const share = () => { - void navigator.clipboard?.writeText(transcript).then(() => { + // #1347: a copy that says nothing is a click that did nothing to anyone watching. `copiedToClipboard` + // answers whether the text really landed — a page with no clipboard at all is a `false` there, not the + // quiet success `navigator.clipboard?.writeText(...)` gives when the API is missing — so the toast + // reports what happened instead of the tick alone claiming it. + const text = transcript + if (!text) { actions.notify?.(t('Nothing to copy yet')); return } + void copiedToClipboard(text).then(written => { + if (!written) { actions.reportError?.(new Error(t('Could not copy the conversation')), 'share'); return } setCopied(true) setTimeout(() => setCopied(false), 1600) + actions.notify?.(t('Conversation copied as Markdown')) }) } const errorLine = error ? h('div', { className: 'bt-error', role: 'alert', style: { padding: 0 } }, error) : null @@ -282,7 +290,15 @@ export function DetailsDrawer({ sessionId, startRenaming, startTab, hasStrip, ro bot && !bot.remote && roster.browser === true && actions.openBrowser ? h('button', { type: 'button', className: 'bt-round', 'aria-label': t('Open browser'), title: t("Open this Bot's browser"), onClick: () => actions.openBrowser(sessionId) }, h(GlobeIcon)) : null, - bot || room ? h('button', { type: 'button', className: 'bt-round', 'aria-label': copied ? t('Copied conversation') : t('Share conversation'), title: copied ? t('Copied') : t('Copy conversation as Markdown'), onClick: share }, copied ? h('span', { key: 'copied', className: 'bt-pop-in' }, '✓') : h(ShareIcon)) : null, + bot || room ? h('button', { + type: 'button', className: 'bt-round', + // #1347: the accessible name says what the click does, which is copy — "Share conversation" + // promised a sharing step that never came. It still says "Copied" after a copy, so a screen + // reader hears the same confirmation the toast gives. + 'aria-label': copied ? t('Copied conversation') : t('Copy conversation'), + title: copied ? t('Copied') : t('Copy conversation as Markdown'), + onClick: share, + }, copied ? h('span', { key: 'copied', className: 'bt-pop-in' }, '✓') : h(ShareIcon)) : null, hasStrip ? null : h('button', { type: 'button', className: 'bt-round', 'aria-label': t('Close details'), onClick: close }, h(CloseIcon))) if (!bot && !room) { return [h(Fragment, { key: 'top' }, top), diff --git a/core/src/client/flybot-slots.js b/core/src/client/flybot-slots.js index 15d4041eb..c0ed0e6ba 100644 --- a/core/src/client/flybot-slots.js +++ b/core/src/client/flybot-slots.js @@ -44,6 +44,7 @@ import { t } from './i18n.js' import { chatOpened, claimApprovals, claimedIds, onClaims } from './approval-claims.js' import { isApprovalWaiting, WAITING_STATUSES } from '../shared/approval-waiting.js' import { canRetry } from '../shared/held-retry.js' +import { isMainOf } from './sources.js' import { createClientApi as createClientApiWith } from './flybot-registry.js' @@ -172,8 +173,34 @@ export function FlyBotSessionDock({ sessionId, useRoster, actions, session }) { // that fact on the snapshot, and a failed first send never turns it back off, so standing in for a // header the host is already drawing cannot happen. const blank = session?.blank === true && session?.promptAttempted !== true - return h(Fragment, null, entries.map(entry => h('div', { key: entry.id, className: 'bt-flybot-session-dock', 'data-flybot': entry.id }, - h(Safe, { entry, props: { sessionId, roster, room, bot, actions, call, t, blank: blank === true, claim: ids => claimApprovals(`${entry.id}:${sessionId}`, ids) } })))) + // #1347: a Bot's chat has the same first-paint problem as a group's, and the sidebar row is the only + // thing naming it. The shell draws `headerBlank` with no title for a Session that has said nothing, + // so the panel below the composer reads as a blank column: no name, no avatar, no way back to the + // Bot's details. The strip is the same one a group gets (#998) and reads the same `blank` fact. + // A friend's Bot is not this home's chat: it keeps its own empty hint (#173), so it is left out. + const botRoom = blank && bot !== undefined && !bot.remote ? bot : null + return h(Fragment, null, + botRoom ? h(BotChatStrip, { bot: botRoom, roster, actions, sessionId }) : null, + entries.map(entry => h('div', { key: entry.id, className: 'bt-flybot-session-dock', 'data-flybot': entry.id }, + h(Safe, { entry, props: { sessionId, roster, room, bot, actions, call, t, blank: blank === true, claim: ids => claimApprovals(`${entry.id}:${sessionId}`, ids) } })))) +} + +// A Bot chat's own header before its first turn (#1347): who this chat is with, that nothing has been +// said yet, and the one thing a blank column cannot offer — its details. It is the dock's own child, so +// it disappears the moment the shell draws the real header (the same `blank` condition #998 uses). +export function BotChatStrip({ bot, roster, actions, sessionId }) { + const main = isMainOf(roster, bot.id) + const subtitle = [bot.role, main ? t('Main Bot') : ''].filter(Boolean).join(' · ') || t('Bot') + return h('div', { className: 'bt-chat-strip', 'data-flybot': 'bot-chat-strip' }, + h(BotAvatar, { bot, size: 44, main, live: false }), + h('div', { className: 'bt-chat-strip-who' }, + h('span', { className: 'bt-chat-strip-name', title: bot.name }, bot.name), + h('span', { className: 'bt-chat-strip-sub' }, subtitle), + h('span', { className: 'bt-chat-strip-empty' }, t('No messages yet. Say something to {name}.', { name: bot.name }))), + h('button', { + type: 'button', className: 'bt-soft bt-soft-sm bt-chat-strip-go', + onClick: () => (actions?.toggleDetails ? actions.toggleDetails(sessionId) : undefined), + }, t('Details'))) } export function FlyBotRoomOverview({ room, roster, actions }) { @@ -323,6 +350,15 @@ export const fileSize = n => (n < 1024 ? `${n} B` : n < 1048576 ? `${(n / 1024). export const FLYBOT_CSS = ` .bt-flybot-session-dock{width:100%;max-width:min(992px,100%);margin:0 auto;padding:0 0 6px;box-sizing:border-box} +/* #1347: a Bot chat's own strip before its first turn, the same shape a group's uses (#998): one row + above the composer naming the chat, so a blank column is never nameless. */ +.bt-chat-strip{display:flex;align-items:center;gap:12px;width:100%;max-width:min(992px,100%);margin:0 auto 10px;padding:10px 12px;box-sizing:border-box;border:.5px solid var(--bt-line-2);border-radius:14px;background:var(--bt-card,var(--bt-main));animation:bt-rise .18s ease-out} +.bt-chat-strip-who{flex:1 1 auto;min-width:0;display:flex;flex-direction:column;gap:2px} +.bt-chat-strip-name{font-size:15px;line-height:20px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.bt-chat-strip-sub{font-size:12px;line-height:16px;color:var(--bt-ink-3);overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.bt-chat-strip-empty{font-size:12px;line-height:16px;color:var(--bt-ink-3);overflow-wrap:anywhere} +.bt-chat-strip-go{flex:none} +@media (prefers-reduced-motion:reduce){.bt-chat-strip{animation:none}} .bt-flybot-session-dock [data-flybot=chat-approvals]{align-items:flex-end;padding-bottom:4px} .bt-flybot-session-dock .fs-hold{margin-left:auto} .bt-flybot-approval-files { margin: 0 0 8px; padding-left: 18px; font-size: 12px; } diff --git a/core/src/client/i18n.js b/core/src/client/i18n.js index f351948e9..cf75d7cc5 100644 --- a/core/src/client/i18n.js +++ b/core/src/client/i18n.js @@ -436,6 +436,16 @@ const ZH = { 'Close details': '关闭详情', 'Copied conversation': '已复制对话', 'Share conversation': '分享对话', + 'Copy conversation': '复制对话', + 'Nothing to copy yet': '还没有可复制的内容', + 'Conversation copied as Markdown': '对话已复制为 Markdown', + 'Could not copy the conversation': '无法复制对话', + 'No messages yet. Say something to {name}.': '还没有消息。给 {name} 发点什么吧。', + 'This model does not support that request. Pick another model in the Bot\'s settings.': '该模型不支持这项请求。请在 Bot 设置里换一个模型。', + 'This model does not support {feature}. Pick another model in the Bot\'s settings.': '该模型不支持 {feature}。请在 Bot 设置里换一个模型。', + 'The provider does not know this model. Check the model name in the Bot\'s settings.': '服务方不认识这个模型。请在 Bot 设置里检查模型名称。', + 'The conversation is longer than this model can read. Condense it, or pick a model with a larger window.': '这段对话超出了该模型能读的长度。请压缩对话,或换一个上下文更大的模型。', + 'The model service refused the request. Check the model and the endpoint in the Bot\'s settings.': '模型服务拒绝了这次请求。请在 Bot 设置里检查模型和接口地址。', 'Copy conversation as Markdown': '把对话复制为 Markdown', 'This conversation is not part of your Bot team.': '这个对话不属于你的 Bot 团队。', 'Admin {name}': '管理员 {name}', diff --git a/core/src/client/inbox.js b/core/src/client/inbox.js index bd9684f7a..0febe9a34 100644 --- a/core/src/client/inbox.js +++ b/core/src/client/inbox.js @@ -16,6 +16,7 @@ import { useEscape } from './overlay-hooks.js' import { alwaysChoices } from './member-status.js' import { currentPart, previewOf, relativeShort } from './text.js' import { hostText, t } from './i18n.js' +import { failureSentence } from '../shared/failure-copy.js' // Done (and a failed task) ages out after 7 days. export const KEEP_MS = 7 * 86_400_000 @@ -461,7 +462,7 @@ export function InboxCard({ card, roster, now, busy, error, onAnswer, onOpen }) h('span', { className: 'bt-inbox-meta', title: meta }, meta)), h('span', { className: 'bt-inbox-title', title: card.title }, card.title), card.calls > 0 ? h('span', { className: 'bt-inbox-calls' }, t('{count} tool calls', { count: card.calls })) : null, - card.reason ? h('span', { className: 'bt-inbox-reason', title: card.reason }, card.reason) : null, + card.reason ? h('span', { className: 'bt-inbox-reason', title: card.reason }, failureSentence(card.reason, t)) : null, card.activity ? h('span', { className: 'bt-inbox-activity', title: card.activity }, t('Latest: {text}', { text: card.activity })) : null, card.hint ? h('span', { className: 'bt-inbox-hint' }, card.hint) : null, evidence.length > 0 ? h('span', { className: 'bt-inbox-evidence' }, ...evidence.map((href, index) => h('a', { diff --git a/core/src/client/model-test.js b/core/src/client/model-test.js index 8f0bece99..3b66e136a 100644 --- a/core/src/client/model-test.js +++ b/core/src/client/model-test.js @@ -62,4 +62,12 @@ export const MODEL_TEST_CSS = ` .bt-model-test-result[data-ok=true]{color:#1a7f37} .bt-model-test-result[data-ok=false]{color:#c21d2e} .bt-model-test-code{margin-left:6px;font-size:11px;color:var(--bt-ink-3)} +/* #1347: the hint beside "Test connection" is prose and wraps; the button next to it keeps its own + words ("Test connection" on one line) and takes the room it needs. */ +.bt-model-test>.bt-set-copy{flex:1 1 12ch;min-width:12ch} +.bt-model-test>.bt-soft{flex:none;white-space:nowrap} +@container bt-panel (max-width:300px){ + .bt-model-test{flex-wrap:wrap;row-gap:6px} + .bt-model-test>.bt-set-copy{flex:1 1 100%} +} ` diff --git a/core/src/client/pane-width.js b/core/src/client/pane-width.js index 01f124fa9..e1a75b870 100644 --- a/core/src/client/pane-width.js +++ b/core/src/client/pane-width.js @@ -4,7 +4,11 @@ // DETAILS_WIDTH..DETAILS_MAX, so a wider browser comes back to DETAILS_MAX and a narrower one keeps // its width; the browser takes any width from MIN_WIDTH. export const DEFAULT_WIDTH = 520 -export const DETAILS_WIDTH = 320 +// #1347: 320 was too narrow for the settings form the details view hosts. A label and its control +// share one row ("Name" beside the name field, "Test connection" beside its hint), and at 320 the +// label took 36px — "Nam" on one line and "e" on the next — while the button broke into +// "Test connecti" / "on". 380 keeps every such row on one line at the default text size. +export const DETAILS_WIDTH = 380 export const DETAILS_MAX = 520 export const MIN_WIDTH = 360 // The conversation keeps this much room beside the panel whenever the window can hold it; below @@ -13,6 +17,8 @@ export const MIN_CHAT = 420 // A narrow window shrinks the Details panel before it starves the conversation (#705): below // MIN_CHAT + DETAILS_WIDTH the panel gives way down to MIN_DETAILS, so the row fits the window and // no pane is pushed past its own edge (the conversation may then be narrower than MIN_CHAT). +// Below MIN_DETAILS the settings row itself stops wrapping: it stacks (see `.bt-bs-row` in +// styles.js), because a 240px column cannot hold a label and a 96px field on one line. export const MIN_DETAILS = 240 export function panelWidthRange(view, viewport) { diff --git a/core/src/client/remote-bot-settings.js b/core/src/client/remote-bot-settings.js index 6434bc20f..aaab11d8d 100644 --- a/core/src/client/remote-bot-settings.js +++ b/core/src/client/remote-bot-settings.js @@ -6,7 +6,7 @@ import { FlyBotDetails } from './flybot-slots.js' import { t } from './i18n.js' export const REMOTE_BOT_SETTINGS_CSS = ` -.bt-remote-sheet-layer{position:fixed;inset:0;z-index:60;display:flex;align-items:flex-end;justify-content:center;padding:16px;pointer-events:none} +.bt-remote-sheet-layer{position:fixed;inset:0;z-index:120;isolation:isolate;display:flex;align-items:flex-end;justify-content:center;padding:16px;pointer-events:none} .bt-remote-sheet-layer[data-leaving]{pointer-events:none} .bt-remote-sheet-layer .bt-scrim{position:absolute;inset:0;z-index:0;pointer-events:auto} .bt-remote-sheet-layer[data-leaving] .bt-scrim{opacity:0;transition:opacity .16s ease-in} diff --git a/core/src/client/sidebar.js b/core/src/client/sidebar.js index 9f4f099d5..6f7446567 100644 --- a/core/src/client/sidebar.js +++ b/core/src/client/sidebar.js @@ -12,6 +12,7 @@ import { Shimmer, workPhrase } from './cells.js' import { springEasing, SPRING_SHAPE } from './motion.js' import { hostText, t } from './i18n.js' import { friendBotTitle, roomTitle, sharedRoomTitle, isShownRemoteChat } from '../shared/remote-label.js' +import { visibleText } from '../shared/preview.js' import { showsDshRow } from './dsh-offer.js' import { InboxRow } from './inbox.js' import { KanbanRow } from './kanban.js' @@ -149,7 +150,9 @@ function FaceMark() { function RoomRow({ room, roster, actions, useSessions, useSessionStatus, useSessionRetainInfo, useWork, wide, marked }) { const id = room.id const fallback = useSessions(list => nameMentions(previewOf(list, id), roster)) - const preview = nameMentions(room.preview?.replace(/\s+/g, ' ').trim(), roster) || fallback + // The host's line is the same kind of stored text as a Bot chat's, so the row reads it the same way: + // a fence or a marker fragment must never reach the row (#1347). + const preview = nameMentions(visibleText(room.preview), roster) || fallback const active = useSessionRetainInfo(id, info => (info?.retainedBy?.mainView ?? 0) > 0) const unread = useSessionStatus(map => map.get(id)?.completionUnread === true) || (marked && !active) useMarkedUnread(id, active, marked, actions) @@ -745,7 +748,7 @@ export const SIDEBAR_CSS = ` .bt-update[data-phase=installing] .bt-cloud-arrow{animation:bt-cloud-drop 1.1s cubic-bezier(.45,0,.55,1) infinite} @keyframes bt-cloud-drop{0%{transform:translateY(-3px);opacity:0}35%{opacity:1}70%{transform:translateY(1px);opacity:1}100%{transform:translateY(2px);opacity:0}} .bt-update-dot{position:absolute;top:6px;right:6px;width:7px;height:7px;border-radius:50%;background:#E5484D;box-shadow:0 0 0 1.5px var(--bt-sidebar)} -.bt-update-pop{position:fixed;z-index:60;width:280px;padding:14px 14px 12px;border-radius:14px;background:var(--bt-main);border:.5px solid var(--bt-line-2);box-shadow:0 10px 20px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1);color:var(--bt-ink);box-sizing:border-box;transform-origin:top left;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;animation:bt-update-pop ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing}} +.bt-update-pop{position:fixed;z-index:130;width:280px;padding:14px 14px 12px;border-radius:14px;background:var(--bt-main);border:.5px solid var(--bt-line-2);box-shadow:0 10px 20px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1);color:var(--bt-ink);box-sizing:border-box;transform-origin:top left;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;animation:bt-update-pop ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing}} @keyframes bt-update-pop{from{opacity:0;transform:scale(.96);filter:blur(4px)}} .bt-update-title{font-size:14px;line-height:20px;font-weight:600} .bt-update-text{margin-top:2px;font-size:12.5px;line-height:18px;color:var(--bt-ink-2)} diff --git a/core/src/client/styles.js b/core/src/client/styles.js index e0f2a88e5..f28195004 100644 --- a/core/src/client/styles.js +++ b/core/src/client/styles.js @@ -362,11 +362,11 @@ button.bt-q-opt:active{background:var(--bt-active)} .bt-soft:hover{background:var(--bt-active)} .bt-time{display:block;text-align:center;font-size:12px;line-height:16px;color:var(--bt-ink-2);padding:6px 0} -.bt-panel{position:fixed;top:var(--dsh-frame-chrome-top,0px);right:0;bottom:0;width:var(--bt-panel-w,320px);pointer-events:auto;z-index:40;background:var(--bt-main);border-left:.5px solid var(--bt-line-2);display:flex;flex-direction:column;color:var(--bt-ink);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;box-sizing:border-box;animation:bt-pane-in .3s cubic-bezier(.2,0,0,1);transition:width ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing}} +.bt-panel{position:fixed;top:var(--dsh-frame-chrome-top,0px);right:0;bottom:0;width:var(--bt-panel-w,320px);pointer-events:auto;z-index:40;background:var(--bt-main);border-left:.5px solid var(--bt-line-2);display:flex;flex-direction:column;color:var(--bt-ink);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;box-sizing:border-box;animation:bt-pane-in .3s cubic-bezier(.2,0,0,1);transition:width ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing};container:bt-panel/inline-size} .bt-panel[data-leaving]{transform:translateX(100%);transition:transform .3s cubic-bezier(.2,0,0,1);pointer-events:none} .bt-panel[data-instant]{transition:none} @keyframes bt-pane-in{from{transform:translateX(100%)}} -[class*="_centerCol"]{transition:margin-right ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing}} +[class*="_centerCol"]{transition:margin-right ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing};position:relative;z-index:0;isolation:isolate} .bt-panel-grip{position:absolute;left:-5px;top:0;bottom:0;width:8px;cursor:col-resize;z-index:3;touch-action:none} .bt-panel-grip:hover,.bt-panel[data-dragging] .bt-panel-grip{background:linear-gradient(to right,transparent 4px,var(--bt-line-2) 4px,var(--bt-line-2) 6px,transparent 6px)} .bt-drag-shield{position:fixed;inset:0;z-index:45;cursor:col-resize} @@ -411,7 +411,7 @@ button.bt-q-opt:active{background:var(--bt-active)} .bt-avatar-anchor{position:relative} .bt-avatar-trigger{position:relative;width:64px;height:64px;border:0;border-radius:50%;background:none;cursor:pointer;padding:0} .bt-avatar-pencil{position:absolute;right:-2px;bottom:-2px;width:22px;height:22px;border-radius:50%;background:var(--bt-main);border:1px solid var(--bt-line-2);color:var(--bt-ink-2);display:flex;align-items:center;justify-content:center;box-shadow:0 1px 4px rgba(0,0,0,.12)} -.bt-avedit{position:absolute;top:calc(100% + 8px);left:50%;transform:translateX(-50%);width:300px;background:var(--bt-main);border:1px solid var(--bt-line);border-radius:12px;box-shadow:0 12px 40px rgba(0,0,0,.18);padding:10px;z-index:70;display:flex;flex-direction:column;gap:10px;color:var(--bt-ink);transform-origin:top center;animation:bt-pop-drop ${SPRING_TAP.duration}ms ${SPRING_TAP.easing},bt-veil-in .16s ease-out} +.bt-avedit{position:absolute;top:calc(100% + 8px);left:50%;transform:translateX(-50%);width:300px;background:var(--bt-main);border:1px solid var(--bt-line);border-radius:12px;box-shadow:0 12px 40px rgba(0,0,0,.18);padding:10px;z-index:130;display:flex;flex-direction:column;gap:10px;color:var(--bt-ink);transform-origin:top center;animation:bt-pop-drop ${SPRING_TAP.duration}ms ${SPRING_TAP.easing},bt-veil-in .16s ease-out} .bt-bs .bt-avedit{transform-origin:32px 0} .bt-avedit-top{display:flex;align-items:center;justify-content:space-between} .bt-avedit-tabs{position:relative;display:flex;gap:2px;background:var(--bt-hover);border-radius:999px;padding:2px} @@ -451,9 +451,23 @@ button.bt-q-opt:active{background:var(--bt-active)} .bt-seg button.bt-perm-danger,.bt-seg button.bt-perm-danger[aria-pressed=true]{color:#e02135} /* Settings dialog: a 198px page list beside the page. */ -.bt-settings-layer{position:fixed;inset:var(--dsh-frame-chrome-top,0px) 0 0;z-index:60;display:flex;align-items:center;justify-content:center;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif} +/* #1347: dialogs, sheets and scrims sit in a tier of their own, well clear of everything the + chat surface may do. They used to share 60/61 with the context menu, the reaction strip and the + remote-Bot sheet, so any body-level popover opened after them tied on z-index and won on DOM + order — and a chat element with a stray z-index could reach the same number. The tier is now + explicit: chat surface at most 50, dialogs 120/121, popovers 130, palette 140, toast 1000. The + chat column is additionally capped at z-index 0 inside its own stacking context (see the + center-column rule below), so nothing in the thread can outrank these whatever it does. */ +.bt-settings-layer{position:fixed;inset:var(--dsh-frame-chrome-top,0px) 0 0;z-index:120;isolation:isolate;display:flex;align-items:center;justify-content:center;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif} .bt-scrim{position:absolute;inset:0;background:rgba(0,0,0,.32);animation:bt-fade .22s ease-out} body[data-ds-dark-theme] .bt-scrim{background:rgba(0,0,0,.5)} +/* #1347: a modal is a modal. A 0.32 scrim leaves the thread legible behind it, and the owner read that + as the chat painting through the dialog ("assistant text readable across columns", a pill visible at + full opacity behind the Inbox). The scrim now separates the two surfaces outright: the thread is + dimmed to a backdrop, not left readable beside the thing being decided. It is still one layer and + still dismisses on a click, so nothing else about it changes. */ +body:not([data-ds-dark-theme]) .bt-scrim{background:rgba(12,12,14,.62)} +body[data-ds-dark-theme] .bt-scrim{background:rgba(0,0,0,.7)} .bt-settings{--bt-set-w:min(800px,calc(100vw - 48px));--bt-set-h:min(620px,calc(100vh - 2*max(32px,var(--dsh-frame-overlay-top,32px))));position:relative;width:var(--bt-set-w);height:var(--bt-set-h);background:var(--bt-main);border-radius:16px;border:.5px solid var(--bt-line-2);box-shadow:0 24px 80px rgba(0,0,0,.24);display:flex;overflow:hidden;color:var(--bt-ink);transition:${spring(SPRING_SHAPE, 'width', 'height')};animation:bt-dialog-rise ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing},bt-veil-in .2s ease-out} .bt-settings[data-wide]{--bt-set-w:min(1100px,calc(100vw - 48px));--bt-set-h:min(780px,calc(100vh - 2*max(32px,var(--dsh-frame-overlay-top,32px))))} :is(.bt-settings-layer,.bt-mem-layer)[data-leaving]{pointer-events:none} @@ -506,6 +520,10 @@ button.bt-set-row:hover{background:var(--bt-hover)} /* #892: a plugin's rows in the Members card (Invite by link) sit on the card's own padding, like a row. */ .bt-flybot-room-members{padding:10px 12px} .bt-set-copy{flex:1;display:flex;flex-direction:column;gap:2px;min-width:0} +/* #1347: a row's copy never breaks its own label. The label used to be a bare inline span inside a + column that flexed down to 36px, so "Name" came out as "Nam" + "e"; a single-word label is now one + line, and the hint below it is what wraps when the column is short. */ +.bt-set-copy>.bt-bs-label,.bt-set-copy>.bt-set-value{white-space:nowrap;overflow:hidden;text-overflow:ellipsis} .bt-set-hint{font-size:12px;line-height:16px;color:color-mix(in srgb,var(--bt-ink-2) 50%,var(--bt-ink-3))} .bt-set-control{flex:none;display:flex;align-items:center;gap:6px} .bt-set-value{font-size:13px;color:var(--bt-ink-2);text-align:right} @@ -587,7 +605,7 @@ button.bt-set-row:hover{background:var(--bt-hover)} .bt-tool:hover,.bt-tool[aria-expanded=true]{background:var(--bt-hover);color:var(--bt-ink)} .bt-tool:active{background:var(--bt-active)} .bt-tool:focus-visible{outline:2px solid var(--bt-accent);outline-offset:-2px} -.bt-react-strip{position:fixed;z-index:60;pointer-events:auto;display:flex;gap:2px;padding:4px;border-radius:999px;background:var(--bt-main);border:.5px solid var(--bt-line-2);box-shadow:0 10px 20px -3px rgba(0,0,0,.08),0 4px 6px -4px rgba(0,0,0,.06);transform:translate(-50%,-100%);transform-origin:bottom center;animation:bt-pop-lift ${SPRING_TAP.duration}ms ${SPRING_TAP.easing},bt-veil-in .14s ease-out} +.bt-react-strip{position:fixed;z-index:130;pointer-events:auto;display:flex;gap:2px;padding:4px;border-radius:999px;background:var(--bt-main);border:.5px solid var(--bt-line-2);box-shadow:0 10px 20px -3px rgba(0,0,0,.08),0 4px 6px -4px rgba(0,0,0,.06);transform:translate(-50%,-100%);transform-origin:bottom center;animation:bt-pop-lift ${SPRING_TAP.duration}ms ${SPRING_TAP.easing},bt-veil-in .14s ease-out} .bt-react-strip button{width:32px;height:32px;border:0;border-radius:50%;background:none;font-size:18px;line-height:1;cursor:pointer;transition:background-color .12s ease,${spring(SPRING_POP, 'transform')};animation:bt-check-pop ${SPRING_POP.duration}ms ${SPRING_POP.easing} backwards} .bt-react-strip button:nth-child(2){animation-delay:20ms} .bt-react-strip button:nth-child(3){animation-delay:40ms} @@ -606,7 +624,7 @@ button:hover .bt-you{background:color-mix(in srgb,var(--bt-ink) 9%,var(--bt-side .bt-you-wrap{display:inline-flex;padding:0} .bt-connect-foot{width:100%;height:36px} -.bt-menu{position:fixed;pointer-events:auto;z-index:60;min-width:200px;padding:6px;border-radius:12px;background:var(--bt-main);border:.5px solid var(--bt-line-2);box-shadow:0 10px 20px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1),0 0 0 1px rgba(228,228,228,.04);display:flex;flex-direction:column;gap:2px;transform-origin:var(--bt-origin,top left);animation:bt-pop-drop ${SPRING_TAP.duration}ms ${SPRING_TAP.easing},bt-veil-in .14s ease-out;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;box-sizing:border-box} +.bt-menu{position:fixed;pointer-events:auto;z-index:130;min-width:200px;padding:6px;border-radius:12px;background:var(--bt-main);border:.5px solid var(--bt-line-2);box-shadow:0 10px 20px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1),0 0 0 1px rgba(228,228,228,.04);display:flex;flex-direction:column;gap:2px;transform-origin:var(--bt-origin,top left);animation:bt-pop-drop ${SPRING_TAP.duration}ms ${SPRING_TAP.easing},bt-veil-in .14s ease-out;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;box-sizing:border-box} .bt-menu button{display:flex;align-items:center;gap:4px;width:100%;min-height:30px;text-align:left;border:0;background:none;border-radius:6px;padding:6px 8px;font:inherit;font-size:13px;line-height:18px;color:var(--bt-ink);cursor:pointer;box-sizing:border-box} .bt-menu button:hover{background:var(--bt-hover)} .bt-menu button svg{flex:none;width:16px;height:16px;margin:0 1px} @@ -616,7 +634,7 @@ button:hover .bt-you{background:color-mix(in srgb,var(--bt-ink) 9%,var(--bt-side .bt-menu[data-leaving]{pointer-events:none;animation:bt-pop-out .12s cubic-bezier(.4,0,1,1) forwards} .bt-menu-label{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .bt-menu-hint{flex:none;color:var(--bt-ink-3);font-size:12px;font-variant-numeric:tabular-nums} -.bt-palette-layer{position:fixed;inset:var(--dsh-frame-chrome-top,0px) 0 0;z-index:70;pointer-events:auto;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif} +.bt-palette-layer{position:fixed;inset:var(--dsh-frame-chrome-top,0px) 0 0;z-index:140;isolation:isolate;pointer-events:auto;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif} .bt-backdrop{position:absolute;inset:0;background:rgba(0,0,0,.46);animation:bt-fade .22s ease-out} .bt-palette{position:absolute;top:50%;left:50%;width:min(560px,92vw);transform:translate(-50%,-50%);display:flex;flex-direction:column;border-radius:12px;background:var(--bt-main);border:1px solid var(--bt-line-2);box-shadow:0 24px 48px -12px rgba(0,0,0,.25);overflow:hidden;color:var(--bt-ink);animation:bt-palette-in ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing},bt-veil-in .2s ease-out} .bt-palette-layer[data-leaving]{pointer-events:none} @@ -721,7 +739,7 @@ body[data-ds-dark-theme] .bt-theme-chip i{background:var(--da)} .bt-mem-row>svg{flex:none;color:var(--bt-ink-2)} .bt-mem-row-copy{flex:1;text-align:left} .bt-mem-row-go{display:inline-flex;align-items:center;gap:2px;color:var(--bt-ink-3);font-size:12px} -.bt-mem-layer{position:fixed;inset:var(--dsh-frame-chrome-top,0px) 0 0;z-index:61;display:flex;align-items:center;justify-content:center;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif} +.bt-mem-layer{position:fixed;inset:var(--dsh-frame-chrome-top,0px) 0 0;z-index:121;isolation:isolate;display:flex;align-items:center;justify-content:center;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif} .bt-mem-scrim{animation:bt-fade .2s ease-out} .bt-mem{position:relative;width:min(680px,calc(100vw - 48px));height:min(760px,calc(100vh - 2*max(32px,var(--dsh-frame-overlay-top,32px))));background:var(--bt-main);border-radius:20px;border:.5px solid var(--bt-line-2);box-shadow:0 24px 80px rgba(0,0,0,.24);display:flex;flex-direction:column;overflow:hidden;color:var(--bt-ink);animation:bt-dialog-rise ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing},bt-veil-in .2s ease-out} .bt-mem-head{position:relative;display:flex;align-items:center;gap:8px;height:60px;flex:none;padding:0 14px 0 24px;border-bottom:.5px solid var(--bt-line);box-sizing:border-box} @@ -869,11 +887,31 @@ export const CONVERSATION_CSS = ` [class*="_header"] [class*="switcherTitle"]{white-space:normal;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;line-clamp:2;overflow:hidden;text-overflow:ellipsis} [class*="_header"] [class*="switcherTrigger"]{height:auto;align-items:flex-start} [class*="_header"] .bt-head .bt-mark{width:22px!important;height:22px!important;--bt-size:22px} +/* #1347: "Standard mode" beside the title reads as a control — a filled chip with an icon — but the + shell draws it as a span with no click handler (dsh-client-ui-agent-preset, AgentPresetLabel): + clicking it does nothing, and the only thing behind it is a hover title. In a Bot chat the mode is + fixed for the whole Session anyway, so the chip becomes what it is: a status word on the title's + line. No fill, no radius, muted ink, and the description stays on hover. */ +[data-slot="conversation.session.header.actions"] [class*="_label"]{background:none;border-radius:0;color:var(--bt-ink-3);cursor:help;pointer-events:auto} +[data-slot="conversation.session.header.actions"] [class*="_label"] svg{opacity:.7} [class*="_body"]:has(> [class*="_scrollBody"]){--dsh-chat-content-width:min(960px,100%)} /* DSH's turn tail keeps what plugins put in it (delivered files, the Bot's separators), without its copy and branch row; a tail with nothing in it takes no room. */ [data-turn-tail]>:not([data-slot="conversation.chat.turnTail"]):not(.bt-typing){display:none!important} [class*="_flowItem"][data-chat-flow-kind="turn-tail"]:not(:has([data-slot="conversation.chat.turnTail"]:not(:empty))):not(:has([data-turn-tail]>.bt-typing)){display:none!important} +/* #1347: the thread must never paint above a dialog, a sheet or a scrim. + The chat column had no stacking context of its own (position:static, z-index:auto), so nothing + inside it was boxed in: anything the browser promoted to its own composited layer — an entrance + animation (bt-row-in animates a transform), a filter, a will-change, a scroll container's sticky + descendant — was ordered against the ROOT stacking context instead of against the column. A + dialog's layer is a z-index box at the root, so a promoted bubble could sort above it, which is + exactly what the owner's screenshots show: a right-aligned pill at full opacity across the Tasks + dialog and over the Inbox scrim, while the same pill sat under it in a plain DOM walk. Giving the + column its own stacking context (isolation:isolate) paints every descendant inside it, and its + z-index:0 keeps all of that below .bt-pane (45), .bt-panel (40) and the dialog layers at 120/121 + whatever any single bubble does. contain:paint is deliberately NOT used: it would clip the + popovers the thread legitimately renders (reaction strip, message menu), which portal to body + for exactly that reason. */ /* Every team Session belongs to a Bot or a group, so a blank one is an empty chat, not the shell's new-session hero: no headline, no workspace or mode picker, and the composer stays docked at the bottom. */ diff --git a/core/src/client/text.js b/core/src/client/text.js index 17ca7a787..5afbb32aa 100644 --- a/core/src/client/text.js +++ b/core/src/client/text.js @@ -3,6 +3,7 @@ import { dateLocale, t } from './i18n.js' import { mentionSpans } from '../shared/mentions.js' import { remoteMentionLabel } from '../shared/remote-label.js' +import { visibleText } from '../shared/preview.js' export const MENTION_ORIGIN = 'https://bots.local/' @@ -283,8 +284,9 @@ export function previewOf(list, id, hiddenTurns = [], unsentTurns = []) { const entry = outline[index] if (hiddenTurns.includes(entry.turn)) continue const text = (unsentTurns.includes(entry.turn) ? '' : entry.response) || entry.prompt || '' - // The outline flattens a reply to one line, so its trace ends at a space there. - if (text !== '') return stripTrace(stripHeader(text)).replace(/\s+/g, ' ').trim().replace(/^[^\s→]{1,40}(?: → [^\s→]{1,40})+ (?=\S)/, '') + // The outline flattens a reply to one line, so its trace ends at a space there. A stored line can + // still carry its untrusted fence, and a row is one line someone reads: no marker reaches it (#1347). + if (text !== '') return visibleText(stripTrace(stripHeader(text)).replace(/\s+/g, ' ').trim().replace(/^[^\s→]{1,40}(?: → [^\s→]{1,40})+ (?=\S)/, '')) } return '' } diff --git a/core/src/host/channel.js b/core/src/host/channel.js index 3e6255d98..9fe1f0eaa 100644 --- a/core/src/host/channel.js +++ b/core/src/host/channel.js @@ -7,7 +7,7 @@ import { COLORS, EXTRA_COLORS, ROUTE } from './constants.js' import { isFaceRoom } from './room-membership.js' import { readQuietHours } from '../shared/quiet-hours.js' import { faceMentions } from './face-mentions.js' -import { unwrapUntrusted } from './delivery.js' +import { visibleText } from '../shared/preview.js' const NOTIFY_KINDS = ['approval', 'finished', 'failed'] const LANGUAGES = { zh: 'Chinese (简体中文)', en: 'English' } @@ -148,7 +148,9 @@ export function install(rt) { rooms: Object.values(state.rooms).map(({ log, seen, botRounds, lateRounds, sessions, segments, faceLine, ...room }) => { const parts = [...Object.values(sessions ?? {}), ...Object.values(segments ?? {}).flat().map(segment => segment.sessionId)] const last = (log ?? []).filter(line => line.kind === undefined).at(-1) - const previewText = last ? unwrapUntrusted(last.text) : '' + // The row is read by a person: a fence, or the fragment an older write left behind, is never part + // of it (#130, #1347). `visibleText` also folds the line to one line, as a row always was. + const previewText = last ? visibleText(last.text) : '' const previewWho = last && (last.authorKind === 'human' && last.who && last.who !== 'User' ? `${last.who}: ${previewText}` : last.who === 'User' ? previewText : `${last.who}:${previewText}`) const presented = rt.presentRoom?.({ ...room, log }, actor) ?? room const pending = rt.listRoomApprovals?.(presented, actor) ?? [] diff --git a/core/src/shared/failure-copy.js b/core/src/shared/failure-copy.js new file mode 100644 index 000000000..257a17831 --- /dev/null +++ b/core/src/shared/failure-copy.js @@ -0,0 +1,52 @@ +// A failed turn's sentence a person can act on (#1347). +// +// The host keeps what the provider said, cut to a length that fits a card +// (`noteEnded` in host/turns.js). That text is a JSON body more often than not, so a real +// instance showed the Inbox as +// +// 404: {"code":"UnsupportedModel","message":"The requested model does not support … +// +// — cut off before the part that says what to do. The words below are the same fact in the +// reader's language: which setting is wrong, and where to change it. They are keyed on the +// provider's own `code` field, which is stable, not on the message text, which is not. +// +// Anything this does not recognise is returned unchanged: a failure nobody has mapped yet is +// still better shown as it came than replaced with a sentence that guessed. + +// The provider code inside the failure text, from either shape the host may have stored: +// the raw JSON body, or `code: message`. +const providerCode = (text) => { + const json = /"code"\s*:\s*"([A-Za-z0-9_.-]+)"/.exec(text) + if (json) return json[1] + const plain = /^([A-Za-z][A-Za-z0-9_.-]{2,40}):\s/.exec(text) + return plain ? plain[1] : '' +} + +// What is wrong and where to fix it, per provider code. `t` is the caller's translate, so the +// sentence is in the reader's language; the key is English, as everywhere else in the client. +const KNOWN = { + UnsupportedModel: 'This model does not support that request. Pick another model in the Bot\'s settings.', + ModelNotFound: 'The provider does not know this model. Check the model name in the Bot\'s settings.', + ContextLengthExceeded: 'The conversation is longer than this model can read. Condense it, or pick a model with a larger window.', + InvalidRequest: 'The model service refused the request. Check the model and the endpoint in the Bot\'s settings.', + InsufficientQuota: 'The account is out of quota.', + RateLimitExceeded: 'Too many requests right now. Try again in a moment.', +} + +// `raw` is the stored failure text; `translate` turns a key into the reader's language. The fill after +// it is harmless where the translator already substituted, and keeps a caller without a locale service +// (or a test's identity translate) from showing a raw `{feature}`. +const fill = (text, params) => (params === undefined ? text : String(text).replace(/\{(\w+)\}/g, (match, name) => (name in params ? String(params[name]) : match))) + +export function failureSentence(raw, translate = key => key) { + const text = String(raw ?? '').trim() + if (text === '') return text + const key = KNOWN[providerCode(text)] + if (key === undefined) return text + // A refusal that names the feature is worth saying in that word, because the feature is what the + // person has to stop asking for. The wording is the provider's, so it is quoted, not translated. + const feature = /does not support\s+([a-z][a-z0-9 _-]{2,40})/i.exec(text)?.[1]?.trim() + if (key !== KNOWN.UnsupportedModel || feature === undefined) return fill(translate(key), undefined) + const withFeature = 'This model does not support {feature}. Pick another model in the Bot\'s settings.' + return fill(translate(withFeature, { feature }), { feature }) +} diff --git a/core/src/shared/preview.js b/core/src/shared/preview.js new file mode 100644 index 000000000..980e1e426 --- /dev/null +++ b/core/src/shared/preview.js @@ -0,0 +1,42 @@ +// What a preview may show of a stored line (#130 follow-up). +// +// A remote line is fenced for the Bot (`…`), and the +// fence is data: a person reading a sidebar row must never see it. `unwrapUntrusted` handles the one +// shape this code writes today, a complete wrap. A stored line can also carry a *broken* fence — an +// older write, a hand-edited log, a nested fragment — and then the wrapper leaks into the preview as +// `from="User"> B踢后测4 >`, which is what a real instance showed in its sidebar (#1347). +// +// So a preview is read the same way whatever shape the fence is in: unwrap the complete wraps, then +// remove whatever marker fragments are left, until neither changes the text. Words that never were +// part of a marker are kept, because a preview is still the line someone said. +const FENCE_TAG = /<\s*\/?\s*untrusted-(?:message|file)\b[^<>]*>/gi +// What is left when the tag name alone was stripped by an older pass: ` from="User">`. +const FENCE_ATTR = /\bfrom\s*=\s*(?:"[^"]*"|'[^']*')\s*>/gi +// A lone opener/closer with its attributes cut short, e.g. `` minus `>`. +const FENCE_TAIL = /<[^<>]*\buntrusted-(?:message|file)\b[^<>]*$/i +// Only the marker word is left, its brackets already gone: `untrusted-message>` / `/untrusted-file`. +const FENCE_BARE = /(?:\/\s*)?untrusted-(?:message|file)\b\s*>?/gi + +export function stripFence(text) { + let out = String(text ?? '') + for (let previous; previous !== out;) { + previous = out + out = out.replace(FENCE_TAG, '').replace(FENCE_ATTR, '') + } + return out.replace(FENCE_TAIL, '').replace(FENCE_BARE, '') +} + +// The words of a stored line as a preview shows them: inner text of a wrap, no marker of any shape, +// and one line, because a row is one line. +export function visibleText(text) { + let out = String(text ?? '') + for (let previous; previous !== out;) { + previous = out + const open = out.indexOf('') + const inner = close > open ? out.slice(out.indexOf('>', open) + 1, close) : out + out = stripFence(inner) + } + return stripFence(out).replace(/\s+/g, ' ').trim() +} diff --git a/core/src/shared/turn-failures.js b/core/src/shared/turn-failures.js index 7ae5d2696..87ed6b1a0 100644 --- a/core/src/shared/turn-failures.js +++ b/core/src/shared/turn-failures.js @@ -1,6 +1,7 @@ // What a chat says about turns that died before writing a word (#1044). The host marks those `ended` // entries `silent` and counts the streak at the same place; this turns the snapshot the client already // has into the lines a chat draws. Kept free of React so both the bundle and the tests can read it. +import { failureSentence } from './failure-copy.js' export const FAILURE_LINES_SHOWN = 3 @@ -11,7 +12,9 @@ export const failureLines = (snapshot, place, t, limit = FAILURE_LINES_SHOWN) => .slice(-limit) .map(entry => ({ id: `turn-failure-${entry.id}`, - text: t('Could not run: {error}', { error: entry.failed ?? t('the model call failed') }), + // The stored text can be a provider's JSON body cut mid-sentence; `failureSentence` says the + // same fact in words that name what to change (#1347). + text: t('Could not run: {error}', { error: entry.failed === undefined ? t('the model call failed') : failureSentence(entry.failed, t) }), ...(Number(entry.streak) > 1 ? { streak: Number(entry.streak) } : {}), })) } diff --git a/core/test/narrow-pane.test.js b/core/test/narrow-pane.test.js index 6ed3df228..282a1709a 100644 --- a/core/test/narrow-pane.test.js +++ b/core/test/narrow-pane.test.js @@ -1,7 +1,7 @@ import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import { test } from 'node:test' -import { MIN_DETAILS, panelWidthRange } from '../src/client/pane-width.js' +import { MIN_DETAILS, DETAILS_WIDTH, panelWidthRange } from '../src/client/pane-width.js' import { PANE_MIN_WIDTH_CSS } from '../src/client/panes.js' // Narrow multi-pane windows (#705): the Details panel's width is reserved as a margin on the @@ -14,9 +14,13 @@ const rule = (selector) => { test('#705: a narrow window shrinks the Details panel before it starves the conversation', () => { assert.equal(MIN_DETAILS, 240) - // Room to spare: the panel keeps its own 320..520 range. - assert.deepEqual(panelWidthRange('details', 1440), [320, 520]) - assert.deepEqual(panelWidthRange('details', 800), [320, 380]) + // #1347: the Details panel opens at 380, not 320. A settings row holds a label and its control on + // one line ("Name" beside its field, "Test connection" beside its hint); at 320 the label took 36px + // and broke into "Nam" + "e", and the button into "Test connecti" + "on". + assert.equal(DETAILS_WIDTH, 380) + // Room to spare: the panel keeps its own 380..520 range. + assert.deepEqual(panelWidthRange('details', 1440), [380, 520]) + assert.deepEqual(panelWidthRange('details', 800), [380, 380]) // 620px: only 200px would be left for the conversation, so the panel goes down to 240. assert.deepEqual(panelWidthRange('details', 620), [240, 240]) assert.deepEqual(panelWidthRange('details', 500), [240, 240]) diff --git a/core/test/overlay-approvals.test.js b/core/test/overlay-approvals.test.js index d612c824f..278b7fc6c 100644 --- a/core/test/overlay-approvals.test.js +++ b/core/test/overlay-approvals.test.js @@ -190,6 +190,42 @@ test('#998: the session dock tells a slot whether the chat is still blank', asyn } finally { off() } }) +// #1347: a Bot chat's first paint has no header either (`headerBlank`), so the column under it is a +// blank box with a composer. The dock draws its own strip on exactly the same fact a group's does — +// and never for a friend's Bot chat, which is not this home's and has its own hint, nor beside a header +// the shell has already drawn. The strip's own text is asserted through its component (`BotChatStrip`), +// because this renderer does not resolve function components. +test('#1347: a blank Bot chat draws its own strip, and only while it is blank', async () => { + const { FlyBotSessionDock, BotChatStrip, registry } = await import('../src/client/flybot-slots.js') + const off = registry().register('flybot.session.dock', { id: 'probe-strip', component: () => null }) + const strips = node => { + if (!node || typeof node !== 'object') return [] + if (typeof node.type === 'function') return node.type.name === 'BotChatStrip' ? [node] : [] + return (node.children ?? []).flatMap(strips) + } + try { + const owner = { id: 'bot-1', name: 'Helper', role: 'Researcher', ownerId: 'me' } + const renderWith = (session, bot = owner) => { + const inst = { hooks: {}, effects: [], rendered: false, props: { sessionId: 'bot-1', useRoster: pick => pick({ byId: { 'bot-1': bot }, roomsById: {} }), session, actions: {} } } + return strips(render(FlyBotSessionDock, inst)) + } + // Nothing said, nothing sent: the shell drew no header, so the strip stands in. + const shown = renderWith({ blank: true }) + assert.equal(shown.length, 1, 'one strip while the chat is blank') + assert.equal(shown[0].props.bot.name, 'Helper', 'it is drawn for this chat\'s Bot') + // From the first send on the shell draws its own header; a second one would be two headers. + assert.equal(renderWith({ blank: true, promptAttempted: true }).length, 0) + assert.equal(renderWith({ blank: false }).length, 0) + assert.equal(renderWith(undefined).length, 0) + // A friend's Bot is its owner's chat, not this home's: it has its own empty hint (#173), not this strip. + assert.equal(renderWith({ blank: true }, { ...owner, remote: { handle: '@helper.bob.home' } }).length, 0) + // What the strip says: the name, and why the column is empty. + const text = JSON.stringify(render(BotChatStrip, { hooks: {}, effects: [], rendered: false, props: { bot: owner, roster: { byId: {}, roomsById: {} }, actions: {}, sessionId: 'bot-1' } })) + assert.match(text, /bt-chat-strip-name/, 'it names the chat') + assert.match(text, /No messages yet/, 'and says why the column is empty') + } finally { off() } +}) + // #968: the approval card in the dock is the second surface that used to ignore the flag. A terminal failure // draws no Retry; the retryable one keeps it. test('#968: a terminal failed approval draws no Retry in the dock card', async () => { diff --git a/core/test/ui-clickthrough.test.js b/core/test/ui-clickthrough.test.js new file mode 100644 index 000000000..dc164c0b9 --- /dev/null +++ b/core/test/ui-clickthrough.test.js @@ -0,0 +1,201 @@ +// #1347: a real mouse click-through of a running FlyBot found UI defects that no test covered. +// Each one is a shape of the shipped page, so each test reads the CSS or the source that draws it and +// asserts the property that was wrong — not the fix by name. Nothing here imports a client module: +// those need React from a DSH install, and the neighbours (`ui-polish-406.test.js`) read the text. +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { test } from 'node:test' +import { visibleText } from '../src/shared/preview.js' +import { failureSentence } from '../src/shared/failure-copy.js' +import { failureLines } from '../src/shared/turn-failures.js' + +const read = name => readFileSync(new URL(`../src/client/${name}`, import.meta.url), 'utf8').replace(/\r\n/g, '\n') +// One rule body out of a CSS template literal, matched on its selector. +const rule = (css, selector) => { + const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + return css.match(new RegExp(`(?:^|\\n)${escaped}\\{([^}]*)\\}`))?.[1] ?? '' +} +// The whole declaration line. `[^}]*` above stops at the `}` inside an interpolated +// `${SPRING_SHAPE.easing}`, so a rule that carries one has to be read as a line. +const ruleLine = (css, selector) => css.split('\n').find(line => line.startsWith(`${selector}{`)) ?? '' +const block = (css, opener) => css.match(new RegExp(`${opener.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\{([\\s\\S]*?)\\n\\}`))?.[1] ?? '' + +test('#1347: a settings row never breaks its own label or its control words', () => { + const css = read('bot-settings.js') + const label = rule(css, '.bt-bs-row>.bt-set-copy>.bt-bs-label') + assert.match(label, /white-space:nowrap/, 'the label is one line, so "Name" cannot become "Nam" + "e"') + assert.match(label, /text-overflow:ellipsis/, 'and a label too long to fit ends in an ellipsis, never mid-word') + // A hint is a sentence, not a label: it wraps and is never cut, or the settings become unreadable. + assert.doesNotMatch(css, /\.bt-set-hint\{[^}]*text-overflow:ellipsis/) + const control = rule(css, '.bt-bs-row .bt-soft,.bt-bs-row .bt-mini') + assert.match(control, /white-space:nowrap/, '"Test connection" and "Change" stay whole') + assert.match(control, /flex:none/, 'a control is not squeezed below its own words') + // The copy is what gives way, but never below a few characters of its own. + assert.match(rule(css, '.bt-bs-row>.bt-set-copy'), /flex:1 1 8ch/) +}) + +test('#1347: the panel is a container, and below 300px the row stacks instead of clipping', () => { + // A container query only fires where one is declared; without the declaration the rule is dead CSS + // that reads green and fixes nothing. + assert.match(ruleLine(read('styles.js'), '.bt-panel'), /container:bt-panel\/inline-size/) + const stacked = block(read('bot-settings.js'), '@container bt-panel (max-width:300px)') + assert.match(stacked, /\.bt-bs-row\{flex-wrap:wrap/, 'the row stacks at the panel floor') + assert.match(stacked, /\.bt-bs-row>\.bt-set-copy\{flex:1 1 100%/, 'the copy keeps the whole width') + assert.match(stacked, /\.bt-bs-input\{[^}]*text-align:left/, 'a stacked field is left-aligned, like any form field') + // The connection test's own button is covered too. + assert.match(rule(read('model-test.js'), '.bt-model-test>.bt-soft'), /white-space:nowrap/) +}) + +test('#1347: a preview shows the words of a line, never its fence', () => { + assert.equal(visibleText('\nhello there\n'), 'hello there') + // The shape a real instance showed in its sidebar: the opener arrived cut, so only its tail survived. + assert.equal(visibleText('kick from="User"> B test 4 >'), 'kick B test 4 >') + // A nested fragment cannot rebuild a marker the strip already removed. + assert.equal(visibleText('message> after'), 'after') + assert.equal(visibleText('untrusted-message> x'), 'x') + // Plain words stay exactly as said, folded to the one line a row is. + assert.equal(visibleText('a\nb c'), 'a b c') + assert.equal(visibleText(''), '') + assert.equal(visibleText(undefined), '') +}) + +test('#1347: both sidebar row kinds read their preview through that helper', () => { + const text = read('text.js') + const preview = text.slice(text.indexOf('export function previewOf'), text.indexOf('const formatTokens')) + assert.match(preview, /return visibleText\(/, 'a Bot row preview is sanitized where it is built') + const sidebar = read('sidebar.js') + assert.match(sidebar, /nameMentions\(visibleText\(room\.preview\), roster\)/, "a group row sanitizes the host's preview") + const channel = readFileSync(new URL('../src/host/channel.js', import.meta.url), 'utf8') + assert.match(channel, /const previewText = last \? visibleText\(last\.text\) : ''/, 'the host sends words, not a fence') +}) + +test('#1347: the details drawer copies, says so, and names the button for what it does', () => { + const source = read('details-drawer.js') + // The accessible name matches the behaviour: no "Share" on a button that only copies. + assert.match(source, /'aria-label': copied \? t\('Copied conversation'\) : t\('Copy conversation'\)/) + assert.doesNotMatch(source, /t\('Share conversation'\)/) + // A copy that succeeds is announced; a refused one is reported, never swallowed as success. The write + // goes through the helper that answers what really happened: `navigator.clipboard?.writeText(text)` with + // no clipboard resolves `undefined`, which the old shape read as a successful copy (#1347). + assert.match(source, /void copiedToClipboard\(text\)\.then\(written =>/) + assert.match(source, /if \(!written\) \{ actions\.reportError\?\.\(new Error\(t\('Could not copy the conversation'\)\), 'share'\); return \}/) + assert.match(source, /actions\.notify\?\.\(t\('Conversation copied as Markdown'\)\)/) + assert.match(source, /setCopied\(true\)/) + // Every string it now says has a Chinese entry. + const i18n = read('i18n.js') + for (const key of ['Copy conversation', 'Nothing to copy yet', 'Conversation copied as Markdown', 'Could not copy the conversation']) { + assert.match(i18n, new RegExp(`\\n {2}'${key}': '`), `missing zh entry for ${key}`) + } +}) + +test('#1347: the mode word beside the title does not look like a control', () => { + const chip = rule(read('styles.js'), '[data-slot="conversation.session.header.actions"] [class*="_label"]') + assert.match(chip, /background:none/, 'no fill, so it reads as a status') + assert.match(chip, /border-radius:0/) + // The description stays reachable on hover, which is the only thing behind the chip. + assert.match(chip, /cursor:help/) +}) + +test('#1347: a Bot chat with nothing said yet draws its own header', () => { + const source = read('flybot-slots.js') + // The same `blank` fact #998 uses for a group, and only then: never beside a header the shell drew. + assert.match(source, /const botRoom = blank && bot !== undefined && !bot\.remote \? bot : null/) + assert.match(source, /botRoom \? h\(BotChatStrip,/) + // The strip names the chat and offers the one thing a blank column cannot: its details. + assert.match(source, /'data-flybot': 'bot-chat-strip'/) + assert.match(source, /'bt-chat-strip-name'/) + assert.match(source, /actions\.toggleDetails\(sessionId\)/) + assert.match(rule(source, '.bt-chat-strip'), /display:flex/) +}) + +test('#1347: a blank column says why it is blank, and only while it is blank', () => { + const source = read('flybot-slots.js') + // The sentence lives in the strip that already stands in for the missing header, so it is drawn and + // withdrawn by the same `blank` condition — never beside the shell's own header. + assert.match(source, /'bt-chat-strip-empty'/) + assert.match(source, /t\('No messages yet\. Say something to \{name\}\.', \{ name: bot\.name \}\)/) + assert.match(rule(source, '.bt-chat-strip-empty'), /overflow-wrap:anywhere/) + assert.match(read('i18n.js'), /'No messages yet\. Say something to \{name\}\.'/) +}) + +test('#1347: a provider refusal reads as a sentence that says what to change', () => { + // The exact text a real Inbox showed, cut off where the host stops storing it. + const raw = '404: {"code":"UnsupportedModel","message":"The requested model does not support the coding plan feature. Please use a model that supports it' + const said = failureSentence(raw, key => key) + assert.doesNotMatch(said, /[{}"]/, 'no JSON body reaches the reader') + assert.doesNotMatch(said, /^404/, 'and no bare status code either') + assert.match(said, /does not support the coding plan feature/, 'the feature is still named') + assert.match(said, /Pick another model in the Bot's settings/, 'and the sentence says where to fix it') + // The same fact without the feature named still gets a sentence. + assert.equal(failureSentence('{"code":"UnsupportedModel","message":"nope"}', key => key), 'This model does not support that request. Pick another model in the Bot\'s settings.') + // Another mapped provider code, and one nobody mapped: unchanged is better than a guess. + assert.match(failureSentence('400: {"code":"ContextLengthExceeded"}', key => key), /longer than this model can read/) + assert.equal(failureSentence('boom: something new', key => key), 'boom: something new') + assert.equal(failureSentence('', key => key), '') + // The chat's own failure lines read through the same helper, so the two surfaces cannot drift. + const lines = failureLines({ ended: [{ id: 'x', sessionId: 's', silent: true, failed: raw }] }, 's', (key, params) => (params ? key.replace(/\{(\w+)\}/g, (m, n) => params[n]) : key)) + assert.equal(lines.length, 1) + assert.match(lines[0].text, /Pick another model/) + // Every sentence above has a Chinese entry. + const i18n = read('i18n.js') + for (const key of ['This model does not support that request. Pick another model in the Bot\\\'s settings.', 'The provider does not know this model. Check the model name in the Bot\\\'s settings.', 'The account is out of quota.', 'Too many requests right now. Try again in a moment.']) { + assert.ok(i18n.includes(`'${key}':`), `missing zh entry for ${key}`) + } +}) + +test('#1347: the thread cannot paint above a dialog, a sheet or a scrim', () => { + const css = read('styles.js') + // The load-bearing half. The chat column had no stacking context of its own, so any descendant the + // browser promoted (an entrance animation's transform, a filter, will-change) was ordered against the + // ROOT stacking context instead of the column, and could sort above a dialog's z-index box. This rule + // is what carries the fix: raising the tiers alone cannot, because a promoted descendant never reads + // the column's z-index at all unless the column is a context. + const column = ruleLine(css, '[class*="_centerCol"]') + assert.match(column, /isolation:isolate/, 'the chat column is its own stacking context') + assert.match(column, /z-index:0/, 'and that whole context sits below the panel (40) and dialogs (120)') + assert.match(column, /position:relative/, 'a z-index needs a positioned box to apply to') + // Not contain:paint — it would clip the reaction strip and message menu the thread portals out. + assert.doesNotMatch(column, /contain:paint/) + + // The second half: an explicit tier, so nothing ties with a dialog and wins on DOM order. Before + // #1347 the context menu and the reaction strip shared z-index 60 with the Settings dialog, so a + // popover opened after it tied and painted over the dialog. + assert.equal(rule(css, '.bt-settings-layer').match(/z-index:(\d+)/)?.[1], '120') + assert.equal(rule(css, '.bt-mem-layer').match(/z-index:(\d+)/)?.[1], '121') + for (const [file, selector] of [['styles.js', '.bt-menu'], ['styles.js', '.bt-react-strip'], ['sidebar.js', '.bt-update-pop'], ['remote-bot-settings.js', '.bt-remote-sheet-layer']]) { + const z = Number(ruleLine(read(file), selector).match(/z-index:(\d+)/)?.[1]) + assert.ok(z >= 120, `${selector} floats above the chat and the panel (was ${z})`) + } + // The palette stays the topmost sheet; the toast stays above everything. + assert.equal(rule(css, '.bt-palette-layer').match(/z-index:(\d+)/)?.[1], '140') + // No dialog or popover may sit in the old ambiguous band any more. + assert.doesNotMatch(css, /z-index:6[01][;}]/) + + // Each overlay layer is its own stacking context too, so the layer as a whole — scrim and dialog + // together — is what sits in the tier, whatever a dialog's own contents do. + for (const [file, selector] of [['styles.js', '.bt-settings-layer'], ['styles.js', '.bt-mem-layer'], ['styles.js', '.bt-palette-layer'], ['remote-bot-settings.js', '.bt-remote-sheet-layer']]) { + assert.match(ruleLine(read(file), selector), /isolation:isolate/, `${selector} is its own stacking context`) + } + + // And the scrim actually separates the two surfaces. A 0.32 scrim left the thread legible behind a + // modal, which read as the chat painting through it; the light theme is now clearly more opaque. + const alpha = sel => { + // The selector can appear more than once (the base rule and #1347's override), so take the + // strongest — that is the one the browser applies last. + const all = [...css.matchAll(new RegExp(`${sel.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\{background:rgba\\(\\s*\\d+\\s*,\\s*\\d+\\s*,\\s*\\d+\\s*,\\s*([\\d.]+)\\s*\\)`, 'g'))] + .map(m => Number(m[1])) + return all.length ? Math.max(...all) : null + } + assert.ok(alpha('body:not([data-ds-dark-theme]) .bt-scrim') >= 0.5, 'the light scrim dims the thread to a backdrop') + assert.ok(alpha('body[data-ds-dark-theme] .bt-scrim') >= 0.6, 'and so does the dark one') +}) + +test('#1347: the built bundle carries these rules, so a page reload shows them', () => { + // The app runs client.js, not src/. A constant that never reached the bundle leaves every fix above + // in the source only. + const bundle = readFileSync(new URL('../client.js', import.meta.url), 'utf8') + assert.ok(bundle.includes('container:bt-panel/inline-size'), 'client.js carries the panel container') + assert.ok(bundle.includes('@container bt-panel (max-width:300px)'), 'client.js carries the stacked row') + assert.ok(bundle.includes('bt-chat-strip-name'), 'client.js carries the blank-chat strip') + assert.ok(bundle.includes('Conversation copied as Markdown'), 'client.js carries the copy confirmation') +}) diff --git a/docs/subsystems/core-client.md b/docs/subsystems/core-client.md index d76e5e143..84c6c39b9 100644 --- a/docs/subsystems/core-client.md +++ b/docs/subsystems/core-client.md @@ -31,13 +31,14 @@ The browser side of the patched `ds-bot` core (`core/`): sources in `core/src/cl | `core/src/client/face-at-filter.js` | A face room's `@` menu without DSH's Sessions rows (#305): `planFaceMenu` (rows to hide, where the highlight goes, when to dismiss), `faceMenuCss`, `watchFaceMenu`. Tests: `core/test/face-at-filter.test.js`. | | `core/test/new-chat-menu.test.js` | **+** menu rows (#283): an action row's description is its own line under the title, an inline Bot hint is capped, so hovering never hides a title. | | `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/pane-width.js` | The side panel's width policy (#705, #462): `DEFAULT_WIDTH` / `DETAILS_WIDTH` / `DETAILS_MAX` / `MIN_WIDTH` for the Bot's browser, `MIN_CHAT` for what the conversation keeps beside the panel, and `panelWidthRange(view, viewport)` — a narrow window shrinks the Details panel down to `MIN_DETAILS` (240) before it starves the conversation, so the row always fits and no pane is pushed past the window edge. Pure numbers, so `core/test/narrow-pane.test.js` needs no React. | +| `core/src/client/pane-width.js` | The side panel's width policy (#705, #462): `DEFAULT_WIDTH` / `DETAILS_WIDTH` / `DETAILS_MAX` / `MIN_WIDTH` for the Bot's browser, `MIN_CHAT` for what the conversation keeps beside the panel, and `panelWidthRange(view, viewport)` — a narrow window shrinks the Details panel down to `MIN_DETAILS` (240) before it starves the conversation, so the row always fits and no pane is pushed past the window edge. `DETAILS_WIDTH` is 380 since #1347: at 320 a settings row's label and its control could not share a line, so "Name" broke into "Nam" + "e" and "Test connection" into "Test connecti" + "on". `browser-pane.js` reads a saved width from `MIN_DETAILS` up, so a width stored under the older 320 policy is kept rather than silently widened. Pure numbers, so `core/test/narrow-pane.test.js` needs no React. | | `core/src/client/panes.js` | `PANE_MIN_WIDTH_CSS` (#705): every pane the multi-pane row flexes gets `min-width:0`, the conversation column is matched by its DSH class fragment, and the panel's long values (About text, `4.1M tok · Cache hit …`) wrap with `overflow-wrap:anywhere`, so a value that cannot wrap never widens the panel past the space reserved for it. The same sheet clips horizontal overflow on the document, the conversation column and the app frame (`overflow-x:clip`, #1086), and `watchShellLevel` / `levelShell` put a leftover `scrollLeft` back, so an error toast or dialog cannot leave the window shifted. Tests: `core/test/shell-level.test.js`. | | `core/src/shared/approval-waiting.js` | The one rule for "this hold still waits for the owner's decision" (#734, #754): `isApprovalWaiting(status)` is true for `pending` and `pending_approval` and false for every settled spelling (`approved`, `auto`, `failed`, `rejected`, `stale`, missing). The host writes `pending`; Bot Social's own outbound hold arrives as `pending_approval`. Host and client both import this one file — the held-reply card, the approvals overlay and the chat bubble on this side, `hooks.js` (`decide()`, `heldOf()`, the cap, the in-flight filter, the cleanup) and `channel.js` (a row's wait badge) on the other — and the overlay asks the host for `[...WAITING_STATUSES, 'failed']`, so the two spellings cannot drift apart again on one side only. Tests: `core/test/approval-waiting.test.js`, `core/test/host-approval-status.test.js`. | | `core/src/shared/held-retry.js` | The one rule for "this failure can still be retried" (#968): `canRetry(record)` is false for `retry: false`, for `noRetry: true`, and for a record whose stored `error`/`code` names a terminal reason (`no such remote bot` / `is not shared with your Bots` / `is no longer shared with you` / `has not granted chat.public` / `bot_not_shared` / `bot_not_found`; a bare `capability_not_granted` is not one, #988), true otherwise. The host states the same answer in two spellings (`noRetry` on an approval, `retry: false` on a held reply), so both cards and Bot Social's own dock ask this one function instead of keeping a copy each; a hold written before the flag existed is read from its stored sentence at render time, with no state migration. Tests: `core/test/held-retry.test.js`. | | `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), **Copy** (`CopyRow`, #1287: the row turns into the create form's target picker, minus the chat the task already runs in, and **Copy** posts `schedule-create` with `copyFrom: { sessionId, id }` — the host writes the row, so the copy keeps the source's next run) and **Pause** / **Resume**; a paused row reads **Paused** (#354). A row with a recorded run adds one line under it: "Running since …", "Last run …", "Last run … · Stopped" or, in red, "Last run … · Failed: "; a failed run of a task that is not paused gets **Retry**, which runs the task once now (`actions.scheduleRetry`, #473). 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). The page opens with the team's **Quiet hours** card (#1166): **Off** with a **Turn on** button, or **On, 22:00–07:00** with the window's two time fields and **Save window** (the settings write refuses an invalid window, and the host's named refusal appears on the form's error line). While quiet hours are on and an `every`/`cron` task exists, the view's `quietUnchecked` flag draws one line saying such a task can still run inside the window: that flag is the only place the rule is computed — the page does not scan the task list itself (#1199) — and it asks for the view again right after the settings write, so the line does not wait for the 30-second poll. Never a silent claim of silence, and no suppressed count. 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`); failed turns, a *Latest* line and a per-card `N tool calls` count (#471); ask_user cards (a group's and the Bot's own chat) answered in the inbox (#552, #1140); a *Needs you* title and reason wrap in full so the ask can be read on the card (#1177); a result card accepted (**Accept**) or sent back with a note (**Send back** → the box → **Send it back**), with its `result.links` evidence and the producing Bot's model, and a sent-back card reading *Sent back: …* (#401). Tests: `core/test/inbox.test.js`. | -| `core/src/client/turn-failures.js` | Dead-turn lines (#1044): a turn that died with no text draws one line above the composer (`flybot.session.dock`, `data-flybot=turn-failures`), newest last, at most 3, naming the provider error from the host's `ended` entry and the streak when it is above 1. Registered by `index.js` (`flybot-turn-failures`, order 20); `failureLines(snapshot, place, t)` lives in `core/src/shared/turn-failures.js` so the test reads it without React. It never posts, retries or counts as a reply. Tests: `core/test/dead-turn.test.js`. | +| `core/src/client/turn-failures.js` | Dead-turn lines (#1044): a turn that died with no text draws one line above the composer (`flybot.session.dock`, `data-flybot=turn-failures`), newest last, at most 3, naming the provider error from the host's `ended` entry and the streak when it is above 1. Registered by `index.js` (`flybot-turn-failures`, order 20); `failureLines(snapshot, place, t)` lives in `core/src/shared/turn-failures.js` so the test reads it without React. Since #1347 the error goes through `failureSentence` (`core/src/shared/failure-copy.js`), which turns a provider refusal stored as a cut-off JSON body into a sentence that names the feature and where to change the model; the Inbox reads the same helper. It never posts, retries or counts as a reply. Tests: `core/test/dead-turn.test.js`, `core/test/ui-clickthrough.test.js`. | +| `core/src/shared/preview.js` | What a preview may show of a stored line (#130, #1347): `visibleText(text)` unwraps complete `` wraps and then removes leftover marker fragments until neither changes the text — a `from="User">` tail, a `untrusted-message>` whose brackets an older pass already stripped, a nested fragment that would otherwise rebuild a marker — and folds the result to the one line a sidebar row is. `stripFence` is the marker half on its own. It is used where a row is built (`previewOf` in `src/client/text.js`, the group row in `src/client/sidebar.js`, the host's `preview` in `src/host/channel.js`), so no surface has to remember it. Tests: `core/test/ui-clickthrough.test.js`. | | `core/src/client/delivery-view.js` | The GitHub half of the one board (#1173, #1211, #1326): the columns the snapshot names through `stagesOf()` — three for `repo-dev` (`not-started`, `in-progress`, `done`) — with the stages a definition draws no lane for reaching the reader as filters instead (`div.bt-del-filters`, `button[data-filter=all|review|done-today]` with `aria-pressed`), which narrow the cards and never reorder or add a column; a delivery card carries `data-stage`, cards that link out, the verdict/CI badge, and the header numbers. A read is one of four states and each reads differently, never as a blank board: `loading` counts its own seconds (“首次加载中,已 N 秒”) and polls every 2 s, `fresh`/`stale` poll every 60 s and a stale board keeps its columns with “显示 03:12 的快照 · 刷新失败:…” beside the header, and `failed` names the endpoint, the advice and a **Retry** button whose countdown really counts: the screen redraws once a second while a retry is scheduled and not otherwise, and the rate-limit and timeout advice says `while this page is open it retries on its own`, because the host installs no timer (#1227). “还没有快照。” is reachable only from `idle`, a host that never tried. Since #1250 the header opens with whose repository this is — `acme/widget · from config` / `from remote` / `from the package (default)`, and `no repository — set delivery.repo` when the host has nothing — so a package default never passes for a fact; a `no-repo` failure adds `Set delivery.repo in this instance's settings…` and never points at the token. Since #1244 the as-of line prints one time, not two: while today is still running it is the task zone's clock (`clockOf`) and the hint beside it is the bare `today is partial` (zh `今天为部分日`), so a partial day never shows a local-clock and a zone rendering of the same instant side by side. The CI row's share divides by `counted` when the day has one and by `total` otherwise — the one denominator `dayValue` draws that day's point from — so the row and the line beside it cannot read one day two ways; a day with no runs at all still reads `—`. The header's **Diagnose** button prints the five `probeGitHub()` lines into a copyable block, so the user reads the same statuses `core/scripts/delivery-probe.mjs` prints. Since #1265 step 4 one board may hold several repositories: more than one is a `section.bt-del-repo-block[data-repo][data-state]` per repository with a sticky `h3.bt-del-repo-head` (name plus its own source) and its own columns, and exactly one renders no wrapper at all — today's single-repository DOM. A repository that cannot be read keeps its block and says why while the others still draw, and the body says `partial`, never an empty board. Which columns exist comes from the payload through `stagesOf()` (the snapshot's own `columns`; `DELIVERY_COLUMNS` only orders and labels them), so a definition with three columns draws three and the renderer has no fixed five. The GitHub cards are read-only — nothing here writes to GitHub — while a task card only asks the dialog to move it (`onMove`), which owns the one write path (`actions.taskUpdate`). Tests: `core/test/delivery-view.test.js`, `core/test/kanban.test.js`. | | `core/src/client/delivery-chart.js` | The 7-day line each delivery metric draws (#1212): `chartGeometry` is pure — seven x positions, a y per known day (`span = max(known)`, baseline 0), the path split at every day with no number, so a gap can never read as zero. A day without a number draws no dot at all; the `full` / `partial` split is a geometry state, not an attribute for the missing day. `DeliveryChart` renders one 168×28 SVG per metric with a 1.5px stroke, `r=2` dots (`r=2.5` for the one hollow dot) and a `title` on every point (`2026-10-26 · 12`, `2026-10-26 · 未取到`); each drawn dot carries `data-state` `full` or `partial`, a real `0` is a filled dot on the baseline, and today with `partial: true` is hollow (`fill:none`, `stroke-width:1.5`) with a `2 2` dashed stretch. `DeliveryMetrics` is the whole block the view calls once — one row per metric, `label | line | Today | 7 days`, five lines (the four counts plus the CI share), with `Σ` only on the counting rows (a ratio and a median are not sums) and a star plus a one-line note, naming the window length, when some days were not read. The CI share's denominator — `counted`, else `total` — is the one the header row now reads too (#1244), so the number on the row and the point on the line come from the same day. A snapshot with no `metrics.daily` draws no SVG at all and says `7 天数据未取到` in one line. Colours come from `--bt-accent` / `--bt-ink-*` only. Tests: `core/test/delivery-chart.test.js`. | | `core/src/client/board-card.js` | One card component behind two boards (#1265 step 1): `BoardCard` draws the identity line, the title, what the card wears, who has it and what it is doing, from a card model that says `source: 'github'` or `source: 'task'`. Each board passes its own `skin` (`DELIVERY_SKIN` / `TASK_SKIN`), so the delivery board and the task board keep their stylesheets while sharing the markup, and every card carries `data-source` and `data-key`. The two callers are `delivery-view.js`'s `DeliveryCard` and `kanban.js`'s `BoardCard`. Tests: `core/test/board-card.test.js`, `core/test/delivery-view.test.js`, `core/test/kanban.test.js`. | @@ -55,6 +56,13 @@ Add a new invariant under the heading it belongs to (or a new heading), not at t - `client.js` matches `core/src/client/`; `npm run build` + `--check` (also invariant 3 of [ds-bot-core](ds-bot-core.md)). - The bundle carries the slot wrapper's empty rule: `.bt-flybot-section:empty { margin-top: 0; border-top: 0; padding-top: 0; }` sits beside the base `.bt-flybot-section` rule in `FLYBOT_CSS` (`flybot-slots.js`), so a `flybot.sidebar.section` entry whose component renders `null` leaves no separator behind while a section with rows keeps the base rule (#818). Behavior is specified on [plugin-runtime](plugin-runtime.md). - The built bundle carries the dock wrapper's new `blank` prop: `FlyBotSessionDock` reads `session.blank && !session.promptAttempted` and hands the answer to every `flybot.session.dock` slot (#998). Behavior is specified on [plugin-runtime](plugin-runtime.md). +- A blank Bot chat draws its own strip (#1347): on that same `blank` fact, and only for a local Bot (`!bot.remote`), the dock renders `BotChatStrip` (`bt-chat-strip`, `data-flybot=bot-chat-strip`) above the composer with the Bot's name, its role, "No messages yet. Say something to {name}." and a **Details** button. It disappears with the strip's own condition the moment the shell draws the real header, and a friend's Bot chat keeps its own empty hint (#173). Tests: `core/test/overlay-approvals.test.js`. +- A settings row never breaks its own words (#1347): `.bt-bs-row>.bt-set-copy>.bt-bs-label` is `white-space:nowrap` with an ellipsis, and `.bt-bs-row .bt-soft` / `.bt-mini` are `flex:none;white-space:nowrap`. The hint under a label still wraps — it is prose, and clipping it was tried and reverted. `.bt-panel` declares `container:bt-panel/inline-size`, and `@container bt-panel (max-width:300px)` stacks the row at the panel's narrow floor, so nothing is clipped to fit a width that is not there. +- The mode word beside the title reads as a status, not a control (#1347): `[data-slot="conversation.session.header.actions"] [class*="_label"]` loses its fill and radius and takes `cursor:help`. DSH draws that label as a `span` with no click handler (`AgentPresetLabel`), so a chip that looks pressable is a promise nothing keeps. +- **The thread cannot paint above a dialog, a sheet or a scrim (#1347).** `[class*="_centerCol"]` carries `position:relative;z-index:0;isolation:isolate`, so the chat column is one stacking context that sits below `.bt-panel` (40), `.bt-pane` (45) and the dialog layers (120/121) whatever any single bubble does. This is the load-bearing half: with `position:static` the column was no context at all, so a descendant the browser promoted to its own composited layer — an entrance animation's transform (`bt-row-in`), a `filter`, a `will-change`, a sticky box — was ordered against the ROOT stacking context and could sort above a dialog's z-index box. Raising the tiers alone does not carry the fix, for the same reason: a promoted descendant is sorted in the root context and never reads the column's z-index. `contain:paint` is deliberately absent: it would clip the reaction strip and message menu, which portal to `body` for that reason. `core/test/ui-clickthrough.test.js` pins the rule and its z-index. `contain:paint` is deliberately absent: it would clip the reaction strip and message menu, which portal to `body` for that reason. `core/test/ui-clickthrough.test.js` pins the rule and its z-index. +- **Each overlay layer is its own stacking context (#1347).** `.bt-settings-layer`, `.bt-mem-layer`, `.bt-palette-layer` and `.bt-remote-sheet-layer` carry `isolation:isolate` beside their `z-index`, so the layer as a whole — scrim and dialog together — is what sits in the tier, whatever a dialog's own contents do. +- **A modal's scrim separates the two surfaces (#1347).** `.bt-scrim` was `rgba(0,0,0,.32)`, which left the thread legible behind a dialog and read as the chat painting through it (the owner saw a message at "full opacity" behind the Inbox, and assistant text "across columns" in Tasks). It is now `rgba(12,12,14,.62)` in the light theme and `rgba(0,0,0,.7)` in the dark one: still one layer, still dismissed by a click. +- **One tier per layer, no ties (#1347).** Dialogs are 120 (`.bt-settings-layer`, and `.bt-remote-sheet-layer`) and 121 (`.bt-mem-layer` — Inbox, Tasks, Memory); popovers are 130 (`.bt-menu`, `.bt-react-strip`, `.bt-update-pop`, and the in-panel `.bt-avedit`); the palette is 140; the toast stays 1000. Before this the context menu and the reaction strip shared `z-index:60` with the Settings dialog, so a popover opened after the dialog tied with it and painted over it on DOM order. Nothing may sit in the old 60/61 band again. ### Host Bot look in the bundle diff --git a/docs/subsystems/core-host.md b/docs/subsystems/core-host.md index 3ce5a21fe..4f169e05f 100644 --- a/docs/subsystems/core-host.md +++ b/docs/subsystems/core-host.md @@ -52,6 +52,8 @@ The server side of the patched `ds-bot` core (`core/`): the part of [ds-bot-core | `core/src/host/usage.js` | Usage ledger at `$DSH_HOME/bot/usage.sqlite` (`calls`). Ledger v3 (#903): every row carries `harness`, `issue_pr`, `tool_calls`, `wall_ms`, plus existing token columns (`withUsageFields` / `missingUsageFields`). Session turns are metered via `foldCalls` over turn events (`tool/call` count, wall time from attempt start, `issuePr` from turn text or `rt.noteUsageIssuePr(sessionId, ref)`), not from llm/stream alone. A triage tier is stamped with `rt.noteUsageHarness(sessionId, idOrLabels)` and lands on the row's `harness` field; the resume cursor (`marks.fold`) stays provider / model / retry. Sample row fixture: `core/test/fixtures/usage-sample.sqlite`. Tests: `core/test/usage-fields.test.js`, `core/test/usage.test.js`. | | `core/src/host/tool-schema.js` | The tool definitions the model sees: its `defineTool` wraps DSH's and completes the compiled schema with `explicitObjectShape`, which fills `properties: {}` and `required: []` on every object node (recursing through `properties`, `items`, `oneOf`/`anyOf`/`allOf`, `$defs`, …). A strict OpenAI-compatible gateway reads an absent member as null and rejects the whole request (`null is not of type "array"`), so one argument-less tool would break every turn; argument validation keeps using DSH's schema unchanged. Every host module registers tools through this wrapper. Tests: `core/test/tool-schema.test.js`. | | `core/src/shared/artifact.js` | The typed facts a workflow node takes in and hands out (#1272): `parseArtifactSpec` reads `check:tests-ok` / `issue.ready` / `text:topic` into `{ kind, name }` and refuses a spelling the vocabulary does not have; `artifact(spec, revision, { value })` is one instance, and `REVISION_RULES` says what the revision of each kind is (a verdict its SHA, a check the SHA it ran on, a PR its number and `headRefOid`). `freshness(input, currentRevision)` answers present-and-current separately from present, and a fact nobody could read is `unreadable`, never a match. Tests: `core/test/workflow.test.js`. | +| `core/src/shared/preview.js` | What a stored line may show a person (#130, #1347): `visibleText(text)` unwraps a complete `` wrap, then removes leftover marker fragments until neither changes the text, then folds to one line. `stripFence` is the marker half alone. The sidebar rows read it (`previewOf` in `src/client/text.js`), and so does the `preview` this channel puts on a room in the `state` payload, which used to be a plain `unwrapUntrusted` and so leaked `from="User"> … >` on a line whose opener had already been cut. Tests: `core/test/ui-clickthrough.test.js`. | +| `core/src/shared/failure-copy.js` | A failed turn's sentence a person can act on (#1347): `failureSentence(raw, translate)` keys on the provider's own `code` field in the stored text — not on the message, which is not stable — and returns the mapped sentence (naming the feature in an `UnsupportedModel` refusal, and where to change the model), or the text unchanged when the code is not mapped, because a failure nobody has mapped reads better as it came than as a guess. `src/shared/turn-failures.js` and `src/client/inbox.js` both read it, so the chat's dead-turn lines and the Inbox card say the same thing. Tests: `core/test/ui-clickthrough.test.js`. | | `core/src/shared/board-card.js` | Card identity for the one board (#1265 step 1): `githubCardKey(repo, number)` is `owner/name#number` and `taskCardKey(id)` is `task:`, the two spellings the client's `BoardCard` puts on every card. The GitHub key is not keyed by kind — an issue and the pull request that closes it are one card — which is the identity step 3 hangs a FlyBot task record under; a row with no repository falls back to `github::` rather than imply a cross-repository identity. `githubCard(row, repo)` / `taskCard(task)` build the model, `sameCard(a, b)` is the one comparison. Tests: `core/test/board-card.test.js`. | | `core/src/shared/workflow.js` | The workflow engine's pure half (#1272): a workflow is data — `nodes` (each with a required `role` and typed `inputs` / `outputs`), `edges` (`. -> .`) and declared `loops` — and every function below is a function of that file. `validateWorkflow` refuses each way a definition cannot run, with its own sentence (an input nothing produces, an output nothing consumes and that is not a `deliverable`, two producers of one fact, an undeclared cycle, a node with no `role`, a role not on the team, an unknown artifact kind, a misaligned edge, a guard whose `is` is not one of `present` / `fresh` / `equals`, a `column` the board does not have); `workflowColumns` is the board's columns in dependency order, so the node ids are the state and no built-in list exists, and a node carrying `column: false` draws no column at all while a definition that states none keeps the node id as its column (#1265); `layoutWorkflow` is the flowchart (one box per node carrying its role and rails, one edge per `edges[]` row, ranks left to right); `enterNode` / `inputStates` / `renderRefusal` answer whether a card may start and, when it may not, say which input is missing or stale with both revisions, in the node's own `refusal` wording when it has one and in the guard's own wording when that guard carries one; both read a fact by the same parsed key the rest of the engine uses, so the spelling on the card and the spelling on the producer cannot disagree (#1298); `holderOf` is the node's role, which is the card's assignee. #1291's four debts are closed here: one word for one thing (`role`, never `bot`), an unknown guard `is` refuses instead of being read as `present`, `producersOf` / `consumersOf` key on the parsed spec so `text: draft` and `text:draft` are one fact, and `resolvedEdges` reports the artifact it carries once. Tests: `core/test/workflow.test.js`. | | `core/src/shared/workflow-blank.js` | The `blank` preset (#1272): three nodes (`draft` / `review` / `publish`, roles `Writer` / `Editor` / `Publisher`), no guards, artifacts `text:topic -> text:draft -> text:edited -> link:published`. Data, not logic — the same JSON is what a workspace `workflows/blank.json` holds — and the proof that the engine special-cases no node id. Tests: `core/test/workflow.test.js`. | diff --git a/docs/subsystems/plugin-runtime.md b/docs/subsystems/plugin-runtime.md index 6989b04c0..850041c57 100644 --- a/docs/subsystems/plugin-runtime.md +++ b/docs/subsystems/plugin-runtime.md @@ -71,7 +71,7 @@ FlyBot's plugin API surface on top of the patched core: host hooks, client slots ### Sidebar + friend actions - `hooks.js` hands a transport `approved: true` (never read from the message) when the owner approved that send on a FlyBot card; social uses it to finish Bot Social's own hold with the same click (#258). -- A `flybot.session.dock` entry may set `policy` (the `by` of the approvals it draws; social registers `policy: 'social'`) and receives the host translator `t` and `claim(ids)`: call it with the approval ids the dock shows so the overlay draws no second card for them (#260). The host also marks the chat each mounted session dock belongs to as open (`chatOpened`), and the overlay draws no pointer for an approval of the open chat (#271). A dock entry also gets `blank`: true only while the host is drawing no session header at all — the session has said nothing (`session.blank`) **and** nothing has been sent to it (`!session.promptAttempted`; the host's own condition is `session.blank && conversationPhase(...) === "blank"`, and a sent-to session reads `engaging`), and a failed first send never turns it blank again. Social's group strip (`social-group-strip`) stands in for that missing header exactly then and is gone at the first send (#998). The pointer's chat is the approval's room, else the friend's chat it is addressed to, else its sender Bot (#278). +- A `flybot.session.dock` entry may set `policy` (the `by` of the approvals it draws; social registers `policy: 'social'`) and receives the host translator `t` and `claim(ids)`: call it with the approval ids the dock shows so the overlay draws no second card for them (#260). The host also marks the chat each mounted session dock belongs to as open (`chatOpened`), and the overlay draws no pointer for an approval of the open chat (#271). A dock entry also gets `blank`: true only while the host is drawing no session header at all — the session has said nothing (`session.blank`) **and** nothing has been sent to it (`!session.promptAttempted`; the host's own condition is `session.blank && conversationPhase(...) === "blank"`, and a sent-to session reads `engaging`), and a failed first send never turns it blank again. Social's group strip (`social-group-strip`) stands in for that missing header exactly then and is gone at the first send (#998). The dock itself stands in for a **Bot** chat's missing header on that same `blank` fact, drawing `BotChatStrip` above the composer with the Bot's name, its role, "No messages yet. Say something to {name}." and a **Details** button — for a local Bot only (`!bot.remote`), because a friend's chat has its own empty hint (#1347). The pointer's chat is the approval's room, else the friend's chat it is addressed to, else its sender Bot (#278). - Social registers a second `flybot.session.dock` entry, `social-room-end` (`FaceRoomStatus`, order 40): the plain ended-room line for a left/closed face room (#191). - Social registers `flybot.settings.page` (`SocialServerSettings`, label **Bot Social**): the server address, its status and the switch form (#337). The host side is `social/server` (a read) and `social/server-check` / `social/server-set` (writes: blocked on a read-only host and, like every plugin write, while the team has more than one member). - Social also registers `flybot.account.header` (`MyHandle`): my own Bot Social handle plus Copy at the top of the account menu (#181), hidden while not connected. diff --git a/patches/ds-bot/0001-core-fixes.patch b/patches/ds-bot/0001-core-fixes.patch index 700b64e22..9f3460b25 100644 --- a/patches/ds-bot/0001-core-fixes.patch +++ b/patches/ds-bot/0001-core-fixes.patch @@ -183,7 +183,7 @@ diff --git a/src/client/browser-pane.js b/src/client/browser-pane.js // body-level layer by bot-browsers.js; the panel draws the strip and the toolbar // and reports the rectangle the shown webview covers. import { createElement as h, useEffect, useLayoutEffect, useRef, useState } from 'react' -+import { DEFAULT_WIDTH, DETAILS_WIDTH, panelWidthRange } from './pane-width.js' ++import { DEFAULT_WIDTH, DETAILS_WIDTH, MIN_DETAILS, panelWidthRange } from './pane-width.js' import { createPortal } from 'react-dom' import { BotMark } from './mark.js' import { STORAGE } from './storage.js' @@ -203,8 +203,19 @@ diff --git a/src/client/browser-pane.js b/src/client/browser-pane.js // How long the Bot's mark shows that it read the page. const READ_FLASH_MS = 2400 const EMPTY_PAGE = { url: '', title: '', favicon: null, loading: false, canGoBack: false, canGoForward: false, error: null, excerpt: '' } -@@ -59,10 +59,8 @@ const savedWidth = () => { - return Number.isFinite(value) && value >= DETAILS_WIDTH ? value : null +@@ -52,17 +52,17 @@ export function Favicon({ src, className }) { + } + + // null until the user sets a width: the Bot page then opens at DETAILS_WIDTH and the +-// browser at DEFAULT_WIDTH. ++// browser at DEFAULT_WIDTH. A saved width narrower than DETAILS_WIDTH is a *stored* choice from a ++// narrower policy (#1347 raised DETAILS_WIDTH from 320 to 380), so it is kept: only the floor of ++// what the panel can be dragged to is enforced here. + const savedWidth = () => { + try { + const value = Number(localStorage.getItem(WIDTH_KEY)) +- return Number.isFinite(value) && value >= DETAILS_WIDTH ? value : null ++ return Number.isFinite(value) && value >= MIN_DETAILS ? value : null } catch { return null } } -const widthRange = (view, viewport) => (view === 'browser' @@ -928,6 +939,23 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js 'Remove {name} from the group': '把 {name} 移出群聊', 'Remove from group': '移出群聊', 'Add member': '添加成员', +@@ -172,6 +172,16 @@ const ZH = { + 'Close details': '关闭详情', + 'Copied conversation': '已复制对话', + 'Share conversation': '分享对话', ++ 'Copy conversation': '复制对话', ++ 'Nothing to copy yet': '还没有可复制的内容', ++ 'Conversation copied as Markdown': '对话已复制为 Markdown', ++ 'Could not copy the conversation': '无法复制对话', ++ 'No messages yet. Say something to {name}.': '还没有消息。给 {name} 发点什么吧。', ++ 'This model does not support that request. Pick another model in the Bot\'s settings.': '该模型不支持这项请求。请在 Bot 设置里换一个模型。', ++ 'This model does not support {feature}. Pick another model in the Bot\'s settings.': '该模型不支持 {feature}。请在 Bot 设置里换一个模型。', ++ 'The provider does not know this model. Check the model name in the Bot\'s settings.': '服务方不认识这个模型。请在 Bot 设置里检查模型名称。', ++ 'The conversation is longer than this model can read. Condense it, or pick a model with a larger window.': '这段对话超出了该模型能读的长度。请压缩对话,或换一个上下文更大的模型。', ++ 'The model service refused the request. Check the model and the endpoint in the Bot\'s settings.': '模型服务拒绝了这次请求。请在 Bot 设置里检查模型和接口地址。', + 'Copy conversation as Markdown': '把对话复制为 Markdown', + 'This conversation is not part of your Bot team.': '这个对话不属于你的 Bot 团队。', + 'Admin {name}': '管理员 {name}', @@ -215,6 +215,25 @@ const ZH = { 'This model is not available now. Pick another one.': '这个模型现在不可用,请换一个。', 'Every message of this Bot runs on it.': '这个 Bot 的每条消息都用它。', @@ -2275,11 +2303,12 @@ diff --git a/src/client/schedules.js b/src/client/schedules.js diff --git a/src/client/text.js b/src/client/text.js --- a/src/client/text.js +++ b/src/client/text.js -@@ -2,6 +2,7 @@ +@@ -2,6 +2,8 @@ // Text helpers import { dateLocale, t } from './i18n.js' import { mentionSpans } from '../shared/mentions.js' +import { remoteMentionLabel } from '../shared/remote-label.js' ++import { visibleText } from '../shared/preview.js' export const MENTION_ORIGIN = 'https://bots.local/' @@ -2402,7 +2431,7 @@ diff --git a/src/client/text.js b/src/client/text.js // parts) are not events in the chat, nor is a request handed back after its line was // already shown: deferred, resumed after a restart, or retried after a tool call // written as text. -@@ -200,14 +200,26 @@ export function commRunOf(order, peers, turn, hiddenTurns = []) { +@@ -200,14 +200,27 @@ export function commRunOf(order, peers, turn, hiddenTurns = []) { } // Latest visible line for a sidebar row; Bot-to-Bot exchange turns stay out of it. @@ -2427,8 +2456,9 @@ diff --git a/src/client/text.js b/src/client/text.js - const text = entry.response || entry.prompt || '' - if (text !== '') return stripHeader(text).replace(/\s+/g, ' ').trim() + const text = (unsentTurns.includes(entry.turn) ? '' : entry.response) || entry.prompt || '' -+ // The outline flattens a reply to one line, so its trace ends at a space there. -+ if (text !== '') return stripTrace(stripHeader(text)).replace(/\s+/g, ' ').trim().replace(/^[^\s→]{1,40}(?: → [^\s→]{1,40})+ (?=\S)/, '') ++ // The outline flattens a reply to one line, so its trace ends at a space there. A stored line can ++ // still carry its untrusted fence, and a row is one line someone reads: no marker reaches it (#1347). ++ if (text !== '') return visibleText(stripTrace(stripHeader(text)).replace(/\s+/g, ' ').trim().replace(/^[^\s→]{1,40}(?: → [^\s→]{1,40})+ (?=\S)/, '')) } return '' } diff --git a/patches/ds-bot/0003-flybot-api-client.patch b/patches/ds-bot/0003-flybot-api-client.patch index 55d30e250..0e6186913 100644 --- a/patches/ds-bot/0003-flybot-api-client.patch +++ b/patches/ds-bot/0003-flybot-api-client.patch @@ -442,14 +442,38 @@ diff --git a/src/client/bot-settings.js b/src/client/bot-settings.js h('button', { type: 'button', className: 'bt-set-row bt-bs-row', onClick: duplicate }, h('span', { className: 'bt-set-copy' }, h('span', { className: 'bt-bs-label' }, t('Duplicate')), h('span', { className: 'bt-set-hint' }, t('A copy with the same job and model, and fresh memory.'))), h('span', { className: 'bt-chevron' }, h(ChevronRightIcon))), -@@ -269,6 +269,7 @@ label.bt-bs-row{cursor:text} - .bt-bs-row>.bt-set-copy{flex:1 1 auto} +@@ -266,9 +266,17 @@ export const BOT_SETTINGS_CSS = ` + .bt-bs .bt-avedit{left:0;transform:none} + .bt-bs-row{min-height:48px} + label.bt-bs-row{cursor:text} +-.bt-bs-row>.bt-set-copy{flex:1 1 auto} .bt-bs-label{font-size:13px;line-height:18px;font-weight:500} ++/* #1347: a row keeps its label and its control side by side. The *label* is one line — "Name" used to ++ break into "Nam" + "e" inside a 36px column — and a control never breaks its own words: "Test ++ connection" came out as "Test connecti" / "on" and Main Bot's "Change" as "Ch/an/ge". The hint under ++ a label is prose, so it wraps and is never cut: the row grows instead. */ ++.bt-bs-row>.bt-set-copy{flex:1 1 8ch;min-width:8ch} ++.bt-bs-row>.bt-set-copy>.bt-bs-label{white-space:nowrap;overflow:hidden;text-overflow:ellipsis} ++.bt-bs-row .bt-soft,.bt-bs-row .bt-mini{flex:none;white-space:nowrap} .bt-bs-row .bt-model-trigger{flex:none;max-width:58%} ++.bt-bs-row .bt-model-trigger :is(.bt-model-name,.bt-model-provider){white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0} +${MODEL_TEST_CSS} .bt-bs-input{flex:0 1 220px;width:0;min-width:96px;height:30px;box-sizing:border-box;margin:-4px -6px -4px 0;padding:0 8px;border:1px solid transparent;border-radius:8px;background:none;color:var(--bt-ink);font:inherit;font-size:13px;text-align:right;outline:none;transition:background-color .12s ease,border-color .12s ease} .bt-bs-input:hover{background:var(--bt-hover)} .bt-bs-input:focus{background:var(--bt-main);border-color:var(--bt-line-2);text-align:left} +@@ -289,4 +289,12 @@ label.bt-bs-row{cursor:text} + .bt-switch[aria-checked=true]:active:not(:disabled) .bt-switch-knob{transform:translateX(11px)} + .bt-avedit :is(.bt-shapes,.bt-colors,.bt-dropzone){animation:bt-veil-in .18s ease-out} + @media (prefers-reduced-motion:reduce){.bt-switch,.bt-switch-knob{transition:none}.bt-avedit :is(.bt-shapes,.bt-colors,.bt-dropzone){animation:none}} ++/* #1347: the panel's floor is 240px, where a label and a 96px field cannot share a line honestly. Below ++ 300px the row stacks instead: the copy keeps its whole width on top, the control sits under it and ++ keeps its own words. Nothing is clipped to fit a width that is not there. */ ++@container bt-panel (max-width:300px){ ++ .bt-bs-row{flex-wrap:wrap;row-gap:6px} ++ .bt-bs-row>.bt-set-copy{flex:1 1 100%;min-width:0} ++ .bt-bs-input{flex:1 1 100%;width:auto;min-width:0;margin:0;text-align:left} ++} + ` diff --git a/src/client/cells.js b/src/client/cells.js --- a/src/client/cells.js +++ b/src/client/cells.js @@ -2032,7 +2056,7 @@ diff --git a/src/client/palette.js b/src/client/palette.js diff --git a/src/client/sidebar.js b/src/client/sidebar.js --- a/src/client/sidebar.js +++ b/src/client/sidebar.js -@@ -1,16 +1,20 @@ +@@ -1,16 +1,21 @@ -import { createElement as h, Fragment, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from 'react' +import { FlyBotSections, FlyBotAccountHeader, useSidebarStructure, botTree } from './flybot-slots.js' +import { Component, createElement as h, Fragment, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' @@ -2050,13 +2074,14 @@ diff --git a/src/client/sidebar.js b/src/client/sidebar.js import { springEasing, SPRING_SHAPE } from './motion.js' import { hostText, t } from './i18n.js' +import { friendBotTitle, roomTitle, sharedRoomTitle, isShownRemoteChat } from '../shared/remote-label.js' ++import { visibleText } from '../shared/preview.js' import { showsDshRow } from './dsh-offer.js' +import { InboxRow } from './inbox.js' +import { KanbanRow } from './kanban.js' // ------------------------------------------------------------------------- // Sidebar -@@ -37,77 +37,149 @@ const workKeys = text => (text === '' ? [] : text.split(',').map((key) => { +@@ -37,77 +37,151 @@ const workKeys = text => (text === '' ? [] : text.split(',').map((key) => { })) const workLine = (lead, tool, more) => h(Shimmer, null, `${lead} · ${workPhrase(tool)}…${more > 0 ? ` +${more}` : ''}`) @@ -2183,7 +2208,9 @@ diff --git a/src/client/sidebar.js b/src/client/sidebar.js - const fallback = useSessions(list => previewOf(list, id)) - const preview = room.preview?.replace(/\s+/g, ' ').trim() || fallback + const fallback = useSessions(list => nameMentions(previewOf(list, id), roster)) -+ const preview = nameMentions(room.preview?.replace(/\s+/g, ' ').trim(), roster) || fallback ++ // The host's line is the same kind of stored text as a Bot chat's, so the row reads it the same way: ++ // a fence or a marker fragment must never reach the row (#1347). ++ const preview = nameMentions(visibleText(room.preview), roster) || fallback const active = useSessionRetainInfo(id, info => (info?.retainedBy?.mainView ?? 0) > 0) const unread = useSessionStatus(map => map.get(id)?.completionUnread === true) || (marked && !active) useMarkedUnread(id, active, marked, actions) @@ -2502,6 +2529,15 @@ diff --git a/src/client/sidebar.js b/src/client/sidebar.js // shell's footer classes are CSS-module names, so they match on their stems. export const SIDEBAR_CSS = ` .bt-side-top .bt-brand{cursor:default;padding-left:6px} +@@ -623,7 +623,7 @@ export const SIDEBAR_CSS = ` + .bt-update[data-phase=installing] .bt-cloud-arrow{animation:bt-cloud-drop 1.1s cubic-bezier(.45,0,.55,1) infinite} + @keyframes bt-cloud-drop{0%{transform:translateY(-3px);opacity:0}35%{opacity:1}70%{transform:translateY(1px);opacity:1}100%{transform:translateY(2px);opacity:0}} + .bt-update-dot{position:absolute;top:6px;right:6px;width:7px;height:7px;border-radius:50%;background:#E5484D;box-shadow:0 0 0 1.5px var(--bt-sidebar)} +-.bt-update-pop{position:fixed;z-index:60;width:280px;padding:14px 14px 12px;border-radius:14px;background:var(--bt-main);border:.5px solid var(--bt-line-2);box-shadow:0 10px 20px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1);color:var(--bt-ink);box-sizing:border-box;transform-origin:top left;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;animation:bt-update-pop ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing}} ++.bt-update-pop{position:fixed;z-index:130;width:280px;padding:14px 14px 12px;border-radius:14px;background:var(--bt-main);border:.5px solid var(--bt-line-2);box-shadow:0 10px 20px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1);color:var(--bt-ink);box-sizing:border-box;transform-origin:top left;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;animation:bt-update-pop ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing}} + @keyframes bt-update-pop{from{opacity:0;transform:scale(.96);filter:blur(4px)}} + .bt-update-title{font-size:14px;line-height:20px;font-weight:600} + .bt-update-text{margin-top:2px;font-size:12.5px;line-height:18px;color:var(--bt-ink-2)} @@ -658,4 +658,14 @@ export const SIDEBAR_CSS = ` .bt-agent-sub[data-open] .bt-agent-sub-row{animation:bt-agent-row-in .24s ease both;animation-delay:calc(var(--i, 0)*30ms)} @keyframes bt-agent-row-in{from{opacity:0;filter:blur(4px)}to{opacity:1;filter:blur(0)}} @@ -2655,6 +2691,20 @@ diff --git a/src/client/styles.js b/src/client/styles.js .bt-pane{position:fixed;top:var(--dsh-frame-chrome-top,0px);right:0;bottom:0;z-index:45;pointer-events:auto;background:var(--bt-main);color:var(--bt-ink);display:flex;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif} .bt-dialog{flex:1;display:flex;flex-direction:column;min-width:0} +@@ -332,11 +332,11 @@ button.bt-q-opt:active{background:var(--bt-active)} + .bt-soft:hover{background:var(--bt-active)} + .bt-time{display:block;text-align:center;font-size:12px;line-height:16px;color:var(--bt-ink-2);padding:6px 0} + +-.bt-panel{position:fixed;top:var(--dsh-frame-chrome-top,0px);right:0;bottom:0;width:var(--bt-panel-w,320px);pointer-events:auto;z-index:40;background:var(--bt-main);border-left:.5px solid var(--bt-line-2);display:flex;flex-direction:column;color:var(--bt-ink);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;box-sizing:border-box;animation:bt-pane-in .3s cubic-bezier(.2,0,0,1);transition:width ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing}} ++.bt-panel{position:fixed;top:var(--dsh-frame-chrome-top,0px);right:0;bottom:0;width:var(--bt-panel-w,320px);pointer-events:auto;z-index:40;background:var(--bt-main);border-left:.5px solid var(--bt-line-2);display:flex;flex-direction:column;color:var(--bt-ink);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;box-sizing:border-box;animation:bt-pane-in .3s cubic-bezier(.2,0,0,1);transition:width ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing};container:bt-panel/inline-size} + .bt-panel[data-leaving]{transform:translateX(100%);transition:transform .3s cubic-bezier(.2,0,0,1);pointer-events:none} + .bt-panel[data-instant]{transition:none} + @keyframes bt-pane-in{from{transform:translateX(100%)}} +-[class*="_centerCol"]{transition:margin-right ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing}} ++[class*="_centerCol"]{transition:margin-right ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing};position:relative;z-index:0;isolation:isolate} + .bt-panel-grip{position:absolute;left:-5px;top:0;bottom:0;width:8px;cursor:col-resize;z-index:3;touch-action:none} + .bt-panel-grip:hover,.bt-panel[data-dragging] .bt-panel-grip{background:linear-gradient(to right,transparent 4px,var(--bt-line-2) 4px,var(--bt-line-2) 6px,transparent 6px)} + .bt-drag-shield{position:fixed;inset:0;z-index:45;cursor:col-resize} @@ -345,6 +345,8 @@ button.bt-q-opt:active{background:var(--bt-active)} .bt-drawer-id{display:flex;flex-direction:column;align-items:center;padding:14px 16px 0} .bt-drawer-id>.bt-mark,.bt-drawer-id>.bt-cluster{margin-bottom:16px} @@ -2664,6 +2714,40 @@ diff --git a/src/client/styles.js b/src/client/styles.js .bt-drawer-name:hover{background:var(--bt-hover)} .bt-drawer-name-input{font-size:17px;line-height:24px;text-align:center;border:.5px solid var(--bt-line-2);border-radius:6px;padding:0 6px;background:var(--bt-main);color:var(--bt-ink);outline:none;font-weight:500} .bt-drawer-role{font-size:12px;line-height:16px;color:var(--bt-ink-3);margin-top:6px;text-align:center} +@@ -379,7 +379,7 @@ button.bt-q-opt:active{background:var(--bt-active)} + .bt-avatar-anchor{position:relative} + .bt-avatar-trigger{position:relative;width:64px;height:64px;border:0;border-radius:50%;background:none;cursor:pointer;padding:0} + .bt-avatar-pencil{position:absolute;right:-2px;bottom:-2px;width:22px;height:22px;border-radius:50%;background:var(--bt-main);border:1px solid var(--bt-line-2);color:var(--bt-ink-2);display:flex;align-items:center;justify-content:center;box-shadow:0 1px 4px rgba(0,0,0,.12)} +-.bt-avedit{position:absolute;top:calc(100% + 8px);left:50%;transform:translateX(-50%);width:300px;background:var(--bt-main);border:1px solid var(--bt-line);border-radius:12px;box-shadow:0 12px 40px rgba(0,0,0,.18);padding:10px;z-index:70;display:flex;flex-direction:column;gap:10px;color:var(--bt-ink);transform-origin:top center;animation:bt-pop-drop ${SPRING_TAP.duration}ms ${SPRING_TAP.easing},bt-veil-in .16s ease-out} ++.bt-avedit{position:absolute;top:calc(100% + 8px);left:50%;transform:translateX(-50%);width:300px;background:var(--bt-main);border:1px solid var(--bt-line);border-radius:12px;box-shadow:0 12px 40px rgba(0,0,0,.18);padding:10px;z-index:130;display:flex;flex-direction:column;gap:10px;color:var(--bt-ink);transform-origin:top center;animation:bt-pop-drop ${SPRING_TAP.duration}ms ${SPRING_TAP.easing},bt-veil-in .16s ease-out} + .bt-bs .bt-avedit{transform-origin:32px 0} + .bt-avedit-top{display:flex;align-items:center;justify-content:space-between} + .bt-avedit-tabs{position:relative;display:flex;gap:2px;background:var(--bt-hover);border-radius:999px;padding:2px} +@@ -419,9 +419,23 @@ button.bt-q-opt:active{background:var(--bt-active)} + .bt-seg button.bt-perm-danger,.bt-seg button.bt-perm-danger[aria-pressed=true]{color:#e02135} + + /* Settings dialog: a 198px page list beside the page. */ +-.bt-settings-layer{position:fixed;inset:var(--dsh-frame-chrome-top,0px) 0 0;z-index:60;display:flex;align-items:center;justify-content:center;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif} ++/* #1347: dialogs, sheets and scrims sit in a tier of their own, well clear of everything the ++ chat surface may do. They used to share 60/61 with the context menu, the reaction strip and the ++ remote-Bot sheet, so any body-level popover opened after them tied on z-index and won on DOM ++ order — and a chat element with a stray z-index could reach the same number. The tier is now ++ explicit: chat surface at most 50, dialogs 120/121, popovers 130, palette 140, toast 1000. The ++ chat column is additionally capped at z-index 0 inside its own stacking context (see the ++ center-column rule below), so nothing in the thread can outrank these whatever it does. */ ++.bt-settings-layer{position:fixed;inset:var(--dsh-frame-chrome-top,0px) 0 0;z-index:120;isolation:isolate;display:flex;align-items:center;justify-content:center;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif} + .bt-scrim{position:absolute;inset:0;background:rgba(0,0,0,.32);animation:bt-fade .22s ease-out} + body[data-ds-dark-theme] .bt-scrim{background:rgba(0,0,0,.5)} ++/* #1347: a modal is a modal. A 0.32 scrim leaves the thread legible behind it, and the owner read that ++ as the chat painting through the dialog ("assistant text readable across columns", a pill visible at ++ full opacity behind the Inbox). The scrim now separates the two surfaces outright: the thread is ++ dimmed to a backdrop, not left readable beside the thing being decided. It is still one layer and ++ still dismisses on a click, so nothing else about it changes. */ ++body:not([data-ds-dark-theme]) .bt-scrim{background:rgba(12,12,14,.62)} ++body[data-ds-dark-theme] .bt-scrim{background:rgba(0,0,0,.7)} + .bt-settings{--bt-set-w:min(800px,calc(100vw - 48px));--bt-set-h:min(620px,calc(100vh - 2*max(32px,var(--dsh-frame-overlay-top,32px))));position:relative;width:var(--bt-set-w);height:var(--bt-set-h);background:var(--bt-main);border-radius:16px;border:.5px solid var(--bt-line-2);box-shadow:0 24px 80px rgba(0,0,0,.24);display:flex;overflow:hidden;color:var(--bt-ink);transition:${spring(SPRING_SHAPE, 'width', 'height')};animation:bt-dialog-rise ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing},bt-veil-in .2s ease-out} + .bt-settings[data-wide]{--bt-set-w:min(1100px,calc(100vw - 48px));--bt-set-h:min(780px,calc(100vh - 2*max(32px,var(--dsh-frame-overlay-top,32px))))} + :is(.bt-settings-layer,.bt-mem-layer)[data-leaving]{pointer-events:none} @@ -437,6 +437,7 @@ body[data-ds-dark-theme] .bt-scrim{background:rgba(0,0,0,.5)} .bt-settings-nav>.bt-thumb{left:8px;right:8px;border-radius:8px;background:var(--bt-active)} .bt-settings-nav[data-glide] .bt-settings-item[aria-current=page]{background:transparent} @@ -2672,15 +2756,20 @@ diff --git a/src/client/styles.js b/src/client/styles.js /* Usage while another page is open: out of the flow at Usage's own width, so it keeps its layout, and not rendered. */ .bt-settings-usage[data-off]{position:absolute;top:0;left:198px;width:calc(min(1100px,calc(100vw - 48px)) - 198px);height:100%;box-sizing:border-box;content-visibility:hidden;pointer-events:none} -@@ -470,6 +470,8 @@ button.bt-set-row:hover{background:var(--bt-hover)} +@@ -470,7 +470,13 @@ button.bt-set-row:hover{background:var(--bt-hover)} .bt-set-card>.bt-set-row+.bt-set-row{border-top:.5px solid var(--bt-line)} .bt-set-row:first-child{border-top-left-radius:12px;border-top-right-radius:12px} .bt-set-row:last-child{border-bottom-left-radius:12px;border-bottom-right-radius:12px} +/* #892: a plugin's rows in the Members card (Invite by link) sit on the card's own padding, like a row. */ +.bt-flybot-room-members{padding:10px 12px} .bt-set-copy{flex:1;display:flex;flex-direction:column;gap:2px;min-width:0} ++/* #1347: a row's copy never breaks its own label. The label used to be a bare inline span inside a ++ column that flexed down to 36px, so "Name" came out as "Nam" + "e"; a single-word label is now one ++ line, and the hint below it is what wraps when the column is short. */ ++.bt-set-copy>.bt-bs-label,.bt-set-copy>.bt-set-value{white-space:nowrap;overflow:hidden;text-overflow:ellipsis} .bt-set-hint{font-size:12px;line-height:16px;color:color-mix(in srgb,var(--bt-ink-2) 50%,var(--bt-ink-3))} .bt-set-control{flex:none;display:flex;align-items:center;gap:6px} + .bt-set-value{font-size:13px;color:var(--bt-ink-2);text-align:right} @@ -490,8 +490,17 @@ button.bt-set-row:hover{background:var(--bt-hover)} .bt-connector-actions{flex:none;display:flex;gap:6px} .bt-connector-form{display:flex;flex-direction:column;gap:8px;padding-left:48px;animation:bt-q-in ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing},bt-veil-in .2s ease-out} @@ -2699,6 +2788,33 @@ diff --git a/src/client/styles.js b/src/client/styles.js /* Shell chrome reshaped for the team view. Selectors use stable data attributes where the shell offers them and CSS-module name suffixes elsewhere. */ +@@ -543,7 +543,7 @@ button.bt-set-row:hover{background:var(--bt-hover)} + .bt-tool:hover,.bt-tool[aria-expanded=true]{background:var(--bt-hover);color:var(--bt-ink)} + .bt-tool:active{background:var(--bt-active)} + .bt-tool:focus-visible{outline:2px solid var(--bt-accent);outline-offset:-2px} +-.bt-react-strip{position:fixed;z-index:60;pointer-events:auto;display:flex;gap:2px;padding:4px;border-radius:999px;background:var(--bt-main);border:.5px solid var(--bt-line-2);box-shadow:0 10px 20px -3px rgba(0,0,0,.08),0 4px 6px -4px rgba(0,0,0,.06);transform:translate(-50%,-100%);transform-origin:bottom center;animation:bt-pop-lift ${SPRING_TAP.duration}ms ${SPRING_TAP.easing},bt-veil-in .14s ease-out} ++.bt-react-strip{position:fixed;z-index:130;pointer-events:auto;display:flex;gap:2px;padding:4px;border-radius:999px;background:var(--bt-main);border:.5px solid var(--bt-line-2);box-shadow:0 10px 20px -3px rgba(0,0,0,.08),0 4px 6px -4px rgba(0,0,0,.06);transform:translate(-50%,-100%);transform-origin:bottom center;animation:bt-pop-lift ${SPRING_TAP.duration}ms ${SPRING_TAP.easing},bt-veil-in .14s ease-out} + .bt-react-strip button{width:32px;height:32px;border:0;border-radius:50%;background:none;font-size:18px;line-height:1;cursor:pointer;transition:background-color .12s ease,${spring(SPRING_POP, 'transform')};animation:bt-check-pop ${SPRING_POP.duration}ms ${SPRING_POP.easing} backwards} + .bt-react-strip button:nth-child(2){animation-delay:20ms} + .bt-react-strip button:nth-child(3){animation-delay:40ms} +@@ -562,7 +562,7 @@ button:hover .bt-you{background:color-mix(in srgb,var(--bt-ink) 9%,var(--bt-side + .bt-you-wrap{display:inline-flex;padding:0} + .bt-connect-foot{width:100%;height:36px} + +-.bt-menu{position:fixed;pointer-events:auto;z-index:60;min-width:200px;padding:6px;border-radius:12px;background:var(--bt-main);border:.5px solid var(--bt-line-2);box-shadow:0 10px 20px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1),0 0 0 1px rgba(228,228,228,.04);display:flex;flex-direction:column;gap:2px;transform-origin:var(--bt-origin,top left);animation:bt-pop-drop ${SPRING_TAP.duration}ms ${SPRING_TAP.easing},bt-veil-in .14s ease-out;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;box-sizing:border-box} ++.bt-menu{position:fixed;pointer-events:auto;z-index:130;min-width:200px;padding:6px;border-radius:12px;background:var(--bt-main);border:.5px solid var(--bt-line-2);box-shadow:0 10px 20px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1),0 0 0 1px rgba(228,228,228,.04);display:flex;flex-direction:column;gap:2px;transform-origin:var(--bt-origin,top left);animation:bt-pop-drop ${SPRING_TAP.duration}ms ${SPRING_TAP.easing},bt-veil-in .14s ease-out;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;box-sizing:border-box} + .bt-menu button{display:flex;align-items:center;gap:4px;width:100%;min-height:30px;text-align:left;border:0;background:none;border-radius:6px;padding:6px 8px;font:inherit;font-size:13px;line-height:18px;color:var(--bt-ink);cursor:pointer;box-sizing:border-box} + .bt-menu button:hover{background:var(--bt-hover)} + .bt-menu button svg{flex:none;width:16px;height:16px;margin:0 1px} +@@ -572,7 +572,7 @@ button:hover .bt-you{background:color-mix(in srgb,var(--bt-ink) 9%,var(--bt-side + .bt-menu[data-leaving]{pointer-events:none;animation:bt-pop-out .12s cubic-bezier(.4,0,1,1) forwards} + .bt-menu-label{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} + .bt-menu-hint{flex:none;color:var(--bt-ink-3);font-size:12px;font-variant-numeric:tabular-nums} +-.bt-palette-layer{position:fixed;inset:var(--dsh-frame-chrome-top,0px) 0 0;z-index:70;pointer-events:auto;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif} ++.bt-palette-layer{position:fixed;inset:var(--dsh-frame-chrome-top,0px) 0 0;z-index:140;isolation:isolate;pointer-events:auto;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif} + .bt-backdrop{position:absolute;inset:0;background:rgba(0,0,0,.46);animation:bt-fade .22s ease-out} + .bt-palette{position:absolute;top:50%;left:50%;width:min(560px,92vw);transform:translate(-50%,-50%);display:flex;flex-direction:column;border-radius:12px;background:var(--bt-main);border:1px solid var(--bt-line-2);box-shadow:0 24px 48px -12px rgba(0,0,0,.25);overflow:hidden;color:var(--bt-ink);animation:bt-palette-in ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing},bt-veil-in .2s ease-out} + .bt-palette-layer[data-leaving]{pointer-events:none} @@ -609,7 +609,8 @@ button:hover .bt-you{background:color-mix(in srgb,var(--bt-ink) 9%,var(--bt-side .bt-palette-empty-label{font-size:14px;font-weight:500;color:var(--bt-ink-2)} .bt-palette-empty-hint{font-size:13px;line-height:16px;color:var(--bt-ink-3)} @@ -2709,6 +2825,15 @@ diff --git a/src/client/styles.js b/src/client/styles.js .bt-account{flex:none;width:36px;height:36px;border:0;padding:0;background:none;border-radius:50%;cursor:pointer;display:inline-flex} .bt-account:hover .bt-you,.bt-account[aria-expanded=true] .bt-you{background:color-mix(in srgb,var(--bt-ink) 9%,var(--bt-sidebar))} /* Popover keyframes move translate and scale, which compose with a transform the popover +@@ -676,7 +676,7 @@ body[data-ds-dark-theme] .bt-theme-chip i{background:var(--da)} + .bt-mem-row>svg{flex:none;color:var(--bt-ink-2)} + .bt-mem-row-copy{flex:1;text-align:left} + .bt-mem-row-go{display:inline-flex;align-items:center;gap:2px;color:var(--bt-ink-3);font-size:12px} +-.bt-mem-layer{position:fixed;inset:var(--dsh-frame-chrome-top,0px) 0 0;z-index:61;display:flex;align-items:center;justify-content:center;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif} ++.bt-mem-layer{position:fixed;inset:var(--dsh-frame-chrome-top,0px) 0 0;z-index:121;isolation:isolate;display:flex;align-items:center;justify-content:center;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif} + .bt-mem-scrim{animation:bt-fade .2s ease-out} + .bt-mem{position:relative;width:min(680px,calc(100vw - 48px));height:min(760px,calc(100vh - 2*max(32px,var(--dsh-frame-overlay-top,32px))));background:var(--bt-main);border-radius:20px;border:.5px solid var(--bt-line-2);box-shadow:0 24px 80px rgba(0,0,0,.24);display:flex;flex-direction:column;overflow:hidden;color:var(--bt-ink);animation:bt-dialog-rise ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing},bt-veil-in .2s ease-out} + .bt-mem-head{position:relative;display:flex;align-items:center;gap:8px;height:60px;flex:none;padding:0 14px 0 24px;border-bottom:.5px solid var(--bt-line);box-sizing:border-box} @@ -782,6 +782,14 @@ export const CONVERSATION_CSS = ` .bt-think{font-size:13px;line-height:18px;color:var(--bt-ink-2);margin:2px 0 4px;max-width:min(560px,78%)} .bt-think summary{cursor:pointer;list-style:none;display:inline-flex;align-items:center;gap:6px} @@ -2732,7 +2857,7 @@ diff --git a/src/client/styles.js b/src/client/styles.js .bt-group-replies{display:contents} /* A Bot chat is flat in every work-details mode, without changing the user's mode (it is a DSH-wide setting): step groups lose their header and stay open, and what a -@@ -805,21 +805,28 @@ export const CONVERSATION_CSS = ` +@@ -805,21 +805,48 @@ export const CONVERSATION_CSS = ` [data-step-process]>[data-step-process-body]{display:block!important;content-visibility:visible!important;max-height:none!important;overflow:visible!important;mask-image:none!important;scrollbar-gutter:auto!important} :is([data-step-process],[data-turn-process-hidden])[hidden]{content-visibility:visible!important} [class*="_flowItem"]:has(> [data-slot="conversation.chat.node"]:empty){display:none!important} @@ -2754,6 +2879,13 @@ diff --git a/src/client/styles.js b/src/client/styles.js +[class*="_header"] [class*="switcherTitle"]{white-space:normal;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;line-clamp:2;overflow:hidden;text-overflow:ellipsis} +[class*="_header"] [class*="switcherTrigger"]{height:auto;align-items:flex-start} +[class*="_header"] .bt-head .bt-mark{width:22px!important;height:22px!important;--bt-size:22px} ++/* #1347: "Standard mode" beside the title reads as a control — a filled chip with an icon — but the ++ shell draws it as a span with no click handler (dsh-client-ui-agent-preset, AgentPresetLabel): ++ clicking it does nothing, and the only thing behind it is a hover title. In a Bot chat the mode is ++ fixed for the whole Session anyway, so the chip becomes what it is: a status word on the title's ++ line. No fill, no radius, muted ink, and the description stays on hover. */ ++[data-slot="conversation.session.header.actions"] [class*="_label"]{background:none;border-radius:0;color:var(--bt-ink-3);cursor:help;pointer-events:auto} ++[data-slot="conversation.session.header.actions"] [class*="_label"] svg{opacity:.7} [class*="_body"]:has(> [class*="_scrollBody"]){--dsh-chat-content-width:min(960px,100%)} /* DSH's turn tail keeps what plugins put in it (delivered files, the Bot's separators), without its copy and branch row; a tail with nothing in it takes no room. */ @@ -2761,6 +2893,19 @@ diff --git a/src/client/styles.js b/src/client/styles.js -[class*="_flowItem"][data-chat-flow-kind="turn-tail"]:not(:has([data-slot="conversation.chat.turnTail"]:not(:empty))){display:none!important} +[data-turn-tail]>:not([data-slot="conversation.chat.turnTail"]):not(.bt-typing){display:none!important} +[class*="_flowItem"][data-chat-flow-kind="turn-tail"]:not(:has([data-slot="conversation.chat.turnTail"]:not(:empty))):not(:has([data-turn-tail]>.bt-typing)){display:none!important} ++/* #1347: the thread must never paint above a dialog, a sheet or a scrim. ++ The chat column had no stacking context of its own (position:static, z-index:auto), so nothing ++ inside it was boxed in: anything the browser promoted to its own composited layer — an entrance ++ animation (bt-row-in animates a transform), a filter, a will-change, a scroll container's sticky ++ descendant — was ordered against the ROOT stacking context instead of against the column. A ++ dialog's layer is a z-index box at the root, so a promoted bubble could sort above it, which is ++ exactly what the owner's screenshots show: a right-aligned pill at full opacity across the Tasks ++ dialog and over the Inbox scrim, while the same pill sat under it in a plain DOM walk. Giving the ++ column its own stacking context (isolation:isolate) paints every descendant inside it, and its ++ z-index:0 keeps all of that below .bt-pane (45), .bt-panel (40) and the dialog layers at 120/121 ++ whatever any single bubble does. contain:paint is deliberately NOT used: it would clip the ++ popovers the thread legitimately renders (reaction strip, message menu), which portal to body ++ for exactly that reason. */ /* Every team Session belongs to a Bot or a group, so a blank one is an empty chat, not the shell's new-session hero: no headline, no workspace or mode picker, and the composer stays docked at the bottom. */ diff --git a/patches/ds-bot/0005-social-seams.patch b/patches/ds-bot/0005-social-seams.patch index 204e1d754..d3f7e0bc4 100644 --- a/patches/ds-bot/0005-social-seams.patch +++ b/patches/ds-bot/0005-social-seams.patch @@ -277,7 +277,7 @@ diff --git a/src/host/channel.js b/src/host/channel.js +import { isFaceRoom } from './room-membership.js' +import { readQuietHours } from '../shared/quiet-hours.js' +import { faceMentions } from './face-mentions.js' -+import { unwrapUntrusted } from './delivery.js' ++import { visibleText } from '../shared/preview.js' +const NOTIFY_KINDS = ['approval', 'finished', 'failed'] const LANGUAGES = { zh: 'Chinese (简体中文)', en: 'English' } @@ -288,7 +288,7 @@ diff --git a/src/host/channel.js b/src/host/channel.js const invalid = message => Object.assign(new Error(message), { code: 'bot/invalid' }) // Programming and file system errors are DS Bot's own faults, logged with their stack; -@@ -21,32 +21,146 @@ export function install(rt) { +@@ -21,32 +21,148 @@ export function install(rt) { // The DSH interface language of the last browser that asked for the team. let uiLocale @@ -418,7 +418,9 @@ diff --git a/src/host/channel.js b/src/host/channel.js const parts = [...Object.values(sessions ?? {}), ...Object.values(segments ?? {}).flat().map(segment => segment.sessionId)] const last = (log ?? []).filter(line => line.kind === undefined).at(-1) - return { ...room, parts, ...(last ? { preview: last.who === 'User' ? last.text : `${last.who}:${last.text}` } : {}) } -+ const previewText = last ? unwrapUntrusted(last.text) : '' ++ // The row is read by a person: a fence, or the fragment an older write left behind, is never part ++ // of it (#130, #1347). `visibleText` also folds the line to one line, as a row always was. ++ const previewText = last ? visibleText(last.text) : '' + const previewWho = last && (last.authorKind === 'human' && last.who && last.who !== 'User' ? `${last.who}: ${previewText}` : last.who === 'User' ? previewText : `${last.who}:${previewText}`) + const presented = rt.presentRoom?.({ ...room, log }, actor) ?? room + const pending = rt.listRoomApprovals?.(presented, actor) ?? [] diff --git a/patches/ds-bot/0008-client-bundle.patch b/patches/ds-bot/0008-client-bundle.patch index e3a361516..dae616e98 100644 --- a/patches/ds-bot/0008-client-bundle.patch +++ b/patches/ds-bot/0008-client-bundle.patch @@ -361,6 +361,23 @@ diff --git a/client.js b/client.js "Remove {name} from the group": "把 {name} 移出群聊", "Remove from group": "移出群聊", "Add member": "添加成员", +@@ -205,6 +205,16 @@ var ZH = { + "Close details": "关闭详情", + "Copied conversation": "已复制对话", + "Share conversation": "分享对话", ++ "Copy conversation": "复制对话", ++ "Nothing to copy yet": "还没有可复制的内容", ++ "Conversation copied as Markdown": "对话已复制为 Markdown", ++ "Could not copy the conversation": "无法复制对话", ++ "No messages yet. Say something to {name}.": "还没有消息。给 {name} 发点什么吧。", ++ "This model does not support that request. Pick another model in the Bot's settings.": "该模型不支持这项请求。请在 Bot 设置里换一个模型。", ++ "This model does not support {feature}. Pick another model in the Bot's settings.": "该模型不支持 {feature}。请在 Bot 设置里换一个模型。", ++ "The provider does not know this model. Check the model name in the Bot's settings.": "服务方不认识这个模型。请在 Bot 设置里检查模型名称。", ++ "The conversation is longer than this model can read. Condense it, or pick a model with a larger window.": "这段对话超出了该模型能读的长度。请压缩对话,或换一个上下文更大的模型。", ++ "The model service refused the request. Check the model and the endpoint in the Bot's settings.": "模型服务拒绝了这次请求。请在 Bot 设置里检查模型和接口地址。", + "Copy conversation as Markdown": "把对话复制为 Markdown", + "This conversation is not part of your Bot team.": "这个对话不属于你的 Bot 团队。", + "Admin {name}": "管理员 {name}", @@ -247,6 +247,25 @@ var ZH = { "This model is not available now. Pick another one.": "这个模型现在不可用,请换一个。", "Every message of this Bot runs on it.": "这个 Bot 的每条消息都用它。", @@ -1340,7 +1357,7 @@ diff --git a/client.js b/client.js // src/client/inks.js var INKS = { deepseek: ["#4D6BFE", "#6A84FF"], -@@ -1177,15 +1177,92 @@ function defaultShape(id) { +@@ -1177,19 +1177,96 @@ function defaultShape(id) { value = Math.imul(value ^ value >>> 13, 3266489909); return DEFAULT_SHAPES[((value ^ value >>> 16) >>> 0) % DEFAULT_SHAPES.length]; } @@ -1435,6 +1452,11 @@ diff --git a/client.js b/client.js var SHAPES = {}; for (const [id, character] of Object.entries(CHARACTERS)) { const layers = [ +- ...(character.paints ?? []).map(([d, fill2, opacity]) => ``), ++ ...(character.paints ?? []).map(([d, fill3, opacity]) => ``), + ``, + `` + ].join(""); @@ -1206,8 +1206,8 @@ function bodyOf(def, paint, flat = false) { return ""; } @@ -1446,7 +1468,7 @@ diff --git a/client.js b/client.js var maskBox = (size) => `maskUnits="userSpaceOnUse" x="${-size}" y="${-size}" width="${size * 3}" height="${size * 3}"`; var holes = (def, eyes) => `${eyes}`; var MASK_ID = "%MASK%"; -@@ -1242,220 +1242,96 @@ function maskOf(shape) { +@@ -1242,217 +1242,119 @@ function maskOf(shape) { } var DEFAULT_MASK = maskOf(FALLBACK_SHAPE); @@ -1596,7 +1618,17 @@ 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" } -- } ++// src/shared/preview.js ++var FENCE_TAG = /<\s*\/?\s*untrusted-(?:message|file)\b[^<>]*>/gi; ++var FENCE_ATTR = /\bfrom\s*=\s*(?:"[^"]*"|'[^']*')\s*>/gi; ++var FENCE_TAIL = /<[^<>]*\buntrusted-(?:message|file)\b[^<>]*$/i; ++var FENCE_BARE = /(?:\/\s*)?untrusted-(?:message|file)\b\s*>?/gi; ++function stripFence(text) { ++ let out = String(text ?? ""); ++ for (let previous; previous !== out; ) { ++ previous = out; ++ out = out.replace(FENCE_TAG, "").replace(FENCE_ATTR, ""); + } -}; -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) { @@ -1724,32 +1756,33 @@ diff --git a/client.js b/client.js - at = end - 1; - } - return spans; --} -- --// src/client/text.js --var MENTION_ORIGIN = "https://bots.local/"; --function linkMentions(text, bots, selfId) { -- if (!text || !text.includes("@")) return text; -- let out = ""; -- let cursor = 0; -- for (const span of mentionSpans(text, bots)) { -- if (!span.bot || span.bot.id === selfId) continue; -- out += `${text.slice(cursor, span.start)}[${text.slice(span.start + 1, span.end).replace(/[[\]\\]/g, "\\$&")}](${MENTION_ORIGIN}${span.bot.id})`; -- cursor = span.end; -+// src/client/text.js -+var MENTION_ORIGIN = "https://bots.local/"; -+function linkMentions(text, bots, selfId) { -+ if (!text || !text.includes("@")) return text; ++ return out.replace(FENCE_TAIL, "").replace(FENCE_BARE, ""); ++} ++function visibleText(text) { ++ let out = String(text ?? ""); ++ for (let previous; previous !== out; ) { ++ previous = out; ++ const open = out.indexOf(""); ++ const inner = close > open ? out.slice(out.indexOf(">", open) + 1, close) : out; ++ out = stripFence(inner); ++ } ++ return stripFence(out).replace(/\s+/g, " ").trim(); + } + + // src/client/text.js + var MENTION_ORIGIN = "https://bots.local/"; + function linkMentions(text, bots, selfId) { + if (!text || !text.includes("@")) return text; + const ordered = [...bots ?? []].sort((a, b) => Number(Boolean(a?.remote)) - Number(Boolean(b?.remote))); -+ let out = ""; -+ let cursor = 0; + let out = ""; + let cursor = 0; +- for (const span of mentionSpans(text, bots)) { + for (const span of mentionSpans(text, ordered)) { -+ if (!span.bot || span.bot.id === selfId) continue; -+ out += `${text.slice(cursor, span.start)}[${text.slice(span.start + 1, span.end).replace(/[[\]\\]/g, "\\$&")}](${MENTION_ORIGIN}${span.bot.id})`; -+ cursor = span.end; - } - return cursor === 0 ? text : out + text.slice(cursor); - } + if (!span.bot || span.bot.id === selfId) continue; + out += `${text.slice(cursor, span.start)}[${text.slice(span.start + 1, span.end).replace(/[[\]\\]/g, "\\$&")}](${MENTION_ORIGIN}${span.bot.id})`; + cursor = span.end; @@ -1463,6 +1463,20 @@ 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; @@ -1862,11 +1895,11 @@ diff --git a/client.js b/client.js - const text = entry.response || entry.prompt || ""; - if (text !== "") return stripHeader(text).replace(/\s+/g, " ").trim(); + 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)/, ""); ++ if (text !== "") return visibleText(stripTrace(stripHeader(text)).replace(/\s+/g, " ").trim().replace(/^[^\s→]{1,40}(?: → [^\s→]{1,40})+ (?=\S)/, "")); } return ""; } -@@ -1624,3156 +1624,7183 @@ function usageOf(list, ids) { +@@ -1624,3156 +1624,7286 @@ 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 }); } @@ -1971,14 +2004,8 @@ diff --git a/client.js b/client.js + }; }, - stop: () => cancelAnimationFrame(frame) -+ set(next) { -+ const resolved = typeof next === "function" ? next(value) : next; -+ if (resolved === value) return; -+ value = resolved; -+ for (const listener of [...listeners2]) listener(); -+ } - }; - } +- }; +-} -var CHOSEN = '[aria-pressed="true"],[aria-selected="true"],[aria-current="page"]'; -function useGlide(key, { axis = "x", selector = CHOSEN, lead = SPRING_LEAD, trail = SPRING_TRAIL, box: given } = {}) { - const ownBox = (0, import_react.useRef)(null); @@ -2006,7 +2033,12 @@ diff --git a/client.js b/client.js - if (!chosen) { - knob.dataset.hidden = ""; - return; -- } ++ set(next) { ++ const resolved = typeof next === "function" ? next(value) : next; ++ if (resolved === value) return; ++ value = resolved; ++ for (const listener of [...listeners2]) listener(); + } - const start = axis === "x" ? chosen.offsetLeft : chosen.offsetTop; - const end = start + (axis === "x" ? chosen.offsetWidth : chosen.offsetHeight); - const { springs } = own.current; @@ -2017,18 +2049,6 @@ diff --git a/client.js b/client.js - const forward = before === null || start >= before.start; - springs.set({ start, end }, forward ? { start: trail, end: lead } : { start: lead, end: trail }, jump || hidden || before === null || reducedMotion()); - container.dataset.glide = ""; -+var EMPTY_ROSTER = { revision: -1, mainBotId: null, mainBotIds: [], bots: [], rooms: [], humans: [], self: null, agents: [], exchangeTurns: {}, remoteTypedTurns: {}, remoteAwaiting: {}, 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) { -+ setLocalBots(value.bots); -+ setPluginTools(value.pluginTools); -+ const byId = {}; -+ const claim = (bot, id) => { -+ if (id === void 0 || id === null || id === "") return; -+ const prev = byId[id]; -+ if (prev && prev !== bot && !prev.remote && bot?.remote) return; -+ if (prev && prev !== bot && prev.id === id) return; -+ byId[id] = bot; }; - (0, import_react.useLayoutEffect)(() => { - const state = own.current; @@ -2049,19 +2069,7 @@ diff --git a/client.js b/client.js - own.current.springs.stop(); - }, []); - return { box, thumb }; -+ for (const bot of value.bots) { -+ for (const id of [...bot.parts ?? [], bot.sessionId ?? bot.id, bot.id]) claim(bot, id); -+ } -+ 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 { ...value, agents: value.agents ?? [], byId, roomsById, agentsById, agentOf, ready: true }; - } +-} -function Segmented({ label, value, className, children }) { - const glide = useGlide(value); - return (0, import_react.createElement)( @@ -2070,12 +2078,6 @@ diff --git a/client.js b/client.js - (0, import_react.createElement)("span", { ref: glide.thumb, className: "bt-thumb", "aria-hidden": true }), - children - ); -+function rosterBot(roster, id) { -+ if (id === void 0 || id === null || id === "") return void 0; -+ const list = roster?.bots ?? []; -+ const own = list.find((bot) => bot && !bot.remote && (bot.id === id || bot.sessionId === id || (bot.parts ?? []).includes(id))); -+ if (own) return own; -+ return roster?.byId?.[id]; } -function useFlip(list, key, selector, id = (node) => node.dataset.flip) { - const tops = (0, import_react.useRef)(null); @@ -2098,11 +2100,31 @@ diff --git a/client.js b/client.js - } - tops.current = next; - }, [key]); -+function botByName(bots, name) { -+ const wanted = String(name ?? "").toLowerCase(); -+ if (wanted === "") return void 0; -+ const list = Array.isArray(bots) ? bots : []; -+ return list.find((bot) => bot && !bot.remote && String(bot.name ?? "").toLowerCase() === wanted) ?? list.find((bot) => bot && String(bot.name ?? "").toLowerCase() === wanted); ++var EMPTY_ROSTER = { revision: -1, mainBotId: null, mainBotIds: [], bots: [], rooms: [], humans: [], self: null, agents: [], exchangeTurns: {}, remoteTypedTurns: {}, remoteAwaiting: {}, 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) { ++ setLocalBots(value.bots); ++ setPluginTools(value.pluginTools); ++ const byId = {}; ++ const claim = (bot, id) => { ++ if (id === void 0 || id === null || id === "") return; ++ const prev = byId[id]; ++ if (prev && prev !== bot && !prev.remote && bot?.remote) return; ++ if (prev && prev !== bot && prev.id === id) return; ++ byId[id] = bot; ++ }; ++ for (const bot of value.bots) { ++ for (const id of [...bot.parts ?? [], bot.sessionId ?? bot.id, bot.id]) claim(bot, id); ++ } ++ 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 { ...value, agents: value.agents ?? [], byId, roomsById, agentsById, agentOf, ready: true }; } -function useMorph(box, key) { - const last = (0, import_react.useRef)({ key, height: null }); @@ -2127,6 +2149,19 @@ diff --git a/client.js b/client.js - const animation = node.animate([{ height: `${from}px` }, { height: `${to}px` }], { duration: SPRING_SHAPE.duration, easing: SPRING_SHAPE.easing, id: "bt-morph" }); - animation.onfinish = () => { - delete node.dataset.morph; ++function rosterBot(roster, id) { ++ if (id === void 0 || id === null || id === "") return void 0; ++ const list = roster?.bots ?? []; ++ const own = list.find((bot) => bot && !bot.remote && (bot.id === id || bot.sessionId === id || (bot.parts ?? []).includes(id))); ++ if (own) return own; ++ return roster?.byId?.[id]; ++} ++function botByName(bots, name) { ++ const wanted = String(name ?? "").toLowerCase(); ++ if (wanted === "") return void 0; ++ const list = Array.isArray(bots) ? bots : []; ++ return list.find((bot) => bot && !bot.remote && String(bot.name ?? "").toLowerCase() === wanted) ?? list.find((bot) => bot && String(bot.name ?? "").toLowerCase() === wanted); ++} +function isAgentSession(roster, cache, sessionId) { + if (sessionId === void 0 || sessionId === null || sessionId === "") return false; + if (roster.ready) return roster.agentOf[sessionId] !== void 0; @@ -2800,28 +2835,6 @@ diff --git a/client.js b/client.js -.bt-send{height:40px;min-width:40px;border-radius:999px;border:0;background:var(--bt-accent);color:var(--bt-accent-ink,#fff);font-size:14px;padding:0 14px;cursor:pointer} -.bt-send:disabled{opacity:.35;cursor:default} -.bt-error{padding:0 14px 10px;color:#e02135;font-size:12px} -- --.bt-pane{position:fixed;top:var(--dsh-frame-chrome-top,0px);right:0;bottom:0;z-index:45;pointer-events:auto;background:var(--bt-main);color:var(--bt-ink);display:flex;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif} --.bt-dialog{flex:1;display:flex;flex-direction:column;min-width:0} --.bt-dialog .bt-head{height:62px} --.bt-dialog-log{flex:1;overflow-y:auto;padding:8px 16px 20px;width:100%;max-width:var(--dsh-chat-content-width,748px);margin:0 auto;box-sizing:border-box} --.bt-dialog-foot{display:flex;justify-content:center;padding:10px 10px 16px} --.bt-exchange{animation:bt-sheet-in ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing} both,bt-veil-in .24s ease-out both} --.bt-exchange[data-leaving]{animation:bt-sheet-out .22s cubic-bezier(.4,0,1,1) forwards;pointer-events:none} --@keyframes bt-sheet-in{from{transform:translateY(24px)}} --@keyframes bt-sheet-out{to{opacity:0;transform:translateY(16px);filter:blur(4px)}} --.bt-exchange .bt-dialog{position:relative} --.bt-exchange .bt-dialog-log{max-width:none;margin:0;padding:39px max(16px,calc((100% - 960px)/2)) 76px;scrollbar-width:thin} --.bt-exchange .bt-tools{display:none} --.bt-exchange .bt-dialog-foot{position:absolute;left:0;right:0;bottom:0;height:76px;box-sizing:border-box;align-items:flex-end;padding:0 0 28px;background:var(--bt-main);z-index:3;animation:bt-foot-in .3s cubic-bezier(.22,1,.36,1) .3s both} --.bt-exchange .bt-dialog-foot::before{content:"";position:absolute;left:0;right:0;bottom:100%;height:40px;background:linear-gradient(to top,var(--bt-main),transparent);pointer-events:none} --.bt-exchange[data-leaving] .bt-dialog-foot{animation:bt-foot-out .22s cubic-bezier(.4,0,1,1) forwards} --@keyframes bt-foot-in{from{opacity:0;transform:translateY(6px)}} --@keyframes bt-foot-out{to{opacity:0;transform:translateY(6px)}} --@media (prefers-reduced-motion:reduce){.bt-exchange,.bt-exchange .bt-dialog-foot{animation-duration:.01s}} --.bt-soft{height:36px;border-radius:999px;border:0;background:var(--bt-hover);color:var(--bt-ink);padding:0 16px;font-size:14px;cursor:pointer} --.bt-soft:hover{background:var(--bt-active)} --.bt-time{display:block;text-align:center;font-size:12px;line-height:16px;color:var(--bt-ink-2);padding:6px 0} +// src/client/flybot-slots.js +var import_react2 = require("react"); +var call2 = (endpoint, payload) => call(`flybot/${endpoint}`, payload); @@ -2909,11 +2922,38 @@ diff --git a/client.js b/client.js + const room2 = roster?.roomsById?.[sessionId]; + const bot = room2 ? void 0 : roster?.byId?.[sessionId]; + const blank3 = session?.blank === true && session?.promptAttempted !== true; -+ return (0, import_react2.createElement)(import_react2.Fragment, null, entries2.map((entry) => (0, import_react2.createElement)( ++ const botRoom = blank3 && bot !== void 0 && !bot.remote ? bot : null; ++ return (0, import_react2.createElement)( ++ import_react2.Fragment, ++ null, ++ botRoom ? (0, import_react2.createElement)(BotChatStrip, { bot: botRoom, roster, actions, sessionId }) : null, ++ entries2.map((entry) => (0, import_react2.createElement)( ++ "div", ++ { key: entry.id, className: "bt-flybot-session-dock", "data-flybot": entry.id }, ++ (0, import_react2.createElement)(Safe, { entry, props: { sessionId, roster, room: room2, bot, actions, call: call2, t, blank: blank3 === true, claim: (ids) => claimApprovals(`${entry.id}:${sessionId}`, ids) } }) ++ )) ++ ); ++} ++function BotChatStrip({ bot, roster, actions, sessionId }) { ++ const main = isMainOf(roster, bot.id); ++ const subtitle = [bot.role, main ? t("Main Bot") : ""].filter(Boolean).join(" · ") || t("Bot"); ++ return (0, import_react2.createElement)( + "div", -+ { key: entry.id, className: "bt-flybot-session-dock", "data-flybot": entry.id }, -+ (0, import_react2.createElement)(Safe, { entry, props: { sessionId, roster, room: room2, bot, actions, call: call2, t, blank: blank3 === true, claim: (ids) => claimApprovals(`${entry.id}:${sessionId}`, ids) } }) -+ ))); ++ { className: "bt-chat-strip", "data-flybot": "bot-chat-strip" }, ++ (0, import_react2.createElement)(BotAvatar, { bot, size: 44, main, live: false }), ++ (0, import_react2.createElement)( ++ "div", ++ { className: "bt-chat-strip-who" }, ++ (0, import_react2.createElement)("span", { className: "bt-chat-strip-name", title: bot.name }, bot.name), ++ (0, import_react2.createElement)("span", { className: "bt-chat-strip-sub" }, subtitle), ++ (0, import_react2.createElement)("span", { className: "bt-chat-strip-empty" }, t("No messages yet. Say something to {name}.", { name: bot.name })) ++ ), ++ (0, import_react2.createElement)("button", { ++ type: "button", ++ className: "bt-soft bt-soft-sm bt-chat-strip-go", ++ onClick: () => actions?.toggleDetails ? actions.toggleDetails(sessionId) : void 0 ++ }, t("Details")) ++ ); +} +function FlyBotRoomOverview({ room: room2, roster, actions }) { + const entries2 = useSlot("flybot.room.overview"); @@ -3074,12 +3114,71 @@ diff --git a/client.js b/client.js +var fileSize = (n) => n < 1024 ? `${n} B` : n < 1048576 ? `${(n / 1024).toFixed(1)} KB` : `${(n / 1048576).toFixed(1)} MB`; +var FLYBOT_CSS = ` +.bt-flybot-session-dock{width:100%;max-width:min(992px,100%);margin:0 auto;padding:0 0 6px;box-sizing:border-box} ++/* #1347: a Bot chat's own strip before its first turn, the same shape a group's uses (#998): one row ++ above the composer naming the chat, so a blank column is never nameless. */ ++.bt-chat-strip{display:flex;align-items:center;gap:12px;width:100%;max-width:min(992px,100%);margin:0 auto 10px;padding:10px 12px;box-sizing:border-box;border:.5px solid var(--bt-line-2);border-radius:14px;background:var(--bt-card,var(--bt-main));animation:bt-rise .18s ease-out} ++.bt-chat-strip-who{flex:1 1 auto;min-width:0;display:flex;flex-direction:column;gap:2px} ++.bt-chat-strip-name{font-size:15px;line-height:20px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} ++.bt-chat-strip-sub{font-size:12px;line-height:16px;color:var(--bt-ink-3);overflow:hidden;text-overflow:ellipsis;white-space:nowrap} ++.bt-chat-strip-empty{font-size:12px;line-height:16px;color:var(--bt-ink-3);overflow-wrap:anywhere} ++.bt-chat-strip-go{flex:none} ++@media (prefers-reduced-motion:reduce){.bt-chat-strip{animation:none}} +.bt-flybot-session-dock [data-flybot=chat-approvals]{align-items:flex-end;padding-bottom:4px} +.bt-flybot-session-dock .fs-hold{margin-left:auto} +.bt-flybot-approval-files { margin: 0 0 8px; padding-left: 18px; font-size: 12px; } +.bt-file-chips { display: flex; flex-wrap: wrap; gap: 6px; margin: 4px 0 0 30px; } +.bt-file-chip { font-size: 12px; padding: 3px 8px; border-radius: 8px; border: 1px solid var(--bt-line, rgba(0,0,0,.15)); background: transparent; cursor: pointer; } +-.bt-pane{position:fixed;top:var(--dsh-frame-chrome-top,0px);right:0;bottom:0;z-index:45;pointer-events:auto;background:var(--bt-main);color:var(--bt-ink);display:flex;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif} +-.bt-dialog{flex:1;display:flex;flex-direction:column;min-width:0} +-.bt-dialog .bt-head{height:62px} +-.bt-dialog-log{flex:1;overflow-y:auto;padding:8px 16px 20px;width:100%;max-width:var(--dsh-chat-content-width,748px);margin:0 auto;box-sizing:border-box} +-.bt-dialog-foot{display:flex;justify-content:center;padding:10px 10px 16px} +-.bt-exchange{animation:bt-sheet-in ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing} both,bt-veil-in .24s ease-out both} +-.bt-exchange[data-leaving]{animation:bt-sheet-out .22s cubic-bezier(.4,0,1,1) forwards;pointer-events:none} +-@keyframes bt-sheet-in{from{transform:translateY(24px)}} +-@keyframes bt-sheet-out{to{opacity:0;transform:translateY(16px);filter:blur(4px)}} +-.bt-exchange .bt-dialog{position:relative} +-.bt-exchange .bt-dialog-log{max-width:none;margin:0;padding:39px max(16px,calc((100% - 960px)/2)) 76px;scrollbar-width:thin} +-.bt-exchange .bt-tools{display:none} +-.bt-exchange .bt-dialog-foot{position:absolute;left:0;right:0;bottom:0;height:76px;box-sizing:border-box;align-items:flex-end;padding:0 0 28px;background:var(--bt-main);z-index:3;animation:bt-foot-in .3s cubic-bezier(.22,1,.36,1) .3s both} +-.bt-exchange .bt-dialog-foot::before{content:"";position:absolute;left:0;right:0;bottom:100%;height:40px;background:linear-gradient(to top,var(--bt-main),transparent);pointer-events:none} +-.bt-exchange[data-leaving] .bt-dialog-foot{animation:bt-foot-out .22s cubic-bezier(.4,0,1,1) forwards} +-@keyframes bt-foot-in{from{opacity:0;transform:translateY(6px)}} +-@keyframes bt-foot-out{to{opacity:0;transform:translateY(6px)}} +-@media (prefers-reduced-motion:reduce){.bt-exchange,.bt-exchange .bt-dialog-foot{animation-duration:.01s}} +-.bt-soft{height:36px;border-radius:999px;border:0;background:var(--bt-hover);color:var(--bt-ink);padding:0 16px;font-size:14px;cursor:pointer} +-.bt-soft:hover{background:var(--bt-active)} +-.bt-time{display:block;text-align:center;font-size:12px;line-height:16px;color:var(--bt-ink-2);padding:6px 0} ++.bt-flybot-section { margin-top: 8px; border-top: 1px solid var(--bt-line, rgba(0,0,0,.08)); padding-top: 6px; } ++/* A section whose component renders null leaves the wrapper empty, and an empty wrapper must not draw ++ the separator: #818's "no border" held for the component but not for the box around it. Keyed on ++ emptiness, not on a section id, so the next null-rendering section is covered too. */ ++.bt-flybot-section:empty { margin-top: 0; border-top: 0; padding-top: 0; } ++.bt-flybot-approvals { position: fixed; right: 16px; bottom: 16px; z-index: 50; display: flex; flex-direction: column; gap: 8px; max-width: 360px; max-height: calc(100vh - 32px); overflow-y: auto; } ++.bt-flybot-approval-error { color: var(--bt-danger, #d33); font-size: 12px; } ++.bt-flybot-approval { background: var(--bt-card, #fff); border: 1px solid var(--bt-line, rgba(0,0,0,.12)); border-radius: 10px; padding: 10px 12px; box-shadow: 0 4px 16px rgba(0,0,0,.12); font-size: 13px; } ++.bt-flybot-approval-head { font-weight: 600; margin-bottom: 4px; } ++.bt-flybot-approval-origin { font-size: 12px; opacity: .7; margin-bottom: 4px; } ++.bt-flybot-approval-text { white-space: pre-wrap; max-height: 120px; overflow: auto; margin-bottom: 8px; } ++.bt-flybot-approval-path { font-family: ui-monospace, monospace; font-size: 11px; opacity: .7; overflow-wrap: anywhere; } ++.bt-flybot-approval-actions { display: flex; gap: 8px; justify-content: flex-end; } ++.bt-flybot-approve { font-weight: 600; } ++.bt-held-reply[data-state=pending] .bt-bubble { background: transparent; border: 1px dashed var(--bt-line-2); color: var(--bt-ink-2); } ++.bt-held-reply[data-state=rejected] .bt-bubble, .bt-held-reply[data-state=failed] .bt-bubble { background: transparent; border: 1px dashed var(--bt-line-2); color: var(--bt-ink-3); } ++.bt-held-actions { margin: 6px 0 2px; max-width: 420px; } ++.bt-held-card { background: var(--bt-card, #fff); border: 1px solid var(--bt-line, rgba(0,0,0,.12)); border-radius: 10px; padding: 8px 10px; font-size: 13px; } ++.bt-held-head { font-weight: 600; margin-bottom: 2px; } ++.bt-held-note { margin: 2px 0 0; font-size: 12px; color: var(--bt-ink-3); display: flex; gap: 8px; align-items: center; } ++.bt-held-note[data-state=failed] { color: var(--bt-danger, #d33); } ++.bt-held-retry { border: 0; background: transparent; color: var(--bt-accent, #2563eb); font-size: 12px; cursor: pointer; padding: 0; } ++.bt-flybot-pointers { position: fixed; top: 12px; right: 16px; z-index: 50; display: flex; flex-direction: column; gap: 6px; align-items: flex-end; max-width: 360px; } ++.bt-flybot-pointer { display: flex; align-items: center; gap: 8px; background: var(--bt-card, #fff); border: 1px solid var(--bt-line, rgba(0,0,0,.12)); border-radius: 999px; padding: 5px 6px 5px 12px; box-shadow: 0 2px 10px rgba(0,0,0,.1); font-size: 12px; } ++.bt-flybot-pointer-text { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 200px; } ++.bt-flybot-pointer-note { opacity: .65; white-space: nowrap; } ++.bt-flybot-pointer button { border: 0; border-radius: 999px; padding: 3px 10px; font-size: 12px; cursor: pointer; background: var(--bt-accent, #2563eb); color: #fff; } ++`; + -.bt-panel{position:fixed;top:var(--dsh-frame-chrome-top,0px);right:0;bottom:0;width:var(--bt-panel-w,320px);pointer-events:auto;z-index:40;background:var(--bt-main);border-left:.5px solid var(--bt-line-2);display:flex;flex-direction:column;color:var(--bt-ink);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;box-sizing:border-box;animation:bt-pane-in .3s cubic-bezier(.2,0,0,1);transition:width ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing}} -.bt-panel[data-leaving]{transform:translateX(100%);transition:transform .3s cubic-bezier(.2,0,0,1);pointer-events:none} -.bt-panel[data-instant]{transition:none} @@ -3114,34 +3213,32 @@ diff --git a/client.js b/client.js -.bt-kv{display:flex;justify-content:space-between;gap:8px;font-size:12px;line-height:20px;color:var(--bt-ink-2)} -.bt-kv span:last-child{color:var(--bt-ink);font-variant-numeric:tabular-nums} -.bt-danger{color:#e02135} -+.bt-flybot-section { margin-top: 8px; border-top: 1px solid var(--bt-line, rgba(0,0,0,.08)); padding-top: 6px; } -+/* A section whose component renders null leaves the wrapper empty, and an empty wrapper must not draw -+ the separator: #818's "no border" held for the component but not for the box around it. Keyed on -+ emptiness, not on a section id, so the next null-rendering section is covered too. */ -+.bt-flybot-section:empty { margin-top: 0; border-top: 0; padding-top: 0; } -+.bt-flybot-approvals { position: fixed; right: 16px; bottom: 16px; z-index: 50; display: flex; flex-direction: column; gap: 8px; max-width: 360px; max-height: calc(100vh - 32px); overflow-y: auto; } -+.bt-flybot-approval-error { color: var(--bt-danger, #d33); font-size: 12px; } -+.bt-flybot-approval { background: var(--bt-card, #fff); border: 1px solid var(--bt-line, rgba(0,0,0,.12)); border-radius: 10px; padding: 10px 12px; box-shadow: 0 4px 16px rgba(0,0,0,.12); font-size: 13px; } -+.bt-flybot-approval-head { font-weight: 600; margin-bottom: 4px; } -+.bt-flybot-approval-origin { font-size: 12px; opacity: .7; margin-bottom: 4px; } -+.bt-flybot-approval-text { white-space: pre-wrap; max-height: 120px; overflow: auto; margin-bottom: 8px; } -+.bt-flybot-approval-path { font-family: ui-monospace, monospace; font-size: 11px; opacity: .7; overflow-wrap: anywhere; } -+.bt-flybot-approval-actions { display: flex; gap: 8px; justify-content: flex-end; } -+.bt-flybot-approve { font-weight: 600; } -+.bt-held-reply[data-state=pending] .bt-bubble { background: transparent; border: 1px dashed var(--bt-line-2); color: var(--bt-ink-2); } -+.bt-held-reply[data-state=rejected] .bt-bubble, .bt-held-reply[data-state=failed] .bt-bubble { background: transparent; border: 1px dashed var(--bt-line-2); color: var(--bt-ink-3); } -+.bt-held-actions { margin: 6px 0 2px; max-width: 420px; } -+.bt-held-card { background: var(--bt-card, #fff); border: 1px solid var(--bt-line, rgba(0,0,0,.12)); border-radius: 10px; padding: 8px 10px; font-size: 13px; } -+.bt-held-head { font-weight: 600; margin-bottom: 2px; } -+.bt-held-note { margin: 2px 0 0; font-size: 12px; color: var(--bt-ink-3); display: flex; gap: 8px; align-items: center; } -+.bt-held-note[data-state=failed] { color: var(--bt-danger, #d33); } -+.bt-held-retry { border: 0; background: transparent; color: var(--bt-accent, #2563eb); font-size: 12px; cursor: pointer; padding: 0; } -+.bt-flybot-pointers { position: fixed; top: 12px; right: 16px; z-index: 50; display: flex; flex-direction: column; gap: 6px; align-items: flex-end; max-width: 360px; } -+.bt-flybot-pointer { display: flex; align-items: center; gap: 8px; background: var(--bt-card, #fff); border: 1px solid var(--bt-line, rgba(0,0,0,.12)); border-radius: 999px; padding: 5px 6px 5px 12px; box-shadow: 0 2px 10px rgba(0,0,0,.1); font-size: 12px; } -+.bt-flybot-pointer-text { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 200px; } -+.bt-flybot-pointer-note { opacity: .65; white-space: nowrap; } -+.bt-flybot-pointer button { border: 0; border-radius: 999px; padding: 3px 10px; font-size: 12px; cursor: pointer; background: var(--bt-accent, #2563eb); color: #fff; } -+`; ++// src/shared/failure-copy.js ++var providerCode = (text) => { ++ const json = /"code"\s*:\s*"([A-Za-z0-9_.-]+)"/.exec(text); ++ if (json) return json[1]; ++ const plain = /^([A-Za-z][A-Za-z0-9_.-]{2,40}):\s/.exec(text); ++ return plain ? plain[1] : ""; ++}; ++var KNOWN = { ++ UnsupportedModel: "This model does not support that request. Pick another model in the Bot's settings.", ++ ModelNotFound: "The provider does not know this model. Check the model name in the Bot's settings.", ++ ContextLengthExceeded: "The conversation is longer than this model can read. Condense it, or pick a model with a larger window.", ++ InvalidRequest: "The model service refused the request. Check the model and the endpoint in the Bot's settings.", ++ InsufficientQuota: "The account is out of quota.", ++ RateLimitExceeded: "Too many requests right now. Try again in a moment." ++}; ++var fill2 = (text, params) => params === void 0 ? text : String(text).replace(/\{(\w+)\}/g, (match, name) => name in params ? String(params[name]) : match); ++function failureSentence(raw, translate = (key) => key) { ++ const text = String(raw ?? "").trim(); ++ if (text === "") return text; ++ const key = KNOWN[providerCode(text)]; ++ if (key === void 0) return text; ++ const feature = /does not support\s+([a-z][a-z0-9 _-]{2,40})/i.exec(text)?.[1]?.trim(); ++ if (key !== KNOWN.UnsupportedModel || feature === void 0) return fill2(translate(key), void 0); ++ const withFeature = "This model does not support {feature}. Pick another model in the Bot's settings."; ++ return fill2(translate(withFeature, { feature }), { feature }); ++} -/* Bot settings: fields, the avatar editor popover, and the details-pane sub-view. */ -.bt-field{display:flex;flex-direction:column;gap:6px} @@ -3199,7 +3296,9 @@ diff --git a/client.js b/client.js + const ended = Array.isArray(snapshot?.ended) ? snapshot.ended : []; + return ended.filter((entry) => entry?.silent === true && (entry.roomId ?? entry.sessionId) === place).slice(-limit).map((entry) => ({ + id: `turn-failure-${entry.id}`, -+ text: t2("Could not run: {error}", { error: entry.failed ?? t2("the model call failed") }), ++ // The stored text can be a provider's JSON body cut mid-sentence; `failureSentence` says the ++ // same fact in words that name what to change (#1347). ++ text: t2("Could not run: {error}", { error: entry.failed === void 0 ? t2("the model call failed") : failureSentence(entry.failed, t2) }), + ...Number(entry.streak) > 1 ? { streak: Number(entry.streak) } : {} + })); +}; @@ -4274,11 +4373,11 @@ diff --git a/client.js b/client.js +.bt-soft:hover{background:var(--bt-active)} +.bt-time{display:block;text-align:center;font-size:12px;line-height:16px;color:var(--bt-ink-2);padding:6px 0} + -+.bt-panel{position:fixed;top:var(--dsh-frame-chrome-top,0px);right:0;bottom:0;width:var(--bt-panel-w,320px);pointer-events:auto;z-index:40;background:var(--bt-main);border-left:.5px solid var(--bt-line-2);display:flex;flex-direction:column;color:var(--bt-ink);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;box-sizing:border-box;animation:bt-pane-in .3s cubic-bezier(.2,0,0,1);transition:width ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing}} ++.bt-panel{position:fixed;top:var(--dsh-frame-chrome-top,0px);right:0;bottom:0;width:var(--bt-panel-w,320px);pointer-events:auto;z-index:40;background:var(--bt-main);border-left:.5px solid var(--bt-line-2);display:flex;flex-direction:column;color:var(--bt-ink);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;box-sizing:border-box;animation:bt-pane-in .3s cubic-bezier(.2,0,0,1);transition:width ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing};container:bt-panel/inline-size} +.bt-panel[data-leaving]{transform:translateX(100%);transition:transform .3s cubic-bezier(.2,0,0,1);pointer-events:none} +.bt-panel[data-instant]{transition:none} +@keyframes bt-pane-in{from{transform:translateX(100%)}} -+[class*="_centerCol"]{transition:margin-right ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing}} ++[class*="_centerCol"]{transition:margin-right ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing};position:relative;z-index:0;isolation:isolate} +.bt-panel-grip{position:absolute;left:-5px;top:0;bottom:0;width:8px;cursor:col-resize;z-index:3;touch-action:none} +.bt-panel-grip:hover,.bt-panel[data-dragging] .bt-panel-grip{background:linear-gradient(to right,transparent 4px,var(--bt-line-2) 4px,var(--bt-line-2) 6px,transparent 6px)} +.bt-drag-shield{position:fixed;inset:0;z-index:45;cursor:col-resize} @@ -4323,7 +4422,7 @@ diff --git a/client.js b/client.js +.bt-avatar-anchor{position:relative} +.bt-avatar-trigger{position:relative;width:64px;height:64px;border:0;border-radius:50%;background:none;cursor:pointer;padding:0} +.bt-avatar-pencil{position:absolute;right:-2px;bottom:-2px;width:22px;height:22px;border-radius:50%;background:var(--bt-main);border:1px solid var(--bt-line-2);color:var(--bt-ink-2);display:flex;align-items:center;justify-content:center;box-shadow:0 1px 4px rgba(0,0,0,.12)} -+.bt-avedit{position:absolute;top:calc(100% + 8px);left:50%;transform:translateX(-50%);width:300px;background:var(--bt-main);border:1px solid var(--bt-line);border-radius:12px;box-shadow:0 12px 40px rgba(0,0,0,.18);padding:10px;z-index:70;display:flex;flex-direction:column;gap:10px;color:var(--bt-ink);transform-origin:top center;animation:bt-pop-drop ${SPRING_TAP.duration}ms ${SPRING_TAP.easing},bt-veil-in .16s ease-out} ++.bt-avedit{position:absolute;top:calc(100% + 8px);left:50%;transform:translateX(-50%);width:300px;background:var(--bt-main);border:1px solid var(--bt-line);border-radius:12px;box-shadow:0 12px 40px rgba(0,0,0,.18);padding:10px;z-index:130;display:flex;flex-direction:column;gap:10px;color:var(--bt-ink);transform-origin:top center;animation:bt-pop-drop ${SPRING_TAP.duration}ms ${SPRING_TAP.easing},bt-veil-in .16s ease-out} +.bt-bs .bt-avedit{transform-origin:32px 0} +.bt-avedit-top{display:flex;align-items:center;justify-content:space-between} +.bt-avedit-tabs{position:relative;display:flex;gap:2px;background:var(--bt-hover);border-radius:999px;padding:2px} @@ -4363,9 +4462,23 @@ diff --git a/client.js b/client.js +.bt-seg button.bt-perm-danger,.bt-seg button.bt-perm-danger[aria-pressed=true]{color:#e02135} + +/* Settings dialog: a 198px page list beside the page. */ -+.bt-settings-layer{position:fixed;inset:var(--dsh-frame-chrome-top,0px) 0 0;z-index:60;display:flex;align-items:center;justify-content:center;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif} ++/* #1347: dialogs, sheets and scrims sit in a tier of their own, well clear of everything the ++ chat surface may do. They used to share 60/61 with the context menu, the reaction strip and the ++ remote-Bot sheet, so any body-level popover opened after them tied on z-index and won on DOM ++ order — and a chat element with a stray z-index could reach the same number. The tier is now ++ explicit: chat surface at most 50, dialogs 120/121, popovers 130, palette 140, toast 1000. The ++ chat column is additionally capped at z-index 0 inside its own stacking context (see the ++ center-column rule below), so nothing in the thread can outrank these whatever it does. */ ++.bt-settings-layer{position:fixed;inset:var(--dsh-frame-chrome-top,0px) 0 0;z-index:120;isolation:isolate;display:flex;align-items:center;justify-content:center;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif} +.bt-scrim{position:absolute;inset:0;background:rgba(0,0,0,.32);animation:bt-fade .22s ease-out} +body[data-ds-dark-theme] .bt-scrim{background:rgba(0,0,0,.5)} ++/* #1347: a modal is a modal. A 0.32 scrim leaves the thread legible behind it, and the owner read that ++ as the chat painting through the dialog ("assistant text readable across columns", a pill visible at ++ full opacity behind the Inbox). The scrim now separates the two surfaces outright: the thread is ++ dimmed to a backdrop, not left readable beside the thing being decided. It is still one layer and ++ still dismisses on a click, so nothing else about it changes. */ ++body:not([data-ds-dark-theme]) .bt-scrim{background:rgba(12,12,14,.62)} ++body[data-ds-dark-theme] .bt-scrim{background:rgba(0,0,0,.7)} +.bt-settings{--bt-set-w:min(800px,calc(100vw - 48px));--bt-set-h:min(620px,calc(100vh - 2*max(32px,var(--dsh-frame-overlay-top,32px))));position:relative;width:var(--bt-set-w);height:var(--bt-set-h);background:var(--bt-main);border-radius:16px;border:.5px solid var(--bt-line-2);box-shadow:0 24px 80px rgba(0,0,0,.24);display:flex;overflow:hidden;color:var(--bt-ink);transition:${spring(SPRING_SHAPE, "width", "height")};animation:bt-dialog-rise ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing},bt-veil-in .2s ease-out} +.bt-settings[data-wide]{--bt-set-w:min(1100px,calc(100vw - 48px));--bt-set-h:min(780px,calc(100vh - 2*max(32px,var(--dsh-frame-overlay-top,32px))))} +:is(.bt-settings-layer,.bt-mem-layer)[data-leaving]{pointer-events:none} @@ -4418,6 +4531,10 @@ diff --git a/client.js b/client.js +/* #892: a plugin's rows in the Members card (Invite by link) sit on the card's own padding, like a row. */ +.bt-flybot-room-members{padding:10px 12px} +.bt-set-copy{flex:1;display:flex;flex-direction:column;gap:2px;min-width:0} ++/* #1347: a row's copy never breaks its own label. The label used to be a bare inline span inside a ++ column that flexed down to 36px, so "Name" came out as "Nam" + "e"; a single-word label is now one ++ line, and the hint below it is what wraps when the column is short. */ ++.bt-set-copy>.bt-bs-label,.bt-set-copy>.bt-set-value{white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.bt-set-hint{font-size:12px;line-height:16px;color:color-mix(in srgb,var(--bt-ink-2) 50%,var(--bt-ink-3))} +.bt-set-control{flex:none;display:flex;align-items:center;gap:6px} +.bt-set-value{font-size:13px;color:var(--bt-ink-2);text-align:right} @@ -4499,7 +4616,7 @@ diff --git a/client.js b/client.js +.bt-tool:hover,.bt-tool[aria-expanded=true]{background:var(--bt-hover);color:var(--bt-ink)} +.bt-tool:active{background:var(--bt-active)} +.bt-tool:focus-visible{outline:2px solid var(--bt-accent);outline-offset:-2px} -+.bt-react-strip{position:fixed;z-index:60;pointer-events:auto;display:flex;gap:2px;padding:4px;border-radius:999px;background:var(--bt-main);border:.5px solid var(--bt-line-2);box-shadow:0 10px 20px -3px rgba(0,0,0,.08),0 4px 6px -4px rgba(0,0,0,.06);transform:translate(-50%,-100%);transform-origin:bottom center;animation:bt-pop-lift ${SPRING_TAP.duration}ms ${SPRING_TAP.easing},bt-veil-in .14s ease-out} ++.bt-react-strip{position:fixed;z-index:130;pointer-events:auto;display:flex;gap:2px;padding:4px;border-radius:999px;background:var(--bt-main);border:.5px solid var(--bt-line-2);box-shadow:0 10px 20px -3px rgba(0,0,0,.08),0 4px 6px -4px rgba(0,0,0,.06);transform:translate(-50%,-100%);transform-origin:bottom center;animation:bt-pop-lift ${SPRING_TAP.duration}ms ${SPRING_TAP.easing},bt-veil-in .14s ease-out} +.bt-react-strip button{width:32px;height:32px;border:0;border-radius:50%;background:none;font-size:18px;line-height:1;cursor:pointer;transition:background-color .12s ease,${spring(SPRING_POP, "transform")};animation:bt-check-pop ${SPRING_POP.duration}ms ${SPRING_POP.easing} backwards} +.bt-react-strip button:nth-child(2){animation-delay:20ms} +.bt-react-strip button:nth-child(3){animation-delay:40ms} @@ -4518,7 +4635,7 @@ diff --git a/client.js b/client.js +.bt-you-wrap{display:inline-flex;padding:0} +.bt-connect-foot{width:100%;height:36px} + -+.bt-menu{position:fixed;pointer-events:auto;z-index:60;min-width:200px;padding:6px;border-radius:12px;background:var(--bt-main);border:.5px solid var(--bt-line-2);box-shadow:0 10px 20px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1),0 0 0 1px rgba(228,228,228,.04);display:flex;flex-direction:column;gap:2px;transform-origin:var(--bt-origin,top left);animation:bt-pop-drop ${SPRING_TAP.duration}ms ${SPRING_TAP.easing},bt-veil-in .14s ease-out;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;box-sizing:border-box} ++.bt-menu{position:fixed;pointer-events:auto;z-index:130;min-width:200px;padding:6px;border-radius:12px;background:var(--bt-main);border:.5px solid var(--bt-line-2);box-shadow:0 10px 20px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1),0 0 0 1px rgba(228,228,228,.04);display:flex;flex-direction:column;gap:2px;transform-origin:var(--bt-origin,top left);animation:bt-pop-drop ${SPRING_TAP.duration}ms ${SPRING_TAP.easing},bt-veil-in .14s ease-out;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;box-sizing:border-box} +.bt-menu button{display:flex;align-items:center;gap:4px;width:100%;min-height:30px;text-align:left;border:0;background:none;border-radius:6px;padding:6px 8px;font:inherit;font-size:13px;line-height:18px;color:var(--bt-ink);cursor:pointer;box-sizing:border-box} +.bt-menu button:hover{background:var(--bt-hover)} +.bt-menu button svg{flex:none;width:16px;height:16px;margin:0 1px} @@ -4528,7 +4645,7 @@ diff --git a/client.js b/client.js +.bt-menu[data-leaving]{pointer-events:none;animation:bt-pop-out .12s cubic-bezier(.4,0,1,1) forwards} +.bt-menu-label{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.bt-menu-hint{flex:none;color:var(--bt-ink-3);font-size:12px;font-variant-numeric:tabular-nums} -+.bt-palette-layer{position:fixed;inset:var(--dsh-frame-chrome-top,0px) 0 0;z-index:70;pointer-events:auto;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif} ++.bt-palette-layer{position:fixed;inset:var(--dsh-frame-chrome-top,0px) 0 0;z-index:140;isolation:isolate;pointer-events:auto;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif} +.bt-backdrop{position:absolute;inset:0;background:rgba(0,0,0,.46);animation:bt-fade .22s ease-out} +.bt-palette{position:absolute;top:50%;left:50%;width:min(560px,92vw);transform:translate(-50%,-50%);display:flex;flex-direction:column;border-radius:12px;background:var(--bt-main);border:1px solid var(--bt-line-2);box-shadow:0 24px 48px -12px rgba(0,0,0,.25);overflow:hidden;color:var(--bt-ink);animation:bt-palette-in ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing},bt-veil-in .2s ease-out} +.bt-palette-layer[data-leaving]{pointer-events:none} @@ -4633,7 +4750,7 @@ diff --git a/client.js b/client.js +.bt-mem-row>svg{flex:none;color:var(--bt-ink-2)} +.bt-mem-row-copy{flex:1;text-align:left} +.bt-mem-row-go{display:inline-flex;align-items:center;gap:2px;color:var(--bt-ink-3);font-size:12px} -+.bt-mem-layer{position:fixed;inset:var(--dsh-frame-chrome-top,0px) 0 0;z-index:61;display:flex;align-items:center;justify-content:center;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif} ++.bt-mem-layer{position:fixed;inset:var(--dsh-frame-chrome-top,0px) 0 0;z-index:121;isolation:isolate;display:flex;align-items:center;justify-content:center;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif} +.bt-mem-scrim{animation:bt-fade .2s ease-out} +.bt-mem{position:relative;width:min(680px,calc(100vw - 48px));height:min(760px,calc(100vh - 2*max(32px,var(--dsh-frame-overlay-top,32px))));background:var(--bt-main);border-radius:20px;border:.5px solid var(--bt-line-2);box-shadow:0 24px 80px rgba(0,0,0,.24);display:flex;flex-direction:column;overflow:hidden;color:var(--bt-ink);animation:bt-dialog-rise ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing},bt-veil-in .2s ease-out} +.bt-mem-head{position:relative;display:flex;align-items:center;gap:8px;height:60px;flex:none;padding:0 14px 0 24px;border-bottom:.5px solid var(--bt-line);box-sizing:border-box} @@ -4780,11 +4897,31 @@ diff --git a/client.js b/client.js +[class*="_header"] [class*="switcherTitle"]{white-space:normal;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;line-clamp:2;overflow:hidden;text-overflow:ellipsis} +[class*="_header"] [class*="switcherTrigger"]{height:auto;align-items:flex-start} +[class*="_header"] .bt-head .bt-mark{width:22px!important;height:22px!important;--bt-size:22px} ++/* #1347: "Standard mode" beside the title reads as a control — a filled chip with an icon — but the ++ shell draws it as a span with no click handler (dsh-client-ui-agent-preset, AgentPresetLabel): ++ clicking it does nothing, and the only thing behind it is a hover title. In a Bot chat the mode is ++ fixed for the whole Session anyway, so the chip becomes what it is: a status word on the title's ++ line. No fill, no radius, muted ink, and the description stays on hover. */ ++[data-slot="conversation.session.header.actions"] [class*="_label"]{background:none;border-radius:0;color:var(--bt-ink-3);cursor:help;pointer-events:auto} ++[data-slot="conversation.session.header.actions"] [class*="_label"] svg{opacity:.7} +[class*="_body"]:has(> [class*="_scrollBody"]){--dsh-chat-content-width:min(960px,100%)} +/* DSH's turn tail keeps what plugins put in it (delivered files, the Bot's separators), + without its copy and branch row; a tail with nothing in it takes no room. */ +[data-turn-tail]>:not([data-slot="conversation.chat.turnTail"]):not(.bt-typing){display:none!important} +[class*="_flowItem"][data-chat-flow-kind="turn-tail"]:not(:has([data-slot="conversation.chat.turnTail"]:not(:empty))):not(:has([data-turn-tail]>.bt-typing)){display:none!important} ++/* #1347: the thread must never paint above a dialog, a sheet or a scrim. ++ The chat column had no stacking context of its own (position:static, z-index:auto), so nothing ++ inside it was boxed in: anything the browser promoted to its own composited layer — an entrance ++ animation (bt-row-in animates a transform), a filter, a will-change, a scroll container's sticky ++ descendant — was ordered against the ROOT stacking context instead of against the column. A ++ dialog's layer is a z-index box at the root, so a promoted bubble could sort above it, which is ++ exactly what the owner's screenshots show: a right-aligned pill at full opacity across the Tasks ++ dialog and over the Inbox scrim, while the same pill sat under it in a plain DOM walk. Giving the ++ column its own stacking context (isolation:isolate) paints every descendant inside it, and its ++ z-index:0 keeps all of that below .bt-pane (45), .bt-panel (40) and the dialog layers at 120/121 ++ whatever any single bubble does. contain:paint is deliberately NOT used: it would clip the ++ popovers the thread legitimately renders (reaction strip, message menu), which portal to body ++ for exactly that reason. */ +/* Every team Session belongs to a Bot or a group, so a blank one is an empty chat, not + the shell's new-session hero: no headline, no workspace or mode picker, and the + composer stays docked at the bottom. */ @@ -7393,14 +7530,14 @@ diff --git a/client.js b/client.js + } + if (reply.kind === "stopped") { + return (0, import_react9.createElement)("div", { key: `${reply.botId}-${index}`, className: "bt-event" }, t("You stopped {name}'s work", { name: bot.name })); -+ } + } +- const bot = people[index]; +- return (0, import_react2.createElement)("span", { key: bot?.id ?? "you", style }, seat(bot, s)); + if (reply.kind === "notice" && room2?.origin === "face-to-face" && String(reply.text ?? "").trim() === WAITING_OTHERS_NOTICE) return null; + if ((reply.kind === "notice" || reply.kind === "left") && leaveNoticeCoveredByClose(room2, reply.text)) return null; + if (reply.kind === "notice" || reply.kind === "joined" || reply.kind === "left" || reply.kind === "started") { + return (0, import_react9.createElement)("div", { key: `notice-${index}`, className: "bt-event", "data-flybot": "face-notice" }, remoteMessageText(reply.text)); - } -- const bot = people[index]; -- return (0, import_react2.createElement)("span", { key: bot?.id ?? "you", style }, seat(bot, s)); ++ } + const remote = reply.remote || reply.handle && !roster.byId[reply.botId]; + if (remote) { + return (0, import_react9.createElement)(RemoteMessage, { @@ -9451,7 +9588,7 @@ diff --git a/client.js b/client.js + ), + (0, import_react13.createElement)("span", { className: "bt-inbox-title", title: card.title }, card.title), + card.calls > 0 ? (0, import_react13.createElement)("span", { className: "bt-inbox-calls" }, t("{count} tool calls", { count: card.calls })) : null, -+ card.reason ? (0, import_react13.createElement)("span", { className: "bt-inbox-reason", title: card.reason }, card.reason) : null, ++ card.reason ? (0, import_react13.createElement)("span", { className: "bt-inbox-reason", title: card.reason }, failureSentence(card.reason, t)) : null, + card.activity ? (0, import_react13.createElement)("span", { className: "bt-inbox-activity", title: card.activity }, t("Latest: {text}", { text: card.activity })) : null, + card.hint ? (0, import_react13.createElement)("span", { className: "bt-inbox-hint" }, card.hint) : null, + evidence.length > 0 ? (0, import_react13.createElement)("span", { className: "bt-inbox-evidence" }, ...evidence.map((href, index) => (0, import_react13.createElement)("a", { @@ -11786,7 +11923,7 @@ diff --git a/client.js b/client.js + (0, import_react17.createElement)(DeliveryView, { actions, tasks, roster, dragging, onMove, onDrag }) + ) + ); -+} + } +function KanbanDialog({ roster, actions, leaving = false }) { + const rail = boardRail(roster); + const [scope, setScope] = (0, import_react17.useState)(() => rail.bots[0] ? { kind: "bot", botId: rail.bots[0].id } : rail.groups[0] ? { kind: "group", roomId: rail.groups[0].id } : null); @@ -11860,7 +11997,7 @@ diff --git a/client.js b/client.js + }) + ) + ); - } ++} +var KANBAN_CSS = ` +.bt-board-row{min-height:40px;padding:6px 8px;gap:10px} +.bt-board-views{display:flex;gap:6px;align-items:center} @@ -12104,7 +12241,7 @@ diff --git a/client.js b/client.js - const fallback = useSessions((list) => previewOf(list, id)); - const preview = room2.preview?.replace(/\s+/g, " ").trim() || fallback; + const fallback = useSessions((list) => nameMentions(previewOf(list, id), roster)); -+ const preview = nameMentions(room2.preview?.replace(/\s+/g, " ").trim(), roster) || fallback; ++ const preview = nameMentions(visibleText(room2.preview), roster) || fallback; const active = useSessionRetainInfo(id, (info) => (info?.retainedBy?.mainView ?? 0) > 0); const unread = useSessionStatus((map) => map.get(id)?.completionUnread === true) || marked && !active; useMarkedUnread(id, active, marked, actions); @@ -12731,6 +12868,15 @@ diff --git a/client.js b/client.js } var SIDEBAR_CSS = ` .bt-side-top .bt-brand{cursor:default;padding-left:6px} +@@ -5456,7 +5456,7 @@ var SIDEBAR_CSS = ` + .bt-update[data-phase=installing] .bt-cloud-arrow{animation:bt-cloud-drop 1.1s cubic-bezier(.45,0,.55,1) infinite} + @keyframes bt-cloud-drop{0%{transform:translateY(-3px);opacity:0}35%{opacity:1}70%{transform:translateY(1px);opacity:1}100%{transform:translateY(2px);opacity:0}} + .bt-update-dot{position:absolute;top:6px;right:6px;width:7px;height:7px;border-radius:50%;background:#E5484D;box-shadow:0 0 0 1.5px var(--bt-sidebar)} +-.bt-update-pop{position:fixed;z-index:60;width:280px;padding:14px 14px 12px;border-radius:14px;background:var(--bt-main);border:.5px solid var(--bt-line-2);box-shadow:0 10px 20px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1);color:var(--bt-ink);box-sizing:border-box;transform-origin:top left;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;animation:bt-update-pop ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing}} ++.bt-update-pop{position:fixed;z-index:130;width:280px;padding:14px 14px 12px;border-radius:14px;background:var(--bt-main);border:.5px solid var(--bt-line-2);box-shadow:0 10px 20px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1);color:var(--bt-ink);box-sizing:border-box;transform-origin:top left;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;animation:bt-update-pop ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing}} + @keyframes bt-update-pop{from{opacity:0;transform:scale(.96);filter:blur(4px)}} + .bt-update-title{font-size:14px;line-height:20px;font-weight:600} + .bt-update-text{margin-top:2px;font-size:12.5px;line-height:18px;color:var(--bt-ink-2)} @@ -5491,18 +5491,28 @@ var SIDEBAR_CSS = ` .bt-agent-sub[data-open] .bt-agent-sub-row{animation:bt-agent-row-in .24s ease both;animation-delay:calc(var(--i, 0)*30ms)} @keyframes bt-agent-row-in{from{opacity:0;filter:blur(4px)}to{opacity:1;filter:blur(0)}} @@ -13400,11 +13546,10 @@ diff --git a/client.js b/client.js // src/client/speech.js var VOICE_BUNDLE = "@deepseek-ai/dsh-experimental-voice-input-bundle"; var USABLE = /* @__PURE__ */ new Set(["ready", "standby", "waking"]); -@@ -6110,243 +6110,19 @@ function turnDetector({ loud = 0.12, quiet = 0.06, quietMs = 1200 } = {}) { - let spokeAt = 0; +@@ -6111,242 +6111,18 @@ function turnDetector({ loud = 0.12, quiet = 0.06, quietMs = 1200 } = {}) { let quietSince = 0; return (level, now) => { -- if (level >= loud) { + if (level >= loud) { - spokeAt ||= now; - quietSince = 0; - return "speech"; @@ -13637,7 +13782,6 @@ diff --git a/client.js b/client.js - ) - ) - ); -+ if (level >= loud) { + spokeAt ||= now; + quietSince = 0; + return "speech"; @@ -13986,7 +14130,7 @@ diff --git a/client.js b/client.js .bt-model-trigger{display:inline-flex;align-items:center;gap:8px;max-width:100%;min-width:0;height:auto;min-height:32px;padding:4px 8px 4px 10px;border:0;border-radius:8px;background:var(--bt-hover);color:var(--bt-ink);font:inherit;text-align:left;cursor:pointer;transition:background-color .12s ease} .bt-model-trigger:hover,.bt-model-trigger[aria-expanded=true]{background:var(--bt-active)} .bt-model-trigger:focus-visible{outline:2px solid var(--bt-accent);outline-offset:1px} -@@ -6675,18 +6675,88 @@ var MODEL_CSS = ` +@@ -6675,18 +6675,96 @@ var MODEL_CSS = ` @media (prefers-reduced-motion:reduce){.bt-model-dock{animation:none}.bt-model-spin{animation-duration:2s}.bt-model-caret{transition:none}.bt-model-tick svg{animation:none}} `; @@ -14058,6 +14202,14 @@ diff --git a/client.js b/client.js +.bt-model-test-result[data-ok=true]{color:#1a7f37} +.bt-model-test-result[data-ok=false]{color:#c21d2e} +.bt-model-test-code{margin-left:6px;font-size:11px;color:var(--bt-ink-3)} ++/* #1347: the hint beside "Test connection" is prose and wraps; the button next to it keeps its own ++ words ("Test connection" on one line) and takes the room it needs. */ ++.bt-model-test>.bt-set-copy{flex:1 1 12ch;min-width:12ch} ++.bt-model-test>.bt-soft{flex:none;white-space:nowrap} ++@container bt-panel (max-width:300px){ ++ .bt-model-test{flex-wrap:wrap;row-gap:6px} ++ .bt-model-test>.bt-set-copy{flex:1 1 100%} ++} +`; + // src/client/folder-menu.js @@ -14978,23 +15130,34 @@ diff --git a/client.js b/client.js key: item.name, item, confirming: confirming === item.name, -@@ -7359,10 +7359,10 @@ function BotCapabilitiesCard({ bot, roster, actions, run, Card: Card2, SwitchRow - const attached = new Set(bot.capabilities ?? []); - const statuses = roster.capabilityStatuses?.[bot.id] ?? {}; - if (items.length === 0) return null; +@@ -7354,27 +7354,73 @@ function CapabilitiesPage({ roster, actions, run }) { + ) + ); + } +-function BotCapabilitiesCard({ bot, roster, actions, run, Card: Card2, SwitchRow: SwitchRow2 }) { +- const items = [...roster.capabilities ?? []].sort((a, b) => Number(b.kind === "pack") - Number(a.kind === "pack") || a.name.localeCompare(b.name)); +- const attached = new Set(bot.capabilities ?? []); +- const statuses = roster.capabilityStatuses?.[bot.id] ?? {}; +- if (items.length === 0) return null; - return (0, import_react16.createElement)( ++function BotCapabilitiesCard({ bot, roster, actions, run, Card: Card2, SwitchRow: SwitchRow2 }) { ++ const items = [...roster.capabilities ?? []].sort((a, b) => Number(b.kind === "pack") - Number(a.kind === "pack") || a.name.localeCompare(b.name)); ++ const attached = new Set(bot.capabilities ?? []); ++ const statuses = roster.capabilityStatuses?.[bot.id] ?? {}; ++ if (items.length === 0) return null; + return (0, import_react25.createElement)( - Card2, - { title: t("Capabilities") }, -- items.map((item) => (0, import_react16.createElement)(SwitchRow2, { ++ Card2, ++ { title: t("Capabilities") }, + items.map((item) => (0, import_react25.createElement)(SwitchRow2, { - key: item.name, - label: item.name, - hint: attached.has(item.name) ? statusText(statuses[item.name]) : void 0, -@@ -7372,9 +7372,55 @@ function BotCapabilitiesCard({ bot, roster, actions, run, Card: Card2, SwitchRow - ); - } - ++ key: item.name, ++ label: item.name, ++ hint: attached.has(item.name) ? statusText(statuses[item.name]) : void 0, ++ on: attached.has(item.name), ++ onChange: (enabled) => void run(actions.setBotCapability(bot.id, item.name, enabled)) ++ })) ++ ); ++} ++ +// src/client/connector-bots.js +var import_react26 = require("react"); +function ownBots(roster) { @@ -15031,16 +15194,24 @@ diff --git a/client.js b/client.js + const connected = connectedConnectors(roster); + if (connected.length === 0) return null; + return (0, import_react26.createElement)( -+ Card2, -+ { title: t("Connectors") }, + Card2, +- { title: t("Capabilities") }, +- items.map((item) => (0, import_react16.createElement)(SwitchRow2, { +- key: item.name, +- label: item.name, +- hint: attached.has(item.name) ? statusText(statuses[item.name]) : void 0, +- on: attached.has(item.name), +- onChange: (enabled) => void run(actions.setBotCapability(bot.id, item.name, enabled)) +- })) ++ { title: t("Connectors") }, + connected.map((connector) => (0, import_react26.createElement)( + "div", + { key: connector.id, className: "bt-connector-bot" }, + (0, import_react26.createElement)(BotSwitch, { bot, connector, name: connector.name, actions, run, SwitchRow: SwitchRow2 }) + )) -+ ); -+} -+ + ); + } + // src/client/memory-panel.js -var import_react17 = require("react"); -var POLL_MS2 = 1500; @@ -15712,7 +15883,7 @@ diff --git a/client.js b/client.js const isMainBot = isMainOf(roster, bot.id); const onlyMain = isMainBot && (roster.mainBotIds ?? []).length === 1; const isTile = roster.mainBotId === bot.id; -@@ -8011,149 +8011,795 @@ function BotSettingsForm({ bot, roster, actions, run }) { +@@ -8011,149 +8011,810 @@ function BotSettingsForm({ bot, roster, actions, run }) { if (confirming) void run(actions.deleteBot(bot.id)); else setConfirming(true); }; @@ -15746,62 +15917,45 @@ diff --git a/client.js b/client.js ), - (0, import_react18.createElement)( + (0, import_react29.createElement)( - "div", - { className: "bt-bs-who" }, -- (0, import_react18.createElement)("span", { className: "bt-bs-name" }, bot.name), -- (0, import_react18.createElement)("span", { className: "bt-bs-sub" }, [bot.role, isMainBot ? t("Main Bot") : ""].filter(Boolean).join(" · ") || t("Bot")) ++ "div", ++ { className: "bt-bs-who" }, + (0, import_react29.createElement)("span", { className: "bt-bs-name" }, bot.name), + (0, import_react29.createElement)("span", { className: "bt-bs-sub" }, [bot.role, isMainBot ? t("Main Bot") : ""].filter(Boolean).join(" · ") || t("Bot")) - ) - ), -- (0, import_react18.createElement)( ++ ) ++ ), + (0, import_react29.createElement)( - Card, - { title: t("Profile") }, -- (0, import_react18.createElement)(Row, { label: t("Name") }, (0, import_react18.createElement)(InlineText, { label: t("Name"), value: bot.name, placeholder: "Bob", required: true, maxLength: 40, onSave: (name) => save({ name }) })), -- (0, import_react18.createElement)(Row, { label: t("Label"), hint: t("Optional. Shown under the name.") }, (0, import_react18.createElement)(InlineText, { label: t("Label"), value: bot.role, placeholder: t("Research, admin…"), maxLength: 24, onSave: (role) => save({ role }) })) ++ Card, ++ { title: t("Profile") }, + (0, import_react29.createElement)(Row, { label: t("Name") }, (0, import_react29.createElement)(InlineText, { label: t("Name"), value: bot.name, placeholder: "Bob", required: true, maxLength: 40, onSave: (name) => save({ name }) })), + (0, import_react29.createElement)(Row, { label: t("Label"), hint: t("Optional. Shown under the name.") }, (0, import_react29.createElement)(InlineText, { label: t("Label"), value: bot.role, placeholder: t("Research, admin…"), maxLength: 24, onSave: (role) => save({ role }) })) - ), -- (0, import_react18.createElement)( ++ ), + (0, import_react29.createElement)( - Card, - { title: t("Model") }, -- (0, import_react18.createElement)( ++ Card, ++ { title: t("Model") }, + (0, import_react29.createElement)( - "div", - { className: "bt-set-row bt-bs-row" }, -- (0, import_react18.createElement)("span", { className: "bt-set-copy" }, (0, import_react18.createElement)("span", { className: "bt-bs-label" }, t("Model")), (0, import_react18.createElement)("span", { className: "bt-set-hint" }, modelHint(bot, roster))), -- (0, import_react18.createElement)(ModelMenu, { bot, roster, actions, run }) -- ) ++ "div", ++ { className: "bt-set-row bt-bs-row" }, + (0, import_react29.createElement)("span", { className: "bt-set-copy" }, (0, import_react29.createElement)("span", { className: "bt-set-hint" }, modelHint(bot, roster))), + (0, import_react29.createElement)(ModelMenu, { bot, roster, actions, run }) + ), + bot.model ? (0, import_react29.createElement)(ModelTestRow, { bot, modelName: modelOf(bot, roster)?.name }) : null - ), -- (0, import_react18.createElement)( ++ ), + (0, import_react29.createElement)( - Card, - { title: t("Working folder") }, -- (0, import_react18.createElement)( ++ Card, ++ { title: t("Working folder") }, + (0, import_react29.createElement)( - "div", - { className: "bt-set-row bt-bs-row" }, -- (0, import_react18.createElement)("span", { className: "bt-set-copy" }, (0, import_react18.createElement)("span", { className: "bt-bs-label" }, t("Folder"))), -- (0, import_react18.createElement)(FolderMenu, { value: bot.workDir, actions, run, onPick: (workDir) => save({ workDir }) }) ++ "div", ++ { className: "bt-set-row bt-bs-row" }, + (0, import_react29.createElement)("span", { className: "bt-set-copy" }, (0, import_react29.createElement)("span", { className: "bt-bs-label" }, t("Folder"))), + (0, import_react29.createElement)(FolderMenu, { value: bot.workDir, actions, run, onPick: (workDir) => save({ workDir }) }) - ) - ), -- (0, import_react18.createElement)( ++ ) ++ ), + (0, import_react29.createElement)( - Card, - { title: t("Instructions") }, -- (0, import_react18.createElement)( ++ Card, ++ { title: t("Instructions") }, + (0, import_react29.createElement)( - Row, - { label: t("What this Bot does"), hint: t("Its job on the team. Part of the Bot's system prompt."), stack: true }, -- (0, import_react18.createElement)(InlineText, { label: t("Instructions"), value: bot.instructions, multiline: true, placeholder: t("What this Bot is responsible for…"), onSave: (instructions) => save({ instructions }) }) ++ Row, ++ { label: t("What this Bot does"), hint: t("Its job on the team. Part of the Bot's system prompt."), stack: true }, + (0, import_react29.createElement)(InlineText, { label: t("Instructions"), value: bot.instructions, multiline: true, placeholder: t("What this Bot is responsible for…"), onSave: (instructions) => save({ instructions }) }) + ) + ), @@ -15865,9 +16019,16 @@ diff --git a/client.js b/client.js +.bt-bs .bt-avedit{left:0;transform:none} +.bt-bs-row{min-height:48px} +label.bt-bs-row{cursor:text} -+.bt-bs-row>.bt-set-copy{flex:1 1 auto} +.bt-bs-label{font-size:13px;line-height:18px;font-weight:500} ++/* #1347: a row keeps its label and its control side by side. The *label* is one line — "Name" used to ++ break into "Nam" + "e" inside a 36px column — and a control never breaks its own words: "Test ++ connection" came out as "Test connecti" / "on" and Main Bot's "Change" as "Ch/an/ge". The hint under ++ a label is prose, so it wraps and is never cut: the row grows instead. */ ++.bt-bs-row>.bt-set-copy{flex:1 1 8ch;min-width:8ch} ++.bt-bs-row>.bt-set-copy>.bt-bs-label{white-space:nowrap;overflow:hidden;text-overflow:ellipsis} ++.bt-bs-row .bt-soft,.bt-bs-row .bt-mini{flex:none;white-space:nowrap} +.bt-bs-row .bt-model-trigger{flex:none;max-width:58%} ++.bt-bs-row .bt-model-trigger :is(.bt-model-name,.bt-model-provider){white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0} +${MODEL_TEST_CSS} +.bt-bs-input{flex:0 1 220px;width:0;min-width:96px;height:30px;box-sizing:border-box;margin:-4px -6px -4px 0;padding:0 8px;border:1px solid transparent;border-radius:8px;background:none;color:var(--bt-ink);font:inherit;font-size:13px;text-align:right;outline:none;transition:background-color .12s ease,border-color .12s ease} +.bt-bs-input:hover{background:var(--bt-hover)} @@ -15889,6 +16050,14 @@ diff --git a/client.js b/client.js +.bt-switch[aria-checked=true]:active:not(:disabled) .bt-switch-knob{transform:translateX(11px)} +.bt-avedit :is(.bt-shapes,.bt-colors,.bt-dropzone){animation:bt-veil-in .18s ease-out} +@media (prefers-reduced-motion:reduce){.bt-switch,.bt-switch-knob{transition:none}.bt-avedit :is(.bt-shapes,.bt-colors,.bt-dropzone){animation:none}} ++/* #1347: the panel's floor is 240px, where a label and a 96px field cannot share a line honestly. Below ++ 300px the row stacks instead: the copy keeps its whole width on top, the control sits under it and ++ keeps its own words. Nothing is clipped to fit a width that is not there. */ ++@container bt-panel (max-width:300px){ ++ .bt-bs-row{flex-wrap:wrap;row-gap:6px} ++ .bt-bs-row>.bt-set-copy{flex:1 1 100%;min-width:0} ++ .bt-bs-input{flex:1 1 100%;width:auto;min-width:0;margin:0;text-align:left} ++} +`; + +// src/client/group-settings.js @@ -16068,15 +16237,20 @@ diff --git a/client.js b/client.js + { className: "bt-bs-hero" }, + (0, import_react30.createElement)(RoomAvatar, { room: room2, roster, size: 64 }), + (0, import_react30.createElement)( -+ "div", -+ { className: "bt-bs-who" }, + "div", + { className: "bt-bs-who" }, +- (0, import_react18.createElement)("span", { className: "bt-bs-name" }, bot.name), +- (0, import_react18.createElement)("span", { className: "bt-bs-sub" }, [bot.role, isMainBot ? t("Main Bot") : ""].filter(Boolean).join(" · ") || t("Bot")) + (0, import_react30.createElement)("span", { className: "bt-bs-name" }, room2.name), + (0, import_react30.createElement)("span", { className: "bt-bs-sub" }, roomSubtitle(room2, roster)) -+ ) -+ ), + ) + ), +- (0, import_react18.createElement)( + (0, import_react30.createElement)( -+ Card, -+ { title: t("Profile") }, + Card, + { title: t("Profile") }, +- (0, import_react18.createElement)(Row, { label: t("Name") }, (0, import_react18.createElement)(InlineText, { label: t("Name"), value: bot.name, placeholder: "Bob", required: true, maxLength: 40, onSave: (name) => save({ name }) })), +- (0, import_react18.createElement)(Row, { label: t("Label"), hint: t("Optional. Shown under the name.") }, (0, import_react18.createElement)(InlineText, { label: t("Label"), value: bot.role, placeholder: t("Research, admin…"), maxLength: 24, onSave: (role) => save({ role }) })) + (0, import_react30.createElement)( + Row, + { label: t("Name"), hint: t("Leave it empty to name the group after its members.") }, @@ -16087,12 +16261,18 @@ diff --git a/client.js b/client.js + { label: t("Notice"), hint: t("Every member reads it before speaking in this group."), stack: true }, + (0, import_react30.createElement)(InlineText, { label: t("Group notice"), value: room2.notice, multiline: true, maxLength: 2e3, placeholder: t("Shared instructions every member reads before speaking…"), onSave: (notice) => update({ notice }) }) + ) -+ ), + ), +- (0, import_react18.createElement)( + (0, import_react30.createElement)( -+ Card, + Card, +- { title: t("Model") }, +- (0, import_react18.createElement)( + { title: t("Who answers") }, + (0, import_react30.createElement)( -+ "div", + "div", +- { className: "bt-set-row bt-bs-row" }, +- (0, import_react18.createElement)("span", { className: "bt-set-copy" }, (0, import_react18.createElement)("span", { className: "bt-bs-label" }, t("Model")), (0, import_react18.createElement)("span", { className: "bt-set-hint" }, modelHint(bot, roster))), +- (0, import_react18.createElement)(ModelMenu, { bot, roster, actions, run }) + { className: "bt-set-row bt-bs-row bt-bs-stack" }, + (0, import_react30.createElement)(Segmented, { label: t("Who answers"), value: mode }, ROOM_MODES.map(([id, label]) => (0, import_react30.createElement)("button", { + key: id, @@ -16105,26 +16285,31 @@ diff --git a/client.js b/client.js + } + }, t(label)))), + (0, import_react30.createElement)("span", { key: mode, className: "bt-set-hint bt-veil" }, `${t(modeHint)} ${t("@mentions always pick who answers.")}`) -+ ) -+ ), + ) + ), +- (0, import_react18.createElement)( + (0, import_react30.createElement)( -+ Card, -+ { title: t("Working folder") }, + Card, + { title: t("Working folder") }, +- (0, import_react18.createElement)( + (0, import_react30.createElement)( -+ "div", -+ { className: "bt-set-row bt-bs-row" }, + "div", + { className: "bt-set-row bt-bs-row" }, +- (0, import_react18.createElement)("span", { className: "bt-set-copy" }, (0, import_react18.createElement)("span", { className: "bt-bs-label" }, t("Folder"))), +- (0, import_react18.createElement)(FolderMenu, { value: bot.workDir, actions, run, onPick: (workDir) => save({ workDir }) }) + (0, import_react30.createElement)("span", { className: "bt-set-copy" }, (0, import_react30.createElement)("span", { className: "bt-bs-label" }, t("Folder"))), + (0, import_react30.createElement)(FolderMenu, { value: room2.workDir, group: true, actions, run, onPick: (workDir) => update({ workDir }) }) ) ), -- (0, import_react18.createElement)(BotCapabilitiesCard, { bot, roster, actions, run, Card, SwitchRow }), -- roster.memory === false ? null : (0, import_react18.createElement)(MemoryCard, { bot, actions }), -- (0, import_react18.createElement)(BotSchedules, { bot, roster, actions }), - (0, import_react18.createElement)( + face ? (0, import_react30.createElement)( Card, -- { title: t("Main Bot") }, -- onlyMain ? (0, import_react18.createElement)( +- { title: t("Instructions") }, +- (0, import_react18.createElement)( +- Row, +- { label: t("What this Bot does"), hint: t("Its job on the team. Part of the Bot's system prompt."), stack: true }, +- (0, import_react18.createElement)(InlineText, { label: t("Instructions"), value: bot.instructions, multiline: true, placeholder: t("What this Bot is responsible for…"), onSave: (instructions) => save({ instructions }) }) +- ) + { title: t("Members · {count}", { count: people2.length }) }, + people2.map((person) => (0, import_react30.createElement)(FacePersonRow, { key: person.key, person, roster, onOpenBot: openBot, lead: room2.face?.lead?.handle })) + ) : null, @@ -16132,15 +16317,10 @@ diff --git a/client.js b/client.js + Card, + { title: t("Members · {count}", { count: members.length + humans.length + remotes.length }) }, + humans.map((person) => (0, import_react30.createElement)( - "div", -- { className: "bt-set-row bt-bs-row" }, -- (0, import_react18.createElement)( ++ "div", + { key: person.id, className: "bt-set-row bt-bs-row bt-gs-member" }, + (0, import_react30.createElement)( - "span", -- { className: "bt-set-copy" }, -- (0, import_react18.createElement)("span", { className: "bt-bs-label" }, t("Main Bot")), -- (0, import_react18.createElement)("span", { className: "bt-set-hint" }, t("The only Main Bot. To hand the role to another Bot, use Main Bot on the Bots page.")) ++ "span", + { className: "bt-gs-who" }, + (0, import_react30.createElement)("span", { className: "bt-row-title" }, person.displayName || person.id), + (0, import_react30.createElement)("span", { className: "bt-tag" }, t("Human")), @@ -16180,9 +16360,7 @@ diff --git a/client.js b/client.js + // #904: a host's roster entry is not a friend's Bot, and one this home cannot confirm is + // labelled as such; a Bot with no Bot Social identity can never be written to at all. + room2.origin === "face-to-face" ? null : (0, import_react30.createElement)("span", { className: "bt-tag" }, person.unconnected ? t("Not connected") : room2.origin === "inbound-share" && person.verified === false ? t("Unconfirmed") : t("Friend's Bot")) - ), -- (0, import_react18.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => actions.openSettings("bots") }, t("Change")) -- ) : (0, import_react18.createElement)(SwitchRow, { label: t("Main Bot"), hint: t("Can create and change Bots, and runs every group chat."), on: isMainBot, onChange: (on) => run(actions.setMain(bot.id, on)) }) ++ ), + // A guest's mirror is not theirs to edit: the host's membership is changed on the host side. + room2.origin === "face-to-face" || room2.origin === "inbound-share" || members.length + remotes.length <= 2 ? null : (0, import_react30.createElement)("button", { type: "button", className: "bt-x", "aria-label": t("Remove {name} from the group", { name: remoteBotTitle(person) }), title: t("Remove from group"), onClick: () => update({ removeParticipants: [{ kind: "bot", id: person.handle || person.id, remote: true }] }) }, "×") + )), @@ -16228,42 +16406,31 @@ diff --git a/client.js b/client.js + // the note on who leads, so that note stays with the members it is about (#892). + room2.origin === "face-to-face" || room2.origin === "inbound-share" ? null : (0, import_react30.createElement)(FlyBotRoomMembers, { room: room2, roster, actions }) ), -- // The Main Bot tile is always first and always shown, like in the context menu. -- isTile ? null : (0, import_react18.createElement)( +- (0, import_react18.createElement)(BotCapabilitiesCard, { bot, roster, actions, run, Card, SwitchRow }), +- roster.memory === false ? null : (0, import_react18.createElement)(MemoryCard, { bot, actions }), +- (0, import_react18.createElement)(BotSchedules, { bot, roster, actions }), +- (0, import_react18.createElement)( + (0, import_react30.createElement)(StandingApprovalsCard, { scope: "room", target: room2, roster, actions, run, Card }), + (0, import_react30.createElement)( Card, - { title: t("Sidebar") }, -- (0, import_react18.createElement)(SwitchRow, { label: t("Pin"), hint: t("Kept above the other chats."), on: bot.pinned === true, onChange: (pinned) => run(actions.setFlags(bot.id, { pinned })) }), -- isMainBot ? null : (0, import_react18.createElement)(SwitchRow, { label: t("Hide from sidebar"), hint: t("Search still finds it."), on: bot.hidden === true, onChange: (hidden) => run(actions.setFlags(bot.id, { hidden })) }) +- { title: t("Main Bot") }, +- onlyMain ? (0, import_react18.createElement)( ++ { title: t("Sidebar") }, + (0, import_react30.createElement)(SwitchRow, { label: t("Pin"), hint: t("Kept above the other chats."), on: room2.pinned === true, onChange: (pinned) => run(actions.setFlags(room2.id, { pinned })) }), + (0, import_react30.createElement)(SwitchRow, { label: t("Hide from sidebar"), hint: t("Search still finds it."), on: room2.hidden === true, onChange: (hidden) => run(actions.setFlags(room2.id, { hidden })) }) - ), -- (0, import_react18.createElement)( ++ ), + (0, import_react30.createElement)( - Card, -- { title: t("Manage") }, -- (0, import_react18.createElement)( -- "button", -- { type: "button", className: "bt-set-row bt-bs-row", onClick: duplicate }, -- (0, import_react18.createElement)("span", { className: "bt-set-copy" }, (0, import_react18.createElement)("span", { className: "bt-bs-label" }, t("Duplicate")), (0, import_react18.createElement)("span", { className: "bt-set-hint" }, t("A copy with the same job and model, and fresh memory."))), -- (0, import_react18.createElement)("span", { className: "bt-chevron" }, (0, import_react18.createElement)(ChevronRightIcon)) -- ), -- isMainBot ? null : (0, import_react18.createElement)( ++ Card, + { title: t("More") }, + (0, import_react30.createElement)( - "button", - { type: "button", className: "bt-set-row bt-bs-row bt-bs-danger", "data-confirm": confirming || void 0, onClick: remove }, -- (0, import_react18.createElement)( ++ "button", ++ { type: "button", className: "bt-set-row bt-bs-row bt-bs-danger", "data-confirm": confirming || void 0, onClick: remove }, + (0, import_react30.createElement)( - "span", - { className: "bt-set-copy" }, -- (0, import_react18.createElement)("span", { className: "bt-bs-label" }, confirming ? t("Delete {name}?", { name: bot.name }) : t("Delete Bot")), -- (0, import_react18.createElement)("span", { className: "bt-set-hint" }, confirming ? t("Click again to delete. Its chats are archived.") : t("Removes the Bot from the team.")) ++ "span", ++ { className: "bt-set-copy" }, + (0, import_react30.createElement)("span", { className: "bt-bs-label" }, confirming ? t("Delete {name}?", { name: room2.name }) : t("Delete group")), + (0, import_react30.createElement)("span", { className: "bt-set-hint" }, confirming ? t("Click again to delete. Its history is archived.") : t("Removes the group chat. Its members stay on the team.")) - ), -- (0, import_react18.createElement)("span", { className: "bt-chevron" }, (0, import_react18.createElement)(ChevronRightIcon)) ++ ), + (0, import_react30.createElement)("span", { className: "bt-chevron" }, (0, import_react30.createElement)(ChevronRightIcon)) + ) + ) @@ -16481,7 +16648,32 @@ diff --git a/client.js b/client.js + "div", + { className: "bt-palette", role: "dialog", "aria-label": t("Search"), "aria-modal": true }, + (0, import_react31.createElement)( -+ "div", + "div", +- { className: "bt-set-row bt-bs-row" }, +- (0, import_react18.createElement)( +- "span", +- { className: "bt-set-copy" }, +- (0, import_react18.createElement)("span", { className: "bt-bs-label" }, t("Main Bot")), +- (0, import_react18.createElement)("span", { className: "bt-set-hint" }, t("The only Main Bot. To hand the role to another Bot, use Main Bot on the Bots page.")) +- ), +- (0, import_react18.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => actions.openSettings("bots") }, t("Change")) +- ) : (0, import_react18.createElement)(SwitchRow, { label: t("Main Bot"), hint: t("Can create and change Bots, and runs every group chat."), on: isMainBot, onChange: (on) => run(actions.setMain(bot.id, on)) }) +- ), +- // The Main Bot tile is always first and always shown, like in the context menu. +- isTile ? null : (0, import_react18.createElement)( +- Card, +- { title: t("Sidebar") }, +- (0, import_react18.createElement)(SwitchRow, { label: t("Pin"), hint: t("Kept above the other chats."), on: bot.pinned === true, onChange: (pinned) => run(actions.setFlags(bot.id, { pinned })) }), +- isMainBot ? null : (0, import_react18.createElement)(SwitchRow, { label: t("Hide from sidebar"), hint: t("Search still finds it."), on: bot.hidden === true, onChange: (hidden) => run(actions.setFlags(bot.id, { hidden })) }) +- ), +- (0, import_react18.createElement)( +- Card, +- { title: t("Manage") }, +- (0, import_react18.createElement)( +- "button", +- { type: "button", className: "bt-set-row bt-bs-row", onClick: duplicate }, +- (0, import_react18.createElement)("span", { className: "bt-set-copy" }, (0, import_react18.createElement)("span", { className: "bt-bs-label" }, t("Duplicate")), (0, import_react18.createElement)("span", { className: "bt-set-hint" }, t("A copy with the same job and model, and fresh memory."))), +- (0, import_react18.createElement)("span", { className: "bt-chevron" }, (0, import_react18.createElement)(ChevronRightIcon)) + { className: "bt-palette-head" }, + (0, import_react31.createElement)("span", { className: "bt-palette-glyph", "aria-hidden": true }, (0, import_react31.createElement)(SearchIcon)), + (0, import_react31.createElement)("input", { @@ -16514,7 +16706,17 @@ diff --git a/client.js b/client.js + setQuery(""); + setCursor(0); + } }, (0, import_react31.createElement)(CloseIcon)) : null -+ ), + ), +- isMainBot ? null : (0, import_react18.createElement)( +- "button", +- { type: "button", className: "bt-set-row bt-bs-row bt-bs-danger", "data-confirm": confirming || void 0, onClick: remove }, +- (0, import_react18.createElement)( +- "span", +- { className: "bt-set-copy" }, +- (0, import_react18.createElement)("span", { className: "bt-bs-label" }, confirming ? t("Delete {name}?", { name: bot.name }) : t("Delete Bot")), +- (0, import_react18.createElement)("span", { className: "bt-set-hint" }, confirming ? t("Click again to delete. Its chats are archived.") : t("Removes the Bot from the team.")) +- ), +- (0, import_react18.createElement)("span", { className: "bt-chevron" }, (0, import_react18.createElement)(ChevronRightIcon)) + (0, import_react31.createElement)( + "div", + { ref: listRef, id: "bt-palette-list", className: "bt-palette-list", role: "listbox", "aria-label": t("Results") }, @@ -17334,41 +17536,55 @@ diff --git a/client.js b/client.js "div", { className: "bt-voice-stage", role: "dialog", "aria-label": t("Voice chat with {name}", { name }), "data-phase": phase === "transcribing" ? "thinking" : phase }, - (0, import_react22.createElement)( -- "div", -- { className: "bt-voice-orb", style: { "--bt-level": level } }, ++ (0, import_react35.createElement)( + "div", + { className: "bt-voice-orb", style: { "--bt-level": level } }, - (0, import_react22.createElement)("span", { className: "bt-voice-ring", "aria-hidden": true }), - (0, import_react22.createElement)("span", { className: "bt-voice-ring bt-voice-ring-2", "aria-hidden": true }), - bot ? (0, import_react22.createElement)(BotMark, { bot, size: 112, state: markState, live: true, gaze: phase === "listening" }) : room2 ? (0, import_react22.createElement)(RoomAvatar, { room: room2, roster, size: 112 }) : null -- ), ++ (0, import_react35.createElement)("span", { className: "bt-voice-ring", "aria-hidden": true }), ++ (0, import_react35.createElement)("span", { className: "bt-voice-ring bt-voice-ring-2", "aria-hidden": true }), ++ bot ? (0, import_react35.createElement)(BotMark, { bot, size: 112, state: markState, live: true, gaze: phase === "listening" }) : room2 ? (0, import_react35.createElement)(RoomAvatar, { room: room2, roster, size: 112 }) : null + ), - (0, import_react22.createElement)("div", { className: "bt-voice-name" }, name), - (0, import_react22.createElement)( -- "div", -- { className: "bt-voice-status", role: "status" }, ++ (0, import_react35.createElement)("div", { className: "bt-voice-name" }, name), ++ (0, import_react35.createElement)( + "div", + { className: "bt-voice-status", role: "status" }, - phase === "thinking" ? (0, import_react22.createElement)(Shimmer, null, t("Thinking…")) : phase === "transcribing" ? (0, import_react22.createElement)(Shimmer, null, t("Recognizing…")) : VOICE_LABELS[phase] ? t(VOICE_LABELS[phase]) : "" -- ), ++ phase === "thinking" ? (0, import_react35.createElement)(Shimmer, null, t("Thinking…")) : phase === "transcribing" ? (0, import_react35.createElement)(Shimmer, null, t("Recognizing…")) : VOICE_LABELS[phase] ? t(VOICE_LABELS[phase]) : "" + ), - (0, import_react22.createElement)("div", { className: "bt-voice-caption", key: `${phase}:${caption.slice(0, 12)}` }, caption), - openSetup ? (0, import_react22.createElement)("button", { type: "button", className: "bt-voice-setup", onClick: () => { -- close(); -- openSetup(); -- } }, t("Set up voice input")) : null, ++ (0, import_react35.createElement)("div", { className: "bt-voice-caption", key: `${phase}:${caption.slice(0, 12)}` }, caption), ++ openSetup ? (0, import_react35.createElement)("button", { type: "button", className: "bt-voice-setup", onClick: () => { + close(); + openSetup(); + } }, t("Set up voice input")) : null, - shownNote ? (0, import_react22.createElement)("div", { className: "bt-voice-note" }, shownNote) : null -- ), ++ shownNote ? (0, import_react35.createElement)("div", { className: "bt-voice-note" }, shownNote) : null + ), - (0, import_react22.createElement)( -- "div", -- { className: "bt-voice-bar" }, ++ (0, import_react35.createElement)( + "div", + { className: "bt-voice-bar" }, - (0, import_react22.createElement)("button", { -- type: "button", -- className: "bt-voice-btn", -- "aria-pressed": phase === "paused", -- disabled: blocked, -- "aria-label": phase === "paused" ? t("Resume listening") : t("Pause"), -- onClick: togglePause ++ (0, import_react35.createElement)("button", { + type: "button", + className: "bt-voice-btn", + "aria-pressed": phase === "paused", + disabled: blocked, + "aria-label": phase === "paused" ? t("Resume listening") : t("Pause"), + onClick: togglePause - }, (0, import_react22.createElement)(MicIcon, { size: 20 }), phase === "paused" ? (0, import_react22.createElement)("span", { className: "bt-voice-slash", "aria-hidden": true }) : null), - (0, import_react22.createElement)("button", { type: "button", className: "bt-voice-btn bt-voice-end", "aria-label": t("End voice chat"), onClick: close }, (0, import_react22.createElement)(CloseIcon)) -- ) -- ); --} -- ++ }, (0, import_react35.createElement)(MicIcon, { size: 20 }), phase === "paused" ? (0, import_react35.createElement)("span", { className: "bt-voice-slash", "aria-hidden": true }) : null), ++ (0, import_react35.createElement)("button", { type: "button", className: "bt-voice-btn bt-voice-end", "aria-label": t("End voice chat"), onClick: close }, (0, import_react35.createElement)(CloseIcon)) + ) + ); + } + -// src/client/group-settings.js -var import_react23 = require("react"); -var ROOM_MODES = [ @@ -17379,193 +17595,6 @@ diff --git a/client.js b/client.js -function roomSubtitle(room2, roster) { - const admin = roster.byId[room2.admin]; - return [t("{count} members", { count: room2.members.length }), admin ? t("Admin {name}", { name: admin.name }) : t("No admin")].join(" · "); --} --function GroupSettingsForm({ room: room2, roster, actions, run, onDeleted }) { -- const [adding, setAdding] = (0, import_react23.useState)(false); -- const [confirming, setConfirming] = (0, import_react23.useState)(false); -- (0, import_react23.useEffect)(() => { -- if (!confirming) return void 0; -- const timer = setTimeout(() => setConfirming(false), 4e3); -- return () => clearTimeout(timer); -- }, [confirming]); -- const members = room2.members.map((id) => roster.byId[id]).filter(Boolean); -- const outside = roster.bots.filter((bot) => !room2.members.includes(bot.id)); -- const admin = roster.byId[room2.admin]; -- const mode = shownMode(room2, roster); -- const modeHint = ROOM_MODES.find(([id]) => id === mode)?.[2] ?? ""; -- const update = (patch) => run(actions.updateRoom(room2.id, patch)); -- const openBot = (id) => { -- actions.closeSettings(); -- actions.openSession(id); -- }; -- const remove = () => { -- if (!confirming) { -- setConfirming(true); -- return; -- } -- void Promise.resolve(run(actions.deleteRoom(room2.id))).then((done) => { -- if (done) onDeleted?.(); -- }); -- }; -- return (0, import_react23.createElement)( -- "div", -- { className: "bt-bs" }, -- (0, import_react23.createElement)( -- "div", -- { className: "bt-bs-hero" }, -- (0, import_react23.createElement)(RoomAvatar, { room: room2, roster, size: 64 }), -- (0, import_react23.createElement)( -- "div", -- { className: "bt-bs-who" }, -- (0, import_react23.createElement)("span", { className: "bt-bs-name" }, room2.name), -- (0, import_react23.createElement)("span", { className: "bt-bs-sub" }, roomSubtitle(room2, roster)) -- ) -- ), -- (0, import_react23.createElement)( -- Card, -- { title: t("Profile") }, -- (0, import_react23.createElement)( -- Row, -- { label: t("Name"), hint: t("Leave it empty to name the group after its members.") }, -- (0, import_react23.createElement)(InlineText, { label: t("Group name"), value: room2.named ? room2.name : "", placeholder: t("Name after members"), maxLength: 60, onSave: (name) => update({ name }) }) -- ), -- (0, import_react23.createElement)( -- Row, -- { label: t("Notice"), hint: t("Every member reads it before speaking in this group."), stack: true }, -- (0, import_react23.createElement)(InlineText, { label: t("Group notice"), value: room2.notice, multiline: true, maxLength: 2e3, placeholder: t("Shared instructions every member reads before speaking…"), onSave: (notice) => update({ notice }) }) -- ) -- ), -- (0, import_react23.createElement)( -- Card, -- { title: t("Who answers") }, -- (0, import_react23.createElement)( -- "div", -- { className: "bt-set-row bt-bs-row bt-bs-stack" }, -- (0, import_react23.createElement)(Segmented, { label: t("Who answers"), value: mode }, ROOM_MODES.map(([id, label]) => (0, import_react23.createElement)("button", { -- key: id, -- type: "button", -- "aria-pressed": mode === id, -- disabled: id === "admin" && !admin, -- title: id === "admin" && !admin ? t("Choose an admin first") : void 0, -- onClick: () => { -- if (mode !== id) void update({ mode: id }); -- } -- }, t(label)))), -- (0, import_react23.createElement)("span", { key: mode, className: "bt-set-hint bt-veil" }, `${t(modeHint)} ${t("@mentions always pick who answers.")}`) -- ) -- ), -- (0, import_react23.createElement)( -- Card, -- { title: t("Working folder") }, -- (0, import_react23.createElement)( -+ (0, import_react35.createElement)( - "div", -- { className: "bt-set-row bt-bs-row" }, -- (0, import_react23.createElement)("span", { className: "bt-set-copy" }, (0, import_react23.createElement)("span", { className: "bt-bs-label" }, t("Folder"))), -- (0, import_react23.createElement)(FolderMenu, { value: room2.workDir, group: true, actions, run, onPick: (workDir) => update({ workDir }) }) -- ) -- ), -- (0, import_react23.createElement)( -- Card, -- { title: t("Members · {count}", { count: members.length }) }, -- members.map((member) => { -- const leads = room2.admin === member.id; -- return (0, import_react23.createElement)( -- "div", -- { key: member.id, className: "bt-set-row bt-bs-row bt-gs-member" }, -- (0, import_react23.createElement)( -- "button", -- { type: "button", className: "bt-gs-who", title: t("Open {name}'s chat", { name: member.name }), onClick: () => openBot(member.id) }, -- (0, import_react23.createElement)(BotAvatar, { bot: member, size: 26, main: isMainOf(roster, member.id), admin: leads, live: false }), -- (0, import_react23.createElement)("span", { className: "bt-row-title" }, member.name), -- leads ? (0, import_react23.createElement)("span", { className: "bt-tag" }, t("Admin")) : null -- ), -- leads ? (0, import_react23.createElement)("button", { type: "button", className: "bt-mini", title: t("Leave the group without an admin"), onClick: () => update({ admin: null }) }, t("Unset")) : (0, import_react23.createElement)("button", { type: "button", className: "bt-mini", onClick: () => update({ admin: member.id }) }, t("Make admin")), -- members.length > 2 ? (0, import_react23.createElement)("button", { type: "button", className: "bt-x", "aria-label": t("Remove {name} from the group", { name: member.name }), title: t("Remove from group"), onClick: () => update({ remove: [member.id] }) }, "×") : null -- ); -- }), -- adding ? [ -- ...outside.map((bot) => (0, import_react23.createElement)( -- "button", -- { key: bot.id, type: "button", className: "bt-set-row bt-bs-row bt-gs-pick", onClick: () => { -- setAdding(false); -- void update({ add: [bot.id] }); -- } }, -- (0, import_react23.createElement)(BotMark, { bot, size: 22 }), -- (0, import_react23.createElement)("span", { className: "bt-set-copy" }, (0, import_react23.createElement)("span", { className: "bt-bs-label" }, bot.name)), -- (0, import_react23.createElement)("span", { className: "bt-set-hint" }, t("Add")) -- )), -- (0, import_react23.createElement)("button", { key: "cancel", type: "button", className: "bt-set-row bt-bs-row bt-gs-add", onClick: () => setAdding(false) }, t("Cancel")) -- ] : outside.length > 0 ? (0, import_react23.createElement)("button", { type: "button", className: "bt-set-row bt-bs-row bt-gs-add", onClick: () => setAdding(true) }, (0, import_react23.createElement)(PlusIcon), t("Add member")) : null, -- (0, import_react23.createElement)("div", { className: "bt-set-row bt-gs-note" }, (0, import_react23.createElement)( -- "span", -- { className: "bt-set-hint" }, -- // The host checks for two members on every change, even a rename. -- members.length < 2 ? t("Add a Bot first: a group chat needs at least two.") : admin ? t("{name} leads this group: it can call members with @Name, change the group, and post here.", { name: admin.name }) : t("No admin yet. An admin can call members with @Name, change the group, and post here.") -- )) -- ), -- (0, import_react23.createElement)( -- Card, -- { title: t("Sidebar") }, -- (0, import_react23.createElement)(SwitchRow, { label: t("Pin"), hint: t("Kept above the other chats."), on: room2.pinned === true, onChange: (pinned) => run(actions.setFlags(room2.id, { pinned })) }), -- (0, import_react23.createElement)(SwitchRow, { label: t("Hide from sidebar"), hint: t("Search still finds it."), on: room2.hidden === true, onChange: (hidden) => run(actions.setFlags(room2.id, { hidden })) }) -+ { className: "bt-voice-orb", style: { "--bt-level": level } }, -+ (0, import_react35.createElement)("span", { className: "bt-voice-ring", "aria-hidden": true }), -+ (0, import_react35.createElement)("span", { className: "bt-voice-ring bt-voice-ring-2", "aria-hidden": true }), -+ bot ? (0, import_react35.createElement)(BotMark, { bot, size: 112, state: markState, live: true, gaze: phase === "listening" }) : room2 ? (0, import_react35.createElement)(RoomAvatar, { room: room2, roster, size: 112 }) : null -+ ), -+ (0, import_react35.createElement)("div", { className: "bt-voice-name" }, name), -+ (0, import_react35.createElement)( -+ "div", -+ { className: "bt-voice-status", role: "status" }, -+ phase === "thinking" ? (0, import_react35.createElement)(Shimmer, null, t("Thinking…")) : phase === "transcribing" ? (0, import_react35.createElement)(Shimmer, null, t("Recognizing…")) : VOICE_LABELS[phase] ? t(VOICE_LABELS[phase]) : "" -+ ), -+ (0, import_react35.createElement)("div", { className: "bt-voice-caption", key: `${phase}:${caption.slice(0, 12)}` }, caption), -+ openSetup ? (0, import_react35.createElement)("button", { type: "button", className: "bt-voice-setup", onClick: () => { -+ close(); -+ openSetup(); -+ } }, t("Set up voice input")) : null, -+ shownNote ? (0, import_react35.createElement)("div", { className: "bt-voice-note" }, shownNote) : null - ), -- (0, import_react23.createElement)( -- Card, -- { title: t("Manage") }, -- (0, import_react23.createElement)( -- "button", -- { type: "button", className: "bt-set-row bt-bs-row bt-bs-danger", "data-confirm": confirming || void 0, onClick: remove }, -- (0, import_react23.createElement)( -- "span", -- { className: "bt-set-copy" }, -- (0, import_react23.createElement)("span", { className: "bt-bs-label" }, confirming ? t("Delete {name}?", { name: room2.name }) : t("Delete group")), -- (0, import_react23.createElement)("span", { className: "bt-set-hint" }, confirming ? t("Click again to delete. Its history is archived.") : t("Removes the group chat. Its members stay on the team.")) -- ), -- (0, import_react23.createElement)("span", { className: "bt-chevron" }, (0, import_react23.createElement)(ChevronRightIcon)) -- ) -+ (0, import_react35.createElement)( -+ "div", -+ { className: "bt-voice-bar" }, -+ (0, import_react35.createElement)("button", { -+ type: "button", -+ className: "bt-voice-btn", -+ "aria-pressed": phase === "paused", -+ disabled: blocked, -+ "aria-label": phase === "paused" ? t("Resume listening") : t("Pause"), -+ onClick: togglePause -+ }, (0, import_react35.createElement)(MicIcon, { size: 20 }), phase === "paused" ? (0, import_react35.createElement)("span", { className: "bt-voice-slash", "aria-hidden": true }) : null), -+ (0, import_react35.createElement)("button", { type: "button", className: "bt-voice-btn bt-voice-end", "aria-label": t("End voice chat"), onClick: close }, (0, import_react35.createElement)(CloseIcon)) - ) - ); - } --function NewGroupForm({ roster, actions, run, onCreated, onCancel }) { -- const [admin, setAdmin] = (0, import_react23.useState)(null); -- const [members, setMembers] = (0, import_react23.useState)([]); -- const [name, setName] = (0, import_react23.useState)(""); -- const [busy, setBusy] = (0, import_react23.useState)(false); -- const toggle = (id) => setMembers((list) => list.includes(id) ? list.filter((item) => item !== id) : [...list, id]); -- const pickAdmin = (id) => { -- setAdmin(id); -- setMembers((list) => list.filter((item) => item !== id)); -+ +// src/client/workflows.js +var import_react36 = require("react"); +function parseList(text) { @@ -17589,7 +17618,24 @@ diff --git a/client.js b/client.js + refusal: before?.refusal ?? "" + }; + }).filter(Boolean); -+} + } +-function GroupSettingsForm({ room: room2, roster, actions, run, onDeleted }) { +- const [adding, setAdding] = (0, import_react23.useState)(false); +- const [confirming, setConfirming] = (0, import_react23.useState)(false); +- (0, import_react23.useEffect)(() => { +- if (!confirming) return void 0; +- const timer = setTimeout(() => setConfirming(false), 4e3); +- return () => clearTimeout(timer); +- }, [confirming]); +- const members = room2.members.map((id) => roster.byId[id]).filter(Boolean); +- const outside = roster.bots.filter((bot) => !room2.members.includes(bot.id)); +- const admin = roster.byId[room2.admin]; +- const mode = shownMode(room2, roster); +- const modeHint = ROOM_MODES.find(([id]) => id === mode)?.[2] ?? ""; +- const update = (patch) => run(actions.updateRoom(room2.id, patch)); +- const openBot = (id) => { +- actions.closeSettings(); +- actions.openSession(id); +function applyNodeField(definition, nodeId, field2, value) { + if (definition === null || definition === void 0) return definition; + return { @@ -17601,15 +17647,10 @@ diff --git a/client.js b/client.js + return { ...node, [field2]: value }; + }) }; -- const create = async () => { -- if (busy || admin === null || members.length === 0) return; -- setBusy(true); -- let room2; -- const done = await run(actions.createRoom([admin, ...members], { admin, greet: true, ...name.trim() ? { name: name.trim() } : {} }).then((value) => { -- room2 = value; -- })); -- if (done && room2) onCreated(room2); -- else setBusy(false); +- const remove = () => { +- if (!confirming) { +- setConfirming(true); +- return; +} +var field = (label, value, onChange, options = {}) => (0, import_react36.createElement)( + "label", @@ -17691,7 +17732,9 @@ diff --git a/client.js b/client.js + if (value?.ok === false) { + setError(value.message ?? t("Could not open this workflow")); + return value; -+ } + } +- void Promise.resolve(run(actions.deleteRoom(room2.id))).then((done) => { +- if (done) onDeleted?.(); + setOpened(value); + setDraft(value.workflow.definition); + setNode(value.preview?.node ?? value.workflow.definition.nodes[0]?.id ?? null); @@ -17707,16 +17750,7 @@ diff --git a/client.js b/client.js + setNode(id); + if (!opened) return; + Promise.resolve(actions.workflows({ op: "preview", id: opened.workflow.id, node: id })).then((value) => setPreview(value?.preview ?? null)).catch((failure) => setError(failure?.message ?? String(failure))); - }; -- const chip = (bot, on, onClick, role) => (0, import_react23.createElement)("button", { -- key: bot.id, -- type: "button", -- role, -- className: "bt-ng-chip", -- "aria-checked": on, -- onClick -- }, (0, import_react23.createElement)(BotAvatar, { bot, size: 22, badge: false, live: false }), (0, import_react23.createElement)("span", { className: "bt-ng-name" }, bot.name), on && role === "radio" ? (0, import_react23.createElement)(AdminStar) : null); -- return (0, import_react23.createElement)( ++ }; + const save = () => Promise.resolve(actions.workflowSave({ op: "save", definition: draft })).then((value) => { + if (value?.ok === false) { + setError(value.message ?? t("This definition cannot be saved")); @@ -17752,53 +17786,72 @@ diff --git a/client.js b/client.js + }).catch((failure) => { + setError(failure?.message ?? String(failure)); + return null; -+ }); -+ }; + }); + }; +- return (0, import_react23.createElement)( + const editable = opened?.workflow.editable === true; + const current = draft?.nodes?.find((entry) => entry.id === node) ?? null; + return (0, import_react36.createElement)( "div", -- { className: "bt-set-card bt-ng" }, +- { className: "bt-bs" }, - (0, import_react23.createElement)( + { className: "bt-wf-page" }, + (0, import_react36.createElement)( "div", -- { className: "bt-set-row bt-bs-row bt-bs-stack" }, -- (0, import_react23.createElement)("span", { className: "bt-set-copy" }, (0, import_react23.createElement)("span", { className: "bt-bs-label bt-ng-label" }, (0, import_react23.createElement)(AdminStar), t("Choose the group admin"))), +- { className: "bt-bs-hero" }, +- (0, import_react23.createElement)(RoomAvatar, { room: room2, roster, size: 64 }), - (0, import_react23.createElement)( - "div", -- { className: "bt-ng-picks", role: "radiogroup", "aria-label": t("Group admin") }, -- roster.bots.map((bot) => chip(bot, admin === bot.id, () => pickAdmin(bot.id), "radio")) +- { className: "bt-bs-who" }, +- (0, import_react23.createElement)("span", { className: "bt-bs-name" }, room2.name), +- (0, import_react23.createElement)("span", { className: "bt-bs-sub" }, roomSubtitle(room2, roster)) +- ) +- ), +- (0, import_react23.createElement)( +- Card, +- { title: t("Profile") }, +- (0, import_react23.createElement)( +- Row, +- { label: t("Name"), hint: t("Leave it empty to name the group after its members.") }, +- (0, import_react23.createElement)(InlineText, { label: t("Group name"), value: room2.named ? room2.name : "", placeholder: t("Name after members"), maxLength: 60, onSave: (name) => update({ name }) }) +- ), +- (0, import_react23.createElement)( +- Row, +- { label: t("Notice"), hint: t("Every member reads it before speaking in this group."), stack: true }, +- (0, import_react23.createElement)(InlineText, { label: t("Group notice"), value: room2.notice, multiline: true, maxLength: 2e3, placeholder: t("Shared instructions every member reads before speaking…"), onSave: (notice) => update({ notice }) }) - ) + { className: "bt-wf-head" }, + (0, import_react36.createElement)("h3", null, (0, import_react36.createElement)(WorkflowIcon), t("Workflows")), + (0, import_react36.createElement)("span", { className: "bt-wf-hint" }, t("A workflow is a file: workflows/.json. Columns, the flowchart and the refusals are all drawn from it.")) ), -- admin === null ? null : (0, import_react23.createElement)( +- (0, import_react23.createElement)( +- Card, +- { title: t("Who answers") }, +- (0, import_react23.createElement)( + error ? (0, import_react36.createElement)("div", { className: "bt-error", role: "alert" }, error) : null, + saved ? (0, import_react36.createElement)("div", { className: "bt-wf-saved", role: "status" }, saved) : null, + (0, import_react36.createElement)( - "div", -- { className: "bt-set-row bt-bs-row bt-bs-stack bt-ng-step" }, -- (0, import_react23.createElement)("span", { className: "bt-set-copy" }, (0, import_react23.createElement)("span", { className: "bt-bs-label" }, t("Who to chat with…"))), -- (0, import_react23.createElement)( ++ "div", + { className: "bt-wf-list" }, + ["presets", "own"].map((group) => (0, import_react36.createElement)( "div", -- { className: "bt-ng-picks", role: "group", "aria-label": t("Members") }, -- roster.bots.filter((bot) => bot.id !== admin).map((bot) => chip(bot, members.includes(bot.id), () => toggle(bot.id), "checkbox")) -- ) -- ), -- admin === null || members.length === 0 ? null : (0, import_react23.createElement)( -- "label", -- { className: "bt-set-row bt-bs-row bt-ng-step" }, -- (0, import_react23.createElement)("span", { className: "bt-set-copy" }, (0, import_react23.createElement)("span", { className: "bt-bs-label" }, t("Name"))), -- (0, import_react23.createElement)("input", { className: "bt-bs-input", value: name, maxLength: 60, placeholder: t("Name after members"), onChange: (event) => setName(event.target.value) }) +- { className: "bt-set-row bt-bs-row bt-bs-stack" }, +- (0, import_react23.createElement)(Segmented, { label: t("Who answers"), value: mode }, ROOM_MODES.map(([id, label]) => (0, import_react23.createElement)("button", { +- key: id, + { key: group, className: "bt-wf-group" }, + (0, import_react36.createElement)("span", { className: "bt-wf-group-name" }, group === "presets" ? t("Factory presets") : t("Yours")), + (list?.[group] ?? []).map((entry) => (0, import_react36.createElement)("button", { + key: `${group}:${entry.id}`, -+ type: "button", + type: "button", +- "aria-pressed": mode === id, +- disabled: id === "admin" && !admin, +- title: id === "admin" && !admin ? t("Choose an admin first") : void 0, +- onClick: () => { +- if (mode !== id) void update({ mode: id }); +- } +- }, t(label)))), +- (0, import_react23.createElement)("span", { key: mode, className: "bt-set-hint bt-veil" }, `${t(modeHint)} ${t("@mentions always pick who answers.")}`) +- ) + className: "bt-wf-pick", + "aria-current": opened?.workflow.id === entry.id ? "true" : void 0, + "data-id": entry.id, @@ -17809,14 +17862,25 @@ diff --git a/client.js b/client.js + list === null ? (0, import_react36.createElement)("span", { className: "bt-wf-hint" }, t("Reading workflows…")) : null ), - (0, import_react23.createElement)( +- Card, +- { title: t("Working folder") }, +- (0, import_react23.createElement)( + opened === null ? null : (0, import_react36.createElement)( - "div", -- { className: "bt-set-row bt-task-foot" }, -- (0, import_react23.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: onCancel }, t("Cancel")), -- (0, import_react23.createElement)("button", { type: "button", className: "bt-send bt-soft-sm", disabled: busy || admin === null || members.length === 0, onClick: () => void create() }, t("Create group chat")) ++ "div", + { className: "bt-wf-doc" }, + (0, import_react36.createElement)( -+ "div", + "div", +- { className: "bt-set-row bt-bs-row" }, +- (0, import_react23.createElement)("span", { className: "bt-set-copy" }, (0, import_react23.createElement)("span", { className: "bt-bs-label" }, t("Folder"))), +- (0, import_react23.createElement)(FolderMenu, { value: room2.workDir, group: true, actions, run, onPick: (workDir) => update({ workDir }) }) +- ) +- ), +- (0, import_react23.createElement)( +- Card, +- { title: t("Members · {count}", { count: members.length }) }, +- members.map((member) => { +- const leads = room2.admin === member.id; +- return (0, import_react23.createElement)( + { className: "bt-wf-doc-head" }, + (0, import_react36.createElement)("strong", null, opened.workflow.name), + (0, import_react36.createElement)("span", { className: "bt-wf-path" }, `workflows/${opened.workflow.id}.json`), @@ -17829,10 +17893,100 @@ diff --git a/client.js b/client.js + "div", + { className: "bt-wf-table" }, + (0, import_react36.createElement)( -+ "div", + "div", +- { key: member.id, className: "bt-set-row bt-bs-row bt-gs-member" }, +- (0, import_react23.createElement)( +- "button", +- { type: "button", className: "bt-gs-who", title: t("Open {name}'s chat", { name: member.name }), onClick: () => openBot(member.id) }, +- (0, import_react23.createElement)(BotAvatar, { bot: member, size: 26, main: isMainOf(roster, member.id), admin: leads, live: false }), +- (0, import_react23.createElement)("span", { className: "bt-row-title" }, member.name), +- leads ? (0, import_react23.createElement)("span", { className: "bt-tag" }, t("Admin")) : null +- ), +- leads ? (0, import_react23.createElement)("button", { type: "button", className: "bt-mini", title: t("Leave the group without an admin"), onClick: () => update({ admin: null }) }, t("Unset")) : (0, import_react23.createElement)("button", { type: "button", className: "bt-mini", onClick: () => update({ admin: member.id }) }, t("Make admin")), +- members.length > 2 ? (0, import_react23.createElement)("button", { type: "button", className: "bt-x", "aria-label": t("Remove {name} from the group", { name: member.name }), title: t("Remove from group"), onClick: () => update({ remove: [member.id] }) }, "×") : null +- ); +- }), +- adding ? [ +- ...outside.map((bot) => (0, import_react23.createElement)( +- "button", +- { key: bot.id, type: "button", className: "bt-set-row bt-bs-row bt-gs-pick", onClick: () => { +- setAdding(false); +- void update({ add: [bot.id] }); +- } }, +- (0, import_react23.createElement)(BotMark, { bot, size: 22 }), +- (0, import_react23.createElement)("span", { className: "bt-set-copy" }, (0, import_react23.createElement)("span", { className: "bt-bs-label" }, bot.name)), +- (0, import_react23.createElement)("span", { className: "bt-set-hint" }, t("Add")) +- )), +- (0, import_react23.createElement)("button", { key: "cancel", type: "button", className: "bt-set-row bt-bs-row bt-gs-add", onClick: () => setAdding(false) }, t("Cancel")) +- ] : outside.length > 0 ? (0, import_react23.createElement)("button", { type: "button", className: "bt-set-row bt-bs-row bt-gs-add", onClick: () => setAdding(true) }, (0, import_react23.createElement)(PlusIcon), t("Add member")) : null, +- (0, import_react23.createElement)("div", { className: "bt-set-row bt-gs-note" }, (0, import_react23.createElement)( +- "span", +- { className: "bt-set-hint" }, +- // The host checks for two members on every change, even a rename. +- members.length < 2 ? t("Add a Bot first: a group chat needs at least two.") : admin ? t("{name} leads this group: it can call members with @Name, change the group, and post here.", { name: admin.name }) : t("No admin yet. An admin can call members with @Name, change the group, and post here.") +- )) +- ), +- (0, import_react23.createElement)( +- Card, +- { title: t("Sidebar") }, +- (0, import_react23.createElement)(SwitchRow, { label: t("Pin"), hint: t("Kept above the other chats."), on: room2.pinned === true, onChange: (pinned) => run(actions.setFlags(room2.id, { pinned })) }), +- (0, import_react23.createElement)(SwitchRow, { label: t("Hide from sidebar"), hint: t("Search still finds it."), on: room2.hidden === true, onChange: (hidden) => run(actions.setFlags(room2.id, { hidden })) }) +- ), +- (0, import_react23.createElement)( +- Card, +- { title: t("Manage") }, +- (0, import_react23.createElement)( +- "button", +- { type: "button", className: "bt-set-row bt-bs-row bt-bs-danger", "data-confirm": confirming || void 0, onClick: remove }, +- (0, import_react23.createElement)( +- "span", +- { className: "bt-set-copy" }, +- (0, import_react23.createElement)("span", { className: "bt-bs-label" }, confirming ? t("Delete {name}?", { name: room2.name }) : t("Delete group")), +- (0, import_react23.createElement)("span", { className: "bt-set-hint" }, confirming ? t("Click again to delete. Its history is archived.") : t("Removes the group chat. Its members stay on the team.")) + { className: "bt-wf-row bt-wf-row-head" }, + ["id", "name", "role", "inputs", "outputs", "guards", "refusal"].map((key) => (0, import_react36.createElement)("span", { key }, key)) -+ ), + ), +- (0, import_react23.createElement)("span", { className: "bt-chevron" }, (0, import_react23.createElement)(ChevronRightIcon)) +- ) +- ) +- ); +-} +-function NewGroupForm({ roster, actions, run, onCreated, onCancel }) { +- const [admin, setAdmin] = (0, import_react23.useState)(null); +- const [members, setMembers] = (0, import_react23.useState)([]); +- const [name, setName] = (0, import_react23.useState)(""); +- const [busy, setBusy] = (0, import_react23.useState)(false); +- const toggle = (id) => setMembers((list) => list.includes(id) ? list.filter((item) => item !== id) : [...list, id]); +- const pickAdmin = (id) => { +- setAdmin(id); +- setMembers((list) => list.filter((item) => item !== id)); +- }; +- const create = async () => { +- if (busy || admin === null || members.length === 0) return; +- setBusy(true); +- let room2; +- const done = await run(actions.createRoom([admin, ...members], { admin, greet: true, ...name.trim() ? { name: name.trim() } : {} }).then((value) => { +- room2 = value; +- })); +- if (done && room2) onCreated(room2); +- else setBusy(false); +- }; +- const chip = (bot, on, onClick, role) => (0, import_react23.createElement)("button", { +- key: bot.id, +- type: "button", +- role, +- className: "bt-ng-chip", +- "aria-checked": on, +- onClick +- }, (0, import_react23.createElement)(BotAvatar, { bot, size: 22, badge: false, live: false }), (0, import_react23.createElement)("span", { className: "bt-ng-name" }, bot.name), on && role === "radio" ? (0, import_react23.createElement)(AdminStar) : null); +- return (0, import_react23.createElement)( +- "div", +- { className: "bt-set-card bt-ng" }, +- (0, import_react23.createElement)( +- "div", +- { className: "bt-set-row bt-bs-row bt-bs-stack" }, +- (0, import_react23.createElement)("span", { className: "bt-set-copy" }, (0, import_react23.createElement)("span", { className: "bt-bs-label bt-ng-label" }, (0, import_react23.createElement)(AdminStar), t("Choose the group admin"))), +- (0, import_react23.createElement)( + (draft?.nodes ?? []).map((entry) => (0, import_react36.createElement)( + "div", + { @@ -17852,17 +18006,40 @@ diff --git a/client.js b/client.js + )) + ), + (0, import_react36.createElement)( -+ "div", + "div", +- { className: "bt-ng-picks", role: "radiogroup", "aria-label": t("Group admin") }, +- roster.bots.map((bot) => chip(bot, admin === bot.id, () => pickAdmin(bot.id), "radio")) +- ) +- ), +- admin === null ? null : (0, import_react23.createElement)( +- "div", +- { className: "bt-set-row bt-bs-row bt-bs-stack bt-ng-step" }, +- (0, import_react23.createElement)("span", { className: "bt-set-copy" }, (0, import_react23.createElement)("span", { className: "bt-bs-label" }, t("Who to chat with…"))), +- (0, import_react23.createElement)( + { className: "bt-wf-edges" }, + (0, import_react36.createElement)("span", { className: "bt-wf-group-name" }, t("Edges")), + (draft?.edges ?? []).map((edge) => (0, import_react36.createElement)("span", { key: `${edge.from}->${edge.to}`, className: "bt-wf-edge-row" }, `${edge.from} → ${edge.to}`)) + ), + (0, import_react36.createElement)( -+ "div", + "div", +- { className: "bt-ng-picks", role: "group", "aria-label": t("Members") }, +- roster.bots.filter((bot) => bot.id !== admin).map((bot) => chip(bot, members.includes(bot.id), () => toggle(bot.id), "checkbox")) + { className: "bt-wf-node" }, + (0, import_react36.createElement)("span", { className: "bt-wf-group-name" }, node === null ? t("Guards") : t("Guards of {node}", { node: current?.name ?? node })), + current === null || current.guards.length === 0 ? (0, import_react36.createElement)("span", { className: "bt-wf-hint" }, t("This node has no guards, so nothing refuses it but its inputs.")) : (0, import_react36.createElement)(Preview, { preview }) -+ ) + ) +- ), +- admin === null || members.length === 0 ? null : (0, import_react23.createElement)( +- "label", +- { className: "bt-set-row bt-bs-row bt-ng-step" }, +- (0, import_react23.createElement)("span", { className: "bt-set-copy" }, (0, import_react23.createElement)("span", { className: "bt-bs-label" }, t("Name"))), +- (0, import_react23.createElement)("input", { className: "bt-bs-input", value: name, maxLength: 60, placeholder: t("Name after members"), onChange: (event) => setName(event.target.value) }) +- ), +- (0, import_react23.createElement)( +- "div", +- { className: "bt-set-row bt-task-foot" }, +- (0, import_react23.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: onCancel }, t("Cancel")), +- (0, import_react23.createElement)("button", { type: "button", className: "bt-send bt-soft-sm", disabled: busy || admin === null || members.length === 0, onClick: () => void create() }, t("Create group chat")) ) ); } @@ -18108,7 +18285,8 @@ diff --git a/client.js b/client.js ) : null ); } - function Row2({ kind, glyph, name, sub, sum, whole, fill: fill2, index, onClick }) { +-function Row2({ kind, glyph, name, sub, sum, whole, fill: fill2, index, onClick }) { ++function Row2({ kind, glyph, name, sub, sum, whole, fill: fill3, index, onClick }) { const width = whole > 0 ? Math.max(sum.total > 0 ? 2 : 0, sum.total / whole * 100) : 0; - return (0, import_react24.createElement)( + return (0, import_react37.createElement)( @@ -18135,7 +18313,7 @@ diff --git a/client.js b/client.js + sub ? (0, import_react37.createElement)("span", { className: "bt-us-row-sub" }, sub) : null ), - (0, import_react24.createElement)("span", { className: "bt-us-track" }, (0, import_react24.createElement)("span", { style: { width: `${width}%`, background: fill2 } })) -+ (0, import_react37.createElement)("span", { className: "bt-us-track" }, (0, import_react37.createElement)("span", { style: { width: `${width}%`, background: fill2 } })) ++ (0, import_react37.createElement)("span", { className: "bt-us-track" }, (0, import_react37.createElement)("span", { style: { width: `${width}%`, background: fill3 } })) ), - (0, import_react24.createElement)("span", { className: "bt-us-row-num" }, (0, import_react24.createElement)("b", null, compact(sum.total)), (0, import_react24.createElement)("small", null, `${percent(sum.total, whole)}%`)) + (0, import_react37.createElement)("span", { className: "bt-us-row-num" }, (0, import_react37.createElement)("b", null, compact(sum.total)), (0, import_react37.createElement)("small", null, `${percent(sum.total, whole)}%`)) @@ -19910,7 +20088,7 @@ diff --git a/client.js b/client.js +// src/client/pane-width.js +var DEFAULT_WIDTH = 520; -+var DETAILS_WIDTH = 320; ++var DETAILS_WIDTH = 380; +var DETAILS_MAX = 520; +var MIN_WIDTH = 360; +var MIN_CHAT = 420; @@ -19931,14 +20109,10 @@ diff --git a/client.js b/client.js prefs: "ds-bot:prefs", surface: "ds-bot:surface", agentSessions: "ds-bot:agent-sessions", -@@ -10985,286 +10985,61 @@ var STORAGE = { - browsers: "ds-bot.browser.v2" - }; - var LEGACY_PREFIXES = ["dsh-bot:", "dsh-bot-team:"]; --var LEGACY_BROWSER_KEYS = ["ds-bot.browser.v1", "dsh-bot.browser.v1"]; --var LEGACY_THEMES = { grok: "mono" }; --function legacyKeys(key) { -- if (key === STORAGE.browsers) return LEGACY_BROWSER_KEYS; +@@ -10989,282 +10989,57 @@ var LEGACY_BROWSER_KEYS = ["ds-bot.browser.v1", "dsh-bot.browser.v1"]; + var LEGACY_THEMES = { grok: "mono" }; + function legacyKeys(key) { + if (key === STORAGE.browsers) return LEGACY_BROWSER_KEYS; - const name = key.slice(key.indexOf(":") + 1); - return LEGACY_PREFIXES.map((prefix) => `${prefix}${name}`); -} @@ -20170,10 +20344,6 @@ diff --git a/client.js b/client.js - entry.here ? (0, import_react30.createElement)("button", { type: "button", className: "bt-mw-stop", disabled: stopping, onClick: stop }, t("Stop")) : null - ) - ); -+var LEGACY_BROWSER_KEYS = ["ds-bot.browser.v1", "dsh-bot.browser.v1"]; -+var LEGACY_THEMES = { grok: "mono" }; -+function legacyKeys(key) { -+ if (key === STORAGE.browsers) return LEGACY_BROWSER_KEYS; + const name = key.slice(key.indexOf(":") + 1); + return LEGACY_PREFIXES.map((prefix) => `${prefix}${name}`); } @@ -20598,8 +20768,24 @@ diff --git a/client.js b/client.js if (last.current.view !== view) last.current = { view, tab, move: view === "settings" ? "in" : "out" }; else if (last.current.tab !== tab) last.current = { view, tab, move: "tab" }; const move = last.current.move; -@@ -11472,36 +11472,43 @@ function DetailsDrawer({ sessionId, startRenaming, startTab, hasStrip, roster, a +@@ -11467,41 +11467,67 @@ function DetailsDrawer({ sessionId, startRenaming, startTab, hasStrip, roster, a + return false; + }); + const share = () => { +- void navigator.clipboard?.writeText(transcript).then(() => { ++ const text = transcript; ++ if (!text) { ++ actions.notify?.(t("Nothing to copy yet")); ++ return; ++ } ++ void copiedToClipboard(text).then((written) => { ++ if (!written) { ++ actions.reportError?.(new Error(t("Could not copy the conversation")), "share"); ++ return; ++ } + setCopied(true); setTimeout(() => setCopied(false), 1600); ++ actions.notify?.(t("Conversation copied as Markdown")); }); }; - const errorLine = error ? (0, import_react31.createElement)("div", { className: "bt-error", role: "alert", style: { padding: 0 } }, error) : null; @@ -20645,7 +20831,16 @@ diff --git a/client.js b/client.js + onClick: () => bot?.remote ? actions.openSettings("bots", bot.id) : setView("settings") + }, (0, import_react43.createElement)(GearIcon)) : null, + bot && !bot.remote && roster.browser === true && actions.openBrowser ? (0, import_react43.createElement)("button", { type: "button", className: "bt-round", "aria-label": t("Open browser"), title: t("Open this Bot's browser"), onClick: () => actions.openBrowser(sessionId) }, (0, import_react43.createElement)(GlobeIcon)) : null, -+ bot || room2 ? (0, import_react43.createElement)("button", { type: "button", className: "bt-round", "aria-label": copied ? t("Copied conversation") : t("Share conversation"), title: copied ? t("Copied") : t("Copy conversation as Markdown"), onClick: share }, copied ? (0, import_react43.createElement)("span", { key: "copied", className: "bt-pop-in" }, "✓") : (0, import_react43.createElement)(ShareIcon)) : null, ++ bot || room2 ? (0, import_react43.createElement)("button", { ++ type: "button", ++ className: "bt-round", ++ // #1347: the accessible name says what the click does, which is copy — "Share conversation" ++ // promised a sharing step that never came. It still says "Copied" after a copy, so a screen ++ // reader hears the same confirmation the toast gives. ++ "aria-label": copied ? t("Copied conversation") : t("Copy conversation"), ++ title: copied ? t("Copied") : t("Copy conversation as Markdown"), ++ onClick: share ++ }, copied ? (0, import_react43.createElement)("span", { key: "copied", className: "bt-pop-in" }, "✓") : (0, import_react43.createElement)(ShareIcon)) : null, + hasStrip ? null : (0, import_react43.createElement)("button", { type: "button", className: "bt-round", "aria-label": t("Close details"), onClick: close }, (0, import_react43.createElement)(CloseIcon)) ); if (!bot && !room2) { @@ -20674,7 +20869,7 @@ diff --git a/client.js b/client.js className: "bt-drawer-name-input", autoFocus: true, value: draftName, -@@ -11535,87 +11535,86 @@ function DetailsDrawer({ sessionId, startRenaming, startTab, hasStrip, roster, a +@@ -11535,98 +11535,97 @@ function DetailsDrawer({ sessionId, startRenaming, startTab, hasStrip, roster, a setEditing(false); } } @@ -20804,7 +20999,10 @@ diff --git a/client.js b/client.js } var savedWidth = () => { try { -@@ -11625,8 +11625,8 @@ var savedWidth = () => { + const value = Number(localStorage.getItem(WIDTH_KEY)); +- return Number.isFinite(value) && value >= DETAILS_WIDTH ? value : null; ++ return Number.isFinite(value) && value >= MIN_DETAILS ? value : null; + } catch { return null; } }; @@ -21137,7 +21335,7 @@ diff --git a/client.js b/client.js +// src/client/remote-bot-settings.js +var import_react45 = require("react"); +var REMOTE_BOT_SETTINGS_CSS = ` -+.bt-remote-sheet-layer{position:fixed;inset:0;z-index:60;display:flex;align-items:flex-end;justify-content:center;padding:16px;pointer-events:none} ++.bt-remote-sheet-layer{position:fixed;inset:0;z-index:120;isolation:isolate;display:flex;align-items:flex-end;justify-content:center;padding:16px;pointer-events:none} +.bt-remote-sheet-layer[data-leaving]{pointer-events:none} +.bt-remote-sheet-layer .bt-scrim{position:absolute;inset:0;z-index:0;pointer-events:auto} +.bt-remote-sheet-layer[data-leaving] .bt-scrim{opacity:0;transition:opacity .16s ease-in} diff --git a/patches/ds-bot/0012-room-membership.patch b/patches/ds-bot/0012-room-membership.patch index 475df6cbc..1917de143 100644 --- a/patches/ds-bot/0012-room-membership.patch +++ b/patches/ds-bot/0012-room-membership.patch @@ -92,7 +92,7 @@ diff --git a/src/client/details-drawer.js b/src/client/details-drawer.js import { Segmented, useGlide, useMorph } from './motion.js' import { MemberWork, useMemberWork } from './member-status.js' import { t } from './i18n.js' -+import { remoteBotTitle, roomTitle } from '../shared/remote-label.js' ++import { remoteBotTitle, roomTitle, copiedToClipboard } from '../shared/remote-label.js' +import { FlyBotRoomOverview, FlyBotDetails } from './flybot-slots.js' +import { call } from './api.js' @@ -221,7 +221,22 @@ diff --git a/src/client/details-drawer.js b/src/client/details-drawer.js ] } -@@ -176,7 +176,7 @@ export function DetailsDrawer({ sessionId, startRenaming, startTab, hasStrip, ro +@@ -170,13 +170,21 @@ export function DetailsDrawer({ sessionId, startRenaming, startTab, hasStrip, ro + .then(() => { setError(''); return true }) + .catch(failure => { setError(failure?.message ?? String(failure)); return false }) + const share = () => { +- void navigator.clipboard?.writeText(transcript).then(() => { ++ // #1347: a copy that says nothing is a click that did nothing to anyone watching. `copiedToClipboard` ++ // answers whether the text really landed — a page with no clipboard at all is a `false` there, not the ++ // quiet success `navigator.clipboard?.writeText(...)` gives when the API is missing — so the toast ++ // reports what happened instead of the tick alone claiming it. ++ const text = transcript ++ if (!text) { actions.notify?.(t('Nothing to copy yet')); return } ++ void copiedToClipboard(text).then(written => { ++ if (!written) { actions.reportError?.(new Error(t('Could not copy the conversation')), 'share'); return } + setCopied(true) + setTimeout(() => setCopied(false), 1600) ++ actions.notify?.(t('Conversation copied as Markdown')) }) } const errorLine = error ? h('div', { className: 'bt-error', role: 'alert', style: { padding: 0 } }, error) : null @@ -230,7 +245,7 @@ diff --git a/src/client/details-drawer.js b/src/client/details-drawer.js return [ h('div', { key: 'head', className: 'bt-subhead', 'data-move': move }, h('button', { type: 'button', className: 'bt-icon-btn', 'aria-label': t('Back to details'), onClick: () => setView('overview') }, h(ChevronLeftIcon)), -@@ -187,8 +187,12 @@ export function DetailsDrawer({ sessionId, startRenaming, startTab, hasStrip, ro +@@ -187,11 +187,23 @@ export function DetailsDrawer({ sessionId, startRenaming, startTab, hasStrip, ro ] } const top = h('div', { className: 'bt-drawer-top', 'data-move': side }, @@ -244,7 +259,19 @@ diff --git a/src/client/details-drawer.js b/src/client/details-drawer.js + bot && !bot.remote && roster.browser === true && actions.openBrowser ? h('button', { type: 'button', className: 'bt-round', 'aria-label': t('Open browser'), title: t("Open this Bot's browser"), onClick: () => actions.openBrowser(sessionId) }, h(GlobeIcon)) : null, - bot || room ? h('button', { type: 'button', className: 'bt-round', 'aria-label': copied ? t('Copied conversation') : t('Share conversation'), title: copied ? t('Copied') : t('Copy conversation as Markdown'), onClick: share }, copied ? h('span', { key: 'copied', className: 'bt-pop-in' }, '✓') : h(ShareIcon)) : null, +- bot || room ? h('button', { type: 'button', className: 'bt-round', 'aria-label': copied ? t('Copied conversation') : t('Share conversation'), title: copied ? t('Copied') : t('Copy conversation as Markdown'), onClick: share }, copied ? h('span', { key: 'copied', className: 'bt-pop-in' }, '✓') : h(ShareIcon)) : null, ++ bot || room ? h('button', { ++ type: 'button', className: 'bt-round', ++ // #1347: the accessible name says what the click does, which is copy — "Share conversation" ++ // promised a sharing step that never came. It still says "Copied" after a copy, so a screen ++ // reader hears the same confirmation the toast gives. ++ 'aria-label': copied ? t('Copied conversation') : t('Copy conversation'), ++ title: copied ? t('Copied') : t('Copy conversation as Markdown'), ++ onClick: share, ++ }, copied ? h('span', { key: 'copied', className: 'bt-pop-in' }, '✓') : h(ShareIcon)) : null, + hasStrip ? null : h('button', { type: 'button', className: 'bt-round', 'aria-label': t('Close details'), onClick: close }, h(CloseIcon))) + if (!bot && !room) { + return [h(Fragment, { key: 'top' }, top), @@ -213,22 +213,30 @@ export function DetailsDrawer({ sessionId, startRenaming, startTab, hasStrip, ro h('div', { key: 'id', className: 'bt-drawer-id', 'data-move': side }, bot ? h(BotAvatar, { bot, size: 64, state: botState(running, doing, roster.questions?.[part] !== undefined), main: isMainBot, badge: false, gaze: true, pokeable: true }) diff --git a/patches/ds-bot/files/src/client/flybot-slots.js b/patches/ds-bot/files/src/client/flybot-slots.js index 15d4041eb..c0ed0e6ba 100644 --- a/patches/ds-bot/files/src/client/flybot-slots.js +++ b/patches/ds-bot/files/src/client/flybot-slots.js @@ -44,6 +44,7 @@ import { t } from './i18n.js' import { chatOpened, claimApprovals, claimedIds, onClaims } from './approval-claims.js' import { isApprovalWaiting, WAITING_STATUSES } from '../shared/approval-waiting.js' import { canRetry } from '../shared/held-retry.js' +import { isMainOf } from './sources.js' import { createClientApi as createClientApiWith } from './flybot-registry.js' @@ -172,8 +173,34 @@ export function FlyBotSessionDock({ sessionId, useRoster, actions, session }) { // that fact on the snapshot, and a failed first send never turns it back off, so standing in for a // header the host is already drawing cannot happen. const blank = session?.blank === true && session?.promptAttempted !== true - return h(Fragment, null, entries.map(entry => h('div', { key: entry.id, className: 'bt-flybot-session-dock', 'data-flybot': entry.id }, - h(Safe, { entry, props: { sessionId, roster, room, bot, actions, call, t, blank: blank === true, claim: ids => claimApprovals(`${entry.id}:${sessionId}`, ids) } })))) + // #1347: a Bot's chat has the same first-paint problem as a group's, and the sidebar row is the only + // thing naming it. The shell draws `headerBlank` with no title for a Session that has said nothing, + // so the panel below the composer reads as a blank column: no name, no avatar, no way back to the + // Bot's details. The strip is the same one a group gets (#998) and reads the same `blank` fact. + // A friend's Bot is not this home's chat: it keeps its own empty hint (#173), so it is left out. + const botRoom = blank && bot !== undefined && !bot.remote ? bot : null + return h(Fragment, null, + botRoom ? h(BotChatStrip, { bot: botRoom, roster, actions, sessionId }) : null, + entries.map(entry => h('div', { key: entry.id, className: 'bt-flybot-session-dock', 'data-flybot': entry.id }, + h(Safe, { entry, props: { sessionId, roster, room, bot, actions, call, t, blank: blank === true, claim: ids => claimApprovals(`${entry.id}:${sessionId}`, ids) } })))) +} + +// A Bot chat's own header before its first turn (#1347): who this chat is with, that nothing has been +// said yet, and the one thing a blank column cannot offer — its details. It is the dock's own child, so +// it disappears the moment the shell draws the real header (the same `blank` condition #998 uses). +export function BotChatStrip({ bot, roster, actions, sessionId }) { + const main = isMainOf(roster, bot.id) + const subtitle = [bot.role, main ? t('Main Bot') : ''].filter(Boolean).join(' · ') || t('Bot') + return h('div', { className: 'bt-chat-strip', 'data-flybot': 'bot-chat-strip' }, + h(BotAvatar, { bot, size: 44, main, live: false }), + h('div', { className: 'bt-chat-strip-who' }, + h('span', { className: 'bt-chat-strip-name', title: bot.name }, bot.name), + h('span', { className: 'bt-chat-strip-sub' }, subtitle), + h('span', { className: 'bt-chat-strip-empty' }, t('No messages yet. Say something to {name}.', { name: bot.name }))), + h('button', { + type: 'button', className: 'bt-soft bt-soft-sm bt-chat-strip-go', + onClick: () => (actions?.toggleDetails ? actions.toggleDetails(sessionId) : undefined), + }, t('Details'))) } export function FlyBotRoomOverview({ room, roster, actions }) { @@ -323,6 +350,15 @@ export const fileSize = n => (n < 1024 ? `${n} B` : n < 1048576 ? `${(n / 1024). export const FLYBOT_CSS = ` .bt-flybot-session-dock{width:100%;max-width:min(992px,100%);margin:0 auto;padding:0 0 6px;box-sizing:border-box} +/* #1347: a Bot chat's own strip before its first turn, the same shape a group's uses (#998): one row + above the composer naming the chat, so a blank column is never nameless. */ +.bt-chat-strip{display:flex;align-items:center;gap:12px;width:100%;max-width:min(992px,100%);margin:0 auto 10px;padding:10px 12px;box-sizing:border-box;border:.5px solid var(--bt-line-2);border-radius:14px;background:var(--bt-card,var(--bt-main));animation:bt-rise .18s ease-out} +.bt-chat-strip-who{flex:1 1 auto;min-width:0;display:flex;flex-direction:column;gap:2px} +.bt-chat-strip-name{font-size:15px;line-height:20px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.bt-chat-strip-sub{font-size:12px;line-height:16px;color:var(--bt-ink-3);overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.bt-chat-strip-empty{font-size:12px;line-height:16px;color:var(--bt-ink-3);overflow-wrap:anywhere} +.bt-chat-strip-go{flex:none} +@media (prefers-reduced-motion:reduce){.bt-chat-strip{animation:none}} .bt-flybot-session-dock [data-flybot=chat-approvals]{align-items:flex-end;padding-bottom:4px} .bt-flybot-session-dock .fs-hold{margin-left:auto} .bt-flybot-approval-files { margin: 0 0 8px; padding-left: 18px; font-size: 12px; } diff --git a/patches/ds-bot/files/src/client/inbox.js b/patches/ds-bot/files/src/client/inbox.js index bd9684f7a..0febe9a34 100644 --- a/patches/ds-bot/files/src/client/inbox.js +++ b/patches/ds-bot/files/src/client/inbox.js @@ -16,6 +16,7 @@ import { useEscape } from './overlay-hooks.js' import { alwaysChoices } from './member-status.js' import { currentPart, previewOf, relativeShort } from './text.js' import { hostText, t } from './i18n.js' +import { failureSentence } from '../shared/failure-copy.js' // Done (and a failed task) ages out after 7 days. export const KEEP_MS = 7 * 86_400_000 @@ -461,7 +462,7 @@ export function InboxCard({ card, roster, now, busy, error, onAnswer, onOpen }) h('span', { className: 'bt-inbox-meta', title: meta }, meta)), h('span', { className: 'bt-inbox-title', title: card.title }, card.title), card.calls > 0 ? h('span', { className: 'bt-inbox-calls' }, t('{count} tool calls', { count: card.calls })) : null, - card.reason ? h('span', { className: 'bt-inbox-reason', title: card.reason }, card.reason) : null, + card.reason ? h('span', { className: 'bt-inbox-reason', title: card.reason }, failureSentence(card.reason, t)) : null, card.activity ? h('span', { className: 'bt-inbox-activity', title: card.activity }, t('Latest: {text}', { text: card.activity })) : null, card.hint ? h('span', { className: 'bt-inbox-hint' }, card.hint) : null, evidence.length > 0 ? h('span', { className: 'bt-inbox-evidence' }, ...evidence.map((href, index) => h('a', { diff --git a/patches/ds-bot/files/src/client/model-test.js b/patches/ds-bot/files/src/client/model-test.js index 8f0bece99..3b66e136a 100644 --- a/patches/ds-bot/files/src/client/model-test.js +++ b/patches/ds-bot/files/src/client/model-test.js @@ -62,4 +62,12 @@ export const MODEL_TEST_CSS = ` .bt-model-test-result[data-ok=true]{color:#1a7f37} .bt-model-test-result[data-ok=false]{color:#c21d2e} .bt-model-test-code{margin-left:6px;font-size:11px;color:var(--bt-ink-3)} +/* #1347: the hint beside "Test connection" is prose and wraps; the button next to it keeps its own + words ("Test connection" on one line) and takes the room it needs. */ +.bt-model-test>.bt-set-copy{flex:1 1 12ch;min-width:12ch} +.bt-model-test>.bt-soft{flex:none;white-space:nowrap} +@container bt-panel (max-width:300px){ + .bt-model-test{flex-wrap:wrap;row-gap:6px} + .bt-model-test>.bt-set-copy{flex:1 1 100%} +} ` diff --git a/patches/ds-bot/files/src/client/pane-width.js b/patches/ds-bot/files/src/client/pane-width.js index 01f124fa9..e1a75b870 100644 --- a/patches/ds-bot/files/src/client/pane-width.js +++ b/patches/ds-bot/files/src/client/pane-width.js @@ -4,7 +4,11 @@ // DETAILS_WIDTH..DETAILS_MAX, so a wider browser comes back to DETAILS_MAX and a narrower one keeps // its width; the browser takes any width from MIN_WIDTH. export const DEFAULT_WIDTH = 520 -export const DETAILS_WIDTH = 320 +// #1347: 320 was too narrow for the settings form the details view hosts. A label and its control +// share one row ("Name" beside the name field, "Test connection" beside its hint), and at 320 the +// label took 36px — "Nam" on one line and "e" on the next — while the button broke into +// "Test connecti" / "on". 380 keeps every such row on one line at the default text size. +export const DETAILS_WIDTH = 380 export const DETAILS_MAX = 520 export const MIN_WIDTH = 360 // The conversation keeps this much room beside the panel whenever the window can hold it; below @@ -13,6 +17,8 @@ export const MIN_CHAT = 420 // A narrow window shrinks the Details panel before it starves the conversation (#705): below // MIN_CHAT + DETAILS_WIDTH the panel gives way down to MIN_DETAILS, so the row fits the window and // no pane is pushed past its own edge (the conversation may then be narrower than MIN_CHAT). +// Below MIN_DETAILS the settings row itself stops wrapping: it stacks (see `.bt-bs-row` in +// styles.js), because a 240px column cannot hold a label and a 96px field on one line. export const MIN_DETAILS = 240 export function panelWidthRange(view, viewport) { diff --git a/patches/ds-bot/files/src/client/remote-bot-settings.js b/patches/ds-bot/files/src/client/remote-bot-settings.js index 6434bc20f..aaab11d8d 100644 --- a/patches/ds-bot/files/src/client/remote-bot-settings.js +++ b/patches/ds-bot/files/src/client/remote-bot-settings.js @@ -6,7 +6,7 @@ import { FlyBotDetails } from './flybot-slots.js' import { t } from './i18n.js' export const REMOTE_BOT_SETTINGS_CSS = ` -.bt-remote-sheet-layer{position:fixed;inset:0;z-index:60;display:flex;align-items:flex-end;justify-content:center;padding:16px;pointer-events:none} +.bt-remote-sheet-layer{position:fixed;inset:0;z-index:120;isolation:isolate;display:flex;align-items:flex-end;justify-content:center;padding:16px;pointer-events:none} .bt-remote-sheet-layer[data-leaving]{pointer-events:none} .bt-remote-sheet-layer .bt-scrim{position:absolute;inset:0;z-index:0;pointer-events:auto} .bt-remote-sheet-layer[data-leaving] .bt-scrim{opacity:0;transition:opacity .16s ease-in} diff --git a/patches/ds-bot/files/src/shared/failure-copy.js b/patches/ds-bot/files/src/shared/failure-copy.js new file mode 100644 index 000000000..257a17831 --- /dev/null +++ b/patches/ds-bot/files/src/shared/failure-copy.js @@ -0,0 +1,52 @@ +// A failed turn's sentence a person can act on (#1347). +// +// The host keeps what the provider said, cut to a length that fits a card +// (`noteEnded` in host/turns.js). That text is a JSON body more often than not, so a real +// instance showed the Inbox as +// +// 404: {"code":"UnsupportedModel","message":"The requested model does not support … +// +// — cut off before the part that says what to do. The words below are the same fact in the +// reader's language: which setting is wrong, and where to change it. They are keyed on the +// provider's own `code` field, which is stable, not on the message text, which is not. +// +// Anything this does not recognise is returned unchanged: a failure nobody has mapped yet is +// still better shown as it came than replaced with a sentence that guessed. + +// The provider code inside the failure text, from either shape the host may have stored: +// the raw JSON body, or `code: message`. +const providerCode = (text) => { + const json = /"code"\s*:\s*"([A-Za-z0-9_.-]+)"/.exec(text) + if (json) return json[1] + const plain = /^([A-Za-z][A-Za-z0-9_.-]{2,40}):\s/.exec(text) + return plain ? plain[1] : '' +} + +// What is wrong and where to fix it, per provider code. `t` is the caller's translate, so the +// sentence is in the reader's language; the key is English, as everywhere else in the client. +const KNOWN = { + UnsupportedModel: 'This model does not support that request. Pick another model in the Bot\'s settings.', + ModelNotFound: 'The provider does not know this model. Check the model name in the Bot\'s settings.', + ContextLengthExceeded: 'The conversation is longer than this model can read. Condense it, or pick a model with a larger window.', + InvalidRequest: 'The model service refused the request. Check the model and the endpoint in the Bot\'s settings.', + InsufficientQuota: 'The account is out of quota.', + RateLimitExceeded: 'Too many requests right now. Try again in a moment.', +} + +// `raw` is the stored failure text; `translate` turns a key into the reader's language. The fill after +// it is harmless where the translator already substituted, and keeps a caller without a locale service +// (or a test's identity translate) from showing a raw `{feature}`. +const fill = (text, params) => (params === undefined ? text : String(text).replace(/\{(\w+)\}/g, (match, name) => (name in params ? String(params[name]) : match))) + +export function failureSentence(raw, translate = key => key) { + const text = String(raw ?? '').trim() + if (text === '') return text + const key = KNOWN[providerCode(text)] + if (key === undefined) return text + // A refusal that names the feature is worth saying in that word, because the feature is what the + // person has to stop asking for. The wording is the provider's, so it is quoted, not translated. + const feature = /does not support\s+([a-z][a-z0-9 _-]{2,40})/i.exec(text)?.[1]?.trim() + if (key !== KNOWN.UnsupportedModel || feature === undefined) return fill(translate(key), undefined) + const withFeature = 'This model does not support {feature}. Pick another model in the Bot\'s settings.' + return fill(translate(withFeature, { feature }), { feature }) +} diff --git a/patches/ds-bot/files/src/shared/preview.js b/patches/ds-bot/files/src/shared/preview.js new file mode 100644 index 000000000..980e1e426 --- /dev/null +++ b/patches/ds-bot/files/src/shared/preview.js @@ -0,0 +1,42 @@ +// What a preview may show of a stored line (#130 follow-up). +// +// A remote line is fenced for the Bot (`…`), and the +// fence is data: a person reading a sidebar row must never see it. `unwrapUntrusted` handles the one +// shape this code writes today, a complete wrap. A stored line can also carry a *broken* fence — an +// older write, a hand-edited log, a nested fragment — and then the wrapper leaks into the preview as +// `from="User"> B踢后测4 >`, which is what a real instance showed in its sidebar (#1347). +// +// So a preview is read the same way whatever shape the fence is in: unwrap the complete wraps, then +// remove whatever marker fragments are left, until neither changes the text. Words that never were +// part of a marker are kept, because a preview is still the line someone said. +const FENCE_TAG = /<\s*\/?\s*untrusted-(?:message|file)\b[^<>]*>/gi +// What is left when the tag name alone was stripped by an older pass: ` from="User">`. +const FENCE_ATTR = /\bfrom\s*=\s*(?:"[^"]*"|'[^']*')\s*>/gi +// A lone opener/closer with its attributes cut short, e.g. `` minus `>`. +const FENCE_TAIL = /<[^<>]*\buntrusted-(?:message|file)\b[^<>]*$/i +// Only the marker word is left, its brackets already gone: `untrusted-message>` / `/untrusted-file`. +const FENCE_BARE = /(?:\/\s*)?untrusted-(?:message|file)\b\s*>?/gi + +export function stripFence(text) { + let out = String(text ?? '') + for (let previous; previous !== out;) { + previous = out + out = out.replace(FENCE_TAG, '').replace(FENCE_ATTR, '') + } + return out.replace(FENCE_TAIL, '').replace(FENCE_BARE, '') +} + +// The words of a stored line as a preview shows them: inner text of a wrap, no marker of any shape, +// and one line, because a row is one line. +export function visibleText(text) { + let out = String(text ?? '') + for (let previous; previous !== out;) { + previous = out + const open = out.indexOf('') + const inner = close > open ? out.slice(out.indexOf('>', open) + 1, close) : out + out = stripFence(inner) + } + return stripFence(out).replace(/\s+/g, ' ').trim() +} diff --git a/patches/ds-bot/files/src/shared/turn-failures.js b/patches/ds-bot/files/src/shared/turn-failures.js index 7ae5d2696..87ed6b1a0 100644 --- a/patches/ds-bot/files/src/shared/turn-failures.js +++ b/patches/ds-bot/files/src/shared/turn-failures.js @@ -1,6 +1,7 @@ // What a chat says about turns that died before writing a word (#1044). The host marks those `ended` // entries `silent` and counts the streak at the same place; this turns the snapshot the client already // has into the lines a chat draws. Kept free of React so both the bundle and the tests can read it. +import { failureSentence } from './failure-copy.js' export const FAILURE_LINES_SHOWN = 3 @@ -11,7 +12,9 @@ export const failureLines = (snapshot, place, t, limit = FAILURE_LINES_SHOWN) => .slice(-limit) .map(entry => ({ id: `turn-failure-${entry.id}`, - text: t('Could not run: {error}', { error: entry.failed ?? t('the model call failed') }), + // The stored text can be a provider's JSON body cut mid-sentence; `failureSentence` says the + // same fact in words that name what to change (#1347). + text: t('Could not run: {error}', { error: entry.failed === undefined ? t('the model call failed') : failureSentence(entry.failed, t) }), ...(Number(entry.streak) > 1 ? { streak: Number(entry.streak) } : {}), })) } diff --git a/patches/ds-bot/files/test/narrow-pane.test.js b/patches/ds-bot/files/test/narrow-pane.test.js index 6ed3df228..282a1709a 100644 --- a/patches/ds-bot/files/test/narrow-pane.test.js +++ b/patches/ds-bot/files/test/narrow-pane.test.js @@ -1,7 +1,7 @@ import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import { test } from 'node:test' -import { MIN_DETAILS, panelWidthRange } from '../src/client/pane-width.js' +import { MIN_DETAILS, DETAILS_WIDTH, panelWidthRange } from '../src/client/pane-width.js' import { PANE_MIN_WIDTH_CSS } from '../src/client/panes.js' // Narrow multi-pane windows (#705): the Details panel's width is reserved as a margin on the @@ -14,9 +14,13 @@ const rule = (selector) => { test('#705: a narrow window shrinks the Details panel before it starves the conversation', () => { assert.equal(MIN_DETAILS, 240) - // Room to spare: the panel keeps its own 320..520 range. - assert.deepEqual(panelWidthRange('details', 1440), [320, 520]) - assert.deepEqual(panelWidthRange('details', 800), [320, 380]) + // #1347: the Details panel opens at 380, not 320. A settings row holds a label and its control on + // one line ("Name" beside its field, "Test connection" beside its hint); at 320 the label took 36px + // and broke into "Nam" + "e", and the button into "Test connecti" + "on". + assert.equal(DETAILS_WIDTH, 380) + // Room to spare: the panel keeps its own 380..520 range. + assert.deepEqual(panelWidthRange('details', 1440), [380, 520]) + assert.deepEqual(panelWidthRange('details', 800), [380, 380]) // 620px: only 200px would be left for the conversation, so the panel goes down to 240. assert.deepEqual(panelWidthRange('details', 620), [240, 240]) assert.deepEqual(panelWidthRange('details', 500), [240, 240]) diff --git a/patches/ds-bot/files/test/overlay-approvals.test.js b/patches/ds-bot/files/test/overlay-approvals.test.js index d612c824f..278b7fc6c 100644 --- a/patches/ds-bot/files/test/overlay-approvals.test.js +++ b/patches/ds-bot/files/test/overlay-approvals.test.js @@ -190,6 +190,42 @@ test('#998: the session dock tells a slot whether the chat is still blank', asyn } finally { off() } }) +// #1347: a Bot chat's first paint has no header either (`headerBlank`), so the column under it is a +// blank box with a composer. The dock draws its own strip on exactly the same fact a group's does — +// and never for a friend's Bot chat, which is not this home's and has its own hint, nor beside a header +// the shell has already drawn. The strip's own text is asserted through its component (`BotChatStrip`), +// because this renderer does not resolve function components. +test('#1347: a blank Bot chat draws its own strip, and only while it is blank', async () => { + const { FlyBotSessionDock, BotChatStrip, registry } = await import('../src/client/flybot-slots.js') + const off = registry().register('flybot.session.dock', { id: 'probe-strip', component: () => null }) + const strips = node => { + if (!node || typeof node !== 'object') return [] + if (typeof node.type === 'function') return node.type.name === 'BotChatStrip' ? [node] : [] + return (node.children ?? []).flatMap(strips) + } + try { + const owner = { id: 'bot-1', name: 'Helper', role: 'Researcher', ownerId: 'me' } + const renderWith = (session, bot = owner) => { + const inst = { hooks: {}, effects: [], rendered: false, props: { sessionId: 'bot-1', useRoster: pick => pick({ byId: { 'bot-1': bot }, roomsById: {} }), session, actions: {} } } + return strips(render(FlyBotSessionDock, inst)) + } + // Nothing said, nothing sent: the shell drew no header, so the strip stands in. + const shown = renderWith({ blank: true }) + assert.equal(shown.length, 1, 'one strip while the chat is blank') + assert.equal(shown[0].props.bot.name, 'Helper', 'it is drawn for this chat\'s Bot') + // From the first send on the shell draws its own header; a second one would be two headers. + assert.equal(renderWith({ blank: true, promptAttempted: true }).length, 0) + assert.equal(renderWith({ blank: false }).length, 0) + assert.equal(renderWith(undefined).length, 0) + // A friend's Bot is its owner's chat, not this home's: it has its own empty hint (#173), not this strip. + assert.equal(renderWith({ blank: true }, { ...owner, remote: { handle: '@helper.bob.home' } }).length, 0) + // What the strip says: the name, and why the column is empty. + const text = JSON.stringify(render(BotChatStrip, { hooks: {}, effects: [], rendered: false, props: { bot: owner, roster: { byId: {}, roomsById: {} }, actions: {}, sessionId: 'bot-1' } })) + assert.match(text, /bt-chat-strip-name/, 'it names the chat') + assert.match(text, /No messages yet/, 'and says why the column is empty') + } finally { off() } +}) + // #968: the approval card in the dock is the second surface that used to ignore the flag. A terminal failure // draws no Retry; the retryable one keeps it. test('#968: a terminal failed approval draws no Retry in the dock card', async () => { diff --git a/patches/ds-bot/files/test/ui-clickthrough.test.js b/patches/ds-bot/files/test/ui-clickthrough.test.js new file mode 100644 index 000000000..dc164c0b9 --- /dev/null +++ b/patches/ds-bot/files/test/ui-clickthrough.test.js @@ -0,0 +1,201 @@ +// #1347: a real mouse click-through of a running FlyBot found UI defects that no test covered. +// Each one is a shape of the shipped page, so each test reads the CSS or the source that draws it and +// asserts the property that was wrong — not the fix by name. Nothing here imports a client module: +// those need React from a DSH install, and the neighbours (`ui-polish-406.test.js`) read the text. +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { test } from 'node:test' +import { visibleText } from '../src/shared/preview.js' +import { failureSentence } from '../src/shared/failure-copy.js' +import { failureLines } from '../src/shared/turn-failures.js' + +const read = name => readFileSync(new URL(`../src/client/${name}`, import.meta.url), 'utf8').replace(/\r\n/g, '\n') +// One rule body out of a CSS template literal, matched on its selector. +const rule = (css, selector) => { + const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + return css.match(new RegExp(`(?:^|\\n)${escaped}\\{([^}]*)\\}`))?.[1] ?? '' +} +// The whole declaration line. `[^}]*` above stops at the `}` inside an interpolated +// `${SPRING_SHAPE.easing}`, so a rule that carries one has to be read as a line. +const ruleLine = (css, selector) => css.split('\n').find(line => line.startsWith(`${selector}{`)) ?? '' +const block = (css, opener) => css.match(new RegExp(`${opener.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\{([\\s\\S]*?)\\n\\}`))?.[1] ?? '' + +test('#1347: a settings row never breaks its own label or its control words', () => { + const css = read('bot-settings.js') + const label = rule(css, '.bt-bs-row>.bt-set-copy>.bt-bs-label') + assert.match(label, /white-space:nowrap/, 'the label is one line, so "Name" cannot become "Nam" + "e"') + assert.match(label, /text-overflow:ellipsis/, 'and a label too long to fit ends in an ellipsis, never mid-word') + // A hint is a sentence, not a label: it wraps and is never cut, or the settings become unreadable. + assert.doesNotMatch(css, /\.bt-set-hint\{[^}]*text-overflow:ellipsis/) + const control = rule(css, '.bt-bs-row .bt-soft,.bt-bs-row .bt-mini') + assert.match(control, /white-space:nowrap/, '"Test connection" and "Change" stay whole') + assert.match(control, /flex:none/, 'a control is not squeezed below its own words') + // The copy is what gives way, but never below a few characters of its own. + assert.match(rule(css, '.bt-bs-row>.bt-set-copy'), /flex:1 1 8ch/) +}) + +test('#1347: the panel is a container, and below 300px the row stacks instead of clipping', () => { + // A container query only fires where one is declared; without the declaration the rule is dead CSS + // that reads green and fixes nothing. + assert.match(ruleLine(read('styles.js'), '.bt-panel'), /container:bt-panel\/inline-size/) + const stacked = block(read('bot-settings.js'), '@container bt-panel (max-width:300px)') + assert.match(stacked, /\.bt-bs-row\{flex-wrap:wrap/, 'the row stacks at the panel floor') + assert.match(stacked, /\.bt-bs-row>\.bt-set-copy\{flex:1 1 100%/, 'the copy keeps the whole width') + assert.match(stacked, /\.bt-bs-input\{[^}]*text-align:left/, 'a stacked field is left-aligned, like any form field') + // The connection test's own button is covered too. + assert.match(rule(read('model-test.js'), '.bt-model-test>.bt-soft'), /white-space:nowrap/) +}) + +test('#1347: a preview shows the words of a line, never its fence', () => { + assert.equal(visibleText('\nhello there\n'), 'hello there') + // The shape a real instance showed in its sidebar: the opener arrived cut, so only its tail survived. + assert.equal(visibleText('kick from="User"> B test 4 >'), 'kick B test 4 >') + // A nested fragment cannot rebuild a marker the strip already removed. + assert.equal(visibleText('message> after'), 'after') + assert.equal(visibleText('untrusted-message> x'), 'x') + // Plain words stay exactly as said, folded to the one line a row is. + assert.equal(visibleText('a\nb c'), 'a b c') + assert.equal(visibleText(''), '') + assert.equal(visibleText(undefined), '') +}) + +test('#1347: both sidebar row kinds read their preview through that helper', () => { + const text = read('text.js') + const preview = text.slice(text.indexOf('export function previewOf'), text.indexOf('const formatTokens')) + assert.match(preview, /return visibleText\(/, 'a Bot row preview is sanitized where it is built') + const sidebar = read('sidebar.js') + assert.match(sidebar, /nameMentions\(visibleText\(room\.preview\), roster\)/, "a group row sanitizes the host's preview") + const channel = readFileSync(new URL('../src/host/channel.js', import.meta.url), 'utf8') + assert.match(channel, /const previewText = last \? visibleText\(last\.text\) : ''/, 'the host sends words, not a fence') +}) + +test('#1347: the details drawer copies, says so, and names the button for what it does', () => { + const source = read('details-drawer.js') + // The accessible name matches the behaviour: no "Share" on a button that only copies. + assert.match(source, /'aria-label': copied \? t\('Copied conversation'\) : t\('Copy conversation'\)/) + assert.doesNotMatch(source, /t\('Share conversation'\)/) + // A copy that succeeds is announced; a refused one is reported, never swallowed as success. The write + // goes through the helper that answers what really happened: `navigator.clipboard?.writeText(text)` with + // no clipboard resolves `undefined`, which the old shape read as a successful copy (#1347). + assert.match(source, /void copiedToClipboard\(text\)\.then\(written =>/) + assert.match(source, /if \(!written\) \{ actions\.reportError\?\.\(new Error\(t\('Could not copy the conversation'\)\), 'share'\); return \}/) + assert.match(source, /actions\.notify\?\.\(t\('Conversation copied as Markdown'\)\)/) + assert.match(source, /setCopied\(true\)/) + // Every string it now says has a Chinese entry. + const i18n = read('i18n.js') + for (const key of ['Copy conversation', 'Nothing to copy yet', 'Conversation copied as Markdown', 'Could not copy the conversation']) { + assert.match(i18n, new RegExp(`\\n {2}'${key}': '`), `missing zh entry for ${key}`) + } +}) + +test('#1347: the mode word beside the title does not look like a control', () => { + const chip = rule(read('styles.js'), '[data-slot="conversation.session.header.actions"] [class*="_label"]') + assert.match(chip, /background:none/, 'no fill, so it reads as a status') + assert.match(chip, /border-radius:0/) + // The description stays reachable on hover, which is the only thing behind the chip. + assert.match(chip, /cursor:help/) +}) + +test('#1347: a Bot chat with nothing said yet draws its own header', () => { + const source = read('flybot-slots.js') + // The same `blank` fact #998 uses for a group, and only then: never beside a header the shell drew. + assert.match(source, /const botRoom = blank && bot !== undefined && !bot\.remote \? bot : null/) + assert.match(source, /botRoom \? h\(BotChatStrip,/) + // The strip names the chat and offers the one thing a blank column cannot: its details. + assert.match(source, /'data-flybot': 'bot-chat-strip'/) + assert.match(source, /'bt-chat-strip-name'/) + assert.match(source, /actions\.toggleDetails\(sessionId\)/) + assert.match(rule(source, '.bt-chat-strip'), /display:flex/) +}) + +test('#1347: a blank column says why it is blank, and only while it is blank', () => { + const source = read('flybot-slots.js') + // The sentence lives in the strip that already stands in for the missing header, so it is drawn and + // withdrawn by the same `blank` condition — never beside the shell's own header. + assert.match(source, /'bt-chat-strip-empty'/) + assert.match(source, /t\('No messages yet\. Say something to \{name\}\.', \{ name: bot\.name \}\)/) + assert.match(rule(source, '.bt-chat-strip-empty'), /overflow-wrap:anywhere/) + assert.match(read('i18n.js'), /'No messages yet\. Say something to \{name\}\.'/) +}) + +test('#1347: a provider refusal reads as a sentence that says what to change', () => { + // The exact text a real Inbox showed, cut off where the host stops storing it. + const raw = '404: {"code":"UnsupportedModel","message":"The requested model does not support the coding plan feature. Please use a model that supports it' + const said = failureSentence(raw, key => key) + assert.doesNotMatch(said, /[{}"]/, 'no JSON body reaches the reader') + assert.doesNotMatch(said, /^404/, 'and no bare status code either') + assert.match(said, /does not support the coding plan feature/, 'the feature is still named') + assert.match(said, /Pick another model in the Bot's settings/, 'and the sentence says where to fix it') + // The same fact without the feature named still gets a sentence. + assert.equal(failureSentence('{"code":"UnsupportedModel","message":"nope"}', key => key), 'This model does not support that request. Pick another model in the Bot\'s settings.') + // Another mapped provider code, and one nobody mapped: unchanged is better than a guess. + assert.match(failureSentence('400: {"code":"ContextLengthExceeded"}', key => key), /longer than this model can read/) + assert.equal(failureSentence('boom: something new', key => key), 'boom: something new') + assert.equal(failureSentence('', key => key), '') + // The chat's own failure lines read through the same helper, so the two surfaces cannot drift. + const lines = failureLines({ ended: [{ id: 'x', sessionId: 's', silent: true, failed: raw }] }, 's', (key, params) => (params ? key.replace(/\{(\w+)\}/g, (m, n) => params[n]) : key)) + assert.equal(lines.length, 1) + assert.match(lines[0].text, /Pick another model/) + // Every sentence above has a Chinese entry. + const i18n = read('i18n.js') + for (const key of ['This model does not support that request. Pick another model in the Bot\\\'s settings.', 'The provider does not know this model. Check the model name in the Bot\\\'s settings.', 'The account is out of quota.', 'Too many requests right now. Try again in a moment.']) { + assert.ok(i18n.includes(`'${key}':`), `missing zh entry for ${key}`) + } +}) + +test('#1347: the thread cannot paint above a dialog, a sheet or a scrim', () => { + const css = read('styles.js') + // The load-bearing half. The chat column had no stacking context of its own, so any descendant the + // browser promoted (an entrance animation's transform, a filter, will-change) was ordered against the + // ROOT stacking context instead of the column, and could sort above a dialog's z-index box. This rule + // is what carries the fix: raising the tiers alone cannot, because a promoted descendant never reads + // the column's z-index at all unless the column is a context. + const column = ruleLine(css, '[class*="_centerCol"]') + assert.match(column, /isolation:isolate/, 'the chat column is its own stacking context') + assert.match(column, /z-index:0/, 'and that whole context sits below the panel (40) and dialogs (120)') + assert.match(column, /position:relative/, 'a z-index needs a positioned box to apply to') + // Not contain:paint — it would clip the reaction strip and message menu the thread portals out. + assert.doesNotMatch(column, /contain:paint/) + + // The second half: an explicit tier, so nothing ties with a dialog and wins on DOM order. Before + // #1347 the context menu and the reaction strip shared z-index 60 with the Settings dialog, so a + // popover opened after it tied and painted over the dialog. + assert.equal(rule(css, '.bt-settings-layer').match(/z-index:(\d+)/)?.[1], '120') + assert.equal(rule(css, '.bt-mem-layer').match(/z-index:(\d+)/)?.[1], '121') + for (const [file, selector] of [['styles.js', '.bt-menu'], ['styles.js', '.bt-react-strip'], ['sidebar.js', '.bt-update-pop'], ['remote-bot-settings.js', '.bt-remote-sheet-layer']]) { + const z = Number(ruleLine(read(file), selector).match(/z-index:(\d+)/)?.[1]) + assert.ok(z >= 120, `${selector} floats above the chat and the panel (was ${z})`) + } + // The palette stays the topmost sheet; the toast stays above everything. + assert.equal(rule(css, '.bt-palette-layer').match(/z-index:(\d+)/)?.[1], '140') + // No dialog or popover may sit in the old ambiguous band any more. + assert.doesNotMatch(css, /z-index:6[01][;}]/) + + // Each overlay layer is its own stacking context too, so the layer as a whole — scrim and dialog + // together — is what sits in the tier, whatever a dialog's own contents do. + for (const [file, selector] of [['styles.js', '.bt-settings-layer'], ['styles.js', '.bt-mem-layer'], ['styles.js', '.bt-palette-layer'], ['remote-bot-settings.js', '.bt-remote-sheet-layer']]) { + assert.match(ruleLine(read(file), selector), /isolation:isolate/, `${selector} is its own stacking context`) + } + + // And the scrim actually separates the two surfaces. A 0.32 scrim left the thread legible behind a + // modal, which read as the chat painting through it; the light theme is now clearly more opaque. + const alpha = sel => { + // The selector can appear more than once (the base rule and #1347's override), so take the + // strongest — that is the one the browser applies last. + const all = [...css.matchAll(new RegExp(`${sel.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\{background:rgba\\(\\s*\\d+\\s*,\\s*\\d+\\s*,\\s*\\d+\\s*,\\s*([\\d.]+)\\s*\\)`, 'g'))] + .map(m => Number(m[1])) + return all.length ? Math.max(...all) : null + } + assert.ok(alpha('body:not([data-ds-dark-theme]) .bt-scrim') >= 0.5, 'the light scrim dims the thread to a backdrop') + assert.ok(alpha('body[data-ds-dark-theme] .bt-scrim') >= 0.6, 'and so does the dark one') +}) + +test('#1347: the built bundle carries these rules, so a page reload shows them', () => { + // The app runs client.js, not src/. A constant that never reached the bundle leaves every fix above + // in the source only. + const bundle = readFileSync(new URL('../client.js', import.meta.url), 'utf8') + assert.ok(bundle.includes('container:bt-panel/inline-size'), 'client.js carries the panel container') + assert.ok(bundle.includes('@container bt-panel (max-width:300px)'), 'client.js carries the stacked row') + assert.ok(bundle.includes('bt-chat-strip-name'), 'client.js carries the blank-chat strip') + assert.ok(bundle.includes('Conversation copied as Markdown'), 'client.js carries the copy confirmation') +})