design(decisions): flatten decision cards to two task-borrowed types (#10474)
The Decisions queue ran five parallel colour/icon vocabularies chosen by source kind, plus a separate severity badge, so two rows needing the same response could look unrelated and none of it matched the task list. Every row now resolves to one of two kinds, each borrowing the task status it corresponds to: blocking renders as `blocked`, review as `in_review`, both through StatusGlyph and the existing --status-task-icon-* tokens. Source kinds keep their own wording; only colour and icon merge. Card anatomy follows the design mock: no left accent rail, rounded cards 16px apart, a "/"-separated meta breadcrumb, a named See more / See less control, and no separately tinted drawer when expanded. Verb order is fixed across both states. Severity moves from chrome to a toolbar filter. Four defects fixed along the way: - blocked rows reported themselves as their own blocker (server-side) - the task key was missing wherever the row's subject IS the task - the task quicklook stuck open, because closing handed focus back to a trigger that opens on focus - the card ring appeared on click, and only on cards with a toggle Also: the standard task preview is aligned to its trigger's text and scales out of it, the task eyebrow renders its project as a tile, and the first motion tokens land alongside the disclosure and crossfade. Supersedes #9574 and #9575. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
c0b875c46c
commit
492555aaf9
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"version": "0.0.1",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "paperclip",
|
||||
"runtimeExecutable": "/bin/sh",
|
||||
"runtimeArgs": ["-c", "TMPDIR=/tmp pnpm dev"],
|
||||
"port": 3108,
|
||||
"autoPort": false
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -99,3 +99,126 @@ Executed on `design/component-convergence` (worktree focused-agnesi). Scope C2/C
|
|||
## Tune session — CLOSED (Jul 6, 2026)
|
||||
|
||||
User approved the complete new design language via gallery v4 + live test drive on the :3300 worktree instance ("ship it"). Merged origin/master (12 commits; one conflict — upstream deliberately removed the Wakes-on-confirm chip, deletion accepted). 296 snapshots re-baselined; gates 3/3 CLEAN; typecheck green; final suite verification run against the new baseline. Remaining roadmap: Run 3 (cards/pills/C11 sidebar + investigations + AgentDetail story), issue→task rename run, Run 4 (palette classes + toast), ESLint ratchet.
|
||||
|
||||
## Decision cards flattened to two types (design session, Jul 29 2026)
|
||||
|
||||
**Five colour/icon vocabularies collapse to two, borrowed from the task status system.** User feedback on `/decisions`: the card types carried "several visual and categorizing inconsistencies". Figma reference — current `1148-1253`, proposed `1148-2169` (PCLP-Core). Every row now resolves to `blocking` (failed run, agent error, blocked dependency, recovery, budget) or `review` (approval, confirmation, review, join request), and each borrows a task status rather than declaring its own palette: blocking → `blocked` (red `CircleMinus`), review → `in_review` (violet `CircleDot`), rendered through `<StatusGlyph>` off `--status-task-icon-*`. `attentionTone`/`attentionToneStyle`/`TONE_STYLE` and the per-source `SourceMeta.icon` are deleted; source kinds keep their own *wording* only. Zero new tokens — the point is that the queue and the task list now share one vocabulary by construction (principle 5).
|
||||
|
||||
Also in the same change, per the proposed mock: the 4px left accent rail is gone (colour lives in the glyph); rows became `rounded-xl` cards spaced 16px apart; issue key + project moved up into a `/`-separated meta breadcrumb; the expand affordance became a bottom-left "See more"/"See less" button; and the expanded state lost its separately tinted/bordered drawer — note, gallery and resolver now flow in the card's own column.
|
||||
|
||||
Three deliberate deviations from the mock, each flagged to the user:
|
||||
|
||||
- **The card border stays.** The mock drops it, which reads correctly in dark mode (`--card` 0.205 on `--background` 0.145) but is fatal in light mode, where both tokens are `oklch(1 0 0)` — a borderless card would be invisible white-on-white. Kept `border border-border`; in dark mode it is `oklch(1 0 0 / 10%)` and barely perceptible, so the intended look survives.
|
||||
- **Radius is `rounded-xl` (11.2px), not the mock's literal 12px.** 12 is off the multiplicative ladder codified Jul 8; minting a one-off token for a 0.8px delta would reopen B4 for nothing.
|
||||
- **`IssueThreadInteractionCard` keeps its action bar internally.** The mock hoists those buttons out into the card footer beside "See less". That component is shared with the issue-thread surface, so hoisting would silently restyle the chat thread too — out of scope for a decisions-card change, and a separate call.
|
||||
|
||||
**Severity is no longer chrome.** The Critical/High badge (`severityBadge`, deleted) was a third colour vocabulary competing with the type colour — an orange HIGH chip next to a red error icon was exactly the reported inconsistency. Severity survives as a filter/group dimension in the toolbar, so nothing is lost, only relocated. `severityStyle` is left in place (dead but pre-existing; not this change's scope).
|
||||
|
||||
**Verb order is now fixed across states.** Collapsed and expanded rows both order verbs outline → destructive → affirmative, right-aligned, so the affirmative button sits in the same place whether or not a row is expanded (previously collapsed rows ran Approve/Reject/Request revision left-to-right and expanded rows ran Approve/Request revision/Reject, left-aligned). Per-row training moved from a header icon button into the row's overflow menu, matching the mock's header (recency + overflow only); the inline "Trained ✓" badge stays and remains the tested `onTrain` path, since Radix menu items are portal-mounted and this repo does not open them in jsdom.
|
||||
|
||||
## Decision-card follow-ups: task keys, self-blocking, stuck quicklook (design session, Jul 29 2026)
|
||||
|
||||
Three defects surfaced by the flattened cards, each fixed at its own layer.
|
||||
|
||||
**Task key missing on the rows most obviously about a task.** The meta breadcrumb read only `relatedIssue`, but the feed stores the task in two shapes: when the subject IS the task (review, blocked dependency) the identifier sits on `subject` and `relatedIssue` is null; when the subject hangs off a task (thread interaction) the task arrives as `relatedIssue`. `attentionTaskRef` (ui/src/lib/attention.ts) resolves both with one rule, preferring `relatedIssue` when both exist — it is the record the subject alone cannot describe — and returning null for rows genuinely unattached (hire approval, agent error) so they stay blank rather than borrowing a key. 1 of 17 seeded rows → 13. Still open, needs a server change: an approval can carry `subject.metadata.issueId` while `relatedIssue` is null, which reaches the client as a bare UUID with no key or href.
|
||||
|
||||
**Every blocked row claimed it was blocked by itself.** Both `blocker_attention` call sites in server/src/services/attention.ts fell back to the blocked task's own identity when no `blocks` relation was loaded — one hardcoded `{ id: issue.id, identifier: issue.identifier }` outright — so the UI rendered "PAP-23 — Blocked by PAP-23" for all eleven seeded rows. `resolveBlockingIssue` prefers the loaded relation, then a blockerAttention sample identifier, then null (the row falls back to its `whyNow` line, which is honest about not knowing). It also rejects a self-referential relation row as corrupt. The dedup key deliberately keeps its original fallback chain including the issue's own identifier: it is the identity dismissals are recorded against, and narrowing it would resurrect dismissed rows.
|
||||
|
||||
**Quicklook stuck open after expanding a row.** Reported as "the hover task card gets stuck and keeps displaying even when I hover off". Root cause is a self-sustaining loop in the shared `IssueLinkQuicklook`, not in the decision card: Radix returns focus to the trigger when a popover closes, and that link opens the quicklook `onFocus` — so every dismissal refocused the trigger, which reopened the card. Fixed by declining the focus hand-back (`onCloseAutoFocus` prevented, symmetric with the existing `onOpenAutoFocus`): a preview must not move focus in either direction. Added alongside it, a pointer-escape guard that closes on any pointer move clear of both boxes, since the only other close paths were `mouseleave` on trigger/content and no leave fires when the layout shifts an element out from under a stationary pointer. The guard needs both `:hover` and geometry to agree the pointer is gone before closing, so a resting pointer is never dropped, and exempts focus-opened quicklooks for keyboard users.
|
||||
|
||||
Separately, evidence thumbnails in an expanded card no longer carry a task quicklook at all (`disableIssueQuicklook`): `Link` upgrades any /issues/ href into a hover preview, which here popped a text card over the very screenshot being examined, and expanding a row mounts that gallery directly under the pointer.
|
||||
|
||||
## Card-level selection ring is keyboard-only (design session, Jul 29 2026)
|
||||
|
||||
User: "it seems weird that only cards with see more/less have a focus state and not the rest… disable focus state for decision cards but retain the focus state for each interactive component (within cards) for accessibility purposes."
|
||||
|
||||
The card-wide stroke was never a focus state — it is the **keyboard cursor**, marking the row that j/k, e, x and s act on. It leaked into mouse use because `handleToggleExpand` set the selection as a side effect of a click, and only expandable rows have a See more/less toggle to click. Hence the reported inconsistency: clicking one kind of card ringed it, and no other card could ever be ringed.
|
||||
|
||||
Fixed by tracking how the selection was made and drawing the ring only for a keyboard-driven one. Clicking still sets the selection, so keyboard actions continue to target the row you just used — it simply draws nothing.
|
||||
|
||||
**The ring is deliberately kept for j/k navigation** rather than removed outright, which the literal request would imply. Those keys dismiss and snooze the selected row; with no indicator an operator would be firing destructive actions at an invisible target. Flagged to the user as the one place the card-level state survives, and it is theirs to remove if they want it gone there too.
|
||||
|
||||
Focus states on everything inside a card are untouched: the See more/less toggle, decision verbs, the task key, the project link, evidence thumbnails and the row menu all keep their `focus-visible` rings.
|
||||
|
||||
## Decision card eyebrow: project dropped, "·" separator (design session, Jul 29 2026)
|
||||
|
||||
Per the proposed mock, the decision card eyebrow is now **decision kind · task key** and nothing else.
|
||||
|
||||
**Project identity left the card.** It cost the eyebrow's width on every row to repeat a fact the operator has usually just chosen — the queue filters and groups by project from the toolbar — and it competed with the task key, which is the identifier an operator actually navigates by. The project is still one click away on the task itself.
|
||||
|
||||
**The separator changed from "/" to "·".** The eyebrow started as a breadcrumb (kind / key / project), but with the project gone it is a flat list of two facts, not a hierarchy. A slash implies containment those two segments do not have; a middle dot just separates. `ProjectMeta` and its `ProjectTile` import were deleted from the row rather than left unused.
|
||||
|
||||
## Standard task preview card (design session, Jul 29 2026)
|
||||
|
||||
**`IssueQuicklookCard` restructured to the proposed mock, and this is now the app-wide standard** — every hover preview of a task renders it, so the same three rows appear in the same order everywhere:
|
||||
|
||||
1. meta — status glyph · task key [· project] …………… last activity
|
||||
2. title
|
||||
3. summary — first lines of the description
|
||||
|
||||
The meta row splits: identity left, recency pinned right. Identity leads because a preview answers "which task is this?", and a title alone does not. The status glyph switches from `StatusIcon` to `StatusGlyph`, so a preview speaks the same status vocabulary as the flattened decision cards and the task list.
|
||||
|
||||
**Status carries no word of its own.** An earlier revision of this card gave status a line under the title ("In review · 1d ago", in `foreground`); the final mock removes it and moves the timestamp up into the meta row, leaving the glyph to be the status — which is what the glyph already is on task rows and decision cards. That leaves shape and colour as the only visual signal, so the glyph is passed a `title`, rendering as `role="img"` with the status as its accessible name. The status stays available to a screen reader without spending a line, and a test pins that (the glyph must carry it and the visible text must not).
|
||||
|
||||
Three shapes the meta row holds, all specified by the mock:
|
||||
|
||||
1. **no project** — glyph, key, timestamp hard right; no separator is rendered
|
||||
2. **project** — a "·", the tile and the name join the left group
|
||||
3. **truncation** — a long project name ellipsizes; the key and the timestamp are `shrink-0`, so the two facts that identify the task survive at any width. Verified live: with a 47-character project name the key and timestamp hold their exact widths (39.7px / 36.1px) and only the name clips.
|
||||
|
||||
Two judgment calls:
|
||||
|
||||
- **The project tile is untinted.** `ProjectTile` supports a colour, and the decision card's old chip used it, but a preview is a quiet surface and the project colour would be the loudest thing on it. The mock shows a neutral tile, and `IssueAncestorProject` carries neither colour nor icon — so following the mock costs nothing and needs no new data. If the tile should ever tint, that is a server-side field addition first.
|
||||
- **11px via `--text-micro`** for the meta row, matching the mock, rather than minting a token for the mock's literal values.
|
||||
|
||||
The other consumer, `IssuesQuicklook` (project workspace linked issues), inherits the new card automatically — which is the point of standardising it.
|
||||
|
||||
## Quicklook aligns to the trigger's text, not its box (design session, Jul 29 2026)
|
||||
|
||||
Radix aligns box to box, so `align="start"` put the preview's *left edge* on the trigger's left edge — leaving the card's text pushed right by the card's own border and padding, and visibly out of line with the task key that opened it.
|
||||
|
||||
`quicklookAlignOffset()` cancels that inset: **13px** — `p-3` (12px) plus the 1px border `PopoverContent` draws. Measured on the live card afterwards, the trigger's text sits at 353.61px and the card's glyph, title and description all sit at 353.50px — a 0.11px residual from the trigger's own sub-pixel position, i.e. aligned.
|
||||
|
||||
The offset follows the align prop (`start` negative, `end` the mirror, `center` zero) rather than being hardcoded to one direction, and both surfaces that render the standard card — `IssueLinkQuicklook` and `IssuesQuicklook` — now share `QUICKLOOK_CONTENT_CLASS` and this helper, so the preview is positioned identically wherever it opens.
|
||||
|
||||
The 13px is a derived constant with the border and padding written out as `12 + 1`, and a test asserts the shell still carries `p-3`, so the two cannot drift apart silently.
|
||||
|
||||
## First motion tokens, and the inert animate-in finding (design session, Jul 29 2026)
|
||||
|
||||
**Motion tokens minted.** Durations and easings were previously written inline at each call site in `index.css`. Four named values now exist, and both new animations consume them:
|
||||
|
||||
- `--motion-duration-enter: 160ms` / `--motion-duration-exit: 110ms` — exit is deliberately shorter: a thing appearing wants to be followed, a thing leaving just needs to get out of the way.
|
||||
- `--motion-ease-out: cubic-bezier(0.16, 1, 0.3, 1)` — the curve the dialog max-width transition already used, promoted to the system. It decelerates hard at the end, which is what reads as "snappy" rather than "slow start".
|
||||
- `--motion-ease-in: cubic-bezier(0.4, 0, 1, 1)` for exits.
|
||||
|
||||
**Decision-card disclosure.** See more / See less now animates height through Radix `Collapsible`, which measures the panel and publishes `--radix-collapsible-content-height`, so the card grows and shrinks to a real number instead of snapping. Measured on the live card: 0 → 65 → 98 → 114 → 121 → 125 → 128px over ~136ms. The Root carries `contents` so a collapsed row pays no flex gap for an empty wrapper, and Radix keeps the panel an empty `hidden` shell when closed — verified all 17 closed panels hold zero children, so no row runs a resolver behind a collapsed card.
|
||||
|
||||
**`animate-in` is dead CSS in this repo.** Chasing the quicklook's scale turned up that the shadcn `PopoverContent` class string (`animate-in`, `zoom-in-95`, `fade-in-0`, `slide-in-from-*`) resolves to nothing: those utilities ship with the `tailwindcss-animate` plugin, which is not a dependency and is not imported in `index.css`. A stylesheet scan found no `enter`/`exit` keyframes in the build. **This affects every shadcn surface in the app** — dialogs, dropdowns, tooltips, sheets all carry the same inert classes and have never animated.
|
||||
|
||||
Rather than add the plugin for one surface — which would newly animate every one of those surfaces at once, an app-wide visual change nobody has reviewed — the quicklook defines its own `quicklook-open` / `quicklook-close` keyframes. Adopting the plugin properly is worth its own run.
|
||||
|
||||
**Quicklook motion.** A shallow scale (0.96 → 1) plus opacity, anchored to `--radix-popover-content-transform-origin` so the card grows out of the task key that opened it rather than swelling in place. Verified live: `quicklook-open`, running, 160ms, ease-out, origin `0px 0px`.
|
||||
|
||||
Both animations are disabled under `prefers-reduced-motion: reduce`.
|
||||
|
||||
## Task eyebrow project reads as tile + name (design session, Jul 29 2026)
|
||||
|
||||
The task detail eyebrow showed a bare `Hexagon` outline glyph next to the project name — a shape used nowhere else for projects. It now renders `ProjectTile` at `xs`, matching the sidebar and Projects list.
|
||||
|
||||
Measured against the mock, every value matches and all of it resolves through tokens: 16×16 tile, 4.8px radius, `bg-muted` (`oklch(0.269 0 0)`, the mock's `#313131`), 10px folder icon, 4px gap, 2px/4px padding, 4px link radius, 12px `text-muted-foreground` (the mock's `#a1a1a1`).
|
||||
|
||||
**The tile stays neutral rather than taking the project colour**, which `ProjectTile` would do if passed one. The eyebrow already carries the status glyph's colour, and a second tinted swatch beside it competes with the one mark that means something. Project colour still identifies the project on project-native surfaces. This matches the direction #9574 took for the Decisions feed.
|
||||
|
||||
The seeded header (rendered from `headerSeed` while the issue loads) was updated in lockstep, so the eyebrow does not change shape when the real issue arrives.
|
||||
|
||||
## Collapsed-only content crossfades against the panel (design session, Jul 29 2026)
|
||||
|
||||
Adding the disclosure animation left a seam: the panel grew smoothly, but the content it *replaces* still popped out of existence in one frame. Two things never carry across the two states — the thumbnail strip, whose counterpart is the full gallery; and an inline row's footer, which the resolver takes over once expanded.
|
||||
|
||||
Both now ride an **inverse disclosure** (`open={!expanded}`) using the same keyframes and tokens as the panel, so the collapsed cluster shrinks and fades out while the panel grows and fades in. Verified live during a toggle: `decision-disclosure-close` running at 110ms on the cluster and `decision-disclosure-open` running at 160ms on the panel, in the same frame.
|
||||
|
||||
Exit being shorter than enter is what makes it read as a handoff rather than a blend — the outgoing content clears slightly ahead of the incoming.
|
||||
|
||||
**Only genuine swaps crossfade.** A non-inline row keeps one standing footer: its Open or Restore button and its toggle are the same control in both states, so it stays put rather than crossfading with itself. The inverse cluster is skipped entirely when a row has neither images nor an inline resolver (`hasCollapsedOnlyContent`), because an always-open empty wrapper would otherwise charge the card a 16px flex gap for nothing — the closed panel avoids this for free, since Radix marks it `hidden` and it drops out of flex layout.
|
||||
|
||||
`renderFooter({ compact })` renders the bar in either position. `compact` is false for the standing copy, so an expanded non-inline row does not show collapsed verbs beside the panel's own.
|
||||
|
|
|
|||
|
|
@ -927,6 +927,79 @@ describeEmbeddedPostgres("attention service", () => {
|
|||
expect(feed.items.some((item) => item.dedupKey === `blocker:${issueId}:ATP-1`)).toBe(true);
|
||||
});
|
||||
|
||||
// Regression: both blocker_attention call sites fell back to the blocked
|
||||
// task's own identity when no `blocks` relation was loaded, so every such row
|
||||
// claimed the task was blocked by itself ("PAP-23 — Blocked by PAP-23").
|
||||
it("reports no blocking task rather than a self-reference when the blocker is unknown", async () => {
|
||||
const { companyId } = await seedCompany("ATV");
|
||||
const issueId = await insertIssue({
|
||||
companyId,
|
||||
identifier: "ATV-1",
|
||||
title: "Blocked with no relation",
|
||||
status: "blocked",
|
||||
blockedTransitionAt: new Date(ROUTABLE_BLOCKED_ROLLOUT_AT.getTime() - 1),
|
||||
});
|
||||
|
||||
const feed = await attentionService(db).list(companyId, { userId: "board-user" });
|
||||
const row = feed.items.find((item) => item.dedupKey === `blocker:${issueId}:ATV-1`);
|
||||
|
||||
expect(row).toBeTruthy();
|
||||
expect(row?.detail).toMatchObject({ kind: "blocker", blockingIssue: null });
|
||||
// The dedup key keeps its original fallback so existing dismissals survive.
|
||||
expect(row?.dismissalKey).toBe(`attention:blocker:${issueId}:ATV-1`);
|
||||
});
|
||||
|
||||
it("names the real blocking task when a blocks relation exists", async () => {
|
||||
const { companyId } = await seedCompany("ATW");
|
||||
const blockedId = await insertIssue({
|
||||
companyId,
|
||||
identifier: "ATW-1",
|
||||
title: "Blocked parent",
|
||||
status: "blocked",
|
||||
blockedTransitionAt: new Date(ROUTABLE_BLOCKED_ROLLOUT_AT.getTime() - 1),
|
||||
});
|
||||
const blockerId = await insertIssue({
|
||||
companyId,
|
||||
identifier: "ATW-2",
|
||||
title: "The actual blocker",
|
||||
status: "in_progress",
|
||||
});
|
||||
await db.insert(issueRelations).values({
|
||||
companyId,
|
||||
issueId: blockerId,
|
||||
relatedIssueId: blockedId,
|
||||
type: "blocks",
|
||||
});
|
||||
|
||||
const feed = await attentionService(db).list(companyId, { userId: "board-user" });
|
||||
const row = feed.items.find((item) => item.sourceKind === "blocker_attention" && item.subject.id === blockedId);
|
||||
|
||||
expect(row?.detail).toMatchObject({
|
||||
kind: "blocker",
|
||||
blockingIssue: { identifier: "ATW-2", title: "The actual blocker" },
|
||||
});
|
||||
});
|
||||
|
||||
it("does not name the blocked task as its own blocker on a human-owned unblock row", async () => {
|
||||
const { companyId } = await seedCompany("ATX");
|
||||
const transitionAt = new Date("2026-07-23T18:30:00.000Z");
|
||||
const issueId = await insertIssue({
|
||||
companyId,
|
||||
identifier: "ATX-1",
|
||||
title: "Needs board action",
|
||||
status: "blocked",
|
||||
unblockDescriptor: { owner: "board", action: "Approve the exception" },
|
||||
blockedTransitionAt: transitionAt,
|
||||
});
|
||||
|
||||
const feed = await attentionService(db).list(companyId, { userId: "board-user" });
|
||||
const row = feed.items.find(
|
||||
(item) => item.dedupKey === `blocked-owner:${issueId}:${transitionAt.toISOString()}`,
|
||||
);
|
||||
|
||||
expect(row?.detail).toMatchObject({ kind: "blocker", blockingIssue: null });
|
||||
});
|
||||
|
||||
it("does not route pre-rollout human unblock descriptors", async () => {
|
||||
const { companyId } = await seedCompany("ATQ");
|
||||
const transitionAt = new Date(ROUTABLE_BLOCKED_ROLLOUT_AT.getTime() - 1);
|
||||
|
|
|
|||
|
|
@ -576,6 +576,31 @@ async function blockingIssueMap(db: Db, companyId: string, blockedIssueIds: Arra
|
|||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* The task that blocks `issue` — never `issue` itself.
|
||||
*
|
||||
* Both blocker_attention call sites used to fall back to the blocked task's own
|
||||
* identity when no `blocks` relation was loaded, so every such row reported
|
||||
* "PAP-23 — Blocked by PAP-23". The UI renders that as a real dependency, which
|
||||
* tells an operator nothing and reads as a bug.
|
||||
*
|
||||
* Order: the loaded `blocks` relation, then an identifier sampled by
|
||||
* blockerAttention, and otherwise nothing — a null lets the row fall back to
|
||||
* its `whyNow` line, which is honest about not knowing the blocker.
|
||||
*/
|
||||
function resolveBlockingIssue(
|
||||
issue: { id: string; identifier: string | null },
|
||||
fromRelation: BlockingIssueSummary | undefined,
|
||||
sampledIdentifier?: string | null,
|
||||
): BlockingIssueSummary | null {
|
||||
// A self-referential relation row would be corrupt data; treat it as unknown.
|
||||
if (fromRelation && fromRelation.id !== issue.id) return fromRelation;
|
||||
if (sampledIdentifier && sampledIdentifier !== issue.identifier && sampledIdentifier !== issue.id) {
|
||||
return { id: null, identifier: sampledIdentifier, title: null };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function readRunIssueId(contextSnapshot: Record<string, unknown> | null) {
|
||||
const issueId = contextSnapshot?.issueId ?? contextSnapshot?.taskId;
|
||||
return typeof issueId === "string" && issueId.length > 0 ? issueId : null;
|
||||
|
|
@ -948,15 +973,23 @@ export function attentionService(db: Db) {
|
|||
updatedAt: toIso(issue.updatedAt),
|
||||
relatedIssue: null,
|
||||
...issueContext(issueSummary),
|
||||
detail: { kind: "blocker", blockingIssue: { id: issue.id, identifier: issue.identifier, title: issue.title }, images: issueImages(blockedImageMap, issue.id) },
|
||||
detail: {
|
||||
kind: "blocker",
|
||||
blockingIssue: resolveBlockingIssue(issue, blockingIssues.get(issue.id)),
|
||||
images: issueImages(blockedImageMap, issue.id),
|
||||
},
|
||||
}));
|
||||
}
|
||||
const blockerAttention = issue.blockerAttention;
|
||||
if (blockerAttention?.state !== "stalled" && blockerAttention?.state !== "needs_attention") continue;
|
||||
const issueSummary = blockedIssueSummaries.get(issue.id) ?? null;
|
||||
const summarizedIssue = issueSummary ?? issue;
|
||||
const sample = blockerAttention.sampleStalledBlockerIdentifier ?? blockerAttention.sampleBlockerIdentifier ?? issue.identifier ?? issue.id;
|
||||
const blockingIssue = blockingIssues.get(issue.id) ?? { id: null, identifier: sample, title: null };
|
||||
const sampledBlocker = blockerAttention.sampleStalledBlockerIdentifier ?? blockerAttention.sampleBlockerIdentifier;
|
||||
const blockingIssue = resolveBlockingIssue(issue, blockingIssues.get(issue.id), sampledBlocker);
|
||||
// The dedup key keeps its original fallback chain (including the issue's
|
||||
// own identifier) on purpose: it is the stable identity a dismissal is
|
||||
// recorded against, so narrowing it would resurrect dismissed rows.
|
||||
const sample = sampledBlocker ?? issue.identifier ?? issue.id;
|
||||
const dedupKey = `blocker:${issue.id}:${sample}`;
|
||||
add(createItem({
|
||||
companyId,
|
||||
|
|
|
|||
|
|
@ -238,7 +238,11 @@ describe("AttentionQueueRow", () => {
|
|||
expect(links.some((a) => a.textContent?.includes("Hire agent: Research Analyst"))).toBe(false);
|
||||
});
|
||||
|
||||
it("renders project identity once without a filter button", () => {
|
||||
// The eyebrow carries the decision kind and the task key only. Project
|
||||
// identity was removed from the card: the queue is filtered and grouped by
|
||||
// project from the toolbar, so repeating it on every row spent the eyebrow's
|
||||
// width on a fact the operator had usually just chosen.
|
||||
it("keeps project identity off the card", () => {
|
||||
render(
|
||||
<AttentionQueueRow
|
||||
item={buildItem({
|
||||
|
|
@ -251,13 +255,80 @@ describe("AttentionQueueRow", () => {
|
|||
/>,
|
||||
);
|
||||
|
||||
const projectMeta = container?.querySelector('[data-testid="attention-project-meta"]');
|
||||
expect(projectMeta?.textContent).toBe("Alpha");
|
||||
expect(projectMeta?.querySelector("button")).toBeNull();
|
||||
expect(projectMeta?.getAttribute("class")).not.toContain("border");
|
||||
expect(projectMeta?.getAttribute("class")).not.toContain("bg-");
|
||||
expect(container?.querySelector('button[title="Filter by Alpha"]')).toBeNull();
|
||||
expect(container?.textContent?.match(/Alpha/g)).toHaveLength(1);
|
||||
expect(container?.querySelector('[data-testid="attention-project-meta"]')).toBeNull();
|
||||
expect(container?.textContent).not.toContain("Alpha");
|
||||
});
|
||||
|
||||
it("separates eyebrow facts with a middle dot, not a slash", () => {
|
||||
render(
|
||||
<AttentionQueueRow
|
||||
item={buildItem({
|
||||
sourceKind: "blocker_attention",
|
||||
subject: {
|
||||
kind: "issue",
|
||||
id: "i1",
|
||||
companyId: "c1",
|
||||
title: "Update primary paperclip instance",
|
||||
identifier: "PAP-23",
|
||||
status: "blocked",
|
||||
href: "/PAP/issues/PAP-23",
|
||||
},
|
||||
relatedIssue: null,
|
||||
})}
|
||||
companyId="c1"
|
||||
expanded={false}
|
||||
onToggleExpand={noop}
|
||||
onDismiss={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
const eyebrow = container?.querySelector('[data-attention-row] > div');
|
||||
expect(eyebrow?.textContent).toContain("·");
|
||||
expect(eyebrow?.textContent).not.toContain("/");
|
||||
});
|
||||
|
||||
// Regression: the meta breadcrumb used to read only `relatedIssue`, so rows
|
||||
// whose subject IS the task (reviews, blocked dependencies) showed no key at
|
||||
// all — the rows most obviously about a task were the ones missing it.
|
||||
it("shows the task key when the subject is the task itself", () => {
|
||||
render(
|
||||
<AttentionQueueRow
|
||||
item={buildItem({
|
||||
sourceKind: "blocker_attention",
|
||||
subject: {
|
||||
kind: "issue",
|
||||
id: "i1",
|
||||
companyId: "c1",
|
||||
title: "Update primary paperclip instance",
|
||||
identifier: "PAP-23",
|
||||
status: "blocked",
|
||||
href: "/PAP/issues/PAP-23",
|
||||
},
|
||||
relatedIssue: null,
|
||||
})}
|
||||
companyId="c1"
|
||||
expanded={false}
|
||||
onToggleExpand={noop}
|
||||
onDismiss={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
const link = Array.from(container?.querySelectorAll("a") ?? []).find((a) => a.textContent === "PAP-23");
|
||||
expect(link).toBeTruthy();
|
||||
expect(link?.getAttribute("href")).toBe("/PAP/issues/PAP-23");
|
||||
});
|
||||
|
||||
it("shows no task key on a row that is not attached to a task", () => {
|
||||
render(
|
||||
<AttentionQueueRow
|
||||
item={buildItem({ relatedIssue: null })}
|
||||
companyId="c1"
|
||||
expanded={false}
|
||||
onToggleExpand={noop}
|
||||
onDismiss={noop}
|
||||
/>,
|
||||
);
|
||||
expect(container?.textContent).not.toMatch(/PAP-\d+/);
|
||||
});
|
||||
|
||||
it("places the timestamp beside the row menu without a clock icon", () => {
|
||||
|
|
@ -277,7 +348,10 @@ describe("AttentionQueueRow", () => {
|
|||
expect(container?.querySelector("svg.lucide-clock")).toBeNull();
|
||||
});
|
||||
|
||||
it("uses square row edges and can show a keyboard selection ring", () => {
|
||||
// Rows became rounded cards when the decision types were flattened: with the
|
||||
// left accent rail gone, the card's own shape carries the separation that the
|
||||
// rail used to, so square edges no longer read as deliberate.
|
||||
it("uses rounded card edges and can show a keyboard selection ring", () => {
|
||||
render(
|
||||
<AttentionQueueRow
|
||||
item={buildItem()}
|
||||
|
|
@ -290,7 +364,7 @@ describe("AttentionQueueRow", () => {
|
|||
);
|
||||
|
||||
const row = container?.querySelector("[data-attention-row]");
|
||||
expect(row?.getAttribute("class")).not.toContain("rounded");
|
||||
expect(row?.getAttribute("class")).toContain("rounded-xl");
|
||||
expect(row?.getAttribute("class")).toContain("ring-ring");
|
||||
});
|
||||
|
||||
|
|
@ -318,11 +392,12 @@ describe("AttentionQueueRow", () => {
|
|||
expect(decisionActions?.textContent).toContain("Approve");
|
||||
expect(decisionActions?.textContent).toContain("Reject");
|
||||
|
||||
// The action bar is its own full-width band (mobile-first) that collapses to
|
||||
// a right-aligned pill row once the row's container is wide (container query)
|
||||
// — no longer a stretched right column.
|
||||
// The footer splits the row's last line: disclosure on the left, decision
|
||||
// verbs on the right, so the affirmative verb sits in the same place in
|
||||
// every row whether it is collapsed or expanded.
|
||||
const actionArea = decisionActions?.closest('[data-attention-actions="true"]');
|
||||
expect(actionArea?.getAttribute("class")).toContain("@xl:justify-end");
|
||||
expect(actionArea?.getAttribute("class")).toContain("justify-between");
|
||||
expect(decisionActions?.parentElement?.getAttribute("class")).toContain("@xl:justify-end");
|
||||
|
||||
const rowMenu = container?.querySelector('[aria-label="Row actions"]');
|
||||
expect(rowMenu?.closest('[data-attention-menu="true"]')).toBeTruthy();
|
||||
|
|
@ -442,7 +517,10 @@ describe("AttentionQueueRow", () => {
|
|||
expect(issuesApi.rejectInteraction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("renders evidence thumbnails in a centered context row below the text stack", () => {
|
||||
// The old context row bundled project identity + thumbnails together; project
|
||||
// identity has since moved up into the meta breadcrumb, so evidence is now a
|
||||
// block of the row's own column rather than a shared strip.
|
||||
it("renders evidence thumbnails as their own block below the text stack", () => {
|
||||
render(
|
||||
<AttentionQueueRow
|
||||
item={buildItem({
|
||||
|
|
@ -464,7 +542,12 @@ describe("AttentionQueueRow", () => {
|
|||
|
||||
const thumbnailStack = image?.parentElement?.parentElement;
|
||||
expect(thumbnailStack?.getAttribute("class")).toContain("items-center");
|
||||
expect(thumbnailStack?.parentElement?.getAttribute("class")).toContain("items-center");
|
||||
// The strip is collapsed-only content: it rides the inverse disclosure so
|
||||
// it can fade out as the expanded gallery fades in, rather than popping.
|
||||
const cluster = thumbnailStack?.closest("[data-decision-disclosure]");
|
||||
expect(cluster).toBeTruthy();
|
||||
expect(cluster?.getAttribute("data-state")).toBe("open");
|
||||
expect(cluster?.closest("[data-attention-row]")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("is memoized — a parent re-render with identical props does not re-render the row", async () => {
|
||||
|
|
@ -636,8 +719,12 @@ describe("AttentionQueueRow", () => {
|
|||
});
|
||||
}
|
||||
|
||||
it("shows an untrained train button and fires onTrain when clicked", () => {
|
||||
const onTrain = vi.fn();
|
||||
// Training moved off the header strip into the row's overflow menu, so the
|
||||
// header carries only recency + overflow. Menu items live in a portal that
|
||||
// only mounts once opened — environment-flaky in jsdom (see the dismiss test
|
||||
// above) — so the untrained path asserts the menu exists and no badge is
|
||||
// shown, and the onTrain contract is exercised through the inline badge.
|
||||
it("offers training through the row menu and shows no badge until trained", () => {
|
||||
render(
|
||||
<AttentionQueueRow
|
||||
item={trainableItem()}
|
||||
|
|
@ -645,18 +732,15 @@ describe("AttentionQueueRow", () => {
|
|||
expanded={false}
|
||||
onToggleExpand={noop}
|
||||
onDismiss={noop}
|
||||
onTrain={onTrain}
|
||||
onTrain={noop}
|
||||
/>,
|
||||
);
|
||||
const button = container?.querySelector('[data-testid="attention-train-button"]');
|
||||
expect(button).toBeTruthy();
|
||||
expect(button?.getAttribute("data-training-state")).toBe("untrained");
|
||||
expect(container?.querySelector('[aria-label="Row actions"]')).toBeTruthy();
|
||||
expect(container?.querySelector('[data-testid="attention-trained-badge"]')).toBeNull();
|
||||
act(() => button?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
||||
expect(onTrain).toHaveBeenCalledWith(expect.objectContaining({ id: "a1" }));
|
||||
});
|
||||
|
||||
it("renders a Trained ✓ badge and a filled button once trained", () => {
|
||||
it("renders a Trained ✓ badge once trained and fires onTrain when it is clicked", () => {
|
||||
const onTrain = vi.fn();
|
||||
render(
|
||||
<AttentionQueueRow
|
||||
item={trainableItem({ trainingExampleId: "example-1" })}
|
||||
|
|
@ -664,14 +748,13 @@ describe("AttentionQueueRow", () => {
|
|||
expanded={false}
|
||||
onToggleExpand={noop}
|
||||
onDismiss={noop}
|
||||
onTrain={noop}
|
||||
onTrain={onTrain}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
container?.querySelector('[data-testid="attention-train-button"]')?.getAttribute("data-training-state"),
|
||||
).toBe("trained");
|
||||
const badge = container?.querySelector('[data-testid="attention-trained-badge"]');
|
||||
expect(badge?.textContent).toContain("Trained");
|
||||
act(() => badge?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
||||
expect(onTrain).toHaveBeenCalledWith(expect.objectContaining({ id: "a1" }));
|
||||
});
|
||||
|
||||
it("does not offer training on a decision that isn't anchored to an issue", () => {
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import { memo, useState, type KeyboardEvent } from "react";
|
||||
import { memo, useState, type KeyboardEvent, type ReactNode } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
AlarmClock,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
ChevronUp,
|
||||
ExternalLink,
|
||||
GraduationCap,
|
||||
Loader2,
|
||||
|
|
@ -22,14 +22,16 @@ import {
|
|||
attentionDetailImages,
|
||||
attentionDetailLine,
|
||||
attentionImageUrl,
|
||||
attentionToneStyle,
|
||||
attentionStatus,
|
||||
attentionTaskRef,
|
||||
isInlineResolvable,
|
||||
severityBadge,
|
||||
sourceMeta,
|
||||
} from "../lib/attention";
|
||||
import { isTrainable } from "../lib/decisionTraining";
|
||||
import { cn, relativeTime } from "../lib/utils";
|
||||
import { StatusGlyph } from "./StatusGlyph";
|
||||
import { Button } from "./ui/button";
|
||||
import { Collapsible, CollapsibleContent } from "./ui/collapsible";
|
||||
import { Textarea } from "./ui/textarea";
|
||||
import {
|
||||
DropdownMenu,
|
||||
|
|
@ -42,7 +44,6 @@ import {
|
|||
DropdownMenuTrigger,
|
||||
} from "./ui/dropdown-menu";
|
||||
import { AttentionInteractionResolver } from "./AttentionInteractionResolver";
|
||||
import { ProjectTile } from "./ProjectTile";
|
||||
|
||||
const HOUR_MS = 60 * 60 * 1000;
|
||||
const DAY_MS = 24 * HOUR_MS;
|
||||
|
|
@ -112,9 +113,11 @@ export const AttentionQueueRow = memo(function AttentionQueueRow({
|
|||
selected = false,
|
||||
}: AttentionQueueRowProps) {
|
||||
const meta = sourceMeta(item.sourceKind);
|
||||
const tone = attentionToneStyle(item);
|
||||
const sevBadge = severityBadge(item.severity);
|
||||
const Icon = meta.icon;
|
||||
// Colour + glyph are borrowed wholesale from the task status system, so a
|
||||
// blocking decision reads exactly like a blocked task (DESIGN.md principle 5).
|
||||
const status = attentionStatus(item);
|
||||
// The task this row belongs to, whichever field the feed put it in.
|
||||
const taskRef = attentionTaskRef(item);
|
||||
const isHidden = variant === "hidden";
|
||||
const inline = !isHidden && isInlineResolvable(item);
|
||||
const href = item.subject.href;
|
||||
|
|
@ -150,18 +153,73 @@ export const AttentionQueueRow = memo(function AttentionQueueRow({
|
|||
// Which rows contribute an action bar. Inline rows carry compact decision
|
||||
// verbs; deep-link rows carry an Open button; curtain rows carry Restore.
|
||||
const compactActions = !isHidden ? collectCompactActions(item) : [];
|
||||
const showCompact = !expanded && compactActions.length > 0;
|
||||
const showOpen = !inline && !!href;
|
||||
const showRestore = isHidden && !!onRestore;
|
||||
const showActionBar = showCompact || showOpen || showRestore;
|
||||
// Left gutter width (chevron + gap) so the stacked content aligns under the
|
||||
// headline in the wide layout; when narrow, everything runs full-bleed.
|
||||
const gutterIndent = "@xl:pl-6";
|
||||
// An expanded inline row hands its footer to the resolver, which owns the
|
||||
// decision verbs — so the toggle rides alongside them on one row rather than
|
||||
// stranding a lone "See less" under the buttons. That makes the collapsed
|
||||
// footer a swap rather than a survivor, so it crossfades with the panel.
|
||||
const hasCollapsedOnlyContent = hasImages || inline;
|
||||
|
||||
// Disclosure control. Now the row's only expand affordance: it names what it
|
||||
// does instead of leaving a bare chevron to be decoded, and it sits at the
|
||||
// bottom-left where the eye lands after reading the row.
|
||||
const toggle = expandable ? (
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex shrink-0 items-center gap-1 rounded-md text-xs font-medium text-muted-foreground hover:text-foreground focus-visible:ring-ring focus-visible:ring-(length:--rad-3) focus-visible:outline-none"
|
||||
aria-label={expanded ? "Collapse decision" : "Expand decision"}
|
||||
aria-expanded={expanded}
|
||||
onClick={activate}
|
||||
>
|
||||
{expanded ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
|
||||
{expanded ? "See less" : "See more"}
|
||||
</button>
|
||||
) : null;
|
||||
|
||||
/**
|
||||
* The row's action bar: disclosure on the left, decision verbs on the right.
|
||||
* Rendered either inside the collapsed-only cluster (inline rows, where the
|
||||
* resolver takes it over once expanded) or as a standing sibling (everything
|
||||
* else). `compact` is false for the standing copy so an expanded row does not
|
||||
* show collapsed verbs beside the panel's own.
|
||||
*/
|
||||
const renderFooter = ({ compact }: { compact: boolean }) => {
|
||||
const showCompact = compactActions.length > 0 && (compact || !expanded);
|
||||
if (!toggle && !showCompact && !showOpen && !showRestore) return null;
|
||||
return (
|
||||
<div className="flex flex-wrap items-center justify-between gap-2" data-attention-actions="true">
|
||||
{toggle ?? <span />}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 @xl:justify-end">
|
||||
{showCompact && (
|
||||
<CompactDecisionActions item={item} companyId={companyId} onOpen={() => onToggleExpand(item)} />
|
||||
)}
|
||||
|
||||
{showOpen && (
|
||||
<Button asChild variant="default" size="xs" className={ACTION_BTN}>
|
||||
<Link to={href!}>
|
||||
Open
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{showRestore && (
|
||||
<Button type="button" variant="outline" size="xs" className={ACTION_BTN} onClick={() => onRestore(item)}>
|
||||
<RotateCcw className="h-3 w-3" />
|
||||
Restore
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"@container relative flex flex-col overflow-hidden border border-border bg-card",
|
||||
"@container relative flex flex-col gap-4 overflow-hidden rounded-xl border border-border bg-card px-4 pt-3 pb-4",
|
||||
// The feed is uncapped, so off-screen rows must not cost layout/paint
|
||||
// while scrolling. The intrinsic-size estimate only matters before a
|
||||
// row's first paint; `auto` keeps the real measured height afterwards.
|
||||
|
|
@ -176,226 +234,186 @@ export const AttentionQueueRow = memo(function AttentionQueueRow({
|
|||
data-attention-source={item.sourceKind}
|
||||
data-attention-severity={item.severity}
|
||||
>
|
||||
{/* Type accent bar (canonical color map — never severity). */}
|
||||
<span className={cn("absolute inset-y-0 left-0 w-1", tone.accent)} aria-hidden />
|
||||
{/* Meta band: one breadcrumb of identity on the left (kind → task →
|
||||
project), recency + overflow on the right. Not part of the clickable
|
||||
headline, so the menu never toggles it. */}
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-1">
|
||||
<span className="inline-flex items-center gap-1 text-xs font-medium text-muted-foreground">
|
||||
<StatusGlyph status={status} size="md" />
|
||||
{meta.label}
|
||||
</span>
|
||||
{taskRef && (
|
||||
<>
|
||||
<EyebrowSeparator />
|
||||
<Link
|
||||
to={taskRef.href ?? "#"}
|
||||
className="font-mono text-(length:--text-nano) text-muted-foreground hover:text-foreground"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{taskRef.identifier}
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
{trainable && trained && (
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1 rounded-sm border border-primary/30 bg-primary/10 px-1.5 py-px text-(length:--text-nano) font-medium text-primary hover:bg-primary/15"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onTrain?.(item);
|
||||
}}
|
||||
data-testid="attention-trained-badge"
|
||||
>
|
||||
<GraduationCap className="h-3 w-3 fill-primary/25" />
|
||||
Trained ✓
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-2 py-3 pl-4 pr-3">
|
||||
{/* Expand affordance / spacer gutter — keeps headlines aligned across the list. */}
|
||||
{expandable ? (
|
||||
<button
|
||||
type="button"
|
||||
className="mt-0.5 shrink-0 rounded-sm p-0.5 text-muted-foreground hover:text-foreground focus-visible:ring-ring focus-visible:ring-(length:--rad-3) focus-visible:outline-none"
|
||||
aria-label={expanded ? "Collapse decision" : "Expand decision"}
|
||||
aria-expanded={expanded}
|
||||
onClick={activate}
|
||||
>
|
||||
{expanded ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
||||
</button>
|
||||
) : (
|
||||
<span className="mt-0.5 hidden h-4 w-4 shrink-0 @xl:block" aria-hidden />
|
||||
)}
|
||||
|
||||
{/* Content column: a single vertical stack that fills the full width on
|
||||
mobile (no competing right-hand controls) and reads top-to-bottom. */}
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-2">
|
||||
{/* Meta band: identity on the left, recency + overflow on the right.
|
||||
Not part of the clickable headline, so the menu never toggles it. */}
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<span className="inline-flex items-center gap-1 text-xs font-medium text-muted-foreground">
|
||||
<Icon className={cn("h-3.5 w-3.5", tone.icon)} />
|
||||
{meta.label}
|
||||
</span>
|
||||
{sevBadge && (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center rounded-sm border px-1.5 py-px text-(length:--text-nano) font-semibold uppercase tracking-(--tracking-eyebrow)",
|
||||
sevBadge.className,
|
||||
)}
|
||||
>
|
||||
{sevBadge.label}
|
||||
</span>
|
||||
)}
|
||||
{item.relatedIssue?.identifier && (
|
||||
<Link
|
||||
to={item.relatedIssue.href ?? "#"}
|
||||
className="font-mono text-(length:--text-nano) text-muted-foreground hover:text-foreground"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{item.relatedIssue.identifier}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-1" data-attention-menu="true">
|
||||
{trainable && (
|
||||
<div className="flex shrink-0 items-center gap-1" data-attention-menu="true">
|
||||
{isHidden && snoozedUntil ? (
|
||||
<span
|
||||
className="text-(length:--text-nano) text-muted-foreground"
|
||||
title={`Reappears ${new Date(snoozedUntil).toLocaleString()}`}
|
||||
>
|
||||
Reappears {reappearLabel(snoozedUntil)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-(length:--text-nano) text-muted-foreground">{relativeTime(item.activityAt)}</span>
|
||||
)}
|
||||
{!isHidden && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className={cn(trained ? "text-primary" : "text-muted-foreground")}
|
||||
aria-label={trained ? "View training example" : "Train this decision"}
|
||||
aria-pressed={trained}
|
||||
title={trained ? "Trained — view example" : "Train this decision"}
|
||||
data-training-state={trained ? "trained" : "untrained"}
|
||||
data-testid="attention-train-button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onTrain?.(item);
|
||||
}}
|
||||
className="text-muted-foreground"
|
||||
aria-label="Row actions"
|
||||
>
|
||||
<GraduationCap className={cn("h-4 w-4", trained && "fill-primary/25")} />
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
{isHidden && snoozedUntil ? (
|
||||
<span
|
||||
className="text-(length:--text-nano) text-muted-foreground"
|
||||
title={`Reappears ${new Date(snoozedUntil).toLocaleString()}`}
|
||||
>
|
||||
Reappears {reappearLabel(snoozedUntil)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-(length:--text-nano) text-muted-foreground">{relativeTime(item.activityAt)}</span>
|
||||
)}
|
||||
{!isHidden && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="text-muted-foreground"
|
||||
aria-label="Row actions"
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{onSnooze && <SnoozeSubmenu onSnooze={(iso) => onSnooze(item, iso)} />}
|
||||
<DropdownMenuItem onClick={() => onDismiss(item)}>
|
||||
<X className="h-4 w-4" />
|
||||
Dismiss
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{/* Training moved off the header strip (which now carries only
|
||||
recency + overflow) but keeps its testids so the affordance
|
||||
is still addressable. */}
|
||||
{trainable && (
|
||||
<DropdownMenuItem
|
||||
data-training-state={trained ? "trained" : "untrained"}
|
||||
data-testid="attention-train-button"
|
||||
onClick={() => onTrain?.(item)}
|
||||
>
|
||||
<GraduationCap className={cn("h-4 w-4", trained && "fill-primary/25")} />
|
||||
{trained ? "View training example" : "Train this decision"}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{onSnooze && <SnoozeSubmenu onSnooze={(iso) => onSnooze(item, iso)} />}
|
||||
<DropdownMenuItem onClick={() => onDismiss(item)}>
|
||||
<X className="h-4 w-4" />
|
||||
Dismiss
|
||||
</DropdownMenuItem>
|
||||
{href && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to={href}>Open source</Link>
|
||||
</DropdownMenuItem>
|
||||
{href && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to={href}>Open source</Link>
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Headline — the primary expand target for inline rows. Title now wraps
|
||||
to two lines instead of truncating to a sliver on narrow screens. */}
|
||||
<div
|
||||
className={cn(
|
||||
"min-w-0 rounded-md",
|
||||
expandable && "cursor-pointer focus-visible:ring-ring focus-visible:ring-(length:--rad-3) focus-visible:outline-none",
|
||||
)}
|
||||
{...(expandable
|
||||
? {
|
||||
role: "button",
|
||||
tabIndex: 0,
|
||||
"aria-expanded": expanded,
|
||||
"aria-label": expanded ? "Collapse decision" : "Expand decision",
|
||||
onClick: activate,
|
||||
onKeyDown: onHeaderKeyDown,
|
||||
}
|
||||
: {})}
|
||||
>
|
||||
<span className="line-clamp-2 text-sm font-medium text-foreground" title={item.subject.title ?? undefined}>
|
||||
{item.subject.title ?? meta.label}
|
||||
</span>
|
||||
<p className="mt-0.5 line-clamp-2 text-xs text-muted-foreground">{detailLine}</p>
|
||||
</div>
|
||||
|
||||
{/* Context row: project identity and evidence thumbnails move below the
|
||||
text so they never squeeze the headline on mobile. */}
|
||||
{(item.project || (hasImages && !expanded) || (trainable && trained)) && (
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-2">
|
||||
{item.project && <ProjectMeta project={item.project} />}
|
||||
{trainable && trained && (
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1 rounded-sm border border-primary/30 bg-primary/10 px-1.5 py-px text-(length:--text-nano) font-medium text-primary hover:bg-primary/15"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onTrain?.(item);
|
||||
}}
|
||||
data-testid="attention-trained-badge"
|
||||
>
|
||||
<GraduationCap className="h-3 w-3 fill-primary/25" />
|
||||
Trained ✓
|
||||
</button>
|
||||
)}
|
||||
{hasImages && !expanded && <ThumbnailStack images={images} />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action bar: full-width, thumb-reachable buttons on mobile;
|
||||
right-aligned dense pills on desktop. Sibling of the headline so
|
||||
taps never toggle expand. */}
|
||||
{showActionBar && (
|
||||
<div
|
||||
className={cn("flex flex-wrap items-center gap-2 @xl:justify-end", gutterIndent)}
|
||||
data-attention-actions="true"
|
||||
>
|
||||
{showCompact && (
|
||||
<CompactDecisionActions
|
||||
item={item}
|
||||
companyId={companyId}
|
||||
onOpen={() => onToggleExpand(item)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showOpen && (
|
||||
<Button asChild variant="outline" size="xs" className={cn(ACTION_BTN, "w-full @xl:w-auto")}>
|
||||
<Link to={href!}>
|
||||
Open
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{showRestore && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="xs"
|
||||
className={cn(ACTION_BTN, "w-full @xl:w-auto")}
|
||||
onClick={() => onRestore(item)}
|
||||
>
|
||||
<RotateCcw className="h-3 w-3" />
|
||||
Restore
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{expanded && (hasImages || inline) && (
|
||||
<div className="space-y-3 border-t border-border/60 bg-muted/20 px-4 py-3 motion-safe:animate-in motion-safe:fade-in-0 motion-safe:slide-in-from-top-1 motion-safe:duration-200">
|
||||
{hasImages && <ExpandedImages images={images} issueHref={issueHref} />}
|
||||
{inline && (
|
||||
<InlineResolver
|
||||
item={item}
|
||||
companyId={companyId}
|
||||
agentMap={agentMap}
|
||||
currentUserId={currentUserId}
|
||||
userLabelMap={userLabelMap}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{/* Headline — the primary expand target for inline rows. Title wraps to
|
||||
two lines instead of truncating to a sliver on narrow screens. */}
|
||||
<div
|
||||
className={cn(
|
||||
"min-w-0 rounded-md",
|
||||
expandable && "cursor-pointer focus-visible:ring-ring focus-visible:ring-(length:--rad-3) focus-visible:outline-none",
|
||||
)}
|
||||
{...(expandable
|
||||
? {
|
||||
role: "button",
|
||||
tabIndex: 0,
|
||||
"aria-expanded": expanded,
|
||||
"aria-label": expanded ? "Collapse decision" : "Expand decision",
|
||||
onClick: activate,
|
||||
onKeyDown: onHeaderKeyDown,
|
||||
}
|
||||
: {})}
|
||||
>
|
||||
<span className="line-clamp-2 text-sm font-medium text-foreground" title={item.subject.title ?? undefined}>
|
||||
{item.subject.title ?? meta.label}
|
||||
</span>
|
||||
<p className="mt-0.5 line-clamp-2 text-xs text-muted-foreground">{detailLine}</p>
|
||||
</div>
|
||||
|
||||
{/* Collapsed-only content. It has no counterpart to morph into — the
|
||||
thumbnail strip becomes a full gallery, and an inline row's footer is
|
||||
replaced by the resolver's own — so it rides an inverse disclosure and
|
||||
crossfades against the panel below: this shrinks and fades out on the
|
||||
same tokens as that grows and fades in, instead of popping. */}
|
||||
{hasCollapsedOnlyContent && (
|
||||
<Collapsible open={!expanded} className="contents">
|
||||
<CollapsibleContent data-decision-disclosure className="-mt-4">
|
||||
<div className="flex flex-col gap-4 pt-4">
|
||||
{hasImages && <ThumbnailStack images={images} />}
|
||||
{inline && renderFooter({ compact: true })}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
)}
|
||||
|
||||
{/* The disclosure panel. Collapsible measures the panel and publishes its
|
||||
height, so the card grows and shrinks to a real number rather than
|
||||
snapping open. `contents` keeps the Root out of the layout, so a
|
||||
collapsed row does not pay a flex gap for an empty wrapper — and once
|
||||
the exit finishes Radix unmounts the panel, so a collapsed row is not
|
||||
left with a live resolver behind it. */}
|
||||
<Collapsible open={expanded} onOpenChange={() => onToggleExpand(item)} className="contents">
|
||||
<CollapsibleContent data-decision-disclosure className="-mt-4">
|
||||
<div className="flex flex-col gap-4 pt-4">
|
||||
{hasImages && <ExpandedImages images={images} issueHref={issueHref} />}
|
||||
{inline && (
|
||||
<InlineResolver
|
||||
item={item}
|
||||
companyId={companyId}
|
||||
agentMap={agentMap}
|
||||
currentUserId={currentUserId}
|
||||
userLabelMap={userLabelMap}
|
||||
toggle={toggle}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
{/* A non-inline row keeps one footer across both states — its Open or
|
||||
Restore button and its toggle are the same control either way, so it
|
||||
stays put rather than crossfading with itself. */}
|
||||
{!inline && renderFooter({ compact: false })}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* "·" between eyebrow segments.
|
||||
*
|
||||
* The eyebrow is a flat list of two facts (decision kind, task key), not a
|
||||
* hierarchy, so a middle dot reads more honestly than the "/" this started as —
|
||||
* a slash implies containment that the two segments do not have.
|
||||
*/
|
||||
function EyebrowSeparator() {
|
||||
return (
|
||||
<span className="text-xs text-muted-foreground" aria-hidden>
|
||||
·
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
type CompactDecisionAction = "accept" | "approve" | "reject" | "request_revision";
|
||||
|
||||
function compactDecisionAction(item: AttentionItem, verbId: string): CompactDecisionAction | null {
|
||||
|
|
@ -415,12 +433,33 @@ function compactDecisionAction(item: AttentionItem, verbId: string): CompactDeci
|
|||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Weight used to order a decision's verbs. The affirmative verb always lands
|
||||
* rightmost — the same place in every row, collapsed or expanded — so the
|
||||
* operator's aim never has to move with the verb list.
|
||||
*/
|
||||
const VERB_ORDER: Record<"outline" | "destructive" | "default", number> = {
|
||||
outline: 0,
|
||||
destructive: 1,
|
||||
default: 2,
|
||||
};
|
||||
|
||||
interface CompactAction {
|
||||
action: CompactDecisionAction;
|
||||
label: string;
|
||||
id: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
/** The compact accept/reject verbs a collapsed row can resolve in place. */
|
||||
function collectCompactActions(item: AttentionItem): Array<{ action: CompactDecisionAction; label: string; id: string }> {
|
||||
return item.decisionVerbs.slice(0, 3).flatMap((verb) => {
|
||||
const action = compactDecisionAction(item, verb.id);
|
||||
return action ? [{ action, label: verb.label, id: verb.id }] : [];
|
||||
});
|
||||
function collectCompactActions(item: AttentionItem): CompactAction[] {
|
||||
return item.decisionVerbs
|
||||
.slice(0, 3)
|
||||
.flatMap((verb) => {
|
||||
const action = compactDecisionAction(item, verb.id);
|
||||
return action ? [{ action, label: verb.label, id: verb.id, description: verb.description ?? "" }] : [];
|
||||
})
|
||||
.sort((a, b) => VERB_ORDER[decisionVerbVariant(a)] - VERB_ORDER[decisionVerbVariant(b)]);
|
||||
}
|
||||
|
||||
function CompactDecisionActions({
|
||||
|
|
@ -481,11 +520,11 @@ function CompactDecisionActions({
|
|||
|
||||
return (
|
||||
<div className="flex w-full flex-wrap items-center gap-2 @xl:w-auto @xl:justify-end @xl:gap-1" aria-label="Decision actions">
|
||||
{actions.map(({ action, id, label }) => (
|
||||
{actions.map(({ action, id, label, description }) => (
|
||||
<Button
|
||||
key={id}
|
||||
type="button"
|
||||
variant={decisionVerbVariant({ id, label, description: "" })}
|
||||
variant={decisionVerbVariant({ id, label, description })}
|
||||
size="xs"
|
||||
className={cn(ACTION_BTN, "min-w-0 flex-1 @xl:flex-none")}
|
||||
disabled={decision.isPending}
|
||||
|
|
@ -525,20 +564,6 @@ function decisionVerbVariant(verb: AttentionItem["decisionVerbs"][number]): "def
|
|||
return "outline";
|
||||
}
|
||||
|
||||
/** Inline project identity keeps useful context without a competing badge. */
|
||||
function ProjectMeta({ project }: { project: NonNullable<AttentionItem["project"]> }) {
|
||||
return (
|
||||
<span
|
||||
className="inline-flex max-w-(--sz-12rem) items-center gap-1.5 text-(length:--text-nano) text-muted-foreground"
|
||||
title={project.name}
|
||||
data-testid="attention-project-meta"
|
||||
>
|
||||
<ProjectTile color={project.color} icon={project.icon} size="xs" />
|
||||
<span className="truncate">{project.name}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** Square screenshot thumbnails at the right of the description (plan §10). */
|
||||
function ThumbnailStack({ images }: { images: AttentionDetailImage[] }) {
|
||||
const visible = images.slice(0, 3);
|
||||
|
|
@ -591,6 +616,12 @@ function ExpandedImages({ images, issueHref }: { images: AttentionDetailImage[];
|
|||
<Link
|
||||
key={key}
|
||||
to={issueHref}
|
||||
// No task quicklook on evidence. `Link` upgrades any /issues/ href
|
||||
// into a hover preview, which here pops a text card over the very
|
||||
// screenshot being examined — and because expanding a row mounts
|
||||
// this gallery directly under a stationary pointer, the preview
|
||||
// opens unbidden and can outlive the pointer that never entered it.
|
||||
disableIssueQuicklook
|
||||
className="block rounded-md focus-visible:ring-ring focus-visible:ring-(length:--rad-3) focus-visible:outline-none"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
|
|
@ -605,6 +636,8 @@ function ExpandedImages({ images, issueHref }: { images: AttentionDetailImage[];
|
|||
{extra > 0 && (issueHref ? (
|
||||
<Link
|
||||
to={issueHref}
|
||||
// Same gallery, same pointer trap — see the thumbnail note above.
|
||||
disableIssueQuicklook
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="flex h-32 w-24 flex-col items-center justify-center rounded-md border border-dashed border-border bg-muted/40 text-sm font-medium text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-ring focus-visible:ring-(length:--rad-3) focus-visible:outline-none"
|
||||
>
|
||||
|
|
@ -682,18 +715,26 @@ function reappearLabel(snoozedUntil: string): string {
|
|||
return `in ${diffDay}d`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Expanded-row content. Resolvers that own their decision verbs also render the
|
||||
* row's footer, so the disclosure toggle (`toggle`) sits on the same line as the
|
||||
* buttons. The issue-thread interaction card keeps its verbs internally — it is
|
||||
* shared with the issue thread surface — so there the toggle gets its own row.
|
||||
*/
|
||||
function InlineResolver({
|
||||
item,
|
||||
companyId,
|
||||
agentMap,
|
||||
currentUserId,
|
||||
userLabelMap,
|
||||
toggle,
|
||||
}: {
|
||||
item: AttentionItem;
|
||||
companyId: string;
|
||||
agentMap?: Map<string, Agent>;
|
||||
currentUserId?: string | null;
|
||||
userLabelMap?: ReadonlyMap<string, string> | null;
|
||||
toggle: ReactNode;
|
||||
}) {
|
||||
if (item.sourceKind === "issue_thread_interaction") {
|
||||
const issueId = (item.subject.metadata?.issueId as string | undefined) ?? item.relatedIssue?.id;
|
||||
|
|
@ -701,29 +742,42 @@ function InlineResolver({
|
|||
return <p className="text-xs text-muted-foreground">Missing issue reference for this decision.</p>;
|
||||
}
|
||||
return (
|
||||
<AttentionInteractionResolver
|
||||
companyId={companyId}
|
||||
issueId={issueId}
|
||||
interactionId={item.subject.id}
|
||||
agentMap={agentMap}
|
||||
currentUserId={currentUserId}
|
||||
userLabelMap={userLabelMap}
|
||||
/>
|
||||
<>
|
||||
<AttentionInteractionResolver
|
||||
companyId={companyId}
|
||||
issueId={issueId}
|
||||
interactionId={item.subject.id}
|
||||
agentMap={agentMap}
|
||||
currentUserId={currentUserId}
|
||||
userLabelMap={userLabelMap}
|
||||
/>
|
||||
{toggle && <div className="flex items-center">{toggle}</div>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (item.sourceKind === "approval") {
|
||||
return <ApprovalResolver item={item} companyId={companyId} />;
|
||||
return <ApprovalResolver item={item} companyId={companyId} toggle={toggle} />;
|
||||
}
|
||||
|
||||
if (item.sourceKind === "join_request") {
|
||||
return <JoinRequestResolver item={item} companyId={companyId} />;
|
||||
return <JoinRequestResolver item={item} companyId={companyId} toggle={toggle} />;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function ApprovalResolver({ item, companyId }: { item: AttentionItem; companyId: string }) {
|
||||
/** Footer shared by the resolvers that own their verbs: toggle left, verbs right. */
|
||||
function ResolverFooter({ toggle, children }: { toggle: ReactNode; children: ReactNode }) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center justify-between gap-2" data-attention-actions="true">
|
||||
{toggle ?? <span />}
|
||||
<div className="flex flex-wrap items-center gap-2">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ApprovalResolver({ item, companyId, toggle }: { item: AttentionItem; companyId: string; toggle: ReactNode }) {
|
||||
const queryClient = useQueryClient();
|
||||
const [note, setNote] = useState("");
|
||||
const invalidate = () => {
|
||||
|
|
@ -744,19 +798,17 @@ function ApprovalResolver({ item, companyId }: { item: AttentionItem; companyId:
|
|||
});
|
||||
const pending = approve.isPending || reject.isPending || revise.isPending;
|
||||
|
||||
// Verb order matches the collapsed row exactly (revise → reject → approve),
|
||||
// so expanding never moves the button the operator was already aiming at.
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<>
|
||||
<Textarea
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
placeholder="Optional decision note…"
|
||||
className="min-h-16 text-sm"
|
||||
/>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button size="sm" onClick={() => approve.mutate()} disabled={pending}>
|
||||
{approve.isPending && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
|
||||
Approve
|
||||
</Button>
|
||||
<ResolverFooter toggle={toggle}>
|
||||
<Button size="sm" variant="outline" onClick={() => revise.mutate()} disabled={pending}>
|
||||
{revise.isPending && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
|
||||
Request revision
|
||||
|
|
@ -765,12 +817,16 @@ function ApprovalResolver({ item, companyId }: { item: AttentionItem; companyId:
|
|||
{reject.isPending && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
|
||||
Reject
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Button size="sm" onClick={() => approve.mutate()} disabled={pending}>
|
||||
{approve.isPending && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
|
||||
Approve
|
||||
</Button>
|
||||
</ResolverFooter>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function JoinRequestResolver({ item, companyId }: { item: AttentionItem; companyId: string }) {
|
||||
function JoinRequestResolver({ item, companyId, toggle }: { item: AttentionItem; companyId: string; toggle: ReactNode }) {
|
||||
const queryClient = useQueryClient();
|
||||
const invalidate = () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.attention(companyId) });
|
||||
|
|
@ -787,15 +843,15 @@ function JoinRequestResolver({ item, companyId }: { item: AttentionItem; company
|
|||
const pending = approve.isPending || reject.isPending;
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button size="sm" onClick={() => approve.mutate()} disabled={pending}>
|
||||
{approve.isPending && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
|
||||
Approve
|
||||
</Button>
|
||||
<ResolverFooter toggle={toggle}>
|
||||
<Button size="sm" variant="destructive" onClick={() => reject.mutate()} disabled={pending}>
|
||||
{reject.isPending && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
|
||||
Reject
|
||||
</Button>
|
||||
</div>
|
||||
<Button size="sm" onClick={() => approve.mutate()} disabled={pending}>
|
||||
{approve.isPending && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
|
||||
Approve
|
||||
</Button>
|
||||
</ResolverFooter>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { MemoryRouter } from "react-router-dom";
|
|||
import type { Issue } from "@paperclipai/shared";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { IssueLinkQuicklook } from "./IssueLinkQuicklook";
|
||||
import { IssueLinkQuicklook, QUICKLOOK_CONTENT_CLASS, quicklookAlignOffset } from "./IssueLinkQuicklook";
|
||||
|
||||
const mockIssuesApiGet = vi.hoisted(() => vi.fn());
|
||||
|
||||
|
|
@ -134,4 +134,169 @@ describe("IssueLinkQuicklook", () => {
|
|||
|
||||
expect(document.body.textContent).not.toContain("Quicklook title");
|
||||
});
|
||||
|
||||
// Regression: the quicklook could only be closed by a `mouseleave` on the
|
||||
// trigger or the card, and no leave event fires when the layout shifts the
|
||||
// trigger out from under a stationary pointer — expanding a decision row does
|
||||
// exactly that, stranding the card on screen. A pointer move anywhere clear of
|
||||
// both boxes now closes it.
|
||||
function renderQuicklook(issueOverrides: Partial<Issue> = {}) {
|
||||
act(() => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter>
|
||||
<IssueLinkQuicklook
|
||||
issuePathId="PAP-1"
|
||||
issuePrefetch={createIssue(issueOverrides)}
|
||||
to="/companies/company-1/issues/PAP-1"
|
||||
>
|
||||
PAP-1
|
||||
</IssueLinkQuicklook>
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
return container.querySelector("a") as HTMLAnchorElement;
|
||||
}
|
||||
|
||||
function movePointerTo(x: number, y: number) {
|
||||
act(() => {
|
||||
document.dispatchEvent(new MouseEvent("pointermove", { clientX: x, clientY: y, bubbles: true }));
|
||||
});
|
||||
}
|
||||
|
||||
it("closes an open quicklook once the pointer moves clear of the trigger and the card", () => {
|
||||
const trigger = renderQuicklook();
|
||||
|
||||
act(() => {
|
||||
trigger.focus();
|
||||
});
|
||||
expect(document.body.textContent).toContain("Quicklook title");
|
||||
|
||||
// jsdom reports zero-size rects, so every box sits at the origin; a move far
|
||||
// from it is unambiguously clear of both the trigger and the card.
|
||||
act(() => {
|
||||
trigger.blur();
|
||||
});
|
||||
movePointerTo(4000, 4000);
|
||||
|
||||
expect(document.body.textContent).not.toContain("Quicklook title");
|
||||
});
|
||||
|
||||
// Regression: Radix returns focus to the trigger when a popover closes, and
|
||||
// this link opens the quicklook `onFocus` — so dismissing it refocused the
|
||||
// trigger, which reopened it, and hovering away left the card up for good.
|
||||
it("does not reopen itself by taking focus back when it closes", () => {
|
||||
const trigger = renderQuicklook();
|
||||
|
||||
act(() => {
|
||||
trigger.dispatchEvent(new MouseEvent("mouseover", { bubbles: true }));
|
||||
vi.advanceTimersByTime(200);
|
||||
});
|
||||
expect(document.body.textContent).toContain("Quicklook title");
|
||||
|
||||
act(() => {
|
||||
trigger.dispatchEvent(new MouseEvent("mouseout", { bubbles: true, relatedTarget: document.body }));
|
||||
vi.runOnlyPendingTimers();
|
||||
});
|
||||
|
||||
expect(document.activeElement).not.toBe(trigger);
|
||||
expect(document.body.textContent).not.toContain("Quicklook title");
|
||||
});
|
||||
|
||||
it("keeps a focus-opened quicklook up while focus stays on the trigger", () => {
|
||||
const trigger = renderQuicklook();
|
||||
|
||||
act(() => {
|
||||
trigger.focus();
|
||||
});
|
||||
expect(document.body.textContent).toContain("Quicklook title");
|
||||
|
||||
// A keyboard user moving the mouse must not dismiss what focus opened.
|
||||
movePointerTo(4000, 4000);
|
||||
|
||||
expect(document.body.textContent).toContain("Quicklook title");
|
||||
});
|
||||
|
||||
// The card is the standard task preview for the whole app, so its rows and
|
||||
// their order are the contract, not incidental markup.
|
||||
function openCard(trigger: HTMLAnchorElement) {
|
||||
act(() => {
|
||||
trigger.dispatchEvent(new MouseEvent("mouseover", { bubbles: true }));
|
||||
vi.advanceTimersByTime(200);
|
||||
});
|
||||
return document.querySelector('[data-slot="popover-content"]');
|
||||
}
|
||||
|
||||
it("states identity, then title, then summary", () => {
|
||||
const card = openCard(
|
||||
renderQuicklook({
|
||||
status: "in_review",
|
||||
// The card reads only `name` off the project; the rest of `Project` is
|
||||
// irrelevant here, so this stands in for a full record.
|
||||
project: { id: "project-1", name: "Paperclip App" } as unknown as Issue["project"],
|
||||
}),
|
||||
);
|
||||
const text = card?.textContent ?? "";
|
||||
|
||||
expect(text).toContain("PAP-1");
|
||||
expect(text).toContain("Paperclip App");
|
||||
expect(text).toContain("Quicklook title");
|
||||
expect(text).toContain("Quicklook description");
|
||||
expect(text.indexOf("PAP-1")).toBeLessThan(text.indexOf("Quicklook title"));
|
||||
expect(text.indexOf("Quicklook title")).toBeLessThan(text.indexOf("Quicklook description"));
|
||||
});
|
||||
|
||||
// Status is the glyph, with no word of its own — so it must survive as the
|
||||
// glyph's accessible name rather than as shape and colour alone.
|
||||
it("carries the status on the glyph instead of spending a line on it", () => {
|
||||
const card = openCard(renderQuicklook({ status: "in_review" }));
|
||||
|
||||
const glyph = card?.querySelector('[role="img"]');
|
||||
expect(glyph?.getAttribute("aria-label")).toBe("In review");
|
||||
|
||||
// The status must reach a screen reader but occupy no visible text. Drop
|
||||
// the glyph (whose <title> counts toward textContent) and the word is gone.
|
||||
const withoutGlyph = card?.cloneNode(true) as HTMLElement;
|
||||
withoutGlyph.querySelector('[role="img"]')?.remove();
|
||||
expect(withoutGlyph.textContent).not.toContain("In review");
|
||||
expect(withoutGlyph.textContent).not.toContain("in_review");
|
||||
});
|
||||
|
||||
it("shows no separator or project when the task has no project", () => {
|
||||
const card = openCard(renderQuicklook());
|
||||
|
||||
expect(card?.querySelector('[data-testid="quicklook-project"]')).toBeNull();
|
||||
expect(card?.textContent).not.toContain("·");
|
||||
});
|
||||
|
||||
// Radix aligns box to box, so the card's own padding would leave its text
|
||||
// inset from the trigger's. The offset cancels the padding in whichever
|
||||
// direction the card is aligned.
|
||||
it("offsets the card by its padding so its text lines up with the trigger", () => {
|
||||
// 12px of `p-3` padding plus the 1px border PopoverContent draws.
|
||||
expect(quicklookAlignOffset("start")).toBe(-13);
|
||||
expect(quicklookAlignOffset("end")).toBe(13);
|
||||
expect(quicklookAlignOffset("center")).toBe(0);
|
||||
expect(quicklookAlignOffset()).toBe(quicklookAlignOffset("start"));
|
||||
// The offset only holds while the shell keeps that padding.
|
||||
expect(QUICKLOOK_CONTENT_CLASS).toContain("p-3");
|
||||
});
|
||||
|
||||
it("truncates a long project name but never the task key or the timestamp", () => {
|
||||
const card = openCard(
|
||||
renderQuicklook({
|
||||
project: { id: "p1", name: "Really loooong project name" } as unknown as Issue["project"],
|
||||
}),
|
||||
);
|
||||
|
||||
const project = card?.querySelector('[data-testid="quicklook-project"]');
|
||||
expect(project?.getAttribute("title")).toBe("Really loooong project name");
|
||||
// The name is the only part allowed to give up width.
|
||||
expect(project?.querySelector(".truncate")?.textContent).toBe("Really loooong project name");
|
||||
expect(project?.getAttribute("class")).toContain("min-w-0");
|
||||
|
||||
const key = Array.from(card?.querySelectorAll("span") ?? []).find((s) => s.textContent === "PAP-1");
|
||||
expect(key?.getAttribute("class")).toContain("shrink-0");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -13,7 +13,8 @@ import {
|
|||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { StatusIcon } from "@/components/StatusIcon";
|
||||
import { StatusGlyph } from "@/components/StatusGlyph";
|
||||
import { ProjectTile } from "@/components/ProjectTile";
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Single-flight quicklook store */
|
||||
|
|
@ -64,6 +65,34 @@ function useIsQuicklookOpen(id: symbol) {
|
|||
* as the pointer crosses cards on its way somewhere else. */
|
||||
const QUICKLOOK_OPEN_DELAY_MS = 120;
|
||||
|
||||
/**
|
||||
* Distance from the quicklook's outer edge to its first column of text: the
|
||||
* `p-3` padding in `QUICKLOOK_CONTENT_CLASS` (12px) plus the 1px border that
|
||||
* `PopoverContent` draws. Both sit between the box edge and the content, so
|
||||
* both have to be cancelled to line the text up with the trigger's.
|
||||
*/
|
||||
const QUICKLOOK_CONTENT_INSET_PX = 12 + 1;
|
||||
|
||||
/** Shared shell for every surface that renders `IssueQuicklookCard`. */
|
||||
export const QUICKLOOK_CONTENT_CLASS = "w-72 p-3";
|
||||
|
||||
/**
|
||||
* Cancel the card's own inset along the align axis, so the preview lines up
|
||||
* with the *text* that opened it rather than with that text's box.
|
||||
*
|
||||
* Radix aligns box to box: with `align="start"` the card's left edge meets the
|
||||
* trigger's left edge, which leaves the card's text pushed right by its border
|
||||
* and padding — visibly off against the task key above it. Pulling the box back
|
||||
* by exactly that inset puts the card's first column of text on the same
|
||||
* vertical line as the trigger's. `end` needs the mirror of it; `center` has no
|
||||
* edge to line up with, so it gets nothing.
|
||||
*/
|
||||
export function quicklookAlignOffset(align: "start" | "center" | "end" = "start"): number {
|
||||
if (align === "start") return -QUICKLOOK_CONTENT_INSET_PX;
|
||||
if (align === "end") return QUICKLOOK_CONTENT_INSET_PX;
|
||||
return 0;
|
||||
}
|
||||
|
||||
export type IssueQuicklookIssue = Pick<Issue, "id" | "title" | "updatedAt"> & {
|
||||
identifier?: string | null;
|
||||
status: string;
|
||||
|
|
@ -89,6 +118,46 @@ function summarizeIssueDescription(description: string | null | undefined) {
|
|||
return summary.length > 180 ? `${summary.slice(0, 177).trimEnd()}...` : summary;
|
||||
}
|
||||
|
||||
/** "·" between facts in the quicklook's meta and status lines. */
|
||||
function QuicklookSeparator({ className }: { className?: string }) {
|
||||
return (
|
||||
<span className={cn("text-xs", className)} aria-hidden>
|
||||
·
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** "in_review" -> "In review". The card states the status as a word, not a chip. */
|
||||
function statusLabel(status: string): string {
|
||||
const words = status.replace(/_/g, " ");
|
||||
return words.charAt(0).toUpperCase() + words.slice(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* The standard task preview card. Every hover preview of a task in the app
|
||||
* renders this, so the same three rows appear in the same order everywhere:
|
||||
*
|
||||
* 1. meta — status glyph · task key [· project] .......... last activity
|
||||
* 2. title
|
||||
* 3. summary — first lines of the description
|
||||
*
|
||||
* The meta row splits: identity on the left, recency pinned right. Identity
|
||||
* leads because a preview answers "which task is this?", and the title alone
|
||||
* does not.
|
||||
*
|
||||
* Status carries no word of its own — the glyph is the status, exactly as it is
|
||||
* on task rows and decision cards. Because that leaves shape and colour as the
|
||||
* only visual signal, the glyph is given a `title`, so it renders as
|
||||
* `role="img"` with the status as its accessible name. The status stays
|
||||
* available to a screen reader without spending a line on it.
|
||||
*
|
||||
* Three shapes the row must hold, per the design mock:
|
||||
* 1. no project — glyph, key, and the timestamp hard right
|
||||
* 2. project — a "·" and the project tile plus name join the left group
|
||||
* 3. truncation — a long project name ellipsizes; the key and the timestamp
|
||||
* never do, so the two facts that identify the task survive
|
||||
* at any width
|
||||
*/
|
||||
export function IssueQuicklookCard({
|
||||
issue,
|
||||
linkTo,
|
||||
|
|
@ -101,26 +170,43 @@ export function IssueQuicklookCard({
|
|||
compact?: boolean;
|
||||
}) {
|
||||
const description = useMemo(() => summarizeIssueDescription(issue.description), [issue.description]);
|
||||
const projectName = issue.project?.name;
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-2", compact && "space-y-1.5")}>
|
||||
<div className="flex items-start gap-2">
|
||||
<StatusIcon status={issue.status} blockerAttention={issue.blockerAttention} className="mt-0.5 shrink-0" />
|
||||
<RouterDom.Link
|
||||
to={linkTo}
|
||||
state={linkState ?? withIssueDetailHeaderSeed(null, issue)}
|
||||
className="text-sm font-medium leading-snug hover:underline line-clamp-2"
|
||||
>
|
||||
{issue.title}
|
||||
</RouterDom.Link>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
<span className="font-mono">{issue.identifier ?? issue.id.slice(0, 8)}</span>
|
||||
<span>·</span>
|
||||
<span>{issue.status.replace(/_/g, " ")}</span>
|
||||
<span>·</span>
|
||||
<span>{timeAgo(new Date(issue.updatedAt))}</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-1">
|
||||
<StatusGlyph status={issue.status} size="md" title={statusLabel(issue.status)} />
|
||||
<span className="shrink-0 font-mono text-(length:--text-micro) text-muted-foreground">
|
||||
{issue.identifier ?? issue.id.slice(0, 8)}
|
||||
</span>
|
||||
{projectName ? (
|
||||
<>
|
||||
<QuicklookSeparator className="shrink-0 text-muted-foreground" />
|
||||
<span
|
||||
className="flex min-w-0 max-w-(--sz-12rem) flex-1 items-center gap-1 text-(length:--text-micro) text-muted-foreground"
|
||||
title={projectName}
|
||||
data-testid="quicklook-project"
|
||||
>
|
||||
<ProjectTile size="xs" />
|
||||
<span className="truncate">{projectName}</span>
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
<span className="shrink-0 text-(length:--text-micro) text-muted-foreground">
|
||||
{timeAgo(new Date(issue.updatedAt))}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<RouterDom.Link
|
||||
to={linkTo}
|
||||
state={linkState ?? withIssueDetailHeaderSeed(null, issue)}
|
||||
className="block text-sm font-medium leading-snug hover:underline line-clamp-2"
|
||||
>
|
||||
{issue.title}
|
||||
</RouterDom.Link>
|
||||
|
||||
{description ? (
|
||||
<p className="text-xs leading-5 text-muted-foreground [display:-webkit-box] [-webkit-box-orient:vertical] [-webkit-line-clamp:4] overflow-hidden">
|
||||
{description}
|
||||
|
|
@ -164,6 +250,19 @@ export const IssueLinkQuicklook = React.forwardRef<
|
|||
const instanceId = React.useMemo(() => Symbol("issue-quicklook"), []);
|
||||
const open = useIsQuicklookOpen(instanceId);
|
||||
const openTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const triggerRef = React.useRef<HTMLAnchorElement | null>(null);
|
||||
const contentRef = React.useRef<HTMLDivElement | null>(null);
|
||||
|
||||
// Keep the caller's ref working while we also hold our own handle on the
|
||||
// trigger node (needed by the pointer-escape guard below).
|
||||
const setTriggerRef = React.useCallback(
|
||||
(node: HTMLAnchorElement | null) => {
|
||||
triggerRef.current = node;
|
||||
if (typeof ref === "function") ref(node);
|
||||
else if (ref) ref.current = node;
|
||||
},
|
||||
[ref],
|
||||
);
|
||||
|
||||
const cancelScheduledOpen = React.useCallback(() => {
|
||||
if (openTimerRef.current) {
|
||||
|
|
@ -200,6 +299,57 @@ export const IssueLinkQuicklook = React.forwardRef<
|
|||
};
|
||||
}, [cancelScheduledOpen, instanceId]);
|
||||
|
||||
// Pointer-escape guard.
|
||||
//
|
||||
// The only close paths are `mouseleave` on the trigger and on the content,
|
||||
// and a leave event is not guaranteed: when the layout shifts, the element
|
||||
// moves out from under a stationary pointer rather than the pointer moving
|
||||
// off the element, and no leave fires. Expanding a decision row does exactly
|
||||
// that, stranding an open quicklook on screen until something else happens
|
||||
// to open one.
|
||||
//
|
||||
// Rather than enumerate the ways a leave can be missed, re-check the
|
||||
// browser's own hover state on every pointer move and close as soon as the
|
||||
// pointer is over neither the trigger nor the card. Keyboard use is exempt:
|
||||
// a quicklook opened by focus stays put while focus remains inside it.
|
||||
React.useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
// Slack around each box, so crossing the 4px gap between the trigger and
|
||||
// the card does not read as leaving.
|
||||
const EDGE_SLACK_PX = 12;
|
||||
const nearBox = (element: Element | null, x: number, y: number) => {
|
||||
if (!element) return false;
|
||||
const rect = element.getBoundingClientRect();
|
||||
return (
|
||||
x >= rect.left - EDGE_SLACK_PX
|
||||
&& x <= rect.right + EDGE_SLACK_PX
|
||||
&& y >= rect.top - EDGE_SLACK_PX
|
||||
&& y <= rect.bottom + EDGE_SLACK_PX
|
||||
);
|
||||
};
|
||||
|
||||
const stillEngaged = (x: number, y: number) => {
|
||||
const trigger = triggerRef.current;
|
||||
const content = contentRef.current;
|
||||
// Two independent signals, and closing needs both to agree the pointer is
|
||||
// gone: `:hover` is authoritative about stacking and overlap, geometry
|
||||
// survives the cases where hover state is not updated. Either one holding
|
||||
// keeps the card open, so a real pointer resting on it is never dropped.
|
||||
if (trigger?.matches(":hover") || content?.matches(":hover")) return true;
|
||||
if (nearBox(trigger, x, y) || nearBox(content, x, y)) return true;
|
||||
// Opened by keyboard: hold while focus is still inside.
|
||||
const active = document.activeElement;
|
||||
return trigger === active || (!!content && !!active && content.contains(active));
|
||||
};
|
||||
|
||||
const onPointerMove = (event: PointerEvent) => {
|
||||
if (!stillEngaged(event.clientX, event.clientY)) close();
|
||||
};
|
||||
document.addEventListener("pointermove", onPointerMove, { passive: true });
|
||||
return () => document.removeEventListener("pointermove", onPointerMove);
|
||||
}, [open, close]);
|
||||
|
||||
const prefetchedState = issuePrefetch ? withIssueDetailHeaderSeed(state, issuePrefetch) : state;
|
||||
const { data, isLoading } = useQuery({
|
||||
...getIssueDetailQueryOptions(queryClient, issuePathId, { placeholderIssue: issuePrefetch ?? undefined }),
|
||||
|
|
@ -213,7 +363,7 @@ export const IssueLinkQuicklook = React.forwardRef<
|
|||
}, [issuePathId, issuePrefetch, queryClient]);
|
||||
const link = (
|
||||
<RouterDom.Link
|
||||
ref={ref}
|
||||
ref={setTriggerRef}
|
||||
to={to}
|
||||
state={prefetchedState}
|
||||
className={className}
|
||||
|
|
@ -266,12 +416,23 @@ export const IssueLinkQuicklook = React.forwardRef<
|
|||
{link}
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-72 p-3"
|
||||
ref={contentRef}
|
||||
// Opts into the scale-from-trigger motion defined in index.css.
|
||||
data-quicklook
|
||||
className={QUICKLOOK_CONTENT_CLASS}
|
||||
side={issueQuicklookSide}
|
||||
align={issueQuicklookAlign}
|
||||
alignOffset={quicklookAlignOffset(issueQuicklookAlign)}
|
||||
onMouseEnter={openNow}
|
||||
onMouseLeave={close}
|
||||
// A preview must not move focus in either direction. Opening already
|
||||
// declined to take focus; closing has to decline to hand it back, or
|
||||
// Radix focuses the trigger on the way out — and because this link
|
||||
// opens the quicklook `onFocus`, that focus immediately reopens the
|
||||
// card that was just dismissed. Hovering away then leaves it up
|
||||
// permanently: close, refocus, reopen, forever.
|
||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||
onCloseAutoFocus={(event) => event.preventDefault()}
|
||||
>
|
||||
{data ? (
|
||||
<IssueQuicklookCard issue={data} linkTo={detailPath} linkState={prefetchedState} compact />
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { useState } from "react";
|
|||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { createIssueDetailPath, withIssueDetailHeaderSeed } from "../lib/issueDetailBreadcrumb";
|
||||
import type { ProjectWorkspaceLinkedIssue } from "../lib/project-workspaces-tab";
|
||||
import { IssueQuicklookCard } from "./IssueLinkQuicklook";
|
||||
import { IssueQuicklookCard, QUICKLOOK_CONTENT_CLASS, quicklookAlignOffset } from "./IssueLinkQuicklook";
|
||||
|
||||
interface IssuesQuicklookProps {
|
||||
issue: ProjectWorkspaceLinkedIssue;
|
||||
|
|
@ -22,9 +22,11 @@ export function IssuesQuicklook({ issue, children }: IssuesQuicklookProps) {
|
|||
{children}
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-72 p-3"
|
||||
data-quicklook
|
||||
className={QUICKLOOK_CONTENT_CLASS}
|
||||
side="top"
|
||||
align="start"
|
||||
alignOffset={quicklookAlignOffset("start")}
|
||||
onMouseEnter={() => setOpen(true)}
|
||||
onMouseLeave={() => setOpen(false)}
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
|
|
|
|||
123
ui/src/index.css
123
ui/src/index.css
|
|
@ -455,6 +455,129 @@
|
|||
transition: max-width 200ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
Motion tokens
|
||||
---------------------------------------------------------------------------
|
||||
The first named motion values in the system. Durations and easings were
|
||||
previously written inline at each call site; these give the "snappy, eases
|
||||
in and out" behaviour one definition so surfaces stay in step.
|
||||
|
||||
Exit is deliberately shorter than enter: a thing appearing wants to be
|
||||
followed, a thing leaving just needs to get out of the way.
|
||||
|
||||
The enter curve is the same cubic-bezier the dialog max-width transition
|
||||
already used — decelerating hard at the end, which is what reads as "snappy"
|
||||
rather than "slow start". Consumers: the decision-card disclosure and the
|
||||
task quicklook, both below.
|
||||
--------------------------------------------------------------------------- */
|
||||
:root {
|
||||
--motion-duration-enter: 160ms;
|
||||
--motion-duration-exit: 110ms;
|
||||
--motion-ease-out: cubic-bezier(0.16, 1, 0.3, 1);
|
||||
--motion-ease-in: cubic-bezier(0.4, 0, 1, 1);
|
||||
|
||||
/* A crossfading pair must share one duration and one curve. Two elements
|
||||
swapping places both change height, and the container shows the SUM. Give
|
||||
them different timing — the outgoing on a 110ms ease-in against the
|
||||
incoming on a 160ms ease-out — and that sum stops being monotonic: the
|
||||
container bulges or dips mid-swap, which reads as a lurch even though each
|
||||
half is individually smooth. Run both off the same clock and the same
|
||||
curve and the sum collapses to a clean interpolation between the two
|
||||
states, because at every moment progress is identical on both sides. */
|
||||
--motion-duration-swap: 150ms;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
Decision-card disclosure (See more / See less)
|
||||
---------------------------------------------------------------------------
|
||||
Radix Collapsible measures the panel and publishes its height, so the card
|
||||
can grow and shrink to a real number instead of snapping. The content
|
||||
unmounts once the exit finishes, so an expanded row's resolver is not left
|
||||
running behind a collapsed card.
|
||||
--------------------------------------------------------------------------- */
|
||||
@keyframes decision-disclosure-open {
|
||||
from {
|
||||
height: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
height: var(--radix-collapsible-content-height);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@keyframes decision-disclosure-close {
|
||||
from {
|
||||
height: var(--radix-collapsible-content-height);
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
height: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
[data-slot="collapsible-content"][data-decision-disclosure] {
|
||||
overflow: hidden;
|
||||
}
|
||||
[data-slot="collapsible-content"][data-decision-disclosure][data-state="open"] {
|
||||
animation: decision-disclosure-open var(--motion-duration-swap) var(--motion-ease-out);
|
||||
}
|
||||
[data-slot="collapsible-content"][data-decision-disclosure][data-state="closed"] {
|
||||
/* Same duration and same curve as the open side — see --motion-duration-swap.
|
||||
The two run against each other, so matched timing is what keeps the card's
|
||||
height a straight interpolation instead of a bulge. */
|
||||
animation: decision-disclosure-close var(--motion-duration-swap) var(--motion-ease-out);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
Task quicklook: a small scale-up out of the trigger, not a fade in place.
|
||||
---------------------------------------------------------------------------
|
||||
The shadcn PopoverContent class string carries `animate-in`, `zoom-in-95`
|
||||
and friends, but those utilities come from the tailwindcss-animate plugin,
|
||||
which this project does not install — there is no `enter`/`exit` keyframe in
|
||||
the build, so they are inert. Rather than take on that dependency for one
|
||||
surface (it would newly animate every dialog, dropdown and tooltip in the
|
||||
app), the quicklook defines its own motion here.
|
||||
|
||||
The scale is deliberately shallow. `--radix-popover-content-transform-origin`
|
||||
pins the origin to the corner nearest the trigger, so the card reads as
|
||||
growing out of the task key that opened it rather than swelling in place.
|
||||
--------------------------------------------------------------------------- */
|
||||
@keyframes quicklook-open {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.96);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
@keyframes quicklook-close {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: scale(0.96);
|
||||
}
|
||||
}
|
||||
[data-slot="popover-content"][data-quicklook][data-state="open"] {
|
||||
animation: quicklook-open var(--motion-duration-enter) var(--motion-ease-out);
|
||||
}
|
||||
[data-slot="popover-content"][data-quicklook][data-state="closed"] {
|
||||
animation: quicklook-close var(--motion-duration-exit) var(--motion-ease-in);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
[data-slot="collapsible-content"][data-decision-disclosure][data-state="open"],
|
||||
[data-slot="collapsible-content"][data-decision-disclosure][data-state="closed"],
|
||||
[data-slot="popover-content"][data-quicklook][data-state="open"],
|
||||
[data-slot="popover-content"][data-quicklook][data-state="closed"] {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Dashboard activity row entry motion */
|
||||
@keyframes dashboard-activity-enter {
|
||||
0% {
|
||||
|
|
|
|||
|
|
@ -6,8 +6,9 @@ import {
|
|||
attentionBadgeCount,
|
||||
attentionDateBucket,
|
||||
attentionDetailLine,
|
||||
attentionTone,
|
||||
attentionToneStyle,
|
||||
attentionKind,
|
||||
attentionStatus,
|
||||
attentionTaskRef,
|
||||
buildAttentionFilterOptions,
|
||||
countActiveAttentionFilters,
|
||||
defaultAttentionFilterState,
|
||||
|
|
@ -18,7 +19,6 @@ import {
|
|||
NO_GROUP_SENTINEL,
|
||||
planAttentionRenderRows,
|
||||
saveAttentionGroupBy,
|
||||
severityBadge,
|
||||
severityStyle,
|
||||
sortAttentionItems,
|
||||
sourceMeta,
|
||||
|
|
@ -127,7 +127,6 @@ describe("sourceMeta + severityStyle", () => {
|
|||
];
|
||||
for (const kind of kinds) {
|
||||
expect(sourceMeta(kind).label.length).toBeGreaterThan(0);
|
||||
expect(sourceMeta(kind).icon).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -136,63 +135,161 @@ describe("sourceMeta + severityStyle", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("attentionTone + attentionToneStyle (canonical color map §4)", () => {
|
||||
it("colors plan approvals violet regardless of source kind", () => {
|
||||
const fromApproval = buildItem({
|
||||
sourceKind: "approval",
|
||||
detail: { kind: "plan_approval", issueTitle: "I", planTitle: "P", summaryExcerpt: null, images: [] },
|
||||
// Supersedes the five-tone map (sky/violet/rose/amber/neutral) + severityBadge:
|
||||
// rows now resolve to one of two kinds, each borrowing a task status for its
|
||||
// colour and glyph, so the queue and the task list share one vocabulary.
|
||||
describe("attentionKind + attentionStatus (flattened decision types)", () => {
|
||||
it("reads anything stuck as blocking", () => {
|
||||
expect(attentionKind(buildItem({ sourceKind: "failed_run" }))).toBe("blocking");
|
||||
expect(attentionKind(buildItem({ sourceKind: "agent_error_alert" }))).toBe("blocking");
|
||||
expect(attentionKind(buildItem({ sourceKind: "blocker_attention" }))).toBe("blocking");
|
||||
expect(attentionKind(buildItem({ sourceKind: "recovery_action" }))).toBe("blocking");
|
||||
expect(attentionKind(buildItem({ sourceKind: "budget_alert" }))).toBe("blocking");
|
||||
});
|
||||
|
||||
it("reads anything awaiting a verdict as review", () => {
|
||||
expect(attentionKind(buildItem({ sourceKind: "approval" }))).toBe("review");
|
||||
expect(attentionKind(buildItem({ sourceKind: "issue_thread_interaction" }))).toBe("review");
|
||||
expect(attentionKind(buildItem({ sourceKind: "join_request" }))).toBe("review");
|
||||
expect(attentionKind(buildItem({ sourceKind: "review" }))).toBe("review");
|
||||
expect(attentionKind(buildItem({ sourceKind: "productivity_review" }))).toBe("review");
|
||||
});
|
||||
|
||||
it("keeps plan approvals in the review family whichever surface raised them", () => {
|
||||
const planApproval = (): AttentionItem["detail"] => ({
|
||||
kind: "plan_approval",
|
||||
issueTitle: "I",
|
||||
planTitle: "P",
|
||||
summaryExcerpt: null,
|
||||
images: [],
|
||||
});
|
||||
const fromInteraction = buildItem({
|
||||
sourceKind: "issue_thread_interaction",
|
||||
detail: { kind: "plan_approval", issueTitle: "I", planTitle: "P", summaryExcerpt: null, images: [] },
|
||||
});
|
||||
expect(attentionTone(fromApproval)).toBe("violet");
|
||||
expect(attentionTone(fromInteraction)).toBe("violet");
|
||||
expect(attentionToneStyle(fromApproval).accent).toContain("violet");
|
||||
});
|
||||
|
||||
it("colors confirmations / questions / verdicts in the sky family", () => {
|
||||
expect(attentionTone(buildItem({ sourceKind: "approval" }))).toBe("sky");
|
||||
expect(attentionTone(buildItem({ sourceKind: "issue_thread_interaction" }))).toBe("sky");
|
||||
expect(
|
||||
attentionTone(
|
||||
buildItem({
|
||||
sourceKind: "issue_thread_interaction",
|
||||
detail: { kind: "questions", questionCount: 2, firstQuestionText: "?", images: [] },
|
||||
}),
|
||||
),
|
||||
).toBe("sky");
|
||||
});
|
||||
|
||||
it("colors failures rose and blocked/recovery/budget amber", () => {
|
||||
expect(attentionTone(buildItem({ sourceKind: "failed_run" }))).toBe("rose");
|
||||
expect(attentionTone(buildItem({ sourceKind: "agent_error_alert" }))).toBe("rose");
|
||||
expect(attentionTone(buildItem({ sourceKind: "blocker_attention" }))).toBe("amber");
|
||||
expect(attentionTone(buildItem({ sourceKind: "recovery_action" }))).toBe("amber");
|
||||
expect(attentionTone(buildItem({ sourceKind: "budget_alert" }))).toBe("amber");
|
||||
});
|
||||
|
||||
it("colors join requests neutral", () => {
|
||||
expect(attentionTone(buildItem({ sourceKind: "join_request" }))).toBe("neutral");
|
||||
});
|
||||
|
||||
it("gives every tone a distinct accent and never keys color off severity", () => {
|
||||
const rose = buildItem({ sourceKind: "failed_run", severity: "low" });
|
||||
const amber = buildItem({ sourceKind: "budget_alert", severity: "critical" });
|
||||
// Same-source rows with opposite severities share one accent (color ≠ severity).
|
||||
expect(attentionToneStyle(buildItem({ sourceKind: "failed_run", severity: "critical" })).accent).toBe(
|
||||
attentionToneStyle(rose).accent,
|
||||
expect(attentionStatus(buildItem({ sourceKind: "approval", detail: planApproval() }))).toBe("in_review");
|
||||
expect(attentionStatus(buildItem({ sourceKind: "issue_thread_interaction", detail: planApproval() }))).toBe(
|
||||
"in_review",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps blocking and review distinct", () => {
|
||||
expect(attentionStatus(buildItem({ sourceKind: "agent_error_alert" }))).not.toBe(
|
||||
attentionStatus(buildItem({ sourceKind: "approval" })),
|
||||
);
|
||||
});
|
||||
|
||||
it("borrows exactly two task statuses — and never keys colour off severity", () => {
|
||||
expect(attentionStatus(buildItem({ sourceKind: "agent_error_alert" }))).toBe("blocked");
|
||||
expect(attentionStatus(buildItem({ sourceKind: "approval" }))).toBe("in_review");
|
||||
// Same source, opposite severities → identical status (colour ≠ severity).
|
||||
expect(attentionStatus(buildItem({ sourceKind: "failed_run", severity: "critical" }))).toBe(
|
||||
attentionStatus(buildItem({ sourceKind: "failed_run", severity: "low" })),
|
||||
);
|
||||
expect(attentionToneStyle(rose).accent).not.toBe(attentionToneStyle(amber).accent);
|
||||
});
|
||||
});
|
||||
|
||||
describe("severityBadge", () => {
|
||||
it("only surfaces a badge for Critical/High", () => {
|
||||
expect(severityBadge("critical")?.label).toBe("Critical");
|
||||
expect(severityBadge("high")?.label).toBe("High");
|
||||
expect(severityBadge("medium")).toBeNull();
|
||||
expect(severityBadge("low")).toBeNull();
|
||||
// The feed stores the task in two different fields depending on what the row is
|
||||
// about. Reading only `relatedIssue` silently dropped the key on every row whose
|
||||
// subject *is* the task — reviews and blocked dependencies, i.e. the rows most
|
||||
// obviously about a task.
|
||||
describe("attentionTaskRef", () => {
|
||||
it("reads the task off the subject when the subject IS the task", () => {
|
||||
const item = buildItem({
|
||||
sourceKind: "blocker_attention",
|
||||
subject: {
|
||||
kind: "issue",
|
||||
id: "i1",
|
||||
companyId: "c1",
|
||||
title: "Update primary paperclip instance",
|
||||
identifier: "PAP-23",
|
||||
status: "blocked",
|
||||
href: "/PAP/issues/PAP-23",
|
||||
},
|
||||
});
|
||||
expect(attentionTaskRef(item)).toEqual({ identifier: "PAP-23", href: "/PAP/issues/PAP-23" });
|
||||
});
|
||||
|
||||
it("reads the task off relatedIssue when the subject merely hangs off one", () => {
|
||||
const item = buildItem({
|
||||
sourceKind: "issue_thread_interaction",
|
||||
subject: {
|
||||
kind: "interaction",
|
||||
id: "x1",
|
||||
companyId: "c1",
|
||||
title: "Ship it?",
|
||||
identifier: null,
|
||||
status: "pending",
|
||||
href: "/PAP/issues/PAP-20#interaction-x1",
|
||||
},
|
||||
relatedIssue: {
|
||||
kind: "issue",
|
||||
id: "i2",
|
||||
companyId: "c1",
|
||||
title: "Produce launch video",
|
||||
identifier: "PAP-20",
|
||||
status: "in_review",
|
||||
href: "/PAP/issues/PAP-20",
|
||||
},
|
||||
});
|
||||
expect(attentionTaskRef(item)).toEqual({ identifier: "PAP-20", href: "/PAP/issues/PAP-20" });
|
||||
});
|
||||
|
||||
it("prefers relatedIssue when both are present — it is the record the subject can't describe", () => {
|
||||
const item = buildItem({
|
||||
subject: {
|
||||
kind: "issue",
|
||||
id: "i1",
|
||||
companyId: "c1",
|
||||
title: "Subject task",
|
||||
identifier: "PAP-1",
|
||||
status: "todo",
|
||||
href: "/PAP/issues/PAP-1",
|
||||
},
|
||||
relatedIssue: {
|
||||
kind: "issue",
|
||||
id: "i2",
|
||||
companyId: "c1",
|
||||
title: "Related task",
|
||||
identifier: "PAP-2",
|
||||
status: "todo",
|
||||
href: "/PAP/issues/PAP-2",
|
||||
},
|
||||
});
|
||||
expect(attentionTaskRef(item)?.identifier).toBe("PAP-2");
|
||||
});
|
||||
|
||||
it("returns null for rows genuinely not attached to a task", () => {
|
||||
// A hire approval: subject is the approval itself, no task anywhere.
|
||||
expect(attentionTaskRef(buildItem({ sourceKind: "approval" }))).toBeNull();
|
||||
// An agent error: subject is the agent.
|
||||
expect(
|
||||
attentionTaskRef(
|
||||
buildItem({
|
||||
sourceKind: "agent_error_alert",
|
||||
subject: {
|
||||
kind: "agent",
|
||||
id: "ag1",
|
||||
companyId: "c1",
|
||||
title: "CTO",
|
||||
identifier: null,
|
||||
status: "error",
|
||||
href: "/PAP/agents/ag1",
|
||||
},
|
||||
}),
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("does not borrow a key from a non-task subject that happens to have one", () => {
|
||||
const item = buildItem({
|
||||
subject: {
|
||||
kind: "approval",
|
||||
id: "ap1",
|
||||
companyId: "c1",
|
||||
title: "Sign off",
|
||||
identifier: "APR-9",
|
||||
status: "pending",
|
||||
href: "/PAP/approvals/ap1",
|
||||
},
|
||||
});
|
||||
expect(attentionTaskRef(item)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,16 +1,3 @@
|
|||
import {
|
||||
AlertTriangle,
|
||||
Ban,
|
||||
DollarSign,
|
||||
Eye,
|
||||
LifeBuoy,
|
||||
MessageSquareQuote,
|
||||
RefreshCw,
|
||||
ShieldCheck,
|
||||
UserPlus,
|
||||
Zap,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import type {
|
||||
AttentionDetailImage,
|
||||
AttentionFeed,
|
||||
|
|
@ -38,26 +25,31 @@ export function isInlineResolvable(item: AttentionItem): boolean {
|
|||
return item.inlineResolvable && INLINE_RESOLVABLE_SOURCE_KINDS.has(item.sourceKind);
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-source wording only. The icon used to live here too — one glyph per
|
||||
* source kind — but rows now borrow the task-status glyph for their kind (see
|
||||
* `attentionStatus` below), so a source contributes its *name* and nothing
|
||||
* visual.
|
||||
*/
|
||||
interface SourceMeta {
|
||||
label: string;
|
||||
icon: LucideIcon;
|
||||
}
|
||||
|
||||
const SOURCE_META: Record<AttentionSourceKind, SourceMeta> = {
|
||||
approval: { label: "Approval", icon: ShieldCheck },
|
||||
issue_thread_interaction: { label: "Decision requested", icon: MessageSquareQuote },
|
||||
join_request: { label: "Join request", icon: UserPlus },
|
||||
recovery_action: { label: "Recovery", icon: LifeBuoy },
|
||||
productivity_review: { label: "Productivity review", icon: Zap },
|
||||
blocker_attention: { label: "Blocked dependency", icon: Ban },
|
||||
review: { label: "Review", icon: Eye },
|
||||
failed_run: { label: "Failed run", icon: RefreshCw },
|
||||
budget_alert: { label: "Budget", icon: DollarSign },
|
||||
agent_error_alert: { label: "Agent error", icon: AlertTriangle },
|
||||
approval: { label: "Approval" },
|
||||
issue_thread_interaction: { label: "Decision requested" },
|
||||
join_request: { label: "Join request" },
|
||||
recovery_action: { label: "Recovery" },
|
||||
productivity_review: { label: "Productivity review" },
|
||||
blocker_attention: { label: "Blocked dependency" },
|
||||
review: { label: "Review" },
|
||||
failed_run: { label: "Failed run" },
|
||||
budget_alert: { label: "Budget" },
|
||||
agent_error_alert: { label: "Agent error" },
|
||||
};
|
||||
|
||||
export function sourceMeta(kind: AttentionSourceKind): SourceMeta {
|
||||
return SOURCE_META[kind] ?? { label: kind.replaceAll("_", " "), icon: AlertTriangle };
|
||||
return SOURCE_META[kind] ?? { label: kind.replaceAll("_", " ") };
|
||||
}
|
||||
|
||||
interface SeverityStyle {
|
||||
|
|
@ -79,99 +71,91 @@ export function severityStyle(severity: AttentionSeverity): SeverityStyle {
|
|||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Canonical type → color map (PAP-13409 §4)
|
||||
// Decision kind → borrowed task status (supersedes the PAP-13409 §4 tone map)
|
||||
//
|
||||
// The row color is driven by the *kind of decision*, never by severity — one
|
||||
// map, sourced from `IssueThreadInteractionCard`'s palette so a plan approval or
|
||||
// confirmation reads identically in the queue and on the issue thread:
|
||||
// • confirmations / questions / suggested-tasks / verdicts / reviews → sky
|
||||
// • plan approvals → violet
|
||||
// • failures (failed run, agent error) → rose
|
||||
// • blocked / recovery / budget → amber
|
||||
// • join request → neutral
|
||||
// Severity only ever surfaces as a small Critical/High badge (never the accent).
|
||||
// The queue used to run five parallel colour/icon vocabularies (sky / violet /
|
||||
// rose / amber / neutral), one glyph per source kind, plus an orange-or-red
|
||||
// severity badge — so two rows demanding the same response from an operator
|
||||
// could look completely unrelated. The system is flattened to TWO kinds, and
|
||||
// each one *borrows the task status it corresponds to* instead of declaring a
|
||||
// palette of its own:
|
||||
//
|
||||
// • blocking — failed run, agent error, blocked dependency, recovery, budget
|
||||
// → task status `blocked` (red, CircleMinus)
|
||||
// • review — approval, confirmation, review, join request, everything else
|
||||
// → task status `in_review` (violet, CircleDot)
|
||||
//
|
||||
// Colour and glyph therefore resolve through <StatusGlyph> and the
|
||||
// `--status-task-icon-*` tokens, so the decision queue and the task list stay
|
||||
// in lockstep by construction — an operator learns the vocabulary once
|
||||
// (DESIGN.md principle 5). Source kinds keep their own *wording* ("Approval",
|
||||
// "Agent error", …); only colour and icon merge.
|
||||
//
|
||||
// Severity is no longer chrome. It survives as a filter/group dimension in the
|
||||
// toolbar, which is where an operator goes when they want to rank by urgency.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type AttentionTone = "sky" | "violet" | "rose" | "amber" | "neutral";
|
||||
export type AttentionKind = "blocking" | "review";
|
||||
|
||||
export interface AttentionToneStyle {
|
||||
/** Left accent bar background. */
|
||||
accent: string;
|
||||
/** Source-icon tint. */
|
||||
icon: string;
|
||||
/** Chip / badge border+bg+text (matches the interaction card badge palette). */
|
||||
chip: string;
|
||||
}
|
||||
|
||||
const TONE_STYLE: Record<AttentionTone, AttentionToneStyle> = {
|
||||
sky: {
|
||||
accent: "bg-sky-500",
|
||||
icon: "text-sky-600 dark:text-sky-400",
|
||||
chip: "border-sky-500/60 bg-sky-500/10 text-sky-900 dark:bg-sky-500/15 dark:text-sky-100",
|
||||
},
|
||||
violet: {
|
||||
accent: "bg-violet-500",
|
||||
icon: "text-violet-600 dark:text-violet-400",
|
||||
chip: "border-violet-500/60 bg-violet-500/10 text-violet-900 dark:bg-violet-500/15 dark:text-violet-100",
|
||||
},
|
||||
rose: {
|
||||
accent: "bg-rose-500",
|
||||
icon: "text-rose-600 dark:text-rose-400",
|
||||
chip: "border-rose-500/60 bg-rose-500/10 text-rose-900 dark:bg-rose-500/15 dark:text-rose-100",
|
||||
},
|
||||
amber: {
|
||||
accent: "bg-amber-500",
|
||||
icon: "text-amber-600 dark:text-amber-400",
|
||||
chip: "border-amber-500/60 bg-amber-500/10 text-amber-900 dark:bg-amber-500/15 dark:text-amber-100",
|
||||
},
|
||||
neutral: {
|
||||
accent: "bg-muted-foreground/40",
|
||||
icon: "text-muted-foreground",
|
||||
chip: "border-border/70 bg-muted/50 text-muted-foreground",
|
||||
},
|
||||
/** The task status each decision kind renders as. */
|
||||
export const ATTENTION_KIND_STATUS: Record<AttentionKind, "blocked" | "in_review"> = {
|
||||
blocking: "blocked",
|
||||
review: "in_review",
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve the canonical tone for a row. A plan approval is violet regardless of
|
||||
* which surface tagged it (approval flow *or* issue-thread confirmation), so we
|
||||
* check the T1 detail discriminant first, then fall back to the source kind.
|
||||
*/
|
||||
export function attentionTone(item: AttentionItem): AttentionTone {
|
||||
if (item.detail?.kind === "plan_approval") return "violet";
|
||||
/** Does this row report something stuck, or something waiting on a verdict? */
|
||||
export function attentionKind(item: AttentionItem): AttentionKind {
|
||||
switch (item.sourceKind) {
|
||||
case "failed_run":
|
||||
case "agent_error_alert":
|
||||
return "rose";
|
||||
case "blocker_attention":
|
||||
case "recovery_action":
|
||||
case "budget_alert":
|
||||
return "amber";
|
||||
case "join_request":
|
||||
return "neutral";
|
||||
return "blocking";
|
||||
case "approval":
|
||||
case "issue_thread_interaction":
|
||||
case "join_request":
|
||||
case "review":
|
||||
case "productivity_review":
|
||||
default:
|
||||
return "sky";
|
||||
return "review";
|
||||
}
|
||||
}
|
||||
|
||||
export function attentionToneStyle(item: AttentionItem): AttentionToneStyle {
|
||||
return TONE_STYLE[attentionTone(item)];
|
||||
/** Task status a row borrows its glyph and colour from — feeds <StatusGlyph>. */
|
||||
export function attentionStatus(item: AttentionItem): "blocked" | "in_review" {
|
||||
return ATTENTION_KIND_STATUS[attentionKind(item)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Severity is demoted to a small badge — and only when it is genuinely
|
||||
* escalated (Critical/High). Medium/Low return `null` so most rows carry no
|
||||
* severity chrome at all.
|
||||
* The task a row belongs to, wherever the feed happens to put it.
|
||||
*
|
||||
* The feed uses two shapes, and a row that reads only one of them silently
|
||||
* drops the task key on the other:
|
||||
* • the subject IS the task (review, blocked dependency) → `subject`
|
||||
* carries the identifier and `relatedIssue` is null;
|
||||
* • the subject hangs off a task (a thread interaction, an issue-scoped
|
||||
* approval) → the task arrives separately as `relatedIssue`.
|
||||
*
|
||||
* `relatedIssue` wins when both are present: it is the *other* record, so it
|
||||
* is the one the subject alone can't tell you about.
|
||||
*
|
||||
* Returns null for rows genuinely not attached to a task — a hire approval, an
|
||||
* agent error — which should show no key rather than a borrowed one.
|
||||
*
|
||||
* Known gap (server-side, not resolvable here): an approval can carry
|
||||
* `subject.metadata.issueId` while `relatedIssue` is null. That is a bare UUID
|
||||
* with no key or href, so there is nothing to render; the feed builder has to
|
||||
* populate `relatedIssue` for those.
|
||||
*/
|
||||
export function severityBadge(severity: AttentionSeverity): { label: string; className: string } | null {
|
||||
if (severity === "critical") {
|
||||
return { label: "Critical", className: "border-red-500/60 bg-red-500/10 text-red-700 dark:text-red-300" };
|
||||
export function attentionTaskRef(item: AttentionItem): { identifier: string; href: string | null } | null {
|
||||
const related = item.relatedIssue;
|
||||
if (related?.identifier) {
|
||||
return { identifier: related.identifier, href: related.href };
|
||||
}
|
||||
if (severity === "high") {
|
||||
return { label: "High", className: "border-orange-500/60 bg-orange-500/10 text-orange-700 dark:text-orange-300" };
|
||||
const subject = item.subject;
|
||||
if (subject.kind === "issue" && subject.identifier) {
|
||||
return { identifier: subject.identifier, href: subject.href };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -83,6 +83,7 @@ import { useProjectOrder } from "../hooks/useProjectOrder";
|
|||
import { relativeTime, cn, formatDurationMs, formatTokens, visibleRunCostUsd } from "../lib/utils";
|
||||
import { liveBlueBadge } from "../lib/status-colors";
|
||||
import { ApprovalCard } from "../components/ApprovalCard";
|
||||
import { ProjectTile } from "../components/ProjectTile";
|
||||
import { InlineEditor } from "../components/InlineEditor";
|
||||
import {
|
||||
IssueChatThread,
|
||||
|
|
@ -175,7 +176,6 @@ import {
|
|||
ScanEye,
|
||||
Flag,
|
||||
FileCode2,
|
||||
Hexagon,
|
||||
ListTree,
|
||||
MessageSquare,
|
||||
MoreHorizontal,
|
||||
|
|
@ -706,16 +706,18 @@ function IssueDetailLoadingState({
|
|||
Routine
|
||||
</Badge>
|
||||
) : null}
|
||||
{/* Seeded header — same anatomy as the resolved one below, so the
|
||||
eyebrow does not change shape when the real issue arrives. */}
|
||||
{headerSeed.projectId ? (
|
||||
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground rounded px-1 -mx-1 py-0.5 min-w-0">
|
||||
<Hexagon className="h-3 w-3 shrink-0" />
|
||||
<ProjectTile size="xs" />
|
||||
<span className="truncate">
|
||||
{headerSeed.projectName ?? headerSeed.projectId.slice(0, 8)}
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground opacity-50 px-1 -mx-1 py-0.5">
|
||||
<Hexagon className="h-3 w-3 shrink-0" />
|
||||
<ProjectTile size="xs" />
|
||||
No project
|
||||
</span>
|
||||
)}
|
||||
|
|
@ -4261,17 +4263,24 @@ export function IssueDetail() {
|
|||
</Badge>
|
||||
) : null}
|
||||
|
||||
{/* Project reads as a tile plus a name, matching the project rows in
|
||||
the sidebar and the Projects list rather than a bare outline
|
||||
glyph. The tile stays neutral here on purpose: the eyebrow already
|
||||
carries the status glyph's colour, and a second tinted swatch
|
||||
beside it competes with the one signal that means something.
|
||||
Project colour still identifies the project on project-native
|
||||
surfaces. */}
|
||||
{issue.projectId ? (
|
||||
<Link
|
||||
to={`/projects/${issue.projectId}`}
|
||||
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors rounded px-1 -mx-1 py-0.5 min-w-0"
|
||||
>
|
||||
<Hexagon className="h-3 w-3 shrink-0" />
|
||||
<ProjectTile size="xs" icon={resolvedProject?.icon ?? issue.project?.icon} />
|
||||
<span className="truncate">{resolvedProject?.name ?? issue.project?.name ?? issue.projectId.slice(0, 8)}</span>
|
||||
</Link>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground opacity-50 px-1 -mx-1 py-0.5">
|
||||
<Hexagon className="h-3 w-3 shrink-0" />
|
||||
<ProjectTile size="xs" />
|
||||
No project
|
||||
</span>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -84,6 +84,14 @@ export function WhatNeedsMe() {
|
|||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||
const [selectedAttentionId, setSelectedAttentionId] = useState<string | null>(null);
|
||||
// How the current selection was made. The selection ring is the keyboard
|
||||
// cursor — it marks the row that j/k, e, x and s will act on — so it is drawn
|
||||
// only for a keyboard-driven selection. Clicking used to set it too, which
|
||||
// put a ring around the card for no reason the operator could act on, and
|
||||
// only ever on rows with a See more/less toggle to click (the toggle is what
|
||||
// set it), so the queue looked arbitrarily inconsistent. The selection itself
|
||||
// still follows a click, so keyboard actions target the row you just used.
|
||||
const [selectionFromKeyboard, setSelectionFromKeyboard] = useState(false);
|
||||
const [autoExpandDone, setAutoExpandDone] = useState(false);
|
||||
// Decision-training drawer target. `null` when closed.
|
||||
const [trainingItem, setTrainingItem] = useState<AttentionItem | null>(null);
|
||||
|
|
@ -272,6 +280,7 @@ export function WhatNeedsMe() {
|
|||
useEffect(() => {
|
||||
if (selectedAttentionId && !keyboardItems.some((item) => item.id === selectedAttentionId)) {
|
||||
setSelectedAttentionId(null);
|
||||
setSelectionFromKeyboard(false);
|
||||
}
|
||||
}, [keyboardItems, selectedAttentionId]);
|
||||
|
||||
|
|
@ -360,6 +369,7 @@ export function WhatNeedsMe() {
|
|||
);
|
||||
const handleToggleExpand = useCallback((item: AttentionItem) => {
|
||||
setSelectedAttentionId(item.id);
|
||||
setSelectionFromKeyboard(false);
|
||||
setExpandedId((prev) => (prev === item.id ? null : item.id));
|
||||
}, []);
|
||||
const handleTrain = useCallback((item: AttentionItem) => {
|
||||
|
|
@ -390,6 +400,7 @@ export function WhatNeedsMe() {
|
|||
: keyboardItems.length - 1
|
||||
: (currentIndex + offset + keyboardItems.length) % keyboardItems.length;
|
||||
setSelectedAttentionId(keyboardItems[nextIndex]?.id ?? null);
|
||||
setSelectionFromKeyboard(true);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -559,7 +570,7 @@ export function WhatNeedsMe() {
|
|||
/>
|
||||
)}
|
||||
{!collapsed && (
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-4">
|
||||
{(renderPlan.groupRows.get(group.key) ?? []).map((item) => (
|
||||
<AttentionQueueRow
|
||||
key={item.id}
|
||||
|
|
@ -572,7 +583,7 @@ export function WhatNeedsMe() {
|
|||
onTrain={handleTrain}
|
||||
agentMap={agentMap}
|
||||
currentUserId={currentUserId}
|
||||
selected={selectedAttentionId === item.id}
|
||||
selected={selectionFromKeyboard && selectedAttentionId === item.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -799,7 +810,7 @@ function Curtain({
|
|||
onToggle={onToggle}
|
||||
className="text-muted-foreground"
|
||||
/>
|
||||
{open && <div className="space-y-2">{children}</div>}
|
||||
{open && <div className="space-y-4">{children}</div>}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue