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
5 changes: 4 additions & 1 deletion web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,10 @@ function GlobalShortcuts() {
action: () => {
const focusNow = () => {
const store = useChatStore.getState();
if (store.sidebarCollapsed) store.toggleSidebar();
// On a phone the list is an off-canvas drawer, on desktop a collapsible
// column; revealSessionList opens whichever one this viewport uses, so
// the search field is never focused inside a closed, inert drawer.
store.revealSessionList();
// The sidebar search input is unmounted until something asks for it.
// requestSearchFocus bumps a nonce the sidebar subscribes to.
store.requestSearchFocus();
Expand Down
61 changes: 54 additions & 7 deletions web/src/components/Chat/SessionSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { Plus, X, MessageSquare, ChevronRight, ChevronDown, Bot, Loader2, Search
import type { Session, AgentStatus } from '../../types/chat';
import { groupByDate, parseTimestamp } from '../../utils/dateGroups';
import { useChatStore } from '../../stores/chatStore';
import { useModalSurface } from '../../hooks/useModalSurface';

/** Strip leading '#' and 'Implement: ' prefixes from generated titles. */
function cleanTitle(session: Session): string {
Expand Down Expand Up @@ -54,13 +55,17 @@ function saveCollapsedGroups(groups: Set<string>): void {
} catch { /* quota exceeded / disabled — keep the in-memory state only */ }
}

export function SessionSidebar({ sessions, activeSession, agentStatus, onCreate, onDelete, collapsed }: {
export function SessionSidebar({ sessions, activeSession, agentStatus, onCreate, onDelete, collapsed, mobile = false, onRequestClose }: {
sessions: Session[];
activeSession: string;
agentStatus: AgentStatus;
onCreate: () => void;
onDelete: (id: string) => void;
collapsed?: boolean;
/** Render as an off-canvas drawer instead of an inline column. */
mobile?: boolean;
/** Drawer mode only — tapping the scrim asks the parent to close. */
onRequestClose?: () => void;
}) {
const [systemExpanded, setSystemExpanded] = useState(false);
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(loadCollapsedGroups);
Expand All @@ -79,6 +84,19 @@ export function SessionSidebar({ sessions, activeSession, agentStatus, onCreate,
const { searchResults, searchLoading, searchSessions, clearSearch, renameSession, toggleStar, archiveSession, virtualSession, discardVirtualSession, sidebarWidth, setSidebarWidth } = useChatStore();
const searchFocusNonce = useChatStore(s => s.searchFocusNonce);

// In drawer mode the list is a modal overlay: it needs focus, Tab
// containment, Escape, and focus restoration. Declared before the search
// effects below so that when Cmd+K opens the drawer and asks for search
// focus in the same tick, the search input wins the race.
const drawerOpen = mobile && !collapsed;
const { dialogProps } = useModalSurface<HTMLDivElement>(drawerOpen, onRequestClose);

// Opening a conversation should reveal it, so every row dismisses the
// drawer. Leaving this to the parent's `activeSession` watcher isn't enough:
// re-tapping the conversation that is already open never changes the route,
// and the drawer would stay parked over the transcript.
const handleSelect = mobile ? onRequestClose : undefined;

// Drag-to-resize the session list. It is left-anchored against the nav rail,
// so the width tracks the cursor 1:1. The width transition is disabled while
// dragging so it stays responsive.
Expand Down Expand Up @@ -279,12 +297,31 @@ export function SessionSidebar({ sessions, activeSession, agentStatus, onCreate,
}, [runningSystemCount]); // eslint-disable-line react-hooks/exhaustive-deps

return (
<>
{/* Drawer scrim. Only in mobile mode, and only while open — a phone has
no room for a persistent column, so the list sits above the
transcript and the scrim is what dismisses it. */}
{drawerOpen && (
<div
onClick={onRequestClose}
className="fixed inset-0 z-40 bg-black/60 transition-opacity duration-200"
aria-hidden="true"
/>
)}
<div
className={`bg-surface border-r border-border-subtle flex flex-col shrink-0 overflow-hidden relative ${collapsed ? 'border-r-0' : ''} ${isDragging ? '' : 'transition-all duration-200'}`}
style={{ width: collapsed ? 0 : sidebarWidth }}
{...(mobile ? dialogProps : {})}
aria-label={mobile ? 'Conversations' : undefined}
className={mobile
? `bg-surface border-r border-border-subtle flex flex-col overflow-hidden fixed inset-y-0 left-0 z-50 w-[85vw] max-w-[320px] transition-transform duration-200 outline-none ${collapsed ? '-translate-x-full' : 'translate-x-0'}`
: `bg-surface border-r border-border-subtle flex flex-col shrink-0 overflow-hidden relative ${collapsed ? 'border-r-0' : ''} ${isDragging ? '' : 'transition-all duration-200'}`}
style={mobile ? undefined : { width: collapsed ? 0 : sidebarWidth }}
// Keep the closed drawer out of the tab order: it stays mounted so the
// slide transition has something to animate, but it is off-canvas.
inert={mobile && collapsed ? true : undefined}
>
{/* Drag-to-resize handle on the right edge (hidden when collapsed). */}
{!collapsed && (
{/* Drag-to-resize handle on the right edge (hidden when collapsed).
Pointer-driven and mouse-only, so it has no place in drawer mode. */}
{!collapsed && !mobile && (
<div
onMouseDown={handleResizeStart}
className="group/resize absolute top-0 right-0 bottom-0 z-20 w-2 cursor-col-resize"
Expand All @@ -310,7 +347,7 @@ export function SessionSidebar({ sessions, activeSession, agentStatus, onCreate,

{/* New chat pill (hidden under input when open) */}
<button
onClick={onCreate}
onClick={() => { onCreate(); handleSelect?.(); }}
title="New chat"
className="absolute right-0 top-1/2 -translate-y-1/2 h-6 pl-1.5 pr-2.5 rounded-full border border-border-subtle flex items-center gap-1 text-[11px] text-text-faint hover:text-text-muted hover:bg-surface-hover cursor-pointer"
>
Expand Down Expand Up @@ -379,6 +416,7 @@ export function SessionSidebar({ sessions, activeSession, agentStatus, onCreate,
onRename={renameSession}
onToggleStar={toggleStar}
onArchive={archiveSession}
onSelect={handleSelect}
showDate
/>
))
Expand All @@ -393,6 +431,7 @@ export function SessionSidebar({ sessions, activeSession, agentStatus, onCreate,
{virtualSession && (
<Link
to={`/chat/${virtualSession.id}`}
onClick={handleSelect}
className={`group flex items-center gap-2 px-3 py-1.5 mx-1 mt-1 rounded-md cursor-pointer text-sm transition-colors no-underline
${virtualSession.id === activeSession
? 'bg-accent/10 text-text'
Expand Down Expand Up @@ -436,6 +475,7 @@ export function SessionSidebar({ sessions, activeSession, agentStatus, onCreate,
onRename={renameSession}
onToggleStar={toggleStar}
onArchive={archiveSession}
onSelect={handleSelect}
/>
))}
</div>
Expand All @@ -462,6 +502,7 @@ export function SessionSidebar({ sessions, activeSession, agentStatus, onCreate,
onRename={renameSession}
onToggleStar={toggleStar}
onArchive={archiveSession}
onSelect={handleSelect}
/>
))}
</div>
Expand Down Expand Up @@ -490,6 +531,7 @@ export function SessionSidebar({ sessions, activeSession, agentStatus, onCreate,
onRename={renameSession}
onToggleStar={toggleStar}
onArchive={archiveSession}
onSelect={handleSelect}
/>
))}
</div>
Expand Down Expand Up @@ -525,6 +567,7 @@ export function SessionSidebar({ sessions, activeSession, agentStatus, onCreate,
<Link
key={s.id}
to={`/chat/${s.id}`}
onClick={handleSelect}
className={`group flex items-center gap-2 px-3 py-1.5 mx-1 rounded-md cursor-pointer text-[12px] transition-colors no-underline
${s.id === activeSession
? 'bg-accent/10 text-text-muted'
Expand All @@ -548,6 +591,7 @@ export function SessionSidebar({ sessions, activeSession, agentStatus, onCreate,
)}
</div>
</div>
</>
);
}

Expand Down Expand Up @@ -662,14 +706,16 @@ function StatusIndicator({ session, isActive, isRunning }: {
}


function SessionItem({ session, isActive, isRunning, onDelete, onRename, onToggleStar, onArchive, showDate }: {
function SessionItem({ session, isActive, isRunning, onDelete, onRename, onToggleStar, onArchive, onSelect, showDate }: {
session: Session;
isActive: boolean;
isRunning: boolean;
onDelete: (id: string) => void;
onRename: (id: string, title: string) => Promise<void>;
onToggleStar: (id: string) => Promise<void>;
onArchive: (id: string) => Promise<void>;
/** Fired when the row itself is opened (not its menu) — drawer mode uses it to close. */
onSelect?: () => void;
showDate?: boolean;
}) {
const [menuOpen, setMenuOpen] = useState(false);
Expand Down Expand Up @@ -727,6 +773,7 @@ function SessionItem({ session, isActive, isRunning, onDelete, onRename, onToggl
return (
<Link
to={`/chat/${session.id}`}
onClick={onSelect}
className={`group flex items-center gap-2 px-3 py-1.5 mx-1 rounded-md cursor-pointer text-sm transition-colors no-underline
${isActive
? 'bg-accent/10 text-text'
Expand Down
41 changes: 41 additions & 0 deletions web/src/components/Chat/SidePanel.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { useRef, useEffect, useState, useCallback } from 'react';
import { X, Lightbulb, Bot, Search, Wrench, Files, Loader2, Check, Ban, Workflow as WorkflowIcon } from 'lucide-react';
import { useChatStore } from '../../stores/chatStore';
import { useIsMobile } from '../../hooks/useMediaQuery';
import { useModalSurface } from '../../hooks/useModalSurface';
import { MarkdownContent } from './MarkdownContent';
import { SelectionToolbar } from './SelectionToolbar';
import { BlockRenderer } from './BlockRenderer';
Expand Down Expand Up @@ -266,10 +268,19 @@ export function SidePanel() {
const focusPanelTab = useChatStore(s => s.focusPanelTab);
const closePanelTab = useChatStore(s => s.closePanelTab);
const setPanelWidth = useChatStore(s => s.setPanelWidth);
const mobile = useIsMobile();

const activeTab = panels.find(p => p.id === activePanelId) || panels[0] || null;
const containerRef = useRef<HTMLDivElement>(null);

// On a phone this panel covers the whole viewport, which makes it a modal:
// without this, the transcript and the navigation underneath stay in the tab
// order and Tab lands on controls nobody can see.
const { dialogProps } = useModalSurface<HTMLDivElement>(
mobile && panelVisible && panels.length > 0,
togglePanel,
);

// Drag-to-resize (disable transition during drag for responsiveness)
const [isDragging, setIsDragging] = useState(false);
const handleResizeStart = useCallback((e: React.MouseEvent) => {
Expand Down Expand Up @@ -306,6 +317,36 @@ export function SidePanel() {
const isOpen = panelVisible;
const showTabs = panels.length > 1;

// A phone has no room to split the viewport: at 412px a 45% panel leaves the
// transcript an unreadable sliver. Cover it instead, and dismiss the same way
// as on desktop — via the tab header's close button.
if (mobile) {
return (
<div
{...dialogProps}
aria-label={activeTab.label || 'Panel'}
className={`side-panel fixed inset-0 z-30 flex flex-col bg-bg-sunken outline-none ${isOpen ? '' : 'hidden'}`}
>
{showTabs && (
<TabBar
panels={panels}
activeId={activePanelId}
onFocus={focusPanelTab}
onClose={closePanelTab}
/>
)}
<TabHeader tab={activeTab} onClose={togglePanel} />
{activeTab.type === 'files'
? <FileChangesPanel />
: activeTab.type === 'workflow'
? <WorkflowPanel tab={activeTab} />
: <TabContent tab={activeTab} containerRef={containerRef} />
}
{activeTab.type === 'plan' && <PlanActions tab={activeTab} />}
</div>
);
}

return (
<div
className={`side-panel flex flex-col bg-bg-sunken shrink-0 relative overflow-hidden ${
Expand Down
44 changes: 44 additions & 0 deletions web/src/hooks/useMediaQuery.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { useCallback, useSyncExternalStore } from 'react';

/**
* Everything below Tailwind's `md` breakpoint (768px) is treated as "mobile":
* one hand, one column, no room for a persistent sidebar. Kept in sync with
* the `md:` prefixes used in the markup — change both together.
*/
export const MOBILE_QUERY = '(max-width: 767px)';

/**
* Subscribe to a CSS media query.
*
* `useSyncExternalStore` rather than `useState` + an effect so the first
* render already has the right answer: an effect-based version paints one
* frame of the desktop layout before correcting itself, which on the chat
* page means a visible flash of the squeezed three-column shell.
*/
export function useMediaQuery(query: string): boolean {
const subscribe = useCallback((onStoreChange: () => void) => {
const mql = window.matchMedia(query);
mql.addEventListener('change', onStoreChange);
return () => mql.removeEventListener('change', onStoreChange);
}, [query]);

return useSyncExternalStore(
subscribe,
() => window.matchMedia(query).matches,
// Server snapshot: assume desktop, matching the pre-JS markup.
() => false,
);
}

/** True on phone-sized viewports (below Tailwind's `md`). */
export function useIsMobile(): boolean {
return useMediaQuery(MOBILE_QUERY);
}

/**
* One-shot check for code that runs outside React — store actions and keyboard
* shortcut handlers, which need the current layout but cannot call hooks.
*/
export function isMobileViewport(): boolean {
return window.matchMedia(MOBILE_QUERY).matches;
}
Loading
Loading