mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-03 17:51:58 +01:00
feat(ui): show scheduled task waits as Monitoring
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
0748b90062
commit
7ddd8205ad
66 files changed
+537
-123
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(","));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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} />)}
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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" })];
|
||||
|
||||
@@ -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,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
|
||||
? {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 } : {}),
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
|
||||
@@ -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" },
|
||||
],
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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 })}
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(); },
|
||||
};
|
||||
Reference in new issue
Block a user