From 1bdae0caab45dc6be934df14336d775196226137 Mon Sep 17 00:00:00 2001 From: Dotta Date: Fri, 3 Jul 2026 00:49:59 +0000 Subject: [PATCH] =?UTF-8?q?PAP-12435=20Work=20Timeline=20=E2=80=94=20routi?= =?UTF-8?q?ne=20badge=20+=20default=20self-lens=20(QA=20plan=20deviations)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit QA Phase D (PAP-12426) validated the Work Timeline against live PAP data and found two deviations from the approved plan (PAP-12405). Both fixed here. Finding 1 — routine-fired runs now carry a distinct hexagon "Routine" badge on the bar's leading edge (with a circular-arrow glyph) instead of a normal kickoff avatar, plus a matching tooltip line. `isRoutineRun()` (layout.ts) treats invocationSource ∈ {automation, timer} as routine/automation-fired. Finding 2 — the lens now defaults to the viewer's own kicked-off work (resolved from the auth session) instead of whole-company. The company fetch is gated until the lens resolves; the dropdown is seeded with the viewer ("(you)") so their option is present before they appear as an actor, and "Everyone (company)" remains one click away. Tests: isRoutineRun unit coverage (automation/timer true; assignment/on_demand/ null false) and a chart render test asserting exactly one badge for the automation run. 17/17 timeline tests pass. Co-Authored-By: Claude Opus 4.8 --- .../timeline/WorkTimelineChart.test.tsx | 11 +++ .../components/timeline/WorkTimelineChart.tsx | 57 +++++++++++++-- ui/src/lib/timeline/layout.test.ts | 29 +++++++- ui/src/lib/timeline/layout.ts | 12 ++++ ui/src/pages/Timeline.tsx | 72 ++++++++++++++++--- 5 files changed, 167 insertions(+), 14 deletions(-) diff --git a/ui/src/components/timeline/WorkTimelineChart.test.tsx b/ui/src/components/timeline/WorkTimelineChart.test.tsx index 1d42452ca2b..40a80161b72 100644 --- a/ui/src/components/timeline/WorkTimelineChart.test.tsx +++ b/ui/src/components/timeline/WorkTimelineChart.test.tsx @@ -108,6 +108,17 @@ describe("WorkTimelineChart", () => { expect(container.querySelector("[data-testid='work-timeline-actor-gutter']")?.textContent).toContain("CodexCoder"); }); + it("marks routine/automation-fired runs with a hexagon badge, not other runs", () => { + const data = timelineSample(); + data.spans[0].invocationSource = "automation"; // routine-fired → badge + data.spans[1].invocationSource = "assignment"; // normal kickoff → no badge + renderChart(data); + + // exactly one routine badge (hexagon polygon) is drawn, for the automation run. + const badges = container.querySelectorAll("svg.absolute polygon"); + expect(badges).toHaveLength(1); + }); + it("renders a human row with diamond event markers when the payload carries events", () => { const data = timelineSample(); data.actors.push({ id: "user:dotta", type: "user", name: "Dotta" }); diff --git a/ui/src/components/timeline/WorkTimelineChart.tsx b/ui/src/components/timeline/WorkTimelineChart.tsx index 7b56b681070..b9b4f51b3b9 100644 --- a/ui/src/components/timeline/WorkTimelineChart.tsx +++ b/ui/src/components/timeline/WorkTimelineChart.tsx @@ -20,6 +20,7 @@ import { chooseTickStepMs, computeLayout, formatDuration, + isRoutineRun, issueColor, shortLabel, type ColorMode, @@ -53,6 +54,8 @@ const GEOM: Omit = { const AVATAR_R = 11; const CHIP_R = 9; const MARKER_R = 5.5; +/** Distinct accent for the routine badge — reads apart from issue hues on light+dark. */ +const ROUTINE_FILL = "hsl(265 52% 60%)"; /** * Per-kind styling for instant event markers (diamonds). Each kind gets a @@ -136,6 +139,37 @@ function AvatarGlyph({ ); } +/** + * A hexagon badge marking a routine/automation-fired run at the bar's leading + * edge — deliberately a different shape from the square (human) / circle (agent) + * avatar chips so routine runs are distinguishable at a glance (PAP-12435). A + * small circular-arrow glyph inside reinforces "recurring / automated". + */ +function RoutineBadge({ cx, cy, r = CHIP_R }: { cx: number; cy: number; r?: number }) { + const points = Array.from({ length: 6 }, (_, k) => { + const a = (Math.PI / 3) * k; // flat-top hexagon + return `${(cx + r * Math.cos(a)).toFixed(2)},${(cy + r * Math.sin(a)).toFixed(2)}`; + }).join(" "); + const ar = r * 0.44; + return ( + + + {/* circular-arrow glyph (open loop + arrowhead) = "routine / recurring" */} + + + + ); +} + export interface WorkTimelineChartProps { data: WorkTimelineResult; zoom: ZoomLevel; @@ -353,7 +387,11 @@ export function WorkTimelineChart({ data, zoom, colorMode, nowMs }: WorkTimeline )} - {bar.kickoff && ( + {isRoutineRun(bar.span) ? ( + + + + ) : bar.kickoff ? ( - )} + ) : null} ); })} @@ -479,12 +517,23 @@ function Tooltip({ tooltip, now }: { tooltip: TooltipState; now: number }) { {fmtClock(startMs)}–{bar.span.end ? fmtClock(endMs) : "now"} · {formatDuration(startMs, endMs)} ·{" "} {bar.span.status} - {bar.kickoff && ( + {isRoutineRun(bar.span) ? ( +
+ + fired by routine{bar.span.retryOfRunId ? " · retry" : ""} +
+ ) : bar.kickoff ? (
kicked off by: {(bar.kickoff as WorkTimelineActor).name} {bar.span.retryOfRunId ? " · retry" : ""}
- )} + ) : null}
click → open task
); diff --git a/ui/src/lib/timeline/layout.test.ts b/ui/src/lib/timeline/layout.test.ts index 4bb95d31690..a43a4d070ad 100644 --- a/ui/src/lib/timeline/layout.test.ts +++ b/ui/src/lib/timeline/layout.test.ts @@ -1,6 +1,13 @@ import { describe, expect, it } from "vitest"; import type { WorkTimelineResult } from "@paperclipai/shared"; -import { chooseTickStepMs, computeLayout, issueColor, shortLabel, type LayoutOptions } from "./layout"; +import { + chooseTickStepMs, + computeLayout, + isRoutineRun, + issueColor, + shortLabel, + type LayoutOptions, +} from "./layout"; const DAY = "2026-07-02"; const t = (hhmm: string) => `${DAY}T${hhmm}:00.000Z`; @@ -194,4 +201,24 @@ describe("helpers", () => { it("chooseTickStepMs grows the step as the view zooms out", () => { expect(chooseTickStepMs(6)).toBeLessThan(chooseTickStepMs(0.4)); }); + + it("isRoutineRun flags automation/timer-fired runs but not human kickoffs", () => { + const span = (invocationSource: string | null): WorkTimelineResult["spans"][number] => ({ + actorId: "agent:x", + laneHint: invocationSource, + runId: "r", + issueId: "i", + issueIdentifier: "PAP-1", + issueTitle: "t", + start: t("09:00"), + end: t("09:10"), + status: "completed", + invocationSource, + }); + expect(isRoutineRun(span("automation"))).toBe(true); + expect(isRoutineRun(span("timer"))).toBe(true); + expect(isRoutineRun(span("assignment"))).toBe(false); + expect(isRoutineRun(span("on_demand"))).toBe(false); + expect(isRoutineRun(span(null))).toBe(false); + }); }); diff --git a/ui/src/lib/timeline/layout.ts b/ui/src/lib/timeline/layout.ts index ca99dd1fecc..9cfc10c9ea5 100644 --- a/ui/src/lib/timeline/layout.ts +++ b/ui/src/lib/timeline/layout.ts @@ -110,6 +110,18 @@ export function isRunningStatus(status: string): boolean { return RUNNING_STATUSES.has(status); } +/** + * Invocation sources that mean "a routine/automation fired this run" rather than + * a human/agent kickoff. Runs with these sources get a distinct "Routine" badge + * on the bar's leading edge instead of a kickoff avatar (plan §actors, PAP-12435). + */ +const ROUTINE_INVOCATION_SOURCES = new Set(["automation", "timer"]); + +/** True when a run was fired by a routine/automation rather than an assignment or on-demand kickoff. */ +export function isRoutineRun(span: WorkTimelineSpan): boolean { + return span.invocationSource != null && ROUTINE_INVOCATION_SOURCES.has(span.invocationSource); +} + export function actorType(actor: WorkTimelineActor | undefined): string { return actor?.type ?? "system"; } diff --git a/ui/src/pages/Timeline.tsx b/ui/src/pages/Timeline.tsx index a8efad08285..f7dd1f12c4d 100644 --- a/ui/src/pages/Timeline.tsx +++ b/ui/src/pages/Timeline.tsx @@ -10,6 +10,7 @@ import { useQuery } from "@tanstack/react-query"; import { GanttChartSquare } from "lucide-react"; import type { WorkTimelineActor, WorkTimelineResult } from "@paperclipai/shared"; import { workTimelineApi, type WorkTimelineParams } from "@/api/workTimeline"; +import { authApi } from "@/api/auth"; import { queryKeys } from "@/lib/queryKeys"; import { useCompany } from "@/context/CompanyContext"; import { useBreadcrumbs } from "@/context/BreadcrumbContext"; @@ -71,23 +72,57 @@ export function Timeline() { setZoom(z); }; const [colorMode, setColorMode] = useState("issue"); - const [lensUserId, setLensUserId] = useState(EVERYONE); + // Lens defaults to the viewer's own kicked-off work (plan §Default lens, + // PAP-12435); resolved from the session once it loads. `null` = not yet resolved + // so we don't fire a whole-company fetch before knowing the viewer. + const [lensUserId, setLensUserId] = useState(null); + const lensTouched = useRef(false); + const setLensManual = (v: string) => { + lensTouched.current = true; + setLensUserId(v); + }; // Union of users discovered across fetches so the lens list stays stable. const [knownUsers, setKnownUsers] = useState([]); + const { data: session, isLoading: sessionLoading } = useQuery({ + queryKey: queryKeys.auth.session, + queryFn: () => authApi.getSession(), + retry: false, + }); + const viewerLensId = session?.user?.id ? `user:${session.user.id}` : null; + useEffect(() => { setBreadcrumbs([{ label: "Timeline" }]); }, [setBreadcrumbs]); + // Resolve the initial lens once the session settles: the viewer's own work if + // we can identify them, otherwise fall back to whole-company. + useEffect(() => { + if (lensTouched.current || lensUserId !== null || sessionLoading) return; + setLensUserId(viewerLensId ?? EVERYONE); + }, [sessionLoading, viewerLensId, lensUserId]); + + // Seed the lens dropdown with the viewer so their own option is always present, + // even before they show up as an actor in a fetched window. + useEffect(() => { + if (!session?.user?.id) return; + const id = `user:${session.user.id}`; + setKnownUsers((prev) => + prev.some((u) => u.id === id) + ? prev + : [{ id, type: "user", name: session.user.name ?? "You" }, ...prev], + ); + }, [session]); + const params: WorkTimelineParams = useMemo( - () => (lensUserId === EVERYONE ? {} : { userId: lensUserId.replace(/^user:/, "") }), + () => (!lensUserId || lensUserId === EVERYONE ? {} : { userId: lensUserId.replace(/^user:/, "") }), [lensUserId], ); const { data, isLoading, error } = useQuery({ - queryKey: queryKeys.workTimeline(selectedCompanyId ?? "", lensUserId), + queryKey: queryKeys.workTimeline(selectedCompanyId ?? "", lensUserId ?? undefined), queryFn: () => workTimelineApi.get(selectedCompanyId!, params), - enabled: !!selectedCompanyId, + enabled: !!selectedCompanyId && lensUserId !== null, }); useEffect(() => { @@ -116,8 +151,9 @@ export function Timeline() {

A Gantt view of who did what, when. Rows are actors; bars are heartbeat runs colored by task; - the avatar chip at a bar's leading edge is who kicked it off; straight lines are agent→agent - delegation. Hover a bar for its task & timing; click to open the task. + the avatar chip at a bar's leading edge is who kicked it off (a hexagon badge marks + routine-fired runs); straight lines are agent→agent delegation. Hover a bar for its task & + timing; click to open the task.

); @@ -138,7 +174,7 @@ export function Timeline() {