[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

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