From f6a1ce5c2e7982d4c37f55b226b44066448d6d3a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 27 Oct 2026 05:24:49 +0000 Subject: [PATCH 1/7] fix(ui): the settings drawer, previews, blank chats and failure copy a click-through found A real mouse click-through of a running FlyBot found defects no test covered. This fixes the ones that reproduce, and states the two that do not. The Details panel opened at 320px and wrapped its own controls: the Name field's label broke into Nam + e, Test connection into Test connecti + on, Main Bot's Change into Ch/an/ge. It 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 instead of clipping. A saved width from the narrower policy is kept, not silently widened. A sidebar preview could carry the untrusted fence, or the fragment an older write left (from="User"> ... >), so a shared helper reads a stored line the same way whatever shape its fence is in: complete wraps are unwrapped, leftover marker fragments are removed until stable, and plain words are kept. A Bot chat with nothing said yet gets no header from the shell (headerBlank), so the column was blank with a composer. The dock now draws a strip on the same blank fact #998 uses for a group - never for a friend's Bot, never beside a header the shell drew. The details drawer's copy button is named for what it does and confirms the copy; a refused copy is reported rather than swallowed. The mode word beside the title no longer draws as a filled chip that looks clickable and is not. A provider failure stored as a cut-off JSON body now reads as a sentence naming the feature and where to change the model, keyed on the provider's stable code, in the chat's failure lines and in the Inbox. Not reproduced on DSH 0.2.0-rc.2 or 0.2.1-rc.3, so not changed: the trajectory toolbar left painted over Chat (it unmounts on the click, and through Inbox, Settings and a chat switch), and chat bubbles drawn above a modal (checked with elementsFromPoint and an opaque scrim at four viewports). The rate_limited flood in Feedback is #1161's retry storm, already fixed and pinned by core/test/room-ticket-seat.test.js. Fixes #1335 Co-authored-by: Danyang Chen --- changelog.d/1335-ui-clickthrough.fixed.md | 1 + core/client.js | 193 +- core/src/client/bot-settings.js | 17 +- core/src/client/browser-pane.js | 8 +- core/src/client/details-drawer.js | 23 +- 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/sidebar.js | 5 +- core/src/client/styles.js | 13 +- 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 | 151 + patches/ds-bot/0001-core-fixes.patch | 44 +- patches/ds-bot/0003-flybot-api-client.patch | 62 +- patches/ds-bot/0005-social-seams.patch | 8 +- patches/ds-bot/0008-client-bundle.patch | 6421 +++++++---------- 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 +- .../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 + 32 files changed, 3286 insertions(+), 4035 deletions(-) create mode 100644 changelog.d/1335-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 diff --git a/changelog.d/1335-ui-clickthrough.fixed.md b/changelog.d/1335-ui-clickthrough.fixed.md new file mode 100644 index 000000000..0fee55420 --- /dev/null +++ b/changelog.d/1335-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 ([#1335](https://github.com/ThinkFlowLab/FlyBot/issues/1335)). diff --git a/core/client.js b/core/client.js index d6f8d8e1a..5184e83d2 100644 --- a/core/client.js +++ b/core/client.js @@ -465,6 +465,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}", @@ -2184,7 +2194,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(""); @@ -2316,6 +2326,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) { @@ -2522,7 +2558,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 ""; } @@ -3078,11 +3114,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"); @@ -3243,6 +3306,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} +/* #1335: 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; } @@ -3278,13 +3350,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 (#1335). + 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) } : {} })); }; @@ -4006,7 +4107,7 @@ 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%)}} @@ -4150,6 +4251,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} +/* #1335: 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} @@ -4512,6 +4617,13 @@ 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} +/* #1335: "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. */ @@ -7884,7 +7996,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", { @@ -9656,7 +9768,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); @@ -11719,6 +11831,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)} +/* #1335: 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 @@ -13476,9 +13596,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} +/* #1335: 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)} @@ -13500,6 +13627,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}} +/* #1335: 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 @@ -15636,7 +15771,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", @@ -15657,7 +15792,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)}%`)) ); @@ -17409,7 +17544,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; @@ -17799,10 +17934,19 @@ function DetailsDrawer({ sessionId, startRenaming, startTab, hasStrip, roster, a return false; }); const share = () => { - void navigator.clipboard?.writeText(transcript).then(() => { - setCopied(true); - setTimeout(() => setCopied(false), 1600); - }); + const text = transcript; + if (!text) { + actions.notify?.(t("Nothing to copy yet")); + return; + } + void Promise.resolve(navigator.clipboard?.writeText(text)).then( + () => { + setCopied(true); + setTimeout(() => setCopied(false), 1600); + actions.notify?.(t("Conversation copied as Markdown")); + }, + () => actions.reportError?.(new Error(t("Could not copy the conversation")), "share") + ); }; const errorLine = error ? (0, import_react43.createElement)("div", { className: "bt-error", role: "alert", style: { padding: 0 } }, error) : null; if ((bot || room2) && view === "settings" && !bot?.remote) { @@ -17834,7 +17978,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", + // #1335: 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) { @@ -17958,7 +18111,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; } diff --git a/core/src/client/bot-settings.js b/core/src/client/bot-settings.js index 862840292..35efdee52 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} +/* #1335: 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}} +/* #1335: 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..2eac016f8 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 (#1335 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..8778538f0 100644 --- a/core/src/client/details-drawer.js +++ b/core/src/client/details-drawer.js @@ -257,10 +257,15 @@ 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(() => { - setCopied(true) - setTimeout(() => setCopied(false), 1600) - }) + // #1335: a copy that says nothing is a click that did nothing to anyone watching. The clipboard + // write is a promise and can be refused (no permission, no user gesture), so the toast reports + // what actually happened instead of the tick alone claiming success. + const text = transcript + if (!text) { actions.notify?.(t('Nothing to copy yet')); return } + void Promise.resolve(navigator.clipboard?.writeText(text)).then( + () => { setCopied(true); setTimeout(() => setCopied(false), 1600); actions.notify?.(t('Conversation copied as Markdown')) }, + () => actions.reportError?.(new Error(t('Could not copy the conversation')), 'share'), + ) } const errorLine = error ? h('div', { className: 'bt-error', role: 'alert', style: { padding: 0 } }, error) : null if ((bot || room) && view === 'settings' && !(bot?.remote)) { @@ -282,7 +287,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', + // #1335: 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..6cfda2ecb 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) } })))) + // #1335: 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 (#1335): 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} +/* #1335: 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 92054b7f4..ee39bbe78 100644 --- a/core/src/client/i18n.js +++ b/core/src/client/i18n.js @@ -434,6 +434,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..e7fb2c241 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)} +/* #1335: 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..eca12859f 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 +// #1335: 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/sidebar.js b/core/src/client/sidebar.js index 9f4f099d5..6459fec6e 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 (#1335). + 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) diff --git a/core/src/client/styles.js b/core/src/client/styles.js index e0f2a88e5..c7181d84b 100644 --- a/core/src/client/styles.js +++ b/core/src/client/styles.js @@ -362,7 +362,7 @@ 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%)}} @@ -506,6 +506,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} +/* #1335: 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} @@ -869,6 +873,13 @@ 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} +/* #1335: "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. */ diff --git a/core/src/client/text.js b/core/src/client/text.js index 17ca7a787..0a64bd9f7 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 (#1335). + 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 7167b0c2e..b5bdff1b4 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, #1335). `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..c16497cc6 --- /dev/null +++ b/core/src/shared/failure-copy.js @@ -0,0 +1,52 @@ +// A failed turn's sentence a person can act on (#1335). +// +// 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..531c3d687 --- /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 (#1335). +// +// 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..b0c51466b 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 (#1335). + 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..6f82fa84f 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]) + // #1335: 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..952b8b4bd 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() } }) +// #1335: 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('#1335: 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..4b8cbcdc0 --- /dev/null +++ b/core/test/ui-clickthrough.test.js @@ -0,0 +1,151 @@ +// #1335: 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('#1335: 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('#1335: 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('#1335: 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('#1335: 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('#1335: 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. + assert.match(source, /actions\.notify\?\.\(t\('Conversation copied as Markdown'\)\)/) + assert.match(source, /actions\.reportError\?\.\(new Error\(t\('Could not copy the conversation'\)\)/) + 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('#1335: 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('#1335: 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('#1335: 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('#1335: 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('#1335: 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/patches/ds-bot/0001-core-fixes.patch b/patches/ds-bot/0001-core-fixes.patch index af94f17ff..943b03234 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 (#1335 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' @@ -926,6 +937,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 的每条消息都用它。', @@ -2271,11 +2299,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/' @@ -2398,7 +2427,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. @@ -2423,8 +2452,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 (#1335). ++ 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..b94ad250f 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} ++/* #1335: 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}} ++/* #1335: 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 (#1335). ++ 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) @@ -2655,6 +2682,15 @@ 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,7 +332,7 @@ 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%)}} @@ -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} @@ -2672,15 +2708,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} ++/* #1335: 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} @@ -2732,7 +2773,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,35 @@ 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 +2795,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} ++/* #1335: "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. */ diff --git a/patches/ds-bot/0005-social-seams.patch b/patches/ds-bot/0005-social-seams.patch index 2a2ed9938..9868ba248 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, #1335). `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 77dbe4075..3519df25f 100644 --- a/patches/ds-bot/0008-client-bundle.patch +++ b/patches/ds-bot/0008-client-bundle.patch @@ -359,6 +359,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 的每条消息都用它。", @@ -1336,7 +1353,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]; } @@ -1431,6 +1448,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 ""; } @@ -1442,7 +1464,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); @@ -1592,7 +1614,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) { @@ -1720,32 +1752,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; @@ -1858,11 +1891,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,835 +1624,1788 @@ function usageOf(list, ids) { +@@ -1624,256 +1624,1238 @@ 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 }); } @@ -1967,14 +2000,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); @@ -2002,7 +2029,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; @@ -2013,18 +2045,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; @@ -2045,19 +2065,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)( @@ -2066,12 +2074,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); @@ -2094,11 +2096,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 }); @@ -2123,6 +2145,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; @@ -2414,97 +2449,6 @@ diff --git a/client.js b/client.js -body[data-bt-motion=quiet] .bt-mark[data-gaze] .bt-gaze{transform:none} -.bt-mark[data-state=thinking] .bt-b{animation:bt-ponder 2.2s ease-in-out infinite} -.bt-mark[data-state=thinking] .bt-eyes{animation:bt-squint 2.2s ease-in-out infinite} --.bt-mark[data-state=searching] .bt-b{animation:bt-seek 1s ease-in-out infinite} --.bt-mark[data-state=searching] .bt-eyes{animation:bt-scan 1.6s ease-in-out infinite} --.bt-mark[data-state=working] .bt-b{animation:bt-bob 1.6s ease-in-out infinite} --.bt-mark[data-state=working]:is([data-shape=star],[data-shape=hex],[data-shape=plus],[data-shape=gem],[data-shape=magnet],[data-shape=ring]) .bt-b{animation:bt-wiggle 1.4s ease-in-out infinite;transform-origin:50% 50%} --.bt-mark[data-state=working]:is([data-shape=gumdrop],[data-shape=peanut],[data-shape=cushion],[data-shape=squircle],[data-shape=moon],[data-shape=wave]) .bt-b{animation:bt-squash 1.2s ease-in-out infinite} --.bt-mark[data-state=working] .bt-eye{animation:bt-blink 2.4s ease-in-out infinite} --.bt-mark[data-state=sending] .bt-b{animation:bt-dash 1.1s cubic-bezier(.4,0,.2,1) infinite} --.bt-mark[data-state=orbit] .bt-b{animation:bt-sway 2.6s ease-in-out infinite;transform-origin:50% 50%} --.bt-mark[data-state=orbit] .bt-eyes{animation:bt-roll 2.6s linear infinite} --/* A question for you hops twice when it arrives and the badge keeps it in view after; -- a lively mark keeps hopping. */ --.bt-mark[data-state=alert] .bt-b{animation:bt-hop 1.3s cubic-bezier(.3,.7,.4,1) 2} --.bt-mark[data-shape=image]:not([data-state=idle]) .bt-mark-img{animation:bt-bob 1.6s ease-in-out infinite} --.bt-mark[data-shape=image][data-state=alert] .bt-mark-img{animation:bt-hop 1.3s cubic-bezier(.3,.7,.4,1) 2} --body[data-bt-motion=lively] .bt-mark[data-state=alert] :is(.bt-b,.bt-mark-img){animation-iteration-count:infinite} --.bt-mark[data-state=done] .bt-b{animation:bt-done 1.8s linear 1} --.bt-mark[data-shape=image][data-state=done] .bt-mark-img{animation:bt-done 1.8s linear 1;transform-origin:50% 100%} --@keyframes bt-done{0%{transform:none;animation-timing-function:cubic-bezier(.4,0,.2,1)}13.3%{transform:scale(1.12,.88);animation-timing-function:cubic-bezier(.4,0,.2,1)}25.6%{transform:scale(.93,1.09);animation-timing-function:cubic-bezier(.4,0,.2,1)}37.8%{transform:scale(1.04,.97);animation-timing-function:cubic-bezier(.4,0,.2,1)}51.1%,100%{transform:none}} --.bt-mark-poke .bt-b{animation:bt-poke .8s cubic-bezier(.3,.7,.4,1) 1!important;transform-origin:50% 60%!important} --.bt-mark-poke .bt-mark-img{animation:bt-poke-img .6s ease-out 1!important} --.bt-mark[data-poke]{cursor:pointer} --body[data-bt-motion=quiet] .bt-mark-poke :is(.bt-b,.bt-mark-img){animation:none!important} --body[data-bt-motion=quiet] .bt-mark[data-poke]{cursor:default} --@keyframes bt-bob{0%,100%{transform:translateY(0) rotate(0)}30%{transform:translateY(-4%) rotate(-5deg)}65%{transform:translateY(0) rotate(4deg)}} --@keyframes bt-wiggle{0%,100%{transform:rotate(0) scale(1)}25%{transform:rotate(-12deg) scale(1.04)}75%{transform:rotate(12deg) scale(1.04)}} --@keyframes bt-squash{0%,100%{transform:scale(1,1)}30%{transform:scale(1.08,.9)}55%{transform:translateY(-4%) scale(.94,1.08)}80%{transform:scale(1.02,.98)}} --@keyframes bt-ponder{0%,100%{transform:translateY(0) rotate(4deg)}50%{transform:translateY(-3%) rotate(7deg)}} --@keyframes bt-squint{0%,100%{transform:translate(0,-4%) scaleY(.7)}50%{transform:translate(5%,-5%) scaleY(.78)}} --@keyframes bt-seek{0%,100%{transform:translateY(0) rotate(-4deg)}50%{transform:translateY(-5%) rotate(-1deg)}} --@keyframes bt-scan{0%,100%{transform:translateX(-12%)}50%{transform:translateX(12%)}} --@keyframes bt-dash{0%,100%{transform:translateX(0) rotate(0)}35%{transform:translateX(9%) rotate(7deg)}55%{transform:translateX(-2%) rotate(-3deg)}} --@keyframes bt-sway{0%,100%{transform:rotate(-10deg)}50%{transform:rotate(10deg)}} --@keyframes bt-roll{0%,100%{transform:translate(12%,0)}25%{transform:translate(0,9%)}50%{transform:translate(-12%,0)}75%{transform:translate(0,-9%)}} --@keyframes bt-hop{0%,55%,100%{transform:translateY(0) scale(1,1)}20%{transform:translateY(-14%) scale(.96,1.05)}40%{transform:translateY(0) scale(1.06,.94)}} --@keyframes bt-poke{0%{transform:none}55%{transform:translateY(-16%) rotate(360deg)}78%{transform:translateY(0) scale(1.08,.92) rotate(360deg)}100%{transform:rotate(360deg)}} --@keyframes bt-poke-img{0%,100%{transform:scale(1)}40%{transform:scale(1.14) rotate(-6deg)}} --/* Idle clip: two .26s blinks (lid to 3%, then a 6% overshoot) at 1.48s and 3.46s. */ --@keyframes bt-blink-idle{0%,29.6%,35%,69.2%,74.6%,100%{transform:scaleY(1)}32.2%,71.8%{transform:scaleY(.03)}33.8%,73.4%{transform:scaleY(1.06)}} --@keyframes bt-blink{0%,42%,50%,100%{transform:scaleY(1)}46%{transform:scaleY(.03)}48%{transform:scaleY(1.06)}} --@media (prefers-reduced-motion:reduce){.bt-mark *{animation:none!important;transition:none!important}} --.bt-badge{position:absolute;right:0;bottom:0;width:12px;height:12px;border-radius:50%;corner-shape:round;display:flex;align-items:center;justify-content:center;box-shadow:0 0 0 1.5px var(--bt-sidebar)} --.bt-badge-admin{background:color-mix(in srgb,var(--bt-star) 82%,var(--bt-sidebar))} --.bt-badge-admin svg{width:7px;height:7px} --.bt-badge-main{right:calc(var(--bt-size,36px) * -.07);bottom:calc(var(--bt-size,36px) * -.07);width:clamp(14px,calc(var(--bt-size,36px) * .36),22px);height:clamp(14px,calc(var(--bt-size,36px) * .36),22px);border-radius:0;box-shadow:none} --.bt-badge-main svg{width:100%;height:100%;overflow:visible} --.bt-badge-main path{fill:var(--bt-star);stroke:var(--bt-sidebar);stroke-width:2.6;stroke-linejoin:round;paint-order:stroke} --.bt-badge-alert{background:color-mix(in srgb,var(--bt-accent) 78%,var(--bt-sidebar));color:var(--bt-accent-ink,#fff);font-size:8.5px;line-height:12px;font-weight:600;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif} --.bt-badge-working{background:color-mix(in srgb,var(--bt-green) 82%,var(--bt-sidebar));width:8px;height:8px;right:1px;bottom:1px} --.bt-cluster{position:relative;display:inline-block;flex:none;line-height:0} --.bt-cluster>*{position:absolute} --.bt-cluster-more,.bt-cluster-you{display:inline-flex;align-items:center;justify-content:center;border-radius:50%;background:var(--bt-active);color:var(--bt-ink-3);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;font-weight:500;line-height:1;font-variant-numeric:tabular-nums} --.bt-cluster-you{background:color-mix(in srgb,var(--bt-ink) 12%,transparent)} --.bt-stack{display:inline-flex;align-items:center;flex:none;line-height:0} --.bt-stack>*+*{margin-left:calc(var(--bt-stack-size) * -.3)} --.bt-stack-more{font-size:11px;line-height:16px;color:var(--bt-ink-3);margin-left:3px!important} -- --.bt-side{display:flex;flex-direction:column;min-height:0;flex:1 1 auto;gap:2px;padding:0 12px 8px 0;color:var(--bt-ink);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif} --.bt-side-top{display:flex;justify-content:flex-end;align-items:center;gap:8px;padding:6px 5px 0 0} --.bt-brand{display:inline-flex;align-items:center;gap:6px;margin-right:auto;padding:4px 8px 4px 4px;border:0;border-radius:8px;background:none;cursor:pointer;font-family:inherit;font-size:15px;line-height:20px;font-weight:600;letter-spacing:-.01em;color:var(--bt-ink);white-space:nowrap} --.bt-brand-whale{display:inline-flex;color:var(--bt-accent)} --.bt-round{width:36px;height:36px;border-radius:50%;border:1px solid var(--bt-line);background:var(--bt-main);color:var(--bt-ink);display:inline-flex;align-items:center;justify-content:center;cursor:pointer;padding:0;transition:background-color .12s ease,transform .12s ease} --.bt-round:hover{background:var(--bt-hover)} --.bt-round:active{transform:scale(.94)} --.bt-list{display:flex;flex-direction:column;gap:3px;overflow-y:auto;min-height:0;flex:1 1 auto} --.bt-tile-wrap{display:flex;justify-content:center;padding:20px 0 15px} --.bt-tile{display:flex;flex-direction:column;align-items:center;gap:4px;width:95px;padding:6px 8px 4px;border-radius:12px;border:0;background:transparent;color:var(--bt-ink);cursor:pointer;font-family:inherit;transition:background-color .12s ease} --.bt-tile:hover{background:var(--bt-hover)} --.bt-tile[aria-current=page]{background:var(--bt-active)} --.bt-tile-name{font-size:13px;line-height:18px;font-weight:400;max-width:84px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} --.bt-chip{font-size:11px;line-height:16px;padding:1px 4px;border-radius:4px;background:var(--bt-hover);border:1px solid var(--bt-line);color:var(--bt-ink-2);white-space:nowrap;max-width:84px;overflow:hidden;text-overflow:ellipsis} --.bt-row{display:flex;align-items:center;gap:8px;min-height:54px;padding:8px;border:0;border-radius:10px;background:transparent;color:var(--bt-ink);text-align:left;cursor:pointer;width:100%;box-sizing:border-box;font-family:inherit;transition:background-color .12s ease} --.bt-row:hover{background:var(--bt-hover)} --.bt-row[aria-current=page]{background:var(--bt-active)} --.bt-row-body{display:flex;flex-direction:column;min-width:0;flex:1 1 auto} --.bt-row-name{display:flex;align-items:center;gap:6px;min-width:0;font-size:14px;line-height:20px;font-weight:400} --.bt-row-title{min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} --.bt-tag{flex:none;max-width:96px;font-size:12px;line-height:16px;font-weight:400;padding:1px 6px;border-radius:4px;background:var(--bt-tag-bg);color:var(--bt-tag-ink);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} --.bt-row-preview{font-size:13px;line-height:18px;color:var(--bt-ink-2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} --.bt-unread{width:8px;height:8px;border-radius:50%;background:#1084fe;flex:none;margin-right:2px;animation:bt-fade .13s cubic-bezier(.22,1,.36,1)} --.bt-unread-wait{background:var(--bt-star)} --.bt-rail{align-items:center;padding:0 0 8px} --.bt-rail .bt-row{justify-content:center;padding:6px 0;min-height:44px} --.bt-side-foot{display:flex;padding-top:8px} --.bt-connect{flex:1;height:36px;border-radius:999px;border:1px solid var(--bt-line);background:var(--bt-main);color:var(--bt-ink);font-family:inherit;font-size:14px;line-height:20px;cursor:pointer;display:flex;align-items:center;justify-content:center;gap:8px;transition:background-color .12s ease} --.bt-connect:hover{background:var(--bt-hover)} --.bt-connect svg{flex:none;width:15px;height:15px} --.bt-connect-wrap{position:relative;display:flex;flex:1 1 auto;min-width:0} --.bt-connect-warn{flex:none;width:16px;height:16px;border-radius:50%;background:#E5484D;color:#fff;font-size:11px;line-height:16px;font-weight:700;display:inline-flex;align-items:center;justify-content:center} --.bt-connect-tip{position:fixed;box-sizing:border-box;width:max-content;max-width:260px;padding:8px 12px;border-radius:10px;background:var(--bt-main);border:.5px solid var(--bt-line-2);box-shadow:0 8px 24px -4px rgba(0,0,0,.14);color:var(--bt-ink);font-size:12px;line-height:17px;white-space:normal;text-align:center;z-index:70;transform-origin:bottom left;animation:bt-tip-in ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing}} --@keyframes bt-tip-in{from{opacity:0;transform:scale(.96);filter:blur(4px)}} --@media (prefers-reduced-motion:reduce){.bt-connect-tip{animation:none}} +// src/client/room-invite-preview.js +var INVITE_PREVIEW_NAME_MAX = 60; +function stripInviteMarkers(text) { @@ -2585,30 +2529,7 @@ diff --git a/client.js b/client.js + const total = Math.max(1, Math.ceil(left / 1e3)); + return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, "0")} left to join`; +} - --.bt-head{position:relative;display:flex;justify-content:center;align-items:center;height:40px;flex:none;pointer-events:none} --.bt-pill{pointer-events:auto;display:inline-flex;align-items:center;gap:8px;height:40px;padding:7.5px 13.5px 7.5px 7.5px;border-radius:999px;background:var(--bt-main);border:.5px solid var(--bt-line-2);box-shadow:0 2px 8px -1px rgba(0,0,0,.06),0 1px 2px rgba(0,0,0,.04);color:var(--bt-ink);font-family:inherit;font-size:14px;line-height:20px;cursor:pointer;max-width:70%;box-sizing:border-box;transition:background-color .12s ease,transform ${SPRING_TAP.duration}ms ${SPRING_TAP.easing}} --.bt-pill:hover{background:color-mix(in srgb,var(--bt-main),#777 9%)} --.bt-pill:active{transform:scale(.965)} --.bt-pill-name{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-weight:500} --.bt-pill-mem{flex:none;display:inline-flex;width:0;margin-left:-8px;overflow:hidden;transition:${spring(SPRING_SHAPE, "width", "margin-left")}} --.bt-pill[data-memory=updated] .bt-pill-mem{width:var(--bt-mem-w);margin-left:0;transition:${spring(SPRING_STRETCH, "width", "margin-left")}} --.bt-pill-mem-in{flex:none;display:inline-flex;align-items:center;gap:5px;white-space:nowrap;color:var(--bt-ink-2);font-size:13px;font-weight:500;opacity:0;filter:blur(6px);transform:translateX(-8px);transition:opacity .14s ease,filter .18s ease,${spring(SPRING_SHAPE, "transform")}} --.bt-pill-mem-in::before{content:"";flex:none;width:1px;height:14px;margin-right:4px;background:var(--bt-line-2)} --.bt-pill[data-memory=updated] .bt-pill-mem-in{opacity:1;filter:none;transform:none;transition-delay:70ms} --.bt-pill-mem-ico{flex:none;display:inline-flex;width:16px;height:16px;color:var(--bt-accent)} --.bt-pill-mem-ico svg{width:16px;height:16px} --.bt-pill[data-memory=updated] .bt-pill-mem-ico{animation:bt-mem-pop ${SPRING_POP.duration}ms ${SPRING_POP.easing} 90ms both} --@keyframes bt-mem-pop{from{transform:scale(.4) rotate(-14deg)}to{transform:none}} --.bt-sr{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap;border:0} --@media (prefers-reduced-motion:reduce){.bt-pill-mem,.bt-pill-mem-in{transition:none!important}.bt-pill-mem-in{filter:none;transform:none}.bt-pill[data-memory=updated] .bt-pill-mem-ico{animation:none}} --.bt-swap{color:var(--bt-ink-3);font-size:12px} --.bt-pill-go{flex:none;display:inline-flex;align-items:center;justify-content:flex-end;width:0;margin-left:-8px;overflow:hidden;opacity:0;filter:blur(6px);transform:translateX(-6px);color:var(--bt-ink-2);transition:width ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing},margin-left ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing},transform ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing},opacity .18s ease,filter .18s ease} --.bt-pill-go svg{flex:none;transition:transform ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing}} --.bt-pill:hover .bt-pill-go,.bt-pill:focus-visible .bt-pill-go,.bt-pill[aria-expanded=true] .bt-pill-go{width:18px;margin-left:0;opacity:1;filter:none;transform:none} --.bt-pill[aria-expanded=true] .bt-pill-go svg{transform:rotate(180deg)} --.bt-pill[data-memory=updated]:is(:hover,:focus-visible,[aria-expanded=true]) .bt-pill-go{width:0;margin-left:-8px;opacity:0;filter:blur(6px);transform:translateX(-6px)} --@media (prefers-reduced-motion:reduce){.bt-pill,.bt-pill-go,.bt-pill-go svg{transition:none!important}.bt-pill-go{filter:none}.bt-pill:active{transform:none}} ++ +// src/client/approval-claims.js +var claims = /* @__PURE__ */ new Map(); +var claimListeners = /* @__PURE__ */ new Set(); @@ -2634,105 +2555,13 @@ diff --git a/client.js b/client.js + claimListeners.delete(fn); + }; +} - --.bt-astack{display:flex;flex-direction:column;align-items:flex-start;gap:4px;margin:0} --.bt-bubble{max-width:min(80%,560px,calc(100% - 82px));background:var(--bt-bubble);color:var(--bt-ink);border-radius:18px;padding:7px 12px;font-size:var(--dsh-content-font-size,14px);line-height:calc(var(--dsh-content-font-size,14px) + 8px);box-sizing:border-box;overflow-wrap:anywhere} --.bt-msg:not(:first-child)>.bt-msg-line>.bt-bubble{border-start-start-radius:6px} --.bt-msg:not(:last-child)>.bt-msg-line>.bt-bubble{border-end-start-radius:6px} --.bt-bubble [class*="_markdown"]>*+*{margin-top:10px} --.bt-bubble [class*="_markdown"]{color:inherit!important;font-size:inherit!important;line-height:inherit!important} --.bt-bubble p{margin:0} --.bt-bubble p + p{margin-top:6px} --.bt-bubble ul,.bt-bubble ol{margin:4px 0;padding-left:20px} --.bt-bubble a[href^="${MENTION_ORIGIN}"]{text-decoration:none;font-weight:400;cursor:pointer;white-space:nowrap} --.bt-bubble a[href^="${MENTION_ORIGIN}"]::before{content:"";display:inline-block;width:16px;height:16px;margin-right:3px;vertical-align:-3px;background:currentColor;-webkit-mask:${DEFAULT_MASK} center/contain no-repeat;mask:${DEFAULT_MASK} center/contain no-repeat} --.bt-group-msg{display:flex;flex-direction:column;gap:4px;margin:16px 0 0} --.bt-group-msg:first-child{margin-top:0} --.bt-lead{flex:none;display:flex;margin-bottom:1px} --button.bt-lead{border:0;background:none;padding:0;cursor:pointer} --/* Rows without the avatar start where the bubble beside it starts: 22px avatar + 8px gap. */ --.bt-astack[data-lead]>.bt-msg:not(:last-child)>.bt-msg-line,.bt-astack[data-lead] .bt-reacts{margin-left:30px} --.bt-group-name{font-size:12px;line-height:16px;font-weight:500;margin-left:42px} --.bt-x{border:0;background:none;color:var(--bt-ink-2);cursor:pointer;width:24px;height:24px;border-radius:6px;font-size:16px;line-height:24px;padding:0;transition:background-color .12s ease,color .12s ease,${spring(SPRING_TAP, "scale")}} --.bt-x:hover{background:var(--bt-hover);color:var(--bt-ink)} ++ +// src/shared/approval-waiting.js +var WAITING_STATUSES = Object.freeze(["pending", "pending_approval"]); +function isApprovalWaiting(status) { + return WAITING_STATUSES.includes(status); +} - --.bt-q{margin:0 auto 8px;width:100%;max-width:var(--dsh-chat-content-width,748px);display:flex;flex-direction:column;gap:12px;--bt-q-edge:color-mix(in srgb,var(--bt-ink) 10%,transparent);--bt-q-rule:color-mix(in srgb,var(--bt-ink) 8%,transparent);--bt-q-rule-2:color-mix(in srgb,var(--bt-ink) 5%,transparent);background:var(--bt-card);box-shadow:0 1px 3px rgba(20,20,20,.04),inset 0 0 0 1px var(--bt-q-edge);border-radius:16px;padding:14px 16px;box-sizing:border-box;color:var(--bt-ink);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;animation:bt-q-in ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing} backwards,bt-veil-in .2s ease-out backwards} --body[data-ds-dark-theme] .bt-q{--bt-q-edge:rgba(255,255,255,.16);--bt-q-rule:rgba(255,255,255,.12);--bt-q-rule-2:rgba(255,255,255,.08)} --@keyframes bt-q-in{from{transform:translateY(10px) scale(.97)}} --.bt-q-settled{animation:none} --.bt-q-head{display:flex;align-items:flex-start;gap:8px;min-width:0} --.bt-q-text{flex:1 1 0;min-width:0;display:flex;flex-direction:column} --.bt-q-title{margin:0;font-size:15px;line-height:22px;font-weight:600;overflow-wrap:anywhere} --.bt-q-detail{font-size:13px;line-height:18px;color:var(--bt-ink-2)} --.bt-q-x{flex:none;width:20px;height:20px;border:0;padding:0;border-radius:6px;background:none;color:var(--bt-ink-2);display:inline-flex;align-items:center;justify-content:center;cursor:pointer;transition:background-color .12s ease,color .12s ease,${spring(SPRING_TAP, "scale")}} --.bt-q-x svg{width:14px;height:14px} --.bt-q-x:hover{background:var(--bt-hover);color:var(--bt-ink)} --.bt-q-x:focus-visible,.bt-q-opt:focus-visible{outline:2px solid var(--bt-accent);outline-offset:-2px} --.bt-q-list{display:flex;flex-direction:column;gap:1px;min-width:0;box-sizing:border-box;margin:0 -8px} --.bt-q-opt{position:relative;display:flex;align-items:flex-start;gap:16px;width:100%;min-width:0;box-sizing:border-box;margin:0;border:0;border-radius:12px;background:transparent;padding:10px 8px;font:inherit;color:var(--bt-ink);text-align:start;cursor:pointer;transition:background-color .12s ease} --.bt-q:not(.bt-q-raised)>.bt-q-head:has(+.bt-q-list){padding-bottom:12px;border-bottom:1px solid var(--bt-q-rule)} --.bt-q:not(.bt-q-raised)>.bt-q-head+.bt-q-list{margin-top:-6px} --.bt-q-opt+.bt-q-opt::before{content:"";position:absolute;left:calc(var(--bt-key,24px) + 24px);right:8px;top:-1px;height:1px;background:var(--bt-q-rule-2);transition:opacity .12s ease;pointer-events:none} --button.bt-q-opt:hover::before,button.bt-q-opt:hover+.bt-q-opt::before,.bt-q-opt[aria-pressed=true]::before,.bt-q-opt[aria-pressed=true]+.bt-q-opt::before{opacity:0} --.bt-q-raised .bt-q-opt::before{display:none} --button.bt-q-opt:hover{background:var(--bt-hover)} --button.bt-q-opt:active{background:var(--bt-active)} --.bt-q-opt[aria-pressed=true]{background:var(--bt-active)} --.bt-q:not(.bt-q-raised) :is(button.bt-q-opt:hover,.bt-q-opt[aria-pressed=true]) .bt-key{background:var(--bt-card);color:var(--bt-ink)} --.bt-key{flex:none;box-sizing:border-box;width:var(--bt-key,24px);height:var(--bt-key,24px);border-radius:8px;background:var(--bt-hover);color:var(--bt-ink-2);font-size:12px;line-height:var(--bt-key,24px);font-weight:500;text-align:center;font-variant-numeric:tabular-nums;transition:background-color .12s ease,color .12s ease} --.bt-q-body{flex:1 1 auto;min-width:0;display:flex;flex-direction:column;gap:1px;padding-top:2px} --/* With a description the key spans the two text lines less 2px above and below. */ --.bt-q-opt:has(.bt-q-desc){--bt-key:34px} --.bt-q-opt:has(.bt-q-desc) .bt-key{margin-top:2px;border-radius:10px;font-size:13px} --.bt-q-opt:has(.bt-q-desc) .bt-q-body{padding-top:0} --.bt-q-label{font-size:14px;line-height:20px;font-weight:500;white-space:normal;overflow-wrap:anywhere} --.bt-q-desc{font-size:12px;line-height:17px;color:var(--bt-ink-2);overflow-wrap:anywhere} --.bt-q-check{flex:none;display:inline-flex;align-items:center;color:var(--bt-ink-2);animation:bt-check-pop ${SPRING_POP.duration}ms ${SPRING_POP.easing} backwards} --.bt-q-settled .bt-q-check{animation:none} --.bt-q-check svg{width:16px;height:16px} --.bt-q-settled .bt-key{opacity:.4} --.bt-q-settled .bt-q-label{color:color-mix(in srgb,var(--bt-ink) 45%,transparent)} --.bt-q-dismissed .bt-q-title{color:var(--bt-ink-2)} --.bt-q.bt-q-settled{gap:6px;padding:12px 16px} --.bt-q-settled .bt-q-title{font-size:14px;line-height:20px;font-weight:500} --.bt-q.bt-q-settled>.bt-q-head:has(+.bt-q-list){padding-bottom:0;border-bottom:0} --.bt-q.bt-q-settled>.bt-q-head+.bt-q-list{margin-top:0} --.bt-q-settled .bt-q-opt{padding:4px 8px} --.bt-q-badge{flex:none;height:20px;padding:0 7px;border-radius:999px;border:1px dotted var(--bt-line-2);color:var(--bt-ink-2);font-size:12px;line-height:18px;box-sizing:border-box} --.bt-q-custom{display:flex;align-items:flex-start;gap:8px;width:100%;min-width:0} --.bt-q-field{flex:1 1 0;min-width:0;display:flex;box-sizing:border-box;min-height:32px;padding:5px 11px;border-radius:8px;border:.5px solid var(--bt-line-2);background:none;cursor:text;transition:border-color .12s ease} --.bt-q-field:focus-within{border-color:color-mix(in srgb,var(--bt-ink) 30%,transparent)} --.bt-q-input{flex:1;width:100%;min-width:0;max-height:120px;margin:0;padding:0;border:0;outline:none;background:transparent;color:var(--bt-ink);font:inherit;font-size:14px;line-height:20px;resize:none;overflow-y:hidden;white-space:pre-wrap;overflow-wrap:anywhere} --.bt-q-input:not(:placeholder-shown){field-sizing:content} --.bt-q-input::placeholder{color:var(--bt-ink-3)} --.bt-q-submit,.bt-q-btn{flex:none;height:36px;padding:0 14px;border:0;border-radius:999px;font:inherit;font-size:14px;font-weight:500;cursor:pointer;transition:opacity .12s ease,background-color .12s ease,${spring(SPRING_TAP, "scale")}} --.bt-q-submit{background:var(--bt-accent);color:var(--bt-accent-ink,#fff);animation:bt-q-submit-in ${SPRING_TAP.duration}ms ${SPRING_TAP.easing} backwards,bt-veil-in .16s ease-out backwards} --@keyframes bt-q-submit-in{from{transform:translateX(8px) scale(.9)}} --.bt-q-submit:hover:not(:disabled){opacity:.86} --.bt-q-submit:disabled{opacity:.4;cursor:default} --.bt-q-btn{background:var(--bt-hover);color:var(--bt-ink)} --.bt-q-btn:hover{background:var(--bt-active)} --.bt-q-foot{display:flex;justify-content:flex-end;gap:8px} --.bt-q-raised{width:calc(var(--dsh-chat-content-width,748px) - 32px);max-width:calc(100% - 32px);gap:0;padding:0;box-shadow:0 2px 8px rgba(20,20,20,.035),inset 0 0 0 .5px color-mix(in srgb,var(--bt-ink) 5%,transparent)} --.bt-q-raised .bt-q-head{padding:14px 16px 4px 20px} --.bt-q-raised .bt-q-head:last-child{padding-bottom:16px} --.bt-q-raised .bt-q-list{gap:1px;margin:0;padding:8px} --.bt-q-raised .bt-q-opt{gap:4px;padding:7px;border-radius:16px;color:var(--bt-ink-2)} --.bt-q-raised button.bt-q-opt:hover{color:var(--bt-ink)} --.bt-q-raised .bt-q-opt[aria-pressed=true]{background:transparent;color:var(--bt-ink)} --.bt-q-raised .bt-q-opt[aria-pressed=true]:hover{background:var(--bt-hover)} --.bt-q-raised .bt-q-opt[aria-pressed=true] .bt-key,.bt-q-ownrow:focus-within .bt-key,.bt-q-ownrow[data-filled] .bt-key{background:var(--bt-user);color:var(--bt-user-ink)} --.bt-q-raised .bt-q-body{padding:2px 6px 0} --.bt-q-raised .bt-q-desc{color:color-mix(in srgb,var(--bt-ink) 61%,transparent)} --.bt-q-ownrow{cursor:text} --.bt-q-ownrow .bt-q-input{color:var(--bt-ink)} --.bt-q-raised .bt-q-foot{padding:0 8px 8px} --@media (prefers-reduced-motion:reduce){.bt-q{animation:bt-fade .12s ease-out backwards}.bt-q-settled{animation:none}.bt-q-submit{animation:none}} ++ +// src/shared/held-retry.js +var TERMINAL = [ + // A friend's Bot that is gone or no longer shared (plugins/social/src/social.js: botGoneError / notSharedError). @@ -2752,72 +2581,7 @@ diff --git a/client.js b/client.js + const said = `${record.error ?? ""} ${record.code ?? ""}`; + return !TERMINAL.some((reason) => reason.test(said)); +}; - --.bt-newchat{flex:1;display:flex;flex-direction:column;min-width:0;position:relative;animation:bt-veil-in .18s ease-out} --.bt-to{display:flex;align-items:center;flex-wrap:wrap;gap:6px;min-height:48px;padding:6px 12px;border-bottom:1px solid var(--bt-line);font-size:14px;box-sizing:border-box} --.bt-to-label{color:var(--bt-ink-2)} --.bt-to input{flex:1;min-width:120px;border:0;outline:none;background:none;color:var(--bt-ink);font-size:14px;height:26px} --.bt-token{display:inline-flex;align-items:center;gap:5px;height:24px;padding:0 6px 0 4px;border-radius:999px;background:var(--bt-hover);font-size:13px;animation:bt-check-pop ${SPRING_POP.duration}ms ${SPRING_POP.easing}} --.bt-token button{border:0;background:none;color:var(--bt-ink-2);cursor:pointer;padding:0 2px} --.bt-admin-star{flex:none;display:inline-flex;align-items:center;justify-content:center;width:12px;height:12px;border-radius:50%;corner-shape:round;background:color-mix(in srgb,var(--bt-star) 82%,var(--bt-sidebar));vertical-align:-1px} --.bt-to-star{display:inline-flex;margin-right:-2px;animation:bt-check-pop ${SPRING_POP.duration}ms ${SPRING_POP.easing}} --.bt-to .bt-to-create{flex:none;height:28px;padding:0 12px;font-size:13px;animation:bt-q-submit-in ${SPRING_TAP.duration}ms ${SPRING_TAP.easing},bt-veil-in .16s ease-out} --.bt-drop{position:absolute;top:48px;left:32px;width:min(550px,calc(100% - 64px));display:flex;flex-direction:column;padding:6px;max-height:min(420px,60vh);overflow-y:auto;background:var(--bt-card);border:1px solid var(--bt-line-2);border-radius:12px;box-shadow:0 6px 20px -4px rgba(0,0,0,.12);box-sizing:border-box;z-index:1;transform-origin:24px 0;animation:bt-pop-drop ${SPRING_TAP.duration}ms ${SPRING_TAP.easing},bt-veil-in .14s ease-out} --.bt-option{display:flex;align-items:center;gap:10px;border:0;background:none;border-radius:8px;padding:7px 8px;min-height:36px;box-sizing:border-box;font-size:13px;line-height:18px;color:var(--bt-ink);cursor:pointer;text-align:left;width:100%} --.bt-option[aria-selected=true]{background:var(--bt-active)} --.bt-drop-list>.bt-option:not([aria-selected=true]):hover{background:var(--bt-hover)} --.bt-option-label{flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} --.bt-option-hint{font-size:11px;color:var(--bt-ink-2);flex:none;animation:bt-veil-in .16s ease-out} --.bt-option-icon{width:22px;height:22px;border-radius:50%;background:var(--bt-hover);color:var(--bt-ink-2);display:inline-flex;align-items:center;justify-content:center;flex:none} --.bt-hint{padding:14px;text-align:center;font-size:13px;color:var(--bt-ink-2)} --.bt-nc-spacer{flex:1} --.bt-nc-compose{padding:0 16px 14px;display:flex;justify-content:center} --.bt-nc-card{display:flex;align-items:flex-end;gap:6px;width:100%;max-width:var(--dsh-chat-content-width,748px);min-height:44px;padding:4px 6px;border-radius:22px;border:1px solid var(--bt-line-2);background:var(--bt-main);box-shadow:0 2px 8px -2px rgba(0,0,0,.06);box-sizing:border-box} --.bt-nc-plus{width:34px;height:34px;border-radius:50%;display:inline-flex;align-items:center;justify-content:center;color:var(--bt-ink-2);background:var(--bt-hover);flex:none} --.bt-nc-card textarea{flex:1;resize:none;border:0;outline:none;background:none;color:var(--bt-ink);font:inherit;font-size:14px;line-height:20px;padding:7px 4px;min-height:34px;max-height:140px;box-sizing:border-box} --.bt-nc-send{width:34px;height:34px;border-radius:50%;border:0;background:var(--bt-accent);color:var(--bt-accent-ink,#fff);display:inline-flex;align-items:center;justify-content:center;cursor:pointer;flex:none} --.bt-nc-send:disabled{opacity:.3;cursor:default} --.bt-create{flex:1;display:flex;flex-direction:column;min-width:0} --.bt-create-head{display:flex;align-items:center;justify-content:space-between;gap:8px;min-height:48px;padding:6px 12px;border-bottom:1px solid var(--bt-line);box-sizing:border-box} --.bt-create-gap{width:28px} --.bt-create-title{font-size:14px;line-height:20px;font-weight:500} --.bt-create-body{flex:1;overflow-y:auto;padding:28px 16px 32px;display:flex;justify-content:center} --.bt-create-card{width:100%;max-width:400px;display:flex;flex-direction:column;gap:18px;animation:bt-q-in ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing} both,bt-veil-in .22s ease-out both} --.bt-create-preview{display:flex;flex-direction:column;align-items:center;gap:10px} --.bt-create-pop{display:inline-flex;animation:bt-create-pop ${SPRING_POP.duration}ms ${SPRING_POP.easing} both} --@keyframes bt-create-pop{from{transform:scale(.72);opacity:.4}} --.bt-create-name{font-size:17px;line-height:24px;font-weight:500;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} --.bt-create-step{font-size:12px;line-height:16px;color:var(--bt-ink-2);margin-bottom:-10px} --.bt-create-looks{display:flex;flex-direction:column;gap:10px;padding:10px;border:1px solid var(--bt-line);border-radius:12px;background:var(--bt-card)} --.bt-create-lookbar{justify-content:center} --.bt-create-foot{display:flex;justify-content:flex-end;gap:8px;padding-top:4px} --.bt-create-go{height:36px;padding:0 20px} --@media (prefers-reduced-motion:reduce){.bt-create-card,.bt-create-pop{animation:none}} --.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); @@ -2905,11 +2669,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"); @@ -3070,46 +2861,21 @@ 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} ++/* #1335: 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-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} --@keyframes bt-pane-in{from{transform:translateX(100%)}} --[class*="_centerCol"]{transition:margin-right ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing}} --.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} --.bt-panel-view{flex:1;min-height:0;display:flex;flex-direction:column} --.bt-drawer-top{display:flex;justify-content:flex-end;gap:8px;padding:12px 17px 6px 12px} --.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} --.bt-drawer-name{font-size:17px;line-height:24px;font-weight:500;border:0;background:none;color:inherit;cursor:text;padding:0 6px;border-radius:6px;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} --.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} --.bt-tabs{position:relative;display:flex;justify-content:center;gap:2px;margin:16px 16px 0} --.bt-tab{position:relative;z-index:1;border:0;background:none;border-radius:9999px;padding:4px 8px;font-size:13px;line-height:20px;color:var(--bt-ink-2);cursor:pointer;white-space:nowrap} --.bt-tab:hover{color:var(--bt-ink)} --.bt-tab[aria-selected=true]{background:var(--bt-hover);color:var(--bt-ink)} --.bt-tabs>.bt-thumb{top:0;bottom:0;border-radius:9999px;background:var(--bt-hover)} --.bt-tabs[data-glide] .bt-tab[aria-selected=true]{background:transparent} --.bt-drawer-body{flex:1;overflow-y:auto;padding:20px 16px 16px;font-size:13px;line-height:18px;display:flex;flex-direction:column;gap:16px} --.bt-empty{text-align:center;color:var(--bt-ink-3);font-size:13px;line-height:18px;padding:8px 12px} --.bt-section-title{font-size:12px;color:var(--bt-ink-2);margin-bottom:6px} --.bt-muted{color:var(--bt-ink-2)} --.bt-swatches{display:flex;flex-wrap:wrap;gap:6px} --.bt-swatch{width:22px;height:22px;border-radius:50%;border:2px solid transparent;cursor:pointer;padding:0;box-shadow:inset 0 0 0 1px var(--bt-line-2)} --.bt-swatch[aria-pressed=true]{border-color:var(--bt-ink)} --.bt-textarea{width:100%;box-sizing:border-box;min-height:120px;border:1px solid var(--bt-line);border-radius:8px;padding:8px;background:var(--bt-card);color:var(--bt-ink);font:inherit;resize:vertical} --.bt-actions{display:flex;gap:8px;flex-wrap:wrap;align-items:center} --.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 @@ -3138,127 +2904,47 @@ diff --git a/client.js b/client.js +.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; } +`; - --/* Bot settings: fields, the avatar editor popover, and the details-pane sub-view. */ --.bt-field{display:flex;flex-direction:column;gap:6px} --.bt-field-label{font-size:13px;line-height:18px;font-weight:500} --.bt-input{width:100%;box-sizing:border-box;height:36px;border:1px solid var(--bt-line);border-radius:8px;padding:0 10px;background:var(--bt-card);color:var(--bt-ink);font:inherit;font-size:13px;outline:none} --.bt-input:focus{border-color:var(--bt-line-2)} --.bt-input-area{height:auto;min-height:96px;padding:8px 10px;line-height:18px;resize:vertical} --.bt-note{font-size:12px;line-height:16px;color:var(--bt-ink-3)} --.bt-soft-sm{height:28px;padding:0 12px;font-size:12px} --.bt-set-avatar{display:flex;justify-content:center;padding:4px 0} --.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-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} --.bt-avedit-tab{position:relative;z-index:1;border:0;background:none;border-radius:999px;padding:3px 10px;font-size:12px;line-height:16px;color:var(--bt-ink-2);cursor:pointer} --.bt-avedit-tab[aria-selected=true]{background:var(--bt-main);color:var(--bt-ink)} --.bt-avedit-tabs>.bt-thumb{top:2px;bottom:2px;border-radius:999px;background:var(--bt-main);box-shadow:0 1px 2px rgba(0,0,0,.06)} --.bt-avedit-tabs[data-glide] .bt-avedit-tab[aria-selected=true]{background:transparent} --.bt-avedit-reset{color:var(--bt-ink-3)} --.bt-avedit-reset:hover{color:var(--bt-ink)} --.bt-shapes{display:flex;flex-direction:column;gap:2px} --.bt-shape-row{display:grid;grid-template-columns:repeat(6,1fr);gap:2px} --.bt-shape{position:relative;aspect-ratio:1;border:0;border-radius:8px;background:none;color:var(--bt-ink);cursor:pointer;display:flex;align-items:center;justify-content:center;padding:0} --.bt-shape:hover{background:var(--bt-hover)} --.bt-shape .bt-ring{position:absolute;left:50%;top:50%;width:36px;height:36px;translate:-50% -50%;overflow:visible;opacity:0;scale:.82;pointer-events:none;transition:opacity .14s ease,${spring(SPRING_POP, "scale")}} --.bt-shape[aria-pressed=true] .bt-ring{opacity:1;scale:1} --.bt-colors{display:flex;justify-content:space-between} --.bt-color{width:22px;height:22px;border-radius:50%;border:2px solid transparent;padding:2px;background:none;cursor:pointer;box-sizing:border-box;transition:border-color .14s ease,${spring(SPRING_TAP, "scale")}} --.bt-color[aria-pressed=true] span{animation:bt-check-pop ${SPRING_POP.duration}ms ${SPRING_POP.easing}} --.bt-color span{display:block;width:100%;height:100%;border-radius:50%} --.bt-color[aria-pressed=true]{border-color:var(--bt-ink)} --.bt-dropzone{border:1.5px dashed var(--bt-line-2);border-radius:10px;padding:18px 12px;display:flex;flex-direction:column;align-items:center;gap:8px;color:var(--bt-ink-2);font-size:12px;line-height:16px;text-align:center} --.bt-dropzone[data-over]{border-color:var(--bt-accent);color:var(--bt-ink)} --.bt-dropzone img{width:64px;height:64px;border-radius:50%;object-fit:cover} --.bt-set-actions{display:flex;gap:8px;flex-wrap:wrap;align-items:center} --.bt-about{white-space:pre-wrap;display:-webkit-box;-webkit-line-clamp:5;-webkit-box-orient:vertical;overflow:hidden} --.bt-about[data-open]{display:block;-webkit-line-clamp:unset} --.bt-more{border:0;background:none;color:var(--bt-ink-3);font-size:12px;line-height:16px;cursor:pointer;padding:2px 0} --.bt-more:hover{color:var(--bt-ink)} --.bt-subhead{display:flex;align-items:center;gap:4px;padding:10px 12px 4px} --.bt-subhead-title{flex:1;font-size:15px;line-height:24px;font-weight:600;padding-left:4px} --.bt-icon-btn{width:28px;height:28px;border-radius:50%;border:0;background:none;color:var(--bt-ink-2);display:inline-flex;align-items:center;justify-content:center;cursor:pointer;padding:0} --.bt-icon-btn:hover{background:var(--bt-hover);color:var(--bt-ink)} --.bt-set-body{gap:20px} --.bt-drawer-model{display:flex;flex-direction:column} --.bt-drawer-model .bt-note{margin-top:6px} --.bt-drawer-permission{display:flex;flex-direction:column} --.bt-seg button.bt-perm-danger,.bt-seg button.bt-perm-danger[aria-pressed=true]{color:#e02135} ++ ++// 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 (#1335). ++ 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) } : {} + })); +}; - --/* 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} --.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)} --.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} --:is(.bt-settings-layer,.bt-mem-layer)[data-leaving] .bt-scrim{opacity:0;transition:opacity .16s ease-in} --:is(.bt-settings-layer,.bt-mem-layer)[data-leaving]>[role=dialog]{animation:bt-dialog-sink .16s cubic-bezier(.4,0,1,1) forwards} --@keyframes bt-dialog-rise{from{transform:translateY(12px) scale(.96)}} --@keyframes bt-dialog-sink{to{opacity:0;transform:translateY(6px) scale(.975);filter:blur(3px)}} --@media (prefers-reduced-motion:reduce){.bt-settings{transition:none}.bt-settings,.bt-scrim,.bt-settings-page>*{animation:bt-fade .12s ease-out!important}} --.bt-settings-nav{position:relative;width:198px;flex:none;padding:16px 8px;border-right:.5px solid var(--bt-line);display:flex;flex-direction:column;gap:2px;background:var(--bt-sidebar);box-sizing:border-box} --.bt-settings-item{position:relative;z-index:1;display:flex;align-items:center;gap:8px;padding:6px 8px;border:0;border-radius:8px;background:none;color:var(--bt-ink-2);font:inherit;font-size:13px;line-height:20px;cursor:pointer;text-align:left;transition:background-color .12s ease,color .12s ease} --.bt-settings-item:hover{color:var(--bt-ink);background:var(--bt-hover)} --.bt-settings-item[aria-current=page]{background:var(--bt-active);color:var(--bt-ink)} --.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} --.bt-settings-shell{margin-top:auto} --/* 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} --/* The page column takes the dialog's final size at once, so while the dialog resizes -- only its edge moves and the page does not lay out again every frame. */ --.bt-settings-main{flex:none;display:flex;flex-direction:column;min-width:0;width:calc(var(--bt-set-w) - 198px);height:var(--bt-set-h)} --.bt-settings-head{display:flex;align-items:center;gap:6px;padding:16px 56px 4px 20px} --.bt-settings-head h2{margin:0;font-size:15px;line-height:24px;font-weight:600;animation:bt-veil-in .2s ease-out} --.bt-settings-head>.bt-icon-btn{animation:bt-back-in ${SPRING_TAP.duration}ms ${SPRING_TAP.easing},bt-veil-in .16s ease-out} --@keyframes bt-back-in{from{translate:6px 0;scale:.8}} --.bt-settings-scroll{flex:1;overflow-y:auto;padding:12px 20px 24px;display:flex;flex-direction:column;gap:20px;scrollbar-width:thin} --.bt-settings-page>:not(.bt-us){animation:bt-page-up ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing} backwards,bt-veil-in .22s ease-out backwards} --.bt-settings-page[data-move=in]>:not(.bt-us){animation-name:bt-page-in,bt-veil-in} --.bt-settings-page[data-move=out]>:not(.bt-us){animation-name:bt-page-out,bt-veil-in} --.bt-settings-page>:nth-child(2){animation-delay:30ms} --.bt-settings-page>:nth-child(3){animation-delay:60ms} --.bt-settings-page>:nth-child(4){animation-delay:90ms} --.bt-settings-page>:nth-child(n+5){animation-delay:120ms} --@keyframes bt-page-up{from{transform:translateY(10px)}} --@keyframes bt-page-in{from{transform:translateX(18px)}} --@keyframes bt-page-out{from{transform:translateX(-18px)}} --.bt-settings-close{position:absolute;top:12px;right:12px;width:32px;height:32px;border-radius:50%;border:0;background:var(--bt-hover);color:var(--bt-ink-2);display:flex;align-items:center;justify-content:center;cursor:pointer;padding:0} --.bt-settings-close:hover{background:var(--bt-active);color:var(--bt-ink)} --.bt-settings-form{display:flex;flex-direction:column;gap:16px} --.bt-set-section h3{font-size:13px;line-height:18px;font-weight:600;margin:0 0 8px;padding:0 4px} --.bt-set-card{border:1px solid var(--bt-line);border-radius:12px;background:var(--bt-card);display:flex;flex-direction:column} --.bt-set-card>*+*{border-top:.5px solid var(--bt-line)} --.bt-set-row{display:flex;align-items:center;gap:12px;padding:10px 12px;border:0;background:none;color:var(--bt-ink);font:inherit;font-size:13px;line-height:18px;text-align:left;width:100%;box-sizing:border-box;border-radius:0} --button.bt-set-row{cursor:pointer} --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} --.bt-set-copy{flex:1;display:flex;flex-direction:column;gap:2px;min-width:0} --.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} --.bt-set-bot{flex:1;display:flex;align-items:center;gap:8px;min-width:0} --.bt-set-paste{flex-direction:column;align-items:stretch;gap:8px} --.bt-chevron{color:var(--bt-ink-3);display:inline-flex;flex:none} ++ +// src/client/turn-failures.js +var import_react3 = require("react"); +function TurnFailures({ sessionId, work }) { @@ -3288,30 +2974,7 @@ diff --git a/client.js b/client.js +.bt-turn-failure{display:flex;gap:6px;align-items:baseline;padding:6px 10px;border-radius:8px;font-size:12px;line-height:1.45;background:color-mix(in srgb,var(--bt-ink) 6%,transparent);color:color-mix(in srgb,var(--bt-ink) 72%,transparent)} +.bt-turn-failure-streak{opacity:.72} +`; - --/* Settings → Connectors. */ --.bt-connector{display:flex;flex-direction:column;gap:10px;padding:12px} --.bt-connector-head{display:flex;align-items:center;gap:12px;min-width:0} --.bt-connector-mark{flex:none;width:36px;height:36px;border-radius:10px;background:var(--bt-card);border:.5px solid var(--bt-line);display:flex;align-items:center;justify-content:center;color:var(--bt-ink)} --.bt-connector-text{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px} --.bt-connector-name{font-size:14px;line-height:20px;font-weight:500} --.bt-connector-status{font-size:12px;line-height:16px;color:var(--bt-ink-3);overflow:hidden;text-overflow:ellipsis;white-space:nowrap} --.bt-connector[data-state=ready] .bt-connector-status{color:var(--bt-ink-2)} --.bt-connector[data-state=error] .bt-connector-status{color:#e02135;white-space:normal} --.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} --.bt-connector-form-row{display:flex;align-items:center;gap:6px} --.bt-connector-link{flex:1;font-size:12px;color:var(--bt-ink-2)} --.bt-connector-error{padding-left:48px;font-size:12px;line-height:16px;color:#e02135} -- --/* Shell chrome reshaped for the team view. Selectors use stable data attributes where the -- shell offers them and CSS-module name suffixes elsewhere. */ --[class*="_logoRow"],[class*="_newSession"],nav[class*="_panelList"]{display:none!important} --[class*="_footArea"]{display:flex!important;flex-direction:row-reverse;align-items:center;gap:9px;padding:8px 6px 6px!important} --[class*="_footArea"]>[class*="_footerActions"]{flex:1 1 auto;min-width:0} --[class*="_footArea"]>[class*="_settingsArea"]{flex:none;width:auto!important} --[class*="_footArea"] button[aria-label="Settings"]{width:auto!important;padding:0!important;background:none!important;border-radius:50%!important;height:auto!important} --[class*="_widthHandle"]{display:none!important} ++ +// src/client/themes.js +var THEME_KEYS = ["bg", "sidebar", "bubble", "card", "text", "user", "userText", "accent"]; +var THEMES = { @@ -3416,66 +3079,7 @@ diff --git a/client.js b/client.js + }; +} +var allThemes = () => ({ ...THEMES, ...customThemes }); - --.bt-voice-pane{flex-direction:column;align-items:center;justify-content:center;animation:bt-fade .2s ease-out} --.bt-voice-stage{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:10px;padding:24px;max-width:560px;text-align:center} --.bt-voice-orb{position:relative;width:200px;height:200px;display:grid;place-items:center;margin-bottom:8px} --.bt-voice-ring{position:absolute;inset:34px;border-radius:50%;background:var(--bt-hover);transform:scale(calc(1 + var(--bt-level,0) * .55));transition:transform .08s linear} --.bt-voice-ring-2{inset:14px;background:none;box-shadow:inset 0 0 0 1px var(--bt-line-2);transform:scale(calc(.94 + var(--bt-level,0) * .3));opacity:.8} --.bt-voice-stage[data-phase=thinking] .bt-voice-ring{animation:bt-voice-breathe 1.6s ease-in-out infinite} --.bt-voice-stage[data-phase=speaking] .bt-voice-ring{animation:bt-voice-speak .9s ease-in-out infinite} --@keyframes bt-voice-breathe{0%,100%{transform:scale(1)}50%{transform:scale(1.08)}} --@keyframes bt-voice-speak{0%,100%{transform:scale(1.04)}30%{transform:scale(1.2)}60%{transform:scale(1.1)}} --.bt-voice-orb>.bt-mark{position:relative} --.bt-voice-name{font-size:16px;line-height:22px;font-weight:500} --.bt-voice-status{font-size:13px;line-height:18px;color:var(--bt-ink-2);min-height:18px} --.bt-voice-caption{min-height:44px;font-size:15px;line-height:22px;color:var(--bt-ink);animation:bt-blur-in .25s ease-out;display:-webkit-box;-webkit-line-clamp:4;-webkit-box-orient:vertical;overflow:hidden} --.bt-voice-note{font-size:12px;color:#e02135} --.bt-voice-setup{height:32px;padding:0 14px;border-radius:16px;border:0;background:var(--bt-hover);color:var(--bt-ink);font:inherit;font-size:13px;cursor:pointer;transition:background-color .15s ease} --.bt-voice-setup:hover{background:var(--bt-active)} --.bt-voice-bar{display:flex;gap:16px;padding:0 0 32px} --.bt-voice-btn{position:relative;width:52px;height:52px;border-radius:50%;border:0;background:var(--bt-hover);color:var(--bt-ink);display:grid;place-items:center;cursor:pointer;transition:background-color .15s ease,transform .12s ease} --.bt-voice-btn:hover{background:var(--bt-active)} --.bt-voice-btn:active{transform:scale(.94)} --.bt-voice-btn[aria-pressed=true]{background:var(--bt-user);color:var(--bt-user-ink)} --.bt-voice-slash{position:absolute;width:24px;height:2px;border-radius:1px;background:currentColor;transform:rotate(-45deg)} --.bt-voice-end{background:#ff3e51;color:#fff} --.bt-voice-end:hover{background:#e02135} --@keyframes bt-fade{from{opacity:0}to{opacity:1}} --@keyframes bt-blur-in{from{opacity:0;filter:blur(2px)}to{opacity:1;filter:blur(0)}} --@media (prefers-reduced-motion:reduce){.bt-voice-pane,.bt-voice-caption,.bt-voice-ring{animation:none!important}} --.bt-msg{display:flex;flex-direction:column;align-items:flex-start;max-width:min(80%,560px,calc(100% - 82px))} --/* The bubble's row: reactions hang below it, so the hover toolbar (and a group -- sender's avatar) line up with the bubble alone. */ --.bt-msg-line{position:relative;display:flex;align-items:flex-end;gap:8px;max-width:100%} --.bt-msg-line>.bt-bubble{max-width:none;min-width:0} --/* The same bubble width as beside an avatar column: 80% of (100% - 30px), plus the 30px. */ --.bt-astack[data-lead]>.bt-msg{max-width:min(calc(80% + 6px),590px,calc(100% - 52px))} --.bt-tools{position:absolute;top:50%;left:100%;margin-left:6px;transform:translateY(-50%);display:flex;gap:2px;opacity:0;visibility:hidden;transition:opacity .12s ease,visibility .12s} --.bt-tools::after{content:"";position:absolute;top:-8px;bottom:-8px;right:100%;width:128px;z-index:-1} --.bt-msg:hover>.bt-msg-line>.bt-tools,.bt-msg:focus-within>.bt-msg-line>.bt-tools,.bt-tools[data-open]{opacity:1;visibility:visible} --.bt-tool{width:24px;height:24px;border:0;padding:0;border-radius:6px;background:none;color:var(--bt-ink-2);display:inline-flex;align-items:center;justify-content:center;cursor:pointer;transition:background-color .12s ease,color .12s ease} --.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 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} --.bt-react-strip button:nth-child(4){animation-delay:60ms} --.bt-react-strip button:nth-child(n+5){animation-delay:80ms} --.bt-react-strip button:hover{background:var(--bt-hover);transform:scale(1.15)} --.bt-react-strip button:active{transform:scale(.92)} --.bt-reacts{display:flex;gap:4px;margin-top:4px} --.bt-react{height:22px;min-width:30px;padding:0 6px;border-radius:999px;border:.5px solid var(--bt-line-2);background:var(--bt-main);font-size:13px;line-height:20px;cursor:pointer;animation:bt-react-pop ${SPRING_POP.duration}ms ${SPRING_POP.easing};transition:background-color .12s ease,${spring(SPRING_TAP, "scale")}} --.bt-react:hover{background:var(--bt-hover)} --.bt-react:active{scale:.92} --@keyframes bt-react-pop{from{transform:scale(.6);opacity:0}} --.bt-bubble a[href^="${MENTION_ORIGIN}"] svg{display:none} --.bt-you{width:36px;height:36px;border-radius:50%;display:inline-flex;align-items:center;justify-content:center;background:color-mix(in srgb,var(--bt-ink) 5%,var(--bt-sidebar));color:var(--bt-ink-2);box-shadow:inset 0 0 0 1px var(--bt-line);transition:background-color .12s ease} --button:hover .bt-you{background:color-mix(in srgb,var(--bt-ink) 9%,var(--bt-sidebar))} --.bt-you-wrap{display:inline-flex;padding:0} --.bt-connect-foot{width:100%;height:36px} ++ +// src/client/mention-style.js +function chipKey(bot) { + if (/\s/.test(bot?.name)) return null; @@ -3502,80 +3106,7 @@ diff --git a/client.js b/client.js + return `${anchor}{color:${inkText(look.color)}}${chipBase}${mark}`; + }).join("\n"); +} - --.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 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} --.bt-menu .bt-danger{color:#c21d2e} --.bt-menu-sep{height:.5px;flex:none;background:var(--bt-line-2);margin:4px 8px} --.bt-menu-up{--bt-origin:bottom left;animation-name:bt-pop-lift,bt-veil-in} --.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-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} --.bt-palette-layer[data-leaving] .bt-backdrop{opacity:0;transition:opacity .16s ease-in} --.bt-palette-layer[data-leaving] .bt-palette{animation:bt-dialog-sink .16s cubic-bezier(.4,0,1,1) forwards} --@keyframes bt-palette-in{from{translate:0 -10px;scale:.96}} --.bt-palette-head{display:flex;align-items:center;gap:4px;padding:14px 10px 14px 14px;border-bottom:.5px solid var(--bt-line-2)} --.bt-palette-glyph{display:inline-flex;width:18px;height:18px;align-items:center;justify-content:center;color:var(--bt-ink-3);margin-right:4px} --.bt-palette-head input{flex:1;min-width:0;border:0;outline:none;background:none;color:var(--bt-ink);font:inherit;font-size:15px;line-height:22px;padding:0} --.bt-palette-head input::placeholder{color:var(--bt-ink-3)} --.bt-palette-clear{width:18px;height:18px;border:0;padding:0;border-radius:6px;background:none;color:var(--bt-ink-3);display:inline-flex;align-items:center;justify-content:center;cursor:pointer} --.bt-palette-clear:hover{color:var(--bt-ink)} --.bt-palette-clear svg{width:12px;height:12px} --.bt-palette-list{position:relative;height:402px;overflow-y:auto;padding:8px;display:flex;flex-direction:column;gap:2px;box-sizing:border-box} --.bt-palette-list>.bt-thumb,.bt-drop-list>.bt-thumb{left:0;right:0;border-radius:8px;background:var(--bt-active)} --.bt-palette-list>.bt-thumb{left:8px;right:8px} --:is(.bt-palette-list,.bt-drop-list)[data-glide] [aria-selected=true]{background:transparent} --:is(.bt-palette-row,.bt-palette-section,.bt-drop-list>.bt-option){position:relative;z-index:1} --.bt-palette-section{padding:8px 8px 4px;font-size:12px;line-height:16px;color:var(--bt-ink-3);flex:none} --.bt-palette-section:first-of-type{padding-top:0} --.bt-palette-row{flex:none;display:flex;align-items:center;gap:8px;height:49px;padding:6px 10px 6px 8px;border:0;border-radius:8px;background:none;color:inherit;font:inherit;text-align:left;cursor:pointer;box-sizing:border-box;width:100%} --.bt-palette-row[aria-selected=true]{background:var(--bt-active)} --.bt-palette-lead{flex:none;width:24px;height:24px;display:inline-flex;align-items:center;justify-content:center} --.bt-palette-cmd{width:24px;height:24px;border-radius:50%;background:var(--bt-hover);display:inline-flex;align-items:center;justify-content:center;color:var(--bt-ink-2)} --.bt-palette-cmd svg{width:14px;height:14px} --.bt-palette-agent{width:24px;height:24px;border-radius:50%;background:var(--bt-hover);display:inline-flex;align-items:center;justify-content:center;color:var(--bt-ink)} --.bt-palette-text{flex:1;min-width:0;display:flex;flex-direction:column;gap:1px} --.bt-palette-title{font-size:14px;line-height:20px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} --.bt-palette-sub{font-size:12px;line-height:16px;color:var(--bt-ink-3);overflow:hidden;text-overflow:ellipsis;white-space:nowrap} --.bt-palette-badge{flex:none;font-size:11px;line-height:16px;padding:0 6px;border-radius:4px;background:var(--bt-tag-bg);color:var(--bt-tag-ink)} --.bt-palette-empty{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:8px;padding:0 16px;text-align:center;animation:bt-veil-in .22s ease-out} --.bt-palette-empty-icon{color:var(--bt-line-2)} --.bt-palette-empty-icon svg{width:32px;height:32px} --.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)} --@media (prefers-reduced-motion:reduce){.bt-palette{animation:bt-fade .1s ease-out}} --.bt-account-menu{width:224px} --.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 -- already has. */ --@keyframes bt-pop-drop{from{translate:0 -4px;scale:.96}} --@keyframes bt-pop-lift{from{translate:0 4px;scale:.96}} --@keyframes bt-pop-out{to{opacity:0;scale:.97;filter:blur(2px)}} --@keyframes bt-veil-in{from{opacity:0;filter:blur(6px)}} --@keyframes bt-check-pop{from{opacity:0;scale:.4}} --@media (prefers-reduced-motion:reduce){.bt-menu,.bt-avedit,.bt-react-strip{animation:bt-fade .1s ease-out}.bt-react-strip button,.bt-q-check,.bt-color[aria-pressed=true] span{animation:none}} --.bt-brand:hover{background:var(--bt-hover)} --.bt-drop-list{position:relative;display:flex;flex-direction:column;gap:2px} --.bt-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:4px} --.bt-pick{display:flex;flex-direction:column;align-items:center;gap:4px;padding:7px 2px 6px;border-radius:10px;border:1px solid transparent;background:none;color:var(--bt-ink);cursor:pointer;font:inherit;font-size:11px;line-height:14px;min-width:0} --.bt-pick:hover{background:var(--bt-hover)} --.bt-pick[aria-pressed=true]{border-color:var(--bt-line-2);background:var(--bt-hover)} --.bt-pick>span:last-child:not(.bt-mark){max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} --.bt-pick:hover .bt-mark[data-state=idle] .bt-eye{animation:bt-blink .5s linear 1} --.bt-select{width:100%;height:34px;box-sizing:border-box;border-radius:8px;border:1px solid var(--bt-line);background:var(--bt-card);color:var(--bt-ink);padding:0 8px;font:inherit;font-size:13px} --.bt-note{font-size:12px;line-height:16px;color:var(--bt-ink-2);margin-top:6px} --.bt-swatch-sep{width:1px;height:22px;background:var(--bt-line-2);margin:0 2px} --.bt-swatch-custom{position:relative;overflow:hidden;background:conic-gradient(#ff3e51,#ffaf38,#00c972,#1cc3b0,#2a92fe,#a97efe,#ff5eb1,#ff3e51);box-sizing:border-box} --.bt-swatch-custom[data-on=true]{border-color:var(--bt-ink)} --.bt-swatch-custom input{position:absolute;inset:-4px;width:calc(100% + 8px);height:calc(100% + 8px);opacity:0;cursor:pointer;border:0;padding:0} --.bt-swatch-default{background:linear-gradient(135deg,var(--bt-card) 0 46%,var(--bt-line-2) 46% 54%,var(--bt-card) 54%);box-shadow:inset 0 0 0 1px var(--bt-line-2)} ++ +// src/client/motion.js +var import_react4 = require("react"); +var reducedMotion = () => window.matchMedia?.("(prefers-reduced-motion: reduce)").matches === true; @@ -3834,65 +3365,14 @@ diff --git a/client.js b/client.js +body[data-bt-motion=quiet] .bt-mark[data-gaze] .bt-gaze{transform:none} +.bt-mark[data-state=thinking] .bt-b{animation:bt-ponder 2.2s ease-in-out infinite} +.bt-mark[data-state=thinking] .bt-eyes{animation:bt-squint 2.2s ease-in-out infinite} -+.bt-mark[data-state=searching] .bt-b{animation:bt-seek 1s ease-in-out infinite} -+.bt-mark[data-state=searching] .bt-eyes{animation:bt-scan 1.6s ease-in-out infinite} -+.bt-mark[data-state=working] .bt-b{animation:bt-bob 1.6s ease-in-out infinite} -+.bt-mark[data-state=working]:is([data-shape=star],[data-shape=hex],[data-shape=plus],[data-shape=gem],[data-shape=magnet],[data-shape=ring]) .bt-b{animation:bt-wiggle 1.4s ease-in-out infinite;transform-origin:50% 50%} -+.bt-mark[data-state=working]:is([data-shape=gumdrop],[data-shape=peanut],[data-shape=cushion],[data-shape=squircle],[data-shape=moon],[data-shape=wave]) .bt-b{animation:bt-squash 1.2s ease-in-out infinite} -+.bt-mark[data-state=working] .bt-eye{animation:bt-blink 2.4s ease-in-out infinite} -+.bt-mark[data-state=sending] .bt-b{animation:bt-dash 1.1s cubic-bezier(.4,0,.2,1) infinite} -+.bt-mark[data-state=orbit] .bt-b{animation:bt-sway 2.6s ease-in-out infinite;transform-origin:50% 50%} -+.bt-mark[data-state=orbit] .bt-eyes{animation:bt-roll 2.6s linear infinite} -+/* A question for you hops twice when it arrives and the badge keeps it in view after; -+ a lively mark keeps hopping. */ -+.bt-mark[data-state=alert] .bt-b{animation:bt-hop 1.3s cubic-bezier(.3,.7,.4,1) 2} -+.bt-mark[data-shape=image]:not([data-state=idle]) .bt-mark-img{animation:bt-bob 1.6s ease-in-out infinite} -+.bt-mark[data-shape=image][data-state=alert] .bt-mark-img{animation:bt-hop 1.3s cubic-bezier(.3,.7,.4,1) 2} -+body[data-bt-motion=lively] .bt-mark[data-state=alert] :is(.bt-b,.bt-mark-img){animation-iteration-count:infinite} -+.bt-mark[data-state=done] .bt-b{animation:bt-done 1.8s linear 1} -+.bt-mark[data-shape=image][data-state=done] .bt-mark-img{animation:bt-done 1.8s linear 1;transform-origin:50% 100%} -+@keyframes bt-done{0%{transform:none;animation-timing-function:cubic-bezier(.4,0,.2,1)}13.3%{transform:scale(1.12,.88);animation-timing-function:cubic-bezier(.4,0,.2,1)}25.6%{transform:scale(.93,1.09);animation-timing-function:cubic-bezier(.4,0,.2,1)}37.8%{transform:scale(1.04,.97);animation-timing-function:cubic-bezier(.4,0,.2,1)}51.1%,100%{transform:none}} -+.bt-mark-poke .bt-b{animation:bt-poke .8s cubic-bezier(.3,.7,.4,1) 1!important;transform-origin:50% 60%!important} -+.bt-mark-poke .bt-mark-img{animation:bt-poke-img .6s ease-out 1!important} -+.bt-mark[data-poke]{cursor:pointer} -+body[data-bt-motion=quiet] .bt-mark-poke :is(.bt-b,.bt-mark-img){animation:none!important} -+body[data-bt-motion=quiet] .bt-mark[data-poke]{cursor:default} -+@keyframes bt-bob{0%,100%{transform:translateY(0) rotate(0)}30%{transform:translateY(-4%) rotate(-5deg)}65%{transform:translateY(0) rotate(4deg)}} -+@keyframes bt-wiggle{0%,100%{transform:rotate(0) scale(1)}25%{transform:rotate(-12deg) scale(1.04)}75%{transform:rotate(12deg) scale(1.04)}} -+@keyframes bt-squash{0%,100%{transform:scale(1,1)}30%{transform:scale(1.08,.9)}55%{transform:translateY(-4%) scale(.94,1.08)}80%{transform:scale(1.02,.98)}} -+@keyframes bt-ponder{0%,100%{transform:translateY(0) rotate(4deg)}50%{transform:translateY(-3%) rotate(7deg)}} -+@keyframes bt-squint{0%,100%{transform:translate(0,-4%) scaleY(.7)}50%{transform:translate(5%,-5%) scaleY(.78)}} -+@keyframes bt-seek{0%,100%{transform:translateY(0) rotate(-4deg)}50%{transform:translateY(-5%) rotate(-1deg)}} -+@keyframes bt-scan{0%,100%{transform:translateX(-12%)}50%{transform:translateX(12%)}} -+@keyframes bt-dash{0%,100%{transform:translateX(0) rotate(0)}35%{transform:translateX(9%) rotate(7deg)}55%{transform:translateX(-2%) rotate(-3deg)}} -+@keyframes bt-sway{0%,100%{transform:rotate(-10deg)}50%{transform:rotate(10deg)}} -+@keyframes bt-roll{0%,100%{transform:translate(12%,0)}25%{transform:translate(0,9%)}50%{transform:translate(-12%,0)}75%{transform:translate(0,-9%)}} -+@keyframes bt-hop{0%,55%,100%{transform:translateY(0) scale(1,1)}20%{transform:translateY(-14%) scale(.96,1.05)}40%{transform:translateY(0) scale(1.06,.94)}} -+@keyframes bt-poke{0%{transform:none}55%{transform:translateY(-16%) rotate(360deg)}78%{transform:translateY(0) scale(1.08,.92) rotate(360deg)}100%{transform:rotate(360deg)}} -+@keyframes bt-poke-img{0%,100%{transform:scale(1)}40%{transform:scale(1.14) rotate(-6deg)}} -+/* Idle clip: two .26s blinks (lid to 3%, then a 6% overshoot) at 1.48s and 3.46s. */ -+@keyframes bt-blink-idle{0%,29.6%,35%,69.2%,74.6%,100%{transform:scaleY(1)}32.2%,71.8%{transform:scaleY(.03)}33.8%,73.4%{transform:scaleY(1.06)}} -+@keyframes bt-blink{0%,42%,50%,100%{transform:scaleY(1)}46%{transform:scaleY(.03)}48%{transform:scaleY(1.06)}} -+@media (prefers-reduced-motion:reduce){.bt-mark *{animation:none!important;transition:none!important}} -+.bt-badge{position:absolute;right:0;bottom:0;width:12px;height:12px;border-radius:50%;corner-shape:round;display:flex;align-items:center;justify-content:center;box-shadow:0 0 0 1.5px var(--bt-sidebar)} -+.bt-badge-admin{background:color-mix(in srgb,var(--bt-star) 82%,var(--bt-sidebar))} -+.bt-badge-admin svg{width:7px;height:7px} -+.bt-badge-main{right:calc(var(--bt-size,36px) * -.07);bottom:calc(var(--bt-size,36px) * -.07);width:clamp(14px,calc(var(--bt-size,36px) * .36),22px);height:clamp(14px,calc(var(--bt-size,36px) * .36),22px);border-radius:0;box-shadow:none} -+.bt-badge-main svg{width:100%;height:100%;overflow:visible} -+.bt-badge-main path{fill:var(--bt-star);stroke:var(--bt-sidebar);stroke-width:2.6;stroke-linejoin:round;paint-order:stroke} -+.bt-badge-alert{background:color-mix(in srgb,var(--bt-accent) 78%,var(--bt-sidebar));color:var(--bt-accent-ink,#fff);font-size:8.5px;line-height:12px;font-weight:600;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif} -+.bt-badge-working{background:color-mix(in srgb,var(--bt-green) 82%,var(--bt-sidebar));width:8px;height:8px;right:1px;bottom:1px} -+.bt-cluster{position:relative;display:inline-block;flex:none;line-height:0} -+.bt-cluster>*{position:absolute} -+.bt-cluster-more,.bt-cluster-you{display:inline-flex;align-items:center;justify-content:center;border-radius:50%;background:var(--bt-active);color:var(--bt-ink-3);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;font-weight:500;line-height:1;font-variant-numeric:tabular-nums} -+.bt-cluster-you{background:color-mix(in srgb,var(--bt-ink) 12%,transparent)} -+.bt-stack{display:inline-flex;align-items:center;flex:none;line-height:0} -+.bt-stack>*+*{margin-left:calc(var(--bt-stack-size) * -.3)} -+.bt-stack-more{font-size:11px;line-height:16px;color:var(--bt-ink-3);margin-left:3px!important} -+ -+.bt-side{display:flex;flex-direction:column;min-height:0;flex:1 1 auto;gap:2px;padding:0 12px 8px 0;color:var(--bt-ink);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif} -+.bt-side-top{display:flex;justify-content:flex-end;align-items:center;gap:8px;padding:6px 5px 0 0} -+.bt-brand{display:inline-flex;align-items:center;gap:6px;margin-right:auto;padding:4px 8px 4px 4px;border:0;border-radius:8px;background:none;cursor:pointer;font-family:inherit;font-size:15px;line-height:20px;font-weight:600;letter-spacing:-.01em;color:var(--bt-ink);white-space:nowrap} + .bt-mark[data-state=searching] .bt-b{animation:bt-seek 1s ease-in-out infinite} + .bt-mark[data-state=searching] .bt-eyes{animation:bt-scan 1.6s ease-in-out infinite} + .bt-mark[data-state=working] .bt-b{animation:bt-bob 1.6s ease-in-out infinite} +@@ -1933,15 +1933,23 @@ body[data-bt-motion=quiet] .bt-mark[data-poke]{cursor:default} + .bt-side{display:flex;flex-direction:column;min-height:0;flex:1 1 auto;gap:2px;padding:0 12px 8px 0;color:var(--bt-ink);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif} + .bt-side-top{display:flex;justify-content:flex-end;align-items:center;gap:8px;padding:6px 5px 0 0} + .bt-brand{display:inline-flex;align-items:center;gap:6px;margin-right:auto;padding:4px 8px 4px 4px;border:0;border-radius:8px;background:none;cursor:pointer;font-family:inherit;font-size:15px;line-height:20px;font-weight:600;letter-spacing:-.01em;color:var(--bt-ink);white-space:nowrap} +-.bt-brand-whale{display:inline-flex;color:var(--bt-accent)} +.bt-brand-mark{display:inline-flex;line-height:0} +.bt-flybot-mark{display:block;flex:none} +.bt-flybot-mark .bt-flybot-wing{fill:#8FB2FF} @@ -3901,757 +3381,166 @@ diff --git a/client.js b/client.js +[data-ds-dark-theme] .bt-flybot-mark .bt-flybot-wing{fill:#A9C2FF} +[data-ds-dark-theme] .bt-flybot-mark .bt-flybot-body{fill:#6E87FF} +[data-ds-dark-theme] .bt-flybot-mark .bt-flybot-eye{fill:#0F1530} -+.bt-round{width:36px;height:36px;border-radius:50%;border:1px solid var(--bt-line);background:var(--bt-main);color:var(--bt-ink);display:inline-flex;align-items:center;justify-content:center;cursor:pointer;padding:0;transition:background-color .12s ease,transform .12s ease} -+.bt-round:hover{background:var(--bt-hover)} -+.bt-round:active{transform:scale(.94)} -+.bt-list{display:flex;flex-direction:column;gap:3px;overflow-y:auto;min-height:0;flex:1 1 auto} -+.bt-tile-wrap{display:flex;justify-content:center;padding:20px 0 15px} + .bt-round{width:36px;height:36px;border-radius:50%;border:1px solid var(--bt-line);background:var(--bt-main);color:var(--bt-ink);display:inline-flex;align-items:center;justify-content:center;cursor:pointer;padding:0;transition:background-color .12s ease,transform .12s ease} + .bt-round:hover{background:var(--bt-hover)} + .bt-round:active{transform:scale(.94)} + .bt-list{display:flex;flex-direction:column;gap:3px;overflow-y:auto;min-height:0;flex:1 1 auto} + .bt-tile-wrap{display:flex;justify-content:center;padding:20px 0 15px} +-.bt-tile{display:flex;flex-direction:column;align-items:center;gap:4px;width:95px;padding:6px 8px 4px;border-radius:12px;border:0;background:transparent;color:var(--bt-ink);cursor:pointer;font-family:inherit;transition:background-color .12s ease} +.bt-tile{position:relative;display:flex;flex-direction:column;align-items:center;gap:4px;width:95px;padding:6px 8px 4px;border-radius:12px;border:0;background:transparent;color:var(--bt-ink);cursor:pointer;font-family:inherit;transition:background-color .12s ease} -+.bt-tile:hover{background:var(--bt-hover)} -+.bt-tile[aria-current=page]{background:var(--bt-active)} + .bt-tile:hover{background:var(--bt-hover)} + .bt-tile[aria-current=page]{background:var(--bt-active)} +.bt-tile-unread{position:absolute;top:8px;right:12px} -+.bt-tile-name{font-size:13px;line-height:18px;font-weight:400;max-width:84px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} -+.bt-chip{font-size:11px;line-height:16px;padding:1px 4px;border-radius:4px;background:var(--bt-hover);border:1px solid var(--bt-line);color:var(--bt-ink-2);white-space:nowrap;max-width:84px;overflow:hidden;text-overflow:ellipsis} -+.bt-row{display:flex;align-items:center;gap:8px;min-height:54px;padding:8px;border:0;border-radius:10px;background:transparent;color:var(--bt-ink);text-align:left;cursor:pointer;width:100%;box-sizing:border-box;font-family:inherit;transition:background-color .12s ease} -+.bt-row:hover{background:var(--bt-hover)} -+.bt-row[aria-current=page]{background:var(--bt-active)} -+.bt-row-body{display:flex;flex-direction:column;min-width:0;flex:1 1 auto} -+.bt-row-name{display:flex;align-items:center;gap:6px;min-width:0;font-size:14px;line-height:20px;font-weight:400} -+.bt-row-title{min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} + .bt-tile-name{font-size:13px;line-height:18px;font-weight:400;max-width:84px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} + .bt-chip{font-size:11px;line-height:16px;padding:1px 4px;border-radius:4px;background:var(--bt-hover);border:1px solid var(--bt-line);color:var(--bt-ink-2);white-space:nowrap;max-width:84px;overflow:hidden;text-overflow:ellipsis} + .bt-row{display:flex;align-items:center;gap:8px;min-height:54px;padding:8px;border:0;border-radius:10px;background:transparent;color:var(--bt-ink);text-align:left;cursor:pointer;width:100%;box-sizing:border-box;font-family:inherit;transition:background-color .12s ease} +@@ -1950,27 +1950,32 @@ body[data-bt-motion=quiet] .bt-mark[data-poke]{cursor:default} + .bt-row-body{display:flex;flex-direction:column;min-width:0;flex:1 1 auto} + .bt-row-name{display:flex;align-items:center;gap:6px;min-width:0;font-size:14px;line-height:20px;font-weight:400} + .bt-row-title{min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.bt-row-title.bt-row-title-face{white-space:normal;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:3;line-clamp:3;overflow:hidden} +.bt-row-remote{flex:none;display:inline-flex;align-items:center;color:var(--bt-ink-2)} -+.bt-tag{flex:none;max-width:96px;font-size:12px;line-height:16px;font-weight:400;padding:1px 6px;border-radius:4px;background:var(--bt-tag-bg);color:var(--bt-tag-ink);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} -+.bt-row-preview{font-size:13px;line-height:18px;color:var(--bt-ink-2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} -+.bt-unread{width:8px;height:8px;border-radius:50%;background:#1084fe;flex:none;margin-right:2px;animation:bt-fade .13s cubic-bezier(.22,1,.36,1)} -+.bt-unread-wait{background:var(--bt-star)} + .bt-tag{flex:none;max-width:96px;font-size:12px;line-height:16px;font-weight:400;padding:1px 6px;border-radius:4px;background:var(--bt-tag-bg);color:var(--bt-tag-ink);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} + .bt-row-preview{font-size:13px;line-height:18px;color:var(--bt-ink-2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} + .bt-unread{width:8px;height:8px;border-radius:50%;background:#1084fe;flex:none;margin-right:2px;animation:bt-fade .13s cubic-bezier(.22,1,.36,1)} + .bt-unread-wait{background:var(--bt-star)} +.bt-shared-from,.bt-shared-wait{display:flex;flex-direction:column;gap:6px;padding:8px 0} +.bt-shared-wait .bt-set-row{min-width:0} +.bt-shared-wait .bt-set-copy{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;display:block} -+.bt-rail{align-items:center;padding:0 0 8px} -+.bt-rail .bt-row{justify-content:center;padding:6px 0;min-height:44px} -+.bt-side-foot{display:flex;padding-top:8px} -+.bt-connect{flex:1;height:36px;border-radius:999px;border:1px solid var(--bt-line);background:var(--bt-main);color:var(--bt-ink);font-family:inherit;font-size:14px;line-height:20px;cursor:pointer;display:flex;align-items:center;justify-content:center;gap:8px;transition:background-color .12s ease} -+.bt-connect:hover{background:var(--bt-hover)} -+.bt-connect svg{flex:none;width:15px;height:15px} + .bt-rail{align-items:center;padding:0 0 8px} + .bt-rail .bt-row{justify-content:center;padding:6px 0;min-height:44px} + .bt-side-foot{display:flex;padding-top:8px} + .bt-connect{flex:1;height:36px;border-radius:999px;border:1px solid var(--bt-line);background:var(--bt-main);color:var(--bt-ink);font-family:inherit;font-size:14px;line-height:20px;cursor:pointer;display:flex;align-items:center;justify-content:center;gap:8px;transition:background-color .12s ease} + .bt-connect:hover{background:var(--bt-hover)} + .bt-connect svg{flex:none;width:15px;height:15px} +-.bt-connect-wrap{position:relative;display:flex;flex:1 1 auto;min-width:0} +-.bt-connect-warn{flex:none;width:16px;height:16px;border-radius:50%;background:#E5484D;color:#fff;font-size:11px;line-height:16px;font-weight:700;display:inline-flex;align-items:center;justify-content:center} +-.bt-connect-tip{position:fixed;box-sizing:border-box;width:max-content;max-width:260px;padding:8px 12px;border-radius:10px;background:var(--bt-main);border:.5px solid var(--bt-line-2);box-shadow:0 8px 24px -4px rgba(0,0,0,.14);color:var(--bt-ink);font-size:12px;line-height:17px;white-space:normal;text-align:center;z-index:70;transform-origin:bottom left;animation:bt-tip-in ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing}} +.bt-connect-wrap{position:relative;display:flex;flex:1 1 auto;min-width:0}ms ${SPRING_SHAPE.easing}} -+@keyframes bt-tip-in{from{opacity:0;transform:scale(.96);filter:blur(4px)}} + @keyframes bt-tip-in{from{opacity:0;transform:scale(.96);filter:blur(4px)}} +-@media (prefers-reduced-motion:reduce){.bt-connect-tip{animation:none}} +@media (prefers-reduced-motion:reduce){} -+ -+.bt-head{position:relative;display:flex;justify-content:center;align-items:center;height:40px;flex:none;pointer-events:none} -+.bt-pill{pointer-events:auto;display:inline-flex;align-items:center;gap:8px;height:40px;padding:7.5px 13.5px 7.5px 7.5px;border-radius:999px;background:var(--bt-main);border:.5px solid var(--bt-line-2);box-shadow:0 2px 8px -1px rgba(0,0,0,.06),0 1px 2px rgba(0,0,0,.04);color:var(--bt-ink);font-family:inherit;font-size:14px;line-height:20px;cursor:pointer;max-width:70%;box-sizing:border-box;transition:background-color .12s ease,transform ${SPRING_TAP.duration}ms ${SPRING_TAP.easing}} -+.bt-pill:hover{background:color-mix(in srgb,var(--bt-main),#777 9%)} -+.bt-pill:active{transform:scale(.965)} -+.bt-pill-name{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-weight:500} + + .bt-head{position:relative;display:flex;justify-content:center;align-items:center;height:40px;flex:none;pointer-events:none} + .bt-pill{pointer-events:auto;display:inline-flex;align-items:center;gap:8px;height:40px;padding:7.5px 13.5px 7.5px 7.5px;border-radius:999px;background:var(--bt-main);border:.5px solid var(--bt-line-2);box-shadow:0 2px 8px -1px rgba(0,0,0,.06),0 1px 2px rgba(0,0,0,.04);color:var(--bt-ink);font-family:inherit;font-size:14px;line-height:20px;cursor:pointer;max-width:70%;box-sizing:border-box;transition:background-color .12s ease,transform ${SPRING_TAP.duration}ms ${SPRING_TAP.easing}} + .bt-pill:hover{background:color-mix(in srgb,var(--bt-main),#777 9%)} + .bt-pill:active{transform:scale(.965)} + .bt-pill-name{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-weight:500} +.bt-pill:has(.bt-pill-name-face){height:auto;min-height:40px;align-items:flex-start} +.bt-pill-name.bt-pill-name-face{min-width:0;white-space:normal;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;line-clamp:2;overflow:hidden} -+.bt-pill-mem{flex:none;display:inline-flex;width:0;margin-left:-8px;overflow:hidden;transition:${spring(SPRING_SHAPE, "width", "margin-left")}} -+.bt-pill[data-memory=updated] .bt-pill-mem{width:var(--bt-mem-w);margin-left:0;transition:${spring(SPRING_STRETCH, "width", "margin-left")}} -+.bt-pill-mem-in{flex:none;display:inline-flex;align-items:center;gap:5px;white-space:nowrap;color:var(--bt-ink-2);font-size:13px;font-weight:500;opacity:0;filter:blur(6px);transform:translateX(-8px);transition:opacity .14s ease,filter .18s ease,${spring(SPRING_SHAPE, "transform")}} -+.bt-pill-mem-in::before{content:"";flex:none;width:1px;height:14px;margin-right:4px;background:var(--bt-line-2)} -+.bt-pill[data-memory=updated] .bt-pill-mem-in{opacity:1;filter:none;transform:none;transition-delay:70ms} -+.bt-pill-mem-ico{flex:none;display:inline-flex;width:16px;height:16px;color:var(--bt-accent)} -+.bt-pill-mem-ico svg{width:16px;height:16px} -+.bt-pill[data-memory=updated] .bt-pill-mem-ico{animation:bt-mem-pop ${SPRING_POP.duration}ms ${SPRING_POP.easing} 90ms both} -+@keyframes bt-mem-pop{from{transform:scale(.4) rotate(-14deg)}to{transform:none}} -+.bt-sr{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap;border:0} -+@media (prefers-reduced-motion:reduce){.bt-pill-mem,.bt-pill-mem-in{transition:none!important}.bt-pill-mem-in{filter:none;transform:none}.bt-pill[data-memory=updated] .bt-pill-mem-ico{animation:none}} -+.bt-swap{color:var(--bt-ink-3);font-size:12px} -+.bt-pill-go{flex:none;display:inline-flex;align-items:center;justify-content:flex-end;width:0;margin-left:-8px;overflow:hidden;opacity:0;filter:blur(6px);transform:translateX(-6px);color:var(--bt-ink-2);transition:width ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing},margin-left ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing},transform ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing},opacity .18s ease,filter .18s ease} -+.bt-pill-go svg{flex:none;transition:transform ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing}} -+.bt-pill:hover .bt-pill-go,.bt-pill:focus-visible .bt-pill-go,.bt-pill[aria-expanded=true] .bt-pill-go{width:18px;margin-left:0;opacity:1;filter:none;transform:none} -+.bt-pill[aria-expanded=true] .bt-pill-go svg{transform:rotate(180deg)} -+.bt-pill[data-memory=updated]:is(:hover,:focus-visible,[aria-expanded=true]) .bt-pill-go{width:0;margin-left:-8px;opacity:0;filter:blur(6px);transform:translateX(-6px)} -+@media (prefers-reduced-motion:reduce){.bt-pill,.bt-pill-go,.bt-pill-go svg{transition:none!important}.bt-pill-go{filter:none}.bt-pill:active{transform:none}} -+ -+.bt-astack{display:flex;flex-direction:column;align-items:flex-start;gap:4px;margin:0} -+.bt-bubble{max-width:min(80%,560px,calc(100% - 82px));background:var(--bt-bubble);color:var(--bt-ink);border-radius:18px;padding:7px 12px;font-size:var(--dsh-content-font-size,14px);line-height:calc(var(--dsh-content-font-size,14px) + 8px);box-sizing:border-box;overflow-wrap:anywhere} + .bt-pill-mem{flex:none;display:inline-flex;width:0;margin-left:-8px;overflow:hidden;transition:${spring(SPRING_SHAPE, "width", "margin-left")}} + .bt-pill[data-memory=updated] .bt-pill-mem{width:var(--bt-mem-w);margin-left:0;transition:${spring(SPRING_STRETCH, "width", "margin-left")}} + .bt-pill-mem-in{flex:none;display:inline-flex;align-items:center;gap:5px;white-space:nowrap;color:var(--bt-ink-2);font-size:13px;font-weight:500;opacity:0;filter:blur(6px);transform:translateX(-8px);transition:opacity .14s ease,filter .18s ease,${spring(SPRING_SHAPE, "transform")}} +@@ -1992,6 +1992,7 @@ body[data-bt-motion=quiet] .bt-mark[data-poke]{cursor:default} + + .bt-astack{display:flex;flex-direction:column;align-items:flex-start;gap:4px;margin:0} + .bt-bubble{max-width:min(80%,560px,calc(100% - 82px));background:var(--bt-bubble);color:var(--bt-ink);border-radius:18px;padding:7px 12px;font-size:var(--dsh-content-font-size,14px);line-height:calc(var(--dsh-content-font-size,14px) + 8px);box-sizing:border-box;overflow-wrap:anywhere} +.bt-event.bt-not-sent{gap:6px;color:var(--bt-ink-3)} -+.bt-msg:not(:first-child)>.bt-msg-line>.bt-bubble{border-start-start-radius:6px} -+.bt-msg:not(:last-child)>.bt-msg-line>.bt-bubble{border-end-start-radius:6px} -+.bt-bubble [class*="_markdown"]>*+*{margin-top:10px} -+.bt-bubble [class*="_markdown"]{color:inherit!important;font-size:inherit!important;line-height:inherit!important} -+.bt-bubble p{margin:0} -+.bt-bubble p + p{margin-top:6px} -+.bt-bubble ul,.bt-bubble ol{margin:4px 0;padding-left:20px} -+.bt-bubble a[href^="${MENTION_ORIGIN}"]{text-decoration:none;font-weight:400;cursor:pointer;white-space:nowrap} -+.bt-bubble a[href^="${MENTION_ORIGIN}"]::before{content:"";display:inline-block;width:16px;height:16px;margin-right:3px;vertical-align:-3px;background:currentColor;-webkit-mask:${DEFAULT_MASK} center/contain no-repeat;mask:${DEFAULT_MASK} center/contain no-repeat} -+.bt-group-msg{display:flex;flex-direction:column;gap:4px;margin:16px 0 0} -+.bt-group-msg:first-child{margin-top:0} -+.bt-lead{flex:none;display:flex;margin-bottom:1px} -+button.bt-lead{border:0;background:none;padding:0;cursor:pointer} -+/* Rows without the avatar start where the bubble beside it starts: 22px avatar + 8px gap. */ -+.bt-astack[data-lead]>.bt-msg:not(:last-child)>.bt-msg-line,.bt-astack[data-lead] .bt-reacts{margin-left:30px} -+.bt-group-name{font-size:12px;line-height:16px;font-weight:500;margin-left:42px} -+.bt-x{border:0;background:none;color:var(--bt-ink-2);cursor:pointer;width:24px;height:24px;border-radius:6px;font-size:16px;line-height:24px;padding:0;transition:background-color .12s ease,color .12s ease,${spring(SPRING_TAP, "scale")}} -+.bt-x:hover{background:var(--bt-hover);color:var(--bt-ink)} -+ -+.bt-q{margin:0 auto 8px;width:100%;max-width:var(--dsh-chat-content-width,748px);display:flex;flex-direction:column;gap:12px;--bt-q-edge:color-mix(in srgb,var(--bt-ink) 10%,transparent);--bt-q-rule:color-mix(in srgb,var(--bt-ink) 8%,transparent);--bt-q-rule-2:color-mix(in srgb,var(--bt-ink) 5%,transparent);background:var(--bt-card);box-shadow:0 1px 3px rgba(20,20,20,.04),inset 0 0 0 1px var(--bt-q-edge);border-radius:16px;padding:14px 16px;box-sizing:border-box;color:var(--bt-ink);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;animation:bt-q-in ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing} backwards,bt-veil-in .2s ease-out backwards} -+body[data-ds-dark-theme] .bt-q{--bt-q-edge:rgba(255,255,255,.16);--bt-q-rule:rgba(255,255,255,.12);--bt-q-rule-2:rgba(255,255,255,.08)} -+@keyframes bt-q-in{from{transform:translateY(10px) scale(.97)}} -+.bt-q-settled{animation:none} -+.bt-q-head{display:flex;align-items:flex-start;gap:8px;min-width:0} -+.bt-q-text{flex:1 1 0;min-width:0;display:flex;flex-direction:column} -+.bt-q-title{margin:0;font-size:15px;line-height:22px;font-weight:600;overflow-wrap:anywhere} -+.bt-q-detail{font-size:13px;line-height:18px;color:var(--bt-ink-2)} -+.bt-q-x{flex:none;width:20px;height:20px;border:0;padding:0;border-radius:6px;background:none;color:var(--bt-ink-2);display:inline-flex;align-items:center;justify-content:center;cursor:pointer;transition:background-color .12s ease,color .12s ease,${spring(SPRING_TAP, "scale")}} -+.bt-q-x svg{width:14px;height:14px} -+.bt-q-x:hover{background:var(--bt-hover);color:var(--bt-ink)} -+.bt-q-x:focus-visible,.bt-q-opt:focus-visible{outline:2px solid var(--bt-accent);outline-offset:-2px} -+.bt-q-list{display:flex;flex-direction:column;gap:1px;min-width:0;box-sizing:border-box;margin:0 -8px} -+.bt-q-opt{position:relative;display:flex;align-items:flex-start;gap:16px;width:100%;min-width:0;box-sizing:border-box;margin:0;border:0;border-radius:12px;background:transparent;padding:10px 8px;font:inherit;color:var(--bt-ink);text-align:start;cursor:pointer;transition:background-color .12s ease} -+.bt-q:not(.bt-q-raised)>.bt-q-head:has(+.bt-q-list){padding-bottom:12px;border-bottom:1px solid var(--bt-q-rule)} -+.bt-q:not(.bt-q-raised)>.bt-q-head+.bt-q-list{margin-top:-6px} -+.bt-q-opt+.bt-q-opt::before{content:"";position:absolute;left:calc(var(--bt-key,24px) + 24px);right:8px;top:-1px;height:1px;background:var(--bt-q-rule-2);transition:opacity .12s ease;pointer-events:none} -+button.bt-q-opt:hover::before,button.bt-q-opt:hover+.bt-q-opt::before,.bt-q-opt[aria-pressed=true]::before,.bt-q-opt[aria-pressed=true]+.bt-q-opt::before{opacity:0} -+.bt-q-raised .bt-q-opt::before{display:none} -+button.bt-q-opt:hover{background:var(--bt-hover)} -+button.bt-q-opt:active{background:var(--bt-active)} -+.bt-q-opt[aria-pressed=true]{background:var(--bt-active)} -+.bt-q:not(.bt-q-raised) :is(button.bt-q-opt:hover,.bt-q-opt[aria-pressed=true]) .bt-key{background:var(--bt-card);color:var(--bt-ink)} -+.bt-key{flex:none;box-sizing:border-box;width:var(--bt-key,24px);height:var(--bt-key,24px);border-radius:8px;background:var(--bt-hover);color:var(--bt-ink-2);font-size:12px;line-height:var(--bt-key,24px);font-weight:500;text-align:center;font-variant-numeric:tabular-nums;transition:background-color .12s ease,color .12s ease} -+.bt-q-body{flex:1 1 auto;min-width:0;display:flex;flex-direction:column;gap:1px;padding-top:2px} -+/* With a description the key spans the two text lines less 2px above and below. */ -+.bt-q-opt:has(.bt-q-desc){--bt-key:34px} -+.bt-q-opt:has(.bt-q-desc) .bt-key{margin-top:2px;border-radius:10px;font-size:13px} -+.bt-q-opt:has(.bt-q-desc) .bt-q-body{padding-top:0} -+.bt-q-label{font-size:14px;line-height:20px;font-weight:500;white-space:normal;overflow-wrap:anywhere} -+.bt-q-desc{font-size:12px;line-height:17px;color:var(--bt-ink-2);overflow-wrap:anywhere} -+.bt-q-check{flex:none;display:inline-flex;align-items:center;color:var(--bt-ink-2);animation:bt-check-pop ${SPRING_POP.duration}ms ${SPRING_POP.easing} backwards} -+.bt-q-settled .bt-q-check{animation:none} -+.bt-q-check svg{width:16px;height:16px} -+.bt-q-settled .bt-key{opacity:.4} -+.bt-q-settled .bt-q-label{color:color-mix(in srgb,var(--bt-ink) 45%,transparent)} -+.bt-q-dismissed .bt-q-title{color:var(--bt-ink-2)} -+.bt-q.bt-q-settled{gap:6px;padding:12px 16px} -+.bt-q-settled .bt-q-title{font-size:14px;line-height:20px;font-weight:500} -+.bt-q.bt-q-settled>.bt-q-head:has(+.bt-q-list){padding-bottom:0;border-bottom:0} -+.bt-q.bt-q-settled>.bt-q-head+.bt-q-list{margin-top:0} -+.bt-q-settled .bt-q-opt{padding:4px 8px} -+.bt-q-badge{flex:none;height:20px;padding:0 7px;border-radius:999px;border:1px dotted var(--bt-line-2);color:var(--bt-ink-2);font-size:12px;line-height:18px;box-sizing:border-box} -+.bt-q-custom{display:flex;align-items:flex-start;gap:8px;width:100%;min-width:0} -+.bt-q-field{flex:1 1 0;min-width:0;display:flex;box-sizing:border-box;min-height:32px;padding:5px 11px;border-radius:8px;border:.5px solid var(--bt-line-2);background:none;cursor:text;transition:border-color .12s ease} -+.bt-q-field:focus-within{border-color:color-mix(in srgb,var(--bt-ink) 30%,transparent)} -+.bt-q-input{flex:1;width:100%;min-width:0;max-height:120px;margin:0;padding:0;border:0;outline:none;background:transparent;color:var(--bt-ink);font:inherit;font-size:14px;line-height:20px;resize:none;overflow-y:hidden;white-space:pre-wrap;overflow-wrap:anywhere} -+.bt-q-input:not(:placeholder-shown){field-sizing:content} -+.bt-q-input::placeholder{color:var(--bt-ink-3)} -+.bt-q-submit,.bt-q-btn{flex:none;height:36px;padding:0 14px;border:0;border-radius:999px;font:inherit;font-size:14px;font-weight:500;cursor:pointer;transition:opacity .12s ease,background-color .12s ease,${spring(SPRING_TAP, "scale")}} -+.bt-q-submit{background:var(--bt-accent);color:var(--bt-accent-ink,#fff);animation:bt-q-submit-in ${SPRING_TAP.duration}ms ${SPRING_TAP.easing} backwards,bt-veil-in .16s ease-out backwards} -+@keyframes bt-q-submit-in{from{transform:translateX(8px) scale(.9)}} -+.bt-q-submit:hover:not(:disabled){opacity:.86} -+.bt-q-submit:disabled{opacity:.4;cursor:default} -+.bt-q-btn{background:var(--bt-hover);color:var(--bt-ink)} -+.bt-q-btn:hover{background:var(--bt-active)} -+.bt-q-foot{display:flex;justify-content:flex-end;gap:8px} -+.bt-q-raised{width:calc(var(--dsh-chat-content-width,748px) - 32px);max-width:calc(100% - 32px);gap:0;padding:0;box-shadow:0 2px 8px rgba(20,20,20,.035),inset 0 0 0 .5px color-mix(in srgb,var(--bt-ink) 5%,transparent)} -+.bt-q-raised .bt-q-head{padding:14px 16px 4px 20px} -+.bt-q-raised .bt-q-head:last-child{padding-bottom:16px} -+.bt-q-raised .bt-q-list{gap:1px;margin:0;padding:8px} -+.bt-q-raised .bt-q-opt{gap:4px;padding:7px;border-radius:16px;color:var(--bt-ink-2)} -+.bt-q-raised button.bt-q-opt:hover{color:var(--bt-ink)} -+.bt-q-raised .bt-q-opt[aria-pressed=true]{background:transparent;color:var(--bt-ink)} -+.bt-q-raised .bt-q-opt[aria-pressed=true]:hover{background:var(--bt-hover)} -+.bt-q-raised .bt-q-opt[aria-pressed=true] .bt-key,.bt-q-ownrow:focus-within .bt-key,.bt-q-ownrow[data-filled] .bt-key{background:var(--bt-user);color:var(--bt-user-ink)} -+.bt-q-raised .bt-q-body{padding:2px 6px 0} -+.bt-q-raised .bt-q-desc{color:color-mix(in srgb,var(--bt-ink) 61%,transparent)} -+.bt-q-ownrow{cursor:text} -+.bt-q-ownrow .bt-q-input{color:var(--bt-ink)} -+.bt-q-raised .bt-q-foot{padding:0 8px 8px} -+@media (prefers-reduced-motion:reduce){.bt-q{animation:bt-fade .12s ease-out backwards}.bt-q-settled{animation:none}.bt-q-submit{animation:none}} -+ -+.bt-newchat{flex:1;display:flex;flex-direction:column;min-width:0;position:relative;animation:bt-veil-in .18s ease-out} + .bt-msg:not(:first-child)>.bt-msg-line>.bt-bubble{border-start-start-radius:6px} + .bt-msg:not(:last-child)>.bt-msg-line>.bt-bubble{border-end-start-radius:6px} + .bt-bubble [class*="_markdown"]>*+*{margin-top:10px} +@@ -2085,6 +2085,7 @@ button.bt-q-opt:active{background:var(--bt-active)} + @media (prefers-reduced-motion:reduce){.bt-q{animation:bt-fade .12s ease-out backwards}.bt-q-settled{animation:none}.bt-q-submit{animation:none}} + + .bt-newchat{flex:1;display:flex;flex-direction:column;min-width:0;position:relative;animation:bt-veil-in .18s ease-out} +.bt-pane[data-menu]{background:transparent}.bt-pane[data-menu] .bt-newchat{flex:none;align-self:flex-start;width:min(280px,calc(100% - 16px));max-height:calc(100% - 16px);margin:8px;overflow:auto;background:var(--bt-card);border:1px solid var(--bt-line-2);border-radius:12px;box-shadow:0 10px 30px rgba(0,0,0,.14),0 2px 6px rgba(0,0,0,.06)}.bt-newchat[data-menu] .bt-drop{position:relative;top:0;left:0;width:auto;max-height:none;margin:0;box-shadow:none;border:0;max-width:none} -+.bt-to{display:flex;align-items:center;flex-wrap:wrap;gap:6px;min-height:48px;padding:6px 12px;border-bottom:1px solid var(--bt-line);font-size:14px;box-sizing:border-box} -+.bt-to-label{color:var(--bt-ink-2)} -+.bt-to input{flex:1;min-width:120px;border:0;outline:none;background:none;color:var(--bt-ink);font-size:14px;height:26px} -+.bt-token{display:inline-flex;align-items:center;gap:5px;height:24px;padding:0 6px 0 4px;border-radius:999px;background:var(--bt-hover);font-size:13px;animation:bt-check-pop ${SPRING_POP.duration}ms ${SPRING_POP.easing}} -+.bt-token button{border:0;background:none;color:var(--bt-ink-2);cursor:pointer;padding:0 2px} -+.bt-admin-star{flex:none;display:inline-flex;align-items:center;justify-content:center;width:12px;height:12px;border-radius:50%;corner-shape:round;background:color-mix(in srgb,var(--bt-star) 82%,var(--bt-sidebar));vertical-align:-1px} -+.bt-to-star{display:inline-flex;margin-right:-2px;animation:bt-check-pop ${SPRING_POP.duration}ms ${SPRING_POP.easing}} -+.bt-to .bt-to-create{flex:none;height:28px;padding:0 12px;font-size:13px;animation:bt-q-submit-in ${SPRING_TAP.duration}ms ${SPRING_TAP.easing},bt-veil-in .16s ease-out} -+.bt-drop{position:absolute;top:48px;left:32px;width:min(550px,calc(100% - 64px));display:flex;flex-direction:column;padding:6px;max-height:min(420px,60vh);overflow-y:auto;background:var(--bt-card);border:1px solid var(--bt-line-2);border-radius:12px;box-shadow:0 6px 20px -4px rgba(0,0,0,.12);box-sizing:border-box;z-index:1;transform-origin:24px 0;animation:bt-pop-drop ${SPRING_TAP.duration}ms ${SPRING_TAP.easing},bt-veil-in .14s ease-out} -+.bt-option{display:flex;align-items:center;gap:10px;border:0;background:none;border-radius:8px;padding:7px 8px;min-height:36px;box-sizing:border-box;font-size:13px;line-height:18px;color:var(--bt-ink);cursor:pointer;text-align:left;width:100%} -+.bt-option[aria-selected=true]{background:var(--bt-active)} -+.bt-drop-list>.bt-option:not([aria-selected=true]):hover{background:var(--bt-hover)} -+.bt-option-label{flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} + .bt-to{display:flex;align-items:center;flex-wrap:wrap;gap:6px;min-height:48px;padding:6px 12px;border-bottom:1px solid var(--bt-line);font-size:14px;box-sizing:border-box} + .bt-to-label{color:var(--bt-ink-2)} + .bt-to input{flex:1;min-width:120px;border:0;outline:none;background:none;color:var(--bt-ink);font-size:14px;height:26px} +@@ -2098,7 +2098,9 @@ button.bt-q-opt:active{background:var(--bt-active)} + .bt-option[aria-selected=true]{background:var(--bt-active)} + .bt-drop-list>.bt-option:not([aria-selected=true]):hover{background:var(--bt-hover)} + .bt-option-label{flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +-.bt-option-hint{font-size:11px;color:var(--bt-ink-2);flex:none;animation:bt-veil-in .16s ease-out} +.bt-option-hint{font-size:11px;color:var(--bt-ink-2);flex:0 1 auto;min-width:0;max-width:50%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;animation:bt-veil-in .16s ease-out} +.bt-option-copy{flex:1;min-width:0;display:flex;flex-direction:column} +.bt-option-sub{font-size:11px;line-height:15px;color:var(--bt-ink-2);white-space:normal;overflow-wrap:anywhere} -+.bt-option-icon{width:22px;height:22px;border-radius:50%;background:var(--bt-hover);color:var(--bt-ink-2);display:inline-flex;align-items:center;justify-content:center;flex:none} -+.bt-hint{padding:14px;text-align:center;font-size:13px;color:var(--bt-ink-2)} -+.bt-nc-spacer{flex:1} -+.bt-nc-compose{padding:0 16px 14px;display:flex;justify-content:center} -+.bt-nc-card{display:flex;align-items:flex-end;gap:6px;width:100%;max-width:var(--dsh-chat-content-width,748px);min-height:44px;padding:4px 6px;border-radius:22px;border:1px solid var(--bt-line-2);background:var(--bt-main);box-shadow:0 2px 8px -2px rgba(0,0,0,.06);box-sizing:border-box} -+.bt-nc-plus{width:34px;height:34px;border-radius:50%;display:inline-flex;align-items:center;justify-content:center;color:var(--bt-ink-2);background:var(--bt-hover);flex:none} -+.bt-nc-card textarea{flex:1;resize:none;border:0;outline:none;background:none;color:var(--bt-ink);font:inherit;font-size:14px;line-height:20px;padding:7px 4px;min-height:34px;max-height:140px;box-sizing:border-box} -+.bt-nc-send{width:34px;height:34px;border-radius:50%;border:0;background:var(--bt-accent);color:var(--bt-accent-ink,#fff);display:inline-flex;align-items:center;justify-content:center;cursor:pointer;flex:none} -+.bt-nc-send:disabled{opacity:.3;cursor:default} -+.bt-create{flex:1;display:flex;flex-direction:column;min-width:0} -+.bt-create-head{display:flex;align-items:center;justify-content:space-between;gap:8px;min-height:48px;padding:6px 12px;border-bottom:1px solid var(--bt-line);box-sizing:border-box} -+.bt-create-gap{width:28px} -+.bt-create-title{font-size:14px;line-height:20px;font-weight:500} -+.bt-create-body{flex:1;overflow-y:auto;padding:28px 16px 32px;display:flex;justify-content:center} -+.bt-create-card{width:100%;max-width:400px;display:flex;flex-direction:column;gap:18px;animation:bt-q-in ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing} both,bt-veil-in .22s ease-out both} -+.bt-create-preview{display:flex;flex-direction:column;align-items:center;gap:10px} -+.bt-create-pop{display:inline-flex;animation:bt-create-pop ${SPRING_POP.duration}ms ${SPRING_POP.easing} both} -+@keyframes bt-create-pop{from{transform:scale(.72);opacity:.4}} -+.bt-create-name{font-size:17px;line-height:24px;font-weight:500;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} -+.bt-create-step{font-size:12px;line-height:16px;color:var(--bt-ink-2);margin-bottom:-10px} -+.bt-create-looks{display:flex;flex-direction:column;gap:10px;padding:10px;border:1px solid var(--bt-line);border-radius:12px;background:var(--bt-card)} -+.bt-create-lookbar{justify-content:center} -+.bt-create-foot{display:flex;justify-content:flex-end;gap:8px;padding-top:4px} -+.bt-create-go{height:36px;padding:0 20px} -+@media (prefers-reduced-motion:reduce){.bt-create-card,.bt-create-pop{animation:none}} -+.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-option-icon{width:22px;height:22px;border-radius:50%;background:var(--bt-hover);color:var(--bt-ink-2);display:inline-flex;align-items:center;justify-content:center;flex:none} + .bt-hint{padding:14px;text-align:center;font-size:13px;color:var(--bt-ink-2)} + .bt-nc-spacer{flex:1} +@@ -2127,6 +2127,10 @@ button.bt-q-opt:active{background:var(--bt-active)} + .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-fix-actions{display:flex;flex-wrap:wrap;gap:6px;margin-top:6px} +.bt-fix-btn{font:inherit;font-size:12px;padding:3px 10px;border-radius:999px;border:1px solid currentColor;background:transparent;color:inherit;cursor:pointer} +.bt-fix .bt-note{margin-top:6px} +.bt-fix-link{margin-top:6px;color:inherit;user-select:all} -+ -+.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-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} -+@keyframes bt-pane-in{from{transform:translateX(100%)}} -+[class*="_centerCol"]{transition:margin-right ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing}} -+.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} -+.bt-panel-view{flex:1;min-height:0;display:flex;flex-direction:column} -+.bt-drawer-top{display:flex;justify-content:flex-end;gap:8px;padding:12px 17px 6px 12px} -+.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} -+.bt-drawer-name{font-size:17px;line-height:24px;font-weight:500;border:0;background:none;color:inherit;cursor:text;padding:0 6px;border-radius:6px;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} + + .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} +@@ -2150,7 +2150,7 @@ 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%)}} +@@ -2163,6 +2163,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} + .bt-drawer-name{font-size:17px;line-height:24px;font-weight:500;border:0;background:none;color:inherit;cursor:text;padding:0 6px;border-radius:6px;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.bt-drawer-name-face{white-space:normal;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:3;overflow:hidden;text-overflow:ellipsis;line-clamp:3;cursor:default} +.bt-drawer-name-face:hover{background:none} -+.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} -+.bt-tabs{position:relative;display:flex;justify-content:center;gap:2px;margin:16px 16px 0} -+.bt-tab{position:relative;z-index:1;border:0;background:none;border-radius:9999px;padding:4px 8px;font-size:13px;line-height:20px;color:var(--bt-ink-2);cursor:pointer;white-space:nowrap} -+.bt-tab:hover{color:var(--bt-ink)} -+.bt-tab[aria-selected=true]{background:var(--bt-hover);color:var(--bt-ink)} -+.bt-tabs>.bt-thumb{top:0;bottom:0;border-radius:9999px;background:var(--bt-hover)} -+.bt-tabs[data-glide] .bt-tab[aria-selected=true]{background:transparent} -+.bt-drawer-body{flex:1;overflow-y:auto;padding:20px 16px 16px;font-size:13px;line-height:18px;display:flex;flex-direction:column;gap:16px} -+.bt-empty{text-align:center;color:var(--bt-ink-3);font-size:13px;line-height:18px;padding:8px 12px} -+.bt-section-title{font-size:12px;color:var(--bt-ink-2);margin-bottom:6px} -+.bt-muted{color:var(--bt-ink-2)} -+.bt-swatches{display:flex;flex-wrap:wrap;gap:6px} -+.bt-swatch{width:22px;height:22px;border-radius:50%;border:2px solid transparent;cursor:pointer;padding:0;box-shadow:inset 0 0 0 1px var(--bt-line-2)} -+.bt-swatch[aria-pressed=true]{border-color:var(--bt-ink)} -+.bt-textarea{width:100%;box-sizing:border-box;min-height:120px;border:1px solid var(--bt-line);border-radius:8px;padding:8px;background:var(--bt-card);color:var(--bt-ink);font:inherit;resize:vertical} -+.bt-actions{display:flex;gap:8px;flex-wrap:wrap;align-items:center} -+.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} -+ -+/* Bot settings: fields, the avatar editor popover, and the details-pane sub-view. */ -+.bt-field{display:flex;flex-direction:column;gap:6px} -+.bt-field-label{font-size:13px;line-height:18px;font-weight:500} -+.bt-input{width:100%;box-sizing:border-box;height:36px;border:1px solid var(--bt-line);border-radius:8px;padding:0 10px;background:var(--bt-card);color:var(--bt-ink);font:inherit;font-size:13px;outline:none} -+.bt-input:focus{border-color:var(--bt-line-2)} -+.bt-input-area{height:auto;min-height:96px;padding:8px 10px;line-height:18px;resize:vertical} -+.bt-note{font-size:12px;line-height:16px;color:var(--bt-ink-3)} -+.bt-soft-sm{height:28px;padding:0 12px;font-size:12px} -+.bt-set-avatar{display:flex;justify-content:center;padding:4px 0} -+.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-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} -+.bt-avedit-tab{position:relative;z-index:1;border:0;background:none;border-radius:999px;padding:3px 10px;font-size:12px;line-height:16px;color:var(--bt-ink-2);cursor:pointer} -+.bt-avedit-tab[aria-selected=true]{background:var(--bt-main);color:var(--bt-ink)} -+.bt-avedit-tabs>.bt-thumb{top:2px;bottom:2px;border-radius:999px;background:var(--bt-main);box-shadow:0 1px 2px rgba(0,0,0,.06)} -+.bt-avedit-tabs[data-glide] .bt-avedit-tab[aria-selected=true]{background:transparent} -+.bt-avedit-reset{color:var(--bt-ink-3)} -+.bt-avedit-reset:hover{color:var(--bt-ink)} -+.bt-shapes{display:flex;flex-direction:column;gap:2px} -+.bt-shape-row{display:grid;grid-template-columns:repeat(6,1fr);gap:2px} -+.bt-shape{position:relative;aspect-ratio:1;border:0;border-radius:8px;background:none;color:var(--bt-ink);cursor:pointer;display:flex;align-items:center;justify-content:center;padding:0} -+.bt-shape:hover{background:var(--bt-hover)} -+.bt-shape .bt-ring{position:absolute;left:50%;top:50%;width:36px;height:36px;translate:-50% -50%;overflow:visible;opacity:0;scale:.82;pointer-events:none;transition:opacity .14s ease,${spring(SPRING_POP, "scale")}} -+.bt-shape[aria-pressed=true] .bt-ring{opacity:1;scale:1} -+.bt-colors{display:flex;justify-content:space-between} -+.bt-color{width:22px;height:22px;border-radius:50%;border:2px solid transparent;padding:2px;background:none;cursor:pointer;box-sizing:border-box;transition:border-color .14s ease,${spring(SPRING_TAP, "scale")}} -+.bt-color[aria-pressed=true] span{animation:bt-check-pop ${SPRING_POP.duration}ms ${SPRING_POP.easing}} -+.bt-color span{display:block;width:100%;height:100%;border-radius:50%} -+.bt-color[aria-pressed=true]{border-color:var(--bt-ink)} -+.bt-dropzone{border:1.5px dashed var(--bt-line-2);border-radius:10px;padding:18px 12px;display:flex;flex-direction:column;align-items:center;gap:8px;color:var(--bt-ink-2);font-size:12px;line-height:16px;text-align:center} -+.bt-dropzone[data-over]{border-color:var(--bt-accent);color:var(--bt-ink)} -+.bt-dropzone img{width:64px;height:64px;border-radius:50%;object-fit:cover} -+.bt-set-actions{display:flex;gap:8px;flex-wrap:wrap;align-items:center} -+.bt-about{white-space:pre-wrap;display:-webkit-box;-webkit-line-clamp:5;-webkit-box-orient:vertical;overflow:hidden} -+.bt-about[data-open]{display:block;-webkit-line-clamp:unset} -+.bt-more{border:0;background:none;color:var(--bt-ink-3);font-size:12px;line-height:16px;cursor:pointer;padding:2px 0} -+.bt-more:hover{color:var(--bt-ink)} -+.bt-subhead{display:flex;align-items:center;gap:4px;padding:10px 12px 4px} -+.bt-subhead-title{flex:1;font-size:15px;line-height:24px;font-weight:600;padding-left:4px} -+.bt-icon-btn{width:28px;height:28px;border-radius:50%;border:0;background:none;color:var(--bt-ink-2);display:inline-flex;align-items:center;justify-content:center;cursor:pointer;padding:0} -+.bt-icon-btn:hover{background:var(--bt-hover);color:var(--bt-ink)} -+.bt-set-body{gap:20px} -+.bt-drawer-model{display:flex;flex-direction:column} -+.bt-drawer-model .bt-note{margin-top:6px} -+.bt-drawer-permission{display:flex;flex-direction:column} -+.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} -+.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)} -+.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} -+:is(.bt-settings-layer,.bt-mem-layer)[data-leaving] .bt-scrim{opacity:0;transition:opacity .16s ease-in} -+:is(.bt-settings-layer,.bt-mem-layer)[data-leaving]>[role=dialog]{animation:bt-dialog-sink .16s cubic-bezier(.4,0,1,1) forwards} -+@keyframes bt-dialog-rise{from{transform:translateY(12px) scale(.96)}} -+@keyframes bt-dialog-sink{to{opacity:0;transform:translateY(6px) scale(.975);filter:blur(3px)}} -+@media (prefers-reduced-motion:reduce){.bt-settings{transition:none}.bt-settings,.bt-scrim,.bt-settings-page>*{animation:bt-fade .12s ease-out!important}} -+.bt-settings-nav{position:relative;width:198px;flex:none;padding:16px 8px;border-right:.5px solid var(--bt-line);display:flex;flex-direction:column;gap:2px;background:var(--bt-sidebar);box-sizing:border-box} -+.bt-settings-item{position:relative;z-index:1;display:flex;align-items:center;gap:8px;padding:6px 8px;border:0;border-radius:8px;background:none;color:var(--bt-ink-2);font:inherit;font-size:13px;line-height:20px;cursor:pointer;text-align:left;transition:background-color .12s ease,color .12s ease} -+.bt-settings-item:hover{color:var(--bt-ink);background:var(--bt-hover)} -+.bt-settings-item[aria-current=page]{background:var(--bt-active);color:var(--bt-ink)} -+.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} -+.bt-settings-shell{margin-top:auto} + .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} +@@ -2255,6 +2255,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} + .bt-settings-shell{margin-top:auto} +.bt-settings-out{margin-left:auto;opacity:.6;font-size:12px} -+/* 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} -+/* The page column takes the dialog's final size at once, so while the dialog resizes -+ only its edge moves and the page does not lay out again every frame. */ -+.bt-settings-main{flex:none;display:flex;flex-direction:column;min-width:0;width:calc(var(--bt-set-w) - 198px);height:var(--bt-set-h)} -+.bt-settings-head{display:flex;align-items:center;gap:6px;padding:16px 56px 4px 20px} -+.bt-settings-head h2{margin:0;font-size:15px;line-height:24px;font-weight:600;animation:bt-veil-in .2s ease-out} -+.bt-settings-head>.bt-icon-btn{animation:bt-back-in ${SPRING_TAP.duration}ms ${SPRING_TAP.easing},bt-veil-in .16s ease-out} -+@keyframes bt-back-in{from{translate:6px 0;scale:.8}} -+.bt-settings-scroll{flex:1;overflow-y:auto;padding:12px 20px 24px;display:flex;flex-direction:column;gap:20px;scrollbar-width:thin} -+.bt-settings-page>:not(.bt-us){animation:bt-page-up ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing} backwards,bt-veil-in .22s ease-out backwards} -+.bt-settings-page[data-move=in]>:not(.bt-us){animation-name:bt-page-in,bt-veil-in} -+.bt-settings-page[data-move=out]>:not(.bt-us){animation-name:bt-page-out,bt-veil-in} -+.bt-settings-page>:nth-child(2){animation-delay:30ms} -+.bt-settings-page>:nth-child(3){animation-delay:60ms} -+.bt-settings-page>:nth-child(4){animation-delay:90ms} -+.bt-settings-page>:nth-child(n+5){animation-delay:120ms} -+@keyframes bt-page-up{from{transform:translateY(10px)}} -+@keyframes bt-page-in{from{transform:translateX(18px)}} -+@keyframes bt-page-out{from{transform:translateX(-18px)}} -+.bt-settings-close{position:absolute;top:12px;right:12px;width:32px;height:32px;border-radius:50%;border:0;background:var(--bt-hover);color:var(--bt-ink-2);display:flex;align-items:center;justify-content:center;cursor:pointer;padding:0} -+.bt-settings-close:hover{background:var(--bt-active);color:var(--bt-ink)} -+.bt-settings-form{display:flex;flex-direction:column;gap:16px} -+.bt-set-section h3{font-size:13px;line-height:18px;font-weight:600;margin:0 0 8px;padding:0 4px} -+.bt-set-card{border:1px solid var(--bt-line);border-radius:12px;background:var(--bt-card);display:flex;flex-direction:column} -+.bt-set-card>*+*{border-top:.5px solid var(--bt-line)} -+.bt-set-row{display:flex;align-items:center;gap:12px;padding:10px 12px;border:0;background:none;color:var(--bt-ink);font:inherit;font-size:13px;line-height:18px;text-align:left;width:100%;box-sizing:border-box;border-radius:0} -+button.bt-set-row{cursor:pointer} -+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} + /* 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} +@@ -2288,7 +2288,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} -+.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} -+.bt-set-bot{flex:1;display:flex;align-items:center;gap:8px;min-width:0} -+.bt-set-paste{flex-direction:column;align-items:stretch;gap:8px} -+.bt-chevron{color:var(--bt-ink-3);display:inline-flex;flex:none} -+ -+/* Settings → Connectors. */ -+.bt-connector{display:flex;flex-direction:column;gap:10px;padding:12px} -+.bt-connector-head{display:flex;align-items:center;gap:12px;min-width:0} -+.bt-connector-mark{flex:none;width:36px;height:36px;border-radius:10px;background:var(--bt-card);border:.5px solid var(--bt-line);display:flex;align-items:center;justify-content:center;color:var(--bt-ink)} -+.bt-connector-text{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px} -+.bt-connector-name{font-size:14px;line-height:20px;font-weight:500} -+.bt-connector-status{font-size:12px;line-height:16px;color:var(--bt-ink-3);overflow:hidden;text-overflow:ellipsis;white-space:nowrap} -+.bt-connector[data-state=ready] .bt-connector-status{color:var(--bt-ink-2)} -+.bt-connector[data-state=error] .bt-connector-status{color:#e02135;white-space:normal} -+.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} -+.bt-connector-form-row{display:flex;align-items:center;gap:6px} + .bt-set-copy{flex:1;display:flex;flex-direction:column;gap:2px;min-width:0} ++/* #1335: 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} +@@ -2308,8 +2308,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} + .bt-connector-form-row{display:flex;align-items:center;gap:6px} +.bt-connector-apps{display:flex;flex-direction:column;gap:8px} -+.bt-connector-link{flex:1;font-size:12px;color:var(--bt-ink-2)} + .bt-connector-link{flex:1;font-size:12px;color:var(--bt-ink-2)} +.bt-connector-uses{padding-left:48px;font-size:12px;line-height:16px;color:var(--bt-ink-3)} +.bt-connector-bots{display:flex;flex-direction:column;gap:2px;padding-left:48px;animation:bt-q-in ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing},bt-veil-in .2s ease-out} +.bt-connector-others{padding-left:20px} -+.bt-connector-error{padding-left:48px;font-size:12px;line-height:16px;color:#e02135} + .bt-connector-error{padding-left:48px;font-size:12px;line-height:16px;color:#e02135} +.bt-connector-app-list{list-style:none;margin:0;padding:0 0 0 48px;display:flex;flex-direction:column;gap:2px} +.bt-connector-app{display:flex;gap:8px;font-size:12px;line-height:16px;color:var(--bt-ink-2)} +.bt-connector-app-name{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.bt-app-status{color:var(--bt-ink-3)} +[data-status=sign-in]>.bt-app-status{color:#c46a00} -+ -+/* Shell chrome reshaped for the team view. Selectors use stable data attributes where the -+ shell offers them and CSS-module name suffixes elsewhere. */ -+[class*="_logoRow"],[class*="_newSession"],nav[class*="_panelList"]{display:none!important} -+[class*="_footArea"]{display:flex!important;flex-direction:row-reverse;align-items:center;gap:9px;padding:8px 6px 6px!important} -+[class*="_footArea"]>[class*="_footerActions"]{flex:1 1 auto;min-width:0} -+[class*="_footArea"]>[class*="_settingsArea"]{flex:none;width:auto!important} -+[class*="_footArea"] button[aria-label="Settings"]{width:auto!important;padding:0!important;background:none!important;border-radius:50%!important;height:auto!important} -+[class*="_widthHandle"]{display:none!important} -+ -+.bt-voice-pane{flex-direction:column;align-items:center;justify-content:center;animation:bt-fade .2s ease-out} -+.bt-voice-stage{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:10px;padding:24px;max-width:560px;text-align:center} -+.bt-voice-orb{position:relative;width:200px;height:200px;display:grid;place-items:center;margin-bottom:8px} -+.bt-voice-ring{position:absolute;inset:34px;border-radius:50%;background:var(--bt-hover);transform:scale(calc(1 + var(--bt-level,0) * .55));transition:transform .08s linear} -+.bt-voice-ring-2{inset:14px;background:none;box-shadow:inset 0 0 0 1px var(--bt-line-2);transform:scale(calc(.94 + var(--bt-level,0) * .3));opacity:.8} -+.bt-voice-stage[data-phase=thinking] .bt-voice-ring{animation:bt-voice-breathe 1.6s ease-in-out infinite} -+.bt-voice-stage[data-phase=speaking] .bt-voice-ring{animation:bt-voice-speak .9s ease-in-out infinite} -+@keyframes bt-voice-breathe{0%,100%{transform:scale(1)}50%{transform:scale(1.08)}} -+@keyframes bt-voice-speak{0%,100%{transform:scale(1.04)}30%{transform:scale(1.2)}60%{transform:scale(1.1)}} -+.bt-voice-orb>.bt-mark{position:relative} -+.bt-voice-name{font-size:16px;line-height:22px;font-weight:500} -+.bt-voice-status{font-size:13px;line-height:18px;color:var(--bt-ink-2);min-height:18px} -+.bt-voice-caption{min-height:44px;font-size:15px;line-height:22px;color:var(--bt-ink);animation:bt-blur-in .25s ease-out;display:-webkit-box;-webkit-line-clamp:4;-webkit-box-orient:vertical;overflow:hidden} -+.bt-voice-note{font-size:12px;color:#e02135} -+.bt-voice-setup{height:32px;padding:0 14px;border-radius:16px;border:0;background:var(--bt-hover);color:var(--bt-ink);font:inherit;font-size:13px;cursor:pointer;transition:background-color .15s ease} -+.bt-voice-setup:hover{background:var(--bt-active)} -+.bt-voice-bar{display:flex;gap:16px;padding:0 0 32px} -+.bt-voice-btn{position:relative;width:52px;height:52px;border-radius:50%;border:0;background:var(--bt-hover);color:var(--bt-ink);display:grid;place-items:center;cursor:pointer;transition:background-color .15s ease,transform .12s ease} -+.bt-voice-btn:hover{background:var(--bt-active)} -+.bt-voice-btn:active{transform:scale(.94)} -+.bt-voice-btn[aria-pressed=true]{background:var(--bt-user);color:var(--bt-user-ink)} -+.bt-voice-slash{position:absolute;width:24px;height:2px;border-radius:1px;background:currentColor;transform:rotate(-45deg)} -+.bt-voice-end{background:#ff3e51;color:#fff} -+.bt-voice-end:hover{background:#e02135} -+@keyframes bt-fade{from{opacity:0}to{opacity:1}} -+@keyframes bt-blur-in{from{opacity:0;filter:blur(2px)}to{opacity:1;filter:blur(0)}} -+@media (prefers-reduced-motion:reduce){.bt-voice-pane,.bt-voice-caption,.bt-voice-ring{animation:none!important}} -+.bt-msg{display:flex;flex-direction:column;align-items:flex-start;max-width:min(80%,560px,calc(100% - 82px))} -+/* The bubble's row: reactions hang below it, so the hover toolbar (and a group -+ sender's avatar) line up with the bubble alone. */ -+.bt-msg-line{position:relative;display:flex;align-items:flex-end;gap:8px;max-width:100%} -+.bt-msg-line>.bt-bubble{max-width:none;min-width:0} -+/* The same bubble width as beside an avatar column: 80% of (100% - 30px), plus the 30px. */ -+.bt-astack[data-lead]>.bt-msg{max-width:min(calc(80% + 6px),590px,calc(100% - 52px))} -+.bt-tools{position:absolute;top:50%;left:100%;margin-left:6px;transform:translateY(-50%);display:flex;gap:2px;opacity:0;visibility:hidden;transition:opacity .12s ease,visibility .12s} -+.bt-tools::after{content:"";position:absolute;top:-8px;bottom:-8px;right:100%;width:128px;z-index:-1} -+.bt-msg:hover>.bt-msg-line>.bt-tools,.bt-msg:focus-within>.bt-msg-line>.bt-tools,.bt-tools[data-open]{opacity:1;visibility:visible} -+.bt-tool{width:24px;height:24px;border:0;padding:0;border-radius:6px;background:none;color:var(--bt-ink-2);display:inline-flex;align-items:center;justify-content:center;cursor:pointer;transition:background-color .12s ease,color .12s ease} -+.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 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} -+.bt-react-strip button:nth-child(4){animation-delay:60ms} -+.bt-react-strip button:nth-child(n+5){animation-delay:80ms} -+.bt-react-strip button:hover{background:var(--bt-hover);transform:scale(1.15)} -+.bt-react-strip button:active{transform:scale(.92)} -+.bt-reacts{display:flex;gap:4px;margin-top:4px} -+.bt-react{height:22px;min-width:30px;padding:0 6px;border-radius:999px;border:.5px solid var(--bt-line-2);background:var(--bt-main);font-size:13px;line-height:20px;cursor:pointer;animation:bt-react-pop ${SPRING_POP.duration}ms ${SPRING_POP.easing};transition:background-color .12s ease,${spring(SPRING_TAP, "scale")}} -+.bt-react:hover{background:var(--bt-hover)} -+.bt-react:active{scale:.92} -+@keyframes bt-react-pop{from{transform:scale(.6);opacity:0}} -+.bt-bubble a[href^="${MENTION_ORIGIN}"] svg{display:none} -+.bt-you{width:36px;height:36px;border-radius:50%;display:inline-flex;align-items:center;justify-content:center;background:color-mix(in srgb,var(--bt-ink) 5%,var(--bt-sidebar));color:var(--bt-ink-2);box-shadow:inset 0 0 0 1px var(--bt-line);transition:background-color .12s ease} -+button:hover .bt-you{background:color-mix(in srgb,var(--bt-ink) 9%,var(--bt-sidebar))} -+.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 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} -+.bt-menu .bt-danger{color:#c21d2e} -+.bt-menu-sep{height:.5px;flex:none;background:var(--bt-line-2);margin:4px 8px} -+.bt-menu-up{--bt-origin:bottom left;animation-name:bt-pop-lift,bt-veil-in} -+.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-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} -+.bt-palette-layer[data-leaving] .bt-backdrop{opacity:0;transition:opacity .16s ease-in} -+.bt-palette-layer[data-leaving] .bt-palette{animation:bt-dialog-sink .16s cubic-bezier(.4,0,1,1) forwards} -+@keyframes bt-palette-in{from{translate:0 -10px;scale:.96}} -+.bt-palette-head{display:flex;align-items:center;gap:4px;padding:14px 10px 14px 14px;border-bottom:.5px solid var(--bt-line-2)} -+.bt-palette-glyph{display:inline-flex;width:18px;height:18px;align-items:center;justify-content:center;color:var(--bt-ink-3);margin-right:4px} -+.bt-palette-head input{flex:1;min-width:0;border:0;outline:none;background:none;color:var(--bt-ink);font:inherit;font-size:15px;line-height:22px;padding:0} -+.bt-palette-head input::placeholder{color:var(--bt-ink-3)} -+.bt-palette-clear{width:18px;height:18px;border:0;padding:0;border-radius:6px;background:none;color:var(--bt-ink-3);display:inline-flex;align-items:center;justify-content:center;cursor:pointer} -+.bt-palette-clear:hover{color:var(--bt-ink)} -+.bt-palette-clear svg{width:12px;height:12px} -+.bt-palette-list{position:relative;height:402px;overflow-y:auto;padding:8px;display:flex;flex-direction:column;gap:2px;box-sizing:border-box} -+.bt-palette-list>.bt-thumb,.bt-drop-list>.bt-thumb{left:0;right:0;border-radius:8px;background:var(--bt-active)} -+.bt-palette-list>.bt-thumb{left:8px;right:8px} -+:is(.bt-palette-list,.bt-drop-list)[data-glide] [aria-selected=true]{background:transparent} -+:is(.bt-palette-row,.bt-palette-section,.bt-drop-list>.bt-option){position:relative;z-index:1} -+.bt-palette-section{padding:8px 8px 4px;font-size:12px;line-height:16px;color:var(--bt-ink-3);flex:none} -+.bt-palette-section:first-of-type{padding-top:0} -+.bt-palette-row{flex:none;display:flex;align-items:center;gap:8px;height:49px;padding:6px 10px 6px 8px;border:0;border-radius:8px;background:none;color:inherit;font:inherit;text-align:left;cursor:pointer;box-sizing:border-box;width:100%} -+.bt-palette-row[aria-selected=true]{background:var(--bt-active)} -+.bt-palette-lead{flex:none;width:24px;height:24px;display:inline-flex;align-items:center;justify-content:center} -+.bt-palette-cmd{width:24px;height:24px;border-radius:50%;background:var(--bt-hover);display:inline-flex;align-items:center;justify-content:center;color:var(--bt-ink-2)} -+.bt-palette-cmd svg{width:14px;height:14px} -+.bt-palette-agent{width:24px;height:24px;border-radius:50%;background:var(--bt-hover);display:inline-flex;align-items:center;justify-content:center;color:var(--bt-ink)} -+.bt-palette-text{flex:1;min-width:0;display:flex;flex-direction:column;gap:1px} -+.bt-palette-title{font-size:14px;line-height:20px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} -+.bt-palette-sub{font-size:12px;line-height:16px;color:var(--bt-ink-3);overflow:hidden;text-overflow:ellipsis;white-space:nowrap} -+.bt-palette-badge{flex:none;font-size:11px;line-height:16px;padding:0 6px;border-radius:4px;background:var(--bt-tag-bg);color:var(--bt-tag-ink)} -+.bt-palette-empty{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:8px;padding:0 16px;text-align:center;animation:bt-veil-in .22s ease-out} -+.bt-palette-empty-icon{color:var(--bt-line-2)} -+.bt-palette-empty-icon svg{width:32px;height:32px} -+.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)} -+@media (prefers-reduced-motion:reduce){.bt-palette{animation:bt-fade .1s ease-out}} + + /* Shell chrome reshaped for the team view. Selectors use stable data attributes where the + shell offers them and CSS-module name suffixes elsewhere. */ +@@ -2427,7 +2427,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)} + @media (prefers-reduced-motion:reduce){.bt-palette{animation:bt-fade .1s ease-out}} +-.bt-account-menu{width:224px} +.bt-account-menu{min-width:224px;width:max-content;max-width:calc(100vw - 16px)} +.bt-flybot-account-header:not(:empty){padding:6px 0 2px;margin-bottom:4px;border-bottom:.5px solid var(--bt-line-2)} -+.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 -+ already has. */ -+@keyframes bt-pop-drop{from{translate:0 -4px;scale:.96}} -+@keyframes bt-pop-lift{from{translate:0 4px;scale:.96}} -+@keyframes bt-pop-out{to{opacity:0;scale:.97;filter:blur(2px)}} -+@keyframes bt-veil-in{from{opacity:0;filter:blur(6px)}} -+@keyframes bt-check-pop{from{opacity:0;scale:.4}} -+@media (prefers-reduced-motion:reduce){.bt-menu,.bt-avedit,.bt-react-strip{animation:bt-fade .1s ease-out}.bt-react-strip button,.bt-q-check,.bt-color[aria-pressed=true] span{animation:none}} -+.bt-brand:hover{background:var(--bt-hover)} -+.bt-drop-list{position:relative;display:flex;flex-direction:column;gap:2px} -+.bt-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:4px} -+.bt-pick{display:flex;flex-direction:column;align-items:center;gap:4px;padding:7px 2px 6px;border-radius:10px;border:1px solid transparent;background:none;color:var(--bt-ink);cursor:pointer;font:inherit;font-size:11px;line-height:14px;min-width:0} -+.bt-pick:hover{background:var(--bt-hover)} -+.bt-pick[aria-pressed=true]{border-color:var(--bt-line-2);background:var(--bt-hover)} -+.bt-pick>span:last-child:not(.bt-mark){max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} -+.bt-pick:hover .bt-mark[data-state=idle] .bt-eye{animation:bt-blink .5s linear 1} -+.bt-select{width:100%;height:34px;box-sizing:border-box;border-radius:8px;border:1px solid var(--bt-line);background:var(--bt-card);color:var(--bt-ink);padding:0 8px;font:inherit;font-size:13px} -+.bt-note{font-size:12px;line-height:16px;color:var(--bt-ink-2);margin-top:6px} -+.bt-swatch-sep{width:1px;height:22px;background:var(--bt-line-2);margin:0 2px} -+.bt-swatch-custom{position:relative;overflow:hidden;background:conic-gradient(#ff3e51,#ffaf38,#00c972,#1cc3b0,#2a92fe,#a97efe,#ff5eb1,#ff3e51);box-sizing:border-box} -+.bt-swatch-custom[data-on=true]{border-color:var(--bt-ink)} -+.bt-swatch-custom input{position:absolute;inset:-4px;width:calc(100% + 8px);height:calc(100% + 8px);opacity:0;cursor:pointer;border:0;padding:0} -+.bt-swatch-default{background:linear-gradient(135deg,var(--bt-card) 0 46%,var(--bt-line-2) 46% 54%,var(--bt-card) 54%);box-shadow:inset 0 0 0 1px var(--bt-line-2)} - .bt-states{display:flex;flex-wrap:wrap;gap:4px} - .bt-state{border:1px solid var(--bt-line);background:none;border-radius:999px;font:inherit;font-size:11px;line-height:16px;padding:2px 8px;color:var(--bt-ink-2);cursor:pointer} - .bt-state:hover{color:var(--bt-ink)} -@@ -2488,2292 +2488,5195 @@ body[data-ds-dark-theme] .bt-theme-chip i{background:var(--da)} - @keyframes bt-rise{from{opacity:0;transform:translateY(-3px)}to{opacity:1;transform:none}} - @media (prefers-reduced-motion:reduce){.bt-member,.bt-addlist{animation:none}} - --/* Memory dialog: a summary of every entry, the entries behind the menu, "Ask or update" below. */ --.bt-mem-row{display:flex;align-items:center;gap:8px;width:100%;height:36px;padding:0 8px 0 10px;border:.5px solid var(--bt-line-2);border-radius:10px;background:var(--bt-card);color:var(--bt-ink);font:inherit;font-size:13px;cursor:pointer;box-sizing:border-box;transition:background-color .12s ease} --.bt-mem-row:hover{background:var(--bt-hover)} --.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-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} --.bt-mem-head .bt-icon-btn{width:32px;height:32px;flex:none} --.bt-mem-head [aria-haspopup] svg{width:18px;height:18px;stroke-width:2.6} --.bt-mem-head>.bt-icon-btn:first-child{margin-left:-10px} --.bt-mem-head h2{margin:0;font-size:17px;line-height:24px;font-weight:600;white-space:nowrap} --.bt-mem-status{font-size:13px;line-height:18px;color:var(--bt-ink-3);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0;font-variant-numeric:tabular-nums} --.bt-mem-head-gap{flex:1} --.bt-mem-menu{position:absolute;top:52px;right:50px;min-width:220px;--bt-origin:top right} --.bt-mem-menu button:disabled{opacity:.4;cursor:default;background:none} --.bt-mem-body{flex:1;overflow-y:auto;padding:22px 28px 24px;scrollbar-width:thin;display:flex;flex-direction:column;gap:12px} --.bt-mem-summary{transition:opacity .3s ease} --.bt-mem-summary[data-stale]{opacity:.55} --.bt-mem-summary h3{margin:20px 0 6px;font-size:17px;line-height:26px;font-weight:600} --.bt-mem-summary h3:first-child{margin-top:0} --.bt-mem-summary p{margin:0;font-size:15px;line-height:26px} --.bt-mem-summary>*{animation:bt-mem-line .45s cubic-bezier(.16,1,.3,1) both} --.bt-mem-summary>:nth-child(2){animation-delay:.03s} --.bt-mem-summary>:nth-child(3){animation-delay:.06s} --.bt-mem-summary>:nth-child(4){animation-delay:.09s} --.bt-mem-summary>:nth-child(5){animation-delay:.12s} --.bt-mem-summary>:nth-child(6){animation-delay:.15s} --.bt-mem-summary>:nth-child(n+7){animation-delay:.18s} --@keyframes bt-mem-line{from{opacity:0;transform:translateY(4px)}} --.bt-mem-skel{display:flex;flex-direction:column;gap:24px} --.bt-mem-skel-block{display:flex;flex-direction:column;gap:10px} --.bt-mem-skel-block span{display:block;height:12px;border-radius:6px;background:linear-gradient(90deg,var(--bt-hover) 25%,var(--bt-active) 50%,var(--bt-hover) 75%);background-size:200% 100%;animation:bt-mem-skel 1.4s ease-in-out infinite} --.bt-mem-skel-block .bt-mem-skel-head{width:30%;height:16px;margin-bottom:2px} --@keyframes bt-mem-skel{from{background-position:150% 0}to{background-position:-50% 0}} --.bt-mem-skel-label{font-size:13px;line-height:18px;color:var(--bt-ink-3)} --.bt-mem-empty{margin:auto;max-width:400px;display:flex;flex-direction:column;align-items:center;gap:8px;text-align:center;padding:24px 0;animation:bt-mem-line .45s cubic-bezier(.16,1,.3,1) both} --.bt-mem-empty-mark{width:48px;height:48px;border-radius:50%;background:var(--bt-hover);display:flex;align-items:center;justify-content:center;color:var(--bt-ink-2);margin-bottom:4px} --.bt-mem-empty-mark svg{width:22px;height:22px} --.bt-mem-empty-title{font-size:15px;line-height:22px;font-weight:600} --.bt-mem-empty-text{font-size:13px;line-height:20px;color:var(--bt-ink-2)} --.bt-mem-failed{display:flex;align-items:center;gap:12px;padding:10px 12px;border-radius:12px;background:var(--bt-hover);font-size:13px;line-height:18px;color:var(--bt-ink-2)} --.bt-mem-failed>span{flex:1;min-width:0;overflow-wrap:anywhere} --.bt-mem-entries{display:flex;flex-direction:column;gap:28px;animation:bt-page-in ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing},bt-veil-in .22s ease-out} --.bt-mem-section-head{display:flex;align-items:baseline;gap:8px;margin-bottom:4px} --.bt-mem-section-head h3{margin:0;font-size:15px;line-height:22px;font-weight:600} --.bt-mem-section-head span{font-size:12px;line-height:16px;color:var(--bt-ink-3)} --.bt-mem-section-head .bt-mem-size{margin-left:auto;font-variant-numeric:tabular-nums;white-space:nowrap} --.bt-mem-topic{font-size:12px;line-height:16px;color:var(--bt-ink-2);margin:14px 0 2px 4px;font-weight:500} --.bt-mem-list{list-style:none;margin:0;padding:0;display:flex;flex-direction:column} --.bt-mem-entry{display:flex;align-items:center;gap:10px;padding:9px 4px;border-bottom:.5px solid var(--bt-line);transition:opacity .2s ease} --.bt-mem-entry[data-busy]{opacity:.4} --.bt-mem-entry-copy{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px} --.bt-mem-entry-text{font-size:14px;line-height:20px;overflow-wrap:anywhere} --.bt-mem-entry-meta{font-size:12px;line-height:16px;color:var(--bt-ink-3);font-variant-numeric:tabular-nums} --.bt-mem-remove{flex:none;height:28px;min-width:28px;border-radius:999px;border:0;background:none;color:var(--bt-ink-3);display:inline-flex;align-items:center;justify-content:center;cursor:pointer;padding:0 6px;font:inherit;font-size:12px;opacity:0;transition:opacity .12s ease,background-color .12s ease,color .12s ease} --.bt-mem-entry:hover .bt-mem-remove,.bt-mem-remove:focus-visible,.bt-mem-remove[data-confirm]{opacity:1} --.bt-mem-remove:hover{background:var(--bt-hover);color:var(--bt-ink)} --.bt-mem-remove[data-confirm]{background:rgba(224,33,53,.1);color:#e02135;padding:0 10px} --@media (hover:none){.bt-mem-remove{opacity:1}} --.bt-mem-none{font-size:13px;line-height:18px;color:var(--bt-ink-3);padding:8px 4px} --.bt-mem-foot{flex:none;padding:8px 20px 20px;display:flex;flex-direction:column;gap:10px} --.bt-mem-reply{border-radius:16px;background:var(--bt-hover);padding:8px 8px 12px 16px;display:flex;flex-direction:column;gap:6px;max-height:36vh;overflow-y:auto;scrollbar-width:thin;transform-origin:50% 100%;animation:bt-pop-lift ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing},bt-veil-in .2s ease-out} --.bt-mem-reply-head{display:flex;align-items:center;gap:8px;min-height:24px} --.bt-mem-reply-asked{flex:1;min-width:0;font-size:12px;line-height:16px;color:var(--bt-ink-3);overflow:hidden;text-overflow:ellipsis;white-space:nowrap} --.bt-mem-reply .bt-icon-btn{width:24px;height:24px;flex:none} --.bt-mem-reply-text{font-size:14px;line-height:21px;padding-right:8px;overflow-wrap:anywhere} --.bt-mem-changes{list-style:none;margin:2px 0 0;padding:0 8px 0 0;display:flex;flex-direction:column;gap:4px} --.bt-mem-changes li{display:flex;align-items:flex-start;gap:6px;font-size:13px;line-height:18px;color:var(--bt-ink-2);overflow-wrap:anywhere} --.bt-mem-changes li svg{flex:none;margin-top:1px;color:#e02135;animation:bt-check-pop ${SPRING_POP.duration}ms ${SPRING_POP.easing} .12s backwards} --.bt-mem-changes li[data-ok] svg{color:var(--bt-green)} --.bt-mem-ask{display:flex;align-items:center;gap:8px;height:52px;padding:0 8px 0 22px;border-radius:999px;border:.5px solid var(--bt-line-2);background:var(--bt-card);box-shadow:0 4px 16px rgba(0,0,0,.06);box-sizing:border-box;transition:border-color .15s ease,box-shadow .15s ease} --.bt-mem-ask:focus-within{border-color:var(--bt-ink-3);box-shadow:0 4px 20px rgba(0,0,0,.1)} --.bt-mem-input{flex:1;min-width:0;border:0;background:none;outline:none;color:var(--bt-ink);font:inherit;font-size:15px;line-height:22px;padding:0} --.bt-mem-input::placeholder{color:var(--bt-ink-3)} --.bt-mem-send{flex:none;width:36px;height:36px;border-radius:50%;border:0;background:var(--bt-ink);color:var(--bt-main);display:flex;align-items:center;justify-content:center;cursor:pointer;padding:0;transition:opacity .15s ease,${spring(SPRING_TAP, "scale")}} --.bt-mem-send:disabled{opacity:.22;cursor:default} --.bt-mem-send:not(:disabled):active{scale:.92} --.bt-mem-send>*{animation:bt-check-pop ${SPRING_POP.duration}ms ${SPRING_POP.easing}} --.bt-mem-spin{width:14px;height:14px;border-radius:50%;border:2px solid currentColor;border-right-color:transparent;box-sizing:border-box;animation:bt-mem-spin .7s linear infinite} --@keyframes bt-mem-spin{to{transform:rotate(360deg)}} --@media (max-width:640px){.bt-mem{width:100vw;height:100%;border-radius:0;border:0}.bt-mem-body{padding:18px 18px 20px}.bt-mem-foot{padding:8px 12px 12px}} --@media (prefers-reduced-motion:reduce){.bt-mem,.bt-mem-scrim,.bt-mem-summary>*,.bt-mem-empty,.bt-mem-reply,.bt-mem-skel-block span,.bt-mem-entries,.bt-mem-send>*,.bt-mem-changes li svg{animation:none}} -+/* Memory dialog: a summary of every entry, the entries behind the menu, "Ask or update" below. */ -+.bt-mem-row{display:flex;align-items:center;gap:8px;width:100%;height:36px;padding:0 8px 0 10px;border:.5px solid var(--bt-line-2);border-radius:10px;background:var(--bt-card);color:var(--bt-ink);font:inherit;font-size:13px;cursor:pointer;box-sizing:border-box;transition:background-color .12s ease} -+.bt-mem-row:hover{background:var(--bt-hover)} -+.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-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} -+.bt-mem-head .bt-icon-btn{width:32px;height:32px;flex:none} -+.bt-mem-head [aria-haspopup] svg{width:18px;height:18px;stroke-width:2.6} -+.bt-mem-head>.bt-icon-btn:first-child{margin-left:-10px} -+.bt-mem-head h2{margin:0;font-size:17px;line-height:24px;font-weight:600;white-space:nowrap} -+.bt-mem-status{font-size:13px;line-height:18px;color:var(--bt-ink-3);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0;font-variant-numeric:tabular-nums} -+.bt-mem-head-gap{flex:1} -+.bt-mem-menu{position:absolute;top:52px;right:50px;min-width:220px;--bt-origin:top right} -+.bt-mem-menu button:disabled{opacity:.4;cursor:default;background:none} -+.bt-mem-body{flex:1;overflow-y:auto;padding:22px 28px 24px;scrollbar-width:thin;display:flex;flex-direction:column;gap:12px} -+.bt-mem-summary{transition:opacity .3s ease} -+.bt-mem-summary[data-stale]{opacity:.55} -+.bt-mem-summary h3{margin:20px 0 6px;font-size:17px;line-height:26px;font-weight:600} -+.bt-mem-summary h3:first-child{margin-top:0} -+.bt-mem-summary p{margin:0;font-size:15px;line-height:26px} -+.bt-mem-summary>*{animation:bt-mem-line .45s cubic-bezier(.16,1,.3,1) both} -+.bt-mem-summary>:nth-child(2){animation-delay:.03s} -+.bt-mem-summary>:nth-child(3){animation-delay:.06s} -+.bt-mem-summary>:nth-child(4){animation-delay:.09s} -+.bt-mem-summary>:nth-child(5){animation-delay:.12s} -+.bt-mem-summary>:nth-child(6){animation-delay:.15s} -+.bt-mem-summary>:nth-child(n+7){animation-delay:.18s} -+@keyframes bt-mem-line{from{opacity:0;transform:translateY(4px)}} -+.bt-mem-skel{display:flex;flex-direction:column;gap:24px} -+.bt-mem-skel-block{display:flex;flex-direction:column;gap:10px} -+.bt-mem-skel-block span{display:block;height:12px;border-radius:6px;background:linear-gradient(90deg,var(--bt-hover) 25%,var(--bt-active) 50%,var(--bt-hover) 75%);background-size:200% 100%;animation:bt-mem-skel 1.4s ease-in-out infinite} -+.bt-mem-skel-block .bt-mem-skel-head{width:30%;height:16px;margin-bottom:2px} -+@keyframes bt-mem-skel{from{background-position:150% 0}to{background-position:-50% 0}} -+.bt-mem-skel-label{font-size:13px;line-height:18px;color:var(--bt-ink-3)} -+.bt-mem-empty{margin:auto;max-width:400px;display:flex;flex-direction:column;align-items:center;gap:8px;text-align:center;padding:24px 0;animation:bt-mem-line .45s cubic-bezier(.16,1,.3,1) both} -+.bt-mem-empty-mark{width:48px;height:48px;border-radius:50%;background:var(--bt-hover);display:flex;align-items:center;justify-content:center;color:var(--bt-ink-2);margin-bottom:4px} -+.bt-mem-empty-mark svg{width:22px;height:22px} -+.bt-mem-empty-title{font-size:15px;line-height:22px;font-weight:600} -+.bt-mem-empty-text{font-size:13px;line-height:20px;color:var(--bt-ink-2)} -+.bt-mem-failed{display:flex;align-items:center;gap:12px;padding:10px 12px;border-radius:12px;background:var(--bt-hover);font-size:13px;line-height:18px;color:var(--bt-ink-2)} -+.bt-mem-failed>span{flex:1;min-width:0;overflow-wrap:anywhere} -+.bt-mem-entries{display:flex;flex-direction:column;gap:28px;animation:bt-page-in ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing},bt-veil-in .22s ease-out} -+.bt-mem-section-head{display:flex;align-items:baseline;gap:8px;margin-bottom:4px} -+.bt-mem-section-head h3{margin:0;font-size:15px;line-height:22px;font-weight:600} -+.bt-mem-section-head span{font-size:12px;line-height:16px;color:var(--bt-ink-3)} -+.bt-mem-section-head .bt-mem-size{margin-left:auto;font-variant-numeric:tabular-nums;white-space:nowrap} -+.bt-mem-topic{font-size:12px;line-height:16px;color:var(--bt-ink-2);margin:14px 0 2px 4px;font-weight:500} -+.bt-mem-list{list-style:none;margin:0;padding:0;display:flex;flex-direction:column} -+.bt-mem-entry{display:flex;align-items:center;gap:10px;padding:9px 4px;border-bottom:.5px solid var(--bt-line);transition:opacity .2s ease} -+.bt-mem-entry[data-busy]{opacity:.4} -+.bt-mem-entry-copy{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px} -+.bt-mem-entry-text{font-size:14px;line-height:20px;overflow-wrap:anywhere} -+.bt-mem-entry-meta{font-size:12px;line-height:16px;color:var(--bt-ink-3);font-variant-numeric:tabular-nums} -+.bt-mem-remove{flex:none;height:28px;min-width:28px;border-radius:999px;border:0;background:none;color:var(--bt-ink-3);display:inline-flex;align-items:center;justify-content:center;cursor:pointer;padding:0 6px;font:inherit;font-size:12px;opacity:0;transition:opacity .12s ease,background-color .12s ease,color .12s ease} -+.bt-mem-entry:hover .bt-mem-remove,.bt-mem-remove:focus-visible,.bt-mem-remove[data-confirm]{opacity:1} -+.bt-mem-remove:hover{background:var(--bt-hover);color:var(--bt-ink)} -+.bt-mem-remove[data-confirm]{background:rgba(224,33,53,.1);color:#e02135;padding:0 10px} -+@media (hover:none){.bt-mem-remove{opacity:1}} -+.bt-mem-none{font-size:13px;line-height:18px;color:var(--bt-ink-3);padding:8px 4px} -+.bt-mem-foot{flex:none;padding:8px 20px 20px;display:flex;flex-direction:column;gap:10px} -+.bt-mem-reply{border-radius:16px;background:var(--bt-hover);padding:8px 8px 12px 16px;display:flex;flex-direction:column;gap:6px;max-height:36vh;overflow-y:auto;scrollbar-width:thin;transform-origin:50% 100%;animation:bt-pop-lift ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing},bt-veil-in .2s ease-out} -+.bt-mem-reply-head{display:flex;align-items:center;gap:8px;min-height:24px} -+.bt-mem-reply-asked{flex:1;min-width:0;font-size:12px;line-height:16px;color:var(--bt-ink-3);overflow:hidden;text-overflow:ellipsis;white-space:nowrap} -+.bt-mem-reply .bt-icon-btn{width:24px;height:24px;flex:none} -+.bt-mem-reply-text{font-size:14px;line-height:21px;padding-right:8px;overflow-wrap:anywhere} -+.bt-mem-changes{list-style:none;margin:2px 0 0;padding:0 8px 0 0;display:flex;flex-direction:column;gap:4px} -+.bt-mem-changes li{display:flex;align-items:flex-start;gap:6px;font-size:13px;line-height:18px;color:var(--bt-ink-2);overflow-wrap:anywhere} -+.bt-mem-changes li svg{flex:none;margin-top:1px;color:#e02135;animation:bt-check-pop ${SPRING_POP.duration}ms ${SPRING_POP.easing} .12s backwards} -+.bt-mem-changes li[data-ok] svg{color:var(--bt-green)} -+.bt-mem-ask{display:flex;align-items:center;gap:8px;height:52px;padding:0 8px 0 22px;border-radius:999px;border:.5px solid var(--bt-line-2);background:var(--bt-card);box-shadow:0 4px 16px rgba(0,0,0,.06);box-sizing:border-box;transition:border-color .15s ease,box-shadow .15s ease} -+.bt-mem-ask:focus-within{border-color:var(--bt-ink-3);box-shadow:0 4px 20px rgba(0,0,0,.1)} -+.bt-mem-input{flex:1;min-width:0;border:0;background:none;outline:none;color:var(--bt-ink);font:inherit;font-size:15px;line-height:22px;padding:0} -+.bt-mem-input::placeholder{color:var(--bt-ink-3)} -+.bt-mem-send{flex:none;width:36px;height:36px;border-radius:50%;border:0;background:var(--bt-ink);color:var(--bt-main);display:flex;align-items:center;justify-content:center;cursor:pointer;padding:0;transition:opacity .15s ease,${spring(SPRING_TAP, "scale")}} -+.bt-mem-send:disabled{opacity:.22;cursor:default} -+.bt-mem-send:not(:disabled):active{scale:.92} -+.bt-mem-send>*{animation:bt-check-pop ${SPRING_POP.duration}ms ${SPRING_POP.easing}} -+.bt-mem-spin{width:14px;height:14px;border-radius:50%;border:2px solid currentColor;border-right-color:transparent;box-sizing:border-box;animation:bt-mem-spin .7s linear infinite} -+@keyframes bt-mem-spin{to{transform:rotate(360deg)}} -+@media (max-width:640px){.bt-mem{width:100vw;height:100%;border-radius:0;border:0}.bt-mem-body{padding:18px 18px 20px}.bt-mem-foot{padding:8px 12px 12px}} -+@media (prefers-reduced-motion:reduce){.bt-mem,.bt-mem-scrim,.bt-mem-summary>*,.bt-mem-empty,.bt-mem-reply,.bt-mem-skel-block span,.bt-mem-entries,.bt-mem-send>*,.bt-mem-changes li svg{animation:none}} -+ -+.bt-thumb{position:absolute;left:0;top:0;z-index:0;pointer-events:none;transition:opacity .14s ease} -+.bt-thumb[data-hidden]{opacity:0} -+[data-morph]{overflow:hidden!important} -+.bt-veil{animation:bt-veil-in .22s ease-out} -+.bt-with-icon{display:inline-flex;align-items:center;gap:4px} -+.bt-with-icon svg{width:14px;height:14px} -+.bt-tag-admin{display:inline-flex;align-items:center;gap:4px} -+.bt-create-inline{padding:18px 16px;border:1px solid var(--bt-line);border-radius:12px;background:var(--bt-card)} -+.bt-create-inline .bt-create-card{max-width:none} -+.bt-panel-view>[data-move=in]{animation:bt-page-in ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing} backwards,bt-veil-in .2s ease-out backwards} -+.bt-panel-view>[data-move=out]{animation:bt-page-out ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing} backwards,bt-veil-in .2s ease-out backwards} -+.bt-panel-view>:is([data-move=in],[data-move=out]):nth-child(2){animation-delay:25ms} -+.bt-panel-view>:is([data-move=in],[data-move=out]):nth-child(n+3){animation-delay:50ms} -+.bt-panel-view>.bt-drawer-body[data-move=tab]{animation:bt-veil-in .2s ease-out} -+.bt-drawer-name-input{animation:bt-veil-in .16s ease-out} -+.bt-pop-in{display:inline-flex;animation:bt-check-pop ${SPRING_POP.duration}ms ${SPRING_POP.easing}} -+:is(.bt-soft,.bt-send,.bt-icon-btn,.bt-settings-close,.bt-mini,.bt-tab,.bt-nc-send,.bt-avedit-tab,.bt-shape,.bt-pick,.bt-swatch,.bt-palette-clear){transition:background-color .12s ease,color .12s ease,border-color .12s ease,opacity .12s ease,${spring(SPRING_TAP, "scale")}} -+:is(.bt-icon-btn,.bt-settings-close,.bt-nc-send,.bt-shape,.bt-swatch,.bt-color,.bt-x,.bt-q-x,.bt-palette-clear):active:not(:disabled){scale:.9} -+:is(.bt-soft,.bt-send,.bt-mini,.bt-tab,.bt-avedit-tab,.bt-pick,.bt-q-submit,.bt-q-btn):active:not(:disabled){scale:.96} -+@media (prefers-reduced-motion:reduce){.bt-thumb{transition:none}.bt-panel-view>[data-move],.bt-drawer-name-input,.bt-veil,.bt-pop-in,.bt-token,.bt-drop,.bt-newchat,.bt-option-hint,.bt-connector-form,.bt-settings-head>*{animation:none}} -+`; -+var 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} -+.bt-think summary::-webkit-details-marker{display:none} + .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 +@@ -2599,6 +2599,14 @@ var 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} + .bt-think summary::-webkit-details-marker{display:none} +.bt-think-caret{display:inline-block;transition:transform .15s;font-size:14px;line-height:1} +.bt-think[open] .bt-think-caret{transform:rotate(90deg)} +.bt-person-mark{display:inline-flex;align-items:center;justify-content:center;border-radius:50%;background:var(--bt-ink-5,rgba(20,20,20,.08));color:var(--bt-ink-3,rgba(20,20,20,.45))} @@ -4660,28 +3549,27 @@ diff --git a/client.js b/client.js +/* A person's typed line keeps its line breaks, as on the sender's side (#455): Markdown would join them. */ +.bt-person-msg .bt-bubble :is(p,li){white-space:pre-line} +.bt-remote-handle{display:inline-flex;align-items:center;gap:3px;margin-left:6px;font-weight:400;color:var(--bt-ink-3,rgba(20,20,20,.36))} -+.bt-think-text{white-space:pre-wrap;margin-top:6px;padding-left:10px;border-left:2px solid var(--bt-line-2)} -+.bt-event{display:flex;justify-content:center;align-items:center;gap:2px;min-height:24px;margin:2px 0;font-size:12px;line-height:16px;color:var(--bt-ink-2);flex-wrap:wrap} -+.bt-event>span{padding:0 2px} -+.bt-event button{border:0;background:none;color:inherit;font:inherit;display:inline-flex;align-items:center;gap:4px;cursor:pointer;height:24px;padding:4px 6px 4px 4px;border-radius:999px;box-sizing:border-box;transition:background-color .12s ease} -+.bt-event button:hover{background:var(--bt-hover)} -+.bt-event button:active{background:var(--bt-active)} -+.bt-event button.bt-event-whole{min-width:0;max-width:100%;padding:4px 6px 4px 8px} -+.bt-event-whole>*{flex:none} -+.bt-event-whole>span:first-child{margin-right:2px} -+/* One button cannot wrap like separate parts, so a long name is cut to fit the chat. */ -+.bt-event-whole>.bt-event-name{flex:0 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} -+.bt-time-sep{display:flex;justify-content:center;align-items:center;height:28px;margin:14px 0 8px;font-size:12px;line-height:16px;color:var(--bt-ink-2);white-space:nowrap} -+[class*="_scroll"]>[class*="_column"]::before{content:var(--bt-first-sep,none);display:block;flex:none;height:28px;margin:17px 0 12px;font-size:12px;line-height:28px;text-align:center;color:var(--bt-ink-2);white-space:nowrap} -+.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 -+ mode folds away in a finished turn stays shown. TurnProcessCell also opens that -+ fold; these rules cover the first frame before it does. */ -+[data-step-process]>div:first-child{display:none!important} -+[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} + .bt-think-text{white-space:pre-wrap;margin-top:6px;padding-left:10px;border-left:2px solid var(--bt-line-2)} + .bt-event{display:flex;justify-content:center;align-items:center;gap:2px;min-height:24px;margin:2px 0;font-size:12px;line-height:16px;color:var(--bt-ink-2);flex-wrap:wrap} + .bt-event>span{padding:0 2px} +@@ -2612,7 +2612,6 @@ var CONVERSATION_CSS = ` + .bt-event-whole>.bt-event-name{flex:0 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} + .bt-time-sep{display:flex;justify-content:center;align-items:center;height:28px;margin:14px 0 8px;font-size:12px;line-height:16px;color:var(--bt-ink-2);white-space:nowrap} + [class*="_scroll"]>[class*="_column"]::before{content:var(--bt-first-sep,none);display:block;flex:none;height:28px;margin:17px 0 12px;font-size:12px;line-height:28px;text-align:center;color:var(--bt-ink-2);white-space:nowrap} +-.bt-activity{display:flex;align-items:center;gap:10px;margin:8px 0 4px 2px;font-size:14px;color:var(--bt-ink-2)} + .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 +@@ -2622,21 +2622,35 @@ var 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} +-[class*="_header"]:has(.bt-head){border-bottom-color:transparent!important;box-shadow:none!important} +-/* The transcript runs under a floating header pill. */ +-header[class*="_header"]:has(.bt-head){position:absolute!important;top:0;left:0;right:0;z-index:8;display:flex!important;padding:10px 0 0!important;background:transparent!important;pointer-events:none;justify-content:center} +-header[class*="_header"]:has(.bt-head)::before{content:"";position:absolute;inset:0 0 auto;height:60px;background:linear-gradient(to bottom,var(--bt-main),color-mix(in srgb,var(--bt-main) 0%,transparent));pointer-events:none;z-index:-1} +-header[class*="_header"]:has(.bt-head)>[class*="_headerLeading"]{position:absolute;left:12px;top:10px;pointer-events:auto} +-header[class*="_header"] .bt-head{flex:1 1 auto;width:100%} +/* FlyBot's pill rides the shell's own Session header utilities, beside the seats + the native header renders (actions: the subagent catalog; lineage; corner). + It is a compact control in that row now, not a floating replacement for the @@ -4694,247 +3582,120 @@ 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} -+[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. */ ++/* #1335: "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"]){display:none!important} +-[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} -+/* 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. */ -+[data-content-phase=hero] [class*="_scrollBody"]{justify-content:flex-end!important} + /* 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. */ + [data-content-phase=hero] [class*="_scrollBody"]{justify-content:flex-end!important} +body[data-bt-remote-chat=empty][data-content-phase=hero] [class*="_scrollBody"]::before{content:var(--bt-remote-empty);display:block;text-align:center;color:var(--bt-ink-3);font-size:13px;line-height:18px;padding:16px 12px 8px;max-width:var(--dsh-chat-content-width,min(960px,100%));margin:0 auto;white-space:pre-wrap} -+[data-content-phase=hero] [class*="_composerHero"]{align-self:stretch!important;width:auto!important;padding-bottom:0!important} -+[data-content-phase=hero] [class*="_composerHero"]>[class$="_root"],[data-content-phase=hero] [class*="_heroWorkspaceRow"]{display:none!important} -+[data-content-phase=hero] [class*="_composerHero"] [class*="_input"]{min-height:0!important} -+/* The right sides give back the scrollbar gutter so both edges sit 16px from the column. */ -+[class*="_scroll"]:has(> [class*="_column"]){padding:60px 11px 16px 16px!important} -+/* The shell's running status only hosts the activity row (.bt-typing). */ -+[data-chat-running]{display:block!important;width:100%;min-height:0!important;margin:0!important;padding:0!important} -+[data-chat-running]>span:not([role=status]){display:none!important} -+[data-chat-running]:has(> .bt-typing){margin-top:var(--dsh-chat-flow-gap,12px)!important} -+.bt-typing{display:flex;align-items:center;gap:12px;height:36px;min-width:0;padding-inline-start:8px;transform-origin:0 50%;animation:bt-typing-in ${SPRING_TAP.duration}ms ${SPRING_TAP.easing} backwards} -+.bt-typing[data-exiting]{animation:bt-typing-out .14s cubic-bezier(.22,1,.36,1) forwards} + [data-content-phase=hero] [class*="_composerHero"]{align-self:stretch!important;width:auto!important;padding-bottom:0!important} + [data-content-phase=hero] [class*="_composerHero"]>[class$="_root"],[data-content-phase=hero] [class*="_heroWorkspaceRow"]{display:none!important} + [data-content-phase=hero] [class*="_composerHero"] [class*="_input"]{min-height:0!important} +@@ -2648,6 +2648,9 @@ header[class*="_header"] .bt-head{flex:1 1 auto;width:100%} + [data-chat-running]:has(> .bt-typing){margin-top:var(--dsh-chat-flow-gap,12px)!important} + .bt-typing{display:flex;align-items:center;gap:12px;height:36px;min-width:0;padding-inline-start:8px;transform-origin:0 50%;animation:bt-typing-in ${SPRING_TAP.duration}ms ${SPRING_TAP.easing} backwards} + .bt-typing[data-exiting]{animation:bt-typing-out .14s cubic-bezier(.22,1,.36,1) forwards} +.bt-typing[data-carry],.bt-typing[data-carry]>.bt-typing-text{animation:none} +/* Between a friend's chat turns the row sits in the last turn's tail (#443), at the transcript's foot. */ +[data-turn-tail]>.bt-typing{margin-top:var(--dsh-chat-flow-gap,12px)} -+.bt-typing>.bt-mark,.bt-typing>.bt-cluster{flex-shrink:0} -+.bt-typing>.bt-mark{cursor:pointer;transition:filter .16s ease-out} -+.bt-typing>.bt-mark:hover{filter:drop-shadow(0 2px 4px rgba(0,0,0,.18))} -+.bt-typing>.bt-mark .bt-eyes{transition:scale .28s cubic-bezier(.4,.06,.18,1)} -+.bt-typing>.bt-mark:hover .bt-eyes{scale:1.18} -+.bt-typing-text{display:flex;align-items:center;flex:1 1 auto;min-width:0;animation:bt-typing-label .22s ease-out .2s backwards} -+.bt-typing-label{display:inline-flex;align-items:center;gap:6px;min-width:0;animation:bt-typing-label .22s ease-out backwards} -+.bt-typing-label>.bt-shimmer{font-size:14px;line-height:22px;font-weight:400;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} -+.bt-typing-time{flex-shrink:0;font-size:12px;line-height:22px;color:var(--bt-ink-3);white-space:nowrap} -+@keyframes bt-typing-in{from{opacity:0;transform:scale(.9)}} -+@keyframes bt-typing-out{from{opacity:1;transform:scale(1)}to{opacity:0;transform:scale(.92);filter:blur(3px)}} -+@keyframes bt-typing-label{from{opacity:0;filter:blur(5px);transform:translateY(3px)}} -+/* Shimmer: a stepped sweep clipped to the text. */ -+.bt-shimmer{color:transparent;background:linear-gradient(90deg,var(--bt-shimmer-base) 0%,var(--bt-shimmer-base) 25%,var(--bt-ink) 60%,var(--bt-shimmer-base) 75%,var(--bt-shimmer-base) 100%);background-size:200% 100%;-webkit-background-clip:text;background-clip:text;animation:bt-shimmer 2.2s steps(60) infinite;--bt-shimmer-base:color-mix(in srgb,var(--bt-ink) 40%,transparent)} -+@keyframes bt-shimmer{from{background-position:200% 0}to{background-position:-200% 0}} -+/* New rows slide up; a reply that replaces the activity row grows out of its corner. */ -+[data-bt-enter="new"]{animation:bt-row-in ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing}} -+[data-bt-enter="after-collapse"]{transform-origin:0 100%;animation:bt-row-after .24s cubic-bezier(.23,1,.32,1) .38s backwards} -+@keyframes bt-row-in{from{transform:translateY(12px)}to{transform:none}} -+@keyframes bt-row-after{0%{opacity:0;transform:translateY(12px) scale(.94)}55%{opacity:1}100%{opacity:1;transform:none}} -+@media (prefers-reduced-motion:reduce){ -+.bt-typing,.bt-typing[data-exiting]{animation-name:bt-fade-in;animation-duration:.12s;transform:none} -+.bt-typing[data-exiting]{animation-name:bt-fade-out} -+.bt-typing-text,.bt-typing-label{animation-name:bt-fade-in;animation-delay:0s} -+.bt-shimmer{animation:none;background:none;color:var(--bt-ink-2)} -+[data-bt-enter="new"]{animation:none} -+[data-bt-enter="after-collapse"]{animation:bt-fade-in .12s ease-out} -+} -+@keyframes bt-fade-in{from{opacity:0}to{opacity:1}} -+@keyframes bt-fade-out{from{opacity:1}to{opacity:0}} -+[data-composer-card]{display:flex!important;flex-direction:row;flex-wrap:wrap;align-items:center;gap:0!important;min-height:44px;border-radius:22px!important;padding:7.5px!important;box-sizing:border-box;background:var(--bt-main)!important;border:.5px solid var(--bt-line-2)!important;box-shadow:0 2px 8px -1px rgba(0,0,0,.05),0 1px 2px rgba(0,0,0,.03),0 0 0 1px rgba(228,228,228,.04)!important;transition:border-color .15s ease,background-color .15s ease,${spring(SPRING_SHAPE, "border-radius")}!important} + .bt-typing>.bt-mark,.bt-typing>.bt-cluster{flex-shrink:0} + .bt-typing>.bt-mark{cursor:pointer;transition:filter .16s ease-out} + .bt-typing>.bt-mark:hover{filter:drop-shadow(0 2px 4px rgba(0,0,0,.18))} +@@ -2679,7 +2679,8 @@ header[class*="_header"] .bt-head{flex:1 1 auto;width:100%} + @keyframes bt-fade-in{from{opacity:0}to{opacity:1}} + @keyframes bt-fade-out{from{opacity:1}to{opacity:0}} + [data-composer-card]{display:flex!important;flex-direction:row;flex-wrap:wrap;align-items:center;gap:0!important;min-height:44px;border-radius:22px!important;padding:7.5px!important;box-sizing:border-box;background:var(--bt-main)!important;border:.5px solid var(--bt-line-2)!important;box-shadow:0 2px 8px -1px rgba(0,0,0,.05),0 1px 2px rgba(0,0,0,.03),0 0 0 1px rgba(228,228,228,.04)!important;transition:border-color .15s ease,background-color .15s ease,${spring(SPRING_SHAPE, "border-radius")}!important} +-[data-composer-card]:hover,[data-composer-card]:focus-within{border-color:color-mix(in srgb,var(--bt-ink) 30%,transparent)!important} +[data-composer-card]:hover{border-color:color-mix(in srgb,var(--bt-ink) 30%,transparent)!important} +[data-composer-card]:focus-within{border-color:color-mix(in srgb,var(--bt-ink) 55%,transparent)!important} -+[data-composer-card]:has(> [data-slot="conversation.input.attachments"] *),[data-composer-card]:has([data-composer-input] br + *),[data-composer-card]:has([data-composer-input] > :nth-child(2)){border-radius:18px!important} -+[class*="_root"]:has(> [data-composer-card]){padding:0 11px 13px 16px!important} -+[class*="_root"]:has(> [data-composer-card])>[data-composer-card]{max-width:min(992px,100%)!important} -+[data-composer-card]>[class*="_row"]{display:contents!important} -+[data-composer-card] [class*="_tools"]{order:1;flex:none;align-self:flex-end;margin:0} -+[data-composer-card]>[data-input-scroll]{order:2;flex:1 1 0;min-width:0;padding:0 8px!important;margin:0!important} -+[data-composer-card] [class*="_trailing"]{order:3;flex:none;align-self:flex-end;margin:0} -+[data-composer-card]>[data-slot="conversation.input.attachments"]{order:0;flex-basis:100%} -+[data-composer-card] [data-composer-input]{min-height:24px;padding:2px 0!important} -+[data-composer-card] [data-composer-placeholder]{left:0!important;top:2px!important} -+[data-composer-placeholder]{font-size:0!important;line-height:0!important} + [data-composer-card]:has(> [data-slot="conversation.input.attachments"] *),[data-composer-card]:has([data-composer-input] br + *),[data-composer-card]:has([data-composer-input] > :nth-child(2)){border-radius:18px!important} + [class*="_root"]:has(> [data-composer-card]){padding:0 11px 13px 16px!important} + [class*="_root"]:has(> [data-composer-card])>[data-composer-card]{max-width:min(992px,100%)!important} +@@ -2691,6 +2691,8 @@ header[class*="_header"] .bt-head{flex:1 1 auto;width:100%} + [data-composer-card] [data-composer-input]{min-height:24px;padding:2px 0!important} + [data-composer-card] [data-composer-placeholder]{left:0!important;top:2px!important} + [data-composer-placeholder]{font-size:0!important;line-height:0!important} +/* A friend's Bot chat: no local run stats (they are relay turns, always 0; #419). */ +body[data-bt-friend-chat] [data-composer-stats]{display:none!important} -+[data-composer-placeholder]::after{content:var(--bt-placeholder,"Message");display:block;font-size:14px;line-height:24px;color:color-mix(in srgb,var(--bt-ink) 36%,transparent)} -+/* The team's "+" (ComposerPlus) takes the seat of the shell's command-menu button. */ -+[data-composer-card] button[class*="_add"]{display:none!important} -+.bt-plus{flex:none;width:28px;height:28px;padding:0;border:0;border-radius:50%;display:inline-flex;align-items:center;justify-content:center;background:color-mix(in srgb,var(--bt-ink) 5%,transparent);box-shadow:inset 0 0 0 .5px var(--bt-line);color:var(--bt-ink-2);cursor:pointer;transition:background-color .15s ease,color .15s ease,${spring(SPRING_TAP, "scale")}} -+.bt-plus:hover,.bt-plus[aria-expanded=true]{background:color-mix(in srgb,var(--bt-ink) 10%,transparent);color:var(--bt-ink)} -+.bt-plus:active{scale:.9} -+.bt-plus svg{width:14px;height:14px;transition:${spring(SPRING_POP, "rotate")}} -+.bt-plus[aria-expanded=true] svg{rotate:45deg} -+.bt-plus-menu{min-width:180px} -+[data-composer-card] [class*="_tools"]{gap:0!important} -+[data-composer-card] [class*="_modes"]:empty{display:none!important} -+[data-composer-card] [class*="_activity"]:not(:has([data-slot] > *)){display:none!important} -+[data-composer-card] [class*="_trailing"]{gap:8px} -+[data-composer-card] [class*="_standardControls"]{display:flex;align-items:center;gap:8px} -+[data-composer-card] button[class*="_primary"]{width:28px!important;height:28px!important;background:var(--bt-accent)!important;color:var(--bt-accent-ink,#fff)!important;border-radius:50%!important;transition:opacity .15s ease,${spring(SPRING_TAP, "scale")};animation:bt-icon-swap ${SPRING_POP.duration}ms ${SPRING_POP.easing}} -+[data-composer-card] button[class*="_primary"]:hover:not(:disabled){opacity:.9} -+[data-composer-card] button[class*="_primary"]:active:not(:disabled){scale:.9} -+@keyframes bt-icon-swap{from{opacity:0;transform:scale(.5) rotate(-30deg)}} -+[data-composer-card] button[class*="_primary"] svg{width:14px;height:14px} -+[data-composer-card] button[class*="_primary"]:disabled{display:none!important} -+[data-composer-dock]{display:none!important} -+.bt-mic,.bt-voice{flex:none;width:28px;height:28px;border-radius:50%;border:0;padding:0;display:inline-grid;place-items:center;cursor:pointer;transition:background-color .15s ease,color .15s ease,${spring(SPRING_TAP, "scale")}} -+.bt-mic{background:color-mix(in srgb,var(--bt-ink) 5%,transparent);box-shadow:inset 0 0 0 .5px var(--bt-line);color:var(--bt-ink-2)} -+.bt-mic:hover{background:color-mix(in srgb,var(--bt-ink) 10%,transparent);color:var(--bt-ink)} -+.bt-mic[aria-pressed=true]{background:#ff3e51;color:#fff;animation:bt-mic-pulse 1.4s ease-in-out infinite} -+@keyframes bt-mic-pulse{0%,100%{box-shadow:0 0 0 0 rgba(255,62,81,.35)}50%{box-shadow:0 0 0 5px rgba(255,62,81,0)}} -+.bt-voice{display:none;background:var(--bt-accent);color:var(--bt-accent-ink,#fff);animation:bt-icon-swap ${SPRING_POP.duration}ms ${SPRING_POP.easing}} -+.bt-voice:hover{opacity:.9} -+.bt-mic:active,.bt-voice:active{scale:.9} -+[data-composer-card]:has(button[class*="_primary"]:disabled) .bt-voice{display:inline-grid} -+body[data-bt-room] .bt-voice{display:none!important} + [data-composer-placeholder]::after{content:var(--bt-placeholder,"Message");display:block;font-size:14px;line-height:24px;color:color-mix(in srgb,var(--bt-ink) 36%,transparent)} + /* The team's "+" (ComposerPlus) takes the seat of the shell's command-menu button. */ + [data-composer-card] button[class*="_add"]{display:none!important} +@@ -2722,6 +2722,7 @@ header[class*="_header"] .bt-head{flex:1 1 auto;width:100%} + .bt-mic:active,.bt-voice:active{scale:.9} + [data-composer-card]:has(button[class*="_primary"]:disabled) .bt-voice{display:inline-grid} + body[data-bt-room] .bt-voice{display:none!important} +body[data-bt-room] [data-composer-card] [class*="_activity"]{display:none!important} -+body[data-bt-room] [data-composer-card]:has(button[class*="_primary"]:disabled) .bt-mic:not([aria-pressed=true]){background:var(--bt-accent);color:var(--bt-accent-ink,#fff)} -+.bt-mic-note{position:absolute;right:12px;bottom:calc(100% + 8px);padding:5px 10px;border-radius:8px;background:var(--bt-user);color:var(--bt-user-ink);font-size:12px;line-height:16px;white-space:nowrap;pointer-events:none;animation:bt-rise .16s ease-out} -+[data-chain-overlay-fallback="conversation.composer"]:has(~ .bt-q){display:block!important;order:2} -+.bt-q{order:1} -+div:has(> nav [class*="_marks"]){display:none!important} -+/* Every user-side bubble (sent, steering mid-turn, not yet admitted, question replies) -+ takes its surface and its text from the one --bt-user pair: a skin that repaints -+ the host's bubble surface alone would otherwise leave the text unreadable. */ -+[class*="_userRow"] [class*="_bubble"],[data-chat-flow-kind=question-reply] [class*="_bubble"],[data-chat-flow-kind=command-input] [class*="_bubble"]{background:var(--bt-user)!important;color:var(--bt-user-ink)!important;border-radius:18px!important;padding:7px 12px!important;font-size:var(--dsh-content-font-size,14px)!important;line-height:calc(var(--dsh-content-font-size,14px) + 8px)!important} -+[class*="_userRow"] [class*="_bubble"] [class*="_markdown"],[class*="_userRow"] [class*="_bubble"] a{color:inherit} -+/* Sent messages get the hover toolbar below; steering and unsent bubbles keep none. */ -+[class*="_userRow"] [class*="_actions"]{display:none!important} -+[data-chat-flow-kind=user] [class*="_userRow"]{flex-direction:row-reverse;align-items:center;justify-content:flex-start} -+[data-chat-flow-kind=user] [class*="_actions"]{display:flex!important;height:24px!important;gap:2px!important;opacity:0!important;visibility:hidden;transition:opacity .12s ease,visibility .12s!important;flex:none} -+[data-chat-flow-kind=user] [class*="_userRow"]:hover [class*="_actions"],[data-chat-flow-kind=user] [class*="_userRow"]:focus-within [class*="_actions"]{opacity:1!important;visibility:visible} -+[data-chat-flow-kind=user] [class*="_actions"] [class*="_timeStart"]{display:none} -+[data-chat-flow-kind=user] [class*="_actions"] button{width:24px;height:24px;border-radius:6px;color:var(--bt-ink-2)} -+[data-chat-flow-kind=user] [class*="_actions"] button:hover{background:var(--bt-hover);color:var(--bt-ink)} -+[data-chat-flow-kind=user] [class*="_actions"] svg{width:15px;height:15px} -+.bt-q-inline{display:flex;margin:4px 0 6px} -+[data-chat-flow-kind=assistant-step]+[data-step-process]:has(.bt-q-inline){margin-top:0!important} -+[data-chat-flow-kind=question-reply]:has(.bt-q-inline),[data-chat-group-key]:has([data-chat-flow-kind=question-reply] .bt-q-inline),[data-chat-group-key]:has(.bt-q-gone){margin-top:0!important} -+.bt-q-inline .bt-q{margin:0;max-width:min(550px,86%);order:0} -+.bt-q-dock{width:100%;display:flex;justify-content:center} -+.bt-q-dock .bt-q{margin:0 auto 8px} -+[class*="_toBottomSlot"]{justify-content:center!important;padding-right:0!important} -+[data-conversation-scroll] [class*="_toBottomSlot"]{bottom:calc(var(--dsh-composer-height,152px) + 8px)!important} -+[class*="_toBottomSlot"]>button{width:36px!important;height:36px!important;margin-top:-36px!important;background:var(--bt-main)!important;color:var(--bt-ink)!important;box-shadow:0 0 0 .5px var(--bt-line-2),0 2px 8px -2px rgba(0,0,0,.12)!important;animation:bt-tobottom-in ${SPRING_TAP.duration}ms ${SPRING_TAP.easing},bt-veil-in .16s ease-out;transition:background-color .12s ease,${spring(SPRING_TAP, "scale")}} -+[class*="_toBottomSlot"]>button:active{scale:.9} -+[class*="_toBottomSlot"]>button:hover{background:color-mix(in srgb,var(--bt-ink) 4%,var(--bt-main))!important} -+[class*="_toBottomSlot"]>button svg{display:none} -+[class*="_toBottomSlot"]>button::before{content:"";width:20px;height:20px;background:currentColor;-webkit-mask:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='none' stroke='black' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M10 4v12M5 11l5 5 5-5'/%3E%3C/svg%3E") center/contain no-repeat;mask:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='none' stroke='black' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M10 4v12M5 11l5 5 5-5'/%3E%3C/svg%3E") center/contain no-repeat} -+@keyframes bt-tobottom-in{from{translate:0 8px;scale:.8}} -+[data-bt-news] [class*="_toBottomSlot"]>button{visibility:hidden!important;pointer-events:none!important} -+.bt-news{position:fixed;z-index:30;transform:translateX(-50%);display:inline-flex;align-items:center;overflow:hidden;border-radius:999px;background:#0c64c1;color:#fcfcfc;box-shadow:inset 0 0 0 1px rgba(20,20,20,.05),0 1px 3px 0 rgba(0,0,0,.12);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;animation:bt-news-in ${SPRING_TAP.duration}ms ${SPRING_TAP.easing},bt-veil-in .16s ease-out} -+@keyframes bt-news-in{from{translate:0 8px;scale:.9}} -+.bt-news[data-direction=up]{animation-name:bt-news-in-up,bt-veil-in} -+@keyframes bt-news-in-up{from{translate:0 -8px;scale:.9}} -+.bt-news-jump{display:inline-flex;align-items:center;gap:2px;margin:0;padding:4px 26px 4px 4px;border:0;border-radius:0;background:transparent;color:inherit;font:inherit;font-size:13px;line-height:18px;white-space:nowrap;cursor:pointer;outline:none} -+.bt-news-ico{flex:none;display:inline-flex;align-items:center;justify-content:center;width:20px;height:20px} -+.bt-news-ico svg{width:16px;height:16px} -+.bt-news-x{position:absolute;right:4px;top:50%;z-index:1;transform:translateY(-50%);width:20px;height:20px;padding:0;border:0;border-radius:999px;background:transparent;color:inherit;opacity:.75;display:inline-flex;align-items:center;justify-content:center;cursor:pointer} -+.bt-news-x svg{width:12px;height:12px} -+.bt-news-x:hover{opacity:1;background:rgba(20,20,20,.5)} -+@media (prefers-reduced-motion:reduce){.bt-news,[class*="_toBottomSlot"]>button{animation:none}} -+.bt-new-sep{display:flex;align-items:center;gap:8px;margin:14px 0 8px;padding:2px 0;font-size:11px;line-height:14px;font-weight:500;letter-spacing:.02em;text-transform:uppercase;color:#0c64c1;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif} -+.bt-new-sep::before,.bt-new-sep::after{content:"";flex:1;height:1px;background:color-mix(in srgb,#0c64c1 20%,transparent)} -+.bt-open-new{display:flex;flex-direction:column} -+.bt-open-new .bt-new-sep{margin:18px 0 0} -+.bt-open-new .bt-time-sep{height:16px;margin:32px 0 2px} -+.bt-earlier{display:flex;flex-direction:column;gap:6px} -+.bt-earlier-top{display:flex;justify-content:center;align-items:center;height:34px;flex:none} -+.bt-earlier-top .bt-soft{height:28px;font-size:12px;padding:0 12px} -+.bt-urow{display:flex;justify-content:flex-end} + body[data-bt-room] [data-composer-card]:has(button[class*="_primary"]:disabled) .bt-mic:not([aria-pressed=true]){background:var(--bt-accent);color:var(--bt-accent-ink,#fff)} + .bt-mic-note{position:absolute;right:12px;bottom:calc(100% + 8px);padding:5px 10px;border-radius:8px;background:var(--bt-user);color:var(--bt-user-ink);font-size:12px;line-height:16px;white-space:nowrap;pointer-events:none;animation:bt-rise .16s ease-out} + [data-chain-overlay-fallback="conversation.composer"]:has(~ .bt-q){display:block!important;order:2} +@@ -2776,6 +2776,7 @@ div:has(> nav [class*="_marks"]){display:none!important} + .bt-earlier-top{display:flex;justify-content:center;align-items:center;height:34px;flex:none} + .bt-earlier-top .bt-soft{height:28px;font-size:12px;padding:0 12px} + .bt-urow{display:flex;justify-content:flex-end} +.bt-urow>.bt-lead{align-self:flex-end;margin-left:6px} -+.bt-ububble{max-width:min(560px,78%);background:var(--bt-user);color:var(--bt-user-ink);border-radius:18px;padding:7px 12px;font-size:14px;line-height:20px;box-sizing:border-box;overflow-wrap:anywhere} -+.bt-ububble [class*="_markdown"],.bt-ububble a{color:inherit!important} -+.bt-ububble p{margin:0;line-height:22px} -+.bt-uimages{display:block;font-size:12px;opacity:.7} -+.bt-earlier .bt-event{margin:4px 0} -+.bt-part-line{display:flex;align-items:center;gap:10px;margin:10px 0 6px;font-size:11px;line-height:14px;color:var(--bt-ink-3)} -+.bt-part-line::before,.bt-part-line::after{content:"";flex:1;height:1px;background:var(--bt-line)} -+`; -+function installStyles(text, tag) { -+ const style = document.createElement("style"); -+ style.dataset.dshBot = tag; -+ style.textContent = text; -+ document.head.appendChild(style); -+ return style; -+} -+var FRAGMENT_SELECTOR = /\[class\*="(_[\w-]+)"\]/g; -+var CLASS_TOKEN = /\.((?:\\.|[\w-])+)/g; -+function sheetClassNames(sheet) { -+ const names = []; -+ const visit = (rules) => { -+ for (const rule of rules) { -+ if (rule.selectorText) for (const match of rule.selectorText.matchAll(CLASS_TOKEN)) names.push(match[1]); -+ if (rule.cssRules) visit(rule.cssRules); -+ } -+ }; -+ try { -+ visit(sheet.cssRules); -+ } catch { -+ } -+ return names; -+} -+function compileFragmentSelectors(text, names) { -+ const compiled = /* @__PURE__ */ new Map(); -+ return text.replace(FRAGMENT_SELECTOR, (whole, fragment) => { -+ if (!compiled.has(fragment)) { -+ const exact = []; -+ for (const name of names) if (name.includes(fragment)) exact.push(`.${name}`); -+ compiled.set(fragment, exact.length ? `:is(${exact.join(",")})` : whole); -+ } -+ return compiled.get(fragment); -+ }); -+} -+function installCompiledStyles(text, tag) { -+ const style = installStyles("", tag); -+ const cache = /* @__PURE__ */ new WeakMap(); -+ const render = () => { -+ const names = /* @__PURE__ */ new Set(); -+ for (const sheet of document.styleSheets) { -+ if (sheet.ownerNode?.dataset?.dshBot) continue; -+ let list = cache.get(sheet); -+ if (!list) { -+ list = sheetClassNames(sheet); -+ cache.set(sheet, list); -+ } -+ for (const name of list) names.add(name); -+ } -+ const next = compileFragmentSelectors(text, names); -+ if (style.textContent !== next) style.textContent = next; -+ }; -+ render(); -+ let timer; -+ const schedule = () => { -+ clearTimeout(timer); -+ timer = setTimeout(render, 120); -+ }; -+ const onLoad = (event) => { -+ if (event.target instanceof HTMLLinkElement) schedule(); -+ }; -+ const observer = new MutationObserver(schedule); -+ observer.observe(document.head, { childList: true }); -+ document.addEventListener("load", onLoad, true); -+ return () => { -+ observer.disconnect(); -+ document.removeEventListener("load", onLoad, true); -+ clearTimeout(timer); -+ style.remove(); -+ }; -+} -+ -+// src/shared/face-title.js -+var MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; -+function isGenericFaceTitle(name) { -+ const n = String(name ?? "").trim(); -+ return !n || n === "Face-to-face room"; + .bt-ububble{max-width:min(560px,78%);background:var(--bt-user);color:var(--bt-user-ink);border-radius:18px;padding:7px 12px;font-size:14px;line-height:20px;box-sizing:border-box;overflow-wrap:anywhere} + .bt-ububble [class*="_markdown"],.bt-ububble a{color:inherit!important} + .bt-ububble p{margin:0;line-height:22px} +@@ -2854,1926 +2854,4807 @@ function installCompiledStyles(text, tag) { + style.remove(); + }; + } +- +-// src/client/mark.js +-var import_react2 = require("react"); +-var STATES = ["idle", "thinking", "searching", "working", "sending", "orbit", "alert", "done"]; +-var botState = (running, activity, alert) => running ? STATES.includes(activity) ? activity : "working" : alert ? "alert" : "idle"; +-var toolState = (name) => /^(message_bot|stop_bot|create_bot|update_bot|create_group|update_group|delete_group|post_to_group)$/.test(name) ? "sending" : /search|fetch|browse|read|grep|glob|list|recall/i.test(name) ? "searching" : "working"; +-function useGaze(ref, enabled) { +- (0, import_react2.useEffect)(() => { +- if (!enabled) return void 0; +- let frame = 0; +- let point = null; +- const place = () => { +- frame = 0; +- const node = ref.current; +- if (!node || !point) return; +- const box = node.getBoundingClientRect(); +- const clamp = (value) => Math.max(-1, Math.min(1, value)); +- node.style.setProperty("--bt-gx", clamp((point.x - box.left - box.width / 2) / 160).toFixed(2)); +- node.style.setProperty("--bt-gy", clamp((point.y - box.top - box.height / 2) / 160).toFixed(2)); +- }; +- const onMove = (event) => { +- point = { x: event.clientX, y: event.clientY }; +- if (!frame) frame = requestAnimationFrame(place); +- }; +- const onLeave = () => { +- point = null; +- ref.current?.style.setProperty("--bt-gx", "0"); +- ref.current?.style.setProperty("--bt-gy", "0"); +- }; +- window.addEventListener("pointermove", onMove, { passive: true }); +- document.documentElement.addEventListener("pointerleave", onLeave); +- return () => { +- cancelAnimationFrame(frame); +- window.removeEventListener("pointermove", onMove); +- document.documentElement.removeEventListener("pointerleave", onLeave); +- }; +- }, [enabled]); ++ ++// src/shared/face-title.js ++var MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; ++function isGenericFaceTitle(name) { ++ const n = String(name ?? "").trim(); ++ return !n || n === "Face-to-face room"; +} +function formatFaceWhen(value) { + const date = value instanceof Date ? value : new Date(value); @@ -5369,7 +4130,32 @@ diff --git a/client.js b/client.js + ); + })) + ); -+} + } +-var FIDGETS = ["hop", "sway", "stretch", "look"]; +-function BotMark({ bot, look: given, size = 36, state = "idle", live = false, gaze = false, pokeable = false, label }) { +- const look = given ?? lookOf(bot); +- const ref = (0, import_react2.useRef)(null); +- const maskId = (0, import_react2.useRef)(""); +- if (maskId.current === "") maskId.current = `bt-mark-${nextMarkSerial()}`; +- const [poked, setPoked] = (0, import_react2.useState)(false); +- useGaze(ref, gaze); +- const common = { +- ref, +- className: poked ? "bt-mark bt-mark-poke" : "bt-mark", +- "data-shape": look.image ? "image" : shapeId(look.shape), +- "data-state": STATES.includes(state) ? state : "idle", +- "data-live": live ? "" : void 0, +- "data-fidget": live ? FIDGETS[fnv1a(`${bot?.id ?? ""}:fidget`) % FIDGETS.length] : void 0, +- "data-gaze": gaze ? "" : void 0, +- "data-poke": pokeable ? "" : void 0, +- style: { width: size, height: size, ...live ? { "--bt-delay": `${-(fnv1a(String(bot?.id ?? "")) % 5e3) / 1e3}s`, "--bt-fidget-delay": `${-(fnv1a(`${bot?.id ?? ""}:when`) % 9e3) / 1e3}s` } : {} }, +- role: label ? "img" : void 0, +- "aria-label": label, +- "aria-hidden": label ? void 0 : true, +- onClick: pokeable ? () => setPoked(true) : void 0, +- onAnimationEnd: poked ? (event) => { +- if (/^bt-poke/.test(event.animationName)) setPoked(false); +- } : void 0 +function QuestionCard({ matched }) { + const pending = matched; + const questions = pending.questions ?? []; @@ -5385,9 +4171,17 @@ diff --git a/client.js b/client.js + return; + } + pending.answer({ answers: next }); -+ }; + }; +- if (look.image) return (0, import_react2.createElement)("span", common, (0, import_react2.createElement)("img", { className: "bt-mark-img", src: look.image, alt: "", draggable: false })); +- const box = shapeOf(look.shape).size; +- return (0, import_react2.createElement)("span", common, (0, import_react2.createElement)("svg", { viewBox: `0 0 ${box} ${box}`, dangerouslySetInnerHTML: { __html: characterMarkup(look.shape, look.color).replaceAll(MASK_ID, maskId.current) } })); + return (0, import_react6.createElement)(QuestionView, { key: index, question, onAnswer, onDismiss: () => pending.dismiss(), ownAnswer: true, raised: true }); -+} + } +-function StarIcon({ size = 8 }) { +- return (0, import_react2.createElement)( +- "svg", +- { width: size, height: size, viewBox: "0 0 10 10", "aria-hidden": true }, +- (0, import_react2.createElement)("path", { d: "M5 .6l1.3 2.8 3 .3-2.3 2 .7 3L5 7.2 2.3 8.7l.7-3L.7 3.7l3-.3z", fill: "#fff" }) +function PendingQuestion({ sessionId, question, actions, inline }) { + const [answered, setAnswered] = (0, import_react6.useState)(null); + if (answered === question.id) return null; @@ -5403,15 +4197,23 @@ diff --git a/client.js b/client.js + "div", + { className: inline ? "bt-q-inline" : "bt-q-dock" }, + (0, import_react6.createElement)(QuestionView, { key: question.id, question, onAnswer, onDismiss, ownAnswer: question.allowCustom === true, raised: !inline }) -+ ); -+} + ); + } +-function AdminStar() { +- return (0, import_react2.createElement)("span", { className: "bt-admin-star", role: "img", "aria-label": t("Admin") }, (0, import_react2.createElement)(StarIcon, { size: 7 })); +function QuestionDock({ sessionId, useRoster, useCards, useSession, actions }) { + const question = useRoster((value) => value.questions?.[sessionId]); + const drawn = useCards?.((state) => state[sessionId]?.[question?.callId] > 0) === true; + const loading = useSession?.(historyLoading) === true; + if (question === void 0 || question.callId && (drawn || loading)) return null; + return (0, import_react6.createElement)(PendingQuestion, { sessionId, question, actions, inline: false }); -+} + } +-function MainStar() { +- return (0, import_react2.createElement)( +- "svg", +- { viewBox: "0 0 20 20", "aria-hidden": true }, +- (0, import_react2.createElement)("path", { d: "M10 2.4l2.29 5.04 5.51.63-4.09 3.74 1.11 5.42L10 14.5l-4.82 2.73 1.11-5.42L2.2 8.07l5.51-.63z" }) +- ); +function AnsweredCell({ node }) { + const data = node.data ?? {}; + const questions = Array.isArray(data.questions) ? data.questions : []; @@ -5424,109 +4226,17 @@ diff --git a/client.js b/client.js + question, + answer: answers.find((answer) => answer.id === question.id) ?? { selected: [] } + }))); -+} - --.bt-thumb{position:absolute;left:0;top:0;z-index:0;pointer-events:none;transition:opacity .14s ease} --.bt-thumb[data-hidden]{opacity:0} --[data-morph]{overflow:hidden!important} --.bt-veil{animation:bt-veil-in .22s ease-out} --.bt-with-icon{display:inline-flex;align-items:center;gap:4px} --.bt-with-icon svg{width:14px;height:14px} --.bt-tag-admin{display:inline-flex;align-items:center;gap:4px} --.bt-create-inline{padding:18px 16px;border:1px solid var(--bt-line);border-radius:12px;background:var(--bt-card)} --.bt-create-inline .bt-create-card{max-width:none} --.bt-panel-view>[data-move=in]{animation:bt-page-in ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing} backwards,bt-veil-in .2s ease-out backwards} --.bt-panel-view>[data-move=out]{animation:bt-page-out ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing} backwards,bt-veil-in .2s ease-out backwards} --.bt-panel-view>:is([data-move=in],[data-move=out]):nth-child(2){animation-delay:25ms} --.bt-panel-view>:is([data-move=in],[data-move=out]):nth-child(n+3){animation-delay:50ms} --.bt-panel-view>.bt-drawer-body[data-move=tab]{animation:bt-veil-in .2s ease-out} --.bt-drawer-name-input{animation:bt-veil-in .16s ease-out} --.bt-pop-in{display:inline-flex;animation:bt-check-pop ${SPRING_POP.duration}ms ${SPRING_POP.easing}} --:is(.bt-soft,.bt-send,.bt-icon-btn,.bt-settings-close,.bt-mini,.bt-tab,.bt-nc-send,.bt-avedit-tab,.bt-shape,.bt-pick,.bt-swatch,.bt-palette-clear){transition:background-color .12s ease,color .12s ease,border-color .12s ease,opacity .12s ease,${spring(SPRING_TAP, "scale")}} --:is(.bt-icon-btn,.bt-settings-close,.bt-nc-send,.bt-shape,.bt-swatch,.bt-color,.bt-x,.bt-q-x,.bt-palette-clear):active:not(:disabled){scale:.9} --:is(.bt-soft,.bt-send,.bt-mini,.bt-tab,.bt-avedit-tab,.bt-pick,.bt-q-submit,.bt-q-btn):active:not(:disabled){scale:.96} --@media (prefers-reduced-motion:reduce){.bt-thumb{transition:none}.bt-panel-view>[data-move],.bt-drawer-name-input,.bt-veil,.bt-pop-in,.bt-token,.bt-drop,.bt-newchat,.bt-option-hint,.bt-connector-form,.bt-settings-head>*{animation:none}} --`; --var 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} --.bt-think summary::-webkit-details-marker{display:none} --.bt-think-text{white-space:pre-wrap;margin-top:6px;padding-left:10px;border-left:2px solid var(--bt-line-2)} --.bt-event{display:flex;justify-content:center;align-items:center;gap:2px;min-height:24px;margin:2px 0;font-size:12px;line-height:16px;color:var(--bt-ink-2);flex-wrap:wrap} --.bt-event>span{padding:0 2px} --.bt-event button{border:0;background:none;color:inherit;font:inherit;display:inline-flex;align-items:center;gap:4px;cursor:pointer;height:24px;padding:4px 6px 4px 4px;border-radius:999px;box-sizing:border-box;transition:background-color .12s ease} --.bt-event button:hover{background:var(--bt-hover)} --.bt-event button:active{background:var(--bt-active)} --.bt-event button.bt-event-whole{min-width:0;max-width:100%;padding:4px 6px 4px 8px} --.bt-event-whole>*{flex:none} --.bt-event-whole>span:first-child{margin-right:2px} --/* One button cannot wrap like separate parts, so a long name is cut to fit the chat. */ --.bt-event-whole>.bt-event-name{flex:0 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} --.bt-time-sep{display:flex;justify-content:center;align-items:center;height:28px;margin:14px 0 8px;font-size:12px;line-height:16px;color:var(--bt-ink-2);white-space:nowrap} --[class*="_scroll"]>[class*="_column"]::before{content:var(--bt-first-sep,none);display:block;flex:none;height:28px;margin:17px 0 12px;font-size:12px;line-height:28px;text-align:center;color:var(--bt-ink-2);white-space:nowrap} --.bt-activity{display:flex;align-items:center;gap:10px;margin:8px 0 4px 2px;font-size:14px;color:var(--bt-ink-2)} --.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 -- mode folds away in a finished turn stays shown. TurnProcessCell also opens that -- fold; these rules cover the first frame before it does. */ --[data-step-process]>div:first-child{display:none!important} --[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} --[class*="_header"]:has(.bt-head){border-bottom-color:transparent!important;box-shadow:none!important} --/* The transcript runs under a floating header pill. */ --header[class*="_header"]:has(.bt-head){position:absolute!important;top:0;left:0;right:0;z-index:8;display:flex!important;padding:10px 0 0!important;background:transparent!important;pointer-events:none;justify-content:center} --header[class*="_header"]:has(.bt-head)::before{content:"";position:absolute;inset:0 0 auto;height:60px;background:linear-gradient(to bottom,var(--bt-main),color-mix(in srgb,var(--bt-main) 0%,transparent));pointer-events:none;z-index:-1} --header[class*="_header"]:has(.bt-head)>[class*="_headerLeading"]{position:absolute;left:12px;top:10px;pointer-events:auto} --header[class*="_header"] .bt-head{flex:1 1 auto;width:100%} --[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"]){display:none!important} --[class*="_flowItem"][data-chat-flow-kind="turn-tail"]:not(:has([data-slot="conversation.chat.turnTail"]:not(:empty))){display:none!important} --/* 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. */ --[data-content-phase=hero] [class*="_scrollBody"]{justify-content:flex-end!important} --[data-content-phase=hero] [class*="_composerHero"]{align-self:stretch!important;width:auto!important;padding-bottom:0!important} --[data-content-phase=hero] [class*="_composerHero"]>[class$="_root"],[data-content-phase=hero] [class*="_heroWorkspaceRow"]{display:none!important} --[data-content-phase=hero] [class*="_composerHero"] [class*="_input"]{min-height:0!important} --/* The right sides give back the scrollbar gutter so both edges sit 16px from the column. */ --[class*="_scroll"]:has(> [class*="_column"]){padding:60px 11px 16px 16px!important} --/* The shell's running status only hosts the activity row (.bt-typing). */ --[data-chat-running]{display:block!important;width:100%;min-height:0!important;margin:0!important;padding:0!important} --[data-chat-running]>span:not([role=status]){display:none!important} --[data-chat-running]:has(> .bt-typing){margin-top:var(--dsh-chat-flow-gap,12px)!important} --.bt-typing{display:flex;align-items:center;gap:12px;height:36px;min-width:0;padding-inline-start:8px;transform-origin:0 50%;animation:bt-typing-in ${SPRING_TAP.duration}ms ${SPRING_TAP.easing} backwards} --.bt-typing[data-exiting]{animation:bt-typing-out .14s cubic-bezier(.22,1,.36,1) forwards} --.bt-typing>.bt-mark,.bt-typing>.bt-cluster{flex-shrink:0} --.bt-typing>.bt-mark{cursor:pointer;transition:filter .16s ease-out} --.bt-typing>.bt-mark:hover{filter:drop-shadow(0 2px 4px rgba(0,0,0,.18))} --.bt-typing>.bt-mark .bt-eyes{transition:scale .28s cubic-bezier(.4,.06,.18,1)} --.bt-typing>.bt-mark:hover .bt-eyes{scale:1.18} --.bt-typing-text{display:flex;align-items:center;flex:1 1 auto;min-width:0;animation:bt-typing-label .22s ease-out .2s backwards} --.bt-typing-label{display:inline-flex;align-items:center;gap:6px;min-width:0;animation:bt-typing-label .22s ease-out backwards} --.bt-typing-label>.bt-shimmer{font-size:14px;line-height:22px;font-weight:400;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} --.bt-typing-time{flex-shrink:0;font-size:12px;line-height:22px;color:var(--bt-ink-3);white-space:nowrap} --@keyframes bt-typing-in{from{opacity:0;transform:scale(.9)}} --@keyframes bt-typing-out{from{opacity:1;transform:scale(1)}to{opacity:0;transform:scale(.92);filter:blur(3px)}} --@keyframes bt-typing-label{from{opacity:0;filter:blur(5px);transform:translateY(3px)}} --/* Shimmer: a stepped sweep clipped to the text. */ --.bt-shimmer{color:transparent;background:linear-gradient(90deg,var(--bt-shimmer-base) 0%,var(--bt-shimmer-base) 25%,var(--bt-ink) 60%,var(--bt-shimmer-base) 75%,var(--bt-shimmer-base) 100%);background-size:200% 100%;-webkit-background-clip:text;background-clip:text;animation:bt-shimmer 2.2s steps(60) infinite;--bt-shimmer-base:color-mix(in srgb,var(--bt-ink) 40%,transparent)} --@keyframes bt-shimmer{from{background-position:200% 0}to{background-position:-200% 0}} --/* New rows slide up; a reply that replaces the activity row grows out of its corner. */ --[data-bt-enter="new"]{animation:bt-row-in ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing}} --[data-bt-enter="after-collapse"]{transform-origin:0 100%;animation:bt-row-after .24s cubic-bezier(.23,1,.32,1) .38s backwards} --@keyframes bt-row-in{from{transform:translateY(12px)}to{transform:none}} --@keyframes bt-row-after{0%{opacity:0;transform:translateY(12px) scale(.94)}55%{opacity:1}100%{opacity:1;transform:none}} --@media (prefers-reduced-motion:reduce){ --.bt-typing,.bt-typing[data-exiting]{animation-name:bt-fade-in;animation-duration:.12s;transform:none} --.bt-typing[data-exiting]{animation-name:bt-fade-out} --.bt-typing-text,.bt-typing-label{animation-name:bt-fade-in;animation-delay:0s} --.bt-shimmer{animation:none;background:none;color:var(--bt-ink-2)} --[data-bt-enter="new"]{animation:none} --[data-bt-enter="after-collapse"]{animation:bt-fade-in .12s ease-out} + } +-function useSettled(state) { +- const prev = (0, import_react2.useRef)(state); +- const [settled, setSettled] = (0, import_react2.useState)(false); +- (0, import_react2.useEffect)(() => { +- const was = prev.current; +- prev.current = state; +- if (state !== "idle") { +- setSettled(false); +- return void 0; ++ +// src/client/secret-card.js +var import_react7 = require("react"); +var MIN_VALUE = 4; @@ -5579,9 +4289,27 @@ diff --git a/client.js b/client.js + if (event.key !== "Enter" || event.nativeEvent.isComposing) return; + event.preventDefault(); + onEnter?.(); -+ } + } +- if (was === "idle" || was === "alert") return void 0; +- setSettled(true); +- const timer = setTimeout(() => setSettled(false), 1800); +- return () => clearTimeout(timer); +- }, [state]); +- return settled; + }); -+} + } +-function BotAvatar({ bot, size, state = "idle", main, admin, badge = true, live = true, gaze = false, pokeable = false }) { +- const settled = useSettled(state); +- if (bot === void 0) return (0, import_react2.createElement)(BotMark, { size }); +- const badgeKind = !badge ? null : state === "alert" ? "alert" : state !== "idle" ? "working" : admin ? "admin" : main ? "main" : null; +- return (0, import_react2.createElement)( +- "span", +- { className: "bt-mark", style: { width: size, height: size, "--bt-size": `${size}px` } }, +- (0, import_react2.createElement)(BotMark, { bot, size, state: settled ? "done" : state, live, gaze, pokeable }), +- badgeKind === "working" ? (0, import_react2.createElement)("span", { className: "bt-badge bt-badge-working", "aria-label": t("Working"), role: "img" }) : null, +- badgeKind === "alert" ? (0, import_react2.createElement)("span", { className: "bt-badge bt-badge-alert", "aria-label": t("Waiting for your answer"), role: "img" }, "?") : null, +- badgeKind === "admin" ? (0, import_react2.createElement)("span", { className: "bt-badge bt-badge-admin", "aria-label": t("Admin"), role: "img" }, (0, import_react2.createElement)(StarIcon)) : null, +- badgeKind === "main" ? (0, import_react2.createElement)("span", { className: "bt-badge bt-badge-main", "aria-label": t("Main Bot"), role: "img" }, (0, import_react2.createElement)(MainStar)) : null +function ScopeSwitch({ value, onChange, bot }) { + const options = [["all", t("All my Bots")], ["bot", t("Only {name}", { name: bot.name })]]; + return (0, import_react7.createElement)( @@ -5596,8 +4324,18 @@ diff --git a/client.js b/client.js + className: "bt-secret-seg", + onClick: () => onChange(id) + }, text)) -+ ); -+} + ); + } +-function clusterSeats(count, frame) { +- if (count === 1) return [[0, 0, frame]]; +- if (count === 2) return [[0, 0, frame * 2 / 3], [frame / 3, frame / 3, frame * 2 / 3]]; +- if (count === 3) { +- const size = frame * 5 / 9; +- const rest = frame - size; +- return [[rest / 2, 0, size], [0, rest, size], [rest, rest, size]]; +- } +- const half = frame / 2; +- return [[0, 0, half], [half, 0, half], [0, half, half], [half, half, half]]; +function SecretHead({ bot, title, detail, name, onClose }) { + return (0, import_react7.createElement)( + "div", @@ -5611,7 +4349,37 @@ diff --git a/client.js b/client.js + ), + onClose ? (0, import_react7.createElement)("button", { type: "button", className: "bt-q-x", "aria-label": t("Cancel the request"), title: t("Cancel"), onClick: onClose }, (0, import_react7.createElement)(CloseIcon)) : null + ); -+} + } +-function RoomAvatar({ room: room2, roster, size = 36 }) { +- const members = room2.members.map((id) => roster.byId[id]).filter(Boolean); +- const people = members.length === 1 ? [members[0], null] : members; +- const seat = (bot, seatSize, key) => bot ? (0, import_react2.createElement)(BotMark, { key, bot, size: seatSize }) : (0, import_react2.createElement)("span", { key, className: "bt-cluster-you", style: { width: seatSize * 0.92, height: seatSize * 0.92, margin: seatSize * 0.04 } }); +- if (people.length === 0) return (0, import_react2.createElement)(BotMark, { size }); +- if (size <= 20 && people.length > 1) { +- return (0, import_react2.createElement)( +- "span", +- { className: "bt-stack", style: { "--bt-stack-size": `${size}px` } }, +- people.slice(0, 3).map((bot, index) => seat(bot, size, bot?.id ?? `you-${index}`)), +- people.length > 3 ? (0, import_react2.createElement)("span", { className: "bt-stack-more" }, `+${people.length - 3}`) : null +- ); +- } +- const seats = clusterSeats(Math.min(people.length, 4), size); +- const overflow = people.length > 4 ? people.length - 3 : 0; +- const gap = size / 14; +- const rings = seats.map(([x, y, s]) => ({ cx: x + s / 2, cy: y + s / 2, r: s * 0.48 + gap })); +- return (0, import_react2.createElement)( +- "span", +- { className: "bt-cluster", style: { width: size, height: size } }, +- seats.map(([x, y, s], index) => { +- const holes2 = rings.slice(index + 1).filter((ring) => Math.hypot(ring.cx - x - s / 2, ring.cy - y - s / 2) < ring.r + s * 0.46).map((ring) => `radial-gradient(circle at ${ring.cx - x}px ${ring.cy - y}px,transparent ${ring.r}px,#000 ${ring.r + 0.5}px)`); +- const style = { left: x, top: y, width: s, height: s }; +- if (holes2.length > 0) Object.assign(style, { WebkitMaskImage: holes2.join(","), maskImage: holes2.join(","), WebkitMaskComposite: "source-in", maskComposite: "intersect" }); +- if (overflow > 0 && index === 3) { +- return (0, import_react2.createElement)("span", { key: "more", className: "bt-cluster-more", style: { ...style, width: s * 0.92, height: s * 0.92, margin: s * 0.04, fontSize: Math.max(8, Math.round(s * 0.36)) } }, `+${overflow}`); +- } +- const bot = people[index]; +- return (0, import_react2.createElement)("span", { key: bot?.id ?? "you", style }, seat(bot, s)); +- }) +function SecretCard({ bot, request, roster, actions }) { + const [value, setValue] = (0, import_react7.useState)(""); + const [scope, setScope] = (0, import_react7.useState)("all"); @@ -5669,8 +4437,24 @@ diff --git a/client.js b/client.js + (0, import_react7.createElement)("button", { type: "button", className: "bt-q-btn", disabled: busy, onClick: cancel }, t("Cancel")), + filling ? (0, import_react7.createElement)("button", { type: "button", className: "bt-q-submit", disabled: !long || busy, onClick: save }, busy ? t("Saving…") : t("Save")) : (0, import_react7.createElement)("button", { type: "button", className: "bt-q-submit", disabled: busy, onClick: allow }, busy ? t("Allowing…") : t("Allow")) + ) -+ ); -+} + ); + } +- +-// src/client/icons.js +-var import_react3 = require("react"); +-function PlusIcon() { +- return (0, import_react3.createElement)("svg", { width: 18, height: 18, viewBox: "0 0 20 20", "aria-hidden": true }, (0, import_react3.createElement)("path", { d: "M10 3.5v13M3.5 10h13", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round" })); +-} +-function SearchIcon() { +- return (0, import_react3.createElement)("svg", { width: 16, height: 16, viewBox: "0 0 20 20", "aria-hidden": true }, (0, import_react3.createElement)("circle", { cx: 8.5, cy: 8.5, r: 5.5, stroke: "currentColor", strokeWidth: 1.6, fill: "none" }), (0, import_react3.createElement)("path", { d: "M13 13l4 4", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round" })); +-} +-function PeopleIcon() { +- return (0, import_react3.createElement)( +- "svg", +- { width: 14, height: 14, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", "aria-hidden": true }, +- (0, import_react3.createElement)("circle", { cx: 7.5, cy: 7, r: 3 }), +- (0, import_react3.createElement)("path", { d: "M2 16.5c.6-2.8 2.8-4.5 5.5-4.5s4.9 1.7 5.5 4.5" }), +- (0, import_react3.createElement)("path", { d: "M13 4.3a3 3 0 0 1 0 5.4M15 12.4c1.6.6 2.6 2 3 4.1" }) +function SettledSecret({ bot, request, roster }) { + const canceled = request.status === "canceled"; + const text = canceled ? t("{bot} asked for {name}", { bot: bot.name, name: request.name }) : request.status === "allowed" ? t("Allowed {bot} to use {name}", { bot: bot.name, name: request.name }) : t("Saved {name}", { name: request.name }); @@ -5689,11 +4473,20 @@ diff --git a/client.js b/client.js + )), + canceled ? (0, import_react7.createElement)("span", { className: "bt-q-badge" }, t("Canceled")) : (0, import_react7.createElement)("span", { className: "bt-q-check bt-secret-check", title: t("Done") }, (0, import_react7.createElement)(CheckIcon)) + ) -+ ); -+} + ); + } +-function ArrowUpIcon() { +- return (0, import_react3.createElement)("svg", { width: 16, height: 16, viewBox: "0 0 20 20", "aria-hidden": true }, (0, import_react3.createElement)("path", { d: "M10 16V4M5 9l5-5 5 5", stroke: "currentColor", strokeWidth: 1.8, fill: "none", strokeLinecap: "round", strokeLinejoin: "round" })); +function RequestView({ bot, request, roster, actions }) { + return request.status === "pending" ? (0, import_react7.createElement)(SecretCard, { key: `${request.id}:${request.kind}`, bot, request, roster, actions }) : (0, import_react7.createElement)(SettledSecret, { bot, request, roster }); -+} + } +-function MicIcon({ size = 16 }) { +- return (0, import_react3.createElement)( +- "svg", +- { width: size, height: size, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round", "aria-hidden": true }, +- (0, import_react3.createElement)("rect", { x: 7, y: 2.5, width: 6, height: 10, rx: 3, fill: "currentColor", stroke: "none" }), +- (0, import_react3.createElement)("path", { d: "M4.5 9.5a5.5 5.5 0 0 0 11 0M10 15v2.5" }) +- ); +function SecretToolCell({ sessionId, callId, name, done, content, roster, actions }) { + const bot = roster.byId[sessionId]; + if (!bot) return null; @@ -5704,113 +4497,12 @@ diff --git a/client.js b/client.js + } + return null; } --@keyframes bt-fade-in{from{opacity:0}to{opacity:1}} --@keyframes bt-fade-out{from{opacity:1}to{opacity:0}} --[data-composer-card]{display:flex!important;flex-direction:row;flex-wrap:wrap;align-items:center;gap:0!important;min-height:44px;border-radius:22px!important;padding:7.5px!important;box-sizing:border-box;background:var(--bt-main)!important;border:.5px solid var(--bt-line-2)!important;box-shadow:0 2px 8px -1px rgba(0,0,0,.05),0 1px 2px rgba(0,0,0,.03),0 0 0 1px rgba(228,228,228,.04)!important;transition:border-color .15s ease,background-color .15s ease,${spring(SPRING_SHAPE, "border-radius")}!important} --[data-composer-card]:hover,[data-composer-card]:focus-within{border-color:color-mix(in srgb,var(--bt-ink) 30%,transparent)!important} --[data-composer-card]:has(> [data-slot="conversation.input.attachments"] *),[data-composer-card]:has([data-composer-input] br + *),[data-composer-card]:has([data-composer-input] > :nth-child(2)){border-radius:18px!important} --[class*="_root"]:has(> [data-composer-card]){padding:0 11px 13px 16px!important} --[class*="_root"]:has(> [data-composer-card])>[data-composer-card]{max-width:min(992px,100%)!important} --[data-composer-card]>[class*="_row"]{display:contents!important} --[data-composer-card] [class*="_tools"]{order:1;flex:none;align-self:flex-end;margin:0} --[data-composer-card]>[data-input-scroll]{order:2;flex:1 1 0;min-width:0;padding:0 8px!important;margin:0!important} --[data-composer-card] [class*="_trailing"]{order:3;flex:none;align-self:flex-end;margin:0} --[data-composer-card]>[data-slot="conversation.input.attachments"]{order:0;flex-basis:100%} --[data-composer-card] [data-composer-input]{min-height:24px;padding:2px 0!important} --[data-composer-card] [data-composer-placeholder]{left:0!important;top:2px!important} --[data-composer-placeholder]{font-size:0!important;line-height:0!important} --[data-composer-placeholder]::after{content:var(--bt-placeholder,"Message");display:block;font-size:14px;line-height:24px;color:color-mix(in srgb,var(--bt-ink) 36%,transparent)} --/* The team's "+" (ComposerPlus) takes the seat of the shell's command-menu button. */ --[data-composer-card] button[class*="_add"]{display:none!important} --.bt-plus{flex:none;width:28px;height:28px;padding:0;border:0;border-radius:50%;display:inline-flex;align-items:center;justify-content:center;background:color-mix(in srgb,var(--bt-ink) 5%,transparent);box-shadow:inset 0 0 0 .5px var(--bt-line);color:var(--bt-ink-2);cursor:pointer;transition:background-color .15s ease,color .15s ease,${spring(SPRING_TAP, "scale")}} --.bt-plus:hover,.bt-plus[aria-expanded=true]{background:color-mix(in srgb,var(--bt-ink) 10%,transparent);color:var(--bt-ink)} --.bt-plus:active{scale:.9} --.bt-plus svg{width:14px;height:14px;transition:${spring(SPRING_POP, "rotate")}} --.bt-plus[aria-expanded=true] svg{rotate:45deg} --.bt-plus-menu{min-width:180px} --[data-composer-card] [class*="_tools"]{gap:0!important} --[data-composer-card] [class*="_modes"]:empty{display:none!important} --[data-composer-card] [class*="_activity"]:not(:has([data-slot] > *)){display:none!important} --[data-composer-card] [class*="_trailing"]{gap:8px} --[data-composer-card] [class*="_standardControls"]{display:flex;align-items:center;gap:8px} --[data-composer-card] button[class*="_primary"]{width:28px!important;height:28px!important;background:var(--bt-accent)!important;color:var(--bt-accent-ink,#fff)!important;border-radius:50%!important;transition:opacity .15s ease,${spring(SPRING_TAP, "scale")};animation:bt-icon-swap ${SPRING_POP.duration}ms ${SPRING_POP.easing}} --[data-composer-card] button[class*="_primary"]:hover:not(:disabled){opacity:.9} --[data-composer-card] button[class*="_primary"]:active:not(:disabled){scale:.9} --@keyframes bt-icon-swap{from{opacity:0;transform:scale(.5) rotate(-30deg)}} --[data-composer-card] button[class*="_primary"] svg{width:14px;height:14px} --[data-composer-card] button[class*="_primary"]:disabled{display:none!important} --[data-composer-dock]{display:none!important} --.bt-mic,.bt-voice{flex:none;width:28px;height:28px;border-radius:50%;border:0;padding:0;display:inline-grid;place-items:center;cursor:pointer;transition:background-color .15s ease,color .15s ease,${spring(SPRING_TAP, "scale")}} --.bt-mic{background:color-mix(in srgb,var(--bt-ink) 5%,transparent);box-shadow:inset 0 0 0 .5px var(--bt-line);color:var(--bt-ink-2)} --.bt-mic:hover{background:color-mix(in srgb,var(--bt-ink) 10%,transparent);color:var(--bt-ink)} --.bt-mic[aria-pressed=true]{background:#ff3e51;color:#fff;animation:bt-mic-pulse 1.4s ease-in-out infinite} --@keyframes bt-mic-pulse{0%,100%{box-shadow:0 0 0 0 rgba(255,62,81,.35)}50%{box-shadow:0 0 0 5px rgba(255,62,81,0)}} --.bt-voice{display:none;background:var(--bt-accent);color:var(--bt-accent-ink,#fff);animation:bt-icon-swap ${SPRING_POP.duration}ms ${SPRING_POP.easing}} --.bt-voice:hover{opacity:.9} --.bt-mic:active,.bt-voice:active{scale:.9} --[data-composer-card]:has(button[class*="_primary"]:disabled) .bt-voice{display:inline-grid} --body[data-bt-room] .bt-voice{display:none!important} --body[data-bt-room] [data-composer-card]:has(button[class*="_primary"]:disabled) .bt-mic:not([aria-pressed=true]){background:var(--bt-accent);color:var(--bt-accent-ink,#fff)} --.bt-mic-note{position:absolute;right:12px;bottom:calc(100% + 8px);padding:5px 10px;border-radius:8px;background:var(--bt-user);color:var(--bt-user-ink);font-size:12px;line-height:16px;white-space:nowrap;pointer-events:none;animation:bt-rise .16s ease-out} --[data-chain-overlay-fallback="conversation.composer"]:has(~ .bt-q){display:block!important;order:2} --.bt-q{order:1} --div:has(> nav [class*="_marks"]){display:none!important} --/* Every user-side bubble (sent, steering mid-turn, not yet admitted, question replies) -- takes its surface and its text from the one --bt-user pair: a skin that repaints -- the host's bubble surface alone would otherwise leave the text unreadable. */ --[class*="_userRow"] [class*="_bubble"],[data-chat-flow-kind=question-reply] [class*="_bubble"],[data-chat-flow-kind=command-input] [class*="_bubble"]{background:var(--bt-user)!important;color:var(--bt-user-ink)!important;border-radius:18px!important;padding:7px 12px!important;font-size:var(--dsh-content-font-size,14px)!important;line-height:calc(var(--dsh-content-font-size,14px) + 8px)!important} --[class*="_userRow"] [class*="_bubble"] [class*="_markdown"],[class*="_userRow"] [class*="_bubble"] a{color:inherit} --/* Sent messages get the hover toolbar below; steering and unsent bubbles keep none. */ --[class*="_userRow"] [class*="_actions"]{display:none!important} --[data-chat-flow-kind=user] [class*="_userRow"]{flex-direction:row-reverse;align-items:center;justify-content:flex-start} --[data-chat-flow-kind=user] [class*="_actions"]{display:flex!important;height:24px!important;gap:2px!important;opacity:0!important;visibility:hidden;transition:opacity .12s ease,visibility .12s!important;flex:none} --[data-chat-flow-kind=user] [class*="_userRow"]:hover [class*="_actions"],[data-chat-flow-kind=user] [class*="_userRow"]:focus-within [class*="_actions"]{opacity:1!important;visibility:visible} --[data-chat-flow-kind=user] [class*="_actions"] [class*="_timeStart"]{display:none} --[data-chat-flow-kind=user] [class*="_actions"] button{width:24px;height:24px;border-radius:6px;color:var(--bt-ink-2)} --[data-chat-flow-kind=user] [class*="_actions"] button:hover{background:var(--bt-hover);color:var(--bt-ink)} --[data-chat-flow-kind=user] [class*="_actions"] svg{width:15px;height:15px} --.bt-q-inline{display:flex;margin:4px 0 6px} --[data-chat-flow-kind=assistant-step]+[data-step-process]:has(.bt-q-inline){margin-top:0!important} --[data-chat-flow-kind=question-reply]:has(.bt-q-inline),[data-chat-group-key]:has([data-chat-flow-kind=question-reply] .bt-q-inline),[data-chat-group-key]:has(.bt-q-gone){margin-top:0!important} --.bt-q-inline .bt-q{margin:0;max-width:min(550px,86%);order:0} --.bt-q-dock{width:100%;display:flex;justify-content:center} --.bt-q-dock .bt-q{margin:0 auto 8px} --[class*="_toBottomSlot"]{justify-content:center!important;padding-right:0!important} --[data-conversation-scroll] [class*="_toBottomSlot"]{bottom:calc(var(--dsh-composer-height,152px) + 8px)!important} --[class*="_toBottomSlot"]>button{width:36px!important;height:36px!important;margin-top:-36px!important;background:var(--bt-main)!important;color:var(--bt-ink)!important;box-shadow:0 0 0 .5px var(--bt-line-2),0 2px 8px -2px rgba(0,0,0,.12)!important;animation:bt-tobottom-in ${SPRING_TAP.duration}ms ${SPRING_TAP.easing},bt-veil-in .16s ease-out;transition:background-color .12s ease,${spring(SPRING_TAP, "scale")}} --[class*="_toBottomSlot"]>button:active{scale:.9} --[class*="_toBottomSlot"]>button:hover{background:color-mix(in srgb,var(--bt-ink) 4%,var(--bt-main))!important} --[class*="_toBottomSlot"]>button svg{display:none} --[class*="_toBottomSlot"]>button::before{content:"";width:20px;height:20px;background:currentColor;-webkit-mask:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='none' stroke='black' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M10 4v12M5 11l5 5 5-5'/%3E%3C/svg%3E") center/contain no-repeat;mask:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='none' stroke='black' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M10 4v12M5 11l5 5 5-5'/%3E%3C/svg%3E") center/contain no-repeat} --@keyframes bt-tobottom-in{from{translate:0 8px;scale:.8}} --[data-bt-news] [class*="_toBottomSlot"]>button{visibility:hidden!important;pointer-events:none!important} --.bt-news{position:fixed;z-index:30;transform:translateX(-50%);display:inline-flex;align-items:center;overflow:hidden;border-radius:999px;background:#0c64c1;color:#fcfcfc;box-shadow:inset 0 0 0 1px rgba(20,20,20,.05),0 1px 3px 0 rgba(0,0,0,.12);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;animation:bt-news-in ${SPRING_TAP.duration}ms ${SPRING_TAP.easing},bt-veil-in .16s ease-out} --@keyframes bt-news-in{from{translate:0 8px;scale:.9}} --.bt-news[data-direction=up]{animation-name:bt-news-in-up,bt-veil-in} --@keyframes bt-news-in-up{from{translate:0 -8px;scale:.9}} --.bt-news-jump{display:inline-flex;align-items:center;gap:2px;margin:0;padding:4px 26px 4px 4px;border:0;border-radius:0;background:transparent;color:inherit;font:inherit;font-size:13px;line-height:18px;white-space:nowrap;cursor:pointer;outline:none} --.bt-news-ico{flex:none;display:inline-flex;align-items:center;justify-content:center;width:20px;height:20px} --.bt-news-ico svg{width:16px;height:16px} --.bt-news-x{position:absolute;right:4px;top:50%;z-index:1;transform:translateY(-50%);width:20px;height:20px;padding:0;border:0;border-radius:999px;background:transparent;color:inherit;opacity:.75;display:inline-flex;align-items:center;justify-content:center;cursor:pointer} --.bt-news-x svg{width:12px;height:12px} --.bt-news-x:hover{opacity:1;background:rgba(20,20,20,.5)} --@media (prefers-reduced-motion:reduce){.bt-news,[class*="_toBottomSlot"]>button{animation:none}} --.bt-new-sep{display:flex;align-items:center;gap:8px;margin:14px 0 8px;padding:2px 0;font-size:11px;line-height:14px;font-weight:500;letter-spacing:.02em;text-transform:uppercase;color:#0c64c1;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif} --.bt-new-sep::before,.bt-new-sep::after{content:"";flex:1;height:1px;background:color-mix(in srgb,#0c64c1 20%,transparent)} --.bt-open-new{display:flex;flex-direction:column} --.bt-open-new .bt-new-sep{margin:18px 0 0} --.bt-open-new .bt-time-sep{height:16px;margin:32px 0 2px} --.bt-earlier{display:flex;flex-direction:column;gap:6px} --.bt-earlier-top{display:flex;justify-content:center;align-items:center;height:34px;flex:none} --.bt-earlier-top .bt-soft{height:28px;font-size:12px;padding:0 12px} --.bt-urow{display:flex;justify-content:flex-end} --.bt-ububble{max-width:min(560px,78%);background:var(--bt-user);color:var(--bt-user-ink);border-radius:18px;padding:7px 12px;font-size:14px;line-height:20px;box-sizing:border-box;overflow-wrap:anywhere} --.bt-ububble [class*="_markdown"],.bt-ububble a{color:inherit!important} --.bt-ububble p{margin:0;line-height:22px} --.bt-uimages{display:block;font-size:12px;opacity:.7} --.bt-earlier .bt-event{margin:4px 0} --.bt-part-line{display:flex;align-items:center;gap:10px;margin:10px 0 6px;font-size:11px;line-height:14px;color:var(--bt-ink-3)} --.bt-part-line::before,.bt-part-line::after{content:"";flex:1;height:1px;background:var(--bt-line)} +-function WaveIcon({ size = 16 }) { +- return (0, import_react3.createElement)( +- "svg", +- { width: size, height: size, viewBox: "0 0 20 20", fill: "currentColor", "aria-hidden": true }, +- [[3, 7.5, 5], [6.5, 4.5, 11], [10, 2.5, 15], [13.5, 5.5, 9], [17, 8, 4]].map(([x, y, height]) => (0, import_react3.createElement)("rect", { key: x, x: x - 1, y, width: 2, height, rx: 1 })) +- ); +function SecretDock({ sessionId, useRoster, useCards, useSession, actions }) { + const roster = useRoster((value) => value); + const shown = useCards?.((state) => state[sessionId]) ?? NONE; @@ -5821,7 +4513,12 @@ diff --git a/client.js b/client.js + const waiting = (roster.secretRequests?.[bot.id] ?? []).filter((entry) => entry.status === "pending" && !drawn(entry)); + if (waiting.length === 0) return null; + return (0, import_react7.createElement)("div", { className: "bt-q-dock bt-secret-dock" }, waiting.map((request) => (0, import_react7.createElement)(SecretCard, { key: `${request.id}:${request.kind}`, bot, request, roster, actions }))); -+} + } +-function ShareIcon() { +- return (0, import_react3.createElement)( +- "svg", +- { width: 16, height: 16, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, +- (0, import_react3.createElement)("path", { d: "M10 12.5V3M6.5 6.5L10 3l3.5 3.5M4 11v4.5A1.5 1.5 0 0 0 5.5 17h9a1.5 1.5 0 0 0 1.5-1.5V11" }) +var scopeMode = (scope) => scope === "all" ? "all" : scope.length === 0 ? "none" : "some"; +function ScopeEditor({ secret, roster, onSave }) { + const [mode, setMode] = (0, import_react7.useState)(scopeMode(secret.scope)); @@ -5855,8 +4552,18 @@ diff --git a/client.js b/client.js + { className: "bt-secret-row-actions" }, + (0, import_react7.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: same2, onClick: () => onSave(next) }, t("Save access")) + ) -+ ); -+} + ); + } +-function CloseIcon() { +- return (0, import_react3.createElement)("svg", { width: 16, height: 16, viewBox: "0 0 20 20", "aria-hidden": true }, (0, import_react3.createElement)("path", { d: "M5 5l10 10M15 5L5 15", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round" })); +-} +-var ChevronLeftIcon = () => (0, import_react3.createElement)("svg", { width: 16, height: 16, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, (0, import_react3.createElement)("path", { d: "M12.5 4.5L7 10l5.5 5.5" })); +-var ChevronRightIcon = () => (0, import_react3.createElement)("svg", { width: 14, height: 14, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, (0, import_react3.createElement)("path", { d: "M7.5 4.5L13 10l-5.5 5.5" })); +-var lineIcon = (paths) => function LineIcon() { +- return (0, import_react3.createElement)( +- "svg", +- { width: 15, height: 15, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, +- paths.map((d, index) => (0, import_react3.createElement)("path", { key: index, d })) +function ValueEditor({ secret, onSave }) { + const [value, setValue] = (0, import_react7.useState)(""); + const [replaced, setReplaced] = (0, import_react7.useState)(false); @@ -5885,7 +4592,15 @@ diff --git a/client.js b/client.js + (0, import_react7.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: !long, onClick: save }, t("Replace")) + ), + value !== "" && !long ? (0, import_react7.createElement)("div", { className: "bt-set-hint" }, t("At least {count} characters.", { count: MIN_VALUE })) : replaced ? (0, import_react7.createElement)("div", { className: "bt-set-hint bt-secret-ok", role: "status" }, (0, import_react7.createElement)(CheckIcon), t("Replaced. Bots get the new value from their next command.")) : null -+ ); + ); +-}; +-var CLOUD = "M6.5 15.25H5.75a3.25 3.25 0 0 1-.55-6.45 4.75 4.75 0 0 1 9.2-1.1 3.75 3.75 0 0 1 .1 7.55H13.5"; +-function CloudDownloadIcon({ done = false }) { +- return (0, import_react3.createElement)( +- "svg", +- { width: 16, height: 16, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, +- (0, import_react3.createElement)("path", { d: CLOUD }), +- done ? (0, import_react3.createElement)("path", { d: "M7.75 13.25l1.75 1.75 3-3.25" }) : (0, import_react3.createElement)("g", { className: "bt-cloud-arrow" }, (0, import_react3.createElement)("path", { d: "M10 9.75v7" }), (0, import_react3.createElement)("path", { d: "M7.75 14.5L10 16.75l2.25-2.25" })) +} +function SecretRow({ secret, roster, actions, run, open, onToggle }) { + const [confirm, setConfirm] = (0, import_react7.useState)(false); @@ -5928,37 +4643,74 @@ diff --git a/client.js b/client.js + ] : (0, import_react7.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm bt-secret-danger", onClick: () => setConfirm(true) }, (0, import_react7.createElement)(TrashIcon), t("Delete")) + ) + ) : null -+ ); -+} -+function AddSecret({ actions, run, onDone }) { -+ const [name, setName] = (0, import_react7.useState)(""); -+ const [purpose, setPurpose] = (0, import_react7.useState)(""); -+ const [value, setValue] = (0, import_react7.useState)(""); -+ const ready = /^[A-Za-z][\w-]{0,63}$/.test(name.trim()) && value.trim().length >= MIN_VALUE; -+ const save = () => { -+ if (!ready) return; -+ void run(actions.secretSet({ name, purpose, value, scope: "all" })).then((ok) => { -+ if (ok) { -+ setValue(""); -+ onDone(); -+ } -+ }); -+ }; -+ return (0, import_react7.createElement)( -+ "div", -+ { className: "bt-set-row bt-set-paste bt-secret-add" }, -+ (0, import_react7.createElement)("input", { className: "bt-input", "aria-label": t("Key name"), placeholder: t("NAME, e.g. GITHUB_TOKEN"), value: name, spellCheck: false, autoComplete: "off", autoFocus: true, onChange: (event) => setName(event.target.value.toUpperCase()) }), -+ (0, import_react7.createElement)("input", { className: "bt-input", "aria-label": t("What it is for"), placeholder: t("What it is for"), value: purpose, autoComplete: "off", onChange: (event) => setPurpose(event.target.value) }), -+ (0, import_react7.createElement)("label", { className: "bt-q-field bt-secret-field" }, (0, import_react7.createElement)(MaskedInput, { value, onChange: setValue, onEnter: save, label: t("Value") })), + ); + } +-var SmileIcon = lineIcon(["M10 3.5a6.5 6.5 0 1 1 0 13 6.5 6.5 0 0 1 0-13z", "M7.3 11.6a3.4 3.4 0 0 0 5.4 0", "M7.75 8.25h.01M12.25 8.25h.01"]); +-var ReplyIcon = lineIcon(["M8 5L4 9l4 4", "M4 9h7.5a4.5 4.5 0 0 1 4.5 4.5V15"]); +-var DotsIcon = lineIcon(["M5 10h.01M10 10h.01M15 10h.01"]); +-var CheckIcon = lineIcon(["M4.5 10.5l3.5 3.5 7.5-8"]); +-var ArrowDownIcon = lineIcon(["M10 4v12", "M5 11l5 5 5-5"]); +-var DownloadIcon = lineIcon(["M10 3.5v9", "M6.5 9.5L10 13l3.5-3.5", "M4 16h12"]); +-var GaugeIcon = lineIcon(["M3.5 13.5a6.5 6.5 0 1 1 13 0", "M10 13.5l3-4", "M10 13.5h.01"]); +-var PlugIcon = lineIcon(["M7 3v4M13 3v4", "M5 7h10v2.5a5 5 0 0 1-10 0z", "M10 14.5V17"]); +-var CalendarIcon = lineIcon(["M5.5 5h9A1.5 1.5 0 0 1 16 6.5v8a1.5 1.5 0 0 1-1.5 1.5h-9A1.5 1.5 0 0 1 4 14.5v-8A1.5 1.5 0 0 1 5.5 5z", "M4 8.75h12", "M7.25 3.5v3M12.75 3.5v3"]); +-var ConnectorIcon = lineIcon(["M7 10a2.5 2.5 0 1 1-5 0 2.5 2.5 0 0 1 5 0z", "M18 10a2.5 2.5 0 1 1-5 0 2.5 2.5 0 0 1 5 0z", "M7 10h6"]); +-var SwitchIcon = lineIcon(["M4 7h9.5M10.5 3.5L14 7l-3.5 3.5", "M16 13H6.5M9.5 9.5L6 13l3.5 3.5"]); +-var PaperclipIcon = lineIcon(["M15.5 9.5l-5.6 5.6a3.4 3.4 0 0 1-4.8-4.8l6.1-6.1a2.25 2.25 0 0 1 3.2 3.2l-6 6a1.1 1.1 0 0 1-1.6-1.6l5.5-5.5"]); +-var ClockIcon = lineIcon(["M10 3.5a6.5 6.5 0 1 1 0 13 6.5 6.5 0 0 1 0-13z", "M10 6.5V10l2.5 1.5"]); +-var GearIcon = lineIcon(["M10 7.4a2.6 2.6 0 1 1 0 5.2 2.6 2.6 0 0 1 0-5.2z", "M7.85 4.4l.29-1.87a7.7 7.7 0 0 1 3.72 0l.29 1.87a6 6 0 0 1 1.63.94l1.76-.69a7.7 7.7 0 0 1 1.86 3.23l-1.47 1.18a6 6 0 0 1 0 1.88l1.47 1.18a7.7 7.7 0 0 1-1.86 3.23l-1.76-.69a6 6 0 0 1-1.63.94l-.29 1.87a7.7 7.7 0 0 1-3.72 0l-.29-1.87a6 6 0 0 1-1.63-.94l-1.76.69a7.7 7.7 0 0 1-1.86-3.23l1.47-1.18a6 6 0 0 1 0-1.88L2.6 7.88a7.7 7.7 0 0 1 1.86-3.23l1.76.69a6 6 0 0 1 1.63-.94z"]); +-var PinIcon = lineIcon(["M12.5 3l4.5 4.5-3 1.5-2.5 2.5.5 3.5-1.5 1.5L7 13 3.5 16.5", "M7 13l-3.5-3.5L5 8l3.5.5L11 6l1.5-3"]); +-function PinMarkIcon() { +- return (0, import_react3.createElement)( +- "svg", +- { width: 12, height: 12, viewBox: "0 0 20 20", fill: "currentColor", "aria-hidden": true }, +- (0, import_react3.createElement)( +- "g", +- { transform: "rotate(45 10 10)" }, +- (0, import_react3.createElement)("path", { d: "M8 2.5h4a.9.9 0 0 1 0 1.8h-.4v4.1c0 1.4 1.1 2.6 2.6 3.1l.7.3v1.5H5.1v-1.5l.7-.3c1.5-.5 2.6-1.7 2.6-3.1V4.3H8a.9.9 0 0 1 0-1.8z" }), +- (0, import_react3.createElement)("path", { d: "M9.35 13.3h1.3v3.6L10 18l-.65-1.1z" }) ++function AddSecret({ actions, run, onDone }) { ++ const [name, setName] = (0, import_react7.useState)(""); ++ const [purpose, setPurpose] = (0, import_react7.useState)(""); ++ const [value, setValue] = (0, import_react7.useState)(""); ++ const ready = /^[A-Za-z][\w-]{0,63}$/.test(name.trim()) && value.trim().length >= MIN_VALUE; ++ const save = () => { ++ if (!ready) return; ++ void run(actions.secretSet({ name, purpose, value, scope: "all" })).then((ok) => { ++ if (ok) { ++ setValue(""); ++ onDone(); ++ } ++ }); ++ }; ++ return (0, import_react7.createElement)( ++ "div", ++ { className: "bt-set-row bt-set-paste bt-secret-add" }, ++ (0, import_react7.createElement)("input", { className: "bt-input", "aria-label": t("Key name"), placeholder: t("NAME, e.g. GITHUB_TOKEN"), value: name, spellCheck: false, autoComplete: "off", autoFocus: true, onChange: (event) => setName(event.target.value.toUpperCase()) }), ++ (0, import_react7.createElement)("input", { className: "bt-input", "aria-label": t("What it is for"), placeholder: t("What it is for"), value: purpose, autoComplete: "off", onChange: (event) => setPurpose(event.target.value) }), ++ (0, import_react7.createElement)("label", { className: "bt-q-field bt-secret-field" }, (0, import_react7.createElement)(MaskedInput, { value, onChange: setValue, onEnter: save, label: t("Value") })), + (0, import_react7.createElement)( + "div", + { className: "bt-secret-row-actions" }, + (0, import_react7.createElement)("span", { className: "bt-set-hint" }, t("New keys are for all your Bots; change who may use it afterwards.")), + (0, import_react7.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: onDone }, t("Cancel")), + (0, import_react7.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: !ready, onClick: save }, t("Save key")) -+ ) -+ ); -+} + ) + ); + } +-var UnreadIcon = lineIcon(["M10 3.5a4.5 4.5 0 0 0-4.5 4.5v3L4 13.5h12L14.5 11V8", "M8.3 16a1.8 1.8 0 0 0 3.4 0", "M15.5 3.2a1.6 1.6 0 1 1 0 3.2 1.6 1.6 0 0 1 0-3.2z"]); +-var PencilIcon = lineIcon(["M9 4H6a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2v-3", "M14.3 3.7a1.6 1.6 0 0 1 2.3 2.3L10.5 12l-3 .8.8-3z"]); +-var InfoIcon = lineIcon(["M10 3.5a6.5 6.5 0 1 1 0 13 6.5 6.5 0 0 1 0-13z", "M10 9.25v4", "M10 6.6h.01"]); +-var StarLineIcon = lineIcon(["M10 3l2.1 4.3 4.7.7-3.4 3.3.8 4.7L10 13.8 5.8 16l.8-4.7L3.2 8l4.7-.7z"]); +-var CopyIcon = lineIcon(["M8.5 7h6A1.5 1.5 0 0 1 16 8.5v6a1.5 1.5 0 0 1-1.5 1.5h-6A1.5 1.5 0 0 1 7 14.5v-6A1.5 1.5 0 0 1 8.5 7z", "M4 12.5V5.5A1.5 1.5 0 0 1 5.5 4H13"]); +-var HideIcon = lineIcon(["M3 3l14 14", "M8.6 5.2A7.6 7.6 0 0 1 10 5c4.5 0 7 5 7 5a12.6 12.6 0 0 1-2 2.6M12.2 14.5A6.8 6.8 0 0 1 10 15c-4.5 0-7-5-7-5a12.4 12.4 0 0 1 3-3.4", "M8.5 8.5a2 2 0 0 0 3 3"]); +-var TrashIcon = lineIcon(["M3.5 5.5h13", "M8 5.5V4a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1v1.5", "M5 5.5l.7 10A1.5 1.5 0 0 0 7.2 17h5.6a1.5 1.5 0 0 0 1.5-1.5l.7-10", "M8.5 9v4.5M11.5 9v4.5"]); +-var DuplicateIcon = lineIcon(["M7 7h8.5v9.5H7z", "M4.5 13V3.5H13", "M11.25 9.5v4.5M9 11.75h4.5"]); +-var SlidersIcon = lineIcon(["M4 6.5h5M12.5 6.5H16M4 13.5h4M11.5 13.5H16", "M10.5 4.5v4M9.5 11.5v4"]); +-var MemoryIcon = lineIcon(["M5.5 3.5h8A1.5 1.5 0 0 1 15 5v10a1.5 1.5 0 0 1-1.5 1.5h-8z", "M5.5 3.5v13", "M8.5 7.5h3.5M8.5 10.5h2.5", "M12 3.5v4l1-.8 1 .8"]); +-var FolderIcon = lineIcon(["M3 6A1.5 1.5 0 0 1 4.5 4.5h3.2l1.8 2h6A1.5 1.5 0 0 1 17 8v6.5a1.5 1.5 0 0 1-1.5 1.5h-11A1.5 1.5 0 0 1 3 14.5z"]); +-var ChatsIcon = lineIcon(["M3.5 5.5A1.5 1.5 0 0 1 5 4h7a1.5 1.5 0 0 1 1.5 1.5V10A1.5 1.5 0 0 1 12 11.5H8L5 14v-2.5A1.5 1.5 0 0 1 3.5 10z", "M13.5 7.5H15A1.5 1.5 0 0 1 16.5 9v4.5A1.5 1.5 0 0 1 15 15v2l-2.5-2h-3A1.5 1.5 0 0 1 8 13.5V13"]); +-var SETTINGS_ICONS = { general: GearIcon, bots: PeopleIcon, groups: ChatsIcon, usage: GaugeIcon }; +function SecretsPage({ roster, actions, run, Section }) { + const [open, setOpen] = (0, import_react7.useState)(null); + const [adding, setAdding] = (0, import_react7.useState)(false); @@ -6045,24 +4797,30 @@ diff --git a/client.js b/client.js +.bt-secret-danger svg{width:13px;height:13px} +.bt-secret-add .bt-input{height:34px} +@media (prefers-reduced-motion:reduce){.bt-secret-glide{transition:none}.bt-secret-check,.bt-secret-done,.bt-secret-ok,.bt-secret-edit{animation:none}} - `; --function installStyles(text, tag) { -- const style = document.createElement("style"); -- style.dataset.dshBot = tag; -- style.textContent = text; -- document.head.appendChild(style); -- return style; --} --var FRAGMENT_SELECTOR = /\[class\*="(_[\w-]+)"\]/g; --var CLASS_TOKEN = /\.((?:\\.|[\w-])+)/g; --function sheetClassNames(sheet) { -- const names = []; -- const visit = (rules) => { -- for (const rule of rules) { -- if (rule.selectorText) for (const match of rule.selectorText.matchAll(CLASS_TOKEN)) names.push(match[1]); -- if (rule.cssRules) visit(rule.cssRules); -- } -+ ++`; + +-// src/client/questions.js +-var import_react4 = require("react"); +-var keyLetter = (index) => String.fromCharCode(65 + index); +-var optionsOf = (question) => (question?.options ?? []).map((option) => typeof option === "string" ? { label: option } : option); +-var isTypingTarget = (node) => node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement || node instanceof HTMLSelectElement || node instanceof HTMLElement && node.isContentEditable; +-function useOptionKeys(count, onPick, ownRef) { +- const pick2 = (0, import_react4.useRef)(onPick); +- pick2.current = onPick; +- (0, import_react4.useEffect)(() => { +- if (count === 0) return void 0; +- const onKey = (event) => { +- if (event.defaultPrevented || event.metaKey || event.ctrlKey || event.altKey || event.shiftKey || event.key.length !== 1) return; +- if (isTypingTarget(event.target) || isTypingTarget(document.activeElement)) return; +- if (document.querySelector('[role="dialog"], [aria-modal="true"]') !== null) return; +- const index = event.key.toUpperCase().charCodeAt(0) - 65; +- if (index >= 0 && index < count) { +- event.preventDefault(); +- pick2.current(index); +- } else if (index === count && ownRef.current) { +- event.preventDefault(); +- ownRef.current.focus(); +- } +// src/client/face-seats.js +function faceSeats(room2, roster, { left = false } = {}) { + const records = (room2.presentedParticipants ?? room2.participants ?? []).filter((p) => p.kind !== "human"); @@ -6075,26 +4833,54 @@ diff --git a/client.js b/client.js + person: local ? seatOwnerName(p.ownerName, p.handle) || cleanPeerName(roster.self?.displayName, "") : ownerNameOf(p), + bot: local ? local.name : remoteBotName(p, "Bot"), + mine: Boolean(local) -+ }; + }; +- document.addEventListener("keydown", onKey, true); +- return () => document.removeEventListener("keydown", onKey, true); +- }, [count, ownRef]); +-} +-function QuestionView({ question, onAnswer, onDismiss, ownAnswer = false, raised = false }) { +- const [own, setOwn] = (0, import_react4.useState)(""); +- const [picked, setPicked] = (0, import_react4.useState)([]); +- const ownRef = (0, import_react4.useRef)(null); +- const titleId = (0, import_react4.useId)(); +- const options = optionsOf(question); +- const multi = question.multiSelect === true; +- const custom = own.trim(); +- const canSubmit = multi ? picked.length > 0 || custom !== "" : custom !== ""; +- const choose = (label) => { +- if (multi) { +- setPicked((current) => current.includes(label) ? current.filter((item) => item !== label) : [...current, label]); +- return; +- } +- onAnswer({ selected: [label] }); +- }; +- const submit = () => { +- if (!canSubmit) return; +- onAnswer({ selected: multi ? picked : [], ...custom ? { custom } : {} }); }; -- try { -- visit(sheet.cssRules); -- } catch { +- useOptionKeys(options.length, (index) => choose(options[index].label), ownRef); +- const ownLetter = options.length > 0 ? keyLetter(options.length) : void 0; +- const field = ownAnswer ? (0, import_react4.createElement)("textarea", { +- ref: ownRef, +- rows: 1, +- value: own, +- autoComplete: "off", +- spellCheck: false, +- className: "bt-q-input", +- "aria-label": t("Your own answer"), +- placeholder: t("Type your own answer"), +- "aria-keyshortcuts": ownLetter?.toLowerCase(), +- onChange: (event) => setOwn(event.target.value), +- onKeyDown: (event) => { +- if (event.key !== "Enter" || event.shiftKey || event.nativeEvent.isComposing) return; +- event.preventDefault(); +- submit(); + const seats = records.filter((p) => p.leftSeq === void 0).map(seat); + for (const id of room2.members ?? []) { + const bot = roster.byId[id]; + if (!bot || seats.some((item) => item.local?.id === id)) continue; + seats.push({ key: id, local: bot, record: { id }, person: cleanPeerName(roster.self?.displayName, ""), bot: bot.name, mine: true }); - } -- return names; --} --function compileFragmentSelectors(text, names) { -- const compiled = /* @__PURE__ */ new Map(); -- return text.replace(FRAGMENT_SELECTOR, (whole, fragment) => { -- if (!compiled.has(fragment)) { -- const exact = []; -- for (const name of names) if (name.includes(fragment)) exact.push(`.${name}`); -- compiled.set(fragment, exact.length ? `:is(${exact.join(",")})` : whole); ++ } + if (!left) return seats; + const gone = records.filter((p) => p.leftSeq !== void 0 && !seats.some((item) => item.key === (p.handle || p.id))).map((item) => ({ ...seat(item), left: true })); + return [...seats, ...gone]; @@ -6126,25 +4912,43 @@ diff --git a/client.js b/client.js + known.seats.push(seat); + continue; } -- return compiled.get(fragment); +- }) : null; +- const rows = options.map((option, optionIndex) => { +- const selected = multi && picked.includes(option.label); +- return (0, import_react4.createElement)( +- "button", +- { +- key: option.label, +- type: "button", +- className: "bt-q-opt", +- "aria-pressed": multi ? selected : void 0, +- "aria-keyshortcuts": keyLetter(optionIndex).toLowerCase(), +- onClick: () => choose(option.label) +- }, +- (0, import_react4.createElement)("span", { className: "bt-key", "aria-hidden": true }, keyLetter(optionIndex)), +- (0, import_react4.createElement)( +- "span", +- { className: "bt-q-body" }, +- (0, import_react4.createElement)("span", { className: "bt-q-label" }, option.label), +- option.description ? (0, import_react4.createElement)("span", { className: "bt-q-desc" }, option.description) : null +- ), +- selected && !raised ? (0, import_react4.createElement)("span", { className: "bt-q-check", title: t("Selected") }, (0, import_react4.createElement)(CheckIcon)) : null +- ); - }); +- if (raised && field) { +- rows.push((0, import_react4.createElement)( +- "label", +- { key: "own", className: "bt-q-opt bt-q-ownrow", "data-filled": custom !== "" ? "" : void 0 }, +- ownLetter ? (0, import_react4.createElement)("span", { className: "bt-key", "aria-hidden": true }, ownLetter) : null, +- (0, import_react4.createElement)("span", { className: "bt-q-body" }, field) +- )); + people2.push({ key, name: seat.person || (seat.mine ? cleanPeerName(roster.self?.displayName, "") : "") || t("Someone"), mine: seat.mine, seats: [seat] }); -+ } + } +- let foot = null; +- if (raised && multi) { +- foot = (0, import_react4.createElement)( + return [...people2.filter((person) => person.mine), ...people2.filter((person) => !person.mine)]; - } --function installCompiledStyles(text, tag) { -- const style = installStyles("", tag); -- const cache = /* @__PURE__ */ new WeakMap(); -- const render = () => { -- const names = /* @__PURE__ */ new Set(); -- for (const sheet of document.styleSheets) { -- if (sheet.ownerNode?.dataset?.dshBot) continue; -- let list = cache.get(sheet); -- if (!list) { -- list = sheetClassNames(sheet); -- cache.set(sheet, list); -- } -- for (const name of list) names.add(name); ++} +function facePersonLabel(person) { + return person.mine ? t("{name} (you)", { name: person.name }) : person.name; +} @@ -6194,85 +4998,93 @@ diff --git a/client.js b/client.js + setError(failure?.message ?? String(failure)); + } finally { + setBusy(false); - } -- const next = compileFragmentSelectors(text, names); -- if (style.textContent !== next) style.textContent = next; -- }; -- render(); -- let timer; -- const schedule = () => { -- clearTimeout(timer); -- timer = setTimeout(render, 120); -- }; -- const onLoad = (event) => { -- if (event.target instanceof HTMLLinkElement) schedule(); -- }; -- const observer = new MutationObserver(schedule); -- observer.observe(document.head, { childList: true }); -- document.addEventListener("load", onLoad, true); -- return () => { -- observer.disconnect(); -- document.removeEventListener("load", onLoad, true); -- clearTimeout(timer); -- style.remove(); - }; ++ } ++ }; + if (!waiting) { + const failed = held.status === "failed"; + return (0, import_react8.createElement)( -+ "div", + "div", +- { className: "bt-q-foot" }, +- (0, import_react4.createElement)("button", { type: "button", className: "bt-q-btn", onClick: onDismiss }, t("Skip")), +- (0, import_react4.createElement)("button", { type: "submit", className: "bt-q-submit", disabled: !canSubmit }, t("Submit")) + { className: "bt-held-note", "data-state": held.status, title: failed && held.error ? held.error : void 0 }, + (0, import_react8.createElement)("span", null, held.status === "approved" ? t("Sent to {to}", { to: held.toName }) : held.status === "auto" ? t("Auto-sent to {to}", { to: held.toName }) : failed ? t("Not sent: delivery failed") : t("Not sent")), + // #968: the terminal answer comes from one predicate, not from `status === 'failed'` here and a + // different field elsewhere. A failure that can never succeed draws no button on any surface. + failed && canRetry(held) ? (0, import_react8.createElement)("button", { type: "button", className: "bt-held-retry", disabled: busy, onClick: () => decide("retry") }, t("Retry")) : null, + error ? (0, import_react8.createElement)("span", { className: "bt-flybot-approval-error", role: "alert" }, error) : null -+ ); -+ } + ); +- } else if ((raised || multi) && canSubmit) { +- foot = (0, import_react4.createElement)("div", { className: "bt-q-foot" }, (0, import_react4.createElement)("button", { type: "submit", className: "bt-q-submit" }, t("Submit"))); + } +- return (0, import_react4.createElement)( +- "form", +- { +- className: raised ? "bt-q bt-q-raised" : "bt-q", +- "aria-labelledby": titleId, +- onSubmit: (event) => { +- event.preventDefault(); +- submit(); +- } +- }, +- (0, import_react4.createElement)( + const plain = (0, import_react8.createElement)( + "div", + { className: "bt-held-card", role: "region", "aria-label": t("Waiting for your approval") }, + (0, import_react8.createElement)("div", { className: "bt-held-head" }, t("Waiting for your approval")), + (0, import_react8.createElement)("div", { className: "bt-flybot-approval-origin" }, t("{from} → {to}", { from: held.from ?? t("A Bot"), to: held.toName })), + (0, import_react8.createElement)( -+ "div", + "div", +- { className: "bt-q-head" }, +- (0, import_react4.createElement)( +- "div", +- { className: "bt-q-text" }, +- (0, import_react4.createElement)("p", { className: "bt-q-title", id: titleId }, question.header ? `${question.header} · ` : "", question.question), +- question.detail ? (0, import_react4.createElement)("div", { className: "bt-q-detail" }, question.detail) : null, +- raised && multi ? (0, import_react4.createElement)("div", { className: "bt-q-detail" }, t("Choose all that apply")) : null +- ), +- (0, import_react4.createElement)("button", { type: "button", className: "bt-q-x", "aria-label": t("Dismiss question"), title: t("Dismiss"), onClick: onDismiss }, (0, import_react4.createElement)(CloseIcon)) + { className: "bt-flybot-approval-actions" }, + (0, import_react8.createElement)("button", { type: "button", disabled: busy, onClick: () => decide("reject") }, t("Reject")), + (0, import_react8.createElement)("button", { type: "button", className: "bt-flybot-approve", disabled: busy, onClick: () => decide("approve") }, t("Approve")) -+ ), + ), +- rows.length > 0 ? (0, import_react4.createElement)("div", { className: "bt-q-list", role: "group" }, rows) : null, +- field && !raised ? (0, import_react4.createElement)( +- "div", +- { className: "bt-q-custom" }, +- (0, import_react4.createElement)("label", { className: "bt-q-field" }, field), +- !multi && canSubmit ? (0, import_react4.createElement)("button", { type: "submit", className: "bt-q-submit" }, t("Submit")) : null +- ) : null, +- foot + error ? (0, import_react8.createElement)("div", { className: "bt-flybot-approval-error", role: "alert" }, error) : null + ); + return (0, import_react8.createElement)( + "div", + { className: "bt-held-actions", "data-flybot": "held-reply-actions" }, + custom ? (0, import_react8.createElement)(boundary(), { id: custom.id, fallback: plain }, (0, import_react8.createElement)(custom.component, { held, decide, call: call3, actions })) : plain -+ ); + ); } - --// src/client/mark.js --var import_react2 = require("react"); --var STATES = ["idle", "thinking", "searching", "working", "sending", "orbit", "alert", "done"]; --var botState = (running, activity, alert) => running ? STATES.includes(activity) ? activity : "working" : alert ? "alert" : "idle"; --var toolState = (name) => /^(message_bot|stop_bot|create_bot|update_bot|create_group|update_group|delete_group|post_to_group)$/.test(name) ? "sending" : /search|fetch|browse|read|grep|glob|list|recall/i.test(name) ? "searching" : "working"; --function useGaze(ref, enabled) { -- (0, import_react2.useEffect)(() => { -- if (!enabled) return void 0; -- let frame = 0; -- let point = null; -- const place = () => { -- frame = 0; -- const node = ref.current; -- if (!node || !point) return; -- const box = node.getBoundingClientRect(); -- const clamp = (value) => Math.max(-1, Math.min(1, value)); -- node.style.setProperty("--bt-gx", clamp((point.x - box.left - box.width / 2) / 160).toFixed(2)); -- node.style.setProperty("--bt-gy", clamp((point.y - box.top - box.height / 2) / 160).toFixed(2)); -- }; -- const onMove = (event) => { -- point = { x: event.clientX, y: event.clientY }; -- if (!frame) frame = requestAnimationFrame(place); -+// src/client/turn-reasoning.js -+function turnReasoning(steps, current) { -+ const members = steps.length ? steps : [current]; +-function SettledQuestion({ question, answer }) { +- const options = optionsOf(question); +- const prompt = question?.question ?? ""; +- const chosen = [...answer.selected ?? [], ...answer.custom ? [answer.custom] : []]; +- if (chosen.length === 0) { +- return (0, import_react4.createElement)( +- "div", +- { className: "bt-q bt-q-settled bt-q-dismissed", role: "group", "aria-label": prompt }, +- (0, import_react4.createElement)( +- "div", +- { className: "bt-q-head" }, +- (0, import_react4.createElement)("div", { className: "bt-q-text" }, (0, import_react4.createElement)("p", { className: "bt-q-title" }, prompt)), +- (0, import_react4.createElement)("span", { className: "bt-q-badge" }, t("Dismissed")) +- ) +- ); +- } +- return (0, import_react4.createElement)( ++ ++// src/client/turn-reasoning.js ++function turnReasoning(steps, current) { ++ const members = steps.length ? steps : [current]; + if (members.find((step) => stepReasoning(step) !== "")?.step !== current.step) return ""; + return members.filter((step) => !thinkingLive(step)).map(stepReasoning).filter(Boolean).join("\n\n"); +} @@ -6295,7 +5107,12 @@ diff --git a/client.js b/client.js + }; + const reactions = useUi ? useUi((state) => msgKey ? state.reactions?.[msgKey] ?? null : null) : null; + return (0, import_react9.createElement)( -+ "div", + "div", +- { className: "bt-q bt-q-settled", role: "group", "aria-label": prompt }, +- prompt ? (0, import_react4.createElement)("div", { className: "bt-q-head" }, (0, import_react4.createElement)("div", { className: "bt-q-text" }, (0, import_react4.createElement)("p", { className: "bt-q-title" }, prompt))) : null, +- (0, import_react4.createElement)("div", { className: "bt-q-list", role: "group", "aria-label": t("Your answer") }, chosen.map((label, index) => { +- const optionIndex = options.findIndex((option) => option.label === label); +- return (0, import_react4.createElement)( + { className: "bt-astack", "data-lead": lead ? "" : void 0, onClickCapture: onClick }, + texts2.map((text, index) => { + const key = msgKey ? `${msgKey}:${index}` : void 0; @@ -6303,7 +5120,11 @@ diff --git a/client.js b/client.js + const last = index === texts2.length - 1; + const live = streaming && last; + return (0, import_react9.createElement)( -+ "div", + "div", +- { key: `${label}-${index}`, className: "bt-q-opt" }, +- optionIndex >= 0 ? (0, import_react4.createElement)("span", { className: "bt-key", "aria-hidden": true }, keyLetter(optionIndex)) : null, +- (0, import_react4.createElement)("span", { className: "bt-q-body" }, (0, import_react4.createElement)("span", { className: "bt-q-label" }, label)), +- (0, import_react4.createElement)("span", { className: "bt-q-check", title: t("Selected") }, (0, import_react4.createElement)(CheckIcon)) + { key: index, className: "bt-msg" }, + (0, import_react9.createElement)( + "div", @@ -6323,10 +5144,23 @@ diff --git a/client.js b/client.js + "aria-label": t("Remove reaction {emoji}", { emoji }), + onClick: () => actions.toggleReaction(msgKey, index, emoji) + }, emoji))) : null -+ ); + ); +- })) + }) -+ ); -+} + ); + } +-function QuestionCard({ matched }) { +- const pending = matched; +- const questions = pending.questions ?? []; +- const [index, setIndex] = (0, import_react4.useState)(0); +- const [answers, setAnswers] = (0, import_react4.useState)([]); +- const question = questions[index]; +- if (question === void 0) return null; +- const onAnswer = (answer) => { +- const next = [...answers, { id: question.id, ...answer }]; +- if (index + 1 < questions.length) { +- setAnswers(next); +- setIndex(index + 1); +var WAITING_OTHERS_NOTICE = "Waiting for the owner's approval"; +var REACTIONS = ["👍", "❤️", "😂", "😮", "😢", "🙏"]; +function leaveNoticeCoveredByClose(room2, text) { @@ -6347,56 +5181,21 @@ diff --git a/client.js b/client.js + const onDown = (event) => { + if (refs.some((ref) => ref.current?.contains(event.target))) return; + close(); - }; -- const onLeave = () => { -- point = null; -- ref.current?.style.setProperty("--bt-gx", "0"); -- ref.current?.style.setProperty("--bt-gy", "0"); ++ }; + const onKey = (event) => { + if (event.key === "Escape") { + event.stopPropagation(); + close(); + } - }; -- window.addEventListener("pointermove", onMove, { passive: true }); -- document.documentElement.addEventListener("pointerleave", onLeave); ++ }; + window.addEventListener("mousedown", onDown); + window.addEventListener("keydown", onKey, true); - return () => { -- cancelAnimationFrame(frame); -- window.removeEventListener("pointermove", onMove); -- document.documentElement.removeEventListener("pointerleave", onLeave); ++ return () => { + window.removeEventListener("mousedown", onDown); + window.removeEventListener("keydown", onKey, true); - }; -- }, [enabled]); ++ }; + }, [open]); - } --var FIDGETS = ["hop", "sway", "stretch", "look"]; --function BotMark({ bot, look: given, size = 36, state = "idle", live = false, gaze = false, pokeable = false, label }) { -- const look = given ?? lookOf(bot); -- const ref = (0, import_react2.useRef)(null); -- const maskId = (0, import_react2.useRef)(""); -- if (maskId.current === "") maskId.current = `bt-mark-${nextMarkSerial()}`; -- const [poked, setPoked] = (0, import_react2.useState)(false); -- useGaze(ref, gaze); -- const common = { -- ref, -- className: poked ? "bt-mark bt-mark-poke" : "bt-mark", -- "data-shape": look.image ? "image" : shapeId(look.shape), -- "data-state": STATES.includes(state) ? state : "idle", -- "data-live": live ? "" : void 0, -- "data-fidget": live ? FIDGETS[fnv1a(`${bot?.id ?? ""}:fidget`) % FIDGETS.length] : void 0, -- "data-gaze": gaze ? "" : void 0, -- "data-poke": pokeable ? "" : void 0, -- style: { width: size, height: size, ...live ? { "--bt-delay": `${-(fnv1a(String(bot?.id ?? "")) % 5e3) / 1e3}s`, "--bt-fidget-delay": `${-(fnv1a(`${bot?.id ?? ""}:when`) % 9e3) / 1e3}s` } : {} }, -- role: label ? "img" : void 0, -- "aria-label": label, -- "aria-hidden": label ? void 0 : true, -- onClick: pokeable ? () => setPoked(true) : void 0, -- onAnimationEnd: poked ? (event) => { -- if (/^bt-poke/.test(event.animationName)) setPoked(false); -- } : void 0 ++} +function MessageTools({ text, name, sessionId, react, actions }) { + const [panel, setPanel] = (0, import_react9.useState)(null); + const [place, setPlace] = (0, import_react9.useState)(null); @@ -6408,34 +5207,46 @@ diff --git a/client.js b/client.js + const open = (kind, event) => { + if (panel === kind) { + close(); -+ return; -+ } + return; + } +- pending.answer({ answers: next }); + const rect = event.currentTarget.getBoundingClientRect(); + const below = rect.bottom + 220 < window.innerHeight; + setPlace(kind === "react" ? { left: rect.left + rect.width / 2, top: rect.top - 6, "--bt-origin": "bottom center" } : { left: Math.min(rect.left, window.innerWidth - 208), ...below ? { top: rect.bottom + 4 } : { bottom: window.innerHeight - rect.top + 4, "--bt-origin": "bottom left" } }); + setPanel(kind); }; -- if (look.image) return (0, import_react2.createElement)("span", common, (0, import_react2.createElement)("img", { className: "bt-mark-img", src: look.image, alt: "", draggable: false })); -- const box = shapeOf(look.shape).size; -- return (0, import_react2.createElement)("span", common, (0, import_react2.createElement)("svg", { viewBox: `0 0 ${box} ${box}`, dangerouslySetInnerHTML: { __html: characterMarkup(look.shape, look.color).replaceAll(MASK_ID, maskId.current) } })); +- return (0, import_react4.createElement)(QuestionView, { key: index, question, onAnswer, onDismiss: () => pending.dismiss(), ownAnswer: true, raised: true }); +-} +-function PendingQuestion({ sessionId, question, actions, inline }) { +- const [answered, setAnswered] = (0, import_react4.useState)(null); +- if (answered === question.id) return null; +- const onAnswer = (answer) => { +- setAnswered(question.id); +- void actions.answerQuestion(sessionId, question.id, { selected: answer.selected ?? [], custom: answer.custom ?? "" }).catch(() => setAnswered(null)); + const copy2 = () => { + void navigator.clipboard?.writeText(text).then(() => { + setCopied(true); + setTimeout(() => setCopied(false), 1e3); + }); -+ }; + }; +- const onDismiss = () => { +- setAnswered(question.id); +- void actions.dismissQuestion(sessionId); + const download2 = () => { + const url = URL.createObjectURL(new Blob([text], { type: "text/markdown" })); + const link = Object.assign(document.createElement("a"), { href: url, download: `${name}-message.md` }); + link.click(); + setTimeout(() => URL.revokeObjectURL(url), 1e3); -+ }; + }; +- return (0, import_react4.createElement)( + const item = (label, Icon, run) => (0, import_react9.createElement)("button", { key: label, type: "button", role: "menuitem", onClick: () => { + close(); + run(); + } }, (0, import_react9.createElement)(Icon), label); + return (0, import_react9.createElement)( -+ "div", + "div", +- { className: inline ? "bt-q-inline" : "bt-q-dock" }, +- (0, import_react4.createElement)(QuestionView, { key: question.id, question, onAnswer, onDismiss, ownAnswer: question.allowCustom === true, raised: !inline }) + { ref: tools, className: "bt-tools", "data-open": panel !== null || void 0 }, + react ? (0, import_react9.createElement)("button", { type: "button", className: "bt-tool", "aria-label": t("Add reaction"), title: t("Add reaction"), "aria-expanded": panel === "react", onClick: (event) => open("react", event) }, (0, import_react9.createElement)(SmileIcon)) : null, + sessionId ? (0, import_react9.createElement)("button", { type: "button", className: "bt-tool", "aria-label": t("Reply"), title: t("Reply"), onClick: () => actions.quote(sessionId, text) }, (0, import_react9.createElement)(ReplyIcon)) : null, @@ -6454,13 +5265,14 @@ diff --git a/client.js b/client.js + item(t("Copy"), CopyIcon, copy2), + item(t("Download"), DownloadIcon, download2) + ), document.body) : null -+ ); + ); } --function StarIcon({ size = 8 }) { -- return (0, import_react2.createElement)( -- "svg", -- { width: size, height: size, viewBox: "0 0 10 10", "aria-hidden": true }, -- (0, import_react2.createElement)("path", { d: "M5 .6l1.3 2.8 3 .3-2.3 2 .7 3L5 7.2 2.3 8.7l.7-3L.7 3.7l3-.3z", fill: "#fff" }) +-function QuestionDock({ sessionId, useRoster, useCards, useSession, actions }) { +- const question = useRoster((value) => value.questions?.[sessionId]); +- const drawn = useCards?.((state) => state[sessionId]?.[question?.callId] > 0) === true; +- const loading = useSession?.(historyLoading) === true; +- if (question === void 0 || question.callId && (drawn || loading)) return null; +- return (0, import_react4.createElement)(PendingQuestion, { sessionId, question, actions, inline: false }); +function ThinkingRow({ text }) { + if (typeof text !== "string" || text.trim() === "") return null; + return (0, import_react9.createElement)( @@ -6468,10 +5280,20 @@ diff --git a/client.js b/client.js + { className: "bt-think" }, + (0, import_react9.createElement)("summary", null, (0, import_react9.createElement)("span", { className: "bt-think-caret", "aria-hidden": true }, "›"), t("Thought it through")), + (0, import_react9.createElement)("div", { className: "bt-think-text" }, text) - ); ++ ); } --function AdminStar() { -- return (0, import_react2.createElement)("span", { className: "bt-admin-star", role: "img", "aria-label": t("Admin") }, (0, import_react2.createElement)(StarIcon, { size: 7 })); +-function AnsweredCell({ node }) { +- const data = node.data ?? {}; +- const questions = Array.isArray(data.questions) ? data.questions : []; +- if (questions.length === 0) { +- return data.text ? (0, import_react4.createElement)("div", { className: "bt-urow" }, (0, import_react4.createElement)("div", { className: "bt-ububble" }, data.text)) : null; +- } +- const answers = Array.isArray(data.answers) ? data.answers : []; +- return (0, import_react4.createElement)("div", { className: "bt-q-inline" }, questions.map((question) => (0, import_react4.createElement)(SettledQuestion, { +- key: question.id, +- question, +- answer: answers.find((answer) => answer.id === question.id) ?? { selected: [] } +- }))); +function useFileMentions({ node, openFile, fileMentions, useTurnData }) { + const tail = useTurnData?.("turn-tail"); + const location2 = node.location; @@ -6482,17 +5304,81 @@ diff --git a/client.js b/client.js + return tail?.closing?.finalNode.seq === seq ? fileMentions({ turn, seq, openFile }) : void 0; + }, [fileMentions, openFile, seq, tail, turn]); } --function MainStar() { -- return (0, import_react2.createElement)( -- "svg", -- { viewBox: "0 0 20 20", "aria-hidden": true }, -- (0, import_react2.createElement)("path", { d: "M10 2.4l2.29 5.04 5.51.63-4.09 3.74 1.11 5.42L10 14.5l-4.82 2.73 1.11-5.42L2.2 8.07l5.51-.63z" }) +- +-// src/client/secret-card.js +-var import_react5 = require("react"); +-var MIN_VALUE = 4; +-var NONE = {}; +-var KeyIcon = () => (0, import_react5.createElement)( +- "svg", +- { width: 15, height: 15, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, +- (0, import_react5.createElement)("path", { d: "M7 9.5a3.5 3.5 0 1 1 0 .01z" }), +- (0, import_react5.createElement)("path", { d: "M10.4 10.6L17 10.6M14.5 10.6v2.4M16.6 10.6v1.8" }) +-); +-var LockIcon = () => (0, import_react5.createElement)( +- "svg", +- { width: 14, height: 14, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, +- (0, import_react5.createElement)("path", { d: "M6.5 9V7a3.5 3.5 0 0 1 7 0v2" }), +- (0, import_react5.createElement)("rect", { x: 4.5, y: 9, width: 11, height: 8, rx: 2 }) +-); +-var WarnIcon = () => (0, import_react5.createElement)( +- "svg", +- { width: 14, height: 14, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, +- (0, import_react5.createElement)("path", { d: "M10 3.5l7 12.5H3z" }), +- (0, import_react5.createElement)("path", { d: "M10 8.5v3.5M10 14.2h.01" }) +-); +-var MASK_BY_CSS = typeof CSS !== "undefined" && typeof CSS.supports === "function" && CSS.supports("-webkit-text-security", "disc"); +-var insecure = () => typeof location !== "undefined" && location.protocol === "http:" && !/^(localhost|127(?:\.\d{1,3}){3}|\[::1\])$/.test(location.hostname); +-var scopeLabel = (scope, roster) => { +- if (scope === "all") return t("All Bots"); +- const names = (scope ?? []).map((id) => roster.byId[id]?.name).filter(Boolean); +- return names.length === 0 ? t("No Bot") : names.join(t(" and ")); +-}; +-var when = (time) => Number.isFinite(time) ? new Date(time).toLocaleString(dateLocale(), { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }) : ""; +-function MaskedInput({ value, onChange, onEnter, label, autoFocus = false }) { +- return (0, import_react5.createElement)("input", { +- className: "bt-secret-input", +- type: MASK_BY_CSS ? "text" : "password", +- "data-masked": MASK_BY_CSS ? "" : void 0, +- value, +- autoFocus, +- autoComplete: MASK_BY_CSS ? "off" : "new-password", +- autoCorrect: "off", +- autoCapitalize: "off", +- spellCheck: false, +- "data-1p-ignore": "", +- "data-lpignore": "true", +- "data-bwignore": "", +- "data-form-type": "other", +- "aria-label": label, +- placeholder: t("Paste or type the key"), +- onChange: (event) => onChange(event.target.value), +- onKeyDown: (event) => { +- if (event.key !== "Enter" || event.nativeEvent.isComposing) return; +- event.preventDefault(); +- onEnter?.(); +- } +- }); +function useClosingText(node, useTurnData) { + const tail = useTurnData?.("turn-tail"); + const seq = node.data.finalNode?.seq; + if (!tail?.closing || seq === void 0) return true; + return tail.closing.finalNode?.seq === seq; -+} + } +-function ScopeSwitch({ value, onChange, bot }) { +- const options = [["all", t("All Bots")], ["bot", t("Only {name}", { name: bot.name })]]; +- return (0, import_react5.createElement)( +- "div", +- { className: "bt-secret-scope", role: "radiogroup", "aria-label": t("Who may use it") }, +- (0, import_react5.createElement)("span", { className: "bt-secret-glide", "data-at": value === "all" ? 0 : 1, "aria-hidden": true }), +- options.map(([id, text]) => (0, import_react5.createElement)("button", { +- key: id, +- type: "button", +- role: "radio", +- "aria-checked": value === id, +- className: "bt-secret-seg", +- onClick: () => onChange(id) +- }, text)) +function friendLead(bot, texts2) { + if (bot?.remote === void 0 || texts2.length === 0) return void 0; + if (texts2.every((text) => /^Not sent:/.test(text.trim()))) return void 0; @@ -6537,65 +5423,80 @@ diff --git a/client.js b/client.js + held && bubbles ? (0, import_react9.createElement)(HeldReply, { held, roster, sessionId, actions, closing }, bubbles) : bubbles ); } --function useSettled(state) { -- const prev = (0, import_react2.useRef)(state); -- const [settled, setSettled] = (0, import_react2.useState)(false); -- (0, import_react2.useEffect)(() => { -- const was = prev.current; -- prev.current = state; -- if (state !== "idle") { -- setSettled(false); -- return void 0; -- } -- if (was === "idle" || was === "alert") return void 0; -- setSettled(true); -- const timer = setTimeout(() => setSettled(false), 1800); -- return () => clearTimeout(timer); -- }, [state]); -- return settled; +-function SecretHead({ bot, title, detail, name, onClose }) { +- return (0, import_react5.createElement)( +function HeldReply({ held, roster, sessionId, actions, closing, children }) { + const bot = roster.byId[sessionId]; + const chat = (roster.bots ?? []).find((item) => item.remote?.handle === held.to); + const toName = cleanPeerName(held.toOwner) || (chat ? friendBotOwner(chat) : ownerNameOf({ handle: held.to })) || held.to || t("a friend"); + return (0, import_react9.createElement)( -+ "div", -+ { className: "bt-held-reply", "data-state": held.status, "data-held": isApprovalWaiting(held.status) ? "" : void 0 }, -+ children, -+ closing ? (0, import_react9.createElement)(HeldReplyActions, { held: { ...held, toName, from: bot?.name }, actions }) : null -+ ); - } --function BotAvatar({ bot, size, state = "idle", main, admin, badge = true, live = true, gaze = false, pokeable = false }) { -- const settled = useSettled(state); -- if (bot === void 0) return (0, import_react2.createElement)(BotMark, { size }); -- const badgeKind = !badge ? null : state === "alert" ? "alert" : state !== "idle" ? "working" : admin ? "admin" : main ? "main" : null; -- return (0, import_react2.createElement)( -- "span", -- { className: "bt-mark", style: { width: size, height: size, "--bt-size": `${size}px` } }, -- (0, import_react2.createElement)(BotMark, { bot, size, state: settled ? "done" : state, live, gaze, pokeable }), -- badgeKind === "working" ? (0, import_react2.createElement)("span", { className: "bt-badge bt-badge-working", "aria-label": t("Working"), role: "img" }) : null, -- badgeKind === "alert" ? (0, import_react2.createElement)("span", { className: "bt-badge bt-badge-alert", "aria-label": t("Waiting for your answer"), role: "img" }, "?") : null, -- badgeKind === "admin" ? (0, import_react2.createElement)("span", { className: "bt-badge bt-badge-admin", "aria-label": t("Admin"), role: "img" }, (0, import_react2.createElement)(StarIcon)) : null, -- badgeKind === "main" ? (0, import_react2.createElement)("span", { className: "bt-badge bt-badge-main", "aria-label": t("Main Bot"), role: "img" }, (0, import_react2.createElement)(MainStar)) : null + "div", +- { className: "bt-q-head bt-secret-head" }, +- (0, import_react5.createElement)("span", { className: "bt-secret-who" }, (0, import_react5.createElement)(BotMark, { bot, size: 28 }), (0, import_react5.createElement)("span", { className: "bt-secret-lock" }, (0, import_react5.createElement)(LockIcon))), +- (0, import_react5.createElement)( +- "div", +- { className: "bt-q-text" }, +- (0, import_react5.createElement)("p", { className: "bt-q-title" }, title), +- (0, import_react5.createElement)("div", { className: "bt-q-detail" }, (0, import_react5.createElement)("code", { className: "bt-secret-name" }, name), detail ? ` · ${detail}` : "") +- ), +- onClose ? (0, import_react5.createElement)("button", { type: "button", className: "bt-q-x", "aria-label": t("Cancel the request"), title: t("Cancel"), onClick: onClose }, (0, import_react5.createElement)(CloseIcon)) : null ++ { className: "bt-held-reply", "data-state": held.status, "data-held": isApprovalWaiting(held.status) ? "" : void 0 }, ++ children, ++ closing ? (0, import_react9.createElement)(HeldReplyActions, { held: { ...held, toName, from: bot?.name }, actions }) : null + ); + } +-function SecretCard({ bot, request, roster, actions }) { +- const [value, setValue] = (0, import_react5.useState)(""); +- const [scope, setScope] = (0, import_react5.useState)("all"); +- const [replacing, setReplacing] = (0, import_react5.useState)(false); +- const [busy, setBusy] = (0, import_react5.useState)(false); +- const [error, setError] = (0, import_react5.useState)(""); +- const noteId = (0, import_react5.useId)(); +- const secret = (roster.secrets ?? []).find((entry) => entry.name === request.name); +- const run = (promise) => { +- setBusy(true); +- setError(""); +- return Promise.resolve(promise).then(() => setValue(""), (failure) => setError(failure?.message ?? String(failure))).finally(() => setBusy(false)); +- }; +- const cancel = () => run(actions.secretCancel(request.id)); +- const long = value.trim().length >= MIN_VALUE; +- const save = () => { +- if (long && !busy) void run(actions.secretSet({ requestId: request.id, value, scope })); +- }; +- const allow = () => { +- if (!busy) void run(actions.secretAllow(request.id)); +- }; +- const filling = request.kind === "fill" || replacing; +- const warn = insecure() ? (0, import_react5.createElement)("div", { className: "bt-secret-warn", role: "note" }, (0, import_react5.createElement)(WarnIcon), t("This page is not on HTTPS: the key crosses the network unencrypted.")) : null; +- return (0, import_react5.createElement)( +function PersonMessage({ name, texts: texts2, roster, selfId, actions, sessionId, msgKey, useUi }) { + const lead = (0, import_react9.createElement)("span", { className: "bt-lead", title: name }, (0, import_react9.createElement)(PersonMark, { name })); + return (0, import_react9.createElement)( -+ "div", + "div", +- { className: "bt-q bt-secret", role: "group", "aria-describedby": noteId, "data-kind": request.kind }, +- (0, import_react5.createElement)(SecretHead, { +- bot, +- title: request.kind === "allow" ? t("Let {bot} use {name}?", { bot: bot.name, name: request.name }) : t("{name} needs a key", { name: bot.name }), +- detail: request.purpose, +- name: request.name, +- onClose: busy ? void 0 : cancel +- }), +- request.kind === "allow" ? (0, import_react5.createElement)("div", { className: "bt-secret-saved" }, (0, import_react5.createElement)(KeyIcon), t("Saved for {scope}", { scope: scopeLabel(secret?.scope, roster) })) : null, +- filling ? (0, import_react5.createElement)( + { className: "bt-group-msg bt-person-msg", "data-person": name }, + (0, import_react9.createElement)("span", { className: "bt-group-name" }, name), + (0, import_react9.createElement)(Bubbles, { texts: texts2, roster, selfId, actions, sessionId, msgKey, useUi, lead }) - ); - } --function clusterSeats(count, frame) { -- if (count === 1) return [[0, 0, frame]]; -- if (count === 2) return [[0, 0, frame * 2 / 3], [frame / 3, frame / 3, frame * 2 / 3]]; -- if (count === 3) { -- const size = frame * 5 / 9; -- const rest = frame - size; -- return [[rest / 2, 0, size], [0, rest, size], [rest, rest, size]]; ++ ); ++} +function EventLine({ prefix, bots, joiner = t("and"), onOpen, whole = false }) { + if (whole && bots.length === 1) { + return (0, import_react9.createElement)( -+ "div", + "div", +- { className: "bt-secret-body" }, +- (0, import_react5.createElement)("label", { className: "bt-q-field bt-secret-field" }, (0, import_react5.createElement)(MaskedInput, { value, onChange: setValue, onEnter: save, label: t("Value of {name}", { name: request.name }), autoFocus: replacing })), +- request.kind === "fill" ? (0, import_react5.createElement)(ScopeSwitch, { value: scope, onChange: setScope, bot }) : null +- ) : null, +- (0, import_react5.createElement)( + { className: "bt-event" }, + (0, import_react9.createElement)( + "button", @@ -6605,9 +5506,7 @@ diff --git a/client.js b/client.js + (0, import_react9.createElement)("span", { className: "bt-event-name" }, bots[0].name) + ) + ); - } -- const half = frame / 2; -- return [[0, 0, half], [half, 0, half], [0, half, half], [half, half, half]]; ++ } + const parts = []; + parts.push((0, import_react9.createElement)("span", { key: "p" }, prefix)); + bots.forEach((bot, index) => { @@ -6633,18 +5532,7 @@ diff --git a/client.js b/client.js + const held = heldForm(item.text); + return held !== "" && (held === line2 || held.length >= 799 && line2.startsWith(held)); + }); - } --function RoomAvatar({ room: room2, roster, size = 36 }) { -- const members = room2.members.map((id) => roster.byId[id]).filter(Boolean); -- const people = members.length === 1 ? [members[0], null] : members; -- const seat = (bot, seatSize, key) => bot ? (0, import_react2.createElement)(BotMark, { key, bot, size: seatSize }) : (0, import_react2.createElement)("span", { key, className: "bt-cluster-you", style: { width: seatSize * 0.92, height: seatSize * 0.92, margin: seatSize * 0.04 } }); -- if (people.length === 0) return (0, import_react2.createElement)(BotMark, { size }); -- if (size <= 20 && people.length > 1) { -- return (0, import_react2.createElement)( -- "span", -- { className: "bt-stack", style: { "--bt-stack-size": `${size}px` } }, -- people.slice(0, 3).map((bot, index) => seat(bot, size, bot?.id ?? `you-${index}`)), -- people.length > 3 ? (0, import_react2.createElement)("span", { className: "bt-stack-more" }, `+${people.length - 3}`) : null ++} +var HELD_SENDING_MS = 6e4; +function heldLineState(room2, { seq, botId, text } = {}, now = Date.now()) { + if (room2?.origin !== "face-to-face" || !Array.isArray(room2.heldLines)) return void 0; @@ -6660,17 +5548,21 @@ diff --git a/client.js b/client.js + const held = heldLineState(room2, { seq, botId: bot.id, text }); + if (held === "rejected") { + return (0, import_react9.createElement)( -+ "div", + "div", +- { className: "bt-secret-note", id: noteId }, +- t("{bot} never sees the value. It reaches {bot}'s shell as ", { bot: bot.name }), +- (0, import_react5.createElement)("code", null, `$DSH_SECRET_${request.name}`), +- ".", +- filling && value !== "" && !long ? t(" At least {count} characters.", { count: MIN_VALUE }) : "" +- ), +- warn, +- error ? (0, import_react5.createElement)("div", { className: "bt-secret-error", role: "alert" }, error) : null, +- (0, import_react5.createElement)( + { className: "bt-event bt-not-sent", "data-flybot": "held-not-sent", role: "status" }, + (0, import_react9.createElement)(BotMark, { bot, size: 16 }), + (0, import_react9.createElement)("span", null, t("{name} · Not sent", { name: bot.name })) - ); - } -- const seats = clusterSeats(Math.min(people.length, 4), size); -- const overflow = people.length > 4 ? people.length - 3 : 0; -- const gap = size / 14; -- const rings = seats.map(([x, y, s]) => ({ cx: x + s / 2, cy: y + s / 2, r: s * 0.48 + gap })); -- return (0, import_react2.createElement)( ++ ); ++ } + if (held === "held" || isHeldLine(room2, text, bot)) return null; + const lead = (0, import_react9.createElement)( + "button", @@ -6678,55 +5570,18 @@ diff --git a/client.js b/client.js + (0, import_react9.createElement)(BotMark, { bot, size: 22 }) + ); + const name = (0, import_react9.createElement)( - "span", -- { className: "bt-cluster", style: { width: size, height: size } }, -- seats.map(([x, y, s], index) => { -- const holes2 = rings.slice(index + 1).filter((ring) => Math.hypot(ring.cx - x - s / 2, ring.cy - y - s / 2) < ring.r + s * 0.46).map((ring) => `radial-gradient(circle at ${ring.cx - x}px ${ring.cy - y}px,transparent ${ring.r}px,#000 ${ring.r + 0.5}px)`); -- const style = { left: x, top: y, width: s, height: s }; -- if (holes2.length > 0) Object.assign(style, { WebkitMaskImage: holes2.join(","), maskImage: holes2.join(","), WebkitMaskComposite: "source-in", maskComposite: "intersect" }); -- if (overflow > 0 && index === 3) { -- return (0, import_react2.createElement)("span", { key: "more", className: "bt-cluster-more", style: { ...style, width: s * 0.92, height: s * 0.92, margin: s * 0.04, fontSize: Math.max(8, Math.round(s * 0.36)) } }, `+${overflow}`); -- } -- const bot = people[index]; -- return (0, import_react2.createElement)("span", { key: bot?.id ?? "you", style }, seat(bot, s)); -- }) ++ "span", + { className: "bt-group-name", style: { color: inkText(colorOf(bot)) } }, + faceBotLabel(room2, roster, bot) ?? bot.name, + room2?.admin === bot.id ? (0, import_react9.createElement)("span", { className: "bt-group-role" }, t("Admin")) : null - ); --} -- --// src/client/icons.js --var import_react3 = require("react"); --function PlusIcon() { -- return (0, import_react3.createElement)("svg", { width: 18, height: 18, viewBox: "0 0 20 20", "aria-hidden": true }, (0, import_react3.createElement)("path", { d: "M10 3.5v13M3.5 10h13", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round" })); --} --function SearchIcon() { -- return (0, import_react3.createElement)("svg", { width: 16, height: 16, viewBox: "0 0 20 20", "aria-hidden": true }, (0, import_react3.createElement)("circle", { cx: 8.5, cy: 8.5, r: 5.5, stroke: "currentColor", strokeWidth: 1.6, fill: "none" }), (0, import_react3.createElement)("path", { d: "M13 13l4 4", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round" })); --} --function PeopleIcon() { -- return (0, import_react3.createElement)( -- "svg", -- { width: 14, height: 14, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", "aria-hidden": true }, -- (0, import_react3.createElement)("circle", { cx: 7.5, cy: 7, r: 3 }), -- (0, import_react3.createElement)("path", { d: "M2 16.5c.6-2.8 2.8-4.5 5.5-4.5s4.9 1.7 5.5 4.5" }), -- (0, import_react3.createElement)("path", { d: "M13 4.3a3 3 0 0 1 0 5.4M15 12.4c1.6.6 2.6 2 3 4.1" }) ++ ); + return (0, import_react9.createElement)( + "div", + { className: "bt-group-msg" }, + name, + (0, import_react9.createElement)(Bubbles, { texts: [text], roster, selfId: bot.id, actions, sessionId: room2?.id, msgKey, useUi, lead }) - ); - } --function ArrowUpIcon() { -- return (0, import_react3.createElement)("svg", { width: 16, height: 16, viewBox: "0 0 20 20", "aria-hidden": true }, (0, import_react3.createElement)("path", { d: "M10 16V4M5 9l5-5 5 5", stroke: "currentColor", strokeWidth: 1.8, fill: "none", strokeLinecap: "round", strokeLinejoin: "round" })); --} --function MicIcon({ size = 16 }) { -- return (0, import_react3.createElement)( -- "svg", -- { width: size, height: size, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round", "aria-hidden": true }, -- (0, import_react3.createElement)("rect", { x: 7, y: 2.5, width: 6, height: 10, rx: 3, fill: "currentColor", stroke: "none" }), -- (0, import_react3.createElement)("path", { d: "M4.5 9.5a5.5 5.5 0 0 0 11 0M10 15v2.5" }) ++ ); ++} +function remoteSpeaker(source, roster, sessionId) { + const handle = source.remote.handle; + const member = (roster.remoteBots ?? []).find((bot2) => bot2.handle === handle); @@ -6767,7 +5622,12 @@ diff --git a/client.js b/client.js + const person = typeof source.remote.typedBy === "string" ? source.remote.typedBy : ""; + if (person) { + return (0, import_react9.createElement)( -+ "div", + "div", +- { className: "bt-q-foot" }, +- request.kind === "allow" && !replacing ? (0, import_react5.createElement)("button", { type: "button", className: "bt-q-btn", disabled: busy, onClick: () => setReplacing(true) }, t("Replace value")) : null, +- (0, import_react5.createElement)("button", { type: "button", className: "bt-q-btn", disabled: busy, onClick: cancel }, t("Cancel")), +- filling ? (0, import_react5.createElement)("button", { type: "button", className: "bt-q-submit", disabled: !long || busy, onClick: save }, busy ? t("Saving…") : t("Save")) : (0, import_react5.createElement)("button", { type: "button", className: "bt-q-submit", disabled: busy, onClick: allow }, busy ? t("Allowing…") : t("Allow")) +- ) + { className: "bt-group-msg bt-person-msg bt-remote-msg", "data-person": person, "data-remote": handle }, + // One short header: the person, and the Bot that carried the line only when it is known (#275). + (0, import_react9.createElement)( @@ -6796,40 +5656,24 @@ diff --git a/client.js b/client.js + files.length ? (0, import_react9.createElement)(RemoteFiles, { files, plugin: member?.plugin ?? "social", botId: roster.byId[sessionId]?.id ?? sessionId }) : null ); } --function WaveIcon({ size = 16 }) { -- return (0, import_react3.createElement)( -- "svg", -- { width: size, height: size, viewBox: "0 0 20 20", fill: "currentColor", "aria-hidden": true }, -- [[3, 7.5, 5], [6.5, 4.5, 11], [10, 2.5, 15], [13.5, 5.5, 9], [17, 8, 4]].map(([x, y, height]) => (0, import_react3.createElement)("rect", { key: x, x: x - 1, y, width: 2, height, rx: 1 })) -- ); +-function SettledSecret({ bot, request, roster }) { +- const canceled = request.status === "canceled"; +- const text = canceled ? t("{bot} asked for {name}", { bot: bot.name, name: request.name }) : request.status === "allowed" ? t("Allowed {bot} to use {name}", { bot: bot.name, name: request.name }) : t("Saved {name}", { name: request.name }); +- return (0, import_react5.createElement)( +function FirstContact({ source, roster, sessionId }) { + const person = typeof source.remote.typedBy === "string" ? source.remote.typedBy : ""; + const who = person || remoteSpeaker(source, roster).name; + const bot = roster.byId[sessionId]?.name ?? t("your Bot"); + return (0, import_react9.createElement)("div", { className: "bt-event", "data-first-contact": source.remote.handle }, (0, import_react9.createElement)("span", null, t("{name} is talking to {bot} through Bot Social", { name: who, bot }))); - } --function ShareIcon() { -- return (0, import_react3.createElement)( ++} +function GlobeMark() { + return (0, import_react9.createElement)( - "svg", -- { width: 16, height: 16, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, -- (0, import_react3.createElement)("path", { d: "M10 12.5V3M6.5 6.5L10 3l3.5 3.5M4 11v4.5A1.5 1.5 0 0 0 5.5 17h9a1.5 1.5 0 0 0 1.5-1.5V11" }) ++ "svg", + { width: 10, height: 10, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round", "aria-hidden": true }, + (0, import_react9.createElement)("circle", { cx: 12, cy: 12, r: 9 }), + (0, import_react9.createElement)("path", { d: "M3 12h18M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18" }) - ); - } --function CloseIcon() { -- return (0, import_react3.createElement)("svg", { width: 16, height: 16, viewBox: "0 0 20 20", "aria-hidden": true }, (0, import_react3.createElement)("path", { d: "M5 5l10 10M15 5L5 15", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round" })); --} --var ChevronLeftIcon = () => (0, import_react3.createElement)("svg", { width: 16, height: 16, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, (0, import_react3.createElement)("path", { d: "M12.5 4.5L7 10l5.5 5.5" })); --var ChevronRightIcon = () => (0, import_react3.createElement)("svg", { width: 14, height: 14, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, (0, import_react3.createElement)("path", { d: "M7.5 4.5L13 10l-5.5 5.5" })); --var lineIcon = (paths) => function LineIcon() { -- return (0, import_react3.createElement)( -- "svg", -- { width: 15, height: 15, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, -- paths.map((d, index) => (0, import_react3.createElement)("path", { key: index, d })) ++ ); ++} +function RemoteFiles({ files, plugin, botId }) { + const [busy, setBusy] = (0, import_react9.useState)(""); + const [error, setError] = (0, import_react9.useState)(""); @@ -6851,7 +5695,20 @@ diff --git a/client.js b/client.js + } + }; + return (0, import_react9.createElement)( -+ "div", + "div", +- { className: "bt-q bt-q-settled bt-secret bt-secret-done", role: "group", "aria-label": text, "data-status": request.status }, +- (0, import_react5.createElement)( +- "div", +- { className: "bt-q-head" }, +- (0, import_react5.createElement)("span", { className: "bt-secret-who" }, (0, import_react5.createElement)(BotMark, { bot, size: 22 })), +- (0, import_react5.createElement)("div", { className: "bt-q-text" }, (0, import_react5.createElement)( +- "p", +- { className: "bt-q-title" }, +- text, +- canceled ? null : (0, import_react5.createElement)("span", { className: "bt-secret-scope-tag" }, ` · ${scopeLabel(request.scope, roster)}`) +- )), +- canceled ? (0, import_react5.createElement)("span", { className: "bt-q-badge" }, t("Canceled")) : (0, import_react5.createElement)("span", { className: "bt-q-check bt-secret-check", title: t("Done") }, (0, import_react5.createElement)(CheckIcon)) +- ) + { className: "bt-file-chips" }, + files.map((file) => (0, import_react9.createElement)( + "button", @@ -6860,49 +5717,27 @@ diff --git a/client.js b/client.js + )), + error ? (0, import_react9.createElement)("span", { className: "bt-file-error", role: "alert" }, error) : null ); --}; --var CLOUD = "M6.5 15.25H5.75a3.25 3.25 0 0 1-.55-6.45 4.75 4.75 0 0 1 9.2-1.1 3.75 3.75 0 0 1 .1 7.55H13.5"; --function CloudDownloadIcon({ done = false }) { -- return (0, import_react3.createElement)( -- "svg", -- { width: 16, height: 16, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, -- (0, import_react3.createElement)("path", { d: CLOUD }), -- done ? (0, import_react3.createElement)("path", { d: "M7.75 13.25l1.75 1.75 3-3.25" }) : (0, import_react3.createElement)("g", { className: "bt-cloud-arrow" }, (0, import_react3.createElement)("path", { d: "M10 9.75v7" }), (0, import_react3.createElement)("path", { d: "M7.75 14.5L10 16.75l2.25-2.25" })) -+} + } +-function RequestView({ bot, request, roster, actions }) { +- return request.status === "pending" ? (0, import_react5.createElement)(SecretCard, { key: `${request.id}:${request.kind}`, bot, request, roster, actions }) : (0, import_react5.createElement)(SettledSecret, { bot, request, roster }); +function GroupEvent({ prefix, room: room2, name, roster, actions }) { + return (0, import_react9.createElement)( + "div", + { className: "bt-event" }, + (0, import_react9.createElement)("span", null, prefix), + room2 ? (0, import_react9.createElement)("button", { type: "button", onClick: () => actions.openSession(room2.id) }, (0, import_react9.createElement)(RoomAvatar, { room: room2, roster, size: 16 }), room2.name) : name ? (0, import_react9.createElement)("span", null, name) : null - ); ++ ); } --var SmileIcon = lineIcon(["M10 3.5a6.5 6.5 0 1 1 0 13 6.5 6.5 0 0 1 0-13z", "M7.3 11.6a3.4 3.4 0 0 0 5.4 0", "M7.75 8.25h.01M12.25 8.25h.01"]); --var ReplyIcon = lineIcon(["M8 5L4 9l4 4", "M4 9h7.5a4.5 4.5 0 0 1 4.5 4.5V15"]); --var DotsIcon = lineIcon(["M5 10h.01M10 10h.01M15 10h.01"]); --var CheckIcon = lineIcon(["M4.5 10.5l3.5 3.5 7.5-8"]); --var ArrowDownIcon = lineIcon(["M10 4v12", "M5 11l5 5 5-5"]); --var DownloadIcon = lineIcon(["M10 3.5v9", "M6.5 9.5L10 13l3.5-3.5", "M4 16h12"]); --var GaugeIcon = lineIcon(["M3.5 13.5a6.5 6.5 0 1 1 13 0", "M10 13.5l3-4", "M10 13.5h.01"]); --var PlugIcon = lineIcon(["M7 3v4M13 3v4", "M5 7h10v2.5a5 5 0 0 1-10 0z", "M10 14.5V17"]); --var CalendarIcon = lineIcon(["M5.5 5h9A1.5 1.5 0 0 1 16 6.5v8a1.5 1.5 0 0 1-1.5 1.5h-9A1.5 1.5 0 0 1 4 14.5v-8A1.5 1.5 0 0 1 5.5 5z", "M4 8.75h12", "M7.25 3.5v3M12.75 3.5v3"]); --var ConnectorIcon = lineIcon(["M7 10a2.5 2.5 0 1 1-5 0 2.5 2.5 0 0 1 5 0z", "M18 10a2.5 2.5 0 1 1-5 0 2.5 2.5 0 0 1 5 0z", "M7 10h6"]); --var SwitchIcon = lineIcon(["M4 7h9.5M10.5 3.5L14 7l-3.5 3.5", "M16 13H6.5M9.5 9.5L6 13l3.5 3.5"]); --var PaperclipIcon = lineIcon(["M15.5 9.5l-5.6 5.6a3.4 3.4 0 0 1-4.8-4.8l6.1-6.1a2.25 2.25 0 0 1 3.2 3.2l-6 6a1.1 1.1 0 0 1-1.6-1.6l5.5-5.5"]); --var ClockIcon = lineIcon(["M10 3.5a6.5 6.5 0 1 1 0 13 6.5 6.5 0 0 1 0-13z", "M10 6.5V10l2.5 1.5"]); --var GearIcon = lineIcon(["M10 7.4a2.6 2.6 0 1 1 0 5.2 2.6 2.6 0 0 1 0-5.2z", "M7.85 4.4l.29-1.87a7.7 7.7 0 0 1 3.72 0l.29 1.87a6 6 0 0 1 1.63.94l1.76-.69a7.7 7.7 0 0 1 1.86 3.23l-1.47 1.18a6 6 0 0 1 0 1.88l1.47 1.18a7.7 7.7 0 0 1-1.86 3.23l-1.76-.69a6 6 0 0 1-1.63.94l-.29 1.87a7.7 7.7 0 0 1-3.72 0l-.29-1.87a6 6 0 0 1-1.63-.94l-1.76.69a7.7 7.7 0 0 1-1.86-3.23l1.47-1.18a6 6 0 0 1 0-1.88L2.6 7.88a7.7 7.7 0 0 1 1.86-3.23l1.76.69a6 6 0 0 1 1.63-.94z"]); --var PinIcon = lineIcon(["M12.5 3l4.5 4.5-3 1.5-2.5 2.5.5 3.5-1.5 1.5L7 13 3.5 16.5", "M7 13l-3.5-3.5L5 8l3.5.5L11 6l1.5-3"]); --function PinMarkIcon() { -- return (0, import_react3.createElement)( -- "svg", -- { width: 12, height: 12, viewBox: "0 0 20 20", fill: "currentColor", "aria-hidden": true }, -- (0, import_react3.createElement)( -- "g", -- { transform: "rotate(45 10 10)" }, -- (0, import_react3.createElement)("path", { d: "M8 2.5h4a.9.9 0 0 1 0 1.8h-.4v4.1c0 1.4 1.1 2.6 2.6 3.1l.7.3v1.5H5.1v-1.5l.7-.3c1.5-.5 2.6-1.7 2.6-3.1V4.3H8a.9.9 0 0 1 0-1.8z" }), -- (0, import_react3.createElement)("path", { d: "M9.35 13.3h1.3v3.6L10 18l-.65-1.1z" }) -- ) -+function MemoryEvent({ sessionId, scope, result, roster, actions, findBot }) { +-function SecretToolCell({ sessionId, callId, name, done, content, roster, actions }) { +- const bot = roster.byId[sessionId]; +- if (!bot) return null; +- const request = (roster.secretRequests?.[bot.id] ?? []).find((entry) => entry.callId === callId && (entry.sessionId ?? sessionId) === sessionId); +- if (request) return (0, import_react5.createElement)("div", { className: "bt-q-inline" }, (0, import_react5.createElement)(RequestView, { bot, request, roster, actions })); +- if (done && /already saved/.test(contentText(content))) { +- return (0, import_react5.createElement)("div", { className: "bt-event" }, (0, import_react5.createElement)("span", { className: "bt-secret-event" }, (0, import_react5.createElement)(KeyIcon), t("{bot} is using {name}", { bot: bot.name, name: name ?? t("a saved key") }))); +- } +- return null; ++function MemoryEvent({ sessionId, scope, result, roster, actions, findBot }) { + if (!/^(Saved to|Updated in|Removed from) /.test(result)) return null; + const self = roster.byId[sessionId]; + const team = /^(team|团队|共享)$/i.test(String(scope ?? "").trim()); @@ -6914,47 +5749,27 @@ diff --git a/client.js b/client.js + "div", + { className: "bt-event" }, + (0, import_react9.createElement)("button", { type: "button", title: result, onClick: () => actions.openMemory(target.id) }, (0, import_react9.createElement)(MemoryIcon), label) - ); ++ ); } --var UnreadIcon = lineIcon(["M10 3.5a4.5 4.5 0 0 0-4.5 4.5v3L4 13.5h12L14.5 11V8", "M8.3 16a1.8 1.8 0 0 0 3.4 0", "M15.5 3.2a1.6 1.6 0 1 1 0 3.2 1.6 1.6 0 0 1 0-3.2z"]); --var PencilIcon = lineIcon(["M9 4H6a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2v-3", "M14.3 3.7a1.6 1.6 0 0 1 2.3 2.3L10.5 12l-3 .8.8-3z"]); --var InfoIcon = lineIcon(["M10 3.5a6.5 6.5 0 1 1 0 13 6.5 6.5 0 0 1 0-13z", "M10 9.25v4", "M10 6.6h.01"]); --var StarLineIcon = lineIcon(["M10 3l2.1 4.3 4.7.7-3.4 3.3.8 4.7L10 13.8 5.8 16l.8-4.7L3.2 8l4.7-.7z"]); --var CopyIcon = lineIcon(["M8.5 7h6A1.5 1.5 0 0 1 16 8.5v6a1.5 1.5 0 0 1-1.5 1.5h-6A1.5 1.5 0 0 1 7 14.5v-6A1.5 1.5 0 0 1 8.5 7z", "M4 12.5V5.5A1.5 1.5 0 0 1 5.5 4H13"]); --var HideIcon = lineIcon(["M3 3l14 14", "M8.6 5.2A7.6 7.6 0 0 1 10 5c4.5 0 7 5 7 5a12.6 12.6 0 0 1-2 2.6M12.2 14.5A6.8 6.8 0 0 1 10 15c-4.5 0-7-5-7-5a12.4 12.4 0 0 1 3-3.4", "M8.5 8.5a2 2 0 0 0 3 3"]); --var TrashIcon = lineIcon(["M3.5 5.5h13", "M8 5.5V4a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1v1.5", "M5 5.5l.7 10A1.5 1.5 0 0 0 7.2 17h5.6a1.5 1.5 0 0 0 1.5-1.5l.7-10", "M8.5 9v4.5M11.5 9v4.5"]); --var DuplicateIcon = lineIcon(["M7 7h8.5v9.5H7z", "M4.5 13V3.5H13", "M11.25 9.5v4.5M9 11.75h4.5"]); --var SlidersIcon = lineIcon(["M4 6.5h5M12.5 6.5H16M4 13.5h4M11.5 13.5H16", "M10.5 4.5v4M9.5 11.5v4"]); --var MemoryIcon = lineIcon(["M5.5 3.5h8A1.5 1.5 0 0 1 15 5v10a1.5 1.5 0 0 1-1.5 1.5h-8z", "M5.5 3.5v13", "M8.5 7.5h3.5M8.5 10.5h2.5", "M12 3.5v4l1-.8 1 .8"]); --var FolderIcon = lineIcon(["M3 6A1.5 1.5 0 0 1 4.5 4.5h3.2l1.8 2h6A1.5 1.5 0 0 1 17 8v6.5a1.5 1.5 0 0 1-1.5 1.5h-11A1.5 1.5 0 0 1 3 14.5z"]); --var ChatsIcon = lineIcon(["M3.5 5.5A1.5 1.5 0 0 1 5 4h7a1.5 1.5 0 0 1 1.5 1.5V10A1.5 1.5 0 0 1 12 11.5H8L5 14v-2.5A1.5 1.5 0 0 1 3.5 10z", "M13.5 7.5H15A1.5 1.5 0 0 1 16.5 9v4.5A1.5 1.5 0 0 1 15 15v2l-2.5-2h-3A1.5 1.5 0 0 1 8 13.5V13"]); --var SETTINGS_ICONS = { general: GearIcon, bots: PeopleIcon, groups: ChatsIcon, usage: GaugeIcon }; -- --// src/client/questions.js --var import_react4 = require("react"); --var keyLetter = (index) => String.fromCharCode(65 + index); --var optionsOf = (question) => (question?.options ?? []).map((option) => typeof option === "string" ? { label: option } : option); --var isTypingTarget = (node) => node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement || node instanceof HTMLSelectElement || node instanceof HTMLElement && node.isContentEditable; --function useOptionKeys(count, onPick, ownRef) { -- const pick2 = (0, import_react4.useRef)(onPick); -- pick2.current = onPick; -- (0, import_react4.useEffect)(() => { -- if (count === 0) return void 0; -- const onKey = (event) => { -- if (event.defaultPrevented || event.metaKey || event.ctrlKey || event.altKey || event.shiftKey || event.key.length !== 1) return; -- if (isTypingTarget(event.target) || isTypingTarget(document.activeElement)) return; -- if (document.querySelector('[role="dialog"], [aria-modal="true"]') !== null) return; -- const index = event.key.toUpperCase().charCodeAt(0) - 65; -- if (index >= 0 && index < count) { -- event.preventDefault(); -- pick2.current(index); -- } else if (index === count && ownRef.current) { -- event.preventDefault(); -- ownRef.current.focus(); -- } +-function SecretDock({ sessionId, useRoster, useCards, useSession, actions }) { +- const roster = useRoster((value) => value); +- const shown = useCards?.((state) => state[sessionId]) ?? NONE; +- const loading = useSession?.(historyLoading) === true; +- const bot = roster.byId[sessionId]; +- if (!bot || currentPart(bot) !== sessionId) return null; +- const drawn = (entry) => entry.callId && entry.sessionId === sessionId && (loading || shown[entry.callId] > 0); +- const waiting = (roster.secretRequests?.[bot.id] ?? []).filter((entry) => entry.status === "pending" && !drawn(entry)); +- if (waiting.length === 0) return null; +- return (0, import_react5.createElement)("div", { className: "bt-q-dock bt-secret-dock" }, waiting.map((request) => (0, import_react5.createElement)(SecretCard, { key: `${request.id}:${request.kind}`, bot, request, roster, actions }))); +function PartLine({ time }) { + return (0, import_react9.createElement)("div", { className: "bt-part-line", role: "separator" }, Number.isFinite(time) ? (0, import_react9.createElement)("span", null, dayLabel(time)) : null); -+} + } +-function ScopeEditor({ secret, roster, onSave }) { +- const [mode, setMode] = (0, import_react5.useState)(secret.scope === "all" ? "all" : "some"); +- const [picked, setPicked] = (0, import_react5.useState)(secret.scope === "all" ? [] : secret.scope); +- const toggle = (id) => setPicked((list) => list.includes(id) ? list.filter((item) => item !== id) : [...list, id]); +- const same = mode === "all" ? secret.scope === "all" : secret.scope !== "all" && [...picked].sort().join() === [...secret.scope].sort().join(); +- return (0, import_react5.createElement)( +var scrollParent = (node) => { + for (let at = node.parentElement; at; at = at.parentElement) { + if (/(auto|scroll)/.test(getComputedStyle(at).overflowY)) return at; @@ -6987,10 +5802,7 @@ diff --git a/client.js b/client.js + load(void 0); + return () => { + alive.current = false; - }; -- document.addEventListener("keydown", onKey, true); -- return () => document.removeEventListener("keydown", onKey, true); -- }, [count, ownRef]); ++ }; + }, [sessionId]); + (0, import_react9.useLayoutEffect)(() => { + const pending = restore.current; @@ -7014,13 +5826,35 @@ diff --git a/client.js b/client.js + else rows.push(item.kind === "bot" ? { kind: "bot", texts: [item.text] } : item); + } + return (0, import_react9.createElement)( -+ "div", + "div", +- { className: "bt-secret-edit-block" }, +- (0, import_react5.createElement)("div", { className: "bt-secret-edit-label" }, t("Who may use it")), +- (0, import_react5.createElement)( + { className: "bt-earlier", role: "feed", "aria-label": t("Earlier messages"), "aria-busy": view.loading }, + (0, import_react9.createElement)( -+ "div", + "div", +- { className: "bt-secret-radios", role: "radiogroup", "aria-label": t("Who may use {name}", { name: secret.name }) }, +- [["all", t("All Bots")], ["some", t("Chosen Bots")]].map(([id, text]) => (0, import_react5.createElement)( +- "label", +- { key: id, className: "bt-secret-radio" }, +- (0, import_react5.createElement)("input", { type: "radio", name: `scope-${secret.name}`, checked: mode === id, onChange: () => setMode(id) }), +- text +- )) + { ref: top, className: "bt-earlier-top" }, + view.loading ? (0, import_react9.createElement)(Shimmer, null, t("Loading earlier messages…")) : view.failed ? (0, import_react9.createElement)("button", { type: "button", className: "bt-soft", onClick: () => load(view.cursor) }, t("Couldn’t load earlier messages · Retry")) : view.cursor ? (0, import_react9.createElement)("button", { type: "button", className: "bt-soft", onClick: () => load(view.cursor) }, t("Load earlier")) : null -+ ), + ), +- mode === "some" ? (0, import_react5.createElement)("div", { className: "bt-secret-bots" }, roster.bots.map((bot) => (0, import_react5.createElement)("button", { +- key: bot.id, +- type: "button", +- className: "bt-secret-bot", +- "aria-pressed": picked.includes(bot.id), +- onClick: () => toggle(bot.id) +- }, (0, import_react5.createElement)(BotAvatar, { bot, size: 20, badge: false, live: false }), bot.name, picked.includes(bot.id) ? (0, import_react5.createElement)(CheckIcon) : null))) : null, +- (0, import_react5.createElement)( +- "div", +- { className: "bt-secret-row-actions" }, +- (0, import_react5.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: same, onClick: () => onSave(mode === "all" ? "all" : picked) }, t("Save access")) +- ) + rows.map((row, index) => { + const key = rows.length - index; + switch (row.kind) { @@ -7050,21 +5884,37 @@ diff --git a/client.js b/client.js + } + }), + (0, import_react9.createElement)(PartLine, { time: view.startedAt }) -+ ); + ); } --function QuestionView({ question, onAnswer, onDismiss, ownAnswer = false, raised = false }) { -- const [own, setOwn] = (0, import_react4.useState)(""); -- const [picked, setPicked] = (0, import_react4.useState)([]); -- const ownRef = (0, import_react4.useRef)(null); -- const titleId = (0, import_react4.useId)(); -- const options = optionsOf(question); -- const multi = question.multiSelect === true; -- const custom = own.trim(); -- const canSubmit = multi ? picked.length > 0 || custom !== "" : custom !== ""; -- const choose = (label) => { -- if (multi) { -- setPicked((current) => current.includes(label) ? current.filter((item) => item !== label) : [...current, label]); -- return; +-function ValueEditor({ secret, onSave }) { +- const [value, setValue] = (0, import_react5.useState)(""); +- const [replaced, setReplaced] = (0, import_react5.useState)(false); +- const long = value.trim().length >= MIN_VALUE; +- const save = () => { +- if (!long) return; +- void Promise.resolve(onSave(value)).then((ok) => { +- if (ok) { +- setValue(""); +- setReplaced(true); +- } +- }); +- }; +- const edit = (next) => { +- setValue(next); +- setReplaced(false); +- }; +- return (0, import_react5.createElement)( +- "div", +- { className: "bt-secret-edit-block" }, +- (0, import_react5.createElement)("div", { className: "bt-secret-edit-label" }, t("Replace the value")), +- (0, import_react5.createElement)( +- "div", +- { className: "bt-secret-inline" }, +- (0, import_react5.createElement)("label", { className: "bt-q-field bt-secret-field" }, (0, import_react5.createElement)(MaskedInput, { value, onChange: edit, onEnter: save, label: t("New value of {name}", { name: secret.name }) })), +- (0, import_react5.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: !long, onClick: save }, t("Replace")) +- ), +- value !== "" && !long ? (0, import_react5.createElement)("div", { className: "bt-set-hint" }, t("At least {count} characters.", { count: MIN_VALUE })) : replaced ? (0, import_react5.createElement)("div", { className: "bt-set-hint bt-secret-ok", role: "status" }, (0, import_react5.createElement)(CheckIcon), t("Replaced. Bots get the new value from their next command.")) : null +- ); +function TriggerCell({ node, sessionId, useRoster, useSessions, useTurnClock, useUi, actions }) { + const roster = useRoster((value) => value); + const data = node.data; @@ -7099,30 +5949,7 @@ diff --git a/client.js b/client.js + if (source.remote?.handle) { + const message = (0, import_react9.createElement)(RemoteMessage, { source, text: remoteMessageText(stripHeader(contentText(data.content))), roster, actions, sessionId, msgKey: `${sessionId}:${data.turn}:remote`, useUi }); + return source.remote.firstContact === true ? (0, import_react9.createElement)(import_react9.Fragment, null, (0, import_react9.createElement)(FirstContact, { source, roster, sessionId }), message) : message; - } -- onAnswer({ selected: [label] }); -- }; -- const submit = () => { -- if (!canSubmit) return; -- onAnswer({ selected: multi ? picked : [], ...custom ? { custom } : {} }); -- }; -- useOptionKeys(options.length, (index) => choose(options[index].label), ownRef); -- const ownLetter = options.length > 0 ? keyLetter(options.length) : void 0; -- const field = ownAnswer ? (0, import_react4.createElement)("textarea", { -- ref: ownRef, -- rows: 1, -- value: own, -- autoComplete: "off", -- spellCheck: false, -- className: "bt-q-input", -- "aria-label": t("Your own answer"), -- placeholder: t("Type your own answer"), -- "aria-keyshortcuts": ownLetter?.toLowerCase(), -- onChange: (event) => setOwn(event.target.value), -- onKeyDown: (event) => { -- if (event.key !== "Enter" || event.shiftKey || event.nativeEvent.isComposing) return; -- event.preventDefault(); -- submit(); ++ } + if (source.kind === "bot") { + if (source.handoff === true) return roster.roomsById[sessionId] ? null : (0, import_react9.createElement)(EarlierParts, { sessionId, roster, actions }); + if (source.role === "room" || source.role === "kickoff") return null; @@ -7159,37 +5986,7 @@ diff --git a/client.js b/client.js + onOpen: (bot) => actions.openExchange(selfId, bot.id), + whole: true + }); - } -- }) : null; -- const rows = options.map((option, optionIndex) => { -- const selected = multi && picked.includes(option.label); -- return (0, import_react4.createElement)( -- "button", -- { -- key: option.label, -- type: "button", -- className: "bt-q-opt", -- "aria-pressed": multi ? selected : void 0, -- "aria-keyshortcuts": keyLetter(optionIndex).toLowerCase(), -- onClick: () => choose(option.label) -- }, -- (0, import_react4.createElement)("span", { className: "bt-key", "aria-hidden": true }, keyLetter(optionIndex)), -- (0, import_react4.createElement)( -- "span", -- { className: "bt-q-body" }, -- (0, import_react4.createElement)("span", { className: "bt-q-label" }, option.label), -- option.description ? (0, import_react4.createElement)("span", { className: "bt-q-desc" }, option.description) : null -- ), -- selected && !raised ? (0, import_react4.createElement)("span", { className: "bt-q-check", title: t("Selected") }, (0, import_react4.createElement)(CheckIcon)) : null -- ); -- }); -- if (raised && field) { -- rows.push((0, import_react4.createElement)( -- "label", -- { key: "own", className: "bt-q-opt bt-q-ownrow", "data-filled": custom !== "" ? "" : void 0 }, -- ownLetter ? (0, import_react4.createElement)("span", { className: "bt-key", "aria-hidden": true }, ownLetter) : null, -- (0, import_react4.createElement)("span", { className: "bt-q-body" }, field) -- )); ++ } + if (source.kind === "schedule") { + const prompts = reminderPrompts(contentText(data.content)); + return (0, import_react9.createElement)("div", { className: "bt-event" }, prompts.length > 0 ? t("Scheduled task: {text}", { text: prompts.join(" · ").slice(0, 80) }) : t("Routine started")); @@ -7270,17 +6067,7 @@ diff --git a/client.js b/client.js + if (isHiddenNotice(block)) continue; + if (block?.type === "tool-removal" && block.toolName) parts.push(`Tool removed: ${block.toolName}`); + else if (block?.type === "tool-addition" && block.toolName) parts.push(`Tool added: ${block.toolName}`); - } -- let foot = null; -- if (raised && multi) { -- foot = (0, import_react4.createElement)( -- "div", -- { className: "bt-q-foot" }, -- (0, import_react4.createElement)("button", { type: "button", className: "bt-q-btn", onClick: onDismiss }, t("Skip")), -- (0, import_react4.createElement)("button", { type: "submit", className: "bt-q-submit", disabled: !canSubmit }, t("Submit")) -- ); -- } else if ((raised || multi) && canSubmit) { -- foot = (0, import_react4.createElement)("div", { className: "bt-q-foot" }, (0, import_react4.createElement)("button", { type: "submit", className: "bt-q-submit" }, t("Submit"))); ++ } + if (parts.length === 0) return null; + return (0, import_react9.createElement)("div", { className: "bt-event" }, parts.join(" · ")); +} @@ -7300,87 +6087,96 @@ diff --git a/client.js b/client.js + for (const key in turns) { + const candidate = Number(key); + if (candidate > turn && turns[key].first !== void 0 && (next === void 0 || candidate < next)) next = candidate; - } -- return (0, import_react4.createElement)( -- "form", -- { -- className: raised ? "bt-q bt-q-raised" : "bt-q", -- "aria-labelledby": titleId, -- onSubmit: (event) => { -- event.preventDefault(); -- submit(); -- } -- }, -- (0, import_react4.createElement)( -- "div", -- { className: "bt-q-head" }, -- (0, import_react4.createElement)( -- "div", -- { className: "bt-q-text" }, -- (0, import_react4.createElement)("p", { className: "bt-q-title", id: titleId }, question.header ? `${question.header} · ` : "", question.question), -- question.detail ? (0, import_react4.createElement)("div", { className: "bt-q-detail" }, question.detail) : null, -- raised && multi ? (0, import_react4.createElement)("div", { className: "bt-q-detail" }, t("Choose all that apply")) : null -- ), -- (0, import_react4.createElement)("button", { type: "button", className: "bt-q-x", "aria-label": t("Dismiss question"), title: t("Dismiss"), onClick: onDismiss }, (0, import_react4.createElement)(CloseIcon)) -- ), -- rows.length > 0 ? (0, import_react4.createElement)("div", { className: "bt-q-list", role: "group" }, rows) : null, -- field && !raised ? (0, import_react4.createElement)( -- "div", -- { className: "bt-q-custom" }, -- (0, import_react4.createElement)("label", { className: "bt-q-field" }, field), -- !multi && canSubmit ? (0, import_react4.createElement)("button", { type: "submit", className: "bt-q-submit" }, t("Submit")) : null -- ) : null, -- foot -- ); ++ } + return next === void 0 ? void 0 : turns[next].first; ++} ++function openingTurn(turns, firstTurns, hiddenTurns) { ++ if (!turns || !firstTurns) return null; ++ const start = nextTurnStart(turns, -Infinity); ++ if (start === void 0) return null; ++ const turn = Number(Object.keys(turns).filter((key) => turns[key].first === start)[0]); ++ const listed = firstTurns.split(",").map(Number); ++ const earlier = listed.filter((entry) => entry < turn); ++ return listed.includes(turn) && earlier.every((entry) => hiddenTurns?.includes(entry)) ? { turn, start } : null; } --function SettledQuestion({ question, answer }) { -- const options = optionsOf(question); -- const prompt = question?.question ?? ""; -- const chosen = [...answer.selected ?? [], ...answer.custom ? [answer.custom] : []]; -- if (chosen.length === 0) { -- return (0, import_react4.createElement)( +-function SecretRow({ secret, roster, actions, run, open, onToggle }) { +- const [confirm, setConfirm] = (0, import_react5.useState)(false); +- (0, import_react5.useEffect)(() => { +- if (!open) setConfirm(false); +- }, [open]); +- const asker = secret.requestedBy ? roster.byId[secret.requestedBy]?.name ?? t("a deleted Bot") : t("you"); +- const allowed = secret.scope === "all" ? [] : secret.scope.map((id) => roster.byId[id]).filter(Boolean); +- const meta = [t("Asked by {name}", { name: asker }), t("added {time}", { time: when(secret.createdAt) }), secret.lastUsedAt ? t("last used {time}", { time: when(secret.lastUsedAt) }) : t("not used yet")].join(" · "); +- return (0, import_react5.createElement)( +- "div", +- { className: "bt-secret-item", "data-open": open ? "" : void 0 }, +- (0, import_react5.createElement)( +- "button", +- { type: "button", className: "bt-set-row bt-secret-summary", "aria-expanded": open, onClick: onToggle }, +- (0, import_react5.createElement)( +- "span", +- { className: "bt-set-copy" }, +- (0, import_react5.createElement)("span", { className: "bt-secret-title" }, (0, import_react5.createElement)(KeyIcon), (0, import_react5.createElement)("code", { className: "bt-secret-name" }, secret.name), secret.purpose ? (0, import_react5.createElement)("span", { className: "bt-secret-purpose" }, secret.purpose) : null), +- (0, import_react5.createElement)("span", { className: "bt-set-hint" }, meta) +- ), +- (0, import_react5.createElement)( +- "span", +- { className: "bt-set-control" }, +- secret.scope === "all" ? (0, import_react5.createElement)("span", { className: "bt-tag" }, t("All Bots")) : allowed.length === 0 ? (0, import_react5.createElement)("span", { className: "bt-tag" }, t("No Bot")) : (0, import_react5.createElement)("span", { className: "bt-secret-faces", title: allowed.map((bot) => bot.name).join(", ") }, allowed.slice(0, 5).map((bot) => (0, import_react5.createElement)(BotAvatar, { key: bot.id, bot, size: 20, badge: false, live: false }))) +- ) +- ), +- open ? (0, import_react5.createElement)( - "div", -- { className: "bt-q bt-q-settled bt-q-dismissed", role: "group", "aria-label": prompt }, -- (0, import_react4.createElement)( +- { className: "bt-secret-edit" }, +- (0, import_react5.createElement)(ScopeEditor, { key: JSON.stringify(secret.scope), secret, roster, onSave: (scope) => run(actions.secretUpdate(secret.name, scope)) }), +- (0, import_react5.createElement)(ValueEditor, { secret, onSave: (value) => run(actions.secretSet({ name: secret.name, value })) }), +- (0, import_react5.createElement)( - "div", -- { className: "bt-q-head" }, -- (0, import_react4.createElement)("div", { className: "bt-q-text" }, (0, import_react4.createElement)("p", { className: "bt-q-title" }, prompt)), -- (0, import_react4.createElement)("span", { className: "bt-q-badge" }, t("Dismissed")) +- { className: "bt-secret-row-actions" }, +- confirm ? [ +- (0, import_react5.createElement)("span", { key: "q", className: "bt-set-hint" }, t("Delete {name}? Bots lose it at once.", { name: secret.name })), +- (0, import_react5.createElement)("button", { key: "no", type: "button", className: "bt-soft bt-soft-sm", onClick: () => setConfirm(false) }, t("Keep")), +- (0, import_react5.createElement)("button", { key: "yes", type: "button", className: "bt-soft bt-soft-sm bt-secret-danger", onClick: () => run(actions.secretDelete(secret.name)) }, t("Delete")) +- ] : (0, import_react5.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm bt-secret-danger", onClick: () => setConfirm(true) }, (0, import_react5.createElement)(TrashIcon), t("Delete")) - ) -- ); -+function openingTurn(turns, firstTurns, hiddenTurns) { -+ if (!turns || !firstTurns) return null; -+ const start = nextTurnStart(turns, -Infinity); -+ if (start === void 0) return null; -+ const turn = Number(Object.keys(turns).filter((key) => turns[key].first === start)[0]); -+ const listed = firstTurns.split(",").map(Number); -+ const earlier = listed.filter((entry) => entry < turn); -+ return listed.includes(turn) && earlier.every((entry) => hiddenTurns?.includes(entry)) ? { turn, start } : null; -+} +- ) : null +- ); +var unseenSince = (visit, time) => typeof visit?.seen === "number" && time > visit.seen && time <= visit.entered; +function movedOnSince(visit, turns, hiddenTurns) { + if (typeof visit?.entered !== "number") return false; + for (const key in turns ?? {}) { + if (hiddenTurns?.includes(Number(key))) continue; + if (turns[key].first !== void 0 && turns[key].first > visit.entered) return true; - } -- return (0, import_react4.createElement)( -- "div", -- { className: "bt-q bt-q-settled", role: "group", "aria-label": prompt }, -- prompt ? (0, import_react4.createElement)("div", { className: "bt-q-head" }, (0, import_react4.createElement)("div", { className: "bt-q-text" }, (0, import_react4.createElement)("p", { className: "bt-q-title" }, prompt))) : null, -- (0, import_react4.createElement)("div", { className: "bt-q-list", role: "group", "aria-label": t("Your answer") }, chosen.map((label, index) => { -- const optionIndex = options.findIndex((option) => option.label === label); -- return (0, import_react4.createElement)( -- "div", -- { key: `${label}-${index}`, className: "bt-q-opt" }, -- optionIndex >= 0 ? (0, import_react4.createElement)("span", { className: "bt-key", "aria-hidden": true }, keyLetter(optionIndex)) : null, -- (0, import_react4.createElement)("span", { className: "bt-q-body" }, (0, import_react4.createElement)("span", { className: "bt-q-label" }, label)), -- (0, import_react4.createElement)("span", { className: "bt-q-check", title: t("Selected") }, (0, import_react4.createElement)(CheckIcon)) -- ); -- })) ++ } + return false; -+} + } +-function AddSecret({ actions, run, onDone }) { +- const [name, setName] = (0, import_react5.useState)(""); +- const [purpose, setPurpose] = (0, import_react5.useState)(""); +- const [value, setValue] = (0, import_react5.useState)(""); +- const ready = /^[A-Za-z][\w-]{0,63}$/.test(name.trim()) && value.trim().length >= MIN_VALUE; +- const save = () => { +- if (!ready) return; +- void run(actions.secretSet({ name, purpose, value, scope: "all" })).then((ok) => { +- if (ok) { +- setValue(""); +- onDone(); +- } +- }); +- }; +- return (0, import_react5.createElement)( +- "div", +- { className: "bt-set-row bt-set-paste bt-secret-add" }, +- (0, import_react5.createElement)("input", { className: "bt-input", "aria-label": t("Key name"), placeholder: t("NAME, e.g. GITHUB_TOKEN"), value: name, spellCheck: false, autoComplete: "off", autoFocus: true, onChange: (event) => setName(event.target.value.toUpperCase()) }), +- (0, import_react5.createElement)("input", { className: "bt-input", "aria-label": t("What it is for"), placeholder: t("What it is for"), value: purpose, autoComplete: "off", onChange: (event) => setPurpose(event.target.value) }), +- (0, import_react5.createElement)("label", { className: "bt-q-field bt-secret-field" }, (0, import_react5.createElement)(MaskedInput, { value, onChange: setValue, onEnter: save, label: t("Value") })), +- (0, import_react5.createElement)( +- "div", +- { className: "bt-secret-row-actions" }, +- (0, import_react5.createElement)("span", { className: "bt-set-hint" }, t("New keys are for all Bots; narrow it down afterwards.")), +- (0, import_react5.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: onDone }, t("Cancel")), +- (0, import_react5.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: !ready, onClick: save }, t("Save key")) +- ) +var settledVisit = (visit) => typeof visit?.seen === "number" && visit.seen < visit.entered ? { ...visit, seen: visit.entered } : visit; +function TurnTailCell({ turn: location2, sessionId, useTurnClock, useUi, actions }) { + const turn = location2.turn; @@ -7406,22 +6202,101 @@ diff --git a/client.js b/client.js + fresh ? (0, import_react9.createElement)(NewDivider) : null ); } --function QuestionCard({ matched }) { -- const pending = matched; -- const questions = pending.questions ?? []; -- const [index, setIndex] = (0, import_react4.useState)(0); -- const [answers, setAnswers] = (0, import_react4.useState)([]); -- const question = questions[index]; -- if (question === void 0) return null; -- const onAnswer = (answer) => { -- const next = [...answers, { id: question.id, ...answer }]; -- if (index + 1 < questions.length) { -- setAnswers(next); -- setIndex(index + 1); -- return; +-function SecretsPage({ roster, actions, run, Section }) { +- const [open, setOpen] = (0, import_react5.useState)(null); +- const [adding, setAdding] = (0, import_react5.useState)(false); +- const secrets = roster.secrets ?? []; +- return [ +- (0, import_react5.createElement)( +- "p", +- { key: "about", className: "bt-secret-about" }, +- t("Keys your Bots asked for. Bots never see the values: they use them as "), +- (0, import_react5.createElement)("code", null, "$DSH_SECRET_NAME"), +- t(" in their shell, and every value is replaced by "), +- (0, import_react5.createElement)("code", null, "[secret:NAME]"), +- t(" before anything goes to a model.") +- ), +- (0, import_react5.createElement)( +- Section, +- { key: "list", title: t("Secrets · {count}", { count: secrets.length }) }, +- secrets.length === 0 && !adding ? (0, import_react5.createElement)("div", { className: "bt-set-row" }, (0, import_react5.createElement)("span", { className: "bt-set-hint" }, t("No keys yet. When a Bot needs one, it shows a card in its chat."))) : null, +- secrets.map((secret) => (0, import_react5.createElement)(SecretRow, { key: secret.name, secret, roster, actions, run, open: open === secret.name, onToggle: () => setOpen(open === secret.name ? null : secret.name) })), +- adding ? (0, import_react5.createElement)(AddSecret, { actions, run, onDone: () => setAdding(false) }) : null +- ), +- adding ? null : (0, import_react5.createElement)("div", { key: "add", className: "bt-actions" }, (0, import_react5.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => setAdding(true) }, t("Add a key"))) +- ]; +function Shimmer({ children }) { + return (0, import_react9.createElement)("span", { className: "bt-shimmer" }, children); -+} + } +-var SECRET_CSS = ` +-.bt-secret-head{align-items:center;gap:10px} +-.bt-secret-who{position:relative;flex:none;display:inline-flex} +-.bt-secret-lock{position:absolute;right:-5px;bottom:-4px;width:16px;height:16px;border-radius:50%;background:var(--bt-main);color:var(--bt-ink-2);display:inline-flex;align-items:center;justify-content:center;box-shadow:0 0 0 1.5px var(--bt-bubble)} +-.bt-secret-lock svg{width:11px;height:11px} +-.bt-secret-name{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px;line-height:16px;padding:1px 6px;border-radius:5px;background:var(--bt-hover);color:var(--bt-ink)} +-.bt-secret-body{display:flex;flex-direction:column;gap:8px} +-.bt-secret-field{align-items:center;min-height:36px} +-.bt-secret-input{flex:1;width:100%;min-width:0;margin:0;padding:0;border:0;outline:none;background:transparent;color:var(--bt-ink);font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:14px;line-height:20px;letter-spacing:.04em} +-.bt-secret-input[data-masked]{-webkit-text-security:disc} +-.bt-secret-input::placeholder{color:var(--bt-ink-3);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;letter-spacing:0} +-.bt-secret-scope{position:relative;display:inline-grid;grid-template-columns:1fr 1fr;align-self:flex-start;padding:2px;border-radius:999px;background:var(--bt-hover)} +-.bt-secret-glide{position:absolute;top:2px;bottom:2px;left:2px;width:calc(50% - 2px);border-radius:999px;background:var(--bt-main);box-shadow:0 1px 3px rgba(20,20,20,.08),inset 0 0 0 .5px color-mix(in srgb,var(--bt-ink) 6%,transparent);transition:${spring(SPRING_SHAPE, "transform")}} +-.bt-secret-glide[data-at="1"]{transform:translateX(100%)} +-.bt-secret-seg{position:relative;z-index:1;height:28px;padding:0 14px;border:0;border-radius:999px;background:none;color:var(--bt-ink-2);font:inherit;font-size:13px;white-space:nowrap;cursor:pointer;transition:color .16s ease} +-.bt-secret-seg[aria-checked=true]{color:var(--bt-ink);font-weight:500} +-.bt-secret-seg:focus-visible{outline:2px solid var(--bt-accent);outline-offset:-2px} +-/* A key card asks the user to act, so the process group carrying it stays open in every +- work-details mode (same override as .bt-group-replies in styles.js). */ +-[data-step-process]:has(.bt-secret)>div:first-child{display:none!important} +-[data-step-process]:has(.bt-secret)>[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} +-.bt-secret-ok{display:flex;align-items:center;gap:4px;margin-top:6px;animation:bt-secret-in .22s cubic-bezier(.22,1,.36,1)} +-.bt-secret-ok svg{width:13px;height:13px;color:#16a34a} +-@keyframes bt-secret-in{from{opacity:0;transform:translateY(-2px)}} +-.bt-secret-saved{display:flex;align-items:center;gap:6px;font-size:13px;line-height:18px;color:var(--bt-ink-2)} +-.bt-secret-note{font-size:12px;line-height:17px;color:var(--bt-ink-3)} +-.bt-secret-note code,.bt-secret-about code{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11.5px} +-.bt-secret-warn{display:flex;align-items:center;gap:6px;padding:6px 10px;border-radius:8px;font-size:12px;line-height:16px;color:#b25e09;background:color-mix(in srgb,#f59e0b 12%,transparent)} +-.bt-secret-error{font-size:12px;line-height:16px;color:#e02135} +-.bt-secret .bt-q-btn:disabled{opacity:.5;cursor:default} +-.bt-secret-done .bt-q-head{align-items:center} +-.bt-secret-done .bt-q-title{font-size:13px;line-height:18px;color:var(--bt-ink-2);font-weight:500} +-.bt-secret-scope-tag{font-weight:400;color:var(--bt-ink-3)} +-.bt-secret-check{color:var(--bt-green,#22a06b);animation:bt-secret-pop ${SPRING_POP.duration}ms ${SPRING_POP.easing} backwards} +-.bt-secret-done{animation:bt-secret-settle .32s cubic-bezier(.22,1,.36,1) backwards} +-@keyframes bt-secret-pop{from{opacity:0;transform:scale(.4)}} +-@keyframes bt-secret-settle{from{opacity:.4;transform:scale(.985)}} +-.bt-secret-event{display:inline-flex;align-items:center;gap:5px} +-.bt-secret-dock{flex-direction:column;align-items:center} +-.bt-secret-dock .bt-q{max-width:min(550px,calc(100% - 32px))} +-.bt-secret-about{margin:0 0 12px;padding:0 4px;font-size:12px;line-height:17px;color:var(--bt-ink-2)} +-.bt-secret-title{display:flex;align-items:center;gap:6px;min-width:0;color:var(--bt-ink)} +-.bt-secret-title svg{flex:none;color:var(--bt-ink-2)} +-.bt-secret-purpose{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--bt-ink-2)} +-.bt-secret-faces{display:inline-flex} +-.bt-secret-faces>*+*{margin-left:-6px} +-.bt-secret-edit{display:flex;flex-direction:column;gap:14px;padding:4px 12px 14px;animation:bt-q-in ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing} backwards,bt-veil-in .2s ease-out backwards} +-.bt-secret-edit-block{display:flex;flex-direction:column;gap:8px} +-.bt-secret-edit-label{font-size:12px;line-height:16px;font-weight:600;color:var(--bt-ink-2)} +-.bt-secret-radios{display:flex;gap:16px;font-size:13px} +-.bt-secret-radio{display:inline-flex;align-items:center;gap:6px;cursor:pointer} +-.bt-secret-bots{display:flex;flex-wrap:wrap;gap:6px} +-.bt-secret-bot{display:inline-flex;align-items:center;gap:6px;height:30px;padding:0 10px 0 5px;border-radius:999px;border:.5px solid var(--bt-line);background:var(--bt-card);color:var(--bt-ink-2);font:inherit;font-size:12px;cursor:pointer;transition:background-color .12s ease,color .12s ease,border-color .12s ease} +-.bt-secret-bot[aria-pressed=true]{background:var(--bt-active);color:var(--bt-ink);border-color:var(--bt-line-2)} +-.bt-secret-bot svg{width:13px;height:13px} +-.bt-secret-inline{display:flex;gap:8px;align-items:center} +-.bt-secret-row-actions{display:flex;align-items:center;justify-content:flex-end;gap:8px;flex-wrap:wrap} +-.bt-secret-row-actions .bt-set-hint{flex:1} +-.bt-secret-danger{color:#e02135;display:inline-flex;align-items:center;gap:4px} +-.bt-secret-danger svg{width:13px;height:13px} +-.bt-secret-add .bt-input{height:34px} +-@media (prefers-reduced-motion:reduce){.bt-secret-glide{transition:none}.bt-secret-check,.bt-secret-done,.bt-secret-ok,.bt-secret-edit{animation:none}} +-`; +- +-// src/client/turn-reasoning.js +-function turnReasoning(steps, current) { +- const members = steps.length ? steps : [current]; +- if (members.find((step) => stepReasoning(step) !== "")?.step !== current.step) return ""; +- return members.filter((step) => !thinkingLive(step)).map(stepReasoning).filter(Boolean).join("\n\n"); +var PHRASES = { + thinking: ["Thinking about it", "Weighing the options", "Working out a plan", "Sorting out the details"], + working: ["On it", "Busy with it", "Moving ahead", "Taking care of it", "Making headway"] @@ -7451,10 +6326,7 @@ diff --git a/client.js b/client.js + case "message_bot": { + const target = findBot(arg("to")); + return { label: target ? t("Asking {name}", { name: target.name }) : t("Asking another Bot"), state: "sending" }; - } -- pending.answer({ answers: next }); -- }; -- return (0, import_react4.createElement)(QuestionView, { key: index, question, onAnswer, onDismiss: () => pending.dismiss(), ownAnswer: true, raised: true }); ++ } + case "stop_bot": { + const target = findBot(arg("bot")); + return { label: target ? t("Stopping {name}", { name: target.name }) : t("Stopping another Bot"), state: "sending" }; @@ -7497,7 +6369,26 @@ diff --git a/client.js b/client.js + actions.noteLive(sessionId, id, JSON.parse(key)); + return () => actions.noteLive(sessionId, id, null); + }, [sessionId, id, key]); -+} + } +-var texts = (step) => step.blocks.filter((block) => block.kind === "text" && block.text.trim() !== ""); +-var thinkingLive = (step) => step.status === "running" && texts(step).length === 0 && step.blocks.some((block) => block.kind === "reasoning"); +-var stepReasoning = (step) => step.blocks.filter((block) => block.kind === "reasoning").map((block) => block.text).join("\n").trim(); +- +-// src/client/cells.js +-var import_react6 = require("react"); +-var import_react_dom = require("react-dom"); +-var import_dsh_client_ui_primitives = require("@deepseek-ai/dsh-client-ui-primitives"); +-var mdLabels = () => ({ code: { copyLabel: t("Copy"), copiedLabel: t("Copied") }, footnotes: t("Footnotes") }); +-function Bubbles({ texts: texts2, roster, selfId, actions, streaming, sessionId, msgKey, useUi, lead, mentions }) { +- const onClick = (event) => { +- const target = mentionTarget(event); +- if (target === null) return; +- event.preventDefault(); +- event.stopPropagation(); +- actions.openSession(target); +- }; +- const reactions = useUi ? useUi((state) => msgKey ? state.reactions?.[msgKey] ?? null : null) : null; +- return (0, import_react6.createElement)( +function GroupReplies({ sessionId, callId, done, content, roster, actions, useUi }) { + const [live, setLive] = (0, import_react9.useState)({ replies: [] }); + (0, import_react9.useEffect)(() => { @@ -7533,7 +6424,17 @@ diff --git a/client.js b/client.js + if (replies.length === 0) return null; + const room2 = roster.roomsById[sessionId]; + return (0, import_react9.createElement)( -+ "div", + "div", +- { className: "bt-astack", "data-lead": lead ? "" : void 0, onClickCapture: onClick }, +- texts2.map((text, index) => { +- const key = msgKey ? `${msgKey}:${index}` : void 0; +- const picked = key ? reactions?.[index] ?? [] : []; +- const last = index === texts2.length - 1; +- const live = streaming && last; +- return (0, import_react6.createElement)( +- "div", +- { key: index, className: "bt-msg" }, +- (0, import_react6.createElement)( + { className: "bt-group-replies" }, + replies.map((reply, index) => { + const stored = roster.byId[reply.botId]; @@ -7545,14 +6446,29 @@ diff --git a/client.js b/client.js + return (0, import_react9.createElement)(PersonMessage, { key: `user-${index}`, name: who, texts: [remoteMessageText(reply.text)], roster, selfId: sessionId, actions, sessionId, msgKey: callId ? `${sessionId}:${callId}:${index}` : void 0, useUi }); + } + return (0, import_react9.createElement)( -+ "div", + "div", +- { className: "bt-msg-line" }, +- lead && last ? lead : null, +- (0, import_react6.createElement)( + { key: `user-${index}`, className: "bt-urow", "data-author": reply.authorId, "data-author-name": who }, + who ? (0, import_react9.createElement)("span", { className: "bt-uauthor" }, who) : null, + (0, import_react9.createElement)( -+ "div", -+ { className: "bt-ububble" }, -+ (0, import_react9.createElement)(import_dsh_client_ui_primitives.MarkdownText, { text: remoteMessageText(reply.text), labels: mdLabels(), variant: "compact" }) -+ ), + "div", +- { className: "bt-bubble" }, +- (0, import_react6.createElement)(import_dsh_client_ui_primitives.MarkdownText, { text: linkMentions(text, roster.bots, selfId), streaming: live, labels: mdLabels(), variant: "compact", fileMentions: mentions }) ++ { className: "bt-ububble" }, ++ (0, import_react9.createElement)(import_dsh_client_ui_primitives.MarkdownText, { text: remoteMessageText(reply.text), labels: mdLabels(), variant: "compact" }) + ), +- live ? null : (0, import_react6.createElement)(MessageTools, { text, name: roster.byId[selfId]?.name ?? t("Bot"), sessionId, react: key ? (emoji) => actions.toggleReaction(msgKey, index, emoji) : void 0, actions }) +- ), +- picked.length ? (0, import_react6.createElement)("div", { className: "bt-reacts" }, picked.map((emoji) => (0, import_react6.createElement)("button", { +- key: emoji, +- type: "button", +- className: "bt-react", +- "aria-label": t("Remove reaction {emoji}", { emoji }), +- onClick: () => actions.toggleReaction(msgKey, index, emoji) +- }, emoji))) : null +- ); + room2?.origin === "face-to-face" ? (0, import_react9.createElement)("span", { className: "bt-lead", title: who || void 0 }, (0, import_react9.createElement)(PersonMark, { name: who || void 0 })) : null + ); + } @@ -7613,19 +6529,16 @@ diff --git a/client.js b/client.js + }); + } + return (0, import_react9.createElement)(GroupMessage, { key: `${reply.botId}-${index}`, bot, text: reply.text, room: room2, roster, actions, msgKey: callId ? `${sessionId}:${callId}:${index}` : void 0, useUi, seq: reply.seq }); -+ }) -+ ); + }) + ); } --function PendingQuestion({ sessionId, question, actions, inline }) { -- const [answered, setAnswered] = (0, import_react4.useState)(null); -- if (answered === question.id) return null; -- const onAnswer = (answer) => { -- setAnswered(question.id); -- void actions.answerQuestion(sessionId, question.id, { selected: answer.selected ?? [], custom: answer.custom ?? "" }).catch(() => setAnswered(null)); -- }; -- const onDismiss = () => { -- setAnswered(question.id); -- void actions.dismissQuestion(sessionId); +-var REACTIONS = ["👍", "❤️", "😂", "😮", "😢", "🙏"]; +-function useDismiss(open, close, refs) { +- (0, import_react6.useEffect)(() => { +- if (!open) return void 0; +- const onDown = (event) => { +- if (refs.some((ref) => ref.current?.contains(event.target))) return; +- close(); +var DRAWN = /* @__PURE__ */ new Set(["message_bot", "stop_bot", "create_bot", "update_bot", "group_relay", "create_group", "update_group", "delete_group", "post_to_group", "ask_user", "request_secret", "ask_user_question", "read_group_chat", "remember", "forget"]); +var CARDS = /* @__PURE__ */ new Set(["ask_user", "request_secret"]); +function callTree(root) { @@ -7637,21 +6550,10 @@ diff --git a/client.js b/client.js + seen.add(id); + calls.push(call4); + for (const child of call4.subCalls ?? []) visit(child); - }; -- return (0, import_react4.createElement)( -- "div", -- { className: inline ? "bt-q-inline" : "bt-q-dock" }, -- (0, import_react4.createElement)(QuestionView, { key: question.id, question, onAnswer, onDismiss, ownAnswer: question.allowCustom === true, raised: !inline }) -- ); ++ }; + visit(root); + return calls; - } --function QuestionDock({ sessionId, useRoster, useCards, useSession, actions }) { -- const question = useRoster((value) => value.questions?.[sessionId]); -- const drawn = useCards?.((state) => state[sessionId]?.[question?.callId] > 0) === true; -- const loading = useSession?.(historyLoading) === true; -- if (question === void 0 || question.callId && (drawn || loading)) return null; -- return (0, import_react4.createElement)(PendingQuestion, { sessionId, question, actions, inline: false }); ++} +function useShownCards(actions, sessionId, callIds) { + const key = JSON.stringify(callIds); + (0, import_react9.useLayoutEffect)(() => { @@ -7660,12 +6562,7 @@ diff --git a/client.js b/client.js + actions.noteCards(sessionId, ids, 1); + return () => actions.noteCards(sessionId, ids, -1); + }, [sessionId, key]); - } --function AnsweredCell({ node }) { -- const data = node.data ?? {}; -- const questions = Array.isArray(data.questions) ? data.questions : []; -- if (questions.length === 0) { -- return data.text ? (0, import_react4.createElement)("div", { className: "bt-urow" }, (0, import_react4.createElement)("div", { className: "bt-ububble" }, data.text)) : null; ++} +function renderCall(call4, { sessionId, roster, actions, useUi, findBot }) { + const tool = toolName(call4); + const done = call4.kind === "tool-result"; @@ -7741,13 +6638,7 @@ diff --git a/client.js b/client.js + return done && !call4.isError ? (0, import_react9.createElement)(MemoryEvent, { sessionId, scope: arg("scope"), result: contentText(call4.content), roster, actions, findBot }) : null; + default: + return null; - } -- const answers = Array.isArray(data.answers) ? data.answers : []; -- return (0, import_react4.createElement)("div", { className: "bt-q-inline" }, questions.map((question) => (0, import_react4.createElement)(SettledQuestion, { -- key: question.id, -- question, -- answer: answers.find((answer) => answer.id === question.id) ?? { selected: [] } -- }))); ++ } +} +function ToolCell({ node, sessionId, useRoster, useUi, actions }) { + const roster = useRoster((value) => value); @@ -7764,36 +6655,8 @@ diff --git a/client.js b/client.js + if (drawn.length === 0) return null; + const env = { sessionId, roster, actions, useUi, findBot }; + return (0, import_react9.createElement)(import_react9.Fragment, null, drawn.map((call4, index) => (0, import_react9.createElement)(import_react9.Fragment, { key: call4.callId ?? index }, renderCall(call4, env)))); - } - --// src/client/secret-card.js --var import_react5 = require("react"); --var MIN_VALUE = 4; --var NONE = {}; --var KeyIcon = () => (0, import_react5.createElement)( -- "svg", -- { width: 15, height: 15, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, -- (0, import_react5.createElement)("path", { d: "M7 9.5a3.5 3.5 0 1 1 0 .01z" }), -- (0, import_react5.createElement)("path", { d: "M10.4 10.6L17 10.6M14.5 10.6v2.4M16.6 10.6v1.8" }) --); --var LockIcon = () => (0, import_react5.createElement)( -- "svg", -- { width: 14, height: 14, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, -- (0, import_react5.createElement)("path", { d: "M6.5 9V7a3.5 3.5 0 0 1 7 0v2" }), -- (0, import_react5.createElement)("rect", { x: 4.5, y: 9, width: 11, height: 8, rx: 2 }) --); --var WarnIcon = () => (0, import_react5.createElement)( -- "svg", -- { width: 14, height: 14, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, -- (0, import_react5.createElement)("path", { d: "M10 3.5l7 12.5H3z" }), -- (0, import_react5.createElement)("path", { d: "M10 8.5v3.5M10 14.2h.01" }) --); --var MASK_BY_CSS = typeof CSS !== "undefined" && typeof CSS.supports === "function" && CSS.supports("-webkit-text-security", "disc"); --var insecure = () => typeof location !== "undefined" && location.protocol === "http:" && !/^(localhost|127(?:\.\d{1,3}){3}|\[::1\])$/.test(location.hostname); --var scopeLabel = (scope, roster) => { -- if (scope === "all") return t("All Bots"); -- const names = (scope ?? []).map((id) => roster.byId[id]?.name).filter(Boolean); -- return names.length === 0 ? t("No Bot") : names.join(t(" and ")); ++} ++ +// src/client/awaiting.js +var AWAIT_REPLY_MS = 10 * 60 * 1e3; +function awaitingReply(roster, sessionId, now = Date.now()) { @@ -7848,30 +6711,7 @@ diff --git a/client.js b/client.js + let top = -1; + for (const node of document.querySelectorAll(`[data-chat-flow-kind${kind ? `="${kind}"` : ""}][data-chat-turn]`)) top = Math.max(top, Number(node.dataset.chatTurn)); + return top; - }; --var when = (time) => Number.isFinite(time) ? new Date(time).toLocaleString(dateLocale(), { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }) : ""; --function MaskedInput({ value, onChange, onEnter, label, autoFocus = false }) { -- return (0, import_react5.createElement)("input", { -- className: "bt-secret-input", -- type: MASK_BY_CSS ? "text" : "password", -- "data-masked": MASK_BY_CSS ? "" : void 0, -- value, -- autoFocus, -- autoComplete: MASK_BY_CSS ? "off" : "new-password", -- autoCorrect: "off", -- autoCapitalize: "off", -- spellCheck: false, -- "data-1p-ignore": "", -- "data-lpignore": "true", -- "data-bwignore": "", -- "data-form-type": "other", -- "aria-label": label, -- placeholder: t("Paste or type the key"), -- onChange: (event) => onChange(event.target.value), -- onKeyDown: (event) => { -- if (event.key !== "Enter" || event.nativeEvent.isComposing) return; -- event.preventDefault(); -- onEnter?.(); ++}; +function useReplyAfterSend(remote, sending) { + const sendTurn = (0, import_react10.useRef)(-1); + const wasSending = (0, import_react10.useRef)(false); @@ -7943,7 +6783,11 @@ diff --git a/client.js b/client.js + if (event.target !== event.currentTarget || !/^bt-row/.test(event.animationName)) return; + delete event.currentTarget.dataset.btEnter; + event.currentTarget.removeEventListener("animationend", settle); -+ }; + }; +- const onKey = (event) => { +- if (event.key === "Escape") { +- event.stopPropagation(); +- close(); + const scan = () => { + frame = 0; + const next = document.querySelector("[data-chat-flow]"); @@ -7966,18 +6810,35 @@ diff --git a/client.js b/client.js + const reply = [void 0, "assistant-step", "tool-call"].includes(item.dataset.chatFlowKind); + item.dataset.btEnter = reply && check.current() ? "after-collapse" : "new"; + item.addEventListener("animationend", settle); -+ } -+ }; + } + }; +- window.addEventListener("mousedown", onDown); +- window.addEventListener("keydown", onKey, true); + const observer = new MutationObserver(() => { + if (!frame) frame = requestAnimationFrame(scan); + }); + scan(); -+ return () => { + return () => { +- window.removeEventListener("mousedown", onDown); +- window.removeEventListener("keydown", onKey, true); + cancelAnimationFrame(frame); + observer.disconnect(); -+ }; + }; +- }, [open]); + }, []); -+} + } +-function MessageTools({ text, name, sessionId, react, actions }) { +- const [panel, setPanel] = (0, import_react6.useState)(null); +- const [place, setPlace] = (0, import_react6.useState)(null); +- const [copied, setCopied] = (0, import_react6.useState)(false); +- const tools = (0, import_react6.useRef)(null); +- const pop = (0, import_react6.useRef)(null); +- const close = () => setPanel(null); +- useDismiss(panel !== null, close, [tools, pop]); +- const open = (kind, event) => { +- if (panel === kind) { +- close(); +- return; +function ActivityIndicator({ sessionId, useRoster, useSessionStatus, useLive, useSession, actions }) { + const roster = useRoster((value) => value); + const running = useSessionStatus((map) => map.get(sessionId)?.running === true); @@ -8017,11 +6878,28 @@ diff --git a/client.js b/client.js + make(); + return void 0; } -- }); --} --function ScopeSwitch({ value, onChange, bot }) { -- const options = [["all", t("All Bots")], ["bot", t("Only {name}", { name: bot.name })]]; -- return (0, import_react5.createElement)( +- const rect = event.currentTarget.getBoundingClientRect(); +- const below = rect.bottom + 220 < window.innerHeight; +- setPlace(kind === "react" ? { left: rect.left + rect.width / 2, top: rect.top - 6, "--bt-origin": "bottom center" } : { left: Math.min(rect.left, window.innerWidth - 208), ...below ? { top: rect.bottom + 4 } : { bottom: window.innerHeight - rect.top + 4, "--bt-origin": "bottom left" } }); +- setPanel(kind); +- }; +- const copy2 = () => { +- void navigator.clipboard?.writeText(text).then(() => { +- setCopied(true); +- setTimeout(() => setCopied(false), 1e3); +- }); +- }; +- const download2 = () => { +- const url = URL.createObjectURL(new Blob([text], { type: "text/markdown" })); +- const link = Object.assign(document.createElement("a"), { href: url, download: `${name}-message.md` }); +- link.click(); +- setTimeout(() => URL.revokeObjectURL(url), 1e3); +- }; +- const item = (label, Icon, run) => (0, import_react6.createElement)("button", { key: label, type: "button", role: "menuitem", onClick: () => { +- close(); +- run(); +- } }, (0, import_react6.createElement)(Icon), label); +- return (0, import_react6.createElement)( + const timer = setTimeout(make, wait); + return () => clearTimeout(timer); + }, [wantKey, shown]); @@ -8079,16 +6957,24 @@ diff --git a/client.js b/client.js + const elapsed = now - shown.at >= 6e4 ? elapsedLabel(now - shown.at) : null; + return (0, import_react_dom2.createPortal)((0, import_react10.createElement)( "div", -- { className: "bt-secret-scope", role: "radiogroup", "aria-label": t("Who may use it") }, -- (0, import_react5.createElement)("span", { className: "bt-secret-glide", "data-at": value === "all" ? 0 : 1, "aria-hidden": true }), -- options.map(([id, text]) => (0, import_react5.createElement)("button", { -- key: id, -- type: "button", -- role: "radio", -- "aria-checked": value === id, -- className: "bt-secret-seg", -- onClick: () => onChange(id) -- }, text)) +- { ref: tools, className: "bt-tools", "data-open": panel !== null || void 0 }, +- react ? (0, import_react6.createElement)("button", { type: "button", className: "bt-tool", "aria-label": t("Add reaction"), title: t("Add reaction"), "aria-expanded": panel === "react", onClick: (event) => open("react", event) }, (0, import_react6.createElement)(SmileIcon)) : null, +- sessionId ? (0, import_react6.createElement)("button", { type: "button", className: "bt-tool", "aria-label": t("Reply"), title: t("Reply"), onClick: () => actions.quote(sessionId, text) }, (0, import_react6.createElement)(ReplyIcon)) : null, +- (0, import_react6.createElement)("button", { type: "button", className: "bt-tool", "aria-label": t("More message actions"), title: t("More"), "aria-haspopup": "menu", "aria-expanded": panel === "more", onClick: (event) => open("more", event) }, copied ? (0, import_react6.createElement)(CheckIcon) : (0, import_react6.createElement)(DotsIcon)), +- panel === "react" ? (0, import_react_dom.createPortal)((0, import_react6.createElement)( +- "div", +- { ref: pop, className: "bt-react-strip", role: "menu", "aria-label": t("Reactions"), style: place }, +- REACTIONS.map((emoji) => (0, import_react6.createElement)("button", { key: emoji, type: "button", role: "menuitem", "aria-label": emoji, onClick: () => { +- close(); +- react(emoji); +- } }, emoji)) +- ), document.body) : null, +- panel === "more" ? (0, import_react_dom.createPortal)((0, import_react6.createElement)( +- "div", +- { ref: pop, className: "bt-menu", role: "menu", "aria-label": t("Message actions"), style: place }, +- item(t("Copy"), CopyIcon, copy2), +- item(t("Download"), DownloadIcon, download2) +- ), document.body) : null - ); + { className: "bt-typing", "data-exiting": phase === "out" ? "" : void 0, "data-carry": carry && phase !== "out" ? "" : void 0, "data-remote": remote ? "" : void 0, "data-flybot": shown.key === "remote-approval" ? "remote-approval" : void 0, "aria-hidden": true }, + bot ? (0, import_react10.createElement)(BotMark, { bot, size: 28, state: markState.current, live: true, pokeable: true }) : (0, import_react10.createElement)(RoomAvatar, { room: room2, roster, size: 28 }), @@ -8104,18 +6990,12 @@ diff --git a/client.js b/client.js + ) + ), host); } --function SecretHead({ bot, title, detail, name, onClose }) { -- return (0, import_react5.createElement)( -- "div", -- { className: "bt-q-head bt-secret-head" }, -- (0, import_react5.createElement)("span", { className: "bt-secret-who" }, (0, import_react5.createElement)(BotMark, { bot, size: 28 }), (0, import_react5.createElement)("span", { className: "bt-secret-lock" }, (0, import_react5.createElement)(LockIcon))), -- (0, import_react5.createElement)( -- "div", -- { className: "bt-q-text" }, -- (0, import_react5.createElement)("p", { className: "bt-q-title" }, title), -- (0, import_react5.createElement)("div", { className: "bt-q-detail" }, (0, import_react5.createElement)("code", { className: "bt-secret-name" }, name), detail ? ` · ${detail}` : "") -- ), -- onClose ? (0, import_react5.createElement)("button", { type: "button", className: "bt-q-x", "aria-label": t("Cancel the request"), title: t("Cancel"), onClick: onClose }, (0, import_react5.createElement)(CloseIcon)) : null +-function ThinkingRow({ text }) { +- return (0, import_react6.createElement)( +- "details", +- { className: "bt-think" }, +- (0, import_react6.createElement)("summary", null, t("Thought it through")), +- (0, import_react6.createElement)("div", { className: "bt-think-text" }, text) - ); + +// src/client/dsh-offer.js @@ -8127,64 +7007,15 @@ diff --git a/client.js b/client.js + if (dsh.kind === "npm") return true; + return dsh.kind === "app" && APP_OFFER.has(app?.phase) && sameVersion(app.version, dsh.verified); } --function SecretCard({ bot, request, roster, actions }) { -- const [value, setValue] = (0, import_react5.useState)(""); -- const [scope, setScope] = (0, import_react5.useState)("all"); -- const [replacing, setReplacing] = (0, import_react5.useState)(false); -- const [busy, setBusy] = (0, import_react5.useState)(false); -- const [error, setError] = (0, import_react5.useState)(""); -- const noteId = (0, import_react5.useId)(); -- const secret = (roster.secrets ?? []).find((entry) => entry.name === request.name); -- const run = (promise) => { -- setBusy(true); -- setError(""); -- return Promise.resolve(promise).then(() => setValue(""), (failure) => setError(failure?.message ?? String(failure))).finally(() => setBusy(false)); -- }; -- const cancel = () => run(actions.secretCancel(request.id)); -- const long = value.trim().length >= MIN_VALUE; -- const save = () => { -- if (long && !busy) void run(actions.secretSet({ requestId: request.id, value, scope })); -- }; -- const allow = () => { -- if (!busy) void run(actions.secretAllow(request.id)); -- }; -- const filling = request.kind === "fill" || replacing; -- const warn = insecure() ? (0, import_react5.createElement)("div", { className: "bt-secret-warn", role: "note" }, (0, import_react5.createElement)(WarnIcon), t("This page is not on HTTPS: the key crosses the network unencrypted.")) : null; -- return (0, import_react5.createElement)( -- "div", -- { className: "bt-q bt-secret", role: "group", "aria-describedby": noteId, "data-kind": request.kind }, -- (0, import_react5.createElement)(SecretHead, { -- bot, -- title: request.kind === "allow" ? t("Let {bot} use {name}?", { bot: bot.name, name: request.name }) : t("{name} needs a key", { name: bot.name }), -- detail: request.purpose, -- name: request.name, -- onClose: busy ? void 0 : cancel -- }), -- request.kind === "allow" ? (0, import_react5.createElement)("div", { className: "bt-secret-saved" }, (0, import_react5.createElement)(KeyIcon), t("Saved for {scope}", { scope: scopeLabel(secret?.scope, roster) })) : null, -- filling ? (0, import_react5.createElement)( -- "div", -- { className: "bt-secret-body" }, -- (0, import_react5.createElement)("label", { className: "bt-q-field bt-secret-field" }, (0, import_react5.createElement)(MaskedInput, { value, onChange: setValue, onEnter: save, label: t("Value of {name}", { name: request.name }), autoFocus: replacing })), -- request.kind === "fill" ? (0, import_react5.createElement)(ScopeSwitch, { value: scope, onChange: setScope, bot }) : null -- ) : null, -- (0, import_react5.createElement)( -- "div", -- { className: "bt-secret-note", id: noteId }, -- t("{bot} never sees the value. It reaches {bot}'s shell as ", { bot: bot.name }), -- (0, import_react5.createElement)("code", null, `$DSH_SECRET_${request.name}`), -- ".", -- filling && value !== "" && !long ? t(" At least {count} characters.", { count: MIN_VALUE }) : "" -- ), -- warn, -- error ? (0, import_react5.createElement)("div", { className: "bt-secret-error", role: "alert" }, error) : null, -- (0, import_react5.createElement)( -- "div", -- { className: "bt-q-foot" }, -- request.kind === "allow" && !replacing ? (0, import_react5.createElement)("button", { type: "button", className: "bt-q-btn", disabled: busy, onClick: () => setReplacing(true) }, t("Replace value")) : null, -- (0, import_react5.createElement)("button", { type: "button", className: "bt-q-btn", disabled: busy, onClick: cancel }, t("Cancel")), -- filling ? (0, import_react5.createElement)("button", { type: "button", className: "bt-q-submit", disabled: !long || busy, onClick: save }, busy ? t("Saving…") : t("Save")) : (0, import_react5.createElement)("button", { type: "button", className: "bt-q-submit", disabled: busy, onClick: allow }, busy ? t("Allowing…") : t("Allow")) -- ) -- ); +-function useFileMentions({ node, openFile, fileMentions, useTurnData }) { +- const tail = useTurnData?.("turn-tail"); +- const location2 = node.location; +- const turn = location2?.kind === "turn" || location2?.kind === "step" ? location2.turn : void 0; +- const seq = node.data.finalNode?.seq; +- return (0, import_react6.useMemo)(() => { +- if (typeof fileMentions !== "function" || turn?.status !== "closed" || seq === void 0) return void 0; +- return tail?.closing?.finalNode.seq === seq ? fileMentions({ turn, seq, openFile }) : void 0; +- }, [fileMentions, openFile, seq, tail, turn]); + +// src/client/overlay-hooks.js +var import_react11 = require("react"); @@ -8204,24 +7035,39 @@ diff --git a/client.js b/client.js + }; + }, []); } --function SettledSecret({ bot, request, roster }) { -- const canceled = request.status === "canceled"; -- const text = canceled ? t("{bot} asked for {name}", { bot: bot.name, name: request.name }) : request.status === "allowed" ? t("Allowed {bot} to use {name}", { bot: bot.name, name: request.name }) : t("Saved {name}", { name: request.name }); -- return (0, import_react5.createElement)( -- "div", -- { className: "bt-q bt-q-settled bt-secret bt-secret-done", role: "group", "aria-label": text, "data-status": request.status }, -- (0, import_react5.createElement)( -- "div", -- { className: "bt-q-head" }, -- (0, import_react5.createElement)("span", { className: "bt-secret-who" }, (0, import_react5.createElement)(BotMark, { bot, size: 22 })), -- (0, import_react5.createElement)("div", { className: "bt-q-text" }, (0, import_react5.createElement)( -- "p", -- { className: "bt-q-title" }, -- text, -- canceled ? null : (0, import_react5.createElement)("span", { className: "bt-secret-scope-tag" }, ` · ${scopeLabel(request.scope, roster)}`) -- )), -- canceled ? (0, import_react5.createElement)("span", { className: "bt-q-badge" }, t("Canceled")) : (0, import_react5.createElement)("span", { className: "bt-q-check bt-secret-check", title: t("Done") }, (0, import_react5.createElement)(CheckIcon)) -- ) +-function AssistantCell({ node, groupPart, sessionId, openFile, fileMentions, useTurnData, useRoster, useUi, useTurnSteps, useTurnContext, actions }) { +- const roster = useRoster((value) => value); +- const data = node.data; +- const steps = useTurnSteps(String(data.turn)); +- const contexts = useTurnContext(String(data.turn)); +- const mentions = useFileMentions({ node, openFile, fileMentions, useTurnData }); +- const hidden = Boolean(roster.roomsById[sessionId]) || isHiddenTurn(roster, sessionId, data.turn); +- const texts2 = groupPart === "reasoning" ? [] : data.blocks.filter((block) => block.kind === "text" && block.text.trim() !== "").map((block) => block.text); +- const running = data.status === "running"; +- useLiveNote(actions, sessionId, `text:${node.key ?? data.seq}`, !hidden && running && texts2.length > 0 ? { text: true } : null); +- if (hidden) return null; +- const reasoning = groupPart === "response" ? "" : turnReasoning(steps, data); +- const relays = relayPart(data, groupPart) ? relaysBefore(contexts, steps, data) : []; +- if (texts2.length === 0 && !reasoning && relays.length === 0) return null; +- const selfId = roster.byId[sessionId]?.id ?? sessionId; +- return (0, import_react6.createElement)( +- import_react6.Fragment, +- null, +- relays.map((relay) => { +- const source = relay.source; +- const sender = roster.byId[source.senderSessionId] ?? { id: source.senderSessionId, name: source.senderName ?? t("a Bot"), color: "gray" }; +- return (0, import_react6.createElement)(EventLine, { +- key: relay.seq, +- prefix: source.role === "stopped" ? t("Stopped by") : source.role === "reply" ? t("Reply from") : t("Message from"), +- bots: [sender], +- onOpen: () => { +- if (sender.id) actions.openExchange(selfId, sender.id); +- }, +- whole: true +- }); +- }), +- reasoning ? (0, import_react6.createElement)(ThinkingRow, { text: reasoning }) : null, +- texts2.length ? (0, import_react6.createElement)(Bubbles, { texts: texts2, roster, selfId, actions, streaming: running, sessionId, msgKey: `${sessionId}:${data.turn}:${node.key ?? data.seq ?? ""}`, useUi, mentions }) : null - ); +function usePaneLeft() { + const [paneLeft, setPaneLeft] = (0, import_react11.useState)(280); @@ -8231,8 +7077,30 @@ diff --git a/client.js b/client.js + }, []); + return paneLeft; } --function RequestView({ bot, request, roster, actions }) { -- return request.status === "pending" ? (0, import_react5.createElement)(SecretCard, { key: `${request.id}:${request.kind}`, bot, request, roster, actions }) : (0, import_react5.createElement)(SettledSecret, { bot, request, roster }); +-function EventLine({ prefix, bots, joiner = t("and"), onOpen, whole = false }) { +- if (whole && bots.length === 1) { +- return (0, import_react6.createElement)( +- "div", +- { className: "bt-event" }, +- (0, import_react6.createElement)( +- "button", +- { type: "button", className: "bt-event-whole", title: bots[0].name, onClick: () => onOpen?.(bots[0]) }, +- (0, import_react6.createElement)("span", null, prefix), +- (0, import_react6.createElement)(BotMark, { bot: bots[0], size: 16 }), +- (0, import_react6.createElement)("span", { className: "bt-event-name" }, bots[0].name) +- ) +- ); +- } +- const parts = []; +- parts.push((0, import_react6.createElement)("span", { key: "p" }, prefix)); +- bots.forEach((bot, index) => { +- if (index > 0) parts.push((0, import_react6.createElement)("span", { key: `j${index}` }, joiner)); +- parts.push((0, import_react6.createElement)( +- "button", +- { key: bot.id ?? index, type: "button", onClick: () => onOpen?.(bot) }, +- (0, import_react6.createElement)(BotMark, { bot, size: 16 }), +- bot.name +- )); + +// src/client/member-status.js +var import_react12 = require("react"); @@ -8255,102 +7123,26 @@ diff --git a/client.js b/client.js + const approvals = askedIn(room2, work, id, doing); + return { id, approvals, doing, here: doing !== void 0, state: approvals.length > 0 ? "ask" : doing ? "work" : "idle" }; + }); - } --function SecretToolCell({ sessionId, callId, name, done, content, roster, actions }) { -- const bot = roster.byId[sessionId]; -- if (!bot) return null; -- const request = (roster.secretRequests?.[bot.id] ?? []).find((entry) => entry.callId === callId && (entry.sessionId ?? sessionId) === sessionId); -- if (request) return (0, import_react5.createElement)("div", { className: "bt-q-inline" }, (0, import_react5.createElement)(RequestView, { bot, request, roster, actions })); -- if (done && /already saved/.test(contentText(content))) { -- return (0, import_react5.createElement)("div", { className: "bt-event" }, (0, import_react5.createElement)("span", { className: "bt-secret-event" }, (0, import_react5.createElement)(KeyIcon), t("{bot} is using {name}", { bot: bot.name, name: name ?? t("a saved key") }))); -- } -- return null; ++} +function busyMembers(room2, work, roster) { + const busy = memberWork(room2, work).filter((entry) => entry.state !== "idle" && roster.byId[entry.id]); + return [...busy.filter((entry) => entry.state === "ask"), ...busy.filter((entry) => entry.state === "work")].map((entry) => ({ ...entry, bot: roster.byId[entry.id] })); - } --function SecretDock({ sessionId, useRoster, useCards, useSession, actions }) { -- const roster = useRoster((value) => value); -- const shown = useCards?.((state) => state[sessionId]) ?? NONE; -- const loading = useSession?.(historyLoading) === true; -- const bot = roster.byId[sessionId]; -- if (!bot || currentPart(bot) !== sessionId) return null; -- const drawn = (entry) => entry.callId && entry.sessionId === sessionId && (loading || shown[entry.callId] > 0); -- const waiting = (roster.secretRequests?.[bot.id] ?? []).filter((entry) => entry.status === "pending" && !drawn(entry)); -- if (waiting.length === 0) return null; -- return (0, import_react5.createElement)("div", { className: "bt-q-dock bt-secret-dock" }, waiting.map((request) => (0, import_react5.createElement)(SecretCard, { key: `${request.id}:${request.kind}`, bot, request, roster, actions }))); ++} +function splitBusy(busy) { + const here = busy.filter((entry) => entry.state === "work" && entry.here); + const rows = [...here.filter((entry) => entry.doing.speaking === true), ...here.filter((entry) => entry.doing.speaking !== true)].slice(0, ROWS_MAX); + return { rows, rest: busy.filter((entry) => !rows.includes(entry)) }; - } --function ScopeEditor({ secret, roster, onSave }) { -- const [mode, setMode] = (0, import_react5.useState)(secret.scope === "all" ? "all" : "some"); -- const [picked, setPicked] = (0, import_react5.useState)(secret.scope === "all" ? [] : secret.scope); -- const toggle = (id) => setPicked((list) => list.includes(id) ? list.filter((item) => item !== id) : [...list, id]); -- const same = mode === "all" ? secret.scope === "all" : secret.scope !== "all" && [...picked].sort().join() === [...secret.scope].sort().join(); -- return (0, import_react5.createElement)( -- "div", -- { className: "bt-secret-edit-block" }, -- (0, import_react5.createElement)("div", { className: "bt-secret-edit-label" }, t("Who may use it")), -- (0, import_react5.createElement)( -- "div", -- { className: "bt-secret-radios", role: "radiogroup", "aria-label": t("Who may use {name}", { name: secret.name }) }, -- [["all", t("All Bots")], ["some", t("Chosen Bots")]].map(([id, text]) => (0, import_react5.createElement)( -- "label", -- { key: id, className: "bt-secret-radio" }, -- (0, import_react5.createElement)("input", { type: "radio", name: `scope-${secret.name}`, checked: mode === id, onChange: () => setMode(id) }), -- text -- )) -- ), -- mode === "some" ? (0, import_react5.createElement)("div", { className: "bt-secret-bots" }, roster.bots.map((bot) => (0, import_react5.createElement)("button", { -- key: bot.id, -- type: "button", -- className: "bt-secret-bot", -- "aria-pressed": picked.includes(bot.id), -- onClick: () => toggle(bot.id) -- }, (0, import_react5.createElement)(BotAvatar, { bot, size: 20, badge: false, live: false }), bot.name, picked.includes(bot.id) ? (0, import_react5.createElement)(CheckIcon) : null))) : null, -- (0, import_react5.createElement)( -- "div", -- { className: "bt-secret-row-actions" }, -- (0, import_react5.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: same, onClick: () => onSave(mode === "all" ? "all" : picked) }, t("Save access")) -- ) -- ); ++} +function botGroupWork(botId, work, roster) { + return Object.entries(work.rooms ?? {}).flatMap(([roomId, entries2]) => { + const entry = entries2.find((item) => item.botId === botId); + const room2 = roster.roomsById[roomId]; + return entry && room2 ? [{ room: room2, tool: entry.tool, step: stepIn(work, botId, roomId, entry.tool), since: entry.since }] : []; -+ }); + }); +- return (0, import_react6.createElement)("div", { className: "bt-event" }, parts); } --function ValueEditor({ secret, onSave }) { -- const [value, setValue] = (0, import_react5.useState)(""); -- const [replaced, setReplaced] = (0, import_react5.useState)(false); -- const long = value.trim().length >= MIN_VALUE; -- const save = () => { -- if (!long) return; -- void Promise.resolve(onSave(value)).then((ok) => { -- if (ok) { -- setValue(""); -- setReplaced(true); -- } -- }); -- }; -- const edit = (next) => { -- setValue(next); -- setReplaced(false); -- }; -- return (0, import_react5.createElement)( -- "div", -- { className: "bt-secret-edit-block" }, -- (0, import_react5.createElement)("div", { className: "bt-secret-edit-label" }, t("Replace the value")), -- (0, import_react5.createElement)( -- "div", -- { className: "bt-secret-inline" }, -- (0, import_react5.createElement)("label", { className: "bt-q-field bt-secret-field" }, (0, import_react5.createElement)(MaskedInput, { value, onChange: edit, onEnter: save, label: t("New value of {name}", { name: secret.name }) })), -- (0, import_react5.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: !long, onClick: save }, t("Replace")) -- ), -- value !== "" && !long ? (0, import_react5.createElement)("div", { className: "bt-set-hint" }, t("At least {count} characters.", { count: MIN_VALUE })) : replaced ? (0, import_react5.createElement)("div", { className: "bt-set-hint bt-secret-ok", role: "status" }, (0, import_react5.createElement)(CheckIcon), t("Replaced. Bots get the new value from their next command.")) : null +-function GroupMessage({ bot, text, room: room2, roster, actions, msgKey, useUi }) { +- const lead = (0, import_react6.createElement)( +function summaryText(busy) { + const asks = busy.filter((entry) => entry.state === "ask"); + const workers = busy.filter((entry) => entry.state === "work"); @@ -8365,44 +7157,25 @@ diff --git a/client.js b/client.js + "span", + { className: "bt-mw-face", "data-ask": entry.state === "ask" || void 0 }, + (0, import_react12.createElement)(BotMark, { bot: entry.bot, size, state: entry.state === "ask" ? "alert" : toolState(entry.doing?.tool ?? "") }) - ); - } --function SecretRow({ secret, roster, actions, run, open, onToggle }) { -- const [confirm, setConfirm] = (0, import_react5.useState)(false); -- (0, import_react5.useEffect)(() => { -- if (!open) setConfirm(false); -- }, [open]); -- const asker = secret.requestedBy ? roster.byId[secret.requestedBy]?.name ?? t("a deleted Bot") : t("you"); -- const allowed = secret.scope === "all" ? [] : secret.scope.map((id) => roster.byId[id]).filter(Boolean); -- const meta = [t("Asked by {name}", { name: asker }), t("added {time}", { time: when(secret.createdAt) }), secret.lastUsedAt ? t("last used {time}", { time: when(secret.lastUsedAt) }) : t("not used yet")].join(" · "); -- return (0, import_react5.createElement)( -- "div", -- { className: "bt-secret-item", "data-open": open ? "" : void 0 }, -- (0, import_react5.createElement)( -- "button", -- { type: "button", className: "bt-set-row bt-secret-summary", "aria-expanded": open, onClick: onToggle }, -- (0, import_react5.createElement)( -- "span", -- { className: "bt-set-copy" }, -- (0, import_react5.createElement)("span", { className: "bt-secret-title" }, (0, import_react5.createElement)(KeyIcon), (0, import_react5.createElement)("code", { className: "bt-secret-name" }, secret.name), secret.purpose ? (0, import_react5.createElement)("span", { className: "bt-secret-purpose" }, secret.purpose) : null), -- (0, import_react5.createElement)("span", { className: "bt-set-hint" }, meta) -- ), -- (0, import_react5.createElement)( -- "span", -- { className: "bt-set-control" }, -- secret.scope === "all" ? (0, import_react5.createElement)("span", { className: "bt-tag" }, t("All Bots")) : allowed.length === 0 ? (0, import_react5.createElement)("span", { className: "bt-tag" }, t("No Bot")) : (0, import_react5.createElement)("span", { className: "bt-secret-faces", title: allowed.map((bot) => bot.name).join(", ") }, allowed.slice(0, 5).map((bot) => (0, import_react5.createElement)(BotAvatar, { key: bot.id, bot, size: 20, badge: false, live: false }))) -- ) ++ ); ++} +var elapsedSince = (now, at) => typeof at === "number" && now - at >= 6e4 ? elapsedLabel(now - at) : ""; +function WorkRow({ bot, tool, step, label, at, now, onOpen }) { + const text = `${label} · ${workText(tool, step)}`; + const elapsed = elapsedSince(now, at); + return (0, import_react12.createElement)( -+ "button", + "button", +- { type: "button", className: "bt-lead", "aria-label": t("Open {name}'s chat", { name: bot.name }), onClick: () => actions.openSession(bot.id) }, +- (0, import_react6.createElement)(BotMark, { bot, size: 22 }) + { type: "button", className: "bt-mw-row", "aria-label": [text, elapsed].filter(Boolean).join(" · "), onClick: onOpen }, + (0, import_react12.createElement)(BotMark, { bot, size: 22, state: toolState(tool ?? ""), live: true }), + (0, import_react12.createElement)("span", { className: "bt-mw-row-text" }, (0, import_react12.createElement)(Shimmer, null, text)), + elapsed ? (0, import_react12.createElement)("span", { className: "bt-mw-row-time" }, `· ${elapsed}`) : null -+ ); + ); +- return (0, import_react6.createElement)( +- "div", +- { className: "bt-group-msg" }, +- (0, import_react6.createElement)( +} +function SummaryLine({ busy, now, onOpen }) { + const { waiting, doing } = summaryText(busy); @@ -8412,43 +7185,21 @@ diff --git a/client.js b/client.js + { type: "button", className: "bt-mw-row bt-mw-line", "aria-label": [waiting, doing, elapsed].filter(Boolean).join(" · "), onClick: onOpen }, + (0, import_react12.createElement)("span", { className: "bt-mw-stack", "aria-hidden": true }, busy.slice(0, 4).map((entry) => (0, import_react12.createElement)(Face, { key: entry.id, entry, size: 16 }))), + (0, import_react12.createElement)( -+ "span", + "span", +- { className: "bt-group-name", style: { color: inkText(colorOf(bot)) } }, +- bot.name, +- room2?.admin === bot.id ? (0, import_react6.createElement)("span", { className: "bt-group-role" }, t("Admin")) : null + { className: "bt-mw-row-text" }, + waiting ? (0, import_react12.createElement)("span", { className: "bt-mw-ask" }, waiting) : null, + waiting && doing ? " · " : null, + doing ? (0, import_react12.createElement)(Shimmer, null, doing) : null ), -- open ? (0, import_react5.createElement)( -- "div", -- { className: "bt-secret-edit" }, -- (0, import_react5.createElement)(ScopeEditor, { key: JSON.stringify(secret.scope), secret, roster, onSave: (scope) => run(actions.secretUpdate(secret.name, scope)) }), -- (0, import_react5.createElement)(ValueEditor, { secret, onSave: (value) => run(actions.secretSet({ name: secret.name, value })) }), -- (0, import_react5.createElement)( -- "div", -- { className: "bt-secret-row-actions" }, -- confirm ? [ -- (0, import_react5.createElement)("span", { key: "q", className: "bt-set-hint" }, t("Delete {name}? Bots lose it at once.", { name: secret.name })), -- (0, import_react5.createElement)("button", { key: "no", type: "button", className: "bt-soft bt-soft-sm", onClick: () => setConfirm(false) }, t("Keep")), -- (0, import_react5.createElement)("button", { key: "yes", type: "button", className: "bt-soft bt-soft-sm bt-secret-danger", onClick: () => run(actions.secretDelete(secret.name)) }, t("Delete")) -- ] : (0, import_react5.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm bt-secret-danger", onClick: () => setConfirm(true) }, (0, import_react5.createElement)(TrashIcon), t("Delete")) -- ) -- ) : null +- (0, import_react6.createElement)(Bubbles, { texts: [text], roster, selfId: bot.id, actions, sessionId: room2?.id, msgKey, useUi, lead }) + elapsed ? (0, import_react12.createElement)("span", { className: "bt-mw-row-time" }, `· ${elapsed}`) : null ); - } --function AddSecret({ actions, run, onDone }) { -- const [name, setName] = (0, import_react5.useState)(""); -- const [purpose, setPurpose] = (0, import_react5.useState)(""); -- const [value, setValue] = (0, import_react5.useState)(""); -- const ready = /^[A-Za-z][\w-]{0,63}$/.test(name.trim()) && value.trim().length >= MIN_VALUE; -- const save = () => { -- if (!ready) return; -- void run(actions.secretSet({ name, purpose, value, scope: "all" })).then((ok) => { -- if (ok) { -- setValue(""); -- onDone(); -- } -- }); + } +-function GroupEvent({ prefix, room: room2, name, roster, actions }) { +- return (0, import_react6.createElement)( +function MemberDock({ sessionId, useRoster, useWork, actions }) { + const roster = useRoster((value) => value); + const work = useWork((value) => value); @@ -8519,26 +7270,19 @@ diff --git a/client.js b/client.js + const answer = (choice) => { + setBusy(true); + void Promise.resolve(actions.answerApproval(approval.id, choice)).catch((error) => actions.reportError(error, "approval")).finally(() => setBusy(false)); - }; -- return (0, import_react5.createElement)( ++ }; + return (0, import_react12.createElement)( "div", -- { className: "bt-set-row bt-set-paste bt-secret-add" }, -- (0, import_react5.createElement)("input", { className: "bt-input", "aria-label": t("Key name"), placeholder: t("NAME, e.g. GITHUB_TOKEN"), value: name, spellCheck: false, autoComplete: "off", autoFocus: true, onChange: (event) => setName(event.target.value.toUpperCase()) }), -- (0, import_react5.createElement)("input", { className: "bt-input", "aria-label": t("What it is for"), placeholder: t("What it is for"), value: purpose, autoComplete: "off", onChange: (event) => setPurpose(event.target.value) }), -- (0, import_react5.createElement)("label", { className: "bt-q-field bt-secret-field" }, (0, import_react5.createElement)(MaskedInput, { value, onChange: setValue, onEnter: save, label: t("Value") })), -- (0, import_react5.createElement)( +- { className: "bt-event" }, +- (0, import_react6.createElement)("span", null, prefix), +- room2 ? (0, import_react6.createElement)("button", { type: "button", onClick: () => actions.openSession(room2.id) }, (0, import_react6.createElement)(RoomAvatar, { room: room2, roster, size: 16 }), room2.name) : name ? (0, import_react6.createElement)("span", null, name) : null + { className: "bt-mw-card", role: "group", "aria-label": t("{name} is waiting for your approval", { name: bot.name }) }, + (0, import_react12.createElement)("div", { className: "bt-mw-tool" }, approval.tool), + approval.detail?.text ? (0, import_react12.createElement)("div", { className: "bt-mw-text" }, approval.detail.text) : null, + approval.reason ? (0, import_react12.createElement)("div", { className: "bt-mw-reason" }, approval.reason) : null, + approval.detail?.code ? (0, import_react12.createElement)("code", { className: "bt-mw-code", title: approval.detail.code }, approval.detail.code) : null, + approval.answerable ? (0, import_react12.createElement)( - "div", -- { className: "bt-secret-row-actions" }, -- (0, import_react5.createElement)("span", { className: "bt-set-hint" }, t("New keys are for all Bots; narrow it down afterwards.")), -- (0, import_react5.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: onDone }, t("Cancel")), -- (0, import_react5.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: !ready, onClick: save }, t("Save key")) ++ "div", + { className: "bt-mw-actions" }, + (0, import_react12.createElement)("button", { type: "button", className: "bt-q-btn", disabled: busy, onClick: () => answer("reject") }, t("Reject")), + (0, import_react12.createElement)("button", { type: "button", className: "bt-q-submit", disabled: busy, onClick: () => answer("allow") }, t("Allow once")), @@ -8547,119 +7291,18 @@ diff --git a/client.js b/client.js + "div", + { className: "bt-mw-actions" }, + (0, import_react12.createElement)("button", { type: "button", className: "bt-q-btn", onClick: () => actions.openSession(bot.id) }, t("Open {name}'s chat", { name: bot.name })) - ) ++ ) ); } --function SecretsPage({ roster, actions, run, Section }) { -- const [open, setOpen] = (0, import_react5.useState)(null); -- const [adding, setAdding] = (0, import_react5.useState)(false); -- const secrets = roster.secrets ?? []; -- return [ -- (0, import_react5.createElement)( -- "p", -- { key: "about", className: "bt-secret-about" }, -- t("Keys your Bots asked for. Bots never see the values: they use them as "), -- (0, import_react5.createElement)("code", null, "$DSH_SECRET_NAME"), -- t(" in their shell, and every value is replaced by "), -- (0, import_react5.createElement)("code", null, "[secret:NAME]"), -- t(" before anything goes to a model.") -- ), -- (0, import_react5.createElement)( -- Section, -- { key: "list", title: t("Secrets · {count}", { count: secrets.length }) }, -- secrets.length === 0 && !adding ? (0, import_react5.createElement)("div", { className: "bt-set-row" }, (0, import_react5.createElement)("span", { className: "bt-set-hint" }, t("No keys yet. When a Bot needs one, it shows a card in its chat."))) : null, -- secrets.map((secret) => (0, import_react5.createElement)(SecretRow, { key: secret.name, secret, roster, actions, run, open: open === secret.name, onToggle: () => setOpen(open === secret.name ? null : secret.name) })), -- adding ? (0, import_react5.createElement)(AddSecret, { actions, run, onDone: () => setAdding(false) }) : null -- ), -- adding ? null : (0, import_react5.createElement)("div", { key: "add", className: "bt-actions" }, (0, import_react5.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => setAdding(true) }, t("Add a key"))) -- ]; --} --var SECRET_CSS = ` --.bt-secret-head{align-items:center;gap:10px} --.bt-secret-who{position:relative;flex:none;display:inline-flex} --.bt-secret-lock{position:absolute;right:-5px;bottom:-4px;width:16px;height:16px;border-radius:50%;background:var(--bt-main);color:var(--bt-ink-2);display:inline-flex;align-items:center;justify-content:center;box-shadow:0 0 0 1.5px var(--bt-bubble)} --.bt-secret-lock svg{width:11px;height:11px} --.bt-secret-name{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px;line-height:16px;padding:1px 6px;border-radius:5px;background:var(--bt-hover);color:var(--bt-ink)} --.bt-secret-body{display:flex;flex-direction:column;gap:8px} --.bt-secret-field{align-items:center;min-height:36px} --.bt-secret-input{flex:1;width:100%;min-width:0;margin:0;padding:0;border:0;outline:none;background:transparent;color:var(--bt-ink);font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:14px;line-height:20px;letter-spacing:.04em} --.bt-secret-input[data-masked]{-webkit-text-security:disc} --.bt-secret-input::placeholder{color:var(--bt-ink-3);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;letter-spacing:0} --.bt-secret-scope{position:relative;display:inline-grid;grid-template-columns:1fr 1fr;align-self:flex-start;padding:2px;border-radius:999px;background:var(--bt-hover)} --.bt-secret-glide{position:absolute;top:2px;bottom:2px;left:2px;width:calc(50% - 2px);border-radius:999px;background:var(--bt-main);box-shadow:0 1px 3px rgba(20,20,20,.08),inset 0 0 0 .5px color-mix(in srgb,var(--bt-ink) 6%,transparent);transition:${spring(SPRING_SHAPE, "transform")}} --.bt-secret-glide[data-at="1"]{transform:translateX(100%)} --.bt-secret-seg{position:relative;z-index:1;height:28px;padding:0 14px;border:0;border-radius:999px;background:none;color:var(--bt-ink-2);font:inherit;font-size:13px;white-space:nowrap;cursor:pointer;transition:color .16s ease} --.bt-secret-seg[aria-checked=true]{color:var(--bt-ink);font-weight:500} --.bt-secret-seg:focus-visible{outline:2px solid var(--bt-accent);outline-offset:-2px} --/* A key card asks the user to act, so the process group carrying it stays open in every -- work-details mode (same override as .bt-group-replies in styles.js). */ --[data-step-process]:has(.bt-secret)>div:first-child{display:none!important} --[data-step-process]:has(.bt-secret)>[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} --.bt-secret-ok{display:flex;align-items:center;gap:4px;margin-top:6px;animation:bt-secret-in .22s cubic-bezier(.22,1,.36,1)} --.bt-secret-ok svg{width:13px;height:13px;color:#16a34a} --@keyframes bt-secret-in{from{opacity:0;transform:translateY(-2px)}} --.bt-secret-saved{display:flex;align-items:center;gap:6px;font-size:13px;line-height:18px;color:var(--bt-ink-2)} --.bt-secret-note{font-size:12px;line-height:17px;color:var(--bt-ink-3)} --.bt-secret-note code,.bt-secret-about code{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11.5px} --.bt-secret-warn{display:flex;align-items:center;gap:6px;padding:6px 10px;border-radius:8px;font-size:12px;line-height:16px;color:#b25e09;background:color-mix(in srgb,#f59e0b 12%,transparent)} --.bt-secret-error{font-size:12px;line-height:16px;color:#e02135} --.bt-secret .bt-q-btn:disabled{opacity:.5;cursor:default} --.bt-secret-done .bt-q-head{align-items:center} --.bt-secret-done .bt-q-title{font-size:13px;line-height:18px;color:var(--bt-ink-2);font-weight:500} --.bt-secret-scope-tag{font-weight:400;color:var(--bt-ink-3)} --.bt-secret-check{color:var(--bt-green,#22a06b);animation:bt-secret-pop ${SPRING_POP.duration}ms ${SPRING_POP.easing} backwards} --.bt-secret-done{animation:bt-secret-settle .32s cubic-bezier(.22,1,.36,1) backwards} --@keyframes bt-secret-pop{from{opacity:0;transform:scale(.4)}} --@keyframes bt-secret-settle{from{opacity:.4;transform:scale(.985)}} --.bt-secret-event{display:inline-flex;align-items:center;gap:5px} --.bt-secret-dock{flex-direction:column;align-items:center} --.bt-secret-dock .bt-q{max-width:min(550px,calc(100% - 32px))} --.bt-secret-about{margin:0 0 12px;padding:0 4px;font-size:12px;line-height:17px;color:var(--bt-ink-2)} --.bt-secret-title{display:flex;align-items:center;gap:6px;min-width:0;color:var(--bt-ink)} --.bt-secret-title svg{flex:none;color:var(--bt-ink-2)} --.bt-secret-purpose{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--bt-ink-2)} --.bt-secret-faces{display:inline-flex} --.bt-secret-faces>*+*{margin-left:-6px} --.bt-secret-edit{display:flex;flex-direction:column;gap:14px;padding:4px 12px 14px;animation:bt-q-in ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing} backwards,bt-veil-in .2s ease-out backwards} --.bt-secret-edit-block{display:flex;flex-direction:column;gap:8px} --.bt-secret-edit-label{font-size:12px;line-height:16px;font-weight:600;color:var(--bt-ink-2)} --.bt-secret-radios{display:flex;gap:16px;font-size:13px} --.bt-secret-radio{display:inline-flex;align-items:center;gap:6px;cursor:pointer} --.bt-secret-bots{display:flex;flex-wrap:wrap;gap:6px} --.bt-secret-bot{display:inline-flex;align-items:center;gap:6px;height:30px;padding:0 10px 0 5px;border-radius:999px;border:.5px solid var(--bt-line);background:var(--bt-card);color:var(--bt-ink-2);font:inherit;font-size:12px;cursor:pointer;transition:background-color .12s ease,color .12s ease,border-color .12s ease} --.bt-secret-bot[aria-pressed=true]{background:var(--bt-active);color:var(--bt-ink);border-color:var(--bt-line-2)} --.bt-secret-bot svg{width:13px;height:13px} --.bt-secret-inline{display:flex;gap:8px;align-items:center} --.bt-secret-row-actions{display:flex;align-items:center;justify-content:flex-end;gap:8px;flex-wrap:wrap} --.bt-secret-row-actions .bt-set-hint{flex:1} --.bt-secret-danger{color:#e02135;display:inline-flex;align-items:center;gap:4px} --.bt-secret-danger svg{width:13px;height:13px} --.bt-secret-add .bt-input{height:34px} --@media (prefers-reduced-motion:reduce){.bt-secret-glide{transition:none}.bt-secret-check,.bt-secret-done,.bt-secret-ok,.bt-secret-edit{animation:none}} --`; -- --// src/client/turn-reasoning.js --function turnReasoning(steps, current) { -- const members = steps.length ? steps : [current]; -- if (members.find((step) => stepReasoning(step) !== "")?.step !== current.step) return ""; -- return members.filter((step) => !thinkingLive(step)).map(stepReasoning).filter(Boolean).join("\n\n"); --} --var texts = (step) => step.blocks.filter((block) => block.kind === "text" && block.text.trim() !== ""); --var thinkingLive = (step) => step.status === "running" && texts(step).length === 0 && step.blocks.some((block) => block.kind === "reasoning"); --var stepReasoning = (step) => step.blocks.filter((block) => block.kind === "reasoning").map((block) => block.text).join("\n").trim(); -- --// src/client/cells.js --var import_react6 = require("react"); --var import_react_dom = require("react-dom"); --var import_dsh_client_ui_primitives = require("@deepseek-ai/dsh-client-ui-primitives"); --var mdLabels = () => ({ code: { copyLabel: t("Copy"), copiedLabel: t("Copied") }, footnotes: t("Footnotes") }); --function Bubbles({ texts: texts2, roster, selfId, actions, streaming, sessionId, msgKey, useUi, lead, mentions }) { -- const onClick = (event) => { -- const target = mentionTarget(event); -- if (target === null) return; -- event.preventDefault(); -- event.stopPropagation(); -- actions.openSession(target); +-function MemoryEvent({ sessionId, scope, result, roster, actions, findBot }) { +- if (!/^(Saved to|Updated in|Removed from) /.test(result)) return null; +- const self = roster.byId[sessionId]; +- const team = /^(team|团队|共享)$/i.test(String(scope ?? "").trim()); +- const other = team || !scope ? void 0 : findBot(scope); +- const target = other && other.id !== self?.id ? other : self; +- if (!target) return null; +- const label = team ? t("Team memory updated") : target === self ? t("Memory updated") : t("Updated {name}'s memory", { name: target.name }); +- return (0, import_react6.createElement)( +function MemberWork({ entry, bot, room: room2, actions, now }) { + const [stopping, setStopping] = (0, import_react12.useState)(false); + if (entry.state === "ask") { @@ -8674,41 +7317,11 @@ diff --git a/client.js b/client.js + const stop = () => { + setStopping(true); + void Promise.resolve(actions.stopMemberWork(room2.id, bot.id)).catch((error) => actions.reportError(error, "stop")).finally(() => setStopping(false)); - }; -- const reactions = useUi ? useUi((state) => msgKey ? state.reactions?.[msgKey] ?? null : null) : null; -- return (0, import_react6.createElement)( -- "div", -- { className: "bt-astack", "data-lead": lead ? "" : void 0, onClickCapture: onClick }, -- texts2.map((text, index) => { -- const key = msgKey ? `${msgKey}:${index}` : void 0; -- const picked = key ? reactions?.[index] ?? [] : []; -- const last = index === texts2.length - 1; -- const live = streaming && last; -- return (0, import_react6.createElement)( -- "div", -- { key: index, className: "bt-msg" }, -- (0, import_react6.createElement)( -- "div", -- { className: "bt-msg-line" }, -- lead && last ? lead : null, -- (0, import_react6.createElement)( -- "div", -- { className: "bt-bubble" }, -- (0, import_react6.createElement)(import_dsh_client_ui_primitives.MarkdownText, { text: linkMentions(text, roster.bots, selfId), streaming: live, labels: mdLabels(), variant: "compact", fileMentions: mentions }) -- ), -- live ? null : (0, import_react6.createElement)(MessageTools, { text, name: roster.byId[selfId]?.name ?? t("Bot"), sessionId, react: key ? (emoji) => actions.toggleReaction(msgKey, index, emoji) : void 0, actions }) -- ), -- picked.length ? (0, import_react6.createElement)("div", { className: "bt-reacts" }, picked.map((emoji) => (0, import_react6.createElement)("button", { -- key: emoji, -- type: "button", -- className: "bt-react", -- "aria-label": t("Remove reaction {emoji}", { emoji }), -- onClick: () => actions.toggleReaction(msgKey, index, emoji) -- }, emoji))) : null -- ); -- }) ++ }; + return (0, import_react12.createElement)( -+ "div", + "div", +- { className: "bt-event" }, +- (0, import_react6.createElement)("button", { type: "button", title: result, onClick: () => actions.openMemory(target.id) }, (0, import_react6.createElement)(MemoryIcon), label) + { className: "bt-mw-member" }, + (0, import_react12.createElement)( + "div", @@ -8718,28 +7331,17 @@ diff --git a/client.js b/client.js + ) ); } --var REACTIONS = ["👍", "❤️", "😂", "😮", "😢", "🙏"]; --function useDismiss(open, close, refs) { -- (0, import_react6.useEffect)(() => { -- if (!open) return void 0; -- const onDown = (event) => { -- if (refs.some((ref) => ref.current?.contains(event.target))) return; -- close(); -- }; -- const onKey = (event) => { -- if (event.key === "Escape") { -- event.stopPropagation(); -- close(); -- } -- }; -- window.addEventListener("mousedown", onDown); -- window.addEventListener("keydown", onKey, true); +-function PartLine({ time }) { +- return (0, import_react6.createElement)("div", { className: "bt-part-line", role: "separator" }, Number.isFinite(time) ? (0, import_react6.createElement)("span", null, dayLabel(time)) : null); +function useMemberWork(room2, useWork) { + const work = useWork?.((value) => value) ?? NO_WORK; + const entries2 = room2 ? memberWork(room2, work) : []; + const now = useNow(entries2.some((entry) => entry.state !== "idle")); + return { entries: entries2, now }; -+} + } +-var scrollParent = (node) => { +- for (let at = node.parentElement; at; at = at.parentElement) { +- if (/(auto|scroll)/.test(getComputedStyle(at).overflowY)) return at; +var MEMBER_CSS = ` +.bt-mw-dock-stack{box-sizing:border-box;display:flex;flex-direction:column;align-items:flex-start;gap:2px;width:var(--dsh-chat-content-width,min(960px,100%));margin:0 auto 6px;pointer-events:none} +.bt-mw-row{pointer-events:auto;display:flex;align-items:center;gap:10px;min-width:0;max-width:calc(100% + 6px);height:32px;margin-inline-start:-6px;padding:0 12px 0 6px;border:0;border-radius:16px;background:none;color:var(--bt-ink-2);font:inherit;font-size:14px;cursor:pointer;animation:bt-mw-in .32s cubic-bezier(.23,1,.32,1) backwards;transition:background-color .16s ease-out} @@ -8828,39 +7430,14 @@ diff --git a/client.js b/client.js + if (!stopped) timer = setTimeout(load, POLL_MS2); + }); + load(); - return () => { -- window.removeEventListener("mousedown", onDown); -- window.removeEventListener("keydown", onKey, true); ++ return () => { + stopped = true; + clearTimeout(timer); + taskListeners.delete(setTasks); - }; -- }, [open]); ++ }; + }, []); + return tasks; - } --function MessageTools({ text, name, sessionId, react, actions }) { -- const [panel, setPanel] = (0, import_react6.useState)(null); -- const [place, setPlace] = (0, import_react6.useState)(null); -- const [copied, setCopied] = (0, import_react6.useState)(false); -- const tools = (0, import_react6.useRef)(null); -- const pop = (0, import_react6.useRef)(null); -- const close = () => setPanel(null); -- useDismiss(panel !== null, close, [tools, pop]); -- const open = (kind, event) => { -- if (panel === kind) { -- close(); -- return; -- } -- const rect = event.currentTarget.getBoundingClientRect(); -- const below = rect.bottom + 220 < window.innerHeight; -- setPlace(kind === "react" ? { left: rect.left + rect.width / 2, top: rect.top - 6, "--bt-origin": "bottom center" } : { left: Math.min(rect.left, window.innerWidth - 208), ...below ? { top: rect.bottom + 4 } : { bottom: window.innerHeight - rect.top + 4, "--bt-origin": "bottom left" } }); -- setPanel(kind); -- }; -- const copy2 = () => { -- void navigator.clipboard?.writeText(text).then(() => { -- setCopied(true); -- setTimeout(() => setCopied(false), 1e3); ++} +var ownBot = (roster, id) => { + const bot = roster.byId?.[id]; + if (bot === void 0 || bot.remote) return void 0; @@ -8878,9 +7455,29 @@ diff --git a/client.js b/client.js + if (question == null || typeof question !== "object" || roster.roomsById?.[key]) continue; + if (question.question !== text) continue; + if (key === bot.id || key === currentPart(bot)) return bot.id; -+ } + } +- return null; + return void 0; -+}; + }; +-function EarlierParts({ sessionId, roster, actions }) { +- const [view, setView] = (0, import_react6.useState)({ items: [], cursor: void 0, loading: true, failed: false, startedAt: null }); +- const top = (0, import_react6.useRef)(null); +- const busy = (0, import_react6.useRef)(false); +- const alive = (0, import_react6.useRef)(true); +- const restore = (0, import_react6.useRef)(null); +- const load = (cursor) => { +- if (busy.current) return; +- busy.current = true; +- setView((current) => ({ ...current, loading: true, failed: false })); +- actions.earlier(sessionId, cursor).then((page) => { +- busy.current = false; +- if (!alive.current) return; +- const scroller = top.current && scrollParent(top.current); +- if (scroller) restore.current = { scroller, fromEnd: scroller.scrollHeight - scroller.scrollTop }; +- setView((current) => ({ items: [...page.items, ...current.items], cursor: page.cursor, loading: false, failed: false, startedAt: page.startedAt ?? current.startedAt })); +- }, () => { +- busy.current = false; +- if (alive.current) setView((current) => ({ ...current, loading: false, failed: true })); +var placeKey = (botId, roomId) => `${botId}|${roomId ?? ""}`; +var partOf = (roster, chat) => roster.roomsById?.[chat] || !roster.byId?.[chat] ? chat : currentPart(roster.byId[chat]); +var runningIn = (work, botId, roomId) => roomId === void 0 ? work.bots?.[botId]?.roomId === void 0 ? work.bots?.[botId] : void 0 : (work.rooms?.[roomId] ?? []).find((entry) => entry.botId === botId) ?? (work.bots?.[botId]?.roomId === roomId ? work.bots[botId] : void 0); @@ -8992,11 +7589,11 @@ diff --git a/client.js b/client.js + ...people(roster, bot.id) }); }; -- const download2 = () => { -- const url = URL.createObjectURL(new Blob([text], { type: "text/markdown" })); -- const link = Object.assign(document.createElement("a"), { href: url, download: `${name}-message.md` }); -- link.click(); -- setTimeout(() => URL.revokeObjectURL(url), 1e3); +- (0, import_react6.useEffect)(() => { +- alive.current = true; +- load(void 0); +- return () => { +- alive.current = false; + for (const [botId, entry] of Object.entries(work.bots ?? {})) running(botId, entry, entry.roomId); + for (const [roomId, entries2] of Object.entries(work.rooms ?? {})) for (const entry of entries2) running(entry.botId, entry, roomId); + const lastEnded = /* @__PURE__ */ new Map(); @@ -9036,7 +7633,28 @@ diff --git a/client.js b/client.js + chat: askedIn2 ?? chatOfTask(task), + task, + ...people(roster, task.workBotId ?? bot.id) -+ }; + }; +- }, [sessionId]); +- (0, import_react6.useLayoutEffect)(() => { +- const pending = restore.current; +- restore.current = null; +- if (pending) pending.scroller.scrollTop = pending.scroller.scrollHeight - pending.fromEnd; +- }, [view.items]); +- (0, import_react6.useEffect)(() => { +- const node = top.current; +- if (!node || view.loading || view.failed || !view.cursor || typeof IntersectionObserver !== "function") return void 0; +- const observer = new IntersectionObserver((entries2) => { +- if (entries2.some((entry) => entry.isIntersecting)) load(view.cursor); +- }, { root: scrollParent(node), rootMargin: "600px 0px 0px 0px" }); +- observer.observe(node); +- return () => observer.disconnect(); +- }, [view.cursor, view.loading, view.failed]); +- const selfId = roster.byId[sessionId]?.id; +- const rows = []; +- for (const item of view.items) { +- const last = rows.at(-1); +- if (item.kind === "bot" && last?.kind === "bot") last.texts.push(item.text); +- else rows.push(item.kind === "bot" ? { kind: "bot", texts: [item.text] } : item); + if (task.status === "failed") { + if (fresh(task.updatedAt)) needs.push({ ...card, status: "failed", reason: lastWhy(task) }); + } else if (task.status === "result-ready") needs.push({ ...card, status: "result", reason: task.result?.summary ?? "" }); @@ -9098,7 +7716,41 @@ diff --git a/client.js b/client.js + if (slot === void 0) { + slot = { text: "", picked: [] }; + answerSlots.set(id, slot); -+ } + } +- return (0, import_react6.createElement)( +- "div", +- { className: "bt-earlier", role: "feed", "aria-label": t("Earlier messages"), "aria-busy": view.loading }, +- (0, import_react6.createElement)( +- "div", +- { ref: top, className: "bt-earlier-top" }, +- view.loading ? (0, import_react6.createElement)(Shimmer, null, t("Loading earlier messages…")) : view.failed ? (0, import_react6.createElement)("button", { type: "button", className: "bt-soft", onClick: () => load(view.cursor) }, t("Couldn’t load earlier messages · Retry")) : view.cursor ? (0, import_react6.createElement)("button", { type: "button", className: "bt-soft", onClick: () => load(view.cursor) }, t("Load earlier")) : null +- ), +- rows.map((row, index) => { +- const key = rows.length - index; +- switch (row.kind) { +- case "divider": +- return (0, import_react6.createElement)(PartLine, { key, time: row.time }); +- case "user": +- return (0, import_react6.createElement)("div", { key, className: "bt-urow" }, (0, import_react6.createElement)( +- "div", +- { className: "bt-ububble" }, +- row.text ? (0, import_react6.createElement)(import_dsh_client_ui_primitives.MarkdownText, { text: row.text, labels: mdLabels(), variant: "compact" }) : null, +- row.images ? (0, import_react6.createElement)("span", { className: "bt-uimages" }, row.images === 1 ? t("1 image") : t("{count} images", { count: row.images })) : null +- )); +- case "answer": +- return (0, import_react6.createElement)(AnsweredCell, { key, node: { data: row } }); +- case "bot": +- return (0, import_react6.createElement)(Bubbles, { key, texts: row.texts, roster, selfId, actions }); +- case "event": { +- if (row.role === "report") return (0, import_react6.createElement)(GroupEvent, { key, prefix: t("Replies from"), room: roster.roomsById[row.roomId], name: t("a group chat"), roster, actions }); +- const other = roster.byId[row.botId] ?? { id: row.botId, name: row.name ?? t("a Bot"), color: "gray" }; +- const prefix = { stopped: t("Stopped by"), reply: t("Reply from") }[row.role] ?? t("Message from"); +- return (0, import_react6.createElement)(EventLine, { key, prefix, bots: [other], onOpen: () => { +- if (selfId && other.id) actions.openExchange(selfId, other.id); +- }, whole: true }); +- } +- default: +- return null; + return slot; +}; +var resultSlots = /* @__PURE__ */ new Map(); @@ -9118,31 +7770,7 @@ diff --git a/client.js b/client.js + const note = slot.text.trim(); + if (busy || note === "") return; + onAnswer(card, { note }); - }; -- const item = (label, Icon, run) => (0, import_react6.createElement)("button", { key: label, type: "button", role: "menuitem", onClick: () => { -- close(); -- run(); -- } }, (0, import_react6.createElement)(Icon), label); -- return (0, import_react6.createElement)( -- "div", -- { ref: tools, className: "bt-tools", "data-open": panel !== null || void 0 }, -- react ? (0, import_react6.createElement)("button", { type: "button", className: "bt-tool", "aria-label": t("Add reaction"), title: t("Add reaction"), "aria-expanded": panel === "react", onClick: (event) => open("react", event) }, (0, import_react6.createElement)(SmileIcon)) : null, -- sessionId ? (0, import_react6.createElement)("button", { type: "button", className: "bt-tool", "aria-label": t("Reply"), title: t("Reply"), onClick: () => actions.quote(sessionId, text) }, (0, import_react6.createElement)(ReplyIcon)) : null, -- (0, import_react6.createElement)("button", { type: "button", className: "bt-tool", "aria-label": t("More message actions"), title: t("More"), "aria-haspopup": "menu", "aria-expanded": panel === "more", onClick: (event) => open("more", event) }, copied ? (0, import_react6.createElement)(CheckIcon) : (0, import_react6.createElement)(DotsIcon)), -- panel === "react" ? (0, import_react_dom.createPortal)((0, import_react6.createElement)( -- "div", -- { ref: pop, className: "bt-react-strip", role: "menu", "aria-label": t("Reactions"), style: place }, -- REACTIONS.map((emoji) => (0, import_react6.createElement)("button", { key: emoji, type: "button", role: "menuitem", "aria-label": emoji, onClick: () => { -- close(); -- react(emoji); -- } }, emoji)) -- ), document.body) : null, -- panel === "more" ? (0, import_react_dom.createPortal)((0, import_react6.createElement)( -- "div", -- { ref: pop, className: "bt-menu", role: "menu", "aria-label": t("Message actions"), style: place }, -- item(t("Copy"), CopyIcon, copy2), -- item(t("Download"), DownloadIcon, download2) -- ), document.body) : null ++ }; + return (0, import_react13.createElement)( + "span", + { className: "bt-inbox-result" }, @@ -9161,7 +7789,9 @@ diff --git a/client.js b/client.js + if (event.key !== "Enter" || event.shiftKey || event.nativeEvent.isComposing) return; + event.preventDefault(); + send(); -+ } + } +- }), +- (0, import_react6.createElement)(PartLine, { time: view.startedAt }) + }) : null, + slot.open ? (0, import_react13.createElement)("button", { type: "button", className: "bt-send bt-soft-sm", disabled: busy, onClick: send }, t("Send it back")) : (0, import_react13.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy, onClick: () => { + slot.open = true; @@ -9170,12 +7800,69 @@ diff --git a/client.js b/client.js + (0, import_react13.createElement)("button", { type: "button", className: "bt-send bt-soft-sm", disabled: busy, onClick: () => onAnswer(card, "accept") }, t("Accept")) ); } --function ThinkingRow({ text }) { -- return (0, import_react6.createElement)( -- "details", -- { className: "bt-think" }, -- (0, import_react6.createElement)("summary", null, t("Thought it through")), -- (0, import_react6.createElement)("div", { className: "bt-think-text" }, text) +-function TriggerCell({ node, sessionId, useRoster, useSessions, useTurnClock, useUi, actions }) { +- const roster = useRoster((value) => value); +- const data = node.data; +- const source = data.source !== null && typeof data.source === "object" ? data.source : {}; +- const selfId = roster.byId[sessionId]?.id ?? sessionId; +- const deferred = source.kind === "bot" && source.deferred === true; +- const relayed = isRelayContext(data); +- const turn = node.location?.turn?.turn ?? data.turn; +- (0, import_react6.useLayoutEffect)(() => { +- if (relayed) actions.noteTurnPeer(sessionId, turn, source.senderSessionId); +- }, [relayed, sessionId, turn, source.senderSessionId]); +- const order = useSessions((list) => { +- const outline = list.projectionsBySession?.[sessionId]?.values?.turnOutline ?? list.byId[sessionId]?.projectionValues?.turnOutline; +- return Array.isArray(outline) ? outline.map((entry) => entry.turn).join(",") : ""; +- }); +- const peers = useTurnClock((state) => { +- if (!relayed) return ""; +- const turns = state[sessionId] ?? {}; +- return Object.keys(turns).filter((key) => turns[key].peer !== void 0).map((key) => `${key}:${turns[key].peer}`).join(","); +- }); +- const visit = useUi((state) => state.visits?.[sessionId]); +- const hiddenTurns = roster.exchangeTurns[sessionId]; +- const unreadOpening = useTurnClock((state) => { +- const first = openingTurn(state[sessionId], order, hiddenTurns); +- return first?.turn === turn && unseenSince(visit, first.start) ? clockLabel(first.start) : ""; +- }); +- const body = triggerBody(); +- return unreadOpening ? (0, import_react6.createElement)(import_react6.Fragment, null, (0, import_react6.createElement)("div", { className: "bt-open-new" }, (0, import_react6.createElement)(NewDivider), (0, import_react6.createElement)("div", { className: "bt-time-sep", role: "separator" }, unreadOpening)), body) : body; +- function triggerBody() { +- if (source.kind === "bot") { +- if (source.handoff === true) return roster.roomsById[sessionId] ? null : (0, import_react6.createElement)(EarlierParts, { sessionId, roster, actions }); +- if (source.role === "kickoff" || source.role === "room") return null; +- if (source.role === "secret" || deferred) return null; +- if (source.role === "post") { +- if (source.failed !== void 0 || source.resumed === true || source.stopped === true) return null; +- const room2 = roster.roomsById[sessionId]; +- const poster = roster.byId[source.senderSessionId] ?? { id: source.senderSessionId, name: source.senderName ?? t("a Bot"), color: "gray" }; +- return (0, import_react6.createElement)(GroupMessage, { bot: poster, text: stripHeader(contentText(data.content)), room: room2, roster, actions, msgKey: `${sessionId}:${data.turn}:post`, useUi }); +- } +- if (source.role === "report") { +- return (0, import_react6.createElement)(GroupEvent, { prefix: t("Replies from"), room: roster.roomsById[source.roomId], name: t("a group chat"), roster, actions }); +- } +- if (source.role === "resume" || source.resumed === true) return (0, import_react6.createElement)("div", { className: "bt-event" }, t("Picked up its work after a restart")); +- if (source.role === "retry" || source.retried === true) return (0, import_react6.createElement)("div", { className: "bt-event" }, t("Asked to make the tool call it wrote as text")); +- const run = relayed ? commRunOf( +- order.split(",").filter(Boolean).map(Number), +- Object.fromEntries(peers.split(",").filter(Boolean).map((pair) => [Number(pair.slice(0, pair.indexOf(":"))), pair.slice(pair.indexOf(":") + 1)])), +- turn, +- hiddenTurns +- ) : null; +- if (run === "folded") return null; +- const sender = roster.byId[source.senderSessionId] ?? { id: source.senderSessionId, name: source.senderName ?? t("a Bot"), color: "gray" }; +- const count = run?.count ?? (isHiddenTurn(roster, sessionId, turn) ? 2 : 1); +- const bots = [sender]; +- for (const peer of (run?.peerIds ?? []).map((id) => roster.byId[id]).filter(Boolean)) { +- if (!bots.some((bot) => bot.id === peer.id)) bots.push(peer); +- } +- return (0, import_react6.createElement)(EventLine, { +- prefix: source.role === "stopped" ? t("Stopped by") : count > 1 ? t("{count} messages with", { count }) : t("Message from"), +- bots, +- onOpen: (bot) => actions.openExchange(selfId, bot.id), +- whole: true +- }); +function AnswerFields({ card, busy, onAnswer }) { + const [, redraw] = (0, import_react13.useState)(0); + const question = card.kind === "question"; @@ -9192,7 +7879,10 @@ diff --git a/client.js b/client.js + if (selected.length === 0 && custom === "") return; + onAnswer(card, { selected, custom }); + return; -+ } + } +- if (source.kind === "schedule") { +- const prompts = reminderPrompts(contentText(data.content)); +- return (0, import_react6.createElement)("div", { className: "bt-event" }, prompts.length > 0 ? t("Scheduled task: {text}", { text: prompts.join(" · ").slice(0, 80) }) : t("Routine started")); + if (custom === "") return; + onAnswer(card, custom); + }; @@ -9201,7 +7891,9 @@ diff --git a/client.js b/client.js + if (!multi) { + onAnswer(card, { selected: [label], custom: "" }); + return; -+ } + } +- if (source.kind === "user-question-reply") return null; +- return (0, import_react6.createElement)("div", { className: "bt-event" }, contentText(data.content).slice(0, 80) || t("Update")); + slot.picked = slot.picked.includes(label) ? slot.picked.filter((item) => item !== label) : [...slot.picked, label]; + redraw((count) => count + 1); + }; @@ -9234,52 +7926,10 @@ diff --git a/client.js b/client.js + } + }), + (0, import_react13.createElement)("button", { type: "button", className: "bt-send bt-soft-sm", disabled: busy, onClick: send }, t("Submit")) - ); - } --function useFileMentions({ node, openFile, fileMentions, useTurnData }) { -- const tail = useTurnData?.("turn-tail"); -- const location2 = node.location; -- const turn = location2?.kind === "turn" || location2?.kind === "step" ? location2.turn : void 0; -- const seq = node.data.finalNode?.seq; -- return (0, import_react6.useMemo)(() => { -- if (typeof fileMentions !== "function" || turn?.status !== "closed" || seq === void 0) return void 0; -- return tail?.closing?.finalNode.seq === seq ? fileMentions({ turn, seq, openFile }) : void 0; -- }, [fileMentions, openFile, seq, tail, turn]); --} --function AssistantCell({ node, groupPart, sessionId, openFile, fileMentions, useTurnData, useRoster, useUi, useTurnSteps, useTurnContext, actions }) { ++ ); ++} +function InboxRow({ wide, useRoster, useWork, useUi, actions }) { - const roster = useRoster((value) => value); -- const data = node.data; -- const steps = useTurnSteps(String(data.turn)); -- const contexts = useTurnContext(String(data.turn)); -- const mentions = useFileMentions({ node, openFile, fileMentions, useTurnData }); -- const hidden = Boolean(roster.roomsById[sessionId]) || isHiddenTurn(roster, sessionId, data.turn); -- const texts2 = groupPart === "reasoning" ? [] : data.blocks.filter((block) => block.kind === "text" && block.text.trim() !== "").map((block) => block.text); -- const running = data.status === "running"; -- useLiveNote(actions, sessionId, `text:${node.key ?? data.seq}`, !hidden && running && texts2.length > 0 ? { text: true } : null); -- if (hidden) return null; -- const reasoning = groupPart === "response" ? "" : turnReasoning(steps, data); -- const relays = relayPart(data, groupPart) ? relaysBefore(contexts, steps, data) : []; -- if (texts2.length === 0 && !reasoning && relays.length === 0) return null; -- const selfId = roster.byId[sessionId]?.id ?? sessionId; -- return (0, import_react6.createElement)( -- import_react6.Fragment, -- null, -- relays.map((relay) => { -- const source = relay.source; -- const sender = roster.byId[source.senderSessionId] ?? { id: source.senderSessionId, name: source.senderName ?? t("a Bot"), color: "gray" }; -- return (0, import_react6.createElement)(EventLine, { -- key: relay.seq, -- prefix: source.role === "stopped" ? t("Stopped by") : source.role === "reply" ? t("Reply from") : t("Message from"), -- bots: [sender], -- onOpen: () => { -- if (sender.id) actions.openExchange(selfId, sender.id); -- }, -- whole: true -- }); -- }), -- reasoning ? (0, import_react6.createElement)(ThinkingRow, { text: reasoning }) : null, -- texts2.length ? (0, import_react6.createElement)(Bubbles, { texts: texts2, roster, selfId, actions, streaming: running, sessionId, msgKey: `${sessionId}:${data.turn}:${node.key ?? data.seq ?? ""}`, useUi, mentions }) : null ++ const roster = useRoster((value) => value); + const work = useWork ? useWork((value) => value) : {}; + const open = useUi ? useUi((value) => Boolean(value.inbox)) : false; + const tasks = useTasks(actions); @@ -9297,51 +7947,18 @@ diff --git a/client.js b/client.js + onClick: () => actions.openInbox() + }, + (0, import_react13.createElement)("span", { className: "bt-inbox-mark" }, (0, import_react13.createElement)(InboxIcon)), -+ wide ? (0, import_react13.createElement)("span", { className: "bt-inbox-label" }, t("Inbox")) : null, -+ count > 0 ? (0, import_react13.createElement)("span", { className: "bt-inbox-count", "aria-hidden": true }, count > 99 ? "99+" : String(count)) : null - ); - } --function EventLine({ prefix, bots, joiner = t("and"), onOpen, whole = false }) { -- if (whole && bots.length === 1) { -- return (0, import_react6.createElement)( -- "div", -- { className: "bt-event" }, -- (0, import_react6.createElement)( -- "button", -- { type: "button", className: "bt-event-whole", title: bots[0].name, onClick: () => onOpen?.(bots[0]) }, -- (0, import_react6.createElement)("span", null, prefix), -- (0, import_react6.createElement)(BotMark, { bot: bots[0], size: 16 }), -- (0, import_react6.createElement)("span", { className: "bt-event-name" }, bots[0].name) -- ) -- ); -- } -- const parts = []; -- parts.push((0, import_react6.createElement)("span", { key: "p" }, prefix)); -- bots.forEach((bot, index) => { -- if (index > 0) parts.push((0, import_react6.createElement)("span", { key: `j${index}` }, joiner)); -- parts.push((0, import_react6.createElement)( -- "button", -- { key: bot.id ?? index, type: "button", onClick: () => onOpen?.(bot) }, -- (0, import_react6.createElement)(BotMark, { bot, size: 16 }), -- bot.name -- )); -- }); -- return (0, import_react6.createElement)("div", { className: "bt-event" }, parts); ++ wide ? (0, import_react13.createElement)("span", { className: "bt-inbox-label" }, t("Inbox")) : null, ++ count > 0 ? (0, import_react13.createElement)("span", { className: "bt-inbox-count", "aria-hidden": true }, count > 99 ? "99+" : String(count)) : null ++ ); ++} +function Status({ status }) { + const { label, tone, Icon } = STATUSES[status]; + return (0, import_react13.createElement)("span", { className: "bt-inbox-status", "data-tone": tone }, (0, import_react13.createElement)(Icon), (0, import_react13.createElement)("span", null, t(label))); - } --function GroupMessage({ bot, text, room: room2, roster, actions, msgKey, useUi }) { -- const lead = (0, import_react6.createElement)( -- "button", -- { type: "button", className: "bt-lead", "aria-label": t("Open {name}'s chat", { name: bot.name }), onClick: () => actions.openSession(bot.id) }, -- (0, import_react6.createElement)(BotMark, { bot, size: 22 }) -- ); -- return (0, import_react6.createElement)( ++} +function whenText(card, now) { + if (card.status === "running" || card.status === "task") { + return typeof card.since === "number" && now - card.since >= 6e4 ? t("for {time}", { time: elapsedLabel(now - card.since) }) : t("just started"); -+ } + } + return typeof card.at === "number" ? relativeShort(card.at) : ""; +} +function InboxCard({ card, roster, now, busy, error, onAnswer, onOpen }) { @@ -9355,16 +7972,11 @@ diff --git a/client.js b/client.js + const writeBack = card.kind === "task" && card.task?.writeBack?.state === "failed" ? card.task.writeBack : void 0; + const unconfirmed = card.kind === "task" && card.task?.writeBack?.state === "unconfirmed" ? card.task.writeBack : void 0; + return (0, import_react13.createElement)( - "div", -- { className: "bt-group-msg" }, -- (0, import_react6.createElement)( ++ "div", + { className: "bt-inbox-card", role: "group", "aria-label": `${t(STATUSES[card.status].label)}: ${card.title}`, "data-status": card.status }, + (0, import_react13.createElement)("span", { className: "bt-inbox-ava" }, card.bot ? (0, import_react13.createElement)(BotAvatar, { bot: card.bot, size: 28, badge: false, live: false }) : card.room ? (0, import_react13.createElement)(RoomAvatar, { room: card.room, roster, size: 28 }) : null), + (0, import_react13.createElement)( - "span", -- { className: "bt-group-name", style: { color: inkText(colorOf(bot)) } }, -- bot.name, -- room2?.admin === bot.id ? (0, import_react6.createElement)("span", { className: "bt-group-role" }, t("Admin")) : null ++ "span", + { className: "bt-inbox-copy" }, + (0, import_react13.createElement)( + "span", @@ -9374,7 +7986,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", { @@ -9398,8 +8010,7 @@ diff --git a/client.js b/client.js + (0, import_react13.createElement)(AnswerFields, { card, busy, onAnswer }), + (0, import_react13.createElement)(ResultActions, { card, busy, onAnswer }), + error ? (0, import_react13.createElement)("span", { className: "bt-inbox-error", role: "alert" }, error) : null - ), -- (0, import_react6.createElement)(Bubbles, { texts: [text], roster, selfId: bot.id, actions, sessionId: room2?.id, msgKey, useUi, lead }) ++ ), + (0, import_react13.createElement)( + "span", + { className: "bt-inbox-actions" }, @@ -9408,15 +8019,10 @@ diff --git a/client.js b/client.js + ...(answerable ? alwaysChoices(card.approval, card.bot) : []).map(([choice, label]) => (0, import_react13.createElement)("button", { key: choice, type: "button", className: "bt-soft bt-soft-sm", "data-choice": choice, disabled: busy, onClick: () => onAnswer(card, choice) }, t(label))), + (0, import_react13.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", "aria-label": who ? t("Open {name}", { name: card.room?.name ?? who }) : t("Open"), onClick: () => onOpen(card) }, t("Open")) + ) - ); ++ ); } --function GroupEvent({ prefix, room: room2, name, roster, actions }) { -- return (0, import_react6.createElement)( -- "div", -- { className: "bt-event" }, -- (0, import_react6.createElement)("span", null, prefix), -- room2 ? (0, import_react6.createElement)("button", { type: "button", onClick: () => actions.openSession(room2.id) }, (0, import_react6.createElement)(RoomAvatar, { room: room2, roster, size: 16 }), room2.name) : name ? (0, import_react6.createElement)("span", null, name) : null -- ); +-function NewDivider() { +- return (0, import_react6.createElement)("div", { className: "bt-new-sep", role: "separator", "aria-label": t("New messages") }, (0, import_react6.createElement)("span", null, t("New"))); +function useNow2(ms) { + const [now, setNow] = (0, import_react13.useState)(Date.now); + (0, import_react13.useEffect)(() => { @@ -9425,15 +8031,23 @@ diff --git a/client.js b/client.js + }, [ms]); + return now; } --function MemoryEvent({ sessionId, scope, result, roster, actions, findBot }) { -- if (!/^(Saved to|Updated in|Removed from) /.test(result)) return null; -- const self = roster.byId[sessionId]; -- const team = /^(team|团队|共享)$/i.test(String(scope ?? "").trim()); -- const other = team || !scope ? void 0 : findBot(scope); -- const target = other && other.id !== self?.id ? other : self; -- if (!target) return null; -- const label = team ? t("Team memory updated") : target === self ? t("Memory updated") : t("Updated {name}'s memory", { name: target.name }); -- return (0, import_react6.createElement)( +-function TurnProcessCell({ node, sessionId, turnProcess, useRoster, useUi, useTurnClock, actions }) { +- const open = turnProcess === void 0 || !turnProcess.foldable || turnProcess.open; +- (0, import_react6.useEffect)(() => { +- if (!open) turnProcess.setOpen(true); +- }, [open, turnProcess]); +- const turn = node?.location?.turn?.turn ?? node?.data?.turn; +- const start = node?.location?.turn?.start?.time; +- const hidden = useRoster((roster) => isHiddenTurn(roster, sessionId, turn)); +- (0, import_react6.useEffect)(() => { +- actions.noteTurnTime(sessionId, turn, "first", start); +- }, [sessionId, turn, start]); +- const visit = useUi((state) => state.visits?.[sessionId]); +- const fresh = useTurnClock((state) => { +- const own = state[sessionId]?.[turn]; +- return !hidden && typeof visit?.seen === "number" && own?.first !== void 0 && own.first <= visit.seen && own.end !== void 0 && own.end > visit.seen && own.end <= visit.entered; +- }); +- return fresh ? (0, import_react6.createElement)(NewDivider) : null; +function InboxDialog({ roster, useWork, useSessions, actions, leaving = false }) { + const work = useWork((value) => value); + const sessions = useSessions((value) => value); @@ -9471,9 +8085,7 @@ diff --git a/client.js b/client.js + actions.openSession(card.chat); + }; + return (0, import_react13.createElement)( - "div", -- { className: "bt-event" }, -- (0, import_react6.createElement)("button", { type: "button", title: result, onClick: () => actions.openMemory(target.id) }, (0, import_react6.createElement)(MemoryIcon), label) ++ "div", + { className: "bt-mem-layer bt-inbox-layer", "data-leaving": leaving || void 0 }, + (0, import_react13.createElement)("div", { className: "bt-scrim bt-mem-scrim", onMouseDown: close }), + (0, import_react13.createElement)( @@ -9513,15 +8125,9 @@ diff --git a/client.js b/client.js + )) + ) + ) - ); ++ ); } --function PartLine({ time }) { -- return (0, import_react6.createElement)("div", { className: "bt-part-line", role: "separator" }, Number.isFinite(time) ? (0, import_react6.createElement)("span", null, dayLabel(time)) : null); --} --var scrollParent = (node) => { -- for (let at = node.parentElement; at; at = at.parentElement) { -- if (/(auto|scroll)/.test(getComputedStyle(at).overflowY)) return at; -- } +-function Nothing() { - return null; +var INBOX_CSS = ` +.bt-inbox-row{min-height:40px;padding:6px 8px;gap:10px} @@ -9585,27 +8191,7 @@ diff --git a/client.js b/client.js + accepted: "done", + cancelled: "done", + failed: "done" - }; --function EarlierParts({ sessionId, roster, actions }) { -- const [view, setView] = (0, import_react6.useState)({ items: [], cursor: void 0, loading: true, failed: false, startedAt: null }); -- const top = (0, import_react6.useRef)(null); -- const busy = (0, import_react6.useRef)(false); -- const alive = (0, import_react6.useRef)(true); -- const restore = (0, import_react6.useRef)(null); -- const load = (cursor) => { -- if (busy.current) return; -- busy.current = true; -- setView((current) => ({ ...current, loading: true, failed: false })); -- actions.earlier(sessionId, cursor).then((page) => { -- busy.current = false; -- if (!alive.current) return; -- const scroller = top.current && scrollParent(top.current); -- if (scroller) restore.current = { scroller, fromEnd: scroller.scrollHeight - scroller.scrollTop }; -- setView((current) => ({ items: [...page.items, ...current.items], cursor: page.cursor, loading: false, failed: false, startedAt: page.startedAt ?? current.startedAt })); -- }, () => { -- busy.current = false; -- if (alive.current) setView((current) => ({ ...current, loading: false, failed: true })); -- }); ++}; +function columnOf(task) { + return COLUMN_OF[task?.status] ?? "not-started"; +} @@ -9641,7 +8227,17 @@ diff --git a/client.js b/client.js + const value = task?.progress; + if (typeof value !== "number" || !Number.isFinite(value)) return columnOf(task) === "done" && task?.status !== "cancelled" && task?.status !== "failed" ? 100 : 0; + return Math.max(0, Math.min(100, Math.round(value))); -+} + } +-var SEPARATOR_GAP_MS = 30 * 60 * 1e3; +-function clockLabel(time, now = Date.now()) { +- const date = new Date(time); +- const midnight = (value) => new Date(value.getFullYear(), value.getMonth(), value.getDate()).getTime(); +- const days = Math.round((midnight(new Date(now)) - midnight(date)) / 864e5); +- const clock4 = date.toLocaleTimeString(dateLocale(), { hour: "numeric", minute: "2-digit" }); +- if (days === 0) return t("Today {time}", { time: clock4 }); +- if (days === 1) return t("Yesterday {time}", { time: clock4 }); +- const sameYear = date.getFullYear() === new Date(now).getFullYear(); +- return `${date.toLocaleDateString(dateLocale(), { weekday: "short", month: "short", day: "numeric", ...sameYear ? {} : { year: "numeric" } })} ${clock4}`; + +// src/shared/board-card.js +var CARD_SOURCES = ["github", "task"]; @@ -9674,43 +8270,15 @@ diff --git a/client.js b/client.js + linked: row.linked ?? null, + sha: row.sha ?? null, + url: row.url ?? null - }; -- (0, import_react6.useEffect)(() => { -- alive.current = true; -- load(void 0); -- return () => { -- alive.current = false; -- }; -- }, [sessionId]); -- (0, import_react6.useLayoutEffect)(() => { -- const pending = restore.current; -- restore.current = null; -- if (pending) pending.scroller.scrollTop = pending.scroller.scrollHeight - pending.fromEnd; -- }, [view.items]); -- (0, import_react6.useEffect)(() => { -- const node = top.current; -- if (!node || view.loading || view.failed || !view.cursor || typeof IntersectionObserver !== "function") return void 0; -- const observer = new IntersectionObserver((entries2) => { -- if (entries2.some((entry) => entry.isIntersecting)) load(view.cursor); -- }, { root: scrollParent(node), rootMargin: "600px 0px 0px 0px" }); -- observer.observe(node); -- return () => observer.disconnect(); -- }, [view.cursor, view.loading, view.failed]); -- const selfId = roster.byId[sessionId]?.id; -- const rows = []; -- for (const item of view.items) { -- const last = rows.at(-1); -- if (item.kind === "bot" && last?.kind === "bot") last.texts.push(item.text); -- else rows.push(item.kind === "bot" ? { kind: "bot", texts: [item.text] } : item); ++ }; + } +-function nextTurnStart(turns, turn) { +- let next; +- for (const key in turns) { +- const candidate = Number(key); +- if (candidate > turn && turns[key].first !== void 0 && (next === void 0 || candidate < next)) next = candidate; - } -- return (0, import_react6.createElement)( -- "div", -- { className: "bt-earlier", role: "feed", "aria-label": t("Earlier messages"), "aria-busy": view.loading }, -- (0, import_react6.createElement)( -- "div", -- { ref: top, className: "bt-earlier-top" }, -- view.loading ? (0, import_react6.createElement)(Shimmer, null, t("Loading earlier messages…")) : view.failed ? (0, import_react6.createElement)("button", { type: "button", className: "bt-soft", onClick: () => load(view.cursor) }, t("Couldn’t load earlier messages · Retry")) : view.cursor ? (0, import_react6.createElement)("button", { type: "button", className: "bt-soft", onClick: () => load(view.cursor) }, t("Load earlier")) : null -+} +- return next === void 0 ? void 0 : turns[next].first; +function taskCard(task) { + const key = taskCardKey(task?.id); + if (key === null) return null; @@ -9730,10 +8298,28 @@ diff --git a/client.js b/client.js + sha: null, + url: null + }; -+} + } +-function openingTurn(turns, firstTurns, hiddenTurns) { +- if (!turns || !firstTurns) return null; +- const start = nextTurnStart(turns, -Infinity); +- if (start === void 0) return null; +- const turn = Number(Object.keys(turns).filter((key) => turns[key].first === start)[0]); +- const listed = firstTurns.split(",").map(Number); +- const earlier = listed.filter((entry) => entry < turn); +- return listed.includes(turn) && earlier.every((entry) => hiddenTurns?.includes(entry)) ? { turn, start } : null; +function sameCard(a, b) { + return Boolean(a?.key) && a.key === b?.key; -+} + } +-var unseenSince = (visit, time) => typeof visit?.seen === "number" && time > visit.seen && time <= visit.entered; +-function TurnTailCell({ turn: location2, sessionId, useTurnClock, useUi, actions }) { +- const turn = location2.turn; +- const time = location2.end.time; +- (0, import_react6.useEffect)(() => { +- actions.noteTurnTime(sessionId, turn, "end", time); +- }, [sessionId, turn, time]); +- const label = useTurnClock((state) => { +- const start = nextTurnStart(state[sessionId] ?? {}, turn); +- return start !== void 0 && start - time >= SEPARATOR_GAP_MS ? clockLabel(start) : ""; + +// src/client/board-card.js +var import_react14 = require("react"); @@ -9801,36 +8387,7 @@ diff --git a/client.js b/client.js + (0, import_react14.createElement)("span", { className: skin.id }, idText), + topExtra, + badge ? (0, import_react14.createElement)("span", { className: skin.badge, "data-badge": card?.badge ?? void 0 }, badge) : null - ), -- rows.map((row, index) => { -- const key = rows.length - index; -- switch (row.kind) { -- case "divider": -- return (0, import_react6.createElement)(PartLine, { key, time: row.time }); -- case "user": -- return (0, import_react6.createElement)("div", { key, className: "bt-urow" }, (0, import_react6.createElement)( -- "div", -- { className: "bt-ububble" }, -- row.text ? (0, import_react6.createElement)(import_dsh_client_ui_primitives.MarkdownText, { text: row.text, labels: mdLabels(), variant: "compact" }) : null, -- row.images ? (0, import_react6.createElement)("span", { className: "bt-uimages" }, row.images === 1 ? t("1 image") : t("{count} images", { count: row.images })) : null -- )); -- case "answer": -- return (0, import_react6.createElement)(AnsweredCell, { key, node: { data: row } }); -- case "bot": -- return (0, import_react6.createElement)(Bubbles, { key, texts: row.texts, roster, selfId, actions }); -- case "event": { -- if (row.role === "report") return (0, import_react6.createElement)(GroupEvent, { key, prefix: t("Replies from"), room: roster.roomsById[row.roomId], name: t("a group chat"), roster, actions }); -- const other = roster.byId[row.botId] ?? { id: row.botId, name: row.name ?? t("a Bot"), color: "gray" }; -- const prefix = { stopped: t("Stopped by"), reply: t("Reply from") }[row.role] ?? t("Message from"); -- return (0, import_react6.createElement)(EventLine, { key, prefix, bots: [other], onOpen: () => { -- if (selfId && other.id) actions.openExchange(selfId, other.id); -- }, whole: true }); -- } -- default: -- return null; -- } -- }), -- (0, import_react6.createElement)(PartLine, { time: view.startedAt }) ++ ), + (0, import_react14.createElement)("span", { className: skin.title }, card?.title || emptyTitle), + labels.length ? (0, import_react14.createElement)("span", { className: skin.labels }, labels.map((name) => (0, import_react14.createElement)("span", { key: name, className: skin.label, "data-label": name }, name))) : null, + card?.owner ? (0, import_react14.createElement)("span", { className: skin.owner, "data-kind": card.ownerKind ?? "human" }, card.owner) : null, @@ -9841,33 +8398,8 @@ diff --git a/client.js b/client.js + card?.url ? (0, import_react14.createElement)("a", { className: skin.sha, href: card.url, target: "_blank", rel: "noreferrer" }, t("open on GitHub")) : null, + footer, + actions - ); - } --function TriggerCell({ node, sessionId, useRoster, useSessions, useTurnClock, useUi, actions }) { -- const roster = useRoster((value) => value); -- const data = node.data; -- const source = data.source !== null && typeof data.source === "object" ? data.source : {}; -- const selfId = roster.byId[sessionId]?.id ?? sessionId; -- const deferred = source.kind === "bot" && source.deferred === true; -- const relayed = isRelayContext(data); -- const turn = node.location?.turn?.turn ?? data.turn; -- (0, import_react6.useLayoutEffect)(() => { -- if (relayed) actions.noteTurnPeer(sessionId, turn, source.senderSessionId); -- }, [relayed, sessionId, turn, source.senderSessionId]); -- const order = useSessions((list) => { -- const outline = list.projectionsBySession?.[sessionId]?.values?.turnOutline ?? list.byId[sessionId]?.projectionValues?.turnOutline; -- return Array.isArray(outline) ? outline.map((entry) => entry.turn).join(",") : ""; -- }); -- const peers = useTurnClock((state) => { -- if (!relayed) return ""; -- const turns = state[sessionId] ?? {}; -- return Object.keys(turns).filter((key) => turns[key].peer !== void 0).map((key) => `${key}:${turns[key].peer}`).join(","); -- }); -- const visit = useUi((state) => state.visits?.[sessionId]); -- const hiddenTurns = roster.exchangeTurns[sessionId]; -- const unreadOpening = useTurnClock((state) => { -- const first = openingTurn(state[sessionId], order, hiddenTurns); -- return first?.turn === turn && unseenSince(visit, first.start) ? clockLabel(first.start) : ""; ++ ); ++} + +// src/client/delivery-chart.js +var import_react15 = require("react"); @@ -9940,24 +8472,14 @@ diff --git a/client.js b/client.js + partial: state === "partial", + state, + title: pointTitle(day, key), -+ value -+ }; - }); -- const body = triggerBody(); -- return unreadOpening ? (0, import_react6.createElement)(import_react6.Fragment, null, (0, import_react6.createElement)("div", { className: "bt-open-new" }, (0, import_react6.createElement)(NewDivider), (0, import_react6.createElement)("div", { className: "bt-time-sep", role: "separator" }, unreadOpening)), body) : body; -- function triggerBody() { -- if (source.kind === "bot") { -- if (source.handoff === true) return roster.roomsById[sessionId] ? null : (0, import_react6.createElement)(EarlierParts, { sessionId, roster, actions }); -- if (source.role === "kickoff" || source.role === "room") return null; -- if (source.role === "secret" || deferred) return null; -- if (source.role === "post") { -- if (source.failed !== void 0 || source.resumed === true || source.stopped === true) return null; -- const room2 = roster.roomsById[sessionId]; -- const poster = roster.byId[source.senderSessionId] ?? { id: source.senderSessionId, name: source.senderName ?? t("a Bot"), color: "gray" }; -- return (0, import_react6.createElement)(GroupMessage, { bot: poster, text: stripHeader(contentText(data.content)), room: room2, roster, actions, msgKey: `${sessionId}:${data.turn}:post`, useUi }); -- } -- if (source.role === "report") { -- return (0, import_react6.createElement)(GroupEvent, { prefix: t("Replies from"), room: roster.roomsById[source.roomId], name: t("a group chat"), roster, actions }); ++ value ++ }; + }); +- const visit = useUi((state) => state.visits?.[sessionId]); +- const fresh = useTurnClock((state) => { +- if (typeof visit?.seen !== "number" || time > visit.seen) return false; +- const start = nextTurnStart(state[sessionId] ?? {}, turn); +- return start !== void 0 && start > visit.seen && start <= visit.entered; + const segments = []; + let run = null; + for (const dot of dots) { @@ -9965,82 +8487,12 @@ diff --git a/client.js b/client.js + if (run) { + segments.push(run); + run = null; - } -- if (source.role === "resume" || source.resumed === true) return (0, import_react6.createElement)("div", { className: "bt-event" }, t("Picked up its work after a restart")); -- if (source.role === "retry" || source.retried === true) return (0, import_react6.createElement)("div", { className: "bt-event" }, t("Asked to make the tool call it wrote as text")); -- const run = relayed ? commRunOf( -- order.split(",").filter(Boolean).map(Number), -- Object.fromEntries(peers.split(",").filter(Boolean).map((pair) => [Number(pair.slice(0, pair.indexOf(":"))), pair.slice(pair.indexOf(":") + 1)])), -- turn, -- hiddenTurns -- ) : null; -- if (run === "folded") return null; -- const sender = roster.byId[source.senderSessionId] ?? { id: source.senderSessionId, name: source.senderName ?? t("a Bot"), color: "gray" }; -- const count = run?.count ?? (isHiddenTurn(roster, sessionId, turn) ? 2 : 1); -- const bots = [sender]; -- for (const peer of (run?.peerIds ?? []).map((id) => roster.byId[id]).filter(Boolean)) { -- if (!bots.some((bot) => bot.id === peer.id)) bots.push(peer); -- } -- return (0, import_react6.createElement)(EventLine, { -- prefix: source.role === "stopped" ? t("Stopped by") : count > 1 ? t("{count} messages with", { count }) : t("Message from"), -- bots, -- onOpen: (bot) => actions.openExchange(selfId, bot.id), -- whole: true -- }); -- } -- if (source.kind === "schedule") { -- const prompts = reminderPrompts(contentText(data.content)); -- return (0, import_react6.createElement)("div", { className: "bt-event" }, prompts.length > 0 ? t("Scheduled task: {text}", { text: prompts.join(" · ").slice(0, 80) }) : t("Routine started")); ++ } + continue; - } -- if (source.kind === "user-question-reply") return null; -- return (0, import_react6.createElement)("div", { className: "bt-event" }, contentText(data.content).slice(0, 80) || t("Update")); -- } --} --function NewDivider() { -- return (0, import_react6.createElement)("div", { className: "bt-new-sep", role: "separator", "aria-label": t("New messages") }, (0, import_react6.createElement)("span", null, t("New"))); --} --function TurnProcessCell({ node, sessionId, turnProcess, useRoster, useUi, useTurnClock, actions }) { -- const open = turnProcess === void 0 || !turnProcess.foldable || turnProcess.open; -- (0, import_react6.useEffect)(() => { -- if (!open) turnProcess.setOpen(true); -- }, [open, turnProcess]); -- const turn = node?.location?.turn?.turn ?? node?.data?.turn; -- const start = node?.location?.turn?.start?.time; -- const hidden = useRoster((roster) => isHiddenTurn(roster, sessionId, turn)); -- (0, import_react6.useEffect)(() => { -- actions.noteTurnTime(sessionId, turn, "first", start); -- }, [sessionId, turn, start]); -- const visit = useUi((state) => state.visits?.[sessionId]); -- const fresh = useTurnClock((state) => { -- const own = state[sessionId]?.[turn]; -- return !hidden && typeof visit?.seen === "number" && own?.first !== void 0 && own.first <= visit.seen && own.end !== void 0 && own.end > visit.seen && own.end <= visit.entered; -- }); -- return fresh ? (0, import_react6.createElement)(NewDivider) : null; --} --function Nothing() { -- return null; --} --var SEPARATOR_GAP_MS = 30 * 60 * 1e3; --function clockLabel(time, now = Date.now()) { -- const date = new Date(time); -- const midnight = (value) => new Date(value.getFullYear(), value.getMonth(), value.getDate()).getTime(); -- const days = Math.round((midnight(new Date(now)) - midnight(date)) / 864e5); -- const clock4 = date.toLocaleTimeString(dateLocale(), { hour: "numeric", minute: "2-digit" }); -- if (days === 0) return t("Today {time}", { time: clock4 }); -- if (days === 1) return t("Yesterday {time}", { time: clock4 }); -- const sameYear = date.getFullYear() === new Date(now).getFullYear(); -- return `${date.toLocaleDateString(dateLocale(), { weekday: "short", month: "short", day: "numeric", ...sameYear ? {} : { year: "numeric" } })} ${clock4}`; --} --function nextTurnStart(turns, turn) { -- let next; -- for (const key in turns) { -- const candidate = Number(key); -- if (candidate > turn && turns[key].first !== void 0 && (next === void 0 || candidate < next)) next = candidate; ++ } + if (!run) run = { dots: [] }; + run.dots.push(dot); - } -- return next === void 0 ? void 0 : turns[next].first; ++ } + if (run) segments.push(run); + return { + width, @@ -10054,15 +8506,7 @@ diff --git a/client.js b/client.js + today: segment.dots.some((dot) => dot.today) + })) + }; - } --function openingTurn(turns, firstTurns, hiddenTurns) { -- if (!turns || !firstTurns) return null; -- const start = nextTurnStart(turns, -Infinity); -- if (start === void 0) return null; -- const turn = Number(Object.keys(turns).filter((key) => turns[key].first === start)[0]); -- const listed = firstTurns.split(",").map(Number); -- const earlier = listed.filter((entry) => entry < turn); -- return listed.includes(turn) && earlier.every((entry) => hiddenTurns?.includes(entry)) ? { turn, start } : null; ++} +function DeliveryChart({ daily, metricKey, label }) { + const geo = chartGeometry(daily, metricKey); + if (!geo || !geo.known) return null; @@ -10096,23 +8540,7 @@ diff --git a/client.js b/client.js + r: dot.hollow ? HOLLOW_RADIUS : DOT_RADIUS + }, (0, import_react15.createElement)("title", null, dot.title))) + ); - } --var unseenSince = (visit, time) => typeof visit?.seen === "number" && time > visit.seen && time <= visit.entered; --function TurnTailCell({ turn: location2, sessionId, useTurnClock, useUi, actions }) { -- const turn = location2.turn; -- const time = location2.end.time; -- (0, import_react6.useEffect)(() => { -- actions.noteTurnTime(sessionId, turn, "end", time); -- }, [sessionId, turn, time]); -- const label = useTurnClock((state) => { -- const start = nextTurnStart(state[sessionId] ?? {}, turn); -- return start !== void 0 && start - time >= SEPARATOR_GAP_MS ? clockLabel(start) : ""; -- }); -- const visit = useUi((state) => state.visits?.[sessionId]); -- const fresh = useTurnClock((state) => { -- if (typeof visit?.seen !== "number" || time > visit.seen) return false; -- const start = nextTurnStart(state[sessionId] ?? {}, turn); -- return start !== void 0 && start > visit.seen && start <= visit.entered; ++} +function DeliveryMetrics({ rows = [], daily }) { + const days = chartDays(daily); + const short = CHART_KEYS.map((key) => knownDays(days, key)).filter((count) => count < (days?.length ?? 0)); @@ -10304,7 +8732,7 @@ diff --git a/client.js b/client.js + if (kind === "no-token") return t("Add a GITHUB_TOKEN in this instance's settings — one that can read {repo}.", { repo: target }); + if (kind === "rate-limit") { + return error.retryAfterMs === null || error.retryAfterMs === void 0 ? t("GitHub is rate-limiting this instance; while this page is open it retries on its own (10 s → 30 s → 2 min).") : t("GitHub is rate-limiting this instance; while this page is open it retries on its own in {seconds} s.", { seconds: secondsOf(error.retryAfterMs) }); -+ } + } + if (kind === "timeout") return t("GitHub did not answer in time; the next attempt happens while this page is open."); + if (scope === "actions" && (kind === "not-found" || kind === "forbidden")) return t("Give the token Actions: Read."); + if (scope === "checks" && (kind === "not-found" || kind === "forbidden")) return t("Give the token Checks: Read."); @@ -10312,7 +8740,7 @@ diff --git a/client.js b/client.js + if (scope === "pulls" && (kind === "not-found" || kind === "forbidden")) return t("Give the token Pull requests: Read."); + if (scope === "repo" || kind === "not-found" || kind === "forbidden" || kind === "auth") { + return t("Check the repository name, and that the token can see {repo}.", { repo: target }); - } ++ } + if (kind === "server" || kind === "network") return t("GitHub is not answering this instance right now; retry in a moment."); + return t("The next attempt happens while this page is open; the reason above names what failed."); +} @@ -10806,22 +9234,8 @@ diff --git a/client.js b/client.js + { className: "bt-del-repos" }, + entries2.map((entry) => (0, import_react16.createElement)(DeliveryRepoBlock, { key: entry.repo ?? "unknown", entry })) + ) - ); - } --var DRAWN = /* @__PURE__ */ new Set(["message_bot", "stop_bot", "create_bot", "update_bot", "group_relay", "create_group", "update_group", "delete_group", "post_to_group", "ask_user", "request_secret", "ask_user_question", "read_group_chat", "remember", "forget"]); --var CARDS = /* @__PURE__ */ new Set(["ask_user", "request_secret"]); --function callTree(root) { -- const calls = []; -- const seen = /* @__PURE__ */ new Set(); -- const visit = (call2) => { -- const id = call2.callId ?? call2; -- if (seen.has(id)) return; -- seen.add(id); -- calls.push(call2); -- for (const child of call2.subCalls ?? []) visit(child); -- }; -- visit(root); -- return calls; ++ ); ++} +function DeliveryView({ actions }) { + const [view, retry] = useDeliveryBoard(actions); + return (0, import_react16.createElement)(DeliveryBoard, { @@ -10830,15 +9244,7 @@ diff --git a/client.js b/client.js + onRetry: actions?.deliveryBoard ? retry : null, + onProbe: actions?.deliveryProbe ? () => actions.deliveryProbe() : null + }); - } --function useShownCards(actions, sessionId, callIds) { -- const key = JSON.stringify(callIds); -- (0, import_react6.useLayoutEffect)(() => { -- const ids = JSON.parse(key); -- if (ids.length === 0 || !actions.noteCards) return void 0; -- actions.noteCards(sessionId, ids, 1); -- return () => actions.noteCards(sessionId, ids, -1); -- }, [sessionId, key]); ++} +function DeliveryCard({ card, repo = null }) { + const model = githubCard(card, repo) ?? { source: "github", key: null, title: card?.title ?? "", badge: card?.badge ?? null }; + return (0, import_react16.createElement)(BoardCard, { @@ -10848,76 +9254,7 @@ diff --git a/client.js b/client.js + idText: `#${card?.number ?? "?"}`, + badge: badgeLabel(card?.badge) + }); - } --function renderCall(call2, { sessionId, roster, actions, useUi, useWork, findBot }) { -- const tool = toolName(call2); -- const done = call2.kind === "tool-result"; -- const { text: arg, value: argValue } = toolArgs(call2); -- const self = roster.byId[sessionId]; -- switch (tool) { -- case "message_bot": { -- const target = findBot(arg("to")) ?? { name: arg("to") ?? "…", color: "gray" }; -- if (done && call2.isError) return (0, import_react6.createElement)("div", { className: "bt-event" }, t("Couldn't message {name}", { name: target.name })); -- return (0, import_react6.createElement)(EventLine, { prefix: t("Messaged"), bots: [target], onOpen: (bot) => bot.id && actions.openExchange(self?.id ?? sessionId, bot.id), whole: true }); -- } -- case "stop_bot": { -- const target = findBot(arg("bot")) ?? { name: arg("bot") ?? "…", color: "gray" }; -- if (done && call2.isError) return (0, import_react6.createElement)("div", { className: "bt-event" }, t("Couldn't stop {name}", { name: target.name })); -- return (0, import_react6.createElement)(EventLine, { prefix: t("Stopped"), bots: [target], onOpen: (bot) => bot.id && actions.openExchange(self?.id ?? sessionId, bot.id), whole: true }); -- } -- case "create_bot": { -- const created = findBot(arg("name")); -- if (!done || created === void 0) return null; -- return (0, import_react6.createElement)(EventLine, { prefix: t("Created"), bots: [created], onOpen: (bot) => actions.openSession(bot.id) }); -- } -- case "update_bot": { -- const target = findBot(arg("name")) ?? findBot(arg("bot")) ?? (arg("bot") ? void 0 : self); -- return done && target ? (0, import_react6.createElement)(EventLine, { prefix: t("Updated"), bots: [target], onOpen: (bot) => actions.openSession(bot.id) }) : null; -- } -- case "group_relay": -- return (0, import_react6.createElement)(GroupReplies, { sessionId, callId: call2.callId, done, content: call2.content, roster, actions, useUi, useWork }); -- case "create_group": -- case "update_group": -- case "delete_group": -- case "post_to_group": { -- const verbs = { -- create_group: ["Created group", "Couldn't create the group"], -- update_group: ["Updated group", "Couldn't update the group"], -- delete_group: ["Deleted group", "Couldn't delete the group"], -- post_to_group: ["Posted in", "Couldn't post in the group"] -- }[tool]; -- if (!done) return null; -- const result = contentText(call2.content); -- const named = /^(Created group|Updated|Nothing changed in|Deleted|Posted in) "([^"]+)"/.exec(result); -- if (call2.isError || !named) return (0, import_react6.createElement)("div", { className: "bt-event", title: result }, t(verbs[1])); -- const name = named[2]; -- const room2 = roster.rooms.find((entry) => entry.name === name); -- return (0, import_react6.createElement)(GroupEvent, { prefix: t(verbs[0]), room: tool === "delete_group" ? void 0 : room2, name, roster, actions }); -- } -- case "ask_user": { -- const question = roster.questions?.[sessionId]; -- if (!question || question.callId !== call2.callId) return (0, import_react6.createElement)("span", { className: "bt-q-gone", hidden: true }); -- return (0, import_react6.createElement)(PendingQuestion, { sessionId, question, actions, inline: true }); -- } -- case "request_secret": -- return (0, import_react6.createElement)(SecretToolCell, { sessionId, callId: call2.callId, name: arg("name"), done, content: call2.content, roster, actions }); -- case "ask_user_question": { -- if (!done) return null; -- let parsed = null; -- try { -- parsed = JSON.parse(contentText(call2.content)); -- } catch { -- parsed = null; -- } -- const answer = parsed?.answers?.[0]; -- const question = argValue("questions")?.[0]; -- if (!answer) return null; -- return (0, import_react6.createElement)("div", { className: "bt-q-inline" }, (0, import_react6.createElement)(SettledQuestion, { question, answer })); -- } -- case "read_group_chat": { -- if (!done) return null; -- const room2 = roster.rooms.find((entry) => entry.name === arg("group")); -- return room2 ? (0, import_react6.createElement)(GroupEvent, { prefix: t("Read"), room: room2, roster, actions }) : null; ++} +var DELIVERY_CSS = ` +.bt-del-body{flex:1 1 auto;min-height:0;display:flex;flex-direction:column;gap:10px;padding:12px 14px 16px} +.bt-del-head{display:flex;flex-direction:column;gap:6px;border-bottom:.5px solid var(--bt-line);padding-bottom:8px} @@ -10976,17 +9313,108 @@ diff --git a/client.js b/client.js + (0, import_react17.createElement)("rect", { x: 2.5, y: 3, width: 4, height: 14, rx: 1 }), + (0, import_react17.createElement)("rect", { x: 8, y: 3, width: 4, height: 9, rx: 1 }), + (0, import_react17.createElement)("rect", { x: 13.5, y: 3, width: 4, height: 12, rx: 1 }) -+ ); -+} + ); + } +-var DRAWN = /* @__PURE__ */ new Set(["message_bot", "stop_bot", "create_bot", "update_bot", "group_relay", "create_group", "update_group", "delete_group", "post_to_group", "ask_user", "request_secret", "ask_user_question", "read_group_chat", "remember", "forget"]); +-var CARDS = /* @__PURE__ */ new Set(["ask_user", "request_secret"]); +-function callTree(root) { +- const calls = []; +- const seen = /* @__PURE__ */ new Set(); +- const visit = (call2) => { +- const id = call2.callId ?? call2; +- if (seen.has(id)) return; +- seen.add(id); +- calls.push(call2); +- for (const child of call2.subCalls ?? []) visit(child); +- }; +- visit(root); +- return calls; +function shortId(id) { + const hex = String(id ?? "").replace(/^task-/, ""); + return `#${hex.slice(-6)}`; -+} + } +-function useShownCards(actions, sessionId, callIds) { +- const key = JSON.stringify(callIds); +- (0, import_react6.useLayoutEffect)(() => { +- const ids = JSON.parse(key); +- if (ids.length === 0 || !actions.noteCards) return void 0; +- actions.noteCards(sessionId, ids, 1); +- return () => actions.noteCards(sessionId, ids, -1); +- }, [sessionId, key]); +var ARCHIVE_SHOWN = 20; +function boardTasksFor(tasks, archive, limit = ARCHIVE_SHOWN) { + const newest = [...archive ?? []].sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0) || (b.createdAt ?? 0) - (a.createdAt ?? 0)); + return [...tasks ?? [], ...newest.slice(0, limit)]; -+} + } +-function renderCall(call2, { sessionId, roster, actions, useUi, useWork, findBot }) { +- const tool = toolName(call2); +- const done = call2.kind === "tool-result"; +- const { text: arg, value: argValue } = toolArgs(call2); +- const self = roster.byId[sessionId]; +- switch (tool) { +- case "message_bot": { +- const target = findBot(arg("to")) ?? { name: arg("to") ?? "…", color: "gray" }; +- if (done && call2.isError) return (0, import_react6.createElement)("div", { className: "bt-event" }, t("Couldn't message {name}", { name: target.name })); +- return (0, import_react6.createElement)(EventLine, { prefix: t("Messaged"), bots: [target], onOpen: (bot) => bot.id && actions.openExchange(self?.id ?? sessionId, bot.id), whole: true }); +- } +- case "stop_bot": { +- const target = findBot(arg("bot")) ?? { name: arg("bot") ?? "…", color: "gray" }; +- if (done && call2.isError) return (0, import_react6.createElement)("div", { className: "bt-event" }, t("Couldn't stop {name}", { name: target.name })); +- return (0, import_react6.createElement)(EventLine, { prefix: t("Stopped"), bots: [target], onOpen: (bot) => bot.id && actions.openExchange(self?.id ?? sessionId, bot.id), whole: true }); +- } +- case "create_bot": { +- const created = findBot(arg("name")); +- if (!done || created === void 0) return null; +- return (0, import_react6.createElement)(EventLine, { prefix: t("Created"), bots: [created], onOpen: (bot) => actions.openSession(bot.id) }); +- } +- case "update_bot": { +- const target = findBot(arg("name")) ?? findBot(arg("bot")) ?? (arg("bot") ? void 0 : self); +- return done && target ? (0, import_react6.createElement)(EventLine, { prefix: t("Updated"), bots: [target], onOpen: (bot) => actions.openSession(bot.id) }) : null; +- } +- case "group_relay": +- return (0, import_react6.createElement)(GroupReplies, { sessionId, callId: call2.callId, done, content: call2.content, roster, actions, useUi, useWork }); +- case "create_group": +- case "update_group": +- case "delete_group": +- case "post_to_group": { +- const verbs = { +- create_group: ["Created group", "Couldn't create the group"], +- update_group: ["Updated group", "Couldn't update the group"], +- delete_group: ["Deleted group", "Couldn't delete the group"], +- post_to_group: ["Posted in", "Couldn't post in the group"] +- }[tool]; +- if (!done) return null; +- const result = contentText(call2.content); +- const named = /^(Created group|Updated|Nothing changed in|Deleted|Posted in) "([^"]+)"/.exec(result); +- if (call2.isError || !named) return (0, import_react6.createElement)("div", { className: "bt-event", title: result }, t(verbs[1])); +- const name = named[2]; +- const room2 = roster.rooms.find((entry) => entry.name === name); +- return (0, import_react6.createElement)(GroupEvent, { prefix: t(verbs[0]), room: tool === "delete_group" ? void 0 : room2, name, roster, actions }); +- } +- case "ask_user": { +- const question = roster.questions?.[sessionId]; +- if (!question || question.callId !== call2.callId) return (0, import_react6.createElement)("span", { className: "bt-q-gone", hidden: true }); +- return (0, import_react6.createElement)(PendingQuestion, { sessionId, question, actions, inline: true }); +- } +- case "request_secret": +- return (0, import_react6.createElement)(SecretToolCell, { sessionId, callId: call2.callId, name: arg("name"), done, content: call2.content, roster, actions }); +- case "ask_user_question": { +- if (!done) return null; +- let parsed = null; +- try { +- parsed = JSON.parse(contentText(call2.content)); +- } catch { +- parsed = null; +- } +- const answer = parsed?.answers?.[0]; +- const question = argValue("questions")?.[0]; +- if (!answer) return null; +- return (0, import_react6.createElement)("div", { className: "bt-q-inline" }, (0, import_react6.createElement)(SettledQuestion, { question, answer })); +- } +- case "read_group_chat": { +- if (!done) return null; +- const room2 = roster.rooms.find((entry) => entry.name === arg("group")); +- return room2 ? (0, import_react6.createElement)(GroupEvent, { prefix: t("Read"), room: room2, roster, actions }) : null; +function boardColumns(tasks) { + const columns = { "not-started": [], "in-progress": [], done: [] }; + for (const task of tasks ?? []) { @@ -11862,7 +10290,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); @@ -12691,32 +11119,21 @@ diff --git a/client.js b/client.js ); const toLatest = () => { const scroller = document.querySelector("[data-conversation-scroll]"); -@@ -5702,31 +5702,129 @@ function NewMessagePills({ sessionId, useSessions, useUi, useTurnClock }) { - if (back) back.click(); - else scroller?.scrollTo({ top: scroller.scrollHeight, behavior: "smooth" }); +@@ -5704,8 +5704,8 @@ function NewMessagePills({ sessionId, useSessions, useUi, useTurnClock }) { }; -- const toDivider = () => document.querySelector("[data-conversation-scroll] .bt-new-sep")?.scrollIntoView({ block: "center", behavior: "smooth" }); -- return (0, import_react_dom4.createPortal)( + const toDivider = () => document.querySelector("[data-conversation-scroll] .bt-new-sep")?.scrollIntoView({ block: "center", behavior: "smooth" }); + return (0, import_react_dom4.createPortal)( - (0, import_react9.createElement)( - import_react9.Fragment, -- null, -- showUp ? pill("up", unread, { left: view.center, top: view.top }, toDivider, () => setAbove((state) => ({ ...state, dismissed: true }))) : null, -- showDown ? pill("down", fresh, { left: view.center, bottom: view.bottom }, toLatest, () => setRelease((state) => ({ ...state, dismissed: true }))) : null -- ), -- document.body -- ); -+ const toDivider = () => document.querySelector("[data-conversation-scroll] .bt-new-sep")?.scrollIntoView({ block: "center", behavior: "smooth" }); -+ return (0, import_react_dom4.createPortal)( + (0, import_react19.createElement)( + import_react19.Fragment, -+ null, -+ showUp ? pill("up", unread, { left: view.center, top: view.top }, toDivider, () => setAbove((state) => ({ ...state, dismissed: true }))) : null, -+ showDown ? pill("down", fresh, { left: view.center, bottom: view.bottom }, toLatest, () => setRelease((state) => ({ ...state, dismissed: true }))) : null -+ ), -+ document.body -+ ); -+} -+ + null, + showUp ? pill("up", unread, { left: view.center, top: view.top }, toDivider, () => setAbove((state) => ({ ...state, dismissed: true }))) : null, + showDown ? pill("down", fresh, { left: view.center, bottom: view.bottom }, toLatest, () => setRelease((state) => ({ ...state, dismissed: true }))) : null +@@ -5714,19 +5714,117 @@ function NewMessagePills({ sessionId, useSessions, useUi, useTurnClock }) { + ); + } + +// src/client/composer-label.js +var ATTRIBUTES = ["aria-label", "data-placeholder"]; +function holdComposerLabel(doc, label) { @@ -12813,8 +11230,8 @@ diff --git a/client.js b/client.js + for (const held of slots.values()) held.off(); + slots.clear(); + }; - } - ++} ++ // src/client/composer.js -var import_react10 = require("react"); +var import_react20 = require("react"); @@ -13158,10 +11575,35 @@ 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; - let quietSince = 0; - return (level, now) => { +@@ -6085,268 +6085,44 @@ async function openCapture() { + }); + stream.getTracks().forEach((track) => track.stop()); + const blob = new Blob(chunks, { type: recorder.mimeType }); +- if (blob.size === 0) return null; +- const decoded = await audio.decodeAudioData(await blob.arrayBuffer()); +- const length = Math.max(1, Math.floor(Math.min(decoded.duration, maxSeconds) * 16e3)); +- const offline = new OfflineAudioContext(1, length, 16e3); +- const source = offline.createBufferSource(); +- source.buffer = decoded; +- source.connect(offline.destination); +- source.start(); +- return waveBytes((await offline.startRendering()).getChannelData(0)); +- } finally { +- close(); +- } +- } +- }; +-} +-async function transcribeWave(speech, catalog, wave, signal) { +- if (wave.byteLength > catalog.maxAudioBytes) throw captureError("too-long"); +- const result = await speech.transcribe({ audioBase64: base64Of(wave), ...catalog.selection }, signal); +- if (!result?.ok) throw Object.assign(new Error(result?.error?.message ?? "Transcription failed"), { kind: "service" }); +- return String(result.value?.text ?? "").trim(); +-} +-function turnDetector({ loud = 0.12, quiet = 0.06, quietMs = 1200 } = {}) { +- let spokeAt = 0; +- let quietSince = 0; +- return (level, now) => { - if (level >= loud) { - spokeAt ||= now; - quietSince = 0; @@ -13395,6 +11837,31 @@ diff --git a/client.js b/client.js - ) - ) - ); ++ if (blob.size === 0) return null; ++ const decoded = await audio.decodeAudioData(await blob.arrayBuffer()); ++ const length = Math.max(1, Math.floor(Math.min(decoded.duration, maxSeconds) * 16e3)); ++ const offline = new OfflineAudioContext(1, length, 16e3); ++ const source = offline.createBufferSource(); ++ source.buffer = decoded; ++ source.connect(offline.destination); ++ source.start(); ++ return waveBytes((await offline.startRendering()).getChannelData(0)); ++ } finally { ++ close(); ++ } ++ } ++ }; ++} ++async function transcribeWave(speech, catalog, wave, signal) { ++ if (wave.byteLength > catalog.maxAudioBytes) throw captureError("too-long"); ++ const result = await speech.transcribe({ audioBase64: base64Of(wave), ...catalog.selection }, signal); ++ if (!result?.ok) throw Object.assign(new Error(result?.error?.message ?? "Transcription failed"), { kind: "service" }); ++ return String(result.value?.text ?? "").trim(); ++} ++function turnDetector({ loud = 0.12, quiet = 0.06, quietMs = 1200 } = {}) { ++ let spokeAt = 0; ++ let quietSince = 0; ++ return (level, now) => { + if (level >= loud) { + spokeAt ||= now; + quietSince = 0; @@ -13744,7 +12211,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}} `; @@ -13816,6 +12283,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)} ++/* #1335: 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 @@ -15470,7 +13945,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); }; @@ -15504,62 +13979,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 }) }) + ) + ), @@ -15623,9 +14081,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} ++/* #1335: 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)} @@ -15647,6 +14112,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}} ++/* #1335: 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 @@ -15826,15 +14299,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.") }, @@ -15845,12 +14323,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, @@ -15863,26 +14347,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, @@ -15890,15 +14379,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")), @@ -15938,9 +14422,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 }] }) }, "×") + )), @@ -15986,42 +14468,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)) + ) + ) @@ -16239,7 +14710,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", { @@ -16272,7 +14768,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") }, @@ -17092,41 +15598,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 = [ @@ -17137,193 +15657,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) { @@ -17347,7 +15680,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 { @@ -17359,15 +15709,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", @@ -17449,7 +15794,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); @@ -17465,16 +15812,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")); @@ -17500,8 +15838,9 @@ diff --git a/client.js b/client.js + }).catch((failure) => { + setError(failure?.message ?? String(failure)); + return null; -+ }); -+ }; + }); + }; +- return (0, import_react23.createElement)( + const useDefault = () => { + if (!opened) return null; + return Promise.resolve(actions.workflowSave({ op: "default", id: opened.workflow.id })).then((value) => { @@ -17516,47 +15855,65 @@ diff --git a/client.js b/client.js + 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, @@ -17567,14 +15924,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`), @@ -17587,10 +15955,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", + { @@ -17610,17 +16068,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")) ) ); } @@ -17866,7 +16347,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)( @@ -17893,7 +16375,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)}%`)) @@ -19668,7 +18150,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; @@ -19689,17 +18171,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; -- const name = key.slice(key.indexOf(":") + 1); -- return LEGACY_PREFIXES.map((prefix) => `${prefix}${name}`); --} +@@ -10992,279 +10992,54 @@ function legacyKeys(key) { + const name = key.slice(key.indexOf(":") + 1); + return LEGACY_PREFIXES.map((prefix) => `${prefix}${name}`); + } -var migrateBrowsers = (storage) => { - if (storage.getItem(STORAGE.browsers) !== null) { - for (const key of LEGACY_BROWSER_KEYS) storage.removeItem(key); @@ -19928,13 +18403,7 @@ 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}`); - } +-} -function useMemberWork(room2, useWork) { - const work = useWork?.((value) => value) ?? NO_WORK; - const entries2 = room2 ? memberWork(room2, work) : []; @@ -20356,9 +18825,27 @@ 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 - setTimeout(() => setCopied(false), 1600); - }); +@@ -11467,41 +11467,66 @@ function DetailsDrawer({ sessionId, startRenaming, startTab, hasStrip, roster, a + return false; + }); + const share = () => { +- void navigator.clipboard?.writeText(transcript).then(() => { +- setCopied(true); +- setTimeout(() => setCopied(false), 1600); +- }); ++ const text = transcript; ++ if (!text) { ++ actions.notify?.(t("Nothing to copy yet")); ++ return; ++ } ++ void Promise.resolve(navigator.clipboard?.writeText(text)).then( ++ () => { ++ setCopied(true); ++ setTimeout(() => setCopied(false), 1600); ++ actions.notify?.(t("Conversation copied as Markdown")); ++ }, ++ () => actions.reportError?.(new Error(t("Could not copy the conversation")), "share") ++ ); }; - const errorLine = error ? (0, import_react31.createElement)("div", { className: "bt-error", role: "alert", style: { padding: 0 } }, error) : null; - if ((bot || room2) && view === "settings") { @@ -20403,7 +18890,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", ++ // #1335: 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) { @@ -20432,7 +18928,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); } } @@ -20562,7 +19058,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; } }; diff --git a/patches/ds-bot/0012-room-membership.patch b/patches/ds-bot/0012-room-membership.patch index 475df6cbc..be01dcd6b 100644 --- a/patches/ds-bot/0012-room-membership.patch +++ b/patches/ds-bot/0012-room-membership.patch @@ -221,8 +221,23 @@ 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,18 @@ 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(() => { +- setCopied(true) +- setTimeout(() => setCopied(false), 1600) +- }) ++ // #1335: a copy that says nothing is a click that did nothing to anyone watching. The clipboard ++ // write is a promise and can be refused (no permission, no user gesture), so the toast reports ++ // what actually happened instead of the tick alone claiming success. ++ const text = transcript ++ if (!text) { actions.notify?.(t('Nothing to copy yet')); return } ++ void Promise.resolve(navigator.clipboard?.writeText(text)).then( ++ () => { setCopied(true); setTimeout(() => setCopied(false), 1600); actions.notify?.(t('Conversation copied as Markdown')) }, ++ () => actions.reportError?.(new Error(t('Could not copy the conversation')), 'share'), ++ ) } const errorLine = error ? h('div', { className: 'bt-error', role: 'alert', style: { padding: 0 } }, error) : null - if ((bot || room) && view === 'settings') { @@ -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', ++ // #1335: 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..6cfda2ecb 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) } })))) + // #1335: 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 (#1335): 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} +/* #1335: 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..e7fb2c241 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)} +/* #1335: 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..eca12859f 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 +// #1335: 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/shared/turn-failures.js b/patches/ds-bot/files/src/shared/turn-failures.js index 7ae5d2696..b0c51466b 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 (#1335). + 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..6f82fa84f 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]) + // #1335: 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..952b8b4bd 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() } }) +// #1335: 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('#1335: 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 () => { From 9d247fa3ffb13b7aeb9a2ca5c19634ab63540b7b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 27 Oct 2026 05:25:28 +0000 Subject: [PATCH 2/7] docs(subsystems): the click-through fixes, in the pages that own them Co-authored-by: Danyang Chen --- docs/subsystems/core-client.md | 8 ++++++-- docs/subsystems/core-host.md | 2 ++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/docs/subsystems/core-client.md b/docs/subsystems/core-client.md index cb50b3803..492e3cc3c 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 #1335: 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; the picker opens on its placeholder, not on the first other chat, so a target is picked before **Copy** can send, #1322) 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 #1335 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, #1335): `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 **Delivery** view of the task dialog (#1173, #1211): five columns (`raw`, `ready`, `working`, `review`, `done-today`) from one host snapshot, 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. Read-only: it renders, it never writes. 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,9 @@ 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 (#1335): 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 (#1335): `.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 (#1335): `[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. ### Host Bot look in the bundle diff --git a/docs/subsystems/core-host.md b/docs/subsystems/core-host.md index 2fba3d2c8..7da5ca359 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, #1335): `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 (#1335): `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`. | From e8baa103c29bd928df89205f8a4698719ffe0c59 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 27 Oct 2026 05:25:39 +0000 Subject: [PATCH 3/7] docs(plugin-runtime): the dock's own header for a blank Bot chat (#1335) Co-authored-by: Danyang Chen --- docs/subsystems/plugin-runtime.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/subsystems/plugin-runtime.md b/docs/subsystems/plugin-runtime.md index ad9e80a79..13e270911 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 (#1335). 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. From 3bbab86f71cb42a92cbba8472b8d0eb219d45559 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 27 Oct 2026 05:25:50 +0000 Subject: [PATCH 4/7] build(patches): store the three new files whole in the patch series Co-authored-by: Danyang Chen --- .../ds-bot/files/src/shared/failure-copy.js | 52 ++++++ patches/ds-bot/files/src/shared/preview.js | 42 +++++ .../ds-bot/files/test/ui-clickthrough.test.js | 151 ++++++++++++++++++ 3 files changed, 245 insertions(+) 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/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..c16497cc6 --- /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 (#1335). +// +// 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..531c3d687 --- /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 (#1335). +// +// 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/test/ui-clickthrough.test.js b/patches/ds-bot/files/test/ui-clickthrough.test.js new file mode 100644 index 000000000..4b8cbcdc0 --- /dev/null +++ b/patches/ds-bot/files/test/ui-clickthrough.test.js @@ -0,0 +1,151 @@ +// #1335: 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('#1335: 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('#1335: 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('#1335: 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('#1335: 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('#1335: 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. + assert.match(source, /actions\.notify\?\.\(t\('Conversation copied as Markdown'\)\)/) + assert.match(source, /actions\.reportError\?\.\(new Error\(t\('Could not copy the conversation'\)\)/) + 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('#1335: 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('#1335: 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('#1335: 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('#1335: 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('#1335: 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') +}) From ab0a953bf09aaf8501ff46a36d340dcdc2a5a33e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 27 Oct 2026 05:29:48 +0000 Subject: [PATCH 5/7] fix(ui): point the click-through fixes at the filed issue (#1347) The comments and the changelog fragment named a number that belongs to an unrelated enqueue-guard defect. The click-through findings are filed as #1347. Fixes #1347 Co-authored-by: Danyang Chen --- ...fixed.md => 1347-ui-clickthrough.fixed.md} | 2 +- core/client.js | 16 +++++++------- core/src/client/bot-settings.js | 4 ++-- core/src/client/browser-pane.js | 2 +- core/src/client/details-drawer.js | 4 ++-- core/src/client/flybot-slots.js | 6 ++--- core/src/client/model-test.js | 2 +- core/src/client/pane-width.js | 2 +- core/src/client/sidebar.js | 2 +- core/src/client/styles.js | 4 ++-- core/src/client/text.js | 2 +- core/src/host/channel.js | 2 +- core/src/shared/failure-copy.js | 2 +- core/src/shared/preview.js | 2 +- core/src/shared/turn-failures.js | 2 +- core/test/narrow-pane.test.js | 2 +- core/test/overlay-approvals.test.js | 4 ++-- core/test/ui-clickthrough.test.js | 22 +++++++++---------- docs/subsystems/core-client.md | 12 +++++----- docs/subsystems/core-host.md | 4 ++-- docs/subsystems/plugin-runtime.md | 2 +- patches/ds-bot/0001-core-fixes.patch | 4 ++-- patches/ds-bot/0003-flybot-api-client.patch | 10 ++++----- patches/ds-bot/0005-social-seams.patch | 2 +- patches/ds-bot/0008-client-bundle.patch | 16 +++++++------- patches/ds-bot/0012-room-membership.patch | 4 ++-- .../ds-bot/files/src/client/flybot-slots.js | 6 ++--- patches/ds-bot/files/src/client/model-test.js | 2 +- patches/ds-bot/files/src/client/pane-width.js | 2 +- .../ds-bot/files/src/shared/failure-copy.js | 2 +- patches/ds-bot/files/src/shared/preview.js | 2 +- .../ds-bot/files/src/shared/turn-failures.js | 2 +- patches/ds-bot/files/test/narrow-pane.test.js | 2 +- .../files/test/overlay-approvals.test.js | 4 ++-- .../ds-bot/files/test/ui-clickthrough.test.js | 22 +++++++++---------- 35 files changed, 90 insertions(+), 90 deletions(-) rename changelog.d/{1335-ui-clickthrough.fixed.md => 1347-ui-clickthrough.fixed.md} (92%) diff --git a/changelog.d/1335-ui-clickthrough.fixed.md b/changelog.d/1347-ui-clickthrough.fixed.md similarity index 92% rename from changelog.d/1335-ui-clickthrough.fixed.md rename to changelog.d/1347-ui-clickthrough.fixed.md index 0fee55420..c7ca6ec92 100644 --- a/changelog.d/1335-ui-clickthrough.fixed.md +++ b/changelog.d/1347-ui-clickthrough.fixed.md @@ -1 +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 ([#1335](https://github.com/ThinkFlowLab/FlyBot/issues/1335)). +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)). diff --git a/core/client.js b/core/client.js index 5184e83d2..404edc5e4 100644 --- a/core/client.js +++ b/core/client.js @@ -3306,7 +3306,7 @@ 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} -/* #1335: a Bot chat's own strip before its first turn, the same shape a group's uses (#998): one row +/* #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} @@ -3384,7 +3384,7 @@ var failureLines = (snapshot, place, t2, limit = FAILURE_LINES_SHOWN) => { return ended.filter((entry) => entry?.silent === true && (entry.roomId ?? entry.sessionId) === place).slice(-limit).map((entry) => ({ id: `turn-failure-${entry.id}`, // 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 (#1335). + // 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) } : {} })); @@ -4251,7 +4251,7 @@ 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} -/* #1335: a row's copy never breaks its own label. The label used to be a bare inline span inside a +/* #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} @@ -4617,7 +4617,7 @@ 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} -/* #1335: "Standard mode" beside the title reads as a control — a filled chip with an icon — but the +/* #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 @@ -11831,7 +11831,7 @@ 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)} -/* #1335: the hint beside "Test connection" is prose and wraps; the button next to it keeps its own +/* #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} @@ -13597,7 +13597,7 @@ var BOT_SETTINGS_CSS = ` .bt-bs-row{min-height:48px} label.bt-bs-row{cursor:text} .bt-bs-label{font-size:13px;line-height:18px;font-weight:500} -/* #1335: a row keeps its label and its control side by side. The *label* is one line — "Name" used to +/* #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. */ @@ -13627,7 +13627,7 @@ ${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}} -/* #1335: the panel's floor is 240px, where a label and a 96px field cannot share a line honestly. Below +/* #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){ @@ -17981,7 +17981,7 @@ function DetailsDrawer({ sessionId, startRenaming, startTab, hasStrip, roster, a bot || room2 ? (0, import_react43.createElement)("button", { type: "button", className: "bt-round", - // #1335: the accessible name says what the click does, which is copy — "Share conversation" + // #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"), diff --git a/core/src/client/bot-settings.js b/core/src/client/bot-settings.js index 35efdee52..a27eb87d7 100644 --- a/core/src/client/bot-settings.js +++ b/core/src/client/bot-settings.js @@ -277,7 +277,7 @@ export const BOT_SETTINGS_CSS = ` .bt-bs-row{min-height:48px} label.bt-bs-row{cursor:text} .bt-bs-label{font-size:13px;line-height:18px;font-weight:500} -/* #1335: a row keeps its label and its control side by side. The *label* is one line — "Name" used to +/* #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. */ @@ -307,7 +307,7 @@ ${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}} -/* #1335: the panel's floor is 240px, where a label and a 96px field cannot share a line honestly. Below +/* #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){ diff --git a/core/src/client/browser-pane.js b/core/src/client/browser-pane.js index 2eac016f8..0f360679a 100644 --- a/core/src/client/browser-pane.js +++ b/core/src/client/browser-pane.js @@ -45,7 +45,7 @@ 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. A saved width narrower than DETAILS_WIDTH is a *stored* choice from a -// narrower policy (#1335 raised DETAILS_WIDTH from 320 to 380), so it is kept: only the floor of +// 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 { diff --git a/core/src/client/details-drawer.js b/core/src/client/details-drawer.js index 8778538f0..6626e25f3 100644 --- a/core/src/client/details-drawer.js +++ b/core/src/client/details-drawer.js @@ -257,7 +257,7 @@ export function DetailsDrawer({ sessionId, startRenaming, startTab, hasStrip, ro .then(() => { setError(''); return true }) .catch(failure => { setError(failure?.message ?? String(failure)); return false }) const share = () => { - // #1335: a copy that says nothing is a click that did nothing to anyone watching. The clipboard + // #1347: a copy that says nothing is a click that did nothing to anyone watching. The clipboard // write is a promise and can be refused (no permission, no user gesture), so the toast reports // what actually happened instead of the tick alone claiming success. const text = transcript @@ -289,7 +289,7 @@ export function DetailsDrawer({ sessionId, startRenaming, startTab, hasStrip, ro : null, bot || room ? h('button', { type: 'button', className: 'bt-round', - // #1335: the accessible name says what the click does, which is copy — "Share conversation" + // #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'), diff --git a/core/src/client/flybot-slots.js b/core/src/client/flybot-slots.js index 6cfda2ecb..c0ed0e6ba 100644 --- a/core/src/client/flybot-slots.js +++ b/core/src/client/flybot-slots.js @@ -173,7 +173,7 @@ 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 - // #1335: a Bot's chat has the same first-paint problem as a group's, and the sidebar row is the only + // #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. @@ -185,7 +185,7 @@ export function FlyBotSessionDock({ sessionId, useRoster, actions, session }) { 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 (#1335): who this chat is with, that nothing has been +// 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 }) { @@ -350,7 +350,7 @@ 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} -/* #1335: a Bot chat's own strip before its first turn, the same shape a group's uses (#998): one row +/* #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} diff --git a/core/src/client/model-test.js b/core/src/client/model-test.js index e7fb2c241..3b66e136a 100644 --- a/core/src/client/model-test.js +++ b/core/src/client/model-test.js @@ -62,7 +62,7 @@ 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)} -/* #1335: the hint beside "Test connection" is prose and wraps; the button next to it keeps its own +/* #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} diff --git a/core/src/client/pane-width.js b/core/src/client/pane-width.js index eca12859f..e1a75b870 100644 --- a/core/src/client/pane-width.js +++ b/core/src/client/pane-width.js @@ -4,7 +4,7 @@ // 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 -// #1335: 320 was too narrow for the settings form the details view hosts. A label and its control +// #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. diff --git a/core/src/client/sidebar.js b/core/src/client/sidebar.js index 6459fec6e..04ce186db 100644 --- a/core/src/client/sidebar.js +++ b/core/src/client/sidebar.js @@ -151,7 +151,7 @@ function RoomRow({ room, roster, actions, useSessions, useSessionStatus, useSess const id = room.id const fallback = useSessions(list => nameMentions(previewOf(list, id), roster)) // 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 (#1335). + // 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) diff --git a/core/src/client/styles.js b/core/src/client/styles.js index c7181d84b..fa73b0e46 100644 --- a/core/src/client/styles.js +++ b/core/src/client/styles.js @@ -506,7 +506,7 @@ 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} -/* #1335: a row's copy never breaks its own label. The label used to be a bare inline span inside a +/* #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} @@ -873,7 +873,7 @@ 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} -/* #1335: "Standard mode" beside the title reads as a control — a filled chip with an icon — but the +/* #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 diff --git a/core/src/client/text.js b/core/src/client/text.js index 0a64bd9f7..5afbb32aa 100644 --- a/core/src/client/text.js +++ b/core/src/client/text.js @@ -285,7 +285,7 @@ export function previewOf(list, id, hiddenTurns = [], unsentTurns = []) { 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. A stored line can - // still carry its untrusted fence, and a row is one line someone reads: no marker reaches it (#1335). + // 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 b5bdff1b4..125ea3720 100644 --- a/core/src/host/channel.js +++ b/core/src/host/channel.js @@ -149,7 +149,7 @@ export function install(rt) { const parts = [...Object.values(sessions ?? {}), ...Object.values(segments ?? {}).flat().map(segment => segment.sessionId)] const last = (log ?? []).filter(line => line.kind === undefined).at(-1) // The row is read by a person: a fence, or the fragment an older write left behind, is never part - // of it (#130, #1335). `visibleText` also folds the line to one line, as a row always was. + // 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 diff --git a/core/src/shared/failure-copy.js b/core/src/shared/failure-copy.js index c16497cc6..257a17831 100644 --- a/core/src/shared/failure-copy.js +++ b/core/src/shared/failure-copy.js @@ -1,4 +1,4 @@ -// A failed turn's sentence a person can act on (#1335). +// 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 diff --git a/core/src/shared/preview.js b/core/src/shared/preview.js index 531c3d687..980e1e426 100644 --- a/core/src/shared/preview.js +++ b/core/src/shared/preview.js @@ -4,7 +4,7 @@ // 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 (#1335). +// `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 diff --git a/core/src/shared/turn-failures.js b/core/src/shared/turn-failures.js index b0c51466b..87ed6b1a0 100644 --- a/core/src/shared/turn-failures.js +++ b/core/src/shared/turn-failures.js @@ -13,7 +13,7 @@ export const failureLines = (snapshot, place, t, limit = FAILURE_LINES_SHOWN) => .map(entry => ({ id: `turn-failure-${entry.id}`, // 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 (#1335). + // 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 6f82fa84f..282a1709a 100644 --- a/core/test/narrow-pane.test.js +++ b/core/test/narrow-pane.test.js @@ -14,7 +14,7 @@ const rule = (selector) => { test('#705: a narrow window shrinks the Details panel before it starves the conversation', () => { assert.equal(MIN_DETAILS, 240) - // #1335: the Details panel opens at 380, not 320. A settings row holds a label and its control on + // #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) diff --git a/core/test/overlay-approvals.test.js b/core/test/overlay-approvals.test.js index 952b8b4bd..278b7fc6c 100644 --- a/core/test/overlay-approvals.test.js +++ b/core/test/overlay-approvals.test.js @@ -190,12 +190,12 @@ test('#998: the session dock tells a slot whether the chat is still blank', asyn } finally { off() } }) -// #1335: a Bot chat's first paint has no header either (`headerBlank`), so the column under it is a +// #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('#1335: a blank Bot chat draws its own strip, and only while it is blank', async () => { +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 => { diff --git a/core/test/ui-clickthrough.test.js b/core/test/ui-clickthrough.test.js index 4b8cbcdc0..8fee45a16 100644 --- a/core/test/ui-clickthrough.test.js +++ b/core/test/ui-clickthrough.test.js @@ -1,4 +1,4 @@ -// #1335: a real mouse click-through of a running FlyBot found UI defects that no test covered. +// #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. @@ -20,7 +20,7 @@ const rule = (css, selector) => { 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('#1335: a settings row never breaks its own label or its control words', () => { +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"') @@ -34,7 +34,7 @@ test('#1335: a settings row never breaks its own label or its control words', () assert.match(rule(css, '.bt-bs-row>.bt-set-copy'), /flex:1 1 8ch/) }) -test('#1335: the panel is a container, and below 300px the row stacks instead of clipping', () => { +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/) @@ -46,7 +46,7 @@ test('#1335: the panel is a container, and below 300px the row stacks instead of assert.match(rule(read('model-test.js'), '.bt-model-test>.bt-soft'), /white-space:nowrap/) }) -test('#1335: a preview shows the words of a line, never its fence', () => { +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 >') @@ -59,7 +59,7 @@ test('#1335: a preview shows the words of a line, never its fence', () => { assert.equal(visibleText(undefined), '') }) -test('#1335: both sidebar row kinds read their preview through that helper', () => { +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') @@ -69,7 +69,7 @@ test('#1335: both sidebar row kinds read their preview through that helper', () assert.match(channel, /const previewText = last \? visibleText\(last\.text\) : ''/, 'the host sends words, not a fence') }) -test('#1335: the details drawer copies, says so, and names the button for what it does', () => { +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'\)/) @@ -85,7 +85,7 @@ test('#1335: the details drawer copies, says so, and names the button for what i } }) -test('#1335: the mode word beside the title does not look like a control', () => { +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/) @@ -93,7 +93,7 @@ test('#1335: the mode word beside the title does not look like a control', () => assert.match(chip, /cursor:help/) }) -test('#1335: a Bot chat with nothing said yet draws its own header', () => { +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/) @@ -105,7 +105,7 @@ test('#1335: a Bot chat with nothing said yet draws its own header', () => { assert.match(rule(source, '.bt-chat-strip'), /display:flex/) }) -test('#1335: a blank column says why it is blank, and only while it is blank', () => { +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. @@ -115,7 +115,7 @@ test('#1335: a blank column says why it is blank, and only while it is blank', ( assert.match(read('i18n.js'), /'No messages yet\. Say something to \{name\}\.'/) }) -test('#1335: a provider refusal reads as a sentence that says what to change', () => { +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) @@ -140,7 +140,7 @@ test('#1335: a provider refusal reads as a sentence that says what to change', ( } }) -test('#1335: the built bundle carries these rules, so a page reload shows them', () => { +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') diff --git a/docs/subsystems/core-client.md b/docs/subsystems/core-client.md index 492e3cc3c..a12f6f18c 100644 --- a/docs/subsystems/core-client.md +++ b/docs/subsystems/core-client.md @@ -31,14 +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. `DETAILS_WIDTH` is 380 since #1335: 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/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; the picker opens on its placeholder, not on the first other chat, so a target is picked before **Copy** can send, #1322) 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. Since #1335 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, #1335): `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/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 **Delivery** view of the task dialog (#1173, #1211): five columns (`raw`, `ready`, `working`, `review`, `done-today`) from one host snapshot, 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. Read-only: it renders, it never writes. 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`. | @@ -56,9 +56,9 @@ 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 (#1335): 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 (#1335): `.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 (#1335): `[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. +- 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. ### Host Bot look in the bundle diff --git a/docs/subsystems/core-host.md b/docs/subsystems/core-host.md index 7da5ca359..7fe097a57 100644 --- a/docs/subsystems/core-host.md +++ b/docs/subsystems/core-host.md @@ -52,8 +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, #1335): `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 (#1335): `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/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 13e270911..4ae9211b6 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 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 (#1335). 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 943b03234..b26153f30 100644 --- a/patches/ds-bot/0001-core-fixes.patch +++ b/patches/ds-bot/0001-core-fixes.patch @@ -209,7 +209,7 @@ diff --git a/src/client/browser-pane.js b/src/client/browser-pane.js // 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 (#1335 raised DETAILS_WIDTH from 320 to 380), so it is kept: only the floor of ++// 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 { @@ -2453,7 +2453,7 @@ diff --git a/src/client/text.js b/src/client/text.js - 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. A stored line can -+ // still carry its untrusted fence, and a row is one line someone reads: no marker reaches it (#1335). ++ // 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 b94ad250f..1794f9daa 100644 --- a/patches/ds-bot/0003-flybot-api-client.patch +++ b/patches/ds-bot/0003-flybot-api-client.patch @@ -448,7 +448,7 @@ diff --git a/src/client/bot-settings.js b/src/client/bot-settings.js 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} -+/* #1335: a row keeps its label and its control side by side. The *label* is one line — "Name" used to ++/* #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. */ @@ -465,7 +465,7 @@ diff --git a/src/client/bot-settings.js b/src/client/bot-settings.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}} -+/* #1335: the panel's floor is 240px, where a label and a 96px field cannot share a line honestly. Below ++/* #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){ @@ -2209,7 +2209,7 @@ diff --git a/src/client/sidebar.js b/src/client/sidebar.js - const preview = room.preview?.replace(/\s+/g, ' ').trim() || fallback + const fallback = useSessions(list => nameMentions(previewOf(list, id), roster)) + // 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 (#1335). ++ // 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) @@ -2715,7 +2715,7 @@ diff --git a/src/client/styles.js b/src/client/styles.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} -+/* #1335: a row's copy never breaks its own label. The label used to be a bare inline span inside a ++/* #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} @@ -2795,7 +2795,7 @@ 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} -+/* #1335: "Standard mode" beside the title reads as a control — a filled chip with an icon — but the ++/* #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 diff --git a/patches/ds-bot/0005-social-seams.patch b/patches/ds-bot/0005-social-seams.patch index 9868ba248..a964d58d9 100644 --- a/patches/ds-bot/0005-social-seams.patch +++ b/patches/ds-bot/0005-social-seams.patch @@ -419,7 +419,7 @@ diff --git a/src/host/channel.js b/src/host/channel.js const last = (log ?? []).filter(line => line.kind === undefined).at(-1) - return { ...room, parts, ...(last ? { preview: last.who === 'User' ? last.text : `${last.who}:${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, #1335). `visibleText` also folds the line to one line, as a row always was. ++ // 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 diff --git a/patches/ds-bot/0008-client-bundle.patch b/patches/ds-bot/0008-client-bundle.patch index 3519df25f..e61e22c1e 100644 --- a/patches/ds-bot/0008-client-bundle.patch +++ b/patches/ds-bot/0008-client-bundle.patch @@ -2861,7 +2861,7 @@ 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} -+/* #1335: a Bot chat's own strip before its first turn, the same shape a group's uses (#998): one row ++/* #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} @@ -2939,7 +2939,7 @@ diff --git a/client.js b/client.js + return ended.filter((entry) => entry?.silent === true && (entry.roomId ?? entry.sessionId) === place).slice(-limit).map((entry) => ({ + id: `turn-failure-${entry.id}`, + // 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 (#1335). ++ // 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) } : {} + })); @@ -3502,7 +3502,7 @@ 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} -+/* #1335: a row's copy never breaks its own label. The label used to be a bare inline span inside a ++/* #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} @@ -3582,7 +3582,7 @@ 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} -+/* #1335: "Standard mode" beside the title reads as a control — a filled chip with an icon — but the ++/* #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 @@ -12283,7 +12283,7 @@ 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)} -+/* #1335: the hint beside "Test connection" is prose and wraps; the button next to it keeps its own ++/* #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} @@ -14082,7 +14082,7 @@ diff --git a/client.js b/client.js +.bt-bs-row{min-height:48px} +label.bt-bs-row{cursor:text} +.bt-bs-label{font-size:13px;line-height:18px;font-weight:500} -+/* #1335: a row keeps its label and its control side by side. The *label* is one line — "Name" used to ++/* #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. */ @@ -14112,7 +14112,7 @@ 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}} -+/* #1335: the panel's floor is 240px, where a label and a 96px field cannot share a line honestly. Below ++/* #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){ @@ -18893,7 +18893,7 @@ diff --git a/client.js b/client.js + bot || room2 ? (0, import_react43.createElement)("button", { + type: "button", + className: "bt-round", -+ // #1335: the accessible name says what the click does, which is copy — "Share conversation" ++ // #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"), diff --git a/patches/ds-bot/0012-room-membership.patch b/patches/ds-bot/0012-room-membership.patch index be01dcd6b..2ed719564 100644 --- a/patches/ds-bot/0012-room-membership.patch +++ b/patches/ds-bot/0012-room-membership.patch @@ -229,7 +229,7 @@ diff --git a/src/client/details-drawer.js b/src/client/details-drawer.js - setCopied(true) - setTimeout(() => setCopied(false), 1600) - }) -+ // #1335: a copy that says nothing is a click that did nothing to anyone watching. The clipboard ++ // #1347: a copy that says nothing is a click that did nothing to anyone watching. The clipboard + // write is a promise and can be refused (no permission, no user gesture), so the toast reports + // what actually happened instead of the tick alone claiming success. + const text = transcript @@ -262,7 +262,7 @@ diff --git a/src/client/details-drawer.js b/src/client/details-drawer.js - 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', -+ // #1335: the accessible name says what the click does, which is copy — "Share conversation" ++ // #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'), diff --git a/patches/ds-bot/files/src/client/flybot-slots.js b/patches/ds-bot/files/src/client/flybot-slots.js index 6cfda2ecb..c0ed0e6ba 100644 --- a/patches/ds-bot/files/src/client/flybot-slots.js +++ b/patches/ds-bot/files/src/client/flybot-slots.js @@ -173,7 +173,7 @@ 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 - // #1335: a Bot's chat has the same first-paint problem as a group's, and the sidebar row is the only + // #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. @@ -185,7 +185,7 @@ export function FlyBotSessionDock({ sessionId, useRoster, actions, session }) { 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 (#1335): who this chat is with, that nothing has been +// 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 }) { @@ -350,7 +350,7 @@ 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} -/* #1335: a Bot chat's own strip before its first turn, the same shape a group's uses (#998): one row +/* #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} diff --git a/patches/ds-bot/files/src/client/model-test.js b/patches/ds-bot/files/src/client/model-test.js index e7fb2c241..3b66e136a 100644 --- a/patches/ds-bot/files/src/client/model-test.js +++ b/patches/ds-bot/files/src/client/model-test.js @@ -62,7 +62,7 @@ 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)} -/* #1335: the hint beside "Test connection" is prose and wraps; the button next to it keeps its own +/* #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} diff --git a/patches/ds-bot/files/src/client/pane-width.js b/patches/ds-bot/files/src/client/pane-width.js index eca12859f..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,7 @@ // 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 -// #1335: 320 was too narrow for the settings form the details view hosts. A label and its control +// #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. diff --git a/patches/ds-bot/files/src/shared/failure-copy.js b/patches/ds-bot/files/src/shared/failure-copy.js index c16497cc6..257a17831 100644 --- a/patches/ds-bot/files/src/shared/failure-copy.js +++ b/patches/ds-bot/files/src/shared/failure-copy.js @@ -1,4 +1,4 @@ -// A failed turn's sentence a person can act on (#1335). +// 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 diff --git a/patches/ds-bot/files/src/shared/preview.js b/patches/ds-bot/files/src/shared/preview.js index 531c3d687..980e1e426 100644 --- a/patches/ds-bot/files/src/shared/preview.js +++ b/patches/ds-bot/files/src/shared/preview.js @@ -4,7 +4,7 @@ // 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 (#1335). +// `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 diff --git a/patches/ds-bot/files/src/shared/turn-failures.js b/patches/ds-bot/files/src/shared/turn-failures.js index b0c51466b..87ed6b1a0 100644 --- a/patches/ds-bot/files/src/shared/turn-failures.js +++ b/patches/ds-bot/files/src/shared/turn-failures.js @@ -13,7 +13,7 @@ export const failureLines = (snapshot, place, t, limit = FAILURE_LINES_SHOWN) => .map(entry => ({ id: `turn-failure-${entry.id}`, // 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 (#1335). + // 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 6f82fa84f..282a1709a 100644 --- a/patches/ds-bot/files/test/narrow-pane.test.js +++ b/patches/ds-bot/files/test/narrow-pane.test.js @@ -14,7 +14,7 @@ const rule = (selector) => { test('#705: a narrow window shrinks the Details panel before it starves the conversation', () => { assert.equal(MIN_DETAILS, 240) - // #1335: the Details panel opens at 380, not 320. A settings row holds a label and its control on + // #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) diff --git a/patches/ds-bot/files/test/overlay-approvals.test.js b/patches/ds-bot/files/test/overlay-approvals.test.js index 952b8b4bd..278b7fc6c 100644 --- a/patches/ds-bot/files/test/overlay-approvals.test.js +++ b/patches/ds-bot/files/test/overlay-approvals.test.js @@ -190,12 +190,12 @@ test('#998: the session dock tells a slot whether the chat is still blank', asyn } finally { off() } }) -// #1335: a Bot chat's first paint has no header either (`headerBlank`), so the column under it is a +// #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('#1335: a blank Bot chat draws its own strip, and only while it is blank', async () => { +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 => { diff --git a/patches/ds-bot/files/test/ui-clickthrough.test.js b/patches/ds-bot/files/test/ui-clickthrough.test.js index 4b8cbcdc0..8fee45a16 100644 --- a/patches/ds-bot/files/test/ui-clickthrough.test.js +++ b/patches/ds-bot/files/test/ui-clickthrough.test.js @@ -1,4 +1,4 @@ -// #1335: a real mouse click-through of a running FlyBot found UI defects that no test covered. +// #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. @@ -20,7 +20,7 @@ const rule = (css, selector) => { 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('#1335: a settings row never breaks its own label or its control words', () => { +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"') @@ -34,7 +34,7 @@ test('#1335: a settings row never breaks its own label or its control words', () assert.match(rule(css, '.bt-bs-row>.bt-set-copy'), /flex:1 1 8ch/) }) -test('#1335: the panel is a container, and below 300px the row stacks instead of clipping', () => { +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/) @@ -46,7 +46,7 @@ test('#1335: the panel is a container, and below 300px the row stacks instead of assert.match(rule(read('model-test.js'), '.bt-model-test>.bt-soft'), /white-space:nowrap/) }) -test('#1335: a preview shows the words of a line, never its fence', () => { +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 >') @@ -59,7 +59,7 @@ test('#1335: a preview shows the words of a line, never its fence', () => { assert.equal(visibleText(undefined), '') }) -test('#1335: both sidebar row kinds read their preview through that helper', () => { +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') @@ -69,7 +69,7 @@ test('#1335: both sidebar row kinds read their preview through that helper', () assert.match(channel, /const previewText = last \? visibleText\(last\.text\) : ''/, 'the host sends words, not a fence') }) -test('#1335: the details drawer copies, says so, and names the button for what it does', () => { +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'\)/) @@ -85,7 +85,7 @@ test('#1335: the details drawer copies, says so, and names the button for what i } }) -test('#1335: the mode word beside the title does not look like a control', () => { +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/) @@ -93,7 +93,7 @@ test('#1335: the mode word beside the title does not look like a control', () => assert.match(chip, /cursor:help/) }) -test('#1335: a Bot chat with nothing said yet draws its own header', () => { +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/) @@ -105,7 +105,7 @@ test('#1335: a Bot chat with nothing said yet draws its own header', () => { assert.match(rule(source, '.bt-chat-strip'), /display:flex/) }) -test('#1335: a blank column says why it is blank, and only while it is blank', () => { +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. @@ -115,7 +115,7 @@ test('#1335: a blank column says why it is blank, and only while it is blank', ( assert.match(read('i18n.js'), /'No messages yet\. Say something to \{name\}\.'/) }) -test('#1335: a provider refusal reads as a sentence that says what to change', () => { +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) @@ -140,7 +140,7 @@ test('#1335: a provider refusal reads as a sentence that says what to change', ( } }) -test('#1335: the built bundle carries these rules, so a page reload shows them', () => { +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') From 33318ac965f379685685821f4bef58dd97b06326 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 27 Oct 2026 05:45:32 +0000 Subject: [PATCH 6/7] fix(ui): the thread can no longer paint above a dialog, a sheet or a scrim The owner's screenshots show a right-aligned user message at full opacity across the Tasks dialog and over the Inbox scrim, with chat text readable through the columns. My earlier elementsFromPoint walk could not see it, because this is a paint-order defect rather than a DOM-order one: the walk returns the dialog as topmost while the browser still paints the bubble above it. Cause. 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 promotes 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 rather than against the column, and a dialog layer is just a z-index box at the root. So a promoted bubble sorted above the dialog. It also explains the repaint that cleared the first report's leftover toolbar, and why the defect did not show up in a headless screenshot, which forces a full repaint. Fix, two halves. The load-bearing one gives the column its own stacking context (position:relative, z-index:0, isolation:isolate), so every descendant paints inside it and the whole context sits below .bt-panel (40), .bt-pane (45) and the dialogs (120/121) whatever any one bubble does. The second half removes the z-index ties: dialogs are 120/121, popovers 130, palette 140, where the context menu and reaction strip used to share 60 with the Settings dialog and a later popover could win on DOM order. Measured on a live page, forcing the promotion the browser performs (transform plus a large z-index on the user bubble, painted opaque red) and counting red pixels inside the dialog: Tasks 1842 before the fix, 0 after; Settings 59 before, 0 after. Removing just the stacking context while keeping the raised tiers puts 1229 back, which is why both are here and why the context is the one that matters. Popovers still float: the account menu and the in-panel model menu are both topmost at 130. Not a speculative Trajectory change: the owner's fresh re-verify found bug 2 gone, and my own frame-by-frame recording agreed, so nothing was changed for it. Refs #1347 Co-authored-by: Danyang Chen --- changelog.d/1347-ui-clickthrough.fixed.md | 2 +- core/client.js | 38 ++++++-- core/src/client/remote-bot-settings.js | 2 +- core/src/client/sidebar.js | 2 +- core/src/client/styles.js | 34 +++++-- core/test/ui-clickthrough.test.js | 30 ++++++ docs/subsystems/core-client.md | 2 + patches/ds-bot/0003-flybot-api-client.patch | 92 +++++++++++++++++- patches/ds-bot/0008-client-bundle.patch | 94 ++++++++++++++++++- .../files/src/client/remote-bot-settings.js | 2 +- .../ds-bot/files/test/ui-clickthrough.test.js | 30 ++++++ 11 files changed, 303 insertions(+), 25 deletions(-) diff --git a/changelog.d/1347-ui-clickthrough.fixed.md b/changelog.d/1347-ui-clickthrough.fixed.md index c7ca6ec92..43f8c7c8c 100644 --- a/changelog.d/1347-ui-clickthrough.fixed.md +++ b/changelog.d/1347-ui-clickthrough.fixed.md @@ -1 +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)). +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; measured on a live page with a promoted bubble, chat content inside the Tasks dialog went from 1842 painted pixels to none. 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)). diff --git a/core/client.js b/core/client.js index 404edc5e4..51d711683 100644 --- a/core/client.js +++ b/core/client.js @@ -4111,7 +4111,7 @@ button.bt-q-opt:active{background:var(--bt-active)} .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} @@ -4156,7 +4156,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} @@ -4196,7 +4196,14 @@ 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;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)} .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} @@ -4336,7 +4343,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} @@ -4355,7 +4362,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} @@ -4365,7 +4372,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;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} @@ -4470,7 +4477,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;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} @@ -4629,6 +4636,19 @@ var CONVERSATION_CSS = ` 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. */ @@ -10443,7 +10463,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)} @@ -18711,7 +18731,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;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/remote-bot-settings.js b/core/src/client/remote-bot-settings.js index 6434bc20f..6c22fc326 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;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 04ce186db..6f7446567 100644 --- a/core/src/client/sidebar.js +++ b/core/src/client/sidebar.js @@ -748,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 fa73b0e46..ea00b8292 100644 --- a/core/src/client/styles.js +++ b/core/src/client/styles.js @@ -366,7 +366,7 @@ button.bt-q-opt:active{background:var(--bt-active)} .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,7 +451,14 @@ 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;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)} .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} @@ -591,7 +598,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} @@ -610,7 +617,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} @@ -620,7 +627,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;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} @@ -725,7 +732,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;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} @@ -885,6 +892,19 @@ export const CONVERSATION_CSS = ` 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/test/ui-clickthrough.test.js b/core/test/ui-clickthrough.test.js index 8fee45a16..044c540b3 100644 --- a/core/test/ui-clickthrough.test.js +++ b/core/test/ui-clickthrough.test.js @@ -140,6 +140,36 @@ test('#1347: a provider refusal reads as a sentence that says what to change', ( } }) +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, so any descendant the browser + // promoted (an entrance animation's transform, a filter, will-change) was ordered against the root + // and could sort above a dialog's z-index box. Measured on a live page with a promoted user bubble: + // 614 px of chat painted inside the Tasks dialog and 59 px inside Settings before this rule, 0 after + // — and 1229 px with the rule removed while the raised tiers stayed, so the tiers alone cannot + // rescue it and this rule is what carries the fix. + 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][;}]/) +}) + 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. diff --git a/docs/subsystems/core-client.md b/docs/subsystems/core-client.md index a12f6f18c..abeb4c36f 100644 --- a/docs/subsystems/core-client.md +++ b/docs/subsystems/core-client.md @@ -59,6 +59,8 @@ Add a new invariant under the heading it belongs to (or a new heading), not at t - 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. A live page with one promoted user bubble painted 1842 red pixels of chat inside the Tasks dialog before this rule and 0 after; removing the rule while keeping the raised tiers put 1229 back, so tiers alone cannot rescue it. `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. +- **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/patches/ds-bot/0003-flybot-api-client.patch b/patches/ds-bot/0003-flybot-api-client.patch index 1794f9daa..b7b7addb5 100644 --- a/patches/ds-bot/0003-flybot-api-client.patch +++ b/patches/ds-bot/0003-flybot-api-client.patch @@ -2529,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)}} @@ -2682,7 +2691,7 @@ 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,7 +332,7 @@ button.bt-q-opt:active{background:var(--bt-active)} +@@ -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} @@ -2691,6 +2700,11 @@ diff --git a/src/client/styles.js b/src/client/styles.js .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} @@ -2700,6 +2714,31 @@ 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,7 +419,14 @@ 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;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)} + .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} @@ -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} @@ -2740,6 +2779,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;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)} @@ -2750,6 +2816,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;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} @@ -2773,7 +2848,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,35 @@ 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} @@ -2809,6 +2884,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/0008-client-bundle.patch b/patches/ds-bot/0008-client-bundle.patch index e61e22c1e..dbb9d64dc 100644 --- a/patches/ds-bot/0008-client-bundle.patch +++ b/patches/ds-bot/0008-client-bundle.patch @@ -3469,7 +3469,7 @@ diff --git a/client.js b/client.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} -@@ -2150,7 +2150,7 @@ button.bt-q-opt:active{background:var(--bt-active)} +@@ -2150,11 +2150,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} @@ -3478,6 +3478,11 @@ diff --git a/client.js b/client.js .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} @@ -2163,6 +2163,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} @@ -3487,6 +3492,31 @@ diff --git a/client.js b/client.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} +@@ -2197,7 +2197,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} +@@ -2237,7 +2237,14 @@ 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;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)} + .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} @@ -2255,6 +2255,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} @@ -3527,6 +3557,33 @@ diff --git a/client.js b/client.js /* Shell chrome reshaped for the team view. Selectors use stable data attributes where the shell offers them and CSS-module name suffixes elsewhere. */ +@@ -2361,7 +2361,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} +@@ -2380,7 +2380,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} +@@ -2390,7 +2390,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;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} @@ -2427,7 +2427,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)} @@ -3537,6 +3594,15 @@ diff --git a/client.js b/client.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 +@@ -2494,7 +2494,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;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} @@ -2599,6 +2599,14 @@ var 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} @@ -3560,7 +3626,7 @@ diff --git a/client.js b/client.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 -@@ -2622,21 +2622,35 @@ var CONVERSATION_CSS = ` +@@ -2622,21 +2622,48 @@ var 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} @@ -3596,6 +3662,19 @@ diff --git a/client.js b/client.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. */ @@ -10917,6 +10996,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)}} @@ -19394,7 +19482,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;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/client/remote-bot-settings.js b/patches/ds-bot/files/src/client/remote-bot-settings.js index 6434bc20f..6c22fc326 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;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/test/ui-clickthrough.test.js b/patches/ds-bot/files/test/ui-clickthrough.test.js index 8fee45a16..044c540b3 100644 --- a/patches/ds-bot/files/test/ui-clickthrough.test.js +++ b/patches/ds-bot/files/test/ui-clickthrough.test.js @@ -140,6 +140,36 @@ test('#1347: a provider refusal reads as a sentence that says what to change', ( } }) +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, so any descendant the browser + // promoted (an entrance animation's transform, a filter, will-change) was ordered against the root + // and could sort above a dialog's z-index box. Measured on a live page with a promoted user bubble: + // 614 px of chat painted inside the Tasks dialog and 59 px inside Settings before this rule, 0 after + // — and 1229 px with the rule removed while the raised tiers stayed, so the tiers alone cannot + // rescue it and this rule is what carries the fix. + 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][;}]/) +}) + 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. From 7f8c7daeebe21e7eea8274f99f973629e93fff48 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 27 Oct 2026 05:58:48 +0000 Subject: [PATCH 7/7] fix(ui): prove and harden the dialog stacking fix on the owner's own tree The owner re-verified on FlyBot A, whose checkout is 0.2.7 (e109361). The stacking fix existed only on this branch, so that instance necessarily still showed the defect: the A/B below is the evidence, run on the exact 0.2.7 tree rather than on this branch. Reproduced on e109361, then cured by porting ONLY the stacking CSS onto it. Method: force the composited-layer promotion a promoted bubble resolves to (transform plus a large z-index on the user bubble, painted opaque red) and count red pixels inside the dialog. pristine 0.2.7 Tasks 615 Settings 59 0.2.7 + this CSS Tasks 0 Settings 0 The screenshot scenario itself was measured too: a long right-aligned pill overlapping the Tasks dialog by 12090 px2 and Settings by 1587 px2 (the owner's ~1864 is the same shape) -- overlap in the layout, zero pixels painted inside the dialog. Two additions, both defence in depth rather than a second theory: - Every overlay layer is now its own stacking context (.bt-settings-layer, .bt-mem-layer, .bt-palette-layer, .bt-remote-sheet-layer carry isolation:isolate). The layer as a whole -- scrim and dialog together -- is what sits in the tier, so nothing a dialog's own contents do can split them. - The scrim actually separates the two surfaces. It was rgba(0,0,0,.32), which left the thread legible behind a dialog; that is what "the pill shows at full opacity behind the Inbox" and "assistant text readable across columns" describe, and no z-index fixes it. It is rgba(12,12,14,.62) light and rgba(0,0,0,.7) dark: still one layer, still dismissed by a click. The regression test pins all of it: the column's isolation and z-index, the tier of every dialog and popover, each layer's own context, and a floor on both scrim alphas. Verified at the tip: core 1874/1874, social 525, tree 125, timeline 125, lint and arch-docs clean, patches reproduce core/, and the stacking measurement is 0 on a live bench of this branch for Tasks, Settings and Inbox. Refs #1347 Co-authored-by: Danyang Chen --- changelog.d/1347-ui-clickthrough.fixed.md | 1 + core/client.js | 15 +++++++++++---- core/src/client/remote-bot-settings.js | 2 +- core/src/client/styles.js | 13 ++++++++++--- core/test/ui-clickthrough.test.js | 18 ++++++++++++++++++ docs/subsystems/core-client.md | 2 ++ patches/ds-bot/0003-flybot-api-client.patch | 17 +++++++++++++---- patches/ds-bot/0008-client-bundle.patch | 19 ++++++++++++++----- .../files/src/client/remote-bot-settings.js | 2 +- .../ds-bot/files/test/ui-clickthrough.test.js | 18 ++++++++++++++++++ 10 files changed, 89 insertions(+), 18 deletions(-) diff --git a/changelog.d/1347-ui-clickthrough.fixed.md b/changelog.d/1347-ui-clickthrough.fixed.md index 43f8c7c8c..f10dcfd03 100644 --- a/changelog.d/1347-ui-clickthrough.fixed.md +++ b/changelog.d/1347-ui-clickthrough.fixed.md @@ -1 +1,2 @@ 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; measured on a live page with a promoted bubble, chat content inside the Tasks dialog went from 1842 painted pixels to none. 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 51d711683..7bad572c3 100644 --- a/core/client.js +++ b/core/client.js @@ -4203,9 +4203,16 @@ button.bt-q-opt:active{background:var(--bt-active)} 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;display:flex;align-items:center;justify-content:center;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif} +.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} @@ -4372,7 +4379,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:140;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} @@ -4477,7 +4484,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:121;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} @@ -18731,7 +18738,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:120;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/remote-bot-settings.js b/core/src/client/remote-bot-settings.js index 6c22fc326..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:120;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/styles.js b/core/src/client/styles.js index ea00b8292..f28195004 100644 --- a/core/src/client/styles.js +++ b/core/src/client/styles.js @@ -458,9 +458,16 @@ button.bt-q-opt:active{background:var(--bt-active)} 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;display:flex;align-items:center;justify-content:center;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif} +.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} @@ -627,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:140;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} @@ -732,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:121;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} diff --git a/core/test/ui-clickthrough.test.js b/core/test/ui-clickthrough.test.js index 044c540b3..7e27aea84 100644 --- a/core/test/ui-clickthrough.test.js +++ b/core/test/ui-clickthrough.test.js @@ -168,6 +168,24 @@ test('#1347: the thread cannot paint above a dialog, a sheet or a scrim', () => 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', () => { diff --git a/docs/subsystems/core-client.md b/docs/subsystems/core-client.md index abeb4c36f..e38de2995 100644 --- a/docs/subsystems/core-client.md +++ b/docs/subsystems/core-client.md @@ -60,6 +60,8 @@ Add a new invariant under the heading it belongs to (or a new heading), not at t - 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. A live page with one promoted user bubble painted 1842 red pixels of chat inside the Tasks dialog before this rule and 0 after; removing the rule while keeping the raised tiers put 1229 back, so tiers alone cannot rescue it. `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/patches/ds-bot/0003-flybot-api-client.patch b/patches/ds-bot/0003-flybot-api-client.patch index b7b7addb5..0e6186913 100644 --- a/patches/ds-bot/0003-flybot-api-client.patch +++ b/patches/ds-bot/0003-flybot-api-client.patch @@ -2723,7 +2723,7 @@ diff --git a/src/client/styles.js b/src/client/styles.js .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,7 +419,14 @@ button.bt-q-opt:active{background:var(--bt-active)} +@@ -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. */ @@ -2735,10 +2735,19 @@ diff --git a/src/client/styles.js b/src/client/styles.js + 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;display:flex;align-items:center;justify-content:center;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif} ++.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} @@ -2802,7 +2811,7 @@ diff --git a/src/client/styles.js b/src/client/styles.js .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;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} @@ -2821,7 +2830,7 @@ diff --git a/src/client/styles.js b/src/client/styles.js .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;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} diff --git a/patches/ds-bot/0008-client-bundle.patch b/patches/ds-bot/0008-client-bundle.patch index dbb9d64dc..ed7b37fec 100644 --- a/patches/ds-bot/0008-client-bundle.patch +++ b/patches/ds-bot/0008-client-bundle.patch @@ -3501,7 +3501,7 @@ diff --git a/client.js b/client.js .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} -@@ -2237,7 +2237,14 @@ button.bt-q-opt:active{background:var(--bt-active)} +@@ -2237,9 +2237,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. */ @@ -3513,10 +3513,19 @@ diff --git a/client.js b/client.js + 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;display:flex;align-items:center;justify-content:center;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif} ++.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} @@ -2255,6 +2255,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} @@ -3580,7 +3589,7 @@ diff --git a/client.js b/client.js .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;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} @@ -3599,7 +3608,7 @@ diff --git a/client.js b/client.js .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;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} @@ -19482,7 +19491,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:120;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/client/remote-bot-settings.js b/patches/ds-bot/files/src/client/remote-bot-settings.js index 6c22fc326..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:120;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/test/ui-clickthrough.test.js b/patches/ds-bot/files/test/ui-clickthrough.test.js index 044c540b3..7e27aea84 100644 --- a/patches/ds-bot/files/test/ui-clickthrough.test.js +++ b/patches/ds-bot/files/test/ui-clickthrough.test.js @@ -168,6 +168,24 @@ test('#1347: the thread cannot paint above a dialog, a sheet or a scrim', () => 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', () => {