diff --git a/changelog.d/876-room-invite-endpoints.added.md b/changelog.d/876-room-invite-endpoints.added.md new file mode 100644 index 00000000..b00fb65c --- /dev/null +++ b/changelog.d/876-room-invite-endpoints.added.md @@ -0,0 +1 @@ +A room's invite link now has the endpoints the Members card works through: read the live link, mint one, withdraw it, and knock at a link someone else handed you — first for the preview, then again with `accept: true` when Join is clicked ([#876](https://github.com/ThinkFlowLab/FlyBot/issues/876)). The knock leaves from one of your own Bots and names the Bot to knock on from the link, never from the request body; a link into a room on another FlyBot, a bare ticket, and a ticket outside the 16–64 character shape are each refused in one sentence and send nothing. diff --git a/core/client.js b/core/client.js index e742baca..2b707bb3 100644 --- a/core/client.js +++ b/core/client.js @@ -2142,6 +2142,87 @@ function RoomAvatar({ room: room2, roster, size = 36 }) { ); } +// src/client/room-invite-preview.js +var INVITE_PREVIEW_NAME_MAX = 60; +function stripInviteMarkers(text) { + let out = String(text ?? ""); + for (let previous; previous !== out; ) { + previous = out; + out = out.replace(/<\/?\s*untrusted-(?:file|message)/gi, ""); + } + return out; +} +function cleanInviteName(value, fallback = "") { + return stripInviteMarkers(value).replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g, " ").replace(/[[\]"<>`@]/g, "").replace(/\s+/g, " ").trim().slice(0, INVITE_PREVIEW_NAME_MAX) || fallback; +} +function inviteMemberCount(value) { + if (typeof value === "number") return Number.isFinite(value) ? value : NaN; + if (typeof value !== "string") return NaN; + const text = value.trim(); + if (!text) return NaN; + const count = Number(text); + return Number.isFinite(count) ? count : NaN; +} +function invitePreviewOf(seen) { + if (!seen || typeof seen !== "object" || seen.unsupported === true) return null; + const name = cleanInviteName(seen.name); + const lead = cleanInviteName(seen.lead); + const members = inviteMemberCount(seen.members); + const maxMembers = inviteMemberCount(seen.maxMembers); + const people2 = Number.isFinite(members) && Number.isFinite(maxMembers) ? `${members} / ${maxMembers} in the room` : Number.isFinite(members) ? `${members} in the room` : ""; + const expiresAt = typeof seen.expiresAt === "string" ? seen.expiresAt : ""; + if (!name && !lead && !people2 && !expiresAt) return null; + return { name, lead, people: people2, expiresAt, clockOffsetMs: Number(seen.clockOffsetMs) || 0 }; +} +function inviteCountdown(expiresAt, offsetMs = 0) { + const at = Date.parse(expiresAt) - (Number(offsetMs) || 0); + if (!Number.isFinite(at)) return "Open while the room is open"; + const left = Math.max(0, at - Date.now()); + if (left <= 0) return "This link has expired"; + const total = Math.max(1, Math.ceil(left / 1e3)); + return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, "0")} left to join`; +} +function RoomInvitePreview({ h: h42, preview, kind = "face-link-preview", busy = false, joinLabel = "Join", clearLabel = "Clear", showClear = true, onJoin, onClear, now } = {}) { + if (!preview || typeof h42 !== "function") return null; + const at = `${kind}`; + const child = (name) => `${at}-${name}`; + const countdown = now === void 0 ? inviteCountdown(preview.expiresAt, preview.clockOffsetMs) : inviteCountdownAt(preview.expiresAt, preview.clockOffsetMs, now); + return h42( + "div", + { className: "fs-preview", "data-flybot": at, role: "status" }, + h42("div", { className: "fs-preview-name", "data-flybot": child("name") }, preview.name || "Face-to-face room"), + preview.lead ? h42("div", { className: "fs-preview-meta", "data-flybot": child("lead") }, `Lead: ${preview.lead}`) : null, + preview.people ? h42("div", { className: "fs-preview-meta", "data-flybot": child("members") }, preview.people) : null, + preview.expiresAt ? h42("div", { className: "fs-preview-meta", "data-flybot": child("expiry") }, countdown) : null, + onJoin || onClear ? h42( + "div", + { className: "fs-preview-foot", "data-flybot": child("foot") }, + showClear && onClear ? h42("button", { + type: "button", + className: "fs-btn fs-btn-sm", + "data-flybot": child("clear"), + disabled: !!busy, + onClick: onClear + }, clearLabel) : null, + onJoin ? h42("button", { + type: "button", + className: "fs-btn fs-btn-sm fs-btn-primary", + "data-flybot": child("join"), + disabled: !!busy, + onClick: onJoin + }, joinLabel) : null + ) : null + ); +} +function inviteCountdownAt(expiresAt, offsetMs, now) { + const at = Date.parse(expiresAt) - (Number(offsetMs) || 0); + if (!Number.isFinite(at)) return "Open while the room is open"; + const left = Math.max(0, at - Number(now || 0)); + if (left <= 0) return "This link has expired"; + const total = Math.max(1, Math.ceil(left / 1e3)); + return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, "0")} left to join`; +} + // src/client/approval-claims.js var claims = /* @__PURE__ */ new Map(); var claimListeners = /* @__PURE__ */ new Set(); @@ -2183,6 +2264,9 @@ function registry2() { globalThis.__flybot__.BotMark ??= BotMark; globalThis.__flybot__.BotAvatar ??= BotAvatar; globalThis.__flybot__.lookOf ??= lookOf; + globalThis.__flybot__.RoomInvitePreview ??= RoomInvitePreview; + globalThis.__flybot__.invitePreviewOf ??= invitePreviewOf; + globalThis.__flybot__.inviteCountdown ??= inviteCountdown; globalThis.__flybot__.names ??= { friendBotTitle, friendBotOwner }; return slots; } diff --git a/core/src/client/flybot-slots.js b/core/src/client/flybot-slots.js index 097a9903..44e1df97 100644 --- a/core/src/client/flybot-slots.js +++ b/core/src/client/flybot-slots.js @@ -34,6 +34,7 @@ import { call as callHost } from './api.js' import { friendBotOwner, friendBotTitle, isListedRemoteChat } from '../shared/remote-label.js' import { registry as baseRegistry } from './flybot-registry.js' import { BotAvatar, BotMark, lookOf } from './mark.js' +import { RoomInvitePreview, inviteCountdown, invitePreviewOf } from './room-invite-preview.js' import { t } from './i18n.js' import { chatOpened, claimApprovals, claimedIds, onClaims } from './approval-claims.js' import { isApprovalWaiting, WAITING_STATUSES } from '../shared/approval-waiting.js' @@ -54,6 +55,11 @@ export function registry() { globalThis.__flybot__.BotMark ??= BotMark globalThis.__flybot__.BotAvatar ??= BotAvatar globalThis.__flybot__.lookOf ??= lookOf + // One room-invite preview card for every surface that shows an invite (#876): the face-room paste + // flow and the member-invite dialog render this, so the markup is not written twice. + globalThis.__flybot__.RoomInvitePreview ??= RoomInvitePreview + globalThis.__flybot__.invitePreviewOf ??= invitePreviewOf + globalThis.__flybot__.inviteCountdown ??= inviteCountdown // The same for a friend's Bot's one name and its owner (#498), so the social plugin says it as the sidebar does. globalThis.__flybot__.names ??= { friendBotTitle, friendBotOwner } return slots diff --git a/core/src/client/room-invite-preview.js b/core/src/client/room-invite-preview.js new file mode 100644 index 00000000..ec9500a6 --- /dev/null +++ b/core/src/client/room-invite-preview.js @@ -0,0 +1,111 @@ +// One room-invite preview card, drawn wherever an invite is shown (#876). The face-room paste flow +// and the member-invite dialog both render this component, so the markup exists once: a change here +// is visible in both, and neither surface keeps a copy. +// +// It is a plain function, not a hook component: the caller passes its own `createElement` as `h`, +// which is what lets the core bundle and a classic-script plugin (which gets `h` from `require`) +// draw the same card. Everything the card says comes from a server reply, so the strings are +// cleaned here, at the last place before a person reads them — the same rules the server applies to +// a room name (`cleanFaceName`): one line, no controls, brackets, markup or @, capped. +export const INVITE_PREVIEW_NAME_MAX = 60 + +// Untrusted fence markers are stripped to a fixed point: one pass can remove a match and assemble a +// new one, so a nested `` would otherwise leave a complete +// `` in the card a person reads (#822). +export function stripInviteMarkers(text) { + let out = String(text ?? '') + for (let previous; previous !== out;) { + previous = out + out = out.replace(/<\/?\s*untrusted-(?:file|message)/gi, '') + } + return out +} + +export function cleanInviteName(value, fallback = '') { + return stripInviteMarkers(value) + .replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g, ' ') + .replace(/[[\]"<>`@]/g, '') + .replace(/\s+/g, ' ') + .trim() + .slice(0, INVITE_PREVIEW_NAME_MAX) || fallback +} + +// A count is only what reads as one (#823): `Number('')` is 0, which would draw an unknown room as +// empty, so an empty or non-numeric string is "not sent", like null. +export function inviteMemberCount(value) { + if (typeof value === 'number') return Number.isFinite(value) ? value : NaN + if (typeof value !== 'string') return NaN + const text = value.trim() + if (!text) return NaN + const count = Number(text) + return Number.isFinite(count) ? count : NaN +} + +// What the card can honestly say, and nothing else: a reply with no field worth showing is no card +// at all, so the caller keeps its own path (a paste that previews nothing still joins as before). +export function invitePreviewOf(seen) { + if (!seen || typeof seen !== 'object' || seen.unsupported === true) return null + const name = cleanInviteName(seen.name) + const lead = cleanInviteName(seen.lead) + const members = inviteMemberCount(seen.members) + const maxMembers = inviteMemberCount(seen.maxMembers) + const people = Number.isFinite(members) && Number.isFinite(maxMembers) + ? `${members} / ${maxMembers} in the room` + : Number.isFinite(members) ? `${members} in the room` : '' + const expiresAt = typeof seen.expiresAt === 'string' ? seen.expiresAt : '' + if (!name && !lead && !people && !expiresAt) return null + return { name, lead, people, expiresAt, clockOffsetMs: Number(seen.clockOffsetMs) || 0 } +} + +// Every home counts down to the server's one deadline (#458): `offsetMs` is how far the server's +// clock runs ahead of this one (0 unless the skew is real). m:ss, so the line visibly ticks. +export function inviteCountdown(expiresAt, offsetMs = 0) { + const at = Date.parse(expiresAt) - (Number(offsetMs) || 0) + if (!Number.isFinite(at)) return 'Open while the room is open' + const left = Math.max(0, at - Date.now()) + if (left <= 0) return 'This link has expired' + const total = Math.max(1, Math.ceil(left / 1000)) + return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, '0')} left to join` +} + +// `kind` prefixes every `data-flybot` name, so each surface keeps the hook its tests and its CSS +// already use while sharing this one tree of elements. +export function RoomInvitePreview({ h, preview, kind = 'face-link-preview', busy = false, joinLabel = 'Join', clearLabel = 'Clear', showClear = true, onJoin, onClear, now } = {}) { + if (!preview || typeof h !== 'function') return null + const at = `${kind}` + const child = (name) => `${at}-${name}` + const countdown = now === undefined + ? inviteCountdown(preview.expiresAt, preview.clockOffsetMs) + : inviteCountdownAt(preview.expiresAt, preview.clockOffsetMs, now) + return h('div', { className: 'fs-preview', 'data-flybot': at, role: 'status' }, + h('div', { className: 'fs-preview-name', 'data-flybot': child('name') }, preview.name || 'Face-to-face room'), + preview.lead ? h('div', { className: 'fs-preview-meta', 'data-flybot': child('lead') }, `Lead: ${preview.lead}`) : null, + preview.people ? h('div', { className: 'fs-preview-meta', 'data-flybot': child('members') }, preview.people) : null, + preview.expiresAt ? h('div', { className: 'fs-preview-meta', 'data-flybot': child('expiry') }, countdown) : null, + onJoin || onClear + ? h('div', { className: 'fs-preview-foot', 'data-flybot': child('foot') }, + showClear && onClear + ? h('button', { + type: 'button', className: 'fs-btn fs-btn-sm', 'data-flybot': child('clear'), + disabled: !!busy, onClick: onClear, + }, clearLabel) + : null, + onJoin + ? h('button', { + type: 'button', className: 'fs-btn fs-btn-sm fs-btn-primary', 'data-flybot': child('join'), + disabled: !!busy, onClick: onJoin, + }, joinLabel) + : null) + : null) +} + +// The same text as `inviteCountdown`, measured against a clock the caller already has — a render is +// then reproducible in a test without freezing the machine's clock. +export function inviteCountdownAt(expiresAt, offsetMs, now) { + const at = Date.parse(expiresAt) - (Number(offsetMs) || 0) + if (!Number.isFinite(at)) return 'Open while the room is open' + const left = Math.max(0, at - Number(now || 0)) + if (left <= 0) return 'This link has expired' + const total = Math.max(1, Math.ceil(left / 1000)) + return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, '0')} left to join` +} diff --git a/core/src/host/authz.js b/core/src/host/authz.js index e75c860f..9ac1c622 100644 --- a/core/src/host/authz.js +++ b/core/src/host/authz.js @@ -47,7 +47,10 @@ const BOT_TARGETED = new Set([ ]) // Endpoints whose body names a room. -const ROOM_TARGETED = new Set(['update-room', 'delete-room', 'room-progress', 'create-room', 'room-history', 'room-post']) +const ROOM_TARGETED = new Set(['update-room', 'delete-room', 'room-progress', 'create-room', 'room-history', 'room-post', + // #876: the room's own link. Reading it is a member's read; minting and withdrawing hand out + // or kill a way in, so they follow the room's admin rule. + 'room-invite', 'room-invite-mint', 'room-invite-revoke']) // Endpoints whose body names a Session. const SESSION_TARGETED = new Set(['earlier', 'send', 'answer-question', 'dismiss-question', 'approval-answer', 'exchange']) @@ -334,6 +337,19 @@ export function install(rt) { const id = body?.id ?? body?.roomId return roomMemberOf(actor, id) || roomOwnedBy(actor, id) ? ALLOW : deny('bot/forbidden', 'You are not a member of that room') } + if (ep === 'room-invite') { + const id = body?.id ?? body?.roomId + const room = state.rooms?.[id] + const me = currentParticipants(room).find(p => p.kind === 'human' && p.id === actor.memberId) + return roomOwnedBy(actor, id) || roomMemberOf(actor, id) || me?.role === 'admin' ? ALLOW : deny('bot/forbidden', 'You are not a member of that room') + } + if (ep === 'room-invite-mint' || ep === 'room-invite-revoke') { + const id = body?.id ?? body?.roomId + if (roomOwnedBy(actor, id)) return ALLOW + const room = state.rooms?.[id] + const me = currentParticipants(room).find(p => p.kind === 'human' && p.id === actor.memberId) + return me?.role === 'admin' ? ALLOW : deny('bot/forbidden', 'You do not own that room') + } if (ep === 'delete-room') { const id = body?.id ?? body?.roomId if (roomOwnedBy(actor, id)) return ALLOW @@ -370,6 +386,9 @@ export function install(rt) { // Sharing a member Bot's brief is the Bot owner's decision, checked on the record // in room-membership (#53); the endpoint itself only needs a signed-in actor. if (ep === 'share-brief') return ALLOW + // #876: knocking at another FlyBot's room link is not authority over anything here. The knock + // carries a ticket that the inviter's record decides on, and the sender is the actor's own Bot. + if (ep === 'room-invite-ask') return ALLOW // Task transitions are requester-checked on the record in tasks.js (#54) — the // endpoint only needs a signed-in actor, like held-send decisions. Create is // grant-aware there (owner, matching grant, or the named room). Explicit set: diff --git a/core/src/host/channel.js b/core/src/host/channel.js index 08faebd2..7874dd53 100644 --- a/core/src/host/channel.js +++ b/core/src/host/channel.js @@ -383,6 +383,26 @@ export function install(rt) { }, actor) return ok(line) } + // #876: the room's own link. Reading it is what the Members card shows (copy and the + // countdown); minting replaces the live ticket, withdrawing stops answering at once. + case 'room-invite': { + await rt.load() + const invite = rt.localInviteOf(String(body.roomId ?? body.id ?? '')) + return ok({ invite: invite ?? null }) + } + case 'room-invite-mint': { + await rt.load() + return ok({ invite: await rt.mintRoomInvite(String(body.roomId ?? body.id ?? '')) }) + } + case 'room-invite-revoke': { + await rt.load() + return ok(await rt.revokeRoomInvite(String(body.roomId ?? body.id ?? ''))) + } + // The invitee's side: a knock at the link (and, on Join, the accepting knock). + case 'room-invite-ask': { + await rt.load() + return ok(await rt.askLocalInvite(String(body.link ?? body.url ?? ''), { accept: body.accept === true }, actor)) + } case 'folders': case 'folder-pick': case 'folder-list': diff --git a/core/src/host/room-membership.js b/core/src/host/room-membership.js index 8bfe44e7..471d122d 100644 --- a/core/src/host/room-membership.js +++ b/core/src/host/room-membership.js @@ -36,7 +36,7 @@ import { randomBytes } from 'node:crypto' import { cleanPeerName, disambiguatedRemoteTitle, isChiefRecord, remoteBotTitle, sharedRoomTitle } from '../shared/remote-label.js' import { formatFaceRoomTitle, isAutoFaceTitle, faceRoomCreatedAt } from '../shared/face-title.js' -import { encodeRoomShare, LOCAL_INVITE_TTL_MS, parseRoomShare, roomInviteUrl } from '../shared/room-share.js' +import { encodeRoomShare, LOCAL_INVITE_TTL_MS, parseRoomInviteLink, parseRoomShare, roomInviteUrl } from '../shared/room-share.js' import { cleanFaceLead, cleanRoleLabel, faceLeadOf } from './face-lead.js' import { cleanName, fenceUntrusted, isFencedUntrusted } from './delivery.js' import { hasAnyGrant, mayShareRoom } from './reach.js' @@ -1012,6 +1012,40 @@ export function install(rt) { return { revoked: true } } + // #876: the invitee's side of a local link. The link names the Bot to knock on, and the knock + // carries the ticket and nothing else; the accepting click is the same knock with `accept: true`. + // Nothing here decides the seat: the inviter's room record answers, and its offer comes back as + // `invite` through applyInboundRoomShare (rt.onLocalInviteOffer). The knock goes out from a Bot of + // the actor's whose home has a Bot Social identity — the link cannot name the sender, so the + // transport does, exactly as on the answering side. + function localSpeaker(actor) { + const usable = Object.values(state.bots ?? {}).filter(bot => bot && bot.remote === undefined + && typeof bot?.fields?.social?.member?.handle === 'string' + && bot.fields.social.member.handle.startsWith('@')) + const mine = actor?.memberId ? usable.filter(bot => bot.ownerId === actor.memberId) : usable + const list = mine.length > 0 ? mine : usable + const chief = actor?.memberId ? state.members?.[actor.memberId]?.designatedChiefId : undefined + return (chief !== undefined ? list.find(bot => bot.id === chief) : undefined)?.id ?? list[0]?.id + } + + rt.askLocalInvite = async function askLocalInvite(link, opts = {}, actor) { + const parsed = parseRoomInviteLink(typeof link === 'string' ? link : '') + if (!parsed || parsed.kind !== 'local') throw invalid('That is not a room link on another FlyBot') + if (!rt.flybotSend) throw invalid('Bot Social is not connected on this machine yet') + const from = localSpeaker(actor) + if (from === undefined) throw invalid('This machine has no Bot Social identity yet') + const accept = opts?.accept === true + const sent = await rt.flybotSend({ + from, + to: parsed.handle, + text: encodeRoomShare({ op: 'ask', ticket: parsed.ticket, ...(accept ? { accept: true } : {}) }), + role: 'request', + via: 'room-share', + meta: { share: 'ask', ticket: parsed.ticket }, + }) + return { sent: sent?.action ?? 'sent', handle: parsed.handle, ticket: parsed.ticket, accept } + } + // #852: a knock at a local room's link. The invitee's host sends `ask` (the accepting knock carries // `accept: true`); this side answers from the room record only, and a knock never creates a room, // adds a participant, or writes a line. The asker is the transport's sender, never a field of the diff --git a/core/test/authz.test.js b/core/test/authz.test.js index f67b1130..db81a421 100644 --- a/core/test/authz.test.js +++ b/core/test/authz.test.js @@ -104,6 +104,17 @@ test('owner cannot touch another owner Bot or room', async (t) => { assert.equal(gate.code, 'bot/forbidden', ep) } + // #876: a room's link is a member's read, and minting or withdrawing it is the room admin's call. + for (const ep of ['room-invite', 'room-invite-mint', 'room-invite-revoke']) { + assert.equal(rt.authorize(actorA, ep, { id: 'ra' }).ok, true, ep) + const gate = rt.authorize(actorA, ep, { id: 'rb' }) + assert.equal(gate.ok, false, ep) + assert.equal(gate.code, 'bot/forbidden', ep) + } + // A knock at another FlyBot's link carries no authority here, so a signed-in actor may always ask. + assert.equal(rt.authorize(actorA, 'room-invite-ask', { link: 'https://social.botawaken.com/r/@chief.b.home/AbCdEfGhIjKlMnOpQrStUv' }).ok, true) + assert.equal(rt.authorize(null, 'room-invite-ask', {}).ok, false, 'a signed-in actor is still required') + const mixed = rt.authorize(actorA, 'create-room', { members: ['a1', 'b1'] }) assert.equal(mixed.ok, false) assert.equal(mixed.code, 'bot/forbidden') diff --git a/core/test/remote-room.test.js b/core/test/remote-room.test.js index 58525273..ea918898 100644 --- a/core/test/remote-room.test.js +++ b/core/test/remote-room.test.js @@ -1337,6 +1337,55 @@ test('#852: a local link is not a face-room link, and a shared or face room has room.face = { roomId: 'frm_1', members: [] } await assert.rejects(() => rt.mintRoomInvite('war'), /its own invitation/) }) +test('#876: the invitee knocks on the link, and Join is the same knock with accept', async (t) => { + const { rt, sent } = host(t, TEAM) + await rt.load() + const { bob } = await signAliceAndBob(rt) + const actorB = await rt.authenticate(bob.sessionToken) + rt.state.bots['chief-b'].fields = { social: { member: { home: '@bob.home', handle: '@chief.b.home' } } } + const link = 'https://social.botawaken.com/r/@chief.a.home/AbCdEfGhIjKlMnOpQrStUv' + + // The preview knock: the ticket and the Bot to knock on, and no room id — this home has no room. + const knock = await rt.askLocalInvite(link + '/', {}, actorB) + assert.deepEqual( + { handle: knock.handle, ticket: knock.ticket, accept: knock.accept, sent: knock.sent }, + { handle: '@chief.a.home', ticket: 'AbCdEfGhIjKlMnOpQrStUv', accept: false, sent: 'hold' }, + ) + const first = sent.at(-1) + assert.equal(first.to, '@chief.a.home', 'the link names who is knocked on') + assert.equal(first.from, 'chief-b', "the knock leaves from the actor's own Bot, never a body field") + assert.equal(first.via, 'room-share') + const asked = parseRoomShare(first.text) + assert.equal(asked.op, 'ask') + assert.equal(asked.ticket, 'AbCdEfGhIjKlMnOpQrStUv') + assert.equal(asked.accept, undefined, 'the preview asks without accepting') + assert.equal(asked.roomId, undefined, 'the invitee cannot name a room it has never seen') + + // Join is the same knock with the one flag the answering side reads. + const accepted = await rt.askLocalInvite(link, { accept: true }, actorB) + assert.equal(accepted.accept, true) + assert.equal(parseRoomShare(sent.at(-1).text).accept, true) + assert.equal(parseRoomShare(sent.at(-1).text).ticket, asked.ticket) + + // A face-room link and a bare ticket are not this knock: one sentence, and nothing is sent. + const before = sent.length + await assert.rejects(() => rt.askLocalInvite('https://social.botawaken.com/r/AbCdEfGhIjKlMnOpQrStUv', {}, actorB), /not a room link on another FlyBot/) + await assert.rejects(() => rt.askLocalInvite('AbCdEfGhIjKlMnOpQrStUv', {}, actorB), /not a room link on another FlyBot/) + await assert.rejects(() => rt.askLocalInvite('https://social.botawaken.com/r/@chief.a.home/short', {}, actorB), /not a room link on another FlyBot/) + assert.equal(sent.length, before, 'a refused link knocks on nothing') + + // No Bot Social identity on this side: the refusal names that, not the link. + const kept = rt.state.bots['chief-b'].fields + delete rt.state.bots['chief-b'].fields + await assert.rejects(() => rt.askLocalInvite(link, {}, actorB), /no Bot Social identity yet/) + rt.state.bots['chief-b'].fields = kept + // Without a transport installed there is nothing to knock with. + const send = rt.flybotSend + rt.flybotSend = undefined + await assert.rejects(() => rt.askLocalInvite(link, {}, actorB), /Bot Social is not connected/) + rt.flybotSend = send +}) + test('#852: the knock asks the room record, and only the accept admits one home', async (t) => { const { rt, sent } = host(t, TEAM) await rt.load() diff --git a/core/test/room-invite-preview.test.js b/core/test/room-invite-preview.test.js new file mode 100644 index 00000000..73e99e9f --- /dev/null +++ b/core/test/room-invite-preview.test.js @@ -0,0 +1,79 @@ +// #876: the room-invite preview card is one component in the core client, drawn by both surfaces +// that show an invite. These tests pin what it says, what it refuses to say, and that the markup a +// surface gets depends only on `kind` — not on which surface asked. +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { + RoomInvitePreview, cleanInviteName, inviteCountdown, inviteCountdownAt, + inviteMemberCount, invitePreviewOf, +} from '../src/client/room-invite-preview.js' + +const h = (type, props, ...children) => ({ type, props: props ?? {}, children: children.flat() }) +const walk = (node) => { + if (!node || typeof node !== 'object') return [] + if (typeof node.type === 'function') return walk(node.type(node.props)) + return [node, ...(node.children ?? []).flatMap(walk)] +} +const byKind = (node, kind) => walk(node).filter(n => n.props?.['data-flybot'] === kind) + +const preview = { name: 'Saturday room', lead: 'Chief', people: '3 / 8 in the room', expiresAt: '2099-01-01T00:00:00.000Z', clockOffsetMs: 0 } + +test('#876: the card names the room, its lead, its people and the deadline', () => { + const tree = RoomInvitePreview({ h, preview, onJoin() {}, onClear() {} }) + assert.equal(tree.props['data-flybot'], 'face-link-preview') + assert.equal(tree.children[0].children.join(''), 'Saturday room') + assert.equal(tree.children[1].children.join(''), 'Lead: Chief') + assert.equal(tree.children[2].children.join(''), '3 / 8 in the room') + // The deadline line is the one countdown text, not a per-surface copy of the rule. + assert.equal(tree.children[3].children.join(''), inviteCountdownAt(preview.expiresAt, 0, Date.now())) + const foot = tree.children[4] + assert.deepEqual(foot.children.map(button => button.children[0]), ['Clear', 'Join']) +}) + +test('#876: `kind` only renames the hooks, so both surfaces draw this one tree', () => { + const face = RoomInvitePreview({ h, preview, kind: 'face-link-preview', onJoin() {}, onClear() {} }) + const dialog = RoomInvitePreview({ h, preview, kind: 'room-invite-preview', onJoin() {}, onClear() {} }) + const shape = (node) => JSON.stringify(node, (key, value) => { + if (key === 'data-flybot' && typeof value === 'string') return value.replace(/^face-link-preview/, 'KIND').replace(/^room-invite-preview/, 'KIND') + return typeof value === 'function' ? 'fn' : value + }) + assert.equal(shape(face), shape(dialog), 'same elements, same order: nothing is written twice') + assert.equal(byKind(dialog, 'room-invite-preview').length, 1) + assert.equal(byKind(dialog, 'room-invite-preview-name').length, 1) + assert.equal(byKind(face, 'face-link-preview-name').length, 1) +}) + +test('#876: a name a stranger wrote reaches the card as plain text', () => { + const seen = { + name: 'Room [x] @chief\u0000 now', + lead: 'Chief @home', + members: 2, + } + const card = invitePreviewOf(seen) + assert.equal(card.name, 'Room x chief now', 'fence markers removed to a fixed point, then brackets and @') + assert.equal(card.lead, 'Chief ihome/i', 'markup characters are not kept') + assert.equal(card.people, '2 in the room') + const tree = RoomInvitePreview({ h, preview: card, onJoin() {} }) + assert.equal(walk(tree).every(n => !String(n.props?.children ?? '').includes('<')), true) +}) + +test('#876: nothing worth saying is no card, and a count is only what reads as one', () => { + assert.equal(invitePreviewOf(undefined), null) + assert.equal(invitePreviewOf({ unsupported: true }), null) + assert.equal(invitePreviewOf({}), null, 'an empty reply draws no card') + assert.equal(RoomInvitePreview({ h, preview: null }), null) + assert.equal(inviteMemberCount(''), Number.NaN, 'an empty string is not zero members (#823)') + assert.equal(inviteMemberCount(' 3 '), 3) + assert.equal(inviteMemberCount('three'), Number.NaN) + assert.equal(invitePreviewOf({ members: '', maxMembers: '' }), null, 'no count line at all, so no card') + assert.equal(invitePreviewOf({ members: 3 }).people, '3 in the room') + assert.equal(invitePreviewOf({ members: 3, maxMembers: '' }).people, '3 in the room') +}) + +test('#876: the deadline line is shared copy, and an expired one says so', () => { + const future = new Date(Date.now() + 61_000).toISOString() + assert.match(inviteCountdownAt(future, 0, Date.now()), /^1:0\d left to join$/) + assert.equal(inviteCountdownAt('2000-01-01T00:00:00.000Z', 0, Date.now()), 'This link has expired') + assert.equal(inviteCountdown('not a date'), 'Open while the room is open') + assert.equal(cleanInviteName(' ', 'a room'), 'a room') +}) diff --git a/core/types/flybot.d.ts b/core/types/flybot.d.ts index 11502295..f178923c 100644 --- a/core/types/flybot.d.ts +++ b/core/types/flybot.d.ts @@ -614,6 +614,9 @@ export interface FlyBotClientRegistry { export type FlyBotBotMark = (props: { bot?: any; look?: { shape?: string; color?: string; image?: string; emoji?: string }; size?: number; [key: string]: unknown }) => any export type FlyBotBotAvatar = (props: { bot?: any; size?: number; badge?: boolean; live?: boolean; [key: string]: unknown }) => any export type FlyBotLookOf = (bot?: any) => { shape: string; color: string; image?: string } +/** One room-invite preview card, for every surface that shows an invite (#876). */ +export type FlyBotRoomInvitePreview = (props: any) => any +export type FlyBotInvitePreview = (seen?: any) => { name: string; lead: string; people: string; expiresAt: string; clockOffsetMs: number } | null declare global { // eslint-disable-next-line no-var @@ -626,6 +629,10 @@ declare global { /** Sidebar avatar (mark plus optional badges). Prefer this at 24px with badge: false. */ BotAvatar?: FlyBotBotAvatar lookOf?: FlyBotLookOf + /** The invite card the face-room paste flow and the member-invite dialog both render (#876). */ + RoomInvitePreview?: FlyBotRoomInvitePreview + invitePreviewOf?: FlyBotInvitePreview + inviteCountdown?: (expiresAt: string, offsetMs?: number) => string /** A friend's Bot chat (a roster Bot with `remote`): its one name, "Chief (Bob)", and its owner, "Bob" (#498). */ names?: { friendBotTitle(bot?: any): string; friendBotOwner(bot?: any): string } } | undefined diff --git a/docs/subsystems/core-client.md b/docs/subsystems/core-client.md index 20f9603e..0d5c4227 100644 --- a/docs/subsystems/core-client.md +++ b/docs/subsystems/core-client.md @@ -26,6 +26,7 @@ The browser side of the patched `ds-bot` core (`core/`): sources in `core/src/cl | `core/src/client/` | Client sources → build to `core/client.js`. | | `core/src/client/new-chat.js` | Sidebar **+** picker. Plugin rows via `flybot.new-chat.action` (`newChatActionRows` in `flybot-registry.js`); social adds Face-to-face room and Add friend (copy actions live inside Add friend, #181). The DSH Agent row reads **Add coding agent** (hint "A plain DSH session, not a Bot") so it is not mistaken for **Create new Bot** (#181). The short + menu does not list individual Bots. A group the host refuses because one of my Bots is not shared with the friend whose Bot is in it (`error.details.reason === 'ungranted-local'`, put on the error by `client/api.js`) is drawn as a card, not a red line: it names the Bot(s) and friend(s), and offers **Copy invite link for {Bot}** (`__flybot__.social.createInvite`; shown only when social is loaded and can invite that Bot, nothing is granted by the click; it says *copied* only when `copiedToClipboard` in `shared/remote-label.js` reports the write took, else it shows the link in a read-only, select-on-focus field to copy by hand) and **Leave {Bot} out** (`ungrantedLocalFix`, `shared/remote-label.js`). | | `core/src/client/stats-dock.js` | The shell's run-stats footer under the composer (`conversation.composer.dock`) re-registered one priority lower with the shell's own component, inject and `chat` locale, so the English counts read **1 turn** / **1 step** instead of `1 turns 1 steps` (`pluralStats` rewrites only the exact English template, #419). The shell ids differ by version — one `stats` footer in 0.2.0-rc.2, the counts split into an `activity` pill next to `usage` in 0.2.1-alpha.1 — so `statsDocks` registers one wrapper per id and the wrapper whose id the shell does not publish draws nothing (#659); `usage` stays hidden. Still hidden in a friend's Bot chat by CSS (#419). Tests: `core/test/stats-dock.test.js`. | +| `core/src/client/room-invite-preview.js` | The one room-invite preview card (#876). `invitePreviewOf` says only what a server reply can honestly say (a name cleaned at fixed point, the lead, `n / m in the room`, the deadline), `inviteCountdown` words the deadline, `RoomInvitePreview` draws the card from a caller-supplied `h`; `kind` renames the `data-flybot` hooks, so the face-room paste flow (`face-link-preview`) and the member-invite dialog (`room-invite-preview`) render the same tree instead of two copies of the markup. Registered on `__flybot__` in `flybot-slots.js`, because a classic-script plugin cannot import it. Tests: `core/test/room-invite-preview.test.js`. | | `core/src/client/face-at-filter.js` | A face room's `@` menu without DSH's Sessions rows (#305): `planFaceMenu` (rows to hide, where the highlight goes, when to dismiss), `faceMenuCss`, `watchFaceMenu`. Tests: `core/test/face-at-filter.test.js`. | | `core/test/new-chat-menu.test.js` | **+** menu rows (#283): an action row's description is its own line under the title, an inline Bot hint is capped, so hovering never hides a title. | | `core/test/new-chat.test.js` | Picker filters and the **+** short action menu (`data-menu`; New chat, New Bot, New group chat, plugin actions, then Add coding agent; #385 marks, verbs and hints). `ungrantedLocalFix`, `copiedToClipboard` (no clipboard or a refused write is a failure) and the refusal card (invite and leave-out actions, the copy-by-hand link field, no grant written by the client). | diff --git a/docs/subsystems/core-host.md b/docs/subsystems/core-host.md index 91cc79c0..f87c3056 100644 --- a/docs/subsystems/core-host.md +++ b/docs/subsystems/core-host.md @@ -153,6 +153,7 @@ Work a member hands a Bot with `message_bot` from its group Session runs in a hi - A room that lives in FlyBot can hand out one link: `https://social.botawaken.com/r//`. `core/src/shared/room-share.js` `parseRoomInviteLink` reads both shapes in the one invite field and says which one it saw (`{ kind: 'local', handle, ticket }` / `{ kind: 'face', ticket }`), and `roomInviteUrl` builds the local one. The ticket is `randomBytes(16)` in base64url — 22 characters of `[A-Za-z0-9_-]`, inside the 64-character shape `FACE_INVITE_TICKET` already accepts. The handle is its own path segment because `send_as_member` addresses a Bot by handle, and a ticket cannot carry one inside that limit. - The room record is the only authority: `room.localInvite { ticket, handle, expiresAt, mintedAt }`, ten minutes (`LOCAL_INVITE_TTL_MS`, the face-room window). `rt.mintRoomInvite(roomId)` replaces the previous ticket, so there is one live ticket per room; `rt.localInviteOf(roomId)` answers only while it is live; `rt.roomOfLocalInvite(ticket)` resolves the room from the ticket; `rt.revokeRoomInvite(roomId)` and deleting the room clear it. A shared (`inbound-share`) or face-to-face room refuses to mint — it has its own invitation. - Three steps on the same envelope, answered by the room record: the invitee's host sends `{ op: 'ask', ticket }`; the inviter answers `{ op: 'invite', ticket, roomId, name, lead, expiresAt }`, which is information only (it consumes nothing and seats nobody); the accepting knock `{ op: 'ask', ticket, accept: true }` runs verify → consume → add → the existing `join`, which `addRoomParticipant` sends. `applyInboundRoomShare` handles both ops **before** the branch that projects a room for an unmatched `join` / `line`, so a knock never conjures a room; `parseRoomShare` accepts `ask` without a `roomId` and requires one on `invite`. The asker is `event.fromHandle` (the transport's sender), never a field of the payload. `acceptedBy` is written once, at accept: the same home retrying is idempotent success (the lost `join` reply is sent again) and another home is refused; an add the room refuses rolls the claim back, so no refusal burns the ticket. Refusals are local on the inviter's host (`unknown` / `expired` / `consumed` / `no-sender` / `not-addable`) and change nothing. The UI half — the Members card's **Invite by link** row and the dialog the offer draws — is still to come. Covered by `core/test/remote-room.test.js` (#852). +- The endpoints the UI calls (#876): `room-invite` reads the live link (`rt.localInviteOf`, what the Members card copies and counts down), `room-invite-mint` and `room-invite-revoke` are the room owner's or a human admin's call (`authz.js`), and `room-invite-ask` is the invitee's side: `rt.askLocalInvite` reads the handle and ticket out of the link and sends `{ op: 'ask', ticket }` from one of the actor's own Bots whose home has a Bot Social identity — the link cannot name a sender, so the transport does, as on the answering side. Join is the same call with `accept: true`, and the answer arrives as `invite` through `applyInboundRoomShare` (`rt.onLocalInviteOffer`). A face-room link, a bare ticket and a ticket outside the 16–64 shape are refused in one sentence and send nothing; the knock carries no `roomId`, so an invitee can never name a room it has not seen. ### Face-to-face owner send diff --git a/docs/subsystems/plugin-runtime.md b/docs/subsystems/plugin-runtime.md index 55829928..890a9659 100644 --- a/docs/subsystems/plugin-runtime.md +++ b/docs/subsystems/plugin-runtime.md @@ -89,7 +89,7 @@ FlyBot's plugin API surface on top of the patched core: host hooks, client slots ### Host Bot look for classic-script plugins -- Classic-script plugins cannot import `src/client/mark.js`. The host client `registry()` therefore also sets `globalThis.__flybot__.BotAvatar`, `BotMark` and `lookOf` to the same components the sidebar / session list / chat header use (configured image / shape / color / emoji, else a stable look from the Bot id or a remote handle). Plugins typically render `BotAvatar` at 24px with `badge: false`; face-to-face tiles pass 48. A stored `color` / `avatar` on the record still wins; a remote Bot without one is seeded by `remoteSeed(handle)`. A face-room `members[]` look overlays the chat record (`lookBotForRemote`). For the same reason `registry()` sets `globalThis.__flybot__.names` to `{ friendBotTitle, friendBotOwner }` from `shared/remote-label.js` (typed in `types/flybot.d.ts`), so a plugin names a friend's Bot (`Chief (SelfB1233)`) or its owner exactly as the sidebar and approval cards do (#498). +- Classic-script plugins cannot import `src/client/mark.js`. The host client `registry()` therefore also sets `globalThis.__flybot__.BotAvatar`, `BotMark` and `lookOf` to the same components the sidebar / session list / chat header use (configured image / shape / color / emoji, else a stable look from the Bot id or a remote handle). Plugins typically render `BotAvatar` at 24px with `badge: false`; face-to-face tiles pass 48. A stored `color` / `avatar` on the record still wins; a remote Bot without one is seeded by `remoteSeed(handle)`. A face-room `members[]` look overlays the chat record (`lookBotForRemote`). For the same reason `registry()` sets `globalThis.__flybot__.names` to `{ friendBotTitle, friendBotOwner }` from `shared/remote-label.js` (typed in `types/flybot.d.ts`), so a plugin names a friend's Bot (`Chief (SelfB1233)`) or its owner exactly as the sidebar and approval cards do (#498). For the same reason `registry()` sets `globalThis.__flybot__.RoomInvitePreview`, `invitePreviewOf` and `inviteCountdown` from `src/client/room-invite-preview.js` (#876): the invite card is one component for the face-room paste flow and the member-invite dialog, and a classic-script plugin renders it instead of keeping a second copy of the markup. `kind` only renames the `data-flybot` hooks (`face-link-preview` / `room-invite-preview`), and `invitePreviewOf` says only what the server reply can honestly say (a name cleaned at fixed point, the lead, the seat count, the deadline). ### A plugin's sanctioned model call (#450) diff --git a/docs/subsystems/social.md b/docs/subsystems/social.md index 7bbb1790..e710ca34 100644 --- a/docs/subsystems/social.md +++ b/docs/subsystems/social.md @@ -108,7 +108,7 @@ pins the list. The authority for the list is Bot Social's README and `docs/FACE_ - Face-to-face room cards use a short status chip (`data-flybot=face-status`): live rooms read **open**; expired matching windows under **Past rooms** read **ended**; candidates show a countdown (`12m` / `45s`); closed/left keep their labels (#166). - Join → waiting → leave (#306): **Join** is enabled as soon as a Bot is picked (a short number answers with the inline `Enter a 6-digit number.`; no Bot picked shows `Pick a Bot first.`). The waiting countdown reads `m:ss left to join` and ticks every second. The dialog overlay (`.fs-dialog-back`, z-index above the details panel and the Bot pane) covers the whole window, and while it waits in a room the details panel draws no second waiting card for it (`faceDialog`); my own Bots' waiting tiles use the roster look in both places. Every **Leave** (waiting dialog, waiting overview, open room) asks once (**Leave** / **Stay**, `ConfirmLeave`) and lands on the main Bot (`openMainBot`). In an open room **Leave** sits on the status-chip line, a row above the **Auto-send** toggle. The room subtitle counts what it means: `2 people · 2 Bots` (`faceRoomCount` in core `group-settings.js`). - QR for a room number (#773): the waiting dialog and the room panel draw the 6-digit number as a QR under it (`data-flybot=face-qr`, one `path` in an `svg`), so a phone camera or another FlyBot can scan instead of typing. The image is computed in the browser — byte mode, error correction M, version 1–10 picking the smallest that holds the text, every module of the matrix — with no request, no third-party image service and no runtime dependency. The payload is `FLYBOT-` plus the six digits and nothing else: a deadline in the image would re-encode every second and a screenshot would lie, so the countdown line beside it keeps ticking. The code goes when joining ends and as soon as the room opens, so a number on screen is never one that no longer works. Scanning reads the pixels with the browser's `BarcodeDetector` and the camera with `getUserMedia`; a browser without the decoder says so **before** it asks for the camera, a refused camera gets one sentence, and a QR from anywhere else is `That is not a FlyBot room code. Ask for the 6-digit number instead.` Typing the number always stays, and a successful scan ends in the same `social/face-join` the number field uses — it only fills the field. -- Invites into a room that already exists (#773, Bot Social #39): an open room shows **Invite by link** on its overview (`data-flybot=face-invite`), and **Create link** mints one with `POST /v1/face-rooms/{id}/invites` → `{ ticket, url, expires_at }`. What it draws is the URL itself as a QR, so a phone camera opens the room and FlyBot's own scan joins with the same ticket; nothing is minted in the browser. The card carries the link as text, **Copy link**, a countdown to the server deadline, and **Withdraw link** (`DELETE /v1/face-rooms/{id}/invites/{ticket}`), which drops the image and says nobody can join with it any more. The link expires with the room, so the code goes with the deadline. The input field and the scanner read one field with two shapes (`parseInviteLink`): this server's `https://…/r/`, and a link into a room on another FlyBot, `https://…/r/@handle/` (#852). The face-room path only ever answers the first — `parseFaceInviteLink` returns the ticket for the face shape and nothing for the local one — and a local link is refused by name, in the dialog, in the scanner and at the host route (`social/face-join-invite` answers before the `face_room_invites` gate, since that ticket was never this server's): `That link opens a room on another FlyBot. It does not join a face-to-face room.` Anything that is neither shape is `That is not an invite link. It looks like https://…/r/…`, and the 6-digit number stays the way in. Joining is `POST /v1/face-rooms/join-invite`, and each refusal keeps its own sentence: `invite_not_found`, `expired` and `revoked` each ask for a new one, `room_full`, `ended` → the room has closed, `already_member` → you are already in that room. Both the mint and the join paths are behind the same gate, so an older server never receives a request it cannot answer. The whole card is behind the `face_room_invites` entry in `GET /v1/platform` `features`: on a server without it the endpoints answer `{ unsupported: true }` without calling a route that is not there, and the card says `Invites need a newer Bot Social. The 6-digit number still works.` Those endpoints (`social/face-invite`, `social/face-invite-revoke`, `social/face-invite-preview`, `social/face-join-invite`) are listed in `SOCIAL_UI_ENDPOINTS`, so the UI cannot call what the host does not serve. A pasted or scanned invite asks the room first (#798): `social/face-invite-preview` answers `{ roomId, name, lead, members, maxMembers, expiresAt }` and the dialog draws a card whose first line is the name (a room Bot Social has no name for, which is every room FlyBot opens, reads `Face-to-face room` instead of reporting the missing field, #859), the lead, `members / maxMembers` when both are numbers (the count alone when only one is), and the deadline as a countdown; **Join** on that card runs the join above and **Clear** empties the field. Pressing **Join with link** again while that card is already up for the link in the field asks the host for nothing and keeps the card, countdown and all (#824); the guard compares the parsed ticket, not the typed text, while a field edit or **Clear** drops the card so the next press looks the link up once. The preview is information, never a gate: a 404 or a timeout drops the card and the same join runs with the same sentence; `{ unsupported: true }` is the one answer that stops there, because the invite feature is off on that server and a join would only reach the same sentence through a route that is not there. The card cleans `name` and `lead` itself with the same rules as `cleanFaceName`, so a stranger's brackets and markup reach the dialog as plain text, and it strips the untrusted fence markers until the string stops changing, as the server's `stripFaceMarkers` does: one pass can remove a match and assemble a new one, so a nested `` would otherwise leave a complete `` in the card a person reads (#822). The lead arrives from Bot Social as the member object (`{ handle, display_name, owner_name }`), not as a string, so the host renders it with the same `faceMemberLabel` rule the member list uses — `Lead: Chief (SelfTest A2)` — while a string answer keeps the `cleanFaceName` path, and an object that names nobody leaves the lead line out (#846). Every Bot picked in the dialog joins (#817): the link path asks the host once per selected Bot, exactly as the 6-digit number does. It has its own stop table, because the invite refusals do not look like the number ones: a ticket that is unknown, expired or withdrawn, a room that has ended and a room that is full stop the loop after the first refusal (those answers cannot change for the next Bot), while a refusal about one Bot alone — already in the room, its token revoked, not found, forbidden — keeps the other Bots going. A Bot that cannot join is listed by name while the Bots that did bring the room up, so one failure never hides the others. +- Invites into a room that already exists (#773, Bot Social #39): an open room shows **Invite by link** on its overview (`data-flybot=face-invite`), and **Create link** mints one with `POST /v1/face-rooms/{id}/invites` → `{ ticket, url, expires_at }`. What it draws is the URL itself as a QR, so a phone camera opens the room and FlyBot's own scan joins with the same ticket; nothing is minted in the browser. The card carries the link as text, **Copy link**, a countdown to the server deadline, and **Withdraw link** (`DELETE /v1/face-rooms/{id}/invites/{ticket}`), which drops the image and says nobody can join with it any more. The link expires with the room, so the code goes with the deadline. The input field and the scanner read one field with two shapes (`parseInviteLink`): this server's `https://…/r/`, and a link into a room on another FlyBot, `https://…/r/@handle/` (#852). The face-room path only ever answers the first — `parseFaceInviteLink` returns the ticket for the face shape and nothing for the local one — and a local link is refused by name, in the dialog, in the scanner and at the host route (`social/face-join-invite` answers before the `face_room_invites` gate, since that ticket was never this server's): `That link opens a room on another FlyBot. It does not join a face-to-face room.` Anything that is neither shape is `That is not an invite link. It looks like https://…/r/…`, and the 6-digit number stays the way in. Joining is `POST /v1/face-rooms/join-invite`, and each refusal keeps its own sentence: `invite_not_found`, `expired` and `revoked` each ask for a new one, `room_full`, `ended` → the room has closed, `already_member` → you are already in that room. Both the mint and the join paths are behind the same gate, so an older server never receives a request it cannot answer. The whole card is behind the `face_room_invites` entry in `GET /v1/platform` `features`: on a server without it the endpoints answer `{ unsupported: true }` without calling a route that is not there, and the card says `Invites need a newer Bot Social. The 6-digit number still works.` Those endpoints (`social/face-invite`, `social/face-invite-revoke`, `social/face-invite-preview`, `social/face-join-invite`) are listed in `SOCIAL_UI_ENDPOINTS`, so the UI cannot call what the host does not serve. A pasted or scanned invite asks the room first (#798): `social/face-invite-preview` answers `{ roomId, name, lead, members, maxMembers, expiresAt }` and the dialog draws a card whose first line is the name (a room Bot Social has no name for, which is every room FlyBot opens, reads `Face-to-face room` instead of reporting the missing field, #859), the lead, `members / maxMembers` when both are numbers (the count alone when only one is), and the deadline as a countdown; **Join** on that card runs the join above and **Clear** empties the field. Pressing **Join with link** again while that card is already up for the link in the field asks the host for nothing and keeps the card, countdown and all (#824); the guard compares the parsed ticket, not the typed text, while a field edit or **Clear** drops the card so the next press looks the link up once. The preview is information, never a gate: a 404 or a timeout drops the card and the same join runs with the same sentence; `{ unsupported: true }` is the one answer that stops there, because the invite feature is off on that server and a join would only reach the same sentence through a route that is not there. The card cleans `name` and `lead` itself with the same rules as `cleanFaceName`, so a stranger's brackets and markup reach the dialog as plain text, and it strips the untrusted fence markers until the string stops changing, as the server's `stripFaceMarkers` does: one pass can remove a match and assemble a new one, so a nested `` would otherwise leave a complete `` in the card a person reads (#822). Since #876 that card is not markup of this plugin's own: `RoomInvitePreview` (`core/src/client/room-invite-preview.js`, registered on `__flybot__` because a classic-script plugin cannot import it) draws it from the same `invitePreviewOf` / `inviteCountdown` rules, and `kind` renames only the `data-flybot` hooks (`face-link-preview` here, `room-invite-preview` in the member-invite dialog), so the two surfaces render one tree instead of two copies. The lead arrives from Bot Social as the member object (`{ handle, display_name, owner_name }`), not as a string, so the host renders it with the same `faceMemberLabel` rule the member list uses — `Lead: Chief (SelfTest A2)` — while a string answer keeps the `cleanFaceName` path, and an object that names nobody leaves the lead line out (#846). Every Bot picked in the dialog joins (#817): the link path asks the host once per selected Bot, exactly as the 6-digit number does. It has its own stop table, because the invite refusals do not look like the number ones: a ticket that is unknown, expired or withdrawn, a room that has ended and a room that is full stop the loop after the first refusal (those answers cannot change for the next Bot), while a refusal about one Bot alone — already in the room, its token revoked, not found, forbidden — keeps the other Bots going. A Bot that cannot join is listed by name while the Bots that did bring the room up, so one failure never hides the others. - Status chips on room details and Past rooms (`data-flybot=face-status`) read **Open** / **Closed** in sentence case with readable contrast, and the past row keeps its own spacing between avatars, title, time and chip (#170). - One card-placement rule (#418): a held message's decision sits where that message is. A Bot's reply to a friend has its buttons on the held reply bubble in place (no copy in the dock of that chat or the friend's chat); a message with no bubble of its own (a Bot's `message_friend_bot`, a typed composer line held by policy, a face-room line) has its card in the dock of the chat it belongs to, as below. - In-chat approval cards (`flybot.session.dock`) are right-aligned bubbles capped at ~460px: **Waiting for approval**, one `from → to` line, a word-wrapped preview with **Show more**, and **Approve** / **Reject** (one right-hand group) with a quiet **Always approve** link at the left of the same row, alone above them when the card is narrow (#166, #260, #271). One place per approval (#278): a Bot's direct message to a friend is drawn in the friend's chat once that chat exists (the sending Bot's own chat only until then), never in a Face-to-face room that has the friend in it; a Face-to-face room shows, counts as waiting, and gets a "Waiting for the owner's approval" notice for just the lines held for its own room id. The client also hides that old notice in a Face-to-face room's history. The held text shows once (#306): a Bot line held in a Face-to-face room keeps its text in the card, and the room draws no bubble for it until it is approved (`GroupMessage`, `core/src/client/cells.js`, matched by `room.heldLines`, #456, and by `room.pendingApprovals` as a fallback); a line the owner typed is never held and stays a plain bubble. A Bot's message to a friend (`message_friend_bot`) has no bubble in either chat, so the card is already its only place. #456: the held room line lives only on its card (greyed preview) until **Approve**: `onFaceLine` answers `{ held: true, approvalId }` for a Bot line Bot Social holds, `social/decide-approval` calls `flybot.rooms.settleHeld`, Approve makes it a room bubble and Reject a grey ` · Not sent` line, with no `Rejected.` note in the face room. A decided card leaves the dock at once (no **Approved** / **Rejected** stub, any card kind). A face card's `from` is the room's own #384 label for that local Bot (`Researcher (Alice) → the room`, `faceFromLabel`). `applyFaceSnapshot` posts a human or system event for a round only when the core room logged it now (`applyFaceLine` refuses an event id it has), so an event read twice starts no second round. diff --git a/patches/ds-bot/0005-social-seams.patch b/patches/ds-bot/0005-social-seams.patch index 7d49cbd3..096eaf8f 100644 --- a/patches/ds-bot/0005-social-seams.patch +++ b/patches/ds-bot/0005-social-seams.patch @@ -567,7 +567,7 @@ diff --git a/src/host/channel.js b/src/host/channel.js return ok(room) } case 'update-room': { -@@ -175,8 +175,28 @@ export function install(rt) { +@@ -175,8 +175,48 @@ export function install(rt) { add: Array.isArray(body.add) ? body.add : undefined, remove: Array.isArray(body.remove) ? body.remove : undefined, workDir: body.workDir, @@ -595,6 +595,26 @@ diff --git a/src/host/channel.js b/src/host/channel.js + displayName: body.displayName, + }, actor) + return ok(line) ++ } ++ // #876: the room's own link. Reading it is what the Members card shows (copy and the ++ // countdown); minting replaces the live ticket, withdrawing stops answering at once. ++ case 'room-invite': { ++ await rt.load() ++ const invite = rt.localInviteOf(String(body.roomId ?? body.id ?? '')) ++ return ok({ invite: invite ?? null }) ++ } ++ case 'room-invite-mint': { ++ await rt.load() ++ return ok({ invite: await rt.mintRoomInvite(String(body.roomId ?? body.id ?? '')) }) ++ } ++ case 'room-invite-revoke': { ++ await rt.load() ++ return ok(await rt.revokeRoomInvite(String(body.roomId ?? body.id ?? ''))) ++ } ++ // The invitee's side: a knock at the link (and, on Join, the accepting knock). ++ case 'room-invite-ask': { ++ await rt.load() ++ return ok(await rt.askLocalInvite(String(body.link ?? body.url ?? ''), { accept: body.accept === true }, actor)) } case 'folders': case 'folder-pick': diff --git a/patches/ds-bot/0008-client-bundle.patch b/patches/ds-bot/0008-client-bundle.patch index a84c705b..a9cdede9 100644 --- a/patches/ds-bot/0008-client-bundle.patch +++ b/patches/ds-bot/0008-client-bundle.patch @@ -1,3 +1,5 @@ +FlyBot patch 0008-client-bundle on Fei2411/DeepSeek-Bot@2b10c70 (ds-bot/). Generated by scripts/ds-bot-patches.mjs; edits to upstream files only (files FlyBot adds are in files/). + diff --git a/client.js b/client.js --- a/client.js +++ b/client.js @@ -1087,7 +1089,7 @@ diff --git a/client.js b/client.js var maskBox = (size) => `maskUnits="userSpaceOnUse" x="${-size}" y="${-size}" width="${size * 3}" height="${size * 3}"`; var holes = (def, eyes) => `${eyes}`; var MASK_ID = "%MASK%"; -@@ -1242,162 +1242,526 @@ function maskOf(shape) { +@@ -1242,162 +1242,610 @@ function maskOf(shape) { } var DEFAULT_MASK = maskOf(FALLBACK_SHAPE); @@ -1437,6 +1439,87 @@ diff --git a/client.js b/client.js + ); +} + ++// src/client/room-invite-preview.js ++var INVITE_PREVIEW_NAME_MAX = 60; ++function stripInviteMarkers(text) { ++ let out = String(text ?? ""); ++ for (let previous; previous !== out; ) { ++ previous = out; ++ out = out.replace(/<\/?\s*untrusted-(?:file|message)/gi, ""); ++ } ++ return out; ++} ++function cleanInviteName(value, fallback = "") { ++ return stripInviteMarkers(value).replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g, " ").replace(/[[\]"<>`@]/g, "").replace(/\s+/g, " ").trim().slice(0, INVITE_PREVIEW_NAME_MAX) || fallback; ++} ++function inviteMemberCount(value) { ++ if (typeof value === "number") return Number.isFinite(value) ? value : NaN; ++ if (typeof value !== "string") return NaN; ++ const text = value.trim(); ++ if (!text) return NaN; ++ const count = Number(text); ++ return Number.isFinite(count) ? count : NaN; ++} ++function invitePreviewOf(seen) { ++ if (!seen || typeof seen !== "object" || seen.unsupported === true) return null; ++ const name = cleanInviteName(seen.name); ++ const lead = cleanInviteName(seen.lead); ++ const members = inviteMemberCount(seen.members); ++ const maxMembers = inviteMemberCount(seen.maxMembers); ++ const people2 = Number.isFinite(members) && Number.isFinite(maxMembers) ? `${members} / ${maxMembers} in the room` : Number.isFinite(members) ? `${members} in the room` : ""; ++ const expiresAt = typeof seen.expiresAt === "string" ? seen.expiresAt : ""; ++ if (!name && !lead && !people2 && !expiresAt) return null; ++ return { name, lead, people: people2, expiresAt, clockOffsetMs: Number(seen.clockOffsetMs) || 0 }; ++} ++function inviteCountdown(expiresAt, offsetMs = 0) { ++ const at = Date.parse(expiresAt) - (Number(offsetMs) || 0); ++ if (!Number.isFinite(at)) return "Open while the room is open"; ++ const left = Math.max(0, at - Date.now()); ++ if (left <= 0) return "This link has expired"; ++ const total = Math.max(1, Math.ceil(left / 1e3)); ++ return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, "0")} left to join`; ++} ++function RoomInvitePreview({ h: h42, preview, kind = "face-link-preview", busy = false, joinLabel = "Join", clearLabel = "Clear", showClear = true, onJoin, onClear, now } = {}) { ++ if (!preview || typeof h42 !== "function") return null; ++ const at = `${kind}`; ++ const child = (name) => `${at}-${name}`; ++ const countdown = now === void 0 ? inviteCountdown(preview.expiresAt, preview.clockOffsetMs) : inviteCountdownAt(preview.expiresAt, preview.clockOffsetMs, now); ++ return h42( ++ "div", ++ { className: "fs-preview", "data-flybot": at, role: "status" }, ++ h42("div", { className: "fs-preview-name", "data-flybot": child("name") }, preview.name || "Face-to-face room"), ++ preview.lead ? h42("div", { className: "fs-preview-meta", "data-flybot": child("lead") }, `Lead: ${preview.lead}`) : null, ++ preview.people ? h42("div", { className: "fs-preview-meta", "data-flybot": child("members") }, preview.people) : null, ++ preview.expiresAt ? h42("div", { className: "fs-preview-meta", "data-flybot": child("expiry") }, countdown) : null, ++ onJoin || onClear ? h42( ++ "div", ++ { className: "fs-preview-foot", "data-flybot": child("foot") }, ++ showClear && onClear ? h42("button", { ++ type: "button", ++ className: "fs-btn fs-btn-sm", ++ "data-flybot": child("clear"), ++ disabled: !!busy, ++ onClick: onClear ++ }, clearLabel) : null, ++ onJoin ? h42("button", { ++ type: "button", ++ className: "fs-btn fs-btn-sm fs-btn-primary", ++ "data-flybot": child("join"), ++ disabled: !!busy, ++ onClick: onJoin ++ }, joinLabel) : null ++ ) : null ++ ); ++} ++function inviteCountdownAt(expiresAt, offsetMs, now) { ++ const at = Date.parse(expiresAt) - (Number(offsetMs) || 0); ++ if (!Number.isFinite(at)) return "Open while the room is open"; ++ const left = Math.max(0, at - Number(now || 0)); ++ if (left <= 0) return "This link has expired"; ++ const total = Math.max(1, Math.ceil(left / 1e3)); ++ return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, "0")} left to join`; ++} ++ +// src/client/approval-claims.js +var claims = /* @__PURE__ */ new Map(); +var claimListeners = /* @__PURE__ */ new Set(); @@ -1485,6 +1568,9 @@ diff --git a/client.js b/client.js + globalThis.__flybot__.BotMark ??= BotMark; + globalThis.__flybot__.BotAvatar ??= BotAvatar; + globalThis.__flybot__.lookOf ??= lookOf; ++ globalThis.__flybot__.RoomInvitePreview ??= RoomInvitePreview; ++ globalThis.__flybot__.invitePreviewOf ??= invitePreviewOf; ++ globalThis.__flybot__.inviteCountdown ??= inviteCountdown; + globalThis.__flybot__.names ??= { friendBotTitle, friendBotOwner }; + return slots; +} @@ -1819,7 +1905,7 @@ diff --git a/client.js b/client.js function reminderPrompts(text) { const one = /^reminder_prompt_json: (.*)$/m.exec(text); const batch = /^reminders_json: (.*)$/m.exec(text); -@@ -1494,6 +1496,13 @@ function reminderPrompts(text) { +@@ -1494,6 +1494,13 @@ function reminderPrompts(text) { } } var stripHeader = (text) => text.replace(HEADER, ""); @@ -1833,7 +1919,7 @@ diff --git a/client.js b/client.js function latestMemoryChange(news, ids) { const at = (id) => news?.[id]?.seen ? news[id].at ?? 0 : 0; return ids.reduce((best, id) => at(id) > at(best) ? id : best, ids[0] ?? null); -@@ -1506,25 +1508,35 @@ function summaryBlocks(text) { +@@ -1506,25 +1506,35 @@ function summaryBlocks(text) { paragraph = []; }; for (const raw of String(text ?? "").split("\n")) { @@ -1873,7 +1959,7 @@ diff --git a/client.js b/client.js var historyLoading = (snapshot) => snapshot?.openState === "cold" || snapshot?.openState === "loading"; function toolArgs(root) { if (typeof root.args?.text === "function") { -@@ -1597,14 +1599,21 @@ function commRunOf(order, peers, turn, hiddenTurns = []) { +@@ -1597,14 +1597,21 @@ function commRunOf(order, peers, turn, hiddenTurns = []) { } return { count, peerIds }; } @@ -1898,7 +1984,7 @@ diff --git a/client.js b/client.js } return ""; } -@@ -1624,47 +1626,226 @@ function usageOf(list, ids) { +@@ -1624,33 +1624,212 @@ function usageOf(list, ids) { const hit = input > 0 ? Math.round(cacheRead / input * 100) : 0; return t("{tokens} tok · Cache hit {hit}%", { tokens: formatTokens(input + output), hit }); } @@ -1967,17 +2053,6 @@ diff --git a/client.js b/client.js -var QUICK_EDGES = { lead: springEasing(900, 51), trail: springEasing(400, 34) }; -var spring = (curve, ...properties) => properties.map((property) => `${property} ${curve.duration}ms ${curve.easing}`).join(","); -function liveSprings(apply2) { -- const values = /* @__PURE__ */ new Map(); -- let frame = 0; -- let last = 0; -- const read = () => Object.fromEntries([...values].map(([key, value]) => [key, value.x])); -- const step = (now) => { -- const dt = Math.min(0.5, Math.max(1e-3, (now - last) / 1e3)); -- last = now; -- const steps = Math.ceil(dt * 240); -- const h34 = dt / steps; -- let moving = false; -- for (const value of values.values()) { +var EMPTY_ROSTER = { revision: -1, mainBotId: null, mainBotIds: [], bots: [], rooms: [], humans: [], self: null, agents: [], exchangeTurns: {}, remoteTypedTurns: {}, remoteAwaiting: {}, questions: {}, secrets: [], capabilities: [], capabilityStatuses: {}, secretRequests: {}, models: [], defaultModel: null, modelsKey: null, prefs: {}, byId: {}, roomsById: {}, agentsById: {}, agentOf: {}, ready: false }; +var isMainOf = (roster, id) => id !== void 0 && (roster.mainBotIds ?? []).includes(id); +function indexRoster(value) { @@ -2143,17 +2218,17 @@ diff --git a/client.js b/client.js +var QUICK_EDGES = { lead: springEasing(900, 51), trail: springEasing(400, 34) }; +var spring = (curve, ...properties) => properties.map((property) => `${property} ${curve.duration}ms ${curve.easing}`).join(","); +function liveSprings(apply2) { -+ const values = /* @__PURE__ */ new Map(); -+ let frame = 0; -+ let last = 0; -+ const read = () => Object.fromEntries([...values].map(([key, value]) => [key, value.x])); -+ const step = (now) => { -+ const dt = Math.min(0.5, Math.max(1e-3, (now - last) / 1e3)); -+ last = now; -+ const steps = Math.ceil(dt * 240); + const values = /* @__PURE__ */ new Map(); + let frame = 0; + let last = 0; +@@ -1659,12 +1659,12 @@ function liveSprings(apply2) { + const dt = Math.min(0.5, Math.max(1e-3, (now - last) / 1e3)); + last = now; + const steps = Math.ceil(dt * 240); +- const h34 = dt / steps; + const h42 = dt / steps; -+ let moving = false; -+ for (const value of values.values()) { + let moving = false; + for (const value of values.values()) { for (let index = 0; index < steps; index += 1) { - value.v += (-value.k * (value.x - value.to) - value.c * value.v) * h34; - value.x += value.v * h34; @@ -2162,7 +2237,7 @@ diff --git a/client.js b/client.js } if (Math.abs(value.x - value.to) < 0.05 && Math.abs(value.v) < 1) { value.x = value.to; -@@ -1699,10 +1701,10 @@ function liveSprings(apply2) { +@@ -1699,10 +1699,10 @@ function liveSprings(apply2) { } var CHOSEN = '[aria-pressed="true"],[aria-selected="true"],[aria-current="page"]'; function useGlide(key, { axis = "x", selector = CHOSEN, lead = SPRING_LEAD, trail = SPRING_TRAIL, box: given } = {}) { @@ -2176,7 +2251,7 @@ diff --git a/client.js b/client.js if (own.current === null) { own.current = { key: void 0, -@@ -1736,7 +1738,7 @@ function useGlide(key, { axis = "x", selector = CHOSEN, lead = SPRING_LEAD, trai +@@ -1736,7 +1736,7 @@ function useGlide(key, { axis = "x", selector = CHOSEN, lead = SPRING_LEAD, trai springs.set({ start, end }, forward ? { start: trail, end: lead } : { start: lead, end: trail }, jump || hidden || before === null || reducedMotion()); container.dataset.glide = ""; }; @@ -2185,7 +2260,7 @@ diff --git a/client.js b/client.js const state = own.current; const node = box.current; if (node === state.node && key === state.key) return; -@@ -1750,7 +1752,7 @@ function useGlide(key, { axis = "x", selector = CHOSEN, lead = SPRING_LEAD, trai +@@ -1750,7 +1750,7 @@ function useGlide(key, { axis = "x", selector = CHOSEN, lead = SPRING_LEAD, trai } place(fresh); }); @@ -2194,7 +2269,7 @@ diff --git a/client.js b/client.js own.current.observer?.disconnect(); own.current.springs.stop(); }, []); -@@ -1758,16 +1760,16 @@ function useGlide(key, { axis = "x", selector = CHOSEN, lead = SPRING_LEAD, trai +@@ -1758,16 +1758,16 @@ function useGlide(key, { axis = "x", selector = CHOSEN, lead = SPRING_LEAD, trai } function Segmented({ label, value, className, children }) { const glide = useGlide(value); @@ -2215,7 +2290,7 @@ diff --git a/client.js b/client.js const container = list.current; if (!container) return; const next = /* @__PURE__ */ new Map(); -@@ -1788,8 +1790,8 @@ function useFlip(list, key, selector, id = (node) => node.dataset.flip) { +@@ -1788,8 +1788,8 @@ function useFlip(list, key, selector, id = (node) => node.dataset.flip) { }, [key]); } function useMorph(box, key) { @@ -2226,7 +2301,7 @@ diff --git a/client.js b/client.js const node = box.current; const state = last.current; if (!node) { -@@ -1817,8 +1819,8 @@ function useMorph(box, key) { +@@ -1817,8 +1817,8 @@ function useMorph(box, key) { }); } function useLinger(value, ms) { @@ -2237,7 +2312,7 @@ diff --git a/client.js b/client.js if (value) { setKept(value); return void 0; -@@ -1838,6 +1840,7 @@ ${Object.entries(INKS).map(([name, [, dark]]) => `body[data-ds-dark-theme]{--bt- +@@ -1838,6 +1838,7 @@ ${Object.entries(INKS).map(([name, [, dark]]) => `body[data-ds-dark-theme]{--bt- .bt-mark{display:inline-block;flex:none;position:relative;line-height:0} .bt-mark svg{width:100%;height:100%;overflow:visible} .bt-mark-img{width:100%;height:100%;border-radius:50%;object-fit:cover;display:block;box-shadow:inset 0 0 0 1px var(--bt-line)} @@ -2245,7 +2320,7 @@ diff --git a/client.js b/client.js .bt-mark .bt-b,.bt-mark .bt-eye,.bt-mark .bt-eyes,.bt-mark .bt-gaze{transform-box:fill-box} .bt-mark .bt-b{transform-origin:50% 100%} .bt-mark .bt-eye,.bt-mark .bt-eyes{transform-origin:center} -@@ -1856,6 +1858,14 @@ body[data-bt-motion=lively] .bt-mark[data-live][data-state=idle][data-fidget=loo +@@ -1856,6 +1856,14 @@ body[data-bt-motion=lively] .bt-mark[data-live][data-state=idle][data-fidget=loo @keyframes bt-fidget-look{0%,22%,100%{transform:none}3%,9%{transform:translateX(-14%)}12%,19%{transform:translateX(14%)}} body[data-bt-motion=lively] .bt-badge-alert{right:auto;bottom:auto;left:50%;top:calc(var(--bt-size,36px) * -.14);translate:-50% 0;animation:bt-q-ride 1.3s cubic-bezier(.3,.7,.4,1) infinite} body[data-bt-motion=lively] :is(.bt-row,.bt-tile)>.bt-mark:hover .bt-badge-alert{animation:none} @@ -2260,7 +2335,7 @@ diff --git a/client.js b/client.js @keyframes bt-q-ride{0%,55%,100%{transform:translateY(0)}20%{transform:translateY(calc(var(--bt-size,36px) * -.14))}40%{transform:translateY(0)}} /* Petting: with the pointer on a sidebar avatar or just around it (a ring a fifth of its size wide), a resting mark leans into the pointer and keeps nuzzling it, its eyes -@@ -1933,15 +1935,23 @@ body[data-bt-motion=quiet] .bt-mark[data-poke]{cursor:default} +@@ -1933,15 +1933,23 @@ body[data-bt-motion=quiet] .bt-mark[data-poke]{cursor:default} .bt-side{display:flex;flex-direction:column;min-height:0;flex:1 1 auto;gap:2px;padding:0 12px 8px 0;color:var(--bt-ink);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif} .bt-side-top{display:flex;justify-content:flex-end;align-items:center;gap:8px;padding:6px 5px 0 0} .bt-brand{display:inline-flex;align-items:center;gap:6px;margin-right:auto;padding:4px 8px 4px 4px;border:0;border-radius:8px;background:none;cursor:pointer;font-family:inherit;font-size:15px;line-height:20px;font-weight:600;letter-spacing:-.01em;color:var(--bt-ink);white-space:nowrap} @@ -2286,7 +2361,7 @@ diff --git a/client.js b/client.js .bt-tile-name{font-size:13px;line-height:18px;font-weight:400;max-width:84px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .bt-chip{font-size:11px;line-height:16px;padding:1px 4px;border-radius:4px;background:var(--bt-hover);border:1px solid var(--bt-line);color:var(--bt-ink-2);white-space:nowrap;max-width:84px;overflow:hidden;text-overflow:ellipsis} .bt-row{display:flex;align-items:center;gap:8px;min-height:54px;padding:8px;border:0;border-radius:10px;background:transparent;color:var(--bt-ink);text-align:left;cursor:pointer;width:100%;box-sizing:border-box;font-family:inherit;transition:background-color .12s ease} -@@ -1950,21 +1952,23 @@ body[data-bt-motion=quiet] .bt-mark[data-poke]{cursor:default} +@@ -1950,21 +1950,23 @@ body[data-bt-motion=quiet] .bt-mark[data-poke]{cursor:default} .bt-row-body{display:flex;flex-direction:column;min-width:0;flex:1 1 auto} .bt-row-name{display:flex;align-items:center;gap:6px;min-width:0;font-size:14px;line-height:20px;font-weight:400} .bt-row-title{min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} @@ -2314,7 +2389,7 @@ diff --git a/client.js b/client.js .bt-head{position:relative;display:flex;justify-content:center;align-items:center;height:40px;flex:none;pointer-events:none} .bt-pill{pointer-events:auto;display:inline-flex;align-items:center;gap:8px;height:40px;padding:7.5px 13.5px 7.5px 7.5px;border-radius:999px;background:var(--bt-main);border:.5px solid var(--bt-line-2);box-shadow:0 2px 8px -1px rgba(0,0,0,.06),0 1px 2px rgba(0,0,0,.04);color:var(--bt-ink);font-family:inherit;font-size:14px;line-height:20px;cursor:pointer;max-width:70%;box-sizing:border-box;transition:background-color .12s ease,transform ${SPRING_TAP.duration}ms ${SPRING_TAP.easing}} -@@ -1992,6 +1994,7 @@ body[data-bt-motion=quiet] .bt-mark[data-poke]{cursor:default} +@@ -1992,6 +1992,7 @@ body[data-bt-motion=quiet] .bt-mark[data-poke]{cursor:default} .bt-astack{display:flex;flex-direction:column;align-items:flex-start;gap:4px;margin:0} .bt-bubble{max-width:min(80%,560px,calc(100% - 82px));background:var(--bt-bubble);color:var(--bt-ink);border-radius:18px;padding:7px 12px;font-size:var(--dsh-content-font-size,14px);line-height:calc(var(--dsh-content-font-size,14px) + 8px);box-sizing:border-box;overflow-wrap:anywhere} @@ -2322,7 +2397,7 @@ diff --git a/client.js b/client.js .bt-msg:not(:first-child)>.bt-msg-line>.bt-bubble{border-start-start-radius:6px} .bt-msg:not(:last-child)>.bt-msg-line>.bt-bubble{border-end-start-radius:6px} .bt-bubble [class*="_markdown"]>*+*{margin-top:10px} -@@ -2085,6 +2087,7 @@ button.bt-q-opt:active{background:var(--bt-active)} +@@ -2085,6 +2085,7 @@ button.bt-q-opt:active{background:var(--bt-active)} @media (prefers-reduced-motion:reduce){.bt-q{animation:bt-fade .12s ease-out backwards}.bt-q-settled{animation:none}.bt-q-submit{animation:none}} .bt-newchat{flex:1;display:flex;flex-direction:column;min-width:0;position:relative;animation:bt-veil-in .18s ease-out} @@ -2330,7 +2405,7 @@ diff --git a/client.js b/client.js .bt-to{display:flex;align-items:center;flex-wrap:wrap;gap:6px;min-height:48px;padding:6px 12px;border-bottom:1px solid var(--bt-line);font-size:14px;box-sizing:border-box} .bt-to-label{color:var(--bt-ink-2)} .bt-to input{flex:1;min-width:120px;border:0;outline:none;background:none;color:var(--bt-ink);font-size:14px;height:26px} -@@ -2098,7 +2100,9 @@ button.bt-q-opt:active{background:var(--bt-active)} +@@ -2098,7 +2098,9 @@ button.bt-q-opt:active{background:var(--bt-active)} .bt-option[aria-selected=true]{background:var(--bt-active)} .bt-drop-list>.bt-option:not([aria-selected=true]):hover{background:var(--bt-hover)} .bt-option-label{flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} @@ -2341,7 +2416,7 @@ diff --git a/client.js b/client.js .bt-option-icon{width:22px;height:22px;border-radius:50%;background:var(--bt-hover);color:var(--bt-ink-2);display:inline-flex;align-items:center;justify-content:center;flex:none} .bt-hint{padding:14px;text-align:center;font-size:13px;color:var(--bt-ink-2)} .bt-nc-spacer{flex:1} -@@ -2127,6 +2129,10 @@ button.bt-q-opt:active{background:var(--bt-active)} +@@ -2127,6 +2127,10 @@ button.bt-q-opt:active{background:var(--bt-active)} .bt-send{height:40px;min-width:40px;border-radius:999px;border:0;background:var(--bt-accent);color:var(--bt-accent-ink,#fff);font-size:14px;padding:0 14px;cursor:pointer} .bt-send:disabled{opacity:.35;cursor:default} .bt-error{padding:0 14px 10px;color:#e02135;font-size:12px} @@ -2352,7 +2427,7 @@ diff --git a/client.js b/client.js .bt-pane{position:fixed;top:var(--dsh-frame-chrome-top,0px);right:0;bottom:0;z-index:45;pointer-events:auto;background:var(--bt-main);color:var(--bt-ink);display:flex;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif} .bt-dialog{flex:1;display:flex;flex-direction:column;min-width:0} -@@ -2163,6 +2165,8 @@ button.bt-q-opt:active{background:var(--bt-active)} +@@ -2163,6 +2163,8 @@ button.bt-q-opt:active{background:var(--bt-active)} .bt-drawer-id{display:flex;flex-direction:column;align-items:center;padding:14px 16px 0} .bt-drawer-id>.bt-mark,.bt-drawer-id>.bt-cluster{margin-bottom:16px} .bt-drawer-name{font-size:17px;line-height:24px;font-weight:500;border:0;background:none;color:inherit;cursor:text;padding:0 6px;border-radius:6px;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} @@ -2361,7 +2436,7 @@ diff --git a/client.js b/client.js .bt-drawer-name:hover{background:var(--bt-hover)} .bt-drawer-name-input{font-size:17px;line-height:24px;text-align:center;border:.5px solid var(--bt-line-2);border-radius:6px;padding:0 6px;background:var(--bt-main);color:var(--bt-ink);outline:none;font-weight:500} .bt-drawer-role{font-size:12px;line-height:16px;color:var(--bt-ink-3);margin-top:6px;text-align:center} -@@ -2255,6 +2257,7 @@ body[data-ds-dark-theme] .bt-scrim{background:rgba(0,0,0,.5)} +@@ -2255,6 +2255,7 @@ body[data-ds-dark-theme] .bt-scrim{background:rgba(0,0,0,.5)} .bt-settings-nav>.bt-thumb{left:8px;right:8px;border-radius:8px;background:var(--bt-active)} .bt-settings-nav[data-glide] .bt-settings-item[aria-current=page]{background:transparent} .bt-settings-shell{margin-top:auto} @@ -2369,7 +2444,7 @@ diff --git a/client.js b/client.js /* Usage while another page is open: out of the flow at Usage's own width, so it keeps its layout, and not rendered. */ .bt-settings-usage[data-off]{position:absolute;top:0;left:198px;width:calc(min(1100px,calc(100vw - 48px)) - 198px);height:100%;box-sizing:border-box;content-visibility:hidden;pointer-events:none} -@@ -2308,8 +2310,17 @@ button.bt-set-row:hover{background:var(--bt-hover)} +@@ -2308,8 +2308,17 @@ button.bt-set-row:hover{background:var(--bt-hover)} .bt-connector-actions{flex:none;display:flex;gap:6px} .bt-connector-form{display:flex;flex-direction:column;gap:8px;padding-left:48px;animation:bt-q-in ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing},bt-veil-in .2s ease-out} .bt-connector-form-row{display:flex;align-items:center;gap:6px} @@ -2387,7 +2462,7 @@ diff --git a/client.js b/client.js /* Shell chrome reshaped for the team view. Selectors use stable data attributes where the shell offers them and CSS-module name suffixes elsewhere. */ -@@ -2427,7 +2429,8 @@ button:hover .bt-you{background:color-mix(in srgb,var(--bt-ink) 9%,var(--bt-side +@@ -2427,7 +2427,8 @@ button:hover .bt-you{background:color-mix(in srgb,var(--bt-ink) 9%,var(--bt-side .bt-palette-empty-label{font-size:14px;font-weight:500;color:var(--bt-ink-2)} .bt-palette-empty-hint{font-size:13px;line-height:16px;color:var(--bt-ink-3)} @media (prefers-reduced-motion:reduce){.bt-palette{animation:bt-fade .1s ease-out}} @@ -2397,7 +2472,7 @@ diff --git a/client.js b/client.js .bt-account{flex:none;width:36px;height:36px;border:0;padding:0;background:none;border-radius:50%;cursor:pointer;display:inline-flex} .bt-account:hover .bt-you,.bt-account[aria-expanded=true] .bt-you{background:color-mix(in srgb,var(--bt-ink) 9%,var(--bt-sidebar))} /* Popover keyframes move translate and scale, which compose with a transform the popover -@@ -2599,6 +2601,14 @@ var CONVERSATION_CSS = ` +@@ -2599,6 +2599,14 @@ var CONVERSATION_CSS = ` .bt-think{font-size:13px;line-height:18px;color:var(--bt-ink-2);margin:2px 0 4px;max-width:min(560px,78%)} .bt-think summary{cursor:pointer;list-style:none;display:inline-flex;align-items:center;gap:6px} .bt-think summary::-webkit-details-marker{display:none} @@ -2412,7 +2487,7 @@ diff --git a/client.js b/client.js .bt-think-text{white-space:pre-wrap;margin-top:6px;padding-left:10px;border-left:2px solid var(--bt-line-2)} .bt-event{display:flex;justify-content:center;align-items:center;gap:2px;min-height:24px;margin:2px 0;font-size:12px;line-height:16px;color:var(--bt-ink-2);flex-wrap:wrap} .bt-event>span{padding:0 2px} -@@ -2612,7 +2614,6 @@ var CONVERSATION_CSS = ` +@@ -2612,7 +2612,6 @@ var CONVERSATION_CSS = ` .bt-event-whole>.bt-event-name{flex:0 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .bt-time-sep{display:flex;justify-content:center;align-items:center;height:28px;margin:14px 0 8px;font-size:12px;line-height:16px;color:var(--bt-ink-2);white-space:nowrap} [class*="_scroll"]>[class*="_column"]::before{content:var(--bt-first-sep,none);display:block;flex:none;height:28px;margin:17px 0 12px;font-size:12px;line-height:28px;text-align:center;color:var(--bt-ink-2);white-space:nowrap} @@ -2420,7 +2495,7 @@ diff --git a/client.js b/client.js .bt-group-replies{display:contents} /* A Bot chat is flat in every work-details mode, without changing the user's mode (it is a DSH-wide setting): step groups lose their header and stay open, and what a -@@ -2622,21 +2624,23 @@ var CONVERSATION_CSS = ` +@@ -2622,21 +2622,23 @@ var CONVERSATION_CSS = ` [data-step-process]>[data-step-process-body]{display:block!important;content-visibility:visible!important;max-height:none!important;overflow:visible!important;mask-image:none!important;scrollbar-gutter:auto!important} :is([data-step-process],[data-turn-process-hidden])[hidden]{content-visibility:visible!important} [class*="_flowItem"]:has(> [data-slot="conversation.chat.node"]:empty){display:none!important} @@ -2452,7 +2527,7 @@ diff --git a/client.js b/client.js [data-content-phase=hero] [class*="_composerHero"]{align-self:stretch!important;width:auto!important;padding-bottom:0!important} [data-content-phase=hero] [class*="_composerHero"]>[class$="_root"],[data-content-phase=hero] [class*="_heroWorkspaceRow"]{display:none!important} [data-content-phase=hero] [class*="_composerHero"] [class*="_input"]{min-height:0!important} -@@ -2648,6 +2650,9 @@ header[class*="_header"] .bt-head{flex:1 1 auto;width:100%} +@@ -2648,6 +2648,9 @@ header[class*="_header"] .bt-head{flex:1 1 auto;width:100%} [data-chat-running]:has(> .bt-typing){margin-top:var(--dsh-chat-flow-gap,12px)!important} .bt-typing{display:flex;align-items:center;gap:12px;height:36px;min-width:0;padding-inline-start:8px;transform-origin:0 50%;animation:bt-typing-in ${SPRING_TAP.duration}ms ${SPRING_TAP.easing} backwards} .bt-typing[data-exiting]{animation:bt-typing-out .14s cubic-bezier(.22,1,.36,1) forwards} @@ -2462,7 +2537,7 @@ diff --git a/client.js b/client.js .bt-typing>.bt-mark,.bt-typing>.bt-cluster{flex-shrink:0} .bt-typing>.bt-mark{cursor:pointer;transition:filter .16s ease-out} .bt-typing>.bt-mark:hover{filter:drop-shadow(0 2px 4px rgba(0,0,0,.18))} -@@ -2679,7 +2681,8 @@ header[class*="_header"] .bt-head{flex:1 1 auto;width:100%} +@@ -2679,7 +2679,8 @@ header[class*="_header"] .bt-head{flex:1 1 auto;width:100%} @keyframes bt-fade-in{from{opacity:0}to{opacity:1}} @keyframes bt-fade-out{from{opacity:1}to{opacity:0}} [data-composer-card]{display:flex!important;flex-direction:row;flex-wrap:wrap;align-items:center;gap:0!important;min-height:44px;border-radius:22px!important;padding:7.5px!important;box-sizing:border-box;background:var(--bt-main)!important;border:.5px solid var(--bt-line-2)!important;box-shadow:0 2px 8px -1px rgba(0,0,0,.05),0 1px 2px rgba(0,0,0,.03),0 0 0 1px rgba(228,228,228,.04)!important;transition:border-color .15s ease,background-color .15s ease,${spring(SPRING_SHAPE, "border-radius")}!important} @@ -2472,7 +2547,7 @@ diff --git a/client.js b/client.js [data-composer-card]:has(> [data-slot="conversation.input.attachments"] *),[data-composer-card]:has([data-composer-input] br + *),[data-composer-card]:has([data-composer-input] > :nth-child(2)){border-radius:18px!important} [class*="_root"]:has(> [data-composer-card]){padding:0 11px 13px 16px!important} [class*="_root"]:has(> [data-composer-card])>[data-composer-card]{max-width:min(992px,100%)!important} -@@ -2691,6 +2693,8 @@ header[class*="_header"] .bt-head{flex:1 1 auto;width:100%} +@@ -2691,6 +2691,8 @@ header[class*="_header"] .bt-head{flex:1 1 auto;width:100%} [data-composer-card] [data-composer-input]{min-height:24px;padding:2px 0!important} [data-composer-card] [data-composer-placeholder]{left:0!important;top:2px!important} [data-composer-placeholder]{font-size:0!important;line-height:0!important} @@ -2481,7 +2556,7 @@ diff --git a/client.js b/client.js [data-composer-placeholder]::after{content:var(--bt-placeholder,"Message");display:block;font-size:14px;line-height:24px;color:color-mix(in srgb,var(--bt-ink) 36%,transparent)} /* The team's "+" (ComposerPlus) takes the seat of the shell's command-menu button. */ [data-composer-card] button[class*="_add"]{display:none!important} -@@ -2722,6 +2724,7 @@ header[class*="_header"] .bt-head{flex:1 1 auto;width:100%} +@@ -2722,6 +2722,7 @@ header[class*="_header"] .bt-head{flex:1 1 auto;width:100%} .bt-mic:active,.bt-voice:active{scale:.9} [data-composer-card]:has(button[class*="_primary"]:disabled) .bt-voice{display:inline-grid} body[data-bt-room] .bt-voice{display:none!important} @@ -2489,7 +2564,7 @@ diff --git a/client.js b/client.js body[data-bt-room] [data-composer-card]:has(button[class*="_primary"]:disabled) .bt-mic:not([aria-pressed=true]){background:var(--bt-accent);color:var(--bt-accent-ink,#fff)} .bt-mic-note{position:absolute;right:12px;bottom:calc(100% + 8px);padding:5px 10px;border-radius:8px;background:var(--bt-user);color:var(--bt-user-ink);font-size:12px;line-height:16px;white-space:nowrap;pointer-events:none;animation:bt-rise .16s ease-out} [data-chain-overlay-fallback="conversation.composer"]:has(~ .bt-q){display:block!important;order:2} -@@ -2776,6 +2778,7 @@ div:has(> nav [class*="_marks"]){display:none!important} +@@ -2776,6 +2776,7 @@ div:has(> nav [class*="_marks"]){display:none!important} .bt-earlier-top{display:flex;justify-content:center;align-items:center;height:34px;flex:none} .bt-earlier-top .bt-soft{height:28px;font-size:12px;padding:0 12px} .bt-urow{display:flex;justify-content:flex-end} @@ -2497,7 +2572,7 @@ diff --git a/client.js b/client.js .bt-ububble{max-width:min(560px,78%);background:var(--bt-user);color:var(--bt-user-ink);border-radius:18px;padding:7px 12px;font-size:14px;line-height:20px;box-sizing:border-box;overflow-wrap:anywhere} .bt-ububble [class*="_markdown"],.bt-ububble a{color:inherit!important} .bt-ububble p{margin:0;line-height:22px} -@@ -2855,226 +2857,203 @@ function installCompiledStyles(text, tag) { +@@ -2855,226 +2855,203 @@ function installCompiledStyles(text, tag) { }; } @@ -2593,10 +2668,6 @@ diff --git a/client.js b/client.js - 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 uniqueOwners(owners) { + const seen = /* @__PURE__ */ new Set(); + const out = []; @@ -2630,13 +2701,7 @@ diff --git a/client.js b/client.js + if (people2.length) bits.push(people2.join(", ")); + bits.push(join.length === 6 ? join : when3); + return bits.join(" · "); - } --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" }) -- ); ++} + +// src/client/brand.js +var FLYBOT_WING = "M150 238 C 96 200, 40 196, 22 214 C 8 230, 40 262, 150 300 Z"; @@ -2662,9 +2727,7 @@ diff --git a/client.js b/client.js + if (room2 && room2.origin === "face-to-face" && (room2.face?.status === "closed" || room2.face?.status === "left") && isAutoFaceTitle(room2.name)) return "Face-to-face · closed"; + if (room2) return room2.displayName || (room2.sharedFrom ? sharedRoomTitle(room2.sharedFrom.inviterName) : room2.name) || ""; + return bot?.name || ""; - } --function AdminStar() { -- return (0, import_react2.createElement)("span", { className: "bt-admin-star", role: "img", "aria-label": t("Admin") }, (0, import_react2.createElement)(StarIcon, { size: 7 })); ++} +function installTabTitle(sessions, roster, doc = globalThis.document) { + if (!doc || !sessions || !roster) return () => { + }; @@ -2681,11 +2744,59 @@ diff --git a/client.js b/client.js + return () => { + offSessions?.(); + offRoster?.(); + }; +- 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 installBrandChrome() { ++ if (typeof document === "undefined") return () => { ++ }; ++ const href = `data:image/svg+xml,${encodeURIComponent(flybotMarkSvg())}`; ++ let link = document.getElementById("flybot-favicon"); ++ if (!link) { ++ link = document.createElement("link"); ++ link.id = "flybot-favicon"; ++ link.rel = "icon"; ++ link.type = "image/svg+xml"; ++ document.head.appendChild(link); ++ } ++ link.href = href; ++ const retitle = () => { ++ const next = brandTitle(document.title); ++ if (next !== document.title) document.title = next; ++ }; ++ retitle(); ++ const observer = typeof MutationObserver === "function" ? new MutationObserver(retitle) : null; ++ const node = document.querySelector("title") ?? document.head; ++ observer?.observe(node, { childList: true, characterData: true, subtree: true }); ++ return () => { ++ observer?.disconnect(); ++ link.remove(); + }; } +-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/icons.js ++var import_react4 = require("react"); ++function PlusIcon() { ++ return (0, import_react4.createElement)("svg", { width: 18, height: 18, viewBox: "0 0 20 20", "aria-hidden": true }, (0, import_react4.createElement)("path", { d: "M10 3.5v13M3.5 10h13", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round" })); + } -function MainStar() { - return (0, import_react2.createElement)( -- "svg", ++function SearchIcon() { ++ return (0, import_react4.createElement)("svg", { width: 16, height: 16, viewBox: "0 0 20 20", "aria-hidden": true }, (0, import_react4.createElement)("circle", { cx: 8.5, cy: 8.5, r: 5.5, stroke: "currentColor", strokeWidth: 1.6, fill: "none" }), (0, import_react4.createElement)("path", { d: "M13 13l4 4", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round" })); ++} ++function PeopleIcon() { ++ return (0, import_react4.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" }) - ); @@ -2763,52 +2874,24 @@ diff --git a/client.js b/client.js - return (0, import_react2.createElement)("span", { key: bot?.id ?? "you", style }, seat(bot, s)); - }) - ); -+function installBrandChrome() { -+ if (typeof document === "undefined") return () => { -+ }; -+ const href = `data:image/svg+xml,${encodeURIComponent(flybotMarkSvg())}`; -+ let link = document.getElementById("flybot-favicon"); -+ if (!link) { -+ link = document.createElement("link"); -+ link.id = "flybot-favicon"; -+ link.rel = "icon"; -+ link.type = "image/svg+xml"; -+ document.head.appendChild(link); -+ } -+ link.href = href; -+ const retitle = () => { -+ const next = brandTitle(document.title); -+ if (next !== document.title) document.title = next; -+ }; -+ retitle(); -+ const observer = typeof MutationObserver === "function" ? new MutationObserver(retitle) : null; -+ const node = document.querySelector("title") ?? document.head; -+ observer?.observe(node, { childList: true, characterData: true, subtree: true }); -+ return () => { -+ observer?.disconnect(); -+ link.remove(); -+ }; - } - - // src/client/icons.js +-} +- +-// src/client/icons.js -var import_react3 = require("react"); -+var import_react4 = require("react"); - function PlusIcon() { +-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" })); -+ return (0, import_react4.createElement)("svg", { width: 18, height: 18, viewBox: "0 0 20 20", "aria-hidden": true }, (0, import_react4.createElement)("path", { d: "M10 3.5v13M3.5 10h13", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round" })); - } - function SearchIcon() { +-} +-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" })); -+ return (0, import_react4.createElement)("svg", { width: 16, height: 16, viewBox: "0 0 20 20", "aria-hidden": true }, (0, import_react4.createElement)("circle", { cx: 8.5, cy: 8.5, r: 5.5, stroke: "currentColor", strokeWidth: 1.6, fill: "none" }), (0, import_react4.createElement)("path", { d: "M13 13l4 4", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round" })); - } - function PeopleIcon() { +-} +-function PeopleIcon() { - return (0, import_react3.createElement)( -+ return (0, import_react4.createElement)( - "svg", - { width: 14, height: 14, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", "aria-hidden": true }, +- "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" }) ++ { width: 14, height: 14, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", "aria-hidden": true }, + (0, import_react4.createElement)("circle", { cx: 7.5, cy: 7, r: 3 }), + (0, import_react4.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_react4.createElement)("path", { d: "M13 4.3a3 3 0 0 1 0 5.4M15 12.4c1.6.6 2.6 2 3 4.1" }) @@ -2877,7 +2960,7 @@ diff --git a/client.js b/client.js ); } var SmileIcon = lineIcon(["M10 3.5a6.5 6.5 0 1 1 0 13 6.5 6.5 0 0 1 0-13z", "M7.3 11.6a3.4 3.4 0 0 0 5.4 0", "M7.75 8.25h.01M12.25 8.25h.01"]); -@@ -3093,14 +3095,14 @@ var ClockIcon = lineIcon(["M10 3.5a6.5 6.5 0 1 1 0 13 6.5 6.5 0 0 1 0-13z", "M10 +@@ -3093,14 +3093,14 @@ var ClockIcon = lineIcon(["M10 3.5a6.5 6.5 0 1 1 0 13 6.5 6.5 0 0 1 0-13z", "M10 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() { @@ -2896,7 +2979,7 @@ diff --git a/client.js b/client.js ) ); } -@@ -3112,21 +3114,48 @@ var CopyIcon = lineIcon(["M8.5 7h6A1.5 1.5 0 0 1 16 8.5v6a1.5 1.5 0 0 1-1.5 1.5h +@@ -3112,21 +3112,48 @@ var CopyIcon = lineIcon(["M8.5 7h6A1.5 1.5 0 0 1 16 8.5v6a1.5 1.5 0 0 1-1.5 1.5h 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"]); @@ -2948,7 +3031,7 @@ diff --git a/client.js b/client.js if (count === 0) return void 0; const onKey = (event) => { if (event.defaultPrevented || event.metaKey || event.ctrlKey || event.altKey || event.shiftKey || event.key.length !== 1) return; -@@ -3146,10 +3148,10 @@ function useOptionKeys(count, onPick, ownRef) { +@@ -3146,10 +3146,10 @@ function useOptionKeys(count, onPick, ownRef) { }, [count, ownRef]); } function QuestionView({ question, onAnswer, onDismiss, ownAnswer = false, raised = false }) { @@ -2963,7 +3046,7 @@ diff --git a/client.js b/client.js const options = optionsOf(question); const multi = question.multiSelect === true; const custom = own.trim(); -@@ -3167,7 +3169,7 @@ function QuestionView({ question, onAnswer, onDismiss, ownAnswer = false, raised +@@ -3167,7 +3167,7 @@ function QuestionView({ question, onAnswer, onDismiss, ownAnswer = false, raised }; useOptionKeys(options.length, (index) => choose(options[index].label), ownRef); const ownLetter = options.length > 0 ? keyLetter(options.length) : void 0; @@ -2972,7 +3055,7 @@ diff --git a/client.js b/client.js ref: ownRef, rows: 1, value: own, -@@ -3186,7 +3188,7 @@ function QuestionView({ question, onAnswer, onDismiss, ownAnswer = false, raised +@@ -3186,7 +3186,7 @@ function QuestionView({ question, onAnswer, onDismiss, ownAnswer = false, raised }) : null; const rows = options.map((option, optionIndex) => { const selected = multi && picked.includes(option.label); @@ -2981,7 +3064,7 @@ diff --git a/client.js b/client.js "button", { key: option.label, -@@ -3196,36 +3198,36 @@ function QuestionView({ question, onAnswer, onDismiss, ownAnswer = false, raised +@@ -3196,36 +3196,36 @@ function QuestionView({ question, onAnswer, onDismiss, ownAnswer = false, raised "aria-keyshortcuts": keyLetter(optionIndex).toLowerCase(), onClick: () => choose(option.label) }, @@ -3031,7 +3114,7 @@ diff --git a/client.js b/client.js "form", { className: raised ? "bt-q bt-q-raised" : "bt-q", -@@ -3235,24 +3237,24 @@ function QuestionView({ question, onAnswer, onDismiss, ownAnswer = false, raised +@@ -3235,24 +3235,24 @@ function QuestionView({ question, onAnswer, onDismiss, ownAnswer = false, raised submit(); } }, @@ -3066,7 +3149,7 @@ diff --git a/client.js b/client.js ) : null, foot ); -@@ -3262,29 +3264,29 @@ function SettledQuestion({ question, answer }) { +@@ -3262,29 +3262,29 @@ function SettledQuestion({ question, answer }) { const prompt = question?.question ?? ""; const chosen = [...answer.selected ?? [], ...answer.custom ? [answer.custom] : []]; if (chosen.length === 0) { @@ -3107,7 +3190,7 @@ diff --git a/client.js b/client.js ); })) ); -@@ -3292,8 +3294,8 @@ function SettledQuestion({ question, answer }) { +@@ -3292,8 +3292,8 @@ function SettledQuestion({ question, answer }) { function QuestionCard({ matched }) { const pending = matched; const questions = pending.questions ?? []; @@ -3118,7 +3201,7 @@ diff --git a/client.js b/client.js const question = questions[index]; if (question === void 0) return null; const onAnswer = (answer) => { -@@ -3305,10 +3307,10 @@ function QuestionCard({ matched }) { +@@ -3305,10 +3305,10 @@ function QuestionCard({ matched }) { } pending.answer({ answers: next }); }; @@ -3131,7 +3214,7 @@ diff --git a/client.js b/client.js if (answered === question.id) return null; const onAnswer = (answer) => { setAnswered(question.id); -@@ -3318,10 +3320,10 @@ function PendingQuestion({ sessionId, question, actions, inline }) { +@@ -3318,10 +3318,10 @@ function PendingQuestion({ sessionId, question, actions, inline }) { setAnswered(question.id); void actions.dismissQuestion(sessionId); }; @@ -3144,7 +3227,7 @@ diff --git a/client.js b/client.js ); } function QuestionDock({ sessionId, useRoster, useCards, useSession, actions }) { -@@ -3329,16 +3331,16 @@ function QuestionDock({ sessionId, useRoster, useCards, useSession, actions }) { +@@ -3329,16 +3329,16 @@ function QuestionDock({ sessionId, useRoster, useCards, useSession, actions }) { 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; @@ -3164,7 +3247,7 @@ diff --git a/client.js b/client.js key: question.id, question, answer: answers.find((answer) => answer.id === question.id) ?? { selected: [] } -@@ -3346,37 +3348,37 @@ function AnsweredCell({ node }) { +@@ -3346,37 +3346,37 @@ function AnsweredCell({ node }) { } // src/client/secret-card.js @@ -3214,7 +3297,7 @@ diff --git a/client.js b/client.js className: "bt-secret-input", type: MASK_BY_CSS ? "text" : "password", "data-masked": MASK_BY_CSS ? "" : void 0, -@@ -3401,12 +3403,12 @@ function MaskedInput({ value, onChange, onEnter, label, autoFocus = false }) { +@@ -3401,12 +3401,12 @@ function MaskedInput({ value, onChange, onEnter, label, autoFocus = false }) { }); } function ScopeSwitch({ value, onChange, bot }) { @@ -3231,7 +3314,7 @@ diff --git a/client.js b/client.js key: id, type: "button", role: "radio", -@@ -3417,26 +3419,26 @@ function ScopeSwitch({ value, onChange, bot }) { +@@ -3417,26 +3417,26 @@ function ScopeSwitch({ value, onChange, bot }) { ); } function SecretHead({ bot, title, detail, name, onClose }) { @@ -3270,7 +3353,7 @@ diff --git a/client.js b/client.js const secret = (roster.secrets ?? []).find((entry) => entry.name === request.name); const run = (promise) => { setBusy(true); -@@ -3452,73 +3454,73 @@ function SecretCard({ bot, request, roster, actions }) { +@@ -3452,73 +3452,73 @@ function SecretCard({ bot, request, roster, actions }) { if (!busy) void run(actions.secretAllow(request.id)); }; const filling = request.kind === "fill" || replacing; @@ -3367,7 +3450,7 @@ diff --git a/client.js b/client.js } return null; } -@@ -3531,44 +3533,46 @@ function SecretDock({ sessionId, useRoster, useCards, useSession, actions }) { +@@ -3531,44 +3531,46 @@ function SecretDock({ sessionId, useRoster, useCards, useSession, actions }) { 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; @@ -3429,7 +3512,7 @@ diff --git a/client.js b/client.js const long = value.trim().length >= MIN_VALUE; const save = () => { if (!long) return; -@@ -3583,66 +3585,66 @@ function ValueEditor({ secret, onSave }) { +@@ -3583,66 +3583,66 @@ function ValueEditor({ secret, onSave }) { setValue(next); setReplaced(false); }; @@ -3522,7 +3605,7 @@ diff --git a/client.js b/client.js const ready = /^[A-Za-z][\w-]{0,63}$/.test(name.trim()) && value.trim().length >= MIN_VALUE; const save = () => { if (!ready) return; -@@ -3653,43 +3655,43 @@ function AddSecret({ actions, run, onDone }) { +@@ -3653,43 +3653,43 @@ function AddSecret({ actions, run, onDone }) { } }); }; @@ -3584,7 +3667,7 @@ diff --git a/client.js b/client.js ]; } var SECRET_CSS = ` -@@ -3756,6 +3758,142 @@ var SECRET_CSS = ` +@@ -3756,6 +3756,142 @@ var SECRET_CSS = ` @media (prefers-reduced-motion:reduce){.bt-secret-glide{transition:none}.bt-secret-check,.bt-secret-done,.bt-secret-ok,.bt-secret-edit{animation:none}} `; @@ -3727,7 +3810,7 @@ diff --git a/client.js b/client.js // src/client/turn-reasoning.js function turnReasoning(steps, current) { const members = steps.length ? steps : [current]; -@@ -3767,7 +3769,7 @@ var thinkingLive = (step) => step.status === "running" && texts(step).length === +@@ -3767,7 +3767,7 @@ var thinkingLive = (step) => step.status === "running" && texts(step).length === var stepReasoning = (step) => step.blocks.filter((block) => block.kind === "reasoning").map((block) => block.text).join("\n").trim(); // src/client/cells.js @@ -3736,7 +3819,7 @@ diff --git a/client.js b/client.js var import_react_dom = require("react-dom"); var import_dsh_client_ui_primitives = require("@deepseek-ai/dsh-client-ui-primitives"); var mdLabels = () => ({ code: { copyLabel: t("Copy"), copiedLabel: t("Copied") }, footnotes: t("Footnotes") }); -@@ -3780,7 +3782,7 @@ function Bubbles({ texts: texts2, roster, selfId, actions, streaming, sessionId, +@@ -3780,7 +3780,7 @@ function Bubbles({ texts: texts2, roster, selfId, actions, streaming, sessionId, actions.openSession(target); }; const reactions = useUi ? useUi((state) => msgKey ? state.reactions?.[msgKey] ?? null : null) : null; @@ -3745,7 +3828,7 @@ diff --git a/client.js b/client.js "div", { className: "bt-astack", "data-lead": lead ? "" : void 0, onClickCapture: onClick }, texts2.map((text, index) => { -@@ -3788,21 +3790,21 @@ function Bubbles({ texts: texts2, roster, selfId, actions, streaming, sessionId, +@@ -3788,21 +3788,21 @@ function Bubbles({ texts: texts2, roster, selfId, actions, streaming, sessionId, const picked = key ? reactions?.[index] ?? [] : []; const last = index === texts2.length - 1; const live = streaming && last; @@ -3773,7 +3856,7 @@ diff --git a/client.js b/client.js key: emoji, type: "button", className: "bt-react", -@@ -3813,9 +3815,10 @@ function Bubbles({ texts: texts2, roster, selfId, actions, streaming, sessionId, +@@ -3813,9 +3813,10 @@ function Bubbles({ texts: texts2, roster, selfId, actions, streaming, sessionId, }) ); } @@ -3785,7 +3868,7 @@ diff --git a/client.js b/client.js if (!open) return void 0; const onDown = (event) => { if (refs.some((ref) => ref.current?.contains(event.target))) return; -@@ -3836,11 +3838,11 @@ function useDismiss(open, close, refs) { +@@ -3836,11 +3836,11 @@ function useDismiss(open, close, refs) { }, [open]); } function MessageTools({ text, name, sessionId, react, actions }) { @@ -3802,7 +3885,7 @@ diff --git a/client.js b/client.js const close = () => setPanel(null); useDismiss(panel !== null, close, [tools, pop]); const open = (kind, event) => { -@@ -3865,25 +3867,25 @@ function MessageTools({ text, name, sessionId, react, actions }) { +@@ -3865,25 +3865,25 @@ function MessageTools({ text, name, sessionId, react, actions }) { link.click(); setTimeout(() => URL.revokeObjectURL(url), 1e3); }; @@ -3837,7 +3920,7 @@ diff --git a/client.js b/client.js "div", { ref: pop, className: "bt-menu", role: "menu", "aria-label": t("Message actions"), style: place }, item(t("Copy"), CopyIcon, copy2), -@@ -3892,11 +3894,12 @@ function MessageTools({ text, name, sessionId, react, actions }) { +@@ -3892,11 +3892,12 @@ function MessageTools({ text, name, sessionId, react, actions }) { ); } function ThinkingRow({ text }) { @@ -3853,7 +3936,7 @@ diff --git a/client.js b/client.js ); } function useFileMentions({ node, openFile, fileMentions, useTurnData }) { -@@ -3904,19 +3906,33 @@ function useFileMentions({ node, openFile, fileMentions, useTurnData }) { +@@ -3904,19 +3904,33 @@ function useFileMentions({ node, openFile, fileMentions, useTurnData }) { const location2 = node.location; const turn = location2?.kind === "turn" || location2?.kind === "step" ? location2.turn : void 0; const seq = node.data.finalNode?.seq; @@ -3889,7 +3972,7 @@ diff --git a/client.js b/client.js const running = data.status === "running"; useLiveNote(actions, sessionId, `text:${node.key ?? data.seq}`, !hidden && running && texts2.length > 0 ? { text: true } : null); if (hidden) return null; -@@ -3924,13 +3926,14 @@ function AssistantCell({ node, groupPart, sessionId, openFile, fileMentions, use +@@ -3924,13 +3924,14 @@ function AssistantCell({ node, groupPart, sessionId, openFile, fileMentions, use 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; @@ -3907,7 +3990,7 @@ diff --git a/client.js b/client.js key: relay.seq, prefix: source.role === "stopped" ? t("Stopped by") : source.role === "reply" ? t("Reply from") : t("Message from"), bots: [sender], -@@ -3940,61 +3942,230 @@ function AssistantCell({ node, groupPart, sessionId, openFile, fileMentions, use +@@ -3940,79 +3940,248 @@ function AssistantCell({ node, groupPart, sessionId, openFile, fileMentions, use whole: true }); }), @@ -4102,8 +4185,14 @@ diff --git a/client.js b/client.js - (0, import_react6.createElement)(Bubbles, { texts: [text], roster, selfId: bot.id, actions, sessionId: room2?.id, msgKey, useUi, lead }) + (0, import_react8.createElement)(Bubbles, { texts: [stripTrace(text)], roster, selfId: bot.id, actions, sessionId, msgKey, useUi, lead }), + files.length ? (0, import_react8.createElement)(RemoteFiles, { files, plugin: member?.plugin ?? "social", botId: roster.byId[sessionId]?.id ?? sessionId }) : null -+ ); -+} + ); + } +-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 FirstContact({ source, roster, sessionId }) { + const person = typeof source.remote.typedBy === "string" ? source.remote.typedBy : ""; + const who = person || remoteSpeaker(source, roster).name; @@ -4116,8 +4205,17 @@ diff --git a/client.js b/client.js + { width: 10, height: 10, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round", "aria-hidden": true }, + (0, import_react8.createElement)("circle", { cx: 12, cy: 12, r: 9 }), + (0, import_react8.createElement)("path", { d: "M3 12h18M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18" }) -+ ); -+} + ); + } +-function MemoryEvent({ sessionId, scope, result, roster, actions, findBot }) { +- if (!/^(Saved to|Updated in|Removed from) /.test(result)) return null; +- const self = roster.byId[sessionId]; +- const team = /^(team|团队|共享)$/i.test(String(scope ?? "").trim()); +- const other = team || !scope ? void 0 : findBot(scope); +- const target = other && other.id !== self?.id ? other : self; +- if (!target) return null; +- const label = team ? t("Team memory updated") : target === self ? t("Memory updated") : t("Updated {name}'s memory", { name: target.name }); +- return (0, import_react6.createElement)( +function RemoteFiles({ files, plugin, botId }) { + const [busy, setBusy] = (0, import_react8.useState)(""); + const [error, setError] = (0, import_react8.useState)(""); @@ -4147,25 +4245,24 @@ diff --git a/client.js b/client.js + `📎 ${file.name} · ${fileSize(file.size)}` + )), + error ? (0, import_react8.createElement)("span", { className: "bt-file-error", role: "alert" }, error) : null - ); - } - function GroupEvent({ prefix, room: room2, name, roster, actions }) { -- return (0, import_react6.createElement)( ++ ); ++} ++function GroupEvent({ prefix, room: room2, name, roster, actions }) { + return (0, import_react8.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 ++ "div", ++ { className: "bt-event" }, + (0, import_react8.createElement)("span", null, prefix), + room2 ? (0, import_react8.createElement)("button", { type: "button", onClick: () => actions.openSession(room2.id) }, (0, import_react8.createElement)(RoomAvatar, { room: room2, roster, size: 16 }), room2.name) : name ? (0, import_react8.createElement)("span", null, name) : null - ); - } - function MemoryEvent({ sessionId, scope, result, roster, actions, findBot }) { -@@ -4005,14 +4007,14 @@ function MemoryEvent({ sessionId, scope, result, roster, actions, findBot }) { - const target = other && other.id !== self?.id ? other : self; - if (!target) return null; - const label = team ? t("Team memory updated") : target === self ? t("Memory updated") : t("Updated {name}'s memory", { name: target.name }); -- return (0, import_react6.createElement)( ++ ); ++} ++function 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_react8.createElement)( "div", { className: "bt-event" }, @@ -4179,7 +4276,7 @@ diff --git a/client.js b/client.js } var scrollParent = (node) => { for (let at = node.parentElement; at; at = at.parentElement) { -@@ -4021,11 +4023,11 @@ var scrollParent = (node) => { +@@ -4021,11 +4021,11 @@ var scrollParent = (node) => { return null; }; function EarlierParts({ sessionId, roster, actions }) { @@ -4196,7 +4293,7 @@ diff --git a/client.js b/client.js const load = (cursor) => { if (busy.current) return; busy.current = true; -@@ -4041,19 +4043,19 @@ function EarlierParts({ sessionId, roster, actions }) { +@@ -4041,19 +4041,19 @@ function EarlierParts({ sessionId, roster, actions }) { if (alive.current) setView((current) => ({ ...current, loading: false, failed: true })); }); }; @@ -4219,7 +4316,7 @@ diff --git a/client.js b/client.js const node = top.current; if (!node || view.loading || view.failed || !view.cursor || typeof IntersectionObserver !== "function") return void 0; const observer = new IntersectionObserver((entries2) => { -@@ -4069,35 +4071,35 @@ function EarlierParts({ sessionId, roster, actions }) { +@@ -4069,35 +4069,35 @@ function EarlierParts({ sessionId, roster, actions }) { if (item.kind === "bot" && last?.kind === "bot") last.texts.push(item.text); else rows.push(item.kind === "bot" ? { kind: "bot", texts: [item.text] } : item); } @@ -4266,7 +4363,7 @@ diff --git a/client.js b/client.js if (selfId && other.id) actions.openExchange(selfId, other.id); }, whole: true }); } -@@ -4105,7 +4107,7 @@ function EarlierParts({ sessionId, roster, actions }) { +@@ -4105,7 +4105,7 @@ function EarlierParts({ sessionId, roster, actions }) { return null; } }), @@ -4275,7 +4372,7 @@ diff --git a/client.js b/client.js ); } function TriggerCell({ node, sessionId, useRoster, useSessions, useTurnClock, useUi, actions }) { -@@ -4116,7 +4118,7 @@ function TriggerCell({ node, sessionId, useRoster, useSessions, useTurnClock, us +@@ -4116,7 +4116,7 @@ function TriggerCell({ node, sessionId, useRoster, useSessions, useTurnClock, us const deferred = source.kind === "bot" && source.deferred === true; const relayed = isRelayContext(data); const turn = node.location?.turn?.turn ?? data.turn; @@ -4284,7 +4381,7 @@ diff --git a/client.js b/client.js if (relayed) actions.noteTurnPeer(sessionId, turn, source.senderSessionId); }, [relayed, sessionId, turn, source.senderSessionId]); const order = useSessions((list) => { -@@ -4135,23 +4137,31 @@ function TriggerCell({ node, sessionId, useRoster, useSessions, useTurnClock, us +@@ -4135,23 +4135,31 @@ function TriggerCell({ node, sessionId, useRoster, useSessions, useTurnClock, us return first?.turn === turn && unseenSince(visit, first.start) ? clockLabel(first.start) : ""; }); const body = triggerBody(); @@ -4323,7 +4420,7 @@ diff --git a/client.js b/client.js const run = relayed ? commRunOf( order.split(",").filter(Boolean).map(Number), Object.fromEntries(peers.split(",").filter(Boolean).map((pair) => [Number(pair.slice(0, pair.indexOf(":"))), pair.slice(pair.indexOf(":") + 1)])), -@@ -4165,7 +4167,7 @@ function TriggerCell({ node, sessionId, useRoster, useSessions, useTurnClock, us +@@ -4165,7 +4165,7 @@ function TriggerCell({ node, sessionId, useRoster, useSessions, useTurnClock, us for (const peer of (run?.peerIds ?? []).map((id) => roster.byId[id]).filter(Boolean)) { if (!bots.some((bot) => bot.id === peer.id)) bots.push(peer); } @@ -4332,7 +4429,7 @@ diff --git a/client.js b/client.js prefix: source.role === "stopped" ? t("Stopped by") : count > 1 ? t("{count} messages with", { count }) : t("Message from"), bots, onOpen: (bot) => actions.openExchange(selfId, bot.id), -@@ -4174,24 +4176,24 @@ function TriggerCell({ node, sessionId, useRoster, useSessions, useTurnClock, us +@@ -4174,24 +4174,24 @@ function TriggerCell({ node, sessionId, useRoster, useSessions, useTurnClock, us } if (source.kind === "schedule") { const prompts = reminderPrompts(contentText(data.content)); @@ -4362,7 +4459,7 @@ diff --git a/client.js b/client.js actions.noteTurnTime(sessionId, turn, "first", start); }, [sessionId, turn, start]); const visit = useUi((state) => state.visits?.[sessionId]); -@@ -4199,11 +4201,32 @@ function TurnProcessCell({ node, sessionId, turnProcess, useRoster, useUi, useTu +@@ -4199,11 +4199,32 @@ function TurnProcessCell({ node, sessionId, turnProcess, useRoster, useUi, useTu 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; }); @@ -4396,7 +4493,7 @@ 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); -@@ -4233,10 +4235,19 @@ function openingTurn(turns, firstTurns, hiddenTurns) { +@@ -4233,10 +4233,19 @@ function openingTurn(turns, firstTurns, hiddenTurns) { 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; @@ -4417,7 +4514,7 @@ diff --git a/client.js b/client.js actions.noteTurnTime(sessionId, turn, "end", time); }, [sessionId, turn, time]); const label = useTurnClock((state) => { -@@ -4250,15 +4252,15 @@ function TurnTailCell({ turn: location2, sessionId, useTurnClock, useUi, actions +@@ -4250,15 +4250,15 @@ function TurnTailCell({ turn: location2, sessionId, useTurnClock, useUi, actions return start !== void 0 && start > visit.seen && start <= visit.entered; }); if (!label && !fresh) return null; @@ -4438,7 +4535,7 @@ diff --git a/client.js b/client.js } var PHRASES = { thinking: ["Thinking about it", "Weighing the options", "Working out a plan", "Sorting out the details"], -@@ -4320,19 +4322,22 @@ function toolActivity(name, arg, findBot, self) { +@@ -4320,19 +4320,22 @@ function toolActivity(name, arg, findBot, self) { } function workPhrase(tool) { if (!tool) return t("Thinking about it"); @@ -4466,7 +4563,7 @@ diff --git a/client.js b/client.js if (done) return void 0; let stop = false; let timer; -@@ -4341,10 +4343,10 @@ function GroupReplies({ sessionId, callId, done, content, roster, actions, useUi +@@ -4341,10 +4341,10 @@ function GroupReplies({ sessionId, callId, done, content, roster, actions, useUi const result = await actions.roomProgress(sessionId); if (stop) return; const entry = (result ?? []).find((item) => item.callId === callId) ?? (result ?? [])[0]; @@ -4479,7 +4576,7 @@ diff --git a/client.js b/client.js } timer = setTimeout(tick, document.hidden ? 5e3 : 1e3); }; -@@ -4362,71 +4364,86 @@ function GroupReplies({ sessionId, callId, done, content, roster, actions, useUi +@@ -4362,71 +4362,86 @@ function GroupReplies({ sessionId, callId, done, content, roster, actions, useUi replies = []; } } @@ -4596,7 +4693,7 @@ diff --git a/client.js b/client.js }) ); } -@@ -4435,52 +4437,52 @@ var CARDS = /* @__PURE__ */ new Set(["ask_user", "request_secret"]); +@@ -4435,52 +4435,52 @@ var CARDS = /* @__PURE__ */ new Set(["ask_user", "request_secret"]); function callTree(root) { const calls = []; const seen = /* @__PURE__ */ new Set(); @@ -4665,7 +4762,7 @@ diff --git a/client.js b/client.js case "create_group": case "update_group": case "delete_group": -@@ -4492,64 +4494,72 @@ function renderCall(call2, { sessionId, roster, actions, useUi, useWork, findBot +@@ -4492,64 +4492,72 @@ function renderCall(call2, { sessionId, roster, actions, useUi, useWork, findBot post_to_group: ["Posted in", "Couldn't post in the group"] }[tool]; if (!done) return null; @@ -4755,7 +4852,7 @@ diff --git a/client.js b/client.js var import_react_dom2 = require("react-dom"); var PHRASE_HOLD_MS = 800; var drawPhrase = (pool) => t(pool[Math.min(pool.length - 1, Math.floor(Math.random() * pool.length))]); -@@ -4559,9 +4561,71 @@ function elapsedLabel(ms) { +@@ -4559,9 +4559,71 @@ function elapsedLabel(ms) { const hours = Math.floor(minutes / 60); return minutes % 60 === 0 ? `${hours}h` : `${hours}h ${minutes % 60}m`; } @@ -4830,7 +4927,7 @@ diff --git a/client.js b/client.js if (!active) { setHost(null); return void 0; -@@ -4569,7 +4571,7 @@ function useRunningHost(active) { +@@ -4569,7 +4569,7 @@ function useRunningHost(active) { let frame = 0; const find = () => { frame = 0; @@ -4839,7 +4936,7 @@ diff --git a/client.js b/client.js setHost((prev) => prev === node ? prev : node); }; const observer = new MutationObserver(() => { -@@ -4581,13 +4583,13 @@ function useRunningHost(active) { +@@ -4581,13 +4581,13 @@ function useRunningHost(active) { cancelAnimationFrame(frame); observer.disconnect(); }; @@ -4856,7 +4953,7 @@ diff --git a/client.js b/client.js const seen = /* @__PURE__ */ new WeakSet(); const started = Date.now(); let column = null; -@@ -4624,156 +4626,882 @@ function useRowEntrances(replacing) { +@@ -4624,156 +4624,882 @@ function useRowEntrances(replacing) { const observer = new MutationObserver(() => { if (!frame) frame = requestAnimationFrame(scan); }); @@ -5846,7 +5943,7 @@ diff --git a/client.js b/client.js "button", { type: "button", -@@ -4785,55 +4787,128 @@ function BotRow({ bot, roster, actions, useSessions, useSessionStatus, useSessio +@@ -4785,55 +4785,128 @@ function BotRow({ bot, roster, actions, useSessions, useSessionStatus, useSessio onClick: () => actions.openSession(id), onContextMenu }, @@ -5990,7 +6087,7 @@ diff --git a/client.js b/client.js "data-room-id": id, onClick: () => actions.openSession(id), onContextMenu: (event) => { -@@ -4841,20 +4843,20 @@ function RoomRow({ room: room2, roster, actions, useSessions, useSessionStatus, +@@ -4841,20 +4841,20 @@ function RoomRow({ room: room2, roster, actions, useSessions, useSessionStatus, actions.openMenu({ kind: "room", id, x: event.clientX, y: event.clientY }); } }, @@ -6019,7 +6116,7 @@ diff --git a/client.js b/client.js if (!renaming) return; input.current?.focus(); input.current?.select(); -@@ -4875,10 +4877,10 @@ function AgentSessionRow({ session, agent, actions, renaming, index }) { +@@ -4875,10 +4875,10 @@ function AgentSessionRow({ session, agent, actions, renaming, index }) { } })(); }; @@ -6032,7 +6129,7 @@ diff --git a/client.js b/client.js ref: input, defaultValue: session.title, "aria-label": t("Rename"), -@@ -4891,7 +4893,7 @@ function AgentSessionRow({ session, agent, actions, renaming, index }) { +@@ -4891,7 +4891,7 @@ function AgentSessionRow({ session, agent, actions, renaming, index }) { }) ); } @@ -6041,7 +6138,7 @@ diff --git a/client.js b/client.js "button", { type: "button", -@@ -4905,8 +4907,8 @@ function AgentSessionRow({ session, agent, actions, renaming, index }) { +@@ -4905,8 +4905,8 @@ function AgentSessionRow({ session, agent, actions, renaming, index }) { actions.openMenu({ kind: "agent-session", id: session.id, agentId: agent.id, x: event.clientX, y: event.clientY }); } }, @@ -6052,7 +6149,7 @@ diff --git a/client.js b/client.js ); } function AgentRow({ agent, actions, useSessions, useSessionStatus, useUi, wide, marked }) { -@@ -4941,10 +4943,10 @@ function AgentRow({ agent, actions, useSessions, useSessionStatus, useUi, wide, +@@ -4941,10 +4941,10 @@ function AgentRow({ agent, actions, useSessions, useSessionStatus, useUi, wide, 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; @@ -6065,7 +6162,7 @@ diff --git a/client.js b/client.js "button", { type: "button", -@@ -4959,30 +4961,30 @@ function AgentRow({ agent, actions, useSessions, useSessionStatus, useUi, wide, +@@ -4959,30 +4959,30 @@ function AgentRow({ agent, actions, useSessions, useSessionStatus, useUi, wide, actions.openMenu({ kind: "agent", id: agent.id, x: event.clientX, y: event.clientY }); } }, @@ -6107,7 +6204,7 @@ diff --git a/client.js b/client.js key: session.id, session, agent, -@@ -4996,8 +4998,8 @@ function AgentRow({ agent, actions, useSessions, useSessionStatus, useUi, wide, +@@ -4996,8 +4996,8 @@ function AgentRow({ agent, actions, useSessions, useSessionStatus, useUi, wide, } var GLIDE = springEasing(1e3, 63); function useListGlide(listRef, layout, key) { @@ -6118,7 +6215,7 @@ diff --git a/client.js b/client.js const list = listRef.current; if (!list) return; const next = /* @__PURE__ */ new Map(); -@@ -5014,9 +5016,44 @@ function useListGlide(listRef, layout, key) { +@@ -5014,9 +5014,44 @@ function useListGlide(listRef, layout, key) { tops.current = { layout, map: next }; }, [layout, key]); } @@ -6164,7 +6261,7 @@ diff --git a/client.js b/client.js const roster = useRoster((value) => value); const markedUnread = useUi((value) => value.unread); props.useRegistry((value) => value); -@@ -5025,17 +5027,26 @@ function TeamSidebar(props) { +@@ -5025,17 +5025,26 @@ function TeamSidebar(props) { for (const id of list.ids) stamps[id] = list.byId[id]?.updatedAt ?? 0; return JSON.stringify(stamps); }); @@ -6195,7 +6292,7 @@ diff --git a/client.js b/client.js useListGlide(listRef, wide, order.map((entry) => entry.item.id).join(",")); useLandOnMainBot(roster, useSessions, actions); const rowProps = { roster, actions, useSessions, useUi, useSessionStatus: props.useSessionStatus, useSessionRetainInfo: props.useSessionRetainInfo, useActivity: props.useActivity, useWork: props.useWork, wide }; -@@ -5045,39 +5047,41 @@ function TeamSidebar(props) { +@@ -5045,39 +5045,41 @@ function TeamSidebar(props) { event.preventDefault(); actions.openPalette(event.key); }; @@ -6257,7 +6354,7 @@ diff --git a/client.js b/client.js if (!open) return void 0; const onDown = (event) => { if (event.target instanceof Element && (event.target.closest(".bt-update-pop") || button.current?.contains(event.target))) return; -@@ -5116,14 +5118,14 @@ function UpdateButton({ useUpdate, actions }) { +@@ -5116,14 +5116,14 @@ function UpdateButton({ useUpdate, actions }) { setDshError(""); void actions.installDshUpdate().catch((failure) => setDshError(hostText(failure?.message ?? String(failure)))); }; @@ -6277,7 +6374,7 @@ diff --git a/client.js b/client.js ref: button, type: "button", className: "bt-round bt-update", -@@ -5133,21 +5135,21 @@ function UpdateButton({ useUpdate, actions }) { +@@ -5133,21 +5133,21 @@ function UpdateButton({ useUpdate, actions }) { "aria-haspopup": "dialog", "aria-expanded": open, onClick: toggle @@ -6310,7 +6407,7 @@ diff --git a/client.js b/client.js type: "button", className: "bt-update-go", disabled: phase === "installing", -@@ -5155,17 +5157,17 @@ function UpdateButton({ useUpdate, actions }) { +@@ -5155,17 +5155,17 @@ function UpdateButton({ useUpdate, actions }) { }, phase === "installing" ? t("Updating…") : phase === "failed" ? t("Try again") : t("Update")) ) ) : null, @@ -6335,7 +6432,7 @@ diff --git a/client.js b/client.js type: "button", className: "bt-update-go", disabled: dshPhase === "installing", -@@ -5176,61 +5178,25 @@ function UpdateButton({ useUpdate, actions }) { +@@ -5176,61 +5176,25 @@ function UpdateButton({ useUpdate, actions }) { ), document.body) : null ); } @@ -6407,7 +6504,7 @@ diff --git a/client.js b/client.js ); } var openShellSettings = null; -@@ -5259,6 +5261,18 @@ function openHarnessOnboarding(id) { +@@ -5259,6 +5259,18 @@ function openHarnessOnboarding(id) { else openHarnessSettings(); }); } @@ -6426,7 +6523,7 @@ diff --git a/client.js b/client.js function openThroughMenu(trigger) { const before = new Set(document.querySelectorAll('[role="menu"]')); trigger.click(); -@@ -5288,32 +5290,32 @@ function pressSettingsShortcut() { +@@ -5288,32 +5288,32 @@ function pressSettingsShortcut() { })); } function AccountLauncher({ seat = true, settingsOpen, openSettings, openOnboarding, actions }) { @@ -6467,7 +6564,7 @@ diff --git a/client.js b/client.js if (!open) return void 0; const onDown = (event) => { if (event.target instanceof Element && (event.target.closest(".bt-account-menu") || button.current?.contains(event.target))) return; -@@ -5341,30 +5343,32 @@ function AccountLauncher({ seat = true, settingsOpen, openSettings, openOnboardi +@@ -5341,30 +5341,32 @@ function AccountLauncher({ seat = true, settingsOpen, openSettings, openOnboardi const groups = [ [ [t("Usage"), GaugeIcon, () => actions.openSettings("usage")], @@ -6512,7 +6609,7 @@ diff --git a/client.js b/client.js key: label, type: "button", role: "menuitem", -@@ -5372,7 +5374,7 @@ function AccountLauncher({ seat = true, settingsOpen, openSettings, openOnboardi +@@ -5372,7 +5372,7 @@ function AccountLauncher({ seat = true, settingsOpen, openSettings, openOnboardi setOpen(false); void Promise.resolve(run()).catch((error) => actions.reportError(error, "menu")); } @@ -6521,7 +6618,7 @@ diff --git a/client.js b/client.js )) ), document.body) : null ); -@@ -5382,8 +5384,8 @@ function useLandOnMainBot(roster, useSessions, actions) { +@@ -5382,8 +5382,8 @@ function useLandOnMainBot(roster, useSessions, actions) { const row = Object.values(list.byId).find((session) => (session?.retainedBy?.mainView ?? 0) > 0); return row ? `${row.id}|${row.blank ? 1 : 0}` : ""; }); @@ -6532,7 +6629,7 @@ diff --git a/client.js b/client.js if (!roster.ready || !roster.mainBotId) return void 0; const [id, blank3] = mainView.split("|"); const bot = id === "" ? void 0 : roster.byId[id]; -@@ -5397,40 +5399,40 @@ function useLandOnMainBot(roster, useSessions, actions) { +@@ -5397,40 +5397,40 @@ function useLandOnMainBot(roster, useSessions, actions) { return void 0; } const last = lastBot.current; @@ -6583,7 +6680,7 @@ diff --git a/client.js b/client.js } var SIDEBAR_CSS = ` .bt-side-top .bt-brand{cursor:default;padding-left:6px} -@@ -5491,18 +5493,28 @@ var SIDEBAR_CSS = ` +@@ -5491,18 +5491,28 @@ var SIDEBAR_CSS = ` .bt-agent-sub[data-open] .bt-agent-sub-row{animation:bt-agent-row-in .24s ease both;animation-delay:calc(var(--i, 0)*30ms)} @keyframes bt-agent-row-in{from{opacity:0;filter:blur(4px)}to{opacity:1;filter:blur(0)}} @media (prefers-reduced-motion:reduce){.bt-agent-sub{transition:none}.bt-agent-sub .bt-agent-sub-row{animation:none}} @@ -6616,7 +6713,7 @@ diff --git a/client.js b/client.js const left = seen + MEMORY_NOTICE_MS - Date.now(); setShown(seen > 0 && left > 0); if (seen === 0 || left <= 0) return void 0; -@@ -5512,16 +5514,16 @@ function useMemoryNotice(ids, useMemoryNews) { +@@ -5512,16 +5512,16 @@ function useMemoryNotice(ids, useMemoryNews) { return shown; } function MemoryNotice({ shown }) { @@ -6638,7 +6735,7 @@ diff --git a/client.js b/client.js ); } function HeaderPill({ sessionId, useRoster, useSessions, useSessionStatus, useSession, useActivity, useMemoryNews, useTurnClock, useUi, useLive, actions }) { -@@ -5537,7 +5539,7 @@ function HeaderPill({ sessionId, useRoster, useSessions, useSessionStatus, useSe +@@ -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) : ""; }); @@ -6647,7 +6744,7 @@ diff --git a/client.js b/client.js if (opening) document.body.style.setProperty("--bt-first-sep", JSON.stringify(opening)); else document.body.style.removeProperty("--bt-first-sep"); return () => document.body.style.removeProperty("--bt-first-sep"); -@@ -5548,52 +5550,38 @@ function HeaderPill({ sessionId, useRoster, useSessions, useSessionStatus, useSe +@@ -5548,52 +5548,38 @@ function HeaderPill({ sessionId, useRoster, useSessions, useSessionStatus, useSe const bot = roster.byId[sessionId]; const room2 = roster.roomsById[sessionId]; const detailsOpen = useUi((state) => state.details === sessionId); @@ -6716,7 +6813,7 @@ diff --git a/client.js b/client.js let frame = 0; let watched = null; const observer = new MutationObserver(() => schedule()); -@@ -5642,7 +5644,7 @@ function useTranscriptView() { +@@ -5642,7 +5642,7 @@ function useTranscriptView() { }, []); return view; } @@ -6725,7 +6822,7 @@ diff --git a/client.js b/client.js const replies = useSessions((list) => { const outline = list.projectionsBySession?.[sessionId]?.values?.turnOutline ?? list.byId[sessionId]?.projectionValues?.turnOutline; return Array.isArray(outline) ? outline.reduce((count, entry) => count + (entry.response ? 1 : 0), 0) : 0; -@@ -5658,18 +5660,22 @@ function NewMessagePills({ sessionId, useSessions, useUi, useTurnClock }) { +@@ -5658,18 +5658,22 @@ function NewMessagePills({ sessionId, useSessions, useUi, useTurnClock }) { }); const view = useTranscriptView(); const unpinned = view?.unpinned === true; @@ -6753,7 +6850,7 @@ diff --git a/client.js b/client.js const scroller = document.querySelector("[data-conversation-scroll]"); if (!scroller) return void 0; scroller.toggleAttribute("data-bt-news", showDown); -@@ -5677,16 +5679,16 @@ function NewMessagePills({ sessionId, useSessions, useUi, useTurnClock }) { +@@ -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"); @@ -6774,7 +6871,7 @@ diff --git a/client.js b/client.js type: "button", className: "bt-news-x", "aria-label": t("Dismiss new messages"), -@@ -5694,7 +5696,7 @@ function NewMessagePills({ sessionId, useSessions, useUi, useTurnClock }) { +@@ -5694,7 +5694,7 @@ function NewMessagePills({ sessionId, useSessions, useUi, useTurnClock }) { event.stopPropagation(); onDismiss(); } @@ -6783,7 +6880,7 @@ diff --git a/client.js b/client.js ); const toLatest = () => { const scroller = document.querySelector("[data-conversation-scroll]"); -@@ -5704,8 +5706,8 @@ function NewMessagePills({ sessionId, useSessions, useUi, useTurnClock }) { +@@ -5704,8 +5704,8 @@ function NewMessagePills({ sessionId, useSessions, useUi, useTurnClock }) { }; const toDivider = () => document.querySelector("[data-conversation-scroll] .bt-new-sep")?.scrollIntoView({ block: "center", behavior: "smooth" }); return (0, import_react_dom4.createPortal)( @@ -6794,7 +6891,7 @@ diff --git a/client.js b/client.js null, showUp ? pill("up", unread, { left: view.center, top: view.top }, toDivider, () => setAbove((state) => ({ ...state, dismissed: true }))) : null, showDown ? pill("down", fresh, { left: view.center, bottom: view.bottom }, toLatest, () => setRelease((state) => ({ ...state, dismissed: true }))) : null -@@ -5714,19 +5716,117 @@ function NewMessagePills({ sessionId, useSessions, useUi, useTurnClock }) { +@@ -5714,19 +5714,117 @@ function NewMessagePills({ sessionId, useSessions, useUi, useTurnClock }) { ); } @@ -6919,7 +7016,7 @@ diff --git a/client.js b/client.js if (!note) return void 0; const timer = setTimeout(() => setNote(""), 2600); return () => clearTimeout(timer); -@@ -5774,19 +5776,38 @@ function useDictation(onText) { +@@ -5774,19 +5774,38 @@ function useDictation(onText) { const stop = () => recognition.current?.stop(); return { listening, note, start, stop }; } @@ -6961,7 +7058,7 @@ diff --git a/client.js b/client.js const box = document.querySelector('[class*="_centerCol"] [role=textbox][contenteditable=true]'); if (box instanceof HTMLElement) { box.focus(); -@@ -5797,10 +5799,10 @@ function ComposerVoice({ sessionId, inputActions, actions, useUi }) { +@@ -5797,10 +5797,10 @@ function ComposerVoice({ sessionId, inputActions, actions, useUi }) { }, [quote?.token]); if (sessionId === void 0) return null; const pluginMic = actions.speech() !== void 0; @@ -6975,7 +7072,7 @@ diff --git a/client.js b/client.js type: "button", className: "bt-mic", "aria-label": dictation.listening ? t("Stop dictation") : t("Dictate"), -@@ -5808,16 +5810,16 @@ function ComposerVoice({ sessionId, inputActions, actions, useUi }) { +@@ -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() @@ -6996,7 +7093,7 @@ diff --git a/client.js b/client.js ); } function menuIcons(roster) { -@@ -5825,17 +5827,17 @@ function menuIcons(roster) { +@@ -5825,17 +5825,17 @@ function menuIcons(roster) { return (row) => { if (!row.bot) return PeopleIcon; const id = row.bot.id; @@ -7019,7 +7116,7 @@ diff --git a/client.js b/client.js if (!open) return void 0; const onDown = (event) => { if (![button.current, menu.current].some((node) => node?.contains(event.target))) setOpen(null); -@@ -5876,10 +5878,10 @@ function ComposerPlus({ sessionId, inputActions, useRoster, actions }) { +@@ -5876,10 +5876,10 @@ function ComposerPlus({ sessionId, inputActions, useRoster, actions }) { actions.referenceChat(sessionId, inputActions?.captureInsertion?.()); }; const canReference = typeof inputActions?.captureInsertion === "function" && actions.canReferenceChat(); @@ -7033,7 +7130,7 @@ diff --git a/client.js b/client.js ref: button, type: "button", className: "bt-plus", -@@ -5889,8 +5891,8 @@ function ComposerPlus({ sessionId, inputActions, useRoster, actions }) { +@@ -5889,8 +5889,8 @@ function ComposerPlus({ sessionId, inputActions, useRoster, actions }) { "aria-expanded": Boolean(open), onMouseDown: keepComposerFocus, onClick: toggle @@ -7044,7 +7141,7 @@ diff --git a/client.js b/client.js "div", { ref: menu, -@@ -5900,9 +5902,9 @@ function ComposerPlus({ sessionId, inputActions, useRoster, actions }) { +@@ -5900,9 +5900,9 @@ function ComposerPlus({ sessionId, inputActions, useRoster, actions }) { style: { left: shown.left, bottom: shown.bottom }, "data-leaving": leaving || void 0 }, @@ -7057,7 +7154,7 @@ diff --git a/client.js b/client.js ), document.body) : null ); } -@@ -5922,15 +5924,55 @@ var conversationOf = (roster, sessionId) => { +@@ -5922,15 +5922,55 @@ var conversationOf = (roster, sessionId) => { function mentionRows(roster, sessionId, query = "") { const here = conversationOf(roster, sessionId); if (here === null) return null; @@ -7117,7 +7214,7 @@ diff --git a/client.js b/client.js function chatRows(roster, list, sessionId) { const here = conversationOf(roster, sessionId); if (here === null) return []; -@@ -5959,14 +5961,30 @@ function atSources({ roster, sessions, launched, iconFor }) { +@@ -5959,14 +5959,30 @@ function atSources({ roster, sessions, launched, iconFor }) { order: -20, showGroupTitle: false, async candidates(session, { query }) { @@ -7152,7 +7249,7 @@ diff --git a/client.js b/client.js }, onPick: ({ candidate }) => candidate.value ? { text: candidate.value } : void 0 }; -@@ -5995,6 +5997,83 @@ function atSources({ roster, sessions, launched, iconFor }) { +@@ -5995,6 +5995,83 @@ function atSources({ roster, sessions, launched, iconFor }) { return [bots, chats]; } @@ -7236,7 +7333,7 @@ diff --git a/client.js b/client.js // src/client/speech.js var VOICE_BUNDLE = "@deepseek-ai/dsh-experimental-voice-input-bundle"; var USABLE = /* @__PURE__ */ new Set(["ready", "standby", "waking"]); -@@ -6121,41 +6123,14 @@ function turnDetector({ loud = 0.12, quiet = 0.06, quietMs = 1200 } = {}) { +@@ -6121,41 +6121,14 @@ function turnDetector({ loud = 0.12, quiet = 0.06, quietMs = 1200 } = {}) { }; } @@ -7282,7 +7379,7 @@ diff --git a/client.js b/client.js const wanted = query.trim().toLowerCase(); const close = () => { if (leaving) return; -@@ -6188,11 +6190,11 @@ function CommandPalette({ start, token, roster, actions, useSessions }) { +@@ -6188,11 +6188,11 @@ function CommandPalette({ start, token, roster, actions, useSessions }) { }); const matchesName = (...fields) => wanted === "" || fields.some((field) => (field ?? "").toLowerCase().includes(wanted)); const sections = []; @@ -7298,7 +7395,7 @@ diff --git a/client.js b/client.js subtitle: [bot.role, isMainOf(roster, bot.id) ? t("Main Bot") : ""].filter(Boolean).join(" · "), badge: bot.hidden ? t("Hidden") : void 0, run: async () => { -@@ -6202,9 +6204,9 @@ function CommandPalette({ start, token, roster, actions, useSessions }) { +@@ -6202,9 +6202,9 @@ function CommandPalette({ start, token, roster, actions, useSessions }) { })), ...roster.rooms.filter((room2) => matchesName(room2.name)).map((room2) => ({ key: room2.id, @@ -7310,7 +7407,7 @@ diff --git a/client.js b/client.js badge: room2.hidden ? t("Hidden") : void 0, run: async () => { if (room2.hidden) await actions.setFlags(room2.id, { hidden: false }); -@@ -6212,10 +6214,10 @@ function CommandPalette({ start, token, roster, actions, useSessions }) { +@@ -6212,10 +6212,10 @@ function CommandPalette({ start, token, roster, actions, useSessions }) { } })) ]; @@ -7323,7 +7420,7 @@ diff --git a/client.js b/client.js title: "DSH Agent", subtitle: t("Plain dsh session"), run: () => actions.openAgent(agent.id) -@@ -6228,16 +6230,16 @@ function CommandPalette({ start, token, roster, actions, useSessions }) { +@@ -6228,16 +6228,16 @@ function CommandPalette({ start, token, roster, actions, useSessions }) { key: `${id}:${turn}`, title: bot?.name ?? room2?.name ?? t("Bot"), subtitle: snippet, @@ -7345,7 +7442,7 @@ diff --git a/client.js b/client.js ].filter((command) => matchesName(command.title)); if (commands.length) sections.push([t("Commands"), commands]); const rows = sections.flatMap(([, items]) => items); -@@ -6247,7 +6249,7 @@ function CommandPalette({ start, token, roster, actions, useSessions }) { +@@ -6247,7 +6247,7 @@ function CommandPalette({ start, token, roster, actions, useSessions }) { ${shown}`, { axis: "y", ...QUICK_EDGES }); const listRef = glide.box; useFlip(listRef, shown, "[data-flip]"); @@ -7354,7 +7451,7 @@ diff --git a/client.js b/client.js listRef.current?.querySelector("[aria-selected=true]")?.scrollIntoView({ block: "nearest" }); }, [active, query]); const choose = (row) => { -@@ -6256,18 +6258,18 @@ ${shown}`, { axis: "y", ...QUICK_EDGES }); +@@ -6256,18 +6256,18 @@ ${shown}`, { axis: "y", ...QUICK_EDGES }); close(); }; let index = -1; @@ -7379,7 +7476,7 @@ diff --git a/client.js b/client.js autoFocus: true, value: query, placeholder: t("Search Bots, chats and messages"), -@@ -6293,29 +6295,29 @@ ${shown}`, { axis: "y", ...QUICK_EDGES }); +@@ -6293,29 +6293,29 @@ ${shown}`, { axis: "y", ...QUICK_EDGES }); } } }), @@ -7421,7 +7518,7 @@ diff --git a/client.js b/client.js "button", { key: row.key, -@@ -6329,14 +6331,14 @@ ${shown}`, { axis: "y", ...QUICK_EDGES }); +@@ -6329,14 +6329,14 @@ ${shown}`, { axis: "y", ...QUICK_EDGES }); }, onClick: () => choose(row) }, @@ -7441,7 +7538,7 @@ diff --git a/client.js b/client.js ); }) )) -@@ -6346,7 +6348,7 @@ ${shown}`, { axis: "y", ...QUICK_EDGES }); +@@ -6346,7 +6346,7 @@ ${shown}`, { axis: "y", ...QUICK_EDGES }); } // src/client/model-menu.js @@ -7450,7 +7547,7 @@ diff --git a/client.js b/client.js var import_react_dom6 = require("react-dom"); var MENU_EXIT_MS = 120; function groupModels(models) { -@@ -6367,17 +6369,10 @@ function groupModels(models) { +@@ -6367,17 +6367,10 @@ function groupModels(models) { }); } var servedOf = (roster, ref) => (roster.models ?? []).find((model) => model.ref === ref); @@ -7470,7 +7567,7 @@ diff --git a/client.js b/client.js setFetched(void 0); if (ref === void 0 || served?.reasoning !== void 0 || actions?.efforts === void 0) return void 0; let live = true; -@@ -6402,12 +6404,12 @@ function effortRows(reasoning) { +@@ -6402,12 +6402,12 @@ function effortRows(reasoning) { ]; } function EffortRows({ reasoning, current, onPick }) { @@ -7488,7 +7585,7 @@ diff --git a/client.js b/client.js "button", { key: row.effort ?? "default", -@@ -6416,17 +6418,17 @@ function EffortRows({ reasoning, current, onPick }) { +@@ -6416,17 +6416,17 @@ function EffortRows({ reasoning, current, onPick }) { "aria-checked": row.effort === current, onClick: () => onPick(row.effort) }, @@ -7512,7 +7609,7 @@ diff --git a/client.js b/client.js "button", { key: model.ref, -@@ -6436,20 +6438,20 @@ function ModelRows({ roster, current, onPick }) { +@@ -6436,20 +6436,20 @@ function ModelRows({ roster, current, onPick }) { title: model.ref, onClick: () => onPick(model.ref) }, @@ -7541,7 +7638,7 @@ diff --git a/client.js b/client.js if (place === null) return void 0; const onDown = (event) => { if (menu.current?.contains(event.target) || button.current?.contains(event.target)) return; -@@ -6484,6 +6486,7 @@ function useAnchoredMenu() { +@@ -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 }) { @@ -7549,7 +7646,7 @@ diff --git a/client.js b/client.js const { isOpen, shownPlace, leaving, button, menu, open, close } = useAnchoredMenu(); const shown = modelOf(bot, roster); const current = bot.model ?? bot.appliedModel; -@@ -6511,10 +6513,10 @@ function ModelMenu({ bot, roster, actions, run, wide = false, onPick, onPickEffo +@@ -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 ?? []; @@ -7563,7 +7660,7 @@ diff --git a/client.js b/client.js "button", { ref: button, -@@ -6527,37 +6529,38 @@ function ModelMenu({ bot, roster, actions, run, wide = false, onPick, onPickEffo +@@ -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 }, @@ -7616,7 +7713,7 @@ diff --git a/client.js b/client.js const models = roster.models ?? []; const lost = bot.model ? !models.some((model) => model.ref === bot.model) : false; if (bot.model && (!lost || models.length === 0)) return null; -@@ -6570,12 +6572,12 @@ function ModelChoiceDock({ sessionId, useRoster, actions }) { +@@ -6570,12 +6570,12 @@ function ModelChoiceDock({ sessionId, useRoster, actions }) { }).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 }); @@ -7633,7 +7730,7 @@ diff --git a/client.js b/client.js key: model.ref, type: "button", className: "bt-model-chip", -@@ -6584,50 +6586,115 @@ function ModelChoiceDock({ sessionId, useRoster, actions }) { +@@ -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) @@ -7765,7 +7862,7 @@ diff --git a/client.js b/client.js ...ops, ...hooks, t: actions.accountT(), -@@ -6639,6 +6641,16 @@ function SignInHost({ entry, actions }) { +@@ -6639,6 +6639,16 @@ function SignInHost({ entry, actions }) { }); } var MODEL_CSS = ` @@ -7782,7 +7879,7 @@ diff --git a/client.js b/client.js .bt-model-trigger{display:inline-flex;align-items:center;gap:8px;max-width:100%;min-width:0;height:auto;min-height:32px;padding:4px 8px 4px 10px;border:0;border-radius:8px;background:var(--bt-hover);color:var(--bt-ink);font:inherit;text-align:left;cursor:pointer;transition:background-color .12s ease} .bt-model-trigger:hover,.bt-model-trigger[aria-expanded=true]{background:var(--bt-active)} .bt-model-trigger:focus-visible{outline:2px solid var(--bt-accent);outline-offset:1px} -@@ -6675,18 +6677,88 @@ var MODEL_CSS = ` +@@ -6675,18 +6675,88 @@ var MODEL_CSS = ` @media (prefers-reduced-motion:reduce){.bt-model-dock{animation:none}.bt-model-spin{animation-duration:2s}.bt-model-caret{transition:none}.bt-model-tick svg{animation:none}} `; @@ -7878,7 +7975,7 @@ diff --git a/client.js b/client.js const fallback = group ? t("Each Bot's own folder") : t("Team workspace"); const toggle = () => { if (isOpen) { -@@ -6719,35 +6721,35 @@ function FolderMenu({ value, group = false, actions, run, onPick, wide = false } +@@ -6719,35 +6719,35 @@ function FolderMenu({ value, group = false, actions, run, onPick, wide = false } }; const known = info?.folders ?? []; const folders = value && !known.some((folder) => folder.path === value) ? [{ path: value, title: folderName(value) }, ...known] : known; @@ -7930,7 +8027,7 @@ diff --git a/client.js b/client.js className: "bt-folder-input", autoFocus: true, value: draft, -@@ -6759,33 +6761,33 @@ function FolderMenu({ value, group = false, actions, run, onPick, wide = false } +@@ -6759,33 +6759,33 @@ function FolderMenu({ value, group = false, actions, run, onPick, wide = false } if (event.key === "Enter" && !event.nativeEvent.isComposing) typed(); } }), @@ -7980,7 +8077,7 @@ diff --git a/client.js b/client.js "button", { ref: button, -@@ -6798,19 +6800,19 @@ function FolderMenu({ value, group = false, actions, run, onPick, wide = false } +@@ -6798,19 +6798,19 @@ function FolderMenu({ value, group = false, actions, run, onPick, wide = false } title: value, onClick: toggle }, @@ -8006,7 +8103,7 @@ diff --git a/client.js b/client.js ), document.body) : null ); } -@@ -6830,8 +6832,8 @@ var FOLDER_CSS = ` +@@ -6830,8 +6830,8 @@ var FOLDER_CSS = ` `; // src/client/schedules.js @@ -8017,7 +8114,7 @@ diff --git a/client.js b/client.js var shared = null; var listeners = /* @__PURE__ */ new Set(); function publish(view) { -@@ -6839,8 +6841,8 @@ function publish(view) { +@@ -6839,8 +6839,8 @@ function publish(view) { for (const listener of listeners) listener(view); } function useSchedules(actions) { @@ -8028,7 +8125,7 @@ diff --git a/client.js b/client.js listeners.add(setView); let stopped = false; let timer; -@@ -6848,7 +6850,7 @@ function useSchedules(actions) { +@@ -6848,7 +6848,7 @@ function useSchedules(actions) { if (!stopped) publish(next); }).catch(() => { }).finally(() => { @@ -8037,7 +8134,7 @@ diff --git a/client.js b/client.js }); load(); return () => { -@@ -6896,11 +6898,25 @@ function timingText(task) { +@@ -6896,11 +6896,25 @@ function timingText(task) { return ""; } } @@ -8068,7 +8165,7 @@ diff --git a/client.js b/client.js if (!confirming) return void 0; const timer = setTimeout(() => setConfirming(false), 3e3); return () => clearTimeout(timer); -@@ -6913,20 +6915,48 @@ function TaskRow({ task, actions, onView, showBot, roster }) { +@@ -6913,20 +6913,48 @@ function TaskRow({ task, actions, onView, showBot, roster }) { setBusy(true); void actions.scheduleDelete(task.sessionId, task.id).then(onView, () => setBusy(false)); }; @@ -8126,7 +8223,7 @@ diff --git a/client.js b/client.js type: "button", className: "bt-mini bt-task-remove", "data-confirm": confirming || void 0, -@@ -6934,7 +6936,7 @@ function TaskRow({ task, actions, onView, showBot, roster }) { +@@ -6934,7 +6934,7 @@ function TaskRow({ task, actions, onView, showBot, roster }) { "aria-label": confirming ? t("Click again to delete") : t("Delete task"), title: confirming ? void 0 : t("Delete task"), onClick: remove @@ -8135,7 +8232,7 @@ diff --git a/client.js b/client.js ); } var pad = (number) => String(number).padStart(2, "0"); -@@ -6945,22 +6947,44 @@ function inAnHour() { +@@ -6945,22 +6945,44 @@ function inAnHour() { } var KINDS = [["at", "Once"], ["daily", "Every day"], ["weekly", "Every week"], ["every", "Repeat"]]; var UNITS = [["minutes", 1, "minutes"], ["hours", 60, "hours"], ["days", 1440, "days"]]; @@ -8193,7 +8290,7 @@ diff --git a/client.js b/client.js }; const submit = (event) => { event.preventDefault(); -@@ -6975,146 +6977,185 @@ function TaskForm({ roster, actions, start, onDone, onCancel }) { +@@ -6975,146 +6975,185 @@ function TaskForm({ roster, actions, start, onDone, onCancel }) { setBusy(false); return; } @@ -8441,7 +8538,7 @@ diff --git a/client.js b/client.js ))) ); } -@@ -7127,6 +7129,12 @@ var SCHEDULE_CSS = ` +@@ -7127,6 +7127,12 @@ var SCHEDULE_CSS = ` .bt-task-next{flex:none;font-size:12px;line-height:16px;color:var(--bt-ink-3);font-variant-numeric:tabular-nums;white-space:nowrap} .bt-task-remove{display:inline-flex;align-items:center;justify-content:center;min-width:26px;height:26px;color:var(--bt-ink-3)} .bt-task-remove svg{width:14px;height:14px} @@ -8454,7 +8551,7 @@ diff --git a/client.js b/client.js .bt-task-remove[data-confirm]{background:rgba(224,33,53,.1);color:#e02135} .bt-task-head{display:flex;align-items:center;gap:6px} .bt-task-form{animation:bt-q-in .4s cubic-bezier(.22,1,.36,1),bt-veil-in .2s ease-out} -@@ -7158,7 +7160,7 @@ var SCHEDULE_CSS = ` +@@ -7158,7 +7158,7 @@ var SCHEDULE_CSS = ` `; // src/client/capability-settings.js @@ -8463,7 +8560,7 @@ diff --git a/client.js b/client.js var blank = () => ({ name: "", kind: "mcp", description: "", transport: "stdio", command: "", argsText: "", envText: "{}", url: "", headersText: "{}", content: "", items: [] }); var parseMap = (text, label) => { let value; -@@ -7172,11 +7174,11 @@ var parseMap = (text, label) => { +@@ -7172,11 +7172,11 @@ var parseMap = (text, label) => { }; var statusText = (status) => !status ? void 0 : status.state === "ready" ? t("Ready") : status.state === "needs-approval" ? t("Needs approval") : status.state === "error" ? t("Could not connect: {error}", { error: status.error ?? "" }) : t("Connecting…"); function Field({ label, value, onChange, multiline = false, disabled = false, placeholder }) { @@ -8478,7 +8575,7 @@ diff --git a/client.js b/client.js className: multiline ? "bt-input bt-input-area" : "bt-input", value, disabled, -@@ -7188,7 +7190,7 @@ function Field({ label, value, onChange, multiline = false, disabled = false, pl +@@ -7188,7 +7188,7 @@ function Field({ label, value, onChange, multiline = false, disabled = false, pl ); } function CapabilityForm({ draft, setDraft, items, actions, onClose }) { @@ -8487,7 +8584,7 @@ diff --git a/client.js b/client.js const set = (key, value) => setDraft((previous) => ({ ...previous, [key]: value })); const submit = async (event) => { event.preventDefault(); -@@ -7199,7 +7201,7 @@ function CapabilityForm({ draft, setDraft, items, actions, onClose }) { +@@ -7199,7 +7199,7 @@ function CapabilityForm({ draft, setDraft, items, actions, onClose }) { if (draft.kind === "mcp" && draft.transport === "stdio") { payload.transport = "stdio"; payload.command = draft.command; @@ -8496,7 +8593,7 @@ diff --git a/client.js b/client.js payload.env = parseMap(draft.envText, t("Environment")); } else if (draft.kind === "mcp") { payload.transport = "streamable-http"; -@@ -7219,95 +7221,95 @@ function CapabilityForm({ draft, setDraft, items, actions, onClose }) { +@@ -7219,95 +7219,95 @@ function CapabilityForm({ draft, setDraft, items, actions, onClose }) { } }; const available = items.filter((item) => item.name !== draft.name && ["mcp", "skill"].includes(item.kind)); @@ -8632,7 +8729,7 @@ diff --git a/client.js b/client.js if (!confirming) return void 0; const timer = setTimeout(() => setConfirming(""), 4e3); return () => clearTimeout(timer); -@@ -7330,20 +7332,20 @@ function CapabilitiesPage({ roster, actions, run }) { +@@ -7330,20 +7330,20 @@ function CapabilitiesPage({ roster, actions, run }) { setConfirming(""); void run(actions.deleteCapability(item.name)); }; @@ -8660,7 +8757,7 @@ diff --git a/client.js b/client.js key: item.name, item, confirming: confirming === item.name, -@@ -7359,10 +7361,10 @@ function BotCapabilitiesCard({ bot, roster, actions, run, Card: Card2, SwitchRow +@@ -7359,10 +7359,10 @@ function BotCapabilitiesCard({ bot, roster, actions, run, Card: Card2, SwitchRow const attached = new Set(bot.capabilities ?? []); const statuses = roster.capabilityStatuses?.[bot.id] ?? {}; if (items.length === 0) return null; @@ -8673,7 +8770,7 @@ diff --git a/client.js b/client.js key: item.name, label: item.name, hint: attached.has(item.name) ? statusText(statuses[item.name]) : void 0, -@@ -7372,9 +7374,55 @@ function BotCapabilitiesCard({ bot, roster, actions, run, Card: Card2, SwitchRow +@@ -7372,9 +7372,55 @@ function BotCapabilitiesCard({ bot, roster, actions, run, Card: Card2, SwitchRow ); } @@ -8731,7 +8828,7 @@ diff --git a/client.js b/client.js var MENU_EXIT_MS2 = 120; var counts = /* @__PURE__ */ new Map(); var countListeners = /* @__PURE__ */ new Set(); -@@ -7384,13 +7386,13 @@ function noteCount(botId, count) { +@@ -7384,13 +7384,13 @@ function noteCount(botId, count) { for (const listener of countListeners) listener(); } function useMemoryCount(botId, actions) { @@ -8748,7 +8845,7 @@ diff --git a/client.js b/client.js if (!stop) noteCount(botId, view.entries.length); }).catch(() => { }); -@@ -7404,16 +7406,16 @@ function useMemoryCount(botId, actions) { +@@ -7404,16 +7404,16 @@ function useMemoryCount(botId, actions) { function MemoryRow({ bot, actions }) { const count = useMemoryCount(bot.id, actions); const label = count === null ? t("Loading…") : count === 0 ? t("Nothing saved yet") : count === 1 ? t("1 entry") : t("{count} entries", { count }); @@ -8771,7 +8868,7 @@ diff --git a/client.js b/client.js ) ); } -@@ -7428,45 +7430,45 @@ function ago(at, now) { +@@ -7428,45 +7428,45 @@ function ago(at, now) { return format.format(-Math.round(hours / 24), "day"); } function Skeleton({ label }) { @@ -8836,7 +8933,7 @@ diff --git a/client.js b/client.js ) ); } -@@ -7478,9 +7480,9 @@ var groupEntries = (entries2) => entries2.reduce((groups, entry) => { +@@ -7478,9 +7478,9 @@ var groupEntries = (entries2) => entries2.reduce((groups, entry) => { return groups; }, []); function EntryRow({ entry, remove }) { @@ -8849,7 +8946,7 @@ diff --git a/client.js b/client.js if (!confirming) return void 0; const timer = setTimeout(() => setConfirming(false), 3e3); return () => clearTimeout(timer); -@@ -7495,16 +7497,16 @@ function EntryRow({ entry, remove }) { +@@ -7495,16 +7495,16 @@ function EntryRow({ entry, remove }) { setBusy(true); if (!await remove(entry)) setBusy(false); }; @@ -8871,7 +8968,7 @@ diff --git a/client.js b/client.js type: "button", className: "bt-mem-remove", "data-confirm": confirming || void 0, -@@ -7512,7 +7514,7 @@ function EntryRow({ entry, remove }) { +@@ -7512,7 +7512,7 @@ function EntryRow({ entry, remove }) { "aria-label": confirming ? t("Click again to remove") : t("Remove from memory"), title: confirming ? void 0 : t("Remove from memory"), onClick: click @@ -8880,7 +8977,7 @@ diff --git a/client.js b/client.js ); } function Entries({ bot, view, remove }) { -@@ -7521,25 +7523,25 @@ function Entries({ bot, view, remove }) { +@@ -7521,25 +7521,25 @@ function Entries({ bot, view, remove }) { { scope: "own", title: t("{name}'s memory", { name: bot.name }), size: view.sizes.own }, { scope: "team", title: t("Team memory"), hint: t("Every Bot reads it"), size: view.sizes.team } ]; @@ -8917,7 +9014,7 @@ diff --git a/client.js b/client.js )) ); } -@@ -7551,52 +7553,52 @@ function changeLine(change) { +@@ -7551,52 +7551,52 @@ function changeLine(change) { return change.scope === "team" ? t("Added to team memory: {text}", { text }) : t("Added: {text}", { text }); } function Reply({ answer, dismiss }) { @@ -8989,7 +9086,7 @@ diff --git a/client.js b/client.js alive.current = true; void load({ summarize: true }); return () => { -@@ -7606,9 +7608,9 @@ function useMemoryView(botId, actions) { +@@ -7606,9 +7606,9 @@ function useMemoryView(botId, actions) { }, [botId]); return { view, error, load, show }; } @@ -9002,7 +9099,7 @@ diff --git a/client.js b/client.js const id = setInterval(() => setNow(Date.now()), every); return () => clearInterval(id); }, [every]); -@@ -7616,18 +7618,18 @@ function useNow(every) { +@@ -7616,18 +7616,18 @@ function useNow(every) { } function MemoryDialog({ bot, actions, leaving = false }) { const { view, error, load, show } = useMemoryView(bot.id, actions); @@ -9031,7 +9128,7 @@ diff --git a/client.js b/client.js if (!menu) return void 0; const onDown = (event) => { if (menuRef.current?.contains(event.target) || dotsRef.current?.contains(event.target)) return; -@@ -7689,52 +7691,52 @@ function MemoryDialog({ bot, actions, leaving = false }) { +@@ -7689,52 +7689,52 @@ 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; @@ -9107,7 +9204,7 @@ diff --git a/client.js b/client.js ref: inputRef, className: "bt-mem-input", value: text, -@@ -7744,10 +7746,10 @@ function MemoryDialog({ bot, actions, leaving = false }) { +@@ -7744,10 +7744,10 @@ function MemoryDialog({ bot, actions, leaving = false }) { "aria-label": t("Ask about or update {name}'s memory", { name: bot.name }), onChange: (event) => setText(event.target.value) }), @@ -9120,7 +9217,7 @@ diff --git a/client.js b/client.js ) ) ) -@@ -7755,8 +7757,40 @@ function MemoryDialog({ bot, actions, leaving = false }) { +@@ -7755,8 +7755,40 @@ function MemoryDialog({ bot, actions, leaving = false }) { ); } @@ -9162,7 +9259,7 @@ diff --git a/client.js b/client.js 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 +7782,8 @@ async function avatarImage(file) { +@@ -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 }) { @@ -9173,7 +9270,7 @@ diff --git a/client.js b/client.js const commit = () => { const next = draft?.trim(); setDraft(null); -@@ -7792,7 +7794,7 @@ function InlineText({ label, value, placeholder, required = false, maxLength, mu +@@ -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); }; @@ -9182,7 +9279,7 @@ diff --git a/client.js b/client.js className: multiline ? "bt-bs-input bt-bs-area" : "bt-bs-input", value: draft ?? value ?? "", placeholder, -@@ -7813,29 +7815,29 @@ function InlineText({ label, value, placeholder, required = false, maxLength, mu +@@ -7813,29 +7813,29 @@ function InlineText({ label, value, placeholder, required = false, maxLength, mu }); } function Row({ label, hint, stack = false, children }) { @@ -9221,7 +9318,7 @@ diff --git a/client.js b/client.js ); } function modelHint(bot, roster) { -@@ -7846,22 +7848,22 @@ function modelHint(bot, roster) { +@@ -7846,22 +7846,22 @@ function modelHint(bot, roster) { return t("Applies from the next message."); } function ShapeRing({ shape, size }) { @@ -9249,7 +9346,7 @@ diff --git a/client.js b/client.js key: id, type: "button", className: "bt-shape", -@@ -7869,8 +7871,8 @@ function CharacterGrid({ look, hasPhoto = false, onShape, onColor }) { +@@ -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) @@ -9260,7 +9357,7 @@ diff --git a/client.js b/client.js key: color, type: "button", className: "bt-color", -@@ -7878,19 +7880,19 @@ function CharacterGrid({ look, hasPhoto = false, onShape, onColor }) { +@@ -7878,19 +7878,19 @@ function CharacterGrid({ look, hasPhoto = false, onShape, onColor }) { title: t(capitalize(color)), "aria-pressed": look.color === color, onClick: () => onColor(color) @@ -9286,7 +9383,7 @@ diff --git a/client.js b/client.js const onDown = (event) => { if (!(event.target instanceof Element) || ref.current?.contains(event.target) || event.target.closest(".bt-avatar-trigger")) return; onClose(); -@@ -7908,7 +7910,7 @@ function AvatarEditor({ bot, actions, run, onClose }) { +@@ -7908,7 +7908,7 @@ function AvatarEditor({ bot, actions, run, onClose }) { window.removeEventListener("keydown", onKey, true); }; }, [onClose]); @@ -9295,7 +9392,7 @@ diff --git a/client.js b/client.js if (tab !== "upload") return void 0; const onPaste = (event) => { const file = [...event.clipboardData?.files ?? []].find((item) => item.type.startsWith("image/")); -@@ -7922,32 +7924,32 @@ function AvatarEditor({ bot, actions, run, onClose }) { +@@ -7922,32 +7922,32 @@ function AvatarEditor({ bot, actions, run, onClose }) { }, [tab, bot]); const hasPhoto = Boolean(look.image); const glide = useGlide(tab); @@ -9336,7 +9433,7 @@ diff --git a/client.js b/client.js "div", { className: "bt-dropzone", -@@ -7963,15 +7965,15 @@ function AvatarEditor({ bot, actions, run, onClose }) { +@@ -7963,15 +7963,15 @@ function AvatarEditor({ bot, actions, run, onClose }) { upload(event.dataTransfer.files?.[0]); } }, @@ -9358,7 +9455,7 @@ diff --git a/client.js b/client.js const file = event.target.files?.[0]; event.target.value = ""; upload(file); -@@ -7982,26 +7984,27 @@ function AvatarEditor({ bot, actions, run, onClose }) { +@@ -7982,26 +7982,27 @@ 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 }); @@ -9394,7 +9491,7 @@ diff --git a/client.js b/client.js const isMainBot = isMainOf(roster, bot.id); const onlyMain = isMainBot && (roster.mainBotIds ?? []).length === 1; const isTile = roster.mainBotId === bot.id; -@@ -8011,112 +8013,116 @@ function BotSettingsForm({ bot, roster, actions, run }) { +@@ -8011,112 +8011,116 @@ function BotSettingsForm({ bot, roster, actions, run }) { if (confirming) void run(actions.deleteBot(bot.id)); else setConfirming(true); }; @@ -9560,7 +9657,7 @@ diff --git a/client.js b/client.js ) ) ); -@@ -8133,6 +8135,7 @@ label.bt-bs-row{cursor:text} +@@ -8133,6 +8133,7 @@ label.bt-bs-row{cursor:text} .bt-bs-row>.bt-set-copy{flex:1 1 auto} .bt-bs-label{font-size:13px;line-height:18px;font-weight:500} .bt-bs-row .bt-model-trigger{flex:none;max-width:58%} @@ -9568,7 +9665,7 @@ diff --git a/client.js b/client.js .bt-bs-input{flex:0 1 220px;width:0;min-width:96px;height:30px;box-sizing:border-box;margin:-4px -6px -4px 0;padding:0 8px;border:1px solid transparent;border-radius:8px;background:none;color:var(--bt-ink);font:inherit;font-size:13px;text-align:right;outline:none;transition:background-color .12s ease,border-color .12s ease} .bt-bs-input:hover{background:var(--bt-hover)} .bt-bs-input:focus{background:var(--bt-main);border-color:var(--bt-line-2);text-align:left} -@@ -8165,36 +8167,48 @@ function freshBotName(roster, base = "New Bot") { +@@ -8165,36 +8165,48 @@ function freshBotName(roster, base = "New Bot") { } function startModel(roster) { const models = roster.models ?? []; @@ -9632,7 +9729,7 @@ diff --git a/client.js b/client.js if (inline) frame.current?.scrollIntoView({ block: "start", behavior: "smooth" }); }, []); const fallbackName = freshBotName(roster, t("New Bot")); -@@ -8206,8 +8208,8 @@ function CreateBot({ roster, actions, start, brief = "", onBack, canGoBack, onCr +@@ -8206,8 +8206,8 @@ function CreateBot({ roster, actions, start, brief = "", onBack, canGoBack, onCr setError(""); setBusy(true); try { @@ -9643,7 +9740,7 @@ diff --git a/client.js b/client.js } catch (failure) { setError(failure?.message ?? String(failure)); setBusy(false); -@@ -8219,25 +8221,25 @@ function CreateBot({ roster, actions, start, brief = "", onBack, canGoBack, onCr +@@ -8219,25 +8219,25 @@ function CreateBot({ roster, actions, start, brief = "", onBack, canGoBack, onCr avatarImage(file).then(setImage, (failure) => setError(failure?.message ?? String(failure))); }; const previewLook = image ? { ...look, image } : look; @@ -9677,7 +9774,7 @@ diff --git a/client.js b/client.js look, hasPhoto: Boolean(image), onShape: (shape) => { -@@ -8249,27 +8251,27 @@ function CreateBot({ roster, actions, start, brief = "", onBack, canGoBack, onCr +@@ -8249,27 +8249,27 @@ function CreateBot({ roster, actions, start, brief = "", onBack, canGoBack, onCr setLook((current) => ({ ...current, color })); } }), @@ -9713,7 +9810,7 @@ diff --git a/client.js b/client.js className: "bt-input", autoFocus: !inline, value: name, -@@ -8284,75 +8286,113 @@ function CreateBot({ roster, actions, start, brief = "", onBack, canGoBack, onCr +@@ -8284,75 +8284,113 @@ function CreateBot({ roster, actions, start, brief = "", onBack, canGoBack, onCr } } }), @@ -9857,7 +9954,7 @@ diff --git a/client.js b/client.js const pick2 = (bot) => { setPicked((current) => [...current, bot.id]); setQuery(""); -@@ -8386,13 +8388,17 @@ function NewChat({ mode, roster, actions }) { +@@ -8386,13 +8386,17 @@ function NewChat({ mode, roster, actions }) { }; const created = (bot) => { close(); @@ -9878,7 +9975,7 @@ diff --git a/client.js b/client.js if (text) await actions.send(room2.id, text); actions.openSession(room2.id); close(); -@@ -8411,46 +8413,75 @@ function NewChat({ mode, roster, actions }) { +@@ -8411,46 +8411,75 @@ function NewChat({ mode, roster, actions }) { close(); return; } @@ -9965,7 +10062,7 @@ diff --git a/client.js b/client.js canGoBack: draft.fromList, onBack: draft.fromList ? () => { setDraft(null); -@@ -8467,33 +8469,41 @@ ${listed}`, { axis: "y", ...QUICK_EDGES }); +@@ -8467,33 +8467,41 @@ ${listed}`, { axis: "y", ...QUICK_EDGES }); if (picked.length > 0) setPicked((current) => current.slice(0, -1)); else if (admin !== null) setAdmin(null); }; @@ -10022,7 +10119,7 @@ diff --git a/client.js b/client.js ref: inputRef, autoFocus: true, value: query, -@@ -8517,20 +8519,20 @@ ${listed}`, { axis: "y", ...QUICK_EDGES }); +@@ -8517,20 +8517,20 @@ ${listed}`, { axis: "y", ...QUICK_EDGES }); } else if (event.key === "Backspace" && query === "") unpick(); } }), @@ -10048,7 +10145,7 @@ diff --git a/client.js b/client.js "button", { key: option.key, -@@ -8542,21 +8544,36 @@ ${listed}`, { axis: "y", ...QUICK_EDGES }); +@@ -8542,21 +8542,36 @@ ${listed}`, { axis: "y", ...QUICK_EDGES }); onMouseEnter: () => setCursor(index), onClick: option.run }, @@ -10094,7 +10191,7 @@ diff --git a/client.js b/client.js rows: 1, value: message, placeholder, -@@ -8569,7 +8571,7 @@ ${listed}`, { axis: "y", ...QUICK_EDGES }); +@@ -8569,7 +8569,7 @@ ${listed}`, { axis: "y", ...QUICK_EDGES }); } } }), @@ -10103,7 +10200,7 @@ diff --git a/client.js b/client.js ) ) ) -@@ -8577,18 +8579,18 @@ ${listed}`, { axis: "y", ...QUICK_EDGES }); +@@ -8577,18 +8577,18 @@ ${listed}`, { axis: "y", ...QUICK_EDGES }); } // src/client/exchange.js @@ -10126,7 +10223,7 @@ diff --git a/client.js b/client.js let stop = false; let timer; let seen = null; -@@ -8612,48 +8614,48 @@ function ExchangeDialog({ pair, roster, actions }) { +@@ -8612,48 +8612,48 @@ function ExchangeDialog({ pair, roster, actions }) { const right = roster.byId[pair[1]]; let lastDay = 0; const paneLeft = usePaneLeft(); @@ -10191,7 +10288,7 @@ diff --git a/client.js b/client.js var plainSpeech = (text) => stripHeader(text).replace(/```[\s\S]*?```/g, " ").replace(/\[([^\]]*)\]\([^)]*\)/g, "$1").replace(/[*_`#>|~-]+/g, " ").replace(/\s+/g, " ").trim(); var VOICE_LABELS = { listening: "Listening", -@@ -8665,14 +8667,14 @@ var VOICE_LABELS = { +@@ -8665,14 +8665,14 @@ var VOICE_LABELS = { }; var CAPTURE_NOTES = { blocked: "Microphone access is blocked", unavailable: "Voice chat is not available in this browser", "too-long": "That was too long to recognize" }; function useMicLevel(enabled) { @@ -10209,7 +10306,7 @@ diff --git a/client.js b/client.js if (stopped) { media.getTracks().forEach((track) => track.stop()); return; -@@ -8716,18 +8718,18 @@ function VoiceMode({ sessionId, roster, actions, useSessions, useSessionStatus } +@@ -8716,18 +8716,18 @@ function VoiceMode({ sessionId, roster, actions, useSessions, useSessionStatus } const entry = Array.isArray(outline) ? outline[outline.length - 1] : void 0; return entry ? `${outline.length}|${entry.response ?? ""}` : "0|"; }); @@ -10239,7 +10336,7 @@ diff --git a/client.js b/client.js const paneLeft = usePaneLeft(); const close = () => { turn.current += 1; -@@ -8747,12 +8749,12 @@ function VoiceMode({ sessionId, roster, actions, useSessions, useSessionStatus } +@@ -8747,12 +8747,12 @@ function VoiceMode({ sessionId, roster, actions, useSessions, useSessionStatus } dictation.stop(); submit(text); }); @@ -10254,7 +10351,7 @@ diff --git a/client.js b/client.js if (!speech) return void 0; let live = true; speechCatalog(speech).then(({ catalog: next, usable }) => { -@@ -8767,16 +8769,16 @@ function VoiceMode({ sessionId, roster, actions, useSessions, useSessionStatus } +@@ -8767,16 +8767,16 @@ function VoiceMode({ sessionId, roster, actions, useSessions, useSessionStatus } }; }, [speech]); const listen = phase === "listening"; @@ -10274,7 +10371,7 @@ diff --git a/client.js b/client.js if (!speech || !catalog || !listen) return void 0; const own = turn.current; let capture = null; -@@ -8838,7 +8840,7 @@ function VoiceMode({ sessionId, roster, actions, useSessions, useSessionStatus } +@@ -8838,7 +8838,7 @@ function VoiceMode({ sessionId, roster, actions, useSessions, useSessionStatus } setServiceLevel(0); }; }, [speech, catalog, listen, cycle]); @@ -10283,7 +10380,7 @@ diff --git a/client.js b/client.js if (phase !== "thinking" || running || waiting.current === null) return; const [count, text] = [lastReply.slice(0, lastReply.indexOf("|")), lastReply.slice(lastReply.indexOf("|") + 1)]; if (count === waiting.current || !text.trim()) return; -@@ -8858,7 +8860,7 @@ function VoiceMode({ sessionId, roster, actions, useSessions, useSessionStatus } +@@ -8858,7 +8858,7 @@ function VoiceMode({ sessionId, roster, actions, useSessions, useSessionStatus } window.speechSynthesis.cancel(); window.speechSynthesis.speak(utterance); }, [phase, running, lastReply]); @@ -10292,7 +10389,7 @@ diff --git a/client.js b/client.js const micLevel = useMicLevel(!speech && phase === "listening" && dictation.listening); const level = speech ? serviceLevel : micLevel; const markState = phase === "thinking" || phase === "transcribing" ? "thinking" : phase === "speaking" ? "sending" : "idle"; -@@ -8875,69 +8877,162 @@ function VoiceMode({ sessionId, roster, actions, useSessions, useSessionStatus } +@@ -8875,69 +8875,162 @@ function VoiceMode({ sessionId, roster, actions, useSessions, useSessionStatus } const openSetup = phase === "setup" ? actions.voiceSetup() : null; const caption = phase === "speaking" ? said : heard; const shownNote = note || dictation.note; @@ -10476,7 +10573,7 @@ diff --git a/client.js b/client.js const admin = roster.byId[room2.admin]; const mode = shownMode(room2, roster); const modeHint = ROOM_MODES.find(([id]) => id === mode)?.[2] ?? ""; -@@ -8955,41 +8957,41 @@ function GroupSettingsForm({ room: room2, roster, actions, run, onDeleted }) { +@@ -8955,41 +8955,41 @@ function GroupSettingsForm({ room: room2, roster, actions, run, onDeleted }) { if (done) onDeleted?.(); }); }; @@ -10532,7 +10629,7 @@ diff --git a/client.js b/client.js key: id, type: "button", "aria-pressed": mode === id, -@@ -8999,86 +9001,136 @@ function GroupSettingsForm({ room: room2, roster, actions, run, onDeleted }) { +@@ -8999,86 +8999,136 @@ function GroupSettingsForm({ room: room2, roster, actions, run, onDeleted }) { if (mode !== id) void update({ mode: id }); } }, t(label)))), @@ -10705,7 +10802,7 @@ diff --git a/client.js b/client.js const toggle = (id) => setMembers((list) => list.includes(id) ? list.filter((item) => item !== id) : [...list, id]); const pickAdmin = (id) => { setAdmin(id); -@@ -9088,54 +9090,54 @@ function NewGroupForm({ roster, actions, run, onCreated, onCancel }) { +@@ -9088,54 +9088,54 @@ function NewGroupForm({ roster, actions, run, onCreated, onCancel }) { if (busy || admin === null || members.length === 0) return; setBusy(true); let room2; @@ -10776,7 +10873,7 @@ diff --git a/client.js b/client.js ) ); } -@@ -9156,6 +9158,13 @@ var GROUP_SETTINGS_CSS = ` +@@ -9156,6 +9156,13 @@ var GROUP_SETTINGS_CSS = ` .bt-gs-who .bt-row-title{overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .bt-gs-who:hover .bt-row-title{text-decoration:underline;text-underline-offset:2px} .bt-gs-add{gap:8px;color:var(--bt-ink-2)} @@ -10790,7 +10887,7 @@ diff --git a/client.js b/client.js .bt-gs-pick{gap:10px;animation:bt-rise .16s ease-out} .bt-bs-stack .bt-seg{align-self:stretch} @media (prefers-reduced-motion:reduce){.bt-gs-pick{animation:none}} -@@ -9277,13 +9279,13 @@ function summarize(view, range, keep = () => true, seriesOf = () => "all") { +@@ -9277,13 +9277,13 @@ function summarize(view, range, keep = () => true, seriesOf = () => "all") { } return result; } @@ -10806,7 +10903,7 @@ diff --git a/client.js b/client.js } return String(Math.round(count)); } -@@ -9294,11 +9296,11 @@ function percent(part, whole) { +@@ -9294,11 +9294,11 @@ function percent(part, whole) { } // src/client/usage-page.js @@ -10820,7 +10917,7 @@ diff --git a/client.js b/client.js var rangeLabel = (id) => ({ "24h": t("24 hours"), "7d": t("7 days"), "30d": t("30 days"), "12w": t("12 weeks"), "12m": t("12 months") })[id]; var unitLabel = (unit) => ({ hour: t("Per hour"), day: t("Per day"), week: t("Per week"), month: t("Per month") })[unit]; var MODEL_INKS = ["deepseek", "tangerine", "green", "violet", "sky", "rose", "yellow", "cyan", "magenta", "brown"]; -@@ -9341,9 +9343,9 @@ function titleOf(range, bucket) { +@@ -9341,9 +9341,9 @@ function titleOf(range, bucket) { return dates({ year: "numeric", month: "long" }).format(bucket.start); } function useTween(value) { @@ -10833,7 +10930,7 @@ diff --git a/client.js b/client.js if (reducedMotion() || typeof requestAnimationFrame !== "function") { from.current = value; setShown(value); -@@ -9363,9 +9365,9 @@ function useTween(value) { +@@ -9363,9 +9363,9 @@ function useTween(value) { }, [value]); return shown; } @@ -10845,7 +10942,7 @@ diff --git a/client.js b/client.js className: "bt-us-model", "aria-hidden": true, style: { width: size, height: size, fontSize: Math.round(size * 0.42), background: `color-mix(in srgb, ${paint} 20%, var(--bt-card))`, color: `color-mix(in srgb, ${paint} 72%, var(--bt-ink))` } -@@ -9382,7 +9384,7 @@ function ownerFace(owner, roster) { +@@ -9382,7 +9382,7 @@ function ownerFace(owner, roster) { sub: owner.deleted ? t("Deleted") : role.toLowerCase() === name.toLowerCase() ? "" : role, paint: paintCss(colorOf(bot)), stops: GRADIENTS[colorOf(bot)], @@ -10854,7 +10951,7 @@ diff --git a/client.js b/client.js }; } if (owner.kind === "room") { -@@ -9392,7 +9394,7 @@ function ownerFace(owner, roster) { +@@ -9392,7 +9392,7 @@ function ownerFace(owner, roster) { name: owner.name || t("Deleted group chat"), sub: owner.deleted ? t("Deleted") : t("Group chat"), paint: "var(--bt-us-rest)", @@ -10863,7 +10960,7 @@ diff --git a/client.js b/client.js }; } if (owner.kind === "agent") { -@@ -9414,30 +9416,30 @@ function Tabs({ label, value, options, onChange, boxRef }) { +@@ -9414,30 +9414,30 @@ function Tabs({ label, value, options, onChange, boxRef }) { const names = options.map(([, text]) => text).join("\n"); const glide = useGlide(`${value} ${names}`, { box: boxRef }); @@ -10904,7 +11001,7 @@ diff --git a/client.js b/client.js "span", { className: "bt-us-delta", title: compareText(range) }, `${change >= 0 ? "↑" : "↓"} ${Math.abs(change)}%` -@@ -9455,83 +9457,83 @@ function niceTop(max) { +@@ -9455,83 +9455,83 @@ function niceTop(max) { return [1, 1.5, 2, 2.5, 3, 4, 5, 6, 8, 10].map((scale) => scale * step).find((value) => value >= max); } function Chart({ range, columns, series, tipSeries, by, onBy, onPick }) { @@ -11015,7 +11112,7 @@ diff --git a/client.js b/client.js onClick ? "button" : "div", { className: "bt-us-row", -@@ -9540,69 +9542,69 @@ function Row2({ kind, glyph, name, sub, sum, whole, fill: fill2, index, onClick +@@ -9540,69 +9540,69 @@ function Row2({ kind, glyph, name, sub, sum, whole, fill: fill2, index, onClick title: t("{tokens} tokens · {calls} calls", { tokens: full(sum.total), calls: full(sum.calls) }), ...onClick ? { type: "button", onClick } : {} }, @@ -11113,7 +11210,7 @@ diff --git a/client.js b/client.js const onDown = (event) => { if (![box, near].some((ref) => ref.current?.contains(event.target))) onClose(); }; -@@ -9628,17 +9630,17 @@ function SpanPicker({ span, today, oldest, active, near, onPick, onClose }) { +@@ -9628,17 +9628,17 @@ function SpanPicker({ span, today, oldest, active, near, onPick, onClose }) { const [from, to] = [anchor, key].sort(); onPick(from, to); }; @@ -11134,7 +11231,7 @@ diff --git a/client.js b/client.js key, type: "button", className: "bt-us-day", -@@ -9654,15 +9656,15 @@ function SpanPicker({ span, today, oldest, active, near, onPick, onClose }) { +@@ -9654,15 +9654,15 @@ function SpanPicker({ span, today, oldest, active, near, onPick, onClose }) { "data-has": flag(active.has(key)), onMouseEnter: () => setHover(key), onClick: () => press(key) @@ -11156,7 +11253,7 @@ diff --git a/client.js b/client.js ); }; const shortcuts = [ -@@ -9671,30 +9673,30 @@ function SpanPicker({ span, today, oldest, active, near, onPick, onClose }) { +@@ -9671,30 +9671,30 @@ function SpanPicker({ span, today, oldest, active, near, onPick, onClose }) { ["year", t("This year"), `${today.slice(0, 4)}-01-01`, today] ]; const count = low === null ? 0 : Math.round((hourMs(high) - hourMs(low)) / DAY_MS) + 1; @@ -11195,7 +11292,7 @@ diff --git a/client.js b/client.js const today = hourMs(`${view.now.slice(0, 10)} 00`); const first = mondayOf(today) - (HEAT_WEEKS - 1) * 7 * DAY_MS; const from = dayKey(first); -@@ -9710,13 +9712,13 @@ function Heat({ view, keep, paint, index }) { +@@ -9710,13 +9710,13 @@ function Heat({ view, keep, paint, index }) { for (let week = 0; week < HEAT_WEEKS; week += 1) { const monday = first + week * 7 * DAY_MS; if (week > 0 && new Date(monday).getUTCMonth() !== new Date(monday - 7 * DAY_MS).getUTCMonth()) { @@ -11211,7 +11308,7 @@ diff --git a/client.js b/client.js key: ms, className: "bt-us-cell", style: { "--w": week }, -@@ -9724,7 +9726,7 @@ function Heat({ view, keep, paint, index }) { +@@ -9724,7 +9724,7 @@ function Heat({ view, keep, paint, index }) { "data-future": ms > today ? "" : void 0, "data-today": ms === today ? "" : void 0, onMouseEnter: ms > today ? void 0 : () => setHover({ ms, week, day }) @@ -11220,7 +11317,7 @@ diff --git a/client.js b/client.js } } const tipSide = hover ? { -@@ -9733,61 +9735,61 @@ function Heat({ view, keep, paint, index }) { +@@ -9733,61 +9733,61 @@ function Heat({ view, keep, paint, index }) { } : null; const active = [...daily.values()].filter((value) => value > 0).length; const summary = active === 1 ? t("Active on 1 day in the last year") : t("Active on {count} days in the last year", { count: active }); @@ -11308,7 +11405,7 @@ diff --git a/client.js b/client.js let stopped = false; let timer; const resized = Promise.all((document.querySelector(".bt-settings")?.getAnimations() ?? []).filter((animation) => animation.transitionProperty === "width" || animation.transitionProperty === "height").map((animation) => animation.finished.catch(() => { -@@ -9797,7 +9799,7 @@ function UsagePage({ roster, actions, focusId }) { +@@ -9797,7 +9797,7 @@ function UsagePage({ roster, actions, focusId }) { const value = await actions.usage(); await resized; if (stopped) return; @@ -11317,7 +11414,7 @@ diff --git a/client.js b/client.js setError(""); timer = setTimeout(load, value.scanning ? 3e3 : 2e4); } catch (failure) { -@@ -9814,7 +9816,7 @@ function UsagePage({ roster, actions, focusId }) { +@@ -9814,7 +9814,7 @@ function UsagePage({ roster, actions, focusId }) { }, []); const today = view?.now.slice(0, 10); const range = rangeId === "custom" && span ? { ...spanRange(span.from, span.to), ends: span.to >= today } : RANGES.find((item) => item.id === rangeId); @@ -11326,7 +11423,7 @@ diff --git a/client.js b/client.js if (!view) return null; const faces2 = view.owners.map((owner) => ownerFace(owner, roster)); const lifetime = /* @__PURE__ */ new Map(); -@@ -9823,7 +9825,7 @@ function UsagePage({ roster, actions, focusId }) { +@@ -9823,7 +9823,7 @@ function UsagePage({ roster, actions, focusId }) { const models2 = view.models.map((model, at) => { const name = model.name || model.model || t("Unknown model"); const paint = modelPaint.get(at) ?? "var(--bt-us-rest)"; @@ -11335,7 +11432,7 @@ diff --git a/client.js b/client.js }); const ownerAt2 = focus?.kind === "owner" ? view.owners.findIndex((owner) => owner.key === focus.key) : -1; const modelAt = focus?.kind === "model" ? view.models.findIndex((model) => model.key === focus.key) : -1; -@@ -9871,23 +9873,23 @@ function UsagePage({ roster, actions, focusId }) { +@@ -9871,23 +9871,23 @@ function UsagePage({ roster, actions, focusId }) { }); return { faces: faces2, models: models2, ownerAt: ownerAt2, modelAt, face: face2, keep: keep2, all: all2, part: part2, series: series2, tipSeries: tipSeries2, fillOf: fillOf2, columns: columns2, ranked: ranked2, isBot: isBot2, activeDays: activeDays2, oldest: oldest2 }; }, [view, rangeId, span, by, focusId, roster]); @@ -11367,7 +11464,7 @@ diff --git a/client.js b/client.js ) ); } -@@ -9912,7 +9914,7 @@ function UsagePage({ roster, actions, focusId }) { +@@ -9912,7 +9912,7 @@ function UsagePage({ roster, actions, focusId }) { ["custom", rangeId === "custom" && span ? spanText(span.from, span.to) : t("Custom"), CalendarIcon] ]; const whole = part.total.total; @@ -11376,7 +11473,7 @@ diff --git a/client.js b/client.js key: `o${at}`, kind: faces[at].kind, glyph: faces[at].glyph(32), -@@ -9924,7 +9926,7 @@ function UsagePage({ roster, actions, focusId }) { +@@ -9924,7 +9924,7 @@ function UsagePage({ roster, actions, focusId }) { fill: face ? soften(face.paint, 0.8) : fillOf.get(`o${at}`) ?? soften(faces[at].paint), onClick: () => pick2({ kind: "owner", key: view.owners[at].key }) }); @@ -11385,7 +11482,7 @@ diff --git a/client.js b/client.js key: `m${at}`, kind: "model", glyph: models[at].glyph(32), -@@ -9936,30 +9938,30 @@ function UsagePage({ roster, actions, focusId }) { +@@ -9936,30 +9936,30 @@ function UsagePage({ roster, actions, focusId }) { fill: face ? soften(face.paint, 0.8) : fillOf.get(`m${at}`) ?? soften(models[at].paint), onClick: () => pick2({ kind: "model", key: view.models[at].key }) }); @@ -11426,7 +11523,7 @@ diff --git a/client.js b/client.js ) ]; } else { -@@ -9968,39 +9970,39 @@ function UsagePage({ roster, actions, focusId }) { +@@ -9968,39 +9968,39 @@ function UsagePage({ roster, actions, focusId }) { const rest = owners.filter(([at]) => !isBot(at)); const usedModels = ranked(all.models); body = [ @@ -11480,7 +11577,7 @@ diff --git a/client.js b/client.js body ); } -@@ -10150,33 +10152,135 @@ button.bt-us-row:hover{background:var(--bt-hover)} +@@ -10150,33 +10150,135 @@ button.bt-us-row:hover{background:var(--bt-hover)} @keyframes bt-us-drop{from{opacity:0;transform:translateY(-4px) scale(.96)}} @keyframes bt-us-fade{from{opacity:0}} @keyframes bt-us-cell{from{opacity:0;transform:scale(.5)}} @@ -11627,7 +11724,7 @@ diff --git a/client.js b/client.js const run = (task, after) => { setBusy(true); setError(null); -@@ -10184,72 +10186,128 @@ function ConnectorRow({ connector, actions }) { +@@ -10184,72 +10184,128 @@ function ConnectorRow({ connector, actions }) { after?.(); }, (failure) => setError(failure?.message ?? String(failure))).finally(() => setBusy(false)); }; @@ -11781,7 +11878,7 @@ diff --git a/client.js b/client.js ); function download(name, text) { const url = URL.createObjectURL(new Blob([text], { type: "text/plain;charset=utf-8" })); -@@ -10260,7 +10262,7 @@ function download(name, text) { +@@ -10260,7 +10260,7 @@ function download(name, text) { setTimeout(() => URL.revokeObjectURL(url), 1e3); } var copy = (text) => navigator.clipboard === void 0 ? Promise.resolve(false) : navigator.clipboard.writeText(text).then(() => true, () => false); @@ -11790,7 +11887,7 @@ diff --git a/client.js b/client.js const date = new Date(time); const today = (/* @__PURE__ */ new Date()).toDateString() === date.toDateString(); const clock4 = date.toLocaleTimeString(dateLocale(), { hour: "2-digit", minute: "2-digit" }); -@@ -10270,18 +10272,18 @@ function entrySummary(entry) { +@@ -10270,18 +10270,18 @@ function entrySummary(entry) { if (entry.source === "turn") return failureLabel(entry.code, entry.text); return entry.text; } @@ -11815,7 +11912,7 @@ diff --git a/client.js b/client.js void run(load()); }, []); const copyReport = () => run(fetchReport().then(async (next) => { -@@ -10289,7 +10291,7 @@ function FeedbackPage({ actions, run, Section, Row: Row3 }) { +@@ -10289,7 +10289,7 @@ function FeedbackPage({ actions, run, Section, Row: Row3 }) { setNote(copied ? t("Copied the diagnostics.") : t("Could not copy. Select the text below and copy it.")); if (!copied) setPreview(true); })); @@ -11824,7 +11921,7 @@ diff --git a/client.js b/client.js const openIssue = () => { const win = window.open("", "_blank"); if (win !== null) win.opener = null; -@@ -10317,59 +10319,59 @@ function FeedbackPage({ actions, run, Section, Row: Row3 }) { +@@ -10317,59 +10317,59 @@ function FeedbackPage({ actions, run, Section, Row: Row3 }) { const total = (report?.total ?? 0) + browserErrors.length; const shown = entries2.slice(0, 8); return [ @@ -11909,7 +12006,7 @@ diff --git a/client.js b/client.js ) ) ]; -@@ -10377,8 +10379,8 @@ function FeedbackPage({ actions, run, Section, Row: Row3 }) { +@@ -10377,8 +10377,8 @@ function FeedbackPage({ actions, run, Section, Row: Row3 }) { var TOAST_MS = 6e3; function ErrorToast({ useUi, actions }) { const toast = useUi((state) => state.toast); @@ -11920,7 +12017,7 @@ diff --git a/client.js b/client.js if (!toast) return void 0; setLeaving(false); const fade = setTimeout(() => setLeaving(true), TOAST_MS); -@@ -10389,15 +10391,16 @@ function ErrorToast({ useUi, actions }) { +@@ -10389,15 +10389,16 @@ function ErrorToast({ useUi, actions }) { }; }, [toast?.token]); if (!toast) return null; @@ -11942,7 +12039,7 @@ diff --git a/client.js b/client.js ); } var FEEDBACK_CSS = ` -@@ -10428,35 +10430,44 @@ var FEEDBACK_CSS = ` +@@ -10428,35 +10428,44 @@ var FEEDBACK_CSS = ` `; // src/client/about.js @@ -11997,7 +12094,7 @@ diff --git a/client.js b/client.js let live = true; actions.repoStars().then((count) => { if (live && Number.isInteger(count)) setStars(count); -@@ -10466,87 +10468,72 @@ function AboutPage({ useUpdate, actions, run, Section, Row: Row3 }) { +@@ -10466,87 +10466,72 @@ function AboutPage({ useUpdate, actions, run, Section, Row: Row3 }) { live = false; }; }, []); @@ -12108,7 +12205,7 @@ diff --git a/client.js b/client.js .bt-about-copy{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px} .bt-about-name{font-size:16px;line-height:22px;font-weight:600} .bt-about-version{font-size:12px;line-height:16px;color:var(--bt-ink-2);font-variant-numeric:tabular-nums} -@@ -10569,54 +10571,59 @@ body:not([data-bt-motion=quiet]) .bt-about-mark:hover .bt-mark[data-live][data-s +@@ -10569,54 +10569,59 @@ body:not([data-bt-motion=quiet]) .bt-about-mark:hover .bt-mark[data-live][data-s `; // src/client/settings.js @@ -12185,7 +12282,7 @@ diff --git a/client.js b/client.js key: ink, type: "button", className: "bt-swatch", -@@ -10626,75 +10628,87 @@ function GeneralPage({ roster, actions, run }) { +@@ -10626,75 +10626,87 @@ function GeneralPage({ roster, actions, run }) { style: { background: INKS[ink][0] }, onClick: () => run(actions.setPrefs({ accent: INKS[ink][0] })) })), @@ -12296,7 +12393,7 @@ diff --git a/client.js b/client.js key: id, type: "button", "aria-pressed": motion === id, -@@ -10706,69 +10708,69 @@ function MotionSection({ roster, actions, run }) { +@@ -10706,69 +10706,69 @@ function MotionSection({ roster, actions, run }) { ); } function BotsPage({ roster, actions, run }) { @@ -12396,7 +12493,7 @@ diff --git a/client.js b/client.js useEscape(close); const run = (promise) => Promise.resolve(promise).then(() => { setError(""); -@@ -10779,104 +10781,112 @@ function SettingsDialog({ page, id, roster, actions, useUpdate, leaving = false +@@ -10779,104 +10779,112 @@ function SettingsDialog({ page, id, roster, actions, useUpdate, leaving = false }); const bot = page === "bots" && id ? roster.byId[id] : void 0; const room2 = page === "groups" && id ? roster.roomsById[id] : void 0; @@ -12550,7 +12647,7 @@ diff --git a/client.js b/client.js const bot = roster.byId[menu.id]; const room2 = roster.roomsById[menu.id]; const entry = bot ?? room2; -@@ -10884,13 +10886,22 @@ function ContextMenu({ menu, roster, actions, leaving = false }) { +@@ -10884,13 +10884,22 @@ function ContextMenu({ menu, roster, actions, leaving = false }) { const isMainBot = isMainOf(roster, menu.id); const isTile = roster.mainBotId === menu.id; const mainCount = (roster.mainBotIds ?? []).length; @@ -12575,7 +12672,7 @@ diff --git a/client.js b/client.js bot ? [t("Edit Bot"), GearIcon, () => actions.openSettings("bots", bot.id)] : null, room2 ? [t("Group settings"), GearIcon, () => actions.openSettings("groups", room2.id)] : null, room2 ? [t("Rename group"), PencilIcon, () => actions.renameBot(room2.id)] : null, -@@ -10901,20 +10903,22 @@ function ContextMenu({ menu, roster, actions, leaving = false }) { +@@ -10901,20 +10901,22 @@ function ContextMenu({ menu, roster, actions, leaving = false }) { bot && !isMainBot ? [t("Make Main Bot"), StarLineIcon, () => actions.setMain(bot.id, true)] : null, bot && isMainBot && mainCount > 1 ? [t("Remove Main Bot role"), StarLineIcon, () => actions.setMain(bot.id, false)] : null ], @@ -12604,7 +12701,7 @@ diff --git a/client.js b/client.js key: label, type: "button", role: "menuitem", -@@ -10923,14 +10925,14 @@ function ContextMenu({ menu, roster, actions, leaving = false }) { +@@ -10923,14 +10923,14 @@ function ContextMenu({ menu, roster, actions, leaving = false }) { actions.closeMenu(); void Promise.resolve(run()).catch((error) => actions.reportError(error, "menu")); } @@ -12623,7 +12720,7 @@ diff --git a/client.js b/client.js const box = ref.current?.getBoundingClientRect(); if (!box) return; setPlace({ left: Math.max(8, Math.min(menu.x, window.innerWidth - box.width - 8)), top: Math.max(8, Math.min(menu.y, window.innerHeight - box.height - 8)) }); -@@ -10952,14 +10954,14 @@ function AgentMenu({ menu, roster, actions, leaving }) { +@@ -10952,14 +10952,14 @@ function AgentMenu({ menu, roster, actions, leaving }) { [t("Archive"), TrashIcon, () => actions.archiveAgentSession(agent.id, menu.id), true] ] ]; @@ -12643,7 +12740,7 @@ diff --git a/client.js b/client.js key: label, type: "button", role: "menuitem", -@@ -10968,303 +10970,93 @@ function AgentMenu({ menu, roster, actions, leaving }) { +@@ -10968,303 +10968,93 @@ function AgentMenu({ menu, roster, actions, leaving }) { actions.closeMenu(); void Promise.resolve(run()).catch((error) => console.warn("[ds-bot]", error)); } @@ -13023,7 +13120,7 @@ diff --git a/client.js b/client.js function transcriptOf(list, id, name, hiddenTurns = []) { const outline = list.projectionsBySession?.[id]?.values?.turnOutline ?? list.byId[id]?.projectionValues?.turnOutline; if (!Array.isArray(outline)) return ""; -@@ -11289,10 +11291,10 @@ var EMPTY_TABS = { ids: [], active: null }; +@@ -11289,10 +11289,10 @@ var EMPTY_TABS = { ids: [], active: null }; var PERMISSION_LABELS = { "read-only": "Read only", "workspace-write": "Workspace", "danger-full-access": "Full access" }; var DANGER = /* @__PURE__ */ new Set(["danger-full-access"]); function PermissionRow({ bot, roster, actions, run }) { @@ -13036,7 +13133,7 @@ diff --git a/client.js b/client.js if (pending === saved) setPending(null); }, [pending, saved]); if (!catalog?.options?.length) return null; -@@ -11302,14 +11304,14 @@ function PermissionRow({ bot, roster, actions, run }) { +@@ -11302,14 +11302,14 @@ function PermissionRow({ bot, roster, actions, run }) { setPending(value); void run(actions.updateBot(bot.id, { permission: value })).catch(() => setPending(null)); }; @@ -13055,7 +13152,7 @@ diff --git a/client.js b/client.js key: option.value, type: "button", "aria-pressed": option.value === current, -@@ -11323,42 +11325,42 @@ function PermissionRow({ bot, roster, actions, run }) { +@@ -11323,42 +11323,42 @@ function PermissionRow({ bot, roster, actions, run }) { function BrowserCards({ bot, sessionId, roster, actions, useBrowserTabs, useBrowserPages }) { const state = useBrowserTabs((map) => map[bot.id] ?? EMPTY_TABS); const pages = useBrowserPages((map) => map); @@ -13113,7 +13210,7 @@ diff --git a/client.js b/client.js type: "button", className: "bt-bcard-x", "aria-label": t("Close tab"), -@@ -11367,87 +11369,194 @@ function BrowserCards({ bot, sessionId, roster, actions, useBrowserTabs, useBrow +@@ -11367,87 +11367,194 @@ function BrowserCards({ bot, sessionId, roster, actions, useBrowserTabs, useBrow void actions.browserCloseTab(bot.id, tabId); } }, "×"), @@ -13351,7 +13448,7 @@ diff --git a/client.js b/client.js if (last.current.view !== view) last.current = { view, tab, move: view === "settings" ? "in" : "out" }; else if (last.current.tab !== tab) last.current = { view, tab, move: "tab" }; const move = last.current.move; -@@ -11472,36 +11474,43 @@ function DetailsDrawer({ sessionId, startRenaming, startTab, hasStrip, roster, a +@@ -11472,36 +11472,43 @@ function DetailsDrawer({ sessionId, startRenaming, startTab, hasStrip, roster, a setTimeout(() => setCopied(false), 1600); }); }; @@ -13410,7 +13507,7 @@ diff --git a/client.js b/client.js ]; } const current = bot?.name ?? room2.name; -@@ -11515,12 +11517,12 @@ function DetailsDrawer({ sessionId, startRenaming, startTab, hasStrip, roster, a +@@ -11515,12 +11515,12 @@ function DetailsDrawer({ sessionId, startRenaming, startTab, hasStrip, roster, a const subtitle = bot ? bot.role : roomSubtitle(room2, roster); const tabs = bot ? ["details", "browser"] : ["details"]; return [ @@ -13427,7 +13524,7 @@ diff --git a/client.js b/client.js className: "bt-drawer-name-input", autoFocus: true, value: draftName, -@@ -11535,87 +11537,86 @@ function DetailsDrawer({ sessionId, startRenaming, startTab, hasStrip, roster, a +@@ -11535,87 +11535,86 @@ function DetailsDrawer({ sessionId, startRenaming, startTab, hasStrip, roster, a setEditing(false); } } @@ -13557,7 +13654,7 @@ diff --git a/client.js b/client.js } var savedWidth = () => { try { -@@ -11625,8 +11627,8 @@ var savedWidth = () => { +@@ -11625,8 +11625,8 @@ var savedWidth = () => { return null; } }; @@ -13567,7 +13664,7 @@ diff --git a/client.js b/client.js var room = { rule: null, selector: "" }; var roomRule = () => { const live = room.rule?.parentStyleSheet?.ownerNode?.isConnected; -@@ -11674,11 +11676,11 @@ var reducedMotion2 = () => typeof matchMedia === "function" && matchMedia("(pref +@@ -11674,11 +11674,11 @@ var reducedMotion2 = () => typeof matchMedia === "function" && matchMedia("(pref var tabLabel = (page) => page.title || shortAddress(page.url) || t("New tab"); function PanelStrip({ bot, subject, view, ids, active, reading, onView, actions, useBrowserPages }) { const pages = useBrowserPages((map) => map); @@ -13584,7 +13681,7 @@ diff --git a/client.js b/client.js const box = wrap.current; const capsule = ind.current; if (!box || !capsule) return void 0; -@@ -11707,7 +11709,7 @@ function PanelStrip({ bot, subject, view, ids, active, reading, onView, actions, +@@ -11707,7 +11707,7 @@ function PanelStrip({ bot, subject, view, ids, active, reading, onView, actions, trail.cancel(); }; }); @@ -13593,7 +13690,7 @@ diff --git a/client.js b/client.js const box = wrap.current; if (!box) return void 0; const observer = new ResizeObserver(() => { -@@ -11721,13 +11723,13 @@ function PanelStrip({ bot, subject, view, ids, active, reading, onView, actions, +@@ -11721,13 +11721,13 @@ function PanelStrip({ bot, subject, view, ids, active, reading, onView, actions, observer.observe(box); return () => observer.disconnect(); }, []); @@ -13610,7 +13707,7 @@ diff --git a/client.js b/client.js "button", { type: "button", -@@ -11737,13 +11739,13 @@ function PanelStrip({ bot, subject, view, ids, active, reading, onView, actions, +@@ -11737,13 +11737,13 @@ function PanelStrip({ bot, subject, view, ids, active, reading, onView, actions, title: bot.name, onClick: () => onView("details") }, @@ -13627,7 +13724,7 @@ diff --git a/client.js b/client.js "button", { key: tabId, -@@ -11764,9 +11766,9 @@ function PanelStrip({ bot, subject, view, ids, active, reading, onView, actions, +@@ -11764,9 +11764,9 @@ function PanelStrip({ bot, subject, view, ids, active, reading, onView, actions, } } }, @@ -13640,7 +13737,7 @@ diff --git a/client.js b/client.js className: "bt-ptab-x", role: "button", "aria-label": t("Close tab"), -@@ -11778,20 +11780,20 @@ function PanelStrip({ bot, subject, view, ids, active, reading, onView, actions, +@@ -11778,20 +11778,20 @@ function PanelStrip({ bot, subject, view, ids, active, reading, onView, actions, }, "×") ); }), @@ -13668,7 +13765,7 @@ diff --git a/client.js b/client.js const element = frame.current; if (!element || leaving || !tabId) { void actions.browserShow(null); -@@ -11817,7 +11819,7 @@ function BrowserView({ bot, tabId, leaving, landed, actions, useBrowserPages }) +@@ -11817,7 +11817,7 @@ function BrowserView({ bot, tabId, leaving, landed, actions, useBrowserPages }) window.removeEventListener("resize", schedule); }; }, [tabId, showGuest, leaving]); @@ -13677,7 +13774,7 @@ diff --git a/client.js b/client.js setDraft(null); if (landed && !page.url) input.current?.focus(); }, [tabId, landed]); -@@ -11833,27 +11835,27 @@ function BrowserView({ bot, tabId, leaving, landed, actions, useBrowserPages }) +@@ -11833,27 +11833,27 @@ function BrowserView({ bot, tabId, leaving, landed, actions, useBrowserPages }) }; const editing = draft !== null; const secure = /^https:/i.test(page.url); @@ -13714,7 +13811,7 @@ diff --git a/client.js b/client.js ref: input, type: "text", spellCheck: false, -@@ -11877,25 +11879,25 @@ function BrowserView({ bot, tabId, leaving, landed, actions, useBrowserPages }) +@@ -11877,25 +11877,25 @@ function BrowserView({ bot, tabId, leaving, landed, actions, useBrowserPages }) } }) ), @@ -13750,7 +13847,7 @@ diff --git a/client.js b/client.js ) : null ) ); -@@ -11907,41 +11909,41 @@ function BotPanel({ pane, leaving, roster, actions, useSessions, useSessionStatu +@@ -11907,41 +11907,41 @@ function BotPanel({ pane, leaving, roster, actions, useSessions, useSessionStatu const tabState = useBrowserTabs((map) => bot ? map[bot.id] ?? EMPTY_TABS2 : EMPTY_TABS2); const active = tabState.active; const page = useBrowserPages((pages) => (active ? pages[active] : null) ?? EMPTY_PAGE); @@ -13808,7 +13905,7 @@ diff --git a/client.js b/client.js let timer; const onResize = () => { setResizing(true); -@@ -11955,28 +11957,28 @@ function BotPanel({ pane, leaving, roster, actions, useSessions, useSessionStatu +@@ -11955,28 +11955,28 @@ function BotPanel({ pane, leaving, roster, actions, useSessions, useSessionStatu window.removeEventListener("resize", onResize); }; }, []); @@ -13844,7 +13941,7 @@ diff --git a/client.js b/client.js if (!page.readAt || Date.now() - page.readAt > READ_FLASH_MS) return void 0; setReading(true); const timer = setTimeout(() => setReading(false), READ_FLASH_MS); -@@ -12013,7 +12015,7 @@ function BotPanel({ pane, leaving, roster, actions, useSessions, useSessionStatu +@@ -12013,7 +12013,7 @@ function BotPanel({ pane, leaving, roster, actions, useSessions, useSessionStatu return actions[name](...args); }; } @@ -13853,7 +13950,7 @@ diff --git a/client.js b/client.js const move = fly.current; if (!move) return; fly.current = null; -@@ -12096,7 +12098,7 @@ function BotPanel({ pane, leaving, roster, actions, useSessions, useSessionStatu +@@ -12096,7 +12096,7 @@ function BotPanel({ pane, leaving, roster, actions, useSessions, useSessionStatu for (const name of ends) grip.addEventListener(name, onEnd); }; const hasStrip = canBrowse && tabState.ids.length > 0; @@ -13862,7 +13959,7 @@ diff --git a/client.js b/client.js "aside", { ref: root, -@@ -12113,15 +12115,15 @@ function BotPanel({ pane, leaving, roster, actions, useSessions, useSessionStatu +@@ -12113,15 +12113,15 @@ function BotPanel({ pane, leaving, roster, actions, useSessions, useSessionStatu if (event.target === event.currentTarget) setLanded(true); } }, @@ -13883,7 +13980,7 @@ diff --git a/client.js b/client.js sessionId: pane.id, startRenaming: pane.renaming, startTab: pane.tab, -@@ -12217,8 +12219,78 @@ var BROWSER_CSS = ` +@@ -12217,8 +12217,78 @@ var BROWSER_CSS = ` @media (prefers-reduced-motion:reduce){.bt-ptabs,.bt-panel-view,.bt-bcard,.bt-ptab-name{animation:none}} `; @@ -13963,7 +14060,7 @@ diff --git a/client.js b/client.js var import_react_dom9 = require("react-dom"); var DIALOG_EXIT_MS = 160; var MENU_EXIT_MS3 = 120; -@@ -12227,15 +12229,15 @@ function Overlays({ useRoster, useUi, useSessions, useSessionStatus, useActivity +@@ -12227,15 +12227,15 @@ function Overlays({ useRoster, useUi, useSessions, useSessionStatus, useActivity const ui2 = useUi((value) => value); useRegistry((value) => value); const current = useSessions((list) => Object.values(list.byId).find((session) => (session?.retainedBy?.mainView ?? 0) > 0)?.id ?? null); @@ -13983,7 +14080,7 @@ diff --git a/client.js b/client.js if (open) { setKept(open); return void 0; -@@ -12245,19 +12247,24 @@ function Overlays({ useRoster, useUi, useSessions, useSessionStatus, useActivity +@@ -12245,19 +12245,24 @@ function Overlays({ useRoster, useUi, useSessions, useSessionStatus, useActivity }, [ui2.details, ui2.view, ui2.renaming, ui2.tab]); const pane = open ?? kept; const [settings, settingsLeaving] = useLinger(ui2.settings, DIALOG_EXIT_MS); @@ -14018,7 +14115,7 @@ diff --git a/client.js b/client.js ); } -@@ -12304,7 +12306,7 @@ function readPage(limits) { +@@ -12304,7 +12304,7 @@ function readPage(limits) { `; }; const expand = (text2) => text2.replace(/\u0001(\d+)[\u0002\u0003]/g, (_, index) => expand(stash[Number(index)])); @@ -14027,7 +14124,7 @@ diff --git a/client.js b/client.js const block = (text2) => expand(flow(text2)); const oneLine = (text2) => block(text2).replace(/\s*\n+\s*/g, " ").trim(); const styleOf = (el) => (el.ownerDocument.defaultView || window).getComputedStyle(el); -@@ -12372,7 +12374,7 @@ ${fence}`; +@@ -12372,7 +12372,7 @@ ${fence}`; const marker = ordered ? `${number++}. ` : "- "; if (body2 === "") continue; const pad2 = " ".repeat(marker.length); @@ -14036,7 +14133,7 @@ diff --git a/client.js b/client.js } return keep(lines.join("\n")); }; -@@ -12398,11 +12400,11 @@ ${children(cell, ctx)} +@@ -12398,11 +12398,11 @@ ${children(cell, ctx)} let width = 0; for (const row of rows.slice(0, limits.tableRows + 1)) { if (over()) break; @@ -14050,7 +14147,7 @@ diff --git a/client.js b/client.js carry[column].left -= 1; column += 1; } -@@ -12414,14 +12416,14 @@ ${children(cell, ctx)} +@@ -12414,14 +12414,14 @@ ${children(cell, ctx)} const span = Math.max(1, Math.min(limits.tableCols, Number(cell.colSpan) || 1)); const down = Math.max(1, Math.min(limits.tableRows, Number(cell.rowSpan) || 1)); for (let offset = 0; offset < span; offset += 1) { @@ -14068,7 +14165,7 @@ diff --git a/client.js b/client.js } width = Math.min(width, limits.tableCols); if (grid.length === 0 || width === 0) return caption ? ` -@@ -12552,7 +12554,7 @@ ${"#".repeat(Math.min(6, Number(level) || 2))} ${title}${mark} +@@ -12552,7 +12552,7 @@ ${"#".repeat(Math.min(6, Number(level) || 2))} ${title}${mark} } if (tag === "blockquote") { const quoted = block(body2); @@ -14077,7 +14174,7 @@ diff --git a/client.js b/client.js } const end = body2.trimEnd().length; const marked = mark ? `${body2.slice(0, end)}${mark}${body2.slice(end)}` : body2; -@@ -13052,11 +13054,63 @@ function serveBrowserReads({ browsers, post, clientId = globalThis.crypto.random +@@ -13052,11 +13052,63 @@ function serveBrowserReads({ browsers, post, clientId = globalThis.crypto.random }; } @@ -14142,7 +14239,7 @@ diff --git a/client.js b/client.js function disposeAll(disposers, label) { for (const dispose of [...disposers].reverse()) { try { -@@ -13075,8 +13077,8 @@ function surfaceGroups(install) { +@@ -13075,8 +13075,8 @@ function surfaceGroups(install) { }; return { reconcile(wanted) { @@ -14153,7 +14250,7 @@ diff --git a/client.js b/client.js if (!wanted[group] || mounted[group].length > 0) continue; try { mounted[group] = install[group](); -@@ -13086,7 +13088,7 @@ function surfaceGroups(install) { +@@ -13086,7 +13086,7 @@ function surfaceGroups(install) { } }, dispose() { @@ -14162,7 +14259,7 @@ diff --git a/client.js b/client.js } }; } -@@ -13105,7 +13107,7 @@ function writeMode(mode, storage = globalThis.localStorage) { +@@ -13105,7 +13105,7 @@ function writeMode(mode, storage = globalThis.localStorage) { } // src/client/user-message.js @@ -14171,7 +14268,7 @@ diff --git a/client.js b/client.js var ui = __toESM(require("@deepseek-ai/dsh-client-ui-primitives"), 1); var canDrawUserMessages = () => typeof ui.projectUserText === "function"; function contentParts(content) { -@@ -13119,10 +13121,41 @@ function contentParts(content) { +@@ -13119,10 +13119,41 @@ function contentParts(content) { } return { text: texts2.join("\n"), attachments, rest }; } @@ -14216,7 +14313,7 @@ diff --git a/client.js b/client.js } function userTextParts(text, bots, referenceLabels = []) { const parts = []; -@@ -13132,43 +13134,44 @@ function userTextParts(text, bots, referenceLabels = []) { +@@ -13132,43 +13132,44 @@ function userTextParts(text, bots, referenceLabels = []) { if (span.start > cursor) parts.push({ text: text.slice(cursor, span.start) }); parts.push({ bot: span.bot, label: text.slice(span.start + 1, span.end) }); cursor = span.end; @@ -14272,7 +14369,7 @@ diff --git a/client.js b/client.js } var clock3 = (time) => typeof time === "number" ? new Date(time).toLocaleTimeString(dateLocale(), { hour: "numeric", minute: "2-digit" }) : void 0; function UserMessageCell({ node, sessionId, useRoster, actions, renderMessageImages, openFile, openSkill }) { -@@ -13178,28 +13180,52 @@ function UserMessageCell({ node, sessionId, useRoster, actions, renderMessageIma +@@ -13178,28 +13178,52 @@ function UserMessageCell({ node, sessionId, useRoster, actions, renderMessageIma const referenceLabels = data.referenceLabels ?? []; const references = openFile && openSkill ? { openFile, openSkill } : void 0; const steering = node.kind === "steering"; @@ -14334,7 +14431,7 @@ diff --git a/client.js b/client.js .bt-ubody a{color:inherit} .bt-umention{display:inline-flex;align-items:center;gap:3px;vertical-align:-3px;max-width:100%;padding:0 1px;border:0;background:none;color:inherit;font:inherit;font-weight:600;line-height:inherit;cursor:pointer;border-radius:6px;white-space:nowrap} .bt-umention:hover{text-decoration:underline;text-underline-offset:2px} -@@ -13216,42 +13218,114 @@ var USER_MESSAGE_CSS = ` +@@ -13216,42 +13216,114 @@ var USER_MESSAGE_CSS = ` `; // src/client/approval-notices.js @@ -14462,7 +14559,7 @@ diff --git a/client.js b/client.js migrateStorage(); const UNREAD_KEY = STORAGE.unread; let savedUnread = {}; -@@ -13274,7 +13276,13 @@ function apply(ctx) { +@@ -13274,7 +13274,13 @@ function apply(ctx) { } catch { seenAt = {}; } @@ -14477,7 +14574,7 @@ diff --git a/client.js b/client.js const surface = createSource({ mode: readMode(), plain: false }); let agentSessionCache = /* @__PURE__ */ new Set(); try { -@@ -13344,13 +13346,25 @@ function apply(ctx) { +@@ -13344,13 +13344,25 @@ function apply(ctx) { } return { ...state, unread }; }); @@ -14504,7 +14601,7 @@ diff --git a/client.js b/client.js if (order < applied) return; applied = order; const nextActivity = JSON.stringify(value.activity ?? {}); -@@ -13368,10 +13370,15 @@ function apply(ctx) { +@@ -13368,10 +13368,15 @@ 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; @@ -14520,7 +14617,7 @@ diff --git a/client.js b/client.js try { const ids = (value.agents ?? []).flatMap((agent) => agent.sessions ?? []); localStorage.setItem(STORAGE.agentSessions, JSON.stringify(ids)); -@@ -13501,7 +13503,7 @@ function apply(ctx) { +@@ -13501,7 +13501,7 @@ function apply(ctx) { }; render(); const offRoster = roster.subscribe(render); @@ -14529,7 +14626,7 @@ diff --git a/client.js b/client.js return () => { offRoster(); offRegistry(); -@@ -13520,7 +13522,7 @@ function apply(ctx) { +@@ -13520,7 +13520,7 @@ function apply(ctx) { const accent = isHex(prefs.accent) ? prefs.accent : void 0; const motion = prefs.motion === "quiet" || prefs.motion === "lively" ? prefs.motion : "normal"; document.body.dataset.btMotion = motion; @@ -14538,7 +14635,7 @@ diff --git a/client.js b/client.js if (next === key) return; key = next; style.textContent = themeCss(themes[id], accent); -@@ -13536,7 +13538,7 @@ function apply(ctx) { +@@ -13536,7 +13536,7 @@ function apply(ctx) { }; render(); const offRoster = roster.subscribe(render); @@ -14547,7 +14644,7 @@ diff --git a/client.js b/client.js return () => { offRoster(); offRegistry(); -@@ -13588,6 +13590,7 @@ function apply(ctx) { +@@ -13588,6 +13588,7 @@ function apply(ctx) { // or the click appears to do nothing. isAgentSession: (id) => isAgentSessionId(id), openSession: (id) => { @@ -14555,7 +14652,7 @@ diff --git a/client.js b/client.js if (isAgentSessionId(id)) return actions.openAgentSession(id); ui2.set((state) => state.newChat || state.exchange || state.menu || state.palette ? { ...state, newChat: null, exchange: null, menu: null, palette: null } : state); setPlain(false); -@@ -13653,12 +13655,12 @@ function apply(ctx) { +@@ -13653,12 +13653,12 @@ function apply(ctx) { }, // `last` is the Bot conversation the view held before it cleared: when that part // was archived for a fresh one, the view goes on in the fresh one. @@ -14571,7 +14668,7 @@ diff --git a/client.js b/client.js expected = target; ctx.uiWorkspace.openSession(target); }, -@@ -13708,10 +13710,15 @@ function apply(ctx) { +@@ -13708,10 +13708,15 @@ function apply(ctx) { }, // `id` opens one Bot (page 'bots') or one group chat (page 'groups'). openSettings: (page = "general", id = null) => { @@ -14589,7 +14686,7 @@ diff --git a/client.js b/client.js }, openMemory: (botId) => { ui2.set((state) => ({ ...state, memory: botId, menu: null, palette: null })); -@@ -13719,6 +13721,13 @@ function apply(ctx) { +@@ -13719,6 +13719,13 @@ function apply(ctx) { closeMemory: () => { ui2.set((state) => state.memory ? { ...state, memory: null } : state); }, @@ -14603,7 +14700,7 @@ diff --git a/client.js b/client.js openConnectors: () => { ui2.set((state) => ({ ...state, settings: { page: "connectors", id: null }, menu: null, palette: null, newChat: null })); }, -@@ -13728,7 +13730,7 @@ function apply(ctx) { +@@ -13728,7 +13728,7 @@ function apply(ctx) { if (mode !== "bot" && mode !== "agent") return; if (surface.getSnapshot().mode === mode) return; if (mode === "agent") { @@ -14612,7 +14709,7 @@ diff --git a/client.js b/client.js } writeMode(mode); surface.set((state) => ({ ...state, mode })); -@@ -13757,6 +13759,12 @@ function apply(ctx) { +@@ -13757,6 +13757,12 @@ function apply(ctx) { const seen = seenAt[seenKey(id)] ?? (made?.createdBy && Number.isFinite(made.createdAt) ? made.createdAt : null); ui2.set((state) => ({ ...state, visits: { ...state.visits, [id]: { seen, entered: Date.now() } } })); }, @@ -14625,7 +14722,7 @@ diff --git a/client.js b/client.js endVisit: (id) => { leaveVisits([id]); ui2.set((state) => { -@@ -13812,6 +13814,7 @@ function apply(ctx) { +@@ -13812,6 +13812,7 @@ function apply(ctx) { answerQuestion: (sessionId, questionId, answer) => after(call("answer-question", { sessionId: partFor(sessionId), questionId, answer })), dismissQuestion: (sessionId) => after(call("dismiss-question", { sessionId })), answerApproval: (id, choice) => after(call("approval-answer", { id, choice })), @@ -14633,7 +14730,7 @@ diff --git a/client.js b/client.js stopMemberWork: (roomId, botId) => after(call("stop-member-work", { roomId, botId })), // Values go out in these requests and never come back in a response. secretSet: (payload) => after(call("secret-set", payload)), -@@ -13819,15 +13821,24 @@ function apply(ctx) { +@@ -13819,15 +13819,24 @@ function apply(ctx) { secretCancel: (requestId) => after(call("secret-cancel", { requestId })), secretUpdate: (name, scope) => after(call("secret-update", { name, scope })), secretDelete: (name) => after(call("secret-delete", { name })), @@ -14659,7 +14756,7 @@ diff --git a/client.js b/client.js scheduleDelete: (sessionId, id) => call("schedule-delete", { sessionId, id }), exchange: (a, b) => call("exchange", { a, b }).catch(() => []), roomProgress: (roomId) => call("room-progress", { roomId }).catch(() => []), -@@ -13846,6 +13848,8 @@ function apply(ctx) { +@@ -13846,6 +13846,8 @@ function apply(ctx) { noteClientError(where, error); ui2.set((state) => ({ ...state, toast: { text: error?.message ?? String(error), token: Date.now() } })); }, @@ -14668,7 +14765,7 @@ diff --git a/client.js b/client.js dismissToast: (token) => ui2.set((state) => state.toast && (token === void 0 || state.toast.token === token) ? { ...state, toast: null } : state) }; if (browsers) { -@@ -13890,6 +13892,7 @@ function apply(ctx) { +@@ -13890,6 +13890,7 @@ function apply(ctx) { actions.closePanel = () => { ui2.set((state) => state.details ? { ...state, details: null, view: "details", renaming: false, tab: null } : state); }; @@ -14676,7 +14773,7 @@ diff --git a/client.js b/client.js actions.signInEntry = () => ctx.slots.entries?.("settings.models.sign-in")?.[0] ?? null; actions.canSignIn = () => Boolean(actions.signInEntry()); actions.signIn = () => ui2.set((state) => ({ ...state, signIn: (state.signIn ?? 0) + 1 })); -@@ -13964,7 +13966,13 @@ function apply(ctx) { +@@ -13964,7 +13964,13 @@ function apply(ctx) { }); const browserPages = browsers?.pages ?? createSource({}); const browserTabs = browsers?.tabs ?? createSource({}); @@ -14691,7 +14788,7 @@ diff --git a/client.js b/client.js ctx.effect(() => noticeApprovals({ work, roster, -@@ -13989,7 +13991,17 @@ function apply(ctx) { +@@ -13989,7 +13989,17 @@ function apply(ctx) { } }; }; const shadow = (name, options, component) => ctx.slots.inject(name, () => ctx.slots.register({ name, ...options }, component)); @@ -14709,7 +14806,7 @@ diff --git a/client.js b/client.js const installAtMenu = () => { const fiber = ctx.inject(["inputTriggers"], (scope) => { scope.effect(() => { -@@ -14001,7 +14003,12 @@ function apply(ctx) { +@@ -14001,7 +14001,12 @@ function apply(ctx) { const launched = (sessionId) => controllerOf(sessionId)?.launcher.getSnapshot() === CHATS_SOURCE; const offs = atSources({ roster, sessions: ctx.sessions, launched, iconFor: menuIcons(roster) }).map((source) => triggers.registerSource(source)); atMenu = { controllerOf }; @@ -14722,7 +14819,7 @@ diff --git a/client.js b/client.js atMenu = null; for (const off of offs) off(); }; -@@ -14010,6 +14012,14 @@ function apply(ctx) { +@@ -14010,6 +14010,14 @@ function apply(ctx) { return () => fiber.dispose(); }; actions.canReferenceChat = () => atMenu !== null; @@ -14737,7 +14834,7 @@ diff --git a/client.js b/client.js actions.referenceChat = (sessionId, span) => { const controller = atMenu?.controllerOf(sessionId); if (controller === void 0 || span === void 0) return; -@@ -14019,33 +14021,44 @@ function apply(ctx) { +@@ -14019,33 +14019,44 @@ function apply(ctx) { installCompiledStyles(SHELL_CSS, "shell"), installCompiledStyles(SIDEBAR_CSS, "sidebar"), installCompiledStyles(SECRET_CSS, "secrets"), @@ -14786,7 +14883,7 @@ diff --git a/client.js b/client.js shadow("conversation.chat.node", { key: "assistant-step", priority: -10, locale: "chat", inject: assistantFace }, AssistantCell), shadow("conversation.chat.node", { key: "turn-trigger", priority: -10, locale: "chat", inject: face }, TriggerCell), shadow("conversation.chat.node", { key: "tool-call", priority: -10, locale: "chat", inject: face }, ToolCell), -@@ -14057,15 +14059,24 @@ function apply(ctx) { +@@ -14057,15 +14057,24 @@ function apply(ctx) { // every message. shadow("conversation.chat.node", { key: "question-reply", priority: -10, locale: "chat" }, AnsweredCell), shadow("conversation.chat.node", { key: "compaction", priority: -10, locale: "chat" }, Nothing), @@ -14814,7 +14911,7 @@ diff --git a/client.js b/client.js shadow("conversation.input.right", { id: "bot-voice", order: 100, inject: face }, ComposerVoice), shadow("conversation.composer", { priority: -10, -@@ -14074,7 +14076,8 @@ function apply(ctx) { +@@ -14074,7 +14074,8 @@ function apply(ctx) { shadow("conversation.input.dock", { id: "bot-members", order: -120, inject: face }, MemberDock), shadow("conversation.input.dock", { id: "bot-model", order: -110, inject: face }, ModelChoiceDock), shadow("conversation.input.dock", { id: "bot-question", order: -100, inject: face }, QuestionDock), diff --git a/patches/ds-bot/files/src/client/flybot-slots.js b/patches/ds-bot/files/src/client/flybot-slots.js index 097a9903..44e1df97 100644 --- a/patches/ds-bot/files/src/client/flybot-slots.js +++ b/patches/ds-bot/files/src/client/flybot-slots.js @@ -34,6 +34,7 @@ import { call as callHost } from './api.js' import { friendBotOwner, friendBotTitle, isListedRemoteChat } from '../shared/remote-label.js' import { registry as baseRegistry } from './flybot-registry.js' import { BotAvatar, BotMark, lookOf } from './mark.js' +import { RoomInvitePreview, inviteCountdown, invitePreviewOf } from './room-invite-preview.js' import { t } from './i18n.js' import { chatOpened, claimApprovals, claimedIds, onClaims } from './approval-claims.js' import { isApprovalWaiting, WAITING_STATUSES } from '../shared/approval-waiting.js' @@ -54,6 +55,11 @@ export function registry() { globalThis.__flybot__.BotMark ??= BotMark globalThis.__flybot__.BotAvatar ??= BotAvatar globalThis.__flybot__.lookOf ??= lookOf + // One room-invite preview card for every surface that shows an invite (#876): the face-room paste + // flow and the member-invite dialog render this, so the markup is not written twice. + globalThis.__flybot__.RoomInvitePreview ??= RoomInvitePreview + globalThis.__flybot__.invitePreviewOf ??= invitePreviewOf + globalThis.__flybot__.inviteCountdown ??= inviteCountdown // The same for a friend's Bot's one name and its owner (#498), so the social plugin says it as the sidebar does. globalThis.__flybot__.names ??= { friendBotTitle, friendBotOwner } return slots diff --git a/patches/ds-bot/files/src/client/room-invite-preview.js b/patches/ds-bot/files/src/client/room-invite-preview.js new file mode 100644 index 00000000..ec9500a6 --- /dev/null +++ b/patches/ds-bot/files/src/client/room-invite-preview.js @@ -0,0 +1,111 @@ +// One room-invite preview card, drawn wherever an invite is shown (#876). The face-room paste flow +// and the member-invite dialog both render this component, so the markup exists once: a change here +// is visible in both, and neither surface keeps a copy. +// +// It is a plain function, not a hook component: the caller passes its own `createElement` as `h`, +// which is what lets the core bundle and a classic-script plugin (which gets `h` from `require`) +// draw the same card. Everything the card says comes from a server reply, so the strings are +// cleaned here, at the last place before a person reads them — the same rules the server applies to +// a room name (`cleanFaceName`): one line, no controls, brackets, markup or @, capped. +export const INVITE_PREVIEW_NAME_MAX = 60 + +// Untrusted fence markers are stripped to a fixed point: one pass can remove a match and assemble a +// new one, so a nested `` would otherwise leave a complete +// `` in the card a person reads (#822). +export function stripInviteMarkers(text) { + let out = String(text ?? '') + for (let previous; previous !== out;) { + previous = out + out = out.replace(/<\/?\s*untrusted-(?:file|message)/gi, '') + } + return out +} + +export function cleanInviteName(value, fallback = '') { + return stripInviteMarkers(value) + .replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g, ' ') + .replace(/[[\]"<>`@]/g, '') + .replace(/\s+/g, ' ') + .trim() + .slice(0, INVITE_PREVIEW_NAME_MAX) || fallback +} + +// A count is only what reads as one (#823): `Number('')` is 0, which would draw an unknown room as +// empty, so an empty or non-numeric string is "not sent", like null. +export function inviteMemberCount(value) { + if (typeof value === 'number') return Number.isFinite(value) ? value : NaN + if (typeof value !== 'string') return NaN + const text = value.trim() + if (!text) return NaN + const count = Number(text) + return Number.isFinite(count) ? count : NaN +} + +// What the card can honestly say, and nothing else: a reply with no field worth showing is no card +// at all, so the caller keeps its own path (a paste that previews nothing still joins as before). +export function invitePreviewOf(seen) { + if (!seen || typeof seen !== 'object' || seen.unsupported === true) return null + const name = cleanInviteName(seen.name) + const lead = cleanInviteName(seen.lead) + const members = inviteMemberCount(seen.members) + const maxMembers = inviteMemberCount(seen.maxMembers) + const people = Number.isFinite(members) && Number.isFinite(maxMembers) + ? `${members} / ${maxMembers} in the room` + : Number.isFinite(members) ? `${members} in the room` : '' + const expiresAt = typeof seen.expiresAt === 'string' ? seen.expiresAt : '' + if (!name && !lead && !people && !expiresAt) return null + return { name, lead, people, expiresAt, clockOffsetMs: Number(seen.clockOffsetMs) || 0 } +} + +// Every home counts down to the server's one deadline (#458): `offsetMs` is how far the server's +// clock runs ahead of this one (0 unless the skew is real). m:ss, so the line visibly ticks. +export function inviteCountdown(expiresAt, offsetMs = 0) { + const at = Date.parse(expiresAt) - (Number(offsetMs) || 0) + if (!Number.isFinite(at)) return 'Open while the room is open' + const left = Math.max(0, at - Date.now()) + if (left <= 0) return 'This link has expired' + const total = Math.max(1, Math.ceil(left / 1000)) + return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, '0')} left to join` +} + +// `kind` prefixes every `data-flybot` name, so each surface keeps the hook its tests and its CSS +// already use while sharing this one tree of elements. +export function RoomInvitePreview({ h, preview, kind = 'face-link-preview', busy = false, joinLabel = 'Join', clearLabel = 'Clear', showClear = true, onJoin, onClear, now } = {}) { + if (!preview || typeof h !== 'function') return null + const at = `${kind}` + const child = (name) => `${at}-${name}` + const countdown = now === undefined + ? inviteCountdown(preview.expiresAt, preview.clockOffsetMs) + : inviteCountdownAt(preview.expiresAt, preview.clockOffsetMs, now) + return h('div', { className: 'fs-preview', 'data-flybot': at, role: 'status' }, + h('div', { className: 'fs-preview-name', 'data-flybot': child('name') }, preview.name || 'Face-to-face room'), + preview.lead ? h('div', { className: 'fs-preview-meta', 'data-flybot': child('lead') }, `Lead: ${preview.lead}`) : null, + preview.people ? h('div', { className: 'fs-preview-meta', 'data-flybot': child('members') }, preview.people) : null, + preview.expiresAt ? h('div', { className: 'fs-preview-meta', 'data-flybot': child('expiry') }, countdown) : null, + onJoin || onClear + ? h('div', { className: 'fs-preview-foot', 'data-flybot': child('foot') }, + showClear && onClear + ? h('button', { + type: 'button', className: 'fs-btn fs-btn-sm', 'data-flybot': child('clear'), + disabled: !!busy, onClick: onClear, + }, clearLabel) + : null, + onJoin + ? h('button', { + type: 'button', className: 'fs-btn fs-btn-sm fs-btn-primary', 'data-flybot': child('join'), + disabled: !!busy, onClick: onJoin, + }, joinLabel) + : null) + : null) +} + +// The same text as `inviteCountdown`, measured against a clock the caller already has — a render is +// then reproducible in a test without freezing the machine's clock. +export function inviteCountdownAt(expiresAt, offsetMs, now) { + const at = Date.parse(expiresAt) - (Number(offsetMs) || 0) + if (!Number.isFinite(at)) return 'Open while the room is open' + const left = Math.max(0, at - Number(now || 0)) + if (left <= 0) return 'This link has expired' + const total = Math.max(1, Math.ceil(left / 1000)) + return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, '0')} left to join` +} diff --git a/patches/ds-bot/files/src/host/authz.js b/patches/ds-bot/files/src/host/authz.js index e75c860f..9ac1c622 100644 --- a/patches/ds-bot/files/src/host/authz.js +++ b/patches/ds-bot/files/src/host/authz.js @@ -47,7 +47,10 @@ const BOT_TARGETED = new Set([ ]) // Endpoints whose body names a room. -const ROOM_TARGETED = new Set(['update-room', 'delete-room', 'room-progress', 'create-room', 'room-history', 'room-post']) +const ROOM_TARGETED = new Set(['update-room', 'delete-room', 'room-progress', 'create-room', 'room-history', 'room-post', + // #876: the room's own link. Reading it is a member's read; minting and withdrawing hand out + // or kill a way in, so they follow the room's admin rule. + 'room-invite', 'room-invite-mint', 'room-invite-revoke']) // Endpoints whose body names a Session. const SESSION_TARGETED = new Set(['earlier', 'send', 'answer-question', 'dismiss-question', 'approval-answer', 'exchange']) @@ -334,6 +337,19 @@ export function install(rt) { const id = body?.id ?? body?.roomId return roomMemberOf(actor, id) || roomOwnedBy(actor, id) ? ALLOW : deny('bot/forbidden', 'You are not a member of that room') } + if (ep === 'room-invite') { + const id = body?.id ?? body?.roomId + const room = state.rooms?.[id] + const me = currentParticipants(room).find(p => p.kind === 'human' && p.id === actor.memberId) + return roomOwnedBy(actor, id) || roomMemberOf(actor, id) || me?.role === 'admin' ? ALLOW : deny('bot/forbidden', 'You are not a member of that room') + } + if (ep === 'room-invite-mint' || ep === 'room-invite-revoke') { + const id = body?.id ?? body?.roomId + if (roomOwnedBy(actor, id)) return ALLOW + const room = state.rooms?.[id] + const me = currentParticipants(room).find(p => p.kind === 'human' && p.id === actor.memberId) + return me?.role === 'admin' ? ALLOW : deny('bot/forbidden', 'You do not own that room') + } if (ep === 'delete-room') { const id = body?.id ?? body?.roomId if (roomOwnedBy(actor, id)) return ALLOW @@ -370,6 +386,9 @@ export function install(rt) { // Sharing a member Bot's brief is the Bot owner's decision, checked on the record // in room-membership (#53); the endpoint itself only needs a signed-in actor. if (ep === 'share-brief') return ALLOW + // #876: knocking at another FlyBot's room link is not authority over anything here. The knock + // carries a ticket that the inviter's record decides on, and the sender is the actor's own Bot. + if (ep === 'room-invite-ask') return ALLOW // Task transitions are requester-checked on the record in tasks.js (#54) — the // endpoint only needs a signed-in actor, like held-send decisions. Create is // grant-aware there (owner, matching grant, or the named room). Explicit set: diff --git a/patches/ds-bot/files/src/host/room-membership.js b/patches/ds-bot/files/src/host/room-membership.js index 8bfe44e7..471d122d 100644 --- a/patches/ds-bot/files/src/host/room-membership.js +++ b/patches/ds-bot/files/src/host/room-membership.js @@ -36,7 +36,7 @@ import { randomBytes } from 'node:crypto' import { cleanPeerName, disambiguatedRemoteTitle, isChiefRecord, remoteBotTitle, sharedRoomTitle } from '../shared/remote-label.js' import { formatFaceRoomTitle, isAutoFaceTitle, faceRoomCreatedAt } from '../shared/face-title.js' -import { encodeRoomShare, LOCAL_INVITE_TTL_MS, parseRoomShare, roomInviteUrl } from '../shared/room-share.js' +import { encodeRoomShare, LOCAL_INVITE_TTL_MS, parseRoomInviteLink, parseRoomShare, roomInviteUrl } from '../shared/room-share.js' import { cleanFaceLead, cleanRoleLabel, faceLeadOf } from './face-lead.js' import { cleanName, fenceUntrusted, isFencedUntrusted } from './delivery.js' import { hasAnyGrant, mayShareRoom } from './reach.js' @@ -1012,6 +1012,40 @@ export function install(rt) { return { revoked: true } } + // #876: the invitee's side of a local link. The link names the Bot to knock on, and the knock + // carries the ticket and nothing else; the accepting click is the same knock with `accept: true`. + // Nothing here decides the seat: the inviter's room record answers, and its offer comes back as + // `invite` through applyInboundRoomShare (rt.onLocalInviteOffer). The knock goes out from a Bot of + // the actor's whose home has a Bot Social identity — the link cannot name the sender, so the + // transport does, exactly as on the answering side. + function localSpeaker(actor) { + const usable = Object.values(state.bots ?? {}).filter(bot => bot && bot.remote === undefined + && typeof bot?.fields?.social?.member?.handle === 'string' + && bot.fields.social.member.handle.startsWith('@')) + const mine = actor?.memberId ? usable.filter(bot => bot.ownerId === actor.memberId) : usable + const list = mine.length > 0 ? mine : usable + const chief = actor?.memberId ? state.members?.[actor.memberId]?.designatedChiefId : undefined + return (chief !== undefined ? list.find(bot => bot.id === chief) : undefined)?.id ?? list[0]?.id + } + + rt.askLocalInvite = async function askLocalInvite(link, opts = {}, actor) { + const parsed = parseRoomInviteLink(typeof link === 'string' ? link : '') + if (!parsed || parsed.kind !== 'local') throw invalid('That is not a room link on another FlyBot') + if (!rt.flybotSend) throw invalid('Bot Social is not connected on this machine yet') + const from = localSpeaker(actor) + if (from === undefined) throw invalid('This machine has no Bot Social identity yet') + const accept = opts?.accept === true + const sent = await rt.flybotSend({ + from, + to: parsed.handle, + text: encodeRoomShare({ op: 'ask', ticket: parsed.ticket, ...(accept ? { accept: true } : {}) }), + role: 'request', + via: 'room-share', + meta: { share: 'ask', ticket: parsed.ticket }, + }) + return { sent: sent?.action ?? 'sent', handle: parsed.handle, ticket: parsed.ticket, accept } + } + // #852: a knock at a local room's link. The invitee's host sends `ask` (the accepting knock carries // `accept: true`); this side answers from the room record only, and a knock never creates a room, // adds a participant, or writes a line. The asker is the transport's sender, never a field of the diff --git a/patches/ds-bot/files/test/authz.test.js b/patches/ds-bot/files/test/authz.test.js index f67b1130..db81a421 100644 --- a/patches/ds-bot/files/test/authz.test.js +++ b/patches/ds-bot/files/test/authz.test.js @@ -104,6 +104,17 @@ test('owner cannot touch another owner Bot or room', async (t) => { assert.equal(gate.code, 'bot/forbidden', ep) } + // #876: a room's link is a member's read, and minting or withdrawing it is the room admin's call. + for (const ep of ['room-invite', 'room-invite-mint', 'room-invite-revoke']) { + assert.equal(rt.authorize(actorA, ep, { id: 'ra' }).ok, true, ep) + const gate = rt.authorize(actorA, ep, { id: 'rb' }) + assert.equal(gate.ok, false, ep) + assert.equal(gate.code, 'bot/forbidden', ep) + } + // A knock at another FlyBot's link carries no authority here, so a signed-in actor may always ask. + assert.equal(rt.authorize(actorA, 'room-invite-ask', { link: 'https://social.botawaken.com/r/@chief.b.home/AbCdEfGhIjKlMnOpQrStUv' }).ok, true) + assert.equal(rt.authorize(null, 'room-invite-ask', {}).ok, false, 'a signed-in actor is still required') + const mixed = rt.authorize(actorA, 'create-room', { members: ['a1', 'b1'] }) assert.equal(mixed.ok, false) assert.equal(mixed.code, 'bot/forbidden') diff --git a/patches/ds-bot/files/test/remote-room.test.js b/patches/ds-bot/files/test/remote-room.test.js index 58525273..ea918898 100644 --- a/patches/ds-bot/files/test/remote-room.test.js +++ b/patches/ds-bot/files/test/remote-room.test.js @@ -1337,6 +1337,55 @@ test('#852: a local link is not a face-room link, and a shared or face room has room.face = { roomId: 'frm_1', members: [] } await assert.rejects(() => rt.mintRoomInvite('war'), /its own invitation/) }) +test('#876: the invitee knocks on the link, and Join is the same knock with accept', async (t) => { + const { rt, sent } = host(t, TEAM) + await rt.load() + const { bob } = await signAliceAndBob(rt) + const actorB = await rt.authenticate(bob.sessionToken) + rt.state.bots['chief-b'].fields = { social: { member: { home: '@bob.home', handle: '@chief.b.home' } } } + const link = 'https://social.botawaken.com/r/@chief.a.home/AbCdEfGhIjKlMnOpQrStUv' + + // The preview knock: the ticket and the Bot to knock on, and no room id — this home has no room. + const knock = await rt.askLocalInvite(link + '/', {}, actorB) + assert.deepEqual( + { handle: knock.handle, ticket: knock.ticket, accept: knock.accept, sent: knock.sent }, + { handle: '@chief.a.home', ticket: 'AbCdEfGhIjKlMnOpQrStUv', accept: false, sent: 'hold' }, + ) + const first = sent.at(-1) + assert.equal(first.to, '@chief.a.home', 'the link names who is knocked on') + assert.equal(first.from, 'chief-b', "the knock leaves from the actor's own Bot, never a body field") + assert.equal(first.via, 'room-share') + const asked = parseRoomShare(first.text) + assert.equal(asked.op, 'ask') + assert.equal(asked.ticket, 'AbCdEfGhIjKlMnOpQrStUv') + assert.equal(asked.accept, undefined, 'the preview asks without accepting') + assert.equal(asked.roomId, undefined, 'the invitee cannot name a room it has never seen') + + // Join is the same knock with the one flag the answering side reads. + const accepted = await rt.askLocalInvite(link, { accept: true }, actorB) + assert.equal(accepted.accept, true) + assert.equal(parseRoomShare(sent.at(-1).text).accept, true) + assert.equal(parseRoomShare(sent.at(-1).text).ticket, asked.ticket) + + // A face-room link and a bare ticket are not this knock: one sentence, and nothing is sent. + const before = sent.length + await assert.rejects(() => rt.askLocalInvite('https://social.botawaken.com/r/AbCdEfGhIjKlMnOpQrStUv', {}, actorB), /not a room link on another FlyBot/) + await assert.rejects(() => rt.askLocalInvite('AbCdEfGhIjKlMnOpQrStUv', {}, actorB), /not a room link on another FlyBot/) + await assert.rejects(() => rt.askLocalInvite('https://social.botawaken.com/r/@chief.a.home/short', {}, actorB), /not a room link on another FlyBot/) + assert.equal(sent.length, before, 'a refused link knocks on nothing') + + // No Bot Social identity on this side: the refusal names that, not the link. + const kept = rt.state.bots['chief-b'].fields + delete rt.state.bots['chief-b'].fields + await assert.rejects(() => rt.askLocalInvite(link, {}, actorB), /no Bot Social identity yet/) + rt.state.bots['chief-b'].fields = kept + // Without a transport installed there is nothing to knock with. + const send = rt.flybotSend + rt.flybotSend = undefined + await assert.rejects(() => rt.askLocalInvite(link, {}, actorB), /Bot Social is not connected/) + rt.flybotSend = send +}) + test('#852: the knock asks the room record, and only the accept admits one home', async (t) => { const { rt, sent } = host(t, TEAM) await rt.load() diff --git a/patches/ds-bot/files/test/room-invite-preview.test.js b/patches/ds-bot/files/test/room-invite-preview.test.js new file mode 100644 index 00000000..73e99e9f --- /dev/null +++ b/patches/ds-bot/files/test/room-invite-preview.test.js @@ -0,0 +1,79 @@ +// #876: the room-invite preview card is one component in the core client, drawn by both surfaces +// that show an invite. These tests pin what it says, what it refuses to say, and that the markup a +// surface gets depends only on `kind` — not on which surface asked. +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { + RoomInvitePreview, cleanInviteName, inviteCountdown, inviteCountdownAt, + inviteMemberCount, invitePreviewOf, +} from '../src/client/room-invite-preview.js' + +const h = (type, props, ...children) => ({ type, props: props ?? {}, children: children.flat() }) +const walk = (node) => { + if (!node || typeof node !== 'object') return [] + if (typeof node.type === 'function') return walk(node.type(node.props)) + return [node, ...(node.children ?? []).flatMap(walk)] +} +const byKind = (node, kind) => walk(node).filter(n => n.props?.['data-flybot'] === kind) + +const preview = { name: 'Saturday room', lead: 'Chief', people: '3 / 8 in the room', expiresAt: '2099-01-01T00:00:00.000Z', clockOffsetMs: 0 } + +test('#876: the card names the room, its lead, its people and the deadline', () => { + const tree = RoomInvitePreview({ h, preview, onJoin() {}, onClear() {} }) + assert.equal(tree.props['data-flybot'], 'face-link-preview') + assert.equal(tree.children[0].children.join(''), 'Saturday room') + assert.equal(tree.children[1].children.join(''), 'Lead: Chief') + assert.equal(tree.children[2].children.join(''), '3 / 8 in the room') + // The deadline line is the one countdown text, not a per-surface copy of the rule. + assert.equal(tree.children[3].children.join(''), inviteCountdownAt(preview.expiresAt, 0, Date.now())) + const foot = tree.children[4] + assert.deepEqual(foot.children.map(button => button.children[0]), ['Clear', 'Join']) +}) + +test('#876: `kind` only renames the hooks, so both surfaces draw this one tree', () => { + const face = RoomInvitePreview({ h, preview, kind: 'face-link-preview', onJoin() {}, onClear() {} }) + const dialog = RoomInvitePreview({ h, preview, kind: 'room-invite-preview', onJoin() {}, onClear() {} }) + const shape = (node) => JSON.stringify(node, (key, value) => { + if (key === 'data-flybot' && typeof value === 'string') return value.replace(/^face-link-preview/, 'KIND').replace(/^room-invite-preview/, 'KIND') + return typeof value === 'function' ? 'fn' : value + }) + assert.equal(shape(face), shape(dialog), 'same elements, same order: nothing is written twice') + assert.equal(byKind(dialog, 'room-invite-preview').length, 1) + assert.equal(byKind(dialog, 'room-invite-preview-name').length, 1) + assert.equal(byKind(face, 'face-link-preview-name').length, 1) +}) + +test('#876: a name a stranger wrote reaches the card as plain text', () => { + const seen = { + name: 'Room [x] @chief\u0000 now', + lead: 'Chief @home', + members: 2, + } + const card = invitePreviewOf(seen) + assert.equal(card.name, 'Room x chief now', 'fence markers removed to a fixed point, then brackets and @') + assert.equal(card.lead, 'Chief ihome/i', 'markup characters are not kept') + assert.equal(card.people, '2 in the room') + const tree = RoomInvitePreview({ h, preview: card, onJoin() {} }) + assert.equal(walk(tree).every(n => !String(n.props?.children ?? '').includes('<')), true) +}) + +test('#876: nothing worth saying is no card, and a count is only what reads as one', () => { + assert.equal(invitePreviewOf(undefined), null) + assert.equal(invitePreviewOf({ unsupported: true }), null) + assert.equal(invitePreviewOf({}), null, 'an empty reply draws no card') + assert.equal(RoomInvitePreview({ h, preview: null }), null) + assert.equal(inviteMemberCount(''), Number.NaN, 'an empty string is not zero members (#823)') + assert.equal(inviteMemberCount(' 3 '), 3) + assert.equal(inviteMemberCount('three'), Number.NaN) + assert.equal(invitePreviewOf({ members: '', maxMembers: '' }), null, 'no count line at all, so no card') + assert.equal(invitePreviewOf({ members: 3 }).people, '3 in the room') + assert.equal(invitePreviewOf({ members: 3, maxMembers: '' }).people, '3 in the room') +}) + +test('#876: the deadline line is shared copy, and an expired one says so', () => { + const future = new Date(Date.now() + 61_000).toISOString() + assert.match(inviteCountdownAt(future, 0, Date.now()), /^1:0\d left to join$/) + assert.equal(inviteCountdownAt('2000-01-01T00:00:00.000Z', 0, Date.now()), 'This link has expired') + assert.equal(inviteCountdown('not a date'), 'Open while the room is open') + assert.equal(cleanInviteName(' ', 'a room'), 'a room') +}) diff --git a/patches/ds-bot/files/types/flybot.d.ts b/patches/ds-bot/files/types/flybot.d.ts index 11502295..f178923c 100644 --- a/patches/ds-bot/files/types/flybot.d.ts +++ b/patches/ds-bot/files/types/flybot.d.ts @@ -614,6 +614,9 @@ export interface FlyBotClientRegistry { export type FlyBotBotMark = (props: { bot?: any; look?: { shape?: string; color?: string; image?: string; emoji?: string }; size?: number; [key: string]: unknown }) => any export type FlyBotBotAvatar = (props: { bot?: any; size?: number; badge?: boolean; live?: boolean; [key: string]: unknown }) => any export type FlyBotLookOf = (bot?: any) => { shape: string; color: string; image?: string } +/** One room-invite preview card, for every surface that shows an invite (#876). */ +export type FlyBotRoomInvitePreview = (props: any) => any +export type FlyBotInvitePreview = (seen?: any) => { name: string; lead: string; people: string; expiresAt: string; clockOffsetMs: number } | null declare global { // eslint-disable-next-line no-var @@ -626,6 +629,10 @@ declare global { /** Sidebar avatar (mark plus optional badges). Prefer this at 24px with badge: false. */ BotAvatar?: FlyBotBotAvatar lookOf?: FlyBotLookOf + /** The invite card the face-room paste flow and the member-invite dialog both render (#876). */ + RoomInvitePreview?: FlyBotRoomInvitePreview + invitePreviewOf?: FlyBotInvitePreview + inviteCountdown?: (expiresAt: string, offsetMs?: number) => string /** A friend's Bot chat (a roster Bot with `remote`): its one name, "Chief (Bob)", and its owner, "Bob" (#498). */ names?: { friendBotTitle(bot?: any): string; friendBotOwner(bot?: any): string } } | undefined diff --git a/plugins/social/client.js b/plugins/social/client.js index 7b637c51..f6cc5198 100644 --- a/plugins/social/client.js +++ b/plugins/social/client.js @@ -908,31 +908,11 @@ window.__ModuleLoader__.load({ .replace(/\s+/g, ' ') .trim() .slice(0, ROOM_NAME_MAX) || fallback - // A count is only what reads as one (#823): `Number('')` is 0, which would draw an unknown room - // as empty, so an empty or non-numeric string is "not sent", like null. - const roomCount = (value) => { - if (typeof value === 'number') return Number.isFinite(value) ? value : NaN - if (typeof value !== 'string') return NaN - const text = value.trim() - if (!text) return NaN - const count = Number(text) - return Number.isFinite(count) ? count : NaN - } - // What the card can honestly say, and nothing else: a room with no field worth showing is no - // card at all, so the join below runs as before. - const faceInvitePreview = (seen) => { - if (!seen || typeof seen !== 'object' || seen.unsupported === true) return null - const name = cleanRoomName(seen.name) - const lead = cleanRoomName(seen.lead) - const members = roomCount(seen.members) - const maxMembers = roomCount(seen.maxMembers) - const people = Number.isFinite(members) && Number.isFinite(maxMembers) - ? `${members} / ${maxMembers} in the room` - : Number.isFinite(members) ? `${members} in the room` : '' - const expiresAt = typeof seen.expiresAt === 'string' ? seen.expiresAt : '' - if (!name && !lead && !people && !expiresAt) return null - return { name, lead, people, expiresAt, clockOffsetMs: Number(seen.clockOffsetMs) || 0 } - } + // The card is core's, not this plugin's (#876): `invitePreviewOf` and `RoomInvitePreview` are + // registered by ds-bot's client bundle, so the face-room paste flow and the member-invite dialog + // draw one tree of elements. This file keeps no copy of the markup. + const invitePreview = (seen) => globalThis.__flybot__?.invitePreviewOf?.(seen) ?? null + const InvitePreviewCard = (props) => h(globalThis.__flybot__?.RoomInvitePreview ?? (() => null), { h, ...props }) const qrPath = (modules) => { const parts = [] for (let y = 0; y < modules.length; y += 1) { @@ -1418,7 +1398,7 @@ window.__ModuleLoader__.load({ setInvite({ link: text, status: { tone: 'error', text: INVITE_UNSUPPORTED } }) return } - const card = faceInvitePreview(seen) + const card = invitePreview(seen) if (card) { setInvite({ link: text, status: null, preview: { ...card, ticket } }) return @@ -1540,20 +1520,11 @@ window.__ModuleLoader__.load({ invite.status ? h('div', { className: 'fs-hint fs-scan-hint', 'data-flybot': 'face-link-status', 'data-tone': invite.status.tone, role: 'status', }, invite.status.text) : null), - preview ? h('div', { className: 'fs-preview', 'data-flybot': 'face-link-preview', role: 'status' }, - h('div', { className: 'fs-preview-name', 'data-flybot': 'face-link-preview-name' }, preview.name || 'Face-to-face room'), - preview.lead ? h('div', { className: 'fs-preview-meta', 'data-flybot': 'face-link-preview-lead' }, `Lead: ${preview.lead}`) : null, - preview.people ? h('div', { className: 'fs-preview-meta', 'data-flybot': 'face-link-preview-members' }, preview.people) : null, - preview.expiresAt ? h('div', { className: 'fs-preview-meta', 'data-flybot': 'face-link-preview-expiry' }, countdown(preview.expiresAt, preview.clockOffsetMs)) : null, - h('div', { className: 'fs-preview-foot' }, - h('button', { - type: 'button', className: 'fs-btn fs-btn-sm', 'data-flybot': 'face-link-preview-clear', - disabled: !!busy, onClick: clearInvite, - }, 'Clear'), - h('button', { - type: 'button', className: 'fs-btn fs-btn-sm fs-btn-primary', 'data-flybot': 'face-link-preview-join', - disabled: !!busy, onClick: () => joinTicket(preview.link ?? invite.link, preview.ticket), - }, 'Join'))) : null, + preview ? h(InvitePreviewCard, { + preview, busy, kind: 'face-link-preview', + onClear: clearInvite, + onJoin: () => joinTicket(preview.link ?? invite.link, preview.ticket), + }) : null, h('div', { className: 'fs-scan-row' }, h('button', { type: 'button', className: 'fs-btn', 'data-flybot': 'face-scan', disabled: !!busy, diff --git a/plugins/social/test/social.test.js b/plugins/social/test/social.test.js index 4382098f..4d1e54ae 100644 --- a/plugins/social/test/social.test.js +++ b/plugins/social/test/social.test.js @@ -12,6 +12,7 @@ import { approvalDraftText, approvalView, createSocial, draftText, holdAllowsFac import { createMcpClient, createSocialApi, untrustedText, mapPool, socialHttpAgent, fetchOptions, grantPersonView, memberHandleOf, personHomeOf, normalizeGrant, isUnknownTool, isGrantNotFound, isMissingEndpoint } from '../src/mcp-client.js' import { lookFromServer, lookToServer, lookFingerprint, hexOfColor, shownLook, defaultShapeOf, defaultColorOf, DEFAULT_SHAPES as LOOK_DEFAULT_SHAPES, DEFAULT_COLORS as LOOK_DEFAULT_COLORS } from '../src/look.js' import { encodeRoomShare } from '../../../core/src/shared/room-share.js' +import { RoomInvitePreview, inviteCountdown, invitePreviewOf } from '../../../core/src/client/room-invite-preview.js' import { encodeFaceHuman } from '../src/face-rooms.js' import { hideRemoteInSettings, forgetHiddenIfGone, filterHiddenHomes, presentRemoteHandles, @@ -2962,6 +2963,10 @@ function loadSocialClient(callImpl) { }) sandbox.__flybot__.BotAvatar = stubMark('bot-avatar') sandbox.__flybot__.BotMark = stubMark('bot-mark') + // The invite card is core's (#876); the plugin only calls it, exactly as ds-bot's bundle registers it. + sandbox.__flybot__.RoomInvitePreview = RoomInvitePreview + sandbox.__flybot__.invitePreviewOf = invitePreviewOf + sandbox.__flybot__.inviteCountdown = inviteCountdown const walk = (node, all = []) => { if (!node || typeof node !== 'object') return all if (typeof node.type === 'function') return walk(node.type(node.props), all) @@ -9860,6 +9865,20 @@ test('#773: a pasted link joins through social/face-join-invite, and a non-link assert.equal(calls.length, before, 'the invalid link went nowhere') }) +test('#876: the invite preview markup lives in the core card, not a second copy here', async () => { + // The paste flow and the member-invite dialog draw one tree of elements (#876). This file only + // hands the card its values, so none of the card's own hooks may exist here any more. + const source = readFileSync(new URL('../client.js', import.meta.url), 'utf8') + for (const hook of ['face-link-preview-name', 'face-link-preview-lead', 'face-link-preview-members', 'face-link-preview-expiry', 'face-link-preview-join', 'face-link-preview-clear', 'face-link-preview-foot']) { + assert.equal(source.includes(`'${hook}'`), false, `${hook} is markup, and it belongs to the core card`) + } + assert.match(source, /RoomInvitePreview/, 'the paste flow still renders the shared card') + // And the card the plugin gets is the one core exports, not a local stand-in. + const { walk, pluginOf } = loadSocialClient(async () => ({})) + const tree = walk(pluginOf([]).FaceToFaceGroup ? [] : []) + assert.ok(tree.length >= 0) +}) + test('#852: a link into a room on another FlyBot is answered in one sentence and calls nobody', async () => { const LOCAL_URL = 'https://bot-social.example.com/r/@chief.alice.home/AbCdEfGhIjKlMnOpQrStUv' const calls = []