mirror of
https://github.com/block/buzz.git
synced 2026-10-31 14:31:02 +00:00
feat(desktop): show workstream waits and priority
Signed-off-by: loganj <loganj@squareup.com>
This commit is contained in:
@@ -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,
|
||||
);
|
||||
});
|
||||
167
desktop/src/features/workstream-board/lib/workstreamWaits.ts
Normal file
167
desktop/src/features/workstream-board/lib/workstreamWaits.ts
Normal 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),
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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" ? (
|
||||
|
||||
@@ -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 (
|
||||
|
||||
32
desktop/src/features/workstream-board/ui/WorkstreamWaits.tsx
Normal file
32
desktop/src/features/workstream-board/ui/WorkstreamWaits.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user