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:
parent
00fb9f9a59
commit
9d9eab4093
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
Loading…
Reference in New Issue