From dc379a18c8d3c178a09cf18b804bc0282461687d Mon Sep 17 00:00:00 2001 From: Danyang Chen Date: Tue, 3 Nov 2026 12:12:44 +0800 Subject: [PATCH] fix(client): Settings takes focus on open and returns it on close (#2251) Adds useDialogFocus to overlay-hooks.js and wires it into the Settings dialog with tabIndex -1 + ref, following the #274 shape; no focus trap, no inert. Tests: core/test/dialog-focus.test.js 3/3 (base 1/3). --- .../2251-settings-dialog-focus.fixed.md | 1 + core/client.js | 13 +- core/src/client/overlay-hooks.js | 10 + core/src/client/settings.js | 8 +- core/test/dialog-focus.test.js | 133 +++ docs/subsystems/core-client.md | 2 + patches/ds-bot/0003-flybot-api-client.patch | 20 + patches/ds-bot/0008-client-bundle.patch | 856 ++++++++++-------- patches/ds-bot/0014-flybot-branding.patch | 19 +- .../ds-bot/files/test/dialog-focus.test.js | 133 +++ patches/ds-bot/topics.json | 1 + 11 files changed, 815 insertions(+), 381 deletions(-) create mode 100644 changelog.d/2251-settings-dialog-focus.fixed.md create mode 100644 core/test/dialog-focus.test.js create mode 100644 patches/ds-bot/files/test/dialog-focus.test.js diff --git a/changelog.d/2251-settings-dialog-focus.fixed.md b/changelog.d/2251-settings-dialog-focus.fixed.md new file mode 100644 index 000000000..c3bacee4c --- /dev/null +++ b/changelog.d/2251-settings-dialog-focus.fixed.md @@ -0,0 +1 @@ +Settings now takes keyboard focus when it opens: the dialog is the focused element, so Tab starts inside it and Escape reaches it even when the message box had focus. Closing returns focus to the still-connected control that opened it (#2251). diff --git a/core/client.js b/core/client.js index e7b2c94c2..78d5a5189 100644 --- a/core/client.js +++ b/core/client.js @@ -7624,6 +7624,15 @@ function useEscape(onClose) { }; }, []); } +function useDialogFocus(ref) { + (0, import_react11.useEffect)(() => { + const before = document.activeElement; + ref.current?.focus?.({ preventScroll: true }); + return () => { + if (before && typeof before.focus === "function" && before.isConnected) before.focus({ preventScroll: true }); + }; + }, []); +} function usePaneLeft() { const [paneLeft, setPaneLeft] = (0, import_react11.useState)(280); (0, import_react11.useLayoutEffect)(() => { @@ -19141,6 +19150,8 @@ function SettingsDialog({ page, id, roster, actions, useUpdate, leaving = false const [error, setError] = (0, import_react42.useState)(""); const close = (0, import_react42.useCallback)(() => actions.closeSettings(), []); useEscape(close); + const dialog = (0, import_react42.useRef)(null); + useDialogFocus(dialog); const run = (promise) => Promise.resolve(promise).then(() => { setError(""); return true; @@ -19173,7 +19184,7 @@ function SettingsDialog({ page, id, roster, actions, useUpdate, leaving = false (0, import_react42.createElement)("div", { className: "bt-scrim", onMouseDown: close }), (0, import_react42.createElement)( "div", - { className: "bt-settings", role: "dialog", "aria-modal": true, "aria-label": t("FlyBot settings"), "data-wide": shown === "usage" ? "" : void 0 }, + { className: "bt-settings", role: "dialog", "aria-modal": true, "aria-label": t("FlyBot settings"), "data-wide": shown === "usage" ? "" : void 0, tabIndex: -1, ref: dialog }, (0, import_react42.createElement)( "nav", { ref: nav.box, className: "bt-settings-nav", "aria-label": t("Settings pages") }, diff --git a/core/src/client/overlay-hooks.js b/core/src/client/overlay-hooks.js index 09e259513..d6c19d0bb 100644 --- a/core/src/client/overlay-hooks.js +++ b/core/src/client/overlay-hooks.js @@ -20,6 +20,16 @@ export function useEscape(onClose) { }, []) } +// A dialog takes focus when it opens, so Tab starts inside it and Escape reaches it, +// and gives that focus back to the still-connected opener when it closes (#2251, #274). +export function useDialogFocus(ref) { + useEffect(() => { + const before = document.activeElement + ref.current?.focus?.({ preventScroll: true }) + return () => { if (before && typeof before.focus === 'function' && before.isConnected) before.focus({ preventScroll: true }) } + }, []) +} + // New chat and Bot exchanges show in place of the conversation, right of the sidebar. export function usePaneLeft() { const [paneLeft, setPaneLeft] = useState(280) diff --git a/core/src/client/settings.js b/core/src/client/settings.js index 945da734c..cba68a53b 100644 --- a/core/src/client/settings.js +++ b/core/src/client/settings.js @@ -8,7 +8,7 @@ import { CloseIcon, ChevronLeftIcon, ChevronRightIcon, ClockIcon, ConnectorIcon, import { AdminStar, BotAvatar, RoomAvatar } from './mark.js' import { openHarnessSettings } from './sidebar.js' import { SettingsPageSlot, useSettingsPages, call } from './flybot-slots.js' -import { useEscape } from './overlay-hooks.js' +import { useDialogFocus, useEscape } from './overlay-hooks.js' import { BotSettingsForm, SwitchRow } from './bot-settings.js' import { Segmented, useGlide } from './motion.js' import { GroupSettingsForm, NewGroupForm, seatedCount } from './group-settings.js' @@ -174,6 +174,10 @@ export function SettingsDialog({ page, id, roster, actions, useUpdate, leaving = const [error, setError] = useState('') const close = useCallback(() => actions.closeSettings(), []) useEscape(close) + // The dialog takes focus when it opens, so Tab starts inside it and Escape reaches it + // even when the composer had focus; closing gives that focus back (#2251). + const dialog = useRef(null) + useDialogFocus(dialog) const run = promise => Promise.resolve(promise) .then(() => { setError(''); return true }) .catch(failure => { setError(failure?.message ?? String(failure)); return false }) @@ -209,7 +213,7 @@ export function SettingsDialog({ page, id, roster, actions, useUpdate, leaving = const errorLine = error ? h('div', { className: 'bt-error', role: 'alert', style: { padding: '0 20px 8px' } }, error) : null return h('div', { className: 'bt-settings-layer', 'data-leaving': leaving || undefined }, h('div', { className: 'bt-scrim', onMouseDown: close }), - h('div', { className: 'bt-settings', role: 'dialog', 'aria-modal': true, 'aria-label': t('FlyBot settings'), 'data-wide': shown === 'usage' ? '' : undefined }, + h('div', { className: 'bt-settings', role: 'dialog', 'aria-modal': true, 'aria-label': t('FlyBot settings'), 'data-wide': shown === 'usage' ? '' : undefined, tabIndex: -1, ref: dialog }, h('nav', { ref: nav.box, className: 'bt-settings-nav', 'aria-label': t('Settings pages') }, h('span', { ref: nav.thumb, className: 'bt-thumb', 'aria-hidden': true }), pages.map(([key, text]) => h('button', { diff --git a/core/test/dialog-focus.test.js b/core/test/dialog-focus.test.js new file mode 100644 index 000000000..bfa9877c7 --- /dev/null +++ b/core/test/dialog-focus.test.js @@ -0,0 +1,133 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { fileURLToPath } from 'node:url' +import { runInNewContext } from 'node:vm' +import { build } from 'esbuild' + +// #2251: an `aria-modal` Settings dialog never took focus, so Tab walked the page +// behind the scrim and Escape could be caught by the composer. The dialog takes focus +// when it opens and gives it back to the opener when it closes (#274's shape). +// This test runs the real hooks: a small runtime below commits refs and runs effects. +const { outputFiles } = await build({ + stdin: { + contents: "export { SettingsDialog } from './src/client/settings.js'", + resolveDir: fileURLToPath(new URL('..', import.meta.url)), + }, + bundle: true, format: 'cjs', platform: 'browser', write: false, logLevel: 'silent', + external: ['react', 'react-dom', '@deepseek-ai/dsh-client-ui-primitives'], +}) + +const h = (type, props, ...children) => ({ type, props: { ...props, children } }) +const all = (node, out = []) => { + if (node && typeof node === 'object') { + out.push(node) + for (const child of [node.props?.children].flat(Infinity)) all(child, out) + } + return out +} + +// One effect per hook call, run by `mount` after refs are committed, the way React +// runs layout effects and effects on mount. `useEffect() {}` stubs would hide the bug. +let effects = [] +const react = { + createElement: h, + Component: class {}, + Fragment: Symbol('F'), + useEffect: (fn) => { effects.push(fn) }, + useLayoutEffect: (fn) => { effects.push(fn) }, + useState: initial => [typeof initial === 'function' ? initial() : initial, () => {}], + useMemo: make => make(), + useRef: current => ({ current }), + useCallback: fn => fn, + useSyncExternalStore: (_subscribe, get) => get(), +} + +const fakeNode = (kind) => ({ + kind, focusCalls: [], style: {}, dataset: {}, isConnected: true, + focus(options) { this.focusCalls.push(options ?? {}) }, + querySelector: () => null, + querySelectorAll: () => [], + addEventListener() {}, removeEventListener() {}, + getBoundingClientRect: () => ({ left: 0, top: 0, right: 0, bottom: 0, width: 0, height: 0 }), + offsetLeft: 0, offsetTop: 0, offsetWidth: 0, offsetHeight: 0, +}) + +// Point every `ref` in the returned tree at a node, as the renderer would on commit. +const commit = (node, seen = new Set()) => { + if (!node || typeof node !== 'object' || seen.has(node)) return + seen.add(node) + const ref = node.props?.ref + if (ref && typeof ref === 'object' && ref.current === null) ref.current = fakeNode('element') + for (const child of [node.props?.children].flat(Infinity)) commit(child, seen) +} + +const roster = { bots: [], rooms: [], byId: {}, roomsById: {}, prefs: {}, mainBotIds: [], connectors: [] } +const actions = { openSettings() {}, closeSettings() {} } + +function mount(overrides = {}) { + effects = [] + const opener = fakeNode('opener') + const context = { + module: { exports: {} }, exports: {}, + document: { + activeElement: opener, hidden: false, addEventListener() {}, removeEventListener() {}, + querySelector: () => null, + }, + navigator: { language: 'en' }, + localStorage: { getItem: () => null, setItem() {} }, + window: { + addEventListener() {}, removeEventListener() {}, + matchMedia: () => ({ matches: false, addEventListener() {}, removeEventListener() {} }), + }, + console, setTimeout, clearTimeout, requestAnimationFrame: () => 0, cancelAnimationFrame() {}, + require: (name) => { + if (name === 'react') return react + if (name === 'react-dom') return { createPortal: node => node } + if (name === '@deepseek-ai/dsh-client-ui-primitives') return {} + throw new Error(`Unexpected platform module ${name}`) + }, + } + context.module.exports = context.exports + runInNewContext(outputFiles[0].text, context) + const { SettingsDialog } = context.module.exports + const tree = SettingsDialog({ page: 'general', id: null, roster, actions, useUpdate: () => ({}), ...overrides }) + commit(tree) + const dialogNode = all(tree).find(node => node.props?.className === 'bt-settings') + const cleanups = [] + for (const effect of effects) { + const cleanup = effect() + if (typeof cleanup === 'function') cleanups.push(cleanup) + } + return { + tree, dialogNode, opener, + unmount() { for (const cleanup of cleanups.reverse()) cleanup() }, + } +} + +test('the Settings dialog is a focus target: tabIndex -1 on the dialog container', () => { + const { dialogNode } = mount() + assert.ok(dialogNode, 'the dialog container is drawn') + assert.equal(dialogNode.props.tabIndex, -1) +}) + +test('opening moves focus into the dialog and closing gives it back to the still-connected opener', () => { + const { dialogNode, opener, unmount } = mount() + const dialogRef = dialogNode.props.ref + assert.ok(dialogRef, 'the dialog container carries a ref so it can take focus') + const dialog = dialogRef.current + assert.equal(dialog.focusCalls.length, 1, 'the dialog takes focus once on mount') + assert.equal(dialog.focusCalls[0]?.preventScroll, true) + assert.equal(opener.focusCalls.length, 0, 'the opener is not touched while the dialog is open') + unmount() + assert.equal(opener.focusCalls.length, 1, 'the opener gets focus back when the dialog unmounts') + assert.equal(opener.focusCalls[0]?.preventScroll, true) +}) + +test('the dialog keeps its role, aria-modal, aria-label and data-wide contract', () => { + const narrow = mount().dialogNode.props + assert.equal(narrow.role, 'dialog') + assert.equal(narrow['aria-modal'], true) + assert.equal(narrow['aria-label'], 'FlyBot settings') + assert.equal(narrow['data-wide'], undefined) + assert.equal(mount({ page: 'usage' }).dialogNode.props['data-wide'], '') +}) diff --git a/docs/subsystems/core-client.md b/docs/subsystems/core-client.md index f2302d7f4..22f6e3e36 100644 --- a/docs/subsystems/core-client.md +++ b/docs/subsystems/core-client.md @@ -14,6 +14,8 @@ The browser side of the patched `ds-bot` core (`core/`): sources in `core/src/cl - Browser client: sidebar, chat, panels, themes, characters, Bot browser panel (`core/client.js` generated from `core/src/client/`). The sidebar **+** new-chat picker (`core/src/client/new-chat.js`) takes extra rows from slot `flybot.new-chat.action` (after New group chat; `label` / `hint` go through the same `t()` map) and lists only `listedChatBots` (gone, hidden, and home-level remotes stay out; owner labels match the sidebar). Covered by `core/test/new-chat.test.js` in patch `0003-flybot-api-client`. - Chat context rows: a tool the plugin registers at install draws one `tool-addition` row, and `cells.js` keeps every name in `TEAM_TOOLS` out of the chat (`SILENT_REGISTRATION_TOOLS`, #1101) — the Project Assistant's `project_digest` joins them (#1712), so its 09:00 Session shows the digest, not its registration. +- A Settings dialog takes focus when it opens (`useDialogFocus` in `core/src/client/overlay-hooks.js`; the `.bt-settings` dialog carries `tabIndex -1` and a ref) and gives it back to the still-connected opener when it closes; it is not a focus trap, so Tab can still leave the dialog for the page behind the scrim (#2251). + ## Does not own diff --git a/patches/ds-bot/0003-flybot-api-client.patch b/patches/ds-bot/0003-flybot-api-client.patch index cd3995da7..52cb389aa 100644 --- a/patches/ds-bot/0003-flybot-api-client.patch +++ b/patches/ds-bot/0003-flybot-api-client.patch @@ -2213,6 +2213,26 @@ diff --git a/src/client/new-chat.js b/src/client/new-chat.js h('div', { className: 'bt-nc-card' }, h('span', { className: 'bt-nc-plus', 'aria-hidden': true }, h(PlusIcon)), h('textarea', { +diff --git a/src/client/overlay-hooks.js b/src/client/overlay-hooks.js +--- a/src/client/overlay-hooks.js ++++ b/src/client/overlay-hooks.js +@@ -20,6 +20,16 @@ export function useEscape(onClose) { + }, []) + } + ++// A dialog takes focus when it opens, so Tab starts inside it and Escape reaches it, ++// and gives that focus back to the still-connected opener when it closes (#2251, #274). ++export function useDialogFocus(ref) { ++ useEffect(() => { ++ const before = document.activeElement ++ ref.current?.focus?.({ preventScroll: true }) ++ return () => { if (before && typeof before.focus === 'function' && before.isConnected) before.focus({ preventScroll: true }) } ++ }, []) ++} ++ + // New chat and Bot exchanges show in place of the conversation, right of the sidebar. + export function usePaneLeft() { + const [paneLeft, setPaneLeft] = useState(280) diff --git a/src/client/overlays.js b/src/client/overlays.js --- a/src/client/overlays.js +++ b/src/client/overlays.js diff --git a/patches/ds-bot/0008-client-bundle.patch b/patches/ds-bot/0008-client-bundle.patch index cee5da257..86e00ab9d 100644 --- a/patches/ds-bot/0008-client-bundle.patch +++ b/patches/ds-bot/0008-client-bundle.patch @@ -2170,7 +2170,7 @@ diff --git a/client.js b/client.js var formatTokens = (count) => count >= 1e6 ? `${(count / 1e6).toFixed(1)}M` : count >= 1e3 ? `${(count / 1e3).toFixed(1)}K` : String(count); function usageOf(list, ids) { let input = 0; -@@ -1624,901 +1624,1984 @@ function usageOf(list, ids) { +@@ -1624,969 +1624,2052 @@ 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 }); } @@ -4016,6 +4016,60 @@ diff --git a/client.js b/client.js -.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}} +// src/client/motion.js +var import_react4 = require("react"); +var reducedMotion = () => window.matchMedia?.("(prefers-reduced-motion: reduce)").matches === true; @@ -4219,7 +4273,20 @@ diff --git a/client.js b/client.js + }, [value]); + return [value || kept, !value && Boolean(kept)]; +} -+ + +-.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} +// src/client/styles.js +var SHELL_CSS = ` +body{--bt-ink:#141414;--bt-ink-2:rgba(20,20,20,.6);--bt-ink-3:rgba(20,20,20,.36);--bt-bubble:#eeeeee;--bt-sidebar:#f7f7f7;--bt-main:#fcfcfc;--bt-line:rgba(20,20,20,.08);--bt-line-2:rgba(20,20,20,.15);--bt-line-weak:rgba(20,20,20,.1);--bt-hover:rgba(119,119,119,.09);--bt-active:rgba(119,119,119,.17);--bt-card:#ffffff;--bt-accent:#4d6bfe;--bt-green:#00c972;--bt-star:#ff9800;--bt-user:#070707;--bt-user-ink:#fcfcfc;--bt-tag-bg:rgba(20,20,20,.06);--bt-tag-ink:rgba(20,20,20,.56)} @@ -5020,34 +5087,81 @@ diff --git a/client.js b/client.js +.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} -@@ -2574,2935 +2574,6828 @@ body[data-ds-dark-theme] .bt-theme-chip i{background:var(--da)} - @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}} --`; ++@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}} +@@ -2595,2914 +2595,6816 @@ body[data-ds-dark-theme] .bt-theme-chip i{background:var(--da)} + :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} @@ -5128,27 +5242,6 @@ diff --git a/client.js b/client.js -.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} -+.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;max-width:100%;min-width:0;white-space:nowrap} @@ -6181,114 +6274,7 @@ diff --git a/client.js b/client.js + (0, import_react7.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: same2, onClick: () => onSave(next) }, t("Save access")) + ) + ); - } --@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 ValueEditor({ secret, onSave }) { + const [value, setValue] = (0, import_react7.useState)(""); + const [replaced, setReplaced] = (0, import_react7.useState)(false); @@ -6477,13 +6463,7 @@ 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; ++`; + +// src/client/face-seats.js +function faceSeats(room2, roster, { left = false } = {}) { @@ -6539,15 +6519,7 @@ diff --git a/client.js b/client.js + people2.push({ key, name: seat.person || (seat.mine ? cleanPeerName(roster.self?.displayName, "") : "") || t("Someone"), mine: seat.mine, seats: [seat] }); + } + return [...people2.filter((person) => person.mine), ...people2.filter((person) => !person.mine)]; - } --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); ++} +function facePersonLabel(person) { + return person.mine ? t("{name} (you)", { name: person.name }) : person.name; +} @@ -6597,11 +6569,8 @@ diff --git a/client.js b/client.js + setError(failure?.message ?? String(failure)); + } finally { + setBusy(false); - } - }; -- try { -- visit(sheet.cssRules); -- } catch { ++ } ++ }; + if (!waiting) { + const failed = held.status === "failed"; + return (0, import_react8.createElement)( @@ -6613,8 +6582,7 @@ diff --git a/client.js b/client.js + 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 + ); - } -- return names; ++ } + const plain = (0, import_react8.createElement)( + "div", + { className: "bt-held-card", role: "region", "aria-label": t("Waiting for your approval") }, @@ -6786,66 +6754,180 @@ diff --git a/client.js b/client.js + item(t("Download"), DownloadIcon, download2) + ), document.body) : null + ); -+} -+var PEEK_MAX = 160; -+var CUT = "…"; -+var MARKUP = [/^\s{0,3}(?:`{3,}|~{3,})/, /^\s{0,3}([-*_])(?:\s*\1){2,}\s*$/]; -+var isMarkup = (line2) => MARKUP.some((pattern) => pattern.test(line2)); -+var graphemes = (line2) => typeof Intl !== "undefined" && typeof Intl.Segmenter === "function" ? [...new Intl.Segmenter().segment(line2)].map((part) => part.segment) : [...line2]; -+var clip = (line2) => { -+ if (line2.length <= PEEK_MAX) return line2; -+ let kept = ""; -+ for (const part of graphemes(line2)) { -+ if (kept.length + part.length > PEEK_MAX - CUT.length) break; -+ kept += part; -+ } -+ return kept + CUT; -+}; -+var onlyMarkup = (lines) => lines.length === 1 ? t("{n} line of reasoning", { n: lines.length }) : t("{n} lines of reasoning", { n: lines.length }); -+var peekLine = (text2) => { -+ const lines = text2.split("\n").map((part) => part.replace(/\s+/g, " ").trim()).filter((part) => part !== ""); -+ const prose = lines.find((line2) => !isMarkup(line2)); -+ return prose === void 0 ? onlyMarkup(lines) : clip(prose); -+}; -+function ThinkingRow({ text: text2 }) { -+ if (typeof text2 !== "string" || text2.trim() === "") return null; -+ const peek = peekLine(text2); -+ return (0, import_react9.createElement)( -+ "details", -+ { 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"), -+ peek ? (0, import_react9.createElement)("span", { className: "bt-think-peek" }, peek) : null -+ ), -+ (0, import_react9.createElement)("div", { className: "bt-think-text" }, text2) -+ ); -+} -+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_react9.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 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 friendLead(bot, texts2) { -+ if (bot?.remote === void 0 || texts2.length === 0) return void 0; -+ if (texts2.every((text2) => /^Not sent:/.test(text2.trim()))) return void 0; -+ return (0, import_react9.createElement)("span", { className: "bt-lead", title: bot.name, "data-flybot": "friend-lead" }, (0, import_react9.createElement)(BotMark, { bot, size: 22 })); -+} -+function AssistantCell({ node, groupPart, sessionId, openFile, fileMentions, useTurnData, useRoster, useUi, useTurnSteps, useTurnContext, actions }) { -+ const roster = useRoster((value) => value); + } +-@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 installStyles(text, tag) { +- const style = document.createElement("style"); +- style.dataset.dshBot = tag; +- style.textContent = text; +- document.head.appendChild(style); +- return style; ++var PEEK_MAX = 160; ++var CUT = "…"; ++var MARKUP = [/^\s{0,3}(?:`{3,}|~{3,})/, /^\s{0,3}([-*_])(?:\s*\1){2,}\s*$/]; ++var isMarkup = (line2) => MARKUP.some((pattern) => pattern.test(line2)); ++var graphemes = (line2) => typeof Intl !== "undefined" && typeof Intl.Segmenter === "function" ? [...new Intl.Segmenter().segment(line2)].map((part) => part.segment) : [...line2]; ++var clip = (line2) => { ++ if (line2.length <= PEEK_MAX) return line2; ++ let kept = ""; ++ for (const part of graphemes(line2)) { ++ if (kept.length + part.length > PEEK_MAX - CUT.length) break; ++ kept += part; ++ } ++ return kept + CUT; ++}; ++var onlyMarkup = (lines) => lines.length === 1 ? t("{n} line of reasoning", { n: lines.length }) : t("{n} lines of reasoning", { n: lines.length }); ++var peekLine = (text2) => { ++ const lines = text2.split("\n").map((part) => part.replace(/\s+/g, " ").trim()).filter((part) => part !== ""); ++ const prose = lines.find((line2) => !isMarkup(line2)); ++ return prose === void 0 ? onlyMarkup(lines) : clip(prose); ++}; ++function ThinkingRow({ text: text2 }) { ++ if (typeof text2 !== "string" || text2.trim() === "") return null; ++ const peek = peekLine(text2); ++ return (0, import_react9.createElement)( ++ "details", ++ { 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"), ++ peek ? (0, import_react9.createElement)("span", { className: "bt-think-peek" }, peek) : null ++ ), ++ (0, import_react9.createElement)("div", { className: "bt-think-text" }, text2) ++ ); ++} ++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_react9.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 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 friendLead(bot, texts2) { ++ if (bot?.remote === void 0 || texts2.length === 0) return void 0; ++ if (texts2.every((text2) => /^Not sent:/.test(text2.trim()))) return void 0; ++ return (0, import_react9.createElement)("span", { className: "bt-lead", title: bot.name, "data-flybot": "friend-lead" }, (0, import_react9.createElement)(BotMark, { bot, size: 22 })); ++} ++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)); @@ -6984,16 +7066,7 @@ diff --git a/client.js b/client.js + name, + (0, import_react9.createElement)(Bubbles, { texts: [text2], roster, selfId: bot.id, actions, sessionId: room2?.id, msgKey, useUi, lead }) + ); - } --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 remoteSpeaker(source, roster, sessionId) { + const handle = source.remote.handle; + const member = (roster.remoteBots ?? []).find((bot2) => bot2.handle === handle); @@ -7006,7 +7079,7 @@ diff --git a/client.js b/client.js + ownerName: member?.homeName || member?.owner || chat?.remote?.homeName || chat?.remote?.owner, + handle, + isChief: member?.isChief ?? chat?.remote?.isChief - }); ++ }); + const bot = seat ? lookBotForRemote({ handle, displayName: name, ...seat }, roster) : chat ?? lookBotForRemote({ + handle, + displayName: name, @@ -7064,20 +7137,7 @@ diff --git a/client.js b/client.js + String(words ?? "").trim() === "" ? null : (0, import_react9.createElement)(Bubbles, { texts: [words], roster, selfId: bot.id, actions, sessionId, msgKey, useUi, lead }), + files.length ? (0, import_react9.createElement)(RemoteFiles, { files, plugin: member?.plugin ?? "social", botId: roster.byId[sessionId]?.id ?? sessionId }) : null + ); - } --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 FirstContact({ source, roster, sessionId }) { + const person = typeof source.remote.typedBy === "string" ? source.remote.typedBy : ""; + const who = person || remoteSpeaker(source, roster).name; @@ -7091,7 +7151,15 @@ diff --git a/client.js b/client.js + (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" }) + ); -+} + } +-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); +function RemoteFiles({ files, plugin, botId }) { + const [busy, setBusy] = (0, import_react9.useState)(""); + const [error, setError] = (0, import_react9.useState)(""); @@ -7111,27 +7179,12 @@ diff --git a/client.js b/client.js + } finally { + setBusy(""); } -- 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(); }; +- try { +- visit(sheet.cssRules); +- } catch { +- } +- return names; + return (0, import_react9.createElement)( + "div", + { className: "bt-file-chips" }, @@ -7143,37 +7196,13 @@ diff --git a/client.js b/client.js + error ? (0, import_react9.createElement)("span", { className: "bt-file-error", role: "alert" }, error) : null + ); } -- --// 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); +-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); +function GroupEvent({ prefix, room: room2, name, roster, actions }) { + return (0, import_react9.createElement)( + "div", @@ -7229,13 +7258,9 @@ diff --git a/client.js b/client.js + (0, import_react9.useEffect)(() => { + alive.current = true; + load(void 0); - return () => { -- cancelAnimationFrame(frame); -- window.removeEventListener("pointermove", onMove); -- document.documentElement.removeEventListener("pointerleave", onLeave); ++ return () => { + alive.current = false; - }; -- }, [enabled]); ++ }; + }, [sessionId]); + (0, import_react9.useLayoutEffect)(() => { + const pending = restore.current; @@ -7263,7 +7288,9 @@ diff --git a/client.js b/client.js + last.msgKeys.push(keyOf(item)); + } else rows.push({ kind: "bot", texts: [item.text], msgKeys: [keyOf(item)] }); + continue; -+ } + } +- return compiled.get(fragment); +- }); + rows.push(item); + } + return (0, import_react9.createElement)( @@ -7304,7 +7331,18 @@ diff --git a/client.js b/client.js + }), + (0, import_react9.createElement)(PartLine, { time: view.startedAt }) + ); -+} + } +-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); +function TriggerCell({ node, sessionId, useRoster, useSessions, useTurnClock, useUi, actions }) { + const roster = useRoster((value) => value); + const data = node.data; @@ -7351,7 +7389,8 @@ diff --git a/client.js b/client.js + 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_react9.createElement)(GroupMessage, { bot: poster, text: stripHeader(contentText(data.content)), room: room2, roster, actions, msgKey: `${sessionId}:${data.turn}:post`, useUi }); -+ } + } +- for (const name of list) names.add(name); + if (source.role === "report") { + return (0, import_react9.createElement)(GroupEvent, { prefix: t("Replies from"), room: roster.roomsById[source.roomId], name: t("a group chat"), roster, actions }); + } @@ -7376,7 +7415,28 @@ diff --git a/client.js b/client.js + onOpen: (bot) => actions.openExchange(selfId, bot.id), + whole: true + }); -+ } + } +- 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 (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")); @@ -7385,6 +7445,46 @@ diff --git a/client.js b/client.js + return (0, import_react9.createElement)("div", { className: "bt-event" }, contentText(data.content).slice(0, 80) || t("Update")); + } } +- +-// 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]); ++function NewDivider() { ++ return (0, import_react9.createElement)("div", { className: "bt-new-sep", role: "separator", "aria-label": t("New messages") }, (0, import_react9.createElement)("span", null, t("New"))); + } -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); @@ -7414,15 +7514,6 @@ diff --git a/client.js b/client.js - 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) } })); -+function NewDivider() { -+ return (0, import_react9.createElement)("div", { className: "bt-new-sep", role: "separator", "aria-label": t("New messages") }, (0, import_react9.createElement)("span", null, t("New"))); - } --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 TurnProcessCell({ node, sessionId, turnProcess, useRoster, useUi, useTurnClock, actions }) { + const open = turnProcess === void 0 || !turnProcess.foldable || turnProcess.open; + (0, import_react9.useEffect)(() => { @@ -7441,17 +7532,17 @@ diff --git a/client.js b/client.js + }); + return fresh ? (0, import_react9.createElement)(NewDivider) : null; } --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 Nothing() { -+ return null; - } --function MainStar() { +-function StarIcon({ size = 8 }) { - 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" }) +- { 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 Nothing() { ++ return null; + } +-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 })); +var HIDDEN_REMOVED_TOOLS = /* @__PURE__ */ new Set(["ask_user_question"]); +var isConnectorTool = (name) => typeof name === "string" && /^mcp__(github|composio)__/.test(name); +var SILENT_REGISTRATION_TOOLS = /* @__PURE__ */ new Set([ @@ -7508,6 +7599,23 @@ diff --git a/client.js b/client.js + if (parts.length === 0) return null; + return (0, import_react9.createElement)("div", { className: "bt-event" }, parts.join(" · ")); } +-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" }) +- ); ++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 useSettled(state) { - const prev = (0, import_react2.useRef)(state); - const [settled, setSettled] = (0, import_react2.useState)(false); @@ -7524,16 +7632,13 @@ diff --git a/client.js b/client.js - return () => clearTimeout(timer); - }, [state]); - return settled; -+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; ++ } ++ return next === void 0 ? void 0 : turns[next].first; } -function BotAvatar({ bot, size, state = "idle", main, admin, badge = true, live = true, gaze = false, pokeable = false }) { - const settled = useSettled(state); @@ -7547,14 +7652,6 @@ diff --git a/client.js b/client.js - 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 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 next === void 0 ? void 0 : turns[next].first; -+} +function openingTurn(turns, firstTurns, hiddenTurns) { + if (!turns || !firstTurns) return null; + const start = nextTurnStart(turns, -Infinity); @@ -8614,7 +8711,18 @@ diff --git a/client.js b/client.js - onEnter?.(); - } - }); --} ++function useEscape(onClose) { ++ const ref = (0, import_react11.useRef)(onClose); ++ ref.current = onClose; ++ (0, import_react11.useEffect)(() => { ++ if (escapeLayers.length === 0) window.addEventListener("keydown", onEscapeKey); ++ escapeLayers.push(ref); ++ return () => { ++ escapeLayers.splice(escapeLayers.indexOf(ref), 1); ++ if (escapeLayers.length === 0) window.removeEventListener("keydown", onEscapeKey); ++ }; ++ }, []); + } -function ScopeSwitch({ value, onChange, bot }) { - const options = [["all", t("All Bots")], ["bot", t("Only {name}", { name: bot.name })]]; - return (0, import_react5.createElement)( @@ -8630,15 +8738,12 @@ diff --git a/client.js b/client.js - onClick: () => onChange(id) - }, text)) - ); -+function useEscape(onClose) { -+ const ref = (0, import_react11.useRef)(onClose); -+ ref.current = onClose; ++function useDialogFocus(ref) { + (0, import_react11.useEffect)(() => { -+ if (escapeLayers.length === 0) window.addEventListener("keydown", onEscapeKey); -+ escapeLayers.push(ref); ++ const before = document.activeElement; ++ ref.current?.focus?.({ preventScroll: true }); + return () => { -+ escapeLayers.splice(escapeLayers.indexOf(ref), 1); -+ if (escapeLayers.length === 0) window.removeEventListener("keydown", onEscapeKey); ++ if (before && typeof before.focus === "function" && before.isConnected) before.focus({ preventScroll: true }); + }; + }, []); } @@ -22246,7 +22351,7 @@ diff --git a/client.js b/client.js key: id, type: "button", "aria-pressed": motion === id, -@@ -10706,69 +10706,69 @@ function MotionSection({ roster, actions, run }) { +@@ -10706,70 +10706,72 @@ function MotionSection({ roster, actions, run }) { ); } function BotsPage({ roster, actions, run }) { @@ -22344,8 +22449,11 @@ diff --git a/client.js b/client.js + const [error, setError] = (0, import_react42.useState)(""); + const close = (0, import_react42.useCallback)(() => actions.closeSettings(), []); useEscape(close); ++ const dialog = (0, import_react42.useRef)(null); ++ useDialogFocus(dialog); const run = (promise) => Promise.resolve(promise).then(() => { setError(""); + return true; @@ -10779,104 +10779,254 @@ function SettingsDialog({ page, id, roster, actions, useUpdate, leaving = false }); const bot = page === "bots" && id ? roster.byId[id] : void 0; @@ -22383,7 +22491,7 @@ diff --git a/client.js b/client.js "div", - { className: "bt-settings", role: "dialog", "aria-modal": true, "aria-label": t("DS Bot settings"), "data-wide": shown === "usage" ? "" : void 0 }, - (0, import_react28.createElement)( -+ { className: "bt-settings", role: "dialog", "aria-modal": true, "aria-label": t("FlyBot settings"), "data-wide": shown === "usage" ? "" : void 0 }, ++ { className: "bt-settings", role: "dialog", "aria-modal": true, "aria-label": t("FlyBot settings"), "data-wide": shown === "usage" ? "" : void 0, tabIndex: -1, ref: dialog }, + (0, import_react42.createElement)( "nav", { ref: nav.box, className: "bt-settings-nav", "aria-label": t("Settings pages") }, diff --git a/patches/ds-bot/0014-flybot-branding.patch b/patches/ds-bot/0014-flybot-branding.patch index 029f43ecf..a7eaeb53f 100644 --- a/patches/ds-bot/0014-flybot-branding.patch +++ b/patches/ds-bot/0014-flybot-branding.patch @@ -259,9 +259,10 @@ diff --git a/src/client/settings.js b/src/client/settings.js +import { CloseIcon, ChevronLeftIcon, ChevronRightIcon, ClockIcon, ConnectorIcon, FolderIcon, InfoIcon, PlugIcon, PlusIcon, SlidersIcon, SETTINGS_ICONS } from './icons.js' import { AdminStar, BotAvatar, RoomAvatar } from './mark.js' import { openHarnessSettings } from './sidebar.js' -+import { SettingsPageSlot, useSettingsPages, call } from './flybot-slots.js' - import { useEscape } from './overlay-hooks.js' +-import { useEscape } from './overlay-hooks.js' -import { BotSettingsForm } from './bot-settings.js' ++import { SettingsPageSlot, useSettingsPages, call } from './flybot-slots.js' ++import { useDialogFocus, useEscape } from './overlay-hooks.js' +import { BotSettingsForm, SwitchRow } from './bot-settings.js' import { Segmented, useGlide } from './motion.js' -import { GroupSettingsForm, NewGroupForm } from './group-settings.js' @@ -364,7 +365,17 @@ diff --git a/src/client/settings.js b/src/client/settings.js admin ? h('span', { className: 'bt-tag bt-tag-admin' }, h(AdminStar), admin.name) : null, room.hidden ? h('span', { className: 'bt-tag' }, t('Hidden')) : null), h('span', { className: 'bt-chevron' }, h(ChevronRightIcon))) -@@ -159,9 +159,13 @@ export function SettingsDialog({ page, id, roster, actions, useUpdate, leaving = +@@ -153,15 +153,23 @@ export function SettingsDialog({ page, id, roster, actions, useUpdate, leaving = + const [error, setError] = useState('') + const close = useCallback(() => actions.closeSettings(), []) + useEscape(close) ++ // The dialog takes focus when it opens, so Tab starts inside it and Escape reaches it ++ // even when the composer had focus; closing gives that focus back (#2251). ++ const dialog = useRef(null) ++ useDialogFocus(dialog) + const run = promise => Promise.resolve(promise) + .then(() => { setError(''); return true }) + .catch(failure => { setError(failure?.message ?? String(failure)); return false }) // A Bot or group deleted while its form is open leaves its page on the list. const bot = page === 'bots' && id ? roster.byId[id] : undefined const room = page === 'groups' && id ? roster.roomsById[id] : undefined @@ -392,7 +403,7 @@ diff --git a/src/client/settings.js b/src/client/settings.js return h('div', { className: 'bt-settings-layer', 'data-leaving': leaving || undefined }, h('div', { className: 'bt-scrim', onMouseDown: close }), - h('div', { className: 'bt-settings', role: 'dialog', 'aria-modal': true, 'aria-label': t('DS Bot settings'), 'data-wide': shown === 'usage' ? '' : undefined }, -+ h('div', { className: 'bt-settings', role: 'dialog', 'aria-modal': true, 'aria-label': t('FlyBot settings'), 'data-wide': shown === 'usage' ? '' : undefined }, ++ h('div', { className: 'bt-settings', role: 'dialog', 'aria-modal': true, 'aria-label': t('FlyBot settings'), 'data-wide': shown === 'usage' ? '' : undefined, tabIndex: -1, ref: dialog }, h('nav', { ref: nav.box, className: 'bt-settings-nav', 'aria-label': t('Settings pages') }, h('span', { ref: nav.thumb, className: 'bt-thumb', 'aria-hidden': true }), - SETTINGS_PAGES.map(([key, text]) => h('button', { diff --git a/patches/ds-bot/files/test/dialog-focus.test.js b/patches/ds-bot/files/test/dialog-focus.test.js new file mode 100644 index 000000000..bfa9877c7 --- /dev/null +++ b/patches/ds-bot/files/test/dialog-focus.test.js @@ -0,0 +1,133 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { fileURLToPath } from 'node:url' +import { runInNewContext } from 'node:vm' +import { build } from 'esbuild' + +// #2251: an `aria-modal` Settings dialog never took focus, so Tab walked the page +// behind the scrim and Escape could be caught by the composer. The dialog takes focus +// when it opens and gives it back to the opener when it closes (#274's shape). +// This test runs the real hooks: a small runtime below commits refs and runs effects. +const { outputFiles } = await build({ + stdin: { + contents: "export { SettingsDialog } from './src/client/settings.js'", + resolveDir: fileURLToPath(new URL('..', import.meta.url)), + }, + bundle: true, format: 'cjs', platform: 'browser', write: false, logLevel: 'silent', + external: ['react', 'react-dom', '@deepseek-ai/dsh-client-ui-primitives'], +}) + +const h = (type, props, ...children) => ({ type, props: { ...props, children } }) +const all = (node, out = []) => { + if (node && typeof node === 'object') { + out.push(node) + for (const child of [node.props?.children].flat(Infinity)) all(child, out) + } + return out +} + +// One effect per hook call, run by `mount` after refs are committed, the way React +// runs layout effects and effects on mount. `useEffect() {}` stubs would hide the bug. +let effects = [] +const react = { + createElement: h, + Component: class {}, + Fragment: Symbol('F'), + useEffect: (fn) => { effects.push(fn) }, + useLayoutEffect: (fn) => { effects.push(fn) }, + useState: initial => [typeof initial === 'function' ? initial() : initial, () => {}], + useMemo: make => make(), + useRef: current => ({ current }), + useCallback: fn => fn, + useSyncExternalStore: (_subscribe, get) => get(), +} + +const fakeNode = (kind) => ({ + kind, focusCalls: [], style: {}, dataset: {}, isConnected: true, + focus(options) { this.focusCalls.push(options ?? {}) }, + querySelector: () => null, + querySelectorAll: () => [], + addEventListener() {}, removeEventListener() {}, + getBoundingClientRect: () => ({ left: 0, top: 0, right: 0, bottom: 0, width: 0, height: 0 }), + offsetLeft: 0, offsetTop: 0, offsetWidth: 0, offsetHeight: 0, +}) + +// Point every `ref` in the returned tree at a node, as the renderer would on commit. +const commit = (node, seen = new Set()) => { + if (!node || typeof node !== 'object' || seen.has(node)) return + seen.add(node) + const ref = node.props?.ref + if (ref && typeof ref === 'object' && ref.current === null) ref.current = fakeNode('element') + for (const child of [node.props?.children].flat(Infinity)) commit(child, seen) +} + +const roster = { bots: [], rooms: [], byId: {}, roomsById: {}, prefs: {}, mainBotIds: [], connectors: [] } +const actions = { openSettings() {}, closeSettings() {} } + +function mount(overrides = {}) { + effects = [] + const opener = fakeNode('opener') + const context = { + module: { exports: {} }, exports: {}, + document: { + activeElement: opener, hidden: false, addEventListener() {}, removeEventListener() {}, + querySelector: () => null, + }, + navigator: { language: 'en' }, + localStorage: { getItem: () => null, setItem() {} }, + window: { + addEventListener() {}, removeEventListener() {}, + matchMedia: () => ({ matches: false, addEventListener() {}, removeEventListener() {} }), + }, + console, setTimeout, clearTimeout, requestAnimationFrame: () => 0, cancelAnimationFrame() {}, + require: (name) => { + if (name === 'react') return react + if (name === 'react-dom') return { createPortal: node => node } + if (name === '@deepseek-ai/dsh-client-ui-primitives') return {} + throw new Error(`Unexpected platform module ${name}`) + }, + } + context.module.exports = context.exports + runInNewContext(outputFiles[0].text, context) + const { SettingsDialog } = context.module.exports + const tree = SettingsDialog({ page: 'general', id: null, roster, actions, useUpdate: () => ({}), ...overrides }) + commit(tree) + const dialogNode = all(tree).find(node => node.props?.className === 'bt-settings') + const cleanups = [] + for (const effect of effects) { + const cleanup = effect() + if (typeof cleanup === 'function') cleanups.push(cleanup) + } + return { + tree, dialogNode, opener, + unmount() { for (const cleanup of cleanups.reverse()) cleanup() }, + } +} + +test('the Settings dialog is a focus target: tabIndex -1 on the dialog container', () => { + const { dialogNode } = mount() + assert.ok(dialogNode, 'the dialog container is drawn') + assert.equal(dialogNode.props.tabIndex, -1) +}) + +test('opening moves focus into the dialog and closing gives it back to the still-connected opener', () => { + const { dialogNode, opener, unmount } = mount() + const dialogRef = dialogNode.props.ref + assert.ok(dialogRef, 'the dialog container carries a ref so it can take focus') + const dialog = dialogRef.current + assert.equal(dialog.focusCalls.length, 1, 'the dialog takes focus once on mount') + assert.equal(dialog.focusCalls[0]?.preventScroll, true) + assert.equal(opener.focusCalls.length, 0, 'the opener is not touched while the dialog is open') + unmount() + assert.equal(opener.focusCalls.length, 1, 'the opener gets focus back when the dialog unmounts') + assert.equal(opener.focusCalls[0]?.preventScroll, true) +}) + +test('the dialog keeps its role, aria-modal, aria-label and data-wide contract', () => { + const narrow = mount().dialogNode.props + assert.equal(narrow.role, 'dialog') + assert.equal(narrow['aria-modal'], true) + assert.equal(narrow['aria-label'], 'FlyBot settings') + assert.equal(narrow['data-wide'], undefined) + assert.equal(mount({ page: 'usage' }).dialogNode.props['data-wide'], '') +}) diff --git a/patches/ds-bot/topics.json b/patches/ds-bot/topics.json index 3e6c081e3..05b46c4cc 100644 --- a/patches/ds-bot/topics.json +++ b/patches/ds-bot/topics.json @@ -85,6 +85,7 @@ "src/client/new-chat.js", "src/client/palette.js", "src/client/overlays.js", + "src/client/overlay-hooks.js", "test/mention-rule.test.js", "test/looks.test.js", "test/user-bubble.test.js",