From f06a66ab7f28b295b94b2935279499eda2177cc5 Mon Sep 17 00:00:00 2001 From: tboome33 <59544266+tboome33@users.noreply.github.com> Date: Mon, 13 Apr 2026 13:46:48 +0200 Subject: [PATCH 01/24] feat(web): add MobileNavContext for mobile sidebar state --- packages/web/src/contexts/MobileNavContext.tsx | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 packages/web/src/contexts/MobileNavContext.tsx diff --git a/packages/web/src/contexts/MobileNavContext.tsx b/packages/web/src/contexts/MobileNavContext.tsx new file mode 100644 index 0000000000..8ca7f04f52 --- /dev/null +++ b/packages/web/src/contexts/MobileNavContext.tsx @@ -0,0 +1,15 @@ +import { createContext, useContext } from 'react'; + +export interface MobileNavContextValue { + open: boolean; + setOpen: (open: boolean) => void; +} + +export const MobileNavContext = createContext({ + open: false, + setOpen: () => {}, +}); + +export function useMobileNav(): MobileNavContextValue { + return useContext(MobileNavContext); +} From edce11840acb0a151851b8555aff2e2edc5e7092 Mon Sep 17 00:00:00 2001 From: tboome33 <59544266+tboome33@users.noreply.github.com> Date: Mon, 13 Apr 2026 13:46:54 +0200 Subject: [PATCH 02/24] feat(web): responsive mobile Layout with drawer sidebar --- packages/web/src/components/layout/Layout.tsx | 120 ++++++++++++++++-- 1 file changed, 107 insertions(+), 13 deletions(-) diff --git a/packages/web/src/components/layout/Layout.tsx b/packages/web/src/components/layout/Layout.tsx index a858364446..c4afe881bb 100644 --- a/packages/web/src/components/layout/Layout.tsx +++ b/packages/web/src/components/layout/Layout.tsx @@ -1,13 +1,107 @@ -import { Outlet } from 'react-router'; -import { TopNav } from './TopNav'; - -export function Layout(): React.ReactElement { - return ( -
- -
- -
-
- ); -} +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 ( + +
+ + + {/* ── Mobile nav overlay backdrop ── */} + {open && ( +
{ setOpen(false); }} + aria-hidden="true" + /> + )} + + {/* ── Mobile nav drawer ── */} + + +
+ +
+
+ + ); +} From 27c8b5302db820a4a6e7835a858094a4432bf0d2 Mon Sep 17 00:00:00 2001 From: tboome33 <59544266+tboome33@users.noreply.github.com> Date: Mon, 13 Apr 2026 13:47:03 +0200 Subject: [PATCH 03/24] feat(web): add hamburger menu button in TopNav for mobile --- packages/web/src/components/layout/TopNav.tsx | 175 ++++++++++-------- 1 file changed, 97 insertions(+), 78 deletions(-) diff --git a/packages/web/src/components/layout/TopNav.tsx b/packages/web/src/components/layout/TopNav.tsx index 45924f5004..60b4c4d4ff 100644 --- a/packages/web/src/components/layout/TopNav.tsx +++ b/packages/web/src/components/layout/TopNav.tsx @@ -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 ( - - ); -} +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 ( + + ); +} From d7577f6b899a2421f6c1e8672b7dd61f668b3a0e Mon Sep 17 00:00:00 2001 From: tboome33 <59544266+tboome33@users.noreply.github.com> Date: Mon, 13 Apr 2026 13:47:09 +0200 Subject: [PATCH 04/24] feat(web): hide conversation panel on mobile for full-width chat --- packages/web/src/routes/ChatPage.tsx | 660 +++++++++++++-------------- 1 file changed, 329 insertions(+), 331 deletions(-) diff --git a/packages/web/src/routes/ChatPage.tsx b/packages/web/src/routes/ChatPage.tsx index b1179d75ea..4ff07a36dd 100644 --- a/packages/web/src/routes/ChatPage.tsx +++ b/packages/web/src/routes/ChatPage.tsx @@ -1,331 +1,329 @@ -import { useState, useMemo, useRef, useCallback, useEffect } from 'react'; -import { useParams, useNavigate } from 'react-router'; -import { useQuery, useQueryClient } from '@tanstack/react-query'; -import { MessageSquarePlus, Search, Plus, Loader2, FolderGit2 } from 'lucide-react'; -import { ChatInterface } from '@/components/chat/ChatInterface'; -import { ConversationItem } from '@/components/conversations/ConversationItem'; -import { ScrollArea } from '@/components/ui/scroll-area'; -import { Separator } from '@/components/ui/separator'; -import { useProject } from '@/contexts/ProjectContext'; -import { listConversations, listWorkflowRuns, addCodebase } from '@/lib/api'; -import type { CodebaseResponse } from '@/lib/api'; -import { cn } from '@/lib/utils'; - -const PANEL_MIN = 220; -const PANEL_MAX = 420; -const PANEL_DEFAULT = 260; -const STORAGE_KEY = 'archon-chat-panel-width'; - -function getInitialWidth(): number { - const stored = localStorage.getItem(STORAGE_KEY); - if (stored) { - const parsed = Number(stored); - if (parsed >= PANEL_MIN && parsed <= PANEL_MAX) return parsed; - } - return PANEL_DEFAULT; -} - -export function ChatPage(): React.ReactElement { - const { '*': rawConversationId } = useParams(); - const conversationId = rawConversationId ? decodeURIComponent(rawConversationId) : undefined; - - const navigate = useNavigate(); - const queryClient = useQueryClient(); - const { selectedProjectId, setSelectedProjectId, codebases, isLoadingCodebases } = useProject(); - - const [searchQuery, setSearchQuery] = useState(''); - const [width, setWidth] = useState(getInitialWidth); - const isResizing = useRef(false); - const searchInputRef = useRef(null); - - // Add-project state - const [showAddInput, setShowAddInput] = useState(false); - const [addValue, setAddValue] = useState(''); - const [addLoading, setAddLoading] = useState(false); - const [addError, setAddError] = useState(null); - const addInputRef = useRef(null); - - useEffect(() => { - localStorage.setItem(STORAGE_KEY, String(width)); - }, [width]); - - useEffect(() => { - if (showAddInput) { - addInputRef.current?.focus(); - } - }, [showAddInput]); - - const handleMouseDown = useCallback( - (e: React.MouseEvent): void => { - e.preventDefault(); - isResizing.current = true; - const startX = e.clientX; - const startWidth = width; - - document.body.style.userSelect = 'none'; - document.body.style.cursor = 'col-resize'; - - const onMouseMove = (moveEvent: MouseEvent): void => { - const newWidth = Math.min( - PANEL_MAX, - Math.max(PANEL_MIN, startWidth + moveEvent.clientX - startX) - ); - setWidth(newWidth); - }; - - const onMouseUp = (): void => { - isResizing.current = false; - document.body.style.userSelect = ''; - document.body.style.cursor = ''; - document.removeEventListener('mousemove', onMouseMove); - document.removeEventListener('mouseup', onMouseUp); - }; - - document.addEventListener('mousemove', onMouseMove); - document.addEventListener('mouseup', onMouseUp); - }, - [width] - ); - - const { data: conversations } = useQuery({ - queryKey: ['conversations', selectedProjectId], - queryFn: () => listConversations(selectedProjectId ?? undefined), - refetchInterval: 10_000, - }); - - const { data: runs } = useQuery({ - queryKey: ['workflow-runs-status'], - queryFn: () => listWorkflowRuns({ limit: 50 }), - refetchInterval: 10_000, - }); - - const conversationStatusMap = useMemo((): Map => { - const map = new Map(); - if (!runs) return map; - for (const run of runs) { - // For web runs, parent_conversation_id is the visible conversation in the sidebar. - // For CLI runs, conversation_id is the only conversation (no parent/worker split). - const key = run.parent_conversation_id ?? run.conversation_id; - if (run.status === 'running') { - map.set(key, 'running'); - } else if (run.status === 'failed' && !map.has(key)) { - map.set(key, 'failed'); - } - } - return map; - }, [runs]); - - const codebaseMap = useMemo((): Map => { - const map = new Map(); - if (codebases) { - for (const cb of codebases) { - map.set(cb.id, cb); - } - } - return map; - }, [codebases]); - - const filtered = useMemo( - () => - conversations?.filter(conv => { - if (!searchQuery) return true; - const query = searchQuery.toLowerCase(); - return (conv.title ?? conv.platform_conversation_id).toLowerCase().includes(query); - }), - [conversations, searchQuery] - ); - - const handleNewChat = useCallback((): void => { - navigate('/chat'); - }, [navigate]); - - const handleAddSubmit = useCallback((): void => { - const trimmed = addValue.trim(); - if (!trimmed || addLoading) return; - - setAddLoading(true); - setAddError(null); - - const isLocalPath = - trimmed.startsWith('/') || trimmed.startsWith('~') || /^[A-Za-z]:[/\\]/.test(trimmed); - const input = isLocalPath ? { path: trimmed } : { url: trimmed }; - - void addCodebase(input) - .then(codebase => { - void queryClient.invalidateQueries({ queryKey: ['codebases'] }); - setSelectedProjectId(codebase.id); - setShowAddInput(false); - setAddValue(''); - setAddError(null); - }) - .catch((err: Error) => { - setAddError(err.message); - }) - .finally(() => { - setAddLoading(false); - }); - }, [addValue, addLoading, queryClient, setSelectedProjectId]); - - const handleAddKeyDown = useCallback( - (e: React.KeyboardEvent): void => { - if (e.key === 'Enter') { - handleAddSubmit(); - } else if (e.key === 'Escape') { - setShowAddInput(false); - setAddValue(''); - setAddError(null); - } - }, - [handleAddSubmit] - ); - - return ( -
- {/* Left panel */} -
- {/* New Chat button */} -
- -
- - {/* Project filter */} -
-
- - Project - - -
- - {showAddInput && ( -
-
- { - setAddValue(e.target.value); - }} - onKeyDown={handleAddKeyDown} - onBlur={(): void => { - if (!addValue.trim() && !addError) { - setShowAddInput(false); - } - }} - placeholder="GitHub URL or local path" - disabled={addLoading} - className="w-full rounded-md border border-border bg-surface-elevated px-2 py-1 text-xs text-text-primary placeholder:text-text-tertiary focus:border-primary focus:outline-none disabled:opacity-50" - /> - {addLoading && ( - - )} -
- {addError &&

{addError}

} -
- )} - - {isLoadingCodebases ? ( -
- Loading... -
- ) : ( - - )} -
- - - - {/* Search */} -
-
- - { - setSearchQuery(e.target.value); - }} - placeholder="Search..." - className="w-full rounded-md border border-border bg-surface-elevated py-1.5 pl-7 pr-2 text-xs text-text-primary placeholder:text-text-tertiary focus:border-primary focus:outline-none" - /> -
-
- - {/* Conversation list */} - -
- {filtered && filtered.length > 0 ? ( - filtered.map(conv => ( - - )) - ) : ( -
- - 0 ? '' : '' - )} - > - {conversations && conversations.length > 0 - ? 'No matching conversations' - : 'No conversations yet — start a new chat!'} - -
- )} -
-
- - {/* Resize handle */} -
-
- - {/* Right panel - chat interface */} -
- -
-
- ); -} +import { useState, useMemo, useRef, useCallback, useEffect } from 'react'; +import { useParams, useNavigate } from 'react-router'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { MessageSquarePlus, Search, Plus, Loader2, FolderGit2 } from 'lucide-react'; +import { ChatInterface } from '@/components/chat/ChatInterface'; +import { ConversationItem } from '@/components/conversations/ConversationItem'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { Separator } from '@/components/ui/separator'; +import { useProject } from '@/contexts/ProjectContext'; +import { listConversations, listWorkflowRuns, addCodebase } from '@/lib/api'; +import type { CodebaseResponse } from '@/lib/api'; +import { cn } from '@/lib/utils'; + +const PANEL_MIN = 220; +const PANEL_MAX = 420; +const PANEL_DEFAULT = 260; +const STORAGE_KEY = 'archon-chat-panel-width'; + +function getInitialWidth(): number { + const stored = localStorage.getItem(STORAGE_KEY); + if (stored) { + const parsed = Number(stored); + if (parsed >= PANEL_MIN && parsed <= PANEL_MAX) return parsed; + } + return PANEL_DEFAULT; +} + +export function ChatPage(): React.ReactElement { + const { '*': rawConversationId } = useParams(); + const conversationId = rawConversationId ? decodeURIComponent(rawConversationId) : undefined; + + const navigate = useNavigate(); + const queryClient = useQueryClient(); + const { selectedProjectId, setSelectedProjectId, codebases, isLoadingCodebases } = useProject(); + + const [searchQuery, setSearchQuery] = useState(''); + const [width, setWidth] = useState(getInitialWidth); + const isResizing = useRef(false); + const searchInputRef = useRef(null); + + // Add-project state + const [showAddInput, setShowAddInput] = useState(false); + const [addValue, setAddValue] = useState(''); + const [addLoading, setAddLoading] = useState(false); + const [addError, setAddError] = useState(null); + const addInputRef = useRef(null); + + useEffect(() => { + localStorage.setItem(STORAGE_KEY, String(width)); + }, [width]); + + useEffect(() => { + if (showAddInput) { + addInputRef.current?.focus(); + } + }, [showAddInput]); + + const handleMouseDown = useCallback( + (e: React.MouseEvent): void => { + e.preventDefault(); + isResizing.current = true; + const startX = e.clientX; + const startWidth = width; + + document.body.style.userSelect = 'none'; + document.body.style.cursor = 'col-resize'; + + const onMouseMove = (moveEvent: MouseEvent): void => { + const newWidth = Math.min( + PANEL_MAX, + Math.max(PANEL_MIN, startWidth + moveEvent.clientX - startX) + ); + setWidth(newWidth); + }; + + const onMouseUp = (): void => { + isResizing.current = false; + document.body.style.userSelect = ''; + document.body.style.cursor = ''; + document.removeEventListener('mousemove', onMouseMove); + document.removeEventListener('mouseup', onMouseUp); + }; + + document.addEventListener('mousemove', onMouseMove); + document.addEventListener('mouseup', onMouseUp); + }, + [width] + ); + + const { data: conversations } = useQuery({ + queryKey: ['conversations', selectedProjectId], + queryFn: () => listConversations(selectedProjectId ?? undefined), + refetchInterval: 10_000, + }); + + const { data: runs } = useQuery({ + queryKey: ['workflow-runs-status'], + queryFn: () => listWorkflowRuns({ limit: 50 }), + refetchInterval: 10_000, + }); + + const conversationStatusMap = useMemo((): Map => { + const map = new Map(); + if (!runs) return map; + for (const run of runs) { + const key = run.parent_conversation_id ?? run.conversation_id; + if (run.status === 'running') { + map.set(key, 'running'); + } else if (run.status === 'failed' && !map.has(key)) { + map.set(key, 'failed'); + } + } + return map; + }, [runs]); + + const codebaseMap = useMemo((): Map => { + const map = new Map(); + if (codebases) { + for (const cb of codebases) { + map.set(cb.id, cb); + } + } + return map; + }, [codebases]); + + const filtered = useMemo( + () => + conversations?.filter(conv => { + if (!searchQuery) return true; + const query = searchQuery.toLowerCase(); + return (conv.title ?? conv.platform_conversation_id).toLowerCase().includes(query); + }), + [conversations, searchQuery] + ); + + const handleNewChat = useCallback((): void => { + navigate('/chat'); + }, [navigate]); + + const handleAddSubmit = useCallback((): void => { + const trimmed = addValue.trim(); + if (!trimmed || addLoading) return; + + setAddLoading(true); + setAddError(null); + + const isLocalPath = + trimmed.startsWith('/') || trimmed.startsWith('~') || /^[A-Za-z]:[/\\]/.test(trimmed); + const input = isLocalPath ? { path: trimmed } : { url: trimmed }; + + void addCodebase(input) + .then(codebase => { + void queryClient.invalidateQueries({ queryKey: ['codebases'] }); + setSelectedProjectId(codebase.id); + setShowAddInput(false); + setAddValue(''); + setAddError(null); + }) + .catch((err: Error) => { + setAddError(err.message); + }) + .finally(() => { + setAddLoading(false); + }); + }, [addValue, addLoading, queryClient, setSelectedProjectId]); + + const handleAddKeyDown = useCallback( + (e: React.KeyboardEvent): void => { + if (e.key === 'Enter') { + handleAddSubmit(); + } else if (e.key === 'Escape') { + setShowAddInput(false); + setAddValue(''); + setAddError(null); + } + }, + [handleAddSubmit] + ); + + return ( +
+ + {/* ── Left panel — hidden on mobile, always visible on desktop ── */} +
+ {/* New Chat button */} +
+ +
+ + {/* Project filter */} +
+
+ + Project + + +
+ + {showAddInput && ( +
+
+ { + setAddValue(e.target.value); + }} + onKeyDown={handleAddKeyDown} + onBlur={(): void => { + if (!addValue.trim() && !addError) { + setShowAddInput(false); + } + }} + placeholder="GitHub URL or local path" + disabled={addLoading} + className="w-full rounded-md border border-border bg-surface-elevated px-2 py-1 text-xs text-text-primary placeholder:text-text-tertiary focus:border-primary focus:outline-none disabled:opacity-50" + /> + {addLoading && ( + + )} +
+ {addError &&

{addError}

} +
+ )} + + {isLoadingCodebases ? ( +
+ Loading... +
+ ) : ( + + )} +
+ + + + {/* Search */} +
+
+ + { + setSearchQuery(e.target.value); + }} + placeholder="Search..." + className="w-full rounded-md border border-border bg-surface-elevated py-1.5 pl-7 pr-2 text-xs text-text-primary placeholder:text-text-tertiary focus:border-primary focus:outline-none" + /> +
+
+ + {/* Conversation list */} + +
+ {filtered && filtered.length > 0 ? ( + filtered.map(conv => ( + + )) + ) : ( +
+ + + {conversations && conversations.length > 0 + ? 'No matching conversations' + : 'No conversations yet — start a new chat!'} + +
+ )} +
+
+ + {/* Resize handle */} +
+
+ + {/* ── Right panel — chat interface, full width on mobile ── */} +
+ +
+
+ ); +} From a45ec74157abe93cfc308f2bad5be3d1be8324bd Mon Sep 17 00:00:00 2001 From: tboome33 <59544266+tboome33@users.noreply.github.com> Date: Mon, 13 Apr 2026 14:35:33 +0200 Subject: [PATCH 05/24] fix(api): fix SSE URL construction for Cloudflare tunnel access Replace window.location.hostname direct-connect with localhost-only check. When accessed via Cloudflare tunnel, use relative URL so requests go through Vite proxy which forwards to the backend (port 3090 not directly accessible). --- packages/web/src/lib/api.ts | 27 +++++++++++++++++++++------ 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/packages/web/src/lib/api.ts b/packages/web/src/lib/api.ts index 81a3529833..399a81afe7 100644 --- a/packages/web/src/lib/api.ts +++ b/packages/web/src/lib/api.ts @@ -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}` : ''; 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; commands: Record; created_at: string; updated_at: string; @@ -157,7 +161,7 @@ export async function getCodebase(id: string): Promise { } export async function addCodebase( - input: { url: string } | { path: string } + input: { url: string; allowEnvKeys?: boolean } | { path: string; allowEnvKeys?: boolean } ): Promise { return fetchJSON('/api/codebases', { method: 'POST', @@ -166,6 +170,17 @@ export async function addCodebase( }); } +export async function updateCodebase( + id: string, + input: { allowEnvKeys: boolean } +): Promise { + return fetchJSON(`/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' }); } From c49aaa92529fc4db03e8158b31d46f3ee648f79f Mon Sep 17 00:00:00 2001 From: tboome33 <59544266+tboome33@users.noreply.github.com> Date: Mon, 13 Apr 2026 16:10:41 +0200 Subject: [PATCH 06/24] feat(mobile): add conversation drawer and overflow-x fix for mobile Adds a slide-in conversations panel accessible from mobile via a 'Conversations' button in the chat topbar. Also fixes horizontal scroll parasite on mobile by adding overflow-x: hidden to html/body/#root. --- packages/web/src/routes/ChatPage.tsx | 87 +++++++++++++++++++++++----- 1 file changed, 74 insertions(+), 13 deletions(-) diff --git a/packages/web/src/routes/ChatPage.tsx b/packages/web/src/routes/ChatPage.tsx index 4ff07a36dd..cf9a1f3fa2 100644 --- a/packages/web/src/routes/ChatPage.tsx +++ b/packages/web/src/routes/ChatPage.tsx @@ -1,7 +1,7 @@ import { useState, useMemo, useRef, useCallback, useEffect } from 'react'; import { useParams, useNavigate } from 'react-router'; import { useQuery, useQueryClient } from '@tanstack/react-query'; -import { MessageSquarePlus, Search, Plus, Loader2, FolderGit2 } from 'lucide-react'; +import { MessageSquarePlus, Search, Plus, Loader2, FolderGit2, PanelLeft, X } from 'lucide-react'; import { ChatInterface } from '@/components/chat/ChatInterface'; import { ConversationItem } from '@/components/conversations/ConversationItem'; import { ScrollArea } from '@/components/ui/scroll-area'; @@ -35,6 +35,7 @@ export function ChatPage(): React.ReactElement { const [searchQuery, setSearchQuery] = useState(''); const [width, setWidth] = useState(getInitialWidth); + const [mobileConvOpen, setMobileConvOpen] = useState(false); const isResizing = useRef(false); const searchInputRef = useRef(null); @@ -55,6 +56,15 @@ export function ChatPage(): React.ReactElement { } }, [showAddInput]); + // Close mobile drawer on desktop resize + useEffect(() => { + const onResize = (): void => { + if (window.innerWidth >= 768) setMobileConvOpen(false); + }; + window.addEventListener('resize', onResize); + return () => { window.removeEventListener('resize', onResize); }; + }, []); + const handleMouseDown = useCallback( (e: React.MouseEvent): void => { e.preventDefault(); @@ -180,24 +190,51 @@ export function ChatPage(): React.ReactElement { return (
- {/* ── Left panel — hidden on mobile, always visible on desktop ── */} + {/* ── Mobile overlay backdrop ── */} + {mobileConvOpen && ( +
{ setMobileConvOpen(false); }} + aria-hidden="true" + /> + )} + + {/* ── Conversations panel ── + Desktop : sidebar inline (relative, h-full) + Mobile : fixed overlay drawer, slides from left via transform ── */}
- {/* New Chat button */} -
+ {/* New Chat button + mobile close */} +
+ {/* Close button — mobile only */} +
{/* Project filter */} @@ -286,9 +323,13 @@ export function ChatPage(): React.ReactElement {
- {/* Conversation list */} + {/* Conversation list — clicking any item closes the mobile drawer */} -
+ {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */} +
{ setMobileConvOpen(false); }} + > {filtered && filtered.length > 0 ? ( filtered.map(conv => ( - {/* Resize handle */} + {/* Resize handle — desktop only */}
{/* ── Right panel — chat interface, full width on mobile ── */}
+ + {/* Mobile-only topbar: conversations toggle + new chat shortcut */} +
+ + +
+
From a19e630e5c3e57adc0ba08c145f915a814348426 Mon Sep 17 00:00:00 2001 From: tboome33 <59544266+tboome33@users.noreply.github.com> Date: Mon, 13 Apr 2026 16:10:48 +0200 Subject: [PATCH 07/24] fix(mobile): prevent horizontal scroll with overflow-x hidden on html/body/#root --- packages/web/src/index.css | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/packages/web/src/index.css b/packages/web/src/index.css index a3d4d39631..a43585a836 100644 --- a/packages/web/src/index.css +++ b/packages/web/src/index.css @@ -139,11 +139,22 @@ } } +/* Prevent horizontal scroll on mobile (fixed drawers, transforms) */ +html { + overflow-x: hidden; +} + +#root { + overflow-x: hidden; + width: 100%; +} + /* Base body styling */ body { background-color: var(--background); color: var(--text-primary); font-family: var(--font-sans); + overflow-x: hidden; } /* Scrollbar styling for dark theme */ From 840822fbf573b78cf86ae07592178d6bc71c9f68 Mon Sep 17 00:00:00 2001 From: tboome33 <59544266+tboome33@users.noreply.github.com> Date: Mon, 13 Apr 2026 16:24:39 +0200 Subject: [PATCH 08/24] fix(mobile): sticky chat input + h-dvh layout + sticky mobile topbar --- packages/web/src/components/layout/Layout.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/web/src/components/layout/Layout.tsx b/packages/web/src/components/layout/Layout.tsx index c4afe881bb..a892e8f1fe 100644 --- a/packages/web/src/components/layout/Layout.tsx +++ b/packages/web/src/components/layout/Layout.tsx @@ -16,7 +16,10 @@ export function Layout(): React.ReactElement { return ( -
+ {/* h-dvh (100dvh) instead of h-screen (100vh) so the layout height follows the + dynamic viewport on mobile — shrinks when the browser address bar is visible, + keeping the chat input always reachable without scrolling. */} +
{/* ── Mobile nav overlay backdrop ── */} From 720c1eded6b21113fcfa4dc0a0245c2544906355 Mon Sep 17 00:00:00 2001 From: tboome33 <59544266+tboome33@users.noreply.github.com> Date: Mon, 13 Apr 2026 16:24:45 +0200 Subject: [PATCH 09/24] fix(chat): flex-shrink-0 + safe-area-inset-bottom on MessageInput --- .../web/src/components/chat/MessageInput.tsx | 680 +++++++++--------- 1 file changed, 342 insertions(+), 338 deletions(-) diff --git a/packages/web/src/components/chat/MessageInput.tsx b/packages/web/src/components/chat/MessageInput.tsx index 2badb7ac65..b5bdde0d24 100644 --- a/packages/web/src/components/chat/MessageInput.tsx +++ b/packages/web/src/components/chat/MessageInput.tsx @@ -1,338 +1,342 @@ -import { - useState, - useRef, - useCallback, - forwardRef, - useImperativeHandle, - type KeyboardEvent, - type DragEvent, - type ClipboardEvent, -} from 'react'; -import { ArrowUp, Loader2, Paperclip, X } from 'lucide-react'; -import { Button } from '@/components/ui/button'; - -/** Binary (non-text) MIME types explicitly accepted */ -const ACCEPTED_BINARY_MIME_TYPES = new Set([ - 'image/png', - 'image/jpeg', - 'image/gif', - 'image/webp', - 'application/pdf', - // application/json may be reported by browsers for .json files - 'application/json', -]); - -/** Extensions for the file-picker `accept` attribute. Covers images, PDFs, and text/code files. */ -const ACCEPTED_EXTENSIONS_LIST = [ - // Images - '.png', - '.jpg', - '.jpeg', - '.gif', - '.webp', - // Documents - '.pdf', - // Text / markup - '.md', - '.txt', - '.csv', - '.xml', - '.html', - '.htm', - // Data / config - '.json', - '.yaml', - '.yml', - '.toml', - '.ini', - '.cfg', - '.conf', - '.env', - '.log', - // Web - '.css', - '.js', - '.jsx', - '.ts', - '.tsx', - '.mjs', - '.cjs', - // Systems / scripting - '.py', - '.rb', - '.go', - '.java', - '.c', - '.cpp', - '.cc', - '.cxx', - '.h', - '.hpp', - '.cs', - '.php', - '.sh', - '.bash', - '.zsh', - '.fish', - '.rs', - '.swift', - '.kt', - '.scala', - '.r', - '.sql', -]; - -/** Comma-separated string for the file input `accept` attribute */ -const ACCEPTED_EXTENSIONS = ACCEPTED_EXTENSIONS_LIST.join(','); - -/** Set for O(1) extension lookup in validation */ -const ACCEPTED_EXTENSIONS_SET = new Set(ACCEPTED_EXTENSIONS_LIST); - -/** Returns true if the file type is accepted (any text/* or an explicitly allowed binary). */ -function isAcceptedFileType(file: File): boolean { - if (file.type.startsWith('text/')) return true; - if (ACCEPTED_BINARY_MIME_TYPES.has(file.type)) return true; - // Browsers assign empty MIME types to many code/config extensions (.md, .py, .rs, etc.) - // Fall back to checking the file extension from the accepted list - const dotIndex = file.name.lastIndexOf('.'); - if (dotIndex !== -1) { - const ext = file.name.slice(dotIndex).toLowerCase(); - return ACCEPTED_EXTENSIONS_SET.has(ext); - } - return false; -} - -const MAX_FILE_BYTES = 10 * 1024 * 1024; // 10 MB -const MAX_FILES = 5; - -interface MessageInputProps { - onSend: (message: string, files?: File[]) => void; - disabled: boolean; - disabledReason?: string; -} - -export interface MessageInputHandle { - focus: () => void; -} - -function formatBytes(bytes: number): string { - if (bytes < 1024) return `${String(bytes)} B`; - if (bytes < 1024 * 1024) return `${String(Math.round(bytes / 1024))} KB`; - return `${String(Math.round(bytes / (1024 * 1024)))} MB`; -} - -const messageInput = forwardRef(function MessageInputInner( - { onSend, disabled, disabledReason }: MessageInputProps, - ref -): React.ReactElement { - const [value, setValue] = useState(''); - const [files, setFiles] = useState<{ file: File; id: string }[]>([]); - const [dragging, setDragging] = useState(false); - const [fileError, setFileError] = useState(null); - const textareaRef = useRef(null); - const fileInputRef = useRef(null); - - useImperativeHandle(ref, () => ({ - focus: (): void => { - textareaRef.current?.focus(); - }, - })); - - const addFiles = useCallback((incoming: File[]): void => { - setFileError(null); - setFiles(prev => { - const combined = [...prev]; - const rejections: string[] = []; - for (const file of incoming) { - if (combined.length >= MAX_FILES) { - rejections.push(`Maximum ${String(MAX_FILES)} files per message`); - break; - } - if (file.size > MAX_FILE_BYTES) { - rejections.push(`"${file.name}" exceeds the 10 MB size limit`); - continue; - } - if (!isAcceptedFileType(file)) { - rejections.push(`"${file.name}" is not a supported file type`); - continue; - } - combined.push({ file, id: crypto.randomUUID() }); - } - if (rejections.length > 0) { - setFileError(rejections.join('; ')); - } - return combined; - }); - }, []); - - const removeFile = useCallback((id: string): void => { - setFiles(prev => prev.filter(f => f.id !== id)); - setFileError(null); - }, []); - - const handleSend = useCallback((): void => { - const trimmed = value.trim(); - if (!trimmed || disabled) return; - onSend(trimmed, files.length > 0 ? files.map(f => f.file) : undefined); - setValue(''); - setFiles([]); - setFileError(null); - if (fileInputRef.current) fileInputRef.current.value = ''; - // Reset textarea height - if (textareaRef.current) { - textareaRef.current.style.height = 'auto'; - textareaRef.current.focus(); - } - }, [value, disabled, onSend, files]); - - const handleKeyDown = (e: KeyboardEvent): void => { - if (e.key === 'Enter' && !e.shiftKey) { - e.preventDefault(); - handleSend(); - } - }; - - const handleInput = (e: React.ChangeEvent): void => { - setValue(e.target.value); - // Auto-expand textarea - const textarea = e.target; - textarea.style.height = 'auto'; - const newHeight = Math.min(textarea.scrollHeight, 200); - textarea.style.height = `${String(newHeight)}px`; - textarea.style.overflowY = newHeight >= 200 ? 'auto' : 'hidden'; - }; - - const handleFilePickerChange = (e: React.ChangeEvent): void => { - if (e.target.files) addFiles(Array.from(e.target.files)); - }; - - const handleDragOver = (e: DragEvent): void => { - e.preventDefault(); - setDragging(true); - }; - - const handleDragLeave = (e: DragEvent): void => { - // Only clear dragging when leaving the outer container, not a child element - if (!e.currentTarget.contains(e.relatedTarget as Node | null)) { - setDragging(false); - } - }; - - const handleDrop = (e: DragEvent): void => { - e.preventDefault(); - setDragging(false); - if (e.dataTransfer.files.length > 0) addFiles(Array.from(e.dataTransfer.files)); - }; - - const handlePaste = (e: ClipboardEvent): void => { - const imageItems = Array.from(e.clipboardData.items).filter(item => - item.type.startsWith('image/') - ); - if (imageItems.length === 0) return; - const pastedFiles: File[] = []; - for (const item of imageItems) { - const file = item.getAsFile(); - if (file) pastedFiles.push(file); - } - if (pastedFiles.length > 0) { - e.preventDefault(); - addFiles(pastedFiles); - } - }; - - return ( -
-
- {/* File preview chips */} - {files.length > 0 && ( -
- {files.map(({ file, id }) => ( -
- - {file.name} - - ({formatBytes(file.size)}) - -
- ))} -
- )} - - {/* File error */} - {fileError !== null &&

{fileError}

} - - {/* Input row */} -
- {/* Hidden file input */} - - - {/* Attach button */} - - -