Compare commits
No commits in common. "b1b3408efab9a97aebd9b194c5a07cabfbabe4e4" and "74687553f37532ee4542754926088e01a70b71f0" have entirely different histories.
b1b3408efa
...
74687553f3
12 changed files with 118 additions and 574 deletions
|
|
@ -40,7 +40,12 @@ interface CommentThreadProps {
|
||||||
linkedRuns?: LinkedRunItem[];
|
linkedRuns?: LinkedRunItem[];
|
||||||
companyId?: string | null;
|
companyId?: string | null;
|
||||||
projectId?: string | null;
|
projectId?: string | null;
|
||||||
onAdd: (body: string, reopen?: boolean, reassignment?: CommentReassignment) => Promise<void>;
|
onAdd: (
|
||||||
|
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>;
|
||||||
|
|
@ -53,6 +58,7 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
@ -323,11 +329,13 @@ 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;
|
||||||
|
|
@ -397,6 +405,14 @@ 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;
|
||||||
|
|
@ -423,10 +439,11 @@ export function CommentThread({
|
||||||
|
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
try {
|
try {
|
||||||
await onAdd(trimmed, reopen ? true : undefined, reassignment ?? undefined);
|
await onAdd(trimmed, reopen ? true : undefined, reassignment ?? undefined, interrupt ? true : 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.
|
||||||
|
|
@ -547,6 +564,17 @@ 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,
|
||||||
MouseSensor,
|
PointerSensor,
|
||||||
useSensor,
|
useSensor,
|
||||||
useSensors,
|
useSensors,
|
||||||
type DragEndEvent,
|
type DragEndEvent,
|
||||||
|
|
@ -244,8 +244,7 @@ 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(
|
||||||
// Keep sidebar reordering mouse-only so touch input can scroll/tap without drag affordances.
|
useSensor(PointerSensor, {
|
||||||
useSensor(MouseSensor, {
|
|
||||||
activationConstraint: { distance: 8 },
|
activationConstraint: { distance: 8 },
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,116 +0,0 @@
|
||||||
// @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,7 +11,6 @@ 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;
|
||||||
|
|
@ -28,7 +27,6 @@ interface IssueRowProps {
|
||||||
export function IssueRow({
|
export function IssueRow({
|
||||||
issue,
|
issue,
|
||||||
issueLinkState,
|
issueLinkState,
|
||||||
selected = false,
|
|
||||||
mobileLeading,
|
mobileLeading,
|
||||||
desktopMetaLeading,
|
desktopMetaLeading,
|
||||||
desktopLeadingSpacer = false,
|
desktopLeadingSpacer = false,
|
||||||
|
|
@ -45,21 +43,18 @@ 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 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 hover:bg-accent/50 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} className={selectedStatusClass} />}
|
{mobileLeading ?? <StatusIcon status={issue.status} />}
|
||||||
</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">
|
||||||
|
|
@ -72,7 +67,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} className={selectedStatusClass} />
|
<StatusIcon status={issue.status} />
|
||||||
</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}
|
||||||
|
|
@ -114,16 +109,12 @@ export function IssueRow({
|
||||||
onMarkRead?.();
|
onMarkRead?.();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className={cn(
|
className="inline-flex h-4 w-4 items-center justify-center rounded-full transition-colors hover:bg-blue-500/20"
|
||||||
"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 transition-opacity duration-300",
|
"block h-2 w-2 rounded-full bg-blue-600 transition-opacity duration-300 dark:bg-blue-400",
|
||||||
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,
|
||||||
MouseSensor,
|
PointerSensor,
|
||||||
closestCenter,
|
closestCenter,
|
||||||
type DragEndEvent,
|
type DragEndEvent,
|
||||||
useSensor,
|
useSensor,
|
||||||
|
|
@ -153,8 +153,7 @@ 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(
|
||||||
// Project reordering is intentionally desktop-only; touch should remain tap/scroll behavior.
|
useSensor(PointerSensor, {
|
||||||
useSensor(MouseSensor, {
|
|
||||||
activationConstraint: { distance: 8 },
|
activationConstraint: { distance: 8 },
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -122,25 +122,4 @@ 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,20 +6,17 @@ 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);
|
||||||
|
|
@ -151,12 +148,10 @@ 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,13 +6,11 @@ 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";
|
||||||
|
|
@ -410,17 +408,4 @@ 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,27 +102,6 @@ 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(),
|
||||||
|
|
|
||||||
|
|
@ -1,181 +0,0 @@
|
||||||
// @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,11 +47,9 @@ 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,
|
||||||
|
|
@ -100,69 +98,8 @@ 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 getSelectedUnreadButtonClass(selected: boolean): string {
|
function FailedRunInboxRow({
|
||||||
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,
|
||||||
|
|
@ -174,7 +111,6 @@ export function FailedRunInboxRow({
|
||||||
onMarkRead,
|
onMarkRead,
|
||||||
onArchive,
|
onArchive,
|
||||||
archiveDisabled,
|
archiveDisabled,
|
||||||
selected = false,
|
|
||||||
className,
|
className,
|
||||||
}: {
|
}: {
|
||||||
run: HeartbeatRun;
|
run: HeartbeatRun;
|
||||||
|
|
@ -188,7 +124,6 @@ export 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);
|
||||||
|
|
@ -209,15 +144,11 @@ export function FailedRunInboxRow({
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onMarkRead}
|
onClick={onMarkRead}
|
||||||
className={cn(
|
className="inline-flex h-4 w-4 items-center justify-center rounded-full transition-colors hover:bg-blue-500/20"
|
||||||
"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 transition-opacity duration-300",
|
"block h-2 w-2 rounded-full bg-blue-600 transition-opacity duration-300 dark:bg-blue-400",
|
||||||
getSelectedUnreadDotClass(selected),
|
|
||||||
unreadState === "fading" ? "opacity-0" : "opacity-100",
|
unreadState === "fading" ? "opacity-0" : "opacity-100",
|
||||||
)} />
|
)} />
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -238,10 +169,7 @@ export function FailedRunInboxRow({
|
||||||
) : null}
|
) : null}
|
||||||
<Link
|
<Link
|
||||||
to={`/agents/${run.agentId}/runs/${run.id}`}
|
to={`/agents/${run.agentId}/runs/${run.id}`}
|
||||||
className={cn(
|
className="flex min-w-0 flex-1 items-start gap-2 no-underline text-inherit transition-colors hover:bg-accent/50"
|
||||||
"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" />
|
||||||
|
|
@ -330,7 +258,6 @@ function ApprovalInboxRow({
|
||||||
onMarkRead,
|
onMarkRead,
|
||||||
onArchive,
|
onArchive,
|
||||||
archiveDisabled,
|
archiveDisabled,
|
||||||
selected = false,
|
|
||||||
className,
|
className,
|
||||||
}: {
|
}: {
|
||||||
approval: Approval;
|
approval: Approval;
|
||||||
|
|
@ -342,7 +269,6 @@ 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;
|
||||||
|
|
@ -365,15 +291,11 @@ function ApprovalInboxRow({
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onMarkRead}
|
onClick={onMarkRead}
|
||||||
className={cn(
|
className="inline-flex h-4 w-4 items-center justify-center rounded-full transition-colors hover:bg-blue-500/20"
|
||||||
"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 transition-opacity duration-300",
|
"block h-2 w-2 rounded-full bg-blue-600 transition-opacity duration-300 dark:bg-blue-400",
|
||||||
getSelectedUnreadDotClass(selected),
|
|
||||||
unreadState === "fading" ? "opacity-0" : "opacity-100",
|
unreadState === "fading" ? "opacity-0" : "opacity-100",
|
||||||
)} />
|
)} />
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -394,10 +316,7 @@ function ApprovalInboxRow({
|
||||||
) : null}
|
) : null}
|
||||||
<Link
|
<Link
|
||||||
to={`/approvals/${approval.id}`}
|
to={`/approvals/${approval.id}`}
|
||||||
className={cn(
|
className="flex min-w-0 flex-1 items-start gap-2 no-underline text-inherit transition-colors hover:bg-accent/50"
|
||||||
"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" />
|
||||||
|
|
@ -471,7 +390,6 @@ function JoinRequestInboxRow({
|
||||||
onMarkRead,
|
onMarkRead,
|
||||||
onArchive,
|
onArchive,
|
||||||
archiveDisabled,
|
archiveDisabled,
|
||||||
selected = false,
|
|
||||||
className,
|
className,
|
||||||
}: {
|
}: {
|
||||||
joinRequest: JoinRequest;
|
joinRequest: JoinRequest;
|
||||||
|
|
@ -482,7 +400,6 @@ function JoinRequestInboxRow({
|
||||||
onMarkRead?: () => void;
|
onMarkRead?: () => void;
|
||||||
onArchive?: () => void;
|
onArchive?: () => void;
|
||||||
archiveDisabled?: boolean;
|
archiveDisabled?: boolean;
|
||||||
selected?: boolean;
|
|
||||||
className?: string;
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
const label =
|
const label =
|
||||||
|
|
@ -504,15 +421,11 @@ function JoinRequestInboxRow({
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onMarkRead}
|
onClick={onMarkRead}
|
||||||
className={cn(
|
className="inline-flex h-4 w-4 items-center justify-center rounded-full transition-colors hover:bg-blue-500/20"
|
||||||
"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 transition-opacity duration-300",
|
"block h-2 w-2 rounded-full bg-blue-600 transition-opacity duration-300 dark:bg-blue-400",
|
||||||
getSelectedUnreadDotClass(selected),
|
|
||||||
unreadState === "fading" ? "opacity-0" : "opacity-100",
|
unreadState === "fading" ? "opacity-0" : "opacity-100",
|
||||||
)} />
|
)} />
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -1015,62 +928,23 @@ export function Inbox() {
|
||||||
return `join:${item.joinRequest.id}`;
|
return `join:${item.joinRequest.id}`;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Keep selection valid when the list shape changes, but do not auto-select on initial load.
|
// Reset selection when the list changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSelectedIndex((prev) => resolveInboxSelectionIndex(prev, workItemsToRender.length));
|
setSelectedIndex((prev) =>
|
||||||
|
prev >= workItemsToRender.length ? workItemsToRender.length - 1 : prev,
|
||||||
|
);
|
||||||
}, [workItemsToRender.length]);
|
}, [workItemsToRender.length]);
|
||||||
|
|
||||||
// Use refs for keyboard handler to avoid stale closures
|
// Keyboard shortcuts (mail-client style)
|
||||||
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;
|
const target = e.target as HTMLElement;
|
||||||
if (
|
if (
|
||||||
!(target instanceof HTMLElement) ||
|
target.tagName === "INPUT" ||
|
||||||
target.closest("input, textarea, select, [contenteditable='true'], [role='textbox'], [role='combobox']") ||
|
target.tagName === "TEXTAREA" ||
|
||||||
|
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
|
||||||
|
|
@ -1078,81 +952,79 @@ export function Inbox() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const st = kbStateRef.current;
|
|
||||||
const act = kbActionsRef.current;
|
|
||||||
|
|
||||||
// Keyboard shortcuts are only active on the "mine" tab
|
// Keyboard shortcuts are only active on the "mine" tab
|
||||||
if (!st.canArchive) return;
|
if (!canArchiveFromTab) return;
|
||||||
|
|
||||||
const itemCount = st.workItems.length;
|
const itemCount = workItemsToRender.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) => getInboxKeyboardSelectionIndex(prev, itemCount, "next"));
|
setSelectedIndex((prev) => Math.min(prev + 1, itemCount - 1));
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "k": {
|
case "k": {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setSelectedIndex((prev) => getInboxKeyboardSelectionIndex(prev, itemCount, "previous"));
|
setSelectedIndex((prev) => Math.max(prev - 1, 0));
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "a":
|
case "a":
|
||||||
case "y": {
|
case "y": {
|
||||||
if (st.selectedIndex < 0 || st.selectedIndex >= itemCount) return;
|
if (selectedIndex < 0 || selectedIndex >= itemCount) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const item = st.workItems[st.selectedIndex];
|
const item = workItemsToRender[selectedIndex];
|
||||||
|
const key = getWorkItemKey(item);
|
||||||
if (item.kind === "issue") {
|
if (item.kind === "issue") {
|
||||||
if (!st.archivingIssueIds.has(item.issue.id)) {
|
if (!archivingIssueIds.has(item.issue.id)) {
|
||||||
act.archiveIssue(item.issue.id);
|
archiveIssueMutation.mutate(item.issue.id);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const key = getWorkItemKey(item);
|
if (!archivingNonIssueIds.has(key)) {
|
||||||
if (!st.archivingNonIssueIds.has(key)) {
|
handleArchiveNonIssue(key);
|
||||||
act.archiveNonIssue(key);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "U": {
|
case "U": {
|
||||||
if (st.selectedIndex < 0 || st.selectedIndex >= itemCount) return;
|
if (selectedIndex < 0 || selectedIndex >= itemCount) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const item = st.workItems[st.selectedIndex];
|
const item = workItemsToRender[selectedIndex];
|
||||||
if (item.kind === "issue") {
|
if (item.kind === "issue") {
|
||||||
act.markUnreadIssue(item.issue.id);
|
markUnreadMutation.mutate(item.issue.id);
|
||||||
} else {
|
} else {
|
||||||
act.markNonIssueUnread(getWorkItemKey(item));
|
const key = getWorkItemKey(item);
|
||||||
|
markItemUnread(key);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "r": {
|
case "r": {
|
||||||
if (st.selectedIndex < 0 || st.selectedIndex >= itemCount) return;
|
if (selectedIndex < 0 || selectedIndex >= itemCount) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const item = st.workItems[st.selectedIndex];
|
const item = workItemsToRender[selectedIndex];
|
||||||
if (item.kind === "issue") {
|
if (item.kind === "issue") {
|
||||||
if (item.issue.isUnreadForMe && !st.fadingOutIssues.has(item.issue.id)) {
|
if (item.issue.isUnreadForMe && !fadingOutIssues.has(item.issue.id)) {
|
||||||
act.markRead(item.issue.id);
|
markReadMutation.mutate(item.issue.id);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const key = getWorkItemKey(item);
|
const key = getWorkItemKey(item);
|
||||||
if (!st.readItems.has(key)) {
|
if (!readItems.has(key)) {
|
||||||
act.markNonIssueRead(key);
|
handleMarkNonIssueRead(key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "Enter": {
|
case "Enter": {
|
||||||
if (st.selectedIndex < 0 || st.selectedIndex >= itemCount) return;
|
if (selectedIndex < 0 || selectedIndex >= itemCount) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const item = st.workItems[st.selectedIndex];
|
const item = workItemsToRender[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;
|
||||||
act.navigate(createIssueDetailPath(pathId, issueLinkState), { state: issueLinkState });
|
navigate(createIssueDetailPath(pathId, issueLinkState), { state: issueLinkState });
|
||||||
} else if (item.kind === "approval") {
|
} else if (item.kind === "approval") {
|
||||||
act.navigate(`/approvals/${item.approval.id}`);
|
navigate(`/approvals/${item.approval.id}`);
|
||||||
} else if (item.kind === "failed_run") {
|
} else if (item.kind === "failed_run") {
|
||||||
act.navigate(`/agents/${item.run.agentId}/runs/${item.run.id}`);
|
navigate(`/agents/${item.run.agentId}/runs/${item.run.id}`);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
@ -1162,7 +1034,13 @@ 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(() => {
|
||||||
|
|
@ -1320,7 +1198,7 @@ export function Inbox() {
|
||||||
<div
|
<div
|
||||||
key={`sel-${key}`}
|
key={`sel-${key}`}
|
||||||
data-inbox-item
|
data-inbox-item
|
||||||
className="relative"
|
className={isSelected ? "bg-accent" : ""}
|
||||||
onClick={() => setSelectedIndex(index)}
|
onClick={() => setSelectedIndex(index)}
|
||||||
>
|
>
|
||||||
{child}
|
{child}
|
||||||
|
|
@ -1352,7 +1230,6 @@ 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)}
|
||||||
|
|
@ -1371,7 +1248,6 @@ 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)}
|
||||||
>
|
>
|
||||||
|
|
@ -1388,7 +1264,6 @@ 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}
|
||||||
|
|
@ -1409,7 +1284,6 @@ 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)}
|
||||||
>
|
>
|
||||||
|
|
@ -1426,7 +1300,6 @@ 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}
|
||||||
|
|
@ -1444,7 +1317,6 @@ 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)}
|
||||||
>
|
>
|
||||||
|
|
@ -1463,19 +1335,32 @@ 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
|
<>
|
||||||
issue={issue}
|
<span className="hidden shrink-0 sm:inline-flex">
|
||||||
selected={isSelected}
|
<StatusIcon status={issue.status} />
|
||||||
isLive={liveIssueIds.has(issue.id)}
|
</span>
|
||||||
/>
|
<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)}`
|
||||||
|
|
@ -1502,7 +1387,6 @@ 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,6 +291,7 @@ 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],
|
||||||
|
|
@ -1245,16 +1246,17 @@ 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) => {
|
onAdd={async (body, reopen, reassignment, interrupt) => {
|
||||||
if (reassignment) {
|
if (reassignment) {
|
||||||
await addCommentAndReassign.mutateAsync({ body, reopen, reassignment });
|
await addCommentAndReassign.mutateAsync({ body, reopen, reassignment, interrupt });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await addComment.mutateAsync({ body, reopen });
|
await addComment.mutateAsync({ body, reopen, interrupt });
|
||||||
}}
|
}}
|
||||||
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