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
87 changes: 63 additions & 24 deletions ui/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,21 +23,35 @@ export type Page =
| 'trading-as-git'
| 'settings' | 'dev'

/** Track whether we're at a desktop viewport (md+ in Tailwind = ≥768px). */
function useIsDesktop(): boolean {
const query = '(min-width: 768px)'
/** Subscribe to a CSS media query, SSR-safe (defaults to matched). */
function useMediaQuery(query: string): boolean {
const [matches, setMatches] = useState(() =>
typeof window !== 'undefined' ? window.matchMedia(query).matches : true,
)
useEffect(() => {
const mq = window.matchMedia(query)
const handler = () => setMatches(mq.matches)
setMatches(mq.matches) // re-sync in case the query changed between renders
mq.addEventListener('change', handler)
return () => mq.removeEventListener('change', handler)
}, [])
}, [query])
return matches
}

/**
* Two breakpoints drive a three-tier responsive shell:
* - <768 (phone): rail = drawer (hamburger), sidebar = drawer (drill-in)
* - 768–1024 (tablet/narrow): rail = static column, sidebar = drawer
* (tap a rail icon to slide it in) — keeps the main pane full-width-
* minus-rail so its `md:` content layouts have real room
* - ≥1024 (desktop): rail + sidebar both static (classic 3-pane)
* The middle tier is what kills the old 768–~1000px dead zone where two
* static left columns (216+200) crushed the main pane and its md:-keyed
* content (stat grids, tables) overflowed/overlapped.
*/
const useIsDesktop = () => useMediaQuery('(min-width: 768px)') // rail static
const useIsWide = () => useMediaQuery('(min-width: 1024px)') // sidebar static

export function App() {
return (
<WorkspacesProvider>
Expand All @@ -56,8 +70,9 @@ function AppShell() {
const selectedSidebar = useWorkspace((state) => state.selectedSidebar)
const focusedTabId = useWorkspace((state) => getFocusedTab(state)?.id ?? null)
const section = findSectionForActivity(selectedSidebar)
const isDesktop = useIsDesktop()
const showSidebarPanel = isDesktop && section != null
const isDesktop = useIsDesktop() // ≥768 — rail is a static column
const isWide = useIsWide() // ≥1024 — sidebar is a static panel
const showSidebarPanel = isWide && section != null

// Auto-close the mobile secondary drawer once the user picks a sub-item.
// We snapshot the focused tab at drawer-open time (see openSecondaryDrawer
Expand All @@ -76,14 +91,27 @@ function AppShell() {
}
}, [focusedTabId, secondaryOpen])

// If we cross into desktop while a mobile drawer is open, drop the drawer
// state — the static columns now own the rendering.
// When a tier's static column takes over, drop its drawer state. The rail
// goes static at ≥768 (drop the activity drawer); the sidebar goes static
// at ≥1024 (drop the secondary drawer). Kept as two effects so the middle
// tier — rail static, sidebar still a drawer — settles correctly.
useEffect(() => {
if (isDesktop) {
setSidebarOpen(false)
setSecondaryOpen(false)
}
if (isDesktop) setSidebarOpen(false)
}, [isDesktop])
useEffect(() => {
if (isWide) setSecondaryOpen(false)
}, [isWide])

// Lock body scroll while a drawer is open so the page behind doesn't drift
// under the backdrop. Restores the previous value on close/unmount.
useEffect(() => {
if (!sidebarOpen && !secondaryOpen) return
const prev = document.body.style.overflow
document.body.style.overflow = 'hidden'
return () => {
document.body.style.overflow = prev
}
}, [sidebarOpen, secondaryOpen])

// Persist the user's resized layout to localStorage. `panelIds` scopes the
// saved layout to the current panel set — sidebar+main and main-only get
Expand Down Expand Up @@ -130,12 +158,14 @@ function AppShell() {
<ActivityBar
open={sidebarOpen}
onClose={() => setSidebarOpen(false)}
sidebarVisible={showSidebarPanel || secondaryOpen}
onItemActivated={(landedOn) => {
// Mobile drill-down: close the activity drawer and slide in the
// secondary navigator for the landed-on section. If the user
// toggled the current section off (landedOn === null), just close.
// Drill-down for any viewport without a static sidebar (<1024):
// close the activity drawer and slide in the secondary navigator
// for the landed-on section. If the user toggled the current
// section off (landedOn === null), just close.
setSidebarOpen(false)
if (!isDesktop && landedOn != null) {
if (!isWide && landedOn != null) {
// Snapshot post-click state — `defaultTab` may have just changed
// the focused tab synchronously via Zustand, and we want THAT to
// be the baseline (not the pre-click value the closure captured).
Expand All @@ -155,7 +185,13 @@ function AppShell() {
>
{showSidebarPanel && section && (
<>
<Panel id="sidebar" defaultSize={20} minSize="200px" maxSize="500px">
<Panel
id="sidebar"
defaultSize={20}
minSize="200px"
maxSize="420px"
groupResizeBehavior="preserve-pixel-size"
>
<Sidebar
title={t(section.titleKey)}
actions={section.Actions ? <section.Actions /> : undefined}
Expand All @@ -171,18 +207,21 @@ function AppShell() {
</Panel>
</Group>

{/* Mobile-only secondary sidebar drawer — drills in after the user
picks an activity in the ActivityBar drawer. Desktop renders the
sidebar as a static Panel above; this branch is gated on !isDesktop
{/* Secondary sidebar drawer for any non-wide viewport (<1024) —
drills in after the user picks an activity (from the rail drawer
on phones, or the static rail at 768–1024). At ≥1024 the sidebar
renders as a static Panel above; this branch is gated on !isWide
so the two never co-exist. */}
{!isDesktop && section && (
{!isWide && section && (
<MobileSecondaryDrawer
open={secondaryOpen}
section={section}
onClose={() => setSecondaryOpen(false)}
onBack={() => {
setSecondaryOpen(false)
setSidebarOpen(true)
// Only re-open the rail drawer when the rail is itself a drawer
// (<768). At 768–1024 the rail is static, so just close.
if (!isDesktop) setSidebarOpen(true)
}}
/>
)}
Expand All @@ -205,15 +244,15 @@ function MobileSecondaryDrawer({ open, section, onClose, onBack }: MobileSeconda
return (
<>
<div
className={`fixed inset-0 bg-black/50 z-40 md:hidden transition-opacity duration-200 ${
className={`fixed inset-0 bg-black/50 z-40 lg:hidden transition-opacity duration-200 ${
open ? 'opacity-100' : 'opacity-0 pointer-events-none'
}`}
onClick={onClose}
/>
<div
className={`
fixed top-0 left-0 z-50 h-full w-[280px] max-w-[85vw]
md:hidden
lg:hidden
transition-transform duration-200
${open ? 'translate-x-0' : '-translate-x-full'}
`}
Expand Down
57 changes: 38 additions & 19 deletions ui/src/components/ActivityBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,15 @@ function activitySectionFor(page: Page): ActivitySection {
interface ActivityBarProps {
open: boolean
onClose: () => void
/**
* Whether the secondary sidebar is actually on screen right now (a static
* panel on wide, or the open drawer on narrow). Re-clicking the active
* item only *collapses* the sidebar when it's visible; if it's hidden
* (e.g. landed on /portfolio at a tablet width with the drawer closed),
* re-clicking re-opens it instead of toggling the selection off. Defaults
* to true so the collapse gesture works when the prop isn't wired.
*/
sidebarVisible?: boolean
/**
* Called after the user activates an item. Receives the activity the user
* landed on (or null if they collapsed the current one by re-clicking it).
Expand Down Expand Up @@ -149,19 +158,23 @@ const NAV_SECTIONS: NavSection[] = [
// ==================== ActivityBar ====================

/**
* Linear-style left nav. 200px wide on all viewports; on mobile (<md)
* it slides in over the page from the left, on desktop it's a static
* column. Top section (no header) is the pinned-nav block — Chat,
* Inbox, Workspaces, etc. — always visible. Labeled sections (Agent,
* System) get collapsible chevron headers; collapse state persists
* to localStorage.
* Linear-style left nav. 200px wide on desktop; on mobile (<md) it
* slides in over the page from the left as a 280px drawer (matching
* the secondary drawer so a drill-in doesn't jump width), on desktop
* it's a static column. The recessed-rail look comes from bg-tertiary
* (one elevation step up from the secondary Sidebar and the base main
* pane) — rail → sidebar → main read as three distinct tiers. Top
* section (no header) is the pinned-nav block — Chat, Inbox,
* Workspaces, etc. — always visible. Labeled sections (Agent, System)
* get collapsible chevron headers; collapse state persists to
* localStorage.
*
* The wider layout (vs VS Code's 56px icon-only column) is deliberate
* for OpenAlice's current phase: items in the bar live in different
* lifecycle stages and the section labels are how we'll later
* communicate that. Mostly-icon view would hide the differentiation.
*/
export function ActivityBar({ open, onClose, onItemActivated }: ActivityBarProps) {
export function ActivityBar({ open, onClose, onItemActivated, sidebarVisible = true }: ActivityBarProps) {
const { t } = useTranslation()
const selectedSidebar = useWorkspace((state) => state.selectedSidebar)
const setSidebar = useWorkspace((state) => state.setSidebar)
Expand All @@ -185,20 +198,21 @@ export function ActivityBar({ open, onClose, onItemActivated }: ActivityBarProps
* page with backdrop. Desktop: static column flush left. */}
<aside
className={`
w-[216px] h-full flex flex-col shrink-0
bg-bg-secondary
w-[280px] md:w-[200px] h-full flex flex-col shrink-0
bg-bg-tertiary
border-r border-border/80
fixed z-50 top-0 left-0 transition-transform duration-200
${open ? 'translate-x-0' : '-translate-x-full'}
md:static md:translate-x-0 md:z-auto md:transition-none
`}
>
{/* Branding */}
<div className="px-5 py-4 flex items-center gap-2.5">
{/* Branding — h-10 to line up with the Sidebar header + TabStrip
(all three top surfaces share the 40px header rhythm). */}
<div className="h-10 px-4 flex items-center gap-2.5 shrink-0">
<img
src="/alice.ico"
alt="Alice"
className="w-7 h-7 rounded-full ring-1 ring-border shadow-[0_0_14px_var(--color-accent-dim)]"
className="w-6 h-6 rounded-full ring-1 ring-border shadow-[0_0_14px_var(--color-accent-dim)]"
draggable={false}
/>
<h1 className="min-w-0 flex-1 truncate text-[15px] font-semibold text-text">OpenAlice</h1>
Expand Down Expand Up @@ -245,10 +259,13 @@ export function ActivityBar({ open, onClose, onItemActivated }: ActivityBarProps
const hasSidebar = findSectionForActivity(sec) != null
const handleClick = () => {
let landedOn: ActivitySection | null
if (selectedSidebar === sec && hasSidebar) {
// Same section re-clicked: toggle sidebar off. Don't
// touch the focused tab — collapsing the sidebar
// shouldn't change what's in the editor.
if (selectedSidebar === sec && hasSidebar && sidebarVisible) {
// Same section re-clicked while the sidebar is on
// screen: collapse it. Don't touch the focused tab —
// collapsing the sidebar shouldn't change the editor.
// (When the sidebar is hidden — e.g. a closed drawer
// at tablet width — we fall through to the else branch
// and re-open it instead of toggling selection off.)
setSidebar(null)
landedOn = null
} else {
Expand All @@ -275,7 +292,7 @@ export function ActivityBar({ open, onClose, onItemActivated }: ActivityBarProps
title={t(item.labelKey)}
className={`relative flex items-center gap-3 px-3 py-1.5 rounded-md text-[13px] transition-colors text-left ${
isActive
? 'bg-bg-tertiary text-text shadow-[inset_0_0_0_1px_var(--color-overlay)]'
? 'bg-accent-dim text-text'
: 'text-text-muted hover:text-text hover:bg-overlay'
}`}
>
Expand Down Expand Up @@ -316,8 +333,10 @@ export function ActivityBar({ open, onClose, onItemActivated }: ActivityBarProps
})}
</nav>

{/* Footer — global toggles pinned to the bottom of the rail. */}
<div className="shrink-0 border-t border-border px-3 py-2">
{/* Footer — global toggles pinned to the bottom of the rail.
py-1.5 matches the nav-item rhythm above (the top border
already provides the separation). */}
<div className="shrink-0 border-t border-border px-3 py-1.5">
<ThemeToggle />
</div>
</aside>
Expand Down
2 changes: 1 addition & 1 deletion ui/src/components/PageHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export function PageHeader({ title, description, right, live }: PageHeaderProps)
<div className="px-4 md:px-6 py-5 flex items-center justify-between gap-4">
<div className="min-w-0">
<div className="flex items-center gap-2">
<h2 className="text-lg font-bold text-text truncate">{title}</h2>
<h2 className="text-title font-bold text-text truncate">{title}</h2>
{live && (
<span
className="relative inline-block w-1.5 h-1.5 rounded-full bg-green live-pulse shrink-0"
Expand Down
12 changes: 11 additions & 1 deletion ui/src/components/SidebarRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,20 @@ interface SidebarRowProps {
active?: boolean
/** Click handler for the row body. Trailing actions should `stopPropagation`. */
onClick: () => void
/**
* Optional leading glyph (shrink-0), e.g. an entity-type icon. Pass the
* fully-styled node — the row doesn't impose a size or colour so callers
* keep control (e.g. `<TrendingUp size={13} className="text-text-muted/70" />`).
*/
icon?: ReactNode
/**
* Right-aligned content slot — status badges, counts, hover-revealed
* action buttons. The row uses `group` so consumers can apply
* `opacity-0 group-hover:opacity-100` to reveal-on-hover affordances.
*/
trail?: ReactNode
/** Optional native tooltip for the whole row (e.g. an entity description). */
title?: string
/** Optional disabled / dimmed presentation, e.g. for off-by-default rows. */
dim?: boolean
}
Expand All @@ -32,12 +40,13 @@ interface SidebarRowProps {
* can nest action buttons inside `trail` (HTML disallows nested buttons).
* Enter / Space activate the row for keyboard users.
*/
export function SidebarRow({ label, active = false, onClick, trail, dim = false }: SidebarRowProps) {
export function SidebarRow({ label, active = false, onClick, icon, trail, title, dim = false }: SidebarRowProps) {
return (
<div
role="button"
tabIndex={0}
onClick={onClick}
title={title}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
Expand All @@ -56,6 +65,7 @@ export function SidebarRow({ label, active = false, onClick, trail, dim = false
className="absolute left-0 top-0 bottom-0 w-[2px] bg-accent"
/>
)}
{icon && <span className="shrink-0 flex items-center">{icon}</span>}
<span className="truncate flex-1">{label}</span>
{trail && <div className="shrink-0 flex items-center gap-0.5">{trail}</div>}
</div>
Expand Down
2 changes: 1 addition & 1 deletion ui/src/components/Toast.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ function ToastContainer({ toasts, onDismiss }: { toasts: ToastItem[]; onDismiss:
if (toasts.length === 0) return null

return (
<div className="fixed bottom-4 right-4 z-50 flex flex-col gap-2 pointer-events-none">
<div className="fixed bottom-4 right-4 z-[70] flex flex-col gap-2 pointer-events-none">
{toasts.map((toast) => (
<ToastNotification key={toast.id} toast={toast} onDismiss={() => onDismiss(toast.id)} />
))}
Expand Down
34 changes: 16 additions & 18 deletions ui/src/components/TrackedSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next'
import { TrendingUp, Hash } from 'lucide-react'
import { entitiesLive } from '../live/entities'
import { useTrackedSelection } from '../live/tracked-selection'
import { SidebarRow } from './SidebarRow'

/**
* Tracked sidebar — the watchlist. A flat list of entities (assets + topics),
Expand Down Expand Up @@ -50,27 +51,24 @@ export function TrackedSidebar() {
const active = e.name === selected
const Icon = e.type === 'asset' ? TrendingUp : Hash
return (
<button
<SidebarRow
key={e.name}
type="button"
active={active}
onClick={() => select(e.name)}
title={e.description}
className={`group relative flex items-center gap-2 px-3 py-1.5 text-left transition-colors ${
active ? 'bg-bg-tertiary text-text' : 'text-text hover:bg-bg-tertiary/50'
}`}
>
{active && <span aria-hidden className="absolute left-0 top-0 bottom-0 w-[2px] bg-accent" />}
<Icon size={13} strokeWidth={1.75} className="shrink-0 text-text-muted/70" aria-hidden />
<span className="flex-1 truncate font-mono text-[12px]">{e.name}</span>
{e.backlinkCount > 0 && (
<span
className="shrink-0 text-[10px] text-text-muted/60 tabular-nums"
title={t('tracked.backlinksTooltip', { count: e.backlinkCount })}
>
{e.backlinkCount}
</span>
)}
</button>
icon={<Icon size={13} strokeWidth={1.75} className="text-text-muted/70" aria-hidden />}
label={<span className="font-mono text-[12px]">{e.name}</span>}
trail={
e.backlinkCount > 0 ? (
<span
className="text-[10px] text-text-muted/60 tabular-nums"
title={t('tracked.backlinksTooltip', { count: e.backlinkCount })}
>
{e.backlinkCount}
</span>
) : undefined
}
/>
)
})}
</div>
Expand Down
Loading
Loading