diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneNewTab.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneNewTab.swift index d83f1e530583..6d3a62742443 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneNewTab.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneNewTab.swift @@ -40,6 +40,8 @@ public nonisolated struct AgentPaneNewTab: Codable, Sendable, Equatable { /// The template (`tabs.newTabTemplate`: "cards", "default", "composer", "threads", "console", /// "classic" or "terminal"); nil follows `layout`. The page validates it. public var template: String? + /// The style switch's look (Debug Settings `newTab.styleSwitch`); nil is the page's default. + public var styleSwitch: String? /// The agent last picked. public var lastAgent: String? /// The harness and model picked last, anywhere and per folder; nil when none was. @@ -82,6 +84,7 @@ public nonisolated struct AgentPaneNewTab: Codable, Sendable, Equatable { self.projects = projects self.layout = layout self.template = template + self.styleSwitch = AgentPaneTunables.newTabStyleSwitch.override?.rawValue self.lastAgent = lastAgent self.lastPicks = lastPicks self.home = home diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneTunables+NewTabStyleSwitch.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneTunables+NewTabStyleSwitch.swift new file mode 100644 index 000000000000..899c0808c002 --- /dev/null +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneTunables+NewTabStyleSwitch.swift @@ -0,0 +1,27 @@ +public import CmuxNextDesign + +nonisolated extension AgentPaneTunables { + /// The New Tab page's style switch look. The page draws round for no override, so only an + /// override is sent (`AgentPaneNewTab.styleSwitch`). + public static let newTabStyleSwitch = Tunable.choice( + "newTab.styleSwitch", .tabs, "New tab style switch", + help: "How the New Tab page shows its styles. Applies to the next new tab.", + default: .round, code: "AgentPaneTunables.newTabStyleSwitch") +} + +/// The New Tab page's style switch looks (webviews newtab/templates.ts STYLE_SWITCHES). Round is +/// the page's own look (#20473); the others are opt-in in Debug Settings (Lawrence 2026-10-14: no +/// Labs; a look moves to Settings once it earns it). +public nonisolated enum AgentPaneNewTabStyleSwitch: String, Sendable, CaseIterable, TunableChoice { + case round, hint, dots, strip, names + + public var tunableTitle: String { + switch self { + case .round: "Round dots (#20473)" + case .hint: "Dots that name a pick" + case .dots: "Chip dots (before #20127)" + case .strip: "Layout sketches" + case .names: "Style names (#20336)" + } + } +} diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneTunables.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneTunables.swift index e88de6068713..c2d6e41dfa02 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneTunables.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneTunables.swift @@ -374,6 +374,7 @@ public nonisolated struct AgentPaneTunables { modelMenu.descriptor, pagesFollowLinkPlacement.descriptor, newTabPrivateChatLists.descriptor, + newTabStyleSwitch.descriptor, newTabWidgets.descriptor, pageHost.descriptor, reasoningSlider.descriptor, diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/NewTabStyleSwitchTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/NewTabStyleSwitchTests.swift index d1079001f5ef..bd6d1a71d6f5 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/NewTabStyleSwitchTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/NewTabStyleSwitchTests.swift @@ -1,5 +1,6 @@ @testable import CmuxNextAgentPane @testable import CmuxNextApp +import AppKit import CmuxNextActions import CmuxNextDesign import CmuxNextSettings @@ -55,4 +56,23 @@ struct NewTabStyleSwitchTests { for save in saves { await save.value } #expect(settings.snapshot.newTabTemplate == .threads) } + + /// Every look the New Tab style switch has is a Debug Settings choice (`newTab.styleSwitch`; no + /// Labs, Lawrence 2026-10-14) that reaches the next New Tab; with none set the page draws its + /// own look, round dots. + @Test func eachDebugStyleSwitchLookReachesTheNextNewTab() throws { + _ = NSApplication.shared + let services = AppServices(environment: AppEnvironment.current([:])) + let store = TunableStore.shared + store.activate(file: nil) + store.register(AgentPaneTunables.all) + defer { store.reset([AgentPaneTunables.newTabStyleSwitch.key]) } + + #expect(NewTabPage.sparePage(services).styleSwitch == nil) + for look in AgentPaneNewTabStyleSwitch.allCases { + #expect(store.set(AgentPaneTunables.newTabStyleSwitch.key, .choice(look.rawValue)) != nil) + #expect(NewTabPage.sparePage(services).styleSwitch == look.rawValue) + } + #expect(Set(AgentPaneNewTabStyleSwitch.allCases.map(\.rawValue)) == ["round", "hint", "dots", "strip", "names"]) + } } diff --git a/webviews/src/agent-session/acpmux/App.tsx b/webviews/src/agent-session/acpmux/App.tsx index ed196ec5d256..e80d402f22d9 100644 --- a/webviews/src/agent-session/acpmux/App.tsx +++ b/webviews/src/agent-session/acpmux/App.tsx @@ -6577,6 +6577,7 @@ function AcpmuxPane() { {!harnessesOpen && showNewTab && !newTab.history && ( // The page's style, under it; a pick shows in place at once. window.dispatchEvent(new Event(FOCUS_LOCATION_EVENT))} onPick={(style) => diff --git a/webviews/src/agent-session/acpmux/NewTabPage.tsx b/webviews/src/agent-session/acpmux/NewTabPage.tsx index c23f156c6b7a..872c9a6a6fe3 100644 --- a/webviews/src/agent-session/acpmux/NewTabPage.tsx +++ b/webviews/src/agent-session/acpmux/NewTabPage.tsx @@ -27,7 +27,7 @@ import { type AcpmuxSessionEntry, } from "./sessionList"; import { type StringKey, type Translate, translate, useT } from "./i18n"; -import { parseNewTabTemplate, type NewTabTemplate } from "./newtab/templates"; +import { parseNewTabTemplate, parseStyleSwitch, type NewTabTemplate, type StyleSwitchLook } from "./newtab/templates"; import { seedWarmNewTab } from "./newtab/warmSnapshot"; /// The three things a new tab can become (#16620). Order is the switch's order and Tab's cycle. @@ -89,6 +89,8 @@ export type NewTabHost = { layout: "a" | "b"; /// The saved template (`tabs.newTabTemplate`); unset follows `layout` (newtab/templates.ts). template?: NewTabTemplate; + /// The style switch's look (Debug Settings `newTab.styleSwitch`); unset is the default look. + styleSwitch?: StyleSwitchLook; /// The agent last picked (decision Q3). lastAgent?: string; /// The home folder, so `~/path` reads as a folder. @@ -125,6 +127,7 @@ export function newTabHost(handshake: { newTab?: unknown; cwd?: unknown }): NewT const omnibar = omnibarContext(object.omnibar); const openTabs = Array.isArray(object.openTabs) ? omnibarContext({ tabs: object.openTabs })?.tabs : undefined; const template = parseNewTabTemplate(object.template); + const styleSwitch = parseStyleSwitch(object.styleSwitch); return { hotkeys, initialKind, @@ -142,6 +145,7 @@ export function newTabHost(handshake: { newTab?: unknown; cwd?: unknown }): NewT : {}), layout: object.layout === "a" ? "a" : "b", ...(template ? { template } : {}), + ...(styleSwitch ? { styleSwitch } : {}), ...(typeof object.lastAgent === "string" && object.lastAgent ? { lastAgent: object.lastAgent } : {}), ...(typeof object.home === "string" && object.home.startsWith("/") ? { home: object.home } : {}), ...(typeof object.inputToken === "string" && object.inputToken ? { inputToken: object.inputToken } : {}), diff --git a/webviews/src/agent-session/acpmux/newtab/NewTabScreen.gallery.ts b/webviews/src/agent-session/acpmux/newtab/NewTabScreen.gallery.ts index 0f4f87fee98e..dd4d318efcd0 100644 --- a/webviews/src/agent-session/acpmux/newtab/NewTabScreen.gallery.ts +++ b/webviews/src/agent-session/acpmux/newtab/NewTabScreen.gallery.ts @@ -157,6 +157,18 @@ async function waitForTabJump( ); } +/// Picks Threads on the style switch and waits for the save and the check (every look is radios). +const pickThreads = async (ctx: PlayContext) => { + await ctx.waitFor(() => ctx.document.querySelector(".nt-style input[value=default]:checked")); + await ctx.click({ role: "radio", name: "Threads" }); + await ctx.waitFor(() => + galleryNativeCalls(ctx.document).some( + (call) => call.method === "newTab.setTemplate" && call.params?.template === "threads", + ), + ); + await ctx.waitFor(() => ctx.document.querySelector(".nt-style input[value=threads]:checked")); +}; + /// Types a prompt, pastes an image and starts Now Playing, then checks that the composer's card /// and its field are where the empty page drew them (Leo 2026-10-19: the composer kept moving). async function holdsItsPlace(ctx: PlayContext): Promise { @@ -1060,19 +1072,34 @@ export default agentPaneEntry({ }, }, "style-switch": { - note: "The New Tab style dots (Leo 2026-10-14): one small round dot per style under the page, the current one in the accent and the accent dot sliding to a pick. A pointer over a dot shows its name; a pick shows the picked name for a moment, then fades it, and is saved as tabs.newTabTemplate.", + note: "The New Tab style dots with nothing set in Debug Settings (Leo 2026-10-14, #20473): one small round dot per style under the page, the current one in the accent and the accent dot sliding to a pick. A pointer over a dot shows its name; a pick shows the picked name for a moment, then fades it, and is saved as tabs.newTabTemplate.", ready: newTab({ template: "default" }), snapshot: noChat(manySessions(4)), - play: async (ctx) => { - await ctx.waitFor(() => ctx.document.querySelector(".nt-style input[value=default]:checked")); - await ctx.click({ role: "radio", name: "Threads" }); - await ctx.waitFor(() => - galleryNativeCalls(ctx.document).some( - (call) => call.method === "newTab.setTemplate" && call.params?.template === "threads", - ), - ); - await ctx.waitFor(() => ctx.document.querySelector(".nt-style input[value=threads]:checked")); - }, + play: pickThreads, + }, + "style-switch-hint": { + note: "Debug Settings newTab.styleSwitch = hint: a dot per style on a chip, its name as the tooltip, and a pick's name over the dots for about a second.", + ready: newTab({ template: "default", styleSwitch: "hint" }), + snapshot: noChat(manySessions(4)), + play: pickThreads, + }, + "style-switch-dots": { + note: "Debug Settings newTab.styleSwitch = dots: the dots as they were before #20127, named by tooltip.", + ready: newTab({ template: "default", styleSwitch: "dots" }), + snapshot: noChat(manySessions(4)), + play: pickThreads, + }, + "style-switch-strip": { + note: "Debug Settings newTab.styleSwitch = strip: a 14px sketch of each style's layout, named by tooltip.", + ready: newTab({ template: "default", styleSwitch: "strip" }), + snapshot: noChat(manySessions(4)), + play: pickThreads, + }, + "style-switch-names": { + note: "Debug Settings newTab.styleSwitch = names: the style names of #20336, the current one on a small fill that slides to a pick.", + ready: newTab({ template: "default", styleSwitch: "names" }), + snapshot: noChat(manySessions(4)), + play: pickThreads, }, "no-template-dots": { note: "New Tab shows no template dots and no default-kind toggle: the template is set in Settings, and a terminal opens only when picked (Leo 2026-10-12). The host payload is what it sent before, dots on.", diff --git a/webviews/src/agent-session/acpmux/newtab/StyleSwitch.tsx b/webviews/src/agent-session/acpmux/newtab/StyleSwitch.tsx index aeff9d13286c..d40b3992af4f 100644 --- a/webviews/src/agent-session/acpmux/newtab/StyleSwitch.tsx +++ b/webviews/src/agent-session/acpmux/newtab/StyleSwitch.tsx @@ -1,22 +1,31 @@ -// The New Tab page's style switch (Leo 2026-10-14, after cx-txe89): one small round dot per page -// style under the page, the current one in the accent (a dot that slides to a new pick) and the rest -// dim. A pointer over a dot shows its name over it; a pick shows the picked name for a moment, then -// fades it. Native radios carry the names (the browser owns arrows and focus) and fill each dot's -// hit target, larger than the dot. The row never changes size or place, so nothing shifts. -import { useEffect, useLayoutEffect, useRef, useState } from "react"; +// The New Tab page's style switch: choosing the page's style, in one quiet row under the page that +// never changes size or place. Its look is Debug Settings `newTab.styleSwitch` (Leo 2026-10-14: every +// look ships as an opt-in; templates.ts STYLE_SWITCHES): +// - round (the default, #20473): one small round dot per style, the current one in the accent (a dot +// that slides to a new pick) and the rest dim; a pointer over a dot shows its name over it, and a +// pick shows the picked name for a moment; +// - hint: a dot per style on a chip (as before #20127), and a pick's name over them for a moment; +// - dots: those chip dots, named by tooltip; +// - strip: a small sketch of each style's layout, named by tooltip; +// - names: the style names (#20336), the current one on a small fill that slides to a pick. +// Every look is native radios (the browser owns arrows and focus) that fill each option's hit +// target, unseen. Nothing slides under Reduce Motion. No capsule, no popup. +import { useEffect, useLayoutEffect, useRef, useState, type ReactNode } from "react"; import { ChoiceGroup } from "../../../ui/ChoiceGroup"; import { FOCUS_LOCATION_EVENT } from "../NewTabPage"; import { useNt } from "./strings"; -import { PAGE_STYLES, type PageStyle } from "./templates"; +import { DEFAULT_STYLE_SWITCH, PAGE_STYLES, type PageStyle, type StyleSwitchLook } from "./templates"; -/// How long a pick's name stays before it fades. +/// How long a pick's name stays (the hint look's name too, screen.css `nt-style-flash`, unmounts after it). const PICKED_NAME_MS = 1100; export function StyleSwitch({ + look = DEFAULT_STYLE_SWITCH, current, onPick, onCancel, }: { + look?: StyleSwitchLook; current: PageStyle; onPick(style: PageStyle): void; onCancel(): void; @@ -25,32 +34,40 @@ export function StyleSwitch({ const group = useRef(null); const mark = useRef(null); const label = useRef(null); - const pointed = useRef(0); + const pointed = useRef(Number.NEGATIVE_INFINITY); // The mark slides only after its first placement, so the page never opens with a slide. const [settled, setSettled] = useState(false); - // The dot whose name shows: the one pointed at, else the one just picked. + // Round: the dot whose name shows, the one pointed at, else the one just picked. const [hovered, setHovered] = useState(); const [picked, setPicked] = useState(); - const named = hovered ?? picked; + const named = look === "round" ? (hovered ?? picked) : undefined; // The last name shown stays in place while it fades. const [shown, setShown] = useState(); if (named && named !== shown) setShown(named); - - useEffect(() => { - if (!picked) return; - const timer = setTimeout(() => setPicked(undefined), PICKED_NAME_MS); - return () => clearTimeout(timer); - }, [picked]); + // Hint: the pick it names; `count` restarts the name for a pick made while one shows. + const [flash, setFlash] = useState<{ style: PageStyle; count: number } | null>(null); useLayoutEffect(() => { const option = group.current?.querySelector(`[data-style="${current}"]`); - if (!option || !mark.current) return; - mark.current.style.transform = `translateX(${option.offsetLeft}px)`; + const fill = mark.current; + if (!option || !fill) return; + fill.style.width = `${option.offsetWidth}px`; + fill.style.transform = `translateX(${option.offsetLeft}px)`; const view = option.ownerDocument.defaultView; if (settled || !view) return; const frame = view.requestAnimationFrame(() => setSettled(true)); return () => view.cancelAnimationFrame(frame); - }, [current, settled]); + }, [current, settled, nt, look]); + + useEffect(() => { + const view = group.current?.ownerDocument.defaultView; + if (!view || (!picked && !flash)) return; + const timer = view.setTimeout(() => { + setPicked(undefined); + setFlash(null); + }, PICKED_NAME_MS); + return () => view.clearTimeout(timer); + }, [picked, flash]); useLayoutEffect(() => { const option = shown && group.current?.querySelector(`[data-style="${shown}"]`); @@ -62,49 +79,118 @@ export function StyleSwitch({
{ if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setHovered(undefined); }} > - + {look === "round" && ( + + )}
); } + +/// Each style's layout in 14px: the field as a rounded box, chats as lines. +const SKETCHES: Record = { + // The field, then three tall chat cards. + cards: ( + <> + + + + ), + // Cards and chats over the field at the foot (the stage layout). + default: ( + <> + + + + ), + // The field alone. + composer: , + // The field, then every chat. + threads: ( + <> + + + + ), + // A prompt. + console: , + // Terminal | Browser | Agent over the field. + classic: ( + <> + + + + ), +}; + +function StyleSketch({ style }: { style: PageStyle }) { + return ( + + ); +} diff --git a/webviews/src/agent-session/acpmux/newtab/screen.css b/webviews/src/agent-session/acpmux/newtab/screen.css index 2dacf0b69528..4c4ed6298fba 100644 --- a/webviews/src/agent-session/acpmux/newtab/screen.css +++ b/webviews/src/agent-session/acpmux/newtab/screen.css @@ -324,24 +324,57 @@ width: 24px; height: 24px; } -/* The style dots (StyleSwitch.tsx, Leo 2026-10-14): a 24px target per style around a 6px round dot, - the rest dim on the text ink and the current one under the accent dot that slides to a pick. A - name shows over a dot while pointed at, and over a pick for a moment; it is out of the row's flow, - so the row never moves. The radio fills the target, unseen (the pane does not load ui.css). The - row sits out of the flow, in the pages' bottom padding, so the pages lay out as without it. */ +/* The style switch (StyleSwitch.tsx): one quiet row under the page, its look `data-look` (Settings > + Debug Settings newTab.styleSwitch). Out of the flow, in the pages' bottom padding (24px), so the pages lay out + (and scroll) as without it. Each radio fills its option's target, unseen (the pane does not load + ui.css, and a native dot drew beside every style, cg-design-judge). */ .nt-style{position:absolute;bottom:2px;left:50%;transform:translateX(-50%);max-width:calc(100% - 32px);margin:0;padding:0;border:0;min-width:0;font-family:system-ui;-webkit-font-smoothing:antialiased} -.nt-style-options{position:relative;display:flex} -.nt-style-option{position:relative;display:flex;align-items:center;justify-content:center;width:24px;height:24px;cursor:default} -.nt-style-option::after{content:"";width:6px;height:6px;border-radius:50%;background:color-mix(in srgb,var(--agent-text) 26%,transparent);transition:background-color var(--agent-motion-hover,80ms) var(--agent-ease-out,ease-out)} -.nt-style-option:hover::after{background:color-mix(in srgb,var(--agent-text) 50%,transparent)} -.nt-style-option:has(:focus-visible)::before{content:"";position:absolute;width:14px;height:14px;border-radius:50%;outline:var(--cmux-focus-ring-width,1.5px) solid var(--cmux-focus-ring-color,color-mix(in srgb,var(--agent-text) 40%,transparent))} -.nt-style-input{position:absolute;inset:0;width:100%;height:100%;margin:0;opacity:0;appearance:none;cursor:default} -.nt-style-mark{position:absolute;top:0;left:0;display:flex;align-items:center;justify-content:center;width:24px;height:24px;pointer-events:none} -.nt-style-mark::after{content:"";width:6px;height:6px;border-radius:50%;background:var(--agent-accent,var(--agent-text))} -.nt-style-options[data-settled] .nt-style-mark{transition:transform var(--agent-motion-move,190ms) var(--agent-ease-out,ease-out)} +.nt-style:has(.nt-style-options:not([data-look=names],[data-look=round])){bottom:0} +.nt-style-options{position:relative;display:flex;gap:2px} +.nt-style-input{position:absolute;inset:0;width:100%;height:100%;margin:0;opacity:0;appearance:none;cursor:inherit} +.nt-style-mark{position:absolute;top:0;left:0;height:100%;box-sizing:border-box;border-radius:5px;background:color-mix(in srgb,var(--agent-accent,var(--agent-text)) 10%,transparent);pointer-events:none} +.nt-style-options[data-settled] .nt-style-mark{transition:transform var(--agent-motion-move,190ms) var(--agent-ease-out,ease-out),width var(--agent-motion-move,190ms) var(--agent-ease-out,ease-out)} +.nt-style-option{position:relative;padding:3px 8px;border-radius:5px;color:var(--acpmux-composer-dim);font-size:var(--text-detail);line-height:var(--text-detail--line-height);white-space:nowrap;cursor:default;transition:color var(--agent-motion-hover,80ms) var(--agent-ease-out,ease-out),background-color var(--agent-motion-hover,80ms) var(--agent-ease-out,ease-out)} +.nt-style-option:hover,.nt-style-option:has(:checked){color:var(--agent-text)} +.nt-style-option:has(:focus-visible){outline:var(--cmux-focus-ring-width,1.5px) solid var(--cmux-focus-ring-color,color-mix(in srgb,var(--agent-text) 40%,transparent));outline-offset:-1px} +/* Round (the default, #20473, Leo 2026-10-14): a 24px target per style around a 6px round dot, the + rest dim on the text ink and the current one under the accent dot that slides to a pick. A name + shows over a dot while pointed at, and over a pick for a moment; it is out of the row's flow, so + the row never moves. */ +.nt-style-options[data-look=round]{gap:0} +.nt-style-options[data-look=round] .nt-style-option{display:flex;align-items:center;justify-content:center;width:24px;height:24px;padding:0;border-radius:0} +.nt-style-options[data-look=round] .nt-style-option::after{content:"";width:6px;height:6px;border-radius:50%;background:color-mix(in srgb,var(--agent-text) 26%,transparent);transition:background-color var(--agent-motion-hover,80ms) var(--agent-ease-out,ease-out)} +.nt-style-options[data-look=round] .nt-style-option:hover::after{background:color-mix(in srgb,var(--agent-text) 50%,transparent)} +.nt-style-options[data-look=round] .nt-style-option:has(:focus-visible){outline:0} +.nt-style-options[data-look=round] .nt-style-option:has(:focus-visible)::before{content:"";position:absolute;width:14px;height:14px;border-radius:50%;outline:var(--cmux-focus-ring-width,1.5px) solid var(--cmux-focus-ring-color,color-mix(in srgb,var(--agent-text) 40%,transparent))} +.nt-style-options[data-look=round] .nt-style-mark{display:flex;align-items:center;justify-content:center;border-radius:0;background:none} +.nt-style-options[data-look=round] .nt-style-mark::after{content:"";width:6px;height:6px;border-radius:50%;background:var(--agent-accent,var(--agent-text))} .nt-style-name{position:absolute;bottom:100%;left:50%;transform:translateX(-50%);padding-bottom:2px;color:var(--acpmux-composer-dim);font-size:var(--text-detail);line-height:var(--text-detail--line-height);white-space:nowrap;pointer-events:none;opacity:0;transition:opacity var(--agent-motion-out,160ms) var(--agent-ease-out,ease-out)} .nt-style-name[data-shown]{opacity:1;transition-duration:var(--agent-motion-hover,80ms)} -@media (prefers-reduced-motion:reduce){.nt-style-options[data-settled] .nt-style-mark,.nt-style-option::after{transition:none}} +/* Hint and dots, drawn as before #20127, like the sidebar's space dots: a 24px slot per style, a + 20px chip inset 2px under the pointer, the current style's chip in the accent tint, the dot at 70%, + 90% under the pointer and full for the current one. */ +.nt-style-options:is([data-look=hint],[data-look=dots]){gap:0} +.nt-style-options:is([data-look=hint],[data-look=dots]) .nt-style-mark{border:2px solid transparent;border-radius:8px;background-clip:padding-box;background-color:color-mix(in srgb,var(--agent-accent,var(--agent-text)) 8%,transparent)} +.nt-style-options:is([data-look=hint],[data-look=dots]) .nt-style-option{display:grid;place-items:center;width:24px;height:24px;padding:0;border-radius:0;cursor:pointer} +.nt-style-options:is([data-look=hint],[data-look=dots]) .nt-style-option::before{content:"";position:absolute;inset:2px;border-radius:6px;pointer-events:none;transition:background-color var(--agent-motion-hover,80ms) var(--agent-ease-out,ease-out)} +.nt-style-options:is([data-look=hint],[data-look=dots]) .nt-style-option:hover::before{background:var(--agent-control-hover)} +.nt-style-options:is([data-look=hint],[data-look=dots]) .nt-style-option:has(:focus-visible){outline:0} +.nt-style-options:is([data-look=hint],[data-look=dots]) .nt-style-option:has(:focus-visible)::before{outline:var(--cmux-focus-ring-width,1.5px) solid var(--cmux-focus-ring-color,color-mix(in srgb,var(--agent-text) 40%,transparent))} +.nt-style-dot{width:6px;height:6px;border-radius:50%;background:var(--agent-text);opacity:.7;transition:opacity var(--agent-motion-hover,80ms) var(--agent-ease-out,ease-out)} +.nt-style-option:hover .nt-style-dot{opacity:.9} +.nt-style-option:has(:checked) .nt-style-dot{opacity:1} +/* Hint: a pick's name over the dots for about a second (StyleSwitch.tsx PICKED_NAME_MS): it fades in, + holds and fades out; no movement. */ +.nt-style-flash{position:absolute;bottom:100%;left:50%;transform:translateX(-50%);color:var(--acpmux-composer-dim);font-size:var(--text-caption);line-height:var(--text-caption--line-height);white-space:nowrap;pointer-events:none;animation:nt-style-flash var(--agent-motion-hover,80ms) var(--agent-ease-out,ease-out)} +/* Shown as its base style; StyleSwitch unmounts it after PICKED_NAME_MS. The fade-in is decoration, so it + still shows with motion off (data-cmux-motion=off forces the duration to 0). */ +@keyframes nt-style-flash{from{opacity:0}} +/* Strip: a 14px sketch of each style's layout in a 26x22 segment, named by tooltip. */ +.nt-style-options[data-look=strip] .nt-style-option{display:grid;place-items:center;width:26px;height:22px;padding:0} +.nt-style-options[data-look=strip] .nt-style-option:hover:not(:has(:checked)){background:var(--agent-control-hover)} +.nt-style-sketch{width:14px;height:14px;fill:none;stroke:currentColor;stroke-width:1.2;stroke-linecap:round;stroke-linejoin:round} +@media (prefers-reduced-motion:reduce){.nt-style-options[data-settled] .nt-style-mark,.nt-style-option,.nt-style-option::before,.nt-style-option::after,.nt-style-dot{transition:none}} /* The browser omnibar's pointer (OmnibarPointer, cx-d0d.80): the field row is the text area, so the I-beam shows over all of it (a press there focuses the field, focusFromRow); the pickers and diff --git a/webviews/src/agent-session/acpmux/newtab/templates.ts b/webviews/src/agent-session/acpmux/newtab/templates.ts index 9f14760e44ae..abe8ca9c7b77 100644 --- a/webviews/src/agent-session/acpmux/newtab/templates.ts +++ b/webviews/src/agent-session/acpmux/newtab/templates.ts @@ -64,6 +64,21 @@ export function pageStyle(template: NewTabTemplate): PageStyle { return template === "terminal" ? "cards" : template; } +/// The style switch's looks (Debug Settings `newTab.styleSwitch`, sent as `styleSwitch`): round +/// dots that name the one pointed at (#20473, the default), the chip dots that name a pick for a +/// moment, the dots as they were before #20127, a strip of small layout glyphs, and the style +/// names of #20336. +export const STYLE_SWITCHES = ["round", "hint", "dots", "strip", "names"] as const; +export type StyleSwitchLook = (typeof STYLE_SWITCHES)[number]; + +/// The look with nothing set: round dots that name the one pointed at or just picked (#20473). +export const DEFAULT_STYLE_SWITCH: StyleSwitchLook = "round"; + +/// A look from the host, or undefined for anything else. +export function parseStyleSwitch(value: unknown): StyleSwitchLook | undefined { + return STYLE_SWITCHES.includes(value as StyleSwitchLook) ? (value as StyleSwitchLook) : undefined; +} + type Native = (method: string, params?: Record) => Promise; /// A style picked on the page: saved as `tabs.newTabTemplate` (the host checks the value), and diff --git a/webviews/test/new-tab-style-switch.test.tsx b/webviews/test/new-tab-style-switch.test.tsx index dc9a09b94e23..8aaecaf94171 100644 --- a/webviews/test/new-tab-style-switch.test.tsx +++ b/webviews/test/new-tab-style-switch.test.tsx @@ -1,133 +1,243 @@ -// The New Tab page chooses its style with dots (Leo 2026-10-14: "what happened to the dots at the -// bottom, why's there text"). One dot per page style: native radios (arrow keys move between them), -// named for screen readers; a pointer over a dot shows its name, a pick shows the picked name for a -// moment and then fades it; a pick is saved as `tabs.newTabTemplate` (`newTab.setTemplate`) and -// shown in place. Terminal is not offered: New Tab never opens a terminal by itself. +// The New Tab page chooses its style (Leo 2026-10-13: the old template dots looked, clicked and +// moved badly, but choosing the style was good; 10-14: the dots again, #20473, and every look he +// compared is an opt-in Debug Settings look). The switch draws one of five looks (Debug Settings +// `newTab.styleSwitch`, sent as `styleSwitch`): round dots that name the one pointed at (the default), +// the chip dots that name a pick for a moment, those dots as they were before #20127, a strip of +// small layout glyphs, or #20336's names. Every look is the same native radio group: arrow keys move +// between the styles, a pick is saved as `tabs.newTabTemplate` (`newTab.setTemplate`) and shown in +// place, a pointer pick hands focus back to the field. Terminal is not offered: New Tab never opens +// a terminal by itself. import { afterAll, beforeAll, expect, test } from "bun:test"; import { readFileSync } from "node:fs"; import { join } from "node:path"; import { JSDOM } from "jsdom"; -import { act, createElement } from "react"; +import { act, createElement, useState } from "react"; import { createRoot } from "react-dom/client"; +import { newTabHost } from "../src/agent-session/acpmux/NewTabPage"; import { StyleSwitch } from "../src/agent-session/acpmux/newtab/StyleSwitch"; -import { pickNewTabStyle } from "../src/agent-session/acpmux/newtab/templates"; +import { + DEFAULT_STYLE_SWITCH, + STYLE_SWITCHES, + pickNewTabStyle, + type PageStyle, + type StyleSwitchLook, +} from "../src/agent-session/acpmux/newtab/templates"; const scope = globalThis as Record; -const keys = ["window", "document", "navigator", "HTMLElement", "IS_REACT_ACT_ENVIRONMENT"] as const; +const keys = [ + "window", + "document", + "navigator", + "HTMLElement", + "HTMLInputElement", + "IS_REACT_ACT_ENVIRONMENT", +] as const; const saved = keys.map((key) => [key, key in scope, scope[key]] as const); let window: JSDOM["window"]; -// Frames run only when the test runs them: JSDOM's own are a 16 ms timer, which on a loaded runner -// fired the pick's frame inside the click's act() (as in #20431). -const frames = new Map(); -let lastFrame = 0; -const nextFrame = () => { - const due = [...frames.values()]; - frames.clear(); - for (const run of due) run(0); -}; beforeAll(() => { - window = new JSDOM("
", { pretendToBeVisual: true }).window; - Object.assign(window, { - requestAnimationFrame: (run: FrameRequestCallback) => (frames.set(++lastFrame, run), lastFrame), - cancelAnimationFrame: (id: number) => frames.delete(id), - }); + window = new JSDOM("
", { pretendToBeVisual: true }).window; Object.assign(scope, { window, document: window.document, navigator: window.navigator, HTMLElement: window.HTMLElement, + HTMLInputElement: window.HTMLInputElement, IS_REACT_ACT_ENVIRONMENT: true, }); }); afterAll(() => { + // Focus moves queue jsdom selectionchange events; closing the window drops them before the + // globals they reach go. + window.close(); for (const [key, had, value] of saved) { if (had) scope[key] = value; else delete scope[key]; } }); -test("the New Tab page offers its six styles as named dots, the current one checked", async () => { - const host = window.document.getElementById("root")!; - const root = createRoot(host); - const picks: string[] = []; - await act(async () => { - root.render( - createElement(StyleSwitch, { current: "threads", onPick: (style) => picks.push(style), onCancel() {} }), +/// The page's wiring (App.tsx): a pick goes through pickNewTabStyle and shows in place. +function Page({ look, sent }: { look: StyleSwitchLook; sent: [string, unknown][] }) { + const [current, setCurrent] = useState("default"); + return createElement(StyleSwitch, { + look, + current, + onCancel: () => window.dispatchEvent(new window.Event("acpmux-focus-location")), + onPick: (style: PageStyle) => + pickNewTabStyle(style, { + callNative: async (method, params) => void sent.push([method, params]), + show: setCurrent, + }), + }); +} + +/// Runs the switch's frames and timers by hand: jsdom's own are timers a loaded runner can reach +/// before the first check, or after the last. +async function withClock(run: (tick: () => Promise, wait: () => Promise) => Promise) { + const frames: FrameRequestCallback[] = []; + const timers: (() => void)[] = []; + const nextFrame = window.requestAnimationFrame; + const setTimer = window.setTimeout; + window.requestAnimationFrame = (callback) => frames.push(callback); + window.setTimeout = ((callback: () => void) => timers.push(callback)) as typeof window.setTimeout; + try { + await run( + () => + act(async () => { + for (const callback of frames.splice(0)) callback(window.performance.now()); + }), + () => + act(async () => { + for (const callback of timers.splice(0)) callback(); + }), ); + } finally { + window.requestAnimationFrame = nextFrame; + window.setTimeout = setTimer; + } +} + +for (const look of STYLE_SWITCHES) { + test(`${look}: the six styles are one radio group; a pick is saved, a click goes back to the field`, async () => { + const host = window.document.getElementById("root")!; + const root = createRoot(host); + const sent: [string, unknown][] = []; + await act(async () => root.render(createElement(Page, { look, sent }))); + + const group = host.querySelector("fieldset")!; + expect(group.getAttribute("aria-label")).toBe("New Tab templates"); + expect(host.querySelector(".nt-style-options")!.dataset.look).toBe(look); + const radios = () => [...host.querySelectorAll("input[type=radio]")]; + expect(radios().map((radio) => radio.value)).toEqual([ + "cards", + "default", + "composer", + "threads", + "console", + "classic", + ]); + expect(radios().map((radio) => radio.getAttribute("aria-label"))).toEqual([ + "Cards", + "Default", + "Composer", + "Threads", + "Console", + "Classic", + ]); + expect(new Set(radios().map((radio) => radio.name)).size).toBe(1); + expect( + radios() + .filter((radio) => radio.checked) + .map((radio) => radio.value), + ).toEqual(["default"]); + const titles = () => [...host.querySelectorAll("label")].map((label) => label.title); + if (look === "names") { + expect(host.textContent).toContain("Composer"); + } else if (look === "round") { + // Round names a dot over it while pointed at, so no tooltip and no name at rest. + expect(host.textContent).toBe(""); + expect(titles()).toEqual(["", "", "", "", "", ""]); + } else { + // At rest the style names are tooltips (the page's instant `title` tooltips), not text. + expect(host.textContent).toBe(""); + expect(titles()).toEqual(["Cards", "Default", "Composer", "Threads", "Console", "Classic"]); + } + + let fieldAsked = 0; + const asked = () => fieldAsked++; + window.addEventListener("acpmux-focus-location", asked); + await withClock(async (tick, wait) => { + // A pointer pick: saved, shown, and the field asked for once the picked page has mounted. + await act(async () => { + radios()[3]!.dispatchEvent(new window.Event("pointerdown", { bubbles: true })); + radios()[3]!.click(); + }); + expect(sent).toEqual([["newTab.setTemplate", { template: "threads" }]]); + expect(radios().find((radio) => radio.checked)?.value).toBe("threads"); + expect(fieldAsked).toBe(0); + await tick(); + expect(fieldAsked).toBe(1); + // The dots that name a pick show its name over them for a moment. + const flash = () => host.querySelector(".nt-style-flash")?.textContent ?? ""; + expect(flash()).toBe(look === "hint" ? "Threads" : ""); + // Round names the pick over its dot for a moment. + const named = () => host.querySelector(".nt-style-name[data-shown]")?.textContent ?? ""; + expect(named()).toBe(look === "round" ? "Threads" : ""); + await wait(); + expect(flash()).toBe(""); + expect(named()).toBe(""); + + // A keyboard pick (an arrow key checks the next radio, with no pointer down): saved, and + // focus stays on the switch to keep moving, even when the picked page focused its field. + radios()[4]!.focus(); + await act(async () => radios()[4]!.click()); + expect(sent.at(-1)).toEqual(["newTab.setTemplate", { template: "console" }]); + window.document.getElementById("field")!.focus(); + await tick(); + expect(window.document.activeElement).toBe(radios()[4]!); + expect(fieldAsked).toBe(1); + + // Return and Escape go back to the field. + for (const key of ["Escape", "Enter"]) { + await act( + async () => void radios()[4]!.dispatchEvent(new window.KeyboardEvent("keydown", { key, bubbles: true })), + ); + } + expect(fieldAsked).toBe(3); + }); + window.removeEventListener("acpmux-focus-location", asked); + await act(async () => root.unmount()); }); - const group = host.querySelector("fieldset")!; - expect(group.getAttribute("aria-label")).toBe("New Tab templates"); - const radios = [...host.querySelectorAll("input[type=radio]")]; - expect(radios.map((radio) => radio.value)).toEqual(["cards", "default", "composer", "threads", "console", "classic"]); - expect(radios.map((radio) => radio.getAttribute("aria-label"))).toEqual([ - "Cards", - "Default", - "Composer", - "Threads", - "Console", - "Classic", - ]); - expect(radios.filter((radio) => radio.checked).map((radio) => radio.value)).toEqual(["threads"]); - // Dots: no name shows at rest. +} + +test("round dots are the look with nothing set", () => { + expect(DEFAULT_STYLE_SWITCH).toBe("round"); + expect(newTabHost({ newTab: { layout: "b" } })!.styleSwitch).toBeUndefined(); + expect(newTabHost({ newTab: { layout: "b", styleSwitch: "strip" } })!.styleSwitch).toBe("strip"); + expect(newTabHost({ newTab: { layout: "b", styleSwitch: "sparkles" } })!.styleSwitch).toBeUndefined(); +}); + +test("round: a pointer over a dot names it, over that dot; leaving the row hides the name", async () => { + const host = window.document.getElementById("root")!; + const root = createRoot(host); + await act(async () => root.render(createElement(StyleSwitch, { current: "threads", onPick() {}, onCancel() {} }))); const name = () => host.querySelector(".nt-style-name")!; expect(name().hasAttribute("data-shown")).toBe(false); - // A pointer over a dot shows its name, over that dot; leaving the row hides it. - await act(async () => { - radios[2]!.closest("label")!.dispatchEvent(new window.MouseEvent("pointerover", { bubbles: true })); - }); - expect(name().textContent).toBe("Composer"); - expect(name().hasAttribute("data-shown")).toBe(true); - await act(async () => { - radios[2]!.closest("label")!.dispatchEvent(new window.MouseEvent("pointerout", { bubbles: true })); - }); - expect(name().hasAttribute("data-shown")).toBe(false); - - // A click goes back to the field once the picked page has mounted (the frame after the pick). - // The test runs the frames itself: jsdom's own frame is a 16 ms timer, which a loaded runner - // can reach before the first check. - let fieldAsked = 0; - window.addEventListener("acpmux-focus-location", () => fieldAsked++); - await act(async () => { - radios[5]!.dispatchEvent(new window.Event("pointerdown", { bubbles: true })); - radios[5]!.click(); - }); - expect(picks).toEqual(["classic"]); - expect(fieldAsked).toBe(0); - await act(async () => nextFrame()); - expect(fieldAsked).toBe(1); - // The pick shows its name for a moment, then lets it fade. - expect(name().textContent).toBe("Classic"); - expect(name().hasAttribute("data-shown")).toBe(true); - await act(() => new Promise((done) => setTimeout(done, 1600))); + const composer = host.querySelector("label[data-style=composer]")!; + await act(async () => void composer.dispatchEvent(new window.MouseEvent("pointerover", { bubbles: true }))); + expect([name().textContent, name().hasAttribute("data-shown")]).toEqual(["Composer", true]); + await act(async () => void composer.dispatchEvent(new window.MouseEvent("pointerout", { bubbles: true }))); expect(name().hasAttribute("data-shown")).toBe(false); await act(async () => root.unmount()); }); // cg-design-judge: every style showed a browser-default radio dot. The pane does not load // src/ui/ui.css, so its .ui-sr-only never applied; the page's own stylesheet hides the inputs. Each -// radio fills its 24px dot target, unseen, so a click anywhere on the target picks the style. -test("the style radios draw no native dot: the page's own stylesheet hides them", async () => { +// radio fills its option's target, unseen, so a click anywhere on the target picks the style. +test("the style radios draw no native dot in any look: the page's own stylesheet hides them", async () => { const style = window.document.createElement("style"); style.textContent = readFileSync(join(import.meta.dir, "../src/agent-session/acpmux/newtab/screen.css"), "utf8"); window.document.head.append(style); const host = window.document.getElementById("root")!; const root = createRoot(host); - await act(async () => { - root.render(createElement(StyleSwitch, { current: "default", onPick() {}, onCancel() {} })); - }); - const radios = [...host.querySelectorAll("input[type=radio]")]; - expect(radios).toHaveLength(6); - for (const radio of radios) { - const drawn = window.getComputedStyle(radio); - expect([radio.value, drawn.position, drawn.width, drawn.height, drawn.opacity]).toEqual([ - radio.value, - "absolute", - "100%", - "100%", - "0", - ]); + for (const look of STYLE_SWITCHES) { + await act(async () => { + root.render(createElement(StyleSwitch, { look, current: "default", onPick() {}, onCancel() {} })); + }); + const radios = [...host.querySelectorAll("input[type=radio]")]; + expect(radios).toHaveLength(6); + for (const radio of radios) { + const drawn = window.getComputedStyle(radio); + expect([look, radio.value, drawn.position, drawn.width, drawn.height, drawn.opacity]).toEqual([ + look, + radio.value, + "absolute", + "100%", + "100%", + "0", + ]); + } } await act(async () => root.unmount()); style.remove();