fix: simplify orchestrator session header (#1981)

* fix: simplify orchestrator session header

* fix: avoid duplicate orchestrator project label

* fix: restore orchestrator project identity

* fix: add orchestrator glyph to role chip

* fix: hide orchestrator session id in header

* fix: align orchestrator header chips

---------

Co-authored-by: i-trytoohard <193449657+i-trytoohard@users.noreply.github.com>
This commit is contained in:
i-trytoohard 2026-05-22 05:02:50 +05:30 committed by GitHub
parent 00fb9f9a59
commit 9d9eab4093
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 210 additions and 219 deletions

View File

@ -1456,6 +1456,17 @@ describe("spawn", () => {
expect(meta?.["displayName"]).toBe("Add rate limiting to /api/upload");
});
it("strips a markdown heading marker from prompt-derived displayName", async () => {
const sm = createSessionManager({ config, registry: mockRegistry });
await sm.spawn({
projectId: "my-app",
prompt: "### Add rate limiting to /api/upload\n\nUse a sliding-window counter.",
});
const meta = readMetadataRaw(sessionsDir, "app-1");
expect(meta?.["displayName"]).toBe("Add rate limiting to /api/upload");
});
it("truncates long displayName values with an ellipsis", async () => {
const longPrompt =
"Implement a comprehensive rate-limiter that supports sliding windows, token buckets, and per-route overrides with distributed counters";

View File

@ -272,7 +272,7 @@ function deriveDisplayName(input: { issueTitle?: string; prompt?: string }): str
}
if (input.prompt && input.prompt.trim()) {
const line = pickLine(input.prompt);
const line = pickLine(input.prompt).replace(/^#{1,6}\s+/, "");
if (line) return truncate(line);
}

View File

@ -1206,6 +1206,7 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active {
.dashboard-app-header__project {
font-size: 12px;
font-weight: 400;
line-height: 1;
color: var(--color-text-secondary);
}
@ -1346,6 +1347,7 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active {
border-radius: 999px;
font-size: 11px;
font-weight: 500;
line-height: 1;
white-space: nowrap;
color: var(--color-text-secondary);
}
@ -1372,6 +1374,23 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active {
height: 6px;
border-radius: 50%;
flex-shrink: 0;
background: var(--color-text-muted);
}
.topbar-status-pill--active .topbar-status-pill__dot {
background: var(--color-status-working);
}
.topbar-status-pill--ready .topbar-status-pill__dot {
background: var(--color-status-ready);
}
.topbar-status-pill--idle .topbar-status-pill__dot {
background: var(--color-text-tertiary);
}
.topbar-status-pill--waiting-for-input .topbar-status-pill__dot {
background: var(--color-status-attention);
}
.topbar-status-pill--blocked .topbar-status-pill__dot,
.topbar-status-pill--exited .topbar-status-pill__dot {
background: var(--color-status-error);
}
.topbar-status-pill__label {
color: inherit;
@ -2657,15 +2676,23 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active {
.session-detail-mode-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border: 1px solid var(--color-accent-amber-border);
background: var(--color-accent-amber-dim);
color: var(--color-accent-amber);
font-size: 10px;
font-weight: 600;
line-height: 1;
letter-spacing: 0.05em;
}
.session-detail-mode-badge--neutral {
border-color: var(--color-border-default);
background: var(--color-bg-subtle);
color: var(--color-text-secondary);
}
.session-detail-link-pill {
display: inline-flex;
align-items: center;
@ -8600,11 +8627,17 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active {
so this inner .topbar-project-line only exists to group them on mobile. */
.topbar-project-line {
display: inline-flex;
align-items: baseline;
align-items: center;
gap: 6px;
min-width: 0;
}
.topbar-identity-sep {
color: var(--color-text-tertiary);
font-size: 12px;
line-height: 1;
}
/* topbar-mobile-only is the inverse of topbar-desktop-only; hidden on
desktop, shown below the mobile breakpoint. */
.topbar-mobile-only {
@ -8673,6 +8706,14 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active {
padding: 1px 5px;
gap: 3px;
}
.topbar-session-pills .topbar-fleet-pills {
gap: 3px;
padding-left: 6px;
margin-left: 3px;
}
.topbar-session-pills .topbar-fleet-pills__label {
font-size: 9px;
}
/* Hide zone pill labels on mobile — count + color is enough */
.topbar-session-pills .topbar-zone-pill__label {
display: none;
@ -8681,6 +8722,21 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active {
/* Orchestrator agent-zone pills compact topbar variant. Replaces the
stacked status strip that used to sit above the terminal. */
.topbar-fleet-pills {
display: inline-flex;
align-items: center;
gap: 4px;
margin-left: 8px;
padding-left: 10px;
border-left: 1px solid var(--color-border-default);
}
.topbar-fleet-pills__label {
font-size: 11px;
font-weight: 600;
line-height: 1;
color: var(--color-text-muted);
white-space: nowrap;
}
.topbar-zone-pill {
display: inline-flex;
align-items: center;
@ -8689,6 +8745,7 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active {
border-radius: 999px;
font-size: 11px;
font-weight: 500;
line-height: 1;
white-space: nowrap;
color: var(--color-text-secondary);
}

View File

@ -54,7 +54,6 @@ export function SessionDetail({
const sidebarCtx = useSidebarContext();
const startFullscreen = searchParams.get("fullscreen") === "true";
const [showTerminal, setShowTerminal] = useState(false);
const [relaunchError, setRelaunchError] = useState<string | null>(null);
const pr = session.pr;
const terminalEnded = isDashboardSessionTerminal(session);
const isRestorable = isDashboardSessionRestorable(session);
@ -108,47 +107,6 @@ export function SessionDetail({
}
}, [session.id]);
const handleRelaunchClean = useCallback(async () => {
const confirmed = window.confirm(
"This will discard the current orchestrator's conversation and state. Continue?",
);
if (!confirmed) return;
setRelaunchError(null);
try {
const res = await fetch("/api/orchestrators", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ projectId: session.projectId, clean: true }),
});
if (!res.ok) {
// Surface server-side errors. Note: a failure here may indicate the
// existing orchestrator was killed but respawn failed — the banner
// tells the user the previous session was terminated so they don't
// assume the page they're looking at is still live.
let message = "";
try {
const data = (await res.json()) as { error?: string };
message = data.error ?? "";
} catch {
message = await res.text().catch(() => "");
}
throw new Error(message || `HTTP ${res.status}`);
}
// Hard-navigate to the freshly spawned orchestrator's session page.
// Orchestrator session IDs are fixed per project, so this is the same
// path in practice — but reading from the response keeps us correct if
// the contract ever changes (and a hard nav forces the terminal
// WebSocket to reconnect against the new tmux session).
const data = (await res.json()) as { orchestrator?: { id: string } };
const newId = data.orchestrator?.id ?? session.id;
window.location.href = projectSessionPath(session.projectId, newId);
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to relaunch orchestrator";
console.error("Failed to relaunch orchestrator:", err);
setRelaunchError(message);
}
}, [session.id, session.projectId]);
const orchestratorHref = useMemo(() => {
if (isOrchestrator) return null;
if (projectOrchestratorId) return projectSessionPath(session.projectId, projectOrchestratorId);
@ -179,34 +137,8 @@ export function SessionDetail({
onToggleSidebar={sidebarCtx?.onToggleSidebar ?? (() => {})}
onRestore={handleRestore}
onKill={handleKill}
onRelaunchClean={isOrchestrator ? handleRelaunchClean : undefined}
/>
<main className="session-detail-page flex-1 min-h-0 flex flex-col bg-[var(--color-bg-base)]">
{relaunchError ? (
<div
className="border-b border-[color-mix(in_srgb,var(--color-status-error)_28%,transparent)] bg-[color-mix(in_srgb,var(--color-status-error)_10%,transparent)] px-4 py-2 text-[12px] text-[var(--color-status-error)]"
role="alert"
aria-live="assertive"
>
<div className="flex items-start justify-between gap-3">
<div>
<span className="font-semibold">Relaunch failed:</span> {relaunchError}
<div className="mt-1 text-[var(--color-text-secondary)]">
The previous orchestrator may already be terminated. Try again from the
project dashboard.
</div>
</div>
<button
type="button"
onClick={() => setRelaunchError(null)}
className="text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)]"
aria-label="Dismiss"
>
×
</button>
</div>
</div>
) : null}
<div className="flex-1 min-h-0 flex flex-col">
{!showTerminal ? (
<div className="session-detail-terminal-placeholder h-full" />

View File

@ -9,6 +9,7 @@ import { DashboardNotificationButton } from "./DashboardNotificationButton";
import { SessionDetailPRCard } from "./SessionDetailPRCard";
import { askAgentToFix } from "./session-detail-agent-actions";
import { buildGitHubBranchUrl } from "./session-detail-utils";
import { projectDashboardPath } from "@/lib/routes";
export interface OrchestratorZones {
merge: number;
@ -33,7 +34,6 @@ interface SessionDetailHeaderProps {
onToggleSidebar: () => void;
onRestore: () => void;
onKill: () => void;
onRelaunchClean?: () => void;
}
function normalizeActivityLabelForClass(activityLabel: string): string {
@ -53,14 +53,15 @@ function OrchestratorZonePills({ zones }: { zones: OrchestratorZones }) {
if (stats.length === 0) return null;
return (
<>
<span className="topbar-fleet-pills" aria-label="Fleet session counts">
<span className="topbar-fleet-pills__label">Fleet</span>
{stats.map((s) => (
<span key={s.label} className={cn("topbar-zone-pill", s.toneClass)}>
<span className="topbar-zone-pill__value">{s.value}</span>
<span className="topbar-zone-pill__label">{s.label}</span>
</span>
))}
</>
</span>
);
}
@ -78,7 +79,6 @@ export function SessionDetailHeader({
onToggleSidebar,
onRestore,
onKill,
onRelaunchClean,
}: SessionDetailHeaderProps) {
const pr = session.pr;
const allGreen = pr ? isPRMergeReady(pr) : false;
@ -106,6 +106,10 @@ export function SessionDetailHeader({
const headerProjectLabel =
projects.find((project) => project.id === session.projectId)?.name ?? session.projectId;
const showHeaderProjectLabel = headerProjectLabel.trim().toLowerCase() !== "agent orchestrator";
const showProductBrand = !isOrchestrator;
const showProjectLabel = isOrchestrator || showHeaderProjectLabel;
const showDesktopTitle = !isOrchestrator;
const showDesktopHeaderSep = showProductBrand && showProjectLabel;
return (
<header className="dashboard-app-header">
@ -144,19 +148,49 @@ export function SessionDetailHeader({
)}
</button>
) : null}
<div className="dashboard-app-header__brand dashboard-app-header__brand--hide-mobile">
<span>Agent Orchestrator</span>
</div>
{showHeaderProjectLabel && (
{showProductBrand ? (
<div className="dashboard-app-header__brand dashboard-app-header__brand--hide-mobile">
<span>Agent Orchestrator</span>
</div>
) : null}
{showDesktopHeaderSep && (
<span className="dashboard-app-header__sep topbar-desktop-only" aria-hidden="true" />
)}
<div className="topbar-project-pills-group">
<div className="topbar-project-line">
{showHeaderProjectLabel && (
{showProjectLabel && (
<span className="dashboard-app-header__project">{headerProjectLabel}</span>
)}
<span className="dashboard-app-header__session-id topbar-mobile-only">{session.id}</span>
{isOrchestrator && <span className="session-detail-mode-badge">orchestrator</span>}
{showProjectLabel && isOrchestrator ? (
<span className="topbar-identity-sep" aria-hidden="true">
·
</span>
) : null}
{isOrchestrator ? (
<span className="session-detail-mode-badge session-detail-mode-badge--neutral">
<svg
width="12"
height="12"
fill="none"
stroke="currentColor"
strokeWidth="2"
viewBox="0 0 24 24"
aria-hidden="true"
>
<circle cx="12" cy="5" r="2" fill="currentColor" stroke="none" />
<path d="M12 7v4M12 11H6M12 11h6M6 11v3M12 11v3M18 11v3" />
<circle cx="6" cy="17" r="2" />
<circle cx="12" cy="17" r="2" />
<circle cx="18" cy="17" r="2" />
</svg>
Orchestrator
</span>
) : null}
{!isOrchestrator ? (
<span className="dashboard-app-header__session-id topbar-mobile-only">
{session.id}
</span>
) : null}
</div>
<div className="topbar-session-pills">
<div
@ -165,10 +199,10 @@ export function SessionDetailHeader({
`topbar-status-pill--${normalizeActivityLabelForClass(activity.label)}`,
)}
>
<span className="topbar-status-pill__dot" style={{ background: activity.color }} />
<span className="topbar-status-pill__dot" />
<span className="topbar-status-pill__label">{activity.label}</span>
</div>
{session.branch ? (
{!isOrchestrator && session.branch ? (
pr ? (
<a
href={buildGitHubBranchUrl(pr)}
@ -187,13 +221,21 @@ export function SessionDetailHeader({
) : null}
</div>
</div>
<span className="dashboard-app-header__sep topbar-desktop-only" aria-hidden="true" />
<span className="dashboard-app-header__session-title topbar-desktop-only">{headline}</span>
<span className="dashboard-app-header__session-id topbar-desktop-only">{session.id}</span>
{showDesktopTitle ? (
<>
<span className="dashboard-app-header__sep topbar-desktop-only" aria-hidden="true" />
<span className="dashboard-app-header__session-title topbar-desktop-only">
{headline}
</span>
</>
) : null}
{!isOrchestrator ? (
<span className="dashboard-app-header__session-id topbar-desktop-only">{session.id}</span>
) : null}
<div className="dashboard-app-header__spacer" />
<div className="dashboard-app-header__actions">
<DashboardNotificationButton />
{pr ? (
{!isOrchestrator && pr ? (
<div className="topbar-pr-btn-wrap" ref={prPopoverRef}>
<a
href={pr.url}
@ -250,7 +292,7 @@ export function SessionDetailHeader({
</div>
) : null}
{isRestorable ? (
{!isOrchestrator && isRestorable ? (
<button
type="button"
className="dashboard-app-btn dashboard-app-btn--restore"
@ -289,30 +331,6 @@ export function SessionDetailHeader({
</button>
) : null}
{isOrchestrator && onRelaunchClean ? (
<button
type="button"
className="dashboard-app-btn dashboard-app-btn--amber"
onClick={onRelaunchClean}
aria-label="Launch Orchestrator (clean context)"
>
<svg
className="h-3.5 w-3.5"
fill="none"
stroke="currentColor"
strokeWidth="2"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path d="M20 11a8 8 0 0 0-14.9-3.98" />
<path d="M4 5v4h4" />
<path d="M4 13a8 8 0 0 0 14.9 3.98" />
<path d="M20 19v-4h-4" />
</svg>
<span className="topbar-btn-label">Relaunch (clean)</span>
</button>
) : null}
{orchestratorHref ? (
<a
href={orchestratorHref}
@ -337,6 +355,27 @@ export function SessionDetailHeader({
<span className="topbar-btn-label">Orchestrator</span>
</a>
) : null}
{isOrchestrator ? (
<a
href={projectDashboardPath(session.projectId)}
className="dashboard-app-btn dashboard-app-btn--amber"
aria-label="Open Kanban"
>
<svg
className="topbar-action-icon"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
viewBox="0 0 24 24"
aria-hidden="true"
>
<rect x="4" y="4" width="4" height="16" rx="1.2" />
<rect x="10" y="4" width="4" height="16" rx="1.2" />
<rect x="16" y="4" width="4" height="16" rx="1.2" />
</svg>
<span className="topbar-btn-label">Open Kanban</span>
</a>
) : null}
</div>
</header>
);

View File

@ -351,7 +351,7 @@ describe("SessionDetail desktop layout", () => {
).toBeInTheDocument();
});
it("shows restore for restorable orchestrator sessions", () => {
it("keeps restore in the ended summary but not the top bar for restorable orchestrators", () => {
render(
<SessionDetail
session={makeSession({
@ -376,39 +376,28 @@ describe("SessionDetail desktop layout", () => {
/>,
);
expect(within(screen.getByRole("banner")).getByRole("button", { name: "Restore" })).toHaveClass(
"dashboard-app-btn--restore",
);
expect(
within(screen.getByRole("banner")).queryByRole("button", { name: "Restore" }),
).not.toBeInTheDocument();
expect(
within(screen.getByRole("region", { name: "Session ended summary" })).getByRole("button", {
name: "Restore session",
}),
).toBeInTheDocument();
expect(
within(screen.getByRole("banner")).getByRole("link", { name: "Open Kanban" }),
).toHaveAttribute("href", "/projects/my-app");
expect(
within(screen.getByRole("banner")).queryByRole("button", {
name: /launch orchestrator \(clean context\)/i,
}),
).not.toBeInTheDocument();
expect(
within(screen.getByRole("banner")).queryByRole("button", { name: "Kill" }),
).not.toBeInTheDocument();
});
it("renders Relaunch (clean) on live orchestrator sessions and navigates to the new session", async () => {
const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(true);
const hrefSetter = vi.fn();
Object.defineProperty(window, "location", {
value: {
...window.location,
set href(value: string) {
hrefSetter(value);
},
},
writable: true,
});
vi.mocked(global.fetch).mockImplementation(async (input: RequestInfo | URL) => {
const url = typeof input === "string" ? input : input.toString();
if (url === "/api/orchestrators") {
return {
ok: true,
json: async () => ({
orchestrator: { id: "my-app-orchestrator", projectId: "my-app" },
}),
} as Response;
}
return { ok: true, json: async () => ({}), text: async () => "" } as Response;
});
it("renders a scoped, non-repetitive orchestrator top bar", () => {
render(
<SessionDetail
session={makeSession({
@ -416,110 +405,73 @@ describe("SessionDetail desktop layout", () => {
projectId: "my-app",
status: "working",
activity: "active",
branch: "orchestrator/my-app-orchestrator",
summary: "Project orchestrator",
displayName: "# My App Orchestrator",
pr: makePR({ number: 777 }),
})}
isOrchestrator
orchestratorZones={{
merge: 0,
respond: 0,
review: 0,
pending: 0,
working: 0,
done: 0,
review: 1,
pending: 2,
working: 1,
done: 4,
}}
projectOrchestratorId="my-app-orchestrator"
projects={[{ id: "my-app", name: "My App", path: "/tmp/my-app" }]}
/>,
);
const relaunchBtn = within(screen.getByRole("banner")).getByRole("button", {
name: /launch orchestrator \(clean context\)/i,
});
fireEvent.click(relaunchBtn);
const banner = within(screen.getByRole("banner"));
expect(confirmSpy).toHaveBeenCalled();
await act(async () => {});
expect(global.fetch).toHaveBeenCalledWith("/api/orchestrators", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ projectId: "my-app", clean: true }),
});
expect(hrefSetter).toHaveBeenCalledWith("/projects/my-app/sessions/my-app-orchestrator");
confirmSpy.mockRestore();
});
it("keeps Relaunch (clean) visible on terminated orchestrator sessions", () => {
render(
<SessionDetail
session={makeSession({
id: "my-app-orchestrator",
projectId: "my-app",
status: "terminated",
activity: "exited",
summary: "Project orchestrator",
pr: null,
})}
isOrchestrator
orchestratorZones={{ merge: 0, respond: 0, review: 0, pending: 0, working: 0, done: 0 }}
projectOrchestratorId="my-app-orchestrator"
projects={[{ id: "my-app", name: "My App", path: "/tmp/my-app" }]}
/>,
expect(banner.getByText("My App")).toBeInTheDocument();
expect(banner.getByText("Orchestrator")).toBeInTheDocument();
expect(banner.getByText("Active")).toBeInTheDocument();
expect(banner.getByText("Fleet")).toBeInTheDocument();
expect(banner.getByText("review")).toBeInTheDocument();
expect(banner.getByText("working")).toBeInTheDocument();
expect(banner.getByText("pending")).toBeInTheDocument();
expect(banner.getByText("done")).toBeInTheDocument();
expect(banner.queryByText("my-app-orchestrator")).not.toBeInTheDocument();
expect(banner.getByRole("link", { name: "Open Kanban" })).toHaveAttribute(
"href",
"/projects/my-app",
);
expect(
within(screen.getByRole("banner")).getByRole("button", {
name: /launch orchestrator \(clean context\)/i,
}),
).toBeInTheDocument();
expect(banner.queryByText("Agent Orchestrator")).not.toBeInTheDocument();
expect(banner.queryByText("# My App Orchestrator")).not.toBeInTheDocument();
expect(banner.queryByText("orchestrator/my-app-orchestrator")).not.toBeInTheDocument();
expect(banner.queryByRole("link", { name: "PR #777" })).not.toBeInTheDocument();
});
it("surfaces a relaunch error banner when POST fails after confirm", async () => {
const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(true);
vi.mocked(global.fetch).mockImplementation(async (input: RequestInfo | URL) => {
const url = typeof input === "string" ? input : input.toString();
if (url === "/api/orchestrators") {
return {
ok: false,
status: 500,
json: async () => ({ error: "kill+respawn failed" }),
text: async () => "kill+respawn failed",
} as Response;
}
return { ok: true, json: async () => ({}), text: async () => "" } as Response;
});
it("shows the project name for the Agent Orchestrator project header", () => {
render(
<SessionDetail
session={makeSession({
id: "my-app-orchestrator",
projectId: "my-app",
id: "ao-orchestrator",
projectId: "agent-orchestrator",
status: "working",
activity: "active",
activity: "ready",
summary: "Project orchestrator",
})}
isOrchestrator
orchestratorZones={{ merge: 0, respond: 0, review: 0, pending: 0, working: 0, done: 0 }}
projectOrchestratorId="my-app-orchestrator"
projects={[{ id: "my-app", name: "My App", path: "/tmp/my-app" }]}
orchestratorZones={{ merge: 0, respond: 0, review: 0, pending: 0, working: 1, done: 2 }}
projectOrchestratorId="ao-orchestrator"
projects={[
{ id: "agent-orchestrator", name: "Agent Orchestrator", path: "/tmp/agent-orchestrator" },
]}
/>,
);
fireEvent.click(
within(screen.getByRole("banner")).getByRole("button", {
name: /launch orchestrator \(clean context\)/i,
}),
const banner = within(screen.getByRole("banner"));
expect(banner.getByText("Agent Orchestrator")).toBeInTheDocument();
expect(banner.getByText("Orchestrator")).toHaveClass("session-detail-mode-badge--neutral");
expect(banner.getByRole("link", { name: "Open Kanban" })).toHaveAttribute(
"href",
"/projects/agent-orchestrator",
);
const alert = await screen.findByRole("alert");
expect(alert).toHaveTextContent(/kill\+respawn failed/i);
expect(alert).toHaveTextContent(/previous orchestrator may already be terminated/i);
fireEvent.click(within(alert).getByRole("button", { name: "Dismiss" }));
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
confirmSpy.mockRestore();
});
it("does not render Relaunch (clean) on worker sessions", () => {
@ -587,7 +539,7 @@ describe("SessionDetail desktop layout", () => {
expect(
within(screen.getByRole("banner")).queryByRole("link", { name: "Orchestrator" }),
).not.toBeInTheDocument();
expect(screen.getByText("orchestrator")).toBeInTheDocument();
expect(within(screen.getByRole("banner")).getByText("Orchestrator")).toBeInTheDocument();
});
it("shows the main orchestrator button when an orchestrator target exists", () => {