[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:
Dotta 2026-04-14 12:57:11 -05:00 committed by GitHub
parent 6e6f538630
commit e89076148a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
64 changed files with 18576 additions and 1063 deletions

View file

@ -12,11 +12,13 @@ import type {
} from "@paperclipai/shared";
import {
DEFAULT_INBOX_ISSUE_COLUMNS,
buildInboxKeyboardNavEntries,
buildInboxDismissedAtByKey,
computeInboxBadgeData,
filterInboxIssues,
getArchivedInboxSearchIssues,
getAvailableInboxIssueColumns,
getInboxWorkItemKey,
getApprovalsForTab,
getInboxWorkItems,
getInboxKeyboardSelectionIndex,
@ -28,16 +30,22 @@ import {
isMineInboxTab,
loadInboxFilterPreferences,
loadInboxIssueColumns,
loadInboxWorkItemGroupBy,
loadCollapsedInboxGroupKeys,
loadLastInboxTab,
matchesInboxIssueSearch,
normalizeInboxIssueColumns,
RECENT_ISSUES_LIMIT,
resolveInboxNestingEnabled,
resolveIssueWorkspaceName,
resolveIssueWorkspaceGroup,
resolveInboxSelectionIndex,
saveInboxFilterPreferences,
saveCollapsedInboxGroupKeys,
saveInboxIssueColumns,
saveInboxWorkItemGroupBy,
saveLastInboxTab,
shouldResetInboxWorkspaceGrouping,
shouldShowInboxSection,
type InboxWorkItem,
} from "./inbox";
@ -487,6 +495,71 @@ describe("inbox helpers", () => {
]);
});
it("skips hidden groups when building keyboard navigation entries", () => {
const visibleIssue = makeIssue("visible", true);
const hiddenIssue = makeIssue("hidden", true);
const approval = makeApprovalWithTimestamps("approval-1", "pending", "2026-03-11T03:00:00.000Z");
const entries = buildInboxKeyboardNavEntries(
[
{
key: "visible-group",
displayItems: [{ kind: "issue", timestamp: 3, issue: visibleIssue }],
childrenByIssueId: new Map(),
},
{
key: "hidden-group",
displayItems: [
{ kind: "issue", timestamp: 2, issue: hiddenIssue },
{ kind: "approval", timestamp: 1, approval },
],
childrenByIssueId: new Map(),
},
],
new Set(["hidden-group"]),
new Set(),
);
expect(entries).toEqual([
{
type: "top",
itemKey: `visible-group:${getInboxWorkItemKey({ kind: "issue", timestamp: 3, issue: visibleIssue })}`,
item: { kind: "issue", timestamp: 3, issue: visibleIssue },
},
]);
});
it("includes child issues only when their parent row is expanded", () => {
const parentIssue = makeIssue("parent", true);
const childIssue = makeIssue("child", true);
childIssue.parentId = parentIssue.id;
const groupedSections = [
{
key: "workspace:default",
displayItems: [{ kind: "issue", timestamp: 2, issue: parentIssue } satisfies InboxWorkItem],
childrenByIssueId: new Map([[parentIssue.id, [childIssue]]]),
},
];
expect(
buildInboxKeyboardNavEntries(groupedSections, new Set(), new Set()).map((entry) => entry.type === "top"
? entry.itemKey
: entry.issueId),
).toEqual([
`workspace:default:${getInboxWorkItemKey({ kind: "issue", timestamp: 2, issue: parentIssue })}`,
childIssue.id,
]);
expect(
buildInboxKeyboardNavEntries(groupedSections, new Set(), new Set([parentIssue.id])).map((entry) => entry.type === "top"
? entry.itemKey
: entry.issueId),
).toEqual([
`workspace:default:${getInboxWorkItemKey({ kind: "issue", timestamp: 2, issue: parentIssue })}`,
]);
});
it("sorts self-touched issues without external comments by updatedAt", () => {
const recentSelfTouched = makeIssue("recent", false);
recentSelfTouched.lastExternalCommentAt = null as unknown as Date;
@ -575,6 +648,22 @@ describe("inbox helpers", () => {
)).toBe(true);
});
it("resolves the default workspace into an explicit grouping label", () => {
const issue = makeIssue("default", false);
issue.projectId = "project-1";
issue.projectWorkspaceId = "project-workspace-1";
expect(resolveIssueWorkspaceGroup(issue, {
projectWorkspaceById: new Map([
["project-workspace-1", { name: "Primary workspace" }],
]),
defaultProjectWorkspaceIdByProjectId: new Map([["project-1", "project-workspace-1"]]),
})).toEqual({
key: "workspace:project:project-workspace-1",
label: "Primary workspace (default)",
});
});
it("returns archived search matches that are not already visible in the inbox", () => {
const visibleIssue = makeIssue("visible", false);
visibleIssue.title = "Alpha visible task";
@ -939,4 +1028,82 @@ describe("inbox helpers", () => {
{ key: "join_request", label: "Join requests", items: [items[4]] },
]);
});
it("groups workspace sections by latest issue activity while preserving non-issue sections", () => {
const defaultIssue = makeIssue("default", true);
defaultIssue.projectId = "project-1";
defaultIssue.projectWorkspaceId = "project-workspace-1";
const sharedDefaultIssue = makeIssue("shared-default", true);
sharedDefaultIssue.projectId = "project-1";
sharedDefaultIssue.projectWorkspaceId = "project-workspace-1";
sharedDefaultIssue.executionWorkspaceId = "execution-workspace-shared-default";
const featureIssue = makeIssue("feature", false);
featureIssue.projectId = "project-1";
featureIssue.projectWorkspaceId = "project-workspace-2";
const execIssue = makeIssue("exec", false);
execIssue.projectId = "project-1";
execIssue.projectWorkspaceId = "project-workspace-1";
execIssue.executionWorkspaceId = "execution-workspace-1";
const items: InboxWorkItem[] = [
{ kind: "issue", timestamp: 5, issue: defaultIssue },
{ kind: "approval", timestamp: 2, approval: makeApproval("pending") },
{ kind: "issue", timestamp: 4, issue: sharedDefaultIssue },
{ kind: "issue", timestamp: 7, issue: featureIssue },
{ kind: "issue", timestamp: 9, issue: execIssue },
];
expect(groupInboxWorkItems(items, "workspace", {
executionWorkspaceById: new Map([
["execution-workspace-1", { name: "Feature Branch", mode: "isolated_workspace", projectWorkspaceId: "project-workspace-1" }],
["execution-workspace-shared-default", { name: "Shared default workspace", mode: "shared_workspace", projectWorkspaceId: "project-workspace-1" }],
]),
projectWorkspaceById: new Map([
["project-workspace-1", { name: "Primary workspace" }],
["project-workspace-2", { name: "Secondary workspace" }],
]),
defaultProjectWorkspaceIdByProjectId: new Map([["project-1", "project-workspace-1"]]),
})).toEqual([
{ key: "workspace:execution:execution-workspace-1", label: "Feature Branch", items: [items[4]] },
{ key: "workspace:project:project-workspace-2", label: "Secondary workspace", items: [items[3]] },
{
key: "workspace:project:project-workspace-1",
label: "Primary workspace (default)",
items: [items[0], items[2]],
},
{ key: "kind:approval", label: "Approvals", items: [items[1]] },
]);
});
it("persists workspace grouping preferences", () => {
saveInboxWorkItemGroupBy("workspace");
expect(loadInboxWorkItemGroupBy()).toBe("workspace");
});
it("persists collapsed inbox groups per company", () => {
saveCollapsedInboxGroupKeys("company-1", new Set(["workspace:alpha", "workspace:beta"]));
saveCollapsedInboxGroupKeys("company-2", new Set(["type:approval"]));
expect(loadCollapsedInboxGroupKeys("company-1")).toEqual(new Set(["workspace:alpha", "workspace:beta"]));
expect(loadCollapsedInboxGroupKeys("company-2")).toEqual(new Set(["type:approval"]));
});
it("returns empty collapsed inbox groups for missing or invalid storage", () => {
expect(loadCollapsedInboxGroupKeys("company-1")).toEqual(new Set());
localStorage.setItem("paperclip:inbox:collapsed-groups:company-1", JSON.stringify({ nope: true }));
expect(loadCollapsedInboxGroupKeys("company-1")).toEqual(new Set());
});
it("does not reset workspace grouping before experimental settings have loaded", () => {
expect(shouldResetInboxWorkspaceGrouping("workspace", false, false)).toBe(false);
});
it("resets workspace grouping only when settings are loaded and workspace grouping is unavailable", () => {
expect(shouldResetInboxWorkspaceGrouping("workspace", false, true)).toBe(true);
expect(shouldResetInboxWorkspaceGrouping("workspace", true, true)).toBe(false);
expect(shouldResetInboxWorkspaceGrouping("none", false, true)).toBe(false);
});
});

View file

@ -22,6 +22,7 @@ export const INBOX_ISSUE_COLUMNS_KEY = "paperclip:inbox:issue-columns";
export const INBOX_NESTING_KEY = "paperclip:inbox:nesting";
export const INBOX_GROUP_BY_KEY = "paperclip:inbox:group-by";
export const INBOX_FILTER_PREFERENCES_KEY_PREFIX = "paperclip:inbox:filters";
export const INBOX_COLLAPSED_GROUPS_KEY_PREFIX = "paperclip:inbox:collapsed-groups";
export type InboxTab = "mine" | "recent" | "unread" | "all";
export type InboxCategoryFilter =
| "everything"
@ -31,7 +32,7 @@ export type InboxCategoryFilter =
| "failed_runs"
| "alerts";
export type InboxApprovalFilter = "all" | "actionable" | "resolved";
export type InboxWorkItemGroupBy = "none" | "type";
export type InboxWorkItemGroupBy = "none" | "type" | "workspace";
export const inboxIssueColumns = [
"status",
"id",
@ -86,6 +87,40 @@ export interface InboxWorkItemGroup {
items: InboxWorkItem[];
}
export interface InboxKeyboardGroupSection {
key: string;
displayItems: InboxWorkItem[];
childrenByIssueId: ReadonlyMap<string, Issue[]>;
}
export type InboxKeyboardNavEntry =
| {
type: "top";
itemKey: string;
item: InboxWorkItem;
}
| {
type: "child";
issueId: string;
issue: Issue;
};
export interface InboxProjectWorkspaceLookup {
name: string;
}
export interface InboxExecutionWorkspaceLookup {
name: string;
mode: "shared_workspace" | "isolated_workspace" | "operator_branch" | "adapter_managed" | "cloud_sandbox";
projectWorkspaceId: string | null;
}
export interface InboxWorkspaceGroupingOptions {
executionWorkspaceById?: ReadonlyMap<string, InboxExecutionWorkspaceLookup>;
projectWorkspaceById?: ReadonlyMap<string, InboxProjectWorkspaceLookup>;
defaultProjectWorkspaceIdByProjectId?: ReadonlyMap<string, string>;
}
const defaultInboxFilterPreferences: InboxFilterPreferences = {
allCategoryFilter: "everything",
allApprovalFilter: "all",
@ -130,6 +165,11 @@ function getInboxFilterPreferencesStorageKey(companyId: string | null | undefine
return `${INBOX_FILTER_PREFERENCES_KEY_PREFIX}:${companyId}`;
}
function getInboxCollapsedGroupsStorageKey(companyId: string | null | undefined): string | null {
if (!companyId) return null;
return `${INBOX_COLLAPSED_GROUPS_KEY_PREFIX}:${companyId}`;
}
export function loadInboxFilterPreferences(
companyId: string | null | undefined,
): InboxFilterPreferences {
@ -184,6 +224,36 @@ export function saveInboxFilterPreferences(
}
}
export function loadCollapsedInboxGroupKeys(
companyId: string | null | undefined,
): Set<string> {
const storageKey = getInboxCollapsedGroupsStorageKey(companyId);
if (!storageKey) return new Set();
try {
const raw = localStorage.getItem(storageKey);
if (!raw) return new Set();
const parsed = JSON.parse(raw);
return new Set(normalizeStringArray(parsed));
} catch {
return new Set();
}
}
export function saveCollapsedInboxGroupKeys(
companyId: string | null | undefined,
groupKeys: ReadonlySet<string>,
) {
const storageKey = getInboxCollapsedGroupsStorageKey(companyId);
if (!storageKey) return;
try {
localStorage.setItem(storageKey, JSON.stringify([...groupKeys]));
} catch {
// Ignore localStorage failures.
}
}
export function loadDismissedInboxAlerts(): Set<string> {
try {
const raw = localStorage.getItem(DISMISSED_KEY);
@ -273,7 +343,7 @@ export function saveInboxIssueColumns(columns: InboxIssueColumn[]) {
export function loadInboxWorkItemGroupBy(): InboxWorkItemGroupBy {
try {
const raw = localStorage.getItem(INBOX_GROUP_BY_KEY);
return raw === "type" ? raw : "none";
return raw === "type" || raw === "workspace" ? raw : "none";
} catch {
return "none";
}
@ -287,6 +357,14 @@ export function saveInboxWorkItemGroupBy(groupBy: InboxWorkItemGroupBy) {
}
}
export function shouldResetInboxWorkspaceGrouping(
groupBy: InboxWorkItemGroupBy,
isolatedWorkspacesEnabled: boolean,
experimentalSettingsLoaded: boolean,
): boolean {
return experimentalSettingsLoaded && groupBy === "workspace" && !isolatedWorkspacesEnabled;
}
export function shouldIncludeRoutineExecutionIssue(
issue: Pick<Issue, "originKind">,
showRoutineExecutions: boolean,
@ -307,15 +385,8 @@ export function matchesInboxIssueSearch(
executionWorkspaceById,
projectWorkspaceById,
defaultProjectWorkspaceIdByProjectId,
}: {
}: InboxWorkspaceGroupingOptions & {
isolatedWorkspacesEnabled?: boolean;
executionWorkspaceById?: ReadonlyMap<string, {
name: string;
mode: "shared_workspace" | "isolated_workspace" | "operator_branch" | "adapter_managed" | "cloud_sandbox";
projectWorkspaceId: string | null;
}>;
projectWorkspaceById?: ReadonlyMap<string, { name: string }>;
defaultProjectWorkspaceIdByProjectId?: ReadonlyMap<string, string>;
} = {},
): boolean {
const normalizedQuery = query.trim().toLowerCase();
@ -346,12 +417,8 @@ export function getArchivedInboxSearchIssues({
searchableIssues: Issue[];
query: string;
isolatedWorkspacesEnabled?: boolean;
executionWorkspaceById?: ReadonlyMap<string, {
name: string;
mode: "shared_workspace" | "isolated_workspace" | "operator_branch" | "adapter_managed" | "cloud_sandbox";
projectWorkspaceId: string | null;
}>;
projectWorkspaceById?: ReadonlyMap<string, { name: string }>;
executionWorkspaceById?: ReadonlyMap<string, InboxExecutionWorkspaceLookup>;
projectWorkspaceById?: ReadonlyMap<string, InboxProjectWorkspaceLookup>;
defaultProjectWorkspaceIdByProjectId?: ReadonlyMap<string, string>;
}): Issue[] {
const normalizedQuery = query.trim();
@ -400,21 +467,34 @@ export function getInboxSearchSupplementIssues({
.filter((issue) => !visibleIssueIds.has(issue.id));
}
function formatDefaultWorkspaceGroupLabel(name: string | null | undefined): string {
const normalizedName = name?.trim();
return normalizedName ? `${normalizedName} (default)` : "Default workspace";
}
function resolveDefaultProjectWorkspaceInfo(
issue: Pick<Issue, "projectId">,
{
projectWorkspaceById,
defaultProjectWorkspaceIdByProjectId,
}: Pick<InboxWorkspaceGroupingOptions, "projectWorkspaceById" | "defaultProjectWorkspaceIdByProjectId">,
): { id: string; label: string } | null {
if (!issue.projectId) return null;
const defaultProjectWorkspaceId = defaultProjectWorkspaceIdByProjectId?.get(issue.projectId) ?? null;
if (!defaultProjectWorkspaceId) return null;
return {
id: defaultProjectWorkspaceId,
label: formatDefaultWorkspaceGroupLabel(projectWorkspaceById?.get(defaultProjectWorkspaceId)?.name),
};
}
export function resolveIssueWorkspaceName(
issue: Pick<Issue, "executionWorkspaceId" | "projectId" | "projectWorkspaceId">,
{
executionWorkspaceById,
projectWorkspaceById,
defaultProjectWorkspaceIdByProjectId,
}: {
executionWorkspaceById?: ReadonlyMap<string, {
name: string;
mode: "shared_workspace" | "isolated_workspace" | "operator_branch" | "adapter_managed" | "cloud_sandbox";
projectWorkspaceId: string | null;
}>;
projectWorkspaceById?: ReadonlyMap<string, { name: string }>;
defaultProjectWorkspaceIdByProjectId?: ReadonlyMap<string, string>;
},
}: InboxWorkspaceGroupingOptions,
): string | null {
const defaultProjectWorkspaceId = issue.projectId
? defaultProjectWorkspaceIdByProjectId?.get(issue.projectId) ?? null
@ -441,6 +521,74 @@ export function resolveIssueWorkspaceName(
return null;
}
export function resolveIssueWorkspaceGroup(
issue: Pick<Issue, "executionWorkspaceId" | "projectId" | "projectWorkspaceId">,
{
executionWorkspaceById,
projectWorkspaceById,
defaultProjectWorkspaceIdByProjectId,
}: InboxWorkspaceGroupingOptions = {},
): { key: string; label: string } {
const defaultProjectWorkspace = resolveDefaultProjectWorkspaceInfo(issue, {
projectWorkspaceById,
defaultProjectWorkspaceIdByProjectId,
});
if (issue.executionWorkspaceId) {
const executionWorkspace = executionWorkspaceById?.get(issue.executionWorkspaceId) ?? null;
const linkedProjectWorkspaceId =
executionWorkspace?.projectWorkspaceId ?? issue.projectWorkspaceId ?? null;
const isDefaultSharedExecutionWorkspace =
executionWorkspace?.mode === "shared_workspace"
&& linkedProjectWorkspaceId != null
&& linkedProjectWorkspaceId === defaultProjectWorkspace?.id;
if (isDefaultSharedExecutionWorkspace && defaultProjectWorkspace) {
return {
key: `workspace:project:${defaultProjectWorkspace.id}`,
label: defaultProjectWorkspace.label,
};
}
const workspaceName = executionWorkspace?.name?.trim();
if (workspaceName) {
return {
key: `workspace:execution:${issue.executionWorkspaceId}`,
label: workspaceName,
};
}
}
if (issue.projectWorkspaceId) {
if (issue.projectWorkspaceId === defaultProjectWorkspace?.id) {
return {
key: `workspace:project:${defaultProjectWorkspace.id}`,
label: defaultProjectWorkspace.label,
};
}
const workspaceName = projectWorkspaceById?.get(issue.projectWorkspaceId)?.name?.trim();
if (workspaceName) {
return {
key: `workspace:project:${issue.projectWorkspaceId}`,
label: workspaceName,
};
}
}
if (defaultProjectWorkspace) {
return {
key: `workspace:project:${defaultProjectWorkspace.id}`,
label: defaultProjectWorkspace.label,
};
}
return {
key: "workspace:none",
label: "No workspace",
};
}
export function loadInboxNesting(): boolean {
try {
const raw = localStorage.getItem(INBOX_NESTING_KEY);
@ -642,11 +790,50 @@ const inboxWorkItemKindLabels: Record<InboxWorkItem["kind"], string> = {
export function groupInboxWorkItems(
items: InboxWorkItem[],
groupBy: InboxWorkItemGroupBy,
options: InboxWorkspaceGroupingOptions = {},
): InboxWorkItemGroup[] {
if (groupBy === "none") {
return [{ key: "__all", label: null, items }];
}
if (groupBy === "workspace") {
const groups = new Map<string, { label: string; items: InboxWorkItem[]; latestTimestamp: number }>();
for (const item of items) {
const resolvedGroup = item.kind === "issue"
? resolveIssueWorkspaceGroup(item.issue, options)
: { key: `kind:${item.kind}`, label: inboxWorkItemKindLabels[item.kind] };
const existing = groups.get(resolvedGroup.key);
if (existing) {
existing.items.push(item);
existing.latestTimestamp = Math.max(existing.latestTimestamp, item.timestamp);
} else {
groups.set(resolvedGroup.key, {
label: resolvedGroup.label,
items: [item],
latestTimestamp: item.timestamp,
});
}
}
return [...groups.entries()]
.map(([key, value]) => ({
key,
label: value.label,
items: value.items,
latestTimestamp: value.latestTimestamp,
}))
.sort((a, b) => {
const timestampDiff = b.latestTimestamp - a.latestTimestamp;
if (timestampDiff !== 0) return timestampDiff;
return a.label.localeCompare(b.label);
})
.map(({ key, label, items: groupItems }) => ({
key,
label,
items: groupItems,
}));
}
const groups = new Map<InboxWorkItem["kind"], InboxWorkItem[]>();
for (const item of items) {
const existing = groups.get(item.kind) ?? [];
@ -729,6 +916,48 @@ export function buildInboxNesting(items: InboxWorkItem[]): {
return { displayItems, childrenByIssueId };
}
export function getInboxWorkItemKey(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}`;
}
export function buildInboxKeyboardNavEntries(
groupedSections: ReadonlyArray<InboxKeyboardGroupSection>,
collapsedGroupKeys: ReadonlySet<string>,
collapsedInboxParents: ReadonlySet<string>,
): InboxKeyboardNavEntry[] {
const entries: InboxKeyboardNavEntry[] = [];
for (const group of groupedSections) {
if (collapsedGroupKeys.has(group.key)) continue;
for (const item of group.displayItems) {
entries.push({
type: "top",
itemKey: `${group.key}:${getInboxWorkItemKey(item)}`,
item,
});
if (item.kind !== "issue") continue;
const children = group.childrenByIssueId.get(item.issue.id);
if (!children?.length || collapsedInboxParents.has(item.issue.id)) continue;
for (const child of children) {
entries.push({
type: "child",
issueId: child.id,
issue: child,
});
}
}
}
return entries;
}
export function shouldShowInboxSection({
tab,
hasItems,

View file

@ -8,6 +8,7 @@ import {
isKeyboardShortcutTextInputTarget,
resolveIssueDetailGoKeyAction,
resolveInboxQuickArchiveKeyAction,
resolveInboxUndoArchiveKeyAction,
shouldBlurPageSearchOnEnter,
shouldBlurPageSearchOnEscape,
} from "./keyboardShortcuts";
@ -181,6 +182,36 @@ describe("keyboardShortcuts helpers", () => {
})).toBe("ignore");
});
it("undoes only a clean lowercase u press when an archive is available", () => {
const button = document.createElement("button");
expect(resolveInboxUndoArchiveKeyAction({
hasUndoableArchive: true,
defaultPrevented: false,
key: "u",
metaKey: false,
ctrlKey: false,
altKey: false,
target: button,
hasOpenDialog: false,
})).toBe("undo_archive");
});
it("keeps uppercase U available for mark-unread handling", () => {
const button = document.createElement("button");
expect(resolveInboxUndoArchiveKeyAction({
hasUndoableArchive: true,
defaultPrevented: false,
key: "U",
metaKey: false,
ctrlKey: false,
altKey: false,
target: button,
hasOpenDialog: false,
})).toBe("ignore");
});
it("arms go-to-inbox on a clean g press", () => {
const button = document.createElement("button");

View file

@ -12,6 +12,7 @@ const PAGE_SEARCH_SHORTCUT_SELECTOR = "[data-page-search-target='true']";
const MODIFIER_ONLY_KEYS = new Set(["Shift", "Meta", "Control", "Alt"]);
export type InboxQuickArchiveKeyAction = "ignore" | "archive" | "disarm";
export type InboxUndoArchiveKeyAction = "ignore" | "undo_archive";
export type IssueDetailGoKeyAction = "ignore" | "arm" | "navigate_inbox" | "focus_comment" | "disarm";
export function isKeyboardShortcutTextInputTarget(target: EventTarget | null): boolean {
@ -105,6 +106,33 @@ export function resolveInboxQuickArchiveKeyAction({
return "ignore";
}
export function resolveInboxUndoArchiveKeyAction({
hasUndoableArchive,
defaultPrevented,
key,
metaKey,
ctrlKey,
altKey,
target,
hasOpenDialog,
}: {
hasUndoableArchive: boolean;
defaultPrevented: boolean;
key: string;
metaKey: boolean;
ctrlKey: boolean;
altKey: boolean;
target: EventTarget | null;
hasOpenDialog: boolean;
}): InboxUndoArchiveKeyAction {
if (!hasUndoableArchive) return "ignore";
if (defaultPrevented) return "ignore";
if (metaKey || ctrlKey || altKey || isModifierOnlyKey(key)) return "ignore";
if (hasOpenDialog || isKeyboardShortcutTextInputTarget(target)) return "ignore";
if (key === "u") return "undo_archive";
return "ignore";
}
export function resolveIssueDetailGoKeyAction({
armed,
defaultPrevented,

View file

@ -95,6 +95,11 @@ export const queryKeys = {
auth: {
session: ["auth", "session"] as const,
},
sidebarPreferences: {
companyOrder: (userId: string) => ["sidebar-preferences", "company-order", userId] as const,
projectOrder: (companyId: string, userId: string) =>
["sidebar-preferences", "project-order", companyId, userId] as const,
},
instance: {
generalSettings: ["instance", "general-settings"] as const,
schedulerHeartbeats: ["instance", "scheduler-heartbeats"] as const,