mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-13 07:12:00 +01:00
## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work.
> - Task indicators tell an operator whether work is active, waiting, or
complete.
> - An agent can yield until a saved monitor checks CI or another
external result.
> - The task currently keeps the In Progress icon between those runs.
> - Changing the stored status would affect monitor admission and
recovery.
> - This pull request adds a read-only Monitoring display state with a
blue clock.
> - Operators can see and filter scheduled waits while execution keeps
its existing rules.
## Linked Issues or Issue Description
**What existing behavior does this improve?**
Task status indicators, task collection filters, board columns, and
search facets.
**Current behavior**
A task that waits for a scheduled monitor shows the same In Progress
icon as active work.
**Proposed behavior**
Show Monitoring with a static blue clock when an agent-owned task has a
saved ordinary monitor and no active run, execution lock, review
participant, or active recovery action. Return to the normal task
indicator when the check starts. Preserve review, blocked, terminal, and
quota-recovery states.
**Reason and benefit**
Operators can distinguish scheduled waits from current agent work
without reading the task history.
**Breaking changes**
None to stored task statuses or execution rules. Task reads gain
optional displayStatus. Collections and search gain a separate read
filter. Existing status filters keep their lifecycle meaning.
Related historical work: #4396 covered a monitoring label exemption.
This change uses the saved monitor and a read projection.
## What Changed
- Add one SQL projection for task detail, compact lists, summaries,
relations, search results, and display-status counts.
- Show the blue clock in shared task icons and chips, task detail and
breadcrumbs, properties, references, list rows, recent navigation, and
agent task cards.
- Add Monitoring to task filtering, grouping, sorting, board columns,
and search facets. Prevent a drag from creating this derived state.
- Keep stored lifecycle statuses, monitor scheduling, checkout,
recovery, permissions, and agent status filters unchanged.
- Add database and UI regression coverage, full-page Storybook examples,
and product and access-map documentation.
## Verification
- Passed `pnpm -r typecheck`.
- Passed `pnpm build`.
- Passed `pnpm check:token-gates` and the map/catalog structural checks.
- Ran the new database suite on a disposable PostgreSQL 17 database. It
covers read surfaces, filter pagination/counts, queued/running checks,
rescheduling, completion, retained monitors, review, and recovery holds.
- Ran existing search, list, board, sidebar, task status, and filter
tests. Corrected board queries use displayStatus and eight columns.
- The full local `pnpm test:run` stopped in a chat integration shard: 12
failures (four timeouts and eight FIFO publication assertions). That run
spanned a rebase. A focused rerun on the current source passed all 21
selected cases without assertion or timeout changes. The original cause
is not established; this does not claim the full local suite passed.
Final-head required CI passed (ci / verify and ci / e2e, run
41488267209).
- Passed 82 tests for task API summaries, recovery precedence, search,
and optimistic updates; 54 tests for status cards, database reads, and
board moves; and 27 tests for blocker relations and recent-task cache
updates. One intermediate database setup hit its 20-second hook timeout;
the serial rerun passed.
- Greptile is 5/5 on d003ea672d. All
review threads are resolved. The separate optional Paperclip Review
ended early and returned neutral; GitHub rejected a direct rerun request
with HTTP 404. It is not counted as a completed review.
- Review fixes cover escalated recovery, is:open, optimistic status
changes, blocker-summary projection, and recent-task dedupe.
- Reviewed 15 screenshots from production pages and components with
isolated Storybook API fixtures: task detail/properties/breadcrumb,
list, groups, status filter, board, search/results/filter counts,
reference pills, sibling navigation, agent cards, picker, dashboard,
inbox, profile, watched status-card tasks, light theme, and mobile. This
does not claim deployed staging verification.
## Risks
- Read projections must stay consistent across full, compact, summary,
and search responses. Tests cover those paths.
- Monitoring is not a writable lifecycle status. The board does not
accept drops into its Monitoring lane or onto a monitored card. This
prevents a drag from implying a scheduled check. Move work into In
Progress through that lane instead.
- The read projection adds run/recovery existence checks. Existing
heartbeat_runs_company_issue_created_idx and
issue_recovery_actions_company_source_status_idx indexes cover the
company/task lookup. It adds no worker, timer, recurring query,
migration, or provider call. No production load benchmark is claimed.
- Older clients can ignore displayStatus. Existing lifecycle status
filters keep their behavior.
## Model Used
OpenAI Codex, GPT-6, with reasoning, repository tools, code execution,
and browser tools. The exact backend model identifier and context-window
size are not exposed in this session.
## Checklist
- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge
---------
Co-authored-by: Paperclip <noreply@paperclip.ing>
154 lines
5.4 KiB
TypeScript
154 lines
5.4 KiB
TypeScript
import type { InboxTab } from "./inbox";
|
|
|
|
/**
|
|
* The single view registry behind the merged Tasks surface (PAP-670).
|
|
*
|
|
* Inbox stopped being its own nav row and its five tabs became views inside
|
|
* Tasks, alongside the status presets that used to live only inside the task
|
|
* list's filter popover. Every view is addressed as `/issues?view=<key>`.
|
|
*
|
|
* Each view declares which surface renders it:
|
|
* - `inbox` — the attention-ordered inbox list (unread state, archive,
|
|
* date groups, mixed approval/failed-run/join-request rows).
|
|
* - `issues` — the task collection list (board/list modes, filters, columns).
|
|
*
|
|
* Views are *not* reimplemented on the other surface: pointing a view at the
|
|
* component that already owns those behaviours is what keeps them intact.
|
|
*/
|
|
export const TASK_VIEW_KEYS = [
|
|
"mine",
|
|
"unread",
|
|
"blocked",
|
|
"recent",
|
|
"everything",
|
|
"all",
|
|
"active",
|
|
"backlog",
|
|
"done",
|
|
] as const;
|
|
|
|
export type TaskViewKey = (typeof TASK_VIEW_KEYS)[number];
|
|
export type TaskViewSurface = "inbox" | "issues";
|
|
|
|
export interface TaskView {
|
|
key: TaskViewKey;
|
|
label: string;
|
|
surface: TaskViewSurface;
|
|
/** Which inbox tab renders this view (`inbox` surface only). */
|
|
inboxTab?: InboxTab;
|
|
/** Status filter applied on entry (`issues` surface only). */
|
|
statuses?: string[];
|
|
/** One-line description shown in the Views menu. */
|
|
hint: string;
|
|
}
|
|
|
|
export interface TaskViewGroup {
|
|
label: string;
|
|
views: TaskView[];
|
|
}
|
|
|
|
export const TASK_VIEW_GROUPS: TaskViewGroup[] = [
|
|
{
|
|
label: "My work",
|
|
views: [
|
|
{ key: "mine", label: "Mine", surface: "inbox", inboxTab: "mine", hint: "Assigned to you or waiting on you" },
|
|
{ key: "unread", label: "Unread", surface: "inbox", inboxTab: "unread", hint: "Tasks you touched with new activity" },
|
|
{ key: "blocked", label: "Blocked", surface: "inbox", inboxTab: "blocked", hint: "Blocked tasks, grouped by blocker" },
|
|
{ key: "recent", label: "Recent", surface: "inbox", inboxTab: "recent", hint: "Everything you touched lately" },
|
|
{ key: "everything", label: "Everything", surface: "inbox", inboxTab: "all", hint: "Tasks, approvals, failed runs and join requests" },
|
|
],
|
|
},
|
|
{
|
|
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", "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" },
|
|
],
|
|
},
|
|
];
|
|
|
|
const VIEWS_BY_KEY = new Map<TaskViewKey, TaskView>(
|
|
TASK_VIEW_GROUPS.flatMap((group) => group.views.map((view) => [view.key, view] as const)),
|
|
);
|
|
|
|
export const DEFAULT_TASK_VIEW: TaskViewKey = "mine";
|
|
export const TASK_VIEW_PARAM = "view";
|
|
export const TASK_LAST_VIEW_KEY = "paperclip:tasks:last-view";
|
|
|
|
export function isTaskViewKey(value: unknown): value is TaskViewKey {
|
|
return typeof value === "string" && VIEWS_BY_KEY.has(value as TaskViewKey);
|
|
}
|
|
|
|
export function taskView(key: TaskViewKey): TaskView {
|
|
return VIEWS_BY_KEY.get(key)!;
|
|
}
|
|
|
|
/** Resolves a `?view=` value, falling back to `null` so callers can pick a default. */
|
|
export function normalizeTaskViewKey(value: string | null | undefined): TaskViewKey | null {
|
|
return isTaskViewKey(value) ? value : null;
|
|
}
|
|
|
|
export function taskViewPath(key: TaskViewKey): string {
|
|
return `/issues?${TASK_VIEW_PARAM}=${key}`;
|
|
}
|
|
|
|
/** Inbox tab → view key, for redirecting the retired `/inbox/*` URLs. */
|
|
const VIEW_BY_INBOX_TAB: Record<InboxTab, TaskViewKey> = {
|
|
mine: "mine",
|
|
unread: "unread",
|
|
blocked: "blocked",
|
|
recent: "recent",
|
|
all: "everything",
|
|
};
|
|
|
|
export function taskViewForInboxTab(tab: string | null | undefined): TaskViewKey {
|
|
return (tab && tab in VIEW_BY_INBOX_TAB)
|
|
? VIEW_BY_INBOX_TAB[tab as InboxTab]
|
|
: DEFAULT_TASK_VIEW;
|
|
}
|
|
|
|
export function loadLastTaskView(): TaskViewKey {
|
|
try {
|
|
return normalizeTaskViewKey(window.localStorage.getItem(TASK_LAST_VIEW_KEY)) ?? DEFAULT_TASK_VIEW;
|
|
} catch {
|
|
return DEFAULT_TASK_VIEW;
|
|
}
|
|
}
|
|
|
|
export function saveLastTaskView(key: TaskViewKey): void {
|
|
try {
|
|
window.localStorage.setItem(TASK_LAST_VIEW_KEY, key);
|
|
} catch {
|
|
/* Navigation still works without storage; the default view just returns. */
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Query params that only the organization task list understands. A deep link
|
|
* carrying one of these (the dashboard's "assigned to me", a workspace
|
|
* drill-in, a search hand-off) must not land on a My-work view, so it resolves
|
|
* to `All tasks` instead of the user's last-used view.
|
|
*/
|
|
export const ORGANIZATION_SCOPED_PARAMS = [
|
|
"assignee",
|
|
"workspace",
|
|
"participantAgentId",
|
|
"originKind",
|
|
"q",
|
|
] as const;
|
|
|
|
/** Resolves which view a `/issues` visit opens. */
|
|
export function resolveInitialTaskView(
|
|
requested: string | null,
|
|
hasOrganizationScopedParam: boolean,
|
|
lastUsed: TaskViewKey,
|
|
): TaskViewKey {
|
|
const explicit = normalizeTaskViewKey(requested);
|
|
// Inbox views can't apply organization filters, so a link carrying one
|
|
// opens All tasks rather than silently dropping the filter.
|
|
if (hasOrganizationScopedParam && (!explicit || taskView(explicit).surface === "inbox")) return "all";
|
|
return explicit ?? lastUsed;
|
|
}
|