From 7e00a60edd9b9de7afdeb7cee3686680352a13b1 Mon Sep 17 00:00:00 2001 From: Rodrigo Date: Tue, 14 Jul 2026 14:37:50 -0700 Subject: [PATCH 1/9] Dock the panel beside the page instead of floating over it The inspector now opens as a full-height side panel that shrinks the host page to make room, instead of a floating box that covers content. - Add page-layout module that sets a width custom property on the page root and restores the original attribute and inline style on unmount. - Simplify Resizable to a single width axis with a keyboard-accessible drag handle, clamped so the page keeps a minimum usable width. - Fall back to a full-screen panel on viewports too narrow to dock. - Move the summary metrics from the footer into the panel header and replace the footer with a floating collapse button that sits exactly where the launcher appears when collapsed. - Respect safe-area insets for the launcher and panel content. Co-Authored-By: Claude Fable 5 --- README.md | 4 +- src/components/panel.tsx | 155 ++++++++++++++++++++++++++--------- src/components/resizable.tsx | 97 ++++++++++++---------- src/mount.tsx | 40 +++++++-- src/page-layout.ts | 96 ++++++++++++++++++++++ src/page.css | 5 ++ src/styles.css | 25 ++++-- 7 files changed, 326 insertions(+), 96 deletions(-) create mode 100644 src/page-layout.ts create mode 100644 src/page.css diff --git a/README.md b/README.md index 946fe48..4090cee 100644 --- a/README.md +++ b/README.md @@ -2,11 +2,13 @@ An open-source, in-app inspector for [eve](https://github.com/vercel/eve) agents. It turns an agent's event stream into a live visual trace of conversations, reasoning, tool calls, token usage, and timing. -The inspector runs as a floating panel inside your app, making it easier to understand agent behavior without switching to a separate dashboard. +The inspector runs in a side panel that makes room for itself beside your app, making it easier to understand agent behavior without switching to a separate dashboard. ## Features - Inspect turns, reasoning, tool inputs and outputs, errors, and usage as they happen. +- Resize the side panel without covering your app. +- Use the full-screen panel on narrow mobile viewports. - Drop it into any browser app with a small framework-agnostic API or the React provider. - Keep application styles isolated with a shadow root. - Debug locally without sending data anywhere. The package makes no network requests. diff --git a/src/components/panel.tsx b/src/components/panel.tsx index 275bbb0..bda8a93 100644 --- a/src/components/panel.tsx +++ b/src/components/panel.tsx @@ -2,8 +2,10 @@ import { ArrowDownToLine, ArrowUpToLine, LucideProvider, + PanelRightClose, Wrench, } from "lucide-preact"; +import { useEffect, useState } from "preact/hooks"; import { Logo } from "@/components/logo"; import { Metric } from "@/components/metric"; import { Resizable } from "@/components/resizable"; @@ -16,21 +18,50 @@ import { summarizeTurns } from "@/trace/summary"; import type { Trace } from "@/trace/trace"; import type { Turn as TurnData } from "@/trace/types"; -const defaultSize = { width: 380, height: 480 }; -const minSize = { width: 280, height: 132 }; +const defaultWidth = 380; +const minPanelWidth = 280; +const minPageWidth = 320; +const minDockedWidth = minPanelWidth + minPageWidth; -function turnsLabel(count: number): string { - if (count === 1) { - return "1 turn"; +function canDock(viewportWidth: number): boolean { + return viewportWidth >= minDockedWidth; +} + +function maximumPanelWidth(viewportWidth: number): number { + return Math.max(minPanelWidth, viewportWidth - minPageWidth); +} + +function panelWidth(storedWidth: number, viewportWidth: number): number { + if (!canDock(viewportWidth)) { + return viewportWidth; } - return `${count} turns`; + const maximumWidth = maximumPanelWidth(viewportWidth); + return Math.min(maximumWidth, Math.max(minPanelWidth, storedWidth)); +} + +function useViewportWidth(): number { + const [width, setWidth] = useState(window.innerWidth); + + useEffect(() => { + function updateWidth() { + setWidth(window.innerWidth); + } + + window.addEventListener("resize", updateWidth); + return () => window.removeEventListener("resize", updateWidth); + }, []); + + return width; } -function Footer({ turns }: { turns: TurnData[] }) { +function Header({ turns }: { turns: TurnData[] }) { const summary = summarizeTurns(turns); return ( -
- {turnsLabel(turns.length)} +
+ + + eve-devtools +
}>{summary.tools} }> @@ -40,51 +71,97 @@ function Footer({ turns }: { turns: TurnData[] }) { {formatTokens(summary.outputTokens)}
-
+ + ); +} + +function CollapseButton({ onCollapse }: { onCollapse: () => void }) { + return ( + ); } function Body({ turns }: { turns: TurnData[] }) { return ( -
- - {turns.length === 0 && ( -

- No agent activity yet. -

- )} - {turns.map((turn, index) => ( - - ))} -
-
-
+ + {turns.length === 0 && ( +

+ No agent activity yet. +

+ )} + {turns.map((turn, index) => ( + + ))} +
); } -export function Panel({ trace }: { trace: Trace }) { +export function Panel({ + trace, + onResize, +}: { + trace: Trace; + onResize: (width: number | null) => void; +}) { const turns = useTurns(trace); const [isOpen, setIsOpen] = useLocalStorage("open", false); - const [size, setSize] = useLocalStorage("size", defaultSize); + const [storedWidth, setStoredWidth] = useLocalStorage("width", defaultWidth); + const viewportWidth = useViewportWidth(); + const isDocked = canDock(viewportWidth); + const width = panelWidth(storedWidth, viewportWidth); + + useEffect(() => { + if (isOpen && isDocked) { + onResize(width); + return; + } + onResize(null); + }, [isDocked, isOpen, onResize, width]); + + if (!isOpen) { + return ( + + + + ); + } return ( -
setIsOpen(event.currentTarget.open)} - class="group fixed right-3 bottom-3 z-2147483647 size-8.5 overflow-hidden rounded-xl border border-line-2 bg-background text-foreground shadow-panel transition-all duration-300 ease-in-out [corner-shape:squircle] [interpolate-size:allow-keywords] open:size-auto open:rounded-2xl open:bg-surface-1 open:shadow-panel-open" +
+
); } diff --git a/src/components/resizable.tsx b/src/components/resizable.tsx index 3f4e5d5..f32c271 100644 --- a/src/components/resizable.tsx +++ b/src/components/resizable.tsx @@ -2,45 +2,28 @@ import { cn } from "cnfast"; import type { ComponentChildren } from "preact"; import { useRef } from "preact/hooks"; -export type Size = { width: number; height: number }; +type DragStart = { x: number; width: number }; -type Edge = "left" | "top" | "corner"; - -type DragStart = { x: number; y: number; size: Size }; +function clampWidth(width: number, minWidth: number, maxWidth: number): number { + return Math.min(maxWidth, Math.max(minWidth, width)); +} -function resizeFrom( +function resizedWidth( start: DragStart, - edge: Edge, event: PointerEvent, - minSize: Size, -): Size { + minWidth: number, + maxWidth: number, +): number { const dx = start.x - event.clientX; - const dy = start.y - event.clientY; - const size = { ...start.size }; - if (edge !== "top") { - size.width = Math.max(minSize.width, start.size.width + dx); - } - if (edge !== "left") { - size.height = Math.max(minSize.height, start.size.height + dy); - } - return size; + return clampWidth(start.width + dx, minWidth, maxWidth); } -const handleClass = "absolute hidden touch-none group-open:block"; - -const handles: { edge: Edge; class: string }[] = [ - { edge: "left", class: "inset-y-3 -left-0.75 w-1.5 cursor-ew-resize" }, - { edge: "top", class: "inset-x-3 -top-0.75 h-1.5 cursor-ns-resize" }, - { edge: "corner", class: "-top-0.75 -left-0.75 size-3 cursor-nwse-resize" }, -]; - -// The wrapper div is intentionally not positioned, so the absolute handles -// anchor to the panel edges (the nearest positioned ancestor) while the -// width and height styles apply to the content area only. export function Resizable(props: { - size: Size; - minSize: Size; - onResize: (size: Size) => void; + width: number; + minWidth: number; + maxWidth: number; + canResize: boolean; + onResize: (width: number) => void; children: ComponentChildren; }) { const dragStart = useRef(null); @@ -51,39 +34,67 @@ export function Resizable(props: { handle.setPointerCapture(event.pointerId); dragStart.current = { x: event.clientX, - y: event.clientY, - size: props.size, + width: props.width, }; } - function onPointerMove(event: PointerEvent, edge: Edge) { + function onPointerMove(event: PointerEvent) { if (dragStart.current === null) { return; } - props.onResize(resizeFrom(dragStart.current, edge, event, props.minSize)); + props.onResize( + resizedWidth(dragStart.current, event, props.minWidth, props.maxWidth), + ); } function onPointerUp() { dragStart.current = null; } + function onKeyDown(event: KeyboardEvent) { + const step = event.shiftKey ? 32 : 8; + let nextWidth = props.width; + + if (event.key === "ArrowLeft") { + nextWidth += step; + } else if (event.key === "ArrowRight") { + nextWidth -= step; + } else { + return; + } + + event.preventDefault(); + props.onResize(clampWidth(nextWidth, props.minWidth, props.maxWidth)); + } + return (
{props.children} - {handles.map((handle) => ( -
onPointerMove(event, handle.edge)} + onPointerMove={onPointerMove} onPointerUp={onPointerUp} + onPointerCancel={onPointerUp} /> - ))} + )}
); } diff --git a/src/mount.tsx b/src/mount.tsx index 98344e5..4c310ac 100644 --- a/src/mount.tsx +++ b/src/mount.tsx @@ -1,15 +1,39 @@ import { render } from "preact"; import { Panel } from "@/components/panel"; +import { createPageLayout } from "@/page-layout"; import styles from "@/styles.css?inline"; import { createTrace } from "@/trace/trace"; +function isDocument(root: Element | Document): root is Document { + return root.nodeType === Node.DOCUMENT_NODE; +} + function parentFor(root: Element | Document): Element { - if (root instanceof Document) { + if (isDocument(root)) { return root.body; } return root; } +function pageFor(root: Element | Document): HTMLElement { + if (isDocument(root)) { + return root.documentElement; + } + + const HTMLElement = root.ownerDocument.defaultView?.HTMLElement; + if (HTMLElement !== undefined && root instanceof HTMLElement) { + return root as HTMLElement; + } + return root.ownerDocument.documentElement; +} + +function documentFor(root: Element | Document): Document { + if (isDocument(root)) { + return root; + } + return root.ownerDocument; +} + function applyStyles(shadow: ShadowRoot): CSSStyleSheet { const sheet = new CSSStyleSheet(); sheet.replaceSync(styles); @@ -21,7 +45,10 @@ function applyStyles(shadow: ShadowRoot): CSSStyleSheet { // ignored inside a shadow root: they only register from document-level // stylesheets. They define no styles, so the host page is unaffected. // Returns a function that releases them again. -function adoptPropertyRules(sheet: CSSStyleSheet): () => void { +function adoptPropertyRules( + sheet: CSSStyleSheet, + document: Document, +): () => void { const properties = new CSSStyleSheet(); for (const rule of sheet.cssRules) { if (rule instanceof CSSPropertyRule) { @@ -44,13 +71,15 @@ export type EveDevtools = { export function mount(root: Element | Document = document): EveDevtools { const trace = createTrace(); - const host = document.createElement("div"); + const pageLayout = createPageLayout(pageFor(root)); + const ownerDocument = documentFor(root); + const host = ownerDocument.createElement("div"); parentFor(root).appendChild(host); const shadow = host.attachShadow({ mode: "open" }); const sheet = applyStyles(shadow); - const releaseProperties = adoptPropertyRules(sheet); - render(, shadow); + const releaseProperties = adoptPropertyRules(sheet, ownerDocument); + render(, shadow); return { onEvent: trace.push, @@ -58,6 +87,7 @@ export function mount(root: Element | Document = document): EveDevtools { render(null, shadow); host.remove(); releaseProperties(); + pageLayout.restore(); }, }; } diff --git a/src/page-layout.ts b/src/page-layout.ts new file mode 100644 index 0000000..34f043c --- /dev/null +++ b/src/page-layout.ts @@ -0,0 +1,96 @@ +import styles from "@/page.css?inline"; + +const pageAttribute = "data-eve-devtools-page"; +const widthProperty = "--eve-devtools-panel-width"; + +type AttributeSnapshot = { + isPresent: boolean; + value: string; +}; + +type PropertySnapshot = { + value: string; + priority: string; +}; + +function readAttribute(element: Element, name: string): AttributeSnapshot { + return { + isPresent: element.hasAttribute(name), + value: element.getAttribute(name) ?? "", + }; +} + +function restoreAttribute( + element: Element, + name: string, + snapshot: AttributeSnapshot, +) { + if (snapshot.isPresent) { + element.setAttribute(name, snapshot.value); + return; + } + element.removeAttribute(name); +} + +function readProperty( + style: CSSStyleDeclaration, + name: string, +): PropertySnapshot { + return { + value: style.getPropertyValue(name), + priority: style.getPropertyPriority(name), + }; +} + +function restoreProperty( + style: CSSStyleDeclaration, + name: string, + snapshot: PropertySnapshot, +) { + if (snapshot.value !== "") { + style.setProperty(name, snapshot.value, snapshot.priority); + return; + } + style.removeProperty(name); +} + +function adoptStyles(document: Document): () => void { + const sheet = new CSSStyleSheet(); + sheet.replaceSync(styles); + document.adoptedStyleSheets = [...document.adoptedStyleSheets, sheet]; + + return () => { + document.adoptedStyleSheets = document.adoptedStyleSheets.filter( + (adopted) => adopted !== sheet, + ); + }; +} + +export type PageLayout = { + resize: (width: number | null) => void; + restore: () => void; +}; + +export function createPageLayout(page: HTMLElement): PageLayout { + const attribute = readAttribute(page, pageAttribute); + const property = readProperty(page.style, widthProperty); + const releaseStyles = adoptStyles(page.ownerDocument); + + function resize(width: number | null) { + if (width === null) { + restoreAttribute(page, pageAttribute, attribute); + restoreProperty(page.style, widthProperty, property); + return; + } + + page.setAttribute(pageAttribute, ""); + page.style.setProperty(widthProperty, `${width}px`); + } + + function restore() { + resize(null); + releaseStyles(); + } + + return { resize, restore }; +} diff --git a/src/page.css b/src/page.css new file mode 100644 index 0000000..bc5e60a --- /dev/null +++ b/src/page.css @@ -0,0 +1,5 @@ +/* Changing the root width keeps the host DOM untouched. Wrapping or + transforming it would change how fixed and floating elements behave. */ +[data-eve-devtools-page] { + width: calc(100% - var(--eve-devtools-panel-width)); +} diff --git a/src/styles.css b/src/styles.css index 2f5450b..060382c 100644 --- a/src/styles.css +++ b/src/styles.css @@ -14,12 +14,16 @@ --color-line-2: #404040; --color-line-3: #525252; --font-mono: ui-monospace, SFMono-Regular, monospace; - --shadow-panel: 0 2px 12px #00000059; - --shadow-panel-open: 0 4px 12px #0000004d, 0 16px 40px #00000066; + --shadow-launcher: 0 2px 12px #00000059; } :host { all: initial; + position: fixed; + top: 0; + left: 0; + width: 0; + height: 0; color-scheme: dark; font-family: ui-sans-serif, system-ui, sans-serif; font-size: 13px; @@ -28,7 +32,18 @@ -moz-osx-font-smoothing: grayscale; } +.launcher { + right: max(0.75rem, env(safe-area-inset-right)); + bottom: max(0.75rem, env(safe-area-inset-bottom)); +} + +.safe-area { + padding: env(safe-area-inset-top) env(safe-area-inset-right) + env(safe-area-inset-bottom) env(safe-area-inset-left); +} + .scroll { + scrollbar-gutter: stable; scrollbar-width: thin; scrollbar-color: var(--color-line-2) transparent; } @@ -42,9 +57,3 @@ background-color: var(--color-line-2); border-radius: 3px; } - -/* Animate only the panel's open/close; nested details (tool calls) toggle - instantly instead of lingering for the transition. */ -:host > details::details-content { - transition: content-visibility 300ms allow-discrete; -} From 4a8f3de365cb506eb060b65fd768c64a257b480a Mon Sep 17 00:00:00 2001 From: Rodrigo Date: Tue, 14 Jul 2026 21:33:34 -0700 Subject: [PATCH 2/9] Polish panel UI --- README.md | 15 +++- bun.lock | 5 ++ demo/app/index.ts | 56 ++++++++++++- demo/app/styles.css | 31 +++++++ package.json | 4 +- src/components/disclosure-chevron.tsx | 10 +++ src/components/entry.tsx | 6 +- src/components/json-view.tsx | 2 +- src/components/metric.tsx | 29 ++++++- src/components/panel.tsx | 66 +++++---------- src/components/reasoning.tsx | 107 +++++++++++++++++++------ src/components/resizable.tsx | 6 +- src/components/scroll-area.tsx | 6 +- src/components/summary-metrics.tsx | 20 +++++ src/components/tool-call.tsx | 23 +++--- src/components/turn.tsx | 111 +++++++++++++++++--------- src/page-layout.test.ts | 106 ++++++++++++++++++++++++ src/page.css | 3 +- src/panel-size.test.ts | 22 +++++ src/panel-size.ts | 21 +++++ src/styles.css | 25 ++++++ src/trace/events.ts | 32 ++++++++ src/trace/format.test.ts | 28 +++++++ src/trace/format.ts | 19 +++-- src/trace/projector.test.ts | 73 +++++++++++++++++ src/trace/projector.ts | 65 ++++++++++++++- src/trace/summary.ts | 17 ++-- src/trace/types.ts | 11 +++ tsconfig.json | 2 +- vite.config.ts | 8 +- 30 files changed, 772 insertions(+), 157 deletions(-) create mode 100644 src/components/disclosure-chevron.tsx create mode 100644 src/components/summary-metrics.tsx create mode 100644 src/page-layout.test.ts create mode 100644 src/panel-size.test.ts create mode 100644 src/panel-size.ts create mode 100644 src/trace/format.test.ts create mode 100644 src/trace/projector.test.ts diff --git a/README.md b/README.md index 4090cee..c6edd71 100644 --- a/README.md +++ b/README.md @@ -7,12 +7,24 @@ The inspector runs in a side panel that makes room for itself beside your app, m ## Features - Inspect turns, reasoning, tool inputs and outputs, errors, and usage as they happen. -- Resize the side panel without covering your app. +- Resize the side panel while the normal page layout makes room for it. - Use the full-screen panel on narrow mobile viewports. - Drop it into any browser app with a small framework-agnostic API or the React provider. - Keep application styles isolated with a shadow root. - Debug locally without sending data anywhere. The package makes no network requests. +### Fixed host elements + +The panel resizes normal and sticky page layouts without changing their positioning contexts. Browser-fixed elements remain anchored to the viewport by design. If your app has a control fixed to the right edge, offset it with the panel width exposed by the devtools: + +```css +.fixed-control { + right: var(--eve-devtools-panel-width, 0px); +} +``` + +The property returns to its previous value when the panel is collapsed or the devtools are unmounted. + ## Install ```sh @@ -69,6 +81,7 @@ Issues and pull requests are welcome. To run the project locally: bun install bun run dev # demo page with a minimal eve agent traced live bun run check # lint, format, and types +bun run test # behavior tests ``` The demo lives in `demo/` and calls its model through the Vercel AI Gateway. Set `AI_GATEWAY_API_KEY` in `demo/.env.local` before starting it. diff --git a/bun.lock b/bun.lock index b994a23..be95a13 100644 --- a/bun.lock +++ b/bun.lock @@ -7,6 +7,7 @@ "devDependencies": { "@biomejs/biome": "^2.5.2", "@tailwindcss/vite": "^4.3.2", + "@types/bun": "^1.3.14", "@types/react": "^19.2.17", "cnfast": "^0.0.8", "lucide-preact": "^1.23.0", @@ -148,6 +149,8 @@ "@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="], + "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], + "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], "@types/node": ["@types/node@26.1.0", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-O0A1G3xPGy4w7AgQdAQYUlQ+BKk2Oovw8eRpofyp5KdBZULnbe+WqaOVNrm705SHphCiG4XHsACrSmPu1f+Kgw=="], @@ -168,6 +171,8 @@ "ai": ["ai@7.0.19", "", { "dependencies": { "@ai-sdk/gateway": "4.0.15", "@ai-sdk/provider": "4.0.3", "@ai-sdk/provider-utils": "5.0.7" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-7kxMNiy6JqCvruCY2qGMNzeqEt9Ey3XqrtYGHWyipDJTXHFN7gOyO1UR2UIXhw112vAUkrO+OJJzemKJD8P3bA=="], + "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], + "cnfast": ["cnfast@0.0.8", "", { "bin": { "cnfast": "bin/cli.js" } }, "sha512-EjXKMfGfdwtV4AcNSQ6AwQaVzpC1B7IxeiwA3FlhTXz+YFlMKVi4c1JX9tgD2QOlahQXjB8KUXrBaYG+3v871Q=="], "compare-versions": ["compare-versions@6.1.1", "", {}, "sha512-4hm4VPpIecmlg59CHXnRDnqGplJFrbLG4aFEl5vl6cK1u76ws3LLvX7ikFnTDl5vo39sjWD6AaDPYodJp/NNHg=="], diff --git a/demo/app/index.ts b/demo/app/index.ts index 9a018c2..9b96e1c 100644 --- a/demo/app/index.ts +++ b/demo/app/index.ts @@ -1,4 +1,9 @@ -import { Client } from "eve/client"; +import { + Client, + type InputOption, + type InputRequest, + type SendTurnInput, +} from "eve/client"; import { mount } from "../../index"; const devtools = mount(); @@ -20,12 +25,54 @@ const addMessage = (role: "user" | "assistant", text: string) => { return message; }; -const send = async (text: string) => { +const createOptionButton = (request: InputRequest, option: InputOption) => { + const button = document.createElement("button"); + button.type = "button"; + button.textContent = option.label; + button.addEventListener("click", () => { + const siblings = button.parentElement?.querySelectorAll("button") ?? []; + for (const sibling of siblings) { + sibling.disabled = true; + } + const response = { requestId: request.requestId, optionId: option.id }; + devtools.onEvent({ + type: "client.input.responded", + data: { responses: [response] }, + }); + void send(option.label, { + inputResponses: [response], + }); + }); + return button; +}; + +const renderInputRequests = ( + reply: HTMLParagraphElement, + requests: readonly InputRequest[], +) => { + reply.replaceChildren(); + reply.classList.add("question"); + for (const request of requests) { + const prompt = document.createElement("span"); + prompt.textContent = request.prompt; + reply.append(prompt); + if (request.options !== undefined && request.options.length > 0) { + const options = document.createElement("span"); + options.className = "options"; + options.append( + ...request.options.map((option) => createOptionButton(request, option)), + ); + reply.append(options); + } + } +}; + +const send = async (text: string, turnInput: SendTurnInput = text) => { input.disabled = true; addMessage("user", text); const reply = addMessage("assistant", "…"); try { - const response = await session.send(text); + const response = await session.send(turnInput); for await (const event of response) { devtools.onEvent(event); if (event.type === "message.appended") { @@ -34,6 +81,9 @@ const send = async (text: string) => { if (event.type === "message.completed" && event.data.message !== null) { reply.textContent = event.data.message; } + if (event.type === "input.requested") { + renderInputRequests(reply, event.data.requests); + } messages.scrollTop = messages.scrollHeight; } } finally { diff --git a/demo/app/styles.css b/demo/app/styles.css index c88d7a8..a336af5 100644 --- a/demo/app/styles.css +++ b/demo/app/styles.css @@ -50,6 +50,37 @@ main p { align-self: flex-start; } +.question { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.options { + display: flex; + flex-wrap: wrap; + gap: 0.375rem; +} + +.options button { + padding: 0.25rem 0.625rem; + border: 1px solid #404040; + border-radius: 1rem; + background: #232323; + color: inherit; + font: inherit; + cursor: pointer; +} + +.options button:hover { + background: #2c2c2c; +} + +.options button:disabled { + opacity: 0.5; + cursor: default; +} + form { width: 100%; max-width: 640px; diff --git a/package.json b/package.json index 2cff99f..0894ef0 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,8 @@ "build": "vite build", "prepack": "bun run build", "check": "biome check . && tsc --noEmit", - "fix": "biome check --write ." + "fix": "biome check --write .", + "test": "bun test" }, "peerDependencies": { "react": ">=17" @@ -47,6 +48,7 @@ "devDependencies": { "@biomejs/biome": "^2.5.2", "@tailwindcss/vite": "^4.3.2", + "@types/bun": "^1.3.14", "@types/react": "^19.2.17", "cnfast": "^0.0.8", "lucide-preact": "^1.23.0", diff --git a/src/components/disclosure-chevron.tsx b/src/components/disclosure-chevron.tsx new file mode 100644 index 0000000..cc21508 --- /dev/null +++ b/src/components/disclosure-chevron.tsx @@ -0,0 +1,10 @@ +import { cn } from "cnfast"; +import { ChevronLeft } from "lucide-preact"; + +export function DisclosureChevron({ className }: { className?: string }) { + return ( + + ); +} diff --git a/src/components/entry.tsx b/src/components/entry.tsx index 36f6d3b..597488c 100644 --- a/src/components/entry.tsx +++ b/src/components/entry.tsx @@ -9,8 +9,10 @@ export function Entry({ children: ComponentChildren; }) { return ( -
- {icon} +
+ + {icon} +
{children}
); diff --git a/src/components/json-view.tsx b/src/components/json-view.tsx index 575ac58..510536a 100644 --- a/src/components/json-view.tsx +++ b/src/components/json-view.tsx @@ -2,7 +2,7 @@ import { stringifyJson } from "@/trace/format"; export function JsonView({ value }: { value: unknown }) { return ( -
+    
       {stringifyJson(value)}
     
); diff --git a/src/components/metric.tsx b/src/components/metric.tsx index 5a4f2c2..125d1b0 100644 --- a/src/components/metric.tsx +++ b/src/components/metric.tsx @@ -1,15 +1,42 @@ +import { cn } from "cnfast"; import type { ComponentChildren } from "preact"; +type MetricTone = "neutral" | "input" | "output"; + +const toneClass: Record = { + neutral: "text-neutral-300", + input: "text-metric-input", + output: "text-metric-output", +}; + +const gapClass: Record = { + neutral: "gap-[3px]", + input: "gap-0.5", + output: "gap-0.5", +}; + // An icon paired with a compact value, e.g. a token count or a duration. export function Metric({ icon, + label, + tone = "neutral", children, }: { icon: ComponentChildren; + label: string; + tone?: MetricTone; children: ComponentChildren; }) { return ( - + svg]:size-3.5", + gapClass[tone], + toneClass[tone], + )} + > + {label}: {icon} {children} diff --git a/src/components/panel.tsx b/src/components/panel.tsx index bda8a93..c742865 100644 --- a/src/components/panel.tsx +++ b/src/components/panel.tsx @@ -1,44 +1,21 @@ -import { - ArrowDownToLine, - ArrowUpToLine, - LucideProvider, - PanelRightClose, - Wrench, -} from "lucide-preact"; +import { LucideProvider, PanelRightClose } from "lucide-preact"; import { useEffect, useState } from "preact/hooks"; import { Logo } from "@/components/logo"; -import { Metric } from "@/components/metric"; import { Resizable } from "@/components/resizable"; import { ScrollArea } from "@/components/scroll-area"; import { Turn } from "@/components/turn"; import { useLocalStorage } from "@/hooks/use-local-storage"; import { useTurns } from "@/hooks/use-turns"; -import { formatTokens } from "@/trace/format"; -import { summarizeTurns } from "@/trace/summary"; +import { + canDock, + defaultPanelWidth, + maximumPanelWidth, + minimumPanelWidth, + panelWidth, +} from "@/panel-size"; import type { Trace } from "@/trace/trace"; import type { Turn as TurnData } from "@/trace/types"; -const defaultWidth = 380; -const minPanelWidth = 280; -const minPageWidth = 320; -const minDockedWidth = minPanelWidth + minPageWidth; - -function canDock(viewportWidth: number): boolean { - return viewportWidth >= minDockedWidth; -} - -function maximumPanelWidth(viewportWidth: number): number { - return Math.max(minPanelWidth, viewportWidth - minPageWidth); -} - -function panelWidth(storedWidth: number, viewportWidth: number): number { - if (!canDock(viewportWidth)) { - return viewportWidth; - } - const maximumWidth = maximumPanelWidth(viewportWidth); - return Math.min(maximumWidth, Math.max(minPanelWidth, storedWidth)); -} - function useViewportWidth(): number { const [width, setWidth] = useState(window.innerWidth); @@ -54,23 +31,13 @@ function useViewportWidth(): number { return width; } -function Header({ turns }: { turns: TurnData[] }) { - const summary = summarizeTurns(turns); +function Header() { return (
eve-devtools -
- }>{summary.tools} - }> - {formatTokens(summary.inputTokens)} - - }> - {formatTokens(summary.outputTokens)} - -
); } @@ -112,7 +79,10 @@ export function Panel({ }) { const turns = useTurns(trace); const [isOpen, setIsOpen] = useLocalStorage("open", false); - const [storedWidth, setStoredWidth] = useLocalStorage("width", defaultWidth); + const [storedWidth, setStoredWidth] = useLocalStorage( + "width", + defaultPanelWidth, + ); const viewportWidth = useViewportWidth(); const isDocked = canDock(viewportWidth); const width = panelWidth(storedWidth, viewportWidth); @@ -144,18 +114,18 @@ export function Panel({