fix: simplify console mode controls with a compact footer toggle

This commit is contained in:
2026-10-05 01:58:22 +09:00
parent 685b8aa65f
commit fec85dd38d
5 changed files with 168 additions and 114 deletions
@@ -85,14 +85,14 @@ Deno.test("Console pending scopes are independent and match Tasks row typography
};
});
assertEquals(statusGeometry.typography, statusGeometry.taskTypography);
assertEquals(statusGeometry.height, 16);
assertEquals(statusGeometry.height, 24);
assertEquals(statusGeometry.belowComposer, true);
assertEquals(statusGeometry.alignedWithTasks, true);
assertEquals(statusGeometry.visible, true);
assertEquals(statusGeometry.wrap, "nowrap");
assertEquals(statusGeometry.pageOverflow, false);
const details = metadata.getByRole("button", { name: "Details", exact: true });
assertEquals(await page.locator(".console-header").getByRole("button", { name: "Details" }).count(), 0);
assertEquals(await page.locator(".console-header").count(), 0);
const detailsGeometry = await details.evaluate((button) => {
const row = button.closest(".worker-context-status")!;
const rect = button.getBoundingClientRect();
@@ -105,7 +105,49 @@ Deno.test("Console pending scopes are independent and match Tasks row typography
});
assertEquals(detailsGeometry.rightAligned, true);
assertEquals(detailsGeometry.centered, true);
assert(detailsGeometry.width > 0);
assertEquals(detailsGeometry.width, 24);
const overview = metadata.getByRole("switch", { name: "Overview", exact: true });
const controls = await overview.evaluate((group) => {
const row = group.closest(".worker-context-status")!;
const rowRect = row.getBoundingClientRect();
const buttons = [...row.querySelectorAll("button")];
return {
buttonsInsideRow: buttons.every((button) => {
const rect = button.getBoundingClientRect();
return rect.left >= rowRect.left && rect.right <= rowRect.right &&
rect.top >= rowRect.top && rect.bottom <= rowRect.bottom;
}),
heights: buttons.map((button) => button.getBoundingClientRect().height),
modeFont: getComputedStyle(buttons[0]).fontFamily,
divider: getComputedStyle(buttons[1]).borderLeftWidth,
beforeDetails: group.nextElementSibling?.getAttribute("aria-label") === "Details",
};
});
assertEquals(controls.buttonsInsideRow, true);
assertEquals(controls.heights, [24, 24]);
assertEquals(controls.modeFont.includes("mono"), false);
assertEquals(controls.divider, "0px");
assertEquals(controls.beforeDetails, true);
const onSize = (await overview.boundingBox())!;
assert(onSize.width < 90, "Switch and fixed label should use less than 90px");
assertEquals(await overview.getAttribute("aria-checked"), "true");
await overview.focus();
assertEquals(await overview.evaluate((button) => getComputedStyle(button).outlineStyle), "solid");
await page.keyboard.press("Enter");
assertEquals(await overview.getAttribute("aria-checked"), "false");
assertEquals((await overview.textContent())!.trim(), "Overview");
assertEquals((await overview.boundingBox())!.width, onSize.width);
if (mode === "both") {
await Deno.mkdir(join(root, "target/web-ux/console-footer-switch"), { recursive: true });
await page.screenshot({ path: join(root, `target/web-ux/console-footer-switch/${colorScheme}-${width}-off.png`) });
}
await page.keyboard.press("Space");
assertEquals(await overview.getAttribute("aria-checked"), "true");
assertEquals((await overview.boundingBox())!.width, onSize.width);
await overview.locator("span").last().click();
assertEquals(await overview.getAttribute("aria-checked"), "false");
await overview.locator(".console-view-toggle-track").click();
assertEquals(await overview.getAttribute("aria-checked"), "true");
await details.focus();
await page.keyboard.press("Enter");
assertEquals(await details.getAttribute("aria-expanded"), "true");
@@ -117,8 +159,8 @@ Deno.test("Console pending scopes are independent and match Tasks row typography
await page.getByRole("button", { name: "Close", exact: true }).click();
assertEquals(await details.getAttribute("aria-expanded"), "false");
if (mode === "both") {
await Deno.mkdir(join(root, "target/web-ux/composer-metadata"), { recursive: true });
await page.screenshot({ path: join(root, `target/web-ux/composer-metadata/${colorScheme}-${width}.png`) });
await Deno.mkdir(join(root, "target/web-ux/console-footer-switch"), { recursive: true });
await page.screenshot({ path: join(root, `target/web-ux/console-footer-switch/${colorScheme}-${width}.png`) });
}
const queue = mode === "both" || mode === "queue";
const notifications = ["both", "notifications", "legacy"].includes(mode);
@@ -1,4 +1,5 @@
<script lang="ts">
import type { Snippet } from "svelte";
import type { ConsoleWorkerMetadata } from "./model";
import {
formatContextSummary,
@@ -7,33 +8,39 @@
type Props = {
metadata: ConsoleWorkerMetadata | null;
controls?: Snippet;
detailsOpen?: boolean;
onToggleDetails?: () => void;
};
let { metadata, detailsOpen = false, onToggleDetails }: Props = $props();
let { metadata, controls, detailsOpen = false, onToggleDetails }: Props = $props();
const modelSummary = $derived(formatModelSummary(metadata));
const contextSummary = $derived(formatContextSummary(metadata));
</script>
<div class="worker-context-status" aria-label="Worker model and context">
<span class="worker-model" title={modelSummary}>{modelSummary}</span>
<span class="separator" aria-hidden="true">|</span>
<span class="separator" aria-hidden="true">·</span>
<span class="worker-context" title={contextSummary}>{contextSummary}</span>
{#if onToggleDetails}
<button
type="button"
class="details-button"
aria-label="Details"
aria-expanded={detailsOpen}
onclick={onToggleDetails}
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<circle cx="5" cy="12" r="2" />
<circle cx="12" cy="12" r="2" />
<circle cx="19" cy="12" r="2" />
</svg>
</button>
{#if controls || onToggleDetails}
<div class="worker-context-actions">
{@render controls?.()}
{#if onToggleDetails}
<button
type="button"
class="details-button"
aria-label="Details"
aria-expanded={detailsOpen}
onclick={onToggleDetails}
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<circle cx="5" cy="12" r="2" />
<circle cx="12" cy="12" r="2" />
<circle cx="19" cy="12" r="2" />
</svg>
</button>
{/if}
</div>
{/if}
</div>
@@ -72,30 +79,39 @@
color: var(--line-strong);
}
.worker-context-actions {
display: flex;
flex: 0 0 auto;
align-items: center;
gap: var(--space-2);
margin-left: auto;
}
.details-button {
display: inline-flex;
flex: 0 0 auto;
align-items: center;
justify-content: center;
width: 20px;
height: var(--line-height-compact);
margin-left: auto;
width: var(--space-5);
height: var(--space-5);
padding: 0;
border: 0;
border-radius: var(--space-1);
background: transparent;
color: var(--text-muted);
cursor: pointer;
}
.details-button:hover,
.details-button[aria-expanded="true"],
.details-button:focus-visible {
color: var(--text);
.details-button[aria-expanded="true"] {
background: var(--bg-subtle);
color: var(--text-strong);
}
.details-button:focus-visible {
color: var(--text-strong);
outline: 1px solid currentColor;
outline-offset: -1px;
outline-offset: -2px;
}
.details-button svg {
@@ -756,6 +756,12 @@ Deno.test("Model information stays below Composer in one compact monospace row",
"line-height: var(--line-height-compact)", "padding-inline: var(--space-3)",
]) assert(component.includes(rule), rule);
assert(!component.includes("@media"), "Narrow layouts must retain the same single row");
const modes = page.indexOf('class="console-view-toggle"');
assert(modes > status && modes < page.indexOf("</WorkerContextStatus>", status), "Display toggle must be in the model information row");
assert(!page.includes("console-header"), "Remove the empty header and its styles");
assert(component.indexOf("{@render controls?.()}") < component.indexOf('class="details-button"'), "Display controls must precede Details");
assert(/\.worker-context-actions\s*\{[^}]*flex: 0 0 auto/.test(component), "Keep display controls from shrinking on narrow screens");
assert(component.includes("margin-left: auto"), "Align footer controls to the right");
});
Deno.test("Console spacing and text metrics use existing design tokens", async () => {
@@ -1571,14 +1577,10 @@ Deno.test("Composer owns TUI-style completion and replaces Compact/Rewind header
const input = await Deno.readTextFile(
new URL("./ComposerInput.svelte", import.meta.url),
);
const header = page.slice(
page.indexOf('<section class="console-header'),
page.indexOf("{#if rewindTargets.length"),
);
assert(
!header.includes("Compact") && !header.includes("Rewind") &&
!page.includes('class="console-header') &&
!page.includes("requestRewindTargets"),
"Header must defer Compact/Rewind to commands",
"Compact/Rewind must remain commands without a dedicated header",
);
assert(
input.includes('role="listbox"') && input.includes('role="option"') &&
@@ -1608,12 +1610,8 @@ Deno.test("mini task summary owns pane toggling instead of the header", async ()
const component = await Deno.readTextFile(
new URL("./ConsoleTasks.svelte", import.meta.url),
);
const header = page.slice(
page.indexOf('<section class="console-header'),
page.indexOf("{#if rewindTargets.length"),
);
assert(
!header.includes("taskPaneOpen = !taskPaneOpen") &&
!page.includes('class="console-header') &&
page.includes("onTogglePane={() => {"),
"Only the mini summary should own Tasks toggling",
);
@@ -1663,33 +1663,6 @@
</svelte:head>
<div class="console-shell worker-console-shell">
<section class="console-header card" aria-label="Worker controls">
<div class="console-header-actions">
<div
class="console-view-modes"
role="group"
aria-label="Console display mode"
>
<button
type="button"
class:active={consoleViewMode === "overview"}
aria-pressed={consoleViewMode === "overview"}
onclick={() => (consoleViewMode = "overview")}
>
Overview
</button>
<button
type="button"
class:active={consoleViewMode === "normal"}
aria-pressed={consoleViewMode === "normal"}
onclick={() => (consoleViewMode = "normal")}
>
Normal
</button>
</div>
</div>
</section>
{#if rewindTargets.length > 0}
<section class="card rewind-targets" aria-label="Rewind targets">
<h3>Rewind targets</h3>
@@ -2101,7 +2074,21 @@
workerDetailsOpen = !workerDetailsOpen;
if (workerDetailsOpen) taskPaneOpen = false;
}}
/>
>
{#snippet controls()}
<button
type="button"
class="console-view-toggle"
role="switch"
aria-checked={consoleViewMode === "overview"}
title="On: Overview · Off: Normal"
onclick={() => (consoleViewMode = consoleViewMode === "overview" ? "normal" : "overview")}
>
<span class="console-view-toggle-track" aria-hidden="true"></span>
<span>Overview</span>
</button>
{/snippet}
</WorkerContextStatus>
</div>
<style>
@@ -2127,14 +2114,6 @@
overscroll-behavior: contain;
}
.console-header {
display: flex;
min-width: 0;
align-items: center;
justify-content: flex-end;
gap: var(--space-4);
}
.console-card {
display: grid;
align-content: start;
@@ -2142,46 +2121,59 @@
min-height: 100%;
}
.console-header-actions {
display: flex;
.console-view-toggle {
display: inline-flex;
flex: 0 0 auto;
align-items: center;
justify-content: flex-end;
flex-wrap: wrap;
gap: var(--space-2);
}
.console-view-modes {
display: inline-flex;
overflow: hidden;
border: 1px solid var(--line);
border-radius: 0.55rem;
background: var(--bg-raised);
}
.console-view-modes button {
gap: var(--space-1);
min-height: var(--space-5);
padding: 0;
border: 0;
border-radius: var(--space-1);
background: transparent;
color: var(--text-muted);
padding: var(--space-1) var(--space-2);
font: inherit;
font-family: var(--font-sans);
font-size: var(--font-size-compact);
line-height: var(--line-height-compact);
font-weight: 700;
font-weight: 400;
cursor: pointer;
}
.console-view-modes button + button {
border-left: 1px solid var(--line);
}
.console-view-modes button:hover {
.console-view-toggle:hover {
color: var(--text-strong);
}
.console-view-modes button.active {
background: var(--accent);
color: var(--bg);
.console-view-toggle:focus-visible {
outline: 1px solid var(--text-strong);
outline-offset: -1px;
}
.console-view-toggle-track {
position: relative;
flex: 0 0 auto;
width: 24px;
height: 14px;
border-radius: 999px;
background: var(--line-strong);
}
.console-view-toggle-track::before {
content: "";
position: absolute;
top: 2px;
left: 2px;
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--bg);
}
.console-view-toggle[aria-checked="true"] .console-view-toggle-track {
background: var(--accent-muted);
}
.console-view-toggle[aria-checked="true"] .console-view-toggle-track::before {
transform: translateX(10px);
}
.rewind-targets {
@@ -2680,14 +2672,5 @@
grid-template-columns: minmax(0, 1fr);
grid-template-rows: minmax(0, 1fr) minmax(0, 1fr);
}
.console-header {
align-items: stretch;
flex-direction: column;
}
.console-header-actions {
align-self: flex-end;
}
}
</style>
@@ -261,13 +261,28 @@ test.each(["live_protocol", "retained_snapshot"] as const)("overlapping history
await screen.findByText("Start of conversation");
expect(view.container.querySelectorAll(".activity-summary")).toHaveLength(1);
expect(screen.getAllByText("searched 1 time・ran 1 command")).toHaveLength(1);
await fireEvent.click(screen.getByRole("button", { name: "Normal" }));
const footer = screen.getByLabelText("Worker model and context");
const overview = screen.getByRole("switch", { name: "Overview" });
expect(screen.queryByRole("button", { name: "Normal" })).toBeNull();
const details = screen.getByRole("button", { name: "Details" });
expect(view.container.querySelector(".console-header")).toBeNull();
expect(footer.contains(overview)).toBe(true);
expect(overview.nextElementSibling).toBe(details);
expect(overview.closest("form")).toBeNull();
expect(view.container.querySelector(".console-composer")?.nextElementSibling).toBe(footer);
expect(overview.getAttribute("aria-checked")).toBe("true");
await fireEvent.click(overview);
expect(overview.getAttribute("aria-checked")).toBe("false");
expect(overview.textContent?.trim()).toBe("Overview");
const transcript = screen.getByRole("article", { name: "main transcript" });
const ids = [...transcript.querySelectorAll("[data-console-line-id]")].map((line) => line.getAttribute("data-console-line-id"));
expect(ids).toHaveLength(3);
expect(new Set(ids).size).toBe(ids.length);
await fireEvent.click(screen.getByRole("button", { name: "Overview" }));
await fireEvent.click(overview);
expect(overview.getAttribute("aria-checked")).toBe("true");
expect(overview.textContent?.trim()).toBe("Overview");
expect(view.container.querySelectorAll(".activity-summary")).toHaveLength(1);
expect(multiplexer.sendWorkerMethod).not.toHaveBeenCalled();
});
test.each([
@@ -473,7 +488,7 @@ test("mini task summary opens and closes details without a header Tasks button",
latestListener().onFrame(subscribedFrame(session));
const summary = await screen.findByRole("button", { name: /1 task — pending/ });
expect(screen.queryByRole("complementary", { name: "Worker tasks" })).toBeNull();
expect(view.container.querySelector(".console-header")?.textContent).not.toContain("Tasks");
expect(view.container.querySelector(".console-header")).toBeNull();
await fireEvent.click(screen.getByRole("button", { name: "Details" }));
const details = await screen.findByRole("complementary", { name: "Worker detail" });
expect(details.textContent).not.toContain("Capabilities");