mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-09 16:02:36 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Task status is the main signal on every task surface: sidebar rows, the task header, the status menu, lists, the inbox, pills and chat cards. > - One task state rendered different status icons in different views. A blocked task that waits on an active sub-task showed the blue "in queue" glyph in the task header. The status menu, the chat chip and the sidebar rows showed the red "Blocked" glyph for the same task. > - The cause: each view passed its own subset of the presentation fields to `StatusIcon`. Many views dropped `blockerAttention` and some dropped `externalConversationState`. The status menu rendered its current row from the bare status. The summaries API did not return `blockerAttention`, so the recent-task sidebar could not know the state. > - This pull request puts the state-to-glyph/label mapping in one resolver and makes every task view pass all of its fields. > - The benefit is that the same state uses the same icon and label everywhere. ## Linked Issues or Issue Description No public issue. Found in a hands-on onboarding test on a staging cloud workspace. **What happened?** The sidebar and the task pane/status menu showed different status icons for the same state. On the test task (blocked, waiting on an active sub-task), the task header and the status button showed the blue "in queue" glyph. The selected "Blocked" row in the status menu showed the red blocked glyph. The coordinator sidebar "Recents" and project rows also showed the red blocked glyph, and the chat "Subtask created" chip showed a red "Blocked" chip. **Expected behavior** The same underlying state uses the same icon and label everywhere. **Steps to reproduce** 1. Create a task with a sub-task. Block the parent on the sub-task, and let the sub-task's agent run (the parent is "blocked, covered"). 2. Open the parent task. The header shows the blue "in queue" glyph. 3. Open the status menu. The current "Blocked" row shows the red glyph. 4. With the coordinator sidebar on, look at the task in Recents or in its expanded project. It shows the red glyph. **Paperclip version or commit** `master` at03eb49053f. ## What Changed - Add `ui/src/lib/task-status-presentation.ts`. `resolveTaskStatusPresentation()` is the one mapping from `status`, `displayStatus`, `externalConversationState` and `blockerAttention` to glyph, label and accessible copy. `taskStatusIconProps()` picks all four fields from a task record. - `StatusIcon` and `IssueStatusBadge` use the resolver. The status menu draws the current row with the trigger's presentation (in queue, idle, monitoring). - `IssueStatusBadge` now accepts `blockerAttention`. `taskStatusVar.in_queue` maps to the existing in-progress chip hue, the same as the existing `--status-task-icon-in_queue` icon token. - Task call sites spread `taskStatusIconProps(task)`: coordinator sidebar recents and project rows, classic recent tasks, lists, rows, columns, inbox, blocked inbox, my issues, dashboard, properties, relation pills, search, sibling navigation, chat chips, case summaries, status cards and user profile. Step/check statuses in protocol cards stay unchanged because they are not task statuses. - `GET /api/companies/:companyId/issues/summaries` returns `blockerAttention` for blocked rows (at most five IDs, through the existing `listBlockerAttention` service). `IssueSummary` gets the optional field. - Recent-task snapshots store and refresh `blockerAttention`. Sidebar project rows request the `rows` projection, which includes blocker state (the `index` projection skips it). - Storybook: the Foundations/Status Language story gets a "Same state, every view" section. ## Verification Red on `master` (tests written first, then run against the unchanged code): ``` cd ui && npx vitest run src/components/StatusIcon.picker.test.tsx src/components/CoordinatorSidebar.test.tsx src/lib/recent-tasks.test.ts × keeps the covered-blocker state so the sidebar shows the same queued glyph as the task pane (BUG-07) × marks the current row with the same glyph as the trigger for a covered-blocked task × marks the current row with the same glyph as the trigger for a waiting review and a monitored task × shows a covered-blocked recent task with the same queued glyph and label as the task pane × shows project tasks with their full status presentation FAIL StatusIcon.picker.test.tsx > ... covered-blocked task AssertionError: expected 'var(--status-task-icon-blocked)' to be 'var(--status-task-icon-in_queue)' FAIL StatusIcon.picker.test.tsx > ... waiting review and a monitored task AssertionError: expected 'var(--status-task-icon-in_review)' to be 'var(--status-task-icon-backlog)' FAIL CoordinatorSidebar.test.tsx > ... covered-blocked recent task ... AssertionError: expected 'var(--status-task-icon-blocked)' to be 'var(--status-task-icon-in_queue)' FAIL CoordinatorSidebar.test.tsx > ... project tasks with their full status presentation AssertionError: expected "vi.fn()" to be called with arguments: [ 'company', ObjectContaining{…} ] FAIL recent-tasks.test.ts > ... keeps the covered-blocker state ... AssertionError: expected undefined to deeply equal { state: 'covered', …(7) } Tests 5 failed | 32 passed (37) cd server && npx vitest run src/__tests__/issue-summaries-routes.test.ts × projects the covered-blocker state so every task view picks the same queued glyph (BUG-07) AssertionError: expected undefined to match object { state: 'covered', …(2) } Tests 1 failed | 14 passed (15) ``` Green with this change (same commands): ``` Test Files 3 passed (3) Tests 37 passed (37) Test Files 1 passed (1) # server summaries route Tests 15 passed (15) ``` Neighbouring and broader checks: - `StatusIcon`, `StatusBadge`, `StatusGlyph`, picker, sidebar and recent-task suites: 73/73 pass. - Full UI Vitest run: 11106 pass, 1 skipped. Three `IssuesList.test.tsx` tests timed out at 5 s under full-suite load. The same file passes 73/73 when run alone. - `tsc --noEmit` for `ui` and `server`: no errors in changed files. Remaining errors are pre-existing (unbuilt `@paperclipai/paperclip-runner`, generated Storybook catalog). - `pnpm check:token-gates`: all gates clean. Screenshots. The staging task before the fix: the status button is blue, the current "Blocked" row in the menu is red. | Staging task (before) | Staging status menu (before) | | --- | --- | |  |  | Storybook "Same state, every view" (Foundations/Status Language). Before uses the `master` `StatusIcon`/`StatusBadge`; after uses this branch. | Before (master) | After (this PR) | | --- | --- | |  |  | Before: the row glyph is blue, but the chip and the current menu row are red. After: the row glyph, the chip and the current menu row are all the blue "in queue" glyph. Review follow-up (commits1d3a1a0a9e,d5c798ec53): blocker state on summaries, search and case responses is glyph-only (state, reason, counts). Blocker titles and IDs are never returned; a sampled identifier stays only when the actor can read that blocker. Producer-level tests failed before the follow-up and pass after it (\`task-created-items.test.tsx\`, \`CoordinatorTaskCommitment.test.tsx\`, \`company-search-service.test.ts\`, \`cases-routes.test.ts\`, private-blocker case in \`issue-summaries-routes.test.ts\`). Before merge, the branch was test-merged locally with current \`master\`: typecheck clean, 1095 UI and 80 server tests in the affected suites pass. ## Risks - Low risk. Presentation only. Status mutations still use the canonical `status`. - The summaries endpoint now runs the blocker-attention query for blocked rows. The endpoint accepts at most five IDs and runs the query only when a row is blocked. - Sidebar project rows use the `rows` projection instead of `index`. This adds the blocker and review attention reads for at most 12 rows, and only when a person expands a project. - `IssueStatusBadge` shows the blue "in queue" chip for covered-blocked tasks where callers pass `blockerAttention`. The label stays "Blocked" and the full reason is in the tooltip. ## Model Used - Claude Opus 5.5 (`claude-opus-5-5`, Anthropic), through Claude Code with tool use (shell, file edits, browser). Default context window. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
352 lines
14 KiB
TypeScript
352 lines
14 KiB
TypeScript
import { useState, type FormEvent } from "react";
|
|
import { useQueryClient } from "@tanstack/react-query";
|
|
import { Archive, MoreHorizontal, Pencil, RefreshCw } from "lucide-react";
|
|
import { agentsApi } from "@/api/agents";
|
|
import { issuesApi } from "@/api/issues";
|
|
import { queryKeys } from "@/lib/queryKeys";
|
|
import { useRecentTasks } from "@/hooks/useRecentTasks";
|
|
import { useAccountIdentity } from "@/api/companies-query";
|
|
import { useSidebar } from "@/context/SidebarContext";
|
|
import { useOptionalToastActions } from "@/context/ToastContext";
|
|
import {
|
|
updateRecentTaskSnapshots,
|
|
type RecentTaskEntry,
|
|
} from "@/lib/recent-tasks";
|
|
import { Button } from "@/components/ui/button";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from "@/components/ui/dialog";
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuTrigger,
|
|
} from "@/components/ui/dropdown-menu";
|
|
import { Input } from "@/components/ui/input";
|
|
import { StatusIcon } from "./StatusIcon";
|
|
import { SidebarSection } from "./SidebarSection";
|
|
import { SidebarNavItem } from "./SidebarNavItem";
|
|
import { taskStatusIconProps } from "@/lib/task-status-presentation";
|
|
|
|
const RECENT_TASK_MENU_ITEM_CLASS =
|
|
"h-(--profile-popover-row-height) gap-(--profile-popover-row-gap) rounded-lg px-2.5 py-0 text-(length:--text-compact) font-medium leading-(--profile-popover-label-line-height) focus:bg-accent/50 focus:text-foreground";
|
|
const RESTART_WAKE_RETRY_STORAGE_SUFFIX = ":restart-wake-retry";
|
|
|
|
function restartWakeRetryStorageKey(companyId: string, userId: string | null) {
|
|
// Action retry state must survive changes to the recent-task snapshot format.
|
|
return `paperclip.recentTasks:${companyId}:${userId ?? "__local_board__"}${RESTART_WAKE_RETRY_STORAGE_SUFFIX}`;
|
|
}
|
|
|
|
function readRestartWakeRetryIssueIds(storageKey: string | null) {
|
|
if (!storageKey) return new Set<string>();
|
|
try {
|
|
const parsed = JSON.parse(window.localStorage.getItem(storageKey) ?? "[]") as unknown;
|
|
return new Set(Array.isArray(parsed) ? parsed.filter((value): value is string => typeof value === "string") : []);
|
|
} catch {
|
|
return new Set<string>();
|
|
}
|
|
}
|
|
|
|
function setRestartWakeRetryPending(storageKey: string | null, issueId: string, pending: boolean) {
|
|
if (!storageKey) return;
|
|
const issueIds = readRestartWakeRetryIssueIds(storageKey);
|
|
if (pending) issueIds.add(issueId);
|
|
else issueIds.delete(issueId);
|
|
try {
|
|
if (issueIds.size > 0) window.localStorage.setItem(storageKey, JSON.stringify([...issueIds]));
|
|
else window.localStorage.removeItem(storageKey);
|
|
} catch {
|
|
// Recent Tasks remains usable when browser storage is unavailable.
|
|
}
|
|
}
|
|
|
|
function errorMessage(error: unknown, fallback: string) {
|
|
return error instanceof Error && error.message.trim() ? error.message : fallback;
|
|
}
|
|
|
|
export function SidebarRecentTasks({
|
|
companyId,
|
|
liveIssueIds,
|
|
}: {
|
|
companyId: string | null | undefined;
|
|
liveIssueIds: ReadonlySet<string>;
|
|
}) {
|
|
const { collapsed, peeking } = useSidebar();
|
|
const rail = collapsed && !peeking;
|
|
const { userId, settled } = useAccountIdentity();
|
|
|
|
if (!companyId || !settled) return null;
|
|
|
|
return (
|
|
<RecentTasksList
|
|
key={`${companyId}:${userId ?? "__local_board__"}`}
|
|
companyId={companyId}
|
|
userId={userId}
|
|
liveIssueIds={liveIssueIds}
|
|
rail={rail}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function RecentTasksList({
|
|
companyId,
|
|
userId,
|
|
liveIssueIds,
|
|
rail,
|
|
}: {
|
|
companyId: string;
|
|
userId: string | null;
|
|
liveIssueIds: ReadonlySet<string>;
|
|
rail: boolean;
|
|
}) {
|
|
const { entries, storageKey } = useRecentTasks({ companyId, userId });
|
|
const queryClient = useQueryClient();
|
|
const toastActions = useOptionalToastActions();
|
|
const [renameEntry, setRenameEntry] = useState<RecentTaskEntry | null>(null);
|
|
const [renameValue, setRenameValue] = useState("");
|
|
const [pendingAction, setPendingAction] = useState<"rename" | "archive" | "pause" | null>(null);
|
|
const restartRetryStorageKey = restartWakeRetryStorageKey(companyId, userId);
|
|
|
|
if (entries.length === 0) return null;
|
|
|
|
const refreshIssueQueries = async (issueId: string) => {
|
|
await Promise.all([
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(issueId) }),
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(companyId) }),
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.activity(issueId) }),
|
|
]);
|
|
};
|
|
|
|
const beginRename = (entry: RecentTaskEntry) => {
|
|
setRenameEntry(entry);
|
|
setRenameValue(entry.title);
|
|
};
|
|
|
|
const submitRename = async (event: FormEvent<HTMLFormElement>) => {
|
|
event.preventDefault();
|
|
const nextTitle = renameValue.trim();
|
|
if (!renameEntry || !nextTitle || nextTitle === renameEntry.title) {
|
|
setRenameEntry(null);
|
|
return;
|
|
}
|
|
|
|
setPendingAction("rename");
|
|
try {
|
|
const updated = await issuesApi.update(renameEntry.id, { title: nextTitle });
|
|
queryClient.setQueryData(queryKeys.issues.detail(renameEntry.id), updated);
|
|
if (storageKey) updateRecentTaskSnapshots(storageKey, companyId, [updated]);
|
|
await refreshIssueQueries(renameEntry.id);
|
|
setRenameEntry(null);
|
|
toastActions?.pushToast({ title: "Task renamed", tone: "success" });
|
|
} catch (error) {
|
|
toastActions?.pushToast({
|
|
title: "Task rename failed",
|
|
body: errorMessage(error, "Unable to rename this task."),
|
|
tone: "error",
|
|
});
|
|
} finally {
|
|
setPendingAction(null);
|
|
}
|
|
};
|
|
|
|
const archiveTask = async (entry: RecentTaskEntry) => {
|
|
setPendingAction("archive");
|
|
try {
|
|
await issuesApi.archiveFromInbox(entry.id);
|
|
await refreshIssueQueries(entry.id);
|
|
await queryClient.invalidateQueries({
|
|
queryKey: queryKeys.sidebarBadges(companyId),
|
|
});
|
|
toastActions?.pushToast({ title: "Task archived from inbox", tone: "success" });
|
|
} catch (error) {
|
|
toastActions?.pushToast({
|
|
title: "Task archive failed",
|
|
body: errorMessage(error, "Unable to archive this task from the inbox."),
|
|
tone: "error",
|
|
});
|
|
} finally {
|
|
setPendingAction(null);
|
|
}
|
|
};
|
|
|
|
const toggleTaskPause = async (entry: RecentTaskEntry) => {
|
|
setPendingAction("pause");
|
|
try {
|
|
const state = await issuesApi.getTreeControlState(entry.id);
|
|
if (state.activePauseHold?.isRoot) {
|
|
const restartIssue = await issuesApi.get(entry.id);
|
|
setRestartWakeRetryPending(restartRetryStorageKey, entry.id, true);
|
|
await issuesApi.releaseTreeHold(entry.id, state.activePauseHold.holdId, {
|
|
reason: "Restarted from Recent Tasks.",
|
|
});
|
|
if (restartIssue.assigneeAgentId) {
|
|
const wakeResult = await agentsApi.wakeup(
|
|
restartIssue.assigneeAgentId,
|
|
{
|
|
source: "assignment",
|
|
triggerDetail: "manual",
|
|
reason: "recent_task_restart",
|
|
payload: { issueId: restartIssue.id },
|
|
},
|
|
restartIssue.companyId,
|
|
);
|
|
if (!("id" in wakeResult)) {
|
|
throw new Error(wakeResult.message ?? "The assignee wake was skipped.");
|
|
}
|
|
}
|
|
setRestartWakeRetryPending(restartRetryStorageKey, entry.id, false);
|
|
toastActions?.pushToast({ title: "Task restarted", tone: "success" });
|
|
} else if (state.activePauseHold) {
|
|
throw new Error("This task is paused by a parent task. Restart it from the pause root.");
|
|
} else if (readRestartWakeRetryIssueIds(restartRetryStorageKey).has(entry.id)) {
|
|
const restartIssue = await issuesApi.get(entry.id);
|
|
if (restartIssue.assigneeAgentId) {
|
|
const wakeResult = await agentsApi.wakeup(
|
|
restartIssue.assigneeAgentId,
|
|
{
|
|
source: "assignment",
|
|
triggerDetail: "manual",
|
|
reason: "recent_task_restart_retry",
|
|
payload: { issueId: restartIssue.id },
|
|
},
|
|
restartIssue.companyId,
|
|
);
|
|
if (!("id" in wakeResult)) {
|
|
throw new Error(wakeResult.message ?? "The assignee wake was skipped.");
|
|
}
|
|
}
|
|
setRestartWakeRetryPending(restartRetryStorageKey, entry.id, false);
|
|
toastActions?.pushToast({ title: "Task restarted", tone: "success" });
|
|
} else {
|
|
await issuesApi.createTreeHold(entry.id, {
|
|
mode: "pause",
|
|
reason: "Paused from Recent Tasks.",
|
|
releasePolicy: { strategy: "manual" },
|
|
});
|
|
toastActions?.pushToast({ title: "Task paused", tone: "success" });
|
|
}
|
|
await queryClient.invalidateQueries({
|
|
queryKey: ["issues", "tree-control-state", entry.id],
|
|
});
|
|
await queryClient.invalidateQueries({ queryKey: queryKeys.issues.summaries(companyId) });
|
|
} catch (error) {
|
|
toastActions?.pushToast({
|
|
title: "Task pause update failed",
|
|
body: errorMessage(error, "Unable to pause or restart this task."),
|
|
tone: "error",
|
|
});
|
|
} finally {
|
|
setPendingAction(null);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<SidebarSection label="Recent Tasks">
|
|
{entries.map((entry) => (
|
|
<div key={entry.id} className="sidebar-action-row group/recent-task relative">
|
|
<SidebarNavItem
|
|
to={`/issues/${entry.id}`}
|
|
label={entry.title}
|
|
iconNode={entry.displayStatus === "monitoring" && !liveIssueIds.has(entry.id) ? <StatusIcon {...taskStatusIconProps(entry)} /> : undefined}
|
|
trailing={entry.status === "in_review" && entry.externalConversationState === "waiting"
|
|
? <span className="text-xs text-muted-foreground">Idle</span> : undefined}
|
|
trailingLabel={entry.status === "in_review" && entry.externalConversationState === "waiting" ? "Idle" : undefined}
|
|
className={rail ? undefined : "sidebar-action-link pointer-coarse:pr-8"}
|
|
liveCount={liveIssueIds.has(entry.id) ? 1 : undefined}
|
|
/>
|
|
{!rail ? (
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon-xs"
|
|
aria-label={`More actions for ${entry.title}`}
|
|
className="sidebar-action-menu absolute right-2 top-(--pct-50) z-10 -translate-y-(--pct-50) text-muted-foreground pointer-events-none opacity-0 transition-opacity hover:bg-sidebar-accent dark:hover:bg-sidebar-accent hover:text-foreground focus-visible:pointer-events-auto focus-visible:opacity-100 pointer-coarse:pointer-events-auto pointer-coarse:opacity-100 pointer-coarse:before:hidden group-hover/recent-task:pointer-events-auto group-hover/recent-task:opacity-100 group-focus-within/recent-task:pointer-events-auto group-focus-within/recent-task:opacity-100 data-[state=open]:pointer-events-auto data-[state=open]:bg-sidebar-accent data-[state=open]:text-foreground data-[state=open]:opacity-100"
|
|
>
|
|
<MoreHorizontal aria-hidden="true" />
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent
|
|
side="right"
|
|
align="start"
|
|
className="w-(--profile-popover-width) rounded-xl p-1.5 shadow-(--shadow-profile-popover)"
|
|
>
|
|
<DropdownMenuItem
|
|
className={RECENT_TASK_MENU_ITEM_CLASS}
|
|
onSelect={() => beginRename(entry)}
|
|
>
|
|
<Pencil aria-hidden="true" />
|
|
Rename
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem
|
|
className={RECENT_TASK_MENU_ITEM_CLASS}
|
|
disabled={pendingAction !== null}
|
|
onSelect={() => void archiveTask(entry)}
|
|
>
|
|
<Archive aria-hidden="true" />
|
|
Archive
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem
|
|
className={RECENT_TASK_MENU_ITEM_CLASS}
|
|
disabled={pendingAction !== null}
|
|
onSelect={() => void toggleTaskPause(entry)}
|
|
>
|
|
<RefreshCw aria-hidden="true" />
|
|
Pause/Restart
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
) : null}
|
|
</div>
|
|
))}
|
|
</SidebarSection>
|
|
|
|
<Dialog
|
|
open={renameEntry !== null}
|
|
onOpenChange={(open) => {
|
|
if (!open && pendingAction !== "rename") setRenameEntry(null);
|
|
}}
|
|
>
|
|
<DialogContent className="sm:max-w-md">
|
|
<form className="grid gap-4" onSubmit={(event) => void submitRename(event)}>
|
|
<DialogHeader>
|
|
<DialogTitle>Rename task</DialogTitle>
|
|
<DialogDescription>Choose a short, clear name for this task.</DialogDescription>
|
|
</DialogHeader>
|
|
<Input
|
|
autoFocus
|
|
aria-label="Task name"
|
|
value={renameValue}
|
|
disabled={pendingAction === "rename"}
|
|
onChange={(event) => setRenameValue(event.target.value)}
|
|
/>
|
|
<DialogFooter>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
disabled={pendingAction === "rename"}
|
|
onClick={() => setRenameEntry(null)}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
type="submit"
|
|
disabled={pendingAction === "rename" || !renameValue.trim()}
|
|
>
|
|
{pendingAction === "rename" ? "Saving..." : "Save"}
|
|
</Button>
|
|
</DialogFooter>
|
|
</form>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</>
|
|
);
|
|
}
|