diff --git a/desktop/src/features/messages/ui/MessageThreadSummaryRow.tsx b/desktop/src/features/messages/ui/MessageThreadSummaryRow.tsx index 083faf04db..6c2c1ff0ee 100644 --- a/desktop/src/features/messages/ui/MessageThreadSummaryRow.tsx +++ b/desktop/src/features/messages/ui/MessageThreadSummaryRow.tsx @@ -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 ( -
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%)", - }), - }} - > - -
- ); -} - export function MessageThreadSummaryRow({ collapseDepthGuideActions, depth = 0, @@ -231,14 +196,14 @@ export function MessageThreadSummaryRow({ }} />
- {summary.participants.map((participant, index) => ( - - ))} + ({ + id: participant.id, + avatarUrl: participant.avatarUrl, + displayName: participant.author, + }))} + testId="message-thread-summary-participant" + />
diff --git a/desktop/src/features/workstream-board/lib/workstreamAgentStatuses.test.mjs b/desktop/src/features/workstream-board/lib/workstreamAgentStatuses.test.mjs new file mode 100644 index 0000000000..b61d69b2c1 --- /dev/null +++ b/desktop/src/features/workstream-board/lib/workstreamAgentStatuses.test.mjs @@ -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: [], + }); +}); diff --git a/desktop/src/features/workstream-board/lib/workstreamAgentStatuses.ts b/desktop/src/features/workstream-board/lib/workstreamAgentStatuses.ts new file mode 100644 index 0000000000..0814839590 --- /dev/null +++ b/desktop/src/features/workstream-board/lib/workstreamAgentStatuses.ts @@ -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(); + 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); + }), + }; +} diff --git a/desktop/src/features/workstream-board/lib/workstreamWaits.test.mjs b/desktop/src/features/workstream-board/lib/workstreamWaits.test.mjs index 196c24712e..cbbe25a826 100644 --- a/desktop/src/features/workstream-board/lib/workstreamWaits.test.mjs +++ b/desktop/src/features/workstream-board/lib/workstreamWaits.test.mjs @@ -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"], ); diff --git a/desktop/src/features/workstream-board/lib/workstreamWaits.ts b/desktop/src/features/workstream-board/lib/workstreamWaits.ts index 9ad48684c3..041195518f 100644 --- a/desktop/src/features/workstream-board/lib/workstreamWaits.ts +++ b/desktop/src/features/workstream-board/lib/workstreamWaits.ts @@ -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()) diff --git a/desktop/src/features/workstream-board/ui/WorkstreamAgentStatusPills.tsx b/desktop/src/features/workstream-board/ui/WorkstreamAgentStatusPills.tsx new file mode 100644 index 0000000000..83bbb4d698 --- /dev/null +++ b/desktop/src/features/workstream-board/ui/WorkstreamAgentStatusPills.tsx @@ -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 ( + item.displayName).join(", ")} + > + + + {agents.length} {label} + + + ); +} + +export function WorkstreamAgentStatusPills({ + profiles, + statuses, +}: { + profiles?: UserProfileLookup; + statuses: WorkstreamAgentStatuses; +}) { + if (statuses.working.length === 0 && statuses.waiting.length === 0) + return null; + return ( +
+ + +
+ ); +} diff --git a/desktop/src/features/workstream-board/ui/WorkstreamBoardScreen.tsx b/desktop/src/features/workstream-board/ui/WorkstreamBoardScreen.tsx index 372b752a7f..a470819a7e 100644 --- a/desktop/src/features/workstream-board/ui/WorkstreamBoardScreen.tsx +++ b/desktop/src/features/workstream-board/ui/WorkstreamBoardScreen.tsx @@ -131,6 +131,7 @@ export function WorkstreamBoardScreen() { void goChannel(channelId)} onWaitsChange={(waits, createdAt) => diff --git a/desktop/src/features/workstream-board/ui/WorkstreamCard.tsx b/desktop/src/features/workstream-board/ui/WorkstreamCard.tsx index 9ce58af462..51e1a7ac97 100644 --- a/desktop/src/features/workstream-board/ui/WorkstreamCard.tsx +++ b/desktop/src/features/workstream-board/ui/WorkstreamCard.tsx @@ -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 (
@@ -80,11 +103,20 @@ export function WorkstreamCard({ > Open #{channel.name} + {waitingOnPrincipal ? ( + + Priority + + ) : null}
-
+
+ {viewModel.status === "ready" ? ( <>

@@ -97,25 +129,21 @@ export function WorkstreamCard({ {viewModel.card.orchestrator.name}

- {viewModel.card.assignees.length > 0 ? ( -
- {viewModel.card.assignees.map((assignee) => ( - - {assignee.name} - - ))} -
- ) : null} + {references.length > 0 ? ( ) : null} - +
) : viewModel.status === "loading" ? ( @@ -135,12 +163,6 @@ export function WorkstreamCard({ ) : null}
)} - {activeWorking ? ( - - ) : null}
); diff --git a/desktop/src/features/workstream-board/ui/WorkstreamWaits.tsx b/desktop/src/features/workstream-board/ui/WorkstreamWaits.tsx index 3f574cfec6..a1a6479042 100644 --- a/desktop/src/features/workstream-board/ui/WorkstreamWaits.tsx +++ b/desktop/src/features/workstream-board/ui/WorkstreamWaits.tsx @@ -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 ( + + ); + } + return ( + + {wait.actor.name.slice(0, 2).toUpperCase()} + + ); +} + 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 (
-

- Waiting on -

+
+

+ {waits.length} {waits.length === 1 ? "blocker" : "blockers"} +

+ oldest first +
); diff --git a/desktop/src/features/workstream-board/ui/WorkstreamWorkingIndicator.tsx b/desktop/src/features/workstream-board/ui/WorkstreamWorkingIndicator.tsx deleted file mode 100644 index 8a8b7e9bd2..0000000000 --- a/desktop/src/features/workstream-board/ui/WorkstreamWorkingIndicator.tsx +++ /dev/null @@ -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 ( -
-
-
-
- {activeWorking.agentPubkeys.map((pubkey) => { - const profile = profiles?.[normalizePubkey(pubkey)]; - const name = - profile?.displayName?.trim() || `Agent ${truncatePubkey(pubkey)}`; - return ( - - - {name} - - ); - })} -
-
- ); -} diff --git a/desktop/src/shared/ui/AvatarStack.tsx b/desktop/src/shared/ui/AvatarStack.tsx new file mode 100644 index 0000000000..da76a5c578 --- /dev/null +++ b/desktop/src/shared/ui/AvatarStack.tsx @@ -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 ( + + {visible.map((item, 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%)", + }), + }} + > + + + ))} + {overflow > 0 ? ( + + +{overflow} + + ) : null} + + ); +}