feat(ui): show scheduled task waits as Monitoring

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-11-02 10:45:12 -06:00
1 parent 0748b90062
commit 7ddd8205ad
66 files changed
+537 -123

No files matched your search

+5
View File
@@ -97,6 +97,11 @@ When an agent creates a subtask or follow-up task, its source task's feed shows
a linked card at creation time with the current title, task number, and status.
The Tasks tab keeps the full relationship list.
Tasks waiting between automatic checks show **Monitoring** with a blue clock.
Task lists, board columns, filters, search, recent navigation and task properties
use the same read-only display state. The task retains its execution lifecycle
status, and the monitor banner shows when the next check is due.
Automatic monitor checks pause when a task is blocked. A person can use Check now
to request one check of a retained monitor through normal execution admission.
Dependencies, recovery, pause, budgets and ownership still apply. Provider-usage
+1
View File
@@ -423,6 +423,7 @@ retain their titles and default to no generation request.
- `title` text not null
- `description` text null
- `status` enum: `backlog | todo | in_progress | in_review | done | blocked | cancelled`
- Task reads additionally expose read-only `displayStatus`: the lifecycle status, or `monitoring` for an agent-owned scheduled ordinary monitor wait with no active execution, locks, review participant or active recovery action. Board collection/search `displayStatus` filters and counts use the same projection before pagination. This field is not persisted and cannot authorize a status transition; existing `status` filters retain their lifecycle semantics.
- `priority` enum: `critical | high | medium | low`
- `review_policy` nullable enum: `anyone | not_creator | human_only`; null is equivalent to `anyone`
- `assignee_agent_id` uuid fk `agents.id` null
+16
View File
@@ -112,6 +112,22 @@ The issue is actively owned work.
For agent-owned issues, `in_progress` should not be allowed to become a silent dead state.
### Monitoring presentation
An agent-owned `in_progress` task displays **Monitoring** with a blue clock while
it has a persisted ordinary monitor deadline and no active run, execution or
checkout lock, review participant, or active recovery action. The persisted
status remains `in_progress`. Monitor admission, checkout, finalization, recovery,
and agent lifecycle filters keep their existing contracts. Review, blocked,
terminal, and provider-quota recovery states keep their own presentation.
Task reads expose optional, read-only `displayStatus`. Board task collections and
search can filter by `displayStatus`; filtering happens before pagination and
counts. Existing `status` filters retain their lifecycle meaning. Monitoring is
not a manually selectable task status or a board drag target. An overdue check
retains its visible deadline warning; the label does not attest that a provider
is running or that a check succeeded.
### `blocked`
The issue cannot proceed until something external changes.
+6
View File
@@ -522,3 +522,9 @@ Evidence: `source-only` — reviewed at `3bbc2104b65caf9f7763938a01260a5673e12f7
Verification: `not-run` — Compare eligible owner/admin ordinary denied with explicit read/audit+notice; ordinary operator/agent/foreign denied, grants unchanged.
Gaps: Local implicit/instance admin bypass cannot establish ordinary two-user private isolation.
Task reads expose optional read-only `displayStatus`. `monitoring` describes a
scheduled ordinary monitor wait; it is not accepted as a writable task `status`.
Existing lifecycle `status` filters retain their meaning. Board collection and
search readers can use the separate `displayStatus` filter before pagination.
Agents keep using the existing monitor and semantic completion tools.
+6
View File
@@ -56,6 +56,12 @@ Use New task from the global launcher, task list, or project task view.
### `task-list`
Scheduled ordinary monitor waits display **Monitoring** with a blue clock in
current task indicators, groups, board columns, and filters. Compare Monitoring
with In Progress, then start the check and verify the status returns to its
execution presentation. Retained monitors must not override blocked/review or
terminal states. Inspect due/overdue copy separately; no runner is implied.
Open `/issues` or a project’s Issues view; legacy `/tasks` links redirect.
Filters → **Routine tasks** selects tasks created by routine runs. The
`/issues?view=all&originKind=routine_execution` link opens that selection.
+6
View File
@@ -198,6 +198,12 @@ export const ISSUE_STATUSES = [
] as const;
export type IssueStatus = (typeof ISSUE_STATUSES)[number];
/** Read-only task presentation. Monitoring never grants execution authority. */
export const ISSUE_DISPLAY_STATUSES = [
"backlog", "todo", "in_progress", "monitoring", "in_review", "done", "blocked", "cancelled",
] as const;
export type IssueDisplayStatus = (typeof ISSUE_DISPLAY_STATUSES)[number];
export const INBOX_MINE_ISSUE_STATUSES = [
"backlog",
"todo",
+2
View File
@@ -364,6 +364,7 @@ export {
AGENT_ICON_NAMES,
PROJECT_ICON_NAMES,
ISSUE_STATUSES,
ISSUE_DISPLAY_STATUSES,
ISSUE_SUMMARIES_MAX_IDS,
INBOX_MINE_ISSUE_STATUSES,
INBOX_MINE_ISSUE_STATUS_FILTER,
@@ -562,6 +563,7 @@ export {
type AgentIconName,
type ProjectIconName,
type IssueStatus,
type IssueDisplayStatus,
type IssuePriority,
type IssueReviewPolicy,
type IssueWorkMode,
+6 -1
View File
@@ -20,6 +20,7 @@ import type {
IssueHarnessKind,
IssueOriginKind,
IssuePriority,
IssueDisplayStatus,
IssueReviewPolicy,
IssueRecoveryActionKind,
IssueRecoveryActionOutcome,
@@ -270,6 +271,7 @@ export interface AcceptedPlanDecompositionSummary extends AcceptedPlanDecomposit
}
export interface IssueRelationIssueSummary {
displayStatus?: IssueDisplayStatus;
id: string;
identifier: string | null;
title: string;
@@ -850,12 +852,14 @@ export type IssueChanges = Record<string, IssueChangeReceiptEntry>;
export type IssueSummary = Pick<
Issue,
"id" | "companyId" | "title" | "identifier" | "status" | "updatedAt"
"id" | "companyId" | "title" | "identifier" | "status" | "displayStatus" | "updatedAt"
> & {
externalConversationState: "active" | "waiting" | null;
};
export interface Issue {
/** Read-only status for task presentation; never use it for execution admission. */
displayStatus?: IssueDisplayStatus;
/** Collection projections omit task bodies and cannot seed the detail cache. */
listProjection?: "index" | "rows";
statusVersion?: number;
@@ -985,6 +989,7 @@ export type CompactIssue = Pick<
| "privacyRootIssueId"
| "title"
| "description"
| "displayStatus"
| "status"
| "workMode"
| "priority"
+3 -1
View File
@@ -1,4 +1,4 @@
import type { IssuePriority, IssueStatus } from "../constants.js";
import type { IssueDisplayStatus, IssuePriority, IssueStatus } from "../constants.js";
export const COMPANY_SEARCH_SCOPES = ["all", "issues", "comments", "documents", "artifacts", "agents", "projects"] as const;
export type CompanySearchScope = (typeof COMPANY_SEARCH_SCOPES)[number];
@@ -38,6 +38,7 @@ export interface CompanySearchIssueSummary {
identifier: string | null;
title: string;
status: IssueStatus;
displayStatus?: IssueDisplayStatus;
externalConversationState?: "active" | "waiting" | null;
priority: IssuePriority;
assigneeAgentId: string | null;
@@ -76,6 +77,7 @@ export interface CompanySearchResult {
export interface CompanySearchFilterOptionCounts {
status: Partial<Record<IssueStatus, number>>;
displayStatus?: Partial<Record<IssueDisplayStatus, number>>;
priority: Partial<Record<IssuePriority, number>>;
assigneeAgentId: Record<string, number>;
assigneeUserId: Record<string, number>;
+2 -1
View File
@@ -1,4 +1,4 @@
import type { IssuePriority, IssueStatus } from "../constants.js";
import type { IssuePriority, IssueStatus, IssueDisplayStatus } from "../constants.js";
export interface UserProfileIdentity {
id: string;
@@ -42,6 +42,7 @@ export interface UserProfileIssueSummary {
identifier: string | null;
title: string;
status: IssueStatus;
displayStatus?: IssueDisplayStatus;
priority: IssuePriority;
assigneeAgentId: string | null;
assigneeUserId: string | null;
+4 -1
View File
@@ -1,5 +1,5 @@
import { z } from "zod";
import { ISSUE_PRIORITIES, ISSUE_STATUSES } from "../constants.js";
import { ISSUE_DISPLAY_STATUSES, ISSUE_PRIORITIES, ISSUE_STATUSES } from "../constants.js";
import { isUuidLike } from "../agent-url-key.js";
import {
COMPANY_SEARCH_EXTRACT_KINDS,
@@ -156,6 +156,9 @@ export const companySearchQuerySchema = z.object({
status: z.unknown()
.optional()
.transform((value, ctx) => parseEnumList(value, ctx, "status", ISSUE_STATUSES)),
displayStatus: z.unknown()
.optional()
.transform((value, ctx) => parseEnumList(value, ctx, "displayStatus", ISSUE_DISPLAY_STATUSES)),
priority: z.unknown()
.optional()
.transform((value, ctx) => parseEnumList(value, ctx, "priority", ISSUE_PRIORITIES)),
@@ -0,0 +1,111 @@
import { randomUUID } from "node:crypto";
import { eq, sql } from "drizzle-orm";
import { expect, it } from "vitest";
import request from "supertest";
import { agents, heartbeatRuns, issueRecoveryActions, issues } from "@paperclipai/db";
import { companySearchQuerySchema, createIssueSchema, PROVIDER_QUOTA_MONITOR_SERVICE_NAME } from "@paperclipai/shared";
import { issueDisplayStatusSql } from "../services/issue-display-status.js";
import { issueService } from "../services/issues.js";
import { companySearchService } from "../services/company-search.js";
import { issueRoutes } from "../routes/issues.js";
import {
describeEmbeddedPostgres, routeApp,
seedCompanyWithBoardAccess, useEmbeddedPostgres,
} from "./helpers/route-test-harness.js";
describeEmbeddedPostgres("Monitoring task presentation", () => {
const ctx = useEmbeddedPostgres("paperclip-monitoring-status-", {
resetEach: async (db) => { await db.execute(sql`SET client_min_messages = warning`); await db.execute(sql`TRUNCATE TABLE companies CASCADE`); },
});
async function fixture() {
const company = await seedCompanyWithBoardAccess(ctx.db, "Monitoring");
const agentId = randomUUID();
await ctx.db.insert(agents).values({ id: agentId, companyId: company.companyId, name: "Monitor owner", role: "engineer", status: "paused", adapterType: "process", adapterConfig: {} });
const nextCheckAt = new Date(Date.now() + 60 * 60_000);
const issueId = randomUUID();
await ctx.db.insert(issues).values({
id: issueId, companyId: company.companyId, title: "Check deployment", status: "in_progress",
assigneeAgentId: agentId, createdByUserId: company.actor.userId,
monitorNextCheckAt: nextCheckAt,
executionPolicy: { mode: "normal", stages: [], commentRequired: false, monitor: { nextCheckAt: nextCheckAt.toISOString(), notes: "Check deployment" } },
});
return { ...company, agentId, issueId };
}
async function displayStatus(id: string) {
const [row] = await ctx.db.select({ status: issues.status, displayStatus: issueDisplayStatusSql() }).from(issues).where(eq(issues.id, id));
return row;
}
it("projects a scheduled wait consistently across detail, compact rows, summaries, search and counts", async () => {
const f = await fixture();
const svc = issueService(ctx.db);
expect(await svc.getById(f.issueId)).toMatchObject({ status: "in_progress", displayStatus: "monitoring" });
expect(await svc.list(f.companyId, { displayStatus: "monitoring", listProjection: "index" })).toEqual([expect.objectContaining({ id: f.issueId, status: "in_progress", displayStatus: "monitoring" })]);
expect(await svc.count(f.companyId, { displayStatus: "monitoring" })).toBe(1);
const app = routeApp(ctx.db, f.actor, issueRoutes);
const summary = await request(app).get(`/api/companies/${f.companyId}/issues/summaries?ids=${f.issueId}`).expect(200);
expect(summary.body[0]).toMatchObject({ status: "in_progress", displayStatus: "monitoring" });
const result = await companySearchService(ctx.db).search(f.companyId, companySearchQuerySchema.parse({ q: "deployment", displayStatus: "monitoring" }));
expect(result.results[0]?.issue).toMatchObject({ id: f.issueId, status: "in_progress", displayStatus: "monitoring" });
expect(result.filterOptionCounts.displayStatus?.monitoring).toBe(1);
expect(result.filterOptionCounts.status.in_progress).toBe(1);
// Existing agent/API lifecycle filters keep their original meaning.
expect(await svc.count(f.companyId, { status: "in_progress" })).toBe(1);
expect(createIssueSchema.safeParse({ title: "Manual monitoring", status: "monitoring" }).success).toBe(false);
});
it("filters before pagination and keeps In Progress separate from Monitoring", async () => {
const f = await fixture();
await ctx.db.insert(issues).values({ companyId: f.companyId, title: "Ordinary work", status: "in_progress", assigneeAgentId: f.agentId });
const svc = issueService(ctx.db);
expect(await svc.list(f.companyId, { displayStatus: "monitoring", limit: 1, offset: 0 })).toEqual([expect.objectContaining({ id: f.issueId })]);
expect(await svc.list(f.companyId, { displayStatus: "monitoring", limit: 1, offset: 1 })).toEqual([]);
expect(await svc.count(f.companyId, { displayStatus: "in_progress" })).toBe(1);
expect(await svc.count(f.companyId, { status: "in_progress" })).toBe(2);
});
it("returns to In Progress during the check and Monitoring after another yield, then Done", async () => {
const f = await fixture();
const runId = randomUUID();
await ctx.db.insert(heartbeatRuns).values({ id: runId, companyId: f.companyId, agentId: f.agentId, issueId: f.issueId, status: "queued" });
expect((await displayStatus(f.issueId)).displayStatus).toBe("in_progress");
await ctx.db.update(heartbeatRuns).set({ status: "running" }).where(eq(heartbeatRuns.id, runId));
await ctx.db.update(issues).set({ executionRunId: runId, checkoutRunId: runId }).where(eq(issues.id, f.issueId));
expect((await displayStatus(f.issueId)).displayStatus).toBe("in_progress");
await ctx.db.update(heartbeatRuns).set({ status: "succeeded" }).where(eq(heartbeatRuns.id, runId));
await ctx.db.update(issues).set({ executionRunId: null, checkoutRunId: null }).where(eq(issues.id, f.issueId));
expect((await displayStatus(f.issueId)).displayStatus).toBe("monitoring");
await ctx.db.update(issues).set({ status: "done" }).where(eq(issues.id, f.issueId));
expect(await displayStatus(f.issueId)).toEqual({ status: "done", displayStatus: "done" });
});
it.each(["blocked", "in_review", "todo", "cancelled"])("preserves %s even with a retained monitor", async (status) => {
const f = await fixture();
await ctx.db.update(issues).set({ status }).where(eq(issues.id, f.issueId));
expect((await displayStatus(f.issueId)).displayStatus).toBe(status);
});
it("does not label cleared, consumed, orphaned or quota-recovery schedules Monitoring", async () => {
const f = await fixture();
await ctx.db.update(issues).set({ executionState: { monitor: { status: "cleared" } } }).where(eq(issues.id, f.issueId));
expect((await displayStatus(f.issueId)).displayStatus).toBe("in_progress");
await ctx.db.update(issues).set({ executionState: null, monitorNextCheckAt: null }).where(eq(issues.id, f.issueId));
expect((await displayStatus(f.issueId)).displayStatus).toBe("in_progress");
await ctx.db.update(issues).set({ monitorNextCheckAt: new Date(), executionPolicy: { monitor: { serviceName: PROVIDER_QUOTA_MONITOR_SERVICE_NAME } } }).where(eq(issues.id, f.issueId));
expect((await displayStatus(f.issueId)).displayStatus).toBe("in_progress");
await ctx.db.update(issues).set({ executionPolicy: null }).where(eq(issues.id, f.issueId));
expect((await displayStatus(f.issueId)).displayStatus).toBe("in_progress");
});
it("keeps recovery and review holds visible and never writes a new lifecycle state", async () => {
const f = await fixture();
await ctx.db.insert(issueRecoveryActions).values({ companyId: f.companyId, sourceIssueId: f.issueId, kind: "source", ownerType: "board", cause: "process_lost", fingerprint: randomUUID(), nextAction: "Inspect stopped run" });
expect((await displayStatus(f.issueId)).displayStatus).toBe("in_progress");
await ctx.db.execute(sql`UPDATE issue_recovery_actions SET status = 'resolved' WHERE source_issue_id = ${f.issueId}`);
expect((await displayStatus(f.issueId)).displayStatus).toBe("monitoring");
await ctx.db.update(issues).set({ executionState: { currentParticipant: { type: "user", userId: "reviewer" } } }).where(eq(issues.id, f.issueId));
expect((await displayStatus(f.issueId)).displayStatus).toBe("in_progress");
});
});
+3
View File
@@ -8379,6 +8379,7 @@ export function issueRoutes(
title: row.title,
identifier: row.identifier,
status: row.status as IssueSummary["status"],
displayStatus: row.displayStatus,
updatedAt: row.updatedAt,
externalConversationState: row.externalConversationState,
}));
@@ -8619,6 +8620,7 @@ export function issueRoutes(
readCondition: await issueReadSqlCondition(db, req.actor),
attention: attention === "blocked" ? "blocked" : undefined,
status: req.query.status as string | string[] | undefined,
displayStatus: req.query.displayStatus as string | string[] | undefined,
assigneeAgentId,
participantAgentId: uuidFilters.participantAgentId,
assigneeUserId,
@@ -8869,6 +8871,7 @@ export function issueRoutes(
readCondition: await issueReadSqlCondition(db, req.actor),
attention: "blocked",
status: req.query.status as string | string[] | undefined,
displayStatus: req.query.displayStatus as string | string[] | undefined,
assigneeAgentId: req.query.assigneeAgentId as string | undefined,
participantAgentId: countUuidFilters.participantAgentId,
assigneeUserId: req.query.assigneeUserId as string | undefined,
+2
View File
@@ -1,3 +1,4 @@
import { issueDisplayStatusSql } from "../services/issue-display-status.js";
import { Router } from "express";
import { and, desc, eq, gte, inArray, isNull, or, sql, type SQLWrapper } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
@@ -347,6 +348,7 @@ export function userProfileRoutes(db: Db) {
identifier: issues.identifier,
title: issues.title,
status: issues.status,
displayStatus: issueDisplayStatusSql(),
priority: issues.priority,
assigneeAgentId: issues.assigneeAgentId,
assigneeUserId: issues.assigneeUserId,
+18 -3
View File
@@ -1,3 +1,4 @@
import { issueDisplayStatusSql } from "./issue-display-status.js";
import { externalConversationStateSql } from "./slack-conversation-state.js";
import { and, desc, eq, gte, inArray, isNotNull, isNull, notInArray, or, sql } from "drizzle-orm";
import type { SQL } from "drizzle-orm";
@@ -21,6 +22,7 @@ import {
COMPANY_ARTIFACTS_MAX_QUERY_LENGTH,
ISSUE_PRIORITIES,
ISSUE_STATUSES,
ISSUE_DISPLAY_STATUSES,
SYSTEM_ISSUE_DOCUMENT_KEYS,
type CompanyArtifact,
type CompanySearchArtifactSummary,
@@ -50,6 +52,7 @@ type IssueSearchRow = {
title: string;
description: string | null;
status: string;
displayStatus: string;
priority: string;
assigneeAgentId: string | null;
assigneeUserId: string | null;
@@ -193,6 +196,7 @@ function emptySearchCounts(): Record<CompanySearchCountType, number> {
function emptyFilterOptionCounts(): CompanySearchFilterOptionCounts {
return {
status: {},
displayStatus: {},
priority: {},
assigneeAgentId: {},
assigneeUserId: {},
@@ -224,7 +228,7 @@ function updatedWithinStart(value: string | undefined, now = new Date()): Date |
}
function issueOnlyFiltersActive(query: CompanySearchQuery) {
return query.status.length > 0
return (query.status.length > 0 || query.displayStatus.length > 0)
|| query.priority.length > 0
|| query.assigneeAgentId !== undefined
|| Boolean(query.assigneeUserId)
@@ -236,7 +240,7 @@ function issueOnlyFiltersActive(query: CompanySearchQuery) {
function activeIssueFilters(query: CompanySearchQuery): Array<{ key: CompanySearchIssueFilterKey; values: string[] }> {
const filters: Array<{ key: CompanySearchIssueFilterKey; values: string[] }> = [];
if (query.status.length > 0) filters.push({ key: "status", values: query.status });
if (query.status.length > 0 || query.displayStatus.length > 0) filters.push({ key: "status", values: [...query.status, ...query.displayStatus] });
if (query.assigneeAgentId !== undefined) filters.push({ key: "assigneeAgentId", values: [query.assigneeAgentId ?? "null"] });
if (query.assigneeUserId) filters.push({ key: "assigneeUserId", values: [query.assigneeUserId] });
if (query.projectId) filters.push({ key: "projectId", values: [query.projectId] });
@@ -251,6 +255,7 @@ function queryWithoutFilter(query: CompanySearchQuery, key: CompanySearchIssueFi
return {
...query,
status: key === "status" ? [] : query.status,
displayStatus: key === "status" ? [] : query.displayStatus,
priority: key === "priority" ? [] : query.priority,
assigneeAgentId: key === "assigneeAgentId" ? undefined : query.assigneeAgentId,
assigneeUserId: key === "assigneeUserId" ? undefined : query.assigneeUserId,
@@ -265,6 +270,7 @@ function queryWithoutIssueFilters(query: CompanySearchQuery): CompanySearchQuery
return {
...query,
status: [],
displayStatus: [],
priority: [],
assigneeAgentId: undefined,
assigneeUserId: undefined,
@@ -277,6 +283,7 @@ function queryWithoutIssueFilters(query: CompanySearchQuery): CompanySearchQuery
function issueFilterConditions(companyId: string, query: CompanySearchQuery, omit?: CompanySearchIssueFilterKey): SQL[] {
const conditions: SQL[] = [];
if (omit !== "status" && query.displayStatus.length) conditions.push(inArray(issueDisplayStatusSql(), query.displayStatus));
if (omit !== "status" && query.status.length > 0) {
conditions.push(query.status.length === 1 ? eq(issues.status, query.status[0]!) : inArray(issues.status, query.status));
}
@@ -315,6 +322,7 @@ function issueFilterConditions(companyId: string, query: CompanySearchQuery, omi
// matched-issues CTE (plain columns, no drizzle table references).
function matchedFacetConditions(companyId: string, query: CompanySearchQuery, omit?: CompanySearchIssueFilterKey): SQL[] {
const conditions: SQL[] = [];
if (omit !== "status" && query.displayStatus.length) conditions.push(sql`m.display_status = ANY(${sqlTextArray(query.displayStatus)})`);
if (omit !== "status" && query.status.length > 0) {
conditions.push(sql`m.status = ANY(${sqlTextArray(query.status)})`);
}
@@ -447,6 +455,7 @@ function issueResult(row: IssueSearchRow, prefix: string, normalizedQuery: strin
identifier: row.identifier,
title: row.title,
status: row.status as CompanySearchIssueSummary["status"],
displayStatus: row.displayStatus as CompanySearchIssueSummary["displayStatus"],
priority: row.priority as CompanySearchIssueSummary["priority"],
assigneeAgentId: row.assigneeAgentId,
assigneeUserId: row.assigneeUserId,
@@ -626,7 +635,7 @@ export function companySearchService(db: Db) {
conditions.length > 0 ? sql`WHERE ${sql.join(conditions, sql` AND `)}` : sql``;
// Count branches must match the result branch's column list; the
// trailing NULLs pad the issue data columns.
const countTail = sql`, NULL::uuid, NULL::text, NULL::text, NULL::text, NULL::text, NULL::text, NULL::uuid, NULL::text, NULL::uuid, NULL::timestamptz, NULL::timestamptz, NULL::double precision, NULL::text[]`;
const countTail = sql`, NULL::uuid, NULL::text, NULL::text, NULL::text, NULL::text, NULL::text, NULL::text, NULL::uuid, NULL::text, NULL::uuid, NULL::timestamptz, NULL::timestamptz, NULL::double precision, NULL::text[]`;
const branches: SQL[] = [];
const wantResultRows = scopeIncludesIssues(scope)
@@ -652,6 +661,7 @@ export function companySearchService(db: Db) {
(SELECT issue_text.description FROM issues issue_text
WHERE issue_text.id = m.id AND issue_text.company_id = ${companyId}) AS description,
m.status,
m.display_status AS "displayStatus",
m.priority,
m.assignee_agent_id AS "assigneeAgentId",
m.assignee_user_id AS "assigneeUserId",
@@ -690,6 +700,7 @@ export function companySearchService(db: Db) {
GROUP BY 2
`;
branches.push(facetBranch("facet:status", sql`m.status`, "status"));
branches.push(facetBranch("facet:displayStatus", sql`m.display_status`, "status"));
branches.push(facetBranch("facet:priority", sql`m.priority`, "priority"));
branches.push(facetBranch("facet:assigneeAgentId", sql`m.assignee_agent_id`, "assigneeAgentId", [sql`m.assignee_agent_id IS NOT NULL`]));
branches.push(facetBranch("facet:assigneeUserId", sql`m.assignee_user_id`, "assigneeUserId", [sql`m.assignee_user_id IS NOT NULL`]));
@@ -757,6 +768,7 @@ export function companySearchService(db: Db) {
title: row.title,
description: row.description,
status: row.status,
displayStatus: row.displayStatus,
priority: row.priority,
assigneeAgentId: row.assigneeAgentId,
assigneeUserId: row.assigneeUserId,
@@ -780,6 +792,9 @@ export function companySearchService(db: Db) {
if (row.kind === "type:issue") aggregates.typeCounts.issue = count;
else if (row.kind === "type:comment") aggregates.typeCounts.comment = count;
else if (row.kind === "type:document") aggregates.typeCounts.document = count;
else if (row.kind === "facet:displayStatus" && row.value && (ISSUE_DISPLAY_STATUSES as readonly string[]).includes(row.value)) {
aggregates.filterOptionCounts.displayStatus![row.value as keyof NonNullable<CompanySearchFilterOptionCounts["displayStatus"]>] = count;
}
else if (row.kind === "facet:status" && row.value && (ISSUE_STATUSES as readonly string[]).includes(row.value)) {
aggregates.filterOptionCounts.status[row.value as keyof CompanySearchFilterOptionCounts["status"]] = count;
} else if (row.kind === "facet:priority" && row.value && (ISSUE_PRIORITIES as readonly string[]).includes(row.value)) {
@@ -0,0 +1,37 @@
import { sql, type SQL } from "drizzle-orm";
import { issues } from "@paperclipai/db";
import { PROVIDER_QUOTA_MONITOR_SERVICE_NAME, type IssueDisplayStatus } from "@paperclipai/shared";
/** Read-only projection, shared by task reads, search, filters and counts.
* The persisted deadline is the scheduler's source of truth. Locks and recovery
* take precedence; a retained monitor alone must not disguise active work.
*/
export function issueDisplayStatusSql(): SQL<IssueDisplayStatus> {
// Drizzle removes column qualifiers in a single-table SELECT. Keep these
// outer references explicitly qualified so correlated reads cannot bind to
// the run/recovery table's own id or company_id instead.
const issueId = sql`"issues"."id"`;
const companyId = sql`"issues"."company_id"`;
return sql<IssueDisplayStatus>`CASE WHEN
${issues.status} = 'in_progress'
AND ${issues.assigneeAgentId} IS NOT NULL AND ${issues.assigneeUserId} IS NULL
AND ${issues.monitorNextCheckAt} IS NOT NULL
AND ${issues.executionRunId} IS NULL AND ${issues.checkoutRunId} IS NULL
AND ${issues.executionState}->>'currentParticipant' IS NULL
AND COALESCE(${issues.executionState}#>>'{monitor,status}', 'scheduled') = 'scheduled'
AND ${issues.executionPolicy}->'monitor' IS NOT NULL
AND COALESCE(${issues.executionPolicy}#>>'{monitor,serviceName}', '') <> ${PROVIDER_QUOTA_MONITOR_SERVICE_NAME}
AND NOT EXISTS (
SELECT 1 FROM heartbeat_runs display_run
WHERE display_run.company_id = ${companyId}
AND display_run.issue_id = ${issueId}
AND display_run.status IN ('queued', 'running')
)
AND NOT EXISTS (
SELECT 1 FROM issue_recovery_actions display_recovery
WHERE display_recovery.company_id = ${companyId}
AND display_recovery.source_issue_id = ${issueId}
AND display_recovery.status = 'active'
)
THEN 'monitoring' ELSE ${issues.status} END`;
}
+2
View File
@@ -1,3 +1,4 @@
import { issueDisplayStatusSql } from "./issue-display-status.js";
import { and, eq, inArray, isNull, type SQL } from "drizzle-orm";
import { issues, type Db } from "@paperclipai/db";
import { ISSUE_SUMMARIES_MAX_IDS } from "@paperclipai/shared";
@@ -18,6 +19,7 @@ export async function listIssueSummaries(
title: issues.title,
identifier: issues.identifier,
status: issues.status,
displayStatus: issueDisplayStatusSql(),
updatedAt: issues.updatedAt,
externalConversationState: externalConversationStateSql(),
projectId: issues.projectId,
+21 -3
View File
@@ -1,3 +1,5 @@
import type { IssueDisplayStatus } from "@paperclipai/shared";
import { issueDisplayStatusSql } from "./issue-display-status.js";
import { notifyStrandedAgentStatus, notifyActiveRunSilence } from "./live-run-recovery-notifications.js";
import { finishedIdleWake } from "./idle-work-proof.js";
import { notifyDeferredExecutionEligibility } from "./deferred-execution-notifications.js";
@@ -1900,6 +1902,7 @@ export function parseStatusFilter(
export interface IssueFilters {
attention?: "blocked";
status?: string | readonly string[];
displayStatus?: string | readonly string[];
/**
* Filter by assignee agent ID.
* - `string` (UUID): match issues assigned to that agent.
@@ -1951,7 +1954,7 @@ export interface IssueFilters {
updatedSince?: string;
}
type IssueRow = typeof issues.$inferSelect & { externalConversationState?: "active" | "waiting" | null };
type IssueRow = typeof issues.$inferSelect & { displayStatus?: IssueDisplayStatus; externalConversationState?: "active" | "waiting" | null };
type IssueLabelRow = typeof labels.$inferSelect;
type IssuePlanDecompositionRow = typeof issuePlanDecompositions.$inferSelect;
type IssueActiveRunRow = {
@@ -3603,6 +3606,7 @@ function appendBlockerAttentionEdges(
}
type IssueRelationSummaryRow = {
displayStatus?: IssueDisplayStatus;
relatedId: string;
identifier: string | null;
title: string;
@@ -3620,6 +3624,7 @@ function summarizeIssueRelationRow(
identifier: row.identifier,
title: row.title,
status: row.status as IssueRelationIssueSummary["status"],
displayStatus: row.displayStatus,
priority: row.priority as IssueRelationIssueSummary["priority"],
assigneeAgentId: row.assigneeAgentId,
assigneeUserId: row.assigneeUserId,
@@ -3735,6 +3740,7 @@ async function terminalExplicitBlockersByRoot(
identifier: issues.identifier,
title: issues.title,
status: issues.status,
displayStatus: issueDisplayStatusSql(),
priority: issues.priority,
assigneeAgentId: issues.assigneeAgentId,
assigneeUserId: issues.assigneeUserId,
@@ -5039,6 +5045,7 @@ const issueListSelect = {
END
`,
status: issues.status,
displayStatus: issueDisplayStatusSql(),
statusVersion: issues.statusVersion,
lastStatusDecisionId: issues.lastStatusDecisionId,
workMode: issues.workMode,
@@ -5301,6 +5308,7 @@ async function blockedByMapForIssues(
identifier: row.identifier,
title: row.title,
status: row.status as IssueRelationIssueSummary["status"],
displayStatus: row.displayStatus,
priority: row.priority as IssueRelationIssueSummary["priority"],
assigneeAgentId: row.assigneeAgentId,
assigneeUserId: row.assigneeUserId,
@@ -6420,6 +6428,8 @@ async function blockedInboxIssueConditions(
filters?.lowTrustBoundary,
);
if (lowTrustCondition) conditions.push(lowTrustCondition);
const displayStatuses = parseStatusFilter(filters?.displayStatus);
if (displayStatuses.length) conditions.push(inArray(issueDisplayStatusSql(), displayStatuses));
const statuses = parseStatusFilter(filters?.status);
if (statuses.length > 0) {
conditions.push(
@@ -6872,7 +6882,7 @@ export function issueService(db: Db) {
async function getIssueByUuid(id: string) {
const row = await retryIdempotentDatabaseOperation(() => db
.select({ ...getTableColumns(issues), externalConversationState: externalConversationStateSql() })
.select({ ...getTableColumns(issues), displayStatus: issueDisplayStatusSql(), externalConversationState: externalConversationStateSql() })
.from(issues)
.where(eq(issues.id, id))
.then((rows) => rows[0] ?? null));
@@ -6883,7 +6893,7 @@ export function issueService(db: Db) {
async function getIssueByIdentifier(identifier: string) {
const row = await retryIdempotentDatabaseOperation(() => db
.select({ ...getTableColumns(issues), externalConversationState: externalConversationStateSql() })
.select({ ...getTableColumns(issues), displayStatus: issueDisplayStatusSql(), externalConversationState: externalConversationStateSql() })
.from(issues)
.where(eq(issues.identifier, identifier.toUpperCase()))
.then((rows) => rows[0] ?? null));
@@ -7482,6 +7492,7 @@ export function issueService(db: Db) {
identifier: issues.identifier,
title: issues.title,
status: issues.status,
displayStatus: issueDisplayStatusSql(),
priority: issues.priority,
assigneeAgentId: issues.assigneeAgentId,
assigneeUserId: issues.assigneeUserId,
@@ -7502,6 +7513,7 @@ export function issueService(db: Db) {
identifier: issues.identifier,
title: issues.title,
status: issues.status,
displayStatus: issueDisplayStatusSql(),
priority: issues.priority,
assigneeAgentId: issues.assigneeAgentId,
assigneeUserId: issues.assigneeUserId,
@@ -8281,6 +8293,8 @@ export function issueService(db: Db) {
filters?.lowTrustBoundary,
);
if (lowTrustCondition) conditions.push(lowTrustCondition);
const displayStatuses = parseStatusFilter(filters?.displayStatus);
if (displayStatuses.length) conditions.push(inArray(issueDisplayStatusSql(), displayStatuses));
const statuses = parseStatusFilter(filters?.status);
if (statuses.length === 1) {
conditions.push(eq(issues.status, statuses[0]));
@@ -8574,6 +8588,8 @@ export function issueService(db: Db) {
conditions.push(nonIdleSlackIssueCondition());
}
}
const displayStatuses = parseStatusFilter(filters?.displayStatus);
if (displayStatuses.length) conditions.push(inArray(issueDisplayStatusSql(), displayStatuses));
const statuses = parseStatusFilter(filters?.status);
if (statuses.length === 1)
conditions.push(eq(issues.status, statuses[0]!));
@@ -8895,6 +8911,7 @@ export function issueService(db: Db) {
identifier: issues.identifier,
title: issues.title,
status: issues.status,
displayStatus: issueDisplayStatusSql(),
priority: issues.priority,
assigneeAgentId: issues.assigneeAgentId,
assigneeUserId: issues.assigneeUserId,
@@ -9522,6 +9539,7 @@ export function issueService(db: Db) {
identifier: issues.identifier,
title: issues.title,
status: issues.status,
displayStatus: issueDisplayStatusSql(),
priority: issues.priority,
assigneeAgentId: issues.assigneeAgentId,
assigneeUserId: issues.assigneeUserId,
+2 -1
View File
@@ -1,3 +1,4 @@
import { issueDisplayStatusSql } from "./issue-display-status.js";
import { sql, type SQL } from "drizzle-orm";
import { COMPANY_SEARCH_MAX_QUERY_LENGTH, COMPANY_SEARCH_MAX_TOKENS } from "@paperclipai/shared";
import { visibleIssueCondition } from "./issue-visibility.js";
@@ -134,7 +135,7 @@ export function taskSearchCtes(companyId: string, search: TaskSearch, includeCon
// The search page fetches descriptions only for its result window.
const flags = (fuzzyMatch: SQL) => sql`
SELECT issues.id, issues.identifier, issues.title,
issues.status, issues.priority, issues.assignee_agent_id, issues.assignee_user_id,
issues.status, ${issueDisplayStatusSql()} AS display_status, issues.priority, issues.assignee_agent_id, issues.assignee_user_id,
issues.project_id, issues.created_at, issues.updated_at,
${identExact} AS ident_exact, ${identStarts} AS ident_starts,
${phraseMatch(sql`issues.identifier`)} AS ident_phrase,
+2
View File
@@ -194,6 +194,7 @@ export type IssueListFilters = {
projection?: "index" | "rows";
attention?: "blocked";
status?: string;
displayStatus?: string;
projectId?: string;
parentId?: string;
assigneeAgentId?: string;
@@ -229,6 +230,7 @@ function issueListSearchParams(filters?: IssueListFilters) {
const params = new URLSearchParams();
if (filters?.attention) params.set("attention", filters.attention);
if (filters?.status) params.set("status", filters.status);
if (filters?.displayStatus) params.set("displayStatus", filters.displayStatus);
if (filters?.projectId) params.set("projectId", filters.projectId);
if (filters?.parentId) params.set("parentId", filters.parentId);
if (filters?.parentIds) params.set("parentIds", filters.parentIds.join(","));
+3 -3
View File
@@ -1,4 +1,4 @@
import type { CompanySearchResponse, CompanySearchScope, CompanySearchSort, IssuePriority, IssueStatus } from "@paperclipai/shared";
import type { CompanySearchResponse, CompanySearchScope, CompanySearchSort, IssuePriority, IssueDisplayStatus } from "@paperclipai/shared";
import { api } from "./client";
export interface CompanySearchParams {
@@ -6,7 +6,7 @@ export interface CompanySearchParams {
scope?: CompanySearchScope;
limit?: number;
offset?: number;
status?: IssueStatus[];
status?: IssueDisplayStatus[];
priority?: IssuePriority[];
assigneeAgentId?: string | null;
assigneeUserId?: string;
@@ -28,7 +28,7 @@ export const searchApi = {
if (params.scope) search.set("scope", params.scope);
if (params.limit !== undefined) search.set("limit", String(params.limit));
if (params.offset !== undefined) search.set("offset", String(params.offset));
appendMulti(search, "status", params.status);
appendMulti(search, "displayStatus", params.status);
appendMulti(search, "priority", params.priority);
if (params.assigneeAgentId !== undefined) search.set("assigneeAgentId", params.assigneeAgentId ?? "null");
if (params.assigneeUserId !== undefined) search.set("assigneeUserId", params.assigneeUserId);
+3 -3
View File
@@ -376,7 +376,7 @@ function BlockedInboxRow({
showDivider={presentation === "legacy"}
statusSlot={presentation === "task"
? showStatusColumn
? <StatusIcon status={row.issue.status} blockerAttention={blockerAttention} />
? <StatusIcon status={row.issue.status} displayStatus={row.issue.displayStatus} blockerAttention={blockerAttention} />
: <span className="inline-flex size-4" aria-hidden="true" />
: undefined}
showIdentifier={presentation === "task" ? showIdentifierColumn : undefined}
@@ -390,7 +390,7 @@ function BlockedInboxRow({
) : undefined}
mobileLeading={
<span className="flex shrink-0 items-center gap-1.5 pt-px">
<StatusIcon status={row.issue.status} blockerAttention={blockerAttention} />
<StatusIcon status={row.issue.status} displayStatus={row.issue.displayStatus} blockerAttention={blockerAttention} />
</span>
}
titleSuffix={
@@ -422,7 +422,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} blockerAttention={blockerAttention} /> : null}
{showStatusColumn ? <StatusIcon status={row.issue.status} displayStatus={row.issue.displayStatus} blockerAttention={blockerAttention} /> : null}
{showIdentifierColumn ? <span className="font-mono text-xs text-muted-foreground">{identifier}</span> : null}
</span>
);
+2 -2
View File
@@ -52,7 +52,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} />} />)
<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} />} />)
: <p className="px-3 py-2 text-xs text-muted-foreground">No tasks yet.</p>}
</div>}
</div>;
@@ -139,7 +139,7 @@ export function CoordinatorSidebar({ children }: { children?: ReactNode }) {
{!rail && <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}>
{session.isSuccess && 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} 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 status={item.task.status} displayStatus={item.task.displayStatus} externalConversationState={item.task.externalConversationState} />} />
: 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} />)}
+1 -1
View File
@@ -202,7 +202,7 @@ export function InboxIssueMetaLeading({
<>
{showStatus ? (
<span className="hidden shrink-0 items-center sm:inline-flex">
{statusSlot ?? <StatusIcon status={issue.status} externalConversationState={issue.externalConversationState} blockerAttention={issue.blockerAttention} />}
{statusSlot ?? <StatusIcon status={issue.status} displayStatus={issue.displayStatus} externalConversationState={issue.externalConversationState} blockerAttention={issue.blockerAttention} />}
</span>
) : null}
{checklistStepNumber !== null ? (
+1 -1
View File
@@ -323,7 +323,7 @@ export function IssueMonitorComposerStrip({
>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="flex min-w-0 items-start gap-2">
<Clock className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true" />
<Clock className={cn("mt-0.5 h-4 w-4 shrink-0", issue.displayStatus === "monitoring" ? "text-(--status-task-icon-monitoring)" : "text-muted-foreground")} aria-hidden="true" />
<div className="flex min-w-0 flex-col gap-2">
<div className="text-sm font-medium text-foreground">{copy.stripTitle}</div>
{copy.pullRequestReview ? <IssuePullRequestLinks products={reviews} /> : null}
+2 -2
View File
@@ -17,7 +17,7 @@ export function IssueReferencePill({
}: {
issue:
| (Pick<IssueRelationIssueSummary, "id" | "identifier" | "title"> &
{ status?: string })
{ status?: string; displayStatus?: string })
| IssueLockedStub;
strikethrough?: boolean;
variant?: "mention" | "property";
@@ -42,7 +42,7 @@ export function IssueReferencePill({
);
const content = (
<>
{issue.status ? <StatusIcon status={issue.status} className="h-3 w-3 shrink-0" /> : null}
{issue.status ? <StatusIcon status={issue.status} displayStatus={issue.displayStatus} className="h-3 w-3 shrink-0" /> : null}
{children !== undefined ? children : <span className="min-w-0 truncate">{issue.identifier ?? issue.title}</span>}
</>
);
+4 -4
View File
@@ -239,8 +239,8 @@ export function IssueRow({
if (presentation === "task") {
const isUnread = unreadState === "visible" || unreadState === "fading";
const refreshed = appearance === "refreshed";
const statusLabel = getStatusIconLabel(issue.status, issue.externalConversationState, issue.blockerAttention);
const statusIcon = <StatusIcon status={issue.status} externalConversationState={issue.externalConversationState} blockerAttention={issue.blockerAttention} size="md" className={selectedStatusClass} />;
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 status = statusSlot ?? (refreshed ? (
<Tooltip>
<TooltipTrigger asChild>
@@ -487,7 +487,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} externalConversationState={issue.externalConversationState} blockerAttention={issue.blockerAttention} size="md" className={selectedStatusClass} />}
{mobileLeading ?? <StatusIcon status={issue.status} displayStatus={issue.displayStatus} externalConversationState={issue.externalConversationState} blockerAttention={issue.blockerAttention} size="md" className={selectedStatusClass} />}
{parkedBlockerIndicator}
</span>
<span className="flex min-w-0 flex-1 flex-col gap-1 sm:contents">
@@ -560,7 +560,7 @@ export function IssueRow({
{desktopMetaLeading ?? (
<>
<span className="hidden shrink-0 items-center gap-1 sm:inline-flex">
<StatusIcon status={issue.status} externalConversationState={issue.externalConversationState} blockerAttention={issue.blockerAttention} size="md" className={selectedStatusClass} />
<StatusIcon status={issue.status} displayStatus={issue.displayStatus} externalConversationState={issue.externalConversationState} blockerAttention={issue.blockerAttention} size="md" className={selectedStatusClass} />
</span>
{checklistStep}
<span className="shrink-0 font-mono text-xs text-muted-foreground">
+1 -1
View File
@@ -79,7 +79,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} externalConversationState={issue.externalConversationState} blockerAttention={issue.blockerAttention} />
<StatusIcon status={issue.status} displayStatus={issue.displayStatus} externalConversationState={issue.externalConversationState} blockerAttention={issue.blockerAttention} />
<span className="shrink-0">{identifier}</span>
</div>
<div className="truncate text-sm text-foreground">
+8 -8
View File
@@ -1399,8 +1399,8 @@ describe("IssuesList", () => {
});
mockIssuesApi.list.mockImplementation((_companyId, filters) => {
if (filters?.status === "backlog") return Promise.resolve([backlogIssue]);
if (filters?.status === "done") return Promise.resolve([doneIssue]);
if (filters?.displayStatus === "backlog") return Promise.resolve([backlogIssue]);
if (filters?.displayStatus === "done") return Promise.resolve([doneIssue]);
return Promise.resolve([]);
});
@@ -1418,13 +1418,13 @@ describe("IssuesList", () => {
await waitForAssertion(() => {
expect(mockIssuesApi.list).toHaveBeenCalledWith("company-1", expect.objectContaining({
status: "backlog",
displayStatus: "backlog",
limit: 200,
includeRoutineExecutions: true,
includeAgentCreatedTasks: false,
}), { signal: expect.any(AbortSignal) });
expect(mockIssuesApi.list).toHaveBeenCalledWith("company-1", expect.objectContaining({
status: "done",
displayStatus: "done",
limit: 200,
includeRoutineExecutions: true,
includeAgentCreatedTasks: false,
@@ -1461,7 +1461,7 @@ describe("IssuesList", () => {
);
mockIssuesApi.list.mockImplementation((_companyId, filters) => {
if (filters?.status === "backlog") return Promise.resolve(backlogIssues);
if (filters?.displayStatus === "backlog") return Promise.resolve(backlogIssues);
return Promise.resolve([]);
});
@@ -1565,7 +1565,7 @@ describe("IssuesList", () => {
);
mockIssuesApi.list.mockImplementation((_companyId, filters) => {
if (filters?.status === "backlog") return Promise.resolve(cappedBacklogIssues);
if (filters?.displayStatus === "backlog") return Promise.resolve(cappedBacklogIssues);
return Promise.resolve([]);
});
@@ -2142,8 +2142,8 @@ describe("IssuesList", () => {
await act(() => filterButton()!.click());
await act(() => container.querySelector<HTMLButtonElement>('[aria-label="Board view"]')!.click());
await waitForAssertion(() => {
const columns = mockIssuesApi.list.mock.calls.filter(([, filters]) => filters.status);
expect(columns).toHaveLength(7);
const columns = mockIssuesApi.list.mock.calls.filter(([, filters]) => filters.displayStatus);
expect(columns).toHaveLength(8);
for (const [, filters] of columns) expect(filters).toMatchObject({
q: "report", originKind: "routine_execution", includeRoutineExecutions: true, includeAgentCreatedTasks: true,
});
+14 -12
View File
@@ -102,7 +102,7 @@ import {
type TaskCollectionPreferenceLocation,
} from "../lib/task-collection-preferences";
import { taskDateGroup, taskDateGroupSeparator, type TaskDateGroup } from "../lib/task-date-groups";
import { deriveOriginatingActor, ISSUE_STATUSES, type Issue, type IssueStatus, type Project } from "@paperclipai/shared";
import { deriveOriginatingActor, ISSUE_STATUSES, ISSUE_DISPLAY_STATUSES, type Issue, type IssueDisplayStatus, type IssueStatus, type Project } from "@paperclipai/shared";
import { Badge } from "@/components/ui/badge";
const ISSUE_SEARCH_DEBOUNCE_MS = 250;
const ISSUE_SEARCH_RESULT_LIMIT = 200;
@@ -138,20 +138,22 @@ function findIssuesScrollContainer(element: HTMLElement | null): HTMLElement | n
}
return null;
}
const boardIssueStatuses = ISSUE_STATUSES;
const issueStatusLabels: Record<IssueStatus, string> = {
const boardIssueStatuses = [...ISSUE_STATUSES, "monitoring"] as const;
const issueStatusLabels: Record<IssueDisplayStatus, string> = {
backlog: "Backlog",
todo: "Todo",
in_progress: "In progress",
monitoring: "Monitoring",
in_review: "In review",
done: "Done",
blocked: "Blocked",
cancelled: "Cancelled",
};
const progressSegmentClasses: Record<IssueStatus, string> = {
const progressSegmentClasses: Record<IssueDisplayStatus, string> = {
backlog: "bg-muted-foreground/40",
todo: "bg-blue-500",
in_progress: "bg-yellow-500",
monitoring: "bg-(--status-task-monitoring)",
in_review: "bg-violet-500",
done: "bg-green-500",
blocked: "bg-red-500",
@@ -298,7 +300,7 @@ function sortIssues(issues: Issue[], state: IssueViewState): Issue[] {
sorted.sort((a, b) => {
switch (state.sortField) {
case "status":
return dir * (issueStatusOrder.indexOf(a.status) - issueStatusOrder.indexOf(b.status));
return dir * (issueStatusOrder.indexOf(a.displayStatus ?? a.status) - issueStatusOrder.indexOf(b.displayStatus ?? b.status));
case "priority":
return dir * (issuePriorityOrder.indexOf(a.priority) - issuePriorityOrder.indexOf(b.priority));
case "title":
@@ -612,7 +614,7 @@ function SubIssueProgressSummaryStrip({
const targetIssue = target?.issue ?? null;
const targetPathId = targetIssue?.identifier ?? targetIssue?.id ?? "";
const targetState = targetIssue ? withIssueDetailHeaderSeed(issueLinkState, targetIssue) : undefined;
const statusEntries = ISSUE_STATUSES
const statusEntries = ISSUE_DISPLAY_STATUSES
.map((status) => ({ status, count: summary.countsByStatus[status] ?? 0 }))
.filter((entry) => entry.count > 0);
@@ -966,7 +968,7 @@ function StreamlinedIssuesList({
...searchFilters,
...(normalizedIssueSearch.length > 0 ? { q: normalizedIssueSearch } : {}),
projectId,
status,
displayStatus: status,
limit: ISSUE_BOARD_COLUMN_RESULT_LIMIT,
...creationRequestFilters,
}, { signal }).then((rows) => rows as Issue[]),
@@ -1331,7 +1333,7 @@ function StreamlinedIssuesList({
return [{ key: "__all", label: null as string | null, items: filtered }];
}
if (viewState.groupBy === "status") {
const groups = groupBy(filtered, (i) => i.status);
const groups = groupBy(filtered, (i) => i.displayStatus ?? i.status);
return issueStatusOrder
.filter((s) => groups[s]?.length)
.map((s) => ({ key: s, label: issueFilterLabel(s), items: groups[s]! }));
@@ -1712,7 +1714,7 @@ function StreamlinedIssuesList({
const defaults: Record<string, unknown> = { ...(baseCreateIssueDefaults ?? {}) };
if (projectId && defaults.projectId === undefined) defaults.projectId = projectId;
if (groupKey) {
if (viewState.groupBy === "status") defaults.status = groupKey;
if (viewState.groupBy === "status") defaults.status = groupKey === "monitoring" ? "in_progress" : groupKey;
else if (viewState.groupBy === "priority") defaults.priority = groupKey;
else if (viewState.groupBy === "assignee" && groupKey !== "__unassigned") {
if (groupKey.startsWith("__user:")) defaults.assigneeUserId = groupKey.slice("__user:".length);
@@ -2339,7 +2341,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} externalConversationState={issue.externalConversationState} size="md" blockerAttention={issue.blockerAttention} onChange={(s) => onUpdateIssue(issue.id, { status: s })} />
<StatusIcon status={issue.status} displayStatus={issue.displayStatus} externalConversationState={issue.externalConversationState} size="md" blockerAttention={issue.blockerAttention} 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}
@@ -2363,7 +2365,7 @@ function StreamlinedIssuesList({
</button>
) : (
<span className="inline-flex items-center" onClick={(e) => { e.preventDefault(); e.stopPropagation(); }}>
<StatusIcon status={issue.status} externalConversationState={issue.externalConversationState} size="md" blockerAttention={issue.blockerAttention} onChange={(s) => onUpdateIssue(issue.id, { status: s })} />
<StatusIcon status={issue.status} displayStatus={issue.displayStatus} externalConversationState={issue.externalConversationState} size="md" blockerAttention={issue.blockerAttention} onChange={(s) => onUpdateIssue(issue.id, { status: s })} />
</span>
)
}
@@ -2390,7 +2392,7 @@ function StreamlinedIssuesList({
checklistStepNumber={checklistStepNumber}
statusSlot={(
<span className="inline-flex items-center" onClick={(e) => { e.preventDefault(); e.stopPropagation(); }}>
<StatusIcon status={issue.status} externalConversationState={issue.externalConversationState} size="md" blockerAttention={issue.blockerAttention} onChange={(s) => onUpdateIssue(issue.id, { status: s })} />
<StatusIcon status={issue.status} displayStatus={issue.displayStatus} externalConversationState={issue.externalConversationState} size="md" blockerAttention={issue.blockerAttention} onChange={(s) => onUpdateIssue(issue.id, { status: s })} />
</span>
)}
/>
+5
View File
@@ -233,3 +233,8 @@ describe("KanbanBoard", () => {
expect(resolveKanbanTargetStatus("missing", issues)).toBeNull();
});
});
it("keeps Monitoring derived rather than allowing a drag to manufacture it", () => {
expect(resolveKanbanTargetStatus("monitoring", [])).toBeNull();
});
+15 -12
View File
@@ -22,7 +22,7 @@ import { StatusIcon } from "./StatusIcon";
import { PriorityIcon } from "./PriorityIcon";
import { SHOW_TASK_PRIORITY_UI } from "../lib/ui-flags";
import { Identity } from "./Identity";
import type { Issue, IssueStatus } from "@paperclipai/shared";
import type { Issue, IssueDisplayStatus, IssueStatus } from "@paperclipai/shared";
import { AlertTriangle } from "lucide-react";
import { isSuccessfulRunHandoffRequired } from "../lib/successful-run-handoff";
import { collectSubtreeLiveCounts } from "../lib/liveIssueIds";
@@ -43,11 +43,12 @@ export const boardStatuses = [
"backlog",
"todo",
"in_progress",
"monitoring",
"in_review",
"blocked",
"done",
"cancelled",
] as const satisfies readonly IssueStatus[];
] as const satisfies readonly IssueDisplayStatus[];
const defaultKanbanColumnTone = {
rail: "border-border bg-muted/20",
@@ -62,7 +63,7 @@ const defaultKanbanColumnTone = {
// Every column carries a status-hued tint (matching the app-wide status
// vocabulary: gray backlog, amber todo, blue in-progress, violet review,
// red blocked, green done) so no column reads as accidentally unstyled.
export const kanbanColumnTones: Partial<Record<IssueStatus, typeof defaultKanbanColumnTone>> = {
export const kanbanColumnTones: Partial<Record<IssueDisplayStatus, typeof defaultKanbanColumnTone>> = {
backlog: {
rail: "border-border bg-muted/30",
railOver: "bg-muted/50 ring-1 ring-neutral-400/25",
@@ -128,8 +129,8 @@ export const kanbanColumnTones: Partial<Record<IssueStatus, typeof defaultKanban
},
};
export function getKanbanColumnTone(status: IssueStatus) {
return kanbanColumnTones[status] ?? defaultKanbanColumnTone;
export function getKanbanColumnTone(status: IssueDisplayStatus) {
return kanbanColumnTones[status === "monitoring" ? "in_progress" : status] ?? defaultKanbanColumnTone;
}
function statusLabel(status: string): string {
@@ -138,9 +139,10 @@ function statusLabel(status: string): string {
export function resolveKanbanTargetStatus(overId: string, issues: Issue[]): IssueStatus | null {
if ((boardStatuses as readonly string[]).includes(overId)) {
return overId as IssueStatus;
return overId === "monitoring" ? null : overId as IssueStatus;
}
return issues.find((issue) => issue.id === overId)?.status ?? null;
const target = issues.find((issue) => issue.id === overId);
return target?.displayStatus === "monitoring" ? null : target?.status ?? null;
}
interface Agent {
@@ -175,7 +177,7 @@ function KanbanColumn({
revealIncrement,
onShowMore,
}: {
status: IssueStatus;
status: IssueDisplayStatus;
issues: Issue[];
agents?: Agent[];
liveIssueIds?: Set<string>;
@@ -186,7 +188,7 @@ function KanbanColumn({
revealIncrement: number;
onShowMore: () => void;
}) {
const { setNodeRef, isOver } = useDroppable({ id: status });
const { setNodeRef, isOver } = useDroppable({ id: status, disabled: status === "monitoring" });
const isEmpty = issues.length === 0;
const visibleIssues = collapsed ? [] : issues.slice(0, visibleCount);
@@ -412,13 +414,14 @@ export function KanbanBoard({
);
const columnIssues = useMemo(() => {
const grouped: Record<IssueStatus, Issue[]> = {} as Record<IssueStatus, Issue[]>;
const grouped: Record<IssueDisplayStatus, Issue[]> = {} as Record<IssueDisplayStatus, Issue[]>;
for (const status of boardStatuses) {
grouped[status] = [];
}
for (const issue of issues) {
if (grouped[issue.status]) {
grouped[issue.status].push(issue);
const status = issue.displayStatus ?? issue.status;
if (grouped[status]) {
grouped[status].push(issue);
}
}
return grouped;
+13 -11
View File
@@ -91,7 +91,7 @@ import { buildSubIssueDefaultsForViewer } from "../lib/subIssueDefaults";
import { statusBadge } from "../lib/status-colors";
import { workflowSort } from "../lib/workflow-sort";
import { isSuccessfulRunHandoffRequired } from "../lib/successful-run-handoff";
import { deriveOriginatingActor, ISSUE_STATUSES, type Issue, type IssueStatus, type Project } from "@paperclipai/shared";
import { deriveOriginatingActor, ISSUE_STATUSES, ISSUE_DISPLAY_STATUSES, type Issue, type IssueDisplayStatus, type IssueStatus, type Project } from "@paperclipai/shared";
import { Badge } from "@/components/ui/badge";
const ISSUE_SEARCH_DEBOUNCE_MS = 250;
const ISSUE_SEARCH_RESULT_LIMIT = 200;
@@ -127,20 +127,22 @@ function findIssuesScrollContainer(element: HTMLElement | null): HTMLElement | n
}
return null;
}
const boardIssueStatuses = ISSUE_STATUSES;
const issueStatusLabels: Record<IssueStatus, string> = {
const boardIssueStatuses = [...ISSUE_STATUSES, "monitoring"] as const;
const issueStatusLabels: Record<IssueDisplayStatus, string> = {
backlog: "Backlog",
todo: "Todo",
in_progress: "In progress",
monitoring: "Monitoring",
in_review: "In review",
done: "Done",
blocked: "Blocked",
cancelled: "Cancelled",
};
const progressSegmentClasses: Record<IssueStatus, string> = {
const progressSegmentClasses: Record<IssueDisplayStatus, string> = {
backlog: "bg-muted-foreground/40",
todo: "bg-blue-500",
in_progress: "bg-yellow-500",
monitoring: "bg-(--status-task-monitoring)",
in_review: "bg-violet-500",
done: "bg-green-500",
blocked: "bg-red-500",
@@ -281,7 +283,7 @@ function sortIssues(issues: Issue[], state: IssueViewState): Issue[] {
sorted.sort((a, b) => {
switch (state.sortField) {
case "status":
return dir * (issueStatusOrder.indexOf(a.status) - issueStatusOrder.indexOf(b.status));
return dir * (issueStatusOrder.indexOf(a.displayStatus ?? a.status) - issueStatusOrder.indexOf(b.displayStatus ?? b.status));
case "priority":
return dir * (issuePriorityOrder.indexOf(a.priority) - issuePriorityOrder.indexOf(b.priority));
case "title":
@@ -567,7 +569,7 @@ function SubIssueProgressSummaryStrip({
const targetIssue = target?.issue ?? null;
const targetPathId = targetIssue?.identifier ?? targetIssue?.id ?? "";
const targetState = targetIssue ? withIssueDetailHeaderSeed(issueLinkState, targetIssue) : undefined;
const statusEntries = ISSUE_STATUSES
const statusEntries = ISSUE_DISPLAY_STATUSES
.map((status) => ({ status, count: summary.countsByStatus[status] ?? 0 }))
.filter((entry) => entry.count > 0);
@@ -871,7 +873,7 @@ export function IssuesList({
...searchFilters,
...(normalizedIssueSearch.length > 0 ? { q: normalizedIssueSearch } : {}),
projectId,
status,
displayStatus: status,
limit: ISSUE_BOARD_COLUMN_RESULT_LIMIT,
...creationRequestFilters,
}, { signal }).then((rows) => rows as Issue[]),
@@ -1226,7 +1228,7 @@ export function IssuesList({
return [{ key: "__all", label: null as string | null, items: filtered }];
}
if (viewState.groupBy === "status") {
const groups = groupBy(filtered, (i) => i.status);
const groups = groupBy(filtered, (i) => i.displayStatus ?? i.status);
return issueStatusOrder
.filter((s) => groups[s]?.length)
.map((s) => ({ key: s, label: issueFilterLabel(s), items: groups[s]! }));
@@ -1600,7 +1602,7 @@ export function IssuesList({
const defaults: Record<string, unknown> = { ...(baseCreateIssueDefaults ?? {}) };
if (projectId && defaults.projectId === undefined) defaults.projectId = projectId;
if (groupKey) {
if (viewState.groupBy === "status") defaults.status = groupKey;
if (viewState.groupBy === "status") defaults.status = groupKey === "monitoring" ? "in_progress" : groupKey;
else if (viewState.groupBy === "priority") defaults.priority = groupKey;
else if (viewState.groupBy === "assignee" && groupKey !== "__unassigned") {
if (groupKey.startsWith("__user:")) defaults.assigneeUserId = groupKey.slice("__user:".length);
@@ -2183,7 +2185,7 @@ export function IssuesList({
</button>
) : (
<span className="inline-flex items-center" onClick={(e) => { e.preventDefault(); e.stopPropagation(); }}>
<StatusIcon status={issue.status} externalConversationState={issue.externalConversationState} size="md" blockerAttention={issue.blockerAttention} onChange={(s) => onUpdateIssue(issue.id, { status: s })} />
<StatusIcon status={issue.status} displayStatus={issue.displayStatus} externalConversationState={issue.externalConversationState} size="md" blockerAttention={issue.blockerAttention} onChange={(s) => onUpdateIssue(issue.id, { status: s })} />
</span>
)
}
@@ -2210,7 +2212,7 @@ export function IssuesList({
checklistStepNumber={checklistStepNumber}
statusSlot={(
<span className="inline-flex items-center" onClick={(e) => { e.preventDefault(); e.stopPropagation(); }}>
<StatusIcon status={issue.status} externalConversationState={issue.externalConversationState} size="md" blockerAttention={issue.blockerAttention} onChange={(s) => onUpdateIssue(issue.id, { status: s })} />
<StatusIcon status={issue.status} displayStatus={issue.displayStatus} externalConversationState={issue.externalConversationState} size="md" blockerAttention={issue.blockerAttention} onChange={(s) => onUpdateIssue(issue.id, { status: s })} />
</span>
)}
/>
+2
View File
@@ -28,6 +28,7 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { StatusIcon } from "./StatusIcon";
import { SidebarSection } from "./SidebarSection";
import { SidebarNavItem } from "./SidebarNavItem";
@@ -251,6 +252,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}
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}
+2 -2
View File
@@ -84,8 +84,8 @@ export function AgentStatusCapsule({ status }: { status: string }) {
* Distinct from the generic {@link StatusBadge} so run/goal/approval badges are
* unaffected.
*/
export function IssueStatusBadge({ status: taskStatus, externalConversationState }: { status: string; externalConversationState?: "active" | "waiting" | null }) {
const status = taskStatus === "in_review" && externalConversationState === "waiting" ? "idle" : taskStatus;
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;
return (
<span
+1
View File
@@ -17,6 +17,7 @@ const STATUS_ICON_CLASS: Record<string, string> = {
backlog: "lucide-circle-dashed",
todo: "lucide-circle",
in_progress: "lucide-task-progress-spinner",
monitoring: "lucide-clock",
in_review: "lucide-circle-dot",
done: "lucide-circle-check",
blocked: "lucide-circle-minus",
+4 -1
View File
@@ -1,6 +1,7 @@
import type { CSSProperties } from "react";
import {
Ban,
Clock,
Circle,
CircleCheck,
CircleDashed,
@@ -20,7 +21,7 @@ import { taskStatusIconVar, taskStatusIconVarDefault } from "../lib/status-color
*
* backlog → circle-dashed · todo → circle · in_progress → animated open circle ·
* in_review → circle-dot · done → circle-check · blocked → circle-minus ·
* cancelled → ban · in_queue → circle-minus (blocked recoloured blue).
* cancelled → ban · monitoring → clock · in_queue → circle-minus (blocked recoloured blue).
*
* The in-progress animation represents task workflow status, independently of
* run execution. It remains between runs until the task status changes; live
@@ -41,6 +42,7 @@ const SIZE_PX: Record<StatusGlyphSize, number> = { sm: 14, md: 16, lg: 20 };
export type StatusGlyphStatus =
| "backlog"
| "todo"
| "monitoring"
| "in_progress"
| "in_review"
| "done"
@@ -60,6 +62,7 @@ const STATUS_ICON: Record<string, LucideIcon> = {
backlog: CircleDashed,
todo: Circle,
in_progress: TaskProgressSpinner,
monitoring: Clock,
in_review: CircleDot,
done: CircleCheck,
blocked: CircleMinus,
+17
View File
@@ -138,3 +138,20 @@ describe("Slack idle status presentation", () => {
expect(renderToStaticMarkup(<IssueStatusBadge status="done" externalConversationState="waiting" />)).toContain(">Done<");
});
});
describe("Monitoring presentation", () => {
it("shows a static blue clock while retaining the canonical status picker", () => {
const html = renderToStaticMarkup(<StatusIcon status="in_progress" displayStatus="monitoring" showLabel onChange={() => {}} />);
expect(html).toContain("lucide-clock");
expect(html).toContain("var(--status-task-icon-monitoring)");
expect(html).toContain("Monitoring");
expect(html).not.toContain("animate-spin");
});
it("keeps terminal and review states ahead of stale Monitoring snapshots", () => {
for (const status of ["done", "cancelled", "blocked", "in_review"]) {
const html = renderToStaticMarkup(<StatusIcon status={status} displayStatus="monitoring" showLabel />);
expect(html).not.toContain("lucide-clock");
}
});
});
+7 -5
View File
@@ -13,6 +13,8 @@ function statusLabel(status: string): string {
interface StatusIconProps {
status: string;
/** Server-derived presentation; mutations still use the canonical status. */
displayStatus?: string | null;
externalConversationState?: "active" | "waiting" | null;
blockerAttention?: IssueBlockerAttention | null;
onChange?: (status: string) => void;
@@ -67,8 +69,8 @@ function blockedAttentionLabel(blockerAttention: IssueBlockerAttention | null |
}
/** Shared accessible/tooltip copy, including waiting and covered-blocker states. */
export function getStatusIconLabel(status: string, externalConversationState?: "active" | "waiting" | null, blockerAttention?: IssueBlockerAttention | null): string {
const displayStatus = status === "in_review" && externalConversationState === "waiting" ? "idle" : status;
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);
}
@@ -84,11 +86,11 @@ export function getStatusIconLabel(status: string, externalConversationState?: "
* glyph — the blocked shape recoloured blue — while the full blocked reason
* still rides on the accessible label.
*/
export function StatusIcon({ status, externalConversationState, blockerAttention, onChange, className, glyphContainerClassName, showLabel, size = "md" }: StatusIconProps) {
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;
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);
const ariaLabel = getStatusIconLabel(status, externalConversationState, blockerAttention, presentationStatus);
const glyphStatus = isCoveredBlocked ? "in_queue" : displayStatus;
const glyphIcon = (
+1 -1
View File
@@ -86,7 +86,7 @@ export function AgentTaskCard({ task }: { task: Issue }) {
>
<div className="flex min-w-0 items-start gap-2">
<StatusIcon
status={task.status}
status={task.status} displayStatus={task.displayStatus}
blockerAttention={task.blockerAttention}
className="mt-0.5 shrink-0"
/>
@@ -2187,7 +2187,7 @@ export function IssueProperties({
setParentOpen(false);
}}
>
<StatusIcon status={candidate.status} className="h-3 w-3" />
<StatusIcon status={candidate.status} displayStatus={candidate.displayStatus} className="h-3 w-3" />
<span className="truncate">
{candidate.identifier ? `${candidate.identifier} ` : ""}
{candidate.title}
@@ -2274,7 +2274,7 @@ export function IssueProperties({
)}
onClick={() => toggleBlockedBy(candidate.id)}
>
<StatusIcon status={candidate.status} className="h-3 w-3" />
<StatusIcon status={candidate.status} displayStatus={candidate.displayStatus} className="h-3 w-3" />
<span className="truncate">
{candidate.identifier ? `${candidate.identifier} ` : ""}
{candidate.title}
@@ -2312,7 +2312,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} className="h-3 w-3" />
<StatusIcon status={child.status} displayStatus={child.displayStatus} className="h-3 w-3" />
<span className="min-w-0 truncate">
{child.identifier ? `${child.identifier} ` : ""}
{child.title}
@@ -2349,7 +2349,7 @@ export function IssueProperties({
>
<PropertyRow label="Status">
<StatusIcon
status={issue.status}
status={issue.status} displayStatus={issue.displayStatus}
externalConversationState={issue.externalConversationState}
glyphContainerClassName="inline-flex size-6 shrink-0 items-center justify-center"
blockerAttention={issue.blockerAttention}
@@ -53,7 +53,7 @@ export function RemovableIssueReferencePill({
<LockedIssueChip identifier={issue.identifier} />
) : (
<>
<StatusIcon status={issue.status} className="h-3 w-3 shrink-0" />
<StatusIcon status={issue.status} displayStatus={issue.displayStatus} className="h-3 w-3 shrink-0" />
<span className="truncate">{issueLabel}</span>
</>
);
+5 -5
View File
@@ -3,10 +3,10 @@ import { User, UserX } from "lucide-react";
import {
COMPANY_SEARCH_UPDATED_WITHIN_OPTIONS,
ISSUE_PRIORITIES,
ISSUE_STATUSES,
ISSUE_DISPLAY_STATUSES,
type CompanySearchFilterOptionCounts,
type CompanySearchSort,
type IssueStatus,
type IssueDisplayStatus,
} from "@paperclipai/shared";
import { StatusIcon } from "@/components/StatusIcon";
import { PriorityIcon } from "@/components/PriorityIcon";
@@ -43,7 +43,7 @@ export interface SearchFilterDataProps {
}
// Non-terminal statuses — the single-click "Open items" preset from wireframe screen 2.
const OPEN_STATUS_PRESET: IssueStatus[] = ISSUE_STATUSES.filter(
const OPEN_STATUS_PRESET: IssueDisplayStatus[] = ISSUE_DISPLAY_STATUSES.filter(
(status) => status !== "done" && status !== "cancelled",
);
@@ -72,11 +72,11 @@ export function buildSearchFilterOptions({
labels,
currentUserId,
}: SearchFilterDataProps): SearchFilterOptionGroups {
const status: FilterMenuOption[] = ISSUE_STATUSES.map((value) => ({
const status: FilterMenuOption[] = ISSUE_DISPLAY_STATUSES.map((value) => ({
value,
label: humanize(value),
icon: <StatusIcon status={value} />,
count: count(counts?.status as Record<string, number> | undefined, value),
count: count((counts?.displayStatus ?? counts?.status) as Record<string, number> | undefined, value),
}));
const priority: FilterMenuOption[] = ISSUE_PRIORITIES.map((value) => ({
+1 -1
View File
@@ -178,7 +178,7 @@ function SearchResultRowImpl({
data-result-type="issue"
>
<div className="mt-1 shrink-0">
<StatusIcon status={issue.status} externalConversationState={issue.externalConversationState} />
<StatusIcon status={issue.status} displayStatus={issue.displayStatus} externalConversationState={issue.externalConversationState} />
</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">
@@ -15,6 +15,7 @@ export interface TaskDetailRelationItem {
identifier?: string | null;
title: string;
status?: string | null;
displayStatus?: string | null;
}
export function RelationNavigationList({
@@ -49,7 +50,7 @@ export function RelationNavigationList({
title={`${item.identifier ? `${item.identifier} — ` : ""}${item.title}`}
>
{item.status ? (
<StatusIcon status={item.status} className="h-3.5 w-3.5 shrink-0" />
<StatusIcon status={item.status} displayStatus={item.displayStatus} className="h-3.5 w-3.5 shrink-0" />
) : null}
<span className="min-w-0 flex-1 truncate">{item.title}</span>
{item.identifier ? (
+2
View File
@@ -349,6 +349,8 @@
--status-task-backlog: #a8aeb2;
--status-task-todo: #f59e0b;
--status-task-in_progress: #2563eb;
--status-task-monitoring: var(--status-task-in_progress);
--status-task-icon-monitoring: var(--status-task-icon-in_progress);
--status-task-in_review: #7c3aed;
--status-task-done: #22c55e;
--status-task-blocked: #dc2626;
+5 -4
View File
@@ -1,4 +1,4 @@
import type { Issue, IssueStatus } from "@paperclipai/shared";
import type { Issue, IssueDisplayStatus, IssueStatus } from "@paperclipai/shared";
import { workflowSort } from "./workflow-sort";
export type SubIssueProgressTargetKind = "next" | "blocked";
@@ -13,7 +13,7 @@ export type SubIssueProgressSummary = {
doneCount: number;
inProgressCount: number;
blockedCount: number;
countsByStatus: Partial<Record<IssueStatus, number>>;
countsByStatus: Partial<Record<IssueDisplayStatus, number>>;
target: SubIssueProgressTarget | null;
};
@@ -35,10 +35,11 @@ export function shouldRenderSubIssueProgressSummary(enabled: boolean | undefined
}
export function buildSubIssueProgressSummary(issues: Issue[]): SubIssueProgressSummary {
const countsByStatus: Partial<Record<IssueStatus, number>> = {};
const countsByStatus: Partial<Record<IssueDisplayStatus, number>> = {};
const progressIssues = issues.filter((issue) => issue.status !== "cancelled");
for (const issue of progressIssues) {
countsByStatus[issue.status] = (countsByStatus[issue.status] ?? 0) + 1;
const status = issue.displayStatus ?? issue.status;
countsByStatus[status] = (countsByStatus[status] ?? 0) + 1;
}
const orderedIssues = workflowSort(progressIssues);
+8
View File
@@ -69,6 +69,14 @@ function makeExternalObjectSummary(overrides: Partial<ExternalObjectSummary> = {
};
}
it("separates monitored waits from In Progress in task filters", () => {
const monitored = makeIssue({ id: "waiting", status: "in_progress", displayStatus: "monitoring" });
const working = makeIssue({ id: "working", status: "in_progress" });
const rows = [monitored, working];
expect(applyIssueFilters(rows, { ...defaultIssueFilterState, statuses: ["monitoring"] }).map(row => row.id)).toEqual(["waiting"]);
expect(applyIssueFilters(rows, { ...defaultIssueFilterState, statuses: ["in_progress"] }).map(row => row.id)).toEqual(["working"]);
});
describe("issue filters", () => {
it("hides automation by default while an opt-in includes every creation source", () => {
const issues = [makeIssue({ id: "human", createdByUserId: "person" }), makeIssue({ id: "agent", createdByAgentId: "agent" }), makeIssue({ id: "routine", originKind: "routine_execution", createdByUserId: "person" }), makeIssue({ id: "system" })];
+3 -3
View File
@@ -80,12 +80,12 @@ export function externalObjectFilterLabel(value: string): string {
return EXTERNAL_OBJECT_FILTER_LABELS[value] ?? issueFilterLabel(value);
}
export const issueStatusOrder = ["in_progress", "todo", "backlog", "in_review", "blocked", "done", "cancelled"];
export const issueStatusOrder = ["in_progress", "monitoring", "todo", "backlog", "in_review", "blocked", "done", "cancelled"];
export const issuePriorityOrder = ["critical", "high", "medium", "low"];
export const issueQuickFilterPresets = [
{ label: "All", statuses: [] as string[] },
{ label: "Active", statuses: ["todo", "in_progress", "in_review", "blocked"] },
{ label: "Active", statuses: ["todo", "in_progress", "monitoring", "in_review", "blocked"] },
{ label: "Backlog", statuses: ["backlog"] },
{ label: "Done", statuses: ["done", "cancelled"] },
];
@@ -244,7 +244,7 @@ export function applyIssueFilters(
if (enableAgentCreatedVisibilityFilter && !state.showAgentCreatedTasks && !state.routineTasksOnly) {
result = result.filter((issue) => !isAgentCreatedIssue(issue));
}
if (state.statuses.length > 0) result = result.filter((issue) => state.statuses.includes(issue.status));
if (state.statuses.length > 0) result = result.filter((issue) => state.statuses.includes(issue.displayStatus ?? issue.status));
if (state.priorities.length > 0) result = result.filter((issue) => state.priorities.includes(issue.priority));
if (state.assignees.length > 0) {
result = result.filter((issue) => {
+4
View File
@@ -4,6 +4,7 @@ type IssuePropertiesPanelKeyIssue = Pick<
Issue,
| "id"
| "status"
| "displayStatus"
| "priority"
| "assigneeAgentId"
| "assigneeUserId"
@@ -36,6 +37,7 @@ export function buildIssuePropertiesPanelKey(
return JSON.stringify({
id: issue.id,
status: issue.status,
displayStatus: issue.displayStatus,
priority: issue.priority,
assigneeAgentId: issue.assigneeAgentId,
assigneeUserId: issue.assigneeUserId,
@@ -77,12 +79,14 @@ export function buildIssuePropertiesPanelKey(
identifier: relation.identifier ?? null,
title: relation.title,
status: relation.status,
displayStatus: relation.displayStatus,
})),
blockedBy: (issue.blockedBy ?? []).map((relation) => ({
id: relation.id,
identifier: relation.identifier ?? null,
title: relation.title,
status: relation.status,
displayStatus: relation.displayStatus,
})),
watchdog: issue.watchdog
? {
+3
View File
@@ -13,6 +13,7 @@ export type IssueDetailHeaderSeed = {
identifier: string | null;
title: string;
status: string;
displayStatus?: Issue["displayStatus"];
blockerAttention?: Issue["blockerAttention"];
priority: string;
projectId: string | null;
@@ -24,6 +25,7 @@ export type IssueDetailHeaderSeed = {
type IssueDetailHeaderSeedSource = Pick<Issue, "id" | "title"> & {
identifier?: string | null;
status: string;
displayStatus?: Issue["displayStatus"];
blockerAttention?: Issue["blockerAttention"];
priority: string;
projectId?: string | null;
@@ -83,6 +85,7 @@ function createIssueDetailHeaderSeed(issue: IssueDetailHeaderSeedSource): IssueD
identifier: issue.identifier ?? null,
title: issue.title,
status: issue.status,
displayStatus: issue.displayStatus,
blockerAttention: issue.blockerAttention,
priority: issue.priority,
projectId: issue.projectId ?? null,
+8
View File
@@ -83,6 +83,14 @@ describe("recent task persistence", () => {
expect(readRecentTasks(storageKey, "company-1")[0]).toMatchObject({ status: "in_review", externalConversationState: "waiting" });
});
it("refreshes Monitoring when a run starts without a new task version", () => {
const task = { ...issue("1"), status: "in_progress" as const, displayStatus: "monitoring" as const, externalConversationState: null };
recordRecentTask(task, "user-1");
const storageKey = getRecentTasksStorageKey("company-1", "user-1");
updateRecentTaskSnapshots(storageKey, "company-1", [{ ...task, displayStatus: "in_progress" }]);
expect(readRecentTasks(storageKey, "company-1")[0]?.displayStatus).toBe("in_progress");
});
it("publishes same-tab updates", () => {
const listener = vi.fn();
window.addEventListener(RECENT_TASKS_UPDATED_EVENT, listener);
+7 -4
View File
@@ -11,6 +11,7 @@ export interface RecentTaskEntry {
title: string;
identifier: string | null;
status: IssueStatus;
displayStatus?: Issue["displayStatus"];
externalConversationState?: Issue["externalConversationState"];
recordedAt: number;
// Server version of the title/status snapshot, independent of comment activity.
@@ -114,9 +115,9 @@ export function mergeRecentTaskSnapshot(entry: RecentTaskEntry, issue: TaskSnaps
// Conversation readiness also depends on deliveries and decisions, which
// can change without changing the task's title/status version.
if (snapshotUpdatedAt === entry.snapshotUpdatedAt) {
return issue.externalConversationState !== undefined
&& issue.externalConversationState !== entry.externalConversationState
? { ...entry, status: issue.status, externalConversationState: issue.externalConversationState }
return (issue.externalConversationState !== undefined
&& issue.externalConversationState !== entry.externalConversationState) || issue.displayStatus !== entry.displayStatus
? { ...entry, status: issue.status, displayStatus: issue.displayStatus, externalConversationState: issue.externalConversationState }
: entry;
}
return {
@@ -124,6 +125,7 @@ export function mergeRecentTaskSnapshot(entry: RecentTaskEntry, issue: TaskSnaps
title: issue.title,
identifier: issue.identifier,
status: issue.status,
displayStatus: issue.displayStatus,
externalConversationState: issue.externalConversationState,
snapshotUpdatedAt,
recordedAt: Math.max(entry.recordedAt, snapshotUpdatedAt),
@@ -131,7 +133,7 @@ export function mergeRecentTaskSnapshot(entry: RecentTaskEntry, issue: TaskSnaps
}
export function recordRecentTask(
issue: Pick<Issue, "id" | "companyId" | "title" | "identifier" | "status" | "externalConversationState" | "updatedAt" | "conversationAgentId">,
issue: Pick<Issue, "id" | "companyId" | "title" | "identifier" | "status" | "displayStatus" | "externalConversationState" | "updatedAt" | "conversationAgentId">,
userId: string | null | undefined,
recordedAt = new Date(issue.updatedAt).getTime(),
) {
@@ -149,6 +151,7 @@ export function recordRecentTask(
title: issue.title,
identifier: issue.identifier,
status: issue.status,
displayStatus: issue.displayStatus,
externalConversationState: issue.externalConversationState,
recordedAt: activityAt,
...(Number.isFinite(snapshotUpdatedAt) ? { snapshotUpdatedAt } : {}),
+7 -7
View File
@@ -1,11 +1,11 @@
import {
COMPANY_SEARCH_UPDATED_WITHIN_OPTIONS,
ISSUE_PRIORITIES,
ISSUE_STATUSES,
ISSUE_DISPLAY_STATUSES,
isUuidLike,
normalizeAgentUrlKey,
type IssuePriority,
type IssueStatus,
type IssueDisplayStatus,
} from "@paperclipai/shared";
import type { CompanySearchParams } from "@/api/search";
@@ -20,8 +20,8 @@ const SEARCH_FILTER_PARAM_KEYS = [
"updatedAfter",
] as const;
const OPEN_STATUSES: IssueStatus[] = ["backlog", "todo", "in_progress", "in_review", "blocked"];
const CLOSED_STATUSES: IssueStatus[] = ["done", "cancelled"];
const OPEN_STATUSES: IssueDisplayStatus[] = ["backlog", "todo", "in_progress", "in_review", "blocked"];
const CLOSED_STATUSES: IssueDisplayStatus[] = ["done", "cancelled"];
export type SearchOperatorKey = "status" | "assignee" | "project" | "label" | "priority" | "updated" | "is";
@@ -166,8 +166,8 @@ function appendText(parts: string[], raw: string) {
if (raw.trim().length > 0) parts.push(raw);
}
function parseStatus(value: string): IssueStatus | null {
return (ISSUE_STATUSES as readonly string[]).includes(value) ? value as IssueStatus : null;
function parseStatus(value: string): IssueDisplayStatus | null {
return (ISSUE_DISPLAY_STATUSES as readonly string[]).includes(value) ? value as IssueDisplayStatus : null;
}
function parsePriority(value: string): IssuePriority | null {
@@ -341,7 +341,7 @@ function validValues<T extends string>(values: string[], allowed: readonly T[]):
export function readSearchFiltersFromParams(search: URLSearchParams): ParsedSearchQuery["filters"] {
const filters: ParsedSearchQuery["filters"] = {};
const statuses = validValues(search.getAll("status").flatMap((value) => value.split(",")), ISSUE_STATUSES);
const statuses = validValues(search.getAll("status").flatMap((value) => value.split(",")), ISSUE_DISPLAY_STATUSES);
const priorities = validValues(search.getAll("priority").flatMap((value) => value.split(",")), ISSUE_PRIORITIES);
const assigneeAgentId = search.get("assigneeAgentId");
const assigneeUserId = search.get("assigneeUserId");
+2
View File
@@ -256,6 +256,7 @@ export const taskStatusVar: Record<string, string> = {
backlog: "--status-task-backlog",
todo: "--status-task-todo",
in_progress: "--status-task-in_progress",
monitoring: "--status-task-monitoring",
in_review: "--status-task-in_review",
done: "--status-task-done",
blocked: "--status-task-blocked",
@@ -275,6 +276,7 @@ export const taskStatusIconVar: Record<string, string> = {
backlog: "--status-task-icon-backlog",
todo: "--status-task-icon-todo",
in_progress: "--status-task-icon-in_progress",
monitoring: "--status-task-icon-monitoring",
in_review: "--status-task-icon-in_review",
done: "--status-task-icon-done",
blocked: "--status-task-icon-blocked",
+1 -1
View File
@@ -33,7 +33,7 @@ describe("task views registry (PAP-670)", () => {
expect(Array.isArray(view.statuses)).toBe(true);
}
expect(taskView("all").statuses).toEqual([]);
expect(taskView("active").statuses).toEqual(["todo", "in_progress", "in_review", "blocked"]);
expect(taskView("active").statuses).toEqual(["todo", "in_progress", "monitoring", "in_review", "blocked"]);
expect(taskView("backlog").statuses).toEqual(["backlog"]);
expect(taskView("done").statuses).toEqual(["done", "cancelled"]);
});
+1 -1
View File
@@ -62,7 +62,7 @@ export const TASK_VIEW_GROUPS: TaskViewGroup[] = [
label: "Organization",
views: [
{ key: "all", label: "All tasks", surface: "issues", statuses: [], hint: "Every task in the organization" },
{ key: "active", label: "Active", surface: "issues", statuses: ["todo", "in_progress", "in_review", "blocked"], hint: "Open tasks, running or not" },
{ key: "active", label: "Active", surface: "issues", statuses: ["todo", "in_progress", "monitoring", "in_review", "blocked"], hint: "Open tasks, running or not" },
{ key: "backlog", label: "Backlog", surface: "issues", statuses: ["backlog"], hint: "Not started yet" },
{ key: "done", label: "Done", surface: "issues", statuses: ["done", "cancelled"], hint: "Completed and cancelled" },
],
+1 -1
View File
@@ -531,7 +531,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} externalConversationState={issue.externalConversationState} blockerAttention={issue.blockerAttention} />
<StatusIcon status={issue.status} displayStatus={issue.displayStatus} externalConversationState={issue.externalConversationState} blockerAttention={issue.blockerAttention} />
</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">
+4
View File
@@ -822,6 +822,10 @@ export function DesignGuide() {
</div>
</SubSection>
<SubSection title="Monitoring task">
<StatusIcon status="in_progress" displayStatus="monitoring" showLabel />
<IssueStatusBadge status="in_progress" displayStatus="monitoring" />
</SubSection>
<SubSection title="Idle Slack conversation">
<StatusIcon status="in_review" externalConversationState="waiting" showLabel />
<IssueStatusBadge status="in_review" externalConversationState="waiting" />
+2 -2
View File
@@ -2922,7 +2922,7 @@ function StreamlinedInbox({
&& blockerAttention?.state === "covered"
);
const rowStatusIcon = (
<StatusIcon status={issue.status} externalConversationState={issue.externalConversationState} blockerAttention={blockerAttention} size="md" />
<StatusIcon status={issue.status} displayStatus={issue.displayStatus} externalConversationState={issue.externalConversationState} blockerAttention={blockerAttention} size="md" />
);
return (
<IssueRow
@@ -3022,7 +3022,7 @@ function StreamlinedInbox({
<ChevronRight className={cn("h-3.5 w-3.5 transition-transform", isExpanded && "rotate-90")} />
</button>
) : (
<StatusIcon status={issue.status} externalConversationState={issue.externalConversationState} blockerAttention={blockerAttention} size="md" />
<StatusIcon status={issue.status} displayStatus={issue.displayStatus} externalConversationState={issue.externalConversationState} blockerAttention={blockerAttention} size="md" />
)
) : undefined}
unreadState={isUnread ? "visible" : isFading ? "fading" : "hidden"}
+4 -3
View File
@@ -1004,7 +1004,7 @@ export function IssueDetailLoadingState({
{headerSeed ? (
<>
<StatusIcon
status={headerSeed.status}
status={headerSeed.status} displayStatus={headerSeed.displayStatus}
blockerAttention={headerSeed.blockerAttention}
/>
{/* PAP-411: priority UI hidden behind SHOW_TASK_PRIORITY_UI. */}
@@ -4228,13 +4228,14 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
// from the blocker counts — so the key signs over the full blockerAttention,
// not just `state`, to avoid a stale label when counts change.
const breadcrumbStatusKey = breadcrumbStatus
? `${breadcrumbStatus}|${issue?.externalConversationState ?? ""}|${JSON.stringify(breadcrumbBlockerAttention ?? null)}`
? `${breadcrumbStatus}|${issue?.displayStatus ?? issueHeaderSeed?.displayStatus ?? ""}|${issue?.externalConversationState ?? ""}|${JSON.stringify(breadcrumbBlockerAttention ?? null)}`
: "loading-task-status";
const breadcrumbStatusLeading = useMemo(
() =>
breadcrumbStatus ? (
<StatusIcon
status={breadcrumbStatus}
displayStatus={issue?.displayStatus ?? issueHeaderSeed?.displayStatus}
externalConversationState={issue?.externalConversationState}
className="size-3"
blockerAttention={breadcrumbBlockerAttention}
@@ -7515,7 +7516,7 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
const issueStatusControl = (
<StatusIcon
status={issue.status} externalConversationState={issue.externalConversationState}
status={issue.status} displayStatus={issue.displayStatus} externalConversationState={issue.externalConversationState}
size={proposedTaskHeader ? "md" : "lg"}
blockerAttention={issue.blockerAttention}
onChange={(status) => updateIssue.mutate({ status })}
+2 -2
View File
@@ -2682,7 +2682,7 @@ export function Inbox() {
&& blockerAttention?.state === "covered"
);
const rowStatusIcon = (
<StatusIcon status={issue.status} externalConversationState={issue.externalConversationState} blockerAttention={blockerAttention} size="md" />
<StatusIcon status={issue.status} displayStatus={issue.displayStatus} externalConversationState={issue.externalConversationState} blockerAttention={blockerAttention} size="md" />
);
return (
<IssueRow
@@ -2752,7 +2752,7 @@ export function Inbox() {
<ChevronRight className={cn("h-3.5 w-3.5 transition-transform", isExpanded && "rotate-90")} />
</button>
) : (
<StatusIcon status={issue.status} externalConversationState={issue.externalConversationState} blockerAttention={blockerAttention} size="md" />
<StatusIcon status={issue.status} displayStatus={issue.displayStatus} externalConversationState={issue.externalConversationState} blockerAttention={blockerAttention} size="md" />
)
}
unreadState={isUnread ? "visible" : isFading ? "fading" : "hidden"}
+1 -1
View File
@@ -65,7 +65,7 @@ export function MyIssues() {
title={issue.title}
to={`/issues/${issue.identifier ?? issue.id}`}
leading={
<StatusIcon status={issue.status} externalConversationState={issue.externalConversationState} blockerAttention={issue.blockerAttention} />
<StatusIcon status={issue.status} displayStatus={issue.displayStatus} externalConversationState={issue.externalConversationState} blockerAttention={issue.blockerAttention} />
}
trailing={
<span className="text-xs text-muted-foreground">
@@ -577,7 +577,7 @@ function WatchedIssueRow({ issue }: { issue: CompanySearchIssueSummary }) {
>
{issue.identifier ?? issue.id.slice(0, 8)}
</Link>
<IssueStatusBadge status={issue.status} externalConversationState={issue.externalConversationState} />
<IssueStatusBadge status={issue.status} displayStatus={issue.displayStatus} externalConversationState={issue.externalConversationState} />
<span className="min-w-0 flex-1 truncate">{issue.title}</span>
<span className="shrink-0 text-muted-foreground">{relativeTime(issue.updatedAt)}</span>
</div>
+1 -1
View File
@@ -343,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} />
<IssueStatusBadge status={issue.status} displayStatus={issue.displayStatus} />
<span className="text-xs tabular-nums text-muted-foreground">{relativeTime(issue.updatedAt)}</span>
</span>
</Link>
@@ -0,0 +1,96 @@
import { useEffect, useState } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, within } from "storybook/test";
import { Route, Routes, useNavigate } from "@/lib/router";
import { Layout } from "@/components/Layout";
import { IssueDetail } from "@/pages/IssueDetail";
import { Issues } from "@/pages/Issues";
import { Search } from "@/pages/Search";
import { Dashboard } from "@/pages/Dashboard";
import { PluginLauncherProvider } from "@/plugins/launchers";
import { StatusIcon } from "@/components/StatusIcon";
import { IssueStatusBadge } from "@/components/StatusBadge";
import { IssueReferencePill } from "@/components/IssueReferencePill";
import { IssueSiblingNavigation } from "@/components/IssueSiblingNavigation";
import { AgentTaskCard } from "@/components/chat/AgentWorkPanels";
import { installFixtureApi } from "../../support/capability-gaps/api";
import { storybookIssues, storybookAgents } from "../../fixtures/paperclipData";
import { instanceExperimentalSettingsSchema, type Issue } from "@paperclipai/shared";
const companyId = "company-storybook";
const nextCheckAt = new Date(Date.now() + 10 * 60_000);
const monitored: Issue = {
...storybookIssues[0]!, status: "in_progress", displayStatus: "monitoring",
title: "Verify deployment after CI completes", identifier: "PAP-120",
description: "The implementation is ready. The agent will check CI and merge after the checks pass.",
assigneeAgentId: storybookAgents[0]!.id, assigneeUserId: null,
executionRunId: null, checkoutRunId: null, activeRun: null,
monitorNextCheckAt: nextCheckAt, monitorAttemptCount: 2,
executionPolicy: { mode: "normal", stages: [], commentRequired: false, monitor: { nextCheckAt: nextCheckAt.toISOString(), scheduledBy: "assignee", notes: "Check CI and merge when green." } },
executionState: null, parentId: null, blockedBy: [], blocks: [],
};
const working: Issue = { ...storybookIssues[1]!, parentId: null, status: "in_progress", displayStatus: "in_progress", title: "Implement the next change" };
const rows = [monitored, working, ...storybookIssues.slice(2)];
function fixtures() {
return installFixtureApi([
{ path: "/api/workspace-preferences/me", read: () => ({ userId: "user-storybook", coordinatorWorkspaceEnabled: false, updatedAt: null }) },
{ path: "/api/instance/settings/experimental", read: () => instanceExperimentalSettingsSchema.parse({ enableStreamlinedUi: true }) },
{ path: "/api/cli-auth/me", read: () => ({ source: "local_implicit", isInstanceAdmin: true, companyIds: [companyId], memberships: [] }) },
{ path: /^\/api\/issues\/[^/]+\/(feedback-votes|browsers|live-runs|attachments|accepted-plan-decompositions)$/, read: () => [] },
{ path: /^\/api\/issues\/[^/]+\/runner-goal$/, read: () => null },
{ path: /^\/api\/companies\/[^/]+\/email\/tasks\/[^/]+$/, read: () => null },
{ path: `/api/companies/${companyId}/live-runs`, read: () => [] },
{ path: `/api/companies/${companyId}/issues`, read: ({ url }) => rows.filter(row => !url.searchParams.has("displayStatus") || url.searchParams.get("displayStatus")!.split(",").includes(row.displayStatus ?? row.status)) },
{ path: `/api/companies/${companyId}/issues/summaries`, read: ({ url }) => rows.filter(row => url.searchParams.get("ids")?.includes(row.id)).map(row => ({ ...row, externalConversationState: null })) },
{ path: `/api/companies/${companyId}/search`, read: () => ({ query: "deployment", normalizedQuery: "deployment", scope: "issues", sort: "relevance", limit: 20, offset: 0, hasMore: false, zeroResults: null,
countsByType: { issue: 1, artifact: 0, agent: 0, project: 0, comment: 0, document: 0 },
filterOptionCounts: { status: { in_progress: 1 }, displayStatus: { monitoring: 1 }, priority: {}, assigneeAgentId: {}, assigneeUserId: {}, projectId: {}, labelId: {}, updatedWithin: {} },
results: [{ id: monitored.id, type: "issue", score: 6000, title: monitored.title, href: `/PAP/issues/${monitored.identifier}`, matchedFields: ["title"], sourceLabel: null, snippet: null, snippets: [], updatedAt: monitored.updatedAt.toISOString(), previewImageUrl: null, issue: { ...monitored, updatedAt: monitored.updatedAt.toISOString() } }] }) },
{ path: /^\/api\/issues\/[^/]+$/, read: ({ url }) => rows.find(row => url.pathname.endsWith(row.id) || url.pathname.endsWith(row.identifier ?? "__none__")) ?? monitored },
{ path: /^\/api\/issues\/[^/]+\/recent-comments$/, read: () => ({ comments: [], hasMore: false, latestCommentId: null }) },
{ path: /^\/api\/issues\/[^/]+\/tree-control\/state$/, read: () => ({ activePauseHold: null }) },
{ path: /^\/api\/issues\/[^/]+\/active-run$/, read: () => null },
{ path: /^\/api\/issues\/[^/]+\/(comments|runs|activity|work-products|documents|interactions)$/, read: () => [] },
{ path: /^\/api\/issues\/[^/]+\/queued-comments$/, read: () => ({ queues: [], items: [], revision: "monitoring-story" }) },
]).cleanup;
}
function MonitoringPage({ path }: { path: string }) {
const navigate = useNavigate();
const [ready, setReady] = useState(false);
useEffect(() => { const cleanup = fixtures(); navigate(path, { replace: true }); setReady(true); return cleanup; }, [path, navigate]);
if (!ready) return null;
return <PluginLauncherProvider><Routes><Route path="/:companyPrefix" element={<Layout />}>
<Route path="issues" element={<Issues />} />
<Route path="issues/:issueId" element={<IssueDetail />} />
<Route path="search" element={<Search />} />
<Route path="dashboard" element={<Dashboard />} />
</Route></Routes></PluginLauncherProvider>;
}
const meta = {
id: "tasks-monitoring-status",
title: "Tasks and conversations/Task creation and lifecycle/Monitoring",
parameters: { layout: "fullscreen", paperclip: { capabilities: ["tasks/organize", "tasks/properties"], implementation: "production", boundary: "fixtures", notes: "Production task pages and shared indicators with isolated API fixtures. Database projection and execution boundaries are tested separately." } },
beforeEach: fixtures,
} satisfies Meta;
export default meta;
type Story = StoryObj<typeof meta>;
export const TaskDetail: Story = { render: () => <MonitoringPage path="/PAP/issues/PAP-120" /> };
export const TaskList: Story = { render: () => <MonitoringPage path="/PAP/issues?view=all" /> };
export const SearchResults: Story = { render: () => <MonitoringPage path="/PAP/search?q=deployment" /> };
export const CompanyDashboard: Story = { render: () => <MonitoringPage path="/PAP/dashboard" /> };
export const TaskDetailMobile: Story = { ...TaskDetail, globals: { viewport: { value: "mobile", isRotated: false } }, parameters: { waitForViewport: true } };
export const TaskDetailDark: Story = { ...TaskDetail, globals: { theme: "dark" } };
export const SharedIndicators: Story = {
render: () => <div className="mx-auto max-w-3xl space-y-6 p-8">
<section className="space-y-3"><h2 className="text-lg font-medium">Task status and properties</h2><StatusIcon status="in_progress" displayStatus="monitoring" showLabel onChange={() => undefined} /><IssueStatusBadge status="in_progress" displayStatus="monitoring" /></section>
<section className="space-y-3"><h2 className="text-lg font-medium">Task references and relationships</h2><IssueReferencePill issue={monitored} /><IssueSiblingNavigation navigation={{ previous: monitored, next: working, currentIndex: 1, totalCount: 3 }} /></section>
<section className="space-y-3"><h2 className="text-lg font-medium">Agent task card</h2><AgentTaskCard task={monitored} /></section>
</div>,
play: async ({ canvasElement }) => { const canvas = within(canvasElement); await expect(canvas.getAllByText("Monitoring", { exact: true })[0]!).toBeVisible(); },
};