Skip to content
43 changes: 43 additions & 0 deletions interface/src/components/MobileTopBar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import {List} from "@phosphor-icons/react";
import clsx from "clsx";
import type {ReactNode} from "react";

interface MobileTopBarProps {
title?: string;
onMenu: () => void;
leading?: ReactNode;
trailing?: ReactNode;
className?: string;
}

export function MobileTopBar({
title,
onMenu,
leading,
trailing,
className,
}: MobileTopBarProps) {
return (
<header
className={clsx(
"flex h-12 shrink-0 items-center gap-2 border-b border-app-line bg-sidebar px-2",
className,
)}
>
{leading ?? (
<button
type="button"
aria-label="Open menu"
onClick={onMenu}
className="flex h-11 w-11 items-center justify-center rounded-lg text-ink hover:bg-app-box/60 active:bg-app-box/80"
>
<List size={20} weight="regular" />
</button>
)}
<div className="min-w-0 flex-1 truncate text-sm font-medium text-ink">
{title}
</div>
{trailing && <div className="flex items-center gap-1">{trailing}</div>}
</header>
);
}
12 changes: 8 additions & 4 deletions interface/src/components/OpenCodeEmbed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,9 @@ export function OpenCodeEmbed({

// Pre-seed OpenCode layout preferences so it starts with a clean
// chat-only view (sidebar, terminal, file tree, review all closed).
// `session.width` is intentionally omitted so OpenCode picks a width
// suitable for the current viewport — seeding a fixed 600px broke
// the native mobile layout at narrow widths.
const layoutKey = "opencode.global.dat:layout";
if (!localStorage.getItem(layoutKey)) {
localStorage.setItem(
Expand All @@ -232,7 +235,6 @@ export function OpenCodeEmbed({
terminal: {height: 280, opened: false},
review: {diffStyle: "split", panelOpened: false},
fileTree: {opened: false, width: 344, tab: "changes"},
session: {width: 600},
mobileSidebar: {opened: false},
sessionTabs: {},
sessionView: {},
Expand Down Expand Up @@ -261,12 +263,14 @@ export function OpenCodeEmbed({
style.textContent = cssText;
shadow.appendChild(style);

// Hide the sidebar, mobile sidebar, and top bar in embedded
// mode — we only want the session/chat view.
// Hide OpenCode's desktop nav + title chrome — spacebot provides
// its own. The mobile nav (`sidebar-nav-mobile`) is intentionally
// NOT hidden: when the viewport drops below md, OpenCode's
// internal responsive layout surfaces it, and we want users to
// have a way to switch sessions inside the embed on phones.
const overrides = document.createElement("style");
overrides.textContent = `
[data-component="sidebar-nav-desktop"],
[data-component="sidebar-nav-mobile"],
header:has(#opencode-titlebar-center),
[data-session-title] { display: none !important; }
main { border: none !important; border-radius: 0 !important; }
Expand Down
15 changes: 13 additions & 2 deletions interface/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@ import {IS_DESKTOP, IS_MACOS} from "@/platform";

interface SidebarProps {
liveStates: Record<string, ChannelLiveState>;
/** When true, drop the fixed 220px width — sidebar fills its drawer container. */
fillWidth?: boolean;
}

const agentSubItems = [
Expand Down Expand Up @@ -185,7 +187,10 @@ function readProjectId(search: unknown): string | null {
return typeof id === "string" ? id : null;
}

export function Sidebar({liveStates: _liveStates}: SidebarProps) {
export function Sidebar({
liveStates: _liveStates,
fillWidth = false,
}: SidebarProps) {
const navigate = useNavigate();
const [createOpen, setCreateOpen] = useState(false);
const [userExpandedAgent, setUserExpandedAgent] = useState<string | null>(
Expand Down Expand Up @@ -273,7 +278,13 @@ export function Sidebar({liveStates: _liveStates}: SidebarProps) {
};

return (
<aside className="flex w-[220px] shrink-0 flex-col bg-sidebar">
<aside
className={
fillWidth
? "flex h-full w-full shrink-0 flex-col bg-sidebar"
: "flex w-[220px] shrink-0 flex-col bg-sidebar"
}
>
{/* Company switcher */}
<div className={`px-3 ${IS_DESKTOP && IS_MACOS ? "pt-[50px]" : "pt-3"}`}>
<Popover.Root open={switcherOpen} onOpenChange={setSwitcherOpen}>
Expand Down
2 changes: 1 addition & 1 deletion interface/src/components/agent-config/ConfigSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export function ConfigSidebar({
identityData,
}: ConfigSidebarProps) {
return (
<div className="flex w-52 flex-shrink-0 flex-col border-r border-app-line/50 bg-app-dark-box/20 overflow-y-auto">
<div className="flex w-full shrink-0 flex-col overflow-y-auto md:w-52 md:border-r md:border-app-line/50 md:bg-app-dark-box/20">
{/* General Group */}
<div className="flex flex-col gap-0.5 px-2 pt-3">
{SECTIONS.filter((s) => s.group === "general").map((section) => (
Expand Down
2 changes: 1 addition & 1 deletion interface/src/components/dashboard/ActivityCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,7 @@ export function ActivityCard() {
</div>

{/* Summary row */}
<div className="mt-3 flex items-center gap-4 text-tiny">
<div className="mt-3 flex flex-wrap items-center gap-x-4 gap-y-1.5 text-tiny">
{SERIES.map((s) => {
const val = totals[s.key as keyof typeof totals] as number;
if (val === 0) return null;
Expand Down
2 changes: 1 addition & 1 deletion interface/src/components/skills/SkillInspector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ export function SkillInspector({
selected.type === "installed" ? installedContentQuery.data?.base_dir : null;

return (
<div className="flex w-80 flex-shrink-0 flex-col border-l border-app-line/50 bg-app-dark-box/10">
<div className="flex h-full w-full shrink-0 flex-col md:w-80 md:border-l md:border-app-line/50 md:bg-app-dark-box/10">
{/* Header */}
<div className="flex items-start justify-between gap-2 border-b border-app-line/50 px-4 py-3">
<div className="min-w-0 flex-1">
Expand Down
2 changes: 1 addition & 1 deletion interface/src/components/skills/SkillsSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ export function SkillsSidebar({
selectedSkill?.type === "installed" ? selectedSkill.skill.name : null;

return (
<div className="flex w-52 flex-shrink-0 flex-col border-r border-app-line/50 bg-app-dark-box/20">
<div className="flex w-full shrink-0 flex-col md:w-52 md:border-r md:border-app-line/50 md:bg-app-dark-box/20">
{/* Top actions */}
<div className="flex flex-col gap-0.5 px-2 pt-3">
{TOP_VIEWS.map(({view, label, icon: Icon}) => (
Expand Down
12 changes: 10 additions & 2 deletions interface/src/components/workbench/WorkbenchSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,21 @@ export function WorkbenchSidebar({
tree,
totalCount,
onSelectWorker,
fillWidth = false,
}: {
tree: ProjectGroup[];
totalCount: number;
onSelectWorker: (id: string) => void;
fillWidth?: boolean;
}) {
return (
<aside className="flex h-full w-[270px] flex-shrink-0 flex-col overflow-hidden rounded-2xl border border-app-line bg-app">
<aside
className={
fillWidth
? "flex h-full w-full flex-col overflow-hidden bg-sidebar"
: "flex h-full w-[270px] shrink-0 flex-col overflow-hidden rounded-2xl border border-app-line bg-app"
}
>
<div className="flex h-[60px] flex-col justify-center gap-0.5 border-b border-app-line px-3 pt-1">
<h2 className="text-xs font-medium text-ink">Workbench</h2>
<div className="text-tiny text-ink-faint">
Expand Down Expand Up @@ -142,7 +150,7 @@ function WorkerRow({
>
<span
className={cx(
"h-2 w-2 flex-shrink-0 rounded-full",
"h-2 w-2 shrink-0 rounded-full",
isRunning && "animate-pulse bg-green-500",
isIdle && "bg-yellow-500",
!isRunning && !isIdle && "bg-ink-faint/40",
Expand Down
6 changes: 3 additions & 3 deletions interface/src/components/workbench/WorkerColumn.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,14 @@ export function WorkerColumn({worker}: {worker: OrchestrationWorker}) {
const taskText = worker.task.replace(/^\[opencode\]\s*/i, "");

return (
<div className="flex h-full w-[560px] flex-shrink-0 flex-col overflow-hidden rounded-2xl border border-app-line bg-app-box">
<div className="flex h-full w-full shrink-0 snap-center flex-col overflow-hidden rounded-2xl border border-app-line bg-app-box md:w-[560px] md:snap-none">
{/* Column header */}
<div className="flex flex-col gap-1 h-[60px] border-b border-app-line px-3 py-2">
<div className="flex items-center gap-2">
{/* Status dot */}
<span
className={cx(
"h-2 w-2 bg-ink-faint flex-shrink-0 rounded-full",
"h-2 w-2 bg-ink-faint shrink-0 rounded-full",
isRunning && "animate-pulse bg-green-500",
isIdle && "bg-yellow-500",
!isRunning && !isIdle && "bg-ink-faint",
Expand Down Expand Up @@ -90,7 +90,7 @@ function CancelButton({
.catch(console.warn)
.finally(() => setCancelling(false));
}}
className="flex-shrink-0 rounded-md border border-app-line px-1.5 py-0.5 text-tiny font-medium text-ink-dull transition-colors hover:border-red-500/50 hover:text-red-400 disabled:opacity-50"
className="shrink-0 rounded-md border border-app-line px-1.5 py-0.5 text-tiny font-medium text-ink-dull transition-colors hover:border-red-500/50 hover:text-red-400 disabled:opacity-50"
title="Cancel worker"
>
{cancelling ? "..." : "Cancel"}
Expand Down
25 changes: 25 additions & 0 deletions interface/src/hooks/useMediaQuery.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import {useEffect, useState} from "react";

export function useMediaQuery(query: string): boolean {
const getMatch = () => {
if (typeof window === "undefined") return false;
return window.matchMedia(query).matches;
};

const [matches, setMatches] = useState<boolean>(getMatch);

useEffect(() => {
if (typeof window === "undefined") return;
const mql = window.matchMedia(query);
const handler = (e: MediaQueryListEvent) => setMatches(e.matches);
setMatches(mql.matches);
mql.addEventListener("change", handler);
return () => mql.removeEventListener("change", handler);
}, [query]);

return matches;
}

export function useIsMobile(): boolean {
return useMediaQuery("(max-width: 767px)");
}
62 changes: 58 additions & 4 deletions interface/src/router.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import {useEffect, useState} from "react";
import {
createRouter,
createRootRoute,
Expand All @@ -7,7 +8,10 @@ import {
} from "@tanstack/react-router";
import {BASE_PATH} from "@/api/client";
import {ConnectionBanner} from "@/components/ConnectionBanner";
import {MobileTopBar} from "@/components/MobileTopBar";
import {Sidebar} from "@/components/Sidebar";
import {useIsMobile} from "@/hooks/useMediaQuery";
import {Drawer} from "@/ui/Drawer";
import {Overview} from "@/routes/Overview";
import {Dashboard} from "@/routes/Dashboard";
import {AgentDetail} from "@/routes/AgentDetail";
Expand All @@ -31,18 +35,58 @@ import {useLiveContext} from "@/hooks/useLiveContext";

// ── Root layout ──────────────────────────────────────────────────────────

function routeTitle(pathname: string): string {
if (pathname === "/" || pathname === "") return "Overview";
if (pathname.startsWith("/dashboard")) return "Dashboard";
if (pathname.startsWith("/workbench")) return "Workbench";
if (pathname.startsWith("/tasks")) return "Tasks";
if (pathname.startsWith("/wiki")) return "Wiki";
if (pathname.startsWith("/settings")) return "Settings";
if (pathname.startsWith("/projects")) return "Projects";
if (pathname.startsWith("/logs")) return "Logs";
const agentMatch = pathname.match(/^\/agents\/[^/]+(?:\/([^/]+))?/);
if (agentMatch) {
const sub = agentMatch[1];
if (!sub) return "Agent";
return sub.charAt(0).toUpperCase() + sub.slice(1);
}
return "";
}

function RootLayout() {
const {liveStates, connectionState, hasData} = useLiveContext();
const location = useLocation();
const bare = location.pathname.startsWith("/workbench") || location.pathname.startsWith("/dashboard");
const isMobile = useIsMobile();
const [drawerOpen, setDrawerOpen] = useState(false);

const bare =
location.pathname.startsWith("/workbench") ||
location.pathname.startsWith("/dashboard");

// Auto-close drawer on route change.
useEffect(() => {
setDrawerOpen(false);
}, [location.pathname]);

return (
<div className="flex h-screen flex-col overflow-hidden bg-sidebar">
<ConnectionBanner state={connectionState} hasData={hasData} />
{isMobile && (
<MobileTopBar
title={routeTitle(location.pathname)}
onMenu={() => setDrawerOpen(true)}
/>
)}
<div className="flex min-h-0 flex-1">
<Sidebar liveStates={liveStates} />
<div className="flex min-w-0 flex-1 flex-col overflow-hidden py-[10px] pr-[10px]">
{bare ? (
{!isMobile && <Sidebar liveStates={liveStates} />}
<div
className={
isMobile
? "flex min-w-0 flex-1 flex-col overflow-hidden"
: "flex min-w-0 flex-1 flex-col overflow-hidden py-[10px] pr-[10px]"
}
>
{bare || isMobile ? (
<Outlet />
) : (
<div className="flex min-w-0 flex-1 flex-col overflow-hidden rounded-2xl border border-app-line bg-app">
Expand All @@ -51,6 +95,16 @@ function RootLayout() {
)}
</div>
</div>
{isMobile && (
<Drawer
open={drawerOpen}
onOpenChange={setDrawerOpen}
side="left"
ariaLabel="Primary navigation"
>
<Sidebar liveStates={liveStates} fillWidth />
</Drawer>
)}
</div>
);
}
Expand Down
Loading