fix: simplify console mode controls with a compact footer toggle
This commit is contained in:
@@ -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>
|
||||
|
||||
+18
-3
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user