feat(desktop): show workstream waits and priority

Signed-off-by: loganj <loganj@squareup.com>
This commit is contained in:
Logan Johnson
2026-08-18 17:42:50 +00:00
parent 31fb40c62b
commit 9637be3b73
6 changed files with 436 additions and 13 deletions

View File

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

View File

@@ -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<WorkstreamWait, "source"> {
if (typeof value !== "object" || value === null || Array.isArray(value))
return false;
const wait = value as Record<string, unknown>;
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<string, unknown>;
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<string>();
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<WorkstreamPullRequestDisplayState, { status: "live" }> {
return (
state?.status === "live" &&
state.lifecycle === "open" &&
state.reviewState !== "approved"
);
}
export function deriveReviewerWaits(
references: readonly WorkstreamPullRequestReference[],
states: ReadonlyMap<string, WorkstreamPullRequestDisplayState>,
): 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<string, WorkstreamWait>();
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<T extends WorkstreamCardSortInput>(
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<string, WorkstreamPullRequestDisplayState>,
): WorkstreamWait[] {
return mergeWorkstreamWaits(
parseManualWorkstreamWaits(card.waitingOn),
deriveReviewerWaits(references, states),
);
}

View File

@@ -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<string, CardMetadata>
>(() => 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 (
<div
@@ -48,7 +105,6 @@ export function WorkstreamBoardScreen() {
description="Live canvases for active workstream channels."
title="Workstream Board"
/>
{channelsQuery.isLoading ? (
<p className="text-sm text-muted-foreground">
Loading workstreams…
@@ -64,20 +120,23 @@ export function WorkstreamBoardScreen() {
Retry
</Button>
</div>
) : workstreamChannels.length === 0 ? (
) : sortedChannels.length === 0 ? (
<p className="text-sm text-muted-foreground">
No workstream channels found. Channels named "loganj-ws-…" will
appear here.
</p>
) : (
<div className={WORKSTREAM_CARD_GRID_CLASS}>
{workstreamChannels.map((channel) => (
{sortedChannels.map((channel) => (
<WorkstreamCard
activeWorking={activeTurnsByChannelId.get(channel.id)}
channel={channel}
key={channel.id}
profiles={activeProfiles}
onSelect={(channelId) => void goChannel(channelId)}
onWaitsChange={(waits, createdAt) =>
onWaitsChange(channel.id, waits, createdAt)
}
profiles={activeProfilesQuery.data?.profiles}
/>
))}
</div>

View File

@@ -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 (
<div
@@ -52,13 +80,11 @@ export function WorkstreamCard({
>
<span className="sr-only">Open #{channel.name}</span>
</button>
<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">
<Hash className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">{channel.name}</span>
</div>
{viewModel.status === "ready" ? (
<>
<p className="mt-3 line-clamp-3 text-sm leading-relaxed text-foreground">
@@ -84,8 +110,12 @@ export function WorkstreamCard({
</div>
) : null}
{references.length > 0 ? (
<WorkstreamPullRequests references={references} />
<WorkstreamPullRequests
references={references}
states={pullRequestStates}
/>
) : null}
<WorkstreamWaits waits={waits} />
</div>
</>
) : viewModel.status === "loading" ? (

View File

@@ -42,10 +42,13 @@ function referenceLabel(reference: WorkstreamPullRequestReference) {
export function WorkstreamPullRequests({
references,
states: providedStates,
}: {
references: readonly WorkstreamPullRequestReference[];
states?: ReadonlyMap<string, WorkstreamPullRequestDisplayState>;
}) {
const states = useWorkstreamPullRequestStatuses(references);
const queriedStates = useWorkstreamPullRequestStatuses(references);
const states = providedStates ?? queriedStates;
const openEntityLink = useOpenEntityLink();
return (

View File

@@ -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 (
<section
aria-label="Waiting on"
className="pointer-events-auto mt-4 space-y-1.5"
data-testid="workstream-waits"
>
<p className="text-2xs font-semibold uppercase tracking-wide text-muted-foreground">
Waiting on
</p>
<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>
))}
</ul>
</section>
);
}