Files
PaperClipAI/ui/src/lib/issueDetailBreadcrumb.ts
T
DottaandPaperclip 569b7affc4 [codex] Add bounded workspace overview (#8627)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - The execution workspace subsystem powers project and workspace views
by listing runtime state, branch metadata, issue links, and status
summaries.
> - The existing workspace views relied on broad list data that can grow
expensive as a company accumulates many workspaces and linked issues.
> - That makes the Workspaces page and project workspace cards slower
than necessary because the UI does not always need the full workspace
detail payload up front.
> - This pull request adds a bounded overview contract for workspace
listings and moves the relevant UI surfaces to that cheaper path.
> - The benefit is faster workspace loading while preserving detail
fetches for pages that actually need full workspace data.

## Linked Issues or Issue Description

No public GitHub issue exists for this change. Inline bug report follows
the repository bug template.

### What happened?

Workspace index-style screens can load too much execution workspace
detail before the user asks for it. Several UI surfaces used fuller
workspace data paths for summary displays, which can make workspace
loading slower as workspace history grows.

### Expected behavior

Overview screens should request a bounded summary payload, while detail
screens should keep using the full workspace detail endpoint.

### Steps to reproduce

1. Run Paperclip from source with enough execution workspace history to
make workspace lists non-trivial.
2. Open the Workspaces page or a project workspace summary card.
3. Observe that summary UI needs only bounded workspace metadata but can
depend on broader workspace payloads.

### Paperclip version or commit

`master` at the time this branch was prepared.

### Deployment mode

Local dev (`pnpm dev`)

## What Changed

- Added shared types, validators, and path constants for bounded
execution workspace overviews.
- Added server service and route support for overview queries with
bounded linked issue/runtime metadata.
- Updated workspace overview UI API calls, query keys, breadcrumbs,
quicklooks, close dialogs, project summaries, and detail links to
consume the cheaper overview shape where appropriate.
- Added regression coverage for the new server route/service behavior
and the UI overview consumers.
- Registered the new workspace overview route in the generated OpenAPI
spec.
- Kept overview totals aligned with the project join and preserved
project slug links in workspace headers.

## Verification

- `pnpm exec vitest run
server/src/__tests__/execution-workspaces-service.test.ts
server/src/__tests__/execution-workspaces-routes.test.ts
ui/src/api/execution-workspaces.test.ts
ui/src/components/ProjectWorkspaceSummaryCard.test.tsx
ui/src/pages/Workspaces.test.tsx`
- `pnpm --filter @paperclipai/shared typecheck && pnpm --filter
@paperclipai/server typecheck && pnpm --filter @paperclipai/ui
typecheck`
- `pnpm exec vitest run server/src/__tests__/openapi-routes.test.ts
server/src/__tests__/execution-workspaces-routes.test.ts
server/src/__tests__/execution-workspaces-service.test.ts`
- `pnpm test:run:serialized -- --shard-index 1 --shard-count 4`

## Risks

Low to medium risk. The change introduces a new overview contract across
shared/server/ui layers, so the main risk is a mismatch between summary
and detail payload expectations. The added route/service/UI tests cover
the intended split, and full detail pages continue using the detail
path.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

OpenAI Codex, GPT-5-based coding agent with repository tool use and
local command execution. Exact served model identifier and context
window were not exposed in this session.

## 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)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-06-25 21:38:14 -05:00

273 lines
9.5 KiB
TypeScript

import type { Issue } from "@paperclipai/shared";
type IssueDetailSource = "issues" | "inbox";
type IssueDetailBreadcrumb = {
label: string;
href: string;
};
export type IssueDetailHeaderSeed = {
id: string;
identifier: string | null;
title: string;
status: string;
blockerAttention?: Issue["blockerAttention"];
priority: string;
projectId: string | null;
projectName: string | null;
originKind?: string;
originId?: string | null;
};
type IssueDetailHeaderSeedSource = Pick<Issue, "id" | "title"> & {
identifier?: string | null;
status: string;
blockerAttention?: Issue["blockerAttention"];
priority: string;
projectId?: string | null;
project?: { name?: string | null } | null;
originKind?: string;
originId?: string | null;
};
type IssueDetailLocationState = {
issueDetailBreadcrumb?: IssueDetailBreadcrumb;
issueDetailSource?: IssueDetailSource;
issueDetailInboxQuickArchiveArmed?: boolean;
issueDetailHeaderSeed?: IssueDetailHeaderSeed;
};
const ISSUE_DETAIL_SOURCE_QUERY_PARAM = "from";
const ISSUE_DETAIL_BREADCRUMB_HREF_QUERY_PARAM = "fromHref";
const ISSUE_DETAIL_STORAGE_KEY_PREFIX = "paperclip:issue-detail-breadcrumb:";
function isIssueDetailBreadcrumb(value: unknown): value is IssueDetailBreadcrumb {
if (typeof value !== "object" || value === null) return false;
const candidate = value as Partial<IssueDetailBreadcrumb>;
return typeof candidate.label === "string" && typeof candidate.href === "string";
}
function isIssueDetailSource(value: unknown): value is IssueDetailSource {
return value === "issues" || value === "inbox";
}
function isIssueDetailHeaderSeed(value: unknown): value is IssueDetailHeaderSeed {
if (typeof value !== "object" || value === null) return false;
const candidate = value as Partial<IssueDetailHeaderSeed>;
const hasOriginKind =
candidate.originKind === undefined || typeof candidate.originKind === "string";
const hasOriginId =
candidate.originId === undefined || candidate.originId === null || typeof candidate.originId === "string";
const hasBlockerAttention =
candidate.blockerAttention === undefined
|| (typeof candidate.blockerAttention === "object" && candidate.blockerAttention !== null);
return (
typeof candidate.id === "string"
&& (candidate.identifier === null || typeof candidate.identifier === "string")
&& typeof candidate.title === "string"
&& typeof candidate.status === "string"
&& hasBlockerAttention
&& typeof candidate.priority === "string"
&& (candidate.projectId === null || typeof candidate.projectId === "string")
&& (candidate.projectName === null || typeof candidate.projectName === "string")
&& hasOriginKind
&& hasOriginId
);
}
function createIssueDetailHeaderSeed(issue: IssueDetailHeaderSeedSource): IssueDetailHeaderSeed {
return {
id: issue.id,
identifier: issue.identifier ?? null,
title: issue.title,
status: issue.status,
blockerAttention: issue.blockerAttention,
priority: issue.priority,
projectId: issue.projectId ?? null,
projectName: issue.project?.name ?? null,
originKind: issue.originKind,
originId: issue.originId ?? null,
};
}
export function withIssueDetailHeaderSeed(state: unknown, issue: IssueDetailHeaderSeedSource): IssueDetailLocationState {
const headerSeed = createIssueDetailHeaderSeed(issue);
if (typeof state !== "object" || state === null) {
return { issueDetailHeaderSeed: headerSeed };
}
return {
...(state as IssueDetailLocationState),
issueDetailHeaderSeed: headerSeed,
};
}
export function readIssueDetailHeaderSeed(state: unknown): IssueDetailHeaderSeed | null {
if (typeof state !== "object" || state === null) return null;
const candidate = (state as IssueDetailLocationState).issueDetailHeaderSeed;
return isIssueDetailHeaderSeed(candidate) ? candidate : null;
}
function readIssueDetailSource(state: unknown): IssueDetailSource | null {
if (typeof state !== "object" || state === null) return null;
const source = (state as IssueDetailLocationState).issueDetailSource;
return isIssueDetailSource(source) ? source : null;
}
function readIssueDetailSourceFromSearch(search?: string): IssueDetailSource | null {
if (!search) return null;
const params = new URLSearchParams(search);
const source = params.get(ISSUE_DETAIL_SOURCE_QUERY_PARAM);
return isIssueDetailSource(source) ? source : null;
}
function readIssueDetailBreadcrumbHrefFromSearch(search?: string): string | null {
if (!search) return null;
const params = new URLSearchParams(search);
const href = params.get(ISSUE_DETAIL_BREADCRUMB_HREF_QUERY_PARAM);
return href && href.startsWith("/") ? href : null;
}
function inferIssueDetailSource(
state: Partial<IssueDetailLocationState> | null,
breadcrumb: IssueDetailBreadcrumb | null,
): IssueDetailSource | null {
if (isIssueDetailSource(state?.issueDetailSource)) return state.issueDetailSource;
if (!breadcrumb) return null;
if (breadcrumb.label === "Inbox" || breadcrumb.href.includes("/inbox")) return "inbox";
if (breadcrumb.label === "Tasks" || breadcrumb.href.includes("/issues")) return "issues";
return null;
}
function breadcrumbForSource(source: IssueDetailSource): IssueDetailBreadcrumb {
if (source === "inbox") return { label: "Inbox", href: "/inbox" };
return { label: "Tasks", href: "/issues" };
}
export function createIssueDetailLocationState(
label: string,
href: string,
source?: IssueDetailSource,
): IssueDetailLocationState {
return {
issueDetailBreadcrumb: { label, href },
issueDetailSource: source,
};
}
export function armIssueDetailInboxQuickArchive(state: unknown): IssueDetailLocationState {
if (typeof state !== "object" || state === null) {
return { issueDetailInboxQuickArchiveArmed: true };
}
return {
...(state as IssueDetailLocationState),
issueDetailInboxQuickArchiveArmed: true,
};
}
function readStoredIssueDetailLocationState(issuePathId: string): IssueDetailLocationState | null {
if (typeof window === "undefined" || !window.sessionStorage) return null;
const raw = window.sessionStorage.getItem(`${ISSUE_DETAIL_STORAGE_KEY_PREFIX}${issuePathId}`);
if (!raw) return null;
try {
const parsed = JSON.parse(raw) as Partial<IssueDetailLocationState>;
const breadcrumb = isIssueDetailBreadcrumb(parsed.issueDetailBreadcrumb)
? parsed.issueDetailBreadcrumb
: null;
const source = inferIssueDetailSource(parsed, breadcrumb);
if (!breadcrumb || !source) return null;
const headerSeed = isIssueDetailHeaderSeed(parsed.issueDetailHeaderSeed)
? parsed.issueDetailHeaderSeed
: undefined;
return {
issueDetailBreadcrumb: breadcrumb,
issueDetailSource: source,
issueDetailInboxQuickArchiveArmed: parsed.issueDetailInboxQuickArchiveArmed === true,
issueDetailHeaderSeed: headerSeed,
};
} catch {
return null;
}
}
function normalizeIssueDetailLocationState(
state: unknown,
search?: string,
): IssueDetailLocationState | null {
if (typeof state === "object" && state !== null) {
const candidate = (state as IssueDetailLocationState).issueDetailBreadcrumb;
if (isIssueDetailBreadcrumb(candidate)) {
const source = inferIssueDetailSource(state as Partial<IssueDetailLocationState>, candidate);
if (!source) return null;
const headerSeed = readIssueDetailHeaderSeed(state) ?? undefined;
return {
issueDetailBreadcrumb: candidate,
issueDetailSource: source,
issueDetailInboxQuickArchiveArmed:
(state as IssueDetailLocationState).issueDetailInboxQuickArchiveArmed === true,
issueDetailHeaderSeed: headerSeed,
};
}
}
const source = readIssueDetailSourceFromSearch(search);
const href = readIssueDetailBreadcrumbHrefFromSearch(search);
if (!source) return null;
return {
issueDetailBreadcrumb: href ? { ...breadcrumbForSource(source), href } : breadcrumbForSource(source),
issueDetailSource: source,
issueDetailInboxQuickArchiveArmed: false,
};
}
export function rememberIssueDetailLocationState(issuePathId: string, state: unknown, search?: string): void {
if (typeof window === "undefined" || !window.sessionStorage) return;
const normalized = normalizeIssueDetailLocationState(state, search);
if (!normalized) return;
window.sessionStorage.setItem(
`${ISSUE_DETAIL_STORAGE_KEY_PREFIX}${issuePathId}`,
JSON.stringify(normalized),
);
}
export function createIssueDetailPath(issuePathId: string): string {
return `/issues/${issuePathId}`;
}
export function hasLegacyIssueDetailQuery(search?: string): boolean {
if (!search) return false;
const params = new URLSearchParams(search);
return params.has(ISSUE_DETAIL_SOURCE_QUERY_PARAM) || params.has(ISSUE_DETAIL_BREADCRUMB_HREF_QUERY_PARAM);
}
export function readIssueDetailLocationState(
issuePathId: string | null | undefined,
state: unknown,
search?: string,
): IssueDetailLocationState | null {
const normalized = normalizeIssueDetailLocationState(state, search);
if (normalized) return normalized;
if (!issuePathId) return null;
return readStoredIssueDetailLocationState(issuePathId);
}
export function readIssueDetailBreadcrumb(
issuePathId: string | null | undefined,
state: unknown,
search?: string,
): IssueDetailBreadcrumb | null {
return readIssueDetailLocationState(issuePathId, state, search)?.issueDetailBreadcrumb ?? null;
}
export function shouldArmIssueDetailInboxQuickArchive(state: unknown): boolean {
if (typeof state !== "object" || state === null) return false;
return (state as IssueDetailLocationState).issueDetailInboxQuickArchiveArmed === true;
}