Compare commits
10 commits
74687553f3
...
b1b3408efa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b1b3408efa | ||
|
|
57357991e4 | ||
|
|
50577b8c63 | ||
|
|
1871a602df | ||
|
|
facf994694 | ||
|
|
403aeff7f6 | ||
|
|
7d81e4cb2a | ||
|
|
44f052f4c5 | ||
|
|
c33dcbd202 | ||
|
|
bc61eb84df |
12 changed files with 575 additions and 119 deletions
|
|
@ -40,12 +40,7 @@ interface CommentThreadProps {
|
||||||
linkedRuns?: LinkedRunItem[];
|
linkedRuns?: LinkedRunItem[];
|
||||||
companyId?: string | null;
|
companyId?: string | null;
|
||||||
projectId?: string | null;
|
projectId?: string | null;
|
||||||
onAdd: (
|
onAdd: (body: string, reopen?: boolean, reassignment?: CommentReassignment) => Promise<void>;
|
||||||
body: string,
|
|
||||||
reopen?: boolean,
|
|
||||||
reassignment?: CommentReassignment,
|
|
||||||
interrupt?: boolean,
|
|
||||||
) => Promise<void>;
|
|
||||||
issueStatus?: string;
|
issueStatus?: string;
|
||||||
agentMap?: Map<string, Agent>;
|
agentMap?: Map<string, Agent>;
|
||||||
imageUploadHandler?: (file: File) => Promise<string>;
|
imageUploadHandler?: (file: File) => Promise<string>;
|
||||||
|
|
@ -58,7 +53,6 @@ interface CommentThreadProps {
|
||||||
currentAssigneeValue?: string;
|
currentAssigneeValue?: string;
|
||||||
suggestedAssigneeValue?: string;
|
suggestedAssigneeValue?: string;
|
||||||
mentions?: MentionOption[];
|
mentions?: MentionOption[];
|
||||||
interruptAvailable?: boolean;
|
|
||||||
onInterruptQueued?: (runId: string) => Promise<void>;
|
onInterruptQueued?: (runId: string) => Promise<void>;
|
||||||
interruptingQueuedRunId?: string | null;
|
interruptingQueuedRunId?: string | null;
|
||||||
}
|
}
|
||||||
|
|
@ -329,13 +323,11 @@ export function CommentThread({
|
||||||
currentAssigneeValue = "",
|
currentAssigneeValue = "",
|
||||||
suggestedAssigneeValue,
|
suggestedAssigneeValue,
|
||||||
mentions: providedMentions,
|
mentions: providedMentions,
|
||||||
interruptAvailable = false,
|
|
||||||
onInterruptQueued,
|
onInterruptQueued,
|
||||||
interruptingQueuedRunId = null,
|
interruptingQueuedRunId = null,
|
||||||
}: CommentThreadProps) {
|
}: CommentThreadProps) {
|
||||||
const [body, setBody] = useState("");
|
const [body, setBody] = useState("");
|
||||||
const [reopen, setReopen] = useState(true);
|
const [reopen, setReopen] = useState(true);
|
||||||
const [interrupt, setInterrupt] = useState(false);
|
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [attaching, setAttaching] = useState(false);
|
const [attaching, setAttaching] = useState(false);
|
||||||
const effectiveSuggestedAssigneeValue = suggestedAssigneeValue ?? currentAssigneeValue;
|
const effectiveSuggestedAssigneeValue = suggestedAssigneeValue ?? currentAssigneeValue;
|
||||||
|
|
@ -405,14 +397,6 @@ export function CommentThread({
|
||||||
setReassignTarget(effectiveSuggestedAssigneeValue);
|
setReassignTarget(effectiveSuggestedAssigneeValue);
|
||||||
}, [effectiveSuggestedAssigneeValue]);
|
}, [effectiveSuggestedAssigneeValue]);
|
||||||
|
|
||||||
const interruptVisible = interruptAvailable && body.trim().length > 0;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!interruptVisible && interrupt) {
|
|
||||||
setInterrupt(false);
|
|
||||||
}
|
|
||||||
}, [interruptVisible, interrupt]);
|
|
||||||
|
|
||||||
// Scroll to comment when URL hash matches #comment-{id}
|
// Scroll to comment when URL hash matches #comment-{id}
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const hash = location.hash;
|
const hash = location.hash;
|
||||||
|
|
@ -439,11 +423,10 @@ export function CommentThread({
|
||||||
|
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
try {
|
try {
|
||||||
await onAdd(trimmed, reopen ? true : undefined, reassignment ?? undefined, interrupt ? true : undefined);
|
await onAdd(trimmed, reopen ? true : undefined, reassignment ?? undefined);
|
||||||
setBody("");
|
setBody("");
|
||||||
if (draftKey) clearDraft(draftKey);
|
if (draftKey) clearDraft(draftKey);
|
||||||
setReopen(true);
|
setReopen(true);
|
||||||
setInterrupt(false);
|
|
||||||
setReassignTarget(effectiveSuggestedAssigneeValue);
|
setReassignTarget(effectiveSuggestedAssigneeValue);
|
||||||
} catch {
|
} catch {
|
||||||
// Parent mutation handlers surface the failure and keep the draft intact.
|
// Parent mutation handlers surface the failure and keep the draft intact.
|
||||||
|
|
@ -564,17 +547,6 @@ export function CommentThread({
|
||||||
/>
|
/>
|
||||||
Re-open
|
Re-open
|
||||||
</label>
|
</label>
|
||||||
{interruptVisible && (
|
|
||||||
<label className="flex items-center gap-1.5 text-xs text-red-700 dark:text-red-300 cursor-pointer select-none">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={interrupt}
|
|
||||||
onChange={(e) => setInterrupt(e.target.checked)}
|
|
||||||
className="rounded border-red-300 accent-red-600"
|
|
||||||
/>
|
|
||||||
Interrupt
|
|
||||||
</label>
|
|
||||||
)}
|
|
||||||
{enableReassign && reassignOptions.length > 0 && (
|
{enableReassign && reassignOptions.length > 0 && (
|
||||||
<InlineEntitySelector
|
<InlineEntitySelector
|
||||||
value={reassignTarget}
|
value={reassignTarget}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { useQueries } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
DndContext,
|
DndContext,
|
||||||
closestCenter,
|
closestCenter,
|
||||||
PointerSensor,
|
MouseSensor,
|
||||||
useSensor,
|
useSensor,
|
||||||
useSensors,
|
useSensors,
|
||||||
type DragEndEvent,
|
type DragEndEvent,
|
||||||
|
|
@ -244,7 +244,8 @@ export function CompanyRail() {
|
||||||
|
|
||||||
// Require 8px of movement before starting a drag to avoid interfering with clicks
|
// Require 8px of movement before starting a drag to avoid interfering with clicks
|
||||||
const sensors = useSensors(
|
const sensors = useSensors(
|
||||||
useSensor(PointerSensor, {
|
// Keep sidebar reordering mouse-only so touch input can scroll/tap without drag affordances.
|
||||||
|
useSensor(MouseSensor, {
|
||||||
activationConstraint: { distance: 8 },
|
activationConstraint: { distance: 8 },
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
|
||||||
116
ui/src/components/IssueRow.test.tsx
Normal file
116
ui/src/components/IssueRow.test.tsx
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
// @vitest-environment jsdom
|
||||||
|
|
||||||
|
import { act } from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import type { Issue } from "@paperclipai/shared";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { IssueRow } from "./IssueRow";
|
||||||
|
|
||||||
|
vi.mock("@/lib/router", () => ({
|
||||||
|
Link: ({ children, className, ...props }: React.ComponentProps<"a">) => (
|
||||||
|
<a className={className} {...props}>{children}</a>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||||
|
|
||||||
|
function createIssue(overrides: Partial<Issue> = {}): Issue {
|
||||||
|
return {
|
||||||
|
id: "issue-1",
|
||||||
|
identifier: "PAP-1",
|
||||||
|
companyId: "company-1",
|
||||||
|
projectId: null,
|
||||||
|
projectWorkspaceId: null,
|
||||||
|
goalId: null,
|
||||||
|
parentId: null,
|
||||||
|
title: "Inbox item",
|
||||||
|
description: null,
|
||||||
|
status: "todo",
|
||||||
|
priority: "medium",
|
||||||
|
assigneeAgentId: null,
|
||||||
|
assigneeUserId: null,
|
||||||
|
createdByAgentId: null,
|
||||||
|
createdByUserId: null,
|
||||||
|
issueNumber: 1,
|
||||||
|
requestDepth: 0,
|
||||||
|
billingCode: null,
|
||||||
|
assigneeAdapterOverrides: null,
|
||||||
|
executionWorkspaceId: null,
|
||||||
|
executionWorkspacePreference: null,
|
||||||
|
executionWorkspaceSettings: null,
|
||||||
|
checkoutRunId: null,
|
||||||
|
executionRunId: null,
|
||||||
|
executionAgentNameKey: null,
|
||||||
|
executionLockedAt: null,
|
||||||
|
startedAt: null,
|
||||||
|
completedAt: null,
|
||||||
|
cancelledAt: null,
|
||||||
|
hiddenAt: null,
|
||||||
|
createdAt: new Date("2026-03-11T00:00:00.000Z"),
|
||||||
|
updatedAt: new Date("2026-03-11T00:00:00.000Z"),
|
||||||
|
labels: [],
|
||||||
|
labelIds: [],
|
||||||
|
myLastTouchAt: null,
|
||||||
|
lastExternalCommentAt: null,
|
||||||
|
isUnreadForMe: false,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("IssueRow", () => {
|
||||||
|
let container: HTMLDivElement;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
container = document.createElement("div");
|
||||||
|
document.body.appendChild(container);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
container.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("suppresses accent hover styling when the row is selected", () => {
|
||||||
|
const root = createRoot(container);
|
||||||
|
const issue = createIssue();
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
root.render(<IssueRow issue={issue} selected />);
|
||||||
|
});
|
||||||
|
|
||||||
|
const link = container.querySelector("[data-inbox-issue-link]") as HTMLAnchorElement | null;
|
||||||
|
expect(link).not.toBeNull();
|
||||||
|
expect(link?.className).toContain("hover:bg-transparent");
|
||||||
|
expect(link?.className).not.toContain("hover:bg-accent/50");
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("neutralizes selected status and unread dot accents", () => {
|
||||||
|
const root = createRoot(container);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
root.render(<IssueRow issue={createIssue()} selected unreadState="visible" />);
|
||||||
|
});
|
||||||
|
|
||||||
|
const markReadButton = container.querySelector('button[aria-label="Mark as read"]');
|
||||||
|
const unreadDot = markReadButton?.querySelector("span");
|
||||||
|
const statusIcon = container.querySelector('span[class*="border-muted-foreground"]');
|
||||||
|
|
||||||
|
expect(markReadButton).not.toBeNull();
|
||||||
|
expect(markReadButton?.className).toContain("hover:bg-muted/80");
|
||||||
|
expect(markReadButton?.className).not.toContain("hover:bg-blue-500/20");
|
||||||
|
expect(unreadDot).not.toBeNull();
|
||||||
|
expect(unreadDot?.className).toContain("bg-muted-foreground/70");
|
||||||
|
expect(unreadDot?.className).not.toContain("bg-blue-600");
|
||||||
|
expect(statusIcon).not.toBeNull();
|
||||||
|
expect(statusIcon?.className).toContain("!border-muted-foreground");
|
||||||
|
expect(statusIcon?.className).toContain("!text-muted-foreground");
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -11,6 +11,7 @@ type UnreadState = "hidden" | "visible" | "fading";
|
||||||
interface IssueRowProps {
|
interface IssueRowProps {
|
||||||
issue: Issue;
|
issue: Issue;
|
||||||
issueLinkState?: unknown;
|
issueLinkState?: unknown;
|
||||||
|
selected?: boolean;
|
||||||
mobileLeading?: ReactNode;
|
mobileLeading?: ReactNode;
|
||||||
desktopMetaLeading?: ReactNode;
|
desktopMetaLeading?: ReactNode;
|
||||||
desktopLeadingSpacer?: boolean;
|
desktopLeadingSpacer?: boolean;
|
||||||
|
|
@ -27,6 +28,7 @@ interface IssueRowProps {
|
||||||
export function IssueRow({
|
export function IssueRow({
|
||||||
issue,
|
issue,
|
||||||
issueLinkState,
|
issueLinkState,
|
||||||
|
selected = false,
|
||||||
mobileLeading,
|
mobileLeading,
|
||||||
desktopMetaLeading,
|
desktopMetaLeading,
|
||||||
desktopLeadingSpacer = false,
|
desktopLeadingSpacer = false,
|
||||||
|
|
@ -43,18 +45,21 @@ export function IssueRow({
|
||||||
const identifier = issue.identifier ?? issue.id.slice(0, 8);
|
const identifier = issue.identifier ?? issue.id.slice(0, 8);
|
||||||
const showUnreadSlot = unreadState !== null;
|
const showUnreadSlot = unreadState !== null;
|
||||||
const showUnreadDot = unreadState === "visible" || unreadState === "fading";
|
const showUnreadDot = unreadState === "visible" || unreadState === "fading";
|
||||||
|
const selectedStatusClass = selected ? "!text-muted-foreground !border-muted-foreground" : undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
to={createIssueDetailPath(issuePathId, issueLinkState)}
|
to={createIssueDetailPath(issuePathId, issueLinkState)}
|
||||||
state={issueLinkState}
|
state={issueLinkState}
|
||||||
|
data-inbox-issue-link
|
||||||
className={cn(
|
className={cn(
|
||||||
"group flex items-start gap-2 border-b border-border py-2.5 pl-2 pr-3 text-sm no-underline text-inherit transition-colors hover:bg-accent/50 last:border-b-0 sm:items-center sm:py-2 sm:pl-1",
|
"group flex items-start gap-2 border-b border-border py-2.5 pl-2 pr-3 text-sm no-underline text-inherit transition-colors last:border-b-0 sm:items-center sm:py-2 sm:pl-1",
|
||||||
|
selected ? "hover:bg-transparent" : "hover:bg-accent/50",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span className="shrink-0 pt-px sm:hidden">
|
<span className="shrink-0 pt-px sm:hidden">
|
||||||
{mobileLeading ?? <StatusIcon status={issue.status} />}
|
{mobileLeading ?? <StatusIcon status={issue.status} className={selectedStatusClass} />}
|
||||||
</span>
|
</span>
|
||||||
<span className="flex min-w-0 flex-1 flex-col gap-1 sm:contents">
|
<span className="flex min-w-0 flex-1 flex-col gap-1 sm:contents">
|
||||||
<span className="line-clamp-2 text-sm sm:order-2 sm:min-w-0 sm:flex-1 sm:truncate sm:line-clamp-none">
|
<span className="line-clamp-2 text-sm sm:order-2 sm:min-w-0 sm:flex-1 sm:truncate sm:line-clamp-none">
|
||||||
|
|
@ -67,7 +72,7 @@ export function IssueRow({
|
||||||
{desktopMetaLeading ?? (
|
{desktopMetaLeading ?? (
|
||||||
<>
|
<>
|
||||||
<span className="hidden shrink-0 sm:inline-flex">
|
<span className="hidden shrink-0 sm:inline-flex">
|
||||||
<StatusIcon status={issue.status} />
|
<StatusIcon status={issue.status} className={selectedStatusClass} />
|
||||||
</span>
|
</span>
|
||||||
<span className="shrink-0 font-mono text-xs text-muted-foreground">
|
<span className="shrink-0 font-mono text-xs text-muted-foreground">
|
||||||
{identifier}
|
{identifier}
|
||||||
|
|
@ -109,12 +114,16 @@ export function IssueRow({
|
||||||
onMarkRead?.();
|
onMarkRead?.();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className="inline-flex h-4 w-4 items-center justify-center rounded-full transition-colors hover:bg-blue-500/20"
|
className={cn(
|
||||||
|
"inline-flex h-4 w-4 items-center justify-center rounded-full transition-colors",
|
||||||
|
selected ? "hover:bg-muted/80" : "hover:bg-blue-500/20",
|
||||||
|
)}
|
||||||
aria-label="Mark as read"
|
aria-label="Mark as read"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"block h-2 w-2 rounded-full bg-blue-600 transition-opacity duration-300 dark:bg-blue-400",
|
"block h-2 w-2 rounded-full transition-opacity duration-300",
|
||||||
|
selected ? "bg-muted-foreground/70" : "bg-blue-600 dark:bg-blue-400",
|
||||||
unreadState === "fading" ? "opacity-0" : "opacity-100",
|
unreadState === "fading" ? "opacity-0" : "opacity-100",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { useQuery } from "@tanstack/react-query";
|
||||||
import { ChevronRight, Plus } from "lucide-react";
|
import { ChevronRight, Plus } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
DndContext,
|
DndContext,
|
||||||
PointerSensor,
|
MouseSensor,
|
||||||
closestCenter,
|
closestCenter,
|
||||||
type DragEndEvent,
|
type DragEndEvent,
|
||||||
useSensor,
|
useSensor,
|
||||||
|
|
@ -153,7 +153,8 @@ export function SidebarProjects() {
|
||||||
const projectMatch = location.pathname.match(/^\/(?:[^/]+\/)?projects\/([^/]+)/);
|
const projectMatch = location.pathname.match(/^\/(?:[^/]+\/)?projects\/([^/]+)/);
|
||||||
const activeProjectRef = projectMatch?.[1] ?? null;
|
const activeProjectRef = projectMatch?.[1] ?? null;
|
||||||
const sensors = useSensors(
|
const sensors = useSensors(
|
||||||
useSensor(PointerSensor, {
|
// Project reordering is intentionally desktop-only; touch should remain tap/scroll behavior.
|
||||||
|
useSensor(MouseSensor, {
|
||||||
activationConstraint: { distance: 8 },
|
activationConstraint: { distance: 8 },
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -122,4 +122,25 @@ describe("SwipeToArchive", () => {
|
||||||
root.unmount();
|
root.unmount();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("renders the selected inbox treatment on the swipe surface", () => {
|
||||||
|
const root = createRoot(container);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
root.render(
|
||||||
|
<SwipeToArchive onArchive={() => {}} selected>
|
||||||
|
<button type="button">Open issue</button>
|
||||||
|
</SwipeToArchive>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const surface = container.querySelector("[data-inbox-row-surface]") as HTMLDivElement | null;
|
||||||
|
expect(surface).not.toBeNull();
|
||||||
|
expect(surface?.style.backgroundColor).toBe("rgb(243, 244, 246)");
|
||||||
|
expect(surface?.style.boxShadow).toBe("");
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -6,17 +6,20 @@ interface SwipeToArchiveProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
onArchive: () => void;
|
onArchive: () => void;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
selected?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const COMMIT_THRESHOLD = 0.32;
|
const COMMIT_THRESHOLD = 0.32;
|
||||||
const MAX_SWIPE = 0.88;
|
const MAX_SWIPE = 0.88;
|
||||||
const COMMIT_DELAY_MS = 140;
|
const COMMIT_DELAY_MS = 140;
|
||||||
|
const SELECTED_ROW_BACKGROUND = "#f3f4f6";
|
||||||
|
|
||||||
export function SwipeToArchive({
|
export function SwipeToArchive({
|
||||||
children,
|
children,
|
||||||
onArchive,
|
onArchive,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
|
selected = false,
|
||||||
className,
|
className,
|
||||||
}: SwipeToArchiveProps) {
|
}: SwipeToArchiveProps) {
|
||||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
@ -148,10 +151,12 @@ export function SwipeToArchive({
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
|
data-inbox-row-surface
|
||||||
className="relative bg-card will-change-transform"
|
className="relative bg-card will-change-transform"
|
||||||
style={{
|
style={{
|
||||||
transform: `translate3d(${offsetX}px, 0, 0)`,
|
transform: `translate3d(${offsetX}px, 0, 0)`,
|
||||||
transition: isDragging ? "none" : "transform 180ms ease-out",
|
transition: isDragging ? "none" : "transform 180ms ease-out",
|
||||||
|
backgroundColor: selected ? SELECTED_ROW_BACKGROUND : undefined,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,13 @@ import {
|
||||||
computeInboxBadgeData,
|
computeInboxBadgeData,
|
||||||
getApprovalsForTab,
|
getApprovalsForTab,
|
||||||
getInboxWorkItems,
|
getInboxWorkItems,
|
||||||
|
getInboxKeyboardSelectionIndex,
|
||||||
getRecentTouchedIssues,
|
getRecentTouchedIssues,
|
||||||
getUnreadTouchedIssues,
|
getUnreadTouchedIssues,
|
||||||
isMineInboxTab,
|
isMineInboxTab,
|
||||||
loadLastInboxTab,
|
loadLastInboxTab,
|
||||||
RECENT_ISSUES_LIMIT,
|
RECENT_ISSUES_LIMIT,
|
||||||
|
resolveInboxSelectionIndex,
|
||||||
saveLastInboxTab,
|
saveLastInboxTab,
|
||||||
shouldShowInboxSection,
|
shouldShowInboxSection,
|
||||||
} from "./inbox";
|
} from "./inbox";
|
||||||
|
|
@ -408,4 +410,17 @@ describe("inbox helpers", () => {
|
||||||
expect(isMineInboxTab("unread")).toBe(false);
|
expect(isMineInboxTab("unread")).toBe(false);
|
||||||
expect(isMineInboxTab("all")).toBe(false);
|
expect(isMineInboxTab("all")).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("anchors Mine selection to the first available inbox row", () => {
|
||||||
|
expect(resolveInboxSelectionIndex(-1, 3)).toBe(-1);
|
||||||
|
expect(resolveInboxSelectionIndex(5, 3)).toBe(2);
|
||||||
|
expect(resolveInboxSelectionIndex(1, 0)).toBe(-1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("selects the first row only after keyboard navigation starts", () => {
|
||||||
|
expect(getInboxKeyboardSelectionIndex(-1, 3, "next")).toBe(0);
|
||||||
|
expect(getInboxKeyboardSelectionIndex(-1, 3, "previous")).toBe(0);
|
||||||
|
expect(getInboxKeyboardSelectionIndex(0, 3, "next")).toBe(1);
|
||||||
|
expect(getInboxKeyboardSelectionIndex(0, 3, "previous")).toBe(0);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -102,6 +102,27 @@ export function isMineInboxTab(tab: InboxTab): boolean {
|
||||||
return tab === "mine";
|
return tab === "mine";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function resolveInboxSelectionIndex(
|
||||||
|
previousIndex: number,
|
||||||
|
itemCount: number,
|
||||||
|
): number {
|
||||||
|
if (itemCount === 0) return -1;
|
||||||
|
if (previousIndex < 0) return -1;
|
||||||
|
return Math.min(previousIndex, itemCount - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getInboxKeyboardSelectionIndex(
|
||||||
|
previousIndex: number,
|
||||||
|
itemCount: number,
|
||||||
|
direction: "next" | "previous",
|
||||||
|
): number {
|
||||||
|
if (itemCount === 0) return -1;
|
||||||
|
if (previousIndex < 0) return 0;
|
||||||
|
return direction === "next"
|
||||||
|
? Math.min(previousIndex + 1, itemCount - 1)
|
||||||
|
: Math.max(previousIndex - 1, 0);
|
||||||
|
}
|
||||||
|
|
||||||
export function getLatestFailedRunsByAgent(runs: HeartbeatRun[]): HeartbeatRun[] {
|
export function getLatestFailedRunsByAgent(runs: HeartbeatRun[]): HeartbeatRun[] {
|
||||||
const sorted = [...runs].sort(
|
const sorted = [...runs].sort(
|
||||||
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
|
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
|
||||||
|
|
|
||||||
181
ui/src/pages/Inbox.test.tsx
Normal file
181
ui/src/pages/Inbox.test.tsx
Normal file
|
|
@ -0,0 +1,181 @@
|
||||||
|
// @vitest-environment jsdom
|
||||||
|
|
||||||
|
import { act } from "react";
|
||||||
|
import type { ComponentProps } from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import type { Issue } from "@paperclipai/shared";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { FailedRunInboxRow, InboxIssueMetaLeading } from "./Inbox";
|
||||||
|
|
||||||
|
vi.mock("@/lib/router", () => ({
|
||||||
|
Link: ({ children, className, ...props }: ComponentProps<"a">) => (
|
||||||
|
<a className={className} {...props}>{children}</a>
|
||||||
|
),
|
||||||
|
useLocation: () => ({ pathname: "/", search: "", hash: "" }),
|
||||||
|
useNavigate: () => () => {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||||
|
|
||||||
|
function createIssue(overrides: Partial<Issue> = {}): Issue {
|
||||||
|
return {
|
||||||
|
id: "issue-1",
|
||||||
|
identifier: "PAP-904",
|
||||||
|
companyId: "company-1",
|
||||||
|
projectId: null,
|
||||||
|
projectWorkspaceId: null,
|
||||||
|
goalId: null,
|
||||||
|
parentId: null,
|
||||||
|
title: "Inbox item",
|
||||||
|
description: null,
|
||||||
|
status: "todo",
|
||||||
|
priority: "medium",
|
||||||
|
assigneeAgentId: null,
|
||||||
|
assigneeUserId: null,
|
||||||
|
createdByAgentId: null,
|
||||||
|
createdByUserId: null,
|
||||||
|
issueNumber: 904,
|
||||||
|
requestDepth: 0,
|
||||||
|
billingCode: null,
|
||||||
|
assigneeAdapterOverrides: null,
|
||||||
|
executionWorkspaceId: null,
|
||||||
|
executionWorkspacePreference: null,
|
||||||
|
executionWorkspaceSettings: null,
|
||||||
|
checkoutRunId: null,
|
||||||
|
executionRunId: null,
|
||||||
|
executionAgentNameKey: null,
|
||||||
|
executionLockedAt: null,
|
||||||
|
startedAt: null,
|
||||||
|
completedAt: null,
|
||||||
|
cancelledAt: null,
|
||||||
|
hiddenAt: null,
|
||||||
|
createdAt: new Date("2026-03-11T00:00:00.000Z"),
|
||||||
|
updatedAt: new Date("2026-03-11T00:00:00.000Z"),
|
||||||
|
labels: [],
|
||||||
|
labelIds: [],
|
||||||
|
myLastTouchAt: null,
|
||||||
|
lastExternalCommentAt: null,
|
||||||
|
isUnreadForMe: false,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("FailedRunInboxRow", () => {
|
||||||
|
let container: HTMLDivElement;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
container = document.createElement("div");
|
||||||
|
document.body.appendChild(container);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
container.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("suppresses accent hover styling when selected", () => {
|
||||||
|
const root = createRoot(container);
|
||||||
|
const run = {
|
||||||
|
id: "run-1",
|
||||||
|
companyId: "company-1",
|
||||||
|
agentId: "agent-1",
|
||||||
|
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,
|
||||||
|
errorCode: null,
|
||||||
|
externalRunId: null,
|
||||||
|
processPid: null,
|
||||||
|
processStartedAt: null,
|
||||||
|
retryOfRunId: null,
|
||||||
|
processLossRetryCount: 0,
|
||||||
|
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;
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
root.render(
|
||||||
|
<FailedRunInboxRow
|
||||||
|
run={run}
|
||||||
|
issueById={new Map()}
|
||||||
|
agentName="Agent"
|
||||||
|
issueLinkState={null}
|
||||||
|
onDismiss={() => {}}
|
||||||
|
onRetry={() => {}}
|
||||||
|
isRetrying={false}
|
||||||
|
selected
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const link = container.querySelector("a");
|
||||||
|
expect(link).not.toBeNull();
|
||||||
|
expect(link?.className).toContain("hover:bg-transparent");
|
||||||
|
expect(link?.className).not.toContain("hover:bg-accent/50");
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("InboxIssueMetaLeading", () => {
|
||||||
|
let container: HTMLDivElement;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
container = document.createElement("div");
|
||||||
|
document.body.appendChild(container);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
container.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("neutralizes selected status and live accents", () => {
|
||||||
|
const root = createRoot(container);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
root.render(<InboxIssueMetaLeading issue={createIssue()} selected isLive />);
|
||||||
|
});
|
||||||
|
|
||||||
|
const statusIcon = container.querySelector('span[class*="border-muted-foreground"]');
|
||||||
|
const liveBadge = container.querySelector('span[class*="px-1.5"][class*="bg-muted"]');
|
||||||
|
const liveBadgeLabel = Array.from(container.querySelectorAll("span")).find(
|
||||||
|
(node) => node.textContent === "Live" && node.className.includes("text-"),
|
||||||
|
);
|
||||||
|
const liveDot = container.querySelector('span[class*="bg-muted-foreground/70"]');
|
||||||
|
const pulseRing = container.querySelector('span[class*="animate-pulse"]');
|
||||||
|
|
||||||
|
expect(statusIcon).not.toBeNull();
|
||||||
|
expect(statusIcon?.className).toContain("!border-muted-foreground");
|
||||||
|
expect(statusIcon?.className).toContain("!text-muted-foreground");
|
||||||
|
expect(liveBadge).not.toBeNull();
|
||||||
|
expect(liveBadge?.className).toContain("bg-muted");
|
||||||
|
expect(liveBadgeLabel).not.toBeNull();
|
||||||
|
expect(liveBadgeLabel?.className).toContain("text-muted-foreground");
|
||||||
|
expect(liveBadgeLabel?.className).not.toContain("text-blue-600");
|
||||||
|
expect(liveDot).not.toBeNull();
|
||||||
|
expect(pulseRing).toBeNull();
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -47,9 +47,11 @@ import {
|
||||||
ACTIONABLE_APPROVAL_STATUSES,
|
ACTIONABLE_APPROVAL_STATUSES,
|
||||||
getApprovalsForTab,
|
getApprovalsForTab,
|
||||||
getInboxWorkItems,
|
getInboxWorkItems,
|
||||||
|
getInboxKeyboardSelectionIndex,
|
||||||
getLatestFailedRunsByAgent,
|
getLatestFailedRunsByAgent,
|
||||||
getRecentTouchedIssues,
|
getRecentTouchedIssues,
|
||||||
isMineInboxTab,
|
isMineInboxTab,
|
||||||
|
resolveInboxSelectionIndex,
|
||||||
InboxApprovalFilter,
|
InboxApprovalFilter,
|
||||||
saveLastInboxTab,
|
saveLastInboxTab,
|
||||||
shouldShowInboxSection,
|
shouldShowInboxSection,
|
||||||
|
|
@ -98,8 +100,69 @@ function readIssueIdFromRun(run: HeartbeatRun): string | null {
|
||||||
|
|
||||||
|
|
||||||
type NonIssueUnreadState = "visible" | "fading" | "hidden" | null;
|
type NonIssueUnreadState = "visible" | "fading" | "hidden" | null;
|
||||||
|
const selectedInboxAccentClass = "!text-muted-foreground !border-muted-foreground";
|
||||||
|
|
||||||
function FailedRunInboxRow({
|
function getSelectedUnreadButtonClass(selected: boolean): string {
|
||||||
|
return selected ? "hover:bg-muted/80" : "hover:bg-blue-500/20";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSelectedUnreadDotClass(selected: boolean): string {
|
||||||
|
return selected ? "bg-muted-foreground/70" : "bg-blue-600 dark:bg-blue-400";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InboxIssueMetaLeading({
|
||||||
|
issue,
|
||||||
|
selected,
|
||||||
|
isLive,
|
||||||
|
}: {
|
||||||
|
issue: Issue;
|
||||||
|
selected: boolean;
|
||||||
|
isLive: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<span className="hidden shrink-0 sm:inline-flex">
|
||||||
|
<StatusIcon
|
||||||
|
status={issue.status}
|
||||||
|
className={selected ? selectedInboxAccentClass : undefined}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<span className="shrink-0 font-mono text-xs text-muted-foreground">
|
||||||
|
{issue.identifier ?? issue.id.slice(0, 8)}
|
||||||
|
</span>
|
||||||
|
{isLive && (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center gap-1 rounded-full px-1.5 py-0.5 sm:gap-1.5 sm:px-2",
|
||||||
|
selected ? "bg-muted" : "bg-blue-500/10",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="relative flex h-2 w-2">
|
||||||
|
{!selected ? (
|
||||||
|
<span className="absolute inline-flex h-full w-full animate-pulse rounded-full bg-blue-400 opacity-75" />
|
||||||
|
) : null}
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"relative inline-flex h-2 w-2 rounded-full",
|
||||||
|
selected ? "bg-muted-foreground/70" : "bg-blue-500",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"hidden text-[11px] font-medium sm:inline",
|
||||||
|
selected ? "text-muted-foreground" : "text-blue-600 dark:text-blue-400",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Live
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FailedRunInboxRow({
|
||||||
run,
|
run,
|
||||||
issueById,
|
issueById,
|
||||||
agentName: linkedAgentName,
|
agentName: linkedAgentName,
|
||||||
|
|
@ -111,6 +174,7 @@ function FailedRunInboxRow({
|
||||||
onMarkRead,
|
onMarkRead,
|
||||||
onArchive,
|
onArchive,
|
||||||
archiveDisabled,
|
archiveDisabled,
|
||||||
|
selected = false,
|
||||||
className,
|
className,
|
||||||
}: {
|
}: {
|
||||||
run: HeartbeatRun;
|
run: HeartbeatRun;
|
||||||
|
|
@ -124,6 +188,7 @@ function FailedRunInboxRow({
|
||||||
onMarkRead?: () => void;
|
onMarkRead?: () => void;
|
||||||
onArchive?: () => void;
|
onArchive?: () => void;
|
||||||
archiveDisabled?: boolean;
|
archiveDisabled?: boolean;
|
||||||
|
selected?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
const issueId = readIssueIdFromRun(run);
|
const issueId = readIssueIdFromRun(run);
|
||||||
|
|
@ -144,11 +209,15 @@ function FailedRunInboxRow({
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onMarkRead}
|
onClick={onMarkRead}
|
||||||
className="inline-flex h-4 w-4 items-center justify-center rounded-full transition-colors hover:bg-blue-500/20"
|
className={cn(
|
||||||
|
"inline-flex h-4 w-4 items-center justify-center rounded-full transition-colors",
|
||||||
|
getSelectedUnreadButtonClass(selected),
|
||||||
|
)}
|
||||||
aria-label="Mark as read"
|
aria-label="Mark as read"
|
||||||
>
|
>
|
||||||
<span className={cn(
|
<span className={cn(
|
||||||
"block h-2 w-2 rounded-full bg-blue-600 transition-opacity duration-300 dark:bg-blue-400",
|
"block h-2 w-2 rounded-full transition-opacity duration-300",
|
||||||
|
getSelectedUnreadDotClass(selected),
|
||||||
unreadState === "fading" ? "opacity-0" : "opacity-100",
|
unreadState === "fading" ? "opacity-0" : "opacity-100",
|
||||||
)} />
|
)} />
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -169,7 +238,10 @@ function FailedRunInboxRow({
|
||||||
) : null}
|
) : null}
|
||||||
<Link
|
<Link
|
||||||
to={`/agents/${run.agentId}/runs/${run.id}`}
|
to={`/agents/${run.agentId}/runs/${run.id}`}
|
||||||
className="flex min-w-0 flex-1 items-start gap-2 no-underline text-inherit transition-colors hover:bg-accent/50"
|
className={cn(
|
||||||
|
"flex min-w-0 flex-1 items-start gap-2 no-underline text-inherit transition-colors",
|
||||||
|
selected ? "hover:bg-transparent" : "hover:bg-accent/50",
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{!showUnreadSlot && <span className="hidden h-2 w-2 shrink-0 sm:inline-flex" aria-hidden="true" />}
|
{!showUnreadSlot && <span className="hidden h-2 w-2 shrink-0 sm:inline-flex" aria-hidden="true" />}
|
||||||
<span className="hidden h-3.5 w-3.5 shrink-0 sm:inline-flex" aria-hidden="true" />
|
<span className="hidden h-3.5 w-3.5 shrink-0 sm:inline-flex" aria-hidden="true" />
|
||||||
|
|
@ -258,6 +330,7 @@ function ApprovalInboxRow({
|
||||||
onMarkRead,
|
onMarkRead,
|
||||||
onArchive,
|
onArchive,
|
||||||
archiveDisabled,
|
archiveDisabled,
|
||||||
|
selected = false,
|
||||||
className,
|
className,
|
||||||
}: {
|
}: {
|
||||||
approval: Approval;
|
approval: Approval;
|
||||||
|
|
@ -269,6 +342,7 @@ function ApprovalInboxRow({
|
||||||
onMarkRead?: () => void;
|
onMarkRead?: () => void;
|
||||||
onArchive?: () => void;
|
onArchive?: () => void;
|
||||||
archiveDisabled?: boolean;
|
archiveDisabled?: boolean;
|
||||||
|
selected?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
const Icon = typeIcon[approval.type] ?? defaultTypeIcon;
|
const Icon = typeIcon[approval.type] ?? defaultTypeIcon;
|
||||||
|
|
@ -291,11 +365,15 @@ function ApprovalInboxRow({
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onMarkRead}
|
onClick={onMarkRead}
|
||||||
className="inline-flex h-4 w-4 items-center justify-center rounded-full transition-colors hover:bg-blue-500/20"
|
className={cn(
|
||||||
|
"inline-flex h-4 w-4 items-center justify-center rounded-full transition-colors",
|
||||||
|
getSelectedUnreadButtonClass(selected),
|
||||||
|
)}
|
||||||
aria-label="Mark as read"
|
aria-label="Mark as read"
|
||||||
>
|
>
|
||||||
<span className={cn(
|
<span className={cn(
|
||||||
"block h-2 w-2 rounded-full bg-blue-600 transition-opacity duration-300 dark:bg-blue-400",
|
"block h-2 w-2 rounded-full transition-opacity duration-300",
|
||||||
|
getSelectedUnreadDotClass(selected),
|
||||||
unreadState === "fading" ? "opacity-0" : "opacity-100",
|
unreadState === "fading" ? "opacity-0" : "opacity-100",
|
||||||
)} />
|
)} />
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -316,7 +394,10 @@ function ApprovalInboxRow({
|
||||||
) : null}
|
) : null}
|
||||||
<Link
|
<Link
|
||||||
to={`/approvals/${approval.id}`}
|
to={`/approvals/${approval.id}`}
|
||||||
className="flex min-w-0 flex-1 items-start gap-2 no-underline text-inherit transition-colors hover:bg-accent/50"
|
className={cn(
|
||||||
|
"flex min-w-0 flex-1 items-start gap-2 no-underline text-inherit transition-colors",
|
||||||
|
selected ? "hover:bg-transparent" : "hover:bg-accent/50",
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{!showUnreadSlot && <span className="hidden h-2 w-2 shrink-0 sm:inline-flex" aria-hidden="true" />}
|
{!showUnreadSlot && <span className="hidden h-2 w-2 shrink-0 sm:inline-flex" aria-hidden="true" />}
|
||||||
<span className="hidden h-3.5 w-3.5 shrink-0 sm:inline-flex" aria-hidden="true" />
|
<span className="hidden h-3.5 w-3.5 shrink-0 sm:inline-flex" aria-hidden="true" />
|
||||||
|
|
@ -390,6 +471,7 @@ function JoinRequestInboxRow({
|
||||||
onMarkRead,
|
onMarkRead,
|
||||||
onArchive,
|
onArchive,
|
||||||
archiveDisabled,
|
archiveDisabled,
|
||||||
|
selected = false,
|
||||||
className,
|
className,
|
||||||
}: {
|
}: {
|
||||||
joinRequest: JoinRequest;
|
joinRequest: JoinRequest;
|
||||||
|
|
@ -400,6 +482,7 @@ function JoinRequestInboxRow({
|
||||||
onMarkRead?: () => void;
|
onMarkRead?: () => void;
|
||||||
onArchive?: () => void;
|
onArchive?: () => void;
|
||||||
archiveDisabled?: boolean;
|
archiveDisabled?: boolean;
|
||||||
|
selected?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
const label =
|
const label =
|
||||||
|
|
@ -421,11 +504,15 @@ function JoinRequestInboxRow({
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onMarkRead}
|
onClick={onMarkRead}
|
||||||
className="inline-flex h-4 w-4 items-center justify-center rounded-full transition-colors hover:bg-blue-500/20"
|
className={cn(
|
||||||
|
"inline-flex h-4 w-4 items-center justify-center rounded-full transition-colors",
|
||||||
|
getSelectedUnreadButtonClass(selected),
|
||||||
|
)}
|
||||||
aria-label="Mark as read"
|
aria-label="Mark as read"
|
||||||
>
|
>
|
||||||
<span className={cn(
|
<span className={cn(
|
||||||
"block h-2 w-2 rounded-full bg-blue-600 transition-opacity duration-300 dark:bg-blue-400",
|
"block h-2 w-2 rounded-full transition-opacity duration-300",
|
||||||
|
getSelectedUnreadDotClass(selected),
|
||||||
unreadState === "fading" ? "opacity-0" : "opacity-100",
|
unreadState === "fading" ? "opacity-0" : "opacity-100",
|
||||||
)} />
|
)} />
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -928,23 +1015,62 @@ export function Inbox() {
|
||||||
return `join:${item.joinRequest.id}`;
|
return `join:${item.joinRequest.id}`;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Reset selection when the list changes
|
// Keep selection valid when the list shape changes, but do not auto-select on initial load.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSelectedIndex((prev) =>
|
setSelectedIndex((prev) => resolveInboxSelectionIndex(prev, workItemsToRender.length));
|
||||||
prev >= workItemsToRender.length ? workItemsToRender.length - 1 : prev,
|
|
||||||
);
|
|
||||||
}, [workItemsToRender.length]);
|
}, [workItemsToRender.length]);
|
||||||
|
|
||||||
// Keyboard shortcuts (mail-client style)
|
// Use refs for keyboard handler to avoid stale closures
|
||||||
|
const kbStateRef = useRef({
|
||||||
|
workItems: workItemsToRender,
|
||||||
|
selectedIndex,
|
||||||
|
canArchive: canArchiveFromTab,
|
||||||
|
archivingIssueIds,
|
||||||
|
archivingNonIssueIds,
|
||||||
|
fadingOutIssues,
|
||||||
|
readItems,
|
||||||
|
});
|
||||||
|
kbStateRef.current = {
|
||||||
|
workItems: workItemsToRender,
|
||||||
|
selectedIndex,
|
||||||
|
canArchive: canArchiveFromTab,
|
||||||
|
archivingIssueIds,
|
||||||
|
archivingNonIssueIds,
|
||||||
|
fadingOutIssues,
|
||||||
|
readItems,
|
||||||
|
};
|
||||||
|
|
||||||
|
const kbActionsRef = useRef({
|
||||||
|
archiveIssue: (id: string) => archiveIssueMutation.mutate(id),
|
||||||
|
archiveNonIssue: handleArchiveNonIssue,
|
||||||
|
markRead: (id: string) => markReadMutation.mutate(id),
|
||||||
|
markUnreadIssue: (id: string) => markUnreadMutation.mutate(id),
|
||||||
|
markNonIssueRead: handleMarkNonIssueRead,
|
||||||
|
markNonIssueUnread: markItemUnread,
|
||||||
|
navigate,
|
||||||
|
});
|
||||||
|
kbActionsRef.current = {
|
||||||
|
archiveIssue: (id: string) => archiveIssueMutation.mutate(id),
|
||||||
|
archiveNonIssue: handleArchiveNonIssue,
|
||||||
|
markRead: (id: string) => markReadMutation.mutate(id),
|
||||||
|
markUnreadIssue: (id: string) => markUnreadMutation.mutate(id),
|
||||||
|
markNonIssueRead: handleMarkNonIssueRead,
|
||||||
|
markNonIssueUnread: markItemUnread,
|
||||||
|
navigate,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Keyboard shortcuts (mail-client style) — single stable listener using refs
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.defaultPrevented) return;
|
||||||
|
|
||||||
// Don't capture when typing in inputs/textareas or with modifier keys
|
// Don't capture when typing in inputs/textareas or with modifier keys
|
||||||
const target = e.target as HTMLElement;
|
const target = e.target;
|
||||||
if (
|
if (
|
||||||
target.tagName === "INPUT" ||
|
!(target instanceof HTMLElement) ||
|
||||||
target.tagName === "TEXTAREA" ||
|
target.closest("input, textarea, select, [contenteditable='true'], [role='textbox'], [role='combobox']") ||
|
||||||
target.tagName === "SELECT" ||
|
|
||||||
target.isContentEditable ||
|
target.isContentEditable ||
|
||||||
|
document.querySelector("[role='dialog'], [aria-modal='true']") ||
|
||||||
e.metaKey ||
|
e.metaKey ||
|
||||||
e.ctrlKey ||
|
e.ctrlKey ||
|
||||||
e.altKey
|
e.altKey
|
||||||
|
|
@ -952,79 +1078,81 @@ export function Inbox() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keyboard shortcuts are only active on the "mine" tab
|
const st = kbStateRef.current;
|
||||||
if (!canArchiveFromTab) return;
|
const act = kbActionsRef.current;
|
||||||
|
|
||||||
const itemCount = workItemsToRender.length;
|
// Keyboard shortcuts are only active on the "mine" tab
|
||||||
|
if (!st.canArchive) return;
|
||||||
|
|
||||||
|
const itemCount = st.workItems.length;
|
||||||
if (itemCount === 0) return;
|
if (itemCount === 0) return;
|
||||||
|
|
||||||
switch (e.key) {
|
switch (e.key) {
|
||||||
case "j": {
|
case "j": {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setSelectedIndex((prev) => Math.min(prev + 1, itemCount - 1));
|
setSelectedIndex((prev) => getInboxKeyboardSelectionIndex(prev, itemCount, "next"));
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "k": {
|
case "k": {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setSelectedIndex((prev) => Math.max(prev - 1, 0));
|
setSelectedIndex((prev) => getInboxKeyboardSelectionIndex(prev, itemCount, "previous"));
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "a":
|
case "a":
|
||||||
case "y": {
|
case "y": {
|
||||||
if (selectedIndex < 0 || selectedIndex >= itemCount) return;
|
if (st.selectedIndex < 0 || st.selectedIndex >= itemCount) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const item = workItemsToRender[selectedIndex];
|
const item = st.workItems[st.selectedIndex];
|
||||||
const key = getWorkItemKey(item);
|
|
||||||
if (item.kind === "issue") {
|
if (item.kind === "issue") {
|
||||||
if (!archivingIssueIds.has(item.issue.id)) {
|
if (!st.archivingIssueIds.has(item.issue.id)) {
|
||||||
archiveIssueMutation.mutate(item.issue.id);
|
act.archiveIssue(item.issue.id);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (!archivingNonIssueIds.has(key)) {
|
const key = getWorkItemKey(item);
|
||||||
handleArchiveNonIssue(key);
|
if (!st.archivingNonIssueIds.has(key)) {
|
||||||
|
act.archiveNonIssue(key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "U": {
|
case "U": {
|
||||||
if (selectedIndex < 0 || selectedIndex >= itemCount) return;
|
if (st.selectedIndex < 0 || st.selectedIndex >= itemCount) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const item = workItemsToRender[selectedIndex];
|
const item = st.workItems[st.selectedIndex];
|
||||||
if (item.kind === "issue") {
|
if (item.kind === "issue") {
|
||||||
markUnreadMutation.mutate(item.issue.id);
|
act.markUnreadIssue(item.issue.id);
|
||||||
} else {
|
} else {
|
||||||
const key = getWorkItemKey(item);
|
act.markNonIssueUnread(getWorkItemKey(item));
|
||||||
markItemUnread(key);
|
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "r": {
|
case "r": {
|
||||||
if (selectedIndex < 0 || selectedIndex >= itemCount) return;
|
if (st.selectedIndex < 0 || st.selectedIndex >= itemCount) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const item = workItemsToRender[selectedIndex];
|
const item = st.workItems[st.selectedIndex];
|
||||||
if (item.kind === "issue") {
|
if (item.kind === "issue") {
|
||||||
if (item.issue.isUnreadForMe && !fadingOutIssues.has(item.issue.id)) {
|
if (item.issue.isUnreadForMe && !st.fadingOutIssues.has(item.issue.id)) {
|
||||||
markReadMutation.mutate(item.issue.id);
|
act.markRead(item.issue.id);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const key = getWorkItemKey(item);
|
const key = getWorkItemKey(item);
|
||||||
if (!readItems.has(key)) {
|
if (!st.readItems.has(key)) {
|
||||||
handleMarkNonIssueRead(key);
|
act.markNonIssueRead(key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "Enter": {
|
case "Enter": {
|
||||||
if (selectedIndex < 0 || selectedIndex >= itemCount) return;
|
if (st.selectedIndex < 0 || st.selectedIndex >= itemCount) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const item = workItemsToRender[selectedIndex];
|
const item = st.workItems[st.selectedIndex];
|
||||||
if (item.kind === "issue") {
|
if (item.kind === "issue") {
|
||||||
const pathId = item.issue.identifier ?? item.issue.id;
|
const pathId = item.issue.identifier ?? item.issue.id;
|
||||||
navigate(createIssueDetailPath(pathId, issueLinkState), { state: issueLinkState });
|
act.navigate(createIssueDetailPath(pathId, issueLinkState), { state: issueLinkState });
|
||||||
} else if (item.kind === "approval") {
|
} else if (item.kind === "approval") {
|
||||||
navigate(`/approvals/${item.approval.id}`);
|
act.navigate(`/approvals/${item.approval.id}`);
|
||||||
} else if (item.kind === "failed_run") {
|
} else if (item.kind === "failed_run") {
|
||||||
navigate(`/agents/${item.run.agentId}/runs/${item.run.id}`);
|
act.navigate(`/agents/${item.run.agentId}/runs/${item.run.id}`);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
@ -1034,13 +1162,7 @@ export function Inbox() {
|
||||||
};
|
};
|
||||||
window.addEventListener("keydown", handleKeyDown);
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||||
}, [
|
}, [getWorkItemKey, issueLinkState]);
|
||||||
workItemsToRender, selectedIndex, canArchiveFromTab, navigate, issueLinkState,
|
|
||||||
getWorkItemKey, archivingIssueIds, archivingNonIssueIds,
|
|
||||||
fadingOutIssues, readItems,
|
|
||||||
archiveIssueMutation, markReadMutation, markUnreadMutation,
|
|
||||||
handleArchiveNonIssue, handleMarkNonIssueRead, markItemUnread,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Scroll selected item into view
|
// Scroll selected item into view
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -1198,7 +1320,7 @@ export function Inbox() {
|
||||||
<div
|
<div
|
||||||
key={`sel-${key}`}
|
key={`sel-${key}`}
|
||||||
data-inbox-item
|
data-inbox-item
|
||||||
className={isSelected ? "bg-accent" : ""}
|
className="relative"
|
||||||
onClick={() => setSelectedIndex(index)}
|
onClick={() => setSelectedIndex(index)}
|
||||||
>
|
>
|
||||||
{child}
|
{child}
|
||||||
|
|
@ -1230,6 +1352,7 @@ export function Inbox() {
|
||||||
<ApprovalInboxRow
|
<ApprovalInboxRow
|
||||||
key={approvalKey}
|
key={approvalKey}
|
||||||
approval={item.approval}
|
approval={item.approval}
|
||||||
|
selected={isSelected}
|
||||||
requesterName={agentName(item.approval.requestedByAgentId)}
|
requesterName={agentName(item.approval.requestedByAgentId)}
|
||||||
onApprove={() => approveMutation.mutate(item.approval.id)}
|
onApprove={() => approveMutation.mutate(item.approval.id)}
|
||||||
onReject={() => rejectMutation.mutate(item.approval.id)}
|
onReject={() => rejectMutation.mutate(item.approval.id)}
|
||||||
|
|
@ -1248,6 +1371,7 @@ export function Inbox() {
|
||||||
elements.push(wrapItem(approvalKey, isSelected, canArchiveFromTab ? (
|
elements.push(wrapItem(approvalKey, isSelected, canArchiveFromTab ? (
|
||||||
<SwipeToArchive
|
<SwipeToArchive
|
||||||
key={approvalKey}
|
key={approvalKey}
|
||||||
|
selected={isSelected}
|
||||||
disabled={isArchiving}
|
disabled={isArchiving}
|
||||||
onArchive={() => handleArchiveNonIssue(approvalKey)}
|
onArchive={() => handleArchiveNonIssue(approvalKey)}
|
||||||
>
|
>
|
||||||
|
|
@ -1264,6 +1388,7 @@ export function Inbox() {
|
||||||
<FailedRunInboxRow
|
<FailedRunInboxRow
|
||||||
key={runKey}
|
key={runKey}
|
||||||
run={item.run}
|
run={item.run}
|
||||||
|
selected={isSelected}
|
||||||
issueById={issueById}
|
issueById={issueById}
|
||||||
agentName={agentName(item.run.agentId)}
|
agentName={agentName(item.run.agentId)}
|
||||||
issueLinkState={issueLinkState}
|
issueLinkState={issueLinkState}
|
||||||
|
|
@ -1284,6 +1409,7 @@ export function Inbox() {
|
||||||
elements.push(wrapItem(runKey, isSelected, canArchiveFromTab ? (
|
elements.push(wrapItem(runKey, isSelected, canArchiveFromTab ? (
|
||||||
<SwipeToArchive
|
<SwipeToArchive
|
||||||
key={runKey}
|
key={runKey}
|
||||||
|
selected={isSelected}
|
||||||
disabled={isArchiving}
|
disabled={isArchiving}
|
||||||
onArchive={() => handleArchiveNonIssue(runKey)}
|
onArchive={() => handleArchiveNonIssue(runKey)}
|
||||||
>
|
>
|
||||||
|
|
@ -1300,6 +1426,7 @@ export function Inbox() {
|
||||||
<JoinRequestInboxRow
|
<JoinRequestInboxRow
|
||||||
key={joinKey}
|
key={joinKey}
|
||||||
joinRequest={item.joinRequest}
|
joinRequest={item.joinRequest}
|
||||||
|
selected={isSelected}
|
||||||
onApprove={() => approveJoinMutation.mutate(item.joinRequest)}
|
onApprove={() => approveJoinMutation.mutate(item.joinRequest)}
|
||||||
onReject={() => rejectJoinMutation.mutate(item.joinRequest)}
|
onReject={() => rejectJoinMutation.mutate(item.joinRequest)}
|
||||||
isPending={approveJoinMutation.isPending || rejectJoinMutation.isPending}
|
isPending={approveJoinMutation.isPending || rejectJoinMutation.isPending}
|
||||||
|
|
@ -1317,6 +1444,7 @@ export function Inbox() {
|
||||||
elements.push(wrapItem(joinKey, isSelected, canArchiveFromTab ? (
|
elements.push(wrapItem(joinKey, isSelected, canArchiveFromTab ? (
|
||||||
<SwipeToArchive
|
<SwipeToArchive
|
||||||
key={joinKey}
|
key={joinKey}
|
||||||
|
selected={isSelected}
|
||||||
disabled={isArchiving}
|
disabled={isArchiving}
|
||||||
onArchive={() => handleArchiveNonIssue(joinKey)}
|
onArchive={() => handleArchiveNonIssue(joinKey)}
|
||||||
>
|
>
|
||||||
|
|
@ -1335,32 +1463,19 @@ export function Inbox() {
|
||||||
key={`issue:${issue.id}`}
|
key={`issue:${issue.id}`}
|
||||||
issue={issue}
|
issue={issue}
|
||||||
issueLinkState={issueLinkState}
|
issueLinkState={issueLinkState}
|
||||||
|
selected={isSelected}
|
||||||
className={
|
className={
|
||||||
isArchiving
|
isArchiving
|
||||||
? "pointer-events-none -translate-x-4 scale-[0.98] opacity-0 transition-all duration-200 ease-out"
|
? "pointer-events-none -translate-x-4 scale-[0.98] opacity-0 transition-all duration-200 ease-out"
|
||||||
: "transition-all duration-200 ease-out"
|
: "transition-all duration-200 ease-out"
|
||||||
}
|
}
|
||||||
desktopMetaLeading={(
|
desktopMetaLeading={
|
||||||
<>
|
<InboxIssueMetaLeading
|
||||||
<span className="hidden shrink-0 sm:inline-flex">
|
issue={issue}
|
||||||
<StatusIcon status={issue.status} />
|
selected={isSelected}
|
||||||
</span>
|
isLive={liveIssueIds.has(issue.id)}
|
||||||
<span className="shrink-0 font-mono text-xs text-muted-foreground">
|
/>
|
||||||
{issue.identifier ?? issue.id.slice(0, 8)}
|
}
|
||||||
</span>
|
|
||||||
{liveIssueIds.has(issue.id) && (
|
|
||||||
<span className="inline-flex items-center gap-1 rounded-full bg-blue-500/10 px-1.5 py-0.5 sm:gap-1.5 sm:px-2">
|
|
||||||
<span className="relative flex h-2 w-2">
|
|
||||||
<span className="absolute inline-flex h-full w-full animate-pulse rounded-full bg-blue-400 opacity-75" />
|
|
||||||
<span className="relative inline-flex h-2 w-2 rounded-full bg-blue-500" />
|
|
||||||
</span>
|
|
||||||
<span className="hidden text-[11px] font-medium text-blue-600 dark:text-blue-400 sm:inline">
|
|
||||||
Live
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
mobileMeta={
|
mobileMeta={
|
||||||
issue.lastExternalCommentAt
|
issue.lastExternalCommentAt
|
||||||
? `commented ${timeAgo(issue.lastExternalCommentAt)}`
|
? `commented ${timeAgo(issue.lastExternalCommentAt)}`
|
||||||
|
|
@ -1387,6 +1502,7 @@ export function Inbox() {
|
||||||
elements.push(wrapItem(`issue:${issue.id}`, isSelected, canArchiveFromTab ? (
|
elements.push(wrapItem(`issue:${issue.id}`, isSelected, canArchiveFromTab ? (
|
||||||
<SwipeToArchive
|
<SwipeToArchive
|
||||||
key={`issue:${issue.id}`}
|
key={`issue:${issue.id}`}
|
||||||
|
selected={isSelected}
|
||||||
disabled={isArchiving || archiveIssueMutation.isPending}
|
disabled={isArchiving || archiveIssueMutation.isPending}
|
||||||
onArchive={() => archiveIssueMutation.mutate(issue.id)}
|
onArchive={() => archiveIssueMutation.mutate(issue.id)}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -291,7 +291,6 @@ export function IssueDetail() {
|
||||||
),
|
),
|
||||||
[activeRun, liveRuns],
|
[activeRun, liveRuns],
|
||||||
);
|
);
|
||||||
const hasRunningIssueRun = Boolean(runningIssueRun);
|
|
||||||
const sourceBreadcrumb = useMemo(
|
const sourceBreadcrumb = useMemo(
|
||||||
() => readIssueDetailBreadcrumb(location.state, location.search) ?? { label: "Issues", href: "/issues" },
|
() => readIssueDetailBreadcrumb(location.state, location.search) ?? { label: "Issues", href: "/issues" },
|
||||||
[location.state, location.search],
|
[location.state, location.search],
|
||||||
|
|
@ -1246,17 +1245,16 @@ export function IssueDetail() {
|
||||||
currentAssigneeValue={actualAssigneeValue}
|
currentAssigneeValue={actualAssigneeValue}
|
||||||
suggestedAssigneeValue={suggestedAssigneeValue}
|
suggestedAssigneeValue={suggestedAssigneeValue}
|
||||||
mentions={mentionOptions}
|
mentions={mentionOptions}
|
||||||
interruptAvailable={hasRunningIssueRun}
|
|
||||||
onInterruptQueued={async (runId) => {
|
onInterruptQueued={async (runId) => {
|
||||||
await interruptQueuedComment.mutateAsync(runId);
|
await interruptQueuedComment.mutateAsync(runId);
|
||||||
}}
|
}}
|
||||||
interruptingQueuedRunId={interruptQueuedComment.isPending ? runningIssueRun?.id ?? null : null}
|
interruptingQueuedRunId={interruptQueuedComment.isPending ? runningIssueRun?.id ?? null : null}
|
||||||
onAdd={async (body, reopen, reassignment, interrupt) => {
|
onAdd={async (body, reopen, reassignment) => {
|
||||||
if (reassignment) {
|
if (reassignment) {
|
||||||
await addCommentAndReassign.mutateAsync({ body, reopen, reassignment, interrupt });
|
await addCommentAndReassign.mutateAsync({ body, reopen, reassignment });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await addComment.mutateAsync({ body, reopen, interrupt });
|
await addComment.mutateAsync({ body, reopen });
|
||||||
}}
|
}}
|
||||||
imageUploadHandler={async (file) => {
|
imageUploadHandler={async (file) => {
|
||||||
const attachment = await uploadAttachment.mutateAsync(file);
|
const attachment = await uploadAttachment.mutateAsync(file);
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue