Merge pull request #3356 from cryppadotta/pap-1331-inbox-ux

feat: polish inbox and issue list workflows
This commit is contained in:
Dotta 2026-04-11 06:35:59 -05:00 committed by GitHub
commit 45ebecab5a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
36 changed files with 1695 additions and 390 deletions

View file

@ -21,7 +21,6 @@ import { queryKeys } from "../lib/queryKeys";
import {
applyIssueFilters,
countActiveIssueFilters,
defaultIssueFilterState,
type IssueFilterState,
} from "../lib/issue-filters";
import {
@ -31,7 +30,12 @@ import {
rememberIssueDetailLocationState,
withIssueDetailHeaderSeed,
} from "../lib/issueDetailBreadcrumb";
import { hasBlockingShortcutDialog, isKeyboardShortcutTextInputTarget } from "../lib/keyboardShortcuts";
import {
hasBlockingShortcutDialog,
isKeyboardShortcutTextInputTarget,
shouldBlurPageSearchOnEnter,
shouldBlurPageSearchOnEscape,
} from "../lib/keyboardShortcuts";
import { EmptyState } from "../components/EmptyState";
import { PageSkeleton } from "../components/PageSkeleton";
import {
@ -91,13 +95,16 @@ import {
buildInboxNesting,
getAvailableInboxIssueColumns,
getApprovalsForTab,
getArchivedInboxSearchIssues,
getInboxWorkItems,
getInboxKeyboardSelectionIndex,
getLatestFailedRunsByAgent,
matchesInboxIssueSearch,
getRecentTouchedIssues,
groupInboxWorkItems,
isInboxEntityDismissed,
isMineInboxTab,
loadInboxFilterPreferences,
loadInboxIssueColumns,
loadInboxNesting,
loadInboxWorkItemGroupBy,
@ -105,10 +112,13 @@ import {
resolveInboxNestingEnabled,
resolveIssueWorkspaceName,
resolveInboxSelectionIndex,
saveInboxFilterPreferences,
saveInboxIssueColumns,
saveInboxNesting,
saveInboxWorkItemGroupBy,
type InboxApprovalFilter,
type InboxCategoryFilter,
type InboxFilterPreferences,
type InboxIssueColumn,
saveLastInboxTab,
shouldShowInboxSection,
@ -119,14 +129,6 @@ import {
import { useDismissedInboxAlerts, useInboxDismissals, useReadInboxItems } from "../hooks/useInboxBadge";
export { InboxIssueMetaLeading, InboxIssueTrailingColumns } from "../components/IssueColumns";
type InboxCategoryFilter =
| "everything"
| "issues_i_touched"
| "join_requests"
| "approvals"
| "failed_runs"
| "alerts";
type SectionKey =
| "work_items"
| "alerts";
@ -141,8 +143,32 @@ type InboxGroupedSection = {
label: string | null;
displayItems: InboxWorkItem[];
childrenByIssueId: Map<string, Issue[]>;
isArchivedSearch: boolean;
};
function buildGroupedInboxSections(
items: InboxWorkItem[],
groupBy: InboxWorkItemGroupBy,
nestingEnabled: boolean,
options?: { keyPrefix?: string; isArchivedSearch?: boolean },
): InboxGroupedSection[] {
const keyPrefix = options?.keyPrefix ?? "";
const isArchivedSearch = options?.isArchivedSearch ?? false;
return groupInboxWorkItems(items, 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: `${keyPrefix}${group.key}`,
label: group.label,
displayItems: nestedGroup.displayItems,
childrenByIssueId: nestedGroup.childrenByIssueId,
isArchivedSearch,
};
});
}
function firstNonEmptyLine(value: string | null | undefined): string | null {
if (!value) return null;
const line = value.split("\n").map((chunk) => chunk.trim()).find(Boolean);
@ -616,14 +642,15 @@ export function Inbox() {
retry: false,
});
const [searchQuery, setSearchQuery] = useState("");
const [allCategoryFilter, setAllCategoryFilter] = useState<InboxCategoryFilter>("everything");
const [allApprovalFilter, setAllApprovalFilter] = useState<InboxApprovalFilter>("all");
const [issueFilters, setIssueFilters] = useState<IssueFilterState>(defaultIssueFilterState);
const [filterPreferences, setFilterPreferences] = useState<InboxFilterPreferences>(
() => loadInboxFilterPreferences(selectedCompanyId),
);
const [groupBy, setGroupBy] = useState<InboxWorkItemGroupBy>(() => loadInboxWorkItemGroupBy());
const [visibleIssueColumns, setVisibleIssueColumns] = useState<InboxIssueColumn[]>(loadInboxIssueColumns);
const { dismissed: dismissedAlerts, dismiss: dismissAlert } = useDismissedInboxAlerts();
const { dismissedAtByKey, dismiss: dismissInboxItem } = useInboxDismissals(selectedCompanyId);
const { readItems, markRead: markItemRead, markUnread: markItemUnread } = useReadInboxItems();
const { allCategoryFilter, allApprovalFilter, issueFilters } = filterPreferences;
const pathSegment = location.pathname.split("/").pop() ?? "mine";
const tab: InboxTab =
@ -681,6 +708,14 @@ export function Inbox() {
setSearchQuery("");
}, [tab]);
const previousSelectedCompanyIdRef = useRef<string | null>(selectedCompanyId);
useEffect(() => {
if (previousSelectedCompanyIdRef.current !== selectedCompanyId) {
previousSelectedCompanyIdRef.current = selectedCompanyId;
setFilterPreferences(loadInboxFilterPreferences(selectedCompanyId));
}
}, [selectedCompanyId]);
const {
data: approvals,
isLoading: isApprovalsLoading,
@ -754,6 +789,13 @@ export function Inbox() {
queryFn: () => heartbeatsApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
const { data: liveRuns } = useQuery({
queryKey: queryKeys.liveRuns(selectedCompanyId!),
queryFn: () => heartbeatsApi.liveRunsForCompany(selectedCompanyId!),
enabled: !!selectedCompanyId,
refetchInterval: 5000,
});
const currentUserId = session?.user.id ?? session?.session.userId ?? null;
const mineIssues = useMemo(() => getRecentTouchedIssues(mineIssuesRaw), [mineIssuesRaw]);
@ -852,13 +894,11 @@ export function Inbox() {
);
const liveIssueIds = useMemo(() => {
const ids = new Set<string>();
for (const run of heartbeatRuns ?? []) {
if (run.status !== "running" && run.status !== "queued") continue;
const issueId = readIssueIdFromRun(run);
if (issueId) ids.add(issueId);
for (const run of liveRuns ?? []) {
if (run.issueId) ids.add(run.issueId);
}
return ids;
}, [heartbeatRuns]);
}, [liveRuns]);
const approvalsToRender = useMemo(() => {
let filtered = getApprovalsForTab(approvals ?? [], tab, allApprovalFilter);
@ -909,19 +949,12 @@ export function Inbox() {
if (!q) return workItemsToRender;
return workItemsToRender.filter((item) => {
if (item.kind === "issue") {
const issue = item.issue;
if (issue.title.toLowerCase().includes(q)) return true;
if (issue.identifier?.toLowerCase().includes(q)) return true;
if (issue.description?.toLowerCase().includes(q)) return true;
if (isolatedWorkspacesEnabled) {
const workspaceName = resolveIssueWorkspaceName(issue, {
executionWorkspaceById,
projectWorkspaceById,
defaultProjectWorkspaceIdByProjectId,
});
if (workspaceName?.toLowerCase().includes(q)) return true;
}
return false;
return matchesInboxIssueSearch(item.issue, q, {
isolatedWorkspacesEnabled,
executionWorkspaceById,
projectWorkspaceById,
defaultProjectWorkspaceIdByProjectId,
});
}
if (item.kind === "approval") {
const a = item.approval;
@ -963,6 +996,35 @@ export function Inbox() {
projectWorkspaceById,
]);
const archivedSearchIssues = useMemo(
() =>
tab === "mine"
? getArchivedInboxSearchIssues({
visibleIssues: visibleMineIssues,
searchableIssues: visibleTouchedIssues,
query: searchQuery,
isolatedWorkspacesEnabled,
executionWorkspaceById,
projectWorkspaceById,
defaultProjectWorkspaceIdByProjectId,
})
: [],
[
defaultProjectWorkspaceIdByProjectId,
executionWorkspaceById,
isolatedWorkspacesEnabled,
projectWorkspaceById,
searchQuery,
tab,
visibleMineIssues,
visibleTouchedIssues,
],
);
const archivedSearchIssueIds = useMemo(
() => new Set(archivedSearchIssues.map((issue) => issue.id)),
[archivedSearchIssues],
);
// --- Parent-child nesting for inbox issues ---
const [nestingPreferenceEnabled, setNestingPreferenceEnabled] = useState(() => loadInboxNesting());
const nestingEnabled = resolveInboxNestingEnabled(nestingPreferenceEnabled, isMobile);
@ -974,33 +1036,15 @@ export function Inbox() {
});
}, []);
const [collapsedInboxParents, setCollapsedInboxParents] = useState<Set<string>>(new Set());
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 groupedSections = useMemo<InboxGroupedSection[]>(() => [
...buildGroupedInboxSections(filteredWorkItems, groupBy, nestingEnabled),
...buildGroupedInboxSections(
getInboxWorkItems({ issues: archivedSearchIssues, approvals: [] }),
groupBy,
nestingEnabled,
{ keyPrefix: "archived-search:", isArchivedSearch: true },
),
], [archivedSearchIssues, filteredWorkItems, groupBy, nestingEnabled]);
const totalVisibleWorkItems = useMemo(
() => groupedSections.reduce((count, group) => count + group.displayItems.length, 0),
[groupedSections],
@ -1052,9 +1096,28 @@ export function Inbox() {
}
setIssueColumns(visibleIssueColumns.filter((value) => value !== column));
}, [setIssueColumns, visibleIssueColumns]);
const updateFilterPreferences = useCallback(
(updater: (previous: InboxFilterPreferences) => InboxFilterPreferences) => {
setFilterPreferences((previous) => {
const next = updater(previous);
saveInboxFilterPreferences(selectedCompanyId, next);
return next;
});
},
[selectedCompanyId],
);
const updateIssueFilters = useCallback((patch: Partial<IssueFilterState>) => {
setIssueFilters((previous) => ({ ...previous, ...patch }));
}, []);
updateFilterPreferences((previous) => ({
...previous,
issueFilters: { ...previous.issueFilters, ...patch },
}));
}, [updateFilterPreferences]);
const updateAllCategoryFilter = useCallback((value: InboxCategoryFilter) => {
updateFilterPreferences((previous) => ({ ...previous, allCategoryFilter: value }));
}, [updateFilterPreferences]);
const updateAllApprovalFilter = useCallback((value: InboxApprovalFilter) => {
updateFilterPreferences((previous) => ({ ...previous, allApprovalFilter: value }));
}, [updateFilterPreferences]);
const updateGroupBy = useCallback((nextGroupBy: InboxWorkItemGroupBy) => {
setGroupBy(nextGroupBy);
saveInboxWorkItemGroupBy(nextGroupBy);
@ -1325,6 +1388,7 @@ export function Inbox() {
flatNavItems,
selectedIndex,
canArchive: canArchiveFromTab,
archivedSearchIssueIds,
archivingIssueIds,
archivingNonIssueIds,
fadingOutIssues,
@ -1335,6 +1399,7 @@ export function Inbox() {
flatNavItems,
selectedIndex,
canArchive: canArchiveFromTab,
archivedSearchIssueIds,
archivingIssueIds,
archivingNonIssueIds,
fadingOutIssues,
@ -1415,10 +1480,12 @@ export function Inbox() {
e.preventDefault();
const { issue, item } = resolveNavEntry(st.selectedIndex);
if (issue) {
if (!st.archivingIssueIds.has(issue.id)) act.archiveIssue(issue.id);
if (!st.archivedSearchIssueIds.has(issue.id) && !st.archivingIssueIds.has(issue.id)) act.archiveIssue(issue.id);
} else if (item) {
if (item.kind === "issue") {
if (!st.archivingIssueIds.has(item.issue.id)) act.archiveIssue(item.issue.id);
if (!st.archivedSearchIssueIds.has(item.issue.id) && !st.archivingIssueIds.has(item.issue.id)) {
act.archiveIssue(item.issue.id);
}
} else {
const key = getWorkItemKey(item);
if (!st.archivingNonIssueIds.has(key)) act.archiveNonIssue(key);
@ -1553,10 +1620,28 @@ export function Inbox() {
placeholder="Search inbox…"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={(e) => {
if (shouldBlurPageSearchOnEnter({
key: e.key,
isComposing: e.nativeEvent.isComposing,
})) {
e.currentTarget.blur();
return;
}
if (shouldBlurPageSearchOnEscape({
key: e.key,
isComposing: e.nativeEvent.isComposing,
currentValue: e.currentTarget.value,
})) {
e.currentTarget.blur();
}
}}
className="h-8 w-full pl-8 text-xs"
data-page-search-target="true"
/>
</div>
<div className="flex items-center justify-between gap-2">
<div className="flex flex-wrap items-center justify-between gap-2">
<Tabs value={tab} onValueChange={(value) => navigate(`/inbox/${value}`)}>
<PageTabBar
items={[
@ -1582,7 +1667,25 @@ export function Inbox() {
placeholder="Search inbox…"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={(e) => {
if (shouldBlurPageSearchOnEnter({
key: e.key,
isComposing: e.nativeEvent.isComposing,
})) {
e.currentTarget.blur();
return;
}
if (shouldBlurPageSearchOnEscape({
key: e.key,
isComposing: e.nativeEvent.isComposing,
currentValue: e.currentTarget.value,
})) {
e.currentTarget.blur();
}
}}
className="h-8 w-[220px] pl-8 text-xs"
data-page-search-target="true"
/>
</div>
<Button
@ -1604,17 +1707,20 @@ export function Inbox() {
labels={labels?.map((label) => ({ id: label.id, name: label.name, color: label.color }))}
currentUserId={currentUserId}
enableRoutineVisibilityFilter
buttonVariant="outline"
iconOnly
workspaces={isolatedWorkspacesEnabled ? executionWorkspaces.filter((w) => w.mode === "isolated_workspace" && w.status === "active").map((w) => ({ id: w.id, name: w.name })) : undefined}
/>
<Popover>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
size="sm"
className={cn("h-8 shrink-0 text-xs", groupBy !== "none" && "bg-accent")}
size="icon"
className={cn("h-8 w-8 shrink-0", groupBy !== "none" && "bg-accent")}
title="Group"
>
<Layers className="mr-1.5 h-3.5 w-3.5" />
Group
<Layers className="h-3.5 w-3.5" />
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-40 p-2">
@ -1645,6 +1751,7 @@ export function Inbox() {
onToggleColumn={toggleIssueColumn}
onResetColumns={() => setIssueColumns(DEFAULT_INBOX_ISSUE_COLUMNS)}
title="Choose which inbox columns stay visible"
iconOnly
/>
{canMarkAllRead && (
<>
@ -1691,7 +1798,7 @@ export function Inbox() {
<div className="flex flex-wrap items-center gap-2">
<Select
value={allCategoryFilter}
onValueChange={(value) => setAllCategoryFilter(value as InboxCategoryFilter)}
onValueChange={(value) => updateAllCategoryFilter(value as InboxCategoryFilter)}
>
<SelectTrigger className="h-8 w-[170px] text-xs">
<SelectValue placeholder="Category" />
@ -1709,7 +1816,7 @@ export function Inbox() {
{showApprovalsCategory && (
<Select
value={allApprovalFilter}
onValueChange={(value) => setAllApprovalFilter(value as InboxApprovalFilter)}
onValueChange={(value) => updateAllApprovalFilter(value as InboxApprovalFilter)}
>
<SelectTrigger className="h-8 w-[170px] text-xs">
<SelectValue placeholder="Approval status" />
@ -1783,6 +1890,7 @@ export function Inbox() {
isExpanded = false,
childCount = 0,
collapseParentId = null,
allowArchive = canArchiveFromTab,
}: {
issue: Issue;
depth: number;
@ -1791,6 +1899,7 @@ export function Inbox() {
isExpanded?: boolean;
childCount?: number;
collapseParentId?: string | null;
allowArchive?: boolean;
}) => {
const isUnread = issue.isUnreadForMe && !fadingOutIssues.has(issue.id);
const isFading = fadingOutIssues.has(issue.id);
@ -1857,7 +1966,7 @@ export function Inbox() {
}
unreadState={isUnread ? "visible" : isFading ? "fading" : "hidden"}
onMarkRead={() => markReadMutation.mutate(issue.id)}
onArchive={canArchiveFromTab ? () => archiveIssueMutation.mutate(issue.id) : undefined}
onArchive={allowArchive ? () => archiveIssueMutation.mutate(issue.id) : undefined}
archiveDisabled={isArchiving || archiveIssueMutation.isPending}
desktopTrailing={
visibleTrailingIssueColumns.length > 0 ? (
@ -1885,6 +1994,20 @@ export function Inbox() {
let previousTimestamp = Number.POSITIVE_INFINITY;
return groupedSections.flatMap((group, groupIndex) => {
const elements: ReactNode[] = [];
if (group.isArchivedSearch && (groupIndex === 0 || !groupedSections[groupIndex - 1]?.isArchivedSearch)) {
elements.push(
<div
key="archived-search-divider"
className="flex items-center gap-3 border-y border-border/70 bg-muted/30 px-4 py-2"
>
<div className="h-px flex-1 bg-border/80" />
<span className="shrink-0 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
Archived
</span>
<div className="h-px flex-1 bg-border/80" />
</div>,
);
}
if (group.label) {
elements.push(
<div
@ -2044,6 +2167,7 @@ export function Inbox() {
const childIssues = group.childrenByIssueId.get(issue.id) ?? [];
const hasChildren = childIssues.length > 0;
const isExpanded = hasChildren && !collapsedInboxParents.has(issue.id);
const canArchiveIssue = canArchiveFromTab && !group.isArchivedSearch;
const parentRow = renderInboxIssue({
issue,
depth: 0,
@ -2052,9 +2176,10 @@ export function Inbox() {
isExpanded,
childCount: childIssues.length,
collapseParentId: issue.id,
allowArchive: canArchiveIssue,
});
elements.push(wrapItem(`issue:${issue.id}`, isSelected, canArchiveFromTab ? (
elements.push(wrapItem(`issue:${issue.id}`, isSelected, canArchiveIssue ? (
<SwipeToArchive
key={`issue:${issue.id}`}
selected={isSelected}
@ -2073,6 +2198,7 @@ export function Inbox() {
issue: child,
depth: 1,
selected: isChildSelected,
allowArchive: canArchiveIssue,
});
const isChildArchiving = archivingIssueIds.has(child.id);
elements.push(
@ -2082,7 +2208,7 @@ export function Inbox() {
className="relative"
onClick={() => setSelectedIndex(childNavIdx)}
>
{canArchiveFromTab ? (
{canArchiveIssue ? (
<SwipeToArchive
key={`issue:${child.id}`}
selected={isChildSelected}

View file

@ -1,6 +1,6 @@
import { startTransition, useEffect, useMemo, useRef, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useNavigate, useSearchParams } from "@/lib/router";
import { Link, useNavigate, useSearchParams } from "@/lib/router";
import { Check, ChevronDown, ChevronRight, Layers, MoreHorizontal, Plus, Repeat } from "lucide-react";
import { routinesApi } from "../api/routines";
import { agentsApi } from "../api/agents";
@ -182,7 +182,7 @@ function RoutineListRow({
agentById,
runningRoutineId,
statusMutationRoutineId,
onNavigate,
href,
onRunNow,
onToggleEnabled,
onToggleArchived,
@ -192,7 +192,7 @@ function RoutineListRow({
agentById: Map<string, { name: string; icon?: string | null }>;
runningRoutineId: string | null;
statusMutationRoutineId: string | null;
onNavigate: (routineId: string) => void;
href: string;
onRunNow: (routine: RoutineListItem) => void;
onToggleEnabled: (routine: RoutineListItem, enabled: boolean) => void;
onToggleArchived: (routine: RoutineListItem) => void;
@ -205,9 +205,9 @@ function RoutineListRow({
const isDraft = !isArchived && !routine.assigneeAgentId;
return (
<div
className="group flex cursor-pointer flex-col gap-3 border-b border-border px-3 py-3 transition-colors hover:bg-accent/50 last:border-b-0 sm:flex-row sm:items-center"
onClick={() => onNavigate(routine.id)}
<Link
to={href}
className="group flex flex-col gap-3 border-b border-border px-3 py-3 transition-colors hover:bg-accent/50 last:border-b-0 sm:flex-row sm:items-center no-underline text-inherit"
>
<div className="min-w-0 flex-1 space-y-1.5">
<div className="flex flex-wrap items-center gap-2">
@ -237,7 +237,7 @@ function RoutineListRow({
</div>
</div>
<div className="flex items-center gap-3" onClick={(event) => event.stopPropagation()}>
<div className="flex items-center gap-3" onClick={(event) => { event.preventDefault(); event.stopPropagation(); }}>
<div className="flex items-center gap-3">
<ToggleSwitch
size="lg"
@ -258,8 +258,8 @@ function RoutineListRow({
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => onNavigate(routine.id)}>
Edit
<DropdownMenuItem asChild>
<Link to={href}>Edit</Link>
</DropdownMenuItem>
<DropdownMenuItem
disabled={runningRoutineId === routine.id || isArchived}
@ -283,7 +283,7 @@ function RoutineListRow({
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</Link>
);
}
@ -566,9 +566,8 @@ export function Routines() {
<div className="space-y-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div className="space-y-1">
<h1 className="text-2xl font-semibold tracking-tight flex items-center gap-2">
<h1 className="text-2xl font-semibold tracking-tight">
Routines
<span className="rounded-full bg-amber-100 px-2 py-0.5 text-xs font-medium text-amber-800 dark:bg-amber-900/30 dark:text-amber-400">Beta</span>
</h1>
<p className="text-sm text-muted-foreground">
Recurring work definitions that materialize into auditable execution issues.
@ -953,7 +952,7 @@ export function Routines() {
agentById={agentById}
runningRoutineId={runningRoutineId}
statusMutationRoutineId={statusMutationRoutineId}
onNavigate={(routineId) => navigate(`/routines/${routineId}`)}
href={`/routines/${routine.id}`}
onRunNow={handleRunNow}
onToggleEnabled={handleToggleEnabled}
onToggleArchived={handleToggleArchived}