Files
PaperClipAI/ui/src/pages/StatusCards/StatusCardDetailDrawer.tsx
T
DottaandClaude Opus 5.5 d807887534 fix(ui): use one status glyph mapping for every task view (#19329)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Task status is the main signal on every task surface: sidebar rows,
the task header, the status menu, lists, the inbox, pills and chat
cards.
> - One task state rendered different status icons in different views. A
blocked task that waits on an active sub-task showed the blue "in queue"
glyph in the task header. The status menu, the chat chip and the sidebar
rows showed the red "Blocked" glyph for the same task.
> - The cause: each view passed its own subset of the presentation
fields to `StatusIcon`. Many views dropped `blockerAttention` and some
dropped `externalConversationState`. The status menu rendered its
current row from the bare status. The summaries API did not return
`blockerAttention`, so the recent-task sidebar could not know the state.
> - This pull request puts the state-to-glyph/label mapping in one
resolver and makes every task view pass all of its fields.
> - The benefit is that the same state uses the same icon and label
everywhere.

## Linked Issues or Issue Description

No public issue. Found in a hands-on onboarding test on a staging cloud
workspace.

**What happened?**

The sidebar and the task pane/status menu showed different status icons
for the same state. On the test task (blocked, waiting on an active
sub-task), the task header and the status button showed the blue "in
queue" glyph. The selected "Blocked" row in the status menu showed the
red blocked glyph. The coordinator sidebar "Recents" and project rows
also showed the red blocked glyph, and the chat "Subtask created" chip
showed a red "Blocked" chip.

**Expected behavior**

The same underlying state uses the same icon and label everywhere.

**Steps to reproduce**

1. Create a task with a sub-task. Block the parent on the sub-task, and
let the sub-task's agent run (the parent is "blocked, covered").
2. Open the parent task. The header shows the blue "in queue" glyph.
3. Open the status menu. The current "Blocked" row shows the red glyph.
4. With the coordinator sidebar on, look at the task in Recents or in
its expanded project. It shows the red glyph.

**Paperclip version or commit**

`master` at 03eb49053f.

## What Changed

- Add `ui/src/lib/task-status-presentation.ts`.
`resolveTaskStatusPresentation()` is the one mapping from `status`,
`displayStatus`, `externalConversationState` and `blockerAttention` to
glyph, label and accessible copy. `taskStatusIconProps()` picks all four
fields from a task record.
- `StatusIcon` and `IssueStatusBadge` use the resolver. The status menu
draws the current row with the trigger's presentation (in queue, idle,
monitoring).
- `IssueStatusBadge` now accepts `blockerAttention`.
`taskStatusVar.in_queue` maps to the existing in-progress chip hue, the
same as the existing `--status-task-icon-in_queue` icon token.
- Task call sites spread `taskStatusIconProps(task)`: coordinator
sidebar recents and project rows, classic recent tasks, lists, rows,
columns, inbox, blocked inbox, my issues, dashboard, properties,
relation pills, search, sibling navigation, chat chips, case summaries,
status cards and user profile. Step/check statuses in protocol cards
stay unchanged because they are not task statuses.
- `GET /api/companies/:companyId/issues/summaries` returns
`blockerAttention` for blocked rows (at most five IDs, through the
existing `listBlockerAttention` service). `IssueSummary` gets the
optional field.
- Recent-task snapshots store and refresh `blockerAttention`. Sidebar
project rows request the `rows` projection, which includes blocker state
(the `index` projection skips it).
- Storybook: the Foundations/Status Language story gets a "Same state,
every view" section.

## Verification

Red on `master` (tests written first, then run against the unchanged
code):

```
cd ui && npx vitest run src/components/StatusIcon.picker.test.tsx src/components/CoordinatorSidebar.test.tsx src/lib/recent-tasks.test.ts
     × keeps the covered-blocker state so the sidebar shows the same queued glyph as the task pane (BUG-07)
     × marks the current row with the same glyph as the trigger for a covered-blocked task
     × marks the current row with the same glyph as the trigger for a waiting review and a monitored task
     × shows a covered-blocked recent task with the same queued glyph and label as the task pane
     × shows project tasks with their full status presentation
 FAIL  StatusIcon.picker.test.tsx > ... covered-blocked task
AssertionError: expected 'var(--status-task-icon-blocked)' to be 'var(--status-task-icon-in_queue)'
 FAIL  StatusIcon.picker.test.tsx > ... waiting review and a monitored task
AssertionError: expected 'var(--status-task-icon-in_review)' to be 'var(--status-task-icon-backlog)'
 FAIL  CoordinatorSidebar.test.tsx > ... covered-blocked recent task ...
AssertionError: expected 'var(--status-task-icon-blocked)' to be 'var(--status-task-icon-in_queue)'
 FAIL  CoordinatorSidebar.test.tsx > ... project tasks with their full status presentation
AssertionError: expected "vi.fn()" to be called with arguments: [ 'company', ObjectContaining{…} ]
 FAIL  recent-tasks.test.ts > ... keeps the covered-blocker state ...
AssertionError: expected undefined to deeply equal { state: 'covered', …(7) }
      Tests  5 failed | 32 passed (37)

cd server && npx vitest run src/__tests__/issue-summaries-routes.test.ts
     × projects the covered-blocker state so every task view picks the same queued glyph (BUG-07)
AssertionError: expected undefined to match object { state: 'covered', …(2) }
      Tests  1 failed | 14 passed (15)
```

Green with this change (same commands):

```
 Test Files  3 passed (3)
      Tests  37 passed (37)
 Test Files  1 passed (1)        # server summaries route
      Tests  15 passed (15)
```

Neighbouring and broader checks:

- `StatusIcon`, `StatusBadge`, `StatusGlyph`, picker, sidebar and
recent-task suites: 73/73 pass.
- Full UI Vitest run: 11106 pass, 1 skipped. Three `IssuesList.test.tsx`
tests timed out at 5 s under full-suite load. The same file passes 73/73
when run alone.
- `tsc --noEmit` for `ui` and `server`: no errors in changed files.
Remaining errors are pre-existing (unbuilt
`@paperclipai/paperclip-runner`, generated Storybook catalog).
- `pnpm check:token-gates`: all gates clean.

Screenshots. The staging task before the fix: the status button is blue,
the current "Blocked" row in the menu is red.

| Staging task (before) | Staging status menu (before) |
| --- | --- |
| ![staging
before](https://raw.githubusercontent.com/paperclipai/paperclip/75e148aa1b100400d406d952e66cfac6c4afa5c3/before-staging-onb1.jpg)
| ![staging menu
before](https://raw.githubusercontent.com/paperclipai/paperclip/75e148aa1b100400d406d952e66cfac6c4afa5c3/before-staging-onb1-menu.png)
|

Storybook "Same state, every view" (Foundations/Status Language). Before
uses the `master` `StatusIcon`/`StatusBadge`; after uses this branch.

| Before (master) | After (this PR) |
| --- | --- |
| ![storybook
before](https://raw.githubusercontent.com/paperclipai/paperclip/75e148aa1b100400d406d952e66cfac6c4afa5c3/before-storybook-menu.jpg)
| ![storybook
after](https://raw.githubusercontent.com/paperclipai/paperclip/75e148aa1b100400d406d952e66cfac6c4afa5c3/after-storybook-menu.jpg)
|

Before: the row glyph is blue, but the chip and the current menu row are
red. After: the row glyph, the chip and the current menu row are all the
blue "in queue" glyph.

Review follow-up (commits 1d3a1a0a9e, d5c798ec53): blocker state on
summaries, search and case responses is glyph-only (state, reason,
counts). Blocker titles and IDs are never returned; a sampled identifier
stays only when the actor can read that blocker. Producer-level tests
failed before the follow-up and pass after it
(\`task-created-items.test.tsx\`,
\`CoordinatorTaskCommitment.test.tsx\`,
\`company-search-service.test.ts\`, \`cases-routes.test.ts\`,
private-blocker case in \`issue-summaries-routes.test.ts\`). Before
merge, the branch was test-merged locally with current \`master\`:
typecheck clean, 1095 UI and 80 server tests in the affected suites
pass.

## Risks

- Low risk. Presentation only. Status mutations still use the canonical
`status`.
- The summaries endpoint now runs the blocker-attention query for
blocked rows. The endpoint accepts at most five IDs and runs the query
only when a row is blocked.
- Sidebar project rows use the `rows` projection instead of `index`.
This adds the blocker and review attention reads for at most 12 rows,
and only when a person expands a project.
- `IssueStatusBadge` shows the blue "in queue" chip for covered-blocked
tasks where callers pass `blockerAttention`. The label stays "Blocked"
and the full reason is in the tooltip.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`, Anthropic), through Claude Code
with tool use (shell, file edits, browser). Default context window.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 14:42:11 -06:00

628 lines
29 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useMemo, useState } from "react";
import { Link } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { CompanySearchIssueSummary, StatusCardUpdate, SummarySlotIssueRef } from "@paperclipai/shared";
import { AlertTriangle, ChevronDown, ExternalLink, History, Loader2, RefreshCw, Wand2 } from "lucide-react";
import { statusCardsApi, type StatusCardDryRun } from "@/api/statusCards";
import { describeError } from "@/api/errors";
import { QueryErrorState, useQueryView } from "@/components/QueryView";
import { MarkdownBody } from "@/components/MarkdownBody";
import { useSummaryDraftStream } from "@/components/useSummaryDraftStream";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { IssueStatusBadge } from "@/components/StatusBadge";
import { Select, SelectContent, SelectItem, SelectSeparator, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { InlineBanner } from "@/components/InlineBanner";
import { cn, formatDateTime, relativeTime } from "@/lib/utils";
import { queryKeys } from "@/lib/queryKeys";
import {
deriveStatusCardLifecycle,
describeRefreshPolicy,
STATUS_CARD_LIFECYCLE_PRESENTATION,
} from "@/lib/status-card-state";
import {
StatusCardSettingsForm,
defaultSettingsValue,
type StatusCardSettingsValue,
} from "./StatusCardSettingsForm";
import { SummarizerAgentSelect } from "./SummarizerAgentSelect";
import {
formatCents,
formatTokens,
formatTokenSplit,
rollupUpdatesToday,
updateKindLabel,
} from "./format";
import type { StatusCardView } from "./types";
import { taskStatusIconProps } from "@/lib/task-status-presentation";
export function StatusCardDetailDrawer({
card,
companyId,
open,
onOpenChange,
initialTab = "summary",
}: {
card: StatusCardView | null;
companyId: string | null | undefined;
open: boolean;
onOpenChange: (open: boolean) => void;
initialTab?: string;
}) {
const queryClient = useQueryClient();
const [tab, setTab] = useState("summary");
const [settings, setSettings] = useState<StatusCardSettingsValue>(defaultSettingsValue());
// Rename + interest ("query") are edited in Settings alongside the policy.
const [title, setTitle] = useState("");
const [interest, setInterest] = useState("");
// "" → the built-in Summarizer; otherwise the id of the override agent.
const [summarizerAgentId, setSummarizerAgentId] = useState("");
const [actionError, setActionError] = useState<string | null>(null);
// A short confirmation after a build/refresh is queued (the state dot + badge
// also update, but a card that finishes fast can look like "nothing happened").
const [actionNote, setActionNote] = useState<string | null>(null);
// null → show the latest summary; otherwise a historical update id.
const [selectedRevisionId, setSelectedRevisionId] = useState<string | null>(null);
useEffect(() => {
if (card) {
setSettings({ refreshPolicy: card.refreshPolicy });
setTitle(card.title ?? "");
setInterest(card.interestPrompt);
setSummarizerAgentId(card.agentId ?? "");
setActionError(null);
setActionNote(null);
setSelectedRevisionId(null);
}
}, [card]);
// Open to the requested tab (e.g. "Query debug" on the tile deep-links to
// Settings) whenever the drawer (re)opens.
useEffect(() => {
if (open) setTab(initialTab);
}, [open, initialTab]);
const updatesQuery = useQuery({
queryKey: card ? queryKeys.statusCards.updates(card.id) : ["status-cards", "detail", "none", "updates"],
queryFn: () => statusCardsApi.updates(card!.id),
enabled: Boolean(card && open),
});
const updatesView = useQueryView(updatesQuery);
const summaryRevisionsQuery = useQuery({
queryKey: card ? queryKeys.statusCards.summaryRevisions(card.id) : ["status-cards", "detail", "none", "summary-revisions"],
queryFn: () => statusCardsApi.summaryRevisions(card!.id),
enabled: Boolean(card && open && card.documentId),
});
const dryRunQuery = useQuery({
queryKey: card ? queryKeys.statusCards.dryRun(card.id) : ["status-cards", "detail", "none", "dry-run"],
queryFn: () => statusCardsApi.dryRun(card!.id),
enabled: Boolean(card && open && tab === "watched" && (card.queries.length > 0 || (card.mentionedIssueIds?.length ?? 0) > 0)),
});
const dryRunView = useQueryView(dryRunQuery);
const lifecycle = card ? deriveStatusCardLifecycle(card) : "fresh";
const generatingIssue = useMemo<SummarySlotIssueRef | null>(
() =>
card && lifecycle === "updating" && card.generatingIssueId
? { id: card.generatingIssueId, identifier: null, title: card.title ?? "Status update", status: "in_progress" }
: null,
[card, lifecycle],
);
const draftStream = useSummaryDraftStream(companyId, generatingIssue);
const invalidateCard = async () => {
if (!card) return;
await Promise.all([
queryClient.invalidateQueries({ queryKey: queryKeys.statusCards.list(card.companyId, false) }),
queryClient.invalidateQueries({ queryKey: queryKeys.statusCards.detail(card.id) }),
]);
};
const refreshMutation = useMutation({
mutationFn: () => statusCardsApi.refresh(card!.id),
onMutate: () => {
setActionError(null);
setActionNote(null);
},
onSuccess: async () => {
await invalidateCard();
setActionNote("Refresh queued — the Summarizer is updating this card.");
},
onError: (err) => setActionError(describeError(err, { action: "refresh the card" }).body),
});
const recompileMutation = useMutation({
mutationFn: () => statusCardsApi.recompile(card!.id),
onMutate: () => {
setActionError(null);
setActionNote(null);
},
onSuccess: async () => {
await invalidateCard();
setActionNote("Run queued — the Summarizer is updating this card.");
},
onError: (err) => setActionError(describeError(err, { action: "run the card" }).body),
});
const saveSettingsMutation = useMutation({
mutationFn: () => {
const trimmedTitle = title.trim();
const trimmedInterest = interest.trim();
const interestChanged = trimmedInterest.length > 0 && trimmedInterest !== card!.interestPrompt.trim();
return statusCardsApi.patch(card!.id, {
// An explicit name pins the title so a recompile won't overwrite it;
// clearing it hands naming back to the compiler.
title: trimmedTitle || null,
titlePinned: trimmedTitle.length > 0,
// Editing the card prompt triggers a server-side recompile.
...(interestChanged ? { interestPrompt: trimmedInterest } : {}),
agentId: summarizerAgentId || null,
refreshPolicy: settings.refreshPolicy,
});
},
onMutate: () => setActionError(null),
onSuccess: async () => {
if (!card) return;
await Promise.all([
queryClient.invalidateQueries({ queryKey: queryKeys.statusCards.list(card.companyId, false) }),
queryClient.invalidateQueries({ queryKey: queryKeys.statusCards.detail(card.id) }),
]);
},
onError: (err) => setActionError(describeError(err, { action: "save the settings" }).body),
});
if (!card) return null;
const updates = updatesQuery.data ?? [];
const latestUpdate = updates[0] ?? null;
const todayRollup = rollupUpdatesToday(updates);
// Each successful summary-producing update is a summary revision (reuses the
// SummarySlotCard revision-history pattern). The finishedAt check excludes
// updates whose generation is still in flight — their document revision does
// not exist yet.
const summaryRevisions = updates.filter(
(update) => update.status === "ok" && update.finishedAt && (update.kind === "full" || update.kind === "incremental"),
);
const selectedRevision = selectedRevisionId
? summaryRevisions.find((update) => update.id === selectedRevisionId) ?? null
: null;
// Updates (newest-first, completed content updates only) correspond 1:1 with
// the card's summary-document revisions (newest-first): writeSummary creates
// both in one transaction. Positional matching recovers the full summary
// body for a historical pick; the change-summary fallback covers any gap.
const documentRevisions = summaryRevisionsQuery.data ?? [];
const selectedRevisionBody = selectedRevision
? documentRevisions[summaryRevisions.indexOf(selectedRevision)]?.body ?? null
: null;
const latestRevisionNumber = summaryRevisions.length;
const revisionNumberOf = (update: StatusCardUpdate) => latestRevisionNumber - summaryRevisions.indexOf(update);
const displayedChanges = selectedRevision ? selectedRevision.changes : latestUpdate?.changes ?? [];
const presentation = STATUS_CARD_LIFECYCLE_PRESENTATION[lifecycle];
const hasSummary = Boolean(card.summaryBody && card.summaryBody.trim().length > 0);
// Setup is genuinely in flight only while a generation task exists; a null id
// on a compiling card means the first run stalled and needs a manual re-kick.
const setupRunning = lifecycle === "compiling" && Boolean(card.generatingIssueId);
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="flex w-full flex-col gap-0 p-0 sm:max-w-2xl">
<SheetHeader className="border-b border-border p-4">
<div className="flex items-center gap-2 pr-8">
<span className={cn("inline-block h-2.5 w-2.5 shrink-0 rounded-full", presentation.dotClassName)} aria-hidden="true" />
<SheetTitle className="min-w-0 flex-1 truncate text-lg">{card.title ?? "Untitled card"}</SheetTitle>
<Badge variant="outline">{presentation.label}</Badge>
{lifecycle === "compiling" ? (
<Button
variant="outline"
size="sm"
onClick={() => recompileMutation.mutate()}
// While the setup run is live, "Run now" is disabled — kicking a
// second run would race the one already building the card.
disabled={recompileMutation.isPending || setupRunning}
>
{setupRunning || recompileMutation.isPending ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Wand2 className="h-3.5 w-3.5" />
)}
{setupRunning ? "Setting up…" : recompileMutation.isPending ? "Running…" : "Run now"}
</Button>
) : (
<Button
variant="outline"
size="sm"
onClick={() => refreshMutation.mutate()}
disabled={refreshMutation.isPending || lifecycle === "updating"}
>
<RefreshCw className={cn("h-3.5 w-3.5", refreshMutation.isPending && "animate-spin")} />
{refreshMutation.isPending ? "Refreshing…" : "Refresh"}
</Button>
)}
</div>
<p className="text-xs text-muted-foreground">
{card.lastGeneratedAt ? `Updated ${relativeTime(card.lastGeneratedAt)}` : "No summary yet"} ·{" "}
{describeRefreshPolicy(card.refreshPolicy)}
</p>
</SheetHeader>
<Tabs value={tab} onValueChange={setTab} className="flex min-h-0 flex-1 flex-col gap-0">
<TabsList variant="line" className="w-full justify-start gap-4 border-b border-border px-4">
<TabsTrigger value="summary">Summary</TabsTrigger>
<TabsTrigger value="settings">Settings</TabsTrigger>
<TabsTrigger value="watched">Watched issues</TabsTrigger>
<TabsTrigger value="history">History</TabsTrigger>
</TabsList>
{actionError ? (
<div className="px-4 pt-3">
<InlineBanner tone="warning" title="Heads up">{actionError}</InlineBanner>
</div>
) : actionNote ? (
<div className="px-4 pt-3">
<InlineBanner tone="info" title="Working on it">{actionNote}</InlineBanner>
</div>
) : null}
<div className="min-h-0 flex-1 overflow-y-auto p-4">
<TabsContent value="summary" className="mt-0 space-y-5">
{/* Revision picker lives on the right, unpilled. A single-revision
card shows a plain label; multi-revision cards get a dropdown
capped at the 30 most recent revisions. */}
{(hasSummary || summaryRevisions.length > 0) && lifecycle !== "compiling" ? (
<div className="flex items-center justify-end gap-2">
{summaryRevisions.length > 1 ? (
<Select
value={selectedRevisionId ?? "__latest__"}
onValueChange={(value) => setSelectedRevisionId(value === "__latest__" ? null : value)}
>
<SelectTrigger size="sm" className="w-auto gap-1.5" aria-label="Select summary revision">
<History className="h-3.5 w-3.5" aria-hidden="true" />
<SelectValue />
</SelectTrigger>
<SelectContent align="end" position="popper">
<SelectItem value="__latest__" className="text-xs">
Revision {latestRevisionNumber} · latest
</SelectItem>
<SelectSeparator />
{summaryRevisions.slice(0, 30).map((update) => (
<SelectItem
key={update.id}
value={update.id}
className="text-xs"
title={formatDateTime(update.startedAt)}
>
Rev {revisionNumberOf(update)} · {updateKindLabel(update.kind)} · {relativeTime(update.startedAt)}
</SelectItem>
))}
</SelectContent>
</Select>
) : latestRevisionNumber > 0 ? (
<span className="text-xs text-muted-foreground">Revision {latestRevisionNumber} · latest</span>
) : null}
</div>
) : null}
{lifecycle === "updating" && draftStream.draft && !selectedRevision ? (
<MarkdownBody className="text-sm leading-7">{draftStream.draft}</MarkdownBody>
) : selectedRevision ? (
<div className="space-y-2 rounded-md border border-border bg-muted/30 p-3">
<p className="text-xs text-muted-foreground" title={formatDateTime(selectedRevision.startedAt)}>
Revision {revisionNumberOf(selectedRevision)} · {updateKindLabel(selectedRevision.kind)} ·{" "}
{relativeTime(selectedRevision.startedAt)}
</p>
{selectedRevisionBody ? (
<MarkdownBody className="text-sm leading-7">{selectedRevisionBody}</MarkdownBody>
) : selectedRevision.changeSummary ? (
<>
<MarkdownBody className="text-sm leading-7">{selectedRevision.changeSummary}</MarkdownBody>
<p className="text-xs text-muted-foreground/70">
The full summary text for this revision is unavailable — showing its change summary. The
integrated changes below are the live ledger for this revision.
</p>
</>
) : (
<p className="text-sm text-muted-foreground">
No change summary was recorded for this revision.
</p>
)}
</div>
) : hasSummary ? (
<MarkdownBody className="text-sm leading-7">{card.summaryBody!}</MarkdownBody>
) : lifecycle === "compiling" ? (
<div className="space-y-2 text-sm text-muted-foreground">
<p className="flex items-center gap-2">
{setupRunning ? (
<Loader2 className="h-4 w-4 shrink-0 animate-spin" />
) : (
<AlertTriangle className="h-4 w-4 shrink-0 text-amber-500" />
)}
{setupRunning
? "Setting up — the first summary is generated automatically once this finishes."
: "Setup didn’t finish. Run it now to try again."}
</p>
{setupRunning && card.generatingIssueId ? (
<Link
to={`/issues/${card.generatingIssueId}`}
className="inline-flex items-center gap-1.5 text-xs font-medium text-foreground underline-offset-2 hover:underline"
>
<ExternalLink className="h-3.5 w-3.5" />
View setup task
</Link>
) : null}
</div>
) : (
<p className="text-sm text-muted-foreground">
No summary yet — the first one is generated automatically once this card finishes setting up.
</p>
)}
{displayedChanges.length > 0 ? (
<section className="space-y-2">
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{selectedRevision ? "Integrated in this revision" : "Integrated in this update"} (
{displayedChanges.length} {displayedChanges.length === 1 ? "change" : "changes"})
</h3>
<div className="space-y-1.5">
{displayedChanges.map((change) => (
<ChangeRow key={change.issueId} change={change} />
))}
</div>
</section>
) : null}
</TabsContent>
<TabsContent value="history" className="mt-0 space-y-3">
{/* History and cost live together: the today rollup up top, then
every recorded update (each update is one summary revision). */}
{updatesQuery.isLoading || updatesView.kind === "reconnecting" ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" /> Loading history…
</div>
) : updates.length === 0 ? (
<p className="text-sm text-muted-foreground">No updates recorded yet.</p>
) : (
<>
<div className="text-xs text-muted-foreground">
Today: {todayRollup.updateCount}{" "}
{todayRollup.updateCount === 1 ? "update" : "updates"} ·{" "}
{formatTokens(todayRollup.totalTokens)} · {formatCents(todayRollup.totalCostCents)}
{card.refreshPolicy.dailyTokenCap ? ` · daily cap ${formatTokens(card.refreshPolicy.dailyTokenCap)}` : ""}
</div>
<div className="divide-y divide-border">
{updates.map((update) => (
<div key={update.id} className="py-2.5 first:pt-1">
<div className="flex items-center justify-between gap-2">
<span className="flex items-center gap-2 text-sm font-medium">
{updateKindLabel(update.kind)}
<Badge variant={update.status === "failed" ? "destructive" : "secondary"}>
{update.status === "ok" ? update.trigger : update.status}
</Badge>
</span>
<span className="text-xs text-muted-foreground" title={formatDateTime(update.startedAt)}>
{relativeTime(update.startedAt)}
</span>
</div>
<p className="mt-1 text-xs text-muted-foreground">
{formatTokenSplit(update.inputTokens, update.outputTokens)} · {formatCents(update.costCents)}
{update.model ? ` · ${update.model}` : ""}
{update.changes.length > 0 ? ` · ${update.changes.length} changes` : ""}
</p>
{update.error ? <p className="mt-1 text-xs text-destructive">{update.error}</p> : null}
</div>
))}
</div>
</>
)}
</TabsContent>
<TabsContent value="watched" className="mt-0 space-y-3">
{card.queries.length === 0 && (card.mentionedIssueIds?.length ?? 0) === 0 ? (
<div className="rounded-md border border-dashed border-border px-3 py-4 text-sm text-muted-foreground">
This card is still setting up — the issues it watches appear here once it's ready.
</div>
) : dryRunQuery.isLoading || dryRunView.kind === "reconnecting" ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" /> Matching issues…
</div>
) : dryRunView.kind === "error" ? (
<QueryErrorState
error={dryRunView.error}
action="load matched issues"
onRetry={dryRunView.retry}
retrying={dryRunView.isFetching}
/>
) : (
<MatchedIssueList
queries={dryRunQuery.data?.queries ?? []}
mentioned={dryRunQuery.data?.mentionedIssues ?? []}
/>
)}
</TabsContent>
<TabsContent value="settings" className="mt-0 space-y-6">
<section className="space-y-2">
<h3 className="text-sm font-semibold">Card name</h3>
<Input
value={title}
onChange={(event) => setTitle(event.target.value)}
placeholder="Auto-named from the query"
className="text-sm"
aria-label="Card name"
/>
</section>
<section className="space-y-2">
<h3 className="text-sm font-semibold">What this card watches & reports</h3>
<Textarea
value={interest}
onChange={(event) => setInterest(event.target.value)}
rows={3}
className="text-sm"
aria-label="What this card watches & reports"
/>
<p className="text-xs text-muted-foreground">
This one message drives the whole card: the agent compiles the watch query from it
and follows it as the instructions for every update. Editing it rebuilds the card.
</p>
</section>
<section className="space-y-2">
<h3 className="text-sm font-semibold">Agent</h3>
<SummarizerAgentSelect
companyId={card.companyId}
value={summarizerAgentId}
onChange={setSummarizerAgentId}
enabled={open}
/>
</section>
<StatusCardSettingsForm value={settings} onChange={setSettings} />
<QueryDebugSection card={card} />
<div className="flex justify-end border-t border-border pt-4">
<Button onClick={() => saveSettingsMutation.mutate()} disabled={saveSettingsMutation.isPending}>
{saveSettingsMutation.isPending ? <Loader2 className="animate-spin" /> : null}
Save
</Button>
</div>
</TabsContent>
</div>
</Tabs>
</SheetContent>
</Sheet>
);
}
/**
* The compiled query is agent-maintained (the Summarizer writes it from its
* generation task) and normally hidden. Surfaced read-only here (moved out of
* the old standalone debug drawer, PAP-15223) so the raw query + version stay
* inspectable without leaving Settings.
*/
function QueryDebugSection({ card }: { card: StatusCardView }) {
const queryJson = JSON.stringify({ queries: card.queries, limit: 50 }, null, 2);
return (
<Collapsible className="rounded-md border border-border">
<CollapsibleTrigger className="group flex w-full items-center justify-between gap-2 px-3 py-2.5 text-sm font-semibold">
<span className="flex items-center gap-2">
Query debug
<Badge variant="secondary">v{card.queryVersion}</Badge>
</span>
<ChevronDown className="h-4 w-4 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" />
</CollapsibleTrigger>
<CollapsibleContent className="space-y-2 border-t border-border px-3 py-3">
<pre className="max-h-64 overflow-auto rounded-md bg-muted p-3 font-mono text-xs text-foreground">
{card.queries.length > 0 ? queryJson : "// query not compiled yet"}
</pre>
<p className="text-xs text-muted-foreground">
{card.queryCompiledAt
? `Compiled by Summarizer ${relativeTime(card.queryCompiledAt)} · version ${card.queryVersion}. Edit “What this card watches” above to rebuild it.`
: "Not compiled yet. The query builds automatically once the card finishes setting up."}
</p>
</CollapsibleContent>
</Collapsible>
);
}
/**
* Live matched-issue list for the Watched tab, fed by the dry-run endpoint.
* Queries in the compiled array are a union, so issues matched by more than
* one query are deduplicated by id. Issues mentioned in the latest summary
* join the watched set too and render as their own group below the matches.
*/
function MatchedIssueList({ queries, mentioned }: { queries: StatusCardDryRun["queries"]; mentioned: CompanySearchIssueSummary[] }) {
const seen = new Set<string>();
const matched: CompanySearchIssueSummary[] = [];
for (const { result } of queries) {
for (const item of result.results) {
if (!item.issue || seen.has(item.issue.id)) continue;
seen.add(item.issue.id);
matched.push(item.issue);
}
}
const mentionedOnly = mentioned.filter((issue) => !seen.has(issue.id));
if (matched.length === 0 && mentionedOnly.length === 0) {
return (
<div className="rounded-md border border-dashed border-border px-3 py-4 text-sm text-muted-foreground">
The compiled query matches no issues right now.
</div>
);
}
return (
<div className="space-y-3">
{matched.length > 0 ? (
<div className="space-y-1.5">
{matched.map((issue) => (
<WatchedIssueRow key={issue.id} issue={issue} />
))}
</div>
) : null}
{mentionedOnly.length > 0 ? (
<div className="space-y-1.5">
<p className="text-xs font-medium text-muted-foreground">Mentioned in the latest update</p>
{mentionedOnly.map((issue) => (
<WatchedIssueRow key={issue.id} issue={issue} />
))}
</div>
) : null}
</div>
);
}
function WatchedIssueRow({ issue }: { issue: CompanySearchIssueSummary }) {
return (
<div className="flex items-center gap-2 rounded-md border border-border px-3 py-2 text-xs">
<Link
to={`/issues/${issue.identifier ?? issue.id}`}
className="shrink-0 font-medium text-muted-foreground underline-offset-2 hover:text-foreground hover:underline"
>
{issue.identifier ?? issue.id.slice(0, 8)}
</Link>
<IssueStatusBadge {...taskStatusIconProps(issue)} />
<span className="min-w-0 flex-1 truncate">{issue.title}</span>
<span className="shrink-0 text-muted-foreground">{relativeTime(issue.updatedAt)}</span>
</div>
);
}
/**
* One row in the "Integrated in this update" change list. Status transitions
* render with the product's issue status pills (recognition over recall,
* design-system consistency) and every row deep-links to the issue.
*/
function ChangeRow({ change }: { change: StatusCardUpdate["changes"][number] }) {
const isTransition = Boolean(change.from && change.to);
return (
<div className="flex items-center gap-2 rounded-md border border-border px-3 py-2 text-xs">
<Link
to={`/issues/${change.identifier}`}
className="shrink-0 font-medium text-muted-foreground underline-offset-2 hover:text-foreground hover:underline"
>
{change.identifier}
</Link>
{isTransition ? (
<span className="flex min-w-0 items-center gap-1.5">
<IssueStatusBadge status={change.from!} />
<span aria-hidden="true" className="text-muted-foreground">→</span>
<IssueStatusBadge status={change.to!} />
</span>
) : (
<span className="truncate text-muted-foreground">{describeChangeKind(change.changeKind)}</span>
)}
</div>
);
}
function describeChangeKind(changeKind: string): string {
if (changeKind === "entered_query" || changeKind === "new") return "new issue matched the query";
if (changeKind === "left_query") return "left the query";
return changeKind.replace(/_/g, " ");
}