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">
<AppSidebar user={user} />
<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 && (
<Link
to={backTo.to}
@ -83,7 +83,7 @@ export function SettingsRow({
comingSoon,
}: SettingsRowProps) {
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}>
<span className="flex items-center gap-2">
<span
@ -103,7 +103,7 @@ export function SettingsRow({
<span className="text-xs text-muted-foreground">{description}</span>
)}
</label>
<div className={`shrink-0 ${comingSoon ? "opacity-50" : ""}`}>{control}</div>
<div className={`sm:shrink-0 ${comingSoon ? "opacity-50" : ""}`}>{control}</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">
<Link
to="/agents"
onClick={layout.closeOnMobile}
className={cn(
"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",
@ -65,6 +66,7 @@ export function AppSidebar({ user }: { user: SessionUser }) {
<Link
key={item.to}
to={item.to}
onClick={layout.closeOnMobile}
className={cn(
"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",

View file

@ -69,25 +69,29 @@ export function AgentRunCard({ thread }: AgentRunCardProps) {
<div className="min-w-0 flex-1">
<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 && (
<>
<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} />
{source.label}
</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 && (
<>
<span>·</span>
<span>{thread.repo}</span>
<span className="shrink-0">·</span>
<span className="min-w-0 truncate" title={thread.repo}>
{thread.repo}
</span>
</>
)}
<span>·</span>
<span>{formatRelativeTime(thread.updatedAt)}</span>
<span className="shrink-0">·</span>
<span className="shrink-0 whitespace-nowrap">{formatRelativeTime(thread.updatedAt)}</span>
</div>
</div>

View file

@ -1,5 +1,4 @@
import { useEffect, useMemo, useState } from "react";
import { FolderIcon } from "@phosphor-icons/react";
import type { SessionUser } from "@/lib/api";
import type { PendingPrompt } from "@/lib/agents/pendingPrompts";
@ -83,17 +82,6 @@ export function AgentThreadView({ user, thread }: AgentThreadViewProps) {
return (
<AgentsShell user={user} activeThreadId={thread.id}>
<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">
{hasMessages ? (
<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">
<Link
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)]"
>
<PlusIcon className="size-4" />
@ -73,6 +74,7 @@ export function AgentsSidebar({ user, activeThreadId }: AgentsSidebarProps) {
<Link
key={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)]"
>
<Icon className="size-4" />
@ -83,9 +85,24 @@ export function AgentsSidebar({ user, activeThreadId }: AgentsSidebarProps) {
</nav>
<div className="min-h-0 flex-1 overflow-y-auto px-2 pb-2">
<ThreadGroup label="Today" threads={groups.today} activeThreadId={activeThreadId} />
<ThreadGroup label="Last 30 days" threads={groups.last30} activeThreadId={activeThreadId} />
<ThreadGroup label="Older" threads={groups.older} activeThreadId={activeThreadId} />
<ThreadGroup
label="Today"
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 className="p-2">
@ -99,10 +116,12 @@ function ThreadGroup({
label,
threads,
activeThreadId,
onNavigate,
}: {
label: string;
threads: Array<AgentThread>;
activeThreadId?: string;
onNavigate?: () => void;
}) {
if (threads.length === 0) return null;
@ -112,13 +131,26 @@ function ThreadGroup({
{label}
</div>
{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>
);
}
function ThreadRow({ thread, isActive }: { thread: AgentThread; isActive: boolean }) {
function ThreadRow({
thread,
isActive,
onNavigate,
}: {
thread: AgentThread;
isActive: boolean;
onNavigate?: () => void;
}) {
const deleteThread = useDeleteAgentThread();
const badge =
thread.diffStats && thread.diffStats.additions > 0
@ -141,6 +173,7 @@ function ThreadRow({ thread, isActive }: { thread: AgentThread; isActive: boolea
<Link
to="/agents/$threadId"
params={{ threadId: thread.id }}
onClick={onNavigate}
className={cn(
"group mb-0.5 flex items-center gap-2 rounded-lg px-2.5 py-1.5 transition-colors",
isActive

View file

@ -20,6 +20,9 @@ function readStoredWidth(): number {
function readStoredCollapsed(): boolean {
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";
}
@ -40,7 +43,13 @@ export function useSidebarLayout() {
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 {
@ -76,10 +85,16 @@ export function SidebarFrame({
return (
<aside
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}
<ResizeHandle width={width} onResize={setWidth} />
<div className="max-md:hidden">
<ResizeHandle width={width} onResize={setWidth} />
</div>
</aside>
);
}