mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-16 19:00:38 +09:00
[codex] Improve workspace runtime and navigation ergonomics (#3680)
## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies > - That operator experience depends not just on issue chat, but also on how workspaces, inbox groups, and navigation state behave over long-running sessions > - The current branch included a separate cluster of workspace-runtime controls, inbox grouping, sidebar ordering, and worktree lifecycle fixes > - Those changes cross server, shared contracts, database state, and UI navigation, but they still form one coherent operator workflow area > - This pull request isolates the workspace/runtime and navigation ergonomics work into one standalone branch > - The benefit is better workspace recovery and navigation persistence without forcing reviewers through the unrelated issue-detail/chat work ## What Changed - Improved execution workspace and project workspace controls, request wiring, layout, and JSON editor ergonomics - Hardened linked worktree reuse/startup behavior and documented the `worktree repair` flow for recovering linked worktrees safely - Added inbox workspace grouping, mobile collapse, archive undo, keyboard navigation, shared group-header styling, and persisted collapsed-group behavior - Added persistent sidebar order preferences with the supporting DB migration, shared/server contracts, routes, services, hooks, and UI integration - Scoped issue-list preferences by context and added targeted UI/server tests for workspace controls, inbox behavior, sidebar preferences, and worktree validation ## Verification - `pnpm vitest run server/src/__tests__/sidebar-preferences-routes.test.ts ui/src/pages/Inbox.test.tsx ui/src/components/ProjectWorkspaceSummaryCard.test.tsx ui/src/components/WorkspaceRuntimeControls.test.tsx ui/src/api/workspace-runtime-control.test.ts` - `server/src/__tests__/workspace-runtime.test.ts` was attempted, but the embedded Postgres suite self-skipped/hung on this host after reporting an init-script issue, so it is not counted as a local pass here ## Risks - Medium: this branch includes migration-backed preference storage plus worktree/runtime behavior, so merge review should pay attention to state persistence and worktree recovery semantics - The sidebar preference migration is standalone, but it should still be watched for conflicts if another migration lands first ## Model Used - OpenAI Codex coding agent (GPT-5-class runtime in Codex CLI; exact deployed model ID is not exposed in this environment), reasoning enabled, tool use and local code execution enabled ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [ ] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [ ] If this change affects the UI, I have included before/after screenshots - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
6e6f538630
commit
e89076148a
64 changed files with 18576 additions and 1063 deletions
|
|
@ -34,10 +34,12 @@ import { prefetchIssueDetail } from "../lib/issueDetailCache";
|
|||
import {
|
||||
hasBlockingShortcutDialog,
|
||||
isKeyboardShortcutTextInputTarget,
|
||||
resolveInboxUndoArchiveKeyAction,
|
||||
shouldBlurPageSearchOnEnter,
|
||||
shouldBlurPageSearchOnEscape,
|
||||
} from "../lib/keyboardShortcuts";
|
||||
import { EmptyState } from "../components/EmptyState";
|
||||
import { IssueGroupHeader } from "../components/IssueGroupHeader";
|
||||
import { PageSkeleton } from "../components/PageSkeleton";
|
||||
import {
|
||||
InboxIssueMetaLeading,
|
||||
|
|
@ -93,12 +95,14 @@ import type { Approval, HeartbeatRun, Issue, JoinRequest } from "@paperclipai/sh
|
|||
import {
|
||||
ACTIONABLE_APPROVAL_STATUSES,
|
||||
DEFAULT_INBOX_ISSUE_COLUMNS,
|
||||
buildInboxKeyboardNavEntries,
|
||||
buildInboxNesting,
|
||||
getAvailableInboxIssueColumns,
|
||||
getInboxWorkItemKey,
|
||||
getApprovalsForTab,
|
||||
getArchivedInboxSearchIssues,
|
||||
getInboxWorkItems,
|
||||
getInboxKeyboardSelectionIndex,
|
||||
getInboxWorkItems,
|
||||
getInboxSearchSupplementIssues,
|
||||
getLatestFailedRunsByAgent,
|
||||
matchesInboxIssueSearch,
|
||||
|
|
@ -106,22 +110,27 @@ import {
|
|||
groupInboxWorkItems,
|
||||
isInboxEntityDismissed,
|
||||
isMineInboxTab,
|
||||
loadCollapsedInboxGroupKeys,
|
||||
loadInboxFilterPreferences,
|
||||
loadInboxIssueColumns,
|
||||
loadInboxNesting,
|
||||
loadInboxWorkItemGroupBy,
|
||||
normalizeInboxIssueColumns,
|
||||
resolveInboxNestingEnabled,
|
||||
shouldResetInboxWorkspaceGrouping,
|
||||
resolveIssueWorkspaceName,
|
||||
resolveInboxSelectionIndex,
|
||||
saveInboxFilterPreferences,
|
||||
saveCollapsedInboxGroupKeys,
|
||||
saveInboxIssueColumns,
|
||||
saveInboxNesting,
|
||||
saveInboxWorkItemGroupBy,
|
||||
type InboxWorkspaceGroupingOptions,
|
||||
type InboxApprovalFilter,
|
||||
type InboxCategoryFilter,
|
||||
type InboxFilterPreferences,
|
||||
type InboxIssueColumn,
|
||||
type InboxKeyboardNavEntry,
|
||||
saveLastInboxTab,
|
||||
shouldShowInboxSection,
|
||||
type InboxTab,
|
||||
|
|
@ -131,14 +140,13 @@ import {
|
|||
import { useDismissedInboxAlerts, useInboxDismissals, useReadInboxItems } from "../hooks/useInboxBadge";
|
||||
|
||||
export { InboxIssueMetaLeading, InboxIssueTrailingColumns } from "../components/IssueColumns";
|
||||
export { IssueGroupHeader as InboxGroupHeader } from "../components/IssueGroupHeader";
|
||||
type SectionKey =
|
||||
| "work_items"
|
||||
| "alerts";
|
||||
|
||||
/** A flat navigation entry for keyboard j/k traversal that includes expanded children. */
|
||||
type NavEntry =
|
||||
| { type: "top"; index: number; item: InboxWorkItem }
|
||||
| { type: "child"; parentIndex: number; issue: Issue };
|
||||
type NavEntry = InboxKeyboardNavEntry;
|
||||
|
||||
type InboxGroupedSection = {
|
||||
key: string;
|
||||
|
|
@ -152,11 +160,12 @@ function buildGroupedInboxSections(
|
|||
items: InboxWorkItem[],
|
||||
groupBy: InboxWorkItemGroupBy,
|
||||
nestingEnabled: boolean,
|
||||
workspaceGrouping: InboxWorkspaceGroupingOptions,
|
||||
options?: { keyPrefix?: string; isArchivedSearch?: boolean },
|
||||
): InboxGroupedSection[] {
|
||||
const keyPrefix = options?.keyPrefix ?? "";
|
||||
const isArchivedSearch = options?.isArchivedSearch ?? false;
|
||||
return groupInboxWorkItems(items, groupBy).map((group) => {
|
||||
return groupInboxWorkItems(items, groupBy, workspaceGrouping).map((group) => {
|
||||
const nestedGroup = nestingEnabled && group.items.some((item) => item.kind === "issue")
|
||||
? buildInboxNesting(group.items)
|
||||
: { displayItems: group.items, childrenByIssueId: new Map<string, Issue[]>() };
|
||||
|
|
@ -643,6 +652,7 @@ export function Inbox() {
|
|||
queryFn: () => instanceSettingsApi.getExperimental(),
|
||||
retry: false,
|
||||
});
|
||||
const experimentalSettingsLoaded = experimentalSettings !== undefined;
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const normalizedSearchQuery = searchQuery.trim();
|
||||
const [filterPreferences, setFilterPreferences] = useState<InboxFilterPreferences>(
|
||||
|
|
@ -716,6 +726,7 @@ export function Inbox() {
|
|||
if (previousSelectedCompanyIdRef.current !== selectedCompanyId) {
|
||||
previousSelectedCompanyIdRef.current = selectedCompanyId;
|
||||
setFilterPreferences(loadInboxFilterPreferences(selectedCompanyId));
|
||||
setCollapsedGroupKeys(loadCollapsedInboxGroupKeys(selectedCompanyId));
|
||||
}
|
||||
}, [selectedCompanyId]);
|
||||
|
||||
|
|
@ -877,6 +888,14 @@ export function Inbox() {
|
|||
}
|
||||
return map;
|
||||
}, [executionWorkspaces]);
|
||||
const inboxWorkspaceGrouping = useMemo<InboxWorkspaceGroupingOptions>(
|
||||
() => ({
|
||||
executionWorkspaceById,
|
||||
projectWorkspaceById,
|
||||
defaultProjectWorkspaceIdByProjectId,
|
||||
}),
|
||||
[defaultProjectWorkspaceIdByProjectId, executionWorkspaceById, projectWorkspaceById],
|
||||
);
|
||||
const visibleIssueColumnSet = useMemo(() => new Set(visibleIssueColumns), [visibleIssueColumns]);
|
||||
const availableIssueColumns = useMemo(
|
||||
() => getAvailableInboxIssueColumns(isolatedWorkspacesEnabled),
|
||||
|
|
@ -1078,6 +1097,11 @@ export function Inbox() {
|
|||
// --- Parent-child nesting for inbox issues ---
|
||||
const [nestingPreferenceEnabled, setNestingPreferenceEnabled] = useState(() => loadInboxNesting());
|
||||
const nestingEnabled = resolveInboxNestingEnabled(nestingPreferenceEnabled, isMobile);
|
||||
useEffect(() => {
|
||||
if (!shouldResetInboxWorkspaceGrouping(groupBy, isolatedWorkspacesEnabled, experimentalSettingsLoaded)) return;
|
||||
setGroupBy("none");
|
||||
saveInboxWorkItemGroupBy("none");
|
||||
}, [experimentalSettingsLoaded, groupBy, isolatedWorkspacesEnabled]);
|
||||
const toggleNesting = useCallback(() => {
|
||||
setNestingPreferenceEnabled((prev) => {
|
||||
const next = !prev;
|
||||
|
|
@ -1086,15 +1110,26 @@ export function Inbox() {
|
|||
});
|
||||
}, []);
|
||||
const [collapsedInboxParents, setCollapsedInboxParents] = useState<Set<string>>(new Set());
|
||||
const [collapsedGroupKeys, setCollapsedGroupKeys] = useState<Set<string>>(() => loadCollapsedInboxGroupKeys(selectedCompanyId));
|
||||
const toggleGroupCollapse = useCallback((groupKey: string) => {
|
||||
setCollapsedGroupKeys((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(groupKey)) next.delete(groupKey);
|
||||
else next.add(groupKey);
|
||||
saveCollapsedInboxGroupKeys(selectedCompanyId, next);
|
||||
return next;
|
||||
});
|
||||
}, [selectedCompanyId]);
|
||||
const groupedSections = useMemo<InboxGroupedSection[]>(() => [
|
||||
...buildGroupedInboxSections(effectiveWorkItems, groupBy, nestingEnabled),
|
||||
...buildGroupedInboxSections(effectiveWorkItems, groupBy, nestingEnabled, inboxWorkspaceGrouping),
|
||||
...buildGroupedInboxSections(
|
||||
getInboxWorkItems({ issues: archivedSearchIssues, approvals: [] }),
|
||||
groupBy,
|
||||
nestingEnabled,
|
||||
inboxWorkspaceGrouping,
|
||||
{ keyPrefix: "archived-search:", isArchivedSearch: true },
|
||||
),
|
||||
], [archivedSearchIssues, effectiveWorkItems, groupBy, nestingEnabled]);
|
||||
], [archivedSearchIssues, effectiveWorkItems, groupBy, inboxWorkspaceGrouping, nestingEnabled]);
|
||||
const totalVisibleWorkItems = useMemo(
|
||||
() => groupedSections.reduce((count, group) => count + group.displayItems.length, 0),
|
||||
[groupedSections],
|
||||
|
|
@ -1108,27 +1143,24 @@ export function Inbox() {
|
|||
});
|
||||
}, []);
|
||||
|
||||
// Build flat navigation list including expanded children for keyboard traversal
|
||||
// Build flat navigation list from visible rows so keyboard traversal respects collapsed groups.
|
||||
const flatNavItems = useMemo((): NavEntry[] => {
|
||||
const entries: NavEntry[] = [];
|
||||
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;
|
||||
}, [groupedSections, collapsedInboxParents]);
|
||||
return buildInboxKeyboardNavEntries(groupedSections, collapsedGroupKeys, collapsedInboxParents);
|
||||
}, [collapsedGroupKeys, collapsedInboxParents, groupedSections]);
|
||||
const topFlatIndex = useMemo(() => {
|
||||
const map = new Map<string, number>();
|
||||
flatNavItems.forEach((entry, index) => {
|
||||
if (entry.type === "top") map.set(entry.itemKey, index);
|
||||
});
|
||||
return map;
|
||||
}, [flatNavItems]);
|
||||
const childFlatIndex = useMemo(() => {
|
||||
const map = new Map<string, number>();
|
||||
flatNavItems.forEach((entry, index) => {
|
||||
if (entry.type === "child") map.set(entry.issueId, index);
|
||||
});
|
||||
return map;
|
||||
}, [flatNavItems]);
|
||||
|
||||
const agentName = (id: string | null) => {
|
||||
if (!id) return null;
|
||||
|
|
@ -1267,6 +1299,8 @@ export function Inbox() {
|
|||
const [fadingOutIssues, setFadingOutIssues] = useState<Set<string>>(new Set());
|
||||
const [showMarkAllReadConfirm, setShowMarkAllReadConfirm] = useState(false);
|
||||
const [archivingIssueIds, setArchivingIssueIds] = useState<Set<string>>(new Set());
|
||||
const [undoableArchiveIssueIds, setUndoableArchiveIssueIds] = useState<string[]>([]);
|
||||
const [unarchivingIssueIds, setUnarchivingIssueIds] = useState<Set<string>>(new Set());
|
||||
const [fadingNonIssueItems, setFadingNonIssueItems] = useState<Set<string>>(new Set());
|
||||
const [archivingNonIssueIds, setArchivingNonIssueIds] = useState<Set<string>>(new Set());
|
||||
const [selectedIndex, setSelectedIndex] = useState<number>(-1);
|
||||
|
|
@ -1321,7 +1355,7 @@ export function Inbox() {
|
|||
}
|
||||
}
|
||||
},
|
||||
onSettled: (_data, error, id) => {
|
||||
onSettled: (_data, _error, id) => {
|
||||
// Clean up archiving state and refetch to sync with server
|
||||
setArchivingIssueIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
|
|
@ -1330,6 +1364,34 @@ export function Inbox() {
|
|||
});
|
||||
invalidateInboxIssueQueries();
|
||||
},
|
||||
onSuccess: (_data, id) => {
|
||||
setUndoableArchiveIssueIds((prev) => [...prev.filter((issueId) => issueId !== id), id]);
|
||||
},
|
||||
});
|
||||
|
||||
const unarchiveIssueMutation = useMutation({
|
||||
mutationFn: (id: string) => issuesApi.unarchiveFromInbox(id),
|
||||
onMutate: (id) => {
|
||||
setActionError(null);
|
||||
setUnarchivingIssueIds((prev) => new Set(prev).add(id));
|
||||
},
|
||||
onError: (err) => {
|
||||
setActionError(err instanceof Error ? err.message : "Failed to undo inbox archive");
|
||||
},
|
||||
onSuccess: (_data, id) => {
|
||||
setUndoableArchiveIssueIds((prev) => {
|
||||
const next = prev.filter((issueId) => issueId !== id);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
onSettled: (_data, _error, id) => {
|
||||
setUnarchivingIssueIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(id);
|
||||
return next;
|
||||
});
|
||||
invalidateInboxIssueQueries();
|
||||
},
|
||||
});
|
||||
|
||||
const markReadMutation = useMutation({
|
||||
|
|
@ -1420,18 +1482,16 @@ export function Inbox() {
|
|||
return "hidden";
|
||||
};
|
||||
|
||||
const getWorkItemKey = useCallback((item: InboxWorkItem): string => {
|
||||
if (item.kind === "issue") return `issue:${item.issue.id}`;
|
||||
if (item.kind === "approval") return `approval:${item.approval.id}`;
|
||||
if (item.kind === "failed_run") return `run:${item.run.id}`;
|
||||
return `join:${item.joinRequest.id}`;
|
||||
}, []);
|
||||
|
||||
// Keep selection valid when the list shape changes, but do not auto-select on initial load.
|
||||
useEffect(() => {
|
||||
setSelectedIndex((prev) => resolveInboxSelectionIndex(prev, flatNavItems.length));
|
||||
}, [flatNavItems.length]);
|
||||
|
||||
useEffect(() => {
|
||||
setUndoableArchiveIssueIds([]);
|
||||
setUnarchivingIssueIds(new Set());
|
||||
}, [selectedCompanyId]);
|
||||
|
||||
// Use refs for keyboard handler to avoid stale closures
|
||||
const kbStateRef = useRef({
|
||||
workItems: groupedSections,
|
||||
|
|
@ -1440,6 +1500,8 @@ export function Inbox() {
|
|||
canArchive: canArchiveFromTab,
|
||||
archivedSearchIssueIds,
|
||||
archivingIssueIds,
|
||||
undoableArchiveIssueIds,
|
||||
unarchivingIssueIds,
|
||||
archivingNonIssueIds,
|
||||
fadingOutIssues,
|
||||
readItems,
|
||||
|
|
@ -1451,6 +1513,8 @@ export function Inbox() {
|
|||
canArchive: canArchiveFromTab,
|
||||
archivedSearchIssueIds,
|
||||
archivingIssueIds,
|
||||
undoableArchiveIssueIds,
|
||||
unarchivingIssueIds,
|
||||
archivingNonIssueIds,
|
||||
fadingOutIssues,
|
||||
readItems,
|
||||
|
|
@ -1458,6 +1522,7 @@ export function Inbox() {
|
|||
|
||||
const kbActionsRef = useRef({
|
||||
archiveIssue: (id: string) => archiveIssueMutation.mutate(id),
|
||||
undoArchiveIssue: (id: string) => unarchiveIssueMutation.mutate(id),
|
||||
archiveNonIssue: handleArchiveNonIssue,
|
||||
markRead: (id: string) => markReadMutation.mutate(id),
|
||||
markUnreadIssue: (id: string) => markUnreadMutation.mutate(id),
|
||||
|
|
@ -1467,6 +1532,7 @@ export function Inbox() {
|
|||
});
|
||||
kbActionsRef.current = {
|
||||
archiveIssue: (id: string) => archiveIssueMutation.mutate(id),
|
||||
undoArchiveIssue: (id: string) => unarchiveIssueMutation.mutate(id),
|
||||
archiveNonIssue: handleArchiveNonIssue,
|
||||
markRead: (id: string) => markReadMutation.mutate(id),
|
||||
markUnreadIssue: (id: string) => markUnreadMutation.mutate(id),
|
||||
|
|
@ -1501,6 +1567,24 @@ export function Inbox() {
|
|||
// Keyboard shortcuts are only active on the "mine" tab
|
||||
if (!st.canArchive) return;
|
||||
|
||||
const undoArchiveAction = resolveInboxUndoArchiveKeyAction({
|
||||
hasUndoableArchive: st.undoableArchiveIssueIds.length > 0,
|
||||
defaultPrevented: e.defaultPrevented,
|
||||
key: e.key,
|
||||
metaKey: e.metaKey,
|
||||
ctrlKey: e.ctrlKey,
|
||||
altKey: e.altKey,
|
||||
target,
|
||||
hasOpenDialog: hasBlockingShortcutDialog(document),
|
||||
});
|
||||
if (undoArchiveAction === "undo_archive") {
|
||||
const issueId = st.undoableArchiveIssueIds[st.undoableArchiveIssueIds.length - 1];
|
||||
if (!issueId || st.unarchivingIssueIds.has(issueId)) return;
|
||||
e.preventDefault();
|
||||
act.undoArchiveIssue(issueId);
|
||||
return;
|
||||
}
|
||||
|
||||
const navItems = st.flatNavItems;
|
||||
const navCount = navItems.length;
|
||||
if (navCount === 0) return;
|
||||
|
|
@ -1537,7 +1621,7 @@ export function Inbox() {
|
|||
act.archiveIssue(item.issue.id);
|
||||
}
|
||||
} else {
|
||||
const key = getWorkItemKey(item);
|
||||
const key = getInboxWorkItemKey(item);
|
||||
if (!st.archivingNonIssueIds.has(key)) act.archiveNonIssue(key);
|
||||
}
|
||||
}
|
||||
|
|
@ -1551,7 +1635,7 @@ export function Inbox() {
|
|||
act.markUnreadIssue(issue.id);
|
||||
} else if (item) {
|
||||
if (item.kind === "issue") act.markUnreadIssue(item.issue.id);
|
||||
else act.markNonIssueUnread(getWorkItemKey(item));
|
||||
else act.markNonIssueUnread(getInboxWorkItemKey(item));
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
|
@ -1565,7 +1649,7 @@ export function Inbox() {
|
|||
if (item.kind === "issue") {
|
||||
if (item.issue.isUnreadForMe && !st.fadingOutIssues.has(item.issue.id)) act.markRead(item.issue.id);
|
||||
} else {
|
||||
const key = getWorkItemKey(item);
|
||||
const key = getInboxWorkItemKey(item);
|
||||
if (!st.readItems.has(key)) act.markNonIssueRead(key);
|
||||
}
|
||||
}
|
||||
|
|
@ -1604,7 +1688,7 @@ export function Inbox() {
|
|||
};
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [getWorkItemKey, issueLinkState, keyboardShortcutsEnabled]);
|
||||
}, [issueLinkState, keyboardShortcutsEnabled]);
|
||||
|
||||
// Scroll selected item into view
|
||||
useEffect(() => {
|
||||
|
|
@ -1780,6 +1864,7 @@ export function Inbox() {
|
|||
{([
|
||||
["none", "None"],
|
||||
["type", "Type"],
|
||||
...(isolatedWorkspacesEnabled ? ([["workspace", "Workspace"]] as const) : []),
|
||||
] as const).map(([value, label]) => (
|
||||
<button
|
||||
key={value}
|
||||
|
|
@ -1913,27 +1998,6 @@ 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.
|
||||
let flatIdx = 0;
|
||||
const topFlatIndex = new Map<string, number>();
|
||||
const childFlatIndex = new Map<string, number>();
|
||||
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++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const renderInboxIssue = ({
|
||||
issue,
|
||||
depth,
|
||||
|
|
@ -2046,6 +2110,7 @@ export function Inbox() {
|
|||
let previousTimestamp = Number.POSITIVE_INFINITY;
|
||||
return groupedSections.flatMap((group, groupIndex) => {
|
||||
const elements: ReactNode[] = [];
|
||||
const isGroupCollapsed = collapsedGroupKeys.has(group.key);
|
||||
if (group.isArchivedSearch && (groupIndex === 0 || !groupedSections[groupIndex - 1]?.isArchivedSearch)) {
|
||||
elements.push(
|
||||
<div
|
||||
|
|
@ -2065,18 +2130,24 @@ export function Inbox() {
|
|||
<div
|
||||
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",
|
||||
"px-3 sm:px-4",
|
||||
groupIndex > 0 && "pt-2",
|
||||
)}
|
||||
>
|
||||
{group.label}
|
||||
<IssueGroupHeader
|
||||
label={group.label}
|
||||
collapsible
|
||||
collapsed={isGroupCollapsed}
|
||||
onToggle={() => toggleGroupCollapse(group.key)}
|
||||
/>
|
||||
</div>,
|
||||
);
|
||||
}
|
||||
if (isGroupCollapsed) 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 navIdx = topFlatIndex.get(`${group.key}:${getInboxWorkItemKey(item)}`) ?? 0;
|
||||
const wrapItem = (key: string, isSelected: boolean, child: ReactNode) => (
|
||||
<div
|
||||
key={`sel-${key}`}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue