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/546-group-status-rows.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Group chats: every Bot at work shows as one row of the same shape in one place above the message box, the current speaker included, instead of one row on the left and another further right. Each row says what the Bot is doing and on what (for example `Running shell commands: npm test`), names a tool it has no phrase for instead of "On it", and keeps its elapsed time ([#546](https://github.com/ThinkFlowLab/FlyBot/issues/546)).
96 changes: 46 additions & 50 deletions core/client.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion core/src/client/activity-row.js
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ 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)
// Group chats show who is typing on their own while members speak.
// While members speak, the member dock shows each one at work (member-status.js).
const relaying = tools.some(entry => entry.relay)
// A friend's chat (#443): my message leaves in one short turn and the reply comes back in
// another, so the row stays up from the send until the reply lands, with the friend's Bot mark.
Expand Down
40 changes: 19 additions & 21 deletions core/src/client/cells.js
Original file line number Diff line number Diff line change
Expand Up @@ -808,10 +808,16 @@ function toolActivity(name, arg, findBot, self) {
}
}

// What a Bot busy elsewhere is doing, from its current tool alone.
// What a Bot busy elsewhere is doing, from its current tool alone. A tool without a
// phrase is named, so the line never says only "On it".
export function workPhrase(tool) {
if (!tool) return t('Thinking about it')
return toolActivity(tool, () => undefined, () => undefined)?.label ?? t('On it')
return toolActivity(tool, () => undefined, () => undefined)?.label ?? t('Using {tool}', { tool })
}

// The phrase and, when the host knows it, the step the tool acts on (a command, a path).
export function workText(tool, step) {
return step ? t('{action}: {detail}', { action: workPhrase(tool), detail: step }) : `${workPhrase(tool)}…`
}

// Transcript rows report what is in flight to the one activity row at the tail.
Expand All @@ -824,8 +830,8 @@ function useLiveNote(actions, sessionId, id, entry) {
}, [sessionId, id, key])
}

export function GroupReplies({ sessionId, callId, done, content, roster, actions, useUi, useWork }) {
const [live, setLive] = useState({ replies: [], speaking: null })
export function GroupReplies({ sessionId, callId, done, content, roster, actions, useUi }) {
const [live, setLive] = useState({ replies: [] })
useEffect(() => {
if (done) return undefined
let stop = false
Expand All @@ -835,8 +841,8 @@ export function GroupReplies({ sessionId, callId, done, content, roster, actions
const result = await actions.roomProgress(sessionId)
if (stop) return
const entry = (result ?? []).find(item => item.callId === callId) ?? (result ?? [])[0]
const key = entry ? JSON.stringify([entry.replies, entry.speaking]) : ''
if (entry && key !== seen) { seen = key; setLive({ replies: entry.replies ?? [], speaking: entry.speaking ?? null }) }
const key = entry ? JSON.stringify(entry.replies) : ''
if (entry && key !== seen) { seen = key; setLive({ replies: entry.replies ?? [] }) }
timer = setTimeout(tick, document.hidden ? 5000 : 1000)
}
void tick()
Expand All @@ -846,10 +852,9 @@ export function GroupReplies({ sessionId, callId, done, content, roster, actions
if (done) {
try { replies = JSON.parse(contentText(content)).replies ?? [] } catch { replies = [] }
}
// Members speak one at a time, so only the current speaker is typing.
const pending = done || !live.speaking ? [] : [live.speaking]
const speakerTool = useWork?.(work => work.bots?.[live.speaking]?.tool) ?? undefined
if (replies.length === 0 && pending.length === 0) return null
// Who is at work, the current speaker included, shows in the member dock above the
// composer (member-status.js), one row per Bot in one column.
if (replies.length === 0) return null
const room = roster.roomsById[sessionId]
return h('div', { className: 'bt-group-replies' },
replies.map((reply, index) => {
Expand Down Expand Up @@ -911,13 +916,6 @@ export function GroupReplies({ sessionId, callId, done, content, roster, actions
})
}
return h(GroupMessage, { key: `${reply.botId}-${index}`, bot, text: reply.text, room, roster, actions, msgKey: callId ? `${sessionId}:${callId}:${index}` : undefined, useUi, seq: reply.seq })
}),
pending.map((id) => {
const bot = roster.byId[id]
if (!bot) return null
return h('div', { key: `pending-${id}`, className: 'bt-activity' },
h(BotMark, { bot: bot, size: 22, state: speakerTool ? toolState(speakerTool) : 'thinking' }),
h(Shimmer, null, speakerTool ? `${bot.name} · ${workPhrase(speakerTool)}…` : t('{name} is typing…', { name: bot.name })))
}))
}

Expand Down Expand Up @@ -954,7 +952,7 @@ function useShownCards(actions, sessionId, callIds) {
}, [sessionId, key])
}

function renderCall(call, { sessionId, roster, actions, useUi, useWork, findBot }) {
function renderCall(call, { sessionId, roster, actions, useUi, findBot }) {
const tool = toolName(call)
const done = call.kind === 'tool-result'
const { text: arg, value: argValue } = toolArgs(call)
Expand All @@ -980,7 +978,7 @@ function renderCall(call, { sessionId, roster, actions, useUi, useWork, findBot
return done && target ? h(EventLine, { prefix: t('Updated'), bots: [target], onOpen: bot => actions.openSession(bot.id) }) : null
}
case 'group_relay':
return h(GroupReplies, { sessionId, callId: call.callId, done, content: call.content, roster, actions, useUi, useWork })
return h(GroupReplies, { sessionId, callId: call.callId, done, content: call.content, roster, actions, useUi })
case 'create_group':
case 'update_group':
case 'delete_group':
Expand Down Expand Up @@ -1030,7 +1028,7 @@ function renderCall(call, { sessionId, roster, actions, useUi, useWork, findBot
}
}

export function ToolCell({ node, sessionId, useRoster, useUi, useWork, actions }) {
export function ToolCell({ node, sessionId, useRoster, useUi, actions }) {
const roster = useRoster(value => value)
const root = node.data.root
const turn = node.location?.turn?.turn ?? root.turn
Expand All @@ -1045,6 +1043,6 @@ export function ToolCell({ node, sessionId, useRoster, useUi, useWork, actions }
useLiveNote(actions, sessionId, `tool:${root.callId ?? node.key}`, note)
useShownCards(actions, sessionId, drawn.filter(call => CARDS.has(toolName(call)) && call.callId).map(call => call.callId))
if (drawn.length === 0) return null
const env = { sessionId, roster, actions, useUi, useWork, findBot }
const env = { sessionId, roster, actions, useUi, findBot }
return h(Fragment, null, drawn.map((call, index) => h(Fragment, { key: call.callId ?? index }, renderCall(call, env))))
}
3 changes: 2 additions & 1 deletion core/src/client/i18n.js
Original file line number Diff line number Diff line change
Expand Up @@ -615,6 +615,8 @@ const ZH = {
'Working out a plan': '理一下思路',
'Sorting out the details': '梳理细节',
'On it': '在做了',
'Using {tool}': '使用 {tool}',
'{action}: {detail}': '{action}:{detail}',
'Busy with it': '正在处理',
'Moving ahead': '推进中',
'Taking care of it': '处理中',
Expand Down Expand Up @@ -642,7 +644,6 @@ const ZH = {
'Looking back through the chat': '翻看聊天记录',
'{name} looked up its own chat': '{name} 查看了自己的对话',
'{name} looked up its own chat for “{text}”': '{name} 在自己的对话里查找「{text}」',
'{name} is typing…': '{name} 正在输入…',
"{name} didn't answer in time": '{name} 没有及时回应',
'{name} paused for your approval': '{name} 停下来等你批准',
'{name} started on it': '{name} 开始做了',
Expand Down
65 changes: 37 additions & 28 deletions core/src/client/member-status.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { createElement as h, useEffect, useLayoutEffect, useRef, useState } from 'react'
import { BotMark, toolState } from './mark.js'
import { Shimmer, workPhrase } from './cells.js'
import { Shimmer, workText } from './cells.js'
import { elapsedLabel } from './activity-row.js'
import { t } from './i18n.js'

Expand All @@ -13,13 +13,20 @@ import { t } from './i18n.js'
const NO_WORK = { bots: {}, rooms: {}, approvals: [] }
const ROWS_MAX = 4

// The step the Bot's own view names (a command, a path) belongs to its work in this group
// when that view is about this group and the same tool.
const stepIn = (work, botId, roomId, tool) => {
const own = work.bots?.[botId]
return own?.roomId === roomId && own.tool === tool ? own.step : undefined
}

// Work in this group wins over the Bot's own chat or another group.
export function memberWork(room, work = NO_WORK) {
const here = work.rooms?.[room.id] ?? []
return room.members.map((id) => {
const approvals = (work.approvals ?? []).filter(entry => entry.botId === id)
const local = here.find(entry => entry.botId === id)
const doing = local ? { ...local, roomId: room.id } : work.bots?.[id]
const doing = local ? { ...local, roomId: room.id, step: stepIn(work, id, room.id, local.tool) } : work.bots?.[id]
return { id, approvals, doing, here: local !== undefined, state: approvals.length > 0 ? 'ask' : doing ? 'work' : 'idle' }
})
}
Expand All @@ -31,20 +38,20 @@ export function busyMembers(room, work, roster) {
.map(entry => ({ ...entry, bot: roster.byId[entry.id] }))
}

// Members at work in this group get a row of their own, apart from the round's current
// speaker, which its round shows; the rest go on the summary line.
// Members at work in this group, the round's current speaker first, get a row of their
// own; the rest go on the summary line.
export function splitBusy(busy) {
const speaking = entry => entry.state === 'work' && entry.here && entry.doing.speaking === true
const rows = busy.filter(entry => entry.state === 'work' && entry.here && !speaking(entry)).slice(0, ROWS_MAX)
return { rows, rest: busy.filter(entry => !rows.includes(entry) && !speaking(entry)) }
const here = busy.filter(entry => entry.state === 'work' && entry.here)
const rows = [...here.filter(entry => entry.doing.speaking === true), ...here.filter(entry => entry.doing.speaking !== true)].slice(0, ROWS_MAX)
return { rows, rest: busy.filter(entry => !rows.includes(entry)) }
}

// The groups a Bot is at work in.
export function botGroupWork(botId, work, roster) {
return Object.entries(work.rooms ?? {}).flatMap(([roomId, entries]) => {
const entry = entries.find(item => item.botId === botId)
const room = roster.roomsById[roomId]
return entry && room ? [{ room, tool: entry.tool, since: entry.since }] : []
return entry && room ? [{ room, tool: entry.tool, step: stepIn(work, botId, roomId, entry.tool), since: entry.since }] : []
})
}

Expand All @@ -57,7 +64,7 @@ export function summaryText(busy) {
: asks.length === 2 ? t('{first} and {second} are waiting for your approval', { first: names[0], second: names[1] })
: t('{first}, {second} and {count} more are waiting for your approval', { first: names[0], second: names[1], count: asks.length - 2, total: asks.length })
const first = workers[0]
const doing = first ? `${first.bot.name} · ${workPhrase(first.doing.tool)}…${workers.length > 1 ? ` +${workers.length - 1}` : ''}` : ''
const doing = first ? `${first.bot.name} · ${workText(first.doing.tool, first.doing.step)}${workers.length > 1 ? ` +${workers.length - 1}` : ''}` : ''
return { waiting, doing }
}

Expand All @@ -66,24 +73,29 @@ function Face({ entry, size }) {
h(BotMark, { bot: entry.bot, size, state: entry.state === 'ask' ? 'alert' : toolState(entry.doing?.tool ?? '') }))
}

// One piece of work: the Bot's mark and what it is doing, like the activity row.
function WorkRow({ bot, tool, label, at, now, onOpen }) {
const text = `${label} · ${workPhrase(tool)}…`
const elapsed = typeof at === 'number' && now - at >= 60000 ? elapsedLabel(now - at) : ''
const elapsedSince = (now, at) => (typeof at === 'number' && now - at >= 60000 ? elapsedLabel(now - at) : '')

// One piece of work: the Bot's mark, what it is doing and on what, and for how long.
function WorkRow({ bot, tool, step, label, at, now, onOpen }) {
const text = `${label} · ${workText(tool, step)}`
const elapsed = elapsedSince(now, at)
return h('button', { type: 'button', className: 'bt-mw-row', 'aria-label': [text, elapsed].filter(Boolean).join(' · '), onClick: onOpen },
h(BotMark, { bot, size: 22, state: toolState(tool ?? ''), live: true }),
h('span', { className: 'bt-mw-row-text' }, h(Shimmer, null, text)),
elapsed ? h('span', { className: 'bt-mw-row-time' }, `· ${elapsed}`) : null)
}

function SummaryLine({ busy, onOpen }) {
// The rest, in the rows' column and shape: who waits for an approval, then who works elsewhere.
function SummaryLine({ busy, now, onOpen }) {
const { waiting, doing } = summaryText(busy)
return h('div', { className: 'bt-event bt-mw-dock' },
h('button', { type: 'button', className: 'bt-event-whole bt-mw-line', 'aria-label': [waiting, doing].filter(Boolean).join(' · '), onClick: onOpen },
h('span', { className: 'bt-mw-stack', 'aria-hidden': true }, busy.slice(0, 4).map(entry => h(Face, { key: entry.id, entry, size: 16 }))),
const elapsed = doing ? elapsedSince(now, busy.find(entry => entry.state === 'work')?.doing.since) : ''
return h('button', { type: 'button', className: 'bt-mw-row bt-mw-line', 'aria-label': [waiting, doing, elapsed].filter(Boolean).join(' · '), onClick: onOpen },
h('span', { className: 'bt-mw-stack', 'aria-hidden': true }, busy.slice(0, 4).map(entry => h(Face, { key: entry.id, entry, size: 16 }))),
h('span', { className: 'bt-mw-row-text' },
waiting ? h('span', { className: 'bt-mw-ask' }, waiting) : null,
waiting && doing ? h('span', { 'aria-hidden': true }, '·') : null,
doing ? h('span', { className: 'bt-event-name' }, h(Shimmer, null, doing)) : null))
waiting && doing ? ' · ' : null,
doing ? h(Shimmer, null, doing) : null),
elapsed ? h('span', { className: 'bt-mw-row-time' }, `· ${elapsed}`) : null)
}

// Above a group's composer: who is at work here, and a line for the rest; both open the
Expand All @@ -106,11 +118,11 @@ export function MemberDock({ sessionId, useRoster, useWork, actions }) {
const { rows, rest } = splitBusy(busy)
const open = () => actions.showDetails(sessionId)
return h('div', { ref, className: 'bt-mw-dock-stack' },
rows.map(entry => h(WorkRow, { key: entry.id, bot: entry.bot, tool: entry.doing.tool, label: entry.bot.name, at: entry.doing.since, now, onOpen: open })),
rest.length > 0 ? h(SummaryLine, { busy: rest, onOpen: open }) : null)
rows.map(entry => h(WorkRow, { key: entry.id, bot: entry.bot, tool: entry.doing.tool, step: entry.doing.step, label: entry.bot.name, at: entry.doing.since, now, onOpen: open })),
rest.length > 0 ? h(SummaryLine, { busy: rest, now, onOpen: open }) : null)
}
return h('div', { ref, className: 'bt-mw-dock-stack' }, groups.slice(0, ROWS_MAX).map(item => h(WorkRow, {
key: item.room.id, bot, tool: item.tool, label: t('in {name}', { name: item.room.name }), at: item.since, now, onOpen: () => actions.openSession(item.room.id),
key: item.room.id, bot, tool: item.tool, step: item.step, label: t('in {name}', { name: item.room.name }), at: item.since, now, onOpen: () => actions.openSession(item.room.id),
})))
}

Expand Down Expand Up @@ -190,7 +202,7 @@ export function MemberWork({ entry, bot, room, roster, actions, now }) {
}
return h('div', { className: 'bt-mw-member' },
h('div', { className: 'bt-mw-state bt-mw-work' },
h('span', null, h(Shimmer, null, `${workPhrase(entry.doing.tool)}…`), where ? ` ${where}` : '', since(now, entry.doing.since)),
h('span', null, h(Shimmer, null, workText(entry.doing.tool, entry.doing.step)), where ? ` ${where}` : '', since(now, entry.doing.since)),
entry.here ? h('button', { type: 'button', className: 'bt-mw-stop', disabled: stopping, onClick: stop }, t('Stop')) : null))
}

Expand All @@ -203,7 +215,6 @@ export function useMemberWork(room, useWork) {

export const MEMBER_CSS = `
.bt-mw-dock-stack{box-sizing:border-box;display:flex;flex-direction:column;align-items:flex-start;gap:2px;width:var(--dsh-chat-content-width,min(960px,100%));margin:0 auto 6px;pointer-events:none}
.bt-mw-dock-stack>.bt-event.bt-mw-dock{align-self:stretch;margin:0}
.bt-mw-row{pointer-events:auto;display:flex;align-items:center;gap:10px;min-width:0;max-width:calc(100% + 6px);height:32px;margin-inline-start:-6px;padding:0 12px 0 6px;border:0;border-radius:16px;background:none;color:var(--bt-ink-2);font:inherit;font-size:14px;cursor:pointer;animation:bt-mw-in .32s cubic-bezier(.23,1,.32,1) backwards;transition:background-color .16s ease-out}
.bt-mw-row:hover{background:var(--bt-hover)}
.bt-mw-row>.bt-mark{flex:none}
Expand All @@ -213,9 +224,7 @@ export const MEMBER_CSS = `
.bt-mw-stop{flex:none;height:22px;padding:0 10px;border:1px solid var(--bt-line,rgba(0,0,0,.12));border-radius:11px;background:none;color:var(--bt-ink-2);font:inherit;font-size:12px;cursor:pointer}
.bt-mw-stop:hover{background:var(--bt-hover)}
.bt-mw-stop:disabled{opacity:.5;cursor:default}
.bt-event.bt-mw-dock{margin:0 0 6px;pointer-events:none}
.bt-mw-dock>button{pointer-events:auto;animation:bt-mw-in .32s cubic-bezier(.23,1,.32,1) backwards}
.bt-mw-line{gap:6px!important}
.bt-mw-line{gap:8px}
.bt-mw-stack{display:inline-flex;align-items:center;flex:none;line-height:0}
.bt-mw-stack>*+*{margin-left:-4px}
.bt-mw-face{position:relative;display:inline-flex;flex:none;border-radius:50%}
Expand All @@ -234,5 +243,5 @@ body[data-ds-dark-theme] .bt-mw-ask{color:#f0b84a}
.bt-mw-actions>button{height:28px;padding:0 12px;font-size:12px}
.bt-mw-actions>button:disabled{opacity:.5;cursor:default}
@keyframes bt-mw-in{from{opacity:0;transform:translateY(4px)}}
@media (prefers-reduced-motion:reduce){.bt-mw-dock>button,.bt-mw-row{animation:none}}
@media (prefers-reduced-motion:reduce){.bt-mw-row{animation:none}}
`
1 change: 0 additions & 1 deletion core/src/client/styles.js
Original file line number Diff line number Diff line change
Expand Up @@ -844,7 +844,6 @@ export const CONVERSATION_CSS = `
.bt-event-whole>.bt-event-name{flex:0 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.bt-time-sep{display:flex;justify-content:center;align-items:center;height:28px;margin:14px 0 8px;font-size:12px;line-height:16px;color:var(--bt-ink-2);white-space:nowrap}
[class*="_scroll"]>[class*="_column"]::before{content:var(--bt-first-sep,none);display:block;flex:none;height:28px;margin:17px 0 12px;font-size:12px;line-height:28px;text-align:center;color:var(--bt-ink-2);white-space:nowrap}
.bt-activity{display:flex;align-items:center;gap:10px;margin:8px 0 4px 2px;font-size:14px;color:var(--bt-ink-2)}
.bt-group-replies{display:contents}
/* A Bot chat is flat in every work-details mode, without changing the user's mode
(it is a DSH-wide setting): step groups lose their header and stay open, and what a
Expand Down
Loading
Loading