diff --git a/packages/app/src/amicode/inspector/inspector-bridge.ts b/packages/app/src/amicode/inspector/inspector-bridge.ts index 736b0be2c..8e4b35b7d 100644 --- a/packages/app/src/amicode/inspector/inspector-bridge.ts +++ b/packages/app/src/amicode/inspector/inspector-bridge.ts @@ -5,18 +5,18 @@ export type RunPulseMeta = { runId: string; drives: number; knots: number; label export type RunPulse = { runId: string; iter: number; dt: number; values: number[][] } export type RunCompletion = { runId: string; fidelity: number; iterations: number; status: string } export type RunBridgeMessage = - | { type: "run:iteration"; runId: string; iter: number; objective: number; inf_pr: number; inf_du: number } - | { type: "run:pulse-meta"; runId: string; drives: number; knots: number; labels: string[]; bounds: [number, number][]; interp?: string } - | { type: "run:pulse"; runId: string; iter: number; dt: number; values: number[][] } - | { type: "run:completion"; runId: string; fidelity: number; iterations: number; status: string } - | { type: "run:activate"; runId: string } - | { type: "run:timing"; runId: string; elapsed: number } - | { type: "run:label"; runId: string; label: string } + | { kind: "run:iteration"; runId: string; iter: number; objective: number; inf_pr: number; inf_du: number } + | { kind: "run:pulse-meta"; runId: string; drives: number; knots: number; labels: string[]; bounds: [number, number][]; interp?: string } + | { kind: "run:pulse"; runId: string; iter: number; dt: number; values: number[][] } + | { kind: "run:completion"; runId: string; fidelity: number; iterations: number; status: string } + | { kind: "run:activate"; runId: string } + | { kind: "run:timing"; runId: string; elapsed: number } + | { kind: "run:label"; runId: string; label: string } export type DeviceBridgeMessage = - | { type: "device:status"; device: string; status: unknown } - | { type: "device:actions"; device: string; actions: unknown[] } - | { type: "device:activate"; device: string } + | { kind: "device:status"; device: string; status: unknown } + | { kind: "device:actions"; device: string; actions: unknown[] } + | { kind: "device:activate"; device: string } export type InspectorMessage = RunBridgeMessage | DeviceBridgeMessage @@ -38,7 +38,7 @@ export function createInspectorBridge() { const onMessage = (e: MessageEvent) => { const d = e.data as InspectorMessage & { source?: string } if (!d || d.source !== "amicode") return - switch (d.type) { + switch (d.kind) { case "run:iteration": { setRuns((m) => { const n = new Map(m) diff --git a/packages/app/src/amicode/inspector/inspector-context.tsx b/packages/app/src/amicode/inspector/inspector-context.tsx new file mode 100644 index 000000000..b50570e0c --- /dev/null +++ b/packages/app/src/amicode/inspector/inspector-context.tsx @@ -0,0 +1,17 @@ +import { createContext, useContext, type ParentProps } from "solid-js" +import { createInspectorBridge } from "./inspector-bridge" + +type InspectorBridge = ReturnType + +const InspectorContext = createContext() + +export function InspectorProvider(props: ParentProps) { + const bridge = createInspectorBridge() + return {props.children} +} + +export function useInspectorBridge(): InspectorBridge { + const ctx = useContext(InspectorContext) + if (!ctx) throw new Error("useInspectorBridge must be used within an InspectorProvider") + return ctx +} diff --git a/packages/app/src/amicode/inspector/run-inspector.tsx b/packages/app/src/amicode/inspector/run-inspector.tsx index 030567179..ea0f42a60 100644 --- a/packages/app/src/amicode/inspector/run-inspector.tsx +++ b/packages/app/src/amicode/inspector/run-inspector.tsx @@ -3,12 +3,12 @@ import type { createInspectorBridge } from "./inspector-bridge" type Props = { bridge: ReturnType } -// Minimal pulse sparkline — renders correctly at 320px column width and responds +// Minimal pulse sparkline — renders correctly at narrow column widths and responds // to resize via viewBox (no fixed pixel width). Each drive is a polyline. -function PulseChart(props: { values: number[][]; bounds?: [number, number][] }) { +function PulseChart(props: { values?: number[][]; bounds?: [number, number][] }) { const paths = createMemo(() => { const v = props.values - if (v.length === 0 || v[0].length === 0) return [] + if (!v || v.length === 0 || v[0].length === 0) return [] return v.map((drive, idx) => { const b = props.bounds?.[idx] const lo = b?.[0] ?? Math.min(...drive) @@ -23,7 +23,10 @@ function PulseChart(props: { values: number[][]; bounds?: [number, number][] }) }) }) return ( - + + + + {(p) => } ) @@ -41,63 +44,76 @@ export function RunInspector(props: Props) { const latestPulse = createMemo(() => active()?.state.pulses.at(-1)) return ( -
-
-
Run Inspector
- 1}> +
+ {/* Run selector — only shown when multiple runs exist */} + 1}> +
- -
+
+
-
- {active()!.state.label ?? active()!.id} -
+ {/* Pulse metadata */} {(meta) => (
- {meta().drives} drive(s) · {meta().knots} knots · {meta().labels.join(", ")} + {meta().drives} control channel{meta().drives > 1 ? "s" : ""} · {meta().knots} timesteps + 0}> + · {meta().labels.join(", ")} +
)}
- {(p) => } - -
Waiting for pulse data…
-
-
-
-
iter
-
{latestIter()?.iter ?? "—"}
-
-
-
objective
-
{latestIter() ? latestIter()!.objective.toExponential(2) : "—"}
-
-
-
inf
-
{latestIter() ? `${latestIter()!.inf_pr.toExponential(1)}/${latestIter()!.inf_du.toExponential(1)}` : "—"}
-
+ + {/* Pulse chart — always visible once a run exists */} + + + {/* Metrics table — horizontally scrollable at narrow widths */} +
+ + + + + + + + + + + + + + + + + +
IterObjectivePrimalDual
{latestIter()?.iter ?? "—"}{latestIter() ? latestIter()!.objective.toExponential(2) : "—"}{latestIter() ? latestIter()!.inf_pr.toExponential(1) : "—"}{latestIter() ? latestIter()!.inf_du.toExponential(1) : "—"}
+ + {/* Completion card */} {(c) => (
-
completion
-
- {c().status} · F={c().fidelity.toFixed(5)} · {c().iterations} iters +
Result
+
F = {c().fidelity.toFixed(5)}
+
+ {c().status} · {c().iterations} iterations · {active()!.state.timing!.toFixed(1)}s
)} + + {/* Running indicator */} -
running · iter {latestIter()!.iter}
+
solving · iteration {latestIter()!.iter}
}> diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index b9e18399f..5311b57cc 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -94,6 +94,7 @@ import { TerminalPanelV2 } from "@/pages/session/terminal-panel-v2" import { useComposerCommands } from "@/pages/session/use-composer-commands" import { useSessionCommands } from "@/pages/session/use-session-commands" import { useAmicodeCommands } from "@/pages/session/use-amicode-commands" +import { InspectorProvider } from "@/amicode/inspector/inspector-context" import { useSessionHashScroll } from "@/pages/session/use-session-hash-scroll" import { Identifier } from "@/utils/id" import { Persist, persisted } from "@/utils/persist" @@ -320,7 +321,9 @@ function SessionProviders(props: ParentProps) { - {props.children} + + {props.children} + diff --git a/packages/app/src/pages/session/helpers.ts b/packages/app/src/pages/session/helpers.ts index 73cb4c9a2..adcfc86d7 100644 --- a/packages/app/src/pages/session/helpers.ts +++ b/packages/app/src/pages/session/helpers.ts @@ -44,6 +44,7 @@ export const createSessionTabs = (input: TabsInput) => { fileBrowser() && (input.tabs().active() === SESSION_OPEN_FILE_TAB || input.tabs().all().includes(SESSION_OPEN_FILE_TAB)), ) + const pulseInspectorOpen = createMemo(() => input.tabs().active() === "pulseInspector" || input.tabs().all().includes("pulseInspector")) const panelTabs = createMemo( () => { const seen = new Set() @@ -51,7 +52,7 @@ export const createSessionTabs = (input: TabsInput) => { .tabs() .all() .flatMap((tab) => { - if (tab === "context" || tab === "review" || tab === "vault" || tab === SESSION_PREVIEW_TAB) return [] + if (tab === "context" || tab === "review" || tab === "vault" || tab === SESSION_PREVIEW_TAB || tab === "pulseInspector") return [] if (tab === SESSION_OPEN_FILE_TAB && !fileBrowser()) return [] const value = input.pathFromTab(tab) ? input.normalizeTab(tab) : tab if (seen.has(value)) return [] @@ -68,6 +69,7 @@ export const createSessionTabs = (input: TabsInput) => { const activeTab = createMemo(() => { const active = input.tabs().active() if (active === "context") return active + if (active === "pulseInspector") return active if (active === SESSION_PREVIEW_TAB && previewOpen()) return active if (active === "vault" && vaultOpen()) return active if (active === SESSION_OPEN_FILE_TAB && openFileOpen()) return active @@ -79,6 +81,7 @@ export const createSessionTabs = (input: TabsInput) => { if (vaultOpen()) return "vault" if (previewOpen()) return SESSION_PREVIEW_TAB if (contextOpen()) return "context" + if (pulseInspectorOpen()) return "pulseInspector" if (review() && hasReview()) return "review" return "empty" }) @@ -98,6 +101,7 @@ export const createSessionTabs = (input: TabsInput) => { return { contextOpen, previewOpen, + pulseInspectorOpen, openFileOpen, panelTabs, openedTabs, diff --git a/packages/app/src/pages/session/session-side-panel.tsx b/packages/app/src/pages/session/session-side-panel.tsx index fefa143e1..49c814026 100644 --- a/packages/app/src/pages/session/session-side-panel.tsx +++ b/packages/app/src/pages/session/session-side-panel.tsx @@ -1,4 +1,4 @@ -import { For, Match, Show, Switch, createEffect, createMemo, on, onCleanup, type JSX } from "solid-js" +import { For, Match, Show, Switch, createEffect, createMemo, createSignal, on, onCleanup, type JSX } from "solid-js" import { createStore } from "solid-js/store" import { createMediaQuery } from "@solid-primitives/media" import { DragDropProvider as DndKitProvider, PointerSensor } from "@dnd-kit/solid" @@ -30,6 +30,8 @@ import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd" import FileTree from "@/components/file-tree" import { normalizeFileTreeV2Path } from "@/components/file-tree-v2-model" import { SessionContextUsage } from "@/components/session-context-usage" +import { RunInspector } from "@/amicode/inspector/run-inspector" +import { useInspectorBridge } from "@/amicode/inspector/inspector-context" const reviewTabID = "session-side-panel-review-tab" const reviewTabPanelID = "session-side-panel-review-tabpanel" @@ -58,6 +60,63 @@ import { useSessionLayout } from "@/pages/session/session-layout" import { WORK_COLUMN_WIDTH_MIN } from "@/pages/session/session-panel-width" import { SessionFileBrowserTab, type SessionFileBrowserState } from "@/pages/session/v2/session-file-browser-tab" +type PulseInspectorStage = "optimization" | "calibration" | "compilation" + +function PulseInspectorContent() { + const bridge = useInspectorBridge() + const [stage, setStage] = createSignal("optimization") + + const hasActiveRun = createMemo(() => { + const r = bridge.runs() + if (r.size === 0) return false + const activeId = bridge.activeRunId() + const state = activeId ? r.get(activeId) : r.values().next().value + return state ? !state.completion : false + }) + + return ( +
+ {/* Stage segmented control */} +
+ + + +
+ + {/* Stage content */} + +
+ +
+
+
+ ) +} + type ReviewDiff = FileDiffInfo | SnapshotFileDiff | VcsFileDiff type RenderDiff = FileDiffInfo | (SnapshotFileDiff & { file: string }) | VcsFileDiff const FILE_TREE_WIDTH_MIN = 240 @@ -254,7 +313,8 @@ export function SessionSidePanel(props: { return false }) - // Panel menu items + // Panel menu items — Files Changed lives as the "review" trigger, Context and + // Preview are the secondary tabs, and Pulse Inspector is the third requested tab. const panelMenuItems = createMemo((): PanelMenuItem[] => [ { id: "context", @@ -263,6 +323,13 @@ export function SessionSidePanel(props: { available: () => true, active: contextOpen, }, + { + id: "pulseInspector", + label: "Pulse Inspector", + icon: "pulse", + available: () => true, + active: () => activeTab() === "pulseInspector", + }, { id: SESSION_PREVIEW_TAB, label: "Preview", @@ -447,6 +514,32 @@ export function SessionSidePanel(props: {
+ + tabs().close("pulseInspector")} + aria-label={language.t("common.closeTab")} + /> + + } + hideCloseButton + onMiddleClick={() => tabs().close("pulseInspector")} + > +
+ +
Pulse Inspector
+
+
+ + + + + +
@@ -645,6 +744,38 @@ export function SessionSidePanel(props: {
+ + {language.t("common.closeTab")} + 0}> + + + + } + placement="bottom" + gutter={10} + > + tabs().close("pulseInspector")} + aria-label={language.t("common.closeTab")} + /> + + } + hideCloseButton + onMiddleClick={() => tabs().close("pulseInspector")} + > +
+ +
Pulse Inspector
+
+
+ + + + + +
diff --git a/packages/app/src/pages/session/use-amicode-commands.tsx b/packages/app/src/pages/session/use-amicode-commands.tsx index dfc683153..610be391b 100644 --- a/packages/app/src/pages/session/use-amicode-commands.tsx +++ b/packages/app/src/pages/session/use-amicode-commands.tsx @@ -67,7 +67,7 @@ export function useAmicodeCommands() { }), amico({ id: "amicode.openInspector", - title: "Open run inspector", + title: "Open pulse inspector", onSelect: () => postAmicode("amicode.openInspector"), }), ] diff --git a/packages/ui/src/amicode/amicode.css b/packages/ui/src/amicode/amicode.css index 5a5506d98..9d0eccb19 100644 --- a/packages/ui/src/amicode/amicode.css +++ b/packages/ui/src/amicode/amicode.css @@ -785,7 +785,7 @@ cursor: default; } /* …except a not-recorded chip that IS a button (the pulse chip, which opens the - Run Inspector before a pulse is banked): keep the dotted not-yet look, but it + Pulse Inspector before a pulse is banked): keep the dotted not-yet look, but it must READ clickable at rest — hover feedback alone failed the glance test (Kate 2026-07-28: "doesn't appear clickable"). Three signals: full-strength ink at weight 600 (faint ink is how a chip says "inert", and this one isn't; diff --git a/packages/ui/src/amicode/entity-rail.tsx b/packages/ui/src/amicode/entity-rail.tsx index 36b991ae2..fc6e61c8e 100644 --- a/packages/ui/src/amicode/entity-rail.tsx +++ b/packages/ui/src/amicode/entity-rail.tsx @@ -205,7 +205,7 @@ export function AmicodeEntityRail(props: { if (snapshot.kind !== "ready") return [] return mergeChips(snapshot.view.entities, snapshot.view.scoreStages) }) - // Which chips hand off to the Run Inspector instead of the entity dialog. + // Which chips hand off to the Pulse Inspector instead of the entity dialog. // Only the pulse chip, and only when the host actually wired an inspector — // standalone opencode has none, so there the chip keeps its dialog behavior. // The pulse chip is the ONLY inspector entry on the rail — the separate @@ -252,7 +252,7 @@ export function AmicodeEntityRail(props: { {(chip) => ( @@ -344,7 +344,7 @@ export function AmicodeEntityRail(props: { "font-weight": "600", cursor: "pointer", }} - title="Open the Run Inspector panel" + title="Open the Pulse Inspector panel" onClick={() => props.onInspectRun?.()} > Inspect Run diff --git a/packages/ui/src/components/icon.tsx b/packages/ui/src/components/icon.tsx index 8ba76fca1..a8b7b61a0 100644 --- a/packages/ui/src/components/icon.tsx +++ b/packages/ui/src/components/icon.tsx @@ -5,6 +5,7 @@ const icons = { // aesthetic (20×20, currentColor). target → System, activity → Pulse in the rail. target: ``, activity: ``, + pulse: ``, "align-right": ``, "arrow-up": ``, "arrow-left": ``,