Skip to content
Closed
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
34 changes: 34 additions & 0 deletions apps/web/src/components/beautiful-ui/CollaborationMarker.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<CollaborationMarker
action="Message from"
ariaLabel="Message from Research"
color="#14B8A6"
name="Research"
onClick={() => 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(
<ActiveBotGlyph
bots={[{ botId: "research", color: "#14B8A6", status: "running" }]}
label="Research is working"
/>,
);

expect(html).toContain('role="status"');
expect(html).toContain('data-working="true"');
expect(html).toContain("rakazo-bot-avatar-ring");
});
});
37 changes: 37 additions & 0 deletions apps/web/src/components/beautiful-ui/CollaborationMarker.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<button
type="button"
aria-label={ariaLabel}
onClick={onClick}
className="flex items-center justify-center gap-1.5 self-center rounded-full px-2.5 py-1 text-[13px] text-[#85858A] transition-colors hover:bg-[#161618] hover:text-[#B8B8BD]"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Marker centering is ineffective

When a bot-to-bot marker is rendered, self-center has no effect because the button's direct message wrapper is neither flex nor grid, causing the marker to remain at the transcript's inline start instead of appearing centered.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

>
<span>{action}</span>
<BotAvatar color={color} size={16} />
<span dir="auto">{name}</span>
</button>
);
}

export function ActiveBotGlyph({ bots, label }: { bots: GroupAvatarMember[]; label: string }) {
return (
<div role="status" aria-label={label} className="flex min-h-10 items-center px-1">
<GroupAvatar members={bots} size={28} />
</div>
);
}
81 changes: 45 additions & 36 deletions apps/web/src/pages/Shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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<ArtifactTarget>(
() => (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],
Expand Down Expand Up @@ -2170,7 +2174,7 @@ export function ShellPage() {
loadingOlder={loadingOlder}
answerableAskMessageId={answerableAskMessageId}
running={transcriptRunning}
workingStartedAt={workingStartedAtMs}
workingBots={workingBots}
onLoadOlder={loadOlder}
onOpenBot={openBot}
onAnswer={answerMessage}
Expand All @@ -2181,6 +2185,7 @@ export function ShellPage() {
setPeerMessagesOpen(true);
}}
memberName={resolveTranscriptMemberName}
peerBot={resolveTranscriptBot}
onRefresh={refreshActiveThread}
onBotChanged={refreshBots}
onAddRoutine={addSkillRoutine}
Expand Down Expand Up @@ -3013,14 +3018,15 @@ const Transcript = memo(function Transcript({
loadingOlder,
answerableAskMessageId,
running,
workingStartedAt,
workingBots,
onLoadOlder,
onOpenBot,
onAnswer,
onReply,
onJumpToMessage,
onOpenPeerMessages,
memberName,
peerBot,
onRefresh,
onBotChanged,
onAddRoutine,
Expand All @@ -3035,14 +3041,15 @@ const Transcript = memo(function Transcript({
loadingOlder: boolean;
answerableAskMessageId: string | null;
running: boolean;
workingStartedAt?: number;
workingBots: GroupAvatarMember[];
onLoadOlder: () => void | Promise<void>;
onOpenBot: (botId: string) => void;
onAnswer: (message: ThreadMessage, text: string) => Promise<void>;
onReply: (message: ThreadMessage) => void;
onJumpToMessage: (messageId: string) => void;
onOpenPeerMessages: (peerBotId: string) => void;
memberName?: (botId: string | undefined) => string | undefined;
peerBot: (botId: string) => { color: string; status?: string } | undefined;
onRefresh: () => Promise<void>;
onBotChanged: () => Promise<void>;
onAddRoutine: (name: string, prompt: string) => void;
Expand Down Expand Up @@ -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
}
Expand All @@ -3108,13 +3116,14 @@ const Transcript = memo(function Transcript({
message.blocks[0]?.kind === "progress" &&
message.blocks[0].text,
) ? (
<div className="flex justify-start">
{/* Box metrics match the progress bubble exactly so swapping between
them never changes height or text position. */}
<div className="flex max-w-[74%] items-center rounded-[20px] bg-[#1A1A1D] px-[18px] py-3 text-[15.5px] leading-[1.5]">
<LoadingState label="working" startedAt={workingStartedAt} />
</div>
</div>
<ActiveBotGlyph
bots={workingBots}
label={
workingBots.length === 1 && workingBots[0]?.name
? t`${workingBots[0].name} is working`
: t`Bots are working`
}
/>
) : null}
</div>
);
Expand Down Expand Up @@ -3796,6 +3805,7 @@ const MessageView = memo(function MessageView({
onOpenPeerMessages,
speakerName,
memberName,
peerBot,
replyPreview,
replyToMessageId,
onJumpToMessage,
Expand All @@ -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;
Expand Down Expand Up @@ -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 (
<button
<CollaborationMarker
key={i}
type="button"
aria-label={label}
action={sent ? <Trans>Messaged</Trans> : <Trans>Message from</Trans>}
ariaLabel={label}
color={peerBot(peerBotId)?.color ?? "#85858A"}
name={peer}
onClick={() => onOpenPeerMessages(peerBotId)}
className="flex items-center justify-center gap-2 self-center rounded-full border border-[#26262A] px-3 py-1 text-[13px] text-[#85858A] hover:bg-[#161618]"
>
<span aria-hidden>↔</span>
<span>{label}</span>
</button>
/>
);
}
if (block.kind === "meta") {
Expand Down
Loading