Compare commits

...

10 commits

Author SHA1 Message Date
dotta
b1b3408efa Restrict sidebar reordering to mouse input
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-29 10:57:34 -05:00
dotta
57357991e4 Set inbox selection to fixed light gray
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-29 10:57:34 -05:00
dotta
50577b8c63 Neutralize selected inbox accents
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-29 10:57:34 -05:00
dotta
1871a602df Align inbox non-issue selection styling
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-29 10:57:34 -05:00
dotta
facf994694 Align inbox click selection styling
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-29 10:57:34 -05:00
dotta
403aeff7f6 Refine mine inbox shortcut behavior
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-29 10:57:34 -05:00
dotta
7d81e4cb2a Fix mine inbox keyboard selection
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-29 10:57:34 -05:00
dotta
44f052f4c5 Fix inbox selection highlight to show on individual items
Replace outline approach (blended with card border, invisible) with:
- 3px blue left-border bar (absolute positioned, like Gmail)
- Subtle tinted background with forced transparent children so the
  highlight shows through opaque child backgrounds

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-29 10:57:34 -05:00
dotta
c33dcbd202 Fix keyboard shortcuts using refs to avoid stale closures
Refactored keyboard handler to use refs (kbStateRef, kbActionsRef) for
all mutable state and actions. This ensures the single stable event
listener always reads fresh values instead of relying on effect
dependency re-registration which could miss updates.

Also fixed selection highlight visibility: replaced bg-accent (too
subtle) with bg-primary/10 + outline-primary/30 which is clearly
visible in both light and dark modes.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-29 10:57:34 -05:00
dotta
bc61eb84df Remove comment composer interrupt checkbox
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-29 10:57:34 -05:00
12 changed files with 575 additions and 119 deletions

View file

@ -40,12 +40,7 @@ interface CommentThreadProps {
linkedRuns?: LinkedRunItem[];
companyId?: string | null;
projectId?: string | null;
onAdd: (
body: string,
reopen?: boolean,
reassignment?: CommentReassignment,
interrupt?: boolean,
) => Promise<void>;
onAdd: (body: string, reopen?: boolean, reassignment?: CommentReassignment) => Promise<void>;
issueStatus?: string;
agentMap?: Map<string, Agent>;
imageUploadHandler?: (file: File) => Promise<string>;
@ -58,7 +53,6 @@ interface CommentThreadProps {
currentAssigneeValue?: string;
suggestedAssigneeValue?: string;
mentions?: MentionOption[];
interruptAvailable?: boolean;
onInterruptQueued?: (runId: string) => Promise<void>;
interruptingQueuedRunId?: string | null;
}
@ -329,13 +323,11 @@ export function CommentThread({
currentAssigneeValue = "",
suggestedAssigneeValue,
mentions: providedMentions,
interruptAvailable = false,
onInterruptQueued,
interruptingQueuedRunId = null,
}: CommentThreadProps) {
const [body, setBody] = useState("");
const [reopen, setReopen] = useState(true);
const [interrupt, setInterrupt] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [attaching, setAttaching] = useState(false);
const effectiveSuggestedAssigneeValue = suggestedAssigneeValue ?? currentAssigneeValue;
@ -405,14 +397,6 @@ export function CommentThread({
setReassignTarget(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}
useEffect(() => {
const hash = location.hash;
@ -439,11 +423,10 @@ export function CommentThread({
setSubmitting(true);
try {
await onAdd(trimmed, reopen ? true : undefined, reassignment ?? undefined, interrupt ? true : undefined);
await onAdd(trimmed, reopen ? true : undefined, reassignment ?? undefined);
setBody("");
if (draftKey) clearDraft(draftKey);
setReopen(true);
setInterrupt(false);
setReassignTarget(effectiveSuggestedAssigneeValue);
} catch {
// Parent mutation handlers surface the failure and keep the draft intact.
@ -564,17 +547,6 @@ export function CommentThread({
/>
Re-open
</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 && (
<InlineEntitySelector
value={reassignTarget}

View file

@ -4,7 +4,7 @@ import { useQueries } from "@tanstack/react-query";
import {
DndContext,
closestCenter,
PointerSensor,
MouseSensor,
useSensor,
useSensors,
type DragEndEvent,
@ -244,7 +244,8 @@ export function CompanyRail() {
// Require 8px of movement before starting a drag to avoid interfering with clicks
const sensors = useSensors(
useSensor(PointerSensor, {
// Keep sidebar reordering mouse-only so touch input can scroll/tap without drag affordances.
useSensor(MouseSensor, {
activationConstraint: { distance: 8 },
})
);

View 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();
});
});
});

View file

@ -11,6 +11,7 @@ type UnreadState = "hidden" | "visible" | "fading";
interface IssueRowProps {
issue: Issue;
issueLinkState?: unknown;
selected?: boolean;
mobileLeading?: ReactNode;
desktopMetaLeading?: ReactNode;
desktopLeadingSpacer?: boolean;
@ -27,6 +28,7 @@ interface IssueRowProps {
export function IssueRow({
issue,
issueLinkState,
selected = false,
mobileLeading,
desktopMetaLeading,
desktopLeadingSpacer = false,
@ -43,18 +45,21 @@ export function IssueRow({
const identifier = issue.identifier ?? issue.id.slice(0, 8);
const showUnreadSlot = unreadState !== null;
const showUnreadDot = unreadState === "visible" || unreadState === "fading";
const selectedStatusClass = selected ? "!text-muted-foreground !border-muted-foreground" : undefined;
return (
<Link
to={createIssueDetailPath(issuePathId, issueLinkState)}
state={issueLinkState}
data-inbox-issue-link
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,
)}
>
<span className="shrink-0 pt-px sm:hidden">
{mobileLeading ?? <StatusIcon status={issue.status} />}
{mobileLeading ?? <StatusIcon status={issue.status} className={selectedStatusClass} />}
</span>
<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">
@ -67,7 +72,7 @@ export function IssueRow({
{desktopMetaLeading ?? (
<>
<span className="hidden shrink-0 sm:inline-flex">
<StatusIcon status={issue.status} />
<StatusIcon status={issue.status} className={selectedStatusClass} />
</span>
<span className="shrink-0 font-mono text-xs text-muted-foreground">
{identifier}
@ -109,12 +114,16 @@ export function IssueRow({
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"
>
<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",
selected ? "bg-muted-foreground/70" : "bg-blue-600 dark:bg-blue-400",
unreadState === "fading" ? "opacity-0" : "opacity-100",
)}
/>

View file

@ -4,7 +4,7 @@ import { useQuery } from "@tanstack/react-query";
import { ChevronRight, Plus } from "lucide-react";
import {
DndContext,
PointerSensor,
MouseSensor,
closestCenter,
type DragEndEvent,
useSensor,
@ -153,7 +153,8 @@ export function SidebarProjects() {
const projectMatch = location.pathname.match(/^\/(?:[^/]+\/)?projects\/([^/]+)/);
const activeProjectRef = projectMatch?.[1] ?? null;
const sensors = useSensors(
useSensor(PointerSensor, {
// Project reordering is intentionally desktop-only; touch should remain tap/scroll behavior.
useSensor(MouseSensor, {
activationConstraint: { distance: 8 },
}),
);

View file

@ -122,4 +122,25 @@ describe("SwipeToArchive", () => {
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();
});
});
});

View file

@ -6,17 +6,20 @@ interface SwipeToArchiveProps {
children: ReactNode;
onArchive: () => void;
disabled?: boolean;
selected?: boolean;
className?: string;
}
const COMMIT_THRESHOLD = 0.32;
const MAX_SWIPE = 0.88;
const COMMIT_DELAY_MS = 140;
const SELECTED_ROW_BACKGROUND = "#f3f4f6";
export function SwipeToArchive({
children,
onArchive,
disabled = false,
selected = false,
className,
}: SwipeToArchiveProps) {
const containerRef = useRef<HTMLDivElement | null>(null);
@ -148,10 +151,12 @@ export function SwipeToArchive({
</span>
</div>
<div
data-inbox-row-surface
className="relative bg-card will-change-transform"
style={{
transform: `translate3d(${offsetX}px, 0, 0)`,
transition: isDragging ? "none" : "transform 180ms ease-out",
backgroundColor: selected ? SELECTED_ROW_BACKGROUND : undefined,
}}
>
{children}

View file

@ -6,11 +6,13 @@ import {
computeInboxBadgeData,
getApprovalsForTab,
getInboxWorkItems,
getInboxKeyboardSelectionIndex,
getRecentTouchedIssues,
getUnreadTouchedIssues,
isMineInboxTab,
loadLastInboxTab,
RECENT_ISSUES_LIMIT,
resolveInboxSelectionIndex,
saveLastInboxTab,
shouldShowInboxSection,
} from "./inbox";
@ -408,4 +410,17 @@ describe("inbox helpers", () => {
expect(isMineInboxTab("unread")).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);
});
});

View file

@ -102,6 +102,27 @@ export function isMineInboxTab(tab: InboxTab): boolean {
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[] {
const sorted = [...runs].sort(
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),

181
ui/src/pages/Inbox.test.tsx Normal file
View 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();
});
});
});

View file

@ -47,9 +47,11 @@ import {
ACTIONABLE_APPROVAL_STATUSES,
getApprovalsForTab,
getInboxWorkItems,
getInboxKeyboardSelectionIndex,
getLatestFailedRunsByAgent,
getRecentTouchedIssues,
isMineInboxTab,
resolveInboxSelectionIndex,
InboxApprovalFilter,
saveLastInboxTab,
shouldShowInboxSection,
@ -98,8 +100,69 @@ function readIssueIdFromRun(run: HeartbeatRun): string | 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,
issueById,
agentName: linkedAgentName,
@ -111,6 +174,7 @@ function FailedRunInboxRow({
onMarkRead,
onArchive,
archiveDisabled,
selected = false,
className,
}: {
run: HeartbeatRun;
@ -124,6 +188,7 @@ function FailedRunInboxRow({
onMarkRead?: () => void;
onArchive?: () => void;
archiveDisabled?: boolean;
selected?: boolean;
className?: string;
}) {
const issueId = readIssueIdFromRun(run);
@ -144,11 +209,15 @@ function FailedRunInboxRow({
<button
type="button"
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"
>
<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",
)} />
</button>
@ -169,7 +238,10 @@ function FailedRunInboxRow({
) : null}
<Link
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" />}
<span className="hidden h-3.5 w-3.5 shrink-0 sm:inline-flex" aria-hidden="true" />
@ -258,6 +330,7 @@ function ApprovalInboxRow({
onMarkRead,
onArchive,
archiveDisabled,
selected = false,
className,
}: {
approval: Approval;
@ -269,6 +342,7 @@ function ApprovalInboxRow({
onMarkRead?: () => void;
onArchive?: () => void;
archiveDisabled?: boolean;
selected?: boolean;
className?: string;
}) {
const Icon = typeIcon[approval.type] ?? defaultTypeIcon;
@ -291,11 +365,15 @@ function ApprovalInboxRow({
<button
type="button"
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"
>
<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",
)} />
</button>
@ -316,7 +394,10 @@ function ApprovalInboxRow({
) : null}
<Link
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" />}
<span className="hidden h-3.5 w-3.5 shrink-0 sm:inline-flex" aria-hidden="true" />
@ -390,6 +471,7 @@ function JoinRequestInboxRow({
onMarkRead,
onArchive,
archiveDisabled,
selected = false,
className,
}: {
joinRequest: JoinRequest;
@ -400,6 +482,7 @@ function JoinRequestInboxRow({
onMarkRead?: () => void;
onArchive?: () => void;
archiveDisabled?: boolean;
selected?: boolean;
className?: string;
}) {
const label =
@ -421,11 +504,15 @@ function JoinRequestInboxRow({
<button
type="button"
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"
>
<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",
)} />
</button>
@ -928,23 +1015,62 @@ export function Inbox() {
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(() => {
setSelectedIndex((prev) =>
prev >= workItemsToRender.length ? workItemsToRender.length - 1 : prev,
);
setSelectedIndex((prev) => resolveInboxSelectionIndex(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(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.defaultPrevented) return;
// Don't capture when typing in inputs/textareas or with modifier keys
const target = e.target as HTMLElement;
const target = e.target;
if (
target.tagName === "INPUT" ||
target.tagName === "TEXTAREA" ||
target.tagName === "SELECT" ||
!(target instanceof HTMLElement) ||
target.closest("input, textarea, select, [contenteditable='true'], [role='textbox'], [role='combobox']") ||
target.isContentEditable ||
document.querySelector("[role='dialog'], [aria-modal='true']") ||
e.metaKey ||
e.ctrlKey ||
e.altKey
@ -952,79 +1078,81 @@ export function Inbox() {
return;
}
// Keyboard shortcuts are only active on the "mine" tab
if (!canArchiveFromTab) return;
const st = kbStateRef.current;
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;
switch (e.key) {
case "j": {
e.preventDefault();
setSelectedIndex((prev) => Math.min(prev + 1, itemCount - 1));
setSelectedIndex((prev) => getInboxKeyboardSelectionIndex(prev, itemCount, "next"));
break;
}
case "k": {
e.preventDefault();
setSelectedIndex((prev) => Math.max(prev - 1, 0));
setSelectedIndex((prev) => getInboxKeyboardSelectionIndex(prev, itemCount, "previous"));
break;
}
case "a":
case "y": {
if (selectedIndex < 0 || selectedIndex >= itemCount) return;
if (st.selectedIndex < 0 || st.selectedIndex >= itemCount) return;
e.preventDefault();
const item = workItemsToRender[selectedIndex];
const key = getWorkItemKey(item);
const item = st.workItems[st.selectedIndex];
if (item.kind === "issue") {
if (!archivingIssueIds.has(item.issue.id)) {
archiveIssueMutation.mutate(item.issue.id);
if (!st.archivingIssueIds.has(item.issue.id)) {
act.archiveIssue(item.issue.id);
}
} else {
if (!archivingNonIssueIds.has(key)) {
handleArchiveNonIssue(key);
const key = getWorkItemKey(item);
if (!st.archivingNonIssueIds.has(key)) {
act.archiveNonIssue(key);
}
}
break;
}
case "U": {
if (selectedIndex < 0 || selectedIndex >= itemCount) return;
if (st.selectedIndex < 0 || st.selectedIndex >= itemCount) return;
e.preventDefault();
const item = workItemsToRender[selectedIndex];
const item = st.workItems[st.selectedIndex];
if (item.kind === "issue") {
markUnreadMutation.mutate(item.issue.id);
act.markUnreadIssue(item.issue.id);
} else {
const key = getWorkItemKey(item);
markItemUnread(key);
act.markNonIssueUnread(getWorkItemKey(item));
}
break;
}
case "r": {
if (selectedIndex < 0 || selectedIndex >= itemCount) return;
if (st.selectedIndex < 0 || st.selectedIndex >= itemCount) return;
e.preventDefault();
const item = workItemsToRender[selectedIndex];
const item = st.workItems[st.selectedIndex];
if (item.kind === "issue") {
if (item.issue.isUnreadForMe && !fadingOutIssues.has(item.issue.id)) {
markReadMutation.mutate(item.issue.id);
if (item.issue.isUnreadForMe && !st.fadingOutIssues.has(item.issue.id)) {
act.markRead(item.issue.id);
}
} else {
const key = getWorkItemKey(item);
if (!readItems.has(key)) {
handleMarkNonIssueRead(key);
if (!st.readItems.has(key)) {
act.markNonIssueRead(key);
}
}
break;
}
case "Enter": {
if (selectedIndex < 0 || selectedIndex >= itemCount) return;
if (st.selectedIndex < 0 || st.selectedIndex >= itemCount) return;
e.preventDefault();
const item = workItemsToRender[selectedIndex];
const item = st.workItems[st.selectedIndex];
if (item.kind === "issue") {
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") {
navigate(`/approvals/${item.approval.id}`);
act.navigate(`/approvals/${item.approval.id}`);
} 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;
}
@ -1034,13 +1162,7 @@ export function Inbox() {
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [
workItemsToRender, selectedIndex, canArchiveFromTab, navigate, issueLinkState,
getWorkItemKey, archivingIssueIds, archivingNonIssueIds,
fadingOutIssues, readItems,
archiveIssueMutation, markReadMutation, markUnreadMutation,
handleArchiveNonIssue, handleMarkNonIssueRead, markItemUnread,
]);
}, [getWorkItemKey, issueLinkState]);
// Scroll selected item into view
useEffect(() => {
@ -1198,7 +1320,7 @@ export function Inbox() {
<div
key={`sel-${key}`}
data-inbox-item
className={isSelected ? "bg-accent" : ""}
className="relative"
onClick={() => setSelectedIndex(index)}
>
{child}
@ -1230,6 +1352,7 @@ export function Inbox() {
<ApprovalInboxRow
key={approvalKey}
approval={item.approval}
selected={isSelected}
requesterName={agentName(item.approval.requestedByAgentId)}
onApprove={() => approveMutation.mutate(item.approval.id)}
onReject={() => rejectMutation.mutate(item.approval.id)}
@ -1248,6 +1371,7 @@ export function Inbox() {
elements.push(wrapItem(approvalKey, isSelected, canArchiveFromTab ? (
<SwipeToArchive
key={approvalKey}
selected={isSelected}
disabled={isArchiving}
onArchive={() => handleArchiveNonIssue(approvalKey)}
>
@ -1264,6 +1388,7 @@ export function Inbox() {
<FailedRunInboxRow
key={runKey}
run={item.run}
selected={isSelected}
issueById={issueById}
agentName={agentName(item.run.agentId)}
issueLinkState={issueLinkState}
@ -1284,6 +1409,7 @@ export function Inbox() {
elements.push(wrapItem(runKey, isSelected, canArchiveFromTab ? (
<SwipeToArchive
key={runKey}
selected={isSelected}
disabled={isArchiving}
onArchive={() => handleArchiveNonIssue(runKey)}
>
@ -1300,6 +1426,7 @@ export function Inbox() {
<JoinRequestInboxRow
key={joinKey}
joinRequest={item.joinRequest}
selected={isSelected}
onApprove={() => approveJoinMutation.mutate(item.joinRequest)}
onReject={() => rejectJoinMutation.mutate(item.joinRequest)}
isPending={approveJoinMutation.isPending || rejectJoinMutation.isPending}
@ -1317,6 +1444,7 @@ export function Inbox() {
elements.push(wrapItem(joinKey, isSelected, canArchiveFromTab ? (
<SwipeToArchive
key={joinKey}
selected={isSelected}
disabled={isArchiving}
onArchive={() => handleArchiveNonIssue(joinKey)}
>
@ -1335,32 +1463,19 @@ export function Inbox() {
key={`issue:${issue.id}`}
issue={issue}
issueLinkState={issueLinkState}
selected={isSelected}
className={
isArchiving
? "pointer-events-none -translate-x-4 scale-[0.98] opacity-0 transition-all duration-200 ease-out"
: "transition-all duration-200 ease-out"
}
desktopMetaLeading={(
<>
<span className="hidden shrink-0 sm:inline-flex">
<StatusIcon status={issue.status} />
</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>
)}
</>
)}
desktopMetaLeading={
<InboxIssueMetaLeading
issue={issue}
selected={isSelected}
isLive={liveIssueIds.has(issue.id)}
/>
}
mobileMeta={
issue.lastExternalCommentAt
? `commented ${timeAgo(issue.lastExternalCommentAt)}`
@ -1387,6 +1502,7 @@ export function Inbox() {
elements.push(wrapItem(`issue:${issue.id}`, isSelected, canArchiveFromTab ? (
<SwipeToArchive
key={`issue:${issue.id}`}
selected={isSelected}
disabled={isArchiving || archiveIssueMutation.isPending}
onArchive={() => archiveIssueMutation.mutate(issue.id)}
>

View file

@ -291,7 +291,6 @@ export function IssueDetail() {
),
[activeRun, liveRuns],
);
const hasRunningIssueRun = Boolean(runningIssueRun);
const sourceBreadcrumb = useMemo(
() => readIssueDetailBreadcrumb(location.state, location.search) ?? { label: "Issues", href: "/issues" },
[location.state, location.search],
@ -1246,17 +1245,16 @@ export function IssueDetail() {
currentAssigneeValue={actualAssigneeValue}
suggestedAssigneeValue={suggestedAssigneeValue}
mentions={mentionOptions}
interruptAvailable={hasRunningIssueRun}
onInterruptQueued={async (runId) => {
await interruptQueuedComment.mutateAsync(runId);
}}
interruptingQueuedRunId={interruptQueuedComment.isPending ? runningIssueRun?.id ?? null : null}
onAdd={async (body, reopen, reassignment, interrupt) => {
onAdd={async (body, reopen, reassignment) => {
if (reassignment) {
await addCommentAndReassign.mutateAsync({ body, reopen, reassignment, interrupt });
await addCommentAndReassign.mutateAsync({ body, reopen, reassignment });
return;
}
await addComment.mutateAsync({ body, reopen, interrupt });
await addComment.mutateAsync({ body, reopen });
}}
imageUploadHandler={async (file) => {
const attachment = await uploadAttachment.mutateAsync(file);