diff --git a/ui/src/components/IssueRow.tsx b/ui/src/components/IssueRow.tsx index bcede8f1dd..6d1027ba9f 100644 --- a/ui/src/components/IssueRow.tsx +++ b/ui/src/components/IssueRow.tsx @@ -61,6 +61,38 @@ interface IssueRowProps { showDivider?: boolean; } +export function InboxArchiveButton({ + onArchive, + disabled, +}: { + onArchive: () => void; + disabled?: boolean; +}) { + return ( + + ); +} + export function IssueRow({ issue, issueLinkState, @@ -292,27 +324,7 @@ export function IssueRow({ {(onArchive || desktopTrailing || trailingMeta || externalObjectSummary) ? ( {onArchive ? ( - + ) : null} {externalObjectSummary ? ( diff --git a/ui/src/components/MarkdownCodeBlockStyles.test.ts b/ui/src/components/MarkdownCodeBlockStyles.test.ts index f269e90bf8..13f31b82e4 100644 --- a/ui/src/components/MarkdownCodeBlockStyles.test.ts +++ b/ui/src/components/MarkdownCodeBlockStyles.test.ts @@ -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. */ diff --git a/ui/src/pages/Inbox.test.tsx b/ui/src/pages/Inbox.test.tsx index 4e5cb53bb5..b7445fa50e 100644 --- a/ui/src/pages/Inbox.test.tsx +++ b/ui/src/pages/Inbox.test.tsx @@ -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 { + 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 { + 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( + + + , + ); + }); + 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('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( diff --git a/ui/src/pages/Inbox.tsx b/ui/src/pages/Inbox.tsx index 4bf93eb0d4..1d2fa37c62 100644 --- a/ui/src/pages/Inbox.tsx +++ b/ui/src/pages/Inbox.tsx @@ -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", )} /> - ) : onArchive ? ( - ) : (
+ {onArchive ? ( + + ) : null} - ) : onArchive ? ( - ) : ( - {showResolutionButtons ? ( + {(onArchive || showResolutionButtons) ? (
- - + {onArchive ? ( + + ) : null} + {showResolutionButtons ? ( + <> + + + + ) : null}
) : null}
@@ -632,16 +622,6 @@ function JoinRequestInboxRow({ unreadState === "fading" ? "opacity-0" : "opacity-100", )} /> - ) : onArchive ? ( - ) : (
+ {onArchive ? ( + + ) : null}