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:
parent
c53c4af8bd
commit
2cb20c23e4
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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" };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in New Issue