From 16874b02caca736538a63a7709dcbeedab399b3d Mon Sep 17 00:00:00 2001 From: Maksym Yezhov Date: Wed, 3 Jun 2026 09:54:21 -0700 Subject: [PATCH] feat: v2 - tangent - scenarios window --- src/routes/tangent/hooks/useRunScenarios.ts | 24 ++ src/routes/tangent/idb/tangentDb.ts | 67 +++++ .../AiChat/components/TangentScenario.tsx | 136 ++++++++-- .../MlExperimentPlannerContent.tsx | 244 ++++++++++++++++++ .../useMlExperimentPlannerWindow.tsx | 29 +++ 5 files changed, 478 insertions(+), 22 deletions(-) create mode 100644 src/routes/tangent/hooks/useRunScenarios.ts create mode 100644 src/routes/tangent/idb/tangentDb.ts create mode 100644 src/routes/v2/shared/components/MlExperimentPlanner/MlExperimentPlannerContent.tsx create mode 100644 src/routes/v2/shared/components/MlExperimentPlanner/useMlExperimentPlannerWindow.tsx diff --git a/src/routes/tangent/hooks/useRunScenarios.ts b/src/routes/tangent/hooks/useRunScenarios.ts new file mode 100644 index 000000000..999347b7d --- /dev/null +++ b/src/routes/tangent/hooks/useRunScenarios.ts @@ -0,0 +1,24 @@ +import { useLiveQuery } from "dexie-react-hooks"; + +import { type ScenarioEntry, tangentDb } from "@/routes/tangent/idb/tangentDb"; + +/** + * Reactively reads the saved experiment scenarios for a given run, + * newest first. Returns an empty array while loading or when no run id + * is provided. + */ +export function useRunScenarios(runId?: string): { + scenarios: ScenarioEntry[]; +} { + const scenarios = + useLiveQuery(async () => { + if (!runId) return []; + const rows = await tangentDb.scenarios + .where("run.runId") + .equals(runId) + .sortBy("createdAt"); + return rows.reverse(); + }, [runId]) ?? []; + + return { scenarios }; +} diff --git a/src/routes/tangent/idb/tangentDb.ts b/src/routes/tangent/idb/tangentDb.ts new file mode 100644 index 000000000..7ea9959b1 --- /dev/null +++ b/src/routes/tangent/idb/tangentDb.ts @@ -0,0 +1,67 @@ +import { Dexie, type EntityTable } from "dexie"; + +export type ScenarioIdeaType = + | "feature_engineering" + | "hyperparameter_optimization" + | "input_data" + | "model_architecture"; + +export type ScenarioImpact = "high" | "medium" | "low"; + +/** Reference back to the Tangle run a scenario was planned against. */ +interface ScenarioRunRef { + runId: string; + /** Full URL captured at creation time (window.location.href). */ + url: string; +} + +export interface ScenarioIdea { + title: string; + ideaType: ScenarioIdeaType; + impact: ScenarioImpact; + evidence: string; +} + +/** + * Placeholders for the richer ScenarioYaml planning fields + * (search_space, metrics, budget, ...). Only name/description are + * populated on creation; the rest are filled in later as the user + * fleshes out the experiment plan. + */ +interface ScenarioPlan { + name: string; + description: string; + pipeline?: { path: string; baseline_run_id: string }; + metrics?: unknown; + search_space?: Record; + experiment_actions?: Record; + research?: unknown; + budget?: unknown; + timing?: unknown; + failure_playbook?: unknown[]; +} + +export interface ScenarioEntry { + id: string; + run: ScenarioRunRef; + score: number; + rationale: string; + summary: string; + /** Only the ideas the user selected when building the scenario. */ + ideas: ScenarioIdea[]; + plan: ScenarioPlan; + createdAt: number; + updatedAt: number; +} + +export const tangentDb = new Dexie("tangle_tangent") as Dexie & { + scenarios: EntityTable; +}; + +tangentDb.version(1).stores({ + scenarios: "id, run.runId, createdAt", +}); + +export async function saveScenario(entry: ScenarioEntry): Promise { + await tangentDb.scenarios.put(entry); +} diff --git a/src/routes/v2/shared/components/AiChat/components/TangentScenario.tsx b/src/routes/v2/shared/components/AiChat/components/TangentScenario.tsx index cf80352b5..19f2b9135 100644 --- a/src/routes/v2/shared/components/AiChat/components/TangentScenario.tsx +++ b/src/routes/v2/shared/components/AiChat/components/TangentScenario.tsx @@ -1,7 +1,18 @@ +import { useState } from "react"; + import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Checkbox } from "@/components/ui/checkbox"; import { BlockStack, InlineStack } from "@/components/ui/layout"; import { Paragraph, Text } from "@/components/ui/typography"; +import { useExecutionDataOptional } from "@/providers/ExecutionDataProvider"; +import { + saveScenario, + type ScenarioEntry, + type ScenarioIdea, +} from "@/routes/tangent/idb/tangentDb"; +import { useMlExperimentPlannerWindow } from "@/routes/v2/shared/components/MlExperimentPlanner/useMlExperimentPlannerWindow"; type Impact = "high" | "medium" | "low"; @@ -11,7 +22,7 @@ type IdeaType = | "input_data" | "model_architecture"; -interface ScenarioIdea { +interface ScenarioIdeaData { title: string; ideaType: IdeaType; impact: Impact; @@ -22,7 +33,7 @@ interface Scenario { score: number; rationale: string; summary: string; - ideas: ScenarioIdea[]; + ideas: ScenarioIdeaData[]; } const IDEA_TYPE_LABEL: Record = { @@ -44,7 +55,7 @@ function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null; } -function parseIdea(value: unknown): ScenarioIdea | null { +function parseIdea(value: unknown): ScenarioIdeaData | null { if (!isRecord(value)) return null; const { title, ideaType, impact, evidence } = value; if (typeof title !== "string") return null; @@ -75,7 +86,7 @@ function parseScenario(raw: string): Scenario | null { if (typeof summary !== "string") return null; if (!Array.isArray(ideas)) return null; - const parsedIdeas: ScenarioIdea[] = []; + const parsedIdeas: ScenarioIdeaData[] = []; for (const idea of ideas) { const parsed = parseIdea(idea); if (parsed) parsedIdeas.push(parsed); @@ -90,26 +101,42 @@ function scoreVariant(score: number): BadgeVariant { return "outline"; } -function IdeaCard({ idea }: { idea: ScenarioIdea }) { +interface IdeaCardProps { + idea: ScenarioIdeaData; + checked: boolean; + onCheckedChange: (checked: boolean) => void; +} + +function IdeaCard({ idea, checked, onCheckedChange }: IdeaCardProps) { return ( - - - {idea.title} - - - - - {IDEA_TYPE_LABEL[idea.ideaType]} - - - {idea.impact} impact - + + onCheckedChange(value === true)} + aria-label={`Include idea ${idea.title}`} + className="mt-0.5" + /> + + + + {idea.title} + + + + + {IDEA_TYPE_LABEL[idea.ideaType]} + + + {idea.impact} impact + + + @@ -123,6 +150,14 @@ function IdeaCard({ idea }: { idea: ScenarioIdea }) { export function TangentScenario({ raw }: { raw: string }) { const scenario = parseScenario(raw); + const ideaCount = scenario?.ideas.length ?? 0; + const [selected, setSelected] = useState>( + () => new Set(Array.from({ length: ideaCount }, (_, index) => index)), + ); + + const executionData = useExecutionDataOptional(); + const runId = executionData?.runId ?? undefined; + const openPlanner = useMlExperimentPlannerWindow(); if (!scenario) { return ( @@ -132,6 +167,49 @@ export function TangentScenario({ raw }: { raw: string }) { ); } + const toggleIdea = (index: number, isChecked: boolean) => { + setSelected((prev) => { + const next = new Set(prev); + if (isChecked) { + next.add(index); + } else { + next.delete(index); + } + return next; + }); + }; + + const handleUseScenario = async () => { + if (!runId) return; + + const selectedIdeas: ScenarioIdea[] = scenario.ideas.filter((_, index) => + selected.has(index), + ); + const now = Date.now(); + const id = + typeof crypto !== "undefined" && "randomUUID" in crypto + ? crypto.randomUUID() + : `scenario-${now}`; + const name = selectedIdeas[0]?.title ?? "Experiment plan"; + + const entry: ScenarioEntry = { + id, + run: { runId, url: window.location.href }, + score: scenario.score, + rationale: scenario.rationale, + summary: scenario.summary, + ideas: selectedIdeas, + plan: { name, description: scenario.summary }, + createdAt: now, + updatedAt: now, + }; + + await saveScenario(entry); + openPlanner(runId, id); + }; + + const canUseScenario = Boolean(runId) && selected.size > 0; + return ( @@ -157,10 +235,24 @@ export function TangentScenario({ raw }: { raw: string }) { Ideas {scenario.ideas.map((idea, index) => ( - + toggleIdea(index, isChecked)} + /> ))} )} + + ); } diff --git a/src/routes/v2/shared/components/MlExperimentPlanner/MlExperimentPlannerContent.tsx b/src/routes/v2/shared/components/MlExperimentPlanner/MlExperimentPlannerContent.tsx new file mode 100644 index 000000000..9656257ea --- /dev/null +++ b/src/routes/v2/shared/components/MlExperimentPlanner/MlExperimentPlannerContent.tsx @@ -0,0 +1,244 @@ +import { useState } from "react"; + +import { Badge } from "@/components/ui/badge"; +import { + Breadcrumb, + BreadcrumbItem, + BreadcrumbLink, + BreadcrumbList, + BreadcrumbPage, + BreadcrumbSeparator, +} from "@/components/ui/breadcrumb"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { BlockStack, InlineStack } from "@/components/ui/layout"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { Paragraph, Text } from "@/components/ui/typography"; +import { useRunScenarios } from "@/routes/tangent/hooks/useRunScenarios"; +import type { + ScenarioEntry, + ScenarioIdea, + ScenarioIdeaType, + ScenarioImpact, +} from "@/routes/tangent/idb/tangentDb"; + +const IDEA_TYPE_LABEL: Record = { + feature_engineering: "Feature engineering", + hyperparameter_optimization: "Hyperparameter optimization", + input_data: "Input data", + model_architecture: "Model architecture", +}; + +type BadgeVariant = "default" | "secondary" | "outline"; + +const IMPACT_VARIANT: Record = { + high: "default", + medium: "secondary", + low: "outline", +}; + +function scoreVariant(score: number): BadgeVariant { + if (score >= 70) return "default"; + if (score >= 40) return "secondary"; + return "outline"; +} + +function formatCreatedAt(createdAt: number): string { + return new Date(createdAt).toLocaleString(); +} + +function IdeaRow({ idea }: { idea: ScenarioIdea }) { + return ( + + + + + {idea.title} + + + + + {IDEA_TYPE_LABEL[idea.ideaType]} + + + {idea.impact} impact + + + + + + {idea.evidence} + + + + ); +} + +interface ScenarioRowProps { + scenario: ScenarioEntry; + onSelect: () => void; +} + +function ScenarioRow({ scenario, onSelect }: ScenarioRowProps) { + return ( + + + + {scenario.score}/100 + + + + + {scenario.plan.name} + + + + + {scenario.ideas.length} + + + + + {formatCreatedAt(scenario.createdAt)} + + + + ); +} + +interface ScenarioDetailProps { + scenario: ScenarioEntry; + onBack: () => void; +} + +function ScenarioDetail({ scenario, onBack }: ScenarioDetailProps) { + return ( + + + + + + + + + + + {scenario.plan.name} + + + + + + + + + {scenario.score}/100 + + + {scenario.plan.name} + + + + {scenario.rationale} + + + {scenario.summary} + + {scenario.ideas.length > 0 && ( + + + Selected ideas + + {scenario.ideas.map((idea, index) => ( + + ))} + + )} + + + + ); +} + +interface MlExperimentPlannerContentProps { + runId: string; + selectedScenarioId?: string; +} + +export function MlExperimentPlannerContent({ + runId, + selectedScenarioId, +}: MlExperimentPlannerContentProps) { + const { scenarios } = useRunScenarios(runId); + const [selectedId, setSelectedId] = useState( + selectedScenarioId ?? null, + ); + + if (scenarios.length === 0) { + return ( + + + No experiment scenarios yet. + + + Use the “Use scenario” action in the AI assistant to plan + one. + + + ); + } + + const selectedScenario = scenarios.find( + (scenario) => scenario.id === selectedId, + ); + + if (selectedScenario) { + return ( + + setSelectedId(null)} + /> + + ); + } + + return ( + + + + + + Score + Name + Ideas + Created + + + + {scenarios.map((scenario) => ( + setSelectedId(scenario.id)} + /> + ))} + +
+
+
+ ); +} diff --git a/src/routes/v2/shared/components/MlExperimentPlanner/useMlExperimentPlannerWindow.tsx b/src/routes/v2/shared/components/MlExperimentPlanner/useMlExperimentPlannerWindow.tsx new file mode 100644 index 000000000..8209afe0f --- /dev/null +++ b/src/routes/v2/shared/components/MlExperimentPlanner/useMlExperimentPlannerWindow.tsx @@ -0,0 +1,29 @@ +import { MlExperimentPlannerContent } from "@/routes/v2/shared/components/MlExperimentPlanner/MlExperimentPlannerContent"; +import { useSharedStores } from "@/routes/v2/shared/store/SharedStoreContext"; + +const ML_EXPERIMENT_WINDOW_ID = "ml-experiment-planner"; + +/** + * Returns an opener that shows the ML Experiment planner window for a run, + * optionally expanding a specific scenario. Reuses a stable window id so + * repeated calls update the content and focus the existing window. + */ +export function useMlExperimentPlannerWindow() { + const { windows } = useSharedStores(); + + return (runId: string, selectedScenarioId?: string) => + windows.openWindow( + , + { + id: ML_EXPERIMENT_WINDOW_ID, + title: "ML Experiment", + size: { width: 480, height: 600 }, + startVisible: true, + persisted: true, + defaultDockState: "right", + }, + ); +}