mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-17 03:10:38 +09:00
Merge pull request #3222 from paperclipai/pap-1266-issue-workflow
feat(issue-ui): refine issue workflow surfaces and live updates
This commit is contained in:
commit
0e87fdbe35
50 changed files with 2860 additions and 1206 deletions
|
|
@ -18,11 +18,18 @@ import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
|||
import { useGeneralSettings } from "../context/GeneralSettingsContext";
|
||||
import { useSidebar } from "../context/SidebarContext";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import {
|
||||
applyIssueFilters,
|
||||
countActiveIssueFilters,
|
||||
defaultIssueFilterState,
|
||||
type IssueFilterState,
|
||||
} from "../lib/issue-filters";
|
||||
import {
|
||||
armIssueDetailInboxQuickArchive,
|
||||
createIssueDetailLocationState,
|
||||
createIssueDetailPath,
|
||||
rememberIssueDetailLocationState,
|
||||
withIssueDetailHeaderSeed,
|
||||
} from "../lib/issueDetailBreadcrumb";
|
||||
import { hasBlockingShortcutDialog, isKeyboardShortcutTextInputTarget } from "../lib/keyboardShortcuts";
|
||||
import { EmptyState } from "../components/EmptyState";
|
||||
|
|
@ -34,6 +41,7 @@ import {
|
|||
issueActivityText,
|
||||
issueTrailingColumns,
|
||||
} from "../components/IssueColumns";
|
||||
import { IssueFiltersPopover } from "../components/IssueFiltersPopover";
|
||||
import { IssueRow } from "../components/IssueRow";
|
||||
import { SwipeToArchive } from "../components/SwipeToArchive";
|
||||
|
||||
|
|
@ -60,10 +68,13 @@ import {
|
|||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import {
|
||||
Inbox as InboxIcon,
|
||||
AlertTriangle,
|
||||
Check,
|
||||
ChevronRight,
|
||||
Layers,
|
||||
XCircle,
|
||||
X,
|
||||
RotateCcw,
|
||||
|
|
@ -84,22 +95,26 @@ import {
|
|||
getInboxKeyboardSelectionIndex,
|
||||
getLatestFailedRunsByAgent,
|
||||
getRecentTouchedIssues,
|
||||
groupInboxWorkItems,
|
||||
isInboxEntityDismissed,
|
||||
isMineInboxTab,
|
||||
loadInboxIssueColumns,
|
||||
loadInboxNesting,
|
||||
loadInboxWorkItemGroupBy,
|
||||
normalizeInboxIssueColumns,
|
||||
resolveInboxNestingEnabled,
|
||||
resolveIssueWorkspaceName,
|
||||
resolveInboxSelectionIndex,
|
||||
saveInboxIssueColumns,
|
||||
saveInboxNesting,
|
||||
InboxApprovalFilter,
|
||||
saveInboxWorkItemGroupBy,
|
||||
type InboxApprovalFilter,
|
||||
type InboxIssueColumn,
|
||||
saveLastInboxTab,
|
||||
shouldShowInboxSection,
|
||||
type InboxTab,
|
||||
type InboxWorkItem,
|
||||
type InboxWorkItemGroupBy,
|
||||
} from "../lib/inbox";
|
||||
import { useDismissedInboxAlerts, useInboxDismissals, useReadInboxItems } from "../hooks/useInboxBadge";
|
||||
|
||||
|
|
@ -121,6 +136,13 @@ type NavEntry =
|
|||
| { type: "top"; index: number; item: InboxWorkItem }
|
||||
| { type: "child"; parentIndex: number; issue: Issue };
|
||||
|
||||
type InboxGroupedSection = {
|
||||
key: string;
|
||||
label: string | null;
|
||||
displayItems: InboxWorkItem[];
|
||||
childrenByIssueId: Map<string, Issue[]>;
|
||||
};
|
||||
|
||||
function firstNonEmptyLine(value: string | null | undefined): string | null {
|
||||
if (!value) return null;
|
||||
const line = value.split("\n").map((chunk) => chunk.trim()).find(Boolean);
|
||||
|
|
@ -596,6 +618,8 @@ export function Inbox() {
|
|||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [allCategoryFilter, setAllCategoryFilter] = useState<InboxCategoryFilter>("everything");
|
||||
const [allApprovalFilter, setAllApprovalFilter] = useState<InboxApprovalFilter>("all");
|
||||
const [issueFilters, setIssueFilters] = useState<IssueFilterState>(defaultIssueFilterState);
|
||||
const [groupBy, setGroupBy] = useState<InboxWorkItemGroupBy>(() => loadInboxWorkItemGroupBy());
|
||||
const [visibleIssueColumns, setVisibleIssueColumns] = useState<InboxIssueColumn[]>(loadInboxIssueColumns);
|
||||
const { dismissed: dismissedAlerts, dismiss: dismissAlert } = useDismissedInboxAlerts();
|
||||
const { dismissedAtByKey, dismiss: dismissInboxItem } = useInboxDismissals(selectedCompanyId);
|
||||
|
|
@ -633,6 +657,11 @@ export function Inbox() {
|
|||
queryFn: () => projectsApi.list(selectedCompanyId!),
|
||||
enabled: !!selectedCompanyId,
|
||||
});
|
||||
const { data: labels } = useQuery({
|
||||
queryKey: queryKeys.issues.labels(selectedCompanyId!),
|
||||
queryFn: () => issuesApi.listLabels(selectedCompanyId!),
|
||||
enabled: !!selectedCompanyId,
|
||||
});
|
||||
const isolatedWorkspacesEnabled = experimentalSettings?.enableIsolatedWorkspaces === true;
|
||||
const { data: executionWorkspaces = [] } = useQuery({
|
||||
queryKey: selectedCompanyId
|
||||
|
|
@ -688,20 +717,21 @@ export function Inbox() {
|
|||
});
|
||||
|
||||
const { data: issues, isLoading: isIssuesLoading } = useQuery({
|
||||
queryKey: queryKeys.issues.list(selectedCompanyId!),
|
||||
queryFn: () => issuesApi.list(selectedCompanyId!),
|
||||
queryKey: [...queryKeys.issues.list(selectedCompanyId!), "with-routine-executions"],
|
||||
queryFn: () => issuesApi.list(selectedCompanyId!, { includeRoutineExecutions: true }),
|
||||
enabled: !!selectedCompanyId,
|
||||
});
|
||||
const {
|
||||
data: mineIssuesRaw = [],
|
||||
isLoading: isMineIssuesLoading,
|
||||
} = useQuery({
|
||||
queryKey: queryKeys.issues.listMineByMe(selectedCompanyId!),
|
||||
queryKey: [...queryKeys.issues.listMineByMe(selectedCompanyId!), "with-routine-executions"],
|
||||
queryFn: () =>
|
||||
issuesApi.list(selectedCompanyId!, {
|
||||
touchedByUserId: "me",
|
||||
inboxArchivedByUserId: "me",
|
||||
status: INBOX_MINE_ISSUE_STATUS_FILTER,
|
||||
includeRoutineExecutions: true,
|
||||
}),
|
||||
enabled: !!selectedCompanyId,
|
||||
});
|
||||
|
|
@ -709,11 +739,12 @@ export function Inbox() {
|
|||
data: touchedIssuesRaw = [],
|
||||
isLoading: isTouchedIssuesLoading,
|
||||
} = useQuery({
|
||||
queryKey: queryKeys.issues.listTouchedByMe(selectedCompanyId!),
|
||||
queryKey: [...queryKeys.issues.listTouchedByMe(selectedCompanyId!), "with-routine-executions"],
|
||||
queryFn: () =>
|
||||
issuesApi.list(selectedCompanyId!, {
|
||||
touchedByUserId: "me",
|
||||
status: INBOX_MINE_ISSUE_STATUS_FILTER,
|
||||
includeRoutineExecutions: true,
|
||||
}),
|
||||
enabled: !!selectedCompanyId,
|
||||
});
|
||||
|
|
@ -723,20 +754,29 @@ export function Inbox() {
|
|||
queryFn: () => heartbeatsApi.list(selectedCompanyId!),
|
||||
enabled: !!selectedCompanyId,
|
||||
});
|
||||
const currentUserId = session?.user.id ?? session?.session.userId ?? null;
|
||||
|
||||
const mineIssues = useMemo(() => getRecentTouchedIssues(mineIssuesRaw), [mineIssuesRaw]);
|
||||
const touchedIssues = useMemo(() => getRecentTouchedIssues(touchedIssuesRaw), [touchedIssuesRaw]);
|
||||
const visibleMineIssues = useMemo(
|
||||
() => applyIssueFilters(mineIssues, issueFilters, currentUserId, true),
|
||||
[mineIssues, issueFilters, currentUserId],
|
||||
);
|
||||
const visibleTouchedIssues = useMemo(
|
||||
() => applyIssueFilters(touchedIssues, issueFilters, currentUserId, true),
|
||||
[touchedIssues, issueFilters, currentUserId],
|
||||
);
|
||||
const unreadTouchedIssues = useMemo(
|
||||
() => touchedIssues.filter((issue) => issue.isUnreadForMe),
|
||||
[touchedIssues],
|
||||
() => visibleTouchedIssues.filter((issue) => issue.isUnreadForMe),
|
||||
[visibleTouchedIssues],
|
||||
);
|
||||
const issuesToRender = useMemo(
|
||||
() => {
|
||||
if (tab === "mine") return mineIssues;
|
||||
if (tab === "mine") return visibleMineIssues;
|
||||
if (tab === "unread") return unreadTouchedIssues;
|
||||
return touchedIssues;
|
||||
return visibleTouchedIssues;
|
||||
},
|
||||
[tab, mineIssues, touchedIssues, unreadTouchedIssues],
|
||||
[tab, visibleMineIssues, visibleTouchedIssues, unreadTouchedIssues],
|
||||
);
|
||||
|
||||
const agentById = useMemo(() => {
|
||||
|
|
@ -802,7 +842,6 @@ export function Inbox() {
|
|||
() => issueTrailingColumns.filter((column) => visibleIssueColumnSet.has(column) && availableIssueColumnSet.has(column)),
|
||||
[availableIssueColumnSet, visibleIssueColumnSet],
|
||||
);
|
||||
const currentUserId = session?.user.id ?? session?.session.userId ?? null;
|
||||
|
||||
const failedRuns = useMemo(
|
||||
() =>
|
||||
|
|
@ -935,11 +974,36 @@ export function Inbox() {
|
|||
});
|
||||
}, []);
|
||||
const [collapsedInboxParents, setCollapsedInboxParents] = useState<Set<string>>(new Set());
|
||||
const { displayItems: nestedWorkItems, childrenByIssueId } = useMemo(
|
||||
() => nestingEnabled
|
||||
? buildInboxNesting(filteredWorkItems)
|
||||
: { displayItems: filteredWorkItems, childrenByIssueId: new Map<string, Issue[]>() },
|
||||
[filteredWorkItems, nestingEnabled],
|
||||
const groupedSections = useMemo<InboxGroupedSection[]>(() => {
|
||||
return groupInboxWorkItems(filteredWorkItems, groupBy).map((group) => {
|
||||
const nestedGroup = nestingEnabled && group.items.some((item) => item.kind === "issue")
|
||||
? buildInboxNesting(group.items)
|
||||
: { displayItems: group.items, childrenByIssueId: new Map<string, Issue[]>() };
|
||||
|
||||
return {
|
||||
key: group.key,
|
||||
label: group.label,
|
||||
displayItems: nestedGroup.displayItems,
|
||||
childrenByIssueId: nestedGroup.childrenByIssueId,
|
||||
};
|
||||
});
|
||||
}, [filteredWorkItems, groupBy, nestingEnabled]);
|
||||
const nestedWorkItems = useMemo(
|
||||
() => groupedSections.flatMap((group) => group.displayItems),
|
||||
[groupedSections],
|
||||
);
|
||||
const childrenByIssueId = useMemo(() => {
|
||||
const merged = new Map<string, Issue[]>();
|
||||
for (const group of groupedSections) {
|
||||
for (const [issueId, children] of group.childrenByIssueId) {
|
||||
merged.set(issueId, children);
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
}, [groupedSections]);
|
||||
const totalVisibleWorkItems = useMemo(
|
||||
() => groupedSections.reduce((count, group) => count + group.displayItems.length, 0),
|
||||
[groupedSections],
|
||||
);
|
||||
const toggleInboxParentCollapse = useCallback((parentId: string) => {
|
||||
setCollapsedInboxParents((prev) => {
|
||||
|
|
@ -953,21 +1017,24 @@ export function Inbox() {
|
|||
// Build flat navigation list including expanded children for keyboard traversal
|
||||
const flatNavItems = useMemo((): NavEntry[] => {
|
||||
const entries: NavEntry[] = [];
|
||||
for (let i = 0; i < nestedWorkItems.length; i++) {
|
||||
const item = nestedWorkItems[i];
|
||||
entries.push({ type: "top", index: i, item });
|
||||
if (item.kind === "issue") {
|
||||
const children = childrenByIssueId.get(item.issue.id);
|
||||
const isExpanded = children?.length && !collapsedInboxParents.has(item.issue.id);
|
||||
if (isExpanded) {
|
||||
for (const child of children) {
|
||||
entries.push({ type: "child", parentIndex: i, issue: child });
|
||||
let topIndex = 0;
|
||||
for (const group of groupedSections) {
|
||||
for (const item of group.displayItems) {
|
||||
entries.push({ type: "top", index: topIndex, item });
|
||||
if (item.kind === "issue") {
|
||||
const children = group.childrenByIssueId.get(item.issue.id);
|
||||
const isExpanded = children?.length && !collapsedInboxParents.has(item.issue.id);
|
||||
if (isExpanded) {
|
||||
for (const child of children) {
|
||||
entries.push({ type: "child", parentIndex: topIndex, issue: child });
|
||||
}
|
||||
}
|
||||
}
|
||||
topIndex += 1;
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}, [nestedWorkItems, childrenByIssueId, collapsedInboxParents]);
|
||||
}, [groupedSections, collapsedInboxParents]);
|
||||
|
||||
const agentName = (id: string | null) => {
|
||||
if (!id) return null;
|
||||
|
|
@ -985,6 +1052,13 @@ export function Inbox() {
|
|||
}
|
||||
setIssueColumns(visibleIssueColumns.filter((value) => value !== column));
|
||||
}, [setIssueColumns, visibleIssueColumns]);
|
||||
const updateIssueFilters = useCallback((patch: Partial<IssueFilterState>) => {
|
||||
setIssueFilters((previous) => ({ ...previous, ...patch }));
|
||||
}, []);
|
||||
const updateGroupBy = useCallback((nextGroupBy: InboxWorkItemGroupBy) => {
|
||||
setGroupBy(nextGroupBy);
|
||||
saveInboxWorkItemGroupBy(nextGroupBy);
|
||||
}, []);
|
||||
|
||||
const approveMutation = useMutation({
|
||||
mutationFn: (id: string) => approvalsApi.approve(id),
|
||||
|
|
@ -1101,8 +1175,8 @@ export function Inbox() {
|
|||
|
||||
// Cancel in-flight refetches so they don't overwrite our optimistic update
|
||||
const queryKeys_ = [
|
||||
queryKeys.issues.listMineByMe(selectedCompanyId!),
|
||||
queryKeys.issues.listTouchedByMe(selectedCompanyId!),
|
||||
[...queryKeys.issues.listMineByMe(selectedCompanyId!), "with-routine-executions"],
|
||||
[...queryKeys.issues.listTouchedByMe(selectedCompanyId!), "with-routine-executions"],
|
||||
queryKeys.issues.listUnreadTouchedByMe(selectedCompanyId!),
|
||||
];
|
||||
await Promise.all(queryKeys_.map((qk) => queryClient.cancelQueries({ queryKey: qk })));
|
||||
|
|
@ -1247,7 +1321,7 @@ export function Inbox() {
|
|||
|
||||
// Use refs for keyboard handler to avoid stale closures
|
||||
const kbStateRef = useRef({
|
||||
workItems: nestedWorkItems,
|
||||
workItems: groupedSections,
|
||||
flatNavItems,
|
||||
selectedIndex,
|
||||
canArchive: canArchiveFromTab,
|
||||
|
|
@ -1257,7 +1331,7 @@ export function Inbox() {
|
|||
readItems,
|
||||
});
|
||||
kbStateRef.current = {
|
||||
workItems: nestedWorkItems,
|
||||
workItems: groupedSections,
|
||||
flatNavItems,
|
||||
selectedIndex,
|
||||
canArchive: canArchiveFromTab,
|
||||
|
|
@ -1386,13 +1460,15 @@ export function Inbox() {
|
|||
const { issue, item } = resolveNavEntry(st.selectedIndex);
|
||||
if (issue) {
|
||||
const pathId = issue.identifier ?? issue.id;
|
||||
const detailState = armIssueDetailInboxQuickArchive(issueLinkState);
|
||||
const detailState = armIssueDetailInboxQuickArchive(withIssueDetailHeaderSeed(issueLinkState, issue));
|
||||
rememberIssueDetailLocationState(pathId, detailState);
|
||||
act.navigate(createIssueDetailPath(pathId), { state: detailState });
|
||||
} else if (item) {
|
||||
if (item.kind === "issue") {
|
||||
const pathId = item.issue.identifier ?? item.issue.id;
|
||||
const detailState = armIssueDetailInboxQuickArchive(issueLinkState);
|
||||
const detailState = armIssueDetailInboxQuickArchive(
|
||||
withIssueDetailHeaderSeed(issueLinkState, item.issue),
|
||||
);
|
||||
rememberIssueDetailLocationState(pathId, detailState);
|
||||
act.navigate(createIssueDetailPath(pathId), { state: detailState });
|
||||
} else if (item.kind === "approval") {
|
||||
|
|
@ -1435,7 +1511,7 @@ export function Inbox() {
|
|||
dashboard.costs.monthUtilizationPercent >= 80 &&
|
||||
!dismissedAlerts.has("alert:budget");
|
||||
const hasAlerts = showAggregateAgentError || showBudgetAlert;
|
||||
const showWorkItemsSection = nestedWorkItems.length > 0;
|
||||
const showWorkItemsSection = totalVisibleWorkItems > 0;
|
||||
const showAlertsSection = shouldShowInboxSection({
|
||||
tab,
|
||||
hasItems: hasAlerts,
|
||||
|
|
@ -1460,11 +1536,12 @@ export function Inbox() {
|
|||
!isRunsLoading;
|
||||
|
||||
const showSeparatorBefore = (key: SectionKey) => visibleSections.indexOf(key) > 0;
|
||||
const markAllReadIssues = (tab === "mine" ? mineIssues : unreadTouchedIssues)
|
||||
const markAllReadIssues = (tab === "mine" ? visibleMineIssues : unreadTouchedIssues)
|
||||
.filter((issue) => issue.isUnreadForMe && !fadingOutIssues.has(issue.id) && !archivingIssueIds.has(issue.id));
|
||||
const unreadIssueIds = markAllReadIssues
|
||||
.map((issue) => issue.id);
|
||||
const canMarkAllRead = unreadIssueIds.length > 0;
|
||||
const activeIssueFilterCount = countActiveIssueFilters(issueFilters, true);
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
|
|
@ -1518,6 +1595,50 @@ export function Inbox() {
|
|||
>
|
||||
<ListTree className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<IssueFiltersPopover
|
||||
state={issueFilters}
|
||||
onChange={updateIssueFilters}
|
||||
activeFilterCount={activeIssueFilterCount}
|
||||
agents={agents}
|
||||
projects={projects?.map((project) => ({ id: project.id, name: project.name }))}
|
||||
labels={labels?.map((label) => ({ id: label.id, name: label.name, color: label.color }))}
|
||||
currentUserId={currentUserId}
|
||||
enableRoutineVisibilityFilter
|
||||
/>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={cn("h-8 shrink-0 text-xs", groupBy !== "none" && "bg-accent")}
|
||||
>
|
||||
<Layers className="mr-1.5 h-3.5 w-3.5" />
|
||||
Group
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="w-40 p-2">
|
||||
<div className="space-y-0.5">
|
||||
{([
|
||||
["none", "None"],
|
||||
["type", "Type"],
|
||||
] as const).map(([value, label]) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between rounded-sm px-2 py-1.5 text-sm",
|
||||
groupBy === value ? "bg-accent/50 text-foreground" : "text-muted-foreground hover:bg-accent/50",
|
||||
)}
|
||||
onClick={() => updateGroupBy(value)}
|
||||
>
|
||||
<span>{label}</span>
|
||||
{groupBy === value ? <Check className="h-3.5 w-3.5" /> : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<IssueColumnPicker
|
||||
availableColumns={availableIssueColumns}
|
||||
visibleColumnSet={visibleIssueColumnSet}
|
||||
|
|
@ -1633,197 +1754,70 @@ export function Inbox() {
|
|||
<div>
|
||||
<div ref={listRef} className="overflow-hidden rounded-xl border border-border bg-card">
|
||||
{(() => {
|
||||
// Pre-compute flat nav index for each top-level item and child issue
|
||||
// Pre-compute flat nav index for each top-level item and child issue.
|
||||
let flatIdx = 0;
|
||||
const topFlatIndex = new Map<number, number>();
|
||||
const topFlatIndex = new Map<string, number>();
|
||||
const childFlatIndex = new Map<string, number>();
|
||||
for (let ti = 0; ti < nestedWorkItems.length; ti++) {
|
||||
topFlatIndex.set(ti, flatIdx);
|
||||
flatIdx++;
|
||||
const topItem = nestedWorkItems[ti];
|
||||
if (topItem.kind === "issue") {
|
||||
const children = childrenByIssueId.get(topItem.issue.id);
|
||||
const isExp = children?.length && !collapsedInboxParents.has(topItem.issue.id);
|
||||
if (isExp) {
|
||||
for (const c of children) {
|
||||
childFlatIndex.set(c.id, flatIdx);
|
||||
flatIdx++;
|
||||
for (const group of groupedSections) {
|
||||
for (const topItem of group.displayItems) {
|
||||
const itemKey = `${group.key}:${getWorkItemKey(topItem)}`;
|
||||
topFlatIndex.set(itemKey, flatIdx);
|
||||
flatIdx++;
|
||||
if (topItem.kind === "issue") {
|
||||
const children = group.childrenByIssueId.get(topItem.issue.id);
|
||||
const isExpanded = children?.length && !collapsedInboxParents.has(topItem.issue.id);
|
||||
if (isExpanded) {
|
||||
for (const child of children) {
|
||||
childFlatIndex.set(child.id, flatIdx);
|
||||
flatIdx++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nestedWorkItems.flatMap((item, index) => {
|
||||
const navIdx = topFlatIndex.get(index) ?? index;
|
||||
const wrapItem = (key: string, isSelected: boolean, child: ReactNode) => (
|
||||
<div
|
||||
key={`sel-${key}`}
|
||||
data-inbox-item
|
||||
className="relative"
|
||||
onClick={() => setSelectedIndex(navIdx)}
|
||||
>
|
||||
{child}
|
||||
</div>
|
||||
);
|
||||
const todayCutoff = Date.now() - 24 * 60 * 60 * 1000;
|
||||
const showTodayDivider =
|
||||
index > 0 &&
|
||||
item.timestamp > 0 &&
|
||||
item.timestamp < todayCutoff &&
|
||||
nestedWorkItems[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-zinc-600" />
|
||||
<span className="shrink-0 text-[11px] font-medium uppercase tracking-wider text-zinc-500">
|
||||
Earlier
|
||||
</span>
|
||||
</div>,
|
||||
);
|
||||
}
|
||||
const isSelected = selectedIndex === navIdx;
|
||||
|
||||
if (item.kind === "approval") {
|
||||
const approvalKey = `approval:${item.approval.id}`;
|
||||
const isArchiving = archivingNonIssueIds.has(approvalKey);
|
||||
const row = (
|
||||
<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={canArchiveFromTab ? () => handleArchiveNonIssue(approvalKey) : undefined}
|
||||
archiveDisabled={isArchiving}
|
||||
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"
|
||||
}
|
||||
/>
|
||||
);
|
||||
elements.push(wrapItem(approvalKey, isSelected, canArchiveFromTab ? (
|
||||
<SwipeToArchive
|
||||
key={approvalKey}
|
||||
selected={isSelected}
|
||||
disabled={isArchiving}
|
||||
onArchive={() => handleArchiveNonIssue(approvalKey)}
|
||||
>
|
||||
{row}
|
||||
</SwipeToArchive>
|
||||
) : row));
|
||||
return elements;
|
||||
}
|
||||
|
||||
if (item.kind === "failed_run") {
|
||||
const runKey = `run:${item.run.id}`;
|
||||
const isArchiving = archivingNonIssueIds.has(runKey);
|
||||
const row = (
|
||||
<FailedRunInboxRow
|
||||
key={runKey}
|
||||
run={item.run}
|
||||
selected={isSelected}
|
||||
issueById={issueById}
|
||||
agentName={agentName(item.run.agentId)}
|
||||
issueLinkState={issueLinkState}
|
||||
onDismiss={() => dismissInboxItem(runKey)}
|
||||
onRetry={() => retryRunMutation.mutate(item.run)}
|
||||
isRetrying={retryingRunIds.has(item.run.id)}
|
||||
unreadState={nonIssueUnreadState(runKey)}
|
||||
onMarkRead={() => handleMarkNonIssueRead(runKey)}
|
||||
onArchive={canArchiveFromTab ? () => handleArchiveNonIssue(runKey) : undefined}
|
||||
archiveDisabled={isArchiving}
|
||||
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"
|
||||
}
|
||||
/>
|
||||
);
|
||||
elements.push(wrapItem(runKey, isSelected, canArchiveFromTab ? (
|
||||
<SwipeToArchive
|
||||
key={runKey}
|
||||
selected={isSelected}
|
||||
disabled={isArchiving}
|
||||
onArchive={() => handleArchiveNonIssue(runKey)}
|
||||
>
|
||||
{row}
|
||||
</SwipeToArchive>
|
||||
) : row));
|
||||
return elements;
|
||||
}
|
||||
|
||||
if (item.kind === "join_request") {
|
||||
const joinKey = `join:${item.joinRequest.id}`;
|
||||
const isArchiving = archivingNonIssueIds.has(joinKey);
|
||||
const row = (
|
||||
<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={canArchiveFromTab ? () => handleArchiveNonIssue(joinKey) : undefined}
|
||||
archiveDisabled={isArchiving}
|
||||
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"
|
||||
}
|
||||
/>
|
||||
);
|
||||
elements.push(wrapItem(joinKey, isSelected, canArchiveFromTab ? (
|
||||
<SwipeToArchive
|
||||
key={joinKey}
|
||||
selected={isSelected}
|
||||
disabled={isArchiving}
|
||||
onArchive={() => handleArchiveNonIssue(joinKey)}
|
||||
>
|
||||
{row}
|
||||
</SwipeToArchive>
|
||||
) : row));
|
||||
return elements;
|
||||
}
|
||||
|
||||
const issue = item.issue;
|
||||
const childIssues = childrenByIssueId.get(issue.id) ?? [];
|
||||
const hasChildren = childIssues.length > 0;
|
||||
const isExpanded = hasChildren && !collapsedInboxParents.has(issue.id);
|
||||
|
||||
const renderInboxIssue = (iss: Issue, depth: number, sel: boolean) => {
|
||||
const isUnread = iss.isUnreadForMe && !fadingOutIssues.has(iss.id);
|
||||
const isFading = fadingOutIssues.has(iss.id);
|
||||
const isArch = archivingIssueIds.has(iss.id);
|
||||
const proj = iss.projectId ? projectById.get(iss.projectId) ?? null : null;
|
||||
const renderInboxIssue = ({
|
||||
issue,
|
||||
depth,
|
||||
selected,
|
||||
hasChildren = false,
|
||||
isExpanded = false,
|
||||
childCount = 0,
|
||||
collapseParentId = null,
|
||||
}: {
|
||||
issue: Issue;
|
||||
depth: number;
|
||||
selected: boolean;
|
||||
hasChildren?: boolean;
|
||||
isExpanded?: boolean;
|
||||
childCount?: number;
|
||||
collapseParentId?: string | null;
|
||||
}) => {
|
||||
const isUnread = issue.isUnreadForMe && !fadingOutIssues.has(issue.id);
|
||||
const isFading = fadingOutIssues.has(issue.id);
|
||||
const isArchiving = archivingIssueIds.has(issue.id);
|
||||
const project = issue.projectId ? projectById.get(issue.projectId) ?? null : null;
|
||||
return (
|
||||
<IssueRow
|
||||
key={`issue:${iss.id}`}
|
||||
issue={iss}
|
||||
key={`issue:${issue.id}`}
|
||||
issue={issue}
|
||||
issueLinkState={issueLinkState}
|
||||
selected={sel}
|
||||
selected={selected}
|
||||
className={
|
||||
isArch
|
||||
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={
|
||||
<>
|
||||
{nestingEnabled ? (
|
||||
depth === 0 && hasChildren ? (
|
||||
depth === 0 && hasChildren && collapseParentId ? (
|
||||
<button
|
||||
type="button"
|
||||
className="hidden w-4 shrink-0 items-center justify-center sm:inline-flex"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
toggleInboxParentCollapse(issue.id);
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
toggleInboxParentCollapse(collapseParentId);
|
||||
}}
|
||||
>
|
||||
<ChevronRight className={cn("h-3.5 w-3.5 transition-transform", isExpanded && "rotate-90")} />
|
||||
|
|
@ -1832,12 +1826,10 @@ export function Inbox() {
|
|||
<span className="hidden w-4 shrink-0 sm:block" />
|
||||
)
|
||||
) : null}
|
||||
{depth > 0 ? (
|
||||
<span className="hidden w-4 shrink-0 sm:block" />
|
||||
) : null}
|
||||
{depth > 0 ? <span className="hidden w-4 shrink-0 sm:block" /> : null}
|
||||
<InboxIssueMetaLeading
|
||||
issue={iss}
|
||||
isLive={liveIssueIds.has(iss.id)}
|
||||
issue={issue}
|
||||
isLive={liveIssueIds.has(issue.id)}
|
||||
showStatus={visibleIssueColumnSet.has("status") && availableIssueColumnSet.has("status")}
|
||||
showIdentifier={visibleIssueColumnSet.has("id") && availableIssueColumnSet.has("id")}
|
||||
/>
|
||||
|
|
@ -1845,47 +1837,44 @@ export function Inbox() {
|
|||
}
|
||||
titleSuffix={hasChildren && !isExpanded && depth === 0 ? (
|
||||
<span className="ml-1.5 text-xs text-muted-foreground">
|
||||
({childIssues.length} sub-task{childIssues.length !== 1 ? "s" : ""})
|
||||
({childCount} sub-task{childCount !== 1 ? "s" : ""})
|
||||
</span>
|
||||
) : undefined}
|
||||
mobileMeta={issueActivityText(iss).toLowerCase()}
|
||||
mobileMeta={issueActivityText(issue).toLowerCase()}
|
||||
mobileLeading={
|
||||
depth === 0 && hasChildren ? (
|
||||
<button type="button" onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
toggleInboxParentCollapse(issue.id);
|
||||
}}>
|
||||
depth === 0 && hasChildren && collapseParentId ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
toggleInboxParentCollapse(collapseParentId);
|
||||
}}
|
||||
>
|
||||
<ChevronRight className={cn("h-3.5 w-3.5 transition-transform", isExpanded && "rotate-90")} />
|
||||
</button>
|
||||
) : undefined
|
||||
}
|
||||
unreadState={
|
||||
isUnread ? "visible" : isFading ? "fading" : "hidden"
|
||||
}
|
||||
onMarkRead={() => markReadMutation.mutate(iss.id)}
|
||||
onArchive={
|
||||
canArchiveFromTab
|
||||
? () => archiveIssueMutation.mutate(iss.id)
|
||||
: undefined
|
||||
}
|
||||
archiveDisabled={isArch || archiveIssueMutation.isPending}
|
||||
unreadState={isUnread ? "visible" : isFading ? "fading" : "hidden"}
|
||||
onMarkRead={() => markReadMutation.mutate(issue.id)}
|
||||
onArchive={canArchiveFromTab ? () => archiveIssueMutation.mutate(issue.id) : undefined}
|
||||
archiveDisabled={isArchiving || archiveIssueMutation.isPending}
|
||||
desktopTrailing={
|
||||
visibleTrailingIssueColumns.length > 0 ? (
|
||||
<InboxIssueTrailingColumns
|
||||
issue={iss}
|
||||
issue={issue}
|
||||
columns={visibleTrailingIssueColumns}
|
||||
projectName={proj?.name ?? null}
|
||||
projectColor={proj?.color ?? null}
|
||||
workspaceName={resolveIssueWorkspaceName(iss, {
|
||||
projectName={project?.name ?? null}
|
||||
projectColor={project?.color ?? null}
|
||||
workspaceName={resolveIssueWorkspaceName(issue, {
|
||||
executionWorkspaceById,
|
||||
projectWorkspaceById,
|
||||
defaultProjectWorkspaceIdByProjectId,
|
||||
})}
|
||||
assigneeName={agentName(iss.assigneeAgentId)}
|
||||
assigneeName={agentName(issue.assigneeAgentId)}
|
||||
currentUserId={currentUserId}
|
||||
parentIdentifier={iss.parentId ? (issueById.get(iss.parentId)?.identifier ?? null) : null}
|
||||
parentTitle={iss.parentId ? (issueById.get(iss.parentId)?.title ?? null) : null}
|
||||
parentIdentifier={issue.parentId ? (issueById.get(issue.parentId)?.identifier ?? null) : null}
|
||||
parentTitle={issue.parentId ? (issueById.get(issue.parentId)?.title ?? null) : null}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
|
|
@ -1893,49 +1882,224 @@ export function Inbox() {
|
|||
);
|
||||
};
|
||||
|
||||
// Render parent issue
|
||||
const parentRow = renderInboxIssue(issue, 0, isSelected);
|
||||
elements.push(wrapItem(`issue:${issue.id}`, isSelected, canArchiveFromTab ? (
|
||||
<SwipeToArchive
|
||||
key={`issue:${issue.id}`}
|
||||
selected={isSelected}
|
||||
disabled={archivingIssueIds.has(issue.id) || archiveIssueMutation.isPending}
|
||||
onArchive={() => archiveIssueMutation.mutate(issue.id)}
|
||||
>
|
||||
{parentRow}
|
||||
</SwipeToArchive>
|
||||
) : parentRow));
|
||||
|
||||
// Render children if expanded
|
||||
if (isExpanded) {
|
||||
for (const child of childIssues) {
|
||||
const cNavIdx = childFlatIndex.get(child.id) ?? -1;
|
||||
const isChildSelected = selectedIndex === cNavIdx;
|
||||
const childRow = renderInboxIssue(child, 1, isChildSelected);
|
||||
const isChildArchiving = archivingIssueIds.has(child.id);
|
||||
let previousTimestamp = Number.POSITIVE_INFINITY;
|
||||
return groupedSections.flatMap((group, groupIndex) => {
|
||||
const elements: ReactNode[] = [];
|
||||
if (group.label) {
|
||||
elements.push(
|
||||
<div
|
||||
key={`sel-issue:${child.id}`}
|
||||
data-inbox-item
|
||||
className="relative"
|
||||
onClick={() => setSelectedIndex(cNavIdx)}
|
||||
key={`group-${group.key}`}
|
||||
className={cn(
|
||||
"border-b border-border/70 bg-muted/30 px-4 py-2 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground",
|
||||
groupIndex > 0 && "border-t border-border",
|
||||
)}
|
||||
>
|
||||
{canArchiveFromTab ? (
|
||||
<SwipeToArchive
|
||||
key={`issue:${child.id}`}
|
||||
selected={isChildSelected}
|
||||
disabled={isChildArchiving || archiveIssueMutation.isPending}
|
||||
onArchive={() => archiveIssueMutation.mutate(child.id)}
|
||||
>
|
||||
{childRow}
|
||||
</SwipeToArchive>
|
||||
) : childRow}
|
||||
{group.label}
|
||||
</div>,
|
||||
);
|
||||
}
|
||||
}
|
||||
return elements;
|
||||
});
|
||||
|
||||
for (let index = 0; index < group.displayItems.length; index += 1) {
|
||||
const item = group.displayItems[index]!;
|
||||
const navIdx = topFlatIndex.get(`${group.key}:${getWorkItemKey(item)}`) ?? 0;
|
||||
const wrapItem = (key: string, isSelected: boolean, child: ReactNode) => (
|
||||
<div
|
||||
key={`sel-${key}`}
|
||||
data-inbox-item
|
||||
className="relative"
|
||||
onClick={() => setSelectedIndex(navIdx)}
|
||||
>
|
||||
{child}
|
||||
</div>
|
||||
);
|
||||
const todayCutoff = Date.now() - 24 * 60 * 60 * 1000;
|
||||
const showTodayDivider =
|
||||
groupBy === "none" &&
|
||||
item.timestamp > 0 &&
|
||||
item.timestamp < todayCutoff &&
|
||||
previousTimestamp >= todayCutoff;
|
||||
previousTimestamp = item.timestamp > 0 ? item.timestamp : previousTimestamp;
|
||||
if (showTodayDivider) {
|
||||
elements.push(
|
||||
<div key={`today-divider-${group.key}-${index}`} className="my-2 flex items-center gap-3 px-4">
|
||||
<div className="flex-1 border-t border-zinc-600" />
|
||||
<span className="shrink-0 text-[11px] font-medium uppercase tracking-wider text-zinc-500">
|
||||
Earlier
|
||||
</span>
|
||||
</div>,
|
||||
);
|
||||
}
|
||||
const isSelected = selectedIndex === navIdx;
|
||||
|
||||
if (item.kind === "approval") {
|
||||
const approvalKey = `approval:${item.approval.id}`;
|
||||
const isArchiving = archivingNonIssueIds.has(approvalKey);
|
||||
const row = (
|
||||
<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={canArchiveFromTab ? () => handleArchiveNonIssue(approvalKey) : undefined}
|
||||
archiveDisabled={isArchiving}
|
||||
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"
|
||||
}
|
||||
/>
|
||||
);
|
||||
elements.push(wrapItem(approvalKey, isSelected, canArchiveFromTab ? (
|
||||
<SwipeToArchive
|
||||
key={approvalKey}
|
||||
selected={isSelected}
|
||||
disabled={isArchiving}
|
||||
onArchive={() => handleArchiveNonIssue(approvalKey)}
|
||||
>
|
||||
{row}
|
||||
</SwipeToArchive>
|
||||
) : row));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (item.kind === "failed_run") {
|
||||
const runKey = `run:${item.run.id}`;
|
||||
const isArchiving = archivingNonIssueIds.has(runKey);
|
||||
const row = (
|
||||
<FailedRunInboxRow
|
||||
key={runKey}
|
||||
run={item.run}
|
||||
selected={isSelected}
|
||||
issueById={issueById}
|
||||
agentName={agentName(item.run.agentId)}
|
||||
issueLinkState={issueLinkState}
|
||||
onDismiss={() => dismissInboxItem(runKey)}
|
||||
onRetry={() => retryRunMutation.mutate(item.run)}
|
||||
isRetrying={retryingRunIds.has(item.run.id)}
|
||||
unreadState={nonIssueUnreadState(runKey)}
|
||||
onMarkRead={() => handleMarkNonIssueRead(runKey)}
|
||||
onArchive={canArchiveFromTab ? () => handleArchiveNonIssue(runKey) : undefined}
|
||||
archiveDisabled={isArchiving}
|
||||
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"
|
||||
}
|
||||
/>
|
||||
);
|
||||
elements.push(wrapItem(runKey, isSelected, canArchiveFromTab ? (
|
||||
<SwipeToArchive
|
||||
key={runKey}
|
||||
selected={isSelected}
|
||||
disabled={isArchiving}
|
||||
onArchive={() => handleArchiveNonIssue(runKey)}
|
||||
>
|
||||
{row}
|
||||
</SwipeToArchive>
|
||||
) : row));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (item.kind === "join_request") {
|
||||
const joinKey = `join:${item.joinRequest.id}`;
|
||||
const isArchiving = archivingNonIssueIds.has(joinKey);
|
||||
const row = (
|
||||
<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={canArchiveFromTab ? () => handleArchiveNonIssue(joinKey) : undefined}
|
||||
archiveDisabled={isArchiving}
|
||||
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"
|
||||
}
|
||||
/>
|
||||
);
|
||||
elements.push(wrapItem(joinKey, isSelected, canArchiveFromTab ? (
|
||||
<SwipeToArchive
|
||||
key={joinKey}
|
||||
selected={isSelected}
|
||||
disabled={isArchiving}
|
||||
onArchive={() => handleArchiveNonIssue(joinKey)}
|
||||
>
|
||||
{row}
|
||||
</SwipeToArchive>
|
||||
) : row));
|
||||
continue;
|
||||
}
|
||||
|
||||
const issue = item.issue;
|
||||
const childIssues = group.childrenByIssueId.get(issue.id) ?? [];
|
||||
const hasChildren = childIssues.length > 0;
|
||||
const isExpanded = hasChildren && !collapsedInboxParents.has(issue.id);
|
||||
const parentRow = renderInboxIssue({
|
||||
issue,
|
||||
depth: 0,
|
||||
selected: isSelected,
|
||||
hasChildren,
|
||||
isExpanded,
|
||||
childCount: childIssues.length,
|
||||
collapseParentId: issue.id,
|
||||
});
|
||||
|
||||
elements.push(wrapItem(`issue:${issue.id}`, isSelected, canArchiveFromTab ? (
|
||||
<SwipeToArchive
|
||||
key={`issue:${issue.id}`}
|
||||
selected={isSelected}
|
||||
disabled={archivingIssueIds.has(issue.id) || archiveIssueMutation.isPending}
|
||||
onArchive={() => archiveIssueMutation.mutate(issue.id)}
|
||||
>
|
||||
{parentRow}
|
||||
</SwipeToArchive>
|
||||
) : parentRow));
|
||||
|
||||
if (isExpanded) {
|
||||
for (const child of childIssues) {
|
||||
const childNavIdx = childFlatIndex.get(child.id) ?? -1;
|
||||
const isChildSelected = selectedIndex === childNavIdx;
|
||||
const childRow = renderInboxIssue({
|
||||
issue: child,
|
||||
depth: 1,
|
||||
selected: isChildSelected,
|
||||
});
|
||||
const isChildArchiving = archivingIssueIds.has(child.id);
|
||||
elements.push(
|
||||
<div
|
||||
key={`sel-issue:${child.id}`}
|
||||
data-inbox-item
|
||||
className="relative"
|
||||
onClick={() => setSelectedIndex(childNavIdx)}
|
||||
>
|
||||
{canArchiveFromTab ? (
|
||||
<SwipeToArchive
|
||||
key={`issue:${child.id}`}
|
||||
selected={isChildSelected}
|
||||
disabled={isChildArchiving || archiveIssueMutation.isPending}
|
||||
onArchive={() => archiveIssueMutation.mutate(child.id)}
|
||||
>
|
||||
{childRow}
|
||||
</SwipeToArchive>
|
||||
) : childRow}
|
||||
</div>,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return elements;
|
||||
});
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import {
|
|||
issueChatUxReassignOptions,
|
||||
issueChatUxReviewComments,
|
||||
issueChatUxReviewEvents,
|
||||
issueChatUxSubmittingComments,
|
||||
issueChatUxTranscriptsByRunId,
|
||||
} from "../fixtures/issueChatUxFixtures";
|
||||
import { cn } from "../lib/utils";
|
||||
|
|
@ -25,6 +26,7 @@ const highlights = [
|
|||
"Running assistant replies with streamed text, reasoning, tool cards, and background status notes",
|
||||
"Historical issue events and linked runs rendered inline with the chat timeline",
|
||||
"Queued user messages, settled assistant comments, and feedback controls",
|
||||
"Submitting (pending) message bubble with Sending... label and reduced opacity",
|
||||
"Empty and disabled-composer states without relying on live backend data",
|
||||
];
|
||||
|
||||
|
|
@ -285,6 +287,26 @@ export function IssueChatUxLab() {
|
|||
/>
|
||||
</LabSection>
|
||||
|
||||
<LabSection
|
||||
eyebrow="Submitting state"
|
||||
title="Pending message bubble"
|
||||
description='When a user sends a message, the bubble briefly shows a "Sending..." label at reduced opacity until the server confirms receipt. This preview renders that transient state.'
|
||||
accentClassName="bg-[linear-gradient(180deg,rgba(59,130,246,0.06),transparent_28%),var(--background)]"
|
||||
>
|
||||
<IssueChatThread
|
||||
comments={issueChatUxSubmittingComments}
|
||||
linkedRuns={[]}
|
||||
timelineEvents={[]}
|
||||
issueStatus="in_progress"
|
||||
agentMap={issueChatUxAgentMap}
|
||||
currentUserId="user-1"
|
||||
onAdd={noop}
|
||||
draftKey="issue-chat-ux-lab-submitting"
|
||||
showComposer={false}
|
||||
enableLiveTranscriptPolling={false}
|
||||
/>
|
||||
</LabSection>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-2">
|
||||
<LabSection
|
||||
eyebrow="Settled review"
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import {
|
|||
createIssueDetailPath,
|
||||
readIssueDetailLocationState,
|
||||
readIssueDetailBreadcrumb,
|
||||
readIssueDetailHeaderSeed,
|
||||
rememberIssueDetailLocationState,
|
||||
} from "../lib/issueDetailBreadcrumb";
|
||||
import {
|
||||
|
|
@ -50,10 +51,10 @@ import { relativeTime, cn, formatTokens, visibleRunCostUsd } from "../lib/utils"
|
|||
import { ApprovalCard } from "../components/ApprovalCard";
|
||||
import { InlineEditor } from "../components/InlineEditor";
|
||||
import { IssueChatThread, type IssueChatComposerHandle } from "../components/IssueChatThread";
|
||||
import { useLiveRunTranscripts } from "../components/transcript/useLiveRunTranscripts";
|
||||
import { IssueDocumentsSection } from "../components/IssueDocumentsSection";
|
||||
import { IssueProperties } from "../components/IssueProperties";
|
||||
import { IssueWorkspaceCard } from "../components/IssueWorkspaceCard";
|
||||
import { PageSkeleton } from "../components/PageSkeleton";
|
||||
import type { MentionOption } from "../components/MarkdownEditor";
|
||||
import { ImageGalleryModal } from "../components/ImageGalleryModal";
|
||||
import { ScrollToBottom } from "../components/ScrollToBottom";
|
||||
|
|
@ -70,6 +71,7 @@ import { Skeleton } from "@/components/ui/skeleton";
|
|||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { formatIssueActivityAction } from "@/lib/activity-format";
|
||||
import { resolveIssueChatTranscriptRuns } from "../lib/issueChatTranscriptRuns";
|
||||
import {
|
||||
Activity as ActivityIcon,
|
||||
Check,
|
||||
|
|
@ -91,7 +93,6 @@ import {
|
|||
type ActivityEvent,
|
||||
type Agent,
|
||||
type FeedbackVote,
|
||||
type FeedbackVoteValue,
|
||||
type Issue,
|
||||
type IssueAttachment,
|
||||
type IssueComment,
|
||||
|
|
@ -107,10 +108,6 @@ type IssueDetailComment = (IssueComment | OptimisticIssueComment) & {
|
|||
};
|
||||
|
||||
const FEEDBACK_TERMS_URL = import.meta.env.VITE_FEEDBACK_TERMS_URL?.trim() || "https://paperclip.ing/tos";
|
||||
const ACTIVE_ISSUE_RUN_POLL_INTERVAL_MS = 3000;
|
||||
const IDLE_ISSUE_RUN_POLL_INTERVAL_MS = 30000;
|
||||
const ACTIVE_ISSUE_TIMELINE_POLL_INTERVAL_MS = 5000;
|
||||
const IDLE_ISSUE_TIMELINE_POLL_INTERVAL_MS = 30000;
|
||||
const ISSUE_COMMENT_PAGE_SIZE = 50;
|
||||
|
||||
function keepPreviousData<T>(previousData: T | undefined) {
|
||||
|
|
@ -284,6 +281,87 @@ function IssueChatSkeleton() {
|
|||
);
|
||||
}
|
||||
|
||||
function IssueDetailLoadingState({
|
||||
headerSeed,
|
||||
}: {
|
||||
headerSeed: ReturnType<typeof readIssueDetailHeaderSeed>;
|
||||
}) {
|
||||
const identifier = headerSeed?.identifier ?? headerSeed?.id.slice(0, 8) ?? null;
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl space-y-6">
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-3 w-40" />
|
||||
|
||||
<div className="flex items-center gap-2 min-w-0 flex-wrap">
|
||||
{headerSeed ? (
|
||||
<>
|
||||
<StatusIcon status={headerSeed.status} />
|
||||
<PriorityIcon priority={headerSeed.priority} />
|
||||
{identifier ? (
|
||||
<span className="text-sm font-mono text-muted-foreground shrink-0">{identifier}</span>
|
||||
) : null}
|
||||
{headerSeed.originKind === "routine_execution" && headerSeed.originId ? (
|
||||
<span className="inline-flex items-center gap-1 rounded-full border border-violet-500/30 bg-violet-500/10 px-2 py-0.5 text-[10px] font-medium text-violet-600 dark:text-violet-400 shrink-0">
|
||||
<Repeat className="h-3 w-3" />
|
||||
Routine
|
||||
</span>
|
||||
) : null}
|
||||
{headerSeed.projectId ? (
|
||||
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground rounded px-1 -mx-1 py-0.5 min-w-0">
|
||||
<Hexagon className="h-3 w-3 shrink-0" />
|
||||
<span className="truncate">
|
||||
{headerSeed.projectName ?? headerSeed.projectId.slice(0, 8)}
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground opacity-50 px-1 -mx-1 py-0.5">
|
||||
<Hexagon className="h-3 w-3 shrink-0" />
|
||||
No project
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Skeleton className="h-6 w-6" />
|
||||
<Skeleton className="h-6 w-6" />
|
||||
<Skeleton className="h-4 w-20" />
|
||||
<Skeleton className="h-4 w-28" />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{headerSeed ? (
|
||||
<>
|
||||
<h2 className="text-xl font-bold leading-tight">{headerSeed.title}</h2>
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-full max-w-xl" />
|
||||
<Skeleton className="h-4 w-[72%]" />
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Skeleton className="h-8 w-[min(100%,22rem)]" />
|
||||
<Skeleton className="h-16 w-full" />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Skeleton className="h-28 w-full rounded-lg border border-border" />
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="h-8 w-20" />
|
||||
<Skeleton className="h-8 w-20" />
|
||||
</div>
|
||||
<IssueChatSkeleton />
|
||||
</div>
|
||||
|
||||
<IssueSectionSkeleton titleWidth="w-24" rows={3} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function IssueDetail() {
|
||||
const { issueId } = useParams<{ issueId: string }>();
|
||||
const { selectedCompanyId } = useCompany();
|
||||
|
|
@ -309,10 +387,15 @@ export function IssueDetail() {
|
|||
const [galleryIndex, setGalleryIndex] = useState(0);
|
||||
const [optimisticComments, setOptimisticComments] = useState<OptimisticIssueComment[]>([]);
|
||||
const [pendingCommentComposerFocusKey, setPendingCommentComposerFocusKey] = useState(0);
|
||||
const [issueChatInitialTranscriptReady, setIssueChatInitialTranscriptReady] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const lastMarkedReadIssueIdRef = useRef<string | null>(null);
|
||||
const commentComposerRef = useRef<IssueChatComposerHandle | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setIssueChatInitialTranscriptReady(false);
|
||||
}, [issueId]);
|
||||
|
||||
const { data: issue, isLoading, error } = useQuery({
|
||||
queryKey: queryKeys.issues.detail(issueId!),
|
||||
queryFn: () => issuesApi.get(issueId!),
|
||||
|
|
@ -358,6 +441,14 @@ export function IssueDetail() {
|
|||
placeholderData: keepPreviousData,
|
||||
});
|
||||
|
||||
const { data: linkedRuns, isLoading: linkedRunsLoading } = useQuery({
|
||||
queryKey: queryKeys.issues.runs(issueId!),
|
||||
queryFn: () => activityApi.runsForIssue(issueId!),
|
||||
enabled: !!issueId,
|
||||
refetchInterval: 5000,
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
|
||||
const { data: linkedApprovals } = useQuery({
|
||||
queryKey: queryKeys.issues.approvals(issueId!),
|
||||
queryFn: () => issuesApi.listApprovals(issueId!),
|
||||
|
|
@ -376,12 +467,7 @@ export function IssueDetail() {
|
|||
queryKey: queryKeys.issues.liveRuns(issueId!),
|
||||
queryFn: () => heartbeatsApi.liveRunsForIssue(issueId!),
|
||||
enabled: !!issueId,
|
||||
refetchInterval: (query) => {
|
||||
const data = query.state.data as Array<unknown> | undefined;
|
||||
return data && data.length > 0
|
||||
? ACTIVE_ISSUE_RUN_POLL_INTERVAL_MS
|
||||
: IDLE_ISSUE_RUN_POLL_INTERVAL_MS;
|
||||
},
|
||||
refetchInterval: 3000,
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
|
||||
|
|
@ -389,25 +475,11 @@ export function IssueDetail() {
|
|||
queryKey: queryKeys.issues.activeRun(issueId!),
|
||||
queryFn: () => heartbeatsApi.activeRunForIssue(issueId!),
|
||||
enabled: !!issueId && (!!issue?.executionRunId || issue?.status === "in_progress"),
|
||||
refetchInterval: (query) =>
|
||||
(liveRuns?.length ?? 0) > 0
|
||||
? false
|
||||
: query.state.data
|
||||
? ACTIVE_ISSUE_RUN_POLL_INTERVAL_MS
|
||||
: IDLE_ISSUE_RUN_POLL_INTERVAL_MS,
|
||||
refetchInterval: (liveRuns?.length ?? 0) > 0 ? false : 3000,
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
|
||||
const hasLiveRuns = (liveRuns ?? []).length > 0 || !!activeRun;
|
||||
const { data: linkedRuns, isLoading: linkedRunsLoading } = useQuery({
|
||||
queryKey: queryKeys.issues.runs(issueId!),
|
||||
queryFn: () => activityApi.runsForIssue(issueId!),
|
||||
enabled: !!issueId,
|
||||
refetchInterval: hasLiveRuns
|
||||
? ACTIVE_ISSUE_TIMELINE_POLL_INTERVAL_MS
|
||||
: IDLE_ISSUE_TIMELINE_POLL_INTERVAL_MS,
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
const runningIssueRun = useMemo(
|
||||
() => (
|
||||
activeRun?.status === "running"
|
||||
|
|
@ -420,6 +492,10 @@ export function IssueDetail() {
|
|||
() => readIssueDetailLocationState(issueId, location.state, location.search),
|
||||
[issueId, location.state, location.search],
|
||||
);
|
||||
const issueHeaderSeed = useMemo(
|
||||
() => readIssueDetailHeaderSeed(location.state) ?? readIssueDetailHeaderSeed(resolvedIssueDetailState),
|
||||
[location.state, resolvedIssueDetailState],
|
||||
);
|
||||
const sourceBreadcrumb = useMemo(
|
||||
() => readIssueDetailBreadcrumb(issueId, location.state, location.search) ?? { label: "Issues", href: "/issues" },
|
||||
[issueId, location.state, location.search],
|
||||
|
|
@ -430,8 +506,14 @@ export function IssueDetail() {
|
|||
const liveIds = new Set<string>();
|
||||
for (const r of liveRuns ?? []) liveIds.add(r.id);
|
||||
if (activeRun) liveIds.add(activeRun.id);
|
||||
if (liveIds.size === 0) return linkedRuns ?? [];
|
||||
return (linkedRuns ?? []).filter((r) => !liveIds.has(r.runId));
|
||||
const historicalRuns = liveIds.size === 0
|
||||
? (linkedRuns ?? [])
|
||||
: (linkedRuns ?? []).filter((r) => !liveIds.has(r.runId));
|
||||
return historicalRuns.map((run) => ({
|
||||
...run,
|
||||
adapterType: run.adapterType,
|
||||
hasStoredOutput: (run.logBytes ?? 0) > 0,
|
||||
}));
|
||||
}, [linkedRuns, liveRuns, activeRun]);
|
||||
|
||||
const { data: rawChildIssues = [], isLoading: childIssuesLoading } = useQuery({
|
||||
|
|
@ -500,6 +582,23 @@ export function IssueDetail() {
|
|||
for (const a of agents ?? []) map.set(a.id, a);
|
||||
return map;
|
||||
}, [agents]);
|
||||
const transcriptRuns = useMemo(
|
||||
() =>
|
||||
resolveIssueChatTranscriptRuns({
|
||||
linkedRuns: timelineRuns,
|
||||
liveRuns: liveRuns ?? [],
|
||||
activeRun,
|
||||
}),
|
||||
[activeRun, liveRuns, timelineRuns],
|
||||
);
|
||||
const {
|
||||
transcriptByRun: issueChatTranscriptByRun,
|
||||
hasOutputForRun: issueChatHasOutputForRun,
|
||||
isInitialHydrating: issueChatTranscriptHydrating,
|
||||
} = useLiveRunTranscripts({
|
||||
runs: transcriptRuns,
|
||||
companyId: issue?.companyId ?? selectedCompanyId,
|
||||
});
|
||||
|
||||
const mentionOptions = useMemo<MentionOption[]>(() => {
|
||||
const options: MentionOption[] = [];
|
||||
|
|
@ -699,6 +798,10 @@ export function IssueDetail() {
|
|||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(issueId!) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.activity(issueId!) });
|
||||
}, [issueId, queryClient]);
|
||||
const invalidateIssueThreadLazily = useCallback(() => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(issueId!), refetchType: "inactive" });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.activity(issueId!), refetchType: "inactive" });
|
||||
}, [issueId, queryClient]);
|
||||
|
||||
const invalidateIssueRunState = useCallback(() => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.runs(issueId!) });
|
||||
|
|
@ -885,6 +988,10 @@ export function IssueDetail() {
|
|||
current.filter((entry) => entry.clientId !== context.optimisticCommentId),
|
||||
);
|
||||
}
|
||||
queryClient.setQueryData<Issue | undefined>(
|
||||
queryKeys.issues.detail(issueId!),
|
||||
(current) => current ? { ...current, updatedAt: comment.createdAt } : current,
|
||||
);
|
||||
queryClient.setQueryData<InfiniteData<IssueComment[], string | null>>(
|
||||
queryKeys.issues.comments(issueId!),
|
||||
(current) => current ? {
|
||||
|
|
@ -912,7 +1019,7 @@ export function IssueDetail() {
|
|||
});
|
||||
},
|
||||
onSettled: (_result, _error, variables) => {
|
||||
invalidateIssueDetail();
|
||||
invalidateIssueThreadLazily();
|
||||
if (variables.interrupt) {
|
||||
invalidateIssueRunState();
|
||||
}
|
||||
|
|
@ -1011,7 +1118,7 @@ export function IssueDetail() {
|
|||
});
|
||||
},
|
||||
onSettled: (_result, _error, variables) => {
|
||||
invalidateIssueDetail();
|
||||
invalidateIssueThreadLazily();
|
||||
if (variables.interrupt) {
|
||||
invalidateIssueRunState();
|
||||
}
|
||||
|
|
@ -1213,53 +1320,6 @@ export function IssueDetail() {
|
|||
},
|
||||
});
|
||||
|
||||
const handleInterruptQueued = useCallback(
|
||||
async (runId: string) => {
|
||||
await interruptQueuedComment.mutateAsync(runId);
|
||||
},
|
||||
[interruptQueuedComment.mutateAsync],
|
||||
);
|
||||
|
||||
const handleCommentImageUpload = useCallback(
|
||||
async (file: File) => {
|
||||
const attachment = await uploadAttachment.mutateAsync(file);
|
||||
return attachment.contentPath;
|
||||
},
|
||||
[uploadAttachment.mutateAsync],
|
||||
);
|
||||
|
||||
const handleCommentAttachImage = useCallback(
|
||||
async (file: File) => {
|
||||
await uploadAttachment.mutateAsync(file);
|
||||
},
|
||||
[uploadAttachment.mutateAsync],
|
||||
);
|
||||
|
||||
const handleCommentAdd = useCallback(
|
||||
async (body: string, reopen?: boolean, reassignment?: CommentReassignment) => {
|
||||
if (reassignment) {
|
||||
await addCommentAndReassign.mutateAsync({ body, reopen, reassignment });
|
||||
return;
|
||||
}
|
||||
await addComment.mutateAsync({ body, reopen });
|
||||
},
|
||||
[addComment.mutateAsync, addCommentAndReassign.mutateAsync],
|
||||
);
|
||||
|
||||
const handleCommentVote = useCallback(
|
||||
async (commentId: string, vote: FeedbackVoteValue, options?: { reason?: string; allowSharing?: boolean }) => {
|
||||
await feedbackVoteMutation.mutateAsync({
|
||||
targetType: "issue_comment",
|
||||
targetId: commentId,
|
||||
vote,
|
||||
reason: options?.reason,
|
||||
allowSharing: options?.allowSharing,
|
||||
sharingPreferenceAtSubmit: feedbackDataSharingPreference,
|
||||
});
|
||||
},
|
||||
[feedbackVoteMutation.mutateAsync, feedbackDataSharingPreference],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const titleLabel = issue?.title ?? issueId ?? "Issue";
|
||||
setBreadcrumbs([
|
||||
|
|
@ -1480,18 +1540,26 @@ export function IssueDetail() {
|
|||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
const issueChatInitialLoading =
|
||||
const issueChatCoreInitialLoading =
|
||||
(commentsLoading && commentPages === undefined)
|
||||
|| (activityLoading && activity === undefined)
|
||||
|| (linkedRunsLoading && linkedRuns === undefined)
|
||||
|| (liveRunsLoading && liveRuns === undefined)
|
||||
|| (activeRunLoading && activeRun === undefined);
|
||||
useEffect(() => {
|
||||
if (issueChatInitialTranscriptReady) return;
|
||||
if (issueChatCoreInitialLoading || issueChatTranscriptHydrating) return;
|
||||
setIssueChatInitialTranscriptReady(true);
|
||||
}, [issueChatCoreInitialLoading, issueChatInitialTranscriptReady, issueChatTranscriptHydrating]);
|
||||
const issueChatInitialLoading =
|
||||
issueChatCoreInitialLoading
|
||||
|| (!issueChatInitialTranscriptReady && issueChatTranscriptHydrating);
|
||||
const activityInitialLoading =
|
||||
(activityLoading && activity === undefined)
|
||||
|| (linkedRunsLoading && linkedRuns === undefined);
|
||||
const attachmentsInitialLoading = attachmentsLoading && attachments === undefined;
|
||||
|
||||
if (isLoading) return <PageSkeleton variant="detail" />;
|
||||
if (isLoading) return <IssueDetailLoadingState headerSeed={issueHeaderSeed} />;
|
||||
if (error) return <p className="text-sm text-destructive">{error.message}</p>;
|
||||
if (!issue) return null;
|
||||
|
||||
|
|
@ -2075,19 +2143,44 @@ export function IssueDetail() {
|
|||
issueStatus={issue.status}
|
||||
agentMap={agentMap}
|
||||
currentUserId={currentUserId}
|
||||
enableLiveTranscriptPolling={false}
|
||||
transcriptsByRunId={issueChatTranscriptByRun}
|
||||
hasOutputForRun={issueChatHasOutputForRun}
|
||||
draftKey={`paperclip:issue-comment-draft:${issue.id}`}
|
||||
enableReassign
|
||||
reassignOptions={commentReassignOptions}
|
||||
currentAssigneeValue={actualAssigneeValue}
|
||||
suggestedAssigneeValue={suggestedAssigneeValue}
|
||||
mentions={mentionOptions}
|
||||
onInterruptQueued={handleInterruptQueued}
|
||||
interruptingQueuedRunId={interruptQueuedComment.isPending ? interruptQueuedComment.variables ?? null : null}
|
||||
composerDisabledReason={commentComposerDisabledReason}
|
||||
onVote={handleCommentVote}
|
||||
onAdd={handleCommentAdd}
|
||||
imageUploadHandler={handleCommentImageUpload}
|
||||
onAttachImage={handleCommentAttachImage}
|
||||
onVote={async (commentId, vote, options) => {
|
||||
await feedbackVoteMutation.mutateAsync({
|
||||
targetType: "issue_comment",
|
||||
targetId: commentId,
|
||||
vote,
|
||||
reason: options?.reason,
|
||||
allowSharing: options?.allowSharing,
|
||||
sharingPreferenceAtSubmit: feedbackDataSharingPreference,
|
||||
});
|
||||
}}
|
||||
onAdd={async (body, reopen, reassignment) => {
|
||||
if (reassignment) {
|
||||
await addCommentAndReassign.mutateAsync({ body, reopen, reassignment });
|
||||
return;
|
||||
}
|
||||
await addComment.mutateAsync({ body, reopen });
|
||||
}}
|
||||
imageUploadHandler={async (file) => {
|
||||
const attachment = await uploadAttachment.mutateAsync(file);
|
||||
return attachment.contentPath;
|
||||
}}
|
||||
onAttachImage={async (file) => {
|
||||
await uploadAttachment.mutateAsync(file);
|
||||
}}
|
||||
onInterruptQueued={async (runId) => {
|
||||
await interruptQueuedComment.mutateAsync(runId);
|
||||
}}
|
||||
interruptingQueuedRunId={interruptQueuedComment.isPending ? interruptQueuedComment.variables ?? null : null}
|
||||
onCancelRun={runningIssueRun
|
||||
? async () => {
|
||||
await interruptQueuedComment.mutateAsync(runningIssueRun.id);
|
||||
|
|
|
|||
|
|
@ -80,8 +80,13 @@ export function Issues() {
|
|||
}, [setBreadcrumbs]);
|
||||
|
||||
const { data: issues, isLoading, error } = useQuery({
|
||||
queryKey: [...queryKeys.issues.list(selectedCompanyId!), "participant-agent", participantAgentId ?? "__all__"],
|
||||
queryFn: () => issuesApi.list(selectedCompanyId!, { participantAgentId }),
|
||||
queryKey: [
|
||||
...queryKeys.issues.list(selectedCompanyId!),
|
||||
"participant-agent",
|
||||
participantAgentId ?? "__all__",
|
||||
"with-routine-executions",
|
||||
],
|
||||
queryFn: () => issuesApi.list(selectedCompanyId!, { participantAgentId, includeRoutineExecutions: true }),
|
||||
enabled: !!selectedCompanyId,
|
||||
});
|
||||
|
||||
|
|
@ -110,6 +115,7 @@ export function Issues() {
|
|||
initialAssignees={searchParams.get("assignee") ? [searchParams.get("assignee")!] : undefined}
|
||||
initialSearch={initialSearch}
|
||||
onSearchChange={handleSearchChange}
|
||||
enableRoutineVisibilityFilter
|
||||
onUpdateIssue={(id, data) => updateIssue.mutate({ id, data })}
|
||||
searchFilters={participantAgentId ? { participantAgentId } : undefined}
|
||||
/>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue