diff --git a/changelog.d/546-group-status-rows.fixed.md b/changelog.d/546-group-status-rows.fixed.md new file mode 100644 index 000000000..3b86bd3e1 --- /dev/null +++ b/changelog.d/546-group-status-rows.fixed.md @@ -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)). diff --git a/core/client.js b/core/client.js index 4ad60cf0a..2eb6e928d 100644 --- a/core/client.js +++ b/core/client.js @@ -671,6 +671,8 @@ var 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": "处理中", @@ -698,7 +700,6 @@ var 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} 开始做了", @@ -3908,7 +3909,6 @@ var CONVERSATION_CSS = ` .bt-event-whole>.bt-event-name{flex:0 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .bt-time-sep{display:flex;justify-content:center;align-items:center;height:28px;margin:14px 0 8px;font-size:12px;line-height:16px;color:var(--bt-ink-2);white-space:nowrap} [class*="_scroll"]>[class*="_column"]::before{content:var(--bt-first-sep,none);display:block;flex:none;height:28px;margin:17px 0 12px;font-size:12px;line-height:28px;text-align:center;color:var(--bt-ink-2);white-space:nowrap} -.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 @@ -5994,7 +5994,10 @@ function toolActivity(name, arg, findBot, self) { } function workPhrase(tool) { if (!tool) return t("Thinking about it"); - return toolActivity(tool, () => void 0, () => void 0)?.label ?? t("On it"); + return toolActivity(tool, () => void 0, () => void 0)?.label ?? t("Using {tool}", { tool }); +} +function workText(tool, step) { + return step ? t("{action}: {detail}", { action: workPhrase(tool), detail: step }) : `${workPhrase(tool)}…`; } function useLiveNote(actions, sessionId, id, entry) { const key = entry ? JSON.stringify(entry) : ""; @@ -6004,8 +6007,8 @@ function useLiveNote(actions, sessionId, id, entry) { return () => actions.noteLive(sessionId, id, null); }, [sessionId, id, key]); } -function GroupReplies({ sessionId, callId, done, content, roster, actions, useUi, useWork }) { - const [live, setLive] = (0, import_react8.useState)({ replies: [], speaking: null }); +function GroupReplies({ sessionId, callId, done, content, roster, actions, useUi }) { + const [live, setLive] = (0, import_react8.useState)({ replies: [] }); (0, import_react8.useEffect)(() => { if (done) return void 0; let stop = false; @@ -6015,10 +6018,10 @@ function GroupReplies({ sessionId, callId, done, content, roster, actions, useUi const result = await actions.roomProgress(sessionId); if (stop) return; const entry = (result ?? []).find((item) => item.callId === callId) ?? (result ?? [])[0]; - const key = entry ? JSON.stringify([entry.replies, entry.speaking]) : ""; + const key = entry ? JSON.stringify(entry.replies) : ""; if (entry && key !== seen) { seen = key; - setLive({ replies: entry.replies ?? [], speaking: entry.speaking ?? null }); + setLive({ replies: entry.replies ?? [] }); } timer = setTimeout(tick, document.hidden ? 5e3 : 1e3); }; @@ -6036,9 +6039,7 @@ function GroupReplies({ sessionId, callId, done, content, roster, actions, useUi replies = []; } } - const pending = done || !live.speaking ? [] : [live.speaking]; - const speakerTool = useWork?.((work) => work.bots?.[live.speaking]?.tool) ?? void 0; - if (replies.length === 0 && pending.length === 0) return null; + if (replies.length === 0) return null; const room2 = roster.roomsById[sessionId]; return (0, import_react8.createElement)( "div", @@ -6118,16 +6119,6 @@ function GroupReplies({ sessionId, callId, done, content, roster, actions, useUi }); } return (0, import_react8.createElement)(GroupMessage, { key: `${reply.botId}-${index}`, bot, text: reply.text, room: room2, roster, actions, msgKey: callId ? `${sessionId}:${callId}:${index}` : void 0, useUi, seq: reply.seq }); - }), - pending.map((id) => { - const bot = roster.byId[id]; - if (!bot) return null; - return (0, import_react8.createElement)( - "div", - { key: `pending-${id}`, className: "bt-activity" }, - (0, import_react8.createElement)(BotMark, { bot, size: 22, state: speakerTool ? toolState(speakerTool) : "thinking" }), - (0, import_react8.createElement)(Shimmer, null, speakerTool ? `${bot.name} · ${workPhrase(speakerTool)}…` : t("{name} is typing…", { name: bot.name })) - ); }) ); } @@ -6155,7 +6146,7 @@ function useShownCards(actions, sessionId, callIds) { return () => actions.noteCards(sessionId, ids, -1); }, [sessionId, key]); } -function renderCall(call4, { sessionId, roster, actions, useUi, useWork, findBot }) { +function renderCall(call4, { sessionId, roster, actions, useUi, findBot }) { const tool = toolName(call4); const done = call4.kind === "tool-result"; const { text: arg, value: argValue } = toolArgs(call4); @@ -6181,7 +6172,7 @@ function renderCall(call4, { sessionId, roster, actions, useUi, useWork, findBot return done && target ? (0, import_react8.createElement)(EventLine, { prefix: t("Updated"), bots: [target], onOpen: (bot) => actions.openSession(bot.id) }) : null; } case "group_relay": - return (0, import_react8.createElement)(GroupReplies, { sessionId, callId: call4.callId, done, content: call4.content, roster, actions, useUi, useWork }); + return (0, import_react8.createElement)(GroupReplies, { sessionId, callId: call4.callId, done, content: call4.content, roster, actions, useUi }); case "create_group": case "update_group": case "delete_group": @@ -6232,7 +6223,7 @@ function renderCall(call4, { sessionId, roster, actions, useUi, useWork, findBot return null; } } -function ToolCell({ node, sessionId, useRoster, useUi, useWork, actions }) { +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; @@ -6245,7 +6236,7 @@ function ToolCell({ node, sessionId, useRoster, useUi, useWork, actions }) { useLiveNote(actions, sessionId, `tool:${root.callId ?? node.key}`, note); useShownCards(actions, sessionId, drawn.filter((call4) => CARDS.has(toolName(call4)) && call4.callId).map((call4) => call4.callId)); if (drawn.length === 0) return null; - const env = { sessionId, roster, actions, useUi, useWork, findBot }; + const env = { sessionId, roster, actions, useUi, findBot }; return (0, import_react8.createElement)(import_react8.Fragment, null, drawn.map((call4, index) => (0, import_react8.createElement)(import_react8.Fragment, { key: call4.callId ?? index }, renderCall(call4, env)))); } @@ -14310,12 +14301,16 @@ function migrateStorage(storage = globalThis.localStorage) { var import_react35 = require("react"); var NO_WORK = { bots: {}, rooms: {}, approvals: [] }; var ROWS_MAX = 4; +var stepIn = (work, botId, roomId, tool) => { + const own = work.bots?.[botId]; + return own?.roomId === roomId && own.tool === tool ? own.step : void 0; +}; function memberWork(room2, work = NO_WORK) { const here = work.rooms?.[room2.id] ?? []; return room2.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: room2.id } : work.bots?.[id]; + const doing = local ? { ...local, roomId: room2.id, step: stepIn(work, id, room2.id, local.tool) } : work.bots?.[id]; return { id, approvals, doing, here: local !== void 0, state: approvals.length > 0 ? "ask" : doing ? "work" : "idle" }; }); } @@ -14324,15 +14319,15 @@ function busyMembers(room2, work, roster) { return [...busy.filter((entry) => entry.state === "ask"), ...busy.filter((entry) => entry.state === "work")].map((entry) => ({ ...entry, bot: roster.byId[entry.id] })); } 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)) }; } function botGroupWork(botId, work, roster) { return Object.entries(work.rooms ?? {}).flatMap(([roomId, entries2]) => { const entry = entries2.find((item) => item.botId === botId); const room2 = roster.roomsById[roomId]; - return entry && room2 ? [{ room: room2, tool: entry.tool, since: entry.since }] : []; + return entry && room2 ? [{ room: room2, tool: entry.tool, step: stepIn(work, botId, roomId, entry.tool), since: entry.since }] : []; }); } function summaryText(busy) { @@ -14341,7 +14336,7 @@ function summaryText(busy) { const names = asks.slice(0, 2).map((entry) => entry.bot.name); const waiting = asks.length === 0 ? "" : asks.length === 1 ? t("{name} is waiting for your approval", { name: names[0] }) : 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 }; } function Face({ entry, size }) { @@ -14351,9 +14346,10 @@ function Face({ entry, size }) { (0, import_react35.createElement)(BotMark, { bot: entry.bot, size, state: entry.state === "ask" ? "alert" : toolState(entry.doing?.tool ?? "") }) ); } -function WorkRow({ bot, tool, label, at, now, onOpen }) { - const text = `${label} · ${workPhrase(tool)}…`; - const elapsed = typeof at === "number" && now - at >= 6e4 ? elapsedLabel(now - at) : ""; +var elapsedSince = (now, at) => typeof at === "number" && now - at >= 6e4 ? elapsedLabel(now - at) : ""; +function WorkRow({ bot, tool, step, label, at, now, onOpen }) { + const text = `${label} · ${workText(tool, step)}`; + const elapsed = elapsedSince(now, at); return (0, import_react35.createElement)( "button", { type: "button", className: "bt-mw-row", "aria-label": [text, elapsed].filter(Boolean).join(" · "), onClick: onOpen }, @@ -14362,19 +14358,21 @@ function WorkRow({ bot, tool, label, at, now, onOpen }) { elapsed ? (0, import_react35.createElement)("span", { className: "bt-mw-row-time" }, `· ${elapsed}`) : null ); } -function SummaryLine({ busy, onOpen }) { +function SummaryLine({ busy, now, onOpen }) { const { waiting, doing } = summaryText(busy); + const elapsed = doing ? elapsedSince(now, busy.find((entry) => entry.state === "work")?.doing.since) : ""; return (0, import_react35.createElement)( - "div", - { className: "bt-event bt-mw-dock" }, + "button", + { type: "button", className: "bt-mw-row bt-mw-line", "aria-label": [waiting, doing, elapsed].filter(Boolean).join(" · "), onClick: onOpen }, + (0, import_react35.createElement)("span", { className: "bt-mw-stack", "aria-hidden": true }, busy.slice(0, 4).map((entry) => (0, import_react35.createElement)(Face, { key: entry.id, entry, size: 16 }))), (0, import_react35.createElement)( - "button", - { type: "button", className: "bt-event-whole bt-mw-line", "aria-label": [waiting, doing].filter(Boolean).join(" · "), onClick: onOpen }, - (0, import_react35.createElement)("span", { className: "bt-mw-stack", "aria-hidden": true }, busy.slice(0, 4).map((entry) => (0, import_react35.createElement)(Face, { key: entry.id, entry, size: 16 }))), + "span", + { className: "bt-mw-row-text" }, waiting ? (0, import_react35.createElement)("span", { className: "bt-mw-ask" }, waiting) : null, - waiting && doing ? (0, import_react35.createElement)("span", { "aria-hidden": true }, "·") : null, - doing ? (0, import_react35.createElement)("span", { className: "bt-event-name" }, (0, import_react35.createElement)(Shimmer, null, doing)) : null - ) + waiting && doing ? " · " : null, + doing ? (0, import_react35.createElement)(Shimmer, null, doing) : null + ), + elapsed ? (0, import_react35.createElement)("span", { className: "bt-mw-row-time" }, `· ${elapsed}`) : null ); } function MemberDock({ sessionId, useRoster, useWork, actions }) { @@ -14396,14 +14394,15 @@ function MemberDock({ sessionId, useRoster, useWork, actions }) { return (0, import_react35.createElement)( "div", { ref, className: "bt-mw-dock-stack" }, - rows.map((entry) => (0, import_react35.createElement)(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 ? (0, import_react35.createElement)(SummaryLine, { busy: rest, onOpen: open }) : null + rows.map((entry) => (0, import_react35.createElement)(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 ? (0, import_react35.createElement)(SummaryLine, { busy: rest, now, onOpen: open }) : null ); } return (0, import_react35.createElement)("div", { ref, className: "bt-mw-dock-stack" }, groups.slice(0, ROWS_MAX).map((item) => (0, import_react35.createElement)(WorkRow, { key: item.room.id, bot, tool: item.tool, + step: item.step, label: t("in {name}", { name: item.room.name }), at: item.since, now, @@ -14484,7 +14483,7 @@ function MemberWork({ entry, bot, room: room2, roster, actions, now }) { (0, import_react35.createElement)( "div", { className: "bt-mw-state bt-mw-work" }, - (0, import_react35.createElement)("span", null, (0, import_react35.createElement)(Shimmer, null, `${workPhrase(entry.doing.tool)}…`), where ? ` ${where}` : "", since(now, entry.doing.since)), + (0, import_react35.createElement)("span", null, (0, import_react35.createElement)(Shimmer, null, workText(entry.doing.tool, entry.doing.step)), where ? ` ${where}` : "", since(now, entry.doing.since)), entry.here ? (0, import_react35.createElement)("button", { type: "button", className: "bt-mw-stop", disabled: stopping, onClick: stop }, t("Stop")) : null ) ); @@ -14497,7 +14496,6 @@ function useMemberWork(room2, useWork) { } var 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} @@ -14507,9 +14505,7 @@ var 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%} @@ -14528,7 +14524,7 @@ 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}} `; // src/client/details-drawer.js diff --git a/core/src/client/activity-row.js b/core/src/client/activity-row.js index 7c6685367..481b46fc4 100644 --- a/core/src/client/activity-row.js +++ b/core/src/client/activity-row.js @@ -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. diff --git a/core/src/client/cells.js b/core/src/client/cells.js index 98c1c6e94..3ab871a35 100644 --- a/core/src/client/cells.js +++ b/core/src/client/cells.js @@ -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. @@ -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 @@ -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() @@ -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) => { @@ -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 }))) })) } @@ -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) @@ -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': @@ -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 @@ -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)))) } diff --git a/core/src/client/i18n.js b/core/src/client/i18n.js index cb616284f..c08a85e74 100644 --- a/core/src/client/i18n.js +++ b/core/src/client/i18n.js @@ -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': '处理中', @@ -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} 开始做了', diff --git a/core/src/client/member-status.js b/core/src/client/member-status.js index d7eb2a862..81cbef8d3 100644 --- a/core/src/client/member-status.js +++ b/core/src/client/member-status.js @@ -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' @@ -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' } }) } @@ -31,12 +38,12 @@ 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. @@ -44,7 +51,7 @@ 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 }] : [] }) } @@ -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 } } @@ -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 @@ -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), }))) } @@ -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)) } @@ -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} @@ -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%} @@ -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}} ` diff --git a/core/src/client/styles.js b/core/src/client/styles.js index 03bf216c8..2a805efd1 100644 --- a/core/src/client/styles.js +++ b/core/src/client/styles.js @@ -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 diff --git a/core/src/host/turns.js b/core/src/host/turns.js index 50b12d050..1b53358bf 100644 --- a/core/src/host/turns.js +++ b/core/src/host/turns.js @@ -211,7 +211,19 @@ export function install(rt) { return merged } - // Per Bot, the turn it is running and its current tool; its own chat wins over a group. + // What the newest running call acts on, in one short line: the command, pattern, query, + // address or path, else its own description. + const stepOf = (entry) => { + let args + try { args = JSON.parse([...(entry.calls?.values() ?? [])].at(-1)?.args ?? '{}') } catch { args = {} } + const value = ['command', 'cmd', 'pattern', 'query', 'url', 'file_path', 'path', 'description'] + .map(key => args?.[key]).find(item => typeof item === 'string' && item.trim() !== '') + const line = value?.replace(/\s+/g, ' ').trim() + return line && line.length > 80 ? `${line.slice(0, 79)}…` : line + } + + // Per Bot, the turn it is running, its current tool and that tool's step; its own chat + // wins over a group. Only the owner reads this view (authz.js), so the step stays theirs. const workView = () => { const bots = {} for (const [id, entry] of live) { @@ -219,7 +231,8 @@ export function install(rt) { const owner = rt.groupOwners.get(id) const botId = owner?.botId ?? rt.chatOwners.get(id) if (botId === undefined || (owner !== undefined && bots[botId] !== undefined)) continue - bots[botId] = { since: entry.since, ...(entry.tool ? { tool: entry.tool } : {}), ...(owner ? { roomId: owner.roomId } : {}) } + const step = entry.tool ? stepOf(entry) : undefined + bots[botId] = { since: entry.since, ...(entry.tool ? { tool: entry.tool } : {}), ...(step ? { step } : {}), ...(owner ? { roomId: owner.roomId } : {}) } } return bots } diff --git a/core/test/group-status.test.js b/core/test/group-status.test.js index 03b2a582b..8c5200dc8 100644 --- a/core/test/group-status.test.js +++ b/core/test/group-status.test.js @@ -294,3 +294,20 @@ test('an approval asked inside a PTC program shows what that call does', async ( assert.deepEqual(approval.detail, { text: 'Copy the doc', code: 'Copy-Item a.md E:/docs' }) assert.equal(value.work.bots.b.tool, 'pwsh') }) + +test('a running Bot\'s work names its current step, in one short line, until the turn ends', async (t) => { + const host = await start() + t.after(host.stop) + await openTurn(t, host) + const bots = async () => (await host.call('state', {})).value.work.bots + host.emit('gb', 'tool/call', { callId: 'k1', name: 'pwsh', arguments: JSON.stringify({ command: 'rg -n "TODO"\n src/client --glob *.js', description: 'Find the TODOs' }) }) + assert.deepEqual([(await bots()).b.tool, (await bots()).b.step], ['pwsh', 'rg -n "TODO" src/client --glob *.js']) + host.emit('gb', 'tool/result', { callId: 'k1' }) + host.emit('gb', 'tool/call', { callId: 'k2', name: 'web_fetch', arguments: JSON.stringify({ url: `https://example.com/${'a'.repeat(200)}` }) }) + const { step } = (await bots()).b + assert.ok(step.startsWith('https://example.com/a') && step.endsWith('…') && step.length <= 80, step) + host.emit('gb', 'tool/result', { callId: 'k2' }) + assert.equal((await bots()).b.step, undefined) + host.emit('gb', 'turn/end', { reason: { kind: 'error', error: new Error('boom') } }) + assert.equal((await bots()).b, undefined) +}) diff --git a/core/test/member-status.test.js b/core/test/member-status.test.js index e50afaacb..467dd360f 100644 --- a/core/test/member-status.test.js +++ b/core/test/member-status.test.js @@ -61,7 +61,7 @@ const team = { } const dock = (sessionId, work) => expand(MemberDock({ sessionId, useRoster: pick => pick(team), useWork: pick => pick(work), actions: {} })) -test('a member at work in the group gets a row; the speaker, work elsewhere and approvals do not', () => { +test('a member at work in the group gets a row, the speaker first; work elsewhere and approvals do not', () => { const work = { // Anvil runs in its own chat too; its work here wins. bots: { a: { since: 1, tool: 'pwsh' }, b: { since: 1, roomId: 'r2', tool: 'edit' } }, @@ -70,9 +70,9 @@ test('a member at work in the group gets a row; the speaker, work elsewhere and } const busy = busyMembers(team.roomsById.r1, work, team) const { rows, rest } = splitBusy(busy) - assert.deepEqual(plain(rows.map(entry => [entry.bot.name, entry.doing.tool, entry.doing.roomId])), [['Anvil', 'write', 'r1']]) + assert.deepEqual(plain(rows.map(entry => [entry.bot.name, entry.doing.tool, entry.doing.roomId])), [['Quill', null, 'r1'], ['Anvil', 'write', 'r1']]) assert.deepEqual(plain(rest.map(entry => [entry.bot.name, entry.state])), [['Prism', 'ask'], ['Flint', 'work']]) - assert.deepEqual(rowLabels(dock('r1', work)), ['Anvil · Writing changes…']) + assert.deepEqual(rowLabels(dock('r1', work)), ['Quill · Thinking about it…', 'Anvil · Writing changes…']) assert.equal(busyMembers(team.roomsById.r2, work, team).length, 2) }) @@ -82,3 +82,32 @@ test('a Bot\'s own chat shows the groups it is at work in, unless its own chat r assert.deepEqual(rowLabels(dock('a', { ...away, bots: { a: { since: 1 } } })), []) assert.equal(dock('b', away), null) }) + +// Owner report 2026-10-20: one member's row sat left with its time, another's centered or +// right with neither, and both said only "On it…" or "Running shell commands…". +const nodes = (node, match) => { + if (Array.isArray(node)) return node.flatMap(child => nodes(child, match)) + if (node === null || typeof node !== 'object') return [] + return [...(match(node) ? [node] : []), ...nodes(node.children, match)] +} +const textOf = node => (Array.isArray(node) ? node.map(textOf).join('') : node === null || typeof node !== 'object' ? String(node ?? '') : textOf(node.children)) + +test('every member at work in the group, the speaker too, gets the same left row with its step and time', () => { + const now = Date.now() + const work = { + bots: { a: { since: now - 11 * 60000, roomId: 'r1', tool: 'pwsh', step: 'npm test' }, b: { since: now - 3 * 60000, tool: 'mcp_custom_tool' } }, + rooms: { r1: [{ botId: 'a', since: now - 11 * 60000, tool: 'pwsh', speaking: true }, { botId: 'd', since: now - 2 * 60000, tool: 'grep' }] }, + approvals: [], + } + // The dock's clock is a stub here, so the rows draw at a real now. + const at = node => (Array.isArray(node) ? node.map(at) : node === null || typeof node !== 'object' ? node + : { ...node, props: 'now' in node.props ? { ...node.props, now } : node.props, children: node.children.map(at) }) + const tree = expand(at(MemberDock({ sessionId: 'r1', useRoster: pick => pick(team), useWork: pick => pick(work), actions: {} }))) + assert.deepEqual(rowLabels(tree), ['Anvil · Running shell commands: npm test · 11m', 'Quill · Looking through files… · 2m']) + // Work elsewhere is one line in the same column, never a centered event line. + const [line] = nodes(tree, node => String(node.props.className ?? '').includes('bt-mw-line')) + assert.equal(line.type, 'button') + assert.equal(nodes(tree, node => String(node.props.className ?? '').split(' ').includes('bt-event')).length, 0) + assert.equal(line.props['aria-label'], 'Flint · Using mcp_custom_tool… · 3m') + assert.match(textOf(line), /Flint · Using mcp_custom_tool…· 3m/) +}) diff --git a/core/test/tool-cells.test.js b/core/test/tool-cells.test.js index 4d2680a3a..43ca19fd2 100644 --- a/core/test/tool-cells.test.js +++ b/core/test/tool-cells.test.js @@ -329,7 +329,7 @@ test('a PTC group relay shows its replies from the sub-call, live and after relo stop() timers.clear() assert.deepEqual(asked, ['room']) - assert.deepEqual(JSON.parse(JSON.stringify(sets)), [{ replies: replies.slice(0, 1), speaking: 'chief' }]) + assert.deepEqual(JSON.parse(JSON.stringify(sets)), [{ replies: replies.slice(0, 1) }]) }) // #105: the secret and question cards of a PTC program showed nowhere. diff --git a/docs/subsystems/core-client.md b/docs/subsystems/core-client.md index 77d759f64..61261c74b 100644 --- a/docs/subsystems/core-client.md +++ b/docs/subsystems/core-client.md @@ -155,6 +155,10 @@ The overlay (`flybot-slots.js` `FlyBotApprovals`) is the fallback, not a second - The composer `@` in a face-to-face room (`mentionRows` / `atSources` in `at-menu.js`) offers the room's own Bots, each once (a repeated remote seat is folded by handle; Bots that left are out), labelled as the Members panel names them (`Chief (SelfA)`, `Chief (SelfB)`), plus **Everyone** with the room's Bot count (`2 Bots`, #457). Published friends' Bots (`remoteMentionRows`) and plugin rows (the social `Friends' Bots` section) are not offered there, since only the Bots in the room can be reached in it. Only the label changes: the inserted text is still the `@Name` the room routes on (`@SelfA's Chief`), never a handle (#305). - A face room's `@` menu shows no DSH Sessions rows (#305, owner decision 2026-10-19: filter in FlyBot for now; #422 asks DSH for an API). DSH's built-in `reference` source cannot be dropped for one Session, so `HeaderPill` (`header.js`) starts `actions.filterFaceAtMenu` for a face room, which watches that Session's trigger controller (`inputTriggers.sessionOf`, the same one the composer uses) with `watchFaceMenu` (`face-at-filter.js`): a `