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":
return "Session idle";
case "needs_input":
return "Waiting for input";
return "Waiting for your input";
case "working":
return null;
default:
@ -537,7 +537,7 @@ const STATUS_PILL: Record<
{ label: string; tone: string; breathe: boolean }
> = {
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 },
mergeable: { label: "Ready", tone: "var(--green)", breathe: false },
done: { label: "Done", tone: "var(--fg-muted)", breathe: false },

View File

@ -1,13 +1,6 @@
import { useState } from "react";
import { useNavigate } from "@tanstack/react-router";
import {
type AttentionZone,
type WorkerDisplayStatus,
type WorkspaceSession,
attentionZone,
workerDisplayStatus,
workerSessions,
} from "../types/workspace";
import { type AttentionZone, type WorkspaceSession, attentionZone, workerSessions } from "../types/workspace";
import { useWorkspaceQuery } from "../hooks/useWorkspaceQuery";
import { DashboardSubhead } from "./DashboardSubhead";
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) {
const navigate = useNavigate();
const workspaceQuery = useWorkspaceQuery();
@ -195,8 +180,9 @@ function ZoneColumn({
}
function SessionCard({ session, onOpen }: { session: WorkspaceSession; onOpen: () => void }) {
const badge = BADGE[workerDisplayStatus(session)];
const branch = session.branch || `session/${session.id}`;
const badge = sessionBadge(session);
const branch = session.branch || "";
const showBranch = branch !== "" && !sameLabel(branch, session.title) && !sameLabel(branch, session.id);
return (
<button
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")} />
{badge.label}
</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
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",
)}
>
{session.title}
</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">
{session.pullRequest ? `PR #${session.pullRequest.number} · ${session.pullRequest.state}` : "no PR yet"}
</div>
</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 { 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 {
findProjectOrchestrator,
@ -16,6 +16,7 @@ import { spawnOrchestrator } from "../lib/spawn-orchestrator";
import { captureRendererEvent, captureRendererException } from "../lib/telemetry";
import { useUiStore } from "../stores/ui-store";
import { OrchestratorIcon } from "./icons";
import { NewTaskDialog } from "./NewTaskDialog";
import { cn } from "../lib/utils";
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 toggleInspector = useUiStore((state) => state.toggleInspector);
const [isSpawning, setIsSpawning] = useState(false);
const [isNewTaskOpen, setIsNewTaskOpen] = useState(false);
const all = useWorkspaceQuery().data ?? [];
const session = params.sessionId
@ -72,6 +74,20 @@ export function ShellTopbar() {
const openBoard = () =>
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 () => {
if (!projectId) return;
void captureRendererEvent("ao.renderer.orchestrator_open_requested", { project_id: projectId });
@ -135,16 +151,28 @@ export function ShellTopbar() {
{isSessionRoute ? (
<>
{isOrchestrator ? (
<button
aria-label="Open Kanban"
className="dashboard-app-header__primary-btn"
onClick={openBoard}
style={noDragStyle}
type="button"
>
<LayoutDashboard className="h-3.5 w-3.5" aria-hidden="true" />
Open Kanban
</button>
<>
<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>
<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
aria-label="Open orchestrator"
@ -181,37 +209,55 @@ export function ShellTopbar() {
)}
</>
) : projectId ? (
orchestrator ? (
<>
<button
aria-label="Orchestrator"
aria-label="New task"
className="dashboard-app-header__primary-btn"
onClick={() =>
void navigate({
to: "/projects/$projectId/sessions/$sessionId",
params: { projectId, sessionId: orchestrator.id },
})
}
onClick={openNewTask}
style={noDragStyle}
type="button"
>
<OrchestratorIcon className="h-3.5 w-3.5" aria-hidden="true" />
Orchestrator
<Plus className="h-3.5 w-3.5" aria-hidden="true" />
New task
</button>
) : (
<button
aria-label="Spawn Orchestrator"
className="dashboard-app-header__primary-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>
)
{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
open={isNewTaskOpen}
projectId={projectId}
onCreated={(sessionId) => void handleTaskCreated(sessionId)}
onOpenChange={setIsNewTaskOpen}
/>
</header>
);
}

View File

@ -46,7 +46,6 @@ import {
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarTrigger,
useSidebar,
@ -97,17 +96,20 @@ function useSelection() {
};
}
// agent-orchestrator's SessionDot: 6px dot, neutral grey at rest, orange +
// breathe while the agent is working. Other attention zones stay neutral here
// (the board carries the richer colour coding).
// 6px session dot: mirrors the board's status language so the sidebar can be
// scanned without opening the project board.
function SessionDot({ session }: { session: WorkspaceSession }) {
const working = attentionZone(session) === "working";
const zone = attentionZone(session);
return (
<span
aria-hidden="true"
className={cn(
"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 && (
<Tooltip>
<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>
<TooltipContent>Collapse sidebar · B</TooltipContent>
</Tooltip>
@ -448,9 +450,10 @@ function ProjectItem({
onClick={onProjectClick}
tooltip={workspace.name}
className={cn(
"h-auto gap-[9px] rounded-[5px] px-1.5 py-[7px] text-[13px] font-medium text-muted-foreground transition-[padding]",
"hover:bg-interactive-hover hover:text-muted-foreground active:bg-interactive-hover active:text-muted-foreground",
"data-[active=true]:bg-interactive-active data-[active=true]:font-semibold data-[active=true]:text-foreground",
"relative h-9 gap-[9px] rounded-[5px] px-1.5 py-0 text-[13px] font-medium text-muted-foreground transition-[background-color,padding,color]",
"before:absolute before:top-2 before:bottom-2 before:left-0 before:w-px before:rounded-full before:bg-transparent",
"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)
// when the row is hovered, focused, or its menu is open (the
// 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="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}
</span>
</SidebarMenuButton>
@ -478,7 +481,7 @@ function ProjectItem({
open), replacing the session count, and stays hidden in the icon rail. */}
<div
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",
"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",
@ -541,40 +544,36 @@ function ProjectItem({
{removeError}
</span>
)}
{/* project-sidebar__sessions. Divergence from AO (user decision
2026-06-12): no left indent or tree guide line every sidebar row
(project or worker) spans the same full width. */}
{/* project-sidebar__sessions: indented under the project parent with a
subtle guide line so worker sessions read as children, not peer
navigation rows. */}
{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) => {
const active = selection.activeSessionId === session.id;
return (
<SidebarMenuSubItem key={session.id}>
<SidebarMenuSubButton asChild isActive={active}>
<button
aria-current={active ? "page" : undefined}
aria-label={`Open ${session.title}`}
className={cn(
"h-auto w-full translate-x-0 gap-[9px] rounded-[5px] py-[5px] pl-2 pr-1.5 text-left transition-colors",
"hover:bg-interactive-hover data-[active=true]:bg-interactive-active",
)}
onClick={() => selection.goSession(workspace.id, session.id)}
type="button"
>
<SessionDot session={session} />
<span className="min-w-0 flex-1">
<span
className={cn(
"block truncate text-[12px]",
active ? "text-foreground" : "text-muted-foreground",
)}
>
{session.title}
</span>
<span className="block truncate font-mono text-[10px] text-passive">{session.id}</span>
<button
aria-current={active ? "page" : undefined}
aria-label={`Open ${session.title}`}
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]",
"before:absolute before:top-1.5 before:bottom-1.5 before:left-0 before:w-px before:rounded-full before:bg-transparent",
"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"
>
<SessionDot session={session} />
<span className="min-w-0 flex-1">
<span
className={cn("block truncate text-[12px]", active ? "text-foreground" : "text-muted-foreground")}
>
{session.title}
</span>
</button>
</SidebarMenuSubButton>
</span>
</button>
</SidebarMenuSubItem>
);
})}

View File

@ -1,8 +1,11 @@
import { useQueryClient } from "@tanstack/react-query";
import { useCallback, useEffect, useRef, useState } from "react";
import type { TerminalTarget } from "../types/terminal";
import type { WorkspaceSession } from "../types/workspace";
import type { Theme } from "../stores/ui-store";
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";
type TerminalPaneProps = {
@ -49,15 +52,35 @@ function AttachedTerminal({ session, theme, daemonReady, terminalTarget }: Termi
// renderer mid-switch and lose the warm GPU surface.
const [terminal, setTerminal] = useState<AttachableTerminal | null>(null);
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 handleId = attachSession?.terminalHandleId;
const hadAttachmentRef = useRef(false);
const canRestoreSession = terminalTarget?.kind !== "reviewer" && session?.status === "terminated";
const handleReady = useCallback((handle: AttachableTerminal) => setTerminal(handle), []);
const handleInitError = useCallback((err: unknown) => {
console.error("xterm failed to initialize", err);
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(() => {
if (!terminal) return;
@ -86,25 +109,77 @@ function AttachedTerminal({ session, theme, daemonReady, terminalTarget }: Termi
const banner = bannerText(state, error);
const showEmptyState = !handleId;
const showExitedState = state === "exited";
return (
<div className="relative h-full min-h-0 bg-terminal">
<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 className="flex h-full min-h-0 flex-col bg-terminal">
{showExitedState && (
<TerminalEndedStrip
canRestore={canRestoreSession}
error={restoreError}
isRestoring={isRestoring}
onRestore={restoreSession}
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>
)}
{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">
{banner}
</div>
)}
)}
{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">
{banner}
</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>
);
}

View File

@ -1,5 +1,8 @@
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[] = [
{
id: "api-gateway",
@ -11,12 +14,12 @@ export const mockWorkspaces: WorkspaceSummary[] = [
terminalHandleId: "refactor-mux/terminal_0",
workspaceId: "api-gateway",
workspaceName: "api-gateway",
title: "refactor-mux",
title: "Split terminal mux responsibilities",
provider: "claude-code",
branch: "feat/refactor-mux",
status: "working",
updatedAt: new Date().toISOString(),
createdAt: new Date(Date.now() - 4 * 60 * 60 * 1000).toISOString(),
updatedAt: now,
createdAt: hoursAgo(4),
changedFiles: [
{
path: "internal/mux/terminal_mux.go",
@ -26,6 +29,30 @@ export const mockWorkspaces: WorkspaceSummary[] = [
],
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",
workspaceId: "webgl-preview",
workspaceName: "webgl-preview",
title: "fix-webgl-fallback",
title: "Restore fallback renderer after WebGL init fails",
provider: "codex",
branch: "fix/webgl-fallback",
status: "needs_input",
updatedAt: new Date().toISOString(),
createdAt: new Date(Date.now() - 4 * 60 * 60 * 1000).toISOString(),
updatedAt: now,
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;
gap: 6px;
border-radius: 7px;
border: 1px solid color-mix(in srgb, var(--accent) 30%, transparent);
border: 1px solid var(--border);
padding: 0 15px;
font-size: 13px;
font-weight: 600;
line-height: 1;
background: var(--accent-weak);
color: var(--accent);
background: var(--raised);
color: var(--fg-muted);
transition: background 0.12s ease;
}
.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 {