feat(desktop): enrich workstream board cards

Signed-off-by: loganj <loganj@squareup.com>
This commit is contained in:
Logan Johnson
2026-08-19 15:44:52 +00:00
parent 4ba6dc36c7
commit 9ebeb7c27b
11 changed files with 424 additions and 147 deletions

View File

@@ -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>

View File

@@ -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: [],
});
});

View File

@@ -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);
}),
};
}

View File

@@ -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"],
);

View File

@@ -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())

View File

@@ -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>
);
}

View File

@@ -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) =>

View File

@@ -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>
);

View File

@@ -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>
);

View File

@@ -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>
);
}

View 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>
);
}