[codex] Improve work timeline activity story (#9222)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - The work timeline helps operators understand when agent and user
activity actually happened across a project.
> - The timeline view needs clearer interaction context so activity is
easier to inspect and reason about.
> - The existing story coverage did not fully exercise the denser
activity states needed to review this UI safely.
> - This pull request expands the work timeline data shape, service
behavior, UI rendering, tests, and Storybook story so the activity
timeline is easier to verify.
> - The benefit is a more inspectable timeline for project activity,
backed by targeted server and UI coverage.

## Linked Issues or Issue Description

No public GitHub issue found, so this PR describes the feature inline
following the feature request template.

**Subsystem affected**

Cross-cutting: `server/`, `packages/shared`, and `ui/`.

**Problem or motivation**

Project operators need a clearer timeline view that shows when work
activity happened, how much agent time is represented inside the
selected window, and enough realistic activity states for safe visual
review. Sparse mock data and unbounded summary calculations make it
harder to trust the timeline when inspecting historical or capped
windows.

**Proposed solution**

Enrich the work timeline activity data returned by the service, render
clearer top-level timeline summary stats, clamp duration calculations to
the returned window, prorate token totals for partially visible spans,
and add Storybook/test coverage with realistic timeline activity data.

**Alternatives considered**

Keeping the existing sparse timeline story was considered, but it would
leave dense activity layouts and selected-window summary behavior
under-reviewed. Counting full span usage for partially visible spans was
also considered, but it makes historical windows report activity outside
the displayed range.

**Roadmap alignment**

Searched `ROADMAP.md` for timeline/activity references and found no
conflicting planned core work.

**Additional context**

This PR does not include migrations and does not commit generated design
screenshots or images.

## What Changed

- Extended shared work timeline activity types and server timeline
service behavior.
- Updated the timeline page and work timeline chart for richer activity
rendering.
- Clamped timeline runtime summary calculations to the returned window
and prorated summary token usage for clipped spans.
- Added and updated targeted server/UI tests for timeline activity
behavior.
- Added Storybook timeline mock coverage and Storybook preview setup
needed by the story.

## Verification

- `git rebase origin/master` completed cleanly after fetching
`paperclipai/paperclip:master`.
- `git diff --check origin/master...HEAD`
- `pnpm exec vitest run
server/src/__tests__/work-timeline-service.test.ts
ui/src/components/timeline/WorkTimelineChart.test.tsx
ui/src/pages/Timeline.test.tsx` — latest run: 3 files passed, 28 tests
passed.
- Greptile review completed at 5/5 with no unresolved Greptile threads
after fixes.
- GitHub checks completed green on the latest head SHA; Storybook visual
regression was skipped by the workflow.
- `pnpm check:token-gates` currently fails locally on existing
`origin/master` violations in `ui/src/components/ActivityCharts.tsx` and
`ui/src/components/IssueRecoveryActionCard.tsx`; this PR does not modify
those files.

## Risks

Low to moderate risk. The change affects the work timeline service
response shape and timeline UI rendering, so regressions would likely
show up as missing/incorrect timeline activity display. Targeted service
and UI tests cover the changed behavior. No migrations are included.

## Model Used

OpenAI Codex running GPT-5 as a tool-enabled coding agent with local
shell and GitHub CLI access. Exact runtime model ID/context-window size
was not exposed by the environment.

## 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>
This commit is contained in:
Dotta 2026-07-08 08:59:24 -05:00 committed by GitHub
parent d3919713bc
commit 562567fcd6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 559 additions and 90 deletions

View File

@ -33,6 +33,12 @@ export interface WorkTimelineSpan {
retryOfRunId?: string | null;
continuationAttempt?: number;
invocationSource?: string | null;
usage?: {
inputTokens: number;
cachedInputTokens: number;
outputTokens: number;
totalTokens: number;
} | null;
}
export interface WorkTimelineEvent {

View File

@ -175,6 +175,7 @@ describeEmbeddedPostgres("work timeline aggregation", () => {
invocationSource: "issue_assigned",
startedAt: new Date("2026-03-01T12:00:00Z"),
finishedAt: null,
usageJson: { inputTokens: 120, cachedInputTokens: 30, outputTokens: 50 },
contextSnapshot: { issueId: childIssueId },
},
{
@ -237,7 +238,13 @@ describeEmbeddedPostgres("work timeline aggregation", () => {
expect(result.actors.map((actor) => actor.name)).toEqual(expect.arrayContaining(["Coder", "QA", "User One"]));
expect(result.spans).toEqual(expect.arrayContaining([
expect.objectContaining({ runId: contextRunId, issueId: childIssueId, end: null, status: "running" }),
expect.objectContaining({
runId: contextRunId,
issueId: childIssueId,
end: null,
status: "running",
usage: { inputTokens: 120, cachedInputTokens: 30, outputTokens: 50, totalTokens: 200 },
}),
expect.objectContaining({ runId: activityRunId, issueId: parentIssueId, status: "completed" }),
]));
expect(result.events.map((event) => event.kind)).toEqual(expect.arrayContaining([

View File

@ -73,6 +73,8 @@ type IssueRow = {
createdAt: Date;
};
type RunUsage = NonNullable<WorkTimelineSpan["usage"]>;
const DEFAULT_LIMIT = 200;
const MAX_LIMIT = 500;
const MAX_WINDOW_MS = 31 * 24 * 60 * 60 * 1000;
@ -119,6 +121,39 @@ function readString(value: unknown) {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
function readNumber(value: unknown) {
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value === "string" && value.trim() !== "") {
const parsed = Number(value);
if (Number.isFinite(parsed)) return parsed;
}
return null;
}
function readUsageToken(source: Record<string, unknown>, ...keys: string[]) {
for (const key of keys) {
const value = readNumber(source[key]);
if (value != null) return Math.max(0, Math.floor(value));
}
return 0;
}
function normalizeRunUsage(usageJson: unknown): RunUsage | null {
if (!usageJson || typeof usageJson !== "object" || Array.isArray(usageJson)) return null;
const source = usageJson as Record<string, unknown>;
const inputTokens = readUsageToken(source, "inputTokens", "input_tokens", "rawInputTokens", "raw_input_tokens");
const cachedInputTokens = readUsageToken(
source,
"cachedInputTokens",
"cached_input_tokens",
"cacheReadInputTokens",
"cache_read_input_tokens",
);
const outputTokens = readUsageToken(source, "outputTokens", "output_tokens", "rawOutputTokens", "raw_output_tokens");
const totalTokens = inputTokens + cachedInputTokens + outputTokens;
return totalTokens > 0 ? { inputTokens, cachedInputTokens, outputTokens, totalTokens } : null;
}
function maybeUuidList(ids: Iterable<string>) {
return Array.from(new Set(Array.from(ids).filter((id) => id.length > 0)));
}
@ -508,6 +543,7 @@ export function workTimelineService(db: Db) {
retryOfRunId: heartbeatRuns.retryOfRunId,
continuationAttempt: heartbeatRuns.continuationAttempt,
invocationSource: heartbeatRuns.invocationSource,
usageJson: heartbeatRuns.usageJson,
})
.from(heartbeatRuns)
.where(
@ -529,6 +565,7 @@ export function workTimelineService(db: Db) {
retryOfRunId: heartbeatRuns.retryOfRunId,
continuationAttempt: heartbeatRuns.continuationAttempt,
invocationSource: heartbeatRuns.invocationSource,
usageJson: heartbeatRuns.usageJson,
})
.from(activityLog)
.innerJoin(heartbeatRuns, eq(activityLog.runId, heartbeatRuns.id))
@ -639,6 +676,7 @@ export function workTimelineService(db: Db) {
retryOfRunId: row.retryOfRunId ?? null,
continuationAttempt: row.continuationAttempt,
invocationSource: row.invocationSource ?? null,
usage: normalizeRunUsage(row.usageJson),
});
}

View File

@ -139,6 +139,8 @@ describe("WorkTimelineChart", () => {
expect(gutter?.getAttribute("width")).toBe("176");
expect(chartSvg?.getAttribute("width")).not.toBe(gutter?.getAttribute("width"));
expect(gutter?.textContent).toContain("CodexCoder");
expect(gutter?.textContent).not.toContain("agent");
expect(gutter?.textContent).not.toContain("×");
flushSync(() => {
scroller!.scrollLeft = 10_000;
@ -323,7 +325,7 @@ describe("WorkTimelineChart", () => {
const onZoomScaleChange = vi.fn();
renderChart(timelineSample(), { onZoomScaleChange });
const rightHandle = container.querySelector<SVGRectElement>("[data-testid='timeline-minimap-right-handle']")!;
const rightHandle = container.querySelector<SVGGElement>("[data-testid='timeline-minimap-right-handle']")!;
const minimap = rightHandle.ownerSVGElement!;
vi.spyOn(minimap, "getBoundingClientRect").mockReturnValue({
x: 0,
@ -346,6 +348,18 @@ describe("WorkTimelineChart", () => {
expect(onZoomScaleChange).toHaveBeenCalled();
});
it("shows grab-handle affordances on minimap selection edges", () => {
renderChart(timelineSample(), { onZoomScaleChange: vi.fn() });
const leftHandle = container.querySelector<SVGGElement>("[data-testid='timeline-minimap-left-handle']")!;
const rightHandle = container.querySelector<SVGGElement>("[data-testid='timeline-minimap-right-handle']")!;
expect(leftHandle.getAttribute("class")).toContain("cursor-grab");
expect(rightHandle.getAttribute("class")).toContain("cursor-grab");
expect(leftHandle.querySelectorAll("line")).toHaveLength(3);
expect(leftHandle.textContent).toContain("Drag left edge");
});
it("cleans up chart drag listeners when unmounted mid-drag", () => {
const add = vi.spyOn(document, "addEventListener");
const remove = vi.spyOn(document, "removeEventListener");
@ -383,7 +397,7 @@ describe("WorkTimelineChart", () => {
const remove = vi.spyOn(document, "removeEventListener");
renderChart(timelineSample(), { onZoomScaleChange: vi.fn() });
const rightHandle = container.querySelector<SVGRectElement>("[data-testid='timeline-minimap-right-handle']")!;
const rightHandle = container.querySelector<SVGGElement>("[data-testid='timeline-minimap-right-handle']")!;
const minimap = rightHandle.ownerSVGElement!;
vi.spyOn(minimap, "getBoundingClientRect").mockReturnValue({
x: 0,

View File

@ -533,12 +533,9 @@ export function WorkTimelineChart({
return (
<g key={`row-${row.actor.id}`}>
<ActorGlyph actor={row.actor} cx={26} cy={cy} r={AVATAR_R} clipId={actorGlyphId} />
<text x={26 + AVATAR_R + 10} y={cy - 2} fontSize={13} fill="var(--color-foreground)">
<text x={26 + AVATAR_R + 10} y={cy + 4} fontSize={13} fill="var(--color-foreground)">
{truncate(row.actor.name, 18)}
</text>
<text x={26 + AVATAR_R + 10} y={cy + 12} fontSize={11} fill="var(--color-muted-foreground)">
{row.actor.type}
</text>
{Array.from({ length: row.laneCount }).map((_, ln) => {
const ly = row.y + AXIS_H + 6 + ln * (GEOM.barH + GEOM.laneGap) + GEOM.barH / 2;
@ -682,23 +679,9 @@ function ActorGutter({ rows, height }: { rows: ReturnType<typeof computeLayout>[
opacity={i % 2 ? 0.35 : 1}
/>
<ActorGlyph actor={row.actor} cx={26} cy={cy} r={AVATAR_R} clipId={actorGlyphId} />
<text x={26 + AVATAR_R + 10} y={cy - 2} fontSize={13} fill="var(--color-foreground)">
<text x={26 + AVATAR_R + 10} y={cy + 4} fontSize={13} fill="var(--color-foreground)">
{truncate(row.actor.name, 16)}
</text>
<text x={26 + AVATAR_R + 10} y={cy + 12} fontSize={11} fill="var(--color-muted-foreground)">
{row.actor.type}
</text>
{/* "Signal" rail: run count + active time, right-aligned in the gutter. */}
<text
x={GEOM.gutter - 10}
y={cy + 11}
fontSize={10.5}
textAnchor="end"
fill="var(--color-muted-foreground)"
style={{ fontVariantNumeric: "tabular-nums" }}
>
{row.runCount}× · {formatDuration(0, row.activeMs)}
</text>
</g>
);
})}
@ -822,6 +805,7 @@ function MiniMap({
const visibleEndMs = timeAtX(scrollLeft + layout.gutter + (viewportW || W));
const brushX = mx(visibleStartMs);
const brushW = Math.max(24, mx(visibleEndMs) - brushX);
const handleW = 14;
const clearDocumentDrag = () => {
documentDragCleanupRef.current?.();
@ -886,7 +870,7 @@ function MiniMap({
width={W}
height={H}
viewBox={`0 0 ${W} ${H}`}
className="block cursor-ew-resize"
className="block cursor-grab active:cursor-grabbing"
onMouseDown={(e) => {
const el = e.currentTarget;
seek(e.clientX, el);
@ -924,27 +908,67 @@ function MiniMap({
strokeWidth={1.5}
onMouseDown={(e) => startRangeDrag("move", e)}
/>
<rect
data-testid="timeline-minimap-left-handle"
x={brushX - 3}
<MiniMapHandle
x={brushX}
y={1}
width={6}
height={H - 2}
fill="var(--color-foreground)"
opacity={0.55}
width={handleW}
testId="timeline-minimap-left-handle"
label="Drag left edge to resize visible range"
onMouseDown={(e) => startRangeDrag("left", e)}
/>
<rect
data-testid="timeline-minimap-right-handle"
x={brushX + brushW - 3}
<MiniMapHandle
x={brushX + brushW}
y={1}
width={6}
height={H - 2}
fill="var(--color-foreground)"
opacity={0.55}
width={handleW}
testId="timeline-minimap-right-handle"
label="Drag right edge to resize visible range"
onMouseDown={(e) => startRangeDrag("right", e)}
/>
</svg>
</div>
);
}
function MiniMapHandle({
x,
y,
width,
height,
testId,
label,
onMouseDown,
}: {
x: number;
y: number;
width: number;
height: number;
testId: string;
label: string;
onMouseDown: (event: React.MouseEvent<SVGElement>) => void;
}) {
const left = x - width / 2;
const gripTop = y + height / 2 - 7;
return (
<g
data-testid={testId}
className="cursor-grab active:cursor-grabbing"
onMouseDown={onMouseDown}
>
<title>{label}</title>
<rect
x={left}
y={y}
width={width}
height={height}
rx={3}
fill="var(--color-foreground)"
opacity={0.16}
/>
<line x1={x - 3} y1={gripTop} x2={x - 3} y2={gripTop + 14} stroke="var(--color-foreground)" strokeWidth={1.5} opacity={0.85} />
<line x1={x} y1={gripTop} x2={x} y2={gripTop + 14} stroke="var(--color-foreground)" strokeWidth={1.5} opacity={0.85} />
<line x1={x + 3} y1={gripTop} x2={x + 3} y2={gripTop + 14} stroke="var(--color-foreground)" strokeWidth={1.5} opacity={0.85} />
</g>
);
}

View File

@ -24,6 +24,10 @@ vi.mock("@/api/workTimeline", () => ({
workTimelineApi: mockWorkTimelineApi,
}));
vi.mock("@/lib/router", () => ({
useLocation: () => ({ pathname: "/PAP/timeline" }),
}));
vi.mock("@/components/RequestCollapsedSidebar", () => ({
RequestCollapsedSidebar: () => <div data-testid="request-collapsed-sidebar" />,
}));
@ -46,6 +50,65 @@ const emptyTimeline: WorkTimelineResult = {
},
};
const populatedTimeline: WorkTimelineResult = {
actors: [
{ id: "agent:codex", type: "agent", name: "CodexCoder", avatar: "code" },
{ id: "agent:qa", type: "agent", name: "QA", avatar: "shield" },
{ id: "user:board", type: "user", name: "Board Operator", avatar: "/avatar.png" },
],
spans: [
{
actorId: "agent:codex",
laneHint: "assignment",
runId: "run-1",
issueId: "issue-1",
issueIdentifier: "PAP-1",
issueTitle: "Implement timeline stats",
start: "2026-07-02T10:00:00.000Z",
end: "2026-07-02T10:30:00.000Z",
status: "succeeded",
retryOfRunId: null,
usage: {
inputTokens: 1_000,
cachedInputTokens: 0,
outputTokens: 500,
totalTokens: 1_500,
},
},
{
actorId: "agent:qa",
laneHint: "assignment",
runId: "run-2",
issueId: "issue-2",
issueIdentifier: "PAP-2",
issueTitle: "Verify timeline stats",
start: "2026-07-02T11:00:00.000Z",
end: "2026-07-02T11:15:00.000Z",
status: "succeeded",
retryOfRunId: null,
usage: {
inputTokens: 900,
cachedInputTokens: 100,
outputTokens: 500,
totalTokens: 1_500,
},
},
],
events: [],
edges: [],
pagination: {
limit: 100,
offset: 0,
totalIssues: 2,
hasMore: false,
},
window: {
from: "2026-07-02T00:00:00.000Z",
to: "2026-07-02T23:59:59.999Z",
capped: false,
},
};
async function flushReact() {
for (let index = 0; index < 3; index += 1) {
await Promise.resolve();
@ -114,6 +177,101 @@ describe("Timeline", () => {
expect(container.textContent).not.toContain("visible");
});
it("renders top timeline stats and keeps range controls in the chart footer", async () => {
mockWorkTimelineApi.get.mockResolvedValue(populatedTimeline);
root = createRoot(container);
flushSync(() => {
root?.render(
<QueryClientProvider client={queryClient}>
<Timeline />
</QueryClientProvider>,
);
});
await flushReact();
expect(container.textContent).toContain("Runs");
expect(container.textContent).toContain("Agents");
expect(container.textContent).toContain("Run time");
expect(container.textContent).toContain("Tokens used");
expect(container.textContent).toContain("45m");
expect(container.textContent).toContain("3K");
const footer = Array.from(container.querySelectorAll("div")).find((element) =>
element.textContent?.includes("2 runs") && element.textContent.includes("Range"),
);
expect(footer).not.toBeUndefined();
});
it("clamps open run summary time to the returned timeline window", async () => {
mockWorkTimelineApi.get.mockResolvedValue({
...populatedTimeline,
spans: [
{
...populatedTimeline.spans[0],
start: "2026-07-02T00:00:00.000Z",
end: null,
},
],
window: {
from: "2026-07-02T00:00:00.000Z",
to: "2026-07-02T02:00:00.000Z",
capped: false,
},
});
root = createRoot(container);
flushSync(() => {
root?.render(
<QueryClientProvider client={queryClient}>
<Timeline />
</QueryClientProvider>,
);
});
await flushReact();
expect(container.textContent).toContain("Run time");
expect(container.textContent).toContain("2h 0m");
});
it("prorates summary tokens to the returned timeline window for clipped spans", async () => {
mockWorkTimelineApi.get.mockResolvedValue({
...populatedTimeline,
spans: [
{
...populatedTimeline.spans[0],
start: "2026-07-02T00:00:00.000Z",
end: "2026-07-02T04:00:00.000Z",
usage: {
inputTokens: 2_000,
cachedInputTokens: 0,
outputTokens: 2_000,
totalTokens: 4_000,
},
},
],
window: {
from: "2026-07-02T02:00:00.000Z",
to: "2026-07-02T04:00:00.000Z",
capped: false,
},
});
root = createRoot(container);
flushSync(() => {
root?.render(
<QueryClientProvider client={queryClient}>
<Timeline />
</QueryClientProvider>,
);
});
await flushReact();
expect(container.textContent).toContain("Tokens used");
expect(container.textContent).toContain("2K");
expect(container.textContent).not.toContain("4K");
});
it("requests the company timeline without a user lens parameter", async () => {
root = createRoot(container);

View File

@ -7,7 +7,8 @@
*/
import { useEffect, useMemo, useRef, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { GanttChartSquare, Minus, Plus, RotateCcw } from "lucide-react";
import { Bot, Clock3, Coins, GanttChartSquare, Minus, Plus, RotateCcw, type LucideIcon } from "lucide-react";
import type { WorkTimelineResult } from "@paperclipai/shared";
import { workTimelineApi, type WorkTimelineParams } from "@/api/workTimeline";
import { queryKeys } from "@/lib/queryKeys";
import { useCompany } from "@/context/CompanyContext";
@ -25,7 +26,7 @@ import {
type ZoomLevel,
zoomScaleForLevel,
} from "@/components/timeline/WorkTimelineChart";
import { TIMELINE_COLORS } from "@/lib/timeline/layout";
import { formatDuration, TIMELINE_COLORS } from "@/lib/timeline/layout";
import { cn } from "@/lib/utils";
type RangePreset = "today" | "7d" | "30d" | "custom";
@ -66,6 +67,72 @@ function rangeError(range: DateRangeState): string | null {
return null;
}
function formatInteger(value: number): string {
return new Intl.NumberFormat("en-US").format(value);
}
function formatCompactInteger(value: number): string {
return new Intl.NumberFormat("en-US", {
notation: "compact",
maximumFractionDigits: 1,
}).format(value);
}
function spanStartMs(span: WorkTimelineResult["spans"][number]) {
return new Date(span.start).getTime();
}
function spanEndMs(span: WorkTimelineResult["spans"][number], fallbackEndMs: number) {
return span.end ? new Date(span.end).getTime() : fallbackEndMs;
}
function spanWindowOverlap(
span: WorkTimelineResult["spans"][number],
windowFromMs: number,
windowToMs: number,
) {
const rawStartMs = spanStartMs(span);
const rawEndMs = spanEndMs(span, windowToMs);
const startMs = Math.max(rawStartMs, windowFromMs);
const endMs = Math.min(rawEndMs, windowToMs);
return {
clippedMs: Math.max(0, endMs - startMs),
rawMs: Math.max(0, rawEndMs - rawStartMs),
};
}
function spanWindowTokens(span: WorkTimelineResult["spans"][number], rawMs: number, clippedMs: number) {
const totalTokens = span.usage?.totalTokens ?? 0;
if (totalTokens <= 0 || clippedMs <= 0) return 0;
if (rawMs <= 0 || clippedMs >= rawMs) return totalTokens;
return Math.round(totalTokens * (clippedMs / rawMs));
}
function timelineSummary(data: WorkTimelineResult) {
const actorById = new Map(data.actors.map((actor) => [actor.id, actor]));
const activeAgentIds = new Set<string>();
const windowFromMs = new Date(data.window.from).getTime();
const windowToMs = new Date(data.window.to).getTime();
let activeMs = 0;
let totalTokens = 0;
for (const span of data.spans) {
if (actorById.get(span.actorId)?.type === "agent") {
activeAgentIds.add(span.actorId);
}
const overlap = spanWindowOverlap(span, windowFromMs, windowToMs);
activeMs += overlap.clippedMs;
totalTokens += spanWindowTokens(span, overlap.rawMs, overlap.clippedMs);
}
return {
runs: data.spans.length,
agents: activeAgentIds.size,
activeMs,
totalTokens,
};
}
function Segmented<T extends string>({
value,
options,
@ -125,6 +192,40 @@ function TimelineLegend() {
);
}
function TimelineSummaryStats({
summary,
}: {
summary: ReturnType<typeof timelineSummary>;
}) {
const stats: { label: string; value: string; icon: LucideIcon }[] = [
{ label: "Runs", value: formatInteger(summary.runs), icon: GanttChartSquare },
{ label: "Agents", value: formatInteger(summary.agents), icon: Bot },
{ label: "Run time", value: formatDuration(0, summary.activeMs), icon: Clock3 },
{
label: "Tokens used",
value: summary.totalTokens > 0 ? formatCompactInteger(summary.totalTokens) : "Not tracked",
icon: Coins,
},
];
return (
<dl className="grid flex-1 grid-cols-2 gap-3 border-y border-border py-3 md:grid-cols-4">
{stats.map((stat) => {
const Icon = stat.icon;
return (
<div key={stat.label} className="min-w-0">
<dt className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Icon className="h-3.5 w-3.5 shrink-0" />
<span>{stat.label}</span>
</dt>
<dd className="mt-1 truncate text-lg font-semibold tabular-nums text-foreground">{stat.value}</dd>
</div>
);
})}
</dl>
);
}
export function Timeline() {
const { selectedCompanyId } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs();
@ -191,46 +292,52 @@ export function Timeline() {
setZoomScale(undefined);
};
const summary = data ? timelineSummary(data) : null;
const rangeControls = (
<label className="flex min-w-0 flex-wrap items-center gap-2 text-xs text-muted-foreground">
Range
<Segmented
value={rangePreset}
onChange={(preset) => {
if (preset === "custom") return;
setRangePreset(preset);
setDateRange(presetRange(preset));
}}
options={[
{ value: "today", label: "Today" },
{ value: "7d", label: "7 days" },
{ value: "30d", label: "30 days" },
]}
/>
<Input
type="date"
value={dateRange.fromDate}
onChange={(event) => {
setRangePreset("custom");
setDateRange((prev) => ({ ...prev, fromDate: event.target.value }));
}}
className="h-8 w-(--sz-150px) text-xs"
aria-label="Timeline start date"
/>
<span>to</span>
<Input
type="date"
value={dateRange.toDate}
onChange={(event) => {
setRangePreset("custom");
setDateRange((prev) => ({ ...prev, toDate: event.target.value }));
}}
className="h-8 w-(--sz-150px) text-xs"
aria-label="Timeline end date"
/>
</label>
);
const toolbar = (
<div className="flex flex-wrap items-center gap-x-6 gap-y-3">
<label className="flex min-w-0 flex-wrap items-center gap-2 text-xs text-muted-foreground">
Range
<Segmented
value={rangePreset}
onChange={(preset) => {
if (preset === "custom") return;
setRangePreset(preset);
setDateRange(presetRange(preset));
}}
options={[
{ value: "today", label: "Today" },
{ value: "7d", label: "7 days" },
{ value: "30d", label: "30 days" },
]}
/>
<Input
type="date"
value={dateRange.fromDate}
onChange={(event) => {
setRangePreset("custom");
setDateRange((prev) => ({ ...prev, fromDate: event.target.value }));
}}
className="h-8 w-(--sz-150px) text-xs"
aria-label="Timeline start date"
/>
<span>to</span>
<Input
type="date"
value={dateRange.toDate}
onChange={(event) => {
setRangePreset("custom");
setDateRange((prev) => ({ ...prev, toDate: event.target.value }));
}}
className="h-8 w-(--sz-150px) text-xs"
aria-label="Timeline end date"
/>
</label>
<div className="ml-auto flex items-center gap-1" aria-label="Timeline zoom controls">
<div className="flex flex-wrap items-start gap-3">
{summary && <TimelineSummaryStats summary={summary} />}
<div className="ml-auto flex items-center gap-1 pt-3" aria-label="Timeline zoom controls">
<Button
type="button"
variant="outline"
@ -274,10 +381,15 @@ export function Timeline() {
{isLoading && <PageSkeleton />}
{dateRangeError && (
<EmptyState
icon={GanttChartSquare}
message={dateRangeError}
/>
<div className="space-y-3">
<EmptyState
icon={GanttChartSquare}
message={dateRangeError}
/>
<div className="flex flex-wrap items-center justify-end gap-3">
{rangeControls}
</div>
</div>
)}
{error && (
@ -289,7 +401,12 @@ export function Timeline() {
{data && !isLoading && !dateRangeError && (
data.spans.length === 0 ? (
<EmptyState icon={GanttChartSquare} message="No activity in this window." />
<div className="space-y-3">
<EmptyState icon={GanttChartSquare} message="No activity in this window." />
<div className="flex flex-wrap items-center justify-end gap-3">
{rangeControls}
</div>
</div>
) : (
<div className="space-y-3">
<div className="rounded-lg border border-border bg-card">
@ -305,11 +422,14 @@ export function Timeline() {
}}
/>
</div>
<p className="text-xs text-muted-foreground">
{data.spans.length} run{data.spans.length === 1 ? "" : "s"} ·{" "}
{new Date(data.window.from).toLocaleString()} {new Date(data.window.to).toLocaleString()}
{data.window.capped ? " · window capped" : ""}
</p>
<div className="flex flex-wrap items-center justify-between gap-3">
<p className="text-xs text-muted-foreground">
{data.spans.length} run{data.spans.length === 1 ? "" : "s"} ·{" "}
{new Date(data.window.from).toLocaleString()} to {new Date(data.window.to).toLocaleString()}
{data.window.capped ? " · window capped" : ""}
</p>
{rangeControls}
</div>
</div>
)
)}

View File

@ -23,7 +23,7 @@ const config: StorybookConfig = {
resolve: {
alias: {
"@": path.resolve(storybookConfigDir, "../../src"),
lexical: path.resolve(storybookConfigDir, "../../node_modules/lexical/Lexical.mjs"),
lexical: path.resolve(storybookConfigDir, "../../node_modules/lexical/dist/Lexical.mjs"),
// Vite's bundled `node:crypto` polyfill omits `createHash`, which
// `@paperclipai/shared/external-objects.ts` imports server-side. Use
// a no-op browser shim so the import resolves; the canonicalizer

View File

@ -1,6 +1,7 @@
import { useEffect, useState, type ReactNode } from "react";
import type { Preview } from "@storybook/react-vite";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { WorkTimelineResult } from "@paperclipai/shared";
import { MemoryRouter } from "@/lib/router";
import { BreadcrumbProvider } from "@/context/BreadcrumbContext";
import { CompanyProvider } from "@/context/CompanyContext";
@ -29,10 +30,39 @@ import {
storybookSecrets,
storybookSidebarBadges,
} from "../fixtures/paperclipData";
import timelineSample from "../fixtures/workTimeline.human.sample.json";
import "@mdxeditor/editor/style.css";
import "./tailwind-entry.css";
import "./styles.css";
const STORYBOOK_USER_AVATAR =
"https://images.unsplash.com/photo-1494790108377-be9c29b29330?auto=format&fit=crop&w=96&q=80";
function withStorybookTimelineDetails(data: WorkTimelineResult): WorkTimelineResult {
return {
...data,
actors: data.actors.map((actor) => (
actor.type === "user" ? { ...actor, avatar: STORYBOOK_USER_AVATAR } : actor
)),
spans: data.spans.map((span, index) => {
const inputTokens = 42_000 + index * 137;
const cachedInputTokens = index % 3 === 0 ? 8_000 : 0;
const outputTokens = 5_400 + index * 29;
return {
...span,
usage: span.usage ?? {
inputTokens,
cachedInputTokens,
outputTokens,
totalTokens: inputTokens + cachedInputTokens + outputTokens,
},
};
}),
};
}
const storybookTimelineSample = withStorybookTimelineDetails(timelineSample as WorkTimelineResult);
// Install fetch monkeypatch eagerly so any module-load-time fetches (e.g. schema
// caches in adapter config renderers) hit our fixtures before they reach the
// network. Some renderers issue a fetch from useEffect on first paint, which
@ -236,6 +266,24 @@ function installStorybookApiFixtures() {
companyId,
});
}
if (resource === "timeline") {
return Response.json(
companyId === "company-storybook"
? storybookTimelineSample
: {
actors: [],
spans: [],
events: [],
edges: [],
pagination: { limit: 100, offset: 0, totalIssues: 0, hasMore: false },
window: {
from: url.searchParams.get("from") ?? new Date(0).toISOString(),
to: url.searchParams.get("to") ?? new Date(0).toISOString(),
capped: false,
},
},
);
}
if (resource === "heartbeat-runs") {
return Response.json([]);
}

View File

@ -1,7 +1,8 @@
import { useState } from "react";
import { useEffect, useState } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import type { WorkTimelineResult } from "@paperclipai/shared";
import { Minus, Plus, RotateCcw } from "lucide-react";
import { Timeline } from "@/pages/Timeline";
import {
WorkTimelineChart,
clampZoomScale,
@ -10,18 +11,67 @@ import {
zoomScaleForLevel,
} from "@/components/timeline/WorkTimelineChart";
import { Button } from "@/components/ui/button";
import { useCompany } from "@/context/CompanyContext";
import sampleJson from "../fixtures/workTimeline.sample.json";
import humanSampleJson from "../fixtures/workTimeline.human.sample.json";
const sample = sampleJson as unknown as WorkTimelineResult;
const COMPANY_ID = "company-storybook";
const STORYBOOK_USER_AVATAR =
"https://images.unsplash.com/photo-1494790108377-be9c29b29330?auto=format&fit=crop&w=96&q=80";
function withStorybookTimelineDetails(data: WorkTimelineResult): WorkTimelineResult {
return {
...data,
actors: data.actors.map((actor) => (
actor.type === "user" ? { ...actor, avatar: STORYBOOK_USER_AVATAR } : actor
)),
spans: data.spans.map((span, index) => {
const inputTokens = 42_000 + index * 137;
const cachedInputTokens = index % 3 === 0 ? 8_000 : 0;
const outputTokens = 5_400 + index * 29;
return {
...span,
usage: span.usage ?? {
inputTokens,
cachedInputTokens,
outputTokens,
totalTokens: inputTokens + cachedInputTokens + outputTokens,
},
};
}),
};
}
const sample = withStorybookTimelineDetails(sampleJson as unknown as WorkTimelineResult);
// A second real slice (2026-07-02 14:0016:00Z) captured straight from the live
// `/timeline` endpoint that DOES carry human events — Dotta's created / commented /
// approved / delegated actions provide human participation and kickoff context.
const humanSample = humanSampleJson as unknown as WorkTimelineResult;
const humanSample = withStorybookTimelineDetails(humanSampleJson as unknown as WorkTimelineResult);
// The fixture is a real slice of PAP company activity (2026-07-02 14:0015:50Z);
// pin "now" to the window end so in-progress runs fade correctly.
const NOW = new Date("2026-07-02T15:45:00.000Z").getTime();
function FullPageTimelineHarness() {
const { selectedCompanyId, setSelectedCompanyId } = useCompany();
useEffect(() => {
window.localStorage.setItem("paperclip.selectedCompanyId", COMPANY_ID);
if (selectedCompanyId !== COMPANY_ID) {
setSelectedCompanyId(COMPANY_ID);
}
}, [selectedCompanyId, setSelectedCompanyId]);
if (selectedCompanyId !== COMPANY_ID) {
return null;
}
return (
<div className="min-h-screen bg-background p-6 text-foreground">
<Timeline />
</div>
);
}
function TimelineHarness({
initialZoom = "day" as ZoomLevel,
data = sample,
@ -127,3 +177,7 @@ export const WithHumanActivity: Story = {
now: new Date("2026-07-02T16:00:00.000Z").getTime(),
},
};
export const FullPageWithMockData: Story = {
render: () => <FullPageTimelineHarness />,
};