mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-10 00:56:52 +01:00
## 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` at03eb49053f. ## 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) | | --- | --- | |  |  | Storybook "Same state, every view" (Foundations/Status Language). Before uses the `master` `StatusIcon`/`StatusBadge`; after uses this branch. | Before (master) | After (this PR) | | --- | --- | |  |  | 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 (commits1d3a1a0a9e,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>
628 lines
29 KiB
TypeScript
628 lines
29 KiB
TypeScript
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, " ");
|
||
}
|