mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-08 01:57:31 +01:00
fix(ui): use one status glyph mapping for every task view (#19329)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Task status is the main signal on every task surface: sidebar rows, the task header, the status menu, lists, the inbox, pills and chat cards. > - One task state rendered different status icons in different views. A blocked task that waits on an active sub-task showed the blue "in queue" glyph in the task header. The status menu, the chat chip and the sidebar rows showed the red "Blocked" glyph for the same task. > - The cause: each view passed its own subset of the presentation fields to `StatusIcon`. Many views dropped `blockerAttention` and some dropped `externalConversationState`. The status menu rendered its current row from the bare status. The summaries API did not return `blockerAttention`, so the recent-task sidebar could not know the state. > - This pull request puts the state-to-glyph/label mapping in one resolver and makes every task view pass all of its fields. > - The benefit is that the same state uses the same icon and label everywhere. ## Linked Issues or Issue Description No public issue. Found in a hands-on onboarding test on a staging cloud workspace. **What happened?** The sidebar and the task pane/status menu showed different status icons for the same state. On the test task (blocked, waiting on an active sub-task), the task header and the status button showed the blue "in queue" glyph. The selected "Blocked" row in the status menu showed the red blocked glyph. The coordinator sidebar "Recents" and project rows also showed the red blocked glyph, and the chat "Subtask created" chip showed a red "Blocked" chip. **Expected behavior** The same underlying state uses the same icon and label everywhere. **Steps to reproduce** 1. Create a task with a sub-task. Block the parent on the sub-task, and let the sub-task's agent run (the parent is "blocked, covered"). 2. Open the parent task. The header shows the blue "in queue" glyph. 3. Open the status menu. The current "Blocked" row shows the red glyph. 4. With the coordinator sidebar on, look at the task in Recents or in its expanded project. It shows the red glyph. **Paperclip version or commit** `master` at03eb49053f. ## What Changed - Add `ui/src/lib/task-status-presentation.ts`. `resolveTaskStatusPresentation()` is the one mapping from `status`, `displayStatus`, `externalConversationState` and `blockerAttention` to glyph, label and accessible copy. `taskStatusIconProps()` picks all four fields from a task record. - `StatusIcon` and `IssueStatusBadge` use the resolver. The status menu draws the current row with the trigger's presentation (in queue, idle, monitoring). - `IssueStatusBadge` now accepts `blockerAttention`. `taskStatusVar.in_queue` maps to the existing in-progress chip hue, the same as the existing `--status-task-icon-in_queue` icon token. - Task call sites spread `taskStatusIconProps(task)`: coordinator sidebar recents and project rows, classic recent tasks, lists, rows, columns, inbox, blocked inbox, my issues, dashboard, properties, relation pills, search, sibling navigation, chat chips, case summaries, status cards and user profile. Step/check statuses in protocol cards stay unchanged because they are not task statuses. - `GET /api/companies/:companyId/issues/summaries` returns `blockerAttention` for blocked rows (at most five IDs, through the existing `listBlockerAttention` service). `IssueSummary` gets the optional field. - Recent-task snapshots store and refresh `blockerAttention`. Sidebar project rows request the `rows` projection, which includes blocker state (the `index` projection skips it). - Storybook: the Foundations/Status Language story gets a "Same state, every view" section. ## Verification Red on `master` (tests written first, then run against the unchanged code): ``` cd ui && npx vitest run src/components/StatusIcon.picker.test.tsx src/components/CoordinatorSidebar.test.tsx src/lib/recent-tasks.test.ts × keeps the covered-blocker state so the sidebar shows the same queued glyph as the task pane (BUG-07) × marks the current row with the same glyph as the trigger for a covered-blocked task × marks the current row with the same glyph as the trigger for a waiting review and a monitored task × shows a covered-blocked recent task with the same queued glyph and label as the task pane × shows project tasks with their full status presentation FAIL StatusIcon.picker.test.tsx > ... covered-blocked task AssertionError: expected 'var(--status-task-icon-blocked)' to be 'var(--status-task-icon-in_queue)' FAIL StatusIcon.picker.test.tsx > ... waiting review and a monitored task AssertionError: expected 'var(--status-task-icon-in_review)' to be 'var(--status-task-icon-backlog)' FAIL CoordinatorSidebar.test.tsx > ... covered-blocked recent task ... AssertionError: expected 'var(--status-task-icon-blocked)' to be 'var(--status-task-icon-in_queue)' FAIL CoordinatorSidebar.test.tsx > ... project tasks with their full status presentation AssertionError: expected "vi.fn()" to be called with arguments: [ 'company', ObjectContaining{…} ] FAIL recent-tasks.test.ts > ... keeps the covered-blocker state ... AssertionError: expected undefined to deeply equal { state: 'covered', …(7) } Tests 5 failed | 32 passed (37) cd server && npx vitest run src/__tests__/issue-summaries-routes.test.ts × projects the covered-blocker state so every task view picks the same queued glyph (BUG-07) AssertionError: expected undefined to match object { state: 'covered', …(2) } Tests 1 failed | 14 passed (15) ``` Green with this change (same commands): ``` Test Files 3 passed (3) Tests 37 passed (37) Test Files 1 passed (1) # server summaries route Tests 15 passed (15) ``` Neighbouring and broader checks: - `StatusIcon`, `StatusBadge`, `StatusGlyph`, picker, sidebar and recent-task suites: 73/73 pass. - Full UI Vitest run: 11106 pass, 1 skipped. Three `IssuesList.test.tsx` tests timed out at 5 s under full-suite load. The same file passes 73/73 when run alone. - `tsc --noEmit` for `ui` and `server`: no errors in changed files. Remaining errors are pre-existing (unbuilt `@paperclipai/paperclip-runner`, generated Storybook catalog). - `pnpm check:token-gates`: all gates clean. Screenshots. The staging task before the fix: the status button is blue, the current "Blocked" row in the menu is red. | Staging task (before) | Staging status menu (before) | | --- | --- | |  |  | Storybook "Same state, every view" (Foundations/Status Language). Before uses the `master` `StatusIcon`/`StatusBadge`; after uses this branch. | Before (master) | After (this PR) | | --- | --- | |  |  | Before: the row glyph is blue, but the chip and the current menu row are red. After: the row glyph, the chip and the current menu row are all the blue "in queue" glyph. Review follow-up (commits1d3a1a0a9e,d5c798ec53): blocker state on summaries, search and case responses is glyph-only (state, reason, counts). Blocker titles and IDs are never returned; a sampled identifier stays only when the actor can read that blocker. Producer-level tests failed before the follow-up and pass after it (\`task-created-items.test.tsx\`, \`CoordinatorTaskCommitment.test.tsx\`, \`company-search-service.test.ts\`, \`cases-routes.test.ts\`, private-blocker case in \`issue-summaries-routes.test.ts\`). Before merge, the branch was test-merged locally with current \`master\`: typecheck clean, 1095 UI and 80 server tests in the affected suites pass. ## Risks - Low risk. Presentation only. Status mutations still use the canonical `status`. - The summaries endpoint now runs the blocker-attention query for blocked rows. The endpoint accepts at most five IDs and runs the query only when a row is blocked. - Sidebar project rows use the `rows` projection instead of `index`. This adds the blocker and review attention reads for at most 12 rows, and only when a person expands a project. - `IssueStatusBadge` shows the blue "in queue" chip for covered-blocked tasks where callers pass `blockerAttention`. The label stays "Blocked" and the full reason is in the tooltip. ## Model Used - Claude Opus 5.5 (`claude-opus-5-5`, Anthropic), through Claude Code with tool use (shell, file edits, browser). Default context window. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
b4cc2d30d2
commit
d807887534
49 files changed
+656
-143
No files matched your search
@@ -855,6 +855,8 @@ export type IssueSummary = Pick<
|
||||
"id" | "companyId" | "title" | "identifier" | "status" | "displayStatus" | "updatedAt"
|
||||
> & {
|
||||
externalConversationState: "active" | "waiting" | null;
|
||||
/** Present for blocked tasks so summary views resolve the same status glyph as detail views. */
|
||||
blockerAttention?: IssueBlockerAttention;
|
||||
};
|
||||
|
||||
export interface Issue {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { IssueDisplayStatus, IssuePriority, IssueStatus } from "../constants.js";
|
||||
import type { IssueBlockerAttention } from "./issue.js";
|
||||
|
||||
export const COMPANY_SEARCH_SCOPES = ["all", "issues", "comments", "documents", "artifacts", "agents", "projects"] as const;
|
||||
export type CompanySearchScope = (typeof COMPANY_SEARCH_SCOPES)[number];
|
||||
@@ -40,6 +41,8 @@ export interface CompanySearchIssueSummary {
|
||||
status: IssueStatus;
|
||||
displayStatus?: IssueDisplayStatus;
|
||||
externalConversationState?: "active" | "waiting" | null;
|
||||
/** Glyph-only blocker state for blocked results (no blocker titles or IDs). */
|
||||
blockerAttention?: IssueBlockerAttention;
|
||||
priority: IssuePriority;
|
||||
assigneeAgentId: string | null;
|
||||
assigneeUserId: string | null;
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
documentRevisions,
|
||||
heartbeatRuns,
|
||||
instanceSettings,
|
||||
issueRelations,
|
||||
issues,
|
||||
labels,
|
||||
projects,
|
||||
@@ -116,6 +117,7 @@ describeEmbeddedPostgres("cases routes", () => {
|
||||
await db.delete(documents);
|
||||
await db.delete(assets);
|
||||
await db.delete(labels);
|
||||
await db.delete(issueRelations);
|
||||
await db.delete(issues);
|
||||
await db.delete(heartbeatRuns);
|
||||
await db.delete(projects);
|
||||
@@ -969,6 +971,33 @@ describeEmbeddedPostgres("cases routes", () => {
|
||||
expect(forIssue.body[0]).toMatchObject({ role: "work" });
|
||||
expect(forIssue.body[0].case).toMatchObject({ id: child.body.id, identifier: child.body.identifier, status: child.body.status });
|
||||
});
|
||||
it("carries glyph-only blocker state on a blocked case task without blocker details (BUG-07)", async () => {
|
||||
await enableCases();
|
||||
const company = await seedCompany("WORK-BLOCKED");
|
||||
const [work, blocker] = await db.insert(issues).values([
|
||||
{ companyId: company.id, title: "Blocked case work", identifier: `${company.issuePrefix}-1`, status: "blocked" },
|
||||
{ companyId: company.id, title: "Hidden case blocker", identifier: `${company.issuePrefix}-2`, status: "todo" },
|
||||
]).returning();
|
||||
await db.insert(issueRelations).values({ companyId: company.id, issueId: blocker!.id, relatedIssueId: work!.id, type: "blocks" });
|
||||
const [record] = await db.insert(cases).values({ companyId: company.id, caseNumber: 1, identifier: `${company.issuePrefix}-C1`,
|
||||
caseType: "factory", title: "Blocked case", workIssueId: work!.id }).returning();
|
||||
const detail = await request(app(boardActor)).get(`/api/cases/${record!.id}`).expect(200);
|
||||
expect(detail.body.workIssue).toMatchObject({ id: work!.id, status: "blocked", blockerAttention: { state: "needs_attention", sampleBlockerIdentifier: null } });
|
||||
expect(JSON.stringify(detail.body.workIssue)).not.toContain("Hidden case blocker");
|
||||
expect(JSON.stringify(detail.body.workIssue)).not.toContain(blocker!.id);
|
||||
|
||||
// Related-task links and the activity feed row draw from the same state.
|
||||
await db.insert(caseIssueLinks).values({ companyId: company.id, caseId: record!.id, issueId: work!.id, role: "reference" });
|
||||
const withLink = await request(app(boardActor)).get(`/api/cases/${record!.id}`).expect(200);
|
||||
expect(withLink.body.issueLinks[0].issue).toMatchObject({ id: work!.id, blockerAttention: { state: "needs_attention", sampleBlockerIdentifier: null } });
|
||||
|
||||
await db.insert(caseEvents).values({ companyId: company.id, caseId: record!.id, kind: "issue_linked",
|
||||
actorType: "system", payload: { issueId: work!.id } });
|
||||
const events = await request(app(boardActor)).get(`/api/cases/${record!.id}/events`).expect(200);
|
||||
const linked = events.body.find((event: { kind: string }) => event.kind === "issue_linked");
|
||||
expect(linked.issue).toMatchObject({ id: work!.id, status: "blocked", blockerAttention: { state: "needs_attention", sampleBlockerIdentifier: null } });
|
||||
expect(JSON.stringify(linked.issue)).not.toContain("Hidden case blocker");
|
||||
});
|
||||
it("enriches case pages and detail with current work in bounded batch queries", async () => {
|
||||
await enableCases();
|
||||
const company = await seedCompany("WORK-LIST");
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
issueComments,
|
||||
issueDocuments,
|
||||
issueAccessGrants,
|
||||
issueRelations,
|
||||
issueLabels,
|
||||
issueWorkProducts,
|
||||
issues,
|
||||
@@ -175,6 +176,29 @@ describeEmbeddedPostgres("companySearchService", () => {
|
||||
return id;
|
||||
}
|
||||
|
||||
it("returns glyph-only blocker state for blocked results without blocker details (BUG-07)", async () => {
|
||||
const companyId = await createCompany();
|
||||
const parent = await createIssue(companyId, { title: "Launch parent", status: "blocked" });
|
||||
const blocker = await createIssue(companyId, { title: "Hidden blocker detail", status: "todo", identifier: "SRCH-2" });
|
||||
const open = await createIssue(companyId, { title: "Launch open", status: "todo" });
|
||||
await db.insert(issueRelations).values({ companyId, issueId: blocker, relatedIssueId: parent, type: "blocks" });
|
||||
try {
|
||||
const result = await svc.search(companyId, companySearchQuerySchema.parse({ q: "launch", scope: "issues" }));
|
||||
const blocked = result.results.find((row) => row.id === parent)?.issue;
|
||||
expect(blocked?.blockerAttention).toMatchObject({ state: "needs_attention", unresolvedBlockerCount: 1 });
|
||||
expect(Object.keys(blocked!.blockerAttention!).sort()).toEqual([
|
||||
"attentionBlockerCount", "coveredBlockerCount", "reason", "sampleBlockerIdentifier",
|
||||
"sampleStalledBlockerIdentifier", "stalledBlockerCount", "state", "unresolvedBlockerCount",
|
||||
]);
|
||||
expect(blocked?.blockerAttention?.sampleBlockerIdentifier).toBeNull();
|
||||
expect(JSON.stringify(result)).not.toContain("Hidden blocker detail");
|
||||
expect(JSON.stringify(result)).not.toContain(blocker);
|
||||
expect(result.results.find((row) => row.id === open)?.issue).not.toHaveProperty("blockerAttention");
|
||||
} finally {
|
||||
await db.delete(issueRelations);
|
||||
}
|
||||
});
|
||||
|
||||
it("paginates issue-result-only searches before unrelated entity competition", async () => {
|
||||
const companyId = await createCompany();
|
||||
const now = Date.now();
|
||||
|
||||
@@ -4,7 +4,7 @@ import request from "supertest";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { afterAll, beforeAll, expect, it, vi } from "vitest";
|
||||
import {
|
||||
agents, chatConversations, chatEndpoints, issues, toolApplications, toolConnections,
|
||||
agents, chatConversations, chatEndpoints, heartbeatRuns, issueRelations, issues, toolApplications, toolConnections,
|
||||
} from "@paperclipai/db";
|
||||
import { issueRoutes } from "../routes/issues.js";
|
||||
import * as issueServices from "../services/issues.js";
|
||||
@@ -110,4 +110,47 @@ describeEmbeddedPostgres("bounded issue summaries", () => {
|
||||
expect(active.externalConversationState).toBe("active");
|
||||
expect(active.updatedAt).toBe(waiting.updatedAt);
|
||||
});
|
||||
|
||||
it("projects the covered-blocker state so every task view picks the same queued glyph (BUG-07)", async () => {
|
||||
const f = await fixture();
|
||||
const [agent] = await ctx.db.insert(agents).values({ companyId: f.companyId, name: "Builder", role: "engineer", adapterType: "process" }).returning();
|
||||
const [parent] = await ctx.db.insert(issues).values({ companyId: f.companyId, title: "Parent", status: "blocked" }).returning();
|
||||
const [child] = await ctx.db.insert(issues).values({
|
||||
companyId: f.companyId, title: "Running child", identifier: "SUM-9", status: "todo", parentId: parent!.id, assigneeAgentId: agent!.id,
|
||||
}).returning();
|
||||
await ctx.db.insert(issueRelations).values({ companyId: f.companyId, issueId: child!.id, relatedIssueId: parent!.id, type: "blocks" });
|
||||
const runId = randomUUID();
|
||||
await ctx.db.insert(heartbeatRuns).values({ id: runId, companyId: f.companyId, agentId: agent!.id, status: "running", contextSnapshot: { issueId: child!.id } });
|
||||
await ctx.db.update(issues).set({ executionRunId: runId }).where(eq(issues.id, child!.id));
|
||||
|
||||
const res = await request(app(f.actor)).get(url(f.companyId)).query({ ids: `${parent!.id},${f.rows[0]!.id}` }).expect(200);
|
||||
expect(res.body[0].blockerAttention).toMatchObject({ state: "covered", reason: "active_child", sampleBlockerIdentifier: "SUM-9" });
|
||||
// Only what the glyph needs: no blocker titles or IDs.
|
||||
expect(Object.keys(res.body[0].blockerAttention).sort()).toEqual([
|
||||
"attentionBlockerCount", "coveredBlockerCount", "reason", "sampleBlockerIdentifier",
|
||||
"sampleStalledBlockerIdentifier", "stalledBlockerCount", "state", "unresolvedBlockerCount",
|
||||
]);
|
||||
expect(JSON.stringify(res.body)).not.toContain("Running child");
|
||||
expect(JSON.stringify(res.body)).not.toContain(child!.id);
|
||||
// Unblocked tasks keep the bounded summary shape.
|
||||
expect(res.body[1]).not.toHaveProperty("blockerAttention");
|
||||
});
|
||||
|
||||
it("does not disclose a private blocker through a readable blocked task's summary", async () => {
|
||||
const f = await fixture();
|
||||
const [parent] = await ctx.db.insert(issues).values({ companyId: f.companyId, title: "Public parent", status: "blocked" }).returning();
|
||||
const privateId = randomUUID();
|
||||
await ctx.db.insert(issues).values({
|
||||
id: privateId, companyId: f.companyId, title: "Secret blocker title", status: "todo", visibility: "private",
|
||||
privacyRootIssueId: privateId, responsibleUserId: "someone-else", createdByUserId: "someone-else",
|
||||
});
|
||||
await ctx.db.insert(issueRelations).values({ companyId: f.companyId, issueId: privateId, relatedIssueId: parent!.id, type: "blocks" });
|
||||
|
||||
const res = await request(app(f.actor)).get(url(f.companyId)).query({ ids: parent!.id }).expect(200);
|
||||
expect(res.body).toHaveLength(1);
|
||||
expect(res.body[0].blockerAttention?.state).toBeDefined();
|
||||
expect(JSON.stringify(res.body)).not.toContain("Secret blocker title");
|
||||
expect(JSON.stringify(res.body)).not.toContain(privateId);
|
||||
expect(res.body[0].blockerAttention).not.toHaveProperty("terminalBlocker");
|
||||
});
|
||||
});
|
||||
@@ -56,6 +56,8 @@ import type { StorageService } from "../storage/types.js";
|
||||
import { authorizationService, issueReadSqlCondition, type AuthorizationActor } from "../services/authorization.js";
|
||||
import { assertActorCanReadCase, assertActorCanWriteCase, caseReadSqlCondition, filterCasesForActor, retainCaseAccessSource } from "../services/case-access.js";
|
||||
import { enrichCaseWork } from "../services/case-work-summary.js";
|
||||
import { listIssueStatusBlockerAttention } from "../services/issue-status-presentation.js";
|
||||
import type { IssueBlockerAttention } from "@paperclipai/shared";
|
||||
import { listCaseTaskDocuments } from "../services/case-task-documents.js";
|
||||
import { authorizeCaseCreation, authorizeCaseMutation } from "../services/case-mutation-authorization.js";
|
||||
import { logger } from "../middleware/logger.js";
|
||||
@@ -270,6 +272,19 @@ async function resolveAgentNames(db: CaseRouteDb, agentIds: (string | null)[]) {
|
||||
return new Map(rows.map((row) => [row.id, row.name]));
|
||||
}
|
||||
|
||||
type CaseAttributedIssue = { id: string; identifier: string; title: string; status: string; blockerAttention?: IssueBlockerAttention };
|
||||
|
||||
/** Glyph-only blocker state so a case activity row draws the same status glyph as the task pane. */
|
||||
async function withStatusBlockerAttention(db: CaseRouteDb, companyId: string, map: Map<string, CaseAttributedIssue>) {
|
||||
const attention = await listIssueStatusBlockerAttention(db as Db, companyId, [...map.values()]);
|
||||
if (attention.size === 0) return map;
|
||||
for (const [key, issue] of map) {
|
||||
const item = attention.get(issue.id);
|
||||
if (item) map.set(key, { ...issue, blockerAttention: item });
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch resolve run → issue attribution. Mirrors resolveIssueForRun's precedence
|
||||
* (latest-updated issue whose execution/checkout/origin run matches), but for a
|
||||
@@ -277,7 +292,7 @@ async function resolveAgentNames(db: CaseRouteDb, agentIds: (string | null)[]) {
|
||||
*/
|
||||
async function resolveIssuesForRuns(db: CaseRouteDb, companyId: string, runIds: (string | null)[], actor: AuthorizationActor) {
|
||||
const valid = [...new Set(runIds.filter((id): id is string => !!id && isUuidLike(id)))];
|
||||
const map = new Map<string, { id: string; identifier: string; title: string; status: string }>();
|
||||
const map = new Map<string, CaseAttributedIssue>();
|
||||
if (valid.length === 0) return map;
|
||||
const rows = await db
|
||||
.select({
|
||||
@@ -314,7 +329,7 @@ async function resolveIssuesForRuns(db: CaseRouteDb, companyId: string, runIds:
|
||||
map.set(runId, { id: match.id, identifier: match.identifier ?? match.id, title: match.title, status: match.status });
|
||||
}
|
||||
}
|
||||
return map;
|
||||
return withStatusBlockerAttention(db, companyId, map);
|
||||
}
|
||||
|
||||
function payloadIssueIdForEvent(kind: string, payload: Record<string, unknown> | null | undefined) {
|
||||
@@ -325,7 +340,7 @@ function payloadIssueIdForEvent(kind: string, payload: Record<string, unknown> |
|
||||
|
||||
async function resolveIssuesByIds(db: CaseRouteDb, companyId: string, issueIds: (string | null)[], actor: AuthorizationActor) {
|
||||
const valid = [...new Set(issueIds.filter((id): id is string => !!id && isUuidLike(id)))];
|
||||
const map = new Map<string, { id: string; identifier: string; title: string; status: string }>();
|
||||
const map = new Map<string, CaseAttributedIssue>();
|
||||
if (valid.length === 0) return map;
|
||||
const rows = await db
|
||||
.select({
|
||||
@@ -342,7 +357,7 @@ async function resolveIssuesByIds(db: CaseRouteDb, companyId: string, issueIds:
|
||||
for (const [index, row] of rows.entries()) {
|
||||
if (readDecisions[index]?.allowed) map.set(row.id, { id: row.id, identifier: row.identifier ?? row.id, title: row.title, status: row.status });
|
||||
}
|
||||
return map;
|
||||
return withStatusBlockerAttention(db, companyId, map);
|
||||
}
|
||||
|
||||
async function autoLinkRunIssue(db: CaseRouteDb, input: {
|
||||
@@ -468,18 +483,21 @@ async function loadCaseDetail(db: CaseRouteDb, row: typeof cases.$inferSelect, a
|
||||
type: "issue" as const, companyId: row.companyId, issueId: item.issue.id,
|
||||
})));
|
||||
const [workSummary] = await enrichCaseWork(db, row.companyId, [row], actor);
|
||||
const visibleLinks = linkRows.filter((_, index) => linkDecisions[index]?.allowed);
|
||||
const linkBlockerAttention = await listIssueStatusBlockerAttention(db as Db, row.companyId, visibleLinks.map((item) => item.issue));
|
||||
return {
|
||||
...workSummary!,
|
||||
...taskDocumentRollup,
|
||||
parent: visibleParent,
|
||||
labels: labelRows.map((item) => item.label),
|
||||
issueLinks: linkRows.filter((_, index) => linkDecisions[index]?.allowed).map((item) => ({
|
||||
issueLinks: visibleLinks.map((item) => ({
|
||||
...item.link,
|
||||
issue: {
|
||||
id: item.issue.id,
|
||||
identifier: item.issue.identifier,
|
||||
title: item.issue.title,
|
||||
status: item.issue.status,
|
||||
...(linkBlockerAttention.has(item.issue.id) ? { blockerAttention: linkBlockerAttention.get(item.issue.id)! } : {}),
|
||||
},
|
||||
})),
|
||||
documents: documentRows.map((item) => ({
|
||||
|
||||
+21
-10
@@ -14,6 +14,7 @@ import { parseObject } from "../adapters/utils.js";
|
||||
import { prepareRestrictedWorkspaceRetry } from "../services/restricted-workspace-retry.js";
|
||||
import { issueSummariesQuerySchema, type IssueSummary } from "@paperclipai/shared";
|
||||
import { listIssueSummaries } from "../services/issue-summaries.js";
|
||||
import { listIssueStatusBlockerAttention } from "../services/issue-status-presentation.js";
|
||||
import { assertNoCaseHandoffMutation } from "../services/heartbeat/case-handoff-lifecycle.js";
|
||||
import { assertLegacyRunTaskOwnership } from "../legacy-run-task-ownership.js";
|
||||
import { runRetainsIssueIdentity } from "../services/task-repository-acquisition.js";
|
||||
@@ -8418,16 +8419,26 @@ export function issueRoutes(
|
||||
}
|
||||
const rows = await listIssueSummaries(db, companyId, parsed.data.ids, await issueReadSqlCondition(db, req.actor));
|
||||
const visible = await filterIssuesForActor(req, rows);
|
||||
const summaries: IssueSummary[] = visible.map((row) => ({
|
||||
id: row.id,
|
||||
companyId: row.companyId,
|
||||
title: row.title,
|
||||
identifier: row.identifier,
|
||||
status: row.status as IssueSummary["status"],
|
||||
displayStatus: row.displayStatus,
|
||||
updatedAt: row.updatedAt,
|
||||
externalConversationState: row.externalConversationState,
|
||||
}));
|
||||
// Blocked rows carry glyph-only blocker state so every task view resolves
|
||||
// the same glyph (covered → "in queue") as the task pane. At most five IDs.
|
||||
const blockerAttention = await listIssueStatusBlockerAttention(db, companyId, visible, async (ids) => new Set(
|
||||
(await filterIssuesForActor(req, await listIssueSummaries(db, companyId, ids, await issueReadSqlCondition(db, req.actor))))
|
||||
.map((row) => row.id),
|
||||
));
|
||||
const summaries: IssueSummary[] = visible.map((row) => {
|
||||
const attention = blockerAttention.get(row.id);
|
||||
return {
|
||||
id: row.id,
|
||||
companyId: row.companyId,
|
||||
title: row.title,
|
||||
identifier: row.identifier,
|
||||
status: row.status as IssueSummary["status"],
|
||||
displayStatus: row.displayStatus,
|
||||
updatedAt: row.updatedAt,
|
||||
externalConversationState: row.externalConversationState,
|
||||
...(attention ? { blockerAttention: attention } : {}),
|
||||
};
|
||||
});
|
||||
res.setHeader("Cache-Control", "private, no-store");
|
||||
res.json(summaries);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { and, eq, getTableColumns, inArray } from "drizzle-orm";
|
||||
import { agents, caseHandoffs, issues, workflows, type Db } from "@paperclipai/db";
|
||||
import { authorizationService, issueReadSqlCondition, type AuthorizationActor } from "./authorization.js";
|
||||
import { listIssueStatusBlockerAttention } from "./issue-status-presentation.js";
|
||||
|
||||
type CaseSummaryDb = Db | Parameters<Parameters<Db["transaction"]>[0]>[0];
|
||||
type CaseWorkIdentity = { id: string; companyId: string; workIssueId: string | null; workflowId?: string | null };
|
||||
@@ -35,6 +36,9 @@ export async function enrichCaseWork<T extends CaseWorkIdentity>(
|
||||
})));
|
||||
const visibleWork = workRows.filter((_, index) => workDecisions[index]?.allowed);
|
||||
const workById = new Map(visibleWork.map(row => [row.id, row]));
|
||||
// Glyph-only blocker state (no blocker titles, IDs or identifiers), so a
|
||||
// covered-blocked case task shows the same "in queue" glyph as its task pane.
|
||||
const blockerAttention = await listIssueStatusBlockerAttention(db as Db, companyId, visibleWork);
|
||||
// A malformed/legacy link must not expose pending task instructions merely
|
||||
// because its case is readable. The primary task has its own access boundary.
|
||||
const primaryByCase = new Map(rows.map(row => [row.id, row.workIssueId]));
|
||||
@@ -56,7 +60,8 @@ export async function enrichCaseWork<T extends CaseWorkIdentity>(
|
||||
return {
|
||||
...row,
|
||||
workflow: row.workflowId ? workflowById.get(row.workflowId) ?? null : null,
|
||||
workIssue: work ? { ...work, assigneeAgentName: work.assigneeAgentId ? names.get(work.assigneeAgentId) ?? null : null } : null,
|
||||
workIssue: work ? { ...work, assigneeAgentName: work.assigneeAgentId ? names.get(work.assigneeAgentId) ?? null : null,
|
||||
...(blockerAttention.has(work.id) ? { blockerAttention: blockerAttention.get(work.id)! } : {}) } : null,
|
||||
pendingHandoff: pending ? { ...pending, fromAgentName: pending.fromAgentId ? names.get(pending.fromAgentId) ?? null : null,
|
||||
toAgentName: pending.toAgentId ? names.get(pending.toAgentId) ?? null : null } : null,
|
||||
};
|
||||
|
||||
@@ -42,6 +42,7 @@ import { companyArtifactsService } from "./company-artifacts.js";
|
||||
import { companySearchExtractService } from "./company-search-extract.js";
|
||||
import { visibleIssueCondition } from "./issue-visibility.js";
|
||||
import { parseTaskSearch, taskSearchCtes, taskSearchScore, taskSearchFieldMatch, taskSearchTermMatch } from "./task-search.js";
|
||||
import { listIssueStatusBlockerAttention } from "./issue-status-presentation.js";
|
||||
|
||||
const SNIPPET_MAX_CHARS = 240;
|
||||
export const COMPANY_SEARCH_BRANCH_FETCH_LIMIT = COMPANY_SEARCH_MAX_OFFSET + COMPANY_SEARCH_MAX_LIMIT + 1;
|
||||
@@ -1155,8 +1156,15 @@ export function companySearchService(db: Db) {
|
||||
const states = await db.select({ id: issues.id, state: externalConversationStateSql() }).from(issues)
|
||||
.where(and(eq(issues.companyId, companyId), inArray(issues.id, issueIds)));
|
||||
const byId = new Map(states.map((row) => [row.id, row.state]));
|
||||
// Glyph-only blocker state (no blocker titles, IDs or identifiers), so a
|
||||
// covered-blocked result shows the same "in queue" glyph as its task.
|
||||
const blockerAttention = await listIssueStatusBlockerAttention(db, companyId,
|
||||
paged.flatMap((result) => result.issue ? [result.issue] : []));
|
||||
for (const result of paged) {
|
||||
if (result.issue) result.issue.externalConversationState = byId.get(result.issue.id) ?? null;
|
||||
if (!result.issue) continue;
|
||||
result.issue.externalConversationState = byId.get(result.issue.id) ?? null;
|
||||
const attention = blockerAttention.get(result.issue.id);
|
||||
if (attention) result.issue.blockerAttention = attention;
|
||||
}
|
||||
}
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { and, eq, inArray } from "drizzle-orm";
|
||||
import { issues, type Db } from "@paperclipai/db";
|
||||
import type { IssueBlockerAttention } from "@paperclipai/shared";
|
||||
import { issueService } from "./issues.js";
|
||||
|
||||
/**
|
||||
* Blocker state for task status glyphs on bounded summary surfaces (recent
|
||||
* tasks, search results, case work). Every view resolves the same glyph from
|
||||
* it (covered → "in queue"), so a blocked task never looks different in a
|
||||
* sidebar than in its detail pane.
|
||||
*
|
||||
* The blocker walk is company-scoped, so the result carries only what the
|
||||
* glyph needs: state, reason and counts. Blocker titles and IDs never leave
|
||||
* this helper. A sampled blocker identifier is kept only when the caller
|
||||
* confirms that the actor can read that blocker.
|
||||
*/
|
||||
export async function listIssueStatusBlockerAttention(
|
||||
db: Db,
|
||||
companyId: string,
|
||||
rows: ReadonlyArray<{ id: string; status: string }>,
|
||||
readableIssueIds?: (ids: string[]) => Promise<ReadonlySet<string>>,
|
||||
): Promise<Map<string, IssueBlockerAttention>> {
|
||||
const blockedIds = [...new Set(rows.filter((row) => row.status === "blocked").map((row) => row.id))];
|
||||
if (blockedIds.length === 0) return new Map();
|
||||
const nodes = await db.select({
|
||||
id: issues.id,
|
||||
companyId: issues.companyId,
|
||||
parentId: issues.parentId,
|
||||
identifier: issues.identifier,
|
||||
title: issues.title,
|
||||
status: issues.status,
|
||||
executionRunId: issues.executionRunId,
|
||||
assigneeAgentId: issues.assigneeAgentId,
|
||||
assigneeUserId: issues.assigneeUserId,
|
||||
}).from(issues).where(and(eq(issues.companyId, companyId), inArray(issues.id, blockedIds)));
|
||||
const attention = await issueService(db).listBlockerAttention(companyId, nodes);
|
||||
const sampledIds = [...new Set([...attention.values()].flatMap((item) => item.terminalBlocker ? [item.terminalBlocker.id] : []))];
|
||||
const readable = readableIssueIds && sampledIds.length > 0 ? await readableIssueIds(sampledIds) : new Set<string>();
|
||||
|
||||
const result = new Map<string, IssueBlockerAttention>();
|
||||
for (const [issueId, item] of attention) {
|
||||
const sample = item.terminalBlocker;
|
||||
const readableSample = sample && readable.has(sample.id) ? (sample.identifier ?? sample.id) : null;
|
||||
result.set(issueId, {
|
||||
state: item.state,
|
||||
reason: item.reason,
|
||||
unresolvedBlockerCount: item.unresolvedBlockerCount,
|
||||
coveredBlockerCount: item.coveredBlockerCount,
|
||||
stalledBlockerCount: item.stalledBlockerCount,
|
||||
attentionBlockerCount: item.attentionBlockerCount,
|
||||
sampleBlockerIdentifier: readableSample && item.sampleBlockerIdentifier === readableSample ? readableSample : null,
|
||||
sampleStalledBlockerIdentifier: readableSample && item.sampleStalledBlockerIdentifier === readableSample ? readableSample : null,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
+7
-1
@@ -1,4 +1,4 @@
|
||||
import type { CaseHandoff, DocumentRevision, HandoffCaseInput, HandoffCaseResult, IssueDocument, IssueLabel } from "@paperclipai/shared";
|
||||
import type { CaseHandoff, DocumentRevision, HandoffCaseInput, HandoffCaseResult, IssueBlockerAttention, IssueDocument, IssueLabel } from "@paperclipai/shared";
|
||||
import { api } from "./client";
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
@@ -102,6 +102,8 @@ export interface CaseIssueLink {
|
||||
identifier: string;
|
||||
title: string;
|
||||
status: string;
|
||||
/** Glyph-only blocker state; present for blocked tasks. */
|
||||
blockerAttention?: IssueBlockerAttention;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -139,6 +141,8 @@ export function isImageAttachment(attachment: CaseAttachmentRef): boolean {
|
||||
export interface CaseWorkIssue {
|
||||
id: string; identifier: string | null; title: string; status: string; statusVersion: number;
|
||||
assigneeAgentId: string | null; assigneeAgentName: string | null;
|
||||
/** Glyph-only blocker state; present for blocked tasks. */
|
||||
blockerAttention?: IssueBlockerAttention;
|
||||
}
|
||||
|
||||
/** The full detail payload (loadCaseDetail on the server). */
|
||||
@@ -183,6 +187,8 @@ export interface CaseAttributionIssue {
|
||||
identifier: string;
|
||||
title: string;
|
||||
status: string;
|
||||
/** Glyph-only blocker state; present for blocked tasks. */
|
||||
blockerAttention?: IssueBlockerAttention;
|
||||
}
|
||||
|
||||
export interface CaseEvent {
|
||||
|
||||
@@ -27,6 +27,7 @@ import { Identity } from "./Identity";
|
||||
import { StatusIcon } from "./StatusIcon";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { taskStatusIconProps } from "@/lib/task-status-presentation";
|
||||
|
||||
interface BlockedInboxViewProps {
|
||||
companyId: string;
|
||||
@@ -377,7 +378,7 @@ function BlockedInboxRow({
|
||||
showDivider={presentation === "legacy"}
|
||||
statusSlot={presentation === "task"
|
||||
? showStatusColumn
|
||||
? <StatusIcon status={row.issue.status} displayStatus={row.issue.displayStatus} blockerAttention={blockerAttention} />
|
||||
? <StatusIcon {...taskStatusIconProps(row.issue)} blockerAttention={blockerAttention} />
|
||||
: <span className="inline-flex size-4" aria-hidden="true" />
|
||||
: undefined}
|
||||
showIdentifier={presentation === "task" ? showIdentifierColumn : undefined}
|
||||
@@ -391,7 +392,7 @@ function BlockedInboxRow({
|
||||
) : undefined}
|
||||
mobileLeading={
|
||||
<span className="flex shrink-0 items-center gap-1.5 pt-px">
|
||||
<StatusIcon status={row.issue.status} displayStatus={row.issue.displayStatus} blockerAttention={blockerAttention} />
|
||||
<StatusIcon {...taskStatusIconProps(row.issue)} blockerAttention={blockerAttention} />
|
||||
</span>
|
||||
}
|
||||
titleSuffix={
|
||||
@@ -423,7 +424,7 @@ function BlockedRowDesktopMeta({
|
||||
const identifier = row.issue.identifier ?? row.issue.id.slice(0, 8);
|
||||
return (
|
||||
<span className="hidden shrink-0 items-center gap-2 sm:inline-flex">
|
||||
{showStatusColumn ? <StatusIcon status={row.issue.status} displayStatus={row.issue.displayStatus} blockerAttention={blockerAttention} /> : null}
|
||||
{showStatusColumn ? <StatusIcon {...taskStatusIconProps(row.issue)} blockerAttention={blockerAttention} /> : null}
|
||||
{showIdentifierColumn ? <span className="font-mono text-xs text-muted-foreground">{identifier}</span> : null}
|
||||
</span>
|
||||
);
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { cn, relativeTime } from "@/lib/utils";
|
||||
import { taskStatusIconProps } from "@/lib/task-status-presentation";
|
||||
|
||||
const EVENT_LABEL: Record<CaseEventKind, string> = {
|
||||
created: "created",
|
||||
@@ -75,7 +76,7 @@ export function CaseEventRow({ event, compact = false }: { event: CaseEvent; com
|
||||
className="inline-flex min-w-0 items-center gap-1 text-foreground/80 hover:underline"
|
||||
title={event.issue.title}
|
||||
>
|
||||
<StatusIcon status={event.issue.status} size="sm" />
|
||||
<StatusIcon {...taskStatusIconProps(event.issue)} size="sm" />
|
||||
<span className="shrink-0 font-mono">{event.issue.identifier}</span>
|
||||
<span className="min-w-0 truncate">{event.issue.title}</span>
|
||||
</Link>
|
||||
|
||||
@@ -10,15 +10,17 @@ import { coordinatorAttentionQueryKey, coordinatorAttentionSummaryQueryKey } fro
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
const fixture = vi.hoisted(() => ({ attention: vi.fn(), summary: vi.fn(), openNewIssue: vi.fn(), pathname: "/COO/issues", collapsed: false, userId: "reviewer", chats: [] as Array<{ isUnreadForMe: boolean }> }));
|
||||
const fixture = vi.hoisted(() => ({ attention: vi.fn(), summary: vi.fn(), openNewIssue: vi.fn(), pathname: "/COO/issues", collapsed: false, userId: "reviewer", chats: [] as Array<{ isUnreadForMe: boolean }>,
|
||||
recentTasks: [] as Array<Record<string, unknown>>, projects: [] as Array<Record<string, unknown>>, projectTasks: vi.fn() }));
|
||||
vi.mock("@/hooks/useAgentChatNavigation", () => ({ useAgentChatNavigation: () => ({
|
||||
companyId: "company", userId: fixture.userId, agents: { data: [] }, chats: { data: fixture.chats }, session: { isSuccess: true },
|
||||
}) }));
|
||||
vi.mock("@/hooks/useRecentTasks", () => ({ useRecentTasks: () => ({ entries: [] }) }));
|
||||
vi.mock("@/hooks/useRecentTasks", () => ({ useRecentTasks: () => ({ entries: fixture.recentTasks }) }));
|
||||
vi.mock("@/api/issues", () => ({ issuesApi: { listCompact: fixture.projectTasks } }));
|
||||
vi.mock("@/context/DialogContext", () => ({ useDialogActions: () => ({ openNewIssue: fixture.openNewIssue }) }));
|
||||
vi.mock("@/context/SidebarContext", () => ({ useSidebar: () => ({ collapsed: fixture.collapsed, peeking: false, isMobile: false, setSidebarOpen: vi.fn() }) }));
|
||||
vi.mock("@/api/attention", () => ({ attentionApi: { list: fixture.attention, summary: fixture.summary } }));
|
||||
vi.mock("@/api/projects", () => ({ projectsApi: { list: async () => [] } }));
|
||||
vi.mock("@/api/projects", () => ({ projectsApi: { list: async () => fixture.projects } }));
|
||||
vi.mock("./SidebarCompanyMenu", () => ({ SidebarCompanyMenu: () => null }));
|
||||
vi.mock("@/lib/router", () => ({
|
||||
useLocation: () => ({ pathname: fixture.pathname, search: "" }),
|
||||
@@ -43,7 +45,7 @@ function summary(items: AttentionItem[]) {
|
||||
let root: Root, container: HTMLDivElement, client: QueryClient;
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks(); localStorage.clear(); fixture.collapsed = false; fixture.userId = "reviewer"; fixture.pathname = "/COO/issues";
|
||||
fixture.chats = [];
|
||||
fixture.chats = []; fixture.recentTasks = []; fixture.projects = [];
|
||||
fixture.summary.mockResolvedValue(summary([pending("question"), pending("review")]));
|
||||
container = document.createElement("div"); document.body.appendChild(container); root = createRoot(container);
|
||||
client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
@@ -214,3 +216,36 @@ describe("Coordinator collection reads", () => {
|
||||
expect(decisionLink().querySelector('[data-slot="badge"]')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Coordinator task status glyphs (BUG-07)", () => {
|
||||
const covered = {
|
||||
state: "covered", reason: "active_child", unresolvedBlockerCount: 1, coveredBlockerCount: 1,
|
||||
attentionBlockerCount: 0, stalledBlockerCount: 0, sampleBlockerIdentifier: "ONB-3", sampleStalledBlockerIdentifier: null,
|
||||
};
|
||||
const glyph = (href: string) => container.querySelector<SVGElement>(`a[href="${href}"] svg[role="img"]`);
|
||||
|
||||
it("shows a covered-blocked recent task with the same queued glyph and label as the task pane", async () => {
|
||||
fixture.recentTasks = [{ id: "task-1", companyId: "company", identifier: "ONB-1", title: "Paperclip onboarding",
|
||||
status: "blocked", displayStatus: "blocked", externalConversationState: null, blockerAttention: covered, recordedAt: 1 }];
|
||||
await renderSidebar();
|
||||
expect(glyph("/issues/ONB-1")?.style.color).toBe("var(--status-task-icon-in_queue)");
|
||||
expect(glyph("/issues/ONB-1")?.getAttribute("aria-label")).toBe("Blocked · waiting on active sub-task ONB-3");
|
||||
});
|
||||
|
||||
it("shows project tasks with their full status presentation", async () => {
|
||||
fixture.projects = [{ id: "project-1", companyId: "company", name: "Onboarding", urlKey: "onboarding" }];
|
||||
fixture.projectTasks.mockResolvedValue([
|
||||
{ id: "task-1", identifier: "ONB-1", title: "Blocked parent", status: "blocked", displayStatus: "blocked", blockerAttention: covered },
|
||||
{ id: "task-3", identifier: "ONB-3", title: "Waiting review", status: "in_review", displayStatus: "in_review", externalConversationState: "waiting" },
|
||||
]);
|
||||
fixture.pathname = "/COO/projects/onboarding";
|
||||
await renderSidebar();
|
||||
const expand = container.querySelector<HTMLButtonElement>('button[aria-label="Expand Onboarding"]');
|
||||
expect(expand).not.toBeNull();
|
||||
await act(async () => expand!.click());
|
||||
await flush();
|
||||
expect(fixture.projectTasks).toHaveBeenCalledWith("company", expect.objectContaining({ projectId: "project-1", projection: "rows" }));
|
||||
expect(glyph("/issues/ONB-1")?.style.color).toBe("var(--status-task-icon-in_queue)");
|
||||
expect(glyph("/issues/ONB-3")?.getAttribute("aria-label")).toBe("Idle");
|
||||
});
|
||||
});
|
||||
@@ -22,6 +22,7 @@ import { Flyout, FlyoutContent, FlyoutTrigger, FlyoutAppearanceProvider } from "
|
||||
import { Button } from "./ui/button";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
|
||||
import { StatusIcon } from "./StatusIcon";
|
||||
import { taskStatusIconProps } from "@/lib/task-status-presentation";
|
||||
|
||||
const moreDestinations = [
|
||||
{ label: "Search", to: "/search", icon: Search },
|
||||
@@ -41,7 +42,7 @@ function ProjectRecent({ project }: { project: Project }) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const { data: tasks, isPending, error } = useQuery({
|
||||
queryKey: [...queryKeys.issues.listByProject(project.companyId, project.id), "coordinator-recents"],
|
||||
queryFn: () => issuesApi.listCompact(project.companyId, { projection: "index", projectId: project.id, limit: 12, sortField: "updated", sortDir: "desc" }),
|
||||
queryFn: () => issuesApi.listCompact(project.companyId, { projection: "rows", projectId: project.id, limit: 12, sortField: "updated", sortDir: "desc" }),
|
||||
enabled: expanded,
|
||||
});
|
||||
return <div>
|
||||
@@ -54,7 +55,7 @@ function ProjectRecent({ project }: { project: Project }) {
|
||||
</div>
|
||||
{expanded && <div className="ml-5 border-l border-border pl-1">
|
||||
{isPending ? <p className="px-3 py-2 text-xs text-muted-foreground" role="status">Loading tasks…</p> : error ? <p className="px-3 py-2 text-xs text-muted-foreground">Couldn’t load tasks.</p> : tasks?.length ? tasks.map(task =>
|
||||
<SidebarNavItem className="design-sidebar-item" key={task.id} to={`/issues/${task.identifier ?? task.id}`} label={task.title} iconNode={<StatusIcon status={task.status} displayStatus={task.displayStatus} />} />)
|
||||
<SidebarNavItem className="design-sidebar-item" key={task.id} to={`/issues/${task.identifier ?? task.id}`} label={task.title} iconNode={<StatusIcon {...taskStatusIconProps(task)} />} />)
|
||||
: <p className="px-3 py-2 text-xs text-muted-foreground">No tasks yet.</p>}
|
||||
</div>}
|
||||
</div>;
|
||||
@@ -148,7 +149,7 @@ export function CoordinatorSidebar({ children }: { children?: ReactNode }) {
|
||||
{!rail && session.isSuccess && recents.length > 0 && <div className="design-sidebar-recents"><SidebarSection label="Recents">
|
||||
<div className="design-sidebar-recent-list scrollbar-while-scrolling" onScroll={handleRecentScroll} role="region" aria-label="Recents" tabIndex={0}>
|
||||
{recents.map(item => item.kind === "task"
|
||||
? <SidebarNavItem className="design-sidebar-item" key={item.id} to={`/issues/${item.task.identifier ?? item.id}`} label={item.task.title} iconNode={<StatusIcon status={item.task.status} displayStatus={item.task.displayStatus} externalConversationState={item.task.externalConversationState} />} />
|
||||
? <SidebarNavItem className="design-sidebar-item" key={item.id} to={`/issues/${item.task.identifier ?? item.id}`} label={item.task.title} iconNode={<StatusIcon {...taskStatusIconProps(item.task)} />} />
|
||||
: item.kind === "chat"
|
||||
? <SidebarNavItem className="design-sidebar-item" key={item.id} to={`/chats/${encodeURIComponent(agentRouteRef(item.agent))}`} label={item.agent.name} icon={MessageCircle} />
|
||||
: <ProjectRecent key={item.id} project={item.project} />)}
|
||||
|
||||
@@ -22,6 +22,7 @@ import { timeAgo } from "../lib/timeAgo";
|
||||
import { Identity } from "./Identity";
|
||||
import { StatusIcon } from "./StatusIcon";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { taskStatusIconProps } from "@/lib/task-status-presentation";
|
||||
|
||||
export const issueTrailingColumns: InboxIssueColumn[] = ["assignee", "kickedOffBy", "project", "workspace", "parent", "labels", "updated"];
|
||||
|
||||
@@ -202,7 +203,7 @@ export function InboxIssueMetaLeading({
|
||||
<>
|
||||
{showStatus ? (
|
||||
<span className="hidden shrink-0 items-center sm:inline-flex">
|
||||
{statusSlot ?? <StatusIcon status={issue.status} displayStatus={issue.displayStatus} externalConversationState={issue.externalConversationState} blockerAttention={issue.blockerAttention} />}
|
||||
{statusSlot ?? <StatusIcon {...taskStatusIconProps(issue)} />}
|
||||
</span>
|
||||
) : null}
|
||||
{checklistStepNumber !== null ? (
|
||||
|
||||
@@ -6,6 +6,7 @@ import { cn } from "../lib/utils";
|
||||
import { LockedIssueChip, isLockedIssueStub } from "./LockedIssueChip";
|
||||
import { badgeVariants } from "./ui/badge";
|
||||
import { StatusIcon } from "./StatusIcon";
|
||||
import { taskStatusIconProps } from "@/lib/task-status-presentation";
|
||||
|
||||
export function IssueReferencePill({
|
||||
issue,
|
||||
@@ -42,7 +43,7 @@ export function IssueReferencePill({
|
||||
);
|
||||
const content = (
|
||||
<>
|
||||
{issue.status ? <StatusIcon status={issue.status} displayStatus={issue.displayStatus} className="h-3 w-3 shrink-0" /> : null}
|
||||
{issue.status ? <StatusIcon {...taskStatusIconProps({ ...issue, status: issue.status })} className="h-3 w-3 shrink-0" /> : null}
|
||||
{children !== undefined ? children : <span className="min-w-0 truncate">{issue.identifier ?? issue.title}</span>}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -26,6 +26,7 @@ import { Badge } from "@/components/ui/badge";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { TaskRowColumnCells, useTaskListColumns, type TaskRowColumnValues } from "./TaskListColumns";
|
||||
import { CellMarquee, HoveredCellContext, useCellMarqueeHover } from "./CellMarquee";
|
||||
import { taskStatusIconProps } from "@/lib/task-status-presentation";
|
||||
|
||||
export type IssueRowUnreadState = "hidden" | "visible" | "fading";
|
||||
export type IssueRowPresentation = "legacy" | "task";
|
||||
@@ -240,7 +241,7 @@ export function IssueRow({
|
||||
const isUnread = unreadState === "visible" || unreadState === "fading";
|
||||
const refreshed = appearance === "refreshed";
|
||||
const statusLabel = getStatusIconLabel(issue.status, issue.externalConversationState, issue.blockerAttention, issue.displayStatus);
|
||||
const statusIcon = <StatusIcon status={issue.status} displayStatus={issue.displayStatus} externalConversationState={issue.externalConversationState} blockerAttention={issue.blockerAttention} size="md" className={selectedStatusClass} />;
|
||||
const statusIcon = <StatusIcon {...taskStatusIconProps(issue)} size="md" className={selectedStatusClass} />;
|
||||
const status = statusSlot ?? (refreshed ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
@@ -487,7 +488,7 @@ export function IssueRow({
|
||||
<span className="sr-only">Open {identifier}: {issue.title}</span>
|
||||
</Link>
|
||||
<span className="flex shrink-0 items-center gap-1 pt-px sm:hidden">
|
||||
{mobileLeading ?? <StatusIcon status={issue.status} displayStatus={issue.displayStatus} externalConversationState={issue.externalConversationState} blockerAttention={issue.blockerAttention} size="md" className={selectedStatusClass} />}
|
||||
{mobileLeading ?? <StatusIcon {...taskStatusIconProps(issue)} size="md" className={selectedStatusClass} />}
|
||||
{parkedBlockerIndicator}
|
||||
</span>
|
||||
<span className="flex min-w-0 flex-1 flex-col gap-1 sm:contents">
|
||||
@@ -560,7 +561,7 @@ export function IssueRow({
|
||||
{desktopMetaLeading ?? (
|
||||
<>
|
||||
<span className="hidden shrink-0 items-center gap-1 sm:inline-flex">
|
||||
<StatusIcon status={issue.status} displayStatus={issue.displayStatus} externalConversationState={issue.externalConversationState} blockerAttention={issue.blockerAttention} size="md" className={selectedStatusClass} />
|
||||
<StatusIcon {...taskStatusIconProps(issue)} size="md" className={selectedStatusClass} />
|
||||
</span>
|
||||
{checklistStep}
|
||||
<span className="shrink-0 font-mono text-xs text-muted-foreground">
|
||||
|
||||
@@ -5,6 +5,7 @@ import { createIssueDetailPath, withIssueDetailHeaderSeed } from "@/lib/issueDet
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Link } from "@/lib/router";
|
||||
import { StatusIcon } from "./StatusIcon";
|
||||
import { taskStatusIconProps } from "@/lib/task-status-presentation";
|
||||
|
||||
type IssueSiblingNavigationProps = {
|
||||
navigation: IssueSiblingNavigationState | null;
|
||||
@@ -79,7 +80,7 @@ function SiblingLink({
|
||||
"flex min-w-0 items-center gap-1.5 text-xs font-mono text-muted-foreground transition-colors group-hover:text-foreground",
|
||||
direction === "next" && "sm:justify-end",
|
||||
)}>
|
||||
<StatusIcon status={issue.status} displayStatus={issue.displayStatus} externalConversationState={issue.externalConversationState} blockerAttention={issue.blockerAttention} />
|
||||
<StatusIcon {...taskStatusIconProps(issue)} />
|
||||
<span className="shrink-0">{identifier}</span>
|
||||
</div>
|
||||
<div className="truncate text-sm text-foreground">
|
||||
|
||||
@@ -106,6 +106,7 @@ import {
|
||||
import { taskDateGroup, taskDateGroupSeparator, type TaskDateGroup } from "../lib/task-date-groups";
|
||||
import { deriveOriginatingActor, ISSUE_STATUSES, ISSUE_DISPLAY_STATUSES, type Issue, type IssueDisplayStatus, type IssueStatus, type Project } from "@paperclipai/shared";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { taskStatusIconProps } from "@/lib/task-status-presentation";
|
||||
const ISSUE_SEARCH_DEBOUNCE_MS = 250;
|
||||
const ISSUE_SEARCH_RESULT_LIMIT = 200;
|
||||
const ISSUE_BOARD_COLUMN_RESULT_LIMIT = 200;
|
||||
@@ -2354,7 +2355,7 @@ function StreamlinedIssuesList({
|
||||
) : undefined}
|
||||
statusSlot={rowPresentation === "task" ? (
|
||||
<span className="relative inline-flex items-start self-stretch sm:items-center" onClick={(e) => { e.preventDefault(); e.stopPropagation(); }}>
|
||||
<StatusIcon status={issue.status} displayStatus={issue.displayStatus} externalConversationState={issue.externalConversationState} size="md" blockerAttention={issue.blockerAttention} onChange={(s) => onUpdateIssue(issue.id, { status: s })} />
|
||||
<StatusIcon {...taskStatusIconProps(issue)} size="md" onChange={(s) => onUpdateIssue(issue.id, { status: s })} />
|
||||
{hasChildren && isExpanded ? (
|
||||
<span aria-hidden="true" className="pointer-events-none absolute top-5 -bottom-2.5 left-1/2 w-px bg-border sm:hidden" />
|
||||
) : null}
|
||||
@@ -2378,7 +2379,7 @@ function StreamlinedIssuesList({
|
||||
</button>
|
||||
) : (
|
||||
<span className="inline-flex items-center" onClick={(e) => { e.preventDefault(); e.stopPropagation(); }}>
|
||||
<StatusIcon status={issue.status} displayStatus={issue.displayStatus} externalConversationState={issue.externalConversationState} size="md" blockerAttention={issue.blockerAttention} onChange={(s) => onUpdateIssue(issue.id, { status: s })} />
|
||||
<StatusIcon {...taskStatusIconProps(issue)} size="md" onChange={(s) => onUpdateIssue(issue.id, { status: s })} />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -2405,7 +2406,7 @@ function StreamlinedIssuesList({
|
||||
checklistStepNumber={checklistStepNumber}
|
||||
statusSlot={(
|
||||
<span className="inline-flex items-center" onClick={(e) => { e.preventDefault(); e.stopPropagation(); }}>
|
||||
<StatusIcon status={issue.status} displayStatus={issue.displayStatus} externalConversationState={issue.externalConversationState} size="md" blockerAttention={issue.blockerAttention} onChange={(s) => onUpdateIssue(issue.id, { status: s })} />
|
||||
<StatusIcon {...taskStatusIconProps(issue)} size="md" onChange={(s) => onUpdateIssue(issue.id, { status: s })} />
|
||||
</span>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -95,6 +95,7 @@ import { statusBadge } from "../lib/status-colors";
|
||||
import { isSuccessfulRunHandoffRequired } from "../lib/successful-run-handoff";
|
||||
import { deriveOriginatingActor, ISSUE_STATUSES, ISSUE_DISPLAY_STATUSES, type Issue, type IssueDisplayStatus, type IssueStatus, type Project } from "@paperclipai/shared";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { taskStatusIconProps } from "@/lib/task-status-presentation";
|
||||
const ISSUE_SEARCH_DEBOUNCE_MS = 250;
|
||||
const ISSUE_SEARCH_RESULT_LIMIT = 200;
|
||||
const ISSUE_BOARD_COLUMN_RESULT_LIMIT = 200;
|
||||
@@ -2196,7 +2197,7 @@ export function IssuesList({
|
||||
</button>
|
||||
) : (
|
||||
<span className="inline-flex items-center" onClick={(e) => { e.preventDefault(); e.stopPropagation(); }}>
|
||||
<StatusIcon status={issue.status} displayStatus={issue.displayStatus} externalConversationState={issue.externalConversationState} size="md" blockerAttention={issue.blockerAttention} onChange={(s) => onUpdateIssue(issue.id, { status: s })} />
|
||||
<StatusIcon {...taskStatusIconProps(issue)} size="md" onChange={(s) => onUpdateIssue(issue.id, { status: s })} />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -2223,7 +2224,7 @@ export function IssuesList({
|
||||
checklistStepNumber={checklistStepNumber}
|
||||
statusSlot={(
|
||||
<span className="inline-flex items-center" onClick={(e) => { e.preventDefault(); e.stopPropagation(); }}>
|
||||
<StatusIcon status={issue.status} displayStatus={issue.displayStatus} externalConversationState={issue.externalConversationState} size="md" blockerAttention={issue.blockerAttention} onChange={(s) => onUpdateIssue(issue.id, { status: s })} />
|
||||
<StatusIcon {...taskStatusIconProps(issue)} size="md" onChange={(s) => onUpdateIssue(issue.id, { status: s })} />
|
||||
</span>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -31,6 +31,7 @@ import { Input } from "@/components/ui/input";
|
||||
import { StatusIcon } from "./StatusIcon";
|
||||
import { SidebarSection } from "./SidebarSection";
|
||||
import { SidebarNavItem } from "./SidebarNavItem";
|
||||
import { taskStatusIconProps } from "@/lib/task-status-presentation";
|
||||
|
||||
const RECENT_TASK_MENU_ITEM_CLASS =
|
||||
"h-(--profile-popover-row-height) gap-(--profile-popover-row-gap) rounded-lg px-2.5 py-0 text-(length:--text-compact) font-medium leading-(--profile-popover-label-line-height) focus:bg-accent/50 focus:text-foreground";
|
||||
@@ -252,7 +253,7 @@ function RecentTasksList({
|
||||
<SidebarNavItem
|
||||
to={`/issues/${entry.id}`}
|
||||
label={entry.title}
|
||||
iconNode={entry.displayStatus === "monitoring" && !liveIssueIds.has(entry.id) ? <StatusIcon status={entry.status} displayStatus={entry.displayStatus} /> : undefined}
|
||||
iconNode={entry.displayStatus === "monitoring" && !liveIssueIds.has(entry.id) ? <StatusIcon {...taskStatusIconProps(entry)} /> : undefined}
|
||||
trailing={entry.status === "in_review" && entry.externalConversationState === "waiting"
|
||||
? <span className="text-xs text-muted-foreground">Idle</span> : undefined}
|
||||
trailingLabel={entry.status === "in_review" && entry.externalConversationState === "waiting" ? "Idle" : undefined}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { CSSProperties } from "react";
|
||||
import { resolveTaskStatusPresentation, type TaskStatusPresentationInput } from "../lib/task-status-presentation";
|
||||
import { cn } from "../lib/utils";
|
||||
import {
|
||||
statusBadge,
|
||||
@@ -84,19 +85,20 @@ export function AgentStatusCapsule({ status }: { status: string }) {
|
||||
* Distinct from the generic {@link StatusBadge} so run/goal/approval badges are
|
||||
* unaffected.
|
||||
*/
|
||||
export function IssueStatusBadge({ status: taskStatus, displayStatus, externalConversationState }: { status: string; displayStatus?: string | null; externalConversationState?: "active" | "waiting" | null }) {
|
||||
const status = taskStatus === "in_review" && externalConversationState === "waiting" ? "idle" : taskStatus === "in_progress" && displayStatus === "monitoring" ? "monitoring" : taskStatus;
|
||||
const cssVar = taskStatusVar[status] ?? taskStatusVarDefault;
|
||||
export function IssueStatusBadge(props: TaskStatusPresentationInput) {
|
||||
const { glyphStatus, label, ariaLabel } = resolveTaskStatusPresentation(props);
|
||||
const cssVar = taskStatusVar[glyphStatus] ?? taskStatusVarDefault;
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"status-chip inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs font-normal leading-none whitespace-nowrap shrink-0",
|
||||
status === "cancelled" && "line-through"
|
||||
glyphStatus === "cancelled" && "line-through"
|
||||
)}
|
||||
style={scStyle(cssVar)}
|
||||
title={ariaLabel === label ? undefined : ariaLabel}
|
||||
>
|
||||
<StatusGlyph status={status} size="sm" />
|
||||
{sentenceCaseStatus(status)}
|
||||
<StatusGlyph status={glyphStatus} size="sm" />
|
||||
{sentenceCaseStatus(label.toLowerCase())}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
// @vitest-environment jsdom
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import type { IssueBlockerAttention } from "@paperclipai/shared";
|
||||
import { StatusIcon } from "./StatusIcon";
|
||||
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
/**
|
||||
* BUG-07: the status picker trigger and the picker's current row are the same
|
||||
* task state, so they must render the same glyph and colour. A blocked task
|
||||
* that is covered by an active sub-task reads "in queue" (blue) on the
|
||||
* trigger; the menu must not repaint that row as plain red "Blocked".
|
||||
*/
|
||||
const covered: IssueBlockerAttention = {
|
||||
state: "covered",
|
||||
reason: "active_child",
|
||||
unresolvedBlockerCount: 1,
|
||||
coveredBlockerCount: 1,
|
||||
attentionBlockerCount: 0,
|
||||
stalledBlockerCount: 0,
|
||||
sampleBlockerIdentifier: "ONB-3",
|
||||
sampleStalledBlockerIdentifier: null,
|
||||
};
|
||||
|
||||
let root: Root;
|
||||
let container: HTMLDivElement;
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
container.remove();
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
const iconName = (svg: Element | null | undefined) => svg?.getAttribute("class")?.match(/lucide-[a-z-]+/)?.[0];
|
||||
const glyphColor = (svg: Element | null | undefined) => (svg as SVGElement | null)?.style.color;
|
||||
|
||||
async function openPicker(props: Parameters<typeof StatusIcon>[0]) {
|
||||
await act(async () => root.render(<StatusIcon {...props} />));
|
||||
const trigger = container.querySelector<HTMLButtonElement>("button[aria-label^='Change status']")!;
|
||||
await act(async () => {
|
||||
trigger.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, button: 0 }));
|
||||
trigger.click();
|
||||
});
|
||||
const currentRow = Array.from(document.querySelectorAll<HTMLButtonElement>("[data-slot='popover-content'] button"))
|
||||
.find((button) => button.classList.contains("bg-accent"));
|
||||
return { trigger, currentRow };
|
||||
}
|
||||
|
||||
describe("StatusIcon picker (BUG-07)", () => {
|
||||
it("marks the current row with the same glyph as the trigger for a covered-blocked task", async () => {
|
||||
const { trigger, currentRow } = await openPicker({ status: "blocked", blockerAttention: covered, onChange: () => {} });
|
||||
expect(currentRow?.textContent).toContain("Blocked");
|
||||
expect(glyphColor(trigger.querySelector("svg"))).toBe("var(--status-task-icon-in_queue)");
|
||||
expect(glyphColor(currentRow?.querySelector("svg"))).toBe(glyphColor(trigger.querySelector("svg")));
|
||||
});
|
||||
|
||||
it("marks the current row with the same glyph as the trigger for a waiting review and a monitored task", async () => {
|
||||
for (const props of [
|
||||
{ status: "in_review", externalConversationState: "waiting" as const },
|
||||
{ status: "in_progress", displayStatus: "monitoring" },
|
||||
]) {
|
||||
const { trigger, currentRow } = await openPicker({ ...props, onChange: () => {} });
|
||||
expect(currentRow).toBeDefined();
|
||||
expect(glyphColor(currentRow?.querySelector("svg"))).toBe(glyphColor(trigger.querySelector("svg")));
|
||||
expect(iconName(currentRow?.querySelector("svg"))).toBe(iconName(trigger.querySelector("svg")));
|
||||
await act(async () => root.render(<></>));
|
||||
document.querySelectorAll("[data-slot='popover-content']").forEach((node) => node.remove());
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -4,19 +4,11 @@ import { cn } from "../lib/utils";
|
||||
import { StatusGlyph, type StatusGlyphSize } from "./StatusGlyph";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { resolveTaskStatusPresentation, taskStatusLabel, type TaskStatusPresentationInput } from "../lib/task-status-presentation";
|
||||
|
||||
const allStatuses = ["backlog", "todo", "in_progress", "in_review", "done", "cancelled", "blocked"];
|
||||
|
||||
function statusLabel(status: string): string {
|
||||
return status.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
}
|
||||
|
||||
interface StatusIconProps {
|
||||
status: string;
|
||||
/** Server-derived presentation; mutations still use the canonical status. */
|
||||
displayStatus?: string | null;
|
||||
externalConversationState?: "active" | "waiting" | null;
|
||||
blockerAttention?: IssueBlockerAttention | null;
|
||||
interface StatusIconProps extends TaskStatusPresentationInput {
|
||||
onChange?: (status: string) => void;
|
||||
className?: string;
|
||||
/** Optional layout wrapper around the glyph. Does not change glyph dimensions. */
|
||||
@@ -26,52 +18,9 @@ interface StatusIconProps {
|
||||
size?: StatusGlyphSize;
|
||||
}
|
||||
|
||||
function blockedAttentionLabel(blockerAttention: IssueBlockerAttention | null | undefined) {
|
||||
if (!blockerAttention || blockerAttention.state === "none") return "Blocked";
|
||||
|
||||
if (blockerAttention.reason === "active_child") {
|
||||
const count = blockerAttention.coveredBlockerCount;
|
||||
if (count === 1 && blockerAttention.sampleBlockerIdentifier) {
|
||||
return `Blocked · waiting on active sub-task ${blockerAttention.sampleBlockerIdentifier}`;
|
||||
}
|
||||
if (count === 1) return "Blocked · waiting on 1 active sub-task";
|
||||
return `Blocked · waiting on ${count} active sub-tasks`;
|
||||
}
|
||||
|
||||
if (blockerAttention.reason === "active_dependency") {
|
||||
const count = blockerAttention.coveredBlockerCount;
|
||||
if (count === 1 && blockerAttention.sampleBlockerIdentifier) {
|
||||
return `Blocked · covered by active dependency ${blockerAttention.sampleBlockerIdentifier}`;
|
||||
}
|
||||
if (count === 1) return "Blocked · covered by 1 active dependency";
|
||||
return `Blocked · covered by ${count} active dependencies`;
|
||||
}
|
||||
|
||||
if (blockerAttention.reason === "stalled_review") {
|
||||
const count = blockerAttention.stalledBlockerCount;
|
||||
const leaf = blockerAttention.sampleStalledBlockerIdentifier ?? blockerAttention.sampleBlockerIdentifier;
|
||||
if (count === 1 && leaf) return `Blocked · review stalled on ${leaf}`;
|
||||
if (count === 1) return "Blocked · review stalled with no clear next step";
|
||||
return `Blocked · ${count} reviews stalled with no clear next step`;
|
||||
}
|
||||
|
||||
if (blockerAttention.reason === "attention_required") {
|
||||
const count = blockerAttention.attentionBlockerCount || blockerAttention.unresolvedBlockerCount;
|
||||
const attentionCopy = `${count} ${count === 1 ? "blocker needs" : "blockers need"} attention`;
|
||||
const coveredCount = blockerAttention.coveredBlockerCount;
|
||||
if (coveredCount > 0) {
|
||||
return `Blocked · ${attentionCopy}; ${coveredCount} covered by active work`;
|
||||
}
|
||||
return `Blocked · ${attentionCopy}`;
|
||||
}
|
||||
|
||||
return "Blocked";
|
||||
}
|
||||
|
||||
/** Shared accessible/tooltip copy, including waiting and covered-blocker states. */
|
||||
export function getStatusIconLabel(status: string, externalConversationState?: "active" | "waiting" | null, blockerAttention?: IssueBlockerAttention | null, presentationStatus?: string | null): string {
|
||||
const displayStatus = status === "in_review" && externalConversationState === "waiting" ? "idle" : status === "in_progress" && presentationStatus === "monitoring" ? "monitoring" : status;
|
||||
return status === "blocked" ? blockedAttentionLabel(blockerAttention) : statusLabel(displayStatus);
|
||||
return resolveTaskStatusPresentation({ status, displayStatus: presentationStatus, externalConversationState, blockerAttention }).ariaLabel;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -88,10 +37,8 @@ export function getStatusIconLabel(status: string, externalConversationState?: "
|
||||
*/
|
||||
export function StatusIcon({ status, displayStatus: presentationStatus, externalConversationState, blockerAttention, onChange, className, glyphContainerClassName, showLabel, size = "md" }: StatusIconProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const displayStatus = status === "in_review" && externalConversationState === "waiting" ? "idle" : status === "in_progress" && presentationStatus === "monitoring" ? "monitoring" : status;
|
||||
const isCoveredBlocked = status === "blocked" && blockerAttention?.state === "covered";
|
||||
const ariaLabel = getStatusIconLabel(status, externalConversationState, blockerAttention, presentationStatus);
|
||||
const glyphStatus = isCoveredBlocked ? "in_queue" : displayStatus;
|
||||
const presentation = { status, displayStatus: presentationStatus, externalConversationState, blockerAttention };
|
||||
const { glyphStatus, label, ariaLabel } = resolveTaskStatusPresentation(presentation);
|
||||
|
||||
const glyphIcon = (
|
||||
<StatusGlyph
|
||||
@@ -111,7 +58,7 @@ export function StatusIcon({ status, displayStatus: presentationStatus, external
|
||||
return showLabel ? (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
{glyph}
|
||||
<span className="text-sm">{statusLabel(displayStatus)}</span>
|
||||
<span className="text-sm">{label}</span>
|
||||
</span>
|
||||
) : (
|
||||
glyph
|
||||
@@ -125,7 +72,7 @@ export function StatusIcon({ status, displayStatus: presentationStatus, external
|
||||
className="inline-flex min-h-5 items-center gap-1.5 cursor-pointer hover:bg-accent/50 rounded px-1 -mx-1 py-0.5 transition-colors"
|
||||
>
|
||||
{glyph}
|
||||
<span className="text-sm">{statusLabel(displayStatus)}</span>
|
||||
<span className="text-sm">{label}</span>
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
@@ -153,8 +100,9 @@ export function StatusIcon({ status, displayStatus: presentationStatus, external
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<StatusIcon status={s} size="lg" />
|
||||
{statusLabel(s)}
|
||||
{/* The current row is the task's state: draw it exactly like the trigger. */}
|
||||
<StatusIcon {...(s === status ? presentation : { status: s })} size="lg" />
|
||||
{taskStatusLabel(s)}
|
||||
</Button>
|
||||
))}
|
||||
</PopoverContent>
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { AvatarAgent } from "@/components/AgentAvatar";
|
||||
import { IssueReferencePill } from "@/components/IssueReferencePill";
|
||||
import { IssueStatusBadge } from "@/components/StatusBadge";
|
||||
import { PropertyRow } from "@/components/issue-properties/primitives";
|
||||
import { taskStatusIconProps } from "@/lib/task-status-presentation";
|
||||
|
||||
export type CaseAgentMap = ReadonlyMap<string, AvatarAgent & { status?: string }>;
|
||||
|
||||
@@ -34,7 +35,7 @@ export function CaseWorkSummary({ caseData, pendingHandoff, agents }: {
|
||||
<PropertyRow label="Workflow"><span className="text-sm">{caseData.workflow?.name ?? "Case instructions"}</span></PropertyRow>
|
||||
{caseData.phase && <PropertyRow label="Phase"><span className="text-sm">{caseData.phase}</span></PropertyRow>}
|
||||
<PropertyRow label="Agent">{assignee ? <AgentIdentity agent={assignee} size="xs" /> : <span className="text-sm text-muted-foreground">Unassigned</span>}</PropertyRow>
|
||||
<PropertyRow label="Task status"><IssueStatusBadge status={task.status} /></PropertyRow>
|
||||
<PropertyRow label="Task status"><IssueStatusBadge {...taskStatusIconProps(task)} /></PropertyRow>
|
||||
</div> : <p className="text-sm text-muted-foreground">The primary task is unavailable.</p>}
|
||||
{pending && pending.state !== "delivered" && pending.state !== "completed" && pending.state !== "cancelled" && <div className="flex items-start gap-2 text-sm">
|
||||
<Clock3 aria-hidden className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { Link } from "@/lib/router";
|
||||
import { cn, formatDate, relativeTime } from "@/lib/utils";
|
||||
import { taskStatusIconProps } from "@/lib/task-status-presentation";
|
||||
|
||||
/**
|
||||
* The agent-scoped side panels shown beside an agent chat: every task the
|
||||
@@ -86,8 +87,7 @@ export function AgentTaskCard({ task }: { task: Issue }) {
|
||||
>
|
||||
<div className="flex min-w-0 items-start gap-2">
|
||||
<StatusIcon
|
||||
status={task.status} displayStatus={task.displayStatus}
|
||||
blockerAttention={task.blockerAttention}
|
||||
{...taskStatusIconProps(task)}
|
||||
className="mt-0.5 shrink-0"
|
||||
/>
|
||||
<span className="min-w-0 flex-1 line-clamp-2 text-sm font-medium text-foreground">
|
||||
|
||||
@@ -10,7 +10,8 @@ import { TooltipProvider } from "../ui/tooltip";
|
||||
|
||||
vi.mock("@/hooks/useCoordinatorWorkspaceEnabled", () => ({ useCoordinatorWorkspaceEnabled: () => ({ enabled: true, loaded: true, userId: "viewer" }) }));
|
||||
vi.mock("@/lib/router", () => ({ Link: ({ to, children, ...props }: any) => <a href={to} {...props}>{children}</a> }));
|
||||
vi.mock("@/api/issues", () => ({ issuesApi: { summaries: vi.fn(async () => [{ id: "task-created", identifier: "PAPER-108", title: "Write the launch post", status: "todo" }]) } }));
|
||||
const summaries = vi.hoisted(() => ({ rows: null as null | Array<Record<string, unknown>> }));
|
||||
vi.mock("@/api/issues", () => ({ issuesApi: { summaries: vi.fn(async () => summaries.rows ?? [{ id: "task-created", identifier: "PAPER-108", title: "Write the launch post", status: "todo" }]) } }));
|
||||
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
let root: Root;
|
||||
@@ -21,7 +22,7 @@ beforeEach(() => {
|
||||
client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
window.matchMedia = vi.fn().mockReturnValue({ matches: false, addEventListener: vi.fn(), removeEventListener: vi.fn() });
|
||||
});
|
||||
afterEach(async () => { await act(() => root.unmount()); host.remove(); client.clear(); vi.restoreAllMocks(); });
|
||||
afterEach(async () => { await act(() => root.unmount()); host.remove(); client.clear(); vi.restoreAllMocks(); summaries.rows = null; });
|
||||
|
||||
function render(interaction: typeof pendingSuggestedTasksInteraction, onAcceptInteraction = vi.fn(), onRejectInteraction = vi.fn()) {
|
||||
return act(() => root.render(<QueryClientProvider client={client}><TooltipProvider>
|
||||
@@ -134,4 +135,14 @@ describe("Coordinator task commitment", () => {
|
||||
expect(animate).not.toHaveBeenCalled();
|
||||
expect(host.firstElementChild?.hasAttribute("data-motion")).toBe(false);
|
||||
});
|
||||
|
||||
it("draws a created task from its full summary state, matching the task pane (BUG-07)", async () => {
|
||||
summaries.rows = [{ id: "task-created", identifier: "PAPER-108", title: "Write the launch post", status: "blocked", displayStatus: "blocked",
|
||||
externalConversationState: null, blockerAttention: { state: "covered", reason: "active_child", unresolvedBlockerCount: 1, coveredBlockerCount: 1,
|
||||
attentionBlockerCount: 0, stalledBlockerCount: 0, sampleBlockerIdentifier: null, sampleStalledBlockerIdentifier: null } }];
|
||||
await render({ ...pendingSuggestedTasksInteraction, status: "accepted", result: { version: 1, createdTasks: [{ clientKey: "launch", issueId: "task-created", identifier: "PAPER-108", title: "Write the launch post" }] } });
|
||||
await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); });
|
||||
const glyph = host.querySelector<SVGElement>(".coordinator-created-task svg[role='img']");
|
||||
expect(glyph?.style.color).toBe("var(--status-task-icon-in_queue)");
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useLayoutEffect, useRef, type ReactNode } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ArrowUpRight, FileCheck2 } from "lucide-react";
|
||||
import type { ConversationTaskHandoff } from "@paperclipai/shared";
|
||||
import type { ConversationTaskHandoff, IssueSummary } from "@paperclipai/shared";
|
||||
import type { SuggestTasksInteraction } from "@/lib/issue-thread-interactions";
|
||||
import { issuesApi } from "@/api/issues";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
@@ -9,6 +9,7 @@ import { Link } from "@/lib/router";
|
||||
import { StatusIcon } from "@/components/StatusIcon";
|
||||
import { CoordinatorRelativeTime } from "./CoordinatorRelativeTime";
|
||||
import { useCoordinatorWorkspaceEnabled } from "@/hooks/useCoordinatorWorkspaceEnabled";
|
||||
import { taskStatusIconProps } from "@/lib/task-status-presentation";
|
||||
|
||||
// Presentation-only memory. It contains no task text or authority, and prevents
|
||||
// revisiting a conversation from replaying its task-creation celebration.
|
||||
@@ -122,10 +123,11 @@ export function CoordinatorTaskMotion({
|
||||
* handoffs, so both creation paths settle into the same card. */
|
||||
export function CoordinatorCreatedTaskLink({ task, createdAt }: {
|
||||
createdAt?: Date | string | null;
|
||||
task: Pick<ConversationTaskHandoff, "issueId" | "identifier" | "title"> & { status?: ConversationTaskHandoff["status"] };
|
||||
task: Pick<ConversationTaskHandoff, "issueId" | "identifier" | "title"> & { status?: ConversationTaskHandoff["status"] }
|
||||
& Partial<Pick<IssueSummary, "displayStatus" | "externalConversationState" | "blockerAttention">>;
|
||||
}) {
|
||||
return <Link to={`/issues/${task.identifier ?? task.issueId}`} className="coordinator-created-task">
|
||||
{task.status ? <StatusIcon status={task.status} size="lg" /> : <FileCheck2 className="size-5 shrink-0 text-muted-foreground" aria-label="Task created" />}
|
||||
{task.status ? <StatusIcon {...taskStatusIconProps({ ...task, status: task.status })} size="lg" /> : <FileCheck2 className="size-5 shrink-0 text-muted-foreground" aria-label="Task created" />}
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-medium">{task.title}</span>
|
||||
<span className="block truncate text-xs text-muted-foreground"><span className="font-mono">{task.identifier ?? task.issueId}</span> · Created</span>
|
||||
@@ -153,6 +155,9 @@ function CreatedTaskLinks({ interaction }: { interaction: SuggestTasksInteractio
|
||||
identifier: summary?.identifier ?? task.identifier ?? null,
|
||||
title: summary?.title ?? task.title ?? "Task created",
|
||||
status: summary?.status,
|
||||
displayStatus: summary?.displayStatus,
|
||||
externalConversationState: summary?.externalConversationState,
|
||||
blockerAttention: summary?.blockerAttention,
|
||||
}} />;
|
||||
})}
|
||||
</div>;
|
||||
|
||||
@@ -137,6 +137,7 @@ import {
|
||||
TaskDetailSubtasksPanel,
|
||||
type TaskDetailRelationItem,
|
||||
} from "../task-detail/TaskDetailRelationsPanel";
|
||||
import { taskStatusIconProps } from "@/lib/task-status-presentation";
|
||||
|
||||
function splitMiddleTruncation(value: string): { prefix: string; suffix: string } | null {
|
||||
const splitAt = Math.max(value.lastIndexOf("/"), value.lastIndexOf("\\"));
|
||||
@@ -2200,7 +2201,7 @@ export function IssueProperties({
|
||||
setParentOpen(false);
|
||||
}}
|
||||
>
|
||||
<StatusIcon status={candidate.status} displayStatus={candidate.displayStatus} className="h-3 w-3" />
|
||||
<StatusIcon {...taskStatusIconProps(candidate)} className="h-3 w-3" />
|
||||
<span className="truncate">
|
||||
{candidate.identifier ? `${candidate.identifier} ` : ""}
|
||||
{candidate.title}
|
||||
@@ -2287,7 +2288,7 @@ export function IssueProperties({
|
||||
)}
|
||||
onClick={() => toggleBlockedBy(candidate.id)}
|
||||
>
|
||||
<StatusIcon status={candidate.status} displayStatus={candidate.displayStatus} className="h-3 w-3" />
|
||||
<StatusIcon {...taskStatusIconProps(candidate)} className="h-3 w-3" />
|
||||
<span className="truncate">
|
||||
{candidate.identifier ? `${candidate.identifier} ` : ""}
|
||||
{candidate.title}
|
||||
@@ -2325,7 +2326,7 @@ export function IssueProperties({
|
||||
className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs hover:bg-accent/50"
|
||||
onClick={() => setSubtasksOpen(false)}
|
||||
>
|
||||
<StatusIcon status={child.status} displayStatus={child.displayStatus} className="h-3 w-3" />
|
||||
<StatusIcon {...taskStatusIconProps(child)} className="h-3 w-3" />
|
||||
<span className="min-w-0 truncate">
|
||||
{child.identifier ? `${child.identifier} ` : ""}
|
||||
{child.title}
|
||||
@@ -2362,8 +2363,7 @@ export function IssueProperties({
|
||||
>
|
||||
<PropertyRow label="Status">
|
||||
<StatusIcon
|
||||
status={issue.status} displayStatus={issue.displayStatus}
|
||||
externalConversationState={issue.externalConversationState}
|
||||
{...taskStatusIconProps(issue)}
|
||||
glyphContainerClassName="inline-flex size-6 shrink-0 items-center justify-center"
|
||||
blockerAttention={issue.blockerAttention}
|
||||
onChange={(status) => onUpdate({ status })}
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
import { ArrowUpRight, X } from "lucide-react";
|
||||
import { cn } from "../../lib/utils";
|
||||
import { StatusIcon } from "../StatusIcon";
|
||||
import { taskStatusIconProps } from "@/lib/task-status-presentation";
|
||||
|
||||
export function RemovableIssueReferencePill({
|
||||
issue,
|
||||
@@ -53,7 +54,7 @@ export function RemovableIssueReferencePill({
|
||||
<LockedIssueChip identifier={issue.identifier} />
|
||||
) : (
|
||||
<>
|
||||
<StatusIcon status={issue.status} displayStatus={issue.displayStatus} className="h-3 w-3 shrink-0" />
|
||||
<StatusIcon {...taskStatusIconProps(issue)} className="h-3 w-3 shrink-0" />
|
||||
<span className="truncate">{issueLabel}</span>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -8,6 +8,7 @@ import { cn } from "@/lib/utils";
|
||||
import { StatusIcon } from "../StatusIcon";
|
||||
import { Identity } from "../Identity";
|
||||
import { HighlightedText, type HighlightedTextProps } from "./HighlightedText";
|
||||
import { taskStatusIconProps } from "@/lib/task-status-presentation";
|
||||
|
||||
type SnippetStyle = {
|
||||
Icon: ComponentType<SVGProps<SVGSVGElement>>;
|
||||
@@ -178,7 +179,7 @@ function SearchResultRowImpl({
|
||||
data-result-type="issue"
|
||||
>
|
||||
<div className="mt-1 shrink-0">
|
||||
<StatusIcon status={issue.status} displayStatus={issue.displayStatus} externalConversationState={issue.externalConversationState} />
|
||||
<StatusIcon {...taskStatusIconProps(issue)} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 flex-wrap items-baseline gap-x-2.5 gap-y-1">
|
||||
|
||||
@@ -2,6 +2,7 @@ import { ArrowUpRight, ListTree } from "lucide-react";
|
||||
import { Link } from "@/lib/router";
|
||||
import { IssueStatusBadge } from "@/components/StatusBadge";
|
||||
import type { TaskChatTaskCreatedItem } from "./task-chat-model";
|
||||
import { taskStatusIconProps } from "@/lib/task-status-presentation";
|
||||
|
||||
export function TaskChatTaskCreatedCard({ item }: { item: TaskChatTaskCreatedItem }) {
|
||||
const { task } = item;
|
||||
@@ -18,7 +19,7 @@ export function TaskChatTaskCreatedCard({ item }: { item: TaskChatTaskCreatedIte
|
||||
<span className="block break-words text-sm font-medium">{task.title}</span>
|
||||
<span className="flex flex-wrap items-center gap-2">
|
||||
{task.identifier && <span className="font-mono text-xs text-muted-foreground">{task.identifier}</span>}
|
||||
<IssueStatusBadge status={task.status} />
|
||||
<IssueStatusBadge {...taskStatusIconProps(task)} />
|
||||
</span>
|
||||
</span>
|
||||
<ArrowUpRight className="size-4 shrink-0 text-muted-foreground" aria-hidden />
|
||||
|
||||
@@ -653,7 +653,7 @@ export interface TaskChatTurnItem {
|
||||
export interface TaskChatTaskCreatedItem {
|
||||
id: string;
|
||||
kind: "task_created";
|
||||
task: Pick<Issue, "id" | "identifier" | "title" | "status">;
|
||||
task: Pick<Issue, "id" | "identifier" | "title" | "status" | "displayStatus" | "externalConversationState" | "blockerAttention">;
|
||||
isSubtask: boolean;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
// @vitest-environment node
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { Issue } from "@paperclipai/shared";
|
||||
import { taskCreatedItems } from "./task-created-items";
|
||||
import { TaskChatTaskCreatedCard } from "./TaskChatTaskCreatedCard";
|
||||
|
||||
vi.mock("@/lib/router", () => ({ Link: ({ to, children, ...props }: { to: string; children: React.ReactNode }) => <a href={to} {...props}>{children}</a> }));
|
||||
|
||||
const created = (overrides: Partial<Issue>): Issue => ({
|
||||
id: "child", companyId: "company", identifier: "ONB-1", title: "Paperclip onboarding", parentId: "parent",
|
||||
createdByAgentId: "agent", createdAt: new Date("2026-11-05T12:00:00Z"), status: "todo", ...overrides,
|
||||
}) as Issue;
|
||||
|
||||
describe("task created chat card (BUG-07)", () => {
|
||||
it("keeps the presentation fields so the chip matches the task pane", () => {
|
||||
const [item] = taskCreatedItems([created({
|
||||
status: "blocked",
|
||||
blockerAttention: { state: "covered", reason: "active_child", unresolvedBlockerCount: 1, coveredBlockerCount: 1,
|
||||
attentionBlockerCount: 0, stalledBlockerCount: 0, sampleBlockerIdentifier: "ONB-3", sampleStalledBlockerIdentifier: null },
|
||||
})], "parent", "company", new Set());
|
||||
const html = renderToStaticMarkup(<TaskChatTaskCreatedCard item={item!} />);
|
||||
expect(html).toContain("var(--status-task-icon-in_queue)");
|
||||
expect(html).toContain("Blocked · waiting on active sub-task ONB-3");
|
||||
});
|
||||
|
||||
it("shows a waiting review as Idle, like the task pane", () => {
|
||||
const [item] = taskCreatedItems([created({ status: "in_review", externalConversationState: "waiting" })], "parent", "company", new Set());
|
||||
expect(renderToStaticMarkup(<TaskChatTaskCreatedCard item={item!} />)).toContain(">Idle<");
|
||||
});
|
||||
});
|
||||
@@ -15,7 +15,11 @@ export function taskCreatedItems(
|
||||
return [{
|
||||
id: `task-created:${task.id}`,
|
||||
kind: "task_created" as const,
|
||||
task: { id: task.id, identifier: task.identifier, title: task.title, status: task.status },
|
||||
// Keep every status-presentation field so the chip matches the task pane.
|
||||
task: {
|
||||
id: task.id, identifier: task.identifier, title: task.title, status: task.status,
|
||||
displayStatus: task.displayStatus, externalConversationState: task.externalConversationState, blockerAttention: task.blockerAttention,
|
||||
},
|
||||
isSubtask: task.parentId === issueId,
|
||||
timestamp: new Date(task.createdAt).toISOString(),
|
||||
}];
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
rememberIssueDetailLocationState,
|
||||
} from "@/lib/issueDetailBreadcrumb";
|
||||
import { Link } from "@/lib/router";
|
||||
import { taskStatusIconProps } from "@/lib/task-status-presentation";
|
||||
|
||||
export interface TaskDetailRelationItem {
|
||||
id: string;
|
||||
@@ -50,7 +51,7 @@ export function RelationNavigationList({
|
||||
title={`${item.identifier ? `${item.identifier} — ` : ""}${item.title}`}
|
||||
>
|
||||
{item.status ? (
|
||||
<StatusIcon status={item.status} displayStatus={item.displayStatus} className="h-3.5 w-3.5 shrink-0" />
|
||||
<StatusIcon {...taskStatusIconProps({ ...item, status: item.status })} className="h-3.5 w-3.5 shrink-0" />
|
||||
) : null}
|
||||
<span className="min-w-0 flex-1 truncate">{item.title}</span>
|
||||
{item.identifier ? (
|
||||
|
||||
@@ -63,6 +63,21 @@ describe("recent task persistence", () => {
|
||||
expect(readRecentTasks(storageKey, "company-1")[0]?.externalConversationState).toBe("active");
|
||||
});
|
||||
|
||||
it("keeps the covered-blocker state so the sidebar shows the same queued glyph as the task pane (BUG-07)", () => {
|
||||
const covered = {
|
||||
state: "covered" as const, reason: "active_child" as const, unresolvedBlockerCount: 1, coveredBlockerCount: 1,
|
||||
attentionBlockerCount: 0, stalledBlockerCount: 0, sampleBlockerIdentifier: "ONB-3", sampleStalledBlockerIdentifier: null,
|
||||
};
|
||||
const blocked = { ...issue("1"), status: "blocked" as const, updatedAt: new Date(10) };
|
||||
recordRecentTask({ ...blocked, blockerAttention: covered }, "user-1");
|
||||
const storageKey = getRecentTasksStorageKey("company-1", "user-1");
|
||||
expect(readRecentTasks(storageKey, "company-1")[0]?.blockerAttention).toEqual(covered);
|
||||
// A same-version summary refresh can change only the derived blocker state.
|
||||
const attention = { ...covered, state: "needs_attention" as const, reason: "attention_required" as const, coveredBlockerCount: 0, attentionBlockerCount: 1 };
|
||||
updateRecentTaskSnapshots(storageKey, "company-1", [{ ...blocked, externalConversationState: null, blockerAttention: attention }]);
|
||||
expect(readRecentTasks(storageKey, "company-1")[0]?.blockerAttention).toEqual(attention);
|
||||
});
|
||||
|
||||
it("accepts server settlement after an optimistic run-lock clear", () => {
|
||||
const running = { ...issue("1"), status: "in_progress" as const, externalConversationState: "active" as const, executionRunId: "run-1", updatedAt: new Date(10) } as Issue;
|
||||
recordRecentTask(running, "user-1");
|
||||
|
||||
@@ -13,6 +13,8 @@ export interface RecentTaskEntry {
|
||||
status: IssueStatus;
|
||||
displayStatus?: Issue["displayStatus"];
|
||||
externalConversationState?: Issue["externalConversationState"];
|
||||
/** Blocked tasks covered by active work render the "in queue" glyph everywhere. */
|
||||
blockerAttention?: Issue["blockerAttention"];
|
||||
recordedAt: number;
|
||||
// Server version of the title/status snapshot, independent of comment activity.
|
||||
// Legacy entries have no version until a detail query refreshes them.
|
||||
@@ -107,6 +109,10 @@ export function writeRecentTasks(storageKey: string, entries: RecentTaskEntry[])
|
||||
|
||||
type TaskSnapshot = Omit<IssueSummary, "externalConversationState"> & Pick<Issue, "externalConversationState">;
|
||||
|
||||
function sameBlockerAttention(a: RecentTaskEntry["blockerAttention"], b: RecentTaskEntry["blockerAttention"]) {
|
||||
return JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
|
||||
}
|
||||
|
||||
export function mergeRecentTaskSnapshot(entry: RecentTaskEntry, issue: TaskSnapshot): RecentTaskEntry {
|
||||
if (entry.id !== issue.id || entry.companyId !== issue.companyId) return entry;
|
||||
const snapshotUpdatedAt = new Date(issue.updatedAt).getTime();
|
||||
@@ -117,7 +123,8 @@ export function mergeRecentTaskSnapshot(entry: RecentTaskEntry, issue: TaskSnaps
|
||||
if (snapshotUpdatedAt === entry.snapshotUpdatedAt) {
|
||||
return (issue.externalConversationState !== undefined
|
||||
&& issue.externalConversationState !== entry.externalConversationState) || issue.displayStatus !== entry.displayStatus
|
||||
? { ...entry, status: issue.status, displayStatus: issue.displayStatus, externalConversationState: issue.externalConversationState }
|
||||
|| !sameBlockerAttention(issue.blockerAttention, entry.blockerAttention)
|
||||
? { ...entry, status: issue.status, displayStatus: issue.displayStatus, externalConversationState: issue.externalConversationState, blockerAttention: issue.blockerAttention }
|
||||
: entry;
|
||||
}
|
||||
return {
|
||||
@@ -127,13 +134,14 @@ export function mergeRecentTaskSnapshot(entry: RecentTaskEntry, issue: TaskSnaps
|
||||
status: issue.status,
|
||||
displayStatus: issue.displayStatus,
|
||||
externalConversationState: issue.externalConversationState,
|
||||
blockerAttention: issue.blockerAttention,
|
||||
snapshotUpdatedAt,
|
||||
recordedAt: Math.max(entry.recordedAt, snapshotUpdatedAt),
|
||||
};
|
||||
}
|
||||
|
||||
export function recordRecentTask(
|
||||
issue: Pick<Issue, "id" | "companyId" | "title" | "identifier" | "status" | "displayStatus" | "externalConversationState" | "updatedAt" | "conversationAgentId">,
|
||||
issue: Pick<Issue, "id" | "companyId" | "title" | "identifier" | "status" | "displayStatus" | "externalConversationState" | "blockerAttention" | "updatedAt" | "conversationAgentId">,
|
||||
userId: string | null | undefined,
|
||||
recordedAt = new Date(issue.updatedAt).getTime(),
|
||||
) {
|
||||
@@ -153,6 +161,7 @@ export function recordRecentTask(
|
||||
status: issue.status,
|
||||
displayStatus: issue.displayStatus,
|
||||
externalConversationState: issue.externalConversationState,
|
||||
blockerAttention: issue.blockerAttention,
|
||||
recordedAt: activityAt,
|
||||
...(Number.isFinite(snapshotUpdatedAt) ? { snapshotUpdatedAt } : {}),
|
||||
};
|
||||
@@ -168,6 +177,7 @@ export function recordRecentTask(
|
||||
&& existing.status === entry.status
|
||||
&& existing.displayStatus === entry.displayStatus
|
||||
&& existing.externalConversationState === entry.externalConversationState
|
||||
&& sameBlockerAttention(existing.blockerAttention, entry.blockerAttention)
|
||||
&& existing.recordedAt === entry.recordedAt
|
||||
&& existing.snapshotUpdatedAt === entry.snapshotUpdatedAt
|
||||
) return;
|
||||
|
||||
@@ -261,6 +261,8 @@ export const taskStatusVar: Record<string, string> = {
|
||||
done: "--status-task-done",
|
||||
blocked: "--status-task-blocked",
|
||||
cancelled: "--status-task-cancelled",
|
||||
// Covered-blocked: the blocked shape on the in-progress blue (mirrors the icon var).
|
||||
in_queue: "--status-task-in_progress",
|
||||
};
|
||||
export const taskStatusVarDefault = "--status-task-backlog";
|
||||
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import type { IssueBlockerAttention } from "@paperclipai/shared";
|
||||
|
||||
/**
|
||||
* Everything a task status surface needs to pick its glyph and label. Every
|
||||
* view (sidebar rows, task header, properties, status picker, lists, inbox,
|
||||
* pills, search) resolves through {@link resolveTaskStatusPresentation}, so one
|
||||
* underlying state always renders the same icon and label (BUG-07).
|
||||
*/
|
||||
export interface TaskStatusPresentationInput {
|
||||
status: string;
|
||||
/** Server-derived presentation; mutations still use the canonical status. */
|
||||
displayStatus?: string | null;
|
||||
externalConversationState?: "active" | "waiting" | null;
|
||||
blockerAttention?: IssueBlockerAttention | null;
|
||||
}
|
||||
|
||||
export interface TaskStatusPresentation {
|
||||
/** Key into the unified StatusGlyph icon/colour tables. */
|
||||
glyphStatus: string;
|
||||
/** Short visible label ("Blocked", "Idle", "Monitoring", …). */
|
||||
label: string;
|
||||
/** Full accessible/tooltip copy, including the blocked reason. */
|
||||
ariaLabel: string;
|
||||
}
|
||||
|
||||
export function taskStatusLabel(status: string): string {
|
||||
return status.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
}
|
||||
|
||||
function blockedAttentionLabel(blockerAttention: IssueBlockerAttention | null | undefined) {
|
||||
if (!blockerAttention || blockerAttention.state === "none") return "Blocked";
|
||||
|
||||
if (blockerAttention.reason === "active_child") {
|
||||
const count = blockerAttention.coveredBlockerCount;
|
||||
if (count === 1 && blockerAttention.sampleBlockerIdentifier) {
|
||||
return `Blocked · waiting on active sub-task ${blockerAttention.sampleBlockerIdentifier}`;
|
||||
}
|
||||
if (count === 1) return "Blocked · waiting on 1 active sub-task";
|
||||
return `Blocked · waiting on ${count} active sub-tasks`;
|
||||
}
|
||||
|
||||
if (blockerAttention.reason === "active_dependency") {
|
||||
const count = blockerAttention.coveredBlockerCount;
|
||||
if (count === 1 && blockerAttention.sampleBlockerIdentifier) {
|
||||
return `Blocked · covered by active dependency ${blockerAttention.sampleBlockerIdentifier}`;
|
||||
}
|
||||
if (count === 1) return "Blocked · covered by 1 active dependency";
|
||||
return `Blocked · covered by ${count} active dependencies`;
|
||||
}
|
||||
|
||||
if (blockerAttention.reason === "stalled_review") {
|
||||
const count = blockerAttention.stalledBlockerCount;
|
||||
const leaf = blockerAttention.sampleStalledBlockerIdentifier ?? blockerAttention.sampleBlockerIdentifier;
|
||||
if (count === 1 && leaf) return `Blocked · review stalled on ${leaf}`;
|
||||
if (count === 1) return "Blocked · review stalled with no clear next step";
|
||||
return `Blocked · ${count} reviews stalled with no clear next step`;
|
||||
}
|
||||
|
||||
if (blockerAttention.reason === "attention_required") {
|
||||
const count = blockerAttention.attentionBlockerCount || blockerAttention.unresolvedBlockerCount;
|
||||
const attentionCopy = `${count} ${count === 1 ? "blocker needs" : "blockers need"} attention`;
|
||||
const coveredCount = blockerAttention.coveredBlockerCount;
|
||||
if (coveredCount > 0) {
|
||||
return `Blocked · ${attentionCopy}; ${coveredCount} covered by active work`;
|
||||
}
|
||||
return `Blocked · ${attentionCopy}`;
|
||||
}
|
||||
|
||||
return "Blocked";
|
||||
}
|
||||
|
||||
/**
|
||||
* The one state → glyph/label mapping:
|
||||
* - in_review whose external conversation is waiting → "Idle"
|
||||
* - in_progress presented as monitoring → "Monitoring" (clock)
|
||||
* - blocked and covered by active work → the blue "in queue" glyph, with the
|
||||
* blocked reason on the accessible label
|
||||
*/
|
||||
export function resolveTaskStatusPresentation(input: TaskStatusPresentationInput): TaskStatusPresentation {
|
||||
const { status, displayStatus, externalConversationState, blockerAttention } = input;
|
||||
const visibleStatus = status === "in_review" && externalConversationState === "waiting"
|
||||
? "idle"
|
||||
: status === "in_progress" && displayStatus === "monitoring"
|
||||
? "monitoring"
|
||||
: status;
|
||||
const isCoveredBlocked = status === "blocked" && blockerAttention?.state === "covered";
|
||||
return {
|
||||
glyphStatus: isCoveredBlocked ? "in_queue" : visibleStatus,
|
||||
label: taskStatusLabel(visibleStatus),
|
||||
ariaLabel: status === "blocked" ? blockedAttentionLabel(blockerAttention) : taskStatusLabel(visibleStatus),
|
||||
};
|
||||
}
|
||||
|
||||
type TaskStatusSource = {
|
||||
status: string;
|
||||
displayStatus?: string | null;
|
||||
externalConversationState?: "active" | "waiting" | null;
|
||||
blockerAttention?: IssueBlockerAttention | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Pick every status-presentation field from a task record so a view cannot
|
||||
* silently drop one (e.g. `blockerAttention`) and render a different glyph
|
||||
* than the task pane. Spread into `<StatusIcon {...taskStatusIconProps(task)} />`.
|
||||
*/
|
||||
export function taskStatusIconProps(task: TaskStatusSource): TaskStatusPresentationInput {
|
||||
return {
|
||||
status: task.status,
|
||||
displayStatus: task.displayStatus ?? null,
|
||||
externalConversationState: task.externalConversationState ?? null,
|
||||
blockerAttention: task.blockerAttention ?? null,
|
||||
};
|
||||
}
|
||||
@@ -42,6 +42,7 @@ import { InlineBanner } from "../components/InlineBanner";
|
||||
import type { Agent, Issue } from "@paperclipai/shared";
|
||||
import { PluginSlotOutlet } from "@/plugins/slots";
|
||||
import { SmokeLabDashboardCard } from "../components/SmokeLabDashboardCard";
|
||||
import { taskStatusIconProps } from "@/lib/task-status-presentation";
|
||||
|
||||
const DASHBOARD_ACTIVITY_LIMIT = 10;
|
||||
|
||||
@@ -545,7 +546,7 @@ export function Dashboard() {
|
||||
>
|
||||
<div className="flex items-start gap-2 @xl:grid @xl:grid-cols-(--dashboard-task-list-columns) @xl:items-baseline">
|
||||
<span className="flex size-6 shrink-0 items-center justify-end @xl:self-center">
|
||||
<StatusIcon status={issue.status} displayStatus={issue.displayStatus} externalConversationState={issue.externalConversationState} blockerAttention={issue.blockerAttention} />
|
||||
<StatusIcon {...taskStatusIconProps(issue)} />
|
||||
</span>
|
||||
<span className="flex min-w-0 flex-1 flex-col gap-1 @xl:contents">
|
||||
<span className="flex min-w-0 items-baseline gap-2 @xl:contents">
|
||||
|
||||
@@ -196,6 +196,7 @@ import {
|
||||
taskDateGroupSeparator,
|
||||
type TaskDateGroup,
|
||||
} from "../lib/task-date-groups";
|
||||
import { taskStatusIconProps } from "@/lib/task-status-presentation";
|
||||
|
||||
const INBOX_HEARTBEAT_RUN_LIMIT = 200;
|
||||
const INBOX_ISSUE_LIST_LIMIT = 100;
|
||||
@@ -2995,7 +2996,7 @@ function StreamlinedInbox({
|
||||
&& blockerAttention?.state === "covered"
|
||||
);
|
||||
const rowStatusIcon = (
|
||||
<StatusIcon status={issue.status} displayStatus={issue.displayStatus} externalConversationState={issue.externalConversationState} blockerAttention={blockerAttention} size="md" />
|
||||
<StatusIcon {...taskStatusIconProps(issue)} blockerAttention={blockerAttention} size="md" />
|
||||
);
|
||||
return (
|
||||
<IssueRow
|
||||
@@ -3095,7 +3096,7 @@ function StreamlinedInbox({
|
||||
<ChevronRight className={cn("h-3.5 w-3.5 transition-transform", isExpanded && "rotate-90")} />
|
||||
</button>
|
||||
) : (
|
||||
<StatusIcon status={issue.status} displayStatus={issue.displayStatus} externalConversationState={issue.externalConversationState} blockerAttention={blockerAttention} size="md" />
|
||||
<StatusIcon {...taskStatusIconProps(issue)} blockerAttention={blockerAttention} size="md" />
|
||||
)
|
||||
) : undefined}
|
||||
unreadState={isUnread ? "visible" : isFading ? "fading" : "hidden"}
|
||||
|
||||
@@ -402,6 +402,7 @@ import {
|
||||
workspaceFileRefSchema,
|
||||
isUuidLike,
|
||||
} from "@paperclipai/shared";
|
||||
import { taskStatusIconProps } from "@/lib/task-status-presentation";
|
||||
|
||||
// Stable empty array for React Query `data` defaults. A literal `= []` default
|
||||
// creates a new array reference on every render while `data` is undefined
|
||||
@@ -997,8 +998,7 @@ export function IssueDetailLoadingState({
|
||||
{headerSeed ? (
|
||||
<>
|
||||
<StatusIcon
|
||||
status={headerSeed.status} displayStatus={headerSeed.displayStatus}
|
||||
blockerAttention={headerSeed.blockerAttention}
|
||||
{...taskStatusIconProps(headerSeed)}
|
||||
/>
|
||||
{/* PAP-411: priority UI hidden behind SHOW_TASK_PRIORITY_UI. */}
|
||||
{SHOW_TASK_PRIORITY_UI && (
|
||||
@@ -7586,7 +7586,7 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
|
||||
|
||||
const issueStatusControl = (
|
||||
<StatusIcon
|
||||
status={issue.status} displayStatus={issue.displayStatus} externalConversationState={issue.externalConversationState}
|
||||
{...taskStatusIconProps(issue)}
|
||||
size={proposedTaskHeader ? "md" : "lg"}
|
||||
blockerAttention={issue.blockerAttention}
|
||||
onChange={(status) => updateIssue.mutate({ status })}
|
||||
|
||||
@@ -184,6 +184,7 @@ import {
|
||||
reconcileInboxOrderPin,
|
||||
type InboxOrderPin,
|
||||
} from "../lib/inboxOrderPin";
|
||||
import { taskStatusIconProps } from "@/lib/task-status-presentation";
|
||||
|
||||
const INBOX_HEARTBEAT_RUN_LIMIT = 200;
|
||||
const INBOX_ISSUE_LIST_LIMIT = 500;
|
||||
@@ -2698,7 +2699,7 @@ export function Inbox() {
|
||||
&& blockerAttention?.state === "covered"
|
||||
);
|
||||
const rowStatusIcon = (
|
||||
<StatusIcon status={issue.status} displayStatus={issue.displayStatus} externalConversationState={issue.externalConversationState} blockerAttention={blockerAttention} size="md" />
|
||||
<StatusIcon {...taskStatusIconProps(issue)} blockerAttention={blockerAttention} size="md" />
|
||||
);
|
||||
return (
|
||||
<IssueRow
|
||||
@@ -2768,7 +2769,7 @@ export function Inbox() {
|
||||
<ChevronRight className={cn("h-3.5 w-3.5 transition-transform", isExpanded && "rotate-90")} />
|
||||
</button>
|
||||
) : (
|
||||
<StatusIcon status={issue.status} displayStatus={issue.displayStatus} externalConversationState={issue.externalConversationState} blockerAttention={blockerAttention} size="md" />
|
||||
<StatusIcon {...taskStatusIconProps(issue)} blockerAttention={blockerAttention} size="md" />
|
||||
)
|
||||
}
|
||||
unreadState={isUnread ? "visible" : isFading ? "fading" : "hidden"}
|
||||
|
||||
@@ -13,6 +13,7 @@ import { QueryErrorState, useQueryView } from "../components/QueryView";
|
||||
import { formatDate } from "../lib/utils";
|
||||
import { ListTodo } from "lucide-react";
|
||||
import { useStreamlinedUiEnabled } from "../hooks/useStreamlinedUiEnabled";
|
||||
import { taskStatusIconProps } from "@/lib/task-status-presentation";
|
||||
|
||||
export function MyIssues() {
|
||||
const { enabled: streamlinedUiEnabled } = useStreamlinedUiEnabled();
|
||||
@@ -79,7 +80,7 @@ export function MyIssues() {
|
||||
title={issue.title}
|
||||
to={`/issues/${issue.identifier ?? issue.id}`}
|
||||
leading={
|
||||
<StatusIcon status={issue.status} displayStatus={issue.displayStatus} externalConversationState={issue.externalConversationState} blockerAttention={issue.blockerAttention} />
|
||||
<StatusIcon {...taskStatusIconProps(issue)} />
|
||||
}
|
||||
trailing={
|
||||
<span className="text-xs text-muted-foreground">
|
||||
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
updateKindLabel,
|
||||
} from "./format";
|
||||
import type { StatusCardView } from "./types";
|
||||
import { taskStatusIconProps } from "@/lib/task-status-presentation";
|
||||
|
||||
export function StatusCardDetailDrawer({
|
||||
card,
|
||||
@@ -584,7 +585,7 @@ function WatchedIssueRow({ issue }: { issue: CompanySearchIssueSummary }) {
|
||||
>
|
||||
{issue.identifier ?? issue.id.slice(0, 8)}
|
||||
</Link>
|
||||
<IssueStatusBadge status={issue.status} displayStatus={issue.displayStatus} externalConversationState={issue.externalConversationState} />
|
||||
<IssueStatusBadge {...taskStatusIconProps(issue)} />
|
||||
<span className="min-w-0 flex-1 truncate">{issue.title}</span>
|
||||
<span className="shrink-0 text-muted-foreground">{relativeTime(issue.updatedAt)}</span>
|
||||
</div>
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
providerDisplayName,
|
||||
relativeTime,
|
||||
} from "../lib/utils";
|
||||
import { taskStatusIconProps } from "@/lib/task-status-presentation";
|
||||
|
||||
const NO_COMPANY = "__none__";
|
||||
const PROFILE_SETTINGS_PATH = "/company/settings/instance/profile";
|
||||
@@ -342,7 +343,7 @@ export function UserProfile() {
|
||||
<span className="font-mono text-xs text-muted-foreground">{issue.identifier ?? issue.id.slice(0, 8)}</span>
|
||||
<span className="truncate text-sm">{issue.title}</span>
|
||||
<span className="flex items-center gap-3 sm:justify-end">
|
||||
<IssueStatusBadge status={issue.status} displayStatus={issue.displayStatus} />
|
||||
<IssueStatusBadge {...taskStatusIconProps(issue)} />
|
||||
<span className="text-xs tabular-nums text-muted-foreground">{relativeTime(issue.updatedAt)}</span>
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
@@ -14,11 +14,27 @@ import { IssueRow } from "@/components/IssueRow";
|
||||
import { MetricCard } from "@/components/MetricCard";
|
||||
import { PriorityIcon } from "@/components/PriorityIcon";
|
||||
import { QuotaBar } from "@/components/QuotaBar";
|
||||
import { StatusBadge } from "@/components/StatusBadge";
|
||||
import { IssueStatusBadge, StatusBadge } from "@/components/StatusBadge";
|
||||
import { StatusIcon } from "@/components/StatusIcon";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import type { TaskStatusPresentationInput } from "@/lib/task-status-presentation";
|
||||
import { createIssue } from "../../fixtures/paperclipData";
|
||||
|
||||
const sameStateAcrossViews: Array<{ label: string; state: TaskStatusPresentationInput }> = [
|
||||
{
|
||||
label: "Blocked, covered by an active sub-task (in queue)",
|
||||
state: {
|
||||
status: "blocked",
|
||||
blockerAttention: {
|
||||
state: "covered", reason: "active_child", unresolvedBlockerCount: 1, coveredBlockerCount: 1,
|
||||
attentionBlockerCount: 0, stalledBlockerCount: 0, sampleBlockerIdentifier: "ONB-3", sampleStalledBlockerIdentifier: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
{ label: "In review, conversation waiting (idle)", state: { status: "in_review", externalConversationState: "waiting" } },
|
||||
{ label: "In progress, monitoring", state: { status: "in_progress", displayStatus: "monitoring" } },
|
||||
];
|
||||
|
||||
function Section({
|
||||
eyebrow,
|
||||
title,
|
||||
@@ -415,6 +431,25 @@ function StatusLanguage() {
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section eyebrow="One mapping" title="Same state, every view">
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
{sameStateAcrossViews.map((item) => (
|
||||
<div key={item.label} className="space-y-3 rounded-lg border border-border bg-background/70 p-4">
|
||||
<div className="text-sm font-medium">{item.label}</div>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<StatusIcon {...item.state} /> Sidebar / list row
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<IssueStatusBadge {...item.state} /> Chat card chip
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<StatusIcon {...item.state} showLabel onChange={() => {}} /> Status menu (open to compare the current row)
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section eyebrow="Covered blocked" title="Blocked attention state matrix">
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||||
{coveredBlockedMatrix.map((item) => (
|
||||
|
||||
Reference in new issue
Block a user