feat(frontend): improve orchestrator board workflows

Improve the frontend board and session workflow presentation.

- refine project sidebar hierarchy and child session display
- clean up kanban task cards and status labels
- add direct task creation from project boards

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

View File

@ -0,0 +1,60 @@
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { NewTaskDialog } from "./NewTaskDialog";
const { postMock } = vi.hoisted(() => ({
postMock: vi.fn(),
}));
vi.mock("../lib/api-client", () => ({
apiClient: {
POST: postMock,
},
apiErrorMessage: (error: unknown, fallback = "Request failed") => {
if (typeof error === "object" && error !== null && "message" in error) {
return String((error as { message: unknown }).message);
}
return fallback;
},
}));
beforeEach(() => {
postMock.mockReset();
postMock.mockResolvedValue({ data: { session: { id: "task-1" } }, error: undefined });
});
describe("NewTaskDialog", () => {
it("starts a worker task with the entered title and brief", async () => {
const onCreated = vi.fn();
const onOpenChange = vi.fn();
render(<NewTaskDialog open projectId="proj-1" onCreated={onCreated} onOpenChange={onOpenChange} />);
await userEvent.type(screen.getByLabelText("Title"), "Fix fallback renderer");
await userEvent.type(screen.getByLabelText("Brief"), "Restore the fallback renderer after WebGL init fails.");
await userEvent.click(screen.getByRole("button", { name: "Start task" }));
await waitFor(() => expect(postMock).toHaveBeenCalledTimes(1));
expect(postMock).toHaveBeenCalledWith("/api/v1/sessions", {
body: {
projectId: "proj-1",
kind: "worker",
harness: "codex",
issueId: "Fix fallback renderer",
prompt: "Restore the fallback renderer after WebGL init fails.",
branch: undefined,
},
});
expect(onCreated).toHaveBeenCalledWith("task-1");
expect(onOpenChange).toHaveBeenCalledWith(false);
});
it("requires both title and brief", async () => {
render(<NewTaskDialog open projectId="proj-1" onCreated={vi.fn()} onOpenChange={vi.fn()} />);
await userEvent.click(screen.getByRole("button", { name: "Start task" }));
expect(await screen.findByText("Title and brief are required.")).toBeInTheDocument();
expect(postMock).not.toHaveBeenCalled();
});
});

View File

@ -0,0 +1,183 @@
import * as Dialog from "@radix-ui/react-dialog";
import { Loader2, X } from "lucide-react";
import { type FormEvent, useEffect, useId, useState } from "react";
import { Button } from "./ui/button";
import { Input } from "./ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
import { apiClient, apiErrorMessage } from "../lib/api-client";
import type { AgentProvider } from "../types/workspace";
type NewTaskDialogProps = {
open: boolean;
projectId?: string;
onCreated: (sessionId: string) => void;
onOpenChange: (open: boolean) => void;
};
const AGENTS: Array<{ value: AgentProvider; label: string }> = [
{ value: "codex", label: "Codex" },
{ value: "claude-code", label: "Claude Code" },
{ value: "opencode", label: "OpenCode" },
{ value: "aider", label: "Aider" },
];
export function NewTaskDialog({ open, projectId, onCreated, onOpenChange }: NewTaskDialogProps) {
const titleId = useId();
const promptId = useId();
const branchId = useId();
const [title, setTitle] = useState("");
const [prompt, setPrompt] = useState("");
const [branch, setBranch] = useState("");
const [agent, setAgent] = useState<AgentProvider>("codex");
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | undefined>();
useEffect(() => {
if (!open) {
setTitle("");
setPrompt("");
setBranch("");
setAgent("codex");
setError(undefined);
setIsSubmitting(false);
}
}, [open]);
const submit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!projectId || isSubmitting) return;
const cleanTitle = title.trim();
const cleanPrompt = prompt.trim();
const cleanBranch = branch.trim();
if (!cleanTitle || !cleanPrompt) {
setError("Title and brief are required.");
return;
}
setIsSubmitting(true);
setError(undefined);
try {
const { data, error: apiError } = await apiClient.POST("/api/v1/sessions", {
body: {
projectId,
kind: "worker",
harness: agent,
issueId: cleanTitle,
prompt: cleanPrompt,
branch: cleanBranch || undefined,
},
});
if (apiError) throw new Error(apiErrorMessage(apiError, "Unable to start task"));
if (!data?.session?.id) throw new Error("Task creation returned no session");
onCreated(data.session.id);
onOpenChange(false);
} catch (err) {
setError(err instanceof Error ? err.message : "Unable to start task");
} finally {
setIsSubmitting(false);
}
};
return (
<Dialog.Root open={open} onOpenChange={onOpenChange}>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 z-50 bg-black/55 data-[state=open]:animate-overlay-in" />
<Dialog.Content className="fixed left-1/2 top-1/2 z-50 w-[min(560px,calc(100vw-32px))] -translate-x-1/2 -translate-y-1/2 rounded-lg border border-border bg-popover p-0 text-popover-foreground shadow-xl data-[state=open]:animate-modal-in">
<div className="flex items-start justify-between gap-4 border-b border-border px-5 py-4">
<div className="min-w-0">
<Dialog.Title className="text-[15px] font-semibold text-foreground">New task</Dialog.Title>
<Dialog.Description className="mt-1 text-[12px] text-muted-foreground">
Start a worker directly from this project.
</Dialog.Description>
</div>
<Dialog.Close asChild>
<button
type="button"
className="grid size-7 shrink-0 place-items-center rounded-md text-muted-foreground transition hover:bg-surface hover:text-foreground"
aria-label="Close new task dialog"
>
<X className="size-4" aria-hidden="true" />
</button>
</Dialog.Close>
</div>
<form onSubmit={submit} className="space-y-4 px-5 py-4">
<div className="space-y-1.5">
<label className="text-[12px] font-medium text-muted-foreground" htmlFor={titleId}>
Title
</label>
<Input
id={titleId}
autoFocus
placeholder="Fix WebGL fallback renderer"
value={title}
onChange={(event) => setTitle(event.target.value)}
/>
</div>
<div className="space-y-1.5">
<label className="text-[12px] font-medium text-muted-foreground" htmlFor={promptId}>
Brief
</label>
<textarea
id={promptId}
className="min-h-[112px] w-full resize-y rounded-md border border-border bg-transparent px-3 py-2 text-[13px] leading-relaxed text-foreground outline-none transition placeholder:text-passive focus-visible:border-accent focus-visible:ring-2 focus-visible:ring-accent-weak"
placeholder="Describe the change, constraints, and expected verification."
value={prompt}
onChange={(event) => setPrompt(event.target.value)}
/>
</div>
<div className="grid gap-3 sm:grid-cols-[1fr_1fr]">
<div className="space-y-1.5">
<label className="text-[12px] font-medium text-muted-foreground">Agent</label>
<Select value={agent} onValueChange={(value) => setAgent(value as AgentProvider)}>
<SelectTrigger className="h-8 w-full text-[13px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{AGENTS.map((entry) => (
<SelectItem key={entry.value} value={entry.value}>
{entry.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<label className="text-[12px] font-medium text-muted-foreground" htmlFor={branchId}>
Branch
</label>
<Input
id={branchId}
placeholder="optional"
value={branch}
onChange={(event) => setBranch(event.target.value)}
/>
</div>
</div>
{error && (
<div className="rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-[12px] text-destructive">
{error}
</div>
)}
<div className="flex items-center justify-end gap-2 pt-1">
<Dialog.Close asChild>
<Button type="button" variant="ghost" disabled={isSubmitting}>
Cancel
</Button>
</Dialog.Close>
<Button type="submit" disabled={isSubmitting || !projectId}>
{isSubmitting ? <Loader2 className="size-3.5 animate-spin" aria-hidden="true" /> : null}
{isSubmitting ? "Starting..." : "Start task"}
</Button>
</div>
</form>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}

View File

@ -524,7 +524,7 @@ function activityDetail(status: SessionStatus): string | null {
case "idle": case "idle":
return "Session idle"; return "Session idle";
case "needs_input": case "needs_input":
return "Waiting for input"; return "Waiting for your input";
case "working": case "working":
return null; return null;
default: default:
@ -537,7 +537,7 @@ const STATUS_PILL: Record<
{ label: string; tone: string; breathe: boolean } { label: string; tone: string; breathe: boolean }
> = { > = {
working: { label: "Working", tone: "var(--orange)", breathe: true }, working: { label: "Working", tone: "var(--orange)", breathe: true },
needs_you: { label: "Needs input", tone: "var(--amber)", breathe: false }, needs_you: { label: "Input needed", tone: "var(--amber)", breathe: false },
ci_failed: { label: "CI failed", tone: "var(--red)", breathe: false }, ci_failed: { label: "CI failed", tone: "var(--red)", breathe: false },
mergeable: { label: "Ready", tone: "var(--green)", breathe: false }, mergeable: { label: "Ready", tone: "var(--green)", breathe: false },
done: { label: "Done", tone: "var(--fg-muted)", breathe: false }, done: { label: "Done", tone: "var(--fg-muted)", breathe: false },

View File

@ -1,13 +1,6 @@
import { useState } from "react"; import { useState } from "react";
import { useNavigate } from "@tanstack/react-router"; import { useNavigate } from "@tanstack/react-router";
import { import { type AttentionZone, type WorkspaceSession, attentionZone, workerSessions } from "../types/workspace";
type AttentionZone,
type WorkerDisplayStatus,
type WorkspaceSession,
attentionZone,
workerDisplayStatus,
workerSessions,
} from "../types/workspace";
import { useWorkspaceQuery } from "../hooks/useWorkspaceQuery"; import { useWorkspaceQuery } from "../hooks/useWorkspaceQuery";
import { DashboardSubhead } from "./DashboardSubhead"; import { DashboardSubhead } from "./DashboardSubhead";
import { cn } from "../lib/utils"; import { cn } from "../lib/utils";
@ -63,14 +56,6 @@ const COLUMNS: Column[] = [
}, },
]; ];
const BADGE: Record<WorkerDisplayStatus, { label: string; className: string }> = {
working: { label: "Working", className: "text-working" },
needs_you: { label: "Needs input", className: "text-warning" },
ci_failed: { label: "CI failed", className: "text-error" },
mergeable: { label: "Ready", className: "text-success" },
done: { label: "Done", className: "text-passive" },
};
export function SessionsBoard({ projectId }: SessionsBoardProps) { export function SessionsBoard({ projectId }: SessionsBoardProps) {
const navigate = useNavigate(); const navigate = useNavigate();
const workspaceQuery = useWorkspaceQuery(); const workspaceQuery = useWorkspaceQuery();
@ -195,8 +180,9 @@ function ZoneColumn({
} }
function SessionCard({ session, onOpen }: { session: WorkspaceSession; onOpen: () => void }) { function SessionCard({ session, onOpen }: { session: WorkspaceSession; onOpen: () => void }) {
const badge = BADGE[workerDisplayStatus(session)]; const badge = sessionBadge(session);
const branch = session.branch || `session/${session.id}`; const branch = session.branch || "";
const showBranch = branch !== "" && !sameLabel(branch, session.title) && !sameLabel(branch, session.id);
return ( return (
<button <button
className="w-full rounded-[7px] border border-border bg-surface text-left transition-colors hover:border-border-strong" className="w-full rounded-[7px] border border-border bg-surface text-left transition-colors hover:border-border-strong"
@ -208,20 +194,70 @@ function SessionCard({ session, onOpen }: { session: WorkspaceSession; onOpen: (
<span className={cn("h-[7px] w-[7px] rounded-full bg-current")} /> <span className={cn("h-[7px] w-[7px] rounded-full bg-current")} />
{badge.label} {badge.label}
</span> </span>
<span className="ml-auto shrink-0 font-mono text-[10.5px] tracking-[0.04em] text-passive">{session.id}</span> <span className="ml-auto shrink-0 font-mono text-[10.5px] tracking-[0.04em] text-passive">
{agentLabel(session.provider)}
</span>
</div> </div>
<div <div
className={cn( className={cn(
"px-[13px] pb-2.5 text-[13px] font-medium leading-[1.42] tracking-[-0.01em] text-foreground", "px-[13px] text-[13px] font-medium leading-[1.42] tracking-[-0.01em] text-foreground",
showBranch ? "pb-2" : "pb-3",
"line-clamp-2 overflow-hidden", "line-clamp-2 overflow-hidden",
)} )}
> >
{session.title} {session.title}
</div> </div>
<div className="px-[13px] pb-2.5 font-mono text-[10.5px] text-passive">{branch}</div> {showBranch && <div className="px-[13px] pb-2.5 font-mono text-[10.5px] text-passive">{branch}</div>}
<div className="border-t border-border px-[13px] py-2 font-mono text-[10.5px] text-passive"> <div className="border-t border-border px-[13px] py-2 font-mono text-[10.5px] text-passive">
{session.pullRequest ? `PR #${session.pullRequest.number} · ${session.pullRequest.state}` : "no PR yet"} {session.pullRequest ? `PR #${session.pullRequest.number} · ${session.pullRequest.state}` : "no PR yet"}
</div> </div>
</button> </button>
); );
} }
function sameLabel(a: string, b: string): boolean {
const normalize = (value: string) =>
value
.toLowerCase()
.replace(/^(feat|fix|chore|refactor|session)\//, "")
.replace(/[^a-z0-9]+/g, "");
return normalize(a) === normalize(b);
}
function agentLabel(provider: WorkspaceSession["provider"]): string {
switch (provider) {
case "claude-code":
return "Claude";
case "opencode":
return "OpenCode";
default:
return provider;
}
}
function sessionBadge(session: WorkspaceSession): { label: string; className: string } {
switch (session.status) {
case "needs_input":
return { label: "Input needed", className: "text-warning" };
case "ci_failed":
return { label: "CI failed", className: "text-error" };
case "changes_requested":
return { label: "Changes requested", className: "text-warning" };
case "review_pending":
return { label: "Review pending", className: "text-muted-foreground" };
case "draft":
return { label: "Draft PR", className: "text-muted-foreground" };
case "pr_open":
return { label: "PR open", className: "text-muted-foreground" };
case "approved":
return { label: "Approved", className: "text-success" };
case "mergeable":
return { label: "Ready", className: "text-success" };
case "merged":
return { label: "Merged", className: "text-passive" };
case "terminated":
return { label: "Terminated", className: "text-passive" };
default:
return { label: "Working", className: "text-working" };
}
}

View File

@ -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, Square } from "lucide-react"; import { GitBranch, LayoutDashboard, PanelRightClose, PanelRightOpen, Plus, Square } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { import {
findProjectOrchestrator, findProjectOrchestrator,
@ -16,6 +16,7 @@ import { spawnOrchestrator } from "../lib/spawn-orchestrator";
import { captureRendererEvent, captureRendererException } from "../lib/telemetry"; import { captureRendererEvent, captureRendererException } from "../lib/telemetry";
import { useUiStore } from "../stores/ui-store"; import { useUiStore } from "../stores/ui-store";
import { OrchestratorIcon } from "./icons"; import { OrchestratorIcon } from "./icons";
import { NewTaskDialog } from "./NewTaskDialog";
import { cn } from "../lib/utils"; import { cn } from "../lib/utils";
const isMac = typeof navigator !== "undefined" && /Mac|iPod|iPhone|iPad/.test(navigator.userAgent); const isMac = typeof navigator !== "undefined" && /Mac|iPod|iPhone|iPad/.test(navigator.userAgent);
@ -52,6 +53,7 @@ export function ShellTopbar() {
const isInspectorOpen = useUiStore((state) => state.isInspectorOpen); const isInspectorOpen = useUiStore((state) => state.isInspectorOpen);
const toggleInspector = useUiStore((state) => state.toggleInspector); const toggleInspector = useUiStore((state) => state.toggleInspector);
const [isSpawning, setIsSpawning] = useState(false); const [isSpawning, setIsSpawning] = useState(false);
const [isNewTaskOpen, setIsNewTaskOpen] = useState(false);
const all = useWorkspaceQuery().data ?? []; const all = useWorkspaceQuery().data ?? [];
const session = params.sessionId const session = params.sessionId
@ -72,6 +74,20 @@ export function ShellTopbar() {
const openBoard = () => const openBoard = () =>
projectId ? void navigate({ to: "/projects/$projectId", params: { projectId } }) : void navigate({ to: "/" }); projectId ? void navigate({ to: "/projects/$projectId", params: { projectId } }) : void navigate({ to: "/" });
const openNewTask = () => {
if (!projectId) return;
setIsNewTaskOpen(true);
};
const handleTaskCreated = async (sessionId: string) => {
if (!projectId) return;
await queryClient.invalidateQueries({ queryKey: workspaceQueryKey });
void navigate({
to: "/projects/$projectId/sessions/$sessionId",
params: { projectId, sessionId },
});
};
const openOrchestrator = async () => { const openOrchestrator = async () => {
if (!projectId) return; if (!projectId) return;
void captureRendererEvent("ao.renderer.orchestrator_open_requested", { project_id: projectId }); void captureRendererEvent("ao.renderer.orchestrator_open_requested", { project_id: projectId });
@ -135,16 +151,28 @@ export function ShellTopbar() {
{isSessionRoute ? ( {isSessionRoute ? (
<> <>
{isOrchestrator ? ( {isOrchestrator ? (
<button <>
aria-label="Open Kanban" <button
className="dashboard-app-header__primary-btn" aria-label="New task"
onClick={openBoard} className="dashboard-app-header__primary-btn"
style={noDragStyle} onClick={openNewTask}
type="button" style={noDragStyle}
> type="button"
<LayoutDashboard className="h-3.5 w-3.5" aria-hidden="true" /> >
Open Kanban <Plus className="h-3.5 w-3.5" aria-hidden="true" />
</button> New task
</button>
<button
aria-label="Open Kanban"
className="dashboard-app-header__accent-btn"
onClick={openBoard}
style={noDragStyle}
type="button"
>
<LayoutDashboard className="h-3.5 w-3.5" aria-hidden="true" />
Kanban
</button>
</>
) : ( ) : (
<button <button
aria-label="Open orchestrator" aria-label="Open orchestrator"
@ -181,37 +209,55 @@ export function ShellTopbar() {
)} )}
</> </>
) : projectId ? ( ) : projectId ? (
orchestrator ? ( <>
<button <button
aria-label="Orchestrator" aria-label="New task"
className="dashboard-app-header__primary-btn" className="dashboard-app-header__primary-btn"
onClick={() => onClick={openNewTask}
void navigate({
to: "/projects/$projectId/sessions/$sessionId",
params: { projectId, sessionId: orchestrator.id },
})
}
style={noDragStyle} style={noDragStyle}
type="button" type="button"
> >
<OrchestratorIcon className="h-3.5 w-3.5" aria-hidden="true" /> <Plus className="h-3.5 w-3.5" aria-hidden="true" />
Orchestrator New task
</button> </button>
) : ( {orchestrator ? (
<button <button
aria-label="Spawn Orchestrator" aria-label="Orchestrator"
className="dashboard-app-header__primary-btn" className="dashboard-app-header__accent-btn"
disabled={isSpawning} onClick={() =>
onClick={() => void openOrchestrator()} void navigate({
style={noDragStyle} to: "/projects/$projectId/sessions/$sessionId",
type="button" params: { projectId, sessionId: orchestrator.id },
> })
<OrchestratorIcon className="h-3.5 w-3.5" aria-hidden="true" /> }
{isSpawning ? "Spawning…" : "Spawn Orchestrator"} style={noDragStyle}
</button> 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
open={isNewTaskOpen}
projectId={projectId}
onCreated={(sessionId) => void handleTaskCreated(sessionId)}
onOpenChange={setIsNewTaskOpen}
/>
</header> </header>
); );
} }

View File

@ -46,7 +46,6 @@ import {
SidebarMenuButton, SidebarMenuButton,
SidebarMenuItem, SidebarMenuItem,
SidebarMenuSub, SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem, SidebarMenuSubItem,
SidebarTrigger, SidebarTrigger,
useSidebar, useSidebar,
@ -97,17 +96,20 @@ function useSelection() {
}; };
} }
// agent-orchestrator's SessionDot: 6px dot, neutral grey at rest, orange + // 6px session dot: mirrors the board's status language so the sidebar can be
// breathe while the agent is working. Other attention zones stay neutral here // scanned without opening the project board.
// (the board carries the richer colour coding).
function SessionDot({ session }: { session: WorkspaceSession }) { function SessionDot({ session }: { session: WorkspaceSession }) {
const working = attentionZone(session) === "working"; const zone = attentionZone(session);
return ( return (
<span <span
aria-hidden="true" aria-hidden="true"
className={cn( className={cn(
"mt-px h-1.5 w-1.5 shrink-0 rounded-full", "mt-px h-1.5 w-1.5 shrink-0 rounded-full",
working ? "animate-status-pulse bg-working" : "bg-passive", zone === "working" && "animate-status-pulse bg-working",
zone === "action" && (session.status === "ci_failed" ? "bg-error" : "bg-warning"),
zone === "pending" && "bg-passive",
zone === "merge" && "bg-success",
zone === "done" && "bg-passive",
)} )}
/> />
); );
@ -182,7 +184,7 @@ export function Sidebar({ daemonStatus, workspaceError, workspaces, onCreateProj
{!isMac && ( {!isMac && (
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<SidebarTrigger className="shrink-0 rounded-md text-passive hover:bg-interactive-hover hover:text-foreground group-data-[collapsible=icon]:hidden [&_svg]:size-[15px]" /> <SidebarTrigger className="size-[18px] shrink-0 rounded-[4px] p-0 text-passive hover:bg-interactive-hover hover:text-foreground group-data-[collapsible=icon]:hidden [&_svg]:size-[15px]" />
</TooltipTrigger> </TooltipTrigger>
<TooltipContent>Collapse sidebar · B</TooltipContent> <TooltipContent>Collapse sidebar · B</TooltipContent>
</Tooltip> </Tooltip>
@ -448,9 +450,10 @@ function ProjectItem({
onClick={onProjectClick} onClick={onProjectClick}
tooltip={workspace.name} tooltip={workspace.name}
className={cn( className={cn(
"h-auto gap-[9px] rounded-[5px] px-1.5 py-[7px] text-[13px] font-medium text-muted-foreground transition-[padding]", "relative h-9 gap-[9px] rounded-[5px] px-1.5 py-0 text-[13px] font-medium text-muted-foreground transition-[background-color,padding,color]",
"hover:bg-interactive-hover hover:text-muted-foreground active:bg-interactive-hover active:text-muted-foreground", "before:absolute before:top-2 before:bottom-2 before:left-0 before:w-px before:rounded-full before:bg-transparent",
"data-[active=true]:bg-interactive-active data-[active=true]:font-semibold data-[active=true]:text-foreground", "hover:bg-interactive-hover hover:text-foreground active:bg-interactive-hover active:text-foreground",
"data-[active=true]:bg-interactive-active data-[active=true]:font-semibold data-[active=true]:text-foreground data-[active=true]:before:bg-accent",
// Make room for the hover actions (dashboard, orchestrator, kebab) // Make room for the hover actions (dashboard, orchestrator, kebab)
// when the row is hovered, focused, or its menu is open (the // when the row is hovered, focused, or its menu is open (the
// absolutely-positioned cluster replaces the count). // absolutely-positioned cluster replaces the count).
@ -469,7 +472,7 @@ function ProjectItem({
/> />
<span className="hidden group-data-[collapsible=icon]:block">{workspace.name.charAt(0).toUpperCase()}</span> <span className="hidden group-data-[collapsible=icon]:block">{workspace.name.charAt(0).toUpperCase()}</span>
<span className="min-w-0 flex-1 truncate group-data-[collapsible=icon]:hidden">{workspace.name}</span> <span className="min-w-0 flex-1 truncate group-data-[collapsible=icon]:hidden">{workspace.name}</span>
<span className="shrink-0 font-mono text-[11px] text-passive group-hover/menu-item:opacity-0 group-focus-within/menu-item:opacity-0 group-has-data-[state=open]/menu-item:opacity-0 group-data-[collapsible=icon]:hidden"> <span className="grid h-4 min-w-4 shrink-0 place-items-center rounded bg-interactive-hover px-1 font-mono text-[10px] leading-none text-passive group-hover/menu-item:opacity-0 group-focus-within/menu-item:opacity-0 group-has-data-[state=open]/menu-item:opacity-0 group-data-[collapsible=icon]:hidden">
{sessions.length} {sessions.length}
</span> </span>
</SidebarMenuButton> </SidebarMenuButton>
@ -478,7 +481,7 @@ function ProjectItem({
open), replacing the session count, and stays hidden in the icon rail. */} open), replacing the session count, and stays hidden in the icon rail. */}
<div <div
className={cn( className={cn(
"absolute top-1/2 right-1 flex -translate-y-1/2 items-center gap-px", "absolute top-0 right-1 flex h-9 items-center gap-px",
"opacity-0 transition-opacity", "opacity-0 transition-opacity",
"group-hover/menu-item:opacity-100 group-focus-within/menu-item:opacity-100 group-has-data-[state=open]/menu-item:opacity-100", "group-hover/menu-item:opacity-100 group-focus-within/menu-item:opacity-100 group-has-data-[state=open]/menu-item:opacity-100",
"group-data-[collapsible=icon]:hidden", "group-data-[collapsible=icon]:hidden",
@ -541,40 +544,36 @@ function ProjectItem({
{removeError} {removeError}
</span> </span>
)} )}
{/* project-sidebar__sessions. Divergence from AO (user decision {/* project-sidebar__sessions: indented under the project parent with a
2026-06-12): no left indent or tree guide line every sidebar row subtle guide line so worker sessions read as children, not peer
(project or worker) spans the same full width. */} navigation rows. */}
{expanded && sessions.length > 0 && ( {expanded && sessions.length > 0 && (
<SidebarMenuSub className="mx-0 translate-x-0 gap-0 border-0 px-0 pb-2 pt-0.5"> <SidebarMenuSub className="mx-0 ml-[18px] translate-x-0 gap-0 border-l border-border 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 (
<SidebarMenuSubItem key={session.id}> <SidebarMenuSubItem key={session.id}>
<SidebarMenuSubButton asChild isActive={active}> <button
<button aria-current={active ? "page" : undefined}
aria-current={active ? "page" : undefined} aria-label={`Open ${session.title}`}
aria-label={`Open ${session.title}`} className={cn(
className={cn( "relative flex h-auto w-full items-center gap-[9px] rounded-[4px] py-[5px] pl-2.5 pr-1.5 text-left outline-hidden transition-[color]",
"h-auto w-full translate-x-0 gap-[9px] rounded-[5px] py-[5px] pl-2 pr-1.5 text-left transition-colors", "before:absolute before:top-1.5 before:bottom-1.5 before:left-0 before:w-px before:rounded-full before:bg-transparent",
"hover:bg-interactive-hover data-[active=true]:bg-interactive-active", "hover:text-foreground focus-visible:ring-2 focus-visible:ring-sidebar-ring",
)} active && "text-foreground before:bg-accent",
onClick={() => selection.goSession(workspace.id, session.id)} )}
type="button" onClick={() => selection.goSession(workspace.id, session.id)}
> type="button"
<SessionDot session={session} /> >
<span className="min-w-0 flex-1"> <SessionDot session={session} />
<span <span className="min-w-0 flex-1">
className={cn( <span
"block truncate text-[12px]", className={cn("block truncate text-[12px]", active ? "text-foreground" : "text-muted-foreground")}
active ? "text-foreground" : "text-muted-foreground", >
)} {session.title}
>
{session.title}
</span>
<span className="block truncate font-mono text-[10px] text-passive">{session.id}</span>
</span> </span>
</button> </span>
</SidebarMenuSubButton> </button>
</SidebarMenuSubItem> </SidebarMenuSubItem>
); );
})} })}

View File

@ -1,8 +1,11 @@
import { useQueryClient } from "@tanstack/react-query";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import type { TerminalTarget } from "../types/terminal"; import type { TerminalTarget } from "../types/terminal";
import type { WorkspaceSession } from "../types/workspace"; import type { WorkspaceSession } from "../types/workspace";
import type { Theme } from "../stores/ui-store"; import type { Theme } from "../stores/ui-store";
import { useTerminalSession, type AttachableTerminal, type TerminalSessionState } from "../hooks/useTerminalSession"; import { useTerminalSession, type AttachableTerminal, type TerminalSessionState } from "../hooks/useTerminalSession";
import { apiClient, apiErrorMessage } from "../lib/api-client";
import { workspaceQueryKey } from "../hooks/useWorkspaceQuery";
import { XtermTerminal } from "./XtermTerminal"; import { XtermTerminal } from "./XtermTerminal";
type TerminalPaneProps = { type TerminalPaneProps = {
@ -49,15 +52,35 @@ function AttachedTerminal({ session, theme, daemonReady, terminalTarget }: Termi
// renderer mid-switch and lose the warm GPU surface. // renderer mid-switch and lose the warm GPU surface.
const [terminal, setTerminal] = useState<AttachableTerminal | null>(null); const [terminal, setTerminal] = useState<AttachableTerminal | null>(null);
const [initFailed, setInitFailed] = useState(false); const [initFailed, setInitFailed] = useState(false);
const [isRestoring, setIsRestoring] = useState(false);
const [restoreError, setRestoreError] = useState<string | undefined>();
const queryClient = useQueryClient();
const { attach, state, error } = useTerminalSession(attachSession, { daemonReady }); const { attach, state, error } = useTerminalSession(attachSession, { daemonReady });
const handleId = attachSession?.terminalHandleId; const handleId = attachSession?.terminalHandleId;
const hadAttachmentRef = useRef(false); const hadAttachmentRef = useRef(false);
const canRestoreSession = terminalTarget?.kind !== "reviewer" && session?.status === "terminated";
const handleReady = useCallback((handle: AttachableTerminal) => setTerminal(handle), []); const handleReady = useCallback((handle: AttachableTerminal) => setTerminal(handle), []);
const handleInitError = useCallback((err: unknown) => { const handleInitError = useCallback((err: unknown) => {
console.error("xterm failed to initialize", err); console.error("xterm failed to initialize", err);
setInitFailed(true); setInitFailed(true);
}, []); }, []);
const restoreSession = useCallback(async () => {
if (!session?.id || !canRestoreSession || isRestoring) return;
setIsRestoring(true);
setRestoreError(undefined);
try {
const { error: restoreError } = await apiClient.POST("/api/v1/sessions/{sessionId}/restore", {
params: { path: { sessionId: session.id } },
});
if (restoreError) throw new Error(apiErrorMessage(restoreError, "Unable to restore session"));
await queryClient.invalidateQueries({ queryKey: workspaceQueryKey });
} catch (err) {
setRestoreError(err instanceof Error ? err.message : "Unable to restore session");
} finally {
setIsRestoring(false);
}
}, [canRestoreSession, isRestoring, queryClient, session?.id]);
useEffect(() => { useEffect(() => {
if (!terminal) return; if (!terminal) return;
@ -86,25 +109,77 @@ function AttachedTerminal({ session, theme, daemonReady, terminalTarget }: Termi
const banner = bannerText(state, error); const banner = bannerText(state, error);
const showEmptyState = !handleId; const showEmptyState = !handleId;
const showExitedState = state === "exited";
return ( return (
<div className="relative h-full min-h-0 bg-terminal"> <div className="flex h-full min-h-0 flex-col bg-terminal">
<XtermTerminal ariaLabel="Session terminal" onError={handleInitError} onReady={handleReady} theme={theme} /> {showExitedState && (
{showEmptyState && ( <TerminalEndedStrip
<div className="absolute inset-0 grid place-items-center bg-terminal font-mono text-[13px]"> canRestore={canRestoreSession}
<div className="text-center"> error={restoreError}
<div className="text-[var(--term-fg)]">Agent Orchestrator</div> isRestoring={isRestoring}
<div className="mt-2 text-[var(--term-dim)]"> onRestore={restoreSession}
No session selected. Pick a worker to attach its terminal. variant={terminalTarget?.kind === "reviewer" ? "reviewer" : "session"}
/>
)}
<div className="relative min-h-0 flex-1">
<XtermTerminal ariaLabel="Session terminal" onError={handleInitError} onReady={handleReady} theme={theme} />
{showEmptyState && (
<div className="absolute inset-0 grid place-items-center bg-terminal font-mono text-[13px]">
<div className="text-center">
<div className="text-[var(--term-fg)]">Agent Orchestrator</div>
<div className="mt-2 text-[var(--term-dim)]">
No session selected. Pick a worker to attach its terminal.
</div>
</div> </div>
</div> </div>
</div> )}
)} {banner && (
{banner && ( <div className="absolute inset-x-3 top-2 rounded-md border border-border bg-surface/95 px-3 py-1.5 font-mono text-[11px] text-muted-foreground">
<div className="absolute inset-x-3 top-2 rounded-md border border-border bg-surface/95 px-3 py-1.5 font-mono text-[11px] text-muted-foreground"> {banner}
{banner} </div>
</div> )}
)} </div>
</div>
);
}
type TerminalEndedStripProps = {
canRestore: boolean;
error?: string;
isRestoring: boolean;
onRestore: () => void;
variant: "reviewer" | "session";
};
function TerminalEndedStrip({ canRestore, error, isRestoring, onRestore, variant }: TerminalEndedStripProps) {
const message = canRestore
? "Restore the session to attach a live terminal and continue writing."
: variant === "reviewer"
? "This reviewer terminal has ended. Re-run review from the summary panel, or switch back to the agent terminal."
: "This terminal process ended, but the session is not marked terminated yet.";
return (
<div className="shrink-0 border-b border-border bg-surface/80 px-4 py-2">
<div className="flex min-h-9 items-center gap-3">
<div className="min-w-0 flex-1">
<div className="font-mono text-[11px] font-medium uppercase tracking-[0.08em] text-muted-foreground">
Terminal ended
</div>
<div className="mt-0.5 truncate text-[12px] text-muted-foreground">{message}</div>
</div>
{error && <div className="max-w-[320px] truncate text-[12px] text-destructive">{error}</div>}
{canRestore && (
<button
type="button"
className="h-8 shrink-0 rounded-md border border-border bg-raised px-3 text-[12px] font-medium text-foreground transition hover:bg-interactive-hover disabled:cursor-not-allowed disabled:opacity-50"
disabled={isRestoring}
onClick={onRestore}
>
{isRestoring ? "Restoring..." : "Restore session"}
</button>
)}
</div>
</div> </div>
); );
} }

View File

@ -1,5 +1,8 @@
import type { WorkspaceSummary } from "../types/workspace"; import type { WorkspaceSummary } from "../types/workspace";
const now = new Date().toISOString();
const hoursAgo = (hours: number) => new Date(Date.now() - hours * 60 * 60 * 1000).toISOString();
export const mockWorkspaces: WorkspaceSummary[] = [ export const mockWorkspaces: WorkspaceSummary[] = [
{ {
id: "api-gateway", id: "api-gateway",
@ -11,12 +14,12 @@ export const mockWorkspaces: WorkspaceSummary[] = [
terminalHandleId: "refactor-mux/terminal_0", terminalHandleId: "refactor-mux/terminal_0",
workspaceId: "api-gateway", workspaceId: "api-gateway",
workspaceName: "api-gateway", workspaceName: "api-gateway",
title: "refactor-mux", title: "Split terminal mux responsibilities",
provider: "claude-code", provider: "claude-code",
branch: "feat/refactor-mux", branch: "feat/refactor-mux",
status: "working", status: "working",
updatedAt: new Date().toISOString(), updatedAt: now,
createdAt: new Date(Date.now() - 4 * 60 * 60 * 1000).toISOString(), createdAt: hoursAgo(4),
changedFiles: [ changedFiles: [
{ {
path: "internal/mux/terminal_mux.go", path: "internal/mux/terminal_mux.go",
@ -26,6 +29,30 @@ export const mockWorkspaces: WorkspaceSummary[] = [
], ],
commitMessage: "refactor terminal mux", commitMessage: "refactor terminal mux",
}, },
{
id: "fix-auth-timeouts",
workspaceId: "api-gateway",
workspaceName: "api-gateway",
title: "fix auth timeout retry loop",
provider: "codex",
branch: "fix/auth-timeouts",
status: "ci_failed",
updatedAt: hoursAgo(1),
createdAt: hoursAgo(6),
pullRequest: { number: 184, state: "open" },
},
{
id: "rate-limit-headers",
workspaceId: "api-gateway",
workspaceName: "api-gateway",
title: "add rate limit headers",
provider: "opencode",
branch: "feat/rate-limit-headers",
status: "review_pending",
updatedAt: hoursAgo(2),
createdAt: hoursAgo(9),
pullRequest: { number: 185, state: "open" },
},
], ],
}, },
{ {
@ -37,12 +64,159 @@ export const mockWorkspaces: WorkspaceSummary[] = [
id: "fix-webgl-fallback", id: "fix-webgl-fallback",
workspaceId: "webgl-preview", workspaceId: "webgl-preview",
workspaceName: "webgl-preview", workspaceName: "webgl-preview",
title: "fix-webgl-fallback", title: "Restore fallback renderer after WebGL init fails",
provider: "codex", provider: "codex",
branch: "fix/webgl-fallback", branch: "fix/webgl-fallback",
status: "needs_input", status: "needs_input",
updatedAt: new Date().toISOString(), updatedAt: now,
createdAt: new Date(Date.now() - 4 * 60 * 60 * 1000).toISOString(), createdAt: hoursAgo(4),
},
{
id: "shader-cache",
workspaceId: "webgl-preview",
workspaceName: "webgl-preview",
title: "cache compiled shader programs",
provider: "claude-code",
branch: "feat/shader-cache",
status: "working",
updatedAt: hoursAgo(0.5),
createdAt: hoursAgo(2),
changedFiles: [
{ path: "src/render/shader-cache.ts", additions: 86, deletions: 12 },
{ path: "src/render/webgl-context.ts", additions: 24, deletions: 5 },
],
},
{
id: "texture-leak",
workspaceId: "webgl-preview",
workspaceName: "webgl-preview",
title: "stop texture leak on scene reload",
provider: "codex",
branch: "fix/texture-leak",
status: "ci_failed",
updatedAt: hoursAgo(1.5),
createdAt: hoursAgo(7),
pullRequest: { number: 51, state: "open" },
},
{
id: "review-camera-pan",
workspaceId: "webgl-preview",
workspaceName: "webgl-preview",
title: "smooth camera pan controls",
provider: "aider",
branch: "feat/camera-pan",
status: "review_pending",
updatedAt: hoursAgo(3),
createdAt: hoursAgo(10),
pullRequest: { number: 52, state: "open" },
},
{
id: "draft-webgpu-probe",
workspaceId: "webgl-preview",
workspaceName: "webgl-preview",
title: "probe WebGPU support before init",
provider: "opencode",
branch: "feat/webgpu-probe",
status: "draft",
updatedAt: hoursAgo(5),
createdAt: hoursAgo(12),
pullRequest: { number: 53, state: "draft" },
},
{
id: "merge-frame-stats",
workspaceId: "webgl-preview",
workspaceName: "webgl-preview",
title: "ship frame statistics overlay",
provider: "codex",
branch: "feat/frame-stats",
status: "mergeable",
updatedAt: hoursAgo(0.25),
createdAt: hoursAgo(14),
pullRequest: { number: 54, state: "open" },
},
{
id: "approved-pixel-ratio",
workspaceId: "webgl-preview",
workspaceName: "webgl-preview",
title: "respect device pixel ratio",
provider: "claude-code",
branch: "fix/device-pixel-ratio",
status: "approved",
updatedAt: hoursAgo(2.5),
createdAt: hoursAgo(16),
pullRequest: { number: 55, state: "open" },
},
{
id: "input-pointer-lock",
workspaceId: "webgl-preview",
workspaceName: "webgl-preview",
title: "pointer lock escape handling",
provider: "codex",
branch: "fix/pointer-lock",
status: "changes_requested",
updatedAt: hoursAgo(4),
createdAt: hoursAgo(18),
pullRequest: { number: 56, state: "open" },
},
],
},
{
id: "mobile-shell",
name: "mobile-shell",
path: "/Users/me/mobile-shell",
sessions: [
{
id: "nav-gesture",
workspaceId: "mobile-shell",
workspaceName: "mobile-shell",
title: "repair back swipe gesture",
provider: "codex",
branch: "fix/back-swipe",
status: "working",
updatedAt: hoursAgo(0.75),
createdAt: hoursAgo(3),
},
{
id: "profile-sheet",
workspaceId: "mobile-shell",
workspaceName: "mobile-shell",
title: "profile sheet accessibility pass",
provider: "claude-code",
branch: "fix/profile-sheet-a11y",
status: "mergeable",
updatedAt: hoursAgo(1.25),
createdAt: hoursAgo(8),
pullRequest: { number: 92, state: "open" },
},
],
},
{
id: "billing-portal",
name: "billing-portal",
path: "/Users/me/billing-portal",
sessions: [
{
id: "invoice-export",
workspaceId: "billing-portal",
workspaceName: "billing-portal",
title: "invoice CSV export",
provider: "opencode",
branch: "feat/invoice-export",
status: "review_pending",
updatedAt: hoursAgo(2.25),
createdAt: hoursAgo(11),
pullRequest: { number: 117, state: "open" },
},
{
id: "tax-id-validation",
workspaceId: "billing-portal",
workspaceName: "billing-portal",
title: "tax id validation errors",
provider: "codex",
branch: "fix/tax-id-validation",
status: "needs_input",
updatedAt: hoursAgo(1.75),
createdAt: hoursAgo(5),
}, },
], ],
}, },

View File

@ -442,18 +442,19 @@ body.is-resizing-x [data-slot="sidebar-container"] {
align-items: center; align-items: center;
gap: 6px; gap: 6px;
border-radius: 7px; border-radius: 7px;
border: 1px solid color-mix(in srgb, var(--accent) 30%, transparent); border: 1px solid var(--border);
padding: 0 15px; padding: 0 15px;
font-size: 13px; font-size: 13px;
font-weight: 600; font-weight: 600;
line-height: 1; line-height: 1;
background: var(--accent-weak); background: var(--raised);
color: var(--accent); color: var(--fg-muted);
transition: background 0.12s ease; transition: background 0.12s ease;
} }
.dashboard-app-header__accent-btn:hover { .dashboard-app-header__accent-btn:hover {
background: color-mix(in srgb, var(--accent-weak) 80%, transparent); background: var(--surface);
color: var(--fg);
} }
.session-topbar__lead { .session-topbar__lead {