Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
255 changes: 203 additions & 52 deletions apps/web/src/components/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,17 @@
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';
Expand All @@ -19,12 +27,23 @@
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<AppShellContextValue>({ setProjectName: () => {} });
const AppShellContext = createContext<AppShellContextValue>({
setProjectName: () => {},
focusMode: 'default',
setFocusMode: () => {},
cycleFocusMode: () => {},
});

export function useAppShell() {
return useContext(AppShellContext);
Expand All @@ -34,7 +53,7 @@
children?: ReactNode;
}

export function AppShell({ children }: AppShellProps) {

Check failure on line 56 in apps/web/src/components/AppShell.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this function to reduce its Cognitive Complexity from 28 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=raphaeltm_simple-agent-manager&issues=AZ7niVhwJVgO0TOTvGM0&open=AZ7niVhwJVgO0TOTvGM0&pullRequest=1375
const { user, isSuperadmin } = useAuth();
const isMobile = useIsMobile();
const location = useLocation();
Expand All @@ -42,6 +61,7 @@
const [drawerOpen, setDrawerOpen] = useState(false);
const [projectName, setProjectNameState] = useState<string | undefined>(undefined);
const [showGlobalNav, setShowGlobalNav] = useState(false);
const [focusModeState, setFocusModeState] = useState<FocusMode>('default');
const commandPalette = useGlobalCommandPalette();
const { projects: sidebarProjects, loading: sidebarProjectsLoading } = useProjectList({
limit: 50,
Expand All @@ -56,6 +76,61 @@
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);

Expand Down Expand Up @@ -174,7 +249,14 @@
</div>
);

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 (
Expand Down Expand Up @@ -245,59 +327,128 @@
<AppShellContext.Provider value={shellContext}>
<OnboardingProvider>
<ChoosePathWizard />
<div className="grid h-screen overflow-hidden" style={{ gridTemplateColumns: '220px 1fr', gridTemplateRows: 'minmax(0, 1fr) auto' }}>
<aside className="relative z-30 glass-panel-container glass-composited flex flex-col glass-chrome border-y-0 border-l-0 overflow-y-auto" style={{ gridRow: '1' }}>
<div className="p-4 border-b border-border-default flex items-center justify-between">
<img src="/sam-head.png" alt="SAM" className="h-6 w-6 object-contain" />
<div className="flex items-center gap-1">
<RecentChatsDropdown />
<NotificationCenter />
</div>
</div>
{/* Command palette trigger */}
<button
onClick={commandPalette.open}
className="mx-2 mt-2 flex items-center gap-2 px-3 py-1.5 rounded-sm bg-transparent border border-border-default text-fg-muted text-xs cursor-pointer hover:bg-surface-hover hover:text-fg-primary transition-colors"
aria-label="Open command palette"
<div
className="grid h-screen overflow-hidden transition-[grid-template-columns] duration-200 ease-out motion-reduce:transition-none"
style={{ gridTemplateColumns: `${navWidthForMode(focusMode)}px 1fr`, gridTemplateRows: 'minmax(0, 1fr) auto' }}
>
{/* Announce Focus Mode changes to assistive tech (mode is cycled via the
"F" key or the toggle, so screen readers need a live region). */}
<div aria-live="polite" className="sr-only">
{focusMode === 'default'
? 'Default view'
: focusMode === 'focus'
? 'Focus mode: sidebars collapsed'
: 'Zen mode: sidebars hidden'}

Check warning on line 341 in apps/web/src/components/AppShell.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Extract this nested ternary operation into an independent statement.

See more on https://sonarcloud.io/project/issues?id=raphaeltm_simple-agent-manager&issues=AZ7niVhwJVgO0TOTvGM6&open=AZ7niVhwJVgO0TOTvGM6&pullRequest=1375
</div>
{focusMode === 'zen' ? (
<ZenPeekRail
edge="nav"
label="Navigation"
onExpand={() => setFocusMode('default')}
gridRow="1"
>
<Search size={12} />
<span className="flex-1 text-left">Search...</span>
<kbd className="font-mono text-[10px] bg-inset border border-border-default rounded px-1 py-0.5">
{isMacPlatform() ? '\u2318K' : 'Ctrl+K'}
</kbd>
</button>
<NavSidebar
projectName={projectName}
showGlobalNav={showGlobalNav}
onToggleGlobalNav={handleToggleGlobalNav}
projectListSection={desktopProjectListSection}
projectHealthElement={projectHealthElement}
/>
{user && (
<div className="mt-auto border-t border-[var(--sam-glass-border-color)] bg-[var(--sam-chrome-footer-bg)]">
<div className="px-3 pt-3">
<ThemeSwitcher />
</div>
<div className="p-3 flex items-center gap-2">
{avatarElement}
<div className="flex-1 min-w-0">
<div className="text-xs font-medium text-fg-primary overflow-hidden text-ellipsis whitespace-nowrap">
{user.name || user.email}
</div>
<NavSidebar
projectName={projectName}
showGlobalNav={showGlobalNav}
onToggleGlobalNav={handleToggleGlobalNav}
projectListSection={desktopProjectListSection}
projectHealthElement={projectHealthElement}
/>
</ZenPeekRail>
) : (
<aside className="relative z-30 glass-panel-container glass-composited flex flex-col glass-chrome border-y-0 border-l-0 overflow-y-auto overflow-x-hidden" style={{ gridRow: '1' }}>
<div className={`p-4 border-b border-border-default flex items-center ${focusMode === 'focus' ? 'justify-center' : 'justify-between'}`}>
<img src="/sam-head.png" alt="SAM" className="h-6 w-6 object-contain" />
{focusMode !== 'focus' && (
<div className="flex items-center gap-1">
<RecentChatsDropdown />
<NotificationCenter />
</div>
<button
onClick={handleSignOut}
aria-label="Sign out"
className="bg-transparent border-none text-fg-muted cursor-pointer p-1 text-xs hover:text-danger-fg transition-colors"
>
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
</svg>
</button>
</div>
)}
</div>
)}
</aside>
{/* Command palette trigger */}
{focusMode === 'focus' ? (
<button
onClick={commandPalette.open}
className="mx-2 mt-2 flex items-center justify-center h-9 rounded-sm bg-transparent border border-border-default text-fg-muted cursor-pointer hover:bg-surface-hover hover:text-fg-primary transition-colors"
aria-label="Open command palette"
title="Search..."
>
<Search size={16} />
</button>
) : (
<button
onClick={commandPalette.open}
className="mx-2 mt-2 flex items-center gap-2 px-3 py-1.5 rounded-sm bg-transparent border border-border-default text-fg-muted text-xs cursor-pointer hover:bg-surface-hover hover:text-fg-primary transition-colors"
aria-label="Open command palette"
>
<Search size={12} />
<span className="flex-1 text-left">Search...</span>
<kbd className="font-mono text-[10px] bg-inset border border-border-default rounded px-1 py-0.5">
{isMacPlatform() ? '\u2318K' : 'Ctrl+K'}
</kbd>
</button>
)}
<NavSidebar
projectName={projectName}
showGlobalNav={showGlobalNav}
onToggleGlobalNav={handleToggleGlobalNav}
projectListSection={desktopProjectListSection}
projectHealthElement={projectHealthElement}
iconOnly={focusMode === 'focus'}
/>
<div className={`mt-auto ${focusMode === 'focus' ? 'px-1 py-2' : 'px-2 py-2'}`}>
<FocusModeToggle
mode={focusMode}
onSelect={setFocusMode}
onCycle={cycleFocusMode}
variant={focusMode === 'focus' ? 'cycle' : 'segmented'}
/>
</div>
{user && (
<div className="border-t border-[var(--sam-glass-border-color)] bg-[var(--sam-chrome-footer-bg)]">
{focusMode === 'focus' ? (
<div className="p-2 flex flex-col items-center gap-2">
{avatarElement}
<button
onClick={handleSignOut}
aria-label="Sign out"
title="Sign out"
className="bg-transparent border-none text-fg-muted cursor-pointer p-1 text-xs hover:text-danger-fg transition-colors"
>
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
</svg>
</button>
</div>
) : (
<>
<div className="px-3 pt-3">
<ThemeSwitcher />
</div>
<div className="p-3 flex items-center gap-2">
{avatarElement}
<div className="flex-1 min-w-0">
<div className="text-xs font-medium text-fg-primary overflow-hidden text-ellipsis whitespace-nowrap">
{user.name || user.email}
</div>
</div>
<button
onClick={handleSignOut}
aria-label="Sign out"
className="bg-transparent border-none text-fg-muted cursor-pointer p-1 text-xs hover:text-danger-fg transition-colors"
>
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
</svg>
</button>
</div>
</>
)}
</div>
)}
</aside>
)}

<main className="sam-main-content flex-1 overflow-y-auto overflow-x-hidden flex flex-col min-w-0" style={{ gridRow: '1' }}>
{children ?? <Outlet />}
Expand Down
Loading
Loading