diff --git a/packages/core/src/__tests__/session-manager/spawn.test.ts b/packages/core/src/__tests__/session-manager/spawn.test.ts index 395d88471..d06dbbe3a 100644 --- a/packages/core/src/__tests__/session-manager/spawn.test.ts +++ b/packages/core/src/__tests__/session-manager/spawn.test.ts @@ -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"; diff --git a/packages/core/src/session-manager.ts b/packages/core/src/session-manager.ts index 4235fb97f..86807c47b 100644 --- a/packages/core/src/session-manager.ts +++ b/packages/core/src/session-manager.ts @@ -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); } diff --git a/packages/web/src/app/globals.css b/packages/web/src/app/globals.css index 386d00941..1e0d69f91 100644 --- a/packages/web/src/app/globals.css +++ b/packages/web/src/app/globals.css @@ -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); } diff --git a/packages/web/src/components/SessionDetail.tsx b/packages/web/src/components/SessionDetail.tsx index a47797729..5ea560e43 100644 --- a/packages/web/src/components/SessionDetail.tsx +++ b/packages/web/src/components/SessionDetail.tsx @@ -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(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} />
- {relaunchError ? ( -
-
-
- Relaunch failed: {relaunchError} -
- The previous orchestrator may already be terminated. Try again from the - project dashboard. -
-
- -
-
- ) : null}
{!showTerminal ? (
diff --git a/packages/web/src/components/SessionDetailHeader.tsx b/packages/web/src/components/SessionDetailHeader.tsx index b264632d4..c9532003e 100644 --- a/packages/web/src/components/SessionDetailHeader.tsx +++ b/packages/web/src/components/SessionDetailHeader.tsx @@ -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 ( - <> + + Fleet {stats.map((s) => ( {s.value} {s.label} ))} - + ); } @@ -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 (
@@ -144,19 +148,49 @@ export function SessionDetailHeader({ )} ) : null} -
- Agent Orchestrator -
- {showHeaderProjectLabel && ( + {showProductBrand ? ( +
+ Agent Orchestrator +
+ ) : null} + {showDesktopHeaderSep && (
); diff --git a/packages/web/src/components/__tests__/SessionDetail.desktop.test.tsx b/packages/web/src/components/__tests__/SessionDetail.desktop.test.tsx index 7f310bb56..bffbbc54a 100644 --- a/packages/web/src/components/__tests__/SessionDetail.desktop.test.tsx +++ b/packages/web/src/components/__tests__/SessionDetail.desktop.test.tsx @@ -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( { />, ); - 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( { 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( - , + 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( , ); - 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", () => {