-
Notifications
You must be signed in to change notification settings - Fork 3.4k
feat(web): responsive mobile UI with hamburger menu #1180
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 5 commits
f06a66a
edce118
27c8b53
d7577f6
a45ec74
c49aaa9
a19e630
840822f
720c1ed
ba0dbc7
f74f344
124d8cc
329924a
fcb18f5
0b2cbb1
a7236ca
239d111
2bb9d41
7eae91e
3c9c768
5325681
0c398b5
4c61f54
f2b28ea
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,13 +1,107 @@ | ||
| import { Outlet } from 'react-router'; | ||
| import { TopNav } from './TopNav'; | ||
|
|
||
| export function Layout(): React.ReactElement { | ||
| return ( | ||
| <div className="flex h-screen flex-col bg-background"> | ||
| <TopNav /> | ||
| <main className="flex flex-1 flex-col overflow-hidden"> | ||
| <Outlet /> | ||
| </main> | ||
| </div> | ||
| ); | ||
| } | ||
| import { useState } from 'react'; | ||
| import { Outlet, NavLink } from 'react-router'; | ||
| import { MessageSquare, LayoutDashboard, Workflow, Settings, X } from 'lucide-react'; | ||
| import { TopNav } from './TopNav'; | ||
| import { MobileNavContext } from '@/contexts/MobileNavContext'; | ||
| import { cn } from '@/lib/utils'; | ||
|
|
||
| const navItems = [ | ||
| { to: '/chat', end: false, icon: MessageSquare, label: 'Chat' }, | ||
| { to: '/dashboard', end: true, icon: LayoutDashboard, label: 'Dashboard' }, | ||
| { to: '/workflows', end: false, icon: Workflow, label: 'Workflows' }, | ||
| ] as const; | ||
|
|
||
| export function Layout(): React.ReactElement { | ||
| const [open, setOpen] = useState(false); | ||
|
|
||
| return ( | ||
| <MobileNavContext.Provider value={{ open, setOpen }}> | ||
| <div className="flex h-screen flex-col bg-background"> | ||
| <TopNav /> | ||
|
|
||
| {/* ── Mobile nav overlay backdrop ── */} | ||
| {open && ( | ||
| <div | ||
| className="fixed inset-0 z-40 bg-black/60 md:hidden" | ||
| onClick={() => { setOpen(false); }} | ||
| aria-hidden="true" | ||
| /> | ||
| )} | ||
|
|
||
| {/* ── Mobile nav drawer ── */} | ||
| <aside | ||
| className={cn( | ||
| 'fixed inset-y-0 left-0 z-50 flex w-72 flex-col bg-surface border-r border-border shadow-2xl', | ||
| 'transition-transform duration-300 ease-in-out', | ||
| 'md:hidden', | ||
| open ? 'translate-x-0' : '-translate-x-full' | ||
| )} | ||
| aria-label="Navigation mobile" | ||
| > | ||
| {/* Drawer header */} | ||
| <div className="flex items-center justify-between px-4 py-3 border-b border-border"> | ||
| <div className="flex items-center gap-2"> | ||
| <div className="flex h-7 w-7 items-center justify-center rounded-md bg-primary"> | ||
| <span className="text-sm font-semibold text-primary-foreground">A</span> | ||
| </div> | ||
| <span className="text-sm font-semibold text-text-primary">Archon</span> | ||
| </div> | ||
| <button | ||
| onClick={() => { setOpen(false); }} | ||
| className="flex items-center justify-center rounded-md p-1.5 text-text-secondary hover:bg-surface-elevated hover:text-text-primary transition-colors" | ||
| aria-label="Fermer le menu" | ||
| > | ||
| <X className="h-4 w-4" /> | ||
| </button> | ||
|
coderabbitai[bot] marked this conversation as resolved.
Outdated
|
||
| </div> | ||
|
|
||
| {/* Navigation links */} | ||
| <nav className="flex-1 overflow-y-auto p-2 pt-3"> | ||
| {navItems.map(({ to, end, icon: Icon, label }) => ( | ||
| <NavLink | ||
| key={to} | ||
| to={to} | ||
| end={end} | ||
| onClick={() => { setOpen(false); }} | ||
| className={({ isActive }: { isActive: boolean }): string => | ||
| cn( | ||
| 'flex items-center gap-3 w-full rounded-md px-3 py-2.5 text-sm font-medium transition-colors mb-0.5', | ||
| isActive | ||
| ? 'bg-accent text-primary' | ||
| : 'text-text-secondary hover:bg-surface-elevated hover:text-text-primary' | ||
| ) | ||
| } | ||
| > | ||
| <Icon className="h-4 w-4 shrink-0" /> | ||
| {label} | ||
| </NavLink> | ||
| ))} | ||
| </nav> | ||
|
|
||
| {/* Settings — pinned at bottom */} | ||
| <div className="p-2 border-t border-border"> | ||
| <NavLink | ||
| to="/settings" | ||
| onClick={() => { setOpen(false); }} | ||
| className={({ isActive }: { isActive: boolean }): string => | ||
| cn( | ||
| 'flex items-center gap-3 w-full rounded-md px-3 py-2.5 text-sm font-medium transition-colors', | ||
| isActive | ||
| ? 'bg-accent text-primary' | ||
| : 'text-text-secondary hover:bg-surface-elevated hover:text-text-primary' | ||
| ) | ||
| } | ||
| > | ||
| <Settings className="h-4 w-4 shrink-0" /> | ||
| Settings | ||
| </NavLink> | ||
| </div> | ||
| </aside> | ||
|
|
||
| <main className="flex flex-1 flex-col overflow-hidden"> | ||
| <Outlet /> | ||
| </main> | ||
| </div> | ||
| </MobileNavContext.Provider> | ||
| ); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,78 +1,97 @@ | ||
| import { NavLink, Link } from 'react-router'; | ||
| import { useQuery } from '@tanstack/react-query'; | ||
| import { LayoutDashboard, MessageSquare, Workflow, Settings } from 'lucide-react'; | ||
| import { listWorkflowRuns, getUpdateCheck } from '@/lib/api'; | ||
| import { cn } from '@/lib/utils'; | ||
|
|
||
| const tabs = [ | ||
| { to: '/chat', end: false, icon: MessageSquare, label: 'Chat' }, | ||
| { to: '/dashboard', end: true, icon: LayoutDashboard, label: 'Dashboard' }, | ||
| { to: '/workflows', end: false, icon: Workflow, label: 'Workflows' }, | ||
| { to: '/settings', end: false, icon: Settings, label: 'Settings' }, | ||
| ] as const; | ||
|
|
||
| export function TopNav(): React.ReactElement { | ||
| const { data: runningRuns } = useQuery({ | ||
| queryKey: ['workflowRuns', { status: 'running' }], | ||
| queryFn: () => listWorkflowRuns({ status: 'running', limit: 1 }), | ||
| refetchInterval: 10_000, | ||
| }); | ||
| const hasRunning = (runningRuns?.length ?? 0) > 0; | ||
|
|
||
| const { data: updateCheck } = useQuery({ | ||
| queryKey: ['update-check'], | ||
| queryFn: getUpdateCheck, | ||
| staleTime: 60 * 60 * 1000, | ||
| refetchInterval: 60 * 60 * 1000, | ||
| retry: false, | ||
| }); | ||
|
|
||
| return ( | ||
| <nav className="flex items-center gap-1 border-b border-border bg-surface px-4"> | ||
| {/* Brand logo */} | ||
| <Link to="/chat" className="flex items-center gap-2 mr-4 hover:opacity-80 transition-opacity"> | ||
| <div className="flex h-7 w-7 items-center justify-center rounded-md bg-primary"> | ||
| <span className="text-sm font-semibold text-primary-foreground">A</span> | ||
| </div> | ||
| <span className="text-sm font-semibold text-text-primary">Archon</span> | ||
| </Link> | ||
|
|
||
| {tabs.map(({ to, end, icon: Icon, label }) => ( | ||
| <NavLink | ||
| key={to} | ||
| to={to} | ||
| end={end} | ||
| className={({ isActive }: { isActive: boolean }): string => | ||
| cn( | ||
| 'flex items-center gap-2 px-3 py-3 text-sm font-medium border-b-2 transition-colors', | ||
| isActive | ||
| ? 'border-primary text-primary' | ||
| : 'border-transparent text-text-secondary hover:text-text-primary' | ||
| ) | ||
| } | ||
| > | ||
| <Icon className="h-4 w-4" /> | ||
| {label} | ||
| {to === '/dashboard' && hasRunning && ( | ||
| <span className="flex h-2 w-2 rounded-full bg-primary animate-pulse" /> | ||
| )} | ||
| </NavLink> | ||
| ))} | ||
| <span className="ml-auto text-xs text-text-secondary"> | ||
| v{import.meta.env.VITE_APP_VERSION as string} | ||
| {updateCheck?.updateAvailable && updateCheck.releaseUrl && ( | ||
| <a | ||
| href={updateCheck.releaseUrl} | ||
| target="_blank" | ||
| rel="noopener noreferrer" | ||
| className="ml-1.5 inline-flex items-center gap-1 text-xs text-primary hover:underline" | ||
| title={`v${updateCheck.latestVersion} available`} | ||
| > | ||
| <span className="inline-block h-1.5 w-1.5 rounded-full bg-primary animate-pulse" />v | ||
| {updateCheck.latestVersion} | ||
| </a> | ||
| )} | ||
| </span> | ||
| </nav> | ||
| ); | ||
| } | ||
| import { NavLink, Link } from 'react-router'; | ||
| import { useQuery } from '@tanstack/react-query'; | ||
| import { LayoutDashboard, MessageSquare, Workflow, Settings, Menu } from 'lucide-react'; | ||
| import { listWorkflowRuns, getUpdateCheck } from '@/lib/api'; | ||
| import { cn } from '@/lib/utils'; | ||
| import { useMobileNav } from '@/contexts/MobileNavContext'; | ||
|
|
||
| const tabs = [ | ||
| { to: '/chat', end: false, icon: MessageSquare, label: 'Chat' }, | ||
| { to: '/dashboard', end: true, icon: LayoutDashboard, label: 'Dashboard' }, | ||
| { to: '/workflows', end: false, icon: Workflow, label: 'Workflows' }, | ||
| { to: '/settings', end: false, icon: Settings, label: 'Settings' }, | ||
| ] as const; | ||
|
|
||
| export function TopNav(): React.ReactElement { | ||
| const { setOpen } = useMobileNav(); | ||
|
|
||
| const { data: runningRuns } = useQuery({ | ||
| queryKey: ['workflowRuns', { status: 'running' }], | ||
| queryFn: () => listWorkflowRuns({ status: 'running', limit: 1 }), | ||
| refetchInterval: 10_000, | ||
| }); | ||
| const hasRunning = (runningRuns?.length ?? 0) > 0; | ||
|
|
||
| const { data: updateCheck } = useQuery({ | ||
| queryKey: ['update-check'], | ||
| queryFn: getUpdateCheck, | ||
| staleTime: 60 * 60 * 1000, | ||
| refetchInterval: 60 * 60 * 1000, | ||
| retry: false, | ||
| }); | ||
|
|
||
| return ( | ||
| <nav className="flex items-center gap-1 border-b border-border bg-surface px-4"> | ||
|
|
||
| {/* ── Mobile: hamburger button (hidden on desktop) ── */} | ||
| <button | ||
| onClick={() => { setOpen(true); }} | ||
| className="flex items-center justify-center rounded-md p-1.5 mr-2 text-text-secondary hover:bg-surface-elevated hover:text-text-primary transition-colors md:hidden" | ||
| aria-label="Ouvrir le menu" | ||
| > | ||
| <Menu className="h-5 w-5" /> | ||
| </button> | ||
|
coderabbitai[bot] marked this conversation as resolved.
Outdated
|
||
|
|
||
| {/* Brand logo */} | ||
| <Link to="/chat" className="flex items-center gap-2 mr-4 hover:opacity-80 transition-opacity"> | ||
| <div className="flex h-7 w-7 items-center justify-center rounded-md bg-primary"> | ||
| <span className="text-sm font-semibold text-primary-foreground">A</span> | ||
| </div> | ||
| {/* Logo text: always visible */} | ||
| <span className="text-sm font-semibold text-text-primary">Archon</span> | ||
| </Link> | ||
|
|
||
| {/* ── Desktop nav tabs (hidden on mobile) ── */} | ||
| <div className="hidden md:flex items-center gap-1"> | ||
| {tabs.map(({ to, end, icon: Icon, label }) => ( | ||
| <NavLink | ||
| key={to} | ||
| to={to} | ||
| end={end} | ||
| className={({ isActive }: { isActive: boolean }): string => | ||
| cn( | ||
| 'flex items-center gap-2 px-3 py-3 text-sm font-medium border-b-2 transition-colors', | ||
| isActive | ||
| ? 'border-primary text-primary' | ||
| : 'border-transparent text-text-secondary hover:text-text-primary' | ||
| ) | ||
| } | ||
| > | ||
| <Icon className="h-4 w-4" /> | ||
| {label} | ||
| {to === '/dashboard' && hasRunning && ( | ||
| <span className="flex h-2 w-2 rounded-full bg-primary animate-pulse" /> | ||
| )} | ||
| </NavLink> | ||
| ))} | ||
| </div> | ||
|
|
||
| {/* Version + update badge */} | ||
| <span className="ml-auto text-xs text-text-secondary"> | ||
| v{import.meta.env.VITE_APP_VERSION as string} | ||
| {updateCheck?.updateAvailable && updateCheck.releaseUrl && ( | ||
| <a | ||
| href={updateCheck.releaseUrl} | ||
| target="_blank" | ||
| rel="noopener noreferrer" | ||
| className="ml-1.5 inline-flex items-center gap-1 text-xs text-primary hover:underline" | ||
| title={`v${updateCheck.latestVersion} available`} | ||
| > | ||
| <span className="inline-block h-1.5 w-1.5 rounded-full bg-primary animate-pulse" />v | ||
| {updateCheck.latestVersion} | ||
| </a> | ||
| )} | ||
| </span> | ||
| </nav> | ||
| ); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,15 @@ | ||
| import { createContext, useContext } from 'react'; | ||
|
|
||
| export interface MobileNavContextValue { | ||
| open: boolean; | ||
| setOpen: (open: boolean) => void; | ||
| } | ||
|
|
||
| export const MobileNavContext = createContext<MobileNavContextValue>({ | ||
| open: false, | ||
| setOpen: () => {}, | ||
| }); | ||
|
|
||
| export function useMobileNav(): MobileNavContextValue { | ||
| return useContext(MobileNavContext); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -10,13 +10,16 @@ export type WorkflowDefinition = components['schemas']['WorkflowDefinition']; | |
| export type DagNode = components['schemas']['DagNode']; | ||
|
|
||
| /** | ||
| * Base URL for SSE streams. In dev, bypasses Vite proxy by connecting directly | ||
| * to the backend server. In production, uses relative URLs (same origin). | ||
| * Uses the page hostname so it works from any network interface. | ||
| * Base URL for SSE streams. | ||
| * - On localhost (direct dev access): connect directly to backend, bypassing Vite proxy | ||
| * (avoids proxy buffering of SSE streams). | ||
| * - On any other hostname (Cloudflare tunnel, remote access): use relative URL so | ||
| * requests go through the Vite proxy, which forwards to the backend. | ||
| * This is required because the backend port is not directly accessible remotely. | ||
| */ | ||
| const apiPort = (import.meta.env.VITE_API_PORT as string | undefined) ?? '3090'; | ||
| export const SSE_BASE_URL = import.meta.env.DEV | ||
| ? `http://${window.location.hostname}:${apiPort}` | ||
| export const SSE_BASE_URL = import.meta.env.DEV && window.location.hostname === 'localhost' | ||
| ? `http://localhost:${apiPort}` | ||
| : ''; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Guard module-load Line 21 can throw when this module is evaluated without Suggested fix const apiPort = (import.meta.env.VITE_API_PORT as string | undefined) ?? '3090';
-export const SSE_BASE_URL = import.meta.env.DEV && window.location.hostname === 'localhost'
- ? `http://localhost:${apiPort}`
- : '';
+function getSSEBaseUrl(): string {
+ const hostname = typeof window !== 'undefined' ? window.location.hostname : 'localhost';
+ return import.meta.env.DEV && hostname === 'localhost' ? `http://localhost:${apiPort}` : '';
+}
+
+export const SSE_BASE_URL = getSSEBaseUrl();Based on learnings: implement SSE URL via a lazy 🤖 Prompt for AI Agents |
||
|
|
||
| export interface ConversationResponse { | ||
|
|
@@ -38,6 +41,7 @@ export interface CodebaseResponse { | |
| repository_url: string | null; | ||
| default_cwd: string; | ||
| ai_assistant_type: string; | ||
| allow_env_keys: boolean; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: #!/bin/bash
# Verify backend contract support for allowEnvKeys / allow_env_keys
set -euo pipefail
echo "== Server schemas =="
rg -n --type=ts -C3 'codebaseSchema|addCodebaseBodySchema|allowEnvKeys|allow_env_keys' packages/server/src/routes/schemas
echo "== Server API routes =="
rg -n --type=ts -C3 '/api/codebases|registerOpenApiRoute|allowEnvKeys|allow_env_keys' packages/server/src/routes/api.ts
echo "== Generated web API types =="
rg -n --type=ts -C2 'allow_env_keys|Codebase' packages/web/src/lib/api.generated.tsRepository: coleam00/Archon Length of output: 15737 🏁 Script executed: # Check the web client file at the mentioned lines
cat -n packages/web/src/lib/api.ts | sed -n '40,50p;160,185p'Repository: coleam00/Archon Length of output: 1455 🏁 Script executed: # Read the complete codebase schema file
cat -n packages/server/src/routes/schemas/codebase.schemas.ts | head -50Repository: coleam00/Archon Length of output: 2013 🏁 Script executed: # Search for generated API type files in the web package
fd -t f "api" packages/web/src/lib/ | head -20Repository: coleam00/Archon Length of output: 126 🏁 Script executed: # Search for allowEnvKeys or allow_env_keys in web package
rg -n 'allowEnvKeys|allow_env_keys' packages/web/src/ -t tsRepository: coleam00/Archon Length of output: 307 🏁 Script executed: # Search for updateCodebase schema or PATCH handling
rg -n 'updateCodebaseBodySchema|updateCodebaseSchema|PATCH.*codebases' packages/server/src/routes/ -t tsRepository: coleam00/Archon Length of output: 41 🏁 Script executed: # Search for any mention of allowEnvKeys on server side
rg -n 'allowEnvKeys|allow_env_keys' packages/server/src/ -t tsRepository: coleam00/Archon Length of output: 41 Fix client-server contract misalignment for allowEnvKeys field. The client sends and expects
Either add full server support (schema fields, handlers, database column if needed) or remove 🤖 Prompt for AI Agents |
||
| commands: Record<string, { path: string; description: string }>; | ||
| created_at: string; | ||
| updated_at: string; | ||
|
|
@@ -157,7 +161,7 @@ export async function getCodebase(id: string): Promise<CodebaseResponse> { | |
| } | ||
|
|
||
| export async function addCodebase( | ||
| input: { url: string } | { path: string } | ||
| input: { url: string; allowEnvKeys?: boolean } | { path: string; allowEnvKeys?: boolean } | ||
| ): Promise<CodebaseResponse> { | ||
| return fetchJSON<CodebaseResponse>('/api/codebases', { | ||
| method: 'POST', | ||
|
|
@@ -166,6 +170,17 @@ export async function addCodebase( | |
| }); | ||
| } | ||
|
|
||
| export async function updateCodebase( | ||
| id: string, | ||
| input: { allowEnvKeys: boolean } | ||
| ): Promise<CodebaseResponse> { | ||
| return fetchJSON<CodebaseResponse>(`/api/codebases/${id}`, { | ||
| method: 'PATCH', | ||
| headers: { 'Content-Type': 'application/json' }, | ||
| body: JSON.stringify(input), | ||
| }); | ||
| } | ||
|
|
||
| export async function deleteCodebase(id: string): Promise<{ success: boolean }> { | ||
| return fetchJSON<{ success: boolean }>(`/api/codebases/${id}`, { method: 'DELETE' }); | ||
| } | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Reset the mobile nav state on desktop resize.
Line 15 keeps
openalive, and Lines 25-42 only hide the drawer with responsive classes. If the menu is open during a rotate/resize, switching back to mobile reopens it unexpectedly.🔧 Proposed fix
Also applies to: 25-42
🤖 Prompt for AI Agents