Skip to content
Open
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
11 changes: 10 additions & 1 deletion web/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,16 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- viewport-fit=cover lets the layout extend under the notch and home
indicator; whatever sits against an edge pays it back with
env(safe-area-inset-*) padding. interactive-widget=resizes-content
makes the on-screen keyboard shrink the layout instead of floating
over it, which is what keeps the composer and the bottom nav
reachable while typing. -->
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, viewport-fit=cover, interactive-widget=resizes-content"
/>
<!-- Served from the workspace config subtree when a favicon is tracked
there, 404 otherwise. Not a bundled asset: it is per-instance config. -->
<link rel="icon" href="/favicon.ico" />
Expand Down
15 changes: 12 additions & 3 deletions web/src/components/Chat/ChatInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -555,9 +555,15 @@ export function ChatInput({ onSend, onStop, isStreaming, disabled }: {
</div>
)}

{/* Main input */}
{/* Main input.

One row on desktop. On a phone the controls alone can run to six
buttons plus the model picker, which left the textarea a ~90px
stub, so the row wraps instead: controls stay on the first line and
the textarea takes a full-width line of its own below them (see the
`basis-full`/`order-1` pair on it). */}
<div className="px-4 py-3">
<div className="max-w-[var(--chat-width)] mx-auto flex gap-3 items-end">
<div className="max-w-[var(--chat-width)] mx-auto flex flex-wrap md:flex-nowrap gap-2 md:gap-3 items-end">
{/* File attach button */}
<button
onClick={() => fileInputRef.current?.click()}
Expand Down Expand Up @@ -675,7 +681,10 @@ export function ChatInput({ onSend, onStop, isStreaming, disabled }: {
}
rows={1}
disabled={disabled || rewriteActive}
className="flex-1 px-4 py-3 bg-surface-raised border border-border rounded-xl text-[15px] text-text outline-none focus:border-accent/50 resize-none disabled:opacity-50 placeholder:text-text-faint"
// basis-full makes the textarea claim a whole flex line on its
// own; order-1 puts that line under the controls rather than
// above them. Both are undone at `md`, back to a single row.
className="flex-1 basis-full order-1 md:basis-0 md:order-none px-4 py-3 bg-surface-raised border border-border rounded-xl text-[15px] text-text outline-none focus:border-accent/50 resize-none disabled:opacity-50 placeholder:text-text-faint"
/>
{isStreaming ? (
<button
Expand Down
26 changes: 23 additions & 3 deletions web/src/components/Layout/AppShell.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,31 @@
import { Outlet } from 'react-router-dom';
import { NavRail } from './NavRail';
import { BottomNav } from './BottomNav';
import { useIsMobile } from '../../hooks/useMediaQuery';

export function AppShell() {
const isMobile = useIsMobile();

// Phones stack instead of splitting: 56px of permanent chrome is 14% of a
// 412px viewport, and it is the width the transcript and every table need.
// Exactly one of NavRail/BottomNav is mounted, so neither the notification
// poll nor the feature-flag fetch they share runs twice.
//
// One tree for both layouts, with `<Outlet>` in a fixed position among its
// siblings. Returning two different trees would swap the wrapper that owns
// the outlet, so React would unmount and remount the whole active route on
// every crossing of `md` — a rotation would throw away page state such as an
// open dialog, a set of filters or half-typed form input.
//
// The nav stays first in the DOM in both layouts, which is where desktop
// already had it; on a phone `BottomNav` paints itself last with `order-last`
// while keeping that reading order.
return (
<div className="h-screen flex bg-bg">
<NavRail />
<div className="flex-1 min-w-0">
// h-dvh on a phone, not h-screen: the dynamic unit tracks the on-screen
// keyboard, so the bar stays put instead of being pushed off the bottom.
<div className={`flex bg-bg ${isMobile ? 'h-dvh flex-col' : 'h-screen'}`}>
{isMobile ? <BottomNav /> : <NavRail />}
<div className="min-h-0 min-w-0 flex-1">
<Outlet />
</div>
</div>
Expand Down
156 changes: 156 additions & 0 deletions web/src/components/Layout/BottomNav.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import { useEffect, useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { MoreHorizontal, LogOut, X } from 'lucide-react';
import { useAuthStore } from '../../stores/authStore';
import { useNotificationStore } from '../../stores/notificationStore';
import { ws } from '../../api/websocket';
import { api } from '../../api/client';
import { Drawer } from '../ui/Drawer';
import { ThemeToggle } from './ThemeToggle';
import { NAV_ITEMS, PRIMARY_PATHS, type NavItem } from './navItems';

/**
* Phone navigation: four primary destinations plus More, pinned to the bottom.
*
* Bottom rather than top because of the notification badge — it counts
* questions the agent is *blocked on*, which is the reason to open the panel
* at all, so it has to be visible without going looking for it. Thumb reach
* is the bonus.
*
* Replaces the 56px nav rail below `md`; the two are never mounted together
* (AppShell picks one), so the notification poll and feature-flag fetch below
* do not run twice.
*/
export function BottomNav() {
const location = useLocation();
const navigate = useNavigate();
const { logout } = useAuthStore();
const pendingCount = useNotificationStore(s => s.pendingCount);
const loadNotifications = useNotificationStore(s => s.loadNotifications);
const [ultracodeEnabled, setUltracodeEnabled] = useState(false);
const [moreOpen, setMoreOpen] = useState(false);

useEffect(() => {
loadNotifications();
// A 404 is expected until the dashboard backend is loaded after restart.
api.getUltracodeDashboardStatus().then(s => setUltracodeEnabled(s.enabled)).catch(() => {});
}, []); // eslint-disable-line react-hooks/exhaustive-deps

const available = NAV_ITEMS.filter(i => !(i.feature === 'ultracode' && !ultracodeEnabled));
const primary = PRIMARY_PATHS
.map(p => available.find(i => i.path === p))
.filter((i): i is NavItem => Boolean(i));
const overflow = available.filter(i => !PRIMARY_PATHS.includes(i.path));

// "More" counts as selected whenever the current route is one of the
// destinations it hides, so the bar always shows where you are.
const inOverflow = overflow.some(i => location.pathname.startsWith(i.path));

const go = (path: string) => {
navigate(path);
setMoreOpen(false);
};

return (
<>
{/* `order-last` paints the bar at the bottom while leaving it first in
the DOM, which is where the desktop nav rail already sits — so the
reading and tab order is the same on both layouts. */}
<nav
className="order-last flex shrink-0 items-stretch border-t border-border-subtle bg-surface"
style={{ paddingBottom: 'env(safe-area-inset-bottom)' }}
aria-label="Main"
>
{primary.map(({ path, icon: Icon, label }) => {
const active = location.pathname.startsWith(path);
const isNotifs = path === '/notifications';
return (
<button
key={path}
onClick={() => go(path)}
aria-current={active ? 'page' : undefined}
// min-h-14 keeps every target at/above the ~44px touch minimum.
className={`relative flex min-h-14 flex-1 cursor-pointer flex-col items-center justify-center gap-0.5 transition-colors ${
active ? 'text-accent' : 'text-text-dim'
}`}
>
<Icon size={20} />
<span className="text-[10px]">{label}</span>
{isNotifs && pendingCount > 0 && (
<span className="absolute right-1/2 top-1.5 flex h-4 w-4 translate-x-3 items-center justify-center rounded-full bg-red-500 text-[9px] font-medium text-white">
{pendingCount > 9 ? '9+' : pendingCount}
</span>
)}
</button>
);
})}

<button
onClick={() => setMoreOpen(true)}
aria-expanded={moreOpen}
aria-haspopup="dialog"
// While an overflow destination is active, the real current item is
// inside a closed, inert drawer — so without this the main navigation
// exposes no current item at all and the state is colour-only.
aria-current={inOverflow ? true : undefined}
className={`flex min-h-14 flex-1 cursor-pointer flex-col items-center justify-center gap-0.5 transition-colors ${
inOverflow ? 'text-accent' : 'text-text-dim'
}`}
>
<MoreHorizontal size={20} />
<span className="text-[10px]">More</span>
</button>
</nav>

{/* Right-anchored so it reads as "which section of the app", distinct
from the left drawer's "which item within this section". */}
<Drawer open={moreOpen} onClose={() => setMoreOpen(false)} side="right" label="More destinations">
<div className="flex items-center justify-between border-b border-border-subtle px-4 py-3">
<span className="text-sm font-medium">More</span>
<div className="flex items-center gap-3">
<span
className={`h-2 w-2 rounded-full ${ws.connected ? 'bg-emerald-400' : 'bg-red-400'}`}
title={ws.connected ? 'Connected' : 'Disconnected'}
/>
<ThemeToggle />
{/* Escape closes it too, but only this is discoverable. */}
<button
onClick={() => setMoreOpen(false)}
aria-label="Close menu"
className="cursor-pointer text-text-faint transition-colors hover:text-text-muted"
>
<X size={16} />
</button>
</div>
</div>

<div className="flex-1 overflow-y-auto py-1">
{overflow.map(({ path, icon: Icon, label }) => {
const active = location.pathname.startsWith(path);
return (
<button
key={path}
onClick={() => go(path)}
aria-current={active ? 'page' : undefined}
className={`flex w-full cursor-pointer items-center gap-3 px-4 py-3 text-left text-sm transition-colors ${
active ? 'bg-accent/10 text-accent' : 'text-text-muted hover:bg-surface-hover'
}`}
>
<Icon size={18} />
{label}
</button>
);
})}
</div>

<button
onClick={logout}
className="flex w-full cursor-pointer items-center gap-3 border-t border-border-subtle px-4 py-3 text-left text-sm text-text-faint hover:bg-surface-hover"
>
<LogOut size={18} />
Log out
</button>
</Drawer>
</>
);
}
19 changes: 2 additions & 17 deletions web/src/components/Layout/NavRail.tsx
Original file line number Diff line number Diff line change
@@ -1,27 +1,12 @@
import { useEffect, useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { MessageSquare, FolderOpen, CheckSquare, Inbox, Activity, Brain, LogOut, Clock, Lightbulb, Sparkles, Bell, Plug, Workflow, Rocket } from 'lucide-react';
import { LogOut } from 'lucide-react';
import { useAuthStore } from '../../stores/authStore';
import { useNotificationStore } from '../../stores/notificationStore';
import { ws } from '../../api/websocket';
import { api } from '../../api/client';
import { ThemeToggle } from './ThemeToggle';

const NAV_ITEMS = [
{ path: '/chat', icon: MessageSquare, label: 'Chat' },
{ path: '/notifications', icon: Bell, label: 'Notifs' },
{ path: '/files', icon: FolderOpen, label: 'Files' },
{ path: '/tasks', icon: CheckSquare, label: 'Tasks' },
{ path: '/plans', icon: Lightbulb, label: 'Plans' },
{ path: '/skills', icon: Sparkles, label: 'Skills' },
{ path: '/mcp', icon: Plug, label: 'MCP' },
{ path: '/ultracode', icon: Workflow, label: 'Ultra', feature: 'ultracode' as const },
{ path: '/workflow-runs', icon: Rocket, label: 'Runs' },
{ path: '/sources', icon: Inbox, label: 'Sources' },
{ path: '/cron', icon: Clock, label: 'Cron' },
{ path: '/memory', icon: Brain, label: 'Memory' },
{ path: '/diagnostics', icon: Activity, label: 'Diag' },
];
import { NAV_ITEMS } from './navItems';

export function NavRail() {
const location = useLocation();
Expand Down
42 changes: 42 additions & 0 deletions web/src/components/Layout/navItems.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import {
MessageSquare, FolderOpen, CheckSquare, Inbox, Activity, Brain, Clock,
Lightbulb, Sparkles, Bell, Plug, Workflow, Rocket,
} from 'lucide-react';

export type NavItem = {
path: string;
icon: typeof MessageSquare;
label: string;
/** Hidden unless the named feature is enabled. */
feature?: 'ultracode';
};

export const NAV_ITEMS: NavItem[] = [
{ path: '/chat', icon: MessageSquare, label: 'Chat' },
{ path: '/notifications', icon: Bell, label: 'Notifs' },
{ path: '/files', icon: FolderOpen, label: 'Files' },
{ path: '/tasks', icon: CheckSquare, label: 'Tasks' },
{ path: '/plans', icon: Lightbulb, label: 'Plans' },
{ path: '/skills', icon: Sparkles, label: 'Skills' },
{ path: '/mcp', icon: Plug, label: 'MCP' },
{ path: '/ultracode', icon: Workflow, label: 'Ultra', feature: 'ultracode' },
{ path: '/workflow-runs', icon: Rocket, label: 'Runs' },
{ path: '/sources', icon: Inbox, label: 'Sources' },
{ path: '/cron', icon: Clock, label: 'Cron' },
{ path: '/memory', icon: Brain, label: 'Memory' },
{ path: '/diagnostics', icon: Activity, label: 'Diag' },
];

/**
* The four destinations that keep a permanent slot in the phone's bottom bar;
* everything else lives behind "More".
*
* These are the ones you open to *decide* something — read what the agent
* said, check a task, approve a plan, answer a blocking question. Notifs
* earns its slot by carrying the pending-question badge, which is the whole
* reason to open the panel on a phone and is useless if it is hidden behind
* a menu. The rest (files, skills, MCP, cron, memory, diagnostics, sources)
* are configuration and inspection surfaces — reached deliberately, rarely
* in a hurry.
*/
export const PRIMARY_PATHS = ['/chat', '/tasks', '/notifications', '/plans'];
59 changes: 59 additions & 0 deletions web/src/components/ui/Drawer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import type { ReactNode } from 'react';
import { useModalSurface } from '../../hooks/useModalSurface';

/**
* Off-canvas panel over a tap-to-dismiss scrim.
*
* Stays mounted while closed so the slide has something to animate, and
* carries `inert` in that state so a panel parked off-screen cannot be
* reached by tab or read by a screen reader.
*
* While open it is a modal, and `useModalSurface` gives it the matching
* keyboard contract: focus moves in, Tab cycles inside instead of walking onto
* the page behind it, Escape closes it — claimed here, so it stops short of the
* global Escape shortcut that halts a streaming response — and focus goes back
* to whatever opened it. Callers should still put a visible close control in
* their header; Escape alone is not discoverable.
*
* `side` distinguishes the two jobs navigation does on a phone: `left` for
* "which item within this section" (the chat session list), `right` for
* "which section of the app" (the nav overflow behind More).
*/
export function Drawer({ open, onClose, side = 'left', label, children }: {
open: boolean;
onClose: () => void;
side?: 'left' | 'right';
/** Accessible name for the panel — it is a dialog with no visible title. */
label: string;
children: ReactNode;
}) {
const closedTransform = side === 'left' ? '-translate-x-full' : 'translate-x-full';
const { dialogProps } = useModalSurface<HTMLDivElement>(open, onClose);

return (
<>
{open && (
<div
onClick={onClose}
className="fixed inset-0 z-40 bg-black/60 transition-opacity duration-200"
aria-hidden="true"
/>
)}
<div
{...dialogProps}
aria-label={label}
inert={open ? undefined : true}
className={`fixed inset-y-0 z-50 flex w-[85vw] max-w-[320px] flex-col overflow-hidden bg-surface outline-none transition-transform duration-200
${side === 'left' ? 'left-0 border-r' : 'right-0 border-l'} border-border-subtle
${open ? 'translate-x-0' : closedTransform}`}
// The panel spans the full height, so it owns both insets itself.
style={{
paddingTop: 'env(safe-area-inset-top)',
paddingBottom: 'env(safe-area-inset-bottom)',
}}
>
{children}
</div>
</>
);
}
Loading