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/components/ProjectKnowledgeEntry.test.tsx b/apps/mobile/src/components/ProjectKnowledgeEntry.test.tsx index dc628aa11f445..fea867f7cd530 100644 --- a/apps/mobile/src/components/ProjectKnowledgeEntry.test.tsx +++ b/apps/mobile/src/components/ProjectKnowledgeEntry.test.tsx @@ -245,7 +245,7 @@ const fact: ProjectFact = { tags: [], kind: "note", sources: [], - status: "proposed", + status: "saved", revision: 1, trust: 0.5, retrievalCount: 0, @@ -486,7 +486,6 @@ it("shows user origin once without treating the assertion as a citation", async const asserted: ProjectFact = { ...fact, origin: { kind: "user" }, - status: "confirmed", sources: [{ kind: "user" }], }; state.list = { facts: [asserted], nextCursor: null }; @@ -681,7 +680,6 @@ it("marks a superseded fact read-only and lists superseded facts on request", as await screen.click(`Inspect fact: ${fact.content}`); const labels = [...screen.container.querySelectorAll("button")].map((item) => item.textContent); expect(labels).not.toContain("Edit"); - expect(labels).not.toContain("Confirm"); expect(labels).not.toContain("Supersede"); await screen.click("Back"); expect(state.listInputs.at(-1)).not.toHaveProperty("includeSuperseded"); @@ -841,7 +839,7 @@ it("drops a revised fact from the selection and the draft after saving", async ( }); screen.openMemory(); await screen.click(`Inspect fact: ${fact.content}`); - await screen.click("Confirm"); + await screen.click("Helpful"); expect(state.setText).toHaveBeenCalledWith(scope.draftKey, ""); expect(screen.container.textContent).toContain("0 selected"); expect(screen.container.textContent).toContain("removed from the draft"); diff --git a/apps/mobile/src/components/ProjectKnowledgeEntry.tsx b/apps/mobile/src/components/ProjectKnowledgeEntry.tsx index a664c574a46a3..4a0142b201da6 100644 --- a/apps/mobile/src/components/ProjectKnowledgeEntry.tsx +++ b/apps/mobile/src/components/ProjectKnowledgeEntry.tsx @@ -440,6 +440,8 @@ function ProjectKnowledgeSheet( @@ -502,18 +504,17 @@ function ProjectKnowledgeSheet( if (inspectedFact.status !== "forgotten") push({ kind: "editor", mode: { kind, fact: inspectedFact } }); }} - onRevise={(action) => { - const revise = () => void apply({ action, ...factMutation(inspectedFact) }); - if (action !== "forget") return revise(); + onForget={() => Dialog.confirm({ title: "Forget this fact permanently?", message: "Its content, history and every copy sent to agents are removed and cannot be restored. If the fact changed rather than became wrong, supersede it instead.", confirmText: "Forget", destructive: true, - onConfirm: revise, - }); - }} + onConfirm: () => + void apply({ action: "forget", ...factMutation(inspectedFact) }), + }) + } onFeedback={(helpful) => void apply({ action: "feedback", helpful, ...factMutation(inspectedFact) }) } diff --git a/apps/mobile/src/components/ProjectKnowledgeFactDetail.tsx b/apps/mobile/src/components/ProjectKnowledgeFactDetail.tsx index eb20cb7fd4448..76fb48a56374e 100644 --- a/apps/mobile/src/components/ProjectKnowledgeFactDetail.tsx +++ b/apps/mobile/src/components/ProjectKnowledgeFactDetail.tsx @@ -126,7 +126,7 @@ export function ProjectFactInspector(props: { readonly onReadSource: (ref: KnowledgeMessageRef) => void; readonly onOpenFact: OpenFact; readonly onEdit: (mode: "edit" | "supersede") => void; - readonly onRevise: (action: "confirm" | "forget") => void; + readonly onForget: () => void; readonly onFeedback: (helpful: boolean) => void; readonly onDismissHints: () => void; }) { @@ -162,13 +162,6 @@ export function ProjectFactInspector(props: { Trust signal {fact.trust.toFixed(2)} · revision {fact.revision} - {fact.status === "proposed" && - fact.origin.kind === "assisted" && - fact.supersedes != null ? ( - - Confirming replaces fact {fact.supersedes.slice(0, 8)}; forgetting leaves it as it is. - - ) : null} @@ -202,14 +195,6 @@ export function ProjectFactInspector(props: { {active ? ( - {fact.status === "proposed" && !superseded ? ( - - ), -})); -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} {project && showProjectEnvironments ? ( , content: string): Pro entities: [...entities], tags: [], sources: [], - status: "confirmed", + status: "saved", forgottenAt: null, }); diff --git a/apps/web/src/components/files/FileCodeRelationships.tsx b/apps/web/src/components/files/FileCodeRelationships.tsx index 015429a71dde3..8db93617357de 100644 --- a/apps/web/src/components/files/FileCodeRelationships.tsx +++ b/apps/web/src/components/files/FileCodeRelationships.tsx @@ -511,7 +511,6 @@ function DecisionPages(props: {
  • {fact.kind} - {fact.status === "proposed" ? proposed : null} {match.by === "path" ? `matched by path ${match.label}` diff --git a/apps/web/src/components/loom/LoomBranchNavigator.tsx b/apps/web/src/components/loom/LoomBranchNavigator.tsx deleted file mode 100644 index 2510cc5f9445e..0000000000000 --- a/apps/web/src/components/loom/LoomBranchNavigator.tsx +++ /dev/null @@ -1,89 +0,0 @@ -import { SearchIcon } from "lucide-react"; -import { useState } from "react"; - -import { cn } from "~/lib/utils"; -import { Input } from "../ui/input"; -import { workstreamColor } from "./loomKinds"; -import { formatLoomTime } from "./loomScale"; -import type { Weave } from "./loomWeave"; - -/** Every workstream in the window, most recently active first. Choosing one opens Agent mode. */ -export function LoomBranchNavigator(props: { - readonly weave: Weave; - readonly currentRootId: string | null; - readonly onOpen: (rootId: string) => 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/projectKnowledge/AssistedLearningSection.tsx b/apps/web/src/components/projectKnowledge/AssistedLearningSection.tsx index 349bbc2f7993b..c224e730de43a 100644 --- a/apps/web/src/components/projectKnowledge/AssistedLearningSection.tsx +++ b/apps/web/src/components/projectKnowledge/AssistedLearningSection.tsx @@ -14,7 +14,6 @@ import { useEnvironmentScope } from "../../state/session"; import { useAtomCommand } from "../../state/use-atom-command"; import { Switch } from "../ui/switch"; -/** The project's opt-in for model-suggested facts and what the last run did with it. */ export function AssistedLearningSection(props: { readonly environmentId: EnvironmentId; readonly projectId: ProjectId; @@ -49,19 +48,19 @@ export function AssistedLearningSection(props: { if (result._tag === "Failure") setError(String(squashAtomCommandFailure(result))); }; return ( -
    +
    - Suggest facts from finished runs + Save facts from finished runs void toggle(checked)} />

    - After each run in this project, your text generation model reads that run and proposes up to - three cited facts for you to review. Each run costs one model call. + After each run in this project, your text generation model reads that run and saves up to + three cited facts. Each run costs one model call.

    {lastRun ?

    {assistedLearningRunLabel(lastRun)}

    : null} {!canWrite ? ( diff --git a/apps/web/src/components/projectKnowledge/ProjectFactDetail.tsx b/apps/web/src/components/projectKnowledge/ProjectFactDetail.tsx index bda4960a163a1..e6e1e3a630c48 100644 --- a/apps/web/src/components/projectKnowledge/ProjectFactDetail.tsx +++ b/apps/web/src/components/projectKnowledge/ProjectFactDetail.tsx @@ -227,8 +227,7 @@ export function ProjectFactHistory(props: { export function KnowledgeHealthStrip(props: { readonly status: ProjectKnowledgeStatus }) { const { status } = props; const counters = [ - ["Confirmed", status.counts.confirmed], - ["Proposed", status.counts.proposed], + ["Saved", status.counts.saved], ["Superseded", status.counts.superseded], ["Below recall threshold", status.belowRecallThreshold], ["Retrievals", status.retrievals], diff --git a/apps/web/src/components/projectKnowledge/ProjectMemoryFactList.tsx b/apps/web/src/components/projectKnowledge/ProjectMemoryFactList.tsx index b3fc28e1c3c01..52929e059c935 100644 --- a/apps/web/src/components/projectKnowledge/ProjectMemoryFactList.tsx +++ b/apps/web/src/components/projectKnowledge/ProjectMemoryFactList.tsx @@ -80,9 +80,9 @@ export function ProjectMemoryFactList(props: { {status.label} - {fact.origin.kind === "assisted" && fact.status === "proposed" ? ( + {fact.origin.kind === "assisted" ? ( - Suggested + Automatic ) : null} {fact.kind} diff --git a/apps/web/src/components/projectKnowledge/ProjectMemoryInspector.tsx b/apps/web/src/components/projectKnowledge/ProjectMemoryInspector.tsx index c3bfcb58111fb..0d79b02046e39 100644 --- a/apps/web/src/components/projectKnowledge/ProjectMemoryInspector.tsx +++ b/apps/web/src/components/projectKnowledge/ProjectMemoryInspector.tsx @@ -22,7 +22,7 @@ import { useEnvironmentQuery } from "../../state/query"; import { projectKnowledge } from "../../state/projectKnowledge"; import { ProjectFactHistory, ProjectFactRelations } from "./ProjectFactDetail"; import type { EditableFact } from "./ProjectFactEditor"; -import { factStatusBadge, isPendingReplacement, shortFactId } from "./projectMemoryLabels"; +import { factStatusBadge, shortFactId } from "./projectMemoryLabels"; export type SourceRef = { readonly threadId: KnowledgeMessageRef["threadId"]; @@ -95,11 +95,7 @@ export function FactInspector(props: { readonly canMutate: boolean; readonly busy: boolean; readonly onEdit: (fact: EditableFact, supersede: boolean) => void; - readonly onRevise: ( - fact: ProjectFact, - action: "confirm" | "forget" | "feedback", - helpful?: boolean, - ) => void; + readonly onRevise: (fact: ProjectFact, action: "forget" | "feedback", helpful?: boolean) => void; readonly onOpenSource: (source: KnowledgeMessageRef) => void; readonly onOpenFact: (factId: ProjectFactId) => void; readonly onBackToResults: () => void; @@ -156,9 +152,7 @@ export function FactInspector(props: { Superseded by {shortFactId(fact.supersededBy)} ) : null} {fact.supersedes ? ( - - {`${isPendingReplacement(fact) ? "Would replace" : "Supersedes"} ${shortFactId(fact.supersedes)}`} - + Supersedes {shortFactId(fact.supersedes)} ) : null} @@ -174,19 +168,9 @@ export function FactInspector(props: { ))} ) : null} - {isPendingReplacement(fact) && fact.supersedes ? ( -

    - Confirming replaces fact {shortFactId(fact.supersedes)}; forgetting leaves it as it is. -

    - ) : null} {fact.status !== "forgotten" ? (
    - {fact.status === "proposed" && !superseded ? ( - - ) : null} {/* The server rejects revisions of a superseded fact; its replacement is the one to change. */} {!superseded ? (