feat(web): pin Logging/History/Network as a resizable column (#1616)

Add the ability to "pin" the Logging, History, and Network screens into a
second column on the right of the InspectorView, so a primary screen and a
monitor screen are visible at once (e.g. watch Logs while calling a Tool).

- A "pin as column" button on the Logs/History/Network panel toolbars opens
  the column (shown only when wide + connected).
- The column hosts a MonitoringControls tab row (available monitor tabs +
  close) over the selected embedded screen. Pinning removes the monitor group
  from the header tab bar; closing (x) returns it.
- The divider between the primary area and the column is draggable (pointer +
  keyboard); width is clamped and persisted.
- The split collapses to one column below ~1040px (the ServerListScreen
  single-card breakpoint) and while disconnected, without discarding the pin
  preference — it reopens when wide + connected again.

State (pinned/tab/width) is view-local and persisted via the existing
useLocalStorage pattern, so no new props thread through App and ViewHeader is
unchanged (it just receives a filtered tab list). Screens/panels gain an
`embedded` mode (fills the parent height, drops the 340px filter sidebar) via a
shared EmbeddableScrollArea element.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
cliffhall 2026-07-08 18:52:41 -04:00
commit 081fd789f1
6 changed files with 1728 additions and 241 deletions

View file

@ -111,4 +111,19 @@ describe("HistoryScreen", () => {
expect.objectContaining({ methodFilter: undefined }),
);
});
it("renders a pin-as-column button when onPin is provided and invokes it", async () => {
const user = userEvent.setup();
const onPin = vi.fn();
renderWithMantine(<HistoryScreen {...baseProps} onPin={onPin} />);
await user.click(screen.getByRole("button", { name: "Pin as column" }));
expect(onPin).toHaveBeenCalledTimes(1);
});
it("drops the filter sidebar when embedded, keeping the request list", () => {
renderWithMantine(<HistoryScreen {...baseProps} embedded />);
expect(screen.getByText("Requests")).toBeInTheDocument();
// The sidebar (HistoryControls, with its Search box) is not rendered.
expect(screen.queryByPlaceholderText("Search...")).toBeNull();
});
});

View file

@ -25,6 +25,10 @@ export interface HistoryScreenProps {
onSortChange: (next: SortDirection) => void;
compact: boolean;
onToggleCompact: () => void;
/** See LoggingScreen: shows a "pin as column" button when set (#1616). */
onPin?: () => void;
/** See LoggingScreen: fills the parent height and drops the filter sidebar. */
embedded?: boolean;
}
// Search text, method filter, and per-direction visibility — controlled by the
@ -69,6 +73,8 @@ export function HistoryScreen({
onSortChange,
compact,
onToggleCompact,
onPin,
embedded = false,
}: HistoryScreenProps) {
const { search, methodFilter, visibleDirections } = ui;
@ -101,23 +107,25 @@ export function HistoryScreen({
}, [ui, visibleDirections, onUiChange]);
return (
<ScreenLayout>
<Sidebar>
<SidebarCard>
<HistoryControls
searchText={search}
methodFilter={methodFilter}
availableMethods={availableMethods}
visibleDirections={visibleDirections}
onSearchChange={(value) => onUiChange({ ...ui, search: value })}
onMethodFilterChange={(value) =>
onUiChange({ ...ui, methodFilter: value })
}
onToggleDirection={handleToggleDirection}
onToggleAllDirections={handleToggleAllDirections}
/>
</SidebarCard>
</Sidebar>
<ScreenLayout h={embedded ? "100%" : undefined}>
{embedded ? null : (
<Sidebar>
<SidebarCard>
<HistoryControls
searchText={search}
methodFilter={methodFilter}
availableMethods={availableMethods}
visibleDirections={visibleDirections}
onSearchChange={(value) => onUiChange({ ...ui, search: value })}
onMethodFilterChange={(value) =>
onUiChange({ ...ui, methodFilter: value })
}
onToggleDirection={handleToggleDirection}
onToggleAllDirections={handleToggleAllDirections}
/>
</SidebarCard>
</Sidebar>
)}
<HistoryListPanel
entries={entries}
pinnedIds={pinnedIds}
@ -134,6 +142,8 @@ export function HistoryScreen({
onSortChange={onSortChange}
compact={compact}
onToggleCompact={onToggleCompact}
onPin={onPin}
embedded={embedded}
/>
</ScreenLayout>
);

View file

@ -131,4 +131,19 @@ describe("LoggingScreen", () => {
await user.click(screen.getByRole("button", { name: "Set" }));
expect(onSetLevel).toHaveBeenCalledWith("info");
});
it("renders a pin-as-column button when onPin is provided and invokes it", async () => {
const user = userEvent.setup();
const onPin = vi.fn();
renderWithMantine(<LoggingScreen {...baseProps} onPin={onPin} />);
await user.click(screen.getByRole("button", { name: "Pin as column" }));
expect(onPin).toHaveBeenCalledTimes(1);
});
it("drops the filter sidebar when embedded, keeping the stream", () => {
renderWithMantine(<LoggingScreen {...baseProps} embedded />);
expect(screen.getByText("Log Stream")).toBeInTheDocument();
// The sidebar (LogControls, with its Set button) is not rendered.
expect(screen.queryByRole("button", { name: "Set" })).toBeNull();
});
});

View file

@ -16,6 +16,17 @@ export interface LoggingScreenProps {
onExport: () => void;
sortDirection: SortDirection;
onSortChange: (next: SortDirection) => void;
/**
* When set, the stream panel shows a "pin as column" button (#1616). Omitted
* when already embedded, or when pinning isn't currently available.
*/
onPin?: () => void;
/**
* True when rendered inside the monitoring column: the screen fills its
* parent's height (instead of the viewport calc) and drops the filter
* sidebar so the narrow column is stream-only.
*/
embedded?: boolean;
}
// Filter text + visible-level set — controlled by the parent (App) as one
@ -52,6 +63,8 @@ export function LoggingScreen({
onExport,
sortDirection,
onSortChange,
onPin,
embedded = false,
}: LoggingScreenProps) {
const { filterText, visibleLevels } = ui;
@ -71,20 +84,24 @@ export function LoggingScreen({
}
return (
<ScreenLayout>
<Sidebar>
<SidebarCard>
<LogControls
currentLevel={currentLevel}
filterText={filterText}
visibleLevels={visibleLevels}
onSetLevel={onSetLevel}
onFilterChange={(value) => onUiChange({ ...ui, filterText: value })}
onToggleLevel={handleToggleLevel}
onToggleAllLevels={handleToggleAllLevels}
/>
</SidebarCard>
</Sidebar>
<ScreenLayout h={embedded ? "100%" : undefined}>
{embedded ? null : (
<Sidebar>
<SidebarCard>
<LogControls
currentLevel={currentLevel}
filterText={filterText}
visibleLevels={visibleLevels}
onSetLevel={onSetLevel}
onFilterChange={(value) =>
onUiChange({ ...ui, filterText: value })
}
onToggleLevel={handleToggleLevel}
onToggleAllLevels={handleToggleAllLevels}
/>
</SidebarCard>
</Sidebar>
)}
<LogStreamPanel
entries={entries}
filterText={filterText}
@ -93,6 +110,8 @@ export function LoggingScreen({
onExport={onExport}
sortDirection={sortDirection}
onSortChange={onSortChange}
onPin={onPin}
embedded={embedded}
/>
</ScreenLayout>
);

View file

@ -163,4 +163,20 @@ describe("NetworkScreen", () => {
expect(screen.getByRole("button", { name: "Clear" })).toBeDisabled();
expect(screen.getByRole("button", { name: "Export" })).toBeDisabled();
});
it("renders a pin-as-column button when onPin is provided and invokes it", async () => {
const user = userEvent.setup();
const onPin = vi.fn();
renderWithMantine(<NetworkScreen {...baseProps} onPin={onPin} />);
await user.click(screen.getByRole("button", { name: "Pin as column" }));
expect(onPin).toHaveBeenCalledTimes(1);
});
it("drops the filter sidebar when embedded, keeping the request list", () => {
renderWithMantine(<NetworkScreen {...baseProps} embedded />);
// The sidebar (NetworkControls, with its Search box) is not rendered.
expect(screen.queryByPlaceholderText("Search...")).toBeNull();
// The stream toolbar (Export) is still present.
expect(screen.getByRole("button", { name: "Export" })).toBeInTheDocument();
});
});

View file

@ -21,6 +21,10 @@ export interface NetworkScreenProps {
onSortChange: (next: SortDirection) => void;
compact: boolean;
onToggleCompact: () => void;
/** See LoggingScreen: shows a "pin as column" button when set (#1616). */
onPin?: () => void;
/** See LoggingScreen: fills the parent height and drops the filter sidebar. */
embedded?: boolean;
}
// Filter text + visible-category set — controlled by the parent (App) as one
@ -57,6 +61,8 @@ export function NetworkScreen({
onSortChange,
compact,
onToggleCompact,
onPin,
embedded = false,
}: NetworkScreenProps) {
const { filterText, visibleCategories } = ui;
@ -81,18 +87,22 @@ export function NetworkScreen({
}
return (
<ScreenLayout>
<Sidebar>
<SidebarCard>
<NetworkControls
filterText={filterText}
visibleCategories={visibleCategories}
onFilterChange={(value) => onUiChange({ ...ui, filterText: value })}
onToggleCategory={handleToggleCategory}
onToggleAllCategories={handleToggleAllCategories}
/>
</SidebarCard>
</Sidebar>
<ScreenLayout h={embedded ? "100%" : undefined}>
{embedded ? null : (
<Sidebar>
<SidebarCard>
<NetworkControls
filterText={filterText}
visibleCategories={visibleCategories}
onFilterChange={(value) =>
onUiChange({ ...ui, filterText: value })
}
onToggleCategory={handleToggleCategory}
onToggleAllCategories={handleToggleAllCategories}
/>
</SidebarCard>
</Sidebar>
)}
<NetworkStreamPanel
entries={entries}
filterText={filterText}
@ -103,6 +113,8 @@ export function NetworkScreen({
onSortChange={onSortChange}
compact={compact}
onToggleCompact={onToggleCompact}
onPin={onPin}
embedded={embedded}
/>
</ScreenLayout>
);