From 69b09bc14d585c0edf9730d97b96049de879d737 Mon Sep 17 00:00:00 2001 From: Danyang Chen Date: Sat, 24 Oct 2026 20:19:08 +0800 Subject: [PATCH] Keep the conversation of a share that ended on the sidebar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A friend's Bot leaving the published list hid its whole chat, so a refresh took the conversation and its history off the sidebar and only an open window still said why. The hide was also indistinguishable from the user's own "Hide from sidebar": one flag carried both meanings. `remoteChats.sync` now records only `remote.gone` and never writes `hidden`, and the sidebar reads a new `isShownRemoteChat` that keeps the row — marked "no longer shared" — while every picker, mention list and `list_bots` line keeps the stricter `isListedRemoteChat`. A hide the user set still removes the row, a Bot published again is an ordinary chat again, and a state file written by an older version gets its imposed hide taken back on the next sync (that marker is now only read). That repair deletes the marker with the hide it explains: a marker left behind would make a later Hide from sidebar read as ours, and the next sync would delete the user's own hide. Fixes #956. Docs-not-needed: the sidebar's own contract is documented in docs/subsystems/core-client.md and docs/subsystems/core-host.md, both updated here; the only change in core/test/flybot-client.test.js is a secondLine assertion that now knows about a share that ended (#956). --- .../956-sidebar-keeps-ended-share.fixed.md | 1 + core/client.js | 12 +- core/src/client/sidebar.js | 8 +- core/src/host/remote-chat.js | 24 +- core/src/shared/remote-label.js | 15 + core/test/flybot-client.test.js | 3 +- core/test/new-chat.test.js | 29 +- core/test/remote-chat.test.js | 68 +- docs/subsystems/core-client.md | 2 +- docs/subsystems/core-host.md | 4 +- patches/ds-bot/0003-flybot-api-client.patch | 36 +- patches/ds-bot/0008-client-bundle.patch | 5221 ++++++----------- patches/ds-bot/files/src/host/remote-chat.js | 24 +- .../ds-bot/files/src/shared/remote-label.js | 15 + .../ds-bot/files/test/flybot-client.test.js | 3 +- patches/ds-bot/files/test/new-chat.test.js | 29 +- patches/ds-bot/files/test/remote-chat.test.js | 68 +- 17 files changed, 1961 insertions(+), 3601 deletions(-) create mode 100644 changelog.d/956-sidebar-keeps-ended-share.fixed.md diff --git a/changelog.d/956-sidebar-keeps-ended-share.fixed.md b/changelog.d/956-sidebar-keeps-ended-share.fixed.md new file mode 100644 index 000000000..5aa45c76b --- /dev/null +++ b/changelog.d/956-sidebar-keeps-ended-share.fixed.md @@ -0,0 +1 @@ +A friend's Bot that leaves your shared list no longer takes its whole conversation off the sidebar: the row stays, marked as no longer shared, with its history and its way back, and it returns as an ordinary chat when the share comes back. Hiding a chat yourself still removes the row, and the two states stay apart in the state file ([#956](https://github.com/ThinkFlowLab/FlyBot/issues/956)). diff --git a/core/client.js b/core/client.js index bc8fc8b55..9d869e707 100644 --- a/core/client.js +++ b/core/client.js @@ -1449,6 +1449,12 @@ function isListedRemoteChat(bot) { if (isHomeLevelRemote(remote)) return false; return true; } +function isShownRemoteChat(bot) { + if (!bot) return false; + if (isListedRemoteChat(bot)) return true; + if (bot.remote?.gone !== true) return false; + return bot.hidden !== true || bot.remote.hiddenByGone === true; +} function listedChatBots(roster) { return (roster?.bots ?? []).filter(isListedRemoteChat); } @@ -7489,7 +7495,7 @@ function BotRow({ bot, roster, actions, useSessions, useSessionStatus, useSessio const state = botState(running || away, doing, alert); const shownAway = away ? awayGroups(groups, roster.roomsById) : []; const awayLine = shownAway.length > 0 ? workLine(roster.roomsById[shownAway[0].id]?.name ?? t("a group chat"), shownAway[0].tool, shownAway.length - 1) : null; - if (!isListedRemoteChat(bot) && !active && !main) return null; + if (!isShownRemoteChat(bot) && !active && !main) return null; const onContextMenu = (event) => { event.preventDefault(); actions.openMenu({ kind: "bot", id, x: event.clientX, y: event.clientY }); @@ -7497,7 +7503,7 @@ function BotRow({ bot, roster, actions, useSessions, useSessionStatus, useSessio const pinned = bot.pinned === true && !tile; const remote = Boolean(bot.remote); const shown = remote ? friendBotTitle(bot) : bot.name; - const secondLine = awayLine || preview || (remote ? t("Friend's Bot") : ""); + const secondLine = awayLine || preview || (remote ? bot.remote?.gone ? t("no longer shared") : t("Friend's Bot") : ""); const label = [shown, main ? t("Main Bot") : "", pinned ? t("Pinned") : "", running || away ? t("Working") : "", waiting ? t("Waiting for your approval") : alert ? t("Waiting for your answer") : "", unread ? t("Unread activity") : ""].filter(Boolean).join(", "); if (tile && wide) { return (0, import_react13.createElement)("div", { className: "bt-tile-wrap" }, (0, import_react13.createElement)( @@ -7867,7 +7873,7 @@ function TeamSidebar(props) { const entries2 = [ // Own Bots, friends' Bots, groups and face rooms share one list. A remote chat // still uses BotRow's globe; gone / hidden / home-level remotes stay out. - ...roster.bots.filter((bot) => bot.id !== roster.mainBotId && isListedRemoteChat(bot)).map((bot) => ({ kind: "bot", item: bot, at: stamps[currentPart(bot)] ?? bot.createdAt })), + ...roster.bots.filter((bot) => bot.id !== roster.mainBotId && isShownRemoteChat(bot)).map((bot) => ({ kind: "bot", item: bot, at: stamps[currentPart(bot)] ?? bot.createdAt })), ...roster.rooms.filter((room2) => !isPastFaceRoom(room2)).map((room2) => ({ kind: "room", item: room2, at: stamps[room2.id] ?? room2.createdAt })), // The Agent sorts by its most recent Session, falling back to when it was added. ...(roster.agents ?? []).map((agent) => ({ kind: "agent", item: agent, at: Math.max(0, ...agent.sessions.map((id) => stamps[id] ?? 0)) || agent.createdAt })) diff --git a/core/src/client/sidebar.js b/core/src/client/sidebar.js index 14b4134ee..36078a6fc 100644 --- a/core/src/client/sidebar.js +++ b/core/src/client/sidebar.js @@ -11,7 +11,7 @@ import { reducedMotion } from './activity-row.js' import { Shimmer, workPhrase } from './cells.js' import { springEasing, SPRING_SHAPE } from './motion.js' import { hostText, t } from './i18n.js' -import { friendBotTitle, roomTitle, sharedRoomTitle, isListedRemoteChat } from '../shared/remote-label.js' +import { friendBotTitle, roomTitle, sharedRoomTitle, isShownRemoteChat } from '../shared/remote-label.js' import { showsDshRow } from './dsh-offer.js' import { InboxRow } from './inbox.js' @@ -60,7 +60,7 @@ function BotRow({ bot, roster, actions, useSessions, useSessionStatus, useSessio const state = botState(running || away, doing, alert) const shownAway = away ? awayGroups(groups, roster.roomsById) : [] const awayLine = shownAway.length > 0 ? workLine(roster.roomsById[shownAway[0].id]?.name ?? t('a group chat'), shownAway[0].tool, shownAway.length - 1) : null - if (!isListedRemoteChat(bot) && !active && !main) return null + if (!isShownRemoteChat(bot) && !active && !main) return null const onContextMenu = (event) => { event.preventDefault() actions.openMenu({ kind: 'bot', id, x: event.clientX, y: event.clientY }) @@ -70,7 +70,7 @@ function BotRow({ bot, roster, actions, useSessions, useSessionStatus, useSessio // A friend's Bot row (#384, #498): "Chief (SelfB1233)" on one line, as its header and the @ menu // say it; the second line is the preview, or "Friend's Bot" before the first message. const shown = remote ? friendBotTitle(bot) : bot.name - const secondLine = awayLine || preview || (remote ? t("Friend's Bot") : '') + const secondLine = awayLine || preview || (remote ? (bot.remote?.gone ? t('no longer shared') : t("Friend's Bot")) : '') const label = [shown, main ? t('Main Bot') : '', pinned ? t('Pinned') : '', running || away ? t('Working') : '', waiting ? t('Waiting for your approval') : alert ? t('Waiting for your answer') : '', unread ? t('Unread activity') : ''].filter(Boolean).join(', ') if (tile && wide) { return h('div', { className: 'bt-tile-wrap' }, h('button', { @@ -350,7 +350,7 @@ export function TeamSidebar(props) { const entries = [ // Own Bots, friends' Bots, groups and face rooms share one list. A remote chat // still uses BotRow's globe; gone / hidden / home-level remotes stay out. - ...roster.bots.filter(bot => bot.id !== roster.mainBotId && isListedRemoteChat(bot)).map(bot => ({ kind: 'bot', item: bot, at: stamps[currentPart(bot)] ?? bot.createdAt })), + ...roster.bots.filter(bot => bot.id !== roster.mainBotId && isShownRemoteChat(bot)).map(bot => ({ kind: 'bot', item: bot, at: stamps[currentPart(bot)] ?? bot.createdAt })), ...roster.rooms.filter(room => !isPastFaceRoom(room)).map(room => ({ kind: 'room', item: room, at: stamps[room.id] ?? room.createdAt })), // The Agent sorts by its most recent Session, falling back to when it was added. ...(roster.agents ?? []).map(agent => ({ kind: 'agent', item: agent, at: Math.max(0, ...agent.sessions.map(id => stamps[id] ?? 0)) || agent.createdAt })), diff --git a/core/src/host/remote-chat.js b/core/src/host/remote-chat.js index bc3c4e215..939901ce4 100644 --- a/core/src/host/remote-chat.js +++ b/core/src/host/remote-chat.js @@ -236,10 +236,9 @@ export function install(rt) { // A published list: every Bot gets its chat; a chat whose Bot left keeps its history, marked gone. async function sync(plugin, list) { // `open()` clears `remote.gone` on a chat that is published again, so remember which chats - // were gone first. Then only a real change of `gone` may touch `hidden`: a chat the user hid - // from the sidebar (`roster.setFlags`) while its Bot is still published must stay hidden, or - // every publish would bring it back, and a returning chat still reappears (its `gone` hide - // is dropped). + // were gone first. `remote.gone` alone says the Bot left; `hidden` stays the user's own flag + // (`roster.setFlags`) and is never written here (#956), so a chat that left keeps its row in the + // sidebar and a returning one cannot carry a hide of the user's away with it. const goneBefore = new Map() for (const bot of chats()) if (bot.remote.plugin === plugin) goneBefore.set(bot.id, Boolean(bot.remote.gone)) const handles = new Set() @@ -256,17 +255,20 @@ export function install(rt) { if (wasGone !== gone) { bot.remote = { ...bot.remote, ...(gone ? { gone: true } : {}) } if (!gone) delete bot.remote.gone - if (gone) { - // Remember when *we* hide the chat because the Bot left the published list: on `hidden` - // alone that is indistinguishable from a hide the user set, so the return below would - // drop the user's own hide too (#631). - if (bot.hidden !== true) bot.remote.hiddenByGone = true - bot.hidden = true - } else if (bot.remote.hiddenByGone === true) { + // The Bot is published again: drop the marker an older version left with the hide it imposed. + if (!gone && bot.remote.hiddenByGone === true) { delete bot.hidden delete bot.remote.hiddenByGone } changed = true + } else if (gone && bot.hidden === true && bot.remote.hiddenByGone === true) { + // A state file written before #956 hid the row we imposed. The row is the conversation's, so + // one sync takes that hide back (#956); a hide the user set carries no marker and stays. The + // marker goes with it — taking the hide back is its only use, and while it stayed a hide the + // user set later read as ours, so the next sync deleted it (the row then ignored the Hide). + delete bot.hidden + delete bot.remote.hiddenByGone + changed = true } } if (changed) await rt.save() diff --git a/core/src/shared/remote-label.js b/core/src/shared/remote-label.js index 45fbdf0b8..8cc588abf 100644 --- a/core/src/shared/remote-label.js +++ b/core/src/shared/remote-label.js @@ -35,6 +35,21 @@ export function isListedRemoteChat(bot) { return true } +// Two questions, two readers (#956). `isListedRemoteChat` answers "may I send to this Bot, offer it +// in a picker, mention it": a share that ended is not a place to send to. The sidebar asks "is this +// the user's conversation": a chat whose Bot left the published list keeps its row, its history and +// its way back, marked as no longer shared. Only a hide the user set (#611) takes a row out, and +// `remote.gone` never doubles as one — the two flags stay apart. +export function isShownRemoteChat(bot) { + if (!bot) return false + if (isListedRemoteChat(bot)) return true + if (bot.remote?.gone !== true) return false + // `hiddenByGone` is the marker a version before #956 wrote beside the hide it imposed: nothing here + // writes it, and `remoteChats.sync` deletes it with that hide, so a state file not yet synced still + // shows the row and a later hide of the user's is left alone. + return bot.hidden !== true || bot.remote.hiddenByGone === true +} + export function listedChatBots(roster) { return (roster?.bots ?? []).filter(isListedRemoteChat) } diff --git a/core/test/flybot-client.test.js b/core/test/flybot-client.test.js index 9e55efbf9..2b9a2763c 100644 --- a/core/test/flybot-client.test.js +++ b/core/test/flybot-client.test.js @@ -223,7 +223,8 @@ test("a friend's Bot sidebar row is its one name, \"Chief (Bob)\", on one line, assert.doesNotMatch(row, /bt-row-owner/) assert.doesNotMatch(styles, /\.bt-row-owner\{/) // Before the first message the second line still says what the row is. - assert.match(row, /const secondLine = awayLine \|\| preview \|\| \(remote \? t\("Friend's Bot"\) : ''\)/) + // #956: the placeholder names the state once the Bot is gone, so a kept row says why it is there. + assert.match(row, /const secondLine = awayLine \|\| preview \|\| \(remote \? \(bot\.remote\?\.gone \? t\(.no longer shared.\) : t\("Friend's Bot"\)\) : ''\)/) // The full name is on hover and in the label. assert.match(row, /title: remote \? shown :/) for (const file of ['header.js', 'brand.js', 'user-message.js', 'at-menu.js']) { diff --git a/core/test/new-chat.test.js b/core/test/new-chat.test.js index 868ac0a2b..8b9b1797f 100644 --- a/core/test/new-chat.test.js +++ b/core/test/new-chat.test.js @@ -1,7 +1,7 @@ import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import { test } from 'node:test' -import { copiedToClipboard, listedChatBots, pickerBotLabel, isHomeLevelRemote, isListedRemoteChat, splitRoomPicks, ungrantedLocalFix } from '../src/shared/remote-label.js' +import { copiedToClipboard, listedChatBots, pickerBotLabel, isHomeLevelRemote, isListedRemoteChat, isShownRemoteChat, splitRoomPicks, ungrantedLocalFix } from '../src/shared/remote-label.js' import { DICTIONARY } from '../src/client/i18n.js' test('picker hides gone, hidden, and home-level remotes; labels include the owner', () => { @@ -56,7 +56,10 @@ test('the sidebar + menu wires plugin actions in after New group chat', () => { const slots = readFileSync(new URL('../src/client/flybot-slots.js', import.meta.url), 'utf8') assert.match(slots, /isListedRemoteChat/) const sidebar = readFileSync(new URL('../src/client/sidebar.js', import.meta.url), 'utf8') - assert.match(sidebar, /isListedRemoteChat\(bot\)/) + // #956: the sidebar keeps a row for a chat we hid because the Bot left; every other reader + // (pickers, @ menu, the model's friend list) keeps the stricter isListedRemoteChat. + assert.match(sidebar, /isShownRemoteChat\(bot\)/) + assert.match(sidebar, /isShownRemoteChat\(bot\)\)\.map/) assert.match(sidebar, /!isPastFaceRoom\(room\)/) assert.match(sidebar, /Number\(foreign\(a\)\) - Number\(foreign\(b\)\)/) // With @flybot/tree on, a friend's Bot is not a tree node under Main; it stays in the flat list. @@ -198,3 +201,25 @@ test('#645: a line typed in the composer is the new Bot\'s first turn, not its i assert.match(src, /onCreated: created/) assert.match(card, /await onCreated\(bot\)/) }) + +// #956: two hides, two flags. `remote.gone` says the Bot left the published list; `hidden` is only +// ever the user's own hide (#611). A chat we hid because it left keeps its row, marked; the user's +// hide wins over everything, and a returned chat is an ordinary row again. +test('#956: a row is kept for a share that ended, dropped for a hide the user set', () => { + const live = { id: 'live', name: 'Chief', remote: { handle: '@chief.hsliu.home', home: '@hsliu.home' } } + const ended = { id: 'ended', name: '助手', remote: { handle: '@assistant.friend.home', home: '@friend.home', gone: true } } + const legacy = { id: 'legacy', name: '旧', hidden: true, remote: { handle: '@old.friend.home', home: '@friend.home', gone: true, hiddenByGone: true } } + const userHidden = { id: 'user', name: 'Coder', hidden: true, remote: { handle: '@coder.bob.home', home: '@bob.home' } } + const userHiddenWhileGone = { id: 'user2', name: 'Coder', hidden: true, remote: { handle: '@coder.bob.home', home: '@bob.home', gone: true } } + assert.equal(isShownRemoteChat(live), true) + assert.equal(isShownRemoteChat(ended), true) + assert.equal(isShownRemoteChat(legacy), true) + assert.equal(isShownRemoteChat(userHidden), false) + assert.equal(isShownRemoteChat(userHiddenWhileGone), false) + assert.equal(isShownRemoteChat({ id: 'local', name: 'Chief' }), true) + assert.equal(isShownRemoteChat(undefined), false) + // The stricter reader is unchanged: a share that ended is still not a place to send to. + assert.equal(isListedRemoteChat(ended), false) + assert.equal(isListedRemoteChat(legacy), false) + assert.equal(isListedRemoteChat(live), true) +}) diff --git a/core/test/remote-chat.test.js b/core/test/remote-chat.test.js index e95a8a240..f5d8f9b09 100644 --- a/core/test/remote-chat.test.js +++ b/core/test/remote-chat.test.js @@ -1,7 +1,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { remoteTurn, isRemoteChat, REMOTE_PROVIDER, install, notSentLine } from '../src/host/remote-chat.js'; -import { isHomeLevelRemote } from '../src/shared/remote-label.js'; +import { isHomeLevelRemote, isListedRemoteChat, isShownRemoteChat } from '../src/shared/remote-label.js'; const user = (content, source, id = 'm1') => ({ role: 'user', id, content, ...(source ? { source } : {}) }); @@ -44,9 +44,12 @@ test('sync hides gone and home-level remotes without deleting the chat', async ( assert.equal(state.bots.a.remote.gone, undefined) assert.equal(state.bots.a.hidden, undefined) assert.equal(state.bots.b.remote.gone, true) - assert.equal(state.bots.b.hidden, true) + // #956: leaving the published list is `remote.gone`, never a `hidden` of the user's. + assert.equal(state.bots.b.hidden, undefined) + assert.equal(isShownRemoteChat(state.bots.b), true) assert.equal(state.bots.c.remote.gone, true) - assert.equal(state.bots.c.hidden, true) + assert.equal(state.bots.c.hidden, undefined) + assert.equal(isShownRemoteChat(state.bots.c), true) assert.equal(state.bots.b.name, 'Friend') await rt.remoteChats.sync('social', [ { handle: '@chief.hsliu.home', name: 'Chief', home: '@hsliu.home' }, @@ -59,7 +62,8 @@ test('sync hides gone and home-level remotes without deleting the chat', async ( ]) assert.equal(state.bots.a.remote.gone, undefined) assert.equal(state.bots.a.hidden, undefined) - assert.equal(state.bots.c.hidden, true) + assert.equal(state.bots.c.remote.gone, true) + assert.equal(isShownRemoteChat(state.bots.c), true) }) test('sync keeps a chat the user hid while the Bot is still published', async () => { @@ -95,6 +99,8 @@ test('a hide the user set survives the Bot leaving the published list and coming await rt.remoteChats.sync('social', []) assert.equal(state.bots.b.remote.gone, true) assert.equal(state.bots.b.hidden, true) + // A hide the user set keeps the row out of the sidebar, gone or not (#611). + assert.equal(isShownRemoteChat(state.bots.b), false) await rt.remoteChats.sync('social', [member]) assert.equal(state.bots.b.remote.gone, undefined) // The hide was the user's, so the return must not drop it. @@ -102,7 +108,7 @@ test('a hide the user set survives the Bot leaving the published list and coming assert.equal(state.bots.b.remote.hiddenByGone, undefined) }) -test('a hide imposed because the Bot left is dropped when it comes back', async () => { +test('#956: a chat whose Bot left keeps its row in the sidebar, and comes back as an ordinary one', async () => { const state = { bots: { b: { id: 'b', name: '助手', remote: { handle: '@assistant.friend.home', home: '@friend.home', plugin: 'social' } }, @@ -113,12 +119,60 @@ test('a hide imposed because the Bot left is dropped when it comes back', async const member = { handle: '@assistant.friend.home', name: '助手', home: '@friend.home' } await rt.remoteChats.sync('social', []) assert.equal(state.bots.b.remote.gone, true) - assert.equal(state.bots.b.hidden, true) - assert.equal(state.bots.b.remote.hiddenByGone, true) + assert.equal(state.bots.b.hidden, undefined) + assert.equal(isShownRemoteChat(state.bots.b), true) await rt.remoteChats.sync('social', [member]) assert.equal(state.bots.b.remote.gone, undefined) assert.equal(state.bots.b.hidden, undefined) assert.equal(state.bots.b.remote.hiddenByGone, undefined) + assert.equal(isShownRemoteChat(state.bots.b), true) +}) + +// A state file written before #956 carries the hide we imposed as a real `hidden`. The next sync +// takes it back, so the row returns without the user doing anything. +test('#956: a hide a previous version imposed is taken back on the next sync', async () => { + const state = { + bots: { + b: { id: 'b', name: '助手', hidden: true, remote: { handle: '@assistant.friend.home', home: '@friend.home', plugin: 'social', gone: true, hiddenByGone: true } }, + }, + } + const rt = { state, load: async () => {}, save: async () => {}, warn() {}, ctx: {} } + install(rt) + await rt.remoteChats.sync('social', []) + assert.equal(state.bots.b.remote.gone, true) + assert.equal(state.bots.b.hidden, undefined) + // The marker goes with the hide it explains: it has that one use, and a marker left behind makes a + // later Hide from sidebar read as ours, so the next sync would delete the user's hide. + assert.equal(state.bots.b.remote.hiddenByGone, undefined) + assert.equal(isShownRemoteChat(state.bots.b), true) + await rt.remoteChats.sync('social', [{ handle: '@assistant.friend.home', name: '助手', home: '@friend.home' }]) + assert.equal(state.bots.b.remote.gone, undefined) + assert.equal(state.bots.b.hidden, undefined) + assert.equal(isShownRemoteChat(state.bots.b), true) +}) + +// The repair above must leave the chat exactly as it found it, so the user's own Hide works from then +// on: set after the repair, it survives the next sync and the Bot coming back (#611, #303, #956). +test('#956: a hide the user sets after the repair keeps the row out, and the Bot coming back changes nothing', async () => { + const state = { + bots: { + b: { id: 'b', name: '助手', hidden: true, remote: { handle: '@assistant.friend.home', home: '@friend.home', plugin: 'social', gone: true, hiddenByGone: true } }, + }, + } + const rt = { state, load: async () => {}, save: async () => {}, warn() {}, ctx: {} } + install(rt) + await rt.remoteChats.sync('social', []) + assert.equal(isShownRemoteChat(state.bots.b), true) + state.bots.b.hidden = true + await rt.remoteChats.sync('social', []) + assert.equal(state.bots.b.hidden, true) + assert.equal(isShownRemoteChat(state.bots.b), false) + assert.equal(state.bots.b.remote.gone, true) + await rt.remoteChats.sync('social', [{ handle: '@assistant.friend.home', name: '助手', home: '@friend.home' }]) + assert.equal(state.bots.b.remote.gone, undefined) + assert.equal(state.bots.b.hidden, true) + assert.equal(isShownRemoteChat(state.bots.b), false) + assert.equal(isListedRemoteChat(state.bots.b), false) }) test('roster and model guards keep a friend\'s Bot on its relay route', async () => { diff --git a/docs/subsystems/core-client.md b/docs/subsystems/core-client.md index b3d329bb8..cdfb1e40b 100644 --- a/docs/subsystems/core-client.md +++ b/docs/subsystems/core-client.md @@ -113,7 +113,7 @@ The overlay (`flybot-slots.js` `FlyBotApprovals`) is the fallback, not a second ### One sidebar list -- `TeamSidebar` lists local Bots, listed remote chats (`isListedRemoteChat`), live face-to-face rooms, and shared rooms in one `bt-list`. Own chats sort before foreign ones (a remote Bot, a face room, or `sharedFrom`); pinned and recency still win. A friend's Bot keeps the globe `RemoteMark`; a live face room gets a small people `FaceMark` (`bt-row-remote bt-row-face`). No row wraps its title (#384): a friend's Bot row shows its one name, `friendBotTitle` from `shared/remote-label.js` ("Helper (Bob)", the same string as its header, tab, composer placeholder and @ menu, #498), in `bt-row-title`, then the globe (at `--bt-ink-2`, readable) on one line; its second line is the preview like an own row, or *Friend's Bot* before the first message, and the full name is its hover text and label. The row wrap's gear is absolutely positioned so the hover/selected band stays full width (#384). A friend's Bot fallback look (`lookAvoidingLocals`) steps its seeded shape (then color) when it would match one of your own Bots exactly; a named ink and its published hex count as one color. `indexRoster` registers the own Bots (`setLocalBots`) and `BotMark` applies the avoidance to every remote Bot, so the sidebar, header, palette, details drawer and transcript agree; a published image/emoji/shape is left alone. A face-room row stays on one line with an ellipsis, and every room row has its full title as hover text (#304). The face-room row leads with its number (`faceRowTitle` in `text.js`: `Face-to-face · A, B · 482915` reads `482915 · A, B`), so the cut never hides it; the hover text and label keep the full title (#458). A group row whose group holds an open question card (#550) says it waits for me: `Waiting for your answer` in its label and the waiting dot (`bt-unread-wait`), even when no approval waits there; the asking Bot's own row stays out of it because the card lives in the group (#552). A Bot row's group-work line (` · Thinking…`) skips face rooms (`awayGroups` in `text.js`): a face room has its own row, so its turns never take over the Bot's preview (#458). Past/ended face rooms stay out of this list (`isPastFaceRoom`) so the social section can fold them under **Past rooms**. With `@flybot/tree` on, only own Bots are tree nodes under Main; a friend's Bot stays in the flat ordered list (not hidden when Main is collapsed). The structure entry may set `include(botId) === false` to omit a Bot from the default list (tree-archived and leftover `归档-*` / `Archive-*` names, #452); those Bots stay findable in Search when not also `bot.hidden`. +- `TeamSidebar` lists local Bots, shown remote chats (`isShownRemoteChat`, #956), live face-to-face rooms, and shared rooms in one `bt-list`. Own chats sort before foreign ones (a remote Bot, a face room, or `sharedFrom`); pinned and recency still win. A friend's Bot keeps the globe `RemoteMark`; a live face room gets a small people `FaceMark` (`bt-row-remote bt-row-face`). No row wraps its title (#384): a friend's Bot row shows its one name, `friendBotTitle` from `shared/remote-label.js` ("Helper (Bob)", the same string as its header, tab, composer placeholder and @ menu, #498), in `bt-row-title`, then the globe (at `--bt-ink-2`, readable) on one line; its second line is the preview like an own row, or *Friend's Bot* before the first message — *no longer shared* once that Bot has left the published list, when the row stays with its history and its way back (#956) — and the full name is its hover text and label. The row wrap's gear is absolutely positioned so the hover/selected band stays full width (#384). A friend's Bot fallback look (`lookAvoidingLocals`) steps its seeded shape (then color) when it would match one of your own Bots exactly; a named ink and its published hex count as one color. `indexRoster` registers the own Bots (`setLocalBots`) and `BotMark` applies the avoidance to every remote Bot, so the sidebar, header, palette, details drawer and transcript agree; a published image/emoji/shape is left alone. A face-room row stays on one line with an ellipsis, and every room row has its full title as hover text (#304). The face-room row leads with its number (`faceRowTitle` in `text.js`: `Face-to-face · A, B · 482915` reads `482915 · A, B`), so the cut never hides it; the hover text and label keep the full title (#458). A group row whose group holds an open question card (#550) says it waits for me: `Waiting for your answer` in its label and the waiting dot (`bt-unread-wait`), even when no approval waits there; the asking Bot's own row stays out of it because the card lives in the group (#552). A Bot row's group-work line (` · Thinking…`) skips face rooms (`awayGroups` in `text.js`): a face room has its own row, so its turns never take over the Bot's preview (#458). Past/ended face rooms stay out of this list (`isPastFaceRoom`) so the social section can fold them under **Past rooms**. With `@flybot/tree` on, only own Bots are tree nodes under Main; a friend's Bot stays in the flat ordered list (not hidden when Main is collapsed). The structure entry may set `include(botId) === false` to omit a Bot from the default list (tree-archived and leftover `归档-*` / `Archive-*` names, #452); those Bots stay findable in Search when not also `bot.hidden`. ### Agent inbox (#349, step 1) diff --git a/docs/subsystems/core-host.md b/docs/subsystems/core-host.md index 9d0452c4d..21feb3474 100644 --- a/docs/subsystems/core-host.md +++ b/docs/subsystems/core-host.md @@ -49,7 +49,7 @@ The server side of the patched `ds-bot` core (`core/`): the part of [ds-bot-core | `core/src/host/experience.js` | Experience library read API (#874 Step 2). `lookupExperience({ repo, techStack, query, root })` reads `core/dev/experience/`. Data and `check.mjs` stay under `core/dev/` (see [ds-bot-core](ds-bot-core.md)). | | `core/src/host/usage.js` | Usage ledger at `$DSH_HOME/bot/usage.sqlite` (`calls`). Ledger v3 (#903): every row carries `harness`, `issue_pr`, `tool_calls`, `wall_ms`, plus existing token columns (`withUsageFields` / `missingUsageFields`). Session turns are metered via `foldCalls` over turn events (`tool/call` count, wall time from attempt start, `issuePr` from turn text or `rt.noteUsageIssuePr(sessionId, ref)`), not from llm/stream alone. A triage tier is stamped with `rt.noteUsageHarness(sessionId, idOrLabels)` and lands on the row's `harness` field; the resume cursor (`marks.fold`) stays provider / model / retry. Sample row fixture: `core/test/fixtures/usage-sample.sqlite`. Tests: `core/test/usage-fields.test.js`, `core/test/usage.test.js`. | | `core/src/host/tool-schema.js` | The tool definitions the model sees: its `defineTool` wraps DSH's and completes the compiled schema with `explicitObjectShape`, which fills `properties: {}` and `required: []` on every object node (recursing through `properties`, `items`, `oneOf`/`anyOf`/`allOf`, `$defs`, …). A strict OpenAI-compatible gateway reads an absent member as null and rejects the whole request (`null is not of type "array"`), so one argument-less tool would break every turn; argument validation keeps using DSH's schema unchanged. Every host module registers tools through this wrapper. Tests: `core/test/tool-schema.test.js`. | -| `core/src/shared/remote-label.js` | Owner-badge labels for a friend's Bot and “Shared room from …” titles. `friendBotTitle(bot)` / `friendBotOwner(bot)` name a friend's chat from the roster (`Chief (SelfB1233)`) and its owner (`SelfB1233`, the person an approval card names) with one formatter for every client surface (#498). `remoteBotName` / `isAutoOwnerName` / `seatOwnerName` keep group and face-room labels in that format too (#526): Bot Social's ` · ` member label is not a Bot name, and an owner name made up from an install id (`FlyBot 682b8a43`) gives way to the username in the home handle (`Reviewer (flybot682b8a43)`). `isListedRemoteChat` / `listedChatBots` keep gone, hidden, and home-level (`@something.home`) remotes out of the sidebar, the **发给** picker, mentions, and `list_bots` friends lines; history stays on disk. | +| `core/src/shared/remote-label.js` | Owner-badge labels for a friend's Bot and “Shared room from …” titles. `friendBotTitle(bot)` / `friendBotOwner(bot)` name a friend's chat from the roster (`Chief (SelfB1233)`) and its owner (`SelfB1233`, the person an approval card names) with one formatter for every client surface (#498). `remoteBotName` / `isAutoOwnerName` / `seatOwnerName` keep group and face-room labels in that format too (#526): Bot Social's ` · ` member label is not a Bot name, and an owner name made up from an install id (`FlyBot 682b8a43`) gives way to the username in the home handle (`Reviewer (flybot682b8a43)`). `isListedRemoteChat` / `listedChatBots` keep gone, hidden, and home-level (`@something.home`) remotes out of the **发给** picker, mentions, and `list_bots` friends lines: a share that ended is not a place to send to. The sidebar asks a different question through `isShownRemoteChat` (#956): a chat whose Bot left the published list keeps its row, marked *no longer shared*, while only a hide the user set (`bot.hidden`) takes a row out. History stays on disk either way. | ## Invariants @@ -269,7 +269,7 @@ Work a member hands a Bot with `message_bot` from its group Session runs in a hi - Channel `state` notes when a window on screen last read the team (#497): the client sends `watching: !document.hidden` with every poll (a visible window asks every 1.5 s, a hidden one every 10 s), and `watching: false` does not count; an older client that sends nothing counts as on screen. `rt.watched()` is true for 8 s after such a read and reaches plugins as `flybot.watched()` ([plugin-runtime](plugin-runtime.md)). Process-local, never saved. Covered by `core/test/flybot.test.js` #497, which also locks in that a friend's first line and its held reply move the revision, so a window that keeps polling reads `friendLineAt`, `pendingCount` and `heldReplies` with no reload. - A face-to-face room's presented Bot participants carry `label` = `remoteBotTitle`, ` ()`, for remote and local seats alike (`room-membership.js`, #384); `faceAliases` still answers to the older ` · 's Bot` / `'s Chief` labels. - `remoteTurn` (`src/host/remote-chat.js`) finds what the user typed on a friend's remote chat by skipping host-appended `role:'user'` context: forms `snapshot`, `instructions` (the AGENTS.md baseline on a chat's first turn) and `notice`, and the time-context kinds. Match by form, not by a whitelist of senders, or the first message of a new chat is dropped (#66). -- `remoteChats.sync(plugin, list)` (`src/host/remote-chat.js`) mirrors a chat whose Bot left the published list (`gone`) into `bot.hidden`, so it leaves the sidebar without deleting its history, and drops that hide when the Bot is published again. It remembers which chats were `gone` before `open()` clears the marker, and only a real change of `gone` touches `hidden`: a chat the user hid himself (`roster.setFlags`, "Hide from sidebar") while its Bot is still published stays hidden instead of returning on the next publish. The marker `bot.remote.hiddenByGone` records a hide the sync itself imposed (and only when the chat was not already hidden), so returning drops that hide alone and a hide the user set survives the Bot leaving the list and coming back (#631). +- `remoteChats.sync(plugin, list)` (`src/host/remote-chat.js`) records that a chat's Bot left the published list as `bot.remote.gone` and never as a `bot.hidden` of its own (#956). The two are separate: `gone` is the platform's fact, `hidden` is the user's **Hide from sidebar** (`roster.setFlags`) and nothing else writes it. So a share that ends keeps its row in the sidebar (`isShownRemoteChat`, marked *no longer shared*) with its history and its way back, a hide the user set keeps the row out whether the Bot is published or not, and a Bot published again is an ordinary chat again with no leftover marker. It remembers which chats were `gone` before `open()` clears the marker, and takes back a `hidden` that a version before #956 wrote beside its `remote.hiddenByGone` marker on the next sync — and deletes the marker with it, because taking that hide back is its only use and one left behind would make a later **Hide from sidebar** read as ours and be deleted by the next sync — so an existing state file shows the row again without the user doing anything, and the user’s own hide works from then on. Covered by the `#956` cases in `core/test/remote-chat.test.js` and `core/test/new-chat.test.js`. - `notSentLine` (`src/host/remote-chat.js`) is the one short line for a failed send to a friend's Bot. It names only steps a FlyBot owner can take (#404): a paused chat says to approve a waiting message or remove and re-add the friend (or, when the plugin's error says `resend`, to send the message again, which starts a new chat; there is no Extend and no turn limit), and the fallbacks end in *Try again.*; it never points to the Bot Social console, which a FlyBot owner cannot sign in to. A friend's Bot Bot Social no longer knows (`bot_not_found` / "no such remote bot") gets its own sentence — *the Bot is no longer shared with you, so messages to it cannot be delivered. Ask its owner for a new invite.* — which the social plugin also writes and the host reuses whole instead of translating again, and no line carries *Try again.* twice (#954). - A friend chat waits on its reply (#443): a successful `forward` sets `state.remoteAwaiting[chatId]` (saved before the send turn ends), the friend's reply clears it as it arrives (`deliver`, before the reply is written into the chat) and again in the echo turn. A failed send never sets it. `channel.js` exposes it as `roster.remoteAwaiting`, and `authz.js` narrows it per owner like `remoteTypedTurns`. - Remote-chat `open` copies optional published `color` / `avatar` / `avatarUpdatedAt` onto the chat record (cached by `avatarUpdatedAt`) so headers, the picker, and group lists match Friends' Bots. A missing look stays handle-seeded (#137). diff --git a/patches/ds-bot/0003-flybot-api-client.patch b/patches/ds-bot/0003-flybot-api-client.patch index d816b75c7..eeb810ad3 100644 --- a/patches/ds-bot/0003-flybot-api-client.patch +++ b/patches/ds-bot/0003-flybot-api-client.patch @@ -1939,13 +1939,13 @@ diff --git a/src/client/sidebar.js b/src/client/sidebar.js import { Shimmer, workPhrase } from './cells.js' import { springEasing, SPRING_SHAPE } from './motion.js' import { hostText, t } from './i18n.js' -+import { friendBotTitle, roomTitle, sharedRoomTitle, isListedRemoteChat } from '../shared/remote-label.js' ++import { friendBotTitle, roomTitle, sharedRoomTitle, isShownRemoteChat } from '../shared/remote-label.js' import { showsDshRow } from './dsh-offer.js' +import { InboxRow } from './inbox.js' // ------------------------------------------------------------------------- // Sidebar -@@ -37,77 +37,148 @@ const workKeys = text => (text === '' ? [] : text.split(',').map((key) => { +@@ -37,77 +37,148 @@ })) const workLine = (lead, tool, more) => h(Shimmer, null, `${lead} · ${workPhrase(tool)}…${more > 0 ? ` +${more}` : ''}`) @@ -1975,7 +1975,7 @@ diff --git a/src/client/sidebar.js b/src/client/sidebar.js - if (bot.hidden && !active && !main) return null + const shownAway = away ? awayGroups(groups, roster.roomsById) : [] + const awayLine = shownAway.length > 0 ? workLine(roster.roomsById[shownAway[0].id]?.name ?? t('a group chat'), shownAway[0].tool, shownAway.length - 1) : null -+ if (!isListedRemoteChat(bot) && !active && !main) return null ++ if (!isShownRemoteChat(bot) && !active && !main) return null const onContextMenu = (event) => { event.preventDefault() actions.openMenu({ kind: 'bot', id, x: event.clientX, y: event.clientY }) @@ -1986,7 +1986,7 @@ diff --git a/src/client/sidebar.js b/src/client/sidebar.js + // A friend's Bot row (#384, #498): "Chief (SelfB1233)" on one line, as its header and the @ menu + // say it; the second line is the preview, or "Friend's Bot" before the first message. + const shown = remote ? friendBotTitle(bot) : bot.name -+ const secondLine = awayLine || preview || (remote ? t("Friend's Bot") : '') ++ const secondLine = awayLine || preview || (remote ? (bot.remote?.gone ? t('no longer shared') : t("Friend's Bot")) : '') + const label = [shown, main ? t('Main Bot') : '', pinned ? t('Pinned') : '', running || away ? t('Working') : '', waiting ? t('Waiting for your approval') : alert ? t('Waiting for your answer') : '', unread ? t('Unread activity') : ''].filter(Boolean).join(', ') if (tile && wide) { return h('div', { className: 'bt-tile-wrap' }, h('button', { @@ -2112,7 +2112,7 @@ diff --git a/src/client/sidebar.js b/src/client/sidebar.js : wide && unread && !active ? h('span', { className: 'bt-unread', 'aria-hidden': true }) : null) } -@@ -235,6 +235,31 @@ function useListGlide(listRef, layout, key) { +@@ -235,6 +235,31 @@ }, [layout, key]) } @@ -2144,7 +2144,7 @@ diff --git a/src/client/sidebar.js b/src/client/sidebar.js export function TeamSidebar(props) { const { wide, useRoster, useUi, useSessions, actions } = props const listRef = useRef(null) -@@ -249,14 +249,26 @@ export function TeamSidebar(props) { +@@ -249,14 +249,26 @@ const order = useMemo(() => { const stamps = JSON.parse(updated) const entries = [ @@ -2152,7 +2152,7 @@ diff --git a/src/client/sidebar.js b/src/client/sidebar.js - ...roster.rooms.map(room => ({ kind: 'room', item: room, at: stamps[room.id] ?? room.createdAt })), + // Own Bots, friends' Bots, groups and face rooms share one list. A remote chat + // still uses BotRow's globe; gone / hidden / home-level remotes stay out. -+ ...roster.bots.filter(bot => bot.id !== roster.mainBotId && isListedRemoteChat(bot)).map(bot => ({ kind: 'bot', item: bot, at: stamps[currentPart(bot)] ?? bot.createdAt })), ++ ...roster.bots.filter(bot => bot.id !== roster.mainBotId && isShownRemoteChat(bot)).map(bot => ({ kind: 'bot', item: bot, at: stamps[currentPart(bot)] ?? bot.createdAt })), + ...roster.rooms.filter(room => !isPastFaceRoom(room)).map(room => ({ kind: 'room', item: room, at: stamps[room.id] ?? room.createdAt })), // The Agent sorts by its most recent Session, falling back to when it was added. ...(roster.agents ?? []).map(agent => ({ kind: 'agent', item: agent, at: Math.max(0, ...agent.sessions.map(id => stamps[id] ?? 0)) || agent.createdAt })), @@ -2174,7 +2174,7 @@ diff --git a/src/client/sidebar.js b/src/client/sidebar.js useListGlide(listRef, wide, order.map(entry => entry.item.id).join(',')) useLandOnMainBot(roster, useSessions, actions) const rowProps = { roster, actions, useSessions, useUi, useSessionStatus: props.useSessionStatus, useSessionRetainInfo: props.useSessionRetainInfo, useActivity: props.useActivity, useWork: props.useWork, wide } -@@ -277,13 +277,16 @@ export function TeamSidebar(props) { +@@ -277,13 +277,16 @@ h(UpdateButton, { useUpdate: props.useUpdate, actions }), h('button', { type: 'button', className: 'bt-round', 'aria-label': t('New…'), title: t('New…'), onClick: () => actions.openNewChat('new') }, h(PlusIcon))), wide && roster.readOnly ? h('div', { className: 'bt-hint bt-read-only', role: 'status' }, hostText(roster.readOnly)) : null, @@ -2194,7 +2194,7 @@ diff --git a/src/client/sidebar.js b/src/client/sidebar.js } // The update button at the sidebar's top right: a cloud while a newer DS Bot — -@@ -327,29 +327,29 @@ function UpdateButton({ useUpdate, actions }) { +@@ -327,29 +327,29 @@ setDshError('') void actions.installDshUpdate().catch(failure => setDshError(hostText(failure?.message ?? String(failure)))) } @@ -2231,7 +2231,7 @@ diff --git a/src/client/sidebar.js b/src/client/sidebar.js : t('You have {version}.', { version: update.current })), message ? h('div', { className: 'bt-update-error', role: 'alert' }, t('The update did not finish: {reason}', { reason: message })) : null, h('div', { className: 'bt-update-actions' }, -@@ -372,36 +372,12 @@ function UpdateButton({ useUpdate, actions }) { +@@ -372,36 +372,12 @@ }, dshPhase === 'installing' ? t('Updating…') : dshPhase === 'failed' ? t('Try again') : t('Update')))) : null), document.body) : null) } @@ -2272,7 +2272,7 @@ diff --git a/src/client/sidebar.js b/src/client/sidebar.js } // Account seat of the Settings trigger: the visible label stays for assistive tech only. -@@ -442,6 +442,26 @@ export function openHarnessOnboarding(id) { +@@ -442,6 +442,26 @@ }) } @@ -2299,7 +2299,7 @@ diff --git a/src/client/sidebar.js b/src/client/sidebar.js function openThroughMenu(trigger) { const before = new Set(document.querySelectorAll('[role="menu"]')) trigger.click() -@@ -466,8 +466,12 @@ function pressSettingsShortcut() { +@@ -466,8 +466,12 @@ })) } @@ -2314,7 +2314,7 @@ diff --git a/src/client/sidebar.js b/src/client/sidebar.js // `seat` is the shell's settings.launcher seat: at priority -10 the whale wins it // over a shell account plugin's own launcher (the Desktop's "··· More"), which comes // back in Agent mode; FooterAccount stands in while no seat exists at all. -@@ -511,9 +511,10 @@ export function AccountLauncher({ seat = true, settingsOpen, openSettings, openO +@@ -511,9 +511,10 @@ const groups = [ [ [t('Usage'), GaugeIcon, () => actions.openSettings('usage')], @@ -2328,7 +2328,7 @@ diff --git a/src/client/sidebar.js b/src/client/sidebar.js ], [ [t('Switch to classic Agent mode'), SwitchIcon, () => actions.setSurface('agent')], -@@ -523,6 +523,7 @@ export function AccountLauncher({ seat = true, settingsOpen, openSettings, openO +@@ -523,6 +523,7 @@ h('button', { ref: button, type: 'button', className: 'bt-account', 'aria-label': t('Account'), 'aria-haspopup': 'menu', 'aria-expanded': open, onClick: toggle }, h('span', { className: 'bt-you', 'aria-hidden': true }, h(BotMark, { look: MAIN_LOOK, size: 24 }))), open && box ? createPortal(h('div', { className: 'bt-menu bt-menu-up bt-account-menu', role: 'menu', 'aria-label': t('Account'), style: box }, @@ -2336,7 +2336,7 @@ diff --git a/src/client/sidebar.js b/src/client/sidebar.js groups.map((group, index) => h(Fragment, { key: index }, index > 0 ? h('div', { className: 'bt-menu-sep', role: 'separator' }) : null, group.map(([label, Icon, run, hint]) => h('button', { -@@ -556,7 +556,10 @@ function useLandOnMainBot(roster, useSessions, actions) { +@@ -556,7 +556,10 @@ return undefined } const last = lastBot.current @@ -2348,7 +2348,7 @@ diff --git a/src/client/sidebar.js b/src/client/sidebar.js return () => clearTimeout(timer) }, [mainView, roster]) } -@@ -572,9 +572,9 @@ export function FooterAccount(props) { +@@ -572,9 +572,9 @@ export function BotReturn({ wide, useSurface, actions }) { const mode = useSurface(value => value.mode) if (mode !== 'agent') return null @@ -2361,7 +2361,7 @@ diff --git a/src/client/sidebar.js b/src/client/sidebar.js } // Agent mode has none of the shell-group styles, so the return button carries its own. -@@ -582,22 +582,22 @@ export const RETURN_CSS = ` +@@ -582,22 +582,22 @@ .bt-return{display:inline-flex;align-items:center;justify-content:center;gap:8px;min-height:36px;padding:0 14px;border:0;border-radius:999px;background:none;color:inherit;font:inherit;font-size:14px;line-height:20px;cursor:pointer;transition:background-color .12s ease} .bt-return:hover{background:rgba(127,127,127,.14)} .bt-return:active{transform:scale(.96)} @@ -2389,7 +2389,7 @@ diff --git a/src/client/sidebar.js b/src/client/sidebar.js // shell's footer classes are CSS-module names, so they match on their stems. export const SIDEBAR_CSS = ` .bt-side-top .bt-brand{cursor:default;padding-left:6px} -@@ -658,4 +658,14 @@ export const SIDEBAR_CSS = ` +@@ -658,4 +658,14 @@ .bt-agent-sub[data-open] .bt-agent-sub-row{animation:bt-agent-row-in .24s ease both;animation-delay:calc(var(--i, 0)*30ms)} @keyframes bt-agent-row-in{from{opacity:0;filter:blur(4px)}to{opacity:1;filter:blur(0)}} @media (prefers-reduced-motion:reduce){.bt-agent-sub{transition:none}.bt-agent-sub .bt-agent-sub-row{animation:none}} diff --git a/patches/ds-bot/0008-client-bundle.patch b/patches/ds-bot/0008-client-bundle.patch index 42edbd238..4bb152266 100644 --- a/patches/ds-bot/0008-client-bundle.patch +++ b/patches/ds-bot/0008-client-bundle.patch @@ -3,7 +3,7 @@ FlyBot patch 0008-client-bundle on Fei2411/DeepSeek-Bot@2b10c70 (ds-bot/). Gener diff --git a/client.js b/client.js --- a/client.js +++ b/client.js -@@ -49,6 +49,48 @@ var ZH = { +@@ -49,6 +49,48 @@ "Unread activity": "有未读", Search: "搜索", "New chat": "新对话", @@ -52,7 +52,7 @@ diff --git a/client.js b/client.js "New…": "新建", "Animation": "动画", "Bot animation": "Bot 动画", -@@ -63,6 +63,21 @@ var ZH = { +@@ -63,6 +63,21 @@ Duplicate: "复制一份", Hidden: "已隐藏", Admin: "管理员", @@ -74,7 +74,7 @@ diff --git a/client.js b/client.js Add: "添加", Close: "关闭", Copy: "复制", -@@ -78,21 +78,68 @@ var ZH = { +@@ -78,21 +78,68 @@ " and ": "、", and: "和", "{count} members": "{count} 位成员", @@ -146,7 +146,7 @@ diff --git a/client.js b/client.js Connectors: "连接器", "Search connectors": "搜索连接器", "No connectors": "暂无连接器", -@@ -129,6 +129,49 @@ var ZH = { +@@ -129,6 +129,49 @@ "Connecting…": "正在连接…", "Could not connect: {error}": "连接失败:{error}", "Connected as {account}": "已连接:{account}", @@ -196,7 +196,7 @@ diff --git a/client.js b/client.js Connected: "已连接", Disconnect: "断开", Connect: "连接", -@@ -141,17 +141,44 @@ var ZH = { +@@ -141,17 +141,44 @@ "GitHub addresses must start with https://": "GitHub 地址必须以 https:// 开头", "This DSH cannot load connectors": "这个 DSH 不能加载连接器", "No such connector": "没有这个连接器", @@ -245,7 +245,7 @@ diff --git a/client.js b/client.js // Header "Message {name}": "给 {name} 发消息", Message: "发消息", -@@ -162,8 +162,6 @@ var ZH = { +@@ -162,8 +162,6 @@ "Dismiss new messages": "关闭新消息提示", New: "新", // New chat @@ -254,7 +254,7 @@ diff --git a/client.js b/client.js "Add to group chat": "加入群聊", "Message Bot": "给 Bot 发消息", "To:": "发给:", -@@ -187,9 +187,20 @@ var ZH = { +@@ -187,9 +187,20 @@ Everyone: "所有人", "Every member answers in turn, admin first.": "每位成员轮流回答,管理员先说。", "Members · {count}": "成员 · {count}", @@ -275,7 +275,7 @@ diff --git a/client.js b/client.js "Remove {name} from the group": "把 {name} 移出群聊", "Remove from group": "移出群聊", "Add member": "添加成员", -@@ -247,6 +247,25 @@ var ZH = { +@@ -247,6 +247,25 @@ "This model is not available now. Pick another one.": "这个模型现在不可用,请换一个。", "Every message of this Bot runs on it.": "这个 Bot 的每条消息都用它。", "Applies from the next message.": "从下一条消息开始生效。", @@ -301,7 +301,7 @@ diff --git a/client.js b/client.js Profile: "资料", Label: "标签", "Optional. Shown under the name.": "可选,显示在名字下方。", -@@ -270,6 +270,7 @@ var ZH = { +@@ -270,6 +270,7 @@ "Kept above the other chats.": "排在其他对话上面。", "Search still finds it.": "仍然可以搜索到。", Manage: "管理", @@ -309,7 +309,7 @@ diff --git a/client.js b/client.js // Group chat settings "Leave it empty to name the group after its members.": "留空就按成员命名。", "Every member reads it before speaking in this group.": "每位成员在这个群里发言前都会读到。", -@@ -303,6 +303,8 @@ var ZH = { +@@ -303,6 +303,8 @@ "Harness default": "Harness 默认", Saving: "保存中", "Manage models": "管理模型", @@ -318,7 +318,7 @@ diff --git a/client.js b/client.js // Shapes Whale: "鲸鱼", Star: "星星", -@@ -374,9 +374,10 @@ var ZH = { +@@ -374,9 +374,10 @@ "No group chats yet.": "还没有群聊。", "A group chat lets several Bots work on one thing with you.": "在群聊里,几个 Bot 和你一起做一件事。", "Back to group chats": "返回群聊列表", @@ -331,7 +331,7 @@ diff --git a/client.js b/client.js "Harness settings": "Harness 设置", "Back to Bots": "返回 Bot 列表", "Close settings": "关闭设置", -@@ -385,7 +385,6 @@ var ZH = { +@@ -385,7 +385,6 @@ Pin: "置顶", Pinned: "已置顶", "Mark as Unread": "标为未读", @@ -339,7 +339,7 @@ diff --git a/client.js b/client.js "Group settings": "群设置", "Rename group": "重命名群聊", "Copy conversation ID": "复制对话 ID", -@@ -394,6 +394,8 @@ var ZH = { +@@ -394,6 +394,8 @@ // The DSH Agent's own row and Session list. "DSH Agent" is a product name and // stays in English. "Add DSH Agent": "添加 DSH Agent", @@ -348,7 +348,7 @@ diff --git a/client.js b/client.js "Remove DSH Agent": "移除 DSH Agent", "New session": "新会话", Archive: "归档", -@@ -404,13 +404,13 @@ var ZH = { +@@ -404,13 +404,13 @@ "{n} h": "{n} 小时", "{n} d": "{n} 天", // Plugin updates @@ -368,7 +368,7 @@ diff --git a/client.js b/client.js "You have {version}.": "当前版本 {version}。", "The update did not finish: {reason}": "更新没有完成:{reason}", "What's new": "更新说明", -@@ -420,8 +420,36 @@ var ZH = { +@@ -420,8 +420,36 @@ "DSH update": "DSH 更新", "DSH {version}": "DSH {version}", "Restart DSH to start using DSH {version}.": "重启 DSH 后开始使用 DSH {version}。", @@ -406,7 +406,7 @@ diff --git a/client.js b/client.js // Command palette Messages: "消息", Commands: "命令", -@@ -446,6 +446,7 @@ var ZH = { +@@ -446,6 +446,7 @@ Reply: "回复", "More message actions": "更多操作", More: "更多", @@ -414,7 +414,7 @@ diff --git a/client.js b/client.js Reactions: "表情", "Message actions": "消息操作", Download: "下载", -@@ -459,6 +459,9 @@ var ZH = { +@@ -459,6 +459,9 @@ "Replies from": "群聊回复:", "Reply from": "收到回复:", "Message from": "收到消息:", @@ -424,7 +424,7 @@ diff --git a/client.js b/client.js "Stopped by": "叫停者:", "{count} messages with": "{count} 条消息,来自", "Routine started": "例行任务开始", -@@ -468,6 +468,8 @@ var ZH = { +@@ -468,6 +468,8 @@ "Working out a plan": "理一下思路", "Sorting out the details": "梳理细节", "On it": "在做了", @@ -433,7 +433,7 @@ diff --git a/client.js b/client.js "Busy with it": "正在处理", "Moving ahead": "推进中", "Taking care of it": "处理中", -@@ -495,7 +495,6 @@ var ZH = { +@@ -495,7 +495,6 @@ "Looking back through the chat": "翻看聊天记录", "{name} looked up its own chat": "{name} 查看了自己的对话", "{name} looked up its own chat for “{text}”": "{name} 在自己的对话里查找「{text}」", @@ -441,7 +441,7 @@ diff --git a/client.js b/client.js "{name} didn't answer in time": "{name} 没有及时回应", "{name} paused for your approval": "{name} 停下来等你批准", "{name} started on it": "{name} 开始做了", -@@ -506,12 +506,29 @@ var ZH = { +@@ -506,12 +506,29 @@ "Asked to make the tool call it wrote as text": "工具调用被写成了文字,已让它重新调用", "A Bot": "一个 Bot", "{name} is waiting for your approval": "{name} 等你批准", @@ -472,7 +472,7 @@ diff --git a/client.js b/client.js "Couldn't message {name}": "没能给 {name} 发消息", Messaged: "已发消息给", "Couldn't stop {name}": "没能叫停 {name}", -@@ -539,7 +539,7 @@ var ZH = { +@@ -539,7 +539,7 @@ Dismissed: "已关闭", "Your answer": "你的回答", // Secrets @@ -481,7 +481,7 @@ diff --git a/client.js b/client.js "No Bot": "没有 Bot", "Paste or type the key": "粘贴或输入密钥", "Only {name}": "只给 {name}", -@@ -582,7 +582,7 @@ var ZH = { +@@ -582,7 +582,7 @@ "NAME, e.g. GITHUB_TOKEN": "名字,例如 GITHUB_TOKEN", "What it is for": "用途", Value: "值", @@ -490,7 +490,7 @@ diff --git a/client.js b/client.js "Save key": "保存密钥", "Keys your Bots asked for. Bots never see the values: they use them as ": "你的 Bot 请求过的密钥。Bot 永远看不到值:它们在 shell 里通过 ", " in their shell, and every value is replaced by ": " 使用,而且发给模型之前,每个值都会被替换成 ", -@@ -747,11 +747,11 @@ var ZH = { +@@ -747,11 +747,11 @@ "Compared with the {count} days before": "与再往前 {count} 天相比", "{share}% of all usage from {span}": "占 {span} 全部用量的 {share}%", // About @@ -505,7 +505,7 @@ diff --git a/client.js b/client.js Updates: "更新", "Check for updates": "检查更新", "Checking…": "正在检查…", -@@ -761,6 +761,13 @@ var ZH = { +@@ -761,6 +761,13 @@ "Update checks are off.": "已关闭更新检查。", "Installed from a local folder; update it there.": "从本地文件夹安装,请在那里更新。", "Updates are not offered for this install.": "这种安装方式不提供更新。", @@ -519,7 +519,7 @@ diff --git a/client.js b/client.js Links: "链接", "Source code": "源代码", "Release notes": "发布说明", -@@ -776,7 +776,7 @@ var ZH = { +@@ -776,7 +776,7 @@ Show: "查看", Hide: "收起", "GitHub issue": "GitHub issue", @@ -528,7 +528,7 @@ diff --git a/client.js b/client.js "Report on GitHub": "在 GitHub 上报告", "Copied the diagnostics.": "已复制诊断信息。", "Could not copy. Select the text below and copy it.": "无法复制。请选中下面的文字手动复制。", -@@ -788,7 +788,7 @@ var ZH = { +@@ -788,7 +788,7 @@ "Recent errors": "最近的错误", "Recent errors · {count}": "最近的错误 · {count}", "No errors recorded.": "没有错误记录。", @@ -537,7 +537,7 @@ diff --git a/client.js b/client.js "Model call": "模型调用", "This window": "这个窗口", "Error log": "错误日志", -@@ -821,6 +821,14 @@ var ZH = { +@@ -821,6 +821,14 @@ "Add at least one more Bot": "再至少添加一个 Bot", Members: "成员", // Connectors and scheduled tasks @@ -552,7 +552,7 @@ diff --git a/client.js b/client.js "Scheduled tasks": "定时任务", "Every day": "每天", "Every {count} days": "每 {count} 天", -@@ -834,7 +834,15 @@ var ZH = { +@@ -834,7 +834,15 @@ "Every weekend at {time}": "每个周末 {time}", "On the schedule {expression}": "按计划 {expression}", "Next: {time}": "下次 {time}", @@ -568,7 +568,7 @@ diff --git a/client.js b/client.js "in {name}": "在 {name}", "Click again to delete": "再点一次删除", "Delete task": "删除任务", -@@ -856,8 +856,8 @@ var ZH = { +@@ -856,8 +856,8 @@ Unit: "单位", "Create task": "创建任务", "Turn on Automation tasks in DSH": "在 DSH 里打开「自动化任务」", @@ -579,7 +579,7 @@ diff --git a/client.js b/client.js "New task": "新建任务", "No scheduled tasks yet.": "还没有定时任务。", "No scheduled tasks yet. Add one": "还没有定时任务,去添加", -@@ -873,6 +873,12 @@ var ZH = { +@@ -873,6 +873,12 @@ "Repeat every 1 minute to 7 days": "重复间隔要在 1 分钟到 7 天之间", "Pick when it runs": "选择什么时候运行", "No such task": "没有这个任务", @@ -592,7 +592,7 @@ diff --git a/client.js b/client.js "Working folder": "工作文件夹", "Working folder: {folder}": "工作文件夹:{folder}", Folder: "文件夹", -@@ -902,6 +902,8 @@ var HOST_ERRORS = [ +@@ -902,6 +902,8 @@ [/^A key can have at most (\d+) characters$/, "密钥最多 $1 个字符"], [/^(.+) is not saved yet$/, "$1 还没有保存"], [/^The Bot team in (.+) is open in another dsh \((.+)\)\. This window shows the team but cannot change it; close the other one and reload\.$/, "$1 里的 Bot 团队已在另一个 dsh 中打开($2)。这个窗口只能查看,不能修改;关掉另一个后刷新。"], @@ -601,7 +601,7 @@ diff --git a/client.js b/client.js [/^HTTP (\d+)$/, "请求失败(HTTP $1)"], [/^GitHub answered (\d+)$/, "GitHub 返回了 $1"], [/^Keep the name under (\d+) characters$/, "名字不能超过 $1 个字"], -@@ -930,6 +930,20 @@ var HOST_ZH = { +@@ -930,6 +930,20 @@ "Unknown Bot": "找不到这个 Bot", "Unknown conversation": "找不到这个对话", "A group chat needs at least two Bots": "群聊至少要两个 Bot", @@ -622,7 +622,7 @@ diff --git a/client.js b/client.js "The admin must be a member of the group": "管理员必须是群成员", "Unknown group chat": "找不到这个群聊", "Hand the admin role to another member instead of leaving the group without one": "请把管理员交给其他成员,不要让群聊没有管理员", -@@ -951,7 +951,9 @@ var HOST_ZH = { +@@ -951,7 +951,9 @@ "The model wrote nothing": "模型没有输出内容", "Write the entry to save.": "请写下要保存的内容。", "This DeepSeek Harness has no folder chooser": "这个 DeepSeek Harness 没有文件夹选择窗口", @@ -633,7 +633,7 @@ diff --git a/client.js b/client.js }; Object.assign(ZH, HOST_ZH); var fill = (text, params) => params ? text.replace(/\{(\w+)\}/g, (match, name) => name in params ? String(params[name]) : match) : text; -@@ -1016,6 +1016,14 @@ function noteClientError(where, error) { +@@ -1016,6 +1016,14 @@ entries.push({ time: now, level: "error", text, ...code ? { code } : {} }); if (entries.length > MAX) entries.splice(0, entries.length - MAX); } @@ -648,7 +648,7 @@ diff --git a/client.js b/client.js var quietly = (where) => (error) => noteClientError(where, error); var clearClientErrors = () => { entries.length = 0; -@@ -1042,12 +1042,17 @@ var FAILURES = { +@@ -1042,12 +1042,17 @@ }; function failureLabel(code, message) { if (FAILURES[code]) return t(FAILURES[code]); @@ -669,7 +669,7 @@ diff --git a/client.js b/client.js function issueUrl(base, { what = "", diagnostics }) { const text = what.trim().slice(0, WHAT_MAX); const title = text.split("\n")[0].slice(0, 80); -@@ -1066,10 +1066,16 @@ var REFUSALS = /* @__PURE__ */ new Set(["bot/refused", "bot/invalid", "bot/read- +@@ -1066,10 +1066,16 @@ var call = async (endpoint, payload, signal) => { let response; try { @@ -687,7 +687,7 @@ diff --git a/client.js b/client.js body: JSON.stringify({ endpoint, payload: payload ?? null }), signal }); -@@ -1087,7 +1087,7 @@ var call = async (endpoint, payload, signal) => { +@@ -1087,7 +1087,7 @@ if (!result.ok) { const code = result.error?.code ?? "bot/error"; const message = hostText(result.error?.message ?? "Request failed"); @@ -696,7 +696,7 @@ diff --git a/client.js b/client.js if (!REFUSALS.has(code)) { noteClientError(endpoint, Object.assign(new Error(result.error?.message ?? "Request failed"), { code })); markNoted(error); -@@ -1097,6 +1097,354 @@ var call = async (endpoint, payload, signal) => { +@@ -1097,6 +1097,360 @@ return result.value; }; @@ -729,6 +729,12 @@ diff --git a/client.js b/client.js + if (isHomeLevelRemote(remote)) return false; + return true; +} ++function isShownRemoteChat(bot) { ++ if (!bot) return false; ++ if (isListedRemoteChat(bot)) return true; ++ if (bot.remote?.gone !== true) return false; ++ return bot.hidden !== true || bot.remote.hiddenByGone === true; ++} +function listedChatBots(roster) { + return (roster?.bots ?? []).filter(isListedRemoteChat); +} @@ -1051,7 +1057,7 @@ diff --git a/client.js b/client.js // src/client/inks.js var INKS = { deepseek: ["#4D6BFE", "#6A84FF"], -@@ -1177,15 +1177,65 @@ function defaultShape(id) { +@@ -1177,15 +1177,65 @@ value = Math.imul(value ^ value >>> 13, 3266489909); return DEFAULT_SHAPES[((value ^ value >>> 16) >>> 0) % DEFAULT_SHAPES.length]; } @@ -1119,7 +1125,7 @@ diff --git a/client.js b/client.js var SHAPES = {}; for (const [id, character] of Object.entries(CHARACTERS)) { const layers = [ -@@ -1206,8 +1206,8 @@ function bodyOf(def, paint, flat = false) { +@@ -1206,8 +1206,8 @@ return ""; } } @@ -1130,7 +1136,7 @@ diff --git a/client.js b/client.js var maskBox = (size) => `maskUnits="userSpaceOnUse" x="${-size}" y="${-size}" width="${size * 3}" height="${size * 3}"`; var holes = (def, eyes) => `${eyes}`; var MASK_ID = "%MASK%"; -@@ -1242,162 +1242,16 @@ function maskOf(shape) { +@@ -1242,162 +1242,16 @@ } var DEFAULT_MASK = maskOf(FALLBACK_SHAPE); @@ -1302,23 +1308,10 @@ diff --git a/client.js b/client.js // src/shared/mentions.js var ALL_WORDS = ["all", "everyone", "所有人", "全体"]; -@@ -1445,24 +1445,59 @@ function mentionSpans(text, bots, { references = [] } = {}) { +@@ -1445,14 +1445,36 @@ } return spans; } -- --// src/client/text.js --var MENTION_ORIGIN = "https://bots.local/"; --function linkMentions(text, bots, selfId) { -- if (!text || !text.includes("@")) return text; -- let out = ""; -- let cursor = 0; -- for (const span of mentionSpans(text, bots)) { -- if (!span.bot || span.bot.id === selfId) continue; -- out += `${text.slice(cursor, span.start)}[${text.slice(span.start + 1, span.end).replace(/[[\]\\]/g, "\\$&")}](${MENTION_ORIGIN}${span.bot.id})`; -- cursor = span.end; -- } -- return cursor === 0 ? text : out + text.slice(cursor); +function unmatchedMentions(text, bots, { references = [] } = {}) { + const value = String(text ?? ""); + const skip = [ @@ -1340,22 +1333,20 @@ diff --git a/client.js b/client.js + } + return missing; +} -+ -+// src/client/text.js -+var MENTION_ORIGIN = "https://bots.local/"; -+function linkMentions(text, bots, selfId) { -+ if (!text || !text.includes("@")) return text; + + // src/client/text.js + var MENTION_ORIGIN = "https://bots.local/"; + function linkMentions(text, bots, selfId) { + if (!text || !text.includes("@")) return text; + const ordered = [...bots ?? []].sort((a, b) => Number(Boolean(a?.remote)) - Number(Boolean(b?.remote))); -+ let out = ""; -+ let cursor = 0; + let out = ""; + let cursor = 0; +- for (const span of mentionSpans(text, bots)) { + for (const span of mentionSpans(text, ordered)) { -+ if (!span.bot || span.bot.id === selfId) continue; -+ out += `${text.slice(cursor, span.start)}[${text.slice(span.start + 1, span.end).replace(/[[\]\\]/g, "\\$&")}](${MENTION_ORIGIN}${span.bot.id})`; -+ cursor = span.end; -+ } -+ return cursor === 0 ? text : out + text.slice(cursor); - } - function mentionTarget(event) { + if (!span.bot || span.bot.id === selfId) continue; + out += `${text.slice(cursor, span.start)}[${text.slice(span.start + 1, span.end).replace(/[[\]\\]/g, "\\$&")}](${MENTION_ORIGIN}${span.bot.id})`; + cursor = span.end; +@@ -1463,6 +1463,19 @@ const anchor = event.target instanceof Element ? event.target.closest(`a[href^="${MENTION_ORIGIN}"]`) : null; return anchor ? anchor.getAttribute("href").slice(MENTION_ORIGIN.length) : null; } @@ -1375,7 +1366,7 @@ diff --git a/client.js b/client.js function clock(ms) { return new Date(ms).toLocaleTimeString(dateLocale(), { hour: "numeric", minute: "2-digit" }); } -@@ -1483,6 +1483,15 @@ function relativeShort(ms) { +@@ -1483,6 +1483,15 @@ var contentText = (content) => (content ?? []).filter((block) => block?.type === "text").map((block) => block.text).join("\n"); var HEADER = /^\[(Message from|Reply from|Group chat|Group post from|Bot team setup|Handoff · part|Secret card)[^\]]*\]\n?/; var currentPart = (bot) => bot.sessionId ?? bot.id; @@ -1391,7 +1382,7 @@ diff --git a/client.js b/client.js function reminderPrompts(text) { const one = /^reminder_prompt_json: (.*)$/m.exec(text); const batch = /^reminders_json: (.*)$/m.exec(text); -@@ -1494,6 +1494,13 @@ function reminderPrompts(text) { +@@ -1494,6 +1494,13 @@ } } var stripHeader = (text) => text.replace(HEADER, ""); @@ -1405,7 +1396,7 @@ diff --git a/client.js b/client.js function latestMemoryChange(news, ids) { const at = (id) => news?.[id]?.seen ? news[id].at ?? 0 : 0; return ids.reduce((best, id) => at(id) > at(best) ? id : best, ids[0] ?? null); -@@ -1506,25 +1506,35 @@ function summaryBlocks(text) { +@@ -1506,25 +1506,35 @@ paragraph = []; }; for (const raw of String(text ?? "").split("\n")) { @@ -1445,7 +1436,7 @@ diff --git a/client.js b/client.js var historyLoading = (snapshot) => snapshot?.openState === "cold" || snapshot?.openState === "loading"; function toolArgs(root) { if (typeof root.args?.text === "function") { -@@ -1597,14 +1597,21 @@ function commRunOf(order, peers, turn, hiddenTurns = []) { +@@ -1597,14 +1597,21 @@ } return { count, peerIds }; } @@ -1470,65 +1461,16 @@ diff --git a/client.js b/client.js } return ""; } -@@ -1624,209 +1624,1052 @@ function usageOf(list, ids) { +@@ -1624,9 +1624,852 @@ const hit = input > 0 ? Math.round(cacheRead / input * 100) : 0; return t("{tokens} tok · Cache hit {hit}%", { tokens: formatTokens(input + output), hit }); } -- --// src/client/motion.js --var import_react = require("react"); --var reducedMotion = () => window.matchMedia?.("(prefers-reduced-motion: reduce)").matches === true; --function springEasing(stiffness, damping) { -- const w0 = Math.sqrt(stiffness); -- const zeta = damping / (2 * w0); -- const at = (t2) => { -- if (zeta >= 1) return 1 - (1 + w0 * t2) * Math.exp(-w0 * t2); -- const wd = w0 * Math.sqrt(1 - zeta * zeta); -- return 1 - Math.exp(-zeta * w0 * t2) * (Math.cos(wd * t2) + zeta * w0 / wd * Math.sin(wd * t2)); -- }; -- let end = 0.05; -- while (end < 2 && Math.abs(1 - at(end)) > 1e-3) end += 0.01; -- const points = Array.from({ length: 25 }, (_, index) => (index === 24 ? 1 : at(end * index / 24)).toFixed(4)); -- return { duration: Math.round(end * 1e3), easing: `linear(${points.join(",")})`, stiffness, damping }; +function faceRowTitle(title) { + const text = String(title ?? ""); + const named = text.match(/^Face-to-face · (.+) · (\d{6})$/); + if (named) return `${named[2]} · ${named[1]}`; + return text.match(/^Face-to-face · (\d{6})$/)?.[1] ?? text; - } --var SPRING_SHAPE = springEasing(400, 31.2); --var SPRING_TAP = springEasing(900, 48); --var SPRING_LIST = springEasing(484, 35.2); --var SPRING_STRETCH = springEasing(320, 20); --var SPRING_POP = springEasing(500, 22); --var SPRING_LEAD = springEasing(784, 48.2); --var SPRING_TRAIL = springEasing(289, 29.2); --var QUICK_EDGES = { lead: springEasing(900, 51), trail: springEasing(400, 34) }; --var spring = (curve, ...properties) => properties.map((property) => `${property} ${curve.duration}ms ${curve.easing}`).join(","); --function liveSprings(apply2) { -- const values = /* @__PURE__ */ new Map(); -- let frame = 0; -- let last = 0; -- const read = () => Object.fromEntries([...values].map(([key, value]) => [key, value.x])); -- const step = (now) => { -- const dt = Math.min(0.5, Math.max(1e-3, (now - last) / 1e3)); -- last = now; -- const steps = Math.ceil(dt * 240); -- const h34 = dt / steps; -- let moving = false; -- for (const value of values.values()) { -- for (let index = 0; index < steps; index += 1) { -- value.v += (-value.k * (value.x - value.to) - value.c * value.v) * h34; -- value.x += value.v * h34; -- } -- if (Math.abs(value.x - value.to) < 0.05 && Math.abs(value.v) < 1) { -- value.x = value.to; -- value.v = 0; -- } else moving = true; -- } -- apply2(read()); -- frame = moving ? requestAnimationFrame(step) : 0; -- }; ++} +function modelOf(bot, roster) { + const ref = bot.model ?? bot.appliedModel; + if (!ref) return void 0; @@ -1543,84 +1485,28 @@ diff --git a/client.js b/client.js +function awayGroups(groups, roomsById) { + return (groups ?? []).filter((entry) => roomsById?.[entry.id]?.origin !== "face-to-face"); +} -+ + +-// src/client/motion.js +// src/client/sources.js +function createSource(initial) { + let value = initial; + const listeners2 = /* @__PURE__ */ new Set(); - return { -- target: () => values.size ? Object.fromEntries([...values].map(([key, value]) => [key, value.to])) : null, -- set(targets, curves, jump) { -- for (const [key, to] of Object.entries(targets)) { -- const value = values.get(key); -- const { stiffness: k, damping: c } = curves[key]; -- if (!value || jump) values.set(key, { x: to, v: 0, to, k, c }); -- else Object.assign(value, { to, k, c }); -- } -- if (jump) { -- cancelAnimationFrame(frame); -- frame = 0; -- apply2(read()); -- return; -- } -- if (!frame) { -- last = performance.now(); -- frame = requestAnimationFrame(step); -- } ++ return { + getSnapshot: () => value, + subscribe: (listener) => { + listeners2.add(listener); + return () => { + listeners2.delete(listener); + }; - }, -- stop: () => cancelAnimationFrame(frame) ++ }, + set(next) { + const resolved = typeof next === "function" ? next(value) : next; + if (resolved === value) return; + value = resolved; + for (const listener of [...listeners2]) listener(); + } - }; - } --var CHOSEN = '[aria-pressed="true"],[aria-selected="true"],[aria-current="page"]'; --function useGlide(key, { axis = "x", selector = CHOSEN, lead = SPRING_LEAD, trail = SPRING_TRAIL, box: given } = {}) { -- const ownBox = (0, import_react.useRef)(null); -- const box = given ?? ownBox; -- const thumb = (0, import_react.useRef)(null); -- const own = (0, import_react.useRef)(null); -- if (own.current === null) { -- own.current = { -- key: void 0, -- node: null, -- observer: null, -- springs: liveSprings(({ start, end }) => { -- const knob = thumb.current; -- if (!knob) return; -- knob.style.transform = axis === "x" ? `translateX(${start}px)` : `translateY(${start}px)`; -- knob.style[axis === "x" ? "width" : "height"] = `${Math.max(0, end - start)}px`; -- }) -- }; -- } -- const place = (jump) => { -- const container = box.current; -- const knob = thumb.current; -- if (!container || !knob) return; -- const chosen = container.querySelector(selector); -- if (!chosen) { -- knob.dataset.hidden = ""; -- return; -- } -- const start = axis === "x" ? chosen.offsetLeft : chosen.offsetTop; -- const end = start + (axis === "x" ? chosen.offsetWidth : chosen.offsetHeight); -- const { springs } = own.current; -- const before = springs.target(); -- const hidden = knob.dataset.hidden !== void 0; -- if (jump && !hidden && before?.start === start && before?.end === end && container.dataset.glide !== void 0) return; -- delete knob.dataset.hidden; -- const forward = before === null || start >= before.start; -- springs.set({ start, end }, forward ? { start: trail, end: lead } : { start: lead, end: trail }, jump || hidden || before === null || reducedMotion()); -- container.dataset.glide = ""; ++ }; ++} +var EMPTY_ROSTER = { revision: -1, mainBotId: null, mainBotIds: [], bots: [], rooms: [], humans: [], self: null, agents: [], exchangeTurns: {}, remoteTypedTurns: {}, remoteAwaiting: {}, questions: {}, secrets: [], capabilities: [], capabilityStatuses: {}, secretRequests: {}, models: [], defaultModel: null, modelsKey: null, prefs: {}, byId: {}, roomsById: {}, agentsById: {}, agentOf: {}, ready: false }; +var isMainOf = (roster, id) => id !== void 0 && (roster.mainBotIds ?? []).includes(id); +function indexRoster(value) { @@ -1633,26 +1519,7 @@ diff --git a/client.js b/client.js + if (prev && prev !== bot && !prev.remote && bot?.remote) return; + if (prev && prev !== bot && prev.id === id) return; + byId[id] = bot; - }; -- (0, import_react.useLayoutEffect)(() => { -- const state = own.current; -- const node = box.current; -- if (node === state.node && key === state.key) return; -- const fresh = node !== state.node; -- state.key = key; -- if (fresh) { -- state.observer?.disconnect(); -- state.node = node; -- state.observer = node && typeof ResizeObserver === "function" ? new ResizeObserver(() => place(true)) : null; -- state.observer?.observe(node); -- } -- place(fresh); -- }); -- (0, import_react.useEffect)(() => () => { -- own.current.observer?.disconnect(); -- own.current.springs.stop(); -- }, []); -- return { box, thumb }; ++ }; + for (const bot of value.bots) { + for (const id of [...bot.parts ?? [], bot.sessionId ?? bot.id, bot.id]) claim(bot, id); + } @@ -1665,93 +1532,25 @@ diff --git a/client.js b/client.js + for (const sessionId of agent.sessions ?? []) agentOf[sessionId] = agent.id; + } + return { ...value, agents: value.agents ?? [], byId, roomsById, agentsById, agentOf, ready: true }; - } --function Segmented({ label, value, className, children }) { -- const glide = useGlide(value); -- return (0, import_react.createElement)( -- "div", -- { ref: glide.box, className: className ? `bt-seg ${className}` : "bt-seg", role: "group", "aria-label": label }, -- (0, import_react.createElement)("span", { ref: glide.thumb, className: "bt-thumb", "aria-hidden": true }), -- children -- ); ++} +function rosterBot(roster, id) { + if (id === void 0 || id === null || id === "") return void 0; + const list = roster?.bots ?? []; + const own = list.find((bot) => bot && !bot.remote && (bot.id === id || bot.sessionId === id || (bot.parts ?? []).includes(id))); + if (own) return own; + return roster?.byId?.[id]; - } --function useFlip(list, key, selector, id = (node) => node.dataset.flip) { -- const tops = (0, import_react.useRef)(null); -- (0, import_react.useLayoutEffect)(() => { -- const container = list.current; -- if (!container) return; -- const next = /* @__PURE__ */ new Map(); -- const moving = tops.current !== null && !reducedMotion(); -- for (const node of container.querySelectorAll(selector)) { -- const name = id(node); -- const top = node.offsetTop; -- next.set(name, top); -- if (!moving) continue; -- const before = tops.current.get(name); -- if (before === void 0) { -- node.animate([{ opacity: 0, filter: "blur(4px)" }, { opacity: 1, filter: "blur(0)" }], { duration: 180, easing: "ease-out" }); -- } else if (Math.abs(before - top) > 0.5) { -- node.animate([{ transform: `translateY(${before - top}px)` }, { transform: "none" }], { duration: SPRING_LIST.duration, easing: SPRING_LIST.easing }); -- } -- } -- tops.current = next; -- }, [key]); ++} +function botByName(bots, name) { + const wanted = String(name ?? "").toLowerCase(); + if (wanted === "") return void 0; + const list = Array.isArray(bots) ? bots : []; + return list.find((bot) => bot && !bot.remote && String(bot.name ?? "").toLowerCase() === wanted) ?? list.find((bot) => bot && String(bot.name ?? "").toLowerCase() === wanted); - } --function useMorph(box, key) { -- const last = (0, import_react.useRef)({ key, height: null }); -- (0, import_react.useLayoutEffect)(() => { -- const node = box.current; -- const state = last.current; -- if (!node) { -- last.current = { key, height: null }; -- return; -- } -- const running = node.getAnimations().filter((animation2) => animation2.id === "bt-morph"); -- if (key === state.key) { -- if (running.length === 0) state.height = node.offsetHeight; -- return; -- } -- const from = running.length ? node.offsetHeight : state.height; -- for (const animation2 of running) animation2.cancel(); -- const to = node.offsetHeight; -- last.current = { key, height: to }; -- if (from === null || Math.abs(from - to) < 1 || reducedMotion()) return; -- node.dataset.morph = ""; -- const animation = node.animate([{ height: `${from}px` }, { height: `${to}px` }], { duration: SPRING_SHAPE.duration, easing: SPRING_SHAPE.easing, id: "bt-morph" }); -- animation.onfinish = () => { -- delete node.dataset.morph; -- }; -- animation.oncancel = () => { -- if (!node.getAnimations().some((other) => other.id === "bt-morph" && other !== animation)) delete node.dataset.morph; -- }; -- }); ++} +function isAgentSession(roster, cache, sessionId) { + if (sessionId === void 0 || sessionId === null || sessionId === "") return false; + if (roster.ready) return roster.agentOf[sessionId] !== void 0; + return cache.has(sessionId); - } --function useLinger(value, ms) { -- const [kept, setKept] = (0, import_react.useState)(value); -- (0, import_react.useEffect)(() => { -- if (value) { -- setKept(value); -- return void 0; -- } -- const timer = setTimeout(() => setKept(value), reducedMotion() ? 0 : ms); -- return () => clearTimeout(timer); -- }, [value]); -- return [value || kept, !value && Boolean(kept)]; ++} +function countCards(state, sessionId, callIds, delta) { + const own = { ...state[sessionId] }; + for (const callId of callIds) { @@ -1763,7 +1562,7 @@ diff --git a/client.js b/client.js +} + +// src/client/mark.js -+var import_react = require("react"); + var import_react = 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"; @@ -2513,210 +2312,101 @@ diff --git a/client.js b/client.js + +// src/client/motion.js +var import_react3 = require("react"); -+var reducedMotion = () => window.matchMedia?.("(prefers-reduced-motion: reduce)").matches === true; -+function springEasing(stiffness, damping) { -+ const w0 = Math.sqrt(stiffness); -+ const zeta = damping / (2 * w0); -+ const at = (t2) => { -+ if (zeta >= 1) return 1 - (1 + w0 * t2) * Math.exp(-w0 * t2); -+ const wd = w0 * Math.sqrt(1 - zeta * zeta); -+ return 1 - Math.exp(-zeta * w0 * t2) * (Math.cos(wd * t2) + zeta * w0 / wd * Math.sin(wd * t2)); -+ }; -+ let end = 0.05; -+ while (end < 2 && Math.abs(1 - at(end)) > 1e-3) end += 0.01; -+ const points = Array.from({ length: 25 }, (_, index) => (index === 24 ? 1 : at(end * index / 24)).toFixed(4)); -+ return { duration: Math.round(end * 1e3), easing: `linear(${points.join(",")})`, stiffness, damping }; -+} -+var SPRING_SHAPE = springEasing(400, 31.2); -+var SPRING_TAP = springEasing(900, 48); -+var SPRING_LIST = springEasing(484, 35.2); -+var SPRING_STRETCH = springEasing(320, 20); -+var SPRING_POP = springEasing(500, 22); -+var SPRING_LEAD = springEasing(784, 48.2); -+var SPRING_TRAIL = springEasing(289, 29.2); -+var QUICK_EDGES = { lead: springEasing(900, 51), trail: springEasing(400, 34) }; -+var spring = (curve, ...properties) => properties.map((property) => `${property} ${curve.duration}ms ${curve.easing}`).join(","); -+function liveSprings(apply2) { -+ const values = /* @__PURE__ */ new Map(); -+ let frame = 0; -+ let last = 0; -+ const read = () => Object.fromEntries([...values].map(([key, value]) => [key, value.x])); -+ const step = (now) => { -+ const dt = Math.min(0.5, Math.max(1e-3, (now - last) / 1e3)); -+ last = now; -+ const steps = Math.ceil(dt * 240); + var reducedMotion = () => window.matchMedia?.("(prefers-reduced-motion: reduce)").matches === true; + function springEasing(stiffness, damping) { + const w0 = Math.sqrt(stiffness); +@@ -1659,12 +1659,12 @@ + const dt = Math.min(0.5, Math.max(1e-3, (now - last) / 1e3)); + last = now; + const steps = Math.ceil(dt * 240); +- const h34 = dt / steps; + const h42 = dt / steps; -+ let moving = false; -+ for (const value of values.values()) { -+ for (let index = 0; index < steps; index += 1) { + let moving = false; + for (const value of values.values()) { + for (let index = 0; index < steps; index += 1) { +- value.v += (-value.k * (value.x - value.to) - value.c * value.v) * h34; +- value.x += value.v * h34; + value.v += (-value.k * (value.x - value.to) - value.c * value.v) * h42; + value.x += value.v * h42; -+ } -+ if (Math.abs(value.x - value.to) < 0.05 && Math.abs(value.v) < 1) { -+ value.x = value.to; -+ value.v = 0; -+ } else moving = true; -+ } -+ apply2(read()); -+ frame = moving ? requestAnimationFrame(step) : 0; -+ }; -+ return { -+ target: () => values.size ? Object.fromEntries([...values].map(([key, value]) => [key, value.to])) : null, -+ set(targets, curves, jump) { -+ for (const [key, to] of Object.entries(targets)) { -+ const value = values.get(key); -+ const { stiffness: k, damping: c } = curves[key]; -+ if (!value || jump) values.set(key, { x: to, v: 0, to, k, c }); -+ else Object.assign(value, { to, k, c }); -+ } -+ if (jump) { -+ cancelAnimationFrame(frame); -+ frame = 0; -+ apply2(read()); -+ return; -+ } -+ if (!frame) { -+ last = performance.now(); -+ frame = requestAnimationFrame(step); -+ } -+ }, -+ stop: () => cancelAnimationFrame(frame) -+ }; -+} -+var CHOSEN = '[aria-pressed="true"],[aria-selected="true"],[aria-current="page"]'; -+function useGlide(key, { axis = "x", selector = CHOSEN, lead = SPRING_LEAD, trail = SPRING_TRAIL, box: given } = {}) { + } + if (Math.abs(value.x - value.to) < 0.05 && Math.abs(value.v) < 1) { + value.x = value.to; +@@ -1699,10 +1699,10 @@ + } + var CHOSEN = '[aria-pressed="true"],[aria-selected="true"],[aria-current="page"]'; + function useGlide(key, { axis = "x", selector = CHOSEN, lead = SPRING_LEAD, trail = SPRING_TRAIL, box: given } = {}) { +- const ownBox = (0, import_react.useRef)(null); + const ownBox = (0, import_react3.useRef)(null); -+ const box = given ?? ownBox; + const box = given ?? ownBox; +- const thumb = (0, import_react.useRef)(null); +- const own = (0, import_react.useRef)(null); + const thumb = (0, import_react3.useRef)(null); + const own = (0, import_react3.useRef)(null); -+ if (own.current === null) { -+ own.current = { -+ key: void 0, -+ node: null, -+ observer: null, -+ springs: liveSprings(({ start, end }) => { -+ const knob = thumb.current; -+ if (!knob) return; -+ knob.style.transform = axis === "x" ? `translateX(${start}px)` : `translateY(${start}px)`; -+ knob.style[axis === "x" ? "width" : "height"] = `${Math.max(0, end - start)}px`; -+ }) -+ }; -+ } -+ const place = (jump) => { -+ const container = box.current; -+ const knob = thumb.current; -+ if (!container || !knob) return; -+ const chosen = container.querySelector(selector); -+ if (!chosen) { -+ knob.dataset.hidden = ""; -+ return; -+ } -+ const start = axis === "x" ? chosen.offsetLeft : chosen.offsetTop; -+ const end = start + (axis === "x" ? chosen.offsetWidth : chosen.offsetHeight); -+ const { springs } = own.current; -+ const before = springs.target(); -+ const hidden = knob.dataset.hidden !== void 0; -+ if (jump && !hidden && before?.start === start && before?.end === end && container.dataset.glide !== void 0) return; -+ delete knob.dataset.hidden; -+ const forward = before === null || start >= before.start; -+ springs.set({ start, end }, forward ? { start: trail, end: lead } : { start: lead, end: trail }, jump || hidden || before === null || reducedMotion()); -+ container.dataset.glide = ""; -+ }; + if (own.current === null) { + own.current = { + key: void 0, +@@ -1736,7 +1736,7 @@ + springs.set({ start, end }, forward ? { start: trail, end: lead } : { start: lead, end: trail }, jump || hidden || before === null || reducedMotion()); + container.dataset.glide = ""; + }; +- (0, import_react.useLayoutEffect)(() => { + (0, import_react3.useLayoutEffect)(() => { -+ const state = own.current; -+ const node = box.current; -+ if (node === state.node && key === state.key) return; -+ const fresh = node !== state.node; -+ state.key = key; -+ if (fresh) { -+ state.observer?.disconnect(); -+ state.node = node; -+ state.observer = node && typeof ResizeObserver === "function" ? new ResizeObserver(() => place(true)) : null; -+ state.observer?.observe(node); -+ } -+ place(fresh); -+ }); + const state = own.current; + const node = box.current; + if (node === state.node && key === state.key) return; +@@ -1750,7 +1750,7 @@ + } + place(fresh); + }); +- (0, import_react.useEffect)(() => () => { + (0, import_react3.useEffect)(() => () => { -+ own.current.observer?.disconnect(); -+ own.current.springs.stop(); -+ }, []); -+ return { box, thumb }; -+} -+function Segmented({ label, value, className, children }) { -+ const glide = useGlide(value); + own.current.observer?.disconnect(); + own.current.springs.stop(); + }, []); +@@ -1758,16 +1758,16 @@ + } + function Segmented({ label, value, className, children }) { + const glide = useGlide(value); +- return (0, import_react.createElement)( + return (0, import_react3.createElement)( -+ "div", -+ { ref: glide.box, className: className ? `bt-seg ${className}` : "bt-seg", role: "group", "aria-label": label }, + "div", + { ref: glide.box, className: className ? `bt-seg ${className}` : "bt-seg", role: "group", "aria-label": label }, +- (0, import_react.createElement)("span", { ref: glide.thumb, className: "bt-thumb", "aria-hidden": true }), + (0, import_react3.createElement)("span", { ref: glide.thumb, className: "bt-thumb", "aria-hidden": true }), -+ children -+ ); -+} -+function useFlip(list, key, selector, id = (node) => node.dataset.flip) { + children + ); + } + function useFlip(list, key, selector, id = (node) => node.dataset.flip) { +- const tops = (0, import_react.useRef)(null); +- (0, import_react.useLayoutEffect)(() => { + const tops = (0, import_react3.useRef)(null); + (0, import_react3.useLayoutEffect)(() => { -+ const container = list.current; -+ if (!container) return; -+ const next = /* @__PURE__ */ new Map(); -+ const moving = tops.current !== null && !reducedMotion(); -+ for (const node of container.querySelectorAll(selector)) { -+ const name = id(node); -+ const top = node.offsetTop; -+ next.set(name, top); -+ if (!moving) continue; -+ const before = tops.current.get(name); -+ if (before === void 0) { -+ node.animate([{ opacity: 0, filter: "blur(4px)" }, { opacity: 1, filter: "blur(0)" }], { duration: 180, easing: "ease-out" }); -+ } else if (Math.abs(before - top) > 0.5) { -+ node.animate([{ transform: `translateY(${before - top}px)` }, { transform: "none" }], { duration: SPRING_LIST.duration, easing: SPRING_LIST.easing }); -+ } -+ } -+ tops.current = next; -+ }, [key]); -+} -+function useMorph(box, key) { + const container = list.current; + if (!container) return; + const next = /* @__PURE__ */ new Map(); +@@ -1788,8 +1788,8 @@ + }, [key]); + } + function useMorph(box, key) { +- const last = (0, import_react.useRef)({ key, height: null }); +- (0, import_react.useLayoutEffect)(() => { + const last = (0, import_react3.useRef)({ key, height: null }); + (0, import_react3.useLayoutEffect)(() => { -+ const node = box.current; -+ const state = last.current; -+ if (!node) { -+ last.current = { key, height: null }; -+ return; -+ } -+ const running = node.getAnimations().filter((animation2) => animation2.id === "bt-morph"); -+ if (key === state.key) { -+ if (running.length === 0) state.height = node.offsetHeight; -+ return; -+ } -+ const from = running.length ? node.offsetHeight : state.height; -+ for (const animation2 of running) animation2.cancel(); -+ const to = node.offsetHeight; -+ last.current = { key, height: to }; -+ if (from === null || Math.abs(from - to) < 1 || reducedMotion()) return; -+ node.dataset.morph = ""; -+ const animation = node.animate([{ height: `${from}px` }, { height: `${to}px` }], { duration: SPRING_SHAPE.duration, easing: SPRING_SHAPE.easing, id: "bt-morph" }); -+ animation.onfinish = () => { -+ delete node.dataset.morph; -+ }; -+ animation.oncancel = () => { -+ if (!node.getAnimations().some((other) => other.id === "bt-morph" && other !== animation)) delete node.dataset.morph; -+ }; -+ }); -+} -+function useLinger(value, ms) { + const node = box.current; + const state = last.current; + if (!node) { +@@ -1817,8 +1817,8 @@ + }); + } + function useLinger(value, ms) { +- const [kept, setKept] = (0, import_react.useState)(value); +- (0, import_react.useEffect)(() => { + const [kept, setKept] = (0, import_react3.useState)(value); + (0, import_react3.useEffect)(() => { -+ if (value) { -+ setKept(value); -+ return void 0; -+ } -+ const timer = setTimeout(() => setKept(value), reducedMotion() ? 0 : ms); -+ return () => clearTimeout(timer); -+ }, [value]); -+ return [value || kept, !value && Boolean(kept)]; - } - - // src/client/styles.js -@@ -1838,6 +1838,7 @@ ${Object.entries(INKS).map(([name, [, dark]]) => `body[data-ds-dark-theme]{--bt- + if (value) { + setKept(value); + return void 0; +@@ -1838,6 +1838,7 @@ .bt-mark{display:inline-block;flex:none;position:relative;line-height:0} .bt-mark svg{width:100%;height:100%;overflow:visible} .bt-mark-img{width:100%;height:100%;border-radius:50%;object-fit:cover;display:block;box-shadow:inset 0 0 0 1px var(--bt-line)} @@ -2724,7 +2414,7 @@ diff --git a/client.js b/client.js .bt-mark .bt-b,.bt-mark .bt-eye,.bt-mark .bt-eyes,.bt-mark .bt-gaze{transform-box:fill-box} .bt-mark .bt-b{transform-origin:50% 100%} .bt-mark .bt-eye,.bt-mark .bt-eyes{transform-origin:center} -@@ -1856,6 +1856,14 @@ body[data-bt-motion=lively] .bt-mark[data-live][data-state=idle][data-fidget=loo +@@ -1856,6 +1856,14 @@ @keyframes bt-fidget-look{0%,22%,100%{transform:none}3%,9%{transform:translateX(-14%)}12%,19%{transform:translateX(14%)}} body[data-bt-motion=lively] .bt-badge-alert{right:auto;bottom:auto;left:50%;top:calc(var(--bt-size,36px) * -.14);translate:-50% 0;animation:bt-q-ride 1.3s cubic-bezier(.3,.7,.4,1) infinite} body[data-bt-motion=lively] :is(.bt-row,.bt-tile)>.bt-mark:hover .bt-badge-alert{animation:none} @@ -2739,7 +2429,7 @@ diff --git a/client.js b/client.js @keyframes bt-q-ride{0%,55%,100%{transform:translateY(0)}20%{transform:translateY(calc(var(--bt-size,36px) * -.14))}40%{transform:translateY(0)}} /* Petting: with the pointer on a sidebar avatar or just around it (a ring a fifth of its size wide), a resting mark leans into the pointer and keeps nuzzling it, its eyes -@@ -1933,15 +1933,23 @@ body[data-bt-motion=quiet] .bt-mark[data-poke]{cursor:default} +@@ -1933,15 +1933,23 @@ .bt-side{display:flex;flex-direction:column;min-height:0;flex:1 1 auto;gap:2px;padding:0 12px 8px 0;color:var(--bt-ink);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif} .bt-side-top{display:flex;justify-content:flex-end;align-items:center;gap:8px;padding:6px 5px 0 0} .bt-brand{display:inline-flex;align-items:center;gap:6px;margin-right:auto;padding:4px 8px 4px 4px;border:0;border-radius:8px;background:none;cursor:pointer;font-family:inherit;font-size:15px;line-height:20px;font-weight:600;letter-spacing:-.01em;color:var(--bt-ink);white-space:nowrap} @@ -2765,7 +2455,7 @@ diff --git a/client.js b/client.js .bt-tile-name{font-size:13px;line-height:18px;font-weight:400;max-width:84px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .bt-chip{font-size:11px;line-height:16px;padding:1px 4px;border-radius:4px;background:var(--bt-hover);border:1px solid var(--bt-line);color:var(--bt-ink-2);white-space:nowrap;max-width:84px;overflow:hidden;text-overflow:ellipsis} .bt-row{display:flex;align-items:center;gap:8px;min-height:54px;padding:8px;border:0;border-radius:10px;background:transparent;color:var(--bt-ink);text-align:left;cursor:pointer;width:100%;box-sizing:border-box;font-family:inherit;transition:background-color .12s ease} -@@ -1950,21 +1950,23 @@ body[data-bt-motion=quiet] .bt-mark[data-poke]{cursor:default} +@@ -1950,21 +1950,23 @@ .bt-row-body{display:flex;flex-direction:column;min-width:0;flex:1 1 auto} .bt-row-name{display:flex;align-items:center;gap:6px;min-width:0;font-size:14px;line-height:20px;font-weight:400} .bt-row-title{min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} @@ -2793,7 +2483,7 @@ diff --git a/client.js b/client.js .bt-head{position:relative;display:flex;justify-content:center;align-items:center;height:40px;flex:none;pointer-events:none} .bt-pill{pointer-events:auto;display:inline-flex;align-items:center;gap:8px;height:40px;padding:7.5px 13.5px 7.5px 7.5px;border-radius:999px;background:var(--bt-main);border:.5px solid var(--bt-line-2);box-shadow:0 2px 8px -1px rgba(0,0,0,.06),0 1px 2px rgba(0,0,0,.04);color:var(--bt-ink);font-family:inherit;font-size:14px;line-height:20px;cursor:pointer;max-width:70%;box-sizing:border-box;transition:background-color .12s ease,transform ${SPRING_TAP.duration}ms ${SPRING_TAP.easing}} -@@ -1992,6 +1992,7 @@ body[data-bt-motion=quiet] .bt-mark[data-poke]{cursor:default} +@@ -1992,6 +1992,7 @@ .bt-astack{display:flex;flex-direction:column;align-items:flex-start;gap:4px;margin:0} .bt-bubble{max-width:min(80%,560px,calc(100% - 82px));background:var(--bt-bubble);color:var(--bt-ink);border-radius:18px;padding:7px 12px;font-size:var(--dsh-content-font-size,14px);line-height:calc(var(--dsh-content-font-size,14px) + 8px);box-sizing:border-box;overflow-wrap:anywhere} @@ -2801,7 +2491,7 @@ diff --git a/client.js b/client.js .bt-msg:not(:first-child)>.bt-msg-line>.bt-bubble{border-start-start-radius:6px} .bt-msg:not(:last-child)>.bt-msg-line>.bt-bubble{border-end-start-radius:6px} .bt-bubble [class*="_markdown"]>*+*{margin-top:10px} -@@ -2085,6 +2085,7 @@ button.bt-q-opt:active{background:var(--bt-active)} +@@ -2085,6 +2085,7 @@ @media (prefers-reduced-motion:reduce){.bt-q{animation:bt-fade .12s ease-out backwards}.bt-q-settled{animation:none}.bt-q-submit{animation:none}} .bt-newchat{flex:1;display:flex;flex-direction:column;min-width:0;position:relative;animation:bt-veil-in .18s ease-out} @@ -2809,7 +2499,7 @@ diff --git a/client.js b/client.js .bt-to{display:flex;align-items:center;flex-wrap:wrap;gap:6px;min-height:48px;padding:6px 12px;border-bottom:1px solid var(--bt-line);font-size:14px;box-sizing:border-box} .bt-to-label{color:var(--bt-ink-2)} .bt-to input{flex:1;min-width:120px;border:0;outline:none;background:none;color:var(--bt-ink);font-size:14px;height:26px} -@@ -2098,7 +2098,9 @@ button.bt-q-opt:active{background:var(--bt-active)} +@@ -2098,7 +2098,9 @@ .bt-option[aria-selected=true]{background:var(--bt-active)} .bt-drop-list>.bt-option:not([aria-selected=true]):hover{background:var(--bt-hover)} .bt-option-label{flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} @@ -2820,7 +2510,7 @@ diff --git a/client.js b/client.js .bt-option-icon{width:22px;height:22px;border-radius:50%;background:var(--bt-hover);color:var(--bt-ink-2);display:inline-flex;align-items:center;justify-content:center;flex:none} .bt-hint{padding:14px;text-align:center;font-size:13px;color:var(--bt-ink-2)} .bt-nc-spacer{flex:1} -@@ -2127,6 +2127,10 @@ button.bt-q-opt:active{background:var(--bt-active)} +@@ -2127,6 +2127,10 @@ .bt-send{height:40px;min-width:40px;border-radius:999px;border:0;background:var(--bt-accent);color:var(--bt-accent-ink,#fff);font-size:14px;padding:0 14px;cursor:pointer} .bt-send:disabled{opacity:.35;cursor:default} .bt-error{padding:0 14px 10px;color:#e02135;font-size:12px} @@ -2831,7 +2521,7 @@ diff --git a/client.js b/client.js .bt-pane{position:fixed;top:var(--dsh-frame-chrome-top,0px);right:0;bottom:0;z-index:45;pointer-events:auto;background:var(--bt-main);color:var(--bt-ink);display:flex;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif} .bt-dialog{flex:1;display:flex;flex-direction:column;min-width:0} -@@ -2163,6 +2163,8 @@ button.bt-q-opt:active{background:var(--bt-active)} +@@ -2163,6 +2163,8 @@ .bt-drawer-id{display:flex;flex-direction:column;align-items:center;padding:14px 16px 0} .bt-drawer-id>.bt-mark,.bt-drawer-id>.bt-cluster{margin-bottom:16px} .bt-drawer-name{font-size:17px;line-height:24px;font-weight:500;border:0;background:none;color:inherit;cursor:text;padding:0 6px;border-radius:6px;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} @@ -2840,7 +2530,7 @@ diff --git a/client.js b/client.js .bt-drawer-name:hover{background:var(--bt-hover)} .bt-drawer-name-input{font-size:17px;line-height:24px;text-align:center;border:.5px solid var(--bt-line-2);border-radius:6px;padding:0 6px;background:var(--bt-main);color:var(--bt-ink);outline:none;font-weight:500} .bt-drawer-role{font-size:12px;line-height:16px;color:var(--bt-ink-3);margin-top:6px;text-align:center} -@@ -2255,6 +2255,7 @@ body[data-ds-dark-theme] .bt-scrim{background:rgba(0,0,0,.5)} +@@ -2255,6 +2255,7 @@ .bt-settings-nav>.bt-thumb{left:8px;right:8px;border-radius:8px;background:var(--bt-active)} .bt-settings-nav[data-glide] .bt-settings-item[aria-current=page]{background:transparent} .bt-settings-shell{margin-top:auto} @@ -2848,7 +2538,7 @@ diff --git a/client.js b/client.js /* Usage while another page is open: out of the flow at Usage's own width, so it keeps its layout, and not rendered. */ .bt-settings-usage[data-off]{position:absolute;top:0;left:198px;width:calc(min(1100px,calc(100vw - 48px)) - 198px);height:100%;box-sizing:border-box;content-visibility:hidden;pointer-events:none} -@@ -2288,6 +2288,8 @@ button.bt-set-row:hover{background:var(--bt-hover)} +@@ -2288,6 +2288,8 @@ .bt-set-card>.bt-set-row+.bt-set-row{border-top:.5px solid var(--bt-line)} .bt-set-row:first-child{border-top-left-radius:12px;border-top-right-radius:12px} .bt-set-row:last-child{border-bottom-left-radius:12px;border-bottom-right-radius:12px} @@ -2857,7 +2547,7 @@ diff --git a/client.js b/client.js .bt-set-copy{flex:1;display:flex;flex-direction:column;gap:2px;min-width:0} .bt-set-hint{font-size:12px;line-height:16px;color:color-mix(in srgb,var(--bt-ink-2) 50%,var(--bt-ink-3))} .bt-set-control{flex:none;display:flex;align-items:center;gap:6px} -@@ -2308,8 +2308,17 @@ button.bt-set-row:hover{background:var(--bt-hover)} +@@ -2308,8 +2308,17 @@ .bt-connector-actions{flex:none;display:flex;gap:6px} .bt-connector-form{display:flex;flex-direction:column;gap:8px;padding-left:48px;animation:bt-q-in ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing},bt-veil-in .2s ease-out} .bt-connector-form-row{display:flex;align-items:center;gap:6px} @@ -2875,7 +2565,7 @@ diff --git a/client.js b/client.js /* Shell chrome reshaped for the team view. Selectors use stable data attributes where the shell offers them and CSS-module name suffixes elsewhere. */ -@@ -2427,7 +2427,8 @@ button:hover .bt-you{background:color-mix(in srgb,var(--bt-ink) 9%,var(--bt-side +@@ -2427,7 +2427,8 @@ .bt-palette-empty-label{font-size:14px;font-weight:500;color:var(--bt-ink-2)} .bt-palette-empty-hint{font-size:13px;line-height:16px;color:var(--bt-ink-3)} @media (prefers-reduced-motion:reduce){.bt-palette{animation:bt-fade .1s ease-out}} @@ -2885,7 +2575,7 @@ diff --git a/client.js b/client.js .bt-account{flex:none;width:36px;height:36px;border:0;padding:0;background:none;border-radius:50%;cursor:pointer;display:inline-flex} .bt-account:hover .bt-you,.bt-account[aria-expanded=true] .bt-you{background:color-mix(in srgb,var(--bt-ink) 9%,var(--bt-sidebar))} /* Popover keyframes move translate and scale, which compose with a transform the popover -@@ -2599,6 +2599,14 @@ var CONVERSATION_CSS = ` +@@ -2599,6 +2599,14 @@ .bt-think{font-size:13px;line-height:18px;color:var(--bt-ink-2);margin:2px 0 4px;max-width:min(560px,78%)} .bt-think summary{cursor:pointer;list-style:none;display:inline-flex;align-items:center;gap:6px} .bt-think summary::-webkit-details-marker{display:none} @@ -2900,7 +2590,7 @@ diff --git a/client.js b/client.js .bt-think-text{white-space:pre-wrap;margin-top:6px;padding-left:10px;border-left:2px solid var(--bt-line-2)} .bt-event{display:flex;justify-content:center;align-items:center;gap:2px;min-height:24px;margin:2px 0;font-size:12px;line-height:16px;color:var(--bt-ink-2);flex-wrap:wrap} .bt-event>span{padding:0 2px} -@@ -2612,7 +2612,6 @@ var CONVERSATION_CSS = ` +@@ -2612,7 +2612,6 @@ .bt-event-whole>.bt-event-name{flex:0 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .bt-time-sep{display:flex;justify-content:center;align-items:center;height:28px;margin:14px 0 8px;font-size:12px;line-height:16px;color:var(--bt-ink-2);white-space:nowrap} [class*="_scroll"]>[class*="_column"]::before{content:var(--bt-first-sep,none);display:block;flex:none;height:28px;margin:17px 0 12px;font-size:12px;line-height:28px;text-align:center;color:var(--bt-ink-2);white-space:nowrap} @@ -2908,7 +2598,7 @@ diff --git a/client.js b/client.js .bt-group-replies{display:contents} /* A Bot chat is flat in every work-details mode, without changing the user's mode (it is a DSH-wide setting): step groups lose their header and stay open, and what a -@@ -2622,21 +2622,23 @@ var CONVERSATION_CSS = ` +@@ -2622,21 +2622,23 @@ [data-step-process]>[data-step-process-body]{display:block!important;content-visibility:visible!important;max-height:none!important;overflow:visible!important;mask-image:none!important;scrollbar-gutter:auto!important} :is([data-step-process],[data-turn-process-hidden])[hidden]{content-visibility:visible!important} [class*="_flowItem"]:has(> [data-slot="conversation.chat.node"]:empty){display:none!important} @@ -2940,7 +2630,7 @@ diff --git a/client.js b/client.js [data-content-phase=hero] [class*="_composerHero"]{align-self:stretch!important;width:auto!important;padding-bottom:0!important} [data-content-phase=hero] [class*="_composerHero"]>[class$="_root"],[data-content-phase=hero] [class*="_heroWorkspaceRow"]{display:none!important} [data-content-phase=hero] [class*="_composerHero"] [class*="_input"]{min-height:0!important} -@@ -2648,6 +2648,9 @@ header[class*="_header"] .bt-head{flex:1 1 auto;width:100%} +@@ -2648,6 +2648,9 @@ [data-chat-running]:has(> .bt-typing){margin-top:var(--dsh-chat-flow-gap,12px)!important} .bt-typing{display:flex;align-items:center;gap:12px;height:36px;min-width:0;padding-inline-start:8px;transform-origin:0 50%;animation:bt-typing-in ${SPRING_TAP.duration}ms ${SPRING_TAP.easing} backwards} .bt-typing[data-exiting]{animation:bt-typing-out .14s cubic-bezier(.22,1,.36,1) forwards} @@ -2950,7 +2640,7 @@ diff --git a/client.js b/client.js .bt-typing>.bt-mark,.bt-typing>.bt-cluster{flex-shrink:0} .bt-typing>.bt-mark{cursor:pointer;transition:filter .16s ease-out} .bt-typing>.bt-mark:hover{filter:drop-shadow(0 2px 4px rgba(0,0,0,.18))} -@@ -2679,7 +2679,8 @@ header[class*="_header"] .bt-head{flex:1 1 auto;width:100%} +@@ -2679,7 +2679,8 @@ @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} @@ -2960,7 +2650,7 @@ diff --git a/client.js b/client.js [data-composer-card]:has(> [data-slot="conversation.input.attachments"] *),[data-composer-card]:has([data-composer-input] br + *),[data-composer-card]:has([data-composer-input] > :nth-child(2)){border-radius:18px!important} [class*="_root"]:has(> [data-composer-card]){padding:0 11px 13px 16px!important} [class*="_root"]:has(> [data-composer-card])>[data-composer-card]{max-width:min(992px,100%)!important} -@@ -2691,6 +2691,8 @@ header[class*="_header"] .bt-head{flex:1 1 auto;width:100%} +@@ -2691,6 +2691,8 @@ [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} @@ -2969,7 +2659,7 @@ diff --git a/client.js b/client.js [data-composer-placeholder]::after{content:var(--bt-placeholder,"Message");display:block;font-size:14px;line-height:24px;color:color-mix(in srgb,var(--bt-ink) 36%,transparent)} /* The team's "+" (ComposerPlus) takes the seat of the shell's command-menu button. */ [data-composer-card] button[class*="_add"]{display:none!important} -@@ -2722,6 +2722,7 @@ header[class*="_header"] .bt-head{flex:1 1 auto;width:100%} +@@ -2722,6 +2722,7 @@ .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} @@ -2977,7 +2667,7 @@ diff --git a/client.js b/client.js body[data-bt-room] [data-composer-card]:has(button[class*="_primary"]:disabled) .bt-mic:not([aria-pressed=true]){background:var(--bt-accent);color:var(--bt-accent-ink,#fff)} .bt-mic-note{position:absolute;right:12px;bottom:calc(100% + 8px);padding:5px 10px;border-radius:8px;background:var(--bt-user);color:var(--bt-user-ink);font-size:12px;line-height:16px;white-space:nowrap;pointer-events:none;animation:bt-rise .16s ease-out} [data-chain-overlay-fallback="conversation.composer"]:has(~ .bt-q){display:block!important;order:2} -@@ -2776,6 +2776,7 @@ div:has(> nav [class*="_marks"]){display:none!important} +@@ -2776,6 +2776,7 @@ .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} @@ -2985,7 +2675,7 @@ diff --git a/client.js b/client.js .bt-ububble{max-width:min(560px,78%);background:var(--bt-user);color:var(--bt-user-ink);border-radius:18px;padding:7px 12px;font-size:14px;line-height:20px;box-sizing:border-box;overflow-wrap:anywhere} .bt-ububble [class*="_markdown"],.bt-ububble a{color:inherit!important} .bt-ububble p{margin:0;line-height:22px} -@@ -2855,226 +2855,203 @@ function installCompiledStyles(text, tag) { +@@ -2855,226 +2855,203 @@ }; } @@ -3025,7 +2715,12 @@ diff --git a/client.js b/client.js - document.documentElement.removeEventListener("pointerleave", onLeave); - }; - }, [enabled]); --} ++// src/shared/face-title.js ++var MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; ++function isGenericFaceTitle(name) { ++ const n = String(name ?? "").trim(); ++ return !n || n === "Face-to-face room"; + } -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); @@ -3055,24 +2750,58 @@ 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 formatFaceWhen(value) { ++ const date = value instanceof Date ? value : new Date(value); ++ if (!Number.isFinite(date.getTime())) return ""; ++ const hh = String(date.getHours()).padStart(2, "0"); ++ const mm = String(date.getMinutes()).padStart(2, "0"); ++ return `${MONTHS[date.getMonth()]} ${date.getDate()} ${hh}:${mm}`; + } -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 isAutoFaceTitle(name) { ++ const n = String(name ?? "").trim(); ++ return isGenericFaceTitle(n) || n.startsWith("Face-to-face · "); + } -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 ownersFromMembers(members) { ++ const rows = []; ++ const seen = /* @__PURE__ */ new Set(); ++ for (const member of members ?? []) { ++ const owner = String(member?.ownerName ?? member?.owner_name ?? "").trim(); ++ const key = owner.toLowerCase(); ++ if (!owner || seen.has(key)) continue; ++ seen.add(key); ++ const handle = String(member?.handle ?? member?.id ?? "").trim().toLowerCase(); ++ rows.push({ owner, sort: handle || key }); ++ } ++ rows.sort((a, b) => a.sort.localeCompare(b.sort)); ++ return rows.slice(0, 3).map((row) => row.owner); + } -function MainStar() { - return (0, import_react2.createElement)( - "svg", - { viewBox: "0 0 20 20", "aria-hidden": true }, - (0, import_react2.createElement)("path", { d: "M10 2.4l2.29 5.04 5.51.63-4.09 3.74 1.11 5.42L10 14.5l-4.82 2.73 1.11-5.42L2.2 8.07l5.51-.63z" }) - ); --} ++function uniqueOwners(owners) { ++ const seen = /* @__PURE__ */ new Set(); ++ const out = []; ++ for (const raw of owners ?? []) { ++ const owner = String(raw ?? "").trim(); ++ const key = owner.toLowerCase(); ++ if (!owner || seen.has(key)) continue; ++ seen.add(key); ++ out.push(owner); ++ } ++ out.sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase())); ++ return out.slice(0, 3); + } -function useSettled(state) { - const prev = (0, import_react2.useRef)(state); - const [settled, setSettled] = (0, import_react2.useState)(false); @@ -3089,36 +2818,8 @@ diff --git a/client.js b/client.js - return () => clearTimeout(timer); - }, [state]); - return settled; -+// src/shared/face-title.js -+var MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; -+function isGenericFaceTitle(name) { -+ const n = String(name ?? "").trim(); -+ return !n || n === "Face-to-face room"; -+} -+function formatFaceWhen(value) { -+ const date = value instanceof Date ? value : new Date(value); -+ if (!Number.isFinite(date.getTime())) return ""; -+ const hh = String(date.getHours()).padStart(2, "0"); -+ const mm = String(date.getMinutes()).padStart(2, "0"); -+ return `${MONTHS[date.getMonth()]} ${date.getDate()} ${hh}:${mm}`; -+} -+function isAutoFaceTitle(name) { -+ const n = String(name ?? "").trim(); -+ return isGenericFaceTitle(n) || n.startsWith("Face-to-face · "); -+} -+function ownersFromMembers(members) { -+ const rows = []; -+ const seen = /* @__PURE__ */ new Set(); -+ for (const member of members ?? []) { -+ const owner = String(member?.ownerName ?? member?.owner_name ?? "").trim(); -+ const key = owner.toLowerCase(); -+ if (!owner || seen.has(key)) continue; -+ seen.add(key); -+ const handle = String(member?.handle ?? member?.id ?? "").trim().toLowerCase(); -+ rows.push({ owner, sort: handle || key }); -+ } -+ rows.sort((a, b) => a.sort.localeCompare(b.sort)); -+ return rows.slice(0, 3).map((row) => row.owner); ++function joinCodeOf(code) { ++ return String(code ?? "").replace(/\D/g, "").slice(0, 6); } -function BotAvatar({ bot, size, state = "idle", main, admin, badge = true, live = true, gaze = false, pokeable = false }) { - const settled = useSettled(state); @@ -3133,39 +2834,12 @@ diff --git a/client.js b/client.js - 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 uniqueOwners(owners) { -+ const seen = /* @__PURE__ */ new Set(); -+ const out = []; -+ for (const raw of owners ?? []) { -+ const owner = String(raw ?? "").trim(); -+ const key = owner.toLowerCase(); -+ if (!owner || seen.has(key)) continue; -+ seen.add(key); -+ out.push(owner); -+ } -+ out.sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase())); -+ return out.slice(0, 3); -+} -+function joinCodeOf(code) { -+ return String(code ?? "").replace(/\D/g, "").slice(0, 6); -+} +var FACE_JOIN_WINDOW_MS = 10 * 6e4; +function faceRoomCreatedAt({ createdAt, expiresAt, startedAt } = {}) { + if (createdAt && Number.isFinite(Date.parse(createdAt))) return createdAt; + const deadline = Date.parse(expiresAt); + if (Number.isFinite(deadline)) return new Date(deadline - FACE_JOIN_WINDOW_MS).toISOString(); + return startedAt || void 0; -+} -+function formatFaceRoomTitle({ name, owners, members, code, at } = {}) { -+ const named = String(name ?? "").trim(); -+ if (named && !isAutoFaceTitle(named)) return named; -+ const people2 = members?.length ? ownersFromMembers(members) : uniqueOwners(owners); -+ const join = joinCodeOf(code); -+ const when3 = formatFaceWhen(at) || formatFaceWhen(/* @__PURE__ */ new Date()); -+ const bits = ["Face-to-face"]; -+ if (people2.length) bits.push(people2.join(", ")); -+ bits.push(join.length === 6 ? join : when3); -+ return bits.join(" · "); } -function clusterSeats(count, frame) { - if (count === 1) return [[0, 0, frame]]; @@ -3174,6 +2848,17 @@ diff --git a/client.js b/client.js - const size = frame * 5 / 9; - const rest = frame - size; - return [[rest / 2, 0, size], [0, rest, size], [rest, rest, size]]; ++function formatFaceRoomTitle({ name, owners, members, code, at } = {}) { ++ const named = String(name ?? "").trim(); ++ if (named && !isAutoFaceTitle(named)) return named; ++ const people2 = members?.length ? ownersFromMembers(members) : uniqueOwners(owners); ++ const join = joinCodeOf(code); ++ const when3 = formatFaceWhen(at) || formatFaceWhen(/* @__PURE__ */ new Date()); ++ const bits = ["Face-to-face"]; ++ if (people2.length) bits.push(people2.join(", ")); ++ bits.push(join.length === 6 ? join : when3); ++ return bits.join(" · "); ++} + +// src/client/brand.js +var FLYBOT_WING = "M150 238 C 96 200, 40 196, 22 214 C 8 230, 40 262, 150 300 Z"; @@ -3364,7 +3049,7 @@ diff --git a/client.js b/client.js ); } var SmileIcon = lineIcon(["M10 3.5a6.5 6.5 0 1 1 0 13 6.5 6.5 0 0 1 0-13z", "M7.3 11.6a3.4 3.4 0 0 0 5.4 0", "M7.75 8.25h.01M12.25 8.25h.01"]); -@@ -3093,14 +3093,14 @@ var ClockIcon = lineIcon(["M10 3.5a6.5 6.5 0 1 1 0 13 6.5 6.5 0 0 1 0-13z", "M10 +@@ -3093,14 +3093,14 @@ var GearIcon = lineIcon(["M10 7.4a2.6 2.6 0 1 1 0 5.2 2.6 2.6 0 0 1 0-5.2z", "M7.85 4.4l.29-1.87a7.7 7.7 0 0 1 3.72 0l.29 1.87a6 6 0 0 1 1.63.94l1.76-.69a7.7 7.7 0 0 1 1.86 3.23l-1.47 1.18a6 6 0 0 1 0 1.88l1.47 1.18a7.7 7.7 0 0 1-1.86 3.23l-1.76-.69a6 6 0 0 1-1.63.94l-.29 1.87a7.7 7.7 0 0 1-3.72 0l-.29-1.87a6 6 0 0 1-1.63-.94l-1.76.69a7.7 7.7 0 0 1-1.86-3.23l1.47-1.18a6 6 0 0 1 0-1.88L2.6 7.88a7.7 7.7 0 0 1 1.86-3.23l1.76.69a6 6 0 0 1 1.63-.94z"]); var PinIcon = lineIcon(["M12.5 3l4.5 4.5-3 1.5-2.5 2.5.5 3.5-1.5 1.5L7 13 3.5 16.5", "M7 13l-3.5-3.5L5 8l3.5.5L11 6l1.5-3"]); function PinMarkIcon() { @@ -3383,7 +3068,7 @@ diff --git a/client.js b/client.js ) ); } -@@ -3112,21 +3112,48 @@ var CopyIcon = lineIcon(["M8.5 7h6A1.5 1.5 0 0 1 16 8.5v6a1.5 1.5 0 0 1-1.5 1.5h +@@ -3112,21 +3112,48 @@ var HideIcon = lineIcon(["M3 3l14 14", "M8.6 5.2A7.6 7.6 0 0 1 10 5c4.5 0 7 5 7 5a12.6 12.6 0 0 1-2 2.6M12.2 14.5A6.8 6.8 0 0 1 10 15c-4.5 0-7-5-7-5a12.4 12.4 0 0 1 3-3.4", "M8.5 8.5a2 2 0 0 0 3 3"]); var TrashIcon = lineIcon(["M3.5 5.5h13", "M8 5.5V4a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1v1.5", "M5 5.5l.7 10A1.5 1.5 0 0 0 7.2 17h5.6a1.5 1.5 0 0 0 1.5-1.5l.7-10", "M8.5 9v4.5M11.5 9v4.5"]); var DuplicateIcon = lineIcon(["M7 7h8.5v9.5H7z", "M4.5 13V3.5H13", "M11.25 9.5v4.5M9 11.75h4.5"]); @@ -3435,7 +3120,7 @@ diff --git a/client.js b/client.js if (count === 0) return void 0; const onKey = (event) => { if (event.defaultPrevented || event.metaKey || event.ctrlKey || event.altKey || event.shiftKey || event.key.length !== 1) return; -@@ -3146,10 +3146,10 @@ function useOptionKeys(count, onPick, ownRef) { +@@ -3146,10 +3146,10 @@ }, [count, ownRef]); } function QuestionView({ question, onAnswer, onDismiss, ownAnswer = false, raised = false }) { @@ -3450,7 +3135,7 @@ diff --git a/client.js b/client.js const options = optionsOf(question); const multi = question.multiSelect === true; const custom = own.trim(); -@@ -3167,7 +3167,7 @@ function QuestionView({ question, onAnswer, onDismiss, ownAnswer = false, raised +@@ -3167,7 +3167,7 @@ }; useOptionKeys(options.length, (index) => choose(options[index].label), ownRef); const ownLetter = options.length > 0 ? keyLetter(options.length) : void 0; @@ -3459,7 +3144,7 @@ diff --git a/client.js b/client.js ref: ownRef, rows: 1, value: own, -@@ -3186,7 +3186,7 @@ function QuestionView({ question, onAnswer, onDismiss, ownAnswer = false, raised +@@ -3186,7 +3186,7 @@ }) : null; const rows = options.map((option, optionIndex) => { const selected = multi && picked.includes(option.label); @@ -3468,7 +3153,7 @@ diff --git a/client.js b/client.js "button", { key: option.label, -@@ -3196,36 +3196,36 @@ function QuestionView({ question, onAnswer, onDismiss, ownAnswer = false, raised +@@ -3196,36 +3196,36 @@ "aria-keyshortcuts": keyLetter(optionIndex).toLowerCase(), onClick: () => choose(option.label) }, @@ -3518,7 +3203,7 @@ diff --git a/client.js b/client.js "form", { className: raised ? "bt-q bt-q-raised" : "bt-q", -@@ -3235,24 +3235,24 @@ function QuestionView({ question, onAnswer, onDismiss, ownAnswer = false, raised +@@ -3235,24 +3235,24 @@ submit(); } }, @@ -3553,7 +3238,7 @@ diff --git a/client.js b/client.js ) : null, foot ); -@@ -3262,29 +3262,29 @@ function SettledQuestion({ question, answer }) { +@@ -3262,29 +3262,29 @@ const prompt = question?.question ?? ""; const chosen = [...answer.selected ?? [], ...answer.custom ? [answer.custom] : []]; if (chosen.length === 0) { @@ -3594,7 +3279,7 @@ diff --git a/client.js b/client.js ); })) ); -@@ -3292,8 +3292,8 @@ function SettledQuestion({ question, answer }) { +@@ -3292,8 +3292,8 @@ function QuestionCard({ matched }) { const pending = matched; const questions = pending.questions ?? []; @@ -3605,7 +3290,7 @@ diff --git a/client.js b/client.js const question = questions[index]; if (question === void 0) return null; const onAnswer = (answer) => { -@@ -3305,10 +3305,10 @@ function QuestionCard({ matched }) { +@@ -3305,10 +3305,10 @@ } pending.answer({ answers: next }); }; @@ -3618,7 +3303,7 @@ diff --git a/client.js b/client.js if (answered === question.id) return null; const onAnswer = (answer) => { setAnswered(question.id); -@@ -3318,10 +3318,10 @@ function PendingQuestion({ sessionId, question, actions, inline }) { +@@ -3318,10 +3318,10 @@ setAnswered(question.id); void actions.dismissQuestion(sessionId); }; @@ -3631,7 +3316,7 @@ diff --git a/client.js b/client.js ); } function QuestionDock({ sessionId, useRoster, useCards, useSession, actions }) { -@@ -3329,16 +3329,16 @@ function QuestionDock({ sessionId, useRoster, useCards, useSession, actions }) { +@@ -3329,16 +3329,16 @@ const drawn = useCards?.((state) => state[sessionId]?.[question?.callId] > 0) === true; const loading = useSession?.(historyLoading) === true; if (question === void 0 || question.callId && (drawn || loading)) return null; @@ -3651,7 +3336,7 @@ diff --git a/client.js b/client.js key: question.id, question, answer: answers.find((answer) => answer.id === question.id) ?? { selected: [] } -@@ -3346,37 +3346,37 @@ function AnsweredCell({ node }) { +@@ -3346,37 +3346,37 @@ } // src/client/secret-card.js @@ -3701,7 +3386,7 @@ diff --git a/client.js b/client.js className: "bt-secret-input", type: MASK_BY_CSS ? "text" : "password", "data-masked": MASK_BY_CSS ? "" : void 0, -@@ -3401,12 +3401,12 @@ function MaskedInput({ value, onChange, onEnter, label, autoFocus = false }) { +@@ -3401,12 +3401,12 @@ }); } function ScopeSwitch({ value, onChange, bot }) { @@ -3718,7 +3403,7 @@ diff --git a/client.js b/client.js key: id, type: "button", role: "radio", -@@ -3417,26 +3417,26 @@ function ScopeSwitch({ value, onChange, bot }) { +@@ -3417,26 +3417,26 @@ ); } function SecretHead({ bot, title, detail, name, onClose }) { @@ -3757,7 +3442,7 @@ diff --git a/client.js b/client.js const secret = (roster.secrets ?? []).find((entry) => entry.name === request.name); const run = (promise) => { setBusy(true); -@@ -3452,73 +3452,73 @@ function SecretCard({ bot, request, roster, actions }) { +@@ -3452,73 +3452,73 @@ if (!busy) void run(actions.secretAllow(request.id)); }; const filling = request.kind === "fill" || replacing; @@ -3854,7 +3539,7 @@ diff --git a/client.js b/client.js } return null; } -@@ -3531,44 +3531,46 @@ function SecretDock({ sessionId, useRoster, useCards, useSession, actions }) { +@@ -3531,44 +3531,46 @@ const drawn = (entry) => entry.callId && entry.sessionId === sessionId && (loading || shown[entry.callId] > 0); const waiting = (roster.secretRequests?.[bot.id] ?? []).filter((entry) => entry.status === "pending" && !drawn(entry)); if (waiting.length === 0) return null; @@ -3916,7 +3601,7 @@ diff --git a/client.js b/client.js const long = value.trim().length >= MIN_VALUE; const save = () => { if (!long) return; -@@ -3583,66 +3583,66 @@ function ValueEditor({ secret, onSave }) { +@@ -3583,66 +3583,66 @@ setValue(next); setReplaced(false); }; @@ -4009,7 +3694,7 @@ diff --git a/client.js b/client.js const ready = /^[A-Za-z][\w-]{0,63}$/.test(name.trim()) && value.trim().length >= MIN_VALUE; const save = () => { if (!ready) return; -@@ -3653,43 +3653,43 @@ function AddSecret({ actions, run, onDone }) { +@@ -3653,43 +3653,43 @@ } }); }; @@ -4071,7 +3756,7 @@ diff --git a/client.js b/client.js ]; } var SECRET_CSS = ` -@@ -3756,6 +3756,142 @@ var SECRET_CSS = ` +@@ -3756,6 +3756,142 @@ @media (prefers-reduced-motion:reduce){.bt-secret-glide{transition:none}.bt-secret-check,.bt-secret-done,.bt-secret-ok,.bt-secret-edit{animation:none}} `; @@ -4214,7 +3899,7 @@ diff --git a/client.js b/client.js // src/client/turn-reasoning.js function turnReasoning(steps, current) { const members = steps.length ? steps : [current]; -@@ -3767,7 +3767,7 @@ var thinkingLive = (step) => step.status === "running" && texts(step).length === +@@ -3767,7 +3767,7 @@ var stepReasoning = (step) => step.blocks.filter((block) => block.kind === "reasoning").map((block) => block.text).join("\n").trim(); // src/client/cells.js @@ -4223,7 +3908,7 @@ diff --git a/client.js b/client.js var import_react_dom = require("react-dom"); var import_dsh_client_ui_primitives = require("@deepseek-ai/dsh-client-ui-primitives"); var mdLabels = () => ({ code: { copyLabel: t("Copy"), copiedLabel: t("Copied") }, footnotes: t("Footnotes") }); -@@ -3780,7 +3780,7 @@ function Bubbles({ texts: texts2, roster, selfId, actions, streaming, sessionId, +@@ -3780,7 +3780,7 @@ actions.openSession(target); }; const reactions = useUi ? useUi((state) => msgKey ? state.reactions?.[msgKey] ?? null : null) : null; @@ -4232,7 +3917,7 @@ diff --git a/client.js b/client.js "div", { className: "bt-astack", "data-lead": lead ? "" : void 0, onClickCapture: onClick }, texts2.map((text, index) => { -@@ -3788,21 +3788,21 @@ function Bubbles({ texts: texts2, roster, selfId, actions, streaming, sessionId, +@@ -3788,21 +3788,21 @@ const picked = key ? reactions?.[index] ?? [] : []; const last = index === texts2.length - 1; const live = streaming && last; @@ -4260,7 +3945,7 @@ diff --git a/client.js b/client.js key: emoji, type: "button", className: "bt-react", -@@ -3813,9 +3813,10 @@ function Bubbles({ texts: texts2, roster, selfId, actions, streaming, sessionId, +@@ -3813,9 +3813,10 @@ }) ); } @@ -4272,7 +3957,7 @@ diff --git a/client.js b/client.js if (!open) return void 0; const onDown = (event) => { if (refs.some((ref) => ref.current?.contains(event.target))) return; -@@ -3836,11 +3836,11 @@ function useDismiss(open, close, refs) { +@@ -3836,11 +3836,11 @@ }, [open]); } function MessageTools({ text, name, sessionId, react, actions }) { @@ -4289,7 +3974,7 @@ diff --git a/client.js b/client.js const close = () => setPanel(null); useDismiss(panel !== null, close, [tools, pop]); const open = (kind, event) => { -@@ -3865,915 +3865,1954 @@ function MessageTools({ text, name, sessionId, react, actions }) { +@@ -3865,25 +3865,25 @@ link.click(); setTimeout(() => URL.revokeObjectURL(url), 1e3); }; @@ -4321,32 +4006,35 @@ diff --git a/client.js b/client.js ), document.body) : null, - panel === "more" ? (0, import_react_dom.createPortal)((0, import_react6.createElement)( + panel === "more" ? (0, import_react_dom.createPortal)((0, import_react8.createElement)( -+ "div", -+ { ref: pop, className: "bt-menu", role: "menu", "aria-label": t("Message actions"), style: place }, -+ item(t("Copy"), CopyIcon, copy2), -+ item(t("Download"), DownloadIcon, download2) -+ ), document.body) : null -+ ); -+} -+function ThinkingRow({ text }) { + "div", + { ref: pop, className: "bt-menu", role: "menu", "aria-label": t("Message actions"), style: place }, + item(t("Copy"), CopyIcon, copy2), +@@ -3892,11 +3892,12 @@ + ); + } + function ThinkingRow({ text }) { +- return (0, import_react6.createElement)( + if (typeof text !== "string" || text.trim() === "") return null; + return (0, import_react8.createElement)( -+ "details", -+ { className: "bt-think" }, + "details", + { className: "bt-think" }, +- (0, import_react6.createElement)("summary", null, t("Thought it through")), +- (0, import_react6.createElement)("div", { className: "bt-think-text" }, text) + (0, import_react8.createElement)("summary", null, (0, import_react8.createElement)("span", { className: "bt-think-caret", "aria-hidden": true }, "›"), t("Thought it through")), + (0, import_react8.createElement)("div", { className: "bt-think-text" }, text) -+ ); -+} -+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; + ); + } + function useFileMentions({ node, openFile, fileMentions, useTurnData }) { +@@ -3904,19 +3904,33 @@ + const location2 = node.location; + const turn = location2?.kind === "turn" || location2?.kind === "step" ? location2.turn : void 0; + const seq = node.data.finalNode?.seq; +- return (0, import_react6.useMemo)(() => { + return (0, import_react8.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]); -+} + 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; @@ -4358,41 +4046,45 @@ diff --git a/client.js b/client.js + if (texts2.every((text) => /^Not sent:/.test(text.trim()))) return void 0; + return (0, import_react8.createElement)("span", { className: "bt-lead", title: bot.name, "data-flybot": "friend-lead" }, (0, import_react8.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)); -+ const mentions = useFileMentions({ node, openFile, fileMentions, useTurnData }); + function AssistantCell({ node, groupPart, sessionId, openFile, fileMentions, useTurnData, useRoster, useUi, useTurnSteps, useTurnContext, actions }) { + const roster = useRoster((value) => value); + const data = node.data; + const steps = useTurnSteps(String(data.turn)); + const contexts = useTurnContext(String(data.turn)); + const mentions = useFileMentions({ node, openFile, fileMentions, useTurnData }); + const held = groupPart === "reasoning" ? void 0 : heldReplyOf(roster.byId[sessionId], sessionId, data.turn); + const closing = useClosingText(node, useTurnData); -+ const hidden = Boolean(roster.roomsById[sessionId]) || isHiddenTurn(roster, sessionId, data.turn); + const hidden = Boolean(roster.roomsById[sessionId]) || isHiddenTurn(roster, sessionId, data.turn); +- const texts2 = groupPart === "reasoning" ? [] : data.blocks.filter((block) => block.kind === "text" && block.text.trim() !== "").map((block) => block.text); + const person = typedBy(roster, sessionId, data.turn); + const texts2 = groupPart === "reasoning" ? [] : data.blocks.filter((block) => block.kind === "text" && block.text.trim() !== "").map((block, index) => index === 0 ? stripTrace(block.text) : block.text).filter((text) => text.trim() !== ""); -+ const running = data.status === "running"; -+ useLiveNote(actions, sessionId, `text:${node.key ?? data.seq}`, !hidden && running && texts2.length > 0 ? { text: true } : null); -+ if (hidden) return null; -+ const reasoning = groupPart === "response" ? "" : turnReasoning(steps, data); -+ const relays = relayPart(data, groupPart) ? relaysBefore(contexts, steps, data) : []; -+ if (texts2.length === 0 && !reasoning && relays.length === 0) return null; -+ const selfId = roster.byId[sessionId]?.id ?? sessionId; + const running = data.status === "running"; + useLiveNote(actions, sessionId, `text:${node.key ?? data.seq}`, !hidden && running && texts2.length > 0 ? { text: true } : null); + if (hidden) return null; +@@ -3924,13 +3924,14 @@ + const relays = relayPart(data, groupPart) ? relaysBefore(contexts, steps, data) : []; + if (texts2.length === 0 && !reasoning && relays.length === 0) return null; + const selfId = roster.byId[sessionId]?.id ?? sessionId; +- return (0, import_react6.createElement)( +- import_react6.Fragment, + const bubbles = texts2.length ? person ? (0, import_react8.createElement)(PersonMessage, { name: person, texts: texts2, roster, selfId, actions, sessionId, msgKey: `${sessionId}:${data.turn}:${node.key ?? data.seq ?? ""}`, useUi }) : (0, import_react8.createElement)(Bubbles, { texts: texts2, roster, selfId, actions, streaming: running, sessionId, msgKey: `${sessionId}:${data.turn}:${node.key ?? data.seq ?? ""}`, useUi, mentions, lead: friendLead(roster.byId[sessionId], texts2) }) : null; + return (0, import_react8.createElement)( + import_react8.Fragment, -+ null, -+ relays.map((relay) => { -+ const source = relay.source; -+ const sender = roster.byId[source.senderSessionId] ?? { id: source.senderSessionId, name: source.senderName ?? t("a Bot"), color: "gray" }; + null, + relays.map((relay) => { + const source = relay.source; + const sender = roster.byId[source.senderSessionId] ?? { id: source.senderSessionId, name: source.senderName ?? t("a Bot"), color: "gray" }; +- return (0, import_react6.createElement)(EventLine, { + return (0, import_react8.createElement)(EventLine, { -+ key: relay.seq, -+ prefix: source.role === "stopped" ? t("Stopped by") : source.role === "reply" ? t("Reply from") : t("Message from"), -+ bots: [sender], -+ onOpen: () => { -+ if (sender.id) actions.openExchange(selfId, sender.id); -+ }, -+ whole: true -+ }); -+ }), + key: relay.seq, + prefix: source.role === "stopped" ? t("Stopped by") : source.role === "reply" ? t("Reply from") : t("Message from"), + bots: [sender], +@@ -3940,61 +3940,230 @@ + whole: true + }); + }), +- reasoning ? (0, import_react6.createElement)(ThinkingRow, { text: reasoning }) : null, +- texts2.length ? (0, import_react6.createElement)(Bubbles, { texts: texts2, roster, selfId, actions, streaming: running, sessionId, msgKey: `${sessionId}:${data.turn}:${node.key ?? data.seq ?? ""}`, useUi, mentions }) : null + reasoning ? (0, import_react8.createElement)(ThinkingRow, { text: reasoning }) : null, + held && bubbles ? (0, import_react8.createElement)(HeldReply, { held, roster, sessionId, actions, closing }, bubbles) : bubbles + ); @@ -4415,35 +4107,47 @@ diff --git a/client.js b/client.js + { className: "bt-group-msg bt-person-msg", "data-person": name }, + (0, import_react8.createElement)("span", { className: "bt-group-name" }, name), + (0, import_react8.createElement)(Bubbles, { texts: texts2, roster, selfId, actions, sessionId, msgKey, useUi, lead }) -+ ); -+} -+function EventLine({ prefix, bots, joiner = t("and"), onOpen, whole = false }) { -+ if (whole && bots.length === 1) { + ); + } + function EventLine({ prefix, bots, joiner = t("and"), onOpen, whole = false }) { + if (whole && bots.length === 1) { +- return (0, import_react6.createElement)( + return (0, import_react8.createElement)( -+ "div", -+ { className: "bt-event" }, + "div", + { className: "bt-event" }, +- (0, import_react6.createElement)( + (0, import_react8.createElement)( -+ "button", -+ { type: "button", className: "bt-event-whole", title: bots[0].name, onClick: () => onOpen?.(bots[0]) }, + "button", + { type: "button", className: "bt-event-whole", title: bots[0].name, onClick: () => onOpen?.(bots[0]) }, +- (0, import_react6.createElement)("span", null, prefix), +- (0, import_react6.createElement)(BotMark, { bot: bots[0], size: 16 }), +- (0, import_react6.createElement)("span", { className: "bt-event-name" }, bots[0].name) + (0, import_react8.createElement)("span", null, prefix), + (0, import_react8.createElement)(BotMark, { bot: bots[0], size: 16 }), + (0, import_react8.createElement)("span", { className: "bt-event-name" }, bots[0].name) -+ ) -+ ); -+ } -+ const parts = []; + ) + ); + } + const parts = []; +- parts.push((0, import_react6.createElement)("span", { key: "p" }, prefix)); + parts.push((0, import_react8.createElement)("span", { key: "p" }, prefix)); -+ bots.forEach((bot, index) => { + bots.forEach((bot, index) => { +- if (index > 0) parts.push((0, import_react6.createElement)("span", { key: `j${index}` }, joiner)); +- parts.push((0, import_react6.createElement)( + if (index > 0) parts.push((0, import_react8.createElement)("span", { key: `j${index}` }, joiner)); + parts.push((0, import_react8.createElement)( -+ "button", -+ { key: bot.id ?? index, type: "button", onClick: () => onOpen?.(bot) }, + "button", + { key: bot.id ?? index, type: "button", onClick: () => onOpen?.(bot) }, +- (0, import_react6.createElement)(BotMark, { bot, size: 16 }), + (0, import_react8.createElement)(BotMark, { bot, size: 16 }), -+ bot.name -+ )); -+ }); + bot.name + )); + }); +- return (0, import_react6.createElement)("div", { className: "bt-event" }, parts); + return (0, import_react8.createElement)("div", { className: "bt-event" }, parts); -+} + } +-function GroupMessage({ bot, text, room: room2, roster, actions, msgKey, useUi }) { +- const lead = (0, import_react6.createElement)( +var heldForm = (text) => String(text ?? "").replace(/\s+/g, " ").trim().replace(/^(to (the )?face[ -]?room|to the room)\s*:\s*/i, "").trim(); +var sameName = (a, b) => String(a ?? "").replace(/\s+/g, " ").trim().toLowerCase() === String(b ?? "").replace(/\s+/g, " ").trim().toLowerCase(); +function isHeldLine(room2, text, bot) { @@ -4480,10 +4184,12 @@ diff --git a/client.js b/client.js + } + if (held === "held" || isHeldLine(room2, text, bot)) return null; + const lead = (0, import_react8.createElement)( -+ "button", -+ { type: "button", className: "bt-lead", "aria-label": t("Open {name}'s chat", { name: bot.name }), onClick: () => actions.openSession(bot.id) }, + "button", + { type: "button", className: "bt-lead", "aria-label": t("Open {name}'s chat", { name: bot.name }), onClick: () => actions.openSession(bot.id) }, +- (0, import_react6.createElement)(BotMark, { bot, size: 22 }) + (0, import_react8.createElement)(BotMark, { bot, size: 22 }) -+ ); + ); +- return (0, import_react6.createElement)( + const name = (0, import_react8.createElement)( + "span", + { className: "bt-group-name", style: { color: inkText(colorOf(bot)) } }, @@ -4491,8 +4197,9 @@ diff --git a/client.js b/client.js + room2?.admin === bot.id ? (0, import_react8.createElement)("span", { className: "bt-group-role" }, t("Admin")) : null + ); + return (0, import_react8.createElement)( -+ "div", -+ { className: "bt-group-msg" }, + "div", + { className: "bt-group-msg" }, +- (0, import_react6.createElement)( + name, + (0, import_react8.createElement)(Bubbles, { texts: [text], roster, selfId: bot.id, actions, sessionId: room2?.id, msgKey, useUi, lead }) + ); @@ -4557,11 +4264,14 @@ diff --git a/client.js b/client.js + { className: "bt-group-msg bt-remote-msg", "data-remote": handle }, + // A face room's header is the one name alone; the handle is on hover only (#457). + face ? (0, import_react8.createElement)("span", { className: "bt-group-name", style: { color: inkText(colorOf(bot)) }, title: handle }, name) : (0, import_react8.createElement)( -+ "span", -+ { className: "bt-group-name", style: { color: inkText(colorOf(bot)) } }, + "span", + { className: "bt-group-name", style: { color: inkText(colorOf(bot)) } }, +- bot.name, +- room2?.admin === bot.id ? (0, import_react6.createElement)("span", { className: "bt-group-role" }, t("Admin")) : null + name, + (0, import_react8.createElement)("span", { className: "bt-remote-handle", title: source.remote.home ?? handle, "aria-label": t("Friend's Bot") }, (0, import_react8.createElement)(GlobeMark), handle) -+ ), + ), +- (0, import_react6.createElement)(Bubbles, { texts: [text], roster, selfId: bot.id, actions, sessionId: room2?.id, msgKey, useUi, lead }) + (0, import_react8.createElement)(Bubbles, { texts: [stripTrace(text)], roster, selfId: bot.id, actions, sessionId, msgKey, useUi, lead }), + files.length ? (0, import_react8.createElement)(RemoteFiles, { files, plugin: member?.plugin ?? "social", botId: roster.byId[sessionId]?.id ?? sessionId }) : null + ); @@ -4609,280 +4319,231 @@ diff --git a/client.js b/client.js + `📎 ${file.name} · ${fileSize(file.size)}` + )), + error ? (0, import_react8.createElement)("span", { className: "bt-file-error", role: "alert" }, error) : null -+ ); -+} -+function GroupEvent({ prefix, room: room2, name, roster, actions }) { + ); + } + function GroupEvent({ prefix, room: room2, name, roster, actions }) { +- return (0, import_react6.createElement)( + return (0, import_react8.createElement)( -+ "div", -+ { className: "bt-event" }, + "div", + { className: "bt-event" }, +- (0, import_react6.createElement)("span", null, prefix), +- room2 ? (0, import_react6.createElement)("button", { type: "button", onClick: () => actions.openSession(room2.id) }, (0, import_react6.createElement)(RoomAvatar, { room: room2, roster, size: 16 }), room2.name) : name ? (0, import_react6.createElement)("span", null, name) : null + (0, import_react8.createElement)("span", null, prefix), + room2 ? (0, import_react8.createElement)("button", { type: "button", onClick: () => actions.openSession(room2.id) }, (0, import_react8.createElement)(RoomAvatar, { room: room2, roster, size: 16 }), room2.name) : name ? (0, import_react8.createElement)("span", null, name) : null -+ ); -+} -+function MemoryEvent({ sessionId, scope, result, roster, actions, findBot }) { -+ if (!/^(Saved to|Updated in|Removed from) /.test(result)) return null; -+ const self = roster.byId[sessionId]; -+ const team = /^(team|团队|共享)$/i.test(String(scope ?? "").trim()); -+ const other = team || !scope ? void 0 : findBot(scope); -+ const target = other && other.id !== self?.id ? other : self; -+ if (!target) return null; -+ const label = team ? t("Team memory updated") : target === self ? t("Memory updated") : t("Updated {name}'s memory", { name: target.name }); + ); + } + function MemoryEvent({ sessionId, scope, result, roster, actions, findBot }) { +@@ -4005,14 +4005,14 @@ + const target = other && other.id !== self?.id ? other : self; + if (!target) return null; + const label = team ? t("Team memory updated") : target === self ? t("Memory updated") : t("Updated {name}'s memory", { name: target.name }); +- return (0, import_react6.createElement)( + return (0, import_react8.createElement)( -+ "div", -+ { className: "bt-event" }, + "div", + { className: "bt-event" }, +- (0, import_react6.createElement)("button", { type: "button", title: result, onClick: () => actions.openMemory(target.id) }, (0, import_react6.createElement)(MemoryIcon), label) + (0, import_react8.createElement)("button", { type: "button", title: result, onClick: () => actions.openMemory(target.id) }, (0, import_react8.createElement)(MemoryIcon), label) -+ ); -+} -+function PartLine({ time }) { + ); + } + function PartLine({ time }) { +- return (0, import_react6.createElement)("div", { className: "bt-part-line", role: "separator" }, Number.isFinite(time) ? (0, import_react6.createElement)("span", null, dayLabel(time)) : null); + return (0, import_react8.createElement)("div", { className: "bt-part-line", role: "separator" }, Number.isFinite(time) ? (0, import_react8.createElement)("span", null, dayLabel(time)) : null); -+} -+var scrollParent = (node) => { -+ for (let at = node.parentElement; at; at = at.parentElement) { -+ if (/(auto|scroll)/.test(getComputedStyle(at).overflowY)) return at; -+ } -+ return null; -+}; -+function EarlierParts({ sessionId, roster, actions }) { + } + var scrollParent = (node) => { + for (let at = node.parentElement; at; at = at.parentElement) { +@@ -4021,11 +4021,11 @@ + return null; + }; + function EarlierParts({ sessionId, roster, actions }) { +- const [view, setView] = (0, import_react6.useState)({ items: [], cursor: void 0, loading: true, failed: false, startedAt: null }); +- const top = (0, import_react6.useRef)(null); +- const busy = (0, import_react6.useRef)(false); +- const alive = (0, import_react6.useRef)(true); +- const restore = (0, import_react6.useRef)(null); + const [view, setView] = (0, import_react8.useState)({ items: [], cursor: void 0, loading: true, failed: false, startedAt: null }); + const top = (0, import_react8.useRef)(null); + const busy = (0, import_react8.useRef)(false); + const alive = (0, import_react8.useRef)(true); + const restore = (0, import_react8.useRef)(null); -+ const load = (cursor) => { -+ if (busy.current) return; -+ busy.current = true; -+ setView((current) => ({ ...current, loading: true, failed: false })); -+ actions.earlier(sessionId, cursor).then((page) => { -+ busy.current = false; -+ if (!alive.current) return; -+ const scroller = top.current && scrollParent(top.current); -+ if (scroller) restore.current = { scroller, fromEnd: scroller.scrollHeight - scroller.scrollTop }; -+ setView((current) => ({ items: [...page.items, ...current.items], cursor: page.cursor, loading: false, failed: false, startedAt: page.startedAt ?? current.startedAt })); -+ }, () => { -+ busy.current = false; -+ if (alive.current) setView((current) => ({ ...current, loading: false, failed: true })); -+ }); -+ }; + const load = (cursor) => { + if (busy.current) return; + busy.current = true; +@@ -4041,19 +4041,19 @@ + if (alive.current) setView((current) => ({ ...current, loading: false, failed: true })); + }); + }; +- (0, import_react6.useEffect)(() => { + (0, import_react8.useEffect)(() => { -+ alive.current = true; -+ load(void 0); -+ return () => { -+ alive.current = false; -+ }; -+ }, [sessionId]); + alive.current = true; + load(void 0); + return () => { + alive.current = false; + }; + }, [sessionId]); +- (0, import_react6.useLayoutEffect)(() => { + (0, import_react8.useLayoutEffect)(() => { -+ const pending = restore.current; -+ restore.current = null; -+ if (pending) pending.scroller.scrollTop = pending.scroller.scrollHeight - pending.fromEnd; -+ }, [view.items]); + const pending = restore.current; + restore.current = null; + if (pending) pending.scroller.scrollTop = pending.scroller.scrollHeight - pending.fromEnd; + }, [view.items]); +- (0, import_react6.useEffect)(() => { + (0, import_react8.useEffect)(() => { -+ const node = top.current; -+ if (!node || view.loading || view.failed || !view.cursor || typeof IntersectionObserver !== "function") return void 0; -+ const observer = new IntersectionObserver((entries2) => { -+ if (entries2.some((entry) => entry.isIntersecting)) load(view.cursor); -+ }, { root: scrollParent(node), rootMargin: "600px 0px 0px 0px" }); -+ observer.observe(node); -+ return () => observer.disconnect(); -+ }, [view.cursor, view.loading, view.failed]); -+ const selfId = roster.byId[sessionId]?.id; -+ const rows = []; -+ for (const item of view.items) { -+ const last = rows.at(-1); -+ if (item.kind === "bot" && last?.kind === "bot") last.texts.push(item.text); -+ else rows.push(item.kind === "bot" ? { kind: "bot", texts: [item.text] } : item); -+ } + const node = top.current; + if (!node || view.loading || view.failed || !view.cursor || typeof IntersectionObserver !== "function") return void 0; + const observer = new IntersectionObserver((entries2) => { +@@ -4069,35 +4069,35 @@ + if (item.kind === "bot" && last?.kind === "bot") last.texts.push(item.text); + else rows.push(item.kind === "bot" ? { kind: "bot", texts: [item.text] } : item); + } +- return (0, import_react6.createElement)( + return (0, import_react8.createElement)( -+ "div", -+ { className: "bt-earlier", role: "feed", "aria-label": t("Earlier messages"), "aria-busy": view.loading }, + "div", + { className: "bt-earlier", role: "feed", "aria-label": t("Earlier messages"), "aria-busy": view.loading }, +- (0, import_react6.createElement)( + (0, import_react8.createElement)( "div", -- { ref: pop, className: "bt-menu", role: "menu", "aria-label": t("Message actions"), style: place }, -- item(t("Copy"), CopyIcon, copy2), -- item(t("Download"), DownloadIcon, download2) -- ), document.body) : null -- ); --} --function ThinkingRow({ text }) { -- return (0, import_react6.createElement)( -- "details", -- { className: "bt-think" }, -- (0, import_react6.createElement)("summary", null, t("Thought it through")), -- (0, import_react6.createElement)("div", { className: "bt-think-text" }, text) -+ { ref: top, className: "bt-earlier-top" }, + { ref: top, className: "bt-earlier-top" }, +- view.loading ? (0, import_react6.createElement)(Shimmer, null, t("Loading earlier messages…")) : view.failed ? (0, import_react6.createElement)("button", { type: "button", className: "bt-soft", onClick: () => load(view.cursor) }, t("Couldn’t load earlier messages · Retry")) : view.cursor ? (0, import_react6.createElement)("button", { type: "button", className: "bt-soft", onClick: () => load(view.cursor) }, t("Load earlier")) : null + view.loading ? (0, import_react8.createElement)(Shimmer, null, t("Loading earlier messages…")) : view.failed ? (0, import_react8.createElement)("button", { type: "button", className: "bt-soft", onClick: () => load(view.cursor) }, t("Couldn’t load earlier messages · Retry")) : view.cursor ? (0, import_react8.createElement)("button", { type: "button", className: "bt-soft", onClick: () => load(view.cursor) }, t("Load earlier")) : null -+ ), -+ rows.map((row, index) => { -+ const key = rows.length - index; -+ switch (row.kind) { -+ case "divider": + ), + rows.map((row, index) => { + const key = rows.length - index; + switch (row.kind) { + case "divider": +- return (0, import_react6.createElement)(PartLine, { key, time: row.time }); + return (0, import_react8.createElement)(PartLine, { key, time: row.time }); -+ case "user": + case "user": +- return (0, import_react6.createElement)("div", { key, className: "bt-urow" }, (0, import_react6.createElement)( + return (0, import_react8.createElement)("div", { key, className: "bt-urow" }, (0, import_react8.createElement)( -+ "div", -+ { className: "bt-ububble" }, + "div", + { className: "bt-ububble" }, +- row.text ? (0, import_react6.createElement)(import_dsh_client_ui_primitives.MarkdownText, { text: row.text, labels: mdLabels(), variant: "compact" }) : null, +- row.images ? (0, import_react6.createElement)("span", { className: "bt-uimages" }, row.images === 1 ? t("1 image") : t("{count} images", { count: row.images })) : null + row.text ? (0, import_react8.createElement)(import_dsh_client_ui_primitives.MarkdownText, { text: row.text, labels: mdLabels(), variant: "compact" }) : null, + row.images ? (0, import_react8.createElement)("span", { className: "bt-uimages" }, row.images === 1 ? t("1 image") : t("{count} images", { count: row.images })) : null -+ )); -+ case "answer": + )); + case "answer": +- return (0, import_react6.createElement)(AnsweredCell, { key, node: { data: row } }); + return (0, import_react8.createElement)(AnsweredCell, { key, node: { data: row } }); -+ case "bot": + case "bot": +- return (0, import_react6.createElement)(Bubbles, { key, texts: row.texts, roster, selfId, actions }); + return (0, import_react8.createElement)(Bubbles, { key, texts: row.texts, roster, selfId, actions }); -+ case "event": { + case "event": { +- if (row.role === "report") return (0, import_react6.createElement)(GroupEvent, { key, prefix: t("Replies from"), room: roster.roomsById[row.roomId], name: t("a group chat"), roster, actions }); + if (row.role === "report") return (0, import_react8.createElement)(GroupEvent, { key, prefix: t("Replies from"), room: roster.roomsById[row.roomId], name: t("a group chat"), roster, actions }); -+ const other = roster.byId[row.botId] ?? { id: row.botId, name: row.name ?? t("a Bot"), color: "gray" }; -+ const prefix = { stopped: t("Stopped by"), reply: t("Reply from") }[row.role] ?? t("Message from"); + const other = roster.byId[row.botId] ?? { id: row.botId, name: row.name ?? t("a Bot"), color: "gray" }; + const prefix = { stopped: t("Stopped by"), reply: t("Reply from") }[row.role] ?? t("Message from"); +- return (0, import_react6.createElement)(EventLine, { key, prefix, bots: [other], onOpen: () => { + return (0, import_react8.createElement)(EventLine, { key, prefix, bots: [other], onOpen: () => { -+ if (selfId && other.id) actions.openExchange(selfId, other.id); -+ }, whole: true }); -+ } -+ default: -+ return null; -+ } -+ }), + if (selfId && other.id) actions.openExchange(selfId, other.id); + }, whole: true }); + } +@@ -4105,7 +4105,7 @@ + return null; + } + }), +- (0, import_react6.createElement)(PartLine, { time: view.startedAt }) + (0, import_react8.createElement)(PartLine, { time: view.startedAt }) ); } --function useFileMentions({ node, openFile, fileMentions, useTurnData }) { -- const tail = useTurnData?.("turn-tail"); -- const location2 = node.location; -- const turn = location2?.kind === "turn" || location2?.kind === "step" ? location2.turn : void 0; -- const seq = node.data.finalNode?.seq; -- return (0, import_react6.useMemo)(() => { -- if (typeof fileMentions !== "function" || turn?.status !== "closed" || seq === void 0) return void 0; -- return tail?.closing?.finalNode.seq === seq ? fileMentions({ turn, seq, openFile }) : void 0; -- }, [fileMentions, openFile, seq, tail, turn]); --} --function AssistantCell({ node, groupPart, sessionId, openFile, fileMentions, useTurnData, useRoster, useUi, useTurnSteps, useTurnContext, actions }) { -+function TriggerCell({ node, sessionId, useRoster, useSessions, useTurnClock, useUi, actions }) { - const roster = useRoster((value) => value); - const data = node.data; -- const steps = useTurnSteps(String(data.turn)); -- const contexts = useTurnContext(String(data.turn)); -- const mentions = useFileMentions({ node, openFile, fileMentions, useTurnData }); -- const hidden = Boolean(roster.roomsById[sessionId]) || isHiddenTurn(roster, sessionId, data.turn); -- const texts2 = groupPart === "reasoning" ? [] : data.blocks.filter((block) => block.kind === "text" && block.text.trim() !== "").map((block) => block.text); -- const running = data.status === "running"; -- useLiveNote(actions, sessionId, `text:${node.key ?? data.seq}`, !hidden && running && texts2.length > 0 ? { text: true } : null); -- if (hidden) return null; -- const reasoning = groupPart === "response" ? "" : turnReasoning(steps, data); -- const relays = relayPart(data, groupPart) ? relaysBefore(contexts, steps, data) : []; -- if (texts2.length === 0 && !reasoning && relays.length === 0) return null; -+ const source = data.source !== null && typeof data.source === "object" ? data.source : {}; - const selfId = roster.byId[sessionId]?.id ?? sessionId; -- return (0, import_react6.createElement)( -- import_react6.Fragment, -- null, -- relays.map((relay) => { -- const source = relay.source; -+ const deferred = source.kind === "bot" && source.deferred === true; -+ const relayed = isRelayContext(data); -+ const turn = node.location?.turn?.turn ?? data.turn; + function TriggerCell({ node, sessionId, useRoster, useSessions, useTurnClock, useUi, actions }) { +@@ -4116,7 +4116,7 @@ + const deferred = source.kind === "bot" && source.deferred === true; + const relayed = isRelayContext(data); + const turn = node.location?.turn?.turn ?? data.turn; +- (0, import_react6.useLayoutEffect)(() => { + (0, import_react8.useLayoutEffect)(() => { -+ if (relayed) actions.noteTurnPeer(sessionId, turn, source.senderSessionId); -+ }, [relayed, sessionId, turn, source.senderSessionId]); -+ const order = useSessions((list) => { -+ const outline = list.projectionsBySession?.[sessionId]?.values?.turnOutline ?? list.byId[sessionId]?.projectionValues?.turnOutline; -+ return Array.isArray(outline) ? outline.map((entry) => entry.turn).join(",") : ""; -+ }); -+ const peers = useTurnClock((state) => { -+ if (!relayed) return ""; -+ const turns = state[sessionId] ?? {}; -+ return Object.keys(turns).filter((key) => turns[key].peer !== void 0).map((key) => `${key}:${turns[key].peer}`).join(","); -+ }); -+ const visit = useUi((state) => state.visits?.[sessionId]); -+ const hiddenTurns = roster.exchangeTurns[sessionId]; -+ const unreadOpening = useTurnClock((state) => { -+ const first = openingTurn(state[sessionId], order, hiddenTurns); -+ return first?.turn === turn && unseenSince(visit, first.start) ? clockLabel(first.start) : ""; -+ }); -+ const body = triggerBody(); + if (relayed) actions.noteTurnPeer(sessionId, turn, source.senderSessionId); + }, [relayed, sessionId, turn, source.senderSessionId]); + const order = useSessions((list) => { +@@ -4135,23 +4135,31 @@ + return first?.turn === turn && unseenSince(visit, first.start) ? clockLabel(first.start) : ""; + }); + const body = triggerBody(); +- return unreadOpening ? (0, import_react6.createElement)(import_react6.Fragment, null, (0, import_react6.createElement)("div", { className: "bt-open-new" }, (0, import_react6.createElement)(NewDivider), (0, import_react6.createElement)("div", { className: "bt-time-sep", role: "separator" }, unreadOpening)), body) : body; + return unreadOpening ? (0, import_react8.createElement)(import_react8.Fragment, null, (0, import_react8.createElement)("div", { className: "bt-open-new" }, (0, import_react8.createElement)(NewDivider), (0, import_react8.createElement)("div", { className: "bt-time-sep", role: "separator" }, unreadOpening)), body) : body; -+ function triggerBody() { + function triggerBody() { + if (source.form === "remote-chat") return null; + if (source.shown === true) return null; + if (source.remote?.handle) { + const message = (0, import_react8.createElement)(RemoteMessage, { source, text: remoteMessageText(stripHeader(contentText(data.content))), roster, actions, sessionId, msgKey: `${sessionId}:${data.turn}:remote`, useUi }); + return source.remote.firstContact === true ? (0, import_react8.createElement)(import_react8.Fragment, null, (0, import_react8.createElement)(FirstContact, { source, roster, sessionId }), message) : message; + } -+ if (source.kind === "bot") { + if (source.kind === "bot") { +- if (source.handoff === true) return roster.roomsById[sessionId] ? null : (0, import_react6.createElement)(EarlierParts, { sessionId, roster, actions }); +- if (source.role === "kickoff" || source.role === "room") return null; + if (source.handoff === true) return roster.roomsById[sessionId] ? null : (0, import_react8.createElement)(EarlierParts, { sessionId, roster, actions }); + if (source.role === "room" || source.role === "kickoff") return null; + if (source.role === "mention") return (0, import_react8.createElement)("div", { className: "bt-event" }, (0, import_react8.createElement)("span", null, contentText(data.content))); -+ if (source.role === "secret" || deferred) return null; -+ if (source.role === "post") { -+ if (source.failed !== void 0 || source.resumed === true || source.stopped === true) return null; + if (source.role === "secret" || deferred) return null; + if (source.role === "post") { + if (source.failed !== void 0 || source.resumed === true || source.stopped === true) return null; + if (source.notice === true || source.senderSessionId === "face-notice") return null; -+ const room2 = roster.roomsById[sessionId]; -+ const poster = roster.byId[source.senderSessionId] ?? { id: source.senderSessionId, name: source.senderName ?? t("a Bot"), color: "gray" }; + const room2 = roster.roomsById[sessionId]; + const poster = roster.byId[source.senderSessionId] ?? { id: source.senderSessionId, name: source.senderName ?? t("a Bot"), color: "gray" }; +- return (0, import_react6.createElement)(GroupMessage, { bot: poster, text: stripHeader(contentText(data.content)), room: room2, roster, actions, msgKey: `${sessionId}:${data.turn}:post`, useUi }); + return (0, import_react8.createElement)(GroupMessage, { bot: poster, text: stripHeader(contentText(data.content)), room: room2, roster, actions, msgKey: `${sessionId}:${data.turn}:post`, useUi }); -+ } -+ if (source.role === "report") { + } + if (source.role === "report") { +- return (0, import_react6.createElement)(GroupEvent, { prefix: t("Replies from"), room: roster.roomsById[source.roomId], name: t("a group chat"), roster, actions }); + return (0, import_react8.createElement)(GroupEvent, { prefix: t("Replies from"), room: roster.roomsById[source.roomId], name: t("a group chat"), roster, actions }); -+ } + } +- if (source.role === "resume" || source.resumed === true) return (0, import_react6.createElement)("div", { className: "bt-event" }, t("Picked up its work after a restart")); +- if (source.role === "retry" || source.retried === true) return (0, import_react6.createElement)("div", { className: "bt-event" }, t("Asked to make the tool call it wrote as text")); + if (source.role === "resume" || source.resumed === true) return (0, import_react8.createElement)("div", { className: "bt-event" }, t("Picked up its work after a restart")); + if (source.role === "retry" || source.retried === true) return (0, import_react8.createElement)("div", { className: "bt-event" }, t("Asked to make the tool call it wrote as text")); -+ const run = relayed ? commRunOf( -+ order.split(",").filter(Boolean).map(Number), -+ Object.fromEntries(peers.split(",").filter(Boolean).map((pair) => [Number(pair.slice(0, pair.indexOf(":"))), pair.slice(pair.indexOf(":") + 1)])), -+ turn, -+ hiddenTurns -+ ) : null; -+ if (run === "folded") return null; - const sender = roster.byId[source.senderSessionId] ?? { id: source.senderSessionId, name: source.senderName ?? t("a Bot"), color: "gray" }; + const run = relayed ? commRunOf( + order.split(",").filter(Boolean).map(Number), + Object.fromEntries(peers.split(",").filter(Boolean).map((pair) => [Number(pair.slice(0, pair.indexOf(":"))), pair.slice(pair.indexOf(":") + 1)])), +@@ -4165,7 +4165,7 @@ + for (const peer of (run?.peerIds ?? []).map((id) => roster.byId[id]).filter(Boolean)) { + if (!bots.some((bot) => bot.id === peer.id)) bots.push(peer); + } - return (0, import_react6.createElement)(EventLine, { -- key: relay.seq, -- prefix: source.role === "stopped" ? t("Stopped by") : source.role === "reply" ? t("Reply from") : t("Message from"), -- bots: [sender], -- onOpen: () => { -- if (sender.id) actions.openExchange(selfId, sender.id); -- }, -+ const count = run?.count ?? (isHiddenTurn(roster, sessionId, turn) ? 2 : 1); -+ const bots = [sender]; -+ for (const peer of (run?.peerIds ?? []).map((id) => roster.byId[id]).filter(Boolean)) { -+ if (!bots.some((bot) => bot.id === peer.id)) bots.push(peer); -+ } + return (0, import_react8.createElement)(EventLine, { -+ prefix: source.role === "stopped" ? t("Stopped by") : count > 1 ? t("{count} messages with", { count }) : t("Message from"), -+ bots, -+ onOpen: (bot) => actions.openExchange(selfId, bot.id), - whole: true - }); -- }), -- reasoning ? (0, import_react6.createElement)(ThinkingRow, { text: reasoning }) : null, -- texts2.length ? (0, import_react6.createElement)(Bubbles, { texts: texts2, roster, selfId, actions, streaming: running, sessionId, msgKey: `${sessionId}:${data.turn}:${node.key ?? data.seq ?? ""}`, useUi, mentions }) : null -+ } -+ if (source.kind === "schedule") { -+ const prompts = reminderPrompts(contentText(data.content)); + prefix: source.role === "stopped" ? t("Stopped by") : count > 1 ? t("{count} messages with", { count }) : t("Message from"), + bots, + onOpen: (bot) => actions.openExchange(selfId, bot.id), +@@ -4174,24 +4174,24 @@ + } + if (source.kind === "schedule") { + const prompts = reminderPrompts(contentText(data.content)); +- return (0, import_react6.createElement)("div", { className: "bt-event" }, prompts.length > 0 ? t("Scheduled task: {text}", { text: prompts.join(" · ").slice(0, 80) }) : t("Routine started")); + return (0, import_react8.createElement)("div", { className: "bt-event" }, prompts.length > 0 ? t("Scheduled task: {text}", { text: prompts.join(" · ").slice(0, 80) }) : t("Routine started")); -+ } -+ if (source.kind === "user-question-reply") return null; + } + if (source.kind === "user-question-reply") return null; +- return (0, import_react6.createElement)("div", { className: "bt-event" }, contentText(data.content).slice(0, 80) || t("Update")); + return (0, import_react8.createElement)("div", { className: "bt-event" }, contentText(data.content).slice(0, 80) || t("Update")); -+ } -+} -+function NewDivider() { + } + } + function NewDivider() { +- return (0, import_react6.createElement)("div", { className: "bt-new-sep", role: "separator", "aria-label": t("New messages") }, (0, import_react6.createElement)("span", null, t("New"))); + return (0, import_react8.createElement)("div", { className: "bt-new-sep", role: "separator", "aria-label": t("New messages") }, (0, import_react8.createElement)("span", null, t("New"))); -+} -+function TurnProcessCell({ node, sessionId, turnProcess, useRoster, useUi, useTurnClock, actions }) { -+ const open = turnProcess === void 0 || !turnProcess.foldable || turnProcess.open; + } + function TurnProcessCell({ node, sessionId, turnProcess, useRoster, useUi, useTurnClock, actions }) { + const open = turnProcess === void 0 || !turnProcess.foldable || turnProcess.open; +- (0, import_react6.useEffect)(() => { + (0, import_react8.useEffect)(() => { -+ if (!open) turnProcess.setOpen(true); -+ }, [open, turnProcess]); -+ const turn = node?.location?.turn?.turn ?? node?.data?.turn; -+ const start = node?.location?.turn?.start?.time; -+ const hidden = useRoster((roster) => isHiddenTurn(roster, sessionId, turn)); + if (!open) turnProcess.setOpen(true); + }, [open, turnProcess]); + const turn = node?.location?.turn?.turn ?? node?.data?.turn; + const start = node?.location?.turn?.start?.time; + const hidden = useRoster((roster) => isHiddenTurn(roster, sessionId, turn)); +- (0, import_react6.useEffect)(() => { + (0, import_react8.useEffect)(() => { -+ actions.noteTurnTime(sessionId, turn, "first", start); -+ }, [sessionId, turn, start]); -+ const visit = useUi((state) => state.visits?.[sessionId]); -+ const fresh = useTurnClock((state) => { -+ const own = state[sessionId]?.[turn]; -+ return !hidden && typeof visit?.seen === "number" && own?.first !== void 0 && own.first <= visit.seen && own.end !== void 0 && own.end > visit.seen && own.end <= visit.entered; -+ }); + actions.noteTurnTime(sessionId, turn, "first", start); + }, [sessionId, turn, start]); + const visit = useUi((state) => state.visits?.[sessionId]); +@@ -4199,11 +4199,32 @@ + const own = state[sessionId]?.[turn]; + return !hidden && typeof visit?.seen === "number" && own?.first !== void 0 && own.first <= visit.seen && own.end !== void 0 && own.end > visit.seen && own.end <= visit.entered; + }); +- return fresh ? (0, import_react6.createElement)(NewDivider) : null; + return fresh ? (0, import_react8.createElement)(NewDivider) : null; -+} -+function Nothing() { -+ return null; -+} + } + function Nothing() { + return null; + } +var HIDDEN_REMOVED_TOOLS = /* @__PURE__ */ new Set(["ask_user_question"]); +var isConnectorTool = (name) => typeof name === "string" && /^mcp__(github|composio)__/.test(name); +var isHiddenNotice = (block) => block?.type === "tool-removal" && (HIDDEN_REMOVED_TOOLS.has(block.toolName) || isConnectorTool(block.toolName) || isPluginTool(block.toolName)) || block?.type === "tool-addition" && (isConnectorTool(block.toolName) || isPluginTool(block.toolName)); @@ -4904,35 +4565,13 @@ diff --git a/client.js b/client.js + if (parts.length === 0) return null; + return (0, import_react8.createElement)("div", { className: "bt-event" }, parts.join(" · ")); +} -+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 openingTurn(turns, firstTurns, hiddenTurns) { -+ if (!turns || !firstTurns) return null; -+ const start = nextTurnStart(turns, -Infinity); -+ if (start === void 0) return null; -+ const turn = Number(Object.keys(turns).filter((key) => turns[key].first === start)[0]); -+ const listed = firstTurns.split(",").map(Number); -+ const earlier = listed.filter((entry) => entry < turn); -+ return listed.includes(turn) && earlier.every((entry) => hiddenTurns?.includes(entry)) ? { turn, start } : null; -+} -+var unseenSince = (visit, time) => typeof visit?.seen === "number" && time > visit.seen && time <= visit.entered; + var SEPARATOR_GAP_MS = 30 * 60 * 1e3; + function clockLabel(time, now = Date.now()) { + const date = new Date(time); +@@ -4233,10 +4233,19 @@ + return listed.includes(turn) && earlier.every((entry) => hiddenTurns?.includes(entry)) ? { turn, start } : null; + } + var unseenSince = (visit, time) => typeof visit?.seen === "number" && time > visit.seen && time <= visit.entered; +function movedOnSince(visit, turns, hiddenTurns) { + if (typeof visit?.entered !== "number") return false; + for (const key in turns ?? {}) { @@ -4942,350 +4581,175 @@ diff --git a/client.js b/client.js + return false; +} +var settledVisit = (visit) => typeof visit?.seen === "number" && visit.seen < visit.entered ? { ...visit, seen: visit.entered } : visit; -+function TurnTailCell({ turn: location2, sessionId, useTurnClock, useUi, actions }) { -+ const turn = location2.turn; -+ const time = location2.end.time; + function TurnTailCell({ turn: location2, sessionId, useTurnClock, useUi, actions }) { + const turn = location2.turn; + const time = location2.end.time; +- (0, import_react6.useEffect)(() => { + (0, import_react8.useEffect)(() => { -+ actions.noteTurnTime(sessionId, turn, "end", time); -+ }, [sessionId, turn, time]); -+ const label = useTurnClock((state) => { -+ const start = nextTurnStart(state[sessionId] ?? {}, turn); -+ return start !== void 0 && start - time >= SEPARATOR_GAP_MS ? clockLabel(start) : ""; -+ }); -+ const visit = useUi((state) => state.visits?.[sessionId]); -+ const fresh = useTurnClock((state) => { -+ if (typeof visit?.seen !== "number" || time > visit.seen) return false; -+ const start = nextTurnStart(state[sessionId] ?? {}, turn); -+ return start !== void 0 && start > visit.seen && start <= visit.entered; -+ }); -+ if (!label && !fresh) return null; + actions.noteTurnTime(sessionId, turn, "end", time); + }, [sessionId, turn, time]); + const label = useTurnClock((state) => { +@@ -4250,15 +4250,15 @@ + return start !== void 0 && start > visit.seen && start <= visit.entered; + }); + if (!label && !fresh) return null; +- return (0, import_react6.createElement)( +- import_react6.Fragment, + return (0, import_react8.createElement)( + import_react8.Fragment, -+ null, -+ label ? (0, import_react8.createElement)("div", { className: "bt-time-sep", role: "separator" }, label) : null, + null, +- label ? (0, import_react6.createElement)("div", { className: "bt-time-sep", role: "separator" }, label) : null, +- fresh ? (0, import_react6.createElement)(NewDivider) : null ++ label ? (0, import_react8.createElement)("div", { className: "bt-time-sep", role: "separator" }, label) : null, + fresh ? (0, import_react8.createElement)(NewDivider) : null ); } --function EventLine({ prefix, bots, joiner = t("and"), onOpen, whole = false }) { -- if (whole && bots.length === 1) { -- return (0, import_react6.createElement)( -- "div", -- { className: "bt-event" }, -- (0, import_react6.createElement)( -- "button", -- { type: "button", className: "bt-event-whole", title: bots[0].name, onClick: () => onOpen?.(bots[0]) }, -- (0, import_react6.createElement)("span", null, prefix), -- (0, import_react6.createElement)(BotMark, { bot: bots[0], size: 16 }), -- (0, import_react6.createElement)("span", { className: "bt-event-name" }, bots[0].name) -- ) -- ); -+function Shimmer({ children }) { + function Shimmer({ children }) { +- return (0, import_react6.createElement)("span", { className: "bt-shimmer" }, children); + return (0, import_react8.createElement)("span", { className: "bt-shimmer" }, children); -+} -+var PHRASES = { -+ thinking: ["Thinking about it", "Weighing the options", "Working out a plan", "Sorting out the details"], -+ working: ["On it", "Busy with it", "Moving ahead", "Taking care of it", "Making headway"] -+}; -+var TOOL_PHRASES = [ -+ [/web_?search|search_?web/i, "Searching online"], -+ [/fetch|browse|browser|web/i, "Reading a page"], -+ [/shell|bash|pwsh|powershell|exec|command|terminal/i, "Running shell commands"], -+ [/^(read|view|cat)\b|read_/i, "Opening a file"], -+ [/grep|glob|list_dir|^ls$|find|search/i, "Looking through files"], -+ [/write|edit|patch|apply|replace/i, "Writing changes"], -+ [/image|photo|draw/i, "Working on an image"] -+]; -+var GROUP_PHRASES = { create_group: "Creating a group", update_group: "Updating the group", delete_group: "Deleting the group", post_to_group: "Posting in the group" }; -+function toolActivity(name, arg, findBot, self) { -+ switch (name) { -+ case "ask_user": -+ return null; -+ case "request_secret": -+ return null; -+ case "list_secrets": -+ return { label: t("Checking the keys"), state: "searching" }; -+ case "ask_user_question": -+ return { label: t("Waiting for your answer"), state: "alert" }; -+ case "group_relay": -+ return { relay: true, state: "orbit" }; -+ case "message_bot": { -+ const target = findBot(arg("to")); -+ return { label: target ? t("Asking {name}", { name: target.name }) : t("Asking another Bot"), state: "sending" }; -+ } -+ case "stop_bot": { -+ const target = findBot(arg("bot")); -+ return { label: target ? t("Stopping {name}", { name: target.name }) : t("Stopping another Bot"), state: "sending" }; -+ } -+ case "create_bot": -+ return { label: t("Creating {name}", { name: arg("name") ?? t("a Bot") }), state: "sending" }; -+ case "update_bot": { -+ const target = findBot(arg("name")) ?? findBot(arg("bot")) ?? (arg("bot") ? void 0 : self); -+ return { label: t("Updating {name}", { name: target?.name ?? t("a Bot") }), state: "sending" }; -+ } -+ case "list_bots": -+ return { label: t("Checking the team"), state: "searching" }; -+ case "read_group_chat": -+ return { label: t("Reading {name}", { name: arg("group") ?? t("a group chat") }), state: "searching" }; -+ case "read_own_chat": -+ return { label: t("Looking back through the chat"), state: "searching" }; -+ case "remember": -+ return { label: t("Saving to memory"), state: "working" }; -+ case "forget": -+ return { label: t("Updating memory"), state: "working" }; -+ case "recall": -+ return { label: t("Checking memory"), state: "searching" }; -+ default: -+ if (GROUP_PHRASES[name]) return { label: t(GROUP_PHRASES[name]), state: "sending" }; -+ const phrase = TOOL_PHRASES.find(([pattern]) => pattern.test(name))?.[1]; -+ return { label: phrase ? t(phrase) : null, state: toolState(name) }; - } -- const parts = []; -- parts.push((0, import_react6.createElement)("span", { key: "p" }, prefix)); -- bots.forEach((bot, index) => { -- if (index > 0) parts.push((0, import_react6.createElement)("span", { key: `j${index}` }, joiner)); -- parts.push((0, import_react6.createElement)( -- "button", -- { key: bot.id ?? index, type: "button", onClick: () => onOpen?.(bot) }, -- (0, import_react6.createElement)(BotMark, { bot, size: 16 }), -- bot.name -- )); -- }); -- return (0, import_react6.createElement)("div", { className: "bt-event" }, parts); --} --function GroupMessage({ bot, text, room: room2, roster, actions, msgKey, useUi }) { -- const lead = (0, import_react6.createElement)( -- "button", -- { type: "button", className: "bt-lead", "aria-label": t("Open {name}'s chat", { name: bot.name }), onClick: () => actions.openSession(bot.id) }, -- (0, import_react6.createElement)(BotMark, { bot, size: 22 }) -- ); -- return (0, import_react6.createElement)( -- "div", -- { className: "bt-group-msg" }, -- (0, import_react6.createElement)( -- "span", -- { className: "bt-group-name", style: { color: inkText(colorOf(bot)) } }, -- bot.name, -- room2?.admin === bot.id ? (0, import_react6.createElement)("span", { className: "bt-group-role" }, t("Admin")) : null -- ), -- (0, import_react6.createElement)(Bubbles, { texts: [text], roster, selfId: bot.id, actions, sessionId: room2?.id, msgKey, useUi, lead }) -- ); } --function GroupEvent({ prefix, room: room2, name, roster, actions }) { -- return (0, import_react6.createElement)( -- "div", -- { className: "bt-event" }, -- (0, import_react6.createElement)("span", null, prefix), -- room2 ? (0, import_react6.createElement)("button", { type: "button", onClick: () => actions.openSession(room2.id) }, (0, import_react6.createElement)(RoomAvatar, { room: room2, roster, size: 16 }), room2.name) : name ? (0, import_react6.createElement)("span", null, name) : null -- ); -+function workPhrase(tool) { -+ if (!tool) return t("Thinking about it"); -+ return toolActivity(tool, () => void 0, () => void 0)?.label ?? t("Using {tool}", { tool }); + var PHRASES = { + thinking: ["Thinking about it", "Weighing the options", "Working out a plan", "Sorting out the details"], +@@ -4320,19 +4320,22 @@ } --function MemoryEvent({ sessionId, scope, result, roster, actions, findBot }) { -- if (!/^(Saved to|Updated in|Removed from) /.test(result)) return null; -- const self = roster.byId[sessionId]; -- const team = /^(team|团队|共享)$/i.test(String(scope ?? "").trim()); -- const other = team || !scope ? void 0 : findBot(scope); -- const target = other && other.id !== self?.id ? other : self; -- if (!target) return null; -- const label = team ? t("Team memory updated") : target === self ? t("Memory updated") : t("Updated {name}'s memory", { name: target.name }); -- return (0, import_react6.createElement)( -- "div", -- { className: "bt-event" }, -- (0, import_react6.createElement)("button", { type: "button", title: result, onClick: () => actions.openMemory(target.id) }, (0, import_react6.createElement)(MemoryIcon), label) -- ); + function workPhrase(tool) { + if (!tool) return t("Thinking about it"); +- return toolActivity(tool, () => void 0, () => void 0)?.label ?? t("On it"); ++ return toolActivity(tool, () => void 0, () => void 0)?.label ?? t("Using {tool}", { tool }); ++} +function workText(tool, step) { + return step ? t("{action}: {detail}", { action: workPhrase(tool), detail: step }) : `${workPhrase(tool)}…`; } --function PartLine({ time }) { -- return (0, import_react6.createElement)("div", { className: "bt-part-line", role: "separator" }, Number.isFinite(time) ? (0, import_react6.createElement)("span", null, dayLabel(time)) : null); -+function useLiveNote(actions, sessionId, id, entry) { -+ const key = entry ? JSON.stringify(entry) : ""; + function useLiveNote(actions, sessionId, id, entry) { + const key = entry ? JSON.stringify(entry) : ""; +- (0, import_react6.useEffect)(() => { + (0, import_react8.useEffect)(() => { -+ if (!key) return void 0; -+ actions.noteLive(sessionId, id, JSON.parse(key)); -+ return () => actions.noteLive(sessionId, id, null); -+ }, [sessionId, id, key]); + if (!key) return void 0; + actions.noteLive(sessionId, id, JSON.parse(key)); + return () => actions.noteLive(sessionId, id, null); + }, [sessionId, id, key]); } --var scrollParent = (node) => { -- for (let at = node.parentElement; at; at = at.parentElement) { -- if (/(auto|scroll)/.test(getComputedStyle(at).overflowY)) return at; -- } -- return null; --}; --function EarlierParts({ sessionId, roster, actions }) { -- const [view, setView] = (0, import_react6.useState)({ items: [], cursor: void 0, loading: true, failed: false, startedAt: null }); -- const top = (0, import_react6.useRef)(null); -- const busy = (0, import_react6.useRef)(false); -- const alive = (0, import_react6.useRef)(true); -- const restore = (0, import_react6.useRef)(null); -- const load = (cursor) => { -- if (busy.current) return; -- busy.current = true; -- setView((current) => ({ ...current, loading: true, failed: false })); -- actions.earlier(sessionId, cursor).then((page) => { -- busy.current = false; -- if (!alive.current) return; -- const scroller = top.current && scrollParent(top.current); -- if (scroller) restore.current = { scroller, fromEnd: scroller.scrollHeight - scroller.scrollTop }; -- setView((current) => ({ items: [...page.items, ...current.items], cursor: page.cursor, loading: false, failed: false, startedAt: page.startedAt ?? current.startedAt })); -- }, () => { -- busy.current = false; -- if (alive.current) setView((current) => ({ ...current, loading: false, failed: true })); -- }); -- }; +-function GroupReplies({ sessionId, callId, done, content, roster, actions, useUi, useWork }) { +- const [live, setLive] = (0, import_react6.useState)({ replies: [], speaking: null }); - (0, import_react6.useEffect)(() => { -- alive.current = true; -- load(void 0); +function GroupReplies({ sessionId, callId, done, content, roster, actions, useUi }) { + const [live, setLive] = (0, import_react8.useState)({ replies: [] }); + (0, import_react8.useEffect)(() => { -+ if (done) return void 0; -+ let stop = false; -+ let timer; -+ let seen = ""; -+ const tick = async () => { -+ const result = await actions.roomProgress(sessionId); -+ if (stop) return; -+ const entry = (result ?? []).find((item) => item.callId === callId) ?? (result ?? [])[0]; + if (done) return void 0; + let stop = false; + let timer; +@@ -4341,10 +4341,10 @@ + const result = await actions.roomProgress(sessionId); + if (stop) return; + const entry = (result ?? []).find((item) => item.callId === callId) ?? (result ?? [])[0]; +- const key = entry ? JSON.stringify([entry.replies, entry.speaking]) : ""; + const key = entry ? JSON.stringify(entry.replies) : ""; -+ if (entry && key !== seen) { -+ seen = key; + if (entry && key !== seen) { + seen = key; +- setLive({ replies: entry.replies ?? [], speaking: entry.speaking ?? null }); + setLive({ replies: entry.replies ?? [] }); -+ } -+ timer = setTimeout(tick, document.hidden ? 5e3 : 1e3); -+ }; -+ void tick(); - return () => { -- alive.current = false; -+ stop = true; -+ clearTimeout(timer); + } + timer = setTimeout(tick, document.hidden ? 5e3 : 1e3); }; -- }, [sessionId]); -- (0, import_react6.useLayoutEffect)(() => { -- const pending = restore.current; -- restore.current = null; -- if (pending) pending.scroller.scrollTop = pending.scroller.scrollHeight - pending.fromEnd; -- }, [view.items]); -- (0, import_react6.useEffect)(() => { -- const node = top.current; -- if (!node || view.loading || view.failed || !view.cursor || typeof IntersectionObserver !== "function") return void 0; -- const observer = new IntersectionObserver((entries2) => { -- if (entries2.some((entry) => entry.isIntersecting)) load(view.cursor); -- }, { root: scrollParent(node), rootMargin: "600px 0px 0px 0px" }); -- observer.observe(node); -- return () => observer.disconnect(); -- }, [view.cursor, view.loading, view.failed]); -- const selfId = roster.byId[sessionId]?.id; -- const rows = []; -- for (const item of view.items) { -- const last = rows.at(-1); -- if (item.kind === "bot" && last?.kind === "bot") last.texts.push(item.text); -- else rows.push(item.kind === "bot" ? { kind: "bot", texts: [item.text] } : item); -+ }, [done, sessionId, callId]); -+ let replies = live.replies; -+ if (done) { -+ try { -+ replies = JSON.parse(contentText(content)).replies ?? []; -+ } catch { -+ replies = []; -+ } +@@ -4362,71 +4362,88 @@ + replies = []; + } } -- return (0, import_react6.createElement)( +- const pending = done || !live.speaking ? [] : [live.speaking]; +- const speakerTool = useWork?.((work) => work.bots?.[live.speaking]?.tool) ?? void 0; +- if (replies.length === 0 && pending.length === 0) return null; + if (replies.length === 0) return null; -+ const room2 = roster.roomsById[sessionId]; + const room2 = roster.roomsById[sessionId]; +- return (0, import_react6.createElement)( + return (0, import_react8.createElement)( "div", -- { className: "bt-earlier", role: "feed", "aria-label": t("Earlier messages"), "aria-busy": view.loading }, -- (0, import_react6.createElement)( -- "div", -- { ref: top, className: "bt-earlier-top" }, -- view.loading ? (0, import_react6.createElement)(Shimmer, null, t("Loading earlier messages…")) : view.failed ? (0, import_react6.createElement)("button", { type: "button", className: "bt-soft", onClick: () => load(view.cursor) }, t("Couldn’t load earlier messages · Retry")) : view.cursor ? (0, import_react6.createElement)("button", { type: "button", className: "bt-soft", onClick: () => load(view.cursor) }, t("Load earlier")) : null -- ), -- rows.map((row, index) => { -- const key = rows.length - index; -- switch (row.kind) { -- case "divider": -- return (0, import_react6.createElement)(PartLine, { key, time: row.time }); -- case "user": -- return (0, import_react6.createElement)("div", { key, className: "bt-urow" }, (0, import_react6.createElement)( -+ { className: "bt-group-replies" }, -+ replies.map((reply, index) => { + { className: "bt-group-replies" }, + replies.map((reply, index) => { +- const bot = roster.byId[reply.botId] ?? { id: reply.botId, name: reply.name, color: reply.color ?? "gray" }; + const stored = roster.byId[reply.botId]; + const resolved = reply.remote ? stored : rosterBot(roster, reply.botId) ?? stored; + const bot = resolved ?? { id: reply.botId, name: reply.name, color: reply.color ?? "gray" }; -+ if (reply.kind === "user") { + if (reply.kind === "user") { +- return (0, import_react6.createElement)("div", { key: `user-${index}`, className: "bt-urow" }, (0, import_react6.createElement)( + const who = reply.name && reply.name !== "User" ? reply.name : reply.authorId; + if (reply.other) { + return (0, import_react8.createElement)(PersonMessage, { key: `user-${index}`, name: who, texts: [remoteMessageText(reply.text)], roster, selfId: sessionId, actions, sessionId, msgKey: callId ? `${sessionId}:${callId}:${index}` : void 0, useUi }); + } + return (0, import_react8.createElement)( -+ "div", + "div", +- { className: "bt-ububble" }, +- (0, import_react6.createElement)(import_dsh_client_ui_primitives.MarkdownText, { text: reply.text, labels: mdLabels(), variant: "compact" }) +- )); + { key: `user-${index}`, className: "bt-urow", "data-author": reply.authorId, "data-author-name": who }, + who ? (0, import_react8.createElement)("span", { className: "bt-uauthor" }, who) : null, + (0, import_react8.createElement)( - "div", - { className: "bt-ububble" }, -- row.text ? (0, import_react6.createElement)(import_dsh_client_ui_primitives.MarkdownText, { text: row.text, labels: mdLabels(), variant: "compact" }) : null, -- row.images ? (0, import_react6.createElement)("span", { className: "bt-uimages" }, row.images === 1 ? t("1 image") : t("{count} images", { count: row.images })) : null -- )); -- case "answer": -- return (0, import_react6.createElement)(AnsweredCell, { key, node: { data: row } }); -- case "bot": -- return (0, import_react6.createElement)(Bubbles, { key, texts: row.texts, roster, selfId, actions }); -- case "event": { -- if (row.role === "report") return (0, import_react6.createElement)(GroupEvent, { key, prefix: t("Replies from"), room: roster.roomsById[row.roomId], name: t("a group chat"), roster, actions }); -- const other = roster.byId[row.botId] ?? { id: row.botId, name: row.name ?? t("a Bot"), color: "gray" }; -- const prefix = { stopped: t("Stopped by"), reply: t("Reply from") }[row.role] ?? t("Message from"); -- return (0, import_react6.createElement)(EventLine, { key, prefix, bots: [other], onOpen: () => { -- if (selfId && other.id) actions.openExchange(selfId, other.id); -- }, whole: true }); -- } -- default: -- return null; ++ "div", ++ { className: "bt-ububble" }, + (0, import_react8.createElement)(import_dsh_client_ui_primitives.MarkdownText, { text: remoteMessageText(reply.text), labels: mdLabels(), variant: "compact" }) + ), + room2?.origin === "face-to-face" ? (0, import_react8.createElement)("span", { className: "bt-lead", title: who || void 0 }, (0, import_react8.createElement)(PersonMark, { name: who || void 0 })) : null + ); -+ } -+ if (reply.kind === "failed") { + } + if (reply.kind === "failed") { +- return (0, import_react6.createElement)( + return (0, import_react8.createElement)( -+ "div", + "div", +- { key: `${reply.botId}-${index}`, className: "bt-event bt-event-error", role: "status", title: [reply.code, reply.status, reply.text].filter(Boolean).join(" · ") }, + { key: `${reply.botId}-${index}`, className: "bt-event bt-event-error", role: "status" }, -+ t("{name} could not answer: {reason}", { name: bot.name, reason: failureLabel(reply.code, reply.text) }) -+ ); -+ } -+ if (reply.kind === "lookup") { + t("{name} could not answer: {reason}", { name: bot.name, reason: failureLabel(reply.code, reply.text) }) + ); + } + if (reply.kind === "lookup") { +- return (0, import_react6.createElement)( + return (0, import_react8.createElement)( -+ "div", -+ { key: `${reply.botId}-${index}`, className: "bt-event" }, -+ reply.text ? t("{name} looked up its own chat for “{text}”", { name: bot.name, text: reply.text }) : t("{name} looked up its own chat", { name: bot.name }) -+ ); -+ } -+ if (reply.kind === "missed") { + "div", + { key: `${reply.botId}-${index}`, className: "bt-event" }, + reply.text ? t("{name} looked up its own chat for “{text}”", { name: bot.name, text: reply.text }) : t("{name} looked up its own chat", { name: bot.name }) + ); + } + if (reply.kind === "missed") { +- return (0, import_react6.createElement)("div", { key: `${reply.botId}-${index}`, className: "bt-event" }, t("{name} didn't answer in time", { name: bot.name })); + return (0, import_react8.createElement)("div", { key: `${reply.botId}-${index}`, className: "bt-event" }, t("{name} didn't answer in time", { name: bot.name })); -+ } -+ if (reply.kind === "resumed") { + } + if (reply.kind === "resumed") { +- return (0, import_react6.createElement)("div", { key: `${reply.botId}-${index}`, className: "bt-event" }, t("{name} was cut off by a restart and picked its work back up", { name: bot.name })); + return (0, import_react8.createElement)("div", { key: `${reply.botId}-${index}`, className: "bt-event" }, t("{name} was cut off by a restart and picked its work back up", { name: bot.name })); -+ } -+ if (reply.kind === "cut") { + } + if (reply.kind === "cut") { +- return (0, import_react6.createElement)("div", { key: `${reply.botId}-${index}`, className: "bt-event" }, t("{name} was cut off by a restart again and stopped", { name: bot.name })); + return (0, import_react8.createElement)("div", { key: `${reply.botId}-${index}`, className: "bt-event" }, t("{name} was cut off by a restart again and stopped", { name: bot.name })); -+ } -+ if (reply.kind === "approval" || reply.kind === "working") { + } + if (reply.kind === "approval" || reply.kind === "working") { +- return (0, import_react6.createElement)( + return (0, import_react8.createElement)( -+ "div", -+ { key: `${reply.botId}-${index}`, className: "bt-event" }, + "div", + { key: `${reply.botId}-${index}`, className: "bt-event" }, +- (0, import_react6.createElement)( + (0, import_react8.createElement)( -+ "button", -+ { type: "button", className: "bt-event-whole", onClick: () => actions.showDetails(sessionId) }, + "button", + { type: "button", className: "bt-event-whole", onClick: () => actions.showDetails(sessionId) }, +- (0, import_react6.createElement)(BotMark, { bot, size: 16 }), +- (0, import_react6.createElement)("span", { className: "bt-event-name" }, t(reply.kind === "working" ? "{name} started on it" : "{name} paused for your approval", { name: bot.name })) + (0, import_react8.createElement)(BotMark, { bot, size: 16 }), + (0, import_react8.createElement)("span", { className: "bt-event-name" }, t(reply.kind === "working" ? "{name} started on it" : "{name} paused for your approval", { name: bot.name })) -+ ) -+ ); -+ } -+ if (reply.kind === "stopped") { + ) + ); + } + if (reply.kind === "stopped") { +- return (0, import_react6.createElement)("div", { key: `${reply.botId}-${index}`, className: "bt-event" }, t("You stopped {name}'s work", { name: bot.name })); + return (0, import_react8.createElement)("div", { key: `${reply.botId}-${index}`, className: "bt-event" }, t("You stopped {name}'s work", { name: bot.name })); -+ } + } +- return (0, import_react6.createElement)(GroupMessage, { key: `${reply.botId}-${index}`, bot, text: reply.text, room: room2, roster, actions, msgKey: callId ? `${sessionId}:${callId}:${index}` : void 0, useUi }); +- }), +- pending.map((id) => { +- const bot = roster.byId[id]; +- if (!bot) return null; +- return (0, import_react6.createElement)( +- "div", +- { key: `pending-${id}`, className: "bt-activity" }, +- (0, import_react6.createElement)(BotMark, { bot, size: 22, state: speakerTool ? toolState(speakerTool) : "thinking" }), +- (0, import_react6.createElement)(Shimmer, null, speakerTool ? `${bot.name} · ${workPhrase(speakerTool)}…` : t("{name} is typing…", { name: bot.name })) +- ); + if (reply.kind === "notice" && room2?.origin === "face-to-face" && String(reply.text ?? "").trim() === WAITING_OTHERS_NOTICE) return null; + if (reply.kind === "notice" || reply.kind === "joined" || reply.kind === "left" || reply.kind === "started") { + return (0, import_react8.createElement)("div", { key: `notice-${index}`, className: "bt-event", "data-flybot": "face-notice" }, remoteMessageText(reply.text)); @@ -5304,188 +4768,151 @@ diff --git a/client.js b/client.js + }); + } + return (0, import_react8.createElement)(GroupMessage, { key: `${reply.botId}-${index}`, bot, text: reply.text, room: room2, roster, actions, msgKey: callId ? `${sessionId}:${callId}:${index}` : void 0, useUi, seq: reply.seq }); -+ }) -+ ); -+} -+var DRAWN = /* @__PURE__ */ new Set(["message_bot", "stop_bot", "create_bot", "update_bot", "group_relay", "create_group", "update_group", "delete_group", "post_to_group", "ask_user", "request_secret", "ask_user_question", "read_group_chat", "remember", "forget"]); -+var CARDS = /* @__PURE__ */ new Set(["ask_user", "request_secret"]); -+function callTree(root) { -+ const calls = []; -+ const seen = /* @__PURE__ */ new Set(); + }) + ); + } +@@ -4435,52 +4435,52 @@ + function callTree(root) { + const calls = []; + const seen = /* @__PURE__ */ new Set(); +- const visit = (call2) => { +- const id = call2.callId ?? call2; + const visit = (call4) => { + const id = call4.callId ?? call4; -+ if (seen.has(id)) return; -+ seen.add(id); + if (seen.has(id)) return; + seen.add(id); +- calls.push(call2); +- for (const child of call2.subCalls ?? []) visit(child); + calls.push(call4); + for (const child of call4.subCalls ?? []) visit(child); -+ }; -+ visit(root); -+ return calls; -+} -+function useShownCards(actions, sessionId, callIds) { -+ const key = JSON.stringify(callIds); + }; + visit(root); + return calls; + } + function useShownCards(actions, sessionId, callIds) { + const key = JSON.stringify(callIds); +- (0, import_react6.useLayoutEffect)(() => { + (0, import_react8.useLayoutEffect)(() => { -+ const ids = JSON.parse(key); -+ if (ids.length === 0 || !actions.noteCards) return void 0; -+ actions.noteCards(sessionId, ids, 1); -+ return () => actions.noteCards(sessionId, ids, -1); -+ }, [sessionId, key]); -+} + const ids = JSON.parse(key); + if (ids.length === 0 || !actions.noteCards) return void 0; + actions.noteCards(sessionId, ids, 1); + return () => actions.noteCards(sessionId, ids, -1); + }, [sessionId, key]); + } +-function renderCall(call2, { sessionId, roster, actions, useUi, useWork, findBot }) { +- const tool = toolName(call2); +- const done = call2.kind === "tool-result"; +- const { text: arg, value: argValue } = toolArgs(call2); +function renderCall(call4, { sessionId, roster, actions, useUi, findBot }) { + const tool = toolName(call4); + const done = call4.kind === "tool-result"; + const { text: arg, value: argValue } = toolArgs(call4); -+ const self = roster.byId[sessionId]; -+ switch (tool) { -+ case "message_bot": { -+ const target = findBot(arg("to")) ?? { name: arg("to") ?? "…", color: "gray" }; + const self = roster.byId[sessionId]; + switch (tool) { + case "message_bot": { + const target = findBot(arg("to")) ?? { name: arg("to") ?? "…", color: "gray" }; +- if (done && call2.isError) return (0, import_react6.createElement)("div", { className: "bt-event" }, t("Couldn't message {name}", { name: target.name })); +- return (0, import_react6.createElement)(EventLine, { prefix: t("Messaged"), bots: [target], onOpen: (bot) => bot.id && actions.openExchange(self?.id ?? sessionId, bot.id), whole: true }); + if (done && call4.isError) return (0, import_react8.createElement)("div", { className: "bt-event" }, t("Couldn't message {name}", { name: target.name })); + return (0, import_react8.createElement)(EventLine, { prefix: t("Messaged"), bots: [target], onOpen: (bot) => bot.id && actions.openExchange(self?.id ?? sessionId, bot.id), whole: true }); -+ } -+ case "stop_bot": { -+ const target = findBot(arg("bot")) ?? { name: arg("bot") ?? "…", color: "gray" }; + } + case "stop_bot": { + const target = findBot(arg("bot")) ?? { name: arg("bot") ?? "…", color: "gray" }; +- if (done && call2.isError) return (0, import_react6.createElement)("div", { className: "bt-event" }, t("Couldn't stop {name}", { name: target.name })); +- return (0, import_react6.createElement)(EventLine, { prefix: t("Stopped"), bots: [target], onOpen: (bot) => bot.id && actions.openExchange(self?.id ?? sessionId, bot.id), whole: true }); + if (done && call4.isError) return (0, import_react8.createElement)("div", { className: "bt-event" }, t("Couldn't stop {name}", { name: target.name })); + return (0, import_react8.createElement)(EventLine, { prefix: t("Stopped"), bots: [target], onOpen: (bot) => bot.id && actions.openExchange(self?.id ?? sessionId, bot.id), whole: true }); -+ } -+ case "create_bot": { -+ const created = findBot(arg("name")); -+ if (!done || created === void 0) return null; + } + case "create_bot": { + const created = findBot(arg("name")); + if (!done || created === void 0) return null; +- return (0, import_react6.createElement)(EventLine, { prefix: t("Created"), bots: [created], onOpen: (bot) => actions.openSession(bot.id) }); + return (0, import_react8.createElement)(EventLine, { prefix: t("Created"), bots: [created], onOpen: (bot) => actions.openSession(bot.id) }); -+ } -+ case "update_bot": { -+ const target = findBot(arg("name")) ?? findBot(arg("bot")) ?? (arg("bot") ? void 0 : self); + } + case "update_bot": { + const target = findBot(arg("name")) ?? findBot(arg("bot")) ?? (arg("bot") ? void 0 : self); +- return done && target ? (0, import_react6.createElement)(EventLine, { prefix: t("Updated"), bots: [target], onOpen: (bot) => actions.openSession(bot.id) }) : null; + return done && target ? (0, import_react8.createElement)(EventLine, { prefix: t("Updated"), bots: [target], onOpen: (bot) => actions.openSession(bot.id) }) : null; -+ } -+ case "group_relay": + } + case "group_relay": +- return (0, import_react6.createElement)(GroupReplies, { sessionId, callId: call2.callId, done, content: call2.content, roster, actions, useUi, useWork }); + return (0, import_react8.createElement)(GroupReplies, { sessionId, callId: call4.callId, done, content: call4.content, roster, actions, useUi }); -+ case "create_group": -+ case "update_group": -+ case "delete_group": -+ case "post_to_group": { -+ const verbs = { -+ create_group: ["Created group", "Couldn't create the group"], -+ update_group: ["Updated group", "Couldn't update the group"], -+ delete_group: ["Deleted group", "Couldn't delete the group"], -+ post_to_group: ["Posted in", "Couldn't post in the group"] -+ }[tool]; -+ if (!done) return null; + case "create_group": + case "update_group": + case "delete_group": +@@ -4492,64 +4492,72 @@ + post_to_group: ["Posted in", "Couldn't post in the group"] + }[tool]; + if (!done) return null; +- const result = contentText(call2.content); + const result = contentText(call4.content); -+ const named = /^(Created group|Updated|Nothing changed in|Deleted|Posted in) "([^"]+)"/.exec(result); + const named = /^(Created group|Updated|Nothing changed in|Deleted|Posted in) "([^"]+)"/.exec(result); +- if (call2.isError || !named) return (0, import_react6.createElement)("div", { className: "bt-event", title: result }, t(verbs[1])); + if (call4.isError || !named) return (0, import_react8.createElement)("div", { className: "bt-event", title: result }, t(verbs[1])); -+ const name = named[2]; -+ const room2 = roster.rooms.find((entry) => entry.name === name); + const name = named[2]; + const room2 = roster.rooms.find((entry) => entry.name === name); +- return (0, import_react6.createElement)(GroupEvent, { prefix: t(verbs[0]), room: tool === "delete_group" ? void 0 : room2, name, roster, actions }); + return (0, import_react8.createElement)(GroupEvent, { prefix: t(verbs[0]), room: tool === "delete_group" ? void 0 : room2, name, roster, actions }); -+ } -+ case "ask_user": { -+ const question = roster.questions?.[sessionId]; + } + case "ask_user": { + const question = roster.questions?.[sessionId]; +- if (!question || question.callId !== call2.callId) return (0, import_react6.createElement)("span", { className: "bt-q-gone", hidden: true }); +- return (0, import_react6.createElement)(PendingQuestion, { sessionId, question, actions, inline: true }); + if (!question || question.callId !== call4.callId) return (0, import_react8.createElement)("span", { className: "bt-q-gone", hidden: true }); + return (0, import_react8.createElement)(PendingQuestion, { sessionId, question, actions, inline: true }); -+ } -+ case "request_secret": + } + case "request_secret": +- return (0, import_react6.createElement)(SecretToolCell, { sessionId, callId: call2.callId, name: arg("name"), done, content: call2.content, roster, actions }); + return (0, import_react8.createElement)(SecretToolCell, { sessionId, callId: call4.callId, name: arg("name"), done, content: call4.content, roster, actions }); -+ case "ask_user_question": { -+ if (!done) return null; -+ let parsed = null; -+ try { + case "ask_user_question": { + if (!done) return null; + let parsed = null; + try { +- parsed = JSON.parse(contentText(call2.content)); + parsed = JSON.parse(contentText(call4.content)); -+ } catch { -+ parsed = null; + } catch { + parsed = null; } -- }), -- (0, import_react6.createElement)(PartLine, { time: view.startedAt }) -- ); -+ const answer = parsed?.answers?.[0]; -+ const question = argValue("questions")?.[0]; -+ if (!answer) return null; + const answer = parsed?.answers?.[0]; + const question = argValue("questions")?.[0]; + if (!answer) return null; +- return (0, import_react6.createElement)("div", { className: "bt-q-inline" }, (0, import_react6.createElement)(SettledQuestion, { question, answer })); + return (0, import_react8.createElement)("div", { className: "bt-q-inline" }, (0, import_react8.createElement)(SettledQuestion, { question, answer })); -+ } -+ case "read_group_chat": { -+ if (!done) return null; -+ const room2 = roster.rooms.find((entry) => entry.name === arg("group")); + } + case "read_group_chat": { + if (!done) return null; + const room2 = roster.rooms.find((entry) => entry.name === arg("group")); +- return room2 ? (0, import_react6.createElement)(GroupEvent, { prefix: t("Read"), room: room2, roster, actions }) : null; + return room2 ? (0, import_react8.createElement)(GroupEvent, { prefix: t("Read"), room: room2, roster, actions }) : null; -+ } -+ case "remember": -+ case "forget": + } + case "remember": + case "forget": +- return done && !call2.isError ? (0, import_react6.createElement)(MemoryEvent, { sessionId, scope: arg("scope"), result: contentText(call2.content), roster, actions, findBot }) : null; + return done && !call4.isError ? (0, import_react8.createElement)(MemoryEvent, { sessionId, scope: arg("scope"), result: contentText(call4.content), roster, actions, findBot }) : null; -+ default: -+ return null; -+ } + default: + return null; + } } --function TriggerCell({ node, sessionId, useRoster, useSessions, useTurnClock, useUi, actions }) { +-function ToolCell({ node, sessionId, useRoster, useUi, useWork, actions }) { +function ToolCell({ node, sessionId, useRoster, useUi, actions }) { const roster = useRoster((value) => value); -- const data = node.data; -- const source = data.source !== null && typeof data.source === "object" ? data.source : {}; -- const selfId = roster.byId[sessionId]?.id ?? sessionId; -- const deferred = source.kind === "bot" && source.deferred === true; -- const relayed = isRelayContext(data); -- const turn = node.location?.turn?.turn ?? data.turn; -- (0, import_react6.useLayoutEffect)(() => { -- if (relayed) actions.noteTurnPeer(sessionId, turn, source.senderSessionId); -- }, [relayed, sessionId, turn, source.senderSessionId]); -- const order = useSessions((list) => { -- const outline = list.projectionsBySession?.[sessionId]?.values?.turnOutline ?? list.byId[sessionId]?.projectionValues?.turnOutline; -- return Array.isArray(outline) ? outline.map((entry) => entry.turn).join(",") : ""; -- }); -- const peers = useTurnClock((state) => { -- if (!relayed) return ""; -- const turns = state[sessionId] ?? {}; -- return Object.keys(turns).filter((key) => turns[key].peer !== void 0).map((key) => `${key}:${turns[key].peer}`).join(","); -- }); -- const visit = useUi((state) => state.visits?.[sessionId]); -- const hiddenTurns = roster.exchangeTurns[sessionId]; -- const unreadOpening = useTurnClock((state) => { -- const first = openingTurn(state[sessionId], order, hiddenTurns); -- return first?.turn === turn && unseenSince(visit, first.start) ? clockLabel(first.start) : ""; -- }); -- const body = triggerBody(); -- return unreadOpening ? (0, import_react6.createElement)(import_react6.Fragment, null, (0, import_react6.createElement)("div", { className: "bt-open-new" }, (0, import_react6.createElement)(NewDivider), (0, import_react6.createElement)("div", { className: "bt-time-sep", role: "separator" }, unreadOpening)), body) : body; -- function triggerBody() { -- if (source.kind === "bot") { -- if (source.handoff === true) return roster.roomsById[sessionId] ? null : (0, import_react6.createElement)(EarlierParts, { sessionId, roster, actions }); -- if (source.role === "kickoff" || source.role === "room") return null; -- if (source.role === "secret" || deferred) return null; -- if (source.role === "post") { -- if (source.failed !== void 0 || source.resumed === true || source.stopped === true) return null; -- const room2 = roster.roomsById[sessionId]; -- const poster = roster.byId[source.senderSessionId] ?? { id: source.senderSessionId, name: source.senderName ?? t("a Bot"), color: "gray" }; -- return (0, import_react6.createElement)(GroupMessage, { bot: poster, text: stripHeader(contentText(data.content)), room: room2, roster, actions, msgKey: `${sessionId}:${data.turn}:post`, useUi }); -- } -- if (source.role === "report") { -- return (0, import_react6.createElement)(GroupEvent, { prefix: t("Replies from"), room: roster.roomsById[source.roomId], name: t("a group chat"), roster, actions }); -- } -- if (source.role === "resume" || source.resumed === true) return (0, import_react6.createElement)("div", { className: "bt-event" }, t("Picked up its work after a restart")); -- if (source.role === "retry" || source.retried === true) return (0, import_react6.createElement)("div", { className: "bt-event" }, t("Asked to make the tool call it wrote as text")); -- const run = relayed ? commRunOf( -- order.split(",").filter(Boolean).map(Number), -- Object.fromEntries(peers.split(",").filter(Boolean).map((pair) => [Number(pair.slice(0, pair.indexOf(":"))), pair.slice(pair.indexOf(":") + 1)])), -- turn, -- hiddenTurns -- ) : null; -- if (run === "folded") return null; -- const sender = roster.byId[source.senderSessionId] ?? { id: source.senderSessionId, name: source.senderName ?? t("a Bot"), color: "gray" }; -- const count = run?.count ?? (isHiddenTurn(roster, sessionId, turn) ? 2 : 1); -- const bots = [sender]; -- for (const peer of (run?.peerIds ?? []).map((id) => roster.byId[id]).filter(Boolean)) { -- if (!bots.some((bot) => bot.id === peer.id)) bots.push(peer); -- } -- return (0, import_react6.createElement)(EventLine, { -- prefix: source.role === "stopped" ? t("Stopped by") : count > 1 ? t("{count} messages with", { count }) : t("Message from"), -- bots, -- onOpen: (bot) => actions.openExchange(selfId, bot.id), -- whole: true -- }); -+ const root = node.data.root; -+ const turn = node.location?.turn?.turn ?? root.turn; -+ const hidden = isHiddenTurn(roster, sessionId, turn); + const root = node.data.root; + const turn = node.location?.turn?.turn ?? root.turn; + const hidden = isHiddenTurn(roster, sessionId, turn); +- const findBot = (nameOrId) => roster.byId[nameOrId] ?? roster.bots.find((bot) => bot.name.toLowerCase() === String(nameOrId ?? "").toLowerCase()); + const findBot = (nameOrId) => roster.byId[nameOrId] ?? botByName(roster.bots, nameOrId); -+ const calls = hidden ? [] : callTree(root); -+ const drawn = calls.filter((call4) => DRAWN.has(toolName(call4))); + const calls = hidden ? [] : callTree(root); +- const drawn = calls.filter((call2) => DRAWN.has(toolName(call2))); +- const running = root.kind === "tool-result" ? void 0 : calls.findLast((call2) => call2.kind !== "tool-result"); ++ const drawn = calls.filter((call4) => DRAWN.has(toolName(call4))); + const running = root.kind === "tool-result" ? void 0 : calls.findLast((call4) => call4.kind !== "tool-result"); -+ const note = running ? toolActivity(toolName(running), toolArgs(running).text, findBot, roster.byId[sessionId]) : null; -+ useLiveNote(actions, sessionId, `tool:${root.callId ?? node.key}`, note); + const note = running ? toolActivity(toolName(running), toolArgs(running).text, findBot, roster.byId[sessionId]) : null; + useLiveNote(actions, sessionId, `tool:${root.callId ?? node.key}`, note); +- useShownCards(actions, sessionId, drawn.filter((call2) => CARDS.has(toolName(call2)) && call2.callId).map((call2) => call2.callId)); + useShownCards(actions, sessionId, drawn.filter((call4) => CARDS.has(toolName(call4)) && call4.callId).map((call4) => call4.callId)); -+ if (drawn.length === 0) return null; + if (drawn.length === 0) return null; +- const env = { sessionId, roster, actions, useUi, useWork, findBot }; +- return (0, import_react6.createElement)(import_react6.Fragment, null, drawn.map((call2, index) => (0, import_react6.createElement)(import_react6.Fragment, { key: call2.callId ?? index }, renderCall(call2, env)))); + const env = { sessionId, roster, actions, useUi, findBot }; + return (0, import_react8.createElement)(import_react8.Fragment, null, drawn.map((call4, index) => (0, import_react8.createElement)(import_react8.Fragment, { key: call4.callId ?? index }, renderCall(call4, env)))); +} @@ -5496,19 +4923,21 @@ diff --git a/client.js b/client.js + const at = roster?.remoteAwaiting?.[sessionId]; + if (typeof at !== "number" || roster?.byId?.[sessionId]?.remote === void 0) return false; + return now - at < AWAIT_REPLY_MS; -+} -+ -+// src/client/activity-row.js + } + + // src/client/activity-row.js +-var import_react7 = require("react"); +var import_react9 = require("react"); -+var import_react_dom2 = require("react-dom"); -+var PHRASE_HOLD_MS = 800; -+var drawPhrase = (pool) => t(pool[Math.min(pool.length - 1, Math.floor(Math.random() * pool.length))]); -+function elapsedLabel(ms) { -+ const minutes = Math.floor(ms / 6e4); -+ if (minutes < 60) return `${Math.max(1, minutes)}m`; -+ const hours = Math.floor(minutes / 60); -+ return minutes % 60 === 0 ? `${hours}h` : `${hours}h ${minutes % 60}m`; -+} + var import_react_dom2 = require("react-dom"); + var PHRASE_HOLD_MS = 800; + var drawPhrase = (pool) => t(pool[Math.min(pool.length - 1, Math.floor(Math.random() * pool.length))]); +@@ -4559,9 +4559,71 @@ + const hours = Math.floor(minutes / 60); + return minutes % 60 === 0 ? `${hours}h` : `${hours}h ${minutes % 60}m`; + } +-function useRunningHost(active) { +- const [host, setHost] = (0, import_react7.useState)(null); +- (0, import_react7.useEffect)(() => { +var RUNNING_HOST = "[data-chat-flow] > [data-chat-running]"; +var TAIL_HOST = "tail"; +var REMOTE_GRACE_MS = 1500; @@ -5548,17 +4977,11 @@ diff --git a/client.js b/client.js + sendTurn.current = -1; + setReplied(false); + return void 0; - } -- if (source.kind === "schedule") { -- const prompts = reminderPrompts(contentText(data.content)); -- return (0, import_react6.createElement)("div", { className: "bt-event" }, prompts.length > 0 ? t("Scheduled task: {text}", { text: prompts.join(" · ").slice(0, 80) }) : t("Routine started")); ++ } + if (wasSending.current) { + wasSending.current = false; + sendTurn.current = lastTurnOnScreen(); - } -- if (source.kind === "user-question-reply") return null; -- return (0, import_react6.createElement)("div", { className: "bt-event" }, contentText(data.content).slice(0, 80) || t("Update")); -- } ++ } + if (sendTurn.current < 0) return void 0; + let frame = 0; + const check = () => { @@ -5576,111 +4999,49 @@ diff --git a/client.js b/client.js + }; + }, [remote, sending]); + return replied; - } --function NewDivider() { -- return (0, import_react6.createElement)("div", { className: "bt-new-sep", role: "separator", "aria-label": t("New messages") }, (0, import_react6.createElement)("span", null, t("New"))); ++} +function useRunningHost(active, where = RUNNING_HOST) { + const [host, setHost] = (0, import_react9.useState)(null); + (0, import_react9.useEffect)(() => { -+ if (!active) { -+ setHost(null); -+ return void 0; -+ } -+ let frame = 0; -+ const find = () => { -+ frame = 0; + if (!active) { + setHost(null); + return void 0; +@@ -4569,7 +4569,7 @@ + let frame = 0; + const find = () => { + frame = 0; +- const node = document.querySelector("[data-chat-flow] > [data-chat-running]"); + const node = findHost(where); -+ setHost((prev) => prev === node ? prev : node); -+ }; -+ const observer = new MutationObserver(() => { -+ if (!frame) frame = requestAnimationFrame(find); -+ }); -+ observer.observe(document.querySelector("[data-conversation-scroll]") ?? document.body, { childList: true, subtree: true }); -+ find(); -+ return () => { -+ cancelAnimationFrame(frame); -+ observer.disconnect(); -+ }; + setHost((prev) => prev === node ? prev : node); + }; + const observer = new MutationObserver(() => { +@@ -4581,13 +4581,13 @@ + cancelAnimationFrame(frame); + observer.disconnect(); + }; +- }, [active]); + }, [active, where]); -+ return host; + return host; } --function TurnProcessCell({ node, sessionId, turnProcess, useRoster, useUi, useTurnClock, actions }) { -- const open = turnProcess === void 0 || !turnProcess.foldable || turnProcess.open; -- (0, import_react6.useEffect)(() => { -- if (!open) turnProcess.setOpen(true); -- }, [open, turnProcess]); -- const turn = node?.location?.turn?.turn ?? node?.data?.turn; -- const start = node?.location?.turn?.start?.time; -- const hidden = useRoster((roster) => isHiddenTurn(roster, sessionId, turn)); -- (0, import_react6.useEffect)(() => { -- actions.noteTurnTime(sessionId, turn, "first", start); -- }, [sessionId, turn, start]); -- const visit = useUi((state) => state.visits?.[sessionId]); -- const fresh = useTurnClock((state) => { -- const own = state[sessionId]?.[turn]; -- return !hidden && typeof visit?.seen === "number" && own?.first !== void 0 && own.first <= visit.seen && own.end !== void 0 && own.end > visit.seen && own.end <= visit.entered; -- }); -- return fresh ? (0, import_react6.createElement)(NewDivider) : null; -+function useRowEntrances(replacing) { + function useRowEntrances(replacing) { +- const check = (0, import_react7.useRef)(replacing); + const check = (0, import_react9.useRef)(replacing); -+ check.current = replacing; + check.current = replacing; +- (0, import_react7.useEffect)(() => { + (0, import_react9.useEffect)(() => { -+ const seen = /* @__PURE__ */ new WeakSet(); -+ const started = Date.now(); -+ let column = null; -+ let frame = 0; -+ const settle = (event) => { -+ if (event.target !== event.currentTarget || !/^bt-row/.test(event.animationName)) return; -+ delete event.currentTarget.dataset.btEnter; -+ event.currentTarget.removeEventListener("animationend", settle); -+ }; -+ const scan = () => { -+ frame = 0; -+ const next = document.querySelector("[data-chat-flow]"); -+ if (next !== column) { -+ observer.disconnect(); -+ column = next; -+ if (column) observer.observe(column, { childList: true, subtree: true }); -+ } -+ if (!column) return; -+ const items = [...column.querySelectorAll(":scope > [data-chat-flow-key]")]; -+ let last = -1; -+ items.forEach((item, index) => { -+ if (seen.has(item)) last = index; -+ }); -+ const fresh = items.filter((item) => !seen.has(item) && item.offsetHeight > 0); -+ for (const item of fresh) seen.add(item); -+ if (last < 0 || Date.now() - started < 800 || fresh.length > 3) return; -+ for (const item of fresh) { -+ if (items.indexOf(item) < last) continue; -+ const reply = [void 0, "assistant-step", "tool-call"].includes(item.dataset.chatFlowKind); -+ item.dataset.btEnter = reply && check.current() ? "after-collapse" : "new"; -+ item.addEventListener("animationend", settle); -+ } -+ }; -+ const observer = new MutationObserver(() => { -+ if (!frame) frame = requestAnimationFrame(scan); -+ }); -+ scan(); -+ return () => { -+ cancelAnimationFrame(frame); -+ observer.disconnect(); -+ }; -+ }, []); - } --function Nothing() { -- return null; -+function ActivityIndicator({ sessionId, useRoster, useSessionStatus, useLive, useSession, actions }) { -+ const roster = useRoster((value) => value); -+ const running = useSessionStatus((map) => map.get(sessionId)?.running === true); -+ const liveKey = useLive((state) => JSON.stringify(state[sessionId] ?? {})); -+ const pendingInput = useSession((snapshot) => Math.max(0, ...(snapshot?.pendingSubmissions ?? []).filter((sub) => sub.placement !== "queued").map((sub) => sub.time))); -+ const bot = roster.byId[sessionId]; -+ const room2 = roster.roomsById[sessionId]; + const seen = /* @__PURE__ */ new WeakSet(); + const started = Date.now(); + let column = null; +@@ -4638,21 +4638,30 @@ + const pendingInput = useSession((snapshot) => Math.max(0, ...(snapshot?.pendingSubmissions ?? []).filter((sub) => sub.placement !== "queued").map((sub) => sub.time))); + const bot = roster.byId[sessionId]; + const room2 = roster.roomsById[sessionId]; +- const entries2 = (0, import_react7.useMemo)(() => Object.values(JSON.parse(liveKey)), [liveKey]); + const entries2 = (0, import_react9.useMemo)(() => Object.values(JSON.parse(liveKey)), [liveKey]); -+ const streaming = entries2.some((entry) => entry.text); -+ const tools = entries2.filter((entry) => !entry.text).sort((a, b) => a.at - b.at); -+ const relaying = tools.some((entry) => entry.relay); + const streaming = entries2.some((entry) => entry.text); + const tools = entries2.filter((entry) => !entry.text).sort((a, b) => a.at - b.at); + const relaying = tools.some((entry) => entry.relay); +- const visible = running && !streaming && !relaying && (bot !== void 0 || room2 !== void 0); + const awaiting = useAwaitingReply(roster, sessionId); + const sendRun = (0, import_react9.useRef)(false); + if (!running) sendRun.current = false; @@ -5691,36 +5052,47 @@ diff --git a/client.js b/client.js + const bridging = bot?.remote !== void 0 && !streaming && lastRun.current.running && lastRun.current.send && !running; + lastRun.current = { running, send: sendRun.current }; + const steady = bridging; -+ const tool = tools.at(-1); + const tool = tools.at(-1); +- const inputRef = (0, import_react7.useRef)(0); + const inputRef = (0, import_react9.useRef)(0); -+ const inputAt = Math.max(inputRef.current, pendingInput); -+ inputRef.current = inputAt; -+ const want = !visible ? null : inputAt > (tool?.at ?? 0) ? { key: `input:${inputAt}`, pool: PHRASES.thinking, state: "thinking" } : tool ? { key: tool.label ? `tool:${tool.label}` : "working", label: tool.label, pool: PHRASES.working, state: tool.state ?? "working" } : { key: "thinking", pool: PHRASES.thinking, state: "thinking" }; + const inputAt = Math.max(inputRef.current, pendingInput); + inputRef.current = inputAt; + const want = !visible ? null : inputAt > (tool?.at ?? 0) ? { key: `input:${inputAt}`, pool: PHRASES.thinking, state: "thinking" } : tool ? { key: tool.label ? `tool:${tool.label}` : "working", label: tool.label, pool: PHRASES.working, state: tool.state ?? "working" } : { key: "thinking", pool: PHRASES.thinking, state: "thinking" }; +- const [shown, setShown] = (0, import_react7.useState)(null); + const [shown, setShown] = (0, import_react9.useState)(null); -+ const wantKey = want?.key ?? null; + const wantKey = want?.key ?? null; +- const markState = (0, import_react7.useRef)("thinking"); + const markState = (0, import_react9.useRef)("thinking"); -+ if (want) markState.current = want.state; + if (want) markState.current = want.state; +- (0, import_react7.useEffect)(() => { + (0, import_react9.useEffect)(() => { -+ if (wantKey === null || shown?.key === wantKey) return void 0; -+ const make = () => setShown({ key: wantKey, text: want.label ?? drawPhrase(want.pool), at: Date.now() }); -+ const wait = shown ? shown.at + PHRASE_HOLD_MS - Date.now() : 0; -+ if (wait <= 0) { -+ make(); -+ return void 0; -+ } -+ const timer = setTimeout(make, wait); -+ return () => clearTimeout(timer); -+ }, [wantKey, shown]); + if (wantKey === null || shown?.key === wantKey) return void 0; + const make = () => setShown({ key: wantKey, text: want.label ?? drawPhrase(want.pool), at: Date.now() }); + const wait = shown ? shown.at + PHRASE_HOLD_MS - Date.now() : 0; +@@ -4663,24 +4663,29 @@ + const timer = setTimeout(make, wait); + return () => clearTimeout(timer); + }, [wantKey, shown]); +- const [phase, setPhase] = (0, import_react7.useState)(null); +- const endedAt = (0, import_react7.useRef)(0); +- const shift = (0, import_react7.useRef)(null); +- const leaving = (0, import_react7.useRef)(false); + const [phase, setPhase] = (0, import_react9.useState)(null); + const endedAt = (0, import_react9.useRef)(0); + const shift = (0, import_react9.useRef)(null); + const leaving = (0, import_react9.useRef)(false); -+ leaving.current = phase !== null && !visible; + leaving.current = phase !== null && !visible; +- (0, import_react7.useEffect)(() => { + (0, import_react9.useEffect)(() => { -+ if (visible) { -+ setPhase("in"); -+ return void 0; -+ } + if (visible) { + setPhase("in"); + return void 0; + } +- setPhase((current) => current === "in" ? "out" : current); +- const timer = setTimeout(() => { +- shift.current = document.querySelector("[data-conversation-scroll]")?.scrollTop ?? null; +- setPhase((current) => current === "out" ? null : current); +- }, 140); + let timer; + const leave = () => { + setPhase((current) => current === "in" ? "out" : current); @@ -5731,29 +5103,31 @@ diff --git a/client.js b/client.js + }; + if (steady) timer = setTimeout(leave, REMOTE_GRACE_MS); + else leave(); -+ return () => clearTimeout(timer); -+ }, [visible]); + return () => clearTimeout(timer); + }, [visible]); +- (0, import_react7.useLayoutEffect)(() => { + (0, import_react9.useLayoutEffect)(() => { -+ if (phase !== null) return; -+ endedAt.current = Date.now(); -+ setShown(null); -+ const before = shift.current; -+ shift.current = null; -+ const scroller = document.querySelector("[data-conversation-scroll]"); -+ if (before === null || !scroller || reducedMotion()) return; -+ const delta = before - scroller.scrollTop; -+ if (delta > 0) scroller.querySelector("[data-chat-flow]")?.animate([{ transform: `translateY(${-delta}px)` }, { transform: "none" }], { duration: 140, easing: "cubic-bezier(.23,1,.32,1)" }); -+ }, [phase]); + if (phase !== null) return; + endedAt.current = Date.now(); + setShown(null); +@@ -4691,31 +4691,37 @@ + const delta = before - scroller.scrollTop; + if (delta > 0) scroller.querySelector("[data-chat-flow]")?.animate([{ transform: `translateY(${-delta}px)` }, { transform: "none" }], { duration: 140, easing: "cubic-bezier(.23,1,.32,1)" }); + }, [phase]); +- (0, import_react7.useEffect)(() => { + (0, import_react9.useEffect)(() => { -+ if (!running) actions.clearLive(sessionId); -+ }, [running, sessionId]); -+ useRowEntrances(() => leaving.current || Date.now() - endedAt.current < 300); + if (!running) actions.clearLive(sessionId); + }, [running, sessionId]); + useRowEntrances(() => leaving.current || Date.now() - endedAt.current < 300); +- const [now, setNow] = (0, import_react7.useState)(Date.now); +- (0, import_react7.useEffect)(() => { + const [now, setNow] = (0, import_react9.useState)(Date.now); + (0, import_react9.useEffect)(() => { -+ if (phase !== "in") return void 0; -+ const timer = setInterval(() => setNow(Date.now()), 1e4); -+ return () => clearInterval(timer); -+ }, [phase]); + if (phase !== "in") return void 0; + const timer = setInterval(() => setNow(Date.now()), 1e4); + return () => clearInterval(timer); + }, [phase]); +- const host = useRunningHost(running); + const remote = bot?.remote !== void 0; + const host = useRunningHost(running || remote && phase !== null, running || !remote ? RUNNING_HOST : TAIL_HOST); + const drawn = (0, import_react9.useRef)(false); @@ -5761,52 +5135,34 @@ diff --git a/client.js b/client.js + (0, import_react9.useEffect)(() => { + drawn.current = Boolean(host && phase !== null && shown); + }, [host, phase, shown]); -+ if (!host || phase === null || !shown) return null; -+ const elapsed = now - shown.at >= 6e4 ? elapsedLabel(now - shown.at) : null; + if (!host || phase === null || !shown) return null; + const elapsed = now - shown.at >= 6e4 ? elapsedLabel(now - shown.at) : null; +- return (0, import_react_dom2.createPortal)((0, import_react7.createElement)( + return (0, import_react_dom2.createPortal)((0, import_react9.createElement)( -+ "div", + "div", +- { className: "bt-typing", "data-exiting": phase === "out" ? "" : void 0, "aria-hidden": true }, +- bot ? (0, import_react7.createElement)(BotMark, { bot, size: 28, state: markState.current, live: true, pokeable: true }) : (0, import_react7.createElement)(RoomAvatar, { room: room2, roster, size: 28 }), +- (0, import_react7.createElement)( + { className: "bt-typing", "data-exiting": phase === "out" ? "" : void 0, "data-carry": carry && phase !== "out" ? "" : void 0, "data-remote": remote ? "" : void 0, "aria-hidden": true }, + bot ? (0, import_react9.createElement)(BotMark, { bot, size: 28, state: markState.current, live: true, pokeable: true }) : (0, import_react9.createElement)(RoomAvatar, { room: room2, roster, size: 28 }), + (0, import_react9.createElement)( -+ "span", -+ { className: "bt-typing-text" }, + "span", + { className: "bt-typing-text" }, +- (0, import_react7.createElement)( + (0, import_react9.createElement)( -+ "span", -+ { key: `${shown.key}:${shown.at}`, className: "bt-typing-label" }, + "span", + { key: `${shown.key}:${shown.at}`, className: "bt-typing-label" }, +- (0, import_react7.createElement)(Shimmer, null, `${shown.text}…`), +- elapsed ? (0, import_react7.createElement)("span", { className: "bt-typing-time" }, `· ${elapsed}`) : null + (0, import_react9.createElement)(Shimmer, null, `${shown.text}…`), + elapsed ? (0, import_react9.createElement)("span", { className: "bt-typing-time" }, `· ${elapsed}`) : null -+ ) -+ ) -+ ), host); - } --var SEPARATOR_GAP_MS = 30 * 60 * 1e3; --function clockLabel(time, now = Date.now()) { -- const date = new Date(time); -- const midnight = (value) => new Date(value.getFullYear(), value.getMonth(), value.getDate()).getTime(); -- const days = Math.round((midnight(new Date(now)) - midnight(date)) / 864e5); -- const clock4 = date.toLocaleTimeString(dateLocale(), { hour: "numeric", minute: "2-digit" }); -- if (days === 0) return t("Today {time}", { time: clock4 }); -- if (days === 1) return t("Yesterday {time}", { time: clock4 }); -- const sameYear = date.getFullYear() === new Date(now).getFullYear(); -- return `${date.toLocaleDateString(dateLocale(), { weekday: "short", month: "short", day: "numeric", ...sameYear ? {} : { year: "numeric" } })} ${clock4}`; -+ -+// src/client/dsh-offer.js -+var APP_OFFER = /* @__PURE__ */ new Set(["available", "downloading", "verifying", "installing", "ready"]); -+var bare = (value) => String(value ?? "").trim().replace(/^v/i, "").split("+")[0]; -+var sameVersion = (a, b) => bare(a) !== "" && bare(a) === bare(b); -+function showsDshRow(dsh, app) { -+ if (!dsh?.verified || !dsh.current || sameVersion(dsh.verified, dsh.current)) return false; -+ if (dsh.kind === "npm") return true; -+ return dsh.kind === "app" && APP_OFFER.has(app?.phase) && sameVersion(app.version, dsh.verified); + ) + ) + ), host); +@@ -4731,49 +4731,755 @@ + return dsh.kind === "app" && APP_OFFER.has(app?.phase) && sameVersion(app.version, dsh.verified); } --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; -+ + +// src/client/overlay-hooks.js +var import_react10 = require("react"); +var escapeLayers = []; @@ -5824,15 +5180,7 @@ diff --git a/client.js b/client.js + if (escapeLayers.length === 0) window.removeEventListener("keydown", onEscapeKey); + }; + }, []); - } --function openingTurn(turns, firstTurns, hiddenTurns) { -- if (!turns || !firstTurns) return null; -- const start = nextTurnStart(turns, -Infinity); -- if (start === void 0) return null; -- const turn = Number(Object.keys(turns).filter((key) => turns[key].first === start)[0]); -- const listed = firstTurns.split(",").map(Number); -- const earlier = listed.filter((entry) => entry < turn); -- return listed.includes(turn) && earlier.every((entry) => hiddenTurns?.includes(entry)) ? { turn, start } : null; ++} +function usePaneLeft() { + const [paneLeft, setPaneLeft] = (0, import_react10.useState)(280); + (0, import_react10.useLayoutEffect)(() => { @@ -5840,23 +5188,7 @@ diff --git a/client.js b/client.js + if (column) setPaneLeft(Math.round(column.getBoundingClientRect().right)); + }, []); + return paneLeft; - } --var unseenSince = (visit, time) => typeof visit?.seen === "number" && time > visit.seen && time <= visit.entered; --function TurnTailCell({ turn: location2, sessionId, useTurnClock, useUi, actions }) { -- const turn = location2.turn; -- const time = location2.end.time; -- (0, import_react6.useEffect)(() => { -- actions.noteTurnTime(sessionId, turn, "end", time); -- }, [sessionId, turn, time]); -- const label = useTurnClock((state) => { -- const start = nextTurnStart(state[sessionId] ?? {}, turn); -- return start !== void 0 && start - time >= SEPARATOR_GAP_MS ? clockLabel(start) : ""; -- }); -- const visit = useUi((state) => state.visits?.[sessionId]); -- const fresh = useTurnClock((state) => { -- if (typeof visit?.seen !== "number" || time > visit.seen) return false; -- const start = nextTurnStart(state[sessionId] ?? {}, turn); -- return start !== void 0 && start > visit.seen && start <= visit.entered; ++} + +// src/client/member-status.js +var import_react11 = require("react"); @@ -5878,200 +5210,24 @@ diff --git a/client.js b/client.js + const doing = workIn(room2, work, id); + const approvals = askedIn(room2, work, id, doing); + return { id, approvals, doing, here: doing !== void 0, state: approvals.length > 0 ? "ask" : doing ? "work" : "idle" }; - }); -- if (!label && !fresh) return null; -- return (0, import_react6.createElement)( -- import_react6.Fragment, -- null, -- label ? (0, import_react6.createElement)("div", { className: "bt-time-sep", role: "separator" }, label) : null, -- fresh ? (0, import_react6.createElement)(NewDivider) : null -- ); --} --function Shimmer({ children }) { -- return (0, import_react6.createElement)("span", { className: "bt-shimmer" }, children); - } --var PHRASES = { -- thinking: ["Thinking about it", "Weighing the options", "Working out a plan", "Sorting out the details"], -- working: ["On it", "Busy with it", "Moving ahead", "Taking care of it", "Making headway"] --}; --var TOOL_PHRASES = [ -- [/web_?search|search_?web/i, "Searching online"], -- [/fetch|browse|browser|web/i, "Reading a page"], -- [/shell|bash|pwsh|powershell|exec|command|terminal/i, "Running shell commands"], -- [/^(read|view|cat)\b|read_/i, "Opening a file"], -- [/grep|glob|list_dir|^ls$|find|search/i, "Looking through files"], -- [/write|edit|patch|apply|replace/i, "Writing changes"], -- [/image|photo|draw/i, "Working on an image"] --]; --var GROUP_PHRASES = { create_group: "Creating a group", update_group: "Updating the group", delete_group: "Deleting the group", post_to_group: "Posting in the group" }; --function toolActivity(name, arg, findBot, self) { -- switch (name) { -- case "ask_user": -- return null; -- case "request_secret": -- return null; -- case "list_secrets": -- return { label: t("Checking the keys"), state: "searching" }; -- case "ask_user_question": -- return { label: t("Waiting for your answer"), state: "alert" }; -- case "group_relay": -- return { relay: true, state: "orbit" }; -- case "message_bot": { -- const target = findBot(arg("to")); -- return { label: target ? t("Asking {name}", { name: target.name }) : t("Asking another Bot"), state: "sending" }; -- } -- case "stop_bot": { -- const target = findBot(arg("bot")); -- return { label: target ? t("Stopping {name}", { name: target.name }) : t("Stopping another Bot"), state: "sending" }; -- } -- case "create_bot": -- return { label: t("Creating {name}", { name: arg("name") ?? t("a Bot") }), state: "sending" }; -- case "update_bot": { -- const target = findBot(arg("name")) ?? findBot(arg("bot")) ?? (arg("bot") ? void 0 : self); -- return { label: t("Updating {name}", { name: target?.name ?? t("a Bot") }), state: "sending" }; -- } -- case "list_bots": -- return { label: t("Checking the team"), state: "searching" }; -- case "read_group_chat": -- return { label: t("Reading {name}", { name: arg("group") ?? t("a group chat") }), state: "searching" }; -- case "read_own_chat": -- return { label: t("Looking back through the chat"), state: "searching" }; -- case "remember": -- return { label: t("Saving to memory"), state: "working" }; -- case "forget": -- return { label: t("Updating memory"), state: "working" }; -- case "recall": -- return { label: t("Checking memory"), state: "searching" }; -- default: -- if (GROUP_PHRASES[name]) return { label: t(GROUP_PHRASES[name]), state: "sending" }; -- const phrase = TOOL_PHRASES.find(([pattern]) => pattern.test(name))?.[1]; -- return { label: phrase ? t(phrase) : null, state: toolState(name) }; -- } ++ }); ++} +function busyMembers(room2, work, roster) { + const busy = memberWork(room2, work).filter((entry) => entry.state !== "idle" && roster.byId[entry.id]); + return [...busy.filter((entry) => entry.state === "ask"), ...busy.filter((entry) => entry.state === "work")].map((entry) => ({ ...entry, bot: roster.byId[entry.id] })); - } --function workPhrase(tool) { -- if (!tool) return t("Thinking about it"); -- return toolActivity(tool, () => void 0, () => void 0)?.label ?? t("On it"); ++} +function splitBusy(busy) { + const here = busy.filter((entry) => entry.state === "work" && entry.here); + const rows = [...here.filter((entry) => entry.doing.speaking === true), ...here.filter((entry) => entry.doing.speaking !== true)].slice(0, ROWS_MAX); + return { rows, rest: busy.filter((entry) => !rows.includes(entry)) }; - } --function useLiveNote(actions, sessionId, id, entry) { -- const key = entry ? JSON.stringify(entry) : ""; -- (0, import_react6.useEffect)(() => { -- if (!key) return void 0; -- actions.noteLive(sessionId, id, JSON.parse(key)); -- return () => actions.noteLive(sessionId, id, null); -- }, [sessionId, id, key]); ++} +function botGroupWork(botId, work, roster) { + return Object.entries(work.rooms ?? {}).flatMap(([roomId, entries2]) => { + const entry = entries2.find((item) => item.botId === botId); + const room2 = roster.roomsById[roomId]; + return entry && room2 ? [{ room: room2, tool: entry.tool, step: stepIn(work, botId, roomId, entry.tool), since: entry.since }] : []; + }); - } --function GroupReplies({ sessionId, callId, done, content, roster, actions, useUi, useWork }) { -- const [live, setLive] = (0, import_react6.useState)({ replies: [], speaking: null }); -- (0, import_react6.useEffect)(() => { -- if (done) return void 0; -- let stop = false; -- let timer; -- let seen = ""; -- const tick = async () => { -- const result = await actions.roomProgress(sessionId); -- if (stop) return; -- const entry = (result ?? []).find((item) => item.callId === callId) ?? (result ?? [])[0]; -- const key = entry ? JSON.stringify([entry.replies, entry.speaking]) : ""; -- if (entry && key !== seen) { -- seen = key; -- setLive({ replies: entry.replies ?? [], speaking: entry.speaking ?? null }); -- } -- timer = setTimeout(tick, document.hidden ? 5e3 : 1e3); -- }; -- void tick(); -- return () => { -- stop = true; -- clearTimeout(timer); -- }; -- }, [done, sessionId, callId]); -- let replies = live.replies; -- if (done) { -- try { -- replies = JSON.parse(contentText(content)).replies ?? []; -- } catch { -- replies = []; -- } -- } -- const pending = done || !live.speaking ? [] : [live.speaking]; -- const speakerTool = useWork?.((work) => work.bots?.[live.speaking]?.tool) ?? void 0; -- if (replies.length === 0 && pending.length === 0) return null; -- const room2 = roster.roomsById[sessionId]; -- return (0, import_react6.createElement)( -- "div", -- { className: "bt-group-replies" }, -- replies.map((reply, index) => { -- const bot = roster.byId[reply.botId] ?? { id: reply.botId, name: reply.name, color: reply.color ?? "gray" }; -- if (reply.kind === "user") { -- return (0, import_react6.createElement)("div", { key: `user-${index}`, className: "bt-urow" }, (0, import_react6.createElement)( -- "div", -- { className: "bt-ububble" }, -- (0, import_react6.createElement)(import_dsh_client_ui_primitives.MarkdownText, { text: reply.text, labels: mdLabels(), variant: "compact" }) -- )); -- } -- if (reply.kind === "failed") { -- return (0, import_react6.createElement)( -- "div", -- { key: `${reply.botId}-${index}`, className: "bt-event bt-event-error", role: "status", title: [reply.code, reply.status, reply.text].filter(Boolean).join(" · ") }, -- t("{name} could not answer: {reason}", { name: bot.name, reason: failureLabel(reply.code, reply.text) }) -- ); -- } -- if (reply.kind === "lookup") { -- return (0, import_react6.createElement)( -- "div", -- { key: `${reply.botId}-${index}`, className: "bt-event" }, -- reply.text ? t("{name} looked up its own chat for “{text}”", { name: bot.name, text: reply.text }) : t("{name} looked up its own chat", { name: bot.name }) -- ); -- } -- if (reply.kind === "missed") { -- return (0, import_react6.createElement)("div", { key: `${reply.botId}-${index}`, className: "bt-event" }, t("{name} didn't answer in time", { name: bot.name })); -- } -- if (reply.kind === "resumed") { -- return (0, import_react6.createElement)("div", { key: `${reply.botId}-${index}`, className: "bt-event" }, t("{name} was cut off by a restart and picked its work back up", { name: bot.name })); -- } -- if (reply.kind === "cut") { -- return (0, import_react6.createElement)("div", { key: `${reply.botId}-${index}`, className: "bt-event" }, t("{name} was cut off by a restart again and stopped", { name: bot.name })); -- } -- if (reply.kind === "approval" || reply.kind === "working") { -- return (0, import_react6.createElement)( -- "div", -- { key: `${reply.botId}-${index}`, className: "bt-event" }, -- (0, import_react6.createElement)( -- "button", -- { type: "button", className: "bt-event-whole", onClick: () => actions.showDetails(sessionId) }, -- (0, import_react6.createElement)(BotMark, { bot, size: 16 }), -- (0, import_react6.createElement)("span", { className: "bt-event-name" }, t(reply.kind === "working" ? "{name} started on it" : "{name} paused for your approval", { name: bot.name })) -- ) -- ); -- } -- if (reply.kind === "stopped") { -- return (0, import_react6.createElement)("div", { key: `${reply.botId}-${index}`, className: "bt-event" }, t("You stopped {name}'s work", { name: bot.name })); -- } -- return (0, import_react6.createElement)(GroupMessage, { key: `${reply.botId}-${index}`, bot, text: reply.text, room: room2, roster, actions, msgKey: callId ? `${sessionId}:${callId}:${index}` : void 0, useUi }); -- }), -- pending.map((id) => { -- const bot = roster.byId[id]; -- if (!bot) return null; -- return (0, import_react6.createElement)( -- "div", -- { key: `pending-${id}`, className: "bt-activity" }, -- (0, import_react6.createElement)(BotMark, { bot, size: 22, state: speakerTool ? toolState(speakerTool) : "thinking" }), -- (0, import_react6.createElement)(Shimmer, null, speakerTool ? `${bot.name} · ${workPhrase(speakerTool)}…` : t("{name} is typing…", { name: bot.name })) -- ); -- }) -- ); ++} +function summaryText(busy) { + const asks = busy.filter((entry) => entry.state === "ask"); + const workers = busy.filter((entry) => entry.state === "work"); @@ -6080,36 +5236,14 @@ diff --git a/client.js b/client.js + const first = workers[0]; + const doing = first ? `${first.bot.name} · ${workText(first.doing.tool, first.doing.step)}${workers.length > 1 ? ` +${workers.length - 1}` : ""}` : ""; + return { waiting, doing }; - } --var DRAWN = /* @__PURE__ */ new Set(["message_bot", "stop_bot", "create_bot", "update_bot", "group_relay", "create_group", "update_group", "delete_group", "post_to_group", "ask_user", "request_secret", "ask_user_question", "read_group_chat", "remember", "forget"]); --var CARDS = /* @__PURE__ */ new Set(["ask_user", "request_secret"]); --function callTree(root) { -- const calls = []; -- const seen = /* @__PURE__ */ new Set(); -- const visit = (call2) => { -- const id = call2.callId ?? call2; -- if (seen.has(id)) return; -- seen.add(id); -- calls.push(call2); -- for (const child of call2.subCalls ?? []) visit(child); -- }; -- visit(root); -- return calls; ++} +function Face({ entry, size }) { + return (0, import_react11.createElement)( + "span", + { className: "bt-mw-face", "data-ask": entry.state === "ask" || void 0 }, + (0, import_react11.createElement)(BotMark, { bot: entry.bot, size, state: entry.state === "ask" ? "alert" : toolState(entry.doing?.tool ?? "") }) + ); - } --function useShownCards(actions, sessionId, callIds) { -- const key = JSON.stringify(callIds); -- (0, import_react6.useLayoutEffect)(() => { -- const ids = JSON.parse(key); -- if (ids.length === 0 || !actions.noteCards) return void 0; -- actions.noteCards(sessionId, ids, 1); -- return () => actions.noteCards(sessionId, ids, -1); -- }, [sessionId, key]); ++} +var elapsedSince = (now, at) => typeof at === "number" && now - at >= 6e4 ? elapsedLabel(now - at) : ""; +function WorkRow({ bot, tool, step, label, at, now, onOpen }) { + const text = `${label} · ${workText(tool, step)}`; @@ -6121,83 +5255,7 @@ diff --git a/client.js b/client.js + (0, import_react11.createElement)("span", { className: "bt-mw-row-text" }, (0, import_react11.createElement)(Shimmer, null, text)), + elapsed ? (0, import_react11.createElement)("span", { className: "bt-mw-row-time" }, `· ${elapsed}`) : null + ); - } --function renderCall(call2, { sessionId, roster, actions, useUi, useWork, findBot }) { -- const tool = toolName(call2); -- const done = call2.kind === "tool-result"; -- const { text: arg, value: argValue } = toolArgs(call2); -- const self = roster.byId[sessionId]; -- switch (tool) { -- case "message_bot": { -- const target = findBot(arg("to")) ?? { name: arg("to") ?? "…", color: "gray" }; -- if (done && call2.isError) return (0, import_react6.createElement)("div", { className: "bt-event" }, t("Couldn't message {name}", { name: target.name })); -- return (0, import_react6.createElement)(EventLine, { prefix: t("Messaged"), bots: [target], onOpen: (bot) => bot.id && actions.openExchange(self?.id ?? sessionId, bot.id), whole: true }); -- } -- case "stop_bot": { -- const target = findBot(arg("bot")) ?? { name: arg("bot") ?? "…", color: "gray" }; -- if (done && call2.isError) return (0, import_react6.createElement)("div", { className: "bt-event" }, t("Couldn't stop {name}", { name: target.name })); -- return (0, import_react6.createElement)(EventLine, { prefix: t("Stopped"), bots: [target], onOpen: (bot) => bot.id && actions.openExchange(self?.id ?? sessionId, bot.id), whole: true }); -- } -- case "create_bot": { -- const created = findBot(arg("name")); -- if (!done || created === void 0) return null; -- return (0, import_react6.createElement)(EventLine, { prefix: t("Created"), bots: [created], onOpen: (bot) => actions.openSession(bot.id) }); -- } -- case "update_bot": { -- const target = findBot(arg("name")) ?? findBot(arg("bot")) ?? (arg("bot") ? void 0 : self); -- return done && target ? (0, import_react6.createElement)(EventLine, { prefix: t("Updated"), bots: [target], onOpen: (bot) => actions.openSession(bot.id) }) : null; -- } -- case "group_relay": -- return (0, import_react6.createElement)(GroupReplies, { sessionId, callId: call2.callId, done, content: call2.content, roster, actions, useUi, useWork }); -- case "create_group": -- case "update_group": -- case "delete_group": -- case "post_to_group": { -- const verbs = { -- create_group: ["Created group", "Couldn't create the group"], -- update_group: ["Updated group", "Couldn't update the group"], -- delete_group: ["Deleted group", "Couldn't delete the group"], -- post_to_group: ["Posted in", "Couldn't post in the group"] -- }[tool]; -- if (!done) return null; -- const result = contentText(call2.content); -- const named = /^(Created group|Updated|Nothing changed in|Deleted|Posted in) "([^"]+)"/.exec(result); -- if (call2.isError || !named) return (0, import_react6.createElement)("div", { className: "bt-event", title: result }, t(verbs[1])); -- const name = named[2]; -- const room2 = roster.rooms.find((entry) => entry.name === name); -- return (0, import_react6.createElement)(GroupEvent, { prefix: t(verbs[0]), room: tool === "delete_group" ? void 0 : room2, name, roster, actions }); -- } -- case "ask_user": { -- const question = roster.questions?.[sessionId]; -- if (!question || question.callId !== call2.callId) return (0, import_react6.createElement)("span", { className: "bt-q-gone", hidden: true }); -- return (0, import_react6.createElement)(PendingQuestion, { sessionId, question, actions, inline: true }); -- } -- case "request_secret": -- return (0, import_react6.createElement)(SecretToolCell, { sessionId, callId: call2.callId, name: arg("name"), done, content: call2.content, roster, actions }); -- case "ask_user_question": { -- if (!done) return null; -- let parsed = null; -- try { -- parsed = JSON.parse(contentText(call2.content)); -- } catch { -- parsed = null; -- } -- const answer = parsed?.answers?.[0]; -- const question = argValue("questions")?.[0]; -- if (!answer) return null; -- return (0, import_react6.createElement)("div", { className: "bt-q-inline" }, (0, import_react6.createElement)(SettledQuestion, { question, answer })); -- } -- case "read_group_chat": { -- if (!done) return null; -- const room2 = roster.rooms.find((entry) => entry.name === arg("group")); -- return room2 ? (0, import_react6.createElement)(GroupEvent, { prefix: t("Read"), room: room2, roster, actions }) : null; -- } -- case "remember": -- case "forget": -- return done && !call2.isError ? (0, import_react6.createElement)(MemoryEvent, { sessionId, scope: arg("scope"), result: contentText(call2.content), roster, actions, findBot }) : null; -- default: -- return null; -- } ++} +function SummaryLine({ busy, now, onOpen }) { + const { waiting, doing } = summaryText(busy); + const elapsed = doing ? elapsedSince(now, busy.find((entry) => entry.state === "work")?.doing.since) : ""; @@ -6214,35 +5272,9 @@ diff --git a/client.js b/client.js + ), + elapsed ? (0, import_react11.createElement)("span", { className: "bt-mw-row-time" }, `· ${elapsed}`) : null + ); - } --function ToolCell({ node, sessionId, useRoster, useUi, useWork, actions }) { ++} +function MemberDock({ sessionId, useRoster, useWork, actions }) { - const roster = useRoster((value) => value); -- const root = node.data.root; -- const turn = node.location?.turn?.turn ?? root.turn; -- const hidden = isHiddenTurn(roster, sessionId, turn); -- const findBot = (nameOrId) => roster.byId[nameOrId] ?? roster.bots.find((bot) => bot.name.toLowerCase() === String(nameOrId ?? "").toLowerCase()); -- const calls = hidden ? [] : callTree(root); -- const drawn = calls.filter((call2) => DRAWN.has(toolName(call2))); -- const running = root.kind === "tool-result" ? void 0 : calls.findLast((call2) => call2.kind !== "tool-result"); -- const note = running ? toolActivity(toolName(running), toolArgs(running).text, findBot, roster.byId[sessionId]) : null; -- useLiveNote(actions, sessionId, `tool:${root.callId ?? node.key}`, note); -- useShownCards(actions, sessionId, drawn.filter((call2) => CARDS.has(toolName(call2)) && call2.callId).map((call2) => call2.callId)); -- if (drawn.length === 0) return null; -- const env = { sessionId, roster, actions, useUi, useWork, findBot }; -- return (0, import_react6.createElement)(import_react6.Fragment, null, drawn.map((call2, index) => (0, import_react6.createElement)(import_react6.Fragment, { key: call2.callId ?? index }, renderCall(call2, env)))); --} -- --// src/client/activity-row.js --var import_react7 = require("react"); --var import_react_dom2 = require("react-dom"); --var PHRASE_HOLD_MS = 800; --var drawPhrase = (pool) => t(pool[Math.min(pool.length - 1, Math.floor(Math.random() * pool.length))]); --function elapsedLabel(ms) { -- const minutes = Math.floor(ms / 6e4); -- if (minutes < 60) return `${Math.max(1, minutes)}m`; -- const hours = Math.floor(minutes / 60); -- return minutes % 60 === 0 ? `${hours}h` : `${hours}h ${minutes % 60}m`; ++ const roster = useRoster((value) => value); + const work = useWork((value) => value); + const room2 = roster.roomsById[sessionId]; + const bot = room2 ? void 0 : roster.byId[sessionId]; @@ -6274,28 +5306,7 @@ diff --git a/client.js b/client.js + now, + onOpen: () => actions.openSession(item.room.id) + }))); - } --function useRunningHost(active) { -- const [host, setHost] = (0, import_react7.useState)(null); -- (0, import_react7.useEffect)(() => { -- if (!active) { -- setHost(null); -- return void 0; -- } -- let frame = 0; -- const find = () => { -- frame = 0; -- const node = document.querySelector("[data-chat-flow] > [data-chat-running]"); -- setHost((prev) => prev === node ? prev : node); -- }; -- const observer = new MutationObserver(() => { -- if (!frame) frame = requestAnimationFrame(find); -- }); -- observer.observe(document.querySelector("[data-conversation-scroll]") ?? document.body, { childList: true, subtree: true }); -- find(); -- return () => { -- cancelAnimationFrame(frame); -- observer.disconnect(); ++} +function useFlowColumn(ref, active, key) { + (0, import_react11.useLayoutEffect)(() => { + const node = ref.current; @@ -6306,7 +5317,7 @@ diff --git a/client.js b/client.js + const column = flow.getBoundingClientRect(); + node.style.marginInlineStart = `${Math.round(column.left - node.getBoundingClientRect().left)}px`; + node.style.width = `${Math.round(column.width)}px`; - }; ++ }; + place(); + const observer = new ResizeObserver(place); + for (const target of [flow, flow.parentElement, node.parentElement]) if (target) observer.observe(target); @@ -6319,49 +5330,9 @@ diff --git a/client.js b/client.js + if (!active) return void 0; + const timer = setInterval(() => setNow(Date.now()), 3e4); + return () => clearInterval(timer); - }, [active]); -- return host; ++ }, [active]); + return now; - } --function useRowEntrances(replacing) { -- const check = (0, import_react7.useRef)(replacing); -- check.current = replacing; -- (0, import_react7.useEffect)(() => { -- const seen = /* @__PURE__ */ new WeakSet(); -- const started = Date.now(); -- let column = null; -- let frame = 0; -- const settle = (event) => { -- if (event.target !== event.currentTarget || !/^bt-row/.test(event.animationName)) return; -- delete event.currentTarget.dataset.btEnter; -- event.currentTarget.removeEventListener("animationend", settle); -- }; -- const scan = () => { -- frame = 0; -- const next = document.querySelector("[data-chat-flow]"); -- if (next !== column) { -- observer.disconnect(); -- column = next; -- if (column) observer.observe(column, { childList: true, subtree: true }); -- } -- if (!column) return; -- const items = [...column.querySelectorAll(":scope > [data-chat-flow-key]")]; -- let last = -1; -- items.forEach((item, index) => { -- if (seen.has(item)) last = index; -- }); -- const fresh = items.filter((item) => !seen.has(item) && item.offsetHeight > 0); -- for (const item of fresh) seen.add(item); -- if (last < 0 || Date.now() - started < 800 || fresh.length > 3) return; -- for (const item of fresh) { -- if (items.indexOf(item) < last) continue; -- const reply = [void 0, "assistant-step", "tool-call"].includes(item.dataset.chatFlowKind); -- item.dataset.btEnter = reply && check.current() ? "after-collapse" : "new"; -- item.addEventListener("animationend", settle); -- } -- }; -- const observer = new MutationObserver(() => { -- if (!frame) frame = requestAnimationFrame(scan); ++} +var since = (now, at) => typeof at === "number" && now - at >= 6e4 ? ` · ${elapsedLabel(now - at)}` : ""; +var alwaysChoices = (approval, bot) => bot?.remote ? [] : [ + ["always-bot", "Always allow for this Bot"], @@ -6511,60 +5482,16 @@ diff --git a/client.js b/client.js + }).catch(() => { + }).finally(() => { + if (!stopped) timer = setTimeout(load, POLL_MS2); - }); -- scan(); ++ }); + load(); - return () => { -- cancelAnimationFrame(frame); -- observer.disconnect(); ++ return () => { + stopped = true; + clearTimeout(timer); + taskListeners.delete(setTasks); - }; - }, []); ++ }; ++ }, []); + return tasks; - } --function ActivityIndicator({ sessionId, useRoster, useSessionStatus, useLive, useSession, actions }) { -- const roster = useRoster((value) => value); -- const running = useSessionStatus((map) => map.get(sessionId)?.running === true); -- const liveKey = useLive((state) => JSON.stringify(state[sessionId] ?? {})); -- const pendingInput = useSession((snapshot) => Math.max(0, ...(snapshot?.pendingSubmissions ?? []).filter((sub) => sub.placement !== "queued").map((sub) => sub.time))); -- const bot = roster.byId[sessionId]; -- const room2 = roster.roomsById[sessionId]; -- const entries2 = (0, import_react7.useMemo)(() => Object.values(JSON.parse(liveKey)), [liveKey]); -- const streaming = entries2.some((entry) => entry.text); -- const tools = entries2.filter((entry) => !entry.text).sort((a, b) => a.at - b.at); -- const relaying = tools.some((entry) => entry.relay); -- const visible = running && !streaming && !relaying && (bot !== void 0 || room2 !== void 0); -- const tool = tools.at(-1); -- const inputRef = (0, import_react7.useRef)(0); -- const inputAt = Math.max(inputRef.current, pendingInput); -- inputRef.current = inputAt; -- const want = !visible ? null : inputAt > (tool?.at ?? 0) ? { key: `input:${inputAt}`, pool: PHRASES.thinking, state: "thinking" } : tool ? { key: tool.label ? `tool:${tool.label}` : "working", label: tool.label, pool: PHRASES.working, state: tool.state ?? "working" } : { key: "thinking", pool: PHRASES.thinking, state: "thinking" }; -- const [shown, setShown] = (0, import_react7.useState)(null); -- const wantKey = want?.key ?? null; -- const markState = (0, import_react7.useRef)("thinking"); -- if (want) markState.current = want.state; -- (0, import_react7.useEffect)(() => { -- if (wantKey === null || shown?.key === wantKey) return void 0; -- const make = () => setShown({ key: wantKey, text: want.label ?? drawPhrase(want.pool), at: Date.now() }); -- const wait = shown ? shown.at + PHRASE_HOLD_MS - Date.now() : 0; -- if (wait <= 0) { -- make(); -- return void 0; -- } -- const timer = setTimeout(make, wait); -- return () => clearTimeout(timer); -- }, [wantKey, shown]); -- const [phase, setPhase] = (0, import_react7.useState)(null); -- const endedAt = (0, import_react7.useRef)(0); -- const shift = (0, import_react7.useRef)(null); -- const leaving = (0, import_react7.useRef)(false); -- leaving.current = phase !== null && !visible; -- (0, import_react7.useEffect)(() => { -- if (visible) { -- setPhase("in"); -- return void 0; ++} +var ownBot = (roster, id) => { + const bot = roster.byId?.[id]; + if (bot === void 0 || bot.remote) return void 0; @@ -6740,39 +5667,7 @@ diff --git a/client.js b/client.js + const text = previewOf(sessions, part); + if (text === "") continue; + done.push({ id: `chat:${bot.id}`, kind: "chat", status: "done", bot, at, title: text, place: "", chat: bot.id, ...people(roster, bot.id) }); - } -- setPhase((current) => current === "in" ? "out" : current); -- const timer = setTimeout(() => { -- shift.current = document.querySelector("[data-conversation-scroll]")?.scrollTop ?? null; -- setPhase((current) => current === "out" ? null : current); -- }, 140); -- return () => clearTimeout(timer); -- }, [visible]); -- (0, import_react7.useLayoutEffect)(() => { -- if (phase !== null) return; -- endedAt.current = Date.now(); -- setShown(null); -- const before = shift.current; -- shift.current = null; -- const scroller = document.querySelector("[data-conversation-scroll]"); -- if (before === null || !scroller || reducedMotion()) return; -- const delta = before - scroller.scrollTop; -- if (delta > 0) scroller.querySelector("[data-chat-flow]")?.animate([{ transform: `translateY(${-delta}px)` }, { transform: "none" }], { duration: 140, easing: "cubic-bezier(.23,1,.32,1)" }); -- }, [phase]); -- (0, import_react7.useEffect)(() => { -- if (!running) actions.clearLive(sessionId); -- }, [running, sessionId]); -- useRowEntrances(() => leaving.current || Date.now() - endedAt.current < 300); -- const [now, setNow] = (0, import_react7.useState)(Date.now); -- (0, import_react7.useEffect)(() => { -- if (phase !== "in") return void 0; -- const timer = setInterval(() => setNow(Date.now()), 1e4); -- return () => clearInterval(timer); -- }, [phase]); -- const host = useRunningHost(running); -- if (!host || phase === null || !shown) return null; -- const elapsed = now - shown.at >= 6e4 ? elapsedLabel(now - shown.at) : null; -- return (0, import_react_dom2.createPortal)((0, import_react7.createElement)( ++ } + } + const newest = (a, b) => (b.at ?? Infinity) - (a.at ?? Infinity); + for (const card of [...needs, ...progress, ...done]) { @@ -6820,23 +5715,14 @@ diff --git a/client.js b/client.js + const meta = [who, card.place, whenText(card, now)].filter(Boolean).join(" · "); + const answerable = card.kind === "approval" && card.approval?.answerable === true; + return (0, import_react12.createElement)( - "div", -- { className: "bt-typing", "data-exiting": phase === "out" ? "" : void 0, "aria-hidden": true }, -- bot ? (0, import_react7.createElement)(BotMark, { bot, size: 28, state: markState.current, live: true, pokeable: true }) : (0, import_react7.createElement)(RoomAvatar, { room: room2, roster, size: 28 }), -- (0, import_react7.createElement)( ++ "div", + { className: "bt-inbox-card", role: "group", "aria-label": `${t(STATUSES[card.status].label)}: ${card.title}`, "data-status": card.status }, + (0, import_react12.createElement)("span", { className: "bt-inbox-ava" }, card.bot ? (0, import_react12.createElement)(BotAvatar, { bot: card.bot, size: 28, badge: false, live: false }) : card.room ? (0, import_react12.createElement)(RoomAvatar, { room: card.room, roster, size: 28 }) : null), + (0, import_react12.createElement)( - "span", -- { className: "bt-typing-text" }, -- (0, import_react7.createElement)( ++ "span", + { className: "bt-inbox-copy" }, + (0, import_react12.createElement)( - "span", -- { key: `${shown.key}:${shown.at}`, className: "bt-typing-label" }, -- (0, import_react7.createElement)(Shimmer, null, `${shown.text}…`), -- elapsed ? (0, import_react7.createElement)("span", { className: "bt-typing-time" }, `· ${elapsed}`) : null -- ) ++ "span", + { className: "bt-inbox-top" }, + (0, import_react12.createElement)(Status, { status: card.status }), + (0, import_react12.createElement)("span", { className: "bt-inbox-meta", title: meta }, meta) @@ -6855,19 +5741,9 @@ diff --git a/client.js b/client.js + answerable ? (0, import_react12.createElement)("button", { type: "button", className: "bt-send bt-soft-sm", disabled: busy, onClick: () => onAnswer(card, "allow") }, t("Allow once")) : null, + ...(answerable ? alwaysChoices(card.approval, card.bot) : []).map(([choice, label]) => (0, import_react12.createElement)("button", { key: choice, type: "button", className: "bt-soft bt-soft-sm", "data-choice": choice, disabled: busy, onClick: () => onAnswer(card, choice) }, t(label))), + (0, import_react12.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", "aria-label": who ? t("Open {name}", { name: card.room?.name ?? who }) : t("Open"), onClick: () => onOpen(card) }, t("Open")) - ) -- ), host); ++ ) + ); - } -- --// src/client/dsh-offer.js --var APP_OFFER = /* @__PURE__ */ new Set(["available", "downloading", "verifying", "installing", "ready"]); --var bare = (value) => String(value ?? "").trim().replace(/^v/i, "").split("+")[0]; --var sameVersion = (a, b) => bare(a) !== "" && bare(a) === bare(b); --function showsDshRow(dsh, app) { -- if (!dsh?.verified || !dsh.current || sameVersion(dsh.verified, dsh.current)) return false; -- if (dsh.kind === "npm") return true; -- return dsh.kind === "app" && APP_OFFER.has(app?.phase) && sameVersion(app.version, dsh.verified); ++} +function useNow2(ms) { + const [now, setNow] = (0, import_react12.useState)(Date.now); + (0, import_react12.useEffect)(() => { @@ -6949,7 +5825,7 @@ diff --git a/client.js b/client.js + ) + ) + ); - } ++} +var INBOX_CSS = ` +.bt-inbox-row{min-height:40px;padding:6px 8px;gap:10px} +.bt-inbox-mark{flex:none;width:36px;height:28px;display:inline-flex;align-items:center;justify-content:center;color:var(--bt-ink-2)} @@ -6987,7 +5863,7 @@ diff --git a/client.js b/client.js +.bt-inbox-actions{flex:none;display:flex;align-items:center;gap:6px;align-self:center} +.bt-inbox-actions .bt-send{height:28px;font-size:12px} +`; - ++ // src/client/sidebar.js -var import_react8 = require("react"); +var import_react13 = require("react"); @@ -7034,7 +5910,7 @@ diff --git a/client.js b/client.js - if (bot.hidden && !active && !main) return null; + const shownAway = away ? awayGroups(groups, roster.roomsById) : []; + const awayLine = shownAway.length > 0 ? workLine(roster.roomsById[shownAway[0].id]?.name ?? t("a group chat"), shownAway[0].tool, shownAway.length - 1) : null; -+ if (!isListedRemoteChat(bot) && !active && !main) return null; ++ if (!isShownRemoteChat(bot) && !active && !main) return null; const onContextMenu = (event) => { event.preventDefault(); actions.openMenu({ kind: "bot", id, x: event.clientX, y: event.clientY }); @@ -7043,7 +5919,7 @@ diff --git a/client.js b/client.js - const label = [bot.name, main ? t("Main Bot") : "", pinned ? t("Pinned") : "", running || away ? t("Working") : "", approval ? t("Waiting for your approval") : alert ? t("Waiting for your answer") : "", unread ? t("Unread activity") : ""].filter(Boolean).join(", "); + const remote = Boolean(bot.remote); + const shown = remote ? friendBotTitle(bot) : bot.name; -+ const secondLine = awayLine || preview || (remote ? t("Friend's Bot") : ""); ++ const secondLine = awayLine || preview || (remote ? bot.remote?.gone ? t("no longer shared") : t("Friend's Bot") : ""); + const label = [shown, main ? t("Main Bot") : "", pinned ? t("Pinned") : "", running || away ? t("Working") : "", waiting ? t("Waiting for your approval") : alert ? t("Waiting for your answer") : "", unread ? t("Unread activity") : ""].filter(Boolean).join(", "); if (tile && wide) { - return (0, import_react8.createElement)("div", { className: "bt-tile-wrap" }, (0, import_react8.createElement)( @@ -7051,7 +5927,7 @@ diff --git a/client.js b/client.js "button", { type: "button", -@@ -4785,55 +4785,130 @@ function BotRow({ bot, roster, actions, useSessions, useSessionStatus, useSessio +@@ -4785,55 +4785,130 @@ onClick: () => actions.openSession(id), onContextMenu }, @@ -7197,7 +6073,7 @@ diff --git a/client.js b/client.js "data-room-id": id, onClick: () => actions.openSession(id), onContextMenu: (event) => { -@@ -4841,20 +4841,20 @@ function RoomRow({ room: room2, roster, actions, useSessions, useSessionStatus, +@@ -4841,20 +4841,20 @@ actions.openMenu({ kind: "room", id, x: event.clientX, y: event.clientY }); } }, @@ -7226,7 +6102,7 @@ diff --git a/client.js b/client.js if (!renaming) return; input.current?.focus(); input.current?.select(); -@@ -4875,10 +4875,10 @@ function AgentSessionRow({ session, agent, actions, renaming, index }) { +@@ -4875,10 +4875,10 @@ } })(); }; @@ -7239,7 +6115,7 @@ diff --git a/client.js b/client.js ref: input, defaultValue: session.title, "aria-label": t("Rename"), -@@ -4891,7 +4891,7 @@ function AgentSessionRow({ session, agent, actions, renaming, index }) { +@@ -4891,7 +4891,7 @@ }) ); } @@ -7248,7 +6124,7 @@ diff --git a/client.js b/client.js "button", { type: "button", -@@ -4905,8 +4905,8 @@ function AgentSessionRow({ session, agent, actions, renaming, index }) { +@@ -4905,8 +4905,8 @@ actions.openMenu({ kind: "agent-session", id: session.id, agentId: agent.id, x: event.clientX, y: event.clientY }); } }, @@ -7259,7 +6135,7 @@ diff --git a/client.js b/client.js ); } function AgentRow({ agent, actions, useSessions, useSessionStatus, useUi, wide, marked }) { -@@ -4941,10 +4941,10 @@ function AgentRow({ agent, actions, useSessions, useSessionStatus, useUi, wide, +@@ -4941,10 +4941,10 @@ const preview = sessions[0]?.title ?? t("New session"); const label = ["DSH Agent", running ? t("Working") : "", unread ? t("Unread activity") : ""].filter(Boolean).join(", "); const open = active && wide; @@ -7272,7 +6148,7 @@ diff --git a/client.js b/client.js "button", { type: "button", -@@ -4959,30 +4959,30 @@ function AgentRow({ agent, actions, useSessions, useSessionStatus, useUi, wide, +@@ -4959,30 +4959,30 @@ actions.openMenu({ kind: "agent", id: agent.id, x: event.clientX, y: event.clientY }); } }, @@ -7314,7 +6190,7 @@ diff --git a/client.js b/client.js key: session.id, session, agent, -@@ -4996,8 +4996,8 @@ function AgentRow({ agent, actions, useSessions, useSessionStatus, useUi, wide, +@@ -4996,8 +4996,8 @@ } var GLIDE = springEasing(1e3, 63); function useListGlide(listRef, layout, key) { @@ -7325,7 +6201,7 @@ diff --git a/client.js b/client.js const list = listRef.current; if (!list) return; const next = /* @__PURE__ */ new Map(); -@@ -5014,9 +5014,44 @@ function useListGlide(listRef, layout, key) { +@@ -5014,9 +5014,44 @@ tops.current = { layout, map: next }; }, [layout, key]); } @@ -7371,7 +6247,7 @@ diff --git a/client.js b/client.js const roster = useRoster((value) => value); const markedUnread = useUi((value) => value.unread); props.useRegistry((value) => value); -@@ -5025,17 +5025,26 @@ function TeamSidebar(props) { +@@ -5025,17 +5025,26 @@ for (const id of list.ids) stamps[id] = list.byId[id]?.updatedAt ?? 0; return JSON.stringify(stamps); }); @@ -7383,7 +6259,7 @@ diff --git a/client.js b/client.js - ...roster.rooms.map((room2) => ({ kind: "room", item: room2, at: stamps[room2.id] ?? room2.createdAt })), + // Own Bots, friends' Bots, groups and face rooms share one list. A remote chat + // still uses BotRow's globe; gone / hidden / home-level remotes stay out. -+ ...roster.bots.filter((bot) => bot.id !== roster.mainBotId && isListedRemoteChat(bot)).map((bot) => ({ kind: "bot", item: bot, at: stamps[currentPart(bot)] ?? bot.createdAt })), ++ ...roster.bots.filter((bot) => bot.id !== roster.mainBotId && isShownRemoteChat(bot)).map((bot) => ({ kind: "bot", item: bot, at: stamps[currentPart(bot)] ?? bot.createdAt })), + ...roster.rooms.filter((room2) => !isPastFaceRoom(room2)).map((room2) => ({ kind: "room", item: room2, at: stamps[room2.id] ?? room2.createdAt })), // The Agent sorts by its most recent Session, falling back to when it was added. ...(roster.agents ?? []).map((agent) => ({ kind: "agent", item: agent, at: Math.max(0, ...agent.sessions.map((id) => stamps[id] ?? 0)) || agent.createdAt })) @@ -7402,7 +6278,7 @@ diff --git a/client.js b/client.js useListGlide(listRef, wide, order.map((entry) => entry.item.id).join(",")); useLandOnMainBot(roster, useSessions, actions); const rowProps = { roster, actions, useSessions, useUi, useSessionStatus: props.useSessionStatus, useSessionRetainInfo: props.useSessionRetainInfo, useActivity: props.useActivity, useWork: props.useWork, wide }; -@@ -5045,39 +5045,41 @@ function TeamSidebar(props) { +@@ -5045,39 +5045,41 @@ event.preventDefault(); actions.openPalette(event.key); }; @@ -7464,7 +6340,7 @@ diff --git a/client.js b/client.js if (!open) return void 0; const onDown = (event) => { if (event.target instanceof Element && (event.target.closest(".bt-update-pop") || button.current?.contains(event.target))) return; -@@ -5116,14 +5116,14 @@ function UpdateButton({ useUpdate, actions }) { +@@ -5116,14 +5116,14 @@ setDshError(""); void actions.installDshUpdate().catch((failure) => setDshError(hostText(failure?.message ?? String(failure)))); }; @@ -7484,7 +6360,7 @@ diff --git a/client.js b/client.js ref: button, type: "button", className: "bt-round bt-update", -@@ -5133,21 +5133,21 @@ function UpdateButton({ useUpdate, actions }) { +@@ -5133,21 +5133,21 @@ "aria-haspopup": "dialog", "aria-expanded": open, onClick: toggle @@ -7517,7 +6393,7 @@ diff --git a/client.js b/client.js type: "button", className: "bt-update-go", disabled: phase === "installing", -@@ -5155,17 +5155,17 @@ function UpdateButton({ useUpdate, actions }) { +@@ -5155,17 +5155,17 @@ }, phase === "installing" ? t("Updating…") : phase === "failed" ? t("Try again") : t("Update")) ) ) : null, @@ -7542,7 +6418,7 @@ diff --git a/client.js b/client.js type: "button", className: "bt-update-go", disabled: dshPhase === "installing", -@@ -5176,61 +5176,25 @@ function UpdateButton({ useUpdate, actions }) { +@@ -5176,61 +5176,25 @@ ), document.body) : null ); } @@ -7570,10 +6446,12 @@ diff --git a/client.js b/client.js - setTip(false); - }; - return (0, import_react8.createElement)( -- "span", -- { className: "bt-connect-wrap" }, ++ return (0, import_react13.createElement)( + "span", + { className: "bt-connect-wrap" }, - (0, import_react8.createElement)( -- "button", ++ (0, import_react13.createElement)( + "button", - { - ref: button, - type: "button", @@ -7594,11 +6472,6 @@ diff --git a/client.js b/client.js - { role: "tooltip", id: tipId, className: "bt-connect-tip", style: box }, - t("Plugin compatibility is not fully verified.") - ), document.body) : null -+ return (0, import_react13.createElement)( -+ "span", -+ { className: "bt-connect-wrap" }, -+ (0, import_react13.createElement)( -+ "button", + { type: "button", className: "bt-connect bt-connect-foot", onClick: () => actions.openConnectors() }, + (0, import_react13.createElement)(PlugIcon), + t("Connect apps") @@ -7617,7 +6490,7 @@ diff --git a/client.js b/client.js ); } var openShellSettings = null; -@@ -5259,6 +5259,18 @@ function openHarnessOnboarding(id) { +@@ -5259,6 +5259,18 @@ else openHarnessSettings(); }); } @@ -7636,7 +6509,7 @@ diff --git a/client.js b/client.js function openThroughMenu(trigger) { const before = new Set(document.querySelectorAll('[role="menu"]')); trigger.click(); -@@ -5288,32 +5288,32 @@ function pressSettingsShortcut() { +@@ -5288,32 +5288,32 @@ })); } function AccountLauncher({ seat = true, settingsOpen, openSettings, openOnboarding, actions }) { @@ -7677,7 +6550,7 @@ diff --git a/client.js b/client.js if (!open) return void 0; const onDown = (event) => { if (event.target instanceof Element && (event.target.closest(".bt-account-menu") || button.current?.contains(event.target))) return; -@@ -5341,30 +5341,32 @@ function AccountLauncher({ seat = true, settingsOpen, openSettings, openOnboardi +@@ -5341,30 +5341,32 @@ const groups = [ [ [t("Usage"), GaugeIcon, () => actions.openSettings("usage")], @@ -7722,7 +6595,7 @@ diff --git a/client.js b/client.js key: label, type: "button", role: "menuitem", -@@ -5372,7 +5372,7 @@ function AccountLauncher({ seat = true, settingsOpen, openSettings, openOnboardi +@@ -5372,7 +5372,7 @@ setOpen(false); void Promise.resolve(run()).catch((error) => actions.reportError(error, "menu")); } @@ -7731,7 +6604,7 @@ diff --git a/client.js b/client.js )) ), document.body) : null ); -@@ -5382,8 +5382,8 @@ function useLandOnMainBot(roster, useSessions, actions) { +@@ -5382,8 +5382,8 @@ const row = Object.values(list.byId).find((session) => (session?.retainedBy?.mainView ?? 0) > 0); return row ? `${row.id}|${row.blank ? 1 : 0}` : ""; }); @@ -7742,7 +6615,7 @@ diff --git a/client.js b/client.js if (!roster.ready || !roster.mainBotId) return void 0; const [id, blank3] = mainView.split("|"); const bot = id === "" ? void 0 : roster.byId[id]; -@@ -5397,40 +5397,40 @@ function useLandOnMainBot(roster, useSessions, actions) { +@@ -5397,40 +5397,40 @@ return void 0; } const last = lastBot.current; @@ -7793,7 +6666,7 @@ diff --git a/client.js b/client.js } var SIDEBAR_CSS = ` .bt-side-top .bt-brand{cursor:default;padding-left:6px} -@@ -5491,18 +5491,28 @@ var SIDEBAR_CSS = ` +@@ -5491,18 +5491,28 @@ .bt-agent-sub[data-open] .bt-agent-sub-row{animation:bt-agent-row-in .24s ease both;animation-delay:calc(var(--i, 0)*30ms)} @keyframes bt-agent-row-in{from{opacity:0;filter:blur(4px)}to{opacity:1;filter:blur(0)}} @media (prefers-reduced-motion:reduce){.bt-agent-sub{transition:none}.bt-agent-sub .bt-agent-sub-row{animation:none}} @@ -7826,7 +6699,7 @@ diff --git a/client.js b/client.js const left = seen + MEMORY_NOTICE_MS - Date.now(); setShown(seen > 0 && left > 0); if (seen === 0 || left <= 0) return void 0; -@@ -5512,16 +5512,16 @@ function useMemoryNotice(ids, useMemoryNews) { +@@ -5512,16 +5512,16 @@ return shown; } function MemoryNotice({ shown }) { @@ -7848,7 +6721,7 @@ diff --git a/client.js b/client.js ); } function HeaderPill({ sessionId, useRoster, useSessions, useSessionStatus, useSession, useActivity, useMemoryNews, useTurnClock, useUi, useLive, actions }) { -@@ -5537,7 +5537,7 @@ function HeaderPill({ sessionId, useRoster, useSessions, useSessionStatus, useSe +@@ -5537,7 +5537,7 @@ const first = openingTurn(state[sessionId], firstTurns, hiddenTurns); return first && !unseenSince(visit, first.start) ? clockLabel(first.start) : ""; }); @@ -7857,7 +6730,7 @@ diff --git a/client.js b/client.js if (opening) document.body.style.setProperty("--bt-first-sep", JSON.stringify(opening)); else document.body.style.removeProperty("--bt-first-sep"); return () => document.body.style.removeProperty("--bt-first-sep"); -@@ -5548,52 +5548,38 @@ function HeaderPill({ sessionId, useRoster, useSessions, useSessionStatus, useSe +@@ -5548,52 +5548,38 @@ const bot = roster.byId[sessionId]; const room2 = roster.roomsById[sessionId]; const detailsOpen = useUi((state) => state.details === sessionId); @@ -7926,7 +6799,7 @@ diff --git a/client.js b/client.js let frame = 0; let watched = null; const observer = new MutationObserver(() => schedule()); -@@ -5642,7 +5642,7 @@ function useTranscriptView() { +@@ -5642,7 +5642,7 @@ }, []); return view; } @@ -7935,7 +6808,7 @@ diff --git a/client.js b/client.js const replies = useSessions((list) => { const outline = list.projectionsBySession?.[sessionId]?.values?.turnOutline ?? list.byId[sessionId]?.projectionValues?.turnOutline; return Array.isArray(outline) ? outline.reduce((count, entry) => count + (entry.response ? 1 : 0), 0) : 0; -@@ -5658,18 +5658,22 @@ function NewMessagePills({ sessionId, useSessions, useUi, useTurnClock }) { +@@ -5658,18 +5658,22 @@ }); const view = useTranscriptView(); const unpinned = view?.unpinned === true; @@ -7963,7 +6836,7 @@ diff --git a/client.js b/client.js const scroller = document.querySelector("[data-conversation-scroll]"); if (!scroller) return void 0; scroller.toggleAttribute("data-bt-news", showDown); -@@ -5677,16 +5677,16 @@ function NewMessagePills({ sessionId, useSessions, useUi, useTurnClock }) { +@@ -5677,16 +5677,16 @@ }, [showDown]); if (!view || !showDown && !showUp) return null; const label = (count) => count === 1 ? t("1 new message") : count > 0 ? t("{count} new messages", { count }) : t("New messages"); @@ -7984,7 +6857,7 @@ diff --git a/client.js b/client.js type: "button", className: "bt-news-x", "aria-label": t("Dismiss new messages"), -@@ -5694,7 +5694,7 @@ function NewMessagePills({ sessionId, useSessions, useUi, useTurnClock }) { +@@ -5694,7 +5694,7 @@ event.stopPropagation(); onDismiss(); } @@ -7993,7 +6866,7 @@ diff --git a/client.js b/client.js ); const toLatest = () => { const scroller = document.querySelector("[data-conversation-scroll]"); -@@ -5704,8 +5704,8 @@ function NewMessagePills({ sessionId, useSessions, useUi, useTurnClock }) { +@@ -5704,8 +5704,8 @@ }; const toDivider = () => document.querySelector("[data-conversation-scroll] .bt-new-sep")?.scrollIntoView({ block: "center", behavior: "smooth" }); return (0, import_react_dom4.createPortal)( @@ -8004,7 +6877,7 @@ diff --git a/client.js b/client.js null, showUp ? pill("up", unread, { left: view.center, top: view.top }, toDivider, () => setAbove((state) => ({ ...state, dismissed: true }))) : null, showDown ? pill("down", fresh, { left: view.center, bottom: view.bottom }, toLatest, () => setRelease((state) => ({ ...state, dismissed: true }))) : null -@@ -5714,19 +5714,117 @@ function NewMessagePills({ sessionId, useSessions, useUi, useTurnClock }) { +@@ -5714,19 +5714,117 @@ ); } @@ -8129,7 +7002,7 @@ diff --git a/client.js b/client.js if (!note) return void 0; const timer = setTimeout(() => setNote(""), 2600); return () => clearTimeout(timer); -@@ -5774,19 +5774,38 @@ function useDictation(onText) { +@@ -5774,19 +5774,38 @@ const stop = () => recognition.current?.stop(); return { listening, note, start, stop }; } @@ -8171,7 +7044,7 @@ diff --git a/client.js b/client.js const box = document.querySelector('[class*="_centerCol"] [role=textbox][contenteditable=true]'); if (box instanceof HTMLElement) { box.focus(); -@@ -5797,10 +5797,10 @@ function ComposerVoice({ sessionId, inputActions, actions, useUi }) { +@@ -5797,10 +5797,10 @@ }, [quote?.token]); if (sessionId === void 0) return null; const pluginMic = actions.speech() !== void 0; @@ -8185,7 +7058,7 @@ diff --git a/client.js b/client.js type: "button", className: "bt-mic", "aria-label": dictation.listening ? t("Stop dictation") : t("Dictate"), -@@ -5808,16 +5808,16 @@ function ComposerVoice({ sessionId, inputActions, actions, useUi }) { +@@ -5808,16 +5808,16 @@ title: dictation.listening ? t("Stop dictation") : t("Dictate"), onMouseDown: keepComposerFocus, onClick: () => dictation.listening ? dictation.stop() : dictation.start() @@ -8206,7 +7079,7 @@ diff --git a/client.js b/client.js ); } function menuIcons(roster) { -@@ -5825,17 +5825,17 @@ function menuIcons(roster) { +@@ -5825,17 +5825,17 @@ return (row) => { if (!row.bot) return PeopleIcon; const id = row.bot.id; @@ -8229,7 +7102,7 @@ diff --git a/client.js b/client.js if (!open) return void 0; const onDown = (event) => { if (![button.current, menu.current].some((node) => node?.contains(event.target))) setOpen(null); -@@ -5876,10 +5876,10 @@ function ComposerPlus({ sessionId, inputActions, useRoster, actions }) { +@@ -5876,10 +5876,10 @@ actions.referenceChat(sessionId, inputActions?.captureInsertion?.()); }; const canReference = typeof inputActions?.captureInsertion === "function" && actions.canReferenceChat(); @@ -8243,7 +7116,7 @@ diff --git a/client.js b/client.js ref: button, type: "button", className: "bt-plus", -@@ -5889,8 +5889,8 @@ function ComposerPlus({ sessionId, inputActions, useRoster, actions }) { +@@ -5889,8 +5889,8 @@ "aria-expanded": Boolean(open), onMouseDown: keepComposerFocus, onClick: toggle @@ -8254,7 +7127,7 @@ diff --git a/client.js b/client.js "div", { ref: menu, -@@ -5900,9 +5900,9 @@ function ComposerPlus({ sessionId, inputActions, useRoster, actions }) { +@@ -5900,9 +5900,9 @@ style: { left: shown.left, bottom: shown.bottom }, "data-leaving": leaving || void 0 }, @@ -8267,7 +7140,7 @@ diff --git a/client.js b/client.js ), document.body) : null ); } -@@ -5922,15 +5922,55 @@ var conversationOf = (roster, sessionId) => { +@@ -5922,15 +5922,55 @@ function mentionRows(roster, sessionId, query = "") { const here = conversationOf(roster, sessionId); if (here === null) return null; @@ -8327,7 +7200,7 @@ diff --git a/client.js b/client.js function chatRows(roster, list, sessionId) { const here = conversationOf(roster, sessionId); if (here === null) return []; -@@ -5959,14 +5959,30 @@ function atSources({ roster, sessions, launched, iconFor }) { +@@ -5959,14 +5959,30 @@ order: -20, showGroupTitle: false, async candidates(session, { query }) { @@ -8362,7 +7235,7 @@ diff --git a/client.js b/client.js }, onPick: ({ candidate }) => candidate.value ? { text: candidate.value } : void 0 }; -@@ -5995,6 +5995,83 @@ function atSources({ roster, sessions, launched, iconFor }) { +@@ -5995,6 +5995,83 @@ return [bots, chats]; } @@ -8446,19 +7319,10 @@ diff --git a/client.js b/client.js // src/client/speech.js var VOICE_BUNDLE = "@deepseek-ai/dsh-experimental-voice-input-bundle"; var USABLE = /* @__PURE__ */ new Set(["ready", "standby", "waking"]); -@@ -6112,241 +6112,17 @@ function turnDetector({ loud = 0.12, quiet = 0.06, quietMs = 1200 } = {}) { - return (level, now) => { - if (level >= loud) { - spokeAt ||= now; -- quietSince = 0; -- return "speech"; -- } -- if (!spokeAt) return "waiting"; -- if (level < quiet) quietSince ||= now; -- return quietSince && now - quietSince >= quietMs ? "done" : "speech"; -- }; --} -- +@@ -6121,232 +6121,8 @@ + }; + } + -// src/client/overlay-hooks.js -var import_react11 = require("react"); -var escapeLayers = []; @@ -8592,13 +7456,7 @@ diff --git a/client.js b/client.js - if (!row || leaving) return; - void Promise.resolve(row.run()).catch((error) => actions.reportError(error, "menu")); - close(); -+ quietSince = 0; -+ return "speech"; -+ } -+ if (!spokeAt) return "waiting"; -+ if (level < quiet) quietSince ||= now; -+ return quietSince && now - quietSince >= quietMs ? "done" : "speech"; - }; +- }; - let index = -1; - return (0, import_react12.createElement)( - "div", @@ -8687,15 +7545,15 @@ diff --git a/client.js b/client.js - ) - ) - ); - } - +-} +- // src/client/model-menu.js -var import_react13 = require("react"); +var import_react16 = require("react"); var import_react_dom6 = require("react-dom"); var MENU_EXIT_MS = 120; function groupModels(models) { -@@ -6367,17 +6367,10 @@ function groupModels(models) { +@@ -6367,17 +6367,10 @@ }); } var servedOf = (roster, ref) => (roster.models ?? []).find((model) => model.ref === ref); @@ -8715,7 +7573,7 @@ diff --git a/client.js b/client.js setFetched(void 0); if (ref === void 0 || served?.reasoning !== void 0 || actions?.efforts === void 0) return void 0; let live = true; -@@ -6402,12 +6402,12 @@ function effortRows(reasoning) { +@@ -6402,12 +6402,12 @@ ]; } function EffortRows({ reasoning, current, onPick }) { @@ -8733,7 +7591,7 @@ diff --git a/client.js b/client.js "button", { key: row.effort ?? "default", -@@ -6416,17 +6416,17 @@ function EffortRows({ reasoning, current, onPick }) { +@@ -6416,17 +6416,17 @@ "aria-checked": row.effort === current, onClick: () => onPick(row.effort) }, @@ -8757,7 +7615,7 @@ diff --git a/client.js b/client.js "button", { key: model.ref, -@@ -6436,20 +6436,20 @@ function ModelRows({ roster, current, onPick }) { +@@ -6436,20 +6436,20 @@ title: model.ref, onClick: () => onPick(model.ref) }, @@ -8786,7 +7644,7 @@ diff --git a/client.js b/client.js if (place === null) return void 0; const onDown = (event) => { if (menu.current?.contains(event.target) || button.current?.contains(event.target)) return; -@@ -6484,6 +6484,7 @@ function useAnchoredMenu() { +@@ -6484,6 +6484,7 @@ return { isOpen: place !== null, shownPlace, leaving, button, menu, open, close }; } function ModelMenu({ bot, roster, actions, run, wide = false, onPick, onPickEffort }) { @@ -8794,7 +7652,7 @@ diff --git a/client.js b/client.js const { isOpen, shownPlace, leaving, button, menu, open, close } = useAnchoredMenu(); const shown = modelOf(bot, roster); const current = bot.model ?? bot.appliedModel; -@@ -6511,10 +6511,10 @@ function ModelMenu({ bot, roster, actions, run, wide = false, onPick, onPickEffo +@@ -6511,10 +6511,10 @@ else if (effort !== bot.effort) void run(actions.updateBot(bot.id, { effort: effort ?? null })); }; const models = roster.models ?? []; @@ -8808,7 +7666,7 @@ diff --git a/client.js b/client.js "button", { ref: button, -@@ -6527,37 +6527,38 @@ function ModelMenu({ bot, roster, actions, run, wide = false, onPick, onPickEffo +@@ -6527,37 +6527,38 @@ "data-missing": shown !== void 0 && !shown.served && models.length > 0 ? "" : void 0, onClick: toggle }, @@ -8861,7 +7719,7 @@ diff --git a/client.js b/client.js const models = roster.models ?? []; const lost = bot.model ? !models.some((model) => model.ref === bot.model) : false; if (bot.model && (!lost || models.length === 0)) return null; -@@ -6570,12 +6570,12 @@ function ModelChoiceDock({ sessionId, useRoster, actions }) { +@@ -6570,12 +6570,12 @@ }).finally(() => setBusy("")); }; const text = lost ? t("{model} is not available anymore. Pick another model for me.", { model: bot.model.split("/").slice(1).join("/") }) : models.length === 0 ? t("Hi, I'm {name}. I don't have a model yet. How do you want to set one up?", { name: bot.name }) : t("Hi, I'm {name}. Pick a model for me first. You can change it later in my details.", { name: bot.name }); @@ -8878,7 +7736,7 @@ diff --git a/client.js b/client.js key: model.ref, type: "button", className: "bt-model-chip", -@@ -6584,50 +6584,115 @@ function ModelChoiceDock({ sessionId, useRoster, actions }) { +@@ -6584,50 +6584,115 @@ "aria-label": `${model.name} · ${provider}`, title: model.ref === roster.defaultModel ? `${model.ref} · ${t("Harness default")}` : model.ref, onClick: () => pick2(model.ref) @@ -9010,7 +7868,7 @@ diff --git a/client.js b/client.js ...ops, ...hooks, t: actions.accountT(), -@@ -6639,6 +6639,16 @@ function SignInHost({ entry, actions }) { +@@ -6639,6 +6639,16 @@ }); } var MODEL_CSS = ` @@ -9027,7 +7885,7 @@ diff --git a/client.js b/client.js .bt-model-trigger{display:inline-flex;align-items:center;gap:8px;max-width:100%;min-width:0;height:auto;min-height:32px;padding:4px 8px 4px 10px;border:0;border-radius:8px;background:var(--bt-hover);color:var(--bt-ink);font:inherit;text-align:left;cursor:pointer;transition:background-color .12s ease} .bt-model-trigger:hover,.bt-model-trigger[aria-expanded=true]{background:var(--bt-active)} .bt-model-trigger:focus-visible{outline:2px solid var(--bt-accent);outline-offset:1px} -@@ -6675,18 +6675,88 @@ var MODEL_CSS = ` +@@ -6675,18 +6675,88 @@ @media (prefers-reduced-motion:reduce){.bt-model-dock{animation:none}.bt-model-spin{animation-duration:2s}.bt-model-caret{transition:none}.bt-model-tick svg{animation:none}} `; @@ -9123,7 +7981,7 @@ diff --git a/client.js b/client.js const fallback = group ? t("Each Bot's own folder") : t("Team workspace"); const toggle = () => { if (isOpen) { -@@ -6719,35 +6719,35 @@ function FolderMenu({ value, group = false, actions, run, onPick, wide = false } +@@ -6719,35 +6719,35 @@ }; const known = info?.folders ?? []; const folders = value && !known.some((folder) => folder.path === value) ? [{ path: value, title: folderName(value) }, ...known] : known; @@ -9175,7 +8033,7 @@ diff --git a/client.js b/client.js className: "bt-folder-input", autoFocus: true, value: draft, -@@ -6759,33 +6759,33 @@ function FolderMenu({ value, group = false, actions, run, onPick, wide = false } +@@ -6759,33 +6759,33 @@ if (event.key === "Enter" && !event.nativeEvent.isComposing) typed(); } }), @@ -9225,7 +8083,7 @@ diff --git a/client.js b/client.js "button", { ref: button, -@@ -6798,19 +6798,19 @@ function FolderMenu({ value, group = false, actions, run, onPick, wide = false } +@@ -6798,19 +6798,19 @@ title: value, onClick: toggle }, @@ -9251,7 +8109,7 @@ diff --git a/client.js b/client.js ), document.body) : null ); } -@@ -6830,8 +6830,8 @@ var FOLDER_CSS = ` +@@ -6830,8 +6830,8 @@ `; // src/client/schedules.js @@ -9262,7 +8120,7 @@ diff --git a/client.js b/client.js var shared = null; var listeners = /* @__PURE__ */ new Set(); function publish(view) { -@@ -6839,8 +6839,8 @@ function publish(view) { +@@ -6839,8 +6839,8 @@ for (const listener of listeners) listener(view); } function useSchedules(actions) { @@ -9273,7 +8131,7 @@ diff --git a/client.js b/client.js listeners.add(setView); let stopped = false; let timer; -@@ -6848,7 +6848,7 @@ function useSchedules(actions) { +@@ -6848,7 +6848,7 @@ if (!stopped) publish(next); }).catch(() => { }).finally(() => { @@ -9282,7 +8140,7 @@ diff --git a/client.js b/client.js }); load(); return () => { -@@ -6896,11 +6896,25 @@ function timingText(task) { +@@ -6896,11 +6896,25 @@ return ""; } } @@ -9313,7 +8171,7 @@ diff --git a/client.js b/client.js if (!confirming) return void 0; const timer = setTimeout(() => setConfirming(false), 3e3); return () => clearTimeout(timer); -@@ -6913,20 +6913,48 @@ function TaskRow({ task, actions, onView, showBot, roster }) { +@@ -6913,20 +6913,48 @@ setBusy(true); void actions.scheduleDelete(task.sessionId, task.id).then(onView, () => setBusy(false)); }; @@ -9371,7 +8229,7 @@ diff --git a/client.js b/client.js type: "button", className: "bt-mini bt-task-remove", "data-confirm": confirming || void 0, -@@ -6934,7 +6934,7 @@ function TaskRow({ task, actions, onView, showBot, roster }) { +@@ -6934,7 +6934,7 @@ "aria-label": confirming ? t("Click again to delete") : t("Delete task"), title: confirming ? void 0 : t("Delete task"), onClick: remove @@ -9380,7 +8238,7 @@ diff --git a/client.js b/client.js ); } var pad = (number) => String(number).padStart(2, "0"); -@@ -6945,22 +6945,44 @@ function inAnHour() { +@@ -6945,22 +6945,44 @@ } var KINDS = [["at", "Once"], ["daily", "Every day"], ["weekly", "Every week"], ["every", "Repeat"]]; var UNITS = [["minutes", 1, "minutes"], ["hours", 60, "hours"], ["days", 1440, "days"]]; @@ -9438,7 +8296,7 @@ diff --git a/client.js b/client.js }; const submit = (event) => { event.preventDefault(); -@@ -6975,146 +6975,185 @@ function TaskForm({ roster, actions, start, onDone, onCancel }) { +@@ -6975,146 +6975,185 @@ setBusy(false); return; } @@ -9609,7 +8467,7 @@ diff --git a/client.js b/client.js + (0, import_react19.createElement)(TaskList, { tasks, editing, roster, actions, onEdit: edit, onDone: shown, onCancel: () => setEditing(null) }) )) ]; - } ++} +function TaskList({ tasks, editing, roster, actions, onEdit, onDone, onCancel, extra = [] }) { + const open = editing ? tasks.find((task) => task.id === editing.id && task.sessionId === editing.sessionId) : void 0; + const rows = tasks.filter((task) => task !== open).map((task) => (0, import_react19.createElement)(TaskRow, { key: task.id, task, actions, roster, onView: publish, onEdit })); @@ -9619,7 +8477,7 @@ diff --git a/client.js b/client.js + open ? (0, import_react19.createElement)(TaskForm, { key: `edit-${open.id}`, roster, actions, task: open, onDone, onCancel }) : null, + rows.length + extra.length > 0 ? (0, import_react19.createElement)("div", { className: "bt-set-card" }, ...rows, ...extra) : null + ); -+} + } function BotSchedules({ bot, roster, actions }) { const view = useSchedules(actions); + const [editing, setEditing] = (0, import_react19.useState)(null); @@ -9686,7 +8544,7 @@ diff --git a/client.js b/client.js ))) ); } -@@ -7127,6 +7127,12 @@ var SCHEDULE_CSS = ` +@@ -7127,6 +7127,12 @@ .bt-task-next{flex:none;font-size:12px;line-height:16px;color:var(--bt-ink-3);font-variant-numeric:tabular-nums;white-space:nowrap} .bt-task-remove{display:inline-flex;align-items:center;justify-content:center;min-width:26px;height:26px;color:var(--bt-ink-3)} .bt-task-remove svg{width:14px;height:14px} @@ -9699,7 +8557,7 @@ diff --git a/client.js b/client.js .bt-task-remove[data-confirm]{background:rgba(224,33,53,.1);color:#e02135} .bt-task-head{display:flex;align-items:center;gap:6px} .bt-task-form{animation:bt-q-in .4s cubic-bezier(.22,1,.36,1),bt-veil-in .2s ease-out} -@@ -7158,7 +7158,7 @@ var SCHEDULE_CSS = ` +@@ -7158,7 +7158,7 @@ `; // src/client/capability-settings.js @@ -9708,7 +8566,7 @@ diff --git a/client.js b/client.js var blank = () => ({ name: "", kind: "mcp", description: "", transport: "stdio", command: "", argsText: "", envText: "{}", url: "", headersText: "{}", content: "", items: [] }); var parseMap = (text, label) => { let value; -@@ -7172,11 +7172,11 @@ var parseMap = (text, label) => { +@@ -7172,11 +7172,11 @@ }; var statusText = (status) => !status ? void 0 : status.state === "ready" ? t("Ready") : status.state === "needs-approval" ? t("Needs approval") : status.state === "error" ? t("Could not connect: {error}", { error: status.error ?? "" }) : t("Connecting…"); function Field({ label, value, onChange, multiline = false, disabled = false, placeholder }) { @@ -9723,7 +8581,7 @@ diff --git a/client.js b/client.js className: multiline ? "bt-input bt-input-area" : "bt-input", value, disabled, -@@ -7188,7 +7188,7 @@ function Field({ label, value, onChange, multiline = false, disabled = false, pl +@@ -7188,7 +7188,7 @@ ); } function CapabilityForm({ draft, setDraft, items, actions, onClose }) { @@ -9732,7 +8590,7 @@ diff --git a/client.js b/client.js const set = (key, value) => setDraft((previous) => ({ ...previous, [key]: value })); const submit = async (event) => { event.preventDefault(); -@@ -7199,7 +7199,7 @@ function CapabilityForm({ draft, setDraft, items, actions, onClose }) { +@@ -7199,7 +7199,7 @@ if (draft.kind === "mcp" && draft.transport === "stdio") { payload.transport = "stdio"; payload.command = draft.command; @@ -9741,7 +8599,7 @@ diff --git a/client.js b/client.js payload.env = parseMap(draft.envText, t("Environment")); } else if (draft.kind === "mcp") { payload.transport = "streamable-http"; -@@ -7219,95 +7219,95 @@ function CapabilityForm({ draft, setDraft, items, actions, onClose }) { +@@ -7219,95 +7219,95 @@ } }; const available = items.filter((item) => item.name !== draft.name && ["mcp", "skill"].includes(item.kind)); @@ -9877,7 +8735,7 @@ diff --git a/client.js b/client.js if (!confirming) return void 0; const timer = setTimeout(() => setConfirming(""), 4e3); return () => clearTimeout(timer); -@@ -7330,20 +7330,20 @@ function CapabilitiesPage({ roster, actions, run }) { +@@ -7330,20 +7330,20 @@ setConfirming(""); void run(actions.deleteCapability(item.name)); }; @@ -9905,7 +8763,7 @@ diff --git a/client.js b/client.js key: item.name, item, confirming: confirming === item.name, -@@ -7359,10 +7359,10 @@ function BotCapabilitiesCard({ bot, roster, actions, run, Card: Card2, SwitchRow +@@ -7359,10 +7359,10 @@ const attached = new Set(bot.capabilities ?? []); const statuses = roster.capabilityStatuses?.[bot.id] ?? {}; if (items.length === 0) return null; @@ -9918,7 +8776,7 @@ diff --git a/client.js b/client.js key: item.name, label: item.name, hint: attached.has(item.name) ? statusText(statuses[item.name]) : void 0, -@@ -7372,9 +7372,55 @@ function BotCapabilitiesCard({ bot, roster, actions, run, Card: Card2, SwitchRow +@@ -7372,9 +7372,55 @@ ); } @@ -9976,7 +8834,7 @@ diff --git a/client.js b/client.js var MENU_EXIT_MS2 = 120; var counts = /* @__PURE__ */ new Map(); var countListeners = /* @__PURE__ */ new Set(); -@@ -7384,13 +7384,13 @@ function noteCount(botId, count) { +@@ -7384,13 +7384,13 @@ for (const listener of countListeners) listener(); } function useMemoryCount(botId, actions) { @@ -9993,7 +8851,7 @@ diff --git a/client.js b/client.js if (!stop) noteCount(botId, view.entries.length); }).catch(() => { }); -@@ -7404,16 +7404,16 @@ function useMemoryCount(botId, actions) { +@@ -7404,16 +7404,16 @@ function MemoryRow({ bot, actions }) { const count = useMemoryCount(bot.id, actions); const label = count === null ? t("Loading…") : count === 0 ? t("Nothing saved yet") : count === 1 ? t("1 entry") : t("{count} entries", { count }); @@ -10016,7 +8874,7 @@ diff --git a/client.js b/client.js ) ); } -@@ -7428,45 +7428,45 @@ function ago(at, now) { +@@ -7428,45 +7428,45 @@ return format.format(-Math.round(hours / 24), "day"); } function Skeleton({ label }) { @@ -10081,7 +8939,7 @@ diff --git a/client.js b/client.js ) ); } -@@ -7478,9 +7478,9 @@ var groupEntries = (entries2) => entries2.reduce((groups, entry) => { +@@ -7478,9 +7478,9 @@ return groups; }, []); function EntryRow({ entry, remove }) { @@ -10094,7 +8952,7 @@ diff --git a/client.js b/client.js if (!confirming) return void 0; const timer = setTimeout(() => setConfirming(false), 3e3); return () => clearTimeout(timer); -@@ -7495,16 +7495,16 @@ function EntryRow({ entry, remove }) { +@@ -7495,16 +7495,16 @@ setBusy(true); if (!await remove(entry)) setBusy(false); }; @@ -10116,7 +8974,7 @@ diff --git a/client.js b/client.js type: "button", className: "bt-mem-remove", "data-confirm": confirming || void 0, -@@ -7512,7 +7512,7 @@ function EntryRow({ entry, remove }) { +@@ -7512,7 +7512,7 @@ "aria-label": confirming ? t("Click again to remove") : t("Remove from memory"), title: confirming ? void 0 : t("Remove from memory"), onClick: click @@ -10125,7 +8983,7 @@ diff --git a/client.js b/client.js ); } function Entries({ bot, view, remove }) { -@@ -7521,25 +7521,25 @@ function Entries({ bot, view, remove }) { +@@ -7521,25 +7521,25 @@ { scope: "own", title: t("{name}'s memory", { name: bot.name }), size: view.sizes.own }, { scope: "team", title: t("Team memory"), hint: t("Every Bot reads it"), size: view.sizes.team } ]; @@ -10162,7 +9020,7 @@ diff --git a/client.js b/client.js )) ); } -@@ -7551,52 +7551,52 @@ function changeLine(change) { +@@ -7551,52 +7551,52 @@ return change.scope === "team" ? t("Added to team memory: {text}", { text }) : t("Added: {text}", { text }); } function Reply({ answer, dismiss }) { @@ -10234,30 +9092,35 @@ diff --git a/client.js b/client.js alive.current = true; void load({ summarize: true }); return () => { -@@ -7606,38 +7606,331 @@ function useMemoryView(botId, actions) { +@@ -7606,9 +7606,9 @@ }, [botId]); return { view, error, load, show }; } -function useNow(every) { - const [now, setNow] = (0, import_react17.useState)(Date.now()); - (0, import_react17.useEffect)(() => { -- const id = setInterval(() => setNow(Date.now()), every); -- return () => clearInterval(id); -- }, [every]); -- return now; +function useNow3(every) { + const [now, setNow] = (0, import_react22.useState)(Date.now()); + (0, import_react22.useEffect)(() => { -+ const id = setInterval(() => setNow(Date.now()), every); -+ return () => clearInterval(id); -+ }, [every]); -+ return now; -+} -+function MemoryDialog({ bot, actions, leaving = false }) { -+ const { view, error, load, show } = useMemoryView(bot.id, actions); + const id = setInterval(() => setNow(Date.now()), every); + return () => clearInterval(id); + }, [every]); +@@ -7616,18 +7616,18 @@ + } + function MemoryDialog({ bot, actions, leaving = false }) { + const { view, error, load, show } = useMemoryView(bot.id, actions); +- const [page, setPage] = (0, import_react17.useState)("summary"); +- const [menu, setMenu] = (0, import_react17.useState)(false); + const [page, setPage] = (0, import_react22.useState)("summary"); + const [menu, setMenu] = (0, import_react22.useState)(false); -+ const [menuShown, menuLeaving] = useLinger(menu, MENU_EXIT_MS2); + const [menuShown, menuLeaving] = useLinger(menu, MENU_EXIT_MS2); +- const [text, setText] = (0, import_react17.useState)(""); +- const [answer, setAnswer] = (0, import_react17.useState)(null); +- const menuRef = (0, import_react17.useRef)(null); +- const dotsRef = (0, import_react17.useRef)(null); +- const inputRef = (0, import_react17.useRef)(null); +- const now = useNow(3e4); +- const close = (0, import_react17.useCallback)(() => actions.closeMemory(), []); + const [text, setText] = (0, import_react22.useState)(""); + const [answer, setAnswer] = (0, import_react22.useState)(null); + const menuRef = (0, import_react22.useRef)(null); @@ -10265,79 +9128,37 @@ diff --git a/client.js b/client.js + const inputRef = (0, import_react22.useRef)(null); + const now = useNow3(3e4); + const close = (0, import_react22.useCallback)(() => actions.closeMemory(), []); -+ useEscape(close); + useEscape(close); +- (0, import_react17.useEffect)(() => { + (0, import_react22.useEffect)(() => { -+ if (!menu) return void 0; -+ const onDown = (event) => { -+ if (menuRef.current?.contains(event.target) || dotsRef.current?.contains(event.target)) return; -+ setMenu(false); -+ }; -+ const onKey = (event) => { -+ if (event.key === "Escape") { -+ event.stopPropagation(); -+ setMenu(false); -+ dotsRef.current?.focus(); -+ } -+ }; -+ window.addEventListener("mousedown", onDown); -+ window.addEventListener("keydown", onKey, true); -+ return () => { -+ window.removeEventListener("mousedown", onDown); -+ window.removeEventListener("keydown", onKey, true); -+ }; -+ }, [menu]); -+ const regenerate = () => { -+ setMenu(false); -+ setPage("summary"); -+ void load({ regenerate: true }); -+ }; -+ const openEntries = () => { -+ setMenu(false); -+ setPage("entries"); -+ }; -+ const backToSummary = () => { -+ setPage("summary"); -+ void load({ summarize: true }); -+ }; -+ const remove = async (entry) => { -+ try { -+ show(await actions.memoryForget(bot.id, entry)); -+ return true; -+ } catch (failure) { -+ setAnswer({ asked: entry.text, error: failure?.message ?? String(failure) }); -+ return false; -+ } -+ }; -+ const ask = async (event) => { -+ event.preventDefault(); -+ const asked = text.trim(); -+ if (asked === "" || answer?.pending) return; -+ setText(""); -+ setAnswer({ asked, pending: true }); -+ try { -+ const result = await actions.memoryAsk(bot.id, asked); -+ setAnswer({ asked, reply: result.reply, changes: result.changes }); -+ show(result.view); -+ if (result.changes.some((change) => change.ok)) void load({ summarize: page === "summary" }); -+ } catch (failure) { -+ setAnswer({ asked, error: failure?.message ?? String(failure) }); -+ } -+ inputRef.current?.focus(); -+ }; -+ const count = view?.entries.length ?? 0; -+ const when3 = view?.summary ? ago(view.summary.at, now) : null; -+ const status = view === null ? "" : page === "entries" ? count === 1 ? t("1 entry") : t("{count} entries", { count }) : view.summarizing && view.summary ? t("Updating…") : view.summary && view.fresh ? when3 ? t("Updated {time}", { time: when3 }) : t("Updated just now") : ""; -+ const busy = answer?.pending === true; + if (!menu) return void 0; + const onDown = (event) => { + if (menuRef.current?.contains(event.target) || dotsRef.current?.contains(event.target)) return; +@@ -7689,52 +7689,52 @@ + const when3 = view?.summary ? ago(view.summary.at, now) : null; + const status = view === null ? "" : page === "entries" ? count === 1 ? t("1 entry") : t("{count} entries", { count }) : view.summarizing && view.summary ? t("Updating…") : view.summary && view.fresh ? when3 ? t("Updated {time}", { time: when3 }) : t("Updated just now") : ""; + const busy = answer?.pending === true; +- return (0, import_react17.createElement)( + return (0, import_react22.createElement)( -+ "div", -+ { className: "bt-mem-layer", "data-leaving": leaving || void 0 }, + "div", + { className: "bt-mem-layer", "data-leaving": leaving || void 0 }, +- (0, import_react17.createElement)("div", { className: "bt-scrim bt-mem-scrim", onMouseDown: close }), +- (0, import_react17.createElement)( + (0, import_react22.createElement)("div", { className: "bt-scrim bt-mem-scrim", onMouseDown: close }), + (0, import_react22.createElement)( -+ "div", -+ { className: "bt-mem", role: "dialog", "aria-modal": true, "aria-label": t("{name}'s memory", { name: bot.name }) }, + "div", + { className: "bt-mem", role: "dialog", "aria-modal": true, "aria-label": t("{name}'s memory", { name: bot.name }) }, +- (0, import_react17.createElement)( + (0, import_react22.createElement)( -+ "div", -+ { className: "bt-mem-head" }, + "div", + { className: "bt-mem-head" }, +- page === "entries" ? (0, import_react17.createElement)("button", { type: "button", className: "bt-icon-btn", "aria-label": t("Back to the summary"), onClick: backToSummary }, (0, import_react17.createElement)(ChevronLeftIcon)) : null, +- (0, import_react17.createElement)("h2", null, page === "entries" ? t("All entries") : t("Memory summary")), +- (0, import_react17.createElement)("span", { className: "bt-mem-status" }, page === "summary" ? [bot.name, status].filter(Boolean).join(" · ") : status), +- (0, import_react17.createElement)("span", { className: "bt-mem-head-gap" }), +- (0, import_react17.createElement)("button", { ref: dotsRef, type: "button", className: "bt-icon-btn", "aria-label": t("Memory options"), "aria-haspopup": "menu", "aria-expanded": menu, onClick: () => setMenu((value) => !value) }, (0, import_react17.createElement)(DotsIcon)), +- (0, import_react17.createElement)("button", { type: "button", className: "bt-icon-btn", "aria-label": t("Close memory"), onClick: close }, (0, import_react17.createElement)(CloseIcon)), +- menuShown ? (0, import_react17.createElement)( + page === "entries" ? (0, import_react22.createElement)("button", { type: "button", className: "bt-icon-btn", "aria-label": t("Back to the summary"), onClick: backToSummary }, (0, import_react22.createElement)(ChevronLeftIcon)) : null, + (0, import_react22.createElement)("h2", null, page === "entries" ? t("All entries") : t("Memory summary")), + (0, import_react22.createElement)("span", { className: "bt-mem-status" }, page === "summary" ? [bot.name, status].filter(Boolean).join(" · ") : status), @@ -10345,52 +9166,64 @@ diff --git a/client.js b/client.js + (0, import_react22.createElement)("button", { ref: dotsRef, type: "button", className: "bt-icon-btn", "aria-label": t("Memory options"), "aria-haspopup": "menu", "aria-expanded": menu, onClick: () => setMenu((value) => !value) }, (0, import_react22.createElement)(DotsIcon)), + (0, import_react22.createElement)("button", { type: "button", className: "bt-icon-btn", "aria-label": t("Close memory"), onClick: close }, (0, import_react22.createElement)(CloseIcon)), + menuShown ? (0, import_react22.createElement)( -+ "div", -+ { ref: menuRef, className: "bt-menu bt-mem-menu", role: "menu", "aria-label": t("Memory options"), "data-leaving": menuLeaving || void 0 }, + "div", + { ref: menuRef, className: "bt-menu bt-mem-menu", role: "menu", "aria-label": t("Memory options"), "data-leaving": menuLeaving || void 0 }, +- (0, import_react17.createElement)( + (0, import_react22.createElement)( -+ "button", -+ { type: "button", role: "menuitem", onClick: openEntries }, + "button", + { type: "button", role: "menuitem", onClick: openEntries }, +- (0, import_react17.createElement)("span", { className: "bt-menu-label" }, t("All entries")), +- (0, import_react17.createElement)("span", { className: "bt-menu-hint" }, String(count)) + (0, import_react22.createElement)("span", { className: "bt-menu-label" }, t("All entries")), + (0, import_react22.createElement)("span", { className: "bt-menu-hint" }, String(count)) -+ ), + ), +- (0, import_react17.createElement)( + (0, import_react22.createElement)( -+ "button", -+ { type: "button", role: "menuitem", disabled: count === 0 || view?.summarizing, onClick: regenerate }, + "button", + { type: "button", role: "menuitem", disabled: count === 0 || view?.summarizing, onClick: regenerate }, +- (0, import_react17.createElement)("span", { className: "bt-menu-label" }, t("Regenerate summary")) + (0, import_react22.createElement)("span", { className: "bt-menu-label" }, t("Regenerate summary")) -+ ) -+ ) : null -+ ), + ) + ) : null + ), +- (0, import_react17.createElement)( + (0, import_react22.createElement)( -+ "div", -+ { className: "bt-mem-body" }, + "div", + { className: "bt-mem-body" }, +- error ? (0, import_react17.createElement)("div", { className: "bt-error", role: "alert", style: { padding: 0 } }, hostText(error)) : null, +- view === null ? error ? null : (0, import_react17.createElement)(Skeleton) : page === "entries" ? (0, import_react17.createElement)(Entries, { bot, view, remove }) : (0, import_react17.createElement)(Summary, { bot, view, regenerate }) + error ? (0, import_react22.createElement)("div", { className: "bt-error", role: "alert", style: { padding: 0 } }, hostText(error)) : null, + view === null ? error ? null : (0, import_react22.createElement)(Skeleton) : page === "entries" ? (0, import_react22.createElement)(Entries, { bot, view, remove }) : (0, import_react22.createElement)(Summary, { bot, view, regenerate }) -+ ), + ), +- (0, import_react17.createElement)( + (0, import_react22.createElement)( -+ "div", -+ { className: "bt-mem-foot" }, + "div", + { className: "bt-mem-foot" }, +- answer ? (0, import_react17.createElement)(Reply, { answer, dismiss: () => setAnswer(null) }) : null, +- (0, import_react17.createElement)( + answer ? (0, import_react22.createElement)(Reply, { answer, dismiss: () => setAnswer(null) }) : null, + (0, import_react22.createElement)( -+ "form", -+ { className: "bt-mem-ask", onSubmit: ask }, + "form", + { className: "bt-mem-ask", onSubmit: ask }, +- (0, import_react17.createElement)("input", { + (0, import_react22.createElement)("input", { -+ ref: inputRef, -+ className: "bt-mem-input", -+ value: text, -+ maxLength: 2e3, -+ disabled: view === null, -+ placeholder: t("Ask or update"), -+ "aria-label": t("Ask about or update {name}'s memory", { name: bot.name }), -+ onChange: (event) => setText(event.target.value) -+ }), + ref: inputRef, + className: "bt-mem-input", + value: text, +@@ -7744,19 +7744,51 @@ + "aria-label": t("Ask about or update {name}'s memory", { name: bot.name }), + onChange: (event) => setText(event.target.value) + }), +- (0, import_react17.createElement)( + (0, import_react22.createElement)( -+ "button", -+ { type: "submit", className: "bt-mem-send", disabled: busy || text.trim() === "", "aria-label": t("Send") }, + "button", + { type: "submit", className: "bt-mem-send", disabled: busy || text.trim() === "", "aria-label": t("Send") }, +- busy ? (0, import_react17.createElement)("span", { className: "bt-mem-spin", "aria-hidden": true }) : (0, import_react17.createElement)(ArrowUpIcon) + busy ? (0, import_react22.createElement)("span", { className: "bt-mem-spin", "aria-hidden": true }) : (0, import_react22.createElement)(ArrowUpIcon) -+ ) -+ ) -+ ) -+ ) + ) + ) + ) + ) + ); +} + @@ -10423,418 +9256,344 @@ diff --git a/client.js b/client.js + onClick: () => run(actions.removeStandingApproval(scope, target.id, grant.kind)) + }, t("Remove")) + )) -+ ); -+} -+ -+// src/client/bot-settings.js + ); + } + + // src/client/bot-settings.js +-var import_react18 = require("react"); +var import_react24 = require("react"); -+var capitalize = (word) => word[0].toUpperCase() + word.slice(1); -+async function avatarImage(file) { -+ if (!/^image\/(png|jpeg|webp|gif)$/.test(file.type)) throw new Error(t("Choose a PNG, JPEG, WebP, or GIF image")); -+ if (file.size > 25 * 1024 * 1024) throw new Error(t("Choose an image smaller than 25 MB")); -+ const bitmap = await createImageBitmap(file); -+ const side = 160; -+ const canvas = document.createElement("canvas"); -+ canvas.width = side; -+ canvas.height = side; -+ const context = canvas.getContext("2d"); -+ context.imageSmoothingQuality = "high"; -+ const crop = Math.min(bitmap.width, bitmap.height); -+ context.drawImage(bitmap, (bitmap.width - crop) / 2, (bitmap.height - crop) / 2, crop, crop, 0, 0, side, side); -+ bitmap.close?.(); -+ for (const quality of [0.9, 0.75, 0.6]) { -+ const url = canvas.toDataURL("image/webp", quality); -+ if (url.startsWith("data:image/webp") && url.length < 15e4) return url; -+ } -+ const png = canvas.toDataURL("image/png"); -+ if (png.length < 15e4) return png; -+ throw new Error(t("That image is too large")); -+} -+function InlineText({ label, value, placeholder, required = false, maxLength, multiline = false, onSave }) { + var capitalize = (word) => word[0].toUpperCase() + word.slice(1); + async function avatarImage(file) { + if (!/^image\/(png|jpeg|webp|gif)$/.test(file.type)) throw new Error(t("Choose a PNG, JPEG, WebP, or GIF image")); +@@ -7780,8 +7780,8 @@ + throw new Error(t("That image is too large")); + } + function InlineText({ label, value, placeholder, required = false, maxLength, multiline = false, onSave }) { +- const [draft, setDraft] = (0, import_react18.useState)(null); +- const reverting = (0, import_react18.useRef)(false); + const [draft, setDraft] = (0, import_react24.useState)(null); + const reverting = (0, import_react24.useRef)(false); -+ const commit = () => { -+ const next = draft?.trim(); -+ setDraft(null); -+ if (reverting.current) { -+ reverting.current = false; -+ return; -+ } -+ if (next === void 0 || next === (value ?? "") || required && next === "") return; -+ void onSave(next); -+ }; + const commit = () => { + const next = draft?.trim(); + setDraft(null); +@@ -7792,7 +7792,7 @@ + if (next === void 0 || next === (value ?? "") || required && next === "") return; + void onSave(next); + }; +- return (0, import_react18.createElement)(multiline ? "textarea" : "input", { + return (0, import_react24.createElement)(multiline ? "textarea" : "input", { -+ className: multiline ? "bt-bs-input bt-bs-area" : "bt-bs-input", -+ value: draft ?? value ?? "", -+ placeholder, -+ maxLength, -+ required, -+ "aria-label": label, -+ spellCheck: false, -+ onChange: (event) => setDraft(event.target.value), -+ onBlur: commit, -+ onKeyDown: (event) => { -+ if (event.key === "Escape") { -+ event.stopPropagation(); -+ reverting.current = true; -+ event.currentTarget.blur(); -+ } -+ if (event.key === "Enter" && !multiline && !event.nativeEvent.isComposing) event.currentTarget.blur(); -+ } -+ }); -+} -+function Row({ label, hint, stack = false, children }) { -+ return (0, import_react24.createElement)( -+ "label", -+ { className: stack ? "bt-set-row bt-bs-row bt-bs-stack" : "bt-set-row bt-bs-row" }, -+ (0, import_react24.createElement)("span", { className: "bt-set-copy" }, (0, import_react24.createElement)("span", { className: "bt-bs-label" }, label), hint ? (0, import_react24.createElement)("span", { className: "bt-set-hint" }, hint) : null), -+ children -+ ); -+} -+function Card({ title, children }) { + className: multiline ? "bt-bs-input bt-bs-area" : "bt-bs-input", + value: draft ?? value ?? "", + placeholder, +@@ -7813,29 +7813,29 @@ + }); + } + function Row({ label, hint, stack = false, children }) { +- return (0, import_react18.createElement)( ++ return (0, import_react24.createElement)( + "label", + { className: stack ? "bt-set-row bt-bs-row bt-bs-stack" : "bt-set-row bt-bs-row" }, +- (0, import_react18.createElement)("span", { className: "bt-set-copy" }, (0, import_react18.createElement)("span", { className: "bt-bs-label" }, label), hint ? (0, import_react18.createElement)("span", { className: "bt-set-hint" }, hint) : null), ++ (0, import_react24.createElement)("span", { className: "bt-set-copy" }, (0, import_react24.createElement)("span", { className: "bt-bs-label" }, label), hint ? (0, import_react24.createElement)("span", { className: "bt-set-hint" }, hint) : null), + children + ); + } + function Card({ title, children }) { +- return (0, import_react18.createElement)("section", { className: "bt-set-section" }, title ? (0, import_react18.createElement)("h3", null, title) : null, (0, import_react18.createElement)("div", { className: "bt-set-card" }, children)); + return (0, import_react24.createElement)("section", { className: "bt-set-section" }, title ? (0, import_react24.createElement)("h3", null, title) : null, (0, import_react24.createElement)("div", { className: "bt-set-card" }, children)); -+} -+function Switch({ on, label, disabled, onChange }) { + } + function Switch({ on, label, disabled, onChange }) { +- return (0, import_react18.createElement)( + return (0, import_react24.createElement)( -+ "button", -+ { type: "button", role: "switch", className: "bt-switch", "aria-checked": on, "aria-label": label, disabled, onClick: () => onChange(!on) }, + "button", + { type: "button", role: "switch", className: "bt-switch", "aria-checked": on, "aria-label": label, disabled, onClick: () => onChange(!on) }, +- (0, import_react18.createElement)("span", { className: "bt-switch-knob", "aria-hidden": true }) + (0, import_react24.createElement)("span", { className: "bt-switch-knob", "aria-hidden": true }) -+ ); -+} + ); + } +-function SwitchRow({ label, hint, on, disabled, onChange }) { +- return (0, import_react18.createElement)( +function SwitchRow({ label, hint, on, disabled, onChange, showLabel = true }) { + return (0, import_react24.createElement)( -+ "div", -+ { className: "bt-set-row bt-bs-row" }, + "div", + { className: "bt-set-row bt-bs-row" }, +- (0, import_react18.createElement)("span", { className: "bt-set-copy" }, (0, import_react18.createElement)("span", { className: "bt-bs-label" }, label), hint ? (0, import_react18.createElement)("span", { className: "bt-set-hint" }, hint) : null), +- (0, import_react18.createElement)(Switch, { on, label, disabled, onChange }) + (0, import_react24.createElement)("span", { className: "bt-set-copy" }, showLabel ? (0, import_react24.createElement)("span", { className: "bt-bs-label" }, label) : null, hint ? (0, import_react24.createElement)("span", { className: "bt-set-hint" }, hint) : null), + (0, import_react24.createElement)(Switch, { on, label, disabled, onChange }) -+ ); -+} -+function modelHint(bot, roster) { -+ const shown = modelOf(bot, roster); -+ if (shown === void 0) return t("Pick the model this Bot runs on."); -+ if (!shown.served && (roster.models ?? []).length > 0) return t("This model is not available now. Pick another one."); -+ if (bot.appliedModel && bot.appliedModel === bot.model) return t("Every message of this Bot runs on it."); -+ return t("Applies from the next message."); + ); } --function MemoryDialog({ bot, actions, leaving = false }) { -- const { view, error, load, show } = useMemoryView(bot.id, actions); -- const [page, setPage] = (0, import_react17.useState)("summary"); -- const [menu, setMenu] = (0, import_react17.useState)(false); -- const [menuShown, menuLeaving] = useLinger(menu, MENU_EXIT_MS2); -- const [text, setText] = (0, import_react17.useState)(""); -- const [answer, setAnswer] = (0, import_react17.useState)(null); -- const menuRef = (0, import_react17.useRef)(null); -- const dotsRef = (0, import_react17.useRef)(null); -- const inputRef = (0, import_react17.useRef)(null); -- const now = useNow(3e4); -- const close = (0, import_react17.useCallback)(() => actions.closeMemory(), []); -- useEscape(close); -- (0, import_react17.useEffect)(() => { -- if (!menu) return void 0; -+function ShapeRing({ shape, size }) { + function modelHint(bot, roster) { +@@ -7846,22 +7846,22 @@ + return t("Applies from the next message."); + } + function ShapeRing({ shape, size }) { +- const maskId = (0, import_react18.useRef)(""); + const maskId = (0, import_react24.useRef)(""); -+ if (maskId.current === "") maskId.current = `bt-ring-${nextMarkSerial()}`; -+ const def = shapeOf(shape); -+ const unit = def.size / size; -+ const markup = `${holes(def, `${bodyOf(def, "#000", true)}`)}${bodyOf(def, "currentColor", true)}`; + if (maskId.current === "") maskId.current = `bt-ring-${nextMarkSerial()}`; + const def = shapeOf(shape); + const unit = def.size / size; + const markup = `${holes(def, `${bodyOf(def, "#000", true)}`)}${bodyOf(def, "currentColor", true)}`; +- return (0, import_react18.createElement)("svg", { className: "bt-ring", viewBox: `0 0 ${def.size} ${def.size}`, "aria-hidden": true, dangerouslySetInnerHTML: { __html: markup } }); + return (0, import_react24.createElement)("svg", { className: "bt-ring", viewBox: `0 0 ${def.size} ${def.size}`, "aria-hidden": true, dangerouslySetInnerHTML: { __html: markup } }); -+} -+function CharacterGrid({ look, hasPhoto = false, onShape, onColor }) { -+ const shapes = [...PICKER_SHAPES, ...Object.keys(SHAPES).filter((id) => !Object.hasOwn(CHARACTERS, id))]; -+ if (!shapes.includes(look.shape)) shapes.push(look.shape); -+ const rows = []; -+ for (let index = 0; index < shapes.length; index += PICKER_COLUMNS) rows.push(shapes.slice(index, index + PICKER_COLUMNS)); + } + function CharacterGrid({ look, hasPhoto = false, onShape, onColor }) { + const shapes = [...PICKER_SHAPES, ...Object.keys(SHAPES).filter((id) => !Object.hasOwn(CHARACTERS, id))]; + if (!shapes.includes(look.shape)) shapes.push(look.shape); + const rows = []; + for (let index = 0; index < shapes.length; index += PICKER_COLUMNS) rows.push(shapes.slice(index, index + PICKER_COLUMNS)); +- return (0, import_react18.createElement)( +- import_react18.Fragment, + return (0, import_react24.createElement)( + import_react24.Fragment, -+ null, + null, +- (0, import_react18.createElement)("div", { className: "bt-shapes", role: "group", "aria-label": t("Character shape") }, rows.map((row, index) => (0, import_react18.createElement)("div", { key: index, className: "bt-shape-row" }, row.map((id) => (0, import_react18.createElement)("button", { + (0, import_react24.createElement)("div", { className: "bt-shapes", role: "group", "aria-label": t("Character shape") }, rows.map((row, index) => (0, import_react24.createElement)("div", { key: index, className: "bt-shape-row" }, row.map((id) => (0, import_react24.createElement)("button", { -+ key: id, -+ type: "button", -+ className: "bt-shape", -+ "aria-label": t(shapeOf(id).label), -+ title: t(shapeOf(id).label), -+ "aria-pressed": !hasPhoto && look.shape === id, -+ onClick: () => onShape(id) + key: id, + type: "button", + className: "bt-shape", +@@ -7869,8 +7869,8 @@ + title: t(shapeOf(id).label), + "aria-pressed": !hasPhoto && look.shape === id, + onClick: () => onShape(id) +- }, (0, import_react18.createElement)(ShapeRing, { shape: id, size: 36 }), (0, import_react18.createElement)(BotMark, { look: { shape: id, color: look.color }, size: 36 })))))), +- (0, import_react18.createElement)("div", { className: "bt-colors", role: "group", "aria-label": t("Character color") }, CHARACTER_COLORS.map((color) => (0, import_react18.createElement)("button", { + }, (0, import_react24.createElement)(ShapeRing, { shape: id, size: 36 }), (0, import_react24.createElement)(BotMark, { look: { shape: id, color: look.color }, size: 36 })))))), + (0, import_react24.createElement)("div", { className: "bt-colors", role: "group", "aria-label": t("Character color") }, CHARACTER_COLORS.map((color) => (0, import_react24.createElement)("button", { -+ key: color, -+ type: "button", -+ className: "bt-color", -+ "aria-label": t(capitalize(color)), -+ title: t(capitalize(color)), -+ "aria-pressed": look.color === color, -+ onClick: () => onColor(color) + key: color, + type: "button", + className: "bt-color", +@@ -7878,19 +7878,19 @@ + title: t(capitalize(color)), + "aria-pressed": look.color === color, + onClick: () => onColor(color) +- }, (0, import_react18.createElement)("span", { style: { background: paintCss(color) } })))) + }, (0, import_react24.createElement)("span", { style: { background: paintCss(color) } })))) -+ ); -+} -+function AvatarEditor({ bot, actions, run, onClose }) { -+ const look = lookOf(bot); + ); + } + function AvatarEditor({ bot, actions, run, onClose }) { + const look = lookOf(bot); +- const [tab, setTab] = (0, import_react18.useState)(look.image ? "upload" : "bot"); +- const [over, setOver] = (0, import_react18.useState)(false); +- const ref = (0, import_react18.useRef)(null); +- const fileRef = (0, import_react18.useRef)(null); + const [tab, setTab] = (0, import_react24.useState)(look.image ? "upload" : "bot"); + const [over, setOver] = (0, import_react24.useState)(false); + const ref = (0, import_react24.useRef)(null); + const fileRef = (0, import_react24.useRef)(null); -+ const upload = (file) => { -+ if (file) void run(avatarImage(file).then((image) => actions.updateBot(bot.id, { avatar: { ...bot.avatar ?? {}, image } }))); -+ }; + const upload = (file) => { + if (file) void run(avatarImage(file).then((image) => actions.updateBot(bot.id, { avatar: { ...bot.avatar ?? {}, image } }))); + }; +- (0, import_react18.useEffect)(() => { + (0, import_react24.useEffect)(() => { const onDown = (event) => { -- if (menuRef.current?.contains(event.target) || dotsRef.current?.contains(event.target)) return; -- setMenu(false); -+ if (!(event.target instanceof Element) || ref.current?.contains(event.target) || event.target.closest(".bt-avatar-trigger")) return; -+ onClose(); - }; - const onKey = (event) => { - if (event.key === "Escape") { - event.stopPropagation(); -- setMenu(false); -- dotsRef.current?.focus(); -+ onClose(); - } - }; - window.addEventListener("mousedown", onDown); -@@ -7646,514 +7646,863 @@ function MemoryDialog({ bot, actions, leaving = false }) { - window.removeEventListener("mousedown", onDown); + if (!(event.target instanceof Element) || ref.current?.contains(event.target) || event.target.closest(".bt-avatar-trigger")) return; + onClose(); +@@ -7908,7 +7908,7 @@ window.removeEventListener("keydown", onKey, true); }; -- }, [menu]); -- const regenerate = () => { -- setMenu(false); -- setPage("summary"); -- void load({ regenerate: true }); -- }; -- const openEntries = () => { -- setMenu(false); -- setPage("entries"); -- }; -- const backToSummary = () => { -- setPage("summary"); -- void load({ summarize: true }); -- }; -- const remove = async (entry) => { -- try { -- show(await actions.memoryForget(bot.id, entry)); -- return true; -- } catch (failure) { -- setAnswer({ asked: entry.text, error: failure?.message ?? String(failure) }); -- return false; -- } -- }; -- const ask = async (event) => { -- event.preventDefault(); -- const asked = text.trim(); -- if (asked === "" || answer?.pending) return; -- setText(""); -- setAnswer({ asked, pending: true }); -- try { -- const result = await actions.memoryAsk(bot.id, asked); -- setAnswer({ asked, reply: result.reply, changes: result.changes }); -- show(result.view); -- if (result.changes.some((change) => change.ok)) void load({ summarize: page === "summary" }); -- } catch (failure) { -- setAnswer({ asked, error: failure?.message ?? String(failure) }); -- } -- inputRef.current?.focus(); -+ }, [onClose]); + }, [onClose]); +- (0, import_react18.useEffect)(() => { + (0, import_react24.useEffect)(() => { -+ if (tab !== "upload") return void 0; -+ const onPaste = (event) => { -+ const file = [...event.clipboardData?.files ?? []].find((item) => item.type.startsWith("image/")); -+ if (file) { -+ event.preventDefault(); -+ upload(file); -+ } -+ }; -+ window.addEventListener("paste", onPaste); -+ return () => window.removeEventListener("paste", onPaste); -+ }, [tab, bot]); -+ const hasPhoto = Boolean(look.image); -+ const glide = useGlide(tab); + if (tab !== "upload") return void 0; + const onPaste = (event) => { + const file = [...event.clipboardData?.files ?? []].find((item) => item.type.startsWith("image/")); +@@ -7922,32 +7922,32 @@ + }, [tab, bot]); + const hasPhoto = Boolean(look.image); + const glide = useGlide(tab); +- return (0, import_react18.createElement)( + return (0, import_react24.createElement)( -+ "div", -+ { ref, className: "bt-avedit", role: "dialog", "aria-label": t("Avatar editor") }, + "div", + { ref, className: "bt-avedit", role: "dialog", "aria-label": t("Avatar editor") }, +- (0, import_react18.createElement)( + (0, import_react24.createElement)( -+ "div", -+ { className: "bt-avedit-top" }, + "div", + { className: "bt-avedit-top" }, +- (0, import_react18.createElement)( + (0, import_react24.createElement)( -+ "div", -+ { ref: glide.box, role: "tablist", "aria-label": t("Avatar source"), className: "bt-avedit-tabs" }, + "div", + { ref: glide.box, role: "tablist", "aria-label": t("Avatar source"), className: "bt-avedit-tabs" }, +- (0, import_react18.createElement)("span", { ref: glide.thumb, className: "bt-thumb", "aria-hidden": true }), +- [["bot", "Bot"], ["upload", "Upload"]].map(([id, label]) => (0, import_react18.createElement)("button", { key: id, type: "button", role: "tab", className: "bt-avedit-tab", "aria-selected": tab === id, onClick: () => setTab(id) }, t(label))) + (0, import_react24.createElement)("span", { ref: glide.thumb, className: "bt-thumb", "aria-hidden": true }), + [["bot", "Bot"], ["upload", "Upload"]].map(([id, label]) => (0, import_react24.createElement)("button", { key: id, type: "button", role: "tab", className: "bt-avedit-tab", "aria-selected": tab === id, onClick: () => setTab(id) }, t(label))) -+ ), + ), +- (0, import_react18.createElement)("button", { + (0, import_react24.createElement)("button", { -+ type: "button", -+ className: "bt-avedit-tab bt-avedit-reset", -+ title: t("Reset character to default"), -+ onClick: () => run(actions.updateBot(bot.id, { avatar: null, color: null })) -+ }, t("Reset")) -+ ), + type: "button", + className: "bt-avedit-tab bt-avedit-reset", + title: t("Reset character to default"), + onClick: () => run(actions.updateBot(bot.id, { avatar: null, color: null })) + }, t("Reset")) + ), +- tab === "bot" ? (0, import_react18.createElement)(CharacterGrid, { + tab === "bot" ? (0, import_react24.createElement)(CharacterGrid, { -+ look, -+ hasPhoto, -+ onShape: (shape) => run(actions.updateBot(bot.id, { avatar: { shape } })), -+ onColor: (color) => run(actions.updateBot(bot.id, { color })) -+ }) : null, + look, + hasPhoto, + onShape: (shape) => run(actions.updateBot(bot.id, { avatar: { shape } })), + onColor: (color) => run(actions.updateBot(bot.id, { color })) + }) : null, +- tab === "upload" ? (0, import_react18.createElement)( + tab === "upload" ? (0, import_react24.createElement)( -+ "div", -+ { -+ className: "bt-dropzone", -+ "data-over": over || void 0, -+ onDragOver: (event) => { -+ event.preventDefault(); -+ setOver(true); -+ }, -+ onDragLeave: () => setOver(false), -+ onDrop: (event) => { -+ event.preventDefault(); -+ setOver(false); -+ upload(event.dataTransfer.files?.[0]); -+ } -+ }, + "div", + { + className: "bt-dropzone", +@@ -7963,15 +7963,15 @@ + upload(event.dataTransfer.files?.[0]); + } + }, +- hasPhoto ? (0, import_react18.createElement)("img", { src: look.image, alt: "" }) : null, +- (0, import_react18.createElement)("div", null, t("Drag, drop, or paste an image")), +- (0, import_react18.createElement)( + hasPhoto ? (0, import_react24.createElement)("img", { src: look.image, alt: "" }) : null, + (0, import_react24.createElement)("div", null, t("Drag, drop, or paste an image")), + (0, import_react24.createElement)( -+ "div", -+ { className: "bt-actions", style: { justifyContent: "center" } }, + "div", + { className: "bt-actions", style: { justifyContent: "center" } }, +- (0, import_react18.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => fileRef.current?.click() }, hasPhoto ? t("Replace") : t("Browse files")), +- hasPhoto ? (0, import_react18.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => run(actions.updateBot(bot.id, { avatar: hasShape(bot.avatar?.shape) ? { shape: bot.avatar.shape } : null })) }, t("Remove photo")) : null + (0, import_react24.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => fileRef.current?.click() }, hasPhoto ? t("Replace") : t("Browse files")), + hasPhoto ? (0, import_react24.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => run(actions.updateBot(bot.id, { avatar: hasShape(bot.avatar?.shape) ? { shape: bot.avatar.shape } : null })) }, t("Remove photo")) : null -+ ), + ), +- (0, import_react18.createElement)("input", { ref: fileRef, type: "file", accept: "image/png,image/jpeg,image/webp,image/gif", hidden: true, onChange: (event) => { + (0, import_react24.createElement)("input", { ref: fileRef, type: "file", accept: "image/png,image/jpeg,image/webp,image/gif", hidden: true, onChange: (event) => { -+ const file = event.target.files?.[0]; -+ event.target.value = ""; -+ upload(file); -+ } }) -+ ) : null -+ ); -+} -+function MemoryCard({ bot, actions }) { -+ const count = useMemoryCount(bot.id, actions); -+ const label = count === null ? t("Loading…") : count === 0 ? t("Nothing saved yet") : count === 1 ? t("1 entry") : t("{count} entries", { count }); + const file = event.target.files?.[0]; + event.target.value = ""; + upload(file); +@@ -7982,26 +7982,27 @@ + function MemoryCard({ bot, actions }) { + const count = useMemoryCount(bot.id, actions); + const label = count === null ? t("Loading…") : count === 0 ? t("Nothing saved yet") : count === 1 ? t("1 entry") : t("{count} entries", { count }); +- return (0, import_react18.createElement)( + return (0, import_react24.createElement)( -+ Card, -+ { title: t("Memory") }, + Card, + { title: t("Memory") }, +- (0, import_react18.createElement)( + (0, import_react24.createElement)( -+ "button", -+ { type: "button", className: "bt-set-row bt-bs-row", "aria-label": t("Manage {name}'s memory", { name: bot.name }), onClick: () => actions.openMemory(bot.id) }, + "button", + { type: "button", className: "bt-set-row bt-bs-row", "aria-label": t("Manage {name}'s memory", { name: bot.name }), onClick: () => actions.openMemory(bot.id) }, +- (0, import_react18.createElement)("span", { className: "bt-set-copy" }, (0, import_react18.createElement)("span", { className: "bt-bs-label" }, label)), +- (0, import_react18.createElement)("span", { className: "bt-mem-row-go" }, t("Manage"), (0, import_react18.createElement)(ChevronRightIcon)) + (0, import_react24.createElement)("span", { className: "bt-set-copy" }, (0, import_react24.createElement)("span", { className: "bt-bs-label" }, label)), + (0, import_react24.createElement)("span", { className: "bt-mem-row-go" }, t("Manage"), (0, import_react24.createElement)(ChevronRightIcon)) -+ ) -+ ); -+} -+function BotSettingsForm({ bot, roster, actions, run }) { + ) + ); + } + function BotSettingsForm({ bot, roster, actions, run }) { +- const [editing, setEditing] = (0, import_react18.useState)(false); +- const [confirming, setConfirming] = (0, import_react18.useState)(false); +- const closeEditor = (0, import_react18.useCallback)(() => setEditing(false), []); +- (0, import_react18.useEffect)(() => { + const [editing, setEditing] = (0, import_react24.useState)(false); + const [confirming, setConfirming] = (0, import_react24.useState)(false); + const closeEditor = (0, import_react24.useCallback)(() => setEditing(false), []); + (0, import_react24.useEffect)(() => { -+ if (!confirming) return void 0; -+ const timer = setTimeout(() => setConfirming(false), 4e3); -+ return () => clearTimeout(timer); -+ }, [confirming]); + if (!confirming) return void 0; + const timer = setTimeout(() => setConfirming(false), 4e3); + return () => clearTimeout(timer); + }, [confirming]); + if (bot.remote) return null; -+ const isMainBot = isMainOf(roster, bot.id); -+ const onlyMain = isMainBot && (roster.mainBotIds ?? []).length === 1; -+ const isTile = roster.mainBotId === bot.id; -+ const save = (patch) => run(actions.updateBot(bot.id, patch)); -+ const duplicate = () => run(actions.duplicateBot(bot.id).then((copy2) => actions.openSession(copy2.id))); -+ const remove = () => { -+ if (confirming) void run(actions.deleteBot(bot.id)); -+ else setConfirming(true); + const isMainBot = isMainOf(roster, bot.id); + const onlyMain = isMainBot && (roster.mainBotIds ?? []).length === 1; + const isTile = roster.mainBotId === bot.id; +@@ -8011,112 +8011,116 @@ + if (confirming) void run(actions.deleteBot(bot.id)); + else setConfirming(true); }; -- const count = view?.entries.length ?? 0; -- const when3 = view?.summary ? ago(view.summary.at, now) : null; -- const status = view === null ? "" : page === "entries" ? count === 1 ? t("1 entry") : t("{count} entries", { count }) : view.summarizing && view.summary ? t("Updating…") : view.summary && view.fresh ? when3 ? t("Updated {time}", { time: when3 }) : t("Updated just now") : ""; -- const busy = answer?.pending === true; -- return (0, import_react17.createElement)( +- (0, import_react18.useEffect)(() => { + (0, import_react24.useEffect)(() => { -+ void actions.refreshModels?.(); -+ }, [bot.id]); + void actions.refreshModels?.(); + }, [bot.id]); +- return (0, import_react18.createElement)( + return (0, import_react24.createElement)( "div", -- { className: "bt-mem-layer", "data-leaving": leaving || void 0 }, -- (0, import_react17.createElement)("div", { className: "bt-scrim bt-mem-scrim", onMouseDown: close }), -- (0, import_react17.createElement)( -+ { className: "bt-bs" }, + { className: "bt-bs" }, +- (0, import_react18.createElement)( + (0, import_react24.createElement)( "div", -- { className: "bt-mem", role: "dialog", "aria-modal": true, "aria-label": t("{name}'s memory", { name: bot.name }) }, -- (0, import_react17.createElement)( -+ { className: "bt-bs-hero" }, + { className: "bt-bs-hero" }, +- (0, import_react18.createElement)( + (0, import_react24.createElement)( "div", -- { className: "bt-mem-head" }, -- page === "entries" ? (0, import_react17.createElement)("button", { type: "button", className: "bt-icon-btn", "aria-label": t("Back to the summary"), onClick: backToSummary }, (0, import_react17.createElement)(ChevronLeftIcon)) : null, -- (0, import_react17.createElement)("h2", null, page === "entries" ? t("All entries") : t("Memory summary")), -- (0, import_react17.createElement)("span", { className: "bt-mem-status" }, page === "summary" ? [bot.name, status].filter(Boolean).join(" · ") : status), -- (0, import_react17.createElement)("span", { className: "bt-mem-head-gap" }), -- (0, import_react17.createElement)("button", { ref: dotsRef, type: "button", className: "bt-icon-btn", "aria-label": t("Memory options"), "aria-haspopup": "menu", "aria-expanded": menu, onClick: () => setMenu((value) => !value) }, (0, import_react17.createElement)(DotsIcon)), -- (0, import_react17.createElement)("button", { type: "button", className: "bt-icon-btn", "aria-label": t("Close memory"), onClick: close }, (0, import_react17.createElement)(CloseIcon)), -- menuShown ? (0, import_react17.createElement)( -- "div", -- { ref: menuRef, className: "bt-menu bt-mem-menu", role: "menu", "aria-label": t("Memory options"), "data-leaving": menuLeaving || void 0 }, -- (0, import_react17.createElement)( -- "button", -- { type: "button", role: "menuitem", onClick: openEntries }, -- (0, import_react17.createElement)("span", { className: "bt-menu-label" }, t("All entries")), -- (0, import_react17.createElement)("span", { className: "bt-menu-hint" }, String(count)) -- ), -- (0, import_react17.createElement)( -- "button", -- { type: "button", role: "menuitem", disabled: count === 0 || view?.summarizing, onClick: regenerate }, -- (0, import_react17.createElement)("span", { className: "bt-menu-label" }, t("Regenerate summary")) -- ) -- ) : null -+ { className: "bt-avatar-anchor" }, + { className: "bt-avatar-anchor" }, +- (0, import_react18.createElement)( + (0, import_react24.createElement)( -+ "button", -+ { type: "button", className: "bt-avatar-trigger", "aria-label": t("Edit Bot avatar"), "aria-expanded": editing, onClick: () => setEditing((value) => !value) }, + "button", + { type: "button", className: "bt-avatar-trigger", "aria-label": t("Edit Bot avatar"), "aria-expanded": editing, onClick: () => setEditing((value) => !value) }, +- (0, import_react18.createElement)(BotMark, { bot, size: 64, live: true }), +- (0, import_react18.createElement)("span", { className: "bt-avatar-pencil", "aria-hidden": true }, (0, import_react18.createElement)(PencilIcon)) + (0, import_react24.createElement)(BotMark, { bot, size: 64, live: true }), + (0, import_react24.createElement)("span", { className: "bt-avatar-pencil", "aria-hidden": true }, (0, import_react24.createElement)(PencilIcon)) -+ ), + ), +- editing ? (0, import_react18.createElement)(AvatarEditor, { bot, actions, run, onClose: closeEditor }) : null + editing ? (0, import_react24.createElement)(AvatarEditor, { bot, actions, run, onClose: closeEditor }) : null ), -- (0, import_react17.createElement)( +- (0, import_react18.createElement)( + (0, import_react24.createElement)( -+ "div", -+ { className: "bt-bs-who" }, + "div", + { className: "bt-bs-who" }, +- (0, import_react18.createElement)("span", { className: "bt-bs-name" }, bot.name), +- (0, import_react18.createElement)("span", { className: "bt-bs-sub" }, [bot.role, isMainBot ? t("Main Bot") : ""].filter(Boolean).join(" · ") || t("Bot")) + (0, import_react24.createElement)("span", { className: "bt-bs-name" }, bot.name), + (0, import_react24.createElement)("span", { className: "bt-bs-sub" }, [bot.role, isMainBot ? t("Main Bot") : ""].filter(Boolean).join(" · ") || t("Bot")) -+ ) -+ ), + ) + ), +- (0, import_react18.createElement)( + (0, import_react24.createElement)( -+ Card, -+ { title: t("Profile") }, + Card, + { title: t("Profile") }, +- (0, import_react18.createElement)(Row, { label: t("Name") }, (0, import_react18.createElement)(InlineText, { label: t("Name"), value: bot.name, placeholder: "Bob", required: true, maxLength: 40, onSave: (name) => save({ name }) })), +- (0, import_react18.createElement)(Row, { label: t("Label"), hint: t("Optional. Shown under the name.") }, (0, import_react18.createElement)(InlineText, { label: t("Label"), value: bot.role, placeholder: t("Research, admin…"), maxLength: 24, onSave: (role) => save({ role }) })) + (0, import_react24.createElement)(Row, { label: t("Name") }, (0, import_react24.createElement)(InlineText, { label: t("Name"), value: bot.name, placeholder: "Bob", required: true, maxLength: 40, onSave: (name) => save({ name }) })), + (0, import_react24.createElement)(Row, { label: t("Label"), hint: t("Optional. Shown under the name.") }, (0, import_react24.createElement)(InlineText, { label: t("Label"), value: bot.role, placeholder: t("Research, admin…"), maxLength: 24, onSave: (role) => save({ role }) })) -+ ), + ), +- (0, import_react18.createElement)( + (0, import_react24.createElement)( -+ Card, -+ { title: t("Model") }, + Card, + { title: t("Model") }, +- (0, import_react18.createElement)( + (0, import_react24.createElement)( -+ "div", -+ { className: "bt-set-row bt-bs-row" }, + "div", + { className: "bt-set-row bt-bs-row" }, +- (0, import_react18.createElement)("span", { className: "bt-set-copy" }, (0, import_react18.createElement)("span", { className: "bt-bs-label" }, t("Model")), (0, import_react18.createElement)("span", { className: "bt-set-hint" }, modelHint(bot, roster))), +- (0, import_react18.createElement)(ModelMenu, { bot, roster, actions, run }) +- ) + (0, import_react24.createElement)("span", { className: "bt-set-copy" }, (0, import_react24.createElement)("span", { className: "bt-set-hint" }, modelHint(bot, roster))), + (0, import_react24.createElement)(ModelMenu, { bot, roster, actions, run }) + ), + bot.model ? (0, import_react24.createElement)(ModelTestRow, { bot, modelName: modelOf(bot, roster)?.name }) : null -+ ), + ), +- (0, import_react18.createElement)( + (0, import_react24.createElement)( -+ Card, -+ { title: t("Working folder") }, + Card, + { title: t("Working folder") }, +- (0, import_react18.createElement)( + (0, import_react24.createElement)( -+ "div", -+ { className: "bt-set-row bt-bs-row" }, + "div", + { className: "bt-set-row bt-bs-row" }, +- (0, import_react18.createElement)("span", { className: "bt-set-copy" }, (0, import_react18.createElement)("span", { className: "bt-bs-label" }, t("Folder"))), +- (0, import_react18.createElement)(FolderMenu, { value: bot.workDir, actions, run, onPick: (workDir) => save({ workDir }) }) + (0, import_react24.createElement)("span", { className: "bt-set-copy" }, (0, import_react24.createElement)("span", { className: "bt-bs-label" }, t("Folder"))), + (0, import_react24.createElement)(FolderMenu, { value: bot.workDir, actions, run, onPick: (workDir) => save({ workDir }) }) -+ ) -+ ), + ) + ), +- (0, import_react18.createElement)( + (0, import_react24.createElement)( -+ Card, -+ { title: t("Instructions") }, + Card, + { title: t("Instructions") }, +- (0, import_react18.createElement)( + (0, import_react24.createElement)( -+ Row, -+ { label: t("What this Bot does"), hint: t("Its job on the team. Part of the Bot's system prompt."), stack: true }, + Row, + { label: t("What this Bot does"), hint: t("Its job on the team. Part of the Bot's system prompt."), stack: true }, +- (0, import_react18.createElement)(InlineText, { label: t("Instructions"), value: bot.instructions, multiline: true, placeholder: t("What this Bot is responsible for…"), onSave: (instructions) => save({ instructions }) }) + (0, import_react24.createElement)(InlineText, { label: t("Instructions"), value: bot.instructions, multiline: true, placeholder: t("What this Bot is responsible for…"), onSave: (instructions) => save({ instructions }) }) -+ ) -+ ), + ) + ), +- (0, import_react18.createElement)(BotCapabilitiesCard, { bot, roster, actions, run, Card, SwitchRow }), +- roster.memory === false ? null : (0, import_react18.createElement)(MemoryCard, { bot, actions }), +- (0, import_react18.createElement)(BotSchedules, { bot, roster, actions }), +- (0, import_react18.createElement)( + // Sharing sits near the top with a title like Profile and Model, not at the end of the page (#406). + (0, import_react24.createElement)(FlyBotDetails, { bot, roster, actions, wrap: (children) => (0, import_react24.createElement)(Card, { title: t("Sharing") }, children) }), + (0, import_react24.createElement)(BotCapabilitiesCard, { bot, roster, actions, run, Card, SwitchRow }), @@ -10843,165 +9602,79 @@ diff --git a/client.js b/client.js + (0, import_react24.createElement)(BotSchedules, { bot, roster, actions }), + (0, import_react24.createElement)(StandingApprovalsCard, { scope: "bot", target: bot, roster, actions, run, Card }), + (0, import_react24.createElement)( -+ Card, -+ { title: t("Main Bot") }, + Card, + { title: t("Main Bot") }, +- onlyMain ? (0, import_react18.createElement)( + onlyMain ? (0, import_react24.createElement)( "div", -- { className: "bt-mem-body" }, -- error ? (0, import_react17.createElement)("div", { className: "bt-error", role: "alert", style: { padding: 0 } }, hostText(error)) : null, -- view === null ? error ? null : (0, import_react17.createElement)(Skeleton) : page === "entries" ? (0, import_react17.createElement)(Entries, { bot, view, remove }) : (0, import_react17.createElement)(Summary, { bot, view, regenerate }) -+ { className: "bt-set-row bt-bs-row" }, + { className: "bt-set-row bt-bs-row" }, +- (0, import_react18.createElement)( + (0, import_react24.createElement)( -+ "span", -+ { className: "bt-set-copy" }, + "span", + { className: "bt-set-copy" }, +- (0, import_react18.createElement)("span", { className: "bt-bs-label" }, t("Main Bot")), +- (0, import_react18.createElement)("span", { className: "bt-set-hint" }, t("The only Main Bot. To hand the role to another Bot, use Main Bot on the Bots page.")) + (0, import_react24.createElement)("span", { className: "bt-set-hint" }, t("The only Main Bot. To hand the role to another Bot, use Main Bot on the Bots page.")) -+ ), + ), +- (0, import_react18.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => actions.openSettings("bots") }, t("Change")) +- ) : (0, import_react18.createElement)(SwitchRow, { label: t("Main Bot"), hint: t("Can create and change Bots, and runs every group chat."), on: isMainBot, onChange: (on) => run(actions.setMain(bot.id, on)) }) + (0, import_react24.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => actions.openSettings("bots") }, t("Change")) + ) : (0, import_react24.createElement)(SwitchRow, { label: t("Main Bot"), showLabel: false, hint: t("Can create and change Bots, and runs every group chat."), on: isMainBot, onChange: (on) => run(actions.setMain(bot.id, on)) }) -+ ), -+ // The Main Bot tile is always first and always shown, like in the context menu. + ), + // The Main Bot tile is always first and always shown, like in the context menu. +- isTile ? null : (0, import_react18.createElement)( + isTile ? null : (0, import_react24.createElement)( -+ Card, -+ { title: t("Sidebar") }, + Card, + { title: t("Sidebar") }, +- (0, import_react18.createElement)(SwitchRow, { label: t("Pin"), hint: t("Kept above the other chats."), on: bot.pinned === true, onChange: (pinned) => run(actions.setFlags(bot.id, { pinned })) }), +- isMainBot ? null : (0, import_react18.createElement)(SwitchRow, { label: t("Hide from sidebar"), hint: t("Search still finds it."), on: bot.hidden === true, onChange: (hidden) => run(actions.setFlags(bot.id, { hidden })) }) + (0, import_react24.createElement)(SwitchRow, { label: t("Pin"), hint: t("Kept above the other chats."), on: bot.pinned === true, onChange: (pinned) => run(actions.setFlags(bot.id, { pinned })) }), + isMainBot ? null : (0, import_react24.createElement)(SwitchRow, { label: t("Hide from sidebar"), hint: t("Search still finds it."), on: bot.hidden === true, onChange: (hidden) => run(actions.setFlags(bot.id, { hidden })) }) -+ ), + ), +- (0, import_react18.createElement)( + (0, import_react24.createElement)( -+ Card, + Card, +- { title: t("Manage") }, +- (0, import_react18.createElement)( + { title: t("More") }, + (0, import_react24.createElement)( -+ "button", -+ { type: "button", className: "bt-set-row bt-bs-row", onClick: duplicate }, + "button", + { type: "button", className: "bt-set-row bt-bs-row", onClick: duplicate }, +- (0, import_react18.createElement)("span", { className: "bt-set-copy" }, (0, import_react18.createElement)("span", { className: "bt-bs-label" }, t("Duplicate")), (0, import_react18.createElement)("span", { className: "bt-set-hint" }, t("A copy with the same job and model, and fresh memory."))), +- (0, import_react18.createElement)("span", { className: "bt-chevron" }, (0, import_react18.createElement)(ChevronRightIcon)) + (0, import_react24.createElement)("span", { className: "bt-set-copy" }, (0, import_react24.createElement)("span", { className: "bt-bs-label" }, t("Duplicate")), (0, import_react24.createElement)("span", { className: "bt-set-hint" }, t("A copy with the same job and model, and fresh memory."))), + (0, import_react24.createElement)("span", { className: "bt-chevron" }, (0, import_react24.createElement)(ChevronRightIcon)) ), -- (0, import_react17.createElement)( -- "div", -- { className: "bt-mem-foot" }, -- answer ? (0, import_react17.createElement)(Reply, { answer, dismiss: () => setAnswer(null) }) : null, -- (0, import_react17.createElement)( -- "form", -- { className: "bt-mem-ask", onSubmit: ask }, -- (0, import_react17.createElement)("input", { -- ref: inputRef, -- className: "bt-mem-input", -- value: text, -- maxLength: 2e3, -- disabled: view === null, -- placeholder: t("Ask or update"), -- "aria-label": t("Ask about or update {name}'s memory", { name: bot.name }), -- onChange: (event) => setText(event.target.value) -- }), -- (0, import_react17.createElement)( -- "button", -- { type: "submit", className: "bt-mem-send", disabled: busy || text.trim() === "", "aria-label": t("Send") }, -- busy ? (0, import_react17.createElement)("span", { className: "bt-mem-spin", "aria-hidden": true }) : (0, import_react17.createElement)(ArrowUpIcon) -- ) -- ) +- isMainBot ? null : (0, import_react18.createElement)( + isMainBot ? null : (0, import_react24.createElement)( -+ "button", -+ { type: "button", className: "bt-set-row bt-bs-row bt-bs-danger", "data-confirm": confirming || void 0, onClick: remove }, + "button", + { type: "button", className: "bt-set-row bt-bs-row bt-bs-danger", "data-confirm": confirming || void 0, onClick: remove }, +- (0, import_react18.createElement)( + (0, import_react24.createElement)( -+ "span", -+ { className: "bt-set-copy" }, + "span", + { className: "bt-set-copy" }, +- (0, import_react18.createElement)("span", { className: "bt-bs-label" }, confirming ? t("Delete {name}?", { name: bot.name }) : t("Delete Bot")), +- (0, import_react18.createElement)("span", { className: "bt-set-hint" }, confirming ? t("Click again to delete. Its chats are archived.") : t("Removes the Bot from the team.")) + (0, import_react24.createElement)("span", { className: "bt-bs-label" }, confirming ? t("Delete {name}?", { name: bot.name }) : t("Delete Bot")), + (0, import_react24.createElement)("span", { className: "bt-set-hint" }, confirming ? t("Click again to delete. Its chats are archived.") : t("Removes the Bot from the team.")) -+ ), + ), +- (0, import_react18.createElement)("span", { className: "bt-chevron" }, (0, import_react18.createElement)(ChevronRightIcon)) + (0, import_react24.createElement)("span", { className: "bt-chevron" }, (0, import_react24.createElement)(ChevronRightIcon)) ) ) ); - } -+var BOT_SETTINGS_CSS = ` -+.bt-bs{display:flex;flex-direction:column;gap:18px} -+.bt-bs-hero{display:flex;align-items:center;gap:14px;padding:2px 4px} -+.bt-bs-who{display:flex;flex-direction:column;min-width:0;gap:2px} -+.bt-bs-name{font-size:17px;line-height:24px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} -+.bt-bs-sub{font-size:13px;line-height:18px;color:var(--bt-ink-2)} -+.bt-bs .bt-avedit{left:0;transform:none} -+.bt-bs-row{min-height:48px} -+label.bt-bs-row{cursor:text} -+.bt-bs-row>.bt-set-copy{flex:1 1 auto} -+.bt-bs-label{font-size:13px;line-height:18px;font-weight:500} -+.bt-bs-row .bt-model-trigger{flex:none;max-width:58%} +@@ -8133,6 +8133,7 @@ + .bt-bs-row>.bt-set-copy{flex:1 1 auto} + .bt-bs-label{font-size:13px;line-height:18px;font-weight:500} + .bt-bs-row .bt-model-trigger{flex:none;max-width:58%} +${MODEL_TEST_CSS} -+.bt-bs-input{flex:0 1 220px;width:0;min-width:96px;height:30px;box-sizing:border-box;margin:-4px -6px -4px 0;padding:0 8px;border:1px solid transparent;border-radius:8px;background:none;color:var(--bt-ink);font:inherit;font-size:13px;text-align:right;outline:none;transition:background-color .12s ease,border-color .12s ease} -+.bt-bs-input:hover{background:var(--bt-hover)} -+.bt-bs-input:focus{background:var(--bt-main);border-color:var(--bt-line-2);text-align:left} -+.bt-bs-input::placeholder{color:var(--bt-ink-3)} -+.bt-bs-stack{flex-direction:column;align-items:stretch;gap:8px} -+.bt-bs-area{flex:none;width:auto;min-height:112px;height:auto;margin:0 -6px -4px;padding:8px;line-height:19px;text-align:left;resize:vertical;background:var(--bt-hover)} -+.bt-bs-area:hover{background:var(--bt-active)} -+.bt-bs-danger .bt-bs-label{color:#c21d2e} -+.bt-bs-danger[data-confirm]{background:color-mix(in srgb,#e02135 8%,transparent)} -+.bt-bs-danger[data-confirm]:hover{background:color-mix(in srgb,#e02135 12%,transparent)} -+.bt-switch{position:relative;flex:none;width:36px;height:20px;padding:0;border:0;border-radius:999px;background:var(--bt-line-2);cursor:pointer;transition:background-color .2s ease} -+.bt-switch[aria-checked=true]{background:var(--bt-accent)} -+.bt-switch:disabled{opacity:.45;cursor:default} -+.bt-switch:focus-visible{outline:2px solid var(--bt-accent);outline-offset:2px} -+.bt-switch-knob{position:absolute;top:2px;left:2px;width:16px;height:16px;border-radius:999px;background:#fff;box-shadow:0 1px 2px rgba(0,0,0,.2);transition:${spring(SPRING_SHAPE, "transform")},${spring(SPRING_TAP, "width")}} -+.bt-switch[aria-checked=true] .bt-switch-knob{transform:translateX(16px)} -+.bt-switch:active:not(:disabled) .bt-switch-knob{width:21px} -+.bt-switch[aria-checked=true]:active:not(:disabled) .bt-switch-knob{transform:translateX(11px)} -+.bt-avedit :is(.bt-shapes,.bt-colors,.bt-dropzone){animation:bt-veil-in .18s ease-out} -+@media (prefers-reduced-motion:reduce){.bt-switch,.bt-switch-knob{transition:none}.bt-avedit :is(.bt-shapes,.bt-colors,.bt-dropzone){animation:none}} -+`; + .bt-bs-input{flex:0 1 220px;width:0;min-width:96px;height:30px;box-sizing:border-box;margin:-4px -6px -4px 0;padding:0 8px;border:1px solid transparent;border-radius:8px;background:none;color:var(--bt-ink);font:inherit;font-size:13px;text-align:right;outline:none;transition:background-color .12s ease,border-color .12s ease} + .bt-bs-input:hover{background:var(--bt-hover)} + .bt-bs-input:focus{background:var(--bt-main);border-color:var(--bt-line-2);text-align:left} +@@ -8155,6 +8155,610 @@ + @media (prefers-reduced-motion:reduce){.bt-switch,.bt-switch-knob{transition:none}.bt-avedit :is(.bt-shapes,.bt-colors,.bt-dropzone){animation:none}} + `; --// src/client/bot-settings.js --var import_react18 = require("react"); --var capitalize = (word) => word[0].toUpperCase() + word.slice(1); --async function avatarImage(file) { -- if (!/^image\/(png|jpeg|webp|gif)$/.test(file.type)) throw new Error(t("Choose a PNG, JPEG, WebP, or GIF image")); -- if (file.size > 25 * 1024 * 1024) throw new Error(t("Choose an image smaller than 25 MB")); -- const bitmap = await createImageBitmap(file); -- const side = 160; -- const canvas = document.createElement("canvas"); -- canvas.width = side; -- canvas.height = side; -- const context = canvas.getContext("2d"); -- context.imageSmoothingQuality = "high"; -- const crop = Math.min(bitmap.width, bitmap.height); -- context.drawImage(bitmap, (bitmap.width - crop) / 2, (bitmap.height - crop) / 2, crop, crop, 0, 0, side, side); -- bitmap.close?.(); -- for (const quality of [0.9, 0.75, 0.6]) { -- const url = canvas.toDataURL("image/webp", quality); -- if (url.startsWith("data:image/webp") && url.length < 15e4) return url; -- } -- const png = canvas.toDataURL("image/png"); -- if (png.length < 15e4) return png; -- throw new Error(t("That image is too large")); --} --function InlineText({ label, value, placeholder, required = false, maxLength, multiline = false, onSave }) { -- const [draft, setDraft] = (0, import_react18.useState)(null); -- const reverting = (0, import_react18.useRef)(false); -- const commit = () => { -- const next = draft?.trim(); -- setDraft(null); -- if (reverting.current) { -- reverting.current = false; -- return; -- } -- if (next === void 0 || next === (value ?? "") || required && next === "") return; -- void onSave(next); -- }; -- return (0, import_react18.createElement)(multiline ? "textarea" : "input", { -- className: multiline ? "bt-bs-input bt-bs-area" : "bt-bs-input", -- value: draft ?? value ?? "", -- placeholder, -- maxLength, -- required, -- "aria-label": label, -- spellCheck: false, -- onChange: (event) => setDraft(event.target.value), -- onBlur: commit, -- onKeyDown: (event) => { -- if (event.key === "Escape") { -- event.stopPropagation(); -- reverting.current = true; -- event.currentTarget.blur(); -- } -- if (event.key === "Enter" && !multiline && !event.nativeEvent.isComposing) event.currentTarget.blur(); -- } +// src/client/group-settings.js +var import_react25 = require("react"); +var ROOM_MODES = [ @@ -11023,24 +9696,7 @@ diff --git a/client.js b/client.js + const name = seat.local && onOpenBot ? (0, import_react25.createElement)("button", { key: seat.key, type: "button", className: "bt-face-bot", title: t("Open {name}'s chat", { name: seat.bot }), onClick: () => onOpenBot(seat.local.id) }, seat.bot) : (0, import_react25.createElement)("span", { key: seat.key, className: "bt-face-bot", title: seat.record?.handle || void 0 }, seat.bot); + const marked = isLeadSeat(seat, lead) ? [name, (0, import_react25.createElement)("span", { key: `${seat.key}-lead`, className: "bt-tag", "data-flybot": "face-lead-tag" }, t("Lead"))] : [name]; + return index === 0 ? marked : [(0, import_react25.createElement)("span", { key: `${seat.key}-sep` }, t(", ")), ...marked]; - }); --} --function Row({ label, hint, stack = false, children }) { -- return (0, import_react18.createElement)( -- "label", -- { className: stack ? "bt-set-row bt-bs-row bt-bs-stack" : "bt-set-row bt-bs-row" }, -- (0, import_react18.createElement)("span", { className: "bt-set-copy" }, (0, import_react18.createElement)("span", { className: "bt-bs-label" }, label), hint ? (0, import_react18.createElement)("span", { className: "bt-set-hint" }, hint) : null), -- children -- ); --} --function Card({ title, children }) { -- return (0, import_react18.createElement)("section", { className: "bt-set-section" }, title ? (0, import_react18.createElement)("h3", null, title) : null, (0, import_react18.createElement)("div", { className: "bt-set-card" }, children)); --} --function Switch({ on, label, disabled, onChange }) { -- return (0, import_react18.createElement)( -- "button", -- { type: "button", role: "switch", className: "bt-switch", "aria-checked": on, "aria-label": label, disabled, onClick: () => onChange(!on) }, -- (0, import_react18.createElement)("span", { className: "bt-switch-knob", "aria-hidden": true }) ++ }); + return (0, import_react25.createElement)( + "div", + { className, role: "listitem", "data-flybot": "face-person", "data-mine": person.mine || void 0 }, @@ -11055,10 +9711,8 @@ diff --git a/client.js b/client.js + (0, import_react25.createElement)("span", { className: "bt-face-bots" }, bots) + ) + ) - ); - } --function SwitchRow({ label, hint, on, disabled, onChange }) { -- return (0, import_react18.createElement)( ++ ); ++} +function FaceLead({ room: room2, roster, pick: pick2 }) { + const lead = room2.face?.lead; + const [open, setOpen] = (0, import_react25.useState)(false); @@ -11081,10 +9735,7 @@ diff --git a/client.js b/client.js + } + }; + return (0, import_react25.createElement)( - "div", -- { className: "bt-set-row bt-bs-row" }, -- (0, import_react18.createElement)("span", { className: "bt-set-copy" }, (0, import_react18.createElement)("span", { className: "bt-bs-label" }, label), hint ? (0, import_react18.createElement)("span", { className: "bt-set-hint" }, hint) : null), -- (0, import_react18.createElement)(Switch, { on, label, disabled, onChange }) ++ "div", + { className: "bt-face-lead", "data-flybot": "face-lead" }, + (0, import_react25.createElement)("div", { className: "bt-section-title" }, t("Room lead")), + (0, import_react25.createElement)( @@ -11107,59 +9758,20 @@ diff --git a/client.js b/client.js + open && canPick && lead.picked ? (0, import_react25.createElement)("button", { type: "button", className: "bt-more", disabled: busy, onClick: () => void choose(null) }, t("Use the default lead")) : null, + canPick || !lead.supported ? null : (0, import_react25.createElement)("div", { className: "bt-set-hint" }, t("Only the person who created this room can change it.")), + error ? (0, import_react25.createElement)("div", { className: "bt-set-hint", role: "alert" }, error) : null - ); - } --function modelHint(bot, roster) { -- const shown = modelOf(bot, roster); -- if (shown === void 0) return t("Pick the model this Bot runs on."); -- if (!shown.served && (roster.models ?? []).length > 0) return t("This model is not available now. Pick another one."); -- if (bot.appliedModel && bot.appliedModel === bot.model) return t("Every message of this Bot runs on it."); -- return t("Applies from the next message."); ++ ); ++} +function seatedCount(room2) { + const live = room2?.presentedParticipants ?? room2?.participants ?? []; + const humans = live.filter((p) => p.kind === "human" && p.leftSeq === void 0).length; + const remotes = live.filter((p) => p.remote && p.leftSeq === void 0).length; + return humans + remotes + (room2?.members?.length ?? 0); - } --function ShapeRing({ shape, size }) { -- const maskId = (0, import_react18.useRef)(""); -- if (maskId.current === "") maskId.current = `bt-ring-${nextMarkSerial()}`; -- const def = shapeOf(shape); -- const unit = def.size / size; -- const markup = `${holes(def, `${bodyOf(def, "#000", true)}`)}${bodyOf(def, "currentColor", true)}`; -- return (0, import_react18.createElement)("svg", { className: "bt-ring", viewBox: `0 0 ${def.size} ${def.size}`, "aria-hidden": true, dangerouslySetInnerHTML: { __html: markup } }); ++} +function roomSubtitle(room2, roster) { + if (room2.origin === "face-to-face") return faceRoomCount(room2, roster); + const admin = roster.byId[room2.admin]; + const count = seatedCount(room2); + return [t(count === 1 ? "{count} member" : "{count} members", { count }), admin ? t("Admin {name}", { name: admin.name }) : t("No admin")].join(" · "); - } --function CharacterGrid({ look, hasPhoto = false, onShape, onColor }) { -- const shapes = [...PICKER_SHAPES, ...Object.keys(SHAPES).filter((id) => !Object.hasOwn(CHARACTERS, id))]; -- if (!shapes.includes(look.shape)) shapes.push(look.shape); -- const rows = []; -- for (let index = 0; index < shapes.length; index += PICKER_COLUMNS) rows.push(shapes.slice(index, index + PICKER_COLUMNS)); -- return (0, import_react18.createElement)( -- import_react18.Fragment, -- null, -- (0, import_react18.createElement)("div", { className: "bt-shapes", role: "group", "aria-label": t("Character shape") }, rows.map((row, index) => (0, import_react18.createElement)("div", { key: index, className: "bt-shape-row" }, row.map((id) => (0, import_react18.createElement)("button", { -- key: id, -- type: "button", -- className: "bt-shape", -- "aria-label": t(shapeOf(id).label), -- title: t(shapeOf(id).label), -- "aria-pressed": !hasPhoto && look.shape === id, -- onClick: () => onShape(id) -- }, (0, import_react18.createElement)(ShapeRing, { shape: id, size: 36 }), (0, import_react18.createElement)(BotMark, { look: { shape: id, color: look.color }, size: 36 })))))), -- (0, import_react18.createElement)("div", { className: "bt-colors", role: "group", "aria-label": t("Character color") }, CHARACTER_COLORS.map((color) => (0, import_react18.createElement)("button", { -- key: color, -- type: "button", -- className: "bt-color", -- "aria-label": t(capitalize(color)), -- title: t(capitalize(color)), -- "aria-pressed": look.color === color, -- onClick: () => onColor(color) -- }, (0, import_react18.createElement)("span", { style: { background: paintCss(color) } })))) ++} +function GroupSettingsForm({ room: room2, roster, actions, run, onDeleted }) { + const [adding, setAdding] = (0, import_react25.useState)(false); + const [confirming, setConfirming] = (0, import_react25.useState)(false); @@ -11374,16 +9986,8 @@ diff --git a/client.js b/client.js + (0, import_react25.createElement)("span", { className: "bt-chevron" }, (0, import_react25.createElement)(ChevronRightIcon)) + ) + ) - ); - } --function AvatarEditor({ bot, actions, run, onClose }) { -- const look = lookOf(bot); -- const [tab, setTab] = (0, import_react18.useState)(look.image ? "upload" : "bot"); -- const [over, setOver] = (0, import_react18.useState)(false); -- const ref = (0, import_react18.useRef)(null); -- const fileRef = (0, import_react18.useRef)(null); -- const upload = (file) => { -- if (file) void run(avatarImage(file).then((image) => actions.updateBot(bot.id, { avatar: { ...bot.avatar ?? {}, image } }))); ++ ); ++} +function NewGroupForm({ roster, actions, run, onCreated, onCancel }) { + const [admin, setAdmin] = (0, import_react25.useState)(null); + const [members, setMembers] = (0, import_react25.useState)([]); @@ -11393,40 +9997,7 @@ diff --git a/client.js b/client.js + const pickAdmin = (id) => { + setAdmin(id); + setMembers((list) => list.filter((item) => item !== id)); - }; -- (0, import_react18.useEffect)(() => { -- const onDown = (event) => { -- if (!(event.target instanceof Element) || ref.current?.contains(event.target) || event.target.closest(".bt-avatar-trigger")) return; -- onClose(); -- }; -- const onKey = (event) => { -- if (event.key === "Escape") { -- event.stopPropagation(); -- onClose(); -- } -- }; -- window.addEventListener("mousedown", onDown); -- window.addEventListener("keydown", onKey, true); -- return () => { -- window.removeEventListener("mousedown", onDown); -- window.removeEventListener("keydown", onKey, true); -- }; -- }, [onClose]); -- (0, import_react18.useEffect)(() => { -- if (tab !== "upload") return void 0; -- const onPaste = (event) => { -- const file = [...event.clipboardData?.files ?? []].find((item) => item.type.startsWith("image/")); -- if (file) { -- event.preventDefault(); -- upload(file); -- } -- }; -- window.addEventListener("paste", onPaste); -- return () => window.removeEventListener("paste", onPaste); -- }, [tab, bot]); -- const hasPhoto = Boolean(look.image); -- const glide = useGlide(tab); -- return (0, import_react18.createElement)( ++ }; + const create = async () => { + if (busy || admin === null || members.length === 0) return; + setBusy(true); @@ -11446,85 +10017,24 @@ diff --git a/client.js b/client.js + onClick + }, (0, import_react25.createElement)(BotAvatar, { bot, size: 22, badge: false, live: false }), (0, import_react25.createElement)("span", { className: "bt-ng-name" }, bot.name), on && role === "radio" ? (0, import_react25.createElement)(AdminStar) : null); + return (0, import_react25.createElement)( - "div", -- { ref, className: "bt-avedit", role: "dialog", "aria-label": t("Avatar editor") }, -- (0, import_react18.createElement)( ++ "div", + { className: "bt-set-card bt-ng" }, + (0, import_react25.createElement)( - "div", -- { className: "bt-avedit-top" }, -- (0, import_react18.createElement)( ++ "div", + { className: "bt-set-row bt-bs-row bt-bs-stack" }, + (0, import_react25.createElement)("span", { className: "bt-set-copy" }, (0, import_react25.createElement)("span", { className: "bt-bs-label bt-ng-label" }, (0, import_react25.createElement)(AdminStar), t("Choose the group admin"))), + (0, import_react25.createElement)( - "div", -- { ref: glide.box, role: "tablist", "aria-label": t("Avatar source"), className: "bt-avedit-tabs" }, -- (0, import_react18.createElement)("span", { ref: glide.thumb, className: "bt-thumb", "aria-hidden": true }), -- [["bot", "Bot"], ["upload", "Upload"]].map(([id, label]) => (0, import_react18.createElement)("button", { key: id, type: "button", role: "tab", className: "bt-avedit-tab", "aria-selected": tab === id, onClick: () => setTab(id) }, t(label))) -- ), -- (0, import_react18.createElement)("button", { -- type: "button", -- className: "bt-avedit-tab bt-avedit-reset", -- title: t("Reset character to default"), -- onClick: () => run(actions.updateBot(bot.id, { avatar: null, color: null })) -- }, t("Reset")) ++ "div", + { className: "bt-ng-picks", role: "radiogroup", "aria-label": t("Group admin") }, + roster.bots.map((bot) => chip(bot, admin === bot.id, () => pickAdmin(bot.id), "radio")) + ) - ), -- tab === "bot" ? (0, import_react18.createElement)(CharacterGrid, { -- look, -- hasPhoto, -- onShape: (shape) => run(actions.updateBot(bot.id, { avatar: { shape } })), -- onColor: (color) => run(actions.updateBot(bot.id, { color })) -- }) : null, -- tab === "upload" ? (0, import_react18.createElement)( ++ ), + admin === null ? null : (0, import_react25.createElement)( - "div", -- { -- className: "bt-dropzone", -- "data-over": over || void 0, -- onDragOver: (event) => { -- event.preventDefault(); -- setOver(true); -- }, -- onDragLeave: () => setOver(false), -- onDrop: (event) => { -- event.preventDefault(); -- setOver(false); -- upload(event.dataTransfer.files?.[0]); -- } -- }, -- hasPhoto ? (0, import_react18.createElement)("img", { src: look.image, alt: "" }) : null, -- (0, import_react18.createElement)("div", null, t("Drag, drop, or paste an image")), -- (0, import_react18.createElement)( ++ "div", + { className: "bt-set-row bt-bs-row bt-bs-stack bt-ng-step" }, + (0, import_react25.createElement)("span", { className: "bt-set-copy" }, (0, import_react25.createElement)("span", { className: "bt-bs-label" }, t("Who to chat with…"))), + (0, import_react25.createElement)( - "div", -- { className: "bt-actions", style: { justifyContent: "center" } }, -- (0, import_react18.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => fileRef.current?.click() }, hasPhoto ? t("Replace") : t("Browse files")), -- hasPhoto ? (0, import_react18.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => run(actions.updateBot(bot.id, { avatar: hasShape(bot.avatar?.shape) ? { shape: bot.avatar.shape } : null })) }, t("Remove photo")) : null -- ), -- (0, import_react18.createElement)("input", { ref: fileRef, type: "file", accept: "image/png,image/jpeg,image/webp,image/gif", hidden: true, onChange: (event) => { -- const file = event.target.files?.[0]; -- event.target.value = ""; -- upload(file); -- } }) -- ) : null -- ); --} --function MemoryCard({ bot, actions }) { -- const count = useMemoryCount(bot.id, actions); -- const label = count === null ? t("Loading…") : count === 0 ? t("Nothing saved yet") : count === 1 ? t("1 entry") : t("{count} entries", { count }); -- return (0, import_react18.createElement)( -- Card, -- { title: t("Memory") }, -- (0, import_react18.createElement)( -- "button", -- { type: "button", className: "bt-set-row bt-bs-row", "aria-label": t("Manage {name}'s memory", { name: bot.name }), onClick: () => actions.openMemory(bot.id) }, -- (0, import_react18.createElement)("span", { className: "bt-set-copy" }, (0, import_react18.createElement)("span", { className: "bt-bs-label" }, label)), -- (0, import_react18.createElement)("span", { className: "bt-mem-row-go" }, t("Manage"), (0, import_react18.createElement)(ChevronRightIcon)) ++ "div", + { className: "bt-ng-picks", role: "group", "aria-label": t("Members") }, + roster.bots.filter((bot) => bot.id !== admin).map((bot) => chip(bot, members.includes(bot.id), () => toggle(bot.id), "checkbox")) + ) @@ -11540,26 +10050,9 @@ diff --git a/client.js b/client.js + { className: "bt-set-row bt-task-foot" }, + (0, import_react25.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: onCancel }, t("Cancel")), + (0, import_react25.createElement)("button", { type: "button", className: "bt-send bt-soft-sm", disabled: busy || admin === null || members.length === 0, onClick: () => void create() }, t("Create group chat")) - ) - ); - } --function BotSettingsForm({ bot, roster, actions, run }) { -- const [editing, setEditing] = (0, import_react18.useState)(false); -- const [confirming, setConfirming] = (0, import_react18.useState)(false); -- const closeEditor = (0, import_react18.useCallback)(() => setEditing(false), []); -- (0, import_react18.useEffect)(() => { -- if (!confirming) return void 0; -- const timer = setTimeout(() => setConfirming(false), 4e3); -- return () => clearTimeout(timer); -- }, [confirming]); -- const isMainBot = isMainOf(roster, bot.id); -- const onlyMain = isMainBot && (roster.mainBotIds ?? []).length === 1; -- const isTile = roster.mainBotId === bot.id; -- const save = (patch) => run(actions.updateBot(bot.id, patch)); -- const duplicate = () => run(actions.duplicateBot(bot.id).then((copy2) => actions.openSession(copy2.id))); -- const remove = () => { -- if (confirming) void run(actions.deleteBot(bot.id)); -- else setConfirming(true); ++ ) ++ ); ++} +var GROUP_SETTINGS_CSS = ` +.bt-ng{animation:bt-q-in .4s cubic-bezier(.22,1,.36,1),bt-veil-in .2s ease-out} +.bt-ng-step{animation:bt-veil-in .22s ease-out} @@ -11695,153 +10188,52 @@ diff --git a/client.js b/client.js + if (!row || leaving) return; + void Promise.resolve(row.run()).catch((error) => actions.reportError(error, "menu")); + close(); - }; -- (0, import_react18.useEffect)(() => { -- void actions.refreshModels?.(); -- }, [bot.id]); -- return (0, import_react18.createElement)( ++ }; + let index = -1; + return (0, import_react26.createElement)( - "div", -- { className: "bt-bs" }, -- (0, import_react18.createElement)( ++ "div", + { className: "bt-palette-layer", "data-leaving": leaving || void 0 }, + (0, import_react26.createElement)("div", { className: "bt-backdrop", onMouseDown: close }), + (0, import_react26.createElement)( - "div", -- { className: "bt-bs-hero" }, -- (0, import_react18.createElement)( ++ "div", + { className: "bt-palette", role: "dialog", "aria-label": t("Search"), "aria-modal": true }, + (0, import_react26.createElement)( - "div", -- { className: "bt-avatar-anchor" }, -- (0, import_react18.createElement)( -- "button", -- { type: "button", className: "bt-avatar-trigger", "aria-label": t("Edit Bot avatar"), "aria-expanded": editing, onClick: () => setEditing((value) => !value) }, -- (0, import_react18.createElement)(BotMark, { bot, size: 64, live: true }), -- (0, import_react18.createElement)("span", { className: "bt-avatar-pencil", "aria-hidden": true }, (0, import_react18.createElement)(PencilIcon)) -- ), -- editing ? (0, import_react18.createElement)(AvatarEditor, { bot, actions, run, onClose: closeEditor }) : null ++ "div", + { className: "bt-palette-head" }, + (0, import_react26.createElement)("span", { className: "bt-palette-glyph", "aria-hidden": true }, (0, import_react26.createElement)(SearchIcon)), -+ (0, import_react26.createElement)("input", { -+ autoFocus: true, -+ value: query, -+ placeholder: t("Search Bots, chats and messages"), -+ "aria-label": t("Search"), -+ role: "combobox", -+ "aria-expanded": true, -+ "aria-controls": "bt-palette-list", -+ onChange: (event) => { -+ setQuery(event.target.value); -+ setCursor(0); -+ }, -+ onKeyDown: (event) => { -+ if (event.nativeEvent.isComposing) return; -+ if (event.key === "ArrowDown") { -+ event.preventDefault(); -+ setCursor(Math.min(active + 1, rows.length - 1)); -+ } else if (event.key === "ArrowUp") { -+ event.preventDefault(); -+ setCursor(Math.max(active - 1, 0)); -+ } else if (event.key === "Enter") { -+ event.preventDefault(); -+ choose(rows[active]); -+ } -+ } -+ }), -+ query ? (0, import_react26.createElement)("button", { type: "button", className: "bt-palette-clear", "aria-label": t("Clear search"), onClick: () => { -+ setQuery(""); -+ setCursor(0); -+ } }, (0, import_react26.createElement)(CloseIcon)) : null - ), -- (0, import_react18.createElement)( -- "div", -- { className: "bt-bs-who" }, -- (0, import_react18.createElement)("span", { className: "bt-bs-name" }, bot.name), -- (0, import_react18.createElement)("span", { className: "bt-bs-sub" }, [bot.role, isMainBot ? t("Main Bot") : ""].filter(Boolean).join(" · ") || t("Bot")) -- ) -- ), -- (0, import_react18.createElement)( -- Card, -- { title: t("Profile") }, -- (0, import_react18.createElement)(Row, { label: t("Name") }, (0, import_react18.createElement)(InlineText, { label: t("Name"), value: bot.name, placeholder: "Bob", required: true, maxLength: 40, onSave: (name) => save({ name }) })), -- (0, import_react18.createElement)(Row, { label: t("Label"), hint: t("Optional. Shown under the name.") }, (0, import_react18.createElement)(InlineText, { label: t("Label"), value: bot.role, placeholder: t("Research, admin…"), maxLength: 24, onSave: (role) => save({ role }) })) -- ), -- (0, import_react18.createElement)( -- Card, -- { title: t("Model") }, -- (0, import_react18.createElement)( -- "div", -- { className: "bt-set-row bt-bs-row" }, -- (0, import_react18.createElement)("span", { className: "bt-set-copy" }, (0, import_react18.createElement)("span", { className: "bt-bs-label" }, t("Model")), (0, import_react18.createElement)("span", { className: "bt-set-hint" }, modelHint(bot, roster))), -- (0, import_react18.createElement)(ModelMenu, { bot, roster, actions, run }) -- ) -- ), -- (0, import_react18.createElement)( -- Card, -- { title: t("Working folder") }, -- (0, import_react18.createElement)( -- "div", -- { className: "bt-set-row bt-bs-row" }, -- (0, import_react18.createElement)("span", { className: "bt-set-copy" }, (0, import_react18.createElement)("span", { className: "bt-bs-label" }, t("Folder"))), -- (0, import_react18.createElement)(FolderMenu, { value: bot.workDir, actions, run, onPick: (workDir) => save({ workDir }) }) -- ) -- ), -- (0, import_react18.createElement)( -- Card, -- { title: t("Instructions") }, -- (0, import_react18.createElement)( -- Row, -- { label: t("What this Bot does"), hint: t("Its job on the team. Part of the Bot's system prompt."), stack: true }, -- (0, import_react18.createElement)(InlineText, { label: t("Instructions"), value: bot.instructions, multiline: true, placeholder: t("What this Bot is responsible for…"), onSave: (instructions) => save({ instructions }) }) -- ) -- ), -- (0, import_react18.createElement)(BotCapabilitiesCard, { bot, roster, actions, run, Card, SwitchRow }), -- roster.memory === false ? null : (0, import_react18.createElement)(MemoryCard, { bot, actions }), -- (0, import_react18.createElement)(BotSchedules, { bot, roster, actions }), -- (0, import_react18.createElement)( -- Card, -- { title: t("Main Bot") }, -- onlyMain ? (0, import_react18.createElement)( ++ (0, import_react26.createElement)("input", { ++ autoFocus: true, ++ value: query, ++ placeholder: t("Search Bots, chats and messages"), ++ "aria-label": t("Search"), ++ role: "combobox", ++ "aria-expanded": true, ++ "aria-controls": "bt-palette-list", ++ onChange: (event) => { ++ setQuery(event.target.value); ++ setCursor(0); ++ }, ++ onKeyDown: (event) => { ++ if (event.nativeEvent.isComposing) return; ++ if (event.key === "ArrowDown") { ++ event.preventDefault(); ++ setCursor(Math.min(active + 1, rows.length - 1)); ++ } else if (event.key === "ArrowUp") { ++ event.preventDefault(); ++ setCursor(Math.max(active - 1, 0)); ++ } else if (event.key === "Enter") { ++ event.preventDefault(); ++ choose(rows[active]); ++ } ++ } ++ }), ++ query ? (0, import_react26.createElement)("button", { type: "button", className: "bt-palette-clear", "aria-label": t("Clear search"), onClick: () => { ++ setQuery(""); ++ setCursor(0); ++ } }, (0, import_react26.createElement)(CloseIcon)) : null ++ ), + (0, import_react26.createElement)( - "div", -- { className: "bt-set-row bt-bs-row" }, -- (0, import_react18.createElement)( -- "span", -- { className: "bt-set-copy" }, -- (0, import_react18.createElement)("span", { className: "bt-bs-label" }, t("Main Bot")), -- (0, import_react18.createElement)("span", { className: "bt-set-hint" }, t("The only Main Bot. To hand the role to another Bot, use Main Bot on the Bots page.")) -- ), -- (0, import_react18.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => actions.openSettings("bots") }, t("Change")) -- ) : (0, import_react18.createElement)(SwitchRow, { label: t("Main Bot"), hint: t("Can create and change Bots, and runs every group chat."), on: isMainBot, onChange: (on) => run(actions.setMain(bot.id, on)) }) -- ), -- // The Main Bot tile is always first and always shown, like in the context menu. -- isTile ? null : (0, import_react18.createElement)( -- Card, -- { title: t("Sidebar") }, -- (0, import_react18.createElement)(SwitchRow, { label: t("Pin"), hint: t("Kept above the other chats."), on: bot.pinned === true, onChange: (pinned) => run(actions.setFlags(bot.id, { pinned })) }), -- isMainBot ? null : (0, import_react18.createElement)(SwitchRow, { label: t("Hide from sidebar"), hint: t("Search still finds it."), on: bot.hidden === true, onChange: (hidden) => run(actions.setFlags(bot.id, { hidden })) }) -- ), -- (0, import_react18.createElement)( -- Card, -- { title: t("Manage") }, -- (0, import_react18.createElement)( -- "button", -- { type: "button", className: "bt-set-row bt-bs-row", onClick: duplicate }, -- (0, import_react18.createElement)("span", { className: "bt-set-copy" }, (0, import_react18.createElement)("span", { className: "bt-bs-label" }, t("Duplicate")), (0, import_react18.createElement)("span", { className: "bt-set-hint" }, t("A copy with the same job and model, and fresh memory."))), -- (0, import_react18.createElement)("span", { className: "bt-chevron" }, (0, import_react18.createElement)(ChevronRightIcon)) -- ), -- isMainBot ? null : (0, import_react18.createElement)( -- "button", -- { type: "button", className: "bt-set-row bt-bs-row bt-bs-danger", "data-confirm": confirming || void 0, onClick: remove }, -- (0, import_react18.createElement)( -- "span", -- { className: "bt-set-copy" }, -- (0, import_react18.createElement)("span", { className: "bt-bs-label" }, confirming ? t("Delete {name}?", { name: bot.name }) : t("Delete Bot")), -- (0, import_react18.createElement)("span", { className: "bt-set-hint" }, confirming ? t("Click again to delete. Its chats are archived.") : t("Removes the Bot from the team.")) -- ), -- (0, import_react18.createElement)("span", { className: "bt-chevron" }, (0, import_react18.createElement)(ChevronRightIcon)) ++ "div", + { ref: listRef, id: "bt-palette-list", className: "bt-palette-list", role: "listbox", "aria-label": t("Results") }, + (0, import_react26.createElement)("span", { ref: glide.thumb, className: "bt-thumb", "aria-hidden": true }), + rows.length === 0 ? (0, import_react26.createElement)( @@ -11882,47 +10274,15 @@ diff --git a/client.js b/client.js + ); + }) + )) - ) - ) - ); - } --var BOT_SETTINGS_CSS = ` --.bt-bs{display:flex;flex-direction:column;gap:18px} --.bt-bs-hero{display:flex;align-items:center;gap:14px;padding:2px 4px} --.bt-bs-who{display:flex;flex-direction:column;min-width:0;gap:2px} --.bt-bs-name{font-size:17px;line-height:24px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} --.bt-bs-sub{font-size:13px;line-height:18px;color:var(--bt-ink-2)} --.bt-bs .bt-avedit{left:0;transform:none} --.bt-bs-row{min-height:48px} --label.bt-bs-row{cursor:text} --.bt-bs-row>.bt-set-copy{flex:1 1 auto} --.bt-bs-label{font-size:13px;line-height:18px;font-weight:500} --.bt-bs-row .bt-model-trigger{flex:none;max-width:58%} --.bt-bs-input{flex:0 1 220px;width:0;min-width:96px;height:30px;box-sizing:border-box;margin:-4px -6px -4px 0;padding:0 8px;border:1px solid transparent;border-radius:8px;background:none;color:var(--bt-ink);font:inherit;font-size:13px;text-align:right;outline:none;transition:background-color .12s ease,border-color .12s ease} --.bt-bs-input:hover{background:var(--bt-hover)} --.bt-bs-input:focus{background:var(--bt-main);border-color:var(--bt-line-2);text-align:left} --.bt-bs-input::placeholder{color:var(--bt-ink-3)} --.bt-bs-stack{flex-direction:column;align-items:stretch;gap:8px} --.bt-bs-area{flex:none;width:auto;min-height:112px;height:auto;margin:0 -6px -4px;padding:8px;line-height:19px;text-align:left;resize:vertical;background:var(--bt-hover)} --.bt-bs-area:hover{background:var(--bt-active)} --.bt-bs-danger .bt-bs-label{color:#c21d2e} --.bt-bs-danger[data-confirm]{background:color-mix(in srgb,#e02135 8%,transparent)} --.bt-bs-danger[data-confirm]:hover{background:color-mix(in srgb,#e02135 12%,transparent)} --.bt-switch{position:relative;flex:none;width:36px;height:20px;padding:0;border:0;border-radius:999px;background:var(--bt-line-2);cursor:pointer;transition:background-color .2s ease} --.bt-switch[aria-checked=true]{background:var(--bt-accent)} --.bt-switch:disabled{opacity:.45;cursor:default} --.bt-switch:focus-visible{outline:2px solid var(--bt-accent);outline-offset:2px} --.bt-switch-knob{position:absolute;top:2px;left:2px;width:16px;height:16px;border-radius:999px;background:#fff;box-shadow:0 1px 2px rgba(0,0,0,.2);transition:${spring(SPRING_SHAPE, "transform")},${spring(SPRING_TAP, "width")}} --.bt-switch[aria-checked=true] .bt-switch-knob{transform:translateX(16px)} --.bt-switch:active:not(:disabled) .bt-switch-knob{width:21px} --.bt-switch[aria-checked=true]:active:not(:disabled) .bt-switch-knob{transform:translateX(11px)} --.bt-avedit :is(.bt-shapes,.bt-colors,.bt-dropzone){animation:bt-veil-in .18s ease-out} --@media (prefers-reduced-motion:reduce){.bt-switch,.bt-switch-knob{transition:none}.bt-avedit :is(.bt-shapes,.bt-colors,.bt-dropzone){animation:none}} --`; - ++ ) ++ ) ++ ); ++} ++ // src/client/bot-draft.js function freshBotName(roster, base = "New Bot") { -@@ -8165,36 +8165,48 @@ function freshBotName(roster, base = "New Bot") { + const taken = new Set(roster.bots.map((bot) => bot.name.toLowerCase())); +@@ -8165,36 +8165,48 @@ } function startModel(roster) { const models = roster.models ?? []; @@ -11986,7 +10346,7 @@ diff --git a/client.js b/client.js if (inline) frame.current?.scrollIntoView({ block: "start", behavior: "smooth" }); }, []); const fallbackName = freshBotName(roster, t("New Bot")); -@@ -8206,8 +8206,8 @@ function CreateBot({ roster, actions, start, brief = "", onBack, canGoBack, onCr +@@ -8206,8 +8206,8 @@ setError(""); setBusy(true); try { @@ -11997,7 +10357,7 @@ diff --git a/client.js b/client.js } catch (failure) { setError(failure?.message ?? String(failure)); setBusy(false); -@@ -8219,25 +8219,25 @@ function CreateBot({ roster, actions, start, brief = "", onBack, canGoBack, onCr +@@ -8219,25 +8219,25 @@ avatarImage(file).then(setImage, (failure) => setError(failure?.message ?? String(failure))); }; const previewLook = image ? { ...look, image } : look; @@ -12031,7 +10391,7 @@ diff --git a/client.js b/client.js look, hasPhoto: Boolean(image), onShape: (shape) => { -@@ -8249,27 +8249,27 @@ function CreateBot({ roster, actions, start, brief = "", onBack, canGoBack, onCr +@@ -8249,27 +8249,27 @@ setLook((current) => ({ ...current, color })); } }), @@ -12067,7 +10427,7 @@ diff --git a/client.js b/client.js className: "bt-input", autoFocus: !inline, value: name, -@@ -8284,75 +8284,113 @@ function CreateBot({ roster, actions, start, brief = "", onBack, canGoBack, onCr +@@ -8284,73 +8284,111 @@ } } }), @@ -12178,8 +10538,8 @@ diff --git a/client.js b/client.js + setFix(ungrantedLocalFix(failure?.details) ?? null); } finally { setBusy(false); - } - }; ++ } ++ }; + const leaveOut = (id) => { + if (id === admin) setAdmin(null); + setPicked((current) => current.filter((other) => other !== id)); @@ -12206,12 +10566,10 @@ diff --git a/client.js b/client.js + } else { + setInviteLink(link); + setFixNote(t("Could not copy the invite link for {name}. Copy the link below and send it to your friend; once they accept, create the group again.", { name: bot.name })); -+ } -+ }; + } + }; const pick2 = (bot) => { - setPicked((current) => [...current, bot.id]); - setQuery(""); -@@ -8386,13 +8386,17 @@ function NewChat({ mode, roster, actions }) { +@@ -8386,13 +8386,17 @@ }; const created = (bot) => { close(); @@ -12232,7 +10590,7 @@ diff --git a/client.js b/client.js if (text) await actions.send(room2.id, text); actions.openSession(room2.id); close(); -@@ -8411,46 +8411,75 @@ function NewChat({ mode, roster, actions }) { +@@ -8411,46 +8411,75 @@ close(); return; } @@ -12319,7 +10677,7 @@ diff --git a/client.js b/client.js canGoBack: draft.fromList, onBack: draft.fromList ? () => { setDraft(null); -@@ -8467,33 +8467,41 @@ ${listed}`, { axis: "y", ...QUICK_EDGES }); +@@ -8467,33 +8467,41 @@ if (picked.length > 0) setPicked((current) => current.slice(0, -1)); else if (admin !== null) setAdmin(null); }; @@ -12376,7 +10734,7 @@ diff --git a/client.js b/client.js ref: inputRef, autoFocus: true, value: query, -@@ -8517,20 +8517,20 @@ ${listed}`, { axis: "y", ...QUICK_EDGES }); +@@ -8517,20 +8517,20 @@ } else if (event.key === "Backspace" && query === "") unpick(); } }), @@ -12402,7 +10760,7 @@ diff --git a/client.js b/client.js "button", { key: option.key, -@@ -8542,21 +8542,36 @@ ${listed}`, { axis: "y", ...QUICK_EDGES }); +@@ -8542,21 +8542,36 @@ onMouseEnter: () => setCursor(index), onClick: option.run }, @@ -12448,7 +10806,7 @@ diff --git a/client.js b/client.js rows: 1, value: message, placeholder, -@@ -8569,7 +8569,7 @@ ${listed}`, { axis: "y", ...QUICK_EDGES }); +@@ -8569,7 +8569,7 @@ } } }), @@ -12457,7 +10815,7 @@ diff --git a/client.js b/client.js ) ) ) -@@ -8577,18 +8577,18 @@ ${listed}`, { axis: "y", ...QUICK_EDGES }); +@@ -8577,18 +8577,18 @@ } // src/client/exchange.js @@ -12480,7 +10838,7 @@ diff --git a/client.js b/client.js let stop = false; let timer; let seen = null; -@@ -8612,48 +8612,48 @@ function ExchangeDialog({ pair, roster, actions }) { +@@ -8612,48 +8612,48 @@ const right = roster.byId[pair[1]]; let lastDay = 0; const paneLeft = usePaneLeft(); @@ -12545,7 +10903,7 @@ diff --git a/client.js b/client.js var plainSpeech = (text) => stripHeader(text).replace(/```[\s\S]*?```/g, " ").replace(/\[([^\]]*)\]\([^)]*\)/g, "$1").replace(/[*_`#>|~-]+/g, " ").replace(/\s+/g, " ").trim(); var VOICE_LABELS = { listening: "Listening", -@@ -8665,14 +8665,14 @@ var VOICE_LABELS = { +@@ -8665,14 +8665,14 @@ }; var CAPTURE_NOTES = { blocked: "Microphone access is blocked", unavailable: "Voice chat is not available in this browser", "too-long": "That was too long to recognize" }; function useMicLevel(enabled) { @@ -12563,7 +10921,7 @@ diff --git a/client.js b/client.js if (stopped) { media.getTracks().forEach((track) => track.stop()); return; -@@ -8716,18 +8716,18 @@ function VoiceMode({ sessionId, roster, actions, useSessions, useSessionStatus } +@@ -8716,18 +8716,18 @@ const entry = Array.isArray(outline) ? outline[outline.length - 1] : void 0; return entry ? `${outline.length}|${entry.response ?? ""}` : "0|"; }); @@ -12593,7 +10951,7 @@ diff --git a/client.js b/client.js const paneLeft = usePaneLeft(); const close = () => { turn.current += 1; -@@ -8747,12 +8747,12 @@ function VoiceMode({ sessionId, roster, actions, useSessions, useSessionStatus } +@@ -8747,12 +8747,12 @@ dictation.stop(); submit(text); }); @@ -12608,7 +10966,7 @@ diff --git a/client.js b/client.js if (!speech) return void 0; let live = true; speechCatalog(speech).then(({ catalog: next, usable }) => { -@@ -8767,16 +8767,16 @@ function VoiceMode({ sessionId, roster, actions, useSessions, useSessionStatus } +@@ -8767,16 +8767,16 @@ }; }, [speech]); const listen = phase === "listening"; @@ -12628,91 +10986,78 @@ diff --git a/client.js b/client.js if (!speech || !catalog || !listen) return void 0; const own = turn.current; let capture = null; -@@ -8830,336 +8830,92 @@ function VoiceMode({ sessionId, roster, actions, useSessions, useSessionStatus } - if (stopped) return; - setNote(t(CAPTURE_NOTES[error?.kind] ?? "Couldn't hear that")); - setPhase("paused"); -- }); -- return () => { -- stopped = true; -- cancelAnimationFrame(frame); -- capture?.cancel(); -- setServiceLevel(0); -- }; -- }, [speech, catalog, listen, cycle]); +@@ -8838,7 +8838,7 @@ + setServiceLevel(0); + }; + }, [speech, catalog, listen, cycle]); - (0, import_react22.useEffect)(() => { -- if (phase !== "thinking" || running || waiting.current === null) return; -- const [count, text] = [lastReply.slice(0, lastReply.indexOf("|")), lastReply.slice(lastReply.indexOf("|") + 1)]; -- if (count === waiting.current || !text.trim()) return; -- waiting.current = null; -- const spoken = plainSpeech(text); -- setSaid(spoken); -- setHeard(""); -- if (!window.speechSynthesis || !spoken) { -- setPhase("listening"); -- return; -- } -- const utterance = new SpeechSynthesisUtterance(spoken); -- utterance.lang = /[\u3400-\u9fff]/.test(spoken) ? "zh-CN" : "en-US"; -- utterance.onend = () => setPhase((current) => current === "speaking" ? "listening" : current); -- utterance.onerror = utterance.onend; -- setPhase("speaking"); -- window.speechSynthesis.cancel(); -- window.speechSynthesis.speak(utterance); -- }, [phase, running, lastReply]); ++ (0, import_react30.useEffect)(() => { + if (phase !== "thinking" || running || waiting.current === null) return; + const [count, text] = [lastReply.slice(0, lastReply.indexOf("|")), lastReply.slice(lastReply.indexOf("|") + 1)]; + if (count === waiting.current || !text.trim()) return; +@@ -8858,7 +8858,7 @@ + window.speechSynthesis.cancel(); + window.speechSynthesis.speak(utterance); + }, [phase, running, lastReply]); - (0, import_react22.useEffect)(() => () => window.speechSynthesis?.cancel(), []); -- const micLevel = useMicLevel(!speech && phase === "listening" && dictation.listening); -- const level = speech ? serviceLevel : micLevel; -- const markState = phase === "thinking" || phase === "transcribing" ? "thinking" : phase === "speaking" ? "sending" : "idle"; -- const blocked = phase === "unsupported" || phase === "setup" || phase === "starting"; -- const togglePause = () => { -- if (phase === "paused") { -- setPhase("listening"); -- return; -- } -- turn.current += 1; -- window.speechSynthesis?.cancel(); -- setPhase("paused"); -- }; -- const openSetup = phase === "setup" ? actions.voiceSetup() : null; -- const caption = phase === "speaking" ? said : heard; -- const shownNote = note || dictation.note; ++ (0, import_react30.useEffect)(() => () => window.speechSynthesis?.cancel(), []); + const micLevel = useMicLevel(!speech && phase === "listening" && dictation.listening); + const level = speech ? serviceLevel : micLevel; + const markState = phase === "thinking" || phase === "transcribing" ? "thinking" : phase === "speaking" ? "sending" : "idle"; +@@ -8875,291 +8875,47 @@ + const openSetup = phase === "setup" ? actions.voiceSetup() : null; + const caption = phase === "speaking" ? said : heard; + const shownNote = note || dictation.note; - return (0, import_react22.createElement)( -- "div", -- { className: "bt-pane bt-voice-pane", style: { left: paneLeft } }, ++ return (0, import_react30.createElement)( + "div", + { className: "bt-pane bt-voice-pane", style: { left: paneLeft } }, - (0, import_react22.createElement)( -- "div", -- { className: "bt-voice-stage", role: "dialog", "aria-label": t("Voice chat with {name}", { name }), "data-phase": phase === "transcribing" ? "thinking" : phase }, ++ (0, import_react30.createElement)( + "div", + { className: "bt-voice-stage", role: "dialog", "aria-label": t("Voice chat with {name}", { name }), "data-phase": phase === "transcribing" ? "thinking" : phase }, - (0, import_react22.createElement)( -- "div", -- { className: "bt-voice-orb", style: { "--bt-level": level } }, ++ (0, import_react30.createElement)( + "div", + { className: "bt-voice-orb", style: { "--bt-level": level } }, - (0, import_react22.createElement)("span", { className: "bt-voice-ring", "aria-hidden": true }), - (0, import_react22.createElement)("span", { className: "bt-voice-ring bt-voice-ring-2", "aria-hidden": true }), - bot ? (0, import_react22.createElement)(BotMark, { bot, size: 112, state: markState, live: true, gaze: phase === "listening" }) : room2 ? (0, import_react22.createElement)(RoomAvatar, { room: room2, roster, size: 112 }) : null -- ), ++ (0, import_react30.createElement)("span", { className: "bt-voice-ring", "aria-hidden": true }), ++ (0, import_react30.createElement)("span", { className: "bt-voice-ring bt-voice-ring-2", "aria-hidden": true }), ++ bot ? (0, import_react30.createElement)(BotMark, { bot, size: 112, state: markState, live: true, gaze: phase === "listening" }) : room2 ? (0, import_react30.createElement)(RoomAvatar, { room: room2, roster, size: 112 }) : null + ), - (0, import_react22.createElement)("div", { className: "bt-voice-name" }, name), - (0, import_react22.createElement)( -- "div", -- { className: "bt-voice-status", role: "status" }, ++ (0, import_react30.createElement)("div", { className: "bt-voice-name" }, name), ++ (0, import_react30.createElement)( + "div", + { className: "bt-voice-status", role: "status" }, - phase === "thinking" ? (0, import_react22.createElement)(Shimmer, null, t("Thinking…")) : phase === "transcribing" ? (0, import_react22.createElement)(Shimmer, null, t("Recognizing…")) : VOICE_LABELS[phase] ? t(VOICE_LABELS[phase]) : "" -- ), ++ phase === "thinking" ? (0, import_react30.createElement)(Shimmer, null, t("Thinking…")) : phase === "transcribing" ? (0, import_react30.createElement)(Shimmer, null, t("Recognizing…")) : VOICE_LABELS[phase] ? t(VOICE_LABELS[phase]) : "" + ), - (0, import_react22.createElement)("div", { className: "bt-voice-caption", key: `${phase}:${caption.slice(0, 12)}` }, caption), - openSetup ? (0, import_react22.createElement)("button", { type: "button", className: "bt-voice-setup", onClick: () => { -- close(); -- openSetup(); -- } }, t("Set up voice input")) : null, ++ (0, import_react30.createElement)("div", { className: "bt-voice-caption", key: `${phase}:${caption.slice(0, 12)}` }, caption), ++ openSetup ? (0, import_react30.createElement)("button", { type: "button", className: "bt-voice-setup", onClick: () => { + close(); + openSetup(); + } }, t("Set up voice input")) : null, - shownNote ? (0, import_react22.createElement)("div", { className: "bt-voice-note" }, shownNote) : null -- ), ++ shownNote ? (0, import_react30.createElement)("div", { className: "bt-voice-note" }, shownNote) : null + ), - (0, import_react22.createElement)( -- "div", -- { className: "bt-voice-bar" }, ++ (0, import_react30.createElement)( + "div", + { className: "bt-voice-bar" }, - (0, import_react22.createElement)("button", { -- type: "button", -- className: "bt-voice-btn", -- "aria-pressed": phase === "paused", -- disabled: blocked, -- "aria-label": phase === "paused" ? t("Resume listening") : t("Pause"), -- onClick: togglePause ++ (0, import_react30.createElement)("button", { + type: "button", + className: "bt-voice-btn", + "aria-pressed": phase === "paused", + disabled: blocked, + "aria-label": phase === "paused" ? t("Resume listening") : t("Pause"), + onClick: togglePause - }, (0, import_react22.createElement)(MicIcon, { size: 20 }), phase === "paused" ? (0, import_react22.createElement)("span", { className: "bt-voice-slash", "aria-hidden": true }) : null), - (0, import_react22.createElement)("button", { type: "button", className: "bt-voice-btn bt-voice-end", "aria-label": t("End voice chat"), onClick: close }, (0, import_react22.createElement)(CloseIcon)) - ) @@ -12895,48 +11240,7 @@ diff --git a/client.js b/client.js - })); - if (done && room2) onCreated(room2); - else setBusy(false); -+ }); -+ return () => { -+ stopped = true; -+ cancelAnimationFrame(frame); -+ capture?.cancel(); -+ setServiceLevel(0); -+ }; -+ }, [speech, catalog, listen, cycle]); -+ (0, import_react30.useEffect)(() => { -+ if (phase !== "thinking" || running || waiting.current === null) return; -+ const [count, text] = [lastReply.slice(0, lastReply.indexOf("|")), lastReply.slice(lastReply.indexOf("|") + 1)]; -+ if (count === waiting.current || !text.trim()) return; -+ waiting.current = null; -+ const spoken = plainSpeech(text); -+ setSaid(spoken); -+ setHeard(""); -+ if (!window.speechSynthesis || !spoken) { -+ setPhase("listening"); -+ return; -+ } -+ const utterance = new SpeechSynthesisUtterance(spoken); -+ utterance.lang = /[\u3400-\u9fff]/.test(spoken) ? "zh-CN" : "en-US"; -+ utterance.onend = () => setPhase((current) => current === "speaking" ? "listening" : current); -+ utterance.onerror = utterance.onend; -+ setPhase("speaking"); -+ window.speechSynthesis.cancel(); -+ window.speechSynthesis.speak(utterance); -+ }, [phase, running, lastReply]); -+ (0, import_react30.useEffect)(() => () => window.speechSynthesis?.cancel(), []); -+ const micLevel = useMicLevel(!speech && phase === "listening" && dictation.listening); -+ const level = speech ? serviceLevel : micLevel; -+ const markState = phase === "thinking" || phase === "transcribing" ? "thinking" : phase === "speaking" ? "sending" : "idle"; -+ const blocked = phase === "unsupported" || phase === "setup" || phase === "starting"; -+ const togglePause = () => { -+ if (phase === "paused") { -+ setPhase("listening"); -+ return; -+ } -+ turn.current += 1; -+ window.speechSynthesis?.cancel(); -+ setPhase("paused"); - }; +- }; - const chip = (bot, on, onClick, role) => (0, import_react23.createElement)("button", { - key: bot.id, - type: "button", @@ -12946,22 +11250,14 @@ diff --git a/client.js b/client.js - onClick - }, (0, import_react23.createElement)(BotAvatar, { bot, size: 22, badge: false, live: false }), (0, import_react23.createElement)("span", { className: "bt-ng-name" }, bot.name), on && role === "radio" ? (0, import_react23.createElement)(AdminStar) : null); - return (0, import_react23.createElement)( -+ const openSetup = phase === "setup" ? actions.voiceSetup() : null; -+ const caption = phase === "speaking" ? said : heard; -+ const shownNote = note || dictation.note; -+ return (0, import_react30.createElement)( - "div", +- "div", - { className: "bt-set-card bt-ng" }, - (0, import_react23.createElement)( -+ { className: "bt-pane bt-voice-pane", style: { left: paneLeft } }, -+ (0, import_react30.createElement)( - "div", +- "div", - { className: "bt-set-row bt-bs-row bt-bs-stack" }, - (0, import_react23.createElement)("span", { className: "bt-set-copy" }, (0, import_react23.createElement)("span", { className: "bt-bs-label bt-ng-label" }, (0, import_react23.createElement)(AdminStar), t("Choose the group admin"))), - (0, import_react23.createElement)( -+ { className: "bt-voice-stage", role: "dialog", "aria-label": t("Voice chat with {name}", { name }), "data-phase": phase === "transcribing" ? "thinking" : phase }, -+ (0, import_react30.createElement)( - "div", +- "div", - { className: "bt-ng-picks", role: "radiogroup", "aria-label": t("Group admin") }, - roster.bots.map((bot) => chip(bot, admin === bot.id, () => pickAdmin(bot.id), "radio")) - ) @@ -12971,14 +11267,7 @@ diff --git a/client.js b/client.js - { className: "bt-set-row bt-bs-row bt-bs-stack bt-ng-step" }, - (0, import_react23.createElement)("span", { className: "bt-set-copy" }, (0, import_react23.createElement)("span", { className: "bt-bs-label" }, t("Who to chat with…"))), - (0, import_react23.createElement)( -+ { className: "bt-voice-orb", style: { "--bt-level": level } }, -+ (0, import_react30.createElement)("span", { className: "bt-voice-ring", "aria-hidden": true }), -+ (0, import_react30.createElement)("span", { className: "bt-voice-ring bt-voice-ring-2", "aria-hidden": true }), -+ bot ? (0, import_react30.createElement)(BotMark, { bot, size: 112, state: markState, live: true, gaze: phase === "listening" }) : room2 ? (0, import_react30.createElement)(RoomAvatar, { room: room2, roster, size: 112 }) : null -+ ), -+ (0, import_react30.createElement)("div", { className: "bt-voice-name" }, name), -+ (0, import_react30.createElement)( - "div", +- "div", - { className: "bt-ng-picks", role: "group", "aria-label": t("Members") }, - roster.bots.filter((bot) => bot.id !== admin).map((bot) => chip(bot, members.includes(bot.id), () => toggle(bot.id), "checkbox")) - ) @@ -12988,30 +11277,12 @@ diff --git a/client.js b/client.js - { className: "bt-set-row bt-bs-row bt-ng-step" }, - (0, import_react23.createElement)("span", { className: "bt-set-copy" }, (0, import_react23.createElement)("span", { className: "bt-bs-label" }, t("Name"))), - (0, import_react23.createElement)("input", { className: "bt-bs-input", value: name, maxLength: 60, placeholder: t("Name after members"), onChange: (event) => setName(event.target.value) }) -+ { className: "bt-voice-status", role: "status" }, -+ phase === "thinking" ? (0, import_react30.createElement)(Shimmer, null, t("Thinking…")) : phase === "transcribing" ? (0, import_react30.createElement)(Shimmer, null, t("Recognizing…")) : VOICE_LABELS[phase] ? t(VOICE_LABELS[phase]) : "" -+ ), -+ (0, import_react30.createElement)("div", { className: "bt-voice-caption", key: `${phase}:${caption.slice(0, 12)}` }, caption), -+ openSetup ? (0, import_react30.createElement)("button", { type: "button", className: "bt-voice-setup", onClick: () => { -+ close(); -+ openSetup(); -+ } }, t("Set up voice input")) : null, -+ shownNote ? (0, import_react30.createElement)("div", { className: "bt-voice-note" }, shownNote) : null - ), +- ), - (0, import_react23.createElement)( -+ (0, import_react30.createElement)( - "div", +- "div", - { className: "bt-set-row bt-task-foot" }, - (0, import_react23.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: onCancel }, t("Cancel")), - (0, import_react23.createElement)("button", { type: "button", className: "bt-send bt-soft-sm", disabled: busy || admin === null || members.length === 0, onClick: () => void create() }, t("Create group chat")) -+ { className: "bt-voice-bar" }, -+ (0, import_react30.createElement)("button", { -+ type: "button", -+ className: "bt-voice-btn", -+ "aria-pressed": phase === "paused", -+ disabled: blocked, -+ "aria-label": phase === "paused" ? t("Resume listening") : t("Pause"), -+ onClick: togglePause + }, (0, import_react30.createElement)(MicIcon, { size: 20 }), phase === "paused" ? (0, import_react30.createElement)("span", { className: "bt-voice-slash", "aria-hidden": true }) : null), + (0, import_react30.createElement)("button", { type: "button", className: "bt-voice-btn bt-voice-end", "aria-label": t("End voice chat"), onClick: close }, (0, import_react30.createElement)(CloseIcon)) ) @@ -13041,7 +11312,7 @@ diff --git a/client.js b/client.js // src/client/usage-data.js var HOUR = 0; -@@ -9277,13 +9277,13 @@ function summarize(view, range, keep = () => true, seriesOf = () => "all") { +@@ -9277,13 +9277,13 @@ } return result; } @@ -13057,7 +11328,7 @@ diff --git a/client.js b/client.js } return String(Math.round(count)); } -@@ -9294,11 +9294,11 @@ function percent(part, whole) { +@@ -9294,11 +9294,11 @@ } // src/client/usage-page.js @@ -13071,7 +11342,7 @@ diff --git a/client.js b/client.js var rangeLabel = (id) => ({ "24h": t("24 hours"), "7d": t("7 days"), "30d": t("30 days"), "12w": t("12 weeks"), "12m": t("12 months") })[id]; var unitLabel = (unit) => ({ hour: t("Per hour"), day: t("Per day"), week: t("Per week"), month: t("Per month") })[unit]; var MODEL_INKS = ["deepseek", "tangerine", "green", "violet", "sky", "rose", "yellow", "cyan", "magenta", "brown"]; -@@ -9341,9 +9341,9 @@ function titleOf(range, bucket) { +@@ -9341,9 +9341,9 @@ return dates({ year: "numeric", month: "long" }).format(bucket.start); } function useTween(value) { @@ -13084,7 +11355,7 @@ diff --git a/client.js b/client.js if (reducedMotion() || typeof requestAnimationFrame !== "function") { from.current = value; setShown(value); -@@ -9363,9 +9363,9 @@ function useTween(value) { +@@ -9363,9 +9363,9 @@ }, [value]); return shown; } @@ -13096,7 +11367,7 @@ diff --git a/client.js b/client.js className: "bt-us-model", "aria-hidden": true, style: { width: size, height: size, fontSize: Math.round(size * 0.42), background: `color-mix(in srgb, ${paint} 20%, var(--bt-card))`, color: `color-mix(in srgb, ${paint} 72%, var(--bt-ink))` } -@@ -9382,7 +9382,7 @@ function ownerFace(owner, roster) { +@@ -9382,7 +9382,7 @@ sub: owner.deleted ? t("Deleted") : role.toLowerCase() === name.toLowerCase() ? "" : role, paint: paintCss(colorOf(bot)), stops: GRADIENTS[colorOf(bot)], @@ -13105,7 +11376,7 @@ diff --git a/client.js b/client.js }; } if (owner.kind === "room") { -@@ -9392,7 +9392,7 @@ function ownerFace(owner, roster) { +@@ -9392,7 +9392,7 @@ name: owner.name || t("Deleted group chat"), sub: owner.deleted ? t("Deleted") : t("Group chat"), paint: "var(--bt-us-rest)", @@ -13114,7 +11385,7 @@ diff --git a/client.js b/client.js }; } if (owner.kind === "agent") { -@@ -9414,30 +9414,30 @@ function Tabs({ label, value, options, onChange, boxRef }) { +@@ -9414,30 +9414,30 @@ const names = options.map(([, text]) => text).join("\n"); const glide = useGlide(`${value} ${names}`, { box: boxRef }); @@ -13155,7 +11426,7 @@ diff --git a/client.js b/client.js "span", { className: "bt-us-delta", title: compareText(range) }, `${change >= 0 ? "↑" : "↓"} ${Math.abs(change)}%` -@@ -9455,83 +9455,83 @@ function niceTop(max) { +@@ -9455,83 +9455,83 @@ return [1, 1.5, 2, 2.5, 3, 4, 5, 6, 8, 10].map((scale) => scale * step).find((value) => value >= max); } function Chart({ range, columns, series, tipSeries, by, onBy, onPick }) { @@ -13266,7 +11537,7 @@ diff --git a/client.js b/client.js onClick ? "button" : "div", { className: "bt-us-row", -@@ -9540,69 +9540,69 @@ function Row2({ kind, glyph, name, sub, sum, whole, fill: fill2, index, onClick +@@ -9540,69 +9540,69 @@ title: t("{tokens} tokens · {calls} calls", { tokens: full(sum.total), calls: full(sum.calls) }), ...onClick ? { type: "button", onClick } : {} }, @@ -13364,7 +11635,7 @@ diff --git a/client.js b/client.js const onDown = (event) => { if (![box, near].some((ref) => ref.current?.contains(event.target))) onClose(); }; -@@ -9628,17 +9628,17 @@ function SpanPicker({ span, today, oldest, active, near, onPick, onClose }) { +@@ -9628,17 +9628,17 @@ const [from, to] = [anchor, key].sort(); onPick(from, to); }; @@ -13385,7 +11656,7 @@ diff --git a/client.js b/client.js key, type: "button", className: "bt-us-day", -@@ -9654,15 +9654,15 @@ function SpanPicker({ span, today, oldest, active, near, onPick, onClose }) { +@@ -9654,15 +9654,15 @@ "data-has": flag(active.has(key)), onMouseEnter: () => setHover(key), onClick: () => press(key) @@ -13407,7 +11678,7 @@ diff --git a/client.js b/client.js ); }; const shortcuts = [ -@@ -9671,30 +9671,30 @@ function SpanPicker({ span, today, oldest, active, near, onPick, onClose }) { +@@ -9671,30 +9671,30 @@ ["year", t("This year"), `${today.slice(0, 4)}-01-01`, today] ]; const count = low === null ? 0 : Math.round((hourMs(high) - hourMs(low)) / DAY_MS) + 1; @@ -13446,7 +11717,7 @@ diff --git a/client.js b/client.js const today = hourMs(`${view.now.slice(0, 10)} 00`); const first = mondayOf(today) - (HEAT_WEEKS - 1) * 7 * DAY_MS; const from = dayKey(first); -@@ -9710,13 +9710,13 @@ function Heat({ view, keep, paint, index }) { +@@ -9710,13 +9710,13 @@ for (let week = 0; week < HEAT_WEEKS; week += 1) { const monday = first + week * 7 * DAY_MS; if (week > 0 && new Date(monday).getUTCMonth() !== new Date(monday - 7 * DAY_MS).getUTCMonth()) { @@ -13462,7 +11733,7 @@ diff --git a/client.js b/client.js key: ms, className: "bt-us-cell", style: { "--w": week }, -@@ -9724,7 +9724,7 @@ function Heat({ view, keep, paint, index }) { +@@ -9724,7 +9724,7 @@ "data-future": ms > today ? "" : void 0, "data-today": ms === today ? "" : void 0, onMouseEnter: ms > today ? void 0 : () => setHover({ ms, week, day }) @@ -13471,7 +11742,7 @@ diff --git a/client.js b/client.js } } const tipSide = hover ? { -@@ -9733,61 +9733,61 @@ function Heat({ view, keep, paint, index }) { +@@ -9733,61 +9733,61 @@ } : null; const active = [...daily.values()].filter((value) => value > 0).length; const summary = active === 1 ? t("Active on 1 day in the last year") : t("Active on {count} days in the last year", { count: active }); @@ -13559,7 +11830,7 @@ diff --git a/client.js b/client.js let stopped = false; let timer; const resized = Promise.all((document.querySelector(".bt-settings")?.getAnimations() ?? []).filter((animation) => animation.transitionProperty === "width" || animation.transitionProperty === "height").map((animation) => animation.finished.catch(() => { -@@ -9797,7 +9797,7 @@ function UsagePage({ roster, actions, focusId }) { +@@ -9797,7 +9797,7 @@ const value = await actions.usage(); await resized; if (stopped) return; @@ -13568,7 +11839,7 @@ diff --git a/client.js b/client.js setError(""); timer = setTimeout(load, value.scanning ? 3e3 : 2e4); } catch (failure) { -@@ -9814,7 +9814,7 @@ function UsagePage({ roster, actions, focusId }) { +@@ -9814,7 +9814,7 @@ }, []); const today = view?.now.slice(0, 10); const range = rangeId === "custom" && span ? { ...spanRange(span.from, span.to), ends: span.to >= today } : RANGES.find((item) => item.id === rangeId); @@ -13577,7 +11848,7 @@ diff --git a/client.js b/client.js if (!view) return null; const faces2 = view.owners.map((owner) => ownerFace(owner, roster)); const lifetime = /* @__PURE__ */ new Map(); -@@ -9823,7 +9823,7 @@ function UsagePage({ roster, actions, focusId }) { +@@ -9823,7 +9823,7 @@ const models2 = view.models.map((model, at) => { const name = model.name || model.model || t("Unknown model"); const paint = modelPaint.get(at) ?? "var(--bt-us-rest)"; @@ -13586,7 +11857,7 @@ diff --git a/client.js b/client.js }); const ownerAt2 = focus?.kind === "owner" ? view.owners.findIndex((owner) => owner.key === focus.key) : -1; const modelAt = focus?.kind === "model" ? view.models.findIndex((model) => model.key === focus.key) : -1; -@@ -9871,23 +9871,23 @@ function UsagePage({ roster, actions, focusId }) { +@@ -9871,23 +9871,23 @@ }); return { faces: faces2, models: models2, ownerAt: ownerAt2, modelAt, face: face2, keep: keep2, all: all2, part: part2, series: series2, tipSeries: tipSeries2, fillOf: fillOf2, columns: columns2, ranked: ranked2, isBot: isBot2, activeDays: activeDays2, oldest: oldest2 }; }, [view, rangeId, span, by, focusId, roster]); @@ -13618,7 +11889,7 @@ diff --git a/client.js b/client.js ) ); } -@@ -9912,7 +9912,7 @@ function UsagePage({ roster, actions, focusId }) { +@@ -9912,7 +9912,7 @@ ["custom", rangeId === "custom" && span ? spanText(span.from, span.to) : t("Custom"), CalendarIcon] ]; const whole = part.total.total; @@ -13627,7 +11898,7 @@ diff --git a/client.js b/client.js key: `o${at}`, kind: faces[at].kind, glyph: faces[at].glyph(32), -@@ -9924,7 +9924,7 @@ function UsagePage({ roster, actions, focusId }) { +@@ -9924,7 +9924,7 @@ fill: face ? soften(face.paint, 0.8) : fillOf.get(`o${at}`) ?? soften(faces[at].paint), onClick: () => pick2({ kind: "owner", key: view.owners[at].key }) }); @@ -13636,7 +11907,7 @@ diff --git a/client.js b/client.js key: `m${at}`, kind: "model", glyph: models[at].glyph(32), -@@ -9936,30 +9936,30 @@ function UsagePage({ roster, actions, focusId }) { +@@ -9936,30 +9936,30 @@ fill: face ? soften(face.paint, 0.8) : fillOf.get(`m${at}`) ?? soften(models[at].paint), onClick: () => pick2({ kind: "model", key: view.models[at].key }) }); @@ -13677,7 +11948,7 @@ diff --git a/client.js b/client.js ) ]; } else { -@@ -9968,39 +9968,39 @@ function UsagePage({ roster, actions, focusId }) { +@@ -9968,39 +9968,39 @@ const rest = owners.filter(([at]) => !isBot(at)); const usedModels = ranked(all.models); body = [ @@ -13731,7 +12002,7 @@ diff --git a/client.js b/client.js body ); } -@@ -10150,33 +10150,135 @@ button.bt-us-row:hover{background:var(--bt-hover)} +@@ -10150,33 +10150,135 @@ @keyframes bt-us-drop{from{opacity:0;transform:translateY(-4px) scale(.96)}} @keyframes bt-us-fade{from{opacity:0}} @keyframes bt-us-cell{from{opacity:0;transform:scale(.5)}} @@ -13878,7 +12149,7 @@ diff --git a/client.js b/client.js const run = (task, after) => { setBusy(true); setError(null); -@@ -10184,72 +10184,128 @@ function ConnectorRow({ connector, actions }) { +@@ -10184,72 +10184,128 @@ after?.(); }, (failure) => setError(failure?.message ?? String(failure))).finally(() => setBusy(false)); }; @@ -13985,8 +12256,9 @@ diff --git a/client.js b/client.js } }, t("Cancel")), - (0, import_react25.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy || token.trim() === "", onClick: connect }, busy ? t("Connecting…") : t("Connect")) + (0, import_react32.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy || token.trim() === "", onClick: connect }, busy ? t("Connecting…") : t("Connect")) -+ ) -+ ) : null, + ) + ) : null, +- error ? (0, import_react25.createElement)("div", { className: "bt-connector-error", role: "alert" }, error) : null + on && gateway && picking ? (0, import_react32.createElement)( + "div", + { className: "bt-connector-form" }, @@ -14001,9 +12273,8 @@ diff --git a/client.js b/client.js + setError(null); + } }, t("Cancel")), + (0, import_react32.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy || apps.length === 0, onClick: saveApps }, busy ? t("Connecting…") : t("Save")) - ) - ) : null, -- error ? (0, import_react25.createElement)("div", { className: "bt-connector-error", role: "alert" }, error) : null ++ ) ++ ) : null, + error ? (0, import_react32.createElement)("div", { className: "bt-connector-error", role: "alert" }, error) : null ); } @@ -14032,7 +12303,7 @@ diff --git a/client.js b/client.js ); function download(name, text) { const url = URL.createObjectURL(new Blob([text], { type: "text/plain;charset=utf-8" })); -@@ -10260,7 +10260,7 @@ function download(name, text) { +@@ -10260,7 +10260,7 @@ setTimeout(() => URL.revokeObjectURL(url), 1e3); } var copy = (text) => navigator.clipboard === void 0 ? Promise.resolve(false) : navigator.clipboard.writeText(text).then(() => true, () => false); @@ -14041,7 +12312,7 @@ diff --git a/client.js b/client.js const date = new Date(time); const today = (/* @__PURE__ */ new Date()).toDateString() === date.toDateString(); const clock4 = date.toLocaleTimeString(dateLocale(), { hour: "2-digit", minute: "2-digit" }); -@@ -10270,18 +10270,18 @@ function entrySummary(entry) { +@@ -10270,18 +10270,18 @@ if (entry.source === "turn") return failureLabel(entry.code, entry.text); return entry.text; } @@ -14066,7 +12337,7 @@ diff --git a/client.js b/client.js void run(load()); }, []); const copyReport = () => run(fetchReport().then(async (next) => { -@@ -10289,7 +10289,7 @@ function FeedbackPage({ actions, run, Section, Row: Row3 }) { +@@ -10289,7 +10289,7 @@ setNote(copied ? t("Copied the diagnostics.") : t("Could not copy. Select the text below and copy it.")); if (!copied) setPreview(true); })); @@ -14075,7 +12346,7 @@ diff --git a/client.js b/client.js const openIssue = () => { const win = window.open("", "_blank"); if (win !== null) win.opener = null; -@@ -10317,59 +10317,59 @@ function FeedbackPage({ actions, run, Section, Row: Row3 }) { +@@ -10317,59 +10317,59 @@ const total = (report?.total ?? 0) + browserErrors.length; const shown = entries2.slice(0, 8); return [ @@ -14160,7 +12431,7 @@ diff --git a/client.js b/client.js ) ) ]; -@@ -10377,8 +10377,8 @@ function FeedbackPage({ actions, run, Section, Row: Row3 }) { +@@ -10377,8 +10377,8 @@ var TOAST_MS = 6e3; function ErrorToast({ useUi, actions }) { const toast = useUi((state) => state.toast); @@ -14171,7 +12442,7 @@ diff --git a/client.js b/client.js if (!toast) return void 0; setLeaving(false); const fade = setTimeout(() => setLeaving(true), TOAST_MS); -@@ -10389,15 +10389,16 @@ function ErrorToast({ useUi, actions }) { +@@ -10389,15 +10389,16 @@ }; }, [toast?.token]); if (!toast) return null; @@ -14193,7 +12464,7 @@ diff --git a/client.js b/client.js ); } var FEEDBACK_CSS = ` -@@ -10428,35 +10428,44 @@ var FEEDBACK_CSS = ` +@@ -10428,35 +10428,44 @@ `; // src/client/about.js @@ -14248,7 +12519,7 @@ diff --git a/client.js b/client.js let live = true; actions.repoStars().then((count) => { if (live && Number.isInteger(count)) setStars(count); -@@ -10466,87 +10466,72 @@ function AboutPage({ useUpdate, actions, run, Section, Row: Row3 }) { +@@ -10466,87 +10466,72 @@ live = false; }; }, []); @@ -14359,7 +12630,7 @@ diff --git a/client.js b/client.js .bt-about-copy{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px} .bt-about-name{font-size:16px;line-height:22px;font-weight:600} .bt-about-version{font-size:12px;line-height:16px;color:var(--bt-ink-2);font-variant-numeric:tabular-nums} -@@ -10569,54 +10569,59 @@ body:not([data-bt-motion=quiet]) .bt-about-mark:hover .bt-mark[data-live][data-s +@@ -10569,54 +10569,59 @@ `; // src/client/settings.js @@ -14436,7 +12707,7 @@ diff --git a/client.js b/client.js key: ink, type: "button", className: "bt-swatch", -@@ -10626,75 +10626,87 @@ function GeneralPage({ roster, actions, run }) { +@@ -10626,75 +10626,87 @@ style: { background: INKS[ink][0] }, onClick: () => run(actions.setPrefs({ accent: INKS[ink][0] })) })), @@ -14547,7 +12818,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,69 +10706,69 @@ ); } function BotsPage({ roster, actions, run }) { @@ -14647,7 +12918,7 @@ diff --git a/client.js b/client.js useEscape(close); const run = (promise) => Promise.resolve(promise).then(() => { setError(""); -@@ -10779,104 +10779,112 @@ function SettingsDialog({ page, id, roster, actions, useUpdate, leaving = false +@@ -10779,104 +10779,112 @@ }); const bot = page === "bots" && id ? roster.byId[id] : void 0; const room2 = page === "groups" && id ? roster.roomsById[id] : void 0; @@ -14801,7 +13072,7 @@ diff --git a/client.js b/client.js const bot = roster.byId[menu.id]; const room2 = roster.roomsById[menu.id]; const entry = bot ?? room2; -@@ -10884,13 +10884,22 @@ function ContextMenu({ menu, roster, actions, leaving = false }) { +@@ -10884,13 +10884,22 @@ const isMainBot = isMainOf(roster, menu.id); const isTile = roster.mainBotId === menu.id; const mainCount = (roster.mainBotIds ?? []).length; @@ -14826,7 +13097,7 @@ diff --git a/client.js b/client.js bot ? [t("Edit Bot"), GearIcon, () => actions.openSettings("bots", bot.id)] : null, room2 ? [t("Group settings"), GearIcon, () => actions.openSettings("groups", room2.id)] : null, room2 ? [t("Rename group"), PencilIcon, () => actions.renameBot(room2.id)] : null, -@@ -10901,20 +10901,22 @@ function ContextMenu({ menu, roster, actions, leaving = false }) { +@@ -10901,20 +10901,22 @@ bot && !isMainBot ? [t("Make Main Bot"), StarLineIcon, () => actions.setMain(bot.id, true)] : null, bot && isMainBot && mainCount > 1 ? [t("Remove Main Bot role"), StarLineIcon, () => actions.setMain(bot.id, false)] : null ], @@ -14855,7 +13126,7 @@ diff --git a/client.js b/client.js key: label, type: "button", role: "menuitem", -@@ -10923,14 +10923,14 @@ function ContextMenu({ menu, roster, actions, leaving = false }) { +@@ -10923,14 +10923,14 @@ actions.closeMenu(); void Promise.resolve(run()).catch((error) => actions.reportError(error, "menu")); } @@ -14874,7 +13145,7 @@ diff --git a/client.js b/client.js const box = ref.current?.getBoundingClientRect(); if (!box) return; setPlace({ left: Math.max(8, Math.min(menu.x, window.innerWidth - box.width - 8)), top: Math.max(8, Math.min(menu.y, window.innerHeight - box.height - 8)) }); -@@ -10952,14 +10952,14 @@ function AgentMenu({ menu, roster, actions, leaving }) { +@@ -10952,14 +10952,14 @@ [t("Archive"), TrashIcon, () => actions.archiveAgentSession(agent.id, menu.id), true] ] ]; @@ -14894,7 +13165,7 @@ diff --git a/client.js b/client.js key: label, type: "button", role: "menuitem", -@@ -10968,303 +10968,93 @@ function AgentMenu({ menu, roster, actions, leaving }) { +@@ -10968,9 +10968,23 @@ actions.closeMenu(); void Promise.resolve(run()).catch((error) => console.warn("[ds-bot]", error)); } @@ -14902,8 +13173,8 @@ diff --git a/client.js b/client.js + }, (0, import_react36.createElement)(Icon), label)) )) ); - } - ++} ++ +// src/client/pane-width.js +var DEFAULT_WIDTH = 520; +var DETAILS_WIDTH = 320; @@ -14916,73 +13187,21 @@ diff --git a/client.js b/client.js + if (view === "browser") return [MIN_WIDTH, Math.max(MIN_WIDTH, room2)]; + const low = Math.max(MIN_DETAILS, Math.min(DETAILS_WIDTH, room2)); + return [low, Math.max(low, Math.min(DETAILS_MAX, room2))]; -+} -+ + } + // src/client/storage.js - var STORAGE = { +@@ -10978,6 +10978,7 @@ unread: "ds-bot:unread", -- reactions: "ds-bot:reactions", -- seen: "ds-bot:seen", -- prefs: "ds-bot:prefs", -- surface: "ds-bot:surface", -- agentSessions: "ds-bot:agent-sessions", -- browserWidth: "ds-bot:browser-width", -- browsers: "ds-bot.browser.v2" --}; --var LEGACY_PREFIXES = ["dsh-bot:", "dsh-bot-team:"]; --var LEGACY_BROWSER_KEYS = ["ds-bot.browser.v1", "dsh-bot.browser.v1"]; --var LEGACY_THEMES = { grok: "mono" }; --function legacyKeys(key) { -- if (key === STORAGE.browsers) return LEGACY_BROWSER_KEYS; -- const name = key.slice(key.indexOf(":") + 1); -- return LEGACY_PREFIXES.map((prefix) => `${prefix}${name}`); --} --var migrateBrowsers = (storage) => { -- if (storage.getItem(STORAGE.browsers) !== null) { -- for (const key of LEGACY_BROWSER_KEYS) storage.removeItem(key); -- return; -- } -- for (const key of LEGACY_BROWSER_KEYS) { -- try { -- const value = storage.getItem(key); -- if (value === null) continue; -- const previous = JSON.parse(value) ?? {}; -- const tabs = Object.fromEntries(Object.entries(previous).filter(([, page]) => page && typeof page === "object").map(([botId, page]) => { -- const tab = { id: `v1-${botId}`, url: page.url ?? "", title: page.title ?? "" }; -- return [botId, { tabs: [tab], active: tab.id }]; -- })); -- storage.setItem(STORAGE.browsers, JSON.stringify(tabs)); -- for (const earlier of LEGACY_BROWSER_KEYS) storage.removeItem(earlier); -- return; -- } catch { -- } -- } --}; --function migrateStorage(storage = globalThis.localStorage) { -- for (const key of Object.values(STORAGE)) { -- if (key === STORAGE.browsers) continue; -- for (const legacy of legacyKeys(key)) { -- try { -- const value = storage.getItem(legacy); -- if (value !== null && storage.getItem(key) === null) storage.setItem(key, value); -- storage.removeItem(legacy); -- } catch { -- } -- } -- } -- try { -- migrateBrowsers(storage); -- } catch { -- } -- try { -- const prefs = JSON.parse(storage.getItem(STORAGE.prefs) ?? "null"); -- if (prefs && Object.hasOwn(LEGACY_THEMES, prefs.theme ?? "")) { -- storage.setItem(STORAGE.prefs, JSON.stringify({ ...prefs, theme: LEGACY_THEMES[prefs.theme] })); -- } -- } catch { -- } --} -- + reactions: "ds-bot:reactions", + seen: "ds-bot:seen", ++ friendLines: "ds-bot:friend-lines", + prefs: "ds-bot:prefs", + surface: "ds-bot:surface", + agentSessions: "ds-bot:agent-sessions", +@@ -11038,233 +11038,8 @@ + } + } + -// src/client/member-status.js -var import_react30 = require("react"); -var NO_WORK = { bots: {}, rooms: {}, approvals: [] }; @@ -15165,73 +13384,13 @@ diff --git a/client.js b/client.js - entry.here ? (0, import_react30.createElement)("button", { type: "button", className: "bt-mw-stop", disabled: stopping, onClick: stop }, t("Stop")) : null - ) - ); -+ reactions: "ds-bot:reactions", -+ seen: "ds-bot:seen", -+ friendLines: "ds-bot:friend-lines", -+ prefs: "ds-bot:prefs", -+ surface: "ds-bot:surface", -+ agentSessions: "ds-bot:agent-sessions", -+ browserWidth: "ds-bot:browser-width", -+ browsers: "ds-bot.browser.v2" -+}; -+var LEGACY_PREFIXES = ["dsh-bot:", "dsh-bot-team:"]; -+var LEGACY_BROWSER_KEYS = ["ds-bot.browser.v1", "dsh-bot.browser.v1"]; -+var LEGACY_THEMES = { grok: "mono" }; -+function legacyKeys(key) { -+ if (key === STORAGE.browsers) return LEGACY_BROWSER_KEYS; -+ const name = key.slice(key.indexOf(":") + 1); -+ return LEGACY_PREFIXES.map((prefix) => `${prefix}${name}`); - } +-} -function useMemberWork(room2, useWork) { - const work = useWork?.((value) => value) ?? NO_WORK; - const entries2 = room2 ? memberWork(room2, work) : []; - const now = useNow2(entries2.some((entry) => entry.state !== "idle")); - return { entries: entries2, now }; -+var migrateBrowsers = (storage) => { -+ if (storage.getItem(STORAGE.browsers) !== null) { -+ for (const key of LEGACY_BROWSER_KEYS) storage.removeItem(key); -+ return; -+ } -+ for (const key of LEGACY_BROWSER_KEYS) { -+ try { -+ const value = storage.getItem(key); -+ if (value === null) continue; -+ const previous = JSON.parse(value) ?? {}; -+ const tabs = Object.fromEntries(Object.entries(previous).filter(([, page]) => page && typeof page === "object").map(([botId, page]) => { -+ const tab = { id: `v1-${botId}`, url: page.url ?? "", title: page.title ?? "" }; -+ return [botId, { tabs: [tab], active: tab.id }]; -+ })); -+ storage.setItem(STORAGE.browsers, JSON.stringify(tabs)); -+ for (const earlier of LEGACY_BROWSER_KEYS) storage.removeItem(earlier); -+ return; -+ } catch { -+ } -+ } -+}; -+function migrateStorage(storage = globalThis.localStorage) { -+ for (const key of Object.values(STORAGE)) { -+ if (key === STORAGE.browsers) continue; -+ for (const legacy of legacyKeys(key)) { -+ try { -+ const value = storage.getItem(legacy); -+ if (value !== null && storage.getItem(key) === null) storage.setItem(key, value); -+ storage.removeItem(legacy); -+ } catch { -+ } -+ } -+ } -+ try { -+ migrateBrowsers(storage); -+ } catch { -+ } -+ try { -+ const prefs = JSON.parse(storage.getItem(STORAGE.prefs) ?? "null"); -+ if (prefs && Object.hasOwn(LEGACY_THEMES, prefs.theme ?? "")) { -+ storage.setItem(STORAGE.prefs, JSON.stringify({ ...prefs, theme: LEGACY_THEMES[prefs.theme] })); -+ } -+ } catch { -+ } - } +-} -var MEMBER_CSS = ` -.bt-mw-dock-stack{box-sizing:border-box;display:flex;flex-direction:column;align-items:flex-start;gap:2px;width:var(--dsh-chat-content-width,min(960px,100%));margin:0 auto 6px;pointer-events:none} -.bt-mw-dock-stack>.bt-event.bt-mw-dock{align-self:stretch;margin:0} @@ -15267,14 +13426,14 @@ diff --git a/client.js b/client.js -@keyframes bt-mw-in{from{opacity:0;transform:translateY(4px)}} -@media (prefers-reduced-motion:reduce){.bt-mw-dock>button,.bt-mw-row{animation:none}} -`; - +- // src/client/details-drawer.js -var import_react31 = require("react"); +var import_react37 = require("react"); function transcriptOf(list, id, name, hiddenTurns = []) { const outline = list.projectionsBySession?.[id]?.values?.turnOutline ?? list.byId[id]?.projectionValues?.turnOutline; if (!Array.isArray(outline)) return ""; -@@ -11289,10 +11289,10 @@ var EMPTY_TABS = { ids: [], active: null }; +@@ -11289,10 +11289,10 @@ var PERMISSION_LABELS = { "read-only": "Read only", "workspace-write": "Workspace", "danger-full-access": "Full access" }; var DANGER = /* @__PURE__ */ new Set(["danger-full-access"]); function PermissionRow({ bot, roster, actions, run }) { @@ -15287,7 +13446,7 @@ diff --git a/client.js b/client.js if (pending === saved) setPending(null); }, [pending, saved]); if (!catalog?.options?.length) return null; -@@ -11302,14 +11302,14 @@ function PermissionRow({ bot, roster, actions, run }) { +@@ -11302,14 +11302,14 @@ setPending(value); void run(actions.updateBot(bot.id, { permission: value })).catch(() => setPending(null)); }; @@ -15306,7 +13465,7 @@ diff --git a/client.js b/client.js key: option.value, type: "button", "aria-pressed": option.value === current, -@@ -11323,42 +11323,42 @@ function PermissionRow({ bot, roster, actions, run }) { +@@ -11323,42 +11323,42 @@ function BrowserCards({ bot, sessionId, roster, actions, useBrowserTabs, useBrowserPages }) { const state = useBrowserTabs((map) => map[bot.id] ?? EMPTY_TABS); const pages = useBrowserPages((map) => map); @@ -15364,7 +13523,7 @@ diff --git a/client.js b/client.js type: "button", className: "bt-bcard-x", "aria-label": t("Close tab"), -@@ -11367,87 +11367,194 @@ function BrowserCards({ bot, sessionId, roster, actions, useBrowserTabs, useBrow +@@ -11367,87 +11367,194 @@ void actions.browserCloseTab(bot.id, tabId); } }, "×"), @@ -15394,8 +13553,11 @@ diff --git a/client.js b/client.js useMorph(body, open); - return (0, import_react31.createElement)( + return (0, import_react37.createElement)( -+ "div", -+ null, + "div", + null, +- (0, import_react31.createElement)("div", { className: "bt-section-title" }, t(title)), +- (0, import_react31.createElement)("div", { ref: body, className: "bt-about", "data-open": open || void 0 }, text), +- long ? (0, import_react31.createElement)("button", { type: "button", className: "bt-more", onClick: () => setOpen((value) => !value) }, open ? t("Show less") : t("Show more")) : null + (0, import_react37.createElement)("div", { className: "bt-section-title" }, t(title)), + (0, import_react37.createElement)("div", { ref: body, className: "bt-about", "data-open": open || void 0 }, text), + long ? (0, import_react37.createElement)("button", { type: "button", className: "bt-more", onClick: () => setOpen((value) => !value) }, open ? t("Show less") : t("Show more")) : null @@ -15448,11 +13610,8 @@ diff --git a/client.js b/client.js + const people2 = facePeople(room2, roster); + const pick2 = (bot) => call("social/face-lead", { roomId: room2.id, bot }); + return (0, import_react37.createElement)( - "div", - null, -- (0, import_react31.createElement)("div", { className: "bt-section-title" }, t(title)), -- (0, import_react31.createElement)("div", { ref: body, className: "bt-about", "data-open": open || void 0 }, text), -- long ? (0, import_react31.createElement)("button", { type: "button", className: "bt-more", onClick: () => setOpen((value) => !value) }, open ? t("Show less") : t("Show more")) : null ++ "div", ++ null, + (0, import_react37.createElement)(FaceLead, { room: room2, roster, pick: pick2 }), + (0, import_react37.createElement)("div", { className: "bt-section-title" }, t("Members · {count}", { count: people2.length })), + (0, import_react37.createElement)("div", { className: "bt-members", role: "list" }, people2.map((person) => (0, import_react37.createElement)(FacePersonRow, { @@ -15602,7 +13761,7 @@ diff --git a/client.js b/client.js if (last.current.view !== view) last.current = { view, tab, move: view === "settings" ? "in" : "out" }; else if (last.current.tab !== tab) last.current = { view, tab, move: "tab" }; const move = last.current.move; -@@ -11472,36 +11472,43 @@ function DetailsDrawer({ sessionId, startRenaming, startTab, hasStrip, roster, a +@@ -11472,36 +11472,43 @@ setTimeout(() => setCopied(false), 1600); }); }; @@ -15661,7 +13820,7 @@ diff --git a/client.js b/client.js ]; } const current = bot?.name ?? room2.name; -@@ -11515,12 +11515,12 @@ function DetailsDrawer({ sessionId, startRenaming, startTab, hasStrip, roster, a +@@ -11515,12 +11515,12 @@ const subtitle = bot ? bot.role : roomSubtitle(room2, roster); const tabs = bot ? ["details", "browser"] : ["details"]; return [ @@ -15678,7 +13837,7 @@ diff --git a/client.js b/client.js className: "bt-drawer-name-input", autoFocus: true, value: draftName, -@@ -11535,87 +11535,86 @@ function DetailsDrawer({ sessionId, startRenaming, startTab, hasStrip, roster, a +@@ -11535,87 +11535,86 @@ setEditing(false); } } @@ -15808,7 +13967,7 @@ diff --git a/client.js b/client.js } var savedWidth = () => { try { -@@ -11625,8 +11625,8 @@ var savedWidth = () => { +@@ -11625,8 +11625,8 @@ return null; } }; @@ -15818,7 +13977,7 @@ diff --git a/client.js b/client.js var room = { rule: null, selector: "" }; var roomRule = () => { const live = room.rule?.parentStyleSheet?.ownerNode?.isConnected; -@@ -11674,11 +11674,11 @@ var reducedMotion2 = () => typeof matchMedia === "function" && matchMedia("(pref +@@ -11674,11 +11674,11 @@ var tabLabel = (page) => page.title || shortAddress(page.url) || t("New tab"); function PanelStrip({ bot, subject, view, ids, active, reading, onView, actions, useBrowserPages }) { const pages = useBrowserPages((map) => map); @@ -15835,7 +13994,7 @@ diff --git a/client.js b/client.js const box = wrap.current; const capsule = ind.current; if (!box || !capsule) return void 0; -@@ -11707,7 +11707,7 @@ function PanelStrip({ bot, subject, view, ids, active, reading, onView, actions, +@@ -11707,7 +11707,7 @@ trail.cancel(); }; }); @@ -15844,7 +14003,7 @@ diff --git a/client.js b/client.js const box = wrap.current; if (!box) return void 0; const observer = new ResizeObserver(() => { -@@ -11721,13 +11721,13 @@ function PanelStrip({ bot, subject, view, ids, active, reading, onView, actions, +@@ -11721,13 +11721,13 @@ observer.observe(box); return () => observer.disconnect(); }, []); @@ -15861,7 +14020,7 @@ diff --git a/client.js b/client.js "button", { type: "button", -@@ -11737,13 +11737,13 @@ function PanelStrip({ bot, subject, view, ids, active, reading, onView, actions, +@@ -11737,13 +11737,13 @@ title: bot.name, onClick: () => onView("details") }, @@ -15878,7 +14037,7 @@ diff --git a/client.js b/client.js "button", { key: tabId, -@@ -11764,9 +11764,9 @@ function PanelStrip({ bot, subject, view, ids, active, reading, onView, actions, +@@ -11764,9 +11764,9 @@ } } }, @@ -15891,7 +14050,7 @@ diff --git a/client.js b/client.js className: "bt-ptab-x", role: "button", "aria-label": t("Close tab"), -@@ -11778,20 +11778,20 @@ function PanelStrip({ bot, subject, view, ids, active, reading, onView, actions, +@@ -11778,20 +11778,20 @@ }, "×") ); }), @@ -15919,7 +14078,7 @@ diff --git a/client.js b/client.js const element = frame.current; if (!element || leaving || !tabId) { void actions.browserShow(null); -@@ -11817,7 +11817,7 @@ function BrowserView({ bot, tabId, leaving, landed, actions, useBrowserPages }) +@@ -11817,7 +11817,7 @@ window.removeEventListener("resize", schedule); }; }, [tabId, showGuest, leaving]); @@ -15928,7 +14087,7 @@ diff --git a/client.js b/client.js setDraft(null); if (landed && !page.url) input.current?.focus(); }, [tabId, landed]); -@@ -11833,27 +11833,27 @@ function BrowserView({ bot, tabId, leaving, landed, actions, useBrowserPages }) +@@ -11833,27 +11833,27 @@ }; const editing = draft !== null; const secure = /^https:/i.test(page.url); @@ -15965,7 +14124,7 @@ diff --git a/client.js b/client.js ref: input, type: "text", spellCheck: false, -@@ -11877,25 +11877,25 @@ function BrowserView({ bot, tabId, leaving, landed, actions, useBrowserPages }) +@@ -11877,25 +11877,25 @@ } }) ), @@ -16001,7 +14160,7 @@ diff --git a/client.js b/client.js ) : null ) ); -@@ -11907,41 +11907,41 @@ function BotPanel({ pane, leaving, roster, actions, useSessions, useSessionStatu +@@ -11907,41 +11907,41 @@ const tabState = useBrowserTabs((map) => bot ? map[bot.id] ?? EMPTY_TABS2 : EMPTY_TABS2); const active = tabState.active; const page = useBrowserPages((pages) => (active ? pages[active] : null) ?? EMPTY_PAGE); @@ -16059,7 +14218,7 @@ diff --git a/client.js b/client.js let timer; const onResize = () => { setResizing(true); -@@ -11955,28 +11955,28 @@ function BotPanel({ pane, leaving, roster, actions, useSessions, useSessionStatu +@@ -11955,28 +11955,28 @@ window.removeEventListener("resize", onResize); }; }, []); @@ -16095,7 +14254,7 @@ diff --git a/client.js b/client.js if (!page.readAt || Date.now() - page.readAt > READ_FLASH_MS) return void 0; setReading(true); const timer = setTimeout(() => setReading(false), READ_FLASH_MS); -@@ -12013,7 +12013,7 @@ function BotPanel({ pane, leaving, roster, actions, useSessions, useSessionStatu +@@ -12013,7 +12013,7 @@ return actions[name](...args); }; } @@ -16104,7 +14263,7 @@ diff --git a/client.js b/client.js const move = fly.current; if (!move) return; fly.current = null; -@@ -12096,7 +12096,7 @@ function BotPanel({ pane, leaving, roster, actions, useSessions, useSessionStatu +@@ -12096,7 +12096,7 @@ for (const name of ends) grip.addEventListener(name, onEnd); }; const hasStrip = canBrowse && tabState.ids.length > 0; @@ -16113,7 +14272,7 @@ diff --git a/client.js b/client.js "aside", { ref: root, -@@ -12113,15 +12113,15 @@ function BotPanel({ pane, leaving, roster, actions, useSessions, useSessionStatu +@@ -12113,15 +12113,15 @@ if (event.target === event.currentTarget) setLanded(true); } }, @@ -16134,7 +14293,7 @@ diff --git a/client.js b/client.js sessionId: pane.id, startRenaming: pane.renaming, startTab: pane.tab, -@@ -12217,8 +12217,78 @@ var BROWSER_CSS = ` +@@ -12217,8 +12217,78 @@ @media (prefers-reduced-motion:reduce){.bt-ptabs,.bt-panel-view,.bt-bcard,.bt-ptab-name{animation:none}} `; @@ -16214,7 +14373,7 @@ diff --git a/client.js b/client.js var import_react_dom9 = require("react-dom"); var DIALOG_EXIT_MS = 160; var MENU_EXIT_MS3 = 120; -@@ -12227,15 +12227,15 @@ function Overlays({ useRoster, useUi, useSessions, useSessionStatus, useActivity +@@ -12227,15 +12227,15 @@ const ui2 = useUi((value) => value); useRegistry((value) => value); const current = useSessions((list) => Object.values(list.byId).find((session) => (session?.retainedBy?.mainView ?? 0) > 0)?.id ?? null); @@ -16234,7 +14393,7 @@ diff --git a/client.js b/client.js if (open) { setKept(open); return void 0; -@@ -12245,19 +12245,24 @@ function Overlays({ useRoster, useUi, useSessions, useSessionStatus, useActivity +@@ -12245,19 +12245,24 @@ }, [ui2.details, ui2.view, ui2.renaming, ui2.tab]); const pane = open ?? kept; const [settings, settingsLeaving] = useLinger(ui2.settings, DIALOG_EXIT_MS); @@ -16269,7 +14428,7 @@ diff --git a/client.js b/client.js ); } -@@ -12304,7 +12304,7 @@ function readPage(limits) { +@@ -12304,7 +12304,7 @@ `; }; const expand = (text2) => text2.replace(/\u0001(\d+)[\u0002\u0003]/g, (_, index) => expand(stash[Number(index)])); @@ -16278,7 +14437,7 @@ diff --git a/client.js b/client.js const block = (text2) => expand(flow(text2)); const oneLine = (text2) => block(text2).replace(/\s*\n+\s*/g, " ").trim(); const styleOf = (el) => (el.ownerDocument.defaultView || window).getComputedStyle(el); -@@ -12372,7 +12372,7 @@ ${fence}`; +@@ -12372,7 +12372,7 @@ const marker = ordered ? `${number++}. ` : "- "; if (body2 === "") continue; const pad2 = " ".repeat(marker.length); @@ -16287,7 +14446,7 @@ diff --git a/client.js b/client.js } return keep(lines.join("\n")); }; -@@ -12398,11 +12398,11 @@ ${children(cell, ctx)} +@@ -12398,11 +12398,11 @@ let width = 0; for (const row of rows.slice(0, limits.tableRows + 1)) { if (over()) break; @@ -16301,7 +14460,7 @@ diff --git a/client.js b/client.js carry[column].left -= 1; column += 1; } -@@ -12414,14 +12414,14 @@ ${children(cell, ctx)} +@@ -12414,14 +12414,14 @@ const span = Math.max(1, Math.min(limits.tableCols, Number(cell.colSpan) || 1)); const down = Math.max(1, Math.min(limits.tableRows, Number(cell.rowSpan) || 1)); for (let offset = 0; offset < span; offset += 1) { @@ -16319,7 +14478,7 @@ diff --git a/client.js b/client.js } width = Math.min(width, limits.tableCols); if (grid.length === 0 || width === 0) return caption ? ` -@@ -12552,7 +12552,7 @@ ${"#".repeat(Math.min(6, Number(level) || 2))} ${title}${mark} +@@ -12552,7 +12552,7 @@ } if (tag === "blockquote") { const quoted = block(body2); @@ -16328,7 +14487,7 @@ diff --git a/client.js b/client.js } const end = body2.trimEnd().length; const marked = mark ? `${body2.slice(0, end)}${mark}${body2.slice(end)}` : body2; -@@ -13052,11 +13052,63 @@ function serveBrowserReads({ browsers, post, clientId = globalThis.crypto.random +@@ -13052,11 +13052,63 @@ }; } @@ -16393,7 +14552,7 @@ diff --git a/client.js b/client.js function disposeAll(disposers, label) { for (const dispose of [...disposers].reverse()) { try { -@@ -13075,8 +13075,8 @@ function surfaceGroups(install) { +@@ -13075,8 +13075,8 @@ }; return { reconcile(wanted) { @@ -16404,7 +14563,7 @@ diff --git a/client.js b/client.js if (!wanted[group] || mounted[group].length > 0) continue; try { mounted[group] = install[group](); -@@ -13086,7 +13086,7 @@ function surfaceGroups(install) { +@@ -13086,7 +13086,7 @@ } }, dispose() { @@ -16413,7 +14572,7 @@ diff --git a/client.js b/client.js } }; } -@@ -13105,7 +13105,7 @@ function writeMode(mode, storage = globalThis.localStorage) { +@@ -13105,7 +13105,7 @@ } // src/client/user-message.js @@ -16422,7 +14581,7 @@ diff --git a/client.js b/client.js var ui = __toESM(require("@deepseek-ai/dsh-client-ui-primitives"), 1); var canDrawUserMessages = () => typeof ui.projectUserText === "function"; function contentParts(content) { -@@ -13119,10 +13119,41 @@ function contentParts(content) { +@@ -13119,10 +13119,41 @@ } return { text: texts2.join("\n"), attachments, rest }; } @@ -16467,7 +14626,7 @@ diff --git a/client.js b/client.js } function userTextParts(text, bots, referenceLabels = []) { const parts = []; -@@ -13132,43 +13132,44 @@ function userTextParts(text, bots, referenceLabels = []) { +@@ -13132,43 +13132,44 @@ if (span.start > cursor) parts.push({ text: text.slice(cursor, span.start) }); parts.push({ bot: span.bot, label: text.slice(span.start + 1, span.end) }); cursor = span.end; @@ -16523,7 +14682,7 @@ diff --git a/client.js b/client.js } var clock3 = (time) => typeof time === "number" ? new Date(time).toLocaleTimeString(dateLocale(), { hour: "numeric", minute: "2-digit" }) : void 0; function UserMessageCell({ node, sessionId, useRoster, actions, renderMessageImages, openFile, openSkill }) { -@@ -13178,28 +13178,52 @@ function UserMessageCell({ node, sessionId, useRoster, actions, renderMessageIma +@@ -13178,28 +13178,52 @@ const referenceLabels = data.referenceLabels ?? []; const references = openFile && openSkill ? { openFile, openSkill } : void 0; const steering = node.kind === "steering"; @@ -16585,7 +14744,7 @@ diff --git a/client.js b/client.js .bt-ubody a{color:inherit} .bt-umention{display:inline-flex;align-items:center;gap:3px;vertical-align:-3px;max-width:100%;padding:0 1px;border:0;background:none;color:inherit;font:inherit;font-weight:600;line-height:inherit;cursor:pointer;border-radius:6px;white-space:nowrap} .bt-umention:hover{text-decoration:underline;text-underline-offset:2px} -@@ -13216,42 +13216,114 @@ var USER_MESSAGE_CSS = ` +@@ -13216,42 +13216,114 @@ `; // src/client/approval-notices.js @@ -16668,7 +14827,7 @@ diff --git a/client.js b/client.js + timers.clearTimeout?.(timer); + }; } - ++ +// src/client/stats-dock.js +var import_react42 = require("react"); +var STATS_DOCK = "conversation.composer.dock"; @@ -16694,7 +14853,7 @@ diff --git a/client.js b/client.js + return { component: StatsDock, inject: (...args) => shell()?.inject?.(...args) ?? {} }; +} +var statsDocks = (entriesOf) => STATS_IDS.map((id) => ({ id, ...statsDock(entriesOf, id) })); -+ + // src/client/index.js var inject = ["sessions", "uiWorkspace", "uiConversation", "slots", "theme", "layout"]; function apply(ctx) { @@ -16713,7 +14872,7 @@ diff --git a/client.js b/client.js migrateStorage(); const UNREAD_KEY = STORAGE.unread; let savedUnread = {}; -@@ -13274,7 +13274,13 @@ function apply(ctx) { +@@ -13274,7 +13274,13 @@ } catch { seenAt = {}; } @@ -16728,7 +14887,7 @@ diff --git a/client.js b/client.js const surface = createSource({ mode: readMode(), plain: false }); let agentSessionCache = /* @__PURE__ */ new Set(); try { -@@ -13344,13 +13344,25 @@ function apply(ctx) { +@@ -13344,13 +13344,25 @@ } return { ...state, unread }; }); @@ -16755,7 +14914,7 @@ diff --git a/client.js b/client.js if (order < applied) return; applied = order; const nextActivity = JSON.stringify(value.activity ?? {}); -@@ -13368,10 +13368,15 @@ function apply(ctx) { +@@ -13368,10 +13368,15 @@ const live2 = {}; if (value.connectors !== void 0 && JSON.stringify(value.connectors) !== JSON.stringify(current.connectors ?? [])) live2.connectors = value.connectors; if (value.capabilityStatuses !== void 0 && JSON.stringify(value.capabilityStatuses) !== JSON.stringify(current.capabilityStatuses ?? {})) live2.capabilityStatuses = value.capabilityStatuses; @@ -16771,7 +14930,7 @@ diff --git a/client.js b/client.js try { const ids = (value.agents ?? []).flatMap((agent) => agent.sessions ?? []); localStorage.setItem(STORAGE.agentSessions, JSON.stringify(ids)); -@@ -13488,20 +13488,12 @@ function apply(ctx) { +@@ -13488,20 +13488,12 @@ const style = installStyles("", "mentions"); let last = ""; const render = () => { @@ -16794,7 +14953,7 @@ diff --git a/client.js b/client.js return () => { offRoster(); offRegistry(); -@@ -13520,7 +13520,7 @@ function apply(ctx) { +@@ -13520,7 +13520,7 @@ const accent = isHex(prefs.accent) ? prefs.accent : void 0; const motion = prefs.motion === "quiet" || prefs.motion === "lively" ? prefs.motion : "normal"; document.body.dataset.btMotion = motion; @@ -16803,7 +14962,7 @@ diff --git a/client.js b/client.js if (next === key) return; key = next; style.textContent = themeCss(themes[id], accent); -@@ -13536,7 +13536,7 @@ function apply(ctx) { +@@ -13536,7 +13536,7 @@ }; render(); const offRoster = roster.subscribe(render); @@ -16812,7 +14971,7 @@ diff --git a/client.js b/client.js return () => { offRoster(); offRegistry(); -@@ -13588,6 +13588,7 @@ function apply(ctx) { +@@ -13588,6 +13588,7 @@ // or the click appears to do nothing. isAgentSession: (id) => isAgentSessionId(id), openSession: (id) => { @@ -16820,7 +14979,7 @@ diff --git a/client.js b/client.js if (isAgentSessionId(id)) return actions.openAgentSession(id); ui2.set((state) => state.newChat || state.exchange || state.menu || state.palette ? { ...state, newChat: null, exchange: null, menu: null, palette: null } : state); setPlain(false); -@@ -13653,12 +13653,12 @@ function apply(ctx) { +@@ -13653,12 +13653,12 @@ }, // `last` is the Bot conversation the view held before it cleared: when that part // was archived for a fresh one, the view goes on in the fresh one. @@ -16836,7 +14995,7 @@ diff --git a/client.js b/client.js expected = target; ctx.uiWorkspace.openSession(target); }, -@@ -13708,10 +13708,15 @@ function apply(ctx) { +@@ -13708,10 +13708,15 @@ }, // `id` opens one Bot (page 'bots') or one group chat (page 'groups'). openSettings: (page = "general", id = null) => { @@ -16854,7 +15013,7 @@ diff --git a/client.js b/client.js }, openMemory: (botId) => { ui2.set((state) => ({ ...state, memory: botId, menu: null, palette: null })); -@@ -13719,6 +13719,13 @@ function apply(ctx) { +@@ -13719,6 +13719,13 @@ closeMemory: () => { ui2.set((state) => state.memory ? { ...state, memory: null } : state); }, @@ -16868,7 +15027,7 @@ diff --git a/client.js b/client.js openConnectors: () => { ui2.set((state) => ({ ...state, settings: { page: "connectors", id: null }, menu: null, palette: null, newChat: null })); }, -@@ -13728,7 +13728,7 @@ function apply(ctx) { +@@ -13728,7 +13728,7 @@ if (mode !== "bot" && mode !== "agent") return; if (surface.getSnapshot().mode === mode) return; if (mode === "agent") { @@ -16877,7 +15036,7 @@ diff --git a/client.js b/client.js } writeMode(mode); surface.set((state) => ({ ...state, mode })); -@@ -13757,6 +13757,12 @@ function apply(ctx) { +@@ -13757,6 +13757,12 @@ const seen = seenAt[seenKey(id)] ?? (made?.createdBy && Number.isFinite(made.createdAt) ? made.createdAt : null); ui2.set((state) => ({ ...state, visits: { ...state.visits, [id]: { seen, entered: Date.now() } } })); }, @@ -16890,7 +15049,7 @@ diff --git a/client.js b/client.js endVisit: (id) => { leaveVisits([id]); ui2.set((state) => { -@@ -13812,6 +13812,7 @@ function apply(ctx) { +@@ -13812,6 +13812,7 @@ answerQuestion: (sessionId, questionId, answer) => after(call("answer-question", { sessionId: partFor(sessionId), questionId, answer })), dismissQuestion: (sessionId) => after(call("dismiss-question", { sessionId })), answerApproval: (id, choice) => after(call("approval-answer", { id, choice })), @@ -16898,7 +15057,7 @@ diff --git a/client.js b/client.js stopMemberWork: (roomId, botId) => after(call("stop-member-work", { roomId, botId })), // Values go out in these requests and never come back in a response. secretSet: (payload) => after(call("secret-set", payload)), -@@ -13819,15 +13819,24 @@ function apply(ctx) { +@@ -13819,15 +13819,24 @@ secretCancel: (requestId) => after(call("secret-cancel", { requestId })), secretUpdate: (name, scope) => after(call("secret-update", { name, scope })), secretDelete: (name) => after(call("secret-delete", { name })), @@ -16924,7 +15083,7 @@ diff --git a/client.js b/client.js scheduleDelete: (sessionId, id) => call("schedule-delete", { sessionId, id }), exchange: (a, b) => call("exchange", { a, b }).catch(() => []), roomProgress: (roomId) => call("room-progress", { roomId }).catch(() => []), -@@ -13846,6 +13846,8 @@ function apply(ctx) { +@@ -13846,6 +13846,8 @@ noteClientError(where, error); ui2.set((state) => ({ ...state, toast: { text: error?.message ?? String(error), token: Date.now() } })); }, @@ -16933,7 +15092,7 @@ diff --git a/client.js b/client.js dismissToast: (token) => ui2.set((state) => state.toast && (token === void 0 || state.toast.token === token) ? { ...state, toast: null } : state) }; if (browsers) { -@@ -13890,6 +13890,7 @@ function apply(ctx) { +@@ -13890,6 +13890,7 @@ actions.closePanel = () => { ui2.set((state) => state.details ? { ...state, details: null, view: "details", renaming: false, tab: null } : state); }; @@ -16941,7 +15100,7 @@ diff --git a/client.js b/client.js actions.signInEntry = () => ctx.slots.entries?.("settings.models.sign-in")?.[0] ?? null; actions.canSignIn = () => Boolean(actions.signInEntry()); actions.signIn = () => ui2.set((state) => ({ ...state, signIn: (state.signIn ?? 0) + 1 })); -@@ -13964,7 +13964,13 @@ function apply(ctx) { +@@ -13964,7 +13964,13 @@ }); const browserPages = browsers?.pages ?? createSource({}); const browserTabs = browsers?.tabs ?? createSource({}); @@ -16956,7 +15115,7 @@ diff --git a/client.js b/client.js ctx.effect(() => noticeApprovals({ work, roster, -@@ -13989,7 +13989,17 @@ function apply(ctx) { +@@ -13989,7 +13989,17 @@ } }; }; const shadow = (name, options, component) => ctx.slots.inject(name, () => ctx.slots.register({ name, ...options }, component)); @@ -16974,7 +15133,7 @@ diff --git a/client.js b/client.js const installAtMenu = () => { const fiber = ctx.inject(["inputTriggers"], (scope) => { scope.effect(() => { -@@ -14001,7 +14001,12 @@ function apply(ctx) { +@@ -14001,7 +14001,12 @@ const launched = (sessionId) => controllerOf(sessionId)?.launcher.getSnapshot() === CHATS_SOURCE; const offs = atSources({ roster, sessions: ctx.sessions, launched, iconFor: menuIcons(roster) }).map((source) => triggers.registerSource(source)); atMenu = { controllerOf }; @@ -16987,7 +15146,7 @@ diff --git a/client.js b/client.js atMenu = null; for (const off of offs) off(); }; -@@ -14010,6 +14010,14 @@ function apply(ctx) { +@@ -14010,6 +14010,14 @@ return () => fiber.dispose(); }; actions.canReferenceChat = () => atMenu !== null; @@ -17002,7 +15161,7 @@ diff --git a/client.js b/client.js actions.referenceChat = (sessionId, span) => { const controller = atMenu?.controllerOf(sessionId); if (controller === void 0 || span === void 0) return; -@@ -14019,33 +14019,44 @@ function apply(ctx) { +@@ -14019,33 +14019,44 @@ installCompiledStyles(SHELL_CSS, "shell"), installCompiledStyles(SIDEBAR_CSS, "sidebar"), installCompiledStyles(SECRET_CSS, "secrets"), @@ -17051,7 +15210,7 @@ diff --git a/client.js b/client.js shadow("conversation.chat.node", { key: "assistant-step", priority: -10, locale: "chat", inject: assistantFace }, AssistantCell), shadow("conversation.chat.node", { key: "turn-trigger", priority: -10, locale: "chat", inject: face }, TriggerCell), shadow("conversation.chat.node", { key: "tool-call", priority: -10, locale: "chat", inject: face }, ToolCell), -@@ -14057,15 +14057,24 @@ function apply(ctx) { +@@ -14057,15 +14057,24 @@ // every message. shadow("conversation.chat.node", { key: "question-reply", priority: -10, locale: "chat" }, AnsweredCell), shadow("conversation.chat.node", { key: "compaction", priority: -10, locale: "chat" }, Nothing), @@ -17079,7 +15238,7 @@ diff --git a/client.js b/client.js shadow("conversation.input.right", { id: "bot-voice", order: 100, inject: face }, ComposerVoice), shadow("conversation.composer", { priority: -10, -@@ -14074,7 +14074,8 @@ function apply(ctx) { +@@ -14074,7 +14074,8 @@ shadow("conversation.input.dock", { id: "bot-members", order: -120, inject: face }, MemberDock), shadow("conversation.input.dock", { id: "bot-model", order: -110, inject: face }, ModelChoiceDock), shadow("conversation.input.dock", { id: "bot-question", order: -100, inject: face }, QuestionDock), diff --git a/patches/ds-bot/files/src/host/remote-chat.js b/patches/ds-bot/files/src/host/remote-chat.js index bc3c4e215..939901ce4 100644 --- a/patches/ds-bot/files/src/host/remote-chat.js +++ b/patches/ds-bot/files/src/host/remote-chat.js @@ -236,10 +236,9 @@ export function install(rt) { // A published list: every Bot gets its chat; a chat whose Bot left keeps its history, marked gone. async function sync(plugin, list) { // `open()` clears `remote.gone` on a chat that is published again, so remember which chats - // were gone first. Then only a real change of `gone` may touch `hidden`: a chat the user hid - // from the sidebar (`roster.setFlags`) while its Bot is still published must stay hidden, or - // every publish would bring it back, and a returning chat still reappears (its `gone` hide - // is dropped). + // were gone first. `remote.gone` alone says the Bot left; `hidden` stays the user's own flag + // (`roster.setFlags`) and is never written here (#956), so a chat that left keeps its row in the + // sidebar and a returning one cannot carry a hide of the user's away with it. const goneBefore = new Map() for (const bot of chats()) if (bot.remote.plugin === plugin) goneBefore.set(bot.id, Boolean(bot.remote.gone)) const handles = new Set() @@ -256,17 +255,20 @@ export function install(rt) { if (wasGone !== gone) { bot.remote = { ...bot.remote, ...(gone ? { gone: true } : {}) } if (!gone) delete bot.remote.gone - if (gone) { - // Remember when *we* hide the chat because the Bot left the published list: on `hidden` - // alone that is indistinguishable from a hide the user set, so the return below would - // drop the user's own hide too (#631). - if (bot.hidden !== true) bot.remote.hiddenByGone = true - bot.hidden = true - } else if (bot.remote.hiddenByGone === true) { + // The Bot is published again: drop the marker an older version left with the hide it imposed. + if (!gone && bot.remote.hiddenByGone === true) { delete bot.hidden delete bot.remote.hiddenByGone } changed = true + } else if (gone && bot.hidden === true && bot.remote.hiddenByGone === true) { + // A state file written before #956 hid the row we imposed. The row is the conversation's, so + // one sync takes that hide back (#956); a hide the user set carries no marker and stays. The + // marker goes with it — taking the hide back is its only use, and while it stayed a hide the + // user set later read as ours, so the next sync deleted it (the row then ignored the Hide). + delete bot.hidden + delete bot.remote.hiddenByGone + changed = true } } if (changed) await rt.save() diff --git a/patches/ds-bot/files/src/shared/remote-label.js b/patches/ds-bot/files/src/shared/remote-label.js index 45fbdf0b8..8cc588abf 100644 --- a/patches/ds-bot/files/src/shared/remote-label.js +++ b/patches/ds-bot/files/src/shared/remote-label.js @@ -35,6 +35,21 @@ export function isListedRemoteChat(bot) { return true } +// Two questions, two readers (#956). `isListedRemoteChat` answers "may I send to this Bot, offer it +// in a picker, mention it": a share that ended is not a place to send to. The sidebar asks "is this +// the user's conversation": a chat whose Bot left the published list keeps its row, its history and +// its way back, marked as no longer shared. Only a hide the user set (#611) takes a row out, and +// `remote.gone` never doubles as one — the two flags stay apart. +export function isShownRemoteChat(bot) { + if (!bot) return false + if (isListedRemoteChat(bot)) return true + if (bot.remote?.gone !== true) return false + // `hiddenByGone` is the marker a version before #956 wrote beside the hide it imposed: nothing here + // writes it, and `remoteChats.sync` deletes it with that hide, so a state file not yet synced still + // shows the row and a later hide of the user's is left alone. + return bot.hidden !== true || bot.remote.hiddenByGone === true +} + export function listedChatBots(roster) { return (roster?.bots ?? []).filter(isListedRemoteChat) } diff --git a/patches/ds-bot/files/test/flybot-client.test.js b/patches/ds-bot/files/test/flybot-client.test.js index 9e55efbf9..2b9a2763c 100644 --- a/patches/ds-bot/files/test/flybot-client.test.js +++ b/patches/ds-bot/files/test/flybot-client.test.js @@ -223,7 +223,8 @@ test("a friend's Bot sidebar row is its one name, \"Chief (Bob)\", on one line, assert.doesNotMatch(row, /bt-row-owner/) assert.doesNotMatch(styles, /\.bt-row-owner\{/) // Before the first message the second line still says what the row is. - assert.match(row, /const secondLine = awayLine \|\| preview \|\| \(remote \? t\("Friend's Bot"\) : ''\)/) + // #956: the placeholder names the state once the Bot is gone, so a kept row says why it is there. + assert.match(row, /const secondLine = awayLine \|\| preview \|\| \(remote \? \(bot\.remote\?\.gone \? t\(.no longer shared.\) : t\("Friend's Bot"\)\) : ''\)/) // The full name is on hover and in the label. assert.match(row, /title: remote \? shown :/) for (const file of ['header.js', 'brand.js', 'user-message.js', 'at-menu.js']) { diff --git a/patches/ds-bot/files/test/new-chat.test.js b/patches/ds-bot/files/test/new-chat.test.js index 868ac0a2b..8b9b1797f 100644 --- a/patches/ds-bot/files/test/new-chat.test.js +++ b/patches/ds-bot/files/test/new-chat.test.js @@ -1,7 +1,7 @@ import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import { test } from 'node:test' -import { copiedToClipboard, listedChatBots, pickerBotLabel, isHomeLevelRemote, isListedRemoteChat, splitRoomPicks, ungrantedLocalFix } from '../src/shared/remote-label.js' +import { copiedToClipboard, listedChatBots, pickerBotLabel, isHomeLevelRemote, isListedRemoteChat, isShownRemoteChat, splitRoomPicks, ungrantedLocalFix } from '../src/shared/remote-label.js' import { DICTIONARY } from '../src/client/i18n.js' test('picker hides gone, hidden, and home-level remotes; labels include the owner', () => { @@ -56,7 +56,10 @@ test('the sidebar + menu wires plugin actions in after New group chat', () => { const slots = readFileSync(new URL('../src/client/flybot-slots.js', import.meta.url), 'utf8') assert.match(slots, /isListedRemoteChat/) const sidebar = readFileSync(new URL('../src/client/sidebar.js', import.meta.url), 'utf8') - assert.match(sidebar, /isListedRemoteChat\(bot\)/) + // #956: the sidebar keeps a row for a chat we hid because the Bot left; every other reader + // (pickers, @ menu, the model's friend list) keeps the stricter isListedRemoteChat. + assert.match(sidebar, /isShownRemoteChat\(bot\)/) + assert.match(sidebar, /isShownRemoteChat\(bot\)\)\.map/) assert.match(sidebar, /!isPastFaceRoom\(room\)/) assert.match(sidebar, /Number\(foreign\(a\)\) - Number\(foreign\(b\)\)/) // With @flybot/tree on, a friend's Bot is not a tree node under Main; it stays in the flat list. @@ -198,3 +201,25 @@ test('#645: a line typed in the composer is the new Bot\'s first turn, not its i assert.match(src, /onCreated: created/) assert.match(card, /await onCreated\(bot\)/) }) + +// #956: two hides, two flags. `remote.gone` says the Bot left the published list; `hidden` is only +// ever the user's own hide (#611). A chat we hid because it left keeps its row, marked; the user's +// hide wins over everything, and a returned chat is an ordinary row again. +test('#956: a row is kept for a share that ended, dropped for a hide the user set', () => { + const live = { id: 'live', name: 'Chief', remote: { handle: '@chief.hsliu.home', home: '@hsliu.home' } } + const ended = { id: 'ended', name: '助手', remote: { handle: '@assistant.friend.home', home: '@friend.home', gone: true } } + const legacy = { id: 'legacy', name: '旧', hidden: true, remote: { handle: '@old.friend.home', home: '@friend.home', gone: true, hiddenByGone: true } } + const userHidden = { id: 'user', name: 'Coder', hidden: true, remote: { handle: '@coder.bob.home', home: '@bob.home' } } + const userHiddenWhileGone = { id: 'user2', name: 'Coder', hidden: true, remote: { handle: '@coder.bob.home', home: '@bob.home', gone: true } } + assert.equal(isShownRemoteChat(live), true) + assert.equal(isShownRemoteChat(ended), true) + assert.equal(isShownRemoteChat(legacy), true) + assert.equal(isShownRemoteChat(userHidden), false) + assert.equal(isShownRemoteChat(userHiddenWhileGone), false) + assert.equal(isShownRemoteChat({ id: 'local', name: 'Chief' }), true) + assert.equal(isShownRemoteChat(undefined), false) + // The stricter reader is unchanged: a share that ended is still not a place to send to. + assert.equal(isListedRemoteChat(ended), false) + assert.equal(isListedRemoteChat(legacy), false) + assert.equal(isListedRemoteChat(live), true) +}) diff --git a/patches/ds-bot/files/test/remote-chat.test.js b/patches/ds-bot/files/test/remote-chat.test.js index e95a8a240..f5d8f9b09 100644 --- a/patches/ds-bot/files/test/remote-chat.test.js +++ b/patches/ds-bot/files/test/remote-chat.test.js @@ -1,7 +1,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { remoteTurn, isRemoteChat, REMOTE_PROVIDER, install, notSentLine } from '../src/host/remote-chat.js'; -import { isHomeLevelRemote } from '../src/shared/remote-label.js'; +import { isHomeLevelRemote, isListedRemoteChat, isShownRemoteChat } from '../src/shared/remote-label.js'; const user = (content, source, id = 'm1') => ({ role: 'user', id, content, ...(source ? { source } : {}) }); @@ -44,9 +44,12 @@ test('sync hides gone and home-level remotes without deleting the chat', async ( assert.equal(state.bots.a.remote.gone, undefined) assert.equal(state.bots.a.hidden, undefined) assert.equal(state.bots.b.remote.gone, true) - assert.equal(state.bots.b.hidden, true) + // #956: leaving the published list is `remote.gone`, never a `hidden` of the user's. + assert.equal(state.bots.b.hidden, undefined) + assert.equal(isShownRemoteChat(state.bots.b), true) assert.equal(state.bots.c.remote.gone, true) - assert.equal(state.bots.c.hidden, true) + assert.equal(state.bots.c.hidden, undefined) + assert.equal(isShownRemoteChat(state.bots.c), true) assert.equal(state.bots.b.name, 'Friend') await rt.remoteChats.sync('social', [ { handle: '@chief.hsliu.home', name: 'Chief', home: '@hsliu.home' }, @@ -59,7 +62,8 @@ test('sync hides gone and home-level remotes without deleting the chat', async ( ]) assert.equal(state.bots.a.remote.gone, undefined) assert.equal(state.bots.a.hidden, undefined) - assert.equal(state.bots.c.hidden, true) + assert.equal(state.bots.c.remote.gone, true) + assert.equal(isShownRemoteChat(state.bots.c), true) }) test('sync keeps a chat the user hid while the Bot is still published', async () => { @@ -95,6 +99,8 @@ test('a hide the user set survives the Bot leaving the published list and coming await rt.remoteChats.sync('social', []) assert.equal(state.bots.b.remote.gone, true) assert.equal(state.bots.b.hidden, true) + // A hide the user set keeps the row out of the sidebar, gone or not (#611). + assert.equal(isShownRemoteChat(state.bots.b), false) await rt.remoteChats.sync('social', [member]) assert.equal(state.bots.b.remote.gone, undefined) // The hide was the user's, so the return must not drop it. @@ -102,7 +108,7 @@ test('a hide the user set survives the Bot leaving the published list and coming assert.equal(state.bots.b.remote.hiddenByGone, undefined) }) -test('a hide imposed because the Bot left is dropped when it comes back', async () => { +test('#956: a chat whose Bot left keeps its row in the sidebar, and comes back as an ordinary one', async () => { const state = { bots: { b: { id: 'b', name: '助手', remote: { handle: '@assistant.friend.home', home: '@friend.home', plugin: 'social' } }, @@ -113,12 +119,60 @@ test('a hide imposed because the Bot left is dropped when it comes back', async const member = { handle: '@assistant.friend.home', name: '助手', home: '@friend.home' } await rt.remoteChats.sync('social', []) assert.equal(state.bots.b.remote.gone, true) - assert.equal(state.bots.b.hidden, true) - assert.equal(state.bots.b.remote.hiddenByGone, true) + assert.equal(state.bots.b.hidden, undefined) + assert.equal(isShownRemoteChat(state.bots.b), true) await rt.remoteChats.sync('social', [member]) assert.equal(state.bots.b.remote.gone, undefined) assert.equal(state.bots.b.hidden, undefined) assert.equal(state.bots.b.remote.hiddenByGone, undefined) + assert.equal(isShownRemoteChat(state.bots.b), true) +}) + +// A state file written before #956 carries the hide we imposed as a real `hidden`. The next sync +// takes it back, so the row returns without the user doing anything. +test('#956: a hide a previous version imposed is taken back on the next sync', async () => { + const state = { + bots: { + b: { id: 'b', name: '助手', hidden: true, remote: { handle: '@assistant.friend.home', home: '@friend.home', plugin: 'social', gone: true, hiddenByGone: true } }, + }, + } + const rt = { state, load: async () => {}, save: async () => {}, warn() {}, ctx: {} } + install(rt) + await rt.remoteChats.sync('social', []) + assert.equal(state.bots.b.remote.gone, true) + assert.equal(state.bots.b.hidden, undefined) + // The marker goes with the hide it explains: it has that one use, and a marker left behind makes a + // later Hide from sidebar read as ours, so the next sync would delete the user's hide. + assert.equal(state.bots.b.remote.hiddenByGone, undefined) + assert.equal(isShownRemoteChat(state.bots.b), true) + await rt.remoteChats.sync('social', [{ handle: '@assistant.friend.home', name: '助手', home: '@friend.home' }]) + assert.equal(state.bots.b.remote.gone, undefined) + assert.equal(state.bots.b.hidden, undefined) + assert.equal(isShownRemoteChat(state.bots.b), true) +}) + +// The repair above must leave the chat exactly as it found it, so the user's own Hide works from then +// on: set after the repair, it survives the next sync and the Bot coming back (#611, #303, #956). +test('#956: a hide the user sets after the repair keeps the row out, and the Bot coming back changes nothing', async () => { + const state = { + bots: { + b: { id: 'b', name: '助手', hidden: true, remote: { handle: '@assistant.friend.home', home: '@friend.home', plugin: 'social', gone: true, hiddenByGone: true } }, + }, + } + const rt = { state, load: async () => {}, save: async () => {}, warn() {}, ctx: {} } + install(rt) + await rt.remoteChats.sync('social', []) + assert.equal(isShownRemoteChat(state.bots.b), true) + state.bots.b.hidden = true + await rt.remoteChats.sync('social', []) + assert.equal(state.bots.b.hidden, true) + assert.equal(isShownRemoteChat(state.bots.b), false) + assert.equal(state.bots.b.remote.gone, true) + await rt.remoteChats.sync('social', [{ handle: '@assistant.friend.home', name: '助手', home: '@friend.home' }]) + assert.equal(state.bots.b.remote.gone, undefined) + assert.equal(state.bots.b.hidden, true) + assert.equal(isShownRemoteChat(state.bots.b), false) + assert.equal(isListedRemoteChat(state.bots.b), false) }) test('roster and model guards keep a friend\'s Bot on its relay route', async () => {