feat(frontend): polish board chrome and task actions

Polish the board and task action chrome.

- move board actions into the board header and remove empty board topbar space
- refine session topbar actions with notification, Kill, and Orchestrator controls
- add pointer cursors for clickable controls and clean sidebar child-session styling

Verified with frontend typecheck and targeted renderer tests.
This commit is contained in:
Khushi Diwan 2026-06-20 14:22:05 +05:30 committed by GitHub
parent 2cb20c23e4
commit a8a3056a6b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 212 additions and 72 deletions

View File

@ -1,11 +1,26 @@
import type { ReactNode } from "react";
// The board subhead (mc-board .dashboard-main__subhead): a 21px bold title with
// a muted one-line subtitle, optionally a trailing count.
export function DashboardSubhead({ title, subtitle, count }: { title: string; subtitle: string; count?: number }) {
export function DashboardSubhead({
title,
subtitle,
count,
actions,
}: {
title: string;
subtitle: string;
count?: number;
actions?: ReactNode;
}) {
return (
<div className="flex items-baseline gap-3 px-[18px] pt-[22px]">
<h1 className="text-[21px] font-bold tracking-[-0.025em] text-foreground">{title}</h1>
{typeof count === "number" && <span className="font-mono text-[13px] text-passive">{count}</span>}
<span className="text-[12.5px] text-passive">{subtitle}</span>
<div className="flex items-center gap-3 px-[18px] pt-[22px]">
<div className="flex min-w-0 items-baseline gap-3">
<h1 className="text-[21px] font-bold tracking-[-0.025em] text-foreground">{title}</h1>
{typeof count === "number" && <span className="font-mono text-[13px] text-passive">{count}</span>}
<span className="text-[12.5px] text-passive">{subtitle}</span>
</div>
{actions ? <div className="ml-auto flex shrink-0 items-center gap-2">{actions}</div> : null}
</div>
);
}

View File

@ -1,8 +1,13 @@
import { useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { useNavigate } from "@tanstack/react-router";
import { Plus } from "lucide-react";
import { type AttentionZone, type WorkspaceSession, attentionZone, workerSessions } from "../types/workspace";
import { useWorkspaceQuery } from "../hooks/useWorkspaceQuery";
import { useWorkspaceQuery, workspaceQueryKey } from "../hooks/useWorkspaceQuery";
import { DashboardSubhead } from "./DashboardSubhead";
import { OrchestratorIcon } from "./icons";
import { NewTaskDialog } from "./NewTaskDialog";
import { spawnOrchestrator } from "../lib/spawn-orchestrator";
import { cn } from "../lib/utils";
type SessionsBoardProps = {
@ -58,10 +63,16 @@ const COLUMNS: Column[] = [
export function SessionsBoard({ projectId }: SessionsBoardProps) {
const navigate = useNavigate();
const queryClient = useQueryClient();
const workspaceQuery = useWorkspaceQuery();
const all = workspaceQuery.data ?? [];
const workspaces = projectId ? all.filter((w) => w.id === projectId) : all;
const sessions = workspaces.flatMap((w) => workerSessions(w.sessions));
const orchestrator = projectId
? workspaces[0]?.sessions.find((session) => session.kind === "orchestrator" && session.status !== "terminated")
: undefined;
const [isNewTaskOpen, setIsNewTaskOpen] = useState(false);
const [isSpawning, setIsSpawning] = useState(false);
const byZone = new Map<AttentionZone, WorkspaceSession[]>();
for (const session of sessions) {
@ -79,9 +90,68 @@ export function SessionsBoard({ projectId }: SessionsBoardProps) {
params: { projectId: session.workspaceId, sessionId: session.id },
});
const openOrchestrator = async () => {
if (!projectId) return;
if (orchestrator) {
void navigate({
to: "/projects/$projectId/sessions/$sessionId",
params: { projectId, sessionId: orchestrator.id },
});
return;
}
setIsSpawning(true);
try {
const sessionId = await spawnOrchestrator(projectId);
await queryClient.invalidateQueries({ queryKey: workspaceQueryKey });
void navigate({
to: "/projects/$projectId/sessions/$sessionId",
params: { projectId, sessionId },
});
} finally {
setIsSpawning(false);
}
};
const handleTaskCreated = async (sessionId: string) => {
if (!projectId) return;
await queryClient.invalidateQueries({ queryKey: workspaceQueryKey });
void navigate({
to: "/projects/$projectId/sessions/$sessionId",
params: { projectId, sessionId },
});
};
const actions = projectId ? (
<>
<button
aria-label="New task"
className="dashboard-app-header__accent-btn"
onClick={() => setIsNewTaskOpen(true)}
type="button"
>
<Plus className="h-3.5 w-3.5" aria-hidden="true" />
New task
</button>
<button
aria-label={orchestrator ? "Orchestrator" : "Spawn Orchestrator"}
className="dashboard-app-header__primary-btn"
disabled={isSpawning}
onClick={() => void openOrchestrator()}
type="button"
>
<OrchestratorIcon className="h-3.5 w-3.5" aria-hidden="true" />
{isSpawning ? "Spawning..." : orchestrator ? "Orchestrator" : "Spawn Orchestrator"}
</button>
</>
) : undefined;
return (
<div className="flex h-full min-h-0 flex-col bg-background text-foreground">
<DashboardSubhead title="Board" subtitle="Live agent sessions flowing from work → review → merge." />
<DashboardSubhead
title="Board"
subtitle="Live agent sessions flowing from work → review → merge."
actions={actions}
/>
<div className="min-h-0 flex-1 overflow-hidden p-[18px]">
{workspaceQuery.isError ? (
@ -139,6 +209,12 @@ export function SessionsBoard({ projectId }: SessionsBoardProps) {
)}
</div>
)}
<NewTaskDialog
open={isNewTaskOpen}
projectId={projectId}
onCreated={(sessionId) => void handleTaskCreated(sessionId)}
onOpenChange={setIsNewTaskOpen}
/>
</div>
);
}

View File

@ -1,6 +1,6 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useNavigate, useParams } from "@tanstack/react-router";
import { GitBranch, LayoutDashboard, PanelRightClose, PanelRightOpen, Plus, Square } from "lucide-react";
import { Bell, GitBranch, LayoutDashboard, PanelRightClose, PanelRightOpen, Plus, Square, Trash2 } from "lucide-react";
import { useState } from "react";
import {
findProjectOrchestrator,
@ -67,6 +67,7 @@ export function ShellTopbar() {
// removed, or data still loading) shows an empty crumb — never the raw
// route slug. "agent-orchestrator" is the root-board crumb only.
const projectId = session?.workspaceId ?? params.projectId;
const isProjectBoardRoute = !isSessionRoute && Boolean(projectId);
const project = projectId ? all.find((workspace) => workspace.id === projectId) : undefined;
const projectLabel = project?.name ?? session?.workspaceName ?? (projectId ? "" : "agent-orchestrator");
const orchestrator = projectId ? findProjectOrchestrator(all, projectId) : undefined;
@ -138,7 +139,7 @@ export function ShellTopbar() {
</div>
{session ? <SessionStatusPill session={session} /> : null}
</div>
) : (
) : isProjectBoardRoute ? null : (
<div className="topbar-project-line">
<span className="dashboard-app-header__project">{projectLabel}</span>
</div>
@ -174,21 +175,24 @@ export function ShellTopbar() {
</button>
</>
) : (
<TopbarNotificationButton />
)}
{/* Kill control sits beside the orchestrator link for active workers
moved here from the inspector's Summary "Danger zone". */}
{!isOrchestrator && session && sessionIsActive(session) ? <TopbarKillButton session={session} /> : null}
{!isOrchestrator && (
<button
aria-label="Open orchestrator"
className="dashboard-app-header__primary-btn"
className="dashboard-app-header__primary-btn dashboard-app-header__primary-btn--compact"
disabled={isSpawning}
onClick={() => void openOrchestrator()}
style={noDragStyle}
type="button"
>
<OrchestratorIcon className="h-3.5 w-3.5" aria-hidden="true" />
{isSpawning ? "Spawning…" : "Open orchestrator"}
{isSpawning ? "Spawning…" : "Orchestrator"}
</button>
)}
{/* Kill control sits beside the orchestrator link for active workers
moved here from the inspector's Summary "Danger zone". */}
{!isOrchestrator && session && sessionIsActive(session) ? <TopbarKillButton session={session} /> : null}
{/* Inspector collapse (worker sessions only — orchestrators have no rail). */}
{!isOrchestrator && (
<button
@ -208,48 +212,6 @@ export function ShellTopbar() {
</button>
)}
</>
) : projectId ? (
<>
<button
aria-label="New task"
className="dashboard-app-header__primary-btn"
onClick={openNewTask}
style={noDragStyle}
type="button"
>
<Plus className="h-3.5 w-3.5" aria-hidden="true" />
New task
</button>
{orchestrator ? (
<button
aria-label="Orchestrator"
className="dashboard-app-header__accent-btn"
onClick={() =>
void navigate({
to: "/projects/$projectId/sessions/$sessionId",
params: { projectId, sessionId: orchestrator.id },
})
}
style={noDragStyle}
type="button"
>
<OrchestratorIcon className="h-3.5 w-3.5" aria-hidden="true" />
Orchestrator
</button>
) : (
<button
aria-label="Spawn Orchestrator"
className="dashboard-app-header__accent-btn"
disabled={isSpawning}
onClick={() => void openOrchestrator()}
style={noDragStyle}
type="button"
>
<OrchestratorIcon className="h-3.5 w-3.5" aria-hidden="true" />
{isSpawning ? "Spawning…" : "Spawn Orchestrator"}
</button>
)}
</>
) : null}
</div>
<NewTaskDialog
@ -262,6 +224,20 @@ export function ShellTopbar() {
);
}
function TopbarNotificationButton() {
return (
<button
aria-label="Notifications"
className="dashboard-app-header__icon-btn dashboard-app-header__icon-btn--quiet"
style={noDragStyle}
title="Notifications"
type="button"
>
<Bell className="h-[15px] w-[15px]" aria-hidden="true" />
</button>
);
}
// Compact kill control for the topbar actions row. Stop a running worker and
// tear down its runtime/workspace. Kill is irreversible from the UI, so the
// button arms a one-step confirmation before firing POST /sessions/{id}/kill,
@ -328,7 +304,8 @@ export function TopbarKillButton({ session }: { session: WorkspaceSession }) {
title="Kill session"
type="button"
>
<Square className="h-[15px] w-[15px]" aria-hidden="true" />
<Trash2 className="h-[13px] w-[13px]" aria-hidden="true" />
Kill
</button>
);
}

View File

@ -71,6 +71,7 @@ const HOVER_ACTION_CLASS =
type SidebarProps = {
daemonStatus: { state: string; message?: string };
underTopbar?: boolean;
workspaceError?: string;
workspaces: WorkspaceSummary[];
onCreateProject: (input: { path: string }) => Promise<void>;
@ -119,7 +120,14 @@ function SessionDot({ session }: { session: WorkspaceSession }) {
// _shell owns open state (synced to the ui-store) and `collapsible="icon"`
// replaces the old hand-rolled CollapsedRail — the same tree restyles itself
// via group-data-[collapsible=icon] into the 48px letter rail.
export function Sidebar({ daemonStatus, workspaceError, workspaces, onCreateProject, onRemoveProject }: SidebarProps) {
export function Sidebar({
daemonStatus,
underTopbar = true,
workspaceError,
workspaces,
onCreateProject,
onRemoveProject,
}: SidebarProps) {
const selection = useSelection();
const eventsConnection = useEventsConnection();
const { state } = useSidebar();
@ -151,8 +159,11 @@ export function Sidebar({ daemonStatus, workspaceError, workspaces, onCreateProj
// The container is fixed-positioned by the shadcn primitive; offset it
// below the 56px shell topbar so the bar runs edge-to-edge above it
// (same override as shadcn's header-above-sidebar block).
<SidebarRoot collapsible="icon" className="border-border top-14 h-[calc(100svh-3.5rem)]!">
<SidebarHeader className="gap-0 p-0 px-[7px] pt-3.5 group-data-[collapsible=icon]:px-1.5">
<SidebarRoot
collapsible="icon"
className={cn("border-border", underTopbar ? "top-14 h-[calc(100svh-3.5rem)]!" : "top-0 h-svh!")}
>
<SidebarHeader className="gap-0 p-0 pl-2.5 pr-[7px] pt-3.5 group-data-[collapsible=icon]:px-1.5">
{/* Brand (project-sidebar__brand); in the icon rail it becomes the old
36px board button wrapping the 22px accent mark. */}
<div className="flex shrink-0 items-center gap-2.5 px-2 pb-[18px] group-data-[collapsible=icon]:justify-center group-data-[collapsible=icon]:px-0 group-data-[collapsible=icon]:pb-2">
@ -192,7 +203,7 @@ export function Sidebar({ daemonStatus, workspaceError, workspaces, onCreateProj
</div>
</SidebarHeader>
<SidebarContent className="gap-0 px-[7px] group-data-[collapsible=icon]:items-center group-data-[collapsible=icon]:px-1.5">
<SidebarContent className="gap-0 pl-2.5 pr-[7px] group-data-[collapsible=icon]:items-center group-data-[collapsible=icon]:px-1.5">
<SidebarGroup className="p-0">
{/* Section label (project-sidebar__nav-label) */}
<div className="flex shrink-0 items-center justify-between px-2 pb-2 group-data-[collapsible=icon]:hidden">
@ -544,11 +555,10 @@ function ProjectItem({
{removeError}
</span>
)}
{/* project-sidebar__sessions: indented under the project parent with a
subtle guide line so worker sessions read as children, not peer
navigation rows. */}
{/* project-sidebar__sessions: indented under the project parent so worker
sessions read as children without adding a persistent guide rail. */}
{expanded && sessions.length > 0 && (
<SidebarMenuSub className="mx-0 ml-[18px] translate-x-0 gap-0 border-l border-border px-0 py-1 pl-2.5">
<SidebarMenuSub className="mx-0 ml-[18px] translate-x-0 gap-0 border-l-0 px-0 py-1 pl-2.5">
{sessions.map((session) => {
const active = selection.activeSessionId === session.id;
return (

View File

@ -1,4 +1,4 @@
import { createFileRoute, Outlet, useNavigate } from "@tanstack/react-router";
import { createFileRoute, Outlet, useNavigate, useRouterState } from "@tanstack/react-router";
import { useQueryClient } from "@tanstack/react-query";
import { type CSSProperties, useCallback, useEffect } from "react";
import { ShellTopbar } from "../components/ShellTopbar";
@ -35,11 +35,13 @@ function errorMessage(error: unknown) {
// instead of Zustand. The daemon-status effect runs here exactly once.
function ShellLayout() {
const navigate = useNavigate();
const pathname = useRouterState({ select: (state) => state.location.pathname });
const queryClient = useQueryClient();
const workspaceQuery = useWorkspaceQuery();
const workspaces = workspaceQuery.data ?? [];
const daemonStatus = useDaemonStatus(queryClient);
const { theme, setTheme, isSidebarOpen, toggleSidebar } = useUiStore();
const isBoardRoute = pathname === "/" || /^\/projects\/[^/]+$/.test(pathname);
const updateWorkspaces = useCallback(
(updater: (workspaces: WorkspaceSummary[]) => WorkspaceSummary[]) => {
@ -129,7 +131,7 @@ function ShellLayout() {
in the layout, not the screens, so the crumb and actions never shift
when the outlet content swaps. */}
<div className="flex h-screen min-h-0 flex-col bg-background text-foreground">
<ShellTopbar />
{!isBoardRoute && <ShellTopbar />}
{/* Controlled by the ui-store so TitlebarNav / Topbar toggles (which
call the store directly) stay in sync. --sidebar-width chains to
the drag-resizable --ao-sidebar-w set on :root by useResizable. */}
@ -141,6 +143,7 @@ function ShellLayout() {
>
<Sidebar
daemonStatus={daemonStatus}
underTopbar={!isBoardRoute}
onCreateProject={createProject}
onRemoveProject={removeProject}
workspaceError={workspaceQuery.isError ? errorMessage(workspaceQuery.error) : undefined}

View File

@ -189,6 +189,44 @@ select {
font: inherit;
}
:is(
button,
a[href],
summary,
select,
[role="button"],
[data-slot="select-trigger"],
[data-slot="select-item"],
[data-slot="dropdown-menu-item"],
[data-sidebar="menu-button"],
[data-sidebar="menu-sub-button"]
):not(:disabled):not([aria-disabled="true"]) {
cursor: pointer;
}
:is(
button,
select,
[role="button"],
[data-slot="select-trigger"],
[data-slot="select-item"],
[data-slot="dropdown-menu-item"],
[data-sidebar="menu-button"],
[data-sidebar="menu-sub-button"]
):disabled,
:is(
button,
select,
[role="button"],
[data-slot="select-trigger"],
[data-slot="select-item"],
[data-slot="dropdown-menu-item"],
[data-sidebar="menu-button"],
[data-sidebar="menu-sub-button"]
)[aria-disabled="true"] {
cursor: not-allowed;
}
.xterm {
height: 100%;
}
@ -345,12 +383,25 @@ body.is-resizing-x [data-slot="sidebar-container"] {
color: var(--fg);
}
.dashboard-app-header__icon-btn--quiet {
width: 30px;
height: 30px;
border-radius: 6px;
color: var(--fg-muted);
}
.dashboard-app-header__kill-btn {
display: grid;
width: 34px;
height: 34px;
place-items: center;
border-radius: 7px;
display: inline-flex;
height: 30px;
align-items: center;
gap: 5px;
border-radius: 6px;
border: 1px solid color-mix(in srgb, var(--red) 58%, transparent);
background: color-mix(in srgb, var(--red) 5%, transparent);
padding: 0 10px;
font-size: 12px;
font-weight: 650;
line-height: 1;
color: var(--red);
transition:
background 0.12s ease,
@ -359,6 +410,7 @@ body.is-resizing-x [data-slot="sidebar-container"] {
.dashboard-app-header__kill-btn:hover {
background: color-mix(in srgb, var(--red) 12%, transparent);
border-color: color-mix(in srgb, var(--red) 75%, transparent);
}
.dashboard-app-header__kill-confirm {
@ -436,6 +488,13 @@ body.is-resizing-x [data-slot="sidebar-container"] {
opacity: 0.6;
}
.dashboard-app-header__primary-btn--compact {
height: 32px;
border-radius: 7px;
padding: 0 14px;
font-size: 13px;
}
.dashboard-app-header__accent-btn {
display: inline-flex;
height: 34px;