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:
parent
2cb20c23e4
commit
a8a3056a6b
|
|
@ -1,11 +1,26 @@
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
// The board subhead (mc-board .dashboard-main__subhead): a 21px bold title with
|
// The board subhead (mc-board .dashboard-main__subhead): a 21px bold title with
|
||||||
// a muted one-line subtitle, optionally a trailing count.
|
// 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 (
|
return (
|
||||||
<div className="flex items-baseline gap-3 px-[18px] pt-[22px]">
|
<div className="flex items-center gap-3 px-[18px] pt-[22px]">
|
||||||
<h1 className="text-[21px] font-bold tracking-[-0.025em] text-foreground">{title}</h1>
|
<div className="flex min-w-0 items-baseline gap-3">
|
||||||
{typeof count === "number" && <span className="font-mono text-[13px] text-passive">{count}</span>}
|
<h1 className="text-[21px] font-bold tracking-[-0.025em] text-foreground">{title}</h1>
|
||||||
<span className="text-[12.5px] text-passive">{subtitle}</span>
|
{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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,13 @@
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { useNavigate } from "@tanstack/react-router";
|
import { useNavigate } from "@tanstack/react-router";
|
||||||
|
import { Plus } from "lucide-react";
|
||||||
import { type AttentionZone, type WorkspaceSession, attentionZone, workerSessions } from "../types/workspace";
|
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 { DashboardSubhead } from "./DashboardSubhead";
|
||||||
|
import { OrchestratorIcon } from "./icons";
|
||||||
|
import { NewTaskDialog } from "./NewTaskDialog";
|
||||||
|
import { spawnOrchestrator } from "../lib/spawn-orchestrator";
|
||||||
import { cn } from "../lib/utils";
|
import { cn } from "../lib/utils";
|
||||||
|
|
||||||
type SessionsBoardProps = {
|
type SessionsBoardProps = {
|
||||||
|
|
@ -58,10 +63,16 @@ const COLUMNS: Column[] = [
|
||||||
|
|
||||||
export function SessionsBoard({ projectId }: SessionsBoardProps) {
|
export function SessionsBoard({ projectId }: SessionsBoardProps) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const workspaceQuery = useWorkspaceQuery();
|
const workspaceQuery = useWorkspaceQuery();
|
||||||
const all = workspaceQuery.data ?? [];
|
const all = workspaceQuery.data ?? [];
|
||||||
const workspaces = projectId ? all.filter((w) => w.id === projectId) : all;
|
const workspaces = projectId ? all.filter((w) => w.id === projectId) : all;
|
||||||
const sessions = workspaces.flatMap((w) => workerSessions(w.sessions));
|
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[]>();
|
const byZone = new Map<AttentionZone, WorkspaceSession[]>();
|
||||||
for (const session of sessions) {
|
for (const session of sessions) {
|
||||||
|
|
@ -79,9 +90,68 @@ export function SessionsBoard({ projectId }: SessionsBoardProps) {
|
||||||
params: { projectId: session.workspaceId, sessionId: session.id },
|
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 (
|
return (
|
||||||
<div className="flex h-full min-h-0 flex-col bg-background text-foreground">
|
<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]">
|
<div className="min-h-0 flex-1 overflow-hidden p-[18px]">
|
||||||
{workspaceQuery.isError ? (
|
{workspaceQuery.isError ? (
|
||||||
|
|
@ -139,6 +209,12 @@ export function SessionsBoard({ projectId }: SessionsBoardProps) {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<NewTaskDialog
|
||||||
|
open={isNewTaskOpen}
|
||||||
|
projectId={projectId}
|
||||||
|
onCreated={(sessionId) => void handleTaskCreated(sessionId)}
|
||||||
|
onOpenChange={setIsNewTaskOpen}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useNavigate, useParams } from "@tanstack/react-router";
|
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 { useState } from "react";
|
||||||
import {
|
import {
|
||||||
findProjectOrchestrator,
|
findProjectOrchestrator,
|
||||||
|
|
@ -67,6 +67,7 @@ export function ShellTopbar() {
|
||||||
// removed, or data still loading) shows an empty crumb — never the raw
|
// removed, or data still loading) shows an empty crumb — never the raw
|
||||||
// route slug. "agent-orchestrator" is the root-board crumb only.
|
// route slug. "agent-orchestrator" is the root-board crumb only.
|
||||||
const projectId = session?.workspaceId ?? params.projectId;
|
const projectId = session?.workspaceId ?? params.projectId;
|
||||||
|
const isProjectBoardRoute = !isSessionRoute && Boolean(projectId);
|
||||||
const project = projectId ? all.find((workspace) => workspace.id === projectId) : undefined;
|
const project = projectId ? all.find((workspace) => workspace.id === projectId) : undefined;
|
||||||
const projectLabel = project?.name ?? session?.workspaceName ?? (projectId ? "" : "agent-orchestrator");
|
const projectLabel = project?.name ?? session?.workspaceName ?? (projectId ? "" : "agent-orchestrator");
|
||||||
const orchestrator = projectId ? findProjectOrchestrator(all, projectId) : undefined;
|
const orchestrator = projectId ? findProjectOrchestrator(all, projectId) : undefined;
|
||||||
|
|
@ -138,7 +139,7 @@ export function ShellTopbar() {
|
||||||
</div>
|
</div>
|
||||||
{session ? <SessionStatusPill session={session} /> : null}
|
{session ? <SessionStatusPill session={session} /> : null}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : isProjectBoardRoute ? null : (
|
||||||
<div className="topbar-project-line">
|
<div className="topbar-project-line">
|
||||||
<span className="dashboard-app-header__project">{projectLabel}</span>
|
<span className="dashboard-app-header__project">{projectLabel}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -174,21 +175,24 @@ export function ShellTopbar() {
|
||||||
</button>
|
</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
|
<button
|
||||||
aria-label="Open orchestrator"
|
aria-label="Open orchestrator"
|
||||||
className="dashboard-app-header__primary-btn"
|
className="dashboard-app-header__primary-btn dashboard-app-header__primary-btn--compact"
|
||||||
disabled={isSpawning}
|
disabled={isSpawning}
|
||||||
onClick={() => void openOrchestrator()}
|
onClick={() => void openOrchestrator()}
|
||||||
style={noDragStyle}
|
style={noDragStyle}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<OrchestratorIcon className="h-3.5 w-3.5" aria-hidden="true" />
|
<OrchestratorIcon className="h-3.5 w-3.5" aria-hidden="true" />
|
||||||
{isSpawning ? "Spawning…" : "Open orchestrator"}
|
{isSpawning ? "Spawning…" : "Orchestrator"}
|
||||||
</button>
|
</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). */}
|
{/* Inspector collapse (worker sessions only — orchestrators have no rail). */}
|
||||||
{!isOrchestrator && (
|
{!isOrchestrator && (
|
||||||
<button
|
<button
|
||||||
|
|
@ -208,48 +212,6 @@ export function ShellTopbar() {
|
||||||
</button>
|
</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}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<NewTaskDialog
|
<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
|
// 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
|
// 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,
|
// 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"
|
title="Kill session"
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<Square className="h-[15px] w-[15px]" aria-hidden="true" />
|
<Trash2 className="h-[13px] w-[13px]" aria-hidden="true" />
|
||||||
|
Kill
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,7 @@ const HOVER_ACTION_CLASS =
|
||||||
|
|
||||||
type SidebarProps = {
|
type SidebarProps = {
|
||||||
daemonStatus: { state: string; message?: string };
|
daemonStatus: { state: string; message?: string };
|
||||||
|
underTopbar?: boolean;
|
||||||
workspaceError?: string;
|
workspaceError?: string;
|
||||||
workspaces: WorkspaceSummary[];
|
workspaces: WorkspaceSummary[];
|
||||||
onCreateProject: (input: { path: string }) => Promise<void>;
|
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"`
|
// _shell owns open state (synced to the ui-store) and `collapsible="icon"`
|
||||||
// replaces the old hand-rolled CollapsedRail — the same tree restyles itself
|
// replaces the old hand-rolled CollapsedRail — the same tree restyles itself
|
||||||
// via group-data-[collapsible=icon] into the 48px letter rail.
|
// 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 selection = useSelection();
|
||||||
const eventsConnection = useEventsConnection();
|
const eventsConnection = useEventsConnection();
|
||||||
const { state } = useSidebar();
|
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
|
// 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
|
// below the 56px shell topbar so the bar runs edge-to-edge above it
|
||||||
// (same override as shadcn's header-above-sidebar block).
|
// (same override as shadcn's header-above-sidebar block).
|
||||||
<SidebarRoot collapsible="icon" className="border-border top-14 h-[calc(100svh-3.5rem)]!">
|
<SidebarRoot
|
||||||
<SidebarHeader className="gap-0 p-0 px-[7px] pt-3.5 group-data-[collapsible=icon]:px-1.5">
|
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
|
{/* Brand (project-sidebar__brand); in the icon rail it becomes the old
|
||||||
36px board button wrapping the 22px accent mark. */}
|
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">
|
<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>
|
</div>
|
||||||
</SidebarHeader>
|
</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">
|
<SidebarGroup className="p-0">
|
||||||
{/* Section label (project-sidebar__nav-label) */}
|
{/* Section label (project-sidebar__nav-label) */}
|
||||||
<div className="flex shrink-0 items-center justify-between px-2 pb-2 group-data-[collapsible=icon]:hidden">
|
<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}
|
{removeError}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{/* project-sidebar__sessions: indented under the project parent with a
|
{/* project-sidebar__sessions: indented under the project parent so worker
|
||||||
subtle guide line so worker sessions read as children, not peer
|
sessions read as children without adding a persistent guide rail. */}
|
||||||
navigation rows. */}
|
|
||||||
{expanded && sessions.length > 0 && (
|
{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) => {
|
{sessions.map((session) => {
|
||||||
const active = selection.activeSessionId === session.id;
|
const active = selection.activeSessionId === session.id;
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -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 { useQueryClient } from "@tanstack/react-query";
|
||||||
import { type CSSProperties, useCallback, useEffect } from "react";
|
import { type CSSProperties, useCallback, useEffect } from "react";
|
||||||
import { ShellTopbar } from "../components/ShellTopbar";
|
import { ShellTopbar } from "../components/ShellTopbar";
|
||||||
|
|
@ -35,11 +35,13 @@ function errorMessage(error: unknown) {
|
||||||
// instead of Zustand. The daemon-status effect runs here exactly once.
|
// instead of Zustand. The daemon-status effect runs here exactly once.
|
||||||
function ShellLayout() {
|
function ShellLayout() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const pathname = useRouterState({ select: (state) => state.location.pathname });
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const workspaceQuery = useWorkspaceQuery();
|
const workspaceQuery = useWorkspaceQuery();
|
||||||
const workspaces = workspaceQuery.data ?? [];
|
const workspaces = workspaceQuery.data ?? [];
|
||||||
const daemonStatus = useDaemonStatus(queryClient);
|
const daemonStatus = useDaemonStatus(queryClient);
|
||||||
const { theme, setTheme, isSidebarOpen, toggleSidebar } = useUiStore();
|
const { theme, setTheme, isSidebarOpen, toggleSidebar } = useUiStore();
|
||||||
|
const isBoardRoute = pathname === "/" || /^\/projects\/[^/]+$/.test(pathname);
|
||||||
|
|
||||||
const updateWorkspaces = useCallback(
|
const updateWorkspaces = useCallback(
|
||||||
(updater: (workspaces: WorkspaceSummary[]) => WorkspaceSummary[]) => {
|
(updater: (workspaces: WorkspaceSummary[]) => WorkspaceSummary[]) => {
|
||||||
|
|
@ -129,7 +131,7 @@ function ShellLayout() {
|
||||||
in the layout, not the screens, so the crumb and actions never shift
|
in the layout, not the screens, so the crumb and actions never shift
|
||||||
when the outlet content swaps. */}
|
when the outlet content swaps. */}
|
||||||
<div className="flex h-screen min-h-0 flex-col bg-background text-foreground">
|
<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
|
{/* Controlled by the ui-store so TitlebarNav / Topbar toggles (which
|
||||||
call the store directly) stay in sync. --sidebar-width chains to
|
call the store directly) stay in sync. --sidebar-width chains to
|
||||||
the drag-resizable --ao-sidebar-w set on :root by useResizable. */}
|
the drag-resizable --ao-sidebar-w set on :root by useResizable. */}
|
||||||
|
|
@ -141,6 +143,7 @@ function ShellLayout() {
|
||||||
>
|
>
|
||||||
<Sidebar
|
<Sidebar
|
||||||
daemonStatus={daemonStatus}
|
daemonStatus={daemonStatus}
|
||||||
|
underTopbar={!isBoardRoute}
|
||||||
onCreateProject={createProject}
|
onCreateProject={createProject}
|
||||||
onRemoveProject={removeProject}
|
onRemoveProject={removeProject}
|
||||||
workspaceError={workspaceQuery.isError ? errorMessage(workspaceQuery.error) : undefined}
|
workspaceError={workspaceQuery.isError ? errorMessage(workspaceQuery.error) : undefined}
|
||||||
|
|
|
||||||
|
|
@ -189,6 +189,44 @@ select {
|
||||||
font: inherit;
|
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 {
|
.xterm {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
@ -345,12 +383,25 @@ body.is-resizing-x [data-slot="sidebar-container"] {
|
||||||
color: var(--fg);
|
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 {
|
.dashboard-app-header__kill-btn {
|
||||||
display: grid;
|
display: inline-flex;
|
||||||
width: 34px;
|
height: 30px;
|
||||||
height: 34px;
|
align-items: center;
|
||||||
place-items: center;
|
gap: 5px;
|
||||||
border-radius: 7px;
|
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);
|
color: var(--red);
|
||||||
transition:
|
transition:
|
||||||
background 0.12s ease,
|
background 0.12s ease,
|
||||||
|
|
@ -359,6 +410,7 @@ body.is-resizing-x [data-slot="sidebar-container"] {
|
||||||
|
|
||||||
.dashboard-app-header__kill-btn:hover {
|
.dashboard-app-header__kill-btn:hover {
|
||||||
background: color-mix(in srgb, var(--red) 12%, transparent);
|
background: color-mix(in srgb, var(--red) 12%, transparent);
|
||||||
|
border-color: color-mix(in srgb, var(--red) 75%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-app-header__kill-confirm {
|
.dashboard-app-header__kill-confirm {
|
||||||
|
|
@ -436,6 +488,13 @@ body.is-resizing-x [data-slot="sidebar-container"] {
|
||||||
opacity: 0.6;
|
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 {
|
.dashboard-app-header__accent-btn {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
height: 34px;
|
height: 34px;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue