From c3edb5b1d6b43c981176cbbd61494211f2fe059e Mon Sep 17 00:00:00 2001 From: Day Dreamer <87640074+HugoXK@users.noreply.github.com> Date: Thu, 5 Nov 2026 08:35:42 +0800 Subject: [PATCH] host: a save replaces a linked state.json and the window says so once --- .../p556-linked-state-save-notice.fixed.md | 1 + core/client.js | 38 + core/src/client/i18n.js | 4 + core/src/client/index.js | 12 +- core/src/client/state-link-notice.js | 61 + core/src/host/authz.js | 6 + core/src/host/channel.js | 4 + core/src/host/store.js | 27 +- core/test/state-link-answer.test.js | 99 + core/test/state-link-notice.test.js | 102 + core/test/store.test.js | 69 + docs/subsystems/core-client.md | 26 + docs/subsystems/core-host.md | 2 +- patches/ds-bot/0001-core-fixes.patch | 8 +- patches/ds-bot/0002-flybot-api-host.patch | 52 +- patches/ds-bot/0003-flybot-api-client.patch | 19 +- patches/ds-bot/0005-social-seams.patch | 6 +- patches/ds-bot/0008-client-bundle.patch | 10933 ++++++++-------- patches/ds-bot/0014-flybot-branding.patch | 76 + .../files/src/client/state-link-notice.js | 61 + patches/ds-bot/files/src/host/authz.js | 6 + .../files/test/state-link-answer.test.js | 99 + .../files/test/state-link-notice.test.js | 102 + 23 files changed, 6074 insertions(+), 5739 deletions(-) create mode 100644 changelog.d/p556-linked-state-save-notice.fixed.md create mode 100644 core/src/client/state-link-notice.js create mode 100644 core/test/state-link-answer.test.js create mode 100644 core/test/state-link-notice.test.js create mode 100644 patches/ds-bot/files/src/client/state-link-notice.js create mode 100644 patches/ds-bot/files/test/state-link-answer.test.js create mode 100644 patches/ds-bot/files/test/state-link-notice.test.js diff --git a/changelog.d/p556-linked-state-save-notice.fixed.md b/changelog.d/p556-linked-state-save-notice.fixed.md new file mode 100644 index 000000000..0cd87eba4 --- /dev/null +++ b/changelog.d/p556-linked-state-save-notice.fixed.md @@ -0,0 +1 @@ +A save over a team file that is a link no longer writes through it: the name becomes a regular file of this home's own, the file the link pointed at keeps every byte and its last write time, and the window says so once (`Saved as a separate file; the file the link pointed at was not changed.`) — the count carries the host process's generation, so a restart cannot swallow the first notice, and a signed-in deployment receives it too. diff --git a/core/client.js b/core/client.js index d20e8c25c..87ca71285 100644 --- a/core/client.js +++ b/core/client.js @@ -1014,6 +1014,9 @@ var ZH = { "{bot} asked for {name}": "{bot} 请求了 {name}", "Allowed {bot} to use {name}": "已允许 {bot} 使用 {name}", "Saved {name}": "已保存 {name}", + // P-556: the store replaced a linked `state.json` with a file of this home's own and never wrote + // through the link, so the file the person linked keeps every byte. + "Saved as a separate file; the file the link pointed at was not changed.": "已保存为独立文件,原链接指向的内容未改动。", Canceled: "已取消", Done: "完成", // #1855: the one collapsed group that holds a Project's finished cards. @@ -21984,6 +21987,39 @@ function writeMode(mode, storage = globalThis.localStorage) { } } +// src/client/state-link-notice.js +var LINK_REPLACED_NOTICE = "Saved as a separate file; the file the link pointed at was not changed."; +var stateLinkCount = (value) => { + const field = value?.stateFileLinkReplaced; + if (!field || typeof field !== "object") return null; + const gen = typeof field.gen === "string" ? field.gen : ""; + const count = Number(field.count); + if (gen === "" || !Number.isFinite(count) || count < 0) return null; + return { gen, count: Math.floor(count) }; +}; +var stateLinkMemory = (seen) => { + if (!seen || typeof seen !== "object") return null; + const gen = typeof seen.gen === "string" ? seen.gen : ""; + if (gen === "") return null; + const count = Number(seen.count); + return { gen, count: Number.isFinite(count) && count > 0 ? Math.floor(count) : 0 }; +}; +var stateLinkNotice = (value, seen) => { + const known = stateLinkMemory(seen); + const answer = stateLinkCount(value); + if (answer === null) return { seen: known ?? { gen: "", count: 0 }, announce: false }; + if (known === null) return { seen: answer, announce: false }; + if (answer.gen !== known.gen) { + return answer.count > 0 ? { seen: answer, announce: true } : { seen: { gen: answer.gen, count: 0 }, announce: false }; + } + return answer.count > known.count ? { seen: answer, announce: true } : { seen: known, announce: false }; +}; +var announceStateLink = (value, seen, say) => { + const next = stateLinkNotice(value, seen); + if (next.announce) say?.(LINK_REPLACED_NOTICE); + return next.seen; +}; + // src/client/user-message.js var import_react48 = require("react"); var ui = __toESM(require("@deepseek-ai/dsh-client-ui-primitives"), 1); @@ -22398,6 +22434,7 @@ function apply(ctx) { const refreshed = /* @__PURE__ */ new Set(); let pulls = 0; let applied = 0; + let linkSeen = null; let reloading = 0; const pull = async ({ fresh = false } = {}) => { const order = ++pulls; @@ -22430,6 +22467,7 @@ function apply(ctx) { live2.pluginTools = value.pluginTools; } if (Object.keys(live2).length > 0 && current.ready) roster.set({ ...current, ...live2 }); + linkSeen = announceStateLink(value, linkSeen, (text2) => actions.notify(t(text2))); if (stateAnswerIsStale(value, current, { fresh })) return; if (Object.keys(seenAt).length === 0) leaveVisits([...value.bots, ...value.rooms].map((entry) => entry.id)); roster.set(indexRoster(value)); diff --git a/core/src/client/i18n.js b/core/src/client/i18n.js index cf9c788fe..f695425a9 100644 --- a/core/src/client/i18n.js +++ b/core/src/client/i18n.js @@ -963,6 +963,10 @@ const ZH = { '{bot} asked for {name}': '{bot} 请求了 {name}', 'Allowed {bot} to use {name}': '已允许 {bot} 使用 {name}', 'Saved {name}': '已保存 {name}', + // P-556: the store replaced a linked `state.json` with a file of this home's own and never wrote + // through the link, so the file the person linked keeps every byte. + 'Saved as a separate file; the file the link pointed at was not changed.': + '已保存为独立文件,原链接指向的内容未改动。', Canceled: '已取消', Done: '完成', // #1855: the one collapsed group that holds a Project's finished cards. diff --git a/core/src/client/index.js b/core/src/client/index.js index ec06591b2..43832b380 100644 --- a/core/src/client/index.js +++ b/core/src/client/index.js @@ -42,7 +42,8 @@ import { PROJECTS_NAV_CSS } from './projects-nav.js' import { STORAGE, migrateStorage } from './storage.js' import { friendLineNews } from './friend-lines.js' import { groupsFor, readMode, surfaceGroups, writeMode } from './surface.js' -import { installLocale, language } from './i18n.js' +import { installLocale, language, t } from './i18n.js' +import { announceStateLink } from './state-link-notice.js' import { noteClientError, noteNavigation, quietly } from './diagnostics.js' import { ErrorToast, FEEDBACK_CSS } from './feedback.js' import { ABOUT_CSS } from './about.js' @@ -172,6 +173,11 @@ export function apply(ctx) { // Pulls can overlap (the poll and a navigation); an older answer never wins. let pulls = 0 let applied = 0 + // P-556: the count of saves that replaced a linked `state.json`, as far as this window has told + // the person, with the host generation it belongs to: the count moves no revision, so it is read on + // every answer, `unchanged` included. `null` until the first answer: a window that has just opened + // takes that count as its baseline, so a refresh never replays a notice about an older save. + let linkSeen = null // A Reload in flight makes the poll stand back. Otherwise the loop's `unchanged` answer can take // the applied slot first and the full answer the press asked for is dropped as the older one. let reloading = 0 @@ -197,6 +203,10 @@ export function apply(ctx) { // Plugin tool names come with a plugin reload, not a revision; context rows re-read them (#419). if (value.pluginTools !== undefined && JSON.stringify(value.pluginTools) !== JSON.stringify(current.pluginTools ?? [])) { setPluginTools(value.pluginTools); live.pluginTools = value.pluginTools } if (Object.keys(live).length > 0 && current.ready) roster.set({ ...current, ...live }) + // P-556: a save whose name was a link replaced it with a file of this home's own instead of + // writing through the link. The count rides every answer, the `unchanged` one too, so the + // window can say it once — before the staleness check, which drops the answer's team, not this. + linkSeen = announceStateLink(value, linkSeen, text => actions.notify(t(text))) if (stateAnswerIsStale(value, current, { fresh })) return // On a browser with no visit record yet, everything already there counts as seen. if (Object.keys(seenAt).length === 0) leaveVisits([...value.bots, ...value.rooms].map(entry => entry.id)) diff --git a/core/src/client/state-link-notice.js b/core/src/client/state-link-notice.js new file mode 100644 index 000000000..59ca545d5 --- /dev/null +++ b/core/src/client/state-link-notice.js @@ -0,0 +1,61 @@ +// P-556: a save over a linked `state.json` replaces the name with a regular file of this home's own +// and never writes through the link, so the file the person linked keeps every byte and its last +// write time. The host counts those saves and the state answer carries the count together with the +// generation of the host process that made it — a restart starts the count at 0 again — and a window +// that has not said so yet says it once, in the same toast the client shows for confirmations. +// `seen` is what this window has already announced, remembered between polls: `{ gen, count }`. +export const LINK_REPLACED_NOTICE = 'Saved as a separate file; the file the link pointed at was not changed.' + +// What one answer says about one host process: the generation that counted, and how many of its saves +// replaced a link. Anything else — a missing field, a bare number, negative or unreadable counts, a +// count with no generation — is not a comparable count, so it answers null and announces nothing. +export const stateLinkCount = (value) => { + const field = value?.stateFileLinkReplaced + if (!field || typeof field !== 'object') return null + const gen = typeof field.gen === 'string' ? field.gen : '' + const count = Number(field.count) + if (gen === '' || !Number.isFinite(count) || count < 0) return null + return { gen, count: Math.floor(count) } +} + +// What this window remembers, or null when it remembers nothing: a page that has just loaded has no +// memory, and its first count is a baseline rather than news. +export const stateLinkMemory = (seen) => { + if (!seen || typeof seen !== 'object') return null + const gen = typeof seen.gen === 'string' ? seen.gen : '' + if (gen === '') return null + const count = Number(seen.count) + return { gen, count: Number.isFinite(count) && count > 0 ? Math.floor(count) : 0 } +} + +// A count is only comparable inside the process that made it: a new host process starts it at 0, so +// the first save of a restarted host can carry a number the window already passed. Three cases, and +// they are different questions (Security, on review of #2473): +// - no memory (a page that has just loaded): the answer's count becomes the baseline and nothing is +// said, so a refresh never replays a notice about a save the person was already told about; +// - memory under another generation (the host restarted while this window stayed open): the baseline +// resets to 0 — not to the answer's count, which would swallow the very save that came with the +// restart — so `count > 0` says that save once; +// - memory under the same generation: the usual `count > known`, quiet on a repeated `unchanged` and +// on a host that replaced no link. +export const stateLinkNotice = (value, seen) => { + const known = stateLinkMemory(seen) + const answer = stateLinkCount(value) + if (answer === null) return { seen: known ?? { gen: '', count: 0 }, announce: false } + if (known === null) return { seen: answer, announce: false } + if (answer.gen !== known.gen) { + return answer.count > 0 + ? { seen: answer, announce: true } + : { seen: { gen: answer.gen, count: 0 }, announce: false } + } + return answer.count > known.count ? { seen: answer, announce: true } : { seen: known, announce: false } +} + +// One poll's worth of that decision: say the sentence when the count is news, and answer what the +// window now remembers. A window that cannot draw (no `say`) still remembers, so a line it missed +// while it could not draw is not repeated once it can. +export const announceStateLink = (value, seen, say) => { + const next = stateLinkNotice(value, seen) + if (next.announce) say?.(LINK_REPLACED_NOTICE) + return next.seen +} diff --git a/core/src/host/authz.js b/core/src/host/authz.js index 16cead28e..990c87d32 100644 --- a/core/src/host/authz.js +++ b/core/src/host/authz.js @@ -229,6 +229,12 @@ export function install(rt) { memoryReviews: alone() ? full.memoryReviews : 0, connectors: alone() ? full.connectors : [], capabilityStatuses: pick(full.capabilityStatuses), + // P-556: how many saves of this host replaced a linked `state.json`, with the generation of the + // process that counted them. This is a fact about the host, not about one member's data, so it + // reaches a signed-in deployment like an unsigned one: absent from this whitelist it reached + // nobody at all, and the notice was silently dead wherever anyone had signed in (Security, on + // review of #2473). + stateFileLinkReplaced: full.stateFileLinkReplaced ?? { gen: '', count: 0 }, } } diff --git a/core/src/host/channel.js b/core/src/host/channel.js index 85cec7d63..df5bbbc94 100644 --- a/core/src/host/channel.js +++ b/core/src/host/channel.js @@ -216,6 +216,10 @@ export function install(rt) { memoryReviews: rt.memoryReviews(), connectors: rt.connectorsView(), capabilityStatuses: rt.capabilityStatusesView(), + // P-556: a save that replaced a linked `state.json` with a regular file keeps the team + // revision, so the count rides the live part of every answer — the `unchanged` one included — + // together with the generation of the host process that counted it (a restart starts over). + stateFileLinkReplaced: rt.stateLinkReplaced?.() ?? { gen: '', count: 0 }, } // Plugin tool names change with a plugin reload, not with the team revision, so they ride every // answer, unchanged or not, and the client re-reads its hidden-row set (#419). diff --git a/core/src/host/store.js b/core/src/host/store.js index 84c35caf9..b2eef423c 100644 --- a/core/src/host/store.js +++ b/core/src/host/store.js @@ -3,7 +3,7 @@ // or group chat it belongs to. import { randomUUID } from 'node:crypto' import { constants, mkdirSync, statSync } from 'node:fs' -import { copyFile, mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises' +import { copyFile, lstat, mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { LEGACY_THEMES, MAX_ENTRY, MAX_EXCHANGES, ROOM_MODES } from './constants.js' import { homeLock } from './home-lock.js' @@ -43,6 +43,16 @@ export function install(rt) { // Every save this host has accepted since it started — a monotonic watermark, never decremented // — and the chain's tail: what `savesQueued` and `savesSettled` answer (#1368). let queued = 0 + // Saves that replaced a linked `state.json` with a regular file (P-556): the host never writes + // through the link, so the file the person linked keeps every byte and stays where it is, and the + // path they opened is a file of this home's own from that save on. Counted once per save so a + // window can say what happened instead of leaving them to wonder. + // + // The count is answered with the generation of this process (Chief, on review): a restarted host + // starts counting at 0 again, and a window holding a larger count from the previous process would + // read `1 > 2` as old news and never say the save that came with the restart. + const linkGeneration = randomUUID() + let linkReplaced = 0 const { warn } = rt // Opt-in (#511): every saved change is also recorded for replay; off by default. // Opening the writer is itself a write — it creates `journal/` (0700) and repairs a half-written @@ -920,6 +930,15 @@ export function install(rt) { return true } + // A name, not its target: `lstat` answers what the path is, and a save is the only place that + // turns a link into a regular file. + const isLink = async path => { + try { + return (await lstat(path)).isSymbolicLink() + } catch { + return false + } + } const save = () => { if (stopped) return writing indexSessions() @@ -957,7 +976,12 @@ export function install(rt) { ensureLock() await writeFile(temp, snapshot, 'utf8') ensureLock() + // The rename replaces the name, link included, so a linked team file is never written + // through: its target keeps every byte and its last write time. Ask the name here, before + // the rename takes it, and count the save that did it. + const wasLink = await isLink(statePath) await rename(temp, statePath) + if (wasLink) linkReplaced += 1 seen = stamp() await journal?.record(snapshot) rt.capabilitiesStateSaved?.() @@ -1086,6 +1110,7 @@ export function install(rt) { Object.assign(rt, { state, load, save, refresh, readOnly, mutate, savesQueued, savesSettled, journal, + stateLinkReplaced: () => ({ gen: linkGeneration, count: linkReplaced }), botOf, roomOf, isMain, chatOf, selfOf, isBotSession, chatOwners, groupOwners, pastOwners, groupSessionsOf, dropGroupSession, findBot, findRooms, isAdminOf, diff --git a/core/test/state-link-answer.test.js b/core/test/state-link-answer.test.js new file mode 100644 index 000000000..7816269ea --- /dev/null +++ b/core/test/state-link-answer.test.js @@ -0,0 +1,99 @@ +// P-556: a save that replaces a linked `state.json` with a regular file does not move the team +// revision, so the count that tells a window about it has to ride every state answer — the +// `unchanged` one included. A window polling with `since` (the normal case) would otherwise never +// learn that its save left the linked file alone. +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { install as installAuthz } from '../src/host/authz.js' +import { install } from '../src/host/channel.js' + +function channel(stateLinkReplaced, { signedIn = false } = {}) { + const routes = [] + const ctx = { + effect: (execute) => { + execute() + return () => {} + }, + connection: { fetch: { register: (route) => { routes.push(route); return () => {} } } }, + logger: { warn() {} }, + } + const rt = { + ctx, + // A deployment that has seen a sign-in is the case that matters here: authz then narrows the live + // part of the answer through its own `ownedLive` whitelist instead of passing everything on. + state: { + revision: 'r1', + bots: {}, + rooms: {}, + members: signedIn ? { m1: { id: 'm1', displayName: 'Me' } } : {}, + authSessions: signedIn ? { token1: { memberId: 'm1' } } : {}, + }, + authenticate: async token => (signedIn && token === 'token1' ? { memberId: 'm1', displayName: 'Me' } : null), + warn: () => {}, + noteFailure: () => {}, + refresh: async () => {}, + load: async () => {}, + bootstrap: async () => {}, + catalog: async () => {}, + loadSecrets: async () => {}, + readOnly: () => undefined, + authorize: () => ({ ok: true }), + activityView: () => ({}), + busyAcksView: () => ({}), + workView: () => ({}), + roomWorkView: () => ({}), + approvalsView: () => [], + endedView: () => [], + memoryNews: () => ({}), + memoryReviews: () => [], + connectorsView: () => [], + capabilityStatusesView: () => ({}), + ownedLive: (full) => full, + pluginToolNames: () => [], + stateLinkReplaced, + } + installAuthz(rt) + install(rt) + const call = async (endpoint, payload = {}) => (await routes[0].fetch(new Request('http://localhost/api/bot', { + method: 'POST', + headers: { 'content-type': 'application/json', ...(signedIn ? { 'x-flybot-member-session': 'token1' } : {}) }, + body: JSON.stringify({ endpoint, payload }), + }))).json() + return { call, rt } +} + +test('P-556: an unchanged state answer still carries the count and the generation it belongs to', async () => { + const { call } = channel(() => ({ gen: 'host-b', count: 2 })) + const value = (await call('state', { since: 'r1' })).value + assert.equal(value.unchanged, true, 'the same revision is answered as unchanged') + assert.deepEqual(value.stateFileLinkReplaced, { gen: 'host-b', count: 2 }, + 'the count rides the unchanged answer too, together with the process that made it') +}) + +test('P-556: a host that replaced no link answers 0 with its generation, never a missing field', async () => { + const { call } = channel(() => ({ gen: 'host-a', count: 0 })) + const value = (await call('state', { since: 'r1' })).value + assert.deepEqual(value.stateFileLinkReplaced, { gen: 'host-a', count: 0 }) +}) + +// A host that never installed the counter (an older build answered the same endpoint) must still +// answer a shape a client can read: no field at all would be a `undefined` the notice has to guess at. +test('P-556: a host with no counter answers the empty generation and zero', async () => { + const { call } = channel(undefined) + const value = (await call('state', { since: 'r1' })).value + assert.deepEqual(value.stateFileLinkReplaced, { gen: '', count: 0 }) +}) + +// Security, on review of the first tip: `channel.js` sends the live part through `rt.ownedLive(full, +// actor)`, and authz.js answers an explicit whitelist once anyone has signed in — a field it does not +// name never reaches the client, and the notice would silently never fire on any real deployment. +// This case drives that real whitelist: signed in, an actor resolved from the session header, and the +// count has to be in the answer with its generation. +test('P-556: a signed-in deployment still receives the count through the real ownedLive whitelist', async () => { + const { call, rt } = channel(() => ({ gen: 'host-a', count: 3 }), { signedIn: true }) + assert.equal(rt.authzEnforced(), true, 'this case runs on a deployment where authz narrows the answer') + const value = (await call('state', { since: 'r1' })).value + assert.deepEqual(value.stateFileLinkReplaced, { gen: 'host-a', count: 3 }, + 'the count and its generation survive the authz whitelist') + assert.equal(value.unchanged, true, 'on the unchanged answer too, which is the one a poll gets') +}) diff --git a/core/test/state-link-notice.test.js b/core/test/state-link-notice.test.js new file mode 100644 index 000000000..929696aff --- /dev/null +++ b/core/test/state-link-notice.test.js @@ -0,0 +1,102 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { DICTIONARY } from '../src/client/i18n.js' +import { LINK_REPLACED_NOTICE, announceStateLink, stateLinkNotice } from '../src/client/state-link-notice.js' + +// P-556: the state answer carries the count of saves that replaced a linked `state.json`, together +// with the generation of the host process that counted them. A restarted host starts counting at 0 +// again, so a count is only comparable with one from the same generation — and the first save after a +// restart can carry a smaller number than the one the window remembers, which a bare `count > known` +// would swallow (Chief, on review). + +// Both orders a window can meet a restart in. (1) It polls the restarted host before the save that +// replaced a link: `{B,0}` first, then `{B,1}` — the save is said. (2) The restart and the save both +// land between two polls and the window sees `{B,1}` while it remembers `{A,2}` — that save is said +// too, which is what resetting to 0 (not to the answer's own count) buys. +test('a count from a new host process starts over and still says the save that came with it', () => { + const afterRestart = stateLinkNotice({ stateFileLinkReplaced: { gen: 'B', count: 0 } }, { gen: 'A', count: 2 }) + assert.deepEqual(afterRestart, { seen: { gen: 'B', count: 0 }, announce: false }, + 'a restarted host with no replaced link says nothing and the window forgets the old count') + const thenSave = stateLinkNotice({ stateFileLinkReplaced: { gen: 'B', count: 1 } }, afterRestart.seen) + assert.deepEqual(thenSave, { seen: { gen: 'B', count: 1 }, announce: true }, + 'the save after the restart is said once') + + const bothAtOnce = stateLinkNotice({ stateFileLinkReplaced: { gen: 'B', count: 1 } }, { gen: 'A', count: 2 }) + assert.deepEqual(bothAtOnce, { seen: { gen: 'B', count: 1 }, announce: true }, + 'a restart and its save seen in the same answer is said, not swallowed by the older, larger count') +}) + +test('within one host process the count is said once, and a later replaced link is news again', () => { + const first = stateLinkNotice({ stateFileLinkReplaced: { gen: 'A', count: 1 } }, { gen: 'A', count: 0 }) + assert.deepEqual(first, { seen: { gen: 'A', count: 1 }, announce: true }) + // The next poll carries the same count: the window stays quiet and keeps what it knows. + assert.deepEqual(stateLinkNotice({ stateFileLinkReplaced: { gen: 'A', count: 1 } }, first.seen), + { seen: { gen: 'A', count: 1 }, announce: false }) + // A second save that replaced a link is news again. + assert.deepEqual(stateLinkNotice({ stateFileLinkReplaced: { gen: 'A', count: 2 } }, first.seen), + { seen: { gen: 'A', count: 2 }, announce: true }) + // An older answer from the same process never re-announces and never walks the window back. + assert.deepEqual(stateLinkNotice({ stateFileLinkReplaced: { gen: 'A', count: 1 } }, { gen: 'A', count: 2 }), + { seen: { gen: 'A', count: 2 }, announce: false }) +}) + +// A window that has just loaded the page remembers nothing, and it must not replay what an older +// window already said: the count it meets is its baseline, not news (Security, on review of #2473). +test('a window with no memory takes the first count as its baseline and says nothing', () => { + assert.deepEqual(stateLinkNotice({ stateFileLinkReplaced: { gen: 'A', count: 2 } }, null), + { seen: { gen: 'A', count: 2 }, announce: false }) + assert.deepEqual(stateLinkNotice({ stateFileLinkReplaced: { gen: 'A', count: 2 } }, undefined), + { seen: { gen: 'A', count: 2 }, announce: false }) + assert.deepEqual(stateLinkNotice({ stateFileLinkReplaced: { gen: 'A', count: 2 } }, { gen: '', count: 0 }), + { seen: { gen: 'A', count: 2 }, announce: false }, 'an empty seed is no memory either') + // From that baseline on, the ordinary same-generation rule applies again. + assert.deepEqual(stateLinkNotice({ stateFileLinkReplaced: { gen: 'A', count: 3 } }, { gen: 'A', count: 2 }), + { seen: { gen: 'A', count: 3 }, announce: true }) + assert.deepEqual(stateLinkNotice({ stateFileLinkReplaced: { gen: 'A', count: 2 } }, { gen: 'A', count: 2 }), + { seen: { gen: 'A', count: 2 }, announce: false }) +}) + +test('a host that replaced no link says nothing, and a window that knows nothing stays quiet', () => { + assert.deepEqual(stateLinkNotice({ stateFileLinkReplaced: { gen: 'A', count: 0 } }, { gen: 'A', count: 0 }), + { seen: { gen: 'A', count: 0 }, announce: false }) + assert.deepEqual(stateLinkNotice({ stateFileLinkReplaced: { gen: 'A', count: 0 } }, { gen: '', count: 0 }), + { seen: { gen: 'A', count: 0 }, announce: false }, 'a first answer with nothing to say still records its generation') + assert.deepEqual(stateLinkNotice({}, { gen: 'A', count: 1 }), { seen: { gen: 'A', count: 1 }, announce: false }) + assert.deepEqual(stateLinkNotice(undefined, { gen: 'A', count: 1 }), { seen: { gen: 'A', count: 1 }, announce: false }) +}) + +test('junk is not a count and leaves what the window knows alone', () => { + const known = { gen: 'A', count: 1 } + assert.deepEqual(stateLinkNotice({ stateFileLinkReplaced: { gen: 'B', count: 'many' } }, known), + { seen: known, announce: false }) + assert.deepEqual(stateLinkNotice({ stateFileLinkReplaced: { gen: 'B', count: -1 } }, known), + { seen: known, announce: false }) + assert.deepEqual(stateLinkNotice({ stateFileLinkReplaced: { count: 3 } }, known), + { seen: known, announce: false }, 'a count with no generation cannot be compared with anything') + assert.deepEqual(stateLinkNotice({ stateFileLinkReplaced: 3 }, known), + { seen: known, announce: false }, 'the old bare number is not a count either') +}) + +// The sentence a person reads is interface copy, so it lives in the dictionary with a zh entry. +test('the link-replaced notice is one dictionary line in both languages', () => { + assert.equal(LINK_REPLACED_NOTICE, 'Saved as a separate file; the file the link pointed at was not changed.') + assert.equal(DICTIONARY.zh[LINK_REPLACED_NOTICE], '已保存为独立文件,原链接指向的内容未改动。') +}) + +// P-556: the window says it once. The count is remembered between polls, and the sentence it shows +// is the dictionary line, translated where the window draws it. +test('the announce step says the sentence once and keeps the count it remembers', () => { + const said = [] + const say = text => said.push(text) + let seen = announceStateLink({ stateFileLinkReplaced: { gen: 'A', count: 1 } }, { gen: 'A', count: 0 }, say) + assert.deepEqual(said, [LINK_REPLACED_NOTICE], 'the first answer says it once') + seen = announceStateLink({ stateFileLinkReplaced: { gen: 'A', count: 1 } }, seen, say) + assert.deepEqual(said, [LINK_REPLACED_NOTICE], 'the same count again says nothing') + seen = announceStateLink({ stateFileLinkReplaced: { gen: 'B', count: 1 } }, seen, say) + assert.equal(said.length, 2, 'the save after a restart is news again') + assert.deepEqual(seen, { gen: 'B', count: 1 }) + assert.deepEqual(announceStateLink({}, seen, say), { gen: 'B', count: 1 }, 'an answer with no count keeps what the window knows') + assert.equal(said.length, 2, 'and says nothing') + // A window that cannot draw says nothing and still remembers, so it does not repeat later. + assert.deepEqual(announceStateLink({ stateFileLinkReplaced: { gen: 'B', count: 2 } }, seen), { gen: 'B', count: 2 }) +}) diff --git a/core/test/store.test.js b/core/test/store.test.js index 8225315eb..f106c00d5 100644 --- a/core/test/store.test.js +++ b/core/test/store.test.js @@ -62,6 +62,75 @@ function runtime(t, saved, { home: at, config } = {}) { return { rt, home, statePath, warnings, dispose } } +// P-556 (Chief's ruling: no write-through): the store never writes through a link. The rename +// replaces the name, link included, so the target keeps every byte and its last write time, and the +// path a person opened is a regular file of this home's own from that save on. +test('a save over a linked state.json replaces the link and leaves its target alone', async (t) => { + const elsewhere = fs.mkdtempSync(join(tmpdir(), 'ds-bot-store-target-')) + t.after(() => fs.rmSync(elsewhere, { recursive: true, force: true, maxRetries: 5 })) + const target = join(elsewhere, 'shared-team.json') + + // A team file this host wrote itself: a hand-written `state.json` is refused at load (member + // records missing), and a store in `stuck` never saves, so the link would survive untouched. + const { rt, statePath } = runtime(t) + await rt.load() + await rt.save() + fs.renameSync(statePath, target) + const kept = fs.readFileSync(target) + const before = fs.lstatSync(target) + fs.symlinkSync(target, statePath) + await rt.save() + + assert.equal(fs.lstatSync(statePath).isSymbolicLink(), false, 'the save replaced the link with a regular file') + const after = fs.lstatSync(target) + assert.deepEqual(fs.readFileSync(target), kept, 'not one byte was written through the link') + assert.equal(after.mtimeMs, before.mtimeMs, 'the target was not touched') + assert.equal(fs.existsSync(`${statePath}.${process.pid}.tmp`), false, 'no temp name is left behind') + assert.match(fs.readFileSync(statePath, 'utf8'), /"version": 2/, 'the regular file is the team the save wrote') +}) + +// P-556: the window can only say "saved as a file of its own, the link's target unchanged" if the +// host counts that save, so the count is the state face's token: it starts at 0, goes up by one for +// the save that replaced a link, and stays put for the saves over the regular file that followed. +// The count is answered with the generation of the process that made it (Chief, on review): a +// restarted host starts counting at 0 again, and a bare 0 would read to a window as an older, smaller +// count, so the save that came with the restart would never be said. +test('a save that replaced a link is counted once, with the generation it belongs to', async (t) => { + const elsewhere = fs.mkdtempSync(join(tmpdir(), 'ds-bot-store-target-')) + t.after(() => fs.rmSync(elsewhere, { recursive: true, force: true, maxRetries: 5 })) + const target = join(elsewhere, 'shared-team.json') + fs.writeFileSync(target, '{"version":2,"bots":{}}\n') + + const home = fs.mkdtempSync(join(tmpdir(), 'ds-bot-store-')) + const statePath = join(home, 'state.json') + const first = runtime(t, undefined, { home }) + await first.rt.load() + await first.rt.save() + const start = first.rt.stateLinkReplaced() + assert.equal(start.count, 0, 'a save that replaced no link counts nothing') + assert.equal(typeof start.gen, 'string', 'the count names the process that made it') + assert.ok(start.gen.length >= 8, 'the generation is a stamp of this process, not an empty string') + + fs.renameSync(statePath, target) + fs.rmSync(target, { force: true }) + fs.writeFileSync(target, '{"version":2,"bots":{}}\n') + fs.symlinkSync(target, statePath) + await first.rt.save() + const counted = first.rt.stateLinkReplaced() + assert.equal(counted.count, 1, 'the save that replaced a link is counted') + assert.equal(counted.gen, start.gen, 'the same process keeps the generation it started with') + await first.rt.save() + assert.equal(first.rt.stateLinkReplaced().count, 1, 'a later save over the regular file is not counted again') + + // Another install is another process: its count starts over at 0 under a generation of its own, + // which is what lets a window tell "this host has replaced nothing yet" from "an older count". + const other = runtime(t) + await other.rt.load() + const restarted = other.rt.stateLinkReplaced() + assert.equal(restarted.count, 0) + assert.notEqual(restarted.gen, start.gen, 'each host process answers its own generation') +}) + test('a failed state write rejects and leaves the saved file unchanged', async (t) => { const { rt, statePath } = runtime(t) await rt.load() diff --git a/docs/subsystems/core-client.md b/docs/subsystems/core-client.md index 109447106..ec234494d 100644 --- a/docs/subsystems/core-client.md +++ b/docs/subsystems/core-client.md @@ -483,3 +483,29 @@ inferred from an assignee label. ## Project board metrics and the running table (#1829) `core/src/client/project-board.js` draws the seven cards the host's `project-board` read carries in `metrics`: four day counts and three seven-day medians, each with a one-line caption naming the field and the window. A value the host answered `null` renders `—` (u+2014) and never `0`; an `ok: false` read draws the same cards plus one line naming the host's reason. `metricsView` is pure and takes the host's object as it came. A meeting row also draws the proxy line (#1861): `projectBoardRecord` keeps each meeting's `participants` with their `speaksFor`, and the decided and pending rows render every proxy as ` for {user}` in the same `for {user}` words a task row uses for its holder, with the spoken-for ids also on `data-speaks-for`. The running table below renders `metrics.running` — a claimed card's title, status, holder, claim age (from the claim's own start), source and last activity — marks a row whose claim passed `RUNNING_OVERDUE_MS` and sends a click through the same `onOpen` the board rows use; with no rows it draws one empty line. The Waiting / In flight / Blocked numbers stay on the one-line count header beside those groups. A card's title, priority and board come off the row the host sent and fall back to the `tasks` read joined by id — that read keeps open cards only, so a finished card in the `done` half has no record to join and would otherwise draw as `#` with no priority and no board to open; `boardOfRow` prefers the row's `{ kind, id }` and derives it only when the row carries none, and a `workflow` row keeps no board. A row stored or served before the row carried text still reads exactly as it did. + +## A save over a linked `state.json` says so once (P-556) + +The host never writes through a link at the team file's name: a save replaces the name with a regular +file of this home's own, so the file the person linked keeps every byte and its last write time. That +save moves no team revision, so the count of them rides the live part of every `state` answer +(`stateFileLinkReplaced: { gen, count }`) rather than the revision, and the `unchanged` answer carries +it too. `gen` is the generation stamp of the host process that counted: a restarted host starts at 0 +again, so a count is only comparable inside one generation. +`core/src/client/state-link-notice.js` turns one answer into a decision. `stateLinkNotice(value, seen)` +answers `{ seen, announce }`, where `seen` is what the window now remembers (`{ gen, count }`, or +`{ gen: '', count: 0 }` until it remembers anything) and `announceStateLink(value, seen, say)` also says +the sentence; `index.js` keeps that memory across polls and runs the call before the staleness check +that drops an answer's team, through the same `notify` toast a confirmation uses. Three cases decide it, +and they are different questions: a window with **no memory** (a page that has just loaded) takes the +answer's count as its baseline and says nothing, so a refresh never replays an older notice; a window +whose memory is under **another generation** resets to 0 and says the answer once when its count is +above 0, which is the save that came with the restart — resetting to the answer's own count instead +would swallow exactly that save; and under the **same generation** the usual `count > known` applies, +quiet on a repeated `unchanged` and on a host that replaced no link. Junk is not a count (a bare number, +a negative or unreadable count, a count with no generation) and leaves the memory alone. The sentence is +`Saved as a separate file; the file the link pointed at was not changed.`, in `i18n.js` with its zh line +(已保存为独立文件,原链接指向的内容未改动。). Tests: `core/test/state-link-answer.test.js` (the answer +carries the pair, `unchanged` included, through the real `ownedLive` whitelist on a signed-in +deployment) and `core/test/state-link-notice.test.js` (both restart orders, the refresh baseline, said +once per count, both languages). diff --git a/docs/subsystems/core-host.md b/docs/subsystems/core-host.md index 28107a1ed..8763fa8e8 100644 --- a/docs/subsystems/core-host.md +++ b/docs/subsystems/core-host.md @@ -214,7 +214,7 @@ An admin's `@` used to be the room's control plane: every `@Dev #1234 rebase and - The saved team (`state.json`) is versioned. Version 2 (#47) carries the identity schema — `members` records, a per-Bot `ownerId`, the owner's `designatedChiefId`. A legacy version-1 file is migrated on load with a one-time `state.json.v1` backup; an existing backup counts only when it is a readable file holding a parseable version-1 state, and anything else (or any malformed ownership) fails closed (read-only). An older binary refuses a newer version read-only. This is schema foundation only — team mode stays disabled, see [team](team.md). On top of it, a durable **Project** record (#1691): `state.projects` holds `{id, name, repository, defaultBranch, ownerUserId, createdAt, updatedAt, status}`, `bot.projectId` / `room.projectId` and `state.projectRuns[runId]` bind a member (a group, a Bot or a workflow run) to at most one Project, and the `api/bot` endpoints `projects`, `project-create`, `project-read`, `project-bind`, `project-of` and `project-board` sit in the task allow list. An unbound member resolves `null`, so an install with zero Projects behaves exactly as before. The read-only board over those bindings is `project-board` (#1696): Project id in, `{ tasks: [...] }` out, one row per core task id with `source` (`bot`/`group`/`workflow`), `column`, `assignee`, `updatedAt` and `runId`/`nodeId` when a run holds it, plus the card's own `title` (`null` when the record has none), `priority` (`priorityOf`, `'normal'` when the record stored none) and `board` (the `{ kind, id }` the row opens, `null` on a `workflow` row) so a row draws a card and opens the board it lives on: the open `tasks` read a page joins by id keeps open cards only, so a finished card in the `done` half has no record there and would otherwise draw as `#` with no priority and nowhere to go. `core/src/host/project-board.js` reads `state.tasks` and the bindings and asks the registered providers through `rt.projectRunRefs` (`flybot.tasks.onProjectProvider`); it never reads `workflows.json` and writes nothing. Since #2021 the board's record halves say what their caps left out and cut the right end of the list: the decision read answers `total` — the files left after the one-row-per-path dedup, before the cut — and takes `.slice(-DECISIONS_MAX)` over the still-ascending sort, so a Project past 200 decisions keeps the newest instead of dropping the ones just written and the order on screen does not change, while the meetings read answers `decidedTotal`/`pendingTotal` per list beside the 100 rows it already kept newest; a list at or under its cap answers the same rows as before, plus the count ([#2021](https://github.com/ThinkFlowLab/FlyBot/issues/2021)). Since #2033 the lookup a decided meeting uses for its own file falls back to the un-truncated dedupe set of that same read when the name is not among the kept rows, so a file that fell off the oldest end of the cut is still named by the meeting (`path` set, and no `sha256`, because only the kept rows carry one) while a name the folder no longer holds still answers `path: ''`; the fallback reads names from that dedupe set and never asks the filesystem, so a crafted `../outside.md` yields no path either ([#2033](https://github.com/ThinkFlowLab/FlyBot/issues/2033)). Since #2037 the name is also checked where the listing is produced, by the same `isDecisionPath` the reader uses: a name with a `..` segment, an absolute path, a symlink standing in for the file, or a name this read never listed yields no row and no meeting `path` (and no `sha256`), so the fallback past the cut cannot bring one back — `archivedOf` reads through `describeFile`, the reader's own `lstat`, never `stat` ([#2037](https://github.com/ThinkFlowLab/FlyBot/issues/2037)). Since #2041 a decision row can be opened: `rt.projectDecision(projectId, name)` reads one name through `readListedFile` and answers the reader's own three states (`text`, `absent`, `too large`), but only for a name the last `projectBoard` read of that Project produced — that read's un-truncated dedupe set is kept per Project, each name with the folder it was read from, so a name past the 200-row cut still opens while `decisions/../outside.md`, an absolute path, a name one level down or a symlink answers `not listed` without a `stat`, an open or a byte of content; the endpoint is `project-decision`, dispatched in `channel.js` beside `project-board` and listed in `authz.js`'s `TASK_ENDPOINTS` ([#2041](https://github.com/ThinkFlowLab/FlyBot/issues/2041)). A decided meeting that saved no folder keeps the cards it decided: its file is one of the Project folder's own rows, and binding those cards to that row is what keeps the name behind the same door as the cards, while a meeting that decided no card still answers through the role list ([#2052](https://github.com/ThinkFlowLab/FlyBot/issues/2052)). A record that named a folder whose file this read could not open is not that folder-less shape: it binds no cards, so the same relative name in the Project's own folder answers through the role list instead of inheriting the cards of a file in another folder. Since [#2029](https://github.com/ThinkFlowLab/FlyBot/issues/2029) a blocked card also carries the age of its own question: `blockedOf(task, now)` answers `since` (the `inputRequest.requestedAt` stamp `tasks.js` wrote, preserved across a re-ask) and `waitedMs` (`now - since`, the same shape `waitingOf` answers), both `null` when the ask wrote no stamp — never derived from `createdAt`/`updatedAt` — and `buildQueues` sorts `blocked` oldest-first with the comparator `waiting.sort` already uses, so an undated card sorts last; the Waiting group's own output is unchanged. Tests: `core/test/project-board.test.js`. On top of the record and the board sits the **Project Assistant** (#1712, Stage 2.5): `project-delete` removes the Project, its bindings and its memory; the assistant's digest is one stored note plus one `deliver()` to the owner, idempotent per Asia/Shanghai day, and its identity is the connector account and never the shared app — no connector signed in, or a multi-member host whose Project owner is not this host's own user, refuses and escalates with nothing written. Per-owner memory (#1733) keys those three fields by `(Project, owner)` under `projectMemory[id].owners`, mirrors the primary owner onto the row itself so a single-owner Project reads as before, and migrates a pre-#1733 record onto its primary owner on the first write instead of replacing it; the boundary a memory read is checked against is the Project's own owner list, so an owner asking for another owner's entries is refused naming that list, while the assistant's own render walks every owner. A card needs an owner's decision when the Bot that owns the card belongs to that owner, never from the board's `assignee` (a Bot name or null); the ask names the card's own title, at most `MAX_ACTIONABLE` (5) per owner. A quiet window on the assistant record holds a due digest and releases it once, labelled, when the window ends; a window covering 09:00 delivers nothing that day; an owner's opt-out stops generation and pauses the shared 09:00 task once every owner is off, and a re-enable resumes it; delivery goes to each owner's own Session and falls back to the main Bot only for the primary owner. A held digest is released by the next `digest()` call, not by the 09:00 task, which does not make one after a window ends. On top of it, member auth (#48) adds host module `src/host/member-auth.js`, installed after `store` in `MODULES` (`core/index.js`): `invitations` and `authSessions` maps live on state (no `STATE_VERSION` bump), the channel resolves the actor from the `x-flybot-member-session` header, and endpoints are `auth-invite` / `auth-redeem` / `auth-revoke` / `auth-whoami` / `auth-remove-member` / `auth-revoke-invite` with `body.actorId` ignored; `auth-invite` alone also opens for an anonymous caller on a host with no sessions yet (bootstrap first invite), `auth-revoke-invite` kills an unredeemed invitation (redeeming it afterwards fails with `revoked`), `removeMember` refuses a member that still owns a Bot (`has_bots`), and the auth mutations are serialized behind an in-process lock so concurrent `removeMember` calls cannot both pass the last-owner check. Sign-in surface only — still not team mode; details in [team](team.md). On top of it, owner-scoped access (#49) adds host module `src/host/authz.js`, installed after member-auth: when any `authSessions` exist, the channel `authorize()` default-denies cross-owner Bot/room/session access, blocks shared non-isolatable resources (secrets, connectors, folder picks, browser bridge, update-install, diagnostics, set-prefs, `add-agent` — the DSH Agent is one per deployment and carries no owner) with `bot/blocked-shared` when more than one member exists, stamps `create-bot` with `actor.memberId`, and filters `view()`/`live()` to the actor; with no sessions the single-human behavior is unchanged. Session targets resolve through the store's owner maps — a Bot id via `botOf`, current chats and group Sessions and earlier parts via `selfOf`/`groupOwners`/`pastOwners` (a shared room Session also resolves for its current participants, #50) — and unresolvable ids deny. `approval-answer` gates on the approval's own Bot or room (resolved by approval id), never on caller-supplied session fields. `flybot/*` plugin endpoints (src/host/hooks.js) reach any signed-in actor for reads — except a Bot Social read naming a `body.botId` the actor does not own, which `bot/forbidden` refuses before the plugin handler registers that Bot's identity or syncs its grants (#1788, an owner-decided tightening); plugin writes follow the shared rule. `view()`/`live()` narrowing (`rt.ownedView`/`rt.ownedLive` in authz.js) keeps per-session entries (`questions`, `exchangeTurns`) the actor owns, and deployment-wide surfaces (agents, secrets, connectors, memory reviews) only while a single member exists — the same rule as the shared writes; rooms the actor belongs to and the Bots in them (#50) stay visible. `create-room` refuses a request with neither a member Bot nor any participant. On top of it, room membership (#50) adds host module `src/host/room-membership.js`, installed after groups: a room may carry typed `participants` (`human` / local `bot` / granted remote `bot` with `handle` + owner fields, roles `admin` / `member`, `joinedSeq` / `leftSeq`); log lines carry `authorId` + `authorKind`; history is the membership window (current members and the owner of an invited Bot see `seq >=` their interval; leave withdraws access; rejoin starts a new interval); local cross-owner rooms accept each side's *granted* Bots (#90: default deny, Chief included); a friend's Bot from `social/remote-bots` (`cleanRemote`) may be added and ungranted Bots stay private, and a room with a friend's Bot holds only granted local Bots (a Bot that joined by room code, `viaCode`, only for that room); `view()` labels remotes with an owner badge and shows an invited owner **Shared room from \**. Face-to-face rooms (#83) reuse the same `participants` / membership-window / owner-badge presentation with `origin: 'face-to-face'`: room-only remotes may sit in the room without a person-grant or a friendship, `presentRoom` treats the viewer as a member (not Shared room from), leave skips the two-Bot floor, and `fanoutRoomLine` goes to `rt.faceRoomFanout` instead of friend `flybotSend`. Still not team mode — see [team](team.md). Per-Bot grants (#90) add host module `src/host/reach.js`, installed after room-membership: `state.grants[botId]` is the list of independent grants (one Bot → one grantee, optionally room-scoped); default deny, Chief included; a Bot whose owner is not a member has none. Grants are set with `rt.setGrants`, exposed as `BotView.granted`, `flybot.bot.hasGrant` / `hasAnyGrant`, `flybot.grants` and the `bot.grants` event. `reach()` is the owner policy: a friend's Bot may reach only local Bots granted to that sender, only a granted Bot (or one code-joined in that room) may message a friend's Bot, another owner's Bot is reached only if both sides have matching grants or both are members of the room the message belongs to. It is not a middleware: hooks.js runs it before the `bot.send` chain, again on approval release, and on every deliver with a sender or remote origin (resumed, retried, deferred, re-sent), so no verdict or `bypass` can skip it and a queued message is judged by what is true when it is delivered. Revoking a grant, or replacing its Chief, removes it from rooms that have friends' Bots or several owners at once (`rt.evictUnshared`). Approval binding (#52) completes the held-send half of this: `state.flybot.approvals` records carry `requestId`, a sha256 `version` over the request content, server-derived `ownerId` and `origin` (`via`/`hop`/audience) — claims in the message (`meta`, text) never confer authority. `flybot/decide` is allowed through authz for any signed-in actor and the per-record check happens in hooks (`decide` requires the sender Bot's owner's session, `bot/forbidden` otherwise, `bot/unauthorized` without a session); `release()` revalidates before dispatch and marks the record `stale` when the request changed, the sender or target Bot is gone (also invalidated eagerly on `deleteBot`), or the Bot changed owners; a demoted Chief's sender-side holds are invalidated eagerly on `setMain` (not inside `staleReason`), and `release()` re-checks immediately before dispatch so an in-flight approve cannot go out after a concurrent invalidation. Unknown `invalidateFor` field values throw. `flybot/approvals` lists only the actor's own holds while enforcement is on; decided stale records keep their attribution in state but appear in no member's list. Which statuses still wait for that decision is one rule in `src/shared/approval-waiting.js` (#754): `decide()`, `heldOf()` in the chat view, the in-flight filter that invalidates holds, `PENDING_MAX` and the `APPROVALS_KEPT` cleanup all ask `isApprovalWaiting()`, and `channel.js` counts a row's waiting replies with it, so a `pending_approval` hold (Bot Social's own spelling) is decidable, drawn as waiting and never dropped mid-wait. -- One writer per home (`src/host/home-lock.js`): the first dsh to load `~/.dsh` takes `state.lock` (pid, host, a random token in the file, its mtime bumped on a timer) and a second window on the same data only reads, answering `bot/read-only` and naming the other host. What such a window still answers is `channel.js`'s own `READS` whitelist: beside `state` it carries the reads a second window's screens draw — the Projects views (`projects`, `project-read`, `project-board`, `project-team`, `project-of`, `project-decision`, `project-memory`), the task boards (`tasks`), the folder rows and browser (`folders`, `folder-list`) and the connector row's app state (`connector-app-status`) — and the write beside each one (`project-create`, `project-bind`, `task-create`, `folder-pick`, `connector-connect`, ...) is still refused, so a missing read is an empty screen and never a change made through the reading window. `diagnostics-clear` is deliberately not on that list (#2302): it truncates `/logs/diagnostics.jsonl`, the file a bug report is supposed to carry, so a reading window is refused with `bot/read-only` instead of erasing the evidence, while `diagnostics` and `diagnostics-log` still read that same file. Takeover is **on the next write**, not on a poll (#1027): a window that was read-only claims the free lock inside `save()` too, and a change made outside a request (a plugin, a scheduled task, a delivery) belongs in `rt.mutate(fn)`, which takes over and adopts what the other host left *before* the change is made and written. The writers that changed `rt.state` inline and then called `save()` are gone (#1048): `delivery.js` (a friend's first line and its unread mark, a team exchange) and `agents.js` (add, adopt, archive and remove of the DSH Agent) take the free lock and adopt first, re-resolving their records inside the change, so a write from a window that was read-only keeps what the other host left instead of replacing it with its older copy. `save()` itself never adopts: `adopt()` replaces `rt.state` whole, so adopting there would discard a change the caller has already made (`#1032`). Either takeover prints one line — `the other dsh has closed; this one now saves the Bot team (was pid on )` — from `refresh()` and from `save()` alike, so a window that writes over the file another host left leaves a trace (#1041). A lock file still naming a live holder on this host (its pid answers) keeps the window read-only, which is the #14 rule. A write that fails does not decide the delivery (#1096): the friend-line mark and the exchange record both land before the agent has the line, so a rejected `mutate()` (the lock was taken away, or the disk refused) prints one warning naming what was not recorded and the line is still handed over — a throw there would drop a message that exists nowhere else. The write chain is also readable: `rt.savesQueued()` is a monotonic watermark — every save this host has accepted since it started, never decremented — and `rt.savesSettled()` resolves once the chain is empty, so a caller that must read `state.json` after a save it did not await — a rollback's own write, in the tests — waits on the store instead of on a delay, where a wait long enough on an idle machine reads a half-written team on a busy one (#1368). +- One writer per home (`src/host/home-lock.js`): the first dsh to load `~/.dsh` takes `state.lock` (pid, host, a random token in the file, its mtime bumped on a timer) and a second window on the same data only reads, answering `bot/read-only` and naming the other host. What such a window still answers is `channel.js`'s own `READS` whitelist: beside `state` it carries the reads a second window's screens draw — the Projects views (`projects`, `project-read`, `project-board`, `project-team`, `project-of`, `project-decision`, `project-memory`), the task boards (`tasks`), the folder rows and browser (`folders`, `folder-list`) and the connector row's app state (`connector-app-status`) — and the write beside each one (`project-create`, `project-bind`, `task-create`, `folder-pick`, `connector-connect`, ...) is still refused, so a missing read is an empty screen and never a change made through the reading window. `diagnostics-clear` is deliberately not on that list (#2302): it truncates `/logs/diagnostics.jsonl`, the file a bug report is supposed to carry, so a reading window is refused with `bot/read-only` instead of erasing the evidence, while `diagnostics` and `diagnostics-log` still read that same file. Takeover is **on the next write**, not on a poll (#1027): a window that was read-only claims the free lock inside `save()` too, and a change made outside a request (a plugin, a scheduled task, a delivery) belongs in `rt.mutate(fn)`, which takes over and adopts what the other host left *before* the change is made and written. The writers that changed `rt.state` inline and then called `save()` are gone (#1048): `delivery.js` (a friend's first line and its unread mark, a team exchange) and `agents.js` (add, adopt, archive and remove of the DSH Agent) take the free lock and adopt first, re-resolving their records inside the change, so a write from a window that was read-only keeps what the other host left instead of replacing it with its older copy. `save()` itself never adopts: `adopt()` replaces `rt.state` whole, so adopting there would discard a change the caller has already made (`#1032`). Either takeover prints one line — `the other dsh has closed; this one now saves the Bot team (was pid on )` — from `refresh()` and from `save()` alike, so a window that writes over the file another host left leaves a trace (#1041). A lock file still naming a live holder on this host (its pid answers) keeps the window read-only, which is the #14 rule. A write that fails does not decide the delivery (#1096): the friend-line mark and the exchange record both land before the agent has the line, so a rejected `mutate()` (the lock was taken away, or the disk refused) prints one warning naming what was not recorded and the line is still handed over — a throw there would drop a message that exists nowhere else. The write chain is also readable: `rt.savesQueued()` is a monotonic watermark — every save this host has accepted since it started, never decremented — and `rt.savesSettled()` resolves once the chain is empty, so a caller that must read `state.json` after a save it did not await — a rollback's own write, in the tests — waits on the store instead of on a delay, where a wait long enough on an idle machine reads a half-written team on a busy one (#1368). A save never writes through a link at the team file's own name (P-556): the write lands on the temp name and the `rename` replaces the name, link included, so the file the link pointed at keeps every byte and its last write time, and the path the person opened is a regular file of this home's own from that save on. The store counts that save — `lstat` on the name before the `rename`, once per save, never for a save over the regular file that followed — and answers `rt.stateLinkReplaced()` as `{ gen, count }`: the count, plus the random generation stamp of the process that made it, because a restarted host starts counting at 0 again and a window holding the previous process's larger count would read the restart's first save as old news (Chief, on review). The pair rides the live part of every `state` answer (`stateFileLinkReplaced`), the `unchanged` one included, because a replaced link moves no revision; `authz.js`'s `ownedLive` names it in its whitelist, so a deployment where anyone has signed in receives it too — a field left out of that whitelist reaches no signed-in client at all (Security, on review). - `list_bots` (`src/host/tools.js`) tags a Bot with `N to approve` while one of its outbound sends is held for the owner's decision (#587): a count of that Bot's pending held sends (`state.flybot.approvals`), never the held message's text, so a coordinator reading the roster sees who is blocked and the tag clears once the owner decides. Covered by the roster case in `core/test/flybot.test.js`. - The bot **write** tools keep the same owner rule (#1844): with authz on, `stop_bot`, `update_bot` and `archive_bot` (`src/host/tools.js`, `writableBot`) accept a target only when its `ownerId` is the caller's owner or `hasGrant(bot.id, ownerId)` holds, and `inRoom` takes no part — a Bot that is only visible because it sits in the same room is not writable, and `listed()` stays a read predicate. With authz off the predicate is the old `findTeamBot`, so a single-owner install is unchanged. All three answer an unknown and a foreign name with the same bare `No Bot named .` instead of a `Team:` list, so the text neither confirms that a Bot the actor cannot see exists nor names one. Covered by `core/test/update-bot.test.js`, `core/test/archive-bot.test.js` and `core/test/bot-messages.test.js`. diff --git a/patches/ds-bot/0001-core-fixes.patch b/patches/ds-bot/0001-core-fixes.patch index 319302b1d..305678883 100644 --- a/patches/ds-bot/0001-core-fixes.patch +++ b/patches/ds-bot/0001-core-fixes.patch @@ -1372,7 +1372,7 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js 'No Bot': '没有 Bot', 'Paste or type the key': '粘贴或输入密钥', 'Only {name}': '只给 {name}', -@@ -501,7 +501,6 @@ const ZH = { +@@ -501,14 +501,19 @@ const ZH = { 'At least {count} characters.': '至少 {count} 个字符。', 'Replace value': '替换值', 'Saving…': '保存中…', @@ -1380,8 +1380,12 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js 'Allowing…': '允许中…', Allow: '允许', '{bot} asked for {name}': '{bot} 请求了 {name}', -@@ -509,6 +509,8 @@ const ZH = { + 'Allowed {bot} to use {name}': '已允许 {bot} 使用 {name}', 'Saved {name}': '已保存 {name}', ++ // P-556: the store replaced a linked `state.json` with a file of this home's own and never wrote ++ // through the link, so the file the person linked keeps every byte. ++ 'Saved as a separate file; the file the link pointed at was not changed.': ++ '已保存为独立文件,原链接指向的内容未改动。', Canceled: '已取消', Done: '完成', + // #1855: the one collapsed group that holds a Project's finished cards. diff --git a/patches/ds-bot/0002-flybot-api-host.patch b/patches/ds-bot/0002-flybot-api-host.patch index c812dc7e5..973b3bbf6 100644 --- a/patches/ds-bot/0002-flybot-api-host.patch +++ b/patches/ds-bot/0002-flybot-api-host.patch @@ -784,14 +784,15 @@ diff --git a/src/host/roster.js b/src/host/roster.js diff --git a/src/host/store.js b/src/host/store.js --- a/src/host/store.js +++ b/src/host/store.js -@@ -1,21 +1,66 @@ +@@ -1,21 +1,76 @@ // Store: the saved team (state.json), its migrations, the lock that keeps a second dsh // host on the same data from writing it, and the indexes that map a Session to the Bot // or group chat it belongs to. -import { mkdirSync, statSync } from 'node:fs' +-import { copyFile, mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises' +import { randomUUID } from 'node:crypto' +import { constants, mkdirSync, statSync } from 'node:fs' - import { copyFile, mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises' ++import { copyFile, lstat, mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises' import { join } from 'node:path' -import { LEGACY_THEMES, MAX_EXCHANGES, ROOM_MODES } from './constants.js' +import { LEGACY_THEMES, MAX_ENTRY, MAX_EXCHANGES, ROOM_MODES } from './constants.js' @@ -836,6 +837,16 @@ diff --git a/src/host/store.js b/src/host/store.js + // Every save this host has accepted since it started — a monotonic watermark, never decremented + // — and the chain's tail: what `savesQueued` and `savesSettled` answer (#1368). + let queued = 0 ++ // Saves that replaced a linked `state.json` with a regular file (P-556): the host never writes ++ // through the link, so the file the person linked keeps every byte and stays where it is, and the ++ // path they opened is a file of this home's own from that save on. Counted once per save so a ++ // window can say what happened instead of leaving them to wonder. ++ // ++ // The count is answered with the generation of this process (Chief, on review): a restarted host ++ // starts counting at 0 again, and a window holding a larger count from the previous process would ++ // read `1 > 2` as old news and never say the save that came with the restart. ++ const linkGeneration = randomUUID() ++ let linkReplaced = 0 const { warn } = rt + // Opt-in (#511): every saved change is also recorded for replay; off by default. + // Opening the writer is itself a write — it creates `journal/` (0700) and repairs a half-written @@ -963,9 +974,10 @@ diff --git a/src/host/store.js b/src/host/store.js + warn('ds-bot: unreadable state %s: %s', statePath, backupProblem) + return undefined + } -+ stuck = undefined -+ return parsed -+ } + stuck = undefined + return parsed + } +- stuck = 'saved by a newer DS Bot; update DS Bot' + if (parsed?.version === STATE_VERSION) { + const problem = ownershipProblem(parsed) + if (problem !== undefined) { @@ -973,10 +985,9 @@ diff --git a/src/host/store.js b/src/host/store.js + warn('ds-bot: state %s was refused: %s', statePath, problem) + return undefined + } - stuck = undefined - return parsed - } -- stuck = 'saved by a newer DS Bot; update DS Bot' ++ stuck = undefined ++ return parsed ++ } + stuck = 'saved by a newer FlyBot; update FlyBot' return undefined } @@ -1564,7 +1575,7 @@ diff --git a/src/host/store.js b/src/host/store.js warnedReadOnly = false adopt(await readFileState()) return -@@ -235,16 +235,45 @@ export function install(rt) { +@@ -235,16 +235,54 @@ export function install(rt) { return warnReadOnly() } @@ -1583,6 +1594,15 @@ diff --git a/src/host/store.js b/src/host/store.js + return true + } + ++ // A name, not its target: `lstat` answers what the path is, and a save is the only place that ++ // turns a link into a regular file. ++ const isLink = async path => { ++ try { ++ return (await lstat(path)).isSymbolicLink() ++ } catch { ++ return false ++ } ++ } const save = () => { if (stopped) return writing indexSessions() @@ -1610,9 +1630,16 @@ diff --git a/src/host/store.js b/src/host/store.js try { await mkdir(home, { recursive: true }) const ensureLock = () => { -@@ -260,9 +260,10 @@ export function install(rt) { +@@ -258,11 +258,17 @@ export function install(rt) { + ensureLock() + await writeFile(temp, snapshot, 'utf8') ensureLock() ++ // The rename replaces the name, link included, so a linked team file is never written ++ // through: its target keeps every byte and its last write time. Ask the name here, before ++ // the rename takes it, and count the save that did it. ++ const wasLink = await isLink(statePath) await rename(temp, statePath) ++ if (wasLink) linkReplaced += 1 seen = stamp() + await journal?.record(snapshot) rt.capabilitiesStateSaved?.() @@ -1685,7 +1712,7 @@ diff --git a/src/host/store.js b/src/host/store.js rt.cancelApprovals?.(sessionId) // A plain archive refuses a running Session, which would go on working for a group // that no longer has it. -@@ -335,23 +335,29 @@ export function install(rt) { +@@ -335,23 +335,30 @@ export function install(rt) { } const findBot = (ref) => { if (ref === undefined) return undefined @@ -1711,6 +1738,7 @@ diff --git a/src/host/store.js b/src/host/store.js Object.assign(rt, { - state, load, save, refresh, readOnly, + state, load, save, refresh, readOnly, mutate, savesQueued, savesSettled, journal, ++ stateLinkReplaced: () => ({ gen: linkGeneration, count: linkReplaced }), botOf, roomOf, isMain, chatOf, selfOf, isBotSession, chatOwners, groupOwners, pastOwners, groupSessionsOf, dropGroupSession, findBot, findRooms, isAdminOf, diff --git a/patches/ds-bot/0003-flybot-api-client.patch b/patches/ds-bot/0003-flybot-api-client.patch index ebf36335f..e4b396f34 100644 --- a/patches/ds-bot/0003-flybot-api-client.patch +++ b/patches/ds-bot/0003-flybot-api-client.patch @@ -1581,7 +1581,7 @@ diff --git a/src/client/icons.js b/src/client/icons.js diff --git a/src/client/index.js b/src/client/index.js --- a/src/client/index.js +++ b/src/client/index.js -@@ -1,44 +1,71 @@ +@@ -1,44 +1,72 @@ +import { FLYBOT_CSS, FlyBotApprovals, FlyBotSessionDock, createClientApi as createFlyBotClient } from './flybot-slots.js' +import { createElement as h } from 'react' +import { registry as flybotSlots } from './flybot-registry.js' @@ -1635,8 +1635,10 @@ diff --git a/src/client/index.js b/src/client/index.js import { STORAGE, migrateStorage } from './storage.js' +import { friendLineNews } from './friend-lines.js' import { groupsFor, readMode, surfaceGroups, writeMode } from './surface.js' - import { installLocale, language } from './i18n.js' +-import { installLocale, language } from './i18n.js' -import { noteClientError, quietly } from './diagnostics.js' ++import { installLocale, language, t } from './i18n.js' ++import { announceStateLink } from './state-link-notice.js' +import { noteClientError, noteNavigation, quietly } from './diagnostics.js' import { ErrorToast, FEEDBACK_CSS } from './feedback.js' import { ABOUT_CSS } from './about.js' @@ -1686,7 +1688,7 @@ diff --git a/src/client/index.js b/src/client/index.js // Per Bot, the turn it runs and its tool; and the approvals waiting for the user. const work = createSource({ bots: {}, approvals: [] }) let workKey = '' -@@ -125,18 +125,35 @@ export function apply(ctx) { +@@ -125,18 +125,40 @@ export function apply(ctx) { try { localStorage.setItem(UNREAD_KEY, JSON.stringify(unread)) } catch { /* storage unavailable */ } return { ...state, unread } }) @@ -1706,6 +1708,11 @@ diff --git a/src/client/index.js b/src/client/index.js let pulls = 0 let applied = 0 - const pull = async () => { ++ // P-556: the count of saves that replaced a linked `state.json`, as far as this window has told ++ // the person, with the host generation it belongs to: the count moves no revision, so it is read on ++ // every answer, `unchanged` included. `null` until the first answer: a window that has just opened ++ // takes that count as its baseline, so a refresh never replays a notice about an older save. ++ let linkSeen = null + // A Reload in flight makes the poll stand back. Otherwise the loop's `unchanged` answer can take + // the applied slot first and the full answer the press asked for is dropped as the older one. + let reloading = 0 @@ -1724,7 +1731,7 @@ diff --git a/src/client/index.js b/src/client/index.js const nextWork = JSON.stringify(value.work ?? null) if (nextWork !== workKey) { workKey = nextWork; work.set(value.work ?? { bots: {}, approvals: [] }) } noteMemoryNews(value.memoryNews ?? {}) -@@ -145,11 +145,14 @@ export function apply(ctx) { +@@ -145,11 +145,18 @@ export function apply(ctx) { const live = {} if (value.connectors !== undefined && JSON.stringify(value.connectors) !== JSON.stringify(current.connectors ?? [])) live.connectors = value.connectors if (value.capabilityStatuses !== undefined && JSON.stringify(value.capabilityStatuses) !== JSON.stringify(current.capabilityStatuses ?? {})) live.capabilityStatuses = value.capabilityStatuses @@ -1732,6 +1739,10 @@ diff --git a/src/client/index.js b/src/client/index.js + if (value.pluginTools !== undefined && JSON.stringify(value.pluginTools) !== JSON.stringify(current.pluginTools ?? [])) { setPluginTools(value.pluginTools); live.pluginTools = value.pluginTools } if (Object.keys(live).length > 0 && current.ready) roster.set({ ...current, ...live }) - if (value.unchanged || (value.revision === current.revision && value.modelsKey === current.modelsKey)) return ++ // P-556: a save whose name was a link replaced it with a file of this home's own instead of ++ // writing through the link. The count rides every answer, the `unchanged` one too, so the ++ // window can say it once — before the staleness check, which drops the answer's team, not this. ++ linkSeen = announceStateLink(value, linkSeen, text => actions.notify(t(text))) + if (stateAnswerIsStale(value, current, { fresh })) return // On a browser with no visit record yet, everything already there counts as seen. if (Object.keys(seenAt).length === 0) leaveVisits([...value.bots, ...value.rooms].map(entry => entry.id)) diff --git a/patches/ds-bot/0005-social-seams.patch b/patches/ds-bot/0005-social-seams.patch index 965676f4e..3f37eec6f 100644 --- a/patches/ds-bot/0005-social-seams.patch +++ b/patches/ds-bot/0005-social-seams.patch @@ -462,7 +462,7 @@ diff --git a/src/host/channel.js b/src/host/channel.js defaultModel: rt.cachedDefault() ?? null, permissions: rt.permissionCatalog?.() ?? null, modelsKey: modelsKey(), -@@ -65,22 +65,62 @@ export function install(rt) { +@@ -65,22 +65,66 @@ export function install(rt) { memory: rt.memoryEnabled !== false, // Set while another dsh host holds the team (store.js): why this one cannot change it. readOnly: rt.readOnly() ?? null, @@ -494,6 +494,10 @@ diff --git a/src/host/channel.js b/src/host/channel.js + memoryReviews: rt.memoryReviews(), + connectors: rt.connectorsView(), + capabilityStatuses: rt.capabilityStatusesView(), ++ // P-556: a save that replaced a linked `state.json` with a regular file keeps the team ++ // revision, so the count rides the live part of every answer — the `unchanged` one included — ++ // together with the generation of the host process that counted it (a restart starts over). ++ stateFileLinkReplaced: rt.stateLinkReplaced?.() ?? { gen: '', count: 0 }, + } + // Plugin tool names change with a plugin reload, not with the team revision, so they ride every + // answer, unchanged or not, and the client re-reads its hidden-row set (#419). diff --git a/patches/ds-bot/0008-client-bundle.patch b/patches/ds-bot/0008-client-bundle.patch index 68a7ad824..819004f53 100644 --- a/patches/ds-bot/0008-client-bundle.patch +++ b/patches/ds-bot/0008-client-bundle.patch @@ -707,7 +707,7 @@ diff --git a/client.js b/client.js "No Bot": "没有 Bot", "Paste or type the key": "粘贴或输入密钥", "Only {name}": "只给 {name}", -@@ -555,7 +555,6 @@ var ZH = { +@@ -555,14 +555,18 @@ var ZH = { "At least {count} characters.": "至少 {count} 个字符。", "Replace value": "替换值", "Saving…": "保存中…", @@ -715,8 +715,11 @@ diff --git a/client.js b/client.js "Allowing…": "允许中…", Allow: "允许", "{bot} asked for {name}": "{bot} 请求了 {name}", -@@ -563,6 +563,8 @@ var ZH = { + "Allowed {bot} to use {name}": "已允许 {bot} 使用 {name}", "Saved {name}": "已保存 {name}", ++ // P-556: the store replaced a linked `state.json` with a file of this home's own and never wrote ++ // through the link, so the file the person linked keeps every byte. ++ "Saved as a separate file; the file the link pointed at was not changed.": "已保存为独立文件,原链接指向的内容未改动。", Canceled: "已取消", Done: "完成", + // #1855: the one collapsed group that holds a Project's finished cards. @@ -6336,7 +6339,7 @@ diff --git a/client.js b/client.js @keyframes bt-typing-in{from{opacity:0;transform:scale(.9)}} @keyframes bt-typing-out{from{opacity:1;transform:scale(1)}to{opacity:0;transform:scale(.92);filter:blur(3px)}} @keyframes bt-typing-label{from{opacity:0;filter:blur(5px);transform:translateY(3px)}} -@@ -2676,4494 +2676,8014 @@ header[class*="_header"] .bt-head{flex:1 1 auto;width:100%} +@@ -2676,2833 +2676,6487 @@ header[class*="_header"] .bt-head{flex:1 1 auto;width:100%} [data-bt-enter="new"]{animation:none} [data-bt-enter="after-collapse"]{animation:bt-fade-in .12s ease-out} } @@ -7163,16 +7166,7 @@ diff --git a/client.js b/client.js + ) + ) : null + ); - } --var FRAGMENT_SELECTOR = /\[class\*="(_[\w-]+)"\]/g; --var CLASS_TOKEN = /\.((?:\\.|[\w-])+)/g; --function sheetClassNames(sheet) { -- const names = []; -- const visit = (rules) => { -- for (const rule of rules) { -- if (rule.selectorText) for (const match of rule.selectorText.matchAll(CLASS_TOKEN)) names.push(match[1]); -- if (rule.cssRules) visit(rule.cssRules); -- } ++} +function AddSecret({ actions, run, onDone }) { + const [name, setName] = (0, import_react9.useState)(""); + const [purpose, setPurpose] = (0, import_react9.useState)(""); @@ -7303,25 +7297,13 @@ diff --git a/client.js b/client.js + bot: local ? local.name : remoteBotName(p, "Bot"), + mine: Boolean(local) + }; - }; -- try { -- visit(sheet.cssRules); -- } catch { ++ }; + const seats = records.filter((p) => p.leftSeq === void 0).map(seat); + for (const id of room2.members ?? []) { + const bot = roster.byId[id]; + if (!bot || seats.some((item) => item.local?.id === id)) continue; + seats.push({ key: id, local: bot, record: { id }, person: cleanPeerName(roster.self?.displayName, ""), bot: bot.name, mine: true }); - } -- return names; --} --function compileFragmentSelectors(text, names) { -- const compiled = /* @__PURE__ */ new Map(); -- return text.replace(FRAGMENT_SELECTOR, (whole, fragment) => { -- if (!compiled.has(fragment)) { -- const exact = []; -- for (const name of names) if (name.includes(fragment)) exact.push(`.${name}`); -- compiled.set(fragment, exact.length ? `:is(${exact.join(",")})` : whole); ++ } + if (!left) return seats; + const gone = records.filter((p) => p.leftSeq !== void 0 && !seats.some((item) => item.key === (p.handle || p.id))).map((item) => ({ ...seat(item), left: true })); + return [...seats, ...gone]; @@ -7352,26 +7334,11 @@ diff --git a/client.js b/client.js + if (known) { + known.seats.push(seat); + continue; - } -- return compiled.get(fragment); -- }); ++ } + people2.push({ key, name: seat.person || (seat.mine ? cleanPeerName(roster.self?.displayName, "") : "") || t("Someone"), mine: seat.mine, seats: [seat] }); + } + return [...people2.filter((person) => person.mine), ...people2.filter((person) => !person.mine)]; - } --function installCompiledStyles(text, tag) { -- const style = installStyles("", tag); -- const cache = /* @__PURE__ */ new WeakMap(); -- const render = () => { -- const names = /* @__PURE__ */ new Set(); -- for (const sheet of document.styleSheets) { -- if (sheet.ownerNode?.dataset?.dshBot) continue; -- let list = cache.get(sheet); -- if (!list) { -- list = sheetClassNames(sheet); -- cache.set(sheet, list); -- } -- for (const name of list) names.add(name); ++} +function facePersonLabel(person) { + return person.mine ? t("{name} (you)", { name: person.name }) : person.name; +} @@ -7423,28 +7390,8 @@ diff --git a/client.js b/client.js + setError(failure?.message ?? String(failure)); + } finally { + setBusy(false); - } -- const next = compileFragmentSelectors(text, names); -- if (style.textContent !== next) style.textContent = next; -- }; -- render(); -- let timer; -- const schedule = () => { -- clearTimeout(timer); -- timer = setTimeout(render, 120); -- }; -- const onLoad = (event) => { -- if (event.target instanceof HTMLLinkElement) schedule(); -- }; -- const observer = new MutationObserver(schedule); -- observer.observe(document.head, { childList: true }); -- document.addEventListener("load", onLoad, true); -- return () => { -- observer.disconnect(); -- document.removeEventListener("load", onLoad, true); -- clearTimeout(timer); -- style.remove(); - }; ++ } ++ }; + if (!waiting) { + const failed = held.status === "failed"; + return (0, import_react10.createElement)( @@ -7479,75 +7426,14 @@ diff --git a/client.js b/client.js + { className: "bt-held-actions", "data-flybot": "held-reply-actions" }, + custom ? (0, import_react10.createElement)(boundary(), { id: custom.id, fallback: plain }, (0, import_react10.createElement)(custom.component, { held, decide, call: call3, actions, readOnly: locked === true })) : plain + ); - } - --// src/client/mark.js --var import_react2 = require("react"); --var STATES = ["idle", "thinking", "searching", "working", "sending", "orbit", "alert", "done"]; --var botState = (running, activity, alert) => running ? STATES.includes(activity) ? activity : "working" : alert ? "alert" : "idle"; --var toolState = (name) => /^(message_bot|stop_bot|create_bot|update_bot|create_group|update_group|delete_group|post_to_group)$/.test(name) ? "sending" : /search|fetch|browse|read|grep|glob|list|recall/i.test(name) ? "searching" : "working"; --function useGaze(ref, enabled) { -- (0, import_react2.useEffect)(() => { -- if (!enabled) return void 0; -- let frame = 0; -- let point = null; -- const place = () => { -- frame = 0; -- const node = ref.current; -- if (!node || !point) return; -- const box = node.getBoundingClientRect(); -- const clamp = (value) => Math.max(-1, Math.min(1, value)); -- node.style.setProperty("--bt-gx", clamp((point.x - box.left - box.width / 2) / 160).toFixed(2)); -- node.style.setProperty("--bt-gy", clamp((point.y - box.top - box.height / 2) / 160).toFixed(2)); -- }; -- const onMove = (event) => { -- point = { x: event.clientX, y: event.clientY }; -- if (!frame) frame = requestAnimationFrame(place); -- }; -- const onLeave = () => { -- point = null; -- ref.current?.style.setProperty("--bt-gx", "0"); -- ref.current?.style.setProperty("--bt-gy", "0"); -- }; -- window.addEventListener("pointermove", onMove, { passive: true }); -- document.documentElement.addEventListener("pointerleave", onLeave); -- return () => { -- cancelAnimationFrame(frame); -- window.removeEventListener("pointermove", onMove); -- document.documentElement.removeEventListener("pointerleave", onLeave); -- }; -- }, [enabled]); ++} ++ +// src/client/turn-reasoning.js +function turnReasoning(steps, current) { + const members = steps.length ? steps : [current]; + if (members.find((step) => stepReasoning(step) !== "")?.step !== current.step) return ""; + return members.filter((step) => !thinkingLive(step)).map(stepReasoning).filter(Boolean).join("\n\n"); - } --var FIDGETS = ["hop", "sway", "stretch", "look"]; --function BotMark({ bot, look: given, size = 36, state = "idle", live = false, gaze = false, pokeable = false, label }) { -- const look = given ?? lookOf(bot); -- const ref = (0, import_react2.useRef)(null); -- const maskId = (0, import_react2.useRef)(""); -- if (maskId.current === "") maskId.current = `bt-mark-${nextMarkSerial()}`; -- const [poked, setPoked] = (0, import_react2.useState)(false); -- useGaze(ref, gaze); -- const common = { -- ref, -- className: poked ? "bt-mark bt-mark-poke" : "bt-mark", -- "data-shape": look.image ? "image" : shapeId(look.shape), -- "data-state": STATES.includes(state) ? state : "idle", -- "data-live": live ? "" : void 0, -- "data-fidget": live ? FIDGETS[fnv1a(`${bot?.id ?? ""}:fidget`) % FIDGETS.length] : void 0, -- "data-gaze": gaze ? "" : void 0, -- "data-poke": pokeable ? "" : void 0, -- style: { width: size, height: size, ...live ? { "--bt-delay": `${-(fnv1a(String(bot?.id ?? "")) % 5e3) / 1e3}s`, "--bt-fidget-delay": `${-(fnv1a(`${bot?.id ?? ""}:when`) % 9e3) / 1e3}s` } : {} }, -- role: label ? "img" : void 0, -- "aria-label": label, -- "aria-hidden": label ? void 0 : true, -- onClick: pokeable ? () => setPoked(true) : void 0, -- onAnimationEnd: poked ? (event) => { -- if (/^bt-poke/.test(event.animationName)) setPoked(false); -- } : void 0 ++} +var texts = (step) => step.blocks.filter((block) => block.kind === "text" && block.text.trim() !== ""); +var thinkingLive = (step) => step.status === "running" && texts(step).length === 0 && step.blocks.some((block) => block.kind === "reasoning"); +var stepReasoning = (step) => step.blocks.filter((block) => block.kind === "reasoning").map((block) => block.text).join("\n").trim(); @@ -7573,16 +7459,8 @@ diff --git a/client.js b/client.js + } + if (typeof window !== "undefined" && typeof window.open === "function") window.open(href, "_blank", "noopener,noreferrer"); + return true; - }; -- 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 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 Bubbles({ texts: texts2, roster, selfId, actions, streaming, sessionId, msgKey, msgKeys, useUi, lead, mentions }) { + const openLink = bubbleLinkClick({ actions, subject: selfId ?? sessionId }); + const onClick = (event) => { @@ -7629,10 +7507,8 @@ diff --git a/client.js b/client.js + }, emoji))) : null + ); + }) - ); - } --function AdminStar() { -- return (0, import_react2.createElement)("span", { className: "bt-admin-star", role: "img", "aria-label": t("Admin") }, (0, import_react2.createElement)(StarIcon, { size: 7 })); ++ ); ++} +var WAITING_OTHERS_NOTICE = "Waiting for the owner's approval"; +var REACTIONS = ["👍", "❤️", "😂", "😮", "😢", "🙏"]; +function leaveNoticeCoveredByClose(room2, text2) { @@ -7646,13 +7522,7 @@ diff --git a/client.js b/client.js + if (name && (who === name || who.endsWith(` (${name})`))) return true; + } + return false; - } --function MainStar() { -- return (0, import_react2.createElement)( -- "svg", -- { viewBox: "0 0 20 20", "aria-hidden": true }, -- (0, import_react2.createElement)("path", { d: "M10 2.4l2.29 5.04 5.51.63-4.09 3.74 1.11 5.42L10 14.5l-4.82 2.73 1.11-5.42L2.2 8.07l5.51-.63z" }) -- ); ++} +function useDismiss(open, close, refs) { + (0, import_react11.useEffect)(() => { + if (!open) return void 0; @@ -7673,16 +7543,7 @@ diff --git a/client.js b/client.js + window.removeEventListener("keydown", onKey, true); + }; + }, [open]); - } --function useSettled(state) { -- const prev = (0, import_react2.useRef)(state); -- const [settled, setSettled] = (0, import_react2.useState)(false); -- (0, import_react2.useEffect)(() => { -- const was = prev.current; -- prev.current = state; -- if (state !== "idle") { -- setSettled(false); -- return void 0; ++} +function MessageTools({ text: text2, name, sessionId, react, actions }) { + const [panel, setPanel] = (0, import_react11.useState)(null); + const [place, setPlace] = (0, import_react11.useState)(null); @@ -7695,26 +7556,7 @@ diff --git a/client.js b/client.js + if (panel === kind) { + close(); + return; - } -- if (was === "idle" || was === "alert") return void 0; -- setSettled(true); -- const timer = setTimeout(() => setSettled(false), 1800); -- return () => clearTimeout(timer); -- }, [state]); -- return settled; --} --function BotAvatar({ bot, size, state = "idle", main, admin, badge = true, live = true, gaze = false, pokeable = false }) { -- const settled = useSettled(state); -- if (bot === void 0) return (0, import_react2.createElement)(BotMark, { size }); -- const badgeKind = !badge ? null : state === "alert" ? "alert" : state !== "idle" ? "working" : admin ? "admin" : main ? "main" : null; -- return (0, import_react2.createElement)( -- "span", -- { className: "bt-mark", style: { width: size, height: size, "--bt-size": `${size}px` } }, -- (0, import_react2.createElement)(BotMark, { bot, size, state: settled ? "done" : state, live, gaze, pokeable }), -- badgeKind === "working" ? (0, import_react2.createElement)("span", { className: "bt-badge bt-badge-working", "aria-label": t("Working"), role: "img" }) : null, -- badgeKind === "alert" ? (0, import_react2.createElement)("span", { className: "bt-badge bt-badge-alert", "aria-label": t("Waiting for your answer"), role: "img" }, "?") : null, -- badgeKind === "admin" ? (0, import_react2.createElement)("span", { className: "bt-badge bt-badge-admin", "aria-label": t("Admin"), role: "img" }, (0, import_react2.createElement)(StarIcon)) : null, -- badgeKind === "main" ? (0, import_react2.createElement)("span", { className: "bt-badge bt-badge-main", "aria-label": t("Main Bot"), role: "img" }, (0, import_react2.createElement)(MainStar)) : null ++ } + const rect = event.currentTarget.getBoundingClientRect(); + const below = rect.bottom + 220 < window.innerHeight; + setPlace(kind === "react" ? { left: rect.left + rect.width / 2, top: rect.top - 6, "--bt-origin": "bottom center" } : { left: Math.min(rect.left, window.innerWidth - 208), ...below ? { top: rect.bottom + 4 } : { bottom: window.innerHeight - rect.top + 4, "--bt-origin": "bottom left" } }); @@ -7756,31 +7598,8 @@ diff --git a/client.js b/client.js + item(t("Copy"), CopyIcon, copy2), + item(t("Download"), DownloadIcon, download2) + ), document.body) : null - ); - } --function clusterSeats(count, frame) { -- if (count === 1) return [[0, 0, frame]]; -- if (count === 2) return [[0, 0, frame * 2 / 3], [frame / 3, frame / 3, frame * 2 / 3]]; -- if (count === 3) { -- const size = frame * 5 / 9; -- const rest = frame - size; -- return [[rest / 2, 0, size], [0, rest, size], [rest, rest, size]]; -- } -- const half = frame / 2; -- return [[0, 0, half], [half, 0, half], [0, half, half], [half, half, half]]; --} --function RoomAvatar({ room: room2, roster, size = 36 }) { -- const members = room2.members.map((id) => roster.byId[id]).filter(Boolean); -- const people = members.length === 1 ? [members[0], null] : members; -- const seat = (bot, seatSize, key) => bot ? (0, import_react2.createElement)(BotMark, { key, bot, size: seatSize }) : (0, import_react2.createElement)("span", { key, className: "bt-cluster-you", style: { width: seatSize * 0.92, height: seatSize * 0.92, margin: seatSize * 0.04 } }); -- if (people.length === 0) return (0, import_react2.createElement)(BotMark, { size }); -- if (size <= 20 && people.length > 1) { -- return (0, import_react2.createElement)( -- "span", -- { className: "bt-stack", style: { "--bt-stack-size": `${size}px` } }, -- people.slice(0, 3).map((bot, index) => seat(bot, size, bot?.id ?? `you-${index}`)), -- people.length > 3 ? (0, import_react2.createElement)("span", { className: "bt-stack-more" }, `+${people.length - 3}`) : null -- ); ++ ); ++} +var PEEK_MAX = 160; +var CUT = "…"; +var MARKUP = [/^\s{0,3}(?:`{3,}|~{3,})/, /^\s{0,3}([-*_])(?:\s*\1){2,}\s*$/]; @@ -7792,42 +7611,7 @@ diff --git a/client.js b/client.js + for (const part of graphemes(line2)) { + if (kept.length + part.length > PEEK_MAX - CUT.length) break; + kept += part; - } -- const seats = clusterSeats(Math.min(people.length, 4), size); -- const overflow = people.length > 4 ? people.length - 3 : 0; -- const gap = size / 14; -- const rings = seats.map(([x, y, s]) => ({ cx: x + s / 2, cy: y + s / 2, r: s * 0.48 + gap })); -- return (0, import_react2.createElement)( -- "span", -- { className: "bt-cluster", style: { width: size, height: size } }, -- seats.map(([x, y, s], index) => { -- const holes2 = rings.slice(index + 1).filter((ring) => Math.hypot(ring.cx - x - s / 2, ring.cy - y - s / 2) < ring.r + s * 0.46).map((ring) => `radial-gradient(circle at ${ring.cx - x}px ${ring.cy - y}px,transparent ${ring.r}px,#000 ${ring.r + 0.5}px)`); -- const style = { left: x, top: y, width: s, height: s }; -- if (holes2.length > 0) Object.assign(style, { WebkitMaskImage: holes2.join(","), maskImage: holes2.join(","), WebkitMaskComposite: "source-in", maskComposite: "intersect" }); -- if (overflow > 0 && index === 3) { -- return (0, import_react2.createElement)("span", { key: "more", className: "bt-cluster-more", style: { ...style, width: s * 0.92, height: s * 0.92, margin: s * 0.04, fontSize: Math.max(8, Math.round(s * 0.36)) } }, `+${overflow}`); -- } -- const bot = people[index]; -- return (0, import_react2.createElement)("span", { key: bot?.id ?? "you", style }, seat(bot, s)); -- }) -- ); --} -- --// src/client/icons.js --var import_react3 = require("react"); --function PlusIcon() { -- return (0, import_react3.createElement)("svg", { width: 18, height: 18, viewBox: "0 0 20 20", "aria-hidden": true }, (0, import_react3.createElement)("path", { d: "M10 3.5v13M3.5 10h13", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round" })); --} --function SearchIcon() { -- return (0, import_react3.createElement)("svg", { width: 16, height: 16, viewBox: "0 0 20 20", "aria-hidden": true }, (0, import_react3.createElement)("circle", { cx: 8.5, cy: 8.5, r: 5.5, stroke: "currentColor", strokeWidth: 1.6, fill: "none" }), (0, import_react3.createElement)("path", { d: "M13 13l4 4", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round" })); --} --function PeopleIcon() { -- return (0, import_react3.createElement)( -- "svg", -- { width: 14, height: 14, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", "aria-hidden": true }, -- (0, import_react3.createElement)("circle", { cx: 7.5, cy: 7, r: 3 }), -- (0, import_react3.createElement)("path", { d: "M2 16.5c.6-2.8 2.8-4.5 5.5-4.5s4.9 1.7 5.5 4.5" }), -- (0, import_react3.createElement)("path", { d: "M13 4.3a3 3 0 0 1 0 5.4M15 12.4c1.6.6 2.6 2 3 4.1" }) ++ } + return kept + CUT; +}; +var onlyMarkup = (lines) => lines.length === 1 ? t("{n} line of reasoning", { n: lines.length }) : t("{n} lines of reasoning", { n: lines.length }); @@ -7850,10 +7634,8 @@ diff --git a/client.js b/client.js + peek ? (0, import_react11.createElement)("span", { className: "bt-think-peek" }, peek) : null + ), + (0, import_react11.createElement)("div", { className: "bt-think-text" }, text2) - ); - } --function ArrowUpIcon() { -- return (0, import_react3.createElement)("svg", { width: 16, height: 16, viewBox: "0 0 20 20", "aria-hidden": true }, (0, import_react3.createElement)("path", { d: "M10 16V4M5 9l5-5 5 5", stroke: "currentColor", strokeWidth: 1.8, fill: "none", strokeLinecap: "round", strokeLinejoin: "round" })); ++ ); ++} +function useFileMentions({ node, openFile, fileMentions, useTurnData }) { + const tail = useTurnData?.("turn-tail"); + const location2 = node.location; @@ -7863,36 +7645,18 @@ diff --git a/client.js b/client.js + 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 MicIcon({ size = 16 }) { -- return (0, import_react3.createElement)( -- "svg", -- { width: size, height: size, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round", "aria-hidden": true }, -- (0, import_react3.createElement)("rect", { x: 7, y: 2.5, width: 6, height: 10, rx: 3, fill: "currentColor", stroke: "none" }), -- (0, import_react3.createElement)("path", { d: "M4.5 9.5a5.5 5.5 0 0 0 11 0M10 15v2.5" }) -- ); ++} +function useClosingText(node, useTurnData) { + const tail = useTurnData?.("turn-tail"); + const seq = node.data.finalNode?.seq; + if (!tail?.closing || seq === void 0) return true; + return tail.closing.finalNode?.seq === seq; - } --function WaveIcon({ size = 16 }) { -- return (0, import_react3.createElement)( -- "svg", -- { width: size, height: size, viewBox: "0 0 20 20", fill: "currentColor", "aria-hidden": true }, -- [[3, 7.5, 5], [6.5, 4.5, 11], [10, 2.5, 15], [13.5, 5.5, 9], [17, 8, 4]].map(([x, y, height]) => (0, import_react3.createElement)("rect", { key: x, x: x - 1, y, width: 2, height, rx: 1 })) -- ); ++} +function friendLead(bot, texts2) { + if (bot?.remote === void 0 || texts2.length === 0) return void 0; + if (texts2.every((text2) => /^Not sent:/.test(text2.trim()))) return void 0; + return (0, import_react11.createElement)("span", { className: "bt-lead", title: bot.name, "data-flybot": "friend-lead" }, (0, import_react11.createElement)(BotMark, { bot, size: 22 })); - } --function ShareIcon() { -- return (0, import_react3.createElement)( -- "svg", -- { width: 16, height: 16, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, -- (0, import_react3.createElement)("path", { d: "M10 12.5V3M6.5 6.5L10 3l3.5 3.5M4 11v4.5A1.5 1.5 0 0 0 5.5 17h9a1.5 1.5 0 0 0 1.5-1.5V11" }) ++} +function AssistantCell({ node, groupPart, sessionId, openFile, fileMentions, useTurnData, useRoster, useUi, useTurnSteps, useTurnContext, actions }) { + const roster = useRoster((value) => value); + const data = node.data; @@ -7930,27 +7694,8 @@ diff --git a/client.js b/client.js + }), + reasoning ? (0, import_react11.createElement)(ThinkingRow, { text: reasoning }) : null, + held && bubbles ? (0, import_react11.createElement)(HeldReply, { held, roster, sessionId, actions, closing }, bubbles) : bubbles - ); - } --function CloseIcon() { -- return (0, import_react3.createElement)("svg", { width: 16, height: 16, viewBox: "0 0 20 20", "aria-hidden": true }, (0, import_react3.createElement)("path", { d: "M5 5l10 10M15 5L5 15", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round" })); --} --var ChevronLeftIcon = () => (0, import_react3.createElement)("svg", { width: 16, height: 16, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, (0, import_react3.createElement)("path", { d: "M12.5 4.5L7 10l5.5 5.5" })); --var ChevronRightIcon = () => (0, import_react3.createElement)("svg", { width: 14, height: 14, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, (0, import_react3.createElement)("path", { d: "M7.5 4.5L13 10l-5.5 5.5" })); --var lineIcon = (paths) => function LineIcon() { -- return (0, import_react3.createElement)( -- "svg", -- { width: 15, height: 15, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, -- paths.map((d, index) => (0, import_react3.createElement)("path", { key: index, d })) -- ); --}; --var CLOUD = "M6.5 15.25H5.75a3.25 3.25 0 0 1-.55-6.45 4.75 4.75 0 0 1 9.2-1.1 3.75 3.75 0 0 1 .1 7.55H13.5"; --function CloudDownloadIcon({ done = false }) { -- return (0, import_react3.createElement)( -- "svg", -- { width: 16, height: 16, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, -- (0, import_react3.createElement)("path", { d: CLOUD }), -- done ? (0, import_react3.createElement)("path", { d: "M7.75 13.25l1.75 1.75 3-3.25" }) : (0, import_react3.createElement)("g", { className: "bt-cloud-arrow" }, (0, import_react3.createElement)("path", { d: "M10 9.75v7" }), (0, import_react3.createElement)("path", { d: "M7.75 14.5L10 16.75l2.25-2.25" })) ++ ); ++} +function HeldReply({ held, roster, sessionId, actions, closing, children }) { + const bot = roster.byId[sessionId]; + const chat = (roster.bots ?? []).find((item) => item.remote?.handle === held.to); @@ -7960,33 +7705,8 @@ diff --git a/client.js b/client.js + { className: "bt-held-reply", "data-state": held.status, "data-held": isApprovalWaiting(held.status) ? "" : void 0 }, + children, + closing ? (0, import_react11.createElement)(HeldReplyActions, { held: { ...held, toName, from: bot?.name }, actions, roster }) : null - ); - } --var SmileIcon = lineIcon(["M10 3.5a6.5 6.5 0 1 1 0 13 6.5 6.5 0 0 1 0-13z", "M7.3 11.6a3.4 3.4 0 0 0 5.4 0", "M7.75 8.25h.01M12.25 8.25h.01"]); --var ReplyIcon = lineIcon(["M8 5L4 9l4 4", "M4 9h7.5a4.5 4.5 0 0 1 4.5 4.5V15"]); --var DotsIcon = lineIcon(["M5 10h.01M10 10h.01M15 10h.01"]); --var CheckIcon = lineIcon(["M4.5 10.5l3.5 3.5 7.5-8"]); --var ArrowDownIcon = lineIcon(["M10 4v12", "M5 11l5 5 5-5"]); --var DownloadIcon = lineIcon(["M10 3.5v9", "M6.5 9.5L10 13l3.5-3.5", "M4 16h12"]); --var GaugeIcon = lineIcon(["M3.5 13.5a6.5 6.5 0 1 1 13 0", "M10 13.5l3-4", "M10 13.5h.01"]); --var PlugIcon = lineIcon(["M7 3v4M13 3v4", "M5 7h10v2.5a5 5 0 0 1-10 0z", "M10 14.5V17"]); --var CalendarIcon = lineIcon(["M5.5 5h9A1.5 1.5 0 0 1 16 6.5v8a1.5 1.5 0 0 1-1.5 1.5h-9A1.5 1.5 0 0 1 4 14.5v-8A1.5 1.5 0 0 1 5.5 5z", "M4 8.75h12", "M7.25 3.5v3M12.75 3.5v3"]); --var ConnectorIcon = lineIcon(["M7 10a2.5 2.5 0 1 1-5 0 2.5 2.5 0 0 1 5 0z", "M18 10a2.5 2.5 0 1 1-5 0 2.5 2.5 0 0 1 5 0z", "M7 10h6"]); --var SwitchIcon = lineIcon(["M4 7h9.5M10.5 3.5L14 7l-3.5 3.5", "M16 13H6.5M9.5 9.5L6 13l3.5 3.5"]); --var PaperclipIcon = lineIcon(["M15.5 9.5l-5.6 5.6a3.4 3.4 0 0 1-4.8-4.8l6.1-6.1a2.25 2.25 0 0 1 3.2 3.2l-6 6a1.1 1.1 0 0 1-1.6-1.6l5.5-5.5"]); --var ClockIcon = lineIcon(["M10 3.5a6.5 6.5 0 1 1 0 13 6.5 6.5 0 0 1 0-13z", "M10 6.5V10l2.5 1.5"]); --var GearIcon = lineIcon(["M10 7.4a2.6 2.6 0 1 1 0 5.2 2.6 2.6 0 0 1 0-5.2z", "M7.85 4.4l.29-1.87a7.7 7.7 0 0 1 3.72 0l.29 1.87a6 6 0 0 1 1.63.94l1.76-.69a7.7 7.7 0 0 1 1.86 3.23l-1.47 1.18a6 6 0 0 1 0 1.88l1.47 1.18a7.7 7.7 0 0 1-1.86 3.23l-1.76-.69a6 6 0 0 1-1.63.94l-.29 1.87a7.7 7.7 0 0 1-3.72 0l-.29-1.87a6 6 0 0 1-1.63-.94l-1.76.69a7.7 7.7 0 0 1-1.86-3.23l1.47-1.18a6 6 0 0 1 0-1.88L2.6 7.88a7.7 7.7 0 0 1 1.86-3.23l1.76.69a6 6 0 0 1 1.63-.94z"]); --var PinIcon = lineIcon(["M12.5 3l4.5 4.5-3 1.5-2.5 2.5.5 3.5-1.5 1.5L7 13 3.5 16.5", "M7 13l-3.5-3.5L5 8l3.5.5L11 6l1.5-3"]); --function PinMarkIcon() { -- return (0, import_react3.createElement)( -- "svg", -- { width: 12, height: 12, viewBox: "0 0 20 20", fill: "currentColor", "aria-hidden": true }, -- (0, import_react3.createElement)( -- "g", -- { transform: "rotate(45 10 10)" }, -- (0, import_react3.createElement)("path", { d: "M8 2.5h4a.9.9 0 0 1 0 1.8h-.4v4.1c0 1.4 1.1 2.6 2.6 3.1l.7.3v1.5H5.1v-1.5l.7-.3c1.5-.5 2.6-1.7 2.6-3.1V4.3H8a.9.9 0 0 1 0-1.8z" }), -- (0, import_react3.createElement)("path", { d: "M9.35 13.3h1.3v3.6L10 18l-.65-1.1z" }) -- ) ++ ); ++} +function PersonMessage({ name, texts: texts2, roster, selfId, actions, sessionId, msgKey, useUi }) { + if (!(texts2 ?? []).some((text2) => String(text2 ?? "").trim() !== "")) return null; + const lead = (0, import_react11.createElement)("span", { className: "bt-lead", title: name }, (0, import_react11.createElement)(PersonMark, { name })); @@ -7995,156 +7715,12 @@ diff --git a/client.js b/client.js + { className: "bt-group-msg bt-person-msg", "data-person": name }, + (0, import_react11.createElement)("span", { className: "bt-group-name" }, name), + (0, import_react11.createElement)(Bubbles, { texts: texts2, roster, selfId, actions, sessionId, msgKey, useUi, lead }) - ); - } --var UnreadIcon = lineIcon(["M10 3.5a4.5 4.5 0 0 0-4.5 4.5v3L4 13.5h12L14.5 11V8", "M8.3 16a1.8 1.8 0 0 0 3.4 0", "M15.5 3.2a1.6 1.6 0 1 1 0 3.2 1.6 1.6 0 0 1 0-3.2z"]); --var PencilIcon = lineIcon(["M9 4H6a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2v-3", "M14.3 3.7a1.6 1.6 0 0 1 2.3 2.3L10.5 12l-3 .8.8-3z"]); --var InfoIcon = lineIcon(["M10 3.5a6.5 6.5 0 1 1 0 13 6.5 6.5 0 0 1 0-13z", "M10 9.25v4", "M10 6.6h.01"]); --var StarLineIcon = lineIcon(["M10 3l2.1 4.3 4.7.7-3.4 3.3.8 4.7L10 13.8 5.8 16l.8-4.7L3.2 8l4.7-.7z"]); --var CopyIcon = lineIcon(["M8.5 7h6A1.5 1.5 0 0 1 16 8.5v6a1.5 1.5 0 0 1-1.5 1.5h-6A1.5 1.5 0 0 1 7 14.5v-6A1.5 1.5 0 0 1 8.5 7z", "M4 12.5V5.5A1.5 1.5 0 0 1 5.5 4H13"]); --var HideIcon = lineIcon(["M3 3l14 14", "M8.6 5.2A7.6 7.6 0 0 1 10 5c4.5 0 7 5 7 5a12.6 12.6 0 0 1-2 2.6M12.2 14.5A6.8 6.8 0 0 1 10 15c-4.5 0-7-5-7-5a12.4 12.4 0 0 1 3-3.4", "M8.5 8.5a2 2 0 0 0 3 3"]); --var TrashIcon = lineIcon(["M3.5 5.5h13", "M8 5.5V4a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1v1.5", "M5 5.5l.7 10A1.5 1.5 0 0 0 7.2 17h5.6a1.5 1.5 0 0 0 1.5-1.5l.7-10", "M8.5 9v4.5M11.5 9v4.5"]); --var DuplicateIcon = lineIcon(["M7 7h8.5v9.5H7z", "M4.5 13V3.5H13", "M11.25 9.5v4.5M9 11.75h4.5"]); --var SlidersIcon = lineIcon(["M4 6.5h5M12.5 6.5H16M4 13.5h4M11.5 13.5H16", "M10.5 4.5v4M9.5 11.5v4"]); --var MemoryIcon = lineIcon(["M5.5 3.5h8A1.5 1.5 0 0 1 15 5v10a1.5 1.5 0 0 1-1.5 1.5h-8z", "M5.5 3.5v13", "M8.5 7.5h3.5M8.5 10.5h2.5", "M12 3.5v4l1-.8 1 .8"]); --var FolderIcon = lineIcon(["M3 6A1.5 1.5 0 0 1 4.5 4.5h3.2l1.8 2h6A1.5 1.5 0 0 1 17 8v6.5a1.5 1.5 0 0 1-1.5 1.5h-11A1.5 1.5 0 0 1 3 14.5z"]); --var ChatsIcon = lineIcon(["M3.5 5.5A1.5 1.5 0 0 1 5 4h7a1.5 1.5 0 0 1 1.5 1.5V10A1.5 1.5 0 0 1 12 11.5H8L5 14v-2.5A1.5 1.5 0 0 1 3.5 10z", "M13.5 7.5H15A1.5 1.5 0 0 1 16.5 9v4.5A1.5 1.5 0 0 1 15 15v2l-2.5-2h-3A1.5 1.5 0 0 1 8 13.5V13"]); --var SETTINGS_ICONS = { general: GearIcon, bots: PeopleIcon, groups: ChatsIcon, usage: GaugeIcon }; -- --// src/client/questions.js --var import_react4 = require("react"); --var keyLetter = (index) => String.fromCharCode(65 + index); --var optionsOf = (question) => (question?.options ?? []).map((option) => typeof option === "string" ? { label: option } : option); --var isTypingTarget = (node) => node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement || node instanceof HTMLSelectElement || node instanceof HTMLElement && node.isContentEditable; --function useOptionKeys(count, onPick, ownRef) { -- const pick2 = (0, import_react4.useRef)(onPick); -- pick2.current = onPick; -- (0, import_react4.useEffect)(() => { -- if (count === 0) return void 0; -- const onKey = (event) => { -- if (event.defaultPrevented || event.metaKey || event.ctrlKey || event.altKey || event.shiftKey || event.key.length !== 1) return; -- if (isTypingTarget(event.target) || isTypingTarget(document.activeElement)) return; -- if (document.querySelector('[role="dialog"], [aria-modal="true"]') !== null) return; -- const index = event.key.toUpperCase().charCodeAt(0) - 65; -- if (index >= 0 && index < count) { -- event.preventDefault(); -- pick2.current(index); -- } else if (index === count && ownRef.current) { -- event.preventDefault(); -- ownRef.current.focus(); -- } -- }; -- document.addEventListener("keydown", onKey, true); -- return () => document.removeEventListener("keydown", onKey, true); -- }, [count, ownRef]); --} --function QuestionView({ question, onAnswer, onDismiss, ownAnswer = false, raised = false }) { -- const [own, setOwn] = (0, import_react4.useState)(""); -- const [picked, setPicked] = (0, import_react4.useState)([]); -- const ownRef = (0, import_react4.useRef)(null); -- const titleId = (0, import_react4.useId)(); -- const options = optionsOf(question); -- const multi = question.multiSelect === true; -- const custom = own.trim(); -- const canSubmit = multi ? picked.length > 0 || custom !== "" : custom !== ""; -- const choose = (label) => { -- if (multi) { -- setPicked((current) => current.includes(label) ? current.filter((item) => item !== label) : [...current, label]); -- return; -- } -- onAnswer({ selected: [label] }); -- }; -- const submit = () => { -- if (!canSubmit) return; -- onAnswer({ selected: multi ? picked : [], ...custom ? { custom } : {} }); -- }; -- useOptionKeys(options.length, (index) => choose(options[index].label), ownRef); -- const ownLetter = options.length > 0 ? keyLetter(options.length) : void 0; -- const field = ownAnswer ? (0, import_react4.createElement)("textarea", { -- ref: ownRef, -- rows: 1, -- value: own, -- autoComplete: "off", -- spellCheck: false, -- className: "bt-q-input", -- "aria-label": t("Your own answer"), -- placeholder: t("Type your own answer"), -- "aria-keyshortcuts": ownLetter?.toLowerCase(), -- onChange: (event) => setOwn(event.target.value), -- onKeyDown: (event) => { -- if (event.key !== "Enter" || event.shiftKey || event.nativeEvent.isComposing) return; -- event.preventDefault(); -- submit(); -- } -- }) : null; -- const rows = options.map((option, optionIndex) => { -- const selected = multi && picked.includes(option.label); -- return (0, import_react4.createElement)( -- "button", -- { -- key: option.label, -- type: "button", -- className: "bt-q-opt", -- "aria-pressed": multi ? selected : void 0, -- "aria-keyshortcuts": keyLetter(optionIndex).toLowerCase(), -- onClick: () => choose(option.label) -- }, -- (0, import_react4.createElement)("span", { className: "bt-key", "aria-hidden": true }, keyLetter(optionIndex)), -- (0, import_react4.createElement)( -- "span", -- { className: "bt-q-body" }, -- (0, import_react4.createElement)("span", { className: "bt-q-label" }, option.label), -- option.description ? (0, import_react4.createElement)("span", { className: "bt-q-desc" }, option.description) : null -- ), -- selected && !raised ? (0, import_react4.createElement)("span", { className: "bt-q-check", title: t("Selected") }, (0, import_react4.createElement)(CheckIcon)) : null -- ); -- }); -- if (raised && field) { -- rows.push((0, import_react4.createElement)( -- "label", -- { key: "own", className: "bt-q-opt bt-q-ownrow", "data-filled": custom !== "" ? "" : void 0 }, -- ownLetter ? (0, import_react4.createElement)("span", { className: "bt-key", "aria-hidden": true }, ownLetter) : null, -- (0, import_react4.createElement)("span", { className: "bt-q-body" }, field) -- )); -- } -- let foot = null; -- if (raised && multi) { -- foot = (0, import_react4.createElement)( -- "div", -- { className: "bt-q-foot" }, -- (0, import_react4.createElement)("button", { type: "button", className: "bt-q-btn", onClick: onDismiss }, t("Skip")), -- (0, import_react4.createElement)("button", { type: "submit", className: "bt-q-submit", disabled: !canSubmit }, t("Submit")) -- ); -- } else if ((raised || multi) && canSubmit) { -- foot = (0, import_react4.createElement)("div", { className: "bt-q-foot" }, (0, import_react4.createElement)("button", { type: "submit", className: "bt-q-submit" }, t("Submit"))); -- } -- return (0, import_react4.createElement)( -- "form", -- { -- className: raised ? "bt-q bt-q-raised" : "bt-q", -- "aria-labelledby": titleId, -- onSubmit: (event) => { -- event.preventDefault(); -- submit(); -- } -- }, -- (0, import_react4.createElement)( ++ ); ++} +function EventLine({ prefix, bots, joiner = t("and"), onOpen, whole = false }) { + if (whole && bots.length === 1) { + return (0, import_react11.createElement)( - "div", -- { className: "bt-q-head" }, -- (0, import_react4.createElement)( -- "div", -- { className: "bt-q-text" }, -- (0, import_react4.createElement)("p", { className: "bt-q-title", id: titleId }, question.header ? `${question.header} · ` : "", question.question), -- question.detail ? (0, import_react4.createElement)("div", { className: "bt-q-detail" }, question.detail) : null, -- raised && multi ? (0, import_react4.createElement)("div", { className: "bt-q-detail" }, t("Choose all that apply")) : null -- ), -- (0, import_react4.createElement)("button", { type: "button", className: "bt-q-x", "aria-label": t("Dismiss question"), title: t("Dismiss"), onClick: onDismiss }, (0, import_react4.createElement)(CloseIcon)) -- ), -- rows.length > 0 ? (0, import_react4.createElement)("div", { className: "bt-q-list", role: "group" }, rows) : null, -- field && !raised ? (0, import_react4.createElement)( ++ "div", + { className: "bt-event" }, + (0, import_react11.createElement)( + "button", @@ -8197,12 +7773,7 @@ diff --git a/client.js b/client.js + const held = heldLineState(room2, { seq, botId: bot.id, text: text2 }); + if (held === "rejected") { + return (0, import_react11.createElement)( - "div", -- { className: "bt-q-custom" }, -- (0, import_react4.createElement)("label", { className: "bt-q-field" }, field), -- !multi && canSubmit ? (0, import_react4.createElement)("button", { type: "submit", className: "bt-q-submit" }, t("Submit")) : null -- ) : null, -- foot ++ "div", + { className: "bt-event bt-not-sent", "data-flybot": "held-not-sent", role: "status" }, + (0, import_react11.createElement)(BotMark, { bot, size: 16 }), + (0, import_react11.createElement)("span", null, t("{name} · Not sent", { name: bot.name })) @@ -8225,14 +7796,8 @@ diff --git a/client.js b/client.js + { className: "bt-group-msg" }, + name, + (0, import_react11.createElement)(Bubbles, { texts: [text2], roster, selfId: bot.id, actions, sessionId: room2?.id, msgKey, useUi, lead }) - ); - } --function SettledQuestion({ question, answer }) { -- const options = optionsOf(question); -- const prompt = question?.question ?? ""; -- const chosen = [...answer.selected ?? [], ...answer.custom ? [answer.custom] : []]; -- if (chosen.length === 0) { -- return (0, import_react4.createElement)( ++ ); ++} +function remoteSpeaker(source, roster, sessionId) { + const handle = source.remote.handle; + const member = (roster.remoteBots ?? []).find((bot2) => bot2.handle === handle); @@ -8275,14 +7840,7 @@ diff --git a/client.js b/client.js + const person = typeof source.remote.typedBy === "string" ? source.remote.typedBy : ""; + if (person) { + return (0, import_react11.createElement)( - "div", -- { className: "bt-q bt-q-settled bt-q-dismissed", role: "group", "aria-label": prompt }, -- (0, import_react4.createElement)( -- "div", -- { className: "bt-q-head" }, -- (0, import_react4.createElement)("div", { className: "bt-q-text" }, (0, import_react4.createElement)("p", { className: "bt-q-title" }, prompt)), -- (0, import_react4.createElement)("span", { className: "bt-q-badge" }, t("Dismissed")) -- ) ++ "div", + { className: "bt-group-msg bt-person-msg bt-remote-msg", "data-person": person, "data-remote": handle }, + // One short header: the person, and the Bot that carried the line only when it is known (#275). + (0, import_react11.createElement)( @@ -8293,25 +7851,12 @@ diff --git a/client.js b/client.js + ), + String(words ?? "").trim() === "" ? null : (0, import_react11.createElement)(Bubbles, { texts: [words], roster, selfId: bot.id, actions, sessionId, msgKey, useUi, lead: (0, import_react11.createElement)("span", { className: "bt-lead", title: person }, (0, import_react11.createElement)(PersonMark, { name: person })) }), + files.length ? (0, import_react11.createElement)(RemoteFiles, { files, plugin: member?.plugin ?? "social", botId: roster.byId[sessionId]?.id ?? sessionId }) : null - ); - } -- return (0, import_react4.createElement)( ++ ); ++ } + const mark = (0, import_react11.createElement)(BotMark, { bot, size: 22 }); + const lead = chatId ? (0, import_react11.createElement)("button", { type: "button", className: "bt-lead", title: handle, "aria-label": t("Open {name}'s chat", { name }), onClick: () => actions.openSession(chatId) }, mark) : (0, import_react11.createElement)("span", { className: "bt-lead", title: handle }, mark); + return (0, import_react11.createElement)( - "div", -- { className: "bt-q bt-q-settled", role: "group", "aria-label": prompt }, -- prompt ? (0, import_react4.createElement)("div", { className: "bt-q-head" }, (0, import_react4.createElement)("div", { className: "bt-q-text" }, (0, import_react4.createElement)("p", { className: "bt-q-title" }, prompt))) : null, -- (0, import_react4.createElement)("div", { className: "bt-q-list", role: "group", "aria-label": t("Your answer") }, chosen.map((label, index) => { -- const optionIndex = options.findIndex((option) => option.label === label); -- return (0, import_react4.createElement)( -- "div", -- { key: `${label}-${index}`, className: "bt-q-opt" }, -- optionIndex >= 0 ? (0, import_react4.createElement)("span", { className: "bt-key", "aria-hidden": true }, keyLetter(optionIndex)) : null, -- (0, import_react4.createElement)("span", { className: "bt-q-body" }, (0, import_react4.createElement)("span", { className: "bt-q-label" }, label)), -- (0, import_react4.createElement)("span", { className: "bt-q-check", title: t("Selected") }, (0, import_react4.createElement)(CheckIcon)) -- ); -- })) ++ "div", + { 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_react11.createElement)("span", { className: "bt-group-name", style: { color: inkText(colorOf(bot)) }, title: handle }, name) : (0, import_react11.createElement)( @@ -8322,21 +7867,8 @@ diff --git a/client.js b/client.js + ), + String(words ?? "").trim() === "" ? null : (0, import_react11.createElement)(Bubbles, { texts: [words], roster, selfId: bot.id, actions, sessionId, msgKey, useUi, lead }), + files.length ? (0, import_react11.createElement)(RemoteFiles, { files, plugin: member?.plugin ?? "social", botId: roster.byId[sessionId]?.id ?? sessionId }) : null - ); - } --function QuestionCard({ matched }) { -- const pending = matched; -- const questions = pending.questions ?? []; -- const [index, setIndex] = (0, import_react4.useState)(0); -- const [answers, setAnswers] = (0, import_react4.useState)([]); -- const question = questions[index]; -- if (question === void 0) return null; -- const onAnswer = (answer) => { -- const next = [...answers, { id: question.id, ...answer }]; -- if (index + 1 < questions.length) { -- setAnswers(next); -- setIndex(index + 1); -- return; ++ ); ++} +function FirstContact({ source, roster, sessionId }) { + const person = typeof source.remote.typedBy === "string" ? source.remote.typedBy : ""; + const who = person || remoteSpeaker(source, roster).name; @@ -8369,10 +7901,8 @@ diff --git a/client.js b/client.js + setError(String(reason?.message ?? reason)); + } finally { + setBusy(""); - } -- pending.answer({ answers: next }); - }; -- return (0, import_react4.createElement)(QuestionView, { key: index, question, onAnswer, onDismiss: () => pending.dismiss(), ownAnswer: true, raised: true }); ++ } ++ }; + return (0, import_react11.createElement)( + "div", + { className: "bt-file-chips" }, @@ -8383,35 +7913,15 @@ diff --git a/client.js b/client.js + )), + error ? (0, import_react11.createElement)("span", { className: "bt-file-error", role: "alert" }, error) : null + ); - } --function PendingQuestion({ sessionId, question, actions, inline }) { -- const [answered, setAnswered] = (0, import_react4.useState)(null); -- if (answered === question.id) return null; -- const onAnswer = (answer) => { -- setAnswered(question.id); -- void actions.answerQuestion(sessionId, question.id, { selected: answer.selected ?? [], custom: answer.custom ?? "" }).catch(() => setAnswered(null)); -- }; -- const onDismiss = () => { -- setAnswered(question.id); -- void actions.dismissQuestion(sessionId); -- }; -- return (0, import_react4.createElement)( ++} +function GroupEvent({ prefix, room: room2, name, roster, actions }) { + return (0, import_react11.createElement)( - "div", -- { className: inline ? "bt-q-inline" : "bt-q-dock" }, -- (0, import_react4.createElement)(QuestionView, { key: question.id, question, onAnswer, onDismiss, ownAnswer: question.allowCustom === true, raised: !inline }) ++ "div", + { className: "bt-event" }, + (0, import_react11.createElement)("span", null, prefix), + room2 ? (0, import_react11.createElement)("button", { type: "button", onClick: () => actions.openSession(room2.id) }, (0, import_react11.createElement)(RoomAvatar, { room: room2, roster, size: 16 }), room2.name) : name ? (0, import_react11.createElement)("span", null, name) : null - ); - } --function QuestionDock({ sessionId, useRoster, useCards, useSession, actions }) { -- const question = useRoster((value) => value.questions?.[sessionId]); -- const drawn = useCards?.((state) => state[sessionId]?.[question?.callId] > 0) === true; -- const loading = useSession?.(historyLoading) === true; -- if (question === void 0 || question.callId && (drawn || loading)) return null; -- return (0, import_react4.createElement)(PendingQuestion, { sessionId, question, actions, inline: false }); ++ ); ++} +function MemoryEvent({ sessionId, scope, result, roster, actions, findBot }) { + if (!/^(Saved to|Updated in|Removed from) /.test(result)) return null; + const self = roster.byId[sessionId]; @@ -8425,80 +7935,16 @@ diff --git a/client.js b/client.js + { className: "bt-event" }, + (0, import_react11.createElement)("button", { type: "button", title: result, onClick: () => actions.openMemory(target.id) }, (0, import_react11.createElement)(MemoryIcon), label) + ); - } --function AnsweredCell({ node }) { -- const data = node.data ?? {}; -- const questions = Array.isArray(data.questions) ? data.questions : []; -- if (questions.length === 0) { -- return data.text ? (0, import_react4.createElement)("div", { className: "bt-urow" }, (0, import_react4.createElement)("div", { className: "bt-ububble" }, data.text)) : null; -- } -- const answers = Array.isArray(data.answers) ? data.answers : []; -- return (0, import_react4.createElement)("div", { className: "bt-q-inline" }, questions.map((question) => (0, import_react4.createElement)(SettledQuestion, { -- key: question.id, -- question, -- answer: answers.find((answer) => answer.id === question.id) ?? { selected: [] } -- }))); ++} +function PartLine({ time }) { + return (0, import_react11.createElement)("div", { className: "bt-part-line", role: "separator" }, Number.isFinite(time) ? (0, import_react11.createElement)("span", null, dayLabel(time)) : null); - } -- --// src/client/secret-card.js --var import_react5 = require("react"); --var MIN_VALUE = 4; --var NONE = {}; --var KeyIcon = () => (0, import_react5.createElement)( -- "svg", -- { width: 15, height: 15, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, -- (0, import_react5.createElement)("path", { d: "M7 9.5a3.5 3.5 0 1 1 0 .01z" }), -- (0, import_react5.createElement)("path", { d: "M10.4 10.6L17 10.6M14.5 10.6v2.4M16.6 10.6v1.8" }) --); --var LockIcon = () => (0, import_react5.createElement)( -- "svg", -- { width: 14, height: 14, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, -- (0, import_react5.createElement)("path", { d: "M6.5 9V7a3.5 3.5 0 0 1 7 0v2" }), -- (0, import_react5.createElement)("rect", { x: 4.5, y: 9, width: 11, height: 8, rx: 2 }) --); --var WarnIcon = () => (0, import_react5.createElement)( -- "svg", -- { width: 14, height: 14, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, -- (0, import_react5.createElement)("path", { d: "M10 3.5l7 12.5H3z" }), -- (0, import_react5.createElement)("path", { d: "M10 8.5v3.5M10 14.2h.01" }) --); --var MASK_BY_CSS = typeof CSS !== "undefined" && typeof CSS.supports === "function" && CSS.supports("-webkit-text-security", "disc"); --var insecure = () => typeof location !== "undefined" && location.protocol === "http:" && !/^(localhost|127(?:\.\d{1,3}){3}|\[::1\])$/.test(location.hostname); --var scopeLabel = (scope, roster) => { -- if (scope === "all") return t("All Bots"); -- const names = (scope ?? []).map((id) => roster.byId[id]?.name).filter(Boolean); -- return names.length === 0 ? t("No Bot") : names.join(t(" and ")); ++} +var scrollParent = (node) => { + for (let at = node.parentElement; at; at = at.parentElement) { + if (/(auto|scroll)/.test(getComputedStyle(at).overflowY)) return at; + } + return null; - }; --var when = (time) => Number.isFinite(time) ? new Date(time).toLocaleString(dateLocale(), { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }) : ""; --function MaskedInput({ value, onChange, onEnter, label, autoFocus = false }) { -- return (0, import_react5.createElement)("input", { -- className: "bt-secret-input", -- type: MASK_BY_CSS ? "text" : "password", -- "data-masked": MASK_BY_CSS ? "" : void 0, -- value, -- autoFocus, -- autoComplete: MASK_BY_CSS ? "off" : "new-password", -- autoCorrect: "off", -- autoCapitalize: "off", -- spellCheck: false, -- "data-1p-ignore": "", -- "data-lpignore": "true", -- "data-bwignore": "", -- "data-form-type": "other", -- "aria-label": label, -- placeholder: t("Paste or type the key"), -- onChange: (event) => onChange(event.target.value), -- onKeyDown: (event) => { -- if (event.key !== "Enter" || event.nativeEvent.isComposing) return; -- event.preventDefault(); -- onEnter?.(); ++}; +function EarlierParts({ sessionId, roster, actions, useUi }) { + const [view, setView] = (0, import_react11.useState)({ items: [], cursor: void 0, loading: true, failed: false, startedAt: null }); + const top = (0, import_react11.useRef)(null); @@ -8666,8 +8112,7 @@ diff --git a/client.js b/client.js + onOpen: (bot) => actions.openExchange(selfId, bot.id), + whole: true + }); - } -- }); ++ } + if (source.kind === "schedule") { + const prompts = reminderPrompts(contentText(data.content)); + return (0, import_react11.createElement)("div", { className: "bt-event" }, prompts.length > 0 ? t("Scheduled task: {text}", { text: prompts.join(" · ").slice(0, 80) }) : t("Routine started")); @@ -8675,38 +8120,10 @@ diff --git a/client.js b/client.js + if (source.kind === "user-question-reply") return null; + return (0, import_react11.createElement)("div", { className: "bt-event" }, contentText(data.content).slice(0, 80) || t("Update")); + } - } --function ScopeSwitch({ value, onChange, bot }) { -- const options = [["all", t("All Bots")], ["bot", t("Only {name}", { name: bot.name })]]; -- return (0, import_react5.createElement)( -- "div", -- { className: "bt-secret-scope", role: "radiogroup", "aria-label": t("Who may use it") }, -- (0, import_react5.createElement)("span", { className: "bt-secret-glide", "data-at": value === "all" ? 0 : 1, "aria-hidden": true }), -- options.map(([id, text]) => (0, import_react5.createElement)("button", { -- key: id, -- type: "button", -- role: "radio", -- "aria-checked": value === id, -- className: "bt-secret-seg", -- onClick: () => onChange(id) -- }, text)) -- ); ++} +function NewDivider() { + return (0, import_react11.createElement)("div", { className: "bt-new-sep", role: "separator", "aria-label": t("New messages") }, (0, import_react11.createElement)("span", null, t("New"))); - } --function SecretHead({ bot, title, detail, name, onClose }) { -- return (0, import_react5.createElement)( -- "div", -- { className: "bt-q-head bt-secret-head" }, -- (0, import_react5.createElement)("span", { className: "bt-secret-who" }, (0, import_react5.createElement)(BotMark, { bot, size: 28 }), (0, import_react5.createElement)("span", { className: "bt-secret-lock" }, (0, import_react5.createElement)(LockIcon))), -- (0, import_react5.createElement)( -- "div", -- { className: "bt-q-text" }, -- (0, import_react5.createElement)("p", { className: "bt-q-title" }, title), -- (0, import_react5.createElement)("div", { className: "bt-q-detail" }, (0, import_react5.createElement)("code", { className: "bt-secret-name" }, name), detail ? ` · ${detail}` : "") -- ), -- onClose ? (0, import_react5.createElement)("button", { type: "button", className: "bt-q-x", "aria-label": t("Cancel the request"), title: t("Cancel"), onClick: onClose }, (0, import_react5.createElement)(CloseIcon)) : null -- ); ++} +function TurnProcessCell({ node, sessionId, turnProcess, useRoster, useUi, useTurnClock, actions }) { + const open = turnProcess === void 0 || !turnProcess.foldable || turnProcess.open; + (0, import_react11.useEffect)(() => { @@ -8724,87 +8141,10 @@ diff --git a/client.js b/client.js + 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_react11.createElement)(NewDivider) : null; - } --function SecretCard({ bot, request, roster, actions }) { -- const [value, setValue] = (0, import_react5.useState)(""); -- const [scope, setScope] = (0, import_react5.useState)("all"); -- const [replacing, setReplacing] = (0, import_react5.useState)(false); -- const [busy, setBusy] = (0, import_react5.useState)(false); -- const [error, setError] = (0, import_react5.useState)(""); -- const noteId = (0, import_react5.useId)(); -- const secret = (roster.secrets ?? []).find((entry) => entry.name === request.name); -- const run = (promise) => { -- setBusy(true); -- setError(""); -- return Promise.resolve(promise).then(() => setValue(""), (failure) => setError(failure?.message ?? String(failure))).finally(() => setBusy(false)); -- }; -- const cancel = () => run(actions.secretCancel(request.id)); -- const long = value.trim().length >= MIN_VALUE; -- const save = () => { -- if (long && !busy) void run(actions.secretSet({ requestId: request.id, value, scope })); -- }; -- const allow = () => { -- if (!busy) void run(actions.secretAllow(request.id)); -- }; -- const filling = request.kind === "fill" || replacing; -- const warn = insecure() ? (0, import_react5.createElement)("div", { className: "bt-secret-warn", role: "note" }, (0, import_react5.createElement)(WarnIcon), t("This page is not on HTTPS: the key crosses the network unencrypted.")) : null; -- return (0, import_react5.createElement)( -- "div", -- { className: "bt-q bt-secret", role: "group", "aria-describedby": noteId, "data-kind": request.kind }, -- (0, import_react5.createElement)(SecretHead, { -- bot, -- title: request.kind === "allow" ? t("Let {bot} use {name}?", { bot: bot.name, name: request.name }) : t("{name} needs a key", { name: bot.name }), -- detail: request.purpose, -- name: request.name, -- onClose: busy ? void 0 : cancel -- }), -- request.kind === "allow" ? (0, import_react5.createElement)("div", { className: "bt-secret-saved" }, (0, import_react5.createElement)(KeyIcon), t("Saved for {scope}", { scope: scopeLabel(secret?.scope, roster) })) : null, -- filling ? (0, import_react5.createElement)( -- "div", -- { className: "bt-secret-body" }, -- (0, import_react5.createElement)("label", { className: "bt-q-field bt-secret-field" }, (0, import_react5.createElement)(MaskedInput, { value, onChange: setValue, onEnter: save, label: t("Value of {name}", { name: request.name }), autoFocus: replacing })), -- request.kind === "fill" ? (0, import_react5.createElement)(ScopeSwitch, { value: scope, onChange: setScope, bot }) : null -- ) : null, -- (0, import_react5.createElement)( -- "div", -- { className: "bt-secret-note", id: noteId }, -- t("{bot} never sees the value. It reaches {bot}'s shell as ", { bot: bot.name }), -- (0, import_react5.createElement)("code", null, `$DSH_SECRET_${request.name}`), -- ".", -- filling && value !== "" && !long ? t(" At least {count} characters.", { count: MIN_VALUE }) : "" -- ), -- warn, -- error ? (0, import_react5.createElement)("div", { className: "bt-secret-error", role: "alert" }, error) : null, -- (0, import_react5.createElement)( -- "div", -- { className: "bt-q-foot" }, -- request.kind === "allow" && !replacing ? (0, import_react5.createElement)("button", { type: "button", className: "bt-q-btn", disabled: busy, onClick: () => setReplacing(true) }, t("Replace value")) : null, -- (0, import_react5.createElement)("button", { type: "button", className: "bt-q-btn", disabled: busy, onClick: cancel }, t("Cancel")), -- filling ? (0, import_react5.createElement)("button", { type: "button", className: "bt-q-submit", disabled: !long || busy, onClick: save }, busy ? t("Saving…") : t("Save")) : (0, import_react5.createElement)("button", { type: "button", className: "bt-q-submit", disabled: busy, onClick: allow }, busy ? t("Allowing…") : t("Allow")) -- ) -- ); ++} +function Nothing() { + return null; - } --function SettledSecret({ bot, request, roster }) { -- const canceled = request.status === "canceled"; -- const text = canceled ? t("{bot} asked for {name}", { bot: bot.name, name: request.name }) : request.status === "allowed" ? t("Allowed {bot} to use {name}", { bot: bot.name, name: request.name }) : t("Saved {name}", { name: request.name }); -- return (0, import_react5.createElement)( -- "div", -- { className: "bt-q bt-q-settled bt-secret bt-secret-done", role: "group", "aria-label": text, "data-status": request.status }, -- (0, import_react5.createElement)( -- "div", -- { className: "bt-q-head" }, -- (0, import_react5.createElement)("span", { className: "bt-secret-who" }, (0, import_react5.createElement)(BotMark, { bot, size: 22 })), -- (0, import_react5.createElement)("div", { className: "bt-q-text" }, (0, import_react5.createElement)( -- "p", -- { className: "bt-q-title" }, -- text, -- canceled ? null : (0, import_react5.createElement)("span", { className: "bt-secret-scope-tag" }, ` · ${scopeLabel(request.scope, roster)}`) -- )), -- canceled ? (0, import_react5.createElement)("span", { className: "bt-q-badge" }, t("Canceled")) : (0, import_react5.createElement)("span", { className: "bt-q-check bt-secret-check", title: t("Done") }, (0, import_react5.createElement)(CheckIcon)) -- ) -- ); ++} +var HIDDEN_REMOVED_TOOLS = /* @__PURE__ */ new Set(["ask_user_question"]); +var isConnectorTool = (name) => typeof name === "string" && /^mcp__(github|composio)__/.test(name); +var SILENT_REGISTRATION_TOOLS = /* @__PURE__ */ new Set([ @@ -8860,9 +8200,7 @@ diff --git a/client.js b/client.js + } + if (parts.length === 0) return null; + return (0, import_react11.createElement)("div", { className: "bt-event" }, parts.join(" · ")); - } --function RequestView({ bot, request, roster, actions }) { -- return request.status === "pending" ? (0, import_react5.createElement)(SecretCard, { key: `${request.id}:${request.kind}`, bot, request, roster, actions }) : (0, import_react5.createElement)(SettledSecret, { bot, request, roster }); ++} +var SEPARATOR_GAP_MS = 30 * 60 * 1e3; +function clockLabel(time, now = Date.now()) { + const date = new Date(time); @@ -8873,66 +8211,15 @@ diff --git a/client.js b/client.js + 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 SecretToolCell({ sessionId, callId, name, done, content, roster, actions }) { -- const bot = roster.byId[sessionId]; -- if (!bot) return null; -- const request = (roster.secretRequests?.[bot.id] ?? []).find((entry) => entry.callId === callId && (entry.sessionId ?? sessionId) === sessionId); -- if (request) return (0, import_react5.createElement)("div", { className: "bt-q-inline" }, (0, import_react5.createElement)(RequestView, { bot, request, roster, actions })); -- if (done && /already saved/.test(contentText(content))) { -- return (0, import_react5.createElement)("div", { className: "bt-event" }, (0, import_react5.createElement)("span", { className: "bt-secret-event" }, (0, import_react5.createElement)(KeyIcon), t("{bot} is using {name}", { bot: bot.name, name: name ?? t("a saved key") }))); ++} +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 null; --} --function SecretDock({ sessionId, useRoster, useCards, useSession, actions }) { -- const roster = useRoster((value) => value); -- const shown = useCards?.((state) => state[sessionId]) ?? NONE; -- const loading = useSession?.(historyLoading) === true; -- const bot = roster.byId[sessionId]; -- if (!bot || currentPart(bot) !== sessionId) return null; -- const drawn = (entry) => entry.callId && entry.sessionId === sessionId && (loading || shown[entry.callId] > 0); -- const waiting = (roster.secretRequests?.[bot.id] ?? []).filter((entry) => entry.status === "pending" && !drawn(entry)); -- if (waiting.length === 0) return null; -- return (0, import_react5.createElement)("div", { className: "bt-q-dock bt-secret-dock" }, waiting.map((request) => (0, import_react5.createElement)(SecretCard, { key: `${request.id}:${request.kind}`, bot, request, roster, actions }))); ++ } + return next === void 0 ? void 0 : turns[next].first; - } --function ScopeEditor({ secret, roster, onSave }) { -- const [mode, setMode] = (0, import_react5.useState)(secret.scope === "all" ? "all" : "some"); -- const [picked, setPicked] = (0, import_react5.useState)(secret.scope === "all" ? [] : secret.scope); -- const toggle = (id) => setPicked((list) => list.includes(id) ? list.filter((item) => item !== id) : [...list, id]); -- const same = mode === "all" ? secret.scope === "all" : secret.scope !== "all" && [...picked].sort().join() === [...secret.scope].sort().join(); -- return (0, import_react5.createElement)( -- "div", -- { className: "bt-secret-edit-block" }, -- (0, import_react5.createElement)("div", { className: "bt-secret-edit-label" }, t("Who may use it")), -- (0, import_react5.createElement)( -- "div", -- { className: "bt-secret-radios", role: "radiogroup", "aria-label": t("Who may use {name}", { name: secret.name }) }, -- [["all", t("All Bots")], ["some", t("Chosen Bots")]].map(([id, text]) => (0, import_react5.createElement)( -- "label", -- { key: id, className: "bt-secret-radio" }, -- (0, import_react5.createElement)("input", { type: "radio", name: `scope-${secret.name}`, checked: mode === id, onChange: () => setMode(id) }), -- text -- )) -- ), -- mode === "some" ? (0, import_react5.createElement)("div", { className: "bt-secret-bots" }, roster.bots.map((bot) => (0, import_react5.createElement)("button", { -- key: bot.id, -- type: "button", -- className: "bt-secret-bot", -- "aria-pressed": picked.includes(bot.id), -- onClick: () => toggle(bot.id) -- }, (0, import_react5.createElement)(BotAvatar, { bot, size: 20, badge: false, live: false }), bot.name, picked.includes(bot.id) ? (0, import_react5.createElement)(CheckIcon) : null))) : null, -- (0, import_react5.createElement)( -- "div", -- { className: "bt-secret-row-actions" }, -- (0, import_react5.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: same, onClick: () => onSave(mode === "all" ? "all" : picked) }, t("Save access")) -- ) -- ); ++} +function openingTurn(turns, firstTurns, hiddenTurns) { + if (!turns || !firstTurns) return null; + const start = nextTurnStart(turns, -Infinity); @@ -8941,36 +8228,7 @@ diff --git a/client.js b/client.js + 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 ValueEditor({ secret, onSave }) { -- const [value, setValue] = (0, import_react5.useState)(""); -- const [replaced, setReplaced] = (0, import_react5.useState)(false); -- const long = value.trim().length >= MIN_VALUE; -- const save = () => { -- if (!long) return; -- void Promise.resolve(onSave(value)).then((ok) => { -- if (ok) { -- setValue(""); -- setReplaced(true); -- } -- }); -- }; -- const edit = (next) => { -- setValue(next); -- setReplaced(false); -- }; -- return (0, import_react5.createElement)( -- "div", -- { className: "bt-secret-edit-block" }, -- (0, import_react5.createElement)("div", { className: "bt-secret-edit-label" }, t("Replace the value")), -- (0, import_react5.createElement)( -- "div", -- { className: "bt-secret-inline" }, -- (0, import_react5.createElement)("label", { className: "bt-q-field bt-secret-field" }, (0, import_react5.createElement)(MaskedInput, { value, onChange: edit, onEnter: save, label: t("New value of {name}", { name: secret.name }) })), -- (0, import_react5.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: !long, onClick: save }, t("Replace")) -- ), -- value !== "" && !long ? (0, import_react5.createElement)("div", { className: "bt-set-hint" }, t("At least {count} characters.", { count: MIN_VALUE })) : replaced ? (0, import_react5.createElement)("div", { className: "bt-set-hint bt-secret-ok", role: "status" }, (0, import_react5.createElement)(CheckIcon), t("Replaced. Bots get the new value from their next command.")) : null -- ); ++} +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; @@ -8979,48 +8237,7 @@ diff --git a/client.js b/client.js + if (turns[key].first !== void 0 && turns[key].first > visit.entered) return true; + } + return false; - } --function SecretRow({ secret, roster, actions, run, open, onToggle }) { -- const [confirm, setConfirm] = (0, import_react5.useState)(false); -- (0, import_react5.useEffect)(() => { -- if (!open) setConfirm(false); -- }, [open]); -- const asker = secret.requestedBy ? roster.byId[secret.requestedBy]?.name ?? t("a deleted Bot") : t("you"); -- const allowed = secret.scope === "all" ? [] : secret.scope.map((id) => roster.byId[id]).filter(Boolean); -- const meta = [t("Asked by {name}", { name: asker }), t("added {time}", { time: when(secret.createdAt) }), secret.lastUsedAt ? t("last used {time}", { time: when(secret.lastUsedAt) }) : t("not used yet")].join(" · "); -- return (0, import_react5.createElement)( -- "div", -- { className: "bt-secret-item", "data-open": open ? "" : void 0 }, -- (0, import_react5.createElement)( -- "button", -- { type: "button", className: "bt-set-row bt-secret-summary", "aria-expanded": open, onClick: onToggle }, -- (0, import_react5.createElement)( -- "span", -- { className: "bt-set-copy" }, -- (0, import_react5.createElement)("span", { className: "bt-secret-title" }, (0, import_react5.createElement)(KeyIcon), (0, import_react5.createElement)("code", { className: "bt-secret-name" }, secret.name), secret.purpose ? (0, import_react5.createElement)("span", { className: "bt-secret-purpose" }, secret.purpose) : null), -- (0, import_react5.createElement)("span", { className: "bt-set-hint" }, meta) -- ), -- (0, import_react5.createElement)( -- "span", -- { className: "bt-set-control" }, -- secret.scope === "all" ? (0, import_react5.createElement)("span", { className: "bt-tag" }, t("All Bots")) : allowed.length === 0 ? (0, import_react5.createElement)("span", { className: "bt-tag" }, t("No Bot")) : (0, import_react5.createElement)("span", { className: "bt-secret-faces", title: allowed.map((bot) => bot.name).join(", ") }, allowed.slice(0, 5).map((bot) => (0, import_react5.createElement)(BotAvatar, { key: bot.id, bot, size: 20, badge: false, live: false }))) -- ) -- ), -- open ? (0, import_react5.createElement)( -- "div", -- { className: "bt-secret-edit" }, -- (0, import_react5.createElement)(ScopeEditor, { key: JSON.stringify(secret.scope), secret, roster, onSave: (scope) => run(actions.secretUpdate(secret.name, scope)) }), -- (0, import_react5.createElement)(ValueEditor, { secret, onSave: (value) => run(actions.secretSet({ name: secret.name, value })) }), -- (0, import_react5.createElement)( -- "div", -- { className: "bt-secret-row-actions" }, -- confirm ? [ -- (0, import_react5.createElement)("span", { key: "q", className: "bt-set-hint" }, t("Delete {name}? Bots lose it at once.", { name: secret.name })), -- (0, import_react5.createElement)("button", { key: "no", type: "button", className: "bt-soft bt-soft-sm", onClick: () => setConfirm(false) }, t("Keep")), -- (0, import_react5.createElement)("button", { key: "yes", type: "button", className: "bt-soft bt-soft-sm bt-secret-danger", onClick: () => run(actions.secretDelete(secret.name)) }, t("Delete")) -- ] : (0, import_react5.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm bt-secret-danger", onClick: () => setConfirm(true) }, (0, import_react5.createElement)(TrashIcon), t("Delete")) -- ) -- ) : null ++} +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; @@ -9044,62 +8261,11 @@ diff --git a/client.js b/client.js + null, + label ? (0, import_react11.createElement)("div", { className: "bt-time-sep", role: "separator" }, label) : null, + fresh ? (0, import_react11.createElement)(NewDivider) : null - ); - } --function AddSecret({ actions, run, onDone }) { -- const [name, setName] = (0, import_react5.useState)(""); -- const [purpose, setPurpose] = (0, import_react5.useState)(""); -- const [value, setValue] = (0, import_react5.useState)(""); -- const ready = /^[A-Za-z][\w-]{0,63}$/.test(name.trim()) && value.trim().length >= MIN_VALUE; -- const save = () => { -- if (!ready) return; -- void run(actions.secretSet({ name, purpose, value, scope: "all" })).then((ok) => { -- if (ok) { -- setValue(""); -- onDone(); -- } -- }); -- }; -- return (0, import_react5.createElement)( -- "div", -- { className: "bt-set-row bt-set-paste bt-secret-add" }, -- (0, import_react5.createElement)("input", { className: "bt-input", "aria-label": t("Key name"), placeholder: t("NAME, e.g. GITHUB_TOKEN"), value: name, spellCheck: false, autoComplete: "off", autoFocus: true, onChange: (event) => setName(event.target.value.toUpperCase()) }), -- (0, import_react5.createElement)("input", { className: "bt-input", "aria-label": t("What it is for"), placeholder: t("What it is for"), value: purpose, autoComplete: "off", onChange: (event) => setPurpose(event.target.value) }), -- (0, import_react5.createElement)("label", { className: "bt-q-field bt-secret-field" }, (0, import_react5.createElement)(MaskedInput, { value, onChange: setValue, onEnter: save, label: t("Value") })), -- (0, import_react5.createElement)( -- "div", -- { className: "bt-secret-row-actions" }, -- (0, import_react5.createElement)("span", { className: "bt-set-hint" }, t("New keys are for all Bots; narrow it down afterwards.")), -- (0, import_react5.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: onDone }, t("Cancel")), -- (0, import_react5.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: !ready, onClick: save }, t("Save key")) -- ) -- ); ++ ); ++} +function Shimmer({ children }) { + return (0, import_react11.createElement)("span", { className: "bt-shimmer" }, children); - } --function SecretsPage({ roster, actions, run, Section }) { -- const [open, setOpen] = (0, import_react5.useState)(null); -- const [adding, setAdding] = (0, import_react5.useState)(false); -- const secrets = roster.secrets ?? []; -- return [ -- (0, import_react5.createElement)( -- "p", -- { key: "about", className: "bt-secret-about" }, -- t("Keys your Bots asked for. Bots never see the values: they use them as "), -- (0, import_react5.createElement)("code", null, "$DSH_SECRET_NAME"), -- t(" in their shell, and every value is replaced by "), -- (0, import_react5.createElement)("code", null, "[secret:NAME]"), -- t(" before anything goes to a model.") -- ), -- (0, import_react5.createElement)( -- Section, -- { key: "list", title: t("Secrets · {count}", { count: secrets.length }) }, -- secrets.length === 0 && !adding ? (0, import_react5.createElement)("div", { className: "bt-set-row" }, (0, import_react5.createElement)("span", { className: "bt-set-hint" }, t("No keys yet. When a Bot needs one, it shows a card in its chat."))) : null, -- secrets.map((secret) => (0, import_react5.createElement)(SecretRow, { key: secret.name, secret, roster, actions, run, open: open === secret.name, onToggle: () => setOpen(open === secret.name ? null : secret.name) })), -- adding ? (0, import_react5.createElement)(AddSecret, { actions, run, onDone: () => setAdding(false) }) : null -- ), -- adding ? null : (0, import_react5.createElement)("div", { key: "add", className: "bt-actions" }, (0, import_react5.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => setAdding(true) }, t("Add a key"))) -- ]; ++} +var 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"] @@ -9157,99 +8323,19 @@ diff --git a/client.js b/client.js + const phrase = TOOL_PHRASES.find(([pattern]) => pattern.test(name))?.[1]; + return { label: phrase ? t(phrase) : null, state: toolState(name) }; + } ++} ++function workPhrase(tool) { ++ if (!tool) return t("Thinking about it"); ++ return toolActivity(tool, () => void 0, () => void 0)?.label ?? t("Using {tool}", { tool }); } --var SECRET_CSS = ` --.bt-secret-head{align-items:center;gap:10px} --.bt-secret-who{position:relative;flex:none;display:inline-flex} --.bt-secret-lock{position:absolute;right:-5px;bottom:-4px;width:16px;height:16px;border-radius:50%;background:var(--bt-main);color:var(--bt-ink-2);display:inline-flex;align-items:center;justify-content:center;box-shadow:0 0 0 1.5px var(--bt-bubble)} --.bt-secret-lock svg{width:11px;height:11px} --.bt-secret-name{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px;line-height:16px;padding:1px 6px;border-radius:5px;background:var(--bt-hover);color:var(--bt-ink)} --.bt-secret-body{display:flex;flex-direction:column;gap:8px} --.bt-secret-field{align-items:center;min-height:36px} --.bt-secret-input{flex:1;width:100%;min-width:0;margin:0;padding:0;border:0;outline:none;background:transparent;color:var(--bt-ink);font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:14px;line-height:20px;letter-spacing:.04em} --.bt-secret-input[data-masked]{-webkit-text-security:disc} --.bt-secret-input::placeholder{color:var(--bt-ink-3);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;letter-spacing:0} --.bt-secret-scope{position:relative;display:inline-grid;grid-template-columns:1fr 1fr;align-self:flex-start;padding:2px;border-radius:999px;background:var(--bt-hover)} --.bt-secret-glide{position:absolute;top:2px;bottom:2px;left:2px;width:calc(50% - 2px);border-radius:999px;background:var(--bt-main);box-shadow:0 1px 3px rgba(20,20,20,.08),inset 0 0 0 .5px color-mix(in srgb,var(--bt-ink) 6%,transparent);transition:${spring(SPRING_SHAPE, "transform")}} --.bt-secret-glide[data-at="1"]{transform:translateX(100%)} --.bt-secret-seg{position:relative;z-index:1;height:28px;padding:0 14px;border:0;border-radius:999px;background:none;color:var(--bt-ink-2);font:inherit;font-size:13px;white-space:nowrap;cursor:pointer;transition:color .16s ease} --.bt-secret-seg[aria-checked=true]{color:var(--bt-ink);font-weight:500} --.bt-secret-seg:focus-visible{outline:2px solid var(--bt-accent);outline-offset:-2px} --/* A key card asks the user to act, so the process group carrying it stays open in every -- work-details mode (same override as .bt-group-replies in styles.js). */ --[data-step-process]:has(.bt-secret)>div:first-child{display:none!important} --[data-step-process]:has(.bt-secret)>[data-step-process-body]{display:block!important;content-visibility:visible!important;max-height:none!important;overflow:visible!important;mask-image:none!important;scrollbar-gutter:auto!important} --.bt-secret-ok{display:flex;align-items:center;gap:4px;margin-top:6px;animation:bt-secret-in .22s cubic-bezier(.22,1,.36,1)} --.bt-secret-ok svg{width:13px;height:13px;color:#16a34a} --@keyframes bt-secret-in{from{opacity:0;transform:translateY(-2px)}} --.bt-secret-saved{display:flex;align-items:center;gap:6px;font-size:13px;line-height:18px;color:var(--bt-ink-2)} --.bt-secret-note{font-size:12px;line-height:17px;color:var(--bt-ink-3)} --.bt-secret-note code,.bt-secret-about code{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11.5px} --.bt-secret-warn{display:flex;align-items:center;gap:6px;padding:6px 10px;border-radius:8px;font-size:12px;line-height:16px;color:#b25e09;background:color-mix(in srgb,#f59e0b 12%,transparent)} --.bt-secret-error{font-size:12px;line-height:16px;color:#e02135} --.bt-secret .bt-q-btn:disabled{opacity:.5;cursor:default} --.bt-secret-done .bt-q-head{align-items:center} --.bt-secret-done .bt-q-title{font-size:13px;line-height:18px;color:var(--bt-ink-2);font-weight:500} --.bt-secret-scope-tag{font-weight:400;color:var(--bt-ink-3)} --.bt-secret-check{color:var(--bt-green,#22a06b);animation:bt-secret-pop ${SPRING_POP.duration}ms ${SPRING_POP.easing} backwards} --.bt-secret-done{animation:bt-secret-settle .32s cubic-bezier(.22,1,.36,1) backwards} --@keyframes bt-secret-pop{from{opacity:0;transform:scale(.4)}} --@keyframes bt-secret-settle{from{opacity:.4;transform:scale(.985)}} --.bt-secret-event{display:inline-flex;align-items:center;gap:5px} --.bt-secret-dock{flex-direction:column;align-items:center} --.bt-secret-dock .bt-q{max-width:min(550px,calc(100% - 32px))} --.bt-secret-about{margin:0 0 12px;padding:0 4px;font-size:12px;line-height:17px;color:var(--bt-ink-2)} --.bt-secret-title{display:flex;align-items:center;gap:6px;min-width:0;color:var(--bt-ink)} --.bt-secret-title svg{flex:none;color:var(--bt-ink-2)} --.bt-secret-purpose{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--bt-ink-2)} --.bt-secret-faces{display:inline-flex} --.bt-secret-faces>*+*{margin-left:-6px} --.bt-secret-edit{display:flex;flex-direction:column;gap:14px;padding:4px 12px 14px;animation:bt-q-in ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing} backwards,bt-veil-in .2s ease-out backwards} --.bt-secret-edit-block{display:flex;flex-direction:column;gap:8px} --.bt-secret-edit-label{font-size:12px;line-height:16px;font-weight:600;color:var(--bt-ink-2)} --.bt-secret-radios{display:flex;gap:16px;font-size:13px} --.bt-secret-radio{display:inline-flex;align-items:center;gap:6px;cursor:pointer} --.bt-secret-bots{display:flex;flex-wrap:wrap;gap:6px} --.bt-secret-bot{display:inline-flex;align-items:center;gap:6px;height:30px;padding:0 10px 0 5px;border-radius:999px;border:.5px solid var(--bt-line);background:var(--bt-card);color:var(--bt-ink-2);font:inherit;font-size:12px;cursor:pointer;transition:background-color .12s ease,color .12s ease,border-color .12s ease} --.bt-secret-bot[aria-pressed=true]{background:var(--bt-active);color:var(--bt-ink);border-color:var(--bt-line-2)} --.bt-secret-bot svg{width:13px;height:13px} --.bt-secret-inline{display:flex;gap:8px;align-items:center} --.bt-secret-row-actions{display:flex;align-items:center;justify-content:flex-end;gap:8px;flex-wrap:wrap} --.bt-secret-row-actions .bt-set-hint{flex:1} --.bt-secret-danger{color:#e02135;display:inline-flex;align-items:center;gap:4px} --.bt-secret-danger svg{width:13px;height:13px} --.bt-secret-add .bt-input{height:34px} --@media (prefers-reduced-motion:reduce){.bt-secret-glide{transition:none}.bt-secret-check,.bt-secret-done,.bt-secret-ok,.bt-secret-edit{animation:none}} --`; -- --// src/client/turn-reasoning.js --function turnReasoning(steps, current) { -- const members = steps.length ? steps : [current]; -- if (members.find((step) => stepReasoning(step) !== "")?.step !== current.step) return ""; -- return members.filter((step) => !thinkingLive(step)).map(stepReasoning).filter(Boolean).join("\n\n"); -+function workPhrase(tool) { -+ if (!tool) return t("Thinking about it"); -+ return toolActivity(tool, () => void 0, () => void 0)?.label ?? t("Using {tool}", { tool }); - } --var texts = (step) => step.blocks.filter((block) => block.kind === "text" && block.text.trim() !== ""); --var thinkingLive = (step) => step.status === "running" && texts(step).length === 0 && step.blocks.some((block) => block.kind === "reasoning"); --var stepReasoning = (step) => step.blocks.filter((block) => block.kind === "reasoning").map((block) => block.text).join("\n").trim(); -- --// src/client/cells.js --var import_react6 = require("react"); --var import_react_dom = require("react-dom"); --var import_dsh_client_ui_primitives = require("@deepseek-ai/dsh-client-ui-primitives"); --var mdLabels = () => ({ code: { copyLabel: t("Copy"), copiedLabel: t("Copied") }, footnotes: t("Footnotes") }); --function Bubbles({ texts: texts2, roster, selfId, actions, streaming, sessionId, msgKey, useUi, lead, mentions }) { -- const onClick = (event) => { -- const target = mentionTarget(event); -- if (target === null) return; -- event.preventDefault(); -- event.stopPropagation(); -- actions.openSession(target); -- }; -- const reactions = useUi ? useUi((state) => msgKey ? state.reactions?.[msgKey] ?? null : null) : null; -- return (0, import_react6.createElement)( +-var FRAGMENT_SELECTOR = /\[class\*="(_[\w-]+)"\]/g; +-var CLASS_TOKEN = /\.((?:\\.|[\w-])+)/g; +-function sheetClassNames(sheet) { +- const names = []; +- const visit = (rules) => { +- for (const rule of rules) { +- if (rule.selectorText) for (const match of rule.selectorText.matchAll(CLASS_TOKEN)) names.push(match[1]); +- if (rule.cssRules) visit(rule.cssRules); +function workText(tool, step) { + return step ? t("{action}: {detail}", { action: workPhrase(tool), detail: step }) : `${workPhrase(tool)}…`; +} @@ -9296,17 +8382,7 @@ diff --git a/client.js b/client.js + if (replies.length === 0) return null; + const room2 = roster.roomsById[sessionId]; + return (0, import_react11.createElement)( - "div", -- { className: "bt-astack", "data-lead": lead ? "" : void 0, onClickCapture: onClick }, -- texts2.map((text, index) => { -- const key = msgKey ? `${msgKey}:${index}` : void 0; -- const picked = key ? reactions?.[index] ?? [] : []; -- const last = index === texts2.length - 1; -- const live = streaming && last; -- return (0, import_react6.createElement)( -- "div", -- { key: index, className: "bt-msg" }, -- (0, import_react6.createElement)( ++ "div", + { className: "bt-group-replies" }, + replies.map((reply, index) => { + const stored = roster.byId[reply.botId]; @@ -9354,24 +8430,7 @@ diff --git a/client.js b/client.js + } + if (reply.kind === "approval" || reply.kind === "working") { + return (0, import_react11.createElement)( - "div", -- { className: "bt-msg-line" }, -- lead && last ? lead : null, -- (0, import_react6.createElement)( -- "div", -- { className: "bt-bubble" }, -- (0, import_react6.createElement)(import_dsh_client_ui_primitives.MarkdownText, { text: linkMentions(text, roster.bots, selfId), streaming: live, labels: mdLabels(), variant: "compact", fileMentions: mentions }) -- ), -- live ? null : (0, import_react6.createElement)(MessageTools, { text, name: roster.byId[selfId]?.name ?? t("Bot"), sessionId, react: key ? (emoji) => actions.toggleReaction(msgKey, index, emoji) : void 0, actions }) -- ), -- picked.length ? (0, import_react6.createElement)("div", { className: "bt-reacts" }, picked.map((emoji) => (0, import_react6.createElement)("button", { -- key: emoji, -- type: "button", -- className: "bt-react", -- "aria-label": t("Remove reaction {emoji}", { emoji }), -- onClick: () => actions.toggleReaction(msgKey, index, emoji) -- }, emoji))) : null -- ); ++ "div", + { key: `${reply.botId}-${index}`, className: "bt-event" }, + (0, import_react11.createElement)( + "button", @@ -9403,60 +8462,9 @@ diff --git a/client.js b/client.js + }); + } + return (0, import_react11.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 REACTIONS = ["👍", "❤️", "😂", "😮", "😢", "🙏"]; --function useDismiss(open, close, refs) { -- (0, import_react6.useEffect)(() => { -- if (!open) return void 0; -- const onDown = (event) => { -- if (refs.some((ref) => ref.current?.contains(event.target))) return; -- close(); -- }; -- const onKey = (event) => { -- if (event.key === "Escape") { -- event.stopPropagation(); -- close(); -- } -- }; -- window.addEventListener("mousedown", onDown); -- window.addEventListener("keydown", onKey, true); -- return () => { -- window.removeEventListener("mousedown", onDown); -- window.removeEventListener("keydown", onKey, true); -- }; -- }, [open]); --} --function MessageTools({ text, name, sessionId, react, actions }) { -- const [panel, setPanel] = (0, import_react6.useState)(null); -- const [place, setPlace] = (0, import_react6.useState)(null); -- const [copied, setCopied] = (0, import_react6.useState)(false); -- const tools = (0, import_react6.useRef)(null); -- const pop = (0, import_react6.useRef)(null); -- const close = () => setPanel(null); -- useDismiss(panel !== null, close, [tools, pop]); -- const open = (kind, event) => { -- if (panel === kind) { -- close(); -- return; -- } -- const rect = event.currentTarget.getBoundingClientRect(); -- const below = rect.bottom + 220 < window.innerHeight; -- setPlace(kind === "react" ? { left: rect.left + rect.width / 2, top: rect.top - 6, "--bt-origin": "bottom center" } : { left: Math.min(rect.left, window.innerWidth - 208), ...below ? { top: rect.bottom + 4 } : { bottom: window.innerHeight - rect.top + 4, "--bt-origin": "bottom left" } }); -- setPanel(kind); -- }; -- const copy2 = () => { -- void navigator.clipboard?.writeText(text).then(() => { -- setCopied(true); -- setTimeout(() => setCopied(false), 1e3); -- }); -- }; -- const download2 = () => { -- const url = URL.createObjectURL(new Blob([text], { type: "text/markdown" })); -- const link = Object.assign(document.createElement("a"), { href: url, download: `${name}-message.md` }); -- link.click(); -- setTimeout(() => URL.revokeObjectURL(url), 1e3); ++ }) ++ ); ++} +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) { @@ -9468,42 +8476,10 @@ diff --git a/client.js b/client.js + seen.add(id); + calls.push(call4); + for (const child of call4.subCalls ?? []) visit(child); - }; -- const item = (label, Icon, run) => (0, import_react6.createElement)("button", { key: label, type: "button", role: "menuitem", onClick: () => { -- close(); -- run(); -- } }, (0, import_react6.createElement)(Icon), label); -- return (0, import_react6.createElement)( -- "div", -- { ref: tools, className: "bt-tools", "data-open": panel !== null || void 0 }, -- react ? (0, import_react6.createElement)("button", { type: "button", className: "bt-tool", "aria-label": t("Add reaction"), title: t("Add reaction"), "aria-expanded": panel === "react", onClick: (event) => open("react", event) }, (0, import_react6.createElement)(SmileIcon)) : null, -- sessionId ? (0, import_react6.createElement)("button", { type: "button", className: "bt-tool", "aria-label": t("Reply"), title: t("Reply"), onClick: () => actions.quote(sessionId, text) }, (0, import_react6.createElement)(ReplyIcon)) : null, -- (0, import_react6.createElement)("button", { type: "button", className: "bt-tool", "aria-label": t("More message actions"), title: t("More"), "aria-haspopup": "menu", "aria-expanded": panel === "more", onClick: (event) => open("more", event) }, copied ? (0, import_react6.createElement)(CheckIcon) : (0, import_react6.createElement)(DotsIcon)), -- panel === "react" ? (0, import_react_dom.createPortal)((0, import_react6.createElement)( -- "div", -- { ref: pop, className: "bt-react-strip", role: "menu", "aria-label": t("Reactions"), style: place }, -- REACTIONS.map((emoji) => (0, import_react6.createElement)("button", { key: emoji, type: "button", role: "menuitem", "aria-label": emoji, onClick: () => { -- close(); -- react(emoji); -- } }, emoji)) -- ), document.body) : null, -- panel === "more" ? (0, import_react_dom.createPortal)((0, import_react6.createElement)( -- "div", -- { ref: pop, className: "bt-menu", role: "menu", "aria-label": t("Message actions"), style: place }, -- item(t("Copy"), CopyIcon, copy2), -- item(t("Download"), DownloadIcon, download2) -- ), document.body) : null -- ); ++ }; + visit(root); + return calls; - } --function ThinkingRow({ text }) { -- return (0, import_react6.createElement)( -- "details", -- { className: "bt-think" }, -- (0, import_react6.createElement)("summary", null, t("Thought it through")), -- (0, import_react6.createElement)("div", { className: "bt-think-text" }, text) -- ); ++} +function useShownCards(actions, sessionId, callIds) { + const key = JSON.stringify(callIds); + (0, import_react11.useLayoutEffect)(() => { @@ -9512,16 +8488,7 @@ diff --git a/client.js b/client.js + actions.noteCards(sessionId, ids, 1); + return () => actions.noteCards(sessionId, ids, -1); + }, [sessionId, key]); - } --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 renderCall(call4, { sessionId, roster, actions, useUi, findBot }) { + const tool = toolName(call4); + const done = call4.kind === "tool-result"; @@ -9566,7 +8533,11 @@ diff --git a/client.js b/client.js + const name = named[2]; + const room2 = roster.rooms.find((entry) => entry.name === name); + return (0, import_react11.createElement)(GroupEvent, { prefix: t(verbs[0]), room: tool === "delete_group" ? void 0 : room2, name, roster, actions }); -+ } + } +- }; +- try { +- visit(sheet.cssRules); +- } catch { + case "ask_user": { + const question = roster.questions?.[sessionId]; + if (!question || question.callId !== call4.callId) return (0, import_react11.createElement)("span", { className: "bt-q-gone", hidden: true }); @@ -9597,43 +8568,18 @@ diff --git a/client.js b/client.js + return done && !call4.isError ? (0, import_react11.createElement)(MemoryEvent, { sessionId, scope: arg("scope"), result: contentText(call4.content), roster, actions, findBot }) : null; + default: + return null; -+ } + } +- return names; } --function AssistantCell({ node, groupPart, sessionId, openFile, fileMentions, useTurnData, useRoster, useUi, useTurnSteps, useTurnContext, actions }) { +-function compileFragmentSelectors(text, names) { +- const compiled = /* @__PURE__ */ new Map(); +- return text.replace(FRAGMENT_SELECTOR, (whole, fragment) => { +- if (!compiled.has(fragment)) { +- const exact = []; +- for (const name of names) if (name.includes(fragment)) exact.push(`.${name}`); +- compiled.set(fragment, exact.length ? `:is(${exact.join(",")})` : whole); +function ToolCell({ node, sessionId, useRoster, useUi, actions }) { - const roster = useRoster((value) => value); -- const data = node.data; -- const steps = useTurnSteps(String(data.turn)); -- const contexts = useTurnContext(String(data.turn)); -- const mentions = useFileMentions({ node, openFile, fileMentions, useTurnData }); -- const hidden = Boolean(roster.roomsById[sessionId]) || isHiddenTurn(roster, sessionId, data.turn); -- const texts2 = groupPart === "reasoning" ? [] : data.blocks.filter((block) => block.kind === "text" && block.text.trim() !== "").map((block) => block.text); -- const running = data.status === "running"; -- useLiveNote(actions, sessionId, `text:${node.key ?? data.seq}`, !hidden && running && texts2.length > 0 ? { text: true } : null); -- if (hidden) return null; -- const reasoning = groupPart === "response" ? "" : turnReasoning(steps, data); -- const relays = relayPart(data, groupPart) ? relaysBefore(contexts, steps, data) : []; -- if (texts2.length === 0 && !reasoning && relays.length === 0) return null; -- const selfId = roster.byId[sessionId]?.id ?? sessionId; -- return (0, import_react6.createElement)( -- import_react6.Fragment, -- null, -- relays.map((relay) => { -- const source = relay.source; -- const sender = roster.byId[source.senderSessionId] ?? { id: source.senderSessionId, name: source.senderName ?? t("a Bot"), color: "gray" }; -- return (0, import_react6.createElement)(EventLine, { -- key: relay.seq, -- prefix: source.role === "stopped" ? t("Stopped by") : source.role === "reply" ? t("Reply from") : t("Message from"), -- bots: [sender], -- onOpen: () => { -- if (sender.id) actions.openExchange(selfId, sender.id); -- }, -- whole: true -- }); -- }), -- reasoning ? (0, import_react6.createElement)(ThinkingRow, { text: reasoning }) : null, -- texts2.length ? (0, import_react6.createElement)(Bubbles, { texts: texts2, roster, selfId, actions, streaming: running, sessionId, msgKey: `${sessionId}:${data.turn}:${node.key ?? data.seq ?? ""}`, useUi, mentions }) : null -- ); ++ const roster = useRoster((value) => value); + const root = node.data.root; + const turn = node.location?.turn?.turn ?? root.turn; + const hidden = isHiddenTurn(roster, sessionId, turn); @@ -9647,33 +8593,7 @@ diff --git a/client.js b/client.js + if (drawn.length === 0) return null; + const env = { sessionId, roster, actions, useUi, findBot }; + return (0, import_react11.createElement)(import_react11.Fragment, null, drawn.map((call4, index) => (0, import_react11.createElement)(import_react11.Fragment, { key: call4.callId ?? index }, renderCall(call4, env)))); - } --function EventLine({ prefix, bots, joiner = t("and"), onOpen, whole = false }) { -- if (whole && bots.length === 1) { -- return (0, import_react6.createElement)( -- "div", -- { className: "bt-event" }, -- (0, import_react6.createElement)( -- "button", -- { type: "button", className: "bt-event-whole", title: bots[0].name, onClick: () => onOpen?.(bots[0]) }, -- (0, import_react6.createElement)("span", null, prefix), -- (0, import_react6.createElement)(BotMark, { bot: bots[0], size: 16 }), -- (0, import_react6.createElement)("span", { className: "bt-event-name" }, bots[0].name) -- ) -- ); -- } -- const parts = []; -- parts.push((0, import_react6.createElement)("span", { key: "p" }, prefix)); -- bots.forEach((bot, index) => { -- if (index > 0) parts.push((0, import_react6.createElement)("span", { key: `j${index}` }, joiner)); -- parts.push((0, import_react6.createElement)( -- "button", -- { key: bot.id ?? index, type: "button", onClick: () => onOpen?.(bot) }, -- (0, import_react6.createElement)(BotMark, { bot, size: 16 }), -- bot.name -- )); -- }); -- return (0, import_react6.createElement)("div", { className: "bt-event" }, parts); ++} + +// src/client/awaiting.js +var AWAIT_REPLY_MS = 10 * 60 * 1e3; @@ -9681,24 +8601,7 @@ 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; - } --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 }) -- ); ++} + +// src/client/remote-approval-wait.js +var REMOTE_APPROVAL_LABEL = "Waiting for their approval"; @@ -9706,14 +8609,7 @@ diff --git a/client.js b/client.js + if (!remote) return null; + if (tool && inputAt <= (tool.at ?? 0)) return null; + return { key: "remote-approval", label: REMOTE_APPROVAL_LABEL, state: "thinking" }; - } --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 -- ); ++} + +// src/client/activity-row-host.js +var RUNNING_HOST = "[data-chat-flow] [data-chat-running]"; @@ -9723,20 +8619,7 @@ diff --git a/client.js b/client.js + if (typeof root.matches === "function" && root.matches("[data-chat-running]")) return root; + if (typeof Element !== "undefined" && root instanceof Element) return root.querySelector("[data-chat-running]"); + return root.querySelector(RUNNING_HOST); - } --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) -- ); ++} + +// src/client/activity-row.js +var import_react12 = require("react"); @@ -9748,9 +8631,7 @@ diff --git a/client.js b/client.js + 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`; - } --function PartLine({ time }) { -- return (0, import_react6.createElement)("div", { className: "bt-part-line", role: "separator" }, Number.isFinite(time) ? (0, import_react6.createElement)("span", null, dayLabel(time)) : null); ++} +var REMOTE_GRACE_MS = 1500; +function useAwaitingReply(roster, sessionId) { + const at = roster?.remoteAwaiting?.[sessionId]; @@ -9762,12 +8643,7 @@ diff --git a/client.js b/client.js + return () => clearTimeout(timer); + }, [shown, at]); + return shown; - } --var scrollParent = (node) => { -- for (let at = node.parentElement; at; at = at.parentElement) { -- if (/(auto|scroll)/.test(getComputedStyle(at).overflowY)) return at; -- } -- return null; ++} +function findHost(where) { + if (where !== TAIL_HOST) return findRunningHostIn(document); + const flow = document.querySelector("[data-conversation-scroll] [data-chat-flow]") ?? document.querySelector("[data-chat-flow]"); @@ -9781,26 +8657,7 @@ diff --git a/client.js b/client.js + let top = -1; + for (const node of document.querySelectorAll(`[data-chat-flow-kind${kind ? `="${kind}"` : ""}][data-chat-turn]`)) top = Math.max(top, Number(node.dataset.chatTurn)); + return top; - }; --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 useReplyAfterSend(remote, sending) { + const sendTurn = (0, import_react12.useRef)(-1); + const wasSending = (0, import_react12.useRef)(false); @@ -9812,7 +8669,9 @@ diff --git a/client.js b/client.js + sendTurn.current = -1; + setReplied(false); + return void 0; -+ } + } +- return compiled.get(fragment); +- }); + if (wasSending.current) { + wasSending.current = false; + sendTurn.current = lastTurnOnScreen(); @@ -9825,144 +8684,57 @@ diff --git a/client.js b/client.js + }; + const observer = new MutationObserver(() => { + if (!frame) frame = requestAnimationFrame(check); - }); -- }; -- (0, import_react6.useEffect)(() => { -- alive.current = true; -- load(void 0); ++ }); + observer.observe(document.querySelector("[data-conversation-scroll]") ?? document.body, { childList: true, subtree: true }); + check(); - return () => { -- alive.current = false; ++ return () => { + cancelAnimationFrame(frame); + observer.disconnect(); - }; -- }, [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); -- } -- return (0, import_react6.createElement)( -- "div", -- { className: "bt-earlier", role: "feed", "aria-label": t("Earlier messages"), "aria-busy": view.loading }, -- (0, import_react6.createElement)( -- "div", -- { ref: top, className: "bt-earlier-top" }, -- view.loading ? (0, import_react6.createElement)(Shimmer, null, t("Loading earlier messages…")) : view.failed ? (0, import_react6.createElement)("button", { type: "button", className: "bt-soft", onClick: () => load(view.cursor) }, t("Couldn’t load earlier messages · Retry")) : view.cursor ? (0, import_react6.createElement)("button", { type: "button", className: "bt-soft", onClick: () => load(view.cursor) }, t("Load earlier")) : null -- ), -- rows.map((row, index) => { -- const key = rows.length - index; -- switch (row.kind) { -- case "divider": -- return (0, import_react6.createElement)(PartLine, { key, time: row.time }); -- case "user": -- return (0, import_react6.createElement)("div", { key, className: "bt-urow" }, (0, import_react6.createElement)( -- "div", -- { className: "bt-ububble" }, -- row.text ? (0, import_react6.createElement)(import_dsh_client_ui_primitives.MarkdownText, { text: row.text, labels: mdLabels(), variant: "compact" }) : null, -- row.images ? (0, import_react6.createElement)("span", { className: "bt-uimages" }, row.images === 1 ? t("1 image") : t("{count} images", { count: row.images })) : null -- )); -- case "answer": -- return (0, import_react6.createElement)(AnsweredCell, { key, node: { data: row } }); -- case "bot": -- return (0, import_react6.createElement)(Bubbles, { key, texts: row.texts, roster, selfId, actions }); -- case "event": { -- if (row.role === "report") return (0, import_react6.createElement)(GroupEvent, { key, prefix: t("Replies from"), room: roster.roomsById[row.roomId], name: t("a group chat"), roster, actions }); -- const other = roster.byId[row.botId] ?? { id: row.botId, name: row.name ?? t("a Bot"), color: "gray" }; -- const prefix = { stopped: t("Stopped by"), reply: t("Reply from") }[row.role] ?? t("Message from"); -- return (0, import_react6.createElement)(EventLine, { key, prefix, bots: [other], onOpen: () => { -- if (selfId && other.id) actions.openExchange(selfId, other.id); -- }, whole: true }); -- } -- default: -- return null; -- } -- }), -- (0, import_react6.createElement)(PartLine, { time: view.startedAt }) -- ); ++ }; + }, [remote, sending]); + return replied; } --function TriggerCell({ node, sessionId, useRoster, useSessions, useTurnClock, useUi, actions }) { -- const roster = useRoster((value) => value); -- const data = node.data; -- const source = data.source !== null && typeof data.source === "object" ? data.source : {}; -- const selfId = roster.byId[sessionId]?.id ?? sessionId; -- const deferred = source.kind === "bot" && source.deferred === true; -- const relayed = isRelayContext(data); -- const turn = node.location?.turn?.turn ?? data.turn; -- (0, import_react6.useLayoutEffect)(() => { -- if (relayed) actions.noteTurnPeer(sessionId, turn, source.senderSessionId); -- }, [relayed, sessionId, turn, source.senderSessionId]); -- const order = useSessions((list) => { -- const outline = list.projectionsBySession?.[sessionId]?.values?.turnOutline ?? list.byId[sessionId]?.projectionValues?.turnOutline; -- return Array.isArray(outline) ? outline.map((entry) => entry.turn).join(",") : ""; -- }); -- const peers = useTurnClock((state) => { -- if (!relayed) return ""; -- const turns = state[sessionId] ?? {}; -- return Object.keys(turns).filter((key) => turns[key].peer !== void 0).map((key) => `${key}:${turns[key].peer}`).join(","); -- }); -- const visit = useUi((state) => state.visits?.[sessionId]); -- const hiddenTurns = roster.exchangeTurns[sessionId]; -- const unreadOpening = useTurnClock((state) => { -- const first = openingTurn(state[sessionId], order, hiddenTurns); -- return first?.turn === turn && unseenSince(visit, first.start) ? clockLabel(first.start) : ""; -- }); -- const body = triggerBody(); -- return unreadOpening ? (0, import_react6.createElement)(import_react6.Fragment, null, (0, import_react6.createElement)("div", { className: "bt-open-new" }, (0, import_react6.createElement)(NewDivider), (0, import_react6.createElement)("div", { className: "bt-time-sep", role: "separator" }, unreadOpening)), body) : body; -- function triggerBody() { -- if (source.kind === "bot") { -- if (source.handoff === true) return roster.roomsById[sessionId] ? null : (0, import_react6.createElement)(EarlierParts, { sessionId, roster, actions }); -- if (source.role === "kickoff" || source.role === "room") return null; -- if (source.role === "secret" || deferred) return null; -- if (source.role === "post") { -- if (source.failed !== void 0 || source.resumed === true || source.stopped === true) return null; -- const room2 = roster.roomsById[sessionId]; -- const poster = roster.byId[source.senderSessionId] ?? { id: source.senderSessionId, name: source.senderName ?? t("a Bot"), color: "gray" }; -- return (0, import_react6.createElement)(GroupMessage, { bot: poster, text: stripHeader(contentText(data.content)), room: room2, roster, actions, msgKey: `${sessionId}:${data.turn}:post`, useUi }); -- } -- if (source.role === "report") { -- return (0, import_react6.createElement)(GroupEvent, { prefix: t("Replies from"), room: roster.roomsById[source.roomId], name: t("a group chat"), roster, actions }); +-function installCompiledStyles(text, tag) { +- const style = installStyles("", tag); +- const cache = /* @__PURE__ */ new WeakMap(); +- const render = () => { +- const names = /* @__PURE__ */ new Set(); +- for (const sheet of document.styleSheets) { +- if (sheet.ownerNode?.dataset?.dshBot) continue; +- let list = cache.get(sheet); +- if (!list) { +- list = sheetClassNames(sheet); +- cache.set(sheet, list); - } -- 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); +- for (const name of list) names.add(name); +function useRunningHost(active, where = RUNNING_HOST) { + const [host, setHost] = (0, import_react12.useState)(null); + (0, import_react12.useEffect)(() => { + if (!active) { + setHost(null); + return void 0; -+ } + } +- const next = compileFragmentSelectors(text, names); +- if (style.textContent !== next) style.textContent = next; +- }; +- render(); +- let timer; +- const schedule = () => { +- clearTimeout(timer); +- timer = setTimeout(render, 120); +- }; +- const onLoad = (event) => { +- if (event.target instanceof HTMLLinkElement) schedule(); +- }; +- const observer = new MutationObserver(schedule); +- observer.observe(document.head, { childList: true }); +- document.addEventListener("load", onLoad, true); +- return () => { +- observer.disconnect(); +- document.removeEventListener("load", onLoad, true); +- clearTimeout(timer); +- style.remove(); +- }; + let frame = 0; + const find = () => { + frame = 0; @@ -9980,7 +8752,16 @@ diff --git a/client.js b/client.js + }; + }, [active, where]); + return host; -+} + } +- +-// src/client/mark.js +-var import_react2 = require("react"); +-var STATES = ["idle", "thinking", "searching", "working", "sending", "orbit", "alert", "done"]; +-var botState = (running, activity, alert) => running ? STATES.includes(activity) ? activity : "working" : alert ? "alert" : "idle"; +-var toolState = (name) => /^(message_bot|stop_bot|create_bot|update_bot|create_group|update_group|delete_group|post_to_group)$/.test(name) ? "sending" : /search|fetch|browse|read|grep|glob|list|recall/i.test(name) ? "searching" : "working"; +-function useGaze(ref, enabled) { +- (0, import_react2.useEffect)(() => { +- if (!enabled) return void 0; +function useRowEntrances(replacing) { + const check = (0, import_react12.useRef)(replacing); + check.current = replacing; @@ -9988,31 +8769,34 @@ diff --git a/client.js b/client.js + 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; + let frame = 0; +- let point = null; +- const place = () => { ++ 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; + frame = 0; +- const node = ref.current; +- if (!node || !point) return; +- const box = node.getBoundingClientRect(); +- const clamp = (value) => Math.max(-1, Math.min(1, value)); +- node.style.setProperty("--bt-gx", clamp((point.x - box.left - box.width / 2) / 160).toFixed(2)); +- node.style.setProperty("--bt-gy", clamp((point.y - box.top - box.height / 2) / 160).toFixed(2)); + const next = document.querySelector("[data-chat-flow]"); + if (next !== column) { + observer.disconnect(); + column = next; + if (column) observer.observe(column, { childList: true, subtree: true }); - } -- return (0, import_react6.createElement)(EventLine, { -- prefix: source.role === "stopped" ? t("Stopped by") : count > 1 ? t("{count} messages with", { count }) : t("Message from"), -- bots, -- onOpen: (bot) => actions.openExchange(selfId, bot.id), -- whole: true ++ } + if (!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; @@ -10022,7 +8806,10 @@ diff --git a/client.js b/client.js + item.dataset.btEnter = reply && check.current() ? "after-collapse" : "new"; + item.addEventListener("animationend", settle); + } -+ }; + }; +- const onMove = (event) => { +- point = { x: event.clientX, y: event.clientY }; +- if (!frame) frame = requestAnimationFrame(place); + const observer = new MutationObserver(() => { + if (!frame) frame = requestAnimationFrame(scan); + }); @@ -10030,7 +8817,11 @@ diff --git a/client.js b/client.js + return () => { + cancelAnimationFrame(frame); + observer.disconnect(); -+ }; + }; +- const onLeave = () => { +- point = null; +- ref.current?.style.setProperty("--bt-gx", "0"); +- ref.current?.style.setProperty("--bt-gy", "0"); + }, []); +} +function ActivityIndicator({ sessionId, useRoster, useSessionStatus, useLive, useSession, actions }) { @@ -10071,10 +8862,7 @@ diff --git a/client.js b/client.js + if (wait <= 0) { + make(); + 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")); ++ } + const timer = setTimeout(make, wait); + return () => clearTimeout(timer); + }, [wantKey, shown]); @@ -10087,10 +8875,7 @@ diff --git a/client.js b/client.js + if (visible) { + setPhase("in"); + return void 0; - } -- 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")); -- } ++ } + let timer; + const leave = () => { + setPhase((current) => current === "in" ? "out" : current); @@ -10098,7 +8883,9 @@ diff --git a/client.js b/client.js + shift.current = document.querySelector("[data-conversation-scroll]")?.scrollTop ?? null; + setPhase((current) => current === "out" ? null : current); + }, 140); -+ }; + }; +- window.addEventListener("pointermove", onMove, { passive: true }); +- document.documentElement.addEventListener("pointerleave", onLeave); + if (steady) timer = setTimeout(leave, REMOTE_GRACE_MS); + else leave(); + return () => clearTimeout(timer); @@ -10148,9 +8935,7 @@ diff --git a/client.js b/client.js + ) + ) + ), host); - } --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"))); ++} + +// src/client/dsh-offer.js +var APP_OFFER = /* @__PURE__ */ new Set(["available", "downloading", "verifying", "installing", "ready"]); @@ -10160,24 +8945,7 @@ diff --git a/client.js b/client.js + 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 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; ++} + +// src/client/overlay-hooks.js +var import_react13 = require("react"); @@ -10191,14 +8959,45 @@ diff --git a/client.js b/client.js + (0, import_react13.useEffect)(() => { + if (escapeLayers.length === 0) window.addEventListener("keydown", onEscapeKey); + escapeLayers.push(ref); -+ return () => { + return () => { +- cancelAnimationFrame(frame); +- window.removeEventListener("pointermove", onMove); +- document.documentElement.removeEventListener("pointerleave", onLeave); + escapeLayers.splice(escapeLayers.indexOf(ref), 1); + if (escapeLayers.length === 0) window.removeEventListener("keydown", onEscapeKey); -+ }; + }; +- }, [enabled]); + }, []); } --function Nothing() { -- return null; +-var FIDGETS = ["hop", "sway", "stretch", "look"]; +-function BotMark({ bot, look: given, size = 36, state = "idle", live = false, gaze = false, pokeable = false, label }) { +- const look = given ?? lookOf(bot); +- const ref = (0, import_react2.useRef)(null); +- const maskId = (0, import_react2.useRef)(""); +- if (maskId.current === "") maskId.current = `bt-mark-${nextMarkSerial()}`; +- const [poked, setPoked] = (0, import_react2.useState)(false); +- useGaze(ref, gaze); +- const common = { +- ref, +- className: poked ? "bt-mark bt-mark-poke" : "bt-mark", +- "data-shape": look.image ? "image" : shapeId(look.shape), +- "data-state": STATES.includes(state) ? state : "idle", +- "data-live": live ? "" : void 0, +- "data-fidget": live ? FIDGETS[fnv1a(`${bot?.id ?? ""}:fidget`) % FIDGETS.length] : void 0, +- "data-gaze": gaze ? "" : void 0, +- "data-poke": pokeable ? "" : void 0, +- style: { width: size, height: size, ...live ? { "--bt-delay": `${-(fnv1a(String(bot?.id ?? "")) % 5e3) / 1e3}s`, "--bt-fidget-delay": `${-(fnv1a(`${bot?.id ?? ""}:when`) % 9e3) / 1e3}s` } : {} }, +- role: label ? "img" : void 0, +- "aria-label": label, +- "aria-hidden": label ? void 0 : true, +- onClick: pokeable ? () => setPoked(true) : void 0, +- onAnimationEnd: poked ? (event) => { +- if (/^bt-poke/.test(event.animationName)) setPoked(false); +- } : void 0 +- }; +- 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 useDialogFocus(ref) { + (0, import_react13.useEffect)(() => { + const before = document.activeElement; @@ -10211,16 +9010,12 @@ diff --git a/client.js b/client.js + }; + }, []); } --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 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 usePaneLeft() { + const [paneLeft, setPaneLeft] = (0, import_react13.useState)(280); + (0, import_react13.useLayoutEffect)(() => { @@ -10229,13 +9024,8 @@ diff --git a/client.js b/client.js + }, []); + return paneLeft; } --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 AdminStar() { +- return (0, import_react2.createElement)("span", { className: "bt-admin-star", role: "img", "aria-label": t("Admin") }, (0, import_react2.createElement)(StarIcon, { size: 7 })); + +// src/client/member-status.js +var import_react14 = require("react"); @@ -10259,49 +9049,50 @@ diff --git a/client.js b/client.js + return { id, approvals, doing, here: doing !== void 0, state: approvals.length > 0 ? "ask" : doing ? "work" : "idle" }; + }); } --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 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 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] })); } --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; -- }); -- 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 useSettled(state) { +- const prev = (0, import_react2.useRef)(state); +- const [settled, setSettled] = (0, import_react2.useState)(false); +- (0, import_react2.useEffect)(() => { +- const was = prev.current; +- prev.current = state; +- if (state !== "idle") { +- setSettled(false); +- return void 0; +- } +- if (was === "idle" || was === "alert") return void 0; +- setSettled(true); +- const timer = setTimeout(() => setSettled(false), 1800); +- return () => clearTimeout(timer); +- }, [state]); +- return settled; +function 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 Shimmer({ children }) { -- return (0, import_react6.createElement)("span", { className: "bt-shimmer" }, children); +-function BotAvatar({ bot, size, state = "idle", main, admin, badge = true, live = true, gaze = false, pokeable = false }) { +- const settled = useSettled(state); +- if (bot === void 0) return (0, import_react2.createElement)(BotMark, { size }); +- const badgeKind = !badge ? null : state === "alert" ? "alert" : state !== "idle" ? "working" : admin ? "admin" : main ? "main" : null; +- return (0, import_react2.createElement)( +- "span", +- { className: "bt-mark", style: { width: size, height: size, "--bt-size": `${size}px` } }, +- (0, import_react2.createElement)(BotMark, { bot, size, state: settled ? "done" : state, live, gaze, pokeable }), +- badgeKind === "working" ? (0, import_react2.createElement)("span", { className: "bt-badge bt-badge-working", "aria-label": t("Working"), role: "img" }) : null, +- badgeKind === "alert" ? (0, import_react2.createElement)("span", { className: "bt-badge bt-badge-alert", "aria-label": t("Waiting for your answer"), role: "img" }, "?") : null, +- badgeKind === "admin" ? (0, import_react2.createElement)("span", { className: "bt-badge bt-badge-admin", "aria-label": t("Admin"), role: "img" }, (0, import_react2.createElement)(StarIcon)) : null, +- badgeKind === "main" ? (0, import_react2.createElement)("span", { className: "bt-badge bt-badge-main", "aria-label": t("Main Bot"), role: "img" }, (0, import_react2.createElement)(MainStar)) : null +- ); +function botGroupWork(botId, work, roster) { + return Object.entries(work.rooms ?? {}).flatMap(([roomId, entries2]) => { + const entry = entries2.find((item) => item.botId === botId); @@ -10309,63 +9100,16 @@ diff --git a/client.js b/client.js + return entry && room2 ? [{ room: room2, tool: entry.tool, step: stepIn(work, botId, roomId, entry.tool), since: entry.since }] : []; + }); } --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 clusterSeats(count, frame) { +- if (count === 1) return [[0, 0, frame]]; +- if (count === 2) return [[0, 0, frame * 2 / 3], [frame / 3, frame / 3, frame * 2 / 3]]; +- if (count === 3) { +- const size = frame * 5 / 9; +- const rest = frame - size; +- return [[rest / 2, 0, size], [0, rest, size], [rest, rest, size]]; - } +- const half = frame / 2; +- return [[0, 0, half], [half, 0, half], [0, half, half], [half, half, half]]; +var moreRooms = (count) => count === 1 ? t("1 more room") : t("{count} more rooms", { count }); +function summaryText(busy) { + const asks = busy.filter((entry) => entry.state === "ask"); @@ -10376,23 +9120,58 @@ diff --git a/client.js b/client.js + const doing = first ? `${first.bot.name} · ${workText(first.doing.tool, first.doing.step)}${workers.length > 1 ? ` +${workers.length - 1}` : ""}` : ""; + return { waiting, doing }; } --function workPhrase(tool) { -- if (!tool) return t("Thinking about it"); -- return toolActivity(tool, () => void 0, () => void 0)?.label ?? t("On it"); +-function RoomAvatar({ room: room2, roster, size = 36 }) { +- const members = room2.members.map((id) => roster.byId[id]).filter(Boolean); +- const people = members.length === 1 ? [members[0], null] : members; +- const seat = (bot, seatSize, key) => bot ? (0, import_react2.createElement)(BotMark, { key, bot, size: seatSize }) : (0, import_react2.createElement)("span", { key, className: "bt-cluster-you", style: { width: seatSize * 0.92, height: seatSize * 0.92, margin: seatSize * 0.04 } }); +- if (people.length === 0) return (0, import_react2.createElement)(BotMark, { size }); +- if (size <= 20 && people.length > 1) { +- return (0, import_react2.createElement)( +- "span", +- { className: "bt-stack", style: { "--bt-stack-size": `${size}px` } }, +- people.slice(0, 3).map((bot, index) => seat(bot, size, bot?.id ?? `you-${index}`)), +- people.length > 3 ? (0, import_react2.createElement)("span", { className: "bt-stack-more" }, `+${people.length - 3}`) : null +- ); +- } +- const seats = clusterSeats(Math.min(people.length, 4), size); +- const overflow = people.length > 4 ? people.length - 3 : 0; +- const gap = size / 14; +- const rings = seats.map(([x, y, s]) => ({ cx: x + s / 2, cy: y + s / 2, r: s * 0.48 + gap })); +- return (0, import_react2.createElement)( +function Face({ entry, size }) { + return (0, import_react14.createElement)( -+ "span", + "span", +- { className: "bt-cluster", style: { width: size, height: size } }, +- seats.map(([x, y, s], index) => { +- const holes2 = rings.slice(index + 1).filter((ring) => Math.hypot(ring.cx - x - s / 2, ring.cy - y - s / 2) < ring.r + s * 0.46).map((ring) => `radial-gradient(circle at ${ring.cx - x}px ${ring.cy - y}px,transparent ${ring.r}px,#000 ${ring.r + 0.5}px)`); +- const style = { left: x, top: y, width: s, height: s }; +- if (holes2.length > 0) Object.assign(style, { WebkitMaskImage: holes2.join(","), maskImage: holes2.join(","), WebkitMaskComposite: "source-in", maskComposite: "intersect" }); +- if (overflow > 0 && index === 3) { +- return (0, import_react2.createElement)("span", { key: "more", className: "bt-cluster-more", style: { ...style, width: s * 0.92, height: s * 0.92, margin: s * 0.04, fontSize: Math.max(8, Math.round(s * 0.36)) } }, `+${overflow}`); +- } +- const bot = people[index]; +- return (0, import_react2.createElement)("span", { key: bot?.id ?? "you", style }, seat(bot, s)); +- }) + { className: "bt-mw-face", "data-ask": entry.state === "ask" || void 0 }, + (0, import_react14.createElement)(BotMark, { bot: entry.bot, size, state: entry.state === "ask" ? "alert" : toolState(entry.doing?.tool ?? "") }) -+ ); + ); } --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]); +- +-// src/client/icons.js +-var import_react3 = require("react"); +-function PlusIcon() { +- return (0, import_react3.createElement)("svg", { width: 18, height: 18, viewBox: "0 0 20 20", "aria-hidden": true }, (0, import_react3.createElement)("path", { d: "M10 3.5v13M3.5 10h13", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round" })); +-} +-function SearchIcon() { +- return (0, import_react3.createElement)("svg", { width: 16, height: 16, viewBox: "0 0 20 20", "aria-hidden": true }, (0, import_react3.createElement)("circle", { cx: 8.5, cy: 8.5, r: 5.5, stroke: "currentColor", strokeWidth: 1.6, fill: "none" }), (0, import_react3.createElement)("path", { d: "M13 13l4 4", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round" })); +-} +-function PeopleIcon() { +- return (0, import_react3.createElement)( +- "svg", +- { width: 14, height: 14, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", "aria-hidden": true }, +- (0, import_react3.createElement)("circle", { cx: 7.5, cy: 7, r: 3 }), +- (0, import_react3.createElement)("path", { d: "M2 16.5c.6-2.8 2.8-4.5 5.5-4.5s4.9 1.7 5.5 4.5" }), +- (0, import_react3.createElement)("path", { d: "M13 4.3a3 3 0 0 1 0 5.4M15 12.4c1.6.6 2.6 2 3 4.1" }) +var elapsedSince = (now, at) => typeof at === "number" && now - at >= 6e4 ? elapsedLabel(now - at) : ""; +function WorkRow({ bot, tool, step, label, at, now, onOpen }) { + const text2 = `${label} · ${workText(tool, step)}`; @@ -10403,43 +9182,17 @@ diff --git a/client.js b/client.js + (0, import_react14.createElement)(BotMark, { bot, size: 22, state: toolState(tool ?? ""), live: true }), + (0, import_react14.createElement)("span", { className: "bt-mw-row-text" }, (0, import_react14.createElement)(Shimmer, null, text2)), + elapsed ? (0, import_react14.createElement)("span", { className: "bt-mw-row-time" }, `· ${elapsed}`) : null -+ ); + ); } --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; +-function ArrowUpIcon() { +- return (0, import_react3.createElement)("svg", { width: 16, height: 16, viewBox: "0 0 20 20", "aria-hidden": true }, (0, import_react3.createElement)("path", { d: "M10 16V4M5 9l5-5 5 5", stroke: "currentColor", strokeWidth: 1.8, fill: "none", strokeLinecap: "round", strokeLinejoin: "round" })); +-} +-function MicIcon({ size = 16 }) { +- return (0, import_react3.createElement)( +- "svg", +- { width: size, height: size, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round", "aria-hidden": true }, +- (0, import_react3.createElement)("rect", { x: 7, y: 2.5, width: 6, height: 10, rx: 3, fill: "currentColor", stroke: "none" }), +- (0, import_react3.createElement)("path", { d: "M4.5 9.5a5.5 5.5 0 0 0 11 0M10 15v2.5" }) +function SummaryLine({ busy, now, onOpen }) { + const { waiting, doing } = summaryText(busy); + const elapsed = doing ? elapsedSince(now, busy.find((entry) => entry.state === "work")?.doing.since) : ""; @@ -10455,13 +9208,17 @@ diff --git a/client.js b/client.js + doing ? (0, import_react14.createElement)(Shimmer, null, doing) : null + ), + elapsed ? (0, import_react14.createElement)("span", { className: "bt-mw-row-time" }, `· ${elapsed}`) : null -+ ); -+} + ); + } +-function WaveIcon({ size = 16 }) { +- return (0, import_react3.createElement)( +- "svg", +- { width: size, height: size, viewBox: "0 0 20 20", fill: "currentColor", "aria-hidden": true }, +- [[3, 7.5, 5], [6.5, 4.5, 11], [10, 2.5, 15], [13.5, 5.5, 9], [17, 8, 4]].map(([x, y, height]) => (0, import_react3.createElement)("rect", { key: x, x: x - 1, y, width: 2, height, rx: 1 })) +function MemberDock({ sessionId, useRoster, useWork, actions }) { + const roster = useRoster((value) => value); + const work = useWork((value) => value); - const room2 = roster.roomsById[sessionId]; -- return (0, import_react6.createElement)( ++ const room2 = roster.roomsById[sessionId]; + const bot = room2 ? void 0 : roster.byId[sessionId]; + const ownChat = bot !== void 0 && work.bots?.[bot.id] !== void 0 && work.bots[bot.id].roomId === void 0; + const busy = room2 ? busyMembers(room2, work, roster) : []; @@ -10484,67 +9241,7 @@ diff --git a/client.js b/client.js + const rows = groups.slice(0, ROWS_MAX); + const more = groups.length - rows.length; + return (0, import_react14.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 })) -- ); -- }) ++ "div", + { ref, className: "bt-mw-dock-stack" }, + rows.map((item) => (0, import_react14.createElement)(WorkRow, { + key: item.room.id, @@ -10563,20 +9260,12 @@ diff --git a/client.js b/client.js + ) : null ); } --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 ShareIcon() { +- return (0, import_react3.createElement)( +- "svg", +- { width: 16, height: 16, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, +- (0, import_react3.createElement)("path", { d: "M10 12.5V3M6.5 6.5L10 3l3.5 3.5M4 11v4.5A1.5 1.5 0 0 0 5.5 17h9a1.5 1.5 0 0 0 1.5-1.5V11" }) +- ); +function useFlowColumn(ref, active, key) { + (0, import_react14.useLayoutEffect)(() => { + const node = ref.current; @@ -10594,14 +9283,8 @@ diff --git a/client.js b/client.js + return () => observer.disconnect(); + }, [active, key]); } --function useShownCards(actions, sessionId, callIds) { -- const key = JSON.stringify(callIds); -- (0, import_react6.useLayoutEffect)(() => { -- const ids = JSON.parse(key); -- if (ids.length === 0 || !actions.noteCards) return void 0; -- actions.noteCards(sessionId, ids, 1); -- return () => actions.noteCards(sessionId, ids, -1); -- }, [sessionId, key]); +-function CloseIcon() { +- return (0, import_react3.createElement)("svg", { width: 16, height: 16, viewBox: "0 0 20 20", "aria-hidden": true }, (0, import_react3.createElement)("path", { d: "M5 5l10 10M15 5L5 15", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round" })); +function useNow(active) { + const [now, setNow] = (0, import_react14.useState)(Date.now); + (0, import_react14.useEffect)(() => { @@ -10611,81 +9294,22 @@ diff --git a/client.js b/client.js + }, [active]); + return now; } --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; +-var ChevronLeftIcon = () => (0, import_react3.createElement)("svg", { width: 16, height: 16, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, (0, import_react3.createElement)("path", { d: "M12.5 4.5L7 10l5.5 5.5" })); +-var ChevronRightIcon = () => (0, import_react3.createElement)("svg", { width: 14, height: 14, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, (0, import_react3.createElement)("path", { d: "M7.5 4.5L13 10l-5.5 5.5" })); +-var lineIcon = (paths) => function LineIcon() { +- return (0, import_react3.createElement)( +- "svg", +- { width: 15, height: 15, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, +- paths.map((d, index) => (0, import_react3.createElement)("path", { key: index, d })) +- ); +-}; +-var CLOUD = "M6.5 15.25H5.75a3.25 3.25 0 0 1-.55-6.45 4.75 4.75 0 0 1 9.2-1.1 3.75 3.75 0 0 1 .1 7.55H13.5"; +-function CloudDownloadIcon({ done = false }) { +- return (0, import_react3.createElement)( +- "svg", +- { width: 16, height: 16, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, +- (0, import_react3.createElement)("path", { d: CLOUD }), +- done ? (0, import_react3.createElement)("path", { d: "M7.75 13.25l1.75 1.75 3-3.25" }) : (0, import_react3.createElement)("g", { className: "bt-cloud-arrow" }, (0, import_react3.createElement)("path", { d: "M10 9.75v7" }), (0, import_react3.createElement)("path", { d: "M7.75 14.5L10 16.75l2.25-2.25" })) +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"], @@ -10715,8 +9339,32 @@ diff --git a/client.js b/client.js + { className: "bt-mw-actions" }, + (0, import_react14.createElement)("button", { type: "button", className: "bt-q-btn", onClick: () => actions.openSession(bot.id) }, t("Open {name}'s chat", { name: bot.name })) + ) -+ ); -+} + ); + } +-var SmileIcon = lineIcon(["M10 3.5a6.5 6.5 0 1 1 0 13 6.5 6.5 0 0 1 0-13z", "M7.3 11.6a3.4 3.4 0 0 0 5.4 0", "M7.75 8.25h.01M12.25 8.25h.01"]); +-var ReplyIcon = lineIcon(["M8 5L4 9l4 4", "M4 9h7.5a4.5 4.5 0 0 1 4.5 4.5V15"]); +-var DotsIcon = lineIcon(["M5 10h.01M10 10h.01M15 10h.01"]); +-var CheckIcon = lineIcon(["M4.5 10.5l3.5 3.5 7.5-8"]); +-var ArrowDownIcon = lineIcon(["M10 4v12", "M5 11l5 5 5-5"]); +-var DownloadIcon = lineIcon(["M10 3.5v9", "M6.5 9.5L10 13l3.5-3.5", "M4 16h12"]); +-var GaugeIcon = lineIcon(["M3.5 13.5a6.5 6.5 0 1 1 13 0", "M10 13.5l3-4", "M10 13.5h.01"]); +-var PlugIcon = lineIcon(["M7 3v4M13 3v4", "M5 7h10v2.5a5 5 0 0 1-10 0z", "M10 14.5V17"]); +-var CalendarIcon = lineIcon(["M5.5 5h9A1.5 1.5 0 0 1 16 6.5v8a1.5 1.5 0 0 1-1.5 1.5h-9A1.5 1.5 0 0 1 4 14.5v-8A1.5 1.5 0 0 1 5.5 5z", "M4 8.75h12", "M7.25 3.5v3M12.75 3.5v3"]); +-var ConnectorIcon = lineIcon(["M7 10a2.5 2.5 0 1 1-5 0 2.5 2.5 0 0 1 5 0z", "M18 10a2.5 2.5 0 1 1-5 0 2.5 2.5 0 0 1 5 0z", "M7 10h6"]); +-var SwitchIcon = lineIcon(["M4 7h9.5M10.5 3.5L14 7l-3.5 3.5", "M16 13H6.5M9.5 9.5L6 13l3.5 3.5"]); +-var PaperclipIcon = lineIcon(["M15.5 9.5l-5.6 5.6a3.4 3.4 0 0 1-4.8-4.8l6.1-6.1a2.25 2.25 0 0 1 3.2 3.2l-6 6a1.1 1.1 0 0 1-1.6-1.6l5.5-5.5"]); +-var ClockIcon = lineIcon(["M10 3.5a6.5 6.5 0 1 1 0 13 6.5 6.5 0 0 1 0-13z", "M10 6.5V10l2.5 1.5"]); +-var GearIcon = lineIcon(["M10 7.4a2.6 2.6 0 1 1 0 5.2 2.6 2.6 0 0 1 0-5.2z", "M7.85 4.4l.29-1.87a7.7 7.7 0 0 1 3.72 0l.29 1.87a6 6 0 0 1 1.63.94l1.76-.69a7.7 7.7 0 0 1 1.86 3.23l-1.47 1.18a6 6 0 0 1 0 1.88l1.47 1.18a7.7 7.7 0 0 1-1.86 3.23l-1.76-.69a6 6 0 0 1-1.63.94l-.29 1.87a7.7 7.7 0 0 1-3.72 0l-.29-1.87a6 6 0 0 1-1.63-.94l-1.76.69a7.7 7.7 0 0 1-1.86-3.23l1.47-1.18a6 6 0 0 1 0-1.88L2.6 7.88a7.7 7.7 0 0 1 1.86-3.23l1.76.69a6 6 0 0 1 1.63-.94z"]); +-var PinIcon = lineIcon(["M12.5 3l4.5 4.5-3 1.5-2.5 2.5.5 3.5-1.5 1.5L7 13 3.5 16.5", "M7 13l-3.5-3.5L5 8l3.5.5L11 6l1.5-3"]); +-function PinMarkIcon() { +- return (0, import_react3.createElement)( +- "svg", +- { width: 12, height: 12, viewBox: "0 0 20 20", fill: "currentColor", "aria-hidden": true }, +- (0, import_react3.createElement)( +- "g", +- { transform: "rotate(45 10 10)" }, +- (0, import_react3.createElement)("path", { d: "M8 2.5h4a.9.9 0 0 1 0 1.8h-.4v4.1c0 1.4 1.1 2.6 2.6 3.1l.7.3v1.5H5.1v-1.5l.7-.3c1.5-.5 2.6-1.7 2.6-3.1V4.3H8a.9.9 0 0 1 0-1.8z" }), +- (0, import_react3.createElement)("path", { d: "M9.35 13.3h1.3v3.6L10 18l-.65-1.1z" }) +function MemberWork({ entry, bot, room: room2, actions, now }) { + const [stopping, setStopping] = (0, import_react14.useState)(false); + if (entry.state === "ask") { @@ -10726,7 +9374,7 @@ diff --git a/client.js b/client.js + (0, import_react14.createElement)("div", { className: "bt-mw-state bt-mw-ask" }, `${t("Waiting for your approval")}${since(now, entry.approvals[0].at)}`), + entry.approvals.map((approval) => (0, import_react14.createElement)(ApprovalCard, { key: approval.id, approval, bot, actions })) + ); - } ++ } + if (entry.state !== "work") return null; + const stop = () => { + setStopping(true); @@ -10740,30 +9388,28 @@ diff --git a/client.js b/client.js + { className: "bt-mw-state bt-mw-work" }, + (0, import_react14.createElement)("span", null, (0, import_react14.createElement)(Shimmer, null, workText(entry.doing.tool, entry.doing.step)), since(now, entry.doing.since)), + entry.here && entry.doing.handed !== true ? (0, import_react14.createElement)("button", { type: "button", className: "bt-mw-stop", disabled: stopping, onClick: stop }, t("Stop")) : null -+ ) -+ ); + ) + ); } --function ToolCell({ node, sessionId, useRoster, useUi, 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)))); +-var UnreadIcon = lineIcon(["M10 3.5a4.5 4.5 0 0 0-4.5 4.5v3L4 13.5h12L14.5 11V8", "M8.3 16a1.8 1.8 0 0 0 3.4 0", "M15.5 3.2a1.6 1.6 0 1 1 0 3.2 1.6 1.6 0 0 1 0-3.2z"]); +-var PencilIcon = lineIcon(["M9 4H6a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2v-3", "M14.3 3.7a1.6 1.6 0 0 1 2.3 2.3L10.5 12l-3 .8.8-3z"]); +-var InfoIcon = lineIcon(["M10 3.5a6.5 6.5 0 1 1 0 13 6.5 6.5 0 0 1 0-13z", "M10 9.25v4", "M10 6.6h.01"]); +-var StarLineIcon = lineIcon(["M10 3l2.1 4.3 4.7.7-3.4 3.3.8 4.7L10 13.8 5.8 16l.8-4.7L3.2 8l4.7-.7z"]); +-var CopyIcon = lineIcon(["M8.5 7h6A1.5 1.5 0 0 1 16 8.5v6a1.5 1.5 0 0 1-1.5 1.5h-6A1.5 1.5 0 0 1 7 14.5v-6A1.5 1.5 0 0 1 8.5 7z", "M4 12.5V5.5A1.5 1.5 0 0 1 5.5 4H13"]); +-var HideIcon = lineIcon(["M3 3l14 14", "M8.6 5.2A7.6 7.6 0 0 1 10 5c4.5 0 7 5 7 5a12.6 12.6 0 0 1-2 2.6M12.2 14.5A6.8 6.8 0 0 1 10 15c-4.5 0-7-5-7-5a12.4 12.4 0 0 1 3-3.4", "M8.5 8.5a2 2 0 0 0 3 3"]); +-var TrashIcon = lineIcon(["M3.5 5.5h13", "M8 5.5V4a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1v1.5", "M5 5.5l.7 10A1.5 1.5 0 0 0 7.2 17h5.6a1.5 1.5 0 0 0 1.5-1.5l.7-10", "M8.5 9v4.5M11.5 9v4.5"]); +-var DuplicateIcon = lineIcon(["M7 7h8.5v9.5H7z", "M4.5 13V3.5H13", "M11.25 9.5v4.5M9 11.75h4.5"]); +-var SlidersIcon = lineIcon(["M4 6.5h5M12.5 6.5H16M4 13.5h4M11.5 13.5H16", "M10.5 4.5v4M9.5 11.5v4"]); +-var MemoryIcon = lineIcon(["M5.5 3.5h8A1.5 1.5 0 0 1 15 5v10a1.5 1.5 0 0 1-1.5 1.5h-8z", "M5.5 3.5v13", "M8.5 7.5h3.5M8.5 10.5h2.5", "M12 3.5v4l1-.8 1 .8"]); +-var FolderIcon = lineIcon(["M3 6A1.5 1.5 0 0 1 4.5 4.5h3.2l1.8 2h6A1.5 1.5 0 0 1 17 8v6.5a1.5 1.5 0 0 1-1.5 1.5h-11A1.5 1.5 0 0 1 3 14.5z"]); +-var ChatsIcon = lineIcon(["M3.5 5.5A1.5 1.5 0 0 1 5 4h7a1.5 1.5 0 0 1 1.5 1.5V10A1.5 1.5 0 0 1 12 11.5H8L5 14v-2.5A1.5 1.5 0 0 1 3.5 10z", "M13.5 7.5H15A1.5 1.5 0 0 1 16.5 9v4.5A1.5 1.5 0 0 1 15 15v2l-2.5-2h-3A1.5 1.5 0 0 1 8 13.5V13"]); +-var SETTINGS_ICONS = { general: GearIcon, bots: PeopleIcon, groups: ChatsIcon, usage: GaugeIcon }; +function useMemberWork(room2, useWork) { + const work = useWork?.((value) => value) ?? NO_WORK; + const entries2 = room2 ? memberWork(room2, work) : []; + const now = useNow(entries2.some((entry) => entry.state !== "idle")); + return { entries: entries2, now }; - } ++} +var MEMBER_CSS = ` +.bt-mw-dock-stack{box-sizing:border-box;display:flex;flex-direction:column;align-items:flex-start;gap:2px;width:var(--dsh-chat-content-width,min(960px,100%));margin:0 auto 6px;pointer-events:none} +.bt-mw-row{pointer-events:auto;display:flex;align-items:center;gap:10px;min-width:0;max-width:calc(100% + 6px);height:32px;margin-inline-start:-6px;padding:0 12px 0 6px;border:0;border-radius:16px;background:none;color:var(--bt-ink-2);font:inherit;font-size:14px;cursor:pointer;animation:bt-mw-in .32s cubic-bezier(.23,1,.32,1) backwards;transition:background-color .16s ease-out} @@ -10799,16 +9445,27 @@ diff --git a/client.js b/client.js +@media (prefers-reduced-motion:reduce){.bt-mw-row{animation:none}} +`; --// 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`; +-// src/client/questions.js +-var import_react4 = require("react"); +-var keyLetter = (index) => String.fromCharCode(65 + index); +-var optionsOf = (question) => (question?.options ?? []).map((option) => typeof option === "string" ? { label: option } : option); +-var isTypingTarget = (node) => node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement || node instanceof HTMLSelectElement || node instanceof HTMLElement && node.isContentEditable; +-function useOptionKeys(count, onPick, ownRef) { +- const pick2 = (0, import_react4.useRef)(onPick); +- pick2.current = onPick; +- (0, import_react4.useEffect)(() => { +- if (count === 0) return void 0; +- const onKey = (event) => { +- if (event.defaultPrevented || event.metaKey || event.ctrlKey || event.altKey || event.shiftKey || event.key.length !== 1) return; +- if (isTypingTarget(event.target) || isTypingTarget(document.activeElement)) return; +- if (document.querySelector('[role="dialog"], [aria-modal="true"]') !== null) return; +- const index = event.key.toUpperCase().charCodeAt(0) - 65; +- if (index >= 0 && index < count) { +- event.preventDefault(); +- pick2.current(index); +- } else if (index === count && ownRef.current) { +- event.preventDefault(); +- ownRef.current.focus(); +// src/shared/task-board.js +var COLUMNS = ["not-started", "in-progress", "done"]; +var PRIORITIES = ["urgent", "high", "normal", "low"]; @@ -10860,22 +9517,7 @@ diff --git a/client.js b/client.js + const value = task?.progress; + if (typeof value !== "number" || !Number.isFinite(value)) return columnOf(task) === "done" && task?.status !== "cancelled" && task?.status !== "failed" ? 100 : 0; + return Math.max(0, Math.min(100, Math.round(value))); - } --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); ++} + +// src/client/inbox.js +var import_react15 = require("react"); @@ -10930,46 +9572,13 @@ diff --git a/client.js b/client.js + }).catch(() => { + }).finally(() => { + if (!stopped) timer = setTimeout(load, POLL_MS3); - }); -- observer.observe(document.querySelector("[data-conversation-scroll]") ?? document.body, { childList: true, subtree: true }); -- find(); ++ }); + load(); - return () => { -- cancelAnimationFrame(frame); -- observer.disconnect(); -- }; -- }, [active]); -- return host; --} --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); ++ return () => { + stopped = true; + clearTimeout(timer); + taskListeners.delete(setTasks); - }; -- 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; ++ }; + }, []); + return tasks; +} @@ -10987,15 +9596,7 @@ diff --git a/client.js b/client.js + workflowListeners.add(setWorkflows); + if (workflowPoll === null && typeof actions?.workflowState === "function") { + const load = () => Promise.resolve(actions.workflowState()).then(publishWorkflows, () => { - }); -- 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); ++ }); + load(); + workflowPoll = setInterval(load, POLL_MS3); + } @@ -11006,11 +9607,31 @@ diff --git a/client.js b/client.js + workflowPoll = null; } }; -- const observer = new MutationObserver(() => { -- if (!frame) frame = requestAnimationFrame(scan); +- document.addEventListener("keydown", onKey, true); +- return () => document.removeEventListener("keydown", onKey, true); +- }, [count, ownRef]); + }, []); + return workflows; -+} + } +-function QuestionView({ question, onAnswer, onDismiss, ownAnswer = false, raised = false }) { +- const [own, setOwn] = (0, import_react4.useState)(""); +- const [picked, setPicked] = (0, import_react4.useState)([]); +- const ownRef = (0, import_react4.useRef)(null); +- const titleId = (0, import_react4.useId)(); +- const options = optionsOf(question); +- const multi = question.multiSelect === true; +- const custom = own.trim(); +- const canSubmit = multi ? picked.length > 0 || custom !== "" : custom !== ""; +- const choose = (label) => { +- if (multi) { +- setPicked((current) => current.includes(label) ? current.filter((item) => item !== label) : [...current, label]); +- return; +- } +- onAnswer({ selected: [label] }); +- }; +- const submit = () => { +- if (!canSubmit) return; +- onAnswer({ selected: multi ? picked : [], ...custom ? { custom } : {} }); +var ownBot = (roster, id) => { + const bot = roster.byId?.[id]; + if (bot === void 0 || bot.remote) return void 0; @@ -11080,11 +9701,7 @@ diff --git a/client.js b/client.js + chat: approval.roomId ?? bot.id, + approval, + ...people(roster, bot.id) - }); -- scan(); -- return () => { -- cancelAnimationFrame(frame); -- observer.disconnect(); ++ }); + } + for (const [key, question] of Object.entries(roster.questions ?? {})) { + if (question == null || typeof question !== "object") continue; @@ -11146,14 +9763,31 @@ diff --git a/client.js b/client.js + chat: roomId ?? bot.id, + ...people(roster, bot.id) + }); -+ }; -+ for (const [botId, entry] of Object.entries(work.bots ?? {})) running(botId, entry, entry.roomId); -+ for (const [roomId, entries2] of Object.entries(work.rooms ?? {})) for (const entry of entries2) running(entry.botId, entry, roomId); -+ const lastEnded = /* @__PURE__ */ new Map(); -+ for (const entry of work.ended ?? []) { -+ const key = placeKey(entry.botId, entry.roomId); -+ if ((entry.at ?? 0) >= (lastEnded.get(key)?.at ?? -Infinity)) lastEnded.set(key, entry); -+ } + }; +- useOptionKeys(options.length, (index) => choose(options[index].label), ownRef); +- const ownLetter = options.length > 0 ? keyLetter(options.length) : void 0; +- const field = ownAnswer ? (0, import_react4.createElement)("textarea", { +- ref: ownRef, +- rows: 1, +- value: own, +- autoComplete: "off", +- spellCheck: false, +- className: "bt-q-input", +- "aria-label": t("Your own answer"), +- placeholder: t("Type your own answer"), +- "aria-keyshortcuts": ownLetter?.toLowerCase(), +- onChange: (event) => setOwn(event.target.value), +- onKeyDown: (event) => { +- if (event.key !== "Enter" || event.shiftKey || event.nativeEvent.isComposing) return; +- event.preventDefault(); +- submit(); ++ for (const [botId, entry] of Object.entries(work.bots ?? {})) running(botId, entry, entry.roomId); ++ for (const [roomId, entries2] of Object.entries(work.rooms ?? {})) for (const entry of entries2) running(entry.botId, entry, roomId); ++ const lastEnded = /* @__PURE__ */ new Map(); ++ for (const entry of work.ended ?? []) { ++ const key = placeKey(entry.botId, entry.roomId); ++ if ((entry.at ?? 0) >= (lastEnded.get(key)?.at ?? -Infinity)) lastEnded.set(key, entry); ++ } + for (const [key, entry] of lastEnded) { + const bot = ownBot(roster, entry.botId); + if (bot === void 0 || entry.failed === void 0 || !fresh(entry.at) || seen.has(key) || waitingAt.has(key)) continue; @@ -11187,8 +9821,7 @@ diff --git a/client.js b/client.js + chat: askedIn2 ?? chatOfTask(task), + task, + ...people(roster, task.workBotId ?? bot.id) - }; -- }, []); ++ }; + if (task.status === "failed") { + if (fresh(task.updatedAt)) needs.push({ ...card, status: "failed", reason: lastWhy(task) }); + } else if (task.status === "result-ready") needs.push({ ...card, status: "result", reason: task.result?.summary ?? "" }); @@ -11197,8 +9830,48 @@ diff --git a/client.js b/client.js + const column = columnOf(task); + if (column === "in-progress") progress.push({ ...card, status: "task", since: startedAtOf(task) }); + else if (column === "done" && fresh(task.updatedAt)) done.push({ ...card, status: task.status === "cancelled" ? "cancelled" : "done" }); -+ } -+ } + } +- }) : null; +- const rows = options.map((option, optionIndex) => { +- const selected = multi && picked.includes(option.label); +- return (0, import_react4.createElement)( +- "button", +- { +- key: option.label, +- type: "button", +- className: "bt-q-opt", +- "aria-pressed": multi ? selected : void 0, +- "aria-keyshortcuts": keyLetter(optionIndex).toLowerCase(), +- onClick: () => choose(option.label) +- }, +- (0, import_react4.createElement)("span", { className: "bt-key", "aria-hidden": true }, keyLetter(optionIndex)), +- (0, import_react4.createElement)( +- "span", +- { className: "bt-q-body" }, +- (0, import_react4.createElement)("span", { className: "bt-q-label" }, option.label), +- option.description ? (0, import_react4.createElement)("span", { className: "bt-q-desc" }, option.description) : null +- ), +- selected && !raised ? (0, import_react4.createElement)("span", { className: "bt-q-check", title: t("Selected") }, (0, import_react4.createElement)(CheckIcon)) : null +- ); +- }); +- if (raised && field) { +- rows.push((0, import_react4.createElement)( +- "label", +- { key: "own", className: "bt-q-opt bt-q-ownrow", "data-filled": custom !== "" ? "" : void 0 }, +- ownLetter ? (0, import_react4.createElement)("span", { className: "bt-key", "aria-hidden": true }, ownLetter) : null, +- (0, import_react4.createElement)("span", { className: "bt-q-body" }, field) +- )); + } +- let foot = null; +- if (raised && multi) { +- foot = (0, import_react4.createElement)( +- "div", +- { className: "bt-q-foot" }, +- (0, import_react4.createElement)("button", { type: "button", className: "bt-q-btn", onClick: onDismiss }, t("Skip")), +- (0, import_react4.createElement)("button", { type: "submit", className: "bt-q-submit", disabled: !canSubmit }, t("Submit")) +- ); +- } else if ((raised || multi) && canSubmit) { +- foot = (0, import_react4.createElement)("div", { className: "bt-q-foot" }, (0, import_react4.createElement)("button", { type: "submit", className: "bt-q-submit" }, t("Submit"))); + for (const graph of Array.isArray(workflows) ? workflows : []) { + if (graph === null || typeof graph !== "object") continue; + const seenRuns = /* @__PURE__ */ new Set(); @@ -11227,7 +9900,54 @@ diff --git a/client.js b/client.js + reason, + ...caller === void 0 ? {} : { bot: caller } + }); -+ } + } +- return (0, import_react4.createElement)( +- "form", +- { +- className: raised ? "bt-q bt-q-raised" : "bt-q", +- "aria-labelledby": titleId, +- onSubmit: (event) => { +- event.preventDefault(); +- submit(); +- } +- }, +- (0, import_react4.createElement)( +- "div", +- { className: "bt-q-head" }, +- (0, import_react4.createElement)( +- "div", +- { className: "bt-q-text" }, +- (0, import_react4.createElement)("p", { className: "bt-q-title", id: titleId }, question.header ? `${question.header} · ` : "", question.question), +- question.detail ? (0, import_react4.createElement)("div", { className: "bt-q-detail" }, question.detail) : null, +- raised && multi ? (0, import_react4.createElement)("div", { className: "bt-q-detail" }, t("Choose all that apply")) : null +- ), +- (0, import_react4.createElement)("button", { type: "button", className: "bt-q-x", "aria-label": t("Dismiss question"), title: t("Dismiss"), onClick: onDismiss }, (0, import_react4.createElement)(CloseIcon)) +- ), +- rows.length > 0 ? (0, import_react4.createElement)("div", { className: "bt-q-list", role: "group" }, rows) : null, +- field && !raised ? (0, import_react4.createElement)( +- "div", +- { className: "bt-q-custom" }, +- (0, import_react4.createElement)("label", { className: "bt-q-field" }, field), +- !multi && canSubmit ? (0, import_react4.createElement)("button", { type: "submit", className: "bt-q-submit" }, t("Submit")) : null +- ) : null, +- foot +- ); +-} +-function SettledQuestion({ question, answer }) { +- const options = optionsOf(question); +- const prompt = question?.question ?? ""; +- const chosen = [...answer.selected ?? [], ...answer.custom ? [answer.custom] : []]; +- if (chosen.length === 0) { +- return (0, import_react4.createElement)( +- "div", +- { className: "bt-q bt-q-settled bt-q-dismissed", role: "group", "aria-label": prompt }, +- (0, import_react4.createElement)( +- "div", +- { className: "bt-q-head" }, +- (0, import_react4.createElement)("div", { className: "bt-q-text" }, (0, import_react4.createElement)("p", { className: "bt-q-title" }, prompt)), +- (0, import_react4.createElement)("span", { className: "bt-q-badge" }, t("Dismissed")) +- ) +- ); + if (sessions?.byId) { + for (const bot of roster.bots ?? []) { + if (ownBot(roster, bot.id) === void 0 || work.bots?.[bot.id] !== void 0 || waitingAt.has(`${bot.id}|`)) continue; @@ -11239,7 +9959,21 @@ diff --git a/client.js b/client.js + if (text2 === "") continue; + done.push({ id: `chat:${bot.id}`, kind: "chat", status: "done", bot, at, title: text2, place: "", chat: bot.id, ...people(roster, bot.id) }); + } -+ } + } +- return (0, import_react4.createElement)( +- "div", +- { className: "bt-q bt-q-settled", role: "group", "aria-label": prompt }, +- prompt ? (0, import_react4.createElement)("div", { className: "bt-q-head" }, (0, import_react4.createElement)("div", { className: "bt-q-text" }, (0, import_react4.createElement)("p", { className: "bt-q-title" }, prompt))) : null, +- (0, import_react4.createElement)("div", { className: "bt-q-list", role: "group", "aria-label": t("Your answer") }, chosen.map((label, index) => { +- const optionIndex = options.findIndex((option) => option.label === label); +- return (0, import_react4.createElement)( +- "div", +- { key: `${label}-${index}`, className: "bt-q-opt" }, +- optionIndex >= 0 ? (0, import_react4.createElement)("span", { className: "bt-key", "aria-hidden": true }, keyLetter(optionIndex)) : null, +- (0, import_react4.createElement)("span", { className: "bt-q-body" }, (0, import_react4.createElement)("span", { className: "bt-q-label" }, label)), +- (0, import_react4.createElement)("span", { className: "bt-q-check", title: t("Selected") }, (0, import_react4.createElement)(CheckIcon)) +- ); +- })) + const newest = (a, b) => (b.at ?? Infinity) - (a.at ?? Infinity); + for (const card of [...needs, ...progress, ...done]) { + card.activity = latestActivity(card, { roster, work, sessions }); @@ -11280,35 +10014,7 @@ diff --git a/client.js b/client.js + return Promise.resolve(actions.answerApproval(card.approval.id, choice)); + } + return void 0; - } --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; ++} +var answerSlots = /* @__PURE__ */ new Map(); +var answerSlot = (id) => { + let slot = answerSlots.get(id); @@ -11361,8 +10067,20 @@ diff --git a/client.js b/client.js + redraw((count) => count + 1); + } }, t("Send back")), + (0, import_react15.createElement)("button", { type: "button", className: "bt-send bt-soft-sm", disabled: busy, onClick: () => onAnswer(card, "accept") }, t("Accept")) -+ ); -+} + ); + } +-function QuestionCard({ matched }) { +- const pending = matched; +- const questions = pending.questions ?? []; +- const [index, setIndex] = (0, import_react4.useState)(0); +- const [answers, setAnswers] = (0, import_react4.useState)([]); +- const question = questions[index]; +- if (question === void 0) return null; +- const onAnswer = (answer) => { +- const next = [...answers, { id: question.id, ...answer }]; +- if (index + 1 < questions.length) { +- setAnswers(next); +- setIndex(index + 1); +function AnswerFields({ card, busy, onAnswer }) { + const [, redraw] = (0, import_react15.useState)(0); + const question = card.kind === "question"; @@ -11378,64 +10096,37 @@ diff --git a/client.js b/client.js + const selected = multi ? slot.picked.filter((label) => label !== "") : []; + if (selected.length === 0 && custom === "") return; + onAnswer(card, { selected, custom }); -+ return; + return; } -- 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; +- pending.answer({ answers: next }); +- }; +- return (0, import_react4.createElement)(QuestionView, { key: index, question, onAnswer, onDismiss: () => pending.dismiss(), ownAnswer: true, raised: true }); +-} +-function PendingQuestion({ sessionId, question, actions, inline }) { +- const [answered, setAnswered] = (0, import_react4.useState)(null); +- if (answered === question.id) return null; +- const onAnswer = (answer) => { +- setAnswered(question.id); +- void actions.answerQuestion(sessionId, question.id, { selected: answer.selected ?? [], custom: answer.custom ?? "" }).catch(() => setAnswered(null)); + if (custom === "") return; + onAnswer(card, custom); -+ }; + }; +- const onDismiss = () => { +- setAnswered(question.id); +- void actions.dismissQuestion(sessionId); + const choose = (label) => { + if (busy) return; + if (!multi) { + onAnswer(card, { selected: [label], custom: "" }); + return; - } -- 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)( ++ } + slot.picked = slot.picked.includes(label) ? slot.picked.filter((item) => item !== label) : [...slot.picked, label]; + redraw((count) => count + 1); -+ }; + }; +- return (0, import_react4.createElement)( +- "div", +- { className: inline ? "bt-q-inline" : "bt-q-dock" }, +- (0, import_react4.createElement)(QuestionView, { key: question.id, question, onAnswer, onDismiss, ownAnswer: question.allowCustom === true, raised: !inline }) + return (0, import_react15.createElement)( + "span", + { className: "bt-inbox-answer" }, @@ -11465,10 +10156,94 @@ diff --git a/client.js b/client.js + } + }), + (0, import_react15.createElement)("button", { type: "button", className: "bt-send bt-soft-sm", disabled: busy, onClick: send }, t("Submit")) -+ ); -+} -+function InboxRow({ wide, useRoster, useWork, useUi, actions }) { -+ const roster = useRoster((value) => value); + ); + } +-function QuestionDock({ sessionId, useRoster, useCards, useSession, actions }) { +- const question = useRoster((value) => value.questions?.[sessionId]); +- const drawn = useCards?.((state) => state[sessionId]?.[question?.callId] > 0) === true; +- const loading = useSession?.(historyLoading) === true; +- if (question === void 0 || question.callId && (drawn || loading)) return null; +- return (0, import_react4.createElement)(PendingQuestion, { sessionId, question, actions, inline: false }); +-} +-function AnsweredCell({ node }) { +- const data = node.data ?? {}; +- const questions = Array.isArray(data.questions) ? data.questions : []; +- if (questions.length === 0) { +- return data.text ? (0, import_react4.createElement)("div", { className: "bt-urow" }, (0, import_react4.createElement)("div", { className: "bt-ububble" }, data.text)) : null; +- } +- const answers = Array.isArray(data.answers) ? data.answers : []; +- return (0, import_react4.createElement)("div", { className: "bt-q-inline" }, questions.map((question) => (0, import_react4.createElement)(SettledQuestion, { +- key: question.id, +- question, +- answer: answers.find((answer) => answer.id === question.id) ?? { selected: [] } +- }))); +-} +- +-// src/client/secret-card.js +-var import_react5 = require("react"); +-var MIN_VALUE = 4; +-var NONE = {}; +-var KeyIcon = () => (0, import_react5.createElement)( +- "svg", +- { width: 15, height: 15, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, +- (0, import_react5.createElement)("path", { d: "M7 9.5a3.5 3.5 0 1 1 0 .01z" }), +- (0, import_react5.createElement)("path", { d: "M10.4 10.6L17 10.6M14.5 10.6v2.4M16.6 10.6v1.8" }) +-); +-var LockIcon = () => (0, import_react5.createElement)( +- "svg", +- { width: 14, height: 14, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, +- (0, import_react5.createElement)("path", { d: "M6.5 9V7a3.5 3.5 0 0 1 7 0v2" }), +- (0, import_react5.createElement)("rect", { x: 4.5, y: 9, width: 11, height: 8, rx: 2 }) +-); +-var WarnIcon = () => (0, import_react5.createElement)( +- "svg", +- { width: 14, height: 14, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, +- (0, import_react5.createElement)("path", { d: "M10 3.5l7 12.5H3z" }), +- (0, import_react5.createElement)("path", { d: "M10 8.5v3.5M10 14.2h.01" }) +-); +-var MASK_BY_CSS = typeof CSS !== "undefined" && typeof CSS.supports === "function" && CSS.supports("-webkit-text-security", "disc"); +-var insecure = () => typeof location !== "undefined" && location.protocol === "http:" && !/^(localhost|127(?:\.\d{1,3}){3}|\[::1\])$/.test(location.hostname); +-var scopeLabel = (scope, roster) => { +- if (scope === "all") return t("All Bots"); +- const names = (scope ?? []).map((id) => roster.byId[id]?.name).filter(Boolean); +- return names.length === 0 ? t("No Bot") : names.join(t(" and ")); +-}; +-var when = (time) => Number.isFinite(time) ? new Date(time).toLocaleString(dateLocale(), { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }) : ""; +-function MaskedInput({ value, onChange, onEnter, label, autoFocus = false }) { +- return (0, import_react5.createElement)("input", { +- className: "bt-secret-input", +- type: MASK_BY_CSS ? "text" : "password", +- "data-masked": MASK_BY_CSS ? "" : void 0, +- value, +- autoFocus, +- autoComplete: MASK_BY_CSS ? "off" : "new-password", +- autoCorrect: "off", +- autoCapitalize: "off", +- spellCheck: false, +- "data-1p-ignore": "", +- "data-lpignore": "true", +- "data-bwignore": "", +- "data-form-type": "other", +- "aria-label": label, +- placeholder: t("Paste or type the key"), +- onChange: (event) => onChange(event.target.value), +- onKeyDown: (event) => { +- if (event.key !== "Enter" || event.nativeEvent.isComposing) return; +- event.preventDefault(); +- onEnter?.(); +- } +- }); +-} +-function ScopeSwitch({ value, onChange, bot }) { +- const options = [["all", t("All Bots")], ["bot", t("Only {name}", { name: bot.name })]]; +- return (0, import_react5.createElement)( +- "div", +- { className: "bt-secret-scope", role: "radiogroup", "aria-label": t("Who may use it") }, +- (0, import_react5.createElement)("span", { className: "bt-secret-glide", "data-at": value === "all" ? 0 : 1, "aria-hidden": true }), +- options.map(([id, text]) => (0, import_react5.createElement)("button", { +- key: id, ++function InboxRow({ wide, useRoster, useWork, useUi, actions }) { ++ const roster = useRoster((value) => value); + const work = useWork ? useWork((value) => value) : {}; + const open = useUi ? useUi((value) => Boolean(value.inbox)) : false; + const tasks = useTasks(actions); @@ -11478,7 +10253,12 @@ diff --git a/client.js b/client.js + return (0, import_react15.createElement)( + "button", + { -+ type: "button", + type: "button", +- role: "radio", +- "aria-checked": value === id, +- className: "bt-secret-seg", +- onClick: () => onChange(id) +- }, text)) + className: "bt-row bt-inbox-row", + "aria-label": label, + title: wide ? void 0 : label, @@ -11489,12 +10269,49 @@ diff --git a/client.js b/client.js + (0, import_react15.createElement)("span", { className: "bt-inbox-mark" }, (0, import_react15.createElement)(InboxIcon)), + wide ? (0, import_react15.createElement)("span", { className: "bt-inbox-label" }, t("Inbox")) : null, + count > 0 ? (0, import_react15.createElement)("span", { className: "bt-inbox-count", "aria-hidden": true }, count > 99 ? "99+" : String(count)) : null -+ ); -+} + ); + } +-function SecretHead({ bot, title, detail, name, onClose }) { +- return (0, import_react5.createElement)( +- "div", +- { className: "bt-q-head bt-secret-head" }, +- (0, import_react5.createElement)("span", { className: "bt-secret-who" }, (0, import_react5.createElement)(BotMark, { bot, size: 28 }), (0, import_react5.createElement)("span", { className: "bt-secret-lock" }, (0, import_react5.createElement)(LockIcon))), +- (0, import_react5.createElement)( +- "div", +- { className: "bt-q-text" }, +- (0, import_react5.createElement)("p", { className: "bt-q-title" }, title), +- (0, import_react5.createElement)("div", { className: "bt-q-detail" }, (0, import_react5.createElement)("code", { className: "bt-secret-name" }, name), detail ? ` · ${detail}` : "") +- ), +- onClose ? (0, import_react5.createElement)("button", { type: "button", className: "bt-q-x", "aria-label": t("Cancel the request"), title: t("Cancel"), onClick: onClose }, (0, import_react5.createElement)(CloseIcon)) : null +- ); +function Status({ status }) { + const { label, tone, Icon } = STATUSES[status]; + return (0, import_react15.createElement)("span", { className: "bt-inbox-status", "data-tone": tone }, (0, import_react15.createElement)(Icon), (0, import_react15.createElement)("span", null, t(label))); -+} + } +-function SecretCard({ bot, request, roster, actions }) { +- const [value, setValue] = (0, import_react5.useState)(""); +- const [scope, setScope] = (0, import_react5.useState)("all"); +- const [replacing, setReplacing] = (0, import_react5.useState)(false); +- const [busy, setBusy] = (0, import_react5.useState)(false); +- const [error, setError] = (0, import_react5.useState)(""); +- const noteId = (0, import_react5.useId)(); +- const secret = (roster.secrets ?? []).find((entry) => entry.name === request.name); +- const run = (promise) => { +- setBusy(true); +- setError(""); +- return Promise.resolve(promise).then(() => setValue(""), (failure) => setError(failure?.message ?? String(failure))).finally(() => setBusy(false)); +- }; +- const cancel = () => run(actions.secretCancel(request.id)); +- const long = value.trim().length >= MIN_VALUE; +- const save = () => { +- if (long && !busy) void run(actions.secretSet({ requestId: request.id, value, scope })); +- }; +- const allow = () => { +- if (!busy) void run(actions.secretAllow(request.id)); +- }; +- const filling = request.kind === "fill" || replacing; +- const warn = insecure() ? (0, import_react5.createElement)("div", { className: "bt-secret-warn", role: "note" }, (0, import_react5.createElement)(WarnIcon), t("This page is not on HTTPS: the key crosses the network unencrypted.")) : null; +- return (0, import_react5.createElement)( +function whenText(card, now) { + if (card.status === "running" || card.status === "task") { + return typeof card.since === "number" && now - card.since >= 6e4 ? t("for {time}", { time: elapsedLabel(now - card.since) }) : t("just started"); @@ -11514,21 +10331,35 @@ diff --git a/client.js b/client.js + const unconfirmed = card.kind === "task" && card.task?.writeBack?.state === "unconfirmed" ? card.task.writeBack : void 0; + return (0, import_react15.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)( +- { className: "bt-q bt-secret", role: "group", "aria-describedby": noteId, "data-kind": request.kind }, +- (0, import_react5.createElement)(SecretHead, { +- bot, +- title: request.kind === "allow" ? t("Let {bot} use {name}?", { bot: bot.name, name: request.name }) : t("{name} needs a key", { name: bot.name }), +- detail: request.purpose, +- name: request.name, +- onClose: busy ? void 0 : cancel +- }), +- request.kind === "allow" ? (0, import_react5.createElement)("div", { className: "bt-secret-saved" }, (0, import_react5.createElement)(KeyIcon), t("Saved for {scope}", { scope: scopeLabel(secret?.scope, roster) })) : null, +- filling ? (0, import_react5.createElement)( +- "div", +- { className: "bt-secret-body" }, +- (0, import_react5.createElement)("label", { className: "bt-q-field bt-secret-field" }, (0, import_react5.createElement)(MaskedInput, { value, onChange: setValue, onEnter: save, label: t("Value of {name}", { name: request.name }), autoFocus: replacing })), +- request.kind === "fill" ? (0, import_react5.createElement)(ScopeSwitch, { value: scope, onChange: setScope, bot }) : null +- ) : null, +- (0, import_react5.createElement)( +- "div", +- { className: "bt-secret-note", id: noteId }, +- t("{bot} never sees the value. It reaches {bot}'s shell as ", { bot: bot.name }), +- (0, import_react5.createElement)("code", null, `$DSH_SECRET_${request.name}`), +- ".", +- filling && value !== "" && !long ? t(" At least {count} characters.", { count: MIN_VALUE }) : "" + { className: "bt-inbox-card", role: "group", "aria-label": `${t(STATUSES[card.status].label)}: ${card.title}`, "data-status": card.status, "data-run": card.kind === "workflow" ? card.run?.id : void 0 }, + (0, import_react15.createElement)("span", { className: "bt-inbox-ava" }, card.bot ? (0, import_react15.createElement)(BotAvatar, { bot: card.bot, size: 28, badge: false, live: false }) : card.room ? (0, import_react15.createElement)(RoomAvatar, { room: card.room, roster, size: 28 }) : null), + (0, import_react15.createElement)( - "span", -- { className: "bt-typing-text" }, -- (0, import_react7.createElement)( ++ "span", + { className: "bt-inbox-copy" }, + (0, import_react15.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_react15.createElement)(Status, { status: card.status }), + (0, import_react15.createElement)("span", { className: "bt-inbox-meta", title: meta }, meta) @@ -11558,7 +10389,35 @@ diff --git a/client.js b/client.js + unconfirmed ? (0, import_react15.createElement)("span", { className: "bt-inbox-writeback", title: unconfirmed.target }, t("Could not confirm the comment on {target} went out", { target: unconfirmed.target })) : null, + ...locked ? [] : [(0, import_react15.createElement)(AnswerFields, { card, busy, onAnswer }), (0, import_react15.createElement)(ResultActions, { card, busy, onAnswer })], + error ? (0, import_react15.createElement)("span", { className: "bt-inbox-error", role: "alert" }, error) : null -+ ), + ), +- warn, +- error ? (0, import_react5.createElement)("div", { className: "bt-secret-error", role: "alert" }, error) : null, +- (0, import_react5.createElement)( +- "div", +- { className: "bt-q-foot" }, +- request.kind === "allow" && !replacing ? (0, import_react5.createElement)("button", { type: "button", className: "bt-q-btn", disabled: busy, onClick: () => setReplacing(true) }, t("Replace value")) : null, +- (0, import_react5.createElement)("button", { type: "button", className: "bt-q-btn", disabled: busy, onClick: cancel }, t("Cancel")), +- filling ? (0, import_react5.createElement)("button", { type: "button", className: "bt-q-submit", disabled: !long || busy, onClick: save }, busy ? t("Saving…") : t("Save")) : (0, import_react5.createElement)("button", { type: "button", className: "bt-q-submit", disabled: busy, onClick: allow }, busy ? t("Allowing…") : t("Allow")) +- ) +- ); +-} +-function SettledSecret({ bot, request, roster }) { +- const canceled = request.status === "canceled"; +- const text = canceled ? t("{bot} asked for {name}", { bot: bot.name, name: request.name }) : request.status === "allowed" ? t("Allowed {bot} to use {name}", { bot: bot.name, name: request.name }) : t("Saved {name}", { name: request.name }); +- return (0, import_react5.createElement)( +- "div", +- { className: "bt-q bt-q-settled bt-secret bt-secret-done", role: "group", "aria-label": text, "data-status": request.status }, +- (0, import_react5.createElement)( +- "div", +- { className: "bt-q-head" }, +- (0, import_react5.createElement)("span", { className: "bt-secret-who" }, (0, import_react5.createElement)(BotMark, { bot, size: 22 })), +- (0, import_react5.createElement)("div", { className: "bt-q-text" }, (0, import_react5.createElement)( +- "p", +- { className: "bt-q-title" }, +- text, +- canceled ? null : (0, import_react5.createElement)("span", { className: "bt-secret-scope-tag" }, ` · ${scopeLabel(request.scope, roster)}`) +- )), +- canceled ? (0, import_react5.createElement)("span", { className: "bt-q-badge" }, t("Canceled")) : (0, import_react5.createElement)("span", { className: "bt-q-check bt-secret-check", title: t("Done") }, (0, import_react5.createElement)(CheckIcon)) + (0, import_react15.createElement)( + "span", + { className: "bt-inbox-actions" }, @@ -11566,9 +10425,11 @@ diff --git a/client.js b/client.js + answerable ? (0, import_react15.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_react15.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_react15.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", "aria-label": who ? t("Open {name}", { name: card.room?.name ?? who }) : t("Open"), onClick: () => onOpen(card) }, t("Open")) -+ ) -+ ); -+} + ) + ); + } +-function RequestView({ bot, request, roster, actions }) { +- return request.status === "pending" ? (0, import_react5.createElement)(SecretCard, { key: `${request.id}:${request.kind}`, bot, request, roster, actions }) : (0, import_react5.createElement)(SettledSecret, { bot, request, roster }); +function useNow2(ms) { + const [now, setNow] = (0, import_react15.useState)(Date.now); + (0, import_react15.useEffect)(() => { @@ -11576,15 +10437,40 @@ diff --git a/client.js b/client.js + return () => clearInterval(timer); + }, [ms]); + return now; -+} + } +-function SecretToolCell({ sessionId, callId, name, done, content, roster, actions }) { +- const bot = roster.byId[sessionId]; +- if (!bot) return null; +- const request = (roster.secretRequests?.[bot.id] ?? []).find((entry) => entry.callId === callId && (entry.sessionId ?? sessionId) === sessionId); +- if (request) return (0, import_react5.createElement)("div", { className: "bt-q-inline" }, (0, import_react5.createElement)(RequestView, { bot, request, roster, actions })); +- if (done && /already saved/.test(contentText(content))) { +- return (0, import_react5.createElement)("div", { className: "bt-event" }, (0, import_react5.createElement)("span", { className: "bt-secret-event" }, (0, import_react5.createElement)(KeyIcon), t("{bot} is using {name}", { bot: bot.name, name: name ?? t("a saved key") }))); +function openCard(actions, card) { + actions.closeInbox(); + if (card?.kind === "workflow") { + actions.openSettings("workflow"); + return; -+ } + } +- return null; +-} +-function SecretDock({ sessionId, useRoster, useCards, useSession, actions }) { +- const roster = useRoster((value) => value); +- const shown = useCards?.((state) => state[sessionId]) ?? NONE; +- const loading = useSession?.(historyLoading) === true; +- const bot = roster.byId[sessionId]; +- if (!bot || currentPart(bot) !== sessionId) return null; +- const drawn = (entry) => entry.callId && entry.sessionId === sessionId && (loading || shown[entry.callId] > 0); +- const waiting = (roster.secretRequests?.[bot.id] ?? []).filter((entry) => entry.status === "pending" && !drawn(entry)); +- if (waiting.length === 0) return null; +- return (0, import_react5.createElement)("div", { className: "bt-q-dock bt-secret-dock" }, waiting.map((request) => (0, import_react5.createElement)(SecretCard, { key: `${request.id}:${request.kind}`, bot, request, roster, actions }))); + actions.openSession(card.chat); -+} + } +-function ScopeEditor({ secret, roster, onSave }) { +- const [mode, setMode] = (0, import_react5.useState)(secret.scope === "all" ? "all" : "some"); +- const [picked, setPicked] = (0, import_react5.useState)(secret.scope === "all" ? [] : secret.scope); +- const toggle = (id) => setPicked((list) => list.includes(id) ? list.filter((item) => item !== id) : [...list, id]); +- const same = mode === "all" ? secret.scope === "all" : secret.scope !== "all" && [...picked].sort().join() === [...secret.scope].sort().join(); +- return (0, import_react5.createElement)( +function InboxDialog({ roster, useWork, useSessions, actions, leaving = false }) { + const work = useWork((value) => value); + const sessions = useSessions((value) => value); @@ -11622,21 +10508,43 @@ diff --git a/client.js b/client.js + }; + const open = (card) => openCard(actions, card); + return (0, import_react15.createElement)( -+ "div", -+ { className: "bt-mem-layer bt-inbox-layer", "data-leaving": leaving || void 0 }, -+ (0, import_react15.createElement)("div", { className: "bt-scrim bt-mem-scrim", onMouseDown: close }), -+ (0, import_react15.createElement)( -+ "div", -+ { className: "bt-mem bt-inbox", role: "dialog", "aria-modal": true, "aria-labelledby": "bt-inbox-heading" }, -+ (0, import_react15.createElement)( -+ "div", -+ { className: "bt-mem-head" }, -+ (0, import_react15.createElement)("h2", { id: "bt-inbox-heading" }, t("Inbox")), -+ (0, import_react15.createElement)("span", { className: "bt-mem-status" }, t("Your Bots")), -+ (0, import_react15.createElement)("span", { className: "bt-mem-head-gap" }), -+ (0, import_react15.createElement)("button", { type: "button", className: "bt-icon-btn", "aria-label": t("Close inbox"), autoFocus: true, onClick: close }, (0, import_react15.createElement)(CloseIcon)) -+ ), -+ (0, import_react15.createElement)( + "div", +- { className: "bt-secret-edit-block" }, +- (0, import_react5.createElement)("div", { className: "bt-secret-edit-label" }, t("Who may use it")), +- (0, import_react5.createElement)( +- "div", +- { className: "bt-secret-radios", role: "radiogroup", "aria-label": t("Who may use {name}", { name: secret.name }) }, +- [["all", t("All Bots")], ["some", t("Chosen Bots")]].map(([id, text]) => (0, import_react5.createElement)( +- "label", +- { key: id, className: "bt-secret-radio" }, +- (0, import_react5.createElement)("input", { type: "radio", name: `scope-${secret.name}`, checked: mode === id, onChange: () => setMode(id) }), +- text +- )) +- ), +- mode === "some" ? (0, import_react5.createElement)("div", { className: "bt-secret-bots" }, roster.bots.map((bot) => (0, import_react5.createElement)("button", { +- key: bot.id, +- type: "button", +- className: "bt-secret-bot", +- "aria-pressed": picked.includes(bot.id), +- onClick: () => toggle(bot.id) +- }, (0, import_react5.createElement)(BotAvatar, { bot, size: 20, badge: false, live: false }), bot.name, picked.includes(bot.id) ? (0, import_react5.createElement)(CheckIcon) : null))) : null, +- (0, import_react5.createElement)( ++ { className: "bt-mem-layer bt-inbox-layer", "data-leaving": leaving || void 0 }, ++ (0, import_react15.createElement)("div", { className: "bt-scrim bt-mem-scrim", onMouseDown: close }), ++ (0, import_react15.createElement)( + "div", +- { className: "bt-secret-row-actions" }, +- (0, import_react5.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: same, onClick: () => onSave(mode === "all" ? "all" : picked) }, t("Save access")) ++ { className: "bt-mem bt-inbox", role: "dialog", "aria-modal": true, "aria-labelledby": "bt-inbox-heading" }, ++ (0, import_react15.createElement)( ++ "div", ++ { className: "bt-mem-head" }, ++ (0, import_react15.createElement)("h2", { id: "bt-inbox-heading" }, t("Inbox")), ++ (0, import_react15.createElement)("span", { className: "bt-mem-status" }, t("Your Bots")), ++ (0, import_react15.createElement)("span", { className: "bt-mem-head-gap" }), ++ (0, import_react15.createElement)("button", { type: "button", className: "bt-icon-btn", "aria-label": t("Close inbox"), autoFocus: true, onClick: close }, (0, import_react15.createElement)(CloseIcon)) ++ ), ++ (0, import_react15.createElement)( + "div", + { className: "bt-mem-body bt-inbox-body" }, + ...locked ? [(0, import_react15.createElement)("p", { key: "readonly", className: "bt-hint bt-read-only", role: "status" }, hostText(roster.readOnly))] : [], @@ -11675,11 +10583,26 @@ diff --git a/client.js b/client.js + }, t(allDone ? "Show fewer" : "Show all")) : null + ); + }) - ) ++ ) ) -- ), host); -+ ); -+} + ); + } +-function ValueEditor({ secret, onSave }) { +- const [value, setValue] = (0, import_react5.useState)(""); +- const [replaced, setReplaced] = (0, import_react5.useState)(false); +- const long = value.trim().length >= MIN_VALUE; +- const save = () => { +- if (!long) return; +- void Promise.resolve(onSave(value)).then((ok) => { +- if (ok) { +- setValue(""); +- setReplaced(true); +- } +- }); +- }; +- const edit = (next) => { +- setValue(next); +- setReplaced(false); +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)} @@ -11747,17 +10670,18 @@ diff --git a/client.js b/client.js + key, + id: String(task.id), + title: String(task.title ?? "") -+ }; - } - --// 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); + }; +- return (0, import_react5.createElement)( +- "div", +- { className: "bt-secret-edit-block" }, +- (0, import_react5.createElement)("div", { className: "bt-secret-edit-label" }, t("Replace the value")), +- (0, import_react5.createElement)( +- "div", +- { className: "bt-secret-inline" }, +- (0, import_react5.createElement)("label", { className: "bt-q-field bt-secret-field" }, (0, import_react5.createElement)(MaskedInput, { value, onChange: edit, onEnter: save, label: t("New value of {name}", { name: secret.name }) })), +- (0, import_react5.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: !long, onClick: save }, t("Replace")) ++} ++ +// src/client/board-card.js +var import_react16 = require("react"); +var TASK_SKIN = { @@ -11810,7 +10734,8 @@ diff --git a/client.js b/client.js + (0, import_react16.createElement)("span", { className: skin.id }, idText), + topExtra, + badge ? (0, import_react16.createElement)("span", { className: skin.badge, "data-badge": card?.badge ?? void 0 }, badge) : null -+ ), + ), +- value !== "" && !long ? (0, import_react5.createElement)("div", { className: "bt-set-hint" }, t("At least {count} characters.", { count: MIN_VALUE })) : replaced ? (0, import_react5.createElement)("div", { className: "bt-set-hint bt-secret-ok", role: "status" }, (0, import_react5.createElement)(CheckIcon), t("Replaced. Bots get the new value from their next command.")) : null + (0, import_react16.createElement)("span", { className: skin.title }, card?.title || emptyTitle), + labels.length ? (0, import_react16.createElement)("span", { className: skin.labels }, labels.map((name) => (0, import_react16.createElement)("span", { key: name, className: skin.label, "data-label": name }, name))) : null, + card?.owner ? (0, import_react16.createElement)("span", { className: skin.owner, "data-kind": card.ownerKind ?? "human" }, card.owner) : null, @@ -11818,17 +10743,30 @@ diff --git a/client.js b/client.js + meta, + footer, + actions -+ ); + ); } - --// src/client/sidebar.js --var import_react8 = require("react"); --var import_react_dom3 = require("react-dom"); --var import_dsh_client_ui_primitives2 = require("@deepseek-ai/dsh-client-ui-primitives"); --function useMarkedUnread(id, active, marked, actions) { -- (0, import_react8.useEffect)(() => { -- if (active && marked) actions.clearUnread(id); -- }, [active, marked]); +-function SecretRow({ secret, roster, actions, run, open, onToggle }) { +- const [confirm, setConfirm] = (0, import_react5.useState)(false); +- (0, import_react5.useEffect)(() => { +- if (!open) setConfirm(false); +- }, [open]); +- const asker = secret.requestedBy ? roster.byId[secret.requestedBy]?.name ?? t("a deleted Bot") : t("you"); +- const allowed = secret.scope === "all" ? [] : secret.scope.map((id) => roster.byId[id]).filter(Boolean); +- const meta = [t("Asked by {name}", { name: asker }), t("added {time}", { time: when(secret.createdAt) }), secret.lastUsedAt ? t("last used {time}", { time: when(secret.lastUsedAt) }) : t("not used yet")].join(" · "); +- return (0, import_react5.createElement)( +- "div", +- { className: "bt-secret-item", "data-open": open ? "" : void 0 }, +- (0, import_react5.createElement)( +- "button", +- { type: "button", className: "bt-set-row bt-secret-summary", "aria-expanded": open, onClick: onToggle }, +- (0, import_react5.createElement)( +- "span", +- { className: "bt-set-copy" }, +- (0, import_react5.createElement)("span", { className: "bt-secret-title" }, (0, import_react5.createElement)(KeyIcon), (0, import_react5.createElement)("code", { className: "bt-secret-name" }, secret.name), secret.purpose ? (0, import_react5.createElement)("span", { className: "bt-secret-purpose" }, secret.purpose) : null), +- (0, import_react5.createElement)("span", { className: "bt-set-hint" }, meta) +- ), +- (0, import_react5.createElement)( ++ +// src/client/kanban.js +var import_react17 = require("react"); +var POLL_MS4 = 15e3; @@ -11840,36 +10778,7 @@ diff --git a/client.js b/client.js + (0, import_react17.createElement)("rect", { x: 8, y: 3, width: 4, height: 9, rx: 1 }), + (0, import_react17.createElement)("rect", { x: 13.5, y: 3, width: 4, height: 12, rx: 1 }) + ); - } --var PinMark = () => (0, import_react8.createElement)("span", { className: "bt-pin", "aria-hidden": true }, (0, import_react8.createElement)(PinMarkIcon)); --var useApproval = (useWork, match) => useWork ? useWork((work) => (work.approvals ?? []).some(match)) : false; --var useGroupWork = (useWork, pick2) => useWork ? useWork((work) => pick2(work.rooms ?? {}).join(",")) : ""; --var workKeys = (text) => text === "" ? [] : text.split(",").map((key) => { -- const [id, tool] = key.split("|"); -- return { id, tool: tool || void 0 }; --}); --var workLine = (lead, tool, more) => (0, import_react8.createElement)(Shimmer, null, `${lead} · ${workPhrase(tool)}…${more > 0 ? ` +${more}` : ""}`); --function BotRow({ bot, roster, actions, useSessions, useSessionStatus, useSessionRetainInfo, useActivity, useWork, wide, marked }) { -- const id = bot.id; -- const part = currentPart(bot); -- const preview = useSessions((list) => previewOf(list, part, roster.exchangeTurns[part])); -- const running = useSessionStatus((map) => map.get(part)?.running === true); -- const active = useSessionRetainInfo(part, (info) => (info?.retainedBy?.mainView ?? 0) > 0); -- const unread = useSessionStatus((map) => map.get(part)?.completionUnread === true || map.get(part)?.pendingInteraction !== void 0) || marked && !active; -- useMarkedUnread(id, active, marked, actions); -- const main = isMainOf(roster, id); -- const tile = roster.mainBotId === id; -- const approval = useApproval(useWork, (entry) => entry.botId === id); -- const alert = roster.questions?.[part] !== void 0 || approval; -- const doing = useActivity((map) => map[id]); -- const groups = workKeys(useGroupWork(useWork, (rooms) => Object.entries(rooms).flatMap(([roomId, entries2]) => entries2.filter((entry) => entry.botId === id).map((entry) => `${roomId}|${entry.tool ?? ""}`)))); -- const away = !running && groups.length > 0; -- const state = botState(running || away, doing, alert); -- const awayLine = away ? workLine(roster.roomsById[groups[0].id]?.name ?? t("a group chat"), groups[0].tool, groups.length - 1) : null; -- if (bot.hidden && !active && !main) return null; -- const onContextMenu = (event) => { -- event.preventDefault(); -- actions.openMenu({ kind: "bot", id, x: event.clientX, y: event.clientY }); ++} +function shortId(id) { + const hex = String(id ?? "").replace(/^task-/, ""); + return `#${hex.slice(-6)}`; @@ -11936,26 +10845,7 @@ diff --git a/client.js b/client.js + rows.push({ kind: "bot", id: bot.id, name: bot.name, depth, bot }); + walk(bot.id, depth + 1); + } - }; -- const pinned = bot.pinned === true && !tile; -- 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(", "); -- if (tile && wide) { -- return (0, import_react8.createElement)("div", { className: "bt-tile-wrap" }, (0, import_react8.createElement)( -- "button", -- { -- type: "button", -- className: "bt-tile", -- "aria-label": label, -- "aria-current": active ? "page" : void 0, -- "data-bot-id": id, -- title: preview || bot.name, -- onClick: () => actions.openSession(id), -- onContextMenu -- }, -- (0, import_react8.createElement)(BotAvatar, { bot, size: 60, state, main: true }), -- (0, import_react8.createElement)("span", { className: "bt-tile-name" }, bot.name), -- bot.role ? (0, import_react8.createElement)("span", { className: "bt-chip" }, bot.role) : null -- )); ++ }; + walk("", 0); + const groups = (roster?.rooms ?? []).filter((room2) => room2 && room2.hidden !== true && room2.origin !== "face-to-face" && adminOf(roster, room2)).map((room2) => ({ kind: "group", id: room2.id, name: room2.displayName || room2.name || t("Group"), room: room2 })); + return { bots: rows, groups }; @@ -11996,8 +10886,7 @@ diff --git a/client.js b/client.js + return t("Failed"); + default: + return t("Not started"); - } -- return (0, import_react8.createElement)( ++ } +} +function dropLabel(column) { + if (column === "in-progress") return t("Start task"); @@ -12067,16 +10956,9 @@ diff --git a/client.js b/client.js +} +function RailButton({ row, selected, meta, onSelect }) { + return (0, import_react17.createElement)( - "button", - { - type: "button", -- className: "bt-row", -- "aria-label": label, -- "aria-current": active ? "page" : void 0, -- "data-bot-id": id, -- title: wide ? void 0 : bot.name, -- onClick: () => actions.openSession(id), -- onContextMenu ++ "button", ++ { ++ type: "button", + className: "bt-board-rail-item", + "data-kind": row.kind, + "data-id": row.id, @@ -12084,47 +10966,11 @@ diff --git a/client.js b/client.js + "aria-current": selected ? "true" : void 0, + style: row.depth ? { paddingLeft: `${10 + row.depth * 14}px` } : void 0, + onClick: () => onSelect(row.kind === "group" ? { kind: "group", roomId: row.id } : { kind: "bot", botId: row.id }) - }, -- (0, import_react8.createElement)(BotAvatar, { bot, size: 36, state, main }), -- pinned && !wide ? (0, import_react8.createElement)(PinMark) : null, -- wide ? (0, import_react8.createElement)( -- "span", -- { className: "bt-row-body" }, -- (0, import_react8.createElement)("span", { className: "bt-row-name" }, (0, import_react8.createElement)("span", { className: "bt-row-title" }, bot.name), pinned ? (0, import_react8.createElement)(PinMark) : null), -- awayLine ? (0, import_react8.createElement)("span", { className: "bt-row-preview" }, awayLine) : preview ? (0, import_react8.createElement)("span", { className: "bt-row-preview" }, preview) : null -- ) : null, -- wide && unread && !active ? (0, import_react8.createElement)("span", { className: "bt-unread", "aria-hidden": true }) : null ++ }, + (0, import_react17.createElement)("span", { className: "bt-board-rail-name" }, row.name), + meta ? (0, import_react17.createElement)("span", { className: "bt-board-rail-meta" }, meta) : null - ); - } --function RoomRow({ room: room2, roster, actions, useSessions, useSessionStatus, useSessionRetainInfo, useWork, wide, marked }) { -- const id = room2.id; -- const fallback = useSessions((list) => previewOf(list, id)); -- const preview = room2.preview?.replace(/\s+/g, " ").trim() || fallback; -- const active = useSessionRetainInfo(id, (info) => (info?.retainedBy?.mainView ?? 0) > 0); -- const unread = useSessionStatus((map) => map.get(id)?.completionUnread === true) || marked && !active; -- useMarkedUnread(id, active, marked, actions); -- const approval = useApproval(useWork, (entry) => entry.roomId === id); -- const members = workKeys(useGroupWork(useWork, (rooms) => (rooms[id] ?? []).map((entry) => `${entry.botId}|${entry.tool ?? ""}`))); -- const first = roster.byId[members[0]?.id]; -- const workingLine = first ? workLine(first.name, members[0].tool, members.length - 1) : null; -- if (room2.hidden && !active) return null; -- const pinned = room2.pinned === true; -- const label = [room2.name, pinned ? t("Pinned") : "", members.length > 0 ? t("Working") : "", approval ? t("Waiting for your approval") : ""].filter(Boolean).join(", "); -- return (0, import_react8.createElement)( -- "button", -- { -- type: "button", -- className: "bt-row", -- "aria-label": label, -- "aria-current": active ? "page" : void 0, -- "data-room-id": id, -- onClick: () => actions.openSession(id), -- onContextMenu: (event) => { -- event.preventDefault(); -- actions.openMenu({ kind: "room", id, x: event.clientX, y: event.clientY }); -- } ++ ); ++} +function BoardCard2({ task, roster, movable, onMove, onDrag, partOf: partOf2 }) { + const column = columnOf(task); + const priority = priorityOf(task); @@ -12141,10 +10987,7 @@ diff --git a/client.js b/client.js + "data-column": column, + "data-priority": priority, + "data-kind": overall ? "group" : void 0 - }, -- (0, import_react8.createElement)(RoomAvatar, { room: room2, roster, size: 36 }), -- pinned && !wide ? (0, import_react8.createElement)(PinMark) : null, -- wide ? (0, import_react8.createElement)( ++ }, + idText: shortId(task.id), + emptyTitle: t("Untitled task"), + topExtra: [ @@ -12158,18 +11001,14 @@ diff --git a/client.js b/client.js + question ? (0, import_react17.createElement)("span", { key: "wait", className: "bt-board-wait" }, question) : null + ], + footer: (0, import_react17.createElement)( - "span", -- { className: "bt-row-body" }, -- (0, import_react8.createElement)("span", { className: "bt-row-name" }, (0, import_react8.createElement)("span", { className: "bt-row-title" }, room2.name), pinned ? (0, import_react8.createElement)(PinMark) : null), -- workingLine ? (0, import_react8.createElement)("span", { className: "bt-row-preview" }, workingLine) : preview ? (0, import_react8.createElement)("span", { className: "bt-row-preview" }, preview) : null -- ) : null, -- wide && approval && !active ? (0, import_react8.createElement)("span", { className: "bt-unread bt-unread-wait", "aria-hidden": true }) : wide && unread && !active ? (0, import_react8.createElement)("span", { className: "bt-unread", "aria-hidden": true }) : null -- ); ++ "span", + { className: "bt-board-foot" }, + who ? (0, import_react17.createElement)("span", { className: "bt-board-who" }, who) : null, + Number.isFinite(finished) && finished > 0 ? (0, import_react17.createElement)("span", { key: "finished", className: "bt-board-when" }, relativeShort(finished)) : null, + (0, import_react17.createElement)( -+ "span", + "span", +- { className: "bt-set-control" }, +- secret.scope === "all" ? (0, import_react5.createElement)("span", { className: "bt-tag" }, t("All Bots")) : allowed.length === 0 ? (0, import_react5.createElement)("span", { className: "bt-tag" }, t("No Bot")) : (0, import_react5.createElement)("span", { className: "bt-secret-faces", title: allowed.map((bot) => bot.name).join(", ") }, allowed.slice(0, 5).map((bot) => (0, import_react5.createElement)(BotAvatar, { key: bot.id, bot, size: 20, badge: false, live: false }))) + { className: "bt-board-progress" }, + (0, import_react17.createElement)( + "span", @@ -12177,8 +11016,9 @@ diff --git a/client.js b/client.js + (0, import_react17.createElement)("span", { style: { width: `${progress}%` } }) + ), + (0, import_react17.createElement)("span", { className: "bt-board-percent" }, t("{percent}%", { percent: progress })) -+ ) -+ ), + ) + ), +- open ? (0, import_react5.createElement)( + actions: [ + movable && column === "not-started" ? (0, import_react17.createElement)("button", { key: "start", type: "button", className: "bt-soft bt-soft-sm", onClick: () => onMove?.(task.id, "in-progress") }, t("Start")) : null, + movable && column === "in-progress" && task.status !== "blocked" ? (0, import_react17.createElement)("button", { key: "complete", type: "button", className: "bt-soft bt-soft-sm", onClick: () => onMove?.(task.id, "done") }, t("Complete")) : null @@ -12188,48 +11028,7 @@ diff --git a/client.js b/client.js + onDragStart: () => onDrag?.(task.id), + onDragEnd: () => onDrag?.(null) + }); - } --function AgentSessionRow({ session, agent, actions, renaming, index }) { -- const input = (0, import_react8.useRef)(null); -- (0, import_react8.useLayoutEffect)(() => { -- if (!renaming) return; -- input.current?.focus(); -- input.current?.select(); -- }, [renaming]); -- if (renaming) { -- const commit = (keep) => { -- const value = input.current?.value ?? ""; -- if (!keep || value.trim() === "" || value.trim() === session.title) { -- actions.endAgentRename(); -- return; -- } -- void (async () => { -- try { -- await actions.renameAgentSession(agent.id, session.id, value); -- actions.endAgentRename(); -- } catch (error) { -- actions.reportError(error, "agent-rename"); -- } -- })(); -- }; -- return (0, import_react8.createElement)( -- "div", -- { className: "bt-agent-sub-row bt-agent-edit", style: { "--i": index } }, -- (0, import_react8.createElement)("input", { -- ref: input, -- defaultValue: session.title, -- "aria-label": t("Rename"), -- onKeyDown: (event) => { -- if (event.nativeEvent.isComposing) return; -- if (event.key === "Enter") commit(true); -- else if (event.key === "Escape") commit(false); -- }, -- onBlur: () => commit(true) -- }) -- ); -- } -- return (0, import_react8.createElement)( -- "button", ++} +function cardProps(task, roster, onMove, onDrag, partOf2, locked) { + return { task, roster, movable: !locked && canMoveTask(roster, task), onMove, onDrag, partOf: partOf2 }; +} @@ -12239,21 +11038,13 @@ diff --git a/client.js b/client.js + const plain = tree ? tree.loose.filter((task) => !task.parentId && !task.parentTitle) : tasks; + return (0, import_react17.createElement)( + "section", - { -- type: "button", -- className: "bt-agent-sub-row", -- "aria-current": session.active ? "page" : void 0, -- style: { "--i": index }, -- onClick: () => actions.openAgentSession(session.id), -- onContextMenu: (event) => { ++ { + className: "bt-board-column", + "data-column": column, + "aria-label": columnTitle(column), + onDragOver: (event) => { + if (locked || !dragging) return; - event.preventDefault(); -- event.stopPropagation(); -- actions.openMenu({ kind: "agent-session", id: session.id, agentId: agent.id, x: event.clientX, y: event.clientY }); ++ event.preventDefault(); + }, + onDrop: (event) => { + if (locked) return; @@ -12261,10 +11052,8 @@ diff --git a/client.js b/client.js + const id = event.dataTransfer?.getData("text/plain"); + if (id) onMove?.(id, column); + onDrag?.(null); - } - }, -- (0, import_react8.createElement)("span", { className: "bt-agent-sub-title" }, session.title), -- (0, import_react8.createElement)("span", { className: "bt-agent-sub-time" }, session.at > 0 ? relativeShort(session.at) : "") ++ } ++ }, + (0, import_react17.createElement)( + "h3", + { className: "bt-board-column-head" }, @@ -12276,11 +11065,23 @@ diff --git a/client.js b/client.js + dragging ? (0, import_react17.createElement)("div", { className: "bt-board-drop" }, dropLabel(column)) : null, + tasks.length === 0 && !dragging ? (0, import_react17.createElement)("p", { className: "bt-board-empty" }, t("Nothing in this column.")) : null, + tree ? tree.nests.map((nest) => (0, import_react17.createElement)( -+ "div", + "div", +- { className: "bt-secret-edit" }, +- (0, import_react5.createElement)(ScopeEditor, { key: JSON.stringify(secret.scope), secret, roster, onSave: (scope) => run(actions.secretUpdate(secret.name, scope)) }), +- (0, import_react5.createElement)(ValueEditor, { secret, onSave: (value) => run(actions.secretSet({ name: secret.name, value })) }), +- (0, import_react5.createElement)( + { key: nest.task.id, className: "bt-board-nest" }, + (0, import_react17.createElement)(BoardCard2, cardProps(nest.task, roster, onMove, onDrag, void 0, locked)), + nest.children.map((child) => (0, import_react17.createElement)( -+ "div", + "div", +- { className: "bt-secret-row-actions" }, +- confirm ? [ +- (0, import_react5.createElement)("span", { key: "q", className: "bt-set-hint" }, t("Delete {name}? Bots lose it at once.", { name: secret.name })), +- (0, import_react5.createElement)("button", { key: "no", type: "button", className: "bt-soft bt-soft-sm", onClick: () => setConfirm(false) }, t("Keep")), +- (0, import_react5.createElement)("button", { key: "yes", type: "button", className: "bt-soft bt-soft-sm bt-secret-danger", onClick: () => run(actions.secretDelete(secret.name)) }, t("Delete")) +- ] : (0, import_react5.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm bt-secret-danger", onClick: () => setConfirm(true) }, (0, import_react5.createElement)(TrashIcon), t("Delete")) +- ) +- ) : null + { key: child.id, className: "bt-board-subtask", "data-parent": nest.task.id }, + (0, import_react17.createElement)(BoardCard2, cardProps(child, roster, onMove, onDrag, void 0, locked)) + )) @@ -12294,39 +11095,21 @@ diff --git a/client.js b/client.js + )) : plain.map((task) => (0, import_react17.createElement)(BoardCard2, { key: task.id, ...cardProps(task, roster, onMove, onDrag, void 0, locked) })) ); } --function AgentRow({ agent, actions, useSessions, useSessionStatus, useUi, wide, marked }) { -- const info = useSessions((list) => JSON.stringify(agent.sessions.map((id) => { -- const row = list.byId[id]; -- return { -- id, -- // A blank Session's title is the workspace folder name; show the label instead. -- title: row?.blank === true ? "" : row?.displayTitle ?? row?.title ?? "", -- at: row?.updatedAt ?? 0, -- running: row?.running === true, -- active: (row?.retainedBy?.mainView ?? 0) > 0 -- }; -- }).sort((a, b) => b.at - a.at))); -- const statusInfo = useSessionStatus((map) => JSON.stringify(agent.sessions.map((id) => ({ -- id, -- running: map.get(id)?.running === true, -- unread: map.get(id)?.completionUnread === true -- })))); -- const statuses = Object.fromEntries(JSON.parse(statusInfo).map((entry) => [entry.id, entry])); -- const sessions = JSON.parse(info).map((session) => ({ -- ...session, -- running: statuses[session.id]?.running ?? session.running, -- unread: statuses[session.id]?.unread ?? false, -- title: session.title === "" ? t("New session") : session.title -- })); -- const renaming = useUi((value) => value.renameAgent); -- const active = sessions.some((session) => session.active); -- const running = sessions.some((session) => session.running); -- const unread = sessions.some((session) => session.unread) || marked && !active; -- useMarkedUnread(agent.id, active, marked, actions); -- 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; -- return (0, import_react8.createElement)( +-function AddSecret({ actions, run, onDone }) { +- const [name, setName] = (0, import_react5.useState)(""); +- const [purpose, setPurpose] = (0, import_react5.useState)(""); +- const [value, setValue] = (0, import_react5.useState)(""); +- const ready = /^[A-Za-z][\w-]{0,63}$/.test(name.trim()) && value.trim().length >= MIN_VALUE; +- const save = () => { +- if (!ready) return; +- void run(actions.secretSet({ name, purpose, value, scope: "all" })).then((ok) => { +- if (ok) { +- setValue(""); +- onDone(); +- } +- }); +- }; +- return (0, import_react5.createElement)( +function BoardView({ roster, tasks, rail, scope, counts: counts2, dragging, error, draft, onDraft, onSelect, onMove, onDrag, onCreate, archiveFull, locked }) { + const columns = boardColumns(tasks); + const lanes = scope?.kind === "group"; @@ -12334,30 +11117,11 @@ diff --git a/client.js b/client.js + const selectedRoom = scope?.kind === "group" ? scope.roomId : ""; + return (0, import_react17.createElement)( "div", -- { className: "bt-agent" }, -- (0, import_react8.createElement)( -- "button", -- { -- type: "button", -- className: "bt-row", -- "aria-label": label, -- "aria-current": active ? "page" : void 0, -- "data-agent-id": agent.id, -- title: wide ? void 0 : "DSH Agent", -- onClick: () => actions.openAgent(agent.id), -- onContextMenu: (event) => { -- event.preventDefault(); -- actions.openMenu({ kind: "agent", id: agent.id, x: event.clientX, y: event.clientY }); -- } -- }, -- (0, import_react8.createElement)("span", { className: `bt-agent-ava${running ? " bt-run" : ""}` }, (0, import_react8.createElement)(import_dsh_client_ui_primitives2.FishLogo, { size: 22 })), -- wide ? (0, import_react8.createElement)( -- "span", -- { className: "bt-row-body" }, -- (0, import_react8.createElement)("span", { className: "bt-row-name" }, "DSH Agent"), -- preview ? (0, import_react8.createElement)("span", { className: "bt-row-preview" }, preview) : null -- ) : null, -- wide && unread && !active ? (0, import_react8.createElement)("span", { className: "bt-unread", "aria-hidden": true }) : null +- { className: "bt-set-row bt-set-paste bt-secret-add" }, +- (0, import_react5.createElement)("input", { className: "bt-input", "aria-label": t("Key name"), placeholder: t("NAME, e.g. GITHUB_TOKEN"), value: name, spellCheck: false, autoComplete: "off", autoFocus: true, onChange: (event) => setName(event.target.value.toUpperCase()) }), +- (0, import_react5.createElement)("input", { className: "bt-input", "aria-label": t("What it is for"), placeholder: t("What it is for"), value: purpose, autoComplete: "off", onChange: (event) => setPurpose(event.target.value) }), +- (0, import_react5.createElement)("label", { className: "bt-q-field bt-secret-field" }, (0, import_react5.createElement)(MaskedInput, { value, onChange: setValue, onEnter: save, label: t("Value") })), +- (0, import_react5.createElement)( + { className: "bt-board-body" }, + (0, import_react17.createElement)( + "aside", @@ -12377,12 +11141,13 @@ diff --git a/client.js b/client.js + }), + rail.groups.length > 0 ? (0, import_react17.createElement)("h3", { className: "bt-board-rail-head" }, t("Groups you admin")) : null, + rail.groups.map((row) => (0, import_react17.createElement)(RailButton, { key: row.id, row, selected: row.id === selectedRoom, onSelect })) - ), -- (0, import_react8.createElement)( ++ ), + (0, import_react17.createElement)( "div", -- { className: "bt-agent-sub", "data-open": open || void 0 }, -- (0, import_react8.createElement)( +- { className: "bt-secret-row-actions" }, +- (0, import_react5.createElement)("span", { className: "bt-set-hint" }, t("New keys are for all Bots; narrow it down afterwards.")), +- (0, import_react5.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: onDone }, t("Cancel")), +- (0, import_react5.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: !ready, onClick: save }, t("Save key")) + { className: "bt-board-main" }, + error ? (0, import_react17.createElement)("p", { className: "bt-board-error", role: "alert" }, error) : null, + lanes ? (0, import_react17.createElement)("p", { className: "bt-board-hint" }, t("Assign, claim, and progress live on this board: one overall task, a subtask per part, an assignee, a column, and progress. @ only wakes someone.")) : null, @@ -12399,23 +11164,7 @@ diff --git a/client.js b/client.js + // so the lock's own sentence is drawn here too, from the host, not in a second wording. + locked ? (0, import_react17.createElement)("p", { className: "bt-board-hint bt-read-only", role: "status" }, hostText(roster.readOnly)) : null, + (0, import_react17.createElement)( - "div", -- { className: "bt-agent-sub-in" }, -- (0, import_react8.createElement)( -- "button", -- { type: "button", className: "bt-agent-sub-row bt-agent-new", style: { "--i": 0 }, onClick: () => { -- void actions.newAgentSession(agent.id).catch((error) => actions.reportError(error, "agent-session")); -- } }, -- (0, import_react8.createElement)(PlusIcon), -- (0, import_react8.createElement)("span", { className: "bt-agent-sub-title" }, t("New session")) -- ), -- sessions.map((session, index) => (0, import_react8.createElement)(AgentSessionRow, { -- key: session.id, -- session, -- agent, -- actions, -- index: index + 1, -- renaming: renaming === session.id ++ "div", + { className: "bt-board-columns" }, + ["not-started", "in-progress", "done"].map((column) => (0, import_react17.createElement)(BoardColumn, { + key: column, @@ -12428,30 +11177,34 @@ diff --git a/client.js b/client.js + onDrag, + archiveFull, + locked - })) - ) ++ })) ++ ) ) ); } --var GLIDE = springEasing(1e3, 63); --function useListGlide(listRef, layout, key) { -- const tops = (0, import_react8.useRef)({ layout, map: /* @__PURE__ */ new Map() }); -- (0, import_react8.useLayoutEffect)(() => { -- const list = listRef.current; -- if (!list) return; -- const next = /* @__PURE__ */ new Map(); -- const still = reducedMotion() || tops.current.layout !== layout; -- for (const row of list.querySelectorAll("[data-bot-id],[data-room-id],[data-agent-id]")) { -- const id = row.dataset.botId ?? row.dataset.roomId ?? row.dataset.agentId; -- const top = row.offsetTop; -- next.set(id, top); -- const before = tops.current.map.get(id); -- if (!still && before !== void 0 && Math.abs(before - top) > 0.5) { -- row.animate([{ transform: `translateY(${before - top}px)` }, { transform: "none" }], GLIDE); -- } -- } -- tops.current = { layout, map: next }; -- }, [layout, key]); +-function SecretsPage({ roster, actions, run, Section }) { +- const [open, setOpen] = (0, import_react5.useState)(null); +- const [adding, setAdding] = (0, import_react5.useState)(false); +- const secrets = roster.secrets ?? []; +- return [ +- (0, import_react5.createElement)( +- "p", +- { key: "about", className: "bt-secret-about" }, +- t("Keys your Bots asked for. Bots never see the values: they use them as "), +- (0, import_react5.createElement)("code", null, "$DSH_SECRET_NAME"), +- t(" in their shell, and every value is replaced by "), +- (0, import_react5.createElement)("code", null, "[secret:NAME]"), +- t(" before anything goes to a model.") +- ), +- (0, import_react5.createElement)( +- Section, +- { key: "list", title: t("Secrets · {count}", { count: secrets.length }) }, +- secrets.length === 0 && !adding ? (0, import_react5.createElement)("div", { className: "bt-set-row" }, (0, import_react5.createElement)("span", { className: "bt-set-hint" }, t("No keys yet. When a Bot needs one, it shows a card in its chat."))) : null, +- secrets.map((secret) => (0, import_react5.createElement)(SecretRow, { key: secret.name, secret, roster, actions, run, open: open === secret.name, onToggle: () => setOpen(open === secret.name ? null : secret.name) })), +- adding ? (0, import_react5.createElement)(AddSecret, { actions, run, onDone: () => setAdding(false) }) : null +- ), +- adding ? null : (0, import_react5.createElement)("div", { key: "add", className: "bt-actions" }, (0, import_react5.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => setAdding(true) }, t("Add a key"))) +- ]; +function KanbanRow({ wide, useUi, actions }) { + const open = useUi ? useUi((value) => Boolean(value.board)) : false; + const label = t("Tasks"); @@ -12474,37 +11227,7 @@ diff --git a/client.js b/client.js + if (scope?.kind === "group") return roster.roomsById?.[scope.roomId]?.name || t("Group"); + if (scope?.kind === "bot") return roster.byId?.[scope.botId]?.name || ""; + return ""; - } --function TeamSidebar(props) { -- const { wide, useRoster, useUi, useSessions, actions } = props; -- const listRef = (0, import_react8.useRef)(null); -- const roster = useRoster((value) => value); -- const markedUnread = useUi((value) => value.unread); -- props.useRegistry((value) => value); -- const updated = useSessions((list) => { -- const stamps = {}; -- for (const id of list.ids) stamps[id] = list.byId[id]?.updatedAt ?? 0; -- return JSON.stringify(stamps); -- }); -- const order = (0, import_react8.useMemo)(() => { -- const stamps = JSON.parse(updated); -- const entries2 = [ -- ...roster.bots.filter((bot) => bot.id !== roster.mainBotId).map((bot) => ({ kind: "bot", item: bot, at: stamps[currentPart(bot)] ?? bot.createdAt })), -- ...roster.rooms.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 })) -- ]; -- return entries2.sort((a, b) => Number(Boolean(b.item.pinned)) - Number(Boolean(a.item.pinned)) || b.at - a.at); -- }, [roster, updated]); -- const main = roster.byId[roster.mainBotId]; -- 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 }; -- const typeToSearch = (event) => { -- if (event.defaultPrevented || event.key.length !== 1 || event.key === " " || event.metaKey || event.ctrlKey || event.altKey || event.nativeEvent.isComposing) return; -- if (event.target instanceof Element && event.target.closest("input,textarea,[contenteditable]")) return; -- event.preventDefault(); -- actions.openPalette(event.key); ++} +function KanbanDialog({ roster, actions, leaving = false }) { + const locked = typeof roster?.readOnly === "string" && roster.readOnly.trim() !== ""; + const rail = boardRail(roster); @@ -12544,8 +11267,7 @@ diff --git a/client.js b/client.js + } catch (failure) { + setError(failure?.message ?? String(failure)); + } - }; -- return (0, import_react8.createElement)( ++ }; + const create = async (title) => { + if (locked) return; + const text2 = String(title ?? "").trim(); @@ -12560,30 +11282,11 @@ diff --git a/client.js b/client.js + }; + const name = scopeName(roster, scope); + return (0, import_react17.createElement)( - "div", -- { className: wide ? "bt-side" : "bt-side bt-rail", onKeyDown: typeToSearch }, -- wide ? (0, import_react8.createElement)( -- "div", -- { className: "bt-side-top" }, -- (0, import_react8.createElement)("span", { className: "bt-brand" }, (0, import_react8.createElement)(BrandMark, { size: 22 }), (0, import_react8.createElement)(BrandName)), -- (0, import_react8.createElement)(UpdateButton, { useUpdate: props.useUpdate, actions }), -- (0, import_react8.createElement)("button", { type: "button", className: "bt-round", "aria-label": t("Search"), title: t("Search"), onClick: () => actions.openPalette() }, (0, import_react8.createElement)(SearchIcon)), -- (0, import_react8.createElement)("button", { type: "button", className: "bt-round", "aria-label": t("New…"), title: t("New…"), onClick: () => actions.openNewChat("new") }, (0, import_react8.createElement)(PlusIcon)) -- ) : (0, import_react8.createElement)( -- import_react8.Fragment, -- null, -- (0, import_react8.createElement)(UpdateButton, { useUpdate: props.useUpdate, actions }), -- (0, import_react8.createElement)("button", { type: "button", className: "bt-round", "aria-label": t("New…"), title: t("New…"), onClick: () => actions.openNewChat("new") }, (0, import_react8.createElement)(PlusIcon)) -- ), -- wide && roster.readOnly ? (0, import_react8.createElement)("div", { className: "bt-hint bt-read-only", role: "status" }, hostText(roster.readOnly)) : null, -- (0, import_react8.createElement)( ++ "div", + { className: "bt-mem-layer bt-board-layer", "data-leaving": leaving || void 0 }, + (0, import_react17.createElement)("div", { className: "bt-scrim", onClick: close }), + (0, import_react17.createElement)( - "div", -- { ref: listRef, className: "bt-list", role: "region", "aria-label": t("Bot list") }, -- main ? (0, import_react8.createElement)(BotRow, { ...rowProps, bot: main, marked: markedUnread[main.id] === true }) : roster.ready ? null : (0, import_react8.createElement)("div", { className: "bt-hint" }, t("Setting up your Main Bot…")), -- order.map((entry) => entry.kind === "bot" ? (0, import_react8.createElement)(BotRow, { key: entry.item.id, ...rowProps, bot: entry.item, marked: markedUnread[entry.item.id] === true }) : entry.kind === "room" ? (0, import_react8.createElement)(RoomRow, { key: entry.item.id, ...rowProps, room: entry.item, marked: markedUnread[entry.item.id] === true }) : (0, import_react8.createElement)(AgentRow, { key: entry.item.id, ...rowProps, agent: entry.item, marked: markedUnread[entry.item.id] === true })) ++ "div", + { className: "bt-mem bt-board", role: "dialog", "aria-modal": true, "aria-labelledby": "bt-board-heading" }, + (0, import_react17.createElement)( + "div", @@ -12612,18 +11315,9 @@ diff --git a/client.js b/client.js + onDrag: setDragging, + onCreate: create + }) - ) - ); - } --function UpdateButton({ useUpdate, actions }) { -- const update = useUpdate((value) => value); -- const [open, setOpen] = (0, import_react8.useState)(false); -- const [box, setBox] = (0, import_react8.useState)(null); -- const [error, setError] = (0, import_react8.useState)(""); -- const [dshError, setDshError] = (0, import_react8.useState)(""); -- const button = (0, import_react8.useRef)(null); -- (0, import_react8.useEffect)(() => { -- if (!open) return void 0; ++ ) ++ ); ++} +var KANBAN_CSS = ` +.bt-board-row{min-height:40px;padding:6px 8px;gap:10px} +.bt-board-mark{flex:none;width:36px;height:28px;display:inline-flex;align-items:center;justify-content:center;color:var(--bt-ink-2)} @@ -12686,16 +11380,101 @@ diff --git a/client.js b/client.js + .bt-mem.bt-board{width:100vw;border-radius:0;border:0} + .bt-board-columns{flex-direction:column} + .bt-board-column{min-width:0} -+} -+`; -+ + } +-var SECRET_CSS = ` +-.bt-secret-head{align-items:center;gap:10px} +-.bt-secret-who{position:relative;flex:none;display:inline-flex} +-.bt-secret-lock{position:absolute;right:-5px;bottom:-4px;width:16px;height:16px;border-radius:50%;background:var(--bt-main);color:var(--bt-ink-2);display:inline-flex;align-items:center;justify-content:center;box-shadow:0 0 0 1.5px var(--bt-bubble)} +-.bt-secret-lock svg{width:11px;height:11px} +-.bt-secret-name{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px;line-height:16px;padding:1px 6px;border-radius:5px;background:var(--bt-hover);color:var(--bt-ink)} +-.bt-secret-body{display:flex;flex-direction:column;gap:8px} +-.bt-secret-field{align-items:center;min-height:36px} +-.bt-secret-input{flex:1;width:100%;min-width:0;margin:0;padding:0;border:0;outline:none;background:transparent;color:var(--bt-ink);font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:14px;line-height:20px;letter-spacing:.04em} +-.bt-secret-input[data-masked]{-webkit-text-security:disc} +-.bt-secret-input::placeholder{color:var(--bt-ink-3);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;letter-spacing:0} +-.bt-secret-scope{position:relative;display:inline-grid;grid-template-columns:1fr 1fr;align-self:flex-start;padding:2px;border-radius:999px;background:var(--bt-hover)} +-.bt-secret-glide{position:absolute;top:2px;bottom:2px;left:2px;width:calc(50% - 2px);border-radius:999px;background:var(--bt-main);box-shadow:0 1px 3px rgba(20,20,20,.08),inset 0 0 0 .5px color-mix(in srgb,var(--bt-ink) 6%,transparent);transition:${spring(SPRING_SHAPE, "transform")}} +-.bt-secret-glide[data-at="1"]{transform:translateX(100%)} +-.bt-secret-seg{position:relative;z-index:1;height:28px;padding:0 14px;border:0;border-radius:999px;background:none;color:var(--bt-ink-2);font:inherit;font-size:13px;white-space:nowrap;cursor:pointer;transition:color .16s ease} +-.bt-secret-seg[aria-checked=true]{color:var(--bt-ink);font-weight:500} +-.bt-secret-seg:focus-visible{outline:2px solid var(--bt-accent);outline-offset:-2px} +-/* A key card asks the user to act, so the process group carrying it stays open in every +- work-details mode (same override as .bt-group-replies in styles.js). */ +-[data-step-process]:has(.bt-secret)>div:first-child{display:none!important} +-[data-step-process]:has(.bt-secret)>[data-step-process-body]{display:block!important;content-visibility:visible!important;max-height:none!important;overflow:visible!important;mask-image:none!important;scrollbar-gutter:auto!important} +-.bt-secret-ok{display:flex;align-items:center;gap:4px;margin-top:6px;animation:bt-secret-in .22s cubic-bezier(.22,1,.36,1)} +-.bt-secret-ok svg{width:13px;height:13px;color:#16a34a} +-@keyframes bt-secret-in{from{opacity:0;transform:translateY(-2px)}} +-.bt-secret-saved{display:flex;align-items:center;gap:6px;font-size:13px;line-height:18px;color:var(--bt-ink-2)} +-.bt-secret-note{font-size:12px;line-height:17px;color:var(--bt-ink-3)} +-.bt-secret-note code,.bt-secret-about code{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11.5px} +-.bt-secret-warn{display:flex;align-items:center;gap:6px;padding:6px 10px;border-radius:8px;font-size:12px;line-height:16px;color:#b25e09;background:color-mix(in srgb,#f59e0b 12%,transparent)} +-.bt-secret-error{font-size:12px;line-height:16px;color:#e02135} +-.bt-secret .bt-q-btn:disabled{opacity:.5;cursor:default} +-.bt-secret-done .bt-q-head{align-items:center} +-.bt-secret-done .bt-q-title{font-size:13px;line-height:18px;color:var(--bt-ink-2);font-weight:500} +-.bt-secret-scope-tag{font-weight:400;color:var(--bt-ink-3)} +-.bt-secret-check{color:var(--bt-green,#22a06b);animation:bt-secret-pop ${SPRING_POP.duration}ms ${SPRING_POP.easing} backwards} +-.bt-secret-done{animation:bt-secret-settle .32s cubic-bezier(.22,1,.36,1) backwards} +-@keyframes bt-secret-pop{from{opacity:0;transform:scale(.4)}} +-@keyframes bt-secret-settle{from{opacity:.4;transform:scale(.985)}} +-.bt-secret-event{display:inline-flex;align-items:center;gap:5px} +-.bt-secret-dock{flex-direction:column;align-items:center} +-.bt-secret-dock .bt-q{max-width:min(550px,calc(100% - 32px))} +-.bt-secret-about{margin:0 0 12px;padding:0 4px;font-size:12px;line-height:17px;color:var(--bt-ink-2)} +-.bt-secret-title{display:flex;align-items:center;gap:6px;min-width:0;color:var(--bt-ink)} +-.bt-secret-title svg{flex:none;color:var(--bt-ink-2)} +-.bt-secret-purpose{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--bt-ink-2)} +-.bt-secret-faces{display:inline-flex} +-.bt-secret-faces>*+*{margin-left:-6px} +-.bt-secret-edit{display:flex;flex-direction:column;gap:14px;padding:4px 12px 14px;animation:bt-q-in ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing} backwards,bt-veil-in .2s ease-out backwards} +-.bt-secret-edit-block{display:flex;flex-direction:column;gap:8px} +-.bt-secret-edit-label{font-size:12px;line-height:16px;font-weight:600;color:var(--bt-ink-2)} +-.bt-secret-radios{display:flex;gap:16px;font-size:13px} +-.bt-secret-radio{display:inline-flex;align-items:center;gap:6px;cursor:pointer} +-.bt-secret-bots{display:flex;flex-wrap:wrap;gap:6px} +-.bt-secret-bot{display:inline-flex;align-items:center;gap:6px;height:30px;padding:0 10px 0 5px;border-radius:999px;border:.5px solid var(--bt-line);background:var(--bt-card);color:var(--bt-ink-2);font:inherit;font-size:12px;cursor:pointer;transition:background-color .12s ease,color .12s ease,border-color .12s ease} +-.bt-secret-bot[aria-pressed=true]{background:var(--bt-active);color:var(--bt-ink);border-color:var(--bt-line-2)} +-.bt-secret-bot svg{width:13px;height:13px} +-.bt-secret-inline{display:flex;gap:8px;align-items:center} +-.bt-secret-row-actions{display:flex;align-items:center;justify-content:flex-end;gap:8px;flex-wrap:wrap} +-.bt-secret-row-actions .bt-set-hint{flex:1} +-.bt-secret-danger{color:#e02135;display:inline-flex;align-items:center;gap:4px} +-.bt-secret-danger svg{width:13px;height:13px} +-.bt-secret-add .bt-input{height:34px} +-@media (prefers-reduced-motion:reduce){.bt-secret-glide{transition:none}.bt-secret-check,.bt-secret-done,.bt-secret-ok,.bt-secret-edit{animation:none}} + `; + +-// src/client/turn-reasoning.js +-function turnReasoning(steps, current) { +- const members = steps.length ? steps : [current]; +- if (members.find((step) => stepReasoning(step) !== "")?.step !== current.step) return ""; +- return members.filter((step) => !thinkingLive(step)).map(stepReasoning).filter(Boolean).join("\n\n"); +// src/client/role-labels.js +var ROLE_LABELS = { owner: "Owner", maintainer: "Maintainer", member: "Member" }; +function roleText(role) { + if (role === null || role === void 0 || role === "") return ""; + return t(ROLE_LABELS[role] ?? String(role)); -+} -+ + } +-var texts = (step) => step.blocks.filter((block) => block.kind === "text" && block.text.trim() !== ""); +-var thinkingLive = (step) => step.status === "running" && texts(step).length === 0 && step.blocks.some((block) => block.kind === "reasoning"); +-var stepReasoning = (step) => step.blocks.filter((block) => block.kind === "reasoning").map((block) => block.text).join("\n").trim(); + +-// src/client/cells.js +-var import_react6 = require("react"); +-var import_react_dom = require("react-dom"); +-var import_dsh_client_ui_primitives = require("@deepseek-ai/dsh-client-ui-primitives"); +-var mdLabels = () => ({ code: { copyLabel: t("Copy"), copiedLabel: t("Copied") }, footnotes: t("Footnotes") }); +-function Bubbles({ texts: texts2, roster, selfId, actions, streaming, sessionId, msgKey, useUi, lead, mentions }) { +- const onClick = (event) => { +- const target = mentionTarget(event); +- if (target === null) return; +- event.preventDefault(); +- event.stopPropagation(); +- actions.openSession(target); +- }; +- const reactions = useUi ? useUi((state) => msgKey ? state.reactions?.[msgKey] ?? null : null) : null; +- return (0, import_react6.createElement)( +// src/client/projects-nav.js +var import_react18 = require("react"); +var import_react_dom3 = require("react-dom"); @@ -12759,12 +11538,45 @@ diff --git a/client.js b/client.js + roleText(project.role) === "" ? null : (0, import_react18.createElement)("span", { className: "bt-project-entry-role" }, roleText(project.role)) + )); + return (0, import_react18.createElement)( -+ "div", + "div", +- { className: "bt-astack", "data-lead": lead ? "" : void 0, onClickCapture: onClick }, +- texts2.map((text, index) => { +- const key = msgKey ? `${msgKey}:${index}` : void 0; +- const picked = key ? reactions?.[index] ?? [] : []; +- const last = index === texts2.length - 1; +- const live = streaming && last; +- return (0, import_react6.createElement)( +- "div", +- { key: index, className: "bt-msg" }, +- (0, import_react6.createElement)( +- "div", +- { className: "bt-msg-line" }, +- lead && last ? lead : null, +- (0, import_react6.createElement)( +- "div", +- { className: "bt-bubble" }, +- (0, import_react6.createElement)(import_dsh_client_ui_primitives.MarkdownText, { text: linkMentions(text, roster.bots, selfId), streaming: live, labels: mdLabels(), variant: "compact", fileMentions: mentions }) +- ), +- live ? null : (0, import_react6.createElement)(MessageTools, { text, name: roster.byId[selfId]?.name ?? t("Bot"), sessionId, react: key ? (emoji) => actions.toggleReaction(msgKey, index, emoji) : void 0, actions }) +- ), +- picked.length ? (0, import_react6.createElement)("div", { className: "bt-reacts" }, picked.map((emoji) => (0, import_react6.createElement)("button", { +- key: emoji, +- type: "button", +- className: "bt-react", +- "aria-label": t("Remove reaction {emoji}", { emoji }), +- onClick: () => actions.toggleReaction(msgKey, index, emoji) +- }, emoji))) : null +- ); +- }) + { className: "bt-projects-list", role: "group", "aria-label": t("Projects") }, + rows, + note === "" || note === void 0 ? null : (0, import_react18.createElement)("div", { className: "bt-projects-note", role: "status" }, note) -+ ); -+} + ); + } +-var REACTIONS = ["👍", "❤️", "😂", "😮", "😢", "🙏"]; +-function useDismiss(open, close, refs) { +- (0, import_react6.useEffect)(() => { +- if (!open) return void 0; +function Chevron() { + return (0, import_react18.createElement)( + "svg", @@ -12784,15 +11596,14 @@ diff --git a/client.js b/client.js + const ref = (0, import_react18.useRef)(null); + (0, import_react18.useEffect)(() => { const onDown = (event) => { -- if (event.target instanceof Element && (event.target.closest(".bt-update-pop") || button.current?.contains(event.target))) return; -- setOpen(false); +- if (refs.some((ref) => ref.current?.contains(event.target))) return; +- close(); + if (!ref.current?.contains(event.target)) onClose(); }; const onKey = (event) => { - if (event.key === "Escape") { - event.stopPropagation(); -- setOpen(false); -- button.current?.focus(); +- close(); - } + if (event.key === "Escape") onClose(); }; @@ -12805,12 +11616,18 @@ diff --git a/client.js b/client.js window.removeEventListener("keydown", onKey, true); }; - }, [open]); -- const phase = update?.phase ?? "idle"; -- const dsh = update?.dsh; -- const dshPhase = dsh?.phase ?? "idle"; -- const dshRow = showsDshRow(dsh, update?.app); -- if (!update?.available && !dshRow && phase === "idle" && dshPhase === "idle") return null; -- const cloudPhase = phase === "installing" || dshPhase === "installing" ? "installing" : phase !== "idle" ? phase : dshPhase; +-} +-function MessageTools({ text, name, sessionId, react, actions }) { +- const [panel, setPanel] = (0, import_react6.useState)(null); +- const [place, setPlace] = (0, import_react6.useState)(null); +- const [copied, setCopied] = (0, import_react6.useState)(false); +- const tools = (0, import_react6.useRef)(null); +- const pop = (0, import_react6.useRef)(null); +- const close = () => setPanel(null); +- useDismiss(panel !== null, close, [tools, pop]); +- const open = (kind, event) => { +- if (panel === kind) { +- close(); + }, [onClose]); + const items = projectMenuItems(project); + return (0, import_react_dom3.createPortal)((0, import_react18.createElement)( @@ -12843,8 +11660,24 @@ diff --git a/client.js b/client.js + const open = (0, import_react18.useCallback)((id) => { + if (onOpen) { + onOpen(id); -+ return; -+ } + return; + } +- const rect = event.currentTarget.getBoundingClientRect(); +- const below = rect.bottom + 220 < window.innerHeight; +- setPlace(kind === "react" ? { left: rect.left + rect.width / 2, top: rect.top - 6, "--bt-origin": "bottom center" } : { left: Math.min(rect.left, window.innerWidth - 208), ...below ? { top: rect.bottom + 4 } : { bottom: window.innerHeight - rect.top + 4, "--bt-origin": "bottom left" } }); +- setPanel(kind); +- }; +- const copy2 = () => { +- void navigator.clipboard?.writeText(text).then(() => { +- setCopied(true); +- setTimeout(() => setCopied(false), 1e3); +- }); +- }; +- const download2 = () => { +- const url = URL.createObjectURL(new Blob([text], { type: "text/markdown" })); +- const link = Object.assign(document.createElement("a"), { href: url, download: `${name}-message.md` }); +- link.click(); +- setTimeout(() => URL.revokeObjectURL(url), 1e3); + if (typeof window !== "undefined") window.location.hash = projectHash(id); + }, [onOpen]); + const [expanded, setExpanded] = (0, import_react18.useState)(false); @@ -12867,18 +11700,7 @@ diff --git a/client.js b/client.js + if (!expanded) return; + void load(); + }, [expanded, load]); - const toggle = () => { -- const rect = button.current?.getBoundingClientRect(); -- if (rect) setBox({ left: Math.max(8, Math.min(rect.left, window.innerWidth - 288)), top: rect.bottom + 6 }); -- setOpen((value) => !value); -- }; -- const start = () => { -- setError(""); -- void actions.installUpdate().catch((failure) => setError(hostText(failure?.message ?? String(failure)))); -- }; -- const startDsh = () => { -- setDshError(""); -- void actions.installDshUpdate().catch((failure) => setDshError(hostText(failure?.message ?? String(failure)))); ++ const toggle = () => { + setNote(""); + if (!wide) { + void open(""); @@ -12886,67 +11708,40 @@ diff --git a/client.js b/client.js + } + setExpanded((value) => !value); }; -- const label = phase === "installing" ? t("Updating DS Bot…") : dshPhase === "installing" ? t("Updating DSH…") : phase === "installed" ? t("DS Bot {version} is installed. Restart DSH to use it.", { version: update.installed ?? update.latest }) : dshPhase === "installed" ? t("Restart DSH to start using DSH {version}.", { version: dsh.installed ?? dsh.verified }) : update?.available ? t("DS Bot {version} is available", { version: update.latest }) : t("DSH {version} is available", { version: dsh?.verified }); -- const message = error || (phase === "failed" ? update.error : ""); -- const dshMessage = dshError || (dshPhase === "failed" ? dsh?.error : ""); -- const dshText = dshPhase === "installed" ? t("Restart DSH to start using DSH {version}.", { version: dsh.installed ?? dsh.verified }) : dsh?.available === false && dsh?.blockedBy ? t("Update DS Bot {version} first.", { version: dsh.blockedBy }) : t("You have {version}.", { version: dsh?.current }); -- return (0, import_react8.createElement)( -- import_react8.Fragment, -- null, -- (0, import_react8.createElement)("button", { -- ref: button, -- type: "button", -- className: "bt-round bt-update", -- "data-phase": cloudPhase, -- "aria-label": label, -- title: label, -- "aria-haspopup": "dialog", -- "aria-expanded": open, -- onClick: toggle -- }, (0, import_react8.createElement)(CloudDownloadIcon, { done: phase === "installed" }), phase === "failed" || dshPhase === "failed" ? (0, import_react8.createElement)("span", { className: "bt-update-dot", "aria-hidden": true }) : null), -- open && box ? (0, import_react_dom3.createPortal)((0, import_react8.createElement)( -- "div", -- { className: "bt-update-pop", role: "dialog", "aria-label": update?.available || phase !== "idle" ? t("DS Bot update") : t("DSH update"), style: box }, -- update?.available || phase !== "idle" ? (0, import_react8.createElement)( -- import_react8.Fragment, -- null, -- (0, import_react8.createElement)("div", { className: "bt-update-title" }, phase === "installed" ? t("Update installed") : t("DS Bot {version}", { version: update.latest })), -- (0, import_react8.createElement)("div", { className: "bt-update-text" }, phase === "installed" ? t("Restart DSH to start using DS Bot {version}.", { version: update.installed ?? update.latest }) : t("You have {version}.", { version: update.current })), -- message ? (0, import_react8.createElement)("div", { className: "bt-update-error", role: "alert" }, t("The update did not finish: {reason}", { reason: message })) : null, -- (0, import_react8.createElement)( -- "div", -- { className: "bt-update-actions" }, -- update.notesUrl ? (0, import_react8.createElement)("a", { className: "bt-update-notes", href: update.notesUrl, target: "_blank", rel: "noopener noreferrer" }, t("What's new")) : null, -- phase === "installed" ? null : (0, import_react8.createElement)("button", { -- type: "button", -- className: "bt-update-go", -- disabled: phase === "installing", -- onClick: start -- }, phase === "installing" ? t("Updating…") : phase === "failed" ? t("Try again") : t("Update")) -- ) -- ) : null, -- dshRow ? (0, import_react8.createElement)( -- "div", -- { className: "bt-update-dsh" }, -- (0, import_react8.createElement)("div", { className: "bt-update-title" }, t("DSH {version}", { version: dsh.verified })), -- (0, import_react8.createElement)("div", { className: "bt-update-text" }, dshText), -- dshMessage ? (0, import_react8.createElement)("div", { className: "bt-update-error", role: "alert" }, t("The update did not finish: {reason}", { reason: dshMessage })) : null, -- (0, import_react8.createElement)( -- "div", -- { className: "bt-update-actions" }, -- dsh.kind === "app" ? dsh.available ? (0, import_react8.createElement)("button", { type: "button", className: "bt-update-go", onClick: () => void actions.openAppUpdates(dsh.verified).catch(() => { -- }) }, t("Open updates")) : null : !dsh.available && dshPhase === "idle" ? null : dshPhase === "installed" ? null : (0, import_react8.createElement)("button", { -- type: "button", -- className: "bt-update-go", -- disabled: dshPhase === "installing", -- onClick: startDsh -- }, dshPhase === "installing" ? t("Updating…") : dshPhase === "failed" ? t("Try again") : t("Update")) -- ) -- ) : null -- ), document.body) : null +- const item = (label, Icon, run) => (0, import_react6.createElement)("button", { key: label, type: "button", role: "menuitem", onClick: () => { +- close(); +- run(); +- } }, (0, import_react6.createElement)(Icon), label); +- return (0, import_react6.createElement)( + const listed = expanded && wide; + return (0, import_react18.createElement)( -+ "div", + "div", +- { ref: tools, className: "bt-tools", "data-open": panel !== null || void 0 }, +- react ? (0, import_react6.createElement)("button", { type: "button", className: "bt-tool", "aria-label": t("Add reaction"), title: t("Add reaction"), "aria-expanded": panel === "react", onClick: (event) => open("react", event) }, (0, import_react6.createElement)(SmileIcon)) : null, +- sessionId ? (0, import_react6.createElement)("button", { type: "button", className: "bt-tool", "aria-label": t("Reply"), title: t("Reply"), onClick: () => actions.quote(sessionId, text) }, (0, import_react6.createElement)(ReplyIcon)) : null, +- (0, import_react6.createElement)("button", { type: "button", className: "bt-tool", "aria-label": t("More message actions"), title: t("More"), "aria-haspopup": "menu", "aria-expanded": panel === "more", onClick: (event) => open("more", event) }, copied ? (0, import_react6.createElement)(CheckIcon) : (0, import_react6.createElement)(DotsIcon)), +- panel === "react" ? (0, import_react_dom.createPortal)((0, import_react6.createElement)( +- "div", +- { ref: pop, className: "bt-react-strip", role: "menu", "aria-label": t("Reactions"), style: place }, +- REACTIONS.map((emoji) => (0, import_react6.createElement)("button", { key: emoji, type: "button", role: "menuitem", "aria-label": emoji, onClick: () => { +- close(); +- react(emoji); +- } }, emoji)) +- ), document.body) : null, +- panel === "more" ? (0, import_react_dom.createPortal)((0, import_react6.createElement)( +- "div", +- { ref: pop, className: "bt-menu", role: "menu", "aria-label": t("Message actions"), style: place }, +- item(t("Copy"), CopyIcon, copy2), +- item(t("Download"), DownloadIcon, download2) +- ), document.body) : null +- ); +-} +-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) + { className: "bt-projects" }, + (0, import_react18.createElement)( + "button", @@ -12982,27 +11777,50 @@ diff --git a/client.js b/client.js + }) ); } --function ConnectPlugins({ wide, actions }) { -- const tipId = (0, import_react8.useId)(); -- const [tip, setTip] = (0, import_react8.useState)(false); -- const timer = (0, import_react8.useRef)(0); -- const button = (0, import_react8.useRef)(null); -- const [box, setBox] = (0, import_react8.useState)(null); -- (0, import_react8.useEffect)(() => () => clearTimeout(timer.current), []); -- if (!wide) return null; -- const show = () => { -- clearTimeout(timer.current); -- timer.current = setTimeout(() => { -- const rect = button.current?.getBoundingClientRect(); -- if (!rect) return; -- const left = Math.max(8, Math.min(rect.left, window.innerWidth - 268)); -- setBox({ left, bottom: window.innerHeight - rect.top + 10 }); -- setTip(true); -- }, 250); -- }; -- const hide = () => { -- clearTimeout(timer.current); -- setTip(false); +-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 }) { +- const roster = useRoster((value) => value); +- const data = node.data; +- const steps = useTurnSteps(String(data.turn)); +- const contexts = useTurnContext(String(data.turn)); +- const mentions = useFileMentions({ node, openFile, fileMentions, useTurnData }); +- const hidden = Boolean(roster.roomsById[sessionId]) || isHiddenTurn(roster, sessionId, data.turn); +- const texts2 = groupPart === "reasoning" ? [] : data.blocks.filter((block) => block.kind === "text" && block.text.trim() !== "").map((block) => block.text); +- const running = data.status === "running"; +- useLiveNote(actions, sessionId, `text:${node.key ?? data.seq}`, !hidden && running && texts2.length > 0 ? { text: true } : null); +- if (hidden) return null; +- const reasoning = groupPart === "response" ? "" : turnReasoning(steps, data); +- const relays = relayPart(data, groupPart) ? relaysBefore(contexts, steps, data) : []; +- if (texts2.length === 0 && !reasoning && relays.length === 0) return null; +- const selfId = roster.byId[sessionId]?.id ?? sessionId; +- return (0, import_react6.createElement)( +- import_react6.Fragment, +- null, +- relays.map((relay) => { +- const source = relay.source; +- const sender = roster.byId[source.senderSessionId] ?? { id: source.senderSessionId, name: source.senderName ?? t("a Bot"), color: "gray" }; +- return (0, import_react6.createElement)(EventLine, { +- key: relay.seq, +- prefix: source.role === "stopped" ? t("Stopped by") : source.role === "reply" ? t("Reply from") : t("Message from"), +- bots: [sender], +- onOpen: () => { +- if (sender.id) actions.openExchange(selfId, sender.id); +- }, +- whole: true +- }); +- }), +- reasoning ? (0, import_react6.createElement)(ThinkingRow, { text: reasoning }) : null, +- texts2.length ? (0, import_react6.createElement)(Bubbles, { texts: texts2, roster, selfId, actions, streaming: running, sessionId, msgKey: `${sessionId}:${data.turn}:${node.key ?? data.seq ?? ""}`, useUi, mentions }) : null +- ); + +// src/client/sidebar.js +var import_react19 = require("react"); @@ -13012,7 +11830,26 @@ diff --git a/client.js b/client.js + (0, import_react19.useEffect)(() => { + if (active && marked) actions.clearUnread(id); + }, [active, marked]); -+} + } +-function EventLine({ prefix, bots, joiner = t("and"), onOpen, whole = false }) { +- if (whole && bots.length === 1) { +- return (0, import_react6.createElement)( +- "div", +- { className: "bt-event" }, +- (0, import_react6.createElement)( +- "button", +- { type: "button", className: "bt-event-whole", title: bots[0].name, onClick: () => onOpen?.(bots[0]) }, +- (0, import_react6.createElement)("span", null, prefix), +- (0, import_react6.createElement)(BotMark, { bot: bots[0], size: 16 }), +- (0, import_react6.createElement)("span", { className: "bt-event-name" }, bots[0].name) +- ) +- ); +- } +- const parts = []; +- parts.push((0, import_react6.createElement)("span", { key: "p" }, prefix)); +- bots.forEach((bot, index) => { +- if (index > 0) parts.push((0, import_react6.createElement)("span", { key: `j${index}` }, joiner)); +- parts.push((0, import_react6.createElement)( +var PinMark = () => (0, import_react19.createElement)("span", { className: "bt-pin", "aria-hidden": true }, (0, import_react19.createElement)(PinMarkIcon)); +var useApproval = (useWork, match) => useWork ? useWork((work) => (work.approvals ?? []).some(match)) : false; +var useGroupWork = (useWork, pick2) => useWork ? useWork((work) => pick2(work.rooms ?? {}).join(",")) : ""; @@ -13044,11 +11881,7 @@ diff --git a/client.js b/client.js + const onContextMenu = (event) => { + event.preventDefault(); + actions.openMenu({ kind: "bot", id, x: event.clientX, y: event.clientY }); - }; -- return (0, import_react8.createElement)( -- "span", -- { className: "bt-connect-wrap" }, -- (0, import_react8.createElement)( ++ }; + const pinned = bot.pinned === true && !tile; + const remote = Boolean(bot.remote); + const shown = remote ? friendBotTitle(bot) : bot.name; @@ -13057,16 +11890,17 @@ diff --git a/client.js b/client.js + if (tile && wide) { + return (0, import_react19.createElement)("div", { className: "bt-tile-wrap" }, (0, import_react19.createElement)( "button", - { -- ref: button, - type: "button", -- className: "bt-connect bt-connect-foot", -- "aria-describedby": tip ? tipId : void 0, -- onMouseEnter: show, -- onMouseLeave: hide, -- onFocus: show, -- onBlur: hide, -- onClick: () => actions.selectPanel("plugins") +- { 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)( ++ { ++ type: "button", + className: "bt-tile", + "aria-label": label, + "aria-current": active ? "page" : void 0, @@ -13074,12 +11908,7 @@ diff --git a/client.js b/client.js + title: preview || bot.name, + onClick: () => actions.openSession(id), + onContextMenu - }, -- (0, import_react8.createElement)(PlugIcon), -- t("Connect plugins"), -- (0, import_react8.createElement)("span", { className: "bt-connect-warn", "aria-hidden": true }, "!") -- ), -- tip && box ? (0, import_react_dom3.createPortal)((0, import_react8.createElement)( ++ }, + (0, import_react19.createElement)(BotAvatar, { bot, size: 60, state, main: true }), + (0, import_react19.createElement)("span", { className: "bt-tile-name" }, shown), + // The Main Bot's tile gets the same unread dot as a row (#420); a wait shows on its avatar. @@ -13089,7 +11918,14 @@ diff --git a/client.js b/client.js + } + const unreadMark = wide && waiting && !active ? (0, import_react19.createElement)("span", { className: "bt-unread bt-unread-wait", "aria-hidden": true }) : wide && unread && !active ? (0, import_react19.createElement)("span", { className: "bt-unread", "aria-hidden": true }) : null; + const row = (0, import_react19.createElement)( -+ "button", + "button", +- { type: "button", className: "bt-lead", "aria-label": t("Open {name}'s chat", { name: bot.name }), onClick: () => actions.openSession(bot.id) }, +- (0, import_react6.createElement)(BotMark, { bot, size: 22 }) +- ); +- return (0, import_react6.createElement)( +- "div", +- { className: "bt-group-msg" }, +- (0, import_react6.createElement)( + { + type: "button", + className: "bt-row", @@ -13105,9 +11941,11 @@ diff --git a/client.js b/client.js + pinned && !wide ? (0, import_react19.createElement)(PinMark) : null, + wide ? (0, import_react19.createElement)( "span", -- { role: "tooltip", id: tipId, className: "bt-connect-tip", style: box }, -- t("Plugin compatibility is not fully verified.") -- ), document.body) : null +- { 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 }) + { className: "bt-row-body" }, + (0, import_react19.createElement)( + "span", @@ -13145,14 +11983,15 @@ diff --git a/client.js b/client.js + reset(); + if (typeof requestAnimationFrame === "function") requestAnimationFrame(() => requestAnimationFrame(reset)); } --function YouAvatar() { -- return (0, import_react8.createElement)( +-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 RemoteMark({ remote }) { + return (0, import_react19.createElement)( - "span", -- { className: "bt-you-wrap" }, -- (0, import_react8.createElement)("span", { className: "bt-you", "aria-hidden": true }, (0, import_react8.createElement)(BotMark, { look: MAIN_LOOK, size: 24 })), -- (0, import_react8.createElement)("span", { style: { position: "absolute", width: 1, height: 1, overflow: "hidden", clip: "rect(0 0 0 0)" } }, t("Settings")) ++ "span", + { className: "bt-row-remote", title: remote.gone ? `${remote.handle} · ${t("no longer shared")}` : remote.handle, "aria-label": t("Friend's Bot") }, + (0, import_react19.createElement)( + "svg", @@ -13162,50 +12001,25 @@ diff --git a/client.js b/client.js + ) ); } --var openShellSettings = null; --var openShellOnboarding = null; --var shellShortcuts = null; --function holdShellShortcuts(shortcuts) { -- shellShortcuts = shortcuts; -- return () => { -- if (shellShortcuts === shortcuts) shellShortcuts = null; -- }; --} --function openHarnessSettings() { -- requestAnimationFrame(() => { -- if (openShellSettings) { -- openShellSettings(); -- return; -- } -- const desktop = document.querySelector('button[aria-haspopup="menu"][data-signed-out]'); -- if (desktop) openThroughMenu(desktop); -- else pressSettingsShortcut(); -- }); --} --function openHarnessOnboarding(id) { -- requestAnimationFrame(() => { -- if (openShellOnboarding) openShellOnboarding(id); -- else openHarnessSettings(); -- }); +-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 isPastFaceRoom(room2) { + if (room2?.origin !== "face-to-face") return false; + const status = room2.face?.status; + if (status === "closed" || status === "left") return true; + const expires = room2.face?.expiresAt; + return status === "candidate" && expires && Number.isFinite(Date.parse(expires)) && Date.parse(expires) <= Date.now(); - } --function openThroughMenu(trigger) { -- const before = new Set(document.querySelectorAll('[role="menu"]')); -- trigger.click(); -- let frames = 0; -- const look = () => { -- const menu = [...document.querySelectorAll('[role="menu"]')].find((node) => !before.has(node)); -- const item = menu?.querySelector('button[role="menuitem"]'); -- if (item) item.click(); -- else if (++frames < 30) requestAnimationFrame(look); -- else pressSettingsShortcut(); -- }; -- requestAnimationFrame(look); ++} +function FaceMark() { + return (0, import_react19.createElement)( + "span", @@ -13217,22 +12031,10 @@ diff --git a/client.js b/client.js + (0, import_react19.createElement)("circle", { cx: 16, cy: 9, r: 2.5 }), + (0, import_react19.createElement)("path", { d: "M4 18a5 5 0 0 1 10 0M14 18a4 4 0 0 1 6 0" }) + ) -+ ); + ); } --function pressSettingsShortcut() { -- const binding = shellShortcuts?.catalog.getSnapshot().find((row) => row.id === "settings.open")?.binding; -- if (!binding) return; -- const held = new Set(binding.modifiers); -- window.dispatchEvent(new KeyboardEvent("keydown", { -- code: binding.code, -- key: binding.code === "Comma" ? "," : "", -- bubbles: true, -- cancelable: true, -- ctrlKey: held.has("control"), -- altKey: held.has("alt"), -- shiftKey: held.has("shift"), -- metaKey: held.has("meta") -- })); +-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 RoomRow({ room: room2, roster, actions, useSessions, useSessionStatus, useSessionRetainInfo, useWork, wide, marked }) { + const id = room2.id; + const fallback = useSessions((list) => nameMentions(previewOf(list, id, [], [], botNames(roster)), roster)); @@ -13282,37 +12084,38 @@ diff --git a/client.js b/client.js + wide && alert && !active ? (0, import_react19.createElement)("span", { className: "bt-unread bt-unread-wait", "aria-hidden": true }) : wide && unread && !active ? (0, import_react19.createElement)("span", { className: "bt-unread", "aria-hidden": true }) : null + ); } --function AccountLauncher({ seat = true, settingsOpen, openSettings, openOnboarding, actions }) { -- const [open, setOpen] = (0, import_react8.useState)(false); -- (0, import_react8.useLayoutEffect)(() => { -- if (!seat) return void 0; -- actions.seatAccount(true); -- return () => actions.seatAccount(false); -- }, [seat]); -- const [box, setBox] = (0, import_react8.useState)(null); -- const button = (0, import_react8.useRef)(null); -- (0, import_react8.useEffect)(() => { -- if (settingsOpen) setOpen(false); -- }, [settingsOpen]); -- (0, import_react8.useEffect)(() => { -- if (!openSettings) return void 0; -- openShellSettings = openSettings; -- return () => { -- if (openShellSettings === openSettings) openShellSettings = null; -- }; -- }, [openSettings]); -- (0, import_react8.useEffect)(() => { -- if (!openOnboarding) return void 0; -- openShellOnboarding = openOnboarding; +-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 })); +- }); +- }; +- (0, import_react6.useEffect)(() => { +- alive.current = true; +- load(void 0); - return () => { -- if (openShellOnboarding === openOnboarding) openShellOnboarding = null; -- }; -- }, [openOnboarding]); -- (0, import_react8.useEffect)(() => { -- 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; -- setOpen(false); +- alive.current = false; +function AgentSessionRow({ session, agent, actions, renaming, index }) { + const input = (0, import_react19.useRef)(null); + (0, import_react19.useLayoutEffect)(() => { @@ -13336,8 +12139,27 @@ diff --git a/client.js b/client.js + } + })(); }; -- const onKey = (event) => { -- if (event.key === "Escape") { +- }, [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); + return (0, import_react19.createElement)( + "div", + { className: "bt-agent-sub-row bt-agent-edit", style: { "--i": index } }, @@ -13353,7 +12175,8 @@ diff --git a/client.js b/client.js + onBlur: () => commit(true) + }) + ); -+ } + } +- return (0, import_react6.createElement)( + return (0, import_react19.createElement)( + "button", + { @@ -13364,11 +12187,9 @@ diff --git a/client.js b/client.js + onClick: () => actions.openAgentSession(session.id), + onContextMenu: (event) => { + event.preventDefault(); - event.stopPropagation(); -- setOpen(false); -- button.current?.focus(); ++ event.stopPropagation(); + actions.openMenu({ kind: "agent-session", id: session.id, agentId: agent.id, x: event.clientX, y: event.clientY }); - } ++ } + }, + (0, import_react19.createElement)("span", { className: "bt-agent-sub-title" }, session.title), + (0, import_react19.createElement)("span", { className: "bt-agent-sub-time" }, session.at > 0 ? relativeShort(session.at) : "") @@ -13384,34 +12205,7 @@ diff --git a/client.js b/client.js + at: row?.updatedAt ?? 0, + running: row?.running === true, + active: (row?.retainedBy?.mainView ?? 0) > 0 - }; -- window.addEventListener("mousedown", onDown); -- window.addEventListener("keydown", onKey, true); -- return () => { -- window.removeEventListener("mousedown", onDown); -- window.removeEventListener("keydown", onKey, true); -- }; -- }, [open]); -- const toggle = () => { -- const rect = button.current?.getBoundingClientRect(); -- if (rect) setBox({ left: rect.left, bottom: window.innerHeight - rect.top + 5 }); -- setOpen((value) => !value); -- }; -- const groups = [ -- [ -- [t("Usage"), GaugeIcon, () => actions.openSettings("usage")], -- [t("Connectors"), ConnectorIcon, () => actions.openConnectors()], -- [t("Automation tasks"), ClockIcon, () => actions.selectPanel("schedules")], -- [t("Bot settings"), GearIcon, () => actions.openSettings("general")] -- ], -- [ -- [t("Switch to classic Agent mode"), SwitchIcon, () => actions.setSurface("agent")] -- ] -- ]; -- return (0, import_react8.createElement)( -- import_react8.Fragment, -- null, -- (0, import_react8.createElement)( ++ }; + }).sort((a, b) => b.at - a.at))); + const statusInfo = useSessionStatus((map) => JSON.stringify(agent.sessions.map((id) => ({ + id, @@ -13434,12 +12228,39 @@ diff --git a/client.js b/client.js + const label = ["DSH Agent", running ? t("Working") : "", unread ? t("Unread activity") : ""].filter(Boolean).join(", "); + const open = active && wide; + return (0, import_react19.createElement)( -+ "div", + "div", +- { className: "bt-earlier", role: "feed", "aria-label": t("Earlier messages"), "aria-busy": view.loading }, +- (0, import_react6.createElement)( +- "div", +- { ref: top, className: "bt-earlier-top" }, +- view.loading ? (0, import_react6.createElement)(Shimmer, null, t("Loading earlier messages…")) : view.failed ? (0, import_react6.createElement)("button", { type: "button", className: "bt-soft", onClick: () => load(view.cursor) }, t("Couldn’t load earlier messages · Retry")) : view.cursor ? (0, import_react6.createElement)("button", { type: "button", className: "bt-soft", onClick: () => load(view.cursor) }, t("Load earlier")) : null +- ), +- rows.map((row, index) => { +- const key = rows.length - index; +- switch (row.kind) { +- case "divider": +- return (0, import_react6.createElement)(PartLine, { key, time: row.time }); +- case "user": +- return (0, import_react6.createElement)("div", { key, className: "bt-urow" }, (0, import_react6.createElement)( +- "div", +- { className: "bt-ububble" }, +- row.text ? (0, import_react6.createElement)(import_dsh_client_ui_primitives.MarkdownText, { text: row.text, labels: mdLabels(), variant: "compact" }) : null, +- row.images ? (0, import_react6.createElement)("span", { className: "bt-uimages" }, row.images === 1 ? t("1 image") : t("{count} images", { count: row.images })) : null +- )); +- case "answer": +- return (0, import_react6.createElement)(AnsweredCell, { key, node: { data: row } }); +- case "bot": +- return (0, import_react6.createElement)(Bubbles, { key, texts: row.texts, roster, selfId, actions }); +- case "event": { +- if (row.role === "report") return (0, import_react6.createElement)(GroupEvent, { key, prefix: t("Replies from"), room: roster.roomsById[row.roomId], name: t("a group chat"), roster, actions }); +- const other = roster.byId[row.botId] ?? { id: row.botId, name: row.name ?? t("a Bot"), color: "gray" }; +- const prefix = { stopped: t("Stopped by"), reply: t("Reply from") }[row.role] ?? t("Message from"); +- return (0, import_react6.createElement)(EventLine, { key, prefix, bots: [other], onOpen: () => { +- if (selfId && other.id) actions.openExchange(selfId, other.id); +- }, whole: true }); + { className: "bt-agent" }, + (0, import_react19.createElement)( - "button", -- { ref: button, type: "button", className: "bt-account", "aria-label": t("Account"), "aria-haspopup": "menu", "aria-expanded": open, onClick: toggle }, -- (0, import_react8.createElement)("span", { className: "bt-you", "aria-hidden": true }, (0, import_react8.createElement)(BotMark, { look: MAIN_LOOK, size: 24 })) ++ "button", + { + type: "button", + className: "bt-row", @@ -13451,7 +12272,12 @@ diff --git a/client.js b/client.js + onContextMenu: (event) => { + event.preventDefault(); + actions.openMenu({ kind: "agent", id: agent.id, x: event.clientX, y: event.clientY }); -+ } + } +- default: +- return null; +- } +- }), +- (0, import_react6.createElement)(PartLine, { time: view.startedAt }) + }, + (0, import_react19.createElement)("span", { className: `bt-agent-ava${running ? " bt-run" : ""}` }, (0, import_react19.createElement)(import_dsh_client_ui_primitives2.FishLogo, { size: 22 })), + wide ? (0, import_react19.createElement)( @@ -13461,26 +12287,9 @@ diff --git a/client.js b/client.js + preview ? (0, import_react19.createElement)("span", { className: "bt-row-preview" }, preview) : null + ) : null, + wide && unread && !active ? (0, import_react19.createElement)("span", { className: "bt-unread", "aria-hidden": true }) : null - ), -- open && box ? (0, import_react_dom3.createPortal)((0, import_react8.createElement)( ++ ), + (0, import_react19.createElement)( - "div", -- { className: "bt-menu bt-menu-up bt-account-menu", role: "menu", "aria-label": t("Account"), style: box }, -- groups.map((group, index) => (0, import_react8.createElement)( -- import_react8.Fragment, -- { key: index }, -- index > 0 ? (0, import_react8.createElement)("div", { className: "bt-menu-sep", role: "separator" }) : null, -- group.map(([label, Icon, run, hint]) => (0, import_react8.createElement)("button", { -- key: label, -- type: "button", -- role: "menuitem", -- onClick: () => { -- setOpen(false); -- void Promise.resolve(run()).catch((error) => actions.reportError(error, "menu")); -- } -- }, (0, import_react8.createElement)(Icon), (0, import_react8.createElement)("span", { className: "bt-menu-label" }, label), hint ? (0, import_react8.createElement)("span", { className: "bt-menu-hint" }, hint) : null)) -- )) -- ), document.body) : null ++ "div", + { className: "bt-agent-sub", "data-open": open || void 0 }, + (0, import_react19.createElement)( + "div", @@ -13505,174 +12314,171 @@ diff --git a/client.js b/client.js + ) ); } --function useLandOnMainBot(roster, useSessions, actions) { -- const mainView = useSessions((list) => { -- const row = Object.values(list.byId).find((session) => (session?.retainedBy?.mainView ?? 0) > 0); -- return row ? `${row.id}|${row.blank ? 1 : 0}` : ""; +-function TriggerCell({ node, sessionId, useRoster, useSessions, useTurnClock, useUi, actions }) { +- const roster = useRoster((value) => value); +- const data = node.data; +- const source = data.source !== null && typeof data.source === "object" ? data.source : {}; +- const selfId = roster.byId[sessionId]?.id ?? sessionId; +- const deferred = source.kind === "bot" && source.deferred === true; +- const relayed = isRelayContext(data); +- const turn = node.location?.turn?.turn ?? data.turn; +- (0, import_react6.useLayoutEffect)(() => { +- if (relayed) actions.noteTurnPeer(sessionId, turn, source.senderSessionId); +- }, [relayed, sessionId, turn, source.senderSessionId]); +- const order = useSessions((list) => { +- const outline = list.projectionsBySession?.[sessionId]?.values?.turnOutline ?? list.byId[sessionId]?.projectionValues?.turnOutline; +- return Array.isArray(outline) ? outline.map((entry) => entry.turn).join(",") : ""; - }); -- const lastBot = (0, import_react8.useRef)(null); -- (0, import_react8.useEffect)(() => { -- if (!roster.ready || !roster.mainBotId) return void 0; -- const [id, blank3] = mainView.split("|"); -- const bot = id === "" ? void 0 : roster.byId[id]; -- if (bot !== void 0) { -- lastBot.current = { botId: bot.id, sessionId: id }; -- if (currentPart(bot) !== id) actions.landOn(bot.id); -- return void 0; -- } -- if (id !== "" && (roster.roomsById[id] || (actions.isAgentSession?.(id) ?? roster.agentOf[id] !== void 0) || blank3 !== "1")) { -- lastBot.current = null; -- return void 0; -+var GLIDE = springEasing(1e3, 63); -+function useListGlide(listRef, layout, key) { -+ const tops = (0, import_react19.useRef)({ layout, map: /* @__PURE__ */ new Map() }); -+ (0, import_react19.useLayoutEffect)(() => { -+ const list = listRef.current; -+ if (!list) return; -+ const next = /* @__PURE__ */ new Map(); -+ const still = reducedMotion() || tops.current.layout !== layout; -+ for (const row of list.querySelectorAll("[data-bot-id],[data-room-id],[data-agent-id]")) { -+ const id = row.dataset.botId ?? row.dataset.roomId ?? row.dataset.agentId; -+ const top = row.offsetTop; -+ next.set(id, top); -+ const before = tops.current.map.get(id); +- 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); ++var GLIDE = springEasing(1e3, 63); ++function useListGlide(listRef, layout, key) { ++ const tops = (0, import_react19.useRef)({ layout, map: /* @__PURE__ */ new Map() }); ++ (0, import_react19.useLayoutEffect)(() => { ++ const list = listRef.current; ++ if (!list) return; ++ const next = /* @__PURE__ */ new Map(); ++ const still = reducedMotion() || tops.current.layout !== layout; ++ for (const row of list.querySelectorAll("[data-bot-id],[data-room-id],[data-agent-id]")) { ++ const id = row.dataset.botId ?? row.dataset.roomId ?? row.dataset.agentId; ++ const top = row.offsetTop; ++ next.set(id, top); ++ const before = tops.current.map.get(id); + if (!still && before !== void 0 && Math.abs(before - top) > 0.5) { + row.animate([{ transform: `translateY(${before - top}px)` }, { transform: "none" }], GLIDE); -+ } + } +- return (0, import_react6.createElement)(EventLine, { +- prefix: source.role === "stopped" ? t("Stopped by") : count > 1 ? t("{count} messages with", { count }) : t("Message from"), +- bots, +- onOpen: (bot) => actions.openExchange(selfId, bot.id), +- whole: true +- }); +- } +- if (source.kind === "schedule") { +- const prompts = reminderPrompts(contentText(data.content)); +- return (0, import_react6.createElement)("div", { className: "bt-event" }, prompts.length > 0 ? t("Scheduled task: {text}", { text: prompts.join(" · ").slice(0, 80) }) : t("Routine started")); } -- const last = lastBot.current; -- const timer = setTimeout(() => actions.landOn(roster.mainBotId, last), last ? 0 : 400); -- return () => clearTimeout(timer); -- }, [mainView, roster]); --} --function FooterAccount(props) { -- const seated = props.useUi((value) => value.accountSeated === true); -- if (seated) return null; -- return (0, import_react8.createElement)("span", { className: "bt-foot-account" }, (0, import_react8.createElement)(AccountLauncher, { ...props, seat: false })); +- if (source.kind === "user-question-reply") return null; +- return (0, import_react6.createElement)("div", { className: "bt-event" }, contentText(data.content).slice(0, 80) || t("Update")); +- } -} --function BotReturn({ wide, useSurface, actions }) { -- const mode = useSurface((value) => value.mode); -- if (mode !== "agent") return null; -- return (0, import_react8.createElement)( -- "button", -- { type: "button", className: "bt-return", "aria-label": t("DS Bot"), title: t("DS Bot"), onClick: () => actions.setSurface("bot") }, -- (0, import_react8.createElement)(BotMark, { look: MAIN_LOOK, size: 20 }), -- wide ? (0, import_react8.createElement)("span", { className: "bt-return-name" }, t("DS Bot")) : null -- ); +-function NewDivider() { +- return (0, import_react6.createElement)("div", { className: "bt-new-sep", role: "separator", "aria-label": t("New messages") }, (0, import_react6.createElement)("span", null, t("New"))); -} --var RETURN_CSS = ` --.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)} --.bt-return .bt-mark{display:inline-flex;flex:none;line-height:0} --.bt-return .bt-mark svg{width:100%;height:100%;overflow:visible} --/* Shell styles carry --bt-ink-deepseek and Agent mode withdraws them; pin it here. */ --.bt-return .bt-mark{--bt-ink-deepseek:#4D6BFE} --.bt-return-name{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} --`; --function BrandMark({ size = 24 }) { -- return (0, import_react8.createElement)("span", { className: "bt-brand-whale" }, (0, import_react8.createElement)(BotMark, { look: MAIN_LOOK, size })); +-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 BrandName() { -- return (0, import_react8.createElement)("span", { style: { fontWeight: 600, fontSize: 14 } }, "DS Bot"); +-function Nothing() { +- return null; -} --var SIDEBAR_CSS = ` --.bt-side-top .bt-brand{cursor:default;padding-left:6px} --.bt-side-top .bt-brand:hover{background:none} --.bt-brand .bt-mark{flex:none} --.bt-pin{flex:none;display:inline-flex;color:var(--bt-ink-3)} --.bt-rail .bt-row{position:relative} --.bt-rail .bt-pin{position:absolute;top:3px;left:calc(50% + 12px)} --.bt-side .bt-read-only{margin:4px 8px 6px;padding:8px 10px;border-radius:10px;background:var(--bt-hover);font-size:12px;line-height:1.45;text-align:left} --[class*="_footArea"]:has(.bt-foot-account){display:flex!important;flex-direction:row!important;align-items:center;gap:6px} --[class*="_footArea"]:has(.bt-foot-account)>[class*="_footerActions"],[class*="_footArea"]:has(.bt-foot-account)>[class*="_footerActions"]>div{display:contents!important} --.bt-foot-account{order:-1;flex:none;display:inline-flex} --[class*="_footArea"]:has(.bt-foot-account) .bt-connect-foot{order:0;flex:1 1 auto;min-width:0;width:auto} --[class*="_footArea"]:has(.bt-foot-account)>[class*="_settingsArea"]{order:1;flex:none} --[class*="_footArea"]:has(.bt-foot-account)>[class*="_settingsArea"] button[class*="_trigger"] [class*="_label"]{display:none!important} -- --/* The update cloud springs in when a newer DS Bot shows up; its arrow drops in a -- loop while the install runs. */ --.bt-round.bt-update{position:relative;color:var(--bt-accent,#4D6BFE);animation:bt-update-in ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing}} --.bt-round.bt-update svg{width:17px;height:17px} --.bt-round.bt-update[data-phase=installed]{color:var(--bt-ink-2)} --@keyframes bt-update-in{from{opacity:0;transform:scale(.6);filter:blur(4px)}} --.bt-update[data-phase=installing] .bt-cloud-arrow{animation:bt-cloud-drop 1.1s cubic-bezier(.45,0,.55,1) infinite} --@keyframes bt-cloud-drop{0%{transform:translateY(-3px);opacity:0}35%{opacity:1}70%{transform:translateY(1px);opacity:1}100%{transform:translateY(2px);opacity:0}} --.bt-update-dot{position:absolute;top:6px;right:6px;width:7px;height:7px;border-radius:50%;background:#E5484D;box-shadow:0 0 0 1.5px var(--bt-sidebar)} --.bt-update-pop{position:fixed;z-index:60;width:280px;padding:14px 14px 12px;border-radius:14px;background:var(--bt-main);border:.5px solid var(--bt-line-2);box-shadow:0 10px 20px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1);color:var(--bt-ink);box-sizing:border-box;transform-origin:top left;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;animation:bt-update-pop ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing}} --@keyframes bt-update-pop{from{opacity:0;transform:scale(.96);filter:blur(4px)}} --.bt-update-title{font-size:14px;line-height:20px;font-weight:600} --.bt-update-text{margin-top:2px;font-size:12.5px;line-height:18px;color:var(--bt-ink-2)} --.bt-update-error{margin-top:8px;font-size:12px;line-height:17px;color:#c21d2e;overflow-wrap:anywhere;max-height:90px;overflow:auto} --.bt-update-actions{display:flex;align-items:center;justify-content:flex-end;gap:10px;margin-top:12px} --.bt-update-notes{margin-right:auto;font-size:12.5px;color:var(--bt-ink-2);text-decoration:none} --.bt-update-notes:hover{color:var(--bt-ink);text-decoration:underline} --.bt-update-go{height:30px;padding:0 14px;border:0;border-radius:999px;background:var(--bt-accent,#4D6BFE);color:var(--bt-accent-ink,#fff);font:inherit;font-size:13px;font-weight:500;cursor:pointer;transition:opacity .12s ease,transform .12s ease} --.bt-update-go:hover:not(:disabled){opacity:.9} --.bt-update-go:active:not(:disabled){transform:scale(.96)} --.bt-update-go:disabled{opacity:.6;cursor:default} --.bt-update-dsh:not(:first-child){margin-top:12px;padding-top:12px;border-top:.5px solid var(--bt-line-2)} --@media (prefers-reduced-motion:reduce){.bt-round.bt-update,.bt-update-pop,.bt-update .bt-cloud-arrow{animation:none}} -- --/* The DSH Agent row and its expanding Session list. The black whale is the -- harness's own mark, so the row reads as "plain dsh" next to the Bot marks. */ --.bt-agent-ava{flex:none;width:36px;height:36px;border-radius:50%;display:inline-flex;align-items:center;justify-content:center;background:var(--bt-hover);color:var(--bt-ink)} --.bt-agent-ava.bt-run{animation:bt-agent-pulse 1.4s ease-in-out infinite} --@keyframes bt-agent-pulse{0%,100%{opacity:1}50%{opacity:.55}} --.bt-agent-sub{display:grid;grid-template-rows:0fr;transition:grid-template-rows ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing}} --.bt-agent-sub[data-open]{grid-template-rows:1fr} --.bt-agent-sub-in{overflow:hidden;min-height:0;margin-left:44px} --.bt-agent-sub-row{display:flex;align-items:center;gap:8px;width:100%;padding:5px 10px 5px 8px;border:0;border-radius:8px;background:none;color:var(--bt-ink-2);font:inherit;font-size:13px;line-height:20px;text-align:left;cursor:pointer} --.bt-agent-sub-row:hover{background:var(--bt-hover);color:var(--bt-ink)} --.bt-agent-sub-row[aria-current=page]{background:var(--bt-hover);color:var(--bt-ink);font-weight:500} --.bt-agent-sub-title{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} --.bt-agent-sub-time{flex:none;font-size:11px;color:var(--bt-ink-3)} --.bt-agent-new{color:var(--bt-ink-3)} --.bt-agent-new svg{width:14px;height:14px;flex:none} --.bt-agent-edit{padding:3px 10px 3px 4px} --.bt-agent-edit input{width:100%;border:0;border-radius:6px;background:var(--bt-hover);color:var(--bt-ink);font:inherit;font-size:13px;line-height:20px;padding:2px 8px;outline:none;box-shadow:inset 0 0 0 .5px var(--bt-line-2)} --.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}} --`; -- --// src/client/header.js --var import_react9 = require("react"); --var import_react_dom4 = require("react-dom"); --var GoArrow = () => (0, import_react9.createElement)("svg", { width: 14, height: 14, viewBox: "-12 -12 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2.2, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, (0, import_react9.createElement)("path", { d: "M-7 0H7M2 -5L7 0L2 5" })); --var MEMORY_NOTICE_MS = 3200; --var noNews = (select) => select({}); --function useMemoryNotice(ids, useMemoryNews) { -- const seen = useMemoryNews((map) => Math.max(0, ...ids.map((id) => map?.[id]?.seen ?? 0))); -- const [shown, setShown] = (0, import_react9.useState)(false); -- (0, import_react9.useEffect)(() => { -- const left = seen + MEMORY_NOTICE_MS - Date.now(); -- setShown(seen > 0 && left > 0); -- if (seen === 0 || left <= 0) return void 0; -- const timer = setTimeout(() => setShown(false), left); -- return () => clearTimeout(timer); -- }, [seen]); -- return shown; +-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}`; + tops.current = { layout, map: next }; + }, [layout, key]); } --function MemoryNotice({ shown }) { -- const inner = (0, import_react9.useRef)(null); -- const [width, setWidth] = (0, import_react9.useState)(0); -- const label = t("Memory updated"); -- (0, import_react9.useLayoutEffect)(() => { -- if (shown) setWidth(inner.current?.scrollWidth ?? 0); -- }, [label, shown]); -- return (0, import_react9.createElement)( -- "span", -- { className: "bt-pill-mem", "aria-hidden": true, style: { "--bt-mem-w": `${width}px` } }, -- (0, import_react9.createElement)("span", { className: "bt-pill-mem-in", ref: inner }, (0, import_react9.createElement)("span", { className: "bt-pill-mem-ico" }, (0, import_react9.createElement)(MemoryIcon)), label) +-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; +var NodeActions = class extends import_react19.Component { + constructor(props) { + super(props); + this.state = { failed: false }; -+ } + } +- 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; +-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; +- }); +- if (!label && !fresh) return null; +- return (0, import_react6.createElement)( +- import_react6.Fragment, + static getDerivedStateFromError() { + return { failed: true }; + } @@ -13688,7 +12494,9 @@ diff --git a/client.js b/client.js + const closed = collapsed[bot.id] === true; + return (0, import_react19.createElement)( + import_react19.Fragment, -+ null, + null, +- label ? (0, import_react6.createElement)("div", { className: "bt-time-sep", role: "separator" }, label) : null, +- fresh ? (0, import_react6.createElement)(NewDivider) : null + (0, import_react19.createElement)( + "div", + { className: "bt-tree-item", "data-depth": depth, style: { paddingLeft: `${depth * 16}px` } }, @@ -13703,60 +12511,59 @@ diff --git a/client.js b/client.js + closed ? null : children.map((child) => (0, import_react19.createElement)(TreeNode, { key: child.bot.id, node: child, rowProps, markedUnread, collapsed, toggle, actions })) ); } --function HeaderPill({ sessionId, useRoster, useSessions, useSessionStatus, useSession, useActivity, useMemoryNews, useTurnClock, useUi, useLive, actions }) { -+function TeamSidebar(props) { -+ const { wide, useRoster, useUi, useSessions, actions } = props; -+ const listRef = (0, import_react19.useRef)(null); - const roster = useRoster((value) => value); -- const title = useSessions((list) => list.byId[sessionId]?.displayTitle ?? list.byId[sessionId]?.title ?? ""); -- const firstTurns = useSessions((list) => { -- const outline = list.projectionsBySession?.[sessionId]?.values?.turnOutline ?? list.byId[sessionId]?.projectionValues?.turnOutline; -- return Array.isArray(outline) ? outline.slice(0, 8).map((entry) => entry.turn).join(",") : ""; -- }); -- const hiddenTurns = roster.exchangeTurns?.[sessionId]; -- const visit = useUi((state) => state.visits?.[sessionId]); -- const opening = useTurnClock((state) => { -- const first = openingTurn(state[sessionId], firstTurns, hiddenTurns); -- return first && !unseenSince(visit, first.start) ? clockLabel(first.start) : ""; -+ const markedUnread = useUi((value) => value.unread); +-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" }; ++function TeamSidebar(props) { ++ const { wide, useRoster, useUi, useSessions, actions } = props; ++ const listRef = (0, import_react19.useRef)(null); ++ const roster = useRoster((value) => value); ++ const markedUnread = useUi((value) => value.unread); + props.useRegistry((value) => value); + const updated = useSessions((list) => { + const stamps = {}; + for (const id of list.ids) stamps[id] = list.byId[id]?.updatedAt ?? 0; + return JSON.stringify(stamps); - }); -- (0, import_react9.useEffect)(() => { -- 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"); -- }, [opening]); -- const running = useSessionStatus((map) => map.get(sessionId)?.running === true); -- const owner = roster.byId[sessionId]?.id ?? sessionId; -- const doing = useActivity((map) => map[owner]); -- const bot = roster.byId[sessionId]; -- const room2 = roster.roomsById[sessionId]; -- const detailsOpen = useUi((state) => state.details === sessionId); -- const name = bot?.name ?? room2?.name ?? ""; -- const useNews = useMemoryNews ?? noNews; -- const memoryIds = bot ? [bot.id] : room2?.members ?? []; -- const notice = useMemoryNotice(memoryIds, useNews); -- const changed = useNews((map) => latestMemoryChange(map, memoryIds)); -- (0, import_react9.useEffect)(() => { -- document.body.style.setProperty("--bt-placeholder", JSON.stringify(name ? t("Message {name}", { name }) : t("Message"))); -- }, [name, t("Message")]); -- (0, import_react9.useEffect)(() => { -- actions.beginVisit(sessionId); -- return () => actions.endVisit(sessionId); -- }, [sessionId]); -- (0, import_react9.useEffect)(() => { -- if (room2) document.body.dataset.btRoom = ""; -- else delete document.body.dataset.btRoom; -- return () => { -- delete document.body.dataset.btRoom; -- }; -- }, [Boolean(room2)]); -- const memoryNotice = notice && changed !== null && roster.memory !== false; -- return (0, import_react9.createElement)( ++ }); + const order = (0, import_react19.useMemo)(() => { + const stamps = JSON.parse(updated); + const entries2 = [ @@ -13777,7 +12584,36 @@ diff --git a/client.js b/client.js + className: "bt-read-only-reload", + onClick: () => { + void Promise.resolve(actions.reloadTeam?.()).catch((error) => actions.reportError(error, "reload")); -+ } + } +- 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 workPhrase(tool) { +- if (!tool) return t("Thinking about it"); +- return toolActivity(tool, () => void 0, () => void 0)?.label ?? t("On it"); +-} +-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]); + }, t("Reload")); + const structure = useSidebarStructure(); + const [collapsed, setCollapsed] = (0, import_react19.useState)({}); @@ -13795,22 +12631,7 @@ diff --git a/client.js b/client.js + actions.openPalette(event.key); + }; + return (0, import_react19.createElement)( - "div", -- { className: "bt-head" }, -- (0, import_react9.createElement)( -- "button", -- { -- type: "button", -- className: "bt-pill", -- "aria-expanded": detailsOpen, -- "data-memory": memoryNotice ? "updated" : void 0, -- "aria-label": memoryNotice ? t("Memory updated. Open {name}'s memory", { name: roster.byId[changed]?.name ?? name }) : t("View conversation details"), -- onClick: () => memoryNotice ? actions.openMemory(changed) : actions.toggleDetails(sessionId) -- }, -- bot ? (0, import_react9.createElement)(BotAvatar, { bot, size: 24, state: botState(running, doing, roster.questions?.[currentPart(bot)] !== void 0), badge: false }) : room2 ? (0, import_react9.createElement)(RoomAvatar, { room: room2, roster, size: 24 }) : null, -- (0, import_react9.createElement)("span", { className: "bt-pill-name" }, bot?.name ?? room2?.name ?? (title || t("New chat"))), -- (0, import_react9.createElement)(MemoryNotice, { shown: memoryNotice }), -- (0, import_react9.createElement)("span", { className: "bt-pill-go", "aria-hidden": true }, (0, import_react9.createElement)(GoArrow)) ++ "div", + { className: wide ? "bt-side" : "bt-side bt-rail", onKeyDown: typeToSearch }, + wide ? (0, import_react19.createElement)( + "div", @@ -13824,10 +12645,7 @@ diff --git a/client.js b/client.js + null, + (0, import_react19.createElement)(UpdateButton, { useUpdate: props.useUpdate, actions }), + (0, import_react19.createElement)("button", { type: "button", className: "bt-round", "aria-label": t("New…"), title: t("New…"), onClick: () => actions.openNewChat("new") }, (0, import_react19.createElement)(PlusIcon)) - ), -- (0, import_react9.createElement)("span", { className: "bt-sr", role: "status" }, memoryNotice ? t("Memory updated") : ""), -- (0, import_react9.createElement)(NewMessagePills, { key: sessionId, sessionId, useSessions, useUi, useTurnClock }), -- (0, import_react9.createElement)(ActivityIndicator, { key: `activity:${sessionId}`, sessionId, useRoster, useSessionStatus, useSession, useLive, actions }) ++ ), + wide && readOnlySaid ? (0, import_react19.createElement)("div", { className: "bt-hint bt-read-only", role: "status" }, hostText(roster.readOnly)) : null, + wide && readOnlySaid ? readOnlyReload : null, + !wide && readOnlySaid ? (0, import_react19.createElement)("div", { className: "bt-hint bt-read-only", role: "status", title: hostText(roster.readOnly), "aria-label": hostText(roster.readOnly) }, hostText(roster.readOnly)) : null, @@ -13842,43 +12660,23 @@ diff --git a/client.js b/client.js + order.filter((entry) => !(tree && inTree(entry)) && !(inTree(entry) && structure?.include?.(entry.item.id) === false)).map((entry) => entry.kind === "bot" ? (0, import_react19.createElement)(BotRow, { key: entry.item.id, ...rowProps, bot: entry.item, marked: markedUnread[entry.item.id] === true }) : entry.kind === "room" ? (0, import_react19.createElement)(RoomRow, { key: entry.item.id, ...rowProps, room: entry.item, marked: markedUnread[entry.item.id] === true }) : (0, import_react19.createElement)(AgentRow, { key: entry.item.id, ...rowProps, agent: entry.item, marked: markedUnread[entry.item.id] === true })), + wide ? (0, import_react19.createElement)(FlyBotSections, { roster, actions, renderBot: (bot) => (0, import_react19.createElement)(BotRow, { key: bot.id, ...rowProps, bot, asRow: true, marked: markedUnread[bot.id] === true }) }) : null + ) - ); ++ ); } --function useTranscriptView() { -- const [view, setView] = (0, import_react9.useState)(null); -- (0, import_react9.useEffect)(() => { -- let frame = 0; -- let watched = null; -- const observer = new MutationObserver(() => schedule()); -- const measure = () => { -- frame = 0; -- const scroller = document.querySelector("[data-conversation-scroll]"); -- if (scroller !== watched) { -- observer.disconnect(); -- watched = scroller; -- if (scroller) observer.observe(scroller, { childList: true, subtree: true }); -- } -- if (!scroller) { -- setView(null); -- return; -- } -- const box = scroller.getBoundingClientRect(); -- const headerBottom = document.querySelector('header[class*="_header"]')?.getBoundingClientRect().bottom ?? box.top + 50; -- const sep = scroller.querySelector(".bt-new-sep"); -- let divider = null; -- if (sep) { -- const at = sep.getBoundingClientRect(); -- divider = at.bottom <= headerBottom ? "above" : at.top >= box.bottom ? "below" : "in-view"; -- } -- const composer = parseFloat(getComputedStyle(scroller).getPropertyValue("--dsh-composer-height")) || 152; -- const next = { -- unpinned: scroller.querySelector('[class*="_toBottomSlot"]>button') !== null, -- divider, -- center: Math.round(box.left + box.width / 2), -- top: Math.round(headerBottom + 8), -- bottom: Math.round(window.innerHeight - box.bottom + composer + 8) -- }; -- setView((prev) => prev && Object.keys(next).every((key) => prev[key] === next[key]) ? prev : next); +-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 }); +function UpdateButton({ useUpdate, actions }) { + const update = useUpdate((value) => value); + const [open, setOpen] = (0, import_react19.useState)(false); @@ -13891,31 +12689,74 @@ diff --git a/client.js b/client.js + const onDown = (event) => { + if (event.target instanceof Element && (event.target.closest(".bt-update-pop") || button.current?.contains(event.target))) return; + setOpen(false); - }; -- const schedule = () => { -- if (!frame) frame = requestAnimationFrame(measure); ++ }; + const onKey = (event) => { + if (event.key === "Escape") { + event.stopPropagation(); + setOpen(false); + button.current?.focus(); -+ } + } +- timer = setTimeout(tick, document.hidden ? 5e3 : 1e3); }; -- document.addEventListener("scroll", schedule, true); -- window.addEventListener("resize", schedule); -- schedule(); +- void tick(); + window.addEventListener("mousedown", onDown); + window.addEventListener("keydown", onKey, true); return () => { -- cancelAnimationFrame(frame); -- observer.disconnect(); -- document.removeEventListener("scroll", schedule, true); -- window.removeEventListener("resize", schedule); +- stop = true; +- clearTimeout(timer); + window.removeEventListener("mousedown", onDown); + window.removeEventListener("keydown", onKey, true); }; -- }, []); -- return view; +- }, [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)( + }, [open]); + const phase = update?.phase ?? "idle"; + const dsh = update?.dsh; @@ -13964,7 +12805,25 @@ diff --git a/client.js b/client.js + (0, import_react19.createElement)("div", { className: "bt-update-text" }, phase === "installed" ? t("Restart DSH to start using FlyBot core {version}.", { version: update.installed ?? update.latest }) : t("You have {version}.", { version: update.current })), + message ? (0, import_react19.createElement)("div", { className: "bt-update-error", role: "alert" }, t("The update did not finish: {reason}", { reason: message })) : null, + (0, import_react19.createElement)( -+ "div", + "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)( + { className: "bt-update-actions" }, + update.notesUrl ? (0, import_react19.createElement)("a", { className: "bt-update-notes", href: update.notesUrl, target: "_blank", rel: "noopener noreferrer" }, t("What's new")) : null, + phase === "installed" ? null : (0, import_react19.createElement)("button", { @@ -13976,7 +12835,12 @@ diff --git a/client.js b/client.js + ) + ) : null, + dshRow ? (0, import_react19.createElement)( -+ "div", + "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 })) +- ); +- }) + { className: "bt-update-dsh" }, + (0, import_react19.createElement)("div", { className: "bt-update-title" }, t("DSH {version}", { version: dsh.verified })), + (0, import_react19.createElement)("div", { className: "bt-update-text" }, dshText), @@ -13994,33 +12858,156 @@ diff --git a/client.js b/client.js + ) + ) : null + ), document.body) : null -+ ); + ); } --function NewMessagePills({ sessionId, useSessions, useUi, useTurnClock }) { -- 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; -- }); -- const visit = useUi((state) => state.visits?.[sessionId]); -- const unread = useTurnClock((state) => { -- if (typeof visit?.seen !== "number") return 0; -- let count = 0; -- for (const entry of Object.values(state[sessionId] ?? {})) { -- if (entry.first !== void 0 && entry.first > visit.seen && entry.first <= visit.entered) count += 1; -+function ConnectApps({ wide, actions }) { -+ if (!wide) return null; -+ return (0, import_react19.createElement)( -+ "span", -+ { className: "bt-connect-wrap" }, -+ (0, import_react19.createElement)( -+ "button", -+ { type: "button", className: "bt-connect bt-connect-foot", onClick: () => actions.openConnectors() }, -+ (0, import_react19.createElement)(PlugIcon), -+ t("Connect apps") -+ ) -+ ); -+} -+function YouAvatar() { +-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 useShownCards(actions, sessionId, callIds) { +- const key = JSON.stringify(callIds); +- (0, import_react6.useLayoutEffect)(() => { +- const ids = JSON.parse(key); +- if (ids.length === 0 || !actions.noteCards) return void 0; +- actions.noteCards(sessionId, ids, 1); +- return () => actions.noteCards(sessionId, ids, -1); +- }, [sessionId, key]); +-} +-function 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 ToolCell({ node, sessionId, useRoster, useUi, 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`; ++function ConnectApps({ wide, actions }) { ++ if (!wide) return null; ++ return (0, import_react19.createElement)( ++ "span", ++ { className: "bt-connect-wrap" }, ++ (0, import_react19.createElement)( ++ "button", ++ { type: "button", className: "bt-connect bt-connect-foot", onClick: () => actions.openConnectors() }, ++ (0, import_react19.createElement)(PlugIcon), ++ t("Connect apps") ++ ) ++ ); + } +-function useRunningHost(active) { +- const [host, setHost] = (0, import_react7.useState)(null); +- (0, import_react7.useEffect)(() => { +- if (!active) { +- setHost(null); +- return void 0; ++function YouAvatar() { + return (0, import_react19.createElement)( + "span", + { className: "bt-you-wrap" }, @@ -14043,47 +13030,73 @@ diff --git a/client.js b/client.js + openShellSettings(); + return; } -- return count; +- 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(); +- }; +- }, [active]); +- return host; + const desktop = document.querySelector('button[aria-haspopup="menu"][data-signed-out]'); + if (desktop) openThroughMenu(desktop); + else pressSettingsShortcut(); - }); -- const view = useTranscriptView(); -- const unpinned = view?.unpinned === true; -- const [release, setRelease] = (0, import_react9.useState)({ base: null, dismissed: false }); -- const [above, setAbove] = (0, import_react9.useState)({ seen: false, dismissed: false }); -- (0, import_react9.useEffect)(() => { -- setRelease({ base: unpinned ? replies : null, dismissed: false }); -- }, [unpinned]); -- (0, import_react9.useEffect)(() => { -- if (view?.divider === "in-view") setAbove((state) => state.seen ? state : { ...state, seen: true }); -- }, [view?.divider]); -- const fresh = release.base === null ? 0 : replies - release.base; -- const showDown = unpinned && fresh > 0 && !release.dismissed; -- const showUp = view?.divider === "above" && !above.seen && !above.dismissed; -- (0, import_react9.useEffect)(() => { -- const scroller = document.querySelector("[data-conversation-scroll]"); -- if (!scroller) return void 0; -- scroller.toggleAttribute("data-bt-news", showDown); -- return () => scroller.removeAttribute("data-bt-news"); -- }, [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"); -- const pill = (direction, count, style, onJump, onDismiss) => (0, import_react9.createElement)( -- "div", -- { className: "bt-news", "data-direction": direction, style }, -- (0, import_react9.createElement)( -- "button", -- { type: "button", className: "bt-news-jump", onClick: onJump }, -- (0, import_react9.createElement)("span", { className: "bt-news-ico" }, (0, import_react9.createElement)(direction === "up" ? ArrowUpIcon : ArrowDownIcon)), -- label(count) -- ), -- (0, import_react9.createElement)("button", { -- type: "button", -- className: "bt-news-x", -- "aria-label": t("Dismiss new messages"), -- onClick: (event) => { -+} ++ }); + } +-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); +- }); +- scan(); +- return () => { +- cancelAnimationFrame(frame); +- observer.disconnect(); +- }; +function openHarnessOnboarding(id) { + requestAnimationFrame(() => { + if (openShellOnboarding) openShellOnboarding(id); @@ -14096,40 +13109,175 @@ diff --git a/client.js b/client.js + (0, import_react19.useLayoutEffect)(() => { + if (explicit) openSection("models"); + complete(); -+ }, []); + }, []); + return null; -+} -+function openHarnessModels() { -+ openHarnessOnboarding(MODELS_STEP_ID); -+} -+function openThroughMenu(trigger) { -+ const before = new Set(document.querySelectorAll('[role="menu"]')); -+ trigger.click(); -+ let frames = 0; -+ const look = () => { -+ const menu = [...document.querySelectorAll('[role="menu"]')].find((node) => !before.has(node)); -+ const item = menu?.querySelector('button[role="menuitem"]'); -+ if (item) item.click(); -+ else if (++frames < 30) requestAnimationFrame(look); -+ else pressSettingsShortcut(); -+ }; -+ requestAnimationFrame(look); -+} -+function pressSettingsShortcut() { -+ const binding = shellShortcuts?.catalog.getSnapshot().find((row) => row.id === "settings.open")?.binding; -+ if (!binding) return; -+ const held = new Set(binding.modifiers); -+ window.dispatchEvent(new KeyboardEvent("keydown", { -+ code: binding.code, -+ key: binding.code === "Comma" ? "," : "", -+ bubbles: true, -+ cancelable: true, + } +-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; +- } +- 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)( +- "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)( +- "span", +- { className: "bt-typing-text" }, +- (0, import_react7.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 +- ) +- ) +- ), host); ++function openHarnessModels() { ++ openHarnessOnboarding(MODELS_STEP_ID); + } +- +-// 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 openThroughMenu(trigger) { ++ const before = new Set(document.querySelectorAll('[role="menu"]')); ++ trigger.click(); ++ let frames = 0; ++ const look = () => { ++ const menu = [...document.querySelectorAll('[role="menu"]')].find((node) => !before.has(node)); ++ const item = menu?.querySelector('button[role="menuitem"]'); ++ if (item) item.click(); ++ else if (++frames < 30) requestAnimationFrame(look); ++ else pressSettingsShortcut(); ++ }; ++ requestAnimationFrame(look); + } +- +-// src/client/sidebar.js +-var import_react8 = require("react"); +-var import_react_dom3 = require("react-dom"); +-var import_dsh_client_ui_primitives2 = require("@deepseek-ai/dsh-client-ui-primitives"); +-function useMarkedUnread(id, active, marked, actions) { +- (0, import_react8.useEffect)(() => { +- if (active && marked) actions.clearUnread(id); +- }, [active, marked]); ++function pressSettingsShortcut() { ++ const binding = shellShortcuts?.catalog.getSnapshot().find((row) => row.id === "settings.open")?.binding; ++ if (!binding) return; ++ const held = new Set(binding.modifiers); ++ window.dispatchEvent(new KeyboardEvent("keydown", { ++ code: binding.code, ++ key: binding.code === "Comma" ? "," : "", ++ bubbles: true, ++ cancelable: true, + ctrlKey: held.has("control"), + altKey: held.has("alt"), + shiftKey: held.has("shift"), + metaKey: held.has("meta") + })); -+} + } +-var PinMark = () => (0, import_react8.createElement)("span", { className: "bt-pin", "aria-hidden": true }, (0, import_react8.createElement)(PinMarkIcon)); +-var useApproval = (useWork, match) => useWork ? useWork((work) => (work.approvals ?? []).some(match)) : false; +-var useGroupWork = (useWork, pick2) => useWork ? useWork((work) => pick2(work.rooms ?? {}).join(",")) : ""; +-var workKeys = (text) => text === "" ? [] : text.split(",").map((key) => { +- const [id, tool] = key.split("|"); +- return { id, tool: tool || void 0 }; +-}); +-var workLine = (lead, tool, more) => (0, import_react8.createElement)(Shimmer, null, `${lead} · ${workPhrase(tool)}…${more > 0 ? ` +${more}` : ""}`); +-function BotRow({ bot, roster, actions, useSessions, useSessionStatus, useSessionRetainInfo, useActivity, useWork, wide, marked }) { +- const id = bot.id; +- const part = currentPart(bot); +- const preview = useSessions((list) => previewOf(list, part, roster.exchangeTurns[part])); +- const running = useSessionStatus((map) => map.get(part)?.running === true); +- const active = useSessionRetainInfo(part, (info) => (info?.retainedBy?.mainView ?? 0) > 0); +- const unread = useSessionStatus((map) => map.get(part)?.completionUnread === true || map.get(part)?.pendingInteraction !== void 0) || marked && !active; +- useMarkedUnread(id, active, marked, actions); +- const main = isMainOf(roster, id); +- const tile = roster.mainBotId === id; +- const approval = useApproval(useWork, (entry) => entry.botId === id); +- const alert = roster.questions?.[part] !== void 0 || approval; +- const doing = useActivity((map) => map[id]); +- const groups = workKeys(useGroupWork(useWork, (rooms) => Object.entries(rooms).flatMap(([roomId, entries2]) => entries2.filter((entry) => entry.botId === id).map((entry) => `${roomId}|${entry.tool ?? ""}`)))); +- const away = !running && groups.length > 0; +- const state = botState(running || away, doing, alert); +- const awayLine = away ? workLine(roster.roomsById[groups[0].id]?.name ?? t("a group chat"), groups[0].tool, groups.length - 1) : null; +- if (bot.hidden && !active && !main) return null; +- const onContextMenu = (event) => { +- event.preventDefault(); +- actions.openMenu({ kind: "bot", id, x: event.clientX, y: event.clientY }); +function AccountLauncher({ seat = true, settingsOpen, openSettings, openOnboarding, actions }) { + const [open, setOpen] = (0, import_react19.useState)(false); + (0, import_react19.useLayoutEffect)(() => { @@ -14164,18 +13312,10 @@ diff --git a/client.js b/client.js + }; + const onKey = (event) => { + if (event.key === "Escape") { - event.stopPropagation(); -- onDismiss(); ++ event.stopPropagation(); + setOpen(false); + button.current?.focus(); - } -- }, (0, import_react9.createElement)(CloseIcon)) -- ); -- const toLatest = () => { -- const scroller = document.querySelector("[data-conversation-scroll]"); -- const back = scroller?.querySelector('[class*="_toBottomSlot"]>button'); -- if (back) back.click(); -- else scroller?.scrollTo({ top: scroller.scrollHeight, behavior: "smooth" }); ++ } + }; + window.addEventListener("mousedown", onDown); + window.addEventListener("keydown", onKey, true); @@ -14189,13 +13329,10 @@ diff --git a/client.js b/client.js + if (rect) setBox({ left: rect.left, bottom: window.innerHeight - rect.top + 5 }); + setOpen((value) => !value); }; -- const toDivider = () => document.querySelector("[data-conversation-scroll] .bt-new-sep")?.scrollIntoView({ block: "center", behavior: "smooth" }); -- return (0, import_react_dom4.createPortal)( -- (0, import_react9.createElement)( -- import_react9.Fragment, -- null, -- showUp ? pill("up", unread, { left: view.center, top: view.top }, toDivider, () => setAbove((state) => ({ ...state, dismissed: true }))) : null, -- showDown ? pill("down", fresh, { left: view.center, bottom: view.bottom }, toLatest, () => setRelease((state) => ({ ...state, dismissed: true }))) : null +- const pinned = bot.pinned === true && !tile; +- 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(", "); +- if (tile && wide) { +- return (0, import_react8.createElement)("div", { className: "bt-tile-wrap" }, (0, import_react8.createElement)( + const groups = [ + [ + [t("Usage"), GaugeIcon, () => actions.openSettings("usage")], @@ -14212,11 +13349,46 @@ diff --git a/client.js b/client.js + import_react19.Fragment, + null, + (0, import_react19.createElement)( -+ "button", + "button", +- { +- type: "button", +- className: "bt-tile", +- "aria-label": label, +- "aria-current": active ? "page" : void 0, +- "data-bot-id": id, +- title: preview || bot.name, +- onClick: () => actions.openSession(id), +- onContextMenu +- }, +- (0, import_react8.createElement)(BotAvatar, { bot, size: 60, state, main: true }), +- (0, import_react8.createElement)("span", { className: "bt-tile-name" }, bot.name), +- bot.role ? (0, import_react8.createElement)("span", { className: "bt-chip" }, bot.role) : null +- )); +- } +- return (0, import_react8.createElement)( +- "button", +- { +- type: "button", +- className: "bt-row", +- "aria-label": label, +- "aria-current": active ? "page" : void 0, +- "data-bot-id": id, +- title: wide ? void 0 : bot.name, +- onClick: () => actions.openSession(id), +- onContextMenu +- }, +- (0, import_react8.createElement)(BotAvatar, { bot, size: 36, state, main }), +- pinned && !wide ? (0, import_react8.createElement)(PinMark) : null, +- wide ? (0, import_react8.createElement)( +- "span", +- { className: "bt-row-body" }, +- (0, import_react8.createElement)("span", { className: "bt-row-name" }, (0, import_react8.createElement)("span", { className: "bt-row-title" }, bot.name), pinned ? (0, import_react8.createElement)(PinMark) : null), +- awayLine ? (0, import_react8.createElement)("span", { className: "bt-row-preview" }, awayLine) : preview ? (0, import_react8.createElement)("span", { className: "bt-row-preview" }, preview) : null +- ) : null, +- wide && unread && !active ? (0, import_react8.createElement)("span", { className: "bt-unread", "aria-hidden": true }) : null + { ref: button, type: "button", className: "bt-account", "aria-label": t("Account"), "aria-haspopup": "menu", "aria-expanded": open, onClick: toggle }, + (0, import_react19.createElement)("span", { className: "bt-you", "aria-hidden": true }, (0, import_react19.createElement)(BotMark, { look: MAIN_LOOK, size: 24 })) - ), -- document.body ++ ), + open && box ? (0, import_react_dom4.createPortal)((0, import_react19.createElement)( + "div", + { className: "bt-menu bt-menu-up bt-account-menu", role: "menu", "aria-label": t("Account"), style: box }, @@ -14238,132 +13410,108 @@ diff --git a/client.js b/client.js + ), document.body) : null ); } -- --// src/client/composer.js --var import_react10 = require("react"); --var import_react_dom5 = require("react-dom"); --var speechApi = () => window.SpeechRecognition ?? window.webkitSpeechRecognition; --var keepComposerFocus = (event) => event.preventDefault(); --function useDictation(onText) { -- const [listening, setListening] = (0, import_react10.useState)(false); -- const [note, setNote] = (0, import_react10.useState)(""); -- const recognition = (0, import_react10.useRef)(null); -- const latest = (0, import_react10.useRef)(onText); -- latest.current = onText; -- (0, import_react10.useEffect)(() => () => recognition.current?.abort(), []); -- (0, import_react10.useEffect)(() => { -- if (!note) return void 0; -- const timer = setTimeout(() => setNote(""), 2600); -- return () => clearTimeout(timer); -- }, [note]); -- const start = ({ continuous = true, onEnd } = {}) => { -- const Api = speechApi(); -- if (!Api) { -- setNote(t("Dictation is not available in this browser")); -- return false; -+function useLandOnMainBot(roster, useSessions, actions) { -+ const mainView = useSessions((list) => { -+ const row = Object.values(list.byId).find((session) => (session?.retainedBy?.mainView ?? 0) > 0); -+ return row ? `${row.id}|${row.blank ? 1 : 0}` : ""; -+ }); -+ const lastBot = (0, import_react19.useRef)(null); -+ (0, import_react19.useEffect)(() => { -+ if (!roster.ready || !roster.mainBotId) return void 0; -+ const [id, blank3] = mainView.split("|"); -+ const bot = id === "" ? void 0 : roster.byId[id]; -+ if (bot !== void 0) { -+ lastBot.current = { botId: bot.id, sessionId: id }; -+ if (currentPart(bot) !== id) actions.landOn(bot.id); -+ return void 0; - } -- const engine = new Api(); -- engine.lang = navigator.language || "zh-CN"; -- engine.interimResults = true; -- engine.continuous = continuous; -- engine.onresult = (event) => { -- let interim = ""; -- for (let index = event.resultIndex; index < event.results.length; index += 1) { -- const result = event.results[index]; -- const text = result[0]?.transcript ?? ""; -- if (result.isFinal) { -- if (text.trim()) latest.current(text, true); -- } else interim += text; -- } -- if (interim) latest.current(interim, false); -- }; -- engine.onerror = (event) => { -- if (event.error === "aborted" || event.error === "no-speech") return; -- setNote(event.error === "not-allowed" || event.error === "service-not-allowed" ? t("Microphone access is blocked") : t("Couldn't hear that")); -- }; -- engine.onend = () => { -- setListening(false); -- recognition.current = null; -- onEnd?.(); -- }; -- recognition.current = engine; -- try { -- engine.start(); -- setListening(true); -- return true; -- } catch { -- setNote(t("Couldn't start dictation")); -- return false; -+ if (id !== "" && (roster.roomsById[id] || (actions.isAgentSession?.(id) ?? roster.agentOf[id] !== void 0) || blank3 !== "1")) { +-function RoomRow({ room: room2, roster, actions, useSessions, useSessionStatus, useSessionRetainInfo, useWork, wide, marked }) { +- const id = room2.id; +- const fallback = useSessions((list) => previewOf(list, id)); +- const preview = room2.preview?.replace(/\s+/g, " ").trim() || fallback; +- const active = useSessionRetainInfo(id, (info) => (info?.retainedBy?.mainView ?? 0) > 0); +- const unread = useSessionStatus((map) => map.get(id)?.completionUnread === true) || marked && !active; +- useMarkedUnread(id, active, marked, actions); +- const approval = useApproval(useWork, (entry) => entry.roomId === id); +- const members = workKeys(useGroupWork(useWork, (rooms) => (rooms[id] ?? []).map((entry) => `${entry.botId}|${entry.tool ?? ""}`))); +- const first = roster.byId[members[0]?.id]; +- const workingLine = first ? workLine(first.name, members[0].tool, members.length - 1) : null; +- if (room2.hidden && !active) return null; +- const pinned = room2.pinned === true; +- const label = [room2.name, pinned ? t("Pinned") : "", members.length > 0 ? t("Working") : "", approval ? t("Waiting for your approval") : ""].filter(Boolean).join(", "); +- return (0, import_react8.createElement)( +- "button", +- { +- type: "button", +- className: "bt-row", +- "aria-label": label, +- "aria-current": active ? "page" : void 0, +- "data-room-id": id, +- onClick: () => actions.openSession(id), +- onContextMenu: (event) => { +- event.preventDefault(); +- actions.openMenu({ kind: "room", id, x: event.clientX, y: event.clientY }); +- } +- }, +- (0, import_react8.createElement)(RoomAvatar, { room: room2, roster, size: 36 }), +- pinned && !wide ? (0, import_react8.createElement)(PinMark) : null, +- wide ? (0, import_react8.createElement)( +- "span", +- { className: "bt-row-body" }, +- (0, import_react8.createElement)("span", { className: "bt-row-name" }, (0, import_react8.createElement)("span", { className: "bt-row-title" }, room2.name), pinned ? (0, import_react8.createElement)(PinMark) : null), +- workingLine ? (0, import_react8.createElement)("span", { className: "bt-row-preview" }, workingLine) : preview ? (0, import_react8.createElement)("span", { className: "bt-row-preview" }, preview) : null +- ) : null, +- wide && approval && !active ? (0, import_react8.createElement)("span", { className: "bt-unread bt-unread-wait", "aria-hidden": true }) : wide && unread && !active ? (0, import_react8.createElement)("span", { className: "bt-unread", "aria-hidden": true }) : null +- ); ++function useLandOnMainBot(roster, useSessions, actions) { ++ const mainView = useSessions((list) => { ++ const row = Object.values(list.byId).find((session) => (session?.retainedBy?.mainView ?? 0) > 0); ++ return row ? `${row.id}|${row.blank ? 1 : 0}` : ""; ++ }); ++ const lastBot = (0, import_react19.useRef)(null); ++ (0, import_react19.useEffect)(() => { ++ if (!roster.ready || !roster.mainBotId) return void 0; ++ const [id, blank3] = mainView.split("|"); ++ const bot = id === "" ? void 0 : roster.byId[id]; ++ if (bot !== void 0) { ++ lastBot.current = { botId: bot.id, sessionId: id }; ++ if (currentPart(bot) !== id) actions.landOn(bot.id); ++ return void 0; ++ } ++ if (id !== "" && (roster.roomsById[id] || (actions.isAgentSession?.(id) ?? roster.agentOf[id] !== void 0) || blank3 !== "1")) { + lastBot.current = null; + return void 0; - } -- }; -- const stop = () => recognition.current?.stop(); -- return { listening, note, start, stop }; ++ } + const last = lastBot.current; + const timer = setTimeout(() => actions.landOn(roster.mainBotId, last, { stay: id === "", view: id }), last ? 0 : 400); + return () => clearTimeout(timer); + }, [mainView, roster]); } --function ComposerVoice({ sessionId, inputActions, actions, useUi }) { -- const dictation = useDictation((text, final) => { -- if (final && inputActions) inputActions.insertText(text, inputActions.captureInsertion()); -- }); -- const quote = useUi((state) => state.quote?.sessionId === sessionId ? state.quote : null); -- (0, import_react10.useEffect)(() => { -- if (!quote || !inputActions) return; -- actions.clearQuote(quote.token); -- const excerpt = quote.text.replace(/\s+/g, " ").trim(); -- const line = `> ${excerpt.length > 200 ? `${excerpt.slice(0, 200)}…` : excerpt} -- --`; -- inputActions.insertText(line, inputActions.captureInsertion()); -- const box = document.querySelector('[class*="_centerCol"] [role=textbox][contenteditable=true]'); -- if (box instanceof HTMLElement) { -- box.focus(); -- const selection = window.getSelection(); -- selection?.selectAllChildren(box); -- selection?.collapseToEnd(); -- } -- }, [quote?.token]); -- if (sessionId === void 0) return null; -- const pluginMic = actions.speech() !== void 0; -- return (0, import_react10.createElement)( -- import_react10.Fragment, -- null, -- pluginMic ? null : (0, import_react10.createElement)("button", { -- type: "button", -- className: "bt-mic", -- "aria-label": dictation.listening ? t("Stop dictation") : t("Dictate"), -- "aria-pressed": dictation.listening, -- title: dictation.listening ? t("Stop dictation") : t("Dictate"), -- onMouseDown: keepComposerFocus, -- onClick: () => dictation.listening ? dictation.stop() : dictation.start() -- }, (0, import_react10.createElement)(MicIcon)), -- (0, import_react10.createElement)("button", { -- type: "button", -- className: "bt-voice", -- "aria-label": t("Start voice chat"), -- title: t("Voice chat"), -- onMouseDown: keepComposerFocus, -- onClick: () => actions.openVoice(sessionId) -- }, (0, import_react10.createElement)(WaveIcon, { size: 18 })), -- dictation.note ? (0, import_react10.createElement)("span", { className: "bt-mic-note", role: "status" }, dictation.note) : null +-function AgentSessionRow({ session, agent, actions, renaming, index }) { +- const input = (0, import_react8.useRef)(null); +- (0, import_react8.useLayoutEffect)(() => { +- if (!renaming) return; +- input.current?.focus(); +- input.current?.select(); +- }, [renaming]); +- if (renaming) { +- const commit = (keep) => { +- const value = input.current?.value ?? ""; +- if (!keep || value.trim() === "" || value.trim() === session.title) { +- actions.endAgentRename(); +- return; +- } +- void (async () => { +- try { +- await actions.renameAgentSession(agent.id, session.id, value); +- actions.endAgentRename(); +- } catch (error) { +- actions.reportError(error, "agent-rename"); +- } +- })(); +- }; +- return (0, import_react8.createElement)( +- "div", +- { className: "bt-agent-sub-row bt-agent-edit", style: { "--i": index } }, +- (0, import_react8.createElement)("input", { +- ref: input, +- defaultValue: session.title, +- "aria-label": t("Rename"), +- onKeyDown: (event) => { +- if (event.nativeEvent.isComposing) return; +- if (event.key === "Enter") commit(true); +- else if (event.key === "Escape") commit(false); +- }, +- onBlur: () => commit(true) +- }) +- ); +- } +- return (0, import_react8.createElement)( +function FooterAccount(props) { + const seated = props.useUi((value) => value.accountSeated === true); + if (seated) return null; @@ -14373,20 +13521,36 @@ diff --git a/client.js b/client.js + const mode = useSurface((value) => value.mode); + if (mode !== "agent") return null; + return (0, import_react19.createElement)( -+ "button", + "button", +- { +- type: "button", +- className: "bt-agent-sub-row", +- "aria-current": session.active ? "page" : void 0, +- style: { "--i": index }, +- onClick: () => actions.openAgentSession(session.id), +- onContextMenu: (event) => { +- event.preventDefault(); +- event.stopPropagation(); +- actions.openMenu({ kind: "agent-session", id: session.id, agentId: agent.id, x: event.clientX, y: event.clientY }); +- } +- }, +- (0, import_react8.createElement)("span", { className: "bt-agent-sub-title" }, session.title), +- (0, import_react8.createElement)("span", { className: "bt-agent-sub-time" }, session.at > 0 ? relativeShort(session.at) : "") + { type: "button", className: "bt-return", "aria-label": t("FlyBot"), title: t("FlyBot"), onClick: () => actions.setSurface("bot") }, + (0, import_react19.createElement)(FlyBotMark, { size: 20 }), + wide ? (0, import_react19.createElement)("span", { className: "bt-return-name" }, t("FlyBot")) : null ); } --function menuIcons(roster) { -- const marks = /* @__PURE__ */ new Map(); -- return (row) => { -- if (!row.bot) return PeopleIcon; -- const id = row.bot.id; -- if (!marks.has(id)) marks.set(id, ({ size = 14 }) => (0, import_react10.createElement)(BotMark, { bot: roster.getSnapshot().byId[id] ?? row.bot, size })); -- return marks.get(id); -- }; +-function AgentRow({ agent, actions, useSessions, useSessionStatus, useUi, wide, marked }) { +- const info = useSessions((list) => JSON.stringify(agent.sessions.map((id) => { +- const row = list.byId[id]; +- return { +- id, +- // A blank Session's title is the workspace folder name; show the label instead. +- title: row?.blank === true ? "" : row?.displayTitle ?? row?.title ?? "", +- at: row?.updatedAt ?? 0, +- running: row?.running === true, +- active: (row?.retainedBy?.mainView ?? 0) > 0 +var RETURN_CSS = ` +.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)} @@ -14484,23 +13648,7 @@ diff --git a/client.js b/client.js +} +function busyAckPending(hostAck) { + return busyAckText(hostAck) === ""; - } --function ComposerPlus({ sessionId, inputActions, useRoster, actions }) { -- const roster = useRoster((value) => value); -- const [open, setOpen] = (0, import_react10.useState)(null); -- const [shown, leaving] = useLinger(open, 120); -- const button = (0, import_react10.useRef)(null); -- const menu = (0, import_react10.useRef)(null); -- (0, import_react10.useEffect)(() => { -- if (!open) return void 0; -- const onDown = (event) => { -- if (![button.current, menu.current].some((node) => node?.contains(event.target))) setOpen(null); -- }; -- const onKey = (event) => { -- if (event.key === "Escape") { -- event.stopPropagation(); -- setOpen(null); -- } ++} +function pendingBusyPlacement(submissions) { + if (!Array.isArray(submissions)) return false; + return submissions.some((item) => item?.placement === "queued" || item?.placement === "steering"); @@ -14536,29 +13684,37 @@ diff --git a/client.js b/client.js + const node = document.querySelector(RUNNING_HOST); + setHost((prev) => prev === node ? prev : node); }; -- window.addEventListener("mousedown", onDown, true); -- window.addEventListener("keydown", onKey, true); +- }).sort((a, b) => b.at - a.at))); +- const statusInfo = useSessionStatus((map) => JSON.stringify(agent.sessions.map((id) => ({ +- id, +- running: map.get(id)?.running === true, +- unread: map.get(id)?.completionUnread === true +- })))); +- const statuses = Object.fromEntries(JSON.parse(statusInfo).map((entry) => [entry.id, entry])); +- const sessions = JSON.parse(info).map((session) => ({ +- ...session, +- running: statuses[session.id]?.running ?? session.running, +- unread: statuses[session.id]?.unread ?? false, +- title: session.title === "" ? t("New session") : session.title +- })); +- const renaming = useUi((value) => value.renameAgent); +- const active = sessions.some((session) => session.active); +- const running = sessions.some((session) => session.running); +- const unread = sessions.some((session) => session.unread) || marked && !active; +- useMarkedUnread(agent.id, active, marked, actions); +- 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; +- return (0, import_react8.createElement)( + const observer = new MutationObserver(() => { + if (!frame) frame = requestAnimationFrame(find); + }); + observer.observe(document.querySelector("[data-conversation-scroll]") ?? document.body, { childList: true, subtree: true }); + find(); - return () => { -- window.removeEventListener("mousedown", onDown, true); -- window.removeEventListener("keydown", onKey, true); ++ return () => { + cancelAnimationFrame(frame); + observer.disconnect(); - }; -- }, [open]); -- const target = roster.byId?.[sessionId]?.id ?? roster.roomsById?.[sessionId]?.id; -- if (sessionId === void 0 || target === void 0) return null; -- const toggle = () => { -- if (open) { -- setOpen(null); -- return; -- } -- const rect = button.current.getBoundingClientRect(); -- setOpen({ left: rect.left, bottom: window.innerHeight - rect.top + 8 }); ++ }; + }, [active]); + return host; +} @@ -14578,7 +13734,33 @@ diff --git a/client.js b/client.js + const text2 = shown.current?.text ?? ""; + if (!visible || !host || !shown.current || !bot) return null; + return (0, import_react_dom5.createPortal)((0, import_react20.createElement)( -+ "div", + "div", +- { className: "bt-agent" }, +- (0, import_react8.createElement)( +- "button", +- { +- type: "button", +- className: "bt-row", +- "aria-label": label, +- "aria-current": active ? "page" : void 0, +- "data-agent-id": agent.id, +- title: wide ? void 0 : "DSH Agent", +- onClick: () => actions.openAgent(agent.id), +- onContextMenu: (event) => { +- event.preventDefault(); +- actions.openMenu({ kind: "agent", id: agent.id, x: event.clientX, y: event.clientY }); +- } +- }, +- (0, import_react8.createElement)("span", { className: `bt-agent-ava${running ? " bt-run" : ""}` }, (0, import_react8.createElement)(import_dsh_client_ui_primitives2.FishLogo, { size: 22 })), +- wide ? (0, import_react8.createElement)( +- "span", +- { className: "bt-row-body" }, +- (0, import_react8.createElement)("span", { className: "bt-row-name" }, "DSH Agent"), +- preview ? (0, import_react8.createElement)("span", { className: "bt-row-preview" }, preview) : null +- ) : null, +- wide && unread && !active ? (0, import_react8.createElement)("span", { className: "bt-unread", "aria-hidden": true }) : null +- ), +- (0, import_react8.createElement)( + { + className: "bt-busy-ack", + "data-flybot": "busy-ack", @@ -14589,10 +13771,29 @@ diff --git a/client.js b/client.js + }, + (0, import_react20.createElement)(BotMark, { bot, size: 28, state: "thinking", live: true }), + (0, import_react20.createElement)( -+ "div", + "div", +- { className: "bt-agent-sub", "data-open": open || void 0 }, +- (0, import_react8.createElement)( + { className: "bt-astack" }, + (0, import_react20.createElement)( -+ "div", + "div", +- { className: "bt-agent-sub-in" }, +- (0, import_react8.createElement)( +- "button", +- { type: "button", className: "bt-agent-sub-row bt-agent-new", style: { "--i": 0 }, onClick: () => { +- void actions.newAgentSession(agent.id).catch((error) => actions.reportError(error, "agent-session")); +- } }, +- (0, import_react8.createElement)(PlusIcon), +- (0, import_react8.createElement)("span", { className: "bt-agent-sub-title" }, t("New session")) +- ), +- sessions.map((session, index) => (0, import_react8.createElement)(AgentSessionRow, { +- key: session.id, +- session, +- agent, +- actions, +- index: index + 1, +- renaming: renaming === session.id +- })) + { className: "bt-msg" }, + (0, import_react20.createElement)( + "div", @@ -14605,20 +13806,37 @@ diff --git a/client.js b/client.js + (0, import_react20.createElement)("span", null) + ) + ) -+ ) -+ ) + ) + ) +- ); + ), host); -+} -+var BUSY_ACK_CSS = ` -+.bt-busy-ack{display:flex;align-items:flex-end;gap:8px;width:100%;margin-top:var(--dsh-chat-flow-gap,12px);margin-bottom:4px} -+.bt-busy-ack .bt-astack{align-items:flex-start} -+.bt-busy-ack .bt-bubble{opacity:.92} -+.bt-busy-dots{display:inline-flex;align-items:center;gap:4px;height:22px} -+.bt-busy-dots>span{width:5px;height:5px;border-radius:50%;background:var(--bt-ink-3);animation:bt-busy-blink 1.2s ease-in-out infinite} -+.bt-busy-dots>span:nth-child(2){animation-delay:.2s}.bt-busy-dots>span:nth-child(3){animation-delay:.4s} -+@keyframes bt-busy-blink{0%,80%,100%{opacity:.25}40%{opacity:1}} -+@media (prefers-reduced-motion:reduce){.bt-busy-dots>span{animation:none;opacity:.6}} -+`; + } +-var GLIDE = springEasing(1e3, 63); +-function useListGlide(listRef, layout, key) { +- const tops = (0, import_react8.useRef)({ layout, map: /* @__PURE__ */ new Map() }); +- (0, import_react8.useLayoutEffect)(() => { +- const list = listRef.current; +- if (!list) return; +- const next = /* @__PURE__ */ new Map(); +- const still = reducedMotion() || tops.current.layout !== layout; +- for (const row of list.querySelectorAll("[data-bot-id],[data-room-id],[data-agent-id]")) { +- const id = row.dataset.botId ?? row.dataset.roomId ?? row.dataset.agentId; +- const top = row.offsetTop; +- next.set(id, top); +- const before = tops.current.map.get(id); +- if (!still && before !== void 0 && Math.abs(before - top) > 0.5) { +- row.animate([{ transform: `translateY(${before - top}px)` }, { transform: "none" }], GLIDE); +- } ++var BUSY_ACK_CSS = ` ++.bt-busy-ack{display:flex;align-items:flex-end;gap:8px;width:100%;margin-top:var(--dsh-chat-flow-gap,12px);margin-bottom:4px} ++.bt-busy-ack .bt-astack{align-items:flex-start} ++.bt-busy-ack .bt-bubble{opacity:.92} ++.bt-busy-dots{display:inline-flex;align-items:center;gap:4px;height:22px} ++.bt-busy-dots>span{width:5px;height:5px;border-radius:50%;background:var(--bt-ink-3);animation:bt-busy-blink 1.2s ease-in-out infinite} ++.bt-busy-dots>span:nth-child(2){animation-delay:.2s}.bt-busy-dots>span:nth-child(3){animation-delay:.4s} ++@keyframes bt-busy-blink{0%,80%,100%{opacity:.25}40%{opacity:1}} ++@media (prefers-reduced-motion:reduce){.bt-busy-dots>span{animation:none;opacity:.6}} ++`; + +// src/client/project-board.js +var import_react21 = require("react"); @@ -14779,10 +13997,7 @@ diff --git a/client.js b/client.js + title: text(file.title), + sha256: text(file.sha256), + shortHash: text(file.sha256).slice(0, 12) - }; -- const attach = () => { -- setOpen(null); -- button.current?.closest("[data-composer-card]")?.querySelector("input[type=file]")?.click(); ++ }; +}; +var meetingOf = (meeting, status) => { + if (meeting === null || typeof meeting !== "object") return null; @@ -14806,10 +14021,7 @@ diff --git a/client.js b/client.js + // #1861: the host's participants with who each position speaks for, so the row can say a + // Project Chief or an agent held it for someone rather than for themselves. + participants: Array.isArray(meeting.participants) ? meeting.participants.map((participant) => ({ name: text(participant?.name), speaksFor: text(participant?.speaksFor) || null })).filter((participant) => participant.name !== "") : [] - }; -- const schedule = () => { -- setOpen(null); -- actions.openSettings("schedules", target); ++ }; +}; +var listOf = (value) => Array.isArray(value) ? value.map((item) => decisionOf(item)).filter((item) => item !== null) : []; +function projectBoardRecord({ decisions, meetings } = {}) { @@ -14842,12 +14054,21 @@ diff --git a/client.js b/client.js + pendingHidden: hiddenOf(pendingTotal, pending.length), + empty: decided.length === 0 && pending.length === 0, + emptyText: text(meetings?.reason) || "No meeting of this Project is recorded." -+ } - }; -- const reference = () => { -- setOpen(null); -- actions.referenceChat(sessionId, inputActions?.captureInsertion?.()); -+} + } +- tops.current = { layout, map: next }; +- }, [layout, key]); ++ }; + } +-function TeamSidebar(props) { +- const { wide, useRoster, useUi, useSessions, actions } = props; +- const listRef = (0, import_react8.useRef)(null); +- const roster = useRoster((value) => value); +- const markedUnread = useUi((value) => value.unread); +- props.useRegistry((value) => value); +- const updated = useSessions((list) => { +- const stamps = {}; +- for (const id of list.ids) stamps[id] = list.byId[id]?.updatedAt ?? 0; +- return JSON.stringify(stamps); +function projectBoardView({ project, rows, done, canManage, tasks, decisions, meetings, queues, metrics } = {}) { + const isRow = (row) => row !== null && typeof row === "object" && text(row.id) !== ""; + const byId = new Map((Array.isArray(tasks) ? tasks : []).filter((task) => task !== null && typeof task === "object" && text(task.id) !== "").map((task) => [text(task.id), task])); @@ -14880,36 +14101,7 @@ diff --git a/client.js b/client.js + speaksFor: text(row.speaksFor) || null, + board: boardOfRow(row, task) + }; - }; -- const canReference = typeof inputActions?.captureInsertion === "function" && actions.canReferenceChat(); -- return (0, import_react10.createElement)( -- import_react10.Fragment, -- null, -- (0, import_react10.createElement)("button", { -- ref: button, -- type: "button", -- className: "bt-plus", -- "aria-label": t("Add"), -- title: t("Add"), -- "aria-haspopup": "menu", -- "aria-expanded": Boolean(open), -- onMouseDown: keepComposerFocus, -- onClick: toggle -- }, (0, import_react10.createElement)(PlusIcon)), -- shown ? (0, import_react_dom5.createPortal)((0, import_react10.createElement)( -- "div", -- { -- ref: menu, -- className: "bt-menu bt-menu-up bt-plus-menu", -- role: "menu", -- "aria-label": t("Add"), -- style: { left: shown.left, bottom: shown.bottom }, -- "data-leaving": leaving || void 0 -- }, -- (0, import_react10.createElement)("button", { type: "button", role: "menuitem", onClick: attach }, (0, import_react10.createElement)(PaperclipIcon), (0, import_react10.createElement)("span", { className: "bt-menu-label" }, t("Add files"))), -- canReference ? (0, import_react10.createElement)("button", { type: "button", role: "menuitem", onMouseDown: keepComposerFocus, onClick: reference }, (0, import_react10.createElement)(ChatsIcon), (0, import_react10.createElement)("span", { className: "bt-menu-label" }, t("Reference a chat"))) : null, -- (0, import_react10.createElement)("button", { type: "button", role: "menuitem", onClick: schedule }, (0, import_react10.createElement)(ClockIcon), (0, import_react10.createElement)("span", { className: "bt-menu-label" }, t("Scheduled task"))) -- ), document.body) : null ++ }; + const groups = PROJECT_SOURCES.map((source) => ({ + source, + label: SOURCE_LABELS[source], @@ -14921,7 +14113,26 @@ diff --git a/client.js b/client.js + const source = text([...list, ...archived].find((row) => text(row.id) === id)?.source); + const task = byId.get(id); + return { id, key: `task:${id}`, title: text(task?.title) || text(task?.note) || null, priority: text(task?.priority) || null, board: boardOfRow({ source }, task), ...annotate(item) }; -+ }); + }); +- const order = (0, import_react8.useMemo)(() => { +- const stamps = JSON.parse(updated); +- const entries2 = [ +- ...roster.bots.filter((bot) => bot.id !== roster.mainBotId).map((bot) => ({ kind: "bot", item: bot, at: stamps[currentPart(bot)] ?? bot.createdAt })), +- ...roster.rooms.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 })) +- ]; +- return entries2.sort((a, b) => Number(Boolean(b.item.pinned)) - Number(Boolean(a.item.pinned)) || b.at - a.at); +- }, [roster, updated]); +- const main = roster.byId[roster.mainBotId]; +- 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 }; +- const typeToSearch = (event) => { +- if (event.defaultPrevented || event.key.length !== 1 || event.key === " " || event.metaKey || event.ctrlKey || event.altKey || event.nativeEvent.isComposing) return; +- if (event.target instanceof Element && event.target.closest("input,textarea,[contenteditable]")) return; +- event.preventDefault(); +- actions.openPalette(event.key); + const waiting = queueItems(queues?.waiting, (item) => ({ role: text(item.role) || null, waitedMs: Number.isFinite(item.waitedMs) ? item.waitedMs : null })); + const inflight = queueItems(queues?.inflight, (item) => ({ holder: text(item.holder) || null, state: item.stalled === true ? "stalled" : "running" })); + const blocked = queueItems(queues?.blocked, (item) => ({ reason: text(item.reason) || null, waitedMs: Number.isFinite(item.waitedMs) ? item.waitedMs : null })); @@ -14946,12 +14157,26 @@ diff --git a/client.js b/client.js + emptyText: "No cards yet.", + record: projectBoardRecord({ decisions, meetings }), + metrics: metricsView(metrics, { tasks }) -+ }; + }; +- return (0, import_react8.createElement)( +} +function projectRowNode(row, onOpen) { + const open = row.board === null ? void 0 : () => onOpen?.(row.board); + return (0, import_react21.createElement)( -+ "div", + "div", +- { className: wide ? "bt-side" : "bt-side bt-rail", onKeyDown: typeToSearch }, +- wide ? (0, import_react8.createElement)( +- "div", +- { className: "bt-side-top" }, +- (0, import_react8.createElement)("span", { className: "bt-brand" }, (0, import_react8.createElement)(BrandMark, { size: 22 }), (0, import_react8.createElement)(BrandName)), +- (0, import_react8.createElement)(UpdateButton, { useUpdate: props.useUpdate, actions }), +- (0, import_react8.createElement)("button", { type: "button", className: "bt-round", "aria-label": t("Search"), title: t("Search"), onClick: () => actions.openPalette() }, (0, import_react8.createElement)(SearchIcon)), +- (0, import_react8.createElement)("button", { type: "button", className: "bt-round", "aria-label": t("New…"), title: t("New…"), onClick: () => actions.openNewChat("new") }, (0, import_react8.createElement)(PlusIcon)) +- ) : (0, import_react8.createElement)( +- import_react8.Fragment, +- null, +- (0, import_react8.createElement)(UpdateButton, { useUpdate: props.useUpdate, actions }), +- (0, import_react8.createElement)("button", { type: "button", className: "bt-round", "aria-label": t("New…"), title: t("New…"), onClick: () => actions.openNewChat("new") }, (0, import_react8.createElement)(PlusIcon)) + { + key: row.key, + className: "bt-project-row", @@ -14973,7 +14198,14 @@ diff --git a/client.js b/client.js + // The audit text the row already carries (#1730): shown inside the card detail, never a + // second read. A row without one renders exactly as before. + row.audit === null ? null : (0, import_react21.createElement)("span", { className: "bt-project-audit" }, row.audit) -+ ), + ), +- wide && roster.readOnly ? (0, import_react8.createElement)("div", { className: "bt-hint bt-read-only", role: "status" }, hostText(roster.readOnly)) : null, +- (0, import_react8.createElement)( +- "div", +- { ref: listRef, className: "bt-list", role: "region", "aria-label": t("Bot list") }, +- main ? (0, import_react8.createElement)(BotRow, { ...rowProps, bot: main, marked: markedUnread[main.id] === true }) : roster.ready ? null : (0, import_react8.createElement)("div", { className: "bt-hint" }, t("Setting up your Main Bot…")), +- order.map((entry) => entry.kind === "bot" ? (0, import_react8.createElement)(BotRow, { key: entry.item.id, ...rowProps, bot: entry.item, marked: markedUnread[entry.item.id] === true }) : entry.kind === "room" ? (0, import_react8.createElement)(RoomRow, { key: entry.item.id, ...rowProps, room: entry.item, marked: markedUnread[entry.item.id] === true }) : (0, import_react8.createElement)(AgentRow, { key: entry.item.id, ...rowProps, agent: entry.item, marked: markedUnread[entry.item.id] === true })) +- ) + // "Who is doing" is the claim, never the assignee label; a card the Assistant speaks for + // says so, so the three identities (Bot, Assistant, user) never read as one. + row.holder === null ? null : (0, import_react21.createElement)("span", { className: "bt-project-holder", "data-holder": row.holder }, `@${row.holder}`), @@ -14982,30 +14214,31 @@ diff --git a/client.js b/client.js + row.runId === null ? null : (0, import_react21.createElement)("span", { className: "bt-project-ref" }, row.nodeId === null ? row.runId : `${row.runId} · ${row.nodeId}`) ); } -- --// src/client/at-menu.js --var BOTS_SOURCE = "ds-bot"; --var CHATS_SOURCE = "ds-bot-chats"; --var ROOM_ALL = ["all", "everyone", "所有人", "全体"]; --var OTHER_CHATS_MAX = 8; --var conversationOf = (roster, sessionId) => { -- if (!roster.ready) return null; -- const room2 = roster.roomsById[sessionId]; -- if (room2) return { room: room2 }; -- const self = roster.byId[sessionId]; -- return self ? { self } : null; --}; --function mentionRows(roster, sessionId, query = "") { -- const here = conversationOf(roster, sessionId); -- if (here === null) return null; -- const bots = here.room ? here.room.members.map((id) => roster.byId[id]).filter(Boolean) : roster.bots.filter((bot) => bot.id !== here.self.id); -- const needle = query.trim().toLowerCase(); -- const rows = bots.filter((bot) => bot.name.toLowerCase().includes(needle)).map((bot) => ({ bot, text: `@${bot.name} ` })); -- const everyone = t("Everyone"); -- if (here.room && bots.length > 1 && [everyone, ...ROOM_ALL].some((word) => word.toLowerCase().startsWith(needle))) { -- rows.push({ room: here.room, text: `@${everyone} ` }); -- } -- return rows; +-function UpdateButton({ useUpdate, actions }) { +- const update = useUpdate((value) => value); +- const [open, setOpen] = (0, import_react8.useState)(false); +- const [box, setBox] = (0, import_react8.useState)(null); +- const [error, setError] = (0, import_react8.useState)(""); +- const [dshError, setDshError] = (0, import_react8.useState)(""); +- const button = (0, import_react8.useRef)(null); +- (0, import_react8.useEffect)(() => { +- 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; +- setOpen(false); +- }; +- const onKey = (event) => { +- if (event.key === "Escape") { +- event.stopPropagation(); +- setOpen(false); +- button.current?.focus(); +- } +- }; +- window.addEventListener("mousedown", onDown); +- window.addEventListener("keydown", onKey, true); +- return () => { +- window.removeEventListener("mousedown", onDown); +- window.removeEventListener("keydown", onKey, true); +function ProjectBoardRows({ view, onOpen }) { + if (view.empty) return (0, import_react21.createElement)("div", { className: "bt-project-empty" }, t(view.emptyText)); + return view.groups.map((group) => (0, import_react21.createElement)( @@ -15014,14 +14247,7 @@ diff --git a/client.js b/client.js + (0, import_react21.createElement)("h4", null, t(group.label)), + group.rows.map((row) => projectRowNode(row, onOpen)) + )); - } --function chatRows(roster, list, sessionId) { -- const here = conversationOf(roster, sessionId); -- if (here === null) return []; -- const byId = list?.byId ?? {}; -- const bots = roster.bots.filter((bot) => bot.id !== here.self?.id).map((bot) => { -- const id = currentPart(bot); -- return { sessionId: id, label: t("Chat with {name}", { name: bot.name }), bot, updatedAt: byId[id]?.updatedAt }; ++} +function ProjectDoneGroup({ view, onOpen }) { + if (view.done.rows.length === 0) return null; + return (0, import_react21.createElement)( @@ -15075,14 +14301,27 @@ diff --git a/client.js b/client.js + lastActivityAt: Number.isFinite(row.lastActivityAt) ? row.lastActivityAt : null, + overdue: Number.isFinite(row.sinceMs) && row.sinceMs > RUNNING_OVERDUE_MS, + board: boardOfRow({ source: text(row.source) }, task) -+ }; - }); -- const team = /* @__PURE__ */ new Set([ -- ...roster.bots.flatMap((bot) => [bot.id, currentPart(bot), ...bot.parts ?? []]), -- ...roster.rooms.flatMap((room2) => [room2.id, ...room2.parts ?? []]) -- ]); -- const others = (list?.ids ?? []).map((id) => byId[id]).filter((row) => row !== void 0 && row.id !== sessionId && !team.has(row.id) && row.origin !== "subagent" && row.blank !== true).sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0)).slice(0, OTHER_CHATS_MAX).map((row) => ({ sessionId: row.id, label: row.displayTitle || row.title || row.id, updatedAt: row.updatedAt })); -- return [...bots, ...others]; + }; +- }, [open]); +- const phase = update?.phase ?? "idle"; +- const dsh = update?.dsh; +- const dshPhase = dsh?.phase ?? "idle"; +- const dshRow = showsDshRow(dsh, update?.app); +- if (!update?.available && !dshRow && phase === "idle" && dshPhase === "idle") return null; +- const cloudPhase = phase === "installing" || dshPhase === "installing" ? "installing" : phase !== "idle" ? phase : dshPhase; +- const toggle = () => { +- const rect = button.current?.getBoundingClientRect(); +- if (rect) setBox({ left: Math.max(8, Math.min(rect.left, window.innerWidth - 288)), top: rect.bottom + 6 }); +- setOpen((value) => !value); +- }; +- const start = () => { +- setError(""); +- void actions.installUpdate().catch((failure) => setError(hostText(failure?.message ?? String(failure)))); +- }; +- const startDsh = () => { +- setDshError(""); +- void actions.installDshUpdate().catch((failure) => setDshError(hostText(failure?.message ?? String(failure)))); ++ }); + return { + available: source?.ok === true, + reason: text(source?.reason) || null, @@ -15090,53 +14329,104 @@ diff --git a/client.js b/client.js + running, + empty: source === null, + emptyText: "No metrics read yet." -+ }; - } --function sessionMention(sessionId, label) { -- let binary = ""; -- for (const byte of new TextEncoder().encode(JSON.stringify(sessionId))) binary += String.fromCharCode(byte); -- const payload = btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); -- return `@[${label.replace(/[\\\]]/g, (mark) => `\\${mark}`)}](dsh-session:${payload})`; + }; +- const label = phase === "installing" ? t("Updating DS Bot…") : dshPhase === "installing" ? t("Updating DSH…") : phase === "installed" ? t("DS Bot {version} is installed. Restart DSH to use it.", { version: update.installed ?? update.latest }) : dshPhase === "installed" ? t("Restart DSH to start using DSH {version}.", { version: dsh.installed ?? dsh.verified }) : update?.available ? t("DS Bot {version} is available", { version: update.latest }) : t("DSH {version} is available", { version: dsh?.verified }); +- const message = error || (phase === "failed" ? update.error : ""); +- const dshMessage = dshError || (dshPhase === "failed" ? dsh?.error : ""); +- const dshText = dshPhase === "installed" ? t("Restart DSH to start using DSH {version}.", { version: dsh.installed ?? dsh.verified }) : dsh?.available === false && dsh?.blockedBy ? t("Update DS Bot {version} first.", { version: dsh.blockedBy }) : t("You have {version}.", { version: dsh?.current }); +- return (0, import_react8.createElement)( +- import_react8.Fragment, +- null, +- (0, import_react8.createElement)("button", { +- ref: button, +- type: "button", +- className: "bt-round bt-update", +- "data-phase": cloudPhase, +- "aria-label": label, +- title: label, +- "aria-haspopup": "dialog", +- "aria-expanded": open, +- onClick: toggle +- }, (0, import_react8.createElement)(CloudDownloadIcon, { done: phase === "installed" }), phase === "failed" || dshPhase === "failed" ? (0, import_react8.createElement)("span", { className: "bt-update-dot", "aria-hidden": true }) : null), +- open && box ? (0, import_react_dom3.createPortal)((0, import_react8.createElement)( ++} +function ProjectMetrics({ view }) { + return (0, import_react21.createElement)( + "section", + { className: "bt-project-metrics", "data-available": view.available ? "true" : "false" }, + view.available || view.reason === null ? null : (0, import_react21.createElement)("p", { className: "bt-project-metrics-note" }, t("Metrics unavailable") + ": " + view.reason), + (0, import_react21.createElement)( -+ "div", + "div", +- { className: "bt-update-pop", role: "dialog", "aria-label": update?.available || phase !== "idle" ? t("DS Bot update") : t("DSH update"), style: box }, +- update?.available || phase !== "idle" ? (0, import_react8.createElement)( +- import_react8.Fragment, +- null, +- (0, import_react8.createElement)("div", { className: "bt-update-title" }, phase === "installed" ? t("Update installed") : t("DS Bot {version}", { version: update.latest })), +- (0, import_react8.createElement)("div", { className: "bt-update-text" }, phase === "installed" ? t("Restart DSH to start using DS Bot {version}.", { version: update.installed ?? update.latest }) : t("You have {version}.", { version: update.current })), +- message ? (0, import_react8.createElement)("div", { className: "bt-update-error", role: "alert" }, t("The update did not finish: {reason}", { reason: message })) : null, +- (0, import_react8.createElement)( +- "div", +- { className: "bt-update-actions" }, +- update.notesUrl ? (0, import_react8.createElement)("a", { className: "bt-update-notes", href: update.notesUrl, target: "_blank", rel: "noopener noreferrer" }, t("What's new")) : null, +- phase === "installed" ? null : (0, import_react8.createElement)("button", { +- type: "button", +- className: "bt-update-go", +- disabled: phase === "installing", +- onClick: start +- }, phase === "installing" ? t("Updating…") : phase === "failed" ? t("Try again") : t("Update")) +- ) +- ) : null, +- dshRow ? (0, import_react8.createElement)( + { className: "bt-project-metric-cards" }, + view.cards.map((card) => (0, import_react21.createElement)( -+ "div", + "div", +- { className: "bt-update-dsh" }, +- (0, import_react8.createElement)("div", { className: "bt-update-title" }, t("DSH {version}", { version: dsh.verified })), +- (0, import_react8.createElement)("div", { className: "bt-update-text" }, dshText), +- dshMessage ? (0, import_react8.createElement)("div", { className: "bt-update-error", role: "alert" }, t("The update did not finish: {reason}", { reason: dshMessage })) : null, +- (0, import_react8.createElement)( +- "div", +- { className: "bt-update-actions" }, +- dsh.kind === "app" ? dsh.available ? (0, import_react8.createElement)("button", { type: "button", className: "bt-update-go", onClick: () => void actions.openAppUpdates(dsh.verified).catch(() => { +- }) }, t("Open updates")) : null : !dsh.available && dshPhase === "idle" ? null : dshPhase === "installed" ? null : (0, import_react8.createElement)("button", { +- type: "button", +- className: "bt-update-go", +- disabled: dshPhase === "installing", +- onClick: startDsh +- }, dshPhase === "installing" ? t("Updating…") : dshPhase === "failed" ? t("Try again") : t("Update")) +- ) +- ) : null +- ), document.body) : null + { key: card.key, className: "bt-project-metric", "data-metric": card.key }, + (0, import_react21.createElement)("span", { className: "bt-project-metric-value" }, card.value), + (0, import_react21.createElement)("span", { className: "bt-project-metric-label" }, t(card.label)), + (0, import_react21.createElement)("span", { className: "bt-project-metric-caption" }, t(card.caption)) + )) + ) -+ ); + ); } --function atSources({ roster, sessions, launched, iconFor }) { -- const bots = { -- trigger: "@", -- name: BOTS_SOURCE, -- order: -20, -- showGroupTitle: false, -- async candidates(session, { query }) { -- return (mentionRows(roster.getSnapshot(), session.sessionId, query) ?? []).map((row) => ({ -- name: row.bot ? row.bot.name : t("Everyone"), -- ...row.bot?.role ? { description: row.bot.role } : {}, -- ...row.room ? { description: t("{count} members", { count: row.room.members.length }) } : {}, -- icon: iconFor(row), -- section: t("Bots"), -- value: row.text -- })); -- }, -- onPick: ({ candidate }) => candidate.value ? { text: candidate.value } : void 0 -+function ProjectRunningTable({ view, onOpen }) { -+ if (view.running.length === 0) return (0, import_react21.createElement)("section", { className: "bt-project-running", "data-running": "0" }, (0, import_react21.createElement)("h4", null, t("Running tasks")), (0, import_react21.createElement)("div", { className: "bt-project-empty" }, t("Nothing is running."))); -+ return (0, import_react21.createElement)( -+ "section", -+ { className: "bt-project-running", "data-running": String(view.running.length) }, +-function ConnectPlugins({ wide, actions }) { +- const tipId = (0, import_react8.useId)(); +- const [tip, setTip] = (0, import_react8.useState)(false); +- const timer = (0, import_react8.useRef)(0); +- const button = (0, import_react8.useRef)(null); +- const [box, setBox] = (0, import_react8.useState)(null); +- (0, import_react8.useEffect)(() => () => clearTimeout(timer.current), []); +- if (!wide) return null; +- const show = () => { +- clearTimeout(timer.current); +- timer.current = setTimeout(() => { +- const rect = button.current?.getBoundingClientRect(); +- if (!rect) return; +- const left = Math.max(8, Math.min(rect.left, window.innerWidth - 268)); +- setBox({ left, bottom: window.innerHeight - rect.top + 10 }); +- setTip(true); +- }, 250); ++function ProjectRunningTable({ view, onOpen }) { ++ if (view.running.length === 0) return (0, import_react21.createElement)("section", { className: "bt-project-running", "data-running": "0" }, (0, import_react21.createElement)("h4", null, t("Running tasks")), (0, import_react21.createElement)("div", { className: "bt-project-empty" }, t("Nothing is running."))); ++ return (0, import_react21.createElement)( ++ "section", ++ { className: "bt-project-running", "data-running": String(view.running.length) }, + (0, import_react21.createElement)("h4", null, t("Running tasks")), + (0, import_react21.createElement)( + "div", @@ -15217,50 +14507,62 @@ diff --git a/client.js b/client.js + const settle = (answer) => setOpen((current) => current !== null && current.path === path ? { path, answer } : current); + call4("project-decision", { id: projectId, path }).then(settle, (error) => settle({ ok: false, reason: String(error?.message ?? error) })); }; -- const chats = { -- trigger: "@", -- name: CHATS_SOURCE, -- order: -10, -- showGroupTitle: false, -- async candidates(session) { -- if (!launched(session.sessionId)) return []; -- return chatRows(roster.getSnapshot(), sessions.list.getSnapshot(), session.sessionId).map((row) => ({ -- name: row.bot ? row.bot.name : row.label, -- ...row.updatedAt ? { description: relativeShort(row.updatedAt) } : {}, -- icon: row.bot ? iconFor(row) : "session", -- section: row.bot ? t("Bots") : t("Conversations"), -- value: JSON.stringify({ sessionId: row.sessionId, label: row.label }) -- })); -- }, -- onPick({ candidate }) { -- const { sessionId, label } = JSON.parse(candidate.value); -- const mention = sessionMention(sessionId, label); -- return { insert: { source: CHATS_SOURCE, ref: mention, label, appearance: "session", clipboardText: mention } }; +- const hide = () => { +- clearTimeout(timer.current); +- setTip(false); + const decisionBody = (file) => { + if (open === null || open.path !== file.path) return null; + const answer = open.answer; + if (answer === null) return (0, import_react21.createElement)("div", { className: "bt-project-decision-text", "data-open-decision": file.path }, t("Reading the decision…")); + if (answer.ok !== true) return (0, import_react21.createElement)("div", { className: "bt-project-decision-text bt-project-decision-refusal", "data-open-decision": file.path, "data-refused": "true" }, text(answer.reason) || t("not listed")); + return (0, import_react21.createElement)("pre", { className: "bt-project-decision-text", "data-open-decision": file.path }, typeof answer.text === "string" ? answer.text : ""); -+ }; + }; +- return (0, import_react8.createElement)( +- "span", +- { className: "bt-connect-wrap" }, +- (0, import_react8.createElement)( + const decisionRows = decisions.files.map((file) => (0, import_react21.createElement)( + "div", + { key: file.key, className: "bt-project-decision-wrap" }, + (0, import_react21.createElement)( -+ "button", -+ { -+ type: "button", + "button", + { +- ref: button, + type: "button", +- className: "bt-connect bt-connect-foot", +- "aria-describedby": tip ? tipId : void 0, +- onMouseEnter: show, +- onMouseLeave: hide, +- onFocus: show, +- onBlur: hide, +- onClick: () => actions.selectPanel("plugins") + className: "bt-project-decision", + "data-path": file.path, + "data-sha256": file.sha256, + "data-open": open !== null && open.path === file.path ? "true" : void 0, + "aria-expanded": open !== null && open.path === file.path ? "true" : "false", + onClick: () => toggle(file.path) -+ }, + }, +- (0, import_react8.createElement)(PlugIcon), +- t("Connect plugins"), +- (0, import_react8.createElement)("span", { className: "bt-connect-warn", "aria-hidden": true }, "!") + (0, import_react21.createElement)("span", { className: "bt-project-date" }, file.date), + (0, import_react21.createElement)("span", { className: "bt-project-title" }, file.title === "" ? file.name : file.title), + (0, import_react21.createElement)("span", { className: "bt-project-hash", title: file.sha256 }, file.shortHash) -+ ), + ), +- tip && box ? (0, import_react_dom3.createPortal)((0, import_react8.createElement)( +- "span", +- { role: "tooltip", id: tipId, className: "bt-connect-tip", style: box }, +- t("Plugin compatibility is not fully verified.") +- ), document.body) : null +- ); +-} +-function YouAvatar() { +- return (0, import_react8.createElement)( +- "span", +- { className: "bt-you-wrap" }, +- (0, import_react8.createElement)("span", { className: "bt-you", "aria-hidden": true }, (0, import_react8.createElement)(BotMark, { look: MAIN_LOOK, size: 24 })), +- (0, import_react8.createElement)("span", { style: { position: "absolute", width: 1, height: 1, overflow: "hidden", clip: "rect(0 0 0 0)" } }, t("Settings")) + decisionBody(file) + )); + const recipientText = (list) => list.length === 0 ? null : list.map((name) => `@${name}`).join(", "); @@ -15298,8 +14600,7 @@ diff --git a/client.js b/client.js + "data-decisions": String(decisions.count), + ...decisions.total === null ? {} : { "data-decisions-total": String(decisions.total) }, + "data-meetings": String(meetings.count) - }, -- codec: { clipboardText: (ref) => ref, serialize: (ref) => Promise.resolve(ref) } ++ }, + (0, import_react21.createElement)("h4", null, t("Decisions")), + decisions.empty ? (0, import_react21.createElement)("div", { className: "bt-project-empty bt-project-record-empty" }, t(decisions.emptyText)) : decisionRows, + moreLine(decisions.hidden, "decisions-more"), @@ -15309,8 +14610,15 @@ diff --git a/client.js b/client.js + meetings.pending.length === 0 ? null : (0, import_react21.createElement)("h5", null, t("Not decided")), + meetings.pending.length === 0 ? null : pendingRows, + moreLine(meetings.pendingHidden, "pending-more") -+ ); -+} + ); + } +-var openShellSettings = null; +-var openShellOnboarding = null; +-var shellShortcuts = null; +-function holdShellShortcuts(shortcuts) { +- shellShortcuts = shortcuts; +- return () => { +- if (shellShortcuts === shortcuts) shellShortcuts = null; +var botIdOf = (entry) => entry !== null && typeof entry === "object" ? text(entry.botId) : ""; +var idList = (value) => Array.isArray(value) ? value.map((id) => text(id)).filter((id) => id !== "") : []; +function projectTeamView(project) { @@ -15334,18 +14642,12 @@ diff --git a/client.js b/client.js + role: text(project?.role), + choices: idList(project?.choices) }; -- return [bots, chats]; } -- --// src/client/speech.js --var VOICE_BUNDLE = "@deepseek-ai/dsh-experimental-voice-input-bundle"; --var USABLE = /* @__PURE__ */ new Set(["ready", "standby", "waking"]); --async function speechCatalog(speech) { -- const result = await speech.catalog(); -- if (!result?.ok) throw new Error(result?.error?.message ?? "Speech catalog failed"); -- const catalog = result.value; -- const provider = catalog.providers.find((item) => item.id === catalog.selection.providerId); -- return { catalog, usable: USABLE.has(provider?.preparation?.phase) }; +-function openHarnessSettings() { +- requestAnimationFrame(() => { +- if (openShellSettings) { +- openShellSettings(); +- return; +var teamRow = (role, label, botId, onOpenBot, onOpenDossier) => (0, import_react21.createElement)( + "div", + { @@ -15365,7 +14667,11 @@ diff --git a/client.js b/client.js + onOpenBot?.(botId); + } + } -+ } + } +- const desktop = document.querySelector('button[aria-haspopup="menu"][data-signed-out]'); +- if (desktop) openThroughMenu(desktop); +- else pressSettingsShortcut(); +- }); + }, + (0, import_react21.createElement)("span", { key: "label", className: "bt-project-team-label" }, label), + botId === "" ? (0, import_react21.createElement)("span", { key: "value", className: "bt-project-missing" }, t("Not set")) : (0, import_react21.createElement)("span", { key: "value", className: "bt-project-team-bot" }, `@${botId}`), @@ -15430,29 +14736,11 @@ diff --git a/client.js b/client.js + ) + ); } --function waveBytes(samples, rate = 16e3) { -- const bytes = new Uint8Array(44 + samples.length * 2); -- const view = new DataView(bytes.buffer); -- const ascii = (at, text) => { -- for (let index = 0; index < text.length; index += 1) bytes[at + index] = text.charCodeAt(index); -- }; -- ascii(0, "RIFF"); -- view.setUint32(4, 36 + samples.length * 2, true); -- ascii(8, "WAVEfmt "); -- view.setUint32(16, 16, true); -- view.setUint16(20, 1, true); -- view.setUint16(22, 1, true); -- view.setUint32(24, rate, true); -- view.setUint32(28, rate * 2, true); -- view.setUint16(32, 2, true); -- view.setUint16(34, 16, true); -- ascii(36, "data"); -- view.setUint32(40, samples.length * 2, true); -- samples.forEach((sample, index) => { -- const value = Math.max(-1, Math.min(1, sample)); -- view.setInt16(44 + index * 2, value < 0 ? Math.round(value * 32768) : Math.round(value * 32767), true); +-function openHarnessOnboarding(id) { +- requestAnimationFrame(() => { +- if (openShellOnboarding) openShellOnboarding(id); +- else openHarnessSettings(); - }); -- return bytes; +function dossierRowOf(team, botId) { + const id = text(botId); + if (id === "") return null; @@ -15503,10 +14791,16 @@ diff --git a/client.js b/client.js + ) + ); } --function base64Of(bytes) { -- let text = ""; -- for (let index = 0; index < bytes.length; index += 32768) text += String.fromCharCode(...bytes.subarray(index, index + 32768)); -- return btoa(text); +-function openThroughMenu(trigger) { +- const before = new Set(document.querySelectorAll('[role="menu"]')); +- trigger.click(); +- let frames = 0; +- const look = () => { +- const menu = [...document.querySelectorAll('[role="menu"]')].find((node) => !before.has(node)); +- const item = menu?.querySelector('button[role="menuitem"]'); +- if (item) item.click(); +- else if (++frames < 30) requestAnimationFrame(look); +- else pressSettingsShortcut(); +function projectAgentFor(bot, selfId) { + if (bot === null || typeof bot !== "object" || bot.remote !== void 0) return null; + const projectId = text(bot.projectId); @@ -15531,7 +14825,23 @@ diff --git a/client.js b/client.js + if (target !== "") setAgentId(target); + } + if (target !== "") onOpen?.(target); -+ }; + }; +- requestAnimationFrame(look); +-} +-function pressSettingsShortcut() { +- const binding = shellShortcuts?.catalog.getSnapshot().find((row) => row.id === "settings.open")?.binding; +- if (!binding) return; +- const held = new Set(binding.modifiers); +- window.dispatchEvent(new KeyboardEvent("keydown", { +- code: binding.code, +- key: binding.code === "Comma" ? "," : "", +- bubbles: true, +- cancelable: true, +- ctrlKey: held.has("control"), +- altKey: held.has("alt"), +- shiftKey: held.has("shift"), +- metaKey: held.has("meta") +- })); + if (id === "") return null; + return (0, import_react21.createElement)("button", { + type: "button", @@ -15576,15 +14886,56 @@ diff --git a/client.js b/client.js + (0, import_react21.createElement)(ProjectBoardRecord, { record: view.record, projectId: view.projectId, call: call4 }) + ); } --var captureError = (kind, cause) => Object.assign(new Error(kind), { kind, cause }); --async function openCapture() { -- const devices = navigator.mediaDevices; -- if (!devices?.getUserMedia || typeof MediaRecorder === "undefined") throw captureError("unavailable"); -- let stream; -- try { -- stream = await devices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true }, video: false }); -- } catch (error) { -- throw captureError(error?.name === "NotAllowedError" ? "blocked" : "failed", error); +-function AccountLauncher({ seat = true, settingsOpen, openSettings, openOnboarding, actions }) { +- const [open, setOpen] = (0, import_react8.useState)(false); +- (0, import_react8.useLayoutEffect)(() => { +- if (!seat) return void 0; +- actions.seatAccount(true); +- return () => actions.seatAccount(false); +- }, [seat]); +- const [box, setBox] = (0, import_react8.useState)(null); +- const button = (0, import_react8.useRef)(null); +- (0, import_react8.useEffect)(() => { +- if (settingsOpen) setOpen(false); +- }, [settingsOpen]); +- (0, import_react8.useEffect)(() => { +- if (!openSettings) return void 0; +- openShellSettings = openSettings; +- return () => { +- if (openShellSettings === openSettings) openShellSettings = null; +- }; +- }, [openSettings]); +- (0, import_react8.useEffect)(() => { +- if (!openOnboarding) return void 0; +- openShellOnboarding = openOnboarding; +- return () => { +- if (openShellOnboarding === openOnboarding) openShellOnboarding = null; +- }; +- }, [openOnboarding]); +- (0, import_react8.useEffect)(() => { +- 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; +- setOpen(false); +- }; +- const onKey = (event) => { +- if (event.key === "Escape") { +- event.stopPropagation(); +- setOpen(false); +- button.current?.focus(); +- } +- }; +- window.addEventListener("mousedown", onDown); +- window.addEventListener("keydown", onKey, true); +- return () => { +- window.removeEventListener("mousedown", onDown); +- window.removeEventListener("keydown", onKey, true); +- }; +- }, [open]); +- const toggle = () => { +- const rect = button.current?.getBoundingClientRect(); +- if (rect) setBox({ left: rect.left, bottom: window.innerHeight - rect.top + 5 }); +- setOpen((value) => !value); +function ProjectSettingsPanel({ project, assistantMine, call: request, onClose, onSaved, locked = false, readOnly = "" } = {}) { + const save = request ?? call; + const [kind, setKind] = (0, import_react21.useState)("bot"); @@ -15608,16 +14959,7 @@ diff --git a/client.js b/client.js + (0, import_react21.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => onClose?.() }, t("Back to board")) + ) + ); - } -- const audio = new AudioContext(); -- const analyser = audio.createAnalyser(); -- analyser.fftSize = 512; -- audio.createMediaStreamSource(stream).connect(analyser); -- const frame = new Float32Array(analyser.fftSize); -- const recorder = new MediaRecorder(stream); -- const chunks = []; -- recorder.ondataavailable = (event) => { -- if (event.data.size > 0) chunks.push(event.data); ++ } + const run = async (endpoint, payload, done) => { + try { + const result = await save(endpoint, payload); @@ -15629,15 +14971,24 @@ diff --git a/client.js b/client.js + return null; + } }; -- recorder.start(); -- let closed = false; -- const close = () => { -- if (closed) return; -- closed = true; -- if (recorder.state !== "inactive") recorder.stop(); -- stream.getTracks().forEach((track) => track.stop()); -- void audio.close().catch(() => { -- }); +- const groups = [ +- [ +- [t("Usage"), GaugeIcon, () => actions.openSettings("usage")], +- [t("Connectors"), ConnectorIcon, () => actions.openConnectors()], +- [t("Automation tasks"), ClockIcon, () => actions.selectPanel("schedules")], +- [t("Bot settings"), GearIcon, () => actions.openSettings("general")] +- ], +- [ +- [t("Switch to classic Agent mode"), SwitchIcon, () => actions.setSurface("agent")] +- ] +- ]; +- return (0, import_react8.createElement)( +- import_react8.Fragment, +- null, +- (0, import_react8.createElement)( +- "button", +- { ref: button, type: "button", className: "bt-account", "aria-label": t("Account"), "aria-haspopup": "menu", "aria-expanded": open, onClick: toggle }, +- (0, import_react8.createElement)("span", { className: "bt-you", "aria-hidden": true }, (0, import_react8.createElement)(BotMark, { look: MAIN_LOOK, size: 24 })) + return (0, import_react21.createElement)( + "div", + { className: "bt-project-panel", "data-panel": "project-settings" }, @@ -15670,9 +15021,17 @@ diff --git a/client.js b/client.js + (0, import_react21.createElement)("label", null, (0, import_react21.createElement)("input", { type: "checkbox", "data-action": "assistant-off", checked: assistantOff, onChange: (event) => setAssistantOff(event.target.checked) }), " ", t("Pause the daily digest")), + (0, import_react21.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", "data-action": "project-assistant-save", onClick: () => run("project-assistant-owner", { id, settings: { off: assistantOff === true } }, t("Assistant settings saved.")) }, t("Save")) + ) -+ ), + ), +- open && box ? (0, import_react_dom3.createPortal)((0, import_react8.createElement)( + (0, import_react21.createElement)( -+ "div", + "div", +- { className: "bt-menu bt-menu-up bt-account-menu", role: "menu", "aria-label": t("Account"), style: box }, +- groups.map((group, index) => (0, import_react8.createElement)( +- import_react8.Fragment, +- { key: index }, +- index > 0 ? (0, import_react8.createElement)("div", { className: "bt-menu-sep", role: "separator" }) : null, +- group.map(([label, Icon, run, hint]) => (0, import_react8.createElement)("button", { +- key: label, + { className: "bt-project-field" }, + // The host knows two memory kinds; the free text the panel takes is recorded as a decision, + // never as a third kind the host would refuse (#1820). @@ -15683,16 +15042,20 @@ diff --git a/client.js b/client.js + "div", + { className: "bt-project-row-actions" }, + (0, import_react21.createElement)("button", { -+ type: "button", + type: "button", +- role: "menuitem", + className: "bt-soft bt-soft-sm", + "data-action": "project-memory-save", + disabled: noteSaveDisabled, + // A disabled button draws no click, but the guard is what a keyboard or a stale tree + // could still reach: neither state sends a request or writes anything. -+ onClick: () => { + onClick: () => { +- setOpen(false); +- void Promise.resolve(run()).catch((error) => actions.reportError(error, "menu")); + if (noteSaveDisabled) return; + run("project-memory-write", { id, kind: "decision", text: text(note) }, t("Memory saved.")); -+ } + } +- }, (0, import_react8.createElement)(Icon), (0, import_react8.createElement)("span", { className: "bt-menu-label" }, label), hint ? (0, import_react8.createElement)("span", { className: "bt-menu-hint" }, hint) : null)) + }, t("Save memory")) + ) + ), @@ -15709,7 +15072,7 @@ diff --git a/client.js b/client.js + const add = (id) => { + const clean = text(id); + if (clean !== "" && !owners.includes(clean)) owners.push(clean); - }; ++ }; + add(project?.ownerUserId); + for (const id of Array.isArray(project?.ownerUserIds) ? project.ownerUserIds : []) add(id); + const boardMembers = []; @@ -15742,7 +15105,8 @@ diff --git a/client.js b/client.js + { key: `board:${name}`, className: "bt-project-member", "data-member-role": "board", "data-member-id": name }, + (0, import_react21.createElement)("span", null, `@${name}`), + (0, import_react21.createElement)("span", { className: "bt-project-member-kind" }, t("On the board")) -+ )) + )) +- ), document.body) : null + ), + owners.length === 0 && boardMembers.length === 0 && chief === "" ? (0, import_react21.createElement)("div", { className: "bt-project-empty" }, t("No member is named yet.")) : null, + (0, import_react21.createElement)("p", { className: "bt-project-note" }, t("Member management is not part of this slice; owners and the members their cards name are listed here.")), @@ -15751,8 +15115,21 @@ diff --git a/client.js b/client.js + { className: "bt-project-row-actions" }, + (0, import_react21.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => onClose?.() }, t("Back to board")) + ) -+ ); -+} + ); + } +-function useLandOnMainBot(roster, useSessions, actions) { +- const mainView = useSessions((list) => { +- const row = Object.values(list.byId).find((session) => (session?.retainedBy?.mainView ?? 0) > 0); +- return row ? `${row.id}|${row.blank ? 1 : 0}` : ""; +- }); +- const lastBot = (0, import_react8.useRef)(null); +- (0, import_react8.useEffect)(() => { +- if (!roster.ready || !roster.mainBotId) return void 0; +- const [id, blank3] = mainView.split("|"); +- const bot = id === "" ? void 0 : roster.byId[id]; +- if (bot !== void 0) { +- lastBot.current = { botId: bot.id, sessionId: id }; +- if (currentPart(bot) !== id) actions.landOn(bot.id); +async function readProjectBoard(read, wanted, knownTeam) { + const listing = await read("projects"); + const projects = Array.isArray(listing?.projects) ? listing.projects : []; @@ -15775,20 +15152,7 @@ diff --git a/client.js b/client.js + const queues = { waiting: board?.waiting, inflight: board?.inflight, blocked: board?.blocked, counts: board?.counts ?? null }; + const base = record?.project ?? project; + const withRole = { ...base, role: project.role ?? null, choices: Array.isArray(project.choices) ? project.choices : [] }; - return { -- level() { -- if (closed) return 0; -- analyser.getFloatTimeDomainData(frame); -- let sum = 0; -- for (const sample of frame) sum += sample * sample; -- return Math.min(1, Math.sqrt(sum / frame.length) * 4); -- }, -- cancel: close, -- async finish(maxSeconds) { -- if (closed || recorder.state === "inactive") { -- close(); -- return null; -- } ++ return { + project: withRole, + assistantMine: record?.assistantMine ?? null, + rows: board?.tasks ?? [], @@ -15841,33 +15205,19 @@ diff --git a/client.js b/client.js + if (typeof window === "undefined") return void 0; + if (!hash.startsWith("#project")) { + setState(null); -+ return void 0; -+ } -+ const wanted = hash.slice("#project".length).replace(/^=/, ""); -+ let gone = false; + return void 0; + } +- if (id !== "" && (roster.roomsById[id] || (actions.isAgentSession?.(id) ?? roster.agentOf[id] !== void 0) || blank3 !== "1")) { +- lastBot.current = null; +- return void 0; ++ const wanted = hash.slice("#project".length).replace(/^=/, ""); ++ let gone = false; + team.current = void 0; + teamGen.current = 0; + const loadBoard = () => readProjectBoard(read, wanted, team.current); + const refresh = async (first) => { + const gen = teamGen.current; - try { -- await new Promise((resolve) => { -- recorder.onstop = resolve; -- recorder.stop(); -- }); -- stream.getTracks().forEach((track) => track.stop()); -- const blob = new Blob(chunks, { type: recorder.mimeType }); -- if (blob.size === 0) return null; -- const decoded = await audio.decodeAudioData(await blob.arrayBuffer()); -- const length = Math.max(1, Math.floor(Math.min(decoded.duration, maxSeconds) * 16e3)); -- const offline = new OfflineAudioContext(1, length, 16e3); -- const source = offline.createBufferSource(); -- source.buffer = decoded; -- source.connect(offline.destination); -- source.start(); -- return waveBytes((await offline.startRendering()).getChannelData(0)); -- } finally { -- close(); ++ try { + const next = await loadBoard(); + if (!gone) { + const newer = teamGen.current !== gen; @@ -15876,7 +15226,7 @@ diff --git a/client.js b/client.js + } + } catch { + if (!gone && first) setState({ project: null, rows: [], canManage: false, tasks: [] }); - } ++ } + }; + void refresh(true); + const timer = setInterval(() => { @@ -15901,7 +15251,25 @@ diff --git a/client.js b/client.js + setState((previous) => previous === null ? previous : { ...previous, team: answer }); + } catch { } - }; +- const last = lastBot.current; +- const timer = setTimeout(() => actions.landOn(roster.mainBotId, last), last ? 0 : 400); +- return () => clearTimeout(timer); +- }, [mainView, roster]); +-} +-function FooterAccount(props) { +- const seated = props.useUi((value) => value.accountSeated === true); +- if (seated) return null; +- return (0, import_react8.createElement)("span", { className: "bt-foot-account" }, (0, import_react8.createElement)(AccountLauncher, { ...props, seat: false })); +-} +-function BotReturn({ wide, useSurface, actions }) { +- const mode = useSurface((value) => value.mode); +- if (mode !== "agent") return null; +- return (0, import_react8.createElement)( +- "button", +- { type: "button", className: "bt-return", "aria-label": t("DS Bot"), title: t("DS Bot"), onClick: () => actions.setSurface("bot") }, +- (0, import_react8.createElement)(BotMark, { look: MAIN_LOOK, size: 20 }), +- wide ? (0, import_react8.createElement)("span", { className: "bt-return-name" }, t("DS Bot")) : null ++ }; + if (state === null) return null; + return (0, import_react21.createElement)( + "div", @@ -15971,122 +15339,199 @@ diff --git a/client.js b/client.js + }) + ) + ) -+ ); + ); } --async function transcribeWave(speech, catalog, wave, signal) { -- if (wave.byteLength > catalog.maxAudioBytes) throw captureError("too-long"); -- const result = await speech.transcribe({ audioBase64: base64Of(wave), ...catalog.selection }, signal); -- if (!result?.ok) throw Object.assign(new Error(result?.error?.message ?? "Transcription failed"), { kind: "service" }); -- return String(result.value?.text ?? "").trim(); -+ -+// src/client/header.js +-var RETURN_CSS = ` +-.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)} +-.bt-return .bt-mark{display:inline-flex;flex:none;line-height:0} +-.bt-return .bt-mark svg{width:100%;height:100%;overflow:visible} +-/* Shell styles carry --bt-ink-deepseek and Agent mode withdraws them; pin it here. */ +-.bt-return .bt-mark{--bt-ink-deepseek:#4D6BFE} +-.bt-return-name{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +-`; +-function BrandMark({ size = 24 }) { +- return (0, import_react8.createElement)("span", { className: "bt-brand-whale" }, (0, import_react8.createElement)(BotMark, { look: MAIN_LOOK, size })); +-} +-function BrandName() { +- return (0, import_react8.createElement)("span", { style: { fontWeight: 600, fontSize: 14 } }, "DS Bot"); +-} +-var SIDEBAR_CSS = ` +-.bt-side-top .bt-brand{cursor:default;padding-left:6px} +-.bt-side-top .bt-brand:hover{background:none} +-.bt-brand .bt-mark{flex:none} +-.bt-pin{flex:none;display:inline-flex;color:var(--bt-ink-3)} +-.bt-rail .bt-row{position:relative} +-.bt-rail .bt-pin{position:absolute;top:3px;left:calc(50% + 12px)} +-.bt-side .bt-read-only{margin:4px 8px 6px;padding:8px 10px;border-radius:10px;background:var(--bt-hover);font-size:12px;line-height:1.45;text-align:left} +-[class*="_footArea"]:has(.bt-foot-account){display:flex!important;flex-direction:row!important;align-items:center;gap:6px} +-[class*="_footArea"]:has(.bt-foot-account)>[class*="_footerActions"],[class*="_footArea"]:has(.bt-foot-account)>[class*="_footerActions"]>div{display:contents!important} +-.bt-foot-account{order:-1;flex:none;display:inline-flex} +-[class*="_footArea"]:has(.bt-foot-account) .bt-connect-foot{order:0;flex:1 1 auto;min-width:0;width:auto} +-[class*="_footArea"]:has(.bt-foot-account)>[class*="_settingsArea"]{order:1;flex:none} +-[class*="_footArea"]:has(.bt-foot-account)>[class*="_settingsArea"] button[class*="_trigger"] [class*="_label"]{display:none!important} +- +-/* The update cloud springs in when a newer DS Bot shows up; its arrow drops in a +- loop while the install runs. */ +-.bt-round.bt-update{position:relative;color:var(--bt-accent,#4D6BFE);animation:bt-update-in ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing}} +-.bt-round.bt-update svg{width:17px;height:17px} +-.bt-round.bt-update[data-phase=installed]{color:var(--bt-ink-2)} +-@keyframes bt-update-in{from{opacity:0;transform:scale(.6);filter:blur(4px)}} +-.bt-update[data-phase=installing] .bt-cloud-arrow{animation:bt-cloud-drop 1.1s cubic-bezier(.45,0,.55,1) infinite} +-@keyframes bt-cloud-drop{0%{transform:translateY(-3px);opacity:0}35%{opacity:1}70%{transform:translateY(1px);opacity:1}100%{transform:translateY(2px);opacity:0}} +-.bt-update-dot{position:absolute;top:6px;right:6px;width:7px;height:7px;border-radius:50%;background:#E5484D;box-shadow:0 0 0 1.5px var(--bt-sidebar)} +-.bt-update-pop{position:fixed;z-index:60;width:280px;padding:14px 14px 12px;border-radius:14px;background:var(--bt-main);border:.5px solid var(--bt-line-2);box-shadow:0 10px 20px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1);color:var(--bt-ink);box-sizing:border-box;transform-origin:top left;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;animation:bt-update-pop ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing}} +-@keyframes bt-update-pop{from{opacity:0;transform:scale(.96);filter:blur(4px)}} +-.bt-update-title{font-size:14px;line-height:20px;font-weight:600} +-.bt-update-text{margin-top:2px;font-size:12.5px;line-height:18px;color:var(--bt-ink-2)} +-.bt-update-error{margin-top:8px;font-size:12px;line-height:17px;color:#c21d2e;overflow-wrap:anywhere;max-height:90px;overflow:auto} +-.bt-update-actions{display:flex;align-items:center;justify-content:flex-end;gap:10px;margin-top:12px} +-.bt-update-notes{margin-right:auto;font-size:12.5px;color:var(--bt-ink-2);text-decoration:none} +-.bt-update-notes:hover{color:var(--bt-ink);text-decoration:underline} +-.bt-update-go{height:30px;padding:0 14px;border:0;border-radius:999px;background:var(--bt-accent,#4D6BFE);color:var(--bt-accent-ink,#fff);font:inherit;font-size:13px;font-weight:500;cursor:pointer;transition:opacity .12s ease,transform .12s ease} +-.bt-update-go:hover:not(:disabled){opacity:.9} +-.bt-update-go:active:not(:disabled){transform:scale(.96)} +-.bt-update-go:disabled{opacity:.6;cursor:default} +-.bt-update-dsh:not(:first-child){margin-top:12px;padding-top:12px;border-top:.5px solid var(--bt-line-2)} +-@media (prefers-reduced-motion:reduce){.bt-round.bt-update,.bt-update-pop,.bt-update .bt-cloud-arrow{animation:none}} +- +-/* The DSH Agent row and its expanding Session list. The black whale is the +- harness's own mark, so the row reads as "plain dsh" next to the Bot marks. */ +-.bt-agent-ava{flex:none;width:36px;height:36px;border-radius:50%;display:inline-flex;align-items:center;justify-content:center;background:var(--bt-hover);color:var(--bt-ink)} +-.bt-agent-ava.bt-run{animation:bt-agent-pulse 1.4s ease-in-out infinite} +-@keyframes bt-agent-pulse{0%,100%{opacity:1}50%{opacity:.55}} +-.bt-agent-sub{display:grid;grid-template-rows:0fr;transition:grid-template-rows ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing}} +-.bt-agent-sub[data-open]{grid-template-rows:1fr} +-.bt-agent-sub-in{overflow:hidden;min-height:0;margin-left:44px} +-.bt-agent-sub-row{display:flex;align-items:center;gap:8px;width:100%;padding:5px 10px 5px 8px;border:0;border-radius:8px;background:none;color:var(--bt-ink-2);font:inherit;font-size:13px;line-height:20px;text-align:left;cursor:pointer} +-.bt-agent-sub-row:hover{background:var(--bt-hover);color:var(--bt-ink)} +-.bt-agent-sub-row[aria-current=page]{background:var(--bt-hover);color:var(--bt-ink);font-weight:500} +-.bt-agent-sub-title{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +-.bt-agent-sub-time{flex:none;font-size:11px;color:var(--bt-ink-3)} +-.bt-agent-new{color:var(--bt-ink-3)} +-.bt-agent-new svg{width:14px;height:14px;flex:none} +-.bt-agent-edit{padding:3px 10px 3px 4px} +-.bt-agent-edit input{width:100%;border:0;border-radius:6px;background:var(--bt-hover);color:var(--bt-ink);font:inherit;font-size:13px;line-height:20px;padding:2px 8px;outline:none;box-shadow:inset 0 0 0 .5px var(--bt-line-2)} +-.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}} +-`; + + // src/client/header.js +-var import_react9 = require("react"); +-var import_react_dom4 = require("react-dom"); +-var GoArrow = () => (0, import_react9.createElement)("svg", { width: 14, height: 14, viewBox: "-12 -12 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2.2, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, (0, import_react9.createElement)("path", { d: "M-7 0H7M2 -5L7 0L2 5" })); +var import_react22 = require("react"); +var import_react_dom6 = require("react-dom"); +var GoArrow = () => (0, import_react22.createElement)("svg", { width: 14, height: 14, viewBox: "-12 -12 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2.2, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, (0, import_react22.createElement)("path", { d: "M-7 0H7M2 -5L7 0L2 5" })); -+var MEMORY_NOTICE_MS = 3200; -+var noNews = (select) => select({}); -+function useMemoryNotice(ids, useMemoryNews) { -+ const seen = useMemoryNews((map) => Math.max(0, ...ids.map((id) => map?.[id]?.seen ?? 0))); + var MEMORY_NOTICE_MS = 3200; + var noNews = (select) => select({}); + function useMemoryNotice(ids, useMemoryNews) { + const seen = useMemoryNews((map) => Math.max(0, ...ids.map((id) => map?.[id]?.seen ?? 0))); +- const [shown, setShown] = (0, import_react9.useState)(false); +- (0, import_react9.useEffect)(() => { + const [shown, setShown] = (0, import_react22.useState)(false); + (0, import_react22.useEffect)(() => { -+ const left = seen + MEMORY_NOTICE_MS - Date.now(); -+ setShown(seen > 0 && left > 0); -+ if (seen === 0 || left <= 0) return void 0; -+ const timer = setTimeout(() => setShown(false), left); -+ return () => clearTimeout(timer); -+ }, [seen]); -+ return shown; + const left = seen + MEMORY_NOTICE_MS - Date.now(); + setShown(seen > 0 && left > 0); + if (seen === 0 || left <= 0) return void 0; +@@ -5512,19 +5512,19 @@ function useMemoryNotice(ids, useMemoryNews) { + return shown; } --function turnDetector({ loud = 0.12, quiet = 0.06, quietMs = 1200 } = {}) { -- let spokeAt = 0; -- let quietSince = 0; -- return (level, now) => { -- if (level >= loud) { -- spokeAt ||= now; -- quietSince = 0; -- return "speech"; -- } -- if (!spokeAt) return "waiting"; -- if (level < quiet) quietSince ||= now; -- return quietSince && now - quietSince >= quietMs ? "done" : "speech"; -- }; -+function MemoryNotice({ shown }) { + function MemoryNotice({ shown }) { +- const inner = (0, import_react9.useRef)(null); +- const [width, setWidth] = (0, import_react9.useState)(0); + const inner = (0, import_react22.useRef)(null); + const [width, setWidth] = (0, import_react22.useState)(0); -+ const label = t("Memory updated"); + const label = t("Memory updated"); +- (0, import_react9.useLayoutEffect)(() => { + (0, import_react22.useLayoutEffect)(() => { -+ if (shown) setWidth(inner.current?.scrollWidth ?? 0); -+ }, [label, shown]); + if (shown) setWidth(inner.current?.scrollWidth ?? 0); + }, [label, shown]); +- return (0, import_react9.createElement)( + return (0, import_react22.createElement)( -+ "span", -+ { className: "bt-pill-mem", "aria-hidden": true, style: { "--bt-mem-w": `${width}px` } }, + "span", + { className: "bt-pill-mem", "aria-hidden": true, style: { "--bt-mem-w": `${width}px` } }, +- (0, import_react9.createElement)("span", { className: "bt-pill-mem-in", ref: inner }, (0, import_react9.createElement)("span", { className: "bt-pill-mem-ico" }, (0, import_react9.createElement)(MemoryIcon)), label) + (0, import_react22.createElement)("span", { className: "bt-pill-mem-in", ref: inner }, (0, import_react22.createElement)("span", { className: "bt-pill-mem-ico" }, (0, import_react22.createElement)(MemoryIcon)), label) -+ ); + ); } -- --// src/client/overlay-hooks.js --var import_react11 = require("react"); --var escapeLayers = []; --var onEscapeKey = (event) => { -- if (event.key === "Escape") escapeLayers.at(-1)?.current(); --}; --function useEscape(onClose) { -- const ref = (0, import_react11.useRef)(onClose); -- ref.current = onClose; -- (0, import_react11.useEffect)(() => { -- if (escapeLayers.length === 0) window.addEventListener("keydown", onEscapeKey); -- escapeLayers.push(ref); +-function HeaderPill({ sessionId, useRoster, useSessions, useSessionStatus, useSession, useActivity, useMemoryNews, useTurnClock, useUi, useLive, actions }) { +function HeaderPill({ sessionId, useRoster, useSessions, useSessionStatus, useSession, useActivity, useBusyAcks, useMemoryNews, useTurnClock, useUi, useLive, actions }) { -+ const roster = useRoster((value) => value); -+ const title = useSessions((list) => list.byId[sessionId]?.displayTitle ?? list.byId[sessionId]?.title ?? ""); -+ const firstTurns = useSessions((list) => { -+ const outline = list.projectionsBySession?.[sessionId]?.values?.turnOutline ?? list.byId[sessionId]?.projectionValues?.turnOutline; -+ return Array.isArray(outline) ? outline.slice(0, 8).map((entry) => entry.turn).join(",") : ""; -+ }); -+ const hiddenTurns = roster.exchangeTurns?.[sessionId]; -+ const visit = useUi((state) => state.visits?.[sessionId]); -+ const opening = useTurnClock((state) => { -+ const first = openingTurn(state[sessionId], firstTurns, hiddenTurns); -+ return first && !unseenSince(visit, first.start) ? clockLabel(first.start) : ""; -+ }); + const roster = useRoster((value) => value); + const title = useSessions((list) => list.byId[sessionId]?.displayTitle ?? list.byId[sessionId]?.title ?? ""); + const firstTurns = useSessions((list) => { +@@ -5537,7 +5537,7 @@ function HeaderPill({ sessionId, useRoster, useSessions, useSessionStatus, useSe + const first = openingTurn(state[sessionId], firstTurns, hiddenTurns); + return first && !unseenSince(visit, first.start) ? clockLabel(first.start) : ""; + }); +- (0, import_react9.useEffect)(() => { + (0, import_react22.useEffect)(() => { -+ 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"); -+ }, [opening]); -+ const running = useSessionStatus((map) => map.get(sessionId)?.running === true); -+ const owner = roster.byId[sessionId]?.id ?? sessionId; -+ const doing = useActivity((map) => map[owner]); -+ const bot = roster.byId[sessionId]; + 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"); +@@ -5546,54 +5546,49 @@ function HeaderPill({ sessionId, useRoster, useSessions, useSessionStatus, useSe + const owner = roster.byId[sessionId]?.id ?? sessionId; + const doing = useActivity((map) => map[owner]); + const bot = roster.byId[sessionId]; + const projectAgent = projectAgentFor(bot, roster.self?.memberId); -+ const room2 = roster.roomsById[sessionId]; -+ const detailsOpen = useUi((state) => state.details === sessionId); + const room2 = roster.roomsById[sessionId]; + const detailsOpen = useUi((state) => state.details === sessionId); +- const name = bot?.name ?? room2?.name ?? ""; + const full2 = bot?.remote ? friendBotTitle(bot) : roomTitle(room2) || bot?.name || ""; + const face = room2?.origin === "face-to-face"; + const name = face ? faceRowTitle(full2) : full2; -+ const useNews = useMemoryNews ?? noNews; -+ const memoryIds = bot ? [bot.id] : room2?.members ?? []; -+ const notice = useMemoryNotice(memoryIds, useNews); + const useNews = useMemoryNews ?? noNews; + const memoryIds = bot ? [bot.id] : room2?.members ?? []; + const notice = useMemoryNotice(memoryIds, useNews); +- const changed = useNews((map) => latestMemoryChange(map, memoryIds)); +- (0, import_react9.useEffect)(() => { +- document.body.style.setProperty("--bt-placeholder", JSON.stringify(name ? t("Message {name}", { name }) : t("Message"))); +- }, [name, t("Message")]); +- (0, import_react9.useEffect)(() => { +- actions.beginVisit(sessionId); +- return () => actions.endVisit(sessionId); +- }, [sessionId]); +- (0, import_react9.useEffect)(() => { +- if (room2) document.body.dataset.btRoom = ""; +- else delete document.body.dataset.btRoom; +- return () => { +- delete document.body.dataset.btRoom; +- }; +- }, [Boolean(room2)]); +- const memoryNotice = notice && changed !== null && roster.memory !== false; +- return (0, import_react9.createElement)( + const changed2 = useNews((map) => latestMemoryChange(map, memoryIds)); + const memoryNotice = notice && changed2 !== null && roster.memory !== false; + return (0, import_react22.createElement)( -+ "div", -+ { className: "bt-head" }, + "div", + { className: "bt-head" }, +- (0, import_react9.createElement)( + (0, import_react22.createElement)( -+ "button", -+ { -+ type: "button", -+ className: "bt-pill", -+ "aria-expanded": detailsOpen, -+ "data-memory": memoryNotice ? "updated" : void 0, + "button", + { + type: "button", + className: "bt-pill", + "aria-expanded": detailsOpen, + "data-memory": memoryNotice ? "updated" : void 0, +- "aria-label": memoryNotice ? t("Memory updated. Open {name}'s memory", { name: roster.byId[changed]?.name ?? name }) : t("View conversation details"), +- onClick: () => memoryNotice ? actions.openMemory(changed) : actions.toggleDetails(sessionId) + "aria-label": memoryNotice ? t("Memory updated. Open {name}'s memory", { name: roster.byId[changed2]?.name ?? full2 }) : t("View conversation details"), + onClick: () => memoryNotice ? actions.openMemory(changed2) : actions.toggleDetails(sessionId) -+ }, + }, +- bot ? (0, import_react9.createElement)(BotAvatar, { bot, size: 24, state: botState(running, doing, roster.questions?.[currentPart(bot)] !== void 0), badge: false }) : room2 ? (0, import_react9.createElement)(RoomAvatar, { room: room2, roster, size: 24 }) : null, +- (0, import_react9.createElement)("span", { className: "bt-pill-name" }, bot?.name ?? room2?.name ?? (title || t("New chat"))), +- (0, import_react9.createElement)(MemoryNotice, { shown: memoryNotice }), +- (0, import_react9.createElement)("span", { className: "bt-pill-go", "aria-hidden": true }, (0, import_react9.createElement)(GoArrow)) + bot ? (0, import_react22.createElement)(BotAvatar, { bot, size: 24, state: botState(running, doing, roster.questions?.[currentPart(bot)] !== void 0), badge: false }) : room2 ? (0, import_react22.createElement)(RoomAvatar, { room: room2, roster, size: 24 }) : null, + (0, import_react22.createElement)("span", { className: face ? "bt-pill-name bt-pill-name-face" : "bt-pill-name", title: face ? full2 : void 0 }, name || title || t("New chat")), + (0, import_react22.createElement)(MemoryNotice, { shown: memoryNotice }), + (0, import_react22.createElement)("span", { className: "bt-pill-go", "aria-hidden": true }, (0, import_react22.createElement)(GoArrow)) -+ ), + ), +- (0, import_react9.createElement)("span", { className: "bt-sr", role: "status" }, memoryNotice ? t("Memory updated") : ""), +- (0, import_react9.createElement)(NewMessagePills, { key: sessionId, sessionId, useSessions, useUi, useTurnClock }), +- (0, import_react9.createElement)(ActivityIndicator, { key: `activity:${sessionId}`, sessionId, useRoster, useSessionStatus, useSession, useLive, actions }) + (0, import_react22.createElement)("span", { className: "bt-sr", role: "status" }, memoryNotice ? t("Memory updated") : ""), + projectAgent === null ? null : (0, import_react22.createElement)(ProjectAgentEntry, { + projectId: projectAgent.projectId, @@ -16096,364 +15541,100 @@ diff --git a/client.js b/client.js + (0, import_react22.createElement)(NewMessagePills, { key: sessionId, sessionId, hiddenTurns, useSessions, useUi, useTurnClock, actions }), + (0, import_react22.createElement)(ActivityIndicator, { key: `activity:${sessionId}`, sessionId, useRoster, useSessionStatus, useSession, useLive, actions }), + (0, import_react22.createElement)(BusyAckBubble, { key: `busy-ack:${sessionId}`, sessionId, useRoster, useBusyAcks, useSessionStatus, useSession, useLive }) -+ ); -+} -+function useTranscriptView() { + ); + } + function useTranscriptView() { +- const [view, setView] = (0, import_react9.useState)(null); +- (0, import_react9.useEffect)(() => { + const [view, setView] = (0, import_react22.useState)(null); + (0, import_react22.useEffect)(() => { -+ let frame = 0; -+ let watched = null; -+ const observer = new MutationObserver(() => schedule()); -+ const measure = () => { -+ frame = 0; -+ const scroller = document.querySelector("[data-conversation-scroll]"); -+ if (scroller !== watched) { -+ observer.disconnect(); -+ watched = scroller; -+ if (scroller) observer.observe(scroller, { childList: true, subtree: true }); -+ } -+ if (!scroller) { -+ setView(null); -+ return; -+ } -+ const box = scroller.getBoundingClientRect(); -+ const headerBottom = document.querySelector('header[class*="_header"]')?.getBoundingClientRect().bottom ?? box.top + 50; -+ const sep = scroller.querySelector(".bt-new-sep"); -+ let divider = null; -+ if (sep) { -+ const at = sep.getBoundingClientRect(); -+ divider = at.bottom <= headerBottom ? "above" : at.top >= box.bottom ? "below" : "in-view"; -+ } -+ const composer = parseFloat(getComputedStyle(scroller).getPropertyValue("--dsh-composer-height")) || 152; -+ const next = { -+ unpinned: scroller.querySelector('[class*="_toBottomSlot"]>button') !== null, -+ divider, -+ center: Math.round(box.left + box.width / 2), -+ top: Math.round(headerBottom + 8), -+ bottom: Math.round(window.innerHeight - box.bottom + composer + 8) -+ }; -+ setView((prev) => prev && Object.keys(next).every((key) => prev[key] === next[key]) ? prev : next); -+ }; -+ const schedule = () => { -+ if (!frame) frame = requestAnimationFrame(measure); -+ }; -+ document.addEventListener("scroll", schedule, true); -+ window.addEventListener("resize", schedule); -+ schedule(); - return () => { -- escapeLayers.splice(escapeLayers.indexOf(ref), 1); -- if (escapeLayers.length === 0) window.removeEventListener("keydown", onEscapeKey); -+ cancelAnimationFrame(frame); -+ observer.disconnect(); -+ document.removeEventListener("scroll", schedule, true); -+ window.removeEventListener("resize", schedule); - }; + let frame = 0; + let watched = null; + const observer = new MutationObserver(() => schedule()); +@@ -5642,7 +5642,7 @@ function useTranscriptView() { }, []); -+ return view; + return view; } --function usePaneLeft() { -- const [paneLeft, setPaneLeft] = (0, import_react11.useState)(280); -- (0, import_react11.useLayoutEffect)(() => { -- const column = document.querySelector('[class*="_footArea"]')?.parentElement; -- if (column) setPaneLeft(Math.round(column.getBoundingClientRect().right)); -- }, []); -- return paneLeft; --} -- --// src/client/palette.js --var import_react12 = require("react"); --var import_dsh_client_ui_primitives3 = require("@deepseek-ai/dsh-client-ui-primitives"); --var PALETTE_EXIT_MS = 160; --function CommandPalette({ start, token, roster, actions, useSessions }) { -- const [query, setQuery] = (0, import_react12.useState)(start ?? ""); -- const [cursor, setCursor] = (0, import_react12.useState)(0); -- const [leaving, setLeaving] = (0, import_react12.useState)(false); -- const wanted = query.trim().toLowerCase(); -- const close = () => { -- if (leaving) return; -- setLeaving(true); -- setTimeout(() => actions.closePalette(token), PALETTE_EXIT_MS); -- }; -- useEscape(close); -- const ids = [...roster.bots.map(currentPart), ...roster.rooms.map((item) => item.id)]; -- const found = useSessions((list) => { -- if (wanted.length < 2) return "[]"; -- const hits = []; -- for (const id of ids) { -- const outline = list.projectionsBySession?.[id]?.values?.turnOutline ?? list.byId[id]?.projectionValues?.turnOutline; -- if (!Array.isArray(outline)) continue; -- const hidden = roster.exchangeTurns[id] ?? []; -- for (let index2 = outline.length - 1; index2 >= 0 && hits.length < 30; index2 -= 1) { -- const entry = outline[index2]; -- if (hidden.includes(entry.turn)) continue; -- for (const raw of [entry.response, entry.prompt]) { -- const text = stripHeader(raw ?? "").replace(/\s+/g, " ").trim(); -- const at = text.toLowerCase().indexOf(wanted); -- if (at < 0) continue; -- const from = Math.max(0, at - 24); -- hits.push([id, entry.turn, (from > 0 ? "…" : "") + text.slice(from, at + wanted.length + 60)]); -- break; -- } -- } +-function NewMessagePills({ sessionId, useSessions, useUi, useTurnClock }) { +function NewMessagePills({ sessionId, hiddenTurns, useSessions, useUi, useTurnClock, actions }) { -+ 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; -+ }); -+ const visit = useUi((state) => state.visits?.[sessionId]); -+ const unread = useTurnClock((state) => { -+ if (typeof visit?.seen !== "number") return 0; -+ let count = 0; -+ for (const entry of Object.values(state[sessionId] ?? {})) { -+ if (entry.first !== void 0 && entry.first > visit.seen && entry.first <= visit.entered) count += 1; - } -- return JSON.stringify(hits); -+ return count; + 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 }) { }); -- const matchesName = (...fields) => wanted === "" || fields.some((field) => (field ?? "").toLowerCase().includes(wanted)); -- const sections = []; -- const people = [ -- ...roster.bots.filter((bot) => matchesName(bot.name, bot.role)).map((bot) => ({ -- key: bot.id, -- avatar: (0, import_react12.createElement)(BotAvatar, { bot, size: 24, badge: false, live: false }), -- title: bot.name, -- subtitle: [bot.role, isMainOf(roster, bot.id) ? t("Main Bot") : ""].filter(Boolean).join(" · "), -- badge: bot.hidden ? t("Hidden") : void 0, -- run: async () => { -- if (bot.hidden) await actions.setFlags(bot.id, { hidden: false }); -- actions.openSession(bot.id); -- } -- })), -- ...roster.rooms.filter((room2) => matchesName(room2.name)).map((room2) => ({ -- key: room2.id, -- avatar: (0, import_react12.createElement)(RoomAvatar, { room: room2, roster, size: 24 }), -- title: room2.name, -- subtitle: t("{count} members", { count: room2.members.length }), -- badge: room2.hidden ? t("Hidden") : void 0, -- run: async () => { -- if (room2.hidden) await actions.setFlags(room2.id, { hidden: false }); -- actions.openSession(room2.id); -+ const view = useTranscriptView(); -+ const unpinned = view?.unpinned === true; + const view = useTranscriptView(); + const unpinned = view?.unpinned === true; +- const [release, setRelease] = (0, import_react9.useState)({ base: null, dismissed: false }); +- const [above, setAbove] = (0, import_react9.useState)({ seen: false, dismissed: false }); +- (0, import_react9.useEffect)(() => { + const [release, setRelease] = (0, import_react22.useState)({ base: null, dismissed: false }); + const [above, setAbove] = (0, import_react22.useState)({ seen: false, dismissed: false }); + (0, import_react22.useEffect)(() => { -+ setRelease({ base: unpinned ? replies : null, dismissed: false }); -+ }, [unpinned]); + setRelease({ base: unpinned ? replies : null, dismissed: false }); + }, [unpinned]); +- (0, import_react9.useEffect)(() => { + (0, import_react22.useEffect)(() => { -+ if (view?.divider === "in-view") setAbove((state) => state.seen ? state : { ...state, seen: true }); -+ }, [view?.divider]); + if (view?.divider === "in-view") setAbove((state) => state.seen ? state : { ...state, seen: true }); + }, [view?.divider]); + const movedOn = useTurnClock((state) => movedOnSince(visit, state[sessionId], hiddenTurns)); + (0, import_react22.useEffect)(() => { + if (above.seen && movedOn) actions?.settleVisit?.(sessionId); + }, [above.seen, movedOn, sessionId]); -+ const fresh = release.base === null ? 0 : replies - release.base; -+ const showDown = unpinned && fresh > 0 && !release.dismissed; -+ const showUp = view?.divider === "above" && !above.seen && !above.dismissed; + const fresh = release.base === null ? 0 : replies - release.base; + const showDown = unpinned && fresh > 0 && !release.dismissed; + const showUp = view?.divider === "above" && !above.seen && !above.dismissed; +- (0, import_react9.useEffect)(() => { + (0, import_react22.useEffect)(() => { -+ const scroller = document.querySelector("[data-conversation-scroll]"); -+ if (!scroller) return void 0; -+ scroller.toggleAttribute("data-bt-news", showDown); -+ return () => scroller.removeAttribute("data-bt-news"); -+ }, [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"); + 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 }) { + }, [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"); +- const pill = (direction, count, style, onJump, onDismiss) => (0, import_react9.createElement)( + const pill = (direction, count, style, onJump, onDismiss) => (0, import_react22.createElement)( -+ "div", -+ { className: "bt-news", "data-direction": direction, style }, + "div", + { className: "bt-news", "data-direction": direction, style }, +- (0, import_react9.createElement)( + (0, import_react22.createElement)( -+ "button", -+ { type: "button", className: "bt-news-jump", onClick: onJump }, + "button", + { type: "button", className: "bt-news-jump", onClick: onJump }, +- (0, import_react9.createElement)("span", { className: "bt-news-ico" }, (0, import_react9.createElement)(direction === "up" ? ArrowUpIcon : ArrowDownIcon)), + (0, import_react22.createElement)("span", { className: "bt-news-ico" }, (0, import_react22.createElement)(direction === "up" ? ArrowUpIcon : ArrowDownIcon)), -+ label(count) -+ ), + label(count) + ), +- (0, import_react9.createElement)("button", { + (0, import_react22.createElement)("button", { -+ type: "button", -+ className: "bt-news-x", -+ "aria-label": t("Dismiss new messages"), -+ onClick: (event) => { -+ event.stopPropagation(); -+ onDismiss(); + type: "button", + className: "bt-news-x", + "aria-label": t("Dismiss new messages"), +@@ -5694,7 +5694,7 @@ function NewMessagePills({ sessionId, useSessions, useUi, useTurnClock }) { + event.stopPropagation(); + onDismiss(); } -- })) -- ]; -- if (people.length) sections.push([t("Bots"), people]); -- const agents = (roster.agents ?? []).filter(() => matchesName("DSH Agent")).map((agent) => ({ -- key: agent.id, -- avatar: (0, import_react12.createElement)("span", { className: "bt-palette-agent" }, (0, import_react12.createElement)(import_dsh_client_ui_primitives3.FishLogo, { size: 18 })), -- title: "DSH Agent", -- subtitle: t("Plain dsh session"), -- run: () => actions.openAgent(agent.id) -- })); -- if (agents.length) sections.push(["DSH Agent", agents]); -- const messages = JSON.parse(found).map(([id, turn, snippet]) => { -- const bot = roster.byId[id]; -- const room2 = roster.roomsById[id]; -- return { -- key: `${id}:${turn}`, -- title: bot?.name ?? room2?.name ?? t("Bot"), -- subtitle: snippet, -- avatar: bot ? (0, import_react12.createElement)(BotAvatar, { bot, size: 24, badge: false, live: false }) : (0, import_react12.createElement)(RoomAvatar, { room: room2, roster, size: 24 }), -- run: () => actions.openSession(id) -- }; -- }); -- if (messages.length) sections.push([t("Messages"), messages]); -- const commands = [ -- { key: "cmd:new", icon: (0, import_react12.createElement)(PlusIcon), title: t("New chat"), run: () => actions.openNewChat("new") }, -- { key: "cmd:bot", icon: (0, import_react12.createElement)(PlusIcon), title: t("Create Bot"), run: () => actions.openNewChat("create") }, -- { key: "cmd:group", icon: (0, import_react12.createElement)(PeopleIcon), title: t("Create group chat"), run: () => actions.openNewChat("group") }, -- { key: "cmd:settings", icon: (0, import_react12.createElement)(GearIcon), title: t("Bot settings"), run: () => actions.openSettings("general") } -- ].filter((command) => matchesName(command.title)); -- if (commands.length) sections.push([t("Commands"), commands]); -- const rows = sections.flatMap(([, items]) => items); -- const active = Math.min(cursor, Math.max(rows.length - 1, 0)); -- const shown = rows.map((row) => row.key).join("\n"); -- const glide = useGlide(`${active} --${shown}`, { axis: "y", ...QUICK_EDGES }); -- const listRef = glide.box; -- useFlip(listRef, shown, "[data-flip]"); -- (0, import_react12.useEffect)(() => { -- listRef.current?.querySelector("[aria-selected=true]")?.scrollIntoView({ block: "nearest" }); -- }, [active, query]); -- const choose = (row) => { -- if (!row || leaving) return; -- void Promise.resolve(row.run()).catch((error) => actions.reportError(error, "menu")); -- close(); +- }, (0, import_react9.createElement)(CloseIcon)) + }, (0, import_react22.createElement)(CloseIcon)) -+ ); -+ const toLatest = () => { -+ const scroller = document.querySelector("[data-conversation-scroll]"); -+ const back = scroller?.querySelector('[class*="_toBottomSlot"]>button'); -+ if (back) back.click(); -+ else scroller?.scrollTo({ top: scroller.scrollHeight, behavior: "smooth" }); + ); + const toLatest = () => { + const scroller = document.querySelector("[data-conversation-scroll]"); +@@ -5703,9 +5703,9 @@ function NewMessagePills({ sessionId, useSessions, useUi, useTurnClock }) { + else scroller?.scrollTo({ top: scroller.scrollHeight, behavior: "smooth" }); }; -- let index = -1; -- return (0, import_react12.createElement)( -- "div", -- { className: "bt-palette-layer", "data-leaving": leaving || void 0 }, -- (0, import_react12.createElement)("div", { className: "bt-backdrop", onMouseDown: close }), -- (0, import_react12.createElement)( -- "div", -- { className: "bt-palette", role: "dialog", "aria-label": t("Search"), "aria-modal": true }, -- (0, import_react12.createElement)( -- "div", -- { className: "bt-palette-head" }, -- (0, import_react12.createElement)("span", { className: "bt-palette-glyph", "aria-hidden": true }, (0, import_react12.createElement)(SearchIcon)), -- (0, import_react12.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_react12.createElement)("button", { type: "button", className: "bt-palette-clear", "aria-label": t("Clear search"), onClick: () => { -- setQuery(""); -- setCursor(0); -- } }, (0, import_react12.createElement)(CloseIcon)) : null -- ), -- (0, import_react12.createElement)( -- "div", -- { ref: listRef, id: "bt-palette-list", className: "bt-palette-list", role: "listbox", "aria-label": t("Results") }, -- (0, import_react12.createElement)("span", { ref: glide.thumb, className: "bt-thumb", "aria-hidden": true }), -- rows.length === 0 ? (0, import_react12.createElement)( -- "div", -- { className: "bt-palette-empty" }, -- (0, import_react12.createElement)("span", { className: "bt-palette-empty-icon", "aria-hidden": true }, (0, import_react12.createElement)(SearchIcon)), -- (0, import_react12.createElement)("span", { className: "bt-palette-empty-label" }, t("No results")), -- (0, import_react12.createElement)("span", { className: "bt-palette-empty-hint" }, t("Try a Bot name or words from a message")) -- ) : sections.map(([title, items]) => (0, import_react12.createElement)( -- import_react12.Fragment, -- { key: title }, -- (0, import_react12.createElement)("div", { className: "bt-palette-section", role: "presentation", "data-flip": `section:${title}` }, title), -- items.map((row) => { -- index += 1; -- const at = index; -- return (0, import_react12.createElement)( -- "button", -- { -- key: row.key, -- type: "button", -- role: "option", -- className: "bt-palette-row", -- "aria-selected": at === active, -- "data-flip": row.key, -- onMouseMove: () => { -- if (cursor !== at) setCursor(at); -- }, -- onClick: () => choose(row) -- }, -- (0, import_react12.createElement)("span", { className: "bt-palette-lead" }, row.avatar ?? (0, import_react12.createElement)("span", { className: "bt-palette-cmd" }, row.icon)), -- (0, import_react12.createElement)( -- "span", -- { className: "bt-palette-text" }, -- (0, import_react12.createElement)("span", { className: "bt-palette-title" }, row.title), -- row.subtitle ? (0, import_react12.createElement)("span", { className: "bt-palette-sub" }, row.subtitle) : null -- ), -- row.badge ? (0, import_react12.createElement)("span", { className: "bt-palette-badge" }, row.badge) : null -- ); -- }) -- )) -- ) -- ) -+ const toDivider = () => document.querySelector("[data-conversation-scroll] .bt-new-sep")?.scrollIntoView({ block: "center", behavior: "smooth" }); + const toDivider = () => document.querySelector("[data-conversation-scroll] .bt-new-sep")?.scrollIntoView({ block: "center", behavior: "smooth" }); +- return (0, import_react_dom4.createPortal)( +- (0, import_react9.createElement)( +- import_react9.Fragment, + return (0, import_react_dom6.createPortal)( + (0, import_react22.createElement)( + import_react22.Fragment, -+ null, -+ showUp ? pill("up", unread, { left: view.center, top: view.top }, toDivider, () => setAbove((state) => ({ ...state, dismissed: true }))) : null, -+ showDown ? pill("down", fresh, { left: view.center, bottom: view.bottom }, toLatest, () => setRelease((state) => ({ ...state, dismissed: true }))) : null -+ ), -+ document.body + null, + showUp ? pill("up", unread, { left: view.center, top: view.top }, toDivider, () => setAbove((state) => ({ ...state, dismissed: true }))) : null, + showDown ? pill("down", fresh, { left: view.center, bottom: view.bottom }, toLatest, () => setRelease((state) => ({ ...state, dismissed: true }))) : null +@@ -5714,19 +5714,135 @@ function NewMessagePills({ sessionId, useSessions, useUi, useTurnClock }) { ); } --// src/client/model-menu.js --var import_react13 = require("react"); --var import_react_dom6 = require("react-dom"); --var MENU_EXIT_MS = 120; --function groupModels(models) { -- const groups = /* @__PURE__ */ new Map(); -- for (const model of models ?? []) { -- const provider = model.provider ?? model.providerName ?? model.ref.split("/")[0]; -- if (!groups.has(provider)) groups.set(provider, []); -- groups.get(provider).push(model); -- } -- const names = /* @__PURE__ */ new Map(); -- for (const [provider, list] of groups) { -- const name = list[0].providerName ?? provider; -- names.set(name, (names.get(name) ?? 0) + 1); -- } -- return [...groups].map(([provider, list]) => { -- const name = list[0].providerName ?? provider; -- return [names.get(name) > 1 ? `${name} (${provider})` : name, list]; -- }); --} --var servedOf = (roster, ref) => (roster.models ?? []).find((model) => model.ref === ref); --function modelOf(bot, roster) { -- const ref = bot.model ?? bot.appliedModel; -- if (!ref) return void 0; -- const served = servedOf(roster, ref); -- const [provider, ...rest] = ref.split("/"); -- return { ref, name: served?.name ?? rest.join("/"), provider: served?.providerName ?? provider, served: served !== void 0 }; +// src/client/composer-label.js +var ATTRIBUTES = ["aria-label", "data-placeholder"]; +function holdComposerLabel(doc, label) { @@ -16484,20 +15665,7 @@ diff --git a/client.js b/client.js + } + } + }; - } --var effortCache = /* @__PURE__ */ new Map(); --function useEfforts(served, ref, actions) { -- const [fetched, setFetched] = (0, import_react13.useState)(void 0); -- (0, import_react13.useEffect)(() => { -- setFetched(void 0); -- if (ref === void 0 || served?.reasoning !== void 0 || actions?.efforts === void 0) return void 0; -- let live = true; -- if (effortCache.has(ref)) setFetched(effortCache.get(ref)); -- else void Promise.resolve(actions.efforts(ref)).then((value) => { -- const reasoning = value?.efforts ? value : null; -- effortCache.set(ref, reasoning); -- if (live) setFetched(reasoning); -- }).catch(() => { ++} + +// src/client/header-effects.js +function installHeaderEffects({ sessions, roster, actions, doc = globalThis.document }) { @@ -16527,12 +15695,7 @@ diff --git a/client.js b/client.js + return () => { + delete doc.body.dataset.btFriendChat; + }; - }); -- return () => { -- live = false; -- }; -- }, [ref, served?.reasoning !== void 0, actions]); -- return served?.reasoning ?? fetched; ++ }); + run("remote-empty", `${sessionId}|${bot?.remote === true}|${row?.blank === true}`, () => { + if (!bot?.remote || row?.blank !== true) return void 0; + doc.body.dataset.btRemoteChat = "empty"; @@ -16586,77 +15749,50 @@ diff --git a/client.js b/client.js + for (const held of slots.values()) held.off(); + slots.clear(); + }; - } --function effortRows(reasoning) { -- if (reasoning?.efforts === void 0 || reasoning.efforts.length === 0) return []; -- return [ -- { effort: void 0, label: t("Default") }, -- ...reasoning.efforts.map((effort) => ({ effort: effort.id, label: effort.name })) -- ]; ++} + -+// src/client/composer.js + // src/client/composer.js +-var import_react10 = require("react"); +-var import_react_dom5 = require("react-dom"); +var import_react23 = require("react"); +var import_react_dom7 = require("react-dom"); -+var speechApi = () => window.SpeechRecognition ?? window.webkitSpeechRecognition; -+var keepComposerFocus = (event) => event.preventDefault(); -+function useDictation(onText) { + var speechApi = () => window.SpeechRecognition ?? window.webkitSpeechRecognition; + var keepComposerFocus = (event) => event.preventDefault(); + function useDictation(onText) { +- const [listening, setListening] = (0, import_react10.useState)(false); +- const [note, setNote] = (0, import_react10.useState)(""); +- const recognition = (0, import_react10.useRef)(null); +- const latest = (0, import_react10.useRef)(onText); + const [listening, setListening] = (0, import_react23.useState)(false); + const [note, setNote] = (0, import_react23.useState)(""); + const recognition = (0, import_react23.useRef)(null); + const latest = (0, import_react23.useRef)(onText); -+ latest.current = onText; + latest.current = onText; +- (0, import_react10.useEffect)(() => () => recognition.current?.abort(), []); +- (0, import_react10.useEffect)(() => { + (0, import_react23.useEffect)(() => () => recognition.current?.abort(), []); + (0, import_react23.useEffect)(() => { -+ if (!note) return void 0; -+ const timer = setTimeout(() => setNote(""), 2600); -+ return () => clearTimeout(timer); -+ }, [note]); -+ const start = ({ continuous = true, onEnd } = {}) => { -+ const Api = speechApi(); -+ if (!Api) { -+ setNote(t("Dictation is not available in this browser")); -+ return false; -+ } -+ const engine = new Api(); -+ engine.lang = navigator.language || "zh-CN"; -+ engine.interimResults = true; -+ engine.continuous = continuous; -+ engine.onresult = (event) => { -+ let interim = ""; -+ for (let index = event.resultIndex; index < event.results.length; index += 1) { -+ const result = event.results[index]; + if (!note) return void 0; + const timer = setTimeout(() => setNote(""), 2600); + return () => clearTimeout(timer); +@@ -5745,10 +5745,10 @@ function useDictation(onText) { + let interim = ""; + for (let index = event.resultIndex; index < event.results.length; index += 1) { + const result = event.results[index]; +- const text = result[0]?.transcript ?? ""; + const text2 = result[0]?.transcript ?? ""; -+ if (result.isFinal) { + if (result.isFinal) { +- if (text.trim()) latest.current(text, true); +- } else interim += text; + if (text2.trim()) latest.current(text2, true); + } else interim += text2; -+ } -+ if (interim) latest.current(interim, false); -+ }; -+ engine.onerror = (event) => { -+ if (event.error === "aborted" || event.error === "no-speech") return; -+ setNote(event.error === "not-allowed" || event.error === "service-not-allowed" ? t("Microphone access is blocked") : t("Couldn't hear that")); -+ }; -+ engine.onend = () => { -+ setListening(false); -+ recognition.current = null; -+ onEnd?.(); -+ }; -+ recognition.current = engine; -+ try { -+ engine.start(); -+ setListening(true); -+ return true; -+ } catch { -+ setNote(t("Couldn't start dictation")); -+ return false; -+ } -+ }; -+ const stop = () => recognition.current?.stop(); -+ return { listening, note, start, stop }; + } + if (interim) latest.current(interim, false); + }; +@@ -5774,19 +5774,38 @@ function useDictation(onText) { + const stop = () => recognition.current?.stop(); + return { listening, note, start, stop }; } --function EffortRows({ reasoning, current, onPick }) { -- return (0, import_react13.createElement)( -- import_react13.Fragment, +function ComposerIdentity({ sessionId, useRoster }) { + const roster = useRoster((value) => value); + const room2 = roster.roomsById?.[sessionId]; @@ -16676,526 +15812,931 @@ diff --git a/client.js b/client.js + t("Sending as {name}", { name: self.displayName || self.memberId }) + ); +} -+function ComposerVoice({ sessionId, inputActions, actions, useUi }) { + function ComposerVoice({ sessionId, inputActions, actions, useUi }) { +- const dictation = useDictation((text, final) => { +- if (final && inputActions) inputActions.insertText(text, inputActions.captureInsertion()); + const dictation = useDictation((text2, final) => { + if (final && inputActions) inputActions.insertText(text2, inputActions.captureInsertion()); -+ }); -+ const quote = useUi((state) => state.quote?.sessionId === sessionId ? state.quote : null); + }); + const quote = useUi((state) => state.quote?.sessionId === sessionId ? state.quote : null); +- (0, import_react10.useEffect)(() => { + (0, import_react23.useEffect)(() => { -+ if (!quote || !inputActions) return; -+ actions.clearQuote(quote.token); -+ const excerpt = quote.text.replace(/\s+/g, " ").trim(); + if (!quote || !inputActions) return; + actions.clearQuote(quote.token); + const excerpt = quote.text.replace(/\s+/g, " ").trim(); +- const line = `> ${excerpt.length > 200 ? `${excerpt.slice(0, 200)}…` : excerpt} + const line2 = `> ${excerpt.length > 200 ? `${excerpt.slice(0, 200)}…` : excerpt} -+ -+`; + + `; +- inputActions.insertText(line, inputActions.captureInsertion()); + inputActions.insertText(line2, inputActions.captureInsertion()); -+ const box = document.querySelector('[class*="_centerCol"] [role=textbox][contenteditable=true]'); -+ if (box instanceof HTMLElement) { -+ box.focus(); -+ const selection = window.getSelection(); -+ selection?.selectAllChildren(box); -+ selection?.collapseToEnd(); -+ } -+ }, [quote?.token]); -+ if (sessionId === void 0) return null; -+ const pluginMic = actions.speech() !== void 0; + 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 }) { + }, [quote?.token]); + if (sessionId === void 0) return null; + const pluginMic = actions.speech() !== void 0; +- return (0, import_react10.createElement)( +- import_react10.Fragment, + return (0, import_react23.createElement)( + import_react23.Fragment, null, -- (0, import_react13.createElement)("div", { className: "bt-menu-sep", role: "separator" }), -- (0, import_react13.createElement)("div", { className: "bt-model-group", role: "presentation" }, t("Thinking level")), -- effortRows(reasoning).map((row) => (0, import_react13.createElement)( -- "button", -- { -- key: row.effort ?? "default", -- type: "button", -- role: "menuitemradio", -- "aria-checked": row.effort === current, -- onClick: () => onPick(row.effort) -- }, -- (0, import_react13.createElement)("span", { className: "bt-menu-label" }, row.label), -- (0, import_react13.createElement)("span", { className: "bt-model-tick", "aria-hidden": true }, row.effort === current ? (0, import_react13.createElement)(CheckIcon) : null) -- )) +- pluginMic ? null : (0, import_react10.createElement)("button", { + pluginMic ? null : (0, import_react23.createElement)("button", { -+ type: "button", -+ className: "bt-mic", -+ "aria-label": dictation.listening ? t("Stop dictation") : t("Dictate"), -+ "aria-pressed": dictation.listening, -+ title: dictation.listening ? t("Stop dictation") : t("Dictate"), -+ onMouseDown: keepComposerFocus, -+ onClick: () => dictation.listening ? dictation.stop() : dictation.start() + type: "button", + className: "bt-mic", + "aria-label": dictation.listening ? t("Stop dictation") : t("Dictate"), +@@ -5808,16 +5808,16 @@ function ComposerVoice({ sessionId, inputActions, actions, useUi }) { + title: dictation.listening ? t("Stop dictation") : t("Dictate"), + onMouseDown: keepComposerFocus, + onClick: () => dictation.listening ? dictation.stop() : dictation.start() +- }, (0, import_react10.createElement)(MicIcon)), +- (0, import_react10.createElement)("button", { + }, (0, import_react23.createElement)(MicIcon)), + (0, import_react23.createElement)("button", { -+ type: "button", -+ className: "bt-voice", -+ "aria-label": t("Start voice chat"), -+ title: t("Voice chat"), -+ onMouseDown: keepComposerFocus, -+ onClick: () => actions.openVoice(sessionId) + type: "button", + className: "bt-voice", + "aria-label": t("Start voice chat"), + title: t("Voice chat"), + onMouseDown: keepComposerFocus, + onClick: () => actions.openVoice(sessionId) +- }, (0, import_react10.createElement)(WaveIcon, { size: 18 })), +- dictation.note ? (0, import_react10.createElement)("span", { className: "bt-mic-note", role: "status" }, dictation.note) : null + }, (0, import_react23.createElement)(WaveIcon, { size: 18 })), + dictation.note ? (0, import_react23.createElement)("span", { className: "bt-mic-note", role: "status" }, dictation.note) : null ); } --function ModelRows({ roster, current, onPick }) { -- return groupModels(roster.models).map(([provider, list]) => (0, import_react13.createElement)( -- import_react13.Fragment, -- { key: list[0].provider ?? provider }, -- (0, import_react13.createElement)("div", { className: "bt-model-group", role: "presentation" }, provider), -- list.map((model) => (0, import_react13.createElement)( -- "button", -- { -- key: model.ref, -- type: "button", -- role: "menuitemradio", -- "aria-checked": model.ref === current, -- title: model.ref, -- onClick: () => onPick(model.ref) -- }, -- (0, import_react13.createElement)("span", { className: "bt-menu-label" }, model.name), -- model.ref === roster.defaultModel ? (0, import_react13.createElement)("span", { className: "bt-menu-hint" }, t("Default")) : null, -- (0, import_react13.createElement)("span", { className: "bt-model-tick", "aria-hidden": true }, model.ref === current ? (0, import_react13.createElement)(CheckIcon) : null) -- )) -- )); -+function menuIcons(roster) { -+ const marks = /* @__PURE__ */ new Map(); -+ return (row) => { -+ if (!row.bot) return PeopleIcon; -+ const id = row.bot.id; + function menuIcons(roster) { +@@ -5825,17 +5825,17 @@ function menuIcons(roster) { + return (row) => { + if (!row.bot) return PeopleIcon; + const id = row.bot.id; +- if (!marks.has(id)) marks.set(id, ({ size = 14 }) => (0, import_react10.createElement)(BotMark, { bot: roster.getSnapshot().byId[id] ?? row.bot, size })); + if (!marks.has(id)) marks.set(id, ({ size = 14 }) => (0, import_react23.createElement)(BotMark, { bot: markBotForMention(row.bot, roster.getSnapshot()), size })); -+ return marks.get(id); -+ }; + return marks.get(id); + }; } --var Caret = () => (0, import_react13.createElement)("svg", { className: "bt-model-caret", width: 12, height: 12, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.8, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, (0, import_react13.createElement)("path", { d: "M5.5 8l4.5 4.5L14.5 8" })); --function useAnchoredMenu() { -- const [place, setPlace] = (0, import_react13.useState)(null); -- const [shownPlace, leaving] = useLinger(place, MENU_EXIT_MS); -- const button = (0, import_react13.useRef)(null); -- const menu = (0, import_react13.useRef)(null); -- const close = () => setPlace(null); -- (0, import_react13.useEffect)(() => { -- if (place === null) return void 0; -+function ComposerPlus({ sessionId, inputActions, useRoster, actions }) { -+ const roster = useRoster((value) => value); + function ComposerPlus({ sessionId, inputActions, useRoster, actions }) { + const roster = useRoster((value) => value); +- const [open, setOpen] = (0, import_react10.useState)(null); + const [open, setOpen] = (0, import_react23.useState)(null); -+ const [shown, leaving] = useLinger(open, 120); + const [shown, leaving] = useLinger(open, 120); +- const button = (0, import_react10.useRef)(null); +- const menu = (0, import_react10.useRef)(null); +- (0, import_react10.useEffect)(() => { + const button = (0, import_react23.useRef)(null); + const menu = (0, import_react23.useRef)(null); + (0, import_react23.useEffect)(() => { -+ if (!open) return void 0; + if (!open) return void 0; const onDown = (event) => { -- if (menu.current?.contains(event.target) || button.current?.contains(event.target)) return; -- close(); -+ if (![button.current, menu.current].some((node) => node?.contains(event.target))) setOpen(null); - }; - const onKey = (event) => { - if (event.key === "Escape") { - event.stopPropagation(); -- close(); -- button.current?.focus(); -+ setOpen(null); - } - }; -- const onScroll = (event) => { -- if (!menu.current?.contains(event.target)) close(); -- }; -- window.addEventListener("mousedown", onDown); -+ window.addEventListener("mousedown", onDown, true); - window.addEventListener("keydown", onKey, true); -- window.addEventListener("scroll", onScroll, true); - return () => { -- window.removeEventListener("mousedown", onDown); -+ window.removeEventListener("mousedown", onDown, true); - window.removeEventListener("keydown", onKey, true); -- window.removeEventListener("scroll", onScroll, true); - }; -- }, [place]); -- const open = () => { -- const rect = button.current.getBoundingClientRect(); -- const width = Math.max(240, rect.width); -- const left = Math.max(8, Math.min(rect.right - width, window.innerWidth - width - 8)); -- const below = window.innerHeight - rect.bottom; -- setPlace(below >= 260 || below >= rect.top ? { left, width, top: rect.bottom + 4, maxHeight: below - 16, "--bt-origin": "top right" } : { left, width, bottom: window.innerHeight - rect.top + 4, maxHeight: rect.top - 16, "--bt-origin": "bottom right" }); -- }; -- return { isOpen: place !== null, shownPlace, leaving, button, menu, open, close }; --} --function ModelMenu({ bot, roster, actions, run, wide = false, onPick, onPickEffort }) { -- const { isOpen, shownPlace, leaving, button, menu, open, close } = useAnchoredMenu(); -- const shown = modelOf(bot, roster); -- const current = bot.model ?? bot.appliedModel; -- const served = servedOf(roster, current); -- const reasoning = useEfforts(served, current, actions); -- const picked = reasoning?.efforts?.some((effort) => effort.id === bot.effort) ? bot.effort : void 0; -- const currentEffort = picked ?? reasoning?.defaultEffort; -- const effortLabel = currentEffort === void 0 ? reasoning?.efforts?.length > 0 ? t("Default") : void 0 : reasoning.efforts.find((effort) => effort.id === currentEffort)?.name ?? currentEffort; -+ }, [open]); -+ const target = roster.byId?.[sessionId]?.id ?? roster.roomsById?.[sessionId]?.id; -+ if (sessionId === void 0 || target === void 0) return null; + if (![button.current, menu.current].some((node) => node?.contains(event.target))) setOpen(null); +@@ -5855,6 +5855,7 @@ function ComposerPlus({ sessionId, inputActions, useRoster, actions }) { + }, [open]); + const target = roster.byId?.[sessionId]?.id ?? roster.roomsById?.[sessionId]?.id; + if (sessionId === void 0 || target === void 0) return null; + const locked = lockedWindow(roster); const toggle = () => { -- if (isOpen) { -- close(); -+ if (open) { -+ setOpen(null); - return; - } -- void actions?.refreshModels?.(); -- open(); -+ const rect = button.current.getBoundingClientRect(); -+ setOpen({ left: rect.left, bottom: window.innerHeight - rect.top + 8 }); + if (open) { + setOpen(null); +@@ -5867,19 +5867,15 @@ function ComposerPlus({ sessionId, inputActions, useRoster, actions }) { + setOpen(null); + button.current?.closest("[data-composer-card]")?.querySelector("input[type=file]")?.click(); }; -- const pick2 = (ref) => { -- close(); -- if (onPick) onPick(ref); -- else if (ref !== current || !bot.model) void run(actions.updateBot(bot.id, { model: ref })); -+ const attach = () => { -+ setOpen(null); -+ button.current?.closest("[data-composer-card]")?.querySelector("input[type=file]")?.click(); +- const schedule = () => { +- setOpen(null); +- actions.openSettings("schedules", target); +- }; + const reference = () => { + setOpen(null); + actions.referenceChat(sessionId, inputActions?.captureInsertion?.()); }; -- const pickEffort = (effort) => { -- close(); -- if (onPickEffort) onPickEffort(effort); -- else if (effort !== bot.effort) void run(actions.updateBot(bot.id, { effort: effort ?? null })); -+ const reference = () => { -+ setOpen(null); -+ actions.referenceChat(sessionId, inputActions?.captureInsertion?.()); + const canReference = typeof inputActions?.captureInsertion === "function" && actions.canReferenceChat(); +- return (0, import_react10.createElement)( +- import_react10.Fragment, ++ return (0, import_react23.createElement)( ++ import_react23.Fragment, + null, +- (0, import_react10.createElement)("button", { ++ (0, import_react23.createElement)("button", { + ref: button, + type: "button", + className: "bt-plus", +@@ -5889,8 +5889,8 @@ function ComposerPlus({ sessionId, inputActions, useRoster, actions }) { + "aria-expanded": Boolean(open), + onMouseDown: keepComposerFocus, + onClick: toggle +- }, (0, import_react10.createElement)(PlusIcon)), +- shown ? (0, import_react_dom5.createPortal)((0, import_react10.createElement)( ++ }, (0, import_react23.createElement)(PlusIcon)), ++ shown ? (0, import_react_dom7.createPortal)((0, import_react23.createElement)( + "div", + { + ref: menu, +@@ -5900,9 +5900,12 @@ function ComposerPlus({ sessionId, inputActions, useRoster, actions }) { + style: { left: shown.left, bottom: shown.bottom }, + "data-leaving": leaving || void 0 + }, +- (0, import_react10.createElement)("button", { type: "button", role: "menuitem", onClick: attach }, (0, import_react10.createElement)(PaperclipIcon), (0, import_react10.createElement)("span", { className: "bt-menu-label" }, t("Add files"))), +- canReference ? (0, import_react10.createElement)("button", { type: "button", role: "menuitem", onMouseDown: keepComposerFocus, onClick: reference }, (0, import_react10.createElement)(ChatsIcon), (0, import_react10.createElement)("span", { className: "bt-menu-label" }, t("Reference a chat"))) : null, +- (0, import_react10.createElement)("button", { type: "button", role: "menuitem", onClick: schedule }, (0, import_react10.createElement)(ClockIcon), (0, import_react10.createElement)("span", { className: "bt-menu-label" }, t("Scheduled task"))) ++ (0, import_react23.createElement)("button", { type: "button", role: "menuitem", onClick: attach }, (0, import_react23.createElement)(PaperclipIcon), (0, import_react23.createElement)("span", { className: "bt-menu-label" }, t("Add files"))), ++ canReference ? (0, import_react23.createElement)("button", { type: "button", role: "menuitem", onMouseDown: keepComposerFocus, onClick: reference }, (0, import_react23.createElement)(ChatsIcon), (0, import_react23.createElement)("span", { className: "bt-menu-label" }, t("Reference a chat"))) : null, ++ locked ? null : (0, import_react23.createElement)("button", { type: "button", role: "menuitem", onClick: () => { ++ setOpen(null); ++ actions.openSettings("schedules", target); ++ } }, (0, import_react23.createElement)(ClockIcon), (0, import_react23.createElement)("span", { className: "bt-menu-label" }, t("Scheduled task"))) + ), document.body) : null + ); + } +@@ -5922,15 +5922,57 @@ var conversationOf = (roster, sessionId) => { + function mentionRows(roster, sessionId, query = "") { + const here = conversationOf(roster, sessionId); + if (here === null) return null; +- const bots = here.room ? here.room.members.map((id) => roster.byId[id]).filter(Boolean) : roster.bots.filter((bot) => bot.id !== here.self.id); ++ const face = isFaceRoom(here.room); ++ const live = (here.room?.participants ?? []).filter((p) => p.leftSeq === void 0); ++ const bots = here.room ? [ ++ ...here.room.members.map((id) => roster.byId[id]).filter(Boolean).map((bot) => face ? { ...bot, label: faceLabel(myOwnerName(roster, live, bot), bot.name) } : bot), ++ ...live.filter((p) => p.kind === "human").map((p) => ({ id: p.id, name: p.displayName || p.id, kind: "human", role: p.role })), ++ // A remote seat already listed under its handle (a duplicate participant record) is offered once. ++ ...uniqueBy(live.filter((p) => p.remote), (p) => p.handle || p.id).map((p) => ({ ++ id: p.handle || p.id, ++ // A face room inserts the disambiguated title. An ordinary group inserts the name the ++ // room routes on (#1141): never "@@handle.home (owner)" and never "(Friend)". ++ name: face ? remoteBotTitle(p) : remoteCallName(p), ++ kind: "bot", ++ remote: p, ++ handle: p.handle, ++ label: face ? faceLabel(ownerNameOf(p), cleanPeerName(p.displayName || p.name, "Bot")) : remoteBotTitle(p) ++ })) ++ ] : listedChatBots(roster).filter((bot) => bot.id !== here.self.id); + const needle = query.trim().toLowerCase(); +- const rows = bots.filter((bot) => bot.name.toLowerCase().includes(needle)).map((bot) => ({ bot, text: `@${bot.name} ` })); ++ const rows = bots.filter((bot) => bot.name.toLowerCase().includes(needle) || String(bot.label ?? "").toLowerCase().includes(needle) || bot.remote && normHandle(remoteRecord(bot)?.handle || bot.handle).includes(normHandle(needle))).map((bot) => ({ bot, text: `@${bot.name} ` })); + const everyone = t("Everyone"); + if (here.room && bots.length > 1 && [everyone, ...ROOM_ALL].some((word) => word.toLowerCase().startsWith(needle))) { +- rows.push({ room: here.room, text: `@${everyone} ` }); ++ rows.push({ room: here.room, count: bots.filter((bot) => bot.kind !== "human").length, text: `@${everyone} ` }); + } +- return rows; ++ if (face) return rows; ++ const seatHandles = new Set(bots.filter((bot) => bot.remote).map((bot) => normHandle(remoteRecord(bot)?.handle)).filter(Boolean)); ++ if (here.room) return [...rows, ...remoteMentionRows(roster, query).filter((row) => !seatHandles.has(normHandle(row.remote.handle)))]; ++ const remoteHandles = new Set(remoteMentionRows(roster, "").map((row) => normHandle(row.remote.handle))); ++ return [ ++ ...rows.filter((row) => !(row.bot && remoteHandles.has(normHandle(remoteRecord(row.bot)?.handle)))), ++ ...remoteMentionRows(roster, query) ++ ]; + } ++var isFaceRoom = (room2) => room2?.origin === "face-to-face"; ++var memberCount = (count) => t(count === 1 ? "{count} member" : "{count} members", { count }); ++var botCount = (count) => count === 1 ? t("1 Bot") : t("{count} Bots", { count }); ++var normHandle = (handle) => String(handle ?? "").trim().replace(/^@+/, "").toLowerCase(); ++var remoteRecord = (bot) => bot?.remote ? bot.remote === true ? bot : bot.remote : null; ++var myOwnerName = (roster, live, bot) => { ++ const seat = live.find((p) => !p.remote && p.kind !== "human" && (p.id === bot.id || bot.handle && p.handle === bot.handle)); ++ return seatOwnerName(seat?.ownerName, seat?.handle) || cleanPeerName(roster.self?.displayName, ""); ++}; ++var uniqueBy = (list, key) => { ++ const seen = /* @__PURE__ */ new Set(); ++ return list.filter((item) => { ++ const k = key(item); ++ if (!k || seen.has(k)) return !k; ++ seen.add(k); ++ return true; ++ }); ++}; ++var faceLabel = (person, bot) => person ? `${bot} (${person})` : bot; + function chatRows(roster, list, sessionId) { + const here = conversationOf(roster, sessionId); + if (here === null) return []; +@@ -5959,14 +5959,31 @@ function atSources({ roster, sessions, launched, iconFor }) { + order: -20, + showGroupTitle: false, + async candidates(session, { query }) { +- return (mentionRows(roster.getSnapshot(), session.sessionId, query) ?? []).map((row) => ({ +- name: row.bot ? row.bot.name : t("Everyone"), +- ...row.bot?.role ? { description: row.bot.role } : {}, +- ...row.room ? { description: t("{count} members", { count: row.room.members.length }) } : {}, ++ const snapshot = roster.getSnapshot(); ++ const face = isFaceRoom(snapshot.roomsById?.[session.sessionId]); ++ const team = (mentionRows(snapshot, session.sessionId, query) ?? []).map((row) => ({ ++ // A friend's Bot by the one name the row inserts, "Chief (Bob)" (#967); its handle is the ++ // second line (#384). A seat carries its own name; a friend's chat is named as its sidebar ++ // row and header (#498). ++ name: row.bot?.remote && !face ? row.bot.kind === "bot" ? remoteBotTitle(remoteRecord(row.bot)) : friendBotTitle(row.bot) : row.bot ? row.bot.label ?? row.bot.name : row.remote ? row.text.replace(/^@/, "").trimEnd() : t("Everyone"), ++ ...row.bot?.role && !face ? { description: row.bot.role } : {}, ++ ...row.remote ? { description: row.remote.handle } : {}, ++ ...row.bot?.remote && !face && remoteRecord(row.bot).handle ? { description: remoteRecord(row.bot).handle } : {}, ++ // A face room counts the Bots in it, mine and everyone else's (#305, #457). ++ ...row.room ? { description: face ? botCount(row.count) : memberCount(row.count) } : {}, + icon: iconFor(row), + section: t("Bots"), + value: row.text + })); ++ if (mentionRows(snapshot, session.sessionId, "") === null || face) return team; ++ const extra = (await mentionCandidates(query, { sessionId: session.sessionId, roster: snapshot })).map((row) => ({ ++ name: row.name, ++ ...row.description ? { description: row.description } : {}, ++ icon: row.icon ?? "session", ++ section: row.section ?? "FlyBot", ++ value: row.value ++ })); ++ return [...team, ...extra]; + }, + onPick: ({ candidate }) => candidate.value ? { text: candidate.value } : void 0 + }; +@@ -5995,6 +5995,83 @@ function atSources({ roster, sessions, launched, iconFor }) { + return [bots, chats]; + } + ++// src/client/face-at-filter.js ++var REFERENCE_SOURCE = "reference"; ++var optionId = (source, index) => `dsh-slash-option-${source}-${index}`; ++var isSessionRow = (group, item) => group.source === REFERENCE_SOURCE && item?.icon === "session"; ++var same = (a, b) => a != null && b != null && a.source === b.source && a.index === b.index; ++function planFaceMenu(state, previous = null) { ++ const hidden = []; ++ const ready = []; ++ let shown = 0; ++ let loading = false; ++ for (const group of state?.groups ?? []) { ++ const items = group.items ?? []; ++ if (group.status !== "ready" && items.length === 0) loading = true; ++ items.forEach((item, index) => { ++ const hide = isSessionRow(group, item); ++ if (hide) hidden.push({ source: group.source, index, title: item.section !== void 0 && item.section !== items[index - 1]?.section }); ++ else shown += 1; ++ if (group.status === "ready") ready.push({ source: group.source, index, hide }); ++ }); ++ } ++ const open = state?.open === true; ++ const blank3 = open && hidden.length > 0 && shown === 0 && !loading; ++ const dismiss = blank3 && (state.groups ?? []).every((group) => group.status === "ready"); ++ return { hidden, blank: blank3, dismiss, target: dismiss ? null : steer(ready, state?.highlight ?? null, previous) }; ++} ++function steer(ready, highlight, previous) { ++ const at = ready.findIndex((row) => same(row, highlight)); ++ if (at < 0 || !ready[at].hide) return null; ++ const from = ready.findIndex((row) => same(row, previous)); ++ const n = ready.length; ++ const dir = from >= 0 && from !== at && (from - at + n) % n === 1 ? -1 : 1; ++ for (let step = 1; step < n; step += 1) { ++ const row = ready[((at + dir * step) % n + n) % n]; ++ if (!row.hide) return { source: row.source, index: row.index }; ++ } ++ return null; ++} ++function faceMenuCss({ hidden, blank: blank3 }) { ++ if (hidden.length === 0) return ""; ++ const rows = hidden.map((row) => `[data-trigger-menu] [id="${optionId(row.source, row.index)}"]`); ++ const titles = hidden.filter((row) => row.title).map((row) => `[data-trigger-menu] [role="presentation"]:has(+ [id="${optionId(row.source, row.index)}"])`); ++ const css = [`${[...rows, ...titles].join(",")}{display:none!important}`]; ++ if (blank3) css.push("[data-trigger-menu]{display:none!important}"); ++ return css.join("\n"); ++} ++function watchFaceMenu(controller, doc = globalThis.document) { ++ if (typeof controller?.menu?.subscribe !== "function" || !doc?.head) return () => { ++ }; ++ const style = doc.createElement("style"); ++ style.setAttribute("data-bt-face-at", ""); ++ doc.head.appendChild(style); ++ let previous = null; ++ let stopped = false; ++ const later = typeof queueMicrotask === "function" ? queueMicrotask : (fn) => Promise.resolve().then(fn); ++ const apply2 = () => { ++ if (stopped) return; ++ const state = controller.menu.getSnapshot(); ++ const plan = planFaceMenu(state, previous); ++ const css = faceMenuCss(plan); ++ if (style.textContent !== css) style.textContent = css; ++ if (plan.dismiss) later(() => { ++ if (!stopped && planFaceMenu(controller.menu.getSnapshot(), previous).dismiss) controller.dismiss(); ++ }); ++ else if (plan.target) later(() => { ++ if (!stopped) controller.hover(plan.target.source, plan.target.index); ++ }); ++ else previous = state.open ? state.highlight : null; ++ }; ++ const unsubscribe = controller.menu.subscribe(apply2); ++ apply2(); ++ return () => { ++ stopped = true; ++ unsubscribe?.(); ++ style.remove(); ++ }; ++} ++ + // src/client/speech.js + var VOICE_BUNDLE = "@deepseek-ai/dsh-experimental-voice-input-bundle"; + var USABLE = /* @__PURE__ */ new Set(["ready", "standby", "waking"]); +@@ -6008,8 +6008,8 @@ async function speechCatalog(speech) { + function waveBytes(samples, rate = 16e3) { + const bytes = new Uint8Array(44 + samples.length * 2); + const view = new DataView(bytes.buffer); +- const ascii = (at, text) => { +- for (let index = 0; index < text.length; index += 1) bytes[at + index] = text.charCodeAt(index); ++ const ascii = (at, text2) => { ++ for (let index = 0; index < text2.length; index += 1) bytes[at + index] = text2.charCodeAt(index); + }; + ascii(0, "RIFF"); + view.setUint32(4, 36 + samples.length * 2, true); +@@ -6030,9 +6030,9 @@ function waveBytes(samples, rate = 16e3) { + return bytes; + } + function base64Of(bytes) { +- let text = ""; +- for (let index = 0; index < bytes.length; index += 32768) text += String.fromCharCode(...bytes.subarray(index, index + 32768)); +- return btoa(text); ++ let text2 = ""; ++ for (let index = 0; index < bytes.length; index += 32768) text2 += String.fromCharCode(...bytes.subarray(index, index + 32768)); ++ return btoa(text2); + } + var captureError = (kind, cause) => Object.assign(new Error(kind), { kind, cause }); + async function openCapture() { +@@ -6121,233 +6121,9 @@ function turnDetector({ loud = 0.12, quiet = 0.06, quietMs = 1200 } = {}) { + }; + } + +-// src/client/overlay-hooks.js +-var import_react11 = require("react"); +-var escapeLayers = []; +-var onEscapeKey = (event) => { +- if (event.key === "Escape") escapeLayers.at(-1)?.current(); +-}; +-function useEscape(onClose) { +- const ref = (0, import_react11.useRef)(onClose); +- ref.current = onClose; +- (0, import_react11.useEffect)(() => { +- if (escapeLayers.length === 0) window.addEventListener("keydown", onEscapeKey); +- escapeLayers.push(ref); +- return () => { +- escapeLayers.splice(escapeLayers.indexOf(ref), 1); +- if (escapeLayers.length === 0) window.removeEventListener("keydown", onEscapeKey); +- }; +- }, []); +-} +-function usePaneLeft() { +- const [paneLeft, setPaneLeft] = (0, import_react11.useState)(280); +- (0, import_react11.useLayoutEffect)(() => { +- const column = document.querySelector('[class*="_footArea"]')?.parentElement; +- if (column) setPaneLeft(Math.round(column.getBoundingClientRect().right)); +- }, []); +- return paneLeft; +-} +- +-// src/client/palette.js +-var import_react12 = require("react"); +-var import_dsh_client_ui_primitives3 = require("@deepseek-ai/dsh-client-ui-primitives"); +-var PALETTE_EXIT_MS = 160; +-function CommandPalette({ start, token, roster, actions, useSessions }) { +- const [query, setQuery] = (0, import_react12.useState)(start ?? ""); +- const [cursor, setCursor] = (0, import_react12.useState)(0); +- const [leaving, setLeaving] = (0, import_react12.useState)(false); +- const wanted = query.trim().toLowerCase(); +- const close = () => { +- if (leaving) return; +- setLeaving(true); +- setTimeout(() => actions.closePalette(token), PALETTE_EXIT_MS); +- }; +- useEscape(close); +- const ids = [...roster.bots.map(currentPart), ...roster.rooms.map((item) => item.id)]; +- const found = useSessions((list) => { +- if (wanted.length < 2) return "[]"; +- const hits = []; +- for (const id of ids) { +- const outline = list.projectionsBySession?.[id]?.values?.turnOutline ?? list.byId[id]?.projectionValues?.turnOutline; +- if (!Array.isArray(outline)) continue; +- const hidden = roster.exchangeTurns[id] ?? []; +- for (let index2 = outline.length - 1; index2 >= 0 && hits.length < 30; index2 -= 1) { +- const entry = outline[index2]; +- if (hidden.includes(entry.turn)) continue; +- for (const raw of [entry.response, entry.prompt]) { +- const text = stripHeader(raw ?? "").replace(/\s+/g, " ").trim(); +- const at = text.toLowerCase().indexOf(wanted); +- if (at < 0) continue; +- const from = Math.max(0, at - 24); +- hits.push([id, entry.turn, (from > 0 ? "…" : "") + text.slice(from, at + wanted.length + 60)]); +- break; +- } +- } +- } +- return JSON.stringify(hits); +- }); +- const matchesName = (...fields) => wanted === "" || fields.some((field) => (field ?? "").toLowerCase().includes(wanted)); +- const sections = []; +- const people = [ +- ...roster.bots.filter((bot) => matchesName(bot.name, bot.role)).map((bot) => ({ +- key: bot.id, +- avatar: (0, import_react12.createElement)(BotAvatar, { bot, size: 24, badge: false, live: false }), +- title: bot.name, +- subtitle: [bot.role, isMainOf(roster, bot.id) ? t("Main Bot") : ""].filter(Boolean).join(" · "), +- badge: bot.hidden ? t("Hidden") : void 0, +- run: async () => { +- if (bot.hidden) await actions.setFlags(bot.id, { hidden: false }); +- actions.openSession(bot.id); +- } +- })), +- ...roster.rooms.filter((room2) => matchesName(room2.name)).map((room2) => ({ +- key: room2.id, +- avatar: (0, import_react12.createElement)(RoomAvatar, { room: room2, roster, size: 24 }), +- title: room2.name, +- subtitle: t("{count} members", { count: room2.members.length }), +- badge: room2.hidden ? t("Hidden") : void 0, +- run: async () => { +- if (room2.hidden) await actions.setFlags(room2.id, { hidden: false }); +- actions.openSession(room2.id); +- } +- })) +- ]; +- if (people.length) sections.push([t("Bots"), people]); +- const agents = (roster.agents ?? []).filter(() => matchesName("DSH Agent")).map((agent) => ({ +- key: agent.id, +- avatar: (0, import_react12.createElement)("span", { className: "bt-palette-agent" }, (0, import_react12.createElement)(import_dsh_client_ui_primitives3.FishLogo, { size: 18 })), +- title: "DSH Agent", +- subtitle: t("Plain dsh session"), +- run: () => actions.openAgent(agent.id) +- })); +- if (agents.length) sections.push(["DSH Agent", agents]); +- const messages = JSON.parse(found).map(([id, turn, snippet]) => { +- const bot = roster.byId[id]; +- const room2 = roster.roomsById[id]; +- return { +- key: `${id}:${turn}`, +- title: bot?.name ?? room2?.name ?? t("Bot"), +- subtitle: snippet, +- avatar: bot ? (0, import_react12.createElement)(BotAvatar, { bot, size: 24, badge: false, live: false }) : (0, import_react12.createElement)(RoomAvatar, { room: room2, roster, size: 24 }), +- run: () => actions.openSession(id) +- }; +- }); +- if (messages.length) sections.push([t("Messages"), messages]); +- const commands = [ +- { key: "cmd:new", icon: (0, import_react12.createElement)(PlusIcon), title: t("New chat"), run: () => actions.openNewChat("new") }, +- { key: "cmd:bot", icon: (0, import_react12.createElement)(PlusIcon), title: t("Create Bot"), run: () => actions.openNewChat("create") }, +- { key: "cmd:group", icon: (0, import_react12.createElement)(PeopleIcon), title: t("Create group chat"), run: () => actions.openNewChat("group") }, +- { key: "cmd:settings", icon: (0, import_react12.createElement)(GearIcon), title: t("Bot settings"), run: () => actions.openSettings("general") } +- ].filter((command) => matchesName(command.title)); +- if (commands.length) sections.push([t("Commands"), commands]); +- const rows = sections.flatMap(([, items]) => items); +- const active = Math.min(cursor, Math.max(rows.length - 1, 0)); +- const shown = rows.map((row) => row.key).join("\n"); +- const glide = useGlide(`${active} +-${shown}`, { axis: "y", ...QUICK_EDGES }); +- const listRef = glide.box; +- useFlip(listRef, shown, "[data-flip]"); +- (0, import_react12.useEffect)(() => { +- listRef.current?.querySelector("[aria-selected=true]")?.scrollIntoView({ block: "nearest" }); +- }, [active, query]); +- const choose = (row) => { +- if (!row || leaving) return; +- void Promise.resolve(row.run()).catch((error) => actions.reportError(error, "menu")); +- close(); +- }; +- let index = -1; +- return (0, import_react12.createElement)( +- "div", +- { className: "bt-palette-layer", "data-leaving": leaving || void 0 }, +- (0, import_react12.createElement)("div", { className: "bt-backdrop", onMouseDown: close }), +- (0, import_react12.createElement)( +- "div", +- { className: "bt-palette", role: "dialog", "aria-label": t("Search"), "aria-modal": true }, +- (0, import_react12.createElement)( +- "div", +- { className: "bt-palette-head" }, +- (0, import_react12.createElement)("span", { className: "bt-palette-glyph", "aria-hidden": true }, (0, import_react12.createElement)(SearchIcon)), +- (0, import_react12.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_react12.createElement)("button", { type: "button", className: "bt-palette-clear", "aria-label": t("Clear search"), onClick: () => { +- setQuery(""); +- setCursor(0); +- } }, (0, import_react12.createElement)(CloseIcon)) : null +- ), +- (0, import_react12.createElement)( +- "div", +- { ref: listRef, id: "bt-palette-list", className: "bt-palette-list", role: "listbox", "aria-label": t("Results") }, +- (0, import_react12.createElement)("span", { ref: glide.thumb, className: "bt-thumb", "aria-hidden": true }), +- rows.length === 0 ? (0, import_react12.createElement)( +- "div", +- { className: "bt-palette-empty" }, +- (0, import_react12.createElement)("span", { className: "bt-palette-empty-icon", "aria-hidden": true }, (0, import_react12.createElement)(SearchIcon)), +- (0, import_react12.createElement)("span", { className: "bt-palette-empty-label" }, t("No results")), +- (0, import_react12.createElement)("span", { className: "bt-palette-empty-hint" }, t("Try a Bot name or words from a message")) +- ) : sections.map(([title, items]) => (0, import_react12.createElement)( +- import_react12.Fragment, +- { key: title }, +- (0, import_react12.createElement)("div", { className: "bt-palette-section", role: "presentation", "data-flip": `section:${title}` }, title), +- items.map((row) => { +- index += 1; +- const at = index; +- return (0, import_react12.createElement)( +- "button", +- { +- key: row.key, +- type: "button", +- role: "option", +- className: "bt-palette-row", +- "aria-selected": at === active, +- "data-flip": row.key, +- onMouseMove: () => { +- if (cursor !== at) setCursor(at); +- }, +- onClick: () => choose(row) +- }, +- (0, import_react12.createElement)("span", { className: "bt-palette-lead" }, row.avatar ?? (0, import_react12.createElement)("span", { className: "bt-palette-cmd" }, row.icon)), +- (0, import_react12.createElement)( +- "span", +- { className: "bt-palette-text" }, +- (0, import_react12.createElement)("span", { className: "bt-palette-title" }, row.title), +- row.subtitle ? (0, import_react12.createElement)("span", { className: "bt-palette-sub" }, row.subtitle) : null +- ), +- row.badge ? (0, import_react12.createElement)("span", { className: "bt-palette-badge" }, row.badge) : null +- ); +- }) +- )) +- ) +- ) +- ); +-} +- + // src/client/model-menu.js +-var import_react13 = require("react"); +-var import_react_dom6 = require("react-dom"); ++var import_react24 = require("react"); ++var import_react_dom8 = require("react-dom"); + var MENU_EXIT_MS = 120; + function groupModels(models) { + const groups = /* @__PURE__ */ new Map(); +@@ -6367,17 +6367,10 @@ function groupModels(models) { + }); + } + var servedOf = (roster, ref) => (roster.models ?? []).find((model) => model.ref === ref); +-function modelOf(bot, roster) { +- const ref = bot.model ?? bot.appliedModel; +- if (!ref) return void 0; +- const served = servedOf(roster, ref); +- const [provider, ...rest] = ref.split("/"); +- return { ref, name: served?.name ?? rest.join("/"), provider: served?.providerName ?? provider, served: served !== void 0 }; +-} + var effortCache = /* @__PURE__ */ new Map(); + function useEfforts(served, ref, actions) { +- const [fetched, setFetched] = (0, import_react13.useState)(void 0); +- (0, import_react13.useEffect)(() => { ++ const [fetched, setFetched] = (0, import_react24.useState)(void 0); ++ (0, import_react24.useEffect)(() => { + 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) { + ]; + } + function EffortRows({ reasoning, current, onPick }) { +- return (0, import_react13.createElement)( +- import_react13.Fragment, ++ return (0, import_react24.createElement)( ++ import_react24.Fragment, + null, +- (0, import_react13.createElement)("div", { className: "bt-menu-sep", role: "separator" }), +- (0, import_react13.createElement)("div", { className: "bt-model-group", role: "presentation" }, t("Thinking level")), +- effortRows(reasoning).map((row) => (0, import_react13.createElement)( ++ (0, import_react24.createElement)("div", { className: "bt-menu-sep", role: "separator" }), ++ (0, import_react24.createElement)("div", { className: "bt-model-group", role: "presentation" }, t("Thinking level")), ++ effortRows(reasoning).map((row) => (0, import_react24.createElement)( + "button", + { + key: row.effort ?? "default", +@@ -6416,17 +6416,17 @@ function EffortRows({ reasoning, current, onPick }) { + "aria-checked": row.effort === current, + onClick: () => onPick(row.effort) + }, +- (0, import_react13.createElement)("span", { className: "bt-menu-label" }, row.label), +- (0, import_react13.createElement)("span", { className: "bt-model-tick", "aria-hidden": true }, row.effort === current ? (0, import_react13.createElement)(CheckIcon) : null) ++ (0, import_react24.createElement)("span", { className: "bt-menu-label" }, row.label), ++ (0, import_react24.createElement)("span", { className: "bt-model-tick", "aria-hidden": true }, row.effort === current ? (0, import_react24.createElement)(CheckIcon) : null) + )) + ); + } + function ModelRows({ roster, current, onPick }) { +- return groupModels(roster.models).map(([provider, list]) => (0, import_react13.createElement)( +- import_react13.Fragment, ++ return groupModels(roster.models).map(([provider, list]) => (0, import_react24.createElement)( ++ import_react24.Fragment, + { key: list[0].provider ?? provider }, +- (0, import_react13.createElement)("div", { className: "bt-model-group", role: "presentation" }, provider), +- list.map((model) => (0, import_react13.createElement)( ++ (0, import_react24.createElement)("div", { className: "bt-model-group", role: "presentation" }, provider), ++ list.map((model) => (0, import_react24.createElement)( + "button", + { + key: model.ref, +@@ -6436,20 +6436,20 @@ function ModelRows({ roster, current, onPick }) { + title: model.ref, + onClick: () => onPick(model.ref) + }, +- (0, import_react13.createElement)("span", { className: "bt-menu-label" }, model.name), +- model.ref === roster.defaultModel ? (0, import_react13.createElement)("span", { className: "bt-menu-hint" }, t("Default")) : null, +- (0, import_react13.createElement)("span", { className: "bt-model-tick", "aria-hidden": true }, model.ref === current ? (0, import_react13.createElement)(CheckIcon) : null) ++ (0, import_react24.createElement)("span", { className: "bt-menu-label" }, model.name), ++ model.ref === roster.defaultModel ? (0, import_react24.createElement)("span", { className: "bt-menu-hint" }, t("Default")) : null, ++ (0, import_react24.createElement)("span", { className: "bt-model-tick", "aria-hidden": true }, model.ref === current ? (0, import_react24.createElement)(CheckIcon) : null) + )) + )); + } +-var Caret = () => (0, import_react13.createElement)("svg", { className: "bt-model-caret", width: 12, height: 12, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.8, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, (0, import_react13.createElement)("path", { d: "M5.5 8l4.5 4.5L14.5 8" })); ++var Caret = () => (0, import_react24.createElement)("svg", { className: "bt-model-caret", width: 12, height: 12, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.8, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, (0, import_react24.createElement)("path", { d: "M5.5 8l4.5 4.5L14.5 8" })); + function useAnchoredMenu() { +- const [place, setPlace] = (0, import_react13.useState)(null); ++ const [place, setPlace] = (0, import_react24.useState)(null); + const [shownPlace, leaving] = useLinger(place, MENU_EXIT_MS); +- const button = (0, import_react13.useRef)(null); +- const menu = (0, import_react13.useRef)(null); ++ const button = (0, import_react24.useRef)(null); ++ const menu = (0, import_react24.useRef)(null); + const close = () => setPlace(null); +- (0, import_react13.useEffect)(() => { ++ (0, import_react24.useEffect)(() => { + 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() { + return { isOpen: place !== null, shownPlace, leaving, button, menu, open, close }; + } + function ModelMenu({ bot, roster, actions, run, wide = false, onPick, onPickEffort }) { ++ if (bot?.remote !== void 0) return null; + 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 + else if (effort !== bot.effort) void run(actions.updateBot(bot.id, { effort: effort ?? null })); }; -- const models = roster.models ?? []; + const models = roster.models ?? []; - return (0, import_react13.createElement)( - import_react13.Fragment, -+ const canReference = typeof inputActions?.captureInsertion === "function" && actions.canReferenceChat(); -+ return (0, import_react23.createElement)( -+ import_react23.Fragment, ++ return (0, import_react24.createElement)( ++ import_react24.Fragment, null, - (0, import_react13.createElement)( -- "button", -+ (0, import_react23.createElement)("button", { -+ ref: button, -+ type: "button", -+ className: "bt-plus", -+ "aria-label": t("Add"), -+ title: t("Add"), -+ "aria-haspopup": "menu", -+ "aria-expanded": Boolean(open), -+ onMouseDown: keepComposerFocus, -+ onClick: toggle -+ }, (0, import_react23.createElement)(PlusIcon)), -+ shown ? (0, import_react_dom7.createPortal)((0, import_react23.createElement)( -+ "div", ++ (0, import_react24.createElement)( + "button", { -- ref: button, -- type: "button", -- className: "bt-model-trigger", -- "data-wide": wide || void 0, -- "aria-haspopup": "menu", -- "aria-expanded": isOpen, -- "aria-label": t("Model: {model}", { model: shown?.name ?? t("none") }), -- "data-missing": shown !== void 0 && !shown.served && models.length > 0 ? "" : void 0, -- onClick: toggle -+ ref: menu, -+ className: "bt-menu bt-menu-up bt-plus-menu", -+ role: "menu", -+ "aria-label": t("Add"), -+ style: { left: shown.left, bottom: shown.bottom }, -+ "data-leaving": leaving || void 0 + ref: button, +@@ -6527,37 +6527,38 @@ function ModelMenu({ bot, roster, actions, run, wide = false, onPick, onPickEffo + "data-missing": shown !== void 0 && !shown.served && models.length > 0 ? "" : void 0, + onClick: toggle }, - (0, import_react13.createElement)( -- "span", -- { className: "bt-model-copy" }, ++ (0, import_react24.createElement)( + "span", + { className: "bt-model-copy" }, - (0, import_react13.createElement)("span", { className: "bt-model-name" }, shown?.name ?? t("Choose a model")), - shown ? (0, import_react13.createElement)("span", { className: "bt-model-provider" }, shown.served || models.length === 0 ? effortLabel === void 0 ? shown.provider : `${shown.provider} · ${effortLabel}` : t("{provider} · not available", { provider: shown.provider })) : null -- ), ++ (0, import_react24.createElement)("span", { className: "bt-model-name" }, shown?.name ?? t("Choose a model")), ++ shown ? (0, import_react24.createElement)("span", { className: "bt-model-provider" }, shown.served || models.length === 0 ? effortLabel === void 0 ? shown.provider : `${shown.provider} · ${effortLabel}` : t("{provider} · not available", { provider: shown.provider })) : null + ), - (0, import_react13.createElement)(Caret) -- ), ++ (0, import_react24.createElement)(Caret) + ), - shownPlace ? (0, import_react_dom6.createPortal)((0, import_react13.createElement)( -- "div", -- { ref: menu, className: `bt-menu bt-model-menu${shownPlace.bottom !== void 0 ? " bt-menu-up" : ""}`, role: "menu", "aria-label": t("Model for {name}", { name: bot.name }), style: shownPlace, "data-leaving": leaving || void 0 }, ++ shownPlace ? (0, import_react_dom8.createPortal)((0, import_react24.createElement)( + "div", + { ref: menu, className: `bt-menu bt-model-menu${shownPlace.bottom !== void 0 ? " bt-menu-up" : ""}`, role: "menu", "aria-label": t("Model for {name}", { name: bot.name }), style: shownPlace, "data-leaving": leaving || void 0 }, - models.length > 0 ? (0, import_react13.createElement)(ModelRows, { roster, current, onPick: pick2 }) : (0, import_react13.createElement)("div", { className: "bt-model-none" }, t("No models are set up in DeepSeek Harness yet.")), - effortRows(reasoning).length > 0 ? (0, import_react13.createElement)(EffortRows, { reasoning, current: picked, onPick: pickEffort }) : null, - (0, import_react13.createElement)("div", { className: "bt-menu-sep", role: "separator" }), - (0, import_react13.createElement)("button", { type: "button", role: "menuitem", onClick: () => { -- close(); ++ models.length > 0 ? (0, import_react24.createElement)(ModelRows, { roster, current, onPick: pick2 }) : (0, import_react24.createElement)("div", { className: "bt-model-none" }, t("No models are set up in DeepSeek Harness yet.")), ++ effortRows(reasoning).length > 0 ? (0, import_react24.createElement)(EffortRows, { reasoning, current: picked, onPick: pickEffort }) : null, ++ (0, import_react24.createElement)("div", { className: "bt-menu-sep", role: "separator" }), ++ (0, import_react24.createElement)("button", { type: "button", role: "menuitem", onClick: () => { + close(); - openHarnessSettings(); - } }, (0, import_react13.createElement)(SlidersIcon), (0, import_react13.createElement)("span", { className: "bt-menu-label" }, t("Models and providers…"))) -+ (0, import_react23.createElement)("button", { type: "button", role: "menuitem", onClick: attach }, (0, import_react23.createElement)(PaperclipIcon), (0, import_react23.createElement)("span", { className: "bt-menu-label" }, t("Add files"))), -+ canReference ? (0, import_react23.createElement)("button", { type: "button", role: "menuitem", onMouseDown: keepComposerFocus, onClick: reference }, (0, import_react23.createElement)(ChatsIcon), (0, import_react23.createElement)("span", { className: "bt-menu-label" }, t("Reference a chat"))) : null, -+ locked ? null : (0, import_react23.createElement)("button", { type: "button", role: "menuitem", onClick: () => { -+ setOpen(null); -+ actions.openSettings("schedules", target); -+ } }, (0, import_react23.createElement)(ClockIcon), (0, import_react23.createElement)("span", { className: "bt-menu-label" }, t("Scheduled task"))) ++ openHarnessModels(); ++ } }, (0, import_react24.createElement)(SlidersIcon), (0, import_react24.createElement)("span", { className: "bt-menu-label" }, t("Models and providers…"))) ), document.body) : null ); } --function ModelChoiceDock({ sessionId, useRoster, actions }) { -- const roster = useRoster((value) => value); -- const bot = roster.byId[sessionId]; + function ModelChoiceDock({ sessionId, useRoster, actions }) { + const roster = useRoster((value) => value); + const bot = roster.byId[sessionId]; - const [busy, setBusy] = (0, import_react13.useState)(""); - const [error, setError] = (0, import_react13.useState)(""); -- const shown = Boolean(bot) && currentPart(bot) === sessionId && roster.ready; ++ const [busy, setBusy] = (0, import_react24.useState)(""); ++ const [error, setError] = (0, import_react24.useState)(""); + const shown = Boolean(bot) && currentPart(bot) === sessionId && roster.ready; - (0, import_react13.useEffect)(() => { -- if (shown) void actions.refreshModels?.(); -- }, [shown, sessionId]); -- if (!bot || currentPart(bot) !== sessionId || !roster.ready) return null; -- 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; -- const pick2 = (ref) => { -- setBusy(ref); -- setError(""); -- void Promise.resolve(actions.updateBot(bot.id, { model: ref })).catch((failure) => { -- setError(failure?.message ?? String(failure)); -- void actions.refreshModels?.(); -- }).finally(() => setBusy("")); -- }; ++ (0, import_react24.useEffect)(() => { + if (shown) void actions.refreshModels?.(); + }, [shown, sessionId]); + if (!bot || currentPart(bot) !== sessionId || !roster.ready) return null; ++ if (bot.remote) return bot.remote.gone ? (0, import_react24.createElement)(RemoteGoneDock, { bot }) : null; + 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; +@@ -6569,13 +6569,13 @@ function ModelChoiceDock({ sessionId, useRoster, actions }) { + void actions.refreshModels?.(); + }).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 }); - const choices = models.length === 0 ? setupChoices(actions).map(([label, run]) => (0, import_react13.createElement)("button", { key: label, type: "button", className: "bt-model-chip", onClick: run }, label)) : [ - ...groupModels(models).map(([provider, list]) => (0, import_react13.createElement)( -- "div", -- { key: list[0].provider ?? provider, className: "bt-model-choice-group", role: "group", "aria-label": provider }, ++ const text2 = 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 }); ++ const choices = models.length === 0 ? [...setupChoices(actions).map(([label, run]) => (0, import_react24.createElement)("button", { key: label, type: "button", className: "bt-model-chip", onClick: run }, label)), (0, import_react24.createElement)(KeyHelpLink, { key: "key-help" })] : [ ++ ...groupModels(models).map(([provider, list]) => (0, import_react24.createElement)( + "div", + { key: list[0].provider ?? provider, className: "bt-model-choice-group", role: "group", "aria-label": provider }, - (0, import_react13.createElement)("div", { className: "bt-model-choice-provider", "aria-hidden": true }, provider), - (0, import_react13.createElement)("div", { className: "bt-model-choice-chips" }, list.map((model) => (0, import_react13.createElement)("button", { -- key: model.ref, -- type: "button", -- className: "bt-model-chip", -- disabled: busy !== "", -- "aria-busy": busy === model.ref || void 0, -- "aria-label": `${model.name} · ${provider}`, -- title: model.ref === roster.defaultModel ? `${model.ref} · ${t("Harness default")}` : model.ref, -- onClick: () => pick2(model.ref) ++ (0, import_react24.createElement)("div", { className: "bt-model-choice-provider", "aria-hidden": true }, provider), ++ (0, import_react24.createElement)("div", { className: "bt-model-choice-chips" }, list.map((model) => (0, import_react24.createElement)("button", { + key: model.ref, + type: "button", + className: "bt-model-chip", +@@ -6584,50 +6584,115 @@ function ModelChoiceDock({ sessionId, useRoster, actions }) { + "aria-label": `${model.name} · ${provider}`, + title: model.ref === roster.defaultModel ? `${model.ref} · ${t("Harness default")}` : model.ref, + onClick: () => pick2(model.ref) - }, model.name, busy === model.ref ? (0, import_react13.createElement)("span", { className: "bt-model-spin", "aria-label": t("Saving…") }) : null))) -- )), ++ }, model.name, busy === model.ref ? (0, import_react24.createElement)("span", { className: "bt-model-spin", "aria-label": t("Saving…") }) : null))) + )), - (0, import_react13.createElement)("button", { key: "manage", type: "button", className: "bt-model-chip bt-model-chip-quiet", onClick: openHarnessSettings }, (0, import_react13.createElement)(SlidersIcon), t("Manage models")) -- ]; ++ (0, import_react24.createElement)("button", { key: "manage", type: "button", className: "bt-model-chip bt-model-chip-quiet", onClick: openHarnessModels }, (0, import_react24.createElement)(SlidersIcon), t("Manage models")) + ]; - return (0, import_react13.createElement)( -- "div", ++ return (0, import_react24.createElement)( ++ "div", ++ { className: "bt-model-dock" }, ++ (0, import_react24.createElement)( ++ "div", ++ { className: "bt-astack", "data-lead": "" }, ++ (0, import_react24.createElement)( ++ "div", ++ { className: "bt-msg" }, ++ (0, import_react24.createElement)( ++ "div", ++ { className: "bt-msg-line" }, ++ (0, import_react24.createElement)(BotMark, { bot, size: 22 }), ++ (0, import_react24.createElement)("div", { className: "bt-bubble" }, text2) ++ ) ++ ) ++ ), ++ (0, import_react24.createElement)("div", { className: "bt-model-chips", role: "group", "aria-label": t("Choose a model") }, choices), ++ error ? (0, import_react24.createElement)("div", { className: "bt-secret-error bt-model-error", role: "alert" }, error) : null ++ ); ++} ++function RemoteGoneDock({ bot }) { ++ return (0, import_react24.createElement)( + "div", - { className: "bt-model-dock" }, - (0, import_react13.createElement)( -- "div", -- { className: "bt-astack", "data-lead": "" }, ++ { className: "bt-model-dock", "data-flybot": "remote-gone" }, ++ (0, import_react24.createElement)( + "div", + { className: "bt-astack", "data-lead": "" }, - (0, import_react13.createElement)( -- "div", -- { className: "bt-msg" }, ++ (0, import_react24.createElement)( + "div", + { className: "bt-msg" }, - (0, import_react13.createElement)( -- "div", -- { className: "bt-msg-line" }, ++ (0, import_react24.createElement)( + "div", + { className: "bt-msg-line" }, - (0, import_react13.createElement)(BotMark, { bot, size: 22 }), - (0, import_react13.createElement)("div", { className: "bt-bubble" }, text) -- ) -- ) ++ (0, import_react24.createElement)(BotMark, { bot, size: 22 }), ++ (0, import_react24.createElement)("div", { className: "bt-bubble" }, t("{name} ({handle}) is no longer shared with you, so messages to it cannot be delivered. It runs on its owner's machine, so another model cannot stand in. Ask its owner for a new invite.", { name: bot.name, handle: bot.remote.handle })) + ) + ) - ), - (0, import_react13.createElement)("div", { className: "bt-model-chips", role: "group", "aria-label": t("Choose a model") }, choices), - error ? (0, import_react13.createElement)("div", { className: "bt-secret-error bt-model-error", role: "alert" }, error) : null -- ); --} --function setupChoices(actions) { -- return [ -- actions.canSignIn?.() ? [t("Sign in to DeepSeek"), () => actions.signIn()] : null, -- [t("Enter a DeepSeek API key"), () => openHarnessOnboarding("deepseek-official")], ++ ) + ); + } + function setupChoices(actions) { + return [ + actions.canSignIn?.() ? [t("Sign in to DeepSeek"), () => actions.signIn()] : null, + [t("Enter a DeepSeek API key"), () => openHarnessOnboarding("deepseek-official")], - [t("Open settings to set up a custom model"), openHarnessSettings] -- ].filter(Boolean); --} --function DeepSeekSignIn({ useUi, actions }) { -- const open = useUi((state) => state.signIn); -- const entry = open ? actions.signInEntry?.() : null; -- if (!entry) return null; -- return (0, import_react13.createElement)(SignInHost, { key: open, entry, actions }); -+ -+// src/client/at-menu.js -+var BOTS_SOURCE = "ds-bot"; -+var CHATS_SOURCE = "ds-bot-chats"; -+var ROOM_ALL = ["all", "everyone", "所有人", "全体"]; -+var OTHER_CHATS_MAX = 8; -+var conversationOf = (roster, sessionId) => { -+ if (!roster.ready) return null; -+ const room2 = roster.roomsById[sessionId]; -+ if (room2) return { room: room2 }; -+ const self = roster.byId[sessionId]; -+ return self ? { self } : null; -+}; -+function mentionRows(roster, sessionId, query = "") { -+ const here = conversationOf(roster, sessionId); -+ if (here === null) return null; -+ const face = isFaceRoom(here.room); -+ const live = (here.room?.participants ?? []).filter((p) => p.leftSeq === void 0); -+ const bots = here.room ? [ -+ ...here.room.members.map((id) => roster.byId[id]).filter(Boolean).map((bot) => face ? { ...bot, label: faceLabel(myOwnerName(roster, live, bot), bot.name) } : bot), -+ ...live.filter((p) => p.kind === "human").map((p) => ({ id: p.id, name: p.displayName || p.id, kind: "human", role: p.role })), -+ // A remote seat already listed under its handle (a duplicate participant record) is offered once. -+ ...uniqueBy(live.filter((p) => p.remote), (p) => p.handle || p.id).map((p) => ({ -+ id: p.handle || p.id, -+ // A face room inserts the disambiguated title. An ordinary group inserts the name the -+ // room routes on (#1141): never "@@handle.home (owner)" and never "(Friend)". -+ name: face ? remoteBotTitle(p) : remoteCallName(p), -+ kind: "bot", -+ remote: p, -+ handle: p.handle, -+ label: face ? faceLabel(ownerNameOf(p), cleanPeerName(p.displayName || p.name, "Bot")) : remoteBotTitle(p) -+ })) -+ ] : listedChatBots(roster).filter((bot) => bot.id !== here.self.id); -+ const needle = query.trim().toLowerCase(); -+ const rows = bots.filter((bot) => bot.name.toLowerCase().includes(needle) || String(bot.label ?? "").toLowerCase().includes(needle) || bot.remote && normHandle(remoteRecord(bot)?.handle || bot.handle).includes(normHandle(needle))).map((bot) => ({ bot, text: `@${bot.name} ` })); -+ const everyone = t("Everyone"); -+ if (here.room && bots.length > 1 && [everyone, ...ROOM_ALL].some((word) => word.toLowerCase().startsWith(needle))) { -+ rows.push({ room: here.room, count: bots.filter((bot) => bot.kind !== "human").length, text: `@${everyone} ` }); -+ } -+ if (face) return rows; -+ const seatHandles = new Set(bots.filter((bot) => bot.remote).map((bot) => normHandle(remoteRecord(bot)?.handle)).filter(Boolean)); -+ if (here.room) return [...rows, ...remoteMentionRows(roster, query).filter((row) => !seatHandles.has(normHandle(row.remote.handle)))]; -+ const remoteHandles = new Set(remoteMentionRows(roster, "").map((row) => normHandle(row.remote.handle))); -+ return [ -+ ...rows.filter((row) => !(row.bot && remoteHandles.has(normHandle(remoteRecord(row.bot)?.handle)))), -+ ...remoteMentionRows(roster, query) -+ ]; ++ [t("Open settings to set up a custom model"), openHarnessModels] + ].filter(Boolean); } --function SignInHost({ entry, actions }) { -- const [ops] = (0, import_react13.useState)(() => entry.inject?.() ?? {}); -- const close = () => actions.closeSignIn(); -- const useHook = (source) => (selector) => (0, import_react13.useSyncExternalStore)(source.subscribe, () => selector(source.getSnapshot())); -- const [hooks] = (0, import_react13.useState)(() => ({ useAccount: useHook(ops.hooks.account), useTheme: useHook(ops.hooks.theme) })); -- return (0, import_react13.createElement)(entry.component, { -- ...ops, -- ...hooks, -- t: actions.accountT(), -- complete: close, -- useApiKey: () => { -- close(); -- openHarnessOnboarding("deepseek-official"); -- } -+var isFaceRoom = (room2) => room2?.origin === "face-to-face"; -+var memberCount = (count) => t(count === 1 ? "{count} member" : "{count} members", { count }); -+var botCount = (count) => count === 1 ? t("1 Bot") : t("{count} Bots", { count }); -+var normHandle = (handle) => String(handle ?? "").trim().replace(/^@+/, "").toLowerCase(); -+var remoteRecord = (bot) => bot?.remote ? bot.remote === true ? bot : bot.remote : null; -+var myOwnerName = (roster, live, bot) => { -+ const seat = live.find((p) => !p.remote && p.kind !== "human" && (p.id === bot.id || bot.handle && p.handle === bot.handle)); -+ return seatOwnerName(seat?.ownerName, seat?.handle) || cleanPeerName(roster.self?.displayName, ""); -+}; -+var uniqueBy = (list, key) => { -+ const seen = /* @__PURE__ */ new Set(); -+ return list.filter((item) => { -+ const k = key(item); -+ if (!k || seen.has(k)) return !k; -+ seen.add(k); -+ return true; - }); -+}; -+var faceLabel = (person, bot) => person ? `${bot} (${person})` : bot; -+function chatRows(roster, list, sessionId) { -+ const here = conversationOf(roster, sessionId); -+ if (here === null) return []; -+ const byId = list?.byId ?? {}; -+ const bots = roster.bots.filter((bot) => bot.id !== here.self?.id).map((bot) => { -+ const id = currentPart(bot); -+ return { sessionId: id, label: t("Chat with {name}", { name: bot.name }), bot, updatedAt: byId[id]?.updatedAt }; -+ }); -+ const team = /* @__PURE__ */ new Set([ -+ ...roster.bots.flatMap((bot) => [bot.id, currentPart(bot), ...bot.parts ?? []]), -+ ...roster.rooms.flatMap((room2) => [room2.id, ...room2.parts ?? []]) -+ ]); -+ const others = (list?.ids ?? []).map((id) => byId[id]).filter((row) => row !== void 0 && row.id !== sessionId && !team.has(row.id) && row.origin !== "subagent" && row.blank !== true).sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0)).slice(0, OTHER_CHATS_MAX).map((row) => ({ sessionId: row.id, label: row.displayTitle || row.title || row.id, updatedAt: row.updatedAt })); -+ return [...bots, ...others]; -+} -+function sessionMention(sessionId, label) { -+ let binary = ""; -+ for (const byte of new TextEncoder().encode(JSON.stringify(sessionId))) binary += String.fromCharCode(byte); -+ const payload = btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); -+ return `@[${label.replace(/[\\\]]/g, (mark) => `\\${mark}`)}](dsh-session:${payload})`; -+} -+function atSources({ roster, sessions, launched, iconFor }) { -+ const bots = { -+ trigger: "@", -+ name: BOTS_SOURCE, -+ order: -20, -+ showGroupTitle: false, -+ async candidates(session, { query }) { -+ const snapshot = roster.getSnapshot(); -+ const face = isFaceRoom(snapshot.roomsById?.[session.sessionId]); -+ const team = (mentionRows(snapshot, session.sessionId, query) ?? []).map((row) => ({ -+ // A friend's Bot by the one name the row inserts, "Chief (Bob)" (#967); its handle is the -+ // second line (#384). A seat carries its own name; a friend's chat is named as its sidebar -+ // row and header (#498). -+ name: row.bot?.remote && !face ? row.bot.kind === "bot" ? remoteBotTitle(remoteRecord(row.bot)) : friendBotTitle(row.bot) : row.bot ? row.bot.label ?? row.bot.name : row.remote ? row.text.replace(/^@/, "").trimEnd() : t("Everyone"), -+ ...row.bot?.role && !face ? { description: row.bot.role } : {}, -+ ...row.remote ? { description: row.remote.handle } : {}, -+ ...row.bot?.remote && !face && remoteRecord(row.bot).handle ? { description: remoteRecord(row.bot).handle } : {}, -+ // A face room counts the Bots in it, mine and everyone else's (#305, #457). -+ ...row.room ? { description: face ? botCount(row.count) : memberCount(row.count) } : {}, -+ icon: iconFor(row), -+ section: t("Bots"), -+ value: row.text -+ })); -+ if (mentionRows(snapshot, session.sessionId, "") === null || face) return team; -+ const extra = (await mentionCandidates(query, { sessionId: session.sessionId, roster: snapshot })).map((row) => ({ -+ name: row.name, -+ ...row.description ? { description: row.description } : {}, -+ icon: row.icon ?? "session", -+ section: row.section ?? "FlyBot", -+ value: row.value -+ })); -+ return [...team, ...extra]; -+ }, -+ onPick: ({ candidate }) => candidate.value ? { text: candidate.value } : void 0 -+ }; -+ const chats = { -+ trigger: "@", -+ name: CHATS_SOURCE, -+ order: -10, -+ showGroupTitle: false, -+ async candidates(session) { -+ if (!launched(session.sessionId)) return []; -+ return chatRows(roster.getSnapshot(), sessions.list.getSnapshot(), session.sessionId).map((row) => ({ -+ name: row.bot ? row.bot.name : row.label, -+ ...row.updatedAt ? { description: relativeShort(row.updatedAt) } : {}, -+ icon: row.bot ? iconFor(row) : "session", -+ section: row.bot ? t("Bots") : t("Conversations"), -+ value: JSON.stringify({ sessionId: row.sessionId, label: row.label }) -+ })); -+ }, -+ onPick({ candidate }) { -+ const { sessionId, label } = JSON.parse(candidate.value); -+ const mention = sessionMention(sessionId, label); -+ return { insert: { source: CHATS_SOURCE, ref: mention, label, appearance: "session", clipboardText: mention } }; -+ }, -+ codec: { clipboardText: (ref) => ref, serialize: (ref) => Promise.resolve(ref) } ++var KEY_HELP_URL = "https://github.com/ThinkFlowLab/FlyBot/blob/main/docs/deepseek-api-key.md"; ++function KeyHelpLink() { ++ return (0, import_react24.createElement)("a", { className: "bt-model-help", href: KEY_HELP_URL, target: "_blank", rel: "noopener noreferrer" }, t("How to set a DeepSeek API key")); ++} ++function missingModelKey(message) { ++ const text2 = String(message ?? ""); ++ if (!/\bno api key\b|\bmissing (?:an? )?api key\b|\bapi key (?:is )?(?:missing|not set|not configured)\b/i.test(text2)) return void 0; ++ return { route: /provider route "([^"]+)"/i.exec(text2)?.[1] ?? "" }; ++} ++function modelKeyChoices(actions, route) { ++ if (/^deepseek/i.test(route)) return setupChoices(actions); ++ return [[t("Manage models"), openHarnessModels]]; ++} ++function TurnErrorCell({ node, t: chatT, actions }) { ++ const data = node?.data ?? {}; ++ const code = typeof data.code === "string" && data.code ? data.code : void 0; ++ const raw = String(data.message ?? ""); ++ const shell = (key, fallback) => { ++ const text2 = typeof chatT === "function" ? chatT(key) : void 0; ++ return typeof text2 === "string" && text2 !== "" && text2 !== key ? text2 : fallback; + }; -+ return [bots, chats]; ++ const missing = missingModelKey(raw); ++ const title = code === "ACCOUNT_SIGNED_OUT" ? shell("message.accountStopped", t("This turn failed")) : shell("message.turnError", t("This turn failed")); ++ const message = missing ? t("This Bot's model has no API key yet. Set one up, then send your message again.") : code === "ACCOUNT_SIGNED_OUT" ? shell("message.failure.accountSignedOut", raw) : code === "ACCOUNT_SIGN_IN_REQUIRED" ? shell("message.failure.accountSignInRequired", raw) : code === "QUOTA" || code === "ACCOUNT_QUOTA" ? shell("message.failure.quota", raw) : code === "AUTH" ? shell("message.failure.auth", raw) : raw; ++ const choices = missing ? modelKeyChoices(actions ?? {}, missing.route) : []; ++ const deepseek = missing ? /^deepseek/i.test(missing.route) : false; ++ return (0, import_react24.createElement)( ++ "div", ++ { className: "bt-turn-error", role: "status", "data-missing-key": missing ? "" : void 0 }, ++ (0, import_react24.createElement)("span", { className: "bt-turn-error-dot", "aria-hidden": true }), ++ (0, import_react24.createElement)( ++ "div", ++ { className: "bt-turn-error-copy" }, ++ (0, import_react24.createElement)("span", { className: "bt-turn-error-title" }, title), ++ (0, import_react24.createElement)("span", { className: "bt-turn-error-message" }, message), ++ code ? (0, import_react24.createElement)("code", { className: "bt-turn-error-code" }, code) : null ++ ), ++ choices.length > 0 ? (0, import_react24.createElement)( ++ "div", ++ { className: "bt-turn-error-actions" }, ++ choices.map(([label, run]) => (0, import_react24.createElement)("button", { key: label, type: "button", className: "bt-model-chip", onClick: run }, label)), ++ deepseek ? (0, import_react24.createElement)(KeyHelpLink, { key: "key-help" }) : null ++ ) : null ++ ); ++} + function DeepSeekSignIn({ useUi, actions }) { + const open = useUi((state) => state.signIn); + const entry = open ? actions.signInEntry?.() : null; + if (!entry) return null; +- return (0, import_react13.createElement)(SignInHost, { key: open, entry, actions }); ++ return (0, import_react24.createElement)(SignInHost, { key: open, entry, actions }); + } + function SignInHost({ entry, actions }) { +- const [ops] = (0, import_react13.useState)(() => entry.inject?.() ?? {}); ++ const [ops] = (0, import_react24.useState)(() => entry.inject?.() ?? {}); + const close = () => actions.closeSignIn(); +- const useHook = (source) => (selector) => (0, import_react13.useSyncExternalStore)(source.subscribe, () => selector(source.getSnapshot())); +- const [hooks] = (0, import_react13.useState)(() => ({ useAccount: useHook(ops.hooks.account), useTheme: useHook(ops.hooks.theme) })); +- return (0, import_react13.createElement)(entry.component, { ++ const useHook = (source) => (selector) => (0, import_react24.useSyncExternalStore)(source.subscribe, () => selector(source.getSnapshot())); ++ const [hooks] = (0, import_react24.useState)(() => ({ useAccount: useHook(ops.hooks.account), useTheme: useHook(ops.hooks.theme) })); ++ return (0, import_react24.createElement)(entry.component, { + ...ops, + ...hooks, + t: actions.accountT(), +@@ -6639,6 +6639,16 @@ function SignInHost({ entry, actions }) { + }); } --var MODEL_CSS = ` --.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} --.bt-model-trigger[data-missing] .bt-model-provider{color:#c21d2e} --.bt-model-trigger[data-wide]{display:flex;width:100%;min-height:48px;padding:7px 12px;justify-content:space-between;border-radius:12px;background:var(--bt-card);box-shadow:inset 0 0 0 1px var(--bt-line)} --.bt-model-trigger[data-wide]:hover,.bt-model-trigger[data-wide][aria-expanded=true]{background:var(--bt-hover)} --.bt-model-copy{display:flex;flex-direction:column;min-width:0} --.bt-model-name{font-size:13px;line-height:18px;font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} --.bt-model-provider{font-size:11px;line-height:14px;color:var(--bt-ink-3);overflow:hidden;text-overflow:ellipsis;white-space:nowrap} --.bt-model-caret{flex:none;color:var(--bt-ink-3);transition:${spring(SPRING_SHAPE, "transform")}} --.bt-model-trigger[aria-expanded=true] .bt-model-caret{transform:rotate(180deg)} --.bt-model-menu{overflow-y:auto;scrollbar-width:thin} --.bt-model-group{flex:none;padding:8px 8px 2px;font-size:11px;line-height:14px;font-weight:500;color:var(--bt-ink-3);text-transform:none} --.bt-model-group:first-child{padding-top:2px} --.bt-model-tick{flex:none;width:16px;height:16px;display:inline-flex;color:var(--bt-ink-2)} --.bt-model-tick svg{animation:bt-check-pop ${SPRING_POP.duration}ms ${SPRING_POP.easing} 60ms backwards} --.bt-model-none{padding:8px;font-size:13px;line-height:18px;color:var(--bt-ink-2)} --.bt-model-dock{width:100%;max-width:var(--dsh-chat-content-width,748px);margin:0 auto;display:flex;flex-direction:column;align-items:flex-start;gap:8px;padding:0 0 10px;box-sizing:border-box;animation:bt-model-in ${SPRING_POP.duration}ms ${SPRING_POP.easing} backwards} --.bt-model-chips{display:flex;flex-wrap:wrap;gap:6px;margin-left:30px;max-width:min(560px,calc(100% - 30px));max-height:min(240px,36vh);overflow-y:auto;scrollbar-width:thin} --.bt-model-choice-group{display:flex;flex-direction:column;gap:6px;width:100%;min-width:0} --.bt-model-choice-provider{padding-top:4px;font-size:12px;line-height:16px;font-weight:500;color:var(--bt-ink-2);overflow-wrap:anywhere} --.bt-model-choice-chips{display:flex;flex-wrap:wrap;gap:6px} --.bt-model-chip{display:inline-flex;align-items:center;gap:6px;min-height:30px;padding:5px 12px;border:.5px solid var(--bt-line-2);border-radius:999px;background:var(--bt-surface,transparent);color:var(--bt-ink);font:inherit;font-size:13px;line-height:18px;cursor:pointer;transition:background-color .12s ease,border-color .12s ease} --.bt-model-chip:hover:not(:disabled){background:var(--bt-hover)} --.bt-model-chip:focus-visible{outline:2px solid var(--bt-accent);outline-offset:1px} --.bt-model-chip-quiet{color:var(--bt-ink-2)} --.bt-model-chip svg{width:14px;height:14px} --.bt-model-chip:disabled{cursor:default} --.bt-model-chip:disabled:not([aria-busy]){opacity:.5} --.bt-model-error{margin-left:30px} + var MODEL_CSS = ` ++.bt-turn-error{display:grid;grid-template-columns:10px minmax(0,1fr);column-gap:8px;row-gap:8px;padding:2px 0;font-size:13px;line-height:20px} ++.bt-turn-error-dot{width:6px;height:6px;margin-top:7px;border-radius:50%;background:#e02135} ++.bt-turn-error-copy{min-width:0;overflow-wrap:anywhere} ++.bt-turn-error-title{margin-right:6px;font-weight:600;color:#e02135} ++.bt-turn-error-message{color:var(--bt-ink-2)} ++.bt-turn-error-code{margin-left:6px;font-size:11px;color:var(--bt-ink-3)} ++.bt-turn-error-actions{grid-column:2;display:flex;flex-wrap:wrap;align-items:center;gap:6px} ++.bt-model-help{display:inline-flex;align-items:center;min-height:28px;padding:0 4px;font-size:12px;line-height:16px;color:var(--bt-ink-2);text-decoration:underline;text-underline-offset:2px} ++.bt-model-help:hover{color:var(--bt-ink)} ++.bt-model-help:focus-visible{outline:2px solid var(--bt-accent);outline-offset:1px;border-radius:4px} + .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} +@@ -6669,501 +6669,251 @@ var MODEL_CSS = ` + .bt-model-chip:disabled{cursor:default} + .bt-model-chip:disabled:not([aria-busy]){opacity:.5} + .bt-model-error{margin-left:30px} -@keyframes bt-model-in{from{opacity:0;transform:translateY(6px)}} -.bt-model-spin{flex:none;width:14px;height:14px;border-radius:50%;border:1.5px solid var(--bt-line-2);border-top-color:var(--bt-ink-2);animation:bt-model-spin .7s linear infinite} -@keyframes bt-model-spin{to{transform:rotate(360deg)}} -@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}} -`; - +- -// src/client/folder-menu.js -var import_react14 = require("react"); -var import_react_dom7 = require("react-dom"); @@ -17218,157 +16759,26 @@ diff --git a/client.js b/client.js - setProblem(""); - void Promise.resolve(actions.folders()).then(setInfo).catch(() => setInfo((last) => last ?? { team: null, picker: null, folders: [] })); - open(); -+// src/client/face-at-filter.js -+var REFERENCE_SOURCE = "reference"; -+var optionId = (source, index) => `dsh-slash-option-${source}-${index}`; -+var isSessionRow = (group, item) => group.source === REFERENCE_SOURCE && item?.icon === "session"; -+var same = (a, b) => a != null && b != null && a.source === b.source && a.index === b.index; -+function planFaceMenu(state, previous = null) { -+ const hidden = []; -+ const ready = []; -+ let shown = 0; -+ let loading = false; -+ for (const group of state?.groups ?? []) { -+ const items = group.items ?? []; -+ if (group.status !== "ready" && items.length === 0) loading = true; -+ items.forEach((item, index) => { -+ const hide = isSessionRow(group, item); -+ if (hide) hidden.push({ source: group.source, index, title: item.section !== void 0 && item.section !== items[index - 1]?.section }); -+ else shown += 1; -+ if (group.status === "ready") ready.push({ source: group.source, index, hide }); -+ }); -+ } -+ const open = state?.open === true; -+ const blank3 = open && hidden.length > 0 && shown === 0 && !loading; -+ const dismiss = blank3 && (state.groups ?? []).every((group) => group.status === "ready"); -+ return { hidden, blank: blank3, dismiss, target: dismiss ? null : steer(ready, state?.highlight ?? null, previous) }; -+} -+function steer(ready, highlight, previous) { -+ const at = ready.findIndex((row) => same(row, highlight)); -+ if (at < 0 || !ready[at].hide) return null; -+ const from = ready.findIndex((row) => same(row, previous)); -+ const n = ready.length; -+ const dir = from >= 0 && from !== at && (from - at + n) % n === 1 ? -1 : 1; -+ for (let step = 1; step < n; step += 1) { -+ const row = ready[((at + dir * step) % n + n) % n]; -+ if (!row.hide) return { source: row.source, index: row.index }; -+ } -+ return null; -+} -+function faceMenuCss({ hidden, blank: blank3 }) { -+ if (hidden.length === 0) return ""; -+ const rows = hidden.map((row) => `[data-trigger-menu] [id="${optionId(row.source, row.index)}"]`); -+ const titles = hidden.filter((row) => row.title).map((row) => `[data-trigger-menu] [role="presentation"]:has(+ [id="${optionId(row.source, row.index)}"])`); -+ const css = [`${[...rows, ...titles].join(",")}{display:none!important}`]; -+ if (blank3) css.push("[data-trigger-menu]{display:none!important}"); -+ return css.join("\n"); -+} -+function watchFaceMenu(controller, doc = globalThis.document) { -+ if (typeof controller?.menu?.subscribe !== "function" || !doc?.head) return () => { - }; +- }; - const choose = (path) => { - close(); - if (path !== (value ?? "")) void onPick(path); -+ const style = doc.createElement("style"); -+ style.setAttribute("data-bt-face-at", ""); -+ doc.head.appendChild(style); -+ let previous = null; -+ let stopped = false; -+ const later = typeof queueMicrotask === "function" ? queueMicrotask : (fn) => Promise.resolve().then(fn); -+ const apply2 = () => { -+ if (stopped) return; -+ const state = controller.menu.getSnapshot(); -+ const plan = planFaceMenu(state, previous); -+ const css = faceMenuCss(plan); -+ if (style.textContent !== css) style.textContent = css; -+ if (plan.dismiss) later(() => { -+ if (!stopped && planFaceMenu(controller.menu.getSnapshot(), previous).dismiss) controller.dismiss(); -+ }); -+ else if (plan.target) later(() => { -+ if (!stopped) controller.hover(plan.target.source, plan.target.index); -+ }); -+ else previous = state.open ? state.highlight : null; - }; +- }; - const browse = (path) => { - setProblem(""); - void Promise.resolve(actions.folderList(path)).then((next) => { - setListing(next); - setView("browse"); - }).catch((failure) => setProblem(failure?.message ?? String(failure))); -+ const unsubscribe = controller.menu.subscribe(apply2); -+ apply2(); -+ return () => { -+ stopped = true; -+ unsubscribe?.(); -+ style.remove(); - }; +- }; - const pickNative = () => { - close(); - void run(Promise.resolve(actions.folderPick()).then((path) => path ? onPick(path) : void 0)); -+} -+ -+// src/client/speech.js -+var VOICE_BUNDLE = "@deepseek-ai/dsh-experimental-voice-input-bundle"; -+var USABLE = /* @__PURE__ */ new Set(["ready", "standby", "waking"]); -+async function speechCatalog(speech) { -+ const result = await speech.catalog(); -+ if (!result?.ok) throw new Error(result?.error?.message ?? "Speech catalog failed"); -+ const catalog = result.value; -+ const provider = catalog.providers.find((item) => item.id === catalog.selection.providerId); -+ return { catalog, usable: USABLE.has(provider?.preparation?.phase) }; -+} -+function waveBytes(samples, rate = 16e3) { -+ const bytes = new Uint8Array(44 + samples.length * 2); -+ const view = new DataView(bytes.buffer); -+ const ascii = (at, text2) => { -+ for (let index = 0; index < text2.length; index += 1) bytes[at + index] = text2.charCodeAt(index); - }; +- }; - const typed = () => { - const path = draft.trim(); - if (path !== "") choose(path); -+ ascii(0, "RIFF"); -+ view.setUint32(4, 36 + samples.length * 2, true); -+ ascii(8, "WAVEfmt "); -+ view.setUint32(16, 16, true); -+ view.setUint16(20, 1, true); -+ view.setUint16(22, 1, true); -+ view.setUint32(24, rate, true); -+ view.setUint32(28, rate * 2, true); -+ view.setUint16(32, 2, true); -+ view.setUint16(34, 16, true); -+ ascii(36, "data"); -+ view.setUint32(40, samples.length * 2, true); -+ samples.forEach((sample, index) => { -+ const value = Math.max(-1, Math.min(1, sample)); -+ view.setInt16(44 + index * 2, value < 0 ? Math.round(value * 32768) : Math.round(value * 32767), true); -+ }); -+ return bytes; -+} -+function base64Of(bytes) { -+ let text2 = ""; -+ for (let index = 0; index < bytes.length; index += 32768) text2 += String.fromCharCode(...bytes.subarray(index, index + 32768)); -+ return btoa(text2); -+} -+var captureError = (kind, cause) => Object.assign(new Error(kind), { kind, cause }); -+async function openCapture() { -+ const devices = navigator.mediaDevices; -+ if (!devices?.getUserMedia || typeof MediaRecorder === "undefined") throw captureError("unavailable"); -+ let stream; -+ try { -+ stream = await devices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true }, video: false }); -+ } catch (error) { -+ throw captureError(error?.name === "NotAllowedError" ? "blocked" : "failed", error); -+ } -+ const audio = new AudioContext(); -+ const analyser = audio.createAnalyser(); -+ analyser.fftSize = 512; -+ audio.createMediaStreamSource(stream).connect(analyser); -+ const frame = new Float32Array(analyser.fftSize); -+ const recorder = new MediaRecorder(stream); -+ const chunks = []; -+ recorder.ondataavailable = (event) => { -+ if (event.data.size > 0) chunks.push(event.data); - }; +- }; - const known = info?.folders ?? []; - const folders = value && !known.some((folder) => folder.path === value) ? [{ path: value, title: folderName(value) }, ...known] : known; - const listView = (0, import_react14.createElement)( @@ -17409,30 +16819,7 @@ diff --git a/client.js b/client.js - onChange: (event) => setDraft(event.target.value), - onKeyDown: (event) => { - if (event.key === "Enter" && !event.nativeEvent.isComposing) typed(); -+ recorder.start(); -+ let closed = false; -+ const close = () => { -+ if (closed) return; -+ closed = true; -+ if (recorder.state !== "inactive") recorder.stop(); -+ stream.getTracks().forEach((track) => track.stop()); -+ void audio.close().catch(() => { -+ }); -+ }; -+ return { -+ level() { -+ if (closed) return 0; -+ analyser.getFloatTimeDomainData(frame); -+ let sum = 0; -+ for (const sample of frame) sum += sample * sample; -+ return Math.min(1, Math.sqrt(sum / frame.length) * 4); -+ }, -+ cancel: close, -+ async finish(maxSeconds) { -+ if (closed || recorder.state === "inactive") { -+ close(); -+ return null; - } +- } - }), - (0, import_react14.createElement)("button", { type: "button", className: "bt-folder-use", disabled: draft.trim() === "", onClick: typed }, t("Use this folder")) - ); @@ -17488,48 +16875,7 @@ diff --git a/client.js b/client.js - problem ? (0, import_react14.createElement)("div", { className: "bt-folder-error", role: "alert" }, problem) : null - ), document.body) : null - ); -+ try { -+ await new Promise((resolve) => { -+ recorder.onstop = resolve; -+ recorder.stop(); -+ }); -+ stream.getTracks().forEach((track) => track.stop()); -+ const blob = new Blob(chunks, { type: recorder.mimeType }); -+ if (blob.size === 0) return null; -+ const decoded = await audio.decodeAudioData(await blob.arrayBuffer()); -+ const length = Math.max(1, Math.floor(Math.min(decoded.duration, maxSeconds) * 16e3)); -+ const offline = new OfflineAudioContext(1, length, 16e3); -+ const source = offline.createBufferSource(); -+ source.buffer = decoded; -+ source.connect(offline.destination); -+ source.start(); -+ return waveBytes((await offline.startRendering()).getChannelData(0)); -+ } finally { -+ close(); -+ } -+ } -+ }; -+} -+async function transcribeWave(speech, catalog, wave, signal) { -+ if (wave.byteLength > catalog.maxAudioBytes) throw captureError("too-long"); -+ const result = await speech.transcribe({ audioBase64: base64Of(wave), ...catalog.selection }, signal); -+ if (!result?.ok) throw Object.assign(new Error(result?.error?.message ?? "Transcription failed"), { kind: "service" }); -+ return String(result.value?.text ?? "").trim(); -+} -+function turnDetector({ loud = 0.12, quiet = 0.06, quietMs = 1200 } = {}) { -+ let spokeAt = 0; -+ let quietSince = 0; -+ return (level, now) => { -+ if (level >= loud) { -+ spokeAt ||= now; -+ quietSince = 0; -+ return "speech"; -+ } -+ if (!spokeAt) return "waiting"; -+ if (level < quiet) quietSince ||= now; -+ return quietSince && now - quietSince >= quietMs ? "done" : "speech"; -+ }; - } +-} -var FOLDER_CSS = ` -.bt-folder-view{display:flex;flex-direction:column;gap:2px;animation:bt-veil-in .14s ease-out} -.bt-folder-copy{flex:1;display:flex;flex-direction:column;min-width:0} @@ -17544,7 +16890,7 @@ diff --git a/client.js b/client.js -.bt-folder-error{padding:4px 8px;font-size:12px;line-height:16px;color:#c21d2e;overflow-wrap:anywhere} -@media (prefers-reduced-motion:reduce){.bt-folder-view{animation:none}} -`; - +- -// src/client/schedules.js -var import_react15 = require("react"); -var POLL_MS = 3e4; @@ -17553,27 +16899,7 @@ diff --git a/client.js b/client.js -function publish(view) { - shared = view; - for (const listener of listeners) listener(view); -+// src/client/model-menu.js -+var import_react24 = require("react"); -+var import_react_dom8 = require("react-dom"); -+var MENU_EXIT_MS = 120; -+function groupModels(models) { -+ const groups = /* @__PURE__ */ new Map(); -+ for (const model of models ?? []) { -+ const provider = model.provider ?? model.providerName ?? model.ref.split("/")[0]; -+ if (!groups.has(provider)) groups.set(provider, []); -+ groups.get(provider).push(model); -+ } -+ const names = /* @__PURE__ */ new Map(); -+ for (const [provider, list] of groups) { -+ const name = list[0].providerName ?? provider; -+ names.set(name, (names.get(name) ?? 0) + 1); -+ } -+ return [...groups].map(([provider, list]) => { -+ const name = list[0].providerName ?? provider; -+ return [names.get(name) > 1 ? `${name} (${provider})` : name, list]; -+ }); - } +-} -function useSchedules(actions) { - const [view, setView] = (0, import_react15.useState)(shared); - (0, import_react15.useEffect)(() => { @@ -17582,96 +16908,35 @@ diff --git a/client.js b/client.js - let timer; - const load = () => actions.schedules?.().then((next) => { - if (!stopped) publish(next); -+var servedOf = (roster, ref) => (roster.models ?? []).find((model) => model.ref === ref); -+var effortCache = /* @__PURE__ */ new Map(); -+function useEfforts(served, ref, actions) { -+ const [fetched, setFetched] = (0, import_react24.useState)(void 0); -+ (0, import_react24.useEffect)(() => { -+ setFetched(void 0); -+ if (ref === void 0 || served?.reasoning !== void 0 || actions?.efforts === void 0) return void 0; -+ let live = true; -+ if (effortCache.has(ref)) setFetched(effortCache.get(ref)); -+ else void Promise.resolve(actions.efforts(ref)).then((value) => { -+ const reasoning = value?.efforts ? value : null; -+ effortCache.set(ref, reasoning); -+ if (live) setFetched(reasoning); - }).catch(() => { +- }).catch(() => { - }).finally(() => { - if (!stopped) timer = setTimeout(load, POLL_MS); - }); +- }); - load(); - return () => { +- return () => { - stopped = true; - clearTimeout(timer); - listeners.delete(setView); -+ live = false; - }; +- }; - }, []); - return view; -+ }, [ref, served?.reasoning !== void 0, actions]); -+ return served?.reasoning ?? fetched; - } +-} -var localZone = () => Intl.DateTimeFormat().resolvedOptions().timeZone; -var clock2 = (time) => String(time ?? "").slice(0, 5); -var when2 = (ms, options) => new Intl.DateTimeFormat(dateLocale(), options).format(ms); -function dayAndTime(ms) { - const sameYear = new Date(ms).getFullYear() === (/* @__PURE__ */ new Date()).getFullYear(); - return when2(ms, { ...sameYear ? {} : { year: "numeric" }, month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }); -+function effortRows(reasoning) { -+ if (reasoning?.efforts === void 0 || reasoning.efforts.length === 0) return []; -+ return [ -+ { effort: void 0, label: t("Default") }, -+ ...reasoning.efforts.map((effort) => ({ effort: effort.id, label: effort.name })) -+ ]; - } +-} -function weekdayNames(days) { - const names = days.map((day) => when2(Date.UTC(2024, 0, day), { weekday: "short", timeZone: "UTC" })); - return names.join(dateLocale().startsWith("zh") ? "、" : ", "); -+function EffortRows({ reasoning, current, onPick }) { -+ return (0, import_react24.createElement)( -+ import_react24.Fragment, -+ null, -+ (0, import_react24.createElement)("div", { className: "bt-menu-sep", role: "separator" }), -+ (0, import_react24.createElement)("div", { className: "bt-model-group", role: "presentation" }, t("Thinking level")), -+ effortRows(reasoning).map((row) => (0, import_react24.createElement)( -+ "button", -+ { -+ key: row.effort ?? "default", -+ type: "button", -+ role: "menuitemradio", -+ "aria-checked": row.effort === current, -+ onClick: () => onPick(row.effort) -+ }, -+ (0, import_react24.createElement)("span", { className: "bt-menu-label" }, row.label), -+ (0, import_react24.createElement)("span", { className: "bt-model-tick", "aria-hidden": true }, row.effort === current ? (0, import_react24.createElement)(CheckIcon) : null) -+ )) -+ ); - } +-} -function everyText(seconds) { - if (seconds % 86400 === 0) return seconds === 86400 ? t("Every day") : t("Every {count} days", { count: seconds / 86400 }); - if (seconds % 3600 === 0) return seconds === 3600 ? t("Every hour") : t("Every {count} hours", { count: seconds / 3600 }); - return t("Every {count} minutes", { count: Math.round(seconds / 60) }); -+function ModelRows({ roster, current, onPick }) { -+ return groupModels(roster.models).map(([provider, list]) => (0, import_react24.createElement)( -+ import_react24.Fragment, -+ { key: list[0].provider ?? provider }, -+ (0, import_react24.createElement)("div", { className: "bt-model-group", role: "presentation" }, provider), -+ list.map((model) => (0, import_react24.createElement)( -+ "button", -+ { -+ key: model.ref, -+ type: "button", -+ role: "menuitemradio", -+ "aria-checked": model.ref === current, -+ title: model.ref, -+ onClick: () => onPick(model.ref) -+ }, -+ (0, import_react24.createElement)("span", { className: "bt-menu-label" }, model.name), -+ model.ref === roster.defaultModel ? (0, import_react24.createElement)("span", { className: "bt-menu-hint" }, t("Default")) : null, -+ (0, import_react24.createElement)("span", { className: "bt-model-tick", "aria-hidden": true }, model.ref === current ? (0, import_react24.createElement)(CheckIcon) : null) -+ )) -+ )); - } +-} -function timingText(task) { - const zone = task.timeZone && task.timeZone !== localZone() ? ` (${task.timeZone})` : ""; - switch (task.kind) { @@ -17687,52 +16952,12 @@ diff --git a/client.js b/client.js - const text = days === "1,2,3,4,5" ? t("Every weekday at {time}", { time: clock2(task.time) }) : days === "6,7" ? t("Every weekend at {time}", { time: clock2(task.time) }) : days === "1,2,3,4,5,6,7" ? t("Every day at {time}", { time: clock2(task.time) }) : t("Every {days} at {time}", { days: weekdayNames(task.weekdays ?? []), time: clock2(task.time) }); - return `${text}${zone}`; - } -- case "cron": -- return `${t("On the schedule {expression}", { expression: task.expression })}${zone}`; -- default: -- return ""; -- } -+var Caret = () => (0, import_react24.createElement)("svg", { className: "bt-model-caret", width: 12, height: 12, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.8, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, (0, import_react24.createElement)("path", { d: "M5.5 8l4.5 4.5L14.5 8" })); -+function useAnchoredMenu() { -+ const [place, setPlace] = (0, import_react24.useState)(null); -+ const [shownPlace, leaving] = useLinger(place, MENU_EXIT_MS); -+ const button = (0, import_react24.useRef)(null); -+ const menu = (0, import_react24.useRef)(null); -+ const close = () => setPlace(null); -+ (0, import_react24.useEffect)(() => { -+ if (place === null) return void 0; -+ const onDown = (event) => { -+ if (menu.current?.contains(event.target) || button.current?.contains(event.target)) return; -+ close(); -+ }; -+ const onKey = (event) => { -+ if (event.key === "Escape") { -+ event.stopPropagation(); -+ close(); -+ button.current?.focus(); -+ } -+ }; -+ const onScroll = (event) => { -+ if (!menu.current?.contains(event.target)) close(); -+ }; -+ window.addEventListener("mousedown", onDown); -+ window.addEventListener("keydown", onKey, true); -+ window.addEventListener("scroll", onScroll, true); -+ return () => { -+ window.removeEventListener("mousedown", onDown); -+ window.removeEventListener("keydown", onKey, true); -+ window.removeEventListener("scroll", onScroll, true); -+ }; -+ }, [place]); -+ const open = () => { -+ const rect = button.current.getBoundingClientRect(); -+ const width = Math.max(240, rect.width); -+ const left = Math.max(8, Math.min(rect.right - width, window.innerWidth - width - 8)); -+ const below = window.innerHeight - rect.bottom; -+ setPlace(below >= 260 || below >= rect.top ? { left, width, top: rect.bottom + 4, maxHeight: below - 16, "--bt-origin": "top right" } : { left, width, bottom: window.innerHeight - rect.top + 4, maxHeight: rect.top - 16, "--bt-origin": "bottom right" }); -+ }; -+ return { isOpen: place !== null, shownPlace, leaving, button, menu, open, close }; - } +- case "cron": +- return `${t("On the schedule {expression}", { expression: task.expression })}${zone}`; +- default: +- return ""; +- } +-} -var nextText = (task) => task.status === "active" ? t("Next: {time}", { time: dayAndTime(Date.parse(task.scheduledAt)) }) : t("Finished"); -function TaskRow({ task, actions, onView, showBot, roster }) { - const [confirming, setConfirming] = (0, import_react15.useState)(false); @@ -17745,26 +16970,11 @@ diff --git a/client.js b/client.js - const remove = () => { - if (!confirming) { - setConfirming(true); -+function ModelMenu({ bot, roster, actions, run, wide = false, onPick, onPickEffort }) { -+ if (bot?.remote !== void 0) return null; -+ const { isOpen, shownPlace, leaving, button, menu, open, close } = useAnchoredMenu(); -+ const shown = modelOf(bot, roster); -+ const current = bot.model ?? bot.appliedModel; -+ const served = servedOf(roster, current); -+ const reasoning = useEfforts(served, current, actions); -+ const picked = reasoning?.efforts?.some((effort) => effort.id === bot.effort) ? bot.effort : void 0; -+ const currentEffort = picked ?? reasoning?.defaultEffort; -+ const effortLabel = currentEffort === void 0 ? reasoning?.efforts?.length > 0 ? t("Default") : void 0 : reasoning.efforts.find((effort) => effort.id === currentEffort)?.name ?? currentEffort; -+ const toggle = () => { -+ if (isOpen) { -+ close(); - return; - } +- return; +- } - setBusy(true); - void actions.scheduleDelete(task.sessionId, task.id).then(onView, () => setBusy(false)); -+ void actions?.refreshModels?.(); -+ open(); - }; +- }; - const room2 = task.roomId ? roster.roomsById[task.roomId] : void 0; - return (0, import_react15.createElement)( - "div", @@ -17776,41 +16986,7 @@ diff --git a/client.js b/client.js - (0, import_react15.createElement)("span", { className: "bt-task-title" }, task.title), - (0, import_react15.createElement)("span", { className: "bt-set-hint" }, [timingText(task), room2 ? t("in {name}", { name: room2.name }) : ""].filter(Boolean).join(" · ")), - task.prompt && task.prompt !== task.title ? (0, import_react15.createElement)("span", { className: "bt-task-prompt" }, task.prompt) : null -+ const pick2 = (ref) => { -+ close(); -+ if (onPick) onPick(ref); -+ else if (ref !== current || !bot.model) void run(actions.updateBot(bot.id, { model: ref })); -+ }; -+ const pickEffort = (effort) => { -+ close(); -+ if (onPickEffort) onPickEffort(effort); -+ else if (effort !== bot.effort) void run(actions.updateBot(bot.id, { effort: effort ?? null })); -+ }; -+ const models = roster.models ?? []; -+ return (0, import_react24.createElement)( -+ import_react24.Fragment, -+ null, -+ (0, import_react24.createElement)( -+ "button", -+ { -+ ref: button, -+ type: "button", -+ className: "bt-model-trigger", -+ "data-wide": wide || void 0, -+ "aria-haspopup": "menu", -+ "aria-expanded": isOpen, -+ "aria-label": t("Model: {model}", { model: shown?.name ?? t("none") }), -+ "data-missing": shown !== void 0 && !shown.served && models.length > 0 ? "" : void 0, -+ onClick: toggle -+ }, -+ (0, import_react24.createElement)( -+ "span", -+ { className: "bt-model-copy" }, -+ (0, import_react24.createElement)("span", { className: "bt-model-name" }, shown?.name ?? t("Choose a model")), -+ shown ? (0, import_react24.createElement)("span", { className: "bt-model-provider" }, shown.served || models.length === 0 ? effortLabel === void 0 ? shown.provider : `${shown.provider} · ${effortLabel}` : t("{provider} · not available", { provider: shown.provider })) : null -+ ), -+ (0, import_react24.createElement)(Caret) - ), +- ), - (0, import_react15.createElement)("span", { className: "bt-task-next" }, nextText(task)), - (0, import_react15.createElement)("button", { - type: "button", @@ -17821,19 +16997,8 @@ diff --git a/client.js b/client.js - title: confirming ? void 0 : t("Delete task"), - onClick: remove - }, confirming ? t("Delete") : (0, import_react15.createElement)(TrashIcon)) -+ shownPlace ? (0, import_react_dom8.createPortal)((0, import_react24.createElement)( -+ "div", -+ { ref: menu, className: `bt-menu bt-model-menu${shownPlace.bottom !== void 0 ? " bt-menu-up" : ""}`, role: "menu", "aria-label": t("Model for {name}", { name: bot.name }), style: shownPlace, "data-leaving": leaving || void 0 }, -+ models.length > 0 ? (0, import_react24.createElement)(ModelRows, { roster, current, onPick: pick2 }) : (0, import_react24.createElement)("div", { className: "bt-model-none" }, t("No models are set up in DeepSeek Harness yet.")), -+ effortRows(reasoning).length > 0 ? (0, import_react24.createElement)(EffortRows, { reasoning, current: picked, onPick: pickEffort }) : null, -+ (0, import_react24.createElement)("div", { className: "bt-menu-sep", role: "separator" }), -+ (0, import_react24.createElement)("button", { type: "button", role: "menuitem", onClick: () => { -+ close(); -+ openHarnessModels(); -+ } }, (0, import_react24.createElement)(SlidersIcon), (0, import_react24.createElement)("span", { className: "bt-menu-label" }, t("Models and providers…"))) -+ ), document.body) : null - ); - } +- ); +-} -var pad = (number) => String(number).padStart(2, "0"); -function inAnHour() { - const date = new Date(Date.now() + 36e5); @@ -17863,23 +17028,7 @@ diff --git a/client.js b/client.js - event.preventDefault(); - if (busy) return; - setBusy(true); -+function ModelChoiceDock({ sessionId, useRoster, actions }) { -+ const roster = useRoster((value) => value); -+ const bot = roster.byId[sessionId]; -+ const [busy, setBusy] = (0, import_react24.useState)(""); -+ const [error, setError] = (0, import_react24.useState)(""); -+ const shown = Boolean(bot) && currentPart(bot) === sessionId && roster.ready; -+ (0, import_react24.useEffect)(() => { -+ if (shown) void actions.refreshModels?.(); -+ }, [shown, sessionId]); -+ if (!bot || currentPart(bot) !== sessionId || !roster.ready) return null; -+ if (bot.remote) return bot.remote.gone ? (0, import_react24.createElement)(RemoteGoneDock, { bot }) : null; -+ 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; -+ const pick2 = (ref) => { -+ setBusy(ref); - setError(""); +- setError(""); - let when3; - try { - when3 = timing(); @@ -17887,250 +17036,10 @@ diff --git a/client.js b/client.js - setError(t("Pick a date and time")); - setBusy(false); - return; -- } -- void actions.scheduleCreate({ target, title, prompt, when: when3 }).then(onDone, (failure) => { -- setError(hostText(failure?.message ?? String(failure))); -- setBusy(false); -- }); -+ void Promise.resolve(actions.updateBot(bot.id, { model: ref })).catch((failure) => { -+ setError(failure?.message ?? String(failure)); -+ void actions.refreshModels?.(); -+ }).finally(() => setBusy("")); - }; -- const toggleDay = (day) => setWeekdays((days) => days.includes(day) ? days.filter((item) => item !== day) : [...days, day].sort()); -- return (0, import_react15.createElement)( -- "form", -- { className: "bt-set-card bt-task-form", onSubmit: submit }, -- (0, import_react15.createElement)( -- "label", -- { className: "bt-set-row bt-bs-row" }, -- (0, import_react15.createElement)("span", { className: "bt-set-copy" }, (0, import_react15.createElement)("span", { className: "bt-bs-label" }, t("Send to"))), -- (0, import_react15.createElement)( -- "select", -- { className: "bt-select bt-task-bot", value: target, onChange: (event) => setTarget(event.target.value) }, -- (0, import_react15.createElement)("optgroup", { label: t("Bots") }, roster.bots.map((bot) => (0, import_react15.createElement)("option", { key: bot.id, value: bot.id }, bot.name))), -- roster.rooms.length > 0 ? (0, import_react15.createElement)("optgroup", { label: t("Group chats") }, roster.rooms.map((room2) => (0, import_react15.createElement)("option", { key: room2.id, value: room2.id }, room2.name))) : null -+ const text2 = 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 }); -+ const choices = models.length === 0 ? [...setupChoices(actions).map(([label, run]) => (0, import_react24.createElement)("button", { key: label, type: "button", className: "bt-model-chip", onClick: run }, label)), (0, import_react24.createElement)(KeyHelpLink, { key: "key-help" })] : [ -+ ...groupModels(models).map(([provider, list]) => (0, import_react24.createElement)( -+ "div", -+ { key: list[0].provider ?? provider, className: "bt-model-choice-group", role: "group", "aria-label": provider }, -+ (0, import_react24.createElement)("div", { className: "bt-model-choice-provider", "aria-hidden": true }, provider), -+ (0, import_react24.createElement)("div", { className: "bt-model-choice-chips" }, list.map((model) => (0, import_react24.createElement)("button", { -+ key: model.ref, -+ type: "button", -+ className: "bt-model-chip", -+ disabled: busy !== "", -+ "aria-busy": busy === model.ref || void 0, -+ "aria-label": `${model.name} · ${provider}`, -+ title: model.ref === roster.defaultModel ? `${model.ref} · ${t("Harness default")}` : model.ref, -+ onClick: () => pick2(model.ref) -+ }, model.name, busy === model.ref ? (0, import_react24.createElement)("span", { className: "bt-model-spin", "aria-label": t("Saving…") }) : null))) -+ )), -+ (0, import_react24.createElement)("button", { key: "manage", type: "button", className: "bt-model-chip bt-model-chip-quiet", onClick: openHarnessModels }, (0, import_react24.createElement)(SlidersIcon), t("Manage models")) -+ ]; -+ return (0, import_react24.createElement)( -+ "div", -+ { className: "bt-model-dock" }, -+ (0, import_react24.createElement)( -+ "div", -+ { className: "bt-astack", "data-lead": "" }, -+ (0, import_react24.createElement)( -+ "div", -+ { className: "bt-msg" }, -+ (0, import_react24.createElement)( -+ "div", -+ { className: "bt-msg-line" }, -+ (0, import_react24.createElement)(BotMark, { bot, size: 22 }), -+ (0, import_react24.createElement)("div", { className: "bt-bubble" }, text2) -+ ) - ) - ), -- (0, import_react15.createElement)( -- "label", -- { className: "bt-set-row bt-bs-row" }, -- (0, import_react15.createElement)("span", { className: "bt-set-copy" }, (0, import_react15.createElement)("span", { className: "bt-bs-label" }, t("Name"))), -- (0, import_react15.createElement)("input", { className: "bt-bs-input", value: title, maxLength: 120, placeholder: t("Morning briefing"), autoFocus: true, onChange: (event) => setTitle(event.target.value) }) -- ), -- (0, import_react15.createElement)( -- "label", -- { className: "bt-set-row bt-bs-row bt-bs-stack" }, -- (0, import_react15.createElement)("span", { className: "bt-set-copy" }, (0, import_react15.createElement)("span", { className: "bt-bs-label" }, t("What to do"))), -- (0, import_react15.createElement)("textarea", { className: "bt-bs-input bt-bs-area", value: prompt, rows: 3, placeholder: t("Sum up what changed since yesterday and what needs me today."), onChange: (event) => setPrompt(event.target.value) }) -- ), -- (0, import_react15.createElement)( -+ (0, import_react24.createElement)("div", { className: "bt-model-chips", role: "group", "aria-label": t("Choose a model") }, choices), -+ error ? (0, import_react24.createElement)("div", { className: "bt-secret-error bt-model-error", role: "alert" }, error) : null -+ ); -+} -+function RemoteGoneDock({ bot }) { -+ return (0, import_react24.createElement)( -+ "div", -+ { className: "bt-model-dock", "data-flybot": "remote-gone" }, -+ (0, import_react24.createElement)( - "div", -- { className: "bt-set-row bt-bs-row bt-bs-stack" }, -- (0, import_react15.createElement)("span", { className: "bt-set-copy" }, (0, import_react15.createElement)("span", { className: "bt-bs-label" }, t("When"))), -- (0, import_react15.createElement)(Segmented, { label: t("When"), value: kind }, KINDS.map(([id, label]) => (0, import_react15.createElement)("button", { key: id, type: "button", "aria-pressed": kind === id, onClick: () => setKind(id) }, t(label)))), -- (0, import_react15.createElement)( -+ { className: "bt-astack", "data-lead": "" }, -+ (0, import_react24.createElement)( - "div", -- { key: kind, className: "bt-task-when bt-veil" }, -- kind === "at" ? (0, import_react15.createElement)("input", { className: "bt-input", type: "datetime-local", value: at, "aria-label": t("Date and time"), onChange: (event) => setAt(event.target.value) }) : null, -- kind === "weekly" ? (0, import_react15.createElement)("div", { className: "bt-task-days", role: "group", "aria-label": t("Days") }, [1, 2, 3, 4, 5, 6, 7].map((day) => (0, import_react15.createElement)("button", { -- key: day, -- type: "button", -- className: "bt-task-day", -- "aria-pressed": weekdays.includes(day), -- onClick: () => toggleDay(day) -- }, when2(Date.UTC(2024, 0, day), { weekday: "narrow", timeZone: "UTC" })))) : null, -- kind === "daily" || kind === "weekly" ? (0, import_react15.createElement)("input", { className: "bt-input bt-task-time", type: "time", value: time, "aria-label": t("Time"), onChange: (event) => setTime(event.target.value) }) : null, -- kind === "every" ? (0, import_react15.createElement)( -- import_react15.Fragment, -- null, -- (0, import_react15.createElement)("span", { className: "bt-task-every" }, t("Every")), -- (0, import_react15.createElement)("input", { className: "bt-input bt-task-count", type: "number", min: 1, value: count, "aria-label": t("How many"), onChange: (event) => setCount(event.target.value) }), -- (0, import_react15.createElement)( -- "select", -- { className: "bt-select bt-task-unit", value: unit, "aria-label": t("Unit"), onChange: (event) => setUnit(event.target.value) }, -- UNITS.map(([id, , label]) => (0, import_react15.createElement)("option", { key: id, value: id }, t(label))) -- ) -- ) : null -+ { className: "bt-msg" }, -+ (0, import_react24.createElement)( -+ "div", -+ { className: "bt-msg-line" }, -+ (0, import_react24.createElement)(BotMark, { bot, size: 22 }), -+ (0, import_react24.createElement)("div", { className: "bt-bubble" }, t("{name} ({handle}) is no longer shared with you, so messages to it cannot be delivered. It runs on its owner's machine, so another model cannot stand in. Ask its owner for a new invite.", { name: bot.name, handle: bot.remote.handle })) -+ ) - ) -+ ) -+ ); -+} -+function setupChoices(actions) { -+ return [ -+ actions.canSignIn?.() ? [t("Sign in to DeepSeek"), () => actions.signIn()] : null, -+ [t("Enter a DeepSeek API key"), () => openHarnessOnboarding("deepseek-official")], -+ [t("Open settings to set up a custom model"), openHarnessModels] -+ ].filter(Boolean); -+} -+var KEY_HELP_URL = "https://github.com/ThinkFlowLab/FlyBot/blob/main/docs/deepseek-api-key.md"; -+function KeyHelpLink() { -+ return (0, import_react24.createElement)("a", { className: "bt-model-help", href: KEY_HELP_URL, target: "_blank", rel: "noopener noreferrer" }, t("How to set a DeepSeek API key")); -+} -+function missingModelKey(message) { -+ const text2 = String(message ?? ""); -+ if (!/\bno api key\b|\bmissing (?:an? )?api key\b|\bapi key (?:is )?(?:missing|not set|not configured)\b/i.test(text2)) return void 0; -+ return { route: /provider route "([^"]+)"/i.exec(text2)?.[1] ?? "" }; -+} -+function modelKeyChoices(actions, route) { -+ if (/^deepseek/i.test(route)) return setupChoices(actions); -+ return [[t("Manage models"), openHarnessModels]]; -+} -+function TurnErrorCell({ node, t: chatT, actions }) { -+ const data = node?.data ?? {}; -+ const code = typeof data.code === "string" && data.code ? data.code : void 0; -+ const raw = String(data.message ?? ""); -+ const shell = (key, fallback) => { -+ const text2 = typeof chatT === "function" ? chatT(key) : void 0; -+ return typeof text2 === "string" && text2 !== "" && text2 !== key ? text2 : fallback; -+ }; -+ const missing = missingModelKey(raw); -+ const title = code === "ACCOUNT_SIGNED_OUT" ? shell("message.accountStopped", t("This turn failed")) : shell("message.turnError", t("This turn failed")); -+ const message = missing ? t("This Bot's model has no API key yet. Set one up, then send your message again.") : code === "ACCOUNT_SIGNED_OUT" ? shell("message.failure.accountSignedOut", raw) : code === "ACCOUNT_SIGN_IN_REQUIRED" ? shell("message.failure.accountSignInRequired", raw) : code === "QUOTA" || code === "ACCOUNT_QUOTA" ? shell("message.failure.quota", raw) : code === "AUTH" ? shell("message.failure.auth", raw) : raw; -+ const choices = missing ? modelKeyChoices(actions ?? {}, missing.route) : []; -+ const deepseek = missing ? /^deepseek/i.test(missing.route) : false; -+ return (0, import_react24.createElement)( -+ "div", -+ { className: "bt-turn-error", role: "status", "data-missing-key": missing ? "" : void 0 }, -+ (0, import_react24.createElement)("span", { className: "bt-turn-error-dot", "aria-hidden": true }), -+ (0, import_react24.createElement)( -+ "div", -+ { className: "bt-turn-error-copy" }, -+ (0, import_react24.createElement)("span", { className: "bt-turn-error-title" }, title), -+ (0, import_react24.createElement)("span", { className: "bt-turn-error-message" }, message), -+ code ? (0, import_react24.createElement)("code", { className: "bt-turn-error-code" }, code) : null - ), -- error ? (0, import_react15.createElement)("div", { className: "bt-set-row" }, (0, import_react15.createElement)("span", { className: "bt-danger bt-set-hint" }, error)) : null, -- (0, import_react15.createElement)( -+ choices.length > 0 ? (0, import_react24.createElement)( - "div", -- { className: "bt-set-row bt-task-foot" }, -- (0, import_react15.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: onCancel }, t("Cancel")), -- (0, import_react15.createElement)("button", { type: "submit", className: "bt-send bt-soft-sm", disabled: busy || !target || title.trim() === "" || prompt.trim() === "" }, busy ? t("Creating…") : t("Create task")) -- ) -+ { className: "bt-turn-error-actions" }, -+ choices.map(([label, run]) => (0, import_react24.createElement)("button", { key: label, type: "button", className: "bt-model-chip", onClick: run }, label)), -+ deepseek ? (0, import_react24.createElement)(KeyHelpLink, { key: "key-help" }) : null -+ ) : null - ); - } --function AutomationOff() { -- return (0, import_react15.createElement)( -+function DeepSeekSignIn({ useUi, actions }) { -+ const open = useUi((state) => state.signIn); -+ const entry = open ? actions.signInEntry?.() : null; -+ if (!entry) return null; -+ return (0, import_react24.createElement)(SignInHost, { key: open, entry, actions }); -+} -+function SignInHost({ entry, actions }) { -+ const [ops] = (0, import_react24.useState)(() => entry.inject?.() ?? {}); -+ const close = () => actions.closeSignIn(); -+ const useHook = (source) => (selector) => (0, import_react24.useSyncExternalStore)(source.subscribe, () => selector(source.getSnapshot())); -+ const [hooks] = (0, import_react24.useState)(() => ({ useAccount: useHook(ops.hooks.account), useTheme: useHook(ops.hooks.theme) })); -+ return (0, import_react24.createElement)(entry.component, { -+ ...ops, -+ ...hooks, -+ t: actions.accountT(), -+ complete: close, -+ useApiKey: () => { -+ close(); -+ openHarnessOnboarding("deepseek-official"); -+ } -+ }); -+} -+var MODEL_CSS = ` -+.bt-turn-error{display:grid;grid-template-columns:10px minmax(0,1fr);column-gap:8px;row-gap:8px;padding:2px 0;font-size:13px;line-height:20px} -+.bt-turn-error-dot{width:6px;height:6px;margin-top:7px;border-radius:50%;background:#e02135} -+.bt-turn-error-copy{min-width:0;overflow-wrap:anywhere} -+.bt-turn-error-title{margin-right:6px;font-weight:600;color:#e02135} -+.bt-turn-error-message{color:var(--bt-ink-2)} -+.bt-turn-error-code{margin-left:6px;font-size:11px;color:var(--bt-ink-3)} -+.bt-turn-error-actions{grid-column:2;display:flex;flex-wrap:wrap;align-items:center;gap:6px} -+.bt-model-help{display:inline-flex;align-items:center;min-height:28px;padding:0 4px;font-size:12px;line-height:16px;color:var(--bt-ink-2);text-decoration:underline;text-underline-offset:2px} -+.bt-model-help:hover{color:var(--bt-ink)} -+.bt-model-help:focus-visible{outline:2px solid var(--bt-accent);outline-offset:1px;border-radius:4px} -+.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} -+.bt-model-trigger[data-missing] .bt-model-provider{color:#c21d2e} -+.bt-model-trigger[data-wide]{display:flex;width:100%;min-height:48px;padding:7px 12px;justify-content:space-between;border-radius:12px;background:var(--bt-card);box-shadow:inset 0 0 0 1px var(--bt-line)} -+.bt-model-trigger[data-wide]:hover,.bt-model-trigger[data-wide][aria-expanded=true]{background:var(--bt-hover)} -+.bt-model-copy{display:flex;flex-direction:column;min-width:0} -+.bt-model-name{font-size:13px;line-height:18px;font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} -+.bt-model-provider{font-size:11px;line-height:14px;color:var(--bt-ink-3);overflow:hidden;text-overflow:ellipsis;white-space:nowrap} -+.bt-model-caret{flex:none;color:var(--bt-ink-3);transition:${spring(SPRING_SHAPE, "transform")}} -+.bt-model-trigger[aria-expanded=true] .bt-model-caret{transform:rotate(180deg)} -+.bt-model-menu{overflow-y:auto;scrollbar-width:thin} -+.bt-model-group{flex:none;padding:8px 8px 2px;font-size:11px;line-height:14px;font-weight:500;color:var(--bt-ink-3);text-transform:none} -+.bt-model-group:first-child{padding-top:2px} -+.bt-model-tick{flex:none;width:16px;height:16px;display:inline-flex;color:var(--bt-ink-2)} -+.bt-model-tick svg{animation:bt-check-pop ${SPRING_POP.duration}ms ${SPRING_POP.easing} 60ms backwards} -+.bt-model-none{padding:8px;font-size:13px;line-height:18px;color:var(--bt-ink-2)} -+.bt-model-dock{width:100%;max-width:var(--dsh-chat-content-width,748px);margin:0 auto;display:flex;flex-direction:column;align-items:flex-start;gap:8px;padding:0 0 10px;box-sizing:border-box;animation:bt-model-in ${SPRING_POP.duration}ms ${SPRING_POP.easing} backwards} -+.bt-model-chips{display:flex;flex-wrap:wrap;gap:6px;margin-left:30px;max-width:min(560px,calc(100% - 30px));max-height:min(240px,36vh);overflow-y:auto;scrollbar-width:thin} -+.bt-model-choice-group{display:flex;flex-direction:column;gap:6px;width:100%;min-width:0} -+.bt-model-choice-provider{padding-top:4px;font-size:12px;line-height:16px;font-weight:500;color:var(--bt-ink-2);overflow-wrap:anywhere} -+.bt-model-choice-chips{display:flex;flex-wrap:wrap;gap:6px} -+.bt-model-chip{display:inline-flex;align-items:center;gap:6px;min-height:30px;padding:5px 12px;border:.5px solid var(--bt-line-2);border-radius:999px;background:var(--bt-surface,transparent);color:var(--bt-ink);font:inherit;font-size:13px;line-height:18px;cursor:pointer;transition:background-color .12s ease,border-color .12s ease} -+.bt-model-chip:hover:not(:disabled){background:var(--bt-hover)} -+.bt-model-chip:focus-visible{outline:2px solid var(--bt-accent);outline-offset:1px} -+.bt-model-chip-quiet{color:var(--bt-ink-2)} -+.bt-model-chip svg{width:14px;height:14px} -+.bt-model-chip:disabled{cursor:default} -+.bt-model-chip:disabled:not([aria-busy]){opacity:.5} -+.bt-model-error{margin-left:30px} +- } +- void actions.scheduleCreate({ target, title, prompt, when: when3 }).then(onDone, (failure) => { +- setError(hostText(failure?.message ?? String(failure))); +- setBusy(false); +@keyframes bt-model-in{from{opacity:0;transform:translateY(6px)}} +.bt-model-spin{flex:none;width:14px;height:14px;border-radius:50%;border:1.5px solid var(--bt-line-2);border-top-color:var(--bt-ink-2);animation:bt-model-spin .7s linear infinite} +@keyframes bt-model-spin{to{transform:rotate(360deg)}} @@ -18179,16 +17088,66 @@ diff --git a/client.js b/client.js + (error) => ({ ok: false, text: String(error?.message ?? t("The test failed.")) }) + ).then((outcome2) => { + if (live.current) setState({ busy: false, outcome: outcome2 }); -+ }); -+ }; + }); + }; +- const toggleDay = (day) => setWeekdays((days) => days.includes(day) ? days.filter((item) => item !== day) : [...days, day].sort()); +- return (0, import_react15.createElement)( +- "form", +- { className: "bt-set-card bt-task-form", onSubmit: submit }, +- (0, import_react15.createElement)( +- "label", +- { className: "bt-set-row bt-bs-row" }, +- (0, import_react15.createElement)("span", { className: "bt-set-copy" }, (0, import_react15.createElement)("span", { className: "bt-bs-label" }, t("Send to"))), +- (0, import_react15.createElement)( +- "select", +- { className: "bt-select bt-task-bot", value: target, onChange: (event) => setTarget(event.target.value) }, +- (0, import_react15.createElement)("optgroup", { label: t("Bots") }, roster.bots.map((bot) => (0, import_react15.createElement)("option", { key: bot.id, value: bot.id }, bot.name))), +- roster.rooms.length > 0 ? (0, import_react15.createElement)("optgroup", { label: t("Group chats") }, roster.rooms.map((room2) => (0, import_react15.createElement)("option", { key: room2.id, value: room2.id }, room2.name))) : null +- ) +- ), +- (0, import_react15.createElement)( +- "label", +- { className: "bt-set-row bt-bs-row" }, +- (0, import_react15.createElement)("span", { className: "bt-set-copy" }, (0, import_react15.createElement)("span", { className: "bt-bs-label" }, t("Name"))), +- (0, import_react15.createElement)("input", { className: "bt-bs-input", value: title, maxLength: 120, placeholder: t("Morning briefing"), autoFocus: true, onChange: (event) => setTitle(event.target.value) }) +- ), +- (0, import_react15.createElement)( +- "label", +- { className: "bt-set-row bt-bs-row bt-bs-stack" }, +- (0, import_react15.createElement)("span", { className: "bt-set-copy" }, (0, import_react15.createElement)("span", { className: "bt-bs-label" }, t("What to do"))), +- (0, import_react15.createElement)("textarea", { className: "bt-bs-input bt-bs-area", value: prompt, rows: 3, placeholder: t("Sum up what changed since yesterday and what needs me today."), onChange: (event) => setPrompt(event.target.value) }) +- ), +- (0, import_react15.createElement)( +- "div", +- { className: "bt-set-row bt-bs-row bt-bs-stack" }, +- (0, import_react15.createElement)("span", { className: "bt-set-copy" }, (0, import_react15.createElement)("span", { className: "bt-bs-label" }, t("When"))), +- (0, import_react15.createElement)(Segmented, { label: t("When"), value: kind }, KINDS.map(([id, label]) => (0, import_react15.createElement)("button", { key: id, type: "button", "aria-pressed": kind === id, onClick: () => setKind(id) }, t(label)))), +- (0, import_react15.createElement)( +- "div", +- { key: kind, className: "bt-task-when bt-veil" }, +- kind === "at" ? (0, import_react15.createElement)("input", { className: "bt-input", type: "datetime-local", value: at, "aria-label": t("Date and time"), onChange: (event) => setAt(event.target.value) }) : null, +- kind === "weekly" ? (0, import_react15.createElement)("div", { className: "bt-task-days", role: "group", "aria-label": t("Days") }, [1, 2, 3, 4, 5, 6, 7].map((day) => (0, import_react15.createElement)("button", { +- key: day, +- type: "button", +- className: "bt-task-day", +- "aria-pressed": weekdays.includes(day), +- onClick: () => toggleDay(day) +- }, when2(Date.UTC(2024, 0, day), { weekday: "narrow", timeZone: "UTC" })))) : null, +- kind === "daily" || kind === "weekly" ? (0, import_react15.createElement)("input", { className: "bt-input bt-task-time", type: "time", value: time, "aria-label": t("Time"), onChange: (event) => setTime(event.target.value) }) : null, +- kind === "every" ? (0, import_react15.createElement)( +- import_react15.Fragment, +- null, +- (0, import_react15.createElement)("span", { className: "bt-task-every" }, t("Every")), +- (0, import_react15.createElement)("input", { className: "bt-input bt-task-count", type: "number", min: 1, value: count, "aria-label": t("How many"), onChange: (event) => setCount(event.target.value) }), +- (0, import_react15.createElement)( +- "select", +- { className: "bt-select bt-task-unit", value: unit, "aria-label": t("Unit"), onChange: (event) => setUnit(event.target.value) }, +- UNITS.map(([id, , label]) => (0, import_react15.createElement)("option", { key: id, value: id }, t(label))) +- ) +- ) : null + const { busy, outcome } = state; + return (0, import_react25.createElement)( - "div", -- { className: "bt-set-card bt-task-off" }, -- (0, import_react15.createElement)("span", { className: "bt-task-off-mark" }, (0, import_react15.createElement)(ClockIcon)), -- (0, import_react15.createElement)("span", { className: "bt-task-off-title" }, t("Turn on Automation tasks in DSH")), -- (0, import_react15.createElement)("span", { className: "bt-set-hint" }, t("Switch on Automation tasks on the Plugins page in Harness settings.")), -- (0, import_react15.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: openHarnessSettings }, t("Open Harness settings")) ++ "div", + { className: "bt-set-row bt-bs-row bt-model-test" }, + (0, import_react25.createElement)( + "span", @@ -18199,11 +17158,41 @@ diff --git a/client.js b/client.js + { className: "bt-model-test-result", role: "status", "aria-live": "polite", "data-ok": outcome === void 0 ? void 0 : String(outcome.ok) }, + busy ? t("Testing…") : outcome === void 0 ? null : outcome.text, + outcome?.code ? (0, import_react25.createElement)("code", { className: "bt-model-test-code" }, outcome.code) : null -+ ) -+ ), + ) + ), +- error ? (0, import_react15.createElement)("div", { className: "bt-set-row" }, (0, import_react15.createElement)("span", { className: "bt-danger bt-set-hint" }, error)) : null, +- (0, import_react15.createElement)( +- "div", +- { className: "bt-set-row bt-task-foot" }, +- (0, import_react15.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: onCancel }, t("Cancel")), +- (0, import_react15.createElement)("button", { type: "submit", className: "bt-send bt-soft-sm", disabled: busy || !target || title.trim() === "" || prompt.trim() === "" }, busy ? t("Creating…") : t("Create task")) +- ) + (0, import_react25.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy, "aria-busy": busy || void 0, onClick: start }, t("Test connection")) ); } +-function AutomationOff() { +- return (0, import_react15.createElement)( +- "div", +- { className: "bt-set-card bt-task-off" }, +- (0, import_react15.createElement)("span", { className: "bt-task-off-mark" }, (0, import_react15.createElement)(ClockIcon)), +- (0, import_react15.createElement)("span", { className: "bt-task-off-title" }, t("Turn on Automation tasks in DSH")), +- (0, import_react15.createElement)("span", { className: "bt-set-hint" }, t("Switch on Automation tasks on the Plugins page in Harness settings.")), +- (0, import_react15.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: openHarnessSettings }, t("Open Harness settings")) +- ); ++var MODEL_TEST_CSS = ` ++.bt-model-test-result{font-size:13px;line-height:18px;color:var(--bt-ink-2)} ++.bt-model-test-result:empty{display:none} ++.bt-model-test-result[data-ok=true]{color:#1a7f37} ++.bt-model-test-result[data-ok=false]{color:#c21d2e} ++.bt-model-test-code{margin-left:6px;font-size:11px;color:var(--bt-ink-3)} ++/* #1347: the hint beside "Test connection" is prose and wraps; the button next to it keeps its own ++ words ("Test connection" on one line) and takes the room it needs. */ ++.bt-model-test>.bt-set-copy{flex:1 1 12ch;min-width:12ch} ++.bt-model-test>.bt-soft{flex:none;white-space:nowrap} ++@container bt-panel (max-width:300px){ ++ .bt-model-test{flex-wrap:wrap;row-gap:6px} ++ .bt-model-test>.bt-set-copy{flex:1 1 100%} + } -function SchedulesPage({ roster, actions, start }) { - const view = useSchedules(actions); - const [adding, setAdding] = (0, import_react15.useState)(Boolean(start)); @@ -18212,7 +17201,31 @@ diff --git a/client.js b/client.js - const shown = (next) => { - publish(next); - setAdding(false); -- }; ++`; ++ ++// src/client/folder-menu.js ++var import_react26 = require("react"); ++var import_react_dom9 = require("react-dom"); ++var folderName = (path) => String(path).replace(/[\\/]+$/, "").split(/[\\/]/).pop() || String(path); ++var Tick = ({ on }) => (0, import_react26.createElement)("span", { className: "bt-model-tick", "aria-hidden": true }, on ? (0, import_react26.createElement)(CheckIcon) : null); ++function FolderMenu({ value, group = false, actions, run, onPick, wide = false }) { ++ const { isOpen, shownPlace, leaving, button, menu, open, close } = useAnchoredMenu(); ++ const [info, setInfo] = (0, import_react26.useState)(null); ++ const [view, setView] = (0, import_react26.useState)("list"); ++ const [draft, setDraft] = (0, import_react26.useState)(""); ++ const [listing, setListing] = (0, import_react26.useState)(null); ++ const [problem, setProblem] = (0, import_react26.useState)(""); ++ const fallback = group ? t("Each Bot's own folder") : t("Team workspace"); ++ const toggle = () => { ++ if (isOpen) { ++ close(); ++ return; ++ } ++ setView("list"); ++ setProblem(""); ++ void Promise.resolve(actions.folders()).then(setInfo).catch(() => setInfo((last) => last ?? { team: null, picker: null, folders: [] })); ++ open(); + }; - const byChat = [ - ...roster.bots.map((bot) => [bot.id, (0, import_react15.createElement)(BotAvatar, { bot, size: 18, badge: false, live: false }), bot.name, view.tasks.filter((task) => task.botId === bot.id)]), - ...roster.rooms.map((room2) => [room2.id, (0, import_react15.createElement)(RoomAvatar, { room: room2, roster, size: 18 }), room2.name, view.tasks.filter((task) => task.botId === void 0 && task.roomId === room2.id)]) @@ -18226,20 +17239,7 @@ diff --git a/client.js b/client.js - (0, import_react15.createElement)("div", { className: "bt-set-card" }, tasks.map((task) => (0, import_react15.createElement)(TaskRow, { key: task.id, task, actions, roster, onView: publish }))) - )) - ]; -+var MODEL_TEST_CSS = ` -+.bt-model-test-result{font-size:13px;line-height:18px;color:var(--bt-ink-2)} -+.bt-model-test-result:empty{display:none} -+.bt-model-test-result[data-ok=true]{color:#1a7f37} -+.bt-model-test-result[data-ok=false]{color:#c21d2e} -+.bt-model-test-code{margin-left:6px;font-size:11px;color:var(--bt-ink-3)} -+/* #1347: the hint beside "Test connection" is prose and wraps; the button next to it keeps its own -+ words ("Test connection" on one line) and takes the room it needs. */ -+.bt-model-test>.bt-set-copy{flex:1 1 12ch;min-width:12ch} -+.bt-model-test>.bt-soft{flex:none;white-space:nowrap} -+@container bt-panel (max-width:300px){ -+ .bt-model-test{flex-wrap:wrap;row-gap:6px} -+ .bt-model-test>.bt-set-copy{flex:1 1 100%} - } +-} -function BotSchedules({ bot, roster, actions }) { - const view = useSchedules(actions); - if (view === null) return null; @@ -18261,31 +17261,6 @@ diff --git a/client.js b/client.js - (0, import_react15.createElement)("button", { key: "new", type: "button", className: "bt-set-row bt-bs-row bt-gs-add", onClick: () => actions.openSettings("schedules", bot.id) }, (0, import_react15.createElement)(PlusIcon), t("New task")) - ] - ) -+`; -+ -+// src/client/folder-menu.js -+var import_react26 = require("react"); -+var import_react_dom9 = require("react-dom"); -+var folderName = (path) => String(path).replace(/[\\/]+$/, "").split(/[\\/]/).pop() || String(path); -+var Tick = ({ on }) => (0, import_react26.createElement)("span", { className: "bt-model-tick", "aria-hidden": true }, on ? (0, import_react26.createElement)(CheckIcon) : null); -+function FolderMenu({ value, group = false, actions, run, onPick, wide = false }) { -+ const { isOpen, shownPlace, leaving, button, menu, open, close } = useAnchoredMenu(); -+ const [info, setInfo] = (0, import_react26.useState)(null); -+ const [view, setView] = (0, import_react26.useState)("list"); -+ const [draft, setDraft] = (0, import_react26.useState)(""); -+ const [listing, setListing] = (0, import_react26.useState)(null); -+ const [problem, setProblem] = (0, import_react26.useState)(""); -+ const fallback = group ? t("Each Bot's own folder") : t("Team workspace"); -+ const toggle = () => { -+ if (isOpen) { -+ close(); -+ return; -+ } -+ setView("list"); -+ setProblem(""); -+ void Promise.resolve(actions.folders()).then(setInfo).catch(() => setInfo((last) => last ?? { team: null, picker: null, folders: [] })); -+ open(); -+ }; + const choose = (path) => { + close(); + if (path !== (value ?? "")) void onPick(path); @@ -19030,7 +18005,7 @@ diff --git a/client.js b/client.js alive.current = true; void load({ summarize: true }); return () => { -@@ -7606,28 +7606,29 @@ function useMemoryView(botId, actions) { +@@ -7606,38 +7606,333 @@ function useMemoryView(botId, actions) { }, [botId]); return { view, error, load, show }; } @@ -19046,14 +18021,10 @@ diff --git a/client.js b/client.js return now; } -function MemoryDialog({ bot, actions, leaving = false }) { -+function MemoryDialog({ bot, actions, roster, leaving = false }) { - const { view, error, load, show } = useMemoryView(bot.id, actions); +- 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 locked = typeof roster?.readOnly === "string" && roster.readOnly.trim() !== ""; -+ const [page, setPage] = (0, import_react28.useState)("summary"); -+ const [menu, setMenu] = (0, import_react28.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); @@ -19061,6 +18032,15 @@ diff --git a/client.js b/client.js - 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 MemoryDialog({ bot, actions, roster, leaving = false }) { ++ const { view, error, load, show } = useMemoryView(bot.id, actions); ++ const locked = typeof roster?.readOnly === "string" && roster.readOnly.trim() !== ""; ++ const [page, setPage] = (0, import_react28.useState)("summary"); ++ const [menu, setMenu] = (0, import_react28.useState)(false); ++ const [menuShown, menuLeaving] = useLinger(menu, MENU_EXIT_MS2); + const [text2, setText] = (0, import_react28.useState)(""); + const [answer, setAnswer] = (0, import_react28.useState)(null); + const menuRef = (0, import_react28.useRef)(null); @@ -19068,46 +18048,79 @@ diff --git a/client.js b/client.js + const inputRef = (0, import_react28.useRef)(null); + const now = useNow3(3e4); + const close = (0, import_react28.useCallback)(() => actions.closeMemory(), []); - useEscape(close); -- (0, import_react17.useEffect)(() => { ++ useEscape(close); + (0, import_react28.useEffect)(() => { - if (!menu) return void 0; - const onDown = (event) => { - if (menuRef.current?.contains(event.target) || dotsRef.current?.contains(event.target)) return; -@@ -7671,7 +7671,7 @@ function MemoryDialog({ bot, actions, leaving = false }) { - }; - const ask = async (event) => { - event.preventDefault(); -- const asked = text.trim(); ++ 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 = text2.trim(); - if (asked === "" || answer?.pending) return; - setText(""); - setAnswer({ asked, pending: true }); -@@ -7689,65 +7689,66 @@ function MemoryDialog({ bot, actions, leaving = false }) { - 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)( ++ 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; + return (0, import_react28.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)( ++ "div", ++ { className: "bt-mem-layer", "data-leaving": leaving || void 0 }, + (0, import_react28.createElement)("div", { className: "bt-scrim bt-mem-scrim", onMouseDown: close }), + (0, import_react28.createElement)( - "div", - { className: "bt-mem", role: "dialog", "aria-modal": true, "aria-label": t("{name}'s memory", { name: bot.name }) }, -- (0, import_react17.createElement)( ++ "div", ++ { className: "bt-mem", role: "dialog", "aria-modal": true, "aria-label": t("{name}'s memory", { name: bot.name }) }, + (0, import_react28.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", ++ { className: "bt-mem-head" }, + page === "entries" ? (0, import_react28.createElement)("button", { type: "button", className: "bt-icon-btn", "aria-label": t("Back to the summary"), onClick: backToSummary }, (0, import_react28.createElement)(ChevronLeftIcon)) : null, + (0, import_react28.createElement)("h2", null, page === "entries" ? t("All entries") : t("Memory summary")), + (0, import_react28.createElement)("span", { className: "bt-mem-status" }, page === "summary" ? [bot.name, status].filter(Boolean).join(" · ") : status), @@ -19115,72 +18128,56 @@ diff --git a/client.js b/client.js + (0, import_react28.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_react28.createElement)(DotsIcon)), + (0, import_react28.createElement)("button", { type: "button", className: "bt-icon-btn", "aria-label": t("Close memory"), onClick: close }, (0, import_react28.createElement)(CloseIcon)), + menuShown ? (0, import_react28.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)( ++ "div", ++ { ref: menuRef, className: "bt-menu bt-mem-menu", role: "menu", "aria-label": t("Memory options"), "data-leaving": menuLeaving || void 0 }, + (0, import_react28.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)) ++ "button", ++ { type: "button", role: "menuitem", onClick: openEntries }, + (0, import_react28.createElement)("span", { className: "bt-menu-label" }, t("All entries")), + (0, import_react28.createElement)("span", { className: "bt-menu-hint" }, String(count)) - ), -- (0, import_react17.createElement)( ++ ), + (0, import_react28.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")) ++ "button", ++ { type: "button", role: "menuitem", disabled: count === 0 || view?.summarizing, onClick: regenerate }, + (0, import_react28.createElement)("span", { className: "bt-menu-label" }, t("Regenerate summary")) - ) - ) : null - ), -- (0, import_react17.createElement)( ++ ) ++ ) : null ++ ), + (0, import_react28.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 }) ++ "div", ++ { className: "bt-mem-body" }, + locked ? (0, import_react28.createElement)("p", { className: "bt-hint bt-read-only", role: "status" }, hostText(roster.readOnly)) : null, + error ? (0, import_react28.createElement)("div", { className: "bt-error", role: "alert", style: { padding: 0 } }, hostText(error)) : null, + view === null ? error ? null : (0, import_react28.createElement)(Skeleton) : page === "entries" ? (0, import_react28.createElement)(Entries, { bot, view, remove: locked ? null : remove }) : (0, import_react28.createElement)(Summary, { bot, view, regenerate }) - ), -- (0, import_react17.createElement)( ++ ), + (0, import_react28.createElement)( - "div", - { className: "bt-mem-foot" }, -- answer ? (0, import_react17.createElement)(Reply, { answer, dismiss: () => setAnswer(null) }) : null, -- (0, import_react17.createElement)( ++ "div", ++ { className: "bt-mem-foot" }, + answer ? (0, import_react28.createElement)(Reply, { answer, dismiss: () => setAnswer(null) }) : null, + locked ? null : (0, import_react28.createElement)( - "form", - { className: "bt-mem-ask", onSubmit: ask }, -- (0, import_react17.createElement)("input", { ++ "form", ++ { className: "bt-mem-ask", onSubmit: ask }, + (0, import_react28.createElement)("input", { - ref: inputRef, - className: "bt-mem-input", -- value: text, ++ ref: inputRef, ++ className: "bt-mem-input", + value: text2, - 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)( ++ 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_react28.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) ++ "button", + { type: "submit", className: "bt-mem-send", disabled: busy || text2.trim() === "", "aria-label": t("Send") }, + busy ? (0, import_react28.createElement)("span", { className: "bt-mem-spin", "aria-hidden": true }) : (0, import_react28.createElement)(ArrowUpIcon) - ) - ) - ) -@@ -7755,8 +7755,40 @@ function MemoryDialog({ bot, actions, leaving = false }) { - ); - } - ++ ) ++ ) ++ ) ++ ) ++ ); ++} ++ +// src/client/standing-approvals.js +var import_react29 = require("react"); +var standingOf = (target, roster) => { @@ -19213,345 +18210,425 @@ diff --git a/client.js b/client.js + ); +} + - // src/client/bot-settings.js --var import_react18 = require("react"); ++// src/client/bot-settings.js +var import_react30 = 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")); -@@ -7780,8 +7780,8 @@ async function avatarImage(file) { - 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); ++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_react30.useState)(null); + const reverting = (0, import_react30.useRef)(false); - const commit = () => { - const next = draft?.trim(); - setDraft(null); -@@ -7792,7 +7792,7 @@ function InlineText({ label, value, placeholder, required = false, maxLength, mu - if (next === void 0 || next === (value ?? "") || required && next === "") return; - void onSave(next); - }; -- return (0, import_react18.createElement)(multiline ? "textarea" : "input", { ++ 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_react30.createElement)(multiline ? "textarea" : "input", { - className: multiline ? "bt-bs-input bt-bs-area" : "bt-bs-input", - value: draft ?? value ?? "", - placeholder, -@@ -7813,29 +7813,29 @@ function InlineText({ label, value, placeholder, required = false, maxLength, mu - }); - } - function Row({ label, hint, stack = false, children }) { -- return (0, import_react18.createElement)( ++ 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_react30.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), ++ "label", ++ { className: stack ? "bt-set-row bt-bs-row bt-bs-stack" : "bt-set-row bt-bs-row" }, + (0, import_react30.createElement)("span", { className: "bt-set-copy" }, (0, import_react30.createElement)("span", { className: "bt-bs-label" }, label), hint ? (0, import_react30.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)); ++ children ++ ); ++} ++function Card({ title, children }) { + return (0, import_react30.createElement)("section", { className: "bt-set-section" }, title ? (0, import_react30.createElement)("h3", null, title) : null, (0, import_react30.createElement)("div", { className: "bt-set-card" }, children)); - } - function Switch({ on, label, disabled, onChange }) { -- return (0, import_react18.createElement)( ++} ++function Switch({ on, label, disabled, onChange }) { + return (0, import_react30.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 }) ++ "button", ++ { type: "button", role: "switch", className: "bt-switch", "aria-checked": on, "aria-label": label, disabled, onClick: () => onChange(!on) }, + (0, import_react30.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_react30.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-set-row bt-bs-row" }, + (0, import_react30.createElement)("span", { className: "bt-set-copy" }, showLabel ? (0, import_react30.createElement)("span", { className: "bt-bs-label" }, label) : null, hint ? (0, import_react30.createElement)("span", { className: "bt-set-hint" }, hint) : null), + (0, import_react30.createElement)(Switch, { on, label, disabled, onChange }) - ); - } - function modelHint(bot, roster) { -@@ -7846,22 +7846,22 @@ function modelHint(bot, roster) { - return t("Applies from the next message."); - } - function ShapeRing({ shape, size }) { -- const maskId = (0, import_react18.useRef)(""); ++ ); ++} ++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 ShapeRing({ shape, size }) { + const maskId = (0, import_react30.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 } }); ++ 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_react30.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)); -- return (0, import_react18.createElement)( -- import_react18.Fragment, ++} ++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_react30.createElement)( + import_react30.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", { ++ null, + (0, import_react30.createElement)("div", { className: "bt-shapes", role: "group", "aria-label": t("Character shape") }, rows.map((row, index) => (0, import_react30.createElement)("div", { key: index, className: "bt-shape-row" }, row.map((id) => (0, import_react30.createElement)("button", { - key: id, - type: "button", - className: "bt-shape", -@@ -7869,8 +7869,8 @@ function CharacterGrid({ look, hasPhoto = false, onShape, onColor }) { - 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: 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_react30.createElement)(ShapeRing, { shape: id, size: 36 }), (0, import_react30.createElement)(BotMark, { look: { shape: id, color: look.color }, size: 36 })))))), + (0, import_react30.createElement)("div", { className: "bt-colors", role: "group", "aria-label": t("Character color") }, CHARACTER_COLORS.map((color) => (0, import_react30.createElement)("button", { - key: color, - type: "button", - className: "bt-color", -@@ -7878,19 +7878,19 @@ function CharacterGrid({ look, hasPhoto = false, onShape, onColor }) { - title: t(capitalize(color)), - "aria-pressed": look.color === color, - onClick: () => onColor(color) -- }, (0, import_react18.createElement)("span", { style: { background: paintCss(color) } })))) ++ 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_react30.createElement)("span", { style: { background: paintCss(color) } })))) - ); - } - 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); ++ ); ++} ++function AvatarEditor({ bot, actions, run, onClose }) { ++ const look = lookOf(bot); + const [tab, setTab] = (0, import_react30.useState)(look.image ? "upload" : "bot"); + const [over, setOver] = (0, import_react30.useState)(false); + const ref = (0, import_react30.useRef)(null); + const fileRef = (0, import_react30.useRef)(null); - const upload = (file) => { - if (file) void run(avatarImage(file).then((image) => actions.updateBot(bot.id, { avatar: { ...bot.avatar ?? {}, image } }))); - }; -- (0, import_react18.useEffect)(() => { ++ const upload = (file) => { ++ if (file) void run(avatarImage(file).then((image) => actions.updateBot(bot.id, { avatar: { ...bot.avatar ?? {}, image } }))); ++ }; + (0, import_react30.useEffect)(() => { const onDown = (event) => { - if (!(event.target instanceof Element) || ref.current?.contains(event.target) || event.target.closest(".bt-avatar-trigger")) return; - onClose(); -@@ -7908,7 +7908,7 @@ function AvatarEditor({ bot, actions, run, onClose }) { +- 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,917 @@ function MemoryDialog({ bot, actions, leaving = false }) { + window.removeEventListener("mousedown", onDown); window.removeEventListener("keydown", onKey, true); }; - }, [onClose]); -- (0, import_react18.useEffect)(() => { +- }, [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]); + (0, import_react30.useEffect)(() => { - if (tab !== "upload") return void 0; - const onPaste = (event) => { - const file = [...event.clipboardData?.files ?? []].find((item) => item.type.startsWith("image/")); -@@ -7922,32 +7922,32 @@ function AvatarEditor({ bot, actions, run, onClose }) { - }, [tab, bot]); - const hasPhoto = Boolean(look.image); - const glide = useGlide(tab); -- return (0, import_react18.createElement)( ++ 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_react30.createElement)( - "div", - { ref, className: "bt-avedit", role: "dialog", "aria-label": t("Avatar editor") }, -- (0, import_react18.createElement)( ++ "div", ++ { ref, className: "bt-avedit", role: "dialog", "aria-label": t("Avatar editor") }, + (0, import_react30.createElement)( - "div", - { className: "bt-avedit-top" }, -- (0, import_react18.createElement)( ++ "div", ++ { className: "bt-avedit-top" }, + (0, import_react30.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))) ++ "div", ++ { ref: glide.box, role: "tablist", "aria-label": t("Avatar source"), className: "bt-avedit-tabs" }, + (0, import_react30.createElement)("span", { ref: glide.thumb, className: "bt-thumb", "aria-hidden": true }), + [["bot", "Bot"], ["upload", "Upload"]].map(([id, label]) => (0, import_react30.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_react30.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")) - ), -- tab === "bot" ? (0, import_react18.createElement)(CharacterGrid, { ++ 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_react30.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)( ++ look, ++ hasPhoto, ++ onShape: (shape) => run(actions.updateBot(bot.id, { avatar: { shape } })), ++ onColor: (color) => run(actions.updateBot(bot.id, { color })) ++ }) : null, + tab === "upload" ? (0, import_react30.createElement)( - "div", - { - className: "bt-dropzone", -@@ -7963,15 +7963,15 @@ function AvatarEditor({ bot, actions, run, onClose }) { - 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-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_react30.createElement)("img", { src: look.image, alt: "" }) : null, + (0, import_react30.createElement)("div", null, t("Drag, drop, or paste an image")), + (0, import_react30.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 ++ "div", ++ { className: "bt-actions", style: { justifyContent: "center" } }, + (0, import_react30.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => fileRef.current?.click() }, hasPhoto ? t("Replace") : t("Browse files")), + hasPhoto ? (0, import_react30.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_react30.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); -@@ -7980,28 +7980,29 @@ function AvatarEditor({ bot, actions, run, onClose }) { - ); - } - 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)( ++ const file = event.target.files?.[0]; ++ event.target.value = ""; ++ upload(file); ++ } }) ++ ) : null ++ ); ++} ++function MemoryCard({ bot, actions }) { + const { count, failure } = useMemoryCount(bot.id, actions); + const label = memoryCountText({ count, failure }); + return (0, import_react30.createElement)( - Card, - { title: t("Memory") }, -- (0, import_react18.createElement)( ++ Card, ++ { title: t("Memory") }, + (0, import_react30.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)) ++ "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_react30.createElement)("span", { className: "bt-set-copy" }, (0, import_react30.createElement)("span", { className: "bt-bs-label" }, label)), + (0, import_react30.createElement)("span", { className: "bt-mem-row-go" }, t("Manage"), (0, import_react30.createElement)(ChevronRightIcon)) - ) - ); - } - 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)(() => { ++ ) ++ ); ++} ++function BotSettingsForm({ bot, roster, actions, run }) { + const [editing, setEditing] = (0, import_react30.useState)(false); + const [confirming, setConfirming] = (0, import_react30.useState)(false); + const closeEditor = (0, import_react30.useCallback)(() => setEditing(false), []); + (0, import_react30.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; -@@ -8011,149 +8011,812 @@ function BotSettingsForm({ bot, roster, actions, run }) { - 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; ++ 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); }; -- (0, import_react18.useEffect)(() => { +- 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_react30.useEffect)(() => { - void actions.refreshModels?.(); - }, [bot.id]); -- return (0, import_react18.createElement)( ++ void actions.refreshModels?.(); ++ }, [bot.id]); + return (0, import_react30.createElement)( "div", - { className: "bt-bs" }, -- (0, import_react18.createElement)( +- { 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" }, + (0, import_react30.createElement)( "div", - { className: "bt-bs-hero" }, -- (0, import_react18.createElement)( +- { 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" }, + (0, import_react30.createElement)( "div", - { className: "bt-avatar-anchor" }, -- (0, import_react18.createElement)( +- { 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" }, + (0, import_react30.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)) ++ "button", ++ { type: "button", className: "bt-avatar-trigger", "aria-label": t("Edit Bot avatar"), "aria-expanded": editing, onClick: () => setEditing((value) => !value) }, + (0, import_react30.createElement)(BotMark, { bot, size: 64, live: true }), + (0, import_react30.createElement)("span", { className: "bt-avatar-pencil", "aria-hidden": true }, (0, import_react30.createElement)(PencilIcon)) - ), -- editing ? (0, import_react18.createElement)(AvatarEditor, { bot, actions, run, onClose: closeEditor }) : null ++ ), + editing ? (0, import_react30.createElement)(AvatarEditor, { bot, actions, run, onClose: closeEditor }) : null ), -- (0, import_react18.createElement)( +- (0, import_react17.createElement)( + (0, import_react30.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")) +- { 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-bs-who" }, + (0, import_react30.createElement)("span", { className: "bt-bs-name" }, bot.name), -+ (0, import_react30.createElement)("span", { className: "bt-bs-sub" }, [bot.role, isMainBot ? t("Main Bot") : ""].filter(Boolean).join(" · ") || t("Bot")) - ) - ), -- (0, import_react18.createElement)( ++ (0, import_react30.createElement)("span", { className: "bt-bs-sub" }, [bot.role, isMainBot ? t("Main Bot") : ""].filter(Boolean).join(" · ") || t("Bot")) ++ ) ++ ), + (0, import_react30.createElement)( - Card, - { title: t("Profile") }, -- (0, import_react18.createElement)(Row, { label: t("Name") }, (0, import_react18.createElement)(InlineText, { label: t("Name"), value: bot.name, placeholder: "Bob", required: true, maxLength: 40, onSave: (name) => save({ name }) })), -- (0, import_react18.createElement)(Row, { label: t("Label"), hint: t("Optional. Shown under the name.") }, (0, import_react18.createElement)(InlineText, { label: t("Label"), value: bot.role, placeholder: t("Research, admin…"), maxLength: 24, onSave: (role) => save({ role }) })) ++ Card, ++ { title: t("Profile") }, + (0, import_react30.createElement)(Row, { label: t("Name") }, (0, import_react30.createElement)(InlineText, { label: t("Name"), value: bot.name, placeholder: "Bob", required: true, maxLength: 40, onSave: (name) => save({ name }) })), + (0, import_react30.createElement)(Row, { label: t("Label"), hint: t("Optional. Shown under the name.") }, (0, import_react30.createElement)(InlineText, { label: t("Label"), value: bot.role, placeholder: t("Research, admin…"), maxLength: 24, onSave: (role) => save({ role }) })) - ), -- (0, import_react18.createElement)( ++ ), + (0, import_react30.createElement)( - Card, - { title: t("Model") }, -- (0, import_react18.createElement)( ++ Card, ++ { title: t("Model") }, + (0, import_react30.createElement)( - "div", - { className: "bt-set-row bt-bs-row" }, -- (0, import_react18.createElement)("span", { className: "bt-set-copy" }, (0, import_react18.createElement)("span", { className: "bt-bs-label" }, t("Model")), (0, import_react18.createElement)("span", { className: "bt-set-hint" }, modelHint(bot, roster))), -- (0, import_react18.createElement)(ModelMenu, { bot, roster, actions, run }) -- ) ++ "div", ++ { className: "bt-set-row bt-bs-row" }, + (0, import_react30.createElement)("span", { className: "bt-set-copy" }, (0, import_react30.createElement)("span", { className: "bt-set-hint" }, modelHint(bot, roster))), + (0, import_react30.createElement)(ModelMenu, { bot, roster, actions, run }) -+ ), + ), +- (0, import_react17.createElement)( + bot.model ? (0, import_react30.createElement)(ModelTestRow, { bot, modelName: modelOf(bot, roster)?.name }) : null - ), -- (0, import_react18.createElement)( ++ ), + (0, import_react30.createElement)( - Card, - { title: t("Working folder") }, -- (0, import_react18.createElement)( ++ Card, ++ { title: t("Working folder") }, + (0, import_react30.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 }) }) +- { 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) +- ) +- ) ++ { className: "bt-set-row bt-bs-row" }, + (0, import_react30.createElement)("span", { className: "bt-set-copy" }, (0, import_react30.createElement)("span", { className: "bt-bs-label" }, t("Folder"))), + (0, import_react30.createElement)(FolderMenu, { value: bot.workDir, actions, run, onPick: (workDir) => save({ workDir }) }) - ) - ), -- (0, import_react18.createElement)( ++ ) ++ ), + (0, import_react30.createElement)( - Card, - { title: t("Instructions") }, -- (0, import_react18.createElement)( ++ Card, ++ { title: t("Instructions") }, + (0, import_react30.createElement)( - Row, - { label: t("What this Bot does"), hint: t("Its job on the team. Part of the Bot's system prompt."), stack: true }, -- (0, import_react18.createElement)(InlineText, { label: t("Instructions"), value: bot.instructions, multiline: true, placeholder: t("What this Bot is responsible for…"), onSave: (instructions) => save({ instructions }) }) ++ Row, ++ { label: t("What this Bot does"), hint: t("Its job on the team. Part of the Bot's system prompt."), stack: true }, + (0, import_react30.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_react30.createElement)(FlyBotDetails, { bot, roster, actions, wrap: (children) => (0, import_react30.createElement)(Card, { title: t("Sharing") }, children) }), + (0, import_react30.createElement)(BotCapabilitiesCard, { bot, roster, actions, run, Card, SwitchRow }), @@ -19560,9 +18637,8 @@ diff --git a/client.js b/client.js + (0, import_react30.createElement)(BotSchedules, { bot, roster, actions }), + (0, import_react30.createElement)(StandingApprovalsCard, { scope: "bot", target: bot, roster, actions, run, Card }), + (0, import_react30.createElement)( - Card, - { title: t("Main Bot") }, -- onlyMain ? (0, import_react18.createElement)( ++ Card, ++ { title: t("Main Bot") }, + onlyMain ? (0, import_react30.createElement)( + "div", + { className: "bt-set-row bt-bs-row" }, @@ -19600,10 +18676,10 @@ diff --git a/client.js b/client.js + (0, import_react30.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_react30.createElement)("span", { className: "bt-chevron" }, (0, import_react30.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} @@ -19653,7 +18729,26 @@ diff --git a/client.js b/client.js + .bt-bs-input{flex:1 1 100%;width:auto;min-width:0;margin:0;text-align:left} +} +`; -+ + +-// 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; +// src/client/group-settings.js +var import_react31 = require("react"); +var ROOM_MODES = [ @@ -19688,7 +18783,42 @@ diff --git a/client.js b/client.js + const bot = faceSeatBot(seat, roster); + if (!bot) continue; + marks.push((0, import_react31.createElement)(BotMark, { key: seat.key, bot, size, live: false })); -+ } + } +- 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(); +- } + if (marks.length === 0) return [(0, import_react31.createElement)(PersonMark, { key: "person", size, name: person?.name })]; + return marks; +} @@ -19706,7 +18836,24 @@ diff --git a/client.js b/client.js + const name = seat.local && onOpenBot ? (0, import_react31.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_react31.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_react31.createElement)("span", { key: `${seat.key}-lead`, className: "bt-tag", "data-flybot": "face-lead-tag" }, t("Lead"))] : [name]; + return index === 0 ? marked : [(0, import_react31.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_react31.createElement)( + "div", + { className, role: "listitem", "data-flybot": "face-person", "data-mine": person.mine || void 0 }, @@ -19721,8 +18868,10 @@ diff --git a/client.js b/client.js + (0, import_react31.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_react31.useState)(false); @@ -19745,7 +18894,10 @@ diff --git a/client.js b/client.js + } + }; + return (0, import_react31.createElement)( -+ "div", + "div", +- { className: "bt-set-row bt-bs-row" }, +- (0, import_react18.createElement)("span", { className: "bt-set-copy" }, (0, import_react18.createElement)("span", { className: "bt-bs-label" }, label), hint ? (0, import_react18.createElement)("span", { className: "bt-set-hint" }, hint) : null), +- (0, import_react18.createElement)(Switch, { on, label, disabled, onChange }) + { className: "bt-face-lead", "data-flybot": "face-lead" }, + (0, import_react31.createElement)("div", { className: "bt-section-title" }, t("Room lead")), + (0, import_react31.createElement)( @@ -19768,20 +18920,59 @@ diff --git a/client.js b/client.js + open && canPick && lead.picked ? (0, import_react31.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_react31.createElement)("div", { className: "bt-set-hint" }, t("Only the person who created this room can change it.")), + error ? (0, import_react31.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_react31.useState)(false); + const [confirming, setConfirming] = (0, import_react31.useState)(false); @@ -19874,9 +19065,8 @@ diff --git a/client.js b/client.js + Card, + { title: t("Working folder") }, + (0, import_react31.createElement)( - "div", - { className: "bt-set-row bt-bs-row" }, -- (0, import_react18.createElement)( ++ "div", ++ { className: "bt-set-row bt-bs-row" }, + (0, import_react31.createElement)("span", { className: "bt-set-copy" }, (0, import_react31.createElement)("span", { className: "bt-bs-label" }, t("Folder"))), + (0, import_react31.createElement)(FolderMenu, { value: room2.workDir, group: true, actions, run, onPick: (workDir) => update({ workDir }) }) + ) @@ -19926,19 +19116,14 @@ diff --git a/client.js b/client.js + "div", + { key: person.handle || person.id, className: "bt-set-row bt-bs-row bt-gs-member" }, + (0, import_react31.createElement)( - "span", -- { className: "bt-set-copy" }, -- (0, import_react18.createElement)("span", { className: "bt-bs-label" }, t("Main Bot")), -- (0, import_react18.createElement)("span", { className: "bt-set-hint" }, t("The only Main Bot. To hand the role to another Bot, use Main Bot on the Bots page.")) ++ "span", + { className: "bt-gs-who" }, + (0, import_react31.createElement)(BotAvatar, { bot: lookBotForRemote(person, roster), size: 26, badge: false, live: false }), + (0, import_react31.createElement)("span", { className: "bt-row-title", title: person.handle || void 0 }, person.label || remoteBotTitle(person)), + // #904: a host's roster entry is not a friend's Bot, and one this home cannot confirm is + // labelled as such; a Bot with no Bot Social identity can never be written to at all. + room2.origin === "face-to-face" ? null : (0, import_react31.createElement)("span", { className: "bt-tag" }, person.unconnected ? t("Not connected") : room2.origin === "inbound-share" && person.verified === false ? t("Unconfirmed") : t("Friend's Bot")) - ), -- (0, import_react18.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => actions.openSettings("bots") }, t("Change")) -- ) : (0, import_react18.createElement)(SwitchRow, { label: t("Main Bot"), hint: t("Can create and change Bots, and runs every group chat."), on: isMainBot, onChange: (on) => run(actions.setMain(bot.id, on)) }) ++ ), + // A guest's mirror is not theirs to edit: the host's membership is changed on the host side. + room2.origin === "face-to-face" || room2.origin === "inbound-share" || members.length + remotes.length <= 2 ? null : (0, import_react31.createElement)("button", { type: "button", className: "bt-x", "aria-label": t("Remove {name} from the group", { name: remoteBotTitle(person) }), title: t("Remove from group"), onClick: () => update({ removeParticipants: [{ kind: "bot", id: person.handle || person.id, remote: true }] }) }, "×") + )), @@ -19983,48 +19168,39 @@ diff --git a/client.js b/client.js + // #876: a plugin's rows for this room (Invite by link), only in a group that lives here. Last, after + // the note on who leads, so that note stays with the members it is about (#892). + room2.origin === "face-to-face" || room2.origin === "inbound-share" ? null : (0, import_react31.createElement)(FlyBotRoomMembers, { room: room2, roster, actions }) - ), -- // The Main Bot tile is always first and always shown, like in the context menu. -- isTile ? null : (0, import_react18.createElement)( ++ ), + (0, import_react31.createElement)(StandingApprovalsCard, { scope: "room", target: room2, roster, actions, run, Card }), + (0, import_react31.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 })) }) ++ Card, ++ { title: t("Sidebar") }, + (0, import_react31.createElement)(SwitchRow, { label: t("Pin"), hint: t("Kept above the other chats."), on: room2.pinned === true, onChange: (pinned) => run(actions.setFlags(room2.id, { pinned })) }), + (0, import_react31.createElement)(SwitchRow, { label: t("Hide from sidebar"), hint: t("Search still finds it."), on: room2.hidden === true, onChange: (hidden) => run(actions.setFlags(room2.id, { hidden })) }) - ), -- (0, import_react18.createElement)( ++ ), + (0, import_react31.createElement)( - Card, -- { title: t("Manage") }, -- (0, import_react18.createElement)( -- "button", -- { type: "button", className: "bt-set-row bt-bs-row", onClick: duplicate }, -- (0, import_react18.createElement)("span", { className: "bt-set-copy" }, (0, import_react18.createElement)("span", { className: "bt-bs-label" }, t("Duplicate")), (0, import_react18.createElement)("span", { className: "bt-set-hint" }, t("A copy with the same job and model, and fresh memory."))), -- (0, import_react18.createElement)("span", { className: "bt-chevron" }, (0, import_react18.createElement)(ChevronRightIcon)) -- ), -- isMainBot ? null : (0, import_react18.createElement)( ++ Card, + { title: t("More") }, + (0, import_react31.createElement)( - "button", - { type: "button", className: "bt-set-row bt-bs-row bt-bs-danger", "data-confirm": confirming || void 0, onClick: remove }, -- (0, import_react18.createElement)( ++ "button", ++ { type: "button", className: "bt-set-row bt-bs-row bt-bs-danger", "data-confirm": confirming || void 0, onClick: remove }, + (0, import_react31.createElement)( - "span", - { className: "bt-set-copy" }, -- (0, import_react18.createElement)("span", { className: "bt-bs-label" }, confirming ? t("Delete {name}?", { name: bot.name }) : t("Delete Bot")), -- (0, import_react18.createElement)("span", { className: "bt-set-hint" }, confirming ? t("Click again to delete. Its chats are archived.") : t("Removes the Bot from the team.")) ++ "span", ++ { className: "bt-set-copy" }, + (0, import_react31.createElement)("span", { className: "bt-bs-label" }, confirming ? t("Delete {name}?", { name: room2.name }) : t("Delete group")), + (0, import_react31.createElement)("span", { className: "bt-set-hint" }, confirming ? t("Click again to delete. Its history is archived.") : t("Removes the group chat. Its members stay on the team.")) - ), -- (0, import_react18.createElement)("span", { className: "bt-chevron" }, (0, import_react18.createElement)(ChevronRightIcon)) ++ ), + (0, import_react31.createElement)("span", { className: "bt-chevron" }, (0, import_react31.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_react31.useState)(null); + const [members, setMembers] = (0, import_react31.useState)([]); @@ -20034,7 +19210,40 @@ 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); @@ -20054,24 +19263,85 @@ diff --git a/client.js b/client.js + onClick + }, (0, import_react31.createElement)(BotAvatar, { bot, size: 22, badge: false, live: false }), (0, import_react31.createElement)("span", { className: "bt-ng-name" }, bot.name), on && role === "radio" ? (0, import_react31.createElement)(AdminStar) : null); + return (0, import_react31.createElement)( -+ "div", + "div", +- { ref, className: "bt-avedit", role: "dialog", "aria-label": t("Avatar editor") }, +- (0, import_react18.createElement)( + { className: "bt-set-card bt-ng" }, + (0, import_react31.createElement)( -+ "div", + "div", +- { className: "bt-avedit-top" }, +- (0, import_react18.createElement)( + { className: "bt-set-row bt-bs-row bt-bs-stack" }, + (0, import_react31.createElement)("span", { className: "bt-set-copy" }, (0, import_react31.createElement)("span", { className: "bt-bs-label bt-ng-label" }, (0, import_react31.createElement)(AdminStar), t("Choose the group admin"))), + (0, import_react31.createElement)( -+ "div", + "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")) + { 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_react31.createElement)( -+ "div", + "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)( + { className: "bt-set-row bt-bs-row bt-bs-stack bt-ng-step" }, + (0, import_react31.createElement)("span", { className: "bt-set-copy" }, (0, import_react31.createElement)("span", { className: "bt-bs-label" }, t("Who to chat with…"))), + (0, import_react31.createElement)( -+ "div", + "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)) + { 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")) + ) @@ -20087,9 +19357,26 @@ diff --git a/client.js b/client.js + { className: "bt-set-row bt-task-foot" }, + (0, import_react31.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: onCancel }, t("Cancel")), + (0, import_react31.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} @@ -20229,17 +19516,33 @@ 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_react32.createElement)( -+ "div", + "div", +- { className: "bt-bs" }, +- (0, import_react18.createElement)( + { className: "bt-palette-layer", "data-leaving": leaving || void 0 }, + (0, import_react32.createElement)("div", { className: "bt-backdrop", onMouseDown: close }), + (0, import_react32.createElement)( -+ "div", + "div", +- { className: "bt-bs-hero" }, +- (0, import_react18.createElement)( + { className: "bt-palette", role: "dialog", "aria-label": t("Search"), "aria-modal": true }, + (0, import_react32.createElement)( -+ "div", + "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 + { className: "bt-palette-head" }, + (0, import_react32.createElement)("span", { className: "bt-palette-glyph", "aria-hidden": true }, (0, import_react32.createElement)(SearchIcon)), + (0, import_react32.createElement)("input", { @@ -20272,9 +19575,94 @@ diff --git a/client.js b/client.js + setQuery(""); + setCursor(0); + } }, (0, import_react32.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_react32.createElement)( -+ "div", + "div", +- { className: "bt-set-row bt-bs-row" }, +- (0, import_react18.createElement)( +- "span", +- { className: "bt-set-copy" }, +- (0, import_react18.createElement)("span", { className: "bt-bs-label" }, t("Main Bot")), +- (0, import_react18.createElement)("span", { className: "bt-set-hint" }, t("The only Main Bot. To hand the role to another Bot, use Main Bot on the Bots page.")) +- ), +- (0, import_react18.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => actions.openSettings("bots") }, t("Change")) +- ) : (0, import_react18.createElement)(SwitchRow, { label: t("Main Bot"), hint: t("Can create and change Bots, and runs every group chat."), on: isMainBot, onChange: (on) => run(actions.setMain(bot.id, on)) }) +- ), +- // The Main Bot tile is always first and always shown, like in the context menu. +- isTile ? null : (0, import_react18.createElement)( +- Card, +- { title: t("Sidebar") }, +- (0, import_react18.createElement)(SwitchRow, { label: t("Pin"), hint: t("Kept above the other chats."), on: bot.pinned === true, onChange: (pinned) => run(actions.setFlags(bot.id, { pinned })) }), +- isMainBot ? null : (0, import_react18.createElement)(SwitchRow, { label: t("Hide from sidebar"), hint: t("Search still finds it."), on: bot.hidden === true, onChange: (hidden) => run(actions.setFlags(bot.id, { hidden })) }) +- ), +- (0, import_react18.createElement)( +- Card, +- { title: t("Manage") }, +- (0, import_react18.createElement)( +- "button", +- { type: "button", className: "bt-set-row bt-bs-row", onClick: duplicate }, +- (0, import_react18.createElement)("span", { className: "bt-set-copy" }, (0, import_react18.createElement)("span", { className: "bt-bs-label" }, t("Duplicate")), (0, import_react18.createElement)("span", { className: "bt-set-hint" }, t("A copy with the same job and model, and fresh memory."))), +- (0, import_react18.createElement)("span", { className: "bt-chevron" }, (0, import_react18.createElement)(ChevronRightIcon)) +- ), +- 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)) + { ref: listRef, id: "bt-palette-list", className: "bt-palette-list", role: "listbox", "aria-label": t("Results") }, + (0, import_react32.createElement)("span", { ref: glide.thumb, className: "bt-thumb", "aria-hidden": true }), + rows.length === 0 ? (0, import_react32.createElement)( @@ -21573,49 +20961,6 @@ diff --git a/client.js b/client.js - { className: "bt-bs-hero" }, - (0, import_react23.createElement)(RoomAvatar, { room: room2, roster, size: 64 }), - (0, import_react23.createElement)( -- "div", -- { className: "bt-bs-who" }, -- (0, import_react23.createElement)("span", { className: "bt-bs-name" }, room2.name), -- (0, import_react23.createElement)("span", { className: "bt-bs-sub" }, roomSubtitle(room2, roster)) -- ) -- ), -- (0, import_react23.createElement)( -- Card, -- { title: t("Profile") }, -- (0, import_react23.createElement)( -- Row, -- { label: t("Name"), hint: t("Leave it empty to name the group after its members.") }, -- (0, import_react23.createElement)(InlineText, { label: t("Group name"), value: room2.named ? room2.name : "", placeholder: t("Name after members"), maxLength: 60, onSave: (name) => update({ name }) }) -- ), -- (0, import_react23.createElement)( -- Row, -- { label: t("Notice"), hint: t("Every member reads it before speaking in this group."), stack: true }, -- (0, import_react23.createElement)(InlineText, { label: t("Group notice"), value: room2.notice, multiline: true, maxLength: 2e3, placeholder: t("Shared instructions every member reads before speaking…"), onSave: (notice) => update({ notice }) }) -- ) -- ), -- (0, import_react23.createElement)( -- Card, -- { title: t("Who answers") }, -- (0, import_react23.createElement)( -- "div", -- { className: "bt-set-row bt-bs-row bt-bs-stack" }, -- (0, import_react23.createElement)(Segmented, { label: t("Who answers"), value: mode }, ROOM_MODES.map(([id, label]) => (0, import_react23.createElement)("button", { -- key: id, -- type: "button", -- "aria-pressed": mode === id, -- disabled: id === "admin" && !admin, -- title: id === "admin" && !admin ? t("Choose an admin first") : void 0, -- onClick: () => { -- if (mode !== id) void update({ mode: id }); -- } -- }, t(label)))), -- (0, import_react23.createElement)("span", { key: mode, className: "bt-set-hint bt-veil" }, `${t(modeHint)} ${t("@mentions always pick who answers.")}`) -- ) -- ), -- (0, import_react23.createElement)( -- Card, -- { title: t("Working folder") }, -- (0, import_react23.createElement)( + { className: "bt-wf-palette" }, + (0, import_react37.createElement)("span", { className: "bt-wf-hint" }, t("Drag a Bot here")), + locals.map((bot) => (0, import_react37.createElement)("button", { @@ -21652,27 +20997,12 @@ diff --git a/client.js b/client.js + { className: "bt-wf-stage", style: pad2 > 0 ? { height: drawnHeight, paddingLeft: pad2 } : { height: drawnHeight } }, + (0, import_react37.createElement)( "div", -- { className: "bt-set-row bt-bs-row" }, -- (0, import_react23.createElement)("span", { className: "bt-set-copy" }, (0, import_react23.createElement)("span", { className: "bt-bs-label" }, t("Folder"))), -- (0, import_react23.createElement)(FolderMenu, { value: room2.workDir, group: true, actions, run, onPick: (workDir) => update({ workDir }) }) -- ) -- ), -- (0, import_react23.createElement)( -- Card, -- { title: t("Members · {count}", { count: members.length }) }, -- members.map((member) => { -- const leads = room2.admin === member.id; -- return (0, import_react23.createElement)( +- { className: "bt-bs-who" }, +- (0, import_react23.createElement)("span", { className: "bt-bs-name" }, room2.name), +- (0, import_react23.createElement)("span", { className: "bt-bs-sub" }, roomSubtitle(room2, roster)) + { className: "bt-wf-fit", style: { height, transform: `scale(${scale})` } }, + (0, import_react37.createElement)( - "div", -- { key: member.id, className: "bt-set-row bt-bs-row bt-gs-member" }, -- (0, import_react23.createElement)( -- "button", -- { type: "button", className: "bt-gs-who", title: t("Open {name}'s chat", { name: member.name }), onClick: () => openBot(member.id) }, -- (0, import_react23.createElement)(BotAvatar, { bot: member, size: 26, main: isMainOf(roster, member.id), admin: leads, live: false }), -- (0, import_react23.createElement)("span", { className: "bt-row-title" }, member.name), -- leads ? (0, import_react23.createElement)("span", { className: "bt-tag" }, t("Admin")) : null ++ "div", + { className: "bt-wf-plane", "data-plane": "1", style: { height, "--bt-wf-wire-span": `${WIRE_SPAN}px` } }, + // P-395: every wire says which two cards it joins, and carries a 10px transparent + // hit path of its own: two wires that end at the same port are one stroke to the @@ -21736,50 +21066,7 @@ diff --git a/client.js b/client.js + }, (0, import_react37.createElement)("title", null, label)) + ]; + })) - ), -- leads ? (0, import_react23.createElement)("button", { type: "button", className: "bt-mini", title: t("Leave the group without an admin"), onClick: () => update({ admin: null }) }, t("Unset")) : (0, import_react23.createElement)("button", { type: "button", className: "bt-mini", onClick: () => update({ admin: member.id }) }, t("Make admin")), -- members.length > 2 ? (0, import_react23.createElement)("button", { type: "button", className: "bt-x", "aria-label": t("Remove {name} from the group", { name: member.name }), title: t("Remove from group"), onClick: () => update({ remove: [member.id] }) }, "×") : null -- ); -- }), -- adding ? [ -- ...outside.map((bot) => (0, import_react23.createElement)( -- "button", -- { key: bot.id, type: "button", className: "bt-set-row bt-bs-row bt-gs-pick", onClick: () => { -- setAdding(false); -- void update({ add: [bot.id] }); -- } }, -- (0, import_react23.createElement)(BotMark, { bot, size: 22 }), -- (0, import_react23.createElement)("span", { className: "bt-set-copy" }, (0, import_react23.createElement)("span", { className: "bt-bs-label" }, bot.name)), -- (0, import_react23.createElement)("span", { className: "bt-set-hint" }, t("Add")) -- )), -- (0, import_react23.createElement)("button", { key: "cancel", type: "button", className: "bt-set-row bt-bs-row bt-gs-add", onClick: () => setAdding(false) }, t("Cancel")) -- ] : outside.length > 0 ? (0, import_react23.createElement)("button", { type: "button", className: "bt-set-row bt-bs-row bt-gs-add", onClick: () => setAdding(true) }, (0, import_react23.createElement)(PlusIcon), t("Add member")) : null, -- (0, import_react23.createElement)("div", { className: "bt-set-row bt-gs-note" }, (0, import_react23.createElement)( -- "span", -- { className: "bt-set-hint" }, -- // The host checks for two members on every change, even a rename. -- members.length < 2 ? t("Add a Bot first: a group chat needs at least two.") : admin ? t("{name} leads this group: it can call members with @Name, change the group, and post here.", { name: admin.name }) : t("No admin yet. An admin can call members with @Name, change the group, and post here.") -- )) -- ), -- (0, import_react23.createElement)( -- Card, -- { title: t("Sidebar") }, -- (0, import_react23.createElement)(SwitchRow, { label: t("Pin"), hint: t("Kept above the other chats."), on: room2.pinned === true, onChange: (pinned) => run(actions.setFlags(room2.id, { pinned })) }), -- (0, import_react23.createElement)(SwitchRow, { label: t("Hide from sidebar"), hint: t("Search still finds it."), on: room2.hidden === true, onChange: (hidden) => run(actions.setFlags(room2.id, { hidden })) }) -- ), -- (0, import_react23.createElement)( -- Card, -- { title: t("Manage") }, -- (0, import_react23.createElement)( -- "button", -- { type: "button", className: "bt-set-row bt-bs-row bt-bs-danger", "data-confirm": confirming || void 0, onClick: remove }, -- (0, import_react23.createElement)( -- "span", -- { className: "bt-set-copy" }, -- (0, import_react23.createElement)("span", { className: "bt-bs-label" }, confirming ? t("Delete {name}?", { name: room2.name }) : t("Delete group")), -- (0, import_react23.createElement)("span", { className: "bt-set-hint" }, confirming ? t("Click again to delete. Its history is archived.") : t("Removes the group chat. Its members stay on the team.")) -- ), -- (0, import_react23.createElement)("span", { className: "bt-chevron" }, (0, import_react23.createElement)(ChevronRightIcon)) ++ ), + frames.map((frame) => (0, import_react37.createElement)("div", { + key: frame.key, + className: "bt-wf-par", @@ -21834,7 +21121,7 @@ diff --git a/client.js b/client.js + ); + }) + ) -+ ) + ) + ) + ); +} @@ -21853,7 +21140,14 @@ diff --git a/client.js b/client.js + { className: "bt-set-copy" }, + (0, import_react37.createElement)("span", null, item.name), + (0, import_react37.createElement)("span", { className: "bt-set-hint" }, item.preview || chainOf(item) || item.description || "") -+ ), + ), +- (0, import_react23.createElement)( +- Card, +- { title: t("Profile") }, +- (0, import_react23.createElement)( +- Row, +- { label: t("Name"), hint: t("Leave it empty to name the group after its members.") }, +- (0, import_react23.createElement)(InlineText, { label: t("Group name"), value: room2.named ? room2.name : "", placeholder: t("Name after members"), maxLength: 60, onSave: (name) => update({ name }) }) + source === "own" && editable ? (0, import_react37.createElement)( + "span", + { className: "bt-set-actions" }, @@ -21922,14 +21216,106 @@ diff --git a/client.js b/client.js + return (0, import_react37.createElement)(Row2, { key: item.id, item: row, source: "factory", onOpen: () => onOpen?.(item, "factory") }); + }) + ) -+ ), + ), +- (0, import_react23.createElement)( +- Row, +- { label: t("Notice"), hint: t("Every member reads it before speaking in this group."), stack: true }, +- (0, import_react23.createElement)(InlineText, { label: t("Group notice"), value: room2.notice, multiline: true, maxLength: 2e3, placeholder: t("Shared instructions every member reads before speaking…"), onSave: (notice) => update({ notice }) }) +- ) +- ), +- (0, import_react23.createElement)( +- Card, +- { title: t("Who answers") }, +- (0, import_react23.createElement)( +- "div", +- { className: "bt-set-row bt-bs-row bt-bs-stack" }, +- (0, import_react23.createElement)(Segmented, { label: t("Who answers"), value: mode }, ROOM_MODES.map(([id, label]) => (0, import_react23.createElement)("button", { +- key: id, +- type: "button", +- "aria-pressed": mode === id, +- disabled: id === "admin" && !admin, +- title: id === "admin" && !admin ? t("Choose an admin first") : void 0, +- onClick: () => { +- if (mode !== id) void update({ mode: id }); +- } +- }, t(label)))), +- (0, import_react23.createElement)("span", { key: mode, className: "bt-set-hint bt-veil" }, `${t(modeHint)} ${t("@mentions always pick who answers.")}`) +- ) +- ), +- (0, import_react23.createElement)( +- Card, +- { title: t("Working folder") }, +- (0, import_react23.createElement)( +- "div", +- { className: "bt-set-row bt-bs-row" }, +- (0, import_react23.createElement)("span", { className: "bt-set-copy" }, (0, import_react23.createElement)("span", { className: "bt-bs-label" }, t("Folder"))), +- (0, import_react23.createElement)(FolderMenu, { value: room2.workDir, group: true, actions, run, onPick: (workDir) => update({ workDir }) }) +- ) +- ), +- (0, import_react23.createElement)( +- Card, +- { title: t("Members · {count}", { count: members.length }) }, +- members.map((member) => { +- const leads = room2.admin === member.id; +- return (0, import_react23.createElement)( + (0, import_react37.createElement)( + "section", + { className: "bt-set-section" }, + (0, import_react37.createElement)("h3", null, t("Yours")), + (0, import_react37.createElement)("p", { className: "bt-wf-hint" }, t("Workflows saved on this machine.")), + (0, import_react37.createElement)( -+ "div", + "div", +- { key: member.id, className: "bt-set-row bt-bs-row bt-gs-member" }, +- (0, import_react23.createElement)( +- "button", +- { type: "button", className: "bt-gs-who", title: t("Open {name}'s chat", { name: member.name }), onClick: () => openBot(member.id) }, +- (0, import_react23.createElement)(BotAvatar, { bot: member, size: 26, main: isMainOf(roster, member.id), admin: leads, live: false }), +- (0, import_react23.createElement)("span", { className: "bt-row-title" }, member.name), +- leads ? (0, import_react23.createElement)("span", { className: "bt-tag" }, t("Admin")) : null +- ), +- leads ? (0, import_react23.createElement)("button", { type: "button", className: "bt-mini", title: t("Leave the group without an admin"), onClick: () => update({ admin: null }) }, t("Unset")) : (0, import_react23.createElement)("button", { type: "button", className: "bt-mini", onClick: () => update({ admin: member.id }) }, t("Make admin")), +- members.length > 2 ? (0, import_react23.createElement)("button", { type: "button", className: "bt-x", "aria-label": t("Remove {name} from the group", { name: member.name }), title: t("Remove from group"), onClick: () => update({ remove: [member.id] }) }, "×") : null +- ); +- }), +- adding ? [ +- ...outside.map((bot) => (0, import_react23.createElement)( +- "button", +- { key: bot.id, type: "button", className: "bt-set-row bt-bs-row bt-gs-pick", onClick: () => { +- setAdding(false); +- void update({ add: [bot.id] }); +- } }, +- (0, import_react23.createElement)(BotMark, { bot, size: 22 }), +- (0, import_react23.createElement)("span", { className: "bt-set-copy" }, (0, import_react23.createElement)("span", { className: "bt-bs-label" }, bot.name)), +- (0, import_react23.createElement)("span", { className: "bt-set-hint" }, t("Add")) +- )), +- (0, import_react23.createElement)("button", { key: "cancel", type: "button", className: "bt-set-row bt-bs-row bt-gs-add", onClick: () => setAdding(false) }, t("Cancel")) +- ] : outside.length > 0 ? (0, import_react23.createElement)("button", { type: "button", className: "bt-set-row bt-bs-row bt-gs-add", onClick: () => setAdding(true) }, (0, import_react23.createElement)(PlusIcon), t("Add member")) : null, +- (0, import_react23.createElement)("div", { className: "bt-set-row bt-gs-note" }, (0, import_react23.createElement)( +- "span", +- { className: "bt-set-hint" }, +- // The host checks for two members on every change, even a rename. +- members.length < 2 ? t("Add a Bot first: a group chat needs at least two.") : admin ? t("{name} leads this group: it can call members with @Name, change the group, and post here.", { name: admin.name }) : t("No admin yet. An admin can call members with @Name, change the group, and post here.") +- )) +- ), +- (0, import_react23.createElement)( +- Card, +- { title: t("Sidebar") }, +- (0, import_react23.createElement)(SwitchRow, { label: t("Pin"), hint: t("Kept above the other chats."), on: room2.pinned === true, onChange: (pinned) => run(actions.setFlags(room2.id, { pinned })) }), +- (0, import_react23.createElement)(SwitchRow, { label: t("Hide from sidebar"), hint: t("Search still finds it."), on: room2.hidden === true, onChange: (hidden) => run(actions.setFlags(room2.id, { hidden })) }) +- ), +- (0, import_react23.createElement)( +- Card, +- { title: t("Manage") }, +- (0, import_react23.createElement)( +- "button", +- { type: "button", className: "bt-set-row bt-bs-row bt-bs-danger", "data-confirm": confirming || void 0, onClick: remove }, +- (0, import_react23.createElement)( +- "span", +- { className: "bt-set-copy" }, +- (0, import_react23.createElement)("span", { className: "bt-bs-label" }, confirming ? t("Delete {name}?", { name: room2.name }) : t("Delete group")), +- (0, import_react23.createElement)("span", { className: "bt-set-hint" }, confirming ? t("Click again to delete. Its history is archived.") : t("Removes the group chat. Its members stay on the team.")) +- ), +- (0, import_react23.createElement)("span", { className: "bt-chevron" }, (0, import_react23.createElement)(ChevronRightIcon)) + { className: "bt-set-card" }, + yours.length === 0 ? (0, import_react37.createElement)("div", { className: "bt-set-row" }, (0, import_react37.createElement)("span", { className: "bt-set-hint" }, t("No workflows of your own yet."))) : yours.map((item) => (0, import_react37.createElement)(Row2, { key: item.id, item, source: "own", editable: !locked, onOpen: () => onOpen?.(item, "own"), onRename, onDelete })) + ) @@ -23285,15 +22671,18 @@ diff --git a/client.js b/client.js + } }, t("Choose apps")) : null, + on && perBot ? (0, import_react39.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", "aria-expanded": bots, onClick: () => setBots((value) => !value) }, bots ? t("Done") : t("Choose Bots")) : null, + on ? (0, import_react39.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy, onClick: () => void run(actions.connectorDisconnect(connector.id)) }, t("Disconnect")) : form ? null : (0, import_react39.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => setForm(true) }, t("Connect")) -+ ) -+ ), + ) + ), +- !on && form ? (0, import_react25.createElement)( + on && gateway && !picking ? (0, import_react39.createElement)(AppStatusList, { apps: connector.apps ?? [], statuses }) : null, + on && perBot ? (0, import_react39.createElement)("span", { className: "bt-connector-uses" }, usageText(roster, connector.id)) : null, + on && bots ? (0, import_react39.createElement)(ConnectorBots, { connector, roster, actions, run, SwitchRow }) : null, + on && channel && connector.mode === "app" ? (0, import_react39.createElement)(AllowedSenders, { connector, actions, run, busy }) : null, + on && canPost ? (0, import_react39.createElement)( -+ "div", -+ { className: "bt-connector-form" }, + "div", + { className: "bt-connector-form" }, +- (0, import_react25.createElement)("label", { className: "bt-q-field bt-secret-field" }, (0, import_react25.createElement)(MaskedInput, { value: token, onChange: setToken, onEnter: connect, label: t("GitHub token"), autoFocus: true })), +- (0, import_react25.createElement)( + (0, import_react39.createElement)("label", { className: "bt-q-field" }, (0, import_react39.createElement)("input", { className: "bt-input", type: "text", value: message, "aria-label": t("Message to send to Feishu"), placeholder: t("Type a message for the Feishu group"), onChange: (event) => setMessage(event.target.value) })), + (0, import_react39.createElement)( + "div", @@ -23326,38 +22715,32 @@ diff --git a/client.js b/client.js + })) : (0, import_react39.createElement)("label", { className: "bt-q-field bt-secret-field" }, (0, import_react39.createElement)(MaskedInput, { value: token, onChange: setToken, onEnter: connect, label: gateway ? t("Composio API key") : t("GitHub token"), autoFocus: true })), + gateway ? (0, import_react39.createElement)(AppPicker, { apps, onChange: setApps, search: token.trim() === "" ? void 0 : search }) : null, + (0, import_react39.createElement)( -+ "div", -+ { className: "bt-connector-form-row" }, + "div", + { className: "bt-connector-form-row" }, +- (0, import_react25.createElement)("a", { className: "bt-connector-link", href: TOKEN_URL, target: "_blank", rel: "noopener noreferrer" }, t("Create a token on GitHub")), +- (0, import_react25.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy, onClick: () => { + (0, import_react39.createElement)( + "a", + { className: "bt-connector-link", href: channel ? chosen?.link ?? FEISHU_WEBHOOK_URL : gateway ? COMPOSIO_KEY_URL : TOKEN_URL, target: "_blank", rel: "noopener noreferrer" }, + channel ? t(chosen?.linkLabel ?? "Get a webhook URL in Feishu") : gateway ? t("Get a Composio API key") : t("Create a token on GitHub") + ), + (0, import_react39.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy, onClick: () => { -+ setForm(false); -+ setToken(""); + setForm(false); + setToken(""); + setValues({}); + setError(null); + } }, t("Cancel")), + (0, import_react39.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy || (channel ? !filled : token.trim() === ""), onClick: connect }, busy ? t("Connecting…") : t("Connect")) - ) -- ), -- !on && form ? (0, import_react25.createElement)( ++ ) + ) : null, + on && gateway && picking ? (0, import_react39.createElement)( - "div", - { className: "bt-connector-form" }, -- (0, import_react25.createElement)("label", { className: "bt-q-field bt-secret-field" }, (0, import_react25.createElement)(MaskedInput, { value: token, onChange: setToken, onEnter: connect, label: t("GitHub token"), autoFocus: true })), -- (0, import_react25.createElement)( ++ "div", ++ { className: "bt-connector-form" }, + (0, import_react39.createElement)(AppPicker, { apps, onChange: setApps, search, statuses }), + (0, import_react39.createElement)("span", { className: "bt-set-hint" }, t("The first time a Bot uses an app, it shows a sign-in link in the chat.")), + (0, import_react39.createElement)( - "div", - { className: "bt-connector-form-row" }, -- (0, import_react25.createElement)("a", { className: "bt-connector-link", href: TOKEN_URL, target: "_blank", rel: "noopener noreferrer" }, t("Create a token on GitHub")), -- (0, import_react25.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy, onClick: () => { -- setForm(false); -- setToken(""); ++ "div", ++ { className: "bt-connector-form-row" }, + (0, import_react39.createElement)("span", { className: "bt-connector-link" }), + (0, import_react39.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy, onClick: () => { + setPicking(false); @@ -24138,52 +23521,33 @@ diff --git a/client.js b/client.js + // DeepSeek Harness's own settings are a separate window with its own title and look; FlyBot + // does not embed or retitle it (never patch DSH), so the entry says it leaves (#421). + (0, import_react42.createElement)("button", { - type: "button", - className: "bt-settings-item bt-settings-shell", -- title: t("Models, providers, and the rest of DeepSeek Harness"), ++ type: "button", ++ className: "bt-settings-item bt-settings-shell", + title: t("Opens DeepSeek Harness's own settings (models, providers, permissions) in a separate window"), + "aria-label": t("Harness settings, opens DeepSeek Harness's own settings window"), - onClick: () => { - close(); - openHarnessSettings(); - } -- }, (0, import_react28.createElement)(SlidersIcon), t("Harness settings")) ++ onClick: () => { ++ close(); ++ openHarnessSettings(); ++ } + }, (0, import_react42.createElement)(SlidersIcon), t("Harness settings"), (0, import_react42.createElement)("span", { className: "bt-settings-out", "aria-hidden": true }, "↗")) - ), -- (0, import_react28.createElement)( ++ ), + (0, import_react42.createElement)( - "div", - { className: "bt-settings-main" }, -- (0, import_react28.createElement)( ++ "div", ++ { className: "bt-settings-main" }, + (0, import_react42.createElement)( - "div", - { className: "bt-settings-head" }, -- back ? (0, import_react28.createElement)("button", { type: "button", className: "bt-icon-btn", "aria-label": back, title: back, onClick: () => actions.openSettings(shown) }, (0, import_react28.createElement)(ChevronLeftIcon)) : null, -- (0, import_react28.createElement)("h2", { key: bot?.id ?? room2?.id ?? shown }, bot?.name ?? room2?.name ?? label) ++ "div", ++ { className: "bt-settings-head" }, + back ? (0, import_react42.createElement)("button", { type: "button", className: "bt-icon-btn", "aria-label": back, title: back, onClick: () => actions.openSettings(shown) }, (0, import_react42.createElement)(ChevronLeftIcon)) : null, + (0, import_react42.createElement)("h2", { key: bot?.id ?? room2?.id ?? shown }, bot?.name ?? room2?.name ?? label) - ), -- shown === "usage" ? null : (0, import_react28.createElement)( ++ ), + // A window that holds the team read-only still opens every page here, so the header says + // why nothing can be changed, in the host's own sentence and above the refusal below it: + // the standing state first, the answer to one pressed control closest to that control. + typeof roster?.readOnly === "string" && roster.readOnly.trim() ? (0, import_react42.createElement)("div", { className: "bt-hint bt-read-only", role: "status", style: { padding: "0 20px 8px", textAlign: "left" } }, hostText(roster.readOnly)) : null, + shown === "usage" ? null : errorLine, + shown === "usage" ? null : (0, import_react42.createElement)( - "div", - { key: pageKey, className: "bt-settings-scroll bt-settings-page", "data-move": last.current.move }, -- errorLine, -- shown === "general" ? (0, import_react28.createElement)(GeneralPage, { roster, actions, run }) : null, -- bot ? (0, import_react28.createElement)("div", { className: "bt-settings-form" }, (0, import_react28.createElement)(BotSettingsForm, { key: bot.id, bot, roster, actions, run })) : null, -- shown === "bots" && !bot ? (0, import_react28.createElement)(BotsPage, { roster, actions, run }) : null, -- room2 ? (0, import_react28.createElement)("div", { className: "bt-settings-form" }, (0, import_react28.createElement)(GroupSettingsForm, { key: room2.id, room: room2, roster, actions, run })) : null, -- shown === "groups" && !room2 ? (0, import_react28.createElement)(GroupsPage, { roster, actions, run }) : null, -- shown === "connectors" ? (0, import_react28.createElement)(ConnectorsPage, { roster, actions }) : null, -- shown === "capabilities" ? (0, import_react28.createElement)(CapabilitiesPage, { roster, actions, run }) : null, -- shown === "schedules" ? (0, import_react28.createElement)(SchedulesPage, { roster, actions, start: id ?? void 0 }) : null, -- shown === "secrets" ? (0, import_react28.createElement)(SecretsPage, { roster, actions, run, Section: SetSection }) : null, -- shown === "feedback" ? (0, import_react28.createElement)(FeedbackPage, { actions, run, Section: SetSection, Row: SetRow }) : null, -- shown === "about" ? (0, import_react28.createElement)(AboutPage, { useUpdate, actions, run, Section: SetSection, Row: SetRow }) : null ++ "div", ++ { key: pageKey, className: "bt-settings-scroll bt-settings-page", "data-move": last.current.move }, + shown === "general" ? (0, import_react42.createElement)(GeneralPage, { roster, actions, run }) : null, + bot ? (0, import_react42.createElement)("div", { className: "bt-settings-form" }, (0, import_react42.createElement)(BotSettingsForm, { key: bot.id, bot, roster, actions, run })) : null, + shown === "bots" && !bot ? (0, import_react42.createElement)(BotsPage, { roster, actions, run }) : null, @@ -24198,21 +23562,18 @@ diff --git a/client.js b/client.js + shown === "secrets" ? (0, import_react42.createElement)(SecretsPage, { roster, actions, run, Section: SetSection }) : null, + shown === "feedback" ? (0, import_react42.createElement)(FeedbackPage, { roster, actions, run, Section: SetSection, Row: SetRow }) : null, + shown === "about" ? (0, import_react42.createElement)(AboutPage, { useUpdate, actions, run, Section: SetSection, Row: SetRow }) : null - ), -- usageOpened ? (0, import_react28.createElement)( ++ ), + usageOpened ? (0, import_react42.createElement)( - "div", - { key: "usage", className: "bt-settings-scroll bt-settings-usage", "data-off": shown === "usage" ? void 0 : "", "aria-hidden": shown === "usage" ? void 0 : true }, - shown === "usage" ? errorLine : null, -- (0, import_react28.createElement)(UsagePage, { roster, actions, focusId: usageFocus }) ++ "div", ++ { key: "usage", className: "bt-settings-scroll bt-settings-usage", "data-off": shown === "usage" ? void 0 : "", "aria-hidden": shown === "usage" ? void 0 : true }, ++ shown === "usage" ? errorLine : null, + (0, import_react42.createElement)(UsagePage, { roster, actions, focusId: usageFocus }) - ) : null - ), -- (0, import_react28.createElement)("button", { type: "button", className: "bt-settings-close", "aria-label": t("Close settings"), onClick: close }, (0, import_react28.createElement)(CloseIcon)) ++ ) : null ++ ), + (0, import_react42.createElement)("button", { type: "button", className: "bt-settings-close", "aria-label": t("Close settings"), onClick: close }, (0, import_react42.createElement)(CloseIcon)) - ) - ); - } ++ ) ++ ); ++} +var openProjectBoard = (id) => { + if (typeof window !== "undefined") window.location.hash = `#project=${id}`; +}; @@ -24339,12 +23700,53 @@ diff --git a/client.js b/client.js + "div", + { className: "bt-actions" }, + (0, import_react42.createElement)("button", { -+ type: "button", + type: "button", +- className: "bt-settings-item bt-settings-shell", +- title: t("Models, providers, and the rest of DeepSeek Harness"), + className: "bt-primary bt-with-icon", + disabled: name.trim() === "", -+ onClick: () => { + onClick: () => { +- close(); +- openHarnessSettings(); + void create(); -+ } + } +- }, (0, import_react28.createElement)(SlidersIcon), t("Harness settings")) +- ), +- (0, import_react28.createElement)( +- "div", +- { className: "bt-settings-main" }, +- (0, import_react28.createElement)( +- "div", +- { className: "bt-settings-head" }, +- back ? (0, import_react28.createElement)("button", { type: "button", className: "bt-icon-btn", "aria-label": back, title: back, onClick: () => actions.openSettings(shown) }, (0, import_react28.createElement)(ChevronLeftIcon)) : null, +- (0, import_react28.createElement)("h2", { key: bot?.id ?? room2?.id ?? shown }, bot?.name ?? room2?.name ?? label) +- ), +- shown === "usage" ? null : (0, import_react28.createElement)( +- "div", +- { key: pageKey, className: "bt-settings-scroll bt-settings-page", "data-move": last.current.move }, +- errorLine, +- shown === "general" ? (0, import_react28.createElement)(GeneralPage, { roster, actions, run }) : null, +- bot ? (0, import_react28.createElement)("div", { className: "bt-settings-form" }, (0, import_react28.createElement)(BotSettingsForm, { key: bot.id, bot, roster, actions, run })) : null, +- shown === "bots" && !bot ? (0, import_react28.createElement)(BotsPage, { roster, actions, run }) : null, +- room2 ? (0, import_react28.createElement)("div", { className: "bt-settings-form" }, (0, import_react28.createElement)(GroupSettingsForm, { key: room2.id, room: room2, roster, actions, run })) : null, +- shown === "groups" && !room2 ? (0, import_react28.createElement)(GroupsPage, { roster, actions, run }) : null, +- shown === "connectors" ? (0, import_react28.createElement)(ConnectorsPage, { roster, actions }) : null, +- shown === "capabilities" ? (0, import_react28.createElement)(CapabilitiesPage, { roster, actions, run }) : null, +- shown === "schedules" ? (0, import_react28.createElement)(SchedulesPage, { roster, actions, start: id ?? void 0 }) : null, +- shown === "secrets" ? (0, import_react28.createElement)(SecretsPage, { roster, actions, run, Section: SetSection }) : null, +- shown === "feedback" ? (0, import_react28.createElement)(FeedbackPage, { actions, run, Section: SetSection, Row: SetRow }) : null, +- shown === "about" ? (0, import_react28.createElement)(AboutPage, { useUpdate, actions, run, Section: SetSection, Row: SetRow }) : null +- ), +- usageOpened ? (0, import_react28.createElement)( +- "div", +- { key: "usage", className: "bt-settings-scroll bt-settings-usage", "data-off": shown === "usage" ? void 0 : "", "aria-hidden": shown === "usage" ? void 0 : true }, +- shown === "usage" ? errorLine : null, +- (0, import_react28.createElement)(UsagePage, { roster, actions, focusId: usageFocus }) +- ) : null +- ), +- (0, import_react28.createElement)("button", { type: "button", className: "bt-settings-close", "aria-label": t("Close settings"), onClick: close }, (0, import_react28.createElement)(CloseIcon)) +- ) +- ); + }, (0, import_react42.createElement)(PlusIcon), t("New Project")) + ) + ), @@ -24352,7 +23754,7 @@ diff --git a/client.js b/client.js + // and the page keeps one reload path for both halves. + ...ProjectsList({ projects: state.projects, roster, call: call4, reload }) + ]; -+} + } // src/client/context-menu.js -var import_react29 = require("react"); @@ -24541,10 +23943,40 @@ diff --git a/client.js b/client.js prefs: "ds-bot:prefs", surface: "ds-bot:surface", agentSessions: "ds-bot:agent-sessions", -@@ -11035,247 +11035,11 @@ function migrateStorage(storage = globalThis.localStorage) { - storage.setItem(STORAGE.prefs, JSON.stringify({ ...prefs, theme: LEGACY_THEMES[prefs.theme] })); - } - } catch { +@@ -11005,277 +11005,41 @@ var migrateBrowsers = (storage) => { + 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 { - } -} - @@ -24736,8 +24168,7 @@ diff --git a/client.js b/client.js - const entries2 = room2 ? memberWork(room2, work) : []; - const now = useNow2(entries2.some((entry) => entry.state !== "idle")); - return { entries: entries2, now }; -+ } - } +-} -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} @@ -24773,8 +24204,8 @@ 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 +- +-// src/client/details-drawer.js -var import_react31 = require("react"); -function transcriptOf(list, id, name, hiddenTurns = []) { - const outline = list.projectionsBySession?.[id]?.values?.turnOutline ?? list.byId[id]?.projectionValues?.turnOutline; @@ -24784,9 +24215,41 @@ diff --git a/client.js b/client.js - if (hiddenTurns.includes(entry.turn)) continue; - if (entry.prompt && !HEADER.test(entry.prompt)) lines.push(`**${t("You")}:** ${entry.prompt.trim()}`); - if (entry.response) lines.push(`**${name}:** ${stripHeader(entry.response).trim()}`); -- } ++ })); ++ 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 { + } - return lines.join("\n\n"); --} + } ++ ++// src/client/details-drawer.js +var import_react44 = require("react"); var TAB_LABELS = { details: "Details", browser: "Browser" }; var hostOf = (url) => { @@ -26251,9 +25714,43 @@ diff --git a/client.js b/client.js } }; } -@@ -13105,7 +13105,7 @@ function writeMode(mode, storage = globalThis.localStorage) { +@@ -13104,8 +13104,41 @@ function writeMode(mode, storage = globalThis.localStorage) { + } } ++// src/client/state-link-notice.js ++var LINK_REPLACED_NOTICE = "Saved as a separate file; the file the link pointed at was not changed."; ++var stateLinkCount = (value) => { ++ const field = value?.stateFileLinkReplaced; ++ if (!field || typeof field !== "object") return null; ++ const gen = typeof field.gen === "string" ? field.gen : ""; ++ const count = Number(field.count); ++ if (gen === "" || !Number.isFinite(count) || count < 0) return null; ++ return { gen, count: Math.floor(count) }; ++}; ++var stateLinkMemory = (seen) => { ++ if (!seen || typeof seen !== "object") return null; ++ const gen = typeof seen.gen === "string" ? seen.gen : ""; ++ if (gen === "") return null; ++ const count = Number(seen.count); ++ return { gen, count: Number.isFinite(count) && count > 0 ? Math.floor(count) : 0 }; ++}; ++var stateLinkNotice = (value, seen) => { ++ const known = stateLinkMemory(seen); ++ const answer = stateLinkCount(value); ++ if (answer === null) return { seen: known ?? { gen: "", count: 0 }, announce: false }; ++ if (known === null) return { seen: answer, announce: false }; ++ if (answer.gen !== known.gen) { ++ return answer.count > 0 ? { seen: answer, announce: true } : { seen: { gen: answer.gen, count: 0 }, announce: false }; ++ } ++ return answer.count > known.count ? { seen: answer, announce: true } : { seen: known, announce: false }; ++}; ++var announceStateLink = (value, seen, say) => { ++ const next = stateLinkNotice(value, seen); ++ if (next.announce) say?.(LINK_REPLACED_NOTICE); ++ return next.seen; ++}; ++ // src/client/user-message.js -var import_react34 = require("react"); +var import_react48 = require("react"); @@ -26615,7 +26112,7 @@ diff --git a/client.js b/client.js const work = createSource({ bots: {}, approvals: [] }); let workKey = ""; const live = createSource({}); -@@ -13344,13 +13344,26 @@ function apply(ctx) { +@@ -13344,13 +13344,27 @@ function apply(ctx) { } return { ...state, unread }; }); @@ -26635,6 +26132,7 @@ diff --git a/client.js b/client.js let pulls = 0; let applied = 0; - const pull = async () => { ++ let linkSeen = null; + let reloading = 0; + const pull = async ({ fresh = false } = {}) => { const order = ++pulls; @@ -26656,7 +26154,7 @@ diff --git a/client.js b/client.js const nextWork = JSON.stringify(value.work ?? null); if (nextWork !== workKey) { workKey = nextWork; -@@ -13368,10 +13368,15 @@ function apply(ctx) { +@@ -13368,10 +13368,16 @@ function apply(ctx) { 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; @@ -26666,6 +26164,7 @@ diff --git a/client.js b/client.js + } if (Object.keys(live2).length > 0 && current.ready) roster.set({ ...current, ...live2 }); - if (value.unchanged || value.revision === current.revision && value.modelsKey === current.modelsKey) return; ++ linkSeen = announceStateLink(value, linkSeen, (text2) => actions.notify(t(text2))); + if (stateAnswerIsStale(value, current, { fresh })) return; if (Object.keys(seenAt).length === 0) leaveVisits([...value.bots, ...value.rooms].map((entry) => entry.id)); roster.set(indexRoster(value)); diff --git a/patches/ds-bot/0014-flybot-branding.patch b/patches/ds-bot/0014-flybot-branding.patch index f0496c965..775a5ea43 100644 --- a/patches/ds-bot/0014-flybot-branding.patch +++ b/patches/ds-bot/0014-flybot-branding.patch @@ -2154,6 +2154,82 @@ diff --git a/test/store.test.js b/test/store.test.js autoRoomName: () => 'Room', retitleRoom: async () => {}, warn: (...args) => warnings.push(args), +@@ -61,6 +61,75 @@ function runtime(t, saved) { + return { rt, home, statePath, warnings, dispose } + } + ++// P-556 (Chief's ruling: no write-through): the store never writes through a link. The rename ++// replaces the name, link included, so the target keeps every byte and its last write time, and the ++// path a person opened is a regular file of this home's own from that save on. ++test('a save over a linked state.json replaces the link and leaves its target alone', async (t) => { ++ const elsewhere = fs.mkdtempSync(join(tmpdir(), 'ds-bot-store-target-')) ++ t.after(() => fs.rmSync(elsewhere, { recursive: true, force: true, maxRetries: 5 })) ++ const target = join(elsewhere, 'shared-team.json') ++ ++ // A team file this host wrote itself: a hand-written `state.json` is refused at load (member ++ // records missing), and a store in `stuck` never saves, so the link would survive untouched. ++ const { rt, statePath } = runtime(t) ++ await rt.load() ++ await rt.save() ++ fs.renameSync(statePath, target) ++ const kept = fs.readFileSync(target) ++ const before = fs.lstatSync(target) ++ fs.symlinkSync(target, statePath) ++ await rt.save() ++ ++ assert.equal(fs.lstatSync(statePath).isSymbolicLink(), false, 'the save replaced the link with a regular file') ++ const after = fs.lstatSync(target) ++ assert.deepEqual(fs.readFileSync(target), kept, 'not one byte was written through the link') ++ assert.equal(after.mtimeMs, before.mtimeMs, 'the target was not touched') ++ assert.equal(fs.existsSync(`${statePath}.${process.pid}.tmp`), false, 'no temp name is left behind') ++ assert.match(fs.readFileSync(statePath, 'utf8'), /"version": 2/, 'the regular file is the team the save wrote') ++}) ++ ++// P-556: the window can only say "saved as a file of its own, the link's target unchanged" if the ++// host counts that save, so the count is the state face's token: it starts at 0, goes up by one for ++// the save that replaced a link, and stays put for the saves over the regular file that followed. ++// The count is answered with the generation of the process that made it (Chief, on review): a ++// restarted host starts counting at 0 again, and a bare 0 would read to a window as an older, smaller ++// count, so the save that came with the restart would never be said. ++test('a save that replaced a link is counted once, with the generation it belongs to', async (t) => { ++ const elsewhere = fs.mkdtempSync(join(tmpdir(), 'ds-bot-store-target-')) ++ t.after(() => fs.rmSync(elsewhere, { recursive: true, force: true, maxRetries: 5 })) ++ const target = join(elsewhere, 'shared-team.json') ++ fs.writeFileSync(target, '{"version":2,"bots":{}}\n') ++ ++ const home = fs.mkdtempSync(join(tmpdir(), 'ds-bot-store-')) ++ const statePath = join(home, 'state.json') ++ const first = runtime(t, undefined, { home }) ++ await first.rt.load() ++ await first.rt.save() ++ const start = first.rt.stateLinkReplaced() ++ assert.equal(start.count, 0, 'a save that replaced no link counts nothing') ++ assert.equal(typeof start.gen, 'string', 'the count names the process that made it') ++ assert.ok(start.gen.length >= 8, 'the generation is a stamp of this process, not an empty string') ++ ++ fs.renameSync(statePath, target) ++ fs.rmSync(target, { force: true }) ++ fs.writeFileSync(target, '{"version":2,"bots":{}}\n') ++ fs.symlinkSync(target, statePath) ++ await first.rt.save() ++ const counted = first.rt.stateLinkReplaced() ++ assert.equal(counted.count, 1, 'the save that replaced a link is counted') ++ assert.equal(counted.gen, start.gen, 'the same process keeps the generation it started with') ++ await first.rt.save() ++ assert.equal(first.rt.stateLinkReplaced().count, 1, 'a later save over the regular file is not counted again') ++ ++ // Another install is another process: its count starts over at 0 under a generation of its own, ++ // which is what lets a window tell "this host has replaced nothing yet" from "an older count". ++ const other = runtime(t) ++ await other.rt.load() ++ const restarted = other.rt.stateLinkReplaced() ++ assert.equal(restarted.count, 0) ++ assert.notEqual(restarted.gen, start.gen, 'each host process answers its own generation') ++}) ++ + test('a failed state write rejects and leaves the saved file unchanged', async (t) => { + const { rt, statePath } = runtime(t) + await rt.load() @@ -68,7 +68,7 @@ test('a failed state write rejects and leaves the saved file unchanged', async ( const original = fs.readFileSync(statePath) fs.mkdirSync(`${statePath}.${process.pid}.tmp`) diff --git a/patches/ds-bot/files/src/client/state-link-notice.js b/patches/ds-bot/files/src/client/state-link-notice.js new file mode 100644 index 000000000..59ca545d5 --- /dev/null +++ b/patches/ds-bot/files/src/client/state-link-notice.js @@ -0,0 +1,61 @@ +// P-556: a save over a linked `state.json` replaces the name with a regular file of this home's own +// and never writes through the link, so the file the person linked keeps every byte and its last +// write time. The host counts those saves and the state answer carries the count together with the +// generation of the host process that made it — a restart starts the count at 0 again — and a window +// that has not said so yet says it once, in the same toast the client shows for confirmations. +// `seen` is what this window has already announced, remembered between polls: `{ gen, count }`. +export const LINK_REPLACED_NOTICE = 'Saved as a separate file; the file the link pointed at was not changed.' + +// What one answer says about one host process: the generation that counted, and how many of its saves +// replaced a link. Anything else — a missing field, a bare number, negative or unreadable counts, a +// count with no generation — is not a comparable count, so it answers null and announces nothing. +export const stateLinkCount = (value) => { + const field = value?.stateFileLinkReplaced + if (!field || typeof field !== 'object') return null + const gen = typeof field.gen === 'string' ? field.gen : '' + const count = Number(field.count) + if (gen === '' || !Number.isFinite(count) || count < 0) return null + return { gen, count: Math.floor(count) } +} + +// What this window remembers, or null when it remembers nothing: a page that has just loaded has no +// memory, and its first count is a baseline rather than news. +export const stateLinkMemory = (seen) => { + if (!seen || typeof seen !== 'object') return null + const gen = typeof seen.gen === 'string' ? seen.gen : '' + if (gen === '') return null + const count = Number(seen.count) + return { gen, count: Number.isFinite(count) && count > 0 ? Math.floor(count) : 0 } +} + +// A count is only comparable inside the process that made it: a new host process starts it at 0, so +// the first save of a restarted host can carry a number the window already passed. Three cases, and +// they are different questions (Security, on review of #2473): +// - no memory (a page that has just loaded): the answer's count becomes the baseline and nothing is +// said, so a refresh never replays a notice about a save the person was already told about; +// - memory under another generation (the host restarted while this window stayed open): the baseline +// resets to 0 — not to the answer's count, which would swallow the very save that came with the +// restart — so `count > 0` says that save once; +// - memory under the same generation: the usual `count > known`, quiet on a repeated `unchanged` and +// on a host that replaced no link. +export const stateLinkNotice = (value, seen) => { + const known = stateLinkMemory(seen) + const answer = stateLinkCount(value) + if (answer === null) return { seen: known ?? { gen: '', count: 0 }, announce: false } + if (known === null) return { seen: answer, announce: false } + if (answer.gen !== known.gen) { + return answer.count > 0 + ? { seen: answer, announce: true } + : { seen: { gen: answer.gen, count: 0 }, announce: false } + } + return answer.count > known.count ? { seen: answer, announce: true } : { seen: known, announce: false } +} + +// One poll's worth of that decision: say the sentence when the count is news, and answer what the +// window now remembers. A window that cannot draw (no `say`) still remembers, so a line it missed +// while it could not draw is not repeated once it can. +export const announceStateLink = (value, seen, say) => { + const next = stateLinkNotice(value, seen) + if (next.announce) say?.(LINK_REPLACED_NOTICE) + return next.seen +} diff --git a/patches/ds-bot/files/src/host/authz.js b/patches/ds-bot/files/src/host/authz.js index 16cead28e..990c87d32 100644 --- a/patches/ds-bot/files/src/host/authz.js +++ b/patches/ds-bot/files/src/host/authz.js @@ -229,6 +229,12 @@ export function install(rt) { memoryReviews: alone() ? full.memoryReviews : 0, connectors: alone() ? full.connectors : [], capabilityStatuses: pick(full.capabilityStatuses), + // P-556: how many saves of this host replaced a linked `state.json`, with the generation of the + // process that counted them. This is a fact about the host, not about one member's data, so it + // reaches a signed-in deployment like an unsigned one: absent from this whitelist it reached + // nobody at all, and the notice was silently dead wherever anyone had signed in (Security, on + // review of #2473). + stateFileLinkReplaced: full.stateFileLinkReplaced ?? { gen: '', count: 0 }, } } diff --git a/patches/ds-bot/files/test/state-link-answer.test.js b/patches/ds-bot/files/test/state-link-answer.test.js new file mode 100644 index 000000000..7816269ea --- /dev/null +++ b/patches/ds-bot/files/test/state-link-answer.test.js @@ -0,0 +1,99 @@ +// P-556: a save that replaces a linked `state.json` with a regular file does not move the team +// revision, so the count that tells a window about it has to ride every state answer — the +// `unchanged` one included. A window polling with `since` (the normal case) would otherwise never +// learn that its save left the linked file alone. +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { install as installAuthz } from '../src/host/authz.js' +import { install } from '../src/host/channel.js' + +function channel(stateLinkReplaced, { signedIn = false } = {}) { + const routes = [] + const ctx = { + effect: (execute) => { + execute() + return () => {} + }, + connection: { fetch: { register: (route) => { routes.push(route); return () => {} } } }, + logger: { warn() {} }, + } + const rt = { + ctx, + // A deployment that has seen a sign-in is the case that matters here: authz then narrows the live + // part of the answer through its own `ownedLive` whitelist instead of passing everything on. + state: { + revision: 'r1', + bots: {}, + rooms: {}, + members: signedIn ? { m1: { id: 'm1', displayName: 'Me' } } : {}, + authSessions: signedIn ? { token1: { memberId: 'm1' } } : {}, + }, + authenticate: async token => (signedIn && token === 'token1' ? { memberId: 'm1', displayName: 'Me' } : null), + warn: () => {}, + noteFailure: () => {}, + refresh: async () => {}, + load: async () => {}, + bootstrap: async () => {}, + catalog: async () => {}, + loadSecrets: async () => {}, + readOnly: () => undefined, + authorize: () => ({ ok: true }), + activityView: () => ({}), + busyAcksView: () => ({}), + workView: () => ({}), + roomWorkView: () => ({}), + approvalsView: () => [], + endedView: () => [], + memoryNews: () => ({}), + memoryReviews: () => [], + connectorsView: () => [], + capabilityStatusesView: () => ({}), + ownedLive: (full) => full, + pluginToolNames: () => [], + stateLinkReplaced, + } + installAuthz(rt) + install(rt) + const call = async (endpoint, payload = {}) => (await routes[0].fetch(new Request('http://localhost/api/bot', { + method: 'POST', + headers: { 'content-type': 'application/json', ...(signedIn ? { 'x-flybot-member-session': 'token1' } : {}) }, + body: JSON.stringify({ endpoint, payload }), + }))).json() + return { call, rt } +} + +test('P-556: an unchanged state answer still carries the count and the generation it belongs to', async () => { + const { call } = channel(() => ({ gen: 'host-b', count: 2 })) + const value = (await call('state', { since: 'r1' })).value + assert.equal(value.unchanged, true, 'the same revision is answered as unchanged') + assert.deepEqual(value.stateFileLinkReplaced, { gen: 'host-b', count: 2 }, + 'the count rides the unchanged answer too, together with the process that made it') +}) + +test('P-556: a host that replaced no link answers 0 with its generation, never a missing field', async () => { + const { call } = channel(() => ({ gen: 'host-a', count: 0 })) + const value = (await call('state', { since: 'r1' })).value + assert.deepEqual(value.stateFileLinkReplaced, { gen: 'host-a', count: 0 }) +}) + +// A host that never installed the counter (an older build answered the same endpoint) must still +// answer a shape a client can read: no field at all would be a `undefined` the notice has to guess at. +test('P-556: a host with no counter answers the empty generation and zero', async () => { + const { call } = channel(undefined) + const value = (await call('state', { since: 'r1' })).value + assert.deepEqual(value.stateFileLinkReplaced, { gen: '', count: 0 }) +}) + +// Security, on review of the first tip: `channel.js` sends the live part through `rt.ownedLive(full, +// actor)`, and authz.js answers an explicit whitelist once anyone has signed in — a field it does not +// name never reaches the client, and the notice would silently never fire on any real deployment. +// This case drives that real whitelist: signed in, an actor resolved from the session header, and the +// count has to be in the answer with its generation. +test('P-556: a signed-in deployment still receives the count through the real ownedLive whitelist', async () => { + const { call, rt } = channel(() => ({ gen: 'host-a', count: 3 }), { signedIn: true }) + assert.equal(rt.authzEnforced(), true, 'this case runs on a deployment where authz narrows the answer') + const value = (await call('state', { since: 'r1' })).value + assert.deepEqual(value.stateFileLinkReplaced, { gen: 'host-a', count: 3 }, + 'the count and its generation survive the authz whitelist') + assert.equal(value.unchanged, true, 'on the unchanged answer too, which is the one a poll gets') +}) diff --git a/patches/ds-bot/files/test/state-link-notice.test.js b/patches/ds-bot/files/test/state-link-notice.test.js new file mode 100644 index 000000000..929696aff --- /dev/null +++ b/patches/ds-bot/files/test/state-link-notice.test.js @@ -0,0 +1,102 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { DICTIONARY } from '../src/client/i18n.js' +import { LINK_REPLACED_NOTICE, announceStateLink, stateLinkNotice } from '../src/client/state-link-notice.js' + +// P-556: the state answer carries the count of saves that replaced a linked `state.json`, together +// with the generation of the host process that counted them. A restarted host starts counting at 0 +// again, so a count is only comparable with one from the same generation — and the first save after a +// restart can carry a smaller number than the one the window remembers, which a bare `count > known` +// would swallow (Chief, on review). + +// Both orders a window can meet a restart in. (1) It polls the restarted host before the save that +// replaced a link: `{B,0}` first, then `{B,1}` — the save is said. (2) The restart and the save both +// land between two polls and the window sees `{B,1}` while it remembers `{A,2}` — that save is said +// too, which is what resetting to 0 (not to the answer's own count) buys. +test('a count from a new host process starts over and still says the save that came with it', () => { + const afterRestart = stateLinkNotice({ stateFileLinkReplaced: { gen: 'B', count: 0 } }, { gen: 'A', count: 2 }) + assert.deepEqual(afterRestart, { seen: { gen: 'B', count: 0 }, announce: false }, + 'a restarted host with no replaced link says nothing and the window forgets the old count') + const thenSave = stateLinkNotice({ stateFileLinkReplaced: { gen: 'B', count: 1 } }, afterRestart.seen) + assert.deepEqual(thenSave, { seen: { gen: 'B', count: 1 }, announce: true }, + 'the save after the restart is said once') + + const bothAtOnce = stateLinkNotice({ stateFileLinkReplaced: { gen: 'B', count: 1 } }, { gen: 'A', count: 2 }) + assert.deepEqual(bothAtOnce, { seen: { gen: 'B', count: 1 }, announce: true }, + 'a restart and its save seen in the same answer is said, not swallowed by the older, larger count') +}) + +test('within one host process the count is said once, and a later replaced link is news again', () => { + const first = stateLinkNotice({ stateFileLinkReplaced: { gen: 'A', count: 1 } }, { gen: 'A', count: 0 }) + assert.deepEqual(first, { seen: { gen: 'A', count: 1 }, announce: true }) + // The next poll carries the same count: the window stays quiet and keeps what it knows. + assert.deepEqual(stateLinkNotice({ stateFileLinkReplaced: { gen: 'A', count: 1 } }, first.seen), + { seen: { gen: 'A', count: 1 }, announce: false }) + // A second save that replaced a link is news again. + assert.deepEqual(stateLinkNotice({ stateFileLinkReplaced: { gen: 'A', count: 2 } }, first.seen), + { seen: { gen: 'A', count: 2 }, announce: true }) + // An older answer from the same process never re-announces and never walks the window back. + assert.deepEqual(stateLinkNotice({ stateFileLinkReplaced: { gen: 'A', count: 1 } }, { gen: 'A', count: 2 }), + { seen: { gen: 'A', count: 2 }, announce: false }) +}) + +// A window that has just loaded the page remembers nothing, and it must not replay what an older +// window already said: the count it meets is its baseline, not news (Security, on review of #2473). +test('a window with no memory takes the first count as its baseline and says nothing', () => { + assert.deepEqual(stateLinkNotice({ stateFileLinkReplaced: { gen: 'A', count: 2 } }, null), + { seen: { gen: 'A', count: 2 }, announce: false }) + assert.deepEqual(stateLinkNotice({ stateFileLinkReplaced: { gen: 'A', count: 2 } }, undefined), + { seen: { gen: 'A', count: 2 }, announce: false }) + assert.deepEqual(stateLinkNotice({ stateFileLinkReplaced: { gen: 'A', count: 2 } }, { gen: '', count: 0 }), + { seen: { gen: 'A', count: 2 }, announce: false }, 'an empty seed is no memory either') + // From that baseline on, the ordinary same-generation rule applies again. + assert.deepEqual(stateLinkNotice({ stateFileLinkReplaced: { gen: 'A', count: 3 } }, { gen: 'A', count: 2 }), + { seen: { gen: 'A', count: 3 }, announce: true }) + assert.deepEqual(stateLinkNotice({ stateFileLinkReplaced: { gen: 'A', count: 2 } }, { gen: 'A', count: 2 }), + { seen: { gen: 'A', count: 2 }, announce: false }) +}) + +test('a host that replaced no link says nothing, and a window that knows nothing stays quiet', () => { + assert.deepEqual(stateLinkNotice({ stateFileLinkReplaced: { gen: 'A', count: 0 } }, { gen: 'A', count: 0 }), + { seen: { gen: 'A', count: 0 }, announce: false }) + assert.deepEqual(stateLinkNotice({ stateFileLinkReplaced: { gen: 'A', count: 0 } }, { gen: '', count: 0 }), + { seen: { gen: 'A', count: 0 }, announce: false }, 'a first answer with nothing to say still records its generation') + assert.deepEqual(stateLinkNotice({}, { gen: 'A', count: 1 }), { seen: { gen: 'A', count: 1 }, announce: false }) + assert.deepEqual(stateLinkNotice(undefined, { gen: 'A', count: 1 }), { seen: { gen: 'A', count: 1 }, announce: false }) +}) + +test('junk is not a count and leaves what the window knows alone', () => { + const known = { gen: 'A', count: 1 } + assert.deepEqual(stateLinkNotice({ stateFileLinkReplaced: { gen: 'B', count: 'many' } }, known), + { seen: known, announce: false }) + assert.deepEqual(stateLinkNotice({ stateFileLinkReplaced: { gen: 'B', count: -1 } }, known), + { seen: known, announce: false }) + assert.deepEqual(stateLinkNotice({ stateFileLinkReplaced: { count: 3 } }, known), + { seen: known, announce: false }, 'a count with no generation cannot be compared with anything') + assert.deepEqual(stateLinkNotice({ stateFileLinkReplaced: 3 }, known), + { seen: known, announce: false }, 'the old bare number is not a count either') +}) + +// The sentence a person reads is interface copy, so it lives in the dictionary with a zh entry. +test('the link-replaced notice is one dictionary line in both languages', () => { + assert.equal(LINK_REPLACED_NOTICE, 'Saved as a separate file; the file the link pointed at was not changed.') + assert.equal(DICTIONARY.zh[LINK_REPLACED_NOTICE], '已保存为独立文件,原链接指向的内容未改动。') +}) + +// P-556: the window says it once. The count is remembered between polls, and the sentence it shows +// is the dictionary line, translated where the window draws it. +test('the announce step says the sentence once and keeps the count it remembers', () => { + const said = [] + const say = text => said.push(text) + let seen = announceStateLink({ stateFileLinkReplaced: { gen: 'A', count: 1 } }, { gen: 'A', count: 0 }, say) + assert.deepEqual(said, [LINK_REPLACED_NOTICE], 'the first answer says it once') + seen = announceStateLink({ stateFileLinkReplaced: { gen: 'A', count: 1 } }, seen, say) + assert.deepEqual(said, [LINK_REPLACED_NOTICE], 'the same count again says nothing') + seen = announceStateLink({ stateFileLinkReplaced: { gen: 'B', count: 1 } }, seen, say) + assert.equal(said.length, 2, 'the save after a restart is news again') + assert.deepEqual(seen, { gen: 'B', count: 1 }) + assert.deepEqual(announceStateLink({}, seen, say), { gen: 'B', count: 1 }, 'an answer with no count keeps what the window knows') + assert.equal(said.length, 2, 'and says nothing') + // A window that cannot draw says nothing and still remembers, so it does not repeat later. + assert.deepEqual(announceStateLink({ stateFileLinkReplaced: { gen: 'B', count: 2 } }, seen), { gen: 'B', count: 2 }) +})