Files
PaperClipAI/ui/src/components/RequestCollapsedSidebar.test.tsx
T
DottaandPaperclip 518fc71cec [codex] Add work timeline page (#8938)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - The board UI is where operators inspect company activity, agent
work, and issue progress.
> - Existing board views show individual issue and run details, but they
do not give operators a compact time-based picture of work across
agents.
> - A work timeline helps operators scan when agents worked, how
handoffs happened, and where overlapping work occurred.
> - This pull request adds a company-scoped Work Timeline page backed by
the existing API surface and renders the timeline as a custom SVG
Gantt-style view.
> - The benefit is faster operator understanding of multi-agent
execution without opening each issue thread individually.

## Linked Issues or Issue Description

No public GitHub issue exists for this change.

Subsystem affected:
- ui/ — React + Vite board UI

Problem or motivation:
- Operators can inspect individual issues and runs, but there is no
compact time-based view of company work across agents.
- This makes it harder to scan overlaps, handoffs, retries, and activity
windows without opening many issue threads.

Proposed solution:
- Add a company-scoped Work Timeline page in the board UI.
- Render agent and system run spans as a custom SVG Gantt-style chart
with packed overlap lanes.
- Show issue color identity, kickoff attribution, hover-revealed
delegation connectors, retry styling, zoom controls, a sticky actor
gutter, and a minimap brush.
- Keep human activity lightweight by showing human kickoff chips without
plotting standalone human event rows.

Alternatives considered:
- Add the same information to existing issue-list or run-list views.
That would preserve simpler UI, but it would not show temporal overlap
or handoff paths clearly.
- Build this as a plugin-only surface. That keeps core smaller, but the
board already has the company-scoped route, navigation, and API client
patterns needed for this operator workflow.

Roadmap alignment:
- `ROADMAP.md` does not list an existing duplicate work-timeline
milestone. This supports the broader operator visibility direction
around artifacts, enforced outcomes, and higher-autonomy execution.

Additional context:
- Storybook includes `Pages/Work Timeline` stories for hour/day zoom and
a human-activity sample so reviewers can inspect the component without a
live backend.

## What Changed

- Added the Work Timeline page, route, sidebar entry, API client, query
key, and company-prefixed route helper coverage.
- Added a pure timeline layout transform for row packing, issue colors,
kickoff attribution, connector calculation, tick selection, and duration
formatting.
- Added the custom SVG timeline chart with sticky actor gutter,
hover-revealed connectors, zoom controls, minimap brushing,
visible-range feedback, and issue navigation.
- Added Storybook coverage plus sample fixtures for the work timeline.
- Added and corrected focused UI tests covering layout, chart behavior,
routing, sidebar behavior, and collapsed-sidebar expectations.
- Addressed Greptile feedback for kickoff fallback ordering, minimap
range math, document drag listener cleanup, and stable default `now`
handling.

## Verification

- `pnpm --filter @paperclipai/ui exec vitest run
src/lib/timeline/layout.test.ts
src/components/timeline/WorkTimelineChart.test.tsx
src/pages/Timeline.test.tsx src/lib/company-routes.test.ts
src/components/Sidebar.test.tsx
src/components/RequestCollapsedSidebar.test.tsx`
- `pnpm --filter @paperclipai/ui typecheck`
- `git merge-tree $(git merge-base HEAD origin/master) HEAD
origin/master | rg -n "<<<<<<<|changed in both|CONFLICT"` returned no
conflicts.
- Attempted Storybook screenshot capture with Playwright; Storybook ran
locally, but Chromium could not launch in this container because native
browser libraries such as `libatk-1.0.so.0` are unavailable and `npx
playwright install-deps chromium` requires interactive sudo.

## Risks

- Medium UI risk: this adds a substantial visual surface with custom SVG
interaction logic, so browser-level review is still useful for
responsive behavior and usability.
- Low backend risk: this PR only adds a UI client/page around the
existing timeline API contract and does not change database schema or
server routes.

> 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 GPT-5 via Codex coding agent, with repository file access, shell
command execution, GitHub connector access, and focused test execution.
Exact context-window details are not exposed in this runtime.

## 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-07-06 07:57:23 -05:00

124 lines
4.1 KiB
TypeScript

// @vitest-environment jsdom
import { flushSync } from "react-dom";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { SidebarProvider, useSidebar } from "../context/SidebarContext";
import { RequestCollapsedSidebar } from "./RequestCollapsedSidebar";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
const COLLAPSED_STORAGE_KEY = "paperclip.sidebar.collapsed";
let capturedValue: ReturnType<typeof useSidebar> | null = null;
function Capture() {
capturedValue = useSidebar();
return null;
}
// A tiny stand-in for "a route that brings its own sidebar". When `onRoute`
// is true it mounts <RequestCollapsedSidebar/>; flipping it to false models
// navigating away to a route that does not request a collapse.
function Harness({ onRoute }: { onRoute: boolean }) {
return (
<SidebarProvider>
<Capture />
{onRoute ? <RequestCollapsedSidebar /> : null}
</SidebarProvider>
);
}
async function flushReact() {
await Promise.resolve();
await new Promise((resolve) => window.setTimeout(resolve, 0));
flushSync(() => {});
}
async function render(onRoute: boolean): Promise<{ root: Root; host: HTMLDivElement }> {
const host = document.createElement("div");
document.body.appendChild(host);
const root = createRoot(host);
flushSync(() => root.render(<Harness onRoute={onRoute} />));
await flushReact();
return { root, host };
}
describe("RequestCollapsedSidebar", () => {
let active: { root: Root; host: HTMLDivElement } | null = null;
beforeEach(() => {
localStorage.clear();
capturedValue = null;
Object.defineProperty(window, "innerWidth", {
configurable: true,
writable: true,
value: 1280, // desktop: collapsed/peek are meaningful
});
Object.defineProperty(window, "matchMedia", {
configurable: true,
writable: true,
value: vi.fn().mockImplementation((query: string) => ({
// Desktop, hover-capable pointer.
matches: query.includes("(hover: hover)"),
media: query,
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
});
afterEach(() => {
if (active) {
flushSync(() => active!.root.unmount());
active.host.remove();
active = null;
}
localStorage.clear();
});
it("requests collapsed while mounted when there is no user pin", async () => {
active = await render(true);
expect(capturedValue?.routeRequestsCollapsed).toBe(true);
expect(capturedValue?.collapsed).toBe(true);
});
it("lets an explicit user pin override the route request", async () => {
active = await render(true);
expect(capturedValue?.collapsed).toBe(true);
// User explicitly pins expanded — must win over the route's request.
flushSync(() => capturedValue?.setCollapsed(false));
expect(capturedValue?.routeRequestsCollapsed).toBe(true);
expect(capturedValue?.collapsed).toBe(false);
});
it("clears the request on unmount, restoring the global default", async () => {
active = await render(true);
expect(capturedValue?.collapsed).toBe(true);
// Navigate away: the route (and its <RequestCollapsedSidebar/>) unmounts.
flushSync(() => active!.root.render(<Harness onRoute={false} />));
await flushReact();
expect(capturedValue?.routeRequestsCollapsed).toBe(false);
expect(capturedValue?.collapsed).toBe(false);
});
it("keeps a user pin after navigating away (pin persists, request cleared)", async () => {
active = await render(true);
flushSync(() => capturedValue?.setCollapsed(true));
expect(localStorage.getItem(COLLAPSED_STORAGE_KEY)).toBe("1");
flushSync(() => active!.root.render(<Harness onRoute={false} />));
await flushReact();
// Route request gone, but the explicit collapsed pin still applies.
expect(capturedValue?.routeRequestsCollapsed).toBe(false);
expect(capturedValue?.collapsed).toBe(true);
});
});