From f4b7f903150f476bfc9f16d379d1069f55206e89 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Titsworth-Morin?= Date: Sat, 20 Jun 2026 22:51:59 +0000 Subject: [PATCH 1/9] task: move collapsible-focus-mode-sidebars to active Co-Authored-By: Claude Opus 4.6 --- .../2026-06-20-collapsible-focus-mode-sidebars.md | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename tasks/{backlog => active}/2026-06-20-collapsible-focus-mode-sidebars.md (100%) diff --git a/tasks/backlog/2026-06-20-collapsible-focus-mode-sidebars.md b/tasks/active/2026-06-20-collapsible-focus-mode-sidebars.md similarity index 100% rename from tasks/backlog/2026-06-20-collapsible-focus-mode-sidebars.md rename to tasks/active/2026-06-20-collapsible-focus-mode-sidebars.md From 100cce9244235215c58ed5968668aee8876b034e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Titsworth-Morin?= Date: Sat, 20 Jun 2026 23:06:36 +0000 Subject: [PATCH 2/9] feat(web): Focus Mode nav sidebar collapse (default/focus/zen) Adds the shared three-state Focus Mode model and the nav-sidebar half: - lib/focus-mode.ts: FocusMode type, width helpers, cycle/persist helpers - AppShell: shared context + localStorage hydration + F-key cycle, dynamic grid column width, focus-aware aside, zen seam - NavSidebar: iconOnly (56px) rail variant across all three layouts - FocusModeToggle: segmented + cycle controls - ZenPeekRail: 0px glowing edge seam with hover/focus peek panel Co-Authored-By: Claude Opus 4.6 --- apps/web/src/components/AppShell.tsx | 255 ++++++++++++++++---- apps/web/src/components/FocusModeToggle.tsx | 95 ++++++++ apps/web/src/components/NavSidebar.tsx | 97 +++++--- apps/web/src/components/ZenPeekRail.tsx | 77 ++++++ apps/web/src/lib/focus-mode.ts | 72 ++++++ 5 files changed, 513 insertions(+), 83 deletions(-) create mode 100644 apps/web/src/components/FocusModeToggle.tsx create mode 100644 apps/web/src/components/ZenPeekRail.tsx create mode 100644 apps/web/src/lib/focus-mode.ts diff --git a/apps/web/src/components/AppShell.tsx b/apps/web/src/components/AppShell.tsx index 82cc4077f..2eb409f1d 100644 --- a/apps/web/src/components/AppShell.tsx +++ b/apps/web/src/components/AppShell.tsx @@ -6,9 +6,17 @@ import { useGlobalCommandPalette } from '../hooks/useGlobalCommandPalette'; import { useIsMobile } from '../hooks/useIsMobile'; import { useProjectList } from '../hooks/useProjectData'; import { signOut } from '../lib/auth'; +import { + type FocusMode, + FOCUS_MODE_STORAGE_KEY, + isFocusMode, + navWidthForMode, + nextFocusMode, +} from '../lib/focus-mode'; import { isMacPlatform } from '../lib/keyboard-shortcuts'; import { useAuth } from './AuthProvider'; import { CredentialHealthNavItem } from './CredentialHealthNavItem'; +import { FocusModeToggle } from './FocusModeToggle'; import { GlobalAudioPlayer } from './GlobalAudioPlayer'; import { GlobalCommandPalette } from './GlobalCommandPalette'; import { MobileNavDrawer, type MobileNavItem } from './MobileNavDrawer'; @@ -19,12 +27,23 @@ import { ChoosePathWizard } from './onboarding/choose-path/ChoosePathWizard'; import { RecentChatsDropdown } from './RecentChatsDropdown'; import { SidebarProjectList } from './SidebarProjectList'; import { ThemeSwitcher } from './ThemeSwitcher'; +import { ZenPeekRail } from './ZenPeekRail'; interface AppShellContextValue { setProjectName: (name: string | undefined) => void; + /** Current desktop Focus Mode. Always `default` on mobile. */ + focusMode: FocusMode; + setFocusMode: (mode: FocusMode) => void; + /** Advance default → focus → zen → default. */ + cycleFocusMode: () => void; } -const AppShellContext = createContext({ setProjectName: () => {} }); +const AppShellContext = createContext({ + setProjectName: () => {}, + focusMode: 'default', + setFocusMode: () => {}, + cycleFocusMode: () => {}, +}); export function useAppShell() { return useContext(AppShellContext); @@ -42,6 +61,7 @@ export function AppShell({ children }: AppShellProps) { const [drawerOpen, setDrawerOpen] = useState(false); const [projectName, setProjectNameState] = useState(undefined); const [showGlobalNav, setShowGlobalNav] = useState(false); + const [focusModeState, setFocusModeState] = useState('default'); const commandPalette = useGlobalCommandPalette(); const { projects: sidebarProjects, loading: sidebarProjectsLoading } = useProjectList({ limit: 50, @@ -56,6 +76,61 @@ export function AppShell({ children }: AppShellProps) { setShowGlobalNav((prev) => !prev); }, []); + const setFocusMode = useCallback((mode: FocusMode) => { + setFocusModeState(mode); + try { + window.localStorage.setItem(FOCUS_MODE_STORAGE_KEY, mode); + } catch { + // localStorage unavailable (private mode / quota) — in-memory state still works. + } + }, []); + + const cycleFocusMode = useCallback(() => { + setFocusModeState((prev) => { + const next = nextFocusMode(prev); + try { + window.localStorage.setItem(FOCUS_MODE_STORAGE_KEY, next); + } catch { + // ignore persistence failure + } + return next; + }); + }, []); + + // Hydrate Focus Mode from localStorage (desktop only). Mobile always uses default. + useEffect(() => { + if (isMobile) return; + try { + const stored = window.localStorage.getItem(FOCUS_MODE_STORAGE_KEY); + if (isFocusMode(stored)) { + setFocusModeState(stored); + } + } catch { + // ignore + } + }, [isMobile]); + + // "F" cycles Focus Mode (desktop only). Ignore when typing in inputs. + useEffect(() => { + if (isMobile) return; + const handler = (e: KeyboardEvent) => { + if (e.key.toLowerCase() !== 'f' || e.metaKey || e.ctrlKey || e.altKey) return; + const target = e.target as HTMLElement | null; + const tag = target?.tagName; + if ( + tag === 'INPUT' || + tag === 'TEXTAREA' || + tag === 'SELECT' || + target?.isContentEditable + ) + return; + e.preventDefault(); + cycleFocusMode(); + }; + window.addEventListener('keydown', handler); + return () => window.removeEventListener('keydown', handler); + }, [isMobile, cycleFocusMode]); + // Detect project context from URL (excludes reserved paths like /projects/new) const projectId = extractProjectId(location.pathname); @@ -174,7 +249,14 @@ export function AppShell({ children }: AppShellProps) { ); - const shellContext = useMemo(() => ({ setProjectName }), [setProjectName]); + // Focus Mode is desktop-only — force `default` on mobile so session-sidebar + // consumers never collapse on small viewports. + const focusMode: FocusMode = isMobile ? 'default' : focusModeState; + + const shellContext = useMemo( + () => ({ setProjectName, focusMode, setFocusMode, cycleFocusMode }), + [setProjectName, focusMode, setFocusMode, cycleFocusMode], + ); if (isMobile) { return ( @@ -245,59 +327,128 @@ export function AppShell({ children }: AppShellProps) { -
- + {/* Command palette trigger */} + {focusMode === 'focus' ? ( + + ) : ( + + )} + +
+ +
+ {user && ( +
+ {focusMode === 'focus' ? ( +
+ {avatarElement} + +
+ ) : ( + <> +
+ +
+
+ {avatarElement} +
+
+ {user.name || user.email} +
+
+ +
+ + )} +
+ )} + + )}
{children ?? } diff --git a/apps/web/src/components/FocusModeToggle.tsx b/apps/web/src/components/FocusModeToggle.tsx new file mode 100644 index 000000000..8ea628247 --- /dev/null +++ b/apps/web/src/components/FocusModeToggle.tsx @@ -0,0 +1,95 @@ +import { ArrowLeftRight, Maximize2, Minimize2, Sparkles } from 'lucide-react'; + +import { + type FocusMode, + FOCUS_MODE_ORDER, + focusModeLabel, + nextFocusMode, +} from '../lib/focus-mode'; + +/** + * Focus Mode control for the desktop nav sidebar. + * + * - `segmented`: a three-button group (Default / Focus / Zen) shown when the + * sidebar is wide enough to fit labels (default mode). + * - `cycle`: a single compact icon button shown in the 56px focus rail, where + * the segmented group would not fit. Clicking it advances to the next mode. + * + * The shared mode state lives in AppShellContext; this component only renders + * controls and forwards intent via `onSelect` / `onCycle`. + */ + +const MODE_ICON: Record = { + default: Maximize2, + focus: Minimize2, + zen: Sparkles, +}; + +export function FocusModeToggle({ + mode, + onSelect, + onCycle, + variant, +}: { + mode: FocusMode; + onSelect: (mode: FocusMode) => void; + onCycle: () => void; + variant: 'segmented' | 'cycle'; +}) { + if (variant === 'cycle') { + const NextIcon = MODE_ICON[nextFocusMode(mode)]; + return ( + + ); + } + + return ( +
+
+ {FOCUS_MODE_ORDER.map((m) => { + const Icon = MODE_ICON[m]; + const active = m === mode; + return ( + + ); + })} +
+ +
+ ); +} diff --git a/apps/web/src/components/NavSidebar.tsx b/apps/web/src/components/NavSidebar.tsx index 4f4a06b24..48acc0f9c 100644 --- a/apps/web/src/components/NavSidebar.tsx +++ b/apps/web/src/components/NavSidebar.tsx @@ -86,6 +86,7 @@ function isProjectSubActive(subPath: string, projectId: string, pathname: string const FOCUS_RING = 'focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-focus-ring'; const NAV_ITEM_BASE = `flex items-center gap-3 pl-[10px] pr-3 py-2 rounded-sm no-underline text-sm font-medium border-l-2 transition-all duration-150 ${FOCUS_RING}`; +const NAV_ITEM_BASE_ICON = `flex items-center justify-center px-0 py-2 rounded-sm no-underline text-sm font-medium border-l-2 transition-all duration-150 ${FOCUS_RING}`; const NAV_ITEM_ACTIVE = 'text-accent border-l-accent bg-[var(--sam-chrome-accent-active-subtle)]'; const NAV_ITEM_INACTIVE = 'text-fg-muted border-l-transparent hover:text-fg-primary hover:bg-[var(--sam-chrome-accent-hover-subtle)]'; const SECTION_DIVIDER = 'mt-2 pt-2 before:content-[\'\'] before:block before:h-px before:mb-2 before:bg-[linear-gradient(90deg,transparent,var(--sam-chrome-accent-divider),transparent)]'; @@ -98,9 +99,11 @@ interface NavSidebarProps { /** Rendered below Infrastructure in global nav views */ projectListSection?: ReactNode; projectHealthElement?: ReactNode; + /** Collapse to a 56px icon rail (Focus Mode) — hides labels, centers icons */ + iconOnly?: boolean; } -export function NavSidebar({ className, projectName, showGlobalNav, onToggleGlobalNav, projectListSection, projectHealthElement }: NavSidebarProps) { +export function NavSidebar({ className, projectName, showGlobalNav, onToggleGlobalNav, projectListSection, projectHealthElement, iconOnly }: NavSidebarProps) { const location = useLocation(); const { isSuperadmin } = useAuth(); const { needsOnboarding, openOnboarding } = useOnboarding(); @@ -109,9 +112,38 @@ export function NavSidebar({ className, projectName, showGlobalNav, onToggleGlob const projectId = extractProjectId(location.pathname); const insideProject = Boolean(projectId); + // Shared item class: centered icon-only in Focus rail, full label otherwise. + const itemClass = (active: boolean) => + `${iconOnly ? NAV_ITEM_BASE_ICON : NAV_ITEM_BASE} ${active ? NAV_ITEM_ACTIVE : NAV_ITEM_INACTIVE}`; + // Collapsible Infrastructure section — identical in the project-context global // panel and the standalone global sidebar. Extracted so the markup lives once. - const infraSection = ( + const infraItems = [ + { label: 'Nodes', path: '/nodes', icon: }, + { label: 'Workspaces', path: '/workspaces', icon: }, + ]; + + // In the Focus icon rail there is no room for a collapsible header — render + // the infra destinations directly as icon links so they remain reachable. + const infraSection = iconOnly ? ( +
+ {infraItems.map((item) => { + const active = isActive(item.path, location.pathname); + return ( + + {item.icon} + + ); + })} +
+ ) : (
{infraOpen && (
- {[ - { label: 'Nodes', path: '/nodes', icon: }, - { label: 'Workspaces', path: '/workspaces', icon: }, - ].map((item) => { + {infraItems.map((item) => { const active = isActive(item.path, location.pathname); return (
); @@ -184,17 +215,20 @@ export function NavSidebar({ className, projectName, showGlobalNav, onToggleGlob {/* Toggle to global nav */} {/* Project name header */} -
- {projectName || 'Project'} -
+ {!iconOnly && ( +
+ {projectName || 'Project'} +
+ )} {projectHealthElement && (
@@ -210,12 +244,12 @@ export function NavSidebar({ className, projectName, showGlobalNav, onToggleGlob key={item.path} to={`/projects/${projectId}/${item.path}`} aria-current={active ? 'page' : undefined} - className={`${NAV_ITEM_BASE} ${ - active ? NAV_ITEM_ACTIVE : NAV_ITEM_INACTIVE - }`} + aria-label={iconOnly ? item.label : undefined} + title={iconOnly ? item.label : undefined} + className={itemClass(active)} > {item.icon} - {item.label} + {!iconOnly && item.label} ); })} @@ -231,11 +265,12 @@ export function NavSidebar({ className, projectName, showGlobalNav, onToggleGlob {/* Toggle back to project nav */} {/* Global nav items */} @@ -246,12 +281,12 @@ export function NavSidebar({ className, projectName, showGlobalNav, onToggleGlob key={item.path} to={item.path} aria-current={active ? 'page' : undefined} - className={`${NAV_ITEM_BASE} ${ - active ? NAV_ITEM_ACTIVE : NAV_ITEM_INACTIVE - }`} + aria-label={iconOnly ? item.label : undefined} + title={iconOnly ? item.label : undefined} + className={itemClass(active)} > {item.icon} - {item.label} + {!iconOnly && item.label} ); })} @@ -260,8 +295,8 @@ export function NavSidebar({ className, projectName, showGlobalNav, onToggleGlob {onboardingResume} - {/* Project list — in global panel within project context */} - {projectListSection} + {/* Project list — hidden in the Focus rail (too wide for 56px) */} + {!iconOnly && projectListSection}
@@ -282,12 +317,12 @@ export function NavSidebar({ className, projectName, showGlobalNav, onToggleGlob key={item.path} to={item.path} aria-current={active ? 'page' : undefined} - className={`${NAV_ITEM_BASE} ${ - active ? NAV_ITEM_ACTIVE : NAV_ITEM_INACTIVE - }`} + aria-label={iconOnly ? item.label : undefined} + title={iconOnly ? item.label : undefined} + className={itemClass(active)} > {item.icon} - {item.label} + {!iconOnly && item.label} ); })} @@ -296,8 +331,8 @@ export function NavSidebar({ className, projectName, showGlobalNav, onToggleGlob {onboardingResume} - {/* Project list — in standalone global nav */} - {projectListSection} + {/* Project list — hidden in the Focus rail (too wide for 56px) */} + {!iconOnly && projectListSection} ); } diff --git a/apps/web/src/components/ZenPeekRail.tsx b/apps/web/src/components/ZenPeekRail.tsx new file mode 100644 index 000000000..0928fa1e6 --- /dev/null +++ b/apps/web/src/components/ZenPeekRail.tsx @@ -0,0 +1,77 @@ +import { type ReactNode, useState } from 'react'; + +/** + * Zen-mode collapsed sidebar. + * + * In Zen mode both desktop sidebars collapse to 0px and are replaced by a + * glowing vertical "seam" pinned to the left viewport edge. The two seams are + * stacked vertically to avoid hover collisions: + * - `nav` seam occupies the TOP half of the viewport. + * - `sessions` seam occupies the BOTTOM half. + * + * Hovering (or focusing) the seam slides out a peek panel containing the real + * sidebar content. The peek panel is a CHILD of the hover wrapper (height 200% + * to span the full viewport) so moving the pointer from seam to panel never + * crosses a gap that would fire `mouseleave` and cause flicker. + * + * Clicking the seam (or activating it via keyboard) calls `onExpand` to leave + * Zen mode and restore the full sidebar. + */ +export function ZenPeekRail({ + edge, + label, + onExpand, + gridRow, + children, +}: { + edge: 'nav' | 'sessions'; + label: string; + onExpand: () => void; + gridRow?: string; + children: ReactNode; +}) { + const [open, setOpen] = useState(false); + const isTop = edge === 'nav'; + const panelWidth = edge === 'nav' ? 220 : 288; + + return ( +
+
setOpen(true)} + onMouseLeave={() => setOpen(false)} + onFocusCapture={() => setOpen(true)} + onBlurCapture={(e) => { + if (!e.currentTarget.contains(e.relatedTarget as Node)) setOpen(false); + }} + > + + + {open && ( + + )} +
+
+ ); +} diff --git a/apps/web/src/lib/focus-mode.ts b/apps/web/src/lib/focus-mode.ts new file mode 100644 index 000000000..0f995e1b7 --- /dev/null +++ b/apps/web/src/lib/focus-mode.ts @@ -0,0 +1,72 @@ +/** + * Focus Mode — three-state collapse model for the desktop sidebars. + * + * - `default`: full nav (220px) + full session sidebar (288px) + * - `focus`: icon rail nav (56px) + status strip sidebar (64px) + * - `zen`: both sidebars collapsed to glowing edge seams (0px); hover to peek + * + * The mode is shared across two components that own different sidebars: + * - AppShell.tsx owns the nav column (hardcoded grid column) + * - pages/project-chat/index.tsx owns the session sidebar (flex child) + * + * Shared via AppShellContext + persisted to localStorage. Desktop-only. + */ + +export type FocusMode = 'default' | 'focus' | 'zen'; + +export const FOCUS_MODE_ORDER: FocusMode[] = ['default', 'focus', 'zen']; + +export const FOCUS_MODE_STORAGE_KEY = 'sam:focus-mode'; + +/** Nav column width (px) for each mode. */ +export const NAV_WIDTH_DEFAULT = 220; +export const NAV_WIDTH_FOCUS = 56; + +/** Session sidebar width (px) for each mode. */ +export const SESSION_WIDTH_DEFAULT = 288; +export const SESSION_WIDTH_FOCUS = 64; + +export function isFocusMode(value: unknown): value is FocusMode { + return value === 'default' || value === 'focus' || value === 'zen'; +} + +/** Returns the next mode in the cycle default → focus → zen → default. */ +export function nextFocusMode(mode: FocusMode): FocusMode { + const idx = FOCUS_MODE_ORDER.indexOf(mode); + return FOCUS_MODE_ORDER[(idx + 1) % FOCUS_MODE_ORDER.length] ?? 'default'; +} + +export function focusModeLabel(mode: FocusMode): string { + switch (mode) { + case 'default': + return 'Default'; + case 'focus': + return 'Focus'; + case 'zen': + return 'Zen'; + } +} + +/** Nav column width in px for the given mode (zen collapses to a 0-width overlay seam). */ +export function navWidthForMode(mode: FocusMode): number { + switch (mode) { + case 'default': + return NAV_WIDTH_DEFAULT; + case 'focus': + return NAV_WIDTH_FOCUS; + case 'zen': + return 0; + } +} + +/** Session sidebar width in px for the given mode (zen collapses to a 0-width overlay seam). */ +export function sessionWidthForMode(mode: FocusMode): number { + switch (mode) { + case 'default': + return SESSION_WIDTH_DEFAULT; + case 'focus': + return SESSION_WIDTH_FOCUS; + case 'zen': + return 0; + } +} From c5ab18998ea198b4f48a5c1c44673387dbd0eeb7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Titsworth-Morin?= Date: Sat, 20 Jun 2026 23:19:06 +0000 Subject: [PATCH 3/9] feat(web): Focus Mode session sidebar collapse + shared status icons Wire FocusStrip (64px status-icon rail) and ZenPeekRail into project-chat sidebar so it collapses with Focus Mode alongside the nav. Extract shared ATTENTION_ICON map into chat-session-utils so SessionItem and FocusStrip render identical status iconography. Co-Authored-By: Claude Opus 4.6 --- apps/web/src/components/AppShell.tsx | 2 +- apps/web/src/components/FocusModeToggle.tsx | 2 +- apps/web/src/lib/chat-session-utils.ts | 30 ++++ .../web/src/pages/project-chat/FocusStrip.tsx | 166 ++++++++++++++++++ .../src/pages/project-chat/SessionItem.tsx | 33 +--- apps/web/src/pages/project-chat/index.tsx | 52 +++++- 6 files changed, 244 insertions(+), 41 deletions(-) create mode 100644 apps/web/src/pages/project-chat/FocusStrip.tsx diff --git a/apps/web/src/components/AppShell.tsx b/apps/web/src/components/AppShell.tsx index 2eb409f1d..ab81edf50 100644 --- a/apps/web/src/components/AppShell.tsx +++ b/apps/web/src/components/AppShell.tsx @@ -7,8 +7,8 @@ import { useIsMobile } from '../hooks/useIsMobile'; import { useProjectList } from '../hooks/useProjectData'; import { signOut } from '../lib/auth'; import { - type FocusMode, FOCUS_MODE_STORAGE_KEY, + type FocusMode, isFocusMode, navWidthForMode, nextFocusMode, diff --git a/apps/web/src/components/FocusModeToggle.tsx b/apps/web/src/components/FocusModeToggle.tsx index 8ea628247..c6f8da81e 100644 --- a/apps/web/src/components/FocusModeToggle.tsx +++ b/apps/web/src/components/FocusModeToggle.tsx @@ -1,8 +1,8 @@ import { ArrowLeftRight, Maximize2, Minimize2, Sparkles } from 'lucide-react'; import { - type FocusMode, FOCUS_MODE_ORDER, + type FocusMode, focusModeLabel, nextFocusMode, } from '../lib/focus-mode'; diff --git a/apps/web/src/lib/chat-session-utils.ts b/apps/web/src/lib/chat-session-utils.ts index 63edd8282..dc7974a41 100644 --- a/apps/web/src/lib/chat-session-utils.ts +++ b/apps/web/src/lib/chat-session-utils.ts @@ -1,6 +1,15 @@ /** * Shared chat session state helpers used by ProjectChat, Chats page, and other components. */ +import { + AlertCircle, + CheckCircle2, + CirclePause, + HelpCircle, + Loader2, + XCircle, +} from 'lucide-react'; + import type { ChatSessionListItem, ChatSessionResponse } from './api'; /** Sessions with no activity in this window are considered stale and hidden by default (ms). */ @@ -130,4 +139,25 @@ export function isHighPriorityAttention(state: AttentionState): boolean { return state === 'needs_input' || state === 'error'; } +/** + * Attention state -> icon + color + label mapping (uses design tokens). + * + * Single source of truth consumed by `SessionItem` (full session card) and the + * Focus Mode session strip (`FocusStrip`). The `active` icon (`Loader2`) is the + * only one intended to spin — callers add `animate-spin` when the state is + * `active`. + */ +export const ATTENTION_ICON: Record< + AttentionState, + { icon: typeof HelpCircle; color: string; label: string } +> = { + needs_input: { icon: HelpCircle, color: 'var(--sam-color-warning, #f59e0b)', label: 'Needs input' }, + error: { icon: AlertCircle, color: 'var(--sam-color-danger, #ef4444)', label: 'Error' }, + active: { icon: Loader2, color: 'var(--sam-color-success)', label: 'Running' }, + idle: { icon: CirclePause, color: 'var(--sam-color-warning, #f59e0b)', label: 'Idle' }, + completed: { icon: CheckCircle2, color: 'var(--sam-color-fg-muted)', label: 'Completed' }, + failed: { icon: XCircle, color: 'var(--sam-color-danger, #ef4444)', label: 'Failed' }, + stopped: { icon: CirclePause, color: 'var(--sam-color-fg-muted)', label: 'Stopped' }, +}; + export { formatRelativeTime } from './time-utils'; diff --git a/apps/web/src/pages/project-chat/FocusStrip.tsx b/apps/web/src/pages/project-chat/FocusStrip.tsx new file mode 100644 index 000000000..dfefaf5f3 --- /dev/null +++ b/apps/web/src/pages/project-chat/FocusStrip.tsx @@ -0,0 +1,166 @@ +import { Plus } from 'lucide-react'; +import { useCallback, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; + +import type { ChatSessionListItem, ChatSessionResponse } from '../../lib/api'; +import { ATTENTION_ICON, getAttentionState } from '../../lib/chat-session-utils'; +import { SessionTreeItem } from './SessionTreeItem'; +import type { TaskInfo } from './useTaskGroups'; + +/** + * Focus Mode session strip — the 64px collapsed form of the project-chat + * session sidebar. + * + * Each session is reduced to its attention-state status icon (shared + * `ATTENTION_ICON` map). Hovering or focusing an icon peeks the real session + * card (`SessionTreeItem`) in a tooltip. + * + * The tooltip is rendered through `createPortal(..., document.body)` with fixed + * coordinates derived from the icon's `getBoundingClientRect()`. This is + * REQUIRED: the sidebar's glass ancestors (`glass-panel-container` => + * `contain: paint`, `glass-composited` => `transform`) create paint/stacking + * contexts that clip and mis-stack a normally-positioned absolute tooltip. + * Parenting to `` escapes both. + * + * A short close delay bridges the gap between the icon and the tooltip so + * moving the pointer across it does not flicker the tooltip closed. + */ + +const CLOSE_DELAY_MS = 140; + +function enrichSession( + session: ChatSessionListItem, + taskInfoMap: Map, +): ChatSessionResponse { + const taskInfo = session.taskId ? taskInfoMap.get(session.taskId) : undefined; + if (!taskInfo) return session; + return { + ...session, + task: { id: taskInfo.id, status: taskInfo.status, taskMode: taskInfo.taskMode }, + }; +} + +interface TooltipState { + session: ChatSessionListItem; + top: number; + left: number; +} + +export function FocusStrip({ + sessions, + selectedSessionId, + onSelect, + onFork, + taskInfoMap, + onShowHierarchy, + onNewChat, +}: { + sessions: ChatSessionListItem[]; + selectedSessionId: string | null; + onSelect: (id: string) => void; + onFork?: (session: ChatSessionResponse) => void; + taskInfoMap: Map; + onShowHierarchy?: (taskId: string) => void; + onNewChat: () => void; +}) { + const [tooltip, setTooltip] = useState(null); + const closeTimer = useRef | null>(null); + + const cancelClose = useCallback(() => { + if (closeTimer.current) { + clearTimeout(closeTimer.current); + closeTimer.current = null; + } + }, []); + + const scheduleClose = useCallback(() => { + cancelClose(); + closeTimer.current = setTimeout(() => setTooltip(null), CLOSE_DELAY_MS); + }, [cancelClose]); + + const openTooltip = useCallback( + (session: ChatSessionListItem, el: HTMLElement) => { + cancelClose(); + const rect = el.getBoundingClientRect(); + setTooltip({ session, top: rect.top, left: rect.right + 6 }); + }, + [cancelClose], + ); + + return ( + <> +
+ + + +
+ + {tooltip && + createPortal( +
+ +
, + document.body, + )} + + ); +} diff --git a/apps/web/src/pages/project-chat/SessionItem.tsx b/apps/web/src/pages/project-chat/SessionItem.tsx index 20d765dd0..077660338 100644 --- a/apps/web/src/pages/project-chat/SessionItem.tsx +++ b/apps/web/src/pages/project-chat/SessionItem.tsx @@ -1,19 +1,9 @@ -import { - AlertCircle, - CheckCircle2, - CirclePause, - HelpCircle, - ListTodo, - Loader2, - MessageSquare, - User2, - XCircle, -} from 'lucide-react'; +import { AlertCircle, ListTodo, MessageSquare, User2 } from 'lucide-react'; import type { ReactNode } from 'react'; import type { ChatSessionResponse } from '../../lib/api'; import { - type AttentionState, + ATTENTION_ICON, formatRelativeTime, getAttentionState, getLastActivity, @@ -30,23 +20,6 @@ function getCreatorLabel(session: ChatSessionResponse): string | null { return creator?.name?.trim() || creator?.email?.split('@')[0] || 'Member'; } -// --------------------------------------------------------------------------- -// Attention state -> icon + color mapping (uses design tokens) -// --------------------------------------------------------------------------- - -const ATTENTION_ICON_MAP: Record = { - needs_input: { icon: HelpCircle, color: 'var(--sam-color-warning, #f59e0b)', label: 'Needs input' }, - error: { icon: AlertCircle, color: 'var(--sam-color-danger, #ef4444)', label: 'Error' }, - active: { icon: Loader2, color: 'var(--sam-color-success)', label: 'Running' }, - idle: { icon: CirclePause, color: 'var(--sam-color-warning, #f59e0b)', label: 'Idle' }, - completed: { icon: CheckCircle2, color: 'var(--sam-color-fg-muted)', label: 'Completed' }, - failed: { icon: XCircle, color: 'var(--sam-color-danger, #ef4444)', label: 'Failed' }, - stopped: { icon: CirclePause, color: 'var(--sam-color-fg-muted)', label: 'Stopped' }, -}; export function SessionItem({ session, @@ -82,7 +55,7 @@ export function SessionItem({ // Icon config — blocked overrides normal attention state const iconConfig = blockedBadge ? { icon: AlertCircle, color: 'var(--sam-color-danger, #ef4444)', label: 'Blocked' } - : ATTENTION_ICON_MAP[attentionState]; + : ATTENTION_ICON[attentionState]; const StatusIcon = iconConfig.icon; const ModeIcon = mode === 'task' ? ListTodo : MessageSquare; diff --git a/apps/web/src/pages/project-chat/index.tsx b/apps/web/src/pages/project-chat/index.tsx index 810d8a106..7fc46a6d1 100644 --- a/apps/web/src/pages/project-chat/index.tsx +++ b/apps/web/src/pages/project-chat/index.tsx @@ -3,13 +3,17 @@ import { ChevronDown, ChevronRight, List, Search, Settings, X } from 'lucide-rea import { useCallback, useMemo, useState } from 'react'; import { useLocation, useNavigate } from 'react-router'; +import { useAppShell } from '../../components/AppShell'; import { BootLogPanel } from '../../components/chat/BootLogPanel'; import { ProjectMessageView } from '../../components/project-message-view'; import { HierarchyModal } from '../../components/task-hierarchy'; import { TriggerDropdown } from '../../components/triggers/TriggerDropdown'; +import { ZenPeekRail } from '../../components/ZenPeekRail'; import { useIsMobile } from '../../hooks/useIsMobile'; +import { sessionWidthForMode } from '../../lib/focus-mode'; import { ChatInput } from './ChatInput'; import { DerivedSessionBanner } from './DerivedSessionBanner'; +import { FocusStrip } from './FocusStrip'; import { getSessionSourceContext } from './lineageUtils'; import { MobileSessionDrawer } from './MobileSessionDrawer'; import { ProvisioningIndicator } from './ProvisioningIndicator'; @@ -19,6 +23,7 @@ import { useProjectChatState } from './useProjectChatState'; export function ProjectChat() { const isMobile = useIsMobile(); + const { focusMode, setFocusMode } = useAppShell(); const location = useLocation(); const navigate = useNavigate(); const state = useProjectChatState(); @@ -73,15 +78,12 @@ export function ProjectChat() { ); } - return ( -
- {/* ================================================================== */} - {/* Desktop sidebar */} - {/* ================================================================== */} - {!isMobile && ( -
- {/* Sidebar header: project name + action buttons */} -
+ // Full session-sidebar content. Reused verbatim by the default-mode panel + // and the Zen peek panel so the two never drift. + const sidebarInner = ( + <> + {/* Sidebar header: project name + action buttons */} +
{state.project?.name || 'Project'} @@ -224,6 +226,38 @@ export function ProjectChat() { No chats yet. Start a new one above.
)} + + ); + + return ( +
+ {/* ================================================================== */} + {/* Desktop session sidebar — collapses with Focus Mode */} + {/* default: 288px full panel · focus: 64px status strip · zen: seam */} + {/* ================================================================== */} + {!isMobile && focusMode === 'zen' && ( + setFocusMode('default')}> + {sidebarInner} + + )} + {!isMobile && focusMode !== 'zen' && ( +
+ {focusMode === 'focus' ? ( + + ) : ( + sidebarInner + )}
)} From 59262f37d1cef228558f042fee1bf18fb82e584b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Titsworth-Morin?= Date: Sat, 20 Jun 2026 23:26:24 +0000 Subject: [PATCH 4/9] test(web): Focus Mode unit tests + desktop/mobile Playwright visual audit Co-Authored-By: Claude Opus 4.6 --- .../tests/playwright/focus-mode-audit.spec.ts | 307 ++++++++++++++++++ .../unit/components/FocusModeToggle.test.tsx | 75 +++++ apps/web/tests/unit/lib/focus-mode.test.ts | 78 +++++ 3 files changed, 460 insertions(+) create mode 100644 apps/web/tests/playwright/focus-mode-audit.spec.ts create mode 100644 apps/web/tests/unit/components/FocusModeToggle.test.tsx create mode 100644 apps/web/tests/unit/lib/focus-mode.test.ts diff --git a/apps/web/tests/playwright/focus-mode-audit.spec.ts b/apps/web/tests/playwright/focus-mode-audit.spec.ts new file mode 100644 index 000000000..dfb098f88 --- /dev/null +++ b/apps/web/tests/playwright/focus-mode-audit.spec.ts @@ -0,0 +1,307 @@ +import { expect, type Page, type Route, test } from '@playwright/test'; + +// --------------------------------------------------------------------------- +// Visual audit for desktop Focus Mode sidebar collapse. +// +// Focus Mode is a three-state control (default -> focus -> zen) that collapses +// the main nav sidebar and the project-chat session sidebar: +// - default: full nav (220px) + full sessions (288px) +// - focus: icon nav rail (56px) + session status strip (64px) +// - zen: both collapsed to 0 with glowing peek seams +// +// Focus Mode is desktop-only (forced to 'default' on mobile). These tests drive +// the segmented toggle and the "F" cycle key at desktop (1280x800) and assert +// no horizontal overflow in any state. They also confirm mobile stays default. +// --------------------------------------------------------------------------- + +const MOCK_USER = { + user: { + id: 'user-test-1', + email: 'test@example.com', + name: 'Test User', + image: null, + role: 'user', + status: 'active', + emailVerified: true, + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + }, + session: { + id: 'session-test-1', + userId: 'user-test-1', + expiresAt: new Date(Date.now() + 86400000).toISOString(), + token: 'mock-token', + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + }, +}; + +const MOCK_PROJECT = { + id: 'proj-1', + name: 'Focus Mode Project', + repository: 'testuser/test-repo', + defaultBranch: 'main', + userId: 'user-test-1', + githubInstallationId: 'inst-1', + defaultVmSize: null, + defaultAgentType: null, + defaultProvider: null, + workspaceIdleTimeoutMs: null, + nodeIdleTimeoutMs: null, + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', +}; + +const NOW = Date.now(); + +function makeSession( + id: string, + taskId: string, + topic: string, + status = 'active', + lastMessageAt = NOW - 30000, +) { + return { + id, + workspaceId: null, + taskId, + topic, + status, + messageCount: 3, + startedAt: lastMessageAt - 30000, + endedAt: status === 'stopped' ? lastMessageAt + 30000 : null, + createdAt: lastMessageAt - 90000, + lastMessageAt, + isIdle: false, + isTerminated: status === 'stopped', + }; +} + +function makeTask(id: string, title: string, status = 'running') { + return { + id, + title, + description: null, + projectId: 'proj-1', + userId: 'user-test-1', + status, + blocked: false, + parentTaskId: null, + triggeredBy: 'user', + createdAt: new Date(NOW - 120000).toISOString(), + updatedAt: new Date(NOW - 30000).toISOString(), + }; +} + +// A varied set: long topic, special chars, several attention states so the +// collapsed status-strip exercises every ATTENTION_ICON branch. +const SESSIONS = [ + makeSession('s-1', 't-1', 'Implement authentication flow', 'active'), + makeSession( + 's-2', + 't-2', + 'Refactor the credential resolution layer with a deliberately long topic that should wrap and never push controls off the canvas in any focus state', + 'needs_input', + ), + makeSession('s-3', 't-3', 'Fix overflow bug <>& in sidebar', 'stopped'), + makeSession('s-4', 't-4', 'Add collapse peek seams', 'completed'), +]; +const TASKS = [ + makeTask('t-1', 'Implement authentication flow', 'running'), + makeTask('t-2', 'Refactor credential resolution', 'running'), + makeTask('t-3', 'Fix overflow bug', 'completed'), + makeTask('t-4', 'Add collapse peek seams', 'completed'), +]; + +async function setupApiMocks(page: Page) { + await page.route('**/api/**', async (route: Route) => { + const url = new URL(route.request().url()); + const path = url.pathname; + const respond = (status: number, body: unknown) => + route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(body) }); + + if (path.includes('/api/auth/')) return respond(200, MOCK_USER); + if (path.startsWith('/api/notifications')) + return respond(200, { notifications: [], unreadCount: 0 }); + if (path.startsWith('/api/credentials')) return respond(200, []); + if (path.startsWith('/api/provider-catalog')) return respond(200, { catalogs: [] }); + if (path.startsWith('/api/github/installations')) return respond(200, []); + if (path.startsWith('/api/trial-status')) { + return respond(200, { + available: false, + agentType: null, + hasInfraCredential: false, + hasAgentCredential: false, + dailyTokenBudget: null, + dailyTokenUsage: null, + }); + } + if (path === '/api/agents') return respond(200, { agents: [] }); + + const projectMatch = path.match(/^\/api\/projects\/([^/]+)(\/.*)?$/); + if (projectMatch) { + const subPath = projectMatch[2] || ''; + if (subPath === '/sessions') { + return respond(200, { sessions: SESSIONS, total: SESSIONS.length }); + } + if (subPath.match(/\/sessions\/[^/]+\/messages/)) return respond(200, []); + if (subPath === '/tasks') return respond(200, { tasks: TASKS, nextCursor: null }); + if (subPath === '/agents') return respond(200, { agents: [] }); + if (subPath === '/agent-profiles') return respond(200, { items: [] }); + if (subPath === '/cached-commands') return respond(200, { items: [] }); + if (subPath === '/triggers') return respond(200, { items: [] }); + if (subPath === '/knowledge') return respond(200, { entities: [], total: 0 }); + return respond(200, MOCK_PROJECT); + } + + if (path === '/api/projects') { + return respond(200, { projects: [MOCK_PROJECT], nextCursor: null }); + } + return respond(200, {}); + }); +} + +async function screenshot(page: Page, name: string) { + await page.waitForTimeout(700); + await page.screenshot({ + path: `../../.codex/tmp/playwright-screenshots/${name}.png`, + fullPage: true, + }); +} + +async function assertNoOverflow(page: Page) { + const overflow = await page.evaluate( + () => document.documentElement.scrollWidth > window.innerWidth, + ); + expect(overflow).toBe(false); +} + +async function assertChatPageRendered(page: Page) { + const body = await page.evaluate(() => document.body.innerText); + expect(body).not.toContain('Something went wrong'); + expect(body).not.toContain('Cannot read properties of undefined'); +} + +async function gotoProject(page: Page) { + await setupApiMocks(page); + await page.goto('/projects/proj-1'); + await page.waitForTimeout(1500); + await assertChatPageRendered(page); +} + +// --------------------------------------------------------------------------- +// Desktop tests (1280x800) — Focus Mode is desktop-only. +// --------------------------------------------------------------------------- + +test.describe('Focus Mode sidebars — Desktop', () => { + test.use({ viewport: { width: 1280, height: 800 }, isMobile: false, hasTouch: false }); + test.beforeEach(({ browserName }, testInfo) => { + test.skip( + !testInfo.project.name.includes('Desktop'), + `Desktop audit skipped for ${testInfo.project.name} on ${browserName}`, + ); + }); + + test('default mode shows the segmented toggle and full sidebars', async ({ page }) => { + await gotoProject(page); + // The segmented toggle group exposes all three modes as buttons. + await expect(page.getByRole('group', { name: 'Focus Mode' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Default', exact: true, pressed: true })).toBeVisible(); + await assertNoOverflow(page); + await screenshot(page, 'focus-mode-default-desktop'); + }); + + test('selecting Focus collapses nav rail + session strip without overflow', async ({ page }) => { + await gotoProject(page); + await page.getByRole('button', { name: 'Focus', exact: true }).click(); + await page.waitForTimeout(400); + // The collapsed nav shows the compact cycle control instead of the segmented group. + await expect( + page.getByRole('button', { name: /Focus Mode: Focus\. Activate to switch to Zen/ }), + ).toBeVisible(); + // The session strip exposes a compact New chat affordance. + await expect(page.getByRole('button', { name: 'New chat' })).toBeVisible(); + await assertNoOverflow(page); + await screenshot(page, 'focus-mode-focus-desktop'); + }); + + test('selecting Zen collapses both sidebars to peek seams', async ({ page }) => { + await gotoProject(page); + await page.getByRole('button', { name: 'Zen', exact: true }).click(); + await page.waitForTimeout(400); + // Both zen peek seams render as expand buttons parented to the grid edges. + await expect( + page.getByRole('button', { name: /Navigation \(Zen mode\)\. Activate to expand\./ }), + ).toBeVisible(); + await expect( + page.getByRole('button', { name: /Chats \(Zen mode\)\. Activate to expand\./ }), + ).toBeVisible(); + await assertNoOverflow(page); + await screenshot(page, 'focus-mode-zen-desktop'); + }); + + test('the F key cycles default -> focus -> zen -> default', async ({ page }) => { + await gotoProject(page); + await expect(page.getByRole('button', { name: 'Default', exact: true, pressed: true })).toBeVisible(); + + await page.keyboard.press('f'); + await page.waitForTimeout(300); + await expect( + page.getByRole('button', { name: /Focus Mode: Focus\. Activate to switch to Zen/ }), + ).toBeVisible(); + await assertNoOverflow(page); + + await page.keyboard.press('f'); + await page.waitForTimeout(300); + await expect( + page.getByRole('button', { name: /Navigation \(Zen mode\)\. Activate to expand\./ }), + ).toBeVisible(); + await assertNoOverflow(page); + + await page.keyboard.press('f'); + await page.waitForTimeout(300); + await expect(page.getByRole('button', { name: 'Default', exact: true, pressed: true })).toBeVisible(); + await assertNoOverflow(page); + }); + + test('expanding a zen peek seam restores the full sidebar', async ({ page }) => { + await gotoProject(page); + await page.getByRole('button', { name: 'Zen', exact: true }).click(); + await page.waitForTimeout(400); + await page + .getByRole('button', { name: /Navigation \(Zen mode\)\. Activate to expand\./ }) + .click(); + await page.waitForTimeout(400); + // Back to default: the segmented toggle is visible again. + await expect(page.getByRole('button', { name: 'Default', exact: true, pressed: true })).toBeVisible(); + await assertNoOverflow(page); + await screenshot(page, 'focus-mode-zen-expanded-desktop'); + }); +}); + +// --------------------------------------------------------------------------- +// Mobile tests (375x667 default) — Focus Mode must stay disabled. +// --------------------------------------------------------------------------- + +test.describe('Focus Mode sidebars — Mobile', () => { + test.beforeEach(({ browserName }, testInfo) => { + test.skip( + testInfo.project.name.includes('Desktop'), + `Mobile audit skipped for ${testInfo.project.name} on ${browserName}`, + ); + }); + + test('mobile never renders the Focus Mode toggle and has no overflow', async ({ page }) => { + await gotoProject(page); + // Focus Mode is desktop-only; the toggle group must not exist on mobile. + await expect(page.getByRole('group', { name: 'Focus Mode' })).toHaveCount(0); + // Pressing F is a no-op on mobile — no zen seams appear. + await page.keyboard.press('f'); + await page.waitForTimeout(300); + await expect( + page.getByRole('button', { name: /Zen mode\)\. Activate to expand\./ }), + ).toHaveCount(0); + await assertNoOverflow(page); + await screenshot(page, 'focus-mode-mobile'); + }); +}); diff --git a/apps/web/tests/unit/components/FocusModeToggle.test.tsx b/apps/web/tests/unit/components/FocusModeToggle.test.tsx new file mode 100644 index 000000000..312002d54 --- /dev/null +++ b/apps/web/tests/unit/components/FocusModeToggle.test.tsx @@ -0,0 +1,75 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import { FocusModeToggle } from '../../../src/components/FocusModeToggle'; + +describe('FocusModeToggle', () => { + describe('segmented variant', () => { + it('marks the active mode with aria-pressed', () => { + render( + , + ); + const focusBtn = screen.getByRole('button', { name: 'Focus', pressed: true }); + expect(focusBtn).toBeInTheDocument(); + // The other two should not be pressed + expect(screen.getByRole('button', { name: 'Default' })).toHaveAttribute( + 'aria-pressed', + 'false', + ); + }); + + it('calls onSelect with the chosen mode when a segment is clicked', () => { + const onSelect = vi.fn(); + render( + , + ); + fireEvent.click(screen.getByRole('button', { name: 'Zen' })); + expect(onSelect).toHaveBeenCalledWith('zen'); + }); + + it('calls onCycle when the cycle button is clicked', () => { + const onCycle = vi.fn(); + render( + , + ); + fireEvent.click(screen.getByRole('button', { name: 'Cycle Focus Mode' })); + expect(onCycle).toHaveBeenCalledTimes(1); + }); + }); + + describe('cycle variant', () => { + it('calls onCycle when the compact button is clicked', () => { + const onCycle = vi.fn(); + render( + , + ); + // aria-label announces current + next mode + fireEvent.click( + screen.getByRole('button', { + name: /Focus Mode: Focus\. Activate to switch to Zen/, + }), + ); + expect(onCycle).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/apps/web/tests/unit/lib/focus-mode.test.ts b/apps/web/tests/unit/lib/focus-mode.test.ts new file mode 100644 index 000000000..6ee390db0 --- /dev/null +++ b/apps/web/tests/unit/lib/focus-mode.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from 'vitest'; + +import { + FOCUS_MODE_ORDER, + FOCUS_MODE_STORAGE_KEY, + type FocusMode, + focusModeLabel, + isFocusMode, + NAV_WIDTH_DEFAULT, + NAV_WIDTH_FOCUS, + navWidthForMode, + nextFocusMode, + SESSION_WIDTH_DEFAULT, + SESSION_WIDTH_FOCUS, + sessionWidthForMode, +} from '../../../src/lib/focus-mode'; + +describe('focus-mode', () => { + describe('isFocusMode', () => { + it('accepts the three valid modes', () => { + expect(isFocusMode('default')).toBe(true); + expect(isFocusMode('focus')).toBe(true); + expect(isFocusMode('zen')).toBe(true); + }); + + it('rejects unknown / malformed values', () => { + expect(isFocusMode('full')).toBe(false); + expect(isFocusMode('')).toBe(false); + expect(isFocusMode(null)).toBe(false); + expect(isFocusMode(undefined)).toBe(false); + expect(isFocusMode(0)).toBe(false); + }); + }); + + describe('nextFocusMode', () => { + it('cycles default → focus → zen → default', () => { + expect(nextFocusMode('default')).toBe('focus'); + expect(nextFocusMode('focus')).toBe('zen'); + expect(nextFocusMode('zen')).toBe('default'); + }); + + it('returns to the start after a full cycle', () => { + let mode: FocusMode = 'default'; + for (let i = 0; i < FOCUS_MODE_ORDER.length; i++) { + mode = nextFocusMode(mode); + } + expect(mode).toBe('default'); + }); + }); + + describe('focusModeLabel', () => { + it('returns a human label for each mode', () => { + expect(focusModeLabel('default')).toBe('Default'); + expect(focusModeLabel('focus')).toBe('Focus'); + expect(focusModeLabel('zen')).toBe('Zen'); + }); + }); + + describe('navWidthForMode', () => { + it('returns full / rail / collapsed widths', () => { + expect(navWidthForMode('default')).toBe(NAV_WIDTH_DEFAULT); + expect(navWidthForMode('focus')).toBe(NAV_WIDTH_FOCUS); + expect(navWidthForMode('zen')).toBe(0); + }); + }); + + describe('sessionWidthForMode', () => { + it('returns full / strip / collapsed widths', () => { + expect(sessionWidthForMode('default')).toBe(SESSION_WIDTH_DEFAULT); + expect(sessionWidthForMode('focus')).toBe(SESSION_WIDTH_FOCUS); + expect(sessionWidthForMode('zen')).toBe(0); + }); + }); + + it('exposes a stable localStorage key', () => { + expect(FOCUS_MODE_STORAGE_KEY).toBe('sam:focus-mode'); + }); +}); From aa498a63fdeef75a3e27cbf989468131d7ab513d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Titsworth-Morin?= Date: Sat, 20 Jun 2026 23:50:02 +0000 Subject: [PATCH 5/9] test: close validator gaps for Focus Mode (localStorage, hydration, tooltip portal, reduced-motion) Adds behavioral coverage flagged by the task-completion-validator: - AppShell: persists focus mode to localStorage + collapses nav column, hydrates persisted mode across reload, F-key cycle, F-key ignored in inputs, mobile-disabled, and prefers-reduced-motion opt-out. - FocusStrip: tooltip is portaled to document.body (escapes glass clipping), hover/focus peek, onSelect/onNewChat wiring, blur close-delay. Co-Authored-By: Claude Opus 4.6 --- apps/web/tests/unit/AppShell.test.tsx | 125 ++++++++++++++++++ .../pages/project-chat/FocusStrip.test.tsx | 121 +++++++++++++++++ 2 files changed, 246 insertions(+) create mode 100644 apps/web/tests/unit/pages/project-chat/FocusStrip.test.tsx diff --git a/apps/web/tests/unit/AppShell.test.tsx b/apps/web/tests/unit/AppShell.test.tsx index ec0135df0..81fbb6e65 100644 --- a/apps/web/tests/unit/AppShell.test.tsx +++ b/apps/web/tests/unit/AppShell.test.tsx @@ -380,3 +380,128 @@ describe('AppShell (mobile)', () => { vi.useRealTimers(); }); }); + +describe('AppShell (Focus Mode — desktop)', () => { + // The grid wrapper carries the collapsing nav column as an inline width. + // navWidthForMode: default=220, focus=56, zen=0 (see src/lib/focus-mode.ts). + function gridColumns(): string { + const grid = document.querySelector('div.grid.h-screen') as HTMLElement | null; + expect(grid).toBeTruthy(); + return grid!.style.gridTemplateColumns; + } + + beforeEach(() => { + matchMediaMatches = false; // desktop — Focus Mode is desktop-only + window.localStorage.clear(); + }); + + afterEach(() => { + window.localStorage.clear(); + vi.restoreAllMocks(); + }); + + it('persists mode changes to localStorage and collapses the nav column', () => { + const setItem = vi.spyOn(Storage.prototype, 'setItem'); + renderAppShell(); + + // Default: full nav rail (220px) and the segmented toggle is visible. + expect(gridColumns()).toBe('220px 1fr'); + expect(screen.getByRole('group', { name: 'Focus Mode' })).toBeInTheDocument(); + + // Select Focus -> nav collapses to the 56px icon rail, persisted. + fireEvent.click(screen.getByRole('button', { name: 'Focus', exact: true })); + expect(setItem).toHaveBeenCalledWith('sam:focus-mode', 'focus'); + expect(gridColumns()).toBe('56px 1fr'); + + // In focus mode the compact cycle control replaces the segmented group. + const cycle = screen.getByRole('button', { + name: /Focus Mode: Focus\. Activate to switch to Zen/, + }); + fireEvent.click(cycle); + expect(setItem).toHaveBeenCalledWith('sam:focus-mode', 'zen'); + expect(gridColumns()).toBe('0px 1fr'); + }); + + it('hydrates the persisted mode across reloads', () => { + // Simulate a prior session that left Focus Mode in "focus". + window.localStorage.setItem('sam:focus-mode', 'focus'); + renderAppShell(); + + // The hydration effect reads localStorage on mount and collapses to 56px. + expect(gridColumns()).toBe('56px 1fr'); + // The compact cycle control (not the segmented group) is shown in focus mode. + expect( + screen.getByRole('button', { name: /Focus Mode: Focus\. Activate to switch to Zen/ }), + ).toBeInTheDocument(); + expect(screen.queryByRole('group', { name: 'Focus Mode' })).not.toBeInTheDocument(); + }); + + it('cycles default -> focus -> zen -> default with the F key', () => { + renderAppShell(); + expect(gridColumns()).toBe('220px 1fr'); + + fireEvent.keyDown(window, { key: 'f' }); + expect(gridColumns()).toBe('56px 1fr'); + + fireEvent.keyDown(window, { key: 'f' }); + expect(gridColumns()).toBe('0px 1fr'); + + fireEvent.keyDown(window, { key: 'f' }); + expect(gridColumns()).toBe('220px 1fr'); + }); + + it('disables the column transition under prefers-reduced-motion', () => { + renderAppShell(); + const grid = document.querySelector('div.grid.h-screen') as HTMLElement | null; + expect(grid).toBeTruthy(); + // The grid animates grid-template-columns, but must opt out when the user + // requests reduced motion (Tailwind motion-reduce: variant). + expect(grid!.className).toContain('transition-[grid-template-columns]'); + expect(grid!.className).toContain('motion-reduce:transition-none'); + }); + + it('ignores the F key while typing in an input', () => { + renderAppShell(); + const search = screen.getByLabelText('Open command palette'); + // Focus a text-like element and press F — Focus Mode must not cycle. + const input = document.createElement('input'); + document.body.appendChild(input); + input.focus(); + fireEvent.keyDown(input, { key: 'f' }); + expect(gridColumns()).toBe('220px 1fr'); + input.remove(); + expect(search).toBeInTheDocument(); + }); +}); + +describe('AppShell (Focus Mode — mobile is disabled)', () => { + beforeEach(() => { + matchMediaMatches = true; // mobile + window.localStorage.clear(); + }); + + afterEach(() => { + window.localStorage.clear(); + }); + + it('never renders the Focus Mode toggle and ignores a persisted mode', () => { + // Even with a persisted non-default mode, mobile must stay default. + window.localStorage.setItem('sam:focus-mode', 'zen'); + renderAppShell(); + + expect(screen.queryByRole('group', { name: 'Focus Mode' })).not.toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: /Focus Mode: .*Activate to switch/ }), + ).not.toBeInTheDocument(); + // The collapsing desktop grid wrapper is not used on mobile. + expect(document.querySelector('div.grid.h-screen')).toBeNull(); + }); + + it('does not cycle Focus Mode when the F key is pressed', () => { + renderAppShell(); + fireEvent.keyDown(window, { key: 'f' }); + expect( + screen.queryByRole('button', { name: /Focus Mode: .*Activate to switch/ }), + ).not.toBeInTheDocument(); + }); +}); diff --git a/apps/web/tests/unit/pages/project-chat/FocusStrip.test.tsx b/apps/web/tests/unit/pages/project-chat/FocusStrip.test.tsx new file mode 100644 index 000000000..bca17183e --- /dev/null +++ b/apps/web/tests/unit/pages/project-chat/FocusStrip.test.tsx @@ -0,0 +1,121 @@ +/** + * Behavioral tests for the Focus Mode session strip (64px collapsed sidebar). + * + * The most important invariant is the tooltip-portal one (research finding #5): + * the session peek tooltip MUST be parented to `document.body` via createPortal, + * not rendered inline inside the strip. The sidebar's glass ancestors apply + * `contain: paint` / `transform`, which clip and mis-stack a normally-positioned + * tooltip. Parenting to escapes both. These tests render FocusStrip, + * hover/focus a session status icon, and assert the rendered tooltip's parent + * is . + */ +import { act, fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import type { ChatSessionListItem } from '../../../../src/lib/api'; +import { FocusStrip } from '../../../../src/pages/project-chat/FocusStrip'; +import type { TaskInfo } from '../../../../src/pages/project-chat/useTaskGroups'; + +const SESSION_DEFAULTS: ChatSessionListItem = { + id: 'sess-1', + workspaceId: null, + taskId: null, + topic: 'Implement authentication flow', + status: 'active', + messageCount: 5, + startedAt: Date.now() - 60_000, + endedAt: null, + createdAt: Date.now() - 60_000, + lastMessageAt: Date.now(), + isIdle: false, + agentCompletedAt: null, +}; + +function makeSession(overrides: Partial = {}): ChatSessionListItem { + return { ...SESSION_DEFAULTS, ...overrides }; +} + +const SESSIONS = [ + makeSession({ id: 'sess-1', topic: 'Implement authentication flow' }), + makeSession({ id: 'sess-2', topic: 'Refactor credential resolution', status: 'stopped' }), +]; + +function renderStrip(props: Partial> = {}) { + return render( + ()} + onNewChat={vi.fn()} + {...props} + />, + ); +} + +describe('FocusStrip', () => { + it('renders a status-icon button per session plus a New chat button', () => { + renderStrip(); + expect(screen.getByRole('button', { name: 'New chat' })).toBeInTheDocument(); + // aria-label is `${topic} — ${attentionLabel}` (label suffix varies). + expect( + screen.getByRole('button', { name: /^Implement authentication flow — / }), + ).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: /^Refactor credential resolution — / }), + ).toBeInTheDocument(); + }); + + it('calls onNewChat when the New chat button is clicked', () => { + const onNewChat = vi.fn(); + renderStrip({ onNewChat }); + fireEvent.click(screen.getByRole('button', { name: 'New chat' })); + expect(onNewChat).toHaveBeenCalledTimes(1); + }); + + it('calls onSelect with the session id when a status icon is clicked', () => { + const onSelect = vi.fn(); + renderStrip({ onSelect }); + fireEvent.click(screen.getByRole('button', { name: /^Implement authentication flow — / })); + expect(onSelect).toHaveBeenCalledWith('sess-1'); + }); + + it('peeks a tooltip on hover that is portaled to document.body (escapes glass clipping)', () => { + renderStrip(); + expect(screen.queryByTestId('focus-tooltip')).not.toBeInTheDocument(); + + fireEvent.mouseEnter( + screen.getByRole('button', { name: /^Implement authentication flow — / }), + ); + + const tooltip = screen.getByTestId('focus-tooltip'); + expect(tooltip).toHaveAttribute('role', 'tooltip'); + // The critical invariant: the tooltip is a direct child of , not + // nested inside the FocusStrip's (glass-clipped) DOM subtree. + expect(tooltip.parentElement).toBe(document.body); + }); + + it('also peeks the tooltip on keyboard focus (parented to body)', () => { + renderStrip(); + fireEvent.focus(screen.getByRole('button', { name: /^Refactor credential resolution — / })); + const tooltip = screen.getByTestId('focus-tooltip'); + expect(tooltip.parentElement).toBe(document.body); + }); + + it('closes the tooltip after blur (close delay elapsed)', () => { + vi.useFakeTimers(); + try { + renderStrip(); + const icon = screen.getByRole('button', { name: /^Implement authentication flow — / }); + fireEvent.focus(icon); + expect(screen.getByTestId('focus-tooltip')).toBeInTheDocument(); + fireEvent.blur(icon); + act(() => { + vi.advanceTimersByTime(200); + }); + expect(screen.queryByTestId('focus-tooltip')).not.toBeInTheDocument(); + } finally { + vi.useRealTimers(); + } + }); +}); From e1ad78c240646704460a8427bad2735f552c83bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Titsworth-Morin?= Date: Sat, 20 Jun 2026 23:54:58 +0000 Subject: [PATCH 6/9] task: check off Focus Mode checklist (Phase 8 N/A on this branch) --- ...6-06-20-collapsible-focus-mode-sidebars.md | 65 ++++++++++--------- 1 file changed, 33 insertions(+), 32 deletions(-) diff --git a/tasks/active/2026-06-20-collapsible-focus-mode-sidebars.md b/tasks/active/2026-06-20-collapsible-focus-mode-sidebars.md index edb7b7815..fbbc334e0 100644 --- a/tasks/active/2026-06-20-collapsible-focus-mode-sidebars.md +++ b/tasks/active/2026-06-20-collapsible-focus-mode-sidebars.md @@ -1,7 +1,8 @@ # Collapsible "Focus Mode" Sidebars (Desktop) → Production **SAM idea:** `01KVK5TMZXFA1YQC1M8KYEGZ2F` -**Prototype source (throwaway, to be removed):** `apps/web/src/pages/sidebar-collapse-prototype/` (route `/prototype/sidebar-collapse`) +**Note:** This branch forked clean off `main`; the validated prototype design +was implemented directly (no prototype page/route/spec exists here — Phase 8 N/A). ## Problem Statement @@ -51,69 +52,69 @@ mobile keeps its existing drawers). ## Implementation Checklist ### Phase 1 — Shared Focus Mode state -- [ ] Add `FocusMode` type + extend `AppShellContext` with `focusMode`, +- [x] Add `FocusMode` type + extend `AppShellContext` with `focusMode`, `setFocusMode`, `cycleFocusMode`. -- [ ] Persist `focusMode` to `localStorage` (hydrate on mount), desktop only. -- [ ] Bind guarded "F" key handler in AppShell (ignore editable targets; +- [x] Persist `focusMode` to `localStorage` (hydrate on mount), desktop only. +- [x] Bind guarded "F" key handler in AppShell (ignore editable targets; desktop only). -- [ ] Gate the entire feature behind `!isMobile`. +- [x] Gate the entire feature behind `!isMobile`. ### Phase 2 — AppShell nav collapse -- [ ] Derive grid column-1 width from `focusMode` (220 / 56 / 0). Animate with +- [x] Derive grid column-1 width from `focusMode` (220 / 56 / 0). Animate with CSS transition; `motion-reduce` disables it. -- [ ] Add `iconOnly` prop to `NavSidebar` (hide labels, center icons, tooltips). -- [ ] Zen: render nav as a top-half edge-seam overlay (`ZenPeekRail`). -- [ ] Render `FocusModeToggle` in the AppShell desktop header. +- [x] Add `iconOnly` prop to `NavSidebar` (hide labels, center icons, tooltips). +- [x] Zen: render nav as a top-half edge-seam overlay (`ZenPeekRail`). +- [x] Render `FocusModeToggle` in the AppShell desktop header. ### Phase 3 — Session sidebar collapse (project-chat) -- [ ] `project-chat/index.tsx` desktop sidebar reads `focusMode`: +- [x] `project-chat/index.tsx` desktop sidebar reads `focusMode`: 288px list / 64px `FocusStrip` / zen bottom-half seam. -- [ ] Port `FocusStrip` with body-portal tooltip rendering real +- [x] Port `FocusStrip` with body-portal tooltip rendering real `SessionTreeItem`. ### Phase 4 — De-duplicate attention model -- [ ] Add shared `ATTENTION_ICON` map to `lib/chat-session-utils.ts`. -- [ ] Consume it in `SessionItem.tsx` and the focus strip (remove duplicates). +- [x] Add shared `ATTENTION_ICON` map to `lib/chat-session-utils.ts`. +- [x] Consume it in `SessionItem.tsx` and the focus strip (remove duplicates). ### Phase 5 — Reusable building blocks -- [ ] Extract `FocusModeToggle`, `NavRail` (iconOnly), `FocusStrip`, +- [x] Extract `FocusModeToggle`, `NavRail` (iconOnly), `FocusStrip`, `ZenPeekRail` into shared component files under `apps/web/src/components/`. ### Phase 6 — Accessibility -- [ ] Collapsed rails keep tab order; tooltips reachable via focus +- [x] Collapsed rails keep tab order; tooltips reachable via focus (onFocus/onBlur). -- [ ] `aria-pressed`/`aria-label` on toggle; zen seams get a focusable expand +- [x] `aria-pressed`/`aria-label` on toggle; zen seams get a focusable expand affordance. -- [ ] `prefers-reduced-motion` disables transitions. +- [x] `prefers-reduced-motion` disables transitions. ### Phase 7 — Tests -- [ ] Behavioral test: render AppShell, toggle mode, assert grid width changes + +- [x] Behavioral test: render AppShell, toggle mode, assert grid width changes + persists to localStorage. -- [ ] Playwright visual audit retargeted at real routes (desktop 1280 + mobile +- [x] Playwright visual audit retargeted at real routes (desktop 1280 + mobile 375): all 3 modes, no horizontal overflow, tooltip parented to body, zen peek stable. -### Phase 8 — Remove prototype (REQUIRED before merge) -- [ ] Delete `apps/web/src/pages/sidebar-collapse-prototype/`. -- [ ] Remove `/prototype/sidebar-collapse` route from `App.tsx`. -- [ ] Remove/port `tests/playwright/sidebar-collapse-prototype-audit.spec.ts`. -- [ ] Grep for leftover prototype imports. +### Phase 8 — Remove prototype (N/A on this branch) +This branch was forked clean off `main`; the feature was implemented directly +(no `sidebar-collapse-prototype/` page, `/prototype/sidebar-collapse` route, or +prototype audit spec exists here). Verified absent via grep/ls. Nothing to +remove. ## Acceptance Criteria -- [ ] On desktop, a Focus Mode toggle in the AppShell header cycles +- [x] On desktop, a Focus Mode toggle in the AppShell header cycles Default → Focus → Zen, collapsing BOTH sidebars in a coordinated way. -- [ ] Default mode is the unchanged current layout (no behavior change until the +- [x] Default mode is the unchanged current layout (no behavior change until the user opts in). -- [ ] Focus mode shows a 56px nav icon rail + 64px session status strip; hovering +- [x] Focus mode shows a 56px nav icon rail + 64px session status strip; hovering a strip icon peeks the real chat card via a body-portal tooltip. -- [ ] Zen mode tucks both sidebars to glowing edge seams; hovering a seam peeks +- [x] Zen mode tucks both sidebars to glowing edge seams; hovering a seam peeks its panel without flicker. -- [ ] Mode persists across reloads (localStorage) and is desktop-only (mobile +- [x] Mode persists across reloads (localStorage) and is desktop-only (mobile drawers untouched). -- [ ] `prefers-reduced-motion` disables the width/slide transitions. -- [ ] No horizontal overflow at 1280px in any mode; no console errors. -- [ ] The throwaway prototype + route + spec are removed; no leftover imports. +- [x] `prefers-reduced-motion` disables the width/slide transitions. +- [x] No horizontal overflow at 1280px in any mode; no console errors. +- [x] N/A — no throwaway prototype exists on this branch (implemented directly). ## References - SAM idea `01KVK5TMZXFA1YQC1M8KYEGZ2F` From decaf15a675ce7c93ae851ecf5f6f3a4d124ef2c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Titsworth-Morin?= Date: Sat, 20 Jun 2026 23:59:49 +0000 Subject: [PATCH 7/9] task: archive collapsible-focus-mode-sidebars (validator PASS) --- .../2026-06-20-collapsible-focus-mode-sidebars.md | 5 +++++ 1 file changed, 5 insertions(+) rename tasks/{active => archive}/2026-06-20-collapsible-focus-mode-sidebars.md (94%) diff --git a/tasks/active/2026-06-20-collapsible-focus-mode-sidebars.md b/tasks/archive/2026-06-20-collapsible-focus-mode-sidebars.md similarity index 94% rename from tasks/active/2026-06-20-collapsible-focus-mode-sidebars.md rename to tasks/archive/2026-06-20-collapsible-focus-mode-sidebars.md index fbbc334e0..89281fb78 100644 --- a/tasks/active/2026-06-20-collapsible-focus-mode-sidebars.md +++ b/tasks/archive/2026-06-20-collapsible-focus-mode-sidebars.md @@ -3,6 +3,11 @@ **SAM idea:** `01KVK5TMZXFA1YQC1M8KYEGZ2F` **Note:** This branch forked clean off `main`; the validated prototype design was implemented directly (no prototype page/route/spec exists here — Phase 8 N/A). +**Wording clarification (validator LOW):** the `FocusModeToggle` lives at the +bottom of the desktop nav `