mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-17 03:10:38 +09:00
Merge public-gh/master into pap-979-runtime-workspaces
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
commit
4d61dbfd34
46 changed files with 3635 additions and 297 deletions
181
ui/src/pages/Inbox.test.tsx
Normal file
181
ui/src/pages/Inbox.test.tsx
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from "react";
|
||||
import type { ComponentProps } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import type { Issue } from "@paperclipai/shared";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { FailedRunInboxRow, InboxIssueMetaLeading } from "./Inbox";
|
||||
|
||||
vi.mock("@/lib/router", () => ({
|
||||
Link: ({ children, className, ...props }: ComponentProps<"a">) => (
|
||||
<a className={className} {...props}>{children}</a>
|
||||
),
|
||||
useLocation: () => ({ pathname: "/", search: "", hash: "" }),
|
||||
useNavigate: () => () => {},
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
function createIssue(overrides: Partial<Issue> = {}): Issue {
|
||||
return {
|
||||
id: "issue-1",
|
||||
identifier: "PAP-904",
|
||||
companyId: "company-1",
|
||||
projectId: null,
|
||||
projectWorkspaceId: null,
|
||||
goalId: null,
|
||||
parentId: null,
|
||||
title: "Inbox item",
|
||||
description: null,
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
assigneeAgentId: null,
|
||||
assigneeUserId: null,
|
||||
createdByAgentId: null,
|
||||
createdByUserId: null,
|
||||
issueNumber: 904,
|
||||
requestDepth: 0,
|
||||
billingCode: null,
|
||||
assigneeAdapterOverrides: null,
|
||||
executionWorkspaceId: null,
|
||||
executionWorkspacePreference: null,
|
||||
executionWorkspaceSettings: null,
|
||||
checkoutRunId: null,
|
||||
executionRunId: null,
|
||||
executionAgentNameKey: null,
|
||||
executionLockedAt: null,
|
||||
startedAt: null,
|
||||
completedAt: null,
|
||||
cancelledAt: null,
|
||||
hiddenAt: null,
|
||||
createdAt: new Date("2026-03-11T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-03-11T00:00:00.000Z"),
|
||||
labels: [],
|
||||
labelIds: [],
|
||||
myLastTouchAt: null,
|
||||
lastExternalCommentAt: null,
|
||||
isUnreadForMe: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("FailedRunInboxRow", () => {
|
||||
let container: HTMLDivElement;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it("suppresses accent hover styling when selected", () => {
|
||||
const root = createRoot(container);
|
||||
const run = {
|
||||
id: "run-1",
|
||||
companyId: "company-1",
|
||||
agentId: "agent-1",
|
||||
invocationSource: "assignment",
|
||||
triggerDetail: null,
|
||||
status: "failed",
|
||||
error: "boom",
|
||||
wakeupRequestId: null,
|
||||
exitCode: null,
|
||||
signal: null,
|
||||
usageJson: null,
|
||||
resultJson: null,
|
||||
sessionIdBefore: null,
|
||||
sessionIdAfter: null,
|
||||
logStore: null,
|
||||
logRef: null,
|
||||
logBytes: null,
|
||||
logSha256: null,
|
||||
logCompressed: false,
|
||||
errorCode: null,
|
||||
externalRunId: null,
|
||||
processPid: null,
|
||||
processStartedAt: null,
|
||||
retryOfRunId: null,
|
||||
processLossRetryCount: 0,
|
||||
stdoutExcerpt: null,
|
||||
stderrExcerpt: null,
|
||||
contextSnapshot: null,
|
||||
startedAt: new Date("2026-03-11T00:00:00.000Z"),
|
||||
finishedAt: null,
|
||||
createdAt: new Date("2026-03-11T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-03-11T00:00:00.000Z"),
|
||||
} as const;
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<FailedRunInboxRow
|
||||
run={run}
|
||||
issueById={new Map()}
|
||||
agentName="Agent"
|
||||
issueLinkState={null}
|
||||
onDismiss={() => {}}
|
||||
onRetry={() => {}}
|
||||
isRetrying={false}
|
||||
selected
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
const link = container.querySelector("a");
|
||||
expect(link).not.toBeNull();
|
||||
expect(link?.className).toContain("hover:bg-transparent");
|
||||
expect(link?.className).not.toContain("hover:bg-accent/50");
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("InboxIssueMetaLeading", () => {
|
||||
let container: HTMLDivElement;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it("neutralizes selected status and live accents", () => {
|
||||
const root = createRoot(container);
|
||||
|
||||
act(() => {
|
||||
root.render(<InboxIssueMetaLeading issue={createIssue()} selected isLive />);
|
||||
});
|
||||
|
||||
const statusIcon = container.querySelector('span[class*="border-muted-foreground"]');
|
||||
const liveBadge = container.querySelector('span[class*="px-1.5"][class*="bg-muted"]');
|
||||
const liveBadgeLabel = Array.from(container.querySelectorAll("span")).find(
|
||||
(node) => node.textContent === "Live" && node.className.includes("text-"),
|
||||
);
|
||||
const liveDot = container.querySelector('span[class*="bg-muted-foreground/70"]');
|
||||
const pulseRing = container.querySelector('span[class*="animate-pulse"]');
|
||||
|
||||
expect(statusIcon).not.toBeNull();
|
||||
expect(statusIcon?.className).toContain("!border-muted-foreground");
|
||||
expect(statusIcon?.className).toContain("!text-muted-foreground");
|
||||
expect(liveBadge).not.toBeNull();
|
||||
expect(liveBadge?.className).toContain("bg-muted");
|
||||
expect(liveBadgeLabel).not.toBeNull();
|
||||
expect(liveBadgeLabel?.className).toContain("text-muted-foreground");
|
||||
expect(liveBadgeLabel?.className).not.toContain("text-blue-600");
|
||||
expect(liveDot).not.toBeNull();
|
||||
expect(pulseRing).toBeNull();
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import { useEffect, useMemo, useState } from "react";
|
||||
import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Link, useLocation, useNavigate } from "@/lib/router";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { INBOX_MINE_ISSUE_STATUS_FILTER } from "@paperclipai/shared";
|
||||
import { approvalsApi } from "../api/approvals";
|
||||
import { accessApi } from "../api/access";
|
||||
import { ApiError } from "../api/client";
|
||||
|
|
@ -11,7 +12,7 @@ import { heartbeatsApi } from "../api/heartbeats";
|
|||
import { useCompany } from "../context/CompanyContext";
|
||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { createIssueDetailLocationState } from "../lib/issueDetailBreadcrumb";
|
||||
import { createIssueDetailLocationState, createIssueDetailPath } from "../lib/issueDetailBreadcrumb";
|
||||
import { EmptyState } from "../components/EmptyState";
|
||||
import { PageSkeleton } from "../components/PageSkeleton";
|
||||
import { IssueRow } from "../components/IssueRow";
|
||||
|
|
@ -46,12 +47,16 @@ import {
|
|||
ACTIONABLE_APPROVAL_STATUSES,
|
||||
getApprovalsForTab,
|
||||
getInboxWorkItems,
|
||||
getInboxKeyboardSelectionIndex,
|
||||
getLatestFailedRunsByAgent,
|
||||
getRecentTouchedIssues,
|
||||
isMineInboxTab,
|
||||
resolveInboxSelectionIndex,
|
||||
InboxApprovalFilter,
|
||||
saveLastInboxTab,
|
||||
shouldShowInboxSection,
|
||||
type InboxTab,
|
||||
type InboxWorkItem,
|
||||
} from "../lib/inbox";
|
||||
import { useDismissedInboxItems, useReadInboxItems } from "../hooks/useInboxBadge";
|
||||
|
||||
|
|
@ -66,8 +71,6 @@ type SectionKey =
|
|||
| "work_items"
|
||||
| "alerts";
|
||||
|
||||
const INBOX_ISSUE_STATUSES = "backlog,todo,in_progress,in_review,blocked,done";
|
||||
|
||||
function firstNonEmptyLine(value: string | null | undefined): string | null {
|
||||
if (!value) return null;
|
||||
const line = value.split("\n").map((chunk) => chunk.trim()).find(Boolean);
|
||||
|
|
@ -97,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,
|
||||
|
|
@ -110,6 +174,7 @@ function FailedRunInboxRow({
|
|||
onMarkRead,
|
||||
onArchive,
|
||||
archiveDisabled,
|
||||
selected = false,
|
||||
className,
|
||||
}: {
|
||||
run: HeartbeatRun;
|
||||
|
|
@ -123,6 +188,7 @@ function FailedRunInboxRow({
|
|||
onMarkRead?: () => void;
|
||||
onArchive?: () => void;
|
||||
archiveDisabled?: boolean;
|
||||
selected?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
const issueId = readIssueIdFromRun(run);
|
||||
|
|
@ -143,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>
|
||||
|
|
@ -168,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" />
|
||||
|
|
@ -257,6 +330,7 @@ function ApprovalInboxRow({
|
|||
onMarkRead,
|
||||
onArchive,
|
||||
archiveDisabled,
|
||||
selected = false,
|
||||
className,
|
||||
}: {
|
||||
approval: Approval;
|
||||
|
|
@ -268,6 +342,7 @@ function ApprovalInboxRow({
|
|||
onMarkRead?: () => void;
|
||||
onArchive?: () => void;
|
||||
archiveDisabled?: boolean;
|
||||
selected?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
const Icon = typeIcon[approval.type] ?? defaultTypeIcon;
|
||||
|
|
@ -290,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>
|
||||
|
|
@ -315,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" />
|
||||
|
|
@ -389,6 +471,7 @@ function JoinRequestInboxRow({
|
|||
onMarkRead,
|
||||
onArchive,
|
||||
archiveDisabled,
|
||||
selected = false,
|
||||
className,
|
||||
}: {
|
||||
joinRequest: JoinRequest;
|
||||
|
|
@ -399,6 +482,7 @@ function JoinRequestInboxRow({
|
|||
onMarkRead?: () => void;
|
||||
onArchive?: () => void;
|
||||
archiveDisabled?: boolean;
|
||||
selected?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
const label =
|
||||
|
|
@ -420,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>
|
||||
|
|
@ -512,18 +600,20 @@ export function Inbox() {
|
|||
const [allCategoryFilter, setAllCategoryFilter] = useState<InboxCategoryFilter>("everything");
|
||||
const [allApprovalFilter, setAllApprovalFilter] = useState<InboxApprovalFilter>("all");
|
||||
const { dismissed, dismiss } = useDismissedInboxItems();
|
||||
const { readItems, markRead: markItemRead } = useReadInboxItems();
|
||||
const { readItems, markRead: markItemRead, markUnread: markItemUnread } = useReadInboxItems();
|
||||
|
||||
const pathSegment = location.pathname.split("/").pop() ?? "mine";
|
||||
const tab: InboxTab =
|
||||
pathSegment === "mine" || pathSegment === "recent" || pathSegment === "all" || pathSegment === "unread"
|
||||
? pathSegment
|
||||
: "mine";
|
||||
const canArchiveFromTab = isMineInboxTab(tab);
|
||||
const issueLinkState = useMemo(
|
||||
() =>
|
||||
createIssueDetailLocationState(
|
||||
"Inbox",
|
||||
`${location.pathname}${location.search}${location.hash}`,
|
||||
"inbox",
|
||||
),
|
||||
[location.pathname, location.search, location.hash],
|
||||
);
|
||||
|
|
@ -540,6 +630,7 @@ export function Inbox() {
|
|||
|
||||
useEffect(() => {
|
||||
saveLastInboxTab(tab);
|
||||
setSelectedIndex(-1);
|
||||
}, [tab]);
|
||||
|
||||
const {
|
||||
|
|
@ -591,7 +682,7 @@ export function Inbox() {
|
|||
issuesApi.list(selectedCompanyId!, {
|
||||
touchedByUserId: "me",
|
||||
inboxArchivedByUserId: "me",
|
||||
status: INBOX_ISSUE_STATUSES,
|
||||
status: INBOX_MINE_ISSUE_STATUS_FILTER,
|
||||
}),
|
||||
enabled: !!selectedCompanyId,
|
||||
});
|
||||
|
|
@ -603,7 +694,7 @@ export function Inbox() {
|
|||
queryFn: () =>
|
||||
issuesApi.list(selectedCompanyId!, {
|
||||
touchedByUserId: "me",
|
||||
status: INBOX_ISSUE_STATUSES,
|
||||
status: INBOX_MINE_ISSUE_STATUS_FILTER,
|
||||
}),
|
||||
enabled: !!selectedCompanyId,
|
||||
});
|
||||
|
|
@ -793,6 +884,8 @@ export function Inbox() {
|
|||
const [archivingIssueIds, setArchivingIssueIds] = useState<Set<string>>(new Set());
|
||||
const [fadingNonIssueItems, setFadingNonIssueItems] = useState<Set<string>>(new Set());
|
||||
const [archivingNonIssueIds, setArchivingNonIssueIds] = useState<Set<string>>(new Set());
|
||||
const [selectedIndex, setSelectedIndex] = useState<number>(-1);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const invalidateInboxIssueQueries = () => {
|
||||
if (!selectedCompanyId) return;
|
||||
|
|
@ -875,7 +968,14 @@ export function Inbox() {
|
|||
},
|
||||
});
|
||||
|
||||
const handleMarkNonIssueRead = (key: string) => {
|
||||
const markUnreadMutation = useMutation({
|
||||
mutationFn: (id: string) => issuesApi.markUnread(id),
|
||||
onSuccess: () => {
|
||||
invalidateInboxIssueQueries();
|
||||
},
|
||||
});
|
||||
|
||||
const handleMarkNonIssueRead = useCallback((key: string) => {
|
||||
setFadingNonIssueItems((prev) => new Set(prev).add(key));
|
||||
markItemRead(key);
|
||||
setTimeout(() => {
|
||||
|
|
@ -885,9 +985,9 @@ export function Inbox() {
|
|||
return next;
|
||||
});
|
||||
}, 300);
|
||||
};
|
||||
}, [markItemRead]);
|
||||
|
||||
const handleArchiveNonIssue = (key: string) => {
|
||||
const handleArchiveNonIssue = useCallback((key: string) => {
|
||||
setArchivingNonIssueIds((prev) => new Set(prev).add(key));
|
||||
setTimeout(() => {
|
||||
dismiss(key);
|
||||
|
|
@ -897,10 +997,10 @@ export function Inbox() {
|
|||
return next;
|
||||
});
|
||||
}, 200);
|
||||
};
|
||||
}, [dismiss]);
|
||||
|
||||
const nonIssueUnreadState = (key: string): NonIssueUnreadState => {
|
||||
if (tab !== "mine") return null;
|
||||
if (!canArchiveFromTab) return null;
|
||||
const isRead = readItems.has(key);
|
||||
const isFading = fadingNonIssueItems.has(key);
|
||||
if (isFading) return "fading";
|
||||
|
|
@ -908,6 +1008,170 @@ export function Inbox() {
|
|||
return "hidden";
|
||||
};
|
||||
|
||||
const getWorkItemKey = useCallback((item: InboxWorkItem): string => {
|
||||
if (item.kind === "issue") return `issue:${item.issue.id}`;
|
||||
if (item.kind === "approval") return `approval:${item.approval.id}`;
|
||||
if (item.kind === "failed_run") return `run:${item.run.id}`;
|
||||
return `join:${item.joinRequest.id}`;
|
||||
}, []);
|
||||
|
||||
// Keep selection valid when the list shape changes, but do not auto-select on initial load.
|
||||
useEffect(() => {
|
||||
setSelectedIndex((prev) => resolveInboxSelectionIndex(prev, workItemsToRender.length));
|
||||
}, [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
|
||||
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;
|
||||
if (
|
||||
!(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
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const st = kbStateRef.current;
|
||||
const act = kbActionsRef.current;
|
||||
|
||||
// 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) => getInboxKeyboardSelectionIndex(prev, itemCount, "next"));
|
||||
break;
|
||||
}
|
||||
case "k": {
|
||||
e.preventDefault();
|
||||
setSelectedIndex((prev) => getInboxKeyboardSelectionIndex(prev, itemCount, "previous"));
|
||||
break;
|
||||
}
|
||||
case "a":
|
||||
case "y": {
|
||||
if (st.selectedIndex < 0 || st.selectedIndex >= itemCount) return;
|
||||
e.preventDefault();
|
||||
const item = st.workItems[st.selectedIndex];
|
||||
if (item.kind === "issue") {
|
||||
if (!st.archivingIssueIds.has(item.issue.id)) {
|
||||
act.archiveIssue(item.issue.id);
|
||||
}
|
||||
} else {
|
||||
const key = getWorkItemKey(item);
|
||||
if (!st.archivingNonIssueIds.has(key)) {
|
||||
act.archiveNonIssue(key);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "U": {
|
||||
if (st.selectedIndex < 0 || st.selectedIndex >= itemCount) return;
|
||||
e.preventDefault();
|
||||
const item = st.workItems[st.selectedIndex];
|
||||
if (item.kind === "issue") {
|
||||
act.markUnreadIssue(item.issue.id);
|
||||
} else {
|
||||
act.markNonIssueUnread(getWorkItemKey(item));
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "r": {
|
||||
if (st.selectedIndex < 0 || st.selectedIndex >= itemCount) return;
|
||||
e.preventDefault();
|
||||
const item = st.workItems[st.selectedIndex];
|
||||
if (item.kind === "issue") {
|
||||
if (item.issue.isUnreadForMe && !st.fadingOutIssues.has(item.issue.id)) {
|
||||
act.markRead(item.issue.id);
|
||||
}
|
||||
} else {
|
||||
const key = getWorkItemKey(item);
|
||||
if (!st.readItems.has(key)) {
|
||||
act.markNonIssueRead(key);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "Enter": {
|
||||
if (st.selectedIndex < 0 || st.selectedIndex >= itemCount) return;
|
||||
e.preventDefault();
|
||||
const item = st.workItems[st.selectedIndex];
|
||||
if (item.kind === "issue") {
|
||||
const pathId = item.issue.identifier ?? item.issue.id;
|
||||
act.navigate(createIssueDetailPath(pathId, issueLinkState), { state: issueLinkState });
|
||||
} else if (item.kind === "approval") {
|
||||
act.navigate(`/approvals/${item.approval.id}`);
|
||||
} else if (item.kind === "failed_run") {
|
||||
act.navigate(`/agents/${item.run.agentId}/runs/${item.run.id}`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
return;
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [getWorkItemKey, issueLinkState]);
|
||||
|
||||
// Scroll selected item into view
|
||||
useEffect(() => {
|
||||
if (selectedIndex < 0 || !listRef.current) return;
|
||||
const rows = listRef.current.querySelectorAll("[data-inbox-item]");
|
||||
const row = rows[selectedIndex];
|
||||
if (row) row.scrollIntoView({ block: "nearest" });
|
||||
}, [selectedIndex]);
|
||||
|
||||
if (!selectedCompanyId) {
|
||||
return <EmptyState icon={InboxIcon} message="Select a company to view inbox." />;
|
||||
}
|
||||
|
|
@ -953,25 +1217,25 @@ export function Inbox() {
|
|||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Tabs value={tab} onValueChange={(value) => navigate(`/inbox/${value}`)}>
|
||||
<PageTabBar
|
||||
items={[
|
||||
{
|
||||
value: "mine",
|
||||
label: "Mine",
|
||||
},
|
||||
{
|
||||
value: "recent",
|
||||
label: "Recent",
|
||||
},
|
||||
{ value: "unread", label: "Unread" },
|
||||
{ value: "all", label: "All" },
|
||||
]}
|
||||
/>
|
||||
</Tabs>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Tabs value={tab} onValueChange={(value) => navigate(`/inbox/${value}`)}>
|
||||
<PageTabBar
|
||||
items={[
|
||||
{
|
||||
value: "mine",
|
||||
label: "Mine",
|
||||
},
|
||||
{
|
||||
value: "recent",
|
||||
label: "Recent",
|
||||
},
|
||||
{ value: "unread", label: "Unread" },
|
||||
{ value: "all", label: "All" },
|
||||
]}
|
||||
/>
|
||||
</Tabs>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{canMarkAllRead && (
|
||||
<Button
|
||||
type="button"
|
||||
|
|
@ -985,44 +1249,44 @@ export function Inbox() {
|
|||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{tab === "all" && (
|
||||
<div className="flex flex-wrap items-center gap-2 sm:justify-end">
|
||||
{tab === "all" && (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Select
|
||||
value={allCategoryFilter}
|
||||
onValueChange={(value) => setAllCategoryFilter(value as InboxCategoryFilter)}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-[170px] text-xs">
|
||||
<SelectValue placeholder="Category" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="everything">All categories</SelectItem>
|
||||
<SelectItem value="issues_i_touched">My recent issues</SelectItem>
|
||||
<SelectItem value="join_requests">Join requests</SelectItem>
|
||||
<SelectItem value="approvals">Approvals</SelectItem>
|
||||
<SelectItem value="failed_runs">Failed runs</SelectItem>
|
||||
<SelectItem value="alerts">Alerts</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{showApprovalsCategory && (
|
||||
<Select
|
||||
value={allCategoryFilter}
|
||||
onValueChange={(value) => setAllCategoryFilter(value as InboxCategoryFilter)}
|
||||
value={allApprovalFilter}
|
||||
onValueChange={(value) => setAllApprovalFilter(value as InboxApprovalFilter)}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-[170px] text-xs">
|
||||
<SelectValue placeholder="Category" />
|
||||
<SelectValue placeholder="Approval status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="everything">All categories</SelectItem>
|
||||
<SelectItem value="issues_i_touched">My recent issues</SelectItem>
|
||||
<SelectItem value="join_requests">Join requests</SelectItem>
|
||||
<SelectItem value="approvals">Approvals</SelectItem>
|
||||
<SelectItem value="failed_runs">Failed runs</SelectItem>
|
||||
<SelectItem value="alerts">Alerts</SelectItem>
|
||||
<SelectItem value="all">All approval statuses</SelectItem>
|
||||
<SelectItem value="actionable">Needs action</SelectItem>
|
||||
<SelectItem value="resolved">Resolved</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{showApprovalsCategory && (
|
||||
<Select
|
||||
value={allApprovalFilter}
|
||||
onValueChange={(value) => setAllApprovalFilter(value as InboxApprovalFilter)}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-[170px] text-xs">
|
||||
<SelectValue placeholder="Approval status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All approval statuses</SelectItem>
|
||||
<SelectItem value="actionable">Needs action</SelectItem>
|
||||
<SelectItem value="resolved">Resolved</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{approvalsError && <p className="text-sm text-destructive">{approvalsError.message}</p>}
|
||||
{actionError && <p className="text-sm text-destructive">{actionError}</p>}
|
||||
|
|
@ -1050,9 +1314,36 @@ export function Inbox() {
|
|||
<>
|
||||
{showSeparatorBefore("work_items") && <Separator />}
|
||||
<div>
|
||||
<div className="overflow-hidden rounded-xl border border-border bg-card">
|
||||
{workItemsToRender.map((item) => {
|
||||
const isMineTab = tab === "mine";
|
||||
<div ref={listRef} className="overflow-hidden rounded-xl border border-border bg-card">
|
||||
{workItemsToRender.flatMap((item, index) => {
|
||||
const wrapItem = (key: string, isSelected: boolean, child: ReactNode) => (
|
||||
<div
|
||||
key={`sel-${key}`}
|
||||
data-inbox-item
|
||||
className="relative"
|
||||
onClick={() => setSelectedIndex(index)}
|
||||
>
|
||||
{child}
|
||||
</div>
|
||||
);
|
||||
const todayCutoff = Date.now() - 24 * 60 * 60 * 1000;
|
||||
const showTodayDivider =
|
||||
index > 0 &&
|
||||
item.timestamp > 0 &&
|
||||
item.timestamp < todayCutoff &&
|
||||
workItemsToRender[index - 1].timestamp >= todayCutoff;
|
||||
const elements: ReactNode[] = [];
|
||||
if (showTodayDivider) {
|
||||
elements.push(
|
||||
<div key="today-divider" className="flex items-center gap-3 px-4 my-2">
|
||||
<div className="flex-1 border-t border-border" />
|
||||
<span className="shrink-0 text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Earlier
|
||||
</span>
|
||||
</div>,
|
||||
);
|
||||
}
|
||||
const isSelected = selectedIndex === index;
|
||||
|
||||
if (item.kind === "approval") {
|
||||
const approvalKey = `approval:${item.approval.id}`;
|
||||
|
|
@ -1061,13 +1352,14 @@ 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)}
|
||||
isPending={approveMutation.isPending || rejectMutation.isPending}
|
||||
unreadState={nonIssueUnreadState(approvalKey)}
|
||||
onMarkRead={() => handleMarkNonIssueRead(approvalKey)}
|
||||
onArchive={isMineTab ? () => handleArchiveNonIssue(approvalKey) : undefined}
|
||||
onArchive={canArchiveFromTab ? () => handleArchiveNonIssue(approvalKey) : undefined}
|
||||
archiveDisabled={isArchiving}
|
||||
className={
|
||||
isArchiving
|
||||
|
|
@ -1076,15 +1368,17 @@ export function Inbox() {
|
|||
}
|
||||
/>
|
||||
);
|
||||
return isMineTab ? (
|
||||
elements.push(wrapItem(approvalKey, isSelected, canArchiveFromTab ? (
|
||||
<SwipeToArchive
|
||||
key={approvalKey}
|
||||
selected={isSelected}
|
||||
disabled={isArchiving}
|
||||
onArchive={() => handleArchiveNonIssue(approvalKey)}
|
||||
>
|
||||
{row}
|
||||
</SwipeToArchive>
|
||||
) : row;
|
||||
) : row));
|
||||
return elements;
|
||||
}
|
||||
|
||||
if (item.kind === "failed_run") {
|
||||
|
|
@ -1094,6 +1388,7 @@ export function Inbox() {
|
|||
<FailedRunInboxRow
|
||||
key={runKey}
|
||||
run={item.run}
|
||||
selected={isSelected}
|
||||
issueById={issueById}
|
||||
agentName={agentName(item.run.agentId)}
|
||||
issueLinkState={issueLinkState}
|
||||
|
|
@ -1102,7 +1397,7 @@ export function Inbox() {
|
|||
isRetrying={retryingRunIds.has(item.run.id)}
|
||||
unreadState={nonIssueUnreadState(runKey)}
|
||||
onMarkRead={() => handleMarkNonIssueRead(runKey)}
|
||||
onArchive={isMineTab ? () => handleArchiveNonIssue(runKey) : undefined}
|
||||
onArchive={canArchiveFromTab ? () => handleArchiveNonIssue(runKey) : undefined}
|
||||
archiveDisabled={isArchiving}
|
||||
className={
|
||||
isArchiving
|
||||
|
|
@ -1111,15 +1406,17 @@ export function Inbox() {
|
|||
}
|
||||
/>
|
||||
);
|
||||
return isMineTab ? (
|
||||
elements.push(wrapItem(runKey, isSelected, canArchiveFromTab ? (
|
||||
<SwipeToArchive
|
||||
key={runKey}
|
||||
selected={isSelected}
|
||||
disabled={isArchiving}
|
||||
onArchive={() => handleArchiveNonIssue(runKey)}
|
||||
>
|
||||
{row}
|
||||
</SwipeToArchive>
|
||||
) : row;
|
||||
) : row));
|
||||
return elements;
|
||||
}
|
||||
|
||||
if (item.kind === "join_request") {
|
||||
|
|
@ -1129,12 +1426,13 @@ 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}
|
||||
unreadState={nonIssueUnreadState(joinKey)}
|
||||
onMarkRead={() => handleMarkNonIssueRead(joinKey)}
|
||||
onArchive={isMineTab ? () => handleArchiveNonIssue(joinKey) : undefined}
|
||||
onArchive={canArchiveFromTab ? () => handleArchiveNonIssue(joinKey) : undefined}
|
||||
archiveDisabled={isArchiving}
|
||||
className={
|
||||
isArchiving
|
||||
|
|
@ -1143,15 +1441,17 @@ export function Inbox() {
|
|||
}
|
||||
/>
|
||||
);
|
||||
return isMineTab ? (
|
||||
elements.push(wrapItem(joinKey, isSelected, canArchiveFromTab ? (
|
||||
<SwipeToArchive
|
||||
key={joinKey}
|
||||
selected={isSelected}
|
||||
disabled={isArchiving}
|
||||
onArchive={() => handleArchiveNonIssue(joinKey)}
|
||||
>
|
||||
{row}
|
||||
</SwipeToArchive>
|
||||
) : row;
|
||||
) : row));
|
||||
return elements;
|
||||
}
|
||||
|
||||
const issue = item.issue;
|
||||
|
|
@ -1163,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)}`
|
||||
|
|
@ -1199,7 +1486,7 @@ export function Inbox() {
|
|||
}
|
||||
onMarkRead={() => markReadMutation.mutate(issue.id)}
|
||||
onArchive={
|
||||
isMineTab
|
||||
canArchiveFromTab
|
||||
? () => archiveIssueMutation.mutate(issue.id)
|
||||
: undefined
|
||||
}
|
||||
|
|
@ -1212,15 +1499,17 @@ export function Inbox() {
|
|||
/>
|
||||
);
|
||||
|
||||
return isMineTab ? (
|
||||
elements.push(wrapItem(`issue:${issue.id}`, isSelected, canArchiveFromTab ? (
|
||||
<SwipeToArchive
|
||||
key={`issue:${issue.id}`}
|
||||
selected={isSelected}
|
||||
disabled={isArchiving || archiveIssueMutation.isPending}
|
||||
onArchive={() => archiveIssueMutation.mutate(issue.id)}
|
||||
>
|
||||
{row}
|
||||
</SwipeToArchive>
|
||||
) : row;
|
||||
) : row));
|
||||
return elements;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -14,7 +14,16 @@ import { useToast } from "../context/ToastContext";
|
|||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||
import { assigneeValueFromSelection, suggestedCommentAssigneeValue } from "../lib/assignees";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { readIssueDetailBreadcrumb } from "../lib/issueDetailBreadcrumb";
|
||||
import { createIssueDetailPath, readIssueDetailBreadcrumb } from "../lib/issueDetailBreadcrumb";
|
||||
import {
|
||||
applyOptimisticIssueCommentUpdate,
|
||||
createOptimisticIssueComment,
|
||||
isQueuedIssueComment,
|
||||
mergeIssueComments,
|
||||
upsertIssueComment,
|
||||
type IssueCommentReassignment,
|
||||
type OptimisticIssueComment,
|
||||
} from "../lib/optimistic-issue-comments";
|
||||
import { useProjectOrder } from "../hooks/useProjectOrder";
|
||||
import { relativeTime, cn, formatTokens, visibleRunCostUsd } from "../lib/utils";
|
||||
import { InlineEditor } from "../components/InlineEditor";
|
||||
|
|
@ -55,11 +64,15 @@ import {
|
|||
Trash2,
|
||||
} from "lucide-react";
|
||||
import type { ActivityEvent } from "@paperclipai/shared";
|
||||
import type { Agent, IssueAttachment } from "@paperclipai/shared";
|
||||
import type { Agent, Issue, IssueAttachment, IssueComment } from "@paperclipai/shared";
|
||||
|
||||
type CommentReassignment = {
|
||||
assigneeAgentId: string | null;
|
||||
assigneeUserId: string | null;
|
||||
type CommentReassignment = IssueCommentReassignment;
|
||||
type IssueDetailComment = (IssueComment | OptimisticIssueComment) & {
|
||||
runId?: string | null;
|
||||
runAgentId?: string | null;
|
||||
interruptedRunId?: string | null;
|
||||
queueState?: "queued";
|
||||
queueTargetRunId?: string | null;
|
||||
};
|
||||
|
||||
const ACTION_LABELS: Record<string, string> = {
|
||||
|
|
@ -213,6 +226,7 @@ export function IssueDetail() {
|
|||
});
|
||||
const [attachmentError, setAttachmentError] = useState<string | null>(null);
|
||||
const [attachmentDragActive, setAttachmentDragActive] = useState(false);
|
||||
const [optimisticComments, setOptimisticComments] = useState<OptimisticIssueComment[]>([]);
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const lastMarkedReadIssueIdRef = useRef<string | null>(null);
|
||||
|
||||
|
|
@ -269,9 +283,17 @@ export function IssueDetail() {
|
|||
});
|
||||
|
||||
const hasLiveRuns = (liveRuns ?? []).length > 0 || !!activeRun;
|
||||
const runningIssueRun = useMemo(
|
||||
() => (
|
||||
activeRun?.status === "running"
|
||||
? activeRun
|
||||
: (liveRuns ?? []).find((run) => run.status === "running") ?? null
|
||||
),
|
||||
[activeRun, liveRuns],
|
||||
);
|
||||
const sourceBreadcrumb = useMemo(
|
||||
() => readIssueDetailBreadcrumb(location.state) ?? { label: "Issues", href: "/issues" },
|
||||
[location.state],
|
||||
() => readIssueDetailBreadcrumb(location.state, location.search) ?? { label: "Issues", href: "/issues" },
|
||||
[location.state, location.search],
|
||||
);
|
||||
|
||||
// Filter out runs already shown by the live widget to avoid duplication
|
||||
|
|
@ -386,12 +408,23 @@ export function IssueDetail() {
|
|||
);
|
||||
|
||||
const suggestedAssigneeValue = useMemo(
|
||||
() => suggestedCommentAssigneeValue(issue ?? {}, comments, currentUserId),
|
||||
[issue, comments, currentUserId],
|
||||
() =>
|
||||
suggestedCommentAssigneeValue(
|
||||
issue ?? {},
|
||||
mergeIssueComments(comments ?? [], optimisticComments),
|
||||
currentUserId,
|
||||
),
|
||||
[issue, comments, optimisticComments, currentUserId],
|
||||
);
|
||||
|
||||
const commentsWithRunMeta = useMemo(() => {
|
||||
const runMetaByCommentId = new Map<string, { runId: string; runAgentId: string | null }>();
|
||||
const threadComments = useMemo(
|
||||
() => mergeIssueComments(comments ?? [], optimisticComments),
|
||||
[comments, optimisticComments],
|
||||
);
|
||||
|
||||
const commentsWithRunMeta = useMemo<IssueDetailComment[]>(() => {
|
||||
const activeRunStartedAt = runningIssueRun?.startedAt ?? runningIssueRun?.createdAt ?? null;
|
||||
const runMetaByCommentId = new Map<string, { runId: string; runAgentId: string | null; interruptedRunId: string | null }>();
|
||||
const agentIdByRunId = new Map<string, string>();
|
||||
for (const run of linkedRuns ?? []) {
|
||||
agentIdByRunId.set(run.runId, run.agentId);
|
||||
|
|
@ -401,16 +434,44 @@ export function IssueDetail() {
|
|||
const details = evt.details ?? {};
|
||||
const commentId = typeof details["commentId"] === "string" ? details["commentId"] : null;
|
||||
if (!commentId || runMetaByCommentId.has(commentId)) continue;
|
||||
const interruptedRunId =
|
||||
typeof details["interruptedRunId"] === "string" ? details["interruptedRunId"] : null;
|
||||
runMetaByCommentId.set(commentId, {
|
||||
runId: evt.runId,
|
||||
runAgentId: evt.agentId ?? agentIdByRunId.get(evt.runId) ?? null,
|
||||
interruptedRunId,
|
||||
});
|
||||
}
|
||||
return (comments ?? []).map((comment) => {
|
||||
return threadComments.map((comment) => {
|
||||
const meta = runMetaByCommentId.get(comment.id);
|
||||
return meta ? { ...comment, ...meta } : comment;
|
||||
const nextComment: IssueDetailComment = meta ? { ...comment, ...meta } : { ...comment };
|
||||
if (
|
||||
isQueuedIssueComment({
|
||||
comment: nextComment,
|
||||
activeRunStartedAt,
|
||||
runId: meta?.runId ?? nextComment.runId ?? null,
|
||||
interruptedRunId: meta?.interruptedRunId ?? nextComment.interruptedRunId ?? null,
|
||||
})
|
||||
) {
|
||||
return {
|
||||
...nextComment,
|
||||
queueState: "queued" as const,
|
||||
queueTargetRunId: runningIssueRun?.id ?? nextComment.queueTargetRunId ?? null,
|
||||
};
|
||||
}
|
||||
return nextComment;
|
||||
});
|
||||
}, [activity, comments, linkedRuns]);
|
||||
}, [activity, threadComments, linkedRuns, runningIssueRun]);
|
||||
|
||||
const queuedComments = useMemo(
|
||||
() => commentsWithRunMeta.filter((comment) => comment.queueState === "queued"),
|
||||
[commentsWithRunMeta],
|
||||
);
|
||||
|
||||
const timelineComments = useMemo(
|
||||
() => commentsWithRunMeta.filter((comment) => comment.queueState !== "queued"),
|
||||
[commentsWithRunMeta],
|
||||
);
|
||||
|
||||
const issueCostSummary = useMemo(() => {
|
||||
let input = 0;
|
||||
|
|
@ -489,9 +550,67 @@ export function IssueDetail() {
|
|||
});
|
||||
|
||||
const addComment = useMutation({
|
||||
mutationFn: ({ body, reopen }: { body: string; reopen?: boolean }) =>
|
||||
issuesApi.addComment(issueId!, body, reopen),
|
||||
onSuccess: () => {
|
||||
mutationFn: ({ body, reopen, interrupt }: { body: string; reopen?: boolean; interrupt?: boolean }) =>
|
||||
issuesApi.addComment(issueId!, body, reopen, interrupt),
|
||||
onMutate: async ({ body, reopen, interrupt }) => {
|
||||
await queryClient.cancelQueries({ queryKey: queryKeys.issues.comments(issueId!) });
|
||||
await queryClient.cancelQueries({ queryKey: queryKeys.issues.detail(issueId!) });
|
||||
|
||||
const previousIssue = queryClient.getQueryData<Issue>(queryKeys.issues.detail(issueId!));
|
||||
const queuedComment = !interrupt && runningIssueRun;
|
||||
const optimisticComment = issue
|
||||
? createOptimisticIssueComment({
|
||||
companyId: issue.companyId,
|
||||
issueId: issue.id,
|
||||
body,
|
||||
authorUserId: currentUserId,
|
||||
clientStatus: queuedComment ? "queued" : "pending",
|
||||
queueTargetRunId: queuedComment ? runningIssueRun.id : null,
|
||||
})
|
||||
: null;
|
||||
|
||||
if (optimisticComment) {
|
||||
setOptimisticComments((current) => [...current, optimisticComment]);
|
||||
}
|
||||
if (previousIssue) {
|
||||
queryClient.setQueryData(
|
||||
queryKeys.issues.detail(issueId!),
|
||||
applyOptimisticIssueCommentUpdate(previousIssue, { reopen }),
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
optimisticCommentId: optimisticComment?.clientId ?? null,
|
||||
previousIssue,
|
||||
};
|
||||
},
|
||||
onSuccess: (comment, _variables, context) => {
|
||||
if (context?.optimisticCommentId) {
|
||||
setOptimisticComments((current) =>
|
||||
current.filter((entry) => entry.clientId !== context.optimisticCommentId),
|
||||
);
|
||||
}
|
||||
queryClient.setQueryData<IssueComment[]>(
|
||||
queryKeys.issues.comments(issueId!),
|
||||
(current) => upsertIssueComment(current, comment),
|
||||
);
|
||||
},
|
||||
onError: (err, _variables, context) => {
|
||||
if (context?.optimisticCommentId) {
|
||||
setOptimisticComments((current) =>
|
||||
current.filter((entry) => entry.clientId !== context.optimisticCommentId),
|
||||
);
|
||||
}
|
||||
if (context?.previousIssue) {
|
||||
queryClient.setQueryData(queryKeys.issues.detail(issueId!), context.previousIssue);
|
||||
}
|
||||
pushToast({
|
||||
title: "Comment failed",
|
||||
body: err instanceof Error ? err.message : "Unable to post comment",
|
||||
tone: "error",
|
||||
});
|
||||
},
|
||||
onSettled: () => {
|
||||
invalidateIssue();
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.comments(issueId!) });
|
||||
},
|
||||
|
|
@ -501,10 +620,12 @@ export function IssueDetail() {
|
|||
mutationFn: ({
|
||||
body,
|
||||
reopen,
|
||||
interrupt,
|
||||
reassignment,
|
||||
}: {
|
||||
body: string;
|
||||
reopen?: boolean;
|
||||
interrupt?: boolean;
|
||||
reassignment: CommentReassignment;
|
||||
}) =>
|
||||
issuesApi.update(issueId!, {
|
||||
|
|
@ -512,13 +633,96 @@ export function IssueDetail() {
|
|||
assigneeAgentId: reassignment.assigneeAgentId,
|
||||
assigneeUserId: reassignment.assigneeUserId,
|
||||
...(reopen ? { status: "todo" } : {}),
|
||||
...(interrupt ? { interrupt } : {}),
|
||||
}),
|
||||
onSuccess: () => {
|
||||
onMutate: async ({ body, reopen, reassignment, interrupt }) => {
|
||||
await queryClient.cancelQueries({ queryKey: queryKeys.issues.comments(issueId!) });
|
||||
await queryClient.cancelQueries({ queryKey: queryKeys.issues.detail(issueId!) });
|
||||
|
||||
const previousIssue = queryClient.getQueryData<Issue>(queryKeys.issues.detail(issueId!));
|
||||
const queuedComment = !interrupt && runningIssueRun;
|
||||
const optimisticComment = issue
|
||||
? createOptimisticIssueComment({
|
||||
companyId: issue.companyId,
|
||||
issueId: issue.id,
|
||||
body,
|
||||
authorUserId: currentUserId,
|
||||
clientStatus: queuedComment ? "queued" : "pending",
|
||||
queueTargetRunId: queuedComment ? runningIssueRun.id : null,
|
||||
})
|
||||
: null;
|
||||
|
||||
if (optimisticComment) {
|
||||
setOptimisticComments((current) => [...current, optimisticComment]);
|
||||
}
|
||||
if (previousIssue) {
|
||||
queryClient.setQueryData(
|
||||
queryKeys.issues.detail(issueId!),
|
||||
applyOptimisticIssueCommentUpdate(previousIssue, { reopen, reassignment }),
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
optimisticCommentId: optimisticComment?.clientId ?? null,
|
||||
previousIssue,
|
||||
};
|
||||
},
|
||||
onSuccess: (result, _variables, context) => {
|
||||
if (context?.optimisticCommentId) {
|
||||
setOptimisticComments((current) =>
|
||||
current.filter((entry) => entry.clientId !== context.optimisticCommentId),
|
||||
);
|
||||
}
|
||||
|
||||
const { comment, ...nextIssue } = result;
|
||||
queryClient.setQueryData(queryKeys.issues.detail(issueId!), nextIssue);
|
||||
if (comment) {
|
||||
queryClient.setQueryData<IssueComment[]>(
|
||||
queryKeys.issues.comments(issueId!),
|
||||
(current) => upsertIssueComment(current, comment),
|
||||
);
|
||||
}
|
||||
},
|
||||
onError: (err, _variables, context) => {
|
||||
if (context?.optimisticCommentId) {
|
||||
setOptimisticComments((current) =>
|
||||
current.filter((entry) => entry.clientId !== context.optimisticCommentId),
|
||||
);
|
||||
}
|
||||
if (context?.previousIssue) {
|
||||
queryClient.setQueryData(queryKeys.issues.detail(issueId!), context.previousIssue);
|
||||
}
|
||||
pushToast({
|
||||
title: "Comment failed",
|
||||
body: err instanceof Error ? err.message : "Unable to post comment",
|
||||
tone: "error",
|
||||
});
|
||||
},
|
||||
onSettled: () => {
|
||||
invalidateIssue();
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.comments(issueId!) });
|
||||
},
|
||||
});
|
||||
|
||||
const interruptQueuedComment = useMutation({
|
||||
mutationFn: (runId: string) => heartbeatsApi.cancel(runId),
|
||||
onSuccess: () => {
|
||||
invalidateIssue();
|
||||
pushToast({
|
||||
title: "Interrupt requested",
|
||||
body: "The active run is stopping so queued comments can continue next.",
|
||||
tone: "success",
|
||||
});
|
||||
},
|
||||
onError: (err) => {
|
||||
pushToast({
|
||||
title: "Interrupt failed",
|
||||
body: err instanceof Error ? err.message : "Unable to interrupt the active run",
|
||||
tone: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const uploadAttachment = useMutation({
|
||||
mutationFn: async (file: File) => {
|
||||
if (!selectedCompanyId) throw new Error("No company selected");
|
||||
|
|
@ -581,9 +785,12 @@ export function IssueDetail() {
|
|||
// Redirect to identifier-based URL if navigated via UUID
|
||||
useEffect(() => {
|
||||
if (issue?.identifier && issueId !== issue.identifier) {
|
||||
navigate(`/issues/${issue.identifier}`, { replace: true, state: location.state });
|
||||
navigate(createIssueDetailPath(issue.identifier, location.state, location.search), {
|
||||
replace: true,
|
||||
state: location.state,
|
||||
});
|
||||
}
|
||||
}, [issue, issueId, navigate, location.state]);
|
||||
}, [issue, issueId, navigate, location.state, location.search]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!issue?.id) return;
|
||||
|
|
@ -695,7 +902,7 @@ export function IssueDetail() {
|
|||
<span key={ancestor.id} className="flex items-center gap-1">
|
||||
{i > 0 && <ChevronRight className="h-3 w-3 shrink-0" />}
|
||||
<Link
|
||||
to={`/issues/${ancestor.identifier ?? ancestor.id}`}
|
||||
to={createIssueDetailPath(ancestor.identifier ?? ancestor.id, location.state, location.search)}
|
||||
state={location.state}
|
||||
className="hover:text-foreground transition-colors truncate max-w-[200px]"
|
||||
title={ancestor.title}
|
||||
|
|
@ -1025,7 +1232,8 @@ export function IssueDetail() {
|
|||
|
||||
<TabsContent value="comments">
|
||||
<CommentThread
|
||||
comments={commentsWithRunMeta}
|
||||
comments={timelineComments}
|
||||
queuedComments={queuedComments}
|
||||
linkedRuns={timelineRuns}
|
||||
companyId={issue.companyId}
|
||||
projectId={issue.projectId}
|
||||
|
|
@ -1037,6 +1245,10 @@ export function IssueDetail() {
|
|||
currentAssigneeValue={actualAssigneeValue}
|
||||
suggestedAssigneeValue={suggestedAssigneeValue}
|
||||
mentions={mentionOptions}
|
||||
onInterruptQueued={async (runId) => {
|
||||
await interruptQueuedComment.mutateAsync(runId);
|
||||
}}
|
||||
interruptingQueuedRunId={interruptQueuedComment.isPending ? runningIssueRun?.id ?? null : null}
|
||||
onAdd={async (body, reopen, reassignment) => {
|
||||
if (reassignment) {
|
||||
await addCommentAndReassign.mutateAsync({ body, reopen, reassignment });
|
||||
|
|
@ -1063,7 +1275,7 @@ export function IssueDetail() {
|
|||
{childIssues.map((child) => (
|
||||
<Link
|
||||
key={child.id}
|
||||
to={`/issues/${child.identifier ?? child.id}`}
|
||||
to={createIssueDetailPath(child.identifier ?? child.id, location.state, location.search)}
|
||||
state={location.state}
|
||||
className="flex items-center justify-between px-3 py-2 text-sm hover:bg-accent/20 transition-colors"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -70,6 +70,7 @@ export function Issues() {
|
|||
createIssueDetailLocationState(
|
||||
"Issues",
|
||||
`${location.pathname}${location.search}${location.hash}`,
|
||||
"issues",
|
||||
),
|
||||
[location.pathname, location.search, location.hash],
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue