feat: adapt dashboard workspace for mobile (#818)

Supersedes #382. Rebuild mobile navigation and full-width workspace panes.

Assisted-by: gpt-6.1-sol
Assisted-by: gpt-6.1-terra
Assisted-by: gpt-6-astra
This commit is contained in:
clover
2026-10-28 22:27:02 +00:00
committed by GitHub
parent f10b3f3371
commit ea41b76ee1
6 changed files with 144 additions and 33 deletions
+1 -1
View File
@@ -2,7 +2,7 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>OpenResearch</title>
<script>
+30 -13
View File
@@ -480,10 +480,13 @@ export default function App({ runtime, projectId, pane }: { runtime: RuntimeInfo
const workspaceWide = windowWidth >= WORKSPACE_CARD_MIN_WIDTH;
const workspaceCardVisible = mainView === "chat" && !panelOpen && workspaceWide;
const [railOpen, setRailOpen] = useState(true);
const [mobileRailOpen, setMobileRailOpen] = useState(false);
const mobile = windowWidth < 768;
useEffect(() => { setMobileRailOpen(false); }, [mobile, location.href]);
const [preferencesLoaded, setPreferencesLoaded] = useState(false);
const railTemporarilyHidden = mainView === "chat" && panelOpen && !sidebarFitsWithPanel(windowWidth);
const railVisible = railOpen && !railTemporarilyHidden;
const stackPanel = mainView === "chat" && panelOpen && windowWidth < PANEL_MIN_WIDTH * 2;
const railVisible = mobile ? mobileRailOpen : railOpen && !railTemporarilyHidden;
const stackPanel = !mobile && mainView === "chat" && panelOpen && windowWidth < PANEL_MIN_WIDTH * 2;
const visiblePanelWidth = Math.min(panelWidth, panelMaxWidth(windowWidth, railVisible));
const bodyRef = useRef<HTMLDivElement>(null);
const focusAfterRailChange = useRef<"rail" | "show" | Element | null>(null);
@@ -1651,6 +1654,12 @@ export default function App({ runtime, projectId, pane }: { runtime: RuntimeInfo
const codeTab = requestedCodeTab
? (codeTabs.find((tab) => sameCodeTab(tab, requestedCodeTab)) ?? null)
: null;
let activeWorkspaceView: "files" | "artifacts" | "experiments" | "terminal" | null = null;
if (panelOpen) {
if (expTab || codeTab) activeWorkspaceView = "experiments";
else if (fileTab) activeWorkspaceView = fileTab.source === "artifacts" ? "artifacts" : "files";
else if (rightTab === "files" || rightTab === "artifacts" || rightTab === "experiments" || rightTab === "terminal") activeWorkspaceView = rightTab;
}
const contentTabByKey = new Map<string, ContentTab>();
for (const tab of [...expTabs, ...fileTabs, ...planTabs, ...subagentTabs, ...sideTabs, ...codeTabs]) {
contentTabByKey.set(rightTabKey(tab), tab);
@@ -1808,7 +1817,8 @@ export default function App({ runtime, projectId, pane }: { runtime: RuntimeInfo
<RailHeader
onCollapse={() => {
focusAfterRailChange.current = "show";
setRailOpen(false);
if (mobile) setMobileRailOpen(false);
else setRailOpen(false);
}}
/>
);
@@ -1818,7 +1828,8 @@ export default function App({ runtime, projectId, pane }: { runtime: RuntimeInfo
{projectId && <div className="absolute start-[calc(5.15rem/min(1,var(--desktop-page-zoom,1)))] top-[calc(-2px/min(1,var(--desktop-page-zoom,1)))] z-10 hidden h-[calc(44px/min(1,var(--desktop-page-zoom,1)))] items-center mac-titlebar:flex">
<Tooltip interactive content={railVisible ? m.header_hide_sidebar() : m.chat_panel_show_sidebar()} className="rounded-md">
<IconButton aria-label={railVisible ? m.header_hide_sidebar() : m.chat_panel_show_sidebar()} aria-expanded={railVisible} onClick={() => {
if (railVisible) setRailOpen(false);
if (mobile) setMobileRailOpen(!mobileRailOpen);
else if (railVisible) setRailOpen(false);
else {
focusAfterRailChange.current = "rail";
setRailOpen(true);
@@ -1833,7 +1844,7 @@ export default function App({ runtime, projectId, pane }: { runtime: RuntimeInfo
{runtime.kind === "local" && <UpdateBanner status={updateStatus} hasSidebar={Boolean(projectId)} />}
<DesktopAppBanner />
{workspaceError && <div role="alert" className="flex items-center gap-2 px-4 mac-titlebar:ps-titlebar-sidebar-controls mac-titlebar:min-h-titlebar win-titlebar:pe-win-controls py-2 text-subtext"><span>{workspaceError}</span><Button onClick={retryWorkspace}>{m.app_retry()}</Button></div>}
<div ref={bodyRef} className={`app-body workspace-body relative flex flex-1 min-h-0 ${stackPanel ? "flex-col [&>.chat-pane]:overflow-y-auto [&_.chat-empty]:min-h-0 [&_.chat-empty]:overflow-y-auto [&_.chat-empty]:justify-start [&_.chat-empty]:p-0" : ""} ${workspaceCardVisible ? "workspace-card-visible" : ""}`}>
<div ref={bodyRef} className={`app-body workspace-body relative flex flex-1 min-h-0 ${stackPanel ? "flex-col [&>.chat-pane]:overflow-y-auto [&_.chat-empty]:min-h-0 [&_.chat-empty]:overflow-y-auto [&_.chat-empty]:justify-start [&_.chat-empty]:p-0" : ""} ${workspaceCardVisible ? "workspace-card-visible" : ""} ${mainView === "chat" && workspaceReady && !conversationError ? "has-workspace-tools" : ""}`}>
{projectId && (
<ChatPanel
projectId={projectId}
@@ -1843,14 +1854,20 @@ export default function App({ runtime, projectId, pane }: { runtime: RuntimeInfo
onRetryContent={retryConversation}
railHeader={railHeader}
railOpen={railVisible}
mobileRail={mobile}
onCloseMobileRail={() => setMobileRailOpen(false)}
contentInert={mobile && panelOpen && mainView === "chat"}
onShowRail={() => {
focusAfterRailChange.current = "rail";
setRailOpen(true);
if (railTemporarilyHidden) closePanel();
if (mobile) setMobileRailOpen(true);
else {
focusAfterRailChange.current = "rail";
setRailOpen(true);
if (railTemporarilyHidden) closePanel();
}
}}
onNewProject={() => setNewProjectOpen(true)}
onNewProject={() => { setMobileRailOpen(false); setNewProjectOpen(true); }}
mainView={mainView}
onSelectMainView={selectMainView}
onSelectMainView={(view) => { setMobileRailOpen(false); selectMainView(view); }}
onOpenFile={openChatFile}
onOpenRun={openRunLogs}
runExperimentName={runExperimentName}
@@ -1863,7 +1880,7 @@ export default function App({ runtime, projectId, pane }: { runtime: RuntimeInfo
composerFocusNonce={composerFocusNonce}
runtime={runtime}
activeSessionId={activeSessionId}
onActiveSessionChange={onActiveSessionChange}
onActiveSessionChange={(...args) => { setMobileRailOpen(false); onActiveSessionChange(...args); }}
preferredAgent={uiState.preferredAgent}
onPreferredAgentChange={persistPreferredAgent}
preferredAutonomy={uiState.preferredAutonomy ?? DEFAULT_AUTONOMY}
@@ -1891,7 +1908,7 @@ export default function App({ runtime, projectId, pane }: { runtime: RuntimeInfo
runs={runs}
onOpenExperiment={(id, runId) => openExperimentTab(id, "overview", "preview", runId)}
rightOffset={panelOpen && !stackPanel ? visiblePanelWidth + 14 : undefined}
activeView={panelOpen && (rightTab === "files" || rightTab === "artifacts" || rightTab === "experiments" || rightTab === "terminal") ? rightTab : null}
activeView={activeWorkspaceView}
projectId={activeProject.id}
onCompute={() => selectMainView("compute")}
sessionId={activeSessionId}
@@ -1909,7 +1926,7 @@ export default function App({ runtime, projectId, pane }: { runtime: RuntimeInfo
{mainView === "chat" && panelOpen && (
<aside
className={`right-pane relative shrink-0 min-w-0 flex flex-col bg-background [&.max]:fixed [&.max]:inset-0 [&.max]:z-60 [&.max]:border-s-0 overflow-hidden ${stackPanel ? "border-t border-t-border" : "border-s border-s-border"} ${panelMax ? "max" : ""}`}
style={panelMax ? undefined : stackPanel ? { width: "100%", height: "50%" } : { width: visiblePanelWidth }}
style={mobile || panelMax ? undefined : stackPanel ? { width: "100%", height: "50%" } : { width: visiblePanelWidth }}
data-onboarding="experiments"
>
{!stackPanel && <div
+55
View File
@@ -449,3 +449,58 @@
font-size: inherit;
}
}
@media (max-width: 767px) {
html, body, #root { height: 100dvh; }
.app { padding-top: env(safe-area-inset-top); }
.workspace-body.has-workspace-tools { padding-bottom: calc(64px + env(safe-area-inset-bottom)); }
.workspace-body > .chat-pane .chat-header { padding-inline: 8px; }
.chat-heading-row { grid-template-columns: 44px minmax(0, 1fr) 0; }
.chat-heading-row > span > .icon-btn, .mac-titlebar-spacer > span > .icon-btn { width: 44px; height: 44px; }
.workspace-tools { display: none; }
.workspace-mobile-nav {
display: flex;
position: fixed;
inset-inline: 0;
bottom: 0;
z-index: 65;
min-height: 64px;
padding: 4px 8px calc(4px + env(safe-area-inset-bottom));
border-top: 1px solid var(--border);
background: var(--base);
}
.workspace-mobile-nav > * { flex: 1 1 0; min-width: 0; }
.workspace-mobile-nav > button, .workspace-mobile-nav > div > button {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
min-height: 56px;
border-radius: var(--radius-md);
color: var(--subtext);
font-size: 11px;
white-space: nowrap;
}
.workspace-mobile-nav button span { max-width: 100%; overflow: hidden; text-overflow: ellipsis; }
.workspace-mobile-nav button:focus-visible { outline: 2px solid var(--focus); outline-offset: -2px; }
.workspace-mobile-nav button[aria-pressed="true"] { background: var(--surface); color: var(--text); }
.mobile-rail-dialog { width: min(320px, calc(100vw - 40px)); padding-top: env(safe-area-inset-top); padding-bottom: env(safe-area-inset-bottom); background: var(--base); }
.mobile-rail-dialog .session-rail { width: 100%; height: 100%; }
.session-rail .rail-nav-item { min-height: 44px; }
.workspace-body .right-pane, .workspace-body .right-pane.max {
position: fixed;
inset: env(safe-area-inset-top) 0 calc(64px + env(safe-area-inset-bottom));
width: 100%;
border: 0;
z-index: 60;
}
.right-pane .panel-resizer, .right-pane .panel-controls > :first-child { display: none; }
.right-pane > .tabs { min-height: 52px; }
.right-pane .panel-controls > span > button { width: 44px; height: 44px; }
.chat-empty { padding: 20px 16px; overflow-y: auto; }
.chat-empty h2 { font-size: 28px; line-height: 1.2; }
.chat-thread, .settings-view-scroll, .home { scrollbar-gutter: auto; }
.composer-actions { flex-wrap: wrap; }
input, textarea, select { font-size: 16px; }
}
+29 -12
View File
@@ -1,3 +1,4 @@
import { Dialog } from "@base-ui/react/dialog";
import { ComposerProjectPicker } from "./ComposerProjectPicker";
import { ProjectInfoCard } from "./ProjectInfoCard";
import { SessionHoverCard } from "./SessionHoverCard";
@@ -4419,6 +4420,7 @@ function SessionRow({
anchor={anchor}
cardId={cardId}
cardRef={cardRef}
container={ref.current?.closest(".mobile-rail-dialog") ?? document.body}
onEnter={() => { pointerInside.current = true; window.clearTimeout(closeTimer.current); }}
onLeave={() => { cardWarmUntil.current = Date.now() + 500; pointerInside.current = false; scheduleClose(); }}
onOpenExperiment={(projectId, experimentId) => { closeCard(); onOpenExperiment(projectId, experimentId); }}
@@ -4534,6 +4536,9 @@ export function ChatPanel({
onRetryContent,
railHeader,
railOpen,
mobileRail = false,
onCloseMobileRail,
contentInert = false,
onShowRail,
onNewProject,
mainView,
@@ -4569,6 +4574,9 @@ export function ChatPanel({
railHeader?: React.ReactNode;
/** Whether the agents rail is showing (collapsed via its own header icon). */
railOpen: boolean;
mobileRail?: boolean;
onCloseMobileRail?: () => void;
contentInert?: boolean;
/** Reopen the rail (from the chat header's sidebar icon). */
onShowRail: () => void;
onNewProject?: () => void;
@@ -4671,15 +4679,15 @@ export function ChatPanel({
const [focusedSidebarRow, setFocusedSidebarRow] = useState<string | null>(null);
const [chatLimits, setChatLimits] = useState<Record<string, number>>({});
const sidebarExpanded = sidebarExpandedAll || Object.keys(chatLimits).length > 0;
useLayoutEffect(() => {
const body = railBodyRef.current;
const setRailBody = useCallback((body: HTMLDivElement | null) => {
railBodyRef.current = body;
if (!body || embedded) return;
const resize = () => setRailHeight(body.clientHeight);
const observer = new ResizeObserver(resize);
observer.observe(body);
resize();
return () => observer.disconnect();
}, [railOpen, embedded, mainView]);
return () => { observer.disconnect(); railBodyRef.current = null; };
}, [embedded]);
const recentActivity = new Map(projectActivity.map((activity) => [activity.projectId, activity.lastActivityAt]));
const sortedProjects = [...sidebarProjects].sort((a, b) => Number(pinnedProjects.includes(b.id)) - Number(pinnedProjects.includes(a.id)) || Math.max(recentActivity.get(b.id) ?? 0, b.updatedAt) - Math.max(recentActivity.get(a.id) ?? 0, a.updatedAt));
const candidateLimit = sidebarGrouping === "list" || sidebarExpanded ? sortedProjects.length : Math.max(1, Math.ceil(railHeight / 36));
@@ -6765,7 +6773,7 @@ export function ChatPanel({
</div>
</div>
<div className="relative flex-1 min-h-0">
<div ref={railBodyRef} tabIndex={-1} className={`rail-body h-full overflow-x-hidden ${sidebarScrollable ? "overflow-y-auto" : "overflow-y-hidden"}`}>
<div ref={setRailBody} tabIndex={-1} className={`rail-body h-full overflow-x-hidden ${sidebarScrollable ? "overflow-y-auto" : "overflow-y-hidden"}`}>
<div className="relative" style={{ height: (sidebarScrollable ? sidebarVirtualizer.getTotalSize() : defaultRowsHeight) + (showProjectMore ? 38 : 0) + (sidebarExpandedAll ? 46 : 0) }}>
{(sidebarScrollable
? sidebarVirtualItems.map((item) => ({ row: sidebarRows[item.index], top: item.start, index: item.index }))
@@ -6902,10 +6910,19 @@ export function ChatPanel({
);
const headerClass = `chat-header flex items-center gap-2 py-0 px-4 bg-background shrink-0 h-12 mac-titlebar:h-titlebar relative z-4 w-full max-w-readable my-0 mx-auto [&::after]:content-[''] [&::after]:absolute [&::after]:top-full [&::after]:start-0 [&::after]:end-0 [&::after]:h-6 [&::after]:bg-[linear-gradient(to_bottom,_var(--base),_transparent)] [&::after]:pointer-events-none`;
const railReopen = !railOpen && !embedded && (
const displayedRail = mobileRail ? (
<Dialog.Root open={railOpen} onOpenChange={(open) => { if (!open) onCloseMobileRail?.(); }}>
<Dialog.Portal>
<Dialog.Backdrop className="fixed inset-0 z-70 bg-modal-backdrop" />
<Dialog.Popup aria-modal="true" aria-label={m.chat_panel_show_sidebar()} className="mobile-rail-dialog fixed inset-y-0 start-0 z-80 outline-none">{rail}</Dialog.Popup>
</Dialog.Portal>
</Dialog.Root>
) : railOpen ? rail : null;
const railReopen = (!railOpen || mobileRail) && !embedded && (
<Tooltip interactive content={m.chat_panel_show_sidebar()} className="rounded-md mac-titlebar:invisible">
<IconButton
aria-label={m.chat_panel_show_sidebar()}
aria-expanded={railOpen}
onClick={onShowRail}
className="mac-titlebar:ms-titlebar-inset"
>
@@ -6917,9 +6934,9 @@ export function ChatPanel({
if (mainView !== "chat") {
return (
<>
{railOpen && rail}
<section className="chat-pane flex-1 min-w-0 flex flex-col bg-background min-h-0">
{!railOpen && <div className="mac-titlebar-spacer flex h-12 mac-titlebar:h-titlebar shrink-0 items-center">{railReopen}</div>}
{displayedRail}
<section inert={contentInert} className="chat-pane flex-1 min-w-0 flex flex-col bg-background min-h-0">
{(!railOpen || mobileRail) && <div className="mac-titlebar-spacer flex h-12 mac-titlebar:h-titlebar shrink-0 items-center">{railReopen}</div>}
{/* Keyed per view so no tab inherits the chat thread's or another tab's scroll offset. */}
<div key={mainView} className="settings-view-scroll flex-1 min-h-0 overflow-y-auto [scrollbar-gutter:stable_both-edges]">
{contentError ? <div role="alert" className="p-6 text-subtext">{contentError}<Button onClick={onRetryContent}>{m.app_retry()}</Button></div>
@@ -6932,11 +6949,11 @@ export function ChatPanel({
return (
<>
{railOpen && rail}
<section data-side-chat={embedded || undefined} className="chat-pane flex-1 min-w-0 flex flex-col bg-background min-h-0">
{displayedRail}
<section inert={contentInert} data-side-chat={embedded || undefined} className="chat-pane flex-1 min-w-0 flex flex-col bg-background min-h-0">
{/* Header — session title on the left, end-pane view switchers on the
right, fading into the chat below (sessions live in the rail). */}
<div className={railOpen || embedded ? "contents" : "grid shrink-0 grid-cols-[2rem_minmax(0,1fr)_2rem] mac-titlebar:grid-cols-titlebar-chat items-center"}>
<div className={railOpen && !mobileRail || embedded ? "contents" : "chat-heading-row grid shrink-0 grid-cols-[2rem_minmax(0,1fr)_2rem] mac-titlebar:grid-cols-titlebar-chat items-center"}>
{railReopen}
<div className={headerClass}>
<div className="flex min-w-0 flex-1 items-center gap-2">
+3 -2
View File
@@ -70,7 +70,7 @@ function ExperimentRow({ row, sessionProjectId, tabbable, onFocus, onOpen }: {
);
}
export function SessionHoverCard({ session, modelName, projectName, harnessLogo, anchor, cardId, cardRef, onEnter, onLeave, onOpenExperiment, onReturnToRow, onContinue }: {
export function SessionHoverCard({ session, modelName, projectName, harnessLogo, anchor, cardId, cardRef, container, onEnter, onLeave, onOpenExperiment, onReturnToRow, onContinue }: {
session: ChatSession;
modelName: string | null;
projectName?: string;
@@ -78,6 +78,7 @@ export function SessionHoverCard({ session, modelName, projectName, harnessLogo,
anchor: DOMRect;
cardId: string;
cardRef: React.RefObject<HTMLDivElement | null>;
container: Element;
onEnter: () => void;
onLeave: () => void;
onOpenExperiment: (projectId: string, experimentId: string) => void;
@@ -190,6 +191,6 @@ export function SessionHoverCard({ session, modelName, projectName, harnessLogo,
</div>
</div>}
</div>,
document.body,
container,
);
}
+26 -5
View File
@@ -45,6 +45,16 @@ export function WorkspaceTools({ expanded, experiments, runs, onOpenExperiment,
{ id: "terminal", label: m.workspace_terminal(), Icon: Terminal, onClick: onTerminal },
];
return (
<>
<nav aria-label={m.workspace_tools_heading()} className="workspace-mobile-nav hidden">
{items.map(({ id, label, Icon, onClick }) => (
<button key={id} type="button" aria-pressed={activeView === id} onClick={onClick}>
<Icon size={20} aria-hidden="true" />
<span>{label}</span>
</button>
))}
{onSideChat && sessionId && <SideChatPicker parentId={sessionId} sessions={sessions} onSelect={onSelectSideChat} onCreate={onSideChat} mobile />}
</nav>
<div className="workspace-tools absolute end-3.5 top-2 mac-titlebar:top-1.5 z-30" style={{ insetInlineEnd: rightOffset }}>
{!expanded ? (
<nav aria-label={m.workspace_tools_heading()} className="flex items-center justify-end gap-1">
@@ -103,11 +113,13 @@ export function WorkspaceTools({ expanded, experiments, runs, onOpenExperiment,
</nav>
)}
</div>
</>
);
}
function SideChatPicker({ parentId, sessions, onSelect, onCreate, expanded = false }: {
function SideChatPicker({ parentId, sessions, onSelect, onCreate, expanded = false, mobile = false }: {
expanded?: boolean;
mobile?: boolean;
parentId: string;
sessions: ChatSession[];
onSelect: (sessionId: string) => void;
@@ -127,7 +139,11 @@ function SideChatPicker({ parentId, sessions, onSelect, onCreate, expanded = fal
useLayoutEffect(() => {
if (!open) return;
const position = () => {
if (panelRef.current) panelRef.current.style.maxHeight = `${Math.max(0, Math.min(384, window.innerHeight - panelRef.current.getBoundingClientRect().top - 8))}px`;
if (!panelRef.current) return;
const available = mobile
? (triggerRef.current?.getBoundingClientRect().top ?? 0) - 12
: window.innerHeight - panelRef.current.getBoundingClientRect().top - 8;
panelRef.current.style.maxHeight = `${Math.max(0, Math.min(384, available))}px`;
};
position();
window.addEventListener("resize", position);
@@ -136,7 +152,7 @@ function SideChatPicker({ parentId, sessions, onSelect, onCreate, expanded = fal
window.removeEventListener("resize", position);
window.removeEventListener("scroll", position, true);
};
}, [open]);
}, [open, mobile]);
const openPicker = () => {
clearTimeout(closeTimer.current);
@@ -183,7 +199,12 @@ function SideChatPicker({ parentId, sessions, onSelect, onCreate, expanded = fal
onBlur={(event) => {
if (!event.currentTarget.contains(event.relatedTarget)) setOpen(false);
}}>
{expanded ? (
{mobile ? (
<button {...triggerProps} className="w-full">
<MessagesSquare size={20} aria-hidden="true" />
<span>{m.side_chat_open()}</span>
</button>
) : expanded ? (
<MenuItem {...triggerProps} active={open} className="min-h-7 py-1 text-text">
<span className="flex items-center gap-4"><MessagesSquare size={16} />{m.side_chat_open()}</span>
</MenuItem>
@@ -194,7 +215,7 @@ function SideChatPicker({ parentId, sessions, onSelect, onCreate, expanded = fal
)}
{open && (
<div ref={panelRef} role="dialog" aria-label={m.side_chat_list({ count: children.length })}
className="absolute end-0 top-[calc(100%_+_4px)] z-50 flex w-80 max-w-[calc(100vw-1rem)] flex-col overflow-hidden rounded-lg border border-border bg-background p-1.5 shadow-menu">
className={`absolute end-0 ${mobile ? "bottom-[calc(100%_+_4px)]" : "top-[calc(100%_+_4px)]"} z-50 flex w-80 max-w-[calc(100vw-1rem)] flex-col overflow-hidden rounded-lg border border-border bg-background p-1.5 shadow-menu`}>
<div className="flex shrink-0 items-center justify-between gap-2 px-2">
<span className="text-sm font-medium text-text">{m.side_chat_list({ count: children.length })}</span>
{children.length > 0 && (