diff --git a/src/routes/tangent/TangentDashboardView.tsx b/src/routes/tangent/TangentDashboardView.tsx index 61e3273da..475b6c8c3 100644 --- a/src/routes/tangent/TangentDashboardView.tsx +++ b/src/routes/tangent/TangentDashboardView.tsx @@ -16,7 +16,9 @@ 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 { useScenarioRunIds } from "@/routes/tangent/hooks/useScenarioRunIds"; import { useTangentPipelines } from "@/routes/tangent/hooks/useTangentPipelines"; +import type { ScenarioEntry } from "@/routes/tangent/idb/tangentDb"; import { PIPELINE_FILTERS } from "@/routes/tangent/labels"; import type { PipelineFilter, TangentPipeline } from "@/routes/tangent/types"; @@ -24,6 +26,27 @@ interface TangentSearch { filter?: string; } +/** + * Builds a dashboard pipeline row from a locally-saved scenario for runs that + * are not present in the mocked pipeline data (e.g. real runs the user planned + * a scenario against). Mocked pipelines are preferred when a run id matches. + */ +function pipelineFromScenario(scenario: ScenarioEntry): TangentPipeline { + return { + runId: scenario.run.runId, + name: scenario.plan.name, + runStatus: "succeeded", + lastRunAt: new Date(scenario.createdAt).toISOString(), + scenarioStatus: scenario.research ? "tangent_running" : "scenario_built", + opportunityScore: scenario.score, + analyzing: false, + builtByCurrentUser: true, + ideas: [], + rationale: scenario.rationale, + summary: scenario.summary, + }; +} + function isPipelineFilter(value: unknown): value is PipelineFilter { return ( typeof value === "string" && (PIPELINE_FILTERS as string[]).includes(value) @@ -35,8 +58,6 @@ function matchesFilter( 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"; } @@ -57,21 +78,35 @@ export function TangentDashboardView() { ? search.filter : "all"; - const { data: pipelines, isPending, isError } = useTangentPipelines(); + const { + data: pipelines, + isPending: pipelinesPending, + isError, + } = useTangentPipelines(); + const { representativeByRun, isLoading: scenariosLoading } = + useScenarioRunIds(); const reanalyze = useReanalyzeAll(); - const allPipelines = pipelines ?? []; - const sorted = [...allPipelines].sort(byOpportunity); + const isPending = pipelinesPending || scenariosLoading; + + const mockByRun = new Map( + (pipelines ?? []).map((pipeline) => [pipeline.runId, pipeline]), + ); + const withScenarios = Array.from(representativeByRun.entries()).map( + ([runId, scenario]) => + mockByRun.get(runId) ?? pipelineFromScenario(scenario), + ); + const sorted = [...withScenarios].sort(byOpportunity); const visible = sorted.filter((pipeline) => matchesFilter(pipeline, activeFilter), ); - const scoredCount = allPipelines.filter( + const scoredCount = withScenarios.filter( (pipeline) => pipeline.opportunityScore !== null, ).length; const subtitle = isPending ? "Loading pipelines…" - : `Ranked by Tangent improvement opportunity · Search team · ${allPipelines.length} pipelines · ${scoredCount} analyzed`; + : `Ranked by Tangent improvement opportunity · Search team · ${withScenarios.length} pipelines with scenarios · ${scoredCount} analyzed`; return ( @@ -136,7 +171,7 @@ export function TangentDashboardView() { )} {!isPending && !isError && visible.length > 0 && ( - +
Name diff --git a/src/routes/tangent/TangentLayout.tsx b/src/routes/tangent/TangentLayout.tsx index a8f163bfa..464c40d86 100644 --- a/src/routes/tangent/TangentLayout.tsx +++ b/src/routes/tangent/TangentLayout.tsx @@ -29,7 +29,6 @@ interface FilterLink { const FILTER_LINKS: FilterLink[] = [ { filter: "my_pipelines", icon: "GitBranch" }, - { filter: "no_scenario", icon: "CircleDashed" }, { filter: "has_results", icon: "ChartLine" }, ]; diff --git a/src/routes/tangent/TangentProjectDetailView.tsx b/src/routes/tangent/TangentProjectDetailView.tsx index 4ca857e2f..fdc0f3ad0 100644 --- a/src/routes/tangent/TangentProjectDetailView.tsx +++ b/src/routes/tangent/TangentProjectDetailView.tsx @@ -1,118 +1,23 @@ 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 { BlockStack } from "@/components/ui/layout"; 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"; +import { MlExperimentPlannerContent } from "@/routes/v2/shared/components/MlExperimentPlanner/MlExperimentPlannerContent"; 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 && ( - - )} - - + ← All projects + +
+ +
); } diff --git a/src/routes/tangent/components/IdeaTypeChip.tsx b/src/routes/tangent/components/IdeaTypeChip.tsx deleted file mode 100644 index 6382f700d..000000000 --- a/src/routes/tangent/components/IdeaTypeChip.tsx +++ /dev/null @@ -1,15 +0,0 @@ -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/detail/CurrentPerformance.tsx b/src/routes/tangent/components/detail/CurrentPerformance.tsx deleted file mode 100644 index 08916bd60..000000000 --- a/src/routes/tangent/components/detail/CurrentPerformance.tsx +++ /dev/null @@ -1,84 +0,0 @@ -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 deleted file mode 100644 index 240d881b8..000000000 --- a/src/routes/tangent/components/detail/IdeaCard.tsx +++ /dev/null @@ -1,161 +0,0 @@ -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 deleted file mode 100644 index 9ff27ebe7..000000000 --- a/src/routes/tangent/components/detail/ResultsSection.tsx +++ /dev/null @@ -1,92 +0,0 @@ -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 deleted file mode 100644 index 43bbf87eb..000000000 --- a/src/routes/tangent/components/detail/ScenarioSection.tsx +++ /dev/null @@ -1,92 +0,0 @@ -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 deleted file mode 100644 index 637764dfc..000000000 --- a/src/routes/tangent/components/detail/TangentAnalysis.tsx +++ /dev/null @@ -1,33 +0,0 @@ -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/useScenarioRunIds.ts b/src/routes/tangent/hooks/useScenarioRunIds.ts new file mode 100644 index 000000000..22caf1b31 --- /dev/null +++ b/src/routes/tangent/hooks/useScenarioRunIds.ts @@ -0,0 +1,33 @@ +import { useLiveQuery } from "dexie-react-hooks"; + +import { type ScenarioEntry, tangentDb } from "@/routes/tangent/idb/tangentDb"; + +/** + * Reactively reads saved experiment scenarios grouped by run id, keeping the + * highest-scoring scenario as the representative for each run. Used to drive + * the dashboard, which lists every run that has at least one scenario. + */ +export function useScenarioRunIds(): { + runIds: Set; + representativeByRun: Map; + isLoading: boolean; +} { + const representativeByRun = useLiveQuery(async () => { + const rows = await tangentDb.scenarios.toArray(); + const byRun = new Map(); + for (const row of rows) { + const current = byRun.get(row.run.runId); + if (!current || row.score > current.score) { + byRun.set(row.run.runId, row); + } + } + return byRun; + }, []); + + return { + runIds: new Set(representativeByRun?.keys() ?? []), + representativeByRun: + representativeByRun ?? new Map(), + isLoading: representativeByRun === undefined, + }; +} diff --git a/src/routes/tangent/hooks/useTangentPipeline.ts b/src/routes/tangent/hooks/useTangentPipeline.ts deleted file mode 100644 index 2523f66ab..000000000 --- a/src/routes/tangent/hooks/useTangentPipeline.ts +++ /dev/null @@ -1,15 +0,0 @@ -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/labels.ts b/src/routes/tangent/labels.ts index 269fe8ca6..ecc8c7313 100644 --- a/src/routes/tangent/labels.ts +++ b/src/routes/tangent/labels.ts @@ -1,17 +1,9 @@ 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", @@ -29,14 +21,12 @@ export const SCENARIO_STATUS_LABELS: Record = { 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", ]; diff --git a/src/routes/tangent/services/autoresearchOpencode.ts b/src/routes/tangent/services/autoresearchOpencode.ts index a36f7ed69..aa7a9f839 100644 --- a/src/routes/tangent/services/autoresearchOpencode.ts +++ b/src/routes/tangent/services/autoresearchOpencode.ts @@ -275,7 +275,9 @@ function extractErrorMessage(error: unknown): string | undefined { } /** Pull the narration / running tool / completion state from message parts. */ -function parseAssistantMessage(message: unknown): ParsedAssistantMessage | null { +function parseAssistantMessage( + message: unknown, +): ParsedAssistantMessage | null { if (!isRecord(message)) return null; const { info, parts } = message; if (!isRecord(info) || info.role !== "assistant") return null; diff --git a/src/routes/tangent/services/tangentApi.ts b/src/routes/tangent/services/tangentApi.ts index 980fd88b7..368120dad 100644 --- a/src/routes/tangent/services/tangentApi.ts +++ b/src/routes/tangent/services/tangentApi.ts @@ -30,11 +30,6 @@ export const fetchTangentStats = (): Promise => 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. diff --git a/src/routes/tangent/types.ts b/src/routes/tangent/types.ts index 65e840390..c825f1d00 100644 --- a/src/routes/tangent/types.ts +++ b/src/routes/tangent/types.ts @@ -16,7 +16,7 @@ export type ScenarioStatus = | "tangent_running" | "results_available"; -export type IdeaType = +type IdeaType = | "feature_engineering" | "hyper_parameter_optimization" | "input_data" @@ -48,14 +48,14 @@ export interface ExperimentIdea { downvotes?: number; } -export interface ResultsCase { +interface ResultsCase { example: string; baseline: string; best: string; delta: string; } -export interface TangentResults { +interface TangentResults { metricDelta: string; bestDelta: string; bestRunId: string; @@ -102,11 +102,7 @@ export interface TeamStats { withResults: number | null; } -export type PipelineFilter = - | "all" - | "my_pipelines" - | "no_scenario" - | "has_results"; +export type PipelineFilter = "all" | "my_pipelines" | "has_results"; export const TangentQueryKeys = { All: () => ["tangent"] as const, diff --git a/src/routes/v2/shared/components/MlExperimentPlanner/MlExperimentPlannerContent.tsx b/src/routes/v2/shared/components/MlExperimentPlanner/MlExperimentPlannerContent.tsx index e065fbb60..b05b847c1 100644 --- a/src/routes/v2/shared/components/MlExperimentPlanner/MlExperimentPlannerContent.tsx +++ b/src/routes/v2/shared/components/MlExperimentPlanner/MlExperimentPlannerContent.tsx @@ -103,8 +103,11 @@ function ScenarioRow({ onRunResearch, isResearchPending, }: ScenarioRowProps) { - const { data: progress, isLoading: isProgressLoading, isError } = - useResearchProgress(scenario.research); + const { + data: progress, + isLoading: isProgressLoading, + isError, + } = useResearchProgress(scenario.research); return ( @@ -176,8 +179,11 @@ function ScenarioDetail({ onRunResearch, isResearchPending, }: ScenarioDetailProps) { - const { data: progress, isLoading: isProgressLoading, isError } = - useResearchProgress(scenario.research); + const { + data: progress, + isLoading: isProgressLoading, + isError, + } = useResearchProgress(scenario.research); return ( diff --git a/src/routes/v2/shared/components/MlExperimentPlanner/components/ResearchProgressView.tsx b/src/routes/v2/shared/components/MlExperimentPlanner/components/ResearchProgressView.tsx index c718300bb..7b4315d7d 100644 --- a/src/routes/v2/shared/components/MlExperimentPlanner/components/ResearchProgressView.tsx +++ b/src/routes/v2/shared/components/MlExperimentPlanner/components/ResearchProgressView.tsx @@ -123,7 +123,11 @@ export function ResearchProgressView({ {progress.todos.length > 0 && ( {progress.todos.map((todo) => ( - + ))} )}