Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions apps/web/src/domains/chat/inspector/components/call-rail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,16 @@ interface CallRailProps {
logs: LLMRequestLogEntry[];
selectedLogId: string | undefined;
buildCallHref: (logId: string) => string;
/**
* Fires when a row is tapped, *before* `Link` navigation kicks in.
*
* The desktop rail lives in a persistent `<aside>` so it never needs
* to know when a selection was made. The mobile bottom-sheet wrapper
* (`mobile-call-selector.tsx`) hooks this to close itself once the
* user picks a call — the URL update alone wouldn't reset the sheet's
* local `open` state.
*/
onSelect?: () => void;
}

/**
Expand All @@ -30,6 +40,7 @@ export function CallRail({
logs,
selectedLogId,
buildCallHref,
onSelect,
}: CallRailProps): ReactNode {
if (!logs.length) {
return (
Expand Down Expand Up @@ -60,6 +71,7 @@ export function CallRail({
isSelected={entry.id === selectedLogId}
isLatest={displayIndex === 0}
href={buildCallHref(entry.id)}
onSelect={onSelect}
/>
))}
</nav>
Expand All @@ -72,6 +84,7 @@ interface CallRowProps {
isSelected: boolean;
isLatest: boolean;
href: string;
onSelect?: () => void;
}

function CallRow({
Expand All @@ -80,6 +93,7 @@ function CallRow({
isSelected,
isLatest,
href,
onSelect,
}: CallRowProps): ReactNode {
const isSynthetic = isSyntheticAgentErrorMessage(entry);
const subtitle = buildCallSubtitle(entry) ?? "Unrecognized call";
Expand All @@ -98,6 +112,7 @@ function CallRow({
return (
<Link
to={href}
onClick={onSelect}
aria-current={isSelected ? "page" : undefined}
className="flex flex-col gap-1 rounded-md p-3 text-left no-underline transition-colors hover:opacity-90"
style={{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
/**
* Tests for the mobile-only call selector that replaces the desktop
* `<aside>` rail on narrow viewports.
*
* The selector wraps `CallRail` in a `BottomSheet`. In its closed state
* (the initial render) only the trigger button is in the markup; the
* sheet's content is portalled and only mounts when the user taps the
* trigger, so static-markup tests focus on the trigger's display
* contract.
*
* Strategy mirrors `call-rail.test.tsx`: `renderToStaticMarkup` with a
* `react-router` `Link` stub so the wrapped rail doesn't trip on a
* missing router context if/when it ends up in the tree.
*/

import { describe, expect, mock, test } from "bun:test";
import { renderToStaticMarkup } from "react-dom/server";
import type { ReactNode } from "react";

import type { LLMRequestLogEntry } from "@vellumai/assistant-api";

mock.module("react-router", () => ({
Link: ({
to,
children,
...rest
}: {
to: string;
children: ReactNode;
[key: string]: unknown;
}) => (
<a href={to} {...rest}>
{children}
</a>
),
}));

// Imported AFTER the mock so the component picks up the stub.
import { MobileCallSelector } from "./mobile-call-selector";

function makeEntry(
id: string,
createdAt: number,
overrides: Partial<LLMRequestLogEntry> = {},
): LLMRequestLogEntry {
return {
id,
createdAt,
requestPayload: null,
responsePayload: null,
callSite: "mainAgent",
summary: {
provider: "anthropic",
model: "claude-sonnet-4",
},
...overrides,
};
}

function render(
logs: LLMRequestLogEntry[],
selectedLogId: string | undefined,
): string {
return renderToStaticMarkup(
<MobileCallSelector
logs={logs}
selectedLogId={selectedLogId}
buildCallHref={(id) => `/conv/test?callId=${id}`}
/>,
);
}

describe("MobileCallSelector — trigger label", () => {
test("renders 'Call N of M' when a known call id is selected", () => {
// `logs` is oldest-first, so the call at index 1 is "Call 2 of 3".
const html = render(
[makeEntry("a", 1), makeEntry("b", 2), makeEntry("c", 3)],
"b",
);
expect(html).toContain("Call 2 of 3");
});

test("renders the latest call's position when no id is provided", () => {
// No selection means the inspector page will fall back to the
// newest call; the selector mirrors that by reporting `Call N of N`
// only when its `selectedLogId` prop actually matches a log. With
// an unknown id we expect the total-only fallback so the user is
// never lied to about which call is selected.
const html = render([makeEntry("a", 1), makeEntry("b", 2)], undefined);
expect(html).toContain("2 LLM calls");
expect(html).not.toContain("Call 0");
});

test("falls back to the total-only label when the selected id is stale", () => {
// Defensive: a `?callId=` in the URL that no longer matches any log
// shouldn't surface as "Call -1 of N" or similar nonsense.
const html = render([makeEntry("a", 1)], "missing");
expect(html).toContain("1 LLM call");
expect(html).not.toContain("Call ");
});

test("renders an accessible name on the trigger button", () => {
// Screen-reader users need a label that explains the surface this
// button opens — the visible text alone ("Call 1 of 1") doesn't.
const html = render([makeEntry("a", 1)], "a");
expect(html).toContain('aria-label="Select an LLM call to inspect"');
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { ChevronDown } from "lucide-react";
import { useState, type ReactNode } from "react";

import { BottomSheet } from "@vellum/design-library";
import type { LLMRequestLogEntry } from "@vellumai/assistant-api";

import { CallRail } from "./call-rail";

interface MobileCallSelectorProps {
logs: LLMRequestLogEntry[];
selectedLogId: string | undefined;
buildCallHref: (logId: string) => string;
}

/**
* Mobile-only trigger + bottom sheet for selecting an LLM call.
*
* The desktop layout keeps the rail visible as a permanent `<aside>` next
* to the tab content. Mobile is too narrow to spare a column, so we
* surface a full-width pill at the top of the content area that shows
* the currently selected call and opens an overlay sheet listing every
* call in the conversation.
*
* The sheet closes itself after a row is tapped via `CallRail`'s
* `onSelect` callback — `?callId` URL changes alone don't reset the
* sheet's local `open` state.
*/
export function MobileCallSelector({
logs,
selectedLogId,
buildCallHref,
}: MobileCallSelectorProps): ReactNode {
const [open, setOpen] = useState(false);

const total = logs.length;
// Identify the selected call's chronological position so the trigger
// can show "Call N of M" — the same numbering the rail uses. `logs`
// is ordered oldest-first; `callNumber = index + 1`.
const selectedIndex = selectedLogId
? logs.findIndex((l) => l.id === selectedLogId)
: -1;
const callNumber = selectedIndex >= 0 ? selectedIndex + 1 : null;

const triggerLabel =
callNumber != null
? `Call ${callNumber} of ${total}`
: total === 1
? "1 LLM call"
: `${total} LLM calls`;

return (
<BottomSheet.Root open={open} onOpenChange={setOpen}>
<BottomSheet.Trigger asChild>
<button
type="button"
aria-label="Select an LLM call to inspect"
className="flex w-full shrink-0 items-center justify-between gap-2 px-4 py-2.5 text-left"
style={{
background: "var(--surface-base)",
borderBottom: "1px solid var(--border-base)",
color: "var(--content-default)",
}}
>
<span className="text-label-medium-default">{triggerLabel}</span>
<ChevronDown
size={16}
aria-hidden
style={{ color: "var(--content-secondary)" }}
/>
</button>
</BottomSheet.Trigger>
<BottomSheet.Content className="max-h-[80dvh]">
<BottomSheet.Header>
<BottomSheet.Title>Select a call</BottomSheet.Title>
</BottomSheet.Header>
{/* Reset the sheet body's horizontal padding so the rail's own
row padding (`p-3` on each `CallRow`) matches the desktop
aside instead of double-padding on the left/right. */}
<BottomSheet.Body className="-mx-4 px-0">
<CallRail
logs={logs}
selectedLogId={selectedLogId}
buildCallHref={buildCallHref}
onSelect={() => setOpen(false)}
/>
</BottomSheet.Body>
</BottomSheet.Content>
</BottomSheet.Root>
);
}
10 changes: 8 additions & 2 deletions apps/web/src/domains/chat/inspector/components/tab-bar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,17 +28,23 @@ interface TabBarProps {

export function TabBar({ selected, onSelect }: TabBarProps): ReactNode {
return (
// Seven tabs overflow most phone viewports, so the row scrolls
// horizontally instead of getting clipped at the right edge. Each
// button keeps its label on one line via `whitespace-nowrap`.
<div
className="flex shrink-0 px-4"
className="flex shrink-0 overflow-x-auto px-4"
style={{ borderBottom: "1px solid var(--border-base)" }}
role="tablist"
>
{TABS.map((tab) => (
<button
key={tab.id}
type="button"
role="tab"
aria-selected={selected === tab.id}
onClick={() => onSelect(tab.id)}
className={cn(
"-mb-px border-b-2 px-3 py-2 text-label-medium-default transition-colors",
"-mb-px shrink-0 whitespace-nowrap border-b-2 px-3 py-2 text-label-medium-default transition-colors",
selected === tab.id
? "border-[var(--primary-base)] text-[var(--content-default)]"
: "border-transparent text-[var(--content-secondary)] hover:text-[var(--content-default)]",
Expand Down
6 changes: 6 additions & 0 deletions apps/web/src/domains/chat/inspector/inspect-page.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,12 @@ describe("InspectPage — data-loading branches", () => {
// Tab bar from the loaded path
expect(html).toContain("Overview");
expect(html).toContain("Prompt");
// The mobile call-selector trigger is mounted alongside the
// desktop aside (CSS hides whichever doesn't match the viewport).
// We rely on its trigger so phone users can pick a call when the
// aside is collapsed; the label shape is `Call N of M`.
expect(html).toContain("Call 2 of 2");
expect(html).toContain('aria-label="Select an LLM call to inspect"');
});

test("renders the error state when the context query errors out", () => {
Expand Down
39 changes: 34 additions & 5 deletions apps/web/src/domains/chat/inspector/inspect-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { useAuthStore } from "@/stores/auth-store";
import { routes } from "@/utils/routes";

import { CallRail } from "./components/call-rail";
import { MobileCallSelector } from "./components/mobile-call-selector";
import { TabBar, type InspectorTab } from "./components/tab-bar";
import { CompactionTab } from "./components/tabs/compaction-tab";
import { MemoryTab } from "./components/tabs/memory-tab";
Expand Down Expand Up @@ -219,9 +220,16 @@ function Header({
}
}

// Mobile-responsive layout:
// - Desktop (≥md): Back · Title block · Export+count on one row.
// - Mobile (<md): Back · Export collapse to row 1; Title block wraps
// to its own row via `order-3 w-full`. Avoids the title squeeze that
// forced "LLM Context Inspector" to wrap inside ~120px on phones.
// The redundant call-count label is hidden on mobile — the
// `MobileCallSelector` pill in the content area already shows it.
return (
<div className="flex flex-col gap-2 px-4 py-3">
<div className="flex items-center gap-3">
<div className="flex flex-wrap items-center gap-3">
<Button
asChild
variant="ghost"
Expand All @@ -235,9 +243,9 @@ function Header({
Back
</Link>
</Button>
<div className="flex flex-1 flex-col">
<div className="order-3 flex w-full min-w-0 flex-col md:order-2 md:w-auto md:flex-1">
<h1
className="text-title-medium"
className="truncate text-body-large-bold md:text-title-medium"
style={{ color: "var(--content-default)" }}
>
LLM Context Inspector
Expand All @@ -247,7 +255,7 @@ function Header({
messageId={messageId}
/>
</div>
<div className="flex items-center gap-3">
<div className="order-2 ml-auto flex items-center gap-3 md:order-3 md:ml-0">
<Button
variant="outlined"
size="compact"
Expand All @@ -258,7 +266,7 @@ function Header({
>
{isExporting ? "Exporting…" : "Export ZIP"}
</Button>
<div className="flex flex-col items-end gap-0.5">
<div className="hidden flex-col items-end gap-0.5 md:flex">
{callCount != null ? (
<span
className="text-label-default"
Expand All @@ -278,6 +286,17 @@ function Header({
) : null}
</div>
</div>
{/* Mobile fallback for export errors — the desktop slot above is
hidden on narrow viewports so the error needs its own row. */}
{exportError ? (
<span
className="order-4 w-full text-body-small-default md:hidden"
role="alert"
style={{ color: "var(--system-negative-strong)" }}
>
{exportError}
</span>
) : null}
</div>
<ScopeControls
assistantId={assistantId}
Expand Down Expand Up @@ -478,6 +497,16 @@ function Loaded({
/>
</aside>
<main className="flex min-h-0 min-w-0 flex-1 flex-col">
{/* Mobile counterpart to the desktop aside. The wrapper hides
both the trigger button and the BottomSheet portal mount on
≥md viewports — the aside above takes over there. */}
<div className="md:hidden">
<MobileCallSelector
logs={logs}
selectedLogId={selectedLogId}
buildCallHref={buildCallHref}
/>
</div>
<TabBar selected={tab} onSelect={setTab} />
<div className="min-h-0 min-w-0 flex-1 overflow-y-auto">
{selectedLog ? (
Expand Down