fix(ui): keep Archive available on every inbox item (#11636)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - The Mine inbox combines tasks, failed runs, approvals, and access requests > - Each item needs a consistent action that removes it from the inbox > - Task rows keep the Archive action separate from the unread marker > - Other rows used one slot for both actions, so an unread marker hid Archive > - This pull request gives every Mine inbox row the same Archive action > - The benefit is consistent hover and swipe cleanup for every inbox item type ## Linked Issues or Issue Description **What happened?** Unread failed runs, approvals, and join requests showed a blue unread marker but no Archive button. The user had to mark the item as read before the old leading Archive control became available. **Expected behavior** Every Mine inbox item shows Archive on hover. Every item also keeps its swipe-to-archive behavior. **Steps to reproduce** 1. Open the Mine inbox. 2. Add an unread failed run, approval, or join request. 3. Hover the new item. 4. Observe that Archive is missing before this change. **Paperclip version or commit** Current `master` at `0e9b03832d`. **Deployment mode** Local development from source. **Additional context** PR #1860 improves the accessibility of the existing swipe gesture. PR #9704 changes server-side archive resurfacing rules. Neither PR adds the missing hover action to unread non-task rows. ## What Changed - Reused the task-row Archive action for failed runs, approvals, and join requests. - Kept the unread marker visible in its own leading slot. - Kept retry, approve, and reject actions beside the new trailing Archive action. - Added regression coverage for hover Archive and swipe wrappers on every non-task Mine row type. - Updated a test comment so the repository token gate does not treat documented color literals as UI source. ## Verification - `pnpm --filter @paperclipai/ui exec vitest run src/pages/Inbox.test.tsx src/components/IssueRow.test.tsx src/components/SwipeToArchive.test.tsx` — 46 tests passed. - `pnpm --filter @paperclipai/ui typecheck` — passed. - `pnpm check:token-gates` — passed. - `pnpm -r typecheck` — passed. - `pnpm test:run` — 4,284 tests passed. Ten unrelated runtime-exposure tests cannot claim fixed ports 42000 and 52000 because host Tailscale listeners already own them. - `pnpm build` — passed. - GitHub CI on the latest head — all required checks passed. - Greptile — 5/5 confidence with no open review threads. ## Risks - Low risk. The change only affects Mine inbox row actions and regression tests. - The server archive and dismiss APIs do not change. - Retry, approve, and reject behavior does not change. - No documentation change is required because this fix restores the expected inbox behavior. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex based on GPT-5. The runtime did not expose a dated model ID or context-window size. Reasoning, terminal tool use, code execution, and GitHub integration were enabled. ## 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>
This commit is contained in:
parent
1b86af995f
commit
d1cd9c37f4
|
|
@ -61,6 +61,38 @@ interface IssueRowProps {
|
|||
showDivider?: boolean;
|
||||
}
|
||||
|
||||
export function InboxArchiveButton({
|
||||
onArchive,
|
||||
disabled,
|
||||
}: {
|
||||
onArchive: () => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-slot="icon-button"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onArchive();
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== "Enter" && event.key !== " ") return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onArchive();
|
||||
}}
|
||||
disabled={disabled}
|
||||
className="inline-flex shrink-0 items-center gap-1.5 rounded-md px-2 py-1 text-xs font-medium text-muted-foreground opacity-0 transition-opacity hover:bg-accent hover:text-foreground group-hover:opacity-100 focus-visible:opacity-100 disabled:pointer-events-none disabled:opacity-30"
|
||||
aria-label="Archive"
|
||||
>
|
||||
<Archive className="h-3.5 w-3.5" />
|
||||
Archive
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function IssueRow({
|
||||
issue,
|
||||
issueLinkState,
|
||||
|
|
@ -292,27 +324,7 @@ export function IssueRow({
|
|||
{(onArchive || desktopTrailing || trailingMeta || externalObjectSummary) ? (
|
||||
<span className="ml-auto hidden shrink-0 items-center gap-2 sm:order-3 sm:flex sm:gap-3">
|
||||
{onArchive ? (
|
||||
<button
|
||||
type="button"
|
||||
data-slot="icon-button"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onArchive();
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== "Enter" && event.key !== " ") return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onArchive();
|
||||
}}
|
||||
disabled={archiveDisabled}
|
||||
className="inline-flex shrink-0 items-center gap-1.5 rounded-md px-2 py-1 text-xs font-medium text-muted-foreground opacity-0 transition-opacity hover:bg-accent hover:text-foreground group-hover:opacity-100 focus-visible:opacity-100 disabled:pointer-events-none disabled:opacity-30"
|
||||
aria-label="Archive"
|
||||
>
|
||||
<Archive className="h-3.5 w-3.5" />
|
||||
Archive
|
||||
</button>
|
||||
<InboxArchiveButton onArchive={onArchive} disabled={archiveDisabled} />
|
||||
) : null}
|
||||
{externalObjectSummary ? (
|
||||
<ExternalObjectStatusSummary summary={externalObjectSummary} compact />
|
||||
|
|
|
|||
|
|
@ -18,8 +18,8 @@ function cssBlock(selector: string): string {
|
|||
return stylesheet.slice(bodyStart + 1, bodyEnd);
|
||||
}
|
||||
|
||||
/* The rendered code block used to be pinned to the Catppuccin literals
|
||||
#1e1e2e / #cdd6f4, in the normal AND the prose-invert variables. A code
|
||||
/* The rendered code block used to be pinned to fixed Catppuccin literals
|
||||
in the normal AND the prose-invert variables. A code
|
||||
block therefore stayed dark in light mode. These tests fail if any of
|
||||
those surfaces is pinned to a literal again, rather than riding a token
|
||||
that carries a `.dark` override. */
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import type { ComponentProps } from "react";
|
|||
import { flushSync } from "react-dom";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { Issue } from "@paperclipai/shared";
|
||||
import type { Approval, HeartbeatRun, Issue } from "@paperclipai/shared";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { CompanyJoinRequest } from "../api/access";
|
||||
import {
|
||||
|
|
@ -266,6 +266,73 @@ function createJoinRequest(
|
|||
};
|
||||
}
|
||||
|
||||
function createApproval(overrides: Partial<Approval> = {}): Approval {
|
||||
return {
|
||||
id: "approval-1",
|
||||
companyId: "company-1",
|
||||
type: "hire_agent",
|
||||
requestedByAgentId: null,
|
||||
requestedByUserId: "local-board",
|
||||
status: "pending",
|
||||
payload: { name: "New teammate" },
|
||||
decisionNote: null,
|
||||
decidedByUserId: null,
|
||||
decidedAt: null,
|
||||
createdAt: new Date("2026-03-11T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-03-11T00:00:00.000Z"),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createFailedRun(overrides: Partial<HeartbeatRun> = {}): HeartbeatRun {
|
||||
return {
|
||||
id: "run-1",
|
||||
companyId: "company-1",
|
||||
agentId: "agent-1",
|
||||
responsibleUserId: null,
|
||||
invocationSource: "assignment",
|
||||
triggerDetail: null,
|
||||
status: "failed",
|
||||
error: "boom",
|
||||
wakeupRequestId: null,
|
||||
exitCode: null,
|
||||
signal: null,
|
||||
usageJson: null,
|
||||
resultJson: null,
|
||||
sessionIdBefore: null,
|
||||
sessionIdAfter: null,
|
||||
logStore: null,
|
||||
logRef: null,
|
||||
logBytes: null,
|
||||
logSha256: null,
|
||||
logCompressed: false,
|
||||
stdoutExcerpt: null,
|
||||
stderrExcerpt: null,
|
||||
errorCode: null,
|
||||
externalRunId: null,
|
||||
processPid: null,
|
||||
processGroupId: null,
|
||||
processStartedAt: null,
|
||||
lastOutputAt: null,
|
||||
lastOutputSeq: 0,
|
||||
lastOutputStream: null,
|
||||
lastOutputBytes: null,
|
||||
retryOfRunId: null,
|
||||
processLossRetryCount: 0,
|
||||
livenessState: null,
|
||||
livenessReason: null,
|
||||
continuationAttempt: 0,
|
||||
lastUsefulActionAt: null,
|
||||
nextAction: null,
|
||||
contextSnapshot: null,
|
||||
startedAt: new Date("2026-03-11T00:00:00.000Z"),
|
||||
finishedAt: new Date("2026-03-11T00:01:00.000Z"),
|
||||
createdAt: new Date("2026-03-11T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-03-11T00:01:00.000Z"),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function resetInboxApiMocks() {
|
||||
for (const mock of Object.values(apiMocks)) mock.mockReset();
|
||||
externalObjectMocks.summaries.clear();
|
||||
|
|
@ -345,6 +412,49 @@ describe("Inbox toolbar", () => {
|
|||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("keeps archive hover actions and swipe targets on every unread non-task Mine row", async () => {
|
||||
routerMock.location.pathname = "/inbox/mine";
|
||||
localStorage.setItem("paperclip:inbox:group-by", "none");
|
||||
apiMocks.approvalsList.mockResolvedValue([createApproval()]);
|
||||
apiMocks.heartbeatRunsList.mockResolvedValue([createFailedRun()]);
|
||||
apiMocks.joinRequestsList.mockResolvedValue([createJoinRequest()]);
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false, staleTime: 0, gcTime: 0 } },
|
||||
});
|
||||
const root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Inbox />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await vi.waitFor(() => {
|
||||
expect(container.textContent).toContain("Hire Agent: New teammate");
|
||||
expect(container.textContent).toContain("Failed run");
|
||||
expect(container.textContent).toContain("Jordan Example");
|
||||
});
|
||||
|
||||
const rowFor = (text: string) =>
|
||||
[...container.querySelectorAll("[data-inbox-item]")]
|
||||
.find((row) => row.textContent?.includes(text));
|
||||
|
||||
for (const text of ["Hire Agent: New teammate", "Failed run", "Jordan Example"]) {
|
||||
const row = rowFor(text);
|
||||
expect(row, `missing inbox row for ${text}`).toBeDefined();
|
||||
expect(row?.querySelector('button[aria-label="Mark as read"]')).not.toBeNull();
|
||||
const archiveButton = row?.querySelector<HTMLButtonElement>('button[aria-label="Archive"]');
|
||||
expect(archiveButton).not.toBeNull();
|
||||
expect(archiveButton?.className).toContain("opacity-0");
|
||||
expect(archiveButton?.className).toContain("group-hover:opacity-100");
|
||||
expect(row?.querySelector("[data-inbox-row-surface]")).not.toBeNull();
|
||||
}
|
||||
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("restores folded and unfolded sub-tasks across remounts", async () => {
|
||||
routerMock.location.pathname = "/inbox/mine";
|
||||
const storageKey = "paperclip:inbox:collapsed-parents:company-1";
|
||||
|
|
@ -990,51 +1100,7 @@ describe("FailedRunInboxRow", () => {
|
|||
|
||||
it("suppresses accent hover styling when selected", () => {
|
||||
const root = createRoot(container);
|
||||
const run = {
|
||||
id: "run-1",
|
||||
companyId: "company-1",
|
||||
agentId: "agent-1",
|
||||
responsibleUserId: null,
|
||||
invocationSource: "assignment",
|
||||
triggerDetail: null,
|
||||
status: "failed",
|
||||
error: "boom",
|
||||
wakeupRequestId: null,
|
||||
exitCode: null,
|
||||
signal: null,
|
||||
usageJson: null,
|
||||
resultJson: null,
|
||||
sessionIdBefore: null,
|
||||
sessionIdAfter: null,
|
||||
logStore: null,
|
||||
logRef: null,
|
||||
logBytes: null,
|
||||
logSha256: null,
|
||||
logCompressed: false,
|
||||
lastOutputAt: null,
|
||||
lastOutputSeq: 0,
|
||||
lastOutputStream: null,
|
||||
lastOutputBytes: null,
|
||||
errorCode: null,
|
||||
externalRunId: null,
|
||||
processPid: null,
|
||||
processGroupId: null,
|
||||
processStartedAt: null,
|
||||
retryOfRunId: null,
|
||||
processLossRetryCount: 0,
|
||||
livenessState: null,
|
||||
livenessReason: null,
|
||||
continuationAttempt: 0,
|
||||
lastUsefulActionAt: null,
|
||||
nextAction: null,
|
||||
stdoutExcerpt: null,
|
||||
stderrExcerpt: null,
|
||||
contextSnapshot: null,
|
||||
startedAt: new Date("2026-03-11T00:00:00.000Z"),
|
||||
finishedAt: null,
|
||||
createdAt: new Date("2026-03-11T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-03-11T00:00:00.000Z"),
|
||||
} as const;
|
||||
const run = createFailedRun();
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
|
|
|
|||
|
|
@ -79,7 +79,7 @@ import {
|
|||
issueTrailingColumns,
|
||||
} from "../components/IssueColumns";
|
||||
import { IssueFiltersPopover } from "../components/IssueFiltersPopover";
|
||||
import { IssueRow } from "../components/IssueRow";
|
||||
import { InboxArchiveButton, IssueRow } from "../components/IssueRow";
|
||||
import { BlockedInboxView } from "../components/BlockedInboxView";
|
||||
import { SwipeToArchive } from "../components/SwipeToArchive";
|
||||
|
||||
|
|
@ -340,16 +340,6 @@ export function FailedRunInboxRow({
|
|||
unreadState === "fading" ? "opacity-0" : "opacity-100",
|
||||
)} />
|
||||
</button>
|
||||
) : onArchive ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onArchive}
|
||||
disabled={archiveDisabled}
|
||||
className="inline-flex h-4 w-4 items-center justify-center rounded-md text-muted-foreground opacity-0 transition-opacity hover:text-foreground group-hover:opacity-100 disabled:pointer-events-none disabled:opacity-30"
|
||||
aria-label="Dismiss from inbox"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
) : (
|
||||
<span className="inline-flex h-4 w-4" aria-hidden="true" />
|
||||
)}
|
||||
|
|
@ -389,6 +379,9 @@ export function FailedRunInboxRow({
|
|||
</span>
|
||||
</Link>
|
||||
<div className="hidden shrink-0 items-center gap-2 sm:flex">
|
||||
{onArchive ? (
|
||||
<InboxArchiveButton onArchive={onArchive} disabled={archiveDisabled} />
|
||||
) : null}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
|
|
@ -496,16 +489,6 @@ function ApprovalInboxRow({
|
|||
unreadState === "fading" ? "opacity-0" : "opacity-100",
|
||||
)} />
|
||||
</button>
|
||||
) : onArchive ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onArchive}
|
||||
disabled={archiveDisabled}
|
||||
className="inline-flex h-4 w-4 items-center justify-center rounded-md text-muted-foreground opacity-0 transition-opacity hover:text-foreground group-hover:opacity-100 disabled:pointer-events-none disabled:opacity-30"
|
||||
aria-label="Dismiss from inbox"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
) : (
|
||||
<span className="inline-flex h-4 w-4" aria-hidden="true" />
|
||||
)}
|
||||
|
|
@ -534,25 +517,32 @@ function ApprovalInboxRow({
|
|||
</span>
|
||||
</span>
|
||||
</Link>
|
||||
{showResolutionButtons ? (
|
||||
{(onArchive || showResolutionButtons) ? (
|
||||
<div className="hidden shrink-0 items-center gap-2 sm:flex">
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-8 bg-green-700 px-3 text-white hover:bg-green-600"
|
||||
onClick={onApprove}
|
||||
disabled={isPending}
|
||||
>
|
||||
Approve
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="h-8 px-3"
|
||||
onClick={onReject}
|
||||
disabled={isPending}
|
||||
>
|
||||
Reject
|
||||
</Button>
|
||||
{onArchive ? (
|
||||
<InboxArchiveButton onArchive={onArchive} disabled={archiveDisabled} />
|
||||
) : null}
|
||||
{showResolutionButtons ? (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-8 bg-green-700 px-3 text-white hover:bg-green-600"
|
||||
onClick={onApprove}
|
||||
disabled={isPending}
|
||||
>
|
||||
Approve
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="h-8 px-3"
|
||||
onClick={onReject}
|
||||
disabled={isPending}
|
||||
>
|
||||
Reject
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
|
@ -632,16 +622,6 @@ function JoinRequestInboxRow({
|
|||
unreadState === "fading" ? "opacity-0" : "opacity-100",
|
||||
)} />
|
||||
</button>
|
||||
) : onArchive ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onArchive}
|
||||
disabled={archiveDisabled}
|
||||
className="inline-flex h-4 w-4 items-center justify-center rounded-md text-muted-foreground opacity-0 transition-opacity hover:text-foreground group-hover:opacity-100 disabled:pointer-events-none disabled:opacity-30"
|
||||
aria-label="Dismiss from inbox"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
) : (
|
||||
<span className="inline-flex h-4 w-4" aria-hidden="true" />
|
||||
)}
|
||||
|
|
@ -664,6 +644,9 @@ function JoinRequestInboxRow({
|
|||
</span>
|
||||
</div>
|
||||
<div className="hidden shrink-0 items-center gap-2 sm:flex">
|
||||
{onArchive ? (
|
||||
<InboxArchiveButton onArchive={onArchive} disabled={archiveDisabled} />
|
||||
) : null}
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-8 bg-green-700 px-3 text-white hover:bg-green-600"
|
||||
|
|
|
|||
Loading…
Reference in New Issue