mirror of
https://github.com/block/buzz.git
synced 2026-10-31 22:41:12 +00:00
feat(desktop): enrich workstream board cards
Signed-off-by: loganj <loganj@squareup.com>
This commit is contained in:
@@ -1,9 +1,6 @@
|
||||
import * as React from "react";
|
||||
|
||||
import type {
|
||||
TimelineThreadSummary,
|
||||
TimelineThreadSummaryParticipant,
|
||||
} from "@/features/messages/lib/threadPanel";
|
||||
import type { TimelineThreadSummary } from "@/features/messages/lib/threadPanel";
|
||||
import type { TimelineMessage } from "@/features/messages/types";
|
||||
import type { ThreadDepthGuideAction } from "@/features/messages/ui/MessageRow";
|
||||
import { formatThreadSummaryLastReplyTime } from "@/features/messages/lib/dateFormatters";
|
||||
@@ -16,44 +13,12 @@ import {
|
||||
THREAD_REPLY_ROW_MARGIN_INLINE_REM,
|
||||
} from "@/features/messages/lib/threadTreeLayout";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { UserAvatar } from "@/shared/ui/UserAvatar";
|
||||
import { AvatarStack } from "@/shared/ui/AvatarStack";
|
||||
|
||||
const THREAD_SUMMARY_CONTENT_OFFSET_REM =
|
||||
THREAD_REPLY_BODY_OFFSET_REM - THREAD_REPLY_ROW_MARGIN_INLINE_REM;
|
||||
const THREAD_SUMMARY_SURFACE_AVATAR_INSET_REM = 0.25;
|
||||
|
||||
function ParticipantAvatar({
|
||||
participant,
|
||||
index,
|
||||
participantCount,
|
||||
}: {
|
||||
participant: TimelineThreadSummaryParticipant;
|
||||
index: number;
|
||||
participantCount: number;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={index > 0 ? "-ml-1" : ""}
|
||||
data-testid="message-thread-summary-participant"
|
||||
style={{
|
||||
zIndex: index + 1,
|
||||
...(index < participantCount - 1 && {
|
||||
mask: "radial-gradient(circle 14px at calc(100% + 6px) 50%, transparent 99%, #fff 100%)",
|
||||
WebkitMask:
|
||||
"radial-gradient(circle 14px at calc(100% + 6px) 50%, transparent 99%, #fff 100%)",
|
||||
}),
|
||||
}}
|
||||
>
|
||||
<UserAvatar
|
||||
avatarUrl={participant.avatarUrl}
|
||||
className="h-6 w-6 text-2xs"
|
||||
displayName={participant.author}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MessageThreadSummaryRow({
|
||||
collapseDepthGuideActions,
|
||||
depth = 0,
|
||||
@@ -231,14 +196,14 @@ export function MessageThreadSummaryRow({
|
||||
}}
|
||||
/>
|
||||
<div className="relative z-10 flex shrink-0 items-center">
|
||||
{summary.participants.map((participant, index) => (
|
||||
<ParticipantAvatar
|
||||
index={index}
|
||||
key={participant.id}
|
||||
participant={participant}
|
||||
participantCount={summary.participants.length}
|
||||
/>
|
||||
))}
|
||||
<AvatarStack
|
||||
items={summary.participants.map((participant) => ({
|
||||
id: participant.id,
|
||||
avatarUrl: participant.avatarUrl,
|
||||
displayName: participant.author,
|
||||
}))}
|
||||
testId="message-thread-summary-participant"
|
||||
/>
|
||||
</div>
|
||||
<div className="relative z-10 min-w-0">
|
||||
<div>
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { classifyWorkstreamAgents } from "./workstreamAgentStatuses.ts";
|
||||
|
||||
const A = "a".repeat(64);
|
||||
const B = "b".repeat(64);
|
||||
const C = "c".repeat(64);
|
||||
const assignees = [
|
||||
{ pubkey: A, name: "Active" },
|
||||
{ pubkey: B, name: "Blocked" },
|
||||
{ pubkey: C, name: "Unknown" },
|
||||
];
|
||||
|
||||
test("classifies only evidence-backed working and waiting states", () => {
|
||||
assert.deepEqual(
|
||||
classifyWorkstreamAgents(
|
||||
assignees,
|
||||
{ channelId: "channel", agentCount: 1, agentPubkeys: [A], anchorAt: 1 },
|
||||
[
|
||||
{
|
||||
key: "blocked",
|
||||
actor: { kind: "agent", pubkey: B.toUpperCase(), name: "Blocked" },
|
||||
reason: "Needs a decision",
|
||||
source: "manual",
|
||||
},
|
||||
],
|
||||
),
|
||||
{ working: [assignees[0]], waiting: [assignees[1]] },
|
||||
);
|
||||
});
|
||||
|
||||
test("working wins when the same assigned agent also has a blocker", () => {
|
||||
const result = classifyWorkstreamAgents(
|
||||
assignees,
|
||||
{ channelId: "channel", agentCount: 1, agentPubkeys: [B], anchorAt: 1 },
|
||||
[
|
||||
{
|
||||
key: "blocked",
|
||||
actor: { kind: "agent", pubkey: B, name: "Blocked" },
|
||||
reason: "Needs a decision",
|
||||
source: "manual",
|
||||
},
|
||||
],
|
||||
);
|
||||
assert.deepEqual(result.working, [assignees[1]]);
|
||||
assert.deepEqual(result.waiting, []);
|
||||
});
|
||||
|
||||
test("does not assert idle or waiting for non-active assignees without named blockers", () => {
|
||||
assert.deepEqual(classifyWorkstreamAgents(assignees, undefined, []), {
|
||||
working: [],
|
||||
waiting: [],
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { ActiveChannelTurnSummary } from "@/features/agents/activeAgentTurnsStore";
|
||||
import type { WorkstreamIdentity } from "@/features/workstream-board/lib/workstreamCardParser";
|
||||
import type { WorkstreamWait } from "@/features/workstream-board/lib/workstreamWaits";
|
||||
import { normalizePubkey } from "@/shared/lib/pubkey";
|
||||
|
||||
export type WorkstreamAgentStatuses = {
|
||||
working: WorkstreamIdentity[];
|
||||
waiting: WorkstreamIdentity[];
|
||||
};
|
||||
|
||||
/** Only projects states backed by current evidence. Non-active assignees remain unclassified. */
|
||||
export function classifyWorkstreamAgents(
|
||||
assignees: readonly WorkstreamIdentity[],
|
||||
activeWorking: ActiveChannelTurnSummary | undefined,
|
||||
waits: readonly WorkstreamWait[],
|
||||
): WorkstreamAgentStatuses {
|
||||
const active = new Set(
|
||||
activeWorking?.agentPubkeys.map((pubkey) => normalizePubkey(pubkey)) ?? [],
|
||||
);
|
||||
const waiting = new Set(
|
||||
waits.flatMap((wait) =>
|
||||
wait.actor.pubkey ? [normalizePubkey(wait.actor.pubkey)] : [],
|
||||
),
|
||||
);
|
||||
const seen = new Set<string>();
|
||||
const unique = assignees.filter((assignee) => {
|
||||
const pubkey = normalizePubkey(assignee.pubkey);
|
||||
if (seen.has(pubkey)) return false;
|
||||
seen.add(pubkey);
|
||||
return true;
|
||||
});
|
||||
const working = unique.filter((assignee) =>
|
||||
active.has(normalizePubkey(assignee.pubkey)),
|
||||
);
|
||||
const workingKeys = new Set(
|
||||
working.map((assignee) => normalizePubkey(assignee.pubkey)),
|
||||
);
|
||||
return {
|
||||
working,
|
||||
waiting: unique.filter((assignee) => {
|
||||
const pubkey = normalizePubkey(assignee.pubkey);
|
||||
return !workingKeys.has(pubkey) && waiting.has(pubkey);
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -29,6 +29,12 @@ test("manual waits retain simultaneous unique entries and ignore malformed or du
|
||||
reason: "Ignored",
|
||||
},
|
||||
{ key: "bad", actor: { kind: "person", name: "" }, reason: "Invalid" },
|
||||
{
|
||||
key: "bad-since",
|
||||
actor: { kind: "person", name: "Logan" },
|
||||
reason: "Invalid timestamp",
|
||||
since: "not-a-date",
|
||||
},
|
||||
]).map((wait) => wait.key),
|
||||
["log", "review"],
|
||||
);
|
||||
|
||||
@@ -55,6 +55,11 @@ export function isWorkstreamWait(
|
||||
)
|
||||
return false;
|
||||
if (typeof actor.name !== "string" || !actor.name.trim()) return false;
|
||||
if (
|
||||
wait.since !== undefined &&
|
||||
(typeof wait.since !== "string" || !Number.isFinite(Date.parse(wait.since)))
|
||||
)
|
||||
return false;
|
||||
return (
|
||||
actor.pubkey === undefined ||
|
||||
(typeof actor.pubkey === "string" && !!actor.pubkey.trim())
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { UserProfileLookup } from "@/features/profile/lib/identity";
|
||||
import type { WorkstreamIdentity } from "@/features/workstream-board/lib/workstreamCardParser";
|
||||
import type { WorkstreamAgentStatuses } from "@/features/workstream-board/lib/workstreamAgentStatuses";
|
||||
import { normalizePubkey } from "@/shared/lib/pubkey";
|
||||
import { AvatarStack } from "@/shared/ui/AvatarStack";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
|
||||
function StatusPill({
|
||||
agents,
|
||||
label,
|
||||
profiles,
|
||||
status,
|
||||
}: {
|
||||
agents: readonly WorkstreamIdentity[];
|
||||
label: string;
|
||||
profiles?: UserProfileLookup;
|
||||
status: "working" | "waiting";
|
||||
}) {
|
||||
if (agents.length === 0) return null;
|
||||
const items = agents.map((agent) => {
|
||||
const profile = profiles?.[normalizePubkey(agent.pubkey)];
|
||||
return {
|
||||
id: normalizePubkey(agent.pubkey),
|
||||
avatarUrl: profile?.avatarUrl ?? null,
|
||||
displayName: profile?.displayName?.trim() || agent.name,
|
||||
};
|
||||
});
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex h-[1.875rem] items-center rounded-full border bg-background/70 py-0 pl-0.5 pr-2.5 text-xs font-medium",
|
||||
status === "working"
|
||||
? "border-emerald-500/35 text-emerald-700 dark:text-emerald-200"
|
||||
: "border-amber-500/35 text-amber-800 dark:text-amber-200",
|
||||
)}
|
||||
data-testid={`workstream-agent-status-${status}`}
|
||||
title={items.map((item) => item.displayName).join(", ")}
|
||||
>
|
||||
<AvatarStack items={items} limit={4} testId="workstream-agent-avatar" />
|
||||
<span className="ml-1.5 whitespace-nowrap">
|
||||
{agents.length} {label}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function WorkstreamAgentStatusPills({
|
||||
profiles,
|
||||
statuses,
|
||||
}: {
|
||||
profiles?: UserProfileLookup;
|
||||
statuses: WorkstreamAgentStatuses;
|
||||
}) {
|
||||
if (statuses.working.length === 0 && statuses.waiting.length === 0)
|
||||
return null;
|
||||
return (
|
||||
<div
|
||||
className="flex flex-wrap gap-1.5"
|
||||
data-testid="workstream-agent-statuses"
|
||||
>
|
||||
<StatusPill
|
||||
agents={statuses.working}
|
||||
label="working"
|
||||
profiles={profiles}
|
||||
status="working"
|
||||
/>
|
||||
<StatusPill
|
||||
agents={statuses.waiting}
|
||||
label="waiting"
|
||||
profiles={profiles}
|
||||
status="waiting"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -131,6 +131,7 @@ export function WorkstreamBoardScreen() {
|
||||
<WorkstreamCard
|
||||
activeWorking={activeTurnsByChannelId.get(channel.id)}
|
||||
channel={channel}
|
||||
currentOwnerPubkey={identityQuery.data?.pubkey}
|
||||
key={channel.id}
|
||||
onSelect={(channelId) => void goChannel(channelId)}
|
||||
onWaitsChange={(waits, createdAt) =>
|
||||
|
||||
@@ -18,7 +18,9 @@ import {
|
||||
import { buildWorkstreamCardViewModel } from "@/features/workstream-board/lib/workstreamCardViewModel";
|
||||
import { WorkstreamPullRequests } from "@/features/workstream-board/ui/WorkstreamPullRequests";
|
||||
import { WorkstreamWaits } from "@/features/workstream-board/ui/WorkstreamWaits";
|
||||
import { WorkstreamWorkingIndicator } from "@/features/workstream-board/ui/WorkstreamWorkingIndicator";
|
||||
import { WorkstreamAgentStatusPills } from "@/features/workstream-board/ui/WorkstreamAgentStatusPills";
|
||||
import { classifyWorkstreamAgents } from "@/features/workstream-board/lib/workstreamAgentStatuses";
|
||||
import { normalizePubkey } from "@/shared/lib/pubkey";
|
||||
import type { Channel } from "@/shared/api/types";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
|
||||
@@ -26,6 +28,7 @@ type WorkstreamCardProps = {
|
||||
activeWorking?: ActiveChannelTurnSummary;
|
||||
channel: Channel;
|
||||
profiles?: UserProfileLookup;
|
||||
currentOwnerPubkey?: string;
|
||||
onWaitsChange?: (
|
||||
waits: readonly WorkstreamWait[],
|
||||
createdAt: string | null | undefined,
|
||||
@@ -36,6 +39,7 @@ type WorkstreamCardProps = {
|
||||
export function WorkstreamCard({
|
||||
activeWorking,
|
||||
channel,
|
||||
currentOwnerPubkey,
|
||||
onSelect,
|
||||
onWaitsChange,
|
||||
profiles,
|
||||
@@ -65,11 +69,30 @@ export function WorkstreamCard({
|
||||
React.useEffect(() => {
|
||||
onWaitsChange?.(waits, detailsQuery.data?.createdAt);
|
||||
}, [detailsQuery.data?.createdAt, onWaitsChange, waits]);
|
||||
const agentStatuses = React.useMemo(
|
||||
() =>
|
||||
viewModel.status === "ready"
|
||||
? classifyWorkstreamAgents(
|
||||
viewModel.card.assignees,
|
||||
activeWorking,
|
||||
waits,
|
||||
)
|
||||
: { working: [], waiting: [] },
|
||||
[activeWorking, viewModel, waits],
|
||||
);
|
||||
const waitingOnPrincipal = waits.some(
|
||||
(wait) =>
|
||||
wait.actor.pubkey &&
|
||||
currentOwnerPubkey &&
|
||||
normalizePubkey(wait.actor.pubkey) ===
|
||||
normalizePubkey(currentOwnerPubkey),
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"group relative min-h-48 w-full overflow-hidden rounded-2xl border border-border/70 bg-muted/50 p-5 text-left text-foreground shadow-xs transition-all hover:-translate-y-0.5 hover:border-border hover:bg-muted/65 hover:shadow-md",
|
||||
waitingOnPrincipal && "border-t-4 border-t-amber-400/70",
|
||||
)}
|
||||
data-testid={`workstream-card-${channel.id}`}
|
||||
>
|
||||
@@ -80,11 +103,20 @@ export function WorkstreamCard({
|
||||
>
|
||||
<span className="sr-only">Open #{channel.name}</span>
|
||||
</button>
|
||||
{waitingOnPrincipal ? (
|
||||
<span className="pointer-events-none absolute right-4 top-0 z-20 rounded-b-md border border-t-0 border-amber-400/60 bg-muted px-2 py-1 text-3xs font-bold uppercase tracking-wider text-foreground">
|
||||
Priority
|
||||
</span>
|
||||
) : null}
|
||||
<div className="pointer-events-none relative z-10 flex h-full min-h-40 flex-col">
|
||||
<div className="flex items-center gap-1.5 text-xs font-semibold text-muted-foreground">
|
||||
<button
|
||||
className="pointer-events-auto flex max-w-[calc(100%-4rem)] items-center gap-1.5 text-xs font-semibold text-muted-foreground hover:text-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
|
||||
onClick={() => onSelect(channel.id)}
|
||||
type="button"
|
||||
>
|
||||
<Hash className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="truncate">{channel.name}</span>
|
||||
</div>
|
||||
</button>
|
||||
{viewModel.status === "ready" ? (
|
||||
<>
|
||||
<p className="mt-3 line-clamp-3 text-sm leading-relaxed text-foreground">
|
||||
@@ -97,25 +129,21 @@ export function WorkstreamCard({
|
||||
{viewModel.card.orchestrator.name}
|
||||
</span>
|
||||
</p>
|
||||
{viewModel.card.assignees.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{viewModel.card.assignees.map((assignee) => (
|
||||
<span
|
||||
className="rounded-full border border-border/65 bg-background/80 px-2 py-0.5 text-2xs text-muted-foreground"
|
||||
key={assignee.pubkey}
|
||||
>
|
||||
{assignee.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<WorkstreamAgentStatusPills
|
||||
profiles={profiles}
|
||||
statuses={agentStatuses}
|
||||
/>
|
||||
{references.length > 0 ? (
|
||||
<WorkstreamPullRequests
|
||||
references={references}
|
||||
states={pullRequestStates}
|
||||
/>
|
||||
) : null}
|
||||
<WorkstreamWaits waits={waits} />
|
||||
<WorkstreamWaits
|
||||
profiles={profiles}
|
||||
references={references}
|
||||
waits={waits}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : viewModel.status === "loading" ? (
|
||||
@@ -135,12 +163,6 @@ export function WorkstreamCard({
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
{activeWorking ? (
|
||||
<WorkstreamWorkingIndicator
|
||||
activeWorking={activeWorking}
|
||||
profiles={profiles}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,31 +1,146 @@
|
||||
import { ExternalLink } from "lucide-react";
|
||||
import { openUrl } from "@tauri-apps/plugin-opener";
|
||||
|
||||
import type { UserProfileLookup } from "@/features/profile/lib/identity";
|
||||
import type { WorkstreamPullRequestReference } from "@/features/workstream-board/lib/workstreamPullRequestStatus";
|
||||
import type { WorkstreamWait } from "@/features/workstream-board/lib/workstreamWaits";
|
||||
import { parseEntityLink } from "@/shared/lib/entityLink";
|
||||
import { normalizePubkey } from "@/shared/lib/pubkey";
|
||||
import { useNow } from "@/shared/lib/useNow";
|
||||
import { UserAvatar } from "@/shared/ui/UserAvatar";
|
||||
import { useOpenEntityLink } from "@/shared/ui/markdown/entityLinks";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
|
||||
function waitAge(since: string | undefined, now: number): string {
|
||||
const then = since ? Date.parse(since) : Number.NaN;
|
||||
if (!Number.isFinite(then)) return "Now";
|
||||
const minutes = Math.max(0, Math.floor((now - then) / 60_000));
|
||||
if (minutes < 60) return `${minutes}m`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours}h`;
|
||||
return `${Math.floor(hours / 24)}d`;
|
||||
}
|
||||
|
||||
function WaitAvatar({
|
||||
wait,
|
||||
profiles,
|
||||
}: {
|
||||
wait: WorkstreamWait;
|
||||
profiles?: UserProfileLookup;
|
||||
}) {
|
||||
const profile = wait.actor.pubkey
|
||||
? profiles?.[normalizePubkey(wait.actor.pubkey)]
|
||||
: undefined;
|
||||
if (wait.actor.kind !== "team") {
|
||||
return (
|
||||
<UserAvatar
|
||||
avatarUrl={profile?.avatarUrl ?? null}
|
||||
className="size-[1.1875rem] text-3xs"
|
||||
displayName={profile?.displayName?.trim() || wait.actor.name}
|
||||
size="xs"
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="grid size-[1.1875rem] shrink-0 place-items-center rounded-[0.3rem] bg-violet-500/60 text-3xs font-bold text-white">
|
||||
{wait.actor.name.slice(0, 2).toUpperCase()}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function WorkstreamWaits({
|
||||
profiles,
|
||||
references,
|
||||
waits,
|
||||
}: {
|
||||
profiles?: UserProfileLookup;
|
||||
references: readonly WorkstreamPullRequestReference[];
|
||||
waits: readonly WorkstreamWait[];
|
||||
}) {
|
||||
const now = useNow(60_000);
|
||||
const openEntityLink = useOpenEntityLink();
|
||||
if (waits.length === 0) return null;
|
||||
const referencesByIdentity = new Map(
|
||||
references.map((reference) => [reference.identity, reference]),
|
||||
);
|
||||
const orderedWaits = [...waits].sort((left, right) => {
|
||||
const leftAt = left.since
|
||||
? Date.parse(left.since)
|
||||
: Number.POSITIVE_INFINITY;
|
||||
const rightAt = right.since
|
||||
? Date.parse(right.since)
|
||||
: Number.POSITIVE_INFINITY;
|
||||
return leftAt - rightAt;
|
||||
});
|
||||
return (
|
||||
<section
|
||||
aria-label="Waiting on"
|
||||
className="pointer-events-auto mt-4 space-y-1.5"
|
||||
aria-label="Blockers"
|
||||
className="pointer-events-auto mt-1 border-t border-border/60 pt-2"
|
||||
data-testid="workstream-waits"
|
||||
>
|
||||
<p className="text-2xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Waiting on
|
||||
</p>
|
||||
<div className="mb-1.5 flex items-center justify-between gap-2 text-2xs text-muted-foreground">
|
||||
<p className="font-semibold text-amber-800 dark:text-amber-200">
|
||||
{waits.length} {waits.length === 1 ? "blocker" : "blockers"}
|
||||
</p>
|
||||
<span>oldest first</span>
|
||||
</div>
|
||||
<ul className="space-y-1">
|
||||
{waits.map((wait) => (
|
||||
<li
|
||||
className="rounded-md border border-amber-500/25 bg-amber-500/10 px-2 py-1.5 text-2xs text-foreground"
|
||||
data-testid={`workstream-wait-${wait.key}`}
|
||||
key={wait.key}
|
||||
>
|
||||
<span className="font-medium">{wait.actor.name}</span>
|
||||
{" · "}
|
||||
{wait.reason}
|
||||
</li>
|
||||
))}
|
||||
{orderedWaits.map((wait) => {
|
||||
const reference = referencesByIdentity.get(wait.key);
|
||||
const open = reference
|
||||
? () => {
|
||||
const parsed =
|
||||
reference.kind === "buzz"
|
||||
? parseEntityLink(reference.href)
|
||||
: null;
|
||||
if (parsed?.ok) openEntityLink(parsed.value);
|
||||
else void openUrl(reference.href);
|
||||
}
|
||||
: undefined;
|
||||
const content = (
|
||||
<>
|
||||
<WaitAvatar profiles={profiles} wait={wait} />
|
||||
<span className="min-w-0 flex-1 text-2xs leading-tight">
|
||||
<span
|
||||
className="line-clamp-2"
|
||||
title={`${wait.actor.name} · ${wait.reason}`}
|
||||
>
|
||||
<span className="font-semibold">{wait.actor.name}</span>
|
||||
{" · "}
|
||||
{wait.reason}
|
||||
</span>
|
||||
</span>
|
||||
<span className="shrink-0 text-right text-3xs text-amber-800 dark:text-amber-200/80">
|
||||
{waitAge(wait.since, now)}
|
||||
<span className="block uppercase tracking-wide text-muted-foreground">
|
||||
{wait.actor.kind} · {wait.source}
|
||||
</span>
|
||||
</span>
|
||||
{open ? (
|
||||
<ExternalLink className="size-3 shrink-0 text-muted-foreground" />
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
const className = cn(
|
||||
"grid w-full grid-cols-[1.1875rem_minmax(0,1fr)_auto_auto] items-center gap-1.5 rounded-md border-l-2 px-1.5 py-1 text-left",
|
||||
wait.source === "derived"
|
||||
? "border-l-sky-400 bg-sky-500/8"
|
||||
: "border-l-amber-400 bg-amber-500/10",
|
||||
open &&
|
||||
"transition-colors hover:bg-background/80 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring",
|
||||
);
|
||||
return (
|
||||
<li data-testid={`workstream-wait-${wait.key}`} key={wait.key}>
|
||||
{open ? (
|
||||
<button className={className} onClick={open} type="button">
|
||||
{content}
|
||||
</button>
|
||||
) : (
|
||||
<div className={className}>{content}</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
import type { ActiveChannelTurnSummary } from "@/features/agents/activeAgentTurnsStore";
|
||||
import { formatElapsed } from "@/features/agents/ui/agentSessionUtils";
|
||||
import type { UserProfileLookup } from "@/features/profile/lib/identity";
|
||||
import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey";
|
||||
import { useNow } from "@/shared/lib/useNow";
|
||||
import { UserAvatar } from "@/shared/ui/UserAvatar";
|
||||
|
||||
type WorkstreamWorkingIndicatorProps = {
|
||||
activeWorking: ActiveChannelTurnSummary;
|
||||
profiles?: UserProfileLookup;
|
||||
};
|
||||
|
||||
/**
|
||||
* Presentation-only projection of the shared active-turn store for one board
|
||||
* card. Ingestion, ordering, and stall pruning stay owned by
|
||||
* activeAgentTurnsStore.
|
||||
*/
|
||||
export function WorkstreamWorkingIndicator({
|
||||
activeWorking,
|
||||
profiles,
|
||||
}: WorkstreamWorkingIndicatorProps) {
|
||||
const now = useNow(1000);
|
||||
const elapsed = formatElapsed(now - activeWorking.anchorAt);
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-label={`${activeWorking.agentCount} active agent${activeWorking.agentCount === 1 ? "" : "s"}`}
|
||||
className="mt-4 border-t border-border/60 pt-3"
|
||||
data-testid={`workstream-working-${activeWorking.channelId}`}
|
||||
>
|
||||
<div className="flex items-center gap-1.5 text-2xs font-medium text-primary">
|
||||
<Loader2 aria-hidden="true" className="size-3 animate-spin" />
|
||||
<span>
|
||||
{activeWorking.agentCount} working · {elapsed}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
{activeWorking.agentPubkeys.map((pubkey) => {
|
||||
const profile = profiles?.[normalizePubkey(pubkey)];
|
||||
const name =
|
||||
profile?.displayName?.trim() || `Agent ${truncatePubkey(pubkey)}`;
|
||||
return (
|
||||
<span
|
||||
className="inline-flex max-w-full items-center gap-1 rounded-full border border-primary/25 bg-primary/10 py-0.5 pl-1 pr-2 text-2xs text-foreground"
|
||||
data-testid={`workstream-working-agent-${pubkey}`}
|
||||
key={pubkey}
|
||||
title={name}
|
||||
>
|
||||
<UserAvatar
|
||||
avatarUrl={profile?.avatarUrl ?? null}
|
||||
className="size-3.5 shrink-0"
|
||||
displayName={name}
|
||||
size="sm"
|
||||
/>
|
||||
<span className="truncate">{name}</span>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
52
desktop/src/shared/ui/AvatarStack.tsx
Normal file
52
desktop/src/shared/ui/AvatarStack.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import { UserAvatar } from "@/shared/ui/UserAvatar";
|
||||
|
||||
export type AvatarStackItem = {
|
||||
id: string;
|
||||
avatarUrl: string | null;
|
||||
displayName: string;
|
||||
};
|
||||
|
||||
export function AvatarStack({
|
||||
items,
|
||||
limit = items.length,
|
||||
testId = "avatar-stack-item",
|
||||
}: {
|
||||
items: readonly AvatarStackItem[];
|
||||
limit?: number;
|
||||
testId?: string;
|
||||
}) {
|
||||
const visible = items.slice(0, limit);
|
||||
const overflow = items.length - visible.length;
|
||||
|
||||
return (
|
||||
<span className="flex h-6 shrink-0 items-center" data-testid="avatar-stack">
|
||||
{visible.map((item, index) => (
|
||||
<span
|
||||
className={index > 0 ? "-ml-1" : ""}
|
||||
data-testid={testId}
|
||||
key={item.id}
|
||||
style={{
|
||||
zIndex: index + 1,
|
||||
...(index < visible.length - 1 && {
|
||||
mask: "radial-gradient(circle 14px at calc(100% + 6px) 50%, transparent 99%, #fff 100%)",
|
||||
WebkitMask:
|
||||
"radial-gradient(circle 14px at calc(100% + 6px) 50%, transparent 99%, #fff 100%)",
|
||||
}),
|
||||
}}
|
||||
>
|
||||
<UserAvatar
|
||||
avatarUrl={item.avatarUrl}
|
||||
className="h-6 w-6 text-2xs"
|
||||
displayName={item.displayName}
|
||||
size="sm"
|
||||
/>
|
||||
</span>
|
||||
))}
|
||||
{overflow > 0 ? (
|
||||
<span className="relative z-10 ml-0.5 inline-grid h-[1.375rem] min-w-[1.6875rem] place-items-center rounded-full border border-border bg-background px-1 text-3xs font-semibold text-muted-foreground">
|
||||
+{overflow}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user