Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/1584-busy-ack.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
A human message accepted while a Bot is busy (queue or steer, including long background job waits) now gets a short spoken acknowledgment in the chat promptly, instead of silence until the job finishes ([#1584](https://github.com/ThinkFlowLab/FlyBot/issues/1584)).
3,534 changes: 1,815 additions & 1,719 deletions core/client.js

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion core/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import * as taskDrive from './src/host/task-drive.js'
import * as models from './src/host/models.js'
import * as permissions from './src/host/permissions.js'
import * as turns from './src/host/turns.js'
import * as busyAck from './src/host/busy-ack.js'
import * as delivery from './src/host/delivery.js'
import * as roster from './src/host/roster.js'
import * as folders from './src/host/folders.js'
Expand Down Expand Up @@ -53,7 +54,7 @@ export const inject = ['llm', 'tools', 'systemPrompt', 'sessionController', 'ses
// the functions other modules call; they call them through `rt` at run time, never during
// install. Listeners run and tools are listed in registration order, so this order holds:
// usage meters model requests after every listener that rewrites them.
const MODULES = [diagnostics, store, harness, memberAuth, authz, models, permissions, turns, delivery, roster, folders, agents, prompt, timeContext, tools, decisions, chatIndex, groups, roomMembership, reach, tasks, taskDrive, approvals, resume, memory, parts, images, secrets, connectors, connectorAccess, capabilities, schedules, automationDefault, browser, usage, update, dshUpdate, hooks, remoteChat, workflowStore, channel]
const MODULES = [diagnostics, store, harness, memberAuth, authz, models, permissions, turns, busyAck, delivery, roster, folders, agents, prompt, timeContext, tools, decisions, chatIndex, groups, roomMembership, reach, tasks, taskDrive, approvals, resume, memory, parts, images, secrets, connectors, connectorAccess, capabilities, schedules, automationDefault, browser, usage, update, dshUpdate, hooks, remoteChat, workflowStore, channel]

export function apply(ctx, config = {}) {
const rt = createRuntime(ctx, config)
Expand Down
13 changes: 13 additions & 0 deletions core/src/client/busy-ack-logic.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
// Pure busy-ack helpers (#1584). Kept free of React so unit tests import them without a DOM.

export const BUSY_ACK_KEY = 'Got it — I’ll answer properly once I finish what’s in progress.'

export function pendingBusyPlacement(submissions) {
if (!Array.isArray(submissions)) return false
return submissions.some(item => item?.placement === 'queued' || item?.placement === 'steering')
}

export function busyAckVisible({ hostAck, pendingBusy, streamingText }) {
if (streamingText) return false
return Boolean(hostAck?.text) || pendingBusy === true
}
65 changes: 65 additions & 0 deletions core/src/client/busy-ack.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { createElement as h, useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { BotMark } from './mark.js'
import { RUNNING_HOST } from './activity-row.js'
import { t } from './i18n.js'
import { BUSY_ACK_KEY, busyAckVisible, pendingBusyPlacement } from './busy-ack-logic.js'

export { BUSY_ACK_KEY, busyAckVisible, pendingBusyPlacement } from './busy-ack-logic.js'

// Spoken busy-ack (#1584): when a human send is accepted while the Bot is running
// (host live busyAcks, or a local pending queue/steer submission), show one short
// bot bubble promptly instead of silence until a blocking job wait ends.

export function busyAckText(hostAck) {
return hostAck?.text || t(BUSY_ACK_KEY)
}

function useRunningHost(active) {
const [host, setHost] = useState(null)
useEffect(() => {
if (!active) { setHost(null); return undefined }
let frame = 0
const find = () => {
frame = 0
const node = document.querySelector(RUNNING_HOST)
setHost(prev => (prev === node ? prev : node))
}
const observer = new MutationObserver(() => { if (!frame) frame = requestAnimationFrame(find) })
observer.observe(document.querySelector('[data-conversation-scroll]') ?? document.body, { childList: true, subtree: true })
find()
return () => { cancelAnimationFrame(frame); observer.disconnect() }
}, [active])
return host
}

export function BusyAckBubble({ sessionId, useRoster, useBusyAcks, useSessionStatus, useSession, useLive }) {
const roster = useRoster(value => value)
const hostAck = useBusyAcks(map => map?.[sessionId] ?? null)
const running = useSessionStatus(map => map.get(sessionId)?.running === true)
const pendingBusy = useSession(snapshot => pendingBusyPlacement(snapshot?.pendingSubmissions))
const streamingText = useLive(state => Object.values(state[sessionId] ?? {}).some(entry => entry?.text))
const visible = running && busyAckVisible({ hostAck, pendingBusy, streamingText })
const bot = roster.byId[sessionId]
const host = useRunningHost(visible)
const shown = useRef(null)
if (visible) shown.current = { text: busyAckText(hostAck), at: hostAck?.at ?? Date.now() }
if (!visible || !host || !shown.current || !bot) return null
return createPortal(h('div', {
className: 'bt-busy-ack',
'data-flybot': 'busy-ack',
role: 'status',
'aria-live': 'polite',
},
h(BotMark, { bot, size: 28, state: 'thinking', live: true }),
h('div', { className: 'bt-astack' },
h('div', { className: 'bt-msg' },
h('div', { className: 'bt-msg-line' },
h('div', { className: 'bt-bubble' }, shown.current.text))))), host)
}

export const BUSY_ACK_CSS = `
.bt-busy-ack{display:flex;align-items:flex-end;gap:8px;width:100%;margin-top:var(--dsh-chat-flow-gap,12px);margin-bottom:4px}
.bt-busy-ack .bt-astack{align-items:flex-start}
.bt-busy-ack .bt-bubble{opacity:.92}
`
6 changes: 4 additions & 2 deletions core/src/client/header.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { ArrowUpIcon, CloseIcon, ArrowDownIcon, MemoryIcon } from './icons.js'
import { botState, BotAvatar, RoomAvatar } from './mark.js'
import { clockLabel, movedOnSince, openingTurn, unseenSince } from './cells.js'
import { ActivityIndicator } from './activity-row.js'
import { BusyAckBubble } from './busy-ack.js'
import { t } from './i18n.js'
import { friendBotTitle, roomTitle } from '../shared/remote-label.js'

Expand Down Expand Up @@ -43,7 +44,7 @@ function MemoryNotice({ shown }) {
h('span', { className: 'bt-pill-mem-in', ref: inner }, h('span', { className: 'bt-pill-mem-ico' }, h(MemoryIcon)), label))
}

export function HeaderPill({ sessionId, useRoster, useSessions, useSessionStatus, useSession, useActivity, useMemoryNews, useTurnClock, useUi, useLive, actions }) {
export function HeaderPill({ sessionId, useRoster, useSessions, useSessionStatus, useSession, useActivity, useBusyAcks, useMemoryNews, useTurnClock, useUi, useLive, actions }) {
const roster = useRoster(value => value)
const title = useSessions(list => list.byId[sessionId]?.displayTitle ?? list.byId[sessionId]?.title ?? '')
const firstTurns = useSessions((list) => {
Expand Down Expand Up @@ -96,7 +97,8 @@ export function HeaderPill({ sessionId, useRoster, useSessions, useSessionStatus
h('span', { className: 'bt-pill-go', 'aria-hidden': true }, h(GoArrow))),
h('span', { className: 'bt-sr', role: 'status' }, memoryNotice ? t('Memory updated') : ''),
h(NewMessagePills, { key: sessionId, sessionId, hiddenTurns, useSessions, useUi, useTurnClock, actions }),
h(ActivityIndicator, { key: `activity:${sessionId}`, sessionId, useRoster, useSessionStatus, useSession, useLive, actions }))
h(ActivityIndicator, { key: `activity:${sessionId}`, sessionId, useRoster, useSessionStatus, useSession, useLive, actions }),
h(BusyAckBubble, { key: `busy-ack:${sessionId}`, sessionId, useRoster, useBusyAcks, useSessionStatus, useSession, useLive }))
}

// New-message pills. "N new messages ↓" takes Back to bottom's place when
Expand Down
1 change: 1 addition & 0 deletions core/src/client/i18n.js
Original file line number Diff line number Diff line change
Expand Up @@ -785,6 +785,7 @@ const ZH = {
'Message actions': '消息操作',
Download: '下载',
'Thought it through': '思考过程',
'Got it — I’ll answer properly once I finish what’s in progress.': '收到,我先记着,手头忙完再详细回你。',
'Earlier messages': '更早的消息',
'Loading earlier messages…': '正在加载更早的消息…',
'Couldn’t load earlier messages · Retry': '更早的消息没加载出来 · 重试',
Expand Down
6 changes: 5 additions & 1 deletion core/src/client/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,8 @@ export function apply(ctx) {
return () => window.removeEventListener('pagehide', onPageHide)
}, 'ds-bot: remember visits on page hide')
const activity = createSource({})
const busyAcks = createSource({})
let busyAcksKey = '{}'
// Per Bot, the turn it runs and its tool; and the approvals waiting for the user.
const work = createSource({ bots: {}, approvals: [] })
let workKey = ''
Expand Down Expand Up @@ -176,6 +178,8 @@ export function apply(ctx) {
applied = order
const nextActivity = JSON.stringify(value.activity ?? {})
if (nextActivity !== activityKey) { activityKey = nextActivity; activity.set(value.activity ?? {}) }
const nextBusyAcks = JSON.stringify(value.busyAcks ?? {})
if (nextBusyAcks !== busyAcksKey) { busyAcksKey = nextBusyAcks; busyAcks.set(value.busyAcks ?? {}) }
const nextWork = JSON.stringify(value.work ?? null)
if (nextWork !== workKey) { workKey = nextWork; work.set(value.work ?? { bots: {}, approvals: [] }) }
noteMemoryNews(value.memoryNews ?? {})
Expand Down Expand Up @@ -771,7 +775,7 @@ export function apply(ctx) {
})
const browserPages = browsers?.pages ?? createSource({})
const browserTabs = browsers?.tabs ?? createSource({})
const face = () => ({ actions, hooks: { roster, ui, activity, work, memoryNews, registry, turnClock, live, cards, browserPages, browserTabs, surface, update } })
const face = () => ({ actions, hooks: { roster, ui, activity, busyAcks, work, memoryNews, registry, turnClock, live, cards, browserPages, browserTabs, surface, update } })
// What the pill does to the page follows the Session being looked at, not the seat the
// pill is painted in: the shell hides `.utilities` on a blank Session, so a Side effect
// parked in the pill stops there. Nothing is claimed for them either — `.corner`, the one
Expand Down
3 changes: 3 additions & 0 deletions core/src/client/styles.js
Original file line number Diff line number Diff line change
Expand Up @@ -967,6 +967,9 @@ body[data-bt-remote-chat=empty][data-content-phase=hero] [class*="_scrollBody"]:
[data-chat-running]{display:block!important;width:100%;min-height:0!important;margin:0!important;padding:0!important}
[data-chat-running]>span:not([role=status]){display:none!important}
[data-chat-running]:has(> .bt-typing){margin-top:var(--dsh-chat-flow-gap,12px)!important}
.bt-busy-ack{display:flex;align-items:flex-end;gap:8px;width:100%;margin-top:var(--dsh-chat-flow-gap,12px);margin-bottom:4px}
.bt-busy-ack .bt-astack{align-items:flex-start}
.bt-busy-ack .bt-bubble{opacity:.92}
.bt-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}
.bt-typing[data-carry],.bt-typing[data-carry]>.bt-typing-text{animation:none}
Expand Down
4 changes: 4 additions & 0 deletions core/src/host/authz.js
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,10 @@ export function install(rt) {
const pick = obj => Object.fromEntries(Object.entries(obj ?? {}).filter(([id]) => mine(id)))
return {
activity: pick(full.activity),
busyAcks: Object.fromEntries(Object.entries(full.busyAcks ?? {}).filter(([sessionId]) => {
const botId = rt.chatOwners?.get(sessionId)
return botId !== undefined && mine(botId)
})),
work: {
bots: pick(full.work.bots),
rooms: Object.fromEntries(Object.entries(full.work.rooms ?? {}).filter(([id]) => roomMemberOf(actor, id) || roomOwnedBy(actor, id))),
Expand Down
72 changes: 72 additions & 0 deletions core/src/host/busy-ack.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
// Busy-ack (#1584): a human message that lands while a Bot turn is already running
// gets a short spoken acknowledgment on the live payload immediately. Steer admits
// to next-step, but a blocking job wait may never yield a step (#1509), so without
// this the chat stays silent until the wait ends.
export const BUSY_ACK_EN = 'Got it — I’ll answer properly once I finish what’s in progress.'
export const BUSY_ACK_ZH = '收到,我先记着,手头忙完再详细回你。'
export const COALESCE_MS = 3_000

export const busyAckText = (language) => (/chinese|简体|\bzh\b/i.test(String(language ?? '')) ? BUSY_ACK_ZH : BUSY_ACK_EN)

export function shouldNoteBusyAck({ sourceKind, turnRunning, ownChat }) {
return sourceKind === 'user' && turnRunning === true && ownChat === true
}

export function install(rt) {
const { ctx } = rt
/** @type {Map<string, { text: string, at: number }>} */
const acks = new Map()

const turnRunning = sessionId => rt.live?.get(sessionId)?.turn !== undefined
const ownChat = sessionId => rt.chatOwners?.has(sessionId) === true

const note = (sessionId) => {
const now = Date.now()
const existing = acks.get(sessionId)
if (existing && now - existing.at < COALESCE_MS) {
existing.at = now
return existing
}
const entry = { text: busyAckText(rt.uiLanguage?.()), at: now }
acks.set(sessionId, entry)
return entry
}

const clear = (sessionId) => { acks.delete(sessionId) }

const textOfAssistant = (content) => {
if (!Array.isArray(content)) return ''
return content.filter(block => block?.type === 'text' && typeof block.text === 'string').map(block => block.text).join('')
}

ctx.on('session/event', (session, event) => {
const id = session?.id
if (id === undefined || event?.type === undefined) return
if (event.type === 'user/message') {
if (!shouldNoteBusyAck({
sourceKind: event.data?.source?.kind,
turnRunning: turnRunning(id),
ownChat: ownChat(id),
})) return
note(id)
return
}
if (event.type === 'assistant/message') {
if (textOfAssistant(event.data?.message?.content).trim() !== '') clear(id)
return
}
if (event.type === 'turn/end') clear(id)
})

ctx.on('agent/status', ({ agent, status }) => {
if (status !== 'idle') return
const id = agent?.session?.id ?? agent?.id
if (typeof id === 'string') clear(id)
})

Object.assign(rt, {
busyAcksView: () => Object.fromEntries([...acks].map(([id, ack]) => [id, { text: ack.text, at: ack.at }])),
noteBusyAck: note,
clearBusyAck: clear,
})
}
1 change: 1 addition & 0 deletions core/src/host/channel.js
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,7 @@ export function install(rt) {
const live = actor => {
const full = {
activity: rt.activityView(),
busyAcks: rt.busyAcksView?.() ?? {},
work: { bots: rt.workView(), rooms: rt.roomWorkView(), approvals: rt.approvalsView(), ended: rt.endedView?.() ?? [] },
memoryNews: rt.memoryNews(),
memoryReviews: rt.memoryReviews(),
Expand Down
22 changes: 22 additions & 0 deletions core/test/busy-ack-client.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import assert from 'node:assert/strict'
import { test } from 'node:test'
import {
BUSY_ACK_KEY, busyAckVisible, pendingBusyPlacement,
} from '../src/client/busy-ack-logic.js'

test('pendingBusyPlacement is true for queued or steering submissions', () => {
assert.equal(pendingBusyPlacement([]), false)
assert.equal(pendingBusyPlacement(undefined), false)
assert.equal(pendingBusyPlacement([{ placement: 'transcript' }]), false)
assert.equal(pendingBusyPlacement([{ placement: 'queued' }]), true)
assert.equal(pendingBusyPlacement([{ placement: 'steering' }]), true)
assert.equal(pendingBusyPlacement([{ placement: 'transcript' }, { placement: 'queued' }]), true)
})

test('busyAckVisible prefers host or pending ack and hides once spoken text streams', () => {
assert.equal(busyAckVisible({ hostAck: null, pendingBusy: false, streamingText: false }), false)
assert.equal(busyAckVisible({ hostAck: { text: '收到' }, pendingBusy: false, streamingText: false }), true)
assert.equal(busyAckVisible({ hostAck: null, pendingBusy: true, streamingText: false }), true)
assert.equal(busyAckVisible({ hostAck: { text: '收到' }, pendingBusy: true, streamingText: true }), false)
assert.equal(BUSY_ACK_KEY.includes('Got it'), true)
})
102 changes: 102 additions & 0 deletions core/test/busy-ack.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import assert from 'node:assert/strict'
import { test } from 'node:test'
import {
BUSY_ACK_EN, BUSY_ACK_ZH, busyAckText, shouldNoteBusyAck,
} from '../src/host/busy-ack.js'

test('busyAckText follows the UI language', () => {
assert.equal(busyAckText('English'), BUSY_ACK_EN)
assert.equal(busyAckText('Chinese (简体中文)'), BUSY_ACK_ZH)
assert.equal(busyAckText('zh-CN'), BUSY_ACK_ZH)
})

test('shouldNoteBusyAck only for a human line in an own chat while a turn runs', () => {
assert.equal(shouldNoteBusyAck({ sourceKind: 'user', turnRunning: true, ownChat: true }), true)
assert.equal(shouldNoteBusyAck({ sourceKind: 'user', turnRunning: false, ownChat: true }), false)
assert.equal(shouldNoteBusyAck({ sourceKind: 'user', turnRunning: true, ownChat: false }), false)
assert.equal(shouldNoteBusyAck({ sourceKind: 'bot', turnRunning: true, ownChat: true }), false)
assert.equal(shouldNoteBusyAck({ sourceKind: undefined, turnRunning: true, ownChat: true }), false)
})

test('install notes a human message while a turn runs and clears on spoken assistant text', async () => {
const { install } = await import('../src/host/busy-ack.js')
const listeners = new Map()
const live = new Map([['a', { turn: 1 }]])
const chatOwners = new Map([['a', 'a']])
const rt = {
live,
chatOwners,
uiLanguage: () => 'Chinese (简体中文)',
}
const ctx = {
on(name, fn) {
(listeners.get(name) ?? listeners.set(name, []).get(name)).push(fn)
},
}
install(Object.assign(rt, { ctx }))
assert.deepEqual(rt.busyAcksView(), {})

for (const fn of listeners.get('session/event')) {
fn({ id: 'a' }, { type: 'user/message', data: { source: { kind: 'user' }, content: [{ type: 'text', text: 'hello' }] } })
}
const acks = rt.busyAcksView()
assert.equal(acks.a.text, BUSY_ACK_ZH)
assert.ok(typeof acks.a.at === 'number')

// A second burst within the coalesce window keeps one ack.
const at = acks.a.at
for (const fn of listeners.get('session/event')) {
fn({ id: 'a' }, { type: 'user/message', data: { source: { kind: 'user' }, content: [{ type: 'text', text: 'again' }] } })
}
assert.equal(Object.keys(rt.busyAcksView()).length, 1)
assert.ok(rt.busyAcksView().a.at >= at)

// Idle agent with no turn does not note.
live.delete('a')
for (const fn of listeners.get('session/event')) {
fn({ id: 'a' }, { type: 'user/message', data: { source: { kind: 'user' }, content: [{ type: 'text', text: 'idle' }] } })
}
assert.ok(rt.busyAcksView().a)

// Spoken assistant text clears the ack.
live.set('a', { turn: 1 })
for (const fn of listeners.get('session/event')) {
fn({ id: 'a' }, { type: 'assistant/message', data: { message: { content: [{ type: 'text', text: '详细回复' }] } } })
}
assert.deepEqual(rt.busyAcksView(), {})
})

test('install clears on turn/end and agent idle', async () => {
const { install, BUSY_ACK_EN } = await import('../src/host/busy-ack.js')
const listeners = new Map()
const live = new Map([['a', { turn: 2 }]])
const rt = {
live,
chatOwners: new Map([['a', 'a']]),
uiLanguage: () => 'English',
}
const ctx = {
on(name, fn) {
(listeners.get(name) ?? listeners.set(name, []).get(name)).push(fn)
},
}
install(Object.assign(rt, { ctx }))
for (const fn of listeners.get('session/event')) {
fn({ id: 'a' }, { type: 'user/message', data: { source: { kind: 'user' } } })
}
assert.equal(rt.busyAcksView().a.text, BUSY_ACK_EN)

for (const fn of listeners.get('session/event')) {
fn({ id: 'a' }, { type: 'turn/end', data: { turn: 2 } })
}
assert.deepEqual(rt.busyAcksView(), {})

for (const fn of listeners.get('session/event')) {
fn({ id: 'a' }, { type: 'user/message', data: { source: { kind: 'user' } } })
}
assert.ok(rt.busyAcksView().a)
for (const fn of listeners.get('agent/status')) {
fn({ agent: { session: { id: 'a' } }, status: 'idle' })
}
assert.deepEqual(rt.busyAcksView(), {})
})
2 changes: 2 additions & 0 deletions core/test/install.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ test('apply registers listeners, tools, and effects in a fixed order', (t) => {
'effect ds-bot: state lock',
'on llm/adapters-updated',
'on session/event',
'on session/event',
'on agent/status',
'effect ds-bot: agent tool restrict retry',
'on agent/created',
'effect ds-bot: identity section',
Expand Down
Loading
Loading