fix: make dashboard UI mobile-friendly (#1416)

* Make dashboard UI mobile-friendly

- Drop the thread title/repo header on the agent chat view.
- Expanded sidebar becomes a full-screen overlay on mobile (<768px) instead of a squished column; resize handle hidden.
- Default to collapsed on mobile first load so the chat stays visible; navigating a link/thread auto-collapses the overlay.

* Fix settings rows and run cards overflowing on small screens

- SettingsRow stacks label/control vertically below sm; reduce AppShell padding on mobile.
- Truncate long model/repo names in AgentRunCard metadata so cards stay within bounds.

* Keep collapsed-sidebar button from overlapping page titles on mobile

* Render page titles below the sidebar button on mobile instead of indenting

* Don't persist mobile auto-close to localStorage, preserving desktop preference

---------

Co-authored-by: open-swe[bot] <215916821+open-swe[bot]@users.noreply.github.com>
This commit is contained in:
Johannes du Plessis 2026-06-04 15:24:25 -07:00 • committed by GitHub
parent a26d5d32cb
commit 9754c2d791
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 73 additions and 31 deletions

View file

@ -18,7 +18,7 @@ export function AppShell({ user, title, description, backTo, children }: AppShel
<div className="flex h-svh overflow-hidden bg-background text-foreground"> <div className="flex h-svh overflow-hidden bg-background text-foreground">
<AppSidebar user={user} /> <AppSidebar user={user} />
<main className="flex-1 overflow-y-auto"> <main className="flex-1 overflow-y-auto">
<div className="mx-auto max-w-3xl px-8 py-10"> <div className="mx-auto max-w-3xl px-4 pt-14 pb-6 sm:px-8 sm:py-10">
{backTo && ( {backTo && (
<Link <Link
to={backTo.to} to={backTo.to}
@ -83,7 +83,7 @@ export function SettingsRow({
comingSoon, comingSoon,
}: SettingsRowProps) { }: SettingsRowProps) {
return ( return (
<div className="flex items-center justify-between gap-6 border-b border-border px-4 py-3 last:border-b-0"> <div className="flex flex-col gap-2 border-b border-border px-4 py-3 last:border-b-0 sm:flex-row sm:items-center sm:justify-between sm:gap-6">
<label className="flex flex-col gap-0.5" htmlFor={htmlFor}> <label className="flex flex-col gap-0.5" htmlFor={htmlFor}>
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
<span <span
@ -103,7 +103,7 @@ export function SettingsRow({
<span className="text-xs text-muted-foreground">{description}</span> <span className="text-xs text-muted-foreground">{description}</span>
)} )}
</label> </label>
<div className={`shrink-0 ${comingSoon ? "opacity-50" : ""}`}>{control}</div> <div className={`sm:shrink-0 ${comingSoon ? "opacity-50" : ""}`}>{control}</div>
</div> </div>
); );
} }

View file

@ -51,6 +51,7 @@ export function AppSidebar({ user }: { user: SessionUser }) {
<nav className="flex flex-1 flex-col gap-0.5 px-2"> <nav className="flex flex-1 flex-col gap-0.5 px-2">
<Link <Link
to="/agents" to="/agents"
onClick={layout.closeOnMobile}
className={cn( className={cn(
"flex items-center gap-2.5 rounded-md px-2.5 py-1.5 text-xs/relaxed text-muted-foreground transition-colors", "flex items-center gap-2.5 rounded-md px-2.5 py-1.5 text-xs/relaxed text-muted-foreground transition-colors",
"hover:bg-sidebar-accent hover:text-sidebar-accent-foreground", "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
@ -65,6 +66,7 @@ export function AppSidebar({ user }: { user: SessionUser }) {
<Link <Link
key={item.to} key={item.to}
to={item.to} to={item.to}
onClick={layout.closeOnMobile}
className={cn( className={cn(
"flex items-center gap-2.5 rounded-md px-2.5 py-1.5 text-xs/relaxed text-muted-foreground transition-colors", "flex items-center gap-2.5 rounded-md px-2.5 py-1.5 text-xs/relaxed text-muted-foreground transition-colors",
"hover:bg-sidebar-accent hover:text-sidebar-accent-foreground", "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",

View file

@ -69,25 +69,29 @@ export function AgentRunCard({ thread }: AgentRunCardProps) {
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium text-[var(--ui-text)]">{thread.title}</div> <div className="truncate text-sm font-medium text-[var(--ui-text)]">{thread.title}</div>
<div className="mt-1 flex items-center gap-2 text-xs text-[var(--ui-text-dim)]"> <div className="mt-1 flex min-w-0 items-center gap-2 text-xs text-[var(--ui-text-dim)]">
{source && SourceIcon && ( {source && SourceIcon && (
<> <>
<span className="flex items-center gap-1" title={source.label}> <span className="flex shrink-0 items-center gap-1" title={source.label}>
<SourceIcon className="size-3.5" aria-label={source.label} /> <SourceIcon className="size-3.5" aria-label={source.label} />
{source.label} {source.label}
</span> </span>
<span>·</span> <span className="shrink-0">·</span>
</> </>
)} )}
<span>{thread.model}</span> <span className="min-w-0 truncate" title={thread.model}>
{thread.model}
</span>
{thread.repo && ( {thread.repo && (
<> <>
<span>·</span> <span className="shrink-0">·</span>
<span>{thread.repo}</span> <span className="min-w-0 truncate" title={thread.repo}>
{thread.repo}
</span>
</> </>
)} )}
<span>·</span> <span className="shrink-0">·</span>
<span>{formatRelativeTime(thread.updatedAt)}</span> <span className="shrink-0 whitespace-nowrap">{formatRelativeTime(thread.updatedAt)}</span>
</div> </div>
</div> </div>

View file

@ -1,5 +1,4 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { FolderIcon } from "@phosphor-icons/react";
import type { SessionUser } from "@/lib/api"; import type { SessionUser } from "@/lib/api";
import type { PendingPrompt } from "@/lib/agents/pendingPrompts"; import type { PendingPrompt } from "@/lib/agents/pendingPrompts";
@ -83,17 +82,6 @@ export function AgentThreadView({ user, thread }: AgentThreadViewProps) {
return ( return (
<AgentsShell user={user} activeThreadId={thread.id}> <AgentsShell user={user} activeThreadId={thread.id}>
<div className="flex min-w-0 flex-1 flex-col"> <div className="flex min-w-0 flex-1 flex-col">
<div className="flex shrink-0 items-center gap-2 border-b border-[var(--ui-border)] px-4 py-3">
<span className="truncate text-sm font-medium text-[color:var(--ui-text)]">
{thread.title}
</span>
{thread.repo && (
<span className="flex min-w-0 items-center gap-1 text-xs text-[color:var(--ui-text-dim)]">
<FolderIcon className="size-3.5 shrink-0" />
<span className="truncate">{thread.repoFullName}</span>
</span>
)}
</div>
<div className="flex min-h-0 flex-1 flex-col"> <div className="flex min-h-0 flex-1 flex-col">
{hasMessages ? ( {hasMessages ? (
<div className="relative flex min-h-0 flex-1 flex-col overflow-hidden"> <div className="relative flex min-h-0 flex-1 flex-col overflow-hidden">

View file

@ -59,6 +59,7 @@ export function AgentsSidebar({ user, activeThreadId }: AgentsSidebarProps) {
<div className="px-2 pb-1"> <div className="px-2 pb-1">
<Link <Link
to="/agents" to="/agents"
onClick={layout.closeOnMobile}
className="flex w-full items-center gap-2.5 rounded-md px-2.5 py-1.5 text-xs font-medium text-[var(--ui-text)] transition-colors hover:bg-[var(--ui-sidebar-hover)]" className="flex w-full items-center gap-2.5 rounded-md px-2.5 py-1.5 text-xs font-medium text-[var(--ui-text)] transition-colors hover:bg-[var(--ui-sidebar-hover)]"
> >
<PlusIcon className="size-4" /> <PlusIcon className="size-4" />
@ -73,6 +74,7 @@ export function AgentsSidebar({ user, activeThreadId }: AgentsSidebarProps) {
<Link <Link
key={item.to} key={item.to}
to={item.to} to={item.to}
onClick={layout.closeOnMobile}
className="flex items-center gap-2.5 rounded-md px-2.5 py-1.5 text-xs text-[var(--ui-text-muted)] transition-colors hover:bg-[var(--ui-sidebar-hover)] hover:text-[var(--ui-text)]" className="flex items-center gap-2.5 rounded-md px-2.5 py-1.5 text-xs text-[var(--ui-text-muted)] transition-colors hover:bg-[var(--ui-sidebar-hover)] hover:text-[var(--ui-text)]"
> >
<Icon className="size-4" /> <Icon className="size-4" />
@ -83,9 +85,24 @@ export function AgentsSidebar({ user, activeThreadId }: AgentsSidebarProps) {
</nav> </nav>
<div className="min-h-0 flex-1 overflow-y-auto px-2 pb-2"> <div className="min-h-0 flex-1 overflow-y-auto px-2 pb-2">
<ThreadGroup label="Today" threads={groups.today} activeThreadId={activeThreadId} /> <ThreadGroup
<ThreadGroup label="Last 30 days" threads={groups.last30} activeThreadId={activeThreadId} /> label="Today"
<ThreadGroup label="Older" threads={groups.older} activeThreadId={activeThreadId} /> threads={groups.today}
activeThreadId={activeThreadId}
onNavigate={layout.closeOnMobile}
/>
<ThreadGroup
label="Last 30 days"
threads={groups.last30}
activeThreadId={activeThreadId}
onNavigate={layout.closeOnMobile}
/>
<ThreadGroup
label="Older"
threads={groups.older}
activeThreadId={activeThreadId}
onNavigate={layout.closeOnMobile}
/>
</div> </div>
<div className="p-2"> <div className="p-2">
@ -99,10 +116,12 @@ function ThreadGroup({
label, label,
threads, threads,
activeThreadId, activeThreadId,
onNavigate,
}: { }: {
label: string; label: string;
threads: Array<AgentThread>; threads: Array<AgentThread>;
activeThreadId?: string; activeThreadId?: string;
onNavigate?: () => void;
}) { }) {
if (threads.length === 0) return null; if (threads.length === 0) return null;
@ -112,13 +131,26 @@ function ThreadGroup({
{label} {label}
</div> </div>
{threads.map((thread) => ( {threads.map((thread) => (
<ThreadRow key={thread.id} thread={thread} isActive={thread.id === activeThreadId} /> <ThreadRow
key={thread.id}
thread={thread}
isActive={thread.id === activeThreadId}
onNavigate={onNavigate}
/>
))} ))}
</div> </div>
); );
} }
function ThreadRow({ thread, isActive }: { thread: AgentThread; isActive: boolean }) { function ThreadRow({
thread,
isActive,
onNavigate,
}: {
thread: AgentThread;
isActive: boolean;
onNavigate?: () => void;
}) {
const deleteThread = useDeleteAgentThread(); const deleteThread = useDeleteAgentThread();
const badge = const badge =
thread.diffStats && thread.diffStats.additions > 0 thread.diffStats && thread.diffStats.additions > 0
@ -141,6 +173,7 @@ function ThreadRow({ thread, isActive }: { thread: AgentThread; isActive: boolea
<Link <Link
to="/agents/$threadId" to="/agents/$threadId"
params={{ threadId: thread.id }} params={{ threadId: thread.id }}
onClick={onNavigate}
className={cn( className={cn(
"group mb-0.5 flex items-center gap-2 rounded-lg px-2.5 py-1.5 transition-colors", "group mb-0.5 flex items-center gap-2 rounded-lg px-2.5 py-1.5 transition-colors",
isActive isActive

View file

@ -20,6 +20,9 @@ function readStoredWidth(): number {
function readStoredCollapsed(): boolean { function readStoredCollapsed(): boolean {
if (typeof window === "undefined") return false; if (typeof window === "undefined") return false;
// On mobile the sidebar is a full-screen overlay, so start collapsed to keep
// the chat visible regardless of the stored desktop preference.
if (window.matchMedia("(max-width: 767px)").matches) return true;
return window.localStorage.getItem(STORAGE_COLLAPSED) === "1"; return window.localStorage.getItem(STORAGE_COLLAPSED) === "1";
} }
@ -40,7 +43,13 @@ export function useSidebarLayout() {
const toggle = useCallback(() => setCollapsed(!collapsed), [collapsed, setCollapsed]); const toggle = useCallback(() => setCollapsed(!collapsed), [collapsed, setCollapsed]);
return { width, collapsed, setWidth, setCollapsed, toggle }; const closeOnMobile = useCallback(() => {
if (typeof window === "undefined") return;
// State-only: don't persist, so the desktop collapsed preference is preserved.
if (window.matchMedia("(max-width: 767px)").matches) setCollapsedState(true);
}, []);
return { width, collapsed, setWidth, setCollapsed, toggle, closeOnMobile };
} }
interface SidebarFrameProps { interface SidebarFrameProps {
@ -76,10 +85,16 @@ export function SidebarFrame({
return ( return (
<aside <aside
style={{ width }} style={{ width }}
className={cn("relative flex h-svh shrink-0 flex-col", className)} className={cn(
"relative flex h-svh shrink-0 flex-col",
"max-md:fixed max-md:inset-0 max-md:z-40 max-md:!w-full",
className,
)}
> >
{children} {children}
<ResizeHandle width={width} onResize={setWidth} /> <div className="max-md:hidden">
<ResizeHandle width={width} onResize={setWidth} />
</div>
</aside> </aside>
); );
} }