From 9637be3b738491f2b4f73faa899fbd11621ea18f Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Tue, 18 Aug 2026 17:42:50 +0000 Subject: [PATCH] feat(desktop): show workstream waits and priority Signed-off-by: loganj --- .../lib/workstreamWaits.test.mjs | 132 ++++++++++++++ .../workstream-board/lib/workstreamWaits.ts | 167 ++++++++++++++++++ .../ui/WorkstreamBoardScreen.tsx | 73 +++++++- .../workstream-board/ui/WorkstreamCard.tsx | 40 ++++- .../ui/WorkstreamPullRequests.tsx | 5 +- .../workstream-board/ui/WorkstreamWaits.tsx | 32 ++++ 6 files changed, 436 insertions(+), 13 deletions(-) create mode 100644 desktop/src/features/workstream-board/lib/workstreamWaits.test.mjs create mode 100644 desktop/src/features/workstream-board/lib/workstreamWaits.ts create mode 100644 desktop/src/features/workstream-board/ui/WorkstreamWaits.tsx diff --git a/desktop/src/features/workstream-board/lib/workstreamWaits.test.mjs b/desktop/src/features/workstream-board/lib/workstreamWaits.test.mjs new file mode 100644 index 0000000000..04ec471e50 --- /dev/null +++ b/desktop/src/features/workstream-board/lib/workstreamWaits.test.mjs @@ -0,0 +1,132 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + deriveReviewerWaits, + mergeWorkstreamWaits, + parseManualWorkstreamWaits, + sortWorkstreamCards, +} from "./workstreamWaits.ts"; + +const LOGAN = "a".repeat(64); + +test("manual waits retain simultaneous unique entries and ignore malformed or duplicate keys", () => { + assert.deepEqual( + parseManualWorkstreamWaits([ + { + key: "log", + actor: { kind: "person", pubkey: LOGAN, name: "Logan" }, + reason: "Decision", + }, + { + key: "review", + actor: { kind: "team", name: "Reviewers" }, + reason: "Review", + }, + { + key: "review", + actor: { kind: "team", name: "Duplicate" }, + reason: "Ignored", + }, + { key: "bad", actor: { kind: "person", name: "" }, reason: "Invalid" }, + ]).map((wait) => wait.key), + ["log", "review"], + ); +}); + +test("an open unapproved PR derives a wait which manual deletion cannot clear", () => { + const reference = { + kind: "github", + identity: "github:block/buzz#1", + rawUrl: "https://github.com/block/buzz/pull/1", + href: "https://github.com/block/buzz/pull/1", + owner: "block", + repository: "buzz", + number: 1, + }; + const states = new Map([ + [ + reference.identity, + { + status: "live", + provider: "github", + href: reference.href, + repository: "block/buzz", + number: 1, + title: "One", + lifecycle: "open", + reviewState: "review-requested", + }, + ], + ]); + const derived = deriveReviewerWaits([reference], states); + assert.equal(mergeWorkstreamWaits([], derived).length, 1); + states.set(reference.identity, { + ...states.get(reference.identity), + lifecycle: "merged", + }); + assert.equal(deriveReviewerWaits([reference], states).length, 0); +}); + +test("sorting authenticates the current owner by pubkey and remains stable across elapsed ticks", () => { + const cards = [ + { + channelId: "lookalike", + channelName: "alpha", + createdAt: "2026-08-18T02:00:00Z", + waits: [ + { + key: "name", + actor: { kind: "person", name: "Logan" }, + reason: "Name only", + source: "manual", + }, + ], + }, + { + channelId: "active", + channelName: "beta", + createdAt: "2026-08-18T03:00:00Z", + waits: [], + activeWorking: { + channelId: "active", + agentCount: 1, + agentPubkeys: ["b"], + anchorAt: 1, + }, + }, + { + channelId: "owner", + channelName: "zeta", + createdAt: "2026-08-18T04:00:00Z", + waits: [ + { + key: "owner", + actor: { + kind: "person", + name: "Not Logan", + pubkey: LOGAN.toUpperCase(), + }, + reason: "Decision", + source: "manual", + }, + ], + }, + ]; + const ordered = sortWorkstreamCards(cards, LOGAN).map( + (card) => card.channelId, + ); + assert.deepEqual(ordered, ["owner", "active", "lookalike"]); + assert.deepEqual( + sortWorkstreamCards( + cards.map((card) => ({ + ...card, + activeWorking: card.activeWorking + ? { ...card.activeWorking, anchorAt: 999999 } + : undefined, + })), + LOGAN, + ).map((card) => card.channelId), + ordered, + ); +}); diff --git a/desktop/src/features/workstream-board/lib/workstreamWaits.ts b/desktop/src/features/workstream-board/lib/workstreamWaits.ts new file mode 100644 index 0000000000..9ad48684c3 --- /dev/null +++ b/desktop/src/features/workstream-board/lib/workstreamWaits.ts @@ -0,0 +1,167 @@ +import { normalizePubkey } from "@/shared/lib/pubkey"; +import type { ActiveChannelTurnSummary } from "@/features/agents/activeAgentTurnsStore"; +import type { WorkstreamCardV1 } from "@/features/workstream-board/lib/workstreamCardParser"; +import type { + WorkstreamPullRequestDisplayState, + WorkstreamPullRequestReference, +} from "@/features/workstream-board/lib/workstreamPullRequestStatus"; + +export type WorkstreamWaitActor = { + kind: "person" | "agent" | "team"; + name: string; + pubkey?: string; +}; + +export type WorkstreamWait = { + key: string; + actor: WorkstreamWaitActor; + reason: string; + since?: string; + source: "manual" | "derived"; +}; + +export type WorkstreamCardSortInput = { + channelId: string; + channelName: string; + createdAt: string | null | undefined; + waits: readonly WorkstreamWait[]; + activeWorking?: ActiveChannelTurnSummary; +}; + +export function isWorkstreamWait( + value: unknown, +): value is Omit { + if (typeof value !== "object" || value === null || Array.isArray(value)) + return false; + const wait = value as Record; + if ( + typeof wait.key !== "string" || + !wait.key.trim() || + typeof wait.reason !== "string" || + !wait.reason.trim() + ) + return false; + if ( + typeof wait.actor !== "object" || + wait.actor === null || + Array.isArray(wait.actor) + ) + return false; + const actor = wait.actor as Record; + if ( + actor.kind !== "person" && + actor.kind !== "agent" && + actor.kind !== "team" + ) + return false; + if (typeof actor.name !== "string" || !actor.name.trim()) return false; + return ( + actor.pubkey === undefined || + (typeof actor.pubkey === "string" && !!actor.pubkey.trim()) + ); +} + +export function parseManualWorkstreamWaits( + values: readonly unknown[], +): WorkstreamWait[] { + const waits: WorkstreamWait[] = []; + const keys = new Set(); + for (const value of values) { + if (!isWorkstreamWait(value) || keys.has(value.key)) continue; + keys.add(value.key); + waits.push({ ...value, actor: { ...value.actor }, source: "manual" }); + } + return waits; +} + +function needsReviewerWait( + state: WorkstreamPullRequestDisplayState | undefined, +): state is Extract { + return ( + state?.status === "live" && + state.lifecycle === "open" && + state.reviewState !== "approved" + ); +} + +export function deriveReviewerWaits( + references: readonly WorkstreamPullRequestReference[], + states: ReadonlyMap, +): WorkstreamWait[] { + return references.flatMap((reference) => { + const state = states.get(reference.identity); + if (!needsReviewerWait(state)) return []; + return [ + { + key: reference.identity, + actor: { kind: "team", name: "Reviewers" }, + reason: `Review requested for ${state.repository}${state.number ? ` #${state.number}` : ""}`, + source: "derived" as const, + }, + ]; + }); +} + +/** Manual wording wins for an intentionally matching derived PR key. */ +export function mergeWorkstreamWaits( + manual: readonly WorkstreamWait[], + derived: readonly WorkstreamWait[], +): WorkstreamWait[] { + const merged = new Map(); + for (const wait of derived) merged.set(wait.key, wait); + for (const wait of manual) merged.set(wait.key, wait); + return [...merged.values()]; +} + +function isCurrentOwnerWait( + wait: WorkstreamWait, + currentOwnerPubkey?: string | null, +): boolean { + return ( + !!currentOwnerPubkey && + !!wait.actor.pubkey && + normalizePubkey(wait.actor.pubkey) === normalizePubkey(currentOwnerPubkey) + ); +} + +function createdAtMillis(value: string | null | undefined): number { + const parsed = value ? Date.parse(value) : Number.NaN; + return Number.isFinite(parsed) ? parsed : Number.MAX_SAFE_INTEGER; +} + +/** Stable board priority: current owner wait, active work, creation time, then name and ID. */ +export function sortWorkstreamCards( + cards: readonly T[], + currentOwnerPubkey?: string | null, +): T[] { + return [...cards].sort((left, right) => { + const leftOwner = left.waits.some((wait) => + isCurrentOwnerWait(wait, currentOwnerPubkey), + ); + const rightOwner = right.waits.some((wait) => + isCurrentOwnerWait(wait, currentOwnerPubkey), + ); + if (leftOwner !== rightOwner) return leftOwner ? -1 : 1; + const leftActive = Boolean(left.activeWorking); + const rightActive = Boolean(right.activeWorking); + if (leftActive !== rightActive) return leftActive ? -1 : 1; + const creationDelta = + createdAtMillis(left.createdAt) - createdAtMillis(right.createdAt); + if (creationDelta !== 0) return creationDelta; + const nameDelta = left.channelName.localeCompare(right.channelName); + return nameDelta !== 0 + ? nameDelta + : left.channelId.localeCompare(right.channelId); + }); +} + +export function waitsForCard( + card: WorkstreamCardV1, + references: readonly WorkstreamPullRequestReference[], + states: ReadonlyMap, +): WorkstreamWait[] { + return mergeWorkstreamWaits( + parseManualWorkstreamWaits(card.waitingOn), + deriveReviewerWaits(references, states), + ); +} diff --git a/desktop/src/features/workstream-board/ui/WorkstreamBoardScreen.tsx b/desktop/src/features/workstream-board/ui/WorkstreamBoardScreen.tsx index 864ec13bed..372b752a7f 100644 --- a/desktop/src/features/workstream-board/ui/WorkstreamBoardScreen.tsx +++ b/desktop/src/features/workstream-board/ui/WorkstreamBoardScreen.tsx @@ -6,18 +6,37 @@ import { useChannelsQuery } from "@/features/channels/hooks"; import { useUsersBatchQuery } from "@/features/profile/hooks"; import { filterWorkstreamChannels } from "@/features/workstream-board/lib/discoverWorkstreamChannels"; import { getActiveWorkstreamTurns } from "@/features/workstream-board/lib/activeWorkstreamTurns"; +import { + sortWorkstreamCards, + type WorkstreamWait, +} from "@/features/workstream-board/lib/workstreamWaits"; import { WorkstreamCard } from "@/features/workstream-board/ui/WorkstreamCard"; +import { useIdentityQuery } from "@/shared/api/hooks"; import { Button } from "@/shared/ui/button"; import { PageHeader } from "@/shared/ui/PageHeader"; const WORKSTREAM_CARD_GRID_CLASS = "grid grid-cols-1 gap-3 [@container(min-width:38rem)]:grid-cols-2 [@container(min-width:54rem)]:grid-cols-3"; +type CardMetadata = { + waits: readonly WorkstreamWait[]; + createdAt: string | null | undefined; +}; + +function sameMetadata( + left: CardMetadata | undefined, + right: CardMetadata, +): boolean { + return ( + left?.createdAt === right.createdAt && + JSON.stringify(left?.waits) === JSON.stringify(right.waits) + ); +} export function WorkstreamBoardScreen() { const { goChannel } = useAppNavigation(); + const identityQuery = useIdentityQuery(); const channelsQuery = useChannelsQuery(); - const channels = channelsQuery.data ?? []; - const workstreamChannels = filterWorkstreamChannels(channels); + const workstreamChannels = filterWorkstreamChannels(channelsQuery.data ?? []); const activeTurnsByChannel = useActiveAgentTurnsByChannel(); const activeWorkstreamTurns = React.useMemo( () => getActiveWorkstreamTurns(workstreamChannels, activeTurnsByChannel), @@ -32,7 +51,45 @@ export function WorkstreamBoardScreen() { [activeWorkstreamTurns], ); const activeProfilesQuery = useUsersBatchQuery(activeAgentPubkeys); - const activeProfiles = activeProfilesQuery.data?.profiles; + const [metadataByChannel, setMetadataByChannel] = React.useState< + ReadonlyMap + >(() => new Map()); + const onWaitsChange = React.useCallback( + ( + channelId: string, + waits: readonly WorkstreamWait[], + createdAt: string | null | undefined, + ) => { + const next = { waits, createdAt }; + setMetadataByChannel((current) => { + if (sameMetadata(current.get(channelId), next)) return current; + const updated = new Map(current); + updated.set(channelId, next); + return updated; + }); + }, + [], + ); + const sortedChannels = React.useMemo( + () => + sortWorkstreamCards( + workstreamChannels.map((channel) => ({ + channelId: channel.id, + channelName: channel.name, + createdAt: metadataByChannel.get(channel.id)?.createdAt, + waits: metadataByChannel.get(channel.id)?.waits ?? [], + activeWorking: activeTurnsByChannelId.get(channel.id), + channel, + })), + identityQuery.data?.pubkey, + ).map((entry) => entry.channel), + [ + activeTurnsByChannelId, + identityQuery.data?.pubkey, + metadataByChannel, + workstreamChannels, + ], + ); return (
- {channelsQuery.isLoading ? (

Loading workstreams… @@ -64,20 +120,23 @@ export function WorkstreamBoardScreen() { Retry

- ) : workstreamChannels.length === 0 ? ( + ) : sortedChannels.length === 0 ? (

No workstream channels found. Channels named "loganj-ws-…" will appear here.

) : (
- {workstreamChannels.map((channel) => ( + {sortedChannels.map((channel) => ( void goChannel(channelId)} + onWaitsChange={(waits, createdAt) => + onWaitsChange(channel.id, waits, createdAt) + } + profiles={activeProfilesQuery.data?.profiles} /> ))}
diff --git a/desktop/src/features/workstream-board/ui/WorkstreamCard.tsx b/desktop/src/features/workstream-board/ui/WorkstreamCard.tsx index 3f297cab26..9ce58af462 100644 --- a/desktop/src/features/workstream-board/ui/WorkstreamCard.tsx +++ b/desktop/src/features/workstream-board/ui/WorkstreamCard.tsx @@ -2,11 +2,22 @@ import * as React from "react"; import { Hash } from "lucide-react"; import type { ActiveChannelTurnSummary } from "@/features/agents/activeAgentTurnsStore"; -import { useCanvasQuery } from "@/features/channels/hooks"; +import { + useCanvasQuery, + useChannelDetailsQuery, +} from "@/features/channels/hooks"; import type { UserProfileLookup } from "@/features/profile/lib/identity"; -import { parseWorkstreamPullRequestReferences } from "@/features/workstream-board/lib/workstreamPullRequestStatus"; +import { + parseWorkstreamPullRequestReferences, + useWorkstreamPullRequestStatuses, +} from "@/features/workstream-board/lib/workstreamPullRequestStatus"; +import { + waitsForCard, + type WorkstreamWait, +} from "@/features/workstream-board/lib/workstreamWaits"; 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 type { Channel } from "@/shared/api/types"; import { cn } from "@/shared/lib/cn"; @@ -15,6 +26,10 @@ type WorkstreamCardProps = { activeWorking?: ActiveChannelTurnSummary; channel: Channel; profiles?: UserProfileLookup; + onWaitsChange?: ( + waits: readonly WorkstreamWait[], + createdAt: string | null | undefined, + ) => void; onSelect: (channelId: string) => void; }; @@ -22,9 +37,11 @@ export function WorkstreamCard({ activeWorking, channel, onSelect, + onWaitsChange, profiles, }: WorkstreamCardProps) { const canvasQuery = useCanvasQuery(channel.id); + const detailsQuery = useChannelDetailsQuery(channel.id); const viewModel = buildWorkstreamCardViewModel({ canvasContent: canvasQuery.data?.content, isLoading: canvasQuery.isLoading, @@ -37,6 +54,17 @@ export function WorkstreamCard({ : [], [viewModel], ); + const pullRequestStates = useWorkstreamPullRequestStatuses(references); + const waits = React.useMemo( + () => + viewModel.status === "ready" + ? waitsForCard(viewModel.card, references, pullRequestStates) + : [], + [pullRequestStates, references, viewModel], + ); + React.useEffect(() => { + onWaitsChange?.(waits, detailsQuery.data?.createdAt); + }, [detailsQuery.data?.createdAt, onWaitsChange, waits]); return (
Open #{channel.name} -
{channel.name}
- {viewModel.status === "ready" ? ( <>

@@ -84,8 +110,12 @@ export function WorkstreamCard({

) : null} {references.length > 0 ? ( - + ) : null} +
) : viewModel.status === "loading" ? ( diff --git a/desktop/src/features/workstream-board/ui/WorkstreamPullRequests.tsx b/desktop/src/features/workstream-board/ui/WorkstreamPullRequests.tsx index a1ee317686..9254dc4d7e 100644 --- a/desktop/src/features/workstream-board/ui/WorkstreamPullRequests.tsx +++ b/desktop/src/features/workstream-board/ui/WorkstreamPullRequests.tsx @@ -42,10 +42,13 @@ function referenceLabel(reference: WorkstreamPullRequestReference) { export function WorkstreamPullRequests({ references, + states: providedStates, }: { references: readonly WorkstreamPullRequestReference[]; + states?: ReadonlyMap; }) { - const states = useWorkstreamPullRequestStatuses(references); + const queriedStates = useWorkstreamPullRequestStatuses(references); + const states = providedStates ?? queriedStates; const openEntityLink = useOpenEntityLink(); return ( diff --git a/desktop/src/features/workstream-board/ui/WorkstreamWaits.tsx b/desktop/src/features/workstream-board/ui/WorkstreamWaits.tsx new file mode 100644 index 0000000000..3f574cfec6 --- /dev/null +++ b/desktop/src/features/workstream-board/ui/WorkstreamWaits.tsx @@ -0,0 +1,32 @@ +import type { WorkstreamWait } from "@/features/workstream-board/lib/workstreamWaits"; +export function WorkstreamWaits({ + waits, +}: { + waits: readonly WorkstreamWait[]; +}) { + if (waits.length === 0) return null; + return ( +
+

+ Waiting on +

+
    + {waits.map((wait) => ( +
  • + {wait.actor.name} + {" · "} + {wait.reason} +
  • + ))} +
+
+ ); +}