From 2cc769cc2202bfd63ea43c420ff9320bfc385ee7 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Thu, 5 Nov 2026 16:43:56 -0800 Subject: [PATCH 01/10] refactor(web): remove the Loom project timeline page The sidebar button, command palette action, route, and page go with it. loomEdgeLabel in client-runtime had no other caller. Co-Authored-By: Claude Sonnet 5.5 --- apps/web/src/components/CommandPalette.tsx | 28 +- apps/web/src/components/Sidebar.logic.test.ts | 43 - apps/web/src/components/Sidebar.logic.ts | 22 - apps/web/src/components/Sidebar.tsx | 62 +- .../components/loom/LoomBranchNavigator.tsx | 89 -- .../src/components/loom/LoomEventPanel.tsx | 282 ------- apps/web/src/components/loom/LoomLegend.tsx | 34 - apps/web/src/components/loom/LoomMinimap.tsx | 151 ---- apps/web/src/components/loom/LoomTimeline.tsx | 518 ------------ .../src/components/loom/LoomWeaveField.tsx | 766 ------------------ .../loom/ProjectLoomPage.browser.test.tsx | 471 ----------- .../src/components/loom/ProjectLoomPage.tsx | 639 --------------- .../src/components/loom/loomBranch.test.ts | 310 ------- apps/web/src/components/loom/loomBranch.ts | 312 ------- apps/web/src/components/loom/loomKinds.ts | 107 --- .../web/src/components/loom/loomScale.test.ts | 114 --- apps/web/src/components/loom/loomScale.ts | 158 ---- apps/web/src/components/loom/loomSearch.ts | 108 --- .../src/components/loom/loomSpindle.test.ts | 269 ------ apps/web/src/components/loom/loomSpindle.ts | 298 ------- .../web/src/components/loom/loomWeave.test.ts | 368 --------- apps/web/src/components/loom/loomWeave.ts | 416 ---------- .../components/loom/useLoomViewGestures.ts | 58 -- .../src/components/sidebar/mainAppLocation.ts | 6 +- apps/web/src/routeTree.gen.ts | 22 - .../routes/loom.$environmentId.$projectId.tsx | 32 - apps/web/src/state/projectLoom.ts | 4 - .../client-runtime/src/projectLoom/index.ts | 13 - 28 files changed, 3 insertions(+), 5697 deletions(-) delete mode 100644 apps/web/src/components/loom/LoomBranchNavigator.tsx delete mode 100644 apps/web/src/components/loom/LoomEventPanel.tsx delete mode 100644 apps/web/src/components/loom/LoomLegend.tsx delete mode 100644 apps/web/src/components/loom/LoomMinimap.tsx delete mode 100644 apps/web/src/components/loom/LoomTimeline.tsx delete mode 100644 apps/web/src/components/loom/LoomWeaveField.tsx delete mode 100644 apps/web/src/components/loom/ProjectLoomPage.browser.test.tsx delete mode 100644 apps/web/src/components/loom/ProjectLoomPage.tsx delete mode 100644 apps/web/src/components/loom/loomBranch.test.ts delete mode 100644 apps/web/src/components/loom/loomBranch.ts delete mode 100644 apps/web/src/components/loom/loomKinds.ts delete mode 100644 apps/web/src/components/loom/loomScale.test.ts delete mode 100644 apps/web/src/components/loom/loomScale.ts delete mode 100644 apps/web/src/components/loom/loomSearch.ts delete mode 100644 apps/web/src/components/loom/loomSpindle.test.ts delete mode 100644 apps/web/src/components/loom/loomSpindle.ts delete mode 100644 apps/web/src/components/loom/loomWeave.test.ts delete mode 100644 apps/web/src/components/loom/loomWeave.ts delete mode 100644 apps/web/src/components/loom/useLoomViewGestures.ts delete mode 100644 apps/web/src/routes/loom.$environmentId.$projectId.tsx delete mode 100644 apps/web/src/state/projectLoom.ts diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 4b42711455c07..2f9cb9e62d627 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -56,7 +56,6 @@ import { useLocation, useNavigate } from "@tanstack/react-router"; import { ArrowLeftIcon, BrainIcon, - ChartGanttIcon, BugIcon, ChartNoAxesColumnIcon, CheckIcon, @@ -221,7 +220,7 @@ import { type SearchOverlayMode, } from "./CommandPalette.logic"; import { resolveEnvironmentOptionLabel } from "./BranchToolbar.logic"; -import { isSidebarHiddenThread, resolveLoomTargets } from "./Sidebar.logic"; +import { isSidebarHiddenThread } from "./Sidebar.logic"; import { CommandPaletteContent } from "./CommandPaletteContent"; import { selectCommandPaletteClientSettings, @@ -1706,31 +1705,6 @@ function OpenCommandPaletteDialog(props: { }); }, }); - const loomTargets = resolveLoomTargets({ - members: contextualProjectGroup.memberProjects, - activeProjectRef: contextualProjectRef, - supportsLoom: (environmentId) => - serverConfigs.get(environmentId)?.environment.capabilities.projectLoom === true, - }); - for (const member of loomTargets) { - actionItems.push({ - kind: "action", - value: `action:open-loom:${member.environmentId}:${member.id}`, - searchTerms: ["loom", "timeline", "activity", "agents", "history", "project"], - title: "Open Loom", - description: - loomTargets.length > 1 && member.environmentLabel !== null - ? `${contextualProjectGroup.displayName} on ${member.environmentLabel}` - : contextualProjectGroup.displayName, - icon: , - run: async () => { - await navigate({ - to: "/loom/$environmentId/$projectId", - params: { environmentId: member.environmentId, projectId: member.id }, - }); - }, - }); - } } const rootGroups = buildRootGroups({ actionItems, recentThreadItems }); diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 0c7b83c87790d..8868d5daba090 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -14,7 +14,6 @@ import { resolveSidebarRowThreadRef, orderItemsByPreferredIds, pinOrderKeyBetween, - resolveLoomTargets, resolveSidebarSweepKeys, resolveSidebarThreadStatus, resolveThreadStatusPill, @@ -1627,48 +1626,6 @@ describe("pendingSidebarRouteReveal", () => { }); }); -describe("resolveLoomTargets", () => { - const local = EnvironmentId.make("local"); - const remote = EnvironmentId.make("remote"); - const legacy = EnvironmentId.make("legacy"); - const member = (environmentId: EnvironmentId) => ({ - environmentId, - id: ProjectId.make(`project-${environmentId}`), - }); - const members = [member(local), member(remote), member(legacy)]; - const supportsLoom = (environmentId: EnvironmentId) => environmentId !== legacy; - - it("opens the active member's Loom when the active project is in the group", () => { - expect( - resolveLoomTargets({ - members, - activeProjectRef: { environmentId: remote, projectId: ProjectId.make("project-remote") }, - supportsLoom, - }), - ).toEqual([member(remote)]); - }); - - it("offers every member whose environment has the Loom when no active member decides", () => { - expect(resolveLoomTargets({ members, activeProjectRef: null, supportsLoom })).toEqual([ - member(local), - member(remote), - ]); - expect( - resolveLoomTargets({ - members, - activeProjectRef: { environmentId: legacy, projectId: ProjectId.make("project-legacy") }, - supportsLoom, - }), - ).toEqual([member(local), member(remote)]); - }); - - it("offers nothing when no member's environment has the Loom", () => { - expect( - resolveLoomTargets({ members, activeProjectRef: null, supportsLoom: () => false }), - ).toEqual([]); - }); -}); - describe("resolveSidebarRouteReveal", () => { const scopedProjectKeys = new Set(["environment-test:project-scoped"]); const hidden = makeThreadFixture({ projectId: ProjectId.make("project-other") }); diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 7380dbb26c3e9..5ae608cba2fd5 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -19,8 +19,6 @@ import { import { foldSearchQuery, rankSearchItems, scoreThreadSearch } from "@t3tools/shared/paletteSearch"; import { isListedThreadLineage, - type ProjectId, - type ScopedProjectRef, type ScopedThreadRef, type ContextMenuItem, type EnvironmentId, @@ -1429,23 +1427,3 @@ export function sortScopedProjectsForSidebar< left.id.localeCompare(right.id), ); } - -/** - * Which grouped-project members a Loom entry opens. The active member decides when its environment - * has the Loom; otherwise every member that has it is offered, so the user picks the environment. - */ -export function resolveLoomTargets< - TMember extends { readonly environmentId: EnvironmentId; readonly id: ProjectId }, ->(input: { - readonly members: ReadonlyArray; - readonly activeProjectRef: ScopedProjectRef | null; - readonly supportsLoom: (environmentId: EnvironmentId) => boolean; -}): ReadonlyArray { - const targets = input.members.filter((member) => input.supportsLoom(member.environmentId)); - const active = targets.find( - (member) => - member.environmentId === input.activeProjectRef?.environmentId && - member.id === input.activeProjectRef.projectId, - ); - return active === undefined ? targets : [active]; -} diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 6bcdcd86a83ea..a9bba2f6d3f64 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -61,7 +61,6 @@ import { import type { OlderSettledScope } from "@t3tools/client-runtime/state/threads"; import { - ChartGanttIcon, CheckIcon, ChevronDownIcon, FolderIcon, @@ -132,7 +131,7 @@ import { useSidebarProjectGroups } from "../hooks/useSidebarProjectGroups"; import { useTerminalFocus } from "../hooks/useTerminalFocus"; import { isCommandPaletteOpen, openCommandPalette } from "../commandPaletteBus"; import { registerSettleAndAdvance } from "../settleAndAdvance"; -import { resolveThreadActionProjectRef, startNewThreadFromContext } from "../lib/chatThreadActions"; +import { startNewThreadFromContext } from "../lib/chatThreadActions"; import { useBoringSidebarEnabled, useClientSettings } from "../hooks/useSettings"; import { useCopyToClipboard } from "../hooks/useCopyToClipboard"; import { useLocalStorage } from "../hooks/useLocalStorage"; @@ -215,7 +214,6 @@ import { useThreadJumpHintsVisible, type SidebarListItem, type SidebarSection, - resolveLoomTargets, resolveSidebarRowThreadRef, } from "./Sidebar.logic"; import { @@ -1266,50 +1264,6 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve }); }, ); - const activeProjectRef = resolveThreadActionProjectRef({ - activeDraftThread: newThreadContext.activeDraftThread, - activeThread: newThreadContext.activeThread ?? undefined, - defaultProjectRef: null, - handleNewThread: newThreadContext.handleNewThread, - }); - const loomTargetsFor = (projectGroup: SidebarProjectSnapshot) => - resolveLoomTargets({ - members: projectGroup.memberProjects, - activeProjectRef, - supportsLoom: (environmentId) => - serverConfigs.get(environmentId)?.environment.capabilities.projectLoom === true, - }); - const handleOpenLoom = useStableCallback( - async (event: ReactMouseEvent, projectGroup: SidebarProjectSnapshot) => { - event.preventDefault(); - event.stopPropagation(); - suppressNextScopeChangeRef.current = true; - dispatchProjectScopeMenu({ type: "project-settings-opened" }); - const targets = loomTargetsFor(projectGroup); - let member = targets.length === 1 ? targets[0] : undefined; - if (targets.length > 1) { - const api = readLocalApi(); - if (!api) return; - const clicked = await settlePromise(() => - api.contextMenu.show( - targets.map((target) => ({ - id: `${target.environmentId}:${target.id}`, - label: `Open Loom on ${target.environmentLabel ?? target.environmentId}`, - })), - { x: event.clientX, y: event.clientY }, - ), - ); - if (clicked._tag === "Failure") return; - member = targets.find((target) => `${target.environmentId}:${target.id}` === clicked.value); - } - if (member === undefined) return; - if (isMobile) setOpenMobile(false); - void router.navigate({ - to: "/loom/$environmentId/$projectId", - params: { environmentId: member.environmentId, projectId: member.id }, - }); - }, - ); const { canPinProjectGroup: canPinProject, setProjectGroupPinned, @@ -3219,20 +3173,6 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve )} {item.label} - {project && loomTargetsFor(project).length > 0 ? ( - - ) : null} {project && showProjectEnvironments ? ( void; -}) { - const [search, setSearch] = useState(""); - const needle = search.trim().toLowerCase(); - const shown = props.weave.workstreams.filter( - (workstream) => - needle.length === 0 || - workstream.strands.some((strand) => strand.title.toLowerCase().includes(needle)), - ); - return ( - <> -
-

Branch navigator

-
- - setSearch(event.currentTarget.value)} - /> -
-
- {shown.length === 0 ? ( -

- {needle.length > 0 - ? "No workstreams match this search." - : "No workstreams in this window."} -

- ) : ( -
    - {shown.map((workstream) => { - const current = workstream.rootId === props.currentRootId; - return ( -
  1. - -
  2. - ); - })} -
- )} - - ); -} diff --git a/apps/web/src/components/loom/LoomEventPanel.tsx b/apps/web/src/components/loom/LoomEventPanel.tsx deleted file mode 100644 index 348d84d41d887..0000000000000 --- a/apps/web/src/components/loom/LoomEventPanel.tsx +++ /dev/null @@ -1,282 +0,0 @@ -import { scopeThreadRef } from "@t3tools/client-runtime/environment"; -import { - loomEdgeLabel, - loomMarkerLabel, - loomMarkerThreadRef, -} from "@t3tools/client-runtime/projectLoom"; -import type { - EnvironmentId, - LoomWindow, - RunId, - ScopedThreadRef, - ThreadId, -} from "@t3tools/contracts"; -import { XIcon } from "lucide-react"; -import { useState, type MouseEvent, type ReactNode } from "react"; - -import { cn } from "~/lib/utils"; -import { Button } from "../ui/button"; -import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; -import { formatLoomTime } from "./loomScale"; -import type { LoomSelection } from "./loomSearch"; -import { loomMarkerKey, loomSelectionThread } from "./loomWeave"; - -export interface LoomActions { - readonly canOpenThread: (target: ScopedThreadRef) => boolean; - readonly openThread: (target: ScopedThreadRef) => void; - readonly openDiff: (threadId: ThreadId, runId: RunId) => void; - readonly openPullRequest: ( - event: MouseEvent, - url: string, - threadId: ThreadId, - ) => void; -} - -interface LoomEvent { - readonly selection: LoomSelection; - readonly threadId: ThreadId; - readonly at: string; - readonly label: string; - readonly open: (event: MouseEvent) => void; - readonly openLabel: string; - readonly unavailable?: string; -} - -const PAGE = 200; - -function loomEvents( - data: LoomWindow, - environmentId: EnvironmentId, - actions: LoomActions, -): ReadonlyArray { - const titleOf = new Map( - [...data.lanes, ...data.ancestors].map((lane) => [lane.threadId as string, lane.title]), - ); - const runs = data.runs.map((run): LoomEvent => ({ - selection: { kind: "run", id: run.id }, - threadId: run.threadId, - at: run.requestedAt, - label: `Run ${run.ordinal} ${run.status}${run.startedAt === null ? ", start unknown" : ""}`, - open: () => actions.openThread(scopeThreadRef(environmentId, run.threadId)), - openLabel: "Open thread", - })); - const markers = data.markers.map((marker): LoomEvent => { - const base = { - selection: { kind: "marker", id: loomMarkerKey(marker) } as const, - threadId: marker.threadId, - at: marker.at, - label: loomMarkerLabel(marker), - }; - if (marker.kind === "checkpoint" && marker.runId !== null) { - const runId = marker.runId; - return { - ...base, - open: () => actions.openDiff(marker.threadId, runId), - openLabel: "Open diff", - }; - } - if (marker.kind === "pull_request") { - return { - ...base, - open: (event) => actions.openPullRequest(event, marker.url, marker.threadId), - openLabel: "Open pull request", - }; - } - const target = loomMarkerThreadRef(environmentId, marker); - const remoteChild = marker.kind === "subagent_task" ? marker.remoteChild : undefined; - return { - ...base, - open: () => actions.openThread(target), - openLabel: remoteChild !== undefined ? "Open remote task" : "Open thread", - ...(actions.canOpenThread(target) - ? {} - : { - unavailable: `Connect to ${remoteChild?.label ?? target.environmentId} to open this thread.`, - }), - }; - }); - const edges = data.edges.map((edge): LoomEvent => ({ - selection: { kind: "edge", id: edge.id }, - threadId: edge.targetThreadId, - at: edge.at, - label: - edge.sourceThreadId === edge.targetThreadId - ? loomEdgeLabel(edge) - : `${loomEdgeLabel(edge)} from ${titleOf.get(edge.sourceThreadId) ?? "another thread"}`, - open: () => actions.openThread(scopeThreadRef(environmentId, edge.targetThreadId)), - openLabel: "Open thread", - })); - return [...runs, ...markers, ...edges].toSorted((left, right) => right.at.localeCompare(left.at)); -} - -const ASIDE_CLASS = - "flex max-h-[40%] shrink-0 flex-col border-t border-border/70 bg-card/30 lg:max-h-none lg:w-80 lg:border-t-0 lg:border-s"; - -/** - * The accessible reading of the timeline: what is selected and every event in time order, - * narrowed to the selected lane. Each entry opens its thread, diff or pull request. With nothing - * selected the panel shows `navigator` instead. - */ -export function LoomEventPanel(props: { - readonly environmentId: EnvironmentId; - readonly data: LoomWindow; - readonly selection: LoomSelection | null; - readonly actions: LoomActions; - readonly navigator: ReactNode; - readonly onSelect: (selection: LoomSelection | null) => void; -}) { - const [limit, setLimit] = useState(PAGE); - const { data, selection } = props; - if (selection === null) { - return ( - - ); - } - const events = loomEvents(data, props.environmentId, props.actions); - const laneId = loomSelectionThread(data, selection) as ThreadId | null; - const lane = - laneId === null ? null : (data.lanes.find((entry) => entry.threadId === laneId) ?? null); - const identity = lane ?? data.ancestors.find((entry) => entry.threadId === laneId); - const delegatedFrom = identity?.delegatedFrom; - const parentAvailable = delegatedFrom === undefined || props.actions.canOpenThread(delegatedFrom); - const laneTitles = new Map(data.lanes.map((entry) => [entry.threadId as string, entry.title])); - const shown = laneId === null ? events : events.filter((event) => event.threadId === laneId); - const selected = - selection === null - ? null - : (events.find( - (event) => event.selection.kind === selection.kind && event.selection.id === selection.id, - ) ?? null); - - return ( - - ); -} diff --git a/apps/web/src/components/loom/LoomLegend.tsx b/apps/web/src/components/loom/LoomLegend.tsx deleted file mode 100644 index 73c5be3da6dc6..0000000000000 --- a/apps/web/src/components/loom/LoomLegend.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { cn } from "~/lib/utils"; -import { WEAVE_KIND_ORDER, WEAVE_KINDS } from "./loomKinds"; - -/** The glyph for each event kind, and the line styles for an exact or inferred placement. */ -export function LoomLegend() { - return ( -
    - {WEAVE_KIND_ORDER.map((kind) => { - const Icon = WEAVE_KINDS[kind].icon; - return ( -
  • - - {WEAVE_KINDS[kind].label} -
  • - ); - })} -
  • - - - - Exact -
  • -
  • - - - - Inferred -
  • -
- ); -} diff --git a/apps/web/src/components/loom/LoomMinimap.tsx b/apps/web/src/components/loom/LoomMinimap.tsx deleted file mode 100644 index 13a7284736694..0000000000000 --- a/apps/web/src/components/loom/LoomMinimap.tsx +++ /dev/null @@ -1,151 +0,0 @@ -import { useLayoutEffect, useRef, useState, type KeyboardEvent, type PointerEvent } from "react"; - -import { observeResize } from "~/lib/observeResize"; -import { cn } from "~/lib/utils"; -import { workstreamColor } from "./loomKinds"; -import { loomScale, panLoomView, type LoomView } from "./loomScale"; -import { spindleGeometry, spindleLayout, strandPath } from "./loomSpindle"; -import { strandColors, type Weave, type Workstream } from "./loomWeave"; - -const MINIMAP_HEIGHT = 56; - -/** - * The whole window at spindle scale with the view drawn over it. Drag the view to pan, click - * elsewhere to centre it there, and use the arrow keys when it has focus. - */ -export function LoomMinimap(props: { - readonly weave: Weave; - readonly spine: Workstream | null; - readonly bounds: LoomView; - readonly view: LoomView; - readonly onViewChange: (view: LoomView) => void; -}) { - const ref = useRef(null); - const [width, setWidth] = useState(0); - // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- subscribes to this element's size through a ResizeObserver - useLayoutEffect(() => { - const element = ref.current; - if (element === null) return; - setWidth(element.getBoundingClientRect().width); - return observeResize(element, (entries) => { - const entry = entries.at(-1); - if (entry) setWidth(entry.contentRect.width); - }); - }, []); - const { weave, spine, bounds, view } = props; - const scale = loomScale(bounds, width); - const layout = spindleLayout(weave, spine, MINIMAP_HEIGHT); - const colors = strandColors(weave, spine); - const strands = (() => { - const tailX = Math.min(scale.x(weave.tailMs), width); - const startX = Math.max(scale.x(weave.startMs), 0); - const geometry = spindleGeometry(width, MINIMAP_HEIGHT, startX, tailX); - return weave.workstreams.flatMap((workstream) => - workstream.strands.flatMap((strand) => { - const homeY = layout.homeY.get(strand.threadId); - if (homeY === undefined) return []; - const active = - strand.extent === null - ? "" - : strandPath( - geometry, - homeY, - Math.max(startX, scale.x(strand.extent.startMs)), - Math.min(tailX, scale.x(strand.extent.endMs)), - ); - return [ - { - threadId: strand.threadId, - warp: strandPath(geometry, homeY, startX, tailX), - active, - spine: strand.threadId === spine?.rootId, - colorIndex: colors.get(strand.threadId) ?? workstream.colorIndex, - }, - ]; - }), - ); - })(); - - const drag = useRef<{ x: number; view: LoomView } | null>(null); - const span = view.toMs - view.fromMs; - const onPointerDown = (event: PointerEvent) => { - if (event.button !== 0 || width === 0) return; - const x = event.clientX - event.currentTarget.getBoundingClientRect().left; - const ms = scale.ms(x); - const inside = ms >= view.fromMs && ms <= view.toMs; - const next = inside ? view : panLoomView(view, bounds, ms - span / 2 - view.fromMs); - if (!inside) props.onViewChange(next); - drag.current = { x: event.clientX, view: next }; - event.currentTarget.setPointerCapture(event.pointerId); - }; - const onPointerMove = (event: PointerEvent) => { - const start = drag.current; - if (start === null) return; - props.onViewChange(panLoomView(start.view, bounds, (event.clientX - start.x) * scale.msPerPx)); - }; - const onPointerUp = () => { - drag.current = null; - }; - const onKeyDown = (event: KeyboardEvent) => { - if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return; - event.preventDefault(); - props.onViewChange( - panLoomView(view, bounds, (event.key === "ArrowLeft" ? -1 : 1) * span * 0.1), - ); - }; - const viewX = scale.x(view.fromMs); - const viewWidth = Math.max(2, scale.x(view.toMs) - viewX); - - return ( -
- {width === 0 ? null : ( - - {strands.map((strand) => ( - - ))} - {strands.map((strand) => - strand.active === "" ? null : ( - - ), - )} - - - )} -
- ); -} diff --git a/apps/web/src/components/loom/LoomTimeline.tsx b/apps/web/src/components/loom/LoomTimeline.tsx deleted file mode 100644 index 655b8f77c1947..0000000000000 --- a/apps/web/src/components/loom/LoomTimeline.tsx +++ /dev/null @@ -1,518 +0,0 @@ -import type { LoomLaneActivity, LoomRow } from "@t3tools/client-runtime/projectLoom"; -import { loomEdgeLabel, loomMarkerLabel } from "@t3tools/client-runtime/projectLoom"; -import type { LoomEdge, LoomMarker, LoomRun, LoomWindow } from "@t3tools/contracts"; -import { BotIcon, ChevronDownIcon, ChevronRightIcon, GitForkIcon, UnlinkIcon } from "lucide-react"; -import { useLayoutEffect, useRef, useState, type PointerEvent } from "react"; - -import { observeResize } from "~/lib/observeResize"; -import { cn } from "~/lib/utils"; -import { - ACTIVE_RUN, - formatLoomTick, - formatLoomTime, - loomScale, - loomTicks, - type LoomView, -} from "./loomScale"; -import type { LoomSelection } from "./loomSearch"; -import { loomMarkerKey } from "./loomWeave"; -import { useLoomViewGestures } from "./useLoomViewGestures"; - -const LOOM_ROW_HEIGHT = 28; -// Lane labels take up to this much, and never more than 40% of a narrow timeline. -const MAX_LABEL_WIDTH = 248; -const AXIS_HEIGHT = 24; -const OVERSCAN_ROWS = 6; -const MARK_SIZE = 4; - -const RUN_STATUS_CLASS: Record = { - preparing: "fill-primary/60", - queued: "fill-primary/60", - starting: "fill-primary/60", - running: "fill-primary/70", - waiting: "fill-warning/70", - completed: "fill-muted-foreground/45", - interrupted: "fill-warning/60", - cancelled: "fill-muted-foreground/30", - rolled_back: "fill-muted-foreground/30", - failed: "fill-destructive/70", -}; - -const PRE_EXECUTION_RUN = new Set(["preparing", "queued", "starting"]); - -export interface LoomTimelineProps { - readonly window: LoomWindow; - readonly rows: ReadonlyArray; - readonly activity: ReadonlyMap; - /** Visible lane for every loaded thread, including those hidden under a collapsed row. */ - readonly visibleRowOf: ReadonlyMap; - readonly view: LoomView; - readonly bounds: LoomView; - readonly selection: LoomSelection | null; - readonly onViewChange: (view: LoomView) => void; - readonly onSelect: (selection: LoomSelection) => void; - readonly onToggleCollapse: (threadId: string) => void; -} - -function useElementWidth() { - const ref = useRef(null); - const [width, setWidth] = useState(0); - // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- subscribes to this element's size through a ResizeObserver - useLayoutEffect(() => { - const element = ref.current; - if (element === null) return; - setWidth(element.getBoundingClientRect().width); - return observeResize(element, (entries) => { - const entry = entries.at(-1); - if (entry) setWidth(entry.contentRect.width); - }); - }, []); - return { ref, width }; -} - -export function LoomTimeline(props: LoomTimelineProps) { - const { ref: sizeRef, width: totalWidth } = useElementWidth(); - const scrollRef = useRef(null); - const [scroll, setScroll] = useState({ top: 0, height: 0 }); - const labelWidth = Math.min(MAX_LABEL_WIDTH, Math.round(totalWidth * 0.4)); - const timelineWidth = Math.max(totalWidth - labelWidth, 0); - const scale = loomScale(props.view, timelineWidth); - const asOfMs = Date.parse(props.window.asOf); - - // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- subscribes to the scroller's height through a ResizeObserver to window visible rows - useLayoutEffect(() => { - const element = scrollRef.current; - if (element === null) return; - const update = () => setScroll({ top: element.scrollTop, height: element.clientHeight }); - update(); - return observeResize(element, update); - }, []); - - const { - onPointerDown, - onPointerMove, - onPointerUp: endDrag, - } = useLoomViewGestures({ - wheelRef: scrollRef, - view: props.view, - bounds: props.bounds, - width: timelineWidth, - leftInset: labelWidth, - onViewChange: props.onViewChange, - }); - - const firstRow = Math.max(0, Math.floor(scroll.top / LOOM_ROW_HEIGHT) - OVERSCAN_ROWS); - const lastRow = Math.min( - props.rows.length, - Math.ceil((scroll.top + scroll.height) / LOOM_ROW_HEIGHT) + OVERSCAN_ROWS, - ); - const visibleRows = props.rows.slice(firstRow, lastRow); - const contentHeight = props.rows.length * LOOM_ROW_HEIGHT; - const inView = (fromMs: number, toMs: number) => - toMs >= props.view.fromMs && fromMs <= props.view.toMs; - const isSelected = (kind: LoomSelection["kind"], id: string) => - props.selection?.kind === kind && props.selection.id === id; - - const ticks = timelineWidth > 0 ? loomTicks(props.view, timelineWidth) : []; - const rowCenter = (index: number) => index * LOOM_ROW_HEIGHT + LOOM_ROW_HEIGHT / 2; - const edgePaths = props.window.edges.flatMap((edge) => { - const source = props.visibleRowOf.get(edge.sourceThreadId); - const target = props.visibleRowOf.get(edge.targetThreadId); - const atMs = Date.parse(edge.at); - if (source === undefined || target === undefined || source === target) return []; - if (!inView(atMs, atMs)) return []; - return [{ edge, x: scale.x(atMs), y1: rowCenter(source), y2: rowCenter(target) }]; - }); - - return ( -
-
-
- {props.rows.length} {props.rows.length === 1 ? "lane" : "lanes"} -
- - {ticks.map((tick) => ( - - {formatLoomTick(tick, props.view)} - - ))} - -
-
- setScroll({ - top: event.currentTarget.scrollTop, - height: event.currentTarget.clientHeight, - }) - } - > -
- - {ticks.map((tick) => ( - - ))} - - {visibleRows.map((row, offset) => { - const index = firstRow + offset; - return ( - - edge.sourceThreadId === row.threadId && edge.targetThreadId === row.threadId, - )} - scale={scale} - asOfMs={asOfMs} - inView={inView} - isSelected={isSelected} - onSelect={props.onSelect} - onToggleCollapse={props.onToggleCollapse} - onPointerDown={onPointerDown} - onPointerMove={onPointerMove} - onPointerUp={endDrag} - /> - ); - })} - - {edgePaths.map(({ edge, x, y1, y2 }) => { - const bend = Math.min(48, Math.abs(y2 - y1) / 2); - return ( - props.onSelect({ kind: "edge", id: edge.id })} - > - {`${loomEdgeLabel(edge)} · ${formatLoomTime(edge.at)}`} - - ); - })} - -
-
-
- ); -} - -const RELATIONSHIP_ICON = { fork: GitForkIcon, subagent: BotIcon } as const; - -function LoomLaneRow(props: { - readonly row: LoomRow; - readonly top: number; - readonly labelWidth: number; - readonly timelineWidth: number; - readonly activity: LoomLaneActivity | undefined; - readonly sameLaneEdges: ReadonlyArray; - readonly scale: ReturnType; - readonly asOfMs: number; - readonly inView: (fromMs: number, toMs: number) => boolean; - readonly isSelected: (kind: LoomSelection["kind"], id: string) => boolean; - readonly onSelect: (selection: LoomSelection) => void; - readonly onToggleCollapse: (threadId: string) => void; - readonly onPointerDown: (event: PointerEvent) => void; - readonly onPointerMove: (event: PointerEvent) => void; - readonly onPointerUp: () => void; -}) { - const { row, scale } = props; - const RelationshipIcon = row.relationship === "root" ? null : RELATIONSHIP_ICON[row.relationship]; - const laneSelected = props.isSelected("lane", row.threadId); - const mid = LOOM_ROW_HEIGHT / 2; - return ( -
-
- {row.childCount > 0 ? ( - - ) : ( - - )} - {RelationshipIcon === null ? null : ( - - )} - {row.parentMissing ? ( - - ) : null} - - {row.hiddenCount > 0 ? ( - - +{row.hiddenCount} - - ) : null} -
- - {(props.activity?.runs ?? []).map((run) => ( - - ))} - {props.sameLaneEdges.map((edge) => { - const atMs = Date.parse(edge.at); - if (!props.inView(atMs, atMs)) return null; - const x = scale.x(atMs); - return ( - props.onSelect({ kind: "edge", id: edge.id })} - > - {`${loomEdgeLabel(edge)} · ${formatLoomTime(edge.at)}`} - - ); - })} - {(props.activity?.markers ?? []).map((marker) => { - const atMs = Date.parse(marker.at); - if (!props.inView(atMs, atMs)) return null; - const key = loomMarkerKey(marker); - return ( - props.onSelect({ kind: "marker", id: key })} - /> - ); - })} - -
- ); -} - -function LoomRunGlyph(props: { - readonly run: LoomRun; - readonly scale: ReturnType; - readonly asOfMs: number; - readonly mid: number; - readonly inView: (fromMs: number, toMs: number) => boolean; - readonly selected: boolean; - readonly onSelect: (selection: LoomSelection) => void; -}) { - const { run, scale, mid } = props; - const requestedMs = Date.parse(run.requestedAt); - const startedMs = run.startedAt === null ? null : Date.parse(run.startedAt); - const active = ACTIVE_RUN.has(run.status); - // An active run is drawn to the as-of time; a finished run with no recorded end gets no span. - const endMs = - run.completedAt !== null ? Date.parse(run.completedAt) : active ? props.asOfMs : null; - // Without a recorded start, only a pre-execution status proves the whole span was queue time. - const queueEndMs = startedMs ?? (PRE_EXECUTION_RUN.has(run.status) ? endMs : null); - if (!props.inView(requestedMs, endMs ?? requestedMs)) return null; - const label = `Run ${run.ordinal}, ${run.status}, requested ${formatLoomTime(run.requestedAt)}${run.startedAt === null ? ", start unknown" : `, started ${formatLoomTime(run.startedAt)}`}${run.completedAt === null ? "" : `, completed ${formatLoomTime(run.completedAt)}`}`; - const select = () => props.onSelect({ kind: "run", id: run.id }); - const xRequested = scale.x(requestedMs); - return ( - - {label} - {queueEndMs !== null && queueEndMs > requestedMs ? ( - - ) : null} - {startedMs === null && queueEndMs === null && endMs !== null && endMs > requestedMs ? ( - - ) : null} - {startedMs !== null && endMs !== null ? ( - - ) : null} - - - ); -} - -function LoomMarkerGlyph(props: { - readonly marker: LoomMarker; - readonly x: number; - readonly y: number; - readonly selected: boolean; - readonly onSelect: () => void; -}) { - const { marker, x, y } = props; - const label = `${loomMarkerLabel(marker)}, ${formatLoomTime(marker.at)}`; - const ring = props.selected ? "stroke-foreground" : "stroke-background"; - const ringWidth = props.selected ? 1.5 : 1; - const shape = (() => { - switch (marker.kind) { - case "checkpoint": - return ( - - ); - case "pull_request": - return ( - - ); - case "test_command": - return ( - - ); - case "subagent_task": - return ( - - ); - } - })(); - return ( - - {label} - {shape} - - ); -} diff --git a/apps/web/src/components/loom/LoomWeaveField.tsx b/apps/web/src/components/loom/LoomWeaveField.tsx deleted file mode 100644 index 3b43c64c48574..0000000000000 --- a/apps/web/src/components/loom/LoomWeaveField.tsx +++ /dev/null @@ -1,766 +0,0 @@ -import type { LoomWindow } from "@t3tools/contracts"; -import { useLayoutEffect, useRef, useState } from "react"; - -import { observeResize } from "~/lib/observeResize"; -import { cn } from "~/lib/utils"; -import { - branchLayout, - branchSegments, - branchView, - workstreamNodes, - type BranchLayout, - type BranchNode, - type BranchStrandPath, -} from "./loomBranch"; -import { WEAVE_KINDS, workstreamColor, type WeaveEventKind } from "./loomKinds"; -import { - formatLoomClock, - formatLoomTick, - formatLoomTime, - loomScale, - loomTicks, - type LoomView, -} from "./loomScale"; -import type { LoomSelection } from "./loomSearch"; -import { - cullGlyphs, - dotPaths, - glyphCandidates, - GLYPH_RADIUS, - spindleGeometry, - spindleLayout, - spindleY, - strandDots, - strandPath, - type Glyph, - type SpindleGeometry, - type SpindleLayout, -} from "./loomSpindle"; -import { - loomSelectionThread, - strandColors, - type StrandEnd, - type Weave, - type WeaveStrand, - type Workstream, -} from "./loomWeave"; -import { useLoomViewGestures } from "./useLoomViewGestures"; - -const AXIS_HEIGHT = 24; -const INFERRED_DASH = "5 4"; - -export interface LoomWeaveFieldProps { - readonly window: LoomWindow; - readonly weave: Weave; - readonly mode: "workstream" | "agent"; - readonly spine: Workstream | null; - readonly view: LoomView; - readonly bounds: LoomView; - readonly selection: LoomSelection | null; - readonly onViewChange: (view: LoomView) => void; - readonly onSelect: (selection: LoomSelection) => void; - readonly onOpenWorkstream: (rootId: string) => void; -} - -function useElementSize() { - const ref = useRef(null); - const [size, setSize] = useState({ width: 0, height: 0 }); - // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- subscribes to this element's size through a ResizeObserver - useLayoutEffect(() => { - const element = ref.current; - if (element === null) return; - const rect = element.getBoundingClientRect(); - setSize({ width: rect.width, height: rect.height }); - return observeResize(element, (entries) => { - const entry = entries.at(-1); - if (entry) setSize({ width: entry.contentRect.width, height: entry.contentRect.height }); - }); - }, []); - return { ref, ...size }; -} - -/** The weave: the workstream spindle, or one workstream's branching execution. */ -export function LoomWeaveField(props: LoomWeaveFieldProps) { - const { ref, width, height } = useElementSize(); - const scale = loomScale(props.view, width); - const gestures = useLoomViewGestures({ - wheelRef: ref, - view: props.view, - bounds: props.bounds, - width, - leftInset: 0, - onViewChange: props.onViewChange, - }); - const asOfMs = props.weave.asOfMs; - const ticks = width > 0 ? loomTicks(props.view, width) : []; - const selectedThread = loomSelectionThread(props.window, props.selection); - const layer = { - ...props, - width, - height, - scale, - selectedThread, - svgProps: { - onPointerDown: gestures.onPointerDown, - onPointerMove: gestures.onPointerMove, - onPointerUp: gestures.onPointerUp, - onPointerCancel: gestures.onPointerUp, - className: "cursor-grab active:cursor-grabbing", - }, - }; - - return ( -
- - {ticks.map((tick) => ( - - {formatLoomTick(tick, props.view)} - - ))} - {asOfMs >= props.view.fromMs && asOfMs <= props.view.toMs ? ( - - Now - - ) : null} - -
- {width === 0 ? null : props.mode === "workstream" ? ( - - ) : props.spine === null ? null : ( - - )} -
-
- ); -} - -interface LayerProps extends LoomWeaveFieldProps { - readonly width: number; - readonly height: number; - readonly scale: ReturnType; - readonly selectedThread: string | null; - readonly svgProps: Record; -} - -/** Anchors and labels keep this far from the field's edges so they are never half drawn. */ -const EDGE = 10; - -function SpindleLayer(props: LayerProps) { - const { weave, spine, scale, width, height } = props; - const layout = spindleLayout(weave, spine, height); - const geometry = spindleGeometry(width, height, scale.x(weave.startMs), scale.x(weave.tailMs)); - const colors = strandColors(weave, spine); - const drawn = spindlePaths(weave, layout, geometry, colors, scale.x, width); - const tailInView = geometry.tailX <= width; - const tailX = Math.min(geometry.tailX, width - EDGE); - const startInView = geometry.startX >= 0; - const startX = Math.max(geometry.startX, EDGE); - const strandCount = layout.homeY.size; - // Labels live left of the start anchor; once the view pans that gutter away they sit over the - // strands on a backdrop. - const labelWidth = Math.min(256, Math.max(200, startX - 36)); - const bandLabel = (workstream: Workstream, top: number, onSpine: boolean) => ( - - ); - - return ( - <> - - {drawn.warps.map((warp) => ( - - ))} - {drawn.actives.map((active) => { - const selected = active.threadId === props.selectedThread; - const onSpine = active.threadId === spine?.rootId; - const stroke = onSpine ? "stroke-foreground" : workstreamColor(active.colorIndex).stroke; - return ( - - {onSpine || selected ? : null} - props.onOpenWorkstream(active.rootId)} - > - {active.title} - - - ); - })} - {drawn.births.map((birth) => ( - - ))} - {[...drawn.dots].map(([kind, d]) => ( - - ))} - {drawn.glyphs.map((glyph) => ( - props.onSelect(glyph.selection)} - /> - ))} - - {startInView ? ( - <> - - - {strandCount} {strandCount === 1 ? "strand" : "strands"} - - - {formatLoomClock(weave.startMs)} - - - ) : null} - {tailInView ? ( - <> - - - Loaded tail - - - {formatLoomClock(weave.tailMs)} - - - ) : null} - - - {spine === null ? null : bandLabel(spine, layout.cy + 12, true)} - {layout.bands.map((band) => bandLabel(band.workstream, band.y - 18, false))} - - ); -} - -function spindlePaths( - weave: Weave, - layout: SpindleLayout, - geometry: SpindleGeometry, - colors: ReadonlyMap, - x: (ms: number) => number, - width: number, -) { - const warps: Array<{ threadId: string; colorIndex: number; d: string }> = []; - const actives: Array<{ - threadId: string; - rootId: string; - title: string; - workstreamTitle: string; - colorIndex: number; - d: string; - }> = []; - const births: Array<{ threadId: string; colorIndex: number; x: number; y: number }> = []; - const dots: Array[number]> = []; - const candidates: Glyph[] = []; - const startX = Math.max(geometry.startX, 0); - const tailX = Math.min(geometry.tailX, width); - for (const workstream of weave.workstreams) { - for (const strand of workstream.strands) { - const homeY = layout.homeY.get(strand.threadId); - const colorIndex = colors.get(strand.threadId) ?? workstream.colorIndex; - if (homeY === undefined) { - // A quiet child has no strand; its spawn marks its parent's. - const parentY = - strand.parentThreadId === null ? undefined : layout.homeY.get(strand.parentThreadId); - const at = strand.spawn === null ? null : x(strand.spawn.ms); - if (parentY !== undefined && at !== null && at >= startX && at <= tailX) { - births.push({ - threadId: strand.threadId, - colorIndex, - x: at, - y: spindleY(geometry, parentY, at), - }); - } - continue; - } - const y = (px: number) => spindleY(geometry, homeY, px); - warps.push({ - threadId: strand.threadId, - colorIndex, - d: strandPath(geometry, homeY, startX, tailX), - }); - if (strand.extent === null) continue; - const fromX = Math.max(x(strand.extent.startMs), startX); - const toX = Math.min(x(strand.extent.endMs), tailX); - const d = strandPath(geometry, homeY, fromX, toX); - if (d.length > 0) { - actives.push({ - threadId: strand.threadId, - rootId: workstream.rootId, - title: strand.title, - workstreamTitle: workstream.title, - colorIndex, - d, - }); - } else if (toX > fromX - 1 && fromX <= tailX) { - births.push({ threadId: strand.threadId, colorIndex, x: fromX, y: y(fromX) }); - } - dots.push(...strandDots(strand.events, x, y, width)); - candidates.push(...glyphCandidates(strand, x, y, width)); - } - } - return { warps, actives, births, dots: dotPaths(dots), glyphs: cullGlyphs(candidates) }; -} - -/** A static glow: the same line drawn wide and faint underneath. */ -function Halo(props: { readonly d: string; readonly stroke: string }) { - return ( - - ); -} - -function Anchor(props: { readonly x: number; readonly y: number }) { - return ( - - - - - - ); -} - -const NODE_RADIUS = 9; -const LANE_LABEL_WIDTH = 220; -const END_STYLE: Record = { - completed: "fill-current stroke-current", - failed: "fill-destructive stroke-destructive", - interrupted: "fill-card stroke-muted-foreground", - active: "fill-card stroke-current", - unknown: "fill-card stroke-current", -}; - -function BranchLayer( - props: LayerProps & { - readonly spine: Workstream; - readonly scrollRef: React.RefObject; - }, -) { - const { spine, scale, width, height } = props; - // Lanes pack at the zoom Agent mode opens with, so they stay put while the view zooms. - const layout = branchLayout( - spine, - loomScale(branchView(spine, props.bounds, props.weave.tailMs), width).msPerPx, - ); - const offsetY = Math.max(0, (height - layout.height) / 2); - const svgHeight = Math.max(height, layout.height); - const nodes = workstreamNodes(spine, layout, scale.x, width); - const colors = strandColors(props.weave, spine); - const paths = spine.strands.flatMap((strand) => { - const path = branchSegments(strand, layout, scale.x); - return path === null ? [] : [{ strand, path }]; - }); - const labelRoom = laneLabelRoom(paths, layout, width); - const rootY = layout.cy; - const { scrollRef } = props; - // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- sets the DOM scroll position so a tall workstream opens on its root, not its topmost branch - useLayoutEffect(() => { - const element = scrollRef.current; - if (element !== null) element.scrollTop = Math.max(0, rootY - element.clientHeight / 2); - }, [scrollRef, rootY]); - - return ( - <> - - - {paths.map(({ strand, path }) => ( - props.onSelect({ kind: "lane", id: strand.threadId })} - /> - ))} - {[...nodes.values()].flatMap((list) => - list.map((node) => ( - props.onSelect(node.selection)} - /> - )), - )} - - - {paths.map(({ strand, path }) => { - const y = layout.laneY.get(strand.threadId); - const room = labelRoom.get(strand.threadId); - if (y === undefined || room === undefined) return null; - const left = Math.max(path.laneX, EDGE); - const color = workstreamColor(colors.get(strand.threadId) ?? spine.colorIndex); - return ( - - ); - })} - - ); -} - -/** - * How wide each strand's title may be: up to the next strand that starts on the same lane, or the - * field's edge. Titles with too little room are left off; the strand still names itself on hover. - */ -function laneLabelRoom( - paths: ReadonlyArray<{ readonly strand: WeaveStrand; readonly path: BranchStrandPath }>, - layout: BranchLayout, - width: number, -): ReadonlyMap { - const byLane = new Map>(); - for (const { strand, path } of paths) { - const y = layout.laneY.get(strand.threadId)!; - const lane = byLane.get(y) ?? []; - lane.push({ threadId: strand.threadId, left: Math.max(path.laneX, EDGE), endX: path.endX }); - byLane.set(y, lane); - } - const room = new Map(); - for (const lane of byLane.values()) { - lane.sort((a, b) => a.left - b.left); - lane.forEach((entry, index) => { - if (entry.endX < 0) return; - const limit = Math.min(lane[index + 1]?.left ?? width, width) - 8; - const available = Math.min(LANE_LABEL_WIDTH, limit - entry.left); - if (available >= 48) room.set(entry.threadId, available); - }); - } - return room; -} - -function BranchStrand(props: { - readonly strand: WeaveStrand; - readonly path: BranchStrandPath; - readonly layout: BranchLayout; - readonly color: ReturnType; - readonly root: boolean; - readonly selected: boolean; - readonly onSelect: () => void; -}) { - const { strand, path, layout } = props; - const y = layout.laneY.get(strand.threadId)!; - const parentY = - strand.parentThreadId === null ? null : (layout.laneY.get(strand.parentThreadId) ?? null); - const segments = path.segments; - return ( - - {props.root || props.selected - ? segments.map((segment) => ( - - )) - : null} - {segments.map((segment, index) => ( - - ))} - {strand.rejoin !== null && parentY !== null ? null : strand.end === "failed" ? ( - - ) : ( - - {END_LABEL[strand.end]} - - )} - - ); -} - -const END_LABEL: Record = { - completed: "Completed", - failed: "Failed", - interrupted: "Interrupted", - active: "Still running", - unknown: "No run recorded", -}; - -function BranchEventNode(props: { - readonly node: BranchNode; - readonly selected: boolean; - readonly onSelect: () => void; -}) { - const { node } = props; - const detail = node.detail ?? (node.count > 1 ? `${node.count} events` : null); - return ( - <> - - {node.labelled ? ( - - - {WEAVE_KINDS[node.kind].label} - - - {formatLoomClock(node.ms)} - - {detail === null ? null : ( - - {detail.length > 12 ? `${detail.slice(0, 11)}…` : detail} - - )} - - ) : null} - - ); -} - -function glyphLabel(kind: WeaveEventKind, ms: number, detail: string | null) { - return `${WEAVE_KINDS[kind].label}, ${formatLoomTime(new Date(ms).toISOString())}${detail === null ? "" : `, ${detail}`}`; -} - -function isSelected(selection: LoomSelection | null, target: LoomSelection) { - return selection?.kind === target.kind && selection.id === target.id; -} - -function GlyphNode(props: { - readonly x: number; - readonly y: number; - readonly kind: WeaveEventKind; - readonly failed: boolean; - readonly inferred: boolean; - readonly radius: number; - readonly label: string; - readonly selected: boolean; - readonly count?: number; - readonly ring?: boolean; - readonly onClick: () => void; -}) { - const style = WEAVE_KINDS[props.kind]; - const Icon = style.icon; - const r = props.radius; - const iconSize = Math.round(r * 1.25); - return ( - - {props.label} - {props.failed ? ( - - ) : ( - - )} - {props.ring ? ( - - ) : null} - - {props.count !== undefined && props.count > 1 ? ( - - ) : null} - - ); -} - -/** Sits off the node's upper right, clear of its circle, so the icon stays readable. */ -function CountBadge(props: { readonly r: number; readonly count: number }) { - const text = props.count > 999 ? "999+" : String(props.count); - const width = 6 + text.length * 5; - const corner = props.r * Math.SQRT1_2 + 1; - return ( - - - - {text} - - - ); -} - -function Octagon(props: { - readonly x: number; - readonly y: number; - readonly radius: number; - readonly className?: string; -}) { - const { x, y, radius: r } = props; - const k = r * Math.tan(Math.PI / 8); - const d = `M${x - k} ${y - r}H${x + k}L${x + r} ${y - k}V${y + k}L${x + k} ${y + r}H${x - k}L${x - r} ${y + k}V${y - k}Z`; - return ( - - ); -} diff --git a/apps/web/src/components/loom/ProjectLoomPage.browser.test.tsx b/apps/web/src/components/loom/ProjectLoomPage.browser.test.tsx deleted file mode 100644 index 4749c49fbdc28..0000000000000 --- a/apps/web/src/components/loom/ProjectLoomPage.browser.test.tsx +++ /dev/null @@ -1,471 +0,0 @@ -import "../../index.css"; -import { RegistryContext } from "@effect/atom-react"; -import { - CheckpointId, - EnvironmentId, - MessageId, - NodeId, - ProjectId, - ProviderInstanceId, - RunId, - ThreadId, - type LoomWindow, - type LoomWindowInput, -} from "@t3tools/contracts"; -import { Atom, AtomRegistry, AsyncResult } from "effect/reactivity"; -import { useState } from "react"; -import { expect, it, vi } from "vite-plus/test"; -import { page, userEvent } from "vite-plus/test/browser"; -import { render } from "../../../test/render"; -import { useDiffPanelStore } from "../../diffPanelStore"; -import type { LoomSearch } from "./loomSearch"; - -const state = vi.hoisted(() => ({ - config: null as unknown, - windows: new Map(), - respond: (_input: unknown): unknown => null, - environments: [] as string[], -})); -vi.mock( - import("../../state/server"), - () => ({ serverEnvironment: { configValueAtom: () => state.config } }) as never, -); -vi.mock( - import("../../state/projectLoom"), - () => - ({ - projectLoom: { - window: (target: { input: unknown }) => { - const key = JSON.stringify(target.input); - if (!state.windows.has(key)) state.windows.set(key, state.respond(target.input)); - return state.windows.get(key); - }, - }, - }) as never, -); -vi.mock(import("../../state/entities"), () => ({ useProject: () => null }) as never); -vi.mock( - import("../../state/environments"), - () => - ({ - useEnvironmentIdentities: () => - state.environments.map((environmentId) => ({ environmentId, label: environmentId })), - }) as never, -); -vi.mock( - import("../../lib/openPullRequestLink"), - () => ({ useOpenPrLink: () => () => false }) as never, -); - -import { ProjectLoomPage } from "./ProjectLoomPage"; - -const environmentId = EnvironmentId.make("loom-browser"); -const projectId = ProjectId.make("project"); -const instanceId = ProviderInstanceId.make("codex"); -const from = "2026-10-16T00:00:00.000Z"; -const to = "2026-10-23T00:00:00.000Z"; -const rootId = ThreadId.make("root-thread"); -const childId = ThreadId.make("child-thread"); -const otherId = ThreadId.make("billing-thread"); -const runId = RunId.make("root-run"); - -const lane = (threadId: ThreadId, title: string, parent: ThreadId | null) => ({ - threadId, - title, - relationship: parent === null ? ("root" as const) : ("subagent" as const), - parentThreadId: parent, - providerInstanceId: instanceId, - model: "gpt-5", - branch: "main", - createdAt: "2026-10-20T00:00:00.000Z", - archivedAt: null, -}); - -const fullWindow: LoomWindow = { - projectId, - asOf: to, - from, - to, - lanes: [ - lane(rootId, "Fix login", null), - lane(childId, "Explore auth module", rootId), - lane(otherId, "Add billing", null), - ], - ancestors: [], - runs: [ - { - id: runId, - threadId: rootId, - ordinal: 1, - status: "completed", - providerInstanceId: instanceId, - model: "gpt-5", - userMessageId: MessageId.make("message"), - requestedAt: "2026-10-20T10:00:00.000Z", - startedAt: "2026-10-20T10:01:00.000Z", - completedAt: "2026-10-20T12:00:00.000Z", - }, - ], - markers: [ - { - kind: "checkpoint", - id: CheckpointId.make("checkpoint"), - threadId: rootId, - runId, - at: "2026-10-20T12:00:00.000Z", - status: "ready", - fileCount: 2, - additions: 13, - deletions: 1, - }, - ], - edges: [], - activity: [], - truncated: { - lanes: false, - ancestors: false, - runs: true, - markers: false, - edges: false, - activity: false, - bytes: false, - }, - coverage: { - untimedTasks: 0, - untimedTasksTruncated: false, - commandsScanned: 0, - commandsTruncated: false, - }, - nextCursor: null, -}; - -function setup() { - state.environments = [environmentId, "build-server", "origin-server"]; - state.config = Atom.make({ environment: { capabilities: { projectLoom: true } } }); - state.windows.clear(); - state.respond = (input) => { - const title = (input as LoomWindowInput).filters?.title?.toLowerCase(); - const window = - title === undefined - ? fullWindow - : { - ...fullWindow, - lanes: fullWindow.lanes.filter((entry) => entry.title.toLowerCase().includes(title)), - }; - return Atom.make(AsyncResult.success(window)); - }; - useDiffPanelStore.setState({ byThreadKey: {} }); - return AtomRegistry.make(); -} - -function LoomHarness(props: { readonly initialSearch?: LoomSearch }) { - const [search, setSearch] = useState(props.initialSearch ?? { from, to }); - return ( - setSearch(update)} - /> - ); -} - -const lanes = () => page.getByTestId("loom-lane"); - -it("nests and collapses lanes, filters them, notes limits, and opens a checkpoint's diff", async () => { - const registry = setup(); - await using screen = await render( - - - , - { router: "/loom/loom-browser/project" }, - ); - - await expect.element(page.getByRole("button", { name: "Explore auth module" })).toBeVisible(); - await expect.element(page.getByRole("status")).toHaveTextContent("Limited: older runs."); - expect( - lanes() - .elements() - .map((element) => element.dataset.threadId), - ).toEqual([rootId, childId, otherId]); - - await userEvent.click(page.getByRole("button", { name: "Collapse Fix login" })); - await expect - .element(page.getByRole("button", { name: "Explore auth module" })) - .not.toBeInTheDocument(); - await expect.element(page.getByText("+1")).toBeVisible(); - await userEvent.click(page.getByRole("button", { name: "Expand Fix login" })); - await expect.element(page.getByRole("button", { name: "Explore auth module" })).toBeVisible(); - - await userEvent.fill(page.getByRole("textbox", { name: "Filter by title" }), "billing"); - await vi.waitFor(() => expect(lanes().elements()).toHaveLength(1)); - expect(lanes().elements()[0]?.dataset.threadId).toBe(otherId); - await userEvent.click(page.getByRole("button", { name: "Clear" })); - await vi.waitFor(() => expect(lanes().elements()).toHaveLength(3)); - - await userEvent.click( - lanes() - .first() - .getByRole("button", { name: /^Checkpoint: 2 files/ }), - ); - await expect - .element(page.getByRole("complementary").getByText("Fix login", { exact: true })) - .toBeVisible(); - await userEvent.click(page.getByRole("button", { name: "Open diff" })); - await vi.waitFor(() => - expect(screen.router?.state.location.pathname).toBe(`/${environmentId}/${rootId}`), - ); - expect(Object.values(useDiffPanelStore.getState().byThreadKey)).toContainEqual( - expect.objectContaining({ kind: "turn", turnId: runId }), - ); -}); - -async function renderLoom(registry: AtomRegistry.AtomRegistry, initialSearch?: LoomSearch) { - return render( - - - , - { router: "/loom/loom-browser/project" }, - ); -} - -it("opens a remote task in its destination even when its id equals a local thread", async () => { - const registry = setup(); - const destination = EnvironmentId.make("build-server"); - state.respond = () => - Atom.make( - AsyncResult.success({ - ...fullWindow, - markers: [ - { - kind: "subagent_task", - id: NodeId.make("remote-task"), - threadId: rootId, - runId: null, - at: "2026-10-20T11:00:00.000Z", - completedAt: null, - status: "running", - title: "Build", - remoteChild: { environmentId: destination, threadId: rootId, label: "Build server" }, - }, - ], - } satisfies LoomWindow), - ); - await using screen = await renderLoom(registry, { - from, - to, - mode: "lanes", - sel: "marker:subagent_task:remote-task", - }); - await expect - .element( - page - .getByRole("list", { name: "Events" }) - .getByText("Agent task on Build server: Build (running)", { exact: true }), - ) - .toBeVisible(); - await userEvent.click(page.getByRole("button", { name: "Open remote task" })); - await vi.waitFor(() => - expect(screen.router?.state.location.pathname).toBe(`/${destination}/${rootId}`), - ); -}); - -it("opens the parent of a remotely delegated lane in the origin environment", async () => { - const registry = setup(); - const origin = EnvironmentId.make("origin-server"); - state.respond = () => - Atom.make( - AsyncResult.success({ - ...fullWindow, - lanes: fullWindow.lanes.map((entry) => - entry.threadId === rootId - ? { - ...entry, - relationship: "subagent", - delegatedFrom: { - environmentId: origin, - threadId: rootId, - title: "Remote parent", - }, - } - : entry, - ), - } satisfies LoomWindow), - ); - await using screen = await renderLoom(registry, { - from, - to, - mode: "lanes", - sel: `lane:${rootId}`, - }); - await userEvent.click( - page.getByRole("button", { name: "Open parent thread Remote parent on origin-server" }), - ); - await vi.waitFor(() => - expect(screen.router?.state.location.pathname).toBe(`/${origin}/${rootId}`), - ); -}); - -it("keeps a remote task visible and explains why an unconnected destination cannot open", async () => { - const registry = setup(); - state.environments = [environmentId]; - state.respond = () => - Atom.make( - AsyncResult.success({ - ...fullWindow, - markers: [ - { - kind: "subagent_task", - id: NodeId.make("remote-task"), - threadId: rootId, - runId: null, - at: "2026-10-20T11:00:00.000Z", - completedAt: null, - status: "running", - title: "Build", - remoteChild: { - environmentId: EnvironmentId.make("build-server"), - threadId: rootId, - label: "Build server", - }, - }, - ], - } satisfies LoomWindow), - ); - await using _screen = await renderLoom(registry, { - from, - to, - mode: "lanes", - sel: "marker:subagent_task:remote-task", - }); - await expect.element(page.getByRole("button", { name: "Open remote task" })).toBeDisabled(); - await expect - .element(page.getByRole("button", { name: "Open thread", exact: true })) - .toBeEnabled(); - await expect - .element(page.getByText("Connect to Build server to open this thread.")) - .toBeVisible(); -}); - -it("pages older lanes of the default window within the bounds that produced the cursor", async () => { - const registry = setup(); - const requests: Array = []; - state.respond = (input) => { - requests.push(input as LoomWindowInput); - const firstPage = (input as LoomWindowInput).cursor === undefined; - return Atom.make( - AsyncResult.success({ ...fullWindow, nextCursor: firstPage ? "page-2" : null }), - ); - }; - await using _screen = await renderLoom(registry, {}); - - await userEvent.click(page.getByRole("button", { name: "Older lanes" })); - await vi.waitFor(() => expect(requests).toHaveLength(2)); - expect(requests[0]).toEqual({ projectId }); - expect(requests[1]).toEqual({ projectId, from, to, cursor: "page-2" }); -}); - -it("notes skipped command scans and untimed tasks", async () => { - const registry = setup(); - state.respond = () => - Atom.make( - AsyncResult.success({ - ...fullWindow, - truncated: { ...fullWindow.truncated, runs: false }, - coverage: { - untimedTasks: 3, - untimedTasksTruncated: false, - commandsScanned: 4000, - commandsTruncated: true, - }, - }), - ); - await using _screen = await renderLoom(registry); - - await expect - .element(page.getByRole("status")) - .toHaveTextContent( - "Limited: test commands from the newest 4000 commands only, 3 agent tasks with no recorded time, not shown.", - ); -}); - -it("draws queue time only when the run's start is recorded or it has not started", async () => { - const registry = setup(); - const unstarted = (status: "completed" | "queued", ordinal: number) => ({ - ...fullWindow.runs[0]!, - id: RunId.make(`run-${status}`), - ordinal, - status, - startedAt: null, - completedAt: status === "completed" ? "2026-10-21T12:00:00.000Z" : null, - }); - state.respond = () => - Atom.make( - AsyncResult.success({ - ...fullWindow, - runs: [unstarted("completed", 2), unstarted("queued", 3)], - }), - ); - await using _screen = await renderLoom(registry, { from, to, mode: "lanes" }); - - const queueLine = (name: RegExp) => - page.getByRole("button", { name }).first().element().querySelector("line[stroke-dasharray]"); - await expect.element(page.getByRole("button", { name: /^Run 2, completed/ })).toBeVisible(); - expect(queueLine(/^Run 2, completed/)).toBeNull(); - expect( - page - .getByRole("button", { name: /^Run 2, completed/ }) - .element() - .querySelector('line[data-timing="unknown"]'), - ).not.toBeNull(); - expect(queueLine(/^Run 3, queued/)).not.toBeNull(); -}); - -it("opens a workstream from the navigator in Agent mode and comes back to the overview", async () => { - const registry = setup(); - // Spawned after the root's run, so the fitted view frames the spawn apart from the checkpoint. - const spawnedLater = { - ...fullWindow, - lanes: fullWindow.lanes.map((entry) => - entry.threadId === childId ? { ...entry, createdAt: "2026-10-21T12:00:00.000Z" } : entry, - ), - }; - state.respond = () => Atom.make(AsyncResult.success(spawnedLater)); - await using _screen = await renderLoom(registry); - - await expect - .element(page.getByRole("button", { name: "Workstream" })) - .toHaveAttribute("aria-pressed", "true"); - const navigator = page.getByRole("complementary", { name: "Loom navigator" }); - await expect - .element(navigator.getByRole("textbox", { name: "Search workstreams" })) - .toBeVisible(); - await userEvent.click(navigator.getByRole("button", { name: /^Fix login/ })); - - await expect - .element(page.getByRole("button", { name: "Agent" })) - .toHaveAttribute("aria-pressed", "true"); - await expect.element(page.getByTestId("loom-weave")).toHaveAttribute("data-mode", "agent"); - await expect - .element(navigator.getByRole("button", { name: /^Fix login/ })) - .toHaveAttribute("aria-current", "true"); - await expect - .element(page.getByRole("button", { name: /^Spawn, .*Explore auth module$/ })) - .toBeVisible(); - - await userEvent.click(page.getByRole("button", { name: /^Checkpoint, .*2 files/ })); - const events = page.getByRole("complementary", { name: "Loom events" }); - await expect.element(events.getByText("Fix login", { exact: true })).toBeVisible(); - await expect.element(events.getByRole("button", { name: "Open diff" })).toBeVisible(); - await userEvent.click(events.getByRole("button", { name: "Back to navigator" })); - await expect - .element(navigator.getByRole("textbox", { name: "Search workstreams" })) - .toBeVisible(); - - await userEvent.click(page.getByRole("button", { name: "Overview" })); - await expect.element(page.getByTestId("loom-weave")).toHaveAttribute("data-mode", "workstream"); - - await userEvent.click(page.getByRole("button", { name: "Lanes" })); - await expect.element(page.getByRole("button", { name: "Explore auth module" })).toBeVisible(); - expect(page.getByTestId("loom-lane").elements()).toHaveLength(3); -}); diff --git a/apps/web/src/components/loom/ProjectLoomPage.tsx b/apps/web/src/components/loom/ProjectLoomPage.tsx deleted file mode 100644 index 7e20488c601b4..0000000000000 --- a/apps/web/src/components/loom/ProjectLoomPage.tsx +++ /dev/null @@ -1,639 +0,0 @@ -import { useAtomValue } from "@effect/atom-react"; -import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment"; -import { - buildLoomRows, - groupLoomActivity, - loomCoverageNotes, - loomTruncationNotes, -} from "@t3tools/client-runtime/projectLoom"; -import { - LOOM_DEFAULT_WINDOW_MS, - type EnvironmentId, - type LoomWindow, - type ProjectId, - type RunId, - type ThreadId, -} from "@t3tools/contracts"; -import { useNavigate } from "@tanstack/react-router"; -import { - ArrowLeftIcon, - ChevronLeftIcon, - ChevronRightIcon, - ListFilterIcon, - ScanIcon, - SearchIcon, -} from "lucide-react"; -import { useEffect, useState } from "react"; - -import { useDiffPanelStore } from "~/diffPanelStore"; -import { useStableCallback } from "~/hooks/useStableCallback"; -import { isElectron } from "~/env"; -import { useOpenPrLink } from "~/lib/openPullRequestLink"; -import { useRightPanelStore } from "~/rightPanelStore"; -import { useProject } from "~/state/entities"; -import { useEnvironmentIdentities } from "~/state/environments"; -import { projectLoom } from "~/state/projectLoom"; -import { useEnvironmentQuery } from "~/state/query"; -import { serverEnvironment } from "~/state/server"; -import { buildThreadRouteParams } from "~/threadRoutes"; -import { Button } from "../ui/button"; -import { Input } from "../ui/input"; -import { - Menu, - MenuCheckboxItem, - MenuGroup, - MenuGroupLabel, - MenuPopup, - MenuSeparator, - MenuTrigger, -} from "../ui/menu"; -import { RefreshIcon } from "../ui/refresh-icon"; -import { SidebarInset } from "../ui/sidebar"; -import { Toggle, ToggleGroup } from "../ui/toggle-group"; -import { - WorkspaceBreadcrumb, - WorkspaceBreadcrumbItem, - WorkspaceBreadcrumbSeparator, -} from "../WorkspaceBreadcrumb"; -import { WorkspacePageHeader } from "../WorkspacePageHeader"; -import { LoomBranchNavigator } from "./LoomBranchNavigator"; -import { LoomEventPanel, type LoomActions } from "./LoomEventPanel"; -import { LoomLegend } from "./LoomLegend"; -import { LoomMinimap } from "./LoomMinimap"; -import { fitLoomView, formatLoomTime, type LoomView } from "./loomScale"; -import { - decodeLoomSelection, - encodeLoomSelection, - LOOM_MODES, - LOOM_STATUS_OPTIONS, - loomFiltersOf, - type LoomMode, - type LoomSearch, - type LoomSearchPatch, - type LoomSelection, -} from "./loomSearch"; -import { LoomTimeline } from "./LoomTimeline"; -import { branchView } from "./loomBranch"; -import { spindleView } from "./loomSpindle"; -import { buildWeave, spineWorkstream } from "./loomWeave"; -import { LoomWeaveField } from "./LoomWeaveField"; - -const HOUR = 60 * 60 * 1000; -const WINDOW_PRESETS = [ - { label: "24h", ms: 24 * HOUR }, - { label: "7d", ms: LOOM_DEFAULT_WINDOW_MS }, - { label: "30d", ms: 30 * 24 * HOUR }, -] as const; - -export interface ProjectLoomPageProps { - readonly environmentId: EnvironmentId; - readonly projectId: ProjectId; - readonly search: LoomSearch; - readonly onSearchChange: (update: (previous: LoomSearch) => LoomSearch) => void; -} - -/** A project's threads and agents over time; the window, filters and selection live in the URL. */ -export function ProjectLoomPage(props: ProjectLoomPageProps) { - const config = useAtomValue(serverEnvironment.configValueAtom(props.environmentId)); - const project = useProject(scopeProjectRef(props.environmentId, props.projectId)); - const supported = config?.environment.capabilities.projectLoom === true; - - const header = ( - - - -

Loom

-
- - - {project?.title ?? props.projectId} - -
-
- ); - - return ( - -
- {header} - {config === null ? ( -

Connecting to the environment…

- ) : !supported ? ( -

- This environment's server does not have the Loom yet. Update the server to use it. -

- ) : ( - - )} -
-
- ); -} - -interface LoomPage { - readonly cursor: string; - readonly from: string; - readonly to: string; -} - -function LoomWorkspace(props: ProjectLoomPageProps) { - const { search, onSearchChange } = props; - const navigate = useNavigate(); - const environments = useEnvironmentIdentities(); - const openPrLink = useOpenPrLink(); - const filters = loomFiltersOf(search); - // Lane pages are ephemeral: a cursor only belongs to the project, window and filters that made - // it. Each page carries the resolved bounds of the page before it, because without explicit - // bounds the server resolves a new "now" per request and would reject the cursor. - const filterKey = JSON.stringify([ - props.environmentId, - props.projectId, - search.from, - search.to, - filters, - ]); - const [cursorState, setCursorState] = useState<{ - readonly key: string; - readonly pages: ReadonlyArray; - }>({ key: filterKey, pages: [] }); - const pages = cursorState.key === filterKey ? cursorState.pages : []; - const setPages = (next: ReadonlyArray) => - setCursorState({ key: filterKey, pages: next }); - const current = pages.at(-1); - const from = current?.from ?? search.from; - const to = current?.to ?? search.to; - const input = { - projectId: props.projectId, - ...(from === undefined ? {} : { from }), - ...(to === undefined ? {} : { to }), - ...(filters === undefined ? {} : { filters }), - ...(current === undefined ? {} : { cursor: current.cursor }), - }; - const query = useEnvironmentQuery( - projectLoom.window({ environmentId: props.environmentId, input }), - ); - const data = query.data; - - const update = useStableCallback((patch: LoomSearchPatch) => - onSearchChange((previous) => { - const next: Record = { ...previous, ...patch }; - for (const key of Object.keys(next)) if (next[key] === undefined) delete next[key]; - return next as LoomSearch; - }), - ); - - const actions: LoomActions = { - canOpenThread: (target) => - target.environmentId === props.environmentId || - environments.some(({ environmentId }) => environmentId === target.environmentId), - openThread: (target) => - void navigate({ - to: "/$environmentId/$threadId", - params: buildThreadRouteParams(target), - }), - openDiff: (threadId: ThreadId, runId: RunId) => { - const threadRef = scopeThreadRef(props.environmentId, threadId); - useDiffPanelStore.getState().selectTurn(threadRef, runId); - useRightPanelStore.getState().open(threadRef, "diff"); - void navigate({ to: "/$environmentId/$threadId", params: buildThreadRouteParams(threadRef) }); - }, - openPullRequest: (event, url, threadId) => - openPrLink(event, url, scopeThreadRef(props.environmentId, threadId)), - }; - - const shiftWindow = (direction: -1 | 1) => { - if (data === null) return; - const fromMs = Date.parse(data.from); - const toMs = Date.parse(data.to); - const span = toMs - fromMs; - update({ - from: new Date(fromMs + direction * span).toISOString(), - to: new Date(toMs + direction * span).toISOString(), - }); - }; - const spanMs = data === null ? null : Date.parse(data.to) - Date.parse(data.from); - const preset = WINDOW_PRESETS.find((option) => option.ms === spanMs); - - return ( - <> -
- { - const option = WINDOW_PRESETS.find((candidate) => candidate.label === next[0]); - if (option === undefined) return; - const toMs = data === null ? Date.now() : Date.parse(data.to); - update({ - from: new Date(toMs - option.ms).toISOString(), - to: new Date(toMs).toISOString(), - }); - }} - > - {WINDOW_PRESETS.map((option) => ( - - {option.label} - - ))} - - - - - {data === null ? null : ( - - {formatLoomTime(data.from)} to {formatLoomTime(data.to)} - - )} -
- - -
-
- {data === null ? ( - query.error !== null ? ( -

- {query.error} -

- ) : ( -

Loading timeline…

- ) - ) : ( - - setPages([...pages, { cursor: data.nextCursor!, from: data.from, to: data.to }]) - } - onNewerLanes={pages.length === 0 ? null : () => setPages(pages.slice(0, -1))} - /> - )} - - ); -} - -function LoomLoaded(props: { - readonly environmentId: EnvironmentId; - readonly data: LoomWindow; - readonly stale: boolean; - readonly search: LoomSearch; - readonly update: (patch: LoomSearchPatch) => void; - readonly actions: LoomActions; - readonly page: number; - readonly onOlderLanes: (() => void) | null; - readonly onNewerLanes: (() => void) | null; -}) { - const { data, search, update } = props; - const bounds = { fromMs: Date.parse(data.from), toMs: Date.parse(data.to) }; - const [laneSearch, setLaneSearch] = useState(""); - const mode: LoomMode = search.mode ?? "workstream"; - const collapsed = new Set(search.collapsed ?? []); - const rows = buildLoomRows(data, collapsed, laneSearch); - const activity = groupLoomActivity(data); - const weave = buildWeave(data); - const spine = spineWorkstream(weave, search.root); - const fit = (): LoomView => - mode === "lanes" - ? fitLoomView(data, bounds) - : mode === "agent" && spine !== null - ? branchView(spine, bounds, weave.tailMs) - : spindleView(weave, bounds); - // Agent mode frames its workstream; entering it, switching workstream or leaving it refits. - const viewKey = mode === "agent" ? `agent:${spine?.rootId}` : "window"; - const [viewState, setViewState] = useState(() => ({ key: viewKey, view: fit() })); - const view = viewState.key === viewKey ? viewState.view : fit(); - const setView = (next: LoomView) => setViewState({ key: viewKey, view: next }); - const visibleRowOf = (() => { - const rowIndex = new Map(rows.map((row, index) => [row.threadId as string, index])); - const parentOf = new Map( - [...data.lanes, ...data.ancestors].map((lane) => [lane.threadId, lane.parentThreadId]), - ); - const resolved = new Map(); - for (const threadId of parentOf.keys()) { - let current: string | null | undefined = threadId; - for (let depth = 0; current != null && depth < 64; depth++) { - const index = rowIndex.get(current); - if (index !== undefined) { - resolved.set(threadId, index); - break; - } - current = parentOf.get(current); - } - } - return resolved; - })(); - const selection = decodeLoomSelection(search.sel); - const truncation = [...loomTruncationNotes(data), ...loomCoverageNotes(data)]; - const select = (next: LoomSelection | null) => - update({ sel: next === null ? undefined : encodeLoomSelection(next) }); - const openWorkstream = (rootId: string) => - update({ root: rootId, mode: "agent", sel: undefined }); - const empty = data.lanes.length === 0 && data.ancestors.length === 0; - - return ( -
-
-
- { - const option = LOOM_MODES.find((candidate) => candidate === next[0]); - if (option !== undefined) - update({ mode: option === "workstream" ? undefined : option }); - }} - > - Workstream - Agent - Lanes - - {mode === "agent" && spine !== null ? ( - <> - - {spine.title} - - ) : null} - {mode === "lanes" ? ( -
- - setLaneSearch(event.currentTarget.value)} - /> -
- ) : null} - - - {props.stale ? "Could not refresh. " : ""}As of {formatLoomTime(data.asOf)} - -
- {truncation.length > 0 || props.stale ? ( -

- {props.stale ? "Showing the last loaded timeline. " : ""} - {truncation.length > 0 ? `Limited: ${truncation.join(", ")}.` : ""} -

- ) : null} - {empty ? ( -

- No thread activity in this window. -

- ) : mode === "lanes" ? ( - rows.length === 0 ? ( -

No lanes match this search.

- ) : ( - { - const next = new Set(collapsed); - if (!next.delete(threadId)) next.add(threadId); - update({ collapsed: next.size === 0 ? undefined : [...next] }); - }} - /> - ) - ) : ( - - )} - {empty ? null : ( - <> - {mode === "lanes" ? null : } - - - )} - {props.onOlderLanes === null && props.onNewerLanes === null ? null : ( -
- Lane page {props.page + 1} - - -
- )} -
- - } - onSelect={select} - /> -
- ); -} - -function LoomFilterControls(props: { - readonly data: LoomWindow | null; - readonly search: LoomSearch; - readonly update: (patch: LoomSearchPatch) => void; -}) { - const { search, update } = props; - // The draft follows the URL whenever the URL's title filter changes underneath it. - const [draft, setDraft] = useState({ source: search.q, text: search.q ?? "" }); - const title = draft.source === search.q ? draft.text : (search.q ?? ""); - // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- The debounce timer syncs the URL filter with the draft and cancels pending navigation on URL changes or unmount. - useEffect(() => { - const trimmed = title.trim(); - if (trimmed === (search.q ?? "")) return; - const timeout = setTimeout(() => { - const q = trimmed.length === 0 ? undefined : trimmed; - // The committed value becomes the draft's source, so a later URL change (Clear, back) - // replaces the draft even when it returns to an earlier value. - setDraft({ source: q, text: title }); - update({ q }); - }, 300); - return () => clearTimeout(timeout); - }, [title, search.q, update]); - - // Options come from the loaded lanes and runs, plus whatever is already selected. - const options = (values: Iterable, selected: ReadonlyArray | undefined) => - [...new Set([...values, ...(selected ?? [])])].filter(Boolean).toSorted(); - const providers = options( - (props.data?.runs ?? []).map((run) => run.providerInstanceId), - search.provider, - ); - const models = options( - (props.data?.runs ?? []).map((run) => run.model), - search.model, - ); - const branches = options( - (props.data?.lanes ?? []).flatMap((lane) => (lane.branch === null ? [] : [lane.branch])), - search.branch, - ); - const toggle = (values: ReadonlyArray | undefined, value: string, checked: boolean) => { - const next = new Set(values ?? []); - if (checked) next.add(value); - else next.delete(value); - return next.size === 0 ? undefined : [...next]; - }; - const activeCount = - (search.status?.length ?? 0) + - (search.provider?.length ?? 0) + - (search.model?.length ?? 0) + - (search.branch?.length ?? 0); - - return ( - <> -
- setDraft({ source: search.q, text: event.currentTarget.value })} - /> -
- - } aria-label="Filters"> - - {activeCount > 0 ? `Filters (${activeCount})` : "Filters"} - - - - Run status - {LOOM_STATUS_OPTIONS.map((option) => ( - - update({ - status: toggle(search.status, option.value, checked) as LoomSearch["status"], - }) - } - > - {option.label} - - ))} - - {( - [ - ["Provider", "provider", providers], - ["Model", "model", models], - ["Branch", "branch", branches], - ] as const - ).map(([label, key, values]) => - values.length === 0 ? null : ( - - - {label} - {values.map((value) => ( - - update({ [key]: toggle(search[key], value, checked) }) - } - > - {value} - - ))} - - ), - )} - - - {activeCount > 0 || search.q !== undefined ? ( - - ) : null} - - ); -} diff --git a/apps/web/src/components/loom/loomBranch.test.ts b/apps/web/src/components/loom/loomBranch.test.ts deleted file mode 100644 index 8470e6556f837..0000000000000 --- a/apps/web/src/components/loom/loomBranch.test.ts +++ /dev/null @@ -1,310 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import { - BRANCH_LANE_SPACING, - BRANCH_MARGIN, - BRANCH_MIN_RUN, - BRANCH_RUN, - branchLayout, - branchSegments, - branchView, - clusterEvents, - workstreamNodes, -} from "./loomBranch"; -import { buildWeave, quietChildren, type WeaveEvent } from "./loomWeave"; -import { edge, iso, lane, run, window } from "./loomWeave.test"; - -const MINUTE = 60_000; -const T0 = Date.parse("2026-10-22T12:00:00.000Z"); -const x = (ms: number) => (ms - T0) / MINUTE; - -const weave = buildWeave( - window({ - lanes: [ - lane("root", null), - lane("second", "root", { createdAt: iso(T0 + 20 * MINUTE) }), - lane("first", "root", { createdAt: iso(T0 + 10 * MINUTE) }), - lane("first-child", "first", { createdAt: iso(T0 + 12 * MINUTE) }), - lane("third", "root", { createdAt: iso(T0 + 30 * MINUTE) }), - lane("fourth", "root", { createdAt: iso(T0 + 45 * MINUTE) }), - ], - runs: [ - run("r", { threadId: "root", requestedAt: iso(T0), completedAt: iso(T0 + 60 * MINUTE) }), - run("f", { - threadId: "first", - requestedAt: iso(T0 + 10 * MINUTE), - completedAt: iso(T0 + 40 * MINUTE), - }), - run("fc", { - threadId: "first-child", - requestedAt: iso(T0 + 12 * MINUTE), - completedAt: iso(T0 + 14 * MINUTE), - }), - run("o", { - threadId: "fourth", - requestedAt: iso(T0 + 45 * MINUTE), - completedAt: iso(T0 + 45 * MINUTE), - }), - run("s", { - threadId: "second", - requestedAt: iso(T0 + 20 * MINUTE), - completedAt: iso(T0 + 25 * MINUTE), - }), - ], - edges: [ - edge("subagent_spawn", "root", "first", T0 + 10 * MINUTE), - edge("subagent_result", "first", "root", T0 + 50 * MINUTE), - ], - }), -); -const workstream = weave.workstreams[0]!; - -describe("branchLayout", () => { - const slots = (msPerPx: number) => { - const layout = branchLayout(workstream, msPerPx); - return (id: string) => (layout.laneY.get(id)! - layout.cy) / BRANCH_LANE_SPACING; - }; - - it("alternates the root's children by spawn time and nests grandchildren beyond their parent", () => { - const slot = slots(MINUTE / 3); - expect(slot("root")).toBe(0); - expect(slot("first")).toBe(-1); - expect(slot("first-child")).toBe(-2); - expect(slot("second")).toBe(1); - expect(slot("fourth")).toBe(2); - }); - - it("reuses a lane once the strand on it has ended", () => { - const slot = slots(1_000); - expect(slot("second")).toBe(1); - expect(slot("fourth")).toBe(1); - }); - - it("keeps a lane for a short strand's title before reusing it", () => { - // At a third of a minute per pixel a title needs 40 minutes; "second" lived for 5. - expect(slots(MINUTE / 3)("fourth")).toBe(2); - }); - - it("never puts strands that overlap in time on one lane, and packs the rest", () => { - const children = Array.from({ length: 40 }, (_, index) => { - const start = T0 + ((index * 37) % 240) * MINUTE; - return { id: `c${index}`, start, end: start + (5 + ((index * 53) % 90)) * MINUTE }; - }); - const packed = buildWeave( - window({ - lanes: [ - lane("root", null), - ...children.map((child) => lane(child.id, "root", { createdAt: iso(child.start) })), - ], - runs: [ - run("r", { threadId: "root", requestedAt: iso(T0), completedAt: iso(T0 + 400 * MINUTE) }), - ...children.map((child) => - run(`r-${child.id}`, { - threadId: child.id, - requestedAt: iso(child.start), - completedAt: iso(child.end), - }), - ), - ], - }), - ).workstreams[0]!; - const layout = branchLayout(packed, 0); - const byLane = new Map(); - for (const child of children) { - const y = layout.laneY.get(child.id)!; - byLane.set(y, [...(byLane.get(y) ?? []), child]); - } - for (const occupants of byLane.values()) { - const sorted = occupants.toSorted((left, right) => left.start - right.start); - sorted.slice(1).forEach((child, index) => { - expect(child.start).toBeGreaterThanOrEqual(sorted[index]!.end); - }); - } - expect(byLane.size).toBeLessThan(children.length / 2); - }); - - it("gives no lane to a child that recorded nothing but its creation", () => { - expect(quietChildren(workstream)).toEqual(new Set(["third"])); - expect(branchLayout(workstream, 0).laneY.has("third")).toBe(false); - }); - - it("is exactly tall enough for every lane and its labels", () => { - const layout = branchLayout(workstream, MINUTE / 3); - expect(layout.cy).toBe(BRANCH_MARGIN + 2 * BRANCH_LANE_SPACING); - expect(layout.height).toBe(2 * BRANCH_MARGIN + 4 * BRANCH_LANE_SPACING); - }); -}); - -describe("branchView", () => { - it("frames the workstream's own activity, not the rest of the window", () => { - const bounds = { fromMs: T0 - 24 * 60 * MINUTE, toMs: T0 + 24 * 60 * MINUTE }; - const view = branchView(workstream, bounds, bounds.toMs); - expect(view.fromMs).toBeLessThan(T0); - expect(view.fromMs).toBeGreaterThan(T0 - 10 * MINUTE); - expect(view.toMs).toBeGreaterThan(T0 + 60 * MINUTE); - expect(view.toMs).toBeLessThan(T0 + 70 * MINUTE); - }); -}); - -describe("branchSegments", () => { - const layout = branchLayout(workstream, MINUTE / 3); - const cy = layout.cy; - const wide = (ms: number) => x(ms) * 10; - - it("runs the root flat across its extent", () => { - expect(branchSegments(weave.strands.get("root")!, layout, x)).toEqual({ - segments: [{ d: `M0.0 ${cy.toFixed(1)}H60.0`, inferred: false }], - laneX: 0, - endX: 60, - }); - }); - - it("leaves the parent at the spawn, runs its lane and curves back at the rejoin", () => { - const path = branchSegments(weave.strands.get("first")!, layout, wide)!; - const laneY = layout.laneY.get("first")!.toFixed(1); - const spine = cy.toFixed(1); - expect(path.segments[0]).toEqual({ - d: `M100.0 ${spine}C120.0 ${spine} 120.0 ${laneY} 140.0 ${laneY}`, - inferred: false, - }); - expect(path.segments[1]!.d).toBe( - `M140.0 ${laneY}H460.0C480.0 ${laneY} 480.0 ${spine} 500.0 ${spine}`, - ); - expect(path).toMatchObject({ laneX: 140, endX: 500 }); - }); - - it("dashes a spawn inferred from creation and stops at the end without a rejoin", () => { - const path = branchSegments(weave.strands.get("second")!, layout, wide)!; - expect(path.segments[0]!.inferred).toBe(true); - expect(path.segments[0]!.d.startsWith(`M200.0 ${cy.toFixed(1)}`)).toBe(true); - expect(path.segments[1]!.d).toBe( - `M${200 + BRANCH_RUN}.0 ${layout.laneY.get("second")!.toFixed(1)}H250.0`, - ); - expect(path.endX).toBe(250); - }); - - it("bends a strand shorter than two runs over a minimum hump", () => { - const short = buildWeave( - window({ - lanes: [lane("root", null), lane("blip", "root", { createdAt: iso(T0 + 10 * MINUTE) })], - runs: [ - run("r", { threadId: "root", requestedAt: iso(T0), completedAt: iso(T0 + 60 * MINUTE) }), - ], - edges: [ - edge("subagent_spawn", "root", "blip", T0 + 10 * MINUTE), - edge("subagent_result", "blip", "root", T0 + 11 * MINUTE), - ], - }), - ); - const path = branchSegments( - short.strands.get("blip")!, - branchLayout(short.workstreams[0]!, 0), - x, - )!; - expect(path.laneX).toBe(10 + BRANCH_MIN_RUN); - expect(path.endX).toBe(10 + 2 * BRANCH_MIN_RUN); - }); - - it("still draws the branch of a child with no recorded duration", () => { - const path = branchSegments(weave.strands.get("fourth")!, layout, wide)!; - expect(path.segments).toHaveLength(1); - expect(path.laneX).toBeGreaterThanOrEqual(450 + BRANCH_RUN); - expect(path.endX).toBe(path.laneX); - }); -}); - -const tick = (offsetMs: number, kind: WeaveEvent["kind"], failed = false): WeaveEvent => ({ - key: `${kind}:${offsetMs}`, - ms: T0 + offsetMs, - kind, - failed, - selection: failed ? { kind: "run", id: "failed" } : { kind: "lane", id: "s" }, - detail: failed ? "Run 1 failed" : null, -}); - -describe("clusterEvents", () => { - it("merges events within the spacing into one node led by the most telling event", () => { - const nodes = clusterEvents( - "s", - [ - tick(0, "agent"), - tick(5 * MINUTE, "edit"), - tick(10 * MINUTE, "error", true), - tick(100 * MINUTE, "user"), - ], - x, - 40, - 200, - 24, - ); - expect(nodes.map((node) => [node.kind, node.count, node.x, node.labelled])).toEqual([ - ["error", 3, 5, true], - ["user", 1, 100, true], - ]); - expect(nodes[0]).toMatchObject({ - selection: { kind: "run", id: "failed" }, - detail: "Run 1 failed", - y: 40, - }); - }); - - it("withholds labels from nodes closer than the label spacing", () => { - const nodes = clusterEvents("s", [tick(0, "agent"), tick(40 * MINUTE, "edit")], x, 0, 200, 24); - expect(nodes.map((node) => node.labelled)).toEqual([false, false]); - }); - - it("drops events outside the field", () => { - expect( - clusterEvents("s", [tick(-60 * MINUTE, "agent"), tick(300 * MINUTE, "agent")], x, 0, 200, 24), - ).toEqual([]); - }); -}); - -describe("workstreamNodes", () => { - const wideNodes = () => - workstreamNodes(workstream, branchLayout(workstream, 0), (ms) => x(ms) * 10, 1000); - - it("puts every child's spawn and rejoin on its parent, a quiet child's included", () => { - const rootNodes = wideNodes().get("root")!; - expect(rootNodes.find((node) => node.key === "spawn:third")).toMatchObject({ - x: 300, - selection: { kind: "lane", id: "third" }, - detail: "third", - }); - const rejoin = rootNodes.find((node) => node.key === "rejoin:first"); - expect(rejoin).toMatchObject({ kind: "rejoin", selection: { kind: "edge" } }); - expect(rejoin!.x).toBeGreaterThan(400); - }); - - it("keeps a child's early events on its lane, after the curve off its parent", () => { - const [first] = wideNodes().get("first")!; - expect(first!.x).toBe(100 + BRANCH_RUN); - }); - - it("widens the clustering until the whole workstream fits under the cap", () => { - const dense = buildWeave( - window({ - lanes: [lane("root", null)], - runs: [run("r", { threadId: "root", requestedAt: iso(T0) })], - activity: [ - { - runId: "r", - threadId: "root", - ticks: Array.from({ length: 2000 }, (_, index) => [index * 1_000, "tool"]), - } as never, - ], - }), - ); - const layout = branchLayout(dense.workstreams[0]!, 0); - const nodes = workstreamNodes( - dense.workstreams[0]!, - layout, - (ms) => (ms - T0) / 1_000, - 2000, - 50, - ); - const list = nodes.get("root")!; - expect(list.length).toBeLessThanOrEqual(50); - expect(list.reduce((total, node) => total + node.count, 0)).toBe(2001); - }); -}); diff --git a/apps/web/src/components/loom/loomBranch.ts b/apps/web/src/components/loom/loomBranch.ts deleted file mode 100644 index 6b580663a16c2..0000000000000 --- a/apps/web/src/components/loom/loomBranch.ts +++ /dev/null @@ -1,312 +0,0 @@ -import { WEAVE_KINDS, type WeaveEventKind } from "./loomKinds"; -import { frameLoomSpan, type LoomView } from "./loomScale"; -import type { LoomSelection } from "./loomSearch"; -import { quietChildren, type WeaveEvent, type WeaveStrand, type Workstream } from "./loomWeave"; - -export const BRANCH_LANE_SPACING = 96; -/** Room above the top lane and below the bottom one for nodes and their labels. */ -export const BRANCH_MARGIN = 56; -/** The horizontal run of the S-curve that leaves or rejoins a parent. */ -export const BRANCH_RUN = 40; -/** A strand too short for two full runs still bends over at least this, so it reads as a hump. */ -export const BRANCH_MIN_RUN = 14; -const LANE_GAP = 24; -const LANE_LABEL = 120; -/** Spawns this many px apart at the fitted zoom leave from what reads as one point. */ -const BURST = 24; - -export interface BranchLayout { - readonly cy: number; - readonly height: number; - readonly laneY: ReadonlyMap; -} - -const startOf = (strand: WeaveStrand) => - strand.spawn?.ms ?? strand.extent?.startMs ?? Number.POSITIVE_INFINITY; -const endOf = (strand: WeaveStrand) => - Math.max(startOf(strand), strand.rejoin?.ms ?? strand.extent?.endMs ?? 0); - -/** - * Frames one workstream's activity: its events, transfers and the ends of its strands, which reach - * the loaded tail while one still runs. Creation times stay out, so an old root does not stretch - * the frame. The whole window when nothing on it happened inside the window. - */ -export function branchView(workstream: Workstream, bounds: LoomView, tailMs: number): LoomView { - const times = workstream.strands - .flatMap((strand) => [ - strand.events[0]?.ms, - strand.events.at(-1)?.ms, - strand.spawn?.ms, - strand.rejoin?.ms, - strand.extent === null ? undefined : Math.min(strand.extent.endMs, tailMs), - ]) - .filter((ms): ms is number => ms !== undefined && ms >= bounds.fromMs && ms <= bounds.toMs); - return frameLoomSpan( - times.length === 0 ? null : { firstMs: Math.min(...times), lastMs: Math.max(...times) }, - bounds, - ); -} - -/** - * Reserve label/gap space at the fitted scale so lanes stay put while zooming. Reuse ended lanes; - * keep descendants on their parent's side so each subtree stays together. - */ -export function branchLayout( - workstream: Workstream, - msPerPx: number, - spacing = BRANCH_LANE_SPACING, - margin = BRANCH_MARGIN, -): BranchLayout { - const byId = new Map(workstream.strands.map((strand) => [strand.threadId, strand])); - const quiet = quietChildren(workstream); - const slots = new Map([[workstream.rootId, 0]]); - const occupied = new Map>(); - let nextSide = -1; - const fits = (slot: number, from: number, to: number) => - (occupied.get(slot) ?? []).every(([start, end]) => to <= start || from >= end); - const place = (strand: WeaveStrand): number | undefined => { - const known = slots.get(strand.threadId); - if (known !== undefined) return known; - if (strand.parentThreadId === null || strand.extent === null || quiet.has(strand.threadId)) - return undefined; - const parent = byId.get(strand.parentThreadId); - const parentSlot = parent === undefined ? 0 : (place(parent) ?? 0); - const from = startOf(strand); - const to = Math.max(endOf(strand) + LANE_GAP * msPerPx, from + LANE_LABEL * msPerPx); - let slot = 0; - if (parentSlot === 0) { - for (let k = 1; slot === 0; k++) { - if (fits(nextSide * k, from, to)) slot = nextSide * k; - else if (fits(-nextSide * k, from, to)) slot = -nextSide * k; - } - nextSide = -Math.sign(slot); - } else { - const side = Math.sign(parentSlot); - for (let k = Math.abs(parentSlot) + 1; slot === 0; k++) { - if (fits(side * k, from, to)) slot = side * k; - } - } - occupied.set(slot, [...(occupied.get(slot) ?? []), [from, to]]); - slots.set(strand.threadId, slot); - return slot; - }; - // Children that leave at the same point take lanes shortest first, so a longer strand encloses - // the shorter ones and its rejoin does not cross their lanes. - const burst = (strand: WeaveStrand) => Math.floor(startOf(strand) / (BURST * msPerPx || 1)); - const order = workstream.strands.toSorted( - (left, right) => - burst(left) - burst(right) || endOf(left) - endOf(right) || startOf(left) - startOf(right), - ); - for (const strand of order) place(strand); - const values = [...slots.values()]; - const above = -Math.min(0, ...values); - const below = Math.max(0, ...values); - const cy = margin + above * spacing; - return { - cy, - height: cy + below * spacing + margin, - laneY: new Map([...slots].map(([threadId, slot]) => [threadId, cy + slot * spacing])), - }; -} - -export interface BranchSegment { - readonly d: string; - /** Drawn dashed: a spawn inferred from creation rather than a recorded transfer. */ - readonly inferred: boolean; -} - -export interface BranchStrandPath { - readonly segments: ReadonlyArray; - /** Where the strand's own lane begins, after the curve off its parent. */ - readonly laneX: number; - /** Where it ends: the rejoin on the parent, or the terminal node on its lane. */ - readonly endX: number; -} - -/** - * A root runs flat across its extent. A child leaves its parent at the spawn time with an - * S-curve, runs flat on its lane, and curves back at its rejoin or stops at its end. The curve off - * the parent is always drawn whole, so a child with no recorded duration still shows where it - * branched. - */ -export function branchSegments( - strand: WeaveStrand, - layout: BranchLayout, - x: (ms: number) => number, -): BranchStrandPath | null { - const y = layout.laneY.get(strand.threadId); - if (y === undefined || strand.extent === null) return null; - const parentY = - strand.parentThreadId === null ? undefined : layout.laneY.get(strand.parentThreadId); - const f = (n: number) => n.toFixed(1); - if (strand.spawn === null || parentY === undefined) { - const startX = x(strand.extent.startMs); - const endX = Math.max(startX, x(strand.extent.endMs)); - return { - segments: endX > startX ? [{ d: `M${f(startX)} ${f(y)}H${f(endX)}`, inferred: false }] : [], - laneX: startX, - endX, - }; - } - const spawnX = x(strand.spawn.ms); - const rejoinX = strand.rejoin === null ? null : x(strand.rejoin.ms); - // A branch that climbs several lanes gets a longer run, so it reads as a curve, not a wall. - const climb = Math.min(BRANCH_RUN * 3, Math.max(BRANCH_RUN, Math.abs(y - parentY) * 0.4)); - const run = - rejoinX === null ? climb : Math.max(BRANCH_MIN_RUN, Math.min(climb, (rejoinX - spawnX) / 2)); - const laneX = spawnX + run; - const bodyEnd = - rejoinX === null ? Math.max(laneX, x(strand.extent.endMs)) : Math.max(laneX, rejoinX - run); - const endX = rejoinX === null ? bodyEnd : bodyEnd + run; - const curve = (fromX: number, fromY: number, toX: number, toY: number) => - `C${f(fromX + run / 2)} ${f(fromY)} ${f(toX - run / 2)} ${f(toY)} ${f(toX)} ${f(toY)}`; - const leave: BranchSegment = { - d: `M${f(spawnX)} ${f(parentY)}${curve(spawnX, parentY, laneX, y)}`, - inferred: !strand.spawn.exact, - }; - const back = rejoinX === null ? "" : curve(bodyEnd, y, endX, parentY); - const body = bodyEnd > laneX || back !== "" ? `M${f(laneX)} ${f(y)}H${f(bodyEnd)}${back}` : ""; - return { - segments: body === "" ? [leave] : [leave, { d: body, inferred: false }], - laneX, - endX, - }; -} - -export interface BranchNode { - readonly key: string; - readonly threadId: string; - readonly x: number; - readonly y: number; - readonly kind: WeaveEventKind; - readonly failed: boolean; - readonly selection: LoomSelection; - readonly detail: string | null; - readonly ms: number; - readonly count: number; - /** Far enough from its neighbours for the three-line label. */ - readonly labelled: boolean; -} - -const NODE_SPACING = 72; -const LABEL_SPACING = 72; -const NODE_CAP = 400; - -const rank = (event: WeaveEvent) => (event.failed ? 100 : 0) + WEAVE_KINDS[event.kind].priority; - -/** - * Events within `spacing` px of each other become one node that takes the most telling event's - * kind, selection and detail, and counts the rest. Events left of `minX` sit at `minX`: a child's - * first events come seconds after its spawn, before its curve has reached its lane. - */ -export function clusterEvents( - threadId: string, - events: ReadonlyArray, - x: (ms: number) => number, - y: number, - width: number, - spacing = NODE_SPACING, - minX = Number.NEGATIVE_INFINITY, -): BranchNode[] { - const nodes: Array<{ first: number; last: number; lead: WeaveEvent; count: number }> = []; - for (const event of events) { - const px = Math.max(minX, x(event.ms)); - if (px < 0 || px > width) continue; - const current = nodes.at(-1); - if (current !== undefined && px - current.first < spacing) { - current.last = px; - current.count += 1; - if (rank(event) > rank(current.lead)) current.lead = event; - } else nodes.push({ first: px, last: px, lead: event, count: 1 }); - } - return nodes.map((node, index) => { - const cx = (node.first + node.last) / 2; - const previous = nodes[index - 1]; - const next = nodes[index + 1]; - const gap = (other: typeof node | undefined) => - other === undefined || Math.abs((other.first + other.last) / 2 - cx) >= LABEL_SPACING; - return { - key: node.lead.key, - threadId, - x: cx, - y, - kind: node.lead.kind, - failed: node.lead.failed, - selection: node.lead.selection, - detail: node.lead.detail, - ms: node.lead.ms, - count: node.count, - labelled: gap(previous) && gap(next), - }; - }); -} - -/** - * Nodes for every strand in the workstream, with each child's spawn and rejoin among its parent's - * events, so transfers at the same moment cluster instead of stacking. When the total passes the cap the spacing doubles until it fits, so a busy workstream - * still renders a bounded number of elements. - */ -export function workstreamNodes( - workstream: Workstream, - layout: BranchLayout, - x: (ms: number) => number, - width: number, - cap = NODE_CAP, -): ReadonlyMap> { - const transfers = new Map(); - const add = (parentId: string, event: WeaveEvent) => { - const list = transfers.get(parentId) ?? []; - list.push(event); - transfers.set(parentId, list); - }; - for (const strand of workstream.strands) { - if (strand.parentThreadId === null) continue; - if (strand.spawn !== null) { - add(strand.parentThreadId, { - key: `spawn:${strand.threadId}`, - ms: strand.spawn.ms, - kind: "spawn", - failed: false, - selection: { kind: "lane", id: strand.threadId }, - detail: strand.title, - }); - } - if (strand.rejoin !== null) { - add(strand.parentThreadId, { - key: `rejoin:${strand.threadId}`, - ms: strand.rejoin.ms, - kind: "rejoin", - failed: false, - selection: { kind: "edge", id: strand.rejoin.edgeId }, - detail: strand.title, - }); - } - } - const eventsOf = (strand: WeaveStrand) => { - const extra = transfers.get(strand.threadId); - return extra === undefined - ? strand.events - : [...strand.events, ...extra].sort((left, right) => left.ms - right.ms); - }; - for (let spacing = NODE_SPACING; ; spacing *= 2) { - const result = new Map(); - let total = 0; - for (const strand of workstream.strands) { - const y = layout.laneY.get(strand.threadId); - if (y === undefined) continue; - const laneX = branchSegments(strand, layout, x)?.laneX; - const nodes = clusterEvents( - strand.threadId, - eventsOf(strand), - x, - y, - width, - spacing, - strand.parentThreadId === null ? undefined : laneX, - ); - total += nodes.length; - result.set(strand.threadId, nodes); - } - if (total <= cap || spacing > width) return result; - } -} diff --git a/apps/web/src/components/loom/loomKinds.ts b/apps/web/src/components/loom/loomKinds.ts deleted file mode 100644 index e2b68e35df858..0000000000000 --- a/apps/web/src/components/loom/loomKinds.ts +++ /dev/null @@ -1,107 +0,0 @@ -import type { LoomActivityKind } from "@t3tools/contracts"; -import { - BotIcon, - DiamondIcon, - FilePenIcon, - FlaskConicalIcon, - GitForkIcon, - ListTodoIcon, - MergeIcon, - PlayIcon, - TerminalIcon, - TriangleAlertIcon, - UserIcon, - WrenchIcon, - type LucideIcon, -} from "lucide-react"; - -import { PullRequestGlyph } from "../pullRequest/pullRequestIcons"; - -/** Everything the weave can place on a strand: activity ticks plus the window's markers and runs. */ -export type WeaveEventKind = - | LoomActivityKind - | "run" - | "checkpoint" - | "test" - | "pull_request" - | "task" - | "spawn" - | "rejoin"; - -interface WeaveKindStyle { - readonly label: string; - /** Which kind names a pixel that several share. Higher wins. */ - readonly priority: number; - readonly icon: LucideIcon; - /** Tailwind colour utilities for the kind's stroke, fill and text. */ - readonly stroke: string; - readonly fill: string; - readonly text: string; - /** Worth a glyph in the overview, where plain ticks only get dots. */ - readonly significant: boolean; -} - -const style = ( - label: string, - priority: number, - icon: LucideIcon, - color: string, - significant: boolean, -): WeaveKindStyle => ({ - label, - priority, - icon, - stroke: `stroke-${color}`, - fill: `fill-${color}`, - text: `text-${color}`, - significant, -}); - -// Tailwind scans these explicit utilities for the dynamic colour classes below. -// stroke-foreground fill-foreground text-foreground stroke-muted-foreground fill-muted-foreground text-muted-foreground -// stroke-info fill-info text-info stroke-success fill-success text-success stroke-primary fill-primary text-primary -// stroke-destructive fill-destructive text-destructive stroke-warning fill-warning text-warning -export const WEAVE_KINDS: Record = { - agent: style("Agent", 0, BotIcon, "muted-foreground", false), - user: style("User", 1, UserIcon, "foreground", false), - run: style("Run", 2, PlayIcon, "muted-foreground", false), - tool: style("Tool", 3, WrenchIcon, "primary", false), - command: style("Command", 4, TerminalIcon, "info", true), - // A branch's curve already shows where it left and rejoined, so a transfer gives way to the - // work recorded at the same moment. - spawn: style("Spawn", 5, GitForkIcon, "foreground", true), - rejoin: style("Rejoin", 6, MergeIcon, "foreground", true), - edit: style("Edit", 7, FilePenIcon, "success", true), - task: style("Agent task", 8, ListTodoIcon, "primary", true), - pull_request: style("Pull request", 9, PullRequestGlyph.pullRequest, "info", true), - checkpoint: style("Checkpoint", 10, DiamondIcon, "success", true), - test: style("Test", 11, FlaskConicalIcon, "warning", true), - error: style("Error", 12, TriangleAlertIcon, "destructive", true), -}; - -/** Legend order: the quiet ticks first, then the events that get glyphs. */ -export const WEAVE_KIND_ORDER: ReadonlyArray = [ - "user", - "agent", - "tool", - "command", - "edit", - "test", - "checkpoint", - "pull_request", - "task", - "error", - "spawn", - "rejoin", -]; - -/** Workstream colours cycle through these; destructive stays reserved for failure. */ -const WORKSTREAM_COLORS = [ - { stroke: "stroke-primary", fill: "fill-primary", text: "text-primary", bg: "bg-primary" }, - { stroke: "stroke-info", fill: "fill-info", text: "text-info", bg: "bg-info" }, - { stroke: "stroke-success", fill: "fill-success", text: "text-success", bg: "bg-success" }, - { stroke: "stroke-warning", fill: "fill-warning", text: "text-warning", bg: "bg-warning" }, -] as const; - -export const workstreamColor = (index: number) => - WORKSTREAM_COLORS[index % WORKSTREAM_COLORS.length]!; diff --git a/apps/web/src/components/loom/loomScale.test.ts b/apps/web/src/components/loom/loomScale.test.ts deleted file mode 100644 index df2c75be1be76..0000000000000 --- a/apps/web/src/components/loom/loomScale.test.ts +++ /dev/null @@ -1,114 +0,0 @@ -import type { LoomWindow } from "@t3tools/contracts"; -import { describe, expect, it } from "vite-plus/test"; - -import { fitLoomView, loomScale } from "./loomScale"; - -const HOUR = 3_600_000; -const FROM = Date.parse("2026-10-22T12:00:00.000Z"); -const TO = FROM + 24 * HOUR; -const bounds = { fromMs: FROM, toMs: TO }; -const iso = (ms: number) => new Date(ms).toISOString(); - -function loomWindow(patch: Partial): LoomWindow { - return { - projectId: "project-1", - asOf: iso(TO), - from: iso(FROM), - to: iso(TO), - lanes: [], - ancestors: [], - runs: [], - markers: [], - edges: [], - activity: [], - truncated: { - lanes: false, - ancestors: false, - runs: false, - markers: false, - edges: false, - activity: false, - bytes: false, - }, - coverage: { lanes: true }, - ...patch, - } as LoomWindow; -} - -describe("loomScale", () => { - it("keeps events at either end of the view inside the drawable width", () => { - const scale = loomScale(bounds, 600); - expect(scale.x(FROM)).toBeGreaterThanOrEqual(6); - expect(scale.x(TO)).toBeLessThanOrEqual(594); - expect(scale.ms(scale.x(FROM + HOUR))).toBeCloseTo(FROM + HOUR); - }); -}); - -describe("fitLoomView", () => { - it("frames the window's activity instead of the whole window", () => { - const start = TO - 5 * 60_000; - const end = TO - 2 * 60_000; - const view = fitLoomView( - loomWindow({ - runs: [ - { - requestedAt: iso(start), - startedAt: iso(start + 1_000), - completedAt: iso(end), - } as LoomWindow["runs"][number], - ], - }), - bounds, - ); - expect(view.fromMs).toBeLessThanOrEqual(start); - expect(view.toMs).toBeGreaterThanOrEqual(end); - expect(view.toMs - view.fromMs).toBeLessThan(HOUR); - expect(view.toMs).toBeLessThanOrEqual(TO); - }); - - it("shows the whole window when nothing happened in it", () => { - expect(fitLoomView(loomWindow({}), bounds)).toEqual(bounds); - }); - - it.each(["completed", "failed", "interrupted", "cancelled", "rolled_back"] as const)( - "fits recorded activity for a %s run without a completion time", - (status) => { - const start = FROM + HOUR; - const view = fitLoomView( - loomWindow({ - runs: [ - { - status, - requestedAt: iso(start), - startedAt: iso(start + 1_000), - completedAt: null, - } as LoomWindow["runs"][number], - ], - }), - bounds, - ); - expect(view.fromMs).toBeLessThanOrEqual(start); - expect(view.toMs).toBeGreaterThanOrEqual(start + 1_000); - expect(view.toMs - view.fromMs).toBeLessThan(HOUR); - }, - ); - - it("fits through the as-of time for a run that is still active", () => { - const start = FROM + HOUR; - const view = fitLoomView( - loomWindow({ - runs: [ - { - status: "running", - requestedAt: iso(start), - startedAt: iso(start + 1_000), - completedAt: null, - } as LoomWindow["runs"][number], - ], - }), - bounds, - ); - expect(view.fromMs).toBeLessThanOrEqual(start); - expect(view.toMs).toBe(TO); - }); -}); diff --git a/apps/web/src/components/loom/loomScale.ts b/apps/web/src/components/loom/loomScale.ts deleted file mode 100644 index 4c56f61336adb..0000000000000 --- a/apps/web/src/components/loom/loomScale.ts +++ /dev/null @@ -1,158 +0,0 @@ -import type { LoomRun, LoomWindow } from "@t3tools/contracts"; - -export const ACTIVE_RUN = new Set([ - "preparing", - "queued", - "starting", - "running", - "waiting", -]); - -export interface LoomView { - readonly fromMs: number; - readonly toMs: number; -} - -const MINUTE = 60_000; -const HOUR = 60 * MINUTE; -const DAY = 24 * HOUR; -const LOOM_MIN_VIEW_MS = 5 * MINUTE; - -const TICK_STEPS = [ - MINUTE, - 5 * MINUTE, - 15 * MINUTE, - 30 * MINUTE, - HOUR, - 3 * HOUR, - 6 * HOUR, - 12 * HOUR, - DAY, - 2 * DAY, - 7 * DAY, - 14 * DAY, -]; - -// Glyphs extend a few pixels around their time, so an event at either end of the view would be -// half clipped by the SVG without this inset. -const LOOM_EDGE_INSET = 8; - -export function loomScale(view: LoomView, width: number) { - const span = Math.max(view.toMs - view.fromMs, 1); - const inner = Math.max(width - 2 * LOOM_EDGE_INSET, 1); - return { - x: (ms: number) => LOOM_EDGE_INSET + ((ms - view.fromMs) / span) * inner, - ms: (x: number) => view.fromMs + ((x - LOOM_EDGE_INSET) / inner) * span, - msPerPx: span / inner, - }; -} - -export interface LoomSpan { - readonly firstMs: number; - readonly lastMs: number; -} - -/** When the window's recorded activity begins and ends, or null when nothing happened in it. */ -export function loomActivitySpan(window: LoomWindow, bounds: LoomView): LoomSpan | null { - const times = [ - ...window.runs.flatMap((run) => [ - run.requestedAt, - run.startedAt, - run.completedAt ?? (ACTIVE_RUN.has(run.status) ? window.asOf : null), - ]), - ...window.markers.flatMap((marker) => [ - marker.at, - "completedAt" in marker ? marker.completedAt : null, - ]), - ...window.edges.map((edge) => edge.at), - ] - .flatMap((iso) => (iso === null ? [] : [Date.parse(iso)])) - .filter((ms) => ms >= bounds.fromMs && ms <= bounds.toMs); - return times.length === 0 ? null : { firstMs: Math.min(...times), lastMs: Math.max(...times) }; -} - -/** - * A view of the span with `lead` and `trail` of the view's width left free before and after it, - * or the whole window when there is no span. - */ -export function frameLoomSpan( - span: LoomSpan | null, - bounds: LoomView, - lead = 0.05, - trail = 0.05, -): LoomView { - if (span === null) return bounds; - const inner = Math.max(span.lastMs - span.firstMs, LOOM_MIN_VIEW_MS * (1 - lead - trail)); - const total = Math.min(inner / (1 - lead - trail), bounds.toMs - bounds.fromMs); - const fromMs = span.firstMs - lead * total; - return clampLoomView({ fromMs, toMs: fromMs + total }, bounds); -} - -/** Frames the window's activity with a margin, or the whole window when nothing happened. */ -export function fitLoomView(window: LoomWindow, bounds: LoomView): LoomView { - return frameLoomSpan(loomActivitySpan(window, bounds), bounds); -} - -/** Evenly stepped ticks aligned to local time, at most one per `minGapPx`. */ -export function loomTicks(view: LoomView, width: number, minGapPx = 96): ReadonlyArray { - const span = view.toMs - view.fromMs; - const maxTicks = Math.max(1, Math.floor(width / minGapPx)); - const step = TICK_STEPS.find((candidate) => span / candidate <= maxTicks) ?? 28 * DAY; - const offset = new Date(view.fromMs).getTimezoneOffset() * MINUTE; - const first = Math.ceil((view.fromMs - offset) / step) * step + offset; - const ticks: Array = []; - for (let tick = first; tick <= view.toMs; tick += step) ticks.push(tick); - return ticks; -} - -export function formatLoomTick(ms: number, view: LoomView): string { - const date = new Date(ms); - const span = view.toMs - view.fromMs; - if (span > 2 * DAY) { - return date.toLocaleDateString(undefined, { month: "short", day: "numeric", hour: "numeric" }); - } - return date.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" }); -} - -/** Hours and minutes only, for labels that sit under a dated axis. */ -export function formatLoomClock(ms: number): string { - return new Date(ms).toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" }); -} - -export function formatLoomTime(iso: string): string { - return new Date(iso).toLocaleString(undefined, { - month: "short", - day: "numeric", - hour: "numeric", - minute: "2-digit", - }); -} - -/** Zooms around `anchorMs`, never past the window or below LOOM_MIN_VIEW_MS. */ -export function zoomLoomView( - view: LoomView, - bounds: LoomView, - anchorMs: number, - factor: number, -): LoomView { - const span = Math.min( - Math.max((view.toMs - view.fromMs) * factor, LOOM_MIN_VIEW_MS), - bounds.toMs - bounds.fromMs, - ); - const ratio = (anchorMs - view.fromMs) / (view.toMs - view.fromMs); - return clampLoomView( - { fromMs: anchorMs - span * ratio, toMs: anchorMs + span * (1 - ratio) }, - bounds, - ); -} - -export function panLoomView(view: LoomView, bounds: LoomView, deltaMs: number): LoomView { - return clampLoomView({ fromMs: view.fromMs + deltaMs, toMs: view.toMs + deltaMs }, bounds); -} - -function clampLoomView(view: LoomView, bounds: LoomView): LoomView { - const span = view.toMs - view.fromMs; - if (view.fromMs < bounds.fromMs) return { fromMs: bounds.fromMs, toMs: bounds.fromMs + span }; - if (view.toMs > bounds.toMs) return { fromMs: bounds.toMs - span, toMs: bounds.toMs }; - return view; -} diff --git a/apps/web/src/components/loom/loomSearch.ts b/apps/web/src/components/loom/loomSearch.ts deleted file mode 100644 index 25db633d041d0..0000000000000 --- a/apps/web/src/components/loom/loomSearch.ts +++ /dev/null @@ -1,108 +0,0 @@ -import type { LoomFilters, LoomRunStatusFilter } from "@t3tools/contracts"; - -export const LOOM_MODES = ["workstream", "agent", "lanes"] as const; -export type LoomMode = (typeof LOOM_MODES)[number]; - -/** URL state of a Loom view: the read window, filters, selection, mode and collapsed lanes. */ -export interface LoomSearch { - readonly from?: string; - readonly to?: string; - readonly q?: string; - readonly status?: ReadonlyArray; - readonly provider?: ReadonlyArray; - readonly model?: ReadonlyArray; - readonly branch?: ReadonlyArray; - readonly sel?: string; - readonly collapsed?: ReadonlyArray; - /** The semantic zoom; the workstream overview when absent. */ - readonly mode?: LoomMode; - /** The root thread on the spine and in Agent mode; the most recently active when absent. */ - readonly root?: string; -} - -/** A URL update; an undefined value removes that key. */ -export type LoomSearchPatch = { readonly [Key in keyof LoomSearch]?: LoomSearch[Key] | undefined }; - -export const LOOM_STATUS_OPTIONS: ReadonlyArray<{ - readonly value: LoomRunStatusFilter; - readonly label: string; -}> = [ - { value: "active", label: "Active" }, - { value: "completed", label: "Completed" }, - { value: "failed", label: "Failed" }, - { value: "interrupted", label: "Interrupted" }, -]; - -const MAX_VALUES = 20; -const MAX_COLLAPSED = 100; - -function strings(raw: unknown, limit: number): ReadonlyArray | undefined { - const values = (Array.isArray(raw) ? raw : typeof raw === "string" ? [raw] : []) - .filter((value): value is string => typeof value === "string" && value.trim().length > 0) - .map((value) => value.trim().slice(0, 300)); - const unique = [...new Set(values)].slice(0, limit); - return unique.length === 0 ? undefined : unique; -} - -function isoString(raw: unknown): string | undefined { - return typeof raw === "string" && Number.isFinite(Date.parse(raw)) ? raw : undefined; -} - -export function validateLoomSearch(raw: Record): LoomSearch { - const status = strings(raw.status, 4)?.filter((value): value is LoomRunStatusFilter => - LOOM_STATUS_OPTIONS.some((option) => option.value === value), - ); - const fields = { - from: isoString(raw.from), - to: isoString(raw.to), - q: typeof raw.q === "string" && raw.q.trim() ? raw.q.trim().slice(0, 200) : undefined, - status: status === undefined || status.length === 0 ? undefined : status, - provider: strings(raw.provider, MAX_VALUES), - model: strings(raw.model, MAX_VALUES), - branch: strings(raw.branch, MAX_VALUES), - sel: typeof raw.sel === "string" && raw.sel ? raw.sel.slice(0, 600) : undefined, - collapsed: strings(raw.collapsed, MAX_COLLAPSED), - mode: LOOM_MODES.find((mode) => mode === raw.mode), - root: typeof raw.root === "string" && raw.root ? raw.root.slice(0, 300) : undefined, - }; - return Object.fromEntries( - Object.entries(fields).filter(([, value]) => value !== undefined), - ) as LoomSearch; -} - -export function loomFiltersOf(search: LoomSearch): LoomFilters | undefined { - const filters: LoomFilters = { - ...(search.q === undefined ? {} : { title: search.q }), - ...(search.status === undefined ? {} : { statuses: search.status }), - ...(search.provider === undefined - ? {} - : { - providerInstanceIds: search.provider as NonNullable, - }), - ...(search.model === undefined ? {} : { models: search.model }), - ...(search.branch === undefined ? {} : { branches: search.branch }), - }; - return Object.keys(filters).length === 0 ? undefined : filters; -} - -/** A selected timeline entity, encoded as `kind:id` in the URL. */ -export type LoomSelection = - | { readonly kind: "lane"; readonly id: string } - | { readonly kind: "run"; readonly id: string } - | { readonly kind: "marker"; readonly id: string } - | { readonly kind: "edge"; readonly id: string }; - -export function encodeLoomSelection(selection: LoomSelection): string { - return `${selection.kind}:${selection.id}`; -} - -export function decodeLoomSelection(value: string | undefined): LoomSelection | null { - if (value === undefined) return null; - const separator = value.indexOf(":"); - const kind = value.slice(0, separator); - const id = value.slice(separator + 1); - if (separator <= 0 || id.length === 0) return null; - return kind === "lane" || kind === "run" || kind === "marker" || kind === "edge" - ? { kind, id } - : null; -} diff --git a/apps/web/src/components/loom/loomSpindle.test.ts b/apps/web/src/components/loom/loomSpindle.test.ts deleted file mode 100644 index 6044799f8a89e..0000000000000 --- a/apps/web/src/components/loom/loomSpindle.test.ts +++ /dev/null @@ -1,269 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import { - BAND_GAP, - cullGlyphs, - dotPaths, - glyphCandidates, - spindleGeometry, - SPINDLE_LEAD, - spindleLayout, - spindleView, - spindleY, - STRAND_GAP, - strandDots, - strandPath, - type Glyph, -} from "./loomSpindle"; -import { buildWeave, type WeaveEvent } from "./loomWeave"; -import { checkpoint, edge, iso, lane, run, window } from "./loomWeave.test"; - -const MINUTE = 60_000; -const T0 = Date.parse("2026-10-22T12:00:00.000Z"); - -describe("spindleY", () => { - const geometry = spindleGeometry(1000, 400, 0, 1000); - - it("sits on the spine at both anchors and at its home between the fans", () => { - expect(geometry.fan).toBe(180); - expect(spindleY(geometry, 50, 0)).toBe(200); - expect(spindleY(geometry, 50, 180)).toBe(50); - expect(spindleY(geometry, 50, 500)).toBe(50); - expect(spindleY(geometry, 50, 1000)).toBe(200); - expect(spindleY(geometry, 50, 90)).toBe(125); - }); - - it("shrinks the fan when the tail is close to the start", () => { - const short = spindleGeometry(1000, 400, 0, 100); - expect(short.fan).toBe(50); - expect(spindleY(short, 0, 50)).toBe(0); - }); -}); - -describe("strandPath", () => { - const geometry = spindleGeometry(1000, 400, 0, 1000); - - it("starts and ends exactly at the requested extent and bends only inside the fans", () => { - const path = strandPath(geometry, 100, 120, 700); - const points = path.split(/[ML]/).filter(Boolean); - expect(points[0]).toBe(`120.0 ${spindleY(geometry, 100, 120).toFixed(1)}`); - expect(points.at(-1)).toBe("700.0 100.0"); - const xs = points.map((point) => Number(point.split(" ")[0])); - expect(xs.filter((x) => x > 180 && x < 820)).toEqual([700]); - }); - - it("is empty for an extent with no length", () => { - expect(strandPath(geometry, 100, 300, 300)).toBe(""); - }); -}); - -describe("spindleView", () => { - it("frames the window's activity through the loaded tail, with the label room on the left", () => { - const bounds = { fromMs: T0 - 24 * 60 * MINUTE, toMs: T0 + 60 * MINUTE }; - const weave = buildWeave( - window({ - from: iso(bounds.fromMs), - to: iso(bounds.toMs), - asOf: iso(T0 + 50 * MINUTE), - lanes: [lane("root", null)], - runs: [run("r", { threadId: "root", requestedAt: iso(T0), completedAt: iso(T0 + MINUTE) })], - }), - ); - expect(weave.startMs).toBe(T0); - expect(weave.tailMs).toBe(T0 + 50 * MINUTE); - const view = spindleView(weave, bounds); - const span = view.toMs - view.fromMs; - expect((T0 - view.fromMs) / span).toBeCloseTo(SPINDLE_LEAD); - expect(view.toMs).toBeGreaterThan(weave.tailMs); - expect(view.toMs).toBeLessThanOrEqual(bounds.toMs); - }); -}); - -describe("spindleLayout", () => { - const data = window({ - lanes: [ - lane("quiet", null), - lane("busy", null), - lane("busy-child", "busy"), - lane("recent", null), - ], - runs: [ - run("q", { threadId: "quiet", requestedAt: iso(T0), completedAt: iso(T0 + MINUTE) }), - run("b", { threadId: "busy", completedAt: iso(T0 + 50 * MINUTE) }), - run("bc", { threadId: "busy-child", completedAt: iso(T0 + 40 * MINUTE) }), - run("r", { threadId: "recent", completedAt: iso(T0 + 90 * MINUTE) }), - ], - }); - const weave = buildWeave(data); - - it("puts the spine root on the centre line and its strands next to it", () => { - const layout = spindleLayout(weave, weave.workstreams[1]!, 400); - expect(layout.spine?.rootId).toBe("busy"); - expect(layout.homeY.get("busy")).toBe(200); - const childOffset = 200 - layout.homeY.get("busy-child")!; - expect(childOffset).toBeGreaterThanOrEqual(STRAND_GAP); - expect(childOffset).toBeLessThan(200 - layout.bands[0]!.y); - }); - - it("spreads a sparse weave to fill the field, but only so far", () => { - const spread = (height: number) => { - const layout = spindleLayout(weave, weave.workstreams[0]!, height); - const ys = [...layout.homeY.values()]; - return Math.max(...ys) - Math.min(...ys); - }; - expect(spread(200)).toBeGreaterThan(spread(120)); - expect(spread(2000)).toBe(spread(4000)); - expect(spread(2000)).toBeLessThan(2000 / 4); - }); - - it("alternates the other bands above and below, nearest first", () => { - const layout = spindleLayout(weave, weave.workstreams[1]!, 400); - expect(layout.bands.map((band) => band.workstream.rootId)).toEqual(["recent", "quiet"]); - const [recent, quiet] = layout.bands; - expect(recent!.y).toBeLessThan(200); - expect(quiet!.y).toBeGreaterThan(200); - expect(200 - recent!.y).toBeLessThan(quiet!.y - 200 + BAND_GAP); - }); - - it("gives a child that recorded nothing but its creation no strand", () => { - const quiet = buildWeave( - window({ - lanes: [lane("root", null), lane("blip", "root")], - runs: [run("r", { threadId: "root" })], - }), - ); - const layout = spindleLayout(quiet, quiet.workstreams[0]!, 400); - expect([...layout.homeY.keys()]).toEqual(["root"]); - }); - - it("compresses toward the centre when the field is too short", () => { - const tall = spindleLayout(weave, weave.workstreams[0]!, 400); - const short = spindleLayout(weave, weave.workstreams[0]!, 40); - for (const [threadId, y] of short.homeY) { - expect(y).toBeGreaterThanOrEqual(0); - expect(y).toBeLessThanOrEqual(40); - expect(Math.sign(y - 20)).toBe(Math.sign(tall.homeY.get(threadId)! - 200)); - } - }); -}); - -const tick = (ms: number, kind: WeaveEvent["kind"]): WeaveEvent => ({ - key: `tick:${ms}:${kind}`, - ms, - kind, - failed: kind === "error", - selection: { kind: "lane", id: "s" }, - detail: null, -}); - -describe("strandDots", () => { - const x = (ms: number) => ms / MINUTE; - - it("keeps one dot per pixel column with the telling kind and the count", () => { - const dots = strandDots( - [tick(10 * MINUTE, "agent"), tick(10.2 * MINUTE, "edit"), tick(30 * MINUTE, "tool")], - x, - () => 7, - 100, - ); - expect(dots).toEqual([ - { kind: "edit", x: 10.5, y: 7, count: 2 }, - { kind: "tool", x: 30.5, y: 7, count: 1 }, - ]); - expect([...dotPaths(dots).keys()]).toEqual(["edit", "tool"]); - }); - - it("drops markers, which get glyphs, and ticks outside the field", () => { - const marker: WeaveEvent = { - ...tick(5 * MINUTE, "checkpoint"), - selection: { kind: "marker", id: "m" }, - }; - expect( - strandDots([marker, tick(-MINUTE, "agent"), tick(500 * MINUTE, "agent")], x, () => 0, 100), - ).toEqual([]); - }); -}); - -describe("glyphCandidates and cullGlyphs", () => { - const glyph = ( - threadId: string, - x: number, - kind: Glyph["kind"], - failed = false, - y = 0, - ): Glyph => ({ - key: `${threadId}:${x}:${kind}`, - threadId, - x, - y, - ms: x, - kind, - failed, - selection: { kind: "lane", id: threadId }, - detail: null, - }); - - it("offers the significant events of a strand inside the field, not its spawn or rejoin", () => { - const weave = buildWeave( - window({ - lanes: [lane("root", null), lane("child", "root", { createdAt: iso(T0 + 5 * MINUTE) })], - runs: [run("c", { threadId: "child" })], - markers: [ - checkpoint("child", T0 + 15 * MINUTE, 2), - checkpoint("child", T0 + 16 * MINUTE, 0), - ], - edges: [edge("subagent_result", "child", "root", T0 + 21 * MINUTE)], - activity: [ - { - runId: "c", - threadId: "child", - ticks: [ - [MINUTE, "agent"], - [2 * MINUTE, "edit"], - ], - } as never, - ], - }), - ); - const candidates = glyphCandidates( - weave.strands.get("child")!, - (ms) => (ms - T0) / MINUTE, - () => 0, - 20, - ); - expect(candidates.map((candidate) => [candidate.kind, candidate.x])).toEqual([ - ["edit", 12], - ["checkpoint", 15], - ]); - }); - - it("keeps a failure over an edit that would touch it, but not over one clear of it", () => { - const kept = cullGlyphs( - [ - glyph("a", 100, "edit"), - glyph("a", 110, "error", true), - glyph("b", 104, "edit", false, 8), - glyph("c", 100, "edit", false, 30), - ], - 26, - 250, - 6, - ); - expect(kept.map((entry) => [entry.threadId, entry.kind])).toEqual([ - ["c", "edit"], - ["a", "error"], - ]); - }); - - it("never returns more than the cap, preferring the higher ranked", () => { - const many = Array.from({ length: 300 }, (_, index) => - glyph(`s${index}`, index, index % 2 === 0 ? "command" : "test", false, index * 20), - ); - const kept = cullGlyphs(many, 26, 250); - expect(kept).toHaveLength(250); - expect(kept.filter((entry) => entry.kind === "test")).toHaveLength(150); - expect(kept.map((entry) => entry.x)).toEqual( - kept.map((entry) => entry.x).toSorted((a, b) => a - b), - ); - }); -}); diff --git a/apps/web/src/components/loom/loomSpindle.ts b/apps/web/src/components/loom/loomSpindle.ts deleted file mode 100644 index 1cab91df44d03..0000000000000 --- a/apps/web/src/components/loom/loomSpindle.ts +++ /dev/null @@ -1,298 +0,0 @@ -import { WEAVE_KINDS, type WeaveEventKind } from "./loomKinds"; -import { frameLoomSpan, type LoomView } from "./loomScale"; -import type { LoomSelection } from "./loomSearch"; -import { - quietChildren, - type Weave, - type WeaveEvent, - type WeaveStrand, - type Workstream, -} from "./loomWeave"; - -/** Distance between strands inside a band, and between bands. */ -export const STRAND_GAP = 6; -export const BAND_GAP = 20; -const FIELD_MARGIN = 28; -/** - * A sparse weave spreads to fill the field, at least to MIN_OCCUPY of its height, but its strands - * never sit further apart than MAX_STRAND_GAP or they stop reading as one weave. - */ -const MAX_SPREAD = 4; -const MIN_OCCUPY = 0.35; -const MAX_STRAND_GAP = 48; - -export interface SpindleGeometry { - readonly cy: number; - readonly startX: number; - readonly tailX: number; - /** How far the strands take to leave the spine and to rejoin it. */ - readonly fan: number; -} - -/** Share of the framed view left of the start anchor, where the workstream labels sit. */ -export const SPINDLE_LEAD = 0.2; - -/** The spindle from its start anchor to its loaded tail, with room on the left for the labels. */ -export function spindleView(weave: Weave, bounds: LoomView): LoomView { - return frameLoomSpan( - { firstMs: weave.startMs, lastMs: weave.tailMs }, - bounds, - SPINDLE_LEAD, - 0.03, - ); -} - -/** - * The anchors sit at the start of the window's activity and at its loaded tail. The fan takes 18% - * of the field each side, or half the spindle when it is shorter than that. - */ -export function spindleGeometry( - width: number, - height: number, - startX: number, - tailX: number, -): SpindleGeometry { - const clampedTail = Math.max(startX, tailX); - return { - cy: height / 2, - startX, - tailX: clampedTail, - fan: Math.min(width * 0.18, (clampedTail - startX) / 2), - }; -} - -const smoothstep = (from: number, to: number, x: number) => { - if (to <= from) return x >= to ? 1 : 0; - const t = Math.min(1, Math.max(0, (x - from) / (to - from))); - return t * t * (3 - 2 * t); -}; - -/** A strand's y at x: on the spine at both anchors, at its home y between the fans. */ -export function spindleY(geometry: SpindleGeometry, homeY: number, x: number): number { - const { cy, startX, tailX, fan } = geometry; - const spread = smoothstep(startX, startX + fan, x) * (1 - smoothstep(tailX - fan, tailX, x)); - return cy + (homeY - cy) * spread; -} - -/** The strand sampled from xa to xb, dense inside the fans and straight between them. */ -export function strandPath(geometry: SpindleGeometry, homeY: number, xa: number, xb: number) { - if (!(xb > xa)) return ""; - const xs: number[] = [xa]; - const { startX, tailX, fan } = geometry; - const step = Math.max(2, fan / 12); - for (const [from, to] of [ - [startX, startX + fan], - [tailX - fan, tailX], - ]) { - for (let x = from!; x <= to!; x += step) if (x > xa && x < xb) xs.push(x); - } - xs.push(xb); - return xs - .map( - (x, index) => - `${index === 0 ? "M" : "L"}${x.toFixed(1)} ${spindleY(geometry, homeY, x).toFixed(1)}`, - ) - .join(""); -} - -export interface SpindleBand { - readonly workstream: Workstream; - /** Where the band's label sits. */ - readonly y: number; -} - -export interface SpindleLayout { - readonly cy: number; - readonly spine: Workstream | null; - /** Bands other than the spine, nearest first. */ - readonly bands: ReadonlyArray; - /** Every strand's home y, the spine root at cy. */ - readonly homeY: ReadonlyMap; -} - -/** - * The spine workstream sits on the centre line, its other strands close around it, busiest - * nearest; quiet children get no strand. Remaining workstreams alternate above and below, most - * recently active nearest. The whole stack scales about the centre to fill the field: compressed - * when it is too tall, spread when it is sparse. - */ -export function spindleLayout( - weave: Weave, - spine: Workstream | null, - height: number, -): SpindleLayout { - const cy = height / 2; - const offsets = new Map(); - const bands: Array<{ workstream: Workstream; offset: number }> = []; - let above = 0; - let below = 0; - const drawn = (workstream: Workstream) => { - const quiet = quietChildren(workstream); - return workstream.strands.filter((strand) => !quiet.has(strand.threadId)); - }; - if (spine !== null) { - // The busiest strands sit next to the spine, where the eye starts; the ones that recorded - // little take the outer edge. - const [root, ...rest] = drawn(spine); - const ordered = [ - root!, - ...rest.toSorted((left, right) => right.events.length - left.events.length), - ]; - ordered.forEach((strand, index) => { - const k = Math.ceil(index / 2) * STRAND_GAP; - offsets.set(strand.threadId, index % 2 === 1 ? -k : k); - }); - const half = Math.ceil((ordered.length - 1) / 2) * STRAND_GAP; - above = -half; - below = half; - } - weave.workstreams - .filter((workstream) => workstream !== spine) - .forEach((workstream, index) => { - const strands = drawn(workstream); - const size = strands.length * STRAND_GAP; - const sign = index % 2 === 0 ? -1 : 1; - const edge = sign < 0 ? (above -= BAND_GAP) : (below += BAND_GAP); - strands.forEach((strand, position) => { - offsets.set(strand.threadId, edge + sign * (STRAND_GAP / 2 + position * STRAND_GAP)); - }); - bands.push({ workstream, offset: edge + (sign * size) / 2 }); - if (sign < 0) above -= size; - else below += size; - }); - const needed = Math.max(-above, below) * 2; - const margin = Math.min(FIELD_MARGIN, height / 8); - const spread = Math.min( - MAX_STRAND_GAP / STRAND_GAP, - Math.max(MAX_SPREAD, (height * MIN_OCCUPY) / needed), - ); - const scale = needed > 0 ? Math.min(spread, (height - 2 * margin) / needed) : 1; - return { - cy, - spine, - bands: bands.map(({ workstream, offset }) => ({ workstream, y: cy + offset * scale })), - homeY: new Map([...offsets].map(([threadId, offset]) => [threadId, cy + offset * scale])), - }; -} - -export interface TickDot { - readonly kind: WeaveEventKind; - readonly x: number; - readonly y: number; - readonly count: number; -} - -/** Ticks on one strand collapsed to one dot per pixel column, coloured by the telling kind. */ -export function strandDots( - events: ReadonlyArray, - x: (ms: number) => number, - y: (x: number) => number, - width: number, -): ReadonlyArray { - const columns = new Map(); - for (const event of events) { - if (event.selection.kind !== "lane") continue; - const column = Math.floor(x(event.ms)); - if (column < 0 || column >= width) continue; - const bin = columns.get(column); - if (bin === undefined) columns.set(column, { kind: event.kind, count: 1 }); - else { - bin.count += 1; - if (WEAVE_KINDS[event.kind].priority > WEAVE_KINDS[bin.kind].priority) bin.kind = event.kind; - } - } - return [...columns].map(([column, bin]) => ({ - kind: bin.kind, - x: column + 0.5, - y: y(column + 0.5), - count: bin.count, - })); -} - -/** One path per kind, so thousands of dots stay a handful of elements. */ -export function dotPaths(dots: ReadonlyArray): ReadonlyMap { - const parts = new Map(); - for (const dot of dots) { - const r = 1.4 + Math.min(1.2, Math.log2(dot.count) * 0.4); - const segment = `M${(dot.x - r).toFixed(1)} ${dot.y.toFixed(1)}a${r} ${r} 0 1 0 ${2 * r} 0a${r} ${r} 0 1 0 ${-2 * r} 0`; - const list = parts.get(dot.kind); - if (list === undefined) parts.set(dot.kind, [segment]); - else list.push(segment); - } - return new Map([...parts].map(([kind, segments]) => [kind, segments.join("")])); -} - -export interface Glyph { - readonly key: string; - readonly threadId: string; - readonly x: number; - readonly y: number; - readonly ms: number; - readonly kind: WeaveEventKind; - readonly failed: boolean; - readonly selection: LoomSelection; - readonly detail: string | null; -} - -export const GLYPH_RADIUS = 7; -const GLYPH_SPACING = 26; -const GLYPH_CAP = 250; - -/** - * The events worth a glyph on a strand. Spawns and rejoins are left to Agent mode: in the overview - * the strand leaving and returning to the spine already shows them. - */ -export function glyphCandidates( - strand: WeaveStrand, - x: (ms: number) => number, - y: (x: number) => number, - width: number, -): Glyph[] { - return strand.events.flatMap((event) => { - if (!WEAVE_KINDS[event.kind].significant) return []; - const px = x(event.ms); - if (px < 0 || px > width) return []; - return [ - { - key: event.key, - threadId: strand.threadId, - x: px, - y: y(px), - ms: event.ms, - kind: event.kind, - failed: event.failed, - selection: event.selection, - detail: event.detail, - }, - ]; - }); -} - -const glyphRank = (glyph: Glyph) => (glyph.failed ? 100 : 0) + WEAVE_KINDS[glyph.kind].priority; - -/** - * Keeps the most telling glyphs: failures first, then by kind. A glyph is dropped when a kept one - * is within `spacing` along the time axis and close enough vertically to touch it, which covers - * both a busy strand and strands that crowd together inside the fans. Never more than `cap` in - * all, returned left to right. - */ -export function cullGlyphs( - candidates: ReadonlyArray, - spacing = GLYPH_SPACING, - cap = GLYPH_CAP, - radius = GLYPH_RADIUS, -): ReadonlyArray { - const ordered = candidates.toSorted( - (left, right) => glyphRank(right) - glyphRank(left) || left.ms - right.ms, - ); - const kept: Glyph[] = []; - for (const glyph of ordered) { - if (kept.length >= cap) break; - const touches = kept.some( - (other) => - Math.abs(other.x - glyph.x) < spacing && Math.abs(other.y - glyph.y) < 2 * radius + 2, - ); - if (!touches) kept.push(glyph); - } - return kept.sort((left, right) => left.x - right.x); -} diff --git a/apps/web/src/components/loom/loomWeave.test.ts b/apps/web/src/components/loom/loomWeave.test.ts deleted file mode 100644 index dd4d16715839d..0000000000000 --- a/apps/web/src/components/loom/loomWeave.test.ts +++ /dev/null @@ -1,368 +0,0 @@ -import type { LoomLaneActivity } from "@t3tools/client-runtime/projectLoom"; -import type { LoomEdge, LoomLane, LoomMarker, LoomRun, LoomWindow } from "@t3tools/contracts"; -import { describe, expect, it } from "vite-plus/test"; - -import { - buildWeave, - isLoomEventMarker, - laneExtent, - spineWorkstream, - strandColors, -} from "./loomWeave"; - -const MINUTE = 60_000; -const T0 = Date.parse("2026-10-22T12:00:00.000Z"); -export const iso = (ms: number) => new Date(ms).toISOString(); - -export function run( - id: string, - patch: Partial> & { readonly threadId?: string } = {}, -): LoomRun { - return { - id, - threadId: "lane", - ordinal: 1, - status: "completed", - providerInstanceId: "codex", - model: "gpt-5", - userMessageId: "message", - requestedAt: iso(T0 + 10 * MINUTE), - startedAt: iso(T0 + 11 * MINUTE), - completedAt: iso(T0 + 20 * MINUTE), - ...patch, - } as LoomRun; -} - -export const lane = (threadId: string, parent: string | null, patch: Partial = {}) => - ({ - threadId, - title: threadId, - relationship: parent === null ? "root" : "subagent", - parentThreadId: parent, - providerInstanceId: "codex", - model: "gpt-5", - branch: null, - createdAt: iso(T0), - archivedAt: null, - ...patch, - }) as LoomLane; - -export const checkpoint = ( - threadId: string, - ms: number, - fileCount: number, - status: "ready" | "error" = "ready", -) => - ({ - kind: "checkpoint", - id: `cp-${threadId}-${ms}`, - threadId, - runId: null, - at: iso(ms), - status, - fileCount, - additions: 0, - deletions: 0, - }) as LoomMarker; - -export const edge = ( - type: LoomEdge["type"], - source: string, - target: string, - ms: number, -): LoomEdge => - ({ - id: `${type}:${source}:${target}`, - type, - sourceThreadId: source, - targetThreadId: target, - sourceRunId: null, - targetRunId: null, - at: iso(ms), - status: "completed", - }) as LoomEdge; - -export function window(patch: Partial): LoomWindow { - return { - projectId: "project", - asOf: iso(T0 + 120 * MINUTE), - from: iso(T0 - 60 * MINUTE), - to: iso(T0 + 120 * MINUTE), - lanes: [], - ancestors: [], - runs: [], - markers: [], - edges: [], - activity: [], - truncated: { - lanes: false, - ancestors: false, - runs: false, - markers: false, - edges: false, - activity: false, - bytes: false, - }, - coverage: { - untimedTasks: 0, - untimedTasksTruncated: false, - commandsScanned: 0, - commandsTruncated: false, - }, - nextCursor: null, - ...patch, - } as LoomWindow; -} - -const activity = (patch: Partial): LoomLaneActivity => ({ - runs: [], - markers: [], - activity: [], - ...patch, -}); - -describe("laneExtent", () => { - const created = { createdAt: iso(T0), archivedAt: null }; - - it("spans creation to the last completion, tick or marker", () => { - const extent = laneExtent( - created, - activity({ - runs: [run("r1")], - activity: [{ runId: "r1", threadId: "lane", ticks: [[25 * MINUTE, "agent"]] } as never], - }), - T0 + 60 * MINUTE, - ); - expect(extent).toEqual({ startMs: T0, endMs: T0 + 35 * MINUTE, active: false }); - }); - - it("reaches the as-of time while a run is still active", () => { - const asOf = T0 + 90 * MINUTE; - const extent = laneExtent( - created, - activity({ runs: [run("r1", { status: "running", completedAt: null })] }), - asOf, - ); - expect(extent).toEqual({ startMs: T0, endMs: asOf, active: true }); - }); - - it("stops at the archive time and is no longer active", () => { - const extent = laneExtent( - { createdAt: iso(T0), archivedAt: iso(T0 + 15 * MINUTE) }, - activity({ runs: [run("r1", { status: "running", completedAt: null })] }), - T0 + 90 * MINUTE, - ); - expect(extent).toEqual({ startMs: T0, endMs: T0 + 15 * MINUTE, active: false }); - }); - - it("starts at an earlier run than the lane's creation", () => { - const extent = laneExtent( - { createdAt: iso(T0 + 30 * MINUTE), archivedAt: null }, - activity({ runs: [run("r1")] }), - T0 + 90 * MINUTE, - ); - expect(extent.startMs).toBe(T0 + 10 * MINUTE); - }); -}); - -describe("isLoomEventMarker", () => { - it("leaves out ready checkpoints that changed nothing", () => { - expect(isLoomEventMarker(checkpoint("lane", T0, 0))).toBe(false); - expect(isLoomEventMarker(checkpoint("lane", T0, 2))).toBe(true); - expect(isLoomEventMarker(checkpoint("lane", T0, 0, "error"))).toBe(true); - }); -}); - -describe("buildWeave", () => { - const data = window({ - lanes: [ - lane("old-root", null), - lane("root", null, { createdAt: iso(T0 + 5 * MINUTE) }), - lane("exact-child", "root", { createdAt: iso(T0 + 12 * MINUTE) }), - lane("inferred-child", "root", { createdAt: iso(T0 + 14 * MINUTE) }), - lane("grandchild", "exact-child", { createdAt: iso(T0 + 16 * MINUTE) }), - ], - runs: [ - run("old", { threadId: "old-root", requestedAt: iso(T0), completedAt: iso(T0 + MINUTE) }), - run("r1", { threadId: "root" }), - run("r2", { - threadId: "root", - ordinal: 2, - status: "failed", - requestedAt: iso(T0 + 30 * MINUTE), - startedAt: iso(T0 + 31 * MINUTE), - completedAt: iso(T0 + 40 * MINUTE), - }), - run("c1", { - threadId: "exact-child", - requestedAt: iso(T0 + 13 * MINUTE), - startedAt: iso(T0 + 13 * MINUTE), - completedAt: iso(T0 + 18 * MINUTE), - }), - ], - markers: [ - checkpoint("root", T0 + 20 * MINUTE, 0), - checkpoint("root", T0 + 21 * MINUTE, 3), - { - kind: "test_command", - id: "t1", - threadId: "root", - runId: "r1", - at: iso(T0 + 19 * MINUTE), - completedAt: iso(T0 + 19 * MINUTE + 5_000), - command: "vp test run", - exitCode: 1, - } as LoomMarker, - ], - edges: [ - edge("subagent_spawn", "root", "exact-child", T0 + 12 * MINUTE + 30_000), - edge("subagent_result", "exact-child", "root", T0 + 18 * MINUTE + 30_000), - ], - activity: [ - { - runId: "r1", - threadId: "root", - ticks: [ - [MINUTE, "user"], - [2 * MINUTE, "edit"], - [3 * MINUTE, "error"], - ], - } as never, - ], - }); - const weave = buildWeave(data); - const root = weave.strands.get("root")!; - - it("orders workstreams by their last activity and colours them in that order", () => { - expect(weave.workstreams.map((w) => [w.rootId, w.colorIndex])).toEqual([ - ["root", 0], - ["old-root", 1], - ]); - expect(weave.workstreams[0]!.strands.map((s) => s.threadId)).toEqual([ - "root", - "exact-child", - "grandchild", - "inferred-child", - ]); - }); - - it("counts agents, runs and failures per workstream", () => { - const [busy, old] = weave.workstreams; - expect(busy).toMatchObject({ agentCount: 3, runCount: 3, failureCount: 2 }); - expect(old).toMatchObject({ agentCount: 0, runCount: 1, failureCount: 0 }); - }); - - it("places a spawn exactly from its edge and otherwise infers it from creation", () => { - expect(weave.strands.get("exact-child")!.spawn).toEqual({ - ms: T0 + 12 * MINUTE + 30_000, - exact: true, - }); - expect(weave.strands.get("inferred-child")!.spawn).toEqual({ - ms: T0 + 14 * MINUTE, - exact: false, - }); - expect(root.spawn).toBeNull(); - }); - - it("records a rejoin only when an edge returns to the parent", () => { - expect(weave.strands.get("exact-child")!.rejoin).toMatchObject({ - ms: T0 + 18 * MINUTE + 30_000, - }); - expect(weave.strands.get("inferred-child")!.rejoin).toBeNull(); - }); - - it("ends each strand by its latest run", () => { - expect(root.end).toBe("failed"); - expect(weave.strands.get("exact-child")!.end).toBe("completed"); - expect(weave.strands.get("inferred-child")!.end).toBe("unknown"); - }); - - it("lists events oldest first, skipping empty checkpoints and marking failures", () => { - expect(root.events.map((event) => [event.kind, event.failed])).toEqual([ - ["run", false], - ["user", false], - ["edit", false], - ["error", true], - ["test", true], - ["checkpoint", false], - ["run", false], - ["error", true], - ]); - expect(root.events.at(-1)!.ms).toBe(T0 + 40 * MINUTE); - expect(root.events[1]!.selection).toEqual({ kind: "lane", id: "root" }); - }); - - it("spans a runless subagent from its recorded spawn to its recorded result", () => { - const native = buildWeave( - window({ - lanes: [ - lane("root", null), - lane("native", "root", { createdAt: iso(T0 + 2 * MINUTE) }), - lane("unrecorded", "root", { createdAt: iso(T0 + 3 * MINUTE) }), - ], - edges: [ - edge("subagent_spawn", "root", "native", T0 + 2 * MINUTE), - edge("subagent_result", "native", "root", T0 + 9 * MINUTE), - ], - }), - ); - expect(native.strands.get("native")!.extent).toMatchObject({ - startMs: T0 + 2 * MINUTE, - endMs: T0 + 9 * MINUTE, - }); - expect(native.strands.get("unrecorded")!.extent).toMatchObject({ - startMs: T0 + 3 * MINUTE, - endMs: T0 + 3 * MINUTE, - }); - }); - - it("ends the loaded tail at the window's end when the as-of time is later", () => { - expect(weave.tailMs).toBe(Math.min(Date.parse(data.asOf), Date.parse(data.to))); - const late = buildWeave({ ...data, to: iso(T0 + 50 * MINUTE), asOf: iso(T0 + 90 * MINUTE) }); - expect(late.tailMs).toBe(T0 + 50 * MINUTE); - }); - - it("colours the spine's branches apart and every other workstream as one", () => { - const colors = strandColors(weave, weave.workstreams[0]!); - expect(colors.get("root")).toBe(0); - expect(colors.get("exact-child")).toBe(1); - expect(colors.get("grandchild")).toBe(1); - expect(colors.get("inferred-child")).toBe(2); - expect(colors.get("old-root")).toBe(1); - const other = strandColors(weave, weave.workstreams[1]!); - expect(new Set(["root", "exact-child", "inferred-child"].map((id) => other.get(id)))).toEqual( - new Set([0]), - ); - }); - - it("preserves nested ancestry when only the descendant is on this page", () => { - const paged = buildWeave( - window({ - lanes: [lane("grandchild", "child")], - ancestors: [lane("root", null), lane("child", "root")], - runs: [run("grandchild-run", { threadId: "grandchild" })], - }), - ); - const workstream = paged.workstreams[0]!; - expect( - workstream.strands.map(({ threadId, parentThreadId, depth }) => [ - threadId, - parentThreadId, - depth, - ]), - ).toEqual([ - ["root", null, 0], - ["child", "root", 1], - ["grandchild", "child", 2], - ]); - const colors = strandColors(paged, workstream); - expect(colors.get("child")).not.toBe(colors.get("root")); - expect(colors.get("grandchild")).toBe(colors.get("child")); - }); - - it("puts the requested workstream on the spine, or the most recent one", () => { - expect(spineWorkstream(weave, "old-root")?.rootId).toBe("old-root"); - expect(spineWorkstream(weave, "missing")?.rootId).toBe("root"); - expect(spineWorkstream(weave, undefined)?.rootId).toBe("root"); - }); -}); diff --git a/apps/web/src/components/loom/loomWeave.ts b/apps/web/src/components/loom/loomWeave.ts deleted file mode 100644 index d3fd30c763108..0000000000000 --- a/apps/web/src/components/loom/loomWeave.ts +++ /dev/null @@ -1,416 +0,0 @@ -import { - buildLoomRows, - groupLoomActivity, - type LoomLaneActivity, -} from "@t3tools/client-runtime/projectLoom"; -import type { LoomEdge, LoomLane, LoomMarker, LoomRun, LoomWindow } from "@t3tools/contracts"; - -import type { WeaveEventKind } from "./loomKinds"; -import { ACTIVE_RUN, loomActivitySpan } from "./loomScale"; -import type { LoomSelection } from "./loomSearch"; - -export const loomMarkerKey = (marker: Pick) => - `${marker.kind}:${marker.id}`; - -/** - * A ready checkpoint with no changed files is the bookkeeping every turn ends with, not an event - * worth a glyph or a list entry. - */ -export function isLoomEventMarker(marker: LoomMarker): boolean { - return !(marker.kind === "checkpoint" && marker.fileCount === 0 && marker.status === "ready"); -} - -export interface LaneExtent { - readonly startMs: number; - readonly endMs: number; - readonly active: boolean; -} - -/** When a lane's lifeline begins and ends: its own creation plus everything recorded on it. */ -export function laneExtent( - lane: Pick, - activity: LoomLaneActivity | undefined, - asOfMs: number, -): LaneExtent { - let startMs = Date.parse(lane.createdAt); - let endMs = startMs; - let active = false; - const include = (ms: number) => { - if (ms < startMs) startMs = ms; - if (ms > endMs) endMs = ms; - }; - const requestedAt = new Map(); - for (const run of activity?.runs ?? []) { - const requestedMs = Date.parse(run.requestedAt); - requestedAt.set(run.id, requestedMs); - include(requestedMs); - if (run.startedAt !== null) include(Date.parse(run.startedAt)); - if (run.completedAt !== null) include(Date.parse(run.completedAt)); - else if (ACTIVE_RUN.has(run.status)) { - active = true; - include(asOfMs); - } - } - for (const marker of activity?.markers ?? []) { - include(Date.parse(marker.at)); - if ("completedAt" in marker && marker.completedAt !== null) { - include(Date.parse(marker.completedAt)); - } - } - for (const run of activity?.activity ?? []) { - const baseMs = requestedAt.get(run.runId); - if (baseMs === undefined) continue; - for (const [offsetMs] of run.ticks) include(baseMs + offsetMs); - } - if (lane.archivedAt !== null) { - const archivedMs = Date.parse(lane.archivedAt); - if (archivedMs < endMs) endMs = Math.max(archivedMs, startMs); - active = false; - } - return { startMs, endMs, active }; -} - -export interface WeaveEvent { - readonly key: string; - readonly ms: number; - readonly kind: WeaveEventKind; - /** A failed run, a test with a non-zero exit, or an error tick. */ - readonly failed: boolean; - /** What clicking the event selects; a plain tick selects its strand. */ - readonly selection: LoomSelection; - readonly detail: string | null; -} - -export type StrandEnd = "completed" | "failed" | "interrupted" | "active" | "unknown"; - -export interface WeaveStrand { - readonly threadId: string; - readonly title: string; - readonly parentThreadId: string | null; - readonly rootId: string; - readonly depth: number; - /** Null for an ancestor stub that is not on this page. */ - readonly lane: LoomLane | null; - readonly extent: LaneExtent | null; - /** Exact when a transfer edge recorded it, inferred from createdAt otherwise. */ - readonly spawn: { readonly ms: number; readonly exact: boolean } | null; - readonly rejoin: { readonly ms: number; readonly edgeId: string } | null; - readonly end: StrandEnd; - /** Oldest first. */ - readonly events: ReadonlyArray; -} - -export interface Workstream { - readonly rootId: string; - readonly title: string; - readonly colorIndex: number; - /** The root first, then its descendants in tree order. */ - readonly strands: ReadonlyArray; - /** -Infinity when nothing on it happened in the window. */ - readonly lastActiveMs: number; - readonly agentCount: number; - readonly runCount: number; - readonly failureCount: number; -} - -export interface Weave { - /** Most recently active first; colours follow this order. */ - readonly workstreams: ReadonlyArray; - readonly strands: ReadonlyMap; - readonly asOfMs: number; - /** Where the window's recorded activity begins: the spindle's start anchor. */ - readonly startMs: number; - /** Where the loaded record ends: the as-of time, or the window's end when that comes first. */ - readonly tailMs: number; -} - -const SPAWN_EDGES = new Set(["subagent_spawn", "fork"]); -const REJOIN_EDGES = new Set([ - "subagent_result", - "merge_back", - "provider_handoff", -]); - -const RUN_END: Record = { - preparing: "active", - queued: "active", - starting: "active", - running: "active", - waiting: "active", - completed: "completed", - failed: "failed", - interrupted: "interrupted", - cancelled: "interrupted", - rolled_back: "interrupted", -}; - -function markerEvent(marker: LoomMarker): WeaveEvent | null { - if (!isLoomEventMarker(marker)) return null; - const base = { - key: loomMarkerKey(marker), - ms: Date.parse(marker.at), - selection: { kind: "marker", id: loomMarkerKey(marker) } as const, - }; - switch (marker.kind) { - case "checkpoint": - return { - ...base, - kind: "checkpoint", - failed: marker.status === "error", - detail: `${marker.fileCount} ${marker.fileCount === 1 ? "file" : "files"}`, - }; - case "test_command": - return { - ...base, - kind: "test", - failed: marker.exitCode !== null && marker.exitCode !== 0, - detail: marker.exitCode === null ? marker.command : `exit ${marker.exitCode}`, - }; - case "pull_request": - return { ...base, kind: "pull_request", failed: false, detail: `#${marker.number}` }; - case "subagent_task": - return { ...base, kind: "task", failed: false, detail: marker.title }; - } -} - -function strandEvents(threadId: string, activity: LoomLaneActivity | undefined): WeaveEvent[] { - if (activity === undefined) return []; - const events: WeaveEvent[] = []; - const requestedAt = new Map(); - for (const run of activity.runs) { - const ms = Date.parse(run.requestedAt); - requestedAt.set(run.id, ms); - const selection = { kind: "run", id: run.id } as const; - events.push({ - key: `run:${run.id}`, - ms, - kind: "run", - failed: false, - selection, - detail: `Run ${run.ordinal}`, - }); - if (run.status === "failed") { - events.push({ - key: `failed:${run.id}`, - ms: run.completedAt === null ? ms : Date.parse(run.completedAt), - kind: "error", - failed: true, - selection, - detail: `Run ${run.ordinal} failed`, - }); - } - } - for (const marker of activity.markers) { - const event = markerEvent(marker); - if (event !== null) events.push(event); - } - const selection = { kind: "lane", id: threadId } as const; - for (const run of activity.activity) { - const baseMs = requestedAt.get(run.runId); - if (baseMs === undefined) continue; - run.ticks.forEach(([offsetMs, kind], index) => { - events.push({ - key: `tick:${run.runId}:${index}`, - ms: baseMs + offsetMs, - kind, - failed: kind === "error", - selection, - detail: null, - }); - }); - } - return events.sort((left, right) => left.ms - right.ms); -} - -/** - * The window as workstreams: each root thread with its descendants, every strand with its extent, - * spawn, rejoin and events. Depends only on the window, so callers memoize it once per load. - */ -export function buildWeave(window: LoomWindow): Weave { - const asOfMs = Date.parse(window.asOf); - const activity = groupLoomActivity(window); - const rows = buildLoomRows(window, new Set()); - const parents = new Map( - [...window.ancestors, ...window.lanes].map((lane) => [lane.threadId, lane.parentThreadId]), - ); - const edgeBetween = ( - source: string, - target: string, - types: ReadonlySet, - ): LoomEdge | undefined => - window.edges.find( - (edge) => - edge.sourceThreadId === source && edge.targetThreadId === target && types.has(edge.type), - ); - - const strands = new Map(); - const groups: WeaveStrand[][] = []; - let rootId = ""; - for (const row of rows) { - if (row.depth === 0) { - rootId = row.threadId; - groups.push([]); - } - const laneActivity = activity.get(row.threadId); - const parent = - row.depth === 0 || row.parentMissing ? null : (parents.get(row.threadId) ?? null); - const spawnEdge = parent === null ? undefined : edgeBetween(parent, row.threadId, SPAWN_EDGES); - const rejoinEdge = - parent === null ? undefined : edgeBetween(row.threadId, parent, REJOIN_EDGES); - const runs = laneActivity?.runs ?? []; - const lastRun = runs.reduce( - (latest, run) => (latest === null || run.requestedAt > latest.requestedAt ? run : latest), - null, - ); - const spawn = - spawnEdge !== undefined - ? { ms: Date.parse(spawnEdge.at), exact: true } - : parent !== null && row.lane !== null - ? { ms: Date.parse(row.lane.createdAt), exact: false } - : null; - const rejoin = - rejoinEdge === undefined ? null : { ms: Date.parse(rejoinEdge.at), edgeId: rejoinEdge.id }; - const strand: WeaveStrand = { - threadId: row.threadId, - title: row.title, - parentThreadId: parent, - rootId, - depth: row.depth, - lane: row.lane, - extent: - row.lane === null - ? null - : withTransfers(laneExtent(row.lane, laneActivity, asOfMs), spawn, rejoin), - spawn, - rejoin, - end: lastRun === null ? "unknown" : RUN_END[lastRun.status], - events: strandEvents(row.threadId, laneActivity), - }; - strands.set(strand.threadId, strand); - groups.at(-1)!.push(strand); - } - - const workstreams = groups - .map((group) => { - const root = group[0]!; - return { - rootId: root.threadId, - title: root.title, - strands: group, - lastActiveMs: Math.max(...group.map((strand) => strand.extent?.endMs ?? -Infinity)), - agentCount: group.length - 1, - runCount: group.reduce( - (total, strand) => total + (activity.get(strand.threadId)?.runs.length ?? 0), - 0, - ), - failureCount: group.reduce( - (total, strand) => - total + - strand.events.filter((event) => event.failed && event.selection.kind !== "lane").length, - 0, - ), - }; - }) - .sort((left, right) => right.lastActiveMs - left.lastActiveMs) - .map((workstream, colorIndex) => ({ ...workstream, colorIndex })); - const fromMs = Date.parse(window.from); - const tailMs = Math.min(asOfMs, Date.parse(window.to)); - const firstMs = loomActivitySpan(window, { fromMs, toMs: tailMs })?.firstMs ?? fromMs; - return { workstreams, strands, asOfMs, startMs: Math.min(firstMs, tailMs), tailMs }; -} - -/** - * A subagent that recorded no run of its own (a provider-native task, say) still worked from the - * recorded spawn to the recorded result, so those transfers bound its extent too. - */ -function withTransfers( - extent: LaneExtent, - spawn: WeaveStrand["spawn"], - rejoin: WeaveStrand["rejoin"], -): LaneExtent { - const exact = [spawn?.exact === true ? spawn.ms : null, rejoin?.ms ?? null].filter( - (ms): ms is number => ms !== null, - ); - if (exact.length === 0) return extent; - return { - ...extent, - startMs: Math.min(extent.startMs, ...exact), - endMs: Math.max(extent.endMs, ...exact), - }; -} - -/** - * Children that recorded nothing but their creation: no run, event or result, and no children of - * their own. A lane for each would be a curve to nowhere, so they show only as a spawn node on - * their parent, and get no strand of their own in either mode. - */ -export function quietChildren(workstream: Workstream): ReadonlySet { - const parents = new Set(workstream.strands.map((strand) => strand.parentThreadId)); - return new Set( - workstream.strands - .filter( - (strand) => - strand.parentThreadId !== null && - strand.events.length === 0 && - strand.rejoin === null && - !parents.has(strand.threadId), - ) - .map((strand) => strand.threadId), - ); -} - -/** The workstream to put on the spine: the requested one when loaded, else the most recent. */ -export function spineWorkstream(weave: Weave, rootId: string | undefined): Workstream | null { - return ( - (rootId === undefined ? undefined : weave.workstreams.find((w) => w.rootId === rootId)) ?? - weave.workstreams[0] ?? - null - ); -} - -/** - * Each strand's colour index. Workstreams keep their own colour, except the one on the spine: - * there each of the root's children takes the next colour and its descendants inherit it, so its - * branches stay apart and a subagent keeps its colour between Workstream and Agent mode. - */ -export function strandColors(weave: Weave, spine: Workstream | null): ReadonlyMap { - const colors = new Map(); - for (const workstream of weave.workstreams) { - if (workstream !== spine) { - for (const strand of workstream.strands) colors.set(strand.threadId, workstream.colorIndex); - continue; - } - let next = workstream.colorIndex + 1; - for (const strand of workstream.strands) { - const inherited = - strand.parentThreadId === null || strand.parentThreadId === workstream.rootId - ? undefined - : colors.get(strand.parentThreadId); - colors.set( - strand.threadId, - strand.parentThreadId === null ? workstream.colorIndex : (inherited ?? next++), - ); - } - } - return colors; -} - -/** The strand a selection belongs to, if any. An edge belongs to the strand it lands on. */ -export function loomSelectionThread( - window: Pick, - selection: LoomSelection | null, -): string | null { - if (selection === null) return null; - switch (selection.kind) { - case "lane": - return selection.id; - case "run": - return window.runs.find((run) => run.id === selection.id)?.threadId ?? null; - case "marker": - return ( - window.markers.find((marker) => loomMarkerKey(marker) === selection.id)?.threadId ?? null - ); - case "edge": - return window.edges.find((edge) => edge.id === selection.id)?.targetThreadId ?? null; - } -} diff --git a/apps/web/src/components/loom/useLoomViewGestures.ts b/apps/web/src/components/loom/useLoomViewGestures.ts deleted file mode 100644 index cdd6b25d268c5..0000000000000 --- a/apps/web/src/components/loom/useLoomViewGestures.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { useEffect, useRef, type PointerEvent, type RefObject } from "react"; - -import { useStableCallback } from "~/hooks/useStableCallback"; -import { loomScale, panLoomView, zoomLoomView, type LoomView } from "./loomScale"; - -/** - * Wheel zoom (ctrl or meta) and horizontal wheel pan on `wheelRef`, plus drag-to-pan pointer - * handlers for an SVG. The timeline starts `leftInset` px into the wheel element. - */ -export function useLoomViewGestures(args: { - readonly wheelRef: RefObject; - readonly view: LoomView; - readonly bounds: LoomView; - readonly width: number; - readonly leftInset: number; - readonly onViewChange: (view: LoomView) => void; -}) { - const { wheelRef, view, bounds, width, leftInset, onViewChange } = args; - const onWheel = useStableCallback((event: WheelEvent) => { - const element = wheelRef.current; - if (element === null || width === 0) return; - const x = event.clientX - element.getBoundingClientRect().left - leftInset; - if (x < 0) return; - const scale = loomScale(view, width); - if (event.ctrlKey || event.metaKey) { - event.preventDefault(); - onViewChange(zoomLoomView(view, bounds, scale.ms(x), Math.exp(event.deltaY * 0.002))); - } else if (event.shiftKey || Math.abs(event.deltaX) > Math.abs(event.deltaY)) { - event.preventDefault(); - const delta = event.shiftKey && event.deltaX === 0 ? event.deltaY : event.deltaX; - onViewChange(panLoomView(view, bounds, delta * scale.msPerPx)); - } - }); - // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- attaches a non-passive wheel listener, since React's passive one cannot preventDefault for zoom - useEffect(() => { - const element = wheelRef.current; - if (element === null) return; - element.addEventListener("wheel", onWheel, { passive: false }); - return () => element.removeEventListener("wheel", onWheel); - }, [onWheel, wheelRef]); - - const drag = useRef<{ x: number; view: LoomView } | null>(null); - const onPointerDown = (event: PointerEvent) => { - if (event.button !== 0 || event.target !== event.currentTarget) return; - drag.current = { x: event.clientX, view }; - event.currentTarget.setPointerCapture(event.pointerId); - }; - const onPointerMove = (event: PointerEvent) => { - const start = drag.current; - if (start === null || width === 0) return; - const { msPerPx } = loomScale(start.view, width); - onViewChange(panLoomView(start.view, bounds, (start.x - event.clientX) * msPerPx)); - }; - const onPointerUp = () => { - drag.current = null; - }; - return { onPointerDown, onPointerMove, onPointerUp }; -} diff --git a/apps/web/src/components/sidebar/mainAppLocation.ts b/apps/web/src/components/sidebar/mainAppLocation.ts index 20a1fac5a5419..8e4267af73890 100644 --- a/apps/web/src/components/sidebar/mainAppLocation.ts +++ b/apps/web/src/components/sidebar/mainAppLocation.ts @@ -9,11 +9,7 @@ export function isAppModalPage(pathname: string) { } export function isSidebarUtilityPage(pathname: string) { - return ( - pathname.startsWith("/projects/") || - pathname.startsWith("/loom/") || - pathname === "/pull-requests" - ); + return pathname.startsWith("/projects/") || pathname === "/pull-requests"; } // Pages the root route always renders without the app shell, so they are never diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index 16939110e9037..6c9f1cb34c7e8 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -22,7 +22,6 @@ import { Route as ChatSettingsRouteImport } from './routes/_chat.settings' import { Route as ChatPullRequestsRouteImport } from './routes/_chat.pull-requests' import { Route as ChatNewRouteImport } from './routes/_chat.new' import { Route as ThreadsEnvironmentIdThreadIdRouteImport } from './routes/threads.$environmentId.$threadId' -import { Route as LoomEnvironmentIdProjectIdRouteImport } from './routes/loom.$environmentId.$projectId' import { Route as ChatSettingsWorktreesRouteImport } from './routes/_chat.settings.worktrees' import { Route as ChatSettingsVoiceRouteImport } from './routes/_chat.settings.voice' import { Route as ChatSettingsThreadsRouteImport } from './routes/_chat.settings.threads' @@ -119,12 +118,6 @@ const ThreadsEnvironmentIdThreadIdRoute = path: '/threads/$environmentId/$threadId', getParentRoute: () => rootRouteImport, } as any) -const LoomEnvironmentIdProjectIdRoute = - LoomEnvironmentIdProjectIdRouteImport.update({ - id: '/loom/$environmentId/$projectId', - path: '/loom/$environmentId/$projectId', - getParentRoute: () => rootRouteImport, - } as any) const ChatSettingsWorktreesRoute = ChatSettingsWorktreesRouteImport.update({ id: '/worktrees', path: '/worktrees', @@ -324,7 +317,6 @@ export interface FileRoutesByFullPath { '/settings/threads': typeof ChatSettingsThreadsRoute '/settings/voice': typeof ChatSettingsVoiceRoute '/settings/worktrees': typeof ChatSettingsWorktreesRoute - '/loom/$environmentId/$projectId': typeof LoomEnvironmentIdProjectIdRoute '/threads/$environmentId/$threadId': typeof ThreadsEnvironmentIdThreadIdRoute } export interface FileRoutesByTo { @@ -369,7 +361,6 @@ export interface FileRoutesByTo { '/settings/threads': typeof ChatSettingsThreadsRoute '/settings/voice': typeof ChatSettingsVoiceRoute '/settings/worktrees': typeof ChatSettingsWorktreesRoute - '/loom/$environmentId/$projectId': typeof LoomEnvironmentIdProjectIdRoute '/threads/$environmentId/$threadId': typeof ThreadsEnvironmentIdThreadIdRoute } export interface FileRoutesById { @@ -416,7 +407,6 @@ export interface FileRoutesById { '/_chat/settings/threads': typeof ChatSettingsThreadsRoute '/_chat/settings/voice': typeof ChatSettingsVoiceRoute '/_chat/settings/worktrees': typeof ChatSettingsWorktreesRoute - '/loom/$environmentId/$projectId': typeof LoomEnvironmentIdProjectIdRoute '/threads/$environmentId/$threadId': typeof ThreadsEnvironmentIdThreadIdRoute } export interface FileRouteTypes { @@ -463,7 +453,6 @@ export interface FileRouteTypes { | '/settings/threads' | '/settings/voice' | '/settings/worktrees' - | '/loom/$environmentId/$projectId' | '/threads/$environmentId/$threadId' fileRoutesByTo: FileRoutesByTo to: @@ -508,7 +497,6 @@ export interface FileRouteTypes { | '/settings/threads' | '/settings/voice' | '/settings/worktrees' - | '/loom/$environmentId/$projectId' | '/threads/$environmentId/$threadId' id: | '__root__' @@ -554,7 +542,6 @@ export interface FileRouteTypes { | '/_chat/settings/threads' | '/_chat/settings/voice' | '/_chat/settings/worktrees' - | '/loom/$environmentId/$projectId' | '/threads/$environmentId/$threadId' fileRoutesById: FileRoutesById } @@ -566,7 +553,6 @@ export interface RootRouteChildren { PairRoute: typeof PairRoute WelcomeRoute: typeof WelcomeRoute ProjectsProjectKeyRoute: typeof ProjectsProjectKeyRoute - LoomEnvironmentIdProjectIdRoute: typeof LoomEnvironmentIdProjectIdRoute ThreadsEnvironmentIdThreadIdRoute: typeof ThreadsEnvironmentIdThreadIdRoute } @@ -663,13 +649,6 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ThreadsEnvironmentIdThreadIdRouteImport parentRoute: typeof rootRouteImport } - '/loom/$environmentId/$projectId': { - id: '/loom/$environmentId/$projectId' - path: '/loom/$environmentId/$projectId' - fullPath: '/loom/$environmentId/$projectId' - preLoaderRoute: typeof LoomEnvironmentIdProjectIdRouteImport - parentRoute: typeof rootRouteImport - } '/_chat/settings/worktrees': { id: '/_chat/settings/worktrees' path: '/worktrees' @@ -979,7 +958,6 @@ const rootRouteChildren: RootRouteChildren = { PairRoute: PairRoute, WelcomeRoute: WelcomeRoute, ProjectsProjectKeyRoute: ProjectsProjectKeyRoute, - LoomEnvironmentIdProjectIdRoute: LoomEnvironmentIdProjectIdRoute, ThreadsEnvironmentIdThreadIdRoute: ThreadsEnvironmentIdThreadIdRoute, } export const routeTree = rootRouteImport diff --git a/apps/web/src/routes/loom.$environmentId.$projectId.tsx b/apps/web/src/routes/loom.$environmentId.$projectId.tsx deleted file mode 100644 index 4347c87c8bb14..0000000000000 --- a/apps/web/src/routes/loom.$environmentId.$projectId.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import type { EnvironmentId, ProjectId } from "@t3tools/contracts"; -import { createFileRoute, redirect } from "@tanstack/react-router"; - -import { ProjectLoomPage } from "../components/loom/ProjectLoomPage"; -import { validateLoomSearch } from "../components/loom/loomSearch"; - -export const Route = createFileRoute("/loom/$environmentId/$projectId")({ - beforeLoad: async ({ context }) => { - if ( - context.authGateState.status !== "authenticated" && - context.authGateState.status !== "hosted-static" - ) { - throw redirect({ to: "/pair", replace: true }); - } - }, - validateSearch: validateLoomSearch, - component: LoomRouteView, -}); - -function LoomRouteView() { - const params = Route.useParams(); - const search = Route.useSearch(); - const navigate = Route.useNavigate(); - return ( - void navigate({ search: update, replace: true })} - /> - ); -} diff --git a/apps/web/src/state/projectLoom.ts b/apps/web/src/state/projectLoom.ts deleted file mode 100644 index b7f1b693c2962..0000000000000 --- a/apps/web/src/state/projectLoom.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { createProjectLoomAtoms } from "@t3tools/client-runtime/projectLoom"; -import { connectionAtomRuntime } from "../connection/runtime"; - -export const projectLoom = createProjectLoomAtoms(connectionAtomRuntime); diff --git a/packages/client-runtime/src/projectLoom/index.ts b/packages/client-runtime/src/projectLoom/index.ts index 3d350a671ad9f..ef899c1df036d 100644 --- a/packages/client-runtime/src/projectLoom/index.ts +++ b/packages/client-runtime/src/projectLoom/index.ts @@ -2,7 +2,6 @@ import { WS_METHODS, type EnvironmentId, type LoomAncestor, - type LoomEdge, type LoomLane, type LoomLaneRelationship, type LoomMarker, @@ -197,18 +196,6 @@ export function loomMarkerLabel(marker: LoomMarker): string { } } -const EDGE_LABELS: Record = { - fork: "Fork", - provider_handoff: "Provider handoff", - merge_back: "Merge back", - subagent_spawn: "Subagent spawned", - subagent_result: "Subagent result", -}; - -export function loomEdgeLabel(edge: LoomEdge): string { - return EDGE_LABELS[edge.type]; -} - /** Which limits cut this window short, in reading order, for a truncation note. */ export function loomTruncationNotes(window: Pick): ReadonlyArray { const { truncated } = window; From fb061bd71737a725b04d769688e83112f316600c Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Thu, 5 Nov 2026 16:47:20 -0800 Subject: [PATCH 02/10] refactor(mobile): remove the Loom project timeline screen The Home and thread sidebar entries, the stack route, and its test registration go with it. With no web or mobile caller left, the client-runtime Loom module goes too. Co-Authored-By: Claude Sonnet 5.5 --- apps/mobile/src/Stack.tsx | 6 - apps/mobile/src/features/home/HomeScreen.tsx | 7 - .../src/features/loom/ProjectLoomEntry.tsx | 36 -- .../loom/ProjectLoomRouteScreen.test.tsx | 389 ----------------- .../features/loom/ProjectLoomRouteScreen.tsx | 409 ------------------ .../src/features/loom/loomActivityList.ts | 100 ----- .../threads/ThreadNavigationSidebar.tsx | 12 +- apps/mobile/src/state/projectLoom.ts | 5 - apps/mobile/vite.config.ts | 1 - packages/client-runtime/package.json | 4 - .../src/projectLoom/index.test.ts | 137 ------ .../client-runtime/src/projectLoom/index.ts | 225 ---------- 12 files changed, 1 insertion(+), 1330 deletions(-) delete mode 100644 apps/mobile/src/features/loom/ProjectLoomEntry.tsx delete mode 100644 apps/mobile/src/features/loom/ProjectLoomRouteScreen.test.tsx delete mode 100644 apps/mobile/src/features/loom/ProjectLoomRouteScreen.tsx delete mode 100644 apps/mobile/src/features/loom/loomActivityList.ts delete mode 100644 apps/mobile/src/state/projectLoom.ts delete mode 100644 packages/client-runtime/src/projectLoom/index.test.ts delete mode 100644 packages/client-runtime/src/projectLoom/index.ts diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index e2200972c564c..76e05f52ba8e3 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -78,7 +78,6 @@ import { AddProjectLocalRoute } from "./features/projects/AddProjectLocalRoute"; import { AddProjectNewRoute } from "./features/projects/AddProjectNewRoute"; import { AddProjectRepositoryRoute } from "./features/projects/AddProjectRepositoryRoute"; import { AddProjectSourceRoute } from "./features/projects/AddProjectSourceRoute"; -import { ProjectLoomRouteScreen } from "./features/loom/ProjectLoomRouteScreen"; import { NewTaskBranchPickerRouteScreen, NewTaskEnvironmentPickerRouteScreen, @@ -507,11 +506,6 @@ const RootStackConfig = createWorkspaceStackNavigator({ ...getCompactBrandHeaderOptions(), }, }), - ProjectLoom: createNativeStackScreen({ - screen: ProjectLoomRouteScreen, - linking: "projects/:environmentId/:projectId/loom", - options: pageRoute(), - }), PullRequest: createNativeStackScreen({ screen: PullRequestRouteScreen, linking: { diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index 885674a76255c..7463e325f734c 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -89,7 +89,6 @@ import { SwipeableScrollGateProvider, useSwipeableScrollGate } from "./thread-sw import { useFabScroll } from "./FabScrollContext"; import { useWorkspaceTabs } from "../layout/workspace/WorkspaceTabsContext"; import { ThreadListFilters } from "./ThreadListFilters"; -import { ProjectLoomEntry } from "../loom/ProjectLoomEntry"; interface HomeScreenProps { readonly projects: ReadonlyArray; @@ -671,15 +670,10 @@ export function HomeScreen(props: HomeScreenProps) { catalogState: props.catalogState, projectCount: props.projects.length, }); - const loomProjects = props.projects.filter( - (project) => - props.selectedEnvironmentId === null || project.environmentId === props.selectedEnvironmentId, - ); if (!hasAnyThreads) { return ( - {listHeader} - {Platform.OS === "ios" && workspace ? ( }) { - const navigation = useNavigation(); - const { savedConnectionsById } = useSavedRemoteConnections(); - if (props.projects.length === 0) return null; - return ( - - ({ - id: JSON.stringify([project.environmentId, project.id]), - title: project.title, - subtitle: - savedConnectionsById[project.environmentId]?.environmentLabel ?? project.environmentId, - }))} - onSelect={(id) => { - const project = props.projects.find( - (candidate) => JSON.stringify([candidate.environmentId, candidate.id]) === id, - ); - if (project) - navigation.navigate("ProjectLoom", { - environmentId: project.environmentId, - projectId: project.id, - }); - }} - /> - - ); -} diff --git a/apps/mobile/src/features/loom/ProjectLoomRouteScreen.test.tsx b/apps/mobile/src/features/loom/ProjectLoomRouteScreen.test.tsx deleted file mode 100644 index 088e26a753431..0000000000000 --- a/apps/mobile/src/features/loom/ProjectLoomRouteScreen.test.tsx +++ /dev/null @@ -1,389 +0,0 @@ -import { act, type ReactNode } from "react"; -import { createRoot } from "react-dom/client"; -import { expect, it, vi } from "vite-plus/test"; -import { RegistryContext } from "@effect/atom-react"; -import { Atom, AtomRegistry } from "effect/reactivity"; -import { - EnvironmentId, - MessageId, - NodeId, - ProjectId, - ProviderInstanceId, - RunId, - ThreadId, - type LoomLane, - type LoomRun, - type LoomWindow, - type LoomWindowInput, -} from "@t3tools/contracts"; - -const state = vi.hoisted(() => ({ - window: null as LoomWindow | null, - config: null as unknown, - queries: [] as LoomWindowInput[], - actions: [] as unknown[], - urls: [] as string[], - environments: [] as string[], -})); -vi.mock("react-native", () => ({ - Platform: { OS: "ios" }, - View: ({ children, style }: { children?: ReactNode; style?: { marginLeft?: number } }) => ( -
{children}
- ), - ScrollView: ({ children }: { children?: ReactNode }) =>
{children}
, - Pressable: ({ - children, - onPress, - accessibilityLabel, - style, - disabled, - }: { - children?: ReactNode; - onPress: () => void; - accessibilityLabel?: string; - style?: { marginLeft?: number }; - disabled?: boolean; - }) => ( - - ), -})); -vi.mock("@legendapp/list/react-native", () => ({ - LegendList: ({ - data, - renderItem, - ListHeaderComponent, - ListFooterComponent, - ListEmptyComponent, - }: { - data: ReadonlyArray<{ key: string }>; - renderItem: (input: { item: unknown }) => ReactNode; - ListHeaderComponent: ReactNode; - ListFooterComponent: ReactNode; - ListEmptyComponent: ReactNode; - }) => ( -
- {ListHeaderComponent} - {data.length === 0 - ? ListEmptyComponent - : data.map((item) => ( -
- {renderItem({ item })} -
- ))} - {ListFooterComponent} -
- ), -})); -vi.mock("@react-navigation/native", () => ({ - useNavigation: () => ({ - goBack: () => {}, - addListener: () => () => {}, - }), -})); -vi.mock("../layout/workspace/useSelectWorkspaceDetail", () => ({ - useSelectWorkspaceDetail: () => (detail: unknown) => state.actions.push(detail), -})); -vi.mock("../../components/ui/AppText", () => ({ - AppText: ({ children }: { children?: ReactNode }) => {children}, -})); -vi.mock("../../native/NativeScrollViewMarker", () => ({ NativeScrollView: () => null })); -vi.mock("../../components/ui/AppBar", () => ({ AppBar: () =>
Loom
})); -vi.mock("../../components/ui/Button", () => ({ - Button: ({ - label, - onPress, - disabled, - }: { - label: string; - onPress: () => void; - disabled?: boolean; - }) => ( - - ), -})); -vi.mock("../layout/workspace/ListFilterMenu", () => ({ - ListFilterMenu: ({ - accessibilityLabel, - actions, - onSelect, - }: { - accessibilityLabel: string; - actions: Array<{ id: string; title: string }>; - onSelect: (id: string) => void; - }) => ( - - ), -})); -vi.mock("../../state/server", () => ({ - serverEnvironment: { - configValueAtom: () => state.config, - }, -})); -vi.mock("../../state/entities", () => ({ useProject: () => ({ title: "Project" }) })); -vi.mock("../../state/environments", () => ({ - useEnvironments: () => ({ presentationById: new Map(state.environments.map((id) => [id, {}])) }), -})); -vi.mock("../../state/projectLoom", () => ({ - projectLoom: { - window: ({ input }: { input: LoomWindowInput }) => { - state.queries.push(input); - return state.window; - }, - }, -})); -vi.mock("../../state/query", () => ({ - useEnvironmentQuery: (data: unknown) => ({ - data, - error: null, - isPending: false, - refresh: () => {}, - }), -})); -vi.mock("../../lib/openExternalUrl", () => ({ - tryOpenExternalUrl: async (url: string) => { - state.urls.push(url); - return true; - }, -})); - -import { ProjectLoomRouteScreen } from "./ProjectLoomRouteScreen"; - -const at = (day: number, hour: number) => new Date(2026, 9, day, hour).toISOString(); -const lane = (id: string, parent: string | null): LoomLane => ({ - threadId: ThreadId.make(id), - title: id, - parentThreadId: parent === null ? null : ThreadId.make(parent), - relationship: parent === null ? "root" : "subagent", - providerInstanceId: ProviderInstanceId.make("codex"), - model: "gpt-5", - branch: null, - createdAt: at(20, 12), - archivedAt: null, -}); -const run = (id: string, thread: string, day: number, hour: number): LoomRun => ({ - id: RunId.make(id), - threadId: ThreadId.make(thread), - ordinal: 1, - status: "completed", - providerInstanceId: ProviderInstanceId.make("codex"), - model: "gpt-5", - userMessageId: MessageId.make(`message-${id}`), - requestedAt: at(day, hour), - startedAt: at(day, hour), - completedAt: at(day, hour + 1), -}); -function setup( - prepare: (window: LoomWindow) => LoomWindow = (window) => window, - environments: ReadonlyArray = ["remote", "origin", "destination"], -) { - state.environments = [...environments]; - const config = Atom.make({ environment: { capabilities: { projectLoom: true } } }); - const registry = AtomRegistry.make(); - state.config = config; - state.actions.length = 0; - state.queries.length = 0; - state.urls.length = 0; - state.window = prepare({ - projectId: ProjectId.make("project"), - asOf: at(23, 12), - from: at(19, 12), - to: at(23, 12), - lanes: [ - lane("root", null), - lane("child", "root"), - lane("grandchild", "child"), - lane("older", null), - ], - ancestors: [], - edges: [], - runs: [ - run("root-run", "root", 22, 12), - run("child-run", "child", 22, 14), - run("grandchild-run", "grandchild", 22, 16), - run("older-run", "older", 20, 12), - ], - markers: [ - { - kind: "pull_request", - id: "pr", - threadId: ThreadId.make("child"), - runId: null, - at: at(22, 15), - repository: "t3/t3code", - number: 1, - url: "https://github.com/t3/t3code/pull/1", - source: "manual", - title: null, - }, - ], - activity: [], - truncated: { - lanes: false, - ancestors: false, - runs: false, - markers: false, - edges: false, - activity: false, - bytes: false, - }, - coverage: { - untimedTasks: 2, - untimedTasksTruncated: false, - commandsScanned: 3, - commandsTruncated: true, - }, - nextCursor: null, - }); - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - const container = document.createElement("div"); - const root = createRoot(container); - const render = () => - act(() => - root.render( - - - , - ), - ); - render(); - const press = async (label: string) => { - const button = [...container.querySelectorAll("button")].find( - (element) => element.getAttribute("aria-label") === label, - ); - expect(button, label).toBeDefined(); - await act(async () => button!.click()); - }; - return { - container, - press, - setSupported: (projectLoom: boolean) => - act(() => registry.set(config, { environment: { capabilities: { projectLoom } } })), - [Symbol.dispose]: () => { - act(() => root.unmount()); - registry.dispose(); - vi.unstubAllGlobals(); - }, - }; -} - -it("groups dates, nests delegated activity, collapses descendants and navigates in the owning environment", async () => { - using screen = setup(); - const rows = () => [...screen.container.querySelectorAll("[data-item]")]; - expect(rows().map((element) => element.getAttribute("data-item"))).toEqual([ - "date:2026-10-22", - "2026-10-22:thread:root", - "2026-10-22:run:root-run", - "2026-10-22:thread:child", - "2026-10-22:run:child-run", - "2026-10-22:pull_request:pr", - "2026-10-22:thread:grandchild", - "2026-10-22:run:grandchild-run", - "date:2026-10-20", - "2026-10-20:thread:older", - "2026-10-20:run:older-run", - ]); - expect( - screen.container - .querySelector('[data-item="2026-10-22:thread:child"] > div') - ?.getAttribute("style"), - ).toBe("margin-left: 16px;"); - expect(screen.container.textContent).toContain("2 agent tasks with no recorded time"); - expect(screen.container.textContent).toContain("test commands from the newest 3 commands only"); - await screen.press("Collapse root"); - expect(screen.container.textContent).toContain("2 hidden threads"); - expect( - rows().some((element) => element.getAttribute("data-item")?.endsWith(":thread:child")), - ).toBe(false); - expect( - rows().some((element) => element.getAttribute("data-item")?.endsWith(":run:child-run")), - ).toBe(false); - await screen.press("Expand root"); - await screen.press("Open thread child"); - await screen.press("Run 1 · completed"); - expect(state.actions).toEqual([ - { name: "Thread", params: { environmentId: "remote", threadId: "child" } }, - { name: "Thread", params: { environmentId: "remote", threadId: "root" } }, - ]); - await screen.press("Pull request linked: #1"); - expect(state.urls).toEqual(["https://github.com/t3/t3code/pull/1"]); - state.queries.length = 0; - screen.setSupported(false); - expect(screen.container.textContent).toContain("Update the server"); - expect(state.queries).toEqual([]); -}); - -const withRemoteTasks = (window: LoomWindow): LoomWindow => ({ - ...window, - lanes: window.lanes.map((entry) => - entry.threadId === "root" - ? { - ...entry, - relationship: "subagent", - delegatedFrom: { - environmentId: EnvironmentId.make("origin"), - threadId: ThreadId.make("root"), - title: "Remote parent", - }, - } - : entry, - ), - markers: [ - { - kind: "subagent_task", - id: NodeId.make("remote-task"), - threadId: ThreadId.make("root"), - runId: null, - at: at(22, 13), - completedAt: null, - status: "running", - title: "Build", - remoteChild: { - environmentId: EnvironmentId.make("destination"), - threadId: ThreadId.make("root"), - label: "Build server", - }, - }, - ], -}); - -it("opens remote child and parent links with their own environment even when thread ids collide", async () => { - using screen = setup(withRemoteTasks); - await screen.press("Agent task on Build server: Build (running)"); - await screen.press("Open parent thread Remote parent on origin"); - expect(state.actions).toEqual([ - { name: "Thread", params: { environmentId: "destination", threadId: "root" } }, - { name: "Thread", params: { environmentId: "origin", threadId: "root" } }, - ]); -}); - -it("keeps remote links visible but cannot navigate to environments this client has not connected", () => { - using screen = setup(withRemoteTasks, ["remote"]); - const buttons = [...screen.container.querySelectorAll("button")]; - expect( - buttons.find( - (button) => - button.getAttribute("aria-label") === "Agent task on Build server: Build (running)", - )?.disabled, - ).toBe(true); - expect( - buttons.find( - (button) => - button.getAttribute("aria-label") === "Open parent thread Remote parent on origin", - )?.disabled, - ).toBe(true); - expect(screen.container.textContent).toContain("Connect to Build server to open this thread."); - expect(screen.container.textContent).toContain("Connect to origin to open this parent."); - expect(state.actions).toEqual([]); -}); diff --git a/apps/mobile/src/features/loom/ProjectLoomRouteScreen.tsx b/apps/mobile/src/features/loom/ProjectLoomRouteScreen.tsx deleted file mode 100644 index 155d8d54b6b13..0000000000000 --- a/apps/mobile/src/features/loom/ProjectLoomRouteScreen.tsx +++ /dev/null @@ -1,409 +0,0 @@ -import { useAtomValue } from "@effect/atom-react"; -import { LegendList } from "@legendapp/list/react-native"; -import { useNavigation, type StaticScreenProps } from "@react-navigation/native"; -import { scopeThreadRef } from "@t3tools/client-runtime/environment"; -import { - loomCoverageNotes, - loomMarkerLabel, - loomMarkerThreadRef, - loomTruncationNotes, -} from "@t3tools/client-runtime/projectLoom"; -import { - EnvironmentId, - ProjectId, - type LoomRunStatusFilter, - type LoomWindowInput, - type ProviderInstanceId, - type ScopedThreadRef, -} from "@t3tools/contracts"; -import { useMemo, useState } from "react"; -import { Platform, Pressable, ScrollView, View } from "react-native"; - -import { AppBar } from "../../components/ui/AppBar"; -import { NativeScrollView } from "../../native/NativeScrollViewMarker"; -import { AppText as Text } from "../../components/ui/AppText"; -import { Button } from "../../components/ui/Button"; -import { ScreenContent } from "../../components/ui/ScreenContent"; -import { tryOpenExternalUrl } from "../../lib/openExternalUrl"; -import { useProject } from "../../state/entities"; -import { projectLoom } from "../../state/projectLoom"; -import { useEnvironmentQuery } from "../../state/query"; -import { serverEnvironment } from "../../state/server"; -import { useEnvironments } from "../../state/environments"; -import { ListFilterMenu } from "../layout/workspace/ListFilterMenu"; -import { useSelectWorkspaceDetail } from "../layout/workspace/useSelectWorkspaceDetail"; -import { buildLoomActivityList, type LoomActivityListItem } from "./loomActivityList"; - -const WINDOWS = { "24h": 1, "7d": 7, "30d": 30 } as const; -const STATUSES = ["active", "completed", "failed", "interrupted"] as const; -type WindowSpan = keyof typeof WINDOWS; - -function bounds(span: WindowSpan) { - const to = new Date(); - return { - from: new Date(to.getTime() - WINDOWS[span] * 86_400_000).toISOString(), - to: to.toISOString(), - }; -} - -function time(at: string) { - return new Date(at).toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" }); -} - -export function ProjectLoomRouteScreen( - props: StaticScreenProps<{ readonly environmentId: string; readonly projectId: string }>, -) { - const environmentId = EnvironmentId.make(props.route.params.environmentId); - const projectId = ProjectId.make(props.route.params.projectId); - return ( - - ); -} - -function ProjectLoomScreen(props: { - readonly environmentId: EnvironmentId; - readonly projectId: ProjectId; -}) { - const navigation = useNavigation(); - const selectDetail = useSelectWorkspaceDetail(); - const { presentationById } = useEnvironments(); - const config = useAtomValue(serverEnvironment.configValueAtom(props.environmentId)); - const supported = config?.environment.capabilities.projectLoom === true; - const project = useProject(props); - const [span, setSpan] = useState("7d"); - const [windowBounds, setWindowBounds] = useState(() => bounds("7d")); - const [status, setStatus] = useState(null); - const [provider, setProvider] = useState(null); - const [cursors, setCursors] = useState>([]); - const [collapsed, setCollapsed] = useState>(() => new Set()); - const input: LoomWindowInput = { - projectId: props.projectId, - ...windowBounds, - filters: { - ...(status === null ? {} : { statuses: [status] }), - ...(provider === null ? {} : { providerInstanceIds: [provider] }), - }, - ...(cursors.length === 0 ? {} : { cursor: cursors.at(-1)! }), - }; - const query = useEnvironmentQuery( - supported ? projectLoom.window({ environmentId: props.environmentId, input }) : null, - ); - const data = query.data; - const items = useMemo( - () => (data === null ? [] : buildLoomActivityList(data, collapsed)), - [collapsed, data], - ); - const providers = [ - ...new Set([ - ...(data?.lanes.map((lane) => lane.providerInstanceId) ?? []), - ...(data?.runs.map((run) => run.providerInstanceId) ?? []), - ...(provider === null ? [] : [provider]), - ]), - ].sort(); - const refresh = () => { - setCursors([]); - setWindowBounds(bounds(span)); - }; - const toggleCollapsed = (threadId: string) => { - setCollapsed((previous) => { - const next = new Set(previous); - if (next.has(threadId)) next.delete(threadId); - else next.add(threadId); - return next; - }); - }; - const selectThread = (target: ScopedThreadRef) => - selectDetail({ name: "Thread", params: target }); - const canOpenThread = (target: ScopedThreadRef) => - target.environmentId === props.environmentId || presentationById.has(target.environmentId); - const renderItem = ({ item }: { item: LoomActivityListItem }) => { - if (item.kind === "date") { - return ( - - {new Date(`${item.date}T12:00:00`).toLocaleDateString(undefined, { - weekday: "long", - month: "short", - day: "numeric", - year: "numeric", - })} - - ); - } - if (item.kind === "thread") { - const { row } = item; - const delegatedFrom = row.delegatedFrom; - const parentAvailable = delegatedFrom === undefined || canOpenThread(delegatedFrom); - return ( - - {row.childCount > 0 ? ( - toggleCollapsed(row.threadId)} - className="min-h-11 min-w-11 items-center justify-center" - > - {row.collapsed ? "▸" : "▾"} - - ) : null} - selectThread(scopeThreadRef(props.environmentId, row.threadId))} - className="min-h-11 min-w-0 flex-1 justify-center gap-1" - > - - {row.title} - - - {[ - row.relationship === "root" - ? null - : row.relationship === "subagent" - ? "Delegated agent" - : "Fork", - row.lane === null ? "Parent context · no activity on this date" : row.lane.model, - row.parentMissing ? "Parent not available in this window" : null, - row.hiddenCount > 0 ? `${row.hiddenCount} hidden threads` : null, - ] - .filter(Boolean) - .join(" · ")} - - - {delegatedFrom === undefined ? null : ( - - selectThread(scopeThreadRef(delegatedFrom.environmentId, delegatedFrom.threadId)) - } - className="min-h-11 max-w-36 justify-center" - > - - {parentAvailable - ? `Parent on ${delegatedFrom.environmentId}` - : `Connect to ${delegatedFrom.environmentId} to open this parent.`} - - - )} - - ); - } - const at = item.kind === "run" ? item.run.requestedAt : item.marker.at; - const label = - item.kind === "run" - ? `Run ${item.run.ordinal} · ${item.run.status}` - : loomMarkerLabel(item.marker); - const target = - item.kind === "run" - ? scopeThreadRef(props.environmentId, item.run.threadId) - : loomMarkerThreadRef(props.environmentId, item.marker); - const available = canOpenThread(target); - const remoteLabel = - item.kind === "marker" && item.marker.kind === "subagent_task" - ? item.marker.remoteChild?.label - : undefined; - return ( - { - if (item.kind === "marker" && item.marker.kind === "pull_request") { - void tryOpenExternalUrl(item.marker.url, "pull-request"); - } else selectThread(target); - }} - className="min-h-11 gap-1 border-b border-separator bg-grouped-card px-4 py-3" - style={{ marginLeft: Math.min(item.depth, 5) * 16 + 16 }} - > - {label} - - {time(at)} - {item.kind === "run" - ? ` · ${item.run.providerInstanceId} · ${item.run.model} · ${ - item.run.startedAt === null - ? "Start unknown" - : `Started ${time(item.run.startedAt)}` - } · ${item.run.completedAt === null ? "End not recorded" : `Ended ${time(item.run.completedAt)}`}` - : ""} - - {available ? null : ( - - Connect to {remoteLabel ?? target.environmentId} to open this thread. - - )} - - ); - }; - - return ( - - navigation.goBack()} - /> - - item.key} - getItemType={(item) => item.kind} - estimatedItemSize={72} - renderItem={renderItem} - contentInsetAdjustmentBehavior="automatic" - contentContainerStyle={{ paddingHorizontal: 16, paddingBottom: 32 }} - ListHeaderComponent={ - - {config === null ? ( - - Connecting to the environment… - - ) : !supported ? ( - - This environment's server does not have the Loom yet. Update the server to use it. - - ) : ( - <> - - ({ - id: value, - title: value, - state: value === span ? "on" : "off", - }))} - onSelect={(value) => { - const next = value as WindowSpan; - setSpan(next); - setWindowBounds(bounds(next)); - setCursors([]); - }} - /> - ({ - id: value, - title: value, - state: value === status ? ("on" as const) : ("off" as const), - })), - ]} - onSelect={(value) => { - setStatus(value === "all" ? null : (value as LoomRunStatusFilter)); - setCursors([]); - }} - /> - ({ - id: value, - title: value, - state: value === provider ? ("on" as const) : ("off" as const), - })), - ]} - onSelect={(value) => { - setProvider(providers.find((id) => id === value) ?? null); - setCursors([]); - }} - /> - - - ) : null} {/* The server rejects revisions of a superseded fact; its replacement is the one to change. */} {!superseded ? (