diff --git a/scripts/cmux-next/layers-baseline.tsv b/scripts/cmux-next/layers-baseline.tsv index 362b15a0cd32..77308d25c45c 100644 --- a/scripts/cmux-next/layers-baseline.tsv +++ b/scripts/cmux-next/layers-baseline.tsv @@ -4,7 +4,7 @@ webviews/src/agent-session/acpmux/changes/treeMotionDom.ts z 1 webviews/src/agent-session/acpmux/composerAttachments.css z 1 webviews/src/agent-session/acpmux/handoff/styles.css z 1 webviews/src/agent-session/acpmux/prototype/prototype.css z 5 -webviews/src/agent-session/acpmux/styles.css z 5 +webviews/src/agent-session/acpmux/styles.css z 3 webviews/src/agent-session/acpmux/threadMinimap/threadMinimap.css z 1 webviews/src/gallery/fixtures/VisualMediaBoard.css backdrop 1 webviews/src/gallery/frame/liveErrors.ts z 1 diff --git a/scripts/cmux-next/type-scale-baseline.tsv b/scripts/cmux-next/type-scale-baseline.tsv index 627f486e7b3b..418be65f1bd6 100644 --- a/scripts/cmux-next/type-scale-baseline.tsv +++ b/scripts/cmux-next/type-scale-baseline.tsv @@ -13,8 +13,8 @@ webviews/src/agent-session/acpmux/keys.css size 1 webviews/src/agent-session/acpmux/markdownField.css size 4 webviews/src/agent-session/acpmux/mockFixture.ts size 2 webviews/src/agent-session/acpmux/newtab/screen.css size 15 -webviews/src/agent-session/acpmux/prototype/prototype.css size 18 -webviews/src/agent-session/acpmux/styles.css size 58 +webviews/src/agent-session/acpmux/prototype/prototype.css size 17 +webviews/src/agent-session/acpmux/styles.css size 53 webviews/src/agent-session/acpmux/subagents/subagents.css size 2 webviews/src/agent-session/acpmux/threadMinimap/threadMinimap.css size 1 webviews/src/gallery/env.ts size 1 diff --git a/webviews/src/agent-session/acpmux/SessionSidebar.gallery.tsx b/webviews/src/agent-session/acpmux/SessionSidebar.gallery.tsx deleted file mode 100644 index 5f79eab22801..000000000000 --- a/webviews/src/agent-session/acpmux/SessionSidebar.gallery.tsx +++ /dev/null @@ -1,352 +0,0 @@ -// l10n-allow-file: gallery fixtures (sample sessions), not shipped UI. -// The sidebar's list, rail and session actions are rendered by the real SessionSidebar component; -// callbacks are no-ops because this gallery lane measures the interaction surface, not the host. -import type { ComponentProps } from "react"; -import { componentEntry } from "../../gallery/format"; -import { minutesAgo } from "../../gallery/clock"; -import { session } from "../../gallery/fixtures/acpmux"; -import type { PlayContext } from "../../gallery/play"; -import type { SessionSidebar } from "./SessionSidebar"; - -type Props = ComponentProps; - -const sessions: Props["sessions"] = [ - session({ - sessionId: "sidebar-first", - title: "Fix the checkout page", - updatedAt: minutesAgo(1), - status: "running", - indicator: "working", - preview: "The checkout still needs a final pass on the payment form.", - cwd: "/Users/you/src/cmux", - branch: "feat/checkout", - }), - session({ - sessionId: "sidebar-second", - title: "Port the sidebar", - updatedAt: minutesAgo(2), - unread: true, - indicator: "done", - cwd: "/Users/you/src/cmux", - branch: "feat/sidebar-polish", - }), - session({ - sessionId: "sidebar-third", - title: "Profile transcript scrolling", - updatedAt: minutesAgo(3), - cwd: "/Users/you/src/cmux", - status: "idle", - }), - session({ - sessionId: "sidebar-cloud", - title: "Review the cloud build", - updatedAt: minutesAgo(4), - cwd: "/workspace/cmux", - host: "Build runner", - hostKind: "cloud", - peer: "Build runner", - branch: "ci/nightly", - }), -]; - -/** An agent whose sub-session waits for the person to trust a folder (hub/trust_wait.rs). */ -const trustWaiting: Props["sessions"] = [ - session({ - sessionId: "sidebar-trust", - title: "Split the importer into parts", - updatedAt: minutesAgo(1), - status: "waiting", - indicator: "input", - trustPending: "/Users/you/src/vendor-sdk", - cwd: "/Users/you/src/cmux", - branch: "feat/importer", - }), - ...sessions.slice(0, 2), -]; - -const sessionsWithoutPreview = sessions.map((entry) => ({ ...entry, preview: undefined })); - -const active = session({ - sessionId: "sidebar-active", - title: "Fix the active bug", - updatedAt: minutesAgo(1), - status: "running", - indicator: "working", -}); -const settled = session({ - sessionId: "sidebar-settled", - title: "Review the settled draft", - updatedAt: minutesAgo(2), - status: "closed", -}); - -const baseProps: Props = { - sessions, - selectedId: "sidebar-first", - onSelect: () => undefined, - onNewChat: () => undefined, - account: { name: "Leo", detail: "Personal" }, - groupProjects: true, - preview: true, - openIds: new Set(["sidebar-second"]), -}; - -const waitForActiveRow = (ctx: PlayContext, id: string) => - ctx.waitFor(() => ctx.document.activeElement?.getAttribute("data-session-id") === id); - -// The field's label is "Search chats" since #19895 (the lists say chats). -const SEARCH = 'input[aria-label="Search chats"]'; - -const search = async (ctx: PlayContext) => { - await ctx.type("sidebar", { selector: SEARCH }); - // "sidebar" matches one chat, by its title and branch; its project's header stays. - await ctx.waitFor(() => { - const rows = ctx.document.querySelectorAll(".acpmux-session-row"); - return rows.length === 1 && rows[0]?.getAttribute("data-session-id") === "sidebar-second"; - }); -}; - -// A search removes rows, so the matches and the Settled section below them move up inside the -// scroll region (about 0.004); the sidebar frame and the rail, the anchors, stay put. -const searchChecks = { - layoutShiftMax: { - value: 0.01, - reason: "Filtering moves the remaining rows up inside the scroll region; the sidebar frame and rail do not move.", - }, -}; - -const searchThenClear = async (ctx: PlayContext) => { - await search(ctx); - await ctx.press("Escape"); - await ctx.waitFor(() => ctx.document.querySelector(SEARCH)?.value === ""); -}; - -const rowKeyboard = async (ctx: PlayContext) => { - await ctx.focus({ selector: ".acpmux-session-row" }); - await ctx.press("ArrowDown"); - await waitForActiveRow(ctx, "sidebar-second"); - await ctx.press("End"); - await waitForActiveRow(ctx, "sidebar-cloud"); - await ctx.press("Home"); - await waitForActiveRow(ctx, "sidebar-first"); -}; - -const railKeyboard = async (ctx: PlayContext) => { - await ctx.focus({ role: "button", name: /Sessions/ }); - await ctx.press("ArrowDown"); - await ctx.waitFor(() => ctx.document.activeElement?.getAttribute("aria-label") === "History"); - await ctx.press("End"); - await ctx.waitFor(() => ctx.document.activeElement?.getAttribute("aria-label") === "Closed sessions"); - await ctx.press("Home"); - await ctx.waitFor(() => ctx.document.activeElement?.getAttribute("aria-label") === "Sessions"); -}; - -const switchViews = async (ctx: PlayContext) => { - await ctx.click({ role: "button", name: "History" }); - await ctx.waitFor(() => ctx.document.querySelector('[aria-label="History"] .acpmux-sidebar-title')); - await ctx.click({ role: "button", name: "Pull requests" }); - await ctx.waitFor(() => ctx.document.querySelector('[aria-label="Pull requests"] .acpmux-sidebar-empty')); - await ctx.click({ role: "button", name: "Closed sessions" }); - await ctx.waitFor(() => ctx.document.querySelector('[aria-label="Closed sessions"] .acpmux-sidebar-title')); -}; - -/** - * The sidebar's rail is also the workspace-switch affordance in the native shell. Keep one - * canonical rapid round-trip for visual review: the play runner records each transition's frame - * timing, settle latency, anchor geometry and layout shift. This deliberately uses the existing - * play/manifest contract instead of introducing a second animation recorder. - */ -const animationAudit = async (ctx: PlayContext) => { - const sidebar = ctx.find({ selector: ".acpmux-sidebar" }); - const before = sidebar.getBoundingClientRect(); - const transitions: Array<[string, string]> = [ - ["History", 'section[aria-label="History"] .acpmux-sidebar-title'], - ["Pull requests", 'section[aria-label="Pull requests"] .acpmux-sidebar-empty'], - ["Closed sessions", 'section[aria-label="Closed sessions"] .acpmux-sidebar-title'], - ["Sessions", ".acpmux-sidebar-active"], - ]; - for (const [label, section] of transitions) { - await ctx.click({ role: "button", name: label }); - await ctx.waitFor(() => ctx.document.querySelector(section) !== null); - const after = sidebar.getBoundingClientRect(); - const moved = Math.max( - Math.abs(before.left - after.left), - Math.abs(before.top - after.top), - Math.abs(before.width - after.width), - Math.abs(before.height - after.height), - ); - if (moved > 0.5) throw new Error(`${label} moved the sidebar frame by ${moved.toFixed(1)}px`); - } -}; - -const contextActions = async (ctx: PlayContext) => { - const openMenu = (sessionId: string) => { - const row = ctx.find({ selector: `[data-session-id="${sessionId}"]` }); - const box = row.getBoundingClientRect(); - const view = ctx.document.defaultView!; - row.dispatchEvent( - new view.MouseEvent("contextmenu", { - bubbles: true, - cancelable: true, - clientX: box.left + 24, - clientY: box.top + 12, - }), - ); - }; - - openMenu("sidebar-active"); - await ctx.waitFor(() => ctx.document.querySelector('[role="menu"] [role="menuitem"]')); - await ctx.click({ role: "menuitem", name: "Settle" }); - await ctx.waitFor(() => !ctx.document.querySelector('[role="menu"]')); - - openMenu("sidebar-settled"); - await ctx.waitFor(() => ctx.document.querySelector('[role="menu"] [role="menuitem"]')); - await ctx.click({ role: "menuitem", name: "Continue" }); - await ctx.waitFor(() => !ctx.document.querySelector('[role="menu"]')); -}; - -const hoverPreview = async (ctx: PlayContext) => { - await ctx.hover({ selector: '[data-session-id="sidebar-first"]' }); - await ctx.waitFor(() => ctx.document.querySelector(".acpmux-session-preview")); -}; - -const focusPreview = async (ctx: PlayContext) => { - await ctx.focus({ selector: '[data-session-id="sidebar-first"]' }); - await ctx.waitFor(() => ctx.document.querySelector(".acpmux-session-preview")); -}; - -const entry = componentEntry({ - id: "agent-pane.session-sidebar", - title: "Session sidebar", - area: "Agent pane", - pane: true, - height: 620, - widths: { narrow: 300, normal: 380, wide: 520 }, - anchors: [{ selector: ".acpmux-sidebar" }, { selector: ".acpmux-sidebar-rail" }], - checks: { - anchorMovePx: { - value: 0, - reason: "Sidebar searches, menus and rail navigation must leave the sidebar frame and rail geometry stable.", - }, - layoutShiftMax: { - value: 0, - reason: "Filtering and switching sidebar views replace rows inside the scroll region without moving the shell.", - }, - longFrameFailMs: { - value: 33, - reason: "Sidebar filtering, menus and keyboard navigation should stay responsive on the gallery host.", - }, - settleMaxMs: { - value: 250, - reason: - "Search, rail and context-menu actions are direct local interactions with a quarter-second response budget.", - }, - }, - covers: ["agent-session/acpmux/SessionSidebar.tsx#SessionSidebar"], - styles: () => import("./styles.css"), - load: () => - import("./SessionSidebar").then(({ SessionSidebar: Sidebar }) => { - // The pane's shell is a flex row the window's height, so the sidebar fills it and a filtered - // list never moves the rail; the stage itself is a block that would size it to its rows. - const GallerySessionSidebar = (props: Props) => ( -
- -
- ); - return GallerySessionSidebar; - }), - variants: { - baseline: { - note: "Grouped projects, an open tab, local and cloud placement, status marks, and the account footer.", - props: baseProps, - }, - "trust-pending": { - note: "An agent's sub-session waits for a folder's trust: its row says Needs trust, in the amber of the question card, instead of Needs your input.", - props: { ...baseProps, sessions: trustWaiting, selectedId: "sidebar-first", groupProjects: false }, - }, - "search-filtered": { - note: "Search narrows rows by title, branch, project or host while preserving project context.", - props: { ...baseProps, groupProjects: false }, - play: search, - checks: searchChecks, - }, - "search-clears": { - note: "Escape clears the sidebar query before it can dismiss the surrounding surface.", - props: { ...baseProps, groupProjects: false }, - play: searchThenClear, - checks: searchChecks, - }, - "row-keyboard": { - note: "ArrowDown, End and Home move through visible session rows without leaving the list.", - props: { ...baseProps, groupProjects: false }, - play: rowKeyboard, - }, - "rail-keyboard": { - note: "The rail skips disabled controls and follows ArrowDown, End and Home through its views.", - props: baseProps, - play: railKeyboard, - }, - "view-switching": { - note: "History, Pull requests and Closed sessions each replace the list in the same pane.", - props: baseProps, - play: switchViews, - }, - "view-switching-animation-audit": { - note: "Rapidly round-trip the sidebar rail through History, Pull requests, Closed sessions and Sessions. The shared play report is the animation receipt: frame timing, settle latency, layout shift and sidebar geometry are checked together.", - props: baseProps, - checks: { - layoutShiftMax: { - value: 0, - reason: "Workspace/sidebar view switches replace the scroll content without moving the sidebar frame.", - }, - longFrameFailMs: { - value: 33, - reason: "Rail view switches should not introduce a long animation frame over two display frames.", - }, - settleMaxMs: { - value: 250, - reason: "Each local rail transition should settle within a quarter second for rapid workspace review.", - }, - }, - play: animationAudit, - }, - "context-actions": { - note: "Right-click an active row to Settle and a settled row to Continue; each menu returns focus cleanly.", - props: { - ...baseProps, - sessions: [active], - settledSessions: [settled], - groupProjects: false, - onSettle: () => undefined, - onContinue: () => undefined, - }, - play: contextActions, - }, - "hover-preview": { - note: "A deliberate hover opens the latest reply with status and placement metadata in a floating card.", - props: { ...baseProps, groupProjects: false }, - play: hoverPreview, - }, - "hover-preview-quiet": { - note: "Debug layout sidebarPreviewVariant=quiet: a narrower, flatter latest-reply card keeps metadata readable with less elevation.", - props: { ...baseProps, groupProjects: false, previewVariant: "quiet" }, - play: hoverPreview, - }, - "hover-preview-outline": { - note: "Debug layout sidebarPreviewVariant=outline: a lightly tinted rim keeps the latest-reply card distinct without a drop shadow.", - props: { ...baseProps, groupProjects: false, previewVariant: "outline" }, - play: hoverPreview, - }, - "focus-preview": { - note: "Keyboard focus opens the same preview without changing the sidebar row geometry.", - props: { ...baseProps, groupProjects: false }, - play: focusPreview, - }, - "no-preview": { - note: "Rows without a latest reply remain compact and do not mount a preview card.", - props: { ...baseProps, sessions: sessionsWithoutPreview, groupProjects: false }, - }, - }, -}); - -export default entry; diff --git a/webviews/src/agent-session/acpmux/SessionSidebar.tsx b/webviews/src/agent-session/acpmux/SessionSidebar.tsx deleted file mode 100644 index 8b8f3ccccb9a..000000000000 --- a/webviews/src/agent-session/acpmux/SessionSidebar.tsx +++ /dev/null @@ -1,704 +0,0 @@ -import React, { createContext, memo, useContext, useEffect, useId, useMemo, useRef, useState } from "react"; -import { - filterSessions, - groupMark, - activityTime, - sessionMark, - sessionPlace, - sortByRecency, - shortAge, - sidebarSections, - visibleSessions, - type AcpmuxSessionEntry, - type SessionMark, -} from "./sessionList"; -import { type StringKey, useT } from "./i18n"; - -import { Icon } from "./icons/Icon"; -import { rowIconSize } from "./icons/iconSize"; -import { ContextMenu, type ContextMenuItem } from "../../ui/ContextMenu"; -import { listRowKeyboardProps } from "../../ui/listRowKeyboard"; -import { pressToOpen } from "../../ui/pressToOpen"; -import { AnchoredCard } from "../../ui/Tooltip"; -import { HOVER_INTENT, HoverGroup, useHoverIntent } from "../../ui/hoverIntent"; -import { useSidebarPreviewVariant, type SidebarPreviewVariant } from "./sidebarPreview"; - -const MARK_LABELS = { - input: "sidebar.markInput", - running: "sidebar.markRunning", - error: "sidebar.markError", - unread: "sidebar.markUnread", -} as const satisfies Record, StringKey>; -/** Sidebar rows set 13px text, so their icons draw at the registry's row size for it. */ -const ROW_ICON = rowIconSize(13); -/** The rail's icon-only buttons keep the 18px glyphs' footprint: a 15px row crop inks about 14px. */ -const RAIL_ICON = 15; -const MARK_GLYPHS: Record, React.ReactNode> = { - input: , - running: , - error: , - unread: null, -}; -/** Where a session runs is secondary to its title, so its glyph takes the caption size (11px text). */ -const PLACE_ICON = rowIconSize(11); -const PLACE_GLYPHS = { - cloud: , - worktree: , - branch: , -}; -const PLACE_LABELS = { - cloud: "sidebar.runsOn", - worktree: "sidebar.worktree", - branch: "sidebar.branch", -} as const satisfies Record; - -/** Sidebar previews are one warm hover-card group, so moving between rows does not replay the dwell. */ -const sessionPreviewGroup = new HoverGroup(HOVER_INTENT.cardGrace); - -/** What the rail switches the list to. */ -export type SidebarView = "sessions" | "history" | "pulls" | "closed"; -export type SidebarAccount = { name: string; detail?: string }; - -const VIEW_TITLES = { - history: "sidebar.history", - pulls: "sidebar.pulls", - closed: "sidebar.closed", -} as const satisfies Record, StringKey>; - -/** Sessions already open in a tab, when the list is a history layer beside them. */ -const OpenSessions = createContext | undefined>(undefined); - -/** The pane's sidebar: an icon rail, the list it switches, and the account at the bottom. */ -export function SessionSidebar({ - sessions, - settledSessions = [], - selectedId, - openIds, - onSelect, - onNewChat, - account, - preview = false, - previewVariant, - groupProjects = false, - onContinue, - onSettle, - onShowMoreSettled, -}: { - sessions: AcpmuxSessionEntry[]; - /** Read-only history from the `_acpmux/chat_settled` seam. */ - settledSessions?: AcpmuxSessionEntry[]; - selectedId?: string; - /** Sessions already open in a tab; their rows say so, and opening one jumps to it. */ - openIds?: ReadonlySet; - onSelect: (sessionId: string) => void; - onNewChat?: () => void; - account?: SidebarAccount; - /** Preview features are on (`labs.previewFeatures`): show Pull requests and session hover cards. */ - preview?: boolean; - /** DEV/NIGHTLY visual trial; defaults to the shared `card` treatment. */ - previewVariant?: SidebarPreviewVariant; - /** Preserves project grouping as an option while keeping the default compact and ungrouped. */ - groupProjects?: boolean; - onContinue?: (sessionIds: string[]) => void; - onSettle?: (sessionIds: string[]) => void; - onShowMoreSettled?: () => void; -}) { - const t = useT(); - const activePreviewVariant = useSidebarPreviewVariant(previewVariant); - const [picked, setView] = useState("sessions"); - // Pull requests turned off while shown falls back to the session list, and stays there. - const view = picked === "pulls" && !preview ? "sessions" : picked; - useEffect(() => { - if (!preview) setView((current) => (current === "pulls" ? "sessions" : current)); - }, [preview]); - // Kept here so expanded projects and a search survive a trip to another rail view. - const [expanded, setExpanded] = useState>(new Set()); - const [query, setQuery] = useState(""); - const needsInput = useMemo( - () => sessions.some((session) => sessionMark(session, session.sessionId === selectedId) === "input"), - [sessions, selectedId], - ); - return ( - - - - ); -} - -function RailButton({ - label, - title, - icon, - current, - dot, - onClick, -}: { - label: string; - title?: string; - /** The registry name of the button's glyph. */ - icon: string; - current?: boolean; - dot?: boolean; - onClick?: () => void; -}) { - const t = useT(); - const keyboard = listRowKeyboardProps(".acpmux-rail-button:not(:disabled)", (row) => row.click()); - return ( - - ); -} - -/** Pinned sessions, then every other acpmux session grouped by folder, newest first. */ -function SessionsView({ - sessions, - settledSessions, - selectedId, - onSelect, - onNewChat, - query, - onQuery, - expanded, - onExpand, - groupProjects, - preview, - previewVariant, - onContinue, - onSettle, - onShowMoreSettled, -}: { - sessions: AcpmuxSessionEntry[]; - settledSessions: AcpmuxSessionEntry[]; - selectedId?: string; - onSelect: (sessionId: string) => void; - onNewChat?: () => void; - query: string; - onQuery: (query: string) => void; - expanded: Set; - onExpand: (groupKey: string) => void; - groupProjects: boolean; - preview: boolean; - previewVariant: SidebarPreviewVariant; - onContinue?: (sessionIds: string[]) => void; - onSettle?: (sessionIds: string[]) => void; - onShowMoreSettled?: () => void; -}) { - const t = useT(); - const newChat = onNewChat && ( - - ); - const searching = query.trim() !== ""; - const { pinned, groups, active } = useMemo( - () => sidebarSections(sessions, query, groupProjects), - [sessions, query, groupProjects], - ); - const settled = useMemo(() => sortByRecency(filterSessions(settledSessions, query)), [settledSessions, query]); - // Grouped by project, the shown chats are in `groups` and `active` is empty. - const noMatches = pinned.length === 0 && active.length === 0 && groups.length === 0 && settled.length === 0; - if (sessions.length === 0 && settledSessions.length === 0) - return ( - <> - {newChat} -
{t("sidebar.noSessions")}
- - ); - // Escape clears a query first; with the field empty it reaches the overlay, which closes. - const onSearchKey = (event: React.KeyboardEvent) => { - if (event.key !== "Escape") return; - // During IME composition Escape cancels the composition, and closes nothing. - // WebKit can end the composition before this keydown, which then reports only keyCode 229. - const composing = event.nativeEvent.isComposing || event.nativeEvent.keyCode === 229; - if (!composing && !query) return; - event.stopPropagation(); - if (!composing) onQuery(""); - }; - return ( - <> - {newChat} - - - - {/* Always present, so a screen reader announces the text when it appears. */} - {noMatches ? t("sidebar.noMatches") : ""} - {pinned.length > 0 && ( -
- -
    - {pinned.map((session) => ( - - ))} -
-
- )} - {active.length > 0 && !groupProjects && ( -
- -
    - {active.map((session) => ( - - ))} -
-
- )} - {groups.length > 0 && groupProjects && ( -
- - {groups.map((group) => { - // A search shows every match, so it never hides rows behind "Show more". - const { rows, hidden } = visibleSessions(group, searching || expanded.has(group.key), selectedId); - const mark = groupMark(group, selectedId); - return ( -
-
- - {group.label} - {group.host && {group.host}} - {mark && ( - - {MARK_GLYPHS[mark]} - {t(MARK_LABELS[mark])} - - )} -
-
    - {rows.map((session) => ( - - ))} -
- {hidden > 0 && ( - - )} -
- ); - })} -
- )} - {/* With nothing matching, the line above says so once; an empty Settled would repeat it. */} - {!noMatches && ( -
- - {settled.length === 0 ? ( -
- {settledSessions.length > 0 && query.trim() ? t("sidebar.noMatches") : t("sidebar.noSettled")} -
- ) : ( -
    - {settled.map((session) => ( - - ))} -
- )} - {onShowMoreSettled && settled.length > 0 && ( - - )} -
- )} - - ); -} - -/** History, pull requests and closed sessions: one list, newest first. */ -function FlatView({ - view, - sessions, - selectedId, - onSelect, - preview, - previewVariant, -}: { - view: Exclude; - sessions: AcpmuxSessionEntry[]; - selectedId?: string; - onSelect: (sessionId: string) => void; - preview: boolean; - previewVariant: SidebarPreviewVariant; -}) { - const t = useT(); - // Pull requests appear once the session summary reports them; until then the view says so. - const rows = useMemo( - () => - view === "pulls" - ? [] - : sessions - .filter((session) => view === "history" || session.status === "closed") - .sort((left, right) => (activityTime(right) ?? 0) - (activityTime(left) ?? 0)), - [sessions, view], - ); - const now = Date.now(); - return ( -
-

{t(VIEW_TITLES[view])}

- {rows.length === 0 ? ( -
{view === "pulls" ? t("sidebar.noPulls") : t("sidebar.nothing")}
- ) : ( -
    - {rows.map((session) => ( - - ))} -
- )} -
- ); -} - -const SessionRow = memo(function SessionRow({ - session, - preview, - previewVariant, - selected, - groupHost, - onSelect, - flat, - trailing, - section, - onContinue, - onSettle, -}: { - session: AcpmuxSessionEntry; - preview: boolean; - previewVariant: SidebarPreviewVariant; - selected: boolean; - groupHost?: string; - onSelect: (sessionId: string) => void; - flat?: boolean; - trailing?: string; - section?: "active" | "settled"; - onContinue?: (sessionIds: string[]) => void; - onSettle?: (sessionIds: string[]) => void; -}) { - const t = useT(); - const open = useContext(OpenSessions)?.has(session.sessionId); - const mark = sessionMark(session, selected); - const title = session.displayTitle || session.sessionId.slice(0, 8); - const place = sessionPlace(session, groupHost); - const previewText = preview ? session.preview?.trim() : undefined; - const previewId = useId(); - const previewIntent = useHoverIntent({ delay: HOVER_INTENT.cardDwell, group: sessionPreviewGroup }); - const { dismiss: dismissPreview } = previewIntent; - const previewLeaveTimer = useRef | undefined>(undefined); - const [placedPreview, setPlacedPreview] = useState(null); - useEffect(() => { - if (previewIntent.shown) setPlacedPreview(previewIntent.shown); - }, [previewIntent.shown]); - useEffect(() => { - if (!previewText) { - clearTimeout(previewLeaveTimer.current); - previewLeaveTimer.current = undefined; - dismissPreview(); - } - }, [dismissPreview, previewText]); - useEffect(() => () => clearTimeout(previewLeaveTimer.current), []); - const cancelPreviewLeave = () => { - clearTimeout(previewLeaveTimer.current); - previewLeaveTimer.current = undefined; - }; - const hoverPreview = (anchor: HTMLElement, delay?: number) => { - cancelPreviewLeave(); - previewIntent.hover(anchor, delay); - }; - const leavePreviewRow = () => { - cancelPreviewLeave(); - if (!previewIntent.shown) { - previewIntent.hover(null); - return; - } - previewLeaveTimer.current = setTimeout(() => { - previewLeaveTimer.current = undefined; - previewIntent.hover(null); - }, HOVER_INTENT.cardGrace); - }; - const leavePreviewCard = () => { - cancelPreviewLeave(); - previewIntent.hover(null); - }; - const dismissPreviewNow = () => { - cancelPreviewLeave(); - dismissPreview(); - }; - const keyboard = listRowKeyboardProps(".acpmux-session-row", (row) => { - const sessionId = row.dataset.sessionId; - if (sessionId) onSelect(sessionId); - }); - const placeLabel = - place && - `${t(PLACE_LABELS[place.kind])} ${place.label}${place.branch ? `, ${t(PLACE_LABELS.branch)} ${place.branch}` : ""}`; - // A chat whose sub-session waits for a folder's trust says so, not just "Needs your input". - const markKey: StringKey | undefined = - mark && (mark === "input" && session.trustPending ? "sidebar.markTrust" : MARK_LABELS[mark]); - const previewStatus = markKey ? t(markKey) : undefined; - const menuItems: ContextMenuItem[] = []; - if (section === "settled" && onContinue) { - menuItems.push({ - id: "continue", - label: t("sidebar.continue"), - onSelect: () => onContinue([session.sessionId]), - }); - } - if (section === "active" && onSettle) { - menuItems.push({ - id: "settle", - label: t("sidebar.settle"), - onSelect: () => onSettle([session.sessionId]), - }); - } - const row = ( - - ); - return ( -
  • - {menuItems.length > 0 ? {row} : row} - {previewText && placedPreview && ( - -
    {title}
    -
    {previewText}
    - {(previewStatus || placeLabel) && ( -
    - {previewStatus && ( - - {mark && MARK_GLYPHS[mark]} - {previewStatus} - - )} - {placeLabel && ( - - {place && PLACE_GLYPHS[place.kind]} - {placeLabel} - - )} -
    - )} -
    - )} -
  • - ); -}); diff --git a/webviews/src/agent-session/acpmux/prototype/WorkspaceShell.tsx b/webviews/src/agent-session/acpmux/prototype/WorkspaceShell.tsx index 123fb5d16e37..9d755b961441 100644 --- a/webviews/src/agent-session/acpmux/prototype/WorkspaceShell.tsx +++ b/webviews/src/agent-session/acpmux/prototype/WorkspaceShell.tsx @@ -1,5 +1,5 @@ // Sidebar prototype (#16688), dev server only: prototype.html. One stack of open workspaces is the -// main sidebar; agent history (the pane's project-grouped session list) is a layer opened from the +// main sidebar; agent history (a plain list of the fixture's sessions) is a layer opened from the // rail, and opening a row there jumps to the tab already showing it instead of opening a copy. // Dots at the bottom switch spaces, and links from a terminal open in the mini window // until Cmd-O promotes them into the workspace. Rows are minimal unless `sidebar.rowDetail` (the @@ -7,8 +7,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { AcpmuxApp } from "../App"; import { mockSessions, sessionSummary } from "../mockFixture"; -import { SessionSidebar } from "../SessionSidebar"; -import { sessionEntry, sessionMark, type AcpmuxSessionEntry } from "../sessionList"; +import { sessionEntry, sessionMark, sessionTitle, type AcpmuxSessionEntry } from "../sessionList"; import { Icon } from "../icons/Icon"; import { rowIconSize } from "../icons/iconSize"; import { @@ -341,12 +340,24 @@ export function WorkspaceShell() { - +
      + {history.map((session) => { + const open = openIds.has(session.sessionId); + return ( +
    • + +
    • + ); + })} +
    )} diff --git a/webviews/src/agent-session/acpmux/prototype/prototype.css b/webviews/src/agent-session/acpmux/prototype/prototype.css index 9f1d4be9cc88..3fbf4ecb7a66 100644 --- a/webviews/src/agent-session/acpmux/prototype/prototype.css +++ b/webviews/src/agent-session/acpmux/prototype/prototype.css @@ -421,31 +421,53 @@ body { .proto-history header button:hover { background: color-mix(in srgb, var(--agent-text) 6%, transparent); } -.proto-history .acpmux-sidebar { - display: flex; - position: static; +.proto-history-list { flex: 1; min-height: 0; - width: auto; + margin: 0; + padding: 4px 8px 16px; + list-style: none; + overflow-y: auto; +} +.proto-history-row { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + height: 30px; + padding: 0 8px; border: 0; + border-radius: 7px; background: none; - box-shadow: none; + color: var(--agent-soft, var(--agent-muted)); + font: inherit; + font-size: var(--text-body); + text-align: start; } -.proto-history .acpmux-sidebar-scroll { - padding: 4px 8px 16px; +.proto-history-row > span { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.proto-history-row:hover { + background: color-mix(in srgb, var(--agent-text) 6%, transparent); +} +.proto-history-row[aria-current] { + background: color-mix(in srgb, var(--agent-text) 10%, transparent); } /* Rows already open in a tab read at full strength and say so; the rest are history. */ -.proto-history .acpmux-session-row:not(.is-open) { - color: var(--agent-soft, var(--agent-muted)); +.proto-history-row.is-open { + color: var(--agent-text); } -.proto-history .acpmux-session-row.is-open::after { - content: "Open"; +.proto-history-row > small { flex: none; padding: 0 5px; border: var(--cmux-hairline, 0.5px) solid var(--agent-border); border-radius: 4px; color: var(--agent-muted); - font-size: 11px; + font-size: var(--text-caption); line-height: 16px; } @@ -540,12 +562,6 @@ body { .proto-agent[hidden] { display: none; } -/* The pane's own session list is the history layer now. */ -.proto-agent .acpmux-sidebar, -.proto-agent .acpmux-sidebar-toggle, -.proto-agent .acpmux-sidebar-scrim { - display: none !important; -} .proto-terminal { height: 100%; margin: 0; @@ -794,12 +810,6 @@ body { backdrop-filter: none; } } -/* The history layer is the session list alone: the pane's own rail and New chat stay in the pane. */ -.proto-history .acpmux-sidebar-rail, -.proto-history .acpmux-sidebar-action, -.proto-history .acpmux-account { - display: none; -} /* Classic cmux (?style=terminal): the chrome takes the terminal's font and agent sessions are one kind among others, drawn as quietly as terminals and browsers. Translucency is the backdrop diff --git a/webviews/src/agent-session/acpmux/sessionList.ts b/webviews/src/agent-session/acpmux/sessionList.ts index b4a5d4d08acf..ff6c0eb3daca 100644 --- a/webviews/src/agent-session/acpmux/sessionList.ts +++ b/webviews/src/agent-session/acpmux/sessionList.ts @@ -89,14 +89,6 @@ export const text = (value: unknown) => (typeof value === "string" && value ? va /** "local" or "cloud", else undefined. */ export const hostKind = (value: unknown) => (value === "local" || value === "cloud" ? value : undefined); -export type SessionGroup = { - key: string; - label: string; - cwd?: string; - host?: string; - sessions: AcpmuxSessionEntry[]; -}; - /** The tag that pins a session to the top of the list. */ export const PINNED_TAG = "pinned"; /** The tag the chat menu's Archive sets. */ @@ -146,9 +138,6 @@ const INDICATORS: readonly string[] = ["input", "working", "failed", "lost", "do /** What a row draws at its right edge, most urgent first. */ export type SessionMark = "input" | "running" | "error" | "unread" | undefined; -/** A long group shows this many rows and a "Show more" (the TUI's GROUP_ROWS). */ -export const GROUP_ROWS = 6; - /** Cuts acpmux's session summary down to the fields the pane uses. */ export function sessionEntry(session: Record & { sessionId: string }): AcpmuxSessionEntry { const pending = Number(session.pendingPermissions ?? 0); @@ -345,71 +334,10 @@ function byRecency(sessions: AcpmuxSessionEntry[]): AcpmuxSessionEntry[] { return [...sessions].sort((left, right) => (activityTime(right) ?? 0) - (activityTime(left) ?? 0)); } -/** A stable newest-first view for ungrouped sidebar sections. */ -export function sortByRecency(sessions: AcpmuxSessionEntry[]): AcpmuxSessionEntry[] { - return byRecency(sessions); -} - -// Session entries are immutable snapshots. Reuse the assembled, case-folded search text while a -// user types so each sidebar keystroke does not rebuild every title/path/branch string. A weak map -// releases entries when the session snapshot is replaced. -const sessionSearchTextCache = new WeakMap(); - -function sessionSearchText(session: AcpmuxSessionEntry): string { - const cached = sessionSearchTextCache.get(session); - if (cached !== undefined) return cached; - const text = [ - session.displayTitle, - session.title, - session.name, - session.cwd && projectLabel(session.cwd), - session.branch, - session.worktree && projectLabel(session.worktree), - cloudHost(session), - ] - .filter(Boolean) - .join("\n") - .toLowerCase(); - sessionSearchTextCache.set(session, text); - return text; -} - -/** Sessions under one header per folder (a cloud-only folder per machine). Groups follow their most recent session; sessions stay newest first. */ -export function groupByProject( - sessions: AcpmuxSessionEntry[], - // Every session, pinned ones too, so pinning a local session doesn't regroup its cloud siblings. - all: AcpmuxSessionEntry[] = sessions, -): SessionGroup[] { - const folder = (session: AcpmuxSessionEntry) => (session.cwd ?? "").replace(/\/+$/, ""); - // A cloud session joins the local project at the same path; otherwise its folder is only - // known on its machine, so `/workspace` on two machines stays two projects. - const local = new Set(all.filter((session) => !cloudHost(session)).map(folder)); - const groups = new Map(); - for (const session of byRecency(sessions)) { - const cwd = folder(session); - const host = cloudHost(session); - const key = host && !local.has(cwd) ? `${host}:${cwd}` : cwd; - let group = groups.get(key); - if (!group) { - group = { key, label: projectLabel(cwd), cwd: cwd || undefined, sessions: [] }; - groups.set(key, group); - } - group.sessions.push(session); - } - for (const group of groups.values()) group.host = sharedCloudHost(group.sessions); - return [...groups.values()]; -} - /** The remote machine a session runs on. A host not marked local counts as remote; this Mac is never named. */ export const cloudHost = (session: AcpmuxSessionEntry) => session.hostKind === "local" ? undefined : (session.peer ?? session.host); -/** The one cloud machine every session in a group runs on, else undefined. */ -function sharedCloudHost(sessions: AcpmuxSessionEntry[]) { - const host = cloudHost(sessions[0]!); - return host && sessions.every((session) => cloudHost(session) === host) ? host : undefined; -} - /** Where a row says it runs, beyond its project: a cloud machine, then a worktree, then a branch. */ export type SessionPlace = | { @@ -427,55 +355,6 @@ export function sessionPlace(session: AcpmuxSessionEntry, groupHost?: string): S return undefined; } -/** Sessions whose title, name, folder, branch, worktree or cloud machine contains every word of the query, ignoring case. */ -export function filterSessions(sessions: AcpmuxSessionEntry[], query: string): AcpmuxSessionEntry[] { - const terms = query.toLowerCase().split(/\s+/).filter(Boolean); - if (terms.length === 0) return sessions; - return sessions.filter((session) => terms.every((term) => sessionSearchText(session).includes(term))); -} - -/** The list's two sections: pinned sessions, newest first, then every other session grouped by project. A query narrows both. Archived sessions and side chats are in neither. */ -export function sidebarSections( - all: AcpmuxSessionEntry[], - query = "", - groupProjects = true, -): { - pinned: AcpmuxSessionEntry[]; - groups: SessionGroup[]; - active: AcpmuxSessionEntry[]; -} { - const sessions = withoutChildren(all.filter(listed)); - const pinned = byRecency( - filterSessions( - sessions.filter((session) => session.pinned), - query, - ), - ); - const groups = groupProjects - ? groupByProject( - sessions.filter((session) => !session.pinned), - sessions, - ) - : []; - const active = groupProjects - ? [] - : byRecency( - filterSessions( - sessions.filter((session) => !session.pinned), - query, - ), - ); - if (!query.trim()) return { pinned, groups, active }; - // Projects and their headers come from every session, so a search only hides rows. - return { - pinned, - active, - groups: groups - .map((group) => ({ ...group, sessions: filterSessions(group.sessions, query) })) - .filter((group) => group.sessions.length > 0), - }; -} - /** The row's mark, from acpmux's `indicator`: a done chat is marked only until the user opens it. */ export function sessionMark(session: AcpmuxSessionEntry, selected: boolean): SessionMark { switch (session.indicator) { @@ -493,31 +372,6 @@ export function sessionMark(session: AcpmuxSessionEntry, selected: boolean): Ses } } -/** The rows a group shows: all of a short group, else its first GROUP_ROWS unless expanded or holding the selection. */ -export function visibleSessions( - group: SessionGroup, - expanded: boolean, - selectedId?: string, -): { rows: AcpmuxSessionEntry[]; hidden: number } { - const open = - expanded || - group.sessions.length <= GROUP_ROWS + 1 || - group.sessions.slice(GROUP_ROWS).some((session) => session.sessionId === selectedId); - if (open) return { rows: group.sessions, hidden: 0 }; - return { rows: group.sessions.slice(0, GROUP_ROWS), hidden: group.sessions.length - GROUP_ROWS }; -} - -/** A project header's mark: the most urgent of its sessions' needs-input and lost marks. */ -export function groupMark(group: SessionGroup, selectedId?: string): "input" | "error" | undefined { - let mark: "input" | "error" | undefined; - for (const session of group.sessions) { - const own = sessionMark(session, session.sessionId === selectedId); - if (own === "input") return "input"; - if (own === "error") mark = "error"; - } - return mark; -} - /** A compact age for the history list: `now`, `5m`, `3h`, `2d`, `6w`. */ export function shortAge(updatedAt: number | undefined, now: number, t: Translate = translate): string { if (updatedAt === undefined) return ""; diff --git a/webviews/src/agent-session/acpmux/styles.css b/webviews/src/agent-session/acpmux/styles.css index 6d478f18aa27..1e801ac331d8 100644 --- a/webviews/src/agent-session/acpmux/styles.css +++ b/webviews/src/agent-session/acpmux/styles.css @@ -2,7 +2,7 @@ .acpmux-stage{position:relative;flex:1;display:flex;flex-direction:column;min-height:0}.acpmux-reviewing>.acpmux-header,.acpmux-reviewing>.acpmux-scroll{visibility:hidden} .acpmux-diff-add{color:var(--acpmux-add)}.acpmux-diff-del{color:var(--acpmux-del)}.acpmux-diff-main{flex:1;display:flex;min-height:0}.acpmux-diff-body{flex:1;min-width:0;overflow-y:auto;overflow-x:hidden}.acpmux-diff-file{border-bottom:1px solid var(--agent-border)}.acpmux-fh-spacer{flex:1} -.acpmux-diff-empty-edit{padding:8px 16px;color:var(--agent-muted);font-size:12px}.acpmux-main{position:relative;flex:1;min-width:0;display:flex;flex-direction:column}.acpmux-header>div{display:flex;align-items:center;min-width:0}.acpmux-sidebar-toggle{position:relative;flex:none;width:28px;height:28px;margin-right:6px;border:0;border-radius:7px;background:none;cursor:default}.acpmux-sidebar-toggle:hover{background:var(--agent-control-hover)}.acpmux-sidebar-toggle:focus-visible{outline:2px solid color-mix(in srgb,var(--agent-text) 40%,transparent);outline-offset:0}.acpmux-sidebar-toggle::before{content:"";position:absolute;left:6px;top:7px;width:13px;height:11px;border:1.5px solid var(--agent-soft);border-radius:3px}.acpmux-sidebar-toggle::after{content:"";position:absolute;left:11px;top:8px;width:1.5px;height:11.5px;background:var(--agent-soft)}.acpmux-sidebar{flex:none;display:flex;width:292px;box-sizing:border-box;overflow:hidden;border-right:0.5px solid var(--agent-border);background:var(--agent-page-bg);font-size:13px;letter-spacing:0.04px}.acpmux-shell[data-sidebar=closed] .acpmux-sidebar{display:none}@media not all and (min-width:640px){.acpmux-shell[data-sidebar=auto] .acpmux-sidebar{display:none}.acpmux-shell[data-sidebar=open] .acpmux-sidebar{position:absolute;z-index:2;inset:0 auto 0 0;width:min(332px,90%);background:color-mix(in srgb,var(--acpmux-base) var(--surface-text-min,92%),transparent);-webkit-backdrop-filter:var(--surface-blur);backdrop-filter:var(--surface-blur);box-shadow:var(--elevation-2)}.acpmux-shell[data-sidebar=open] .acpmux-sidebar-scrim{display:block;position:absolute;z-index:1;inset:0;border:0;padding:0;background:var(--cmux-scrim, rgb(0 0 0 / 12%));cursor:default}}.acpmux-sidebar-scrim{display:none}.acpmux-sidebar-group+.acpmux-sidebar-group{margin-top:10px}.acpmux-sidebar-project{display:flex;align-items:center;gap:8px;height:30px;margin-bottom:1px;padding:0 8px 0 9px;color:var(--agent-text);white-space:nowrap}.acpmux-sidebar ul{margin:0;padding:0;list-style:none}.acpmux-session-row{display:flex;align-items:center;gap:8px;width:100%;height:30px;margin-bottom:1px;padding:0 8px 0 33px;border:0;border-radius:8px;background:none;color:var(--agent-text);font:inherit;letter-spacing:inherit;text-align:left;cursor:pointer}.acpmux-session-row:hover,.acpmux-sidebar-more:hover{background:color-mix(in srgb,var(--agent-text) 5%,transparent)}.acpmux-session-row.is-selected{background:color-mix(in srgb,var(--agent-accent,var(--agent-text)) 9%,transparent)}.acpmux-sidebar-more:focus-visible{outline:2px solid var(--agent-accent);outline-offset:-2px}.acpmux-session-row.is-closed{color:var(--agent-muted)}.acpmux-session-row-title{flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.acpmux-session-place{flex:none;display:inline-flex;color:var(--agent-muted);opacity:.75}.acpmux-session-place-branch{opacity:.5}.acpmux-session-row:hover .acpmux-session-place,.acpmux-session-row.is-selected .acpmux-session-place{opacity:1}.acpmux-session-mark{flex:none;display:inline-flex;align-items:center;justify-content:center;width:16px;height:16px;color:var(--agent-muted)}.acpmux-session-mark-input{color:var(--agent-warning,var(--agent-accent))}.acpmux-session-mark-labeled{width:auto;gap:4px;font-size:var(--text-detail);font-weight:var(--weight-emphasis);white-space:nowrap}.acpmux-session-mark-unread::before{content:"";width:7px;height:7px;border-radius:50%;background:var(--agent-accent)}.acpmux-session-mark-running{color:var(--agent-accent)}.acpmux-sidebar-more{display:block;width:100%;height:30px;margin-bottom:1px;padding:0 8px 0 33px;border:0;border-radius:8px;background:none;color:var(--agent-muted);font:inherit;letter-spacing:inherit;text-align:left;cursor:pointer}.acpmux-sidebar-section{height:30px;padding-left:8px;line-height:30px;color:var(--agent-muted);font-weight:500}.acpmux-sidebar-pinned .acpmux-session-row{padding-left:9px}.acpmux-sidebar-pinned+.acpmux-sidebar-projects{margin-top:12px}.acpmux-sidebar-host{flex:none;max-width:45%;overflow:hidden;text-overflow:ellipsis;color:var(--agent-muted);font-size:12px}.acpmux-sidebar-search{display:flex;align-items:center;gap:8px;height:30px;margin:0 0 10px;padding:0 8px 0 9px;border-radius:8px;background:color-mix(in srgb,var(--agent-text) 5%,transparent);color:var(--agent-muted)}.acpmux-sidebar-search:focus-within{box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--agent-accent) 55%,transparent)}.acpmux-sidebar-search>label{display:contents}.acpmux-sidebar-search svg{flex:none}.acpmux-sidebar-search input{flex:1;min-width:0;height:100%;padding:0;border:0;outline:none;background:none;color:var(--agent-text);font:inherit;letter-spacing:inherit;-webkit-appearance:none;appearance:none}.acpmux-sidebar-search input::placeholder{color:var(--agent-muted)}.acpmux-sidebar-search input::-webkit-search-cancel-button{-webkit-appearance:none;appearance:none}.acpmux-sidebar-empty:empty{padding:0}.acpmux-sidebar-empty{padding:6px 9px;color:var(--agent-muted)} +.acpmux-diff-empty-edit{padding:8px 16px;color:var(--agent-muted);font-size:12px}.acpmux-main{position:relative;flex:1;min-width:0;display:flex;flex-direction:column}.acpmux-header>div{display:flex;align-items:center;min-width:0}.acpmux-session-mark{flex:none;display:inline-flex;align-items:center;justify-content:center;width:16px;height:16px;color:var(--agent-muted)}.acpmux-session-mark-input{color:var(--agent-warning,var(--agent-accent))}.acpmux-session-mark-unread::before{content:"";width:7px;height:7px;border-radius:50%;background:var(--agent-accent)}.acpmux-session-mark-running{color:var(--agent-accent)} /* Composer (Composer.tsx, ComposerPickers.tsx), with metrics and font from the reference prototype (src/shell Composer): the prompt over a bar with +, the mode and model chips, and a round Send. Every color derives from the theme bridge: each surface is the page @@ -250,8 +250,7 @@ .acpmux-queue-paused>span{grid-column:1/3} .acpmux-queue-resume{grid-column:3;justify-self:end;height:22px;padding:0 8px;border:0;border-radius:6px;background:none;color:var(--agent-text);font:inherit;cursor:default} .acpmux-queue-resume:hover{background:var(--agent-control-hover)} -.acpmux-queue-resume:focus-visible{outline:1.5px solid var(--agent-text);outline-offset:-1px} -.acpmux-sidebar-project>svg{flex:none;color:var(--agent-muted)}.acpmux-sidebar-project>span:not(.acpmux-session-mark){flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis}.acpmux-sidebar-rail{flex:0 0 48px;display:flex;flex-direction:column;align-items:center;gap:4px;padding:10px 0;box-sizing:border-box}.acpmux-rail-button{position:relative;display:grid;place-items:center;width:34px;height:34px;padding:0;border:0;border-radius:9px;background:none;color:var(--agent-muted);cursor:default}.acpmux-rail-button:hover:not(:disabled){background:color-mix(in srgb,var(--agent-text) 6%,transparent);color:var(--agent-text)}.acpmux-rail-button[aria-current=page]{background:color-mix(in srgb,var(--agent-text) 9%,transparent);color:var(--agent-text)}.acpmux-rail-button:focus-visible,.acpmux-sidebar-action:focus-visible{outline:2px solid color-mix(in srgb,var(--agent-text) 40%,transparent);outline-offset:-2px}.acpmux-rail-button:disabled{opacity:.4}.acpmux-rail-dot{position:absolute;top:6px;right:6px;width:6px;height:6px;border-radius:50%;background:var(--agent-warning,var(--agent-accent))}.acpmux-sidebar-body{flex:1;min-width:0;display:flex;flex-direction:column}.acpmux-sidebar-scroll{flex:1;min-height:0;overflow:auto;padding:10px 8px 16px 0}.acpmux-sidebar-action{display:flex;align-items:center;gap:8px;width:100%;height:30px;margin-bottom:6px;padding:0 9px;border:0;border-radius:8px;background:none;color:var(--agent-text);font:inherit;letter-spacing:inherit;cursor:default}.acpmux-sidebar-action svg{flex:none;color:var(--agent-soft)}.acpmux-sidebar-action:hover{background:color-mix(in srgb,var(--agent-text) 5%,transparent)}.acpmux-sidebar-title{display:flex;align-items:center;height:30px;margin:0 0 6px;padding:0 9px;font-size:13px;font-weight:600}.acpmux-session-row.is-flat{padding-left:9px}.acpmux-session-trailing{flex:none;color:var(--agent-muted);font-size:12px;font-variant-numeric:tabular-nums}.acpmux-account{flex:none;display:flex;align-items:center;gap:8px;height:48px;padding:0 17px 0 9px}.acpmux-avatar{flex:none;display:grid;place-items:center;width:24px;height:24px;border-radius:50%;background:color-mix(in srgb,var(--agent-text) 14%,transparent);color:var(--agent-text);font-size:12px;font-weight:600}.acpmux-account-name{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:500}.acpmux-account-detail{flex:none;color:var(--agent-muted)}.acpmux-account-detail::before{content:"ยท "}.acpmux-hidden-label{position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%);white-space:nowrap} +.acpmux-queue-resume:focus-visible{outline:1.5px solid var(--agent-text);outline-offset:-1px}.acpmux-hidden-label{position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%);white-space:nowrap} /* New chat (EmptyState.tsx): a centered hero, small and muted, in place of the empty transcript. */ .acpmux-empty{flex:1;min-height:0;overflow:hidden;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:14px;padding:0 24px 6%;box-sizing:border-box;text-align:center;font-family:system-ui,-apple-system,"Helvetica Neue",sans-serif;-webkit-font-smoothing:antialiased} .acpmux-empty-glyph{color:var(--acpmux-composer-placeholder)} @@ -275,7 +274,6 @@ /* Search files: a quick-open palette, centered over the transcript's upper part (main column). */ /* Quick open drops from the top as it opens; the narrow pane's sidebar drawer slides in from the left, its own width (ui-enter, ui/popupSurface.css). */ .acpmux-file-search{--ui-enter-y:-8px;animation:ui-enter var(--ui-enter-duration) var(--ui-enter-ease) both} -@media not all and (min-width:640px){.acpmux-shell[data-sidebar=open] .acpmux-sidebar{--ui-enter-x:-100%;animation:ui-enter var(--ui-enter-duration) var(--ui-enter-ease) both}} .acpmux-file-search{position:absolute;z-index:20;top:max(48px,18%);left:50%;right:auto;margin:0;border:0;width:min(600px,calc(100vw - 32px));transform:translateX(-50%);padding:6px;box-sizing:border-box;border-radius:14px;background:var(--surface-popup);box-shadow:inset 0 0 0 var(--cmux-hairline, .5px) var(--acpmux-composer-edge),var(--elevation-2);color:var(--agent-text);font-size:14px} .acpmux-file-search input{display:block;width:100%;height:34px;padding:0 10px;box-sizing:border-box;border:0;background:none;color:var(--agent-text);font:inherit;outline:none} .acpmux-file-search input::placeholder{color:var(--acpmux-composer-dim)} @@ -775,18 +773,6 @@ fieldset.acpmux-question[hidden]{display:none} /* Session row previews float in the shared popup layer, so the 30px row and its gutters never change when a last-message card appears. */ -.acpmux-session-preview{display:grid;gap:6px;min-width:220px;max-width:300px;padding:10px 12px;color:var(--ui-popup-fg,var(--agent-text));} -.ui-card.acpmux-session-preview{pointer-events:auto;} -.acpmux-session-preview-title{min-width:0;overflow:hidden;color:var(--agent-text);font-size:var(--text-body);line-height:var(--text-body--line-height);font-weight:var(--weight-emphasis);text-overflow:ellipsis;white-space:nowrap;} -.acpmux-session-preview-copy{max-width:34ch;color:var(--agent-soft);font-size:var(--text-detail);line-height:var(--text-detail--line-height);overflow-wrap:anywhere;} -.acpmux-session-preview-meta{display:flex;flex-wrap:wrap;gap:8px;color:var(--agent-muted);font-size:var(--text-caption);line-height:var(--text-caption--line-height);} -.acpmux-session-preview-status,.acpmux-session-preview-place{display:inline-flex;align-items:center;gap:4px;min-width:0;} -.acpmux-session-preview-status.acpmux-session-mark-input{color:var(--agent-warning,var(--agent-accent));} -.acpmux-session-preview-status.acpmux-session-mark-running{color:var(--agent-accent);} -.ui-card.acpmux-session-preview-quiet{min-width:204px;max-width:270px;gap:4px;padding:8px 10px;border:0.5px solid color-mix(in srgb,currentColor 14%,transparent);background:color-mix(in srgb,var(--surface-popup) 88%,var(--agent-page-bg));box-shadow:none;} -.acpmux-session-preview-quiet .acpmux-session-preview-title{font-size:var(--text-control);line-height:var(--text-control--line-height);} -.acpmux-session-preview-quiet .acpmux-session-preview-copy{max-width:30ch;} -.ui-card.acpmux-session-preview-outline{min-width:220px;max-width:300px;padding:10px 12px;border:0.5px solid color-mix(in srgb,var(--agent-accent) 34%,transparent);background:color-mix(in srgb,var(--agent-page-bg) 92%,var(--agent-text) 8%);box-shadow:none;} /* The host's all-zero motion policy is stronger than the browser preference: it is the explicit `ui.animationSpeed: off` setting. Stop every pane animation, including loops owned by a child diff --git a/webviews/src/agent-session/acpmux/summary/cardState.ts b/webviews/src/agent-session/acpmux/summary/cardState.ts index 1d8c48992f7c..ff3317cdeed1 100644 --- a/webviews/src/agent-session/acpmux/summary/cardState.ts +++ b/webviews/src/agent-session/acpmux/summary/cardState.ts @@ -75,9 +75,8 @@ export function useSummaryColumn(shell: HTMLElement | null, blocked: boolean): b return; } const measure = () => { - const sidebar = shell.querySelector(":scope>.acpmux-sidebar"); const rail = shell.querySelector(":scope>.acpmux-chat-inspector"); - const reserved = (sidebar?.offsetWidth ?? 0) + (rail?.offsetWidth ?? 0); + const reserved = rail?.offsetWidth ?? 0; const available = shell.clientWidth - reserved; // The summary takes 300px from the same flex row as the transcript. At a width where that // would erase the active mode's extra measure, keep it as a popover. The preceding mode's diff --git a/webviews/test/chat-rows-open-on-press.test.tsx b/webviews/test/chat-rows-open-on-press.test.tsx index 6653c1fb673f..f44b9f6e832e 100644 --- a/webviews/test/chat-rows-open-on-press.test.tsx +++ b/webviews/test/chat-rows-open-on-press.test.tsx @@ -89,34 +89,6 @@ const keyboardClick = (element: Element) => const settle = () => act(async () => new Promise((resolve) => setTimeout(resolve, 0))); -test("a chat row in the agent pane's sidebar opens on the press, once", async () => { - const { createRoot } = await import("react-dom/client"); - const { SessionSidebar } = await import("../src/agent-session/acpmux/SessionSidebar"); - const opened: string[] = []; - const session = { - sessionId: "session-1", - title: "Fix the checkout", - harness: "codex", - cwd: "/Users/you/src/cmux", - status: "running", - indicator: "working" as const, - host: "This Mac", - hostKind: "local" as const, - updatedAt: 1, - }; - root = createRoot(host); - await act(async () => - root!.render( opened.push(id)} />), - ); - const row = host.querySelector(".acpmux-session-row")!; - await press(row); - expect(opened).toEqual(["session-1"]); - await release(row); - expect(opened).toEqual(["session-1"]); - await keyboardClick(row); - expect(opened).toEqual(["session-1", "session-1"]); -}); - const chats = [ { key: "a", harness: "claude", title: "Retry uploads", cwd: "/Users/me/src/uploader", updatedAt: 9 }, { key: "b", harness: "claude", title: "Fix the sidebar", cwd: "/Users/me/src/cmux", updatedAt: 8 }, diff --git a/webviews/test/chats-sidebar-empty.test.tsx b/webviews/test/chats-sidebar-empty.test.tsx deleted file mode 100644 index d75578b1153a..000000000000 --- a/webviews/test/chats-sidebar-empty.test.tsx +++ /dev/null @@ -1,122 +0,0 @@ -// POLISH sweep: the chats sidebar's empty states say "chats", like every other chats list, and a -// search that matches nothing says so once, not again under Settled. -import { afterAll, beforeAll, expect, test } from "bun:test"; -import { JSDOM } from "jsdom"; -import { act } from "react"; -import type { Root } from "react-dom/client"; -import type { AcpmuxSessionEntry } from "../src/agent-session/acpmux/sessionList"; - -const scope = globalThis as Record; -const keys = ["window", "document", "navigator", "HTMLElement", "IS_REACT_ACT_ENVIRONMENT"] as const; -const saved = keys.map((key) => [key, key in scope, scope[key]] as const); -let root: Root | undefined; -let host: HTMLElement; - -beforeAll(() => { - const { window } = new JSDOM("
    "); - Object.assign(window, { - ResizeObserver: class { - observe() {} - unobserve() {} - disconnect() {} - }, - }); - Object.assign(scope, { - window, - document: window.document, - navigator: window.navigator, - HTMLElement: window.HTMLElement, - IS_REACT_ACT_ENVIRONMENT: true, - }); - host = window.document.getElementById("root")!; -}); - -afterAll(() => { - act(() => root?.unmount()); - for (const [key, had, value] of saved) { - if (had) scope[key] = value; - else delete scope[key]; - } -}); - -const chat = (sessionId: string, title: string): AcpmuxSessionEntry => ({ - sessionId, - displayTitle: title, - title, - harness: "claude", - cwd: "/tmp/cmux", - updatedAt: 1, -}); - -async function sidebar( - sessions: ReturnType[], - settledSessions: ReturnType[], - groupProjects = false, -) { - const { createRoot } = await import("react-dom/client"); - const { SessionSidebar } = await import("../src/agent-session/acpmux/SessionSidebar"); - act(() => root?.unmount()); - root = createRoot(host); - await act(async () => - root!.render( - {}} - />, - ), - ); -} - -async function search(text: string) { - const field = host.querySelector('input[type="search"]')!; - const setter = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(field), "value")!.set!; - await act(async () => { - setter.call(field, text); - field.dispatchEvent(new window.Event("input", { bubbles: true })); - }); -} - -test("a search that matches nothing says so once", async () => { - await sidebar([chat("a", "Fix the build")], [chat("b", "Polish the home screen")]); - await search("zzz"); - const said = host.textContent!.split("No matching chats").length - 1; - expect(said).toBe(1); - expect(host.textContent).not.toContain("sessions"); - expect(host.querySelector('input[type="search"]')!.getAttribute("aria-label")).toBe("Search chats"); -}); - -// Grouped by project, the chats live in groups, not the flat Active list; they count as matches. -test("chats grouped by project are not called no matches", async () => { - await sidebar([chat("a", "Fix the build"), chat("c", "Ship the docs")], [], true); - expect(host.textContent).toContain("Fix the build"); - expect(host.textContent).not.toContain("No matching chats"); - expect(host.querySelector(".acpmux-sidebar-settled")).not.toBeNull(); - await search("docs"); - expect(host.textContent).toContain("Ship the docs"); - expect(host.textContent).not.toContain("No matching chats"); - await search("zzz"); - expect(host.textContent!.split("No matching chats").length - 1).toBe(1); -}); - -test("an empty sidebar says there are no chats yet", async () => { - await sidebar([], []); - expect(host.textContent).toContain("No chats yet"); -}); - -test("the default sidebar hides archived, side, and folded child chats", async () => { - await sidebar( - [ - chat("parent", "Parent chat"), - { ...chat("child", "Folded child"), spawnedBy: "parent" }, - { ...chat("archived", "Archived chat"), archived: true }, - { ...chat("side", "Side question"), side: true }, - ], - [], - ); - expect(host.textContent).toContain("Parent chat"); - expect(host.textContent).not.toContain("Folded child"); - expect(host.textContent).not.toContain("Archived chat"); - expect(host.textContent).not.toContain("Side question"); -}); diff --git a/webviews/test/gallery-agent-pane-entries.test.ts b/webviews/test/gallery-agent-pane-entries.test.ts index 409f9c66cab3..4d22ed7a5846 100644 --- a/webviews/test/gallery-agent-pane-entries.test.ts +++ b/webviews/test/gallery-agent-pane-entries.test.ts @@ -37,8 +37,6 @@ await requireBrowserLane("gallery-agent-pane-entries.test.ts", async () => { (await count(page, ".acpmux-composer")) > 0 && (await count(page, "[data-harness-notice]")) === 0; const CASES: Case[] = [ - { entry: "session-sidebar", variant: "search-filtered", play: "steps" }, - { entry: "session-sidebar", variant: "search-clears", play: "steps" }, { entry: "start-folder", variant: "home-question", play: "steps" }, { entry: "start-folder", variant: "root-refused", play: "steps" }, { entry: "permission-panel", variant: "allowance", play: "error" }, diff --git a/webviews/test/row-focus.test.ts b/webviews/test/row-focus.test.ts index feb1acf3012f..1f86b6b1e2e8 100644 --- a/webviews/test/row-focus.test.ts +++ b/webviews/test/row-focus.test.ts @@ -95,20 +95,6 @@ await requireBrowserLane("row-focus.test.ts", async () => { return `${computed.backgroundColor} ${computed.backgroundImage} ${computed.outlineStyle}`; }); - test("a selected session row still changes when it takes keyboard focus, with no ring", async () => { - const page = await browser.newPage(); - await page.setContent(` -
    -`); - const resting = await look(page, "#session"); - await page.keyboard.press("Tab"); - expect(await page.evaluate(() => document.activeElement?.id)).toBe("session"); - const focused = await look(page, "#session"); - expect(focused).not.toBe(resting); - expect(focused.endsWith(" none")).toBe(true); - await page.close(); - }); - test("a checked question row still shows the keyboard highlight", async () => { const page = await browser.newPage(); const row = (id: string, highlighted: boolean) => diff --git a/webviews/test/selected-row-accent.test.ts b/webviews/test/selected-row-accent.test.ts index f8f3b6a25ad6..73d11a07795a 100644 --- a/webviews/test/selected-row-accent.test.ts +++ b/webviews/test/selected-row-accent.test.ts @@ -1,4 +1,4 @@ -// Selected rows in the agent pane (New Tab's rows and chats list, the omnibar, the session list and +// Selected rows in the agent pane (New Tab's rows and chats list, the omnibar and // checked question rows) tint with the theme accent when `appearance.accent` sets one. With the // default neutral accent the host sends the text color as `--agent-accent`, so the tint is the // same as before. The row text stays the text color either way. @@ -39,7 +39,6 @@ await requireBrowserLane("selected-row-accent.test.ts", async () => { ["New Tab row", `
    Fix the build
    `], ["New Tab chats row", `
    Fix the build
    `], ["omnibar row", `
    Fix the build
    `], - ["session row", `
    Fix the build
    `], [ "checked question mark", `
    `, diff --git a/webviews/test/session-list-grouping.test.ts b/webviews/test/session-list-grouping.test.ts deleted file mode 100644 index 2501248c1fab..000000000000 --- a/webviews/test/session-list-grouping.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { expect, test } from "bun:test"; -import { sidebarSections } from "../src/agent-session/acpmux/sessionList"; - -const session = (id: string, cwd: string) => ({ - sessionId: id, - displayTitle: id, - cwd, - updatedAt: 1, -}); - -test("ungrouped sidebar sections skip project grouping", () => { - const sessions = [session("a", "/tmp/one"), session("b", "/tmp/two")]; - expect(sidebarSections(sessions, "", false)).toMatchObject({ pinned: [], groups: [], active: sessions }); - expect(sidebarSections(sessions, "", true).groups).toHaveLength(2); -}); - -test("ungrouped sidebar sections keep archived, side, and folded chats out", () => { - const parent = session("parent", "/tmp/one"); - const child = { ...session("child", "/tmp/one"), spawnedBy: "parent" }; - const archived = { ...session("archived", "/tmp/one"), archived: true }; - const side = { ...session("side", "/tmp/one"), side: true }; - expect(sidebarSections([parent, child, archived, side], "", false).active.map((entry) => entry.sessionId)).toEqual([ - "parent", - ]); -}); - -test("session search refreshes when an immutable snapshot is replaced", () => { - const first = session("one", "/tmp/one"); - expect(sidebarSections([first], "one", false).active).toHaveLength(1); - const replacement = { ...first, displayTitle: "two", cwd: "/tmp/two" }; - expect(sidebarSections([replacement], "one", false).active).toHaveLength(0); - expect(sidebarSections([replacement], "two", false).active).toHaveLength(1); -}); diff --git a/webviews/test/session-sidebar-preview.test.tsx b/webviews/test/session-sidebar-preview.test.tsx deleted file mode 100644 index 170aa510ad24..000000000000 --- a/webviews/test/session-sidebar-preview.test.tsx +++ /dev/null @@ -1,255 +0,0 @@ -// Session rows keep their 30px geometry while a latest-reply preview floats in the shared popup -// layer. The same card is available to pointer hover and keyboard focus, and missing previews stay -// absent. -import { afterAll, afterEach, beforeAll, expect, test } from "bun:test"; -import { JSDOM } from "jsdom"; -import { act } from "react"; -import type { Root } from "react-dom/client"; -import { HOVER_INTENT } from "../src/ui/hoverIntent"; -import { - configureNativeSidebarPreviewVariant, - configureSidebarPreviewVariant, - type SidebarPreviewVariant, -} from "../src/agent-session/acpmux/sidebarPreview"; - -const scope = globalThis as Record; -const saved: (readonly [string, boolean, unknown])[] = []; -let dom: JSDOM; -let root: Root | undefined; -let host: HTMLElement; - -beforeAll(() => { - dom = new JSDOM("
    ", { url: "http://localhost/", pretendToBeVisual: true }); - const { window } = dom; - Object.assign(window, { - ResizeObserver: class { - observe() {} - unobserve() {} - disconnect() {} - }, - }); - const replacements: Record = { - window, - document: window.document, - navigator: window.navigator, - HTMLElement: window.HTMLElement, - Element: window.Element, - Node: window.Node, - MutationObserver: window.MutationObserver, - ResizeObserver: window.ResizeObserver, - getComputedStyle: window.getComputedStyle.bind(window), - requestAnimationFrame: window.requestAnimationFrame.bind(window), - cancelAnimationFrame: window.cancelAnimationFrame.bind(window), - IS_REACT_ACT_ENVIRONMENT: true, - }; - for (const key of Object.getOwnPropertyNames(window)) - if ( - /^(HTML|SVG|CSS|Shadow|Document|Mutation|KeyboardEvent|PointerEvent|MouseEvent|FocusEvent)/.test(key) && - !(key in scope) - ) - replacements[key] = (window as unknown as Record)[key]; - saved.push(...Object.keys(replacements).map((key) => [key, key in scope, scope[key]] as const)); - Object.assign(scope, replacements); - host = window.document.getElementById("root")!; -}); - -afterEach(() => { - act(() => root?.unmount()); - root = undefined; -}); - -afterAll(() => { - dom.window.close(); - for (const [key, had, value] of saved) { - if (had) scope[key] = value; - else delete scope[key]; - } -}); - -const chat = (sessionId: string, preview?: string) => ({ - sessionId, - title: "Fix the checkout", - harness: "codex", - cwd: "/Users/you/src/cmux", - branch: "feat/checkout", - status: "running", - indicator: "working" as const, - host: "This Mac", - hostKind: "local" as const, - preview, - updatedAt: 1, -}); - -async function sidebar( - preview?: string, - enabled = true, - count = 1, - onSelect = (_id: string) => {}, - previewVariant?: SidebarPreviewVariant, -) { - const { createRoot } = await import("react-dom/client"); - const { SessionSidebar } = await import("../src/agent-session/acpmux/SessionSidebar"); - root ??= createRoot(host); - const sessions = Array.from({ length: count }, (_, index) => chat(`session-${index}`, preview)); - await act(async () => - root!.render( - , - ), - ); - return host.querySelector(".acpmux-session-row")!; -} - -const wait = (ms: number) => act(async () => void (await new Promise((resolve) => setTimeout(resolve, ms)))); - -test("session preview appears after hover and includes status and placement", async () => { - const row = await sidebar("The payment form needs a final pass."); - await act(async () => { - row.dispatchEvent(new window.MouseEvent("pointerover", { bubbles: true })); - }); - await wait(1_050); - const card = document.querySelector(".acpmux-session-preview"); - expect(card?.textContent).toContain("The payment form needs a final pass."); - expect(card?.textContent).toContain("Working"); - expect(card?.textContent).toContain("Branch feat/checkout"); - await act(async () => { - row.dispatchEvent(new window.MouseEvent("pointerout", { bubbles: true, relatedTarget: document.body })); - }); - await wait(HOVER_INTENT.cardGrace + 50); - expect(document.querySelector(".acpmux-session-preview") === null).toBe(true); -}); - -test("session preview appears immediately on focus and stays absent without preview text", async () => { - const row = await sidebar("A focused latest reply."); - await act(async () => row.focus()); - expect(document.querySelector(".acpmux-session-preview")?.textContent).toContain( - "A focused latest reply.", - ); - const descriptionId = row.getAttribute("aria-describedby"); - expect(descriptionId !== null).toBe(true); - expect(document.getElementById(descriptionId!)?.textContent).toContain("A focused latest reply."); - - await sidebar("An updated latest reply."); - expect(row.getAttribute("aria-describedby")).toBe(descriptionId); - expect(document.getElementById(descriptionId!)?.textContent).toContain("An updated latest reply."); - - await sidebar(); - expect(document.querySelector(".acpmux-session-preview") === null).toBe(true); - expect(row.getAttribute("aria-describedby")).toBeNull(); -}); - -test("the preview remains readable while the pointer crosses from its row into the card", async () => { - const row = await sidebar("A reply that remains readable inside its hover card."); - await act(async () => row.dispatchEvent(new window.MouseEvent("pointerover", { bubbles: true }))); - await wait(HOVER_INTENT.cardDwell + 50); - const card = document.querySelector(".acpmux-session-preview")!; - expect(card !== null).toBe(true); - - await act(async () => row.dispatchEvent(new window.MouseEvent("pointerout", { bubbles: true, relatedTarget: card }))); - expect(document.querySelector(".acpmux-session-preview") !== null).toBe(true); - await act(async () => - card.dispatchEvent(new window.MouseEvent("pointerover", { bubbles: true, relatedTarget: row })), - ); - await wait(HOVER_INTENT.cardGrace + 50); - expect(document.querySelector(".acpmux-session-preview")?.textContent).toContain( - "A reply that remains readable inside its hover card.", - ); - await act(async () => - card.dispatchEvent(new window.MouseEvent("pointerout", { bubbles: true, relatedTarget: document.body })), - ); - expect(document.querySelector(".acpmux-session-preview") === null).toBe(true); -}); - -test("turning preview features off removes the focused card and prevents hover cards", async () => { - const row = await sidebar("A reply behind the preview setting."); - await act(async () => row.focus()); - expect(document.querySelector(".acpmux-session-preview")?.textContent).toContain( - "A reply behind the preview setting.", - ); - - const disabledRow = await sidebar("A reply behind the preview setting.", false); - expect(document.querySelector(".acpmux-session-preview") === null).toBe(true); - expect(disabledRow.getAttribute("aria-describedby")).toBeNull(); - await act(async () => { - disabledRow.blur(); - disabledRow.dispatchEvent(new window.MouseEvent("pointerover", { bubbles: true })); - }); - await wait(1_050); - expect(document.querySelector(".acpmux-session-preview") === null).toBe(true); - await act(async () => disabledRow.focus()); - expect(document.querySelector(".acpmux-session-preview") === null).toBe(true); -}); - -test("Escape dismisses the focused preview", async () => { - const row = await sidebar("A reply that can be dismissed."); - await act(async () => row.focus()); - expect(document.querySelector(".acpmux-session-preview") !== null).toBe(true); - await act(async () => row.dispatchEvent(new window.KeyboardEvent("keydown", { key: "Escape", bubbles: true }))); - expect(document.querySelector(".acpmux-session-preview") === null).toBe(true); - expect(row.getAttribute("aria-describedby")).toBeNull(); -}); - -test("sidebar preview visual variants stay opt-in and preserve the shared card semantics", async () => { - const quietRow = await sidebar("A quieter preview treatment.", true, 1, undefined, "quiet"); - await act(async () => quietRow.focus()); - const quiet = document.querySelector(".acpmux-session-preview"); - expect(quiet?.classList.contains("acpmux-session-preview-quiet")).toBe(true); - expect(quiet?.textContent).toContain("A quieter preview treatment."); - - const outlineRow = await sidebar("An outlined preview treatment.", true, 1, undefined, "outline"); - await act(async () => outlineRow.focus()); - const outline = document.querySelector(".acpmux-session-preview"); - expect(outline?.classList.contains("acpmux-session-preview-outline")).toBe(true); - expect(outline?.classList.contains("acpmux-session-preview-quiet")).toBe(false); - - const defaultRow = await sidebar("The default card treatment."); - await act(async () => defaultRow.focus()); - const card = document.querySelector(".acpmux-session-preview"); - expect(card?.classList.contains("acpmux-session-preview-card")).toBe(true); -}); - -test("the sidebar preview Debug layout choice selects the default treatment without bypassing the preview gate", async () => { - await act(async () => configureSidebarPreviewVariant("quiet")); - const quietRow = await sidebar("A layout-selected quiet preview."); - await act(async () => quietRow.focus()); - expect(document.querySelector(".acpmux-session-preview-quiet") !== null).toBe(true); - - await act(async () => configureSidebarPreviewVariant("outline")); - const outlineRow = await sidebar("A layout-selected outline preview."); - await act(async () => outlineRow.focus()); - expect(document.querySelector(".acpmux-session-preview-outline") !== null).toBe(true); - - await act(async () => configureSidebarPreviewVariant("card")); - const disabledRow = await sidebar("Preview stays gated.", false); - await act(async () => disabledRow.focus()); - expect(document.querySelector(".acpmux-session-preview") === null).toBe(true); -}); - -test("removing the Debug layout choice restores the native sidebar preview baseline", async () => { - await act(async () => configureNativeSidebarPreviewVariant("quiet")); - await act(async () => configureSidebarPreviewVariant("outline")); - await act(async () => configureSidebarPreviewVariant(undefined)); - - const row = await sidebar("The native quiet baseline is restored."); - await act(async () => row.focus()); - expect(document.querySelector(".acpmux-session-preview-quiet") !== null).toBe(true); - - await act(async () => configureNativeSidebarPreviewVariant("card")); - await act(async () => configureSidebarPreviewVariant(undefined)); -}); - -test("session rows retain ArrowDown, End and Home navigation", async () => { - const selected: string[] = []; - const row = await sidebar(undefined, true, 3, (id) => selected.push(id)); - await act(async () => row.focus()); - for (const [key, expected] of [ - ["ArrowDown", "session-1"], - ["End", "session-2"], - ["Home", "session-0"], - ]) { - await act(async () => - document.activeElement?.dispatchEvent(new window.KeyboardEvent("keydown", { key, bubbles: true })), - ); - expect(document.activeElement?.getAttribute("data-session-id")).toBe(expected); - } - expect(selected).toEqual(["session-1", "session-2", "session-0"]); -}); diff --git a/webviews/test/sidebar-rail-css.test.tsx b/webviews/test/sidebar-rail-css.test.tsx deleted file mode 100644 index 18c5049e763d..000000000000 --- a/webviews/test/sidebar-rail-css.test.tsx +++ /dev/null @@ -1,31 +0,0 @@ -// The sidebar's icon rail and the chat's session rail are two components; the pane loads both -// sheets, so the session rail's 240-340px column (summary.css) must not land on the 48px icon rail. -import { expect, test } from "bun:test"; -import { readFileSync } from "node:fs"; -import { Window } from "happy-dom"; -import { renderToStaticMarkup } from "react-dom/server"; -import { SessionSidebar } from "../src/agent-session/acpmux/SessionSidebar"; - -const sheet = (path: string) => readFileSync(new URL(path, import.meta.url), "utf8"); - -test("the sidebar's icon rail keeps its own layout with the session rail's sheet loaded", () => { - const markup = renderToStaticMarkup( {}} />); - const window = new Window({ width: 900, height: 600 }); - const { document } = window; - for (const css of [ - sheet("../src/agent-session/acpmux/styles.css"), - sheet("../src/agent-session/acpmux/summary/summary.css"), - ]) { - const style = document.createElement("style"); - style.textContent = css; - document.head.append(style); - } - document.body.innerHTML = markup; - const rail = document.querySelector("nav.acpmux-sidebar > div")!; - expect(rail.querySelector("button")).not.toBeNull(); - const computed = window.getComputedStyle(rail); - // happy-dom drops the session rail's clamp() basis, so check the plain values both rules set. - expect([computed.alignItems, computed.paddingLeft]).toEqual(["center", "0px"]); - expect(computed.overflowY).not.toBe("auto"); - void window.happyDOM.close(); -}); diff --git a/webviews/test/subtle-elevation.test.ts b/webviews/test/subtle-elevation.test.ts index ff8beaf6bbfe..da2b611728d7 100644 --- a/webviews/test/subtle-elevation.test.ts +++ b/webviews/test/subtle-elevation.test.ts @@ -243,7 +243,6 @@ describe("one subtle elevation scale", () => { ["agent-session/acpmux/styles.css", ".acpmux-newtab-kind.is-selected", "--elevation-1"], ["agent-session/acpmux/styles.css", ".acpmux-menu", "--elevation-2"], ["agent-session/acpmux/styles.css", ".acpmux-file-search", "--elevation-2"], - ["agent-session/acpmux/styles.css", ".acpmux-shell[data-sidebar=open] .acpmux-sidebar", "--elevation-2"], // Every find bar (Find in Chat, Find in Diff) is the shared find pill. ["ui/findPill.css", ".find-pill", "--elevation-2"], ["agent-session/acpmux/conversation/conversation.css", ".acpmux-jump-latest", "--elevation-2"], diff --git a/webviews/test/ui-rules.pending.json b/webviews/test/ui-rules.pending.json index 5be3218072e6..5a8cd50a1532 100644 --- a/webviews/test/ui-rules.pending.json +++ b/webviews/test/ui-rules.pending.json @@ -24,7 +24,6 @@ "agent-session/acpmux/PermissionCard.tsx": 1, "agent-session/acpmux/ProjectChooser.tsx": 5, "agent-session/acpmux/prototype/WorkspaceShell.tsx": 5, - "agent-session/acpmux/SessionSidebar.tsx": 1, "agent-session/acpmux/summary/usePopover.ts": 1, "App.tsx": 1, "BranchBasePicker.tsx": 6,