From 72ef0d90b36ac193d1278d4e2c96753c54b18b12 Mon Sep 17 00:00:00 2001 From: Lu <42706009+luinbytes@users.noreply.github.com> Date: Thu, 27 Aug 2026 14:21:48 +0100 Subject: [PATCH] feat(web): show bot activity in transcript --- .../beautiful-ui/CollaborationMarker.test.tsx | 34 ++++++++ .../beautiful-ui/CollaborationMarker.tsx | 37 +++++++++ apps/web/src/pages/Shell.tsx | 81 ++++++++++--------- 3 files changed, 116 insertions(+), 36 deletions(-) create mode 100644 apps/web/src/components/beautiful-ui/CollaborationMarker.test.tsx create mode 100644 apps/web/src/components/beautiful-ui/CollaborationMarker.tsx diff --git a/apps/web/src/components/beautiful-ui/CollaborationMarker.test.tsx b/apps/web/src/components/beautiful-ui/CollaborationMarker.test.tsx new file mode 100644 index 000000000..74b3160a5 --- /dev/null +++ b/apps/web/src/components/beautiful-ui/CollaborationMarker.test.tsx @@ -0,0 +1,34 @@ +import { renderToString } from "react-dom/server"; +import { describe, expect, it } from "vitest"; +import { ActiveBotGlyph, CollaborationMarker } from "./CollaborationMarker"; + +describe("collaboration transcript markers", () => { + it("shows the peer avatar in an accessible message marker", () => { + const html = renderToString( + undefined} + />, + ); + + expect(html).toContain('aria-label="Message from Research"'); + expect(html).toContain("rakazo-bot-avatar"); + expect(html).toContain("Research"); + }); + + it("animates the active bot glyph from its run status", () => { + const html = renderToString( + , + ); + + expect(html).toContain('role="status"'); + expect(html).toContain('data-working="true"'); + expect(html).toContain("rakazo-bot-avatar-ring"); + }); +}); diff --git a/apps/web/src/components/beautiful-ui/CollaborationMarker.tsx b/apps/web/src/components/beautiful-ui/CollaborationMarker.tsx new file mode 100644 index 000000000..66b2fdc8e --- /dev/null +++ b/apps/web/src/components/beautiful-ui/CollaborationMarker.tsx @@ -0,0 +1,37 @@ +import { BotAvatar, GroupAvatar, type GroupAvatarMember } from "@rakazo/ui-web"; +import type { ReactNode } from "react"; + +export function CollaborationMarker({ + action, + ariaLabel, + color, + name, + onClick, +}: { + action: ReactNode; + ariaLabel: string; + color: string; + name: string; + onClick: () => void; +}) { + return ( + + ); +} + +export function ActiveBotGlyph({ bots, label }: { bots: GroupAvatarMember[]; label: string }) { + return ( +
+ +
+ ); +} diff --git a/apps/web/src/pages/Shell.tsx b/apps/web/src/pages/Shell.tsx index 776317673..7756fe8e4 100644 --- a/apps/web/src/pages/Shell.tsx +++ b/apps/web/src/pages/Shell.tsx @@ -58,7 +58,7 @@ import { speechFromBlocks, truncateSlashDescription, } from "@rakazo/core"; -import { BotAvatar, Button, GroupAvatar } from "@rakazo/ui-web"; +import { BotAvatar, Button, GroupAvatar, type GroupAvatarMember } from "@rakazo/ui-web"; import { ArrowUp, Bell, @@ -99,11 +99,10 @@ import { useNavigate, useParams, useSearchParams } from "react-router-dom"; import { ArtifactFileCard } from "../components/ArtifactFileCard"; import { AskCard } from "../components/AskCard"; import { - BuiButton, - BuiCard, - LoadingState, - SuccessPop, -} from "../components/beautiful-ui/primitives"; + ActiveBotGlyph, + CollaborationMarker, +} from "../components/beautiful-ui/CollaborationMarker"; +import { BuiButton, BuiCard, SuccessPop } from "../components/beautiful-ui/primitives"; import { ComputerMaintenanceActions } from "../components/ComputerMaintenanceActions"; import { SkillDraftCard } from "../components/teach/SkillDraftCard"; import { TeachCaptureOverlay } from "../components/teach/TeachCaptureOverlay"; @@ -1088,24 +1087,29 @@ export function ShellPage() { ["running", "queued", "leased"].includes(run.status), ); const transcriptRunning = workingRuns.length > 0; - const workingStartedAtMs = (() => { - let earliest: number | undefined; - for (const run of workingRuns) { - // Prefer startedAt; fall back to createdAt so queued/leased runs keep a - // stable clock across remounts before the executor sets startedAt. - const iso = run.startedAt ?? run.createdAt; - const ms = Date.parse(iso); - if (Number.isNaN(ms)) continue; - if (earliest === undefined || ms < earliest) earliest = ms; - } - return earliest; - })(); const composerRunning = currentRuns.some((run) => isActive(run.status)); const transcriptArtifactTarget = useMemo( () => (inGroup ? { groupId: groupId ?? "" } : { botId: active?.id ?? "" }), [active?.id, groupId, inGroup], ); const transcriptMembers = activeSnapshot?.members ?? activeGroup?.members; + const resolveTranscriptBot = useCallback( + (botId: string) => { + const bot = bots.find((candidate) => candidate.id === botId); + if (bot) return bot; + return transcriptMembers?.find((member) => member.botId === botId); + }, + [bots, transcriptMembers], + ); + const workingBots: GroupAvatarMember[] = workingRuns.map((run) => { + const bot = resolveTranscriptBot(run.botId); + return { + botId: run.botId, + color: bot?.color ?? "#85858A", + name: bot?.name, + status: run.status, + }; + }); const resolveTranscriptMemberName = useCallback( (botId: string | undefined) => memberName(transcriptMembers, botId), [transcriptMembers], @@ -2170,7 +2174,7 @@ export function ShellPage() { loadingOlder={loadingOlder} answerableAskMessageId={answerableAskMessageId} running={transcriptRunning} - workingStartedAt={workingStartedAtMs} + workingBots={workingBots} onLoadOlder={loadOlder} onOpenBot={openBot} onAnswer={answerMessage} @@ -2181,6 +2185,7 @@ export function ShellPage() { setPeerMessagesOpen(true); }} memberName={resolveTranscriptMemberName} + peerBot={resolveTranscriptBot} onRefresh={refreshActiveThread} onBotChanged={refreshBots} onAddRoutine={addSkillRoutine} @@ -3013,7 +3018,7 @@ const Transcript = memo(function Transcript({ loadingOlder, answerableAskMessageId, running, - workingStartedAt, + workingBots, onLoadOlder, onOpenBot, onAnswer, @@ -3021,6 +3026,7 @@ const Transcript = memo(function Transcript({ onJumpToMessage, onOpenPeerMessages, memberName, + peerBot, onRefresh, onBotChanged, onAddRoutine, @@ -3035,7 +3041,7 @@ const Transcript = memo(function Transcript({ loadingOlder: boolean; answerableAskMessageId: string | null; running: boolean; - workingStartedAt?: number; + workingBots: GroupAvatarMember[]; onLoadOlder: () => void | Promise; onOpenBot: (botId: string) => void; onAnswer: (message: ThreadMessage, text: string) => Promise; @@ -3043,6 +3049,7 @@ const Transcript = memo(function Transcript({ onJumpToMessage: (messageId: string) => void; onOpenPeerMessages: (peerBotId: string) => void; memberName?: (botId: string | undefined) => string | undefined; + peerBot: (botId: string) => { color: string; status?: string } | undefined; onRefresh: () => Promise; onBotChanged: () => Promise; onAddRoutine: (name: string, prompt: string) => void; @@ -3087,6 +3094,7 @@ const Transcript = memo(function Transcript({ onAnswer={onAnswer} speakerName={message.role === "bot" ? memberName?.(message.botId) : undefined} memberName={memberName} + peerBot={peerBot} replyPreview={ message.replyToMessageId ? messageById.get(message.replyToMessageId) : undefined } @@ -3108,13 +3116,14 @@ const Transcript = memo(function Transcript({ message.blocks[0]?.kind === "progress" && message.blocks[0].text, ) ? ( -
- {/* Box metrics match the progress bubble exactly so swapping between - them never changes height or text position. */} -
- -
-
+ ) : null} ); @@ -3796,6 +3805,7 @@ const MessageView = memo(function MessageView({ onOpenPeerMessages, speakerName, memberName, + peerBot, replyPreview, replyToMessageId, onJumpToMessage, @@ -3814,6 +3824,7 @@ const MessageView = memo(function MessageView({ onOpenPeerMessages: (peerBotId: string) => void; speakerName?: string; memberName?: (botId: string | undefined) => string | undefined; + peerBot: (botId: string) => { color: string; status?: string } | undefined; replyPreview?: ThreadMessage; replyToMessageId?: string; onJumpToMessage?: (messageId: string) => void; @@ -3924,16 +3935,14 @@ const MessageView = memo(function MessageView({ const peerBotId = sent ? block.toBotId : block.fromBotId; const label = sent ? t`Messaged ${peer}` : t`Message from ${peer}`; return ( - + /> ); } if (block.kind === "meta") {