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:
Dotta 2026-04-09 14:52:16 -05:00 committed by GitHub
commit 0e87fdbe35
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
50 changed files with 2860 additions and 1206 deletions

View file

@ -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>

View file

@ -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"

View file

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

View file

@ -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}
/>