From 370019d466de29ebbcbcdb644c82b74e88266e06 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 25 Oct 2026 02:25:24 +0000 Subject: [PATCH] Say when a friend DM is waiting on their approval The activity row kept a generic thinking line for the whole wait, so a reply the other home is holding looked like this side was still working it out. Fixes #1087 Co-authored-by: Danyang Chen --- .../1087-remote-approval-wait.fixed.md | 1 + core/client.js | 14 +- core/src/client/activity-row.js | 6 +- core/src/client/i18n.js | 1 + core/src/client/remote-approval-wait.js | 14 + core/test/remote-approval-wait.test.js | 28 ++ docs/subsystems/core-client.md | 2 +- patches/ds-bot/0001-core-fixes.patch | 3 +- patches/ds-bot/0002-flybot-api-host.patch | 43 +-- patches/ds-bot/0003-flybot-api-client.patch | 17 +- patches/ds-bot/0008-client-bundle.patch | 359 ++++++++---------- .../files/src/client/remote-approval-wait.js | 14 + .../files/test/remote-approval-wait.test.js | 28 ++ 13 files changed, 290 insertions(+), 240 deletions(-) create mode 100644 changelog.d/1087-remote-approval-wait.fixed.md create mode 100644 core/src/client/remote-approval-wait.js create mode 100644 core/test/remote-approval-wait.test.js create mode 100644 patches/ds-bot/files/src/client/remote-approval-wait.js create mode 100644 patches/ds-bot/files/test/remote-approval-wait.test.js diff --git a/changelog.d/1087-remote-approval-wait.fixed.md b/changelog.d/1087-remote-approval-wait.fixed.md new file mode 100644 index 000000000..a212c2fde --- /dev/null +++ b/changelog.d/1087-remote-approval-wait.fixed.md @@ -0,0 +1 @@ +A friend's direct chat says "Waiting for their approval" (等对方批准) while you wait for their reply, instead of a generic thinking line such as "Sorting out the details…", so a reply their home is holding for approval is not presented as this side still working it out (#1087). diff --git a/core/client.js b/core/client.js index 6bc534c08..99f060bb2 100644 --- a/core/client.js +++ b/core/client.js @@ -791,6 +791,7 @@ var ZH = { "{first} and {second} are waiting for your approval": "{first}、{second} 等你批准", "{first}, {second} and {count} more are waiting for your approval": "{first}、{second} 等 {total} 个等你批准", "Waiting for your approval": "等你批准", + "Waiting for their approval": "等对方批准", "Your Bots post in rooms without asking each time. Use Auto-send in the room header to turn it off.": "你的 Bot 会在房间里直接发送,不再每次询问。要关掉,用房间标题里的自动发送。", "Turn on auto-send": "打开自动发送", "Approve and always auto-send in this room": "批准并在此房间始终自动发送", @@ -6578,6 +6579,14 @@ function awaitingReply(roster, sessionId, now = Date.now()) { return now - at < AWAIT_REPLY_MS; } +// src/client/remote-approval-wait.js +var REMOTE_APPROVAL_LABEL = "Waiting for their approval"; +function remoteApprovalPhrase(remote, { tool, inputAt = 0 } = {}) { + if (!remote) return null; + if (tool && inputAt <= (tool.at ?? 0)) return null; + return { key: "remote-approval", label: REMOTE_APPROVAL_LABEL, state: "thinking" }; +} + // src/client/activity-row.js var import_react9 = require("react"); var import_react_dom2 = require("react-dom"); @@ -6748,7 +6757,8 @@ function ActivityIndicator({ sessionId, useRoster, useSessionStatus, useLive, us const inputRef = (0, import_react9.useRef)(0); const inputAt = Math.max(inputRef.current, pendingInput); inputRef.current = inputAt; - const want = !visible ? null : inputAt > (tool?.at ?? 0) ? { key: `input:${inputAt}`, pool: PHRASES.thinking, state: "thinking" } : tool ? { key: tool.label ? `tool:${tool.label}` : "working", label: tool.label, pool: PHRASES.working, state: tool.state ?? "working" } : { key: "thinking", pool: PHRASES.thinking, state: "thinking" }; + const phrase = visible ? remoteApprovalPhrase(bot?.remote !== void 0, { tool, inputAt }) : null; + const want = !visible ? null : phrase ? { ...phrase, label: t(phrase.label) } : inputAt > (tool?.at ?? 0) ? { key: `input:${inputAt}`, pool: PHRASES.thinking, state: "thinking" } : tool ? { key: tool.label ? `tool:${tool.label}` : "working", label: tool.label, pool: PHRASES.working, state: tool.state ?? "working" } : { key: "thinking", pool: PHRASES.thinking, state: "thinking" }; const [shown, setShown] = (0, import_react9.useState)(null); const wantKey = want?.key ?? null; const markState = (0, import_react9.useRef)("thinking"); @@ -6818,7 +6828,7 @@ function ActivityIndicator({ sessionId, useRoster, useSessionStatus, useLive, us const elapsed = now - shown.at >= 6e4 ? elapsedLabel(now - shown.at) : null; return (0, import_react_dom2.createPortal)((0, import_react9.createElement)( "div", - { className: "bt-typing", "data-exiting": phase === "out" ? "" : void 0, "data-carry": carry && phase !== "out" ? "" : void 0, "data-remote": remote ? "" : void 0, "aria-hidden": true }, + { className: "bt-typing", "data-exiting": phase === "out" ? "" : void 0, "data-carry": carry && phase !== "out" ? "" : void 0, "data-remote": remote ? "" : void 0, "data-flybot": shown.key === "remote-approval" ? "remote-approval" : void 0, "aria-hidden": true }, bot ? (0, import_react9.createElement)(BotMark, { bot, size: 28, state: markState.current, live: true, pokeable: true }) : (0, import_react9.createElement)(RoomAvatar, { room: room2, roster, size: 28 }), (0, import_react9.createElement)( "span", diff --git a/core/src/client/activity-row.js b/core/src/client/activity-row.js index 481b46fc4..605322f5e 100644 --- a/core/src/client/activity-row.js +++ b/core/src/client/activity-row.js @@ -5,6 +5,7 @@ import { Shimmer, PHRASES } from './cells.js' import { reducedMotion } from './motion.js' import { t } from './i18n.js' import { AWAIT_REPLY_MS, awaitingReply } from './awaiting.js' +import { remoteApprovalPhrase } from './remote-approval-wait.js' // ------------------------------------------------------------------------- // The activity row: the Bot's mark and a shimmering phrase at the transcript @@ -178,7 +179,10 @@ export function ActivityIndicator({ sessionId, useRoster, useSessionStatus, useL // the newest activity, and the phrase and the clock restart from the send. const inputAt = Math.max(inputRef.current, pendingInput) inputRef.current = inputAt + // Their home may be holding the reply. Say so, unless a local tool is newer (#1087). + const phrase = visible ? remoteApprovalPhrase(bot?.remote !== undefined, { tool, inputAt }) : null const want = !visible ? null + : phrase ? { ...phrase, label: t(phrase.label) } : inputAt > (tool?.at ?? 0) ? { key: `input:${inputAt}`, pool: PHRASES.thinking, state: 'thinking' } : tool ? { key: tool.label ? `tool:${tool.label}` : 'working', label: tool.label, pool: PHRASES.working, state: tool.state ?? 'working' } @@ -245,7 +249,7 @@ export function ActivityIndicator({ sessionId, useRoster, useSessionStatus, useL useEffect(() => { drawn.current = Boolean(host && phase !== null && shown) }, [host, phase, shown]) if (!host || phase === null || !shown) return null const elapsed = now - shown.at >= 60000 ? elapsedLabel(now - shown.at) : null - return createPortal(h('div', { className: 'bt-typing', 'data-exiting': phase === 'out' ? '' : undefined, 'data-carry': carry && phase !== 'out' ? '' : undefined, 'data-remote': remote ? '' : undefined, 'aria-hidden': true }, + return createPortal(h('div', { className: 'bt-typing', 'data-exiting': phase === 'out' ? '' : undefined, 'data-carry': carry && phase !== 'out' ? '' : undefined, 'data-remote': remote ? '' : undefined, 'data-flybot': shown.key === 'remote-approval' ? 'remote-approval' : undefined, 'aria-hidden': true }, bot ? h(BotMark, { bot, size: 28, state: markState.current, live: true, pokeable: true }) : h(RoomAvatar, { room, roster, size: 28 }), h('span', { className: 'bt-typing-text' }, h('span', { key: `${shown.key}:${shown.at}`, className: 'bt-typing-label' }, diff --git a/core/src/client/i18n.js b/core/src/client/i18n.js index 1893708e5..2644f9007 100644 --- a/core/src/client/i18n.js +++ b/core/src/client/i18n.js @@ -735,6 +735,7 @@ const ZH = { '{first} and {second} are waiting for your approval': '{first}、{second} 等你批准', '{first}, {second} and {count} more are waiting for your approval': '{first}、{second} 等 {total} 个等你批准', 'Waiting for your approval': '等你批准', + 'Waiting for their approval': '等对方批准', 'Your Bots post in rooms without asking each time. Use Auto-send in the room header to turn it off.': '你的 Bot 会在房间里直接发送,不再每次询问。要关掉,用房间标题里的自动发送。', 'Turn on auto-send': '打开自动发送', 'Approve and always auto-send in this room': '批准并在此房间始终自动发送', diff --git a/core/src/client/remote-approval-wait.js b/core/src/client/remote-approval-wait.js new file mode 100644 index 000000000..56ecec614 --- /dev/null +++ b/core/src/client/remote-approval-wait.js @@ -0,0 +1,14 @@ +// A friend's 1:1 chat (#1087). The activity row stays up from my send until their +// reply, and that wait is often their home holding the reply for approval. This +// home cannot see that card, so the row says what the wait is for instead of +// drawing the generic thinking pool ("Sorting out the details"). + +export const REMOTE_APPROVAL_LABEL = 'Waiting for their approval' + +// The phrase for that wait, or null when a local tool is the newest activity +// (its own line stays) or the chat is not a friend's. +export function remoteApprovalPhrase(remote, { tool, inputAt = 0 } = {}) { + if (!remote) return null + if (tool && inputAt <= (tool.at ?? 0)) return null + return { key: 'remote-approval', label: REMOTE_APPROVAL_LABEL, state: 'thinking' } +} diff --git a/core/test/remote-approval-wait.test.js b/core/test/remote-approval-wait.test.js new file mode 100644 index 000000000..18892d609 --- /dev/null +++ b/core/test/remote-approval-wait.test.js @@ -0,0 +1,28 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { test } from 'node:test' +import { DICTIONARY } from '../src/client/i18n.js' +import { REMOTE_APPROVAL_LABEL, remoteApprovalPhrase } from '../src/client/remote-approval-wait.js' + +const row = readFileSync(new URL('../src/client/activity-row.js', import.meta.url), 'utf8') +const cells = readFileSync(new URL('../src/client/cells.js', import.meta.url), 'utf8') + +test('#1087: a friend DM wait says their approval is pending, not the thinking pool', () => { + const phrase = remoteApprovalPhrase(true, { inputAt: 50 }) + assert.deepEqual(phrase, { key: 'remote-approval', label: 'Waiting for their approval', state: 'thinking' }) + assert.equal(phrase.label, REMOTE_APPROVAL_LABEL) + // The line people were reading for the whole approval wait. + assert.match(cells, /thinking: \[[^\]]*Sorting out the details/) + assert.equal(cells.includes(`'${REMOTE_APPROVAL_LABEL}'`), false) + assert.equal(DICTIONARY.zh[REMOTE_APPROVAL_LABEL], '等对方批准') + // Fresh input after an older tool is still that wait. + assert.equal(remoteApprovalPhrase(true, { inputAt: 30, tool: { at: 10, label: 'Searching' } }).key, 'remote-approval') + // A local tool that is the newest activity keeps its own line. + assert.equal(remoteApprovalPhrase(true, { inputAt: 10, tool: { at: 20, label: 'Searching' } }), null) + // A local chat never uses it. + assert.equal(remoteApprovalPhrase(false, { inputAt: 50 }), null) + // The row actually asks, and marks the line so the wait is not the thinking pool. + assert.match(row, /remoteApprovalPhrase\(bot\?\.remote !== undefined, \{ tool, inputAt \}\)/) + assert.match(row, /phrase \? \{ \.\.\.phrase, label: t\(phrase\.label\) \}/) + assert.match(row, /shown\.key === 'remote-approval' \? 'remote-approval'/) +}) diff --git a/docs/subsystems/core-client.md b/docs/subsystems/core-client.md index 573a323fc..a53041c7e 100644 --- a/docs/subsystems/core-client.md +++ b/docs/subsystems/core-client.md @@ -93,7 +93,7 @@ The overlay (`flybot-slots.js` `FlyBotApprovals`) is the fallback, not a second - In a face-to-face room the bubble matches a mention by every alias the room host routes on (`faceAliases` in `shared/remote-label.js`, the same list `face-mentions.js` uses), so `@SelfA's Chief` typed on B is A's own Chief on A. The chip shows `@` and the seat label (`faceSeatLabel`, `SelfA · Chief`) on both homes, a bare name means the writer's own Bot (my line tries my seats first, a friend's line the friend's, whatever order the room lists its seats in, as `faceCandidates` does), a friend's Bot outside the room is not matched, and one space typed after a picked `@Name ` (the menu inserts its own) is dropped from the drawing (#305). - A remote Bot's generated look is seeded by its handle (`remoteSeed` in `src/client/characters.js`), never by its session id. When Bot Social sends `avatar` / `color` / `avatar_updated_at`, `flybot.remote.publish` stores them on the remote-chat record (cached by `avatar_updated_at`). `lookBotForRemote` overlays a person payload's look (face-room `members[]` is the source for room avatars). A stored `color`/`avatar` still wins over the seed; missing look stays handle-seeded (#137). - A shared display name does not restyle a local Bot (#963). The `@Name` file chip (`mentionCss` in `src/client/mention-style.js`) uses the local Bot's look when a friend has the same name; that friend's own mention anchor keeps the friend's color, and a name only a friend has keeps that friend's look. `indexRoster` does not let a remote chat replace a local Bot's id or session keys, and `rosterBot` resolves a member id to the local Bot. `@Name` in a bubble (`linkMentions`) names the local Bot when both are listed. A group reply that is not remote resolves through `rosterBot` before the index. The friend's own row keeps the friend's look, unless that drawn look is the same picture as the local Bot of the same display name, in which case the row steps its shape and the local Bot stays as stored (#1069). -- A friend's 1:1 chat (#443): `AssistantCell` gives the reply bubbles a `bt-lead` `BotMark` from the chat record (`friendLead`; none on a `Not sent:` note). The activity row (`activity-row.js`) stays up while `awaitingReply` (`awaiting.js`, `roster.remoteAwaiting`, 10 min cap) holds. Between the send turn and the echo turn it sits in the last turn's tail, with a 1.5 s grace and `data-carry` so there is no second entrance, which keeps one row from send to reply. The social `PeerThinking` line is for face rooms only. A friend chat shows the row for a run that started from my submission and, between runs, while `awaitingReply` holds. The friend's reply run (the echo, which has no submission) hides it at once, and only the end of my send run gets the grace. The turn-tail hiding rules in `styles.js` spare `.bt-typing` and its tail item, or the row between runs would be drawn with `display:none`. +- A friend's 1:1 chat (#443): `AssistantCell` gives the reply bubbles a `bt-lead` `BotMark` from the chat record (`friendLead`; none on a `Not sent:` note). The activity row (`activity-row.js`) stays up while `awaitingReply` (`awaiting.js`, `roster.remoteAwaiting`, 10 min cap) holds. Between the send turn and the echo turn it sits in the last turn's tail, with a 1.5 s grace and `data-carry` so there is no second entrance, which keeps one row from send to reply. The social `PeerThinking` line is for face rooms only. While the row is up in a friend chat and no local tool is the newest activity, its phrase is **Waiting for their approval** (等对方批准) instead of the thinking pool (`Sorting out the details` and the rest): that wait is the other home holding the reply, and this home cannot see the card (#1087). A local tool line still wins. A friend chat shows the row for a run that started from my submission and, between runs, while `awaitingReply` holds. The friend's reply run (the echo, which has no submission) hides it at once, and only the end of my send run gets the grace. The turn-tail hiding rules in `styles.js` spare `.bt-typing` and its tail item, or the row between runs would be drawn with `display:none`. - A friend's Bot the owner hid, a remote chat marked `gone`/`hidden`, and a home-level handle (`@friend.home`, not `@bot.friend.home`) must not reappear in the sidebar, the **发给 / 和谁聊…** picker, `@` mentions, or `list_bots` friends lines (`isListedRemoteChat`). `flybot.remote.publish` skips those handles so remote-chat sync marks the persisted session gone/hidden instead of opening it again; history is kept. Handle comparison is case-insensitive (`normRemoteHandle`). - The "@" reader is shared with the host (`core/src/shared/mentions.js`, bundled into `core/client.js`). #287 added `unmatchedMentions` there (the `@` words that name nobody, used by the host for the face-room hint); `mentionSpans` and what the bubbles draw are unchanged. - A friend's Bot settings surface is not the local editor (#160): no rename, avatar editor, model, folder, instructions, Main Bot, Hide, Duplicate, or Delete. The details gear and context menu say **Bot settings**. The menu is Mark as Unread, Bot settings, and Copy conversation ID. Per-friend **Auto-send** and **Remove this Bot** stay on that page (`flybot.bot.details`); Remove closes the overlay so a stale local editor cannot appear. The Bots settings list is local Bots only. diff --git a/patches/ds-bot/0001-core-fixes.patch b/patches/ds-bot/0001-core-fixes.patch index 83fdccbbb..e06a878f8 100644 --- a/patches/ds-bot/0001-core-fixes.patch +++ b/patches/ds-bot/0001-core-fixes.patch @@ -922,7 +922,7 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js "{name} didn't answer in time": '{name} 没有及时回应', '{name} paused for your approval': '{name} 停下来等你批准', '{name} started on it': '{name} 开始做了', -@@ -450,12 +450,29 @@ const ZH = { +@@ -450,12 +450,30 @@ const ZH = { 'Asked to make the tool call it wrote as text': '工具调用被写成了文字,已让它重新调用', 'A Bot': '一个 Bot', '{name} is waiting for your approval': '{name} 等你批准', @@ -937,6 +937,7 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js '{first}, {second} and {count} more are waiting for your approval': '{first}、{second} 等 {total} 个等你批准', 'Waiting for your approval': '等你批准', - 'in its own chat': '在自己的对话里', ++ 'Waiting for their approval': '等对方批准', + 'Your Bots post in rooms without asking each time. Use Auto-send in the room header to turn it off.': '你的 Bot 会在房间里直接发送,不再每次询问。要关掉,用房间标题里的自动发送。', + 'Turn on auto-send': '打开自动发送', + 'Approve and always auto-send in this room': '批准并在此房间始终自动发送', diff --git a/patches/ds-bot/0002-flybot-api-host.patch b/patches/ds-bot/0002-flybot-api-host.patch index 19d4cfba6..49a44db27 100644 --- a/patches/ds-bot/0002-flybot-api-host.patch +++ b/patches/ds-bot/0002-flybot-api-host.patch @@ -76,7 +76,7 @@ diff --git a/index.js b/index.js diff --git a/src/host/constants.js b/src/host/constants.js --- a/src/host/constants.js +++ b/src/host/constants.js -@@ -49,14 +49,13 @@ +@@ -49,14 +49,13 @@ export const AGENT_SESSION_TITLE_MAX = 80 // Desktop profile, so a restriction filters this list down to registered names. export const TEAM_TOOLS = [ 'list_bots', 'message_bot', 'stop_bot', 'create_bot', 'update_bot', 'list_models', @@ -93,7 +93,6 @@ diff --git a/src/host/constants.js b/src/host/constants.js // Answers a group gives to what members post after the round moved on (their finished // work, mostly), before the user speaks there again. export const ROOM_LATE_ROUNDS = 10 - diff --git a/src/host/home-lock.js b/src/host/home-lock.js --- a/src/host/home-lock.js +++ b/src/host/home-lock.js @@ -799,7 +798,7 @@ diff --git a/src/host/store.js b/src/host/store.js const load = () => loaded ??= (async () => { claimLock() if (holder !== undefined) warn('ds-bot: %s', readOnly()) -@@ -185,14 +185,24 @@ +@@ -185,14 +185,24 @@ export function install(rt) { for (const room of stale) void rt.retitleRoom(room) })() @@ -825,7 +824,7 @@ diff --git a/src/host/store.js b/src/host/store.js warnedReadOnly = false adopt(await readFileState()) return -@@ -235,10 +235,35 @@ +@@ -235,10 +235,35 @@ export function install(rt) { return warnReadOnly() } @@ -873,7 +872,7 @@ diff --git a/src/host/store.js b/src/host/store.js throw error } }) -@@ -273,6 +273,17 @@ +@@ -273,6 +273,17 @@ export function install(rt) { }) } @@ -904,7 +903,7 @@ diff --git a/src/host/store.js b/src/host/store.js } // Every group chat whose id or name matches; callers refuse an ambiguous name. const findRooms = (ref) => { -@@ -349,9 +349,10 @@ +@@ -349,9 +349,10 @@ export function install(rt) { const isAdminOf = (room, id) => id !== undefined && room.admin === id Object.assign(rt, { @@ -916,7 +915,6 @@ diff --git a/src/host/store.js b/src/host/store.js + initialOwnerId, syncDesignatedChief, }) } - diff --git a/src/host/tools.js b/src/host/tools.js --- a/src/host/tools.js +++ b/src/host/tools.js @@ -938,7 +936,7 @@ diff --git a/src/host/tools.js b/src/host/tools.js export const output = { schema: { type: 'string' }, render: (_args, value) => [{ type: 'text', text: String(value) }] } export const GROUP_REF = { type: 'string', required: true, description: 'Exact group chat name from list_bots' } -@@ -27,39 +33,110 @@ +@@ -27,39 +27,110 @@ export function install(rt) { const roomLine = (room, self) => { const members = room.members.map(rt.botOf).filter(Boolean) .map(bot => `${bot.name}${bot.id === room.admin ? ' (admin)' : ''}${bot.id === self ? ' (you)' : ''}`) @@ -1056,7 +1054,7 @@ diff --git a/src/host/tools.js b/src/host/tools.js ].join('\n') }, })) -@@ -86,7 +163,7 @@ +@@ -86,7 +86,7 @@ export function install(rt) { name: 'message_bot', description: 'Send a message to another Bot on the team. If that Bot is working, it reads your message at its next step and can change course, then answers once that work is done; if it is idle, it starts on it right away. Its reply arrives later as a new message to you; with reply_to "user" it answers the user in its own chat instead.', parameters: { @@ -1065,7 +1063,7 @@ diff --git a/src/host/tools.js b/src/host/tools.js message: { type: 'string', required: true, description: 'Self-contained message; the other Bot cannot see this conversation' }, reply_to: { type: 'string', -@@ -105,7 +182,17 @@ +@@ -105,7 +105,17 @@ export function install(rt) { const sender = callerBot(exec) if (sender === undefined) return 'Only Bots on the team can use message_bot.' const target = rt.findBot(args.to) @@ -1084,7 +1082,7 @@ diff --git a/src/host/tools.js b/src/host/tools.js if (target.id === sender.id) return 'You cannot message yourself.' const turn = rt.live.get(exec.agent?.id) const hop = Math.max(turn?.info?.hop ?? 0, ...(turn?.joined ?? []).map(joined => joined.info?.hop ?? 0)) -@@ -118,7 +205,11 @@ +@@ -118,7 +118,11 @@ export function install(rt) { const toUser = args.reply_to === 'user' const working = rt.live.get(chat)?.turn !== undefined const steer = args.when !== 'queue' && !toUser @@ -1097,7 +1095,7 @@ diff --git a/src/host/tools.js b/src/host/tools.js const sent = toUser ? `Delivered to ${target.name}. It will answer the user in its own chat; nothing comes back to you, so point the user to ${target.name}'s chat instead of relaying.` : working && steer -@@ -153,7 +244,7 @@ +@@ -153,7 +153,7 @@ export function install(rt) { const sender = callerBot(exec) if (sender === undefined || !rt.isMain(sender.id)) refuse('Only a Main Bot can stop other Bots.') const target = rt.findBot(args.bot) @@ -1106,7 +1104,7 @@ diff --git a/src/host/tools.js b/src/host/tools.js if (target.id === sender.id) refuse('You cannot stop yourself.') const { running } = activeSessions(target.id) if (running.length === 0) refuse(`${target.name} is not running right now, so there is nothing to stop. Use message_bot to reach it.`) -@@ -162,20 +253,20 @@ +@@ -162,20 +162,20 @@ export function install(rt) { agent.cancel({ kind: 'hook', reason: 'ds-bot/stopped' }) } const why = String(args.reason ?? '').trim() || 'Stop what you are doing; the previous turn was ended on purpose. Acknowledge briefly.' @@ -1130,7 +1128,7 @@ diff --git a/src/host/tools.js b/src/host/tools.js work_dir: WORK_DIR_PARAM, }, output, -@@ -187,7 +278,7 @@ +@@ -187,7 +187,7 @@ export function install(rt) { const name = String(args.name ?? '').trim() await rt.approveFolder(exec, { path: workDir, who: { en: `the new Bot ${name}`, zh: `新 Bot ${name} ` } }) const bot = await rt.createBot({ ...args, workDir, createdBy: rt.selfOf(exec.agent.id) }) @@ -1139,7 +1137,7 @@ diff --git a/src/host/tools.js b/src/host/tools.js } catch (error) { return `Could not create the Bot: ${error.message}` } -@@ -257,7 +348,7 @@ +@@ -257,7 +257,7 @@ export function install(rt) { } return { bots, unknown } } @@ -1148,7 +1146,7 @@ diff --git a/src/host/tools.js b/src/host/tools.js const groupNames = self => visibleRooms(self).map(room => `"${room.name}"`).join(', ') || 'none' const pickRoom = (ref, self) => { const matches = rt.findRooms(ref).filter(room => visibleRooms(self).includes(room)) -@@ -395,12 +486,11 @@ +@@ -395,12 +395,11 @@ export function install(rt) { if ((info?.hop ?? 0) >= MAX_HOPS) return 'This chain of Bot messages is already long. Stop here and report to the user instead.' const text = String(args.message ?? '').trim() if (text === '') return 'Write a message to post.' @@ -1164,7 +1162,7 @@ diff --git a/src/host/tools.js b/src/host/tools.js } catch (failure) { return `Could not post in "${room.name}": ${failure.message}` } -@@ -445,6 +535,10 @@ +@@ -445,6 +445,10 @@ export function install(rt) { .filter(option => option.label !== '') .slice(0, 6) if (options.length === 0) return 'Give at least one option.' @@ -1175,7 +1173,7 @@ diff --git a/src/host/tools.js b/src/host/tools.js // A hidden Bot-to-Bot turn shows none of its calls, so its card goes to the // composer dock, which takes questions without a call. const turn = rt.live.get(id)?.turn -@@ -465,6 +559,77 @@ +@@ -465,6 +465,77 @@ export function install(rt) { }, })) @@ -1253,7 +1251,7 @@ diff --git a/src/host/tools.js b/src/host/tools.js // dsh's own question tool holds the turn open until the user answers, and every // message from another Bot or a group round queues behind it. Bot Sessions ask // through ask_user instead, so the tool is taken out of their tool list. -@@ -525,7 +690,7 @@ +@@ -525,7 +525,7 @@ export function install(rt) { name: 'save_capability', description: 'Create or update a shared MCP server, skill, or pack. Changes apply from your next message.', parameters: { @@ -1262,7 +1260,7 @@ diff --git a/src/host/tools.js b/src/host/tools.js kind: { type: 'string', required: true, enum: ['mcp', 'skill', 'pack'] }, description: { type: 'string', description: 'What this capability provides' }, transport: { type: 'string', enum: ['stdio', 'streamable-http'], description: 'How to reach an MCP server: a local command or an HTTP URL' }, -@@ -545,7 +710,7 @@ +@@ -545,7 +545,7 @@ export function install(rt) { try { const item = await rt.capabilitySave(args, { botId: self }) const approval = item.kind === 'mcp' && item.transport === 'stdio' && !item.approved @@ -1271,7 +1269,7 @@ diff --git a/src/host/tools.js b/src/host/tools.js : '' return `Saved ${item.name}. Bots with it attached use the change from their next message.${approval}` } catch (error) { return error.message } -@@ -587,7 +752,7 @@ +@@ -587,7 +587,7 @@ export function install(rt) { : attached ? [attached] : [] const approvals = members .filter(item => item.kind === 'mcp' && item.transport === 'stdio' && item.approved !== true) @@ -1280,7 +1278,7 @@ diff --git a/src/host/tools.js b/src/host/tools.js .join('') return `${verb} ${args.name} ${args.bot ? `to ${result.bot}` : 'to yourself'}. Changes apply from ${result.bot}'s next message.${endpoint === 'attach' ? approvals : ''}` } catch (error) { return error.message } -@@ -595,5 +760,43 @@ +@@ -595,5 +595,43 @@ export function install(rt) { })) } @@ -1324,7 +1322,6 @@ diff --git a/src/host/tools.js b/src/host/tools.js + Object.assign(rt, { pickRoom, hideBlockingQuestion }) } - diff --git a/test/blocking-question.test.js b/test/blocking-question.test.js --- a/test/blocking-question.test.js +++ b/test/blocking-question.test.js diff --git a/patches/ds-bot/0003-flybot-api-client.patch b/patches/ds-bot/0003-flybot-api-client.patch index 34757da23..0916fb30b 100644 --- a/patches/ds-bot/0003-flybot-api-client.patch +++ b/patches/ds-bot/0003-flybot-api-client.patch @@ -3,11 +3,12 @@ FlyBot patch 0003-flybot-api-client on Fei2411/DeepSeek-Bot@2b10c70 (ds-bot/). G diff --git a/src/client/activity-row.js b/src/client/activity-row.js --- a/src/client/activity-row.js +++ b/src/client/activity-row.js -@@ -4,6 +4,7 @@ import { BotMark, RoomAvatar } from './mark.js' +@@ -4,6 +4,8 @@ import { BotMark, RoomAvatar } from './mark.js' import { Shimmer, PHRASES } from './cells.js' import { reducedMotion } from './motion.js' import { t } from './i18n.js' +import { AWAIT_REPLY_MS, awaitingReply } from './awaiting.js' ++import { remoteApprovalPhrase } from './remote-approval-wait.js' // ------------------------------------------------------------------------- // The activity row: the Bot's mark and a shimmering phrase at the transcript @@ -91,7 +92,7 @@ diff --git a/src/client/activity-row.js b/src/client/activity-row.js return host } -@@ -94,9 +94,28 @@ export function ActivityIndicator({ sessionId, useRoster, useSessionStatus, useL +@@ -94,16 +94,38 @@ export function ActivityIndicator({ sessionId, useRoster, useSessionStatus, useL const entries = useMemo(() => Object.values(JSON.parse(liveKey)), [liveKey]) const streaming = entries.some(entry => entry.text) const tools = entries.filter(entry => !entry.text).sort((a, b) => a.at - b.at) @@ -122,6 +123,16 @@ diff --git a/src/client/activity-row.js b/src/client/activity-row.js const tool = tools.at(-1) const inputRef = useRef(0) // A steer keeps the run going, so nothing else resets the row: fresh input is + // the newest activity, and the phrase and the clock restart from the send. + const inputAt = Math.max(inputRef.current, pendingInput) + inputRef.current = inputAt ++ // Their home may be holding the reply. Say so, unless a local tool is newer (#1087). ++ const phrase = visible ? remoteApprovalPhrase(bot?.remote !== undefined, { tool, inputAt }) : null + const want = !visible ? null ++ : phrase ? { ...phrase, label: t(phrase.label) } + : inputAt > (tool?.at ?? 0) + ? { key: `input:${inputAt}`, pool: PHRASES.thinking, state: 'thinking' } + : tool ? { key: tool.label ? `tool:${tool.label}` : 'working', label: tool.label, pool: PHRASES.working, state: tool.state ?? 'working' } @@ -127,11 +127,16 @@ export function ActivityIndicator({ sessionId, useRoster, useSessionStatus, useL leaving.current = phase !== null && !visible useEffect(() => { @@ -158,7 +169,7 @@ diff --git a/src/client/activity-row.js b/src/client/activity-row.js if (!host || phase === null || !shown) return null const elapsed = now - shown.at >= 60000 ? elapsedLabel(now - shown.at) : null - return createPortal(h('div', { className: 'bt-typing', 'data-exiting': phase === 'out' ? '' : undefined, 'aria-hidden': true }, -+ return createPortal(h('div', { className: 'bt-typing', 'data-exiting': phase === 'out' ? '' : undefined, 'data-carry': carry && phase !== 'out' ? '' : undefined, 'data-remote': remote ? '' : undefined, 'aria-hidden': true }, ++ return createPortal(h('div', { className: 'bt-typing', 'data-exiting': phase === 'out' ? '' : undefined, 'data-carry': carry && phase !== 'out' ? '' : undefined, 'data-remote': remote ? '' : undefined, 'data-flybot': shown.key === 'remote-approval' ? 'remote-approval' : undefined, 'aria-hidden': true }, bot ? h(BotMark, { bot, size: 28, state: markState.current, live: true, pokeable: true }) : h(RoomAvatar, { room, roster, size: 28 }), h('span', { className: 'bt-typing-text' }, h('span', { key: `${shown.key}:${shown.at}`, className: 'bt-typing-label' }, diff --git a/patches/ds-bot/0008-client-bundle.patch b/patches/ds-bot/0008-client-bundle.patch index 720cd49c4..8af8715c9 100644 --- a/patches/ds-bot/0008-client-bundle.patch +++ b/patches/ds-bot/0008-client-bundle.patch @@ -476,7 +476,7 @@ diff --git a/client.js b/client.js "{name} didn't answer in time": "{name} 没有及时回应", "{name} paused for your approval": "{name} 停下来等你批准", "{name} started on it": "{name} 开始做了", -@@ -506,12 +506,29 @@ var ZH = { +@@ -506,12 +506,30 @@ var ZH = { "Asked to make the tool call it wrote as text": "工具调用被写成了文字,已让它重新调用", "A Bot": "一个 Bot", "{name} is waiting for your approval": "{name} 等你批准", @@ -491,6 +491,7 @@ diff --git a/client.js b/client.js "{first}, {second} and {count} more are waiting for your approval": "{first}、{second} 等 {total} 个等你批准", "Waiting for your approval": "等你批准", - "in its own chat": "在自己的对话里", ++ "Waiting for their approval": "等对方批准", + "Your Bots post in rooms without asking each time. Use Auto-send in the room header to turn it off.": "你的 Bot 会在房间里直接发送,不再每次询问。要关掉,用房间标题里的自动发送。", + "Turn on auto-send": "打开自动发送", + "Approve and always auto-send in this room": "批准并在此房间始终自动发送", @@ -3076,80 +3077,74 @@ 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} -@@ -2818,1962 +2818,3620 @@ function compileFragmentSelectors(text, names) { - return compiled.get(fragment); - }); - } --function installCompiledStyles(text, tag) { -- const style = installStyles("", tag); -- const cache = /* @__PURE__ */ new WeakMap(); -- const render = () => { -- const names = /* @__PURE__ */ new Set(); -- for (const sheet of document.styleSheets) { -- if (sheet.ownerNode?.dataset?.dshBot) continue; -- let list = cache.get(sheet); -- if (!list) { -- list = sheetClassNames(sheet); -- cache.set(sheet, list); -- } -- for (const name of list) names.add(name); -- } -- const next = compileFragmentSelectors(text, names); -- if (style.textContent !== next) style.textContent = next; -- }; -- render(); -- let timer; -- const schedule = () => { -- clearTimeout(timer); -- timer = setTimeout(render, 120); -- }; -- const onLoad = (event) => { -- if (event.target instanceof HTMLLinkElement) schedule(); -- }; -- const observer = new MutationObserver(schedule); -- observer.observe(document.head, { childList: true }); -- document.addEventListener("load", onLoad, true); -- return () => { -- observer.disconnect(); -- document.removeEventListener("load", onLoad, true); -- clearTimeout(timer); -- style.remove(); -- }; -+function installCompiledStyles(text, tag) { -+ const style = installStyles("", tag); -+ const cache = /* @__PURE__ */ new WeakMap(); -+ const render = () => { -+ const names = /* @__PURE__ */ new Set(); -+ for (const sheet of document.styleSheets) { -+ if (sheet.ownerNode?.dataset?.dshBot) continue; -+ let list = cache.get(sheet); -+ if (!list) { -+ list = sheetClassNames(sheet); -+ cache.set(sheet, list); -+ } -+ for (const name of list) names.add(name); -+ } -+ const next = compileFragmentSelectors(text, names); -+ if (style.textContent !== next) style.textContent = next; -+ }; -+ render(); -+ let timer; -+ const schedule = () => { -+ clearTimeout(timer); -+ timer = setTimeout(render, 120); -+ }; -+ const onLoad = (event) => { -+ if (event.target instanceof HTMLLinkElement) schedule(); -+ }; -+ const observer = new MutationObserver(schedule); -+ observer.observe(document.head, { childList: true }); -+ document.addEventListener("load", onLoad, true); -+ return () => { -+ observer.disconnect(); -+ document.removeEventListener("load", onLoad, true); -+ clearTimeout(timer); -+ style.remove(); -+ }; +@@ -2853,1927 +2853,3594 @@ function installCompiledStyles(text, tag) { + clearTimeout(timer); + style.remove(); + }; +-} +- +-// src/client/mark.js +-var import_react2 = require("react"); +-var STATES = ["idle", "thinking", "searching", "working", "sending", "orbit", "alert", "done"]; +-var botState = (running, activity, alert) => running ? STATES.includes(activity) ? activity : "working" : alert ? "alert" : "idle"; +-var toolState = (name) => /^(message_bot|stop_bot|create_bot|update_bot|create_group|update_group|delete_group|post_to_group)$/.test(name) ? "sending" : /search|fetch|browse|read|grep|glob|list|recall/i.test(name) ? "searching" : "working"; +-function useGaze(ref, enabled) { +- (0, import_react2.useEffect)(() => { +- if (!enabled) return void 0; +- let frame = 0; +- let point = null; +- const place = () => { +- frame = 0; +- const node = ref.current; +- if (!node || !point) return; +- const box = node.getBoundingClientRect(); +- const clamp = (value) => Math.max(-1, Math.min(1, value)); +- node.style.setProperty("--bt-gx", clamp((point.x - box.left - box.width / 2) / 160).toFixed(2)); +- node.style.setProperty("--bt-gy", clamp((point.y - box.top - box.height / 2) / 160).toFixed(2)); +- }; +- const onMove = (event) => { +- point = { x: event.clientX, y: event.clientY }; +- if (!frame) frame = requestAnimationFrame(place); +- }; +- const onLeave = () => { +- point = null; +- ref.current?.style.setProperty("--bt-gx", "0"); +- ref.current?.style.setProperty("--bt-gy", "0"); +- }; +- window.addEventListener("pointermove", onMove, { passive: true }); +- document.documentElement.addEventListener("pointerleave", onLeave); +- return () => { +- cancelAnimationFrame(frame); +- window.removeEventListener("pointermove", onMove); +- document.documentElement.removeEventListener("pointerleave", onLeave); +- }; +- }, [enabled]); +-} +-var FIDGETS = ["hop", "sway", "stretch", "look"]; +-function BotMark({ bot, look: given, size = 36, state = "idle", live = false, gaze = false, pokeable = false, label }) { +- const look = given ?? lookOf(bot); +- const ref = (0, import_react2.useRef)(null); +- const maskId = (0, import_react2.useRef)(""); +- if (maskId.current === "") maskId.current = `bt-mark-${nextMarkSerial()}`; +- const [poked, setPoked] = (0, import_react2.useState)(false); +- useGaze(ref, gaze); +- const common = { +- ref, +- className: poked ? "bt-mark bt-mark-poke" : "bt-mark", +- "data-shape": look.image ? "image" : shapeId(look.shape), +- "data-state": STATES.includes(state) ? state : "idle", +- "data-live": live ? "" : void 0, +- "data-fidget": live ? FIDGETS[fnv1a(`${bot?.id ?? ""}:fidget`) % FIDGETS.length] : void 0, +- "data-gaze": gaze ? "" : void 0, +- "data-poke": pokeable ? "" : void 0, +- style: { width: size, height: size, ...live ? { "--bt-delay": `${-(fnv1a(String(bot?.id ?? "")) % 5e3) / 1e3}s`, "--bt-fidget-delay": `${-(fnv1a(`${bot?.id ?? ""}:when`) % 9e3) / 1e3}s` } : {} }, +- role: label ? "img" : void 0, +- "aria-label": label, +- "aria-hidden": label ? void 0 : true, +- onClick: pokeable ? () => setPoked(true) : void 0, +- onAnimationEnd: poked ? (event) => { +- if (/^bt-poke/.test(event.animationName)) setPoked(false); +- } : void 0 +} + +// src/shared/face-title.js @@ -3259,8 +3254,13 @@ 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)( +function installBrandChrome() { + if (typeof document === "undefined") return () => { + }; @@ -3298,24 +3298,41 @@ diff --git a/client.js b/client.js +} +function PeopleIcon() { + return (0, import_react4.createElement)( -+ "svg", + "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" }) + { 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" }) -+ ); -+} + ); + } +-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 ArrowUpIcon() { + return (0, import_react4.createElement)("svg", { width: 16, height: 16, viewBox: "0 0 20 20", "aria-hidden": true }, (0, import_react4.createElement)("path", { d: "M10 16V4M5 9l5-5 5 5", stroke: "currentColor", strokeWidth: 1.8, fill: "none", strokeLinecap: "round", strokeLinejoin: "round" })); -+} + } +-function MainStar() { +- return (0, import_react2.createElement)( +function MicIcon({ size = 16 }) { + return (0, import_react4.createElement)( -+ "svg", + "svg", +- { viewBox: "0 0 20 20", "aria-hidden": true }, +- (0, import_react2.createElement)("path", { d: "M10 2.4l2.29 5.04 5.51.63-4.09 3.74 1.11 5.42L10 14.5l-4.82 2.73 1.11-5.42L2.2 8.07l5.51-.63z" }) + { width: size, height: size, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round", "aria-hidden": true }, + (0, import_react4.createElement)("rect", { x: 7, y: 2.5, width: 6, height: 10, rx: 3, fill: "currentColor", stroke: "none" }), + (0, import_react4.createElement)("path", { d: "M4.5 9.5a5.5 5.5 0 0 0 11 0M10 15v2.5" }) -+ ); -+} + ); + } +-function useSettled(state) { +- const prev = (0, import_react2.useRef)(state); +- const [settled, setSettled] = (0, import_react2.useState)(false); +- (0, import_react2.useEffect)(() => { +- const was = prev.current; +- prev.current = state; +- if (state !== "idle") { +- setSettled(false); +- return void 0; +function WaveIcon({ size = 16 }) { + return (0, import_react4.createElement)( + "svg", @@ -3417,42 +3434,8 @@ diff --git a/client.js b/client.js + (0, import_react4.createElement)("circle", { className: "bt-flybot-body", cx: 256, cy: 100, r: 22 }), + (0, import_react4.createElement)("rect", { className: "bt-flybot-body", x: 248, y: 104, width: 16, height: 34 }) + ); - } - --// src/client/mark.js --var import_react2 = require("react"); --var STATES = ["idle", "thinking", "searching", "working", "sending", "orbit", "alert", "done"]; --var botState = (running, activity, alert) => running ? STATES.includes(activity) ? activity : "working" : alert ? "alert" : "idle"; --var toolState = (name) => /^(message_bot|stop_bot|create_bot|update_bot|create_group|update_group|delete_group|post_to_group)$/.test(name) ? "sending" : /search|fetch|browse|read|grep|glob|list|recall/i.test(name) ? "searching" : "working"; --function useGaze(ref, enabled) { -- (0, import_react2.useEffect)(() => { -- if (!enabled) return void 0; -- let frame = 0; -- let point = null; -- const place = () => { -- frame = 0; -- const node = ref.current; -- if (!node || !point) return; -- const box = node.getBoundingClientRect(); -- const clamp = (value) => Math.max(-1, Math.min(1, value)); -- node.style.setProperty("--bt-gx", clamp((point.x - box.left - box.width / 2) / 160).toFixed(2)); -- node.style.setProperty("--bt-gy", clamp((point.y - box.top - box.height / 2) / 160).toFixed(2)); -- }; -- const onMove = (event) => { -- point = { x: event.clientX, y: event.clientY }; -- if (!frame) frame = requestAnimationFrame(place); -- }; -- const onLeave = () => { -- point = null; -- ref.current?.style.setProperty("--bt-gx", "0"); -- ref.current?.style.setProperty("--bt-gy", "0"); -- }; -- window.addEventListener("pointermove", onMove, { passive: true }); -- document.documentElement.addEventListener("pointerleave", onLeave); -- return () => { -- cancelAnimationFrame(frame); -- window.removeEventListener("pointermove", onMove); -- document.documentElement.removeEventListener("pointerleave", onLeave); ++} ++ +// src/client/questions.js +var import_react5 = require("react"); +var keyLetter = (index) => String.fromCharCode(65 + index); @@ -3475,37 +3458,11 @@ diff --git a/client.js b/client.js + event.preventDefault(); + ownRef.current.focus(); + } - }; -- }, [enabled]); ++ }; + document.addEventListener("keydown", onKey, true); + return () => document.removeEventListener("keydown", onKey, true); + }, [count, ownRef]); - } --var FIDGETS = ["hop", "sway", "stretch", "look"]; --function BotMark({ bot, look: given, size = 36, state = "idle", live = false, gaze = false, pokeable = false, label }) { -- const look = given ?? lookOf(bot); -- const ref = (0, import_react2.useRef)(null); -- const maskId = (0, import_react2.useRef)(""); -- if (maskId.current === "") maskId.current = `bt-mark-${nextMarkSerial()}`; -- const [poked, setPoked] = (0, import_react2.useState)(false); -- useGaze(ref, gaze); -- const common = { -- ref, -- className: poked ? "bt-mark bt-mark-poke" : "bt-mark", -- "data-shape": look.image ? "image" : shapeId(look.shape), -- "data-state": STATES.includes(state) ? state : "idle", -- "data-live": live ? "" : void 0, -- "data-fidget": live ? FIDGETS[fnv1a(`${bot?.id ?? ""}:fidget`) % FIDGETS.length] : void 0, -- "data-gaze": gaze ? "" : void 0, -- "data-poke": pokeable ? "" : void 0, -- style: { width: size, height: size, ...live ? { "--bt-delay": `${-(fnv1a(String(bot?.id ?? "")) % 5e3) / 1e3}s`, "--bt-fidget-delay": `${-(fnv1a(`${bot?.id ?? ""}:when`) % 9e3) / 1e3}s` } : {} }, -- role: label ? "img" : void 0, -- "aria-label": label, -- "aria-hidden": label ? void 0 : true, -- onClick: pokeable ? () => setPoked(true) : void 0, -- onAnimationEnd: poked ? (event) => { -- if (/^bt-poke/.test(event.animationName)) setPoked(false); -- } : void 0 ++} +function QuestionView({ question, onAnswer, onDismiss, ownAnswer = false, raised = false }) { + const [own, setOwn] = (0, import_react5.useState)(""); + const [picked, setPicked] = (0, import_react5.useState)([]); @@ -3521,16 +3478,7 @@ diff --git a/client.js b/client.js + return; + } + onAnswer({ selected: [label] }); - }; -- 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" }) ++ }; + const submit = () => { + if (!canSubmit) return; + onAnswer({ selected: multi ? picked : [], ...custom ? { custom } : {} }); @@ -3625,16 +3573,8 @@ diff --git a/client.js b/client.js + !multi && canSubmit ? (0, import_react5.createElement)("button", { type: "submit", className: "bt-q-submit" }, t("Submit")) : null + ) : null, + foot - ); - } --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 MainStar() { -- return (0, import_react2.createElement)( -- "svg", -- { viewBox: "0 0 20 20", "aria-hidden": true }, -- (0, import_react2.createElement)("path", { d: "M10 2.4l2.29 5.04 5.51.63-4.09 3.74 1.11 5.42L10 14.5l-4.82 2.73 1.11-5.42L2.2 8.07l5.51-.63z" }) ++ ); ++} +function SettledQuestion({ question, answer }) { + const options = optionsOf(question); + const prompt = question?.question ?? ""; @@ -3665,17 +3605,8 @@ diff --git a/client.js b/client.js + (0, import_react5.createElement)("span", { className: "bt-q-check", title: t("Selected") }, (0, import_react5.createElement)(CheckIcon)) + ); + })) - ); - } --function useSettled(state) { -- const prev = (0, import_react2.useRef)(state); -- const [settled, setSettled] = (0, import_react2.useState)(false); -- (0, import_react2.useEffect)(() => { -- const was = prev.current; -- prev.current = state; -- if (state !== "idle") { -- setSettled(false); -- return void 0; ++ ); ++} +function QuestionCard({ matched }) { + const pending = matched; + const questions = pending.questions ?? []; @@ -5261,6 +5192,18 @@ diff --git a/client.js b/client.js + } }; - return (0, import_react5.createElement)( ++ return (0, import_react8.createElement)( ++ "div", ++ { className: "bt-file-chips" }, ++ files.map((file) => (0, import_react8.createElement)( ++ "button", ++ { key: file.id, type: "button", className: "bt-file-chip", disabled: busy === file.id, title: `Download ${file.name} (untrusted file from a remote Bot)`, onClick: () => download2(file) }, ++ `📎 ${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_react8.createElement)( "div", - { className: "bt-secret-edit-block" }, @@ -5272,13 +5215,9 @@ diff --git a/client.js b/client.js - (0, import_react5.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: !long, onClick: save }, t("Replace")) - ), - value !== "" && !long ? (0, import_react5.createElement)("div", { className: "bt-set-hint" }, t("At least {count} characters.", { count: MIN_VALUE })) : replaced ? (0, import_react5.createElement)("div", { className: "bt-set-hint bt-secret-ok", role: "status" }, (0, import_react5.createElement)(CheckIcon), t("Replaced. Bots get the new value from their next command.")) : null -+ { className: "bt-file-chips" }, -+ files.map((file) => (0, import_react8.createElement)( -+ "button", -+ { key: file.id, type: "button", className: "bt-file-chip", disabled: busy === file.id, title: `Download ${file.name} (untrusted file from a remote Bot)`, onClick: () => download2(file) }, -+ `📎 ${file.name} · ${fileSize(file.size)}` -+ )), -+ error ? (0, import_react8.createElement)("span", { className: "bt-file-error", role: "alert" }, error) : null ++ { 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 SecretRow({ secret, roster, actions, run, open, onToggle }) { @@ -5290,7 +5229,14 @@ diff --git a/client.js b/client.js - const allowed = secret.scope === "all" ? [] : secret.scope.map((id) => roster.byId[id]).filter(Boolean); - const meta = [t("Asked by {name}", { name: asker }), t("added {time}", { time: when(secret.createdAt) }), secret.lastUsedAt ? t("last used {time}", { time: when(secret.lastUsedAt) }) : t("not used yet")].join(" · "); - return (0, import_react5.createElement)( -+function GroupEvent({ prefix, room: room2, name, roster, actions }) { ++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-secret-item", "data-open": open ? "" : void 0 }, @@ -5325,8 +5271,7 @@ diff --git a/client.js b/client.js - ) - ) : null + { 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 ++ (0, import_react8.createElement)("button", { type: "button", title: result, onClick: () => actions.openMemory(target.id) }, (0, import_react8.createElement)(MemoryIcon), label) ); } -function AddSecret({ actions, run, onDone }) { @@ -5341,20 +5286,6 @@ diff --git a/client.js b/client.js - setValue(""); - onDone(); - } -+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" }, -+ (0, import_react8.createElement)("button", { type: "button", title: result, onClick: () => actions.openMemory(target.id) }, (0, import_react8.createElement)(MemoryIcon), label) -+ ); -+} +function PartLine({ time }) { + return (0, import_react8.createElement)("div", { className: "bt-part-line", role: "separator" }, Number.isFinite(time) ? (0, import_react8.createElement)("span", null, dayLabel(time)) : null); +} @@ -6302,6 +6233,14 @@ diff --git a/client.js b/client.js - } - return null; + ++// src/client/remote-approval-wait.js ++var REMOTE_APPROVAL_LABEL = "Waiting for their approval"; ++function remoteApprovalPhrase(remote, { tool, inputAt = 0 } = {}) { ++ if (!remote) return null; ++ if (tool && inputAt <= (tool.at ?? 0)) return null; ++ return { key: "remote-approval", label: REMOTE_APPROVAL_LABEL, state: "thinking" }; ++} ++ +// src/client/activity-row.js +var import_react9 = require("react"); +var import_react_dom2 = require("react-dom"); @@ -6617,7 +6556,8 @@ diff --git a/client.js b/client.js + const inputRef = (0, import_react9.useRef)(0); + const inputAt = Math.max(inputRef.current, pendingInput); + inputRef.current = inputAt; -+ const want = !visible ? null : inputAt > (tool?.at ?? 0) ? { key: `input:${inputAt}`, pool: PHRASES.thinking, state: "thinking" } : tool ? { key: tool.label ? `tool:${tool.label}` : "working", label: tool.label, pool: PHRASES.working, state: tool.state ?? "working" } : { key: "thinking", pool: PHRASES.thinking, state: "thinking" }; ++ const phrase = visible ? remoteApprovalPhrase(bot?.remote !== void 0, { tool, inputAt }) : null; ++ const want = !visible ? null : phrase ? { ...phrase, label: t(phrase.label) } : inputAt > (tool?.at ?? 0) ? { key: `input:${inputAt}`, pool: PHRASES.thinking, state: "thinking" } : tool ? { key: tool.label ? `tool:${tool.label}` : "working", label: tool.label, pool: PHRASES.working, state: tool.state ?? "working" } : { key: "thinking", pool: PHRASES.thinking, state: "thinking" }; + const [shown, setShown] = (0, import_react9.useState)(null); + const wantKey = want?.key ?? null; + const markState = (0, import_react9.useRef)("thinking"); @@ -6633,6 +6573,10 @@ diff --git a/client.js b/client.js - if (source.kind === "schedule") { - const prompts = reminderPrompts(contentText(data.content)); - return (0, import_react6.createElement)("div", { className: "bt-event" }, prompts.length > 0 ? t("Scheduled task: {text}", { text: prompts.join(" · ").slice(0, 80) }) : t("Routine started")); +- } +- if (source.kind === "user-question-reply") return null; +- return (0, import_react6.createElement)("div", { className: "bt-event" }, contentText(data.content).slice(0, 80) || t("Update")); +- } + const timer = setTimeout(make, wait); + return () => clearTimeout(timer); + }, [wantKey, shown]); @@ -6645,10 +6589,7 @@ diff --git a/client.js b/client.js + if (visible) { + setPhase("in"); + return void 0; - } -- if (source.kind === "user-question-reply") return null; -- return (0, import_react6.createElement)("div", { className: "bt-event" }, contentText(data.content).slice(0, 80) || t("Update")); -- } ++ } + let timer; + const leave = () => { + setPhase((current) => current === "in" ? "out" : current); @@ -6693,7 +6634,7 @@ diff --git a/client.js b/client.js + const elapsed = now - shown.at >= 6e4 ? elapsedLabel(now - shown.at) : null; + return (0, import_react_dom2.createPortal)((0, import_react9.createElement)( + "div", -+ { className: "bt-typing", "data-exiting": phase === "out" ? "" : void 0, "data-carry": carry && phase !== "out" ? "" : void 0, "data-remote": remote ? "" : void 0, "aria-hidden": true }, ++ { className: "bt-typing", "data-exiting": phase === "out" ? "" : void 0, "data-carry": carry && phase !== "out" ? "" : void 0, "data-remote": remote ? "" : void 0, "data-flybot": shown.key === "remote-approval" ? "remote-approval" : void 0, "aria-hidden": true }, + bot ? (0, import_react9.createElement)(BotMark, { bot, size: 28, state: markState.current, live: true, pokeable: true }) : (0, import_react9.createElement)(RoomAvatar, { room: room2, roster, size: 28 }), + (0, import_react9.createElement)( + "span", diff --git a/patches/ds-bot/files/src/client/remote-approval-wait.js b/patches/ds-bot/files/src/client/remote-approval-wait.js new file mode 100644 index 000000000..56ecec614 --- /dev/null +++ b/patches/ds-bot/files/src/client/remote-approval-wait.js @@ -0,0 +1,14 @@ +// A friend's 1:1 chat (#1087). The activity row stays up from my send until their +// reply, and that wait is often their home holding the reply for approval. This +// home cannot see that card, so the row says what the wait is for instead of +// drawing the generic thinking pool ("Sorting out the details"). + +export const REMOTE_APPROVAL_LABEL = 'Waiting for their approval' + +// The phrase for that wait, or null when a local tool is the newest activity +// (its own line stays) or the chat is not a friend's. +export function remoteApprovalPhrase(remote, { tool, inputAt = 0 } = {}) { + if (!remote) return null + if (tool && inputAt <= (tool.at ?? 0)) return null + return { key: 'remote-approval', label: REMOTE_APPROVAL_LABEL, state: 'thinking' } +} diff --git a/patches/ds-bot/files/test/remote-approval-wait.test.js b/patches/ds-bot/files/test/remote-approval-wait.test.js new file mode 100644 index 000000000..18892d609 --- /dev/null +++ b/patches/ds-bot/files/test/remote-approval-wait.test.js @@ -0,0 +1,28 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { test } from 'node:test' +import { DICTIONARY } from '../src/client/i18n.js' +import { REMOTE_APPROVAL_LABEL, remoteApprovalPhrase } from '../src/client/remote-approval-wait.js' + +const row = readFileSync(new URL('../src/client/activity-row.js', import.meta.url), 'utf8') +const cells = readFileSync(new URL('../src/client/cells.js', import.meta.url), 'utf8') + +test('#1087: a friend DM wait says their approval is pending, not the thinking pool', () => { + const phrase = remoteApprovalPhrase(true, { inputAt: 50 }) + assert.deepEqual(phrase, { key: 'remote-approval', label: 'Waiting for their approval', state: 'thinking' }) + assert.equal(phrase.label, REMOTE_APPROVAL_LABEL) + // The line people were reading for the whole approval wait. + assert.match(cells, /thinking: \[[^\]]*Sorting out the details/) + assert.equal(cells.includes(`'${REMOTE_APPROVAL_LABEL}'`), false) + assert.equal(DICTIONARY.zh[REMOTE_APPROVAL_LABEL], '等对方批准') + // Fresh input after an older tool is still that wait. + assert.equal(remoteApprovalPhrase(true, { inputAt: 30, tool: { at: 10, label: 'Searching' } }).key, 'remote-approval') + // A local tool that is the newest activity keeps its own line. + assert.equal(remoteApprovalPhrase(true, { inputAt: 10, tool: { at: 20, label: 'Searching' } }), null) + // A local chat never uses it. + assert.equal(remoteApprovalPhrase(false, { inputAt: 50 }), null) + // The row actually asks, and marks the line so the wait is not the thinking pool. + assert.match(row, /remoteApprovalPhrase\(bot\?\.remote !== undefined, \{ tool, inputAt \}\)/) + assert.match(row, /phrase \? \{ \.\.\.phrase, label: t\(phrase\.label\) \}/) + assert.match(row, /shown\.key === 'remote-approval' \? 'remote-approval'/) +})