Compare commits

..

No commits in common. "b1b3408efab9a97aebd9b194c5a07cabfbabe4e4" and "74687553f37532ee4542754926088e01a70b71f0" have entirely different histories.

12 changed files with 118 additions and 574 deletions

View file

@ -40,7 +40,12 @@ interface CommentThreadProps {
linkedRuns?: LinkedRunItem[];
companyId?: 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;
agentMap?: Map<string, Agent>;
imageUploadHandler?: (file: File) => Promise<string>;
@ -53,6 +58,7 @@ interface CommentThreadProps {
currentAssigneeValue?: string;
suggestedAssigneeValue?: string;
mentions?: MentionOption[];
interruptAvailable?: boolean;
onInterruptQueued?: (runId: string) => Promise<void>;
interruptingQueuedRunId?: string | null;
}
@ -323,11 +329,13 @@ 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;
@ -397,6 +405,14 @@ 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;
@ -423,10 +439,11 @@ export function CommentThread({
setSubmitting(true);
try {
await onAdd(trimmed, reopen ? true : undefined, reassignment ?? undefined);
await onAdd(trimmed, reopen ? true : undefined, reassignment ?? undefined, interrupt ? true : undefined);
setBody("");
if (draftKey) clearDraft(draftKey);
setReopen(true);
setInterrupt(false);
setReassignTarget(effectiveSuggestedAssigneeValue);
} catch {
// Parent mutation handlers surface the failure and keep the draft intact.
@ -547,6 +564,17 @@ 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,
MouseSensor,
PointerSensor,
useSensor,
useSensors,
type DragEndEvent,
@ -244,8 +244,7 @@ export function CompanyRail() {
// Require 8px of movement before starting a drag to avoid interfering with clicks
const sensors = useSensors(
// Keep sidebar reordering mouse-only so touch input can scroll/tap without drag affordances.
useSensor(MouseSensor, {
useSensor(PointerSensor, {
activationConstraint: { distance: 8 },
})
);

View file

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

View file

@ -11,7 +11,6 @@ type UnreadState = "hidden" | "visible" | "fading";
interface IssueRowProps {
issue: Issue;
issueLinkState?: unknown;
selected?: boolean;
mobileLeading?: ReactNode;
desktopMetaLeading?: ReactNode;
desktopLeadingSpacer?: boolean;
@ -28,7 +27,6 @@ interface IssueRowProps {
export function IssueRow({
issue,
issueLinkState,
selected = false,
mobileLeading,
desktopMetaLeading,
desktopLeadingSpacer = false,
@ -45,21 +43,18 @@ 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 last:border-b-0 sm:items-center sm:py-2 sm:pl-1",
selected ? "hover:bg-transparent" : "hover:bg-accent/50",
"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",
className,
)}
>
<span className="shrink-0 pt-px sm:hidden">
{mobileLeading ?? <StatusIcon status={issue.status} className={selectedStatusClass} />}
{mobileLeading ?? <StatusIcon status={issue.status} />}
</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">
@ -72,7 +67,7 @@ export function IssueRow({
{desktopMetaLeading ?? (
<>
<span className="hidden shrink-0 sm:inline-flex">
<StatusIcon status={issue.status} className={selectedStatusClass} />
<StatusIcon status={issue.status} />
</span>
<span className="shrink-0 font-mono text-xs text-muted-foreground">
{identifier}
@ -114,16 +109,12 @@ export function IssueRow({
onMarkRead?.();
}
}}
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",
)}
className="inline-flex h-4 w-4 items-center justify-center rounded-full transition-colors hover:bg-blue-500/20"
aria-label="Mark as read"
>
<span
className={cn(
"block h-2 w-2 rounded-full transition-opacity duration-300",
selected ? "bg-muted-foreground/70" : "bg-blue-600 dark:bg-blue-400",
"block h-2 w-2 rounded-full bg-blue-600 transition-opacity duration-300 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,
MouseSensor,
PointerSensor,
closestCenter,
type DragEndEvent,
useSensor,
@ -153,8 +153,7 @@ export function SidebarProjects() {
const projectMatch = location.pathname.match(/^\/(?:[^/]+\/)?projects\/([^/]+)/);
const activeProjectRef = projectMatch?.[1] ?? null;
const sensors = useSensors(
// Project reordering is intentionally desktop-only; touch should remain tap/scroll behavior.
useSensor(MouseSensor, {
useSensor(PointerSensor, {
activationConstraint: { distance: 8 },
}),
);

View file

@ -122,25 +122,4 @@ 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,20 +6,17 @@ 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);
@ -151,12 +148,10 @@ 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,13 +6,11 @@ import {
computeInboxBadgeData,
getApprovalsForTab,
getInboxWorkItems,
getInboxKeyboardSelectionIndex,
getRecentTouchedIssues,
getUnreadTouchedIssues,
isMineInboxTab,
loadLastInboxTab,
RECENT_ISSUES_LIMIT,
resolveInboxSelectionIndex,
saveLastInboxTab,
shouldShowInboxSection,
} from "./inbox";
@ -410,17 +408,4 @@ 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,27 +102,6 @@ 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(),

View file

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

View file

@ -47,11 +47,9 @@ import {
ACTIONABLE_APPROVAL_STATUSES,
getApprovalsForTab,
getInboxWorkItems,
getInboxKeyboardSelectionIndex,
getLatestFailedRunsByAgent,
getRecentTouchedIssues,
isMineInboxTab,
resolveInboxSelectionIndex,
InboxApprovalFilter,
saveLastInboxTab,
shouldShowInboxSection,
@ -100,69 +98,8 @@ function readIssueIdFromRun(run: HeartbeatRun): string | null {
type NonIssueUnreadState = "visible" | "fading" | "hidden" | null;
const selectedInboxAccentClass = "!text-muted-foreground !border-muted-foreground";
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({
function FailedRunInboxRow({
run,
issueById,
agentName: linkedAgentName,
@ -174,7 +111,6 @@ export function FailedRunInboxRow({
onMarkRead,
onArchive,
archiveDisabled,
selected = false,
className,
}: {
run: HeartbeatRun;
@ -188,7 +124,6 @@ export function FailedRunInboxRow({
onMarkRead?: () => void;
onArchive?: () => void;
archiveDisabled?: boolean;
selected?: boolean;
className?: string;
}) {
const issueId = readIssueIdFromRun(run);
@ -209,15 +144,11 @@ export function FailedRunInboxRow({
<button
type="button"
onClick={onMarkRead}
className={cn(
"inline-flex h-4 w-4 items-center justify-center rounded-full transition-colors",
getSelectedUnreadButtonClass(selected),
)}
className="inline-flex h-4 w-4 items-center justify-center rounded-full transition-colors hover:bg-blue-500/20"
aria-label="Mark as read"
>
<span className={cn(
"block h-2 w-2 rounded-full transition-opacity duration-300",
getSelectedUnreadDotClass(selected),
"block h-2 w-2 rounded-full bg-blue-600 transition-opacity duration-300 dark:bg-blue-400",
unreadState === "fading" ? "opacity-0" : "opacity-100",
)} />
</button>
@ -238,10 +169,7 @@ export function FailedRunInboxRow({
) : null}
<Link
to={`/agents/${run.agentId}/runs/${run.id}`}
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",
)}
className="flex min-w-0 flex-1 items-start gap-2 no-underline text-inherit transition-colors 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" />
@ -330,7 +258,6 @@ function ApprovalInboxRow({
onMarkRead,
onArchive,
archiveDisabled,
selected = false,
className,
}: {
approval: Approval;
@ -342,7 +269,6 @@ function ApprovalInboxRow({
onMarkRead?: () => void;
onArchive?: () => void;
archiveDisabled?: boolean;
selected?: boolean;
className?: string;
}) {
const Icon = typeIcon[approval.type] ?? defaultTypeIcon;
@ -365,15 +291,11 @@ function ApprovalInboxRow({
<button
type="button"
onClick={onMarkRead}
className={cn(
"inline-flex h-4 w-4 items-center justify-center rounded-full transition-colors",
getSelectedUnreadButtonClass(selected),
)}
className="inline-flex h-4 w-4 items-center justify-center rounded-full transition-colors hover:bg-blue-500/20"
aria-label="Mark as read"
>
<span className={cn(
"block h-2 w-2 rounded-full transition-opacity duration-300",
getSelectedUnreadDotClass(selected),
"block h-2 w-2 rounded-full bg-blue-600 transition-opacity duration-300 dark:bg-blue-400",
unreadState === "fading" ? "opacity-0" : "opacity-100",
)} />
</button>
@ -394,10 +316,7 @@ function ApprovalInboxRow({
) : null}
<Link
to={`/approvals/${approval.id}`}
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",
)}
className="flex min-w-0 flex-1 items-start gap-2 no-underline text-inherit transition-colors 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" />
@ -471,7 +390,6 @@ function JoinRequestInboxRow({
onMarkRead,
onArchive,
archiveDisabled,
selected = false,
className,
}: {
joinRequest: JoinRequest;
@ -482,7 +400,6 @@ function JoinRequestInboxRow({
onMarkRead?: () => void;
onArchive?: () => void;
archiveDisabled?: boolean;
selected?: boolean;
className?: string;
}) {
const label =
@ -504,15 +421,11 @@ function JoinRequestInboxRow({
<button
type="button"
onClick={onMarkRead}
className={cn(
"inline-flex h-4 w-4 items-center justify-center rounded-full transition-colors",
getSelectedUnreadButtonClass(selected),
)}
className="inline-flex h-4 w-4 items-center justify-center rounded-full transition-colors hover:bg-blue-500/20"
aria-label="Mark as read"
>
<span className={cn(
"block h-2 w-2 rounded-full transition-opacity duration-300",
getSelectedUnreadDotClass(selected),
"block h-2 w-2 rounded-full bg-blue-600 transition-opacity duration-300 dark:bg-blue-400",
unreadState === "fading" ? "opacity-0" : "opacity-100",
)} />
</button>
@ -1015,62 +928,23 @@ export function Inbox() {
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(() => {
setSelectedIndex((prev) => resolveInboxSelectionIndex(prev, workItemsToRender.length));
setSelectedIndex((prev) =>
prev >= workItemsToRender.length ? workItemsToRender.length - 1 : prev,
);
}, [workItemsToRender.length]);
// 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
// Keyboard shortcuts (mail-client style)
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;
const target = e.target as HTMLElement;
if (
!(target instanceof HTMLElement) ||
target.closest("input, textarea, select, [contenteditable='true'], [role='textbox'], [role='combobox']") ||
target.tagName === "INPUT" ||
target.tagName === "TEXTAREA" ||
target.tagName === "SELECT" ||
target.isContentEditable ||
document.querySelector("[role='dialog'], [aria-modal='true']") ||
e.metaKey ||
e.ctrlKey ||
e.altKey
@ -1078,81 +952,79 @@ export function Inbox() {
return;
}
const st = kbStateRef.current;
const act = kbActionsRef.current;
// 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;
switch (e.key) {
case "j": {
e.preventDefault();
setSelectedIndex((prev) => getInboxKeyboardSelectionIndex(prev, itemCount, "next"));
setSelectedIndex((prev) => Math.min(prev + 1, itemCount - 1));
break;
}
case "k": {
e.preventDefault();
setSelectedIndex((prev) => getInboxKeyboardSelectionIndex(prev, itemCount, "previous"));
setSelectedIndex((prev) => Math.max(prev - 1, 0));
break;
}
case "a":
case "y": {
if (st.selectedIndex < 0 || st.selectedIndex >= itemCount) return;
if (selectedIndex < 0 || selectedIndex >= itemCount) return;
e.preventDefault();
const item = st.workItems[st.selectedIndex];
const item = workItemsToRender[selectedIndex];
const key = getWorkItemKey(item);
if (item.kind === "issue") {
if (!st.archivingIssueIds.has(item.issue.id)) {
act.archiveIssue(item.issue.id);
if (!archivingIssueIds.has(item.issue.id)) {
archiveIssueMutation.mutate(item.issue.id);
}
} else {
const key = getWorkItemKey(item);
if (!st.archivingNonIssueIds.has(key)) {
act.archiveNonIssue(key);
if (!archivingNonIssueIds.has(key)) {
handleArchiveNonIssue(key);
}
}
break;
}
case "U": {
if (st.selectedIndex < 0 || st.selectedIndex >= itemCount) return;
if (selectedIndex < 0 || selectedIndex >= itemCount) return;
e.preventDefault();
const item = st.workItems[st.selectedIndex];
const item = workItemsToRender[selectedIndex];
if (item.kind === "issue") {
act.markUnreadIssue(item.issue.id);
markUnreadMutation.mutate(item.issue.id);
} else {
act.markNonIssueUnread(getWorkItemKey(item));
const key = getWorkItemKey(item);
markItemUnread(key);
}
break;
}
case "r": {
if (st.selectedIndex < 0 || st.selectedIndex >= itemCount) return;
if (selectedIndex < 0 || selectedIndex >= itemCount) return;
e.preventDefault();
const item = st.workItems[st.selectedIndex];
const item = workItemsToRender[selectedIndex];
if (item.kind === "issue") {
if (item.issue.isUnreadForMe && !st.fadingOutIssues.has(item.issue.id)) {
act.markRead(item.issue.id);
if (item.issue.isUnreadForMe && !fadingOutIssues.has(item.issue.id)) {
markReadMutation.mutate(item.issue.id);
}
} else {
const key = getWorkItemKey(item);
if (!st.readItems.has(key)) {
act.markNonIssueRead(key);
if (!readItems.has(key)) {
handleMarkNonIssueRead(key);
}
}
break;
}
case "Enter": {
if (st.selectedIndex < 0 || st.selectedIndex >= itemCount) return;
if (selectedIndex < 0 || selectedIndex >= itemCount) return;
e.preventDefault();
const item = st.workItems[st.selectedIndex];
const item = workItemsToRender[selectedIndex];
if (item.kind === "issue") {
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") {
act.navigate(`/approvals/${item.approval.id}`);
navigate(`/approvals/${item.approval.id}`);
} 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;
}
@ -1162,7 +1034,13 @@ export function Inbox() {
};
window.addEventListener("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
useEffect(() => {
@ -1320,7 +1198,7 @@ export function Inbox() {
<div
key={`sel-${key}`}
data-inbox-item
className="relative"
className={isSelected ? "bg-accent" : ""}
onClick={() => setSelectedIndex(index)}
>
{child}
@ -1352,7 +1230,6 @@ 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)}
@ -1371,7 +1248,6 @@ export function Inbox() {
elements.push(wrapItem(approvalKey, isSelected, canArchiveFromTab ? (
<SwipeToArchive
key={approvalKey}
selected={isSelected}
disabled={isArchiving}
onArchive={() => handleArchiveNonIssue(approvalKey)}
>
@ -1388,7 +1264,6 @@ export function Inbox() {
<FailedRunInboxRow
key={runKey}
run={item.run}
selected={isSelected}
issueById={issueById}
agentName={agentName(item.run.agentId)}
issueLinkState={issueLinkState}
@ -1409,7 +1284,6 @@ export function Inbox() {
elements.push(wrapItem(runKey, isSelected, canArchiveFromTab ? (
<SwipeToArchive
key={runKey}
selected={isSelected}
disabled={isArchiving}
onArchive={() => handleArchiveNonIssue(runKey)}
>
@ -1426,7 +1300,6 @@ 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}
@ -1444,7 +1317,6 @@ export function Inbox() {
elements.push(wrapItem(joinKey, isSelected, canArchiveFromTab ? (
<SwipeToArchive
key={joinKey}
selected={isSelected}
disabled={isArchiving}
onArchive={() => handleArchiveNonIssue(joinKey)}
>
@ -1463,19 +1335,32 @@ 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={
<InboxIssueMetaLeading
issue={issue}
selected={isSelected}
isLive={liveIssueIds.has(issue.id)}
/>
}
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>
)}
</>
)}
mobileMeta={
issue.lastExternalCommentAt
? `commented ${timeAgo(issue.lastExternalCommentAt)}`
@ -1502,7 +1387,6 @@ 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,6 +291,7 @@ 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],
@ -1245,16 +1246,17 @@ 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) => {
onAdd={async (body, reopen, reassignment, interrupt) => {
if (reassignment) {
await addCommentAndReassign.mutateAsync({ body, reopen, reassignment });
await addCommentAndReassign.mutateAsync({ body, reopen, reassignment, interrupt });
return;
}
await addComment.mutateAsync({ body, reopen });
await addComment.mutateAsync({ body, reopen, interrupt });
}}
imageUploadHandler={async (file) => {
const attachment = await uploadAttachment.mutateAsync(file);