Skip to content
Merged
1 change: 1 addition & 0 deletions changelog.d/418-held-reply-in-place.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
A Bot's reply to a friend that waits for your approval now looks held where it is: its bubble is dashed, and **Approve** / **Reject** (with **Always approve**) sit on it instead of a separate card above the composer. The card names the person who gets the reply, and afterwards the bubble says **Sent to …** or **Not sent**, so a rejected reply never looks sent; the chat list preview no longer shows a reply that did not go, and the Bot's row shows the wait badge until you decide ([#418](https://github.com/ThinkFlowLab/FlyBot/issues/418)).
3,911 changes: 2,017 additions & 1,894 deletions core/client.js

Large diffs are not rendered by default.

23 changes: 23 additions & 0 deletions core/src/client/approval-claims.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
// Which approvals a chat draws itself, so the overlay draws no second card or pointer for them
// (#260), and which chats are open (#271). Free of the slot components: a chat cell imports it (#418).
const claims = new Map()
const claimListeners = new Set()
// Chats open right now (a session dock is mounted for them): the pointer never points at the chat you are in (#271).
const openChats = new Map()
const changed = () => { for (const fn of [...claimListeners]) fn() }
export function chatOpened(sessionId, delta) {
const count = (openChats.get(sessionId) ?? 0) + delta
if (count > 0) openChats.set(sessionId, count)
else openChats.delete(sessionId)
changed()
}
export const claimedIds = () => Object.assign(new Set([...claims.values()].flatMap(ids => [...ids])), { chats: new Set(openChats.keys()) })
export function claimApprovals(owner, ids) {
if (!Array.isArray(ids) || ids.length === 0) claims.delete(owner)
else claims.set(owner, new Set(ids.map(String)))
changed()
}
export function onClaims(fn) {
claimListeners.add(fn)
return () => { claimListeners.delete(fn) }
}
37 changes: 32 additions & 5 deletions core/src/client/cells.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,16 @@ import { createElement as h, Fragment, useEffect, useLayoutEffect, useMemo, useR
import { createPortal } from 'react-dom'
import { MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives'
import { inkText } from './inks.js'
import { currentPart, linkMentions, mentionTarget, dayLabel, contentText, stripHeader, stripTrace, isHiddenTurn, typedBy, isRelayContext, relaysBefore, relayPart, commRunOf, toolName, toolArgs, reminderPrompts, isPluginTool } from './text.js'
import { currentPart, linkMentions, mentionTarget, dayLabel, contentText, stripHeader, stripTrace, isHiddenTurn, typedBy, heldReplyOf, isRelayContext, relaysBefore, relayPart, commRunOf, toolName, toolArgs, reminderPrompts, isPluginTool } from './text.js'
import { SmileIcon, ReplyIcon, DotsIcon, CheckIcon, DownloadIcon, CopyIcon, MemoryIcon } from './icons.js'
import { colorOf, lookBotForRemote, toolState, BotMark, PersonMark, RoomAvatar } from './mark.js'
import { AnsweredCell, SettledQuestion, PendingQuestion } from './questions.js'
import { SecretToolCell } from './secret-card.js'
import { dateLocale, t } from './i18n.js'
import { remoteMessageText } from './flybot-registry.js'
import { remoteBotTitle } from '../shared/remote-label.js'
import { isListedRemoteChat, remoteBotTitle } from '../shared/remote-label.js'
import { fileSize } from './flybot-slots.js'
import { HeldReplyActions } from './held-reply.js'
import { call } from './api.js'
import { failureLabel } from './diagnostics.js'
import { turnReasoning } from './turn-reasoning.js'
Expand Down Expand Up @@ -128,12 +129,22 @@ function useFileMentions({ node, openFile, fileMentions, useTurnData }) {
}, [fileMentions, openFile, seq, tail, turn])
}

// Whether this text is the one that closes its turn (no turn data: assume it is).
function useClosingText(node, useTurnData) {
const tail = useTurnData?.('turn-tail')
const seq = node.data.finalNode?.seq
if (!tail?.closing || seq === undefined) return true
return tail.closing.finalNode?.seq === seq
}

export function AssistantCell({ node, groupPart, sessionId, openFile, fileMentions, useTurnData, useRoster, useUi, useTurnSteps, useTurnContext, actions }) {
const roster = useRoster(value => value)
const data = node.data
const steps = useTurnSteps(String(data.turn))
const contexts = useTurnContext(String(data.turn))
const mentions = useFileMentions({ node, openFile, fileMentions, useTurnData })
const held = groupPart === 'reasoning' ? undefined : heldReplyOf(roster.byId[sessionId], sessionId, data.turn)
const closing = useClosingText(node, useTurnData)
const hidden = Boolean(roster.roomsById[sessionId]) || isHiddenTurn(roster, sessionId, data.turn)
const person = typedBy(roster, sessionId, data.turn)
const texts = groupPart === 'reasoning' ? [] : data.blocks.filter(block => block.kind === 'text' && block.text.trim() !== '').map((block, index) => index === 0 ? stripTrace(block.text) : block.text).filter(text => text.trim() !== '')
Expand All @@ -148,6 +159,9 @@ export function AssistantCell({ node, groupPart, sessionId, openFile, fileMentio
const relays = relayPart(data, groupPart) ? relaysBefore(contexts, steps, data) : []
if (texts.length === 0 && !reasoning && relays.length === 0) return null
const selfId = roster.byId[sessionId]?.id ?? sessionId
const bubbles = texts.length ? (person
? h(PersonMessage, { name: person, texts, roster, selfId, actions, sessionId, msgKey: `${sessionId}:${data.turn}:${node.key ?? data.seq ?? ''}`, useUi })
: h(Bubbles, { texts, roster, selfId, actions, streaming: running, sessionId, msgKey: `${sessionId}:${data.turn}:${node.key ?? data.seq ?? ''}`, useUi, mentions })) : null
return h(Fragment, null,
relays.map(relay => {
const source = relay.source
Expand All @@ -161,9 +175,22 @@ export function AssistantCell({ node, groupPart, sessionId, openFile, fileMentio
})
}),
reasoning ? h(ThinkingRow, { text: reasoning }) : null,
texts.length ? (person
? h(PersonMessage, { name: person, texts, roster, selfId, actions, sessionId, msgKey: `${sessionId}:${data.turn}:${node.key ?? data.seq ?? ''}`, useUi })
: h(Bubbles, { texts, roster, selfId, actions, streaming: running, sessionId, msgKey: `${sessionId}:${data.turn}:${node.key ?? data.seq ?? ''}`, useUi, mentions })) : null)
held && bubbles ? h(HeldReply, { held, roster, sessionId, actions, closing }, bubbles) : bubbles)
}

// A reply to a friend held for the owner (#418): its bubble reads as not sent (dashed) and carries
// the decision where it stands; once decided it says Sent or Not sent in the same place. The
// turn's closing text carries the buttons; an earlier text of the same turn is only marked.
function HeldReply({ held, roster, sessionId, actions, closing, children }) {
const bot = roster.byId[sessionId]
// The friend's Bot by name and owner when its chat is listed (#384), else the person (#418).
const chat = (roster.bots ?? []).find(item => item.remote?.handle === held.to && isListedRemoteChat(item))
const toName = chat
? remoteBotTitle({ displayName: chat.name, ownerName: chat.remote.homeName || chat.remote.owner, handle: chat.remote.handle, isChief: chat.remote.isChief })
: held.toOwner || held.to || t('a friend')
return h('div', { className: 'bt-held-reply', 'data-state': held.status, 'data-held': held.status === 'pending' ? '' : undefined },
children,
closing ? h(HeldReplyActions, { held: { ...held, toName, from: bot?.name }, actions }) : null)
}

// A friend's line that Bot Social attests the friend typed themselves, or another person's own
Expand Down
40 changes: 19 additions & 21 deletions core/src/client/flybot-slots.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
// flybot.composer.mention { id, order, candidates(query, { sessionId, roster, call }) → rows } rows in the "@" menu
// flybot.bot.details { id, order, component({ bot, roster, call }) } in a Bot's settings
// flybot.approvals { id, order, component({ approval, decide, call }) } custom card for a held send
// `inline: true` with `policy`: instead, the buttons drawn on a held reply's own bubble in the
// chat (#418), component({ held, decide, call, actions }); held = { id, by, status, to, toName, from }
// flybot.room.overview { id, order, component({ room, roster, actions, call }) } top of a group's overview
// flybot.session.dock { id, order, policy?, component({ sessionId, roster, room, bot, actions, call, claim, t }) } above the composer;
// a dock that draws the cards of one approval policy sets `policy` and calls claim(ids) with the
Expand All @@ -31,9 +33,11 @@ import { isListedRemoteChat } from '../shared/remote-label.js'
import { registry as baseRegistry } from './flybot-registry.js'
import { BotAvatar, BotMark, lookOf } from './mark.js'
import { t } from './i18n.js'
import { chatOpened, claimApprovals, claimedIds, onClaims } from './approval-claims.js'

import { createClientApi as createClientApiWith } from './flybot-registry.js'

export { chatOpened, claimApprovals } from './approval-claims.js'
export { SLOTS, createRegistry, mentionCandidates, newChatActionRows, botTree } from './flybot-registry.js'

// Calls a FlyBot endpoint on the host: "approvals", "decide", or a plugin's "social/...".
Expand Down Expand Up @@ -110,29 +114,14 @@ export function FlyBotSections({ roster, actions, renderBot }) {

// Approval ids a dock draws in its chat. The overlay skips them, so one approval has one card, in the
// chat it belongs to; an approval no open chat shows becomes a small pointer to that chat instead (#260).
const claims = new Map()
const claimListeners = new Set()
// Chats open right now (a session dock is mounted for them): the pointer never points at the chat you are in (#271).
const openChats = new Map()
export function chatOpened(sessionId, delta) {
const count = (openChats.get(sessionId) ?? 0) + delta
if (count > 0) openChats.set(sessionId, count)
else openChats.delete(sessionId)
for (const fn of [...claimListeners]) fn()
}
const claimedIds = () => Object.assign(new Set([...claims.values()].flatMap(ids => [...ids])), { chats: new Set(openChats.keys()) })
export function claimApprovals(owner, ids) {
if (!Array.isArray(ids) || ids.length === 0) claims.delete(owner)
else claims.set(owner, new Set(ids.map(String)))
for (const fn of [...claimListeners]) fn()
}
// The claims live in approval-claims.js, which a chat cell can import without the slot components (#418).
function useClaimed() {
const [claimed, setClaimed] = useState(claimedIds)
useEffect(() => {
const fn = () => setClaimed(claimedIds())
claimListeners.add(fn)
const off = onClaims(fn)
fn()
return () => { claimListeners.delete(fn) }
return off
}, [])
return claimed
}
Expand Down Expand Up @@ -188,7 +177,8 @@ const friendChatOf = (approval, roster) => {
}
// The one chat an approval belongs to: its room, else the friend's chat it is addressed to, else the
// Bot that wrote it (#278). The chat's dock draws its card; the pointer opens that chat.
const chatOf = (approval, roster) => approval.request?.meta?.roomId ?? friendChatOf(approval, roster)?.id ?? (typeof approval.request?.from === 'string' ? approval.request.from : undefined)
// A Bot's held reply to a friend is decided on its bubble in the chat it answers (#418).
const chatOf = (approval, roster) => approval.request?.meta?.roomId ?? approval.request?.meta?.replyIn?.sessionId ?? friendChatOf(approval, roster)?.id ?? (typeof approval.request?.from === 'string' ? approval.request.from : undefined)
// A friend's Bot by name and owner when the roster knows its chat; otherwise what the host called it.
function recipientName(approval, roster) {
const handle = handleOf(approval.request?.to)
Expand All @@ -198,7 +188,7 @@ function recipientName(approval, roster) {
if (owner) return `${bot.name} (${owner})`
return bot.name
}
return approval.targetName ?? handle ?? String(approval.request?.to ?? '')
return approval.recipientName ?? approval.targetName ?? handle ?? String(approval.request?.to ?? '')
}

export function FlyBotApprovals({ useRoster, actions } = {}) {
Expand Down Expand Up @@ -263,7 +253,7 @@ export function FlyBotApprovals({ useRoster, actions } = {}) {
typeof actions?.openSession === 'function' && chatOf(list[0], roster) ? h('button', { type: 'button', onClick: () => actions.openSession(chatOf(list[0], roster)) }, t('Review')) : null))) : null,
cards.length > 0 ? h('div', { className: 'bt-flybot-approvals', role: 'region', 'aria-label': t('Messages waiting for your approval') },
cards.map((approval) => {
const custom = entries.find(entry => entry.policy === undefined || entry.policy === approval.by)
const custom = entries.find(entry => entry.inline !== true && (entry.policy === undefined || entry.policy === approval.by))
if (custom) return h('div', { key: approval.id }, h(Safe, { entry: custom, props: { approval, decide: decision => decide(approval.id, decision), call } }))
const failed = approval.status === 'failed'
return h('div', { key: approval.id, className: 'bt-flybot-approval', title: approval.reason ?? undefined },
Expand Down Expand Up @@ -304,6 +294,14 @@ export const FLYBOT_CSS = `
.bt-flybot-approval-path { font-family: ui-monospace, monospace; font-size: 11px; opacity: .7; overflow-wrap: anywhere; }
.bt-flybot-approval-actions { display: flex; gap: 8px; justify-content: flex-end; }
.bt-flybot-approve { font-weight: 600; }
.bt-held-reply[data-state=pending] .bt-bubble { background: transparent; border: 1px dashed var(--bt-line-2); color: var(--bt-ink-2); }
.bt-held-reply[data-state=rejected] .bt-bubble, .bt-held-reply[data-state=failed] .bt-bubble { background: transparent; border: 1px dashed var(--bt-line-2); color: var(--bt-ink-3); }
.bt-held-actions { margin: 6px 0 2px; max-width: 420px; }
.bt-held-card { background: var(--bt-card, #fff); border: 1px solid var(--bt-line, rgba(0,0,0,.12)); border-radius: 10px; padding: 8px 10px; font-size: 13px; }
.bt-held-head { font-weight: 600; margin-bottom: 2px; }
.bt-held-note { margin: 2px 0 0; font-size: 12px; color: var(--bt-ink-3); display: flex; gap: 8px; align-items: center; }
.bt-held-note[data-state=failed] { color: var(--bt-danger, #d33); }
.bt-held-retry { border: 0; background: transparent; color: var(--bt-accent, #2563eb); font-size: 12px; cursor: pointer; padding: 0; }
.bt-flybot-pointers { position: fixed; top: 12px; right: 16px; z-index: 50; display: flex; flex-direction: column; gap: 6px; align-items: flex-end; max-width: 360px; }
.bt-flybot-pointer { display: flex; align-items: center; gap: 8px; background: var(--bt-card, #fff); border: 1px solid var(--bt-line, rgba(0,0,0,.12)); border-radius: 999px; padding: 5px 6px 5px 12px; box-shadow: 0 2px 10px rgba(0,0,0,.1); font-size: 12px; }
.bt-flybot-pointer-text { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 200px; }
Expand Down
65 changes: 65 additions & 0 deletions core/src/client/held-reply.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
// A Bot's reply to a friend, held for its owner (#418): the decision sits on the reply's own
// bubble in the Bot's chat, which is where the owner reads it. The holding policy's inline
// flybot.approvals entry draws the buttons (the social plugin's card); otherwise plain Approve /
// Reject. A waiting id is claimed, so no other card or pointer repeats it. Once decided, the
// bubble says what became of it, quietly, in the same place.
import { Component, createElement as h, useEffect, useState } from 'react'
import { call as callHost } from './api.js'
import { registry } from './flybot-registry.js'
import { claimApprovals } from './approval-claims.js'
import { t } from './i18n.js'

const call = (endpoint, payload) => callHost(`flybot/${endpoint}`, payload)

// A plugin card that throws draws the plain buttons instead, never a broken chat.
let Boundary
const boundary = () => Boundary ??= class HeldReplyBoundary extends Component {
constructor(props) {
super(props)
this.state = { failed: false }
}
static getDerivedStateFromError() { return { failed: true } }
componentDidCatch(error) { console.error(`flybot: ${this.props.id} failed`, error) }
render() { return this.state.failed ? this.props.fallback : this.props.children }
}

function useInlineEntry(policy) {
const slots = registry()
const [, setVersion] = useState(0)
useEffect(() => slots.subscribe(() => setVersion(slots.version())), [slots])
return slots.entries('flybot.approvals').find(entry => entry.inline === true && entry.policy === policy)
}

export function HeldReplyActions({ held, actions }) {
const custom = useInlineEntry(held?.by)
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
const waiting = held?.status === 'pending'
useEffect(() => {
if (!waiting) return undefined
claimApprovals(`held-reply:${held.id}`, [held.id])
return () => claimApprovals(`held-reply:${held.id}`, [])
}, [held?.id, waiting])
if (!held?.id) return null
const decide = async (decision) => {
setBusy(true)
setError('')
try { await call('decide', { id: held.id, decision }) } catch (failure) { setError(failure?.message ?? String(failure)) } finally { setBusy(false) }
}
if (!waiting) {
const failed = held.status === 'failed'
return h('div', { className: 'bt-held-note', 'data-state': held.status, title: failed && held.error ? held.error : undefined },
h('span', null, held.status === 'approved' ? t('Sent to {to}', { to: held.toName }) : failed ? t('Not sent: delivery failed') : t('Not sent')),
failed ? h('button', { type: 'button', className: 'bt-held-retry', disabled: busy, onClick: () => decide('retry') }, t('Retry')) : null,
error ? h('span', { className: 'bt-flybot-approval-error', role: 'alert' }, error) : null)
}
const plain = h('div', { className: 'bt-held-card', role: 'region', 'aria-label': t('Waiting for your approval') },
h('div', { className: 'bt-held-head' }, t('Waiting for your approval')),
h('div', { className: 'bt-flybot-approval-origin' }, t('{from} → {to}', { from: held.from ?? t('A Bot'), to: held.toName })),
h('div', { className: 'bt-flybot-approval-actions' },
h('button', { type: 'button', disabled: busy, onClick: () => decide('reject') }, t('Reject')),
h('button', { type: 'button', className: 'bt-flybot-approve', disabled: busy, onClick: () => decide('approve') }, t('Approve'))),
error ? h('div', { className: 'bt-flybot-approval-error', role: 'alert' }, error) : null)
return h('div', { className: 'bt-held-actions', 'data-flybot': 'held-reply-actions' },
custom ? h(boundary(), { id: custom.id, fallback: plain }, h(custom.component, { held, decide, call, actions })) : plain)
}
2 changes: 2 additions & 0 deletions core/src/client/i18n.js
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,8 @@ const ZH = {
'Messages waiting for your approval': '等你批准的消息',
'Waiting for approval': '等待批准',
'Not sent': '未发出',
'Sent to {to}': '已发给 {to}',
'Not sent: delivery failed': '未发出:发送失败',
'{from} → {to}': '{from} → {to}',
'+{count} more': '另有 {count} 条',
delegated: '委托转达',
Expand Down
Loading
Loading