diff --git a/react-compiler.config.js b/react-compiler.config.js index 0f9e98dcf..c47165296 100644 --- a/react-compiler.config.js +++ b/react-compiler.config.js @@ -23,6 +23,7 @@ export const REACT_COMPILER_ENABLED_DIRS = [ "src/components/shared/GitHubAuth", "src/components/shared/Authentication", "src/routes", + "src/routes/tangent", "src/components/shared/ReactFlow/FlowCanvas/FlowCanvas.tsx", "src/components/shared/ComponentEditor", "src/components/shared/Settings", diff --git a/src/routes/router.ts b/src/routes/router.ts index 2f70b2694..c829a0a97 100644 --- a/src/routes/router.ts +++ b/src/routes/router.ts @@ -40,6 +40,9 @@ import { BetaFeaturesSettings } from "./Settings/sections/BetaFeaturesSettings"; import { PreferencesSettings } from "./Settings/sections/PreferencesSettings"; import { SecretsSettings } from "./Settings/sections/SecretsSettings"; import { SettingsLayout } from "./Settings/SettingsLayout"; +import { TangentDashboardView } from "./tangent/TangentDashboardView"; +import { TangentLayout } from "./tangent/TangentLayout"; +import { TangentProjectDetailView } from "./tangent/TangentProjectDetailView"; import { EditorV2 } from "./v2/pages/Editor/EditorV2"; import { PipelineFoldersPage } from "./v2/pages/PipelineFolders/PipelineFoldersPage"; import { RunViewV2 } from "./v2/pages/RunView/RunViewV2"; @@ -93,6 +96,8 @@ export const APP_ROUTES = { PIPELINE_FOLDERS: "/pipeline-folders", PLAYGROUND: "/playground", ARTIFACT_PREVIEW: "/artifact/$artifactId", + TANGENT: "/tangent", + TANGENT_PROJECT: "/tangent/$runId", } as const; const rootRoute = createRootRoute({ @@ -355,6 +360,29 @@ const artifactPreviewRoute = createRoute({ component: ArtifactPreviewPage, }); +const tangentLayoutRoute = createRoute({ + id: "tangent-layout", + getParentRoute: () => mainLayout, + component: TangentLayout, +}); + +const tangentRoute = createRoute({ + getParentRoute: () => tangentLayoutRoute, + path: APP_ROUTES.TANGENT, + component: TangentDashboardView, +}); + +const tangentProjectRoute = createRoute({ + getParentRoute: () => tangentLayoutRoute, + path: APP_ROUTES.TANGENT_PROJECT, + component: TangentProjectDetailView, +}); + +const tangentRouteTree = tangentLayoutRoute.addChildren([ + tangentRoute, + tangentProjectRoute, +]); + const dashboardRouteTree = dashboardRoute.addChildren([ dashboardIndexRoute, dashboardRunsRoute, @@ -383,6 +411,7 @@ const appRouteTree = mainLayout.addChildren([ runV2WithSubgraphRoute, pipelineFoldersRoute, artifactPreviewRoute, + tangentRouteTree, ]); const rootRouteTree = rootRoute.addChildren([ diff --git a/src/routes/tangent/TangentDashboardView.tsx b/src/routes/tangent/TangentDashboardView.tsx new file mode 100644 index 000000000..61e3273da --- /dev/null +++ b/src/routes/tangent/TangentDashboardView.tsx @@ -0,0 +1,160 @@ +import { useSearch } from "@tanstack/react-router"; + +import { Button } from "@/components/ui/button"; +import { Icon } from "@/components/ui/icon"; +import { BlockStack, InlineStack } from "@/components/ui/layout"; +import { Spinner } from "@/components/ui/spinner"; +import { + Table, + TableBody, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { Heading, Paragraph, Text } from "@/components/ui/typography"; +import { AnalyzeRunBlock } from "@/routes/tangent/components/AnalyzeRunBlock"; +import { HeroBanner } from "@/routes/tangent/components/HeroBanner"; +import { PipelineRow } from "@/routes/tangent/components/PipelineRow"; +import { useReanalyzeAll } from "@/routes/tangent/hooks/useReanalyzeAll"; +import { useTangentPipelines } from "@/routes/tangent/hooks/useTangentPipelines"; +import { PIPELINE_FILTERS } from "@/routes/tangent/labels"; +import type { PipelineFilter, TangentPipeline } from "@/routes/tangent/types"; + +interface TangentSearch { + filter?: string; +} + +function isPipelineFilter(value: unknown): value is PipelineFilter { + return ( + typeof value === "string" && (PIPELINE_FILTERS as string[]).includes(value) + ); +} + +function matchesFilter( + pipeline: TangentPipeline, + filter: PipelineFilter, +): boolean { + if (filter === "my_pipelines") return pipeline.builtByCurrentUser; + if (filter === "no_scenario") + return pipeline.scenarioStatus === "no_scenario"; + if (filter === "has_results") { + return pipeline.scenarioStatus === "results_available"; + } + return true; +} + +/** Sorts by opportunity score (highest first); unscored pipelines sink last. */ +function byOpportunity(a: TangentPipeline, b: TangentPipeline): number { + if (a.opportunityScore === null && b.opportunityScore === null) return 0; + if (a.opportunityScore === null) return 1; + if (b.opportunityScore === null) return -1; + return b.opportunityScore - a.opportunityScore; +} + +export function TangentDashboardView() { + const search = useSearch({ strict: false }) as TangentSearch; + const activeFilter: PipelineFilter = isPipelineFilter(search.filter) + ? search.filter + : "all"; + + const { data: pipelines, isPending, isError } = useTangentPipelines(); + const reanalyze = useReanalyzeAll(); + + const allPipelines = pipelines ?? []; + const sorted = [...allPipelines].sort(byOpportunity); + const visible = sorted.filter((pipeline) => + matchesFilter(pipeline, activeFilter), + ); + const scoredCount = allPipelines.filter( + (pipeline) => pipeline.opportunityScore !== null, + ).length; + + const subtitle = isPending + ? "Loading pipelines…" + : `Ranked by Tangent improvement opportunity · Search team · ${allPipelines.length} pipelines · ${scoredCount} analyzed`; + + return ( + + + + Tangent + + Search Team + + + + + + + + + + + + Pipelines + + {subtitle} + + + + {isPending && ( + + + + Loading… + + + )} + + {isError && ( + + ⚠️ Could not load pipelines + + )} + + {!isPending && !isError && visible.length === 0 && ( + + 📭 No pipelines match this filter + + )} + + {!isPending && !isError && visible.length > 0 && ( + + + + Name + Status + Last run + Metric + Owner + Score + + + + {visible.map((pipeline) => ( + + ))} + +
+ )} +
+
+ ); +} diff --git a/src/routes/tangent/TangentLayout.tsx b/src/routes/tangent/TangentLayout.tsx new file mode 100644 index 000000000..a8f163bfa --- /dev/null +++ b/src/routes/tangent/TangentLayout.tsx @@ -0,0 +1,176 @@ +import { Link, Outlet, useSearch } from "@tanstack/react-router"; + +import { Icon, type IconName } from "@/components/ui/icon"; +import { BlockStack, InlineStack } from "@/components/ui/layout"; +import { Link as UILink } from "@/components/ui/link"; +import { Text } from "@/components/ui/typography"; +import { cn } from "@/lib/utils"; +import { APP_ROUTES } from "@/routes/router"; +import { PIPELINE_FILTER_LABELS } from "@/routes/tangent/labels"; +import type { PipelineFilter } from "@/routes/tangent/types"; +import { + ABOUT_URL, + DOCUMENTATION_URL, + GIT_COMMIT, + GIT_REPO_URL, + GIVE_FEEDBACK_URL, + PRIVACY_POLICY_URL, + TOP_NAV_HEIGHT, +} from "@/utils/constants"; + +interface TangentSearch { + filter?: string; +} + +interface FilterLink { + filter: Exclude; + icon: IconName; +} + +const FILTER_LINKS: FilterLink[] = [ + { filter: "my_pipelines", icon: "GitBranch" }, + { filter: "no_scenario", icon: "CircleDashed" }, + { filter: "has_results", icon: "ChartLine" }, +]; + +const navItemClass = (isActive: boolean) => + cn( + "w-full px-3 py-2 rounded-md text-sm cursor-pointer hover:bg-accent", + isActive && "bg-accent font-medium", + ); + +export function TangentLayout() { + const search = useSearch({ strict: false }) as TangentSearch; + const activeFilter = search.filter; + + return ( +
+ {/* Sidebar — fixed height, independent scroll */} +
+ {/* Tangent heading */} +
+ + Tangent + +
+ + + + {({ isActive }) => ( + + + Tangent Dashboard + + )} + + + {FILTER_LINKS.map(({ filter, icon }) => ( + + + + {PIPELINE_FILTER_LABELS[filter]} + + + ))} + + + {/* Spacer */} +
+ + {/* Bottom utilities */} + + + + + Back to My Dashboard + + + + + + Docs + + + + {({ isActive }) => ( + + + Settings + + )} + + + {/* Footer links */} + + {[ + { label: "About", href: ABOUT_URL }, + { label: "Give feedback", href: GIVE_FEEDBACK_URL }, + { label: "Privacy policy", href: PRIVACY_POLICY_URL }, + ].map(({ label, href }) => ( + + {label} + + ))} + + ver: {GIT_COMMIT.substring(0, 6)} + + + +
+ + {/* Main content — independent scroll */} +
+ +
+
+ ); +} diff --git a/src/routes/tangent/TangentProjectDetailView.tsx b/src/routes/tangent/TangentProjectDetailView.tsx new file mode 100644 index 000000000..4ca857e2f --- /dev/null +++ b/src/routes/tangent/TangentProjectDetailView.tsx @@ -0,0 +1,118 @@ +import { Link, useParams } from "@tanstack/react-router"; + +import { Icon } from "@/components/ui/icon"; +import { BlockStack, InlineStack } from "@/components/ui/layout"; +import { Spinner } from "@/components/ui/spinner"; +import { Heading, Paragraph, Text } from "@/components/ui/typography"; +import { APP_ROUTES } from "@/routes/router"; +import { CurrentPerformance } from "@/routes/tangent/components/detail/CurrentPerformance"; +import { ResultsSection } from "@/routes/tangent/components/detail/ResultsSection"; +import { ScenarioSection } from "@/routes/tangent/components/detail/ScenarioSection"; +import { TangentAnalysis } from "@/routes/tangent/components/detail/TangentAnalysis"; +import { OpportunityScoreRing } from "@/routes/tangent/components/OpportunityScoreRing"; +import { RunStatusIndicator } from "@/routes/tangent/components/RunStatusIndicator"; +import { ScenarioStatusBadge } from "@/routes/tangent/components/ScenarioStatusBadge"; +import { useTangentPipeline } from "@/routes/tangent/hooks/useTangentPipeline"; +import { getCreatorHandle } from "@/routes/tangent/labels"; + +export function TangentProjectDetailView() { + const { runId = "" } = useParams({ strict: false }); + const { data: pipeline, isPending, isError } = useTangentPipeline(runId); + + const backLink = ( + + ← All projects + + ); + + if (isPending) { + return ( + + {backLink} + + + + Loading… + + + + ); + } + + if (isError || !pipeline) { + return ( + + {backLink} + + ⚠️ Could not load this pipeline + + + ); + } + + const creator = getCreatorHandle(pipeline.ownerEmail); + + return ( + + + {backLink} + + + + Run {pipeline.runId} + + {pipeline.oasisUrl && ( + + View in Oasis + + + )} + + + + + + + {pipeline.name} + + + + {pipeline.metricName ? `${pipeline.metricName} · ` : ""} + {pipeline.baselineValue !== undefined + ? `Baseline: ${pipeline.baselineValue.toFixed(4)}` + : "No baseline set"} + {creator ? ` · ${creator}` : ""} + + + {pipeline.analyzing ? ( + + ) : ( + + )} + + + + + + + {pipeline.scenarioStatus === "results_available" && pipeline.results && ( + + )} + + + + ); +} diff --git a/src/routes/tangent/components/AnalyzeRunBlock.tsx b/src/routes/tangent/components/AnalyzeRunBlock.tsx new file mode 100644 index 000000000..162ab05d3 --- /dev/null +++ b/src/routes/tangent/components/AnalyzeRunBlock.tsx @@ -0,0 +1,68 @@ +import { useState } from "react"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { BlockStack, InlineStack } from "@/components/ui/layout"; +import { Heading, Paragraph, Text } from "@/components/ui/typography"; +import useToastNotification from "@/hooks/useToastNotification"; + +const RUN_ID_PATTERN = /^019[0-9a-f]{17}$/; + +const INVALID_ID_MESSAGE = + 'Run IDs are 20 hex characters starting with "019", e.g. 019db6c661691a51840d'; + +export function AnalyzeRunBlock() { + const notify = useToastNotification(); + const [value, setValue] = useState(""); + const [error, setError] = useState(null); + + const handleAnalyze = () => { + const normalized = value.trim().toLowerCase(); + if (!RUN_ID_PATTERN.test(normalized)) { + setError(INVALID_ID_MESSAGE); + return; + } + setError(null); + // Phase 1: the analyze flow is mocked and not yet wired. + notify( + "Analyzing arbitrary runs is coming soon — browse the pipelines below for now.", + "info", + ); + }; + + return ( + + + Add your run here + + Get an instant optimization score and scenario recommendations for any + Tangle run + + + + + { + setValue(event.target.value); + if (error) setError(null); + }} + onEnter={handleAnalyze} + /> + {error && ( + + {error} + + )} + + + + + ); +} diff --git a/src/routes/tangent/components/HeroBanner.tsx b/src/routes/tangent/components/HeroBanner.tsx new file mode 100644 index 000000000..ca4640374 --- /dev/null +++ b/src/routes/tangent/components/HeroBanner.tsx @@ -0,0 +1,24 @@ +import { BlockStack } from "@/components/ui/layout"; +import { Heading, Paragraph } from "@/components/ui/typography"; +import { StatBoxes } from "@/routes/tangent/components/StatBoxes"; + +export function HeroBanner() { + return ( + + + + Scenarios in. Optimized models out. Tangent ships. + + + Drop a pipeline. Build a scenario in minutes. Tangent runs the + experiments autonomously — Bayesian search, failure recovery, + best-config promotion. No babysitting required. + + + + + ); +} diff --git a/src/routes/tangent/components/IdeaTypeChip.tsx b/src/routes/tangent/components/IdeaTypeChip.tsx new file mode 100644 index 000000000..6382f700d --- /dev/null +++ b/src/routes/tangent/components/IdeaTypeChip.tsx @@ -0,0 +1,15 @@ +import { Badge } from "@/components/ui/badge"; +import { IDEA_TYPE_LABELS } from "@/routes/tangent/labels"; +import type { IdeaType } from "@/routes/tangent/types"; + +interface IdeaTypeChipProps { + type: IdeaType; +} + +export function IdeaTypeChip({ type }: IdeaTypeChipProps) { + return ( + + {IDEA_TYPE_LABELS[type]} + + ); +} diff --git a/src/routes/tangent/components/MetricDelta.tsx b/src/routes/tangent/components/MetricDelta.tsx new file mode 100644 index 000000000..def0d6381 --- /dev/null +++ b/src/routes/tangent/components/MetricDelta.tsx @@ -0,0 +1,29 @@ +import { Text } from "@/components/ui/typography"; +import { cn } from "@/lib/utils"; + +interface MetricDeltaProps { + /** Percentage change vs baseline; positive renders green, negative red. */ + deltaPct: number; + className?: string; +} + +export function MetricDelta({ deltaPct, className }: MetricDeltaProps) { + const isPositive = deltaPct >= 0; + const arrow = isPositive ? "▲" : "▼"; + const sign = isPositive ? "+" : ""; + return ( + + {arrow} {sign} + {deltaPct.toFixed(1)}% + + ); +} diff --git a/src/routes/tangent/components/OpportunityScoreRing.tsx b/src/routes/tangent/components/OpportunityScoreRing.tsx new file mode 100644 index 000000000..616c2885b --- /dev/null +++ b/src/routes/tangent/components/OpportunityScoreRing.tsx @@ -0,0 +1,93 @@ +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { Text } from "@/components/ui/typography"; +import { cn } from "@/lib/utils"; +import { + getScoreColorClass, + getScoreStrokeClass, +} from "@/routes/tangent/labels"; + +interface OpportunityScoreRingProps { + score: number | null; + /** Pixel diameter of the ring. */ + size?: number; + /** Show the "Opportunity" label beneath the ring. */ + showLabel?: boolean; +} + +const STROKE_WIDTH = 6; + +export function OpportunityScoreRing({ + score, + size = 64, + showLabel = false, +}: OpportunityScoreRingProps) { + const radius = (size - STROKE_WIDTH) / 2; + const circumference = 2 * Math.PI * radius; + const progress = score === null ? 0 : Math.max(0, Math.min(100, score)) / 100; + const dashOffset = circumference * (1 - progress); + + const tooltip = + score === null + ? "Not yet analyzed — click Re-analyze" + : "Tangent Opportunity Score"; + + return ( +
+ + +
+ + + {score !== null && ( + + )} + + + {score === null ? "—" : score} + +
+
+ {tooltip} +
+ {showLabel && ( + + Opportunity + + )} +
+ ); +} diff --git a/src/routes/tangent/components/PipelineRow.tsx b/src/routes/tangent/components/PipelineRow.tsx new file mode 100644 index 000000000..cfca703dd --- /dev/null +++ b/src/routes/tangent/components/PipelineRow.tsx @@ -0,0 +1,85 @@ +import { useNavigate } from "@tanstack/react-router"; +import type { MouseEvent } from "react"; + +import { InlineStack } from "@/components/ui/layout"; +import { Spinner } from "@/components/ui/spinner"; +import { TableCell, TableRow } from "@/components/ui/table"; +import { Text } from "@/components/ui/typography"; +import { APP_ROUTES } from "@/routes/router"; +import { MetricDelta } from "@/routes/tangent/components/MetricDelta"; +import { OpportunityScoreRing } from "@/routes/tangent/components/OpportunityScoreRing"; +import { RunStatusIndicator } from "@/routes/tangent/components/RunStatusIndicator"; +import { ScenarioStatusBadge } from "@/routes/tangent/components/ScenarioStatusBadge"; +import { getCreatorHandle } from "@/routes/tangent/labels"; +import type { TangentPipeline } from "@/routes/tangent/types"; +import { formatRelativeTime } from "@/utils/date"; + +interface PipelineRowProps { + pipeline: TangentPipeline; +} + +export function PipelineRow({ pipeline }: PipelineRowProps) { + const navigate = useNavigate(); + const creator = getCreatorHandle(pipeline.ownerEmail); + const detailPath = `/tangent/${pipeline.runId}`; + + const handleRowClick = (event: MouseEvent) => { + if (event.ctrlKey || event.metaKey) { + window.open(detailPath, "_blank"); + return; + } + void navigate({ + to: APP_ROUTES.TANGENT_PROJECT, + params: { runId: pipeline.runId }, + }); + }; + + return ( + + + + + {pipeline.name} + + + + + + + + + + {formatRelativeTime(new Date(pipeline.lastRunAt))} + + + + {pipeline.metricName && pipeline.metricValue !== undefined ? ( + + + {pipeline.metricName}: {pipeline.metricValue.toFixed(4)} + + {pipeline.metricDeltaPct !== undefined && ( + + )} + + ) : ( + + — + + )} + + + + {creator ?? "—"} + + + + {pipeline.analyzing ? ( + + ) : ( + + )} + + + ); +} diff --git a/src/routes/tangent/components/RunStatusIndicator.tsx b/src/routes/tangent/components/RunStatusIndicator.tsx new file mode 100644 index 000000000..0b2bbf7dd --- /dev/null +++ b/src/routes/tangent/components/RunStatusIndicator.tsx @@ -0,0 +1,27 @@ +import { InlineStack } from "@/components/ui/layout"; +import { Text } from "@/components/ui/typography"; +import { cn } from "@/lib/utils"; +import { RUN_STATUS_LABELS } from "@/routes/tangent/labels"; +import type { RunStatus } from "@/routes/tangent/types"; + +interface RunStatusIndicatorProps { + status: RunStatus; +} + +const DOT_CLASS: Record = { + succeeded: "bg-emerald-500", + failed: "bg-destructive", + running: "bg-amber-500 animate-pulse", +}; + +export function RunStatusIndicator({ status }: RunStatusIndicatorProps) { + return ( + + + {RUN_STATUS_LABELS[status]} + + ); +} diff --git a/src/routes/tangent/components/ScenarioStatusBadge.tsx b/src/routes/tangent/components/ScenarioStatusBadge.tsx new file mode 100644 index 000000000..448e24ff2 --- /dev/null +++ b/src/routes/tangent/components/ScenarioStatusBadge.tsx @@ -0,0 +1,34 @@ +import { Badge } from "@/components/ui/badge"; +import { SCENARIO_STATUS_LABELS } from "@/routes/tangent/labels"; +import type { ScenarioStatus } from "@/routes/tangent/types"; + +interface ScenarioStatusBadgeProps { + status: ScenarioStatus; +} + +const STATUS_PREFIX: Record = { + no_scenario: "○", + scenario_built: "✓", + scenario_ready: "●", + tangent_running: "◎", + results_available: "✓", +}; + +const STATUS_VARIANT: Record< + ScenarioStatus, + "secondary" | "default" | "outline" +> = { + no_scenario: "outline", + scenario_built: "secondary", + scenario_ready: "secondary", + tangent_running: "default", + results_available: "default", +}; + +export function ScenarioStatusBadge({ status }: ScenarioStatusBadgeProps) { + return ( + + {STATUS_PREFIX[status]} {SCENARIO_STATUS_LABELS[status]} + + ); +} diff --git a/src/routes/tangent/components/StatBoxes.tsx b/src/routes/tangent/components/StatBoxes.tsx new file mode 100644 index 000000000..9b674918a --- /dev/null +++ b/src/routes/tangent/components/StatBoxes.tsx @@ -0,0 +1,42 @@ +import { BlockStack, InlineStack } from "@/components/ui/layout"; +import { Text } from "@/components/ui/typography"; +import { useTangentStats } from "@/routes/tangent/hooks/useTangentStats"; + +interface StatBoxProps { + label: string; + value: number | null | undefined; +} + +/** Renders an em dash while loading and when a count is zero, never `0`. */ +function StatBox({ label, value }: StatBoxProps) { + const display = + value === null || value === undefined || value === 0 ? "—" : value; + + return ( + + + {display} + + + {label} + + + ); +} + +export function StatBoxes() { + const { data: stats, isPending } = useTangentStats(); + + const resolved = isPending ? undefined : stats; + + return ( + + + + + + ); +} diff --git a/src/routes/tangent/components/detail/CurrentPerformance.tsx b/src/routes/tangent/components/detail/CurrentPerformance.tsx new file mode 100644 index 000000000..08916bd60 --- /dev/null +++ b/src/routes/tangent/components/detail/CurrentPerformance.tsx @@ -0,0 +1,84 @@ +import { BlockStack, InlineStack } from "@/components/ui/layout"; +import { Text } from "@/components/ui/typography"; +import { cn } from "@/lib/utils"; +import type { TangentPipeline } from "@/routes/tangent/types"; + +interface CurrentPerformanceProps { + pipeline: TangentPipeline; +} + +function PerformanceBox({ + label, + children, +}: { + label: string; + children: React.ReactNode; +}) { + return ( + + + {label} + + {children} + + ); +} + +export function CurrentPerformance({ pipeline }: CurrentPerformanceProps) { + const hasMetric = + pipeline.metricName !== undefined && pipeline.metricValue !== undefined; + if (!pipeline.metricName) return null; + + const deltaPct = pipeline.metricDeltaPct; + + return ( + + + {hasMetric ? ( + + + {pipeline.metricValue?.toFixed(4)} + + {deltaPct !== undefined && ( + = 0 + ? "text-emerald-600 dark:text-emerald-400" + : "text-destructive", + )} + > + {deltaPct >= 0 ? "+" : ""} + {deltaPct.toFixed(1)}% vs baseline + + )} + + ) : ( + + last run had no result + + )} + + + + + {pipeline.baselineValue !== undefined + ? pipeline.baselineValue.toFixed(4) + : "—"} + + + + + + {pipeline.opportunityScore !== null + ? `${pipeline.opportunityScore}/100` + : "Not scored"} + + + + ); +} diff --git a/src/routes/tangent/components/detail/IdeaCard.tsx b/src/routes/tangent/components/detail/IdeaCard.tsx new file mode 100644 index 000000000..240d881b8 --- /dev/null +++ b/src/routes/tangent/components/detail/IdeaCard.tsx @@ -0,0 +1,161 @@ +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { BlockStack, InlineStack } from "@/components/ui/layout"; +import { Spinner } from "@/components/ui/spinner"; +import { Text } from "@/components/ui/typography"; +import useToastNotification from "@/hooks/useToastNotification"; +import { IdeaTypeChip } from "@/routes/tangent/components/IdeaTypeChip"; +import type { ExperimentIdea } from "@/routes/tangent/types"; +import { formatRelativeTime } from "@/utils/date"; + +interface IdeaCardProps { + idea: ExperimentIdea; +} + +const STUB_MESSAGE = + "This action is not wired up in the Phase 1 prototype yet."; + +function BuildStatePill({ idea }: { idea: ExperimentIdea }) { + if (idea.buildState === "building") { + return ( + + + + Building + + + ); + } + if (idea.buildState === "built") { + return ( + + ✓ Built + + ); + } + if (idea.buildState === "failed") { + return ( + + ⚠ Failed + + ); + } + return null; +} + +function IdeaActions({ idea }: { idea: ExperimentIdea }) { + const notify = useToastNotification(); + const stub = () => notify(STUB_MESSAGE, "info"); + + if (idea.buildState === "built") { + return ( + + + + + + ); + } + if (idea.buildState === "unbuilt") { + return idea.source === "tangent" ? ( + + ) : ( + + ); + } + if (idea.buildState === "failed") { + return ( + + ); + } + return null; +} + +function IdeaVotes({ idea }: { idea: ExperimentIdea }) { + const notify = useToastNotification(); + if (idea.source !== "tangent") return null; + const stub = () => notify(STUB_MESSAGE, "info"); + return ( + + + + + ); +} + +export function IdeaCard({ idea }: IdeaCardProps) { + return ( + + + + + + {idea.source === "tangent" ? "Tangent" : "Human"} + + + {idea.impact && ( + + Impact: {idea.impact} + + )} + + + + {idea.title} + + + {idea.evidence} + + {idea.author && ( + + by {idea.author} + + )} + {idea.buildState === "built" && idea.builtBy && ( + + + ✓ Built by {idea.builtBy} + {idea.builtAt + ? ` · ${formatRelativeTime(new Date(idea.builtAt))}` + : ""} + + {idea.unverifiedCount ? ( + + {idea.unverifiedCount} UNVERIFIED + + ) : null} + + )} + + + + + + ); +} diff --git a/src/routes/tangent/components/detail/ResultsSection.tsx b/src/routes/tangent/components/detail/ResultsSection.tsx new file mode 100644 index 000000000..9ff27ebe7 --- /dev/null +++ b/src/routes/tangent/components/detail/ResultsSection.tsx @@ -0,0 +1,92 @@ +import { BlockStack, InlineStack } from "@/components/ui/layout"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { Heading, Paragraph, Text } from "@/components/ui/typography"; +import type { ResultsCase, TangentResults } from "@/routes/tangent/types"; + +interface ResultsSectionProps { + results: TangentResults; +} + +function CaseTable({ title, cases }: { title: string; cases: ResultsCase[] }) { + return ( + + + {title} + + + + + Example + Baseline + Best + Δ + + + + {cases.map((row) => ( + + {row.example} + {row.baseline} + {row.best} + {row.delta} + + ))} + +
+
+ ); +} + +export function ResultsSection({ results }: ResultsSectionProps) { + return ( + + Previous Tangent Results + + ✓ Optimization complete — {results.metricDelta} + + + + + + Best delta + + + {results.bestDelta} + + + + + Best run + + + {results.bestRunId} + + + + + + + Config changes + +
+          {results.configChanges}
+        
+
+ + + + + +
+ ); +} diff --git a/src/routes/tangent/components/detail/ScenarioSection.tsx b/src/routes/tangent/components/detail/ScenarioSection.tsx new file mode 100644 index 000000000..43bbf87eb --- /dev/null +++ b/src/routes/tangent/components/detail/ScenarioSection.tsx @@ -0,0 +1,92 @@ +import { Button } from "@/components/ui/button"; +import { BlockStack, InlineStack } from "@/components/ui/layout"; +import { Heading, Paragraph, Text } from "@/components/ui/typography"; +import useToastNotification from "@/hooks/useToastNotification"; +import { IdeaCard } from "@/routes/tangent/components/detail/IdeaCard"; +import type { ExperimentIdea } from "@/routes/tangent/types"; + +interface ScenarioSectionProps { + ideas: ExperimentIdea[]; +} + +function builtCount(ideas: ExperimentIdea[]): number { + return ideas.filter((idea) => idea.buildState === "built").length; +} + +function IdeaSubsection({ + title, + ideas, + emptyMessage, +}: { + title: string; + ideas: ExperimentIdea[]; + emptyMessage: string; +}) { + return ( + + + {title} ({builtCount(ideas)}/{ideas.length} built) + + {ideas.length === 0 ? ( + + {emptyMessage} + + ) : ( + + {ideas + .slice() + .sort((a, b) => a.rank - b.rank) + .map((idea) => ( + + ))} + + )} + + ); +} + +export function ScenarioSection({ ideas }: ScenarioSectionProps) { + const notify = useToastNotification(); + const tangentIdeas = ideas.filter((idea) => idea.source === "tangent"); + const humanIdeas = ideas.filter((idea) => idea.source === "human"); + + return ( + + + Scenarios + + + + Each scenario starts as an idea. Click ⚡ Auto build on a Tangent idea + to generate scenario.yaml + MEMORY.md in the background. + + + + + + ); +} diff --git a/src/routes/tangent/components/detail/TangentAnalysis.tsx b/src/routes/tangent/components/detail/TangentAnalysis.tsx new file mode 100644 index 000000000..637764dfc --- /dev/null +++ b/src/routes/tangent/components/detail/TangentAnalysis.tsx @@ -0,0 +1,33 @@ +import { BlockStack } from "@/components/ui/layout"; +import { Heading, Paragraph } from "@/components/ui/typography"; +import type { TangentPipeline } from "@/routes/tangent/types"; + +interface TangentAnalysisProps { + pipeline: TangentPipeline; +} + +export function TangentAnalysis({ pipeline }: TangentAnalysisProps) { + return ( + + Tangent Analysis + {pipeline.analyzing ? ( + + ◎ Analyzing pipeline with Claude — this takes a few seconds… + + ) : pipeline.summary ? ( + + {pipeline.summary.split("\n\n").map((paragraph, index) => ( + + {paragraph} + + ))} + + ) : ( + + No analysis yet. Click Re-analyze in the top nav to have the Tangent + Researcher evaluate this pipeline. + + )} + + ); +} diff --git a/src/routes/tangent/hooks/useReanalyzeAll.ts b/src/routes/tangent/hooks/useReanalyzeAll.ts new file mode 100644 index 000000000..fa246ac5c --- /dev/null +++ b/src/routes/tangent/hooks/useReanalyzeAll.ts @@ -0,0 +1,26 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import useToastNotification from "@/hooks/useToastNotification"; +import { reanalyzeAllPipelines } from "@/routes/tangent/services/tangentApi"; +import { TangentQueryKeys } from "@/routes/tangent/types"; + +export function useReanalyzeAll() { + const queryClient = useQueryClient(); + const notify = useToastNotification(); + + return useMutation({ + mutationFn: reanalyzeAllPipelines, + onSuccess: () => { + void queryClient.invalidateQueries({ + queryKey: TangentQueryKeys.Pipelines(), + }); + notify( + "Re-analysis queued for all pipelines. Scores will update in ~5–10 min as the Tangent Researcher runs.", + "success", + ); + }, + onError: () => { + notify("Failed to queue re-analysis", "error"); + }, + }); +} diff --git a/src/routes/tangent/hooks/useTangentPipeline.ts b/src/routes/tangent/hooks/useTangentPipeline.ts new file mode 100644 index 000000000..2523f66ab --- /dev/null +++ b/src/routes/tangent/hooks/useTangentPipeline.ts @@ -0,0 +1,15 @@ +import { useQuery } from "@tanstack/react-query"; + +import { fetchTangentPipeline } from "@/routes/tangent/services/tangentApi"; +import { TangentQueryKeys } from "@/routes/tangent/types"; +import { MINUTES } from "@/utils/constants"; + +export function useTangentPipeline(runId: string) { + return useQuery({ + queryKey: TangentQueryKeys.Pipeline(runId), + queryFn: () => fetchTangentPipeline(runId), + enabled: runId.length > 0, + staleTime: 5 * MINUTES, + refetchOnWindowFocus: false, + }); +} diff --git a/src/routes/tangent/hooks/useTangentPipelines.ts b/src/routes/tangent/hooks/useTangentPipelines.ts new file mode 100644 index 000000000..ca7c570d9 --- /dev/null +++ b/src/routes/tangent/hooks/useTangentPipelines.ts @@ -0,0 +1,14 @@ +import { useQuery } from "@tanstack/react-query"; + +import { fetchTangentPipelines } from "@/routes/tangent/services/tangentApi"; +import { TangentQueryKeys } from "@/routes/tangent/types"; +import { MINUTES } from "@/utils/constants"; + +export function useTangentPipelines() { + return useQuery({ + queryKey: TangentQueryKeys.Pipelines(), + queryFn: fetchTangentPipelines, + staleTime: 5 * MINUTES, + refetchOnWindowFocus: false, + }); +} diff --git a/src/routes/tangent/hooks/useTangentStats.ts b/src/routes/tangent/hooks/useTangentStats.ts new file mode 100644 index 000000000..612f4474e --- /dev/null +++ b/src/routes/tangent/hooks/useTangentStats.ts @@ -0,0 +1,14 @@ +import { useQuery } from "@tanstack/react-query"; + +import { fetchTangentStats } from "@/routes/tangent/services/tangentApi"; +import { TangentQueryKeys } from "@/routes/tangent/types"; +import { MINUTES } from "@/utils/constants"; + +export function useTangentStats() { + return useQuery({ + queryKey: TangentQueryKeys.Stats(), + queryFn: fetchTangentStats, + staleTime: 5 * MINUTES, + refetchOnWindowFocus: false, + }); +} diff --git a/src/routes/tangent/labels.ts b/src/routes/tangent/labels.ts new file mode 100644 index 000000000..269fe8ca6 --- /dev/null +++ b/src/routes/tangent/labels.ts @@ -0,0 +1,75 @@ +import type { + IdeaType, + PipelineFilter, + RunStatus, + ScenarioStatus, +} from "@/routes/tangent/types"; + +export const IDEA_TYPE_LABELS: Record = { + feature_engineering: "Feature Engineering", + hyper_parameter_optimization: "Hyper Parameter Optimization", + input_data: "Input Data", + model_architecture: "Model Architecture", +}; + +export const RUN_STATUS_LABELS: Record = { + succeeded: "Succeeded", + failed: "Failed", + running: "Running", +}; + +export const SCENARIO_STATUS_LABELS: Record = { + no_scenario: "No scenario", + scenario_built: "Scenario built", + scenario_ready: "Scenario ready", + tangent_running: "Tangent running", + results_available: "Results available", +}; + +export const PIPELINE_FILTER_LABELS: Record = { + all: "All", + my_pipelines: "My pipelines", + no_scenario: "No scenario", + has_results: "Has results", +}; + +export const PIPELINE_FILTERS: PipelineFilter[] = [ + "all", + "my_pipelines", + "no_scenario", + "has_results", +]; + +type ScoreBand = "high" | "medium" | "low"; + +/** Maps an opportunity score to its color band per the product spec. */ +function getScoreBand(score: number): ScoreBand { + if (score >= 70) return "high"; + if (score >= 45) return "medium"; + return "low"; +} + +/** Tailwind text color class for a score band (and the unscored case). */ +export function getScoreColorClass(score: number | null): string { + if (score === null) return "text-muted-foreground"; + const band = getScoreBand(score); + if (band === "high") return "text-emerald-600 dark:text-emerald-400"; + if (band === "medium") return "text-amber-600 dark:text-amber-400"; + return "text-muted-foreground"; +} + +/** Stroke color (currentColor-friendly) for the score ring per band. */ +export function getScoreStrokeClass(score: number | null): string { + if (score === null) return "stroke-muted-foreground/40"; + const band = getScoreBand(score); + if (band === "high") return "stroke-emerald-500"; + if (band === "medium") return "stroke-amber-500"; + return "stroke-muted-foreground/50"; +} + +/** Derives the creator handle from an owner email (part before `@`). */ +export function getCreatorHandle(ownerEmail?: string): string | undefined { + if (!ownerEmail) return undefined; + const [handle] = ownerEmail.split("@"); + return handle || undefined; +} diff --git a/src/routes/tangent/mocks/mockData.ts b/src/routes/tangent/mocks/mockData.ts new file mode 100644 index 000000000..007a88b57 --- /dev/null +++ b/src/routes/tangent/mocks/mockData.ts @@ -0,0 +1,278 @@ +import type { + ExperimentIdea, + TangentPipeline, + TeamStats, +} from "@/routes/tangent/types"; + +const CURRENT_USER_NAME = "you"; + +const daysAgo = (days: number): string => + new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString(); + +const tangentIdea = ( + idea: Omit & + Partial>, +): ExperimentIdea => ({ source: "tangent", ...idea }); + +const humanIdea = ( + idea: Omit, +): ExperimentIdea => ({ source: "human", ...idea }); + +const queryEncoderPipeline: TangentPipeline = { + runId: "019db6c661691a51840d", + name: "Query Encoder Distillation", + ownerEmail: "rin.tanaka@shopify.com", + runStatus: "succeeded", + lastRunAt: daysAgo(1), + metricName: "ndcg@10", + metricValue: 0.6421, + baselineValue: 0.619, + metricDeltaPct: 3.7, + scenarioStatus: "results_available", + opportunityScore: 88, + analyzing: false, + rationale: + "Relies on a manual grid search over KD temperature and alpha with no Bayesian optimization. Wide unexplored hyperparameter space suggests substantial headroom.", + summary: + "Query Encoder Distillation trains a compact query tower by distilling from a larger teacher. The pipeline currently tunes knowledge-distillation alpha and temperature through a hand-run grid, leaving most of the search space unexplored.\n\nTangent can add the most value by replacing the grid with Bayesian search across KD alpha, temperature, learning-rate schedule, and warmup. Early evidence shows the baseline sits well below the achievable frontier on ndcg@10.\n\nPrioritize KD hyperparameter optimization first, then revisit feature pooling once the tuning frontier is mapped.", + oasisUrl: "https://oasis.example.com/runs/019db6c661691a51840d", + builtByCurrentUser: true, + ideas: [ + tangentIdea({ + id: "qed-1", + rank: 1, + title: "Bayesian sweep over KD alpha and temperature", + type: "hyper_parameter_optimization", + impact: "high", + evidence: + "Current tuning is a 3x3 manual grid; neighboring configs show monotonic gains, implying an unexplored optimum.", + buildState: "built", + builtBy: CURRENT_USER_NAME, + builtAt: daysAgo(2), + unverifiedCount: 2, + upvotes: 3, + downvotes: 0, + }), + tangentIdea({ + id: "qed-2", + rank: 2, + title: "Add cross-shop interaction features", + type: "feature_engineering", + impact: "medium", + evidence: + "Shops with shared catalogs show correlated relevance; interaction terms are not yet modeled.", + buildState: "unbuilt", + upvotes: 1, + downvotes: 1, + }), + humanIdea({ + id: "qed-3", + rank: 1, + title: "Try a wider warmup schedule", + type: "hyper_parameter_optimization", + evidence: + "Loss curves plateau early — a longer warmup may stabilize the distillation signal.", + author: "marco.silva", + buildState: "built", + builtBy: "marco.silva", + builtAt: daysAgo(4), + }), + ], + results: { + metricDelta: "+3.7% ndcg@10", + bestDelta: "+3.7%", + bestRunId: "019db6c661691a51999f", + configChanges: + "- kd_alpha: 0.5 -> 0.72\n- kd_temperature: 2.0 -> 3.5\n- warmup_steps: 500 -> 1500", + topWinningCases: [ + { + example: "winter boots", + baseline: "0.61", + best: "0.74", + delta: "+0.13", + }, + { + example: "linen dress", + baseline: "0.58", + best: "0.69", + delta: "+0.11", + }, + ], + topLosingCases: [ + { example: "gift card", baseline: "0.81", best: "0.77", delta: "-0.04" }, + ], + }, +}; + +const rerankerPipeline: TangentPipeline = { + runId: "019dc1a2b3c4d5e6f708", + name: "Catalog Reranker v3", + ownerEmail: "marco.silva@shopify.com", + runStatus: "running", + lastRunAt: daysAgo(0), + metricName: "mrr", + metricValue: 0.4123, + baselineValue: 0.415, + metricDeltaPct: -0.7, + scenarioStatus: "tangent_running", + opportunityScore: 72, + analyzing: false, + rationale: + "Deep architecture with many tunable knobs but stale tuning from six months ago. Recent regression vs baseline indicates drift worth re-optimizing.", + summary: + "Catalog Reranker v3 is a cross-encoder reranking model with a large number of tunable layers and heads. Tuning has not been revisited in months and the latest run regressed slightly against baseline.\n\nTangent can drive value by re-optimizing learning rate, layer freezing, and head capacity together, and by exploring negative-mining strategies that the current data pipeline does not use.", + oasisUrl: "https://oasis.example.com/runs/019dc1a2b3c4d5e6f708", + builtByCurrentUser: false, + ideas: [ + tangentIdea({ + id: "rr-1", + rank: 1, + title: "Unfreeze top transformer layers", + type: "model_architecture", + impact: "high", + evidence: + "Only the head is trainable today; unfreezing the top 2 layers is cheap and commonly lifts reranking quality.", + buildState: "building", + upvotes: 2, + downvotes: 0, + }), + tangentIdea({ + id: "rr-2", + rank: 2, + title: "Hard negative mining from impression logs", + type: "input_data", + impact: "medium", + evidence: + "Training uses random negatives; impression logs contain harder negatives that better match serving.", + buildState: "unbuilt", + }), + ], +}; + +const embeddingPipeline: TangentPipeline = { + runId: "019dc9f8e7d6c5b4a302", + name: "Product Embedding Tower", + ownerEmail: "rin.tanaka@shopify.com", + runStatus: "succeeded", + lastRunAt: daysAgo(3), + metricName: "recall@100", + metricValue: 0.8312, + baselineValue: 0.8205, + metricDeltaPct: 1.3, + scenarioStatus: "scenario_ready", + opportunityScore: 58, + analyzing: false, + rationale: + "Already uses partial Bayesian tuning, so opportunity is moderate. Embedding pooling choices remain unexplored and could yield incremental gains.", + summary: + "Product Embedding Tower learns dense product representations for retrieval. It already uses a partial Bayesian sweep, so the highest-leverage remaining opportunities are around embedding pooling and feature crosses.", + oasisUrl: "https://oasis.example.com/runs/019dc9f8e7d6c5b4a302", + builtByCurrentUser: true, + ideas: [ + tangentIdea({ + id: "emb-1", + rank: 1, + title: "Compare mean vs attention pooling", + type: "feature_engineering", + impact: "medium", + evidence: + "Mean pooling discards positional signal; attention pooling is inexpensive to trial.", + buildState: "built", + builtBy: CURRENT_USER_NAME, + builtAt: daysAgo(5), + unverifiedCount: 0, + upvotes: 1, + downvotes: 0, + }), + ], +}; + +const intentPipeline: TangentPipeline = { + runId: "019dd0112233445566aa", + name: "Search Intent Classifier", + ownerEmail: "amara.okafor@shopify.com", + runStatus: "failed", + lastRunAt: daysAgo(2), + metricName: "f1", + metricValue: undefined, + baselineValue: 0.774, + scenarioStatus: "scenario_built", + opportunityScore: 49, + analyzing: false, + rationale: + "Moderate opportunity with a mature tuning history. The last run failed, so re-establishing a clean baseline is the first step before optimization.", + summary: + "Search Intent Classifier routes queries to downstream rankers. Tuning is relatively mature, so opportunity is moderate. The most recent run failed and should be re-run to restore a reliable baseline.", + oasisUrl: "https://oasis.example.com/runs/019dd0112233445566aa", + builtByCurrentUser: false, + ideas: [ + humanIdea({ + id: "int-1", + rank: 1, + title: "Rebalance training labels by intent class", + type: "input_data", + evidence: + "Tail intent classes are under-represented; class weighting may recover F1 on rare intents.", + author: "amara.okafor", + buildState: "built", + builtBy: "amara.okafor", + builtAt: daysAgo(6), + }), + ], +}; + +const spellPipeline: TangentPipeline = { + runId: "019dd44556677889900b", + name: "Spell Correction Seq2Seq", + ownerEmail: "amara.okafor@shopify.com", + runStatus: "succeeded", + lastRunAt: daysAgo(8), + metricName: "exact_match", + metricValue: 0.9012, + baselineValue: 0.901, + metricDeltaPct: 0.02, + scenarioStatus: "no_scenario", + opportunityScore: 31, + analyzing: false, + rationale: + "Low opportunity: the model is near a well-explored optimum with stable, mature tuning and little remaining search space.", + summary: + "Spell Correction Seq2Seq is a mature pipeline operating close to its explored optimum. Remaining opportunity is low; gains would likely require new data sources rather than tuning.", + oasisUrl: "https://oasis.example.com/runs/019dd44556677889900b", + builtByCurrentUser: false, + ideas: [], +}; + +const newPipeline: TangentPipeline = { + runId: "019dd8899aabbccddee0", + name: "Multimodal Ranker (experimental)", + ownerEmail: "rin.tanaka@shopify.com", + runStatus: "succeeded", + lastRunAt: daysAgo(0), + metricName: "ndcg@10", + metricValue: 0.598, + baselineValue: undefined, + scenarioStatus: "no_scenario", + opportunityScore: null, + analyzing: true, + rationale: undefined, + summary: undefined, + oasisUrl: "https://oasis.example.com/runs/019dd8899aabbccddee0", + builtByCurrentUser: false, + ideas: [], +}; + +export const MOCK_PIPELINES: TangentPipeline[] = [ + queryEncoderPipeline, + rerankerPipeline, + embeddingPipeline, + intentPipeline, + spellPipeline, + newPipeline, +]; + +export const MOCK_TEAM_STATS: TeamStats = { + members: 4, + scenarios: 7, + withResults: 1, +}; diff --git a/src/routes/tangent/services/tangentApi.ts b/src/routes/tangent/services/tangentApi.ts new file mode 100644 index 000000000..980fd88b7 --- /dev/null +++ b/src/routes/tangent/services/tangentApi.ts @@ -0,0 +1,42 @@ +import { + MOCK_PIPELINES, + MOCK_TEAM_STATS, +} from "@/routes/tangent/mocks/mockData"; +import type { TangentPipeline, TeamStats } from "@/routes/tangent/types"; + +/** + * Mocked Tangent API service for Phase 1. + * + * Every function simulates a short network round-trip and resolves data from + * the in-memory fixtures. When the real `Tangent/*` endpoints land, these + * functions are the single place to swap in the generated SDK calls. + */ + +const SIMULATED_LATENCY_MS = 350; + +const delay = (value: T, ms = SIMULATED_LATENCY_MS): Promise => + new Promise((resolve) => { + setTimeout(() => resolve(value), ms); + }); + +const clone = (value: T): T => + typeof structuredClone === "function" + ? structuredClone(value) + : JSON.parse(JSON.stringify(value)); + +export const fetchTangentStats = (): Promise => + delay(clone(MOCK_TEAM_STATS)); + +export const fetchTangentPipelines = (): Promise => + delay(clone(MOCK_PIPELINES)); + +export const fetchTangentPipeline = ( + runId: string, +): Promise => + delay(clone(MOCK_PIPELINES.find((pipeline) => pipeline.runId === runId))); + +/** + * Stubbed re-analyze trigger. In Phase 1 this is a no-op that resolves after a + * simulated delay so the UI can show a queued/refresh affordance. + */ +export const reanalyzeAllPipelines = (): Promise => delay(undefined, 600); diff --git a/src/routes/tangent/types.ts b/src/routes/tangent/types.ts new file mode 100644 index 000000000..596e763bb --- /dev/null +++ b/src/routes/tangent/types.ts @@ -0,0 +1,116 @@ +/** + * Local domain types for the Tangent Phase 1 prototype. + * + * These intentionally live outside `src/api/types.gen.ts`: the real backend + * `Tangent/*` endpoints currently only cover instances, so for Phase 1 the + * dashboard and project-detail screens are driven entirely by a mocked API + * layer that resolves these shapes. + */ + +export type RunStatus = "succeeded" | "failed" | "running"; + +export type ScenarioStatus = + | "no_scenario" + | "scenario_built" + | "scenario_ready" + | "tangent_running" + | "results_available"; + +export type IdeaType = + | "feature_engineering" + | "hyper_parameter_optimization" + | "input_data" + | "model_architecture"; + +type IdeaImpact = "high" | "medium" | "low"; + +type IdeaSource = "tangent" | "human"; + +type IdeaBuildState = "unbuilt" | "building" | "built" | "failed"; + +export interface ExperimentIdea { + id: string; + rank: number; + title: string; + type: IdeaType; + source: IdeaSource; + impact?: IdeaImpact; + /** One-sentence evidence (Tangent ideas) or description (human ideas). */ + evidence: string; + /** Display name of the human author (human ideas only). */ + author?: string; + buildState: IdeaBuildState; + /** Display name of whoever built the scenario, when built. */ + builtBy?: string; + builtAt?: string; + unverifiedCount?: number; + upvotes?: number; + downvotes?: number; +} + +export interface ResultsCase { + example: string; + baseline: string; + best: string; + delta: string; +} + +export interface TangentResults { + metricDelta: string; + bestDelta: string; + bestRunId: string; + configChanges: string; + topWinningCases: ResultsCase[]; + topLosingCases: ResultsCase[]; +} + +export interface TangentPipeline { + /** Tangle run ID — 20 hex characters beginning with `019`. */ + runId: string; + name: string; + /** Owner email; the part before `@` is the displayed creator handle. */ + ownerEmail?: string; + runStatus: RunStatus; + lastRunAt: string; + metricName?: string; + metricValue?: number; + baselineValue?: number; + /** Percentage change vs baseline; positive is an improvement. */ + metricDeltaPct?: number; + scenarioStatus: ScenarioStatus; + /** 0-100 opportunity score; `null` means not yet analyzed. */ + opportunityScore: number | null; + /** Whether Tangent is currently scoring this pipeline. */ + analyzing: boolean; + /** Two-sentence rationale shown on the card. */ + rationale?: string; + /** Longer summary shown in the project-detail analysis section. */ + summary?: string; + oasisUrl?: string; + /** Whether the signed-in user has built a scenario for this pipeline. */ + builtByCurrentUser: boolean; + ideas: ExperimentIdea[]; + results?: TangentResults; +} + +export interface TeamStats { + /** Distinct people who built at least one scenario, or `null` while loading. */ + members: number | null; + /** Total scenarios built across the team, or `null` while loading. */ + scenarios: number | null; + /** Pipelines that completed optimization and have results, or `null`. */ + withResults: number | null; +} + +export type PipelineFilter = + | "all" + | "my_pipelines" + | "no_scenario" + | "has_results"; + +export const TangentQueryKeys = { + All: () => ["tangent"] as const, + Stats: () => ["tangent", "stats"] as const, + Pipelines: () => ["tangent", "pipelines"] as const, + Pipeline: (runId: string) => ["tangent", "pipeline", runId] as const, +} as const;