diff --git a/apps/web/src/components/AppShell.tsx b/apps/web/src/components/AppShell.tsx index 82cc4077f..ab81edf50 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 { + FOCUS_MODE_STORAGE_KEY, + type FocusMode, + 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..c6f8da81e --- /dev/null +++ b/apps/web/src/components/FocusModeToggle.tsx @@ -0,0 +1,95 @@ +import { ArrowLeftRight, Maximize2, Minimize2, Sparkles } from 'lucide-react'; + +import { + FOCUS_MODE_ORDER, + type FocusMode, + 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..5f11353e9 --- /dev/null +++ b/apps/web/src/components/ZenPeekRail.tsx @@ -0,0 +1,90 @@ +import { type ReactNode, useRef, 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 buttonRef = useRef(null); + const isTop = edge === 'nav'; + const panelWidth = edge === 'nav' ? 220 : 288; + + return ( +
+
setOpen(true)} + onMouseLeave={(e) => { + // Keep the panel open while focus lives inside it (e.g. tabbing + // through sidebar links) so the pointer leaving doesn't yank it shut. + if (!e.currentTarget.contains(document.activeElement)) setOpen(false); + }} + onFocusCapture={() => setOpen(true)} + onBlurCapture={(e) => { + if (!e.currentTarget.contains(e.relatedTarget as Node)) setOpen(false); + }} + onKeyDown={(e) => { + if (e.key === 'Escape' && open) { + setOpen(false); + buttonRef.current?.focus(); + } + }} + > + + + {open && ( + + )} +
+
+ ); +} 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/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; + } +} 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..b9afdc44f --- /dev/null +++ b/apps/web/src/pages/project-chat/FocusStrip.tsx @@ -0,0 +1,165 @@ +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, + taskInfoMap, + onShowHierarchy, + onNewChat, +}: { + sessions: ChatSessionListItem[]; + selectedSessionId: string | null; + onSelect: (id: string) => 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..14ea0022e 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,37 @@ 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 + )}
)} 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/AppShell.test.tsx b/apps/web/tests/unit/AppShell.test.tsx index ec0135df0..8c2164ff8 100644 --- a/apps/web/tests/unit/AppShell.test.tsx +++ b/apps/web/tests/unit/AppShell.test.tsx @@ -380,3 +380,140 @@ 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(); + }); + + it('ignores the F key while a select element is focused', () => { + renderAppShell(); + // A native